fix: Einladungs-Auto-Accept, isShared-Cache und Recovery-Validierung

Auto-Accept kann nach Session-Verlust erneut starten, isShared wird offline in Dexie persistiert, und leere Recovery-Benutzernamen werden abgefangen.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-29 17:00:49 +02:00
parent 6f0385ee1b
commit 14b52c684d
4 changed files with 36 additions and 10 deletions
+15 -3
View File
@@ -137,6 +137,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
const masterKey = getActiveMasterKey()
const activeUserId = localStorage.getItem('active_userid')
if (!masterKey || !activeUserId) {
autoAcceptStarted.current = false
setError(isDe
? 'Sitzung unvollständig — bitte erneut anmelden (Benutzer-ID fehlt).'
: 'Incomplete session — please log in again (user ID missing).')
@@ -184,7 +185,8 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
id: logbookId,
encryptedTitle: rawEncryptedTitle,
updatedAt: new Date().toISOString(),
isSynced: 1
isSynced: 1,
isShared: 1
})
}
@@ -202,7 +204,10 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
useEffect(() => {
if (loading || accepting || autoAcceptStarted.current) return
if (!isLoggedIn || !logbookId || !logbookKey || !token) return
if (!sessionReady()) return
if (!sessionReady()) {
autoAcceptStarted.current = false
return
}
autoAcceptStarted.current = true
void handleAccept()
@@ -240,10 +245,17 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
e.preventDefault()
if (!recoveryInput.trim() || !encryptedPayloads) return
const resolvedUser = (username.trim() || encryptedPayloads.username || '').trim()
if (!resolvedUser) {
setAuthError(isDe
? 'Benutzername konnte nicht ermittelt werden — bitte erneut anmelden.'
: 'Could not determine username — please try logging in again.')
return
}
setLoading(true)
setAuthError(null)
try {
const resolvedUser = username.trim() || encryptedPayloads.username
const success = await completeLoginWithRecovery(resolvedUser, recoveryInput.trim(), encryptedPayloads)
if (success) {
setShowRecoveryFallback(false)
+12
View File
@@ -5,6 +5,7 @@ export interface LocalLogbook {
encryptedTitle: string
updatedAt: string
isSynced: number // 1 = yes, 0 = pending local modifications
isShared?: number // 1 = collaborator copy, 0 or unset = owned
}
export interface LocalYacht {
@@ -120,6 +121,17 @@ class DaagboxDatabase extends Dexie {
gpsTracks: 'entryId, logbookId, updatedAt',
logbookKeys: 'logbookId'
})
this.version(4).stores({
logbooks: 'id, encryptedTitle, updatedAt, isSynced, isShared',
yachts: 'logbookId, updatedAt',
crews: 'payloadId, logbookId, updatedAt',
deviations: 'logbookId, updatedAt',
entries: 'payloadId, logbookId, updatedAt',
syncQueue: '++id, action, type, payloadId, logbookId',
photos: 'payloadId, entryId, logbookId, updatedAt',
gpsTracks: 'entryId, logbookId, updatedAt',
logbookKeys: 'logbookId'
})
}
}
+7 -7
View File
@@ -43,8 +43,6 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
throw new Error('Master key not found. User must log in.')
}
const sharedLogbookIds = new Set<string>()
if (navigator.onLine) {
try {
const response = await fetch(API_BASE, {
@@ -61,7 +59,6 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
// Decrypt and save logbook keys locally if they exist
for (const lb of serverLogbooks) {
const isShared = lb.userId !== userId
if (isShared) sharedLogbookIds.add(lb.id)
const encryptedKeyStr = isShared
? lb.collaborators?.[0]?.encryptedLogbookKey
@@ -105,7 +102,8 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
id: lb.id,
encryptedTitle: lb.encryptedTitle,
updatedAt: lb.updatedAt || new Date().toISOString(),
isSynced: 1
isSynced: 1,
isShared: lb.userId !== userId ? 1 : 0
}))
// Clear existing cache for this user and insert new ones
@@ -128,7 +126,7 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
title,
updatedAt: lb.updatedAt,
isSynced: lb.isSynced === 1,
isShared: sharedLogbookIds.has(lb.id)
isShared: lb.isShared === 1
})
}
@@ -195,7 +193,8 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
id: serverLb.id,
encryptedTitle: serverLb.encryptedTitle,
updatedAt: serverLb.updatedAt,
isSynced: 1
isSynced: 1,
isShared: 0
})
return {
@@ -216,7 +215,8 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
id: localId,
encryptedTitle: encryptedTitleStr,
updatedAt: now,
isSynced: 0
isSynced: 0,
isShared: 0
})
await db.syncQueue.put({
+2
View File
@@ -61,6 +61,7 @@ async function getLogbookWithAccess(logbookId: string, userId: string) {
}
function hasWriteAccess(access: { isOwner: boolean; collaboration?: { role: string } | null }) {
// Intentional (HYBRID-ELECTRONIC-SIGNATURES.md §2.1): owner OR WRITE collaborator may sign entries.
return access.isOwner || access.collaboration?.role === 'WRITE'
}
@@ -106,6 +107,7 @@ async function isAuthorizedSigner(
role: 'skipper' | 'crew'
): Promise<boolean> {
if (role === 'skipper') {
// Skipper signing: owner or WRITE collaborator (design §2.1), using their own passkey.
if (signerUserId === ownerUserId) return true
const collaboration = await prisma.collaboration.findUnique({
where: {