auth in progress

This commit is contained in:
Jeff Emmett 2025-04-17 15:51:49 -07:00
parent ef0ec789ab
commit 39294a2f0c
27 changed files with 4514 additions and 30 deletions

1922
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -19,6 +19,7 @@
"@anthropic-ai/sdk": "^0.33.1",
"@daily-co/daily-js": "^0.60.0",
"@daily-co/daily-react": "^0.20.0",
"@oddjs/odd": "^0.37.2",
"@tldraw/assets": "^3.6.0",
"@tldraw/sync": "^3.6.0",
"@tldraw/sync-core": "^3.6.0",
@ -38,6 +39,7 @@
"jspdf": "^2.5.2",
"lodash.throttle": "^4.1.1",
"marked": "^15.0.4",
"one-webcrypto": "^1.0.3",
"openai": "^4.79.3",
"rbush": "^4.0.1",
"react": "^18.2.0",
@ -46,7 +48,8 @@
"react-router-dom": "^7.0.2",
"recoil": "^0.7.7",
"tldraw": "^3.6.0",
"vercel": "^39.1.1"
"vercel": "^39.1.1",
"webnative": "^0.36.3"
},
"devDependencies": {
"@cloudflare/types": "^6.0.0",

View File

@ -1,33 +1,124 @@
import { inject } from "@vercel/analytics"
import "tldraw/tldraw.css"
import "@/css/style.css"
import { Default } from "@/routes/Default"
import { BrowserRouter, Route, Routes } from "react-router-dom"
import { Contact } from "@/routes/Contact"
import { Board } from "./routes/Board"
import { Inbox } from "./routes/Inbox"
import { createRoot } from "react-dom/client"
import { DailyProvider } from "@daily-co/daily-react"
import Daily from "@daily-co/daily-js"
import { inject } from "@vercel/analytics";
import "tldraw/tldraw.css";
import "@/css/style.css";
import "@/styles/auth.css"; // Import auth styles
import { Default } from "@/routes/Default";
import { BrowserRouter, Route, Routes, Navigate } from "react-router-dom";
import { Contact } from "@/routes/Contact";
import { Board } from "./routes/Board";
import { Inbox } from "./routes/Inbox";
import { createRoot } from "react-dom/client";
import { DailyProvider } from "@daily-co/daily-react";
import Daily from "@daily-co/daily-js";
import { useState, useEffect } from 'react';
inject()
// Import React Context providers
import { AuthProvider, useAuth } from './context/AuthContext';
import { FileSystemProvider } from './context/FileSystemContext';
import { NotificationProvider } from './context/NotificationContext';
import NotificationsDisplay from './components/NotificationsDisplay';
const callObject = Daily.createCallObject()
// Import auth components
import Login from './components/auth/Login';
inject();
const callObject = Daily.createCallObject();
/**
* Protected Route component
* Redirects to login if user is not authenticated
*/
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const { session } = useAuth();
const [isInitialized, setIsInitialized] = useState(false);
// Wait for authentication to initialize before rendering
useEffect(() => {
if (!session.loading) {
setIsInitialized(true);
}
}, [session.loading]);
if (!isInitialized) {
return <div className="loading">Loading...</div>;
}
// Redirect to login if not authenticated
if (!session.authed) {
return <Navigate to="/login" />;
}
// Render the protected content
return <>{children}</>;
};
/**
* Auth page - renders login/register component
*/
const AuthPage = () => {
const { session } = useAuth();
// Redirect to home if already authenticated
if (session.authed) {
return <Navigate to="/" />;
}
function App() {
return (
<DailyProvider callObject={callObject}>
<BrowserRouter>
<Routes>
<Route path="/" element={<Default />} />
<Route path="/contact" element={<Contact />} />
<Route path="/board/:slug" element={<Board />} />
<Route path="/inbox" element={<Inbox />} />
</Routes>
</BrowserRouter>
</DailyProvider>
)
}
<div className="auth-page">
<Login onSuccess={() => window.location.href = '/'} />
</div>
);
};
createRoot(document.getElementById("root")!).render(<App />)
/**
* Main App with context providers
*/
const AppWithProviders = () => {
return (
<AuthProvider>
<FileSystemProvider>
<NotificationProvider>
<DailyProvider callObject={callObject}>
<BrowserRouter>
{/* Display notifications */}
<NotificationsDisplay />
<Routes>
{/* Auth routes */}
<Route path="/login" element={<AuthPage />} />
{/* Protected routes */}
<Route path="/" element={
<ProtectedRoute>
<Default />
</ProtectedRoute>
} />
<Route path="/contact" element={
<ProtectedRoute>
<Contact />
</ProtectedRoute>
} />
<Route path="/board/:slug" element={
<ProtectedRoute>
<Board />
</ProtectedRoute>
} />
<Route path="/inbox" element={
<ProtectedRoute>
<Inbox />
</ProtectedRoute>
} />
</Routes>
</BrowserRouter>
</DailyProvider>
</NotificationProvider>
</FileSystemProvider>
</AuthProvider>
);
};
// Initialize the app
createRoot(document.getElementById("root")!).render(<AppWithProviders />);
export default AppWithProviders;

View File

@ -0,0 +1,105 @@
import React, { useEffect, useState } from 'react';
import { useNotifications, Notification } from '../context/NotificationContext';
/**
* Component to display a single notification
*/
const NotificationItem: React.FC<{
notification: Notification;
onClose: (id: string) => void;
}> = ({ notification, onClose }) => {
const [isExiting, setIsExiting] = useState(false);
const exitDuration = 300; // ms for exit animation
// Set up automatic dismissal based on notification timeout
useEffect(() => {
if (notification.timeout > 0) {
const timer = setTimeout(() => {
setIsExiting(true);
// Wait for exit animation before removing
setTimeout(() => {
onClose(notification.id);
}, exitDuration);
}, notification.timeout);
return () => clearTimeout(timer);
}
}, [notification, onClose]);
// Handle manual close
const handleClose = () => {
setIsExiting(true);
// Wait for exit animation before removing
setTimeout(() => {
onClose(notification.id);
}, exitDuration);
};
// Determine icon based on notification type
const getIcon = () => {
switch (notification.type) {
case 'success':
return '✓';
case 'error':
return '✕';
case 'warning':
return '⚠';
case 'info':
default:
return '';
}
};
return (
<div
className={`notification ${notification.type} ${isExiting ? 'exiting' : ''}`}
style={{
animationDuration: `${exitDuration}ms`,
}}
>
<div className="notification-icon">
{getIcon()}
</div>
<div className="notification-content">
{notification.msg}
</div>
<button
className="notification-close"
onClick={handleClose}
aria-label="Close notification"
>
×
</button>
</div>
);
};
/**
* Component that displays all active notifications
*/
const NotificationsDisplay: React.FC = () => {
const { notifications, removeNotification } = useNotifications();
// Don't render anything if there are no notifications
if (notifications.length === 0) {
return null;
}
return (
<div className="notifications-container">
{notifications.map((notification) => (
<NotificationItem
key={notification.id}
notification={notification}
onClose={removeNotification}
/>
))}
</div>
);
};
export default NotificationsDisplay;

View File

