diff --git a/.gitignore b/.gitignore index 0dbff760e..735bf2599 100644 --- a/.gitignore +++ b/.gitignore @@ -93,3 +93,4 @@ data/ docker-compose.staging.yml .windsurf +apps/ideploy/public/hot diff --git a/apps/api/api/controllers/appgen.controller.ts b/apps/api/api/controllers/appgen.controller.ts new file mode 100644 index 000000000..05ad19cfe --- /dev/null +++ b/apps/api/api/controllers/appgen.controller.ts @@ -0,0 +1,113 @@ +import { Response } from 'express'; +import { CustomRequest } from '../interfaces/express.interface'; +import { v4 as uuidv4 } from 'uuid'; +import RedisConnection from '../config/redis.config'; +import logger from '../config/logger'; + +const HANDOFF_TTL_SECONDS = 15 * 60; // 15 minutes +const HANDOFF_KEY_PREFIX = 'appgen:handoff:'; + +/** + * POST /appgen/handoff + * Stores an AppGen generation payload in Redis and returns a handoffId. + * iDeploy reads it at GET /api/ideploy/handoff/:handoffId + */ +export const createHandoffController = async ( + req: CustomRequest, + res: Response +): Promise => { + const userId = req.user?.uid; + logger.info('AppGen handoff requested', { userId }); + + try { + const { draftId, appName, description, files, metadata, messages, generatedAt, source, target, expiresAt } = + req.body; + + if (!files || typeof files !== 'object') { + res.status(400).json({ success: false, message: 'files is required' }); + return; + } + + const handoffId = uuidv4(); + const handoffData = { + handoffId, + userId: userId || null, + draftId, + appName: appName || 'Mon Application', + description: description || '', + files, + metadata: metadata || {}, + messages: messages || [], + generatedAt: generatedAt || new Date().toISOString(), + createdAt: new Date().toISOString(), + source: source || 'appgen', + target: target || 'ideploy', + expiresAt: expiresAt || new Date(Date.now() + HANDOFF_TTL_SECONDS * 1000).toISOString(), + }; + + try { + const redis = RedisConnection.getInstance(); + await redis.set( + `${HANDOFF_KEY_PREFIX}${handoffId}`, + JSON.stringify(handoffData), + 'EX', + HANDOFF_TTL_SECONDS + ); + logger.info('Handoff stored in Redis', { handoffId, userId }); + } catch (redisError: any) { + logger.warn('Redis unavailable, handoff stored only in response', { + error: redisError.message, + }); + // Still return the handoffId — iDeploy will fall back to sessionStorage on client side + } + + res.status(201).json({ success: true, handoffId }); + } catch (error: any) { + logger.error('Error in createHandoffController:', { + userId, + message: error.message, + stack: error.stack, + }); + res.status(500).json({ + success: false, + message: 'Failed to create handoff', + error: error.message, + }); + } +}; + +/** + * GET /appgen/handoff/:handoffId + * Retrieves a handoff payload by ID (consumed by iDeploy). + */ +export const getHandoffController = async ( + req: CustomRequest, + res: Response +): Promise => { + const { handoffId } = req.params; + logger.info('AppGen handoff fetch', { handoffId }); + + try { + const redis = RedisConnection.getInstance(); + const raw = await redis.get(`${HANDOFF_KEY_PREFIX}${handoffId}`); + + if (!raw) { + res.status(404).json({ success: false, message: 'Handoff not found or expired' }); + return; + } + + const handoffData = JSON.parse(raw); + res.status(200).json({ success: true, data: handoffData }); + } catch (error: any) { + logger.error('Error in getHandoffController:', { + handoffId, + message: error.message, + stack: error.stack, + }); + res.status(500).json({ + success: false, + message: 'Failed to retrieve handoff', + error: error.message, + }); + } +}; diff --git a/apps/api/api/controllers/auth.controller.ts b/apps/api/api/controllers/auth.controller.ts index ae8d04b04..15bc00885 100644 --- a/apps/api/api/controllers/auth.controller.ts +++ b/apps/api/api/controllers/auth.controller.ts @@ -5,6 +5,8 @@ import { userService } from '../services/user.service'; import { UserModel } from '../models/userModel'; import { refreshTokenService } from '../services/refreshToken.service'; import { CustomRequest } from '../interfaces/express.interface'; +import { v4 as uuidv4 } from 'uuid'; +import RedisConnection from '../config/redis.config'; export const sessionLoginController = async (req: Request, res: Response): Promise => { const token = req.body.token; const user = req.body.user; @@ -380,3 +382,99 @@ export const getRefreshTokensController = async ( }); } }; + +const IDEPLOY_TOKEN_PREFIX = 'ideploy:token:'; +const IDEPLOY_TOKEN_TTL = 5 * 60; // 5 minutes + +/** + * POST /auth/ideploy-token + * Generates a short-lived one-time token for iDeploy SSO. + * Called by main-dashboard after Firebase login when redirect=ideploy. + */ +export const generateIdeployTokenController = async ( + req: CustomRequest, + res: Response +): Promise => { + const uid = req.user?.uid; + const email = req.user?.email; + + if (!uid) { + res.status(401).json({ success: false, message: 'Unauthorized' }); + return; + } + + try { + const firebaseUser = await admin.auth().getUser(uid); + const token = uuidv4(); + + const payload = { + uid, + email: email || firebaseUser.email, + displayName: firebaseUser.displayName || null, + photoURL: firebaseUser.photoURL || null, + createdAt: new Date().toISOString(), + }; + + const redis = RedisConnection.getInstance(); + await redis.set( + `${IDEPLOY_TOKEN_PREFIX}${token}`, + JSON.stringify(payload), + 'EX', + IDEPLOY_TOKEN_TTL + ); + + logger.info('iDeploy SSO token generated', { uid }); + res.status(201).json({ success: true, token }); + } catch (error: any) { + logger.error('Error generating iDeploy token:', { uid, message: error.message }); + res + .status(500) + .json({ success: false, message: 'Failed to generate token', error: error.message }); + } +}; + +/** + * POST /auth/ideploy-token/validate + * Validates a one-time iDeploy SSO token and returns user data. + * Called by iDeploy Laravel backend to verify the token. + * Protected by IDEPLOY_SHARED_SECRET header. + */ +export const validateIdeployTokenController = async ( + req: Request, + res: Response +): Promise => { + const sharedSecret = process.env.IDEPLOY_SHARED_SECRET; + const providedSecret = req.headers['x-ideploy-secret']; + + if (sharedSecret && providedSecret !== sharedSecret) { + res.status(403).json({ success: false, message: 'Invalid secret' }); + return; + } + + const { token } = req.body; + if (!token) { + res.status(400).json({ success: false, message: 'Token is required' }); + return; + } + + try { + const redis = RedisConnection.getInstance(); + const raw = await redis.get(`${IDEPLOY_TOKEN_PREFIX}${token}`); + + if (!raw) { + res.status(401).json({ success: false, message: 'Token not found or expired' }); + return; + } + + await redis.del(`${IDEPLOY_TOKEN_PREFIX}${token}`); + const userData = JSON.parse(raw); + + logger.info('iDeploy SSO token validated', { uid: userData.uid }); + res.status(200).json({ success: true, user: userData }); + } catch (error: any) { + logger.error('Error validating iDeploy token:', { message: error.message }); + res + .status(500) + .json({ success: false, message: 'Failed to validate token', error: error.message }); + } +}; diff --git a/apps/api/api/index.ts b/apps/api/api/index.ts index 5ffacbc9c..e36eef22d 100644 --- a/apps/api/api/index.ts +++ b/apps/api/api/index.ts @@ -66,6 +66,7 @@ import { teamsRoutes } from './routes/teams.routes'; import contactRoutes from './routes/contactRoutes'; import logoImportRoutes from './routes/logo-import.routes'; import ideployRoutes from './routes/ideploy.routes'; +import appgenRoutes from './routes/appgen.routes'; const app: Express = express(); @@ -155,6 +156,9 @@ app.use('/api/logo', logoImportRoutes); // iDeploy routes app.use('/api/ideploy', ideployRoutes); +// AppGen routes +app.use('/appgen', appgenRoutes); + // Swagger setup const swaggerSpec = swaggerJsdoc(swaggerOptions); app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec)); diff --git a/apps/api/api/routes/appgen.routes.ts b/apps/api/api/routes/appgen.routes.ts new file mode 100644 index 000000000..da70a5709 --- /dev/null +++ b/apps/api/api/routes/appgen.routes.ts @@ -0,0 +1,79 @@ +import { Router } from 'express'; +import { authenticate } from '../services/auth.service'; +import { createHandoffController, getHandoffController } from '../controllers/appgen.controller'; + +const router = Router(); + +/** + * @swagger + * /appgen/handoff: + * post: + * summary: Crée un handoff AppGen vers iDeploy + * tags: [AppGen] + * security: + * - bearerAuth: [] + * - cookieAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [files] + * properties: + * draftId: + * type: string + * appName: + * type: string + * description: + * type: string + * files: + * type: object + * metadata: + * type: object + * messages: + * type: array + * responses: + * 201: + * description: Handoff créé avec succès + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * handoffId: + * type: string + * 400: + * description: Données invalides + * 401: + * description: Non authentifié + * 500: + * description: Erreur serveur + */ +router.post('/handoff', authenticate, createHandoffController); + +/** + * @swagger + * /appgen/handoff/{handoffId}: + * get: + * summary: Récupère un handoff AppGen par son ID + * tags: [AppGen] + * parameters: + * - in: path + * name: handoffId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Handoff trouvé + * 404: + * description: Handoff non trouvé ou expiré + * 500: + * description: Erreur serveur + */ +router.get('/handoff/:handoffId', getHandoffController); + +export default router; diff --git a/apps/api/api/routes/auth.routes.ts b/apps/api/api/routes/auth.routes.ts index c28a2bd95..820fd0c4b 100644 --- a/apps/api/api/routes/auth.routes.ts +++ b/apps/api/api/routes/auth.routes.ts @@ -6,6 +6,8 @@ import { logoutAllController, getRefreshTokensController, verifySessionController, + generateIdeployTokenController, + validateIdeployTokenController, } from '../controllers/auth.controller'; import { authenticate } from '../services/auth.service'; @@ -259,3 +261,8 @@ authRoutes.get('/refresh-tokens', authenticate, getRefreshTokensController); * description: Invalid API key */ authRoutes.post('/verify-session', verifySessionController); + +// iDeploy SSO — generate a one-time token (requires authenticated session) +authRoutes.post('/ideploy-token', authenticate, generateIdeployTokenController); +// iDeploy SSO — validate a one-time token (called by iDeploy Laravel backend) +authRoutes.post('/ideploy-token/validate', validateIdeployTokenController); diff --git a/apps/appgen/apps/we-dev-client/package.json b/apps/appgen/apps/we-dev-client/package.json index 43a731aa2..cd567f82c 100644 --- a/apps/appgen/apps/we-dev-client/package.json +++ b/apps/appgen/apps/we-dev-client/package.json @@ -8,8 +8,8 @@ "email": "your.email@example.com" }, "scripts": { - "dev": "vite", - "build": "vite build", + "dev": "vite --port 5174", + "build": "vite build ", "tsc": "tsc", "start": "vite preview --host 0.0.0.0" }, diff --git a/apps/appgen/apps/we-dev-client/src/App.tsx b/apps/appgen/apps/we-dev-client/src/App.tsx index d33d26250..70a181e27 100644 --- a/apps/appgen/apps/we-dev-client/src/App.tsx +++ b/apps/appgen/apps/we-dev-client/src/App.tsx @@ -1,36 +1,137 @@ -import useUserStore from "./stores/userSlice"; -import useChatModeStore from "./stores/chatModeSlice"; -import { GlobalLimitModal } from "./components/UserModal"; -import Header from "./components/Header"; -import AiChat from "./components/AiChat"; -import EditorPreviewTabs from "./components/EditorPreviewTabs"; -import "./utils/i18"; -import classNames from "classnames"; -import { ChatMode } from "./types/chat"; -import { ToastContainer } from "react-toastify"; -import "react-toastify/dist/ReactToastify.css"; -import { UpdateTip } from "./components/UpdateTip"; -import useInit from "./hooks/useInit"; -import { Loading } from "./components/loading"; -import TopViewContainer from "./components/TopView"; +import { useState, useEffect } from 'react'; +import useUserStore from './stores/userSlice'; +import useChatModeStore from './stores/chatModeSlice'; +import { GlobalLimitModal } from './components/UserModal'; +import Header from './components/Header'; +import AiChat from './components/AiChat'; +import EditorPreviewTabs from './components/EditorPreviewTabs'; +import './utils/i18'; +import classNames from 'classnames'; +import { ChatMode } from './types/chat'; +import { ToastContainer } from 'react-toastify'; +import 'react-toastify/dist/ReactToastify.css'; +import { UpdateTip } from './components/UpdateTip'; +import useInit from './hooks/useInit'; +import { Loading } from './components/loading'; +import TopViewContainer from './components/TopView'; +import { AppGenLanding } from './components/Landing/AppGenLanding'; +import useAppGenContextStore from './stores/appgenContextSlice'; +import { consumePendingContext } from './hooks/useAuth'; +import { getCurrentUser } from './api/persistence/db'; + +const PENDING_PROMPT_KEY = 'appgen_pending_prompt'; + +// View states managed by App +type AppView = 'loading' | 'landing' | 'chat'; function App() { const { mode, initOpen } = useChatModeStore(); + const { openLoginModal, isAuthenticated } = useUserStore(); + const { isDarkMode } = useInit(); + const { initDraft, setPendingIntent, updateDraftMetadata } = useAppGenContextStore(); - const { openLoginModal } = useUserStore(); + const [view, setView] = useState('loading'); - const { isDarkMode } = useInit(); + useEffect(() => { + // URL params take priority — preserve all existing workflows + const urlParams = new URLSearchParams(window.location.search); + const projectId = urlParams.get('projectId'); + const fromParam = urlParams.get('from'); + const promptParam = urlParams.get('prompt'); + + // Restore AppGen context if returning from login redirect + const pendingCtx = consumePendingContext(); + if (pendingCtx?.intent) { + setPendingIntent(pendingCtx.intent); + } + + initDraft(); + + // Skip landing entirely for existing workflows: + // - projectId: linked from main-dashboard + // - prompt: coming from landing start + // - from=dashboard / from=appgen: returning after auth (restore pending prompt if any) + if (projectId || promptParam || fromParam === 'dashboard' || fromParam === 'appgen') { + // If returning from login with a pending prompt, inject it via URL param + if (!promptParam) { + const pendingPrompt = localStorage.getItem(PENDING_PROMPT_KEY); + if (pendingPrompt) { + localStorage.removeItem(PENDING_PROMPT_KEY); + const url = new URL(window.location.href); + url.searchParams.set('prompt', encodeURIComponent(pendingPrompt)); + window.history.replaceState({}, '', url.toString()); + } + } + setView('chat'); + return; + } + + // Use the same auth check as AuthWrapper: getCurrentUser() via session cookie + getCurrentUser().then((user) => { + setView(user ? 'chat' : 'landing'); + }); + }, []); + + // When user logs in via the login modal, switch from landing to chat + useEffect(() => { + if (isAuthenticated && view === 'landing') { + setView('chat'); + } + }, [isAuthenticated]); + + const handleLandingStart = (prompt?: string) => { + updateDraftMetadata({}); + if (prompt) { + const url = new URL(window.location.href); + url.searchParams.set('prompt', encodeURIComponent(prompt)); + window.history.replaceState({}, '', url.toString()); + } + setView('chat'); + }; + + // Minimal loading screen while checking auth + if (view === 'loading') { + return ( +
+
+ logo +
+
+
+ ); + } + + // Landing page — non-authenticated entry point + if (view === 'landing') { + return ( +
+ + +
+ ); + } + // Chat — full app (authenticated or projectId workflow) return (
diff --git a/apps/appgen/apps/we-dev-client/src/components/AiChat/chat/index.tsx b/apps/appgen/apps/we-dev-client/src/components/AiChat/chat/index.tsx index e8b878e57..83fa5f7fe 100644 --- a/apps/appgen/apps/we-dev-client/src/components/AiChat/chat/index.tsx +++ b/apps/appgen/apps/we-dev-client/src/components/AiChat/chat/index.tsx @@ -40,6 +40,7 @@ import { ProjectTutorial } from '../../Onboarding/ProjectTutorial'; import { useLoading } from '../../loading'; import { ProjectModel } from '@/api/persistence/models/project.model'; import { MultiChatPromptService } from './services/multiChatPromptService'; +import useChatHistoryStore from '@/stores/chatHistoryStore'; type WeMessages = (Message & { experimental_attachments?: Array<{ @@ -202,6 +203,7 @@ export const BaseChat = ({ uuid: propUuid }: { uuid?: string }) => { setOldFiles, } = useFileStore(); const { mode } = useChatModeStore(); + const { touchSession, updateSessionTitle, setActiveChatUuid } = useChatHistoryStore(); // use global state const { uploadedImages, addImages, removeImage, clearImages, setModelOptions } = useChatStore(); const { resetTerminals } = useTerminalStore(); @@ -589,13 +591,18 @@ export const BaseChat = ({ uuid: propUuid }: { uuid?: string }) => { content: input, }, ]; + const autoTitle = + [...initMessage, ...messages] + .find((m) => m.role === 'user' && !m.content.includes(' m.role === 'user' && !m.content.includes(' { }, }); + // Listen for auto-prompt from AppGen landing page (placed here, after useChat, so isLoading/append are defined) + useEffect(() => { + const unsubscribe = eventEmitter.on('chat:autoPrompt', (prompt: string) => { + if (prompt && !isLoading) { + append({ + id: uuidv4(), + role: 'user', + content: prompt, + }); + } + }); + return () => unsubscribe(); + }, [isLoading, append]); + // Get status and type from URL data (projectId already obtained above) const { status, type } = useUrlData({ append }); const { setLoading } = useLoading(); diff --git a/apps/appgen/apps/we-dev-client/src/components/AuthWrapper.tsx b/apps/appgen/apps/we-dev-client/src/components/AuthWrapper.tsx index 8b2fc380b..a41f2c9c0 100644 --- a/apps/appgen/apps/we-dev-client/src/components/AuthWrapper.tsx +++ b/apps/appgen/apps/we-dev-client/src/components/AuthWrapper.tsx @@ -1,12 +1,14 @@ -import React, { useEffect, useState } from "react"; -import { getCurrentUser } from "../api/persistence/db"; -import { Loading } from "./loading"; +import React, { useEffect, useState } from 'react'; +import { getCurrentUser } from '../api/persistence/db'; +import { Loading } from './loading'; +import { redirectToLogin } from '../hooks/useAuth'; interface AuthWrapperProps { children: React.ReactNode; + intent?: string; } -const AuthWrapper: React.FC = ({ children }) => { +const AuthWrapper: React.FC = ({ children, intent }) => { const [isAuthenticated, setIsAuthenticated] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -16,7 +18,7 @@ const AuthWrapper: React.FC = ({ children }) => { const user = await getCurrentUser(); setIsAuthenticated(!!user); } catch (error) { - console.error("Authentication check failed:", error); + console.error('Authentication check failed:', error); setIsAuthenticated(false); } finally { setIsLoading(false); @@ -27,19 +29,17 @@ const AuthWrapper: React.FC = ({ children }) => { }, []); useEffect(() => { - // Redirect to login if not authenticated if (isAuthenticated === false) { - console.log("Redirecting to login"); - // window.location.href = "http://localhost:4200/login"; + redirectToLogin(intent); } - }, [isAuthenticated]); + }, [isAuthenticated, intent]); if (isLoading) { return ; } if (!isAuthenticated) { - return null; // Will redirect, so don't render anything + return null; } return <>{children}; diff --git a/apps/appgen/apps/we-dev-client/src/components/ChatHistory/ChatHistoryPanel.tsx b/apps/appgen/apps/we-dev-client/src/components/ChatHistory/ChatHistoryPanel.tsx new file mode 100644 index 000000000..14cf571de --- /dev/null +++ b/apps/appgen/apps/we-dev-client/src/components/ChatHistory/ChatHistoryPanel.tsx @@ -0,0 +1,128 @@ +import React, { useEffect, useRef } from 'react'; +import useChatHistoryStore, { ChatSession } from '@/stores/chatHistoryStore'; +import { db } from '@/utils/indexDB'; +import { eventEmitter } from '@/components/AiChat/utils/EventEmitter'; + +interface ChatHistoryPanelProps { + open: boolean; + onClose: () => void; +} + +function formatDate(ts: number): string { + const d = new Date(ts); + const now = new Date(); + const diff = now.getTime() - ts; + if (diff < 60_000) return "A l'instant"; + if (diff < 3600_000) return `Il y a ${Math.floor(diff / 60_000)} min`; + if (d.toDateString() === now.toDateString()) + return `Aujourd'hui ${d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}`; + const yesterday = new Date(now); + yesterday.setDate(now.getDate() - 1); + if (d.toDateString() === yesterday.toDateString()) return 'Hier'; + return d.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' }); +} + +const ChatHistoryPanel: React.FC = ({ open, onClose }) => { + const { sessions, activeChatUuid, deleteSession, createSession, setActiveChatUuid } = + useChatHistoryStore(); + const panelRef = useRef(null); + + // Close on outside click + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (panelRef.current && !panelRef.current.contains(e.target as Node)) { + onClose(); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [open, onClose]); + + const handleSelectChat = (session: ChatSession) => { + setActiveChatUuid(session.uuid); + eventEmitter.emit('chat:select', session.uuid); + onClose(); + }; + + const handleNewChat = () => { + const uuid = createSession(); + eventEmitter.emit('chat:select', ''); + setActiveChatUuid(uuid); + onClose(); + }; + + const handleDeleteSession = async (e: React.MouseEvent, uuid: string) => { + e.stopPropagation(); + await db.deleteByUuid(uuid); + deleteSession(uuid); + // If deleted active chat, start fresh + if (uuid === activeChatUuid) { + eventEmitter.emit('chat:select', ''); + } + }; + + if (!open) return null; + + return ( +
+ {/* Header */} +
+ Historique des chats + +
+ + {/* List */} +
+ {sessions.length === 0 ? ( +
+ Aucun chat pour l'instant +
+ ) : ( + sessions.map((session) => ( + + + )) + )} +
+
+ ); +}; + +export default ChatHistoryPanel; diff --git a/apps/appgen/apps/we-dev-client/src/components/DeployModal/DeployModal.tsx b/apps/appgen/apps/we-dev-client/src/components/DeployModal/DeployModal.tsx new file mode 100644 index 000000000..82daa352a --- /dev/null +++ b/apps/appgen/apps/we-dev-client/src/components/DeployModal/DeployModal.tsx @@ -0,0 +1,319 @@ +import { useState, useEffect } from 'react'; +import { Modal } from 'antd'; +import { toast } from 'react-toastify'; +import useAppGenContextStore from '@/stores/appgenContextSlice'; +import useUserStore from '@/stores/userSlice'; +import { redirectToLogin } from '@/hooks/useAuth'; +import { getCurrentUser } from '@/api/persistence/db'; +import type { UserModel } from '@/api/persistence/userModel'; + +interface DeployModalProps { + open: boolean; + onClose: () => void; + onNetlifyDeploy: () => void; +} + +const IDEPLOY_URL = process.env.REACT_APP_IDEPLOY_URL || 'http://localhost:8000'; +const DASHBOARD_URL = process.env.REACT_APP_IDEM_MAIN_APP_URL || 'http://localhost:4200'; +const API_BASE = process.env.REACT_APP_IDEM_API_BASE_URL || 'http://localhost:3001'; + +const HANDOFF_TTL_MS = 15 * 60 * 1000; // 15 minutes + +export function DeployModal({ open, onClose, onNetlifyDeploy }: DeployModalProps) { + const [isHandingOff, setIsHandingOff] = useState(false); + const [currentUser, setCurrentUser] = useState(null); + const { getHandoffPayload } = useAppGenContextStore(); + const { token } = useUserStore(); + + useEffect(() => { + if (open) { + getCurrentUser().then((user) => setCurrentUser(user)); + } + }, [open]); + + const handleNetlify = () => { + onClose(); + onNetlifyDeploy(); + }; + + const handleIdemDeploy = async () => { + if (!currentUser) { + redirectToLogin('deploy_idem'); + onClose(); + return; + } + + const payload = getHandoffPayload(); + if (!payload) { + toast.error('Aucune génération disponible à déployer'); + return; + } + + setIsHandingOff(true); + try { + const response = await fetch(`${API_BASE}/appgen/handoff`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + credentials: 'include', + body: JSON.stringify({ + ...payload, + target: 'ideploy', + expiresAt: new Date(Date.now() + HANDOFF_TTL_MS).toISOString(), + }), + }); + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + const { handoffId } = await response.json(); + onClose(); + window.location.href = `${IDEPLOY_URL}/deploy/from-appgen?handoffId=${handoffId}`; + } catch (error) { + console.error('Handoff failed:', error); + // Fallback: pass payload via sessionStorage + redirect + const payload = getHandoffPayload(); + if (payload) { + sessionStorage.setItem( + 'appgen_handoff', + JSON.stringify({ + ...payload, + expiresAt: new Date(Date.now() + HANDOFF_TTL_MS).toISOString(), + }) + ); + } + onClose(); + window.location.href = `${IDEPLOY_URL}/deploy/from-appgen?source=appgen`; + } finally { + setIsHandingOff(false); + } + }; + + const handleConnectProject = () => { + if (!currentUser) { + redirectToLogin('connect_project'); + onClose(); + return; + } + + const payload = getHandoffPayload(); + if (payload) { + sessionStorage.setItem( + 'appgen_handoff', + JSON.stringify({ + ...payload, + target: 'dashboard', + expiresAt: new Date(Date.now() + HANDOFF_TTL_MS).toISOString(), + }) + ); + } + + onClose(); + const encodedName = encodeURIComponent(payload?.appName || 'Mon Application'); + const encodedDesc = encodeURIComponent(payload?.description || ''); + window.location.href = `${DASHBOARD_URL}/create-project?from=appgen&name=${encodedName}&description=${encodedDesc}`; + }; + + return ( + +
+ {/* Header */} +
+
+ + + +
+

Déployer votre application

+

Choisissez comment vous souhaitez déployer

+
+ + {/* Options */} +
+ {/* Quick deploy - Netlify */} + + + {/* iDeploy */} + +
+ + {/* Connect to project */} +
+ +
+ + {/* Cancel */} +
+ +
+
+
+ ); +} + +export default DeployModal; diff --git a/apps/appgen/apps/we-dev-client/src/components/Header/HeaderActions.tsx b/apps/appgen/apps/we-dev-client/src/components/Header/HeaderActions.tsx index 293d4ba17..941f8774e 100644 --- a/apps/appgen/apps/we-dev-client/src/components/Header/HeaderActions.tsx +++ b/apps/appgen/apps/we-dev-client/src/components/Header/HeaderActions.tsx @@ -5,11 +5,15 @@ import useChatModeStore from '@/stores/chatModeSlice'; import { ChatMode } from '@/types/chat'; import useTerminalStore from '@/stores/terminalSlice'; import { getWebContainerInstance } from '../WeIde/services/webcontainer'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { toast } from 'react-toastify'; import { Modal } from 'antd'; -import { sendToGitHub } from '@/api/persistence/db'; +import { sendToGitHub, getCurrentUser } from '@/api/persistence/db'; import { HelpButton } from './HelpButton'; +import { DeployModal } from '../DeployModal/DeployModal'; +import useAppGenContextStore from '@/stores/appgenContextSlice'; +import { UserProfile } from './UserProfile'; +import type { UserModel } from '@/api/persistence/userModel'; // Add a helper function to recursively get all files const getAllFiles = async ( @@ -98,23 +102,17 @@ export function HeaderActions() { } }; - const handleDeployClick = () => { - setShowDeployChoiceModal(true); - }; + const { updateDraftFiles } = useAppGenContextStore(); + const [currentUser, setCurrentUser] = useState(null); - const handleDeployChoice = (choice: 'netlify' | 'idem') => { - setShowDeployChoiceModal(false); + useEffect(() => { + getCurrentUser().then((user) => setCurrentUser(user)); + }, []); - if (choice === 'idem') { - const idemUrl = process.env.REACT_APP_IDEM_MAIN_APP_URL; - if (idemUrl) { - window.open(`${idemUrl}/console/deployments`, '_blank'); - } else { - toast.error('REACT_APP_IDEM_MAIN_APP_URL not configured'); - } - } else { - publishToNetlify(); - } + const handleDeployClick = () => { + setShowDeployChoiceModal(true); + // Sync current files into AppGen context before deploying + updateDraftFiles(files as Record); }; const publishToNetlify = async () => { @@ -216,6 +214,7 @@ export function HeaderActions() { return (
+ {currentUser && } {mode === ChatMode.Builder && (
@@ -308,6 +307,12 @@ export function HeaderActions() { {/* Directory opening option disabled in web mode */}
)} + setShowDeployChoiceModal(false)} + onNetlifyDeploy={publishToNetlify} + /> + {showModal && ( )} - - {/* Deploy Choice Modal */} - {showDeployChoiceModal && ( - setShowDeployChoiceModal(false)} - footer={null} - width={600} - className=" bg-black" - styles={{ - content: { backgroundColor: 'var(--color-bg-light)' }, - body: { - padding: 0, - }, - header: { - display: 'none', - }, - }} - > -
-
- - - -
-

Déployer votre projet

-

Choisissez votre méthode de déploiement

-
- -
- {/* Custom Deployment - Coming Soon */} -
-
-
-
- - - -
-
-

Déploiement personnalisé

- - Disponible bientôt - -
-
- -

- Déployez sur votre infrastructure avec Idem Deploy -

- -
- - AWS - - - Docker - - - Kubernetes - -
-
-
- - {/* Quick Deployment */} - -
- -
- -
-
- )}
); } diff --git a/apps/appgen/apps/we-dev-client/src/components/Header/ProjectTitle.tsx b/apps/appgen/apps/we-dev-client/src/components/Header/ProjectTitle.tsx index d0487f26a..ceb4a30b8 100644 --- a/apps/appgen/apps/we-dev-client/src/components/Header/ProjectTitle.tsx +++ b/apps/appgen/apps/we-dev-client/src/components/Header/ProjectTitle.tsx @@ -1,57 +1,94 @@ -import React, { useState, useEffect } from "react"; -import { getProjectById } from "../../api/persistence/db"; -import { useUrlData } from "../../hooks/useUrlData"; -import { ProjectModel } from "@/api/persistence/models/project.model"; +import React, { useState, useEffect } from 'react'; +import { getProjectById } from '../../api/persistence/db'; +import { useUrlData } from '../../hooks/useUrlData'; +import { ProjectModel } from '@/api/persistence/models/project.model'; +import ChatHistoryPanel from '@/components/ChatHistory/ChatHistoryPanel'; +import useChatHistoryStore from '@/stores/chatHistoryStore'; export function ProjectTitle() { const [projectData, setProjectData] = useState(null); + const [historyOpen, setHistoryOpen] = useState(false); const { projectId } = useUrlData({ append: () => {} }); + const { sessions, activeChatUuid } = useChatHistoryStore(); - // Load project data - const loadProjectData = async () => { - if (!projectId) return; - - try { - const project = await getProjectById(projectId); - setProjectData(project); - } catch (error) { - console.error("Error loading project:", error); - } - }; + const activeSession = sessions.find((s) => s.uuid === activeChatUuid); useEffect(() => { - loadProjectData(); + if (!projectId) return; + getProjectById(projectId) + .then((p) => setProjectData(p)) + .catch((e) => console.error('Error loading project:', e)); }, [projectId]); - return ( -
- {/* Project Icon */} -
- -
- - {/* Project Info */} -
-
- {projectData?.name || "Project"} + // Project mode: show project info + if (projectId) { + return ( +
+
+
-
- Generation Workspace +
+
+ {projectData?.name || 'Project'} +
+
Generation Workspace
+
+
+ Active Project
+ ); + } - {/* Generation Status Badge */} -
- Active Project -
+ // No project: show chat history button + return ( +
+ + + setHistoryOpen(false)} />
); } diff --git a/apps/appgen/apps/we-dev-client/src/components/Header/UserProfile.tsx b/apps/appgen/apps/we-dev-client/src/components/Header/UserProfile.tsx index dd394c287..a7222a252 100644 --- a/apps/appgen/apps/we-dev-client/src/components/Header/UserProfile.tsx +++ b/apps/appgen/apps/we-dev-client/src/components/Header/UserProfile.tsx @@ -1,53 +1,117 @@ -import React from 'react'; +import React, { useState, useRef, useEffect } from 'react'; import type { UserModel } from '../../api/persistence/userModel'; +import useUserStore from '@/stores/userSlice'; interface UserProfileProps { user: UserModel; } export const UserProfile: React.FC = ({ user }) => { - // Function to get initials from name (display name or email) - const getInitials = (name: string) => { - return name + const [open, setOpen] = useState(false); + const ref = useRef(null); + const { logout } = useUserStore(); + + const mainAppUrl = process.env.REACT_APP_IDEM_MAIN_APP_URL || 'http://localhost:4200'; + + const getInitials = (name: string) => + name ?.split(' ') - .map((word) => word[0]) + .map((w) => w[0]) .join('') .toUpperCase() .slice(0, 2) || '?'; - }; - // Display name is preferred, fallback to email const displayName = user.displayName || user.email; const initials = getInitials(user.displayName || user.email); + // Close dropdown on outside click + useEffect(() => { + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, []); + + const handleLogout = () => { + logout(); + setOpen(false); + window.location.reload(); + }; + return ( -
-
+
-
-
- {displayName} +
+ {!user.photoURL && initials}
-
- {user.subscription} plan +
+
+ {displayName} +
+
{user.subscription}
+
+ + + + + + {open && ( +
+
+
{displayName}
+
{user.email}
+
+ setOpen(false)} + > + + + + Dashboard Idem + +
-
+ )}
); }; diff --git a/apps/appgen/apps/we-dev-client/src/components/Landing/AppGenLanding.tsx b/apps/appgen/apps/we-dev-client/src/components/Landing/AppGenLanding.tsx new file mode 100644 index 000000000..23338d5c6 --- /dev/null +++ b/apps/appgen/apps/we-dev-client/src/components/Landing/AppGenLanding.tsx @@ -0,0 +1,295 @@ +import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import useAppGenContextStore from '@/stores/appgenContextSlice'; +import { getCurrentUser } from '@/api/persistence/db'; +import type { UserModel } from '@/api/persistence/userModel'; +import { UserProfile } from '../Header/UserProfile'; +import { redirectToLogin } from '@/hooks/useAuth'; + +const PENDING_PROMPT_KEY = 'appgen_pending_prompt'; + +interface AppGenLandingProps { + onStart: (prompt?: string) => void; +} + +const EXAMPLE_PROMPTS = [ + 'Une application de gestion de tontines pour les communautés africaines', + 'Un dashboard de suivi des livraisons pour un e-commerce à Dakar', + 'Une landing page pour une startup fintech à Lagos', + 'Une plateforme de mise en relation entre freelances et entreprises à Abidjan', +]; + +export function AppGenLanding({ onStart }: AppGenLandingProps) { + const { t } = useTranslation(); + const [inputValue, setInputValue] = useState(''); + const [currentUser, setCurrentUser] = useState(null); + const { initDraft } = useAppGenContextStore(); + + useEffect(() => { + getCurrentUser().then((user) => setCurrentUser(user)); + }, []); + + const handleStart = (prompt?: string) => { + const finalPrompt = prompt || inputValue.trim() || undefined; + if (!currentUser) { + // Save prompt so we can restore it after login redirect + if (finalPrompt) { + localStorage.setItem(PENDING_PROMPT_KEY, finalPrompt); + } + redirectToLogin('generate'); + return; + } + initDraft(); + onStart(finalPrompt); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + if (inputValue.trim()) handleStart(); + } + }; + + return ( +
+ {/* Navbar */} + + + {/* Hero Section */} +
+
+
+
+ +
+

+ Build apps with AI +

+

+ Describe your idea, IDEON generates production-ready code in seconds +

+ + {/* Input */} +
+
+