From 3d32d1418e139b129d34be4f8ff5dc94cd595b9d Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 16 Apr 2025 12:31:35 -0700 Subject: [PATCH] Auth install seems to be working, dev and prod split, what's happening with wrangler.toml? Mysterious things are not working right now. Will come back to this. --- .cursor/mcp.json | 23 +++ src/components/auth/Login.tsx | 117 +++++++++++++++ src/components/auth/Profile.tsx | 50 +++++++ src/components/auth/ProtectedRoute.tsx | 23 +++ src/components/footer.tsx | 26 ++++ src/components/layout/Header.tsx | 48 ++++++ src/context/AuthContext.tsx | 44 ++++++ src/css/auth.css | 176 ++++++++++++++++++++++ src/css/header.css | 93 ++++++++++++ src/lib/auth/account.ts | 120 +++++++++++++++ src/lib/auth/crypto.ts | 197 +++++++++++++++++++++++++ src/lib/auth/init.ts | 135 +++++++++++++++++ src/lib/auth/types.ts | 19 +++ src/routes/Auth.tsx | 44 ++++++ src/routes/Board.tsx | 13 +- src/ui/AuthDialog.tsx | 146 ++++++++++++++++++ 16 files changed, 1272 insertions(+), 2 deletions(-) create mode 100644 .cursor/mcp.json create mode 100644 src/components/auth/Login.tsx create mode 100644 src/components/auth/Profile.tsx create mode 100644 src/components/auth/ProtectedRoute.tsx create mode 100644 src/components/footer.tsx create mode 100644 src/components/layout/Header.tsx create mode 100644 src/context/AuthContext.tsx create mode 100644 src/css/auth.css create mode 100644 src/css/header.css create mode 100644 src/lib/auth/account.ts create mode 100644 src/lib/auth/crypto.ts create mode 100644 src/lib/auth/init.ts create mode 100644 src/lib/auth/types.ts create mode 100644 src/routes/Auth.tsx create mode 100644 src/ui/AuthDialog.tsx diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 0000000..ce34bc1 --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,23 @@ +{ + "mcpServers": { + "obsidian-mcp": { + "command": "npx", + "args": [ + "-y", + "@smithery/cli@latest", + "run", + "obsidian-mcp", + "--key", + "301b50ba-715f-45df-8f1d-d9695232d0b4" + ] + } + }, + "MCP Installer": { + "command": "cursor-mcp-installer-free", + "type": "stdio", + "args": [ + "index.mjs" + ] + } +} + diff --git a/src/components/auth/Login.tsx b/src/components/auth/Login.tsx new file mode 100644 index 0000000..a140e15 --- /dev/null +++ b/src/components/auth/Login.tsx @@ -0,0 +1,117 @@ +import React, { useState } from 'react'; +import { isUsernameValid, isUsernameAvailable, register, loadAccount } from '../../lib/auth/account'; +import { useAuth } from '../../context/AuthContext'; +import { saveSession } from '../../lib/auth/init'; + +interface LoginProps { + onSuccess?: () => void; +} + +export const Login: React.FC = ({ onSuccess }) => { + const [username, setUsername] = useState(''); + const [isRegistering, setIsRegistering] = useState(false); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const { updateSession } = useAuth(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setIsLoading(true); + + try { + // Validate username format + const valid = await isUsernameValid(username); + if (!valid) { + setError('Username must be 3-20 characters and can only contain letters, numbers, underscores, and hyphens'); + setIsLoading(false); + return; + } + + if (isRegistering) { + // Registration flow + const available = await isUsernameAvailable(username); + if (!available) { + setError('Username is already taken'); + setIsLoading(false); + return; + } + + const success = await register(username); + if (success) { + // Update session state + const newSession = { + username, + authed: true, + loading: false, + backupCreated: false, + }; + + updateSession(newSession); + saveSession(newSession); + + if (onSuccess) onSuccess(); + } else { + setError('Registration failed'); + } + } else { + // Login flow + const success = await loadAccount(username); + if (success) { + // Update session state + const newSession = { + username, + authed: true, + loading: false, + backupCreated: true, + }; + + updateSession(newSession); + saveSession(newSession); + + if (onSuccess) onSuccess(); + } else { + setError('User not found'); + } + } + } catch (err) { + console.error('Authentication error:', err); + setError('An unexpected error occurred'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+

