feat(collab): E2E-compliant crew invitations and link-sharing collaboration
This commit is contained in:
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { db } from '../services/db.js'
|
||||
import { getActiveMasterKey } from '../services/auth.js'
|
||||
import { getLogbookKey } from '../services/logbookKeys.js'
|
||||
import { encryptJson, decryptJson } from '../services/crypto.js'
|
||||
import { syncLogbook } from '../services/sync.js'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
@@ -123,8 +124,8 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Master key not found. Please log in.')
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
const localCrews = await db.crews.where({ logbookId }).toArray()
|
||||
|
||||
@@ -168,8 +169,8 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
|
||||
setSkipperSuccess(false)
|
||||
|
||||
try {
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Master key not found. Please log in.')
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
const skipperData: CrewMemberData = {
|
||||
name: skipName.trim(),
|
||||
@@ -258,8 +259,8 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Master key not found. Please log in.')
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
const memberData: CrewMemberData = {
|
||||
name: memName.trim(),
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { db } from '../services/db.js'
|
||||
import { getActiveMasterKey } from '../services/auth.js'
|
||||
import { getLogbookKey } from '../services/logbookKeys.js'
|
||||
import { encryptJson, decryptJson } from '../services/crypto.js'
|
||||
import { syncLogbook } from '../services/sync.js'
|
||||
import { Compass, Save, Check } from 'lucide-react'
|
||||
@@ -28,8 +29,8 @@ export default function DeviationForm({ logbookId }: DeviationFormProps) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Master key not found. Please log in.')
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
const local = await db.deviations.get(logbookId)
|
||||
if (local) {
|
||||
@@ -75,8 +76,8 @@ export default function DeviationForm({ logbookId }: DeviationFormProps) {
|
||||
setSuccess(false)
|
||||
|
||||
try {
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Master key not found. Please log in.')
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
// Parse values, substituting 0 if empty
|
||||
const sanitizedDeviations: Record<number, number> = {}
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Ship, LogIn, UserPlus, AlertTriangle, ShieldCheck, Languages, ArrowRight } from 'lucide-react'
|
||||
import { getActiveMasterKey, registerUser, loginUser } from '../services/auth.js'
|
||||
import { decryptJson, encryptBuffer } from '../services/crypto.js'
|
||||
import { saveLogbookKey } from '../services/logbookKeys.js'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
|
||||
interface InvitationAcceptanceProps {
|
||||
onAccepted: (logbookId: string, title: string) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
// Convert Hex String back to ArrayBuffer
|
||||
const hexToBuffer = (hex: string): ArrayBuffer => {
|
||||
const bytes = new Uint8Array(hex.length / 2)
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16)
|
||||
}
|
||||
return bytes.buffer
|
||||
}
|
||||
|
||||
export default function InvitationAcceptance({ onAccepted, onCancel }: InvitationAcceptanceProps) {
|
||||
const { i18n } = useTranslation()
|
||||
const { showAlert } = useDialog()
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [accepting, setAccepting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Link parameters
|
||||
const [token, setToken] = useState('')
|
||||
const [logbookKey, setLogbookKey] = useState<ArrayBuffer | null>(null)
|
||||
|
||||
// Details loaded from server
|
||||
const [ownerUsername, setOwnerUsername] = useState('')
|
||||
const [decryptedTitle, setDecryptedTitle] = useState('')
|
||||
const [logbookId, setLogbookId] = useState('')
|
||||
|
||||
// Authentication states
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false)
|
||||
const [username, setUsername] = useState('')
|
||||
const [loginMode, setLoginMode] = useState<'options' | 'login' | 'register'>('options')
|
||||
const [regUsername, setRegUsername] = useState('')
|
||||
const [authError, setAuthError] = useState<string | null>(null)
|
||||
|
||||
// Check login state on mount
|
||||
useEffect(() => {
|
||||
const key = getActiveMasterKey()
|
||||
const savedUser = localStorage.getItem('active_username')
|
||||
if (key && savedUser) {
|
||||
setIsLoggedIn(true)
|
||||
setUsername(savedUser)
|
||||
}
|
||||
|
||||
// Extract parameters from URL
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const tokenVal = params.get('token') || ''
|
||||
setToken(tokenVal)
|
||||
|
||||
// Hash anchor (#key=xxx)
|
||||
const hash = window.location.hash
|
||||
if (hash.startsWith('#key=')) {
|
||||
const hexKey = hash.substring(5)
|
||||
try {
|
||||
const keyBuffer = hexToBuffer(hexKey)
|
||||
setLogbookKey(keyBuffer)
|
||||
} catch (err) {
|
||||
console.error('Invalid key in URL fragment:', err)
|
||||
setError('The invitation link is cryptographically invalid or corrupted (missing key).')
|
||||
}
|
||||
} else {
|
||||
setError('The invitation link is missing the necessary decryption key fragment (#key=...).')
|
||||
}
|
||||
|
||||
// Suggest a random guest skipper username
|
||||
const rand = Math.floor(1000 + Math.random() * 9000)
|
||||
setRegUsername(`CrewSkipper_${rand}`)
|
||||
}, [])
|
||||
|
||||
// Load invitation details once parameters are ready
|
||||
useEffect(() => {
|
||||
if (token && logbookKey) {
|
||||
loadDetails()
|
||||
}
|
||||
}, [token, logbookKey])
|
||||
|
||||
const loadDetails = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`/api/collaboration/invite-details?token=${token}`)
|
||||
|
||||
if (res.status === 410) {
|
||||
setError('This invitation link has expired (valid for 48 hours only).')
|
||||
return
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to verify invitation token.')
|
||||
}
|
||||
|
||||
const details = await res.json()
|
||||
setOwnerUsername(details.ownerUsername)
|
||||
setLogbookId(details.logbookId)
|
||||
|
||||
// Decrypt title client-side using URL key
|
||||
const parsed = JSON.parse(details.encryptedTitle)
|
||||
const title = await decryptJson(parsed.ciphertext, parsed.iv, parsed.tag, logbookKey!)
|
||||
setDecryptedTitle(title)
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load invitation details:', err)
|
||||
setError(err.message || 'Invitation details could not be retrieved from the server.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAccept = async () => {
|
||||
const masterKey = getActiveMasterKey()
|
||||
const activeUserId = localStorage.getItem('active_userid')
|
||||
if (!masterKey || !activeUserId || !logbookKey || !logbookId) return
|
||||
|
||||
setAccepting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// 1. Encrypt logbook key with user's master key
|
||||
const aesMasterKey = await window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
masterKey,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['encrypt']
|
||||
)
|
||||
const encrypted = await encryptBuffer(logbookKey, aesMasterKey)
|
||||
|
||||
// 2. Register collaboration on server
|
||||
const res = await fetch('/api/collaboration/accept', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-User-Id': activeUserId
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
encryptedLogbookKey: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag
|
||||
})
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const serverError = await res.json()
|
||||
throw new Error(serverError.error || 'Failed to join logbook on the server.')
|
||||
}
|
||||
|
||||
// 3. Save key locally in Dexie
|
||||
await saveLogbookKey(logbookId, logbookKey)
|
||||
|
||||
// 4. Redirect to workspace
|
||||
onAccepted(logbookId, decryptedTitle)
|
||||
} catch (err: any) {
|
||||
console.error('Accepting invitation failed:', err)
|
||||
setError(err.message || 'Acceptance failed.')
|
||||
} finally {
|
||||
setAccepting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setAuthError(null)
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const result = await loginUser()
|
||||
if (result.verified && result.prfSuccess) {
|
||||
setIsLoggedIn(true)
|
||||
setUsername(result.username || 'Skipper')
|
||||
} else if (result.verified) {
|
||||
// Biometrics succeeded but fallback phrase is needed
|
||||
setAuthError('Device doesn\'t support PRF key derivation. Traditional login is not supported in the invitation screen. Please log in normally on the main page first.')
|
||||
}
|
||||
} catch (err: any) {
|
||||
setAuthError(err.message || 'Passkey authentication failed.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!regUsername.trim()) return
|
||||
|
||||
setAuthError(null)
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const result = await registerUser(regUsername.trim())
|
||||
if (result.verified) {
|
||||
setIsLoggedIn(true)
|
||||
setUsername(regUsername.trim())
|
||||
showAlert(`Account created successfully! Your 12-word recovery phrase is: ${result.recoveryPhrase}. Write it down securely!`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
setAuthError(err.message || 'Registration failed.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleLanguage = () => {
|
||||
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
|
||||
i18n.changeLanguage(nextLang)
|
||||
}
|
||||
|
||||
if (loading && !accepting) {
|
||||
return (
|
||||
<div className="auth-card glass">
|
||||
<div className="auth-header">
|
||||
<Ship className="auth-icon accent spin" size={48} />
|
||||
<h2>{i18n.language.startsWith('de') ? 'Einladung wird geprüft...' : 'Checking Invitation...'}</h2>
|
||||
</div>
|
||||
<p className="recovery-warning">
|
||||
{i18n.language.startsWith('de') ? 'Lade Verschlüsselungsschlüssel und Verifizierungstoken...' : 'Retrieving credentials and secure key components...'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="auth-card glass">
|
||||
<div className="auth-header">
|
||||
<AlertTriangle className="auth-icon warn" size={48} />
|
||||
<h2>{i18n.language.startsWith('de') ? 'Einladungsfehler' : 'Invitation Error'}</h2>
|
||||
</div>
|
||||
<p className="recovery-warning" style={{ color: '#ef4444' }}>{error}</p>
|
||||
|
||||
<div className="auth-actions mt-6">
|
||||
<button className="btn primary" onClick={onCancel} style={{ width: '100%' }}>
|
||||
{i18n.language.startsWith('de') ? 'Zurück zum Start' : 'Back to Dashboard'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-card glass">
|
||||
<div className="auth-header">
|
||||
<ShieldCheck className="auth-icon success" size={48} style={{ color: '#10b981' }} />
|
||||
<h2>{i18n.language.startsWith('de') ? 'Logbuch-Einladung' : 'Logbook Invitation'}</h2>
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: 'center', margin: '20px 0', padding: '16px', background: 'rgba(255,255,255,0.03)', borderRadius: '12px' }}>
|
||||
<p style={{ margin: '0 0 8px 0', fontSize: '13px', color: '#64748b', textTransform: 'uppercase' }}>
|
||||
{i18n.language.startsWith('de') ? 'Einladung von' : 'INVITED BY'}
|
||||
</p>
|
||||
<p style={{ margin: '0 0 16px 0', fontSize: '18px', fontWeight: 600, color: '#f1f5f9' }}>
|
||||
Skipper {ownerUsername}
|
||||
</p>
|
||||
<p style={{ margin: '0 0 8px 0', fontSize: '13px', color: '#64748b', textTransform: 'uppercase' }}>
|
||||
{i18n.language.startsWith('de') ? 'Schiff / Logbuch' : 'VESSEL / LOGBOOK'}
|
||||
</p>
|
||||
<p style={{ margin: 0, fontSize: '20px', fontWeight: 700, color: '#fbbf24' }}>
|
||||
{decryptedTitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoggedIn ? (
|
||||
/* If logged in: Accept and Join immediately */
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', width: '100%' }}>
|
||||
<p style={{ fontSize: '14px', color: '#94a3b8', textAlign: 'center', lineHeight: '145%' }}>
|
||||
{i18n.language.startsWith('de')
|
||||
? `Sie sind angemeldet als ${username}. Möchten Sie diesem Logbuch als Crewmitglied beitreten?`
|
||||
: `You are logged in as ${username}. Would you like to join this logbook with write permissions?`
|
||||
}
|
||||
</p>
|
||||
|
||||
<div className="auth-actions mt-4" style={{ display: 'flex', gap: '12px' }}>
|
||||
<button className="btn secondary" onClick={onCancel} disabled={accepting} style={{ flex: 1 }}>
|
||||
{i18n.language.startsWith('de') ? 'Abbrechen' : 'Cancel'}
|
||||
</button>
|
||||
<button className="btn primary" onClick={handleAccept} disabled={accepting} style={{ flex: 2 }}>
|
||||
{accepting ? (i18n.language.startsWith('de') ? 'Beitritt...' : 'Joining...') : (i18n.language.startsWith('de') ? 'Beitreten' : 'Accept & Join')}
|
||||
<ArrowRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* If not logged in: Ask to authenticate or register */
|
||||
<div style={{ width: '100%' }}>
|
||||
{loginMode === 'options' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<p style={{ fontSize: '13.5px', color: '#94a3b8', textAlign: 'center', lineHeight: '145%' }}>
|
||||
{i18n.language.startsWith('de')
|
||||
? 'Sie müssen ein Passkey-Konto besitzen oder erstellen, um E2E-verschlüsselte Einträge zu schreiben.'
|
||||
: 'You must authenticate or register an E2E-secured crew account to write entries.'
|
||||
}
|
||||
</p>
|
||||
|
||||
<button className="btn primary" onClick={handleLogin} style={{ width: '100%', padding: '14px' }}>
|
||||
<LogIn size={16} />
|
||||
{i18n.language.startsWith('de') ? 'Mit Passkey anmelden' : 'Log In with Passkey'}
|
||||
</button>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', margin: '8px 0' }}>
|
||||
<div style={{ flex: 1, height: '1px', background: 'rgba(255,255,255,0.08)' }}></div>
|
||||
<span style={{ padding: '0 10px', fontSize: '12px', color: '#64748b' }}>
|
||||
{i18n.language.startsWith('de') ? 'ODER NEU REGISTRIEREN' : 'OR SIGN UP'}
|
||||
</span>
|
||||
<div style={{ flex: 1, height: '1px', background: 'rgba(255,255,255,0.08)' }}></div>
|
||||
</div>
|
||||
|
||||
<button className="btn secondary" onClick={() => setLoginMode('register')} style={{ width: '100%' }}>
|
||||
<UserPlus size={16} />
|
||||
{i18n.language.startsWith('de') ? 'Neues Crew-Konto erstellen' : 'Register New Crew Account'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loginMode === 'register' && (
|
||||
<form onSubmit={handleRegister} style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<div className="input-group">
|
||||
<label style={{ display: 'block', fontSize: '13px', color: '#94a3b8', marginBottom: '6px' }}>
|
||||
{i18n.language.startsWith('de') ? 'Skipper- / Benutzername' : 'Skipper / User Name'}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
placeholder="e.g. Max Mustermann"
|
||||
value={regUsername}
|
||||
onChange={(e) => setRegUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="auth-actions">
|
||||
<button type="button" className="btn secondary" onClick={() => setLoginMode('options')}>
|
||||
{i18n.language.startsWith('de') ? 'Zurück' : 'Back'}
|
||||
</button>
|
||||
<button type="submit" className="btn primary" disabled={!regUsername.trim()}>
|
||||
{i18n.language.startsWith('de') ? 'Passkey erstellen & beitreten' : 'Create Passkey & Join'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{authError && (
|
||||
<div className="auth-error mt-4" style={{ fontSize: '13px' }}>
|
||||
{authError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="auth-footer" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '16px', marginTop: '24px' }}>
|
||||
<button className="btn-icon-text" onClick={toggleLanguage}>
|
||||
<Languages size={18} />
|
||||
{i18n.language.startsWith('de') ? 'English' : 'Deutsch'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { db } from '../services/db.js'
|
||||
import { getActiveMasterKey } from '../services/auth.js'
|
||||
import { getLogbookKey } from '../services/logbookKeys.js'
|
||||
import { decryptJson, encryptJson } from '../services/crypto.js'
|
||||
import { syncLogbook } from '../services/sync.js'
|
||||
import { downloadCsv, shareCsv } from '../services/csvExport.js'
|
||||
@@ -42,8 +43,8 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Master key not found. Please log in.')
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
const local = await db.entries.where({ logbookId }).toArray()
|
||||
|
||||
@@ -131,8 +132,8 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Master key not found. Please log in.')
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
const localId = window.crypto.randomUUID()
|
||||
const nowStr = new Date().toISOString()
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { db } from '../services/db.js'
|
||||
import { getActiveMasterKey } from '../services/auth.js'
|
||||
import { getLogbookKey } from '../services/logbookKeys.js'
|
||||
import { encryptJson, decryptJson } from '../services/crypto.js'
|
||||
import { syncLogbook } from '../services/sync.js'
|
||||
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
||||
@@ -132,7 +133,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
||||
useEffect(() => {
|
||||
async function loadYachtSails() {
|
||||
try {
|
||||
const masterKey = getActiveMasterKey()
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) return
|
||||
|
||||
const yacht = await db.yachts.get(logbookId)
|
||||
@@ -155,8 +156,8 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Master key not found. Please log in.')
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
const local = await db.entries.get(entryId)
|
||||
if (local) {
|
||||
@@ -592,8 +593,8 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
||||
setSuccess(false)
|
||||
|
||||
try {
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Master key not found. Please log in.')
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
const entryData = {
|
||||
date,
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { db } from '../services/db.js'
|
||||
import { getActiveMasterKey } from '../services/auth.js'
|
||||
import { getLogbookKey } from '../services/logbookKeys.js'
|
||||
import { encryptJson, decryptJson } from '../services/crypto.js'
|
||||
import { syncLogbook } from '../services/sync.js'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
@@ -41,7 +42,7 @@ export default function PhotoCapture({ entryId, logbookId }: PhotoCaptureProps)
|
||||
async function decryptPhotosList() {
|
||||
if (!localPhotos) return
|
||||
|
||||
const masterKey = getActiveMasterKey()
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) return
|
||||
|
||||
const list: DecryptedPhoto[] = []
|
||||
@@ -102,8 +103,8 @@ export default function PhotoCapture({ entryId, logbookId }: PhotoCaptureProps)
|
||||
const compressedBase64 = canvas.toDataURL('image/jpeg', 0.7)
|
||||
|
||||
// Encrypt
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Master key not found. Please log in.')
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
const photoId = window.crypto.randomUUID()
|
||||
const photoPayload = {
|
||||
|
||||
@@ -1,14 +1,156 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Settings, Save, Check } from 'lucide-react'
|
||||
import { Settings as SettingsIcon, Save, Check, Users, Trash2, Copy, Link as LinkIcon } from 'lucide-react'
|
||||
import { ensureLogbookKey } from '../services/logbookKeys.js'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
|
||||
export default function SettingsForm() {
|
||||
interface SettingsFormProps {
|
||||
logbookId?: string | null
|
||||
}
|
||||
|
||||
interface Collaborator {
|
||||
id: string
|
||||
userId: string
|
||||
username: string
|
||||
role: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// Convert ArrayBuffer to Hex String for URL fragment
|
||||
const bufferToHex = (buffer: ArrayBuffer): string => {
|
||||
return Array.from(new Uint8Array(buffer))
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
}
|
||||
|
||||
export default function SettingsForm({ logbookId }: SettingsFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const { showConfirm, showAlert } = useDialog()
|
||||
const [apiKey, setApiKey] = useState(localStorage.getItem('owm_api_key') || '')
|
||||
const [theme, setTheme] = useState(localStorage.getItem('active_theme') || 'auto')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
// Collaboration States
|
||||
const [collaborators, setCollaborators] = useState<Collaborator[]>([])
|
||||
const [isOwner, setIsOwner] = useState(false)
|
||||
const [inviteLink, setInviteLink] = useState('')
|
||||
const [inviteCopied, setInviteCopied] = useState(false)
|
||||
const [generatingInvite, setGeneratingInvite] = useState(false)
|
||||
const [collabError, setCollabError] = useState<string | null>(null)
|
||||
const [loadingCollabs, setLoadingCollabs] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (logbookId) {
|
||||
loadCollaborators()
|
||||
}
|
||||
}, [logbookId])
|
||||
|
||||
const loadCollaborators = async () => {
|
||||
setLoadingCollabs(true)
|
||||
setCollabError(null)
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/collaboration/collaborators?logbookId=${logbookId}`, {
|
||||
headers: {
|
||||
'X-User-Id': userId
|
||||
}
|
||||
})
|
||||
|
||||
if (res.status === 403) {
|
||||
setIsOwner(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (res.ok) {
|
||||
setIsOwner(true)
|
||||
const data = await res.json()
|
||||
setCollaborators(data)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load collaborators:', err)
|
||||
setCollabError('Failed to load collaborator list.')
|
||||
} finally {
|
||||
setLoadingCollabs(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerateInvite = async () => {
|
||||
if (!logbookId) return
|
||||
setGeneratingInvite(true)
|
||||
setInviteLink('')
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId) return
|
||||
|
||||
try {
|
||||
// 1. Ensure logbook has an E2E key (upgrades legacy logbooks if needed)
|
||||
const logbookKey = await ensureLogbookKey(logbookId)
|
||||
|
||||
// 2. Create invite token on server
|
||||
const res = await fetch('/api/collaboration/invite', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-User-Id': userId
|
||||
},
|
||||
body: JSON.stringify({ logbookId, role: 'WRITE' })
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to create invitation on the server.')
|
||||
}
|
||||
|
||||
const invite = await res.json()
|
||||
|
||||
// 3. Format link containing token (URL params) and key (URL hash anchor)
|
||||
const hexKey = bufferToHex(logbookKey)
|
||||
const link = `${window.location.origin}/invite?token=${invite.token}#key=${hexKey}`
|
||||
|
||||
setInviteLink(link)
|
||||
} catch (err: any) {
|
||||
console.error('Failed to generate invite:', err)
|
||||
showAlert(err.message || 'Failed to generate invite link.')
|
||||
} finally {
|
||||
setGeneratingInvite(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyInvite = () => {
|
||||
if (inviteLink) {
|
||||
navigator.clipboard.writeText(inviteLink)
|
||||
setInviteCopied(true)
|
||||
setTimeout(() => setInviteCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRevoke = async (collabId: string, collName: string) => {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId) return
|
||||
|
||||
if (await showConfirm(t('logs.revoke_confirm'), collName, t('logs.confirm_yes'), t('logs.confirm_no'))) {
|
||||
try {
|
||||
const res = await fetch(`/api/collaboration/collaborators/${collabId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-User-Id': userId
|
||||
}
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setCollaborators(prev => prev.filter(c => c.id !== collabId))
|
||||
showAlert('Crew member access revoked successfully.')
|
||||
} else {
|
||||
throw new Error('Failed to revoke collaborator access.')
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Revocation failed:', err)
|
||||
showAlert(err.message || 'Failed to revoke access.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
@@ -29,7 +171,7 @@ export default function SettingsForm() {
|
||||
return (
|
||||
<div className="form-card">
|
||||
<div className="form-header">
|
||||
<Settings size={24} className="form-icon" />
|
||||
<SettingsIcon size={24} className="form-icon" />
|
||||
<div>
|
||||
<h2>{t('settings.title')}</h2>
|
||||
<p className="form-subtitle" style={{ margin: '4px 0 0 0', fontSize: '13px', color: '#94a3b8' }}>
|
||||
@@ -90,7 +232,7 @@ export default function SettingsForm() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-actions mt-4">
|
||||
<div className="form-actions mt-4 mb-6">
|
||||
{success && (
|
||||
<div className="success-toast">
|
||||
<Check size={16} />
|
||||
@@ -104,6 +246,101 @@ export default function SettingsForm() {
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Crew Collaboration Card (Only visible to Logbook Owner) */}
|
||||
{logbookId && isOwner && (
|
||||
<div className="member-editor-card glass mt-6" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '24px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
|
||||
<Users size={20} style={{ color: '#fbbf24' }} />
|
||||
<h3 style={{ margin: 0, color: '#fbbf24', fontSize: '16px' }}>
|
||||
{t('logs.invite_crew')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: '13.5px', color: '#94a3b8', lineHeight: '145%', margin: '0 0 16px 0' }}>
|
||||
{t('logs.invite_link_desc')}
|
||||
</p>
|
||||
|
||||
<div className="form-actions" style={{ justifyContent: 'flex-start', gap: '12px', marginBottom: '20px' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
onClick={handleGenerateInvite}
|
||||
disabled={generatingInvite}
|
||||
>
|
||||
<LinkIcon size={16} />
|
||||
{generatingInvite ? 'Generating...' : t('logs.invite_crew')}
|
||||
</button>
|
||||
<span style={{ fontSize: '12px', color: '#64748b' }}>{t('logs.invite_expires')}</span>
|
||||
</div>
|
||||
|
||||
{inviteLink && (
|
||||
<div className="input-group mb-6" style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={inviteLink}
|
||||
className="input-text font-mono text-xs"
|
||||
style={{ flex: 1, padding: '10px' }}
|
||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={handleCopyInvite}
|
||||
style={{ width: 'auto', padding: '10px' }}
|
||||
>
|
||||
{inviteCopied ? <Check size={16} /> : <Copy size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collaborator List */}
|
||||
<h4 style={{ color: '#fbbf24', fontSize: '14px', marginBottom: '12px' }}>
|
||||
{t('logs.collaborators_list')}
|
||||
</h4>
|
||||
|
||||
{loadingCollabs ? (
|
||||
<div style={{ fontSize: '13px', color: '#64748b' }}>Loading crew members...</div>
|
||||
) : collabError ? (
|
||||
<div className="auth-error">{collabError}</div>
|
||||
) : collaborators.length === 0 ? (
|
||||
<div style={{ fontSize: '13.5px', color: '#64748b', fontStyle: 'italic' }}>No active crew members.</div>
|
||||
) : (
|
||||
<div className="table-responsive">
|
||||
<table className="event-table" style={{ width: '100%' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>{t('logs.invite_role')}</th>
|
||||
<th>Joined</th>
|
||||
<th style={{ width: '60px' }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{collaborators.map((c) => (
|
||||
<tr key={c.id}>
|
||||
<td style={{ fontWeight: 500 }}>{c.username}</td>
|
||||
<td>{c.role}</td>
|
||||
<td>{new Date(c.createdAt).toLocaleDateString()}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon logout"
|
||||
onClick={() => handleRevoke(c.id, c.username)}
|
||||
title="Revoke access"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { db } from '../services/db.js'
|
||||
import { getActiveMasterKey } from '../services/auth.js'
|
||||
import { getLogbookKey } from '../services/logbookKeys.js'
|
||||
import { encryptJson, decryptJson } from '../services/crypto.js'
|
||||
import { syncLogbook } from '../services/sync.js'
|
||||
import { Ship, Save, Check, Plus, X, Camera, Trash2 } from 'lucide-react'
|
||||
@@ -38,8 +39,8 @@ export default function VesselForm({ logbookId }: VesselFormProps) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Master key not found. Please log in.')
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
const local = await db.yachts.get(logbookId)
|
||||
if (local) {
|
||||
@@ -147,8 +148,8 @@ export default function VesselForm({ logbookId }: VesselFormProps) {
|
||||
setSuccess(false)
|
||||
|
||||
try {
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Master key not found. Please log in.')
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
const yachtData = {
|
||||
name: name.trim(),
|
||||
|
||||
Reference in New Issue
Block a user