Compare commits

...

8 Commits

Author SHA1 Message Date
elpatron aee8f4f3db chore: release v0.1.0.103 2026-06-02 22:48:22 +02:00
elpatron 2b029a26f0 Fix passkey login 429 by forwarding client IPs correctly.
Forward X-Forwarded-For through frontend nginx, use TRUST_PROXY=1 for the Docker hop, and limit auth rate limiting to login flows only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 22:48:15 +02:00
elpatron 2156aa4bbd chore: release v0.1.0.102 2026-06-02 22:32:16 +02:00
elpatron 5eb4543255 Use native OS time picker on mobile for event times.
EventTimeInput24h switches to input type=time on touch devices while keeping dual selects on desktop for reliable 24h entry.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 22:32:03 +02:00
elpatron fb9bb6754c chore: release v0.1.0.101 2026-06-02 22:29:37 +02:00
elpatron 959afd5a63 Make scrollbars wider and more visible on touch devices.
Global theme-aware scrollbar styling replaces the thin 6px event-table bar so long forms are easier to scroll on mobile.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 22:29:16 +02:00
elpatron e3ea45f717 chore: release v0.1.0.100 2026-06-02 20:55:03 +02:00
elpatron 8f57b6ff22 Remove diagnostic debug code and backend endpoint 2026-06-02 20:54:58 +02:00
12 changed files with 124 additions and 80 deletions
+2 -2
View File
@@ -13,8 +13,8 @@ RP_ID=localhost
# Must match the frontend URL exactly (Vite dev: http://localhost:5173; Docker: http://localhost)
ORIGIN=http://localhost:5173
# Behind Nginx Proxy Manager — see docs/deployment/npm-security.md
# TRUST_PROXY=172.16.10.10
# Behind reverse proxy — see docs/deployment/npm-security.md
# Docker Compose (NPM → frontend nginx → backend): TRUST_PROXY=1
# TRUST_PROXY=1
# Docker Compose database (required for production deploy)
+1 -1
View File
@@ -1 +1 @@
0.1.0.100
0.1.0.104
+3
View File
@@ -43,6 +43,9 @@ server {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
+8 -16
View File
@@ -148,7 +148,8 @@ select.input-text {
width: 100%;
}
.time-input-24h__select {
.time-input-24h__select,
.time-input-24h__native {
flex: 1 1 0;
min-width: 0;
padding-left: 12px;
@@ -157,6 +158,11 @@ select.input-text {
font-variant-numeric: tabular-nums;
}
input[type='time'].time-input-24h__native {
color-scheme: inherit;
cursor: pointer;
}
.time-input-24h__sep {
flex-shrink: 0;
font-size: 18px;
@@ -2647,27 +2653,13 @@ html.scheme-dark .themed-select-option.is-selected {
.events-scroll-container {
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
background: rgba(11, 12, 16, 0.4);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
box-sizing: border-box;
}
/* Custom Scrollbar for events container */
.events-scroll-container::-webkit-scrollbar {
height: 6px;
}
.events-scroll-container::-webkit-scrollbar-track {
background: rgba(11, 12, 16, 0.2);
}
.events-scroll-container::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
}
.events-scroll-container::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
.events-table {
width: 100%;
border-collapse: collapse;
@@ -1,5 +1,6 @@
import { useId, useMemo } from 'react'
import { joinTimeHHMM, splitTimeHHMM } from '../utils/logEntryPayload.js'
import { preferNativeCameraPicker } from '../utils/captureVideoFrame.js'
const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'))
const MINUTES = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'))
@@ -18,7 +19,29 @@ export default function EventTimeInput24h({
'aria-label': ariaLabel
}: EventTimeInput24hProps) {
const baseId = useId()
const useNativePicker = preferNativeCameraPicker()
const { hours, minutes } = useMemo(() => splitTimeHHMM(value), [value])
const timeValue = useMemo(() => joinTimeHHMM(hours, minutes), [hours, minutes])
if (useNativePicker) {
return (
<div className="time-input-24h">
<input
id={baseId}
type="time"
step={60}
className="input-text time-input-24h__native"
value={timeValue}
onChange={(e) => {
const next = e.target.value
if (next) onChange(next.slice(0, 5))
}}
disabled={disabled}
aria-label={ariaLabel}
/>
</div>
)
}
return (
<div className="time-input-24h">
+59
View File
@@ -18,3 +18,62 @@ body {
flex-direction: column;
align-items: center;
}
/* Scrollbars — auf Touch-Geräten breiter und besser sichtbar */
:root {
--app-scrollbar-size: 10px;
}
@media (hover: none), (pointer: coarse), (max-width: 768px) {
:root {
--app-scrollbar-size: 14px;
}
}
html {
scrollbar-width: auto;
scrollbar-color: var(--app-accent-light) var(--app-surface-inset);
-webkit-overflow-scrolling: touch;
}
html::-webkit-scrollbar,
body::-webkit-scrollbar,
*::-webkit-scrollbar {
width: var(--app-scrollbar-size);
height: var(--app-scrollbar-size);
}
html::-webkit-scrollbar-track,
body::-webkit-scrollbar-track,
*::-webkit-scrollbar-track {
background: var(--app-surface-inset);
border-radius: calc(var(--app-scrollbar-size) / 2);
}
html::-webkit-scrollbar-thumb,
body::-webkit-scrollbar-thumb,
*::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--app-accent-light) 55%, transparent);
border-radius: calc(var(--app-scrollbar-size) / 2);
min-height: 48px;
}
html::-webkit-scrollbar-thumb:hover,
body::-webkit-scrollbar-thumb:hover,
*::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--app-accent-light) 80%, transparent);
}
@media (hover: none), (pointer: coarse), (max-width: 768px) {
html::-webkit-scrollbar-thumb,
body::-webkit-scrollbar-thumb,
*::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--app-accent-light) 70%, transparent);
}
html::-webkit-scrollbar-thumb:active,
body::-webkit-scrollbar-thumb:active,
*::-webkit-scrollbar-thumb:active {
background: var(--app-accent-light);
}
}
-4
View File
@@ -14,7 +14,6 @@ import {
reconcileVersionOnStartup
} from './services/pwaStartup.ts'
import { redirectToPasskeyCompatibleHostIfNeeded } from './utils/passkeyHost.ts'
import { logToBackend } from './services/pushNotifications.ts'
declare global {
interface Window {
@@ -75,16 +74,13 @@ async function bootstrap(): Promise<void> {
}
if ('serviceWorker' in navigator && !import.meta.env.DEV) {
logToBackend('Attempting manual Service Worker registration...')
navigator.serviceWorker
.register('/sw.js', { scope: '/' })
.then((reg) => {
console.log('Service Worker registered successfully with scope:', reg.scope)
logToBackend('Service Worker registered successfully with scope: ' + reg.scope)
})
.catch((err) => {
console.error('Service Worker registration failed:', err)
logToBackend('Service Worker registration failed', err)
})
}
+5 -47
View File
@@ -2,29 +2,6 @@ import { apiFetch, apiJson } from './api.js'
const API_BASE = '/api/push'
export async function logToBackend(message: string, error?: any): Promise<void> {
try {
await fetch(`${API_BASE}/debug-log`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message,
error: error ? {
name: error.name,
message: error.message,
stack: error.stack,
...error
} : undefined,
userAgent: navigator.userAgent,
href: window.location.href,
timestamp: new Date().toISOString()
})
})
} catch (err) {
console.warn('Failed to send debug log:', err)
}
}
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
@@ -212,35 +189,23 @@ async function saveSubscriptionToServer(subscription: PushSubscription): Promise
}
export async function subscribeToPush(): Promise<void> {
logToBackend('subscribeToPush called')
if (!isPushSupported()) {
logToBackend('subscribeToPush: push not supported')
throw new Error('Push notifications are not supported on this device')
}
// Pre-resolve registration using getRegistrationCompat to prevent ready state hangs
let registration = cachedRegistration
if (!registration) {
try {
logToBackend('subscribeToPush: getting registration...')
registration = await getRegistrationCompat()
cachedRegistration = registration
logToBackend('subscribeToPush: got registration successfully')
} catch (err) {
logToBackend('subscribeToPush: failed to get registration', err)
throw err
}
registration = await getRegistrationCompat()
cachedRegistration = registration
}
const publicKey = cachedVapidKey || await fetchVapidPublicKey()
if (!publicKey) {
logToBackend('subscribeToPush: no public key available')
throw new Error('Push notifications are not configured on this server')
}
logToBackend('subscribeToPush: requesting permission...')
const permission = await requestNotificationPermission()
logToBackend(`subscribeToPush: permission result: ${permission}`)
if (permission !== 'granted') {
throw new Error('Notification permission denied')
}
@@ -249,7 +214,6 @@ export async function subscribeToPush(): Promise<void> {
const applicationServerKey = new Uint8Array(keyBytes)
// Always call subscribe with timeout to prevent silent hangs on push network errors
logToBackend('subscribeToPush: subscribing via pushManager...')
const subscribePromise = registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey
@@ -257,15 +221,9 @@ export async function subscribeToPush(): Promise<void> {
const subscribeTimeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Timeout establishing subscription with push service (FCM/APNs)')), 12000)
)
try {
const subscription = await Promise.race([subscribePromise, subscribeTimeout])
logToBackend('subscribeToPush: subscribed successfully, saving to server...')
await saveSubscriptionToServer(subscription)
logToBackend('subscribeToPush: saved to server successfully')
} catch (err) {
logToBackend('subscribeToPush: subscription or save failed', err)
throw err
}
const subscription = await Promise.race([subscribePromise, subscribeTimeout])
await saveSubscriptionToServer(subscription)
}
export async function unsubscribeFromPush(): Promise<void> {
+3 -2
View File
@@ -30,8 +30,9 @@ proxy_set_header X-Real-IP $remote_addr;
ORIGIN=https://kapteins-daagbok.eu
RP_ID=kapteins-daagbok.eu
SESSION_SECRET=<min. 32 Zeichen, openssl rand -base64 48>
TRUST_PROXY=172.16.10.10
# oder TRUST_PROXY=1 für genau einen Proxy-Hop
# Docker Compose: Frontend-Nginx ist der direkte Proxy zum Backend → 1 Hop
TRUST_PROXY=1
# Nur bei direktem Backend-Zugriff ohne Frontend-Nginx: NPM-IP, z. B. TRUST_PROXY=172.16.10.10
```
`ORIGIN` muss **exakt** der Browser-URL entsprechen (ohne trailing slash).
+2 -2
View File
@@ -34,7 +34,7 @@ if ! grep -q "^POSTGRES_PASSWORD=" "$ENV_FILE" || grep -q "^POSTGRES_PASSWORD=$"
else
echo " keep POSTGRES_PASSWORD (already set)"
fi
# NPM on 172.16.10.10 → app on this host
ensure_var TRUST_PROXY "172.16.10.10"
# Frontend-Nginx → Backend (one hop); NPM is in front of Nginx, not Backend directly
ensure_var TRUST_PROXY "1"
echo "Done. Verify with: docker exec daagbox-prod-db psql -U postgres -d daagbox -c 'SELECT 1'"
+18 -1
View File
@@ -45,6 +45,18 @@ export function createApp(): express.Express {
app.use(cookieParser())
app.use(express.json({ limit: '50mb' }))
/** Passkey login/register only — not person-pool, profile, etc. */
const authFlowPaths = new Set([
'/register-options',
'/register-verify',
'/login-options',
'/login-verify',
'/reauth-options',
'/reauth-verify',
'/logout',
'/session'
])
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 60,
@@ -66,7 +78,12 @@ export function createApp(): express.Express {
legacyHeaders: false
})
app.use('/api/auth', authLimiter)
app.use('/api/auth', (req, res, next) => {
if (authFlowPaths.has(req.path)) {
return authLimiter(req, res, next)
}
return next()
})
app.use('/api/collaboration/invite-details', publicCollaborationLimiter)
app.use('/api/collaboration/share-pull', publicCollaborationLimiter)
app.use('/api', apiLimiter)
-5
View File
@@ -22,11 +22,6 @@ router.get('/vapid-public-key', (_req, res) => {
return res.json({ publicKey })
})
router.post('/debug-log', (req, res) => {
console.log('[CLIENT_DEBUG]', req.body)
return res.json({ success: true })
})
router.use(requireUser)
router.get('/prefs', async (req: any, res) => {