refactor: remove OddJS dependency and fix Automerge sync

Major Changes:
- Fix Automerge "Document unavailable" error by awaiting repo.find()
- Remove @oddjs/odd package and all related dependencies (205 packages)
- Remove location sharing features (OddJS filesystem-dependent)
- Simplify auth to use only CryptoAuthService (WebCryptoAPI-based)

Auth System Changes:
- Refactor AuthService to remove OddJS filesystem integration
- Update AuthContext to remove FileSystem references
- Delete unused auth files (account.ts, backup.ts, linking.ts)
- Delete unused auth components (Register.tsx, LinkDevice.tsx)

Location Features Removed:
- Delete all location components and routes
- Remove LocationShareShape from shape registry
- Clean up location references across codebase

Documentation Updates:
- Update WEBCRYPTO_AUTH.md to remove OddJS references
- Correct component names (CryptoLogin → CryptID)
- Update file structure and dependencies
- Fix Automerge README WebSocket path documentation

Build System:
- Successfully builds without OddJS dependencies
- All TypeScript errors resolved
- Production bundle size optimized

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2025-11-16 20:19:02 -07:00
parent e1f4e83383
commit e96e6480fe
33 changed files with 143 additions and 5310 deletions

View File

@ -4,7 +4,7 @@ This document describes the complete WebCryptoAPI authentication system implemen
## Overview ## Overview
The WebCryptoAPI authentication system provides cryptographic authentication using ECDSA P-256 key pairs, challenge-response authentication, and secure key storage. It integrates with the existing ODD (Open Data Directory) framework while providing a fallback authentication mechanism. The WebCryptoAPI authentication system provides cryptographic authentication using ECDSA P-256 key pairs, challenge-response authentication, and secure key storage. This is the primary authentication mechanism for the application.
## Architecture ## Architecture
@ -23,13 +23,14 @@ The WebCryptoAPI authentication system provides cryptographic authentication usi
- User registration and login - User registration and login
- Credential verification - Credential verification
3. **Enhanced AuthService** (`src/lib/auth/authService.ts`) 3. **AuthService** (`src/lib/auth/authService.ts`)
- Integrates crypto authentication with ODD - Simplified authentication service
- Fallback mechanisms
- Session management - Session management
- Integration with CryptoAuthService
4. **UI Components** 4. **UI Components**
- `CryptoLogin.tsx` - Cryptographic authentication UI - `CryptID.tsx` - Cryptographic authentication UI
- `CryptoDebug.tsx` - Debug component for verification
- `CryptoTest.tsx` - Test component for verification - `CryptoTest.tsx` - Test component for verification
## Features ## Features
@ -41,7 +42,6 @@ The WebCryptoAPI authentication system provides cryptographic authentication usi
- **Public Key Infrastructure**: Store and verify public keys - **Public Key Infrastructure**: Store and verify public keys
- **Browser Support Detection**: Checks for WebCryptoAPI availability - **Browser Support Detection**: Checks for WebCryptoAPI availability
- **Secure Context Validation**: Ensures HTTPS requirement - **Secure Context Validation**: Ensures HTTPS requirement
- **Fallback Authentication**: Works with existing ODD system
- **Modern UI**: Responsive design with dark mode support - **Modern UI**: Responsive design with dark mode support
- **Comprehensive Testing**: Test component for verification - **Comprehensive Testing**: Test component for verification
@ -98,26 +98,26 @@ const isSecure = window.isSecureContext;
1. **Secure Context Requirement**: Only works over HTTPS 1. **Secure Context Requirement**: Only works over HTTPS
2. **ECDSA P-256**: Industry-standard elliptic curve 2. **ECDSA P-256**: Industry-standard elliptic curve
3. **Challenge-Response**: Prevents replay attacks 3. **Challenge-Response**: Prevents replay attacks
4. **Key Storage**: Public keys stored securely 4. **Key Storage**: Public keys stored securely in localStorage
5. **Input Validation**: Username format validation 5. **Input Validation**: Username format validation
6. **Error Handling**: Comprehensive error management 6. **Error Handling**: Comprehensive error management
### ⚠️ Security Notes ### ⚠️ Security Notes
1. **Private Key Storage**: Currently simplified for demo purposes 1. **Private Key Storage**: Currently uses localStorage for demo purposes
- In production, use Web Crypto API's key storage - In production, consider using Web Crypto API's non-extractable keys
- Consider hardware security modules (HSM) - Consider hardware security modules (HSM)
- Implement proper key derivation - Implement proper key derivation
2. **Session Management**: 2. **Session Management**:
- Integrates with existing ODD session system - Uses localStorage for session persistence
- Consider implementing JWT tokens - Consider implementing JWT tokens for server-side verification
- Add session expiration - Add session expiration and refresh logic
3. **Network Security**: 3. **Network Security**:
- All crypto operations happen client-side - All crypto operations happen client-side
- No private keys transmitted over network - No private keys transmitted over network
- Consider adding server-side verification - Consider adding server-side signature verification
## Usage ## Usage
@ -146,11 +146,22 @@ import { useAuth } from './context/AuthContext';
const { login, register } = useAuth(); const { login, register } = useAuth();
// The AuthService automatically tries crypto auth first, // AuthService automatically uses crypto auth
// then falls back to ODD authentication
const success = await login('username'); const success = await login('username');
``` ```
### Using the CryptID Component
```typescript
import CryptID from './components/auth/CryptID';
// Render the authentication component
<CryptID
onSuccess={() => console.log('Login successful')}
onCancel={() => console.log('Login cancelled')}
/>
```
### Testing the Implementation ### Testing the Implementation
```typescript ```typescript
@ -168,14 +179,18 @@ src/
│ ├── auth/ │ ├── auth/
│ │ ├── crypto.ts # WebCryptoAPI wrapper │ │ ├── crypto.ts # WebCryptoAPI wrapper
│ │ ├── cryptoAuthService.ts # High-level auth service │ │ ├── cryptoAuthService.ts # High-level auth service
│ │ ├── authService.ts # Enhanced auth service │ │ ├── authService.ts # Simplified auth service
│ │ └── account.ts # User account management │ │ ├── sessionPersistence.ts # Session storage utilities
│ │ └── types.ts # TypeScript types
│ └── utils/ │ └── utils/
│ └── browser.ts # Browser support detection │ └── browser.ts # Browser support detection
├── components/ ├── components/
│ └── auth/ │ └── auth/
│ ├── CryptoLogin.tsx # Crypto auth UI │ ├── CryptID.tsx # Main crypto auth UI
│ ├── CryptoDebug.tsx # Debug component
│ └── CryptoTest.tsx # Test component │ └── CryptoTest.tsx # Test component
├── context/
│ └── AuthContext.tsx # React context for auth state
└── css/ └── css/
└── crypto-auth.css # Styles for crypto components └── crypto-auth.css # Styles for crypto components
``` ```
@ -184,13 +199,20 @@ src/
### Required Packages ### Required Packages
- `one-webcrypto`: WebCryptoAPI polyfill (^1.0.3) - `one-webcrypto`: WebCryptoAPI polyfill (^1.0.3)
- `@oddjs/odd`: Open Data Directory framework (^0.37.2)
### Browser APIs Used ### Browser APIs Used
- `window.crypto.subtle`: WebCryptoAPI - `window.crypto.subtle`: WebCryptoAPI
- `window.localStorage`: Key storage - `window.localStorage`: Key and session storage
- `window.isSecureContext`: Security context check - `window.isSecureContext`: Security context check
## Storage
### localStorage Keys Used
- `registeredUsers`: Array of registered usernames
- `${username}_publicKey`: User's public key (Base64)
- `${username}_authData`: Authentication data (challenge, signature, timestamp)
- `session`: Current user session data
## Testing ## Testing
### Manual Testing ### Manual Testing
@ -208,6 +230,7 @@ src/
- [x] User registration - [x] User registration
- [x] User login - [x] User login
- [x] Credential verification - [x] Credential verification
- [x] Session persistence
## Troubleshooting ## Troubleshooting
@ -228,13 +251,13 @@ src/
- Try refreshing the page - Try refreshing the page
4. **"Authentication failed"** 4. **"Authentication failed"**
- Verify user exists - Verify user exists in localStorage
- Check stored credentials - Check stored credentials
- Clear browser data and retry - Clear browser data and retry
### Debug Mode ### Debug Mode
Enable debug logging by setting: Enable debug logging by opening the browser console:
```typescript ```typescript
localStorage.setItem('debug_crypto', 'true'); localStorage.setItem('debug_crypto', 'true');
``` ```
@ -242,7 +265,7 @@ localStorage.setItem('debug_crypto', 'true');
## Future Enhancements ## Future Enhancements
### Planned Improvements ### Planned Improvements
1. **Enhanced Key Storage**: Use Web Crypto API's key storage 1. **Enhanced Key Storage**: Use Web Crypto API's non-extractable keys
2. **Server-Side Verification**: Add server-side signature verification 2. **Server-Side Verification**: Add server-side signature verification
3. **Multi-Factor Authentication**: Add additional authentication factors 3. **Multi-Factor Authentication**: Add additional authentication factors
4. **Key Rotation**: Implement automatic key rotation 4. **Key Rotation**: Implement automatic key rotation
@ -254,6 +277,15 @@ localStorage.setItem('debug_crypto', 'true');
3. **Post-Quantum Cryptography**: Prepare for quantum threats 3. **Post-Quantum Cryptography**: Prepare for quantum threats
4. **Biometric Integration**: Add biometric authentication 4. **Biometric Integration**: Add biometric authentication
## Integration with Automerge Sync
The authentication system works seamlessly with the Automerge-based real-time collaboration:
- **User Identification**: Each user is identified by their username in Automerge
- **Session Management**: Sessions persist across page reloads via localStorage
- **Collaboration**: Authenticated users can join shared canvas rooms
- **Privacy**: Only authenticated users can access canvas data
## Contributing ## Contributing
When contributing to the WebCryptoAPI authentication system: When contributing to the WebCryptoAPI authentication system:

