fix(invitation): Fehlertexte bei Sprachwechsel und Beta-Flyer-Logo

Speichert Einladungsfehler als i18n-Keys statt übersetzter Strings.
Beta-Flyer nutzt das Steuerrad-Logo (logo.png), PDF neu erzeugt.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-30 14:21:03 +02:00
co-authored by Cursor
parent caf7d81ac9
commit c3836eb07d
3 changed files with 43 additions and 20 deletions
+41 -19
View File
@@ -21,6 +21,25 @@ interface InvitationAcceptanceProps {
onCancel: () => 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 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++) {
@@ -34,7 +53,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
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<LocalizedError | null>(null)
const [token, setToken] = useState('') const [token, setToken] = useState('')
const [logbookKey, setLogbookKey] = useState<ArrayBuffer | null>(null) const [logbookKey, setLogbookKey] = useState<ArrayBuffer | null>(null)
@@ -48,7 +67,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [loginMode, setLoginMode] = useState<'options' | '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<LocalizedError | null>(null)
const [recoveryPhrase, setRecoveryPhrase] = useState<string | null>(null) const [recoveryPhrase, setRecoveryPhrase] = useState<string | null>(null)
const [showRecoveryFallback, setShowRecoveryFallback] = useState(false) const [showRecoveryFallback, setShowRecoveryFallback] = useState(false)
@@ -57,6 +76,9 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
const autoAcceptStarted = useRef(false) const autoAcceptStarted = useRef(false)
const errorText = resolveLocalizedError(error, t)
const authErrorText = resolveLocalizedError(authError, t)
const sessionReady = (): boolean => { const sessionReady = (): boolean => {
return !!(getActiveMasterKey() && localStorage.getItem('active_userid')) return !!(getActiveMasterKey() && localStorage.getItem('active_userid'))
} }
@@ -81,15 +103,14 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
setLogbookKey(hexToBuffer(hexKey)) setLogbookKey(hexToBuffer(hexKey))
} catch (err) { } catch (err) {
console.error('Invalid key in URL fragment:', err) console.error('Invalid key in URL fragment:', err)
setError(t('invitation.error_invalid_key')) setError({ source: 'i18n', key: 'invitation.error_invalid_key' })
} }
} else { } else {
setError(t('invitation.error_missing_key')) setError({ source: 'i18n', key: 'invitation.error_missing_key' })
} }
const rand = Math.floor(1000 + Math.random() * 9000) const rand = Math.floor(1000 + Math.random() * 9000)
setRegUsername(`CrewSkipper_${rand}`) setRegUsername(`CrewSkipper_${rand}`)
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only: URL parsing and default username
}, []) }, [])
useEffect(() => { useEffect(() => {
@@ -105,12 +126,13 @@ 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(t('invitation.error_expired')) setError({ source: 'i18n', key: 'invitation.error_expired' })
return return
} }
if (!res.ok) { if (!res.ok) {
throw new Error(t('invitation.error_invalid_token')) setError({ source: 'i18n', key: 'invitation.error_invalid_token' })
return
} }
const details = await res.json() const details = await res.json()
@@ -123,7 +145,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
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 || t('invitation.error_load_failed')) setError(localizedErrorFromMessage(err.message, 'invitation.error_load_failed'))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -134,7 +156,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
const activeUserId = localStorage.getItem('active_userid') const activeUserId = localStorage.getItem('active_userid')
if (!masterKey || !activeUserId) { if (!masterKey || !activeUserId) {
autoAcceptStarted.current = false autoAcceptStarted.current = false
setError(t('invitation.error_incomplete_session')) setError({ source: 'i18n', key: 'invitation.error_incomplete_session' })
setIsLoggedIn(false) setIsLoggedIn(false)
return return
} }
@@ -185,12 +207,12 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
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 || t('invitation.error_accept_failed')) setError(localizedErrorFromMessage(err.message, 'invitation.error_accept_failed'))
autoAcceptStarted.current = false autoAcceptStarted.current = false
} finally { } finally {
setAccepting(false) setAccepting(false)
} }
}, [logbookId, logbookKey, token, rawEncryptedTitle, decryptedTitle, onAccepted, t]) }, [logbookId, logbookKey, token, rawEncryptedTitle, decryptedTitle, onAccepted])
useEffect(() => { useEffect(() => {
if (loading || accepting || autoAcceptStarted.current) return if (loading || accepting || autoAcceptStarted.current) return
@@ -226,7 +248,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
if (resolvedUser) setUsername(resolvedUser) if (resolvedUser) setUsername(resolvedUser)
setShowRecoveryFallback(true) setShowRecoveryFallback(true)
} catch (err: any) { } catch (err: any) {
setAuthError(err.message || t('invitation.error_login_failed')) setAuthError(localizedErrorFromMessage(err.message, 'invitation.error_login_failed'))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -238,7 +260,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
const resolvedUser = (username.trim() || encryptedPayloads.username || '').trim() const resolvedUser = (username.trim() || encryptedPayloads.username || '').trim()
if (!resolvedUser) { if (!resolvedUser) {
setAuthError(t('invitation.error_username_missing')) setAuthError({ source: 'i18n', key: 'invitation.error_username_missing' })
return return
} }
@@ -251,10 +273,10 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
setIsLoggedIn(true) setIsLoggedIn(true)
setUsername(resolvedUser) setUsername(resolvedUser)
} else { } else {
setAuthError(t('auth.error_incorrect_recovery')) setAuthError({ source: 'i18n', key: 'auth.error_incorrect_recovery' })
} }
} catch (err: any) { } catch (err: any) {
setAuthError(err.message || t('auth.error_decryption_failed')) setAuthError(localizedErrorFromMessage(err.message, 'auth.error_decryption_failed'))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -274,7 +296,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
setRecoveryPhrase(result.recoveryPhrase) setRecoveryPhrase(result.recoveryPhrase)
} }
} catch (err: any) { } catch (err: any) {
setAuthError(err.message || t('invitation.error_register_failed')) setAuthError(localizedErrorFromMessage(err.message, 'invitation.error_register_failed'))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -340,7 +362,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
</button> </button>
</div> </div>
</form> </form>
{authError && <div className="auth-error mt-4">{authError}</div>} {authErrorText && <div className="auth-error mt-4">{authErrorText}</div>}
</div> </div>
) )
} }
@@ -366,7 +388,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
<AlertTriangle className="auth-icon warn" size={48} /> <AlertTriangle className="auth-icon warn" size={48} />
<h2>{t('invitation.error_title')}</h2> <h2>{t('invitation.error_title')}</h2>
</div> </div>
<p className="recovery-warning" style={{ color: '#ef4444' }}>{error}</p> <p className="recovery-warning" style={{ color: '#ef4444' }}>{errorText}</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%' }}>
{t('invitation.back_to_start')} {t('invitation.back_to_start')}
@@ -461,7 +483,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
</form> </form>
)} )}
{authError && <div className="auth-error mt-4" style={{ fontSize: '13px' }}>{authError}</div>} {authErrorText && <div className="auth-error mt-4" style={{ fontSize: '13px' }}>{authErrorText}</div>}
</div> </div>
)} )}
+2 -1
View File
@@ -61,6 +61,7 @@
width: 14mm; width: 14mm;
height: 14mm; height: 14mm;
flex-shrink: 0; flex-shrink: 0;
object-fit: contain;
} }
.title-block h1 { .title-block h1 {
@@ -232,7 +233,7 @@
<body> <body>
<article class="page"> <article class="page">
<header> <header>
<img class="logo" src="../../client/public/favicon.svg" alt="" /> <img class="logo" src="../../client/public/logo.png" alt="Kapteins Daagbok" />
<div class="title-block"> <div class="title-block">
<h1>Kapteins Daagbok</h1> <h1>Kapteins Daagbok</h1>
<p>Digitales Yacht-Logbuch — kostenlos &amp; werbefrei</p> <p>Digitales Yacht-Logbuch — kostenlos &amp; werbefrei</p>
Binary file not shown.