diff --git a/MULTIUSER_DOCUMENTATION.md b/MULTIUSER_DOCUMENTATION.md new file mode 100644 index 0000000000..82a07020b6 --- /dev/null +++ b/MULTIUSER_DOCUMENTATION.md @@ -0,0 +1,512 @@ +# 🚀 bolt.diy Multi-User System Documentation + +**Developer: Keoma Wright** +**Version: 1.0.0** +**Date: 27 August 2025** + +## 📋 Table of Contents +1. [Overview](#overview) +2. [Architecture](#architecture) +3. [Features](#features) +4. [Installation & Setup](#installation--setup) +5. [User Guide](#user-guide) +6. [Admin Guide](#admin-guide) +7. [Security](#security) +8. [API Reference](#api-reference) +9. [Technical Details](#technical-details) +10. [Troubleshooting](#troubleshooting) + +## Overview + +The bolt.diy Multi-User System transforms the single-user bolt.diy application into a comprehensive multi-user platform with isolated workspaces, personalized settings, and robust user management - all without requiring a traditional database. + +### Key Highlights +- ✅ **No Database Required** - File-based storage system +- ✅ **Isolated Workspaces** - Each user has their own chat history and projects +- ✅ **Beautiful UI** - Stunning login/signup pages with glassmorphism design +- ✅ **Avatar Support** - Users can upload custom avatars +- ✅ **Admin Panel** - Comprehensive user management interface +- ✅ **Security** - JWT authentication with bcrypt password hashing +- ✅ **Personalized Experience** - Custom greetings and user preferences + +## Architecture + +### System Components + +``` +┌─────────────────────────────────────────────────┐ +│ Frontend │ +├─────────────────────────────────────────────────┤ +│ Authentication Pages │ Protected Routes │ +│ - Login/Signup │ - Chat Interface │ +│ - Avatar Upload │ - User Management │ +│ │ - Settings │ +├─────────────────────────────────────────────────┤ +│ Authentication Layer │ +│ - JWT Tokens │ - Session Management │ +│ - Auth Store │ - Protected HOCs │ +├─────────────────────────────────────────────────┤ +│ Storage Layer │ +│ File-Based Storage │ User-Specific DBs │ +│ - .users/ │ - IndexedDB per user │ +│ - Security logs │ - Isolated workspaces │ +└─────────────────────────────────────────────────┘ +``` + +### File Structure + +``` +/root/bolt/ +├── .users/ # User data directory (secured) +│ ├── users.json # User registry +│ ├── security.log # Security audit logs +│ └── data/ # User-specific data +│ └── {userId}/ # Individual user directories +├── app/ +│ ├── components/ +│ │ ├── auth/ +│ │ │ ├── LoginForm.tsx +│ │ │ ├── SignupForm.tsx +│ │ │ └── ProtectedRoute.tsx +│ │ ├── chat/ +│ │ │ ├── AuthenticatedChat.tsx +│ │ │ └── WelcomeMessage.tsx +│ │ ├── header/ +│ │ │ └── UserMenu.tsx +│ │ └── admin/ +│ │ └── UserManager.tsx +│ ├── lib/ +│ │ ├── stores/ +│ │ │ └── auth.ts +│ │ ├── utils/ +│ │ │ ├── crypto.ts +│ │ │ └── fileUserStorage.ts +│ │ └── persistence/ +│ │ └── userDb.ts +│ └── routes/ +│ ├── auth.tsx +│ ├── admin.users.tsx +│ └── api.auth.*.ts +``` + +## Features + +### 🔐 Authentication System + +#### Login Page +- Beautiful gradient animated background +- Glassmorphism card design +- Remember me functionality (7-day sessions) +- Smooth tab transitions between login/signup +- Real-time validation feedback + +#### Signup Page +- Avatar upload with preview +- Password strength indicator +- First name for personalization +- Username validation +- Animated form transitions + +### 👤 User Management + +#### User Profile +- Unique user ID generation +- Avatar storage as base64 +- Preferences storage +- Last login tracking +- Creation date tracking + +#### Admin Panel +- User grid with search +- User statistics dashboard +- Delete user with confirmation +- Edit user capabilities +- Activity monitoring + +### 💬 Personalized Chat Experience + +#### Welcome Message +- Personalized greeting: "{First Name}, What would you like to build today?" +- Time-based greetings (morning/afternoon/evening) +- User statistics display +- Example prompts + +#### Chat History Isolation +- User-specific IndexedDB +- Isolated chat sessions +- Personal workspace files +- Settings per user + +### 🎨 UI/UX Enhancements + +#### Design Elements +- Glassmorphism effects +- Animated gradients +- Smooth transitions (Framer Motion) +- Dark/light theme support +- Responsive design + +#### User Menu +- Avatar display +- Quick access to settings +- User management link +- Sign out option +- Member since date + +## Installation & Setup + +### Prerequisites +```bash +# Required packages +pnpm add bcryptjs jsonwebtoken +pnpm add -D @types/bcryptjs @types/jsonwebtoken +``` + +### Initial Setup + +1. **Create user directory** +```bash +mkdir -p .users +chmod 700 .users +``` + +2. **Environment Variables** +```env +JWT_SECRET=your-secure-secret-key-here +``` + +3. **Start the application** +```bash +pnpm run dev +``` + +4. **Access the application** +Navigate to `http://localhost:5173/auth` to create your first account. + +## User Guide + +### Getting Started + +1. **Create an Account** + - Navigate to `/auth` + - Click "Sign Up" tab + - Upload an avatar (optional) + - Enter your details + - Create a strong password + +2. **Login** + - Enter username and password + - Check "Remember me" for persistent sessions + - Click "Sign In" + +3. **Using the Chat** + - Personalized greeting appears + - Your chat history is private + - Settings are saved per user + +4. **Managing Your Profile** + - Click your avatar in the header + - Access settings + - View member information + +## Admin Guide + +### User Management + +1. **Access Admin Panel** + - Click user menu → "Manage Users" + - Or navigate to `/admin/users` + +2. **View Users** + - See all registered users + - View statistics + - Search and filter + +3. **Delete Users** + - Click trash icon + - Confirm deletion + - User data is permanently removed + +4. **Monitor Activity** + - Check security logs + - View last login times + - Track user creation + +### Security Logs + +Security events are logged to `.users/security.log`: +- Login attempts (successful/failed) +- User creation +- User deletion +- Errors + +Example log entry: +```json +{ + "timestamp": "2024-12-27T10:30:45.123Z", + "userId": "user_123456_abc", + "username": "john_doe", + "action": "login", + "details": "Successful login", + "ip": "192.168.1.1" +} +``` + +## Security + +### Password Security +- **Bcrypt hashing** with salt rounds +- **Complexity requirements**: + - Minimum 8 characters + - At least one uppercase letter + - At least one lowercase letter + - At least one number + +### Session Management +- **JWT tokens** with expiration +- **7-day session** option +- **Automatic logout** on expiration +- **Secure cookie storage** + +### File Permissions +- `.users/` directory: `700` (owner only) +- User data files: JSON format +- Security logs: Append-only + +### Best Practices +- Never store plain passwords +- Use environment variables for secrets +- Regular security log reviews +- Implement rate limiting (future) + +## API Reference + +### Authentication Endpoints + +#### POST `/api/auth/login` +```typescript +Request: { + username: string; + password: string; +} + +Response: { + success: boolean; + user?: UserProfile; + token?: string; + error?: string; +} +``` + +#### POST `/api/auth/signup` +```typescript +Request: { + username: string; + password: string; + firstName: string; + avatar?: string; +} + +Response: { + success: boolean; + user?: UserProfile; + token?: string; + error?: string; +} +``` + +#### POST `/api/auth/logout` +```typescript +Headers: { + Authorization: "Bearer {token}" +} + +Response: { + success: boolean; +} +``` + +#### POST `/api/auth/verify` +```typescript +Headers: { + Authorization: "Bearer {token}" +} + +Response: { + success: boolean; + user?: UserProfile; +} +``` + +### User Management Endpoints + +#### GET `/api/users` +Get all users (requires authentication) + +#### DELETE `/api/users/:id` +Delete a specific user (requires authentication) + +## Technical Details + +### Storage System + +#### User Registry (`users.json`) +```json +{ + "users": [ + { + "id": "user_123456_abc", + "username": "john_doe", + "firstName": "John", + "passwordHash": "$2a$10$...", + "avatar": "data:image/png;base64,...", + "createdAt": "2024-12-27T10:00:00.000Z", + "lastLogin": "2024-12-27T15:30:00.000Z", + "preferences": { + "theme": "dark", + "deploySettings": {}, + "githubSettings": {}, + "workspaceConfig": {} + } + } + ] +} +``` + +#### User-Specific IndexedDB +Each user has their own database: `boltHistory_{userId}` +- Chats store +- Snapshots store +- Settings store +- Workspaces store + +### Authentication Flow + +```mermaid +sequenceDiagram + User->>Frontend: Enter credentials + Frontend->>API: POST /api/auth/login + API->>FileStorage: Verify user + API->>Crypto: Verify password + API->>Crypto: Generate JWT + API->>SecurityLog: Log attempt + API->>Frontend: Return token + user + Frontend->>AuthStore: Save state + Frontend->>Cookie: Store token + Frontend->>Chat: Redirect to chat +``` + +### Workspace Isolation + +Each user's workspace is completely isolated: +1. **Chat History** - Stored in user-specific IndexedDB +2. **Settings** - LocalStorage with user prefix +3. **Files** - Virtual file system per user +4. **Deploy Settings** - User-specific configurations + +## Troubleshooting + +### Common Issues + +#### Cannot Login +- Verify username/password +- Check security logs +- Ensure `.users/` directory exists + +#### Session Expired +- Re-login required +- Use "Remember me" for longer sessions + +#### User Data Not Loading +- Check browser IndexedDB +- Verify user ID in auth store +- Clear browser cache if needed + +#### Avatar Not Displaying +- Check file size (max 2MB recommended) +- Verify base64 encoding +- Test with different image formats + +### Debug Mode + +Enable debug logging: +```javascript +// In browser console +localStorage.setItem('DEBUG', 'true'); +``` + +View security logs: +```bash +tail -f .users/security.log +``` + +### Recovery + +#### Reset User Password +Currently requires manual intervention: +1. Generate new hash using bcrypt +2. Update users.json +3. Restart application + +#### Restore Deleted User +If backup exists: +1. Restore from users.json backup +2. Recreate user data directory +3. Restore IndexedDB if available + +## Future Enhancements + +### Planned Features +- [ ] Password reset via email +- [ ] Two-factor authentication +- [ ] User roles and permissions +- [ ] Team workspaces +- [ ] Usage analytics +- [ ] Export/import user data +- [ ] Social login integration +- [ ] Rate limiting +- [ ] Session management UI +- [ ] Audit trail viewer + +### Performance Optimizations +- [ ] Database indexing strategies +- [ ] Lazy loading user data +- [ ] Caching layer +- [ ] CDN for avatars + +## Contributing + +This system was developed by **Keoma Wright** as an enhancement to the bolt.diy project. + +### Development Guidelines +1. Maintain backward compatibility +2. Follow existing code patterns +3. Add tests for new features +4. Update documentation +5. Consider security implications + +### Testing +```bash +# Run tests +pnpm test + +# Type checking +pnpm typecheck + +# Linting +pnpm lint +``` + +## License + +This multi-user system is an extension of the bolt.diy project and follows the same license terms. + +## Credits + +**Developer**: Keoma Wright +**Project**: bolt.diy Multi-User Edition +**Year**: 2025 + +--- + +*This documentation provides a comprehensive guide to the bolt.diy Multi-User System. For questions or issues, please contact the developer or submit an issue to the repository.* \ No newline at end of file diff --git a/app/components/@settings/tabs/connections/NetlifyConnection.tsx b/app/components/@settings/tabs/connections/NetlifyConnection.tsx index 2bd95f4fd2..ccba7dac1e 100644 --- a/app/components/@settings/tabs/connections/NetlifyConnection.tsx +++ b/app/components/@settings/tabs/connections/NetlifyConnection.tsx @@ -3,7 +3,8 @@ import { toast } from 'react-toastify'; import { classNames } from '~/utils/classNames'; import { useStore } from '@nanostores/react'; import { netlifyConnection, updateNetlifyConnection, initializeNetlifyConnection } from '~/lib/stores/netlify'; -import type { NetlifySite, NetlifyDeploy, NetlifyBuild, NetlifyUser } from '~/types/netlify'; +import type { NetlifySite, NetlifyDeploy, NetlifyBuild } from '~/types/netlify'; +import { NetlifyQuickConnect } from './NetlifyQuickConnect'; import { CloudIcon, BuildingLibraryIcon, @@ -43,7 +44,6 @@ interface SiteAction { export default function NetlifyConnection() { const connection = useStore(netlifyConnection); - const [tokenInput, setTokenInput] = useState(''); const [fetchingStats, setFetchingStats] = useState(false); const [sites, setSites] = useState([]); const [deploys, setDeploys] = useState([]); @@ -53,7 +53,6 @@ export default function NetlifyConnection() { const [isStatsOpen, setIsStatsOpen] = useState(false); const [activeSiteIndex, setActiveSiteIndex] = useState(0); const [isActionLoading, setIsActionLoading] = useState(false); - const [isConnecting, setIsConnecting] = useState(false); // Add site actions const siteActions: SiteAction[] = [ @@ -160,46 +159,6 @@ export default function NetlifyConnection() { } }, [connection]); - const handleConnect = async () => { - if (!tokenInput) { - toast.error('Please enter a Netlify API token'); - return; - } - - setIsConnecting(true); - - try { - const response = await fetch('https://api.netlify.com/api/v1/user', { - headers: { - Authorization: `Bearer ${tokenInput}`, - }, - }); - - if (!response.ok) { - throw new Error(`HTTP error! Status: ${response.status}`); - } - - const userData = (await response.json()) as NetlifyUser; - - // Update the connection store - updateNetlifyConnection({ - user: userData, - token: tokenInput, - }); - - toast.success('Connected to Netlify successfully'); - - // Fetch stats after successful connection - fetchNetlifyStats(tokenInput); - } catch (error) { - console.error('Error connecting to Netlify:', error); - toast.error(`Failed to connect to Netlify: ${error instanceof Error ? error.message : 'Unknown error'}`); - } finally { - setIsConnecting(false); - setTokenInput(''); - } - }; - const handleDisconnect = () => { // Clear from localStorage localStorage.removeItem('netlify_connection'); @@ -649,59 +608,15 @@ export default function NetlifyConnection() { {!connection.user ? (
- - setTokenInput(e.target.value)} - placeholder="Enter your Netlify API token" - className={classNames( - 'w-full px-3 py-2 rounded-lg text-sm', - 'bg-[#F8F8F8] dark:bg-[#1A1A1A]', - 'border border-[#E5E5E5] dark:border-[#333333]', - 'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary', - 'focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive', - 'disabled:opacity-50', - )} + { + // Fetch stats after successful connection + if (connection.token) { + fetchNetlifyStats(connection.token); + } + }} + showInstructions={true} /> -
- - Get your token -
- -
-
- -
) : (
diff --git a/app/components/@settings/tabs/connections/NetlifyQuickConnect.tsx b/app/components/@settings/tabs/connections/NetlifyQuickConnect.tsx new file mode 100644 index 0000000000..b2f10c1e6c --- /dev/null +++ b/app/components/@settings/tabs/connections/NetlifyQuickConnect.tsx @@ -0,0 +1,226 @@ +import React, { useState } from 'react'; +import { toast } from 'react-toastify'; +import { updateNetlifyConnection } from '~/lib/stores/netlify'; +import { classNames } from '~/utils/classNames'; + +interface NetlifyQuickConnectProps { + onSuccess?: () => void; + showInstructions?: boolean; +} + +export const NetlifyQuickConnect: React.FC = ({ onSuccess, showInstructions = true }) => { + const [token, setToken] = useState(''); + const [isConnecting, setIsConnecting] = useState(false); + const [showHelp, setShowHelp] = useState(false); + + const handleConnect = async () => { + if (!token.trim()) { + toast.error('Please enter your Netlify API token'); + return; + } + + setIsConnecting(true); + + try { + // Validate token with Netlify API + const response = await fetch('https://api.netlify.com/api/v1/user', { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + throw new Error('Invalid token or authentication failed'); + } + + const userData = (await response.json()) as any; + + // Fetch initial site statistics + const sitesResponse = await fetch('https://api.netlify.com/api/v1/sites', { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + let sites: any[] = []; + + if (sitesResponse.ok) { + sites = (await sitesResponse.json()) as any[]; + } + + // Update the connection store + updateNetlifyConnection({ + user: userData, + token, + stats: { + sites, + totalSites: sites.length, + deploys: [], + builds: [], + lastDeployTime: '', + }, + }); + + toast.success(`Connected to Netlify as ${userData.email || userData.name || 'User'}`); + setToken(''); // Clear the token field + + if (onSuccess) { + onSuccess(); + } + } catch (error) { + console.error('Netlify connection error:', error); + toast.error('Failed to connect to Netlify. Please check your token.'); + } finally { + setIsConnecting(false); + } + }; + + return ( +
+
+
+
+ + {showInstructions && ( + + )} +
+
+ setToken(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && token.trim() && !isConnecting) { + handleConnect(); + } + }} + placeholder="Enter your Netlify API token" + className={classNames( + 'w-full px-3 py-2 pr-10 rounded-lg text-sm', + '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 focus:border-transparent', + 'disabled:opacity-50', + )} + disabled={isConnecting} + /> + {token && ( + + )} +
+
+ + {showHelp && showInstructions && ( +
+
+ +
+

+ Getting your Netlify Personal Access Token: +

+
    +
  1. + 1. + + Go to{' '} + + Netlify Account Settings + + + +
  2. +
  3. + 2. + Navigate to "Applications" → "Personal access tokens" +
  4. +
  5. + 3. + Click "New access token" +
  6. +
  7. + 4. + Give it a descriptive name (e.g., "bolt.diy deployment") +
  8. +
  9. + 5. + Copy the token and paste it above +
  10. +
+
+

+ Note: Keep your token safe! It provides full access to your Netlify account. +

+
+
+
+
+ )} + +
+ + + Get Token + + +
+
+ +
+
+ +
+

Quick Tip

+

+ Once connected, you can deploy any project with a single click directly from the editor! +

+
+
+
+
+ ); +}; diff --git a/app/components/auth/ProtectedRoute.tsx b/app/components/auth/ProtectedRoute.tsx new file mode 100644 index 0000000000..5791cc6602 --- /dev/null +++ b/app/components/auth/ProtectedRoute.tsx @@ -0,0 +1,56 @@ +import { useEffect } from 'react'; +import { useNavigate } from '@remix-run/react'; +import { useStore } from '@nanostores/react'; +import { authStore } from '~/lib/stores/auth'; +import { motion } from 'framer-motion'; + +interface ProtectedRouteProps { + children: React.ReactNode; +} + +export function ProtectedRoute({ children }: ProtectedRouteProps) { + const navigate = useNavigate(); + const authState = useStore(authStore); + + useEffect(() => { + // If not loading and not authenticated, redirect to auth page + if (!authState.loading && !authState.isAuthenticated) { + navigate('/auth'); + } + }, [authState.loading, authState.isAuthenticated, navigate]); + + // Show loading state + if (authState.loading) { + return ( +
+ +
+ +
+

Loading your workspace...

+
+
+ ); + } + + // If not authenticated, don't render children (will redirect) + if (!authState.isAuthenticated) { + return null; + } + + // Render protected content + return <>{children}; +} + +// HOC for protecting pages +export function withAuth

(wrappedComponent: React.ComponentType

) { + const Component = wrappedComponent; + + return function ProtectedComponent(props: P) { + return ( + + + + ); + }; +} diff --git a/app/components/chat/AuthenticatedChat.tsx b/app/components/chat/AuthenticatedChat.tsx new file mode 100644 index 0000000000..e8ce3a0d44 --- /dev/null +++ b/app/components/chat/AuthenticatedChat.tsx @@ -0,0 +1,86 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from '@remix-run/react'; +import { ClientOnly } from 'remix-utils/client-only'; +import { useStore } from '@nanostores/react'; +import { authStore } from '~/lib/stores/auth'; +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 { motion } from 'framer-motion'; +import { UserMenu } from '~/components/header/UserMenu'; + +/** + * Authenticated chat component that ensures user is logged in + */ +export function AuthenticatedChat() { + const navigate = useNavigate(); + const authState = useStore(authStore); + const [isInitialized, setIsInitialized] = useState(false); + + useEffect(() => { + // Check authentication status after component mounts + const checkAuth = async () => { + // Give auth store time to initialize + await new Promise((resolve) => setTimeout(resolve, 100)); + + const state = authStore.get(); + + if (!state.loading) { + if (!state.isAuthenticated) { + navigate('/auth'); + } else { + setIsInitialized(true); + } + } + }; + + checkAuth(); + }, [navigate]); + + useEffect(() => { + // Subscribe to auth changes + const unsubscribe = authStore.subscribe((state) => { + if (!state.loading && !state.isAuthenticated) { + navigate('/auth'); + } + }); + + return () => { + unsubscribe(); + }; + }, [navigate]); + + // Show loading state + if (authState.loading || !isInitialized) { + return ( +

+ +
+ +
+ +
+

Initializing workspace...

+
+
+
+ ); + } + + // If not authenticated, don't render (will redirect) + if (!authState.isAuthenticated) { + return null; + } + + // Render authenticated content with enhanced header + return ( +
+ +
+ +
+ }>{() => } +
+ ); +} diff --git a/app/components/chat/WelcomeMessage.tsx b/app/components/chat/WelcomeMessage.tsx new file mode 100644 index 0000000000..9250e3b873 --- /dev/null +++ b/app/components/chat/WelcomeMessage.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { useStore } from '@nanostores/react'; +import { authStore } from '~/lib/stores/auth'; +import { motion } from 'framer-motion'; + +const EXAMPLE_PROMPTS = [ + { text: 'Create a mobile app about bolt.diy' }, + { text: 'Build a todo app in React using Tailwind' }, + { text: 'Build a simple blog using Astro' }, + { text: 'Create a cookie consent form using Material UI' }, + { text: 'Make a space invaders game' }, + { text: 'Make a Tic Tac Toe game in html, css and js only' }, +]; + +interface WelcomeMessageProps { + sendMessage?: (event: React.UIEvent, messageInput?: string) => void; +} + +export function WelcomeMessage({ sendMessage }: WelcomeMessageProps) { + const authState = useStore(authStore); + const timeOfDay = new Date().getHours(); + + const getGreeting = () => { + if (timeOfDay < 12) { + return 'Good morning'; + } + + if (timeOfDay < 17) { + return 'Good afternoon'; + } + + return 'Good evening'; + }; + + return ( +
+ {/* Personalized Greeting */} + +

+ {getGreeting()}, {authState.user?.firstName || 'Developer'}! +

+

What would you like to build today?

+
+ + {/* Example Prompts */} + +

Try one of these examples to get started:

+
+ {EXAMPLE_PROMPTS.map((examplePrompt, index) => ( + sendMessage?.(event, examplePrompt.text)} + className="border border-bolt-elements-borderColor rounded-full bg-gray-50 hover:bg-gray-100 dark:bg-gray-950 dark:hover:bg-gray-900 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary px-3 py-1 text-xs transition-all hover:scale-105" + > + {examplePrompt.text} + + ))} +
+
+ + {/* User Stats */} + {authState.user && ( + +

+ Logged in as{' '} + @{authState.user.username} +

+
+ )} +
+ ); +} diff --git a/app/components/deploy/DeployButton.tsx b/app/components/deploy/DeployButton.tsx index 6ff62806be..ffc58ada95 100644 --- a/app/components/deploy/DeployButton.tsx +++ b/app/components/deploy/DeployButton.tsx @@ -1,210 +1,29 @@ -import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; +import { useState } from 'react'; import { useStore } from '@nanostores/react'; -import { netlifyConnection } from '~/lib/stores/netlify'; -import { vercelConnection } from '~/lib/stores/vercel'; import { workbenchStore } from '~/lib/stores/workbench'; import { streamingState } from '~/lib/stores/streaming'; -import { classNames } from '~/utils/classNames'; -import { useState } from 'react'; -import { NetlifyDeploymentLink } from '~/components/chat/NetlifyDeploymentLink.client'; -import { VercelDeploymentLink } from '~/components/chat/VercelDeploymentLink.client'; -import { useVercelDeploy } from '~/components/deploy/VercelDeploy.client'; -import { useNetlifyDeploy } from '~/components/deploy/NetlifyDeploy.client'; -import { useGitHubDeploy } from '~/components/deploy/GitHubDeploy.client'; -import { GitHubDeploymentDialog } from '~/components/deploy/GitHubDeploymentDialog'; +import { DeployDialog } from './DeployDialog'; -interface DeployButtonProps { - onVercelDeploy?: () => Promise; - onNetlifyDeploy?: () => Promise; - onGitHubDeploy?: () => Promise; -} - -export const DeployButton = ({ onVercelDeploy, onNetlifyDeploy, onGitHubDeploy }: DeployButtonProps) => { - const netlifyConn = useStore(netlifyConnection); - const vercelConn = useStore(vercelConnection); +export const DeployButton = () => { + const [isDialogOpen, setIsDialogOpen] = useState(false); const [activePreviewIndex] = useState(0); const previews = useStore(workbenchStore.previews); const activePreview = previews[activePreviewIndex]; - const [isDeploying, setIsDeploying] = useState(false); - const [deployingTo, setDeployingTo] = useState<'netlify' | 'vercel' | 'github' | null>(null); const isStreaming = useStore(streamingState); - const { handleVercelDeploy } = useVercelDeploy(); - const { handleNetlifyDeploy } = useNetlifyDeploy(); - const { handleGitHubDeploy } = useGitHubDeploy(); - const [showGitHubDeploymentDialog, setShowGitHubDeploymentDialog] = useState(false); - const [githubDeploymentFiles, setGithubDeploymentFiles] = useState | null>(null); - const [githubProjectName, setGithubProjectName] = useState(''); - - const handleVercelDeployClick = async () => { - setIsDeploying(true); - setDeployingTo('vercel'); - - try { - if (onVercelDeploy) { - await onVercelDeploy(); - } else { - await handleVercelDeploy(); - } - } finally { - setIsDeploying(false); - setDeployingTo(null); - } - }; - - const handleNetlifyDeployClick = async () => { - setIsDeploying(true); - setDeployingTo('netlify'); - - try { - if (onNetlifyDeploy) { - await onNetlifyDeploy(); - } else { - await handleNetlifyDeploy(); - } - } finally { - setIsDeploying(false); - setDeployingTo(null); - } - }; - - const handleGitHubDeployClick = async () => { - setIsDeploying(true); - setDeployingTo('github'); - - try { - if (onGitHubDeploy) { - await onGitHubDeploy(); - } else { - const result = await handleGitHubDeploy(); - - if (result && result.success && result.files) { - setGithubDeploymentFiles(result.files); - setGithubProjectName(result.projectName); - setShowGitHubDeploymentDialog(true); - } - } - } finally { - setIsDeploying(false); - setDeployingTo(null); - } - }; return ( <> -
- - - {isDeploying ? `Deploying to ${deployingTo}...` : 'Deploy'} - - - - - - - {!netlifyConn.user ? 'No Netlify Account Connected' : 'Deploy to Netlify'} - - {netlifyConn.user && } - - - - vercel - {!vercelConn.user ? 'No Vercel Account Connected' : 'Deploy to Vercel'} - {vercelConn.user && } - - - - github - Deploy to GitHub - - - - cloudflare - Deploy to Cloudflare (Coming Soon) - - - -
- - {/* GitHub Deployment Dialog */} - {showGitHubDeploymentDialog && githubDeploymentFiles && ( - setShowGitHubDeploymentDialog(false)} - projectName={githubProjectName} - files={githubDeploymentFiles} - /> - )} + + + setIsDialogOpen(false)} /> ); }; diff --git a/app/components/deploy/DeployDialog.tsx b/app/components/deploy/DeployDialog.tsx new file mode 100644 index 0000000000..169d541eba --- /dev/null +++ b/app/components/deploy/DeployDialog.tsx @@ -0,0 +1,462 @@ +import React, { useState } from 'react'; +import * as RadixDialog from '@radix-ui/react-dialog'; +import { Dialog, DialogTitle, DialogDescription } from '~/components/ui/Dialog'; +import { useStore } from '@nanostores/react'; +import { netlifyConnection, updateNetlifyConnection } from '~/lib/stores/netlify'; +import { vercelConnection } from '~/lib/stores/vercel'; +import { useNetlifyDeploy } from './NetlifyDeploy.client'; +import { useVercelDeploy } from './VercelDeploy.client'; +import { useGitHubDeploy } from './GitHubDeploy.client'; +import { GitHubDeploymentDialog } from './GitHubDeploymentDialog'; +import { toast } from 'react-toastify'; +import { classNames } from '~/utils/classNames'; + +interface DeployDialogProps { + isOpen: boolean; + onClose: () => void; +} + +interface DeployProvider { + id: 'netlify' | 'vercel' | 'github' | 'cloudflare'; + name: string; + iconClass: string; + iconColor?: string; + connected: boolean; + comingSoon?: boolean; + description: string; + features: string[]; +} + +const NetlifyConnectForm: React.FC<{ onSuccess: () => void }> = ({ onSuccess }) => { + const [token, setToken] = useState(''); + const [isConnecting, setIsConnecting] = useState(false); + + const handleConnect = async () => { + if (!token.trim()) { + toast.error('Please enter your Netlify API token'); + return; + } + + setIsConnecting(true); + + try { + // Validate token with Netlify API + const response = await fetch('https://api.netlify.com/api/v1/user', { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + throw new Error('Invalid token or authentication failed'); + } + + const userData = (await response.json()) as any; + + // Update the connection store + updateNetlifyConnection({ + user: userData, + token, + }); + + toast.success(`Connected to Netlify as ${userData.email || userData.name || 'User'}`); + onSuccess(); + } catch (error) { + console.error('Netlify connection error:', error); + toast.error('Failed to connect to Netlify. Please check your token.'); + } finally { + setIsConnecting(false); + } + }; + + return ( +
+
+

Connect to Netlify

+

+ To deploy your project to Netlify, you need to connect your account using a Personal Access Token. +

+
+ +
+
+ + setToken(e.target.value)} + placeholder="Enter your Netlify API token" + className={classNames( + 'w-full px-3 py-2 rounded-lg text-sm', + '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 focus:border-transparent', + 'disabled:opacity-50', + )} + disabled={isConnecting} + /> +
+ + + +
+

How to get your token:

+
    +
  1. Go to your Netlify account settings
  2. +
  3. Navigate to "Applications" → "Personal access tokens"
  4. +
  5. Click "New access token"
  6. +
  7. Give it a descriptive name (e.g., "bolt.diy deployment")
  8. +
  9. Copy the token and paste it here
  10. +
+
+ +
+ +
+
+
+ ); +}; + +export const DeployDialog: React.FC = ({ isOpen, onClose }) => { + const netlifyConn = useStore(netlifyConnection); + const vercelConn = useStore(vercelConnection); + const [selectedProvider, setSelectedProvider] = useState<'netlify' | 'vercel' | 'github' | null>(null); + const [isDeploying, setIsDeploying] = useState(false); + const [showGitHubDialog, setShowGitHubDialog] = useState(false); + const [githubFiles, setGithubFiles] = useState | null>(null); + const [githubProjectName, setGithubProjectName] = useState(''); + const { handleNetlifyDeploy } = useNetlifyDeploy(); + const { handleVercelDeploy } = useVercelDeploy(); + const { handleGitHubDeploy } = useGitHubDeploy(); + + const providers: DeployProvider[] = [ + { + id: 'netlify', + name: 'Netlify', + iconClass: 'i-simple-icons:netlify', + iconColor: 'text-[#00C7B7]', + connected: !!netlifyConn.user, + description: 'Deploy your site with automatic SSL, global CDN, and continuous deployment', + features: [ + 'Automatic SSL certificates', + 'Global CDN', + 'Instant rollbacks', + 'Deploy previews', + 'Form handling', + 'Serverless functions', + ], + }, + { + id: 'vercel', + name: 'Vercel', + iconClass: 'i-simple-icons:vercel', + connected: !!vercelConn.user, + description: 'Deploy with the platform built for frontend developers', + features: [ + 'Zero-config deployments', + 'Edge Functions', + 'Analytics', + 'Web Vitals monitoring', + 'Preview deployments', + 'Automatic HTTPS', + ], + }, + { + id: 'github', + name: 'GitHub', + iconClass: 'i-simple-icons:github', + connected: true, // GitHub doesn't require separate auth + description: 'Deploy to GitHub Pages or create a repository', + features: [ + 'Free hosting with GitHub Pages', + 'Version control integration', + 'Collaborative development', + 'Actions & Workflows', + 'Issue tracking', + 'Pull requests', + ], + }, + { + id: 'cloudflare', + name: 'Cloudflare Pages', + iconClass: 'i-simple-icons:cloudflare', + iconColor: 'text-[#F38020]', + connected: false, + comingSoon: true, + description: "Deploy on Cloudflare's global network", + features: [ + 'Unlimited bandwidth', + 'DDoS protection', + 'Web Analytics', + 'Edge Workers', + 'Custom domains', + 'Automatic builds', + ], + }, + ]; + + const handleDeploy = async (provider: 'netlify' | 'vercel' | 'github') => { + setIsDeploying(true); + + try { + let success = false; + + if (provider === 'netlify') { + success = await handleNetlifyDeploy(); + } else if (provider === 'vercel') { + success = await handleVercelDeploy(); + } else if (provider === 'github') { + const result = await handleGitHubDeploy(); + + if (result && typeof result === 'object' && result.success && result.files) { + setGithubFiles(result.files); + setGithubProjectName(result.projectName); + setShowGitHubDialog(true); + onClose(); + + return; + } + + success = result && typeof result === 'object' ? result.success : false; + } + + if (success) { + toast.success( + `Successfully deployed to ${provider === 'netlify' ? 'Netlify' : provider === 'vercel' ? 'Vercel' : 'GitHub'}`, + ); + onClose(); + } + } catch (error) { + console.error('Deployment error:', error); + toast.error( + `Failed to deploy to ${provider === 'netlify' ? 'Netlify' : provider === 'vercel' ? 'Vercel' : 'GitHub'}`, + ); + } finally { + setIsDeploying(false); + } + }; + + const renderProviderContent = () => { + if (!selectedProvider) { + return ( +
+ {providers.map((provider) => ( + + ))} +
+ ); + } + + const provider = providers.find((p) => p.id === selectedProvider); + + if (!provider) { + return null; + } + + // If provider is not connected, show connection form + if (!provider.connected) { + if (selectedProvider === 'netlify') { + return ( + { + handleDeploy('netlify'); + }} + /> + ); + } + + // Add Vercel connection form here if needed + return
Vercel connection form coming soon...
; + } + + // If connected, show deployment confirmation + return ( +
+
+ +
+

