450 lines
15 KiB
TypeScript
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>
|
|
)
|
|
}
|