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:
parent
4e88428706
commit
3d32d1418e
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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}</>;
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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,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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
@ -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,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);
|
||||
}
|
||||
};
|
||||
|
|
@ -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`;
|
||||
}
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue