import React, { useState, useEffect, useCallback, useRef } from 'react' import { useTranslation } from 'react-i18next' import LanguageDropdown from './LanguageDropdown.tsx' import { Ship, LogIn, UserPlus, AlertTriangle, ShieldCheck, ArrowRight, KeyRound } from 'lucide-react' import { getActiveMasterKey, registerUser, loginUser, completeLoginWithRecovery, getKnownUsernames } from '../services/auth.js' import { decryptJson, encryptBuffer } from '../services/crypto.js' import { saveLogbookKey } from '../services/logbookKeys.js' import { parseCollaborationRole } from '../services/logbook.js' import { syncLogbook } from '../services/sync.js' import { db } from '../services/db.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { apiJson } from '../services/api.js' interface InvitationAcceptanceProps { onAccepted: (logbookId: string, title: string) => void onCancel: () => void } type LocalizedError = | { source: 'i18n'; key: string } | { source: 'raw'; text: string } const resolveLocalizedError = ( error: LocalizedError | null, t: (key: string) => string ): string | null => { if (!error) return null return error.source === 'i18n' ? t(error.key) : error.text } const localizedErrorFromMessage = ( message: string | undefined, fallbackKey: string ): LocalizedError => { return message ? { source: 'raw', text: message } : { source: 'i18n', key: fallbackKey } } 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 { t } = useTranslation() const [loading, setLoading] = useState(true) const [accepting, setAccepting] = useState(false) const [error, setError] = useState(null) const [token, setToken] = useState('') const [logbookKey, setLogbookKey] = useState(null) const [ownerUsername, setOwnerUsername] = useState('') const [decryptedTitle, setDecryptedTitle] = useState('') const [logbookId, setLogbookId] = useState('') const [rawEncryptedTitle, setRawEncryptedTitle] = useState('') const [isLoggedIn, setIsLoggedIn] = useState(false) const [username, setUsername] = useState('') const [loginMode, setLoginMode] = useState<'options' | 'register'>('options') const [regUsername, setRegUsername] = useState('') const [authError, setAuthError] = useState(null) const [recoveryPhrase, setRecoveryPhrase] = useState(null) const [showRecoveryFallback, setShowRecoveryFallback] = useState(false) const [recoveryInput, setRecoveryInput] = useState('') const [encryptedPayloads, setEncryptedPayloads] = useState(null) const autoAcceptStarted = useRef(false) const errorText = resolveLocalizedError(error, t) const authErrorText = resolveLocalizedError(authError, t) const sessionReady = (): boolean => { return !!(getActiveMasterKey() && localStorage.getItem('active_userid')) } useEffect(() => { const key = getActiveMasterKey() const savedUser = localStorage.getItem('active_username') const savedUserId = localStorage.getItem('active_userid') if (key && savedUser && savedUserId) { setIsLoggedIn(true) setUsername(savedUser) } const params = new URLSearchParams(window.location.search) const tokenVal = params.get('token') || '' setToken(tokenVal) const hash = window.location.hash if (hash.startsWith('#key=')) { const hexKey = hash.substring(5) try { setLogbookKey(hexToBuffer(hexKey)) } catch (err) { console.error('Invalid key in URL fragment:', err) setError({ source: 'i18n', key: 'invitation.error_invalid_key' }) } } else { setError({ source: 'i18n', key: 'invitation.error_missing_key' }) } const rand = Math.floor(1000 + Math.random() * 9000) setRegUsername(`CrewSkipper_${rand}`) }, []) 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({ source: 'i18n', key: 'invitation.error_expired' }) return } if (!res.ok) { setError({ source: 'i18n', key: 'invitation.error_invalid_token' }) return } const details = await res.json() setOwnerUsername(details.ownerUsername) setLogbookId(details.logbookId) setRawEncryptedTitle(details.encryptedTitle) 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(localizedErrorFromMessage(err.message, 'invitation.error_load_failed')) } finally { setLoading(false) } } const handleAccept = useCallback(async () => { const masterKey = getActiveMasterKey() const activeUserId = localStorage.getItem('active_userid') if (!masterKey || !activeUserId) { autoAcceptStarted.current = false setError({ source: 'i18n', key: 'invitation.error_incomplete_session' }) setIsLoggedIn(false) return } if (!logbookKey || !logbookId) { autoAcceptStarted.current = false return } setAccepting(true) setError(null) try { const aesMasterKey = await window.crypto.subtle.importKey( 'raw', masterKey, { name: 'AES-GCM' }, false, ['encrypt'] ) const encrypted = await encryptBuffer(logbookKey, aesMasterKey) const acceptResult = await apiJson<{ role: string; logbookId: string }>('/api/collaboration/accept', { method: 'POST', body: JSON.stringify({ token, encryptedLogbookKey: encrypted.ciphertext, iv: encrypted.iv, tag: encrypted.tag }) }) const collaborationRole = parseCollaborationRole(acceptResult.role, 'invitation accept') await saveLogbookKey(logbookId, logbookKey) if (rawEncryptedTitle) { await db.logbooks.put({ id: logbookId, encryptedTitle: rawEncryptedTitle, updatedAt: new Date().toISOString(), isSynced: 1, isShared: 1, collaborationRole }) } await syncLogbook(logbookId) trackPlausibleEvent(PlausibleEvents.INVITE_ACCEPTED) onAccepted(logbookId, decryptedTitle) } catch (err: any) { console.error('Accepting invitation failed:', err) setError(localizedErrorFromMessage(err.message, 'invitation.error_accept_failed')) autoAcceptStarted.current = false } finally { setAccepting(false) } }, [logbookId, logbookKey, token, rawEncryptedTitle, decryptedTitle, onAccepted]) useEffect(() => { if (loading || accepting || autoAcceptStarted.current) return if (!isLoggedIn || !logbookId || !logbookKey || !token) return if (!sessionReady()) { autoAcceptStarted.current = false return } autoAcceptStarted.current = true void handleAccept() }, [isLoggedIn, logbookId, logbookKey, token, loading, accepting, handleAccept]) const handleLogin = async () => { setAuthError(null) setLoading(true) try { const remembered = getKnownUsernames() const target = remembered.length === 1 ? remembered[0] : undefined const result = await loginUser(target) if (!result.verified) return if (result.prfSuccess) { setIsLoggedIn(true) setUsername(result.username || 'Skipper') return } setEncryptedPayloads(result.encryptedPayloads) const resolvedUser = result.username || result.encryptedPayloads?.username || '' if (resolvedUser) setUsername(resolvedUser) setShowRecoveryFallback(true) } catch (err: any) { setAuthError(localizedErrorFromMessage(err.message, 'invitation.error_login_failed')) } finally { setLoading(false) } } const handleRecoverySubmit = async (e: React.FormEvent) => { e.preventDefault() if (!recoveryInput.trim() || !encryptedPayloads) return const resolvedUser = (username.trim() || encryptedPayloads.username || '').trim() if (!resolvedUser) { setAuthError({ source: 'i18n', key: 'invitation.error_username_missing' }) return } setLoading(true) setAuthError(null) try { const success = await completeLoginWithRecovery(resolvedUser, recoveryInput.trim(), encryptedPayloads) if (success) { setShowRecoveryFallback(false) setIsLoggedIn(true) setUsername(resolvedUser) } else { setAuthError({ source: 'i18n', key: 'auth.error_incorrect_recovery' }) } } catch (err: any) { setAuthError(localizedErrorFromMessage(err.message, 'auth.error_decryption_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) { setUsername(regUsername.trim()) setRecoveryPhrase(result.recoveryPhrase) } } catch (err: any) { setAuthError(localizedErrorFromMessage(err.message, 'invitation.error_register_failed')) } finally { setLoading(false) } } const handleConfirmRecovery = () => { setRecoveryPhrase(null) setIsLoggedIn(true) } if (recoveryPhrase) { return (