{isRegistering ? 'Create Account' : 'Sign In'}

+ +
+
+ + setUsername(e.target.value)} + placeholder="Enter username" + required + disabled={isLoading} + /> +
+ + {error &&
{error}
} + + +
+ +
+ +
+
+ ); +}; \ No newline at end of file diff --git a/src/components/auth/Profile.tsx b/src/components/auth/Profile.tsx new file mode 100644 index 0000000..03a9228 --- /dev/null +++ b/src/components/auth/Profile.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { useAuth } from '../../context/AuthContext'; +import { clearSession } from '../../lib/auth/init'; + +interface ProfileProps { + onLogout?: () => void; +} + +export const Profile: React.FC = ({ 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 ( +
+
+

Welcome, {session.username}!

+
+ +
+ +
+ + {!session.backupCreated && ( +
+

Remember to back up your encryption keys to prevent data loss!

+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/src/components/auth/ProtectedRoute.tsx b/src/components/auth/ProtectedRoute.tsx new file mode 100644 index 0000000..0373bc8 --- /dev/null +++ b/src/components/auth/ProtectedRoute.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { useAuth } from '../../context/AuthContext'; + +interface ProtectedRouteProps { + children: React.ReactNode; +} + +export const ProtectedRoute: React.FC = ({ children }) => { + const { session } = useAuth(); + + if (session.loading) { + // Show loading indicator while authentication is being checked + return ( +
+

Checking authentication...

+
+ ); + } + + // For board routes, we'll allow access even if not authenticated + // The auth button in the toolbar will handle authentication + return <>{children}; +}; \ No newline at end of file diff --git a/src/components/footer.tsx b/src/components/footer.tsx new file mode 100644 index 0000000..2dca55b --- /dev/null +++ b/src/components/footer.tsx @@ -0,0 +1,26 @@ +import React from 'react'; + +export function Footer() { + return ( + + ); +} diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx new file mode 100644 index 0000000..fcf06ea --- /dev/null +++ b/src/components/layout/Header.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { useAuth } from '../../context/AuthContext'; +import { Profile } from '../auth/Profile'; + +export const Header: React.FC = () => { + const { session } = useAuth(); + const navigate = useNavigate(); + + return ( +
+
+
+ Canvas Website +
+ + + +
+ {session.authed ? ( + navigate('/')} /> + ) : ( + + )} +
+
+
+ ); +}; \ No newline at end of file diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx new file mode 100644 index 0000000..b3b3930 --- /dev/null +++ b/src/context/AuthContext.tsx @@ -0,0 +1,44 @@ +import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; +import { Session } from '../lib/auth/types'; +import { initialize } from '../lib/auth/init'; + +interface AuthContextType { + session: Session; + updateSession: (updatedSession: Partial) => void; +} + +const AuthContext = createContext(undefined); + +interface AuthProviderProps { + children: ReactNode; +} + +export const AuthProvider: React.FC = ({ children }) => { + const [session, setSession] = useState({ + username: '', + authed: false, + loading: false, + backupCreated: null, + }); + + const updateSession = (updatedSession: Partial) => { + setSession((prevSession) => ({ + ...prevSession, + ...updatedSession, + })); + }; + + return ( + + {children} + + ); +}; + +export const useAuth = (): AuthContextType => { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; \ No newline at end of file diff --git a/src/css/auth.css b/src/css/auth.css new file mode 100644 index 0000000..29d2c4f --- /dev/null +++ b/src/css/auth.css @@ -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; + } \ No newline at end of file diff --git a/src/css/header.css b/src/css/header.css new file mode 100644 index 0000000..8b9b78e --- /dev/null +++ b/src/css/header.css @@ -0,0 +1,93 @@ +/* Header Styles */ +.site-header { + background-color: white; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + padding: 16px 0; + } + + .header-container { + display: flex; + justify-content: space-between; + align-items: center; + max-width: 1200px; + margin: 0 auto; + padding: 0 20px; + } + + .logo a { + font-size: 20px; + font-weight: 700; + color: #333; + text-decoration: none; + } + + .main-nav ul { + display: flex; + list-style: none; + margin: 0; + padding: 0; + } + + .main-nav li { + margin-left: 24px; + } + + .main-nav li:first-child { + margin-left: 0; + } + + .main-nav a { + color: #555; + text-decoration: none; + font-weight: 500; + transition: color 0.2s; + } + + .main-nav a:hover { + color: #6366f1; + } + + .auth-section { + display: flex; + align-items: center; + } + + .login-button { + background-color: #6366f1; + color: white; + border: none; + border-radius: 4px; + padding: 8px 16px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s; + } + + .login-button:hover { + background-color: #4f46e5; + } + + /* Mobile Responsive Styles */ + @media (max-width: 768px) { + .header-container { + flex-direction: column; + } + + .main-nav { + margin: 16px 0; + } + + .main-nav ul { + flex-wrap: wrap; + justify-content: center; + } + + .main-nav li { + margin: 0 12px; + } + + .auth-section { + margin-top: 16px; + } + } \ No newline at end of file diff --git a/src/lib/auth/account.ts b/src/lib/auth/account.ts new file mode 100644 index 0000000..48e955e --- /dev/null +++ b/src/lib/auth/account.ts @@ -0,0 +1,120 @@ +import * as crypto from './crypto'; + +/** + * Validates if a username meets the required format + * @param username The username to validate + * @returns A boolean indicating if the username is valid + */ +export const isUsernameValid = async (username: string): Promise => { + console.log('Checking if username is valid:', username); + try { + // Basic validation - can be expanded as needed + const isValid = crypto.isUsernameValid(username); + console.log('Username validity check result:', isValid); + return isValid; + } catch (error) { + console.error('Error checking username validity:', error); + return false; + } +}; + +/** + * Checks if a username is available for registration + * @param username The username to check + * @returns A boolean indicating if the username is available + */ +export const isUsernameAvailable = async ( + username: string +): Promise => { + console.log('Checking if username is available:', username); + try { + const isAvailable = crypto.isUsernameAvailable(username); + console.log('Username availability check result:', isAvailable); + return isAvailable; + } catch (error) { + console.error('Error checking username availability:', error); + return false; + } +}; + +/** + * Registers a new user by generating cryptographic keys + * @param username The username to register + * @returns A boolean indicating if the registration was successful + */ +export const register = async (username: string): Promise => { + console.log('Starting registration process for username:', username); + + try { + // Check if we're in a browser environment + if (crypto.isBrowser()) { + console.log('Generating cryptographic keys for user...'); + // Generate a key pair using Web Crypto API + const keyPair = await crypto.generateKeyPair(); + + if (keyPair) { + // Export the public key + const publicKeyBase64 = await crypto.exportPublicKey(keyPair.publicKey); + + if (publicKeyBase64) { + console.log('Keys generated successfully'); + + // Store the username and public key + crypto.addRegisteredUser(username); + crypto.storePublicKey(username, publicKeyBase64); + + // In a production scenario, you would send the public key to a server + // and establish session management, etc. + + return true; + } else { + console.error('Failed to export public key'); + return false; + } + } else { + console.error('Failed to generate key pair'); + return false; + } + } else { + console.log('Not in browser environment, skipping key generation'); + return false; + } + } catch (error) { + console.error('Error during registration process:', error); + return false; + } +}; + +/** + * Loads a user account + * @param username The username to load + * @returns A Promise that resolves when the account is loaded + */ +export const loadAccount = async (username: string): Promise => { + console.log('Loading account for username:', username); + + try { + // Check if the user exists in our local storage + const users = crypto.getRegisteredUsers(); + if (!users.includes(username)) { + console.error('User not found:', username); + return false; + } + + // Get the user's public key + const publicKey = crypto.getPublicKey(username); + if (!publicKey) { + console.error('Public key not found for user:', username); + return false; + } + + // In a production scenario, you would verify the user's identity, + // load their data from a server, etc. + + console.log('User account loaded successfully'); + return true; + } catch (error) { + console.error('Error during account loading:', error); + return false; + } +}; \ No newline at end of file diff --git a/src/lib/auth/crypto.ts b/src/lib/auth/crypto.ts new file mode 100644 index 0000000..37a1723 --- /dev/null +++ b/src/lib/auth/crypto.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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; + } +}; \ No newline at end of file diff --git a/src/lib/auth/init.ts b/src/lib/auth/init.ts new file mode 100644 index 0000000..5f6497f --- /dev/null +++ b/src/lib/auth/init.ts @@ -0,0 +1,135 @@ +import * as crypto from './crypto'; +import { Session } from './types'; + +// Debug flag to enable detailed logging +const DEBUG = true; + +/** + * Initializes the authentication system + * This now only checks if the browser supports required features + * but does NOT attempt to authenticate the user automatically + */ +export const initialize = async (): Promise => { + if (DEBUG) console.log('Initializing authentication system...'); + + // Always return unauthenticated state initially + // Authentication will only happen when user explicitly clicks the Sign In button + return { + username: '', + authed: false, + loading: false, + backupCreated: null, + }; +}; + +/** + * Checks if there's an existing valid session + * This is only called when the user explicitly tries to authenticate + */ +export const checkExistingSession = async (): Promise => { + if (!crypto.isBrowser()) return null; + + try { + // Check if the browser supports the Web Crypto API + if (!window.crypto || !window.crypto.subtle) { + if (DEBUG) console.error('Web Crypto API not supported'); + return { + username: '', + authed: false, + loading: false, + backupCreated: null, + error: 'Unsupported Browser', + }; + } + + // Check for an existing session in localStorage + const sessionData = localStorage.getItem('authSession'); + if (sessionData) { + try { + const parsedSession = JSON.parse(sessionData); + const username = parsedSession.username; + + // Verify the username exists in our registered users + const users = crypto.getRegisteredUsers(); + if (users.includes(username)) { + if (DEBUG) console.log('Existing session found for user:', username); + + // In a real-world scenario, you'd verify the session validity here + return { + username, + authed: true, + loading: false, + backupCreated: true, + }; + } + } catch (error) { + if (DEBUG) console.error('Error parsing session data:', error); + } + } + + // No valid session found + if (DEBUG) console.log('No valid session found'); + return null; + } catch (error) { + console.error('Error checking existing session:', error); + + if (error instanceof Error && error.name === 'SecurityError') { + return { + username: '', + authed: false, + loading: false, + backupCreated: null, + error: 'Insecure Context', + }; + } + + return { + username: '', + authed: false, + loading: false, + backupCreated: null, + error: 'Unsupported Browser', + }; + } +}; + +/** + * Saves the current session to localStorage + * @param session The session to save + */ +export const saveSession = (session: Session): void => { + if (!crypto.isBrowser()) return; + + try { + // Only save if the user is authenticated + if (session.authed && session.username) { + const sessionData = { + username: session.username, + timestamp: new Date().getTime(), + }; + + localStorage.setItem('authSession', JSON.stringify(sessionData)); + if (DEBUG) console.log('Session saved for user:', session.username); + } else { + // Clear any existing session + localStorage.removeItem('authSession'); + if (DEBUG) console.log('Session cleared'); + } + } catch (error) { + console.error('Error saving session:', error); + } +}; + +/** + * Clears the current session + */ +export const clearSession = (): void => { + if (!crypto.isBrowser()) return; + + try { + localStorage.removeItem('authSession'); + if (DEBUG) console.log('Session cleared'); + } catch (error) { + console.error('Error clearing session:', error); + } +}; \ No newline at end of file diff --git a/src/lib/auth/types.ts b/src/lib/auth/types.ts new file mode 100644 index 0000000..d74293a --- /dev/null +++ b/src/lib/auth/types.ts @@ -0,0 +1,19 @@ +export type Session = { + username: string; + authed: boolean; + loading: boolean; + backupCreated: boolean | null; + error?: SessionError; +}; + +export type SessionError = 'Insecure Context' | 'Unsupported Browser'; + +export const errorToMessage = (error: SessionError): string => { + 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`; + } +}; \ No newline at end of file diff --git a/src/routes/Auth.tsx b/src/routes/Auth.tsx new file mode 100644 index 0000000..7af3a36 --- /dev/null +++ b/src/routes/Auth.tsx @@ -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 ( +
+
+

Loading authentication system...

+
+
+ ); + } + + if (session.error) { + return ( +
+
+

Authentication Error

+

{errorToMessage(session.error)}

+
+
+ ); + } + + return ( +
+ navigate('/')} /> +
+ ); +}; \ No newline at end of file diff --git a/src/routes/Board.tsx b/src/routes/Board.tsx index c893f5b..a880a87 100644 --- a/src/routes/Board.tsx +++ b/src/routes/Board.tsx @@ -39,8 +39,10 @@ import { zoomToSelection, } from "@/ui/cameraUtils" -// Default to production URL if env var isn't available -export const WORKER_URL = "https://jeffemmett-canvas.jeffemmett.workers.dev" +// Use environment-specific worker URL +export const WORKER_URL = import.meta.env.MODE === 'production' + ? "https://api.jeffemmett.com" + : "http://localhost:5172" const customShapeUtils = [ ChatBoxShape, @@ -79,6 +81,7 @@ export function Board() { const [editor, setEditor] = useState(null) const [isCameraLocked, setIsCameraLocked] = useState(false) + const [connectionError, setConnectionError] = useState(null) useEffect(() => { const value = localStorage.getItem("makereal_settings_2") @@ -215,6 +218,12 @@ export function Board() { ]) }} /> + {connectionError && ( +
+

