Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f4d6b11414 | |||
| 968e81f4fb | |||
| 10835c9def | |||
| cdbc618521 |
+87
-5
@@ -5025,16 +5025,68 @@ html.theme-cupertino .events-scroll-container {
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.admin-page {
|
||||
padding: 16px;
|
||||
padding: 12px 12px 20px;
|
||||
gap: 16px;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.admin-header-left {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
align-items: center;
|
||||
column-gap: 10px;
|
||||
row-gap: 2px;
|
||||
}
|
||||
|
||||
.admin-kpi-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
.admin-header-left .btn-back {
|
||||
grid-row: 1 / -1;
|
||||
align-self: center;
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.admin-title {
|
||||
font-size: 18px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.admin-subtitle {
|
||||
font-size: 11px;
|
||||
margin: 0;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.admin-controls {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.admin-control-buttons {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.admin-control-label {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.admin-control-buttons .btn {
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-charts-grid {
|
||||
gap: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5137,6 +5189,36 @@ html.theme-cupertino .events-scroll-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* Admin dashboard: keep 2-column KPI grid on mobile (overrides rule above) */
|
||||
.stats-kpi-grid.admin-kpi-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-kpi-grid .stats-kpi-card {
|
||||
padding: 10px 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-kpi-grid .stats-kpi-icon {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.admin-kpi-grid .stats-kpi-icon svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.admin-kpi-grid .stats-kpi-label {
|
||||
font-size: 11px;
|
||||
line-height: 1.25;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.admin-kpi-grid .stats-kpi-value {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.stats-kpi-value {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
+19
-1
@@ -97,6 +97,8 @@ function App() {
|
||||
const [isDemoMode, setIsDemoMode] = useState(() => window.location.pathname === '/demo')
|
||||
const [isAdminRoute, setIsAdminRoute] = useState(() => window.location.pathname.startsWith('/admin'))
|
||||
const [isAdminUser, setIsAdminUser] = useState(false)
|
||||
const [sessionChecked, setSessionChecked] = useState(false)
|
||||
const [serverSessionActive, setServerSessionActive] = useState(false)
|
||||
|
||||
const syncQueueCount = useLiveQuery(
|
||||
() => activeLogbookId ? db.syncQueue.where({ logbookId: activeLogbookId }).count() : db.syncQueue.count(),
|
||||
@@ -316,6 +318,8 @@ function App() {
|
||||
const session = await checkServerSession()
|
||||
if (cancelled) return
|
||||
|
||||
setServerSessionActive(session.authenticated)
|
||||
|
||||
if (session.authenticated) {
|
||||
persistSessionUserId(session.userId)
|
||||
}
|
||||
@@ -335,6 +339,10 @@ function App() {
|
||||
if (!cancelled) {
|
||||
console.warn('Session restore failed:', err)
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setSessionChecked(true)
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -618,7 +626,17 @@ function App() {
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="auth-screen">
|
||||
<AuthOnboarding onAuthenticated={handleAuthenticated} onOpenDemo={openDemo} />
|
||||
{!sessionChecked ? (
|
||||
<div className="auth-card glass">
|
||||
<p className="dashboard-status-msg">{t('auth.restore_checking')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<AuthOnboarding
|
||||
restoreSession={serverSessionActive}
|
||||
onAuthenticated={handleAuthenticated}
|
||||
onOpenDemo={openDemo}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
|
||||
import {
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
getKnownUsernames,
|
||||
forgetUsername,
|
||||
hasUnlockedLocalSession,
|
||||
logoutUser
|
||||
logoutUser,
|
||||
resolveRestoreUsername
|
||||
} from '../services/auth.js'
|
||||
import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react'
|
||||
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
|
||||
@@ -27,9 +28,15 @@ import {
|
||||
interface AuthOnboardingProps {
|
||||
onAuthenticated: () => void
|
||||
onOpenDemo?: () => void
|
||||
/** Server session cookie is valid but the in-memory master key was lost (e.g. after reload). */
|
||||
restoreSession?: boolean
|
||||
}
|
||||
|
||||
export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnboardingProps) {
|
||||
export default function AuthOnboarding({
|
||||
onAuthenticated,
|
||||
onOpenDemo,
|
||||
restoreSession = false
|
||||
}: AuthOnboardingProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const [username, setUsername] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -60,7 +67,10 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
const [isNewRegistration, setIsNewRegistration] = useState(false)
|
||||
const [showDisclaimer, setShowDisclaimer] = useState(false)
|
||||
const [showHelp, setShowHelp] = useState(false)
|
||||
const [showStandardLogin, setShowStandardLogin] = useState(false)
|
||||
const autoUnlockAttempted = useRef(false)
|
||||
|
||||
const isRestoreFlow = restoreSession && !showStandardLogin
|
||||
const passkeyHostOk = isPasskeyCompatibleLocation()
|
||||
const passkeyCompatibleUrl = passkeyHostOk ? null : toPasskeyCompatibleUrl(window.location.href)
|
||||
|
||||
@@ -144,6 +154,23 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRestoreFlow || autoUnlockAttempted.current) return
|
||||
|
||||
const user = resolveRestoreUsername()
|
||||
if (user && hasLocalPin(user)) {
|
||||
autoUnlockAttempted.current = true
|
||||
setUsername(user)
|
||||
setShowPinLogin(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (user && passkeyHostOk) {
|
||||
autoUnlockAttempted.current = true
|
||||
void handleLogin(user)
|
||||
}
|
||||
}, [isRestoreFlow, passkeyHostOk])
|
||||
|
||||
const handleRecoverySubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!recoveryInput.trim() || !encryptedPayloads) return
|
||||
@@ -347,10 +374,10 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
<div className="auth-card glass">
|
||||
<div className="auth-header">
|
||||
<KeyRound className="auth-icon accent" size={48} />
|
||||
<h2>{t('auth.enter_pin_title')}</h2>
|
||||
<h2>{isRestoreFlow ? t('auth.restore_title') : t('auth.enter_pin_title')}</h2>
|
||||
</div>
|
||||
<p className="recovery-warning">
|
||||
{t('auth.enter_pin_warning')}
|
||||
{isRestoreFlow ? t('auth.restore_pin_warning') : t('auth.enter_pin_warning')}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handlePinLoginSubmit} className="auth-form">
|
||||
@@ -397,6 +424,12 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => {
|
||||
if (isRestoreFlow) {
|
||||
setShowPinLogin(false)
|
||||
setPinLoginInput('')
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
void (async () => {
|
||||
setShowPinLogin(false)
|
||||
setPinLoginInput('')
|
||||
@@ -480,6 +513,101 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
)
|
||||
}
|
||||
|
||||
// Render: Session restore (active server cookie, master key lost after reload)
|
||||
if (isRestoreFlow) {
|
||||
const restoreUser = resolveRestoreUsername()
|
||||
const restoreKnownUsers = getKnownUsernames()
|
||||
|
||||
return (
|
||||
<div className="auth-card glass">
|
||||
<div className="auth-header">
|
||||
<KeyRound className="auth-icon accent" size={48} />
|
||||
<h2>{t('auth.restore_title')}</h2>
|
||||
</div>
|
||||
<p className="recovery-warning">{t('auth.restore_subtitle')}</p>
|
||||
|
||||
{loading && (
|
||||
<p className="dashboard-status-msg" style={{ marginTop: '12px' }}>
|
||||
{t('auth.restore_unlocking')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && <div className="auth-error">{error}</div>}
|
||||
|
||||
{!loading && (
|
||||
<div className="auth-actions" style={{ flexDirection: 'column', gap: '10px', marginTop: '16px' }}>
|
||||
{restoreUser && passkeyHostOk && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
onClick={() => handleLogin(restoreUser)}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{t('auth.restore_with_passkey', { name: restoreUser })}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{restoreUser && hasLocalPin(restoreUser) && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => {
|
||||
setUsername(restoreUser)
|
||||
setShowPinLogin(true)
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{t('auth.restore_with_pin')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{restoreKnownUsers.length > 1 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', width: '100%' }}>
|
||||
<span style={{ fontSize: '12px', color: '#64748b', textTransform: 'uppercase' }}>
|
||||
{t('auth.quick_login')}
|
||||
</span>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', width: '100%' }}>
|
||||
{restoreKnownUsers.map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (hasLocalPin(name)) {
|
||||
setUsername(name)
|
||||
setShowPinLogin(true)
|
||||
} else {
|
||||
void handleLogin(name)
|
||||
}
|
||||
}}
|
||||
disabled={loading}
|
||||
className="btn secondary"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '6px' }}
|
||||
>
|
||||
<UserRound size={16} />
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => {
|
||||
setShowStandardLogin(true)
|
||||
setError(null)
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{t('auth.restore_other_account')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Render 3: Standard Login / Registration options form
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -91,7 +91,15 @@
|
||||
"use_localhost_link": "Skift til localhost",
|
||||
"error_passkey_cancelled": "Passkey-login blev annulleret eller udløb. Prøv igen.",
|
||||
"error_invalid_rp_id": "Passkey-domæne matcher ikke (RP ID). Brug http://localhost:5173 med RP_ID=localhost i .env til lokal udvikling.",
|
||||
"error_session_incomplete": "Login ufuldstændig. Log ind med passkey igen."
|
||||
"error_session_incomplete": "Login ufuldstændig. Log ind med passkey igen.",
|
||||
"restore_checking": "Tjekker session…",
|
||||
"restore_title": "Gendan session",
|
||||
"restore_subtitle": "Du er stadig logget ind. Lås din logbog op med passkey eller PIN.",
|
||||
"restore_unlocking": "Låser op…",
|
||||
"restore_with_passkey": "Lås op med passkey ({{name}})",
|
||||
"restore_with_pin": "Lås op med PIN",
|
||||
"restore_pin_warning": "Indtast din lokale PIN for at låse logbogen op efter genindlæsning.",
|
||||
"restore_other_account": "Log ind med en anden konto"
|
||||
},
|
||||
"pwa": {
|
||||
"title": "Installer app",
|
||||
|
||||
@@ -91,7 +91,15 @@
|
||||
"use_localhost_link": "Zu localhost wechseln",
|
||||
"error_passkey_cancelled": "Passkey-Anmeldung abgebrochen oder abgelaufen. Bitte erneut versuchen.",
|
||||
"error_invalid_rp_id": "Passkey-Domain passt nicht (RP ID). Lokal nur http://localhost:5173 mit RP_ID=localhost in .env verwenden.",
|
||||
"error_session_incomplete": "Anmeldung unvollständig. Bitte erneut mit Passkey anmelden."
|
||||
"error_session_incomplete": "Anmeldung unvollständig. Bitte erneut mit Passkey anmelden.",
|
||||
"restore_checking": "Session wird geprüft…",
|
||||
"restore_title": "Session wiederherstellen",
|
||||
"restore_subtitle": "Deine Anmeldung ist noch aktiv. Entsperre dein Logbuch mit Passkey oder PIN.",
|
||||
"restore_unlocking": "Wird entsperrt…",
|
||||
"restore_with_passkey": "Mit Passkey entsperren ({{name}})",
|
||||
"restore_with_pin": "Mit PIN entsperren",
|
||||
"restore_pin_warning": "Gib deine lokale PIN ein, um dein Logbuch nach dem Neuladen zu entsperren.",
|
||||
"restore_other_account": "Anderer Account anmelden"
|
||||
},
|
||||
"pwa": {
|
||||
"title": "App installieren",
|
||||
|
||||
@@ -91,7 +91,15 @@
|
||||
"use_localhost_link": "Switch to localhost",
|
||||
"error_passkey_cancelled": "Passkey sign-in was cancelled or timed out. Please try again.",
|
||||
"error_invalid_rp_id": "Passkey domain mismatch (RP ID). For local dev use http://localhost:5173 with RP_ID=localhost in .env.",
|
||||
"error_session_incomplete": "Sign-in incomplete. Please sign in with your passkey again."
|
||||
"error_session_incomplete": "Sign-in incomplete. Please sign in with your passkey again.",
|
||||
"restore_checking": "Checking session…",
|
||||
"restore_title": "Restore session",
|
||||
"restore_subtitle": "You are still signed in. Unlock your logbook with passkey or PIN.",
|
||||
"restore_unlocking": "Unlocking…",
|
||||
"restore_with_passkey": "Unlock with passkey ({{name}})",
|
||||
"restore_with_pin": "Unlock with PIN",
|
||||
"restore_pin_warning": "Enter your local PIN to unlock your logbook after reload.",
|
||||
"restore_other_account": "Sign in with another account"
|
||||
},
|
||||
"pwa": {
|
||||
"title": "Install app",
|
||||
|
||||
@@ -91,7 +91,15 @@
|
||||
"use_localhost_link": "Bytt til localhost",
|
||||
"error_passkey_cancelled": "Passkey-innlogging ble avbrutt eller utløp. Prøv igjen.",
|
||||
"error_invalid_rp_id": "Passkey-domene stemmer ikke (RP ID). Bruk http://localhost:5173 med RP_ID=localhost i .env for lokal utvikling.",
|
||||
"error_session_incomplete": "Innlogging ufullstendig. Logg inn med passkey igjen."
|
||||
"error_session_incomplete": "Innlogging ufullstendig. Logg inn med passkey igjen.",
|
||||
"restore_checking": "Sjekker økt…",
|
||||
"restore_title": "Gjenopprett økt",
|
||||
"restore_subtitle": "Du er fortsatt innlogget. Lås opp loggboken med passkey eller PIN.",
|
||||
"restore_unlocking": "Låser opp…",
|
||||
"restore_with_passkey": "Lås opp med passkey ({{name}})",
|
||||
"restore_with_pin": "Lås opp med PIN",
|
||||
"restore_pin_warning": "Skriv inn din lokale PIN for å låse opp loggboken etter omlasting.",
|
||||
"restore_other_account": "Logg inn med en annen konto"
|
||||
},
|
||||
"pwa": {
|
||||
"title": "Installer app",
|
||||
|
||||
@@ -91,7 +91,15 @@
|
||||
"use_localhost_link": "Byt till localhost",
|
||||
"error_passkey_cancelled": "Passkey-inloggning avbröts eller gick ut. Försök igen.",
|
||||
"error_invalid_rp_id": "Passkey-domänen matchar inte (RP ID). Använd http://localhost:5173 med RP_ID=localhost i .env för lokal utveckling.",
|
||||
"error_session_incomplete": "Inloggning ofullständig. Logga in med passkey igen."
|
||||
"error_session_incomplete": "Inloggning ofullständig. Logga in med passkey igen.",
|
||||
"restore_checking": "Kontrollerar session…",
|
||||
"restore_title": "Återställ session",
|
||||
"restore_subtitle": "Du är fortfarande inloggad. Lås upp din loggbok med passkey eller PIN.",
|
||||
"restore_unlocking": "Låser upp…",
|
||||
"restore_with_passkey": "Lås upp med passkey ({{name}})",
|
||||
"restore_with_pin": "Lås upp med PIN",
|
||||
"restore_pin_warning": "Ange din lokala PIN för att låsa upp loggboken efter omladdning.",
|
||||
"restore_other_account": "Logga in med ett annat konto"
|
||||
},
|
||||
"pwa": {
|
||||
"title": "Installera app",
|
||||
|
||||
@@ -64,6 +64,15 @@ export function persistSessionUserId(userId: string | undefined): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Username to use when re-unlocking after reload (active account or sole remembered user). */
|
||||
export function resolveRestoreUsername(): string | null {
|
||||
const stored = localStorage.getItem('active_username')
|
||||
if (stored) return stored
|
||||
const known = getKnownUsernames()
|
||||
if (known.length === 1) return known[0]
|
||||
return null
|
||||
}
|
||||
|
||||
export async function reauthWithPasskey(): Promise<boolean> {
|
||||
const options = await apiJson<any>(`${API_BASE}/reauth-options`, {
|
||||
method: 'POST'
|
||||
|
||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
hasUnlockedLocalCrypto,
|
||||
hasUnlockedLocalSession,
|
||||
resolveRestoreUsername,
|
||||
setActiveMasterKey
|
||||
} from './auth.js'
|
||||
|
||||
@@ -33,6 +34,28 @@ describe('local session unlock checks', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveRestoreUsername', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('prefers active_username from storage', () => {
|
||||
localStorage.setItem('active_username', 'captain')
|
||||
localStorage.setItem('daagbox_known_users', JSON.stringify(['other']))
|
||||
expect(resolveRestoreUsername()).toBe('captain')
|
||||
})
|
||||
|
||||
it('falls back to a single remembered user', () => {
|
||||
localStorage.setItem('daagbox_known_users', JSON.stringify(['solo']))
|
||||
expect(resolveRestoreUsername()).toBe('solo')
|
||||
})
|
||||
|
||||
it('returns null when multiple users and no active username', () => {
|
||||
localStorage.setItem('daagbox_known_users', JSON.stringify(['alpha', 'beta']))
|
||||
expect(resolveRestoreUsername()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('persistSessionUserId', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
|
||||
Reference in New Issue
Block a user