Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dee2f7b95b | |||
| 4eaf5d7f30 | |||
| 257bca14d1 | |||
| 917fb92d85 | |||
| b48b31580d | |||
| 7f0223c636 | |||
| 68af8c6361 | |||
| ad7e036ab7 | |||
| 12c02f6392 | |||
| 3698c6fbca | |||
| d4538ec06e | |||
| 86cb4d92ec | |||
| b72b20b66c | |||
| 6ad75ff947 | |||
| 75eba362d6 | |||
| afc5a1e200 |
+348
-11
@@ -732,17 +732,13 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.skipper-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.skipper-badge.btn-icon {
|
||||
width: auto;
|
||||
border-radius: 18px;
|
||||
padding: 0 12px;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
color: var(--app-text-muted);
|
||||
cursor: default;
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@@ -800,6 +796,274 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
padding-bottom: calc(32px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.profile-main {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px 48px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dashboard-header--profile .profile-header-brand {
|
||||
align-items: flex-start;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.profile-back-btn {
|
||||
margin-top: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.profile-dl {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.profile-dl-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(140px, 200px) minmax(0, 1fr);
|
||||
gap: 8px 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.profile-dl-row dt {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--app-text-muted);
|
||||
text-align: left;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.profile-dl-row dd {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
word-break: break-word;
|
||||
text-align: left;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.profile-user-id {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.profile-user-id code {
|
||||
font-size: 12px;
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.profile-copy-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.profile-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.profile-section-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.profile-section-desc,
|
||||
.profile-pin-status,
|
||||
.profile-empty {
|
||||
margin: 0 0 12px;
|
||||
font-size: 13px;
|
||||
color: var(--app-text-muted);
|
||||
line-height: 1.5;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.profile-pin-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.profile-pin-form .input-group label {
|
||||
display: block;
|
||||
text-align: left;
|
||||
font-size: 13.5px;
|
||||
color: var(--app-text-muted);
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.profile-main .form-actions:not(.account-danger-zone__actions) {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.profile-passkey-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.profile-passkey-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: rgba(148, 163, 184, 0.06);
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
|
||||
.profile-passkey-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.profile-passkey-label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--app-text);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.profile-passkey-rename {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.profile-passkey-rename .input-text {
|
||||
flex: 1 1 160px;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.profile-add-passkey {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.profile-add-passkey .input-group label {
|
||||
display: block;
|
||||
text-align: left;
|
||||
font-size: 13.5px;
|
||||
color: var(--app-text-muted);
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.profile-security-list {
|
||||
list-style: none;
|
||||
margin: 0 0 12px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.profile-security-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.profile-security-item--ok {
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.profile-security-item--warn {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.profile-recovery-hint {
|
||||
margin-bottom: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.profile-recovery-actions {
|
||||
margin-top: 16px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.profile-recovery-actions .btn {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.profile-recovery-card .phrase-grid {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.profile-recovery-warning {
|
||||
margin: 0 0 20px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #fbbf24;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.profile-device-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.account-danger-zone__hint {
|
||||
margin: 0 0 16px;
|
||||
font-size: 13px;
|
||||
color: var(--app-text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.profile-passkey-id {
|
||||
display: block;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.profile-passkey-transports {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--app-text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.profile-dl-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.dashboard-header--profile .profile-header-brand {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.profile-back-btn {
|
||||
margin-top: 0;
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.account-danger-zone {
|
||||
border-top: 1px solid rgba(239, 68, 68, 0.2);
|
||||
padding-top: 24px;
|
||||
@@ -984,7 +1248,7 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
border-radius: var(--app-radius-card);
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
@@ -1028,10 +1292,65 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-title-row h3 {
|
||||
margin: 0;
|
||||
flex: 1 1 8rem;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.card-title-row .role-badge {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logbook-card-actions {
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: -2px;
|
||||
}
|
||||
|
||||
.logbook-card-actions .btn-delete {
|
||||
position: static;
|
||||
top: auto;
|
||||
right: auto;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.logbook-card:hover .logbook-card-actions .btn-delete,
|
||||
.logbook-card:focus-within .logbook-card-actions .btn-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: none), (pointer: coarse) {
|
||||
.logbook-card-actions .btn-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.logbook-title-editable {
|
||||
cursor: text;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.logbook-title-editable:hover {
|
||||
background: var(--app-accent-bg);
|
||||
}
|
||||
|
||||
.logbook-title-inline-edit {
|
||||
flex: 1 1 8rem;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
padding: 2px 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
@@ -1737,6 +2056,11 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.skipper-badge.btn-icon {
|
||||
width: 36px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
padding: 8px 10px;
|
||||
flex-shrink: 0;
|
||||
@@ -1782,15 +2106,28 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
}
|
||||
|
||||
.logbook-card {
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
padding: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.logbook-card-actions {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.logbook-card-actions .btn-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card-title-row h3,
|
||||
.logbook-title-inline-edit {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.card-info h3 {
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
|
||||
+15
-4
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
|
||||
import './App.css'
|
||||
import { DialogProvider } from './components/ModalDialog.tsx'
|
||||
import AuthOnboarding from './components/AuthOnboarding.tsx'
|
||||
import UserProfilePage from './components/UserProfilePage.tsx'
|
||||
import LogbookDashboard from './components/LogbookDashboard.tsx'
|
||||
import VesselForm from './components/VesselForm.tsx'
|
||||
import CrewForm from './components/CrewForm.tsx'
|
||||
@@ -61,6 +62,7 @@ function App() {
|
||||
const [online, setOnline] = useState(navigator.onLine)
|
||||
const [isSyncing, setIsSyncing] = useState(false)
|
||||
const [isAcceptingInvite, setIsAcceptingInvite] = useState(false)
|
||||
const [showUserProfile, setShowUserProfile] = useState(false)
|
||||
|
||||
// Viewer mode for read-only shared links
|
||||
const [isViewerMode, setIsViewerMode] = useState(false)
|
||||
@@ -361,6 +363,7 @@ function App() {
|
||||
setIsAuthenticated(false)
|
||||
setActiveLogbookId(null)
|
||||
setActiveLogbookTitle(null)
|
||||
setShowUserProfile(false)
|
||||
setTourSelectedEntryId(null)
|
||||
setDemoHighlightEntryId(null)
|
||||
localStorage.removeItem('active_logbook_id')
|
||||
@@ -442,10 +445,18 @@ function App() {
|
||||
return (
|
||||
<div style={{ display: 'contents' }}>
|
||||
{pwaInstallBanner}
|
||||
<LogbookDashboard
|
||||
onSelectLogbook={selectLogbook}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
{showUserProfile ? (
|
||||
<UserProfilePage
|
||||
onBack={() => setShowUserProfile(false)}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
) : (
|
||||
<LogbookDashboard
|
||||
onSelectLogbook={selectLogbook}
|
||||
onLogout={handleLogout}
|
||||
onOpenProfile={() => setShowUserProfile(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ export default function AccountDangerZone({ className = '' }: AccountDangerZoneP
|
||||
</div>
|
||||
|
||||
<p className="account-danger-zone__desc">{t('settings.danger_zone_desc')}</p>
|
||||
<p className="account-danger-zone__hint">{t('settings.delete_backup_hint')}</p>
|
||||
|
||||
<div className="form-actions account-danger-zone__actions">
|
||||
<button
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { db } from '../services/db.js'
|
||||
import { fetchLogbooks, createLogbook, deleteLogbook, type DecryptedLogbook } from '../services/logbook.js'
|
||||
import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type DecryptedLogbook } from '../services/logbook.js'
|
||||
import LogbookRoleBadge from './LogbookRoleBadge.tsx'
|
||||
import BetaBadge from './BetaBadge.tsx'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { logoutUser } from '../services/auth.js'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import AccountDangerZone from './AccountDangerZone.tsx'
|
||||
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff } from 'lucide-react'
|
||||
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
|
||||
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
|
||||
@@ -16,13 +15,17 @@ import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
|
||||
interface LogbookDashboardProps {
|
||||
onSelectLogbook: (id: string, title: string) => void
|
||||
onLogout: () => void
|
||||
onOpenProfile: () => void
|
||||
}
|
||||
|
||||
export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookDashboardProps) {
|
||||
export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProfile }: LogbookDashboardProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { showConfirm } = useDialog()
|
||||
const [logbooks, setLogbooks] = useState<DecryptedLogbook[]>([])
|
||||
const [newTitle, setNewTitle] = useState('')
|
||||
const [editingLogbookId, setEditingLogbookId] = useState<string | null>(null)
|
||||
const [editingTitleDraft, setEditingTitleDraft] = useState('')
|
||||
const titleInputRef = useRef<HTMLInputElement>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -99,6 +102,49 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (editingLogbookId) {
|
||||
titleInputRef.current?.focus()
|
||||
titleInputRef.current?.select()
|
||||
}
|
||||
}, [editingLogbookId])
|
||||
|
||||
const startTitleEdit = (lb: DecryptedLogbook, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setEditingLogbookId(lb.id)
|
||||
setEditingTitleDraft(lb.title)
|
||||
}
|
||||
|
||||
const cancelTitleEdit = () => {
|
||||
setEditingLogbookId(null)
|
||||
setEditingTitleDraft('')
|
||||
}
|
||||
|
||||
const commitTitleEdit = async (id: string) => {
|
||||
if (editingLogbookId !== id) return
|
||||
|
||||
const lb = logbooks.find((item) => item.id === id)
|
||||
const trimmedTitle = editingTitleDraft.trim()
|
||||
cancelTitleEdit()
|
||||
|
||||
if (!lb || !trimmedTitle || trimmedTitle === lb.title.trim()) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
await updateLogbookTitle(id, trimmedTitle)
|
||||
setLogbooks((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === id ? { ...item, title: trimmedTitle, updatedAt: new Date().toISOString() } : item
|
||||
)
|
||||
)
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to update logbook title')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
void logoutUser()
|
||||
onLogout()
|
||||
@@ -112,7 +158,10 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
||||
const ownedLogbooks = logbooks.filter((lb) => !lb.isShared)
|
||||
const sharedLogbooks = logbooks.filter((lb) => lb.isShared)
|
||||
|
||||
const renderLogbookCard = (lb: DecryptedLogbook) => (
|
||||
const renderLogbookCard = (lb: DecryptedLogbook) => {
|
||||
const isEditingTitle = editingLogbookId === lb.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={lb.id}
|
||||
className={`logbook-card glass${lb.isShared ? ' logbook-card--shared' : ''}`}
|
||||
@@ -124,7 +173,36 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
||||
|
||||
<div className="card-info">
|
||||
<div className="card-title-row">
|
||||
<h3>{lb.title}</h3>
|
||||
{isEditingTitle ? (
|
||||
<input
|
||||
ref={titleInputRef}
|
||||
type="text"
|
||||
className="logbook-title-inline-edit input-text"
|
||||
value={editingTitleDraft}
|
||||
onChange={(e) => setEditingTitleDraft(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
void commitTitleEdit(lb.id)
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelTitleEdit()
|
||||
}
|
||||
}}
|
||||
onBlur={() => void commitTitleEdit(lb.id)}
|
||||
disabled={loading}
|
||||
aria-label={t('dashboard.edit_title')}
|
||||
/>
|
||||
) : (
|
||||
<h3
|
||||
className={lb.isShared ? undefined : 'logbook-title-editable'}
|
||||
onClick={lb.isShared ? undefined : (e) => startTitleEdit(lb, e)}
|
||||
title={lb.isShared ? undefined : t('dashboard.edit_title')}
|
||||
>
|
||||
{lb.title}
|
||||
</h3>
|
||||
)}
|
||||
<LogbookRoleBadge role={lb.accessRole} />
|
||||
</div>
|
||||
<div className="card-meta">
|
||||
@@ -144,16 +222,22 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn-delete"
|
||||
onClick={(e) => handleDelete(lb.id, e)}
|
||||
title={t('dashboard.delete_btn')}
|
||||
style={{ visibility: lb.isShared ? 'hidden' : 'visible' }}
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
{!lb.isShared && (
|
||||
<div className="logbook-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-delete"
|
||||
onClick={(e) => handleDelete(lb.id, e)}
|
||||
title={t('dashboard.delete_btn')}
|
||||
aria-label={t('dashboard.delete_btn')}
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const renderLogbookSection = (
|
||||
title: string,
|
||||
@@ -210,14 +294,16 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
||||
</div>
|
||||
|
||||
{/* Skipper profile */}
|
||||
<div
|
||||
className="skipper-badge"
|
||||
title={t('dashboard.logged_in_as', { name: username })}
|
||||
aria-label={t('dashboard.logged_in_as', { name: username })}
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon skipper-badge"
|
||||
onClick={onOpenProfile}
|
||||
title={t('dashboard.open_profile', { name: username })}
|
||||
aria-label={t('dashboard.open_profile', { name: username })}
|
||||
>
|
||||
<User size={16} aria-hidden="true" />
|
||||
<User size={18} aria-hidden="true" />
|
||||
<span className="skipper-badge__name">{username}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Lang toggle */}
|
||||
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
|
||||
@@ -289,10 +375,6 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<section className="dashboard-account-section" aria-label={t('settings.danger_zone_title')}>
|
||||
<AccountDangerZone />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useTranslation } from 'react-i18next'
|
||||
import { Settings as SettingsIcon, Save, Check, Users, Trash2, Copy, Link as LinkIcon, Compass } from 'lucide-react'
|
||||
import { ensureLogbookKey } from '../services/logbookKeys.js'
|
||||
import LogbookBackupPanel from './LogbookBackupPanel.tsx'
|
||||
import AccountDangerZone from './AccountDangerZone.tsx'
|
||||
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
|
||||
import PushNotificationSettings from './PushNotificationSettings.tsx'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
@@ -541,8 +540,6 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Danger Zone / Account Deletion */}
|
||||
<AccountDangerZone className="mt-6" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,782 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import {
|
||||
User,
|
||||
ChevronLeft,
|
||||
LogOut,
|
||||
KeyRound,
|
||||
Copy,
|
||||
Check,
|
||||
Plus,
|
||||
Trash2,
|
||||
BookOpen,
|
||||
Anchor,
|
||||
Gauge,
|
||||
Sailboat,
|
||||
Timer,
|
||||
Share2,
|
||||
Calendar,
|
||||
Lock,
|
||||
BarChart2,
|
||||
Shield,
|
||||
Smartphone,
|
||||
RefreshCw,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
CircleCheck,
|
||||
CircleAlert
|
||||
} from 'lucide-react'
|
||||
import AccountDangerZone from './AccountDangerZone.tsx'
|
||||
import BetaBadge from './BetaBadge.tsx'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import {
|
||||
addPasskey,
|
||||
fetchUserProfile,
|
||||
forgetUsername,
|
||||
getActiveMasterKey,
|
||||
getKnownUsernames,
|
||||
hasLocalPin,
|
||||
removeLocalPin,
|
||||
removePasskey,
|
||||
renamePasskey,
|
||||
rotateRecoveryPhrase,
|
||||
setLocalPin,
|
||||
type UserProfile
|
||||
} from '../services/auth.js'
|
||||
import {
|
||||
formatHours,
|
||||
formatNm,
|
||||
loadAccountStats,
|
||||
type AccountStatsSummary
|
||||
} from '../services/statsAggregation.js'
|
||||
import { db } from '../services/db.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
|
||||
interface UserProfilePageProps {
|
||||
onBack: () => void
|
||||
onLogout: () => void
|
||||
}
|
||||
|
||||
function formatAccountAge(createdAt: string, locale: string): string {
|
||||
const created = new Date(createdAt)
|
||||
if (Number.isNaN(created.getTime())) return createdAt
|
||||
return created.toLocaleDateString(locale, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
function KpiCard({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
unit
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
value: string
|
||||
unit?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="stats-kpi-card glass">
|
||||
<div className="stats-kpi-icon">{icon}</div>
|
||||
<div className="stats-kpi-body">
|
||||
<span className="stats-kpi-label">{label}</span>
|
||||
<span className="stats-kpi-value">
|
||||
{value}
|
||||
{unit ? <span className="stats-kpi-unit">{unit}</span> : null}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SecurityCheckItem({ ok, label }: { ok: boolean; label: string }) {
|
||||
return (
|
||||
<li className={`profile-security-item ${ok ? 'profile-security-item--ok' : 'profile-security-item--warn'}`}>
|
||||
{ok ? <CircleCheck size={18} aria-hidden="true" /> : <CircleAlert size={18} aria-hidden="true" />}
|
||||
<span>{label}</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export default function UserProfilePage({ onBack, onLogout }: UserProfilePageProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { showConfirm, showAlert } = useDialog()
|
||||
const username = localStorage.getItem('active_username') || 'Skipper'
|
||||
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null)
|
||||
const [accountStats, setAccountStats] = useState<AccountStatsSummary | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [copiedUserId, setCopiedUserId] = useState(false)
|
||||
const [passkeyBusy, setPasskeyBusy] = useState(false)
|
||||
const [pinBusy, setPinBusy] = useState(false)
|
||||
const [pinInput, setPinInput] = useState('')
|
||||
const [pinConfirm, setPinConfirm] = useState('')
|
||||
const [pinActive, setPinActive] = useState(() => hasLocalPin(username))
|
||||
const [newPasskeyLabel, setNewPasskeyLabel] = useState('')
|
||||
const [passkeyLabels, setPasskeyLabels] = useState<Record<string, string>>({})
|
||||
const [online, setOnline] = useState(navigator.onLine)
|
||||
const [isKnownDevice, setIsKnownDevice] = useState(() =>
|
||||
getKnownUsernames().some((u) => u.toLowerCase() === username.toLowerCase())
|
||||
)
|
||||
const [recoveryBusy, setRecoveryBusy] = useState(false)
|
||||
const [pendingRecoveryPhrase, setPendingRecoveryPhrase] = useState<string | null>(null)
|
||||
const [recoveryCopied, setRecoveryCopied] = useState(false)
|
||||
|
||||
const pendingSyncCount = useLiveQuery(() => db.syncQueue.count()) ?? 0
|
||||
|
||||
const sharedLogbookCount = useLiveQuery(
|
||||
() => db.logbooks.filter((lb) => lb.isShared === 1).count(),
|
||||
[]
|
||||
) ?? 0
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const profileData = await fetchUserProfile()
|
||||
setProfile(profileData)
|
||||
|
||||
try {
|
||||
const stats = await loadAccountStats(false)
|
||||
setAccountStats(stats)
|
||||
} catch (statsErr) {
|
||||
console.error('Failed to load account stats for profile:', statsErr)
|
||||
setAccountStats(null)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : t('profile.load_error'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [t])
|
||||
|
||||
useEffect(() => {
|
||||
void loadData()
|
||||
}, [loadData])
|
||||
|
||||
useEffect(() => {
|
||||
trackPlausibleEvent(PlausibleEvents.PROFILE_OPENED)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleOnline = () => setOnline(true)
|
||||
const handleOffline = () => setOnline(false)
|
||||
window.addEventListener('online', handleOnline)
|
||||
window.addEventListener('offline', handleOffline)
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline)
|
||||
window.removeEventListener('offline', handleOffline)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!profile) return
|
||||
const labels: Record<string, string> = {}
|
||||
for (const cred of profile.credentials) {
|
||||
labels[cred.id] = cred.label ?? ''
|
||||
}
|
||||
setPasskeyLabels(labels)
|
||||
}, [profile])
|
||||
|
||||
const statsTotals = accountStats?.totals
|
||||
const logbookCount =
|
||||
accountStats?.logbooks.length ?? profile?.serverMeta.ownedLogbookCount ?? 0
|
||||
|
||||
const accountAgeLabel = useMemo(() => {
|
||||
if (!profile?.createdAt) return '—'
|
||||
return formatAccountAge(profile.createdAt, i18n.language)
|
||||
}, [profile?.createdAt, i18n.language])
|
||||
|
||||
const handleCopyUserId = async () => {
|
||||
if (!profile?.userId) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(profile.userId)
|
||||
setCopiedUserId(true)
|
||||
window.setTimeout(() => setCopiedUserId(false), 2000)
|
||||
} catch {
|
||||
showAlert(t('profile.copy_failed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddPasskey = async () => {
|
||||
setPasskeyBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const hadLabel = Boolean(newPasskeyLabel.trim())
|
||||
await addPasskey(newPasskeyLabel)
|
||||
setNewPasskeyLabel('')
|
||||
await loadData()
|
||||
trackPlausibleEvent(PlausibleEvents.PASSKEY_ADDED, { labeled: hadLabel })
|
||||
showAlert(t('profile.add_passkey_success'))
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : t('profile.add_passkey_failed'))
|
||||
} finally {
|
||||
setPasskeyBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRenamePasskey = async (credentialId: string) => {
|
||||
setPasskeyBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
await renamePasskey(credentialId, passkeyLabels[credentialId] ?? '')
|
||||
await loadData()
|
||||
trackPlausibleEvent(PlausibleEvents.PASSKEY_RENAMED)
|
||||
showAlert(t('profile.passkey_rename_success'))
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : t('profile.passkey_rename_failed'))
|
||||
} finally {
|
||||
setPasskeyBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleForgetDevice = async () => {
|
||||
const confirmed = await showConfirm(
|
||||
t('profile.device_forget_confirm_desc'),
|
||||
t('profile.device_forget_confirm_title'),
|
||||
t('profile.device_forget_confirm_yes'),
|
||||
t('profile.device_forget_confirm_no')
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
forgetUsername(username)
|
||||
setIsKnownDevice(false)
|
||||
trackPlausibleEvent(PlausibleEvents.DEVICE_FORGOTTEN)
|
||||
}
|
||||
|
||||
const handleRemovePasskey = async (credentialId: string) => {
|
||||
if (profile && profile.credentials.length <= 1) {
|
||||
trackPlausibleEvent(PlausibleEvents.LAST_PASSKEY_REMOVE_HINTED)
|
||||
await showAlert(
|
||||
t('profile.remove_passkey_last_desc'),
|
||||
t('profile.remove_passkey_last_title')
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const confirmed = await showConfirm(
|
||||
t('profile.remove_passkey_confirm_desc'),
|
||||
t('profile.remove_passkey_confirm_title'),
|
||||
t('profile.remove_passkey_confirm_yes'),
|
||||
t('profile.remove_passkey_confirm_no')
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
setPasskeyBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
await removePasskey(credentialId)
|
||||
await loadData()
|
||||
trackPlausibleEvent(PlausibleEvents.PASSKEY_REMOVED)
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : t('profile.remove_passkey_failed'))
|
||||
} finally {
|
||||
setPasskeyBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSavePin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (pinInput.length < 4) {
|
||||
setError(t('profile.pin_length_error'))
|
||||
return
|
||||
}
|
||||
if (pinInput !== pinConfirm) {
|
||||
setError(t('profile.pin_mismatch'))
|
||||
return
|
||||
}
|
||||
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) {
|
||||
setError(t('profile.pin_no_session'))
|
||||
return
|
||||
}
|
||||
|
||||
const pinAction = pinActive ? 'change' : 'set'
|
||||
|
||||
setPinBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
await setLocalPin(pinInput.trim(), username, masterKey)
|
||||
setPinActive(true)
|
||||
setPinInput('')
|
||||
setPinConfirm('')
|
||||
trackPlausibleEvent(PlausibleEvents.LOCAL_PIN_SET, { action: pinAction })
|
||||
showAlert(t('profile.pin_saved'))
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : t('profile.pin_save_failed'))
|
||||
} finally {
|
||||
setPinBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemovePin = async () => {
|
||||
const confirmed = await showConfirm(
|
||||
t('profile.remove_pin_confirm_desc'),
|
||||
t('profile.remove_pin_confirm_title'),
|
||||
t('profile.remove_pin_confirm_yes'),
|
||||
t('profile.remove_pin_confirm_no')
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
removeLocalPin(username)
|
||||
setPinActive(false)
|
||||
setPinInput('')
|
||||
setPinConfirm('')
|
||||
trackPlausibleEvent(PlausibleEvents.LOCAL_PIN_REMOVED)
|
||||
}
|
||||
|
||||
const handleRotateRecovery = async () => {
|
||||
const confirmed = await showConfirm(
|
||||
t('profile.recovery_rotate_confirm_desc'),
|
||||
t('profile.recovery_rotate_confirm_title'),
|
||||
t('profile.recovery_rotate_confirm_yes'),
|
||||
t('profile.recovery_rotate_confirm_no')
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
if (!getActiveMasterKey()) {
|
||||
setError(t('profile.recovery_rotate_no_session'))
|
||||
return
|
||||
}
|
||||
|
||||
setRecoveryBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const phrase = await rotateRecoveryPhrase()
|
||||
setPendingRecoveryPhrase(phrase)
|
||||
trackPlausibleEvent(PlausibleEvents.RECOVERY_ROTATED)
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.message === 'NO_ACTIVE_MASTER_KEY') {
|
||||
setError(t('profile.recovery_rotate_no_session'))
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : t('profile.recovery_rotate_failed'))
|
||||
}
|
||||
} finally {
|
||||
setRecoveryBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyRecoveryPhrase = async () => {
|
||||
if (!pendingRecoveryPhrase) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(pendingRecoveryPhrase)
|
||||
setRecoveryCopied(true)
|
||||
window.setTimeout(() => setRecoveryCopied(false), 2000)
|
||||
} catch {
|
||||
showAlert(t('profile.copy_failed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirmRecoverySaved = () => {
|
||||
setPendingRecoveryPhrase(null)
|
||||
setRecoveryCopied(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dashboard-container">
|
||||
<header className="dashboard-header dashboard-header--profile">
|
||||
<div className="header-brand profile-header-brand">
|
||||
<button className="btn-back profile-back-btn" onClick={onBack} title={t('profile.back')}>
|
||||
<ChevronLeft size={16} />
|
||||
<span>{t('profile.back')}</span>
|
||||
</button>
|
||||
<div>
|
||||
<div className="header-brand-title-row">
|
||||
<h1>{t('profile.title')}</h1>
|
||||
<BetaBadge />
|
||||
</div>
|
||||
<p className="subtitle">{t('profile.subtitle', { name: username })}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="header-actions">
|
||||
<button className="btn-icon logout" onClick={onLogout} title={t('dashboard.logout')}>
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="profile-main">
|
||||
{error && <div className="auth-error mb-4">{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="tab-placeholder">
|
||||
<User className="header-logo spin" size={48} />
|
||||
<p>{t('profile.loading')}</p>
|
||||
</div>
|
||||
) : pendingRecoveryPhrase ? (
|
||||
<section className="form-card profile-recovery-card">
|
||||
<div className="form-header">
|
||||
<KeyRound size={24} className="form-icon" />
|
||||
<h2>{t('auth.recovery_title')}</h2>
|
||||
</div>
|
||||
<p className="profile-recovery-warning">{t('profile.recovery_rotate_new_warning')}</p>
|
||||
<div className="phrase-grid">
|
||||
{pendingRecoveryPhrase.split(' ').map((word, idx) => (
|
||||
<div key={idx} className="phrase-word">
|
||||
<span className="word-num">{idx + 1}</span>
|
||||
{word}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="form-actions profile-recovery-actions">
|
||||
<button type="button" className="btn secondary" onClick={() => void handleCopyRecoveryPhrase()}>
|
||||
{recoveryCopied ? t('auth.copied') : t('auth.copy_phrase')}
|
||||
</button>
|
||||
<button type="button" className="btn primary" onClick={handleConfirmRecoverySaved}>
|
||||
{t('auth.confirm_recovery')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
) : profile ? (
|
||||
<>
|
||||
<section className="form-card">
|
||||
<div className="form-header">
|
||||
<User size={24} className="form-icon" />
|
||||
<h2>{t('profile.identity_title')}</h2>
|
||||
</div>
|
||||
|
||||
<dl className="profile-dl">
|
||||
<div className="profile-dl-row">
|
||||
<dt>{t('profile.username')}</dt>
|
||||
<dd>{profile.username}</dd>
|
||||
</div>
|
||||
<div className="profile-dl-row">
|
||||
<dt>{t('profile.user_id')}</dt>
|
||||
<dd className="profile-user-id">
|
||||
<code>{profile.userId}</code>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon profile-copy-btn"
|
||||
onClick={() => void handleCopyUserId()}
|
||||
title={t('profile.copy_user_id')}
|
||||
>
|
||||
{copiedUserId ? <Check size={16} /> : <Copy size={16} />}
|
||||
</button>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="profile-dl-row">
|
||||
<dt>{t('profile.account_since')}</dt>
|
||||
<dd>{accountAgeLabel}</dd>
|
||||
</div>
|
||||
<div className="profile-dl-row">
|
||||
<dt>{t('profile.prf_status')}</dt>
|
||||
<dd>
|
||||
{profile.hasPrfEncryption
|
||||
? t('profile.prf_active')
|
||||
: t('profile.prf_inactive')}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="member-editor-card glass">
|
||||
<div className="profile-section-header">
|
||||
<Shield size={20} />
|
||||
<h3>{t('profile.security_title')}</h3>
|
||||
</div>
|
||||
<p className="profile-section-desc">{t('profile.security_desc')}</p>
|
||||
<ul className="profile-security-list">
|
||||
<SecurityCheckItem
|
||||
ok={profile.credentials.length > 0}
|
||||
label={
|
||||
profile.credentials.length > 0
|
||||
? t('profile.security_passkeys_ok')
|
||||
: t('profile.security_passkeys_missing')
|
||||
}
|
||||
/>
|
||||
<SecurityCheckItem
|
||||
ok={profile.hasPrfEncryption}
|
||||
label={
|
||||
profile.hasPrfEncryption
|
||||
? t('profile.security_prf_ok')
|
||||
: t('profile.security_prf_missing')
|
||||
}
|
||||
/>
|
||||
<SecurityCheckItem
|
||||
ok={pinActive}
|
||||
label={pinActive ? t('profile.security_pin_ok') : t('profile.security_pin_missing')}
|
||||
/>
|
||||
<SecurityCheckItem ok label={t('profile.security_recovery_ok')} />
|
||||
</ul>
|
||||
<p className="profile-section-desc profile-recovery-hint">{t('profile.security_recovery_hint')}</p>
|
||||
<div className="form-actions profile-recovery-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => void handleRotateRecovery()}
|
||||
disabled={recoveryBusy || passkeyBusy || pinBusy}
|
||||
>
|
||||
{recoveryBusy ? t('profile.processing') : t('profile.recovery_rotate_btn')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="member-editor-card glass">
|
||||
<div className="profile-section-header">
|
||||
<Smartphone size={20} />
|
||||
<h3>{t('profile.device_title')}</h3>
|
||||
</div>
|
||||
<p className="profile-section-desc">{t('profile.device_desc')}</p>
|
||||
<div className={`profile-device-status conn-status ${online ? (pendingSyncCount > 0 ? 'warning' : 'online') : 'offline'}`}>
|
||||
{online ? (
|
||||
pendingSyncCount > 0 ? (
|
||||
<>
|
||||
<RefreshCw size={16} className="spin" aria-hidden="true" />
|
||||
<span>{t('profile.device_sync_pending', { count: pendingSyncCount })}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Wifi size={16} aria-hidden="true" />
|
||||
<span>{t('profile.device_sync_ok')}</span>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<WifiOff size={16} aria-hidden="true" />
|
||||
<span>{t('sync.status_offline')}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="profile-pin-status">
|
||||
{isKnownDevice ? t('profile.device_remembered') : t('profile.device_not_remembered')}
|
||||
</p>
|
||||
{isKnownDevice && (
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => void handleForgetDevice()}
|
||||
>
|
||||
{t('profile.device_forget_btn')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="member-editor-card glass">
|
||||
<div className="profile-section-header">
|
||||
<Lock size={20} />
|
||||
<h3>{t('profile.pin_title')}</h3>
|
||||
</div>
|
||||
<p className="profile-section-desc">{t('auth.setup_pin_warning')}</p>
|
||||
<p className="profile-pin-status">
|
||||
{t('profile.pin_status')}:{' '}
|
||||
<strong>{pinActive ? t('profile.pin_active') : t('profile.pin_inactive')}</strong>
|
||||
</p>
|
||||
|
||||
<form onSubmit={(e) => void handleSavePin(e)} className="profile-pin-form">
|
||||
<div className="input-group">
|
||||
<label htmlFor="profile-pin">{t('auth.pin_label')}</label>
|
||||
<input
|
||||
id="profile-pin"
|
||||
type="password"
|
||||
inputMode="numeric"
|
||||
autoComplete="new-password"
|
||||
className="input-text"
|
||||
placeholder={t('auth.pin_placeholder')}
|
||||
value={pinInput}
|
||||
onChange={(e) => setPinInput(e.target.value)}
|
||||
disabled={pinBusy}
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label htmlFor="profile-pin-confirm">{t('profile.pin_confirm_label')}</label>
|
||||
<input
|
||||
id="profile-pin-confirm"
|
||||
type="password"
|
||||
inputMode="numeric"
|
||||
autoComplete="new-password"
|
||||
className="input-text"
|
||||
placeholder={t('profile.pin_confirm_placeholder')}
|
||||
value={pinConfirm}
|
||||
onChange={(e) => setPinConfirm(e.target.value)}
|
||||
disabled={pinBusy}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn primary"
|
||||
disabled={pinBusy || pinInput.length < 4 || pinConfirm.length < 4}
|
||||
>
|
||||
{pinActive ? t('profile.pin_change_btn') : t('profile.pin_set_btn')}
|
||||
</button>
|
||||
{pinActive && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => void handleRemovePin()}
|
||||
disabled={pinBusy}
|
||||
>
|
||||
{t('profile.pin_remove_btn')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="member-editor-card glass">
|
||||
<div className="profile-section-header">
|
||||
<KeyRound size={20} />
|
||||
<h3>{t('profile.passkeys_title')}</h3>
|
||||
</div>
|
||||
<p className="profile-section-desc">{t('profile.passkeys_desc')}</p>
|
||||
|
||||
{profile.credentials.length === 0 ? (
|
||||
<p className="profile-empty">{t('profile.passkeys_empty')}</p>
|
||||
) : (
|
||||
<ul className="profile-passkey-list">
|
||||
{profile.credentials.map((cred) => (
|
||||
<li key={cred.id} className="profile-passkey-item">
|
||||
<div className="profile-passkey-main">
|
||||
<span className="profile-passkey-label">
|
||||
{cred.label || t('profile.passkey_unnamed')}
|
||||
</span>
|
||||
<span className="profile-passkey-id">{cred.credentialIdPreview}</span>
|
||||
{cred.transports.length > 0 && (
|
||||
<span className="profile-passkey-transports">
|
||||
{cred.transports.join(', ')}
|
||||
</span>
|
||||
)}
|
||||
<div className="profile-passkey-rename">
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={passkeyLabels[cred.id] ?? ''}
|
||||
onChange={(e) =>
|
||||
setPasskeyLabels((prev) => ({ ...prev, [cred.id]: e.target.value }))
|
||||
}
|
||||
placeholder={t('profile.passkey_label_placeholder')}
|
||||
disabled={passkeyBusy}
|
||||
maxLength={64}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => void handleRenamePasskey(cred.id)}
|
||||
disabled={passkeyBusy}
|
||||
>
|
||||
{t('profile.passkey_rename_btn')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon danger"
|
||||
onClick={() => void handleRemovePasskey(cred.id)}
|
||||
disabled={passkeyBusy}
|
||||
title={t('profile.remove_passkey_btn')}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="profile-add-passkey">
|
||||
<div className="input-group">
|
||||
<label htmlFor="profile-new-passkey-label">{t('profile.passkey_label')}</label>
|
||||
<input
|
||||
id="profile-new-passkey-label"
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={newPasskeyLabel}
|
||||
onChange={(e) => setNewPasskeyLabel(e.target.value)}
|
||||
placeholder={t('profile.passkey_label_placeholder')}
|
||||
disabled={passkeyBusy}
|
||||
maxLength={64}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-actions mt-4">
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
onClick={() => void handleAddPasskey()}
|
||||
disabled={passkeyBusy}
|
||||
>
|
||||
<Plus size={16} />
|
||||
{passkeyBusy ? t('profile.processing') : t('profile.add_passkey_btn')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="form-card">
|
||||
<div className="form-header">
|
||||
<BarChart2 size={24} className="form-icon" />
|
||||
<div>
|
||||
<h2>{t('profile.stats_title')}</h2>
|
||||
<p className="stats-subtitle">{t('profile.stats_subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(statsTotals || profile) && (
|
||||
<div className="stats-kpi-grid">
|
||||
<KpiCard
|
||||
icon={<BookOpen size={20} />}
|
||||
label={t('profile.stats_logbooks')}
|
||||
value={String(logbookCount)}
|
||||
/>
|
||||
{statsTotals && (
|
||||
<>
|
||||
<KpiCard
|
||||
icon={<Anchor size={20} />}
|
||||
label={t('stats.travel_days')}
|
||||
value={String(statsTotals.travelDayCount)}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Gauge size={20} />}
|
||||
label={t('stats.total_distance')}
|
||||
value={formatNm(statsTotals.totalDistanceNm)}
|
||||
unit={t('stats.unit_nm')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Sailboat size={20} />}
|
||||
label={t('stats.sail_distance')}
|
||||
value={formatNm(statsTotals.sailDistanceNm)}
|
||||
unit={t('stats.unit_nm')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Gauge size={20} />}
|
||||
label={t('stats.motor_distance')}
|
||||
value={formatNm(statsTotals.motorDistanceNm)}
|
||||
unit={t('stats.unit_nm')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Timer size={20} />}
|
||||
label={t('stats.motor_hours_total')}
|
||||
value={formatHours(statsTotals.totalMotorHours)}
|
||||
unit={t('stats.unit_h')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Share2 size={20} />}
|
||||
label={t('profile.stats_shared_logbooks')}
|
||||
value={String(sharedLogbookCount)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<KpiCard
|
||||
icon={<Calendar size={20} />}
|
||||
label={t('profile.stats_account_since')}
|
||||
value={accountAgeLabel}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<AccountDangerZone className="mt-6" />
|
||||
</>
|
||||
) : null}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -269,7 +269,101 @@
|
||||
"role_crew": "Crew-Zugang",
|
||||
"role_crew_hint": "Eingeladenes Logbuch — du kannst als Crew mitarbeiten und signieren",
|
||||
"role_read": "Nur Lesen",
|
||||
"role_read_hint": "Geteiltes Logbuch — nur Ansicht, keine Bearbeitung"
|
||||
"role_read_hint": "Geteiltes Logbuch — nur Ansicht, keine Bearbeitung",
|
||||
"open_profile": "Profil von {{name}} öffnen",
|
||||
"edit_title": "Logbuch umbenennen",
|
||||
"edit_placeholder": "Neuer Name des Logbuchs",
|
||||
"edit_success": "Logbuch erfolgreich umbenannt",
|
||||
"edit_btn": "Umbenennen"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Benutzerprofil",
|
||||
"subtitle": "Konto, Passkeys und Statistiken für {{name}}",
|
||||
"back": "Zurück zum Dashboard",
|
||||
"loading": "Profil wird geladen…",
|
||||
"load_error": "Profil konnte nicht geladen werden.",
|
||||
"copy_failed": "Kopieren fehlgeschlagen.",
|
||||
"processing": "Wird verarbeitet…",
|
||||
"identity_title": "Konto-Identität",
|
||||
"username": "Benutzername",
|
||||
"user_id": "Benutzer-ID",
|
||||
"copy_user_id": "Benutzer-ID kopieren",
|
||||
"account_since": "Konto seit",
|
||||
"prf_status": "Passkey-Schlüsselableitung (PRF)",
|
||||
"prf_active": "Aktiv",
|
||||
"prf_inactive": "Nicht eingerichtet",
|
||||
"passkeys_title": "Passkeys",
|
||||
"passkeys_desc": "Registriere auf jedem Gerät einen eigenen Passkey. So kannst du dich auch nach einem Plattformwechsel anmelden.",
|
||||
"passkeys_empty": "Keine Passkeys gefunden.",
|
||||
"add_passkey_btn": "Neuen Passkey hinzufügen",
|
||||
"add_passkey_success": "Passkey erfolgreich hinzugefügt.",
|
||||
"add_passkey_failed": "Passkey konnte nicht hinzugefügt werden.",
|
||||
"remove_passkey_btn": "Passkey entfernen",
|
||||
"remove_passkey_last_title": "Letzter Passkey",
|
||||
"remove_passkey_last_desc": "Der einzige Passkey kann nicht entfernt werden, ohne den Zugang zu deinem Konto zu verlieren. Um das Konto vollständig zu löschen, nutze die Gefahrenzone am Ende dieser Seite.",
|
||||
"remove_passkey_failed": "Passkey konnte nicht entfernt werden.",
|
||||
"remove_passkey_confirm_title": "Passkey entfernen?",
|
||||
"remove_passkey_confirm_desc": "Dieses Gerät kann sich danach nicht mehr mit diesem Passkey anmelden.",
|
||||
"remove_passkey_confirm_yes": "Entfernen",
|
||||
"remove_passkey_confirm_no": "Abbrechen",
|
||||
"pin_title": "Lokaler PIN",
|
||||
"pin_status": "Status",
|
||||
"pin_active": "Aktiv auf diesem Gerät",
|
||||
"pin_inactive": "Nicht eingerichtet",
|
||||
"pin_confirm_label": "PIN bestätigen",
|
||||
"pin_confirm_placeholder": "PIN erneut eingeben",
|
||||
"pin_set_btn": "PIN einrichten",
|
||||
"pin_change_btn": "PIN ändern",
|
||||
"pin_remove_btn": "PIN entfernen",
|
||||
"pin_saved": "PIN gespeichert.",
|
||||
"pin_save_failed": "PIN konnte nicht gespeichert werden.",
|
||||
"pin_mismatch": "Die PIN-Eingaben stimmen nicht überein.",
|
||||
"pin_length_error": "Die PIN muss mindestens 4 Zeichen haben.",
|
||||
"pin_no_session": "Sitzung abgelaufen — bitte erneut anmelden.",
|
||||
"remove_pin_confirm_title": "PIN entfernen?",
|
||||
"remove_pin_confirm_desc": "Du musst dich auf diesem Gerät wieder mit Passkey oder Wiederherstellungsschlüssel anmelden.",
|
||||
"remove_pin_confirm_yes": "PIN entfernen",
|
||||
"remove_pin_confirm_no": "Abbrechen",
|
||||
"security_title": "Sicherheits-Checkliste",
|
||||
"security_desc": "Überblick über die wichtigsten Schutzmechanismen deines Kontos.",
|
||||
"security_passkeys_ok": "Mindestens ein Passkey registriert",
|
||||
"security_passkeys_missing": "Kein Passkey registriert",
|
||||
"security_prf_ok": "PRF-Schlüsselableitung aktiv",
|
||||
"security_prf_missing": "PRF nicht eingerichtet",
|
||||
"security_pin_ok": "Lokaler PIN auf diesem Gerät",
|
||||
"security_pin_missing": "Kein lokaler PIN",
|
||||
"security_recovery_ok": "Wiederherstellungsschlüssel eingerichtet",
|
||||
"security_recovery_hint": "Die 12 Wörter wurden bei der Registrierung angezeigt. Bewahre sie offline und getrennt vom Gerät auf. Du kannst unten einen neuen Schlüssel erstellen — der alte wird dann ungültig.",
|
||||
"recovery_rotate_btn": "Neuen Wiederherstellungsschlüssel erstellen",
|
||||
"recovery_rotate_confirm_title": "Neuen Wiederherstellungsschlüssel erstellen?",
|
||||
"recovery_rotate_confirm_desc": "Der bisherige 12-Wörter-Schlüssel wird sofort ungültig. Stelle sicher, dass du den neuen Schlüssel sicher aufbewahrst, bevor du fortfährst.",
|
||||
"recovery_rotate_confirm_yes": "Neuen Schlüssel erstellen",
|
||||
"recovery_rotate_confirm_no": "Abbrechen",
|
||||
"recovery_rotate_new_warning": "WICHTIG: Schreib diese 12 Wörter auf und bewahre sie offline auf. Der bisherige Wiederherstellungsschlüssel ist ab sofort ungültig.",
|
||||
"recovery_rotate_failed": "Wiederherstellungsschlüssel konnte nicht erstellt werden.",
|
||||
"recovery_rotate_no_session": "Verschlüsselungssitzung abgelaufen — bitte abmelden und erneut anmelden, dann erneut versuchen.",
|
||||
"device_title": "Dieses Gerät",
|
||||
"device_desc": "Lokaler Cache, Sync-Status und Schnell-Login auf diesem Browser.",
|
||||
"device_sync_pending": "{{count}} ausstehende Sync-Einträge",
|
||||
"device_sync_ok": "Alle lokalen Änderungen synchronisiert",
|
||||
"device_remembered": "Account für Schnell-Login auf diesem Gerät gespeichert",
|
||||
"device_not_remembered": "Account nicht in der Schnell-Login-Liste",
|
||||
"device_forget_btn": "Account auf diesem Gerät vergessen",
|
||||
"device_forget_confirm_title": "Schnell-Login entfernen?",
|
||||
"device_forget_confirm_desc": "Der Account verschwindet aus der Schnell-Login-Liste auf diesem Gerät. Deine Session und lokalen Logbücher bleiben erhalten.",
|
||||
"device_forget_confirm_yes": "Entfernen",
|
||||
"device_forget_confirm_no": "Abbrechen",
|
||||
"passkey_label": "Name für neuen Passkey (optional)",
|
||||
"passkey_label_placeholder": "z. B. MacBook, iPhone",
|
||||
"passkey_rename_btn": "Name speichern",
|
||||
"passkey_rename_success": "Passkey-Name gespeichert.",
|
||||
"passkey_rename_failed": "Passkey-Name konnte nicht gespeichert werden.",
|
||||
"passkey_unnamed": "Unbenannter Passkey",
|
||||
"stats_title": "Statistiken",
|
||||
"stats_subtitle": "Über alle deine Logbücher auf diesem Gerät",
|
||||
"stats_logbooks": "Logbücher",
|
||||
"stats_account_since": "Konto seit",
|
||||
"stats_shared_logbooks": "Geteilte Logbücher"
|
||||
},
|
||||
"crew": {
|
||||
"title": "Skipper- & Crew-Profile",
|
||||
@@ -344,6 +438,7 @@
|
||||
"delete_account_confirm_yes": "Ja, Konto und alle Daten löschen",
|
||||
"delete_account_confirm_no": "Abbrechen",
|
||||
"delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuche es erneut.",
|
||||
"delete_backup_hint": "Tipp: Erstelle vor dem Löschen Backups deiner Logbücher (.daagbok.json) in den Einstellungen jedes Logbuchs.",
|
||||
"deleting_account": "Konto wird gelöscht…",
|
||||
"tour_title": "App-Tour",
|
||||
"tour_desc": "Lass dich erneut durch die wichtigsten Bereiche der App führen.",
|
||||
|
||||
@@ -269,7 +269,101 @@
|
||||
"role_crew": "Crew access",
|
||||
"role_crew_hint": "Invited logbook — you can collaborate and sign as crew",
|
||||
"role_read": "Read only",
|
||||
"role_read_hint": "Shared logbook — view only, no editing"
|
||||
"role_read_hint": "Shared logbook — view only, no editing",
|
||||
"open_profile": "Open profile for {{name}}",
|
||||
"edit_title": "Rename Logbook",
|
||||
"edit_placeholder": "New name of the logbook",
|
||||
"edit_success": "Logbook renamed successfully",
|
||||
"edit_btn": "Rename"
|
||||
},
|
||||
"profile": {
|
||||
"title": "User profile",
|
||||
"subtitle": "Account, passkeys and statistics for {{name}}",
|
||||
"back": "Back to dashboard",
|
||||
"loading": "Loading profile…",
|
||||
"load_error": "Could not load profile.",
|
||||
"copy_failed": "Copy failed.",
|
||||
"processing": "Processing…",
|
||||
"identity_title": "Account identity",
|
||||
"username": "Username",
|
||||
"user_id": "User ID",
|
||||
"copy_user_id": "Copy user ID",
|
||||
"account_since": "Account since",
|
||||
"prf_status": "Passkey key derivation (PRF)",
|
||||
"prf_active": "Active",
|
||||
"prf_inactive": "Not configured",
|
||||
"passkeys_title": "Passkeys",
|
||||
"passkeys_desc": "Register a passkey on each device you use. This helps when switching platforms or browsers.",
|
||||
"passkeys_empty": "No passkeys found.",
|
||||
"add_passkey_btn": "Add new passkey",
|
||||
"add_passkey_success": "Passkey added successfully.",
|
||||
"add_passkey_failed": "Could not add passkey.",
|
||||
"remove_passkey_btn": "Remove passkey",
|
||||
"remove_passkey_last_title": "Last passkey",
|
||||
"remove_passkey_last_desc": "The only passkey cannot be removed without losing access to your account. To delete the account entirely, use the danger zone at the bottom of this page.",
|
||||
"remove_passkey_failed": "Could not remove passkey.",
|
||||
"remove_passkey_confirm_title": "Remove passkey?",
|
||||
"remove_passkey_confirm_desc": "This device will no longer be able to sign in with this passkey.",
|
||||
"remove_passkey_confirm_yes": "Remove",
|
||||
"remove_passkey_confirm_no": "Cancel",
|
||||
"pin_title": "Local PIN",
|
||||
"pin_status": "Status",
|
||||
"pin_active": "Active on this device",
|
||||
"pin_inactive": "Not configured",
|
||||
"pin_confirm_label": "Confirm PIN",
|
||||
"pin_confirm_placeholder": "Re-enter PIN",
|
||||
"pin_set_btn": "Set PIN",
|
||||
"pin_change_btn": "Change PIN",
|
||||
"pin_remove_btn": "Remove PIN",
|
||||
"pin_saved": "PIN saved.",
|
||||
"pin_save_failed": "Could not save PIN.",
|
||||
"pin_mismatch": "PIN entries do not match.",
|
||||
"pin_length_error": "PIN must be at least 4 characters.",
|
||||
"pin_no_session": "Session expired — please sign in again.",
|
||||
"remove_pin_confirm_title": "Remove PIN?",
|
||||
"remove_pin_confirm_desc": "You will need to sign in on this device with passkey or recovery phrase again.",
|
||||
"remove_pin_confirm_yes": "Remove PIN",
|
||||
"remove_pin_confirm_no": "Cancel",
|
||||
"security_title": "Security checklist",
|
||||
"security_desc": "Overview of the most important protections for your account.",
|
||||
"security_passkeys_ok": "At least one passkey registered",
|
||||
"security_passkeys_missing": "No passkey registered",
|
||||
"security_prf_ok": "PRF key derivation active",
|
||||
"security_prf_missing": "PRF not configured",
|
||||
"security_pin_ok": "Local PIN on this device",
|
||||
"security_pin_missing": "No local PIN",
|
||||
"security_recovery_ok": "Recovery phrase configured",
|
||||
"security_recovery_hint": "The 12 words were shown at registration. Store them offline and separately from this device. You can create a new phrase below — the old one will then be invalidated.",
|
||||
"recovery_rotate_btn": "Create new recovery phrase",
|
||||
"recovery_rotate_confirm_title": "Create new recovery phrase?",
|
||||
"recovery_rotate_confirm_desc": "Your previous 12-word phrase will be invalidated immediately. Make sure you can store the new phrase securely before continuing.",
|
||||
"recovery_rotate_confirm_yes": "Create new phrase",
|
||||
"recovery_rotate_confirm_no": "Cancel",
|
||||
"recovery_rotate_new_warning": "IMPORTANT: Write down these 12 words and store them offline. Your previous recovery phrase is no longer valid.",
|
||||
"recovery_rotate_failed": "Could not create a new recovery phrase.",
|
||||
"recovery_rotate_no_session": "Encryption session expired — please sign out and sign in again, then retry.",
|
||||
"device_title": "This device",
|
||||
"device_desc": "Local cache, sync status, and quick login on this browser.",
|
||||
"device_sync_pending": "{{count}} pending sync items",
|
||||
"device_sync_ok": "All local changes synced",
|
||||
"device_remembered": "Account saved for quick login on this device",
|
||||
"device_not_remembered": "Account not in the quick-login list",
|
||||
"device_forget_btn": "Forget account on this device",
|
||||
"device_forget_confirm_title": "Remove quick login?",
|
||||
"device_forget_confirm_desc": "The account will be removed from the quick-login list on this device. Your session and local logbooks stay on this device.",
|
||||
"device_forget_confirm_yes": "Remove",
|
||||
"device_forget_confirm_no": "Cancel",
|
||||
"passkey_label": "Name for new passkey (optional)",
|
||||
"passkey_label_placeholder": "e.g. MacBook, iPhone",
|
||||
"passkey_rename_btn": "Save name",
|
||||
"passkey_rename_success": "Passkey name saved.",
|
||||
"passkey_rename_failed": "Could not save passkey name.",
|
||||
"passkey_unnamed": "Unnamed passkey",
|
||||
"stats_title": "Statistics",
|
||||
"stats_subtitle": "Across all your logbooks on this device",
|
||||
"stats_logbooks": "Logbooks",
|
||||
"stats_account_since": "Account since",
|
||||
"stats_shared_logbooks": "Shared logbooks"
|
||||
},
|
||||
"crew": {
|
||||
"title": "Skipper & Crew Profiles",
|
||||
@@ -344,6 +438,7 @@
|
||||
"delete_account_confirm_yes": "Yes, Delete Account and All Data",
|
||||
"delete_account_confirm_no": "Cancel",
|
||||
"delete_account_failed": "Failed to delete account. Please try again.",
|
||||
"delete_backup_hint": "Tip: Before deleting, create backups of your logbooks (.daagbok.json) in each logbook's settings.",
|
||||
"deleting_account": "Deleting account…",
|
||||
"tour_title": "App tour",
|
||||
"tour_desc": "Take a guided walkthrough of the main areas of the app again.",
|
||||
|
||||
@@ -25,7 +25,16 @@ export const PlausibleEvents = {
|
||||
DEMO_OPENED: 'Demo Opened',
|
||||
PUSH_ENABLED: 'Push Enabled',
|
||||
PUSH_DISABLED: 'Push Disabled',
|
||||
FOOTER_LINK_CLICKED: 'Footer Link Clicked'
|
||||
FOOTER_LINK_CLICKED: 'Footer Link Clicked',
|
||||
PROFILE_OPENED: 'Profile Opened',
|
||||
PASSKEY_ADDED: 'Passkey Added',
|
||||
PASSKEY_REMOVED: 'Passkey Removed',
|
||||
PASSKEY_RENAMED: 'Passkey Renamed',
|
||||
LAST_PASSKEY_REMOVE_HINTED: 'Last Passkey Remove Hinted',
|
||||
LOCAL_PIN_SET: 'Local PIN Set',
|
||||
LOCAL_PIN_REMOVED: 'Local PIN Removed',
|
||||
DEVICE_FORGOTTEN: 'Device Forgotten',
|
||||
RECOVERY_ROTATED: 'Recovery Rotated'
|
||||
} as const
|
||||
|
||||
export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
|
||||
|
||||
@@ -543,3 +543,137 @@ export async function deleteAccount(): Promise<boolean> {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export interface UserProfileCredential {
|
||||
id: string
|
||||
label: string | null
|
||||
credentialIdPreview: string
|
||||
transports: string[]
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
userId: string
|
||||
username: string
|
||||
createdAt: string
|
||||
hasPrfEncryption: boolean
|
||||
credentials: UserProfileCredential[]
|
||||
serverMeta: {
|
||||
ownedLogbookCount: number
|
||||
collaborationCount: number
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchUserProfile(): Promise<UserProfile> {
|
||||
return apiJson<UserProfile>(`${API_BASE}/profile`)
|
||||
}
|
||||
|
||||
async function enrollPrfFromMasterKey(masterKey: ArrayBuffer, prfFirst: ArrayBuffer): Promise<void> {
|
||||
const prfKey = await deriveKeyFromPrf(prfFirst)
|
||||
const encryptedPrf = await encryptBuffer(masterKey, prfKey)
|
||||
await apiJson(`${API_BASE}/enroll-prf`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
encryptedMasterKeyPrf: encryptedPrf.ciphertext,
|
||||
encryptedMasterKeyPrfIv: encryptedPrf.iv,
|
||||
encryptedMasterKeyPrfTag: encryptedPrf.tag
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function addPasskey(label?: string): Promise<void> {
|
||||
await reauthWithPasskey()
|
||||
|
||||
const options = await apiJson<any>(`${API_BASE}/add-credential-options`, {
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
if (!options.extensions) {
|
||||
options.extensions = {}
|
||||
}
|
||||
options.extensions.prf = { eval: { first: PRF_SALT.buffer } }
|
||||
|
||||
let credentialResponse
|
||||
const prfRequested = !!options.extensions?.prf
|
||||
try {
|
||||
credentialResponse = await startRegistration({ optionsJSON: options })
|
||||
} catch (err: any) {
|
||||
const isOptionError = err.name === 'NotSupportedError' ||
|
||||
err.message?.toLowerCase().includes('options') ||
|
||||
err.message?.toLowerCase().includes('process') ||
|
||||
err.message?.toLowerCase().includes('unable to')
|
||||
if (prfRequested && isOptionError) {
|
||||
console.warn('Add passkey with PRF extension failed, retrying without PRF:', err)
|
||||
if (options.extensions) {
|
||||
delete options.extensions.prf
|
||||
}
|
||||
credentialResponse = await startRegistration({ optionsJSON: options })
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
await apiJson(`${API_BASE}/add-credential-verify`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
credentialResponse,
|
||||
challenge: options.challenge,
|
||||
...(label?.trim() ? { label: label.trim() } : {})
|
||||
})
|
||||
})
|
||||
|
||||
const masterKey = getActiveMasterKey()
|
||||
const prfFirstBuffer = extractPrfFirst(credentialResponse.clientExtensionResults || {})
|
||||
if (masterKey && prfFirstBuffer) {
|
||||
try {
|
||||
await enrollPrfFromMasterKey(masterKey, prfFirstBuffer)
|
||||
} catch (err) {
|
||||
console.error('Failed to enroll PRF after adding passkey:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function removePasskey(credentialDbId: string): Promise<void> {
|
||||
await reauthWithPasskey()
|
||||
|
||||
const res = await apiFetch(`${API_BASE}/credentials/${credentialDbId}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body.error || 'Failed to remove passkey')
|
||||
}
|
||||
}
|
||||
|
||||
export async function renamePasskey(credentialDbId: string, label: string): Promise<void> {
|
||||
await reauthWithPasskey()
|
||||
|
||||
await apiJson(`${API_BASE}/credentials/${credentialDbId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ label })
|
||||
})
|
||||
}
|
||||
|
||||
export async function rotateRecoveryPhrase(): Promise<string> {
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) {
|
||||
throw new Error('NO_ACTIVE_MASTER_KEY')
|
||||
}
|
||||
|
||||
await reauthWithPasskey()
|
||||
|
||||
const recoveryPhrase = generateRecoveryPhrase()
|
||||
const recoveryKey = await deriveKeyFromPhrase(recoveryPhrase)
|
||||
const encryptedRecovery = await encryptBuffer(masterKey, recoveryKey)
|
||||
|
||||
await apiJson(`${API_BASE}/rotate-recovery`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
encryptedMasterKeyRec: encryptedRecovery.ciphertext,
|
||||
encryptedMasterKeyRecIv: encryptedRecovery.iv,
|
||||
encryptedMasterKeyRecTag: encryptedRecovery.tag
|
||||
})
|
||||
})
|
||||
|
||||
return recoveryPhrase
|
||||
}
|
||||
|
||||
@@ -322,3 +322,64 @@ export async function deleteLogbook(id: string): Promise<void> {
|
||||
await deleteLocalLogbookCache(id)
|
||||
trackPlausibleEvent(PlausibleEvents.LOGBOOK_DELETED)
|
||||
}
|
||||
|
||||
// Update the title of a logbook. Encrypts the title and updates locally + on server
|
||||
export async function updateLogbookTitle(id: string, newTitle: string): Promise<void> {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId) {
|
||||
throw new Error('User not authenticated')
|
||||
}
|
||||
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) {
|
||||
throw new Error('Master key not found. User must log in.')
|
||||
}
|
||||
|
||||
const logbookKey = await getLogbookKey(id) || masterKey
|
||||
|
||||
// E2E Encrypt the new title using the Logbook Key (or master key fallback)
|
||||
const encrypted = await encryptJson(newTitle, logbookKey)
|
||||
const encryptedTitleStr = JSON.stringify(encrypted)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const payloadData = {
|
||||
encryptedTitle: encryptedTitleStr
|
||||
}
|
||||
|
||||
if (navigator.onLine) {
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payloadData)
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
// Update local IndexedDB cache as synced
|
||||
await db.logbooks.update(id, {
|
||||
encryptedTitle: encryptedTitleStr,
|
||||
updatedAt: now,
|
||||
isSynced: 1
|
||||
})
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to update logbook on server, saving locally instead:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// If offline or request failed, store locally as unsynced and add to queue
|
||||
await db.logbooks.update(id, {
|
||||
encryptedTitle: encryptedTitleStr,
|
||||
updatedAt: now,
|
||||
isSynced: 0
|
||||
})
|
||||
|
||||
await db.syncQueue.put({
|
||||
action: 'update',
|
||||
type: 'logbook',
|
||||
payloadId: id,
|
||||
logbookId: id,
|
||||
data: JSON.stringify(payloadData),
|
||||
updatedAt: now
|
||||
})
|
||||
}
|
||||
|
||||
@@ -40,12 +40,23 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
|
||||
| Push Enabled | Crew-Änderungs-Push aktiviert (`PushNotificationSettings.tsx`) | — |
|
||||
| Push Disabled | Crew-Änderungs-Push deaktiviert (`PushNotificationSettings.tsx`) | — |
|
||||
| Footer Link Clicked | Klick auf Autoren-Link im App-Footer (`AppFooter.tsx`) | — |
|
||||
| Profile Opened | Profilseite geöffnet (`UserProfilePage.tsx`, einmal pro Mount) | — |
|
||||
| Passkey Added | Passkey erfolgreich registriert (`UserProfilePage.tsx`) | `labeled`: `true` \| `false` (optionaler Name gesetzt) |
|
||||
| Passkey Removed | Passkey entfernt, mindestens ein Key verbleibt (`UserProfilePage.tsx`) | — |
|
||||
| Passkey Renamed | Passkey-Name gespeichert (`UserProfilePage.tsx`) | — |
|
||||
| Last Passkey Remove Hinted | Löschen des einzigen Passkeys abgebrochen — Hinweisdialog zur Kontolöschung (`UserProfilePage.tsx`) | — |
|
||||
| Local PIN Set | Lokaler PIN gesetzt oder geändert (`UserProfilePage.tsx`) | `action`: `set` \| `change` |
|
||||
| Local PIN Removed | Lokaler PIN entfernt (`UserProfilePage.tsx`) | — |
|
||||
| Device Forgotten | Account aus Schnell-Login-Liste dieses Geräts entfernt (`UserProfilePage.tsx`) | — |
|
||||
| Recovery Rotated | Neuer 12-Wörter-Wiederherstellungsschlüssel erstellt (`UserProfilePage.tsx`) | — |
|
||||
|
||||
## Bewusst nicht getrackt
|
||||
|
||||
- **Demo-Logbuch:** Beim automatischen Seed (`demoLogbook.ts`) werden keine Events ausgelöst — nur echte Nutzeraktionen zählen.
|
||||
- **Manuelle Signaturen:** Nur Passkey-Signaturen lösen `Entry Signed` aus.
|
||||
- **PII:** Keine Inhalte aus verschlüsselten Logbüchern in Properties.
|
||||
- **Profil-KPIs:** Statistik-Karten und User-ID-Kopieren werden nicht getrackt (reine Anzeige bzw. zu granular).
|
||||
- **Kontolöschung:** `Account Deleted` bleibt in `auth.ts` — unabhängig davon, ob die Gefahrenzone auf der Profilseite oder früher in den Einstellungen genutzt wurde.
|
||||
|
||||
## Typische Funnels (Plausible Goals)
|
||||
|
||||
@@ -57,6 +68,7 @@ Empfohlene Goal-Ketten für Auswertung:
|
||||
4. **Öffentliche Freigabe:** Logbook Shared → Public Link Opened
|
||||
5. **Export:** Travel Day Saved → PDF Exported / CSV Exported
|
||||
6. **Datensicherung:** Backup Exported → Backup Restored
|
||||
7. **Kontosicherheit:** Profile Opened → Passkey Added / Local PIN Set / Recovery Rotated; Last Passkey Remove Hinted → Account Deleted (selten, aber aussagekräftig)
|
||||
|
||||
## Entwicklung
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ model Credential {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
credentialId String @unique
|
||||
label String?
|
||||
publicKey Bytes
|
||||
counter BigInt
|
||||
transports String[] // WebAuthn transports list
|
||||
|
||||
@@ -22,8 +22,22 @@ const rpID = process.env.RP_ID || 'localhost'
|
||||
const origin = process.env.ORIGIN || 'http://localhost:5173'
|
||||
|
||||
const registrationChallenges = new Map<string, string>()
|
||||
/** WebAuthn registration challenges for add-credential flow: challenge -> userId */
|
||||
const addCredentialChallenges = new Map<string, string>()
|
||||
const activeChallenges = new Set<string>()
|
||||
|
||||
function previewCredentialId(credentialId: string): string {
|
||||
if (credentialId.length <= 16) return credentialId
|
||||
return `${credentialId.slice(0, 8)}…${credentialId.slice(-8)}`
|
||||
}
|
||||
|
||||
function normalizeCredentialLabel(label: unknown): string | null {
|
||||
if (typeof label !== 'string') return null
|
||||
const trimmed = label.trim()
|
||||
if (!trimmed) return null
|
||||
return trimmed.slice(0, 64)
|
||||
}
|
||||
|
||||
router.post('/register-options', async (req, res) => {
|
||||
try {
|
||||
const { username } = req.body
|
||||
@@ -381,4 +395,259 @@ router.post('/enroll-prf', requireReauth, async (req: any, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/rotate-recovery', requireReauth, async (req: any, res) => {
|
||||
try {
|
||||
const { encryptedMasterKeyRec, encryptedMasterKeyRecIv, encryptedMasterKeyRecTag } = req.body
|
||||
if (!encryptedMasterKeyRec || !encryptedMasterKeyRecIv || !encryptedMasterKeyRecTag) {
|
||||
return res.status(400).json({ error: 'Missing required recovery key fields' })
|
||||
}
|
||||
|
||||
if (
|
||||
typeof encryptedMasterKeyRec !== 'string' ||
|
||||
typeof encryptedMasterKeyRecIv !== 'string' ||
|
||||
typeof encryptedMasterKeyRecTag !== 'string'
|
||||
) {
|
||||
return res.status(400).json({ error: 'Invalid recovery key fields format' })
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: req.userId },
|
||||
data: {
|
||||
encryptedMasterKeyRec,
|
||||
encryptedMasterKeyRecIv,
|
||||
encryptedMasterKeyRecTag
|
||||
}
|
||||
})
|
||||
|
||||
return res.json({ success: true })
|
||||
} catch (error: any) {
|
||||
console.error('Error rotating recovery key:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/profile', requireUser, async (req: any, res) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.userId },
|
||||
include: {
|
||||
credentials: {
|
||||
orderBy: { id: 'asc' }
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
logbooks: true,
|
||||
collaborations: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' })
|
||||
}
|
||||
|
||||
return res.json({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
hasPrfEncryption: user.encryptedMasterKeyPrf != null,
|
||||
credentials: user.credentials.map((cred) => ({
|
||||
id: cred.id,
|
||||
label: cred.label,
|
||||
credentialIdPreview: previewCredentialId(cred.credentialId),
|
||||
transports: cred.transports
|
||||
})),
|
||||
serverMeta: {
|
||||
ownedLogbookCount: user._count.logbooks,
|
||||
collaborationCount: user._count.collaborations
|
||||
}
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching user profile:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/add-credential-options', requireReauth, async (req: any, res) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.userId },
|
||||
include: { credentials: true }
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' })
|
||||
}
|
||||
|
||||
const userID = Buffer.from(user.username, 'utf8').toString('base64url')
|
||||
const excludeCredentials = user.credentials.map((cred) => ({
|
||||
id: Buffer.from(cred.credentialId, 'base64url'),
|
||||
type: 'public-key' as const,
|
||||
transports: cred.transports as any[]
|
||||
}))
|
||||
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName,
|
||||
rpID,
|
||||
userID,
|
||||
userName: user.username,
|
||||
userDisplayName: user.username,
|
||||
attestationType: 'none',
|
||||
authenticatorSelection: {
|
||||
residentKey: 'required',
|
||||
userVerification: 'preferred'
|
||||
},
|
||||
supportedAlgorithmIDs: [-7, -257],
|
||||
excludeCredentials
|
||||
})
|
||||
|
||||
addCredentialChallenges.set(options.challenge, req.userId)
|
||||
|
||||
return res.json(options)
|
||||
} catch (error: any) {
|
||||
console.error('Error generating add-credential options:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/add-credential-verify', requireReauth, async (req: any, res) => {
|
||||
try {
|
||||
const { credentialResponse, challenge } = req.body
|
||||
if (!credentialResponse || !challenge) {
|
||||
return res.status(400).json({ error: 'credentialResponse and challenge are required' })
|
||||
}
|
||||
|
||||
const label = normalizeCredentialLabel(req.body.label)
|
||||
|
||||
const challengeUserId = addCredentialChallenges.get(challenge)
|
||||
if (!challengeUserId) {
|
||||
return res.status(400).json({ error: 'Challenge not found or expired' })
|
||||
}
|
||||
|
||||
if (challengeUserId !== req.userId) {
|
||||
return res.status(403).json({ error: 'Challenge does not belong to this account' })
|
||||
}
|
||||
|
||||
// Single-use: invalidate before verification so failed attempts cannot be retried
|
||||
addCredentialChallenges.delete(challenge)
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.userId }
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' })
|
||||
}
|
||||
|
||||
const verification = await verifyRegistrationResponse({
|
||||
response: credentialResponse,
|
||||
expectedChallenge: challenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpID
|
||||
})
|
||||
|
||||
if (!verification.verified || !verification.registrationInfo) {
|
||||
return res.status(400).json({ error: 'WebAuthn verification failed' })
|
||||
}
|
||||
|
||||
const { credentialID, credentialPublicKey, counter } = verification.registrationInfo
|
||||
const credentialId = Buffer.from(credentialID).toString('base64url')
|
||||
|
||||
const existing = await prisma.credential.findUnique({
|
||||
where: { credentialId }
|
||||
})
|
||||
if (existing) {
|
||||
return res.status(400).json({ error: 'Credential already registered' })
|
||||
}
|
||||
|
||||
const credential = await prisma.credential.create({
|
||||
data: {
|
||||
userId: req.userId,
|
||||
credentialId,
|
||||
label,
|
||||
publicKey: Buffer.from(credentialPublicKey),
|
||||
counter: BigInt(counter),
|
||||
transports: credentialResponse.response.transports || []
|
||||
}
|
||||
})
|
||||
|
||||
return res.json({
|
||||
verified: true,
|
||||
credential: {
|
||||
id: credential.id,
|
||||
label: credential.label,
|
||||
credentialIdPreview: previewCredentialId(credential.credentialId),
|
||||
transports: credential.transports
|
||||
}
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Error verifying add-credential response:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
router.patch('/credentials/:id', requireReauth, async (req: any, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const label = normalizeCredentialLabel(req.body?.label)
|
||||
|
||||
const credential = await prisma.credential.findUnique({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
if (!credential || credential.userId !== req.userId) {
|
||||
return res.status(404).json({ error: 'Credential not found' })
|
||||
}
|
||||
|
||||
const updated = await prisma.credential.update({
|
||||
where: { id },
|
||||
data: { label }
|
||||
})
|
||||
|
||||
return res.json({
|
||||
credential: {
|
||||
id: updated.id,
|
||||
label: updated.label,
|
||||
credentialIdPreview: previewCredentialId(updated.credentialId),
|
||||
transports: updated.transports
|
||||
}
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Error updating credential label:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/credentials/:id', requireReauth, async (req: any, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
const credential = await prisma.credential.findUnique({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
if (!credential || credential.userId !== req.userId) {
|
||||
return res.status(404).json({ error: 'Credential not found' })
|
||||
}
|
||||
|
||||
const credentialCount = await prisma.credential.count({
|
||||
where: { userId: req.userId }
|
||||
})
|
||||
|
||||
if (credentialCount <= 1) {
|
||||
return res.status(400).json({ error: 'Cannot remove the last passkey' })
|
||||
}
|
||||
|
||||
await prisma.credential.delete({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
return res.json({ success: true })
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting credential:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -131,4 +131,41 @@ router.delete('/:id', async (req: any, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 5. Update a logbook title
|
||||
router.put('/:id', async (req: any, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const { encryptedTitle } = req.body
|
||||
|
||||
if (!encryptedTitle) {
|
||||
return res.status(400).json({ error: 'encryptedTitle is required' })
|
||||
}
|
||||
|
||||
const logbook = await prisma.logbook.findUnique({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
if (!logbook) {
|
||||
return res.status(404).json({ error: 'Logbook not found' })
|
||||
}
|
||||
|
||||
if (logbook.userId !== req.userId) {
|
||||
return res.status(403).json({ error: 'Forbidden: Access denied' })
|
||||
}
|
||||
|
||||
const updatedLogbook = await prisma.logbook.update({
|
||||
where: { id },
|
||||
data: {
|
||||
encryptedTitle,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
})
|
||||
|
||||
return res.json(updatedLogbook)
|
||||
} catch (error: any) {
|
||||
console.error('Error updating logbook:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -46,7 +46,7 @@ router.post('/push', async (req: any, res) => {
|
||||
// Authorize: Check if logbook belongs to user
|
||||
// Exception: If action is create logbook, the logbook might not exist yet,
|
||||
// so we authorize based on user creating a logbook with their userId.
|
||||
if (type === 'logbook' && action === 'create') {
|
||||
if (type === 'logbook' && (action === 'create' || action === 'update')) {
|
||||
const existing = await prisma.logbook.findUnique({
|
||||
where: { id: logbookId }
|
||||
})
|
||||
@@ -69,9 +69,9 @@ router.post('/push', async (req: any, res) => {
|
||||
},
|
||||
update: {
|
||||
encryptedTitle: parsed.encryptedTitle,
|
||||
encryptedKey: parsed.encryptedKey || null,
|
||||
iv: parsed.iv || null,
|
||||
tag: parsed.tag || null,
|
||||
...(parsed.encryptedKey !== undefined ? { encryptedKey: parsed.encryptedKey } : {}),
|
||||
...(parsed.iv !== undefined ? { iv: parsed.iv } : {}),
|
||||
...(parsed.tag !== undefined ? { tag: parsed.tag } : {}),
|
||||
updatedAt: itemUpdatedAt
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user