fix: Einladungsflow für geteilte Logbücher reparieren

Eingeladene Nutzer konnten nach Registrierung/Login kein Logbuch öffnen, weil der Beitritt nicht abgeschlossen wurde und der Collaboration-Schlüssel falsch importiert wurde.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-29 16:54:22 +02:00
co-authored by Cursor
parent f87f5e382d
commit 241b2fdf63
5 changed files with 234 additions and 110 deletions
+197 -97
View File
@@ -1,18 +1,23 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect, useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Ship, LogIn, UserPlus, AlertTriangle, ShieldCheck, Languages, ArrowRight } from 'lucide-react' import { Ship, LogIn, UserPlus, AlertTriangle, ShieldCheck, Languages, ArrowRight, KeyRound } from 'lucide-react'
import { getActiveMasterKey, registerUser, loginUser } from '../services/auth.js' import {
getActiveMasterKey,
registerUser,
loginUser,
completeLoginWithRecovery,
getKnownUsernames
} from '../services/auth.js'
import { decryptJson, encryptBuffer } from '../services/crypto.js' import { decryptJson, encryptBuffer } from '../services/crypto.js'
import { saveLogbookKey } from '../services/logbookKeys.js' import { saveLogbookKey } from '../services/logbookKeys.js'
import { syncLogbook } from '../services/sync.js'
import { db } from '../services/db.js' import { db } from '../services/db.js'
import { useDialog } from './ModalDialog.tsx'
interface InvitationAcceptanceProps { interface InvitationAcceptanceProps {
onAccepted: (logbookId: string, title: string) => void onAccepted: (logbookId: string, title: string) => void
onCancel: () => void onCancel: () => void
} }
// Convert Hex String back to ArrayBuffer
const hexToBuffer = (hex: string): ArrayBuffer => { const hexToBuffer = (hex: string): ArrayBuffer => {
const bytes = new Uint8Array(hex.length / 2) const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < bytes.length; i++) { for (let i = 0; i < bytes.length; i++) {
@@ -22,65 +27,73 @@ const hexToBuffer = (hex: string): ArrayBuffer => {
} }
export default function InvitationAcceptance({ onAccepted, onCancel }: InvitationAcceptanceProps) { export default function InvitationAcceptance({ onAccepted, onCancel }: InvitationAcceptanceProps) {
const { i18n } = useTranslation() const { t, i18n } = useTranslation()
const { showAlert } = useDialog()
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [accepting, setAccepting] = useState(false) const [accepting, setAccepting] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
// Link parameters
const [token, setToken] = useState('') const [token, setToken] = useState('')
const [logbookKey, setLogbookKey] = useState<ArrayBuffer | null>(null) const [logbookKey, setLogbookKey] = useState<ArrayBuffer | null>(null)
// Details loaded from server
const [ownerUsername, setOwnerUsername] = useState('') const [ownerUsername, setOwnerUsername] = useState('')
const [decryptedTitle, setDecryptedTitle] = useState('') const [decryptedTitle, setDecryptedTitle] = useState('')
const [logbookId, setLogbookId] = useState('') const [logbookId, setLogbookId] = useState('')
const [rawEncryptedTitle, setRawEncryptedTitle] = useState('') const [rawEncryptedTitle, setRawEncryptedTitle] = useState('')
// Authentication states
const [isLoggedIn, setIsLoggedIn] = useState(false) const [isLoggedIn, setIsLoggedIn] = useState(false)
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [loginMode, setLoginMode] = useState<'options' | 'login' | 'register'>('options') const [loginMode, setLoginMode] = useState<'options' | 'register'>('options')
const [regUsername, setRegUsername] = useState('') const [regUsername, setRegUsername] = useState('')
const [authError, setAuthError] = useState<string | null>(null) const [authError, setAuthError] = useState<string | null>(null)
// Check login state on mount const [recoveryPhrase, setRecoveryPhrase] = useState<string | null>(null)
const [showRecoveryFallback, setShowRecoveryFallback] = useState(false)
const [recoveryInput, setRecoveryInput] = useState('')
const [encryptedPayloads, setEncryptedPayloads] = useState<any>(null)
const autoAcceptStarted = useRef(false)
const isDe = i18n.language.startsWith('de')
const sessionReady = (): boolean => {
return !!(getActiveMasterKey() && localStorage.getItem('active_userid'))
}
useEffect(() => { useEffect(() => {
const key = getActiveMasterKey() const key = getActiveMasterKey()
const savedUser = localStorage.getItem('active_username') const savedUser = localStorage.getItem('active_username')
if (key && savedUser) { const savedUserId = localStorage.getItem('active_userid')
if (key && savedUser && savedUserId) {
setIsLoggedIn(true) setIsLoggedIn(true)
setUsername(savedUser) setUsername(savedUser)
} }
// Extract parameters from URL
const params = new URLSearchParams(window.location.search) const params = new URLSearchParams(window.location.search)
const tokenVal = params.get('token') || '' const tokenVal = params.get('token') || ''
setToken(tokenVal) setToken(tokenVal)
// Hash anchor (#key=xxx)
const hash = window.location.hash const hash = window.location.hash
if (hash.startsWith('#key=')) { if (hash.startsWith('#key=')) {
const hexKey = hash.substring(5) const hexKey = hash.substring(5)
try { try {
const keyBuffer = hexToBuffer(hexKey) setLogbookKey(hexToBuffer(hexKey))
setLogbookKey(keyBuffer)
} catch (err) { } catch (err) {
console.error('Invalid key in URL fragment:', err) console.error('Invalid key in URL fragment:', err)
setError('The invitation link is cryptographically invalid or corrupted (missing key).') setError(isDe
? 'Der Einladungslink ist kryptografisch ungültig (Schlüssel fehlerhaft).'
: 'The invitation link is cryptographically invalid (corrupted key).')
} }
} else { } else {
setError('The invitation link is missing the necessary decryption key fragment (#key=...).') setError(isDe
? 'Der Einladungslink enthält keinen Entschlüsselungsschlüssel (#key=...). Bitte den vollständigen Link vom Eigner verwenden.'
: 'The invitation link is missing the decryption key (#key=...). Please use the complete link from the owner.')
} }
// Suggest a random guest skipper username
const rand = Math.floor(1000 + Math.random() * 9000) const rand = Math.floor(1000 + Math.random() * 9000)
setRegUsername(`CrewSkipper_${rand}`) setRegUsername(`CrewSkipper_${rand}`)
}, []) }, [isDe])
// Load invitation details once parameters are ready
useEffect(() => { useEffect(() => {
if (token && logbookKey) { if (token && logbookKey) {
loadDetails() loadDetails()
@@ -94,42 +107,48 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
const res = await fetch(`/api/collaboration/invite-details?token=${token}`) const res = await fetch(`/api/collaboration/invite-details?token=${token}`)
if (res.status === 410) { if (res.status === 410) {
setError('This invitation link has expired (valid for 48 hours only).') setError(isDe
? 'Diese Einladung ist abgelaufen (48 Stunden gültig).'
: 'This invitation link has expired (valid for 48 hours only).')
return return
} }
if (!res.ok) { if (!res.ok) {
throw new Error('Failed to verify invitation token.') throw new Error(isDe ? 'Einladungstoken ungültig.' : 'Failed to verify invitation token.')
} }
const details = await res.json() const details = await res.json()
setOwnerUsername(details.ownerUsername) setOwnerUsername(details.ownerUsername)
setLogbookId(details.logbookId) setLogbookId(details.logbookId)
setRawEncryptedTitle(details.encryptedTitle) setRawEncryptedTitle(details.encryptedTitle)
// Decrypt title client-side using URL key
const parsed = JSON.parse(details.encryptedTitle) const parsed = JSON.parse(details.encryptedTitle)
const title = await decryptJson(parsed.ciphertext, parsed.iv, parsed.tag, logbookKey!) const title = await decryptJson(parsed.ciphertext, parsed.iv, parsed.tag, logbookKey!)
setDecryptedTitle(title) setDecryptedTitle(title)
} catch (err: any) { } catch (err: any) {
console.error('Failed to load invitation details:', err) console.error('Failed to load invitation details:', err)
setError(err.message || 'Invitation details could not be retrieved from the server.') setError(err.message || (isDe ? 'Einladungsdetails konnten nicht geladen werden.' : 'Invitation details could not be retrieved.'))
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }
const handleAccept = async () => { const handleAccept = useCallback(async () => {
const masterKey = getActiveMasterKey() const masterKey = getActiveMasterKey()
const activeUserId = localStorage.getItem('active_userid') const activeUserId = localStorage.getItem('active_userid')
if (!masterKey || !activeUserId || !logbookKey || !logbookId) return if (!masterKey || !activeUserId) {
setError(isDe
? 'Sitzung unvollständig — bitte erneut anmelden (Benutzer-ID fehlt).'
: 'Incomplete session — please log in again (user ID missing).')
setIsLoggedIn(false)
return
}
if (!logbookKey || !logbookId) return
setAccepting(true) setAccepting(true)
setError(null) setError(null)
try { try {
// 1. Encrypt logbook key with user's master key
const aesMasterKey = await window.crypto.subtle.importKey( const aesMasterKey = await window.crypto.subtle.importKey(
'raw', 'raw',
masterKey, masterKey,
@@ -139,7 +158,6 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
) )
const encrypted = await encryptBuffer(logbookKey, aesMasterKey) const encrypted = await encryptBuffer(logbookKey, aesMasterKey)
// 2. Register collaboration on server
const res = await fetch('/api/collaboration/accept', { const res = await fetch('/api/collaboration/accept', {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -155,14 +173,12 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
}) })
if (!res.ok) { if (!res.ok) {
const serverError = await res.json() const serverError = await res.json().catch(() => ({}))
throw new Error(serverError.error || 'Failed to join logbook on the server.') throw new Error(serverError.error || (isDe ? 'Beitritt auf dem Server fehlgeschlagen.' : 'Failed to join logbook on the server.'))
} }
// 3. Save key locally in Dexie
await saveLogbookKey(logbookId, logbookKey) await saveLogbookKey(logbookId, logbookKey)
// 3b. Save logbook index locally in Dexie so sync is triggered immediately
if (rawEncryptedTitle) { if (rawEncryptedTitle) {
await db.logbooks.put({ await db.logbooks.put({
id: logbookId, id: logbookId,
@@ -172,32 +188,72 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
}) })
} }
// 4. Redirect to workspace await syncLogbook(logbookId)
onAccepted(logbookId, decryptedTitle) onAccepted(logbookId, decryptedTitle)
} catch (err: any) { } catch (err: any) {
console.error('Accepting invitation failed:', err) console.error('Accepting invitation failed:', err)
setError(err.message || 'Acceptance failed.') setError(err.message || (isDe ? 'Beitritt fehlgeschlagen.' : 'Acceptance failed.'))
autoAcceptStarted.current = false
} finally { } finally {
setAccepting(false) setAccepting(false)
} }
} }, [logbookId, logbookKey, token, rawEncryptedTitle, decryptedTitle, onAccepted, isDe])
const handleLogin = async (e: React.FormEvent) => { useEffect(() => {
e.preventDefault() if (loading || accepting || autoAcceptStarted.current) return
if (!isLoggedIn || !logbookId || !logbookKey || !token) return
if (!sessionReady()) return
autoAcceptStarted.current = true
void handleAccept()
}, [isLoggedIn, logbookId, logbookKey, token, loading, accepting, handleAccept])
const handleLogin = async () => {
setAuthError(null) setAuthError(null)
setLoading(true) setLoading(true)
try { try {
const result = await loginUser() const remembered = getKnownUsernames()
if (result.verified && result.prfSuccess) { const target = remembered.length === 1 ? remembered[0] : undefined
const result = await loginUser(target)
if (!result.verified) return
if (result.prfSuccess) {
setIsLoggedIn(true) setIsLoggedIn(true)
setUsername(result.username || 'Skipper') setUsername(result.username || 'Skipper')
} else if (result.verified) { return
// 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.')
setEncryptedPayloads(result.encryptedPayloads)
const resolvedUser = result.username || result.encryptedPayloads?.username || ''
if (resolvedUser) setUsername(resolvedUser)
setShowRecoveryFallback(true)
} catch (err: any) {
setAuthError(err.message || (isDe ? 'Passkey-Anmeldung fehlgeschlagen.' : 'Passkey authentication failed.'))
} finally {
setLoading(false)
}
}
const handleRecoverySubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!recoveryInput.trim() || !encryptedPayloads) return
setLoading(true)
setAuthError(null)
try {
const resolvedUser = username.trim() || encryptedPayloads.username
const success = await completeLoginWithRecovery(resolvedUser, recoveryInput.trim(), encryptedPayloads)
if (success) {
setShowRecoveryFallback(false)
setIsLoggedIn(true)
setUsername(resolvedUser)
} else {
setAuthError(t('auth.error_incorrect_recovery'))
} }
} catch (err: any) { } catch (err: any) {
setAuthError(err.message || 'Passkey authentication failed.') setAuthError(err.message || t('auth.error_decryption_failed'))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -213,31 +269,92 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
try { try {
const result = await registerUser(regUsername.trim()) const result = await registerUser(regUsername.trim())
if (result.verified) { if (result.verified) {
setIsLoggedIn(true)
setUsername(regUsername.trim()) setUsername(regUsername.trim())
showAlert(`Account created successfully! Your 12-word recovery phrase is: ${result.recoveryPhrase}. Write it down securely!`) setRecoveryPhrase(result.recoveryPhrase)
} }
} catch (err: any) { } catch (err: any) {
setAuthError(err.message || 'Registration failed.') setAuthError(err.message || (isDe ? 'Registrierung fehlgeschlagen.' : 'Registration failed.'))
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }
const toggleLanguage = () => { const handleConfirmRecovery = () => {
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de' setRecoveryPhrase(null)
i18n.changeLanguage(nextLang) setIsLoggedIn(true)
} }
if (loading && !accepting) { const toggleLanguage = () => {
i18n.changeLanguage(i18n.language.startsWith('de') ? 'en' : 'de')
}
if (recoveryPhrase) {
return (
<div className="auth-card glass">
<div className="auth-header">
<KeyRound className="auth-icon accent" size={48} />
<h2>{t('auth.recovery_title')}</h2>
</div>
<p className="recovery-warning">{t('auth.recovery_warning')}</p>
<div className="recovery-phrase-grid">
{recoveryPhrase.split(' ').map((word, idx) => (
<div key={idx} className="recovery-word">
<span className="word-index">{idx + 1}</span>
{word}
</div>
))}
</div>
<div className="auth-actions mt-6">
<button className="btn primary" onClick={handleConfirmRecovery} style={{ width: '100%' }}>
{t('auth.confirm_recovery')}
</button>
</div>
</div>
)
}
if (showRecoveryFallback) {
return (
<div className="auth-card glass">
<div className="auth-header">
<KeyRound className="auth-icon accent" size={48} />
<h2>{t('auth.enter_recovery')}</h2>
</div>
<p className="recovery-warning">{t('auth.recovery_fallback_warning')}</p>
<form onSubmit={handleRecoverySubmit}>
<textarea
className="input-text"
placeholder={t('auth.recovery_placeholder')}
value={recoveryInput}
onChange={(e) => setRecoveryInput(e.target.value)}
rows={3}
required
/>
<div className="auth-actions mt-4">
<button type="button" className="btn secondary" onClick={() => setShowRecoveryFallback(false)}>
{isDe ? 'Zurück' : 'Back'}
</button>
<button type="submit" className="btn primary" disabled={loading}>
{t('auth.decrypt_logbook')}
</button>
</div>
</form>
{authError && <div className="auth-error mt-4">{authError}</div>}
</div>
)
}
if ((loading || accepting) && !error) {
return ( return (
<div className="auth-card glass"> <div className="auth-card glass">
<div className="auth-header"> <div className="auth-header">
<Ship className="auth-icon accent spin" size={48} /> <Ship className="auth-icon accent spin" size={48} />
<h2>{i18n.language.startsWith('de') ? 'Einladung wird geprüft...' : 'Checking Invitation...'}</h2> <h2>{accepting ? (isDe ? 'Beitritt...' : 'Joining...') : (isDe ? 'Einladung wird geprüft...' : 'Checking Invitation...')}</h2>
</div> </div>
<p className="recovery-warning"> <p className="recovery-warning">
{i18n.language.startsWith('de') ? 'Lade Verschlüsselungsschlüssel und Verifizierungstoken...' : 'Retrieving credentials and secure key components...'} {accepting
? (isDe ? 'Logbuch wird freigeschaltet und synchronisiert...' : 'Unlocking logbook and syncing data...')
: (isDe ? 'Lade Verschlüsselungsschlüssel...' : 'Retrieving encryption key...')}
</p> </p>
</div> </div>
) )
@@ -248,13 +365,12 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
<div className="auth-card glass"> <div className="auth-card glass">
<div className="auth-header"> <div className="auth-header">
<AlertTriangle className="auth-icon warn" size={48} /> <AlertTriangle className="auth-icon warn" size={48} />
<h2>{i18n.language.startsWith('de') ? 'Einladungsfehler' : 'Invitation Error'}</h2> <h2>{isDe ? 'Einladungsfehler' : 'Invitation Error'}</h2>
</div> </div>
<p className="recovery-warning" style={{ color: '#ef4444' }}>{error}</p> <p className="recovery-warning" style={{ color: '#ef4444' }}>{error}</p>
<div className="auth-actions mt-6"> <div className="auth-actions mt-6">
<button className="btn primary" onClick={onCancel} style={{ width: '100%' }}> <button className="btn primary" onClick={onCancel} style={{ width: '100%' }}>
{i18n.language.startsWith('de') ? 'Zurück zum Start' : 'Back to Dashboard'} {isDe ? 'Zurück zum Start' : 'Back to Dashboard'}
</button> </button>
</div> </div>
</div> </div>
@@ -265,18 +381,18 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
<div className="auth-card glass"> <div className="auth-card glass">
<div className="auth-header"> <div className="auth-header">
<ShieldCheck className="auth-icon success" size={48} style={{ color: '#10b981' }} /> <ShieldCheck className="auth-icon success" size={48} style={{ color: '#10b981' }} />
<h2>{i18n.language.startsWith('de') ? 'Logbuch-Einladung' : 'Logbook Invitation'}</h2> <h2>{isDe ? 'Logbuch-Einladung' : 'Logbook Invitation'}</h2>
</div> </div>
<div style={{ textAlign: 'center', margin: '20px 0', padding: '16px', background: 'rgba(255,255,255,0.03)', borderRadius: '12px' }}> <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' }}> <p style={{ margin: '0 0 8px 0', fontSize: '13px', color: '#64748b', textTransform: 'uppercase' }}>
{i18n.language.startsWith('de') ? 'Einladung von' : 'INVITED BY'} {isDe ? 'Einladung von' : 'INVITED BY'}
</p> </p>
<p style={{ margin: '0 0 16px 0', fontSize: '18px', fontWeight: 600, color: '#f1f5f9' }}> <p style={{ margin: '0 0 16px 0', fontSize: '18px', fontWeight: 600, color: '#f1f5f9' }}>
Skipper {ownerUsername} Skipper {ownerUsername}
</p> </p>
<p style={{ margin: '0 0 8px 0', fontSize: '13px', color: '#64748b', textTransform: 'uppercase' }}> <p style={{ margin: '0 0 8px 0', fontSize: '13px', color: '#64748b', textTransform: 'uppercase' }}>
{i18n.language.startsWith('de') ? 'Schiff / Logbuch' : 'VESSEL / LOGBOOK'} {isDe ? 'Schiff / Logbuch' : 'VESSEL / LOGBOOK'}
</p> </p>
<p style={{ margin: 0, fontSize: '20px', fontWeight: 700, color: '#fbbf24' }}> <p style={{ margin: 0, fontSize: '20px', fontWeight: 700, color: '#fbbf24' }}>
{decryptedTitle} {decryptedTitle}
@@ -284,53 +400,43 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
</div> </div>
{isLoggedIn ? ( {isLoggedIn ? (
/* If logged in: Accept and Join immediately */
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', width: '100%' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '16px', width: '100%' }}>
<p style={{ fontSize: '14px', color: '#94a3b8', textAlign: 'center', lineHeight: '145%' }}> <p style={{ fontSize: '14px', color: '#94a3b8', textAlign: 'center', lineHeight: '145%' }}>
{i18n.language.startsWith('de') {isDe
? `Sie sind angemeldet als ${username}. Möchten Sie diesem Logbuch als Crewmitglied beitreten?` ? `Angemeldet als ${username}. Beitritt wird vorbereitet...`
: `You are logged in as ${username}. Would you like to join this logbook with write permissions?` : `Signed in as ${username}. Preparing to join...`}
}
</p> </p>
<button className="btn primary" onClick={handleAccept} disabled={accepting} style={{ width: '100%' }}>
<div className="auth-actions mt-4" style={{ display: 'flex', gap: '12px' }}> {accepting ? (isDe ? 'Beitritt...' : 'Joining...') : (isDe ? 'Erneut beitreten' : 'Join again')}
<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} /> <ArrowRight size={16} />
</button> </button>
</div> </div>
</div>
) : ( ) : (
/* If not logged in: Ask to authenticate or register */
<div style={{ width: '100%' }}> <div style={{ width: '100%' }}>
{loginMode === 'options' && ( {loginMode === 'options' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<p style={{ fontSize: '13.5px', color: '#94a3b8', textAlign: 'center', lineHeight: '145%' }}> <p style={{ fontSize: '13.5px', color: '#94a3b8', textAlign: 'center', lineHeight: '145%' }}>
{i18n.language.startsWith('de') {isDe
? 'Sie müssen ein Passkey-Konto besitzen oder erstellen, um E2E-verschlüsselte Einträge zu schreiben.' ? 'Melden Sie sich an oder registrieren Sie ein Konto, um dem Logbuch beizutreten.'
: 'You must authenticate or register an E2E-secured crew account to write entries.' : 'Sign in or register an account to join this logbook.'}
}
</p> </p>
<button className="btn primary" onClick={handleLogin} style={{ width: '100%', padding: '14px' }}> <button className="btn primary" onClick={handleLogin} disabled={loading} style={{ width: '100%', padding: '14px' }}>
<LogIn size={16} /> <LogIn size={16} />
{i18n.language.startsWith('de') ? 'Mit Passkey anmelden' : 'Log In with Passkey'} {isDe ? 'Mit Passkey anmelden' : 'Log In with Passkey'}
</button> </button>
<div style={{ display: 'flex', alignItems: 'center', margin: '8px 0' }}> <div style={{ display: 'flex', alignItems: 'center', margin: '8px 0' }}>
<div style={{ flex: 1, height: '1px', background: 'rgba(255,255,255,0.08)' }}></div> <div style={{ flex: 1, height: '1px', background: 'rgba(255,255,255,0.08)' }} />
<span style={{ padding: '0 10px', fontSize: '12px', color: '#64748b' }}> <span style={{ padding: '0 10px', fontSize: '12px', color: '#64748b' }}>
{i18n.language.startsWith('de') ? 'ODER NEU REGISTRIEREN' : 'OR SIGN UP'} {isDe ? 'ODER NEU REGISTRIEREN' : 'OR SIGN UP'}
</span> </span>
<div style={{ flex: 1, height: '1px', background: 'rgba(255,255,255,0.08)' }}></div> <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%' }}> <button className="btn secondary" onClick={() => setLoginMode('register')} style={{ width: '100%' }}>
<UserPlus size={16} /> <UserPlus size={16} />
{i18n.language.startsWith('de') ? 'Neues Crew-Konto erstellen' : 'Register New Crew Account'} {isDe ? 'Neues Crew-Konto erstellen' : 'Register New Crew Account'}
</button> </button>
</div> </div>
)} )}
@@ -339,41 +445,35 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
<form onSubmit={handleRegister} style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}> <form onSubmit={handleRegister} style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div className="input-group"> <div className="input-group">
<label style={{ display: 'block', fontSize: '13px', color: '#94a3b8', marginBottom: '6px' }}> <label style={{ display: 'block', fontSize: '13px', color: '#94a3b8', marginBottom: '6px' }}>
{i18n.language.startsWith('de') ? 'Skipper- / Benutzername' : 'Skipper / User Name'} {isDe ? 'Benutzername' : 'Username'}
</label> </label>
<input <input
type="text" type="text"
className="input-text" className="input-text"
placeholder="e.g. Max Mustermann"
value={regUsername} value={regUsername}
onChange={(e) => setRegUsername(e.target.value)} onChange={(e) => setRegUsername(e.target.value)}
required required
/> />
</div> </div>
<div className="auth-actions"> <div className="auth-actions">
<button type="button" className="btn secondary" onClick={() => setLoginMode('options')}> <button type="button" className="btn secondary" onClick={() => setLoginMode('options')}>
{i18n.language.startsWith('de') ? 'Zurück' : 'Back'} {isDe ? 'Zurück' : 'Back'}
</button> </button>
<button type="submit" className="btn primary" disabled={!regUsername.trim()}> <button type="submit" className="btn primary" disabled={!regUsername.trim() || loading}>
{i18n.language.startsWith('de') ? 'Passkey erstellen & beitreten' : 'Create Passkey & Join'} {isDe ? 'Passkey erstellen' : 'Create Passkey'}
</button> </button>
</div> </div>
</form> </form>
)} )}
{authError && ( {authError && <div className="auth-error mt-4" style={{ fontSize: '13px' }}>{authError}</div>}
<div className="auth-error mt-4" style={{ fontSize: '13px' }}>
{authError}
</div>
)}
</div> </div>
)} )}
<div className="auth-footer" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '16px', marginTop: '24px' }}> <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}> <button className="btn-icon-text" onClick={toggleLanguage}>
<Languages size={18} /> <Languages size={18} />
{i18n.language.startsWith('de') ? 'English' : 'Deutsch'} {isDe ? 'English' : 'Deutsch'}
</button> </button>
</div> </div>
</div> </div>
+6 -1
View File
@@ -219,7 +219,12 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
</div> </div>
</div> </div>
<button className="btn-delete" onClick={(e) => handleDelete(lb.id, e)} title="Delete Logbook"> <button
className="btn-delete"
onClick={(e) => handleDelete(lb.id, e)}
title={t('dashboard.delete_btn')}
style={{ visibility: lb.isShared ? 'hidden' : 'visible' }}
>
<Trash2 size={18} /> <Trash2 size={18} />
</button> </button>
</div> </div>
+2 -1
View File
@@ -222,7 +222,8 @@
"no_logbooks": "Keine Logbücher gefunden. Erstellen Sie Ihr erstes Logbuch, um zu beginnen!", "no_logbooks": "Keine Logbücher gefunden. Erstellen Sie Ihr erstes Logbuch, um zu beginnen!",
"loading": "Logbücher werden geladen...", "loading": "Logbücher werden geladen...",
"status_synced": "Synchronisiert", "status_synced": "Synchronisiert",
"status_local": "Nur lokaler Cache" "status_local": "Nur lokaler Cache",
"delete_btn": "Logbuch löschen"
}, },
"crew": { "crew": {
"title": "Skipper- & Crew-Profile", "title": "Skipper- & Crew-Profile",
+2 -1
View File
@@ -222,7 +222,8 @@
"no_logbooks": "No logbooks found. Create your first logbook to begin!", "no_logbooks": "No logbooks found. Create your first logbook to begin!",
"loading": "Loading logbooks...", "loading": "Loading logbooks...",
"status_synced": "Synced", "status_synced": "Synced",
"status_local": "Local Cache Only" "status_local": "Local Cache Only",
"delete_btn": "Delete logbook"
}, },
"crew": { "crew": {
"title": "Skipper & Crew Profiles", "title": "Skipper & Crew Profiles",
+23 -6
View File
@@ -10,6 +10,7 @@ export interface DecryptedLogbook {
title: string title: string
updatedAt: string updatedAt: string
isSynced: boolean isSynced: boolean
isShared: boolean
} }
// Helper to decrypt a logbook's title using the active logbook key or master key // Helper to decrypt a logbook's title using the active logbook key or master key
@@ -42,6 +43,8 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
throw new Error('Master key not found. User must log in.') throw new Error('Master key not found. User must log in.')
} }
const sharedLogbookIds = new Set<string>()
if (navigator.onLine) { if (navigator.onLine) {
try { try {
const response = await fetch(API_BASE, { const response = await fetch(API_BASE, {
@@ -57,9 +60,18 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
// Decrypt and save logbook keys locally if they exist // Decrypt and save logbook keys locally if they exist
for (const lb of serverLogbooks) { for (const lb of serverLogbooks) {
const encryptedKeyStr = lb.encryptedKey || (lb.collaborators && lb.collaborators[0]?.encryptedLogbookKey) const isShared = lb.userId !== userId
const ivStr = lb.iv || (lb.collaborators && lb.collaborators[0]?.iv) if (isShared) sharedLogbookIds.add(lb.id)
const tagStr = lb.tag || (lb.collaborators && lb.collaborators[0]?.tag)
const encryptedKeyStr = isShared
? lb.collaborators?.[0]?.encryptedLogbookKey
: (lb.encryptedKey || lb.collaborators?.[0]?.encryptedLogbookKey)
const ivStr = isShared
? lb.collaborators?.[0]?.iv
: (lb.iv || lb.collaborators?.[0]?.iv)
const tagStr = isShared
? lb.collaborators?.[0]?.tag
: (lb.tag || lb.collaborators?.[0]?.tag)
if (encryptedKeyStr && ivStr && tagStr) { if (encryptedKeyStr && ivStr && tagStr) {
try { try {
@@ -75,6 +87,8 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
} catch (err) { } catch (err) {
console.error(`Failed to decrypt and save logbook key for logbook ${lb.id}:`, err) console.error(`Failed to decrypt and save logbook key for logbook ${lb.id}:`, err)
} }
} else if (isShared) {
console.warn(`Shared logbook ${lb.id} is missing collaboration key on server`)
} }
} }
// Clear local cache for any logbooks that are no longer on the server // Clear local cache for any logbooks that are no longer on the server
@@ -113,7 +127,8 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
id: lb.id, id: lb.id,
title, title,
updatedAt: lb.updatedAt, updatedAt: lb.updatedAt,
isSynced: lb.isSynced === 1 isSynced: lb.isSynced === 1,
isShared: sharedLogbookIds.has(lb.id)
}) })
} }
@@ -187,7 +202,8 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
id: serverLb.id, id: serverLb.id,
title, title,
updatedAt: serverLb.updatedAt, updatedAt: serverLb.updatedAt,
isSynced: true isSynced: true,
isShared: false
} }
} }
} catch (error) { } catch (error) {
@@ -216,7 +232,8 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
id: localId, id: localId,
title, title,
updatedAt: now, updatedAt: now,
isSynced: false isSynced: false,
isShared: false
} }
} }