MVP passwords
This commit is contained in:
+13
-8
@@ -2,19 +2,24 @@ import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import { Dashboard } from './pages/Dashboard';
|
||||
import { Editor } from './pages/Editor';
|
||||
import { Settings } from './pages/Settings';
|
||||
import { PrivateDrawings } from './pages/PrivateDrawings';
|
||||
import { ThemeProvider } from './context/ThemeContext';
|
||||
import { VaultProvider } from './context/VaultContext';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/collections" element={<Dashboard />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/editor/:id" element={<Editor />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
<VaultProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/collections" element={<Dashboard />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/private" element={<PrivateDrawings />} />
|
||||
<Route path="/editor/:id" element={<Editor />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</VaultProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import axios from "axios";
|
||||
import type { Drawing, Collection } from "../types";
|
||||
import type {
|
||||
Drawing,
|
||||
Collection,
|
||||
VaultStatus,
|
||||
VaultVerifyResult,
|
||||
} from "../types";
|
||||
|
||||
export const API_URL = import.meta.env.VITE_API_URL || "/api";
|
||||
|
||||
@@ -96,3 +101,91 @@ export const updateLibrary = async (items: any[]) => {
|
||||
const response = await api.put<{ items: any[] }>("/library", { items });
|
||||
return response.data.items;
|
||||
};
|
||||
|
||||
// --- Private Vault ---
|
||||
|
||||
export const getVaultStatus = async (): Promise<VaultStatus> => {
|
||||
const response = await api.get<VaultStatus>("/vault/status");
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const setupVault = async (
|
||||
passwordHash: string,
|
||||
salt: string,
|
||||
hint?: string
|
||||
): Promise<void> => {
|
||||
await api.post("/vault/setup", { passwordHash, salt, hint });
|
||||
};
|
||||
|
||||
export const verifyVaultPassword = async (
|
||||
password: string
|
||||
): Promise<VaultVerifyResult> => {
|
||||
const response = await api.post<VaultVerifyResult>("/vault/verify", {
|
||||
password,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateVaultHint = async (hint: string): Promise<void> => {
|
||||
await api.put("/vault/hint", { hint });
|
||||
};
|
||||
|
||||
export const getVaultHint = async (): Promise<string | null> => {
|
||||
const response = await api.get<{ hint: string | null }>("/vault/hint");
|
||||
return response.data.hint;
|
||||
};
|
||||
|
||||
export const changeVaultPassword = async (
|
||||
newPasswordHash: string,
|
||||
newSalt: string,
|
||||
_oldKey: CryptoKey,
|
||||
_newKey: CryptoKey
|
||||
): Promise<void> => {
|
||||
// Note: The actual re-encryption of drawings happens client-side
|
||||
// This endpoint just updates the password hash and salt
|
||||
await api.put("/vault/password", {
|
||||
passwordHash: newPasswordHash,
|
||||
salt: newSalt,
|
||||
});
|
||||
};
|
||||
|
||||
// --- Private Drawings ---
|
||||
|
||||
export const getPrivateDrawings = async (): Promise<Drawing[]> => {
|
||||
const response = await api.get<Drawing[]>("/drawings/private");
|
||||
return response.data.map(deserializeDrawing);
|
||||
};
|
||||
|
||||
export const lockDrawing = async (
|
||||
id: string,
|
||||
encryptedData: string,
|
||||
iv: string
|
||||
): Promise<void> => {
|
||||
await api.put(`/drawings/${id}/lock`, { encryptedData, iv });
|
||||
};
|
||||
|
||||
export const lockDrawingWithPreview = async (
|
||||
id: string,
|
||||
encryptedData: string,
|
||||
iv: string,
|
||||
preview?: string
|
||||
): Promise<void> => {
|
||||
const body: any = { encryptedData, iv };
|
||||
if (preview !== undefined) body.preview = preview;
|
||||
await api.put(`/drawings/${id}/lock`, body);
|
||||
};
|
||||
|
||||
export const unlockDrawing = async (
|
||||
id: string,
|
||||
elements: any[],
|
||||
appState: any,
|
||||
files: any,
|
||||
preview?: string
|
||||
): Promise<void> => {
|
||||
await api.put(`/drawings/${id}/unlock`, {
|
||||
elements,
|
||||
appState,
|
||||
files,
|
||||
preview,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, Key, Eye, EyeOff, AlertCircle } from 'lucide-react';
|
||||
import { validatePasswordStrength } from '../utils/crypto';
|
||||
|
||||
interface ChangeVaultPasswordProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onChangePassword: (oldPassword: string, newPassword: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const ChangeVaultPassword: React.FC<ChangeVaultPasswordProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onChangePassword,
|
||||
}) => {
|
||||
const [oldPassword, setOldPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [showOld, setShowOld] = useState(false);
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const strength = validatePasswordStrength(newPassword);
|
||||
const passwordsMatch = confirmPassword === newPassword;
|
||||
const canSubmit = oldPassword.length > 0 && newPassword.length > 0 && passwordsMatch && strength.isValid;
|
||||
|
||||
const handleClose = () => {
|
||||
setOldPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setShowOld(false);
|
||||
setShowNew(false);
|
||||
setShowConfirm(false);
|
||||
setError(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await onChangePassword(oldPassword, newPassword);
|
||||
handleClose();
|
||||
} catch (err: any) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to change password');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStrengthColor = (score: number) => {
|
||||
if (score <= 1) return 'bg-red-500';
|
||||
if (score === 2) return 'bg-orange-500';
|
||||
if (score === 3) return 'bg-yellow-500';
|
||||
return 'bg-green-500';
|
||||
};
|
||||
|
||||
const getStrengthText = (score: number) => {
|
||||
if (score <= 1) return 'Weak';
|
||||
if (score === 2) return 'Fair';
|
||||
if (score === 3) return 'Good';
|
||||
return 'Strong';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-2xl border-2 border-black dark:border-neutral-700 shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] dark:shadow-[8px_8px_0px_0px_rgba(255,255,255,0.2)] w-full max-w-md mx-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b-2 border-black dark:border-neutral-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-amber-100 dark:bg-amber-900/30 rounded-xl flex items-center justify-center">
|
||||
<Key size={20} className="text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-900 dark:text-white">Change Vault Password</h2>
|
||||
<p className="text-sm text-slate-500 dark:text-neutral-400">Update your vault password</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-2 hover:bg-slate-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
|
||||
>
|
||||
<X size={20} className="text-slate-500 dark:text-neutral-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-50 dark:bg-red-900/20 border-2 border-red-200 dark:border-red-800 rounded-lg">
|
||||
<AlertCircle size={16} className="text-red-600 dark:text-red-400" />
|
||||
<span className="text-sm text-red-700 dark:text-red-300">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Old Password */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-bold text-slate-700 dark:text-neutral-300">Current Password</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showOld ? 'text' : 'password'}
|
||||
value={oldPassword}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
placeholder="Enter your current password"
|
||||
autoFocus
|
||||
className="w-full px-4 py-3 pr-12 bg-white dark:bg-neutral-800 border-2 border-black dark:border-neutral-700 rounded-lg text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-amber-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowOld(!showOld)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600 dark:text-neutral-500 dark:hover:text-neutral-300"
|
||||
>
|
||||
{showOld ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* New Password */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-bold text-slate-700 dark:text-neutral-300">New Password</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showNew ? 'text' : 'password'}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="Enter your new password"
|
||||
className="w-full px-4 py-3 pr-12 bg-white dark:bg-neutral-800 border-2 border-black dark:border-neutral-700 rounded-lg text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-amber-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNew(!showNew)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600 dark:text-neutral-500 dark:hover:text-neutral-300"
|
||||
>
|
||||
{showNew ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Strength Indicator */}
|
||||
{newPassword.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-1">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`h-1.5 flex-1 rounded-full transition-colors ${
|
||||
i <= strength.score ? getStrengthColor(strength.score) : 'bg-slate-200 dark:bg-neutral-700'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className={`text-xs font-medium ${
|
||||
strength.score <= 1 ? 'text-red-600 dark:text-red-400' :
|
||||
strength.score === 2 ? 'text-orange-600 dark:text-orange-400' :
|
||||
strength.score === 3 ? 'text-yellow-600 dark:text-yellow-400' :
|
||||
'text-green-600 dark:text-green-400'
|
||||
}`}>
|
||||
{getStrengthText(strength.score)}
|
||||
</p>
|
||||
{strength.feedback.length > 0 && (
|
||||
<ul className="text-xs text-slate-500 dark:text-neutral-400 space-y-1">
|
||||
{strength.feedback.map((fb, i) => (
|
||||
<li key={i}>• {fb}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirm Password */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-bold text-slate-700 dark:text-neutral-300">Confirm New Password</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showConfirm ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="Confirm your new password"
|
||||
className={`w-full px-4 py-3 pr-12 bg-white dark:bg-neutral-800 border-2 rounded-lg text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-amber-500 ${
|
||||
confirmPassword.length > 0 && !passwordsMatch ? 'border-red-500' : 'border-black dark:border-neutral-700'
|
||||
}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirm(!showConfirm)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600 dark:text-neutral-500 dark:hover:text-neutral-300"
|
||||
>
|
||||
{showConfirm ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="flex-1 px-4 py-3 bg-slate-100 dark:bg-neutral-800 border-2 border-black dark:border-neutral-700 rounded-lg font-bold text-slate-700 dark:text-neutral-300 hover:bg-slate-200 dark:hover:bg-neutral-700 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit || isLoading}
|
||||
className="flex-1 px-4 py-3 bg-indigo-500 border-2 border-black dark:border-indigo-600 rounded-lg font-bold text-white hover:bg-indigo-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(79,70,229,0.5)]"
|
||||
>
|
||||
{isLoading ? 'Changing...' : 'Change Password'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { PenTool, Trash2, FolderInput, ArrowRight, Check, Clock, Copy, Download } from 'lucide-react';
|
||||
import { PenTool, Trash2, FolderInput, ArrowRight, Check, Clock, Copy, Download, Lock } from 'lucide-react';
|
||||
import type { Drawing, Collection } from '../types';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import clsx from 'clsx';
|
||||
@@ -24,6 +24,8 @@ interface DrawingCardProps {
|
||||
onDragStart?: (e: React.DragEvent, id: string) => void;
|
||||
onMouseDown?: (e: React.MouseEvent, id: string) => void;
|
||||
onPreviewGenerated?: (id: string, preview: string) => void;
|
||||
onMoveToVault?: (id: string) => void;
|
||||
isVaultSetup?: boolean;
|
||||
}
|
||||
|
||||
const ContextMenuPortal: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
@@ -44,6 +46,8 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
|
||||
onDragStart,
|
||||
onMouseDown,
|
||||
onPreviewGenerated,
|
||||
onMoveToVault,
|
||||
isVaultSetup = false,
|
||||
}) => {
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [showMoveSubmenu, setShowMoveSubmenu] = useState(false);
|
||||
@@ -336,6 +340,18 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
|
||||
<Download size={14} /> Export
|
||||
</button>
|
||||
|
||||
{isVaultSetup && onMoveToVault && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onMoveToVault(drawing.id);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
className="w-full px-3 py-2 text-sm text-left text-amber-600 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-900/30 flex items-center gap-2"
|
||||
>
|
||||
<Lock size={14} /> Move to Private Vault
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="border-t border-slate-50 dark:border-slate-700 my-1"></div>
|
||||
|
||||
<button
|
||||
|
||||
@@ -11,6 +11,7 @@ interface LayoutProps {
|
||||
onEditCollection: (id: string, name: string) => void;
|
||||
onDeleteCollection: (id: string) => void;
|
||||
onDrop?: (e: React.DragEvent, collectionId: string | null) => void;
|
||||
onDropToVault?: (e: React.DragEvent) => void;
|
||||
}
|
||||
|
||||
export const Layout: React.FC<LayoutProps> = ({
|
||||
@@ -21,7 +22,8 @@ export const Layout: React.FC<LayoutProps> = ({
|
||||
onCreateCollection,
|
||||
onEditCollection,
|
||||
onDeleteCollection,
|
||||
onDrop
|
||||
onDrop,
|
||||
onDropToVault
|
||||
}) => {
|
||||
const [sidebarWidth, setSidebarWidth] = useState(260);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
@@ -76,6 +78,7 @@ export const Layout: React.FC<LayoutProps> = ({
|
||||
onEditCollection={onEditCollection}
|
||||
onDeleteCollection={onDeleteCollection}
|
||||
onDrop={onDrop}
|
||||
onDropToVault={onDropToVault}
|
||||
/>
|
||||
|
||||
{/* Resize Handle */}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, Lock, Eye, EyeOff, AlertCircle, CheckCircle2 } from 'lucide-react';
|
||||
import { validatePasswordStrength } from '../utils/crypto';
|
||||
|
||||
interface PrivateVaultSetupProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSetup: (password: string, hint?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const PrivateVaultSetup: React.FC<PrivateVaultSetupProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSetup,
|
||||
}) => {
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [hint, setHint] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const passwordStrength = validatePasswordStrength(password);
|
||||
const passwordsMatch = password === confirmPassword;
|
||||
const canSubmit = passwordStrength.isValid && passwordsMatch && password.length > 0;
|
||||
|
||||
const getStrengthColor = (score: number) => {
|
||||
if (score <= 1) return 'bg-red-500';
|
||||
if (score === 2) return 'bg-orange-500';
|
||||
if (score === 3) return 'bg-yellow-500';
|
||||
return 'bg-green-500';
|
||||
};
|
||||
|
||||
const getStrengthText = (score: number) => {
|
||||
if (score <= 1) return 'Weak';
|
||||
if (score === 2) return 'Fair';
|
||||
if (score === 3) return 'Good';
|
||||
return 'Strong';
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await onSetup(password, hint || undefined);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to set up vault');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setPassword('');
|
||||
setConfirmPassword('');
|
||||
setHint('');
|
||||
setError(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-2xl border-2 border-black dark:border-neutral-700 shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] dark:shadow-[8px_8px_0px_0px_rgba(255,255,255,0.2)] w-full max-w-md mx-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b-2 border-black dark:border-neutral-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-indigo-100 dark:bg-indigo-900/30 rounded-xl flex items-center justify-center">
|
||||
<Lock size={20} className="text-indigo-600 dark:text-indigo-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-900 dark:text-white">Set Up Private Vault</h2>
|
||||
<p className="text-sm text-slate-500 dark:text-neutral-400">Protect your drawings with a password</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-2 hover:bg-slate-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
|
||||
>
|
||||
<X size={20} className="text-slate-500 dark:text-neutral-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-50 dark:bg-red-900/20 border-2 border-red-200 dark:border-red-800 rounded-lg">
|
||||
<AlertCircle size={16} className="text-red-600 dark:text-red-400" />
|
||||
<span className="text-sm text-red-700 dark:text-red-300">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Password */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-bold text-slate-700 dark:text-neutral-300">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
className="w-full px-4 py-3 pr-12 bg-white dark:bg-neutral-800 border-2 border-black dark:border-neutral-700 rounded-lg text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600 dark:text-neutral-500 dark:hover:text-neutral-300"
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Strength Indicator */}
|
||||
{password.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-1">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`h-1.5 flex-1 rounded-full transition-colors ${
|
||||
i <= passwordStrength.score ? getStrengthColor(passwordStrength.score) : 'bg-slate-200 dark:bg-neutral-700'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className={`text-xs font-medium ${
|
||||
passwordStrength.score <= 1 ? 'text-red-600 dark:text-red-400' :
|
||||
passwordStrength.score === 2 ? 'text-orange-600 dark:text-orange-400' :
|
||||
passwordStrength.score === 3 ? 'text-yellow-600 dark:text-yellow-400' :
|
||||
'text-green-600 dark:text-green-400'
|
||||
}`}>
|
||||
{getStrengthText(passwordStrength.score)}
|
||||
</p>
|
||||
{passwordStrength.feedback.length > 0 && (
|
||||
<ul className="text-xs text-slate-500 dark:text-neutral-400 space-y-1">
|
||||
{passwordStrength.feedback.map((feedback, i) => (
|
||||
<li key={i}>• {feedback}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirm Password */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-bold text-slate-700 dark:text-neutral-300">
|
||||
Confirm Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="Confirm your password"
|
||||
className={`w-full px-4 py-3 pr-12 bg-white dark:bg-neutral-800 border-2 rounded-lg text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 ${
|
||||
confirmPassword.length > 0 && !passwordsMatch
|
||||
? 'border-red-500'
|
||||
: 'border-black dark:border-neutral-700'
|
||||
}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600 dark:text-neutral-500 dark:hover:text-neutral-300"
|
||||
>
|
||||
{showConfirmPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
{confirmPassword.length > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
{passwordsMatch ? (
|
||||
<>
|
||||
<CheckCircle2 size={14} className="text-green-600 dark:text-green-400" />
|
||||
<span className="text-xs text-green-600 dark:text-green-400">Passwords match</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle size={14} className="text-red-600 dark:text-red-400" />
|
||||
<span className="text-xs text-red-600 dark:text-red-400">Passwords do not match</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password Hint (Optional) */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-bold text-slate-700 dark:text-neutral-300">
|
||||
Password Hint <span className="font-normal text-slate-400 dark:text-neutral-500">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={hint}
|
||||
onChange={(e) => setHint(e.target.value)}
|
||||
placeholder="A hint to help you remember"
|
||||
className="w-full px-4 py-3 bg-white dark:bg-neutral-800 border-2 border-black dark:border-neutral-700 rounded-lg text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Warning */}
|
||||
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-200 dark:border-amber-800 rounded-lg">
|
||||
<p className="text-xs text-amber-700 dark:text-amber-300">
|
||||
<strong>Important:</strong> There is no way to recover your password. If you forget it,
|
||||
all private drawings will be permanently inaccessible.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="flex-1 px-4 py-3 bg-slate-100 dark:bg-neutral-800 border-2 border-black dark:border-neutral-700 rounded-lg font-bold text-slate-700 dark:text-neutral-300 hover:bg-slate-200 dark:hover:bg-neutral-700 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit || isLoading}
|
||||
className="flex-1 px-4 py-3 bg-indigo-500 border-2 border-black dark:border-indigo-600 rounded-lg font-bold text-white hover:bg-indigo-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(79,70,229,0.5)]"
|
||||
>
|
||||
{isLoading ? 'Setting up...' : 'Set Up Vault'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { LayoutGrid, Folder, Plus, Trash2, Edit2, Archive, FolderOpen, Settings as SettingsIcon } from 'lucide-react';
|
||||
import { LayoutGrid, Folder, Plus, Trash2, Edit2, Archive, FolderOpen, Settings as SettingsIcon, Lock, Unlock } from 'lucide-react';
|
||||
import type { Collection } from '../types';
|
||||
import clsx from 'clsx';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
import { Logo } from './Logo';
|
||||
import { useVault } from '../context/VaultContext';
|
||||
|
||||
interface SidebarProps {
|
||||
collections: Collection[];
|
||||
@@ -14,6 +15,7 @@ interface SidebarProps {
|
||||
onEditCollection: (id: string, name: string) => void;
|
||||
onDeleteCollection: (id: string) => void;
|
||||
onDrop?: (e: React.DragEvent, collectionId: string | null) => void;
|
||||
onDropToVault?: (e: React.DragEvent) => void;
|
||||
}
|
||||
|
||||
interface SidebarItemProps {
|
||||
@@ -109,6 +111,98 @@ const SidebarItem: React.FC<SidebarItemProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Private Folder Item Component
|
||||
const PrivateFolderItem: React.FC<{
|
||||
isActive: boolean;
|
||||
onSelectCollection: (id: string | null | undefined) => void;
|
||||
onDropToVault?: (e: React.DragEvent) => void;
|
||||
}> = ({ isActive, onSelectCollection, onDropToVault }) => {
|
||||
const vault = useVault();
|
||||
const navigate = useNavigate();
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
// Don't show if still loading
|
||||
if (vault.isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
// Always navigate to /private - that page handles setup/unlock flow
|
||||
onSelectCollection('private');
|
||||
navigate('/private');
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Show drag over state for any drag (the actual check happens on drop)
|
||||
setIsDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
onDropToVault?.(e);
|
||||
};
|
||||
|
||||
// Show different badge based on vault state
|
||||
const getBadge = () => {
|
||||
if (isDragOver) {
|
||||
return (
|
||||
<span className="text-xs bg-amber-200 dark:bg-amber-800 text-amber-800 dark:text-amber-200 px-1.5 py-0.5 rounded-md font-bold animate-pulse">
|
||||
Drop
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (!vault.isSetup) {
|
||||
return (
|
||||
<span className="text-xs bg-indigo-100 dark:bg-indigo-900/50 text-indigo-600 dark:text-indigo-400 px-1.5 py-0.5 rounded-md font-bold">
|
||||
Setup
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (vault.privateDrawingsCount > 0) {
|
||||
return (
|
||||
<span className="text-xs bg-amber-100 dark:bg-amber-900/50 text-amber-700 dark:text-amber-300 px-1.5 py-0.5 rounded-md font-bold">
|
||||
{vault.privateDrawingsCount}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pl-3 pr-2">
|
||||
<button
|
||||
onClick={handleClick}
|
||||
onDragOver={vault.isSetup ? handleDragOver : undefined}
|
||||
onDragLeave={vault.isSetup ? handleDragLeave : undefined}
|
||||
onDrop={vault.isSetup ? handleDrop : undefined}
|
||||
className={clsx(
|
||||
"w-full flex items-center gap-3 px-3 py-2.5 text-sm font-bold rounded-lg transition-all duration-200 border-2",
|
||||
isActive || isDragOver
|
||||
? "bg-amber-50 dark:bg-amber-900/30 text-amber-900 dark:text-amber-300 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] -translate-y-0.5"
|
||||
: "text-slate-600 dark:text-neutral-400 border-transparent hover:bg-amber-50 dark:hover:bg-amber-900/30 hover:text-amber-900 dark:hover:text-amber-300 hover:border-black dark:hover:border-neutral-700 hover:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5"
|
||||
)}
|
||||
>
|
||||
{vault.isSetup && vault.isUnlocked ? (
|
||||
<Unlock size={18} className={clsx(isActive || isDragOver ? "text-amber-900 dark:text-amber-300" : "text-amber-500 dark:text-amber-400")} />
|
||||
) : (
|
||||
<Lock size={18} className={clsx(isActive || isDragOver ? "text-amber-900 dark:text-amber-300" : "text-amber-500 dark:text-amber-400")} />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 text-left">Private</span>
|
||||
{getBadge()}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export const Sidebar: React.FC<SidebarProps> = ({
|
||||
@@ -118,7 +212,8 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
onCreateCollection,
|
||||
onEditCollection,
|
||||
onDeleteCollection,
|
||||
onDrop
|
||||
onDrop,
|
||||
onDropToVault
|
||||
}) => {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [newCollectionName, setNewCollectionName] = useState('');
|
||||
@@ -206,6 +301,12 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
onClick={() => onSelectCollection(null)}
|
||||
onDrop={onDrop}
|
||||
/>
|
||||
|
||||
<PrivateFolderItem
|
||||
isActive={selectedCollectionId === 'private'}
|
||||
onSelectCollection={onSelectCollection}
|
||||
onDropToVault={onDropToVault}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, Lock, Eye, EyeOff, AlertCircle, HelpCircle } from 'lucide-react';
|
||||
|
||||
interface UnlockVaultModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onUnlock: (password: string) => Promise<boolean>;
|
||||
passwordHint?: string | null;
|
||||
}
|
||||
|
||||
export const UnlockVaultModal: React.FC<UnlockVaultModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onUnlock,
|
||||
passwordHint,
|
||||
}) => {
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showHint, setShowHint] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!password) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const success = await onUnlock(password);
|
||||
if (success) {
|
||||
setPassword('');
|
||||
onClose();
|
||||
} else {
|
||||
setError('Incorrect password');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to unlock vault');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setPassword('');
|
||||
setError(null);
|
||||
setShowHint(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-2xl border-2 border-black dark:border-neutral-700 shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] dark:shadow-[8px_8px_0px_0px_rgba(255,255,255,0.2)] w-full max-w-sm mx-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b-2 border-black dark:border-neutral-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-amber-100 dark:bg-amber-900/30 rounded-xl flex items-center justify-center">
|
||||
<Lock size={20} className="text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-900 dark:text-white">Unlock Vault</h2>
|
||||
<p className="text-sm text-slate-500 dark:text-neutral-400">Enter your password</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-2 hover:bg-slate-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
|
||||
>
|
||||
<X size={20} className="text-slate-500 dark:text-neutral-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-50 dark:bg-red-900/20 border-2 border-red-200 dark:border-red-800 rounded-lg">
|
||||
<AlertCircle size={16} className="text-red-600 dark:text-red-400" />
|
||||
<span className="text-sm text-red-700 dark:text-red-300">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Password */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-bold text-slate-700 dark:text-neutral-300">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
autoFocus
|
||||
className="w-full px-4 py-3 pr-12 bg-white dark:bg-neutral-800 border-2 border-black dark:border-neutral-700 rounded-lg text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-amber-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600 dark:text-neutral-500 dark:hover:text-neutral-300"
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password Hint */}
|
||||
{passwordHint && (
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowHint(!showHint)}
|
||||
className="flex items-center gap-1.5 text-sm text-slate-500 dark:text-neutral-400 hover:text-slate-700 dark:hover:text-neutral-300 transition-colors"
|
||||
>
|
||||
<HelpCircle size={14} />
|
||||
<span>{showHint ? 'Hide hint' : 'Show password hint'}</span>
|
||||
</button>
|
||||
{showHint && (
|
||||
<div className="p-3 bg-slate-100 dark:bg-neutral-800 border-2 border-slate-200 dark:border-neutral-700 rounded-lg">
|
||||
<p className="text-sm text-slate-600 dark:text-neutral-300">{passwordHint}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="flex-1 px-4 py-3 bg-slate-100 dark:bg-neutral-800 border-2 border-black dark:border-neutral-700 rounded-lg font-bold text-slate-700 dark:text-neutral-300 hover:bg-slate-200 dark:hover:bg-neutral-700 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!password || isLoading}
|
||||
className="flex-1 px-4 py-3 bg-amber-500 border-2 border-black dark:border-amber-600 rounded-lg font-bold text-white hover:bg-amber-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(245,158,11,0.5)]"
|
||||
>
|
||||
{isLoading ? 'Unlocking...' : 'Unlock'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,240 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { deriveKey, hexToBytes, generateSalt, bytesToHex, hashPassword } from '../utils/crypto';
|
||||
import * as api from '../api';
|
||||
|
||||
interface VaultState {
|
||||
isSetup: boolean;
|
||||
isUnlocked: boolean;
|
||||
isLoading: boolean;
|
||||
passwordHint: string | null;
|
||||
privateDrawingsCount: number;
|
||||
}
|
||||
|
||||
interface VaultContextType extends VaultState {
|
||||
sessionKey: CryptoKey | null;
|
||||
salt: Uint8Array | null;
|
||||
checkVaultStatus: () => Promise<void>;
|
||||
unlock: (password: string) => Promise<boolean>;
|
||||
lock: () => void;
|
||||
setupVault: (password: string, hint?: string) => Promise<void>;
|
||||
changePassword: (oldPassword: string, newPassword: string) => Promise<void>;
|
||||
updateHint: (hint: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const VaultContext = createContext<VaultContextType | null>(null);
|
||||
|
||||
// Auto-lock timeout in milliseconds (15 minutes)
|
||||
const AUTO_LOCK_TIMEOUT = 15 * 60 * 1000;
|
||||
|
||||
export const VaultProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [state, setState] = useState<VaultState>({
|
||||
isSetup: false,
|
||||
isUnlocked: false,
|
||||
isLoading: true,
|
||||
passwordHint: null,
|
||||
privateDrawingsCount: 0,
|
||||
});
|
||||
|
||||
const [sessionKey, setSessionKey] = useState<CryptoKey | null>(null);
|
||||
const [salt, setSalt] = useState<Uint8Array | null>(null);
|
||||
const autoLockTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Reset auto-lock timer on activity
|
||||
const resetAutoLockTimer = useCallback(() => {
|
||||
if (autoLockTimer.current) {
|
||||
clearTimeout(autoLockTimer.current);
|
||||
}
|
||||
if (state.isUnlocked) {
|
||||
autoLockTimer.current = setTimeout(() => {
|
||||
lock();
|
||||
}, AUTO_LOCK_TIMEOUT);
|
||||
}
|
||||
}, [state.isUnlocked]);
|
||||
|
||||
// Set up activity listeners for auto-lock
|
||||
useEffect(() => {
|
||||
if (state.isUnlocked) {
|
||||
const handleActivity = () => resetAutoLockTimer();
|
||||
window.addEventListener('mousemove', handleActivity);
|
||||
window.addEventListener('keydown', handleActivity);
|
||||
window.addEventListener('click', handleActivity);
|
||||
resetAutoLockTimer();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleActivity);
|
||||
window.removeEventListener('keydown', handleActivity);
|
||||
window.removeEventListener('click', handleActivity);
|
||||
if (autoLockTimer.current) {
|
||||
clearTimeout(autoLockTimer.current);
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [state.isUnlocked, resetAutoLockTimer]);
|
||||
|
||||
// Check vault status on mount
|
||||
const checkVaultStatus = useCallback(async () => {
|
||||
try {
|
||||
setState(prev => ({ ...prev, isLoading: true }));
|
||||
const status = await api.getVaultStatus();
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isSetup: status.isSetup,
|
||||
passwordHint: status.hint || null,
|
||||
privateDrawingsCount: status.privateDrawingsCount || 0,
|
||||
isLoading: false,
|
||||
}));
|
||||
if (status.salt) {
|
||||
setSalt(hexToBytes(status.salt));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check vault status:', error);
|
||||
setState(prev => ({ ...prev, isLoading: false }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkVaultStatus();
|
||||
}, [checkVaultStatus]);
|
||||
|
||||
// Lock the vault
|
||||
const lock = useCallback(() => {
|
||||
setSessionKey(null);
|
||||
setState(prev => ({ ...prev, isUnlocked: false }));
|
||||
if (autoLockTimer.current) {
|
||||
clearTimeout(autoLockTimer.current);
|
||||
autoLockTimer.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Unlock the vault with password
|
||||
const unlock = useCallback(async (password: string): Promise<boolean> => {
|
||||
try {
|
||||
// Hash the password the same way we did during setup
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
// First verify the password with the server
|
||||
const result = await api.verifyVaultPassword(passwordHash);
|
||||
|
||||
if (!result.success) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Derive the encryption key client-side
|
||||
const saltBytes = hexToBytes(result.salt);
|
||||
const key = await deriveKey(password, saltBytes);
|
||||
|
||||
setSalt(saltBytes);
|
||||
setSessionKey(key);
|
||||
setState(prev => ({ ...prev, isUnlocked: true }));
|
||||
resetAutoLockTimer();
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to unlock vault:', error);
|
||||
return false;
|
||||
}
|
||||
}, [resetAutoLockTimer]);
|
||||
|
||||
// Setup the vault with initial password
|
||||
const setupVault = useCallback(async (password: string, hint?: string): Promise<void> => {
|
||||
try {
|
||||
// Generate a new salt
|
||||
const newSalt = generateSalt();
|
||||
const saltHex = bytesToHex(newSalt);
|
||||
|
||||
// Hash the password for server storage
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
// Create vault on server
|
||||
await api.setupVault(passwordHash, saltHex, hint);
|
||||
|
||||
// Derive the encryption key
|
||||
const key = await deriveKey(password, newSalt);
|
||||
|
||||
setSalt(newSalt);
|
||||
setSessionKey(key);
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isSetup: true,
|
||||
isUnlocked: true,
|
||||
passwordHint: hint || null,
|
||||
}));
|
||||
resetAutoLockTimer();
|
||||
} catch (error) {
|
||||
console.error('Failed to setup vault:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [resetAutoLockTimer]);
|
||||
|
||||
// Change vault password (requires re-encrypting all private drawings)
|
||||
const changePassword = useCallback(async (oldPassword: string, newPassword: string): Promise<void> => {
|
||||
try {
|
||||
// Hash the old password the same way we did during setup
|
||||
const oldPasswordHash = await hashPassword(oldPassword);
|
||||
|
||||
// Verify old password first
|
||||
const verifyResult = await api.verifyVaultPassword(oldPasswordHash);
|
||||
if (!verifyResult.success) {
|
||||
throw new Error('Invalid current password');
|
||||
}
|
||||
|
||||
// Derive old key for decryption
|
||||
const oldSalt = hexToBytes(verifyResult.salt);
|
||||
const oldKey = await deriveKey(oldPassword, oldSalt);
|
||||
|
||||
// Generate new salt and derive new key
|
||||
const newSalt = generateSalt();
|
||||
const newSaltHex = bytesToHex(newSalt);
|
||||
const newKey = await deriveKey(newPassword, newSalt);
|
||||
const newPasswordHash = await hashPassword(newPassword);
|
||||
|
||||
// Re-encrypt all private drawings
|
||||
await api.changeVaultPassword(newPasswordHash, newSaltHex, oldKey, newKey);
|
||||
|
||||
// Update local state
|
||||
setSalt(newSalt);
|
||||
setSessionKey(newKey);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to change password:', error);
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Update password hint
|
||||
const updateHint = useCallback(async (hint: string): Promise<void> => {
|
||||
try {
|
||||
await api.updateVaultHint(hint);
|
||||
setState(prev => ({ ...prev, passwordHint: hint }));
|
||||
} catch (error) {
|
||||
console.error('Failed to update hint:', error);
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<VaultContext.Provider
|
||||
value={{
|
||||
...state,
|
||||
sessionKey,
|
||||
salt,
|
||||
checkVaultStatus,
|
||||
unlock,
|
||||
lock,
|
||||
setupVault,
|
||||
changePassword,
|
||||
updateHint,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</VaultContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useVault = (): VaultContextType => {
|
||||
const context = useContext(VaultContext);
|
||||
if (!context) {
|
||||
throw new Error('useVault must be used within a VaultProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -10,6 +10,9 @@ import { useDebounce } from '../hooks/useDebounce';
|
||||
import clsx from 'clsx';
|
||||
import { ConfirmModal } from '../components/ConfirmModal';
|
||||
import { importDrawings } from '../utils/importUtils';
|
||||
import { useVault } from '../context/VaultContext';
|
||||
import { encryptDrawing, generateLockedPreview } from '../utils/crypto';
|
||||
import { UnlockVaultModal } from '../components/UnlockVaultModal';
|
||||
|
||||
type Point = { x: number; y: number };
|
||||
|
||||
@@ -102,6 +105,11 @@ export const Dashboard: React.FC = () => {
|
||||
});
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Vault state
|
||||
const vault = useVault();
|
||||
const [drawingToMoveToVault, setDrawingToMoveToVault] = useState<string | null>(null);
|
||||
const [showVaultUnlockModal, setShowVaultUnlockModal] = useState(false);
|
||||
// navigate is already declared at the top
|
||||
|
||||
const refreshData = useCallback(async () => {
|
||||
@@ -472,6 +480,106 @@ export const Dashboard: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Move drawing to private vault
|
||||
const handleMoveToVault = useCallback(async (id: string) => {
|
||||
// If vault isn't set up, redirect to settings
|
||||
if (!vault.isSetup) {
|
||||
navigate('/settings');
|
||||
return;
|
||||
}
|
||||
|
||||
// If vault isn't unlocked, show unlock modal
|
||||
if (!vault.isUnlocked || !vault.sessionKey) {
|
||||
setDrawingToMoveToVault(id);
|
||||
setShowVaultUnlockModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Proceed with encryption
|
||||
await encryptAndMoveToVault(id);
|
||||
}, [vault.isSetup, vault.isUnlocked, vault.sessionKey, navigate]);
|
||||
|
||||
const encryptAndMoveToVault = useCallback(async (id: string) => {
|
||||
if (!vault.sessionKey) return;
|
||||
|
||||
const drawing = drawings.find(d => d.id === id);
|
||||
if (!drawing) return;
|
||||
|
||||
try {
|
||||
// Encrypt the drawing data
|
||||
const dataToEncrypt = {
|
||||
elements: drawing.elements || [],
|
||||
appState: drawing.appState || {},
|
||||
files: drawing.files || {},
|
||||
};
|
||||
|
||||
const { encryptedData, iv } = await encryptDrawing(dataToEncrypt, vault.sessionKey);
|
||||
const lockedPreview = generateLockedPreview();
|
||||
|
||||
// Update drawing to be private with encrypted data (include locked preview)
|
||||
await api.lockDrawingWithPreview(id, encryptedData, iv, lockedPreview);
|
||||
|
||||
// Remove from current view
|
||||
setDrawings(prev => prev.filter(d => d.id !== id));
|
||||
vault.checkVaultStatus(); // Refresh vault status to update count
|
||||
} catch (err) {
|
||||
console.error("Failed to move to vault:", err);
|
||||
}
|
||||
}, [vault.sessionKey, drawings, vault]);
|
||||
|
||||
const handleVaultUnlockForMove = useCallback(async (password: string) => {
|
||||
const success = await vault.unlock(password);
|
||||
if (success && drawingToMoveToVault) {
|
||||
setShowVaultUnlockModal(false);
|
||||
await encryptAndMoveToVault(drawingToMoveToVault);
|
||||
setDrawingToMoveToVault(null);
|
||||
}
|
||||
return success;
|
||||
}, [vault, drawingToMoveToVault, encryptAndMoveToVault]);
|
||||
|
||||
// Handle dropping drawings to the vault
|
||||
const handleDropToVault = useCallback(async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const draggedDrawingId = e.dataTransfer.getData('drawingId');
|
||||
if (!draggedDrawingId) return;
|
||||
|
||||
// Collect IDs to move - if dragged item is selected, move all selected
|
||||
let idsToMove: string[] = [];
|
||||
if (selectedIds.has(draggedDrawingId)) {
|
||||
idsToMove = Array.from(selectedIds);
|
||||
} else {
|
||||
idsToMove = [draggedDrawingId];
|
||||
}
|
||||
|
||||
// If vault isn't set up, redirect to settings
|
||||
if (!vault.isSetup) {
|
||||
navigate('/settings');
|
||||
return;
|
||||
}
|
||||
|
||||
// If vault isn't unlocked, show unlock modal for the first drawing
|
||||
if (!vault.isUnlocked || !vault.sessionKey) {
|
||||
// Store first ID and show unlock modal
|
||||
setDrawingToMoveToVault(idsToMove[0]);
|
||||
setShowVaultUnlockModal(true);
|
||||
// Note: For bulk moves when vault is locked, we only move the first one after unlock
|
||||
// A more sophisticated approach would store all IDs, but this keeps it simple
|
||||
return;
|
||||
}
|
||||
|
||||
// Move all drawings to vault
|
||||
for (const id of idsToMove) {
|
||||
await encryptAndMoveToVault(id);
|
||||
}
|
||||
|
||||
// Clear selection if we moved selected items
|
||||
if (selectedIds.has(draggedDrawingId)) {
|
||||
setSelectedIds(new Set());
|
||||
}
|
||||
}, [selectedIds, vault.isSetup, vault.isUnlocked, vault.sessionKey, navigate, encryptAndMoveToVault]);
|
||||
|
||||
const handleBulkDuplicate = async () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
|
||||
@@ -635,6 +743,7 @@ export const Dashboard: React.FC = () => {
|
||||
onEditCollection={handleEditCollection}
|
||||
onDeleteCollection={handleDeleteCollection}
|
||||
onDrop={handleDrop}
|
||||
onDropToVault={handleDropToVault}
|
||||
>
|
||||
{/* Drag Preview */}
|
||||
<div
|
||||
@@ -912,6 +1021,7 @@ export const Dashboard: React.FC = () => {
|
||||
drawing={drawing}
|
||||
collections={collections}
|
||||
isSelected={selectedIds.has(drawing.id)}
|
||||
isTrash={isTrashView}
|
||||
onToggleSelection={(e) => handleToggleSelection(drawing.id, e)}
|
||||
onRename={handleRenameDrawing}
|
||||
onDelete={handleDeleteDrawing}
|
||||
@@ -927,6 +1037,8 @@ export const Dashboard: React.FC = () => {
|
||||
onMouseDown={handleCardMouseDown}
|
||||
onDragStart={handleCardDragStart}
|
||||
onPreviewGenerated={handlePreviewGenerated}
|
||||
onMoveToVault={!isTrashView ? handleMoveToVault : undefined}
|
||||
isVaultSetup={vault.isSetup}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
@@ -975,6 +1087,17 @@ export const Dashboard: React.FC = () => {
|
||||
onConfirm={() => setShowImportSuccess(false)}
|
||||
onCancel={() => setShowImportSuccess(false)}
|
||||
/>
|
||||
|
||||
{/* Vault Unlock Modal for Move to Vault */}
|
||||
<UnlockVaultModal
|
||||
isOpen={showVaultUnlockModal}
|
||||
onClose={() => {
|
||||
setShowVaultUnlockModal(false);
|
||||
setDrawingToMoveToVault(null);
|
||||
}}
|
||||
onUnlock={handleVaultUnlockForMove}
|
||||
passwordHint={vault.passwordHint}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useState, useRef } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Download } from 'lucide-react';
|
||||
import { ArrowLeft, Download, Lock } from 'lucide-react';
|
||||
import { Excalidraw, exportToSvg } from '@excalidraw/excalidraw';
|
||||
import '@excalidraw/excalidraw/index.css';
|
||||
import debounce from 'lodash/debounce';
|
||||
@@ -10,9 +10,12 @@ import { io, Socket } from 'socket.io-client';
|
||||
import { getUserIdentity } from '../utils/identity';
|
||||
import { reconcileElements } from '../utils/sync';
|
||||
import { exportFromEditor } from '../utils/exportUtils';
|
||||
import { encryptDrawing, decryptDrawing, generateLockedPreview } from '../utils/crypto';
|
||||
import type { UserIdentity } from '../utils/identity';
|
||||
import * as api from '../api';
|
||||
import { useTheme } from '../context/ThemeContext';
|
||||
import { useVault } from '../context/VaultContext';
|
||||
import { UnlockVaultModal } from '../components/UnlockVaultModal';
|
||||
|
||||
interface Peer extends UserIdentity {
|
||||
isActive: boolean;
|
||||
@@ -51,12 +54,16 @@ export const Editor: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { theme } = useTheme();
|
||||
const vault = useVault();
|
||||
|
||||
const [drawingName, setDrawingName] = useState('Drawing Editor');
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [initialData, setInitialData] = useState<any>(null);
|
||||
const [isSceneLoading, setIsSceneLoading] = useState(true);
|
||||
const [isPrivateDrawing, setIsPrivateDrawing] = useState(false);
|
||||
const [showUnlockModal, setShowUnlockModal] = useState(false);
|
||||
const [pendingPrivateData, setPendingPrivateData] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `${drawingName} - ExcaliDash`;
|
||||
@@ -334,13 +341,35 @@ export const Editor: React.FC = () => {
|
||||
elementCount: persistableElements.length,
|
||||
hasRenderableElements: persistableElements.some((el: any) => !el?.isDeleted),
|
||||
appState: persistableAppState,
|
||||
isPrivate: isPrivateDrawing,
|
||||
});
|
||||
|
||||
await api.updateDrawing(id, {
|
||||
elements: persistableElements,
|
||||
appState: persistableAppState,
|
||||
files: latestFilesRef.current || {},
|
||||
});
|
||||
// Handle private drawings - encrypt before saving
|
||||
if (isPrivateDrawing && vault.sessionKey) {
|
||||
const dataToEncrypt = {
|
||||
elements: persistableElements,
|
||||
appState: persistableAppState,
|
||||
files: latestFilesRef.current || {},
|
||||
};
|
||||
|
||||
const { encryptedData, iv } = await encryptDrawing(dataToEncrypt, vault.sessionKey);
|
||||
|
||||
await api.updateDrawing(id, {
|
||||
encryptedData,
|
||||
iv,
|
||||
// Don't save plaintext data for private drawings
|
||||
elements: [],
|
||||
appState: {},
|
||||
files: {},
|
||||
});
|
||||
} else {
|
||||
// Normal drawing - save plaintext
|
||||
await api.updateDrawing(id, {
|
||||
elements: persistableElements,
|
||||
appState: persistableAppState,
|
||||
files: latestFilesRef.current || {},
|
||||
});
|
||||
}
|
||||
|
||||
console.log("[Editor] Save complete", { drawingId: id });
|
||||
} catch (err) {
|
||||
@@ -356,6 +385,14 @@ export const Editor: React.FC = () => {
|
||||
const currentSnapshot = latestElementsRef.current ?? elements;
|
||||
const currentFiles = latestFilesRef.current ?? files;
|
||||
|
||||
// For private drawings, generate a locked preview instead
|
||||
if (isPrivateDrawing) {
|
||||
const lockedPreview = generateLockedPreview();
|
||||
await api.updateDrawing(id, { preview: lockedPreview });
|
||||
console.log("[Editor] Locked preview saved", { drawingId: id });
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate preview
|
||||
const svg = await exportToSvg({
|
||||
elements: currentSnapshot,
|
||||
@@ -448,6 +485,50 @@ export const Editor: React.FC = () => {
|
||||
// ------------------------------------------------------------------
|
||||
// 2. DATA LOADING
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
// Helper to decrypt and load private drawing data
|
||||
const loadDecryptedDrawing = useCallback(async (data: any, libraryItems: any[]) => {
|
||||
if (!vault.sessionKey || !data.encryptedData || !data.iv) {
|
||||
toast.error("Cannot decrypt drawing - vault not unlocked");
|
||||
navigate('/');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const decrypted = await decryptDrawing(data.encryptedData, data.iv, vault.sessionKey);
|
||||
|
||||
const elements = decrypted.elements || [];
|
||||
const files = decrypted.files || {};
|
||||
latestElementsRef.current = elements;
|
||||
latestFilesRef.current = files;
|
||||
|
||||
elements.forEach((el: any) => {
|
||||
recordElementVersion(el);
|
||||
});
|
||||
|
||||
const persistedAppState = decrypted.appState || {};
|
||||
const hydratedAppState = {
|
||||
...persistedAppState,
|
||||
viewBackgroundColor: persistedAppState.viewBackgroundColor ?? '#ffffff',
|
||||
gridSize: persistedAppState.gridSize ?? null,
|
||||
collaborators: new Map(),
|
||||
};
|
||||
|
||||
setInitialData({
|
||||
elements,
|
||||
appState: hydratedAppState,
|
||||
files,
|
||||
scrollToContent: true,
|
||||
libraryItems,
|
||||
});
|
||||
setIsSceneLoading(false);
|
||||
} catch (err) {
|
||||
console.error('Failed to decrypt drawing', err);
|
||||
toast.error("Failed to decrypt drawing");
|
||||
navigate('/private');
|
||||
}
|
||||
}, [vault.sessionKey, navigate, recordElementVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
isBootstrappingScene.current = true;
|
||||
hasHydratedInitialScene.current = false;
|
||||
@@ -458,6 +539,8 @@ export const Editor: React.FC = () => {
|
||||
setIsReady(false);
|
||||
setIsSceneLoading(true);
|
||||
setInitialData(null);
|
||||
setIsPrivateDrawing(false);
|
||||
setPendingPrivateData(null);
|
||||
|
||||
const loadData = async () => {
|
||||
if (!id) {
|
||||
@@ -476,7 +559,23 @@ export const Editor: React.FC = () => {
|
||||
]);
|
||||
setDrawingName(data.name);
|
||||
|
||||
// Use elements directly without converting - they're already normalized during import
|
||||
// Check if this is a private drawing
|
||||
if (data.isPrivate) {
|
||||
setIsPrivateDrawing(true);
|
||||
|
||||
// If vault is not unlocked, show unlock modal
|
||||
if (!vault.isUnlocked || !vault.sessionKey) {
|
||||
setPendingPrivateData({ data, libraryItems });
|
||||
setShowUnlockModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Decrypt and load the drawing
|
||||
await loadDecryptedDrawing(data, libraryItems);
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal (non-private) drawing loading
|
||||
const elements = data.elements || [];
|
||||
const files = data.files || {};
|
||||
latestElementsRef.current = elements;
|
||||
@@ -508,11 +607,24 @@ export const Editor: React.FC = () => {
|
||||
latestFilesRef.current = {};
|
||||
setInitialData(buildEmptyScene());
|
||||
} finally {
|
||||
setIsSceneLoading(false);
|
||||
if (!isPrivateDrawing) {
|
||||
setIsSceneLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
loadData();
|
||||
}, [id, recordElementVersion, buildEmptyScene]);
|
||||
}, [id, recordElementVersion, buildEmptyScene, vault.isUnlocked, vault.sessionKey, loadDecryptedDrawing]);
|
||||
|
||||
// Handle vault unlock for pending private drawing
|
||||
const handleVaultUnlock = useCallback(async (password: string) => {
|
||||
const success = await vault.unlock(password);
|
||||
if (success && pendingPrivateData) {
|
||||
setShowUnlockModal(false);
|
||||
await loadDecryptedDrawing(pendingPrivateData.data, pendingPrivateData.libraryItems);
|
||||
setPendingPrivateData(null);
|
||||
}
|
||||
return success;
|
||||
}, [vault, pendingPrivateData, loadDecryptedDrawing]);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 3. HANDLERS
|
||||
@@ -660,6 +772,14 @@ export const Editor: React.FC = () => {
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
|
||||
{/* Private indicator */}
|
||||
{isPrivateDrawing && (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded-lg text-sm font-medium">
|
||||
<Lock size={14} />
|
||||
<span>Private</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isRenaming ? (
|
||||
<form onSubmit={handleRenameSubmit}>
|
||||
<input
|
||||
@@ -760,6 +880,17 @@ export const Editor: React.FC = () => {
|
||||
)}
|
||||
<Toaster position="bottom-center" />
|
||||
</div>
|
||||
|
||||
{/* Unlock Modal for Private Drawings */}
|
||||
<UnlockVaultModal
|
||||
isOpen={showUnlockModal}
|
||||
onClose={() => {
|
||||
setShowUnlockModal(false);
|
||||
navigate('/');
|
||||
}}
|
||||
onUnlock={handleVaultUnlock}
|
||||
passwordHint={vault.passwordHint}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,21 +3,31 @@ import { Layout } from '../components/Layout';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import * as api from '../api';
|
||||
import type { Collection } from '../types';
|
||||
import { Database, FileJson, Upload, Moon, Sun, Info, HardDrive } from 'lucide-react';
|
||||
import { Database, FileJson, Upload, Moon, Sun, Info, HardDrive, Lock, Key, ShieldCheck, Unlock } from 'lucide-react';
|
||||
import { ConfirmModal } from '../components/ConfirmModal';
|
||||
import { importDrawings } from '../utils/importUtils';
|
||||
import { useTheme } from '../context/ThemeContext';
|
||||
import { useVault } from '../context/VaultContext';
|
||||
import { PrivateVaultSetup } from '../components/PrivateVaultSetup';
|
||||
import { UnlockVaultModal } from '../components/UnlockVaultModal';
|
||||
import { ChangeVaultPassword } from '../components/ChangeVaultPassword';
|
||||
|
||||
export const Settings: React.FC = () => {
|
||||
const [collections, setCollections] = useState<Collection[]>([]);
|
||||
const navigate = useNavigate();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const vault = useVault();
|
||||
|
||||
// Import state
|
||||
const [importConfirmation, setImportConfirmation] = useState<{ isOpen: boolean; file: File | null }>({ isOpen: false, file: null });
|
||||
const [importError, setImportError] = useState<{ isOpen: boolean; message: string }>({ isOpen: false, message: '' });
|
||||
const [importSuccess, setImportSuccess] = useState(false);
|
||||
|
||||
// Vault modal state
|
||||
const [showVaultSetup, setShowVaultSetup] = useState(false);
|
||||
const [showUnlockModal, setShowUnlockModal] = useState(false);
|
||||
const [showChangePassword, setShowChangePassword] = useState(false);
|
||||
|
||||
const appVersion = import.meta.env.VITE_APP_VERSION || 'Unknown version';
|
||||
const buildLabel = import.meta.env.VITE_APP_BUILD_LABEL;
|
||||
|
||||
@@ -71,6 +81,114 @@ export const Settings: React.FC = () => {
|
||||
Settings
|
||||
</h1>
|
||||
|
||||
{/* Private Vault Section */}
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4 pl-1">Private Vault</h2>
|
||||
<div className="bg-white dark:bg-neutral-900 border-2 border-black dark:border-neutral-700 rounded-2xl shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] p-6">
|
||||
{vault.isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
|
||||
</div>
|
||||
) : !vault.isSetup ? (
|
||||
<div className="flex flex-col items-center text-center py-6">
|
||||
<div className="w-16 h-16 bg-indigo-100 dark:bg-indigo-900/30 rounded-2xl flex items-center justify-center mb-4">
|
||||
<Lock size={32} className="text-indigo-600 dark:text-indigo-400" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-slate-900 dark:text-white mb-2">
|
||||
Set Up Private Vault
|
||||
</h3>
|
||||
<p className="text-slate-500 dark:text-neutral-400 mb-4 max-w-md">
|
||||
Protect sensitive drawings with end-to-end encryption.
|
||||
Only you can access them with your password.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowVaultSetup(true)}
|
||||
className="px-6 py-3 bg-indigo-500 border-2 border-black dark:border-indigo-600 rounded-lg font-bold text-white hover:bg-indigo-600 transition-colors shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(79,70,229,0.5)]"
|
||||
>
|
||||
Set Up Vault
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Vault Status */}
|
||||
<div className="flex items-center justify-between p-4 bg-slate-50 dark:bg-neutral-800 rounded-xl border-2 border-slate-200 dark:border-neutral-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${
|
||||
vault.isUnlocked
|
||||
? 'bg-green-100 dark:bg-green-900/30'
|
||||
: 'bg-amber-100 dark:bg-amber-900/30'
|
||||
}`}>
|
||||
{vault.isUnlocked ? (
|
||||
<Unlock size={20} className="text-green-600 dark:text-green-400" />
|
||||
) : (
|
||||
<Lock size={20} className="text-amber-600 dark:text-amber-400" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-slate-900 dark:text-white">
|
||||
{vault.isUnlocked ? 'Vault Unlocked' : 'Vault Locked'}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500 dark:text-neutral-400">
|
||||
{vault.privateDrawingsCount} private drawing{vault.privateDrawingsCount !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => vault.isUnlocked ? vault.lock() : setShowUnlockModal(true)}
|
||||
className={`px-4 py-2 border-2 border-black dark:border-neutral-600 rounded-lg font-bold transition-colors shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] ${
|
||||
vault.isUnlocked
|
||||
? 'bg-slate-100 dark:bg-neutral-700 text-slate-700 dark:text-neutral-300 hover:bg-slate-200 dark:hover:bg-neutral-600'
|
||||
: 'bg-amber-500 text-white hover:bg-amber-600'
|
||||
}`}
|
||||
>
|
||||
{vault.isUnlocked ? 'Lock' : 'Unlock'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Vault Actions */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{/* View Private Drawings */}
|
||||
<button
|
||||
onClick={() => navigate('/private')}
|
||||
className="flex items-center gap-3 p-4 bg-white dark:bg-neutral-800 border-2 border-black dark:border-neutral-700 rounded-xl hover:bg-slate-50 dark:hover:bg-neutral-750 transition-colors shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)]"
|
||||
>
|
||||
<div className="w-10 h-10 bg-indigo-100 dark:bg-indigo-900/30 rounded-lg flex items-center justify-center">
|
||||
<ShieldCheck size={20} className="text-indigo-600 dark:text-indigo-400" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="font-bold text-slate-900 dark:text-white">View Private Drawings</p>
|
||||
<p className="text-sm text-slate-500 dark:text-neutral-400">Access encrypted drawings</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Change Password */}
|
||||
<button
|
||||
onClick={() => setShowChangePassword(true)}
|
||||
className="flex items-center gap-3 p-4 bg-white dark:bg-neutral-800 border-2 border-black dark:border-neutral-700 rounded-xl hover:bg-slate-50 dark:hover:bg-neutral-750 transition-colors shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)]"
|
||||
>
|
||||
<div className="w-10 h-10 bg-slate-100 dark:bg-neutral-700 rounded-lg flex items-center justify-center">
|
||||
<Key size={20} className="text-slate-600 dark:text-neutral-400" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="font-bold text-slate-900 dark:text-white">Change Password</p>
|
||||
<p className="text-sm text-slate-500 dark:text-neutral-400">Update vault password</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Password Hint */}
|
||||
{vault.passwordHint && (
|
||||
<div className="p-3 bg-slate-50 dark:bg-neutral-800 border-2 border-slate-200 dark:border-neutral-700 rounded-lg">
|
||||
<p className="text-xs text-slate-500 dark:text-neutral-400 mb-1">Password Hint</p>
|
||||
<p className="text-sm text-slate-700 dark:text-neutral-300">{vault.passwordHint}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4 pl-1">General</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{/* Theme Toggle */}
|
||||
<button
|
||||
@@ -298,6 +416,26 @@ export const Settings: React.FC = () => {
|
||||
onConfirm={() => setImportSuccess(false)}
|
||||
onCancel={() => setImportSuccess(false)}
|
||||
/>
|
||||
|
||||
{/* Vault Modals */}
|
||||
<PrivateVaultSetup
|
||||
isOpen={showVaultSetup}
|
||||
onClose={() => setShowVaultSetup(false)}
|
||||
onSetup={vault.setupVault}
|
||||
/>
|
||||
|
||||
<UnlockVaultModal
|
||||
isOpen={showUnlockModal}
|
||||
onClose={() => setShowUnlockModal(false)}
|
||||
onUnlock={vault.unlock}
|
||||
passwordHint={vault.passwordHint}
|
||||
/>
|
||||
|
||||
<ChangeVaultPassword
|
||||
isOpen={showChangePassword}
|
||||
onClose={() => setShowChangePassword(false)}
|
||||
onChangePassword={vault.changePassword}
|
||||
/>
|
||||
</Layout >
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,6 +8,10 @@ export interface Drawing {
|
||||
updatedAt: number;
|
||||
createdAt: number;
|
||||
preview?: string;
|
||||
// Privacy/Encryption fields
|
||||
isPrivate?: boolean;
|
||||
encryptedData?: string | null;
|
||||
iv?: string | null;
|
||||
}
|
||||
|
||||
export interface Collection {
|
||||
@@ -15,3 +19,16 @@ export interface Collection {
|
||||
name: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
// Vault types
|
||||
export interface VaultStatus {
|
||||
isSetup: boolean;
|
||||
salt?: string;
|
||||
hint?: string | null;
|
||||
privateDrawingsCount?: number;
|
||||
}
|
||||
|
||||
export interface VaultVerifyResult {
|
||||
success: boolean;
|
||||
salt: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Cryptographic utilities for the Private Vault feature.
|
||||
* Uses Web Crypto API for secure client-side encryption.
|
||||
*
|
||||
* Security Model:
|
||||
* - PBKDF2 for key derivation (100,000 iterations)
|
||||
* - AES-256-GCM for authenticated encryption
|
||||
* - Random IV per encryption operation
|
||||
* - Zero-knowledge: server never sees plaintext private data
|
||||
*/
|
||||
|
||||
// Constants for cryptographic operations
|
||||
const PBKDF2_ITERATIONS = 100000;
|
||||
const SALT_LENGTH = 16; // 128 bits
|
||||
const IV_LENGTH = 12; // 96 bits (recommended for GCM)
|
||||
const KEY_LENGTH = 256; // AES-256
|
||||
|
||||
/**
|
||||
* Generate a random salt for key derivation
|
||||
*/
|
||||
export function generateSalt(): Uint8Array {
|
||||
const salt = new Uint8Array(SALT_LENGTH);
|
||||
crypto.getRandomValues(salt);
|
||||
return salt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random IV for encryption
|
||||
*/
|
||||
export function generateIV(): Uint8Array {
|
||||
const iv = new Uint8Array(IV_LENGTH);
|
||||
crypto.getRandomValues(iv);
|
||||
return iv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Uint8Array to hex string for storage
|
||||
*/
|
||||
export function bytesToHex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex string back to Uint8Array
|
||||
*/
|
||||
export function hexToBytes(hex: string): Uint8Array {
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive an encryption key from a password using PBKDF2
|
||||
* @param password - The user's password
|
||||
* @param salt - Salt for key derivation (should be stored for later decryption)
|
||||
* @returns CryptoKey suitable for AES-GCM encryption
|
||||
*/
|
||||
export async function deriveKey(
|
||||
password: string,
|
||||
salt: Uint8Array
|
||||
): Promise<CryptoKey> {
|
||||
// Import password as a key
|
||||
const passwordKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
new TextEncoder().encode(password),
|
||||
"PBKDF2",
|
||||
false,
|
||||
["deriveBits", "deriveKey"]
|
||||
);
|
||||
|
||||
// Create a proper ArrayBuffer from the salt
|
||||
const saltBuffer = new Uint8Array(salt).buffer as ArrayBuffer;
|
||||
|
||||
// Derive the actual encryption key
|
||||
return crypto.subtle.deriveKey(
|
||||
{
|
||||
name: "PBKDF2",
|
||||
salt: saltBuffer,
|
||||
iterations: PBKDF2_ITERATIONS,
|
||||
hash: "SHA-256",
|
||||
},
|
||||
passwordKey,
|
||||
{ name: "AES-GCM", length: KEY_LENGTH },
|
||||
false, // Not extractable
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt drawing data using AES-256-GCM
|
||||
* @param data - Drawing data (elements, appState, files)
|
||||
* @param key - Derived encryption key
|
||||
* @returns Encrypted data as base64 string and IV as hex string
|
||||
*/
|
||||
export async function encryptDrawing(
|
||||
data: { elements: any[]; appState: any; files: any },
|
||||
key: CryptoKey
|
||||
): Promise<{ encryptedData: string; iv: string }> {
|
||||
const iv = generateIV();
|
||||
const plaintext = new TextEncoder().encode(JSON.stringify(data));
|
||||
const ivBuffer = new Uint8Array(iv).buffer as ArrayBuffer;
|
||||
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv: ivBuffer },
|
||||
key,
|
||||
plaintext
|
||||
);
|
||||
|
||||
return {
|
||||
encryptedData: btoa(String.fromCharCode(...new Uint8Array(ciphertext))),
|
||||
iv: bytesToHex(iv),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt drawing data using AES-256-GCM
|
||||
* @param encryptedData - Base64 encoded encrypted data
|
||||
* @param iv - Hex encoded initialization vector
|
||||
* @param key - Derived encryption key
|
||||
* @returns Decrypted drawing data
|
||||
*/
|
||||
export async function decryptDrawing(
|
||||
encryptedData: string,
|
||||
iv: string,
|
||||
key: CryptoKey
|
||||
): Promise<{ elements: any[]; appState: any; files: any }> {
|
||||
const ciphertext = Uint8Array.from(atob(encryptedData), (c) =>
|
||||
c.charCodeAt(0)
|
||||
);
|
||||
const ivBytes = hexToBytes(iv);
|
||||
const ivBuffer = new Uint8Array(ivBytes).buffer as ArrayBuffer;
|
||||
|
||||
const plaintext = await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: ivBuffer },
|
||||
key,
|
||||
ciphertext
|
||||
);
|
||||
|
||||
return JSON.parse(new TextDecoder().decode(plaintext));
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a simple string (e.g., drawing name)
|
||||
* @param str - String to encrypt
|
||||
* @param key - Derived encryption key
|
||||
* @returns Encrypted string as base64 with IV prefix
|
||||
*/
|
||||
export async function encryptString(
|
||||
str: string,
|
||||
key: CryptoKey
|
||||
): Promise<string> {
|
||||
const iv = generateIV();
|
||||
const plaintext = new TextEncoder().encode(str);
|
||||
const ivBuffer = new Uint8Array(iv).buffer as ArrayBuffer;
|
||||
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv: ivBuffer },
|
||||
key,
|
||||
plaintext
|
||||
);
|
||||
|
||||
// Combine IV and ciphertext for storage
|
||||
const combined = new Uint8Array(iv.length + ciphertext.byteLength);
|
||||
combined.set(iv);
|
||||
combined.set(new Uint8Array(ciphertext), iv.length);
|
||||
|
||||
return btoa(String.fromCharCode(...combined));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a string encrypted with encryptString
|
||||
* @param encrypted - Base64 encoded encrypted string (IV + ciphertext)
|
||||
* @param key - Derived encryption key
|
||||
* @returns Decrypted string
|
||||
*/
|
||||
export async function decryptString(
|
||||
encrypted: string,
|
||||
key: CryptoKey
|
||||
): Promise<string> {
|
||||
const combined = Uint8Array.from(atob(encrypted), (c) => c.charCodeAt(0));
|
||||
|
||||
// Extract IV and ciphertext
|
||||
const iv = combined.slice(0, IV_LENGTH);
|
||||
const ciphertext = combined.slice(IV_LENGTH);
|
||||
const ivBuffer = new Uint8Array(iv).buffer as ArrayBuffer;
|
||||
|
||||
const plaintext = await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: ivBuffer },
|
||||
key,
|
||||
ciphertext
|
||||
);
|
||||
|
||||
return new TextDecoder().decode(plaintext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a password for storage (client-side hash before sending to server)
|
||||
* Server will apply bcrypt on top of this for additional security
|
||||
*/
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(password);
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
||||
return bytesToHex(new Uint8Array(hashBuffer));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate password strength
|
||||
* Returns an object with validation results
|
||||
*/
|
||||
export function validatePasswordStrength(password: string): {
|
||||
isValid: boolean;
|
||||
score: number; // 0-4
|
||||
feedback: string[];
|
||||
} {
|
||||
const feedback: string[] = [];
|
||||
let score = 0;
|
||||
|
||||
if (password.length >= 8) {
|
||||
score++;
|
||||
} else {
|
||||
feedback.push("Password should be at least 8 characters");
|
||||
}
|
||||
|
||||
if (password.length >= 12) {
|
||||
score++;
|
||||
}
|
||||
|
||||
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) {
|
||||
score++;
|
||||
} else {
|
||||
feedback.push("Include both uppercase and lowercase letters");
|
||||
}
|
||||
|
||||
if (/\d/.test(password)) {
|
||||
score++;
|
||||
} else {
|
||||
feedback.push("Include at least one number");
|
||||
}
|
||||
|
||||
if (/[!@#$%^&*(),.?":{}|<>]/.test(password)) {
|
||||
score++;
|
||||
} else {
|
||||
feedback.push("Include at least one special character");
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: password.length >= 8,
|
||||
score: Math.min(score, 4),
|
||||
feedback,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a locked preview SVG placeholder
|
||||
*/
|
||||
export function generateLockedPreview(): string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="200" height="150" viewBox="0 0 200 150">
|
||||
<rect width="200" height="150" fill="#f1f5f9"/>
|
||||
<rect x="75" y="45" width="50" height="40" rx="4" fill="#94a3b8"/>
|
||||
<rect x="85" y="30" width="30" height="25" rx="15" fill="none" stroke="#94a3b8" stroke-width="6"/>
|
||||
<circle cx="100" cy="65" r="4" fill="#f1f5f9"/>
|
||||
<rect x="98" y="65" width="4" height="10" fill="#f1f5f9"/>
|
||||
<text x="100" y="110" font-family="system-ui" font-size="12" fill="#64748b" text-anchor="middle">Private Drawing</text>
|
||||
</svg>`;
|
||||
}
|
||||
Reference in New Issue
Block a user