@ -0,0 +1,103 @@
import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { createAccountLinkingConsumer } from '../../lib/auth/linking'
import * as account from '@oddjs/odd/account'
import { useAuth } from '../../context/AuthContext'
import { useNotifications } from '../../context/NotificationContext'
const LinkDevice: React.FC = () => {
const [username, setUsername] = useState('')
const [displayPin, setDisplayPin] = useState('')
const [view, setView] = useState<'enter-username' | 'show-pin' | 'load-filesystem'>('enter-username')
const [accountLinkingConsumer, setAccountLinkingConsumer] = useState<account.AccountLinkingConsumer | null>(null)
const navigate = useNavigate()
const { login } = useAuth()
const { addNotification } = useNotifications()
const initAccountLinkingConsumer = async () => {
try {
const consumer = await createAccountLinkingConsumer(username)
setAccountLinkingConsumer(consumer)
consumer.on('challenge', ({ pin }: { pin: number[] }) => {
setDisplayPin(pin.join(''))
setView('show-pin')
})
consumer.on('link', async ({ approved, username }: { approved: boolean, username: string }) => {
if (approved) {
setView('load-filesystem')
const success = await login(username)
if (success) {
addNotification("You're now connected!", "success")
navigate('/')
} else {
addNotification("Connection successful but login failed", "error")
navigate('/login')
}
} else {
addNotification('The connection attempt was cancelled', "warning")
navigate('/')
}
})
} catch (error) {
console.error('Error initializing account linking consumer:', error)
addNotification('Failed to initialize device linking', "error")
}
}
const handleSubmitUsername = (e: React.FormEvent) => {
e.preventDefault()
initAccountLinkingConsumer()
}
// Clean up consumer on unmount
useEffect(() => {
return () => {
if (accountLinkingConsumer) {
accountLinkingConsumer.destroy()
}
}
}, [accountLinkingConsumer])
return (
<div className="link-device-container">
{view === 'enter-username' && (
<>
<h2>Link a New Device</h2>
<form onSubmit={handleSubmitUsername}>
<div className="form-group">
<label htmlFor="username">Username</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
</div>
<button type="submit" disabled={!username}>Continue</button>
</form>
</>
)}
{view === 'show-pin' && (
<div className="pin-display">
<h2>Enter this PIN on your other device</h2>
<div className="pin-code">{displayPin}</div>
</div>
)}
{view === 'load-filesystem' && (
<div className="loading">
<h2>Loading your filesystem...</h2>
<p>Please wait while we connect to your account.</p>
</div>
)}
</div>
)
}
export default LinkDevice

View File

@ -0,0 +1,18 @@
import React from 'react';
interface LoadingProps {
message?: string;
}
const Loading: React.FC<LoadingProps> = ({ message = 'Loading...' }) => {
return (
<div className="loading-container">
<div className="loading-spinner">
<div className="spinner"></div>
</div>
<p className="loading-message">{message}</p>
</div>
);
};
export default Loading;

View File

@ -0,0 +1,188 @@
import React, { useState, useEffect } from 'react';
import { isUsernameValid, isUsernameAvailable } from '../../lib/auth/account';
import { useAuth } from '../../context/AuthContext';
import { useNotifications } from '../../context/NotificationContext';
interface LoginProps {
onSuccess?: () => void;
}
/**
* Combined Login/Register component
*
* Handles both login and registration flows based on user selection
*/
const Login: React.FC<LoginProps> = ({ onSuccess }) => {
const [username, setUsername] = useState('');
const [isRegistering, setIsRegistering] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [usernameValid, setUsernameValid] = useState<boolean | null>(null);
const [usernameAvailable, setUsernameAvailable] = useState<boolean | null>(null);
const [isCheckingUsername, setIsCheckingUsername] = useState(false);
const { login, register } = useAuth();
const { addNotification } = useNotifications();
/**
* Validate username when it changes and we're in registration mode
*/
useEffect(() => {
if (!isRegistering || !username || username.length < 3) {
setUsernameValid(null);
setUsernameAvailable(null);
return;
}
const validateUsername = async () => {
setIsCheckingUsername(true);
try {
// Check username validity
const valid = await isUsernameValid(username);
setUsernameValid(valid);
if (!valid) {
setUsernameAvailable(null);
setIsCheckingUsername(false);
return;
}
// Check username availability
const available = await isUsernameAvailable(username);
setUsernameAvailable(available);
} catch (error) {
console.error('Username validation error:', error);
setUsernameValid(false);
setUsernameAvailable(null);
} finally {
setIsCheckingUsername(false);
}
};
validateUsername();
}, [username, isRegistering]);
/**
* Handle form submission for both login and registration
*/
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setIsLoading(true);
try {
if (isRegistering) {
// Registration flow
if (!usernameValid) {
setError('Invalid username format');
setIsLoading(false);
return;
}
if (!usernameAvailable) {
setError('Username is already taken');
setIsLoading(false);
return;
}
const success = await register(username);
if (success) {
addNotification(`Welcome, ${username}! Your account has been created.`, 'success');
if (onSuccess) onSuccess();
} else {
setError('Registration failed');
addNotification('Registration failed. Please try again.', 'error');
}
} else {
// Login flow
const success = await login(username);
if (success) {
addNotification(`Welcome back, ${username}!`, 'success');
if (onSuccess) onSuccess();
} else {
setError('User not found or login failed');
addNotification('Login failed. Please check your username.', 'error');
}
}
} catch (err) {
console.error('Authentication error:', err);
setError('An unexpected error occurred');
addNotification('Authentication error. Please try again later.', 'error');
} finally {
setIsLoading(false);
}
};
return (
<div className="auth-container">
<h2>{isRegistering ? 'Create Account' : 'Sign In'}</h2>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="username">Username</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter username"
required
disabled={isLoading}
autoComplete="username"
minLength={3}
maxLength={20}
/>
{/* Username validation feedback */}
{isRegistering && username.length >= 3 && (
<div className="validation-feedback">
{isCheckingUsername && (
<span className="checking">Checking username...</span>
)}
{!isCheckingUsername && usernameValid === false && (
<span className="invalid">
Username must be 3-20 characters and contain only letters, numbers, underscores, or hyphens
</span>
)}
{!isCheckingUsername && usernameValid === true && usernameAvailable === false && (
<span className="unavailable">Username is already taken</span>
)}
{!isCheckingUsername && usernameValid === true && usernameAvailable === true && (
<span className="available">Username is available</span>
)}
</div>
)}
</div>
{error && <div className="error-message">{error}</div>}
<button
type="submit"
disabled={isLoading || (isRegistering && (!usernameValid || !usernameAvailable))}
className="auth-button"
>
{isLoading ? 'Processing...' : isRegistering ? 'Register' : 'Login'}
</button>
</form>
<div className="auth-toggle">
<button
onClick={() => {
setIsRegistering(!isRegistering);
setError(null);
}}
disabled={isLoading}
className="toggle-button"
>
{isRegistering ? 'Already have an account? Sign in' : 'Need an account? Register'}
</button>
</div>
</div>
);
};
export default Login;

View File

