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

450 lines
15 KiB
TypeScript

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,
preloadPushService
} 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<Collaborator[]>([])
const [isOwner, setIsOwner] = useState(true)
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)
const [shareEnabled, setShareEnabled] = useState(false)
const [shareLink, setShareLink] = useState('')
const [shareCopied, setShareCopied] = useState(false)
const [loadingShareLink, setLoadingShareLink] = useState(false)
useEffect(() => {
if (logbookId) {
loadCollaborators()
loadShareLink()
}
void preloadPushService()
}, [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<HTMLInputElement>) => {
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)
const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
await showAlert(message)
}
}
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 (
<div className="form-card">
<div className="form-header">
<SettingsIcon size={24} className="form-icon" />
<div>
<h2>{t('settings.title')}</h2>
<p className="form-subtitle">{t('settings.subtitle')}</p>
</div>
</div>
<p className="text-muted mt-4">{t('settings.select_logbook_hint')}</p>
</div>
)
}
return (
<div className="form-card">
<div className="form-header">
<SettingsIcon size={24} className="form-icon" />
<div>
<h2>{t('settings.title')}</h2>
<p className="form-subtitle">{t('settings.subtitle')}</p>
</div>
</div>
{logbookId && isOwner && (
<div className="member-editor-card glass mt-6">
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
<LinkIcon size={20} style={{ color: '#fbbf24' }} />
<h3 style={{ margin: 0, color: '#fbbf24', fontSize: '16px' }}>
{t('settings.share_title')}
</h3>
</div>
<p style={{ fontSize: '13.5px', color: '#94a3b8', lineHeight: '145%', margin: '0 0 16px 0' }}>
{t('settings.share_desc')}
</p>
<p className="signature-lock-notice" style={{ marginBottom: '16px' }}>
{t('settings.share_privacy_warning')}
</p>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '20px' }}>
<label className="switch-label" style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer', fontSize: '14px', color: '#f1f5f9' }}>
<input
type="checkbox"
checked={shareEnabled}
onChange={handleToggleShare}
disabled={loadingShareLink}
style={{ width: '18px', height: '18px', cursor: 'pointer' }}
/>
<span>{t('settings.share_enable')}</span>
</label>
{loadingShareLink && <span style={{ fontSize: '12px', color: '#64748b' }}>Updating...</span>}
</div>
{shareEnabled && shareLink && (
<div className="link-with-qr mb-4">
<div className="input-group copy-link-row">
<input
type="text"
readOnly
value={shareLink}
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={handleCopyShareLink}
style={{ width: 'auto', padding: '10px' }}
title={t('settings.share_copy_btn')}
>
{shareCopied ? <Check size={16} /> : <Copy size={16} />}
</button>
</div>
<LinkQrCode value={shareLink} />
</div>
)}
</div>
)}
{logbookId && isOwner && (
<LogbookBackupPanel logbookId={logbookId} onRestored={onLogbookRestored} />
)}
{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 form-actions--start" style={{ 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="link-with-qr mb-6">
<div className="input-group copy-link-row">
<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' }}
title={t('settings.share_copy_btn')}
>
{inviteCopied ? <Check size={16} /> : <Copy size={16} />}
</button>
</div>
<LinkQrCode value={inviteLink} />
</div>
)}
<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>
)
}