{provider.name}

+

Ready to deploy to your {provider.name} account

+
+ Connected +
+ +
+

Deployment Features:

+
    + {provider.features.map((feature, index) => ( +
  • + + {feature} +
  • + ))} +
+
+ +
+ + +
+
+ ); + }; + + return ( + <> + !open && onClose()}> + +
+ Deploy Your Project + + Choose a deployment platform to publish your project to the web + + + {renderProviderContent()} + + {!selectedProvider && ( +
+ +
+ )} +
+
+
+ + {/* GitHub Deployment Dialog */} + {showGitHubDialog && githubFiles && ( + setShowGitHubDialog(false)} + projectName={githubProjectName} + files={githubFiles} + /> + )} + + ); +}; diff --git a/app/components/header/Header.tsx b/app/components/header/Header.tsx index 1d509ce82e..ccca5e105e 100644 --- a/app/components/header/Header.tsx +++ b/app/components/header/Header.tsx @@ -5,7 +5,7 @@ import { classNames } from '~/utils/classNames'; import { HeaderActionButtons } from './HeaderActionButtons.client'; import { ChatDescription } from '~/lib/persistence/ChatDescription.client'; -export function Header() { +export function Header({ children }: { children?: React.ReactNode }) { const chat = useStore(chatStore); return ( @@ -37,6 +37,8 @@ export function Header() { )} + {!chat.started &&
} + {children} ); } diff --git a/app/components/header/HeaderActionButtons.client.tsx b/app/components/header/HeaderActionButtons.client.tsx index 5fe19a51dc..212b37277b 100644 --- a/app/components/header/HeaderActionButtons.client.tsx +++ b/app/components/header/HeaderActionButtons.client.tsx @@ -5,6 +5,10 @@ import { streamingState } from '~/lib/stores/streaming'; import { ExportChatButton } from '~/components/chat/chatExportAndImport/ExportChatButton'; import { useChatHistory } from '~/lib/persistence'; import { DeployButton } from '~/components/deploy/DeployButton'; +import { ImportProjectButton } from '~/components/import-project/ImportProjectButton'; +import { authStore } from '~/lib/stores/auth'; +import { useNavigate } from '@remix-run/react'; +import { motion } from 'framer-motion'; interface HeaderActionButtonsProps { chatStarted: boolean; @@ -15,12 +19,50 @@ export function HeaderActionButtons({ chatStarted }: HeaderActionButtonsProps) { const previews = useStore(workbenchStore.previews); const activePreview = previews[activePreviewIndex]; const isStreaming = useStore(streamingState); + const authState = useStore(authStore); + const navigate = useNavigate(); const { exportChat } = useChatHistory(); const shouldShowButtons = !isStreaming && activePreview; return ( -
+
+ {/* Multi-User Button - Only show if not authenticated */} + {!authState.isAuthenticated && ( + navigate('/auth')} + className="relative group px-3 py-1.5 rounded-lg bg-bolt-elements-button-secondary-background hover:bg-bolt-elements-button-secondary-backgroundHover border border-bolt-elements-borderColor transition-all" + title="Activate Multi-User Features" + > +
+ + + + + Multi-User + +
+ + + + +
+
+
+ )} + {chatStarted && shouldShowButtons && } {shouldShowButtons && }
diff --git a/app/components/header/UserMenu.tsx b/app/components/header/UserMenu.tsx new file mode 100644 index 0000000000..a61f30cfaf --- /dev/null +++ b/app/components/header/UserMenu.tsx @@ -0,0 +1,176 @@ +import { useState, useRef, useEffect } from 'react'; +import { useNavigate } from '@remix-run/react'; +import { useStore } from '@nanostores/react'; +import { authStore, logout } from '~/lib/stores/auth'; +import { motion, AnimatePresence } from 'framer-motion'; +import { classNames } from '~/utils/classNames'; + +export function UserMenu() { + const navigate = useNavigate(); + const authState = useStore(authStore); + const [isOpen, setIsOpen] = useState(false); + const menuRef = useRef(null); + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + } + + document.addEventListener('mousedown', handleClickOutside); + + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const handleLogout = async () => { + await logout(); + navigate('/auth'); + }; + + const handleManageUsers = () => { + setIsOpen(false); + navigate('/admin/users'); + }; + + const handleSettings = () => { + setIsOpen(false); + + // Open settings modal or navigate to settings + }; + + if (!authState.isAuthenticated || !authState.user) { + return null; + } + + return ( +
+ {/* User Avatar Button */} + + + {/* Dropdown Menu */} + + {isOpen && ( + + {/* User Info */} +
+
+
+ {authState.user.avatar ? ( + {authState.user.firstName} + ) : ( + + {authState.user.firstName[0].toUpperCase()} + + )} +
+
+

{authState.user.firstName}

+

@{authState.user.username}

+
+
+
+ + {/* Menu Items */} +
+ + + + +
+ + +
+ + {/* Footer */} +
+

+ Member since {new Date(authState.user.createdAt).toLocaleDateString()} +

+
+ + )} + +
+ ); +} diff --git a/app/components/import-project/ImportProjectButton.tsx b/app/components/import-project/ImportProjectButton.tsx new file mode 100644 index 0000000000..b932785197 --- /dev/null +++ b/app/components/import-project/ImportProjectButton.tsx @@ -0,0 +1,72 @@ +import React, { useState, useCallback } from 'react'; +import { ImportProjectDialog } from './ImportProjectDialog'; +import { workbenchStore } from '~/lib/stores/workbench'; +import { toast } from 'react-toastify'; +import { useHotkeys } from 'react-hotkeys-hook'; + +export const ImportProjectButton: React.FC = () => { + const [isDialogOpen, setIsDialogOpen] = useState(false); + + // Add keyboard shortcut + useHotkeys('ctrl+shift+i, cmd+shift+i', (e) => { + e.preventDefault(); + setIsDialogOpen(true); + }); + + const handleImport = useCallback(async (files: Map) => { + try { + console.log('[ImportProject] Starting import of', files.size, 'files'); + + // Add files to workbench + for (const [path, content] of files.entries()) { + // Ensure path starts with / + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + + console.log('[ImportProject] Adding file:', normalizedPath); + + // Add file to workbench file system + workbenchStore.files.setKey(normalizedPath, { + type: 'file', + content, + isBinary: false, + }); + } + + // Open the first file in the editor if any + const firstFile = Array.from(files.keys())[0]; + + if (firstFile) { + const normalizedPath = firstFile.startsWith('/') ? firstFile : `/${firstFile}`; + workbenchStore.setSelectedFile(normalizedPath); + } + + toast.success(`Successfully imported ${files.size} files`, { + position: 'bottom-right', + autoClose: 3000, + }); + + setIsDialogOpen(false); + } catch (error) { + console.error('[ImportProject] Import failed:', error); + toast.error('Failed to import project files', { + position: 'bottom-right', + autoClose: 5000, + }); + } + }, []); + + return ( + <> + + + setIsDialogOpen(false)} onImport={handleImport} /> + + ); +}; diff --git a/app/components/import-project/ImportProjectDialog.tsx b/app/components/import-project/ImportProjectDialog.tsx new file mode 100644 index 0000000000..f58704fe06 --- /dev/null +++ b/app/components/import-project/ImportProjectDialog.tsx @@ -0,0 +1,567 @@ +import React, { useState, useCallback, useRef } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import JSZip from 'jszip'; +import { toast } from 'react-toastify'; +import * as RadixDialog from '@radix-ui/react-dialog'; +import { Dialog, DialogTitle, DialogDescription } from '~/components/ui/Dialog'; +import { classNames } from '~/utils/classNames'; + +interface ImportProjectDialogProps { + isOpen: boolean; + onClose: () => void; + onImport?: (files: Map) => void; +} + +interface FileStructure { + [path: string]: string | ArrayBuffer; +} + +interface ImportStats { + totalFiles: number; + totalSize: number; + fileTypes: Map; + directories: Set; +} + +const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB max per file +const MAX_TOTAL_SIZE = 200 * 1024 * 1024; // 200MB max total + +const IGNORED_PATTERNS = [ + /node_modules\//, + /\.git\//, + /\.next\//, + /dist\//, + /build\//, + /\.cache\//, + /\.vscode\//, + /\.idea\//, + /\.DS_Store$/, + /Thumbs\.db$/, + /\.env\.local$/, + /\.env\.production$/, +]; + +const BINARY_EXTENSIONS = [ + '.png', + '.jpg', + '.jpeg', + '.gif', + '.webp', + '.svg', + '.ico', + '.pdf', + '.zip', + '.tar', + '.gz', + '.rar', + '.mp3', + '.mp4', + '.avi', + '.mov', + '.exe', + '.dll', + '.so', + '.dylib', + '.woff', + '.woff2', + '.ttf', + '.eot', +]; + +export const ImportProjectDialog: React.FC = ({ isOpen, onClose, onImport }) => { + const [isDragging, setIsDragging] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); + const [importProgress, setImportProgress] = useState(0); + const [importStats, setImportStats] = useState(null); + const [selectedFiles, setSelectedFiles] = useState({}); + const [errorMessage, setErrorMessage] = useState(null); + const fileInputRef = useRef(null); + const dropZoneRef = useRef(null); + + const resetState = useCallback(() => { + setSelectedFiles({}); + setImportStats(null); + setImportProgress(0); + setErrorMessage(null); + setIsProcessing(false); + }, []); + + const shouldIgnoreFile = (path: string): boolean => { + return IGNORED_PATTERNS.some((pattern) => pattern.test(path)); + }; + + const isBinaryFile = (filename: string): boolean => { + return BINARY_EXTENSIONS.some((ext) => filename.toLowerCase().endsWith(ext)); + }; + + const formatFileSize = (bytes: number): string => { + if (bytes < 1024) { + return `${bytes} B`; + } + + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(2)} KB`; + } + + return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; + }; + + const processZipFile = async (file: File): Promise => { + const zip = new JSZip(); + const zipData = await zip.loadAsync(file); + const files: FileStructure = {}; + const stats: ImportStats = { + totalFiles: 0, + totalSize: 0, + fileTypes: new Map(), + directories: new Set(), + }; + + const filePromises: Promise[] = []; + + zipData.forEach((relativePath, zipEntry) => { + if (!zipEntry.dir && !shouldIgnoreFile(relativePath)) { + const promise = (async () => { + try { + const content = await zipEntry.async(isBinaryFile(relativePath) ? 'arraybuffer' : 'string'); + files[relativePath] = content; + + stats.totalFiles++; + + // Use a safe method to get uncompressed size + const size = (zipEntry as any)._data?.uncompressedSize || 0; + stats.totalSize += size; + + const ext = relativePath.split('.').pop() || 'unknown'; + stats.fileTypes.set(ext, (stats.fileTypes.get(ext) || 0) + 1); + + const dir = relativePath.substring(0, relativePath.lastIndexOf('/')); + + if (dir) { + stats.directories.add(dir); + } + + setImportProgress((prev) => Math.min(prev + 100 / Object.keys(zipData.files).length, 100)); + } catch (err) { + console.error(`Failed to process ${relativePath}:`, err); + } + })(); + filePromises.push(promise); + } + }); + + await Promise.all(filePromises); + setImportStats(stats); + + return files; + }; + + const processFileList = async (fileList: FileList): Promise => { + const files: FileStructure = {}; + const stats: ImportStats = { + totalFiles: 0, + totalSize: 0, + fileTypes: new Map(), + directories: new Set(), + }; + + let totalSize = 0; + + for (let i = 0; i < fileList.length; i++) { + const file = fileList[i]; + const path = (file as any).webkitRelativePath || file.name; + + if (shouldIgnoreFile(path)) { + continue; + } + + if (file.size > MAX_FILE_SIZE) { + toast.warning(`Skipping ${file.name}: File too large (${formatFileSize(file.size)})`); + continue; + } + + totalSize += file.size; + + if (totalSize > MAX_TOTAL_SIZE) { + toast.error('Total size exceeds 200MB limit'); + break; + } + + try { + const content = await (isBinaryFile(file.name) ? file.arrayBuffer() : file.text()); + + files[path] = content; + stats.totalFiles++; + stats.totalSize += file.size; + + const ext = file.name.split('.').pop() || 'unknown'; + stats.fileTypes.set(ext, (stats.fileTypes.get(ext) || 0) + 1); + + const dir = path.substring(0, path.lastIndexOf('/')); + + if (dir) { + stats.directories.add(dir); + } + + setImportProgress(((i + 1) / fileList.length) * 100); + } catch (err) { + console.error(`Failed to read ${file.name}:`, err); + } + } + + setImportStats(stats); + + return files; + }; + + const handleFileSelect = async (event: React.ChangeEvent) => { + const files = event.target.files; + + if (!files || files.length === 0) { + return; + } + + setIsProcessing(true); + setErrorMessage(null); + setImportProgress(0); + + try { + let processedFiles: FileStructure = {}; + + if (files.length === 1 && files[0].name.endsWith('.zip')) { + processedFiles = await processZipFile(files[0]); + } else { + processedFiles = await processFileList(files); + } + + if (Object.keys(processedFiles).length === 0) { + toast.warning('No valid files found to import'); + setIsProcessing(false); + + return; + } + + setSelectedFiles(processedFiles); + toast.info(`Ready to import ${Object.keys(processedFiles).length} files`); + } catch (error) { + console.error('Error processing files:', error); + setErrorMessage(error instanceof Error ? error.message : 'Failed to process files'); + toast.error('Failed to process files'); + } finally { + setIsProcessing(false); + setImportProgress(0); + } + }; + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + + const files = e.dataTransfer.files; + + if (files.length > 0) { + const input = fileInputRef.current; + + if (input) { + const dataTransfer = new DataTransfer(); + Array.from(files).forEach((file) => dataTransfer.items.add(file)); + input.files = dataTransfer.files; + handleFileSelect({ target: input } as any); + } + } + }, []); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (e.currentTarget === e.target) { + setIsDragging(false); + } + }, []); + + const getFileExtension = (filename: string): string => { + const parts = filename.split('.'); + return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : 'file'; + }; + + const getFileIcon = (filename: string): string => { + const ext = getFileExtension(filename); + const iconMap: { [key: string]: string } = { + js: 'i-vscode-icons:file-type-js', + jsx: 'i-vscode-icons:file-type-reactjs', + ts: 'i-vscode-icons:file-type-typescript', + tsx: 'i-vscode-icons:file-type-reactts', + css: 'i-vscode-icons:file-type-css', + scss: 'i-vscode-icons:file-type-scss', + html: 'i-vscode-icons:file-type-html', + json: 'i-vscode-icons:file-type-json', + md: 'i-vscode-icons:file-type-markdown', + py: 'i-vscode-icons:file-type-python', + vue: 'i-vscode-icons:file-type-vue', + svg: 'i-vscode-icons:file-type-svg', + git: 'i-vscode-icons:file-type-git', + folder: 'i-vscode-icons:default-folder', + }; + + return iconMap[ext] || 'i-vscode-icons:default-file'; + }; + + const handleImportClick = useCallback(async () => { + if (Object.keys(selectedFiles).length === 0) { + return; + } + + setIsProcessing(true); + + try { + const fileMap = new Map(); + + for (const [path, content] of Object.entries(selectedFiles)) { + if (typeof content === 'string') { + fileMap.set(path, content); + } else if (content instanceof ArrayBuffer) { + // Convert ArrayBuffer to base64 string for binary files + const bytes = new Uint8Array(content); + const binary = String.fromCharCode(...bytes); + const base64 = btoa(binary); + fileMap.set(path, base64); + } + } + + if (onImport) { + // Use the provided onImport callback + await onImport(fileMap); + } + + toast.success(`Successfully imported ${importStats?.totalFiles || 0} files`, { + position: 'bottom-right', + autoClose: 3000, + }); + + resetState(); + onClose(); + } catch (error) { + toast.error('Failed to import project', { position: 'bottom-right' }); + setErrorMessage(error instanceof Error ? error.message : 'Import failed'); + } finally { + setIsProcessing(false); + } + }, [selectedFiles, importStats, onImport, onClose, resetState]); + + return ( + !open && onClose()}> + +
+ +
+ Import Existing Project + + + Upload your project files or drag and drop them here. Supports individual files, folders, or ZIP archives. + + +
+ + {!Object.keys(selectedFiles).length ? ( + +
+ + +
+
+ +
+ +
+

+ {isDragging ? 'Drop your project here' : 'Drag & Drop your project'} +

+

+ Support for folders, multiple files, or ZIP archives +

+
+ +
+ + +
+
+ + {isProcessing && ( +
+
+
+

+ Processing files... {Math.round(importProgress)}% +

+
+
+ )} +
+ + {errorMessage && ( + +

{errorMessage}

+
+ )} + + ) : ( + + {importStats && ( +
+
+

Total Files

+

{importStats.totalFiles}

+
+
+

Total Size

+

+ {formatFileSize(importStats.totalSize)} +

+
+
+

Directories

+

+ {importStats.directories.size} +

+
+
+ )} + +
+
+

Files to Import

+
+
+ {Object.keys(selectedFiles) + .slice(0, 50) + .map((path, index) => ( +
+
+ {path} +
+ ))} + {Object.keys(selectedFiles).length > 50 && ( +
+ ... and {Object.keys(selectedFiles).length - 50} more files +
+ )} +
+
+ +
+ + + +
+ + )} + +
+
+
+
+ ); +}; diff --git a/app/lib/persistence/userDb.ts b/app/lib/persistence/userDb.ts new file mode 100644 index 0000000000..5a0ad7bb74 --- /dev/null +++ b/app/lib/persistence/userDb.ts @@ -0,0 +1,241 @@ +import { createScopedLogger } from '~/utils/logger'; +import type { ChatHistoryItem } from './useChatHistory'; +import { authStore } from '~/lib/stores/auth'; + +export interface IUserChatMetadata { + userId: string; + gitUrl?: string; + gitBranch?: string; + netlifySiteId?: string; +} + +const logger = createScopedLogger('UserChatHistory'); + +/** + * Open user-specific database + */ +export async function openUserDatabase(): Promise { + if (typeof indexedDB === 'undefined') { + console.error('indexedDB is not available in this environment.'); + return undefined; + } + + const authState = authStore.get(); + + if (!authState.user?.id) { + console.error('No authenticated user found.'); + return undefined; + } + + // Use user-specific database name + const dbName = `boltHistory_${authState.user.id}`; + + return new Promise((resolve) => { + const request = indexedDB.open(dbName, 1); + + request.onupgradeneeded = (event: IDBVersionChangeEvent) => { + const db = (event.target as IDBOpenDBRequest).result; + + if (!db.objectStoreNames.contains('chats')) { + const store = db.createObjectStore('chats', { keyPath: 'id' }); + store.createIndex('id', 'id', { unique: true }); + store.createIndex('urlId', 'urlId', { unique: true }); + store.createIndex('userId', 'userId', { unique: false }); + store.createIndex('timestamp', 'timestamp', { unique: false }); + } + + if (!db.objectStoreNames.contains('snapshots')) { + db.createObjectStore('snapshots', { keyPath: 'chatId' }); + } + + if (!db.objectStoreNames.contains('settings')) { + db.createObjectStore('settings', { keyPath: 'key' }); + } + + if (!db.objectStoreNames.contains('workspaces')) { + const workspaceStore = db.createObjectStore('workspaces', { keyPath: 'id' }); + workspaceStore.createIndex('name', 'name', { unique: false }); + workspaceStore.createIndex('createdAt', 'createdAt', { unique: false }); + } + }; + + request.onsuccess = (event: Event) => { + resolve((event.target as IDBOpenDBRequest).result); + }; + + request.onerror = (event: Event) => { + resolve(undefined); + logger.error((event.target as IDBOpenDBRequest).error); + }; + }); +} + +/** + * Get all chats for current user + */ +export async function getUserChats(db: IDBDatabase): Promise { + const authState = authStore.get(); + + if (!authState.user?.id) { + return []; + } + + return new Promise((resolve, reject) => { + const transaction = db.transaction('chats', 'readonly'); + const store = transaction.objectStore('chats'); + const request = store.getAll(); + + request.onsuccess = () => { + // Filter by userId and sort by timestamp + const chats = (request.result as ChatHistoryItem[]).sort( + (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(), + ); + + resolve(chats); + }; + + request.onerror = () => reject(request.error); + }); +} + +/** + * Save user-specific settings + */ +export async function saveUserSetting(db: IDBDatabase, key: string, value: any): Promise { + return new Promise((resolve, reject) => { + const transaction = db.transaction('settings', 'readwrite'); + const store = transaction.objectStore('settings'); + + const request = store.put({ key, value, updatedAt: new Date().toISOString() }); + + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); +} + +/** + * Load user-specific settings + */ +export async function loadUserSetting(db: IDBDatabase, key: string): Promise { + return new Promise((resolve, reject) => { + const transaction = db.transaction('settings', 'readonly'); + const store = transaction.objectStore('settings'); + const request = store.get(key); + + request.onsuccess = () => { + const result = request.result; + resolve(result ? result.value : null); + }; + + request.onerror = () => reject(request.error); + }); +} + +/** + * Create a workspace for the user + */ +export interface Workspace { + id: string; + name: string; + description?: string; + createdAt: string; + lastAccessed?: string; + files?: Record; +} + +export async function createWorkspace(db: IDBDatabase, workspace: Omit): Promise { + const authState = authStore.get(); + + if (!authState.user?.id) { + throw new Error('No authenticated user'); + } + + const workspaceId = `workspace_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`; + + return new Promise((resolve, reject) => { + const transaction = db.transaction('workspaces', 'readwrite'); + const store = transaction.objectStore('workspaces'); + + const fullWorkspace: Workspace = { + id: workspaceId, + ...workspace, + }; + + const request = store.add(fullWorkspace); + + request.onsuccess = () => resolve(workspaceId); + request.onerror = () => reject(request.error); + }); +} + +/** + * Get user workspaces + */ +export async function getUserWorkspaces(db: IDBDatabase): Promise { + return new Promise((resolve, reject) => { + const transaction = db.transaction('workspaces', 'readonly'); + const store = transaction.objectStore('workspaces'); + const request = store.getAll(); + + request.onsuccess = () => { + const workspaces = (request.result as Workspace[]).sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + resolve(workspaces); + }; + + request.onerror = () => reject(request.error); + }); +} + +/** + * Delete a workspace + */ +export async function deleteWorkspace(db: IDBDatabase, workspaceId: string): Promise { + return new Promise((resolve, reject) => { + const transaction = db.transaction('workspaces', 'readwrite'); + const store = transaction.objectStore('workspaces'); + const request = store.delete(workspaceId); + + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); +} + +/** + * Get user statistics + */ +export async function getUserStats(db: IDBDatabase): Promise<{ + totalChats: number; + totalWorkspaces: number; + lastActivity?: string; + storageUsed?: number; +}> { + try { + const [chats, workspaces] = await Promise.all([getUserChats(db), getUserWorkspaces(db)]); + + // Calculate last activity + let lastActivity: string | undefined; + + const allTimestamps = [ + ...chats.map((c) => c.timestamp), + ...workspaces.map((w) => w.lastAccessed || w.createdAt), + ].filter(Boolean); + + if (allTimestamps.length > 0) { + lastActivity = allTimestamps.sort().reverse()[0]; + } + + return { + totalChats: chats.length, + totalWorkspaces: workspaces.length, + lastActivity, + }; + } catch (error) { + logger.error('Failed to get user stats:', error); + return { + totalChats: 0, + totalWorkspaces: 0, + }; + } +} diff --git a/app/lib/stores/auth.ts b/app/lib/stores/auth.ts new file mode 100644 index 0000000000..ecaf61487e --- /dev/null +++ b/app/lib/stores/auth.ts @@ -0,0 +1,300 @@ +import { atom, map } from 'nanostores'; +import type { UserProfile } from '~/lib/utils/fileUserStorage'; +import Cookies from 'js-cookie'; + +export interface AuthState { + isAuthenticated: boolean; + user: Omit | null; + token: string | null; + loading: boolean; +} + +// Authentication state store +export const authStore = map({ + isAuthenticated: false, + user: null, + token: null, + loading: true, +}); + +// Remember me preference +export const rememberMeStore = atom(false); + +// Session timeout tracking +let sessionTimeout: NodeJS.Timeout | null = null; +const SESSION_TIMEOUT = 7 * 24 * 60 * 60 * 1000; // 7 days + +/** + * Initialize auth from stored token + */ +export async function initializeAuth(): Promise { + if (typeof window === 'undefined') { + return; + } + + authStore.setKey('loading', true); + + try { + const token = Cookies.get('auth_token'); + + if (token) { + // Verify token with backend + const response = await fetch('/api/auth/verify', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + }); + + if (response.ok) { + const data = (await response.json()) as { user: Omit }; + setAuthState({ + isAuthenticated: true, + user: data.user, + token, + loading: false, + }); + startSessionTimer(); + } else { + // Token is invalid, clear it + clearAuth(); + } + } else { + authStore.setKey('loading', false); + } + } catch (error) { + console.error('Failed to initialize auth:', error); + authStore.setKey('loading', false); + } +} + +/** + * Set authentication state + */ +export function setAuthState(state: AuthState): void { + authStore.set(state); + + if (state.token) { + // Store token in cookie + const cookieOptions = rememberMeStore.get() + ? { expires: 7 } // 7 days + : undefined; // Session cookie + + Cookies.set('auth_token', state.token, cookieOptions); + + // Store user preferences in localStorage + if (state.user) { + localStorage.setItem(`bolt_user_${state.user.id}`, JSON.stringify(state.user.preferences || {})); + } + } +} + +/** + * Login user + */ +export async function login( + username: string, + password: string, + rememberMe: boolean = false, +): Promise<{ success: boolean; error?: string }> { + try { + const response = await fetch('/api/auth/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ username, password }), + }); + + const data = (await response.json()) as { + success?: boolean; + error?: string; + user?: Omit; + token?: string; + }; + + if (response.ok) { + rememberMeStore.set(rememberMe); + setAuthState({ + isAuthenticated: true, + user: data.user || null, + token: data.token || null, + loading: false, + }); + startSessionTimer(); + + return { success: true }; + } else { + return { success: false, error: data.error || 'Login failed' }; + } + } catch (error) { + console.error('Login error:', error); + return { success: false, error: 'Network error' }; + } +} + +/** + * Signup new user + */ +export async function signup( + username: string, + password: string, + firstName: string, + avatar?: string, +): Promise<{ success: boolean; error?: string }> { + try { + const response = await fetch('/api/auth/signup', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ username, password, firstName, avatar }), + }); + + const data = (await response.json()) as { + success?: boolean; + error?: string; + user?: Omit; + token?: string; + }; + + if (response.ok) { + setAuthState({ + isAuthenticated: true, + user: data.user || null, + token: data.token || null, + loading: false, + }); + startSessionTimer(); + + return { success: true }; + } else { + return { success: false, error: data.error || 'Signup failed' }; + } + } catch (error) { + console.error('Signup error:', error); + return { success: false, error: 'Network error' }; + } +} + +/** + * Logout user + */ +export async function logout(): Promise { + const state = authStore.get(); + + if (state.token) { + try { + await fetch('/api/auth/logout', { + method: 'POST', + headers: { + Authorization: `Bearer ${state.token}`, + }, + }); + } catch (error) { + console.error('Logout error:', error); + } + } + + clearAuth(); +} + +/** + * Clear authentication state + */ +function clearAuth(): void { + authStore.set({ + isAuthenticated: false, + user: null, + token: null, + loading: false, + }); + + Cookies.remove('auth_token'); + stopSessionTimer(); + + // Clear user-specific localStorage + const currentUser = authStore.get().user; + + if (currentUser?.id) { + // Keep preferences but clear sensitive data + const prefs = localStorage.getItem(`bolt_user_${currentUser.id}`); + + if (prefs) { + try { + const parsed = JSON.parse(prefs); + delete parsed.deploySettings; + delete parsed.githubSettings; + localStorage.setItem(`bolt_user_${currentUser.id}`, JSON.stringify(parsed)); + } catch {} + } + } +} + +/** + * Start session timer + */ +function startSessionTimer(): void { + stopSessionTimer(); + + if (!rememberMeStore.get()) { + sessionTimeout = setTimeout(() => { + logout(); + + if (typeof window !== 'undefined') { + window.location.href = '/auth'; + } + }, SESSION_TIMEOUT); + } +} + +/** + * Stop session timer + */ +function stopSessionTimer(): void { + if (sessionTimeout) { + clearTimeout(sessionTimeout); + sessionTimeout = null; + } +} + +/** + * Update user profile + */ +export async function updateProfile( + updates: Partial>, +): Promise { + const state = authStore.get(); + + if (!state.token || !state.user) { + return false; + } + + try { + const response = await fetch('/api/users/profile', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${state.token}`, + }, + body: JSON.stringify(updates), + }); + + if (response.ok) { + const updatedUser = (await response.json()) as Omit; + authStore.setKey('user', updatedUser); + + return true; + } + } catch (error) { + console.error('Failed to update profile:', error); + } + + return false; +} + +// Initialize auth on load +if (typeof window !== 'undefined') { + initializeAuth(); +} diff --git a/app/lib/utils/crypto.ts b/app/lib/utils/crypto.ts new file mode 100644 index 0000000000..0766f58032 --- /dev/null +++ b/app/lib/utils/crypto.ts @@ -0,0 +1,86 @@ +import bcrypt from 'bcryptjs'; +import jwt from 'jsonwebtoken'; + +// Use a secure secret key (in production, this should be an environment variable) +const JWT_SECRET = process.env.JWT_SECRET || 'bolt-multi-user-secret-key-2024-secure'; +const SALT_ROUNDS = 10; + +export interface JWTPayload { + userId: string; + username: string; + firstName: string; + exp?: number; +} + +/** + * Hash a password using bcrypt + */ +export async function hashPassword(password: string): Promise { + return bcrypt.hash(password, SALT_ROUNDS); +} + +/** + * Verify a password against a hash + */ +export async function verifyPassword(password: string, hash: string): Promise { + return bcrypt.compare(password, hash); +} + +/** + * Generate a JWT token + */ +export function generateToken(payload: Omit): string { + return jwt.sign( + { + ...payload, + exp: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60, // 7 days + }, + JWT_SECRET, + ); +} + +/** + * Verify and decode a JWT token + */ +export function verifyToken(token: string): JWTPayload | null { + try { + return jwt.verify(token, JWT_SECRET) as JWTPayload; + } catch { + return null; + } +} + +/** + * Generate a secure user ID + */ +export function generateUserId(): string { + return `user_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`; +} + +/** + * Validate password strength + */ +export function validatePassword(password: string): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + if (password.length < 8) { + errors.push('Password must be at least 8 characters long'); + } + + if (!/[A-Z]/.test(password)) { + errors.push('Password must contain at least one uppercase letter'); + } + + if (!/[a-z]/.test(password)) { + errors.push('Password must contain at least one lowercase letter'); + } + + if (!/[0-9]/.test(password)) { + errors.push('Password must contain at least one number'); + } + + return { + valid: errors.length === 0, + errors, + }; +} diff --git a/app/lib/utils/fileUserStorage.ts b/app/lib/utils/fileUserStorage.ts new file mode 100644 index 0000000000..1ddf48373e --- /dev/null +++ b/app/lib/utils/fileUserStorage.ts @@ -0,0 +1,338 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { generateUserId, hashPassword } from './crypto'; + +const USERS_DIR = path.join(process.cwd(), '.users'); +const USERS_INDEX_FILE = path.join(USERS_DIR, 'users.json'); +const USER_DATA_DIR = path.join(USERS_DIR, 'data'); + +export interface UserProfile { + id: string; + username: string; + firstName: string; + passwordHash: string; + avatar?: string; + createdAt: string; + lastLogin?: string; + preferences: UserPreferences; +} + +export interface UserPreferences { + theme: 'light' | 'dark'; + deploySettings: { + netlify?: any; + vercel?: any; + }; + githubSettings?: any; + workspaceConfig: any; +} + +export interface SecurityLog { + timestamp: string; + userId?: string; + username?: string; + action: 'login' | 'logout' | 'signup' | 'delete' | 'error' | 'failed_login'; + details: string; + ip?: string; +} + +/** + * Initialize the user storage system + */ +export async function initializeUserStorage(): Promise { + try { + // Create directories if they don't exist + await fs.mkdir(USERS_DIR, { recursive: true }); + await fs.mkdir(USER_DATA_DIR, { recursive: true }); + + // Create users index if it doesn't exist + try { + await fs.access(USERS_INDEX_FILE); + } catch { + await fs.writeFile(USERS_INDEX_FILE, JSON.stringify({ users: [] }, null, 2)); + } + } catch (error) { + console.error('Failed to initialize user storage:', error); + throw error; + } +} + +/** + * Get all users (without passwords) + */ +export async function getAllUsers(): Promise[]> { + try { + await initializeUserStorage(); + + const data = await fs.readFile(USERS_INDEX_FILE, 'utf-8'); + const { users } = JSON.parse(data) as { users: UserProfile[] }; + + return users.map(({ passwordHash, ...user }) => user); + } catch (error) { + console.error('Failed to get users:', error); + return []; + } +} + +/** + * Get a user by username + */ +export async function getUserByUsername(username: string): Promise { + try { + await initializeUserStorage(); + + const data = await fs.readFile(USERS_INDEX_FILE, 'utf-8'); + const { users } = JSON.parse(data) as { users: UserProfile[] }; + + return users.find((u) => u.username === username) || null; + } catch (error) { + console.error('Failed to get user:', error); + return null; + } +} + +/** + * Get a user by ID + */ +export async function getUserById(id: string): Promise { + try { + await initializeUserStorage(); + + const data = await fs.readFile(USERS_INDEX_FILE, 'utf-8'); + const { users } = JSON.parse(data) as { users: UserProfile[] }; + + return users.find((u) => u.id === id) || null; + } catch (error) { + console.error('Failed to get user:', error); + return null; + } +} + +/** + * Create a new user + */ +export async function createUser( + username: string, + password: string, + firstName: string, + avatar?: string, +): Promise { + try { + await initializeUserStorage(); + + // Check if username already exists + const existingUser = await getUserByUsername(username); + + if (existingUser) { + throw new Error('Username already exists'); + } + + // Create new user + const newUser: UserProfile = { + id: generateUserId(), + username, + firstName, + passwordHash: await hashPassword(password), + avatar, + createdAt: new Date().toISOString(), + preferences: { + theme: 'dark', + deploySettings: {}, + workspaceConfig: {}, + }, + }; + + // Load existing users + const data = await fs.readFile(USERS_INDEX_FILE, 'utf-8'); + const { users } = JSON.parse(data) as { users: UserProfile[] }; + + // Add new user + users.push(newUser); + + // Save updated users + await fs.writeFile(USERS_INDEX_FILE, JSON.stringify({ users }, null, 2)); + + // Create user data directory + const userDataDir = path.join(USER_DATA_DIR, newUser.id); + await fs.mkdir(userDataDir, { recursive: true }); + + // Log the signup + await logSecurityEvent({ + timestamp: new Date().toISOString(), + userId: newUser.id, + username: newUser.username, + action: 'signup', + details: `User ${newUser.username} created successfully`, + }); + + return newUser; + } catch (error) { + console.error('Failed to create user:', error); + await logSecurityEvent({ + timestamp: new Date().toISOString(), + action: 'error', + details: `Failed to create user ${username}: ${error}`, + }); + throw error; + } +} + +/** + * Update user profile + */ +export async function updateUser(userId: string, updates: Partial): Promise { + try { + await initializeUserStorage(); + + const data = await fs.readFile(USERS_INDEX_FILE, 'utf-8'); + const { users } = JSON.parse(data) as { users: UserProfile[] }; + + const userIndex = users.findIndex((u) => u.id === userId); + + if (userIndex === -1) { + return false; + } + + // Update user (excluding certain fields) + const { id, username, passwordHash, ...safeUpdates } = updates; + users[userIndex] = { + ...users[userIndex], + ...safeUpdates, + }; + + // Save updated users + await fs.writeFile(USERS_INDEX_FILE, JSON.stringify({ users }, null, 2)); + + return true; + } catch (error) { + console.error('Failed to update user:', error); + return false; + } +} + +/** + * Update user's last login time + */ +export async function updateLastLogin(userId: string): Promise { + await updateUser(userId, { lastLogin: new Date().toISOString() }); +} + +/** + * Delete a user + */ +export async function deleteUser(userId: string): Promise { + try { + await initializeUserStorage(); + + const data = await fs.readFile(USERS_INDEX_FILE, 'utf-8'); + const { users } = JSON.parse(data) as { users: UserProfile[] }; + + const userIndex = users.findIndex((u) => u.id === userId); + + if (userIndex === -1) { + return false; + } + + const deletedUser = users[userIndex]; + + // Remove user from list + users.splice(userIndex, 1); + + // Save updated users + await fs.writeFile(USERS_INDEX_FILE, JSON.stringify({ users }, null, 2)); + + // Delete user data directory + const userDataDir = path.join(USER_DATA_DIR, userId); + + try { + await fs.rm(userDataDir, { recursive: true, force: true }); + } catch (error) { + console.warn(`Failed to delete user data directory: ${error}`); + } + + // Log the deletion + await logSecurityEvent({ + timestamp: new Date().toISOString(), + userId, + username: deletedUser.username, + action: 'delete', + details: `User ${deletedUser.username} deleted`, + }); + + return true; + } catch (error) { + console.error('Failed to delete user:', error); + return false; + } +} + +/** + * Save user-specific data + */ +export async function saveUserData(userId: string, key: string, data: any): Promise { + try { + const userDataDir = path.join(USER_DATA_DIR, userId); + await fs.mkdir(userDataDir, { recursive: true }); + + const filePath = path.join(userDataDir, `${key}.json`); + await fs.writeFile(filePath, JSON.stringify(data, null, 2)); + } catch (error) { + console.error(`Failed to save user data for ${userId}:`, error); + throw error; + } +} + +/** + * Load user-specific data + */ +export async function loadUserData(userId: string, key: string): Promise { + try { + const filePath = path.join(USER_DATA_DIR, userId, `${key}.json`); + const data = await fs.readFile(filePath, 'utf-8'); + + return JSON.parse(data); + } catch { + return null; + } +} + +/** + * Log security events + */ +export async function logSecurityEvent(event: SecurityLog): Promise { + try { + const logFile = path.join(USERS_DIR, 'security.log'); + const logEntry = `${JSON.stringify(event)}\n`; + + await fs.appendFile(logFile, logEntry); + } catch (error) { + console.error('Failed to log security event:', error); + } +} + +/** + * Get security logs + */ +export async function getSecurityLogs(limit: number = 100): Promise { + try { + const logFile = path.join(USERS_DIR, 'security.log'); + const data = await fs.readFile(logFile, 'utf-8'); + + const logs = data + .trim() + .split('\n') + .filter((line) => line) + .map((line) => { + try { + return JSON.parse(line) as SecurityLog; + } catch { + return null; + } + }) + .filter(Boolean) as SecurityLog[]; + + return logs.slice(-limit).reverse(); + } catch { + return []; + } +} diff --git a/app/routes/_index.tsx b/app/routes/_index.tsx index 65df404aad..59c289c125 100644 --- a/app/routes/_index.tsx +++ b/app/routes/_index.tsx @@ -1,28 +1,128 @@ import { json, type MetaFunction } from '@remix-run/cloudflare'; 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 { authStore } from '~/lib/stores/auth'; +import { useNavigate } from '@remix-run/react'; +import { motion, AnimatePresence } from 'framer-motion'; export const meta: MetaFunction = () => { - return [{ title: 'Bolt' }, { name: 'description', content: 'Talk with Bolt, an AI assistant from StackBlitz' }]; + return [{ title: 'bolt.diy' }, { name: 'description', content: 'Build web applications with AI assistance' }]; }; export const loader = () => json({}); /** - * 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. + * Landing page component with optional multi-user authentication + * Users can continue as guests or activate multi-user features + * Developed by Keoma Wright */ export default function Index() { + const [showMultiUserBanner, setShowMultiUserBanner] = useState(false); + const navigate = useNavigate(); + + 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 ( -
- -
- }>{() => } +
+ +
+
+