@ -0,0 +1,50 @@
import React from 'react';
import { useAuth } from '../../../src/context/AuthContext';
import { clearSession } from '../../lib/init';
interface ProfileProps {
onLogout?: () => void;
}
export const Profile: React.FC<ProfileProps> = ({ onLogout }) => {
const { session, updateSession } = useAuth();
const handleLogout = () => {
// Clear the session
clearSession();
// Update the auth context
updateSession({
username: '',
authed: false,
backupCreated: null,
});
// Call the onLogout callback if provided
if (onLogout) onLogout();
};
if (!session.authed || !session.username) {
return null;
}
return (
<div className="profile-container">
<div className="profile-header">
<h3>Welcome, {session.username}!</h3>
</div>
<div className="profile-actions">
<button onClick={handleLogout} className="logout-button">
Sign Out
</button>
</div>
{!session.backupCreated && (
<div className="backup-reminder">
<p>Remember to back up your encryption keys to prevent data loss!</p>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,23 @@
import React from 'react';
import { useAuth } from '../../../src/context/AuthContext';
interface ProtectedRouteProps {
children: React.ReactNode;
}
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
const { session } = useAuth();
if (session.loading) {
// Show loading indicator while authentication is being checked
return (
<div className="auth-loading">
<p>Checking authentication...</p>
</div>
);
}
// For board routes, we'll allow access even if not authenticated
// The auth button in the toolbar will handle authentication
return <>{children}</>;
};

View File

@ -0,0 +1,64 @@
import React, { useState } from 'react'
import { register } from '../../lib/auth/account'
const Register: React.FC = () => {
const [username, setUsername] = useState('')
const [checkingUsername, setCheckingUsername] = useState(false)
const [initializingFilesystem, setInitializingFilesystem] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault()
if (checkingUsername) {
return
}
setInitializingFilesystem(true)
setError(null)
try {
const success = await register(username)
if (!success) {
setError('Registration failed. Username may be taken.')
setInitializingFilesystem(false)
}
} catch (err) {
setError('An error occurred during registration')
setInitializingFilesystem(false)
console.error(err)
}
}
return (
<div className="register-container">
<h2>Create an Account</h2>
<form onSubmit={handleRegister}>
<div className="form-group">
<label htmlFor="username">Username</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={initializingFilesystem}
required
/>
</div>
{error && <div className="error-message">{error}</div>}
<button
type="submit"
disabled={initializingFilesystem || !username}
>
{initializingFilesystem ? 'Creating Account...' : 'Create Account'}
</button>
</form>
</div>
)
}
export default Register

149
src/context/AuthContext.tsx Normal file
View File

@ -0,0 +1,149 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import type FileSystem from '@oddjs/odd/fs/index';
import { Session, SessionError } from '../lib/auth/types';
import { AuthService } from '../lib/auth/authService';
interface AuthContextType {
session: Session;
setSession: (updatedSession: Partial<Session>) => void;
fileSystem: FileSystem | null;
setFileSystem: (fs: FileSystem | null) => void;
initialize: () => Promise<void>;
login: (username: string) => Promise<boolean>;
register: (username: string) => Promise<boolean>;
logout: () => Promise<void>;
}
const initialSession: Session = {
username: '',
authed: false,
loading: true,
backupCreated: null
};
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [session, setSessionState] = useState<Session>(initialSession);
const [fileSystem, setFileSystemState] = useState<FileSystem | null>(null);
// Update session with partial data
const setSession = (updatedSession: Partial<Session>) => {
setSessionState(prev => ({ ...prev, ...updatedSession }));
};
// Set file system
const setFileSystem = (fs: FileSystem | null) => {
setFileSystemState(fs);
};
/**
* Initialize the authentication state
*/
const initialize = async (): Promise<void> => {
setSession({ loading: true });
try {
const { session: newSession, fileSystem: newFs } = await AuthService.initialize();
setSession(newSession);
setFileSystem(newFs);
} catch (error) {
setSession({
loading: false,
authed: false,
error: error as SessionError
});
}
};
/**
* Login with a username
*/
const login = async (username: string): Promise<boolean> => {
setSession({ loading: true });
const result = await AuthService.login(username);
if (result.success && result.session && result.fileSystem) {
setSession(result.session);
setFileSystem(result.fileSystem);
return true;
} else {
setSession({
loading: false,
error: result.error as SessionError
});
return false;
}
};
/**
* Register a new user
*/
const register = async (username: string): Promise<boolean> => {
setSession({ loading: true });
const result = await AuthService.register(username);
if (result.success && result.session && result.fileSystem) {
setSession(result.session);
setFileSystem(result.fileSystem);
return true;
} else {
setSession({
loading: false,
error: result.error as SessionError
});
return false;
}
};
/**
* Logout the current user
*/
const logout = async (): Promise<void> => {
try {
await AuthService.logout();
setSession({
username: '',
authed: false,
loading: false,
backupCreated: null
});
setFileSystem(null);
} catch (error) {
console.error('Logout error:', error);
throw error;
}
};
// Initialize on mount
useEffect(() => {
initialize();
}, []);
const contextValue: AuthContextType = {
session,
setSession,
fileSystem,
setFileSystem,
initialize,
login,
register,
logout
};
return (
<AuthContext.Provider value={contextValue}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = (): AuthContextType => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

View File

@ -0,0 +1,158 @@
import React, { createContext, useContext, useState, ReactNode } from 'react';
import type * as webnative from 'webnative';
import type FileSystem from 'webnative/fs/index';
/**
* File system context interface
*/
interface FileSystemContextType {
fs: FileSystem | null;
setFs: (fs: FileSystem | null) => void;
isReady: boolean;
}
// Create context with a default undefined value
const FileSystemContext = createContext<FileSystemContextType | undefined>(undefined);
/**
* FileSystemProvider component
*
* Provides access to the webnative filesystem throughout the application.
*/
export const FileSystemProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [fs, setFs] = useState<FileSystem | null>(null);
// File system is ready when it's not null
const isReady = fs !== null;
return (
<FileSystemContext.Provider value={{ fs, setFs, isReady }}>
{children}
</FileSystemContext.Provider>
);
};
/**
* Hook to access the file system context
*
* @returns The file system context
* @throws Error if used outside of FileSystemProvider
*/
export const useFileSystem = (): FileSystemContextType => {
const context = useContext(FileSystemContext);
if (context === undefined) {
throw new Error('useFileSystem must be used within a FileSystemProvider');
}
return context;
};
/**
* Directory paths used in the application
*/
export const DIRECTORIES = {
PUBLIC: {
ROOT: ['public'],
GALLERY: ['public', 'gallery'],
DOCUMENTS: ['public', 'documents']
},
PRIVATE: {
ROOT: ['private'],
GALLERY: ['private', 'gallery'],
SETTINGS: ['private', 'settings'],
DOCUMENTS: ['private', 'documents']
}
};
/**
* Common filesystem operations
*
* @param fs The filesystem instance
* @returns An object with filesystem utility functions
*/
export const createFileSystemUtils = (fs: FileSystem) => {
return {
/**
* Creates a directory if it doesn't exist
*
* @param path Array of path segments
*/
ensureDirectory: async (path: string[]): Promise<void> => {
const dirPath = webnative.path.directory(...path);
const exists = await fs.exists(dirPath);
if (!exists) {
await fs.mkdir(dirPath);
}
},
/**
* Writes a file to the filesystem
*
* @param path Array of path segments
* @param fileName The name of the file
* @param content The content to write
*/
writeFile: async (path: string[], fileName: string, content: Blob | string): Promise<void> => {
const filePath = webnative.path.file(...path, fileName);
await fs.write(filePath, content);
await fs.publish();
},
/**
* Reads a file from the filesystem
*
* @param path Array of path segments
* @param fileName The name of the file
* @returns The file content
*/
readFile: async (path: string[], fileName: string): Promise<any> => {
const filePath = webnative.path.file(...path, fileName);
const exists = await fs.exists(filePath);
if (!exists) {
throw new Error(`File doesn't exist: ${filePath}`);
}
return await fs.read(filePath);
},
/**
* Checks if a file exists
*
* @param path Array of path segments
* @param fileName The name of the file
* @returns Boolean indicating if the file exists
*/
fileExists: async (path: string[], fileName: string): Promise<boolean> => {
const filePath = webnative.path.file(...path, fileName);
return await fs.exists(filePath);
},
/**
* Lists files in a directory
*
* @param path Array of path segments
* @returns Object with file names as keys
*/
listDirectory: async (path: string[]): Promise<Record<string, any>> => {
const dirPath = webnative.path.directory(...path);
const exists = await fs.exists(dirPath);
if (!exists) {
return {};
}
return await fs.ls(dirPath);
}
};
};
/**
* Hook to use filesystem utilities
*
* @returns Filesystem utilities or null if filesystem is not ready
*/
export const useFileSystemUtils = () => {
const { fs, isReady } = useFileSystem();
if (!isReady || !fs) {
return null;
}
return createFileSystemUtils(fs);
};

View File

@ -0,0 +1,111 @@
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
/**
* Types of notifications supported by the system
*/
export type NotificationType = 'success' | 'error' | 'info' | 'warning';
/**
* Notification object structure
*/
export type Notification = {
id: string;
msg: string;
type: NotificationType;
timeout: number;
};
/**
* Interface for the notification context
*/
interface NotificationContextType {
notifications: Notification[];
addNotification: (msg: string, type?: NotificationType, timeout?: number) => string;
removeNotification: (id: string) => void;
clearAllNotifications: () => void;
}
// Create context with a default undefined value
const NotificationContext = createContext<NotificationContextType | undefined>(undefined);
/**
* NotificationProvider component - provides notification functionality to the app
*/
export const NotificationProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [notifications, setNotifications] = useState<Notification[]>([]);
/**
* Remove a notification by ID
*/
const removeNotification = useCallback((id: string) => {
setNotifications(current => current.filter(notification => notification.id !== id));
}, []);
/**
* Add a new notification
* @param msg The message to display
* @param type The type of notification (success, error, info, warning)
* @param timeout Time in ms before notification is automatically removed
* @returns The ID of the created notification
*/
const addNotification = useCallback(
(msg: string, type: NotificationType = 'info', timeout: number = 5000): string => {
// Create a unique ID for the notification
const id = crypto.randomUUID();
// Add notification to the array
setNotifications(current => [
...current,
{
id,
msg,
type,
timeout,
}
]);
// Set up automatic removal after timeout
if (timeout > 0) {
setTimeout(() => {
removeNotification(id);
}, timeout);
}
// Return the notification ID for reference
return id;
},
[removeNotification]
);
/**
* Clear all current notifications
*/
const clearAllNotifications = useCallback(() => {
setNotifications([]);
}, []);
// Create the context value with all functions and state
const contextValue: NotificationContextType = {
notifications,
addNotification,
removeNotification,
clearAllNotifications
};
return (
<NotificationContext.Provider value={contextValue}>
{children}
</NotificationContext.Provider>
);
};
/**
* Hook to access the notification context
*/
export const useNotifications = (): NotificationContextType => {
const context = useContext(NotificationContext);
if (context === undefined) {
throw new Error('useNotifications must be used within a NotificationProvider');
}
return context;
};

176
src/css/auth.css Normal file
View File

@ -0,0 +1,176 @@
/* Authentication Page Styles */
.auth-page {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background-color: #f5f5f5;
padding: 20px;
}
.auth-container {
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
padding: 30px;
width: 100%;
max-width: 400px;
}
.auth-container h2 {
margin-top: 0;
margin-bottom: 24px;
text-align: center;
color: #333;
font-size: 24px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-weight: 500;
color: #555;
}
.form-group input {
width: 100%;
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 16px;
transition: border-color 0.2s;
}
.form-group input:focus {
border-color: #6366f1;
outline: none;
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
}
.error-message {
color: #dc2626;
margin-bottom: 20px;
font-size: 14px;
background-color: #fee2e2;
padding: 8px 12px;
border-radius: 4px;
border-left: 3px solid #dc2626;
}
.auth-button {
width: 100%;
background-color: #6366f1;
color: white;
border: none;
border-radius: 4px;
padding: 12px 16px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.auth-button:hover {
background-color: #4f46e5;
}
.auth-button:disabled {
background-color: #9ca3af;
cursor: not-allowed;
}
.auth-toggle {
margin-top: 20px;
text-align: center;
}
.auth-toggle button {
background: none;
border: none;
color: #6366f1;
font-size: 14px;
cursor: pointer;
text-decoration: underline;
}
.auth-toggle button:hover {
color: #4f46e5;
}
.auth-toggle button:disabled {
color: #9ca3af;
cursor: not-allowed;
text-decoration: none;
}
.auth-container.loading,
.auth-container.error {
text-align: center;
padding: 40px 30px;
}
.auth-loading {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background-color: #f5f5f5;
}
/* Profile Component Styles */
.profile-container {
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
padding: 20px;
margin-bottom: 20px;
}
.profile-header {
margin-bottom: 16px;
}
.profile-header h3 {
margin: 0;
color: #333;
font-size: 18px;
}
.profile-actions {
display: flex;
justify-content: flex-end;
}
.logout-button {
background-color: #ef4444;
color: white;
border: none;
border-radius: 4px;
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.logout-button:hover {
background-color: #dc2626;
}
.backup-reminder {
margin-top: 16px;
padding: 12px;
background-color: #fffbeb;
border-radius: 4px;
border-left: 3px solid #f59e0b;
}
.backup-reminder p {
margin: 0;
color: #92400e;
font-size: 14px;
}

32
src/css/loading.css Normal file
View File

@ -0,0 +1,32 @@
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
width: 100%;
}
.loading-spinner {
margin-bottom: 1rem;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid rgba(0, 0, 0, 0.1);
border-radius: 50%;
border-top-color: #3498db;
animation: spin 1s ease-in-out infinite;
}
.loading-message {
font-size: 1.2rem;
color: #333;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}

0
src/lib/auth/Login.tsx Normal file
View File

193
src/lib/auth/account.ts Normal file
View File

@ -0,0 +1,193 @@
import * as odd from '@oddjs/odd';
import type FileSystem from '@oddjs/odd/fs/index';
import { asyncDebounce } from '../utils/asyncDebounce';
import * as browser from '../utils/browser';
import { DIRECTORIES } from '../../context/FileSystemContext';
/**
* Constants for filesystem paths
*/
export const ACCOUNT_SETTINGS_DIR = ['private', 'settings'];
export const GALLERY_DIRS = {
PUBLIC: ['public', 'gallery'],
PRIVATE: ['private', 'gallery']
};
export const AREAS = {
PUBLIC: 'public',
PRIVATE: 'private'
};
/**
* Checks if a username is valid according to ODD's rules
* @param username The username to check
* @returns A boolean indicating if the username is valid
*/
export const isUsernameValid = async (username: string): Promise<boolean> => {
console.log('Checking if username is valid:', username);
try {
const isValid = await odd.account.isUsernameValid(username);
console.log('Username validity check result:', isValid);
return isValid;
} catch (error) {
console.error('Error checking username validity:', error);
return false;
}
};
/**
* Debounced function to check if a username is available
*/
const debouncedIsUsernameAvailable = asyncDebounce(
odd.account.isUsernameAvailable,
300
);
/**
* Checks if a username is available
* @param username The username to check
* @returns A boolean indicating if the username is available
*/
export const isUsernameAvailable = async (
username: string
): Promise<boolean> => {
console.log('Checking if username is available:', username);
try {
// In a local development environment, simulate the availability check
// by checking if the username exists in localStorage
if (browser.isBrowser()) {
const isAvailable = browser.isUsernameAvailable(username);
console.log('Username availability check result:', isAvailable);
return isAvailable;
} else {
// If not in a browser (SSR), use the ODD API
const isAvailable = await debouncedIsUsernameAvailable(username);
console.log('Username availability check result:', isAvailable);
return isAvailable;
}
} catch (error) {
console.error('Error checking username availability:', error);
return false;
}
};
/**
* Create additional directories and files needed by the app
* @param fs FileSystem
*/
export const initializeFilesystem = async (fs: FileSystem): Promise<void> => {
try {
// Create required directories
console.log('Creating required directories...');
// Public directories
await fs.mkdir(odd.path.directory(...DIRECTORIES.PUBLIC.ROOT));
await fs.mkdir(odd.path.directory(...DIRECTORIES.PUBLIC.GALLERY));
await fs.mkdir(odd.path.directory(...DIRECTORIES.PUBLIC.DOCUMENTS));
// Private directories
await fs.mkdir(odd.path.directory(...DIRECTORIES.PRIVATE.ROOT));
await fs.mkdir(odd.path.directory(...DIRECTORIES.PRIVATE.GALLERY));
await fs.mkdir(odd.path.directory(...DIRECTORIES.PRIVATE.SETTINGS));
await fs.mkdir(odd.path.directory(...DIRECTORIES.PRIVATE.DOCUMENTS));
console.log('Filesystem initialized successfully');
} catch (error) {
console.error('Error during filesystem initialization:', error);
throw error;
}
};
/**
* Checks data root for a username with retries
* @param username The username to check
*/
export const checkDataRoot = async (username: string): Promise<void> => {
console.log('Looking up data root for username:', username);
let dataRoot = await odd.dataRoot.lookup(username);
console.log('Initial data root lookup result:', dataRoot ? 'found' : 'not found');
if (dataRoot) return;
console.log('Data root not found, starting retry process...');
return new Promise((resolve, reject) => {
const maxRetries = 20;
let attempt = 0;
const dataRootInterval = setInterval(async () => {
console.warn(`Could not fetch filesystem data root. Retrying (${attempt + 1}/${maxRetries})`);
dataRoot = await odd.dataRoot.lookup(username);
console.log(`Retry ${attempt + 1} result:`, dataRoot ? 'found' : 'not found');
if (!dataRoot && attempt < maxRetries) {
attempt++;
return;
}
console.log(`Retry process completed. Data root ${dataRoot ? 'found' : 'not found'} after ${attempt + 1} attempts`);
clearInterval(dataRootInterval);
if (dataRoot) {
resolve();
} else {
reject(new Error(`Data root not found after ${maxRetries} attempts`));
}
}, 500);
});
};
/**
* Generate a cryptographic key pair and store in localStorage during registration
* @param username The username being registered
*/
export const generateUserCredentials = async (username: string): Promise<boolean> => {
if (!browser.isBrowser()) return false;
try {
console.log('Generating cryptographic keys for user...');
// Generate a key pair using Web Crypto API
const keyPair = await browser.generateKeyPair();
if (!keyPair) {
console.error('Failed to generate key pair');
return false;
}
// Export the public key
const publicKeyBase64 = await browser.exportPublicKey(keyPair.publicKey);
if (!publicKeyBase64) {
console.error('Failed to export public key');
return false;
}
console.log('Keys generated successfully');
// Store the username and public key
browser.addRegisteredUser(username);
browser.storePublicKey(username, publicKeyBase64);
return true;
} catch (error) {
console.error('Error generating user credentials:', error);
return false;
}
};
/**
* Validate a user's stored credentials (for development mode)
* @param username The username to validate
*/
export const validateStoredCredentials = (username: string): boolean => {
if (!browser.isBrowser()) return false;
try {
const users = browser.getRegisteredUsers();
const publicKey = browser.getPublicKey(username);
return users.includes(username) && !!publicKey;
} catch (error) {
console.error('Error validating stored credentials:', error);
return false;
}
};

186
src/lib/auth/authService.ts Normal file
View File

@ -0,0 +1,186 @@
import * as odd from '@oddjs/odd';
import type FileSystem from '@oddjs/odd/fs/index';
import { checkDataRoot, initializeFilesystem, isUsernameValid, isUsernameAvailable } from './account';
import { getBackupStatus } from './backup';
import { Session } from './types';
export class AuthService {
/**
* Initialize the authentication state
*/
static async initialize(): Promise<{
session: Session;
fileSystem: FileSystem | null;
}> {
console.log('Initializing authentication...');
try {
// Call the ODD program function to get current auth state
const program = await odd.program({
namespace: { creator: 'mycrozine', name: 'app' }
});
let session: Session;
let fileSystem: FileSystem | null = null;
if (program.session) {
// User is authenticated
fileSystem = program.session.fs;
const backupStatus = await getBackupStatus(fileSystem);
session = {
username: program.session.username,
authed: true,
loading: false,
backupCreated: backupStatus.created
};
} else {
// User is not authenticated
session = {
username: '',
authed: false,
loading: false,
backupCreated: null
};
}
return { session, fileSystem };
} catch (error) {
console.error('Authentication initialization error:', error);
return {
session: {
username: '',
authed: false,
loading: false,
backupCreated: null,
error: String(error)
},
fileSystem: null
};
}
}
/**
* Login with a username
*/
static async login(username: string): Promise<{
success: boolean;
session?: Session;
fileSystem?: FileSystem;
error?: string;
}> {
try {
// Attempt to load the account
const program = await odd.program({
namespace: { creator: 'mycrozine', name: 'app' },
username
});
if (program.session) {
const fs = program.session.fs;
const backupStatus = await getBackupStatus(fs);
return {
success: true,
session: {
username,
authed: true,
loading: false,
backupCreated: backupStatus.created
},
fileSystem: fs
};
} else {
return {
success: false,
error: 'Failed to authenticate'
};
}
} catch (error) {
console.error('Login error:', error);
return {
success: false,
error: String(error)
};
}
}
/**
* Register a new user
*/
static async register(username: string): Promise<{
success: boolean;
session?: Session;
fileSystem?: FileSystem;
error?: string;
}> {
try {
// Validate username
const valid = await isUsernameValid(username);
if (!valid) {
return {
success: false,
error: 'Invalid username format'
};
}
// Check availability
const available = await isUsernameAvailable(username);
if (!available) {
return {
success: false,
error: 'Username is already taken'
};
}
// Register the user
const program = await odd.program({
namespace: { creator: 'mycrozine', name: 'app' },
username
});
if (program.session) {
const fs = program.session.fs;
// Initialize filesystem with required directories
await initializeFilesystem(fs);
// Check backup status
const backupStatus = await getBackupStatus(fs);
return {
success: true,
session: {
username,
authed: true,
loading: false,
backupCreated: backupStatus.created
},
fileSystem: fs
};
} else {
return {
success: false,
error: 'Failed to create account'
};
}
} catch (error) {
console.error('Registration error:', error);
return {
success: false,
error: String(error)
};
}
}
/**
* Logout the current user
*/
static async logout(): Promise<boolean> {
try {
await odd.session.destroy();
return true;
} catch (error) {
console.error('Logout error:', error);
return false;
}
}
}

15
src/lib/auth/backup.ts Normal file
View File

@ -0,0 +1,15 @@
import type * as odd from '@oddjs/odd'
export type BackupStatus = {
created: boolean | null
}
export const getBackupStatus = async (fs: odd.FileSystem): Promise<BackupStatus> => {
try {
const backupStatus = await fs.exists(odd.path.backups())
return { created: backupStatus }
} catch (error) {
console.error('Error checking backup status:', error)
return { created: null }
}
}

197
src/lib/auth/crypto.ts Normal file
View File

@ -0,0 +1,197 @@
// This module contains browser-specific WebCrypto API utilities
// Check if we're in a browser environment
export const isBrowser = (): boolean => typeof window !== 'undefined';
// Get registered users from localStorage
export const getRegisteredUsers = (): string[] => {
if (!isBrowser()) return [];
try {
return JSON.parse(window.localStorage.getItem('registeredUsers') || '[]');
} catch (error) {
console.error('Error getting registered users:', error);
return [];
}
};
// Add a user to the registered users list
export const addRegisteredUser = (username: string): void => {
if (!isBrowser()) return;
try {
const users = getRegisteredUsers();
if (!users.includes(username)) {
users.push(username);
window.localStorage.setItem('registeredUsers', JSON.stringify(users));
}
} catch (error) {
console.error('Error adding registered user:', error);
}
};
// Check if a username is available
export const isUsernameAvailable = async (username: string): Promise<boolean> => {
console.log('Checking if username is available:', username);
try {
// Get the list of registered users
const users = getRegisteredUsers();
// Check if the username is already taken
const isAvailable = !users.includes(username);
console.log('Username availability result:', isAvailable);
return isAvailable;
} catch (error) {
console.error('Error checking username availability:', error);
return false;
}
};
// Check if username is valid format (letters, numbers, underscores, hyphens)
export const isUsernameValid = (username: string): boolean => {
const usernameRegex = /^[a-zA-Z0-9_-]{3,20}$/;
return usernameRegex.test(username);
};
// Store a public key for a user
export const storePublicKey = (username: string, publicKey: string): void => {
if (!isBrowser()) return;
try {
window.localStorage.setItem(`${username}_publicKey`, publicKey);
} catch (error) {
console.error('Error storing public key:', error);
}
};
// Get a user's public key
export const getPublicKey = (username: string): string | null => {
if (!isBrowser()) return null;
try {
return window.localStorage.getItem(`${username}_publicKey`);
} catch (error) {
console.error('Error getting public key:', error);
return null;
}
};
// Generate a key pair using Web Crypto API
export const generateKeyPair = async (): Promise<CryptoKeyPair | null> => {
if (!isBrowser()) return null;
try {
return await window.crypto.subtle.generateKey(
{
name: 'ECDSA',
namedCurve: 'P-256',
},
true,
['sign', 'verify']
);
} catch (error) {
console.error('Error generating key pair:', error);
return null;
}
};
// Export a public key to a base64 string
export const exportPublicKey = async (publicKey: CryptoKey): Promise<string | null> => {
if (!isBrowser()) return null;
try {
const publicKeyBuffer = await window.crypto.subtle.exportKey(
'raw',
publicKey
);
return btoa(
String.fromCharCode.apply(null, Array.from(new Uint8Array(publicKeyBuffer)))
);
} catch (error) {
console.error('Error exporting public key:', error);
return null;
}
};
// Import a public key from a base64 string
export const importPublicKey = async (base64Key: string): Promise<CryptoKey | null> => {
if (!isBrowser()) return null;
try {
const binaryString = atob(base64Key);
const len = binaryString.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return await window.crypto.subtle.importKey(
'raw',
bytes,
{
name: 'ECDSA',
namedCurve: 'P-256',
},
true,
['verify']
);
} catch (error) {
console.error('Error importing public key:', error);
return null;
}
};
// Sign data with a private key
export const signData = async (privateKey: CryptoKey, data: string): Promise<string | null> => {
if (!isBrowser()) return null;
try {
const encoder = new TextEncoder();
const encodedData = encoder.encode(data);
const signature = await window.crypto.subtle.sign(
{
name: 'ECDSA',
hash: { name: 'SHA-256' },
},
privateKey,
encodedData
);
return btoa(
String.fromCharCode.apply(null, Array.from(new Uint8Array(signature)))
);
} catch (error) {
console.error('Error signing data:', error);
return null;
}
};
// Verify a signature
export const verifySignature = async (
publicKey: CryptoKey,
signature: string,
data: string
): Promise<boolean> => {
if (!isBrowser()) return false;
try {
const encoder = new TextEncoder();
const encodedData = encoder.encode(data);
const binarySignature = atob(signature);
const signatureBytes = new Uint8Array(binarySignature.length);
for (let i = 0; i < binarySignature.length; i++) {
signatureBytes[i] = binarySignature.charCodeAt(i);
}
return await window.crypto.subtle.verify(
{
name: 'ECDSA',
hash: { name: 'SHA-256' },
},
publicKey,
signatureBytes,
encodedData
);
} catch (error) {
console.error('Error verifying signature:', error);
return false;
}
};

24
src/lib/auth/linking.ts Normal file
View File

@ -0,0 +1,24 @@
import * as odd from '@oddjs/odd';
import * as account from '@oddjs/odd/account';
/**
* Creates an account linking consumer for the specified username
* @param username The username to create a consumer for
* @returns A Promise resolving to an AccountLinkingConsumer
*/
export const createAccountLinkingConsumer = async (
username: string
): Promise<account.AccountLinkingConsumer> => {
return await odd.account.createConsumer({ username });
};
/**
* Creates an account linking producer for the specified username
* @param username The username to create a producer for
* @returns A Promise resolving to an AccountLinkingProducer
*/
export const createAccountLinkingProducer = async (
username: string
): Promise<account.AccountLinkingProducer> => {
return await odd.account.createProducer({ username });
};

25
src/lib/auth/types.ts Normal file
View File

@ -0,0 +1,25 @@
export interface Session {
username: string;
authed: boolean;
loading: boolean;
backupCreated: boolean | null;
error?: string;
}
export enum SessionError {
PROGRAM_FAILURE = 'PROGRAM_FAILURE',
FILESYSTEM_INIT_FAILURE = 'FILESYSTEM_INIT_FAILURE',
DATAROOT_NOT_FOUND = 'DATAROOT_NOT_FOUND',
UNKNOWN = 'UNKNOWN'
}
export const errorToMessage = (error: SessionError): string | undefined => {
switch (error) {
case 'Insecure Context':
return `This application requires a secure context (HTTPS)`;
case 'Unsupported Browser':
return `Your browser does not support the required features`;
}
};

View File

@ -0,0 +1,188 @@
/**
* Creates a debounced version of an async function.
*
* A debounced function will only execute after a specified delay has passed
* without the function being called again. This is particularly useful for
* functions that make API calls in response to user input, to avoid making
* too many calls when a user is actively typing or interacting.
*
* @param fn The async function to debounce
* @param wait The time to wait in milliseconds before the function is called
* @returns A debounced version of the input function
*
* @example
* // Create a debounced version of an API call function
* const debouncedFetch = asyncDebounce(fetchFromAPI, 300);
*
* // Use the debounced function in an input handler
* const handleInputChange = (e) => {
* debouncedFetch(e.target.value)
* .then(result => setData(result))
* .catch(error => setError(error));
* };
*/
export function asyncDebounce<A extends unknown[], R>(
fn: (...args: A) => Promise<R>,
wait: number
): (...args: A) => Promise<R> {
let lastTimeoutId: ReturnType<typeof setTimeout> | undefined = undefined;
return (...args: A): Promise<R> => {
// Clear any existing timeout to cancel pending executions
clearTimeout(lastTimeoutId);
// Return a promise that will resolve with the function's result
return new Promise((resolve, reject) => {
// Create a new timeout
const currentTimeoutId = setTimeout(async () => {
try {
// Only execute if this is still the most recent timeout
if (currentTimeoutId === lastTimeoutId) {
const result = await fn(...args);
resolve(result);
}
} catch (err) {
reject(err);
}
}, wait);
// Store the current timeout ID
lastTimeoutId = currentTimeoutId;
});
};
}
/**
* Throttles an async function to be called at most once per specified period.
*
* Unlike debounce which resets the timer on each call, throttle will ensure the
* function is called at most once in the specified period, regardless of how many
* times the throttled function is called.
*
* @param fn The async function to throttle
* @param limit The minimum time in milliseconds between function executions
* @returns A throttled version of the input function
*
* @example
* // Create a throttled version of an API call function
* const throttledSave = asyncThrottle(saveToAPI, 1000);
*
* // Use the throttled function in an input handler
* const handleInputChange = (e) => {
* throttledSave(e.target.value)
* .then(() => setSaveStatus('Saved'))
* .catch(error => setSaveStatus('Error saving'));
* };
*/
export function asyncThrottle<A extends unknown[], R>(
fn: (...args: A) => Promise<R>,
limit: number
): (...args: A) => Promise<R> {
let lastRun = 0;
let lastPromise: Promise<R> | null = null;
let pending = false;
let lastArgs: A | null = null;
const execute = async (...args: A): Promise<R> => {
lastRun = Date.now();
pending = false;
return await fn(...args);
};
return (...args: A): Promise<R> => {
lastArgs = args;
// If we're not pending and it's been longer than the limit since the last run,
// execute immediately
if (!pending && Date.now() - lastRun >= limit) {
return execute(...args);
}
// If we don't have a promise or we're not pending, create a new promise
if (!lastPromise || !pending) {
pending = true;
lastPromise = new Promise<R>((resolve, reject) => {
setTimeout(async () => {
try {
// Make sure we're using the most recent args
if (lastArgs) {
const result = await execute(...lastArgs);
resolve(result);
}
} catch (err) {
reject(err);
}
}, limit - (Date.now() - lastRun));
});
}
return lastPromise;
};
}
/**
* Extracts a search parameter from a URL and removes it from the URL.
*
* Useful for handling one-time parameters like auth tokens or invite codes.
*
* @param url The URL object
* @param param The parameter name to extract
* @returns The parameter value or null if not found
*
* @example
* // Extract an invite code from the current URL
* const url = new URL(window.location.href);
* const inviteCode = extractSearchParam(url, 'invite');
* // The parameter is now removed from the URL
*/
export const extractSearchParam = (url: URL, param: string): string | null => {
// Get the parameter value
const val = url.searchParams.get(param);
// Remove the parameter from the URL
url.searchParams.delete(param);
// Update the browser history to reflect the URL change without reloading
if (typeof history !== 'undefined') {
history.replaceState(null, document.title, url.toString());
}
return val;
};
/**
* Checks if a function execution is taking too long and returns a timeout result if so.
*
* @param fn The async function to execute with timeout
* @param timeout The maximum time in milliseconds to wait
* @param timeoutResult The result to return if timeout occurs
* @returns The function result or timeout result
*
* @example
* // Execute a function with a 5-second timeout
* const result = await withTimeout(
* fetchDataFromSlowAPI,
* 5000,
* { error: 'Request timed out' }
* );
*/
export async function withTimeout<T, R>(
fn: () => Promise<T>,
timeout: number,
timeoutResult: R
): Promise<T | R> {
let timeoutId: ReturnType<typeof setTimeout>;
const timeoutPromise = new Promise<R>((resolve) => {
timeoutId = setTimeout(() => resolve(timeoutResult), timeout);
});
try {
const result = await Promise.race([fn(), timeoutPromise]);
clearTimeout(timeoutId);
return result;
} catch (error) {
clearTimeout(timeoutId);
throw error;
}
}

187
src/lib/utils/browser.ts Normal file
View File

@ -0,0 +1,187 @@
/**
* Browser-specific utility functions
*
* This module contains browser-specific functionality for environment detection
* and other browser-related operations.
*/
/**
* Check if we're in a browser environment
*/
export const isBrowser = (): boolean => typeof window !== 'undefined';
/**
* Check if the browser supports the required features for the application
*/
export const checkBrowserSupport = (): boolean => {
if (!isBrowser()) return false;
// Check for IndexedDB support
const hasIndexedDB = typeof window.indexedDB !== 'undefined';
// Check for WebCrypto API support
const hasWebCrypto = typeof window.crypto !== 'undefined' &&
typeof window.crypto.subtle !== 'undefined';
// Check for other required browser features
const hasLocalStorage = typeof window.localStorage !== 'undefined';
const hasServiceWorker = 'serviceWorker' in navigator;
return hasIndexedDB && hasWebCrypto && hasLocalStorage && hasServiceWorker;
};
/**
* Check if we're in a secure context (HTTPS)
*/
export const isSecureContext = (): boolean => {
if (!isBrowser()) return false;
return window.isSecureContext;
};
/**
* Get a URL parameter value
* @param name The parameter name
* @returns The parameter value or null if not found
*/
export const getUrlParameter = (name: string): string | null => {
if (!isBrowser()) return null;
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get(name);
};
/**
* Set a cookie
* @param name The cookie name
* @param value The cookie value
* @param days Number of days until expiration
*/
export const setCookie = (name: string, value: string, days: number = 7): void => {
if (!isBrowser()) return;
const expires = new Date();
expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000);
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/;SameSite=Strict`;
};
/**
* Get a cookie value
* @param name The cookie name
* @returns The cookie value or null if not found
*/
export const getCookie = (name: string): string | null => {
if (!isBrowser()) return null;
const nameEQ = `${name}=`;
const ca = document.cookie.split(';');
for (let i = 0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) === ' ') c = c.substring(1, c.length);
if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length);
}
return null;
};
/**
* Delete a cookie
* @param name The cookie name
*/
export const deleteCookie = (name: string): void => {
if (!isBrowser()) return;
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;SameSite=Strict`;
};
/**
* Check if the device is mobile
*/
export const isMobileDevice = (): boolean => {
if (!isBrowser()) return false;
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
};
/**
* Get the browser name
*/
export const getBrowserName = (): string => {
if (!isBrowser()) return 'unknown';
const userAgent = navigator.userAgent;
if (userAgent.indexOf('Firefox') > -1) return 'Firefox';
if (userAgent.indexOf('Chrome') > -1) return 'Chrome';
if (userAgent.indexOf('Safari') > -1) return 'Safari';
if (userAgent.indexOf('Edge') > -1) return 'Edge';
if (userAgent.indexOf('MSIE') > -1 || userAgent.indexOf('Trident') > -1) return 'Internet Explorer';
return 'unknown';
};
/**
* Check if local storage is available
*/
export const isLocalStorageAvailable = (): boolean => {
if (!isBrowser()) return false;
try {
const test = '__test__';
localStorage.setItem(test, test);
localStorage.removeItem(test);
return true;
} catch (e) {
return false;
}
};
/**
* Safely get an item from local storage
* @param key The storage key
* @returns The stored value or null if not found
*/
export const getLocalStorageItem = (key: string): string | null => {
if (!isBrowser() || !isLocalStorageAvailable()) return null;
try {
return localStorage.getItem(key);
} catch (error) {
console.error('Error getting item from localStorage:', error);
return null;
}
};
/**
* Safely set an item in local storage
* @param key The storage key
* @param value The value to store
* @returns True if successful, false otherwise
*/
export const setLocalStorageItem = (key: string, value: string): boolean => {
if (!isBrowser() || !isLocalStorageAvailable()) return false;
try {
localStorage.setItem(key, value);
return true;
} catch (error) {
console.error('Error setting item in localStorage:', error);
return false;
}
};
/**
* Safely remove an item from local storage
* @param key The storage key
* @returns True if successful, false otherwise
*/
export const removeLocalStorageItem = (key: string): boolean => {
if (!isBrowser() || !isLocalStorageAvailable()) return false;
try {
localStorage.removeItem(key);
return true;
} catch (error) {
console.error('Error removing item from localStorage:', error);
return false;
}
};

44
src/routes/Auth.tsx Normal file
View File

@ -0,0 +1,44 @@
import React, { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Login } from '../components/auth/Login';
import { useAuth } from '../context/AuthContext';
import { errorToMessage } from '../lib/auth/types';
export const Auth: React.FC = () => {
const { session } = useAuth();
const navigate = useNavigate();
// Redirect to home if already authenticated
useEffect(() => {
if (session.authed) {
navigate('/');
}
}, [session.authed, navigate]);
if (session.loading) {
return (
<div className="auth-page">
<div className="auth-container loading">
<p>Loading authentication system...</p>
</div>
</div>
);
}
if (session.error) {
return (
<div className="auth-page">
<div className="auth-container error">
<h2>Authentication Error</h2>
<p>{errorToMessage(session.error)}</p>
</div>
</div>
);
}
return (
<div className="auth-page">
<Login onSuccess={() => navigate('/')} />
</div>
);
};

123
src/ui/AuthDialog.tsx Normal file
View File

@ -0,0 +1,123 @@
import {
TLUiDialogProps,
TldrawUiButton,
TldrawUiButtonLabel,
TldrawUiDialogBody,
TldrawUiDialogCloseButton,
TldrawUiDialogFooter,
TldrawUiDialogHeader,
TldrawUiDialogTitle,
TldrawUiInput,
useDialogs
} from "tldraw"
import React, { useState, useEffect, useRef, FormEvent } from "react"
import { useAuth } from "../context/AuthContext"
interface AuthDialogProps extends TLUiDialogProps {
autoFocus?: boolean
}
export function AuthDialog({ onClose, autoFocus = false }: AuthDialogProps) {
const [username, setUsername] = useState('')
const [isRegistering, setIsRegistering] = useState(false)
const [error, setError] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const { login, register } = useAuth()
const { removeDialog } = useDialogs()
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (autoFocus && inputRef.current) {
setTimeout(() => {
inputRef.current?.focus()
}, 100)
}
}, [autoFocus])
const handleSubmit = async () => {
if (!username.trim()) {
setError('Username is required')
return
}
setError(null)
setIsLoading(true)
try {
let success = false
if (isRegistering) {
success = await register(username)
} else {
success = await login(username)
}
if (success) {
removeDialog("auth")
if (onClose) onClose()
} else {
setError(isRegistering ? 'Registration failed' : 'Login failed')
}
} catch (err) {
console.error('Authentication error:', err)
setError('An unexpected error occurred')
} finally {
setIsLoading(false)
}
}
// Handle form submission (triggered by Enter key or submit button)
const handleFormSubmit = (e: FormEvent) => {
e.preventDefault()
handleSubmit()
}
return (
<>
<TldrawUiDialogHeader>
<TldrawUiDialogTitle>{isRegistering ? 'Create Account' : 'Sign In'}</TldrawUiDialogTitle>
<TldrawUiDialogCloseButton />
</TldrawUiDialogHeader>
<TldrawUiDialogBody style={{ maxWidth: 350 }}>
<form onSubmit={handleFormSubmit}>
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<label>Username</label>
<TldrawUiInput
ref={inputRef}
value={username}
placeholder="Enter username"
onValueChange={setUsername}
disabled={isLoading}
/>
</div>
{error && <div style={{ color: 'red' }}>{error}</div>}
<div style={{ display: "flex", justifyContent: "space-between", gap: 8 }}>
<TldrawUiButton
type="normal"
onClick={() => setIsRegistering(!isRegistering)}
disabled={isLoading}
>
<TldrawUiButtonLabel>
{isRegistering ? 'Already have an account?' : 'Need an account?'}
</TldrawUiButtonLabel>
</TldrawUiButton>
<TldrawUiButton
type="primary"
onClick={handleSubmit}
disabled={isLoading}
>
<TldrawUiButtonLabel>
{isLoading ? 'Processing...' : isRegistering ? 'Register' : 'Login'}
</TldrawUiButtonLabel>
</TldrawUiButton>
</div>
</div>
</form>
</TldrawUiDialogBody>
</>
)
}

