diff --git a/client/src/App.css b/client/src/App.css index c1a8522..05b0c43 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -742,8 +742,21 @@ html.scheme-dark .themed-select-option.is-selected { background: rgba(148, 163, 184, 0.08); border: 1px solid rgba(148, 163, 184, 0.18); color: var(--app-text-muted); - cursor: default; + cursor: pointer; user-select: none; + font-family: inherit; + transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; +} + +.skipper-badge:hover { + background: rgba(148, 163, 184, 0.14); + border-color: rgba(148, 163, 184, 0.28); + color: var(--app-text); +} + +.skipper-badge:focus-visible { + outline: 2px solid var(--app-accent, #38bdf8); + outline-offset: 2px; } .skipper-badge__name { @@ -800,6 +813,128 @@ 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; +} + +.profile-back-btn { + margin-right: 12px; +} + +.profile-dl { + margin: 0; + display: flex; + flex-direction: column; + gap: 12px; +} + +.profile-dl-row { + display: grid; + grid-template-columns: minmax(120px, 180px) 1fr; + gap: 8px 16px; + align-items: center; +} + +.profile-dl-row dt { + margin: 0; + font-size: 13px; + color: var(--app-text-muted); +} + +.profile-dl-row dd { + margin: 0; + font-size: 14px; + word-break: break-all; +} + +.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; +} + +.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; +} + +.profile-pin-form { + display: flex; + flex-direction: column; + gap: 12px; +} + +.profile-passkey-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.profile-passkey-item { + display: flex; + align-items: center; + 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-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; + } +} + .account-danger-zone { border-top: 1px solid rgba(239, 68, 68, 0.2); padding-top: 24px; diff --git a/client/src/App.tsx b/client/src/App.tsx index c30b015..e2524ad 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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 (
{pwaInstallBanner} - + {showUserProfile ? ( + setShowUserProfile(false)} + onLogout={handleLogout} + /> + ) : ( + setShowUserProfile(true)} + /> + )}
) } diff --git a/client/src/components/LogbookDashboard.tsx b/client/src/components/LogbookDashboard.tsx index e2fb9ef..6c66c2d 100644 --- a/client/src/components/LogbookDashboard.tsx +++ b/client/src/components/LogbookDashboard.tsx @@ -8,7 +8,6 @@ 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,9 +15,10 @@ 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([]) @@ -210,14 +210,16 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD {/* Skipper profile */} -
+ {/* Lang toggle */} +
+
+

{t('profile.title')}

+ +
+

{t('profile.subtitle', { name: username })}

+
+ + +
+ +
+ + +
+ {error &&
{error}
} + + {loading ? ( +
+ +

{t('profile.loading')}

+
+ ) : profile ? ( + <> +
+
+ +

{t('profile.identity_title')}

+
+ +
+
+
{t('profile.username')}
+
{profile.username}
+
+
+
{t('profile.user_id')}
+
+ {profile.userId} + +
+
+
+
{t('profile.account_since')}
+
{accountAgeLabel}
+
+
+
{t('profile.prf_status')}
+
+ {profile.hasPrfEncryption + ? t('profile.prf_active') + : t('profile.prf_inactive')} +
+
+
+
+ +
+
+ +

{t('profile.pin_title')}

+
+

{t('auth.setup_pin_warning')}

+

+ {t('profile.pin_status')}:{' '} + {pinActive ? t('profile.pin_active') : t('profile.pin_inactive')} +

+ +
void handleSavePin(e)} className="profile-pin-form"> +
+ + setPinInput(e.target.value)} + disabled={pinBusy} + /> +
+
+ + setPinConfirm(e.target.value)} + disabled={pinBusy} + /> +
+
+ + {pinActive && ( + + )} +
+
+
+ +
+
+ +

{t('profile.passkeys_title')}

+
+

{t('profile.passkeys_desc')}