Loading bolt.diy...

+
+
+ } + > + {() => ( + <> + + + {/* Optional Multi-User Activation Banner */} + + {showMultiUserBanner && ( + +
+ + +
+
+
+ + + +
+
+ +
+

+ Unlock Multi-User Features +

+

+ Save your projects, personalized settings, and collaborate with workspace isolation. +

+
+ + +
+
+
+
+
+ )} +
+ + )} +
); } diff --git a/app/routes/admin.users.tsx b/app/routes/admin.users.tsx new file mode 100644 index 0000000000..5247bd6e08 --- /dev/null +++ b/app/routes/admin.users.tsx @@ -0,0 +1,351 @@ +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([]); + const [loading, setLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedUser, setSelectedUser] = useState(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 ( + +
+ {/* Header */} +
+
+
+
+ +
+

User Management

+

Manage system users

+
+
+ +
+
+ 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', + )} + /> + +
+ + +
+
+
+
+ + {/* User Stats */} +
+
+
+

Total Users

+

{users.length}

+
+
+

Active Today

+

+ { + users.filter((u) => { + if (!u.lastLogin) { + return false; + } + + const lastLogin = new Date(u.lastLogin); + const today = new Date(); + + return lastLogin.toDateString() === today.toDateString(); + }).length + } +

+
+
+

New This Week

+

+ { + users.filter((u) => { + const created = new Date(u.createdAt); + const weekAgo = new Date(); + weekAgo.setDate(weekAgo.getDate() - 7); + + return created > weekAgo; + }).length + } +

+
+
+

Storage Used

+

0 MB

+
+
+
+ + {/* User List */} +
+ {loading ? ( +
+ +
+ ) : filteredUsers.length === 0 ? ( +
+ +

+ {searchQuery ? 'No users found matching your search' : 'No users yet'} +

+
+ ) : ( +
+ + {filteredUsers.map((user, index) => ( + +
+
+
+ {user.avatar ? ( + {user.firstName} + ) : ( + + {user.firstName[0].toUpperCase()} + + )} +
+
+

+ {user.firstName} + {user.id === authState.user?.id && ( + + You + + )} +

+

@{user.username}

+
+
+ +
+ + {user.id !== authState.user?.id && ( + + )} +
+
+ +
+
+ + Joined {new Date(user.createdAt).toLocaleDateString()} +
+ {user.lastLogin && ( +
+ + Last active {new Date(user.lastLogin).toLocaleDateString()} +
+ )} +
+
+ ))} +
+
+ )} +
+ + {/* Delete Confirmation Modal */} + + {showDeleteModal && selectedUser && ( + !deleting && setShowDeleteModal(false)} + > + e.stopPropagation()} + > +

Delete User

+

+ Are you sure you want to delete{' '} + @{selectedUser.username}? This + action cannot be undone and will permanently remove all user data. +

+ +
+ + +
+
+
+ )} +
+
+
+ ); +} diff --git a/app/routes/api.auth.login.ts b/app/routes/api.auth.login.ts new file mode 100644 index 0000000000..1037ff8bf0 --- /dev/null +++ b/app/routes/api.auth.login.ts @@ -0,0 +1,92 @@ +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 }); + } +} diff --git a/app/routes/api.auth.logout.ts b/app/routes/api.auth.logout.ts new file mode 100644 index 0000000000..3d25c5582e --- /dev/null +++ b/app/routes/api.auth.logout.ts @@ -0,0 +1,37 @@ +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 }); + } +} diff --git a/app/routes/api.auth.signup.ts b/app/routes/api.auth.signup.ts new file mode 100644 index 0000000000..849e1cbc29 --- /dev/null +++ b/app/routes/api.auth.signup.ts @@ -0,0 +1,93 @@ +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 }); + } +} diff --git a/app/routes/api.auth.verify.ts b/app/routes/api.auth.verify.ts new file mode 100644 index 0000000000..f66585fba4 --- /dev/null +++ b/app/routes/api.auth.verify.ts @@ -0,0 +1,44 @@ +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 }); + } +} diff --git a/app/routes/api.users.$id.ts b/app/routes/api.users.$id.ts new file mode 100644 index 0000000000..246c0de5af --- /dev/null +++ b/app/routes/api.users.$id.ts @@ -0,0 +1,49 @@ +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 }); + } +} diff --git a/app/routes/api.users.ts b/app/routes/api.users.ts new file mode 100644 index 0000000000..1e79f7e2a1 --- /dev/null +++ b/app/routes/api.users.ts @@ -0,0 +1,30 @@ +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 }); + } +} diff --git a/app/routes/auth.tsx b/app/routes/auth.tsx new file mode 100644 index 0000000000..10a35dba9d --- /dev/null +++ b/app/routes/auth.tsx @@ -0,0 +1,422 @@ +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(); + const [loading, setLoading] = useState(false); + const [errors, setErrors] = useState>({}); + + const handleInputChange = (e: React.ChangeEvent) => { + 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) => { + 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 = {}; + + 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 ( +
+ {/* Animated gradient background */} +
+
+ +
+ + {/* Logo and Title */} +
+ +
+ +
+
+

bolt.diy

+

Multi-User Edition

+
+
+
+ + {/* Auth Card */} + +
+ {/* Tab Header */} +
+ + + + {/* Sliding indicator */} + +
+ + {/* Form Content */} +
+ + + {/* Avatar Upload (Signup only) */} + {mode === 'signup' && ( +
+
+
+ {avatar ? ( + Avatar + ) : ( + 👤 + )} +
+ +
+
+ )} + + {/* First Name (Signup only) */} + {mode === 'signup' && ( +
+ + + {errors.firstName &&

{errors.firstName}

} +
+ )} + + {/* Username */} +
+ + + {errors.username &&

{errors.username}

} +
+ + {/* Password */} +
+ + + {errors.password &&

{errors.password}

} + {mode === 'signup' && formData.password && ( +
+
+
= 8 ? 'bg-green-400' : 'bg-white/30', + )} + /> + At least 8 characters +
+
+
+ One uppercase letter +
+
+
+ One lowercase letter +
+
+
+ One number +
+
+ )} +
+ + {/* Confirm Password (Signup only) */} + {mode === 'signup' && ( +
+ + + {errors.confirmPassword &&

{errors.confirmPassword}

} +
+ )} + + {/* Remember Me (Login only) */} + {mode === 'login' && ( +
+ + +
+ )} + + {/* Error Message */} + {errors.general && ( +
+

{errors.general}

+
+ )} + + {/* Submit Button */} + + + + + {/* Developer Credit */} +
+