2535
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -28,7 +28,6 @@
"@chengsokdara/use-whisper": "^0.2.0", "@chengsokdara/use-whisper": "^0.2.0",
"@daily-co/daily-js": "^0.60.0", "@daily-co/daily-js": "^0.60.0",
"@daily-co/daily-react": "^0.20.0", "@daily-co/daily-react": "^0.20.0",
"@oddjs/odd": "^0.37.2",
"@tldraw/assets": "^3.15.4", "@tldraw/assets": "^3.15.4",
"@tldraw/tldraw": "^3.15.4", "@tldraw/tldraw": "^3.15.4",
"@tldraw/tlschema": "^3.15.4", "@tldraw/tlschema": "^3.15.4",

View File

@ -16,11 +16,7 @@ import "@/css/auth.css"; // Import auth styles
import "@/css/crypto-auth.css"; // Import crypto auth styles import "@/css/crypto-auth.css"; // Import crypto auth styles
import "@/css/starred-boards.css"; // Import starred boards styles import "@/css/starred-boards.css"; // Import starred boards styles
import "@/css/user-profile.css"; // Import user profile styles import "@/css/user-profile.css"; // Import user profile styles
import "@/css/location.css"; // Import location sharing styles
import { Dashboard } from "./routes/Dashboard"; import { Dashboard } from "./routes/Dashboard";
import { LocationShareCreate } from "./routes/LocationShareCreate";
import { LocationShareView } from "./routes/LocationShareView";
import { LocationDashboardRoute } from "./routes/LocationDashboardRoute";
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
// Import React Context providers // Import React Context providers
@ -149,22 +145,6 @@ const AppWithProviders = () => {
<Resilience /> <Resilience />
</OptionalAuthRoute> </OptionalAuthRoute>
} /> } />
{/* Location sharing routes */}
<Route path="/share-location" element={
<OptionalAuthRoute>
<LocationShareCreate />
</OptionalAuthRoute>
} />
<Route path="/location/:token" element={
<OptionalAuthRoute>
<LocationShareView />
</OptionalAuthRoute>
} />
<Route path="/location-dashboard" element={
<OptionalAuthRoute>
<LocationDashboardRoute />
</OptionalAuthRoute>
} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
</DailyProvider> </DailyProvider>

View File

@ -593,7 +593,7 @@ export function sanitizeRecord(record: any): TLRecord {
'holon': 'Holon', 'holon': 'Holon',
'obsidianBrowser': 'ObsidianBrowser', 'obsidianBrowser': 'ObsidianBrowser',
'fathomMeetingsBrowser': 'FathomMeetingsBrowser', 'fathomMeetingsBrowser': 'FathomMeetingsBrowser',
'locationShare': 'LocationShare', // locationShare removed
'imageGen': 'ImageGen', 'imageGen': 'ImageGen',
} }

View File

@ -47,6 +47,6 @@ To switch from TLdraw sync to Automerge sync:
1. Update the Board component to use `useAutomergeSync` 1. Update the Board component to use `useAutomergeSync`
2. Deploy the new worker with Automerge Durable Object 2. Deploy the new worker with Automerge Durable Object
3. Update the URI to use `/automerge/connect/` instead of `/connect/` 3. The CloudflareAdapter will automatically connect to `/connect/{roomId}` via WebSocket
The migration is backward compatible - existing TLdraw sync will continue to work while you test the new system. The migration is backward compatible - the system will handle both legacy and new document formats.

View File

@ -123,7 +123,7 @@ import { FathomNoteShape } from "@/shapes/FathomNoteShapeUtil"
import { HolonShape } from "@/shapes/HolonShapeUtil" import { HolonShape } from "@/shapes/HolonShapeUtil"
import { ObsidianBrowserShape } from "@/shapes/ObsidianBrowserShapeUtil" import { ObsidianBrowserShape } from "@/shapes/ObsidianBrowserShapeUtil"
import { FathomMeetingsBrowserShape } from "@/shapes/FathomMeetingsBrowserShapeUtil" import { FathomMeetingsBrowserShape } from "@/shapes/FathomMeetingsBrowserShapeUtil"
import { LocationShareShape } from "@/shapes/LocationShareShapeUtil" // Location shape removed - no longer needed
export function useAutomergeStoreV2({ export function useAutomergeStoreV2({
handle, handle,
@ -173,7 +173,6 @@ export function useAutomergeStoreV2({
HolonShape, HolonShape,
ObsidianBrowserShape, ObsidianBrowserShape,
FathomMeetingsBrowserShape, FathomMeetingsBrowserShape,
LocationShareShape,
], ],
}) })
return store return store

View File