{t('auth.recovery_title')}

{t('auth.recovery_warning')}

{recoveryPhrase.split(' ').map((word, idx) => (
{idx + 1} {word}
))}
) } if (showRecoveryFallback) { return (

{t('auth.enter_recovery')}

{t('auth.recovery_fallback_warning')}

{(username.trim() || encryptedPayloads?.username) && ( )}
setRecoveryInput(e.target.value)} required autoComplete="current-password" style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }} />
{authErrorText &&
{authErrorText}
}
) } if ((loading || accepting) && !error) { return (

{accepting ? t('invitation.loading_joining') : t('invitation.loading_checking')}

{accepting ? t('invitation.loading_unlocking') : t('invitation.loading_retrieving_key')}

) } if (error) { return (

{t('invitation.error_title')}

{errorText}

) } return (

{t('invitation.title')}

{t('invitation.invited_by')}

Skipper {ownerUsername}

{t('invitation.vessel_logbook')}

{decryptedTitle}

{isLoggedIn ? (

{t('invitation.signed_in_preparing', { username })}

) : (
{loginMode === 'options' && (

{t('invitation.login_or_register_hint')}

{t('invitation.or_sign_up')}
)} {loginMode === 'register' && (
setRegUsername(e.target.value)} required />
)} {authErrorText &&
{authErrorText}
}
)}
) }