feat(security): Session-Cookies statt X-User-Id und API-Härtung

Ersetzt die spoofbare X-User-Id-Auth durch signierte HttpOnly-Sessions nach
WebAuthn, erzwingt WRITE-only Sync, speichert den Master-Key nur im RAM und
ergänzt CORS, Rate-Limits, Helmet sowie Passkey-Reauth für sensible Aktionen.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-30 13:47:24 +02:00
co-authored by Cursor
parent 4f3f530f1f
commit dea33e3f00
33 changed files with 657 additions and 397 deletions
+23 -14
View File
@@ -13,7 +13,7 @@ import SettingsForm from './components/SettingsForm.tsx'
import InvitationAcceptance from './components/InvitationAcceptance.tsx'
import AppTourOverlay from './components/AppTourOverlay.tsx'
import { AppTourProvider, useAppTour, type AppTab } from './context/AppTourContext.tsx'
import { getActiveMasterKey, logoutUser } from './services/auth.js'
import { getActiveMasterKey, logoutUser, checkServerSession } from './services/auth.js'
import { PlausibleEvents, trackPlausibleEvent } from './services/analytics.js'
import {
applyAppearanceToDocument,
@@ -186,17 +186,23 @@ function App() {
)
}
const savedUser = localStorage.getItem('active_username')
const key = getActiveMasterKey()
if (savedUser && key) {
setIsAuthenticated(true)
const savedLogbookId = localStorage.getItem('active_logbook_id')
const savedLogbookTitle = localStorage.getItem('active_logbook_title')
if (savedLogbookId && savedLogbookTitle) {
setActiveLogbookId(savedLogbookId)
setActiveLogbookTitle(savedLogbookTitle)
void (async () => {
const session = await checkServerSession()
if (session.authenticated && session.userId) {
localStorage.setItem('active_userid', session.userId)
}
}
const savedUser = localStorage.getItem('active_username')
const key = getActiveMasterKey()
if (session.authenticated && savedUser && key) {
setIsAuthenticated(true)
const savedLogbookId = localStorage.getItem('active_logbook_id')
const savedLogbookTitle = localStorage.getItem('active_logbook_title')
if (savedLogbookId && savedLogbookTitle) {
setActiveLogbookId(savedLogbookId)
setActiveLogbookTitle(savedLogbookTitle)
}
}
})()
}, [])
useEffect(() => {
@@ -307,7 +313,7 @@ function App() {
}
const handleLogout = () => {
logoutUser()
void logoutUser()
setIsAuthenticated(false)
setActiveLogbookId(null)
setActiveLogbookTitle(null)
@@ -382,6 +388,8 @@ function App() {
const pwaInstallBanner = <PwaInstallPrompt variant="banner" />
const logbookReadOnly = activeAccessRole === 'READ'
if (!activeLogbookId) {
return (
<div style={{ display: 'contents' }}>
@@ -514,6 +522,7 @@ function App() {
{activeTab === 'logs' && (
<LogEntriesList
logbookId={activeLogbookId}
readOnly={logbookReadOnly}
controlledSelectedEntryId={tourSelectedEntryId}
onSelectedEntryIdChange={setTourSelectedEntryId}
highlightEntryId={demoHighlightEntryId}
@@ -521,11 +530,11 @@ function App() {
)}
{activeTab === 'vessel' && (
<VesselForm logbookId={activeLogbookId} />
<VesselForm logbookId={activeLogbookId} readOnly={logbookReadOnly} />
)}
{activeTab === 'crew' && (
<CrewForm logbookId={activeLogbookId} />
<CrewForm logbookId={activeLogbookId} readOnly={logbookReadOnly} />
)}
{activeTab === 'stats' && activeLogbookId && activeLogbookTitle && (
+2 -12
View File
@@ -14,6 +14,7 @@ import { parseCollaborationRole } from '../services/logbook.js'
import { syncLogbook } from '../services/sync.js'
import { db } from '../services/db.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { apiJson } from '../services/api.js'
interface InvitationAcceptanceProps {
onAccepted: (logbookId: string, title: string) => void
@@ -164,12 +165,8 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
)
const encrypted = await encryptBuffer(logbookKey, aesMasterKey)
const res = await fetch('/api/collaboration/accept', {
const acceptResult = await apiJson<{ role: string; logbookId: string }>('/api/collaboration/accept', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Id': activeUserId
},
body: JSON.stringify({
token,
encryptedLogbookKey: encrypted.ciphertext,
@@ -177,13 +174,6 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
tag: encrypted.tag
})
})
if (!res.ok) {
const serverError = await res.json().catch(() => ({}))
throw new Error(serverError.error || (isDe ? 'Beitritt auf dem Server fehlgeschlagen.' : 'Failed to join logbook on the server.'))
}
const acceptResult = await res.json()
const collaborationRole = parseCollaborationRole(acceptResult.role, 'invitation accept')
await saveLogbookKey(logbookId, logbookKey)
+1 -1
View File
@@ -99,7 +99,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
}
const handleLogout = () => {
logoutUser()
void logoutUser()
onLogout()
}
+12 -35
View File
@@ -11,6 +11,7 @@ import { notifyAppearanceChanged } from '../services/appearance.js'
import ThemedSelect from './ThemedSelect.tsx'
import { useAppTour } from '../context/AppTourContext.tsx'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { apiFetch } from '../services/api.js'
interface SettingsFormProps {
logbookId?: string | null
@@ -67,15 +68,10 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
const loadShareLink = async () => {
if (!logbookId) return
setLoadingShareLink(true)
const userId = localStorage.getItem('active_userid')
if (!userId) return
if (!localStorage.getItem('active_userid')) return
try {
const res = await fetch(`/api/collaboration/share-link?logbookId=${logbookId}`, {
headers: {
'X-User-Id': userId
}
})
const res = await apiFetch(`/api/collaboration/share-link?logbookId=${logbookId}`)
if (res.ok) {
const data = await res.json()
@@ -99,17 +95,12 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
const handleToggleShare = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (!logbookId) return
const checked = e.target.checked
const userId = localStorage.getItem('active_userid')
if (!userId) return
if (!localStorage.getItem('active_userid')) return
setLoadingShareLink(true)
try {
const res = await fetch('/api/collaboration/share-link', {
const res = await apiFetch('/api/collaboration/share-link', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Id': userId
},
body: JSON.stringify({ logbookId, enabled: checked })
})
@@ -149,15 +140,10 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
const loadCollaborators = async () => {
setLoadingCollabs(true)
setCollabError(null)
const userId = localStorage.getItem('active_userid')
if (!userId) return
if (!localStorage.getItem('active_userid')) return
try {
const res = await fetch(`/api/collaboration/collaborators?logbookId=${logbookId}`, {
headers: {
'X-User-Id': userId
}
})
const res = await apiFetch(`/api/collaboration/collaborators?logbookId=${logbookId}`)
if (res.status === 403) {
setIsOwner(false)
@@ -184,20 +170,15 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
if (!logbookId) return
setGeneratingInvite(true)
setInviteLink('')
const userId = localStorage.getItem('active_userid')
if (!userId) return
if (!localStorage.getItem('active_userid')) return
try {
// 1. Ensure logbook has an E2E key (upgrades legacy logbooks if needed)
const logbookKey = await ensureLogbookKey(logbookId)
// 2. Create invite token on server
const res = await fetch('/api/collaboration/invite', {
const res = await apiFetch('/api/collaboration/invite', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Id': userId
},
body: JSON.stringify({ logbookId, role: 'WRITE' })
})
@@ -230,16 +211,12 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
}
const handleRevoke = async (collabId: string, collName: string) => {
const userId = localStorage.getItem('active_userid')
if (!userId) return
if (!localStorage.getItem('active_userid')) return
if (await showConfirm(t('logs.revoke_confirm'), collName, t('logs.confirm_yes'), t('logs.confirm_no'))) {
try {
const res = await fetch(`/api/collaboration/collaborators/${collabId}`, {
method: 'DELETE',
headers: {
'X-User-Id': userId
}
const res = await apiFetch(`/api/collaboration/collaborators/${collabId}`, {
method: 'DELETE'
})
if (res.ok) {
+38
View File
@@ -0,0 +1,38 @@
export class ApiError extends Error {
status: number
constructor(message: string, status: number) {
super(message)
this.name = 'ApiError'
this.status = status
}
}
export async function apiFetch(
input: string,
init: RequestInit = {}
): Promise<Response> {
const headers = new Headers(init.headers)
if (init.body !== undefined && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
return fetch(input, {
...init,
headers,
credentials: 'include'
})
}
export async function apiJson<T>(input: string, init: RequestInit = {}): Promise<T> {
const res = await apiFetch(input, init)
const data = await res.json().catch(() => ({}))
if (!res.ok) {
const message =
typeof data === 'object' && data && 'error' in data && typeof data.error === 'string'
? data.error
: `Request failed (${res.status})`
throw new ApiError(message, res.status)
}
return data as T
}
+63 -75
View File
@@ -6,27 +6,22 @@ import {
deriveKeyFromPin,
encryptBuffer,
decryptBuffer,
generateRecoveryPhrase,
base64ToBuffer,
bufferToBase64
generateRecoveryPhrase
} from './crypto.js'
import { clearLogbookKeysCache } from './logbookKeys.js'
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
import { db } from './db.js'
import { apiFetch, apiJson } from './api.js'
const API_BASE = '/api/auth'
// Shared in-memory container for the active user's session master key
// Master key lives in memory only (never localStorage — XSS-resistant).
let activeMasterKey: ArrayBuffer | null = null
// Restore key from localStorage on load if present (survives reload/restart)
try {
const savedKey = localStorage.getItem('active_master_key')
if (savedKey) {
activeMasterKey = base64ToBuffer(savedKey)
}
} catch (e) {
console.error('Failed to restore active master key:', e)
localStorage.removeItem('active_master_key')
} catch {
/* ignore */
}
export function getActiveMasterKey(): ArrayBuffer | null {
@@ -35,17 +30,34 @@ export function getActiveMasterKey(): ArrayBuffer | null {
export function setActiveMasterKey(key: ArrayBuffer | null) {
activeMasterKey = key
if (key) {
try {
localStorage.setItem('active_master_key', bufferToBase64(key))
} catch (e) {
console.error('Failed to save master key to localStorage:', e)
}
} else {
localStorage.removeItem('active_master_key')
}
export async function checkServerSession(): Promise<{ authenticated: boolean; userId?: string }> {
try {
return await apiJson<{ authenticated: boolean; userId?: string }>(`${API_BASE}/session`)
} catch {
return { authenticated: false }
}
}
export async function reauthWithPasskey(): Promise<boolean> {
const options = await apiJson<any>(`${API_BASE}/reauth-options`, {
method: 'POST'
})
const credentialResponse = await startAuthentication({ optionsJSON: options })
await apiJson(`${API_BASE}/reauth-verify`, {
method: 'POST',
body: JSON.stringify({
credentialResponse,
challenge: options.challenge
})
})
return true
}
// PIN fallback mechanism functions
export async function setLocalPin(pin: string, username: string, masterKey: ArrayBuffer): Promise<void> {
const pinKey = await deriveKeyFromPin(pin, username)
@@ -152,19 +164,11 @@ export interface RegistrationResult {
export async function registerUser(username: string): Promise<RegistrationResult> {
// 1. Get registration options
const optionsRes = await fetch(`${API_BASE}/register-options`, {
const options = await apiJson<any>(`${API_BASE}/register-options`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username })
})
if (!optionsRes.ok) {
const err = await optionsRes.json()
throw new Error(err.error || 'Failed to fetch registration options')
}
const options = await optionsRes.json()
// Request the PRF extension WITH an evaluation salt. This must match the
// salt used during login (PRF_SALT), otherwise the PRF-derived key produced
// at login would never match what was stored here and every login would fall
@@ -229,9 +233,8 @@ export async function registerUser(username: string): Promise<RegistrationResult
const encryptedRecovery = await encryptBuffer(masterKey, recoveryKey)
// 4. Verify registration on the server
const verifyRes = await fetch(`${API_BASE}/register-verify`, {
const result = await apiJson<{ verified: boolean; userId: string }>(`${API_BASE}/register-verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username,
credentialResponse,
@@ -243,13 +246,6 @@ export async function registerUser(username: string): Promise<RegistrationResult
encryptedMasterKeyRecTag: encryptedRecovery.tag
})
})
if (!verifyRes.ok) {
const err = await verifyRes.json()
throw new Error(err.error || 'Failed to verify registration response')
}
const result = await verifyRes.json()
if (result.verified) {
setActiveMasterKey(masterKey)
localStorage.setItem('active_username', username)
@@ -292,19 +288,11 @@ export async function loginUser(username?: string): Promise<LoginResult> {
}
// 1. Get authentication options
const optionsRes = await fetch(`${API_BASE}/login-options`, {
const options = await apiJson<any>(`${API_BASE}/login-options`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username })
})
if (!optionsRes.ok) {
const err = await optionsRes.json()
throw new Error(err.error || 'Failed to fetch login options')
}
const options = await optionsRes.json()
// Add PRF extension evaluation input.
// When the server returned a concrete allowCredentials list we use
// `evalByCredential` (keyed by the base64url credential id), which is the
@@ -366,21 +354,23 @@ export async function loginUser(username?: string): Promise<LoginResult> {
}
// 3. Verify assertion on the server
const verifyRes = await fetch(`${API_BASE}/login-verify`, {
const result = await apiJson<{
verified: boolean
userId: string
username: string
encryptedMasterKeyPrf: string | null
encryptedMasterKeyPrfIv: string | null
encryptedMasterKeyPrfTag: string | null
encryptedMasterKeyRec: string
encryptedMasterKeyRecIv: string
encryptedMasterKeyRecTag: string
}>(`${API_BASE}/login-verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
credentialResponse,
challenge: options.challenge
})
})
if (!verifyRes.ok) {
const err = await verifyRes.json()
throw new Error(err.error || 'Failed to verify login response')
}
const result = await verifyRes.json()
if (!result.verified) {
return { verified: false, prfSuccess: false }
}
@@ -407,7 +397,12 @@ export async function loginUser(username?: string): Promise<LoginResult> {
console.log('PRF extension results first present:', !!prfResults.results?.first)
}
if (prfResults?.results?.first && result.encryptedMasterKeyPrf) {
if (
prfResults?.results?.first &&
result.encryptedMasterKeyPrf &&
result.encryptedMasterKeyPrfIv &&
result.encryptedMasterKeyPrfTag
) {
try {
const firstBuffer = typeof prfResults.results.first === 'string'
? base64urlToBuffer(prfResults.results.first)
@@ -475,22 +470,14 @@ export async function completeLoginWithRecovery(
const prfKey = await deriveKeyFromPrf(firstBuffer)
const encryptedPrf = await encryptBuffer(decryptedMaster, prfKey)
console.log('Sending PRF credentials to server...')
const enrollRes = await fetch(`${API_BASE}/enroll-prf`, {
await apiJson(`${API_BASE}/enroll-prf`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Id': encryptedPayloads.userId
},
body: JSON.stringify({
encryptedMasterKeyPrf: encryptedPrf.ciphertext,
encryptedMasterKeyPrfIv: encryptedPrf.iv,
encryptedMasterKeyPrfTag: encryptedPrf.tag
})
})
console.log('Enrollment response status:', enrollRes.status)
if (!enrollRes.ok) {
console.warn('Server rejected PRF enrollment')
}
} catch (err) {
console.error('Failed to encrypt/enroll master key with PRF key:', err)
}
@@ -508,25 +495,26 @@ export async function completeLoginWithRecovery(
}
}
export function logoutUser() {
export async function logoutUser() {
setActiveMasterKey(null)
clearLogbookKeysCache()
localStorage.removeItem('active_username')
localStorage.removeItem('active_userid')
try {
await apiFetch(`${API_BASE}/logout`, { method: 'POST' })
} catch {
/* ignore network errors on logout */
}
}
export async function deleteAccount(): Promise<boolean> {
const userId = localStorage.getItem('active_userid')
const username = localStorage.getItem('active_username')
if (!userId) return false
if (!localStorage.getItem('active_userid')) return false
try {
const res = await fetch(`${API_BASE}/delete-account`, {
method: 'DELETE',
headers: {
'X-User-Id': userId
}
})
await reauthWithPasskey()
const res = await apiFetch(`${API_BASE}/delete-account`, { method: 'DELETE' })
if (res.ok) {
if (username) {
@@ -546,7 +534,7 @@ export async function deleteAccount(): Promise<boolean> {
])
// Wipe localStorage and session variables
logoutUser()
await logoutUser()
trackPlausibleEvent(PlausibleEvents.ACCOUNT_DELETED)
return true
}
+9 -25
View File
@@ -1,5 +1,6 @@
import { startAuthentication } from '@simplewebauthn/browser'
import type { PasskeySignature } from '../types/signatures.js'
import { apiJson } from './api.js'
export async function signLogEntry(params: {
logbookId: string
@@ -7,32 +8,22 @@ export async function signLogEntry(params: {
entryHash: string
role: 'skipper' | 'crew'
}): Promise<PasskeySignature> {
const userId = localStorage.getItem('active_userid')
if (!userId) throw new Error('User not authenticated')
if (!localStorage.getItem('active_userid')) throw new Error('User not authenticated')
const optionsRes = await fetch('/api/sign/options', {
const options = await apiJson<any>('/api/sign/options', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Id': userId
},
body: JSON.stringify(params)
})
if (!optionsRes.ok) {
const err = await optionsRes.json().catch(() => ({}))
throw new Error(err.error || 'Failed to start passkey signing')
}
const options = await optionsRes.json()
const credentialResponse = await startAuthentication({ optionsJSON: options })
const verifyRes = await fetch('/api/sign/verify', {
const result = await apiJson<{
userId: string
username: string
credentialId: string
signedAt: string
}>('/api/sign/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Id': userId
},
body: JSON.stringify({
credentialResponse,
challenge: options.challenge,
@@ -43,13 +34,6 @@ export async function signLogEntry(params: {
})
})
if (!verifyRes.ok) {
const err = await verifyRes.json().catch(() => ({}))
throw new Error(err.error || 'Passkey signature verification failed')
}
const result = await verifyRes.json()
return {
kind: 'passkey',
version: 1,
+3 -11
View File
@@ -1,3 +1,5 @@
import { apiFetch } from './api.js'
export type FeedbackCategory = 'bug' | 'feature' | 'general'
export class FeedbackApiError extends Error {
@@ -19,15 +21,6 @@ export function isValidFeedbackEmail(email: string): boolean {
return EMAIL_PATTERN.test(email.trim())
}
function buildFeedbackHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json'
}
const userId = localStorage.getItem('active_userid')
if (userId) headers['X-User-Id'] = userId
return headers
}
export async function sendFeedback(payload: {
category: FeedbackCategory
message: string
@@ -40,9 +33,8 @@ export async function sendFeedback(payload: {
throw new FeedbackApiError('Invalid email address', 'INVALID_EMAIL')
}
const res = await fetch('/api/feedback', {
const res = await apiFetch('/api/feedback', {
method: 'POST',
headers: buildFeedbackHeaders(),
body: JSON.stringify({
category: payload.category,
message: payload.message,
+4 -18
View File
@@ -3,6 +3,7 @@ import { getActiveMasterKey } from './auth.js'
import { encryptJson, decryptJson, encryptBuffer, decryptBuffer } from './crypto.js'
import { getLogbookKey, saveLogbookKey, generateLogbookKey } from './logbookKeys.js'
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
import { apiFetch } from './api.js'
const API_BASE = '/api/logbooks'
@@ -66,13 +67,7 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
if (navigator.onLine) {
try {
const response = await fetch(API_BASE, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-User-Id': userId
}
})
const response = await apiFetch(API_BASE, { method: 'GET' })
if (response.ok) {
const serverLogbooks = await response.json()
@@ -208,12 +203,8 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
if (navigator.onLine) {
try {
const response = await fetch(API_BASE, {
const response = await apiFetch(API_BASE, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Id': userId
},
body: JSON.stringify({
id: localId,
...payloadData
@@ -301,12 +292,7 @@ export async function deleteLogbook(id: string): Promise<void> {
if (navigator.onLine) {
try {
const response = await fetch(`${API_BASE}/${id}`, {
method: 'DELETE',
headers: {
'X-User-Id': userId
}
})
const response = await apiFetch(`${API_BASE}/${id}`, { method: 'DELETE' })
if (!response.ok) {
console.warn('Server deletion failed or was rejected')
}
+4 -7
View File
@@ -1,3 +1,5 @@
import { apiJson } from './api.js'
export interface LogbookAccess {
isOwner: boolean
role: 'OWNER' | 'READ' | 'WRITE'
@@ -5,15 +7,10 @@ export interface LogbookAccess {
}
export async function getLogbookAccess(logbookId: string): Promise<LogbookAccess | null> {
const userId = localStorage.getItem('active_userid')
if (!userId || !navigator.onLine) return null
if (!localStorage.getItem('active_userid') || !navigator.onLine) return null
try {
const res = await fetch(`/api/logbooks/${logbookId}/access`, {
headers: { 'X-User-Id': userId }
})
if (!res.ok) return null
return res.json()
return await apiJson<LogbookAccess>(`/api/logbooks/${logbookId}/access`)
} catch {
return null
}
+12 -40
View File
@@ -1,8 +1,6 @@
const API_BASE = '/api/push'
import { apiFetch, apiJson } from './api.js'
function getUserId(): string | null {
return localStorage.getItem('active_userid')
}
const API_BASE = '/api/push'
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
@@ -46,38 +44,24 @@ async function fetchVapidPublicKey(): Promise<string | null> {
}
export async function fetchPushPrefs(): Promise<{ collaboratorChangesEnabled: boolean }> {
const userId = getUserId()
if (!userId) return { collaboratorChangesEnabled: false }
const res = await fetch(`${API_BASE}/prefs`, {
headers: { 'X-User-Id': userId }
})
if (!res.ok) {
throw new Error('Failed to load push notification preferences')
if (!localStorage.getItem('active_userid')) {
return { collaboratorChangesEnabled: false }
}
return res.json()
return apiJson<{ collaboratorChangesEnabled: boolean }>(`${API_BASE}/prefs`)
}
export async function savePushPrefs(collaboratorChangesEnabled: boolean): Promise<void> {
const userId = getUserId()
if (!userId) throw new Error('Not authenticated')
if (!localStorage.getItem('active_userid')) throw new Error('Not authenticated')
const res = await fetch(`${API_BASE}/prefs`, {
await apiJson(`${API_BASE}/prefs`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-User-Id': userId
},
body: JSON.stringify({ collaboratorChangesEnabled })
})
if (!res.ok) {
throw new Error('Failed to save push notification preferences')
}
}
async function saveSubscriptionToServer(subscription: PushSubscription): Promise<void> {
const userId = getUserId()
if (!userId) throw new Error('Not authenticated')
if (!localStorage.getItem('active_userid')) throw new Error('Not authenticated')
const json = subscription.toJSON()
if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) {
@@ -86,12 +70,8 @@ async function saveSubscriptionToServer(subscription: PushSubscription): Promise
const locale = document.documentElement.lang?.startsWith('en') ? 'en' : 'de'
const res = await fetch(`${API_BASE}/subscription`, {
await apiJson(`${API_BASE}/subscription`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-User-Id': userId
},
body: JSON.stringify({
endpoint: json.endpoint,
keys: json.keys,
@@ -99,9 +79,6 @@ async function saveSubscriptionToServer(subscription: PushSubscription): Promise
userAgent: navigator.userAgent
})
})
if (!res.ok) {
throw new Error('Failed to register push subscription on server')
}
}
export async function subscribeToPush(): Promise<void> {
@@ -137,7 +114,6 @@ export async function subscribeToPush(): Promise<void> {
export async function unsubscribeFromPush(): Promise<void> {
if (!isPushSupported()) return
const userId = getUserId()
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.getSubscription()
if (!subscription) return
@@ -145,13 +121,9 @@ export async function unsubscribeFromPush(): Promise<void> {
const endpoint = subscription.endpoint
await subscription.unsubscribe()
if (userId && endpoint) {
await fetch(`${API_BASE}/subscription`, {
if (localStorage.getItem('active_userid') && endpoint) {
await apiFetch(`${API_BASE}/subscription`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-User-Id': userId
},
body: JSON.stringify({ endpoint })
}).catch(() => {})
}
+10 -14
View File
@@ -1,5 +1,7 @@
import { db, type SyncQueueItem } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { apiFetch } from './api.js'
import { getLogbookAccess } from './logbookAccess.js'
const API_BASE = '/api/sync'
const syncingLogbooks = new Set<string>()
@@ -126,19 +128,17 @@ function scheduleResync(logbookId: string) {
// Push local sync queue items to the server
async function pushChanges(logbookId: string): Promise<boolean> {
const userId = localStorage.getItem('active_userid')
if (!userId) return false
if (!getActiveMasterKey() || !localStorage.getItem('active_userid')) return false
const access = await getLogbookAccess(logbookId)
if (access && access.role === 'READ') return true
const pending = await coalesceSyncQueue(logbookId)
if (pending.length === 0) return true
try {
const response = await fetch(`${API_BASE}/push`, {
const response = await apiFetch(`${API_BASE}/push`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Id': userId
},
body: JSON.stringify({ items: pending })
})
@@ -187,15 +187,11 @@ async function flushPushQueue(logbookId: string): Promise<boolean> {
// Pull updates from the server and apply last-write-wins
async function pullChanges(logbookId: string): Promise<boolean> {
const userId = localStorage.getItem('active_userid')
if (!userId) return false
if (!localStorage.getItem('active_userid')) return false
try {
const response = await fetch(`${API_BASE}/pull?logbookId=${logbookId}`, {
method: 'GET',
headers: {
'X-User-Id': userId
}
const response = await apiFetch(`${API_BASE}/pull?logbookId=${logbookId}`, {
method: 'GET'
})
if (!response.ok) {
+7 -14
View File
@@ -1,3 +1,5 @@
import { apiFetch } from './api.js'
export class WeatherApiError extends Error {
code: 'NO_KEY' | 'REQUEST_FAILED'
@@ -8,17 +10,6 @@ export class WeatherApiError extends Error {
}
}
function buildWeatherHeaders(): Record<string, string> {
const headers: Record<string, string> = {}
const userId = localStorage.getItem('active_userid')
const userKey = localStorage.getItem('owm_api_key')?.trim()
if (userId) headers['X-User-Id'] = userId
if (userKey) headers['X-OWM-Api-Key'] = userKey
return headers
}
export async function fetchOpenWeatherCurrent(params: {
lat?: string
lon?: string
@@ -35,9 +26,11 @@ export async function fetchOpenWeatherCurrent(params: {
throw new WeatherApiError('lat/lon or location query required')
}
const res = await fetch(`/api/weather/current?${searchParams.toString()}`, {
headers: buildWeatherHeaders()
})
const userKey = localStorage.getItem('owm_api_key')?.trim()
const headers: Record<string, string> = {}
if (userKey) headers['X-OWM-Api-Key'] = userKey
const res = await apiFetch(`/api/weather/current?${searchParams.toString()}`, { headers })
if (res.status === 503) {
throw new WeatherApiError('No OpenWeatherMap API key configured', 'NO_KEY')