@ -146,15 +146,10 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
let handle: DocHandle<TLStoreSnapshot> let handle: DocHandle<TLStoreSnapshot>
if (documentId) { if (documentId) {
// Try to find the existing document // Find the existing document (will sync from network if not available locally)
const foundHandle = await repo.find<TLStoreSnapshot>(documentId as any) console.log(`🔍 Finding document ${documentId} (will sync from network if needed)`)
if (!foundHandle) { handle = await repo.find<TLStoreSnapshot>(documentId as any)
console.log(`📝 Document ${documentId} not in local repo, creating handle`) console.log(`✅ Got handle for document: ${documentId}`)
handle = repo.create<TLStoreSnapshot>()
} else {
console.log(`✅ Found existing document in local repo: ${documentId}`)
handle = foundHandle
}
} else { } else {
// Create a new document and register its ID with the server // Create a new document and register its ID with the server
handle = repo.create<TLStoreSnapshot>() handle = repo.create<TLStoreSnapshot>()

View File

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

View File

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

View File

@ -1,187 +0,0 @@
"use client"
import type React from "react"
import { useState, useEffect } from "react"
import { useAuth } from "@/context/AuthContext"
import { LocationStorageService, type LocationData } from "@/lib/location/locationStorage"
import type { GeolocationPosition } from "@/lib/location/types"
interface LocationCaptureProps {
onLocationCaptured?: (location: LocationData) => void
onError?: (error: string) => void
}
export const LocationCapture: React.FC<LocationCaptureProps> = ({ onLocationCaptured, onError }) => {
const { session, fileSystem } = useAuth()
const [isCapturing, setIsCapturing] = useState(false)
const [permissionState, setPermissionState] = useState<"prompt" | "granted" | "denied">("prompt")
const [currentLocation, setCurrentLocation] = useState<GeolocationPosition | null>(null)
const [error, setError] = useState<string | null>(null)
// Show loading state while auth is initializing
if (session.loading) {
return (
<div className="location-capture-loading flex items-center justify-center min-h-[200px]">
<div className="text-center">
<div className="text-2xl mb-2 animate-spin"></div>
<p className="text-sm text-muted-foreground">Loading authentication...</p>
</div>
</div>
)
}
// Check permission status on mount
useEffect(() => {
if ("permissions" in navigator) {
navigator.permissions.query({ name: "geolocation" }).then((result) => {
setPermissionState(result.state as "prompt" | "granted" | "denied")
result.addEventListener("change", () => {
setPermissionState(result.state as "prompt" | "granted" | "denied")
})
})
}
}, [])
const captureLocation = async () => {
// Don't proceed if still loading
if (session.loading) {
return
}
if (!session.authed) {
const errorMsg = "You must be logged in to share your location. Please log in and try again."
setError(errorMsg)
onError?.(errorMsg)
return
}
if (!fileSystem) {
const errorMsg = "File system not available. Please refresh the page and try again."
setError(errorMsg)
onError?.(errorMsg)
return
}
setIsCapturing(true)
setError(null)
try {
// Request geolocation
const position = await new Promise<GeolocationPosition>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(
(pos) => resolve(pos as GeolocationPosition),
(err) => reject(err),
{
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 0,
},
)
})
setCurrentLocation(position)
// Create location data
const locationData: LocationData = {
id: crypto.randomUUID(),
userId: session.username,
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy: position.coords.accuracy,
timestamp: position.timestamp,
expiresAt: null, // Will be set when creating a share
precision: "exact",
}
// Save to filesystem
const storageService = new LocationStorageService(fileSystem)
await storageService.initialize()
await storageService.saveLocation(locationData)
onLocationCaptured?.(locationData)
} catch (err: any) {
let errorMsg = "Failed to capture location"
if (err.code === 1) {
errorMsg = "Location permission denied. Please enable location access in your browser settings."
setPermissionState("denied")
} else if (err.code === 2) {
errorMsg = "Location unavailable. Please check your device settings."
} else if (err.code === 3) {
errorMsg = "Location request timed out. Please try again."
}
setError(errorMsg)
onError?.(errorMsg)
} finally {
setIsCapturing(false)
}
}
return (
<div className="location-capture">
<div className="capture-header">
<h2 className="text-2xl font-semibold text-balance">Share Your Location</h2>
<p className="text-sm text-muted-foreground mt-2">Securely share your current location with others</p>
</div>
{/* Permission status */}
{permissionState === "denied" && (
<div className="permission-denied bg-destructive/10 border border-destructive/20 rounded-lg p-4 mt-4">
<p className="text-sm text-destructive">
Location access is blocked. Please enable it in your browser settings to continue.
</p>
</div>
)}
{/* Current location display */}
{currentLocation && (
<div className="current-location bg-muted/50 rounded-lg p-4 mt-4">
<h3 className="text-sm font-medium mb-2">Current Location</h3>
<div className="location-details text-xs space-y-1">
<p>
<span className="text-muted-foreground">Latitude:</span> {currentLocation.coords.latitude.toFixed(6)}
</p>
<p>
<span className="text-muted-foreground">Longitude:</span> {currentLocation.coords.longitude.toFixed(6)}
</p>
<p>
<span className="text-muted-foreground">Accuracy:</span> ±{Math.round(currentLocation.coords.accuracy)}m
</p>
<p className="text-muted-foreground">Captured {new Date(currentLocation.timestamp).toLocaleString()}</p>
</div>
</div>
)}
{/* Error display */}
{error && (
<div className="error-message bg-destructive/10 border border-destructive/20 rounded-lg p-4 mt-4">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
{/* Capture button */}
<button
onClick={captureLocation}
disabled={isCapturing || permissionState === "denied" || !session.authed}
className="capture-button w-full mt-6 bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg px-6 py-3 font-medium transition-colors"
>
{isCapturing ? (
<span className="flex items-center justify-center gap-2">
<span className="spinner" />
Capturing Location...
</span>
) : (
"Capture My Location"
)}
</button>
{!session.authed && (
<p className="text-xs text-muted-foreground text-center mt-3">Please log in to share your location</p>
)}
</div>
)
}

View File

@ -1,270 +0,0 @@
"use client"
import type React from "react"
import { useState, useEffect } from "react"
import { useAuth } from "@/context/AuthContext"
import { LocationStorageService, type LocationData, type LocationShare } from "@/lib/location/locationStorage"
import { LocationMap } from "./LocationMap"
interface ShareWithLocation {
share: LocationShare
location: LocationData
}
export const LocationDashboard: React.FC = () => {
const { session, fileSystem } = useAuth()
const [shares, setShares] = useState<ShareWithLocation[]>([])
const [loading, setLoading] = useState(true)
const [selectedShare, setSelectedShare] = useState<ShareWithLocation | null>(null)
const [error, setError] = useState<string | null>(null)
const loadShares = async () => {
if (!fileSystem) {
setError("File system not available")
setLoading(false)
return
}
try {
const storageService = new LocationStorageService(fileSystem)
await storageService.initialize()
// Get all shares
const allShares = await storageService.getAllShares()
// Get locations for each share
const sharesWithLocations: ShareWithLocation[] = []
for (const share of allShares) {
const location = await storageService.getLocation(share.locationId)
if (location) {
sharesWithLocations.push({ share, location })
}
}
// Sort by creation date (newest first)
sharesWithLocations.sort((a, b) => b.share.createdAt - a.share.createdAt)
setShares(sharesWithLocations)
setLoading(false)
} catch (err) {
console.error("Error loading shares:", err)
setError("Failed to load location shares")
setLoading(false)
}
}
useEffect(() => {
if (session.authed && fileSystem) {
loadShares()
}
}, [session.authed, fileSystem])
const handleCopyLink = async (shareToken: string) => {
const baseUrl = window.location.origin
const link = `${baseUrl}/location/${shareToken}`
try {
await navigator.clipboard.writeText(link)
alert("Link copied to clipboard!")
} catch (err) {
console.error("Failed to copy link:", err)
alert("Failed to copy link")
}
}
const isExpired = (share: LocationShare): boolean => {
return share.expiresAt ? share.expiresAt < Date.now() : false
}
const isMaxViewsReached = (share: LocationShare): boolean => {
return share.maxViews ? share.viewCount >= share.maxViews : false
}
const getShareStatus = (share: LocationShare): { label: string; color: string } => {
if (isExpired(share)) {
return { label: "Expired", color: "text-destructive" }
}
if (isMaxViewsReached(share)) {
return { label: "Max Views Reached", color: "text-destructive" }
}
return { label: "Active", color: "text-green-600" }
}
if (!session.authed) {
return (
<div className="location-dashboard-auth flex items-center justify-center min-h-[400px]">
<div className="text-center max-w-md">
<div className="text-4xl mb-4">🔒</div>
<h2 className="text-xl font-semibold mb-2">Authentication Required</h2>
<p className="text-sm text-muted-foreground">Please log in to view your location shares</p>
</div>
</div>
)
}
if (loading) {
return (
<div className="location-dashboard flex items-center justify-center min-h-[400px]">
<div className="flex flex-col items-center gap-3">
<div className="spinner" />
<p className="text-sm text-muted-foreground">Loading your shares...</p>
</div>
</div>
)
}
if (error) {
return (
<div className="location-dashboard flex items-center justify-center min-h-[400px]">
<div className="text-center max-w-md">
<div className="text-4xl mb-4"></div>
<h2 className="text-xl font-semibold mb-2">Error Loading Dashboard</h2>
<p className="text-sm text-muted-foreground">{error}</p>
<button
onClick={loadShares}
className="mt-4 px-6 py-2 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
>
Retry
</button>
</div>
</div>
)
}
return (
<div className="location-dashboard max-w-6xl mx-auto p-6">
<div className="dashboard-header mb-8">
<h1 className="text-3xl font-bold text-balance">Location Shares</h1>
<p className="text-sm text-muted-foreground mt-2">Manage your shared locations and privacy settings</p>
</div>
{shares.length === 0 ? (
<div className="empty-state flex flex-col items-center justify-center min-h-[400px] text-center">
<div className="text-6xl mb-4">📍</div>
<h2 className="text-xl font-semibold mb-2">No Location Shares Yet</h2>
<p className="text-sm text-muted-foreground mb-6 max-w-md">
You haven't shared any locations yet. Create your first share to get started.
</p>
<a
href="/share-location"
className="px-6 py-3 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors font-medium"
>
Share Your Location
</a>
</div>
) : (
<div className="dashboard-content">
{/* Stats Overview */}
<div className="stats-grid grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
<div className="stat-card bg-muted/50 rounded-lg p-4 border border-border">
<div className="stat-label text-sm text-muted-foreground mb-1">Total Shares</div>
<div className="stat-value text-3xl font-bold">{shares.length}</div>
</div>
<div className="stat-card bg-muted/50 rounded-lg p-4 border border-border">
<div className="stat-label text-sm text-muted-foreground mb-1">Active Shares</div>
<div className="stat-value text-3xl font-bold text-green-600">
{shares.filter((s) => !isExpired(s.share) && !isMaxViewsReached(s.share)).length}
</div>
</div>
<div className="stat-card bg-muted/50 rounded-lg p-4 border border-border">
<div className="stat-label text-sm text-muted-foreground mb-1">Total Views</div>
<div className="stat-value text-3xl font-bold">
{shares.reduce((sum, s) => sum + s.share.viewCount, 0)}
</div>
</div>
</div>
{/* Shares List */}
<div className="shares-list space-y-4">
{shares.map(({ share, location }) => {
const status = getShareStatus(share)
const isSelected = selectedShare?.share.id === share.id
return (
<div
key={share.id}
className={`share-card bg-background rounded-lg border-2 transition-colors ${
isSelected ? "border-primary" : "border-border hover:border-primary/50"
}`}
>
<div className="share-card-header p-4 flex items-start justify-between gap-4">
<div className="share-info flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="font-semibold">Location Share</h3>
<span className={`text-xs font-medium ${status.color}`}>{status.label}</span>
</div>
<div className="share-meta text-xs text-muted-foreground space-y-1">
<p>Created: {new Date(share.createdAt).toLocaleString()}</p>
{share.expiresAt && <p>Expires: {new Date(share.expiresAt).toLocaleString()}</p>}
<p>
Views: {share.viewCount}
{share.maxViews && ` / ${share.maxViews}`}
</p>
<p>
Precision: <span className="capitalize">{share.precision}</span>
</p>
</div>
</div>
<div className="share-actions flex gap-2">
<button
onClick={() => handleCopyLink(share.shareToken)}
disabled={isExpired(share) || isMaxViewsReached(share)}
className="px-4 py-2 rounded-lg border border-border hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm"
>
Copy Link
</button>
<button
onClick={() => setSelectedShare(isSelected ? null : { share, location })}
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors text-sm"
>
{isSelected ? "Hide" : "View"} Map
</button>
</div>
</div>
{isSelected && (
<div className="share-card-body p-4 pt-0 border-t border-border mt-4">
<LocationMap location={location} precision={share.precision} showAccuracy={true} height="300px" />
</div>
)}
</div>
)
})}
</div>
</div>
)}
</div>
)
}

View File

@ -1,241 +0,0 @@
"use client"
import type React from "react"
import { useEffect, useRef, useState } from "react"
import type { LocationData } from "@/lib/location/locationStorage"
import { obfuscateLocation } from "@/lib/location/locationStorage"
import type { PrecisionLevel } from "@/lib/location/types"
// Leaflet types
interface LeafletMap {
setView: (coords: [number, number], zoom: number) => void
remove: () => void
}
interface LeafletMarker {
addTo: (map: LeafletMap) => LeafletMarker
bindPopup: (content: string) => LeafletMarker
}
interface LeafletCircle {
addTo: (map: LeafletMap) => LeafletCircle
}
interface LeafletTileLayer {
addTo: (map: LeafletMap) => LeafletTileLayer
}
interface Leaflet {
map: (element: HTMLElement, options?: any) => LeafletMap
marker: (coords: [number, number], options?: any) => LeafletMarker
circle: (coords: [number, number], options?: any) => LeafletCircle
tileLayer: (url: string, options?: any) => LeafletTileLayer
icon: (options: any) => any
}
declare global {
interface Window {
L?: Leaflet
}
}
interface LocationMapProps {
location: LocationData
precision?: PrecisionLevel
showAccuracy?: boolean
height?: string
}
export const LocationMap: React.FC<LocationMapProps> = ({
location,
precision = "exact",
showAccuracy = true,
height = "400px",
}) => {
const mapContainer = useRef<HTMLDivElement>(null)
const mapInstance = useRef<LeafletMap | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
// Load Leaflet CSS and JS
const loadLeaflet = async () => {
try {
// Load CSS
if (!document.querySelector('link[href*="leaflet.css"]')) {
const link = document.createElement("link")
link.rel = "stylesheet"
link.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
link.integrity = "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
link.crossOrigin = ""
document.head.appendChild(link)
}
// Load JS
if (!window.L) {
await new Promise<void>((resolve, reject) => {
const script = document.createElement("script")
script.src = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
script.integrity = "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
script.crossOrigin = ""
script.onload = () => resolve()
script.onerror = () => reject(new Error("Failed to load Leaflet"))
document.head.appendChild(script)
})
}
setIsLoading(false)
} catch (err) {
setError("Failed to load map library")
setIsLoading(false)
}
}
loadLeaflet()
}, [])
useEffect(() => {
if (!mapContainer.current || !window.L || isLoading) return
// Clean up existing map
if (mapInstance.current) {
mapInstance.current.remove()
}
const L = window.L!
// Get obfuscated location based on precision
const { lat, lng, radius } = obfuscateLocation(location.latitude, location.longitude, precision)
// Create map
const map = L.map(mapContainer.current, {
center: [lat, lng],
zoom: precision === "exact" ? 15 : precision === "street" ? 14 : precision === "neighborhood" ? 12 : 10,
zoomControl: true,
attributionControl: true,
})
// Add OpenStreetMap tiles
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19,
}).addTo(map)
// Add marker
const marker = L.marker([lat, lng], {
icon: L.icon({
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41],
}),
}).addTo(map)
// Add popup with location info
const popupContent = `
<div style="font-family: system-ui, sans-serif;">
<strong>Shared Location</strong><br/>
<small style="color: #666;">
Precision: ${precision}<br/>
${new Date(location.timestamp).toLocaleString()}
</small>
</div>
`
marker.bindPopup(popupContent)
// Add accuracy circle if showing accuracy
if (showAccuracy && radius > 0) {
L.circle([lat, lng], {
radius: radius,
color: "#3b82f6",
fillColor: "#3b82f6",
fillOpacity: 0.1,
weight: 2,
}).addTo(map)
}
mapInstance.current = map
// Cleanup
return () => {
if (mapInstance.current) {
mapInstance.current.remove()
mapInstance.current = null
}
}
}, [location, precision, showAccuracy, isLoading])
if (error) {
return (
<div
className="map-error flex items-center justify-center bg-muted/50 rounded-lg border border-border"
style={{ height }}
>
<p className="text-sm text-destructive">{error}</p>
</div>
)
}
if (isLoading) {
return (
<div
className="map-loading flex items-center justify-center bg-muted/50 rounded-lg border border-border"
style={{ height }}
>
<div className="flex flex-col items-center gap-3">
<div className="spinner" />
<p className="text-sm text-muted-foreground">Loading map...</p>
</div>
</div>
)
}
return (
<div className="location-map-wrapper">
<div
ref={mapContainer}
className="location-map rounded-lg border border-border overflow-hidden"
style={{ height, width: "100%" }}
/>
<div className="map-info mt-3 text-xs text-muted-foreground">
<p>
Showing {precision} location Last updated {new Date(location.timestamp).toLocaleTimeString()}
</p>
</div>
</div>
)
}

View File

@ -1,45 +0,0 @@
import {
TLUiDialogProps,
TldrawUiDialogBody,
TldrawUiDialogCloseButton,
TldrawUiDialogHeader,
TldrawUiDialogTitle,
} from "tldraw"
import React from "react"
import { ShareLocation } from "./ShareLocation"
export function LocationShareDialog({ onClose: _onClose }: TLUiDialogProps) {
return (
<>
<TldrawUiDialogHeader>
<TldrawUiDialogTitle>Share Location</TldrawUiDialogTitle>
<TldrawUiDialogCloseButton />
</TldrawUiDialogHeader>
<TldrawUiDialogBody style={{ maxWidth: 800, maxHeight: "90vh", overflow: "auto" }}>
<ShareLocation />
</TldrawUiDialogBody>
</>
)
}

View File

@ -1,183 +0,0 @@
"use client"
import type React from "react"
import { useState, useEffect } from "react"
import { LocationMap } from "./LocationMap"
import type { LocationData, LocationShare } from "@/lib/location/locationStorage"
import { LocationStorageService } from "@/lib/location/locationStorage"
import { useAuth } from "@/context/AuthContext"
interface LocationViewerProps {
shareToken: string
}
export const LocationViewer: React.FC<LocationViewerProps> = ({ shareToken }) => {
const { fileSystem } = useAuth()
const [location, setLocation] = useState<LocationData | null>(null)
const [share, setShare] = useState<LocationShare | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const loadSharedLocation = async () => {
if (!fileSystem) {
setError("File system not available")
setLoading(false)
return
}
try {
const storageService = new LocationStorageService(fileSystem)
await storageService.initialize()
// Get share by token
const shareData = await storageService.getShareByToken(shareToken)
if (!shareData) {
setError("Share not found or expired")
setLoading(false)
return
}
// Check if share is expired
if (shareData.expiresAt && shareData.expiresAt < Date.now()) {
setError("This share has expired")
setLoading(false)
return
}
// Check if max views reached
if (shareData.maxViews && shareData.viewCount >= shareData.maxViews) {
setError("This share has reached its maximum view limit")
setLoading(false)
return
}
// Get location data
const locationData = await storageService.getLocation(shareData.locationId)
if (!locationData) {
setError("Location data not found")
setLoading(false)
return
}
setShare(shareData)
setLocation(locationData)
// Increment view count
await storageService.incrementShareViews(shareData.id)
setLoading(false)
} catch (err) {
console.error("Error loading shared location:", err)
setError("Failed to load shared location")
setLoading(false)
}
}
loadSharedLocation()
}, [shareToken, fileSystem])
if (loading) {
return (
<div className="location-viewer flex items-center justify-center min-h-[400px]">
<div className="flex flex-col items-center gap-3">
<div className="spinner" />
<p className="text-sm text-muted-foreground">Loading shared location...</p>
</div>
</div>
)
}
if (error) {
return (
<div className="location-viewer flex items-center justify-center min-h-[400px]">
<div className="text-center max-w-md">
<div className="text-4xl mb-4">📍</div>
<h2 className="text-xl font-semibold mb-2">Unable to Load Location</h2>
<p className="text-sm text-muted-foreground">{error}</p>
</div>
</div>
)
}
if (!location || !share) {
return null
}
return (
<div className="location-viewer max-w-4xl mx-auto p-6">
<div className="viewer-header mb-6">
<h1 className="text-3xl font-bold text-balance">Shared Location</h1>
<p className="text-sm text-muted-foreground mt-2">Someone has shared their location with you</p>
</div>
<div className="viewer-content space-y-6">
{/* Map Display */}
<LocationMap location={location} precision={share.precision} showAccuracy={true} height="500px" />
{/* Share Info */}
<div className="share-info bg-muted/50 rounded-lg p-4 space-y-2">
<div className="info-row flex justify-between text-sm">
<span className="text-muted-foreground">Precision Level:</span>
<span className="font-medium capitalize">{share.precision}</span>
</div>
<div className="info-row flex justify-between text-sm">
<span className="text-muted-foreground">Views:</span>
<span className="font-medium">
{share.viewCount} {share.maxViews ? `/ ${share.maxViews}` : ""}
</span>
</div>
{share.expiresAt && (
<div className="info-row flex justify-between text-sm">
<span className="text-muted-foreground">Expires:</span>
<span className="font-medium">{new Date(share.expiresAt).toLocaleString()}</span>
</div>
)}
<div className="info-row flex justify-between text-sm">
<span className="text-muted-foreground">Shared:</span>
<span className="font-medium">{new Date(share.createdAt).toLocaleString()}</span>
</div>
</div>
{/* Privacy Notice */}
<div className="privacy-notice bg-primary/5 border border-primary/20 rounded-lg p-4">
<p className="text-xs text-muted-foreground">
This location is shared securely and will expire based on the sender's privacy settings. The location data
is stored in a decentralized filesystem and is only accessible via this unique link.
</p>
</div>
</div>
</div>
)
}

View File