+ Developed by Keoma Wright +

+
+ + {/* Continue as Guest */} +
+ +
+
+
+ +
+ ); +} diff --git a/package.json b/package.json index cbf558f6fd..85ef7bcc00 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "@radix-ui/react-progress": "^1.0.3", "@radix-ui/react-scroll-area": "^1.2.2", "@radix-ui/react-separator": "^1.1.0", + "@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-switch": "^1.1.1", "@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.4", @@ -97,6 +98,7 @@ "@remix-run/node": "^2.15.2", "@remix-run/react": "^2.15.2", "@tanstack/react-virtual": "^3.13.0", + "@types/jszip": "^3.4.1", "@types/react-beautiful-dnd": "^13.1.8", "@uiw/codemirror-theme-vscode": "^4.23.6", "@unocss/reset": "^0.61.9", @@ -105,6 +107,7 @@ "@xterm/addon-web-links": "^0.11.0", "@xterm/xterm": "^5.5.0", "ai": "4.3.16", + "bcryptjs": "^3.0.2", "chalk": "^5.4.1", "chart.js": "^4.4.7", "class-variance-authority": "^0.7.0", @@ -123,6 +126,7 @@ "istextorbinary": "^9.5.0", "jose": "^5.9.6", "js-cookie": "^3.0.5", + "jsonwebtoken": "^9.0.2", "jspdf": "^2.5.2", "jszip": "^3.10.1", "lucide-react": "^0.485.0", @@ -162,16 +166,19 @@ "@cloudflare/workers-types": "^4.20241127.0", "@electron/notarize": "^2.5.0", "@iconify-json/ph": "^1.2.1", + "@iconify-json/simple-icons": "^1.2.49", "@iconify/types": "^2.0.0", "@remix-run/dev": "^2.15.2", "@remix-run/serve": "^2.15.2", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.2.0", + "@types/bcryptjs": "^3.0.0", "@types/diff": "^5.2.3", "@types/dom-speech-recognition": "^0.0.4", "@types/electron": "^1.6.12", "@types/file-saver": "^2.0.7", "@types/js-cookie": "^3.0.6", + "@types/jsonwebtoken": "^9.0.10", "@types/path-browserify": "^1.0.3", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 468c93caad..2db0e603cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -146,6 +146,9 @@ importers: '@radix-ui/react-separator': specifier: ^1.1.0 version: 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slider': + specifier: ^1.3.6 + version: 1.3.6(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-switch': specifier: ^1.1.1 version: 1.2.5(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -170,6 +173,9 @@ importers: '@tanstack/react-virtual': specifier: ^3.13.0 version: 3.13.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/jszip': + specifier: ^3.4.1 + version: 3.4.1 '@types/react-beautiful-dnd': specifier: ^13.1.8 version: 13.1.8 @@ -194,6 +200,9 @@ importers: ai: specifier: 4.3.16 version: 4.3.16(react@18.3.1)(zod@3.25.76) + bcryptjs: + specifier: ^3.0.2 + version: 3.0.2 chalk: specifier: ^5.4.1 version: 5.4.1 @@ -248,6 +257,9 @@ importers: js-cookie: specifier: ^3.0.5 version: 3.0.5 + jsonwebtoken: + specifier: ^9.0.2 + version: 9.0.2 jspdf: specifier: ^2.5.2 version: 2.5.2 @@ -360,6 +372,9 @@ importers: '@iconify-json/ph': specifier: ^1.2.1 version: 1.2.2 + '@iconify-json/simple-icons': + specifier: ^1.2.49 + version: 1.2.49 '@iconify/types': specifier: ^2.0.0 version: 2.0.0 @@ -375,6 +390,9 @@ importers: '@testing-library/react': specifier: ^16.2.0 version: 16.3.0(@testing-library/dom@10.4.0)(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/bcryptjs': + specifier: ^3.0.0 + version: 3.0.0 '@types/diff': specifier: ^5.2.3 version: 5.2.3 @@ -390,6 +408,9 @@ importers: '@types/js-cookie': specifier: ^3.0.6 version: 3.0.6 + '@types/jsonwebtoken': + specifier: ^9.0.10 + version: 9.0.10 '@types/path-browserify': specifier: ^1.0.3 version: 1.0.3 @@ -1953,6 +1974,9 @@ packages: '@iconify-json/ph@1.2.2': resolution: {integrity: sha512-PgkEZNtqa8hBGjHXQa4pMwZa93hmfu8FUSjs/nv4oUU6yLsgv+gh9nu28Kqi8Fz9CCVu4hj1MZs9/60J57IzFw==} + '@iconify-json/simple-icons@1.2.49': + resolution: {integrity: sha512-nRLwrHzz+cTAQYBNQrcr4eWOmQIcHObTj/QSi7nj0SFwVh5MvBsgx8OhoDC/R8iGklNmMpmoE/NKU0cPXMlOZw==} + '@iconify-json/svg-spinners@1.2.2': resolution: {integrity: sha512-DIErwfBWWzLfmAG2oQnbUOSqZhDxlXvr8941itMCrxQoMB0Hiv8Ww6Bln/zIgxwjDvSem2dKJtap+yKKwsB/2A==} @@ -2297,6 +2321,9 @@ packages: '@radix-ui/primitive@1.1.2': resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==} + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + '@radix-ui/react-arrow@1.1.7': resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} peerDependencies: @@ -2602,6 +2629,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-slider@1.3.6': + resolution: {integrity: sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-slot@1.2.3': resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} peerDependencies: @@ -3296,6 +3336,10 @@ packages: '@types/babel__traverse@7.20.7': resolution: {integrity: sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==} + '@types/bcryptjs@3.0.0': + resolution: {integrity: sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==} + deprecated: This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed. + '@types/cacheable-request@6.0.3': resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} @@ -3350,6 +3394,13 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + + '@types/jszip@3.4.1': + resolution: {integrity: sha512-TezXjmf3lj+zQ651r6hPqvSScqBLvyPI9FxdXBqpEwBijNGQ2NXpaFW/7joGzveYkKQUil7iiDHLo6LV71Pc0A==} + deprecated: This is a stub types definition. jszip provides its own type definitions, so you do not need this installed. + '@types/keyv@3.1.4': resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} @@ -3850,6 +3901,10 @@ packages: resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} engines: {node: '>= 0.8'} + bcryptjs@3.0.2: + resolution: {integrity: sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==} + hasBin: true + before-after-hook@3.0.2: resolution: {integrity: sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==} @@ -3943,6 +3998,9 @@ packages: buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -4504,6 +4562,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + editions@6.21.0: resolution: {integrity: sha512-ofkXJtn7z0urokN62DI3SBo/5xAtF0rR7tn+S/bSYV79Ka8pTajIIl+fFQ1q88DQEImymmo97M4azY3WX/nUdg==} engines: {node: '>=4'} @@ -5586,12 +5647,22 @@ packages: jsonfile@6.1.0: resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + jsonwebtoken@9.0.2: + resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + engines: {node: '>=12', npm: '>=6'} + jspdf@2.5.2: resolution: {integrity: sha512-myeX9c+p7znDWPk0eTrujCzNjT+CXdXyk7YmJq5nD5V7uLLKmSXnlQ/Jn/kuo3X09Op70Apm0rQSnFWyGK8uEQ==} jszip@3.10.1: resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + jwa@1.4.2: + resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} + + jws@3.2.2: + resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -5641,13 +5712,34 @@ packages: lodash.escaperegexp@4.1.2: resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + lodash.isequal@4.5.0: resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -9863,6 +9955,10 @@ snapshots: dependencies: '@iconify/types': 2.0.0 + '@iconify-json/simple-icons@1.2.49': + dependencies: + '@iconify/types': 2.0.0 + '@iconify-json/svg-spinners@1.2.2': dependencies: '@iconify/types': 2.0.0 @@ -10270,6 +10366,8 @@ snapshots: '@radix-ui/primitive@1.1.2': {} + '@radix-ui/primitive@1.1.3': {} + '@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -10587,6 +10685,25 @@ snapshots: '@types/react': 18.3.23 '@types/react-dom': 18.3.7(@types/react@18.3.23) + '@radix-ui/react-slider@1.3.6(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.23)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.23 + '@types/react-dom': 18.3.7(@types/react@18.3.23) + '@radix-ui/react-slot@1.2.3(@types/react@18.3.23)(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1) @@ -11451,11 +11568,15 @@ snapshots: dependencies: '@babel/types': 7.28.1 + '@types/bcryptjs@3.0.0': + dependencies: + bcryptjs: 3.0.2 + '@types/cacheable-request@6.0.3': dependencies: '@types/http-cache-semantics': 4.0.4 '@types/keyv': 3.1.4 - '@types/node': 20.19.9 + '@types/node': 24.1.0 '@types/responselike': 1.0.3 '@types/cookie@0.6.0': {} @@ -11507,9 +11628,18 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 24.1.0 + + '@types/jszip@3.4.1': + dependencies: + jszip: 3.10.1 + '@types/keyv@3.1.4': dependencies: - '@types/node': 20.19.9 + '@types/node': 24.1.0 '@types/mdast@3.0.15': dependencies: @@ -11570,7 +11700,7 @@ snapshots: '@types/responselike@1.0.3': dependencies: - '@types/node': 20.19.9 + '@types/node': 24.1.0 '@types/unist@2.0.11': {} @@ -11583,7 +11713,7 @@ snapshots: '@types/yauzl@2.10.3': dependencies: - '@types/node': 20.19.9 + '@types/node': 24.1.0 optional: true '@typescript-eslint/eslint-plugin@8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.31.0(jiti@1.21.7))(typescript@5.8.3))(eslint@9.31.0(jiti@1.21.7))(typescript@5.8.3)': @@ -12189,6 +12319,8 @@ snapshots: dependencies: safe-buffer: 5.1.2 + bcryptjs@3.0.2: {} + before-after-hook@3.0.2: {} binary-extensions@2.3.0: {} @@ -12326,6 +12458,8 @@ snapshots: buffer-crc32@0.2.13: {} + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} buffer-xor@1.0.3: {} @@ -12954,6 +13088,10 @@ snapshots: eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + editions@6.21.0: dependencies: version-range: 4.14.0 @@ -14397,6 +14535,19 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonwebtoken@9.0.2: + dependencies: + jws: 3.2.2 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.7.2 + jspdf@2.5.2: dependencies: '@babel/runtime': 7.27.6 @@ -14416,6 +14567,17 @@ snapshots: readable-stream: 2.3.8 setimmediate: 1.0.5 + jwa@1.4.2: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@3.2.2: + dependencies: + jwa: 1.4.2 + safe-buffer: 5.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -14457,10 +14619,24 @@ snapshots: lodash.escaperegexp@4.1.2: {} + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + lodash.isequal@4.5.0: {} + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + lodash.merge@4.6.2: {} + lodash.once@4.1.1: {} + lodash@4.17.21: {} log-symbols@4.1.0: