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:
parent
e1f4e83383
commit
e96e6480fe
|
|
@ -4,7 +4,7 @@ This document describes the complete WebCryptoAPI authentication system implemen
|
|||
|
||||
## 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
|
||||
|
||||
|
|
@ -23,13 +23,14 @@ The WebCryptoAPI authentication system provides cryptographic authentication usi
|
|||
- User registration and login
|
||||
- Credential verification
|
||||
|
||||
3. **Enhanced AuthService** (`src/lib/auth/authService.ts`)
|
||||
- Integrates crypto authentication with ODD
|
||||
- Fallback mechanisms
|
||||
3. **AuthService** (`src/lib/auth/authService.ts`)
|
||||
- Simplified authentication service
|
||||
- Session management
|
||||
- Integration with CryptoAuthService
|
||||
|
||||
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
|
||||
|
||||
## Features
|
||||
|
|
@ -41,7 +42,6 @@ The WebCryptoAPI authentication system provides cryptographic authentication usi
|
|||
- **Public Key Infrastructure**: Store and verify public keys
|
||||
- **Browser Support Detection**: Checks for WebCryptoAPI availability
|
||||
- **Secure Context Validation**: Ensures HTTPS requirement
|
||||
- **Fallback Authentication**: Works with existing ODD system
|
||||
- **Modern UI**: Responsive design with dark mode support
|
||||
- **Comprehensive Testing**: Test component for verification
|
||||
|
||||
|
|
@ -98,26 +98,26 @@ const isSecure = window.isSecureContext;
|
|||
1. **Secure Context Requirement**: Only works over HTTPS
|
||||
2. **ECDSA P-256**: Industry-standard elliptic curve
|
||||
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
|
||||
6. **Error Handling**: Comprehensive error management
|
||||
|
||||
### ⚠️ Security Notes
|
||||
|
||||
1. **Private Key Storage**: Currently simplified for demo purposes
|
||||
- In production, use Web Crypto API's key storage
|
||||
1. **Private Key Storage**: Currently uses localStorage for demo purposes
|
||||
- In production, consider using Web Crypto API's non-extractable keys
|
||||
- Consider hardware security modules (HSM)
|
||||
- Implement proper key derivation
|
||||
|
||||
2. **Session Management**:
|
||||
- Integrates with existing ODD session system
|
||||
- Consider implementing JWT tokens
|
||||
- Add session expiration
|
||||
- Uses localStorage for session persistence
|
||||
- Consider implementing JWT tokens for server-side verification
|
||||
- Add session expiration and refresh logic
|
||||
|
||||
3. **Network Security**:
|
||||
- All crypto operations happen client-side
|
||||
- No private keys transmitted over network
|
||||
- Consider adding server-side verification
|
||||
- Consider adding server-side signature verification
|
||||
|
||||
## Usage
|
||||
|
||||
|
|
@ -146,11 +146,22 @@ import { useAuth } from './context/AuthContext';
|
|||
|
||||
const { login, register } = useAuth();
|
||||
|
||||
// The AuthService automatically tries crypto auth first,
|
||||
// then falls back to ODD authentication
|
||||
// AuthService automatically uses crypto auth
|
||||
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
|
||||
|
||||
```typescript
|
||||
|
|
@ -168,14 +179,18 @@ src/
|
|||
│ ├── auth/
|
||||
│ │ ├── crypto.ts # WebCryptoAPI wrapper
|
||||
│ │ ├── cryptoAuthService.ts # High-level auth service
|
||||
│ │ ├── authService.ts # Enhanced auth service
|
||||
│ │ └── account.ts # User account management
|
||||
│ │ ├── authService.ts # Simplified auth service
|
||||
│ │ ├── sessionPersistence.ts # Session storage utilities
|
||||
│ │ └── types.ts # TypeScript types
|
||||
│ └── utils/
|
||||
│ └── browser.ts # Browser support detection
|
||||
├── components/
|
||||
│ └── auth/
|
||||
│ ├── CryptoLogin.tsx # Crypto auth UI
|
||||
│ ├── CryptID.tsx # Main crypto auth UI
|
||||
│ ├── CryptoDebug.tsx # Debug component
|
||||
│ └── CryptoTest.tsx # Test component
|
||||
├── context/
|
||||
│ └── AuthContext.tsx # React context for auth state
|
||||
└── css/
|
||||
└── crypto-auth.css # Styles for crypto components
|
||||
```
|
||||
|
|
@ -184,13 +199,20 @@ src/
|
|||
|
||||
### Required Packages
|
||||
- `one-webcrypto`: WebCryptoAPI polyfill (^1.0.3)
|
||||
- `@oddjs/odd`: Open Data Directory framework (^0.37.2)
|
||||
|
||||
### Browser APIs Used
|
||||
- `window.crypto.subtle`: WebCryptoAPI
|
||||
- `window.localStorage`: Key storage
|
||||
- `window.localStorage`: Key and session storage
|
||||
- `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
|
||||
|
||||
### Manual Testing
|
||||
|
|
@ -208,6 +230,7 @@ src/
|
|||
- [x] User registration
|
||||
- [x] User login
|
||||
- [x] Credential verification
|
||||
- [x] Session persistence
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
|
@ -228,13 +251,13 @@ src/
|
|||
- Try refreshing the page
|
||||
|
||||
4. **"Authentication failed"**
|
||||
- Verify user exists
|
||||
- Verify user exists in localStorage
|
||||
- Check stored credentials
|
||||
- Clear browser data and retry
|
||||
|
||||
### Debug Mode
|
||||
|
||||
Enable debug logging by setting:
|
||||
Enable debug logging by opening the browser console:
|
||||
```typescript
|
||||
localStorage.setItem('debug_crypto', 'true');
|
||||
```
|
||||
|
|
@ -242,7 +265,7 @@ localStorage.setItem('debug_crypto', 'true');
|
|||
## Future Enhancements
|
||||
|
||||
### 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
|
||||
3. **Multi-Factor Authentication**: Add additional authentication factors
|
||||
4. **Key Rotation**: Implement automatic key rotation
|
||||
|
|
@ -254,6 +277,15 @@ localStorage.setItem('debug_crypto', 'true');
|
|||
3. **Post-Quantum Cryptography**: Prepare for quantum threats
|
||||
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
|
||||
|
||||
When contributing to the WebCryptoAPI authentication system:
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -28,7 +28,6 @@
|
|||
"@chengsokdara/use-whisper": "^0.2.0",
|
||||
"@daily-co/daily-js": "^0.60.0",
|
||||
"@daily-co/daily-react": "^0.20.0",
|
||||
"@oddjs/odd": "^0.37.2",
|
||||
"@tldraw/assets": "^3.15.4",
|
||||
"@tldraw/tldraw": "^3.15.4",
|
||||
"@tldraw/tlschema": "^3.15.4",
|
||||
|
|
|
|||
20
src/App.tsx
20
src/App.tsx
|
|
@ -16,11 +16,7 @@ import "@/css/auth.css"; // Import auth styles
|
|||
import "@/css/crypto-auth.css"; // Import crypto auth styles
|
||||
import "@/css/starred-boards.css"; // Import starred boards styles
|
||||
import "@/css/user-profile.css"; // Import user profile styles
|
||||
import "@/css/location.css"; // Import location sharing styles
|
||||
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 React Context providers
|
||||
|
|
@ -149,22 +145,6 @@ const AppWithProviders = () => {
|
|||
<Resilience />
|
||||
</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>
|
||||
</BrowserRouter>
|
||||
</DailyProvider>
|
||||
|
|
|
|||
|
|
@ -593,7 +593,7 @@ export function sanitizeRecord(record: any): TLRecord {
|
|||
'holon': 'Holon',
|
||||
'obsidianBrowser': 'ObsidianBrowser',
|
||||
'fathomMeetingsBrowser': 'FathomMeetingsBrowser',
|
||||
'locationShare': 'LocationShare',
|
||||
// locationShare removed
|
||||
'imageGen': 'ImageGen',
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -47,6 +47,6 @@ To switch from TLdraw sync to Automerge sync:
|
|||
|
||||
1. Update the Board component to use `useAutomergeSync`
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ import { FathomNoteShape } from "@/shapes/FathomNoteShapeUtil"
|
|||
import { HolonShape } from "@/shapes/HolonShapeUtil"
|
||||
import { ObsidianBrowserShape } from "@/shapes/ObsidianBrowserShapeUtil"
|
||||
import { FathomMeetingsBrowserShape } from "@/shapes/FathomMeetingsBrowserShapeUtil"
|
||||
import { LocationShareShape } from "@/shapes/LocationShareShapeUtil"
|
||||
// Location shape removed - no longer needed
|
||||
|
||||
export function useAutomergeStoreV2({
|
||||
handle,
|
||||
|
|
@ -173,7 +173,6 @@ export function useAutomergeStoreV2({
|
|||
HolonShape,
|
||||
ObsidianBrowserShape,
|
||||
FathomMeetingsBrowserShape,
|
||||
LocationShareShape,
|
||||
],
|
||||
})
|
||||
return store
|
||||
|
|
|
|||
|
|
@ -146,15 +146,10 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
let handle: DocHandle<TLStoreSnapshot>
|
||||
|
||||
if (documentId) {
|
||||
// Try to find the existing document
|
||||
const foundHandle = await repo.find<TLStoreSnapshot>(documentId as any)
|
||||
if (!foundHandle) {
|
||||
console.log(`📝 Document ${documentId} not in local repo, creating handle`)
|
||||
handle = repo.create<TLStoreSnapshot>()
|
||||
} else {
|
||||
console.log(`✅ Found existing document in local repo: ${documentId}`)
|
||||
handle = foundHandle
|
||||
}
|
||||
// Find the existing document (will sync from network if not available locally)
|
||||
console.log(`🔍 Finding document ${documentId} (will sync from network if needed)`)
|
||||
handle = await repo.find<TLStoreSnapshot>(documentId as any)
|
||||
console.log(`✅ Got handle for document: ${documentId}`)
|
||||
} else {
|
||||
// Create a new document and register its ID with the server
|
||||
handle = repo.create<TLStoreSnapshot>()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -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: '© <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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
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 { AuthService } from '../lib/auth/authService';
|
||||
import { saveSession, clearStoredSession } from '../lib/auth/sessionPersistence';
|
||||
|
|
@ -9,8 +8,6 @@ interface AuthContextType {
|
|||
setSession: (updatedSession: Partial<Session>) => void;
|
||||
updateSession: (updatedSession: Partial<Session>) => void;
|
||||
clearSession: () => void;
|
||||
fileSystem: FileSystem | null;
|
||||
setFileSystem: (fs: FileSystem | null) => void;
|
||||
initialize: () => Promise<void>;
|
||||
login: (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 }) => {
|
||||
const [session, setSessionState] = useState<Session>(initialSession);
|
||||
const [fileSystem, setFileSystemState] = useState<FileSystem | null>(null);
|
||||
|
||||
// Update session with partial data
|
||||
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
|
||||
*/
|
||||
|
|
@ -58,9 +49,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||
setSessionState(prev => ({ ...prev, loading: true }));
|
||||
|
||||
try {
|
||||
const { session: newSession, fileSystem: newFs } = await AuthService.initialize();
|
||||
const { session: newSession } = await AuthService.initialize();
|
||||
setSessionState(newSession);
|
||||
setFileSystemState(newFs);
|
||||
|
||||
// Save session to localStorage if authenticated
|
||||
if (newSession.authed && newSession.username) {
|
||||
|
|
@ -86,9 +76,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||
try {
|
||||
const result = await AuthService.login(username);
|
||||
|
||||
if (result.success && result.session && result.fileSystem) {
|
||||
if (result.success && result.session) {
|
||||
setSessionState(result.session);
|
||||
setFileSystemState(result.fileSystem);
|
||||
|
||||
// Save session to localStorage if authenticated
|
||||
if (result.session.authed && result.session.username) {
|
||||
|
|
@ -123,9 +112,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||
try {
|
||||
const result = await AuthService.register(username);
|
||||
|
||||
if (result.success && result.session && result.fileSystem) {
|
||||
if (result.success && result.session) {
|
||||
setSessionState(result.session);
|
||||
setFileSystemState(result.fileSystem);
|
||||
|
||||
// Save session to localStorage if authenticated
|
||||
if (result.session.authed && result.session.username) {
|
||||
|
|
@ -164,7 +152,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||
obsidianVaultPath: undefined,
|
||||
obsidianVaultName: undefined
|
||||
});
|
||||
setFileSystemState(null);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
|
|
@ -200,13 +187,11 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||
setSession,
|
||||
updateSession: setSession,
|
||||
clearSession,
|
||||
fileSystem,
|
||||
setFileSystem,
|
||||
initialize,
|
||||
login,
|
||||
register,
|
||||
logout
|
||||
}), [session, setSession, clearSession, fileSystem, setFileSystem, initialize, login, register, logout]);
|
||||
}), [session, setSession, clearSession, initialize, login, register, logout]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={contextValue}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
@ -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 { CryptoAuthService } from './cryptoAuthService';
|
||||
import { loadSession, saveSession, clearStoredSession, getStoredUsername } from './sessionPersistence';
|
||||
import { loadSession, saveSession, clearStoredSession } from './sessionPersistence';
|
||||
|
||||
export class AuthService {
|
||||
/**
|
||||
|
|
@ -12,35 +8,13 @@ export class AuthService {
|
|||
*/
|
||||
static async initialize(): Promise<{
|
||||
session: Session;
|
||||
fileSystem: FileSystem | null;
|
||||
}> {
|
||||
// First try to load stored session
|
||||
// Try to load stored session
|
||||
const storedSession = loadSession();
|
||||
let session: Session;
|
||||
let fileSystem: FileSystem | null = null;
|
||||
|
||||
if (storedSession && storedSession.authed && storedSession.username) {
|
||||
// Try to restore ODD session with stored username
|
||||
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
|
||||
// Restore existing session
|
||||
session = {
|
||||
username: storedSession.username,
|
||||
authed: true,
|
||||
|
|
@ -49,35 +23,8 @@ export class AuthService {
|
|||
obsidianVaultPath: storedSession.obsidianVaultPath,
|
||||
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 {
|
||||
// No stored session
|
||||
session = {
|
||||
username: '',
|
||||
authed: false,
|
||||
|
|
@ -85,18 +32,8 @@ export class AuthService {
|
|||
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<{
|
||||
success: boolean;
|
||||
session?: Session;
|
||||
fileSystem?: FileSystem;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// First try cryptographic authentication
|
||||
// Use cryptographic authentication
|
||||
const cryptoResult = await CryptoAuthService.login(username);
|
||||
|
||||
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;
|
||||
if (session) {
|
||||
saveSession(session);
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
session: cryptoResult.session,
|
||||
fileSystem: undefined
|
||||
session: cryptoResult.session
|
||||
};
|
||||
}
|
||||
|
||||
// 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 {
|
||||
success: false,
|
||||
error: cryptoResult.error || 'Failed to authenticate'
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
|
|
@ -194,99 +75,33 @@ export class AuthService {
|
|||
static async register(username: string): Promise<{
|
||||
success: boolean;
|
||||
session?: Session;
|
||||
fileSystem?: FileSystem;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// Validate username
|
||||
const valid = await isUsernameValid(username);
|
||||
if (!valid) {
|
||||
// Validate username format (basic check)
|
||||
if (!username || username.length < 3) {
|
||||
return {
|
||||
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);
|
||||
|
||||
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;
|
||||
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);
|
||||
return {
|
||||
success: true,
|
||||
session,
|
||||
fileSystem: fs
|
||||
session: cryptoResult.session
|
||||
};
|
||||
} else {
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: cryptoResult.error || 'Failed to create account'
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
|
|
@ -302,14 +117,6 @@ export class AuthService {
|
|||
try {
|
||||
// Clear stored session
|
||||
clearStoredSession();
|
||||
|
||||
// Try to destroy ODD session
|
||||
try {
|
||||
await odd.session.destroy();
|
||||
} catch (oddError) {
|
||||
// ODD session destroy failed
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
@ -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('');
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -41,7 +41,7 @@ import { FathomMeetingsTool } from "@/tools/FathomMeetingsTool"
|
|||
import { HolonBrowserShape } from "@/shapes/HolonBrowserShapeUtil"
|
||||
import { ObsidianBrowserShape } from "@/shapes/ObsidianBrowserShapeUtil"
|
||||
import { FathomMeetingsBrowserShape } from "@/shapes/FathomMeetingsBrowserShapeUtil"
|
||||
import { LocationShareShape } from "@/shapes/LocationShareShapeUtil"
|
||||
// Location shape removed - no longer needed
|
||||
import {
|
||||
lockElement,
|
||||
unlockElement,
|
||||
|
|
@ -81,7 +81,6 @@ const customShapeUtils = [
|
|||
HolonBrowserShape,
|
||||
ObsidianBrowserShape,
|
||||
FathomMeetingsBrowserShape,
|
||||
LocationShareShape,
|
||||
]
|
||||
const customTools = [
|
||||
ChatBoxTool,
|
||||
|
|
|
|||
|
|
@ -1,37 +0,0 @@
|
|||
import React from 'react';
|
||||
import { LocationDashboard } from '@/components/location/LocationDashboard';
|
||||
|
||||
export const LocationDashboardRoute: React.FC = () => {
|
||||
return <LocationDashboard />;
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import React from 'react';
|
||||
import { ShareLocation } from '@/components/location/ShareLocation';
|
||||
|
||||
export const LocationShareCreate: React.FC = () => {
|
||||
return <ShareLocation />;
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -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} />;
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -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} />
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -29,7 +29,7 @@ export function CustomMainMenu() {
|
|||
const validateAndNormalizeShapeType = (shape: any): string => {
|
||||
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 allValidShapes = [...validCustomShapes, ...validDefaultShapes]
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import { createShapeId } from "tldraw"
|
|||
import type { ObsidianObsNote } from "../lib/obsidianImporter"
|
||||
import { HolonData } from "../lib/HoloSphereService"
|
||||
import { FathomMeetingsPanel } from "../components/FathomMeetingsPanel"
|
||||
import { LocationShareDialog } from "../components/location/LocationShareDialog"
|
||||
import { getFathomApiKey, saveFathomApiKey, removeFathomApiKey, isFathomApiKeyConfigured } from "../lib/fathomApiKey"
|
||||
|
||||
export function CustomToolbar() {
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export function resolveOverlaps(editor: Editor, shapeId: string): void {
|
|||
const allShapes = editor.getCurrentPageShapes()
|
||||
const customShapeTypes = [
|
||||
'ObsNote', 'ObsidianBrowser', 'HolonBrowser', 'VideoChat',
|
||||
'Transcription', 'Holon', 'LocationShare', 'FathomMeetingsBrowser', 'Prompt',
|
||||
'Transcription', 'Holon', 'FathomMeetingsBrowser', 'Prompt',
|
||||
'Embed', 'Slide', 'Markdown', 'MycrozineTemplate', 'ChatBox'
|
||||
]
|
||||
|
||||
|
|
@ -119,7 +119,7 @@ export function findNonOverlappingPosition(
|
|||
const allShapes = editor.getCurrentPageShapes()
|
||||
const customShapeTypes = [
|
||||
'ObsNote', 'ObsidianBrowser', 'HolonBrowser', 'VideoChat',
|
||||
'Transcription', 'Holon', 'LocationShare', 'FathomMeetingsBrowser', 'Prompt',
|
||||
'Transcription', 'Holon', 'FathomMeetingsBrowser', 'Prompt',
|
||||
'Embed', 'Slide', 'Markdown', 'MycrozineTemplate', 'ChatBox'
|
||||
]
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue