import React, { useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { Settings as SettingsIcon, Check, Users, Trash2, Copy, Link as LinkIcon } from 'lucide-react' import { ensureLogbookKey } from '../services/logbookKeys.js' import LogbookBackupPanel from './LogbookBackupPanel.tsx' import LinkQrCode from './LinkQrCode.tsx' import { useDialog } from './ModalDialog.tsx' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { apiFetch } from '../services/api.js' import { enableCollaboratorChangePush, isCollaboratorPushActive, isPushSupported } from '../services/pushNotifications.js' import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js' interface SettingsFormProps { logbookId?: string | null onLogbookRestored?: (logbookId: string, title: string) => void } interface Collaborator { id: string userId: string username: string role: string createdAt: string } 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, onLogbookRestored }: SettingsFormProps) { const { t } = useTranslation() const { showConfirm, showAlert } = useDialog() const [collaborators, setCollaborators] = useState([]) const [isOwner, setIsOwner] = useState(true) const [inviteLink, setInviteLink] = useState('') const [inviteCopied, setInviteCopied] = useState(false) const [generatingInvite, setGeneratingInvite] = useState(false) const [collabError, setCollabError] = useState(null) const [loadingCollabs, setLoadingCollabs] = useState(false) const [shareEnabled, setShareEnabled] = useState(false) const [shareLink, setShareLink] = useState('') const [shareCopied, setShareCopied] = useState(false) const [loadingShareLink, setLoadingShareLink] = useState(false) useEffect(() => { if (logbookId) { loadCollaborators() loadShareLink() } }, [logbookId]) const loadShareLink = async () => { if (!logbookId) return setLoadingShareLink(true) if (!localStorage.getItem('active_userid')) return try { const res = await apiFetch(`/api/collaboration/share-link?logbookId=${logbookId}`) if (res.ok) { const data = await res.json() if (data.token) { setShareEnabled(true) const logbookKey = await ensureLogbookKey(logbookId) const hexKey = bufferToHex(logbookKey) setShareLink(`${window.location.origin}/share?token=${data.token}#key=${hexKey}`) } else { setShareEnabled(false) setShareLink('') } } } catch (err) { console.error('Failed to load share link:', err) } finally { setLoadingShareLink(false) } } const handleToggleShare = async (e: React.ChangeEvent) => { if (!logbookId) return const checked = e.target.checked if (!localStorage.getItem('active_userid')) return setLoadingShareLink(true) try { const res = await apiFetch('/api/collaboration/share-link', { method: 'POST', body: JSON.stringify({ logbookId, enabled: checked }) }) if (res.ok) { const data = await res.json() if (checked && data.token) { setShareEnabled(true) const logbookKey = await ensureLogbookKey(logbookId) const hexKey = bufferToHex(logbookKey) setShareLink(`${window.location.origin}/share?token=${data.token}#key=${hexKey}`) trackPlausibleEvent(PlausibleEvents.LOGBOOK_SHARED) showAlert('Public share link enabled!') } else { setShareEnabled(false) setShareLink('') showAlert('Public share link disabled.') } } else { throw new Error('Failed to toggle public share link.') } } catch (err: unknown) { console.error('Toggle share link failed:', err) showAlert(err instanceof Error ? err.message : 'Failed to update public share link.') } finally { setLoadingShareLink(false) } } const handleCopyShareLink = () => { if (shareLink) { navigator.clipboard.writeText(shareLink) setShareCopied(true) setTimeout(() => setShareCopied(false), 2000) } } const loadCollaborators = async () => { setLoadingCollabs(true) setCollabError(null) if (!localStorage.getItem('active_userid')) return try { const res = await apiFetch(`/api/collaboration/collaborators?logbookId=${logbookId}`) if (res.status === 403) { setIsOwner(false) return } if (res.ok) { setIsOwner(true) const data = await res.json() setCollaborators(data) } else { setIsOwner(true) } } catch (err) { console.error('Failed to load collaborators:', err) setIsOwner(true) setCollabError('Failed to load collaborator list.') } finally { setLoadingCollabs(false) } } const promptPushAfterInviteCreated = async () => { if (!isPushSupported()) return if (await isCollaboratorPushActive()) return const iosNeedsInstall = isIosDevice() && !isRunningStandalone() if (iosNeedsInstall) { await showAlert( t('settings.invite_push_prompt_ios_message'), t('settings.invite_push_prompt_title'), t('settings.invite_push_prompt_later') ) return } const enable = await showConfirm( t('settings.invite_push_prompt_message'), t('settings.invite_push_prompt_title'), t('settings.invite_push_prompt_enable'), t('settings.invite_push_prompt_later') ) if (!enable) return try { await enableCollaboratorChangePush() await showAlert( t('settings.invite_push_prompt_success'), t('settings.invite_push_prompt_title') ) trackPlausibleEvent(PlausibleEvents.PUSH_ENABLED) } catch (err: unknown) { console.error('Failed to enable push after invite:', err) await showAlert(err instanceof Error ? err.message : t('profile.push_error')) } } const handleGenerateInvite = async () => { if (!logbookId) return setGeneratingInvite(true) setInviteLink('') if (!localStorage.getItem('active_userid')) return try { const logbookKey = await ensureLogbookKey(logbookId) const res = await apiFetch('/api/collaboration/invite', { method: 'POST', body: JSON.stringify({ logbookId, role: 'WRITE' }) }) if (!res.ok) { throw new Error('Failed to create invitation on the server.') } const invite = await res.json() const hexKey = bufferToHex(logbookKey) const link = `${window.location.origin}/invite?token=${invite.token}#key=${hexKey}` setInviteLink(link) trackPlausibleEvent(PlausibleEvents.INVITE_GENERATED) await promptPushAfterInviteCreated() } catch (err: unknown) { console.error('Failed to generate invite:', err) showAlert(err instanceof Error ? 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) => { if (!localStorage.getItem('active_userid')) return if (await showConfirm(t('logs.revoke_confirm'), collName, t('logs.confirm_yes'), t('logs.confirm_no'))) { try { const res = await apiFetch(`/api/collaboration/collaborators/${collabId}`, { method: 'DELETE' }) 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: unknown) { console.error('Revocation failed:', err) showAlert(err instanceof Error ? err.message : 'Failed to revoke access.') } } } if (!logbookId) { return (

{t('settings.title')}

{t('settings.subtitle')}

{t('settings.select_logbook_hint')}

) } return (

{t('settings.title')}

{t('settings.subtitle')}

{logbookId && isOwner && (

{t('settings.share_title')}

{t('settings.share_desc')}

{t('settings.share_privacy_warning')}

{loadingShareLink && Updating...}
{shareEnabled && shareLink && (
(e.target as HTMLInputElement).select()} />
)}
)} {logbookId && isOwner && ( )} {logbookId && isOwner && (

{t('logs.invite_crew')}

{t('logs.invite_link_desc')}

{t('logs.invite_expires')}
{inviteLink && (
(e.target as HTMLInputElement).select()} />
)}

{t('logs.collaborators_list')}

{loadingCollabs ? (
Loading crew members...
) : collabError ? (
{collabError}
) : collaborators.length === 0 ? (
No active crew members.
) : (
{collaborators.map((c) => ( ))}
Username {t('logs.invite_role')} Joined
{c.username} {c.role} {new Date(c.createdAt).toLocaleDateString()}
)}
)}
) }