+ + {profile.credentials.length === 0 ? ( +

{t('profile.passkeys_empty')}

+ ) : ( +
    + {profile.credentials.map((cred) => ( +
  • +
    + {cred.credentialIdPreview} + {cred.transports.length > 0 && ( + + {cred.transports.join(', ')} + + )} +
    + +
  • + ))} +
+ )} + +
+ +
+
+ +
+
+ +
+

{t('profile.stats_title')}

+

{t('profile.stats_subtitle')}

+
+
+ + {statsTotals && ( +
+ } + label={t('profile.stats_logbooks')} + value={String(accountStats?.logbooks.length ?? profile.serverMeta.ownedLogbookCount)} + /> + } + label={t('stats.travel_days')} + value={String(statsTotals.travelDayCount)} + /> + } + label={t('stats.total_distance')} + value={formatNm(statsTotals.totalDistanceNm)} + unit={t('stats.unit_nm')} + /> + } + label={t('stats.sail_distance')} + value={formatNm(statsTotals.sailDistanceNm)} + unit={t('stats.unit_nm')} + /> + } + label={t('stats.motor_distance')} + value={formatNm(statsTotals.motorDistanceNm)} + unit={t('stats.unit_nm')} + /> + } + label={t('stats.motor_hours_total')} + value={formatHours(statsTotals.totalMotorHours)} + unit={t('stats.unit_h')} + /> + } + label={t('profile.stats_account_since')} + value={accountAgeLabel} + /> + } + label={t('profile.stats_shared_logbooks')} + value={String(sharedLogbookCount)} + /> +
+ )} +
+ + + + ) : null} +
+ + ) +} diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index c465669..6735d2a 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -269,7 +269,61 @@ "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" + }, + "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": "Der letzte Passkey kann nicht entfernt werden.", + "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", + "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", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 93a3add..5a507d1 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -269,7 +269,61 @@ "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}}" + }, + "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": "The last passkey cannot be removed.", + "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", + "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", diff --git a/client/src/services/auth.ts b/client/src/services/auth.ts index 78bcb61..1c03e80 100644 --- a/client/src/services/auth.ts +++ b/client/src/services/auth.ts @@ -543,3 +543,99 @@ export async function deleteAccount(): Promise { } return false } + +export interface UserProfileCredential { + id: string + 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 { + return apiJson(`${API_BASE}/profile`) +} + +async function enrollPrfFromMasterKey(masterKey: ArrayBuffer, prfFirst: ArrayBuffer): Promise { + 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(): Promise { + await reauthWithPasskey() + + const options = await apiJson(`${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 }) + }) + + 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 { + 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') + } +} diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index b8f8c15..f9cf483 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -22,8 +22,14 @@ const rpID = process.env.RP_ID || 'localhost' const origin = process.env.ORIGIN || 'http://localhost:5173' const registrationChallenges = new Map() +const addCredentialChallenges = new Map() const activeChallenges = new Set() +function previewCredentialId(credentialId: string): string { + if (credentialId.length <= 16) return credentialId + return `${credentialId.slice(0, 8)}…${credentialId.slice(-8)}` +} + router.post('/register-options', async (req, res) => { try { const { username } = req.body @@ -381,4 +387,186 @@ router.post('/enroll-prf', requireReauth, async (req: any, res) => { } }) +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, + 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(req.userId, options.challenge) + + 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 } = req.body + if (!credentialResponse) { + return res.status(400).json({ error: 'credentialResponse is required' }) + } + + const expectedChallenge = addCredentialChallenges.get(req.userId) + if (!expectedChallenge) { + return res.status(400).json({ error: 'Challenge not found or expired' }) + } + + 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, + 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, + publicKey: Buffer.from(credentialPublicKey), + counter: BigInt(counter), + transports: credentialResponse.response.transports || [] + } + }) + + addCredentialChallenges.delete(req.userId) + + return res.json({ + verified: true, + credential: { + id: credential.id, + 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.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