View File

@ -5,6 +5,8 @@ import { useEditor } from "tldraw"
import { useState, useEffect } from "react"
import { useDialogs } from "tldraw"
import { SettingsDialog } from "./SettingsDialog"
import { AuthDialog } from "./AuthDialog"
import { useAuth, clearSession } from "../context/AuthContext"
export function CustomToolbar() {
const editor = useEditor()
@ -12,6 +14,8 @@ export function CustomToolbar() {
const [isReady, setIsReady] = useState(false)
const [hasApiKey, setHasApiKey] = useState(false)
const { addDialog, removeDialog } = useDialogs()
const { session, updateSession } = useAuth()
const [showProfilePopup, setShowProfilePopup] = useState(false)
useEffect(() => {
if (editor && tools) {
@ -51,6 +55,21 @@ export function CustomToolbar() {
return () => clearInterval(interval)
}, [])
const handleLogout = () => {
// Clear the session
clearSession()
// Update the auth context
updateSession({
username: '',
authed: false,
backupCreated: null,
})
// Close the popup
setShowProfilePopup(false)
}
if (!isReady) return null
return (
@ -107,6 +126,100 @@ export function CustomToolbar() {
>
Keys {hasApiKey ? "✅" : "❌"}
</button>
<div style={{ position: "relative" }}>
<button
onClick={() => {
if (session.authed) {
setShowProfilePopup(!showProfilePopup)
} else {
addDialog({
id: "auth",
component: ({ onClose }: { onClose: () => void }) => (
<AuthDialog onClose={onClose} autoFocus={true} />
),
})
}
}}
style={{
padding: "8px 16px",
borderRadius: "4px",
background: session.authed ? "#6B7280" : "#2F80ED",
color: "white",
border: "none",
cursor: "pointer",
fontWeight: 500,
transition: "background 0.2s ease",
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
whiteSpace: "nowrap",
userSelect: "none",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = session.authed ? "#4B5563" : "#1366D6"
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = session.authed ? "#6B7280" : "#2F80ED"
}}
>
{session.authed ? `${session.username}` : "Sign In"}
</button>
{showProfilePopup && session.authed && (
<div
style={{
position: "absolute",
top: "40px",
right: "0",
width: "200px",
backgroundColor: "white",
borderRadius: "4px",
boxShadow: "0 2px 10px rgba(0,0,0,0.1)",
padding: "16px",
zIndex: 100000,
}}
>
<div style={{ marginBottom: "12px", fontWeight: "bold" }}>
Hello, {session.username}!
</div>
{!session.backupCreated && (
<div style={{
marginBottom: "12px",
fontSize: "12px",
color: "#666",
padding: "8px",
backgroundColor: "#f8f8f8",
borderRadius: "4px"
}}>
Remember to back up your encryption keys to prevent data loss!
</div>
)}
<button
onClick={handleLogout}
style={{
width: "100%",
padding: "8px 12px",
backgroundColor: "#EF4444",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer",
fontWeight: 500,
transition: "background 0.2s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "#DC2626"
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "#EF4444"
}}
>
Sign Out
</button>
</div>
)}
</div>
</div>
<DefaultToolbar>
<DefaultToolbarContent />