auth in progress
This commit is contained in:
parent
bb144428d0
commit
c5e606e326
File diff suppressed because it is too large
Load Diff
|
|
@ -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",
|
||||
|
|
|
|||
145
src/App.tsx
145
src/App.tsx
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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}</>;
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,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;
|
||||
}
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
@ -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 });
|
||||
};
|
||||
|
|
@ -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`;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 />
|
||||
|
|
|
|||
Loading…
Reference in New Issue