diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 5c90b4ed..7e91bc68 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -6,6 +6,8 @@ import { QueryClientProvider } from "@tanstack/react-query"; import { queryClient } from "@/lib/queryClient"; import { BrowserRouter, Routes, Route, useLocation } from "react-router-dom"; import { AuthProvider, useAuth } from "@/hooks/useAuth"; +import { AuthProvider as OidcProvider } from "react-oidc-context"; +import { WebStorageStateStore } from "oidc-client-ts"; import { LogProvider } from "@/contexts/LogContext"; @@ -24,6 +26,7 @@ registerAllWidgets(); import Index from "./pages/Index"; import Auth from "./pages/Auth"; +import AuthZ from "./pages/AuthZ"; const UpdatePassword = React.lazy(() => import("./pages/UpdatePassword")); @@ -55,6 +58,7 @@ let TypesPlayground: any; let VariablePlayground: any; let I18nPlayground: any; let PlaygroundChat: any; +let PlaygroundVfs: any; let GridSearch: any; let LocationDetail: any; let Tetris: any; @@ -79,6 +83,7 @@ if (enablePlaygrounds) { VariablePlayground = React.lazy(() => import("./components/variables/VariablesEditor").then(module => ({ default: module.VariablesEditor }))); I18nPlayground = React.lazy(() => import("./components/playground/I18nPlayground")); PlaygroundChat = React.lazy(() => import("./pages/PlaygroundChat")); + PlaygroundVfs = React.lazy(() => import("./pages/PlaygroundVfs")); SupportChat = React.lazy(() => import("./pages/SupportChat")); } @@ -130,6 +135,7 @@ const AppWrapper = () => { {/* Top-level routes (no organization context) */} } /> } /> + } /> Loading...}>} /> } /> Loading...}>} /> @@ -192,6 +198,7 @@ const AppWrapper = () => { Loading...}>} /> Loading...}>} /> Loading...}>} /> + Loading...}>} /> )} @@ -251,10 +258,27 @@ const App = () => { initFormatDetection(); }, []); + const oidcConfig = { + authority: "https://auth.polymech.info", + client_id: "367440527605432321", + redirect_uri: window.location.origin + "/authz", // Where Zitadel sends the code back to + post_logout_redirect_uri: window.location.origin, + response_type: "code", + scope: "openid profile email", + loadUserInfo: true, // Specifically instruct the client to fetch /userinfo + // Store tokens in localStorage instead of sessionStorage so they survive new tabs + userStore: new WebStorageStateStore({ store: window.localStorage }), + onSigninCallback: () => { + // Clean up the URL after successful login + window.history.replaceState({}, document.title, window.location.pathname); + } + }; + return ( new Map() }}> - + + @@ -281,9 +305,10 @@ const App = () => { - + + - + ); }; diff --git a/packages/ui/src/apps/filebrowser/FileBrowser.tsx b/packages/ui/src/apps/filebrowser/FileBrowser.tsx index 0272ff8a..0f16b740 100644 --- a/packages/ui/src/apps/filebrowser/FileBrowser.tsx +++ b/packages/ui/src/apps/filebrowser/FileBrowser.tsx @@ -153,11 +153,12 @@ const FileBrowser: React.FC<{ mode?: 'simple' | 'advanced', index?: boolean, disableRoutingSync?: boolean, + initialMount?: string, onSelect?: (node: INode | null, mount?: string) => void -}> = ({ allowPanels, mode, index, disableRoutingSync, onSelect }) => { +}> = ({ allowPanels, mode, index, disableRoutingSync, initialMount: propInitialMount, onSelect }) => { const location = useLocation(); - let initialMount: string | undefined; + let initialMount = propInitialMount; let initialPath: string | undefined; if (!disableRoutingSync) { @@ -166,10 +167,10 @@ const FileBrowser: React.FC<{ const segments = urlRest.split('/').filter(Boolean); if (segments.length > 0) { - initialMount = segments[0]; + initialMount = initialMount || segments[0]; initialPath = segments.slice(1).join('/'); } else { - initialMount = localStorage.getItem('fb-last-mount') || undefined; + initialMount = initialMount || localStorage.getItem('fb-last-mount') || undefined; initialPath = localStorage.getItem('fb-last-path') || undefined; } } diff --git a/packages/ui/src/apps/filebrowser/FileBrowserPanel.tsx b/packages/ui/src/apps/filebrowser/FileBrowserPanel.tsx index 1b821ea4..d43429f0 100644 --- a/packages/ui/src/apps/filebrowser/FileBrowserPanel.tsx +++ b/packages/ui/src/apps/filebrowser/FileBrowserPanel.tsx @@ -1,14 +1,16 @@ -import React, { useState, useEffect, useCallback, useRef } from 'react'; +import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { Loader2 } from 'lucide-react'; import { useAuth } from '@/hooks/useAuth'; import ImageLightbox from '@/components/ImageLightbox'; import LightboxText from '@/modules/storage/views/LightboxText'; import LightboxIframe from '@/modules/storage/views/LightboxIframe'; import { renderFileViewer } from '@/modules/storage/FileViewerRegistry'; +import { useDragDrop } from '@/contexts/DragDropContext'; import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable'; import type { INode, SortKey } from '@/modules/storage/types'; import { getMimeCategory, vfsUrl, formatSize } from '@/modules/storage/helpers'; +import { serverUrl } from '@/lib/db'; import FileBrowserToolbar from '@/modules/storage/FileBrowserToolbar'; import FileListView from '@/modules/storage/FileListView'; import FileGridView from '@/modules/storage/FileGridView'; @@ -140,6 +142,37 @@ const FileBrowserPanel: React.FC = ({ } }, [onFilterChange]); + // ── Drag & Drop Uploads ─────────────────────────────────────── + + type UploadingNode = INode & { _uploading: boolean; _progress: number; _error?: string; _file?: File }; + const { setLocalZoneActive, resetDragState } = useDragDrop(); + const [uploads, setUploads] = useState([]); + const [isDragOver, setIsDragOver] = useState(false); + + const handleDragEnter = (e: React.DragEvent) => { + if (!e.dataTransfer.types.includes('Files')) return; + e.preventDefault(); + e.stopPropagation(); + setLocalZoneActive(true); + setIsDragOver(true); + }; + + const handleDragOver = (e: React.DragEvent) => { + if (!e.dataTransfer.types.includes('Files')) return; + e.preventDefault(); + e.stopPropagation(); + if (!isDragOver) setIsDragOver(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + if (e.clientX <= rect.left || e.clientX >= rect.right || e.clientY <= rect.top || e.clientY >= rect.bottom) { + setIsDragOver(false); + setLocalZoneActive(false); + } + }; + // ── Available mounts ───────────────────────────────────────── const [availableMounts, setAvailableMounts] = useState([]); @@ -207,6 +240,90 @@ const FileBrowserPanel: React.FC = ({ // ── View Mode & Zoom ───────────────────────────────────────── + const displayNodes = useMemo(() => [...sorted, ...(uploads as INode[])], [sorted, uploads]); + + const handleDrop = async (e: React.DragEvent) => { + if (!e.dataTransfer.types.includes('Files')) return; + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + setLocalZoneActive(false); + resetDragState(); + + const files = Array.from(e.dataTransfer.files); + if (files.length === 0) return; + + const newUploads = files.map((f, index) => ({ + name: f.name, + path: currentPath ? `${currentPath}/${f.name}` : f.name, + size: f.size, + mtime: Date.now(), + parent: currentPath, + type: 'file', + _uploading: true, + _progress: 0, + _file: f + })); + setUploads(prev => [...prev, ...newUploads]); + + const uploadOne = (uploadItem: typeof newUploads[0]) => new Promise((resolve) => { + const xhr = new XMLHttpRequest(); + const cleanDir = (currentPath || '').replace(/^\/+|\/+$/g, ''); + const filePath = cleanDir ? `${cleanDir}/${uploadItem.name}` : uploadItem.name; + const uploadUrl = `${serverUrl}/api/vfs/upload/${mount}/${filePath}`; + xhr.open('POST', uploadUrl, true); + if (accessToken) { + xhr.setRequestHeader('Authorization', `Bearer ${accessToken}`); + } + + xhr.upload.onprogress = (evt) => { + if (evt.lengthComputable) { + const percent = Math.round((evt.loaded / evt.total) * 100); + setUploads(prev => prev.map(u => + u.name === uploadItem.name ? { ...u, _progress: percent } : u + )); + } + }; + + const markError = (msg: string) => { + setUploads(prev => prev.map(u => + u.name === uploadItem.name ? { ...u, _uploading: false, _error: msg } : u + )); + setTimeout(() => { + setUploads(prev => prev.filter(u => u.name !== uploadItem.name)); + }, 4000); + }; + + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + setUploads(prev => prev.filter(u => u.name !== uploadItem.name)); + } else { + let msg = `Error ${xhr.status}`; + try { + const body = JSON.parse(xhr.responseText); + msg = body.message || body.error || msg; + } catch {} + markError(msg); + } + resolve(); + }; + + xhr.onerror = () => { + markError('Network error'); + resolve(); + }; + + const formData = new FormData(); + if (uploadItem._file) { + formData.append('file', uploadItem._file); + } + xhr.send(formData); + }); + + await Promise.all(newUploads.map(uploadOne)); + fetchDir(currentPath || '/'); + }; + const [internalViewMode, setInternalViewMode] = useState<'list' | 'thumbs' | 'tree'>(() => { if (autoSaveId) { const saved = localStorage.getItem(`${autoSaveId}-viewMode`); @@ -283,7 +400,7 @@ const FileBrowserPanel: React.FC = ({ handleItemClick, clearSelection } = useSelection({ - sorted, + sorted: displayNodes, canGoUp, onSelect }); @@ -348,7 +465,7 @@ const FileBrowserPanel: React.FC = ({ const [pendingFileSelect, setPendingFileSelect] = useState(null); const { goUp } = useDefaultSelectionHandler({ - sorted, + sorted: displayNodes, canGoUp, rawGoUp, currentPath, @@ -410,7 +527,7 @@ const FileBrowserPanel: React.FC = ({ searchQuery, isSearchMode, onSelect, - sorted, + sorted: displayNodes, }); // ── Default Actions ────────────────────────────────────────── @@ -436,7 +553,7 @@ const FileBrowserPanel: React.FC = ({ pathProp, accessToken, selected, - sorted, + sorted: displayNodes, canGoUp, setFocusIdx, setSelected, @@ -459,9 +576,14 @@ const FileBrowserPanel: React.FC = ({ ref={containerRef} data-testid="file-browser-panel" tabIndex={viewMode === 'tree' ? undefined : 0} - className="fb-panel-container" + className={`fb-panel-container ${isDragOver ? 'ring-2 ring-primary bg-primary/5' : ''}`} onKeyDown={handleKeyDown} + onDragEnter={handleDragEnter} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} style={{ + position: 'relative', display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0, border: '1px solid var(--border, #334155)', borderRadius: 6, overflow: 'hidden', @@ -474,6 +596,27 @@ const FileBrowserPanel: React.FC = ({ } `} + {/* ═══ Drop Overlay ═══════════════════════════════ */} + {isDragOver && ( +
+ + + + + + + Drop files to upload + +
+ )} + {/* ═══ Toolbar ═══════════════════════════════════ */} {showToolbar && ( = ({ {viewMode === 'tree' ? (
= ({
= ({
= ({ for (const img of preloadedImages) { // Handle External Pages (Links) if (img.type === 'page-external') { - console.log('Skipping upload for external page:', img.title); await createPicture({ user_id: user.id, title: img.title || 'Untitled Link', @@ -257,7 +256,6 @@ export const CreationWizardPopup: React.FC = ({ if (file.type.startsWith('video/')) continue; const { publicUrl } = await uploadImage(file, user.id); - console.log('image uploaded, url:', publicUrl); let dbData = { user_id: user.id, @@ -514,7 +512,7 @@ export const CreationWizardPopup: React.FC = ({ ) : ( <> - Upload Image + Upload as Picture )} diff --git a/packages/ui/src/modules/storage/FileGridView.tsx b/packages/ui/src/modules/storage/FileGridView.tsx index 03cc75b0..57975215 100644 --- a/packages/ui/src/modules/storage/FileGridView.tsx +++ b/packages/ui/src/modules/storage/FileGridView.tsx @@ -57,16 +57,19 @@ const FileGridView: React.FC = ({ const isDir = getMimeCategory(node) === 'dir'; const isFocused = focusIdx === idx; const isSelected = selected.some(sel => sel.path === node.path); + const { _uploading, _progress, _error } = node as any; + return (
onItemClick(idx, e)} - onDoubleClick={() => onItemDoubleClick(idx)} + onDoubleClick={() => !_uploading && onItemDoubleClick(idx)} className="fb-thumb" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', - padding: 6, borderRadius: 6, cursor: 'pointer', gap: 6, overflow: 'hidden', + padding: 6, borderRadius: 6, cursor: _uploading ? 'default' : 'pointer', gap: 6, overflow: 'hidden', background: isSelected ? 'rgba(59, 130, 246, 0.05)' : 'transparent', + opacity: (_uploading || _error) ? 0.7 : 1, }}>
= ({ borderStyle: isSelected ? 'solid' : 'solid', outline: isFocused ? `2px solid ${FOCUS_BORDER}` : 'none', outlineOffset: '2px', + position: 'relative' }}> {isDir ? : } + + {_uploading && ( +
+
+
+ )} + {_error && ( +
+ {_error} +
+ )}
= ({ const isDir = getMimeCategory(node) === 'dir'; const isFocused = focusIdx === idx; const isSelected = selected.some(sel => sel.path === node.path); + const { _uploading, _progress, _error } = node as any; + return (
onItemClick(idx, e)} - onDoubleClick={() => onItemDoubleClick(idx)} + onDoubleClick={() => !_uploading && onItemDoubleClick(idx)} className="fb-row" style={{ display: 'flex', alignItems: 'center', gap: 8, padding: isSearchMode ? '8px 10px' : '5px 10px', - cursor: 'pointer', fontSize, + cursor: _uploading ? 'default' : 'pointer', fontSize, borderBottomWidth: 1, borderBottomColor: 'rgba(255,255,255,0.06)', borderBottomStyle: 'solid', background: isSelected ? SELECTED_BG : isFocused ? FOCUS_BG : 'transparent', borderLeftWidth: 2, borderLeftColor: isSelected ? SELECTED_BORDER : 'transparent', borderLeftStyle: isSelected ? 'outset' : 'solid', outline: isFocused ? `2px solid ${FOCUS_BORDER}` : 'none', outlineOffset: '-2px', + opacity: _uploading ? 0.7 : 1, + position: 'relative' }}>
@@ -99,13 +103,27 @@ const FileListView: React.FC = ({ {node.parent || '/'} )} + {_uploading && ( +
+
+
+ )} + {_error && ( + {_error} + )}
{node.size !== undefined && ( - - {formatSize(node.size)} + + {_error ? '✕' : _uploading ? `${Math.round(_progress || 0)}%` : formatSize(node.size)} )} - {mode === 'advanced' && node.mtime && ( + {mode === 'advanced' && node.mtime && !_uploading && ( {formatDate(node.mtime)} diff --git a/packages/ui/src/pages/Auth.tsx b/packages/ui/src/pages/Auth.tsx index f8cd91f3..af772e6c 100644 --- a/packages/ui/src/pages/Auth.tsx +++ b/packages/ui/src/pages/Auth.tsx @@ -52,39 +52,6 @@ const Auth = () => { const { data } = await signUp(formData.email, formData.password, formData.username, formData.displayName); - // If signing up from an organization context, add user to that organization - if (data?.user && isOrgContext && orgSlug) { - try { - // Get organization ID from slug - const { data: org, error: orgError } = await supabase - .from('organizations') - .select('id') - .eq('slug', orgSlug) - .single(); - - if (orgError) throw orgError; - - if (org) { - // Add user to organization - const { error: memberError } = await supabase - .from('user_organizations') - .insert({ - user_id: data.user.id, - organization_id: org.id, - role: 'member' - }); - - if (memberError) throw memberError; - - toast({ - title: translate('Welcome!'), - description: translate("You've been added to the organization.") - }); - } - } catch (error) { - console.error('Error adding user to organization:', error); - } - } }; const handleSignIn = async (e: React.FormEvent) => { diff --git a/packages/ui/src/pages/AuthZ.tsx b/packages/ui/src/pages/AuthZ.tsx new file mode 100644 index 00000000..83176fd3 --- /dev/null +++ b/packages/ui/src/pages/AuthZ.tsx @@ -0,0 +1,78 @@ +import { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Shield, ArrowRight } from 'lucide-react'; +import { T, translate } from '@/i18n'; +import { useAuth } from 'react-oidc-context'; +import { useNavigate } from 'react-router-dom'; + +const AuthZ = () => { + const auth = useAuth(); + const navigate = useNavigate(); + const [isLoading, setIsLoading] = useState(false); + + // Monitor auth state changes and log them for debugging + useEffect(() => { + console.log("🛡️ [AuthZ] Auth State Update:", { + isAuthenticated: auth.isAuthenticated, + isLoading: auth.isLoading, + hasError: !!auth.error, + errorMsg: auth.error?.message + }); + + if (auth.user) { + console.log("🛡️ [AuthZ] Identity Token Profile:", auth.user.profile); + console.log("🛡️ [AuthZ] Access Token:", auth.user.access_token); + } + + if (auth.isAuthenticated) { + console.log(`🛡️ [AuthZ] Successfully logged in as: ${auth.user?.profile.email || 'Unknown'}. Redirecting to /...`); + navigate('/'); + } + }, [auth.isAuthenticated, auth.isLoading, auth.error, auth.user, navigate]); + + const handleZitadelLogin = async () => { + try { + setIsLoading(true); + await auth.signinRedirect(); + } catch (e) { + console.error(e); + setIsLoading(false); + } + }; + + return ( +
+ + +
+ +
+ + Corporate Login + + + Sign in securely via Zitadel Identity + +
+ +

+ You will be redirected to the secure portal to complete your authentication via Google or company credentials. +

+ + + +
+
+
+ ); +}; + +export default AuthZ; diff --git a/packages/ui/src/pages/PlaygroundVfs.tsx b/packages/ui/src/pages/PlaygroundVfs.tsx new file mode 100644 index 00000000..0989ffe6 --- /dev/null +++ b/packages/ui/src/pages/PlaygroundVfs.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import FileBrowser from '@/apps/filebrowser/FileBrowser'; + +const PlaygroundVfs = () => { + return ( +
+ +
+ ); +}; + +export default PlaygroundVfs;