feat(collab): E2E-compliant crew invitations and link-sharing collaboration

This commit is contained in:
2026-05-28 20:31:10 +02:00
parent d8f9585ac8
commit b3978ed294
22 changed files with 1243 additions and 66 deletions
+7 -6
View File
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js'
import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { useDialog } from './ModalDialog.tsx'
@@ -123,8 +124,8 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
setLoading(true)
setError(null)
try {
const masterKey = getActiveMasterKey()
if (!masterKey) throw new Error('Master key not found. Please log in.')
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const localCrews = await db.crews.where({ logbookId }).toArray()
@@ -168,8 +169,8 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
setSkipperSuccess(false)
try {
const masterKey = getActiveMasterKey()
if (!masterKey) throw new Error('Master key not found. Please log in.')
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const skipperData: CrewMemberData = {
name: skipName.trim(),
@@ -258,8 +259,8 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
setError(null)
try {
const masterKey = getActiveMasterKey()
if (!masterKey) throw new Error('Master key not found. Please log in.')
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const memberData: CrewMemberData = {
name: memName.trim(),
+5 -4
View File
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js'
import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { Compass, Save, Check } from 'lucide-react'
@@ -28,8 +29,8 @@ export default function DeviationForm({ logbookId }: DeviationFormProps) {
setLoading(true)
setError(null)
try {
const masterKey = getActiveMasterKey()
if (!masterKey) throw new Error('Master key not found. Please log in.')
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const local = await db.deviations.get(logbookId)
if (local) {
@@ -75,8 +76,8 @@ export default function DeviationForm({ logbookId }: DeviationFormProps) {
setSuccess(false)
try {
const masterKey = getActiveMasterKey()
if (!masterKey) throw new Error('Master key not found. Please log in.')
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
// Parse values, substituting 0 if empty
const sanitizedDeviations: Record<number, number> = {}
@@ -0,0 +1,367 @@
import React, { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Ship, LogIn, UserPlus, AlertTriangle, ShieldCheck, Languages, ArrowRight } from 'lucide-react'
import { getActiveMasterKey, registerUser, loginUser } from '../services/auth.js'
import { decryptJson, encryptBuffer } from '../services/crypto.js'
import { saveLogbookKey } from '../services/logbookKeys.js'
import { useDialog } from './ModalDialog.tsx'
interface InvitationAcceptanceProps {
onAccepted: (logbookId: string, title: string) => void
onCancel: () => void
}
// Convert Hex String back to ArrayBuffer
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 { i18n } = useTranslation()
const { showAlert } = useDialog()
const [loading, setLoading] = useState(true)
const [accepting, setAccepting] = useState(false)
const [error, setError] = useState<string | null>(null)
// Link parameters
const [token, setToken] = useState('')
const [logbookKey, setLogbookKey] = useState<ArrayBuffer | null>(null)
// Details loaded from server
const [ownerUsername, setOwnerUsername] = useState('')
const [decryptedTitle, setDecryptedTitle] = useState('')
const [logbookId, setLogbookId] = useState('')
// Authentication states
const [isLoggedIn, setIsLoggedIn] = useState(false)
const [username, setUsername] = useState('')
const [loginMode, setLoginMode] = useState<'options' | 'login' | 'register'>('options')
const [regUsername, setRegUsername] = useState('')
const [authError, setAuthError] = useState<string | null>(null)
// Check login state on mount
useEffect(() => {
const key = getActiveMasterKey()
const savedUser = localStorage.getItem('active_username')
if (key && savedUser) {
setIsLoggedIn(true)
setUsername(savedUser)
}
// Extract parameters from URL
const params = new URLSearchParams(window.location.search)
const tokenVal = params.get('token') || ''
setToken(tokenVal)
// Hash anchor (#key=xxx)
const hash = window.location.hash
if (hash.startsWith('#key=')) {
const hexKey = hash.substring(5)
try {
const keyBuffer = hexToBuffer(hexKey)
setLogbookKey(keyBuffer)
} catch (err) {
console.error('Invalid key in URL fragment:', err)
setError('The invitation link is cryptographically invalid or corrupted (missing key).')
}
} else {
setError('The invitation link is missing the necessary decryption key fragment (#key=...).')
}
// Suggest a random guest skipper username
const rand = Math.floor(1000 + Math.random() * 9000)
setRegUsername(`CrewSkipper_${rand}`)
}, [])
// Load invitation details once parameters are ready
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('This invitation link has expired (valid for 48 hours only).')
return
}
if (!res.ok) {
throw new Error('Failed to verify invitation token.')
}
const details = await res.json()
setOwnerUsername(details.ownerUsername)
setLogbookId(details.logbookId)
// Decrypt title client-side using URL key
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(err.message || 'Invitation details could not be retrieved from the server.')
} finally {
setLoading(false)
}
}
const handleAccept = async () => {
const masterKey = getActiveMasterKey()
const activeUserId = localStorage.getItem('active_userid')
if (!masterKey || !activeUserId || !logbookKey || !logbookId) return
setAccepting(true)
setError(null)
try {
// 1. Encrypt logbook key with user's master key
const aesMasterKey = await window.crypto.subtle.importKey(
'raw',
masterKey,
{ name: 'AES-GCM' },
false,
['encrypt']
)
const encrypted = await encryptBuffer(logbookKey, aesMasterKey)
// 2. Register collaboration on server
const res = await fetch('/api/collaboration/accept', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Id': activeUserId
},
body: JSON.stringify({
token,
encryptedLogbookKey: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag
})
})
if (!res.ok) {
const serverError = await res.json()
throw new Error(serverError.error || 'Failed to join logbook on the server.')
}
// 3. Save key locally in Dexie
await saveLogbookKey(logbookId, logbookKey)
// 4. Redirect to workspace
onAccepted(logbookId, decryptedTitle)
} catch (err: any) {
console.error('Accepting invitation failed:', err)
setError(err.message || 'Acceptance failed.')
} finally {
setAccepting(false)
}
}
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setAuthError(null)
setLoading(true)
try {
const result = await loginUser()
if (result.verified && result.prfSuccess) {
setIsLoggedIn(true)
setUsername(result.username || 'Skipper')
} else if (result.verified) {
// 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.')
}
} catch (err: any) {
setAuthError(err.message || 'Passkey authentication 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) {
setIsLoggedIn(true)
setUsername(regUsername.trim())
showAlert(`Account created successfully! Your 12-word recovery phrase is: ${result.recoveryPhrase}. Write it down securely!`)
}
} catch (err: any) {
setAuthError(err.message || 'Registration failed.')
} finally {
setLoading(false)
}
}
const toggleLanguage = () => {
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
i18n.changeLanguage(nextLang)
}
if (loading && !accepting) {
return (
<div className="auth-card glass">
<div className="auth-header">
<Ship className="auth-icon accent spin" size={48} />
<h2>{i18n.language.startsWith('de') ? 'Einladung wird geprüft...' : 'Checking Invitation...'}</h2>
</div>
<p className="recovery-warning">
{i18n.language.startsWith('de') ? 'Lade Verschlüsselungsschlüssel und Verifizierungstoken...' : 'Retrieving credentials and secure key components...'}
</p>
</div>
)
}
if (error) {
return (
<div className="auth-card glass">
<div className="auth-header">
<AlertTriangle className="auth-icon warn" size={48} />
<h2>{i18n.language.startsWith('de') ? 'Einladungsfehler' : 'Invitation Error'}</h2>
</div>
<p className="recovery-warning" style={{ color: '#ef4444' }}>{error}</p>
<div className="auth-actions mt-6">
<button className="btn primary" onClick={onCancel} style={{ width: '100%' }}>
{i18n.language.startsWith('de') ? 'Zurück zum Start' : 'Back to Dashboard'}
</button>
</div>
</div>
)
}
return (
<div className="auth-card glass">
<div className="auth-header">
<ShieldCheck className="auth-icon success" size={48} style={{ color: '#10b981' }} />
<h2>{i18n.language.startsWith('de') ? 'Logbuch-Einladung' : 'Logbook Invitation'}</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' }}>
{i18n.language.startsWith('de') ? 'Einladung von' : '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' }}>
{i18n.language.startsWith('de') ? 'Schiff / Logbuch' : 'VESSEL / LOGBOOK'}
</p>
<p style={{ margin: 0, fontSize: '20px', fontWeight: 700, color: '#fbbf24' }}>
{decryptedTitle}
</p>
</div>
{isLoggedIn ? (
/* If logged in: Accept and Join immediately */
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', width: '100%' }}>
<p style={{ fontSize: '14px', color: '#94a3b8', textAlign: 'center', lineHeight: '145%' }}>
{i18n.language.startsWith('de')
? `Sie sind angemeldet als ${username}. Möchten Sie diesem Logbuch als Crewmitglied beitreten?`
: `You are logged in as ${username}. Would you like to join this logbook with write permissions?`
}
</p>
<div className="auth-actions mt-4" style={{ display: 'flex', gap: '12px' }}>
<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} />
</button>
</div>
</div>
) : (
/* If not logged in: Ask to authenticate or register */
<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%' }}>
{i18n.language.startsWith('de')
? 'Sie müssen ein Passkey-Konto besitzen oder erstellen, um E2E-verschlüsselte Einträge zu schreiben.'
: 'You must authenticate or register an E2E-secured crew account to write entries.'
}
</p>
<button className="btn primary" onClick={handleLogin} style={{ width: '100%', padding: '14px' }}>
<LogIn size={16} />
{i18n.language.startsWith('de') ? 'Mit Passkey anmelden' : 'Log In with Passkey'}
</button>
<div style={{ display: 'flex', alignItems: 'center', margin: '8px 0' }}>
<div style={{ flex: 1, height: '1px', background: 'rgba(255,255,255,0.08)' }}></div>
<span style={{ padding: '0 10px', fontSize: '12px', color: '#64748b' }}>
{i18n.language.startsWith('de') ? 'ODER NEU REGISTRIEREN' : 'OR SIGN UP'}
</span>
<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%' }}>
<UserPlus size={16} />
{i18n.language.startsWith('de') ? 'Neues Crew-Konto erstellen' : 'Register New 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' }}>
{i18n.language.startsWith('de') ? 'Skipper- / Benutzername' : 'Skipper / User Name'}
</label>
<input
type="text"
className="input-text"
placeholder="e.g. Max Mustermann"
value={regUsername}
onChange={(e) => setRegUsername(e.target.value)}
required
/>
</div>
<div className="auth-actions">
<button type="button" className="btn secondary" onClick={() => setLoginMode('options')}>
{i18n.language.startsWith('de') ? 'Zurück' : 'Back'}
</button>
<button type="submit" className="btn primary" disabled={!regUsername.trim()}>
{i18n.language.startsWith('de') ? 'Passkey erstellen & beitreten' : 'Create Passkey & Join'}
</button>
</div>
</form>
)}
{authError && (
<div className="auth-error mt-4" style={{ fontSize: '13px' }}>
{authError}
</div>
)}
</div>
)}
<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}>
<Languages size={18} />
{i18n.language.startsWith('de') ? 'English' : 'Deutsch'}
</button>
</div>
</div>
)
}
+5 -4
View File
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js'
import { getLogbookKey } from '../services/logbookKeys.js'
import { decryptJson, encryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { downloadCsv, shareCsv } from '../services/csvExport.js'
@@ -42,8 +43,8 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) {
setLoading(true)
setError(null)
try {
const masterKey = getActiveMasterKey()
if (!masterKey) throw new Error('Master key not found. Please log in.')
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const local = await db.entries.where({ logbookId }).toArray()
@@ -131,8 +132,8 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) {
setLoading(true)
setError(null)
try {
const masterKey = getActiveMasterKey()
if (!masterKey) throw new Error('Master key not found. Please log in.')
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const localId = window.crypto.randomUUID()
const nowStr = new Date().toISOString()
+6 -5
View File
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js'
import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
@@ -132,7 +133,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
useEffect(() => {
async function loadYachtSails() {
try {
const masterKey = getActiveMasterKey()
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) return
const yacht = await db.yachts.get(logbookId)
@@ -155,8 +156,8 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
setLoading(true)
setError(null)
try {
const masterKey = getActiveMasterKey()
if (!masterKey) throw new Error('Master key not found. Please log in.')
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const local = await db.entries.get(entryId)
if (local) {
@@ -592,8 +593,8 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
setSuccess(false)
try {
const masterKey = getActiveMasterKey()
if (!masterKey) throw new Error('Master key not found. Please log in.')
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const entryData = {
date,
+4 -3
View File
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js'
import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { useLiveQuery } from 'dexie-react-hooks'
@@ -41,7 +42,7 @@ export default function PhotoCapture({ entryId, logbookId }: PhotoCaptureProps)
async function decryptPhotosList() {
if (!localPhotos) return
const masterKey = getActiveMasterKey()
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) return
const list: DecryptedPhoto[] = []
@@ -102,8 +103,8 @@ export default function PhotoCapture({ entryId, logbookId }: PhotoCaptureProps)
const compressedBase64 = canvas.toDataURL('image/jpeg', 0.7)
// Encrypt
const masterKey = getActiveMasterKey()
if (!masterKey) throw new Error('Master key not found. Please log in.')
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const photoId = window.crypto.randomUUID()
const photoPayload = {
+242 -5
View File
@@ -1,14 +1,156 @@
import React, { useState } from 'react'
import React, { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Settings, Save, Check } from 'lucide-react'
import { Settings as SettingsIcon, Save, Check, Users, Trash2, Copy, Link as LinkIcon } from 'lucide-react'
import { ensureLogbookKey } from '../services/logbookKeys.js'
import { useDialog } from './ModalDialog.tsx'
export default function SettingsForm() {
interface SettingsFormProps {
logbookId?: string | null
}
interface Collaborator {
id: string
userId: string
username: string
role: string
createdAt: string
}
// Convert ArrayBuffer to Hex String for URL fragment
const bufferToHex = (buffer: ArrayBuffer): string => {
return Array.from(new Uint8Array(buffer))
.map(b => b.toString(16).padStart(2, '0'))
.join('')
}
export default function SettingsForm({ logbookId }: SettingsFormProps) {
const { t } = useTranslation()
const { showConfirm, showAlert } = useDialog()
const [apiKey, setApiKey] = useState(localStorage.getItem('owm_api_key') || '')
const [theme, setTheme] = useState(localStorage.getItem('active_theme') || 'auto')
const [saving, setSaving] = useState(false)
const [success, setSuccess] = useState(false)
// Collaboration States
const [collaborators, setCollaborators] = useState<Collaborator[]>([])
const [isOwner, setIsOwner] = useState(false)
const [inviteLink, setInviteLink] = useState('')
const [inviteCopied, setInviteCopied] = useState(false)
const [generatingInvite, setGeneratingInvite] = useState(false)
const [collabError, setCollabError] = useState<string | null>(null)
const [loadingCollabs, setLoadingCollabs] = useState(false)
useEffect(() => {
if (logbookId) {
loadCollaborators()
}
}, [logbookId])
const loadCollaborators = async () => {
setLoadingCollabs(true)
setCollabError(null)
const userId = localStorage.getItem('active_userid')
if (!userId) return
try {
const res = await fetch(`/api/collaboration/collaborators?logbookId=${logbookId}`, {
headers: {
'X-User-Id': userId
}
})
if (res.status === 403) {
setIsOwner(false)
return
}
if (res.ok) {
setIsOwner(true)
const data = await res.json()
setCollaborators(data)
}
} catch (err) {
console.error('Failed to load collaborators:', err)
setCollabError('Failed to load collaborator list.')
} finally {
setLoadingCollabs(false)
}
}
const handleGenerateInvite = async () => {
if (!logbookId) return
setGeneratingInvite(true)
setInviteLink('')
const userId = localStorage.getItem('active_userid')
if (!userId) return
try {
// 1. Ensure logbook has an E2E key (upgrades legacy logbooks if needed)
const logbookKey = await ensureLogbookKey(logbookId)
// 2. Create invite token on server
const res = await fetch('/api/collaboration/invite', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Id': userId
},
body: JSON.stringify({ logbookId, role: 'WRITE' })
})
if (!res.ok) {
throw new Error('Failed to create invitation on the server.')
}
const invite = await res.json()
// 3. Format link containing token (URL params) and key (URL hash anchor)
const hexKey = bufferToHex(logbookKey)
const link = `${window.location.origin}/invite?token=${invite.token}#key=${hexKey}`
setInviteLink(link)
} catch (err: any) {
console.error('Failed to generate invite:', err)
showAlert(err.message || 'Failed to generate invite link.')
} finally {
setGeneratingInvite(false)
}
}
const handleCopyInvite = () => {
if (inviteLink) {
navigator.clipboard.writeText(inviteLink)
setInviteCopied(true)
setTimeout(() => setInviteCopied(false), 2000)
}
}
const handleRevoke = async (collabId: string, collName: string) => {
const userId = localStorage.getItem('active_userid')
if (!userId) return
if (await showConfirm(t('logs.revoke_confirm'), collName, t('logs.confirm_yes'), t('logs.confirm_no'))) {
try {
const res = await fetch(`/api/collaboration/collaborators/${collabId}`, {
method: 'DELETE',
headers: {
'X-User-Id': userId
}
})
if (res.ok) {
setCollaborators(prev => prev.filter(c => c.id !== collabId))
showAlert('Crew member access revoked successfully.')
} else {
throw new Error('Failed to revoke collaborator access.')
}
} catch (err: any) {
console.error('Revocation failed:', err)
showAlert(err.message || 'Failed to revoke access.')
}
}
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
setSaving(true)
@@ -29,7 +171,7 @@ export default function SettingsForm() {
return (
<div className="form-card">
<div className="form-header">
<Settings size={24} className="form-icon" />
<SettingsIcon size={24} className="form-icon" />
<div>
<h2>{t('settings.title')}</h2>
<p className="form-subtitle" style={{ margin: '4px 0 0 0', fontSize: '13px', color: '#94a3b8' }}>
@@ -90,7 +232,7 @@ export default function SettingsForm() {
</div>
</div>
<div className="form-actions mt-4">
<div className="form-actions mt-4 mb-6">
{success && (
<div className="success-toast">
<Check size={16} />
@@ -104,6 +246,101 @@ export default function SettingsForm() {
</button>
</div>
</form>
{/* Crew Collaboration Card (Only visible to Logbook Owner) */}
{logbookId && isOwner && (
<div className="member-editor-card glass mt-6" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '24px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
<Users size={20} style={{ color: '#fbbf24' }} />
<h3 style={{ margin: 0, color: '#fbbf24', fontSize: '16px' }}>
{t('logs.invite_crew')}
</h3>
</div>
<p style={{ fontSize: '13.5px', color: '#94a3b8', lineHeight: '145%', margin: '0 0 16px 0' }}>
{t('logs.invite_link_desc')}
</p>
<div className="form-actions" style={{ justifyContent: 'flex-start', gap: '12px', marginBottom: '20px' }}>
<button
type="button"
className="btn primary"
onClick={handleGenerateInvite}
disabled={generatingInvite}
>
<LinkIcon size={16} />
{generatingInvite ? 'Generating...' : t('logs.invite_crew')}
</button>
<span style={{ fontSize: '12px', color: '#64748b' }}>{t('logs.invite_expires')}</span>
</div>
{inviteLink && (
<div className="input-group mb-6" style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<input
type="text"
readOnly
value={inviteLink}
className="input-text font-mono text-xs"
style={{ flex: 1, padding: '10px' }}
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<button
type="button"
className="btn secondary"
onClick={handleCopyInvite}
style={{ width: 'auto', padding: '10px' }}
>
{inviteCopied ? <Check size={16} /> : <Copy size={16} />}
</button>
</div>
)}
{/* Collaborator List */}
<h4 style={{ color: '#fbbf24', fontSize: '14px', marginBottom: '12px' }}>
{t('logs.collaborators_list')}
</h4>
{loadingCollabs ? (
<div style={{ fontSize: '13px', color: '#64748b' }}>Loading crew members...</div>
) : collabError ? (
<div className="auth-error">{collabError}</div>
) : collaborators.length === 0 ? (
<div style={{ fontSize: '13.5px', color: '#64748b', fontStyle: 'italic' }}>No active crew members.</div>
) : (
<div className="table-responsive">
<table className="event-table" style={{ width: '100%' }}>
<thead>
<tr>
<th>Username</th>
<th>{t('logs.invite_role')}</th>
<th>Joined</th>
<th style={{ width: '60px' }}></th>
</tr>
</thead>
<tbody>
{collaborators.map((c) => (
<tr key={c.id}>
<td style={{ fontWeight: 500 }}>{c.username}</td>
<td>{c.role}</td>
<td>{new Date(c.createdAt).toLocaleDateString()}</td>
<td>
<button
type="button"
className="btn-icon logout"
onClick={() => handleRevoke(c.id, c.username)}
title="Revoke access"
>
<Trash2 size={16} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
)
}
+5 -4
View File
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js'
import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { Ship, Save, Check, Plus, X, Camera, Trash2 } from 'lucide-react'
@@ -38,8 +39,8 @@ export default function VesselForm({ logbookId }: VesselFormProps) {
setLoading(true)
setError(null)
try {
const masterKey = getActiveMasterKey()
if (!masterKey) throw new Error('Master key not found. Please log in.')
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const local = await db.yachts.get(logbookId)
if (local) {
@@ -147,8 +148,8 @@ export default function VesselForm({ logbookId }: VesselFormProps) {
setSuccess(false)
try {
const masterKey = getActiveMasterKey()
if (!masterKey) throw new Error('Master key not found. Please log in.')
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const yachtData = {
name: name.trim(),