Files
kapteins-daagbok/client/src/components/InvitationAcceptance.tsx
T

515 lines
18 KiB
TypeScript

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<LocalizedError | null>(null)
const [token, setToken] = useState('')
const [logbookKey, setLogbookKey] = useState<ArrayBuffer | null>(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<LocalizedError | null>(null)
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 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 (
<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} autoComplete="on">
{(username.trim() || encryptedPayloads?.username) && (
<input
type="text"
name="username"
autoComplete="username"
value={username.trim() || encryptedPayloads?.username || ''}
readOnly
tabIndex={-1}
aria-hidden="true"
style={{ position: 'absolute', width: 0, height: 0, opacity: 0, pointerEvents: 'none' }}
/>
)}
<div className="input-group">
<label htmlFor="invitation-recovery-key" className="input-label" style={{ display: 'block', marginBottom: '8px', color: '#94a3b8' }}>
{t('auth.enter_recovery')}
</label>
<input
id="invitation-recovery-key"
name="recovery-key"
type="password"
className="input-text"
placeholder={t('auth.recovery_placeholder')}
value={recoveryInput}
onChange={(e) => setRecoveryInput(e.target.value)}
required
autoComplete="current-password"
style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }}
/>
</div>
<div className="auth-actions mt-4">
<button type="button" className="btn secondary" onClick={() => setShowRecoveryFallback(false)}>
{t('auth.back')}
</button>
<button type="submit" className="btn primary" disabled={loading}>
{t('auth.decrypt_logbook')}
</button>
</div>
</form>
{authErrorText && <div className="auth-error mt-4">{authErrorText}</div>}
</div>
)
}
if ((loading || accepting) && !error) {
return (
<div className="auth-card glass">
<div className="auth-header">
<Ship className="auth-icon accent spin" size={48} />
<h2>{accepting ? t('invitation.loading_joining') : t('invitation.loading_checking')}</h2>
</div>
<p className="recovery-warning">
{accepting ? t('invitation.loading_unlocking') : t('invitation.loading_retrieving_key')}
</p>
</div>
)
}
if (error) {
return (
<div className="auth-card glass">
<div className="auth-header">
<AlertTriangle className="auth-icon warn" size={48} />
<h2>{t('invitation.error_title')}</h2>
</div>
<p className="recovery-warning" style={{ color: '#ef4444' }}>{errorText}</p>
<div className="auth-actions mt-6">
<button className="btn primary" onClick={onCancel} style={{ width: '100%' }}>
{t('invitation.back_to_start')}
</button>
</div>
</div>
)
}
return (
<div className="auth-card glass">
<div className="auth-header">
<ShieldCheck className="auth-icon success" size={48} style={{ color: '#10b981' }} />
<h2>{t('invitation.title')}</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' }}>
{t('invitation.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' }}>
{t('invitation.vessel_logbook')}
</p>
<p style={{ margin: 0, fontSize: '20px', fontWeight: 700, color: '#fbbf24' }}>
{decryptedTitle}
</p>
</div>
{isLoggedIn ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', width: '100%' }}>
<p style={{ fontSize: '14px', color: '#94a3b8', textAlign: 'center', lineHeight: '145%' }}>
{t('invitation.signed_in_preparing', { username })}
</p>
<button className="btn primary" onClick={handleAccept} disabled={accepting} style={{ width: '100%' }}>
{accepting ? t('invitation.loading_joining') : t('invitation.join_again')}
<ArrowRight size={16} />
</button>
</div>
) : (
<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%' }}>
{t('invitation.login_or_register_hint')}
</p>
<button className="btn primary" onClick={handleLogin} disabled={loading} style={{ width: '100%', padding: '14px' }}>
<LogIn size={16} />
{t('auth.login')}
</button>
<div style={{ display: 'flex', alignItems: 'center', margin: '8px 0' }}>
<div style={{ flex: 1, height: '1px', background: 'rgba(255,255,255,0.08)' }} />
<span style={{ padding: '0 10px', fontSize: '12px', color: '#64748b' }}>
{t('invitation.or_sign_up')}
</span>
<div style={{ flex: 1, height: '1px', background: 'rgba(255,255,255,0.08)' }} />
</div>
<button className="btn secondary" onClick={() => setLoginMode('register')} style={{ width: '100%' }}>
<UserPlus size={16} />
{t('invitation.register_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' }}>
{t('invitation.username_label')}
</label>
<input
type="text"
className="input-text"
value={regUsername}
onChange={(e) => setRegUsername(e.target.value)}
required
/>
</div>
<div className="auth-actions">
<button type="button" className="btn secondary" onClick={() => setLoginMode('options')}>
{t('auth.back')}
</button>
<button type="submit" className="btn primary" disabled={!regUsername.trim() || loading}>
{t('invitation.create_passkey')}
</button>
</div>
</form>
)}
{authErrorText && <div className="auth-error mt-4" style={{ fontSize: '13px' }}>{authErrorText}</div>}
</div>
)}
<div className="auth-footer" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '16px', marginTop: '24px' }}>
<LanguageDropdown variant="text" align="left" />
</div>
</div>
)
}