@ -1,279 +0,0 @@
"use client"
import React, { useState } from "react"
import { LocationCapture } from "./LocationCapture"
import { ShareSettingsComponent } from "./ShareSettings"
import { LocationMap } from "./LocationMap"
import type { LocationData, LocationShare } from "@/lib/location/locationStorage"
import { LocationStorageService, generateShareToken } from "@/lib/location/locationStorage"
import type { ShareSettings } from "@/lib/location/types"
import { useAuth } from "@/context/AuthContext"
export const ShareLocation: React.FC = () => {
const { session, fileSystem } = useAuth()
const [step, setStep] = useState<"capture" | "settings" | "share">("capture")
const [capturedLocation, setCapturedLocation] = useState<LocationData | null>(null)
const [shareSettings, setShareSettings] = useState<ShareSettings>({
duration: 24 * 3600000, // 24 hours
maxViews: null,
precision: "street",
})
const [shareLink, setShareLink] = useState<string | null>(null)
const [isCreatingShare, setIsCreatingShare] = useState(false)
const [error, setError] = useState<string | null>(null)
// Show loading state while auth is initializing
if (session.loading) {
return (
<div className="share-location-loading flex items-center justify-center min-h-[400px]">
<div className="text-center max-w-md">
<div className="text-4xl mb-4 animate-spin"></div>
<h2 className="text-xl font-semibold mb-2">Loading...</h2>
<p className="text-sm text-muted-foreground">Initializing authentication</p>
</div>
</div>
)
}
const handleLocationCaptured = (location: LocationData) => {
setCapturedLocation(location)
setStep("settings")
}
const handleCreateShare = async () => {
if (!capturedLocation || !fileSystem) {
setError("Location or filesystem not available")
return
}
setIsCreatingShare(true)
setError(null)
try {
const storageService = new LocationStorageService(fileSystem)
await storageService.initialize()
// Generate share token
const shareToken = generateShareToken()
// Calculate expiration
const expiresAt = shareSettings.duration ? Date.now() + shareSettings.duration : null
// Update location with expiration
const updatedLocation: LocationData = {
...capturedLocation,
expiresAt,
precision: shareSettings.precision,
}
await storageService.saveLocation(updatedLocation)
// Create share
const share: LocationShare = {
id: crypto.randomUUID(),
locationId: capturedLocation.id,
shareToken,
createdAt: Date.now(),
expiresAt,
maxViews: shareSettings.maxViews,
viewCount: 0,
precision: shareSettings.precision,
}
await storageService.createShare(share)
// Generate share link
const baseUrl = window.location.origin
const link = `${baseUrl}/location/${shareToken}`
setShareLink(link)
setStep("share")
} catch (err) {
console.error("Error creating share:", err)
setError("Failed to create share link")
} finally {
setIsCreatingShare(false)
}
}
const handleCopyLink = async () => {
if (!shareLink) return
try {
await navigator.clipboard.writeText(shareLink)
// Could add a toast notification here
alert("Link copied to clipboard!")
} catch (err) {
console.error("Failed to copy link:", err)
alert("Failed to copy link. Please copy manually.")
}
}
const handleReset = () => {
setStep("capture")
setCapturedLocation(null)
setShareLink(null)
setError(null)
}
if (!session.authed) {
return (
<div className="share-location-auth flex items-center justify-center min-h-[400px]">
<div className="text-center max-w-md">
<div className="text-4xl mb-4">🔒</div>
<h2 className="text-xl font-semibold mb-2">Authentication Required</h2>
<p className="text-sm text-muted-foreground">Please log in to share your location securely</p>
</div>
</div>
)
}
return (
<div className="share-location max-w-4xl mx-auto p-6">
{/* Progress Steps */}
<div className="progress-steps flex items-center justify-center gap-4 mb-8">
{["capture", "settings", "share"].map((s, index) => (
<React.Fragment key={s}>
<div className="step-item flex items-center gap-2">
<div
className={`step-number w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium transition-colors ${
step === s
? "bg-primary text-primary-foreground"
: index < ["capture", "settings", "share"].indexOf(step)
? "bg-primary/20 text-primary"
: "bg-muted text-muted-foreground"
}`}
>
{index + 1}
</div>
<span
className={`step-label text-sm font-medium capitalize ${
step === s ? "text-foreground" : "text-muted-foreground"
}`}
>
{s}
</span>
</div>
{index < 2 && (
<div
className={`step-connector h-0.5 w-12 ${
index < ["capture", "settings", "share"].indexOf(step) ? "bg-primary" : "bg-muted"
}`}
/>
)}
</React.Fragment>
))}
</div>
{/* Error Display */}
{error && (
<div className="error-message bg-destructive/10 border border-destructive/20 rounded-lg p-4 mb-6">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
{/* Step Content */}
<div className="step-content">
{step === "capture" && <LocationCapture onLocationCaptured={handleLocationCaptured} onError={setError} />}
{step === "settings" && capturedLocation && (
<div className="settings-step space-y-6">
<div className="location-preview">
<h3 className="text-lg font-semibold mb-4">Preview Your Location</h3>
<LocationMap
location={capturedLocation}
precision={shareSettings.precision}
showAccuracy={true}
height="300px"
/>
</div>
<ShareSettingsComponent onSettingsChange={setShareSettings} initialSettings={shareSettings} />
<div className="settings-actions flex gap-3">
<button
onClick={() => setStep("capture")}
className="flex-1 px-6 py-3 rounded-lg border border-border hover:bg-muted transition-colors"
>
Back
</button>
<button
onClick={handleCreateShare}
disabled={isCreatingShare}
className="flex-1 px-6 py-3 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
>
{isCreatingShare ? "Creating Share..." : "Create Share Link"}
</button>
</div>
</div>
)}
{step === "share" && shareLink && capturedLocation && (
<div className="share-step space-y-6">
<div className="share-success text-center mb-6">
<div className="text-5xl mb-4"></div>
<h2 className="text-2xl font-bold mb-2">Share Link Created!</h2>
<p className="text-sm text-muted-foreground">Your location is ready to share securely</p>
</div>
<div className="share-link-box bg-muted/50 rounded-lg p-4 border border-border">
<label className="block text-sm font-medium mb-2">Share Link</label>
<div className="flex gap-2">
<input
type="text"
value={shareLink}
readOnly
className="flex-1 px-3 py-2 rounded-lg border border-border bg-background text-sm"
onClick={(e) => e.currentTarget.select()}
/>
<button
onClick={handleCopyLink}
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors text-sm font-medium whitespace-nowrap"
>
Copy Link
</button>
</div>
</div>
<div className="share-preview">
<h3 className="text-lg font-semibold mb-4">Location Preview</h3>
<LocationMap
location={capturedLocation}
precision={shareSettings.precision}
showAccuracy={true}
height="300px"
/>
</div>
<div className="share-details bg-muted/50 rounded-lg p-4 space-y-2">
<h4 className="font-medium mb-3">Share Settings</h4>
<div className="detail-row flex justify-between text-sm">
<span className="text-muted-foreground">Precision:</span>
<span className="font-medium capitalize">{shareSettings.precision}</span>
</div>
<div className="detail-row flex justify-between text-sm">
<span className="text-muted-foreground">Duration:</span>
<span className="font-medium">
{shareSettings.duration ? `${shareSettings.duration / 3600000} hours` : "No expiration"}
</span>
</div>
<div className="detail-row flex justify-between text-sm">
<span className="text-muted-foreground">Max Views:</span>
<span className="font-medium">{shareSettings.maxViews || "Unlimited"}</span>
</div>
</div>
<button
onClick={handleReset}
className="w-full px-6 py-3 rounded-lg border border-border hover:bg-muted transition-colors"
>
Share Another Location
</button>
</div>
)}
</div>
</div>
)
}

View File

@ -1,150 +0,0 @@
"use client"
import React, { useState } from "react"
import type { ShareSettings, PrecisionLevel } from "@/lib/location/types"
interface ShareSettingsProps {
onSettingsChange: (settings: ShareSettings) => void
initialSettings?: Partial<ShareSettings>
}
export const ShareSettingsComponent: React.FC<ShareSettingsProps> = ({ onSettingsChange, initialSettings = {} }) => {
const [duration, setDuration] = useState<string>(
initialSettings.duration ? String(initialSettings.duration / 3600000) : "24",
)
const [maxViews, setMaxViews] = useState<string>(
initialSettings.maxViews ? String(initialSettings.maxViews) : "unlimited",
)
const [precision, setPrecision] = useState<PrecisionLevel>(initialSettings.precision || "street")
const handleChange = () => {
const settings: ShareSettings = {
duration: duration === "unlimited" ? null : Number(duration) * 3600000,
maxViews: maxViews === "unlimited" ? null : Number(maxViews),
precision,
}
onSettingsChange(settings)
}
React.useEffect(() => {
handleChange()
}, [duration, maxViews, precision])
return (
<div className="share-settings space-y-6">
<div className="settings-header">
<h3 className="text-lg font-semibold">Privacy Settings</h3>
<p className="text-sm text-muted-foreground mt-1">Control how your location is shared</p>
</div>
{/* Precision Level */}
<div className="setting-group">
<label className="block text-sm font-medium mb-3">Location Precision</label>
<div className="precision-options space-y-2">
{[
{ value: "exact", label: "Exact Location", desc: "Share precise coordinates" },
{ value: "street", label: "Street Level", desc: "~100m radius" },
{ value: "neighborhood", label: "Neighborhood", desc: "~1km radius" },
{ value: "city", label: "City Level", desc: "~10km radius" },
].map((option) => (
<label
key={option.value}
className={`precision-option flex items-start gap-3 p-3 rounded-lg border-2 cursor-pointer transition-colors ${
precision === option.value ? "border-primary bg-primary/5" : "border-border hover:border-primary/50"
}`}
>
<input
type="radio"
name="precision"
value={option.value}
checked={precision === option.value}
onChange={(e) => setPrecision(e.target.value as PrecisionLevel)}
className="mt-0.5"
/>
<div className="flex-1">
<div className="font-medium text-sm">{option.label}</div>
<div className="text-xs text-muted-foreground">{option.desc}</div>
</div>
</label>
))}
</div>
</div>
{/* Duration */}
<div className="setting-group">
<label htmlFor="duration" className="block text-sm font-medium mb-2">
Share Duration
</label>
<select
id="duration"
value={duration}
onChange={(e) => setDuration(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="1">1 hour</option>
<option value="6">6 hours</option>
<option value="24">24 hours</option>
<option value="168">1 week</option>
<option value="unlimited">No expiration</option>
</select>
</div>
{/* Max Views */}
<div className="setting-group">
<label htmlFor="maxViews" className="block text-sm font-medium mb-2">
Maximum Views
</label>
<select
id="maxViews"
value={maxViews}
onChange={(e) => setMaxViews(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="1">1 view</option>
<option value="5">5 views</option>
<option value="10">10 views</option>
<option value="unlimited">Unlimited</option>
</select>
</div>
{/* Privacy Notice */}
<div className="privacy-notice bg-muted/50 rounded-lg p-4">
<p className="text-xs text-muted-foreground">
Your location data is stored securely in your private filesystem. Only people with the share link can view
your location, and shares automatically expire based on your settings.
</p>
</div>
</div>
)
}

View File

@ -1,5 +1,4 @@
import React, { createContext, useContext, useState, useEffect, useCallback, useMemo, ReactNode } from 'react'; import React, { createContext, useContext, useState, useEffect, useCallback, useMemo, ReactNode } from 'react';
import type FileSystem from '@oddjs/odd/fs/index';
import { Session, SessionError } from '../lib/auth/types'; import { Session, SessionError } from '../lib/auth/types';
import { AuthService } from '../lib/auth/authService'; import { AuthService } from '../lib/auth/authService';
import { saveSession, clearStoredSession } from '../lib/auth/sessionPersistence'; import { saveSession, clearStoredSession } from '../lib/auth/sessionPersistence';
@ -9,8 +8,6 @@ interface AuthContextType {
setSession: (updatedSession: Partial<Session>) => void; setSession: (updatedSession: Partial<Session>) => void;
updateSession: (updatedSession: Partial<Session>) => void; updateSession: (updatedSession: Partial<Session>) => void;
clearSession: () => void; clearSession: () => void;
fileSystem: FileSystem | null;
setFileSystem: (fs: FileSystem | null) => void;
initialize: () => Promise<void>; initialize: () => Promise<void>;
login: (username: string) => Promise<boolean>; login: (username: string) => Promise<boolean>;
register: (username: string) => Promise<boolean>; register: (username: string) => Promise<boolean>;
@ -30,7 +27,6 @@ export const AuthContext = createContext<AuthContextType | undefined>(undefined)
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => { export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [session, setSessionState] = useState<Session>(initialSession); const [session, setSessionState] = useState<Session>(initialSession);
const [fileSystem, setFileSystemState] = useState<FileSystem | null>(null);
// Update session with partial data // Update session with partial data
const setSession = useCallback((updatedSession: Partial<Session>) => { const setSession = useCallback((updatedSession: Partial<Session>) => {
@ -46,11 +42,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
}); });
}, []); }, []);
// Set file system
const setFileSystem = useCallback((fs: FileSystem | null) => {
setFileSystemState(fs);
}, []);
/** /**
* Initialize the authentication state * Initialize the authentication state
*/ */
@ -58,9 +49,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
setSessionState(prev => ({ ...prev, loading: true })); setSessionState(prev => ({ ...prev, loading: true }));
try { try {
const { session: newSession, fileSystem: newFs } = await AuthService.initialize(); const { session: newSession } = await AuthService.initialize();
setSessionState(newSession); setSessionState(newSession);
setFileSystemState(newFs);
// Save session to localStorage if authenticated // Save session to localStorage if authenticated
if (newSession.authed && newSession.username) { if (newSession.authed && newSession.username) {
@ -86,9 +76,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
try { try {
const result = await AuthService.login(username); const result = await AuthService.login(username);
if (result.success && result.session && result.fileSystem) { if (result.success && result.session) {
setSessionState(result.session); setSessionState(result.session);
setFileSystemState(result.fileSystem);
// Save session to localStorage if authenticated // Save session to localStorage if authenticated
if (result.session.authed && result.session.username) { if (result.session.authed && result.session.username) {
@ -123,9 +112,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
try { try {
const result = await AuthService.register(username); const result = await AuthService.register(username);
if (result.success && result.session && result.fileSystem) { if (result.success && result.session) {
setSessionState(result.session); setSessionState(result.session);
setFileSystemState(result.fileSystem);
// Save session to localStorage if authenticated // Save session to localStorage if authenticated
if (result.session.authed && result.session.username) { if (result.session.authed && result.session.username) {
@ -164,7 +152,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
obsidianVaultPath: undefined, obsidianVaultPath: undefined,
obsidianVaultName: undefined obsidianVaultName: undefined
}); });
setFileSystemState(null);
}, []); }, []);
/** /**
@ -200,13 +187,11 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
setSession, setSession,
updateSession: setSession, updateSession: setSession,
clearSession, clearSession,
fileSystem,
setFileSystem,
initialize, initialize,
login, login,
register, register,
logout logout
}), [session, setSession, clearSession, fileSystem, setFileSystem, initialize, login, register, logout]); }), [session, setSession, clearSession, initialize, login, register, logout]);
return ( return (
<AuthContext.Provider value={contextValue}> <AuthContext.Provider value={contextValue}>

View File

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

View File

@ -1,10 +1,6 @@
import * as odd from '@oddjs/odd';
import type FileSystem from '@oddjs/odd/fs/index';
import { checkDataRoot, initializeFilesystem, isUsernameValid, isUsernameAvailable } from './account';
import { getBackupStatus } from './backup';
import { Session } from './types'; import { Session } from './types';
import { CryptoAuthService } from './cryptoAuthService'; import { CryptoAuthService } from './cryptoAuthService';
import { loadSession, saveSession, clearStoredSession, getStoredUsername } from './sessionPersistence'; import { loadSession, saveSession, clearStoredSession } from './sessionPersistence';
export class AuthService { export class AuthService {
/** /**
@ -12,35 +8,13 @@ export class AuthService {
*/ */
static async initialize(): Promise<{ static async initialize(): Promise<{
session: Session; session: Session;
fileSystem: FileSystem | null;
}> { }> {
// First try to load stored session // Try to load stored session
const storedSession = loadSession(); const storedSession = loadSession();
let session: Session; let session: Session;
let fileSystem: FileSystem | null = null;
if (storedSession && storedSession.authed && storedSession.username) { if (storedSession && storedSession.authed && storedSession.username) {
// Try to restore ODD session with stored username // Restore existing session
try {
const program = await odd.program({
namespace: { creator: 'mycrozine', name: 'app' },
username: storedSession.username
});
if (program.session) {
// ODD session restored successfully
fileSystem = program.session.fs;
const backupStatus = await getBackupStatus(fileSystem);
session = {
username: storedSession.username,
authed: true,
loading: false,
backupCreated: backupStatus.created,
obsidianVaultPath: storedSession.obsidianVaultPath,
obsidianVaultName: storedSession.obsidianVaultName
};
} else {
// ODD session not available, but we have crypto auth
session = { session = {
username: storedSession.username, username: storedSession.username,
authed: true, authed: true,
@ -49,35 +23,8 @@ export class AuthService {
obsidianVaultPath: storedSession.obsidianVaultPath, obsidianVaultPath: storedSession.obsidianVaultPath,
obsidianVaultName: storedSession.obsidianVaultName obsidianVaultName: storedSession.obsidianVaultName
}; };
}
} catch (oddError) {
// ODD session restoration failed, using stored session
session = {
username: storedSession.username,
authed: true,
loading: false,
backupCreated: storedSession.backupCreated,
obsidianVaultPath: storedSession.obsidianVaultPath,
obsidianVaultName: storedSession.obsidianVaultName
};
}
} else {
// No stored session, try ODD initialization
try {
const program = await odd.program({
namespace: { creator: 'mycrozine', name: 'app' }
});
if (program.session) {
fileSystem = program.session.fs;
const backupStatus = await getBackupStatus(fileSystem);
session = {
username: program.session.username,
authed: true,
loading: false,
backupCreated: backupStatus.created
};
} else { } else {
// No stored session
session = { session = {
username: '', username: '',
authed: false, authed: false,
@ -85,18 +32,8 @@ export class AuthService {
backupCreated: null backupCreated: null
}; };
} }
} catch (error) {
session = {
username: '',
authed: false,
loading: false,
backupCreated: null,
error: String(error)
};
}
}
return { session, fileSystem }; return { session };
} }
/** /**
@ -105,81 +42,25 @@ export class AuthService {
static async login(username: string): Promise<{ static async login(username: string): Promise<{
success: boolean; success: boolean;
session?: Session; session?: Session;
fileSystem?: FileSystem;
error?: string; error?: string;
}> { }> {
try { try {
// First try cryptographic authentication // Use cryptographic authentication
const cryptoResult = await CryptoAuthService.login(username); const cryptoResult = await CryptoAuthService.login(username);
if (cryptoResult.success && cryptoResult.session) { if (cryptoResult.success && cryptoResult.session) {
// If crypto auth succeeds, also try to load ODD session
try {
const program = await odd.program({
namespace: { creator: 'mycrozine', name: 'app' },
username
});
if (program.session) {
const fs = program.session.fs;
const backupStatus = await getBackupStatus(fs);
return {
success: true,
session: {
username,
authed: true,
loading: false,
backupCreated: backupStatus.created
},
fileSystem: fs
};
}
} catch (oddError) {
// ODD session not available, using crypto auth only
}
// Return crypto auth result if ODD is not available
const session = cryptoResult.session; const session = cryptoResult.session;
if (session) {
saveSession(session); saveSession(session);
}
return { return {
success: true, success: true,
session: cryptoResult.session, session: cryptoResult.session
fileSystem: undefined
}; };
} }
// Fallback to ODD authentication
const program = await odd.program({
namespace: { creator: 'mycrozine', name: 'app' },
username
});
if (program.session) {
const fs = program.session.fs;
const backupStatus = await getBackupStatus(fs);
const session = {
username,
authed: true,
loading: false,
backupCreated: backupStatus.created
};
saveSession(session);
return {
success: true,
session,
fileSystem: fs
};
} else {
return { return {
success: false, success: false,
error: cryptoResult.error || 'Failed to authenticate' error: cryptoResult.error || 'Failed to authenticate'
}; };
}
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
@ -194,99 +75,33 @@ export class AuthService {
static async register(username: string): Promise<{ static async register(username: string): Promise<{
success: boolean; success: boolean;
session?: Session; session?: Session;
fileSystem?: FileSystem;
error?: string; error?: string;
}> { }> {
try { try {
// Validate username // Validate username format (basic check)
const valid = await isUsernameValid(username); if (!username || username.length < 3) {
if (!valid) {
return { return {
success: false, success: false,
error: 'Invalid username format' error: 'Username must be at least 3 characters'
}; };
} }
// First try cryptographic registration // Use cryptographic registration
const cryptoResult = await CryptoAuthService.register(username); const cryptoResult = await CryptoAuthService.register(username);
if (cryptoResult.success && cryptoResult.session) { if (cryptoResult.success && cryptoResult.session) {
// If crypto registration succeeds, also try to create ODD session
try {
const program = await odd.program({
namespace: { creator: 'mycrozine', name: 'app' },
username
});
if (program.session) {
const fs = program.session.fs;
// Initialize filesystem with required directories
await initializeFilesystem(fs);
// Check backup status
const backupStatus = await getBackupStatus(fs);
return {
success: true,
session: {
username,
authed: true,
loading: false,
backupCreated: backupStatus.created
},
fileSystem: fs
};
}
} catch (oddError) {
// ODD session creation failed, using crypto auth only
}
// Return crypto registration result if ODD is not available
const session = cryptoResult.session; const session = cryptoResult.session;
if (session) {
saveSession(session);
}
return {
success: true,
session: cryptoResult.session,
fileSystem: undefined
};
}
// Fallback to ODD-only registration
const program = await odd.program({
namespace: { creator: 'mycrozine', name: 'app' },
username
});
if (program.session) {
const fs = program.session.fs;
// Initialize filesystem with required directories
await initializeFilesystem(fs);
// Check backup status
const backupStatus = await getBackupStatus(fs);
const session = {
username,
authed: true,
loading: false,
backupCreated: backupStatus.created
};
saveSession(session); saveSession(session);
return { return {
success: true, success: true,
session, session: cryptoResult.session
fileSystem: fs
}; };
} else { }
return { return {
success: false, success: false,
error: cryptoResult.error || 'Failed to create account' error: cryptoResult.error || 'Failed to create account'
}; };
}
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
@ -302,14 +117,6 @@ export class AuthService {
try { try {
// Clear stored session // Clear stored session
clearStoredSession(); clearStoredSession();
// Try to destroy ODD session
try {
await odd.session.destroy();
} catch (oddError) {
// ODD session destroy failed
}
return true; return true;
} catch (error) { } catch (error) {
return false; return false;

View File

@ -1,22 +0,0 @@
import * as odd from '@oddjs/odd'
export type BackupStatus = {
created: boolean | null
}
export const getBackupStatus = async (fs: odd.FileSystem): Promise<BackupStatus> => {
try {
// Check if the required methods exist
if ((fs as any).exists && odd.path && (odd.path as any).backups) {
const backupStatus = await (fs as any).exists((odd.path as any).backups());
return { created: backupStatus };
}
// Fallback if methods don't exist
console.warn('Backup methods not available in current ODD version');
return { created: null };
} catch (error) {
console.error('Error checking backup status:', error);
return { created: null };
}
}

View File

@ -1,58 +0,0 @@
import * as odd from '@oddjs/odd';
/**
* Creates an account linking consumer for the specified username
* @param username The username to create a consumer for
* @returns A Promise resolving to an AccountLinkingConsumer-like object
*/
export const createAccountLinkingConsumer = async (
username: string
): Promise<any> => {
// Check if the method exists in the current ODD version
if (odd.account && typeof (odd.account as any).createConsumer === 'function') {
return await (odd.account as any).createConsumer({ username });
}
// Fallback: create a mock consumer for development
console.warn('Account linking consumer not available in current ODD version, using mock implementation');
return {
on: (event: string, callback: Function) => {
// Mock event handling
if (event === 'challenge') {
// Simulate PIN challenge
setTimeout(() => callback({ pin: [1, 2, 3, 4] }), 1000);
} else if (event === 'link') {
// Simulate successful link
setTimeout(() => callback({ approved: true, username }), 2000);
}
},
destroy: () => {
// Cleanup mock consumer
}
};
};
/**
* Creates an account linking producer for the specified username
* @param username The username to create a producer for
* @returns A Promise resolving to an AccountLinkingProducer-like object
*/
export const createAccountLinkingProducer = async (
username: string
): Promise<any> => {
// Check if the method exists in the current ODD version
if (odd.account && typeof (odd.account as any).createProducer === 'function') {
return await (odd.account as any).createProducer({ username });
}
// Fallback: create a mock producer for development
console.warn('Account linking producer not available in current ODD version, using mock implementation');
return {
on: (_event: string, _callback: Function) => {
// Mock event handling - parameters unused in mock implementation
},
destroy: () => {
// Cleanup mock producer
}
};
};

View File

@ -1,302 +0,0 @@
import type FileSystem from '@oddjs/odd/fs/index';
import * as odd from '@oddjs/odd';
import type { PrecisionLevel } from './types';
/**
* Location data stored in the filesystem
*/
export interface LocationData {
id: string;
userId: string;
latitude: number;
longitude: number;
accuracy: number;
timestamp: number;
expiresAt: number | null;
precision: PrecisionLevel;
}
/**
* Location share metadata
*/
export interface LocationShare {
id: string;
locationId: string;
shareToken: string;
createdAt: number;
expiresAt: number | null;
maxViews: number | null;
viewCount: number;
precision: PrecisionLevel;
}
/**
* Location storage service
* Handles storing and retrieving locations from the ODD.js filesystem
*/
export class LocationStorageService {
private fs: FileSystem;
private locationsPath: string[];
private sharesPath: string[];
private publicSharesPath: string[];
constructor(fs: FileSystem) {
this.fs = fs;
// Private storage paths
this.locationsPath = ['private', 'locations'];
this.sharesPath = ['private', 'location-shares'];
// Public reference path for share validation
this.publicSharesPath = ['public', 'location-shares'];
}
/**
* Initialize directories
*/
async initialize(): Promise<void> {
// Ensure private directories exist
await this.ensureDirectory(this.locationsPath);
await this.ensureDirectory(this.sharesPath);
// Ensure public directory for share references
await this.ensureDirectory(this.publicSharesPath);
}
/**
* Ensure a directory exists
*/
private async ensureDirectory(path: string[]): Promise<void> {
try {
const dirPath = odd.path.directory(...path);
const fs = this.fs as any;
const exists = await fs.exists(dirPath);
if (!exists) {
await fs.mkdir(dirPath);
}
} catch (error) {
console.error('Error ensuring directory:', error);
throw error;
}
}
/**
* Save a location to the filesystem
*/
async saveLocation(location: LocationData): Promise<void> {
try {
const filePath = (odd.path as any).file(...this.locationsPath, `${location.id}.json`);
const content = new TextEncoder().encode(JSON.stringify(location, null, 2));
const fs = this.fs as any;
await fs.write(filePath, content);
await fs.publish();
} catch (error) {
console.error('Error saving location:', error);
throw error;
}
}
/**
* Get a location by ID
*/
async getLocation(locationId: string): Promise<LocationData | null> {
try {
const filePath = (odd.path as any).file(...this.locationsPath, `${locationId}.json`);
const fs = this.fs as any;
const exists = await fs.exists(filePath);
if (!exists) {
return null;
}
const content = await fs.read(filePath);
const text = new TextDecoder().decode(content as Uint8Array);
return JSON.parse(text) as LocationData;
} catch (error) {
console.error('Error reading location:', error);
return null;
}
}
/**
* Create a location share
*/
async createShare(share: LocationShare): Promise<void> {
try {
// Save share metadata in private directory
const sharePath = (odd.path as any).file(...this.sharesPath, `${share.id}.json`);
const shareContent = new TextEncoder().encode(JSON.stringify(share, null, 2));
const fs = this.fs as any;
await fs.write(sharePath, shareContent);
// Create public reference file for share validation (only token, not full data)
const publicSharePath = (odd.path as any).file(...this.publicSharesPath, `${share.shareToken}.json`);
const publicShareRef = {
shareToken: share.shareToken,
shareId: share.id,
createdAt: share.createdAt,
expiresAt: share.expiresAt,
};
const publicContent = new TextEncoder().encode(JSON.stringify(publicShareRef, null, 2));
await fs.write(publicSharePath, publicContent);
await fs.publish();
} catch (error) {
console.error('Error creating share:', error);
throw error;
}
}
/**
* Get a share by token
*/
async getShareByToken(shareToken: string): Promise<LocationShare | null> {
try {
// First check public reference
const publicSharePath = (odd.path as any).file(...this.publicSharesPath, `${shareToken}.json`);
const fs = this.fs as any;
const publicExists = await fs.exists(publicSharePath);
if (!publicExists) {
return null;
}
const publicContent = await fs.read(publicSharePath);
const publicText = new TextDecoder().decode(publicContent as Uint8Array);
const publicRef = JSON.parse(publicText);
// Now get full share from private directory
const sharePath = (odd.path as any).file(...this.sharesPath, `${publicRef.shareId}.json`);
const shareExists = await fs.exists(sharePath);
if (!shareExists) {
return null;
}
const shareContent = await fs.read(sharePath);
const shareText = new TextDecoder().decode(shareContent as Uint8Array);
return JSON.parse(shareText) as LocationShare;
} catch (error) {
console.error('Error reading share:', error);
return null;
}
}
/**
* Get all shares for the current user
*/
async getAllShares(): Promise<LocationShare[]> {
try {
const dirPath = odd.path.directory(...this.sharesPath);
const fs = this.fs as any;
const exists = await fs.exists(dirPath);
if (!exists) {
return [];
}
const files = await fs.ls(dirPath);
const shares: LocationShare[] = [];
for (const fileName of Object.keys(files)) {
if (fileName.endsWith('.json')) {
const shareId = fileName.replace('.json', '');
const share = await this.getShareById(shareId);
if (share) {
shares.push(share);
}
}
}
return shares;
} catch (error) {
console.error('Error listing shares:', error);
return [];
}
}
/**
* Get a share by ID
*/
private async getShareById(shareId: string): Promise<LocationShare | null> {
try {
const sharePath = (odd.path as any).file(...this.sharesPath, `${shareId}.json`);
const fs = this.fs as any;
const exists = await fs.exists(sharePath);
if (!exists) {
return null;
}
const content = await fs.read(sharePath);
const text = new TextDecoder().decode(content as Uint8Array);
return JSON.parse(text) as LocationShare;
} catch (error) {
console.error('Error reading share:', error);
return null;
}
}
/**
* Increment view count for a share
*/
async incrementShareViews(shareId: string): Promise<void> {
try {
const share = await this.getShareById(shareId);
if (!share) {
throw new Error('Share not found');
}
share.viewCount += 1;
await this.createShare(share); // Re-save the share
} catch (error) {
console.error('Error incrementing share views:', error);
throw error;
}
}
}
/**
* Obfuscate location based on precision level
*/
export function obfuscateLocation(
lat: number,
lng: number,
precision: PrecisionLevel
): { lat: number; lng: number; radius: number } {
let radius = 0;
switch (precision) {
case 'exact':
radius = 0;
break;
case 'street':
radius = 100; // ~100m radius
break;
case 'neighborhood':
radius = 1000; // ~1km radius
break;
case 'city':
radius = 10000; // ~10km radius
break;
}
if (radius === 0) {
return { lat, lng, radius: 0 };
}
// Add random offset within the radius
const angle = Math.random() * 2 * Math.PI;
const distance = Math.random() * radius;
// Convert distance to degrees (rough approximation: 1 degree ≈ 111km)
const latOffset = (distance / 111000) * Math.cos(angle);
const lngOffset = (distance / (111000 * Math.cos(lat * Math.PI / 180))) * Math.sin(angle);
return {
lat: lat + latOffset,
lng: lng + lngOffset,
radius,
};
}
/**
* Generate a secure share token
*/
export function generateShareToken(): string {
// Generate a cryptographically secure random token
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('');
}

View File

@ -1,55 +0,0 @@
/**
* Location sharing types
*/
export type PrecisionLevel = "exact" | "street" | "neighborhood" | "city";
export interface ShareSettings {
duration: number | null; // Duration in milliseconds
maxViews: number | null; // Maximum number of views allowed
precision: PrecisionLevel; // Precision level for location obfuscation
}
export interface GeolocationPosition {
coords: {
latitude: number;
longitude: number;
accuracy: number;
altitude?: number | null;
altitudeAccuracy?: number | null;
heading?: number | null;
speed?: number | null;
};
timestamp: number;
}

View File

@ -41,7 +41,7 @@ import { FathomMeetingsTool } from "@/tools/FathomMeetingsTool"
import { HolonBrowserShape } from "@/shapes/HolonBrowserShapeUtil" import { HolonBrowserShape } from "@/shapes/HolonBrowserShapeUtil"
import { ObsidianBrowserShape } from "@/shapes/ObsidianBrowserShapeUtil" import { ObsidianBrowserShape } from "@/shapes/ObsidianBrowserShapeUtil"
import { FathomMeetingsBrowserShape } from "@/shapes/FathomMeetingsBrowserShapeUtil" import { FathomMeetingsBrowserShape } from "@/shapes/FathomMeetingsBrowserShapeUtil"
import { LocationShareShape } from "@/shapes/LocationShareShapeUtil" // Location shape removed - no longer needed
import { import {
lockElement, lockElement,
unlockElement, unlockElement,
@ -81,7 +81,6 @@ const customShapeUtils = [
HolonBrowserShape, HolonBrowserShape,
ObsidianBrowserShape, ObsidianBrowserShape,
FathomMeetingsBrowserShape, FathomMeetingsBrowserShape,
LocationShareShape,
] ]
const customTools = [ const customTools = [
ChatBoxTool, ChatBoxTool,

View File

@ -1,37 +0,0 @@
import React from 'react';
import { LocationDashboard } from '@/components/location/LocationDashboard';
export const LocationDashboardRoute: React.FC = () => {
return <LocationDashboard />;
};

View File

@ -1,37 +0,0 @@
import React from 'react';
import { ShareLocation } from '@/components/location/ShareLocation';
export const LocationShareCreate: React.FC = () => {
return <ShareLocation />;
};

View File

@ -1,51 +0,0 @@
import React from 'react';
import { useParams } from 'react-router-dom';
import { LocationViewer } from '@/components/location/LocationViewer';
export const LocationShareView: React.FC = () => {
const { token } = useParams<{ token: string }>();
if (!token) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<h2 className="text-xl font-semibold mb-2">Invalid Share Link</h2>
<p className="text-sm text-muted-foreground">No share token provided in the URL</p>
</div>
</div>
);
}
return <LocationViewer shareToken={token} />;
};

View File

@ -1,61 +0,0 @@
import {
BaseBoxShapeUtil,
HTMLContainer,
TLBaseShape,
RecordProps,
T
} from "tldraw"
import { ShareLocation } from "@/components/location/ShareLocation"
export type ILocationShare = TLBaseShape<
"LocationShare",
{
w: number
h: number
}
>
export class LocationShareShape extends BaseBoxShapeUtil<ILocationShare> {
static override type = "LocationShare" as const
static override props: RecordProps<ILocationShare> = {
w: T.number,
h: T.number
}
getDefaultProps(): ILocationShare["props"] {
return {
w: 800,
h: 600
}
}
component(shape: ILocationShare) {
return (
<HTMLContainer
id={shape.id}
style={{
overflow: "auto",
pointerEvents: "all",
backgroundColor: "var(--color-panel)",
borderRadius: "8px",
border: "1px solid var(--color-panel-contrast)"
}}
>
<div
style={{
width: "100%",
height: "100%",
padding: "0"
}}
>
<ShareLocation />
</div>
</HTMLContainer>
)
}
indicator(shape: ILocationShare) {
return <rect width={shape.props.w} height={shape.props.h} />
}
}

36
src/types/odd.d.ts vendored
View File

@ -1,36 +0,0 @@
declare module '@oddjs/odd' {
export interface Program {
session?: Session;
}
export interface Session {
username: string;
fs: FileSystem;
}
export interface FileSystem {
mkdir(path: string): Promise<void>;
}
export const program: (options: { namespace: { creator: string; name: string }; username?: string }) => Promise<Program>;
export const session: {
destroy(): Promise<void>;
};
export const account: {
isUsernameValid(username: string): Promise<boolean>;
isUsernameAvailable(username: string): Promise<boolean>;
};
export const dataRoot: {
lookup(username: string): Promise<any>;
};
export const path: {
directory(...parts: string[]): string;
};
}
declare module '@oddjs/odd/fs/index' {
export interface FileSystem {
mkdir(path: string): Promise<void>;
}
export default FileSystem;
}

View File

@ -29,7 +29,7 @@ export function CustomMainMenu() {
const validateAndNormalizeShapeType = (shape: any): string => { const validateAndNormalizeShapeType = (shape: any): string => {
if (!shape || !shape.type) return 'text' if (!shape || !shape.type) return 'text'
const validCustomShapes = ['ObsNote', 'VideoChat', 'Transcription', 'Prompt', 'ChatBox', 'Embed', 'Markdown', 'MycrozineTemplate', 'Slide', 'Holon', 'ObsidianBrowser', 'HolonBrowser', 'FathomMeetingsBrowser', 'LocationShare'] const validCustomShapes = ['ObsNote', 'VideoChat', 'Transcription', 'Prompt', 'ChatBox', 'Embed', 'Markdown', 'MycrozineTemplate', 'Slide', 'Holon', 'ObsidianBrowser', 'HolonBrowser', 'FathomMeetingsBrowser']
const validDefaultShapes = ['arrow', 'bookmark', 'draw', 'embed', 'frame', 'geo', 'group', 'highlight', 'image', 'line', 'note', 'text', 'video'] const validDefaultShapes = ['arrow', 'bookmark', 'draw', 'embed', 'frame', 'geo', 'group', 'highlight', 'image', 'line', 'note', 'text', 'video']
const allValidShapes = [...validCustomShapes, ...validDefaultShapes] const allValidShapes = [...validCustomShapes, ...validDefaultShapes]

View File

@ -15,7 +15,6 @@ import { createShapeId } from "tldraw"
import type { ObsidianObsNote } from "../lib/obsidianImporter" import type { ObsidianObsNote } from "../lib/obsidianImporter"
import { HolonData } from "../lib/HoloSphereService" import { HolonData } from "../lib/HoloSphereService"
import { FathomMeetingsPanel } from "../components/FathomMeetingsPanel" import { FathomMeetingsPanel } from "../components/FathomMeetingsPanel"
import { LocationShareDialog } from "../components/location/LocationShareDialog"
import { getFathomApiKey, saveFathomApiKey, removeFathomApiKey, isFathomApiKeyConfigured } from "../lib/fathomApiKey" import { getFathomApiKey, saveFathomApiKey, removeFathomApiKey, isFathomApiKeyConfigured } from "../lib/fathomApiKey"
export function CustomToolbar() { export function CustomToolbar() {

View File

@ -32,7 +32,7 @@ export function resolveOverlaps(editor: Editor, shapeId: string): void {
const allShapes = editor.getCurrentPageShapes() const allShapes = editor.getCurrentPageShapes()
const customShapeTypes = [ const customShapeTypes = [
'ObsNote', 'ObsidianBrowser', 'HolonBrowser', 'VideoChat', 'ObsNote', 'ObsidianBrowser', 'HolonBrowser', 'VideoChat',
'Transcription', 'Holon', 'LocationShare', 'FathomMeetingsBrowser', 'Prompt', 'Transcription', 'Holon', 'FathomMeetingsBrowser', 'Prompt',
'Embed', 'Slide', 'Markdown', 'MycrozineTemplate', 'ChatBox' 'Embed', 'Slide', 'Markdown', 'MycrozineTemplate', 'ChatBox'
] ]
@ -119,7 +119,7 @@ export function findNonOverlappingPosition(
const allShapes = editor.getCurrentPageShapes() const allShapes = editor.getCurrentPageShapes()
const customShapeTypes = [ const customShapeTypes = [
'ObsNote', 'ObsidianBrowser', 'HolonBrowser', 'VideoChat', 'ObsNote', 'ObsidianBrowser', 'HolonBrowser', 'VideoChat',
'Transcription', 'Holon', 'LocationShare', 'FathomMeetingsBrowser', 'Prompt', 'Transcription', 'Holon', 'FathomMeetingsBrowser', 'Prompt',
'Embed', 'Slide', 'Markdown', 'MycrozineTemplate', 'ChatBox' 'Embed', 'Slide', 'Markdown', 'MycrozineTemplate', 'ChatBox'
] ]