{connectionError}

+ +
+ )} ) } diff --git a/src/ui/AuthDialog.tsx b/src/ui/AuthDialog.tsx new file mode 100644 index 0000000..002fdfc --- /dev/null +++ b/src/ui/AuthDialog.tsx @@ -0,0 +1,146 @@ +import { + TLUiDialogProps, + TldrawUiButton, + TldrawUiButtonLabel, + TldrawUiDialogBody, + TldrawUiDialogCloseButton, + TldrawUiDialogFooter, + TldrawUiDialogHeader, + TldrawUiDialogTitle, + TldrawUiInput, + useDialogs +} from "tldraw" +import React, { useState } from "react" +import { useAuth } from "../context/AuthContext" +import { isUsernameValid, isUsernameAvailable, register, loadAccount } from '../lib/auth/account' +import { saveSession } from '../lib/auth/init' + +export function AuthDialog({ onClose }: TLUiDialogProps) { + const [username, setUsername] = useState('') + const [isRegistering, setIsRegistering] = useState(false) + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const { updateSession } = useAuth() + const { removeDialog } = useDialogs() + + const handleSubmit = async () => { + if (!username.trim()) { + setError('Username is required') + return + } + + setError(null) + setIsLoading(true) + + try { + // Validate username format + if (!isUsernameValid(username)) { + setError('Username must be 3-20 characters and can only contain letters, numbers, underscores, and hyphens') + setIsLoading(false) + return + } + + if (isRegistering) { + // Registration flow + const available = await isUsernameAvailable(username) + if (!available) { + setError('Username is already taken') + setIsLoading(false) + return + } + + const success = await register(username) + if (success) { + // Update session state + const newSession = { + username, + authed: true, + loading: false, + backupCreated: false, + } + + updateSession(newSession) + saveSession(newSession) + + // Close the dialog safely + removeDialog("auth") + if (onClose) onClose() + } else { + setError('Registration failed') + } + } else { + // Login flow + const success = await loadAccount(username) + if (success) { + // Update session state + const newSession = { + username, + authed: true, + loading: false, + backupCreated: true, + } + + updateSession(newSession) + saveSession(newSession) + + // Close the dialog safely + removeDialog("auth") + if (onClose) onClose() + } else { + setError('User not found') + } + } + } catch (err) { + console.error('Authentication error:', err) + setError('An unexpected error occurred') + } finally { + setIsLoading(false) + } + } + + return ( + <> + + {isRegistering ? 'Create Account' : 'Sign In'} + + + +
+
+ + +
+ + {error &&
{error}
} + +
+ setIsRegistering(!isRegistering)} + disabled={isLoading} + > + + {isRegistering ? 'Already have an account?' : 'Need an account?'} + + + + + + {isLoading ? 'Processing...' : isRegistering ? 'Register' : 'Login'} + + +
+
+
+ + ) +} \ No newline at end of file