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.

This commit is contained in:
Jeff Emmett 2025-04-16 12:31:35 -07:00
parent 4e88428706
commit 3d32d1418e
16 changed files with 1272 additions and 2 deletions

23
.cursor/mcp.json Normal file
View File

@ -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"
]
}
}

View File

@ -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<LoginProps> = ({ onSuccess }) => {
const [username, setUsername] = useState('');
const [isRegistering, setIsRegistering] = useState(false);
const [error, setError] = useState<string | null>(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 (
<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}
/>
</div>
{error && <div className="error-message">{error}</div>}
<button type="submit" disabled={isLoading} className="auth-button">
{isLoading ? 'Processing...' : isRegistering ? 'Register' : 'Login'}
</button>
</form>
<div className="auth-toggle">
<button onClick={() => setIsRegistering(!isRegistering)} disabled={isLoading}>
{isRegistering ? 'Already have an account? Sign in' : 'Need an account? Register'}
</button>
</div>
</div>
);
};

View File

@ -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<ProfileProps> = ({ onLogout }) => {
const { session, updateSession } = useAuth();
const handleLogout = () => {
// Clear the session
clearSession();
// Update the auth context
updateSession({
username: '',
authed: false,
backupCreated: null,
});
// Call the onLogout callback if provided
if (onLogout) onLogout();
};
if (!session.authed || !session.username) {
return null;
}
return (
<div className="profile-container">
<div className="profile-header">
<h3>Welcome, {session.username}!</h3>
</div>
<div className="profile-actions">
<button onClick={handleLogout} className="logout-button">
Sign Out
</button>
</div>
{!session.backupCreated && (
<div className="backup-reminder">
<p>Remember to back up your encryption keys to prevent data loss!</p>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,23 @@
import React from 'react';
import { useAuth } from '../../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}</>;
};

26
src/components/footer.tsx Normal file
View File

@ -0,0 +1,26 @@
import React from 'react';
export function Footer() {
return (
<footer style={{
marginTop: "40px",
padding: "20px",
fontFamily: "'Recursive', sans-serif",
fontSize: "0.9rem",
color: "#666",
textAlign: "center",
maxWidth: "800px",
marginLeft: "auto",
marginRight: "auto"
}}>
<div className="footer-links">
<p>Explore more of Jeff's mycelial tendrils:</p>
<ul style={{listStyle: "none", padding: 0}}>
<li><a href="https://draw.jeffemmett.com" style={{color: "#555", textDecoration: "underline"}}>draw.jeffemmett.com</a> - An AI-augmented art generation tool</li>
<li><a href="https://quartz.jeffemmett.com" style={{color: "#555", textDecoration: "underline"}}>quartz.jeffemmett.com</a> - A glimpse into Jeff's Obsidian knowledge graph</li>
<li><a href="https://jeffemmett.com/board/explainer" style={{color: "#555", textDecoration: "underline"}}>jeffemmett.com/board/explainer</a> - A board explaining how boards work</li>
</ul>
</div>
</footer>
);
}

View File

@ -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 (
<header className="site-header">
<div className="header-container">
<div className="logo">
<Link to="/">Canvas Website</Link>
</div>
<nav className="main-nav">
<ul>
<li>
<Link to="/">Home</Link>
</li>
{session.authed ? (
<li>
<Link to="/inbox">Inbox</Link>
</li>
) : null}
<li>
<Link to="/contact">Contact</Link>
</li>
</ul>
</nav>
<div className="auth-section">
{session.authed ? (
<Profile onLogout={() => navigate('/')} />
) : (
<button
className="login-button"
onClick={() => navigate('/auth')}
>
Sign In
</button>
)}
</div>
</div>
</header>
);
};

View File

@ -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<Session>) => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
interface AuthProviderProps {
children: ReactNode;
}
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const [session, setSession] = useState<Session>({
username: '',
authed: false,
loading: false,
backupCreated: null,
});
const updateSession = (updatedSession: Partial<Session>) => {
setSession((prevSession) => ({
...prevSession,
...updatedSession,
}));
};
return (
<AuthContext.Provider value={{ session, updateSession }}>
{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;
};

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

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

93
src/css/header.css Normal file
View File

@ -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;
}
}

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

@ -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<boolean> => {
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<boolean> => {
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<boolean> => {
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<boolean> => {
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;
}
};

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

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

135
src/lib/auth/init.ts Normal file
View File

@ -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<Session> => {
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<Session | null> => {
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);
}
};

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

@ -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`;
}
};

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

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

View File

@ -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<Editor | null>(null)
const [isCameraLocked, setIsCameraLocked] = useState(false)
const [connectionError, setConnectionError] = useState<string | null>(null)
useEffect(() => {
const value = localStorage.getItem("makereal_settings_2")
@ -215,6 +218,12 @@ export function Board() {
])
}}
/>
{connectionError && (
<div className="connection-error">
<p>{connectionError}</p>
<button onClick={() => window.location.reload()}>Retry</button>
</div>
)}
</div>
)
}

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

@ -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<string | null>(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 (
<>
<TldrawUiDialogHeader>
<TldrawUiDialogTitle>{isRegistering ? 'Create Account' : 'Sign In'}</TldrawUiDialogTitle>
<TldrawUiDialogCloseButton />
</TldrawUiDialogHeader>
<TldrawUiDialogBody style={{ maxWidth: 350 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<label>Username</label>
<TldrawUiInput
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>
</TldrawUiDialogBody>
</>
)
}