Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8bcfb97e98 | |||
| b9ccb0dfb6 | |||
| d98e2e8dc0 | |||
| f5f12f50f5 | |||
| 1437b75c2f | |||
| 7d75e74679 | |||
| 0276d8445e | |||
| dea33e3f00 | |||
| 4f3f530f1f | |||
| 858d5d1d25 | |||
| c914156d70 |
+8
-1
@@ -4,7 +4,14 @@ OpenWeatherMapAPIKey=<owm_api_key>
|
||||
# For local dev: localhost and http://localhost
|
||||
# For production: e.g. kapteins-daagbok.eu and https://kapteins-daagbok.eu
|
||||
RP_ID=localhost
|
||||
ORIGIN=http://localhost
|
||||
# Must match the frontend URL (Vite dev: http://localhost:5173; Docker: http://localhost)
|
||||
ORIGIN=http://localhost:5173
|
||||
# Optional: comma-separated CORS origins (defaults to ORIGIN; dev also allows 127.0.0.1:5173)
|
||||
# CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||
|
||||
# API session signing (min. 32 chars; required in production)
|
||||
# Generate: openssl rand -base64 48
|
||||
SESSION_SECRET=
|
||||
|
||||
# Web Push (VAPID) — generate with: npx web-push generate-vapid-keys
|
||||
# Public key may also be set on the client as VITE_VAPID_PUBLIC_KEY
|
||||
|
||||
@@ -168,7 +168,7 @@ Beim Laden eines Eintrags: `computedHash !== sig.entryHash` → UI-Warnung.
|
||||
|
||||
Neuer Router: `server/src/routes/sign.ts` → Mount unter `/api/sign`
|
||||
|
||||
Auth wie bestehend: Header `X-User-Id` (siehe `sync.ts`).
|
||||
Auth wie bestehend: HttpOnly-Session-Cookie `daagbok_session` nach WebAuthn (`server/src/middleware/auth.ts`, Client `apiFetch` mit `credentials: 'include'`).
|
||||
|
||||
### 4.1 `POST /api/sign/options`
|
||||
|
||||
@@ -472,7 +472,7 @@ test('isSignatureValidForEntry')
|
||||
| WebAuthn Login | `client/src/services/auth.ts`, `server/src/routes/auth.ts` |
|
||||
| Collaborators | `server/src/routes/collaboration.ts`, `SettingsForm.tsx` |
|
||||
| E2E-Einträge | `EntryPayload` in `server/prisma/schema.prisma` |
|
||||
| Auth-Header | `X-User-Id` in `server/src/routes/sync.ts` |
|
||||
| API-Auth | Session-Cookie via `requireUser` in `server/src/middleware/auth.ts` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ Alle sensiblen Inhalte werden **clientseitig verschlüsselt** (Web Crypto API).
|
||||
| Lokaler Speicher | Dexie.js (IndexedDB), Hintergrund-Sync |
|
||||
| Backend | Node.js, Express, Prisma |
|
||||
| Datenbank | PostgreSQL 16 |
|
||||
| Auth | WebAuthn (Passkeys) via `@simplewebauthn` |
|
||||
| Auth | WebAuthn (Passkeys) + signiertes HttpOnly-Session-Cookie (`daagbok_session`) |
|
||||
| Krypto | Web Crypto API (AES-GCM), BIP39 Recovery |
|
||||
| Push (optional) | Web Push (VAPID), Custom Service Worker (`injectManifest`) |
|
||||
|
||||
@@ -59,6 +59,18 @@ Alle sensiblen Inhalte werden **clientseitig verschlüsselt** (Web Crypto API).
|
||||
|
||||
Skipper- und Crew-Profile im Logbuch sind **Inhaltsdaten** (verschlüsselt), nicht an den Account gebunden. Ein Account kann gleichzeitig Owner eines eigenen und Collaborator in fremden Logbüchern sein.
|
||||
|
||||
### Authentifizierung & Session
|
||||
|
||||
| Schicht | Verhalten |
|
||||
|---------|-----------|
|
||||
| **Login** | WebAuthn (`/api/auth/login-verify`) — danach HttpOnly-Cookie, 7 Tage gültig |
|
||||
| **API-Aufrufe** | Cookie `credentials: 'include'` (Client: `apiFetch`) — kein `X-User-Id` |
|
||||
| **Master-Key** | Nur im RAM; nach Reload Entsperren per Passkey oder lokalem PIN |
|
||||
| **Step-up** | Konto löschen, PRF-Enrollment: frische Passkey-Bestätigung (`/api/auth/reauth-*`) |
|
||||
| **Sync WRITE** | Server lehnt Schreib-Sync für Collaborator mit `READ` ab |
|
||||
|
||||
Öffentliche Routen (ohne Session): Registrierung/Login-Optionen, Einladungsdetails, Read-only-Share (`share-pull`), Health-Check, VAPID-Public-Key.
|
||||
|
||||
## Backup & Wiederherstellung
|
||||
|
||||
Nur der **Logbuch-Eigner** kann unter **Einstellungen → Backup & Wiederherstellung** ein vollständiges Backup erstellen:
|
||||
@@ -134,21 +146,30 @@ cd client && npm ci && cd ..
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Für lokale Passkeys: `RP_ID=localhost`, `ORIGIN=http://localhost:5173` (bzw. die tatsächliche Frontend-URL).
|
||||
Kopiere `.env.example` nach `.env` und passe mindestens an:
|
||||
|
||||
Im `server/`-Verzeichnis eine `.env` mit `DATABASE_URL` anlegen — oder den Key in der **Projekt-`.env`** (`OpenWeatherMapAPIKey=...`); das Backend lädt beide Dateien.
|
||||
| Variable | Dev (Vite) | Produktion |
|
||||
|----------|------------|------------|
|
||||
| `RP_ID` | `localhost` | `kapteins-daagbok.eu` |
|
||||
| `ORIGIN` | `http://localhost:5173` | `https://kapteins-daagbok.eu` |
|
||||
| `SESSION_SECRET` | empfohlen (≥ 32 Zeichen) | **Pflicht** |
|
||||
|
||||
`ORIGIN` muss **exakt** der Frontend-URL entsprechen (CORS + Session-Cookie). Das Backend lädt `.env` aus dem Projektroot und optional `server/.env`.
|
||||
|
||||
```
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/daagbox?schema=public"
|
||||
OpenWeatherMapAPIKey= # Fallback für Wetter-Abruf, wenn Nutzer keinen eigenen Key hat
|
||||
RP_ID=localhost
|
||||
ORIGIN=http://localhost:5173
|
||||
SESSION_SECRET= # openssl rand -base64 48 (in Prod Pflicht)
|
||||
# Optional — Web Push (npx web-push generate-vapid-keys)
|
||||
VAPID_PUBLIC_KEY=
|
||||
VAPID_PRIVATE_KEY=
|
||||
VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu
|
||||
```
|
||||
|
||||
`./scripts/start-dev.sh` prüft `ORIGIN` und `SESSION_SECRET` beim Start und gibt Hinweise aus.
|
||||
|
||||
### 3. Datenbank & Schema
|
||||
|
||||
Das Dev-Skript startet PostgreSQL in Docker (`postgres-daagbox`). Schema anwenden:
|
||||
@@ -179,7 +200,7 @@ Gesamten Stack lokal bauen und starten:
|
||||
|
||||
Frontend: http://localhost · API: http://localhost/api/health
|
||||
|
||||
Umgebungsvariablen in `.env` setzen — mindestens `RP_ID` und `ORIGIN` für Passkeys. Für Push die VAPID-Variablen an den **Backend**-Container durchreichen (z. B. in `docker-compose.yml` unter `backend.environment` ergänzen).
|
||||
Umgebungsvariablen in `.env` setzen — mindestens `RP_ID`, `ORIGIN` (z. B. `http://localhost`) und `SESSION_SECRET`. Für Push die VAPID-Variablen an den Backend-Container durchreichen (`docker-compose.yml` → `backend.environment`).
|
||||
|
||||
## Deployment
|
||||
|
||||
@@ -191,7 +212,7 @@ Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen):
|
||||
|
||||
Standard-Ziel: `root@10.0.0.25:/opt/kapteins-daagbok` — per `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_DIR` überschreibbar.
|
||||
|
||||
Auf dem Server müssen `server/.env` (oder gleichwertige Umgebung) u. a. `DATABASE_URL`, `RP_ID`, `ORIGIN` und bei Push `VAPID_*` enthalten. Nach Schema-Änderungen: `npx prisma db push` im Backend-Container.
|
||||
Auf dem Server müssen `server/.env` (oder gleichwertige Umgebung) u. a. `DATABASE_URL`, `RP_ID`, `ORIGIN`, `SESSION_SECRET` (≥ 32 Zeichen) und bei Push `VAPID_*` enthalten. Nach Schema-Änderungen: `npx prisma db push` im Backend-Container.
|
||||
|
||||
## Dokumentation
|
||||
|
||||
|
||||
Generated
+590
-670
File diff suppressed because it is too large
Load Diff
+5
-5
@@ -31,7 +31,7 @@
|
||||
"@types/node": "^24.12.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
@@ -40,10 +40,10 @@
|
||||
"qrcode": "^1.5.4",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.59.2",
|
||||
"vite": "^8.0.12",
|
||||
"vite-plugin-pwa": "^1.3.0"
|
||||
"vite": "^6.3.5",
|
||||
"vite-plugin-pwa": "^1.0.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-linux-x64-gnu": "^1.0.2"
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,6 +388,51 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
max-height: min(90vh, 820px);
|
||||
}
|
||||
|
||||
.feedback-modal {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.feedback-modal__close {
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.feedback-status {
|
||||
text-align: center;
|
||||
padding: 8px 0 4px;
|
||||
}
|
||||
|
||||
.feedback-status p {
|
||||
margin: 12px 0 0;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.feedback-status--success {
|
||||
color: #4ade80;
|
||||
padding: 24px 8px 32px;
|
||||
}
|
||||
|
||||
.feedback-status--success p {
|
||||
color: var(--app-text-heading, #f1f5f9);
|
||||
}
|
||||
|
||||
.feedback-status--error {
|
||||
color: var(--app-error-text, #fda4af);
|
||||
background: var(--app-error-bg, rgba(244, 63, 94, 0.08));
|
||||
border: 1px solid var(--app-error-border, #f43f5e);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.feedback-status--error p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.feedback-modal .auth-actions {
|
||||
margin-top: 0;
|
||||
}
|
||||
@@ -398,6 +443,16 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.feedback-form__honeypot {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.feedback-form__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -412,6 +467,7 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
}
|
||||
|
||||
.feedback-form__field select,
|
||||
.feedback-form__field input,
|
||||
.feedback-form__field textarea {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
@@ -424,6 +480,7 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
}
|
||||
|
||||
.feedback-form__field select:focus,
|
||||
.feedback-form__field input:focus,
|
||||
.feedback-form__field textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--app-accent, #38bdf8);
|
||||
@@ -3354,3 +3411,11 @@ body.app-tour-active .app-tour-target-active {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
body.app-tour-active .disclaimer-modal-overlay.feedback-modal-overlay--tour {
|
||||
z-index: 9990;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
body.app-tour-active .feedback-modal-overlay--tour .disclaimer-modal-panel {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
+74
-26
@@ -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,
|
||||
@@ -40,19 +40,20 @@ import {
|
||||
getStoredDemoFirstEntryId,
|
||||
seedDemoLogbookIfNeeded
|
||||
} from './services/demoLogbook.js'
|
||||
import { fetchLogbooks } from './services/logbook.js'
|
||||
import { fetchLogbooks, parseCollaborationRole } from './services/logbook.js'
|
||||
import { ensurePushSubscriptionIfEnabled } from './services/pushNotifications.js'
|
||||
|
||||
const PENDING_PUSH_LOGBOOK_KEY = 'pending_push_logbook_id'
|
||||
|
||||
function App() {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { registerNavigation, requestStartAfterLogin } = useAppTour()
|
||||
const { registerNavigation, requestStartAfterLogin, isActive, currentStepId } = useAppTour()
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||
const [activeLogbookId, setActiveLogbookId] = useState<string | null>(null)
|
||||
const [activeLogbookTitle, setActiveLogbookTitle] = useState<string | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<AppTab>('logs')
|
||||
const [tourSelectedEntryId, setTourSelectedEntryId] = useState<string | null>(null)
|
||||
const [tourFeedbackOpen, setTourFeedbackOpen] = useState(false)
|
||||
const [demoHighlightEntryId, setDemoHighlightEntryId] = useState<string | null>(null)
|
||||
const [online, setOnline] = useState(navigator.onLine)
|
||||
const [isSyncing, setIsSyncing] = useState(false)
|
||||
@@ -76,7 +77,7 @@ function App() {
|
||||
[activeLogbookId]
|
||||
)
|
||||
|
||||
const [activeAccessRole, setActiveAccessRole] = useState<LogbookAccessRole>('OWNER')
|
||||
const [activeAccessRole, setActiveAccessRole] = useState<LogbookAccessRole | null>('OWNER')
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeLogbookId) {
|
||||
@@ -84,19 +85,34 @@ function App() {
|
||||
return
|
||||
}
|
||||
|
||||
if (activeLogbookRecord?.isShared !== 1) {
|
||||
if (!activeLogbookRecord) {
|
||||
setActiveAccessRole(null)
|
||||
return
|
||||
}
|
||||
|
||||
if (activeLogbookRecord.isShared !== 1) {
|
||||
setActiveAccessRole('OWNER')
|
||||
return
|
||||
}
|
||||
|
||||
const cachedRole = activeLogbookRecord.collaborationRole
|
||||
if (cachedRole) {
|
||||
setActiveAccessRole(cachedRole)
|
||||
}
|
||||
setActiveAccessRole(
|
||||
cachedRole ? parseCollaborationRole(cachedRole, `logbook ${activeLogbookId}`) : null
|
||||
)
|
||||
|
||||
getLogbookAccess(activeLogbookId).then((access) => {
|
||||
if (access) setActiveAccessRole(access.role)
|
||||
})
|
||||
let cancelled = false
|
||||
getLogbookAccess(activeLogbookId)
|
||||
.then((access) => {
|
||||
if (cancelled || !access) return
|
||||
setActiveAccessRole(access.role)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('Failed to resolve logbook access role:', err)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [activeLogbookId, activeLogbookRecord])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -185,17 +201,40 @@ function App() {
|
||||
`${cleanUrl.pathname}${cleanUrl.search}${cleanUrl.hash}`
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
|
||||
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)
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
const session = await checkServerSession()
|
||||
if (cancelled) return
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
console.warn('Session restore failed:', err)
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -215,7 +254,8 @@ function App() {
|
||||
useEffect(() => {
|
||||
registerNavigation({
|
||||
setActiveTab,
|
||||
setSelectedEntryId: setTourSelectedEntryId
|
||||
setSelectedEntryId: setTourSelectedEntryId,
|
||||
setFeedbackOpen: setTourFeedbackOpen
|
||||
})
|
||||
}, [registerNavigation])
|
||||
|
||||
@@ -307,7 +347,7 @@ function App() {
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
logoutUser()
|
||||
void logoutUser()
|
||||
setIsAuthenticated(false)
|
||||
setActiveLogbookId(null)
|
||||
setActiveLogbookTitle(null)
|
||||
@@ -382,6 +422,9 @@ function App() {
|
||||
|
||||
const pwaInstallBanner = <PwaInstallPrompt variant="banner" />
|
||||
|
||||
const logbookReadOnly =
|
||||
activeLogbookRecord?.isShared === 1 && activeAccessRole === 'READ'
|
||||
|
||||
if (!activeLogbookId) {
|
||||
return (
|
||||
<div style={{ display: 'contents' }}>
|
||||
@@ -409,12 +452,12 @@ function App() {
|
||||
<div className="app-title-area">
|
||||
<div className="app-title-row">
|
||||
<h2>{activeLogbookTitle}</h2>
|
||||
{activeAccessRole !== 'OWNER' && (
|
||||
{activeAccessRole && activeAccessRole !== 'OWNER' && (
|
||||
<LogbookRoleBadge role={activeAccessRole} />
|
||||
)}
|
||||
</div>
|
||||
<p className="app-subtitle">
|
||||
{activeAccessRole !== 'OWNER'
|
||||
{activeAccessRole && activeAccessRole !== 'OWNER'
|
||||
? t('dashboard.section_shared_hint')
|
||||
: `${t('app.name')} / ${activeLogbookId?.substring(0, 8)}...`}
|
||||
</p>
|
||||
@@ -443,6 +486,9 @@ function App() {
|
||||
<FeedbackHeaderButton
|
||||
logbookId={activeLogbookId}
|
||||
logbookTitle={activeLogbookTitle}
|
||||
tourOpen={tourFeedbackOpen}
|
||||
onTourOpenChange={setTourFeedbackOpen}
|
||||
tourHighlight={isActive && currentStepId === 'nav_feedback'}
|
||||
/>
|
||||
|
||||
<button className="btn-icon logout" onClick={handleLogout} title={t('dashboard.logout')}>
|
||||
@@ -495,6 +541,7 @@ function App() {
|
||||
<button
|
||||
className={`sidebar-btn ${activeTab === 'stats' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('stats')}
|
||||
data-tour="nav-stats"
|
||||
>
|
||||
<BarChart2 size={18} />
|
||||
{t('nav.stats')}
|
||||
@@ -514,6 +561,7 @@ function App() {
|
||||
{activeTab === 'logs' && (
|
||||
<LogEntriesList
|
||||
logbookId={activeLogbookId}
|
||||
readOnly={logbookReadOnly}
|
||||
controlledSelectedEntryId={tourSelectedEntryId}
|
||||
onSelectedEntryIdChange={setTourSelectedEntryId}
|
||||
highlightEntryId={demoHighlightEntryId}
|
||||
@@ -521,11 +569,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 && (
|
||||
|
||||
@@ -30,7 +30,8 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
||||
useEffect(() => {
|
||||
registerNavigation({
|
||||
setActiveTab,
|
||||
setSelectedEntryId: setTourSelectedEntryId
|
||||
setSelectedEntryId: setTourSelectedEntryId,
|
||||
setFeedbackOpen: () => {}
|
||||
})
|
||||
registerDemoTourContext({ firstEntryId: fixture.firstEntryId })
|
||||
|
||||
|
||||
@@ -6,31 +6,45 @@ import FeedbackModal from './FeedbackModal.tsx'
|
||||
interface FeedbackHeaderButtonProps {
|
||||
logbookId?: string | null
|
||||
logbookTitle?: string | null
|
||||
tourOpen?: boolean
|
||||
onTourOpenChange?: (open: boolean) => void
|
||||
tourHighlight?: boolean
|
||||
}
|
||||
|
||||
export default function FeedbackHeaderButton({
|
||||
logbookId,
|
||||
logbookTitle
|
||||
logbookTitle,
|
||||
tourOpen = false,
|
||||
onTourOpenChange,
|
||||
tourHighlight = false
|
||||
}: FeedbackHeaderButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [userOpen, setUserOpen] = useState(false)
|
||||
const open = tourOpen || userOpen
|
||||
|
||||
const handleClose = () => {
|
||||
setUserOpen(false)
|
||||
onTourOpenChange?.(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
onClick={() => setOpen(true)}
|
||||
onClick={() => setUserOpen(true)}
|
||||
title={t('feedback.button_title')}
|
||||
aria-label={t('feedback.button_title')}
|
||||
data-tour="feedback-button"
|
||||
>
|
||||
<MessageSquarePlus size={18} />
|
||||
</button>
|
||||
<FeedbackModal
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
onClose={handleClose}
|
||||
logbookId={logbookId}
|
||||
logbookTitle={logbookTitle}
|
||||
tourMode={tourHighlight}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,128 +1,238 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { MessageSquarePlus, X } from 'lucide-react'
|
||||
import { CheckCircle2, MessageSquarePlus, X } from 'lucide-react'
|
||||
import { FeedbackApiError, sendFeedback, type FeedbackCategory } from '../services/feedback.js'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
|
||||
const SUCCESS_CLOSE_DELAY_MS = 1800
|
||||
|
||||
interface FeedbackModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
logbookId?: string | null
|
||||
logbookTitle?: string | null
|
||||
tourMode?: boolean
|
||||
}
|
||||
|
||||
type SubmitState = 'idle' | 'submitting' | 'success' | 'error'
|
||||
|
||||
export default function FeedbackModal({
|
||||
open,
|
||||
onClose,
|
||||
logbookId,
|
||||
logbookTitle
|
||||
logbookTitle,
|
||||
tourMode = false
|
||||
}: FeedbackModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const { showAlert } = useDialog()
|
||||
const [category, setCategory] = useState<FeedbackCategory>('general')
|
||||
const [contactEmail, setContactEmail] = useState('')
|
||||
const [message, setMessage] = useState('')
|
||||
const [sending, setSending] = useState(false)
|
||||
const [website, setWebsite] = useState('')
|
||||
const [submitState, setSubmitState] = useState<SubmitState>('idle')
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null)
|
||||
const closeTimerRef = useRef<number | null>(null)
|
||||
const openedAtRef = useRef<number>(Date.now())
|
||||
|
||||
const isBusy = submitState === 'submitting' || submitState === 'success'
|
||||
|
||||
const clearCloseTimer = () => {
|
||||
if (closeTimerRef.current !== null) {
|
||||
window.clearTimeout(closeTimerRef.current)
|
||||
closeTimerRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => clearCloseTimer()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && !sending) onClose()
|
||||
if (event.key === 'Escape' && !isBusy) onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [open, onClose, sending])
|
||||
}, [open, onClose, isBusy])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
clearCloseTimer()
|
||||
setCategory('general')
|
||||
setContactEmail('')
|
||||
setMessage('')
|
||||
setSending(false)
|
||||
setWebsite('')
|
||||
setSubmitState('idle')
|
||||
setStatusMessage(null)
|
||||
return
|
||||
}
|
||||
openedAtRef.current = Date.now()
|
||||
}, [open])
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
if (!message.trim() || sending) return
|
||||
if (!message.trim() || submitState === 'submitting' || submitState === 'success') return
|
||||
|
||||
setSubmitState('submitting')
|
||||
setStatusMessage(null)
|
||||
|
||||
setSending(true)
|
||||
try {
|
||||
await sendFeedback({
|
||||
category,
|
||||
message: message.trim(),
|
||||
contactEmail: contactEmail.trim() || undefined,
|
||||
logbookId,
|
||||
logbookTitle
|
||||
logbookTitle,
|
||||
openedAt: openedAtRef.current,
|
||||
website
|
||||
})
|
||||
await showAlert(t('feedback.success'), t('feedback.title'))
|
||||
onClose()
|
||||
setSubmitState('success')
|
||||
setStatusMessage(t('feedback.success'))
|
||||
closeTimerRef.current = window.setTimeout(() => {
|
||||
closeTimerRef.current = null
|
||||
onClose()
|
||||
}, SUCCESS_CLOSE_DELAY_MS)
|
||||
} catch (error) {
|
||||
const msg =
|
||||
setSubmitState('error')
|
||||
setStatusMessage(
|
||||
error instanceof FeedbackApiError && error.code === 'NOT_CONFIGURED'
|
||||
? t('feedback.error_not_configured')
|
||||
: t('feedback.error_send')
|
||||
await showAlert(msg, t('feedback.title'))
|
||||
} finally {
|
||||
setSending(false)
|
||||
: error instanceof FeedbackApiError && error.code === 'INVALID_EMAIL'
|
||||
? t('feedback.error_invalid_email')
|
||||
: error instanceof FeedbackApiError && error.code === 'RATE_LIMITED'
|
||||
? t('feedback.error_rate_limited')
|
||||
: error instanceof FeedbackApiError && error.code === 'SPAM_DETECTED'
|
||||
? t('feedback.error_spam')
|
||||
: t('feedback.error_send')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="disclaimer-modal-overlay" onClick={sending ? undefined : onClose}>
|
||||
<div
|
||||
className={`disclaimer-modal-overlay${tourMode ? ' feedback-modal-overlay--tour' : ''}`}
|
||||
onClick={isBusy || tourMode ? undefined : onClose}
|
||||
>
|
||||
<div className="disclaimer-modal-panel" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="auth-card glass registration-disclaimer registration-disclaimer--modal feedback-modal">
|
||||
<div
|
||||
className="auth-card glass registration-disclaimer registration-disclaimer--modal feedback-modal"
|
||||
data-tour="feedback-form"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="registration-disclaimer__close feedback-modal__close"
|
||||
onClick={onClose}
|
||||
disabled={isBusy || tourMode}
|
||||
aria-label={t('feedback.cancel')}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
<div className="auth-header">
|
||||
<MessageSquarePlus className="auth-icon accent" size={48} />
|
||||
<h2>{t('feedback.title')}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="registration-disclaimer__close"
|
||||
onClick={onClose}
|
||||
disabled={sending}
|
||||
aria-label={t('feedback.cancel')}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="registration-disclaimer__intro">{t('feedback.intro')}</p>
|
||||
|
||||
<form className="feedback-form" onSubmit={handleSubmit}>
|
||||
<label className="feedback-form__field">
|
||||
<span>{t('feedback.category_label')}</span>
|
||||
<select
|
||||
value={category}
|
||||
onChange={(event) => setCategory(event.target.value as FeedbackCategory)}
|
||||
disabled={sending}
|
||||
>
|
||||
<option value="general">{t('feedback.category_general')}</option>
|
||||
<option value="bug">{t('feedback.category_bug')}</option>
|
||||
<option value="feature">{t('feedback.category_feature')}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="feedback-form__field">
|
||||
<span>{t('feedback.message_label')}</span>
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(event) => setMessage(event.target.value)}
|
||||
placeholder={t('feedback.message_placeholder')}
|
||||
rows={6}
|
||||
maxLength={2000}
|
||||
required
|
||||
disabled={sending}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="auth-actions feedback-form__actions">
|
||||
<button type="button" className="btn secondary" onClick={onClose} disabled={sending}>
|
||||
{t('feedback.cancel')}
|
||||
</button>
|
||||
<button type="submit" className="btn primary" disabled={sending || !message.trim()}>
|
||||
{sending ? t('feedback.sending') : t('feedback.send')}
|
||||
</button>
|
||||
{submitState === 'success' ? (
|
||||
<div className="feedback-status feedback-status--success" role="status" aria-live="polite">
|
||||
<CheckCircle2 size={40} aria-hidden="true" />
|
||||
<p>{statusMessage}</p>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<p className="registration-disclaimer__intro">{t('feedback.intro')}</p>
|
||||
|
||||
{statusMessage && submitState === 'error' && (
|
||||
<div className="feedback-status feedback-status--error" role="alert">
|
||||
<p>{statusMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="feedback-form" onSubmit={handleSubmit}>
|
||||
<label className="feedback-form__honeypot" aria-hidden="true">
|
||||
<span>Website</span>
|
||||
<input
|
||||
type="text"
|
||||
name="website"
|
||||
value={website}
|
||||
onChange={(event) => setWebsite(event.target.value)}
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="feedback-form__field">
|
||||
<span>{t('feedback.category_label')}</span>
|
||||
<select
|
||||
value={category}
|
||||
onChange={(event) => setCategory(event.target.value as FeedbackCategory)}
|
||||
disabled={submitState === 'submitting'}
|
||||
>
|
||||
<option value="general">{t('feedback.category_general')}</option>
|
||||
<option value="bug">{t('feedback.category_bug')}</option>
|
||||
<option value="feature">{t('feedback.category_feature')}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="feedback-form__field">
|
||||
<span>{t('feedback.contact_label')}</span>
|
||||
<input
|
||||
type="email"
|
||||
value={contactEmail}
|
||||
onChange={(event) => {
|
||||
setContactEmail(event.target.value)
|
||||
if (submitState === 'error') {
|
||||
setSubmitState('idle')
|
||||
setStatusMessage(null)
|
||||
}
|
||||
}}
|
||||
placeholder={t('feedback.contact_placeholder')}
|
||||
autoComplete="email"
|
||||
maxLength={254}
|
||||
disabled={submitState === 'submitting'}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="feedback-form__field">
|
||||
<span>{t('feedback.message_label')}</span>
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(event) => {
|
||||
setMessage(event.target.value)
|
||||
if (submitState === 'error') {
|
||||
setSubmitState('idle')
|
||||
setStatusMessage(null)
|
||||
}
|
||||
}}
|
||||
placeholder={t('feedback.message_placeholder')}
|
||||
rows={6}
|
||||
maxLength={2000}
|
||||
required
|
||||
disabled={submitState === 'submitting'}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="auth-actions feedback-form__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={onClose}
|
||||
disabled={submitState === 'submitting' || tourMode}
|
||||
>
|
||||
{t('feedback.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn primary"
|
||||
disabled={submitState === 'submitting' || !message.trim()}
|
||||
>
|
||||
{submitState === 'submitting' ? t('feedback.sending') : t('feedback.send')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -99,7 +99,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
logoutUser()
|
||||
void logoutUser()
|
||||
onLogout()
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -310,7 +310,7 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
|
||||
}, [accountStats])
|
||||
|
||||
return (
|
||||
<div className="form-card">
|
||||
<div className="form-card" data-tour="stats-dashboard">
|
||||
<div className="form-header">
|
||||
<BarChart2 size={24} className="form-icon" />
|
||||
<div>
|
||||
|
||||
@@ -27,11 +27,14 @@ export type TourStepId =
|
||||
| 'entry_track'
|
||||
| 'nav_vessel'
|
||||
| 'nav_crew'
|
||||
| 'nav_stats'
|
||||
| 'nav_feedback'
|
||||
| 'finish'
|
||||
|
||||
interface TourNavigation {
|
||||
setActiveTab: (tab: AppTab) => void
|
||||
setSelectedEntryId: (entryId: string | null) => void
|
||||
setFeedbackOpen: (open: boolean) => void
|
||||
}
|
||||
|
||||
interface DemoTourContext {
|
||||
@@ -55,7 +58,7 @@ interface AppTourContextValue {
|
||||
requestStartAfterLogin: () => void
|
||||
}
|
||||
|
||||
const STEP_ORDER: TourStepId[] = [
|
||||
const FULL_STEP_ORDER: TourStepId[] = [
|
||||
'welcome',
|
||||
'nav_logs',
|
||||
'entry_list',
|
||||
@@ -63,16 +66,28 @@ const STEP_ORDER: TourStepId[] = [
|
||||
'entry_track',
|
||||
'nav_vessel',
|
||||
'nav_crew',
|
||||
'nav_stats',
|
||||
'nav_feedback',
|
||||
'finish'
|
||||
]
|
||||
|
||||
/** Public demo has no stats/feedback UI — skip those steps. */
|
||||
const DEMO_EXCLUDED_STEPS: TourStepId[] = ['nav_stats', 'nav_feedback']
|
||||
const DEMO_STEP_ORDER: TourStepId[] = FULL_STEP_ORDER.filter((id) => !DEMO_EXCLUDED_STEPS.includes(id))
|
||||
|
||||
function getStepOrder(demoMode: boolean): TourStepId[] {
|
||||
return demoMode ? DEMO_STEP_ORDER : FULL_STEP_ORDER
|
||||
}
|
||||
|
||||
const TARGET_BY_STEP: Partial<Record<TourStepId, string>> = {
|
||||
nav_logs: '[data-tour="nav-logs"]',
|
||||
entry_list: '[data-tour="entry-list"]',
|
||||
entry_open: '[data-tour="entry-first"]',
|
||||
entry_track: '[data-tour="entry-track"]',
|
||||
nav_vessel: '[data-tour="nav-vessel"]',
|
||||
nav_crew: '[data-tour="nav-crew"]'
|
||||
nav_crew: '[data-tour="nav-crew"]',
|
||||
nav_stats: '[data-tour="stats-dashboard"]',
|
||||
nav_feedback: '[data-tour="feedback-form"]'
|
||||
}
|
||||
|
||||
const AppTourContext = createContext<AppTourContextValue | null>(null)
|
||||
@@ -86,7 +101,8 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
||||
const demoContextRef = useRef<DemoTourContext | null>(null)
|
||||
const tourModeRef = useRef<{ demoMode: boolean }>({ demoMode: false })
|
||||
|
||||
const currentStepId = isActive ? STEP_ORDER[stepIndex] ?? null : null
|
||||
const stepOrder = getStepOrder(isDemoTour)
|
||||
const currentStepId = isActive ? stepOrder[stepIndex] ?? null : null
|
||||
|
||||
const resolveFirstEntryId = useCallback((): string | null => {
|
||||
return demoContextRef.current?.firstEntryId ?? getStoredDemoFirstEntryId()
|
||||
@@ -111,16 +127,29 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
||||
nav.setSelectedEntryId(null)
|
||||
nav.setActiveTab('crew')
|
||||
}
|
||||
if (stepId === 'nav_stats') {
|
||||
nav.setSelectedEntryId(null)
|
||||
nav.setActiveTab('stats')
|
||||
}
|
||||
if (stepId === 'nav_feedback') {
|
||||
nav.setSelectedEntryId(null)
|
||||
nav.setFeedbackOpen(true)
|
||||
} else {
|
||||
nav.setFeedbackOpen(false)
|
||||
}
|
||||
}, [resolveFirstEntryId])
|
||||
|
||||
const scrollToCurrentTarget = useCallback((stepId: TourStepId | null) => {
|
||||
if (!stepId) return
|
||||
const selector = TARGET_BY_STEP[stepId]
|
||||
if (!selector) return
|
||||
window.requestAnimationFrame(() => {
|
||||
const el = document.querySelector(selector)
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' })
|
||||
})
|
||||
const delayMs = stepId === 'nav_feedback' ? 180 : 0
|
||||
window.setTimeout(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
const el = document.querySelector(selector)
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' })
|
||||
})
|
||||
}, delayMs)
|
||||
}, [])
|
||||
|
||||
const startTour = useCallback((options?: { force?: boolean; demoMode?: boolean }) => {
|
||||
@@ -142,12 +171,18 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
||||
const tourProps = tourModeRef.current.demoMode ? { mode: 'demo' } : undefined
|
||||
if (outcome === 'completed') {
|
||||
trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_COMPLETED, tourProps)
|
||||
const nav = navigationRef.current
|
||||
if (nav && !tourModeRef.current.demoMode) {
|
||||
nav.setSelectedEntryId(null)
|
||||
nav.setActiveTab('stats')
|
||||
}
|
||||
} else {
|
||||
const step = STEP_ORDER[stepIndexAtDismiss] ?? 'welcome'
|
||||
const step = getStepOrder(tourModeRef.current.demoMode)[stepIndexAtDismiss] ?? 'welcome'
|
||||
trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_SKIPPED, { step, ...tourProps })
|
||||
}
|
||||
|
||||
tourModeRef.current = { demoMode: false }
|
||||
navigationRef.current?.setFeedbackOpen(false)
|
||||
setIsDemoTour(false)
|
||||
setIsActive(false)
|
||||
setStepIndex(0)
|
||||
@@ -162,12 +197,13 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
||||
}, [dismissTour, stepIndex])
|
||||
|
||||
const nextStep = useCallback(() => {
|
||||
if (stepIndex + 1 >= STEP_ORDER.length) {
|
||||
const order = getStepOrder(isDemoTour)
|
||||
if (stepIndex + 1 >= order.length) {
|
||||
dismissTour('completed', stepIndex)
|
||||
return
|
||||
}
|
||||
setStepIndex(stepIndex + 1)
|
||||
}, [dismissTour, stepIndex])
|
||||
}, [dismissTour, isDemoTour, stepIndex])
|
||||
|
||||
const prevStep = useCallback(() => {
|
||||
setStepIndex((current) => Math.max(0, current - 1))
|
||||
@@ -175,11 +211,11 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return
|
||||
const stepId = STEP_ORDER[stepIndex]
|
||||
const stepId = getStepOrder(isDemoTour)[stepIndex]
|
||||
if (!stepId) return
|
||||
applyStepSideEffects(stepId)
|
||||
scrollToCurrentTarget(stepId)
|
||||
}, [isActive, stepIndex, applyStepSideEffects, scrollToCurrentTarget])
|
||||
}, [isActive, isDemoTour, stepIndex, applyStepSideEffects, scrollToCurrentTarget])
|
||||
|
||||
const restartTour = useCallback(() => {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
@@ -220,7 +256,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
||||
isDemoTour,
|
||||
currentStepId,
|
||||
currentStepIndex: stepIndex,
|
||||
totalSteps: STEP_ORDER.length,
|
||||
totalSteps: stepOrder.length,
|
||||
startTour,
|
||||
stopTour,
|
||||
restartTour,
|
||||
@@ -244,6 +280,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
||||
skipTour,
|
||||
startTour,
|
||||
stepIndex,
|
||||
stepOrder.length,
|
||||
stopTour
|
||||
]
|
||||
)
|
||||
|
||||
@@ -406,6 +406,8 @@
|
||||
"category_general": "Allgemein",
|
||||
"category_bug": "Fehler melden",
|
||||
"category_feature": "Feature-Wunsch",
|
||||
"contact_label": "E-Mail (optional)",
|
||||
"contact_placeholder": "ihre@email.beispiel",
|
||||
"message_label": "Nachricht",
|
||||
"message_placeholder": "Beschreiben Sie Ihr Feedback…",
|
||||
"send": "Senden",
|
||||
@@ -413,7 +415,10 @@
|
||||
"cancel": "Abbrechen",
|
||||
"success": "Vielen Dank! Ihr Feedback wurde gesendet.",
|
||||
"error_send": "Feedback konnte nicht gesendet werden. Bitte versuchen Sie es später erneut.",
|
||||
"error_not_configured": "Feedback ist auf diesem Server nicht verfügbar."
|
||||
"error_invalid_email": "Bitte geben Sie eine gültige E-Mail-Adresse ein.",
|
||||
"error_not_configured": "Feedback ist auf diesem Server nicht verfügbar.",
|
||||
"error_rate_limited": "Zu viele Feedback-Nachrichten in kurzer Zeit. Bitte warten Sie einige Minuten.",
|
||||
"error_spam": "Diese Nachricht konnte nicht gesendet werden. Bitte formulieren Sie sie anders."
|
||||
},
|
||||
"demo": {
|
||||
"logbook_title": "Demo-Logbuch Ostsee",
|
||||
@@ -464,7 +469,7 @@
|
||||
"steps": {
|
||||
"welcome": {
|
||||
"title": "Willkommen an Bord!",
|
||||
"body": "Wir haben ein Demo-Logbuch mit drei Reisetagen in der Kieler Förde für Sie angelegt. Diese kurze Tour zeigt Ihnen die wichtigsten Funktionen."
|
||||
"body": "Wir haben ein Demo-Logbuch mit drei Reisetagen in der Kieler Förde für Sie angelegt. Die Beispieleinträge können Sie jederzeit löschen, wenn Sie mit dem eigenen Logbuch starten möchten. Diese kurze Tour zeigt Ihnen die wichtigsten Funktionen."
|
||||
},
|
||||
"welcome_public": {
|
||||
"title": "Willkommen an Bord!",
|
||||
@@ -494,9 +499,17 @@
|
||||
"title": "Crew-Liste",
|
||||
"body": "Verwalten Sie Besatzungsmitglieder und weisen Sie sie später Reisetagen zu."
|
||||
},
|
||||
"nav_stats": {
|
||||
"title": "Statistik-Dashboard",
|
||||
"body": "Hier sehen Sie Reisedistanzen, Verbrauch, Routenkarten und Antriebsanteile – automatisch aus Ihren Logbucheinträgen berechnet."
|
||||
},
|
||||
"nav_feedback": {
|
||||
"title": "Feedback senden",
|
||||
"body": "Über dieses Formular können Sie Fehler, Ideen oder allgemeines Feedback direkt an das Projektteam schicken – auch nach der Tour jederzeit über das Symbol oben rechts."
|
||||
},
|
||||
"finish": {
|
||||
"title": "Alles klar!",
|
||||
"body": "Sie können die Tour jederzeit unter Einstellungen erneut starten. Gute Fahrt!"
|
||||
"body": "Sie landen gleich im Statistik-Dashboard. Die Tour können Sie jederzeit unter Einstellungen erneut starten. Gute Fahrt!"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,6 +406,8 @@
|
||||
"category_general": "General",
|
||||
"category_bug": "Bug report",
|
||||
"category_feature": "Feature request",
|
||||
"contact_label": "Email (optional)",
|
||||
"contact_placeholder": "your@email.example",
|
||||
"message_label": "Message",
|
||||
"message_placeholder": "Describe your feedback…",
|
||||
"send": "Send",
|
||||
@@ -413,7 +415,10 @@
|
||||
"cancel": "Cancel",
|
||||
"success": "Thank you! Your feedback has been sent.",
|
||||
"error_send": "Could not send feedback. Please try again later.",
|
||||
"error_not_configured": "Feedback is not available on this server."
|
||||
"error_invalid_email": "Please enter a valid email address.",
|
||||
"error_not_configured": "Feedback is not available on this server.",
|
||||
"error_rate_limited": "Too many feedback messages in a short time. Please wait a few minutes.",
|
||||
"error_spam": "This message could not be sent. Please rephrase it and try again."
|
||||
},
|
||||
"demo": {
|
||||
"logbook_title": "Baltic Sea Demo Logbook",
|
||||
@@ -464,7 +469,7 @@
|
||||
"steps": {
|
||||
"welcome": {
|
||||
"title": "Welcome aboard!",
|
||||
"body": "We created a demo logbook with three travel days in Kiel Bay. This short tour shows you the key features."
|
||||
"body": "We created a demo logbook with three travel days in Kiel Bay. You can delete the sample entries anytime when you're ready to start your own logbook. This short tour shows you the key features."
|
||||
},
|
||||
"welcome_public": {
|
||||
"title": "Welcome aboard!",
|
||||
@@ -494,9 +499,17 @@
|
||||
"title": "Crew list",
|
||||
"body": "Manage crew members and assign them to travel days later."
|
||||
},
|
||||
"nav_stats": {
|
||||
"title": "Statistics dashboard",
|
||||
"body": "View travel distances, consumption, route maps, and propulsion breakdown — calculated automatically from your log entries."
|
||||
},
|
||||
"nav_feedback": {
|
||||
"title": "Send feedback",
|
||||
"body": "Use this form to report bugs, ideas, or general feedback to the project team — you can also open it anytime later via the icon in the top right."
|
||||
},
|
||||
"finish": {
|
||||
"title": "You're all set!",
|
||||
"body": "You can restart the tour anytime in Settings. Fair winds!"
|
||||
"body": "You'll land on the statistics dashboard next. You can restart the tour anytime in Settings. Fair winds!"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,41 +1,53 @@
|
||||
import { apiFetch } from './api.js'
|
||||
|
||||
export type FeedbackCategory = 'bug' | 'feature' | 'general'
|
||||
|
||||
export class FeedbackApiError extends Error {
|
||||
code: 'NOT_CONFIGURED' | 'REQUEST_FAILED'
|
||||
code: 'NOT_CONFIGURED' | 'REQUEST_FAILED' | 'INVALID_EMAIL' | 'RATE_LIMITED' | 'SPAM_DETECTED'
|
||||
|
||||
constructor(message: string, code: 'NOT_CONFIGURED' | 'REQUEST_FAILED' = 'REQUEST_FAILED') {
|
||||
constructor(
|
||||
message: string,
|
||||
code: 'NOT_CONFIGURED' | 'REQUEST_FAILED' | 'INVALID_EMAIL' | 'RATE_LIMITED' | 'SPAM_DETECTED' = 'REQUEST_FAILED'
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'FeedbackApiError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
export function isValidFeedbackEmail(email: string): boolean {
|
||||
return EMAIL_PATTERN.test(email.trim())
|
||||
}
|
||||
|
||||
export async function sendFeedback(payload: {
|
||||
category: FeedbackCategory
|
||||
message: string
|
||||
contactEmail?: string | null
|
||||
logbookId?: string | null
|
||||
logbookTitle?: string | null
|
||||
openedAt: number
|
||||
website?: string
|
||||
}): Promise<void> {
|
||||
const res = await fetch('/api/feedback', {
|
||||
const contactEmail = payload.contactEmail?.trim()
|
||||
if (contactEmail && !isValidFeedbackEmail(contactEmail)) {
|
||||
throw new FeedbackApiError('Invalid email address', 'INVALID_EMAIL')
|
||||
}
|
||||
|
||||
const res = await apiFetch('/api/feedback', {
|
||||
method: 'POST',
|
||||
headers: buildFeedbackHeaders(),
|
||||
body: JSON.stringify({
|
||||
category: payload.category,
|
||||
message: payload.message,
|
||||
contactEmail: contactEmail || undefined,
|
||||
username: localStorage.getItem('active_username') || undefined,
|
||||
logbookId: payload.logbookId || undefined,
|
||||
logbookTitle: payload.logbookTitle || undefined,
|
||||
appVersion: typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : undefined,
|
||||
pageUrl: window.location.href
|
||||
pageUrl: window.location.href,
|
||||
openedAt: payload.openedAt,
|
||||
website: payload.website || undefined
|
||||
})
|
||||
})
|
||||
|
||||
@@ -43,8 +55,15 @@ export async function sendFeedback(payload: {
|
||||
throw new FeedbackApiError('Feedback is not configured on this server', 'NOT_CONFIGURED')
|
||||
}
|
||||
|
||||
if (res.status === 429) {
|
||||
throw new FeedbackApiError('Too many feedback submissions', 'RATE_LIMITED')
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
throw new FeedbackApiError(data.error || 'Failed to send feedback')
|
||||
throw new FeedbackApiError(
|
||||
data.error || 'Failed to send feedback',
|
||||
data.code === 'SPAM_DETECTED' ? 'SPAM_DETECTED' : 'REQUEST_FAILED'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(() => {})
|
||||
}
|
||||
|
||||
+30
-14
@@ -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>()
|
||||
@@ -124,21 +126,39 @@ function scheduleResync(logbookId: string) {
|
||||
})
|
||||
}
|
||||
|
||||
type LogbookPushAccess = 'OWNER' | 'WRITE' | 'READ' | 'UNKNOWN'
|
||||
|
||||
async function resolveLogbookPushAccess(logbookId: string): Promise<LogbookPushAccess> {
|
||||
const access = await getLogbookAccess(logbookId)
|
||||
if (access) {
|
||||
return access.isOwner || access.role === 'OWNER' ? 'OWNER' : access.role
|
||||
}
|
||||
|
||||
const local = await db.logbooks.get(logbookId)
|
||||
if (local?.isShared !== 1) return 'OWNER'
|
||||
if (local.collaborationRole === 'READ') return 'READ'
|
||||
if (local.collaborationRole === 'WRITE') return 'WRITE'
|
||||
return 'UNKNOWN'
|
||||
}
|
||||
|
||||
// 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 pending = await coalesceSyncQueue(logbookId)
|
||||
if (pending.length === 0) return true
|
||||
|
||||
const pushAccess = await resolveLogbookPushAccess(logbookId)
|
||||
if (pushAccess === 'READ' || pushAccess === 'UNKNOWN') {
|
||||
console.warn(
|
||||
`[sync] Skipping push for logbook ${logbookId} (${pushAccess}); ${pending.length} queue item(s) retained`
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
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 +207,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) {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -30,6 +30,7 @@ services:
|
||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
|
||||
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:support@kapteins-daagbok.eu}
|
||||
OpenWeatherMapAPIKey: ${OpenWeatherMapAPIKey:-}
|
||||
SESSION_SECRET: ${SESSION_SECRET:-}
|
||||
NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh}
|
||||
NTFY_TOPIC: ${NTFY_TOPIC:-}
|
||||
NTFY_TOKEN: ${NTFY_TOKEN:-}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Ziel:** Der Owner eines Logbuchs soll per Web Push informiert werden, wenn ein eingeladenes Crewmitglied (Collaborator mit WRITE) Änderungen synchronisiert — auch wenn die App geschlossen ist.
|
||||
|
||||
**Stand Codebase:** Service Worker nur für PWA-Caching/Updates (`vite-plugin-pwa`). Sync läuft per `setInterval` im Tab (~30 s). Kein `web-push`, keine Push-Subscriptions in der DB.
|
||||
**Stand Codebase:** Push MVP ist implementiert (`web-push`, Prisma-Modelle, `routes/push.ts`, `pushNotify.ts`, Custom SW `sw.ts`, Settings-UI). API-Auth erfolgt über **HttpOnly-Session-Cookie** (`daagbok_session`) nach WebAuthn-Login — nicht mehr über `X-User-Id`.
|
||||
|
||||
---
|
||||
|
||||
@@ -48,7 +48,7 @@ sequenceDiagram
|
||||
participant SW as Service Worker (Owner)
|
||||
participant Owner as Owner-Gerät
|
||||
|
||||
Crew->>API: POST /api/sync/push (X-User-Id: crew)
|
||||
Crew->>API: POST /api/sync/push (Session-Cookie)
|
||||
API->>DB: Payloads speichern
|
||||
API->>API: collaborator change? → notify owner
|
||||
API->>DB: PushSubscriptions (owner)
|
||||
@@ -129,6 +129,8 @@ npm install web-push --workspace=server
|
||||
`.env` (Beispiel):
|
||||
|
||||
```env
|
||||
ORIGIN=https://kapteins-daagbok.eu
|
||||
SESSION_SECRET=... # min. 32 Zeichen, Pflicht in Produktion
|
||||
VAPID_PUBLIC_KEY=...
|
||||
VAPID_PRIVATE_KEY=...
|
||||
VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu
|
||||
@@ -147,12 +149,12 @@ npx web-push generate-vapid-keys
|
||||
| Methode | Pfad | Auth | Beschreibung |
|
||||
|---------|------|------|--------------|
|
||||
| `GET` | `/vapid-public-key` | nein | Liefert Public Key für `pushManager.subscribe` |
|
||||
| `PUT` | `/subscription` | `X-User-Id` | Upsert Subscription (endpoint + keys) |
|
||||
| `DELETE` | `/subscription` | `X-User-Id` | Body: `{ endpoint }` — Gerät abmelden |
|
||||
| `GET` | `/prefs` | `X-User-Id` | Liest `collaboratorChangesEnabled` |
|
||||
| `PUT` | `/prefs` | `X-User-Id` | Body: `{ collaboratorChangesEnabled: boolean }` |
|
||||
| `PUT` | `/subscription` | Session-Cookie | Upsert Subscription (endpoint + keys) |
|
||||
| `DELETE` | `/subscription` | Session-Cookie | Body: `{ endpoint }` — Gerät abmelden |
|
||||
| `GET` | `/prefs` | Session-Cookie | Liest `collaboratorChangesEnabled` |
|
||||
| `PUT` | `/prefs` | Session-Cookie | Body: `{ collaboratorChangesEnabled: boolean }` |
|
||||
|
||||
`requireUser`-Middleware wie in `sync.ts` / `collaboration.ts` wiederverwenden.
|
||||
`requireUser` in `server/src/middleware/auth.ts` — liest und verifiziert `daagbok_session` (HMAC-signiert). Client sendet `credentials: 'include'` (`client/src/services/api.ts`).
|
||||
|
||||
### 5.3 Benachrichtigungs-Service
|
||||
|
||||
@@ -307,7 +309,7 @@ Sicherstellen, dass Route `/logbook/:logbookId` (oder bestehende Logbuch-Route)
|
||||
|
||||
| Risiko | Maßnahme |
|
||||
|--------|----------|
|
||||
| Fremde subscriben mit fremder `userId` | Nur authentifizierte Requests (`X-User-Id` wie heute — langfristig Session/JWT erwägen). |
|
||||
| Fremde subscriben mit fremder `userId` | Session-Cookie nach WebAuthn; `userId` kommt aus verifiziertem Token, nicht aus Client-Header. |
|
||||
| Push an falschen User | `notifyOwner` nur mit `logbook.userId` aus DB, nie aus Client-Body. |
|
||||
| Endpoint-Injection | `endpoint` muss HTTPS-URL sein; Länge begrenzen. |
|
||||
| Spam durch Crew | Rate-Limit + nur `create`/`update` im MVP. |
|
||||
@@ -377,7 +379,7 @@ Sicherstellen, dass Route `/logbook/:logbookId` (oder bestehende Logbuch-Route)
|
||||
1. **Nur Owner oder auch andere Collaborators?** — MVP: nur Owner.
|
||||
2. **Rate-Limit-Dauer:** 2 min vs. 5 min — Empfehlung: **3 min** pro Logbuch.
|
||||
3. **Mehrere Geräte des Owners:** alle Subscriptions benachrichtigen — ja (Standard).
|
||||
4. **Auth verbessern:** Push-Routen jetzt mit `X-User-Id` wie Rest der API; Roadmap-Item: echte Session.
|
||||
4. ~~**Auth verbessern**~~ — erledigt: HttpOnly-Session-Cookie für alle geschützten Routen inkl. Push.
|
||||
|
||||
---
|
||||
|
||||
@@ -410,8 +412,13 @@ client/
|
||||
src/components/SettingsForm.tsx # Integration
|
||||
src/i18n/locales/de.json, en.json
|
||||
.env.example # VITE_VAPID_PUBLIC_KEY
|
||||
src/services/api.ts # apiFetch (credentials: include)
|
||||
|
||||
server/
|
||||
src/session.ts # Session-Cookie signieren/verifizieren
|
||||
src/middleware/auth.ts # requireUser, requireReauth
|
||||
|
||||
docs/
|
||||
push-notifications-plan.md # dieses Dokument
|
||||
README.md # Feature-Zeile + Env-Hinweis
|
||||
README.md # Auth/Session, Env-Hinweise
|
||||
```
|
||||
|
||||
@@ -44,6 +44,7 @@ if [ "$IS_READY" = true ]; then
|
||||
echo "SUCCESS: Services are up and healthy!"
|
||||
echo " -> App Frontend (Nginx): http://localhost"
|
||||
echo " -> Backend API Health: http://localhost/api/health"
|
||||
echo " -> Auth: session cookie (set ORIGIN=http://localhost, SESSION_SECRET in .env)"
|
||||
echo "=================================================="
|
||||
else
|
||||
echo "WARNING: Backend did not transition to healthy in time."
|
||||
|
||||
@@ -38,6 +38,35 @@ resolve_node_toolchain() {
|
||||
command -v npm >/dev/null 2>&1
|
||||
}
|
||||
|
||||
check_dev_env() {
|
||||
local env_file="$REPO_ROOT/.env"
|
||||
if [ ! -f "$env_file" ]; then
|
||||
echo "Warning: $env_file missing — copy from .env.example (RP_ID, ORIGIN, SESSION_SECRET)."
|
||||
return
|
||||
fi
|
||||
|
||||
local origin_line origin_val
|
||||
origin_line=$(grep -E '^ORIGIN=' "$env_file" | tail -1 || true)
|
||||
origin_val="${origin_line#ORIGIN=}"
|
||||
origin_val="${origin_val%\"}"
|
||||
origin_val="${origin_val#\"}"
|
||||
local expected_origin="http://localhost:$CLIENT_PORT"
|
||||
if [ -n "$origin_val" ] && [ "$origin_val" != "$expected_origin" ]; then
|
||||
echo "Warning: ORIGIN=$origin_val — for Vite dev use ORIGIN=$expected_origin (session cookie + CORS)."
|
||||
fi
|
||||
|
||||
local secret_line secret_val
|
||||
secret_line=$(grep -E '^SESSION_SECRET=' "$env_file" | tail -1 || true)
|
||||
secret_val="${secret_line#SESSION_SECRET=}"
|
||||
secret_val="${secret_val%\"}"
|
||||
secret_val="${secret_val#\"}"
|
||||
if [ -z "$secret_val" ]; then
|
||||
echo "Note: SESSION_SECRET is empty — backend uses a dev-only fallback (not for production)."
|
||||
elif [ "${#secret_val}" -lt 32 ]; then
|
||||
echo "Warning: SESSION_SECRET should be at least 32 characters."
|
||||
fi
|
||||
}
|
||||
|
||||
require_node_toolchain() {
|
||||
if resolve_node_toolchain; then
|
||||
echo "Using Node $(node -v), npm $(npm -v)"
|
||||
@@ -62,6 +91,7 @@ echo "========================================"
|
||||
echo "Preparing to (re)start services..."
|
||||
|
||||
require_node_toolchain
|
||||
check_dev_env
|
||||
|
||||
# Clean up processes running on ports
|
||||
cleanup_port() {
|
||||
@@ -155,6 +185,11 @@ if [ ! -d node_modules ]; then
|
||||
kill "$BACKEND_PID" 2>/dev/null
|
||||
exit 1
|
||||
fi
|
||||
# Vite 6+ via plugin-react 4; refresh lockfile after package.json changes
|
||||
if ! node -e "require.resolve('vite/package.json')" 2>/dev/null; then
|
||||
echo "Client dependencies incomplete — running npm ci..."
|
||||
npm ci || exit 1
|
||||
fi
|
||||
npm run dev &
|
||||
CLIENT_PID=$!
|
||||
cd "$REPO_ROOT" || exit 1
|
||||
@@ -170,6 +205,8 @@ echo "========================================"
|
||||
echo "Dev services are now running:"
|
||||
echo " -> Backend: http://localhost:$SERVER_PORT"
|
||||
echo " -> Frontend: http://localhost:$CLIENT_PORT"
|
||||
echo " -> API auth: HttpOnly session cookie (after Passkey login)"
|
||||
echo " -> Health: http://localhost:$SERVER_PORT/api/health"
|
||||
echo "========================================"
|
||||
echo "Press Ctrl+C to terminate both servers."
|
||||
echo "========================================"
|
||||
|
||||
Generated
+72
@@ -10,13 +10,17 @@
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.10.2",
|
||||
"@simplewebauthn/server": "^9.0.3",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"express-rate-limit": "^8.5.2",
|
||||
"helmet": "^8.2.0",
|
||||
"prisma": "^5.10.2",
|
||||
"web-push": "^3.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.11.24",
|
||||
@@ -657,6 +661,16 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cookie-parser": {
|
||||
"version": "1.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
|
||||
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cors": {
|
||||
"version": "2.8.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
||||
@@ -932,6 +946,25 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-parser": {
|
||||
"version": "1.4.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
|
||||
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "0.7.2",
|
||||
"cookie-signature": "1.0.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-parser/node_modules/cookie-signature": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||
@@ -1175,6 +1208,24 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.5.2",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
|
||||
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "^10.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/express-rate-limit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||
@@ -1307,6 +1358,18 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/helmet": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.2.0.tgz",
|
||||
"integrity": "sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/EvanHahn"
|
||||
}
|
||||
},
|
||||
"node_modules/http_ece": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||
@@ -1390,6 +1453,15 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
|
||||
@@ -12,13 +12,17 @@
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.10.2",
|
||||
"@simplewebauthn/server": "^9.0.3",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"express-rate-limit": "^8.5.2",
|
||||
"helmet": "^8.2.0",
|
||||
"prisma": "^5.10.2",
|
||||
"web-push": "^3.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.11.24",
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { CorsOptions } from 'cors'
|
||||
|
||||
function normalizeOrigin(origin: string): string {
|
||||
return origin.trim().replace(/\/$/, '')
|
||||
}
|
||||
|
||||
/** Origins allowed for credentialed CORS (must match the browser frontend URL exactly). */
|
||||
export function getAllowedCorsOrigins(): Set<string> {
|
||||
const raw =
|
||||
process.env.CORS_ORIGINS?.trim() ||
|
||||
process.env.ORIGIN?.trim() ||
|
||||
'http://localhost:5173'
|
||||
|
||||
const origins = raw
|
||||
.split(',')
|
||||
.map(normalizeOrigin)
|
||||
.filter(Boolean)
|
||||
|
||||
const allowed = new Set(origins)
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
for (const dev of [
|
||||
'http://localhost:5173',
|
||||
'http://127.0.0.1:5173',
|
||||
'http://localhost:4173'
|
||||
]) {
|
||||
allowed.add(dev)
|
||||
}
|
||||
}
|
||||
|
||||
return allowed
|
||||
}
|
||||
|
||||
export function buildCorsOptions(): CorsOptions {
|
||||
const allowed = getAllowedCorsOrigins()
|
||||
|
||||
return {
|
||||
origin(origin, callback) {
|
||||
// Non-browser clients, same-origin via reverse proxy (no Origin header)
|
||||
if (!origin) {
|
||||
callback(null, true)
|
||||
return
|
||||
}
|
||||
|
||||
const normalized = normalizeOrigin(origin)
|
||||
if (allowed.has(normalized)) {
|
||||
callback(null, normalized)
|
||||
return
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`[cors] Rejected origin "${origin}". Allowed: ${[...allowed].join(', ')}`
|
||||
)
|
||||
callback(new Error('Not allowed by CORS'))
|
||||
},
|
||||
credentials: true
|
||||
}
|
||||
}
|
||||
+31
-3
@@ -1,5 +1,8 @@
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import cookieParser from 'cookie-parser'
|
||||
import helmet from 'helmet'
|
||||
import rateLimit from 'express-rate-limit'
|
||||
import dotenv from 'dotenv'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
@@ -12,6 +15,7 @@ import pushRouter from './routes/push.js'
|
||||
import weatherRouter from './routes/weather.js'
|
||||
import feedbackRouter from './routes/feedback.js'
|
||||
import { prisma } from './db.js'
|
||||
import { buildCorsOptions } from './cors.js'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
@@ -21,9 +25,34 @@ dotenv.config({ path: resolve(__dirname, '../.env') })
|
||||
const app = express()
|
||||
const PORT = process.env.PORT || 5000
|
||||
|
||||
app.use(cors())
|
||||
app.use(
|
||||
helmet({
|
||||
contentSecurityPolicy: false,
|
||||
crossOriginEmbedderPolicy: false
|
||||
})
|
||||
)
|
||||
app.use(cors(buildCorsOptions()))
|
||||
app.use(cookieParser())
|
||||
// Encrypted sync payloads (photos, GPS tracks) can be large — align with nginx client_max_body_size
|
||||
app.use(express.json({ limit: '50mb' }))
|
||||
|
||||
const authLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 60,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false
|
||||
})
|
||||
|
||||
const apiLimiter = rateLimit({
|
||||
windowMs: 1 * 60 * 1000,
|
||||
max: 300,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false
|
||||
})
|
||||
|
||||
app.use('/api/auth', authLimiter)
|
||||
app.use('/api', apiLimiter)
|
||||
|
||||
// Mount routes
|
||||
app.use('/api/auth', authRouter)
|
||||
app.use('/api/logbooks', logbooksRouter)
|
||||
@@ -44,11 +73,10 @@ app.get('/api/health', async (req, res) => {
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'Kapteins Daagbok Backend'
|
||||
})
|
||||
} catch (err: any) {
|
||||
} catch {
|
||||
res.status(500).json({
|
||||
status: 'error',
|
||||
database: 'disconnected',
|
||||
error: err.message,
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'Kapteins Daagbok Backend'
|
||||
})
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Request, Response, NextFunction } from 'express'
|
||||
import { hasValidReauth, readSessionFromRequest } from '../session.js'
|
||||
|
||||
export interface AuthedRequest extends Request {
|
||||
userId: string
|
||||
session: NonNullable<ReturnType<typeof readSessionFromRequest>>
|
||||
}
|
||||
|
||||
export function requireUser(req: Request, res: Response, next: NextFunction): void {
|
||||
const session = readSessionFromRequest(req)
|
||||
if (!session) {
|
||||
res.status(401).json({ error: 'Unauthorized: valid session required' })
|
||||
return
|
||||
}
|
||||
;(req as AuthedRequest).userId = session.userId
|
||||
;(req as AuthedRequest).session = session
|
||||
next()
|
||||
}
|
||||
|
||||
export function requireReauth(req: Request, res: Response, next: NextFunction): void {
|
||||
const session = readSessionFromRequest(req)
|
||||
if (!session) {
|
||||
res.status(401).json({ error: 'Unauthorized: valid session required' })
|
||||
return
|
||||
}
|
||||
if (!hasValidReauth(session)) {
|
||||
res.status(403).json({ error: 'Recent passkey confirmation required' })
|
||||
return
|
||||
}
|
||||
;(req as AuthedRequest).userId = session.userId
|
||||
;(req as AuthedRequest).session = session
|
||||
next()
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import rateLimit from 'express-rate-limit'
|
||||
import type { AuthedRequest } from './auth.js'
|
||||
|
||||
const MIN_SUBMIT_MS = 2_000
|
||||
const MAX_SUBMIT_MS = 60 * 60 * 1000
|
||||
const DUPLICATE_WINDOW_MS = 10 * 60 * 1000
|
||||
const MAX_URLS = 8
|
||||
const MAX_REPEATED_CHAR = 40
|
||||
|
||||
const recentByUser = new Map<string, { hash: string; at: number }>()
|
||||
|
||||
function normalizeMessage(message: string): string {
|
||||
return message.trim().toLowerCase().replace(/\s+/g, ' ')
|
||||
}
|
||||
|
||||
function countUrls(message: string): number {
|
||||
const matches = message.match(/https?:\/\/|www\./gi)
|
||||
return matches?.length ?? 0
|
||||
}
|
||||
|
||||
function hasExcessiveRepeatedChars(message: string): boolean {
|
||||
return /(.)\1{39,}/.test(message)
|
||||
}
|
||||
|
||||
function pruneRecentEntries(now: number): void {
|
||||
for (const [userId, entry] of recentByUser) {
|
||||
if (now - entry.at > DUPLICATE_WINDOW_MS) {
|
||||
recentByUser.delete(userId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type FeedbackSpamVerdict = 'ok' | 'silent_reject' | 'reject'
|
||||
|
||||
export function analyzeFeedbackSpam(
|
||||
userId: string,
|
||||
payload: { message: string; website?: unknown; openedAt?: unknown }
|
||||
): FeedbackSpamVerdict {
|
||||
if (typeof payload.website === 'string' && payload.website.trim()) {
|
||||
return 'silent_reject'
|
||||
}
|
||||
|
||||
if (typeof payload.openedAt === 'number' && Number.isFinite(payload.openedAt)) {
|
||||
const elapsed = Date.now() - payload.openedAt
|
||||
if (elapsed < MIN_SUBMIT_MS || elapsed > MAX_SUBMIT_MS) {
|
||||
return 'silent_reject'
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = normalizeMessage(payload.message)
|
||||
const now = Date.now()
|
||||
pruneRecentEntries(now)
|
||||
|
||||
const previous = recentByUser.get(userId)
|
||||
if (previous && previous.hash === normalized && now - previous.at < DUPLICATE_WINDOW_MS) {
|
||||
return 'reject'
|
||||
}
|
||||
|
||||
if (countUrls(payload.message) > MAX_URLS || hasExcessiveRepeatedChars(payload.message)) {
|
||||
return 'reject'
|
||||
}
|
||||
|
||||
recentByUser.set(userId, { hash: normalized, at: now })
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
export const feedbackLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 5,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => (req as AuthedRequest).userId ?? req.ip ?? 'unknown',
|
||||
handler: (_req, res) => {
|
||||
res.status(429).json({
|
||||
error: 'Too many feedback submissions. Please try again later.',
|
||||
code: 'RATE_LIMITED'
|
||||
})
|
||||
}
|
||||
})
|
||||
+119
-37
@@ -6,6 +6,14 @@ import {
|
||||
verifyAuthenticationResponse
|
||||
} from '@simplewebauthn/server'
|
||||
import { prisma } from '../db.js'
|
||||
import { requireReauth, requireUser } from '../middleware/auth.js'
|
||||
import {
|
||||
clearSessionCookie,
|
||||
extendReauth,
|
||||
readSessionFromRequest,
|
||||
setSessionCookie,
|
||||
setSessionTokenCookie
|
||||
} from '../session.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -13,12 +21,9 @@ const rpName = 'Kapteins Daagbok'
|
||||
const rpID = process.env.RP_ID || 'localhost'
|
||||
const origin = process.env.ORIGIN || 'http://localhost:5173'
|
||||
|
||||
// In-memory challenge stores
|
||||
const registrationChallenges = new Map<string, string>()
|
||||
const authenticationChallenges = new Map<string, { challenge: string; userId: string }>()
|
||||
const activeChallenges = new Set<string>()
|
||||
|
||||
// 1. Generate Registration Options
|
||||
router.post('/register-options', async (req, res) => {
|
||||
try {
|
||||
const { username } = req.body
|
||||
@@ -34,13 +39,6 @@ router.post('/register-options', async (req, res) => {
|
||||
return res.status(400).json({ error: 'User already exists' })
|
||||
}
|
||||
|
||||
// NOTE: @simplewebauthn/server v9 places `userID` verbatim into the
|
||||
// emitted `user.id` JSON field. The browser client (v13) however decodes
|
||||
// `user.id` as a base64url string. Passing a raw username therefore either
|
||||
// corrupts the user handle or, for usernames containing characters outside
|
||||
// the base64url alphabet (".", " ", "@", umlauts, ...), makes the browser
|
||||
// throw "Invalid character" before the passkey prompt even appears.
|
||||
// Encoding the username as base64url keeps the value spec-compliant.
|
||||
const userID = Buffer.from(username, 'utf8').toString('base64url')
|
||||
|
||||
const options = await generateRegistrationOptions({
|
||||
@@ -54,10 +52,9 @@ router.post('/register-options', async (req, res) => {
|
||||
residentKey: 'required',
|
||||
userVerification: 'preferred'
|
||||
},
|
||||
supportedAlgorithmIDs: [-7, -257] // ES256 and RS256
|
||||
supportedAlgorithmIDs: [-7, -257]
|
||||
})
|
||||
|
||||
// Store challenge
|
||||
registrationChallenges.set(username, options.challenge)
|
||||
|
||||
return res.json(options)
|
||||
@@ -67,7 +64,6 @@ router.post('/register-options', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 2. Verify Registration Response
|
||||
router.post('/register-verify', async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
@@ -103,7 +99,6 @@ router.post('/register-verify', async (req, res) => {
|
||||
|
||||
const { credentialID, credentialPublicKey, counter } = verification.registrationInfo
|
||||
|
||||
// Save user and credential
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
@@ -125,6 +120,7 @@ router.post('/register-verify', async (req, res) => {
|
||||
})
|
||||
|
||||
registrationChallenges.delete(username)
|
||||
setSessionCookie(res, user.id, true)
|
||||
|
||||
return res.json({ verified: true, userId: user.id })
|
||||
} catch (error: any) {
|
||||
@@ -133,12 +129,10 @@ router.post('/register-verify', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 3. Generate Authentication Options
|
||||
router.post('/login-options', async (req, res) => {
|
||||
try {
|
||||
const { username } = req.body
|
||||
|
||||
// If username is supplied, we do a targeted login, otherwise usernameless
|
||||
let allowCredentials: any[] = []
|
||||
if (username) {
|
||||
const user = await prisma.user.findUnique({
|
||||
@@ -146,7 +140,7 @@ router.post('/login-options', async (req, res) => {
|
||||
include: { credentials: true }
|
||||
})
|
||||
if (user) {
|
||||
allowCredentials = user.credentials.map(cred => ({
|
||||
allowCredentials = user.credentials.map((cred) => ({
|
||||
id: Buffer.from(cred.credentialId, 'base64url'),
|
||||
type: 'public-key',
|
||||
transports: cred.transports as any[]
|
||||
@@ -160,7 +154,6 @@ router.post('/login-options', async (req, res) => {
|
||||
userVerification: 'preferred'
|
||||
})
|
||||
|
||||
// Store challenge
|
||||
activeChallenges.add(options.challenge)
|
||||
|
||||
return res.json(options)
|
||||
@@ -170,7 +163,6 @@ router.post('/login-options', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 4. Verify Authentication Response
|
||||
router.post('/login-verify', async (req, res) => {
|
||||
try {
|
||||
const { credentialResponse, challenge } = req.body
|
||||
@@ -178,13 +170,11 @@ router.post('/login-verify', async (req, res) => {
|
||||
return res.status(400).json({ error: 'credentialResponse and challenge are required' })
|
||||
}
|
||||
|
||||
// Verify challenge
|
||||
if (!activeChallenges.has(challenge)) {
|
||||
return res.status(400).json({ error: 'Challenge not found or expired' })
|
||||
}
|
||||
activeChallenges.delete(challenge)
|
||||
|
||||
// Find the credential in DB
|
||||
const dbCred = await prisma.credential.findUnique({
|
||||
where: { credentialId: credentialResponse.id },
|
||||
include: { user: true }
|
||||
@@ -212,12 +202,13 @@ router.post('/login-verify', async (req, res) => {
|
||||
return res.status(400).json({ error: 'Authentication failed' })
|
||||
}
|
||||
|
||||
// Update counter
|
||||
await prisma.credential.update({
|
||||
where: { id: dbCred.id },
|
||||
data: { counter: BigInt(verification.authenticationInfo.newCounter) }
|
||||
})
|
||||
|
||||
setSessionCookie(res, user.id, true)
|
||||
|
||||
return res.json({
|
||||
verified: true,
|
||||
userId: user.id,
|
||||
@@ -235,16 +226,112 @@ router.post('/login-verify', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 5. Delete own account
|
||||
router.delete('/delete-account', async (req: any, res) => {
|
||||
router.get('/session', (req, res) => {
|
||||
const session = readSessionFromRequest(req)
|
||||
if (!session) {
|
||||
return res.status(401).json({ authenticated: false })
|
||||
}
|
||||
return res.json({ authenticated: true, userId: session.userId })
|
||||
})
|
||||
|
||||
router.post('/logout', (req, res) => {
|
||||
clearSessionCookie(res)
|
||||
return res.json({ success: true })
|
||||
})
|
||||
|
||||
router.post('/reauth-options', requireUser, async (req: any, res) => {
|
||||
try {
|
||||
const userId = req.headers['x-user-id']
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' })
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.userId },
|
||||
include: { credentials: true }
|
||||
})
|
||||
|
||||
if (!user || user.credentials.length === 0) {
|
||||
return res.status(400).json({ error: 'No passkey credentials found' })
|
||||
}
|
||||
|
||||
const allowCredentials = user.credentials.map((cred) => ({
|
||||
id: Buffer.from(cred.credentialId, 'base64url'),
|
||||
type: 'public-key' as const,
|
||||
transports: cred.transports as any[]
|
||||
}))
|
||||
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID,
|
||||
allowCredentials,
|
||||
userVerification: 'required'
|
||||
})
|
||||
|
||||
activeChallenges.add(options.challenge)
|
||||
|
||||
return res.json(options)
|
||||
} catch (error: any) {
|
||||
console.error('Error generating reauth options:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/reauth-verify', requireUser, async (req: any, res) => {
|
||||
try {
|
||||
const { credentialResponse, challenge } = req.body
|
||||
if (!credentialResponse || !challenge) {
|
||||
return res.status(400).json({ error: 'credentialResponse and challenge are required' })
|
||||
}
|
||||
|
||||
if (!activeChallenges.has(challenge)) {
|
||||
return res.status(400).json({ error: 'Challenge not found or expired' })
|
||||
}
|
||||
activeChallenges.delete(challenge)
|
||||
|
||||
const dbCred = await prisma.credential.findUnique({
|
||||
where: { credentialId: credentialResponse.id },
|
||||
include: { user: true }
|
||||
})
|
||||
|
||||
if (!dbCred || dbCred.userId !== req.userId) {
|
||||
return res.status(403).json({ error: 'Credential does not belong to this account' })
|
||||
}
|
||||
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response: credentialResponse,
|
||||
expectedChallenge: challenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpID,
|
||||
authenticator: {
|
||||
credentialID: Buffer.from(dbCred.credentialId, 'base64url'),
|
||||
credentialPublicKey: dbCred.publicKey,
|
||||
counter: Number(dbCred.counter)
|
||||
}
|
||||
})
|
||||
|
||||
if (!verification.verified || !verification.authenticationInfo) {
|
||||
return res.status(400).json({ error: 'Reauthentication failed' })
|
||||
}
|
||||
|
||||
await prisma.credential.update({
|
||||
where: { id: dbCred.id },
|
||||
data: { counter: BigInt(verification.authenticationInfo.newCounter) }
|
||||
})
|
||||
|
||||
const currentToken = req.cookies?.daagbok_session
|
||||
const extended = typeof currentToken === 'string' ? extendReauth(currentToken) : null
|
||||
if (extended) {
|
||||
setSessionTokenCookie(res, extended)
|
||||
} else {
|
||||
setSessionCookie(res, req.userId, true)
|
||||
}
|
||||
|
||||
return res.json({ verified: true })
|
||||
} catch (error: any) {
|
||||
console.error('Error verifying reauth:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/delete-account', requireReauth, async (req: any, res) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId }
|
||||
where: { id: req.userId }
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
@@ -252,9 +339,10 @@ router.delete('/delete-account', async (req: any, res) => {
|
||||
}
|
||||
|
||||
await prisma.user.delete({
|
||||
where: { id: userId }
|
||||
where: { id: req.userId }
|
||||
})
|
||||
|
||||
clearSessionCookie(res)
|
||||
return res.json({ success: true })
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting account:', error)
|
||||
@@ -262,14 +350,8 @@ router.delete('/delete-account', async (req: any, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 6. Enroll PRF encrypted master key
|
||||
router.post('/enroll-prf', async (req: any, res) => {
|
||||
router.post('/enroll-prf', requireReauth, async (req: any, res) => {
|
||||
try {
|
||||
const userId = req.headers['x-user-id']
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' })
|
||||
}
|
||||
|
||||
const { encryptedMasterKeyPrf, encryptedMasterKeyPrfIv, encryptedMasterKeyPrfTag } = req.body
|
||||
if (!encryptedMasterKeyPrf || !encryptedMasterKeyPrfIv || !encryptedMasterKeyPrfTag) {
|
||||
return res.status(400).json({ error: 'Missing required PRF key fields' })
|
||||
@@ -284,7 +366,7 @@ router.post('/enroll-prf', async (req: any, res) => {
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
where: { id: req.userId },
|
||||
data: {
|
||||
encryptedMasterKeyPrf,
|
||||
encryptedMasterKeyPrfIv,
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
import { Router } from 'express'
|
||||
import { prisma } from '../db.js'
|
||||
import { requireUser } from '../middleware/auth.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// Middleware to extract user ID from headers (for authenticated routes)
|
||||
const requireUser = (req: any, res: any, next: any) => {
|
||||
const userId = req.headers['x-user-id']
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' })
|
||||
}
|
||||
req.userId = userId
|
||||
next()
|
||||
}
|
||||
|
||||
// 1. Get invitation details (public route, does not require authentication)
|
||||
router.get('/invite-details', async (req: any, res) => {
|
||||
try {
|
||||
|
||||
@@ -1,31 +1,49 @@
|
||||
import { Router } from 'express'
|
||||
import { isNtfyConfigured, sendFeedbackViaNtfy } from '../services/ntfyNotify.js'
|
||||
import { requireUser } from '../middleware/auth.js'
|
||||
import { analyzeFeedbackSpam, feedbackLimiter } from '../middleware/feedbackProtection.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const VALID_CATEGORIES = new Set(['bug', 'feature', 'general'])
|
||||
const MAX_MESSAGE_LENGTH = 2000
|
||||
const MAX_EMAIL_LENGTH = 254
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
const requireUser = (req: any, res: any, next: any) => {
|
||||
const userId = req.headers['x-user-id']
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' })
|
||||
}
|
||||
req.userId = userId
|
||||
next()
|
||||
function parseOptionalEmail(value: unknown): string | undefined {
|
||||
if (value === undefined || value === null || value === '') return undefined
|
||||
if (typeof value !== 'string') return undefined
|
||||
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return undefined
|
||||
if (trimmed.length > MAX_EMAIL_LENGTH) return undefined
|
||||
if (!EMAIL_PATTERN.test(trimmed)) return undefined
|
||||
|
||||
return trimmed
|
||||
}
|
||||
|
||||
router.get('/status', requireUser, (_req, res) => {
|
||||
res.json({ enabled: isNtfyConfigured() })
|
||||
})
|
||||
|
||||
router.post('/', requireUser, async (req: any, res) => {
|
||||
router.post('/', requireUser, feedbackLimiter, async (req: any, res) => {
|
||||
try {
|
||||
if (!isNtfyConfigured()) {
|
||||
return res.status(503).json({ error: 'Feedback is not configured on this server' })
|
||||
}
|
||||
|
||||
const { category, message, username, logbookId, logbookTitle, appVersion, pageUrl } = req.body ?? {}
|
||||
const {
|
||||
category,
|
||||
message,
|
||||
username,
|
||||
contactEmail,
|
||||
logbookId,
|
||||
logbookTitle,
|
||||
appVersion,
|
||||
pageUrl,
|
||||
website,
|
||||
openedAt
|
||||
} = req.body ?? {}
|
||||
|
||||
if (typeof category !== 'string' || !VALID_CATEGORIES.has(category)) {
|
||||
return res.status(400).json({ error: 'Invalid category' })
|
||||
@@ -40,10 +58,34 @@ router.post('/', requireUser, async (req: any, res) => {
|
||||
return res.status(400).json({ error: `Message must be at most ${MAX_MESSAGE_LENGTH} characters` })
|
||||
}
|
||||
|
||||
let parsedContactEmail: string | undefined
|
||||
if (contactEmail !== undefined && contactEmail !== null && String(contactEmail).trim()) {
|
||||
parsedContactEmail = parseOptionalEmail(contactEmail)
|
||||
if (!parsedContactEmail) {
|
||||
return res.status(400).json({ error: 'Invalid email address' })
|
||||
}
|
||||
}
|
||||
|
||||
const spamVerdict = analyzeFeedbackSpam(req.userId, {
|
||||
message: trimmedMessage,
|
||||
website,
|
||||
openedAt
|
||||
})
|
||||
if (spamVerdict === 'silent_reject') {
|
||||
return res.json({ ok: true })
|
||||
}
|
||||
if (spamVerdict === 'reject') {
|
||||
return res.status(400).json({
|
||||
error: 'This feedback could not be sent. Please change your message and try again.',
|
||||
code: 'SPAM_DETECTED'
|
||||
})
|
||||
}
|
||||
|
||||
await sendFeedbackViaNtfy({
|
||||
category,
|
||||
message: trimmedMessage,
|
||||
username: typeof username === 'string' ? username.trim() : undefined,
|
||||
contactEmail: parsedContactEmail,
|
||||
userId: req.userId,
|
||||
logbookId: typeof logbookId === 'string' ? logbookId.trim() : undefined,
|
||||
logbookTitle: typeof logbookTitle === 'string' ? logbookTitle.trim() : undefined,
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
import { Router } from 'express'
|
||||
import { prisma } from '../db.js'
|
||||
import { requireUser } from '../middleware/auth.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// Middleware to extract user ID from headers
|
||||
const requireUser = (req: any, res: any, next: any) => {
|
||||
const userId = req.headers['x-user-id']
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' })
|
||||
}
|
||||
req.userId = userId
|
||||
next()
|
||||
}
|
||||
|
||||
router.use(requireUser)
|
||||
|
||||
// 1. Get all logbooks for the authenticated user (owned and shared)
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import { Router } from 'express'
|
||||
import { prisma } from '../db.js'
|
||||
import { requireUser } from '../middleware/auth.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const requireUser = (req: any, res: any, next: any) => {
|
||||
const userId = req.headers['x-user-id']
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' })
|
||||
}
|
||||
req.userId = userId
|
||||
next()
|
||||
}
|
||||
|
||||
function isValidHttpsEndpoint(endpoint: unknown): endpoint is string {
|
||||
if (typeof endpoint !== 'string' || endpoint.length > 2048) return false
|
||||
try {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
verifyAuthenticationResponse
|
||||
} from '@simplewebauthn/server'
|
||||
import { prisma } from '../db.js'
|
||||
import { requireUser } from '../middleware/auth.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -31,15 +32,6 @@ function pruneExpiredChallenges() {
|
||||
}
|
||||
}
|
||||
|
||||
const requireUser = (req: any, res: any, next: any) => {
|
||||
const userId = req.headers['x-user-id']
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' })
|
||||
}
|
||||
req.userId = userId
|
||||
next()
|
||||
}
|
||||
|
||||
router.use(requireUser)
|
||||
|
||||
async function getLogbookWithAccess(logbookId: string, userId: string) {
|
||||
|
||||
+11
-15
@@ -1,19 +1,10 @@
|
||||
import { Router } from 'express'
|
||||
import { prisma } from '../db.js'
|
||||
import { notifyOwnerOfCollaboratorChanges } from '../services/pushNotify.js'
|
||||
import { requireUser } from '../middleware/auth.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// Middleware to extract user ID from headers
|
||||
const requireUser = (req: any, res: any, next: any) => {
|
||||
const userId = req.headers['x-user-id']
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' })
|
||||
}
|
||||
req.userId = userId
|
||||
next()
|
||||
}
|
||||
|
||||
router.use(requireUser)
|
||||
|
||||
// 1. Push local changes to the server
|
||||
@@ -99,7 +90,7 @@ router.post('/push', async (req: any, res) => {
|
||||
}
|
||||
|
||||
const isOwner = logbook.userId === req.userId
|
||||
const isCollaborator = await prisma.collaboration.findUnique({
|
||||
const collaboration = await prisma.collaboration.findUnique({
|
||||
where: {
|
||||
logbookId_userId: {
|
||||
logbookId,
|
||||
@@ -108,11 +99,16 @@ router.post('/push', async (req: any, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
if (!isOwner && !isCollaborator) {
|
||||
if (!isOwner && !collaboration) {
|
||||
results.push({ payloadId, status: 'error', error: 'Forbidden: Access denied' })
|
||||
continue
|
||||
}
|
||||
|
||||
if (!isOwner && (!collaboration || collaboration.role !== 'WRITE')) {
|
||||
results.push({ payloadId, status: 'error', error: 'Forbidden: WRITE access required' })
|
||||
continue
|
||||
}
|
||||
|
||||
if (type === 'logbook' && action === 'delete') {
|
||||
if (!isOwner) {
|
||||
results.push({ payloadId, status: 'error', error: 'Forbidden: Only owner can delete logbook' })
|
||||
@@ -244,7 +240,7 @@ router.post('/push', async (req: any, res) => {
|
||||
logbook.userId,
|
||||
logbookId,
|
||||
isOwner,
|
||||
isCollaborator,
|
||||
collaboration,
|
||||
action,
|
||||
type
|
||||
)
|
||||
@@ -284,7 +280,7 @@ router.get('/pull', async (req: any, res) => {
|
||||
}
|
||||
|
||||
const isOwner = logbook.userId === req.userId
|
||||
const isCollaborator = await prisma.collaboration.findUnique({
|
||||
const collaboration = await prisma.collaboration.findUnique({
|
||||
where: {
|
||||
logbookId_userId: {
|
||||
logbookId,
|
||||
@@ -293,7 +289,7 @@ router.get('/pull', async (req: any, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
if (!isOwner && !isCollaborator) {
|
||||
if (!isOwner && !collaboration) {
|
||||
return res.status(403).json({ error: 'Forbidden: Access denied' })
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
import { Router } from 'express'
|
||||
import { requireUser } from '../middleware/auth.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const requireUser = (req: any, res: any, next: any) => {
|
||||
const userId = req.headers['x-user-id']
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' })
|
||||
}
|
||||
req.userId = userId
|
||||
next()
|
||||
}
|
||||
|
||||
function resolveOwmApiKey(userProvidedKey: unknown): string | null {
|
||||
if (typeof userProvidedKey === 'string' && userProvidedKey.trim()) {
|
||||
return userProvidedKey.trim()
|
||||
@@ -21,7 +13,7 @@ function resolveOwmApiKey(userProvidedKey: unknown): string | null {
|
||||
return fromEnv || null
|
||||
}
|
||||
|
||||
router.get('/current', requireUser, async (req: any, res) => {
|
||||
router.get('/current', requireUser, async (req, res) => {
|
||||
try {
|
||||
const { lat, lon, q } = req.query
|
||||
const apiKey = resolveOwmApiKey(req.headers['x-owm-api-key'])
|
||||
|
||||
@@ -2,6 +2,7 @@ export interface FeedbackPayload {
|
||||
category: string
|
||||
message: string
|
||||
username?: string
|
||||
contactEmail?: string
|
||||
userId: string
|
||||
logbookId?: string
|
||||
logbookTitle?: string
|
||||
@@ -40,6 +41,10 @@ export async function sendFeedbackViaNtfy(payload: FeedbackPayload): Promise<voi
|
||||
`User ID: ${payload.userId}`
|
||||
]
|
||||
|
||||
if (payload.contactEmail) {
|
||||
lines.push(`Contact: ${payload.contactEmail}`)
|
||||
}
|
||||
|
||||
if (payload.logbookTitle || payload.logbookId) {
|
||||
lines.push(`Logbook: ${payload.logbookTitle || payload.logbookId}`)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import crypto from 'crypto'
|
||||
import type { CookieOptions, Request, Response } from 'express'
|
||||
|
||||
export const SESSION_COOKIE = 'daagbok_session'
|
||||
const SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
|
||||
export const REAUTH_MAX_AGE_MS = 10 * 60 * 1000
|
||||
|
||||
export interface SessionPayload {
|
||||
userId: string
|
||||
exp: number
|
||||
reauthExp?: number
|
||||
}
|
||||
|
||||
function sessionSecret(): string {
|
||||
const secret = process.env.SESSION_SECRET?.trim()
|
||||
if (secret && secret.length >= 32) return secret
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new Error('SESSION_SECRET must be set in production (min. 32 characters)')
|
||||
}
|
||||
return 'dev-only-insecure-session-secret-change-me!!'
|
||||
}
|
||||
|
||||
function sign(data: string): string {
|
||||
return crypto.createHmac('sha256', sessionSecret()).update(data).digest('base64url')
|
||||
}
|
||||
|
||||
export function createSessionToken(userId: string, withReauth = true): string {
|
||||
const payload: SessionPayload = {
|
||||
userId,
|
||||
exp: Date.now() + SESSION_MAX_AGE_MS,
|
||||
...(withReauth ? { reauthExp: Date.now() + REAUTH_MAX_AGE_MS } : {})
|
||||
}
|
||||
const body = Buffer.from(JSON.stringify(payload)).toString('base64url')
|
||||
const signature = sign(body)
|
||||
return `${body}.${signature}`
|
||||
}
|
||||
|
||||
export function extendReauth(token: string): string | null {
|
||||
const payload = verifySessionToken(token)
|
||||
if (!payload) return null
|
||||
payload.reauthExp = Date.now() + REAUTH_MAX_AGE_MS
|
||||
const body = Buffer.from(JSON.stringify(payload)).toString('base64url')
|
||||
return `${body}.${sign(body)}`
|
||||
}
|
||||
|
||||
export function verifySessionToken(token: string | undefined): SessionPayload | null {
|
||||
if (!token || typeof token !== 'string') return null
|
||||
const dot = token.lastIndexOf('.')
|
||||
if (dot <= 0) return null
|
||||
const body = token.slice(0, dot)
|
||||
const sig = token.slice(dot + 1)
|
||||
if (sig !== sign(body)) return null
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as SessionPayload
|
||||
if (!payload.userId || typeof payload.exp !== 'number') return null
|
||||
if (payload.exp <= Date.now()) return null
|
||||
return payload
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function readSessionFromRequest(req: Request): SessionPayload | null {
|
||||
const raw = req.cookies?.[SESSION_COOKIE]
|
||||
if (typeof raw !== 'string') return null
|
||||
return verifySessionToken(raw)
|
||||
}
|
||||
|
||||
export function sessionCookieOptions(): CookieOptions {
|
||||
const origin = process.env.ORIGIN || 'http://localhost:5173'
|
||||
const secure = origin.startsWith('https://')
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: SESSION_MAX_AGE_MS
|
||||
}
|
||||
}
|
||||
|
||||
export function setSessionCookie(res: Response, userId: string, withReauth = true): void {
|
||||
res.cookie(SESSION_COOKIE, createSessionToken(userId, withReauth), sessionCookieOptions())
|
||||
}
|
||||
|
||||
export function setSessionTokenCookie(res: Response, token: string): void {
|
||||
res.cookie(SESSION_COOKIE, token, sessionCookieOptions())
|
||||
}
|
||||
|
||||
export function clearSessionCookie(res: Response): void {
|
||||
res.clearCookie(SESSION_COOKIE, {
|
||||
httpOnly: true,
|
||||
secure: sessionCookieOptions().secure,
|
||||
sameSite: 'lax',
|
||||
path: '/'
|
||||
})
|
||||
}
|
||||
|
||||
export function hasValidReauth(payload: SessionPayload): boolean {
|
||||
return typeof payload.reauthExp === 'number' && payload.reauthExp > Date.now()
|
||||
}
|
||||
Reference in New Issue
Block a user