Revert "fix: resolve chat conversation hanging and stream interruption issues (#1971)"
This reverts commit e68593f22d.
This commit is contained in:
@@ -1,173 +1,28 @@
|
||||
import { json, type MetaFunction } from '@remix-run/cloudflare';
|
||||
import { useLoaderData } from '@remix-run/react';
|
||||
import { ClientOnly } from 'remix-utils/client-only';
|
||||
import { BaseChat } from '~/components/chat/BaseChat';
|
||||
import { Chat } from '~/components/chat/Chat.client';
|
||||
import { Header } from '~/components/header/Header';
|
||||
import BackgroundRays from '~/components/ui/BackgroundRays';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { providersStore } from '~/lib/stores/settings';
|
||||
import { authStore } from '~/lib/stores/auth';
|
||||
import { useNavigate } from '@remix-run/react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{ title: 'Bolt.gives' },
|
||||
{
|
||||
name: 'description',
|
||||
content: 'Build web applications with AI assistance - Enhanced fork with advanced features',
|
||||
},
|
||||
];
|
||||
return [{ title: 'Bolt' }, { name: 'description', content: 'Talk with Bolt, an AI assistant from StackBlitz' }];
|
||||
};
|
||||
|
||||
export const loader = ({ context }: { context: any }) => {
|
||||
// Check which local providers are configured
|
||||
const configuredProviders: string[] = [];
|
||||
|
||||
// Check Ollama
|
||||
if (context.cloudflare?.env?.OLLAMA_API_BASE_URL || process.env?.OLLAMA_API_BASE_URL) {
|
||||
configuredProviders.push('Ollama');
|
||||
}
|
||||
|
||||
// Check LMStudio
|
||||
if (context.cloudflare?.env?.LMSTUDIO_API_BASE_URL || process.env?.LMSTUDIO_API_BASE_URL) {
|
||||
configuredProviders.push('LMStudio');
|
||||
}
|
||||
|
||||
// Check OpenAILike
|
||||
if (context.cloudflare?.env?.OPENAI_LIKE_API_BASE_URL || process.env?.OPENAI_LIKE_API_BASE_URL) {
|
||||
configuredProviders.push('OpenAILike');
|
||||
}
|
||||
|
||||
return json({ configuredProviders });
|
||||
};
|
||||
export const loader = () => json({});
|
||||
|
||||
/**
|
||||
* Landing page component for Bolt.gives
|
||||
* Enhanced fork with multi-user authentication, advanced features, and provider auto-detection
|
||||
* Landing page component for Bolt
|
||||
* Note: Settings functionality should ONLY be accessed through the sidebar menu.
|
||||
* Do not add settings button/panel to this landing page as it was intentionally removed
|
||||
* to keep the UI clean and consistent with the design system.
|
||||
*/
|
||||
export default function Index() {
|
||||
const data = useLoaderData<{ configuredProviders: string[] }>();
|
||||
const [showMultiUserBanner, setShowMultiUserBanner] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
// Enable configured providers if they haven't been manually configured yet
|
||||
if (data?.configuredProviders && data.configuredProviders.length > 0) {
|
||||
const savedSettings = localStorage.getItem('provider_settings');
|
||||
|
||||
if (!savedSettings) {
|
||||
// No saved settings, so enable the configured providers
|
||||
const currentProviders = providersStore.get();
|
||||
data.configuredProviders.forEach((providerName) => {
|
||||
if (currentProviders[providerName]) {
|
||||
providersStore.setKey(providerName, {
|
||||
...currentProviders[providerName],
|
||||
settings: {
|
||||
...currentProviders[providerName].settings,
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Save to localStorage so this only happens once
|
||||
localStorage.setItem('provider_settings', JSON.stringify(providersStore.get()));
|
||||
}
|
||||
}
|
||||
}, [data?.configuredProviders]);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user is authenticated
|
||||
const authState = authStore.get();
|
||||
|
||||
// Show banner only if not authenticated and hasn't been dismissed
|
||||
const bannerDismissed = localStorage.getItem('multiUserBannerDismissed');
|
||||
|
||||
if (!authState.isAuthenticated && !bannerDismissed) {
|
||||
setTimeout(() => setShowMultiUserBanner(true), 2000);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleActivateMultiUser = () => {
|
||||
navigate('/auth');
|
||||
};
|
||||
|
||||
const handleDismissBanner = () => {
|
||||
setShowMultiUserBanner(false);
|
||||
localStorage.setItem('multiUserBannerDismissed', 'true');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full bg-bolt-elements-background-depth-1">
|
||||
<BackgroundRays />
|
||||
<Header />
|
||||
<ClientOnly fallback={<BaseChat />}>{() => <Chat />}</ClientOnly>
|
||||
|
||||
{/* Optional Multi-User Activation Banner */}
|
||||
<AnimatePresence>
|
||||
{showMultiUserBanner && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="fixed bottom-6 right-6 max-w-sm z-50"
|
||||
>
|
||||
<div className="bg-bolt-elements-background-depth-2 backdrop-blur-xl rounded-xl border border-bolt-elements-borderColor shadow-2xl p-4">
|
||||
<button
|
||||
onClick={handleDismissBanner}
|
||||
className="absolute top-2 right-2 text-bolt-elements-textTertiary hover:text-bolt-elements-textSecondary transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-r from-blue-500 to-purple-600 flex items-center justify-center shadow-lg">
|
||||
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-semibold text-bolt-elements-textPrimary mb-1">
|
||||
Unlock Multi-User Features
|
||||
</h3>
|
||||
<p className="text-xs text-bolt-elements-textSecondary mb-3">
|
||||
Save your projects, personalized settings, and collaborate with workspace isolation.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleActivateMultiUser}
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-gradient-to-r from-blue-500 to-purple-600 text-white hover:from-blue-600 hover:to-purple-700 transition-all shadow-lg hover:shadow-xl"
|
||||
>
|
||||
Activate Now
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDismissBanner}
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-bolt-elements-button-secondary-background text-bolt-elements-button-secondary-text hover:bg-bolt-elements-button-secondary-backgroundHover transition-all"
|
||||
>
|
||||
Continue as Guest
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,351 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from '@remix-run/react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { authStore } from '~/lib/stores/auth';
|
||||
import { ProtectedRoute } from '~/components/auth/ProtectedRoute';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
firstName: string;
|
||||
avatar?: string;
|
||||
createdAt: string;
|
||||
lastLogin?: string;
|
||||
}
|
||||
|
||||
export default function UserManagement() {
|
||||
const navigate = useNavigate();
|
||||
const authState = useStore(authStore);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/users', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${authState.token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as { users: User[] };
|
||||
setUsers(data.users);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch users:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUser = async () => {
|
||||
if (!selectedUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleting(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/users/${selectedUser.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${authState.token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setUsers(users.filter((u) => u.id !== selectedUser.id));
|
||||
setShowDeleteModal(false);
|
||||
setSelectedUser(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete user:', error);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredUsers = users.filter(
|
||||
(user) =>
|
||||
user.username.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
user.firstName.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen bg-bolt-elements-background-depth-1">
|
||||
{/* Header */}
|
||||
<header className="border-b border-bolt-elements-borderColor bg-bolt-elements-background-depth-2">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="p-2 rounded-lg hover:bg-bolt-elements-background-depth-3 transition-colors"
|
||||
>
|
||||
<span className="i-ph:arrow-left text-xl text-bolt-elements-textPrimary" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-bolt-elements-textPrimary">User Management</h1>
|
||||
<p className="text-sm text-bolt-elements-textSecondary">Manage system users</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search users..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className={classNames(
|
||||
'w-64 px-4 py-2 pl-10 rounded-lg',
|
||||
'bg-bolt-elements-background-depth-1',
|
||||
'border border-bolt-elements-borderColor',
|
||||
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
||||
'focus:outline-none focus:ring-2 focus:ring-accent-500',
|
||||
)}
|
||||
/>
|
||||
<span className="absolute left-3 top-2.5 i-ph:magnifying-glass text-bolt-elements-textTertiary" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/auth')}
|
||||
className={classNames(
|
||||
'px-4 py-2 rounded-lg',
|
||||
'bg-accent-500 text-white',
|
||||
'hover:bg-accent-600',
|
||||
'transition-colors',
|
||||
'flex items-center gap-2',
|
||||
)}
|
||||
>
|
||||
<span className="i-ph:plus text-lg" />
|
||||
<span>Add User</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* User Stats */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-bolt-elements-background-depth-2 rounded-lg p-4 border border-bolt-elements-borderColor">
|
||||
<p className="text-sm text-bolt-elements-textSecondary mb-1">Total Users</p>
|
||||
<p className="text-2xl font-bold text-bolt-elements-textPrimary">{users.length}</p>
|
||||
</div>
|
||||
<div className="bg-bolt-elements-background-depth-2 rounded-lg p-4 border border-bolt-elements-borderColor">
|
||||
<p className="text-sm text-bolt-elements-textSecondary mb-1">Active Today</p>
|
||||
<p className="text-2xl font-bold text-green-500">
|
||||
{
|
||||
users.filter((u) => {
|
||||
if (!u.lastLogin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lastLogin = new Date(u.lastLogin);
|
||||
const today = new Date();
|
||||
|
||||
return lastLogin.toDateString() === today.toDateString();
|
||||
}).length
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-bolt-elements-background-depth-2 rounded-lg p-4 border border-bolt-elements-borderColor">
|
||||
<p className="text-sm text-bolt-elements-textSecondary mb-1">New This Week</p>
|
||||
<p className="text-2xl font-bold text-blue-500">
|
||||
{
|
||||
users.filter((u) => {
|
||||
const created = new Date(u.createdAt);
|
||||
const weekAgo = new Date();
|
||||
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||
|
||||
return created > weekAgo;
|
||||
}).length
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-bolt-elements-background-depth-2 rounded-lg p-4 border border-bolt-elements-borderColor">
|
||||
<p className="text-sm text-bolt-elements-textSecondary mb-1">Storage Used</p>
|
||||
<p className="text-2xl font-bold text-bolt-elements-textPrimary">0 MB</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User List */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-8">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<span className="i-svg-spinners:3-dots-scale text-2xl text-bolt-elements-textPrimary" />
|
||||
</div>
|
||||
) : filteredUsers.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<span className="i-ph:users text-4xl text-bolt-elements-textTertiary mb-4" />
|
||||
<p className="text-bolt-elements-textSecondary">
|
||||
{searchQuery ? 'No users found matching your search' : 'No users yet'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<AnimatePresence>
|
||||
{filteredUsers.map((user, index) => (
|
||||
<motion.div
|
||||
key={user.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.3, delay: index * 0.05 }}
|
||||
className={classNames(
|
||||
'bg-bolt-elements-background-depth-2 rounded-lg p-6',
|
||||
'border border-bolt-elements-borderColor',
|
||||
'hover:shadow-lg transition-all',
|
||||
user.id === authState.user?.id ? 'ring-2 ring-accent-500' : '',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-full bg-bolt-elements-background-depth-3 flex items-center justify-center overflow-hidden border border-bolt-elements-borderColor">
|
||||
{user.avatar ? (
|
||||
<img src={user.avatar} alt={user.firstName} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-lg font-medium text-bolt-elements-textPrimary">
|
||||
{user.firstName[0].toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-bolt-elements-textPrimary">
|
||||
{user.firstName}
|
||||
{user.id === authState.user?.id && (
|
||||
<span className="ml-2 text-xs px-2 py-0.5 rounded-full bg-accent-500/20 text-accent-500">
|
||||
You
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-sm text-bolt-elements-textSecondary">@{user.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
className="p-1 rounded hover:bg-bolt-elements-background-depth-3 transition-colors"
|
||||
title="Edit user"
|
||||
>
|
||||
<span className="i-ph:pencil text-bolt-elements-textSecondary" />
|
||||
</button>
|
||||
{user.id !== authState.user?.id && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedUser(user);
|
||||
setShowDeleteModal(true);
|
||||
}}
|
||||
className="p-1 rounded hover:bg-red-500/10 transition-colors"
|
||||
title="Delete user"
|
||||
>
|
||||
<span className="i-ph:trash text-red-500" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2 text-bolt-elements-textSecondary">
|
||||
<span className="i-ph:calendar-blank" />
|
||||
<span>Joined {new Date(user.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
{user.lastLogin && (
|
||||
<div className="flex items-center gap-2 text-bolt-elements-textSecondary">
|
||||
<span className="i-ph:clock" />
|
||||
<span>Last active {new Date(user.lastLogin).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<AnimatePresence>
|
||||
{showDeleteModal && selectedUser && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
|
||||
onClick={() => !deleting && setShowDeleteModal(false)}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.95 }}
|
||||
animate={{ scale: 1 }}
|
||||
exit={{ scale: 0.95 }}
|
||||
className="bg-bolt-elements-background-depth-2 rounded-lg p-6 max-w-md w-full border border-bolt-elements-borderColor"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-xl font-bold text-bolt-elements-textPrimary mb-2">Delete User</h2>
|
||||
<p className="text-bolt-elements-textSecondary mb-6">
|
||||
Are you sure you want to delete{' '}
|
||||
<span className="font-medium text-bolt-elements-textPrimary">@{selectedUser.username}</span>? This
|
||||
action cannot be undone and will permanently remove all user data.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowDeleteModal(false)}
|
||||
disabled={deleting}
|
||||
className={classNames(
|
||||
'flex-1 px-4 py-2 rounded-lg',
|
||||
'border border-bolt-elements-borderColor',
|
||||
'text-bolt-elements-textPrimary',
|
||||
'hover:bg-bolt-elements-background-depth-3',
|
||||
'disabled:opacity-50',
|
||||
'transition-colors',
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteUser}
|
||||
disabled={deleting}
|
||||
className={classNames(
|
||||
'flex-1 px-4 py-2 rounded-lg',
|
||||
'bg-red-500 text-white',
|
||||
'hover:bg-red-600',
|
||||
'disabled:opacity-50',
|
||||
'transition-colors',
|
||||
'flex items-center justify-center gap-2',
|
||||
)}
|
||||
>
|
||||
{deleting ? (
|
||||
<>
|
||||
<span className="i-svg-spinners:3-dots-scale" />
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="i-ph:trash" />
|
||||
Delete User
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import type { ActionFunctionArgs } from '@remix-run/cloudflare';
|
||||
import { json } from '@remix-run/cloudflare';
|
||||
import { getUserByUsername, updateLastLogin, logSecurityEvent } from '~/lib/utils/fileUserStorage';
|
||||
import { verifyPassword, generateToken } from '~/lib/utils/crypto';
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
if (request.method !== 'POST') {
|
||||
return json({ error: 'Method not allowed' }, { status: 405 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = (await request.json()) as { username?: string; password?: string };
|
||||
const { username, password } = body;
|
||||
|
||||
if (!username || !password) {
|
||||
return json({ error: 'Username and password are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get user from storage
|
||||
const user = await getUserByUsername(username);
|
||||
|
||||
if (!user) {
|
||||
// Log failed login attempt
|
||||
await logSecurityEvent({
|
||||
timestamp: new Date().toISOString(),
|
||||
username,
|
||||
action: 'failed_login',
|
||||
details: `Failed login attempt for non-existent user: ${username}`,
|
||||
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined,
|
||||
});
|
||||
|
||||
return json({ error: 'Invalid username or password' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValid = await verifyPassword(password, user.passwordHash);
|
||||
|
||||
if (!isValid) {
|
||||
// Log failed login attempt
|
||||
await logSecurityEvent({
|
||||
timestamp: new Date().toISOString(),
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
action: 'failed_login',
|
||||
details: `Failed login attempt with incorrect password`,
|
||||
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined,
|
||||
});
|
||||
|
||||
return json({ error: 'Invalid username or password' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Update last login time
|
||||
await updateLastLogin(user.id);
|
||||
|
||||
// Generate JWT token
|
||||
const token = generateToken({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
firstName: user.firstName,
|
||||
});
|
||||
|
||||
// Log successful login
|
||||
await logSecurityEvent({
|
||||
timestamp: new Date().toISOString(),
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
action: 'login',
|
||||
details: 'Successful login',
|
||||
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined,
|
||||
});
|
||||
|
||||
// Return user data without password
|
||||
const { passwordHash, ...userWithoutPassword } = user;
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
user: userWithoutPassword,
|
||||
token,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
|
||||
await logSecurityEvent({
|
||||
timestamp: new Date().toISOString(),
|
||||
action: 'error',
|
||||
details: `Login error: ${error}`,
|
||||
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined,
|
||||
});
|
||||
|
||||
return json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { ActionFunctionArgs } from '@remix-run/cloudflare';
|
||||
import { json } from '@remix-run/cloudflare';
|
||||
import { verifyToken } from '~/lib/utils/crypto';
|
||||
import { logSecurityEvent } from '~/lib/utils/fileUserStorage';
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
if (request.method !== 'POST') {
|
||||
return json({ error: 'Method not allowed' }, { status: 405 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Get token from Authorization header
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
const token = authHeader.substring(7);
|
||||
const payload = verifyToken(token);
|
||||
|
||||
if (payload) {
|
||||
// Log logout event
|
||||
await logSecurityEvent({
|
||||
timestamp: new Date().toISOString(),
|
||||
userId: payload.userId,
|
||||
username: payload.username,
|
||||
action: 'logout',
|
||||
details: 'User logged out',
|
||||
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
return json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import type { ActionFunctionArgs } from '@remix-run/cloudflare';
|
||||
import { json } from '@remix-run/cloudflare';
|
||||
import { createUser, getUserByUsername, logSecurityEvent } from '~/lib/utils/fileUserStorage';
|
||||
import { validatePassword, generateToken } from '~/lib/utils/crypto';
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
if (request.method !== 'POST') {
|
||||
return json({ error: 'Method not allowed' }, { status: 405 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = (await request.json()) as {
|
||||
username?: string;
|
||||
password?: string;
|
||||
firstName?: string;
|
||||
avatar?: string;
|
||||
};
|
||||
const { username, password, firstName, avatar } = body;
|
||||
|
||||
// Validate required fields
|
||||
if (!username || !password || !firstName) {
|
||||
return json({ error: 'Username, password, and first name are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate username format
|
||||
if (!/^[a-zA-Z0-9_]{3,20}$/.test(username)) {
|
||||
return json(
|
||||
{
|
||||
error: 'Username must be 3-20 characters and contain only letters, numbers, and underscores',
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate password strength
|
||||
const passwordValidation = validatePassword(password);
|
||||
|
||||
if (!passwordValidation.valid) {
|
||||
return json({ error: passwordValidation.errors.join('. ') }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if username already exists
|
||||
const existingUser = await getUserByUsername(username);
|
||||
|
||||
if (existingUser) {
|
||||
return json({ error: 'Username already exists' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Create new user
|
||||
const user = await createUser(username, password, firstName, avatar);
|
||||
|
||||
if (!user) {
|
||||
return json({ error: 'Failed to create user' }, { status: 500 });
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
const token = generateToken({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
firstName: user.firstName,
|
||||
});
|
||||
|
||||
// Log successful signup
|
||||
await logSecurityEvent({
|
||||
timestamp: new Date().toISOString(),
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
action: 'signup',
|
||||
details: 'New user registration',
|
||||
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined,
|
||||
});
|
||||
|
||||
// Return user data without password
|
||||
const { passwordHash, ...userWithoutPassword } = user;
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
user: userWithoutPassword,
|
||||
token,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Signup error:', error);
|
||||
|
||||
await logSecurityEvent({
|
||||
timestamp: new Date().toISOString(),
|
||||
action: 'error',
|
||||
details: `Signup error: ${error}`,
|
||||
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined,
|
||||
});
|
||||
|
||||
return json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import type { ActionFunctionArgs } from '@remix-run/cloudflare';
|
||||
import { json } from '@remix-run/cloudflare';
|
||||
import { verifyToken } from '~/lib/utils/crypto';
|
||||
import { getUserById } from '~/lib/utils/fileUserStorage';
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
if (request.method !== 'POST') {
|
||||
return json({ error: 'Method not allowed' }, { status: 405 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Get token from Authorization header
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return json({ error: 'No token provided' }, { status: 401 });
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const payload = verifyToken(token);
|
||||
|
||||
if (!payload) {
|
||||
return json({ error: 'Invalid token' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get user from storage
|
||||
const user = await getUserById(payload.userId);
|
||||
|
||||
if (!user) {
|
||||
return json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Return user data without password
|
||||
const { passwordHash, ...userWithoutPassword } = user;
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
user: userWithoutPassword,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Token verification error:', error);
|
||||
return json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import { createSummary } from '~/lib/.server/llm/create-summary';
|
||||
import { extractPropertiesFromMessage } from '~/lib/.server/llm/utils';
|
||||
import type { DesignScheme } from '~/types/design-scheme';
|
||||
import { MCPService } from '~/lib/services/mcpService';
|
||||
import { StreamRecoveryManager } from '~/lib/.server/llm/stream-recovery';
|
||||
|
||||
export async function action(args: ActionFunctionArgs) {
|
||||
return chatAction(args);
|
||||
@@ -75,22 +74,6 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
|
||||
const encoder: TextEncoder = new TextEncoder();
|
||||
let progressCounter: number = 1;
|
||||
|
||||
// Initialize stream recovery manager
|
||||
const recovery = new StreamRecoveryManager({
|
||||
maxRetries: 3,
|
||||
retryDelay: 2000,
|
||||
timeout: 45000, // 45 seconds timeout
|
||||
onTimeout: () => {
|
||||
logger.warn('Stream timeout detected - attempting recovery');
|
||||
},
|
||||
onRetry: (attempt) => {
|
||||
logger.info(`Stream recovery attempt ${attempt}`);
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Stream error in recovery:', error);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const mcpService = MCPService.getInstance();
|
||||
const totalMessageContent = messages.reduce((acc, message) => acc + message.content, '');
|
||||
@@ -330,77 +313,28 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
recovery.startMonitoring();
|
||||
for await (const part of result.fullStream) {
|
||||
if (part.type === 'error') {
|
||||
const error: any = part.error;
|
||||
logger.error('Streaming error:', error);
|
||||
|
||||
let lastActivityTime = Date.now();
|
||||
const activityCheckInterval = 5000; // Check every 5 seconds
|
||||
|
||||
// Set up activity monitoring
|
||||
const activityChecker = setInterval(() => {
|
||||
const timeSinceLastActivity = Date.now() - lastActivityTime;
|
||||
|
||||
if (timeSinceLastActivity > 30000) {
|
||||
logger.warn(`No stream activity for ${timeSinceLastActivity}ms`);
|
||||
|
||||
// Attempt to recover if stream appears stuck
|
||||
recovery.attemptRecovery();
|
||||
// Enhanced error handling for common streaming issues
|
||||
if (error.message?.includes('Invalid JSON response')) {
|
||||
logger.error('Invalid JSON response detected - likely malformed API response');
|
||||
} else if (error.message?.includes('token')) {
|
||||
logger.error('Token-related error detected - possible token limit exceeded');
|
||||
}
|
||||
}, activityCheckInterval);
|
||||
|
||||
for await (const part of result.fullStream) {
|
||||
// Record activity
|
||||
lastActivityTime = Date.now();
|
||||
recovery.recordActivity();
|
||||
|
||||
if (part.type === 'error') {
|
||||
const error: any = part.error;
|
||||
logger.error('Streaming error:', error);
|
||||
|
||||
// Enhanced error handling for common streaming issues
|
||||
if (error.message?.includes('Invalid JSON response')) {
|
||||
logger.error('Invalid JSON response detected - likely malformed API response');
|
||||
} else if (error.message?.includes('token')) {
|
||||
logger.error('Token-related error detected - possible token limit exceeded');
|
||||
}
|
||||
|
||||
// Attempt recovery for certain errors
|
||||
const canRecover = await recovery.handleError(error);
|
||||
|
||||
if (!canRecover) {
|
||||
clearInterval(activityChecker);
|
||||
recovery.stop();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up
|
||||
clearInterval(activityChecker);
|
||||
recovery.stop();
|
||||
} catch (streamError) {
|
||||
logger.error('Fatal stream error:', streamError);
|
||||
recovery.stop();
|
||||
throw streamError;
|
||||
}
|
||||
})();
|
||||
result.mergeIntoDataStream(dataStream);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
// Stop recovery manager on error
|
||||
recovery.stop();
|
||||
|
||||
// Provide more specific error messages for common issues
|
||||
const errorMessage = error.message || 'Unknown error';
|
||||
|
||||
// Log detailed error for debugging
|
||||
logger.error('Chat API error:', {
|
||||
message: errorMessage,
|
||||
stack: error.stack,
|
||||
code: error.code,
|
||||
});
|
||||
|
||||
if (errorMessage.includes('model') && errorMessage.includes('not found')) {
|
||||
return 'Custom error: Invalid model selected. Please check that the model name is correct and available.';
|
||||
}
|
||||
@@ -426,11 +360,7 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
|
||||
}
|
||||
|
||||
if (errorMessage.includes('network') || errorMessage.includes('timeout')) {
|
||||
return 'Custom error: Network error or timeout. The connection was interrupted. Please try again or switch to a different AI model.';
|
||||
}
|
||||
|
||||
if (errorMessage.includes('stream') || errorMessage.includes('hang')) {
|
||||
return 'Custom error: The conversation stream was interrupted. Please refresh the page and try again.';
|
||||
return 'Custom error: Network error. Please check your internet connection and try again.';
|
||||
}
|
||||
|
||||
return `Custom error: ${errorMessage}`;
|
||||
@@ -473,32 +403,17 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
|
||||
}),
|
||||
);
|
||||
|
||||
// Set up cleanup for recovery manager
|
||||
const cleanupStream = dataStream.pipeThrough(
|
||||
new TransformStream({
|
||||
flush() {
|
||||
recovery.stop();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return new Response(cleanupStream, {
|
||||
return new Response(dataStream, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
Connection: 'keep-alive',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Text-Encoding': 'chunked',
|
||||
'X-Accel-Buffering': 'no', // Disable nginx buffering
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error('Fatal error in chat API:', error);
|
||||
|
||||
// Ensure recovery manager is stopped on error
|
||||
if (typeof recovery !== 'undefined') {
|
||||
recovery.stop();
|
||||
}
|
||||
logger.error(error);
|
||||
|
||||
const errorResponse = {
|
||||
error: true,
|
||||
|
||||
@@ -8,7 +8,6 @@ interface ModelsResponse {
|
||||
modelList: ModelInfo[];
|
||||
providers: ProviderInfo[];
|
||||
defaultProvider: ProviderInfo;
|
||||
configuredProviders?: string[];
|
||||
}
|
||||
|
||||
let cachedProviders: ProviderInfo[] | null = null;
|
||||
@@ -83,28 +82,9 @@ export async function loader({
|
||||
});
|
||||
}
|
||||
|
||||
// Check which local providers are configured in environment
|
||||
const configuredProviders: string[] = [];
|
||||
|
||||
// Check Ollama
|
||||
if (context.cloudflare?.env?.OLLAMA_API_BASE_URL || process.env?.OLLAMA_API_BASE_URL) {
|
||||
configuredProviders.push('Ollama');
|
||||
}
|
||||
|
||||
// Check LMStudio
|
||||
if (context.cloudflare?.env?.LMSTUDIO_API_BASE_URL || process.env?.LMSTUDIO_API_BASE_URL) {
|
||||
configuredProviders.push('LMStudio');
|
||||
}
|
||||
|
||||
// Check OpenAILike
|
||||
if (context.cloudflare?.env?.OPENAI_LIKE_API_BASE_URL || process.env?.OPENAI_LIKE_API_BASE_URL) {
|
||||
configuredProviders.push('OpenAILike');
|
||||
}
|
||||
|
||||
return json<ModelsResponse>({
|
||||
modelList,
|
||||
providers,
|
||||
defaultProvider,
|
||||
configuredProviders,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
/**
|
||||
* Netlify Quick Deploy API Endpoint
|
||||
* Contributed by Keoma Wright
|
||||
*
|
||||
* This endpoint handles quick deployments to Netlify without requiring authentication,
|
||||
* using Netlify's drop API for instant deployment.
|
||||
*/
|
||||
|
||||
import { type ActionFunctionArgs, json } from '@remix-run/cloudflare';
|
||||
import crypto from 'crypto';
|
||||
|
||||
interface QuickDeployRequestBody {
|
||||
files: Record<string, string>;
|
||||
chatId: string;
|
||||
framework?: string;
|
||||
}
|
||||
|
||||
// Use environment variable or fallback to public token for quick deploys
|
||||
const NETLIFY_QUICK_DEPLOY_TOKEN = process.env.NETLIFY_QUICK_DEPLOY_TOKEN || '';
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
try {
|
||||
const { files, chatId, framework } = (await request.json()) as QuickDeployRequestBody;
|
||||
|
||||
if (!files || Object.keys(files).length === 0) {
|
||||
return json({ error: 'No files to deploy' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Generate a unique site name
|
||||
const siteName = `bolt-quick-${chatId.substring(0, 8)}-${Date.now()}`;
|
||||
|
||||
// Prepare files for Netlify Drop API
|
||||
const deployFiles: Record<string, string> = {};
|
||||
|
||||
// Add index.html if it doesn't exist (for static sites)
|
||||
if (!files['/index.html'] && !files['index.html']) {
|
||||
// Check if there's any HTML file
|
||||
const htmlFile = Object.keys(files).find((f) => f.endsWith('.html'));
|
||||
|
||||
if (!htmlFile) {
|
||||
// Create a basic index.html
|
||||
deployFiles['/index.html'] = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${framework || 'Bolt'} App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script>
|
||||
// Check if there's a main.js or app.js
|
||||
const scripts = ${JSON.stringify(Object.keys(files).filter((f) => f.endsWith('.js')))};
|
||||
if (scripts.length > 0) {
|
||||
const script = document.createElement('script');
|
||||
script.src = scripts[0];
|
||||
document.body.appendChild(script);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Process and normalize file paths
|
||||
for (const [filePath, content] of Object.entries(files)) {
|
||||
const normalizedPath = filePath.startsWith('/') ? filePath : '/' + filePath;
|
||||
deployFiles[normalizedPath] = content;
|
||||
}
|
||||
|
||||
// Use Netlify's API to create a new site and deploy
|
||||
let siteId: string | undefined;
|
||||
let deployUrl: string | undefined;
|
||||
|
||||
if (NETLIFY_QUICK_DEPLOY_TOKEN) {
|
||||
// If we have a token, use the authenticated API
|
||||
try {
|
||||
// Create a new site
|
||||
const createSiteResponse = await fetch('https://api.netlify.com/api/v1/sites', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${NETLIFY_QUICK_DEPLOY_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: siteName,
|
||||
custom_domain: null,
|
||||
}),
|
||||
});
|
||||
|
||||
if (createSiteResponse.ok) {
|
||||
const site = (await createSiteResponse.json()) as any;
|
||||
siteId = site.id;
|
||||
|
||||
// Create file digests for deployment
|
||||
const fileDigests: Record<string, string> = {};
|
||||
|
||||
for (const [path, content] of Object.entries(deployFiles)) {
|
||||
const hash = crypto.createHash('sha1').update(content).digest('hex');
|
||||
fileDigests[path] = hash;
|
||||
}
|
||||
|
||||
// Create deployment
|
||||
const deployResponse = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}/deploys`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${NETLIFY_QUICK_DEPLOY_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
files: fileDigests,
|
||||
async: false,
|
||||
draft: false,
|
||||
}),
|
||||
});
|
||||
|
||||
if (deployResponse.ok) {
|
||||
const deploy = (await deployResponse.json()) as any;
|
||||
|
||||
// Upload files
|
||||
for (const [path, content] of Object.entries(deployFiles)) {
|
||||
await fetch(`https://api.netlify.com/api/v1/deploys/${deploy.id}/files${path}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${NETLIFY_QUICK_DEPLOY_TOKEN}`,
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
body: content,
|
||||
});
|
||||
}
|
||||
|
||||
deployUrl = deploy.ssl_url || deploy.url || `https://${siteName}.netlify.app`;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error with authenticated deployment:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to Netlify Drop (no authentication required)
|
||||
if (!deployUrl) {
|
||||
// Create a form data with files
|
||||
const formData = new FormData();
|
||||
|
||||
// Add each file to the form data
|
||||
for (const [path, content] of Object.entries(deployFiles)) {
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const fileName = path.startsWith('/') ? path.substring(1) : path;
|
||||
formData.append('file', blob, fileName);
|
||||
}
|
||||
|
||||
// Deploy using Netlify Drop API (no auth required)
|
||||
const dropResponse = await fetch('https://api.netlify.com/api/v1/sites', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (dropResponse.ok) {
|
||||
const dropData = (await dropResponse.json()) as any;
|
||||
siteId = dropData.id;
|
||||
deployUrl = dropData.ssl_url || dropData.url || `https://${dropData.subdomain}.netlify.app`;
|
||||
} else {
|
||||
// Try alternative deployment method
|
||||
const zipContent = await createZipArchive(deployFiles);
|
||||
|
||||
const zipResponse = await fetch('https://api.netlify.com/api/v1/sites', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
},
|
||||
body: zipContent,
|
||||
});
|
||||
|
||||
if (zipResponse.ok) {
|
||||
const zipData = (await zipResponse.json()) as any;
|
||||
siteId = zipData.id;
|
||||
deployUrl = zipData.ssl_url || zipData.url;
|
||||
} else {
|
||||
throw new Error('Failed to deploy to Netlify');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!deployUrl) {
|
||||
return json({ error: 'Deployment failed - could not get deployment URL' }, { status: 500 });
|
||||
}
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
url: deployUrl,
|
||||
siteId,
|
||||
siteName,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Quick deploy error:', error);
|
||||
return json(
|
||||
{
|
||||
error: error instanceof Error ? error.message : 'Deployment failed',
|
||||
details: error instanceof Error ? error.stack : undefined,
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to create a simple ZIP archive (minimal implementation)
|
||||
async function createZipArchive(files: Record<string, string>): Promise<ArrayBuffer> {
|
||||
// This is a simplified ZIP creation - in production, use a proper ZIP library
|
||||
const encoder = new TextEncoder();
|
||||
const parts: Uint8Array[] = [];
|
||||
|
||||
// For simplicity, we'll create a tar-like format
|
||||
for (const [path, content] of Object.entries(files)) {
|
||||
const pathBytes = encoder.encode(path);
|
||||
const contentBytes = encoder.encode(content);
|
||||
|
||||
// Simple header: path length (4 bytes) + content length (4 bytes)
|
||||
const header = new Uint8Array(8);
|
||||
new DataView(header.buffer).setUint32(0, pathBytes.length, true);
|
||||
new DataView(header.buffer).setUint32(4, contentBytes.length, true);
|
||||
|
||||
parts.push(header);
|
||||
parts.push(pathBytes);
|
||||
parts.push(contentBytes);
|
||||
}
|
||||
|
||||
// Combine all parts
|
||||
const totalLength = parts.reduce((sum, part) => sum + part.length, 0);
|
||||
const result = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
|
||||
for (const part of parts) {
|
||||
result.set(part, offset);
|
||||
offset += part.length;
|
||||
}
|
||||
|
||||
return result.buffer;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import type { ActionFunctionArgs } from '@remix-run/cloudflare';
|
||||
import { json } from '@remix-run/cloudflare';
|
||||
import { verifyToken } from '~/lib/utils/crypto';
|
||||
import { deleteUser } from '~/lib/utils/fileUserStorage';
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
try {
|
||||
const { id } = params;
|
||||
|
||||
if (!id) {
|
||||
return json({ error: 'User ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify authentication
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const payload = verifyToken(token);
|
||||
|
||||
if (!payload) {
|
||||
return json({ error: 'Invalid token' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Prevent users from deleting themselves
|
||||
if (payload.userId === id) {
|
||||
return json({ error: 'Cannot delete your own account' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (request.method === 'DELETE') {
|
||||
// Delete the user
|
||||
const success = await deleteUser(id);
|
||||
|
||||
if (success) {
|
||||
return json({ success: true });
|
||||
} else {
|
||||
return json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
return json({ error: 'Method not allowed' }, { status: 405 });
|
||||
} catch (error) {
|
||||
console.error('User operation error:', error);
|
||||
return json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { LoaderFunctionArgs } from '@remix-run/cloudflare';
|
||||
import { json } from '@remix-run/cloudflare';
|
||||
import { verifyToken } from '~/lib/utils/crypto';
|
||||
import { getAllUsers } from '~/lib/utils/fileUserStorage';
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
try {
|
||||
// Verify authentication
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const payload = verifyToken(token);
|
||||
|
||||
if (!payload) {
|
||||
return json({ error: 'Invalid token' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get all users (without passwords)
|
||||
const users = await getAllUsers();
|
||||
|
||||
return json({ users });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch users:', error);
|
||||
return json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,422 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from '@remix-run/react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { login, signup } from '~/lib/stores/auth';
|
||||
import { validatePassword } from '~/lib/utils/crypto';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
|
||||
export default function AuthPage() {
|
||||
const navigate = useNavigate();
|
||||
const [mode, setMode] = useState<'login' | 'signup'>('login');
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
firstName: '',
|
||||
confirmPassword: '',
|
||||
rememberMe: false,
|
||||
});
|
||||
const [avatar, setAvatar] = useState<string | undefined>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value,
|
||||
}));
|
||||
|
||||
// Clear error for this field
|
||||
setErrors((prev) => ({ ...prev, [name]: '' }));
|
||||
};
|
||||
|
||||
const handleAvatarUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onloadend = () => {
|
||||
setAvatar(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErrors({});
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
if (mode === 'signup') {
|
||||
// Validate form
|
||||
const validationErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.username) {
|
||||
validationErrors.username = 'Username is required';
|
||||
}
|
||||
|
||||
if (!formData.firstName) {
|
||||
validationErrors.firstName = 'First name is required';
|
||||
}
|
||||
|
||||
const passwordValidation = validatePassword(formData.password);
|
||||
|
||||
if (!passwordValidation.valid) {
|
||||
validationErrors.password = passwordValidation.errors[0];
|
||||
}
|
||||
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
validationErrors.confirmPassword = 'Passwords do not match';
|
||||
}
|
||||
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setErrors(validationErrors);
|
||||
setLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await signup(formData.username, formData.password, formData.firstName, avatar);
|
||||
|
||||
if (result.success) {
|
||||
navigate('/');
|
||||
} else {
|
||||
setErrors({ general: result.error || 'Signup failed' });
|
||||
}
|
||||
} else {
|
||||
const result = await login(formData.username, formData.password, formData.rememberMe);
|
||||
|
||||
if (result.success) {
|
||||
navigate('/');
|
||||
} else {
|
||||
setErrors({ general: result.error || 'Invalid username or password' });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setErrors({ general: 'An error occurred. Please try again.' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center relative overflow-hidden">
|
||||
{/* Animated gradient background */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-600 via-purple-600 to-pink-600">
|
||||
<div className="absolute inset-0 bg-black/30" />
|
||||
<motion.div
|
||||
className="absolute inset-0 opacity-30"
|
||||
animate={{
|
||||
background: [
|
||||
'radial-gradient(circle at 20% 80%, #3b82f6 0%, transparent 50%)',
|
||||
'radial-gradient(circle at 80% 20%, #a855f7 0%, transparent 50%)',
|
||||
'radial-gradient(circle at 40% 40%, #ec4899 0%, transparent 50%)',
|
||||
'radial-gradient(circle at 20% 80%, #3b82f6 0%, transparent 50%)',
|
||||
],
|
||||
}}
|
||||
transition={{ duration: 10, repeat: Infinity }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Logo and Title */}
|
||||
<div className="absolute top-8 left-8 z-20">
|
||||
<motion.div initial={{ opacity: 0, y: -20 }} animate={{ opacity: 1, y: 0 }} className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-xl bg-white/10 backdrop-blur flex items-center justify-center">
|
||||
<span className="text-2xl">⚡</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">bolt.diy</h1>
|
||||
<p className="text-sm text-white/70">Multi-User Edition</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Auth Card */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="relative z-10 w-full max-w-md mx-4"
|
||||
>
|
||||
<div className="bg-white/10 backdrop-blur-xl rounded-2xl shadow-2xl border border-white/20 overflow-hidden">
|
||||
{/* Tab Header */}
|
||||
<div className="flex relative bg-white/5">
|
||||
<button
|
||||
onClick={() => setMode('login')}
|
||||
className={classNames(
|
||||
'flex-1 py-4 text-center font-semibold transition-all',
|
||||
mode === 'login'
|
||||
? 'text-white bg-gradient-to-r from-blue-500/20 to-purple-600/20'
|
||||
: 'text-white/70 hover:text-white hover:bg-white/5',
|
||||
)}
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('signup')}
|
||||
className={classNames(
|
||||
'flex-1 py-4 text-center font-semibold transition-all',
|
||||
mode === 'signup'
|
||||
? 'text-white bg-gradient-to-r from-blue-500/20 to-purple-600/20'
|
||||
: 'text-white/70 hover:text-white hover:bg-white/5',
|
||||
)}
|
||||
>
|
||||
Sign Up
|
||||
</button>
|
||||
|
||||
{/* Sliding indicator */}
|
||||
<motion.div
|
||||
className="absolute bottom-0 h-1 bg-gradient-to-r from-blue-500 to-purple-600"
|
||||
initial={false}
|
||||
animate={{
|
||||
x: mode === 'login' ? '0%' : '100%',
|
||||
width: '50%',
|
||||
}}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Form Content */}
|
||||
<div className="p-8">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.form
|
||||
key={mode}
|
||||
initial={{ opacity: 0, x: mode === 'login' ? -20 : 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: mode === 'login' ? 20 : -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Avatar Upload (Signup only) */}
|
||||
{mode === 'signup' && (
|
||||
<div className="flex justify-center">
|
||||
<div className="relative">
|
||||
<div className="w-24 h-24 rounded-full bg-white/20 backdrop-blur flex items-center justify-center overflow-hidden border-2 border-white/30">
|
||||
{avatar ? (
|
||||
<img src={avatar} alt="Avatar" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-3xl text-white/50">👤</span>
|
||||
)}
|
||||
</div>
|
||||
<label className="absolute bottom-0 right-0 w-8 h-8 bg-white/20 backdrop-blur rounded-full flex items-center justify-center cursor-pointer hover:bg-white/30 transition-colors border border-white/30">
|
||||
<span className="text-sm">📷</span>
|
||||
<input type="file" accept="image/*" onChange={handleAvatarUpload} className="hidden" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* First Name (Signup only) */}
|
||||
{mode === 'signup' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-2">First Name</label>
|
||||
<input
|
||||
type="text"
|
||||
name="firstName"
|
||||
value={formData.firstName}
|
||||
onChange={handleInputChange}
|
||||
className={classNames(
|
||||
'w-full px-4 py-3 rounded-lg bg-white/10 backdrop-blur',
|
||||
'border border-white/20 text-white placeholder-white/40',
|
||||
'focus:outline-none focus:ring-2 focus:ring-white/50 focus:border-transparent',
|
||||
'transition-all',
|
||||
errors.firstName && 'border-red-400',
|
||||
)}
|
||||
placeholder="Enter your first name"
|
||||
required
|
||||
/>
|
||||
{errors.firstName && <p className="mt-1 text-sm text-red-300">{errors.firstName}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Username */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-2">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleInputChange}
|
||||
className={classNames(
|
||||
'w-full px-4 py-3 rounded-lg bg-white/10 backdrop-blur',
|
||||
'border border-white/20 text-white placeholder-white/40',
|
||||
'focus:outline-none focus:ring-2 focus:ring-white/50 focus:border-transparent',
|
||||
'transition-all',
|
||||
errors.username && 'border-red-400',
|
||||
)}
|
||||
placeholder="Enter your username"
|
||||
required
|
||||
/>
|
||||
{errors.username && <p className="mt-1 text-sm text-red-300">{errors.username}</p>}
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-2">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
className={classNames(
|
||||
'w-full px-4 py-3 rounded-lg bg-white/10 backdrop-blur',
|
||||
'border border-white/20 text-white placeholder-white/40',
|
||||
'focus:outline-none focus:ring-2 focus:ring-white/50 focus:border-transparent',
|
||||
'transition-all',
|
||||
errors.password && 'border-red-400',
|
||||
)}
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
/>
|
||||
{errors.password && <p className="mt-1 text-sm text-red-300">{errors.password}</p>}
|
||||
{mode === 'signup' && formData.password && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={classNames(
|
||||
'w-2 h-2 rounded-full',
|
||||
formData.password.length >= 8 ? 'bg-green-400' : 'bg-white/30',
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs text-white/60">At least 8 characters</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={classNames(
|
||||
'w-2 h-2 rounded-full',
|
||||
/[A-Z]/.test(formData.password) ? 'bg-green-400' : 'bg-white/30',
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs text-white/60">One uppercase letter</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={classNames(
|
||||
'w-2 h-2 rounded-full',
|
||||
/[a-z]/.test(formData.password) ? 'bg-green-400' : 'bg-white/30',
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs text-white/60">One lowercase letter</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={classNames(
|
||||
'w-2 h-2 rounded-full',
|
||||
/[0-9]/.test(formData.password) ? 'bg-green-400' : 'bg-white/30',
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs text-white/60">One number</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirm Password (Signup only) */}
|
||||
{mode === 'signup' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-2">Confirm Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleInputChange}
|
||||
className={classNames(
|
||||
'w-full px-4 py-3 rounded-lg bg-white/10 backdrop-blur',
|
||||
'border border-white/20 text-white placeholder-white/40',
|
||||
'focus:outline-none focus:ring-2 focus:ring-white/50 focus:border-transparent',
|
||||
'transition-all',
|
||||
errors.confirmPassword && 'border-red-400',
|
||||
)}
|
||||
placeholder="Confirm your password"
|
||||
required
|
||||
/>
|
||||
{errors.confirmPassword && <p className="mt-1 text-sm text-red-300">{errors.confirmPassword}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Remember Me (Login only) */}
|
||||
{mode === 'login' && (
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="rememberMe"
|
||||
id="rememberMe"
|
||||
checked={formData.rememberMe}
|
||||
onChange={handleInputChange}
|
||||
className="w-4 h-4 rounded bg-white/10 border-white/20 text-blue-500 focus:ring-white/50"
|
||||
/>
|
||||
<label htmlFor="rememberMe" className="ml-2 text-sm text-white/70">
|
||||
Remember me for 7 days
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{errors.general && (
|
||||
<div className="p-3 rounded-lg bg-red-500/20 border border-red-500/30">
|
||||
<p className="text-sm text-red-200">{errors.general}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className={classNames(
|
||||
'w-full py-3 rounded-lg font-semibold transition-all',
|
||||
'bg-gradient-to-r from-blue-500 to-purple-600 text-white',
|
||||
'hover:from-blue-600 hover:to-purple-700',
|
||||
'focus:outline-none focus:ring-2 focus:ring-purple-500/50',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
'flex items-center justify-center gap-2',
|
||||
'shadow-lg hover:shadow-xl',
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="i-svg-spinners:3-dots-scale w-5 h-5" />
|
||||
{mode === 'login' ? 'Signing in...' : 'Creating account...'}
|
||||
</>
|
||||
) : mode === 'login' ? (
|
||||
'Sign In'
|
||||
) : (
|
||||
'Create Account'
|
||||
)}
|
||||
</button>
|
||||
</motion.form>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Developer Credit */}
|
||||
<div className="mt-8 pt-6 border-t border-white/10">
|
||||
<p className="text-center text-xs text-white/40">
|
||||
Developed by <span className="text-white/60 font-medium">Keoma Wright</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Continue as Guest */}
|
||||
<div className="pb-6">
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="w-full py-2 text-center text-sm text-white/60 hover:text-white transition-colors"
|
||||
>
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
/>
|
||||
</svg>
|
||||
Continue as Guest
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user