Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f4d6b11414 | |||
| 968e81f4fb | |||
| 10835c9def | |||
| cdbc618521 | |||
| f75fe42910 | |||
| 212775ffdc | |||
| c80760db02 | |||
| cd1dd12c15 | |||
| 43cf589613 | |||
| e1cb2754c4 | |||
| 5dedb8fac0 | |||
| 78f1659db4 | |||
| 935c263648 |
@@ -36,6 +36,10 @@ ORIGIN=http://localhost:5173
|
||||
# Generate: openssl rand -base64 48
|
||||
SESSION_SECRET=
|
||||
|
||||
# Admin dashboard access — comma-separated list of User IDs (UUIDs)
|
||||
# Example: ADMIN_USER_IDS=11111111-2222-3333-4444-555555555555,22222222-3333-4444-5555-666666666666
|
||||
ADMIN_USER_IDS=
|
||||
|
||||
# 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
|
||||
VAPID_PUBLIC_KEY=
|
||||
|
||||
@@ -4919,6 +4919,177 @@ html.theme-cupertino .events-scroll-container {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.admin-page {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
min-height: 100vh;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
color: var(--app-text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--app-header-border);
|
||||
}
|
||||
|
||||
.admin-header-left {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-title {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
background: var(--app-accent-gradient);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.admin-subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 14px;
|
||||
color: var(--app-text-muted);
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-kpi-grid {
|
||||
margin-top: 0;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.admin-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
padding: 16px 20px;
|
||||
border-radius: var(--app-radius-card);
|
||||
border: 1px solid var(--app-border-subtle);
|
||||
background: var(--app-surface);
|
||||
}
|
||||
|
||||
.admin-control-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-control-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--app-text-muted);
|
||||
}
|
||||
|
||||
.admin-control-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-control-buttons .btn {
|
||||
width: auto;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.admin-charts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.dashboard-subtitle {
|
||||
margin-top: 4px;
|
||||
font-size: 14px;
|
||||
color: var(--app-text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.admin-page {
|
||||
padding: 12px 12px 20px;
|
||||
gap: 16px;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.admin-header-left {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
align-items: center;
|
||||
column-gap: 10px;
|
||||
row-gap: 2px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.stats-consumption-chart .stats-bar-column--grouped {
|
||||
display: inline-flex;
|
||||
white-space: normal;
|
||||
@@ -5018,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;
|
||||
}
|
||||
@@ -5663,6 +5864,27 @@ html.theme-cupertino .events-scroll-container {
|
||||
border-color: rgba(56, 189, 248, 0.32);
|
||||
}
|
||||
|
||||
.knorrlabs-footer-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #94a3b8;
|
||||
text-decoration: none;
|
||||
background: rgba(139, 92, 246, 0.08);
|
||||
border: 1px solid rgba(139, 92, 246, 0.18);
|
||||
transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.knorrlabs-footer-badge:hover {
|
||||
color: #ddd6fe;
|
||||
background: rgba(139, 92, 246, 0.14);
|
||||
border-color: rgba(139, 92, 246, 0.32);
|
||||
}
|
||||
|
||||
.demo-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
+79
-4
@@ -36,6 +36,7 @@ import { syncAppearancePrefs } from './services/appearancePrefs.js'
|
||||
import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js'
|
||||
import ReadOnlyViewer from './components/ReadOnlyViewer.tsx'
|
||||
import DemoViewer from './components/DemoViewer.tsx'
|
||||
import AdminDashboard from './admin/AdminDashboard.tsx'
|
||||
import PwaInstallPrompt from './components/PwaInstallPrompt.tsx'
|
||||
import PwaUpdatePrompt from './components/PwaUpdatePrompt.tsx'
|
||||
import AppFooter from './components/AppFooter.tsx'
|
||||
@@ -49,6 +50,8 @@ import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, La
|
||||
import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
|
||||
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
|
||||
import ProfileHeaderButton from './components/ProfileHeaderButton.tsx'
|
||||
import AdminHeaderButton from './components/AdminHeaderButton.tsx'
|
||||
import { checkAdminAccess } from './services/adminApi.js'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cycleAppLanguage } from './utils/i18nLanguages.js'
|
||||
import {
|
||||
@@ -92,6 +95,10 @@ function App() {
|
||||
|
||||
// Public demo mode (no account required)
|
||||
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(),
|
||||
@@ -160,14 +167,23 @@ function App() {
|
||||
})
|
||||
}, [])
|
||||
|
||||
const refreshAdminAccess = useCallback(async () => {
|
||||
const isAdmin = await checkAdminAccess()
|
||||
setIsAdminUser(isAdmin)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return
|
||||
if (!isAuthenticated) {
|
||||
setIsAdminUser(false)
|
||||
return
|
||||
}
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId) return
|
||||
void syncAppearancePrefs(userId)
|
||||
void migrateLegacyCrewToPoolIfNeeded().then(() => syncPersonPool())
|
||||
void migrateLegacyYachtsToPoolIfNeeded().then(() => syncVesselPool())
|
||||
}, [isAuthenticated])
|
||||
void refreshAdminAccess()
|
||||
}, [isAuthenticated, refreshAdminAccess])
|
||||
|
||||
useEffect(() => {
|
||||
const handleOnline = () => {
|
||||
@@ -199,6 +215,13 @@ function App() {
|
||||
const hashParams = new URLSearchParams(window.location.hash.substring(1))
|
||||
const path = window.location.pathname
|
||||
|
||||
if (path.startsWith('/admin')) {
|
||||
setIsAdminRoute(true)
|
||||
return
|
||||
}
|
||||
|
||||
setIsAdminRoute(false)
|
||||
|
||||
if (path === '/demo') {
|
||||
setIsDemoMode(true)
|
||||
setIsViewerMode(false)
|
||||
@@ -240,6 +263,7 @@ function App() {
|
||||
|
||||
const clearAuthenticatedAppState = useCallback(() => {
|
||||
setIsAuthenticated(false)
|
||||
setIsAdminUser(false)
|
||||
setActiveLogbookId(null)
|
||||
setActiveLogbookTitle(null)
|
||||
setShowUserProfile(false)
|
||||
@@ -249,7 +273,7 @@ function App() {
|
||||
|
||||
/** After PWA/bfcache resume, React state may still say "logged in" while the master key is gone. */
|
||||
const enforceUnlockedSession = useCallback(() => {
|
||||
if (isViewerMode || isDemoMode || isAcceptingInvite) return
|
||||
if (isViewerMode || isDemoMode || isAcceptingInvite || isAdminRoute) return
|
||||
// Require full local session (incl. userId) so API calls are not left headless.
|
||||
if (isAuthenticated && !hasUnlockedLocalSession()) {
|
||||
clearAuthenticatedAppState()
|
||||
@@ -259,6 +283,7 @@ function App() {
|
||||
isViewerMode,
|
||||
isDemoMode,
|
||||
isAcceptingInvite,
|
||||
isAdminRoute,
|
||||
clearAuthenticatedAppState
|
||||
])
|
||||
|
||||
@@ -293,6 +318,8 @@ function App() {
|
||||
const session = await checkServerSession()
|
||||
if (cancelled) return
|
||||
|
||||
setServerSessionActive(session.authenticated)
|
||||
|
||||
if (session.authenticated) {
|
||||
persistSessionUserId(session.userId)
|
||||
}
|
||||
@@ -312,6 +339,10 @@ function App() {
|
||||
if (!cancelled) {
|
||||
console.warn('Session restore failed:', err)
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setSessionChecked(true)
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -333,6 +364,14 @@ function App() {
|
||||
setIsAcceptingInvite(false)
|
||||
}, [])
|
||||
|
||||
const openAdmin = useCallback(() => {
|
||||
window.history.pushState({}, document.title, '/admin')
|
||||
setIsAdminRoute(true)
|
||||
setIsDemoMode(false)
|
||||
setIsViewerMode(false)
|
||||
setIsAcceptingInvite(false)
|
||||
}, [])
|
||||
|
||||
const selectLogbook = useCallback((id: string, title: string) => {
|
||||
setActiveLogbookId(id)
|
||||
setActiveLogbookTitle(title)
|
||||
@@ -497,6 +536,7 @@ function App() {
|
||||
if (!(await confirmLeave())) return
|
||||
void logoutUser()
|
||||
setIsAuthenticated(false)
|
||||
setIsAdminUser(false)
|
||||
setActiveLogbookId(null)
|
||||
setActiveLogbookTitle(null)
|
||||
setShowUserProfile(false)
|
||||
@@ -524,6 +564,28 @@ function App() {
|
||||
syncRouteFromLocation()
|
||||
}
|
||||
|
||||
const handleBackFromAdmin = () => {
|
||||
window.history.replaceState({}, document.title, '/')
|
||||
setIsAdminRoute(false)
|
||||
syncRouteFromLocation()
|
||||
}
|
||||
|
||||
if (isAdminRoute) {
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="auth-screen">
|
||||
<AuthOnboarding onAuthenticated={handleAuthenticated} onOpenDemo={openDemo} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'contents' }}>
|
||||
<AdminDashboard onBack={handleBackFromAdmin} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isDemoMode) {
|
||||
return (
|
||||
<div style={{ display: 'contents' }}>
|
||||
@@ -564,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>
|
||||
)
|
||||
}
|
||||
@@ -597,6 +669,7 @@ function App() {
|
||||
onSelectLogbook={selectLogbook}
|
||||
onLogout={handleLogout}
|
||||
onOpenProfile={() => setShowUserProfile(true)}
|
||||
onOpenAdmin={isAdminUser ? openAdmin : undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -647,6 +720,8 @@ function App() {
|
||||
<Languages size={18} />
|
||||
</button>
|
||||
|
||||
{isAdminUser && <AdminHeaderButton onClick={openAdmin} />}
|
||||
|
||||
<ProfileHeaderButton onClick={() => setShowUserProfile(true)} />
|
||||
|
||||
<DisclaimerHeaderButton />
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
fetchAdminMe,
|
||||
fetchAdminSummary,
|
||||
fetchAdminTimeSeries,
|
||||
type AdminSummary,
|
||||
type AdminTimeSeriesResponse,
|
||||
type AdminTimeBucket
|
||||
} from '../services/adminApi.js'
|
||||
import { BarChart2, Bookmark, ChevronLeft, Image, MapPin, Mic, Users } from 'lucide-react'
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
function KpiCard({
|
||||
icon,
|
||||
label,
|
||||
value
|
||||
}: {
|
||||
icon: ReactNode
|
||||
label: string
|
||||
value: number
|
||||
}) {
|
||||
return (
|
||||
<div className="stats-kpi-card glass">
|
||||
<div className="stats-kpi-icon">{icon}</div>
|
||||
<div className="stats-kpi-body">
|
||||
<span className="stats-kpi-label">{label}</span>
|
||||
<span className="stats-kpi-value">{formatNumber(value)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TimeSeriesChart({
|
||||
title,
|
||||
seriesKey,
|
||||
data
|
||||
}: {
|
||||
title: string
|
||||
seriesKey: string
|
||||
data: AdminTimeSeriesResponse | null
|
||||
}) {
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
|
||||
const metric = data.series.find((s) => s.metric === seriesKey)
|
||||
if (!metric || metric.points.length === 0) {
|
||||
return (
|
||||
<div className="form-card glass">
|
||||
<div className="form-header">
|
||||
<BarChart2 className="form-icon" />
|
||||
<h2>{title}</h2>
|
||||
</div>
|
||||
<p className="dashboard-status-msg">Keine Daten im gewählten Zeitraum.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const max = metric.points.reduce((acc, p) => (p.count > acc ? p.count : acc), 0) || 1
|
||||
|
||||
return (
|
||||
<div className="form-card glass">
|
||||
<div className="form-header">
|
||||
<BarChart2 className="form-icon" />
|
||||
<h2>{title}</h2>
|
||||
</div>
|
||||
<div className="stats-bar-chart" role="img" aria-label={title}>
|
||||
{metric.points.map((point) => {
|
||||
const heightPct = Math.max(2, (point.count / max) * 100)
|
||||
return (
|
||||
<div key={point.date} className="stats-bar-column" title={`${point.date}: ${point.count}`}>
|
||||
<span className="stats-bar-value">{point.count > 0 ? String(point.count) : ''}</span>
|
||||
<div className="stats-bar-track">
|
||||
<div className="stats-bar stats-bar--distance" style={{ height: `${heightPct}%` }} />
|
||||
</div>
|
||||
<span className="stats-bar-label">{point.date.slice(5)}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface AdminDashboardProps {
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
export default function AdminDashboard({ onBack }: AdminDashboardProps) {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [summary, setSummary] = useState<AdminSummary | null>(null)
|
||||
const [timeSeries, setTimeSeries] = useState<AdminTimeSeriesResponse | null>(null)
|
||||
const [bucket, setBucket] = useState<AdminTimeBucket>('day')
|
||||
const [windowDays, setWindowDays] = useState(90)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
await fetchAdminMe()
|
||||
const [summaryRes, tsRes] = await Promise.all([
|
||||
fetchAdminSummary(),
|
||||
fetchAdminTimeSeries({ bucket, windowDays })
|
||||
])
|
||||
|
||||
if (!cancelled) {
|
||||
setSummary(summaryRes)
|
||||
setTimeSeries(tsRes)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (!cancelled) {
|
||||
const message =
|
||||
err instanceof Error && err.message ? err.message : 'Fehler beim Laden des Admin-Dashboards'
|
||||
setError(message)
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [bucket, windowDays])
|
||||
|
||||
if (loading && !summary) {
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<p className="dashboard-status-msg">Admin-Dashboard wird geladen…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<header className="admin-header">
|
||||
<button type="button" className="btn-back" onClick={onBack}>
|
||||
<ChevronLeft size={16} />
|
||||
Zur App
|
||||
</button>
|
||||
</header>
|
||||
<p className="dashboard-status-msg">{error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!summary) {
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<p className="dashboard-status-msg">Keine Admin-Daten verfügbar.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<header className="admin-header">
|
||||
<div className="admin-header-left">
|
||||
<button type="button" className="btn-back" onClick={onBack}>
|
||||
<ChevronLeft size={16} />
|
||||
Zur App
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="admin-title">Admin-Dashboard</h1>
|
||||
<p className="admin-subtitle">
|
||||
Übersicht über Nutzung und Wachstum von Kapteins Daagbok.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="admin-main">
|
||||
<section className="stats-kpi-grid admin-kpi-grid">
|
||||
<KpiCard icon={<Users size={20} />} label="Registrierte Benutzer" value={summary.totalUsers} />
|
||||
<KpiCard icon={<Bookmark size={20} />} label="Logbücher" value={summary.totalLogbooks} />
|
||||
<KpiCard icon={<Image size={20} />} label="Fotos" value={summary.totalPhotos} />
|
||||
<KpiCard icon={<Mic size={20} />} label="Sprachmemos" value={summary.totalVoiceMemos} />
|
||||
<KpiCard icon={<MapPin size={20} />} label="GPS-Tracks" value={summary.totalGpsTracks} />
|
||||
<KpiCard
|
||||
icon={<BarChart2 size={20} />}
|
||||
label="Einträge mit AI-Zusammenfassung"
|
||||
value={summary.aiSummaryEntries}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="admin-controls">
|
||||
<div className="admin-control-group">
|
||||
<span className="admin-control-label">Zeitraum</span>
|
||||
<div className="admin-control-buttons">
|
||||
{[30, 90, 365].map((days) => (
|
||||
<button
|
||||
key={days}
|
||||
type="button"
|
||||
className={days === windowDays ? 'btn primary' : 'btn secondary'}
|
||||
onClick={() => setWindowDays(days)}
|
||||
>
|
||||
{days} Tage
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-control-group">
|
||||
<span className="admin-control-label">Aggregation</span>
|
||||
<div className="admin-control-buttons">
|
||||
{(['day', 'week', 'month'] as AdminTimeBucket[]).map((b) => (
|
||||
<button
|
||||
key={b}
|
||||
type="button"
|
||||
className={b === bucket ? 'btn primary' : 'btn secondary'}
|
||||
onClick={() => setBucket(b)}
|
||||
>
|
||||
{b === 'day' ? 'Tag' : b === 'week' ? 'Woche' : 'Monat'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="admin-charts-grid">
|
||||
<TimeSeriesChart title="Neue Benutzer" seriesKey="users_created" data={timeSeries} />
|
||||
<TimeSeriesChart title="Neue Logbücher" seriesKey="logbooks_created" data={timeSeries} />
|
||||
<TimeSeriesChart title="Foto-Aktivität" seriesKey="photos_updated" data={timeSeries} />
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { LayoutDashboard } from 'lucide-react'
|
||||
|
||||
interface AdminHeaderButtonProps {
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export default function AdminHeaderButton({ onClick }: AdminHeaderButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon skipper-badge"
|
||||
onClick={onClick}
|
||||
title={t('nav.admin')}
|
||||
aria-label={t('nav.admin')}
|
||||
>
|
||||
<LayoutDashboard size={18} aria-hidden="true" />
|
||||
<span className="skipper-badge__name">{t('nav.admin')}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Coffee, Mail } from 'lucide-react'
|
||||
import { Coffee, Mail, Compass } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
|
||||
@@ -15,11 +15,24 @@ export default function AppFooter() {
|
||||
·
|
||||
</span>
|
||||
<span className="app-version-footer__copyright">
|
||||
© 2026 KnorrLabs
|
||||
© 2026
|
||||
</span>
|
||||
<span className="app-version-footer__sep" aria-hidden="true">
|
||||
·
|
||||
</span>
|
||||
<a
|
||||
className="knorrlabs-footer-badge"
|
||||
href="https://dashy.elpatron.me/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackPlausibleEvent(PlausibleEvents.FOOTER_LINK_CLICKED)}
|
||||
>
|
||||
<Compass size={14} aria-hidden="true" />
|
||||
<span>KnorrLabs</span>
|
||||
</a>
|
||||
<span className="app-version-footer__sep" aria-hidden="true">
|
||||
·
|
||||
</span>
|
||||
<a
|
||||
className="mail-footer-badge"
|
||||
href="mailto:moin@kapteins-daagbok.eu"
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -15,11 +15,13 @@ import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, Wifi, WifiO
|
||||
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
|
||||
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
|
||||
import ProfileHeaderButton from './ProfileHeaderButton.tsx'
|
||||
import AdminHeaderButton from './AdminHeaderButton.tsx'
|
||||
|
||||
interface LogbookDashboardProps {
|
||||
onSelectLogbook: (id: string, title: string) => void
|
||||
onLogout: () => void
|
||||
onOpenProfile: () => void
|
||||
onOpenAdmin?: () => void
|
||||
}
|
||||
|
||||
type LogbookSortKey = 'name' | 'date'
|
||||
@@ -42,7 +44,7 @@ function sortLogbooks(
|
||||
return sorted
|
||||
}
|
||||
|
||||
export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProfile }: LogbookDashboardProps) {
|
||||
export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProfile, onOpenAdmin }: LogbookDashboardProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { showConfirm } = useDialog()
|
||||
const [logbooks, setLogbooks] = useState<DecryptedLogbook[]>([])
|
||||
@@ -388,6 +390,8 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
||||
|
||||
<ProfileHeaderButton onClick={onOpenProfile} />
|
||||
|
||||
{onOpenAdmin && <AdminHeaderButton onClick={onOpenAdmin} />}
|
||||
|
||||
{/* Lang toggle */}
|
||||
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
|
||||
<Languages size={18} />
|
||||
|
||||
@@ -43,7 +43,8 @@
|
||||
"deviation": "Tabel over distraktioner",
|
||||
"logs": "Indlæg i logbogen",
|
||||
"stats": "Statistik",
|
||||
"settings": "Indstillinger"
|
||||
"settings": "Indstillinger",
|
||||
"admin": "Admin"
|
||||
},
|
||||
"auth": {
|
||||
"welcome": "Velkommen til Kapteins Daagbok.",
|
||||
@@ -90,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",
|
||||
|
||||
@@ -43,7 +43,8 @@
|
||||
"deviation": "Ablenkungstabelle",
|
||||
"logs": "Logbucheinträge",
|
||||
"stats": "Statistik",
|
||||
"settings": "Einstellungen"
|
||||
"settings": "Einstellungen",
|
||||
"admin": "Admin"
|
||||
},
|
||||
"auth": {
|
||||
"welcome": "Willkommen bei Kapteins Daagbok",
|
||||
@@ -90,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",
|
||||
|
||||
@@ -43,7 +43,8 @@
|
||||
"deviation": "Deviation Table",
|
||||
"logs": "Logbook Entries",
|
||||
"stats": "Statistics",
|
||||
"settings": "Settings"
|
||||
"settings": "Settings",
|
||||
"admin": "Admin"
|
||||
},
|
||||
"auth": {
|
||||
"welcome": "Welcome to Kapteins Daagbok",
|
||||
@@ -90,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",
|
||||
|
||||
@@ -43,7 +43,8 @@
|
||||
"deviation": "Tabell over distraksjoner",
|
||||
"logs": "Loggbokoppføringer",
|
||||
"stats": "Statistikk",
|
||||
"settings": "Innstillinger"
|
||||
"settings": "Innstillinger",
|
||||
"admin": "Admin"
|
||||
},
|
||||
"auth": {
|
||||
"welcome": "Velkommen til Kapteins Daagbok",
|
||||
@@ -90,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",
|
||||
|
||||
@@ -43,7 +43,8 @@
|
||||
"deviation": "Distraktionsbord",
|
||||
"logs": "Loggboksanteckningar",
|
||||
"stats": "Statistik",
|
||||
"settings": "Inställningar"
|
||||
"settings": "Inställningar",
|
||||
"admin": "Admin"
|
||||
},
|
||||
"auth": {
|
||||
"welcome": "Välkommen till Kapteins Daagbok",
|
||||
@@ -90,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",
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { ApiError, apiJson } from './api.js'
|
||||
|
||||
const ADMIN_BASE = '/api/admin'
|
||||
|
||||
export interface AdminMe {
|
||||
isAdmin: boolean
|
||||
userId: string
|
||||
}
|
||||
|
||||
export interface AdminSummary {
|
||||
totalUsers: number
|
||||
totalLogbooks: number
|
||||
totalPhotos: number
|
||||
totalVoiceMemos: number
|
||||
totalGpsTracks: number
|
||||
totalCollaborations: number
|
||||
totalInvitations: number
|
||||
aiSummaryEntries: number
|
||||
}
|
||||
|
||||
export type AdminTimeBucket = 'day' | 'week' | 'month'
|
||||
|
||||
export interface AdminTimeSeriesPoint {
|
||||
date: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface AdminTimeSeriesMetric {
|
||||
metric: string
|
||||
points: AdminTimeSeriesPoint[]
|
||||
}
|
||||
|
||||
export interface AdminTimeSeriesResponse {
|
||||
bucket: AdminTimeBucket
|
||||
windowDays: number
|
||||
series: AdminTimeSeriesMetric[]
|
||||
}
|
||||
|
||||
export async function fetchAdminMe(): Promise<AdminMe> {
|
||||
return await apiJson<AdminMe>(`${ADMIN_BASE}/me`)
|
||||
}
|
||||
|
||||
/** Returns true only for users listed in server ADMIN_USER_IDS. */
|
||||
export async function checkAdminAccess(): Promise<boolean> {
|
||||
try {
|
||||
await fetchAdminMe()
|
||||
return true
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAdminSummary(): Promise<AdminSummary> {
|
||||
return await apiJson<AdminSummary>(`${ADMIN_BASE}/summary`)
|
||||
}
|
||||
|
||||
export async function fetchAdminTimeSeries(
|
||||
params: { bucket?: AdminTimeBucket; windowDays?: number } = {}
|
||||
): Promise<AdminTimeSeriesResponse> {
|
||||
const search = new URLSearchParams()
|
||||
if (params.bucket) {
|
||||
search.set('bucket', params.bucket)
|
||||
}
|
||||
if (params.windowDays && Number.isFinite(params.windowDays)) {
|
||||
search.set('window', String(params.windowDays))
|
||||
}
|
||||
const query = search.toString()
|
||||
const url = query ? `${ADMIN_BASE}/timeseries?${query}` : `${ADMIN_BASE}/timeseries`
|
||||
return await apiJson<AdminTimeSeriesResponse>(url)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -35,6 +35,7 @@ services:
|
||||
OpenRouterAPIKey: ${OpenRouterAPIKey:-}
|
||||
OpenRouterModel: ${OpenRouterModel:-anthropic/claude-3.5-haiku}
|
||||
SESSION_SECRET: ${SESSION_SECRET:-}
|
||||
ADMIN_USER_IDS: ${ADMIN_USER_IDS:-}
|
||||
NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh}
|
||||
NTFY_TOPIC: ${NTFY_TOPIC:-}
|
||||
NTFY_TOKEN: ${NTFY_TOKEN:-}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
const ADMIN_ENV_KEY = 'ADMIN_USER_IDS'
|
||||
|
||||
export function getAdminUserIds(): Set<string> {
|
||||
const raw = process.env[ADMIN_ENV_KEY]
|
||||
if (!raw) {
|
||||
return new Set()
|
||||
}
|
||||
|
||||
const ids = raw
|
||||
.split(',')
|
||||
.map((id) => id.trim())
|
||||
.filter((id) => id.length > 0)
|
||||
|
||||
if (ids.length === 0) {
|
||||
return new Set()
|
||||
}
|
||||
|
||||
return new Set(ids)
|
||||
}
|
||||
|
||||
export function isAdminUserId(userId: string): boolean {
|
||||
const adminIds = getAdminUserIds()
|
||||
return adminIds.has(userId)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import pushRouter from './routes/push.js'
|
||||
import weatherRouter from './routes/weather.js'
|
||||
import aiRouter from './routes/ai.js'
|
||||
import feedbackRouter from './routes/feedback.js'
|
||||
import adminRouter from './routes/admin.js'
|
||||
import { prisma } from './db.js'
|
||||
import { buildCorsOptions } from './cors.js'
|
||||
|
||||
@@ -121,6 +122,7 @@ export function createApp(): express.Express {
|
||||
app.use('/api/weather', weatherRouter)
|
||||
app.use('/api/ai', aiRouter)
|
||||
app.use('/api/feedback', feedbackRouter)
|
||||
app.use('/api/admin', adminRouter)
|
||||
|
||||
app.get('/api/health', async (_req, res) => {
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Request, Response, NextFunction } from 'express'
|
||||
import { hasValidReauth, readSessionFromRequest } from '../session.js'
|
||||
import { isAdminUserId } from '../adminConfig.js'
|
||||
|
||||
export interface AuthedRequest extends Request {
|
||||
userId: string
|
||||
@@ -31,3 +32,21 @@ export function requireReauth(req: Request, res: Response, next: NextFunction):
|
||||
;(req as AuthedRequest).session = session
|
||||
next()
|
||||
}
|
||||
|
||||
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
|
||||
const session = readSessionFromRequest(req)
|
||||
if (!session) {
|
||||
res.status(401).json({ error: 'Unauthorized: valid session required' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!isAdminUserId(session.userId)) {
|
||||
res.status(403).json({ error: 'Forbidden: admin access required' })
|
||||
return
|
||||
}
|
||||
|
||||
;(req as AuthedRequest).userId = session.userId
|
||||
;(req as AuthedRequest).session = session
|
||||
next()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { Router } from 'express'
|
||||
import { prisma } from '../db.js'
|
||||
import { requireUser, requireAdmin, type AuthedRequest } from '../middleware/auth.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/me', requireUser, requireAdmin, (req, res) => {
|
||||
const { userId } = req as AuthedRequest
|
||||
res.json({ isAdmin: true, userId })
|
||||
})
|
||||
|
||||
router.get('/summary', requireUser, requireAdmin, async (_req, res) => {
|
||||
try {
|
||||
const [totalUsers, totalLogbooks, totalPhotos, totalVoiceMemos, totalGpsTracks, totalCollaborations, totalInvitations, aiSummaryEntries] =
|
||||
await Promise.all([
|
||||
prisma.user.count(),
|
||||
prisma.logbook.count(),
|
||||
prisma.photoPayload.count(),
|
||||
prisma.voiceMemoPayload.count(),
|
||||
prisma.gpsTrackPayload.count(),
|
||||
prisma.collaboration.count(),
|
||||
prisma.invitation.count(),
|
||||
prisma.aiSummaryUsage.count()
|
||||
])
|
||||
|
||||
res.json({
|
||||
totalUsers,
|
||||
totalLogbooks,
|
||||
totalPhotos,
|
||||
totalVoiceMemos,
|
||||
totalGpsTracks,
|
||||
totalCollaborations,
|
||||
totalInvitations,
|
||||
aiSummaryEntries
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
console.error('admin/summary error', error)
|
||||
res.status(500).json({ error: 'Failed to load admin summary' })
|
||||
}
|
||||
})
|
||||
|
||||
type TimeBucket = 'day' | 'week' | 'month'
|
||||
|
||||
interface TimeSeriesPoint {
|
||||
date: string
|
||||
count: number
|
||||
}
|
||||
|
||||
interface TimeSeries {
|
||||
metric: string
|
||||
points: TimeSeriesPoint[]
|
||||
}
|
||||
|
||||
function normalizeBucket(value: string | undefined | null): TimeBucket {
|
||||
if (value === 'week' || value === 'month') return value
|
||||
return 'day'
|
||||
}
|
||||
|
||||
function parseWindowDays(raw: string | undefined | null): number {
|
||||
const n = raw ? Number.parseInt(raw, 10) : NaN
|
||||
if (!Number.isFinite(n) || n <= 0) return 90
|
||||
return Math.min(n, 365)
|
||||
}
|
||||
|
||||
function startOfDay(date: Date): Date {
|
||||
const d = new Date(date)
|
||||
d.setUTCHours(0, 0, 0, 0)
|
||||
return d
|
||||
}
|
||||
|
||||
function startOfWeek(date: Date): Date {
|
||||
const d = startOfDay(date)
|
||||
const day = d.getUTCDay() || 7
|
||||
d.setUTCDate(d.getUTCDate() - (day - 1))
|
||||
return d
|
||||
}
|
||||
|
||||
function startOfMonth(date: Date): Date {
|
||||
const d = startOfDay(date)
|
||||
d.setUTCDate(1)
|
||||
return d
|
||||
}
|
||||
|
||||
function bucketDate(date: Date, bucket: TimeBucket): string {
|
||||
const base =
|
||||
bucket === 'week' ? startOfWeek(date) : bucket === 'month' ? startOfMonth(date) : startOfDay(date)
|
||||
return base.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
async function buildTimeSeries(bucket: TimeBucket, windowDays: number): Promise<TimeSeries[]> {
|
||||
const since = new Date()
|
||||
since.setUTCDate(since.getUTCDate() - windowDays)
|
||||
|
||||
const [users, logbooks, photos] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
where: { createdAt: { gte: since } },
|
||||
select: { createdAt: true }
|
||||
}),
|
||||
prisma.logbook.findMany({
|
||||
where: { createdAt: { gte: since } },
|
||||
select: { createdAt: true }
|
||||
}),
|
||||
prisma.photoPayload.findMany({
|
||||
where: { updatedAt: { gte: since } },
|
||||
select: { updatedAt: true }
|
||||
})
|
||||
])
|
||||
|
||||
function aggregate(dates: Date[], metric: string): TimeSeries {
|
||||
const map = new Map<string, number>()
|
||||
for (const d of dates) {
|
||||
const key = bucketDate(d, bucket)
|
||||
map.set(key, (map.get(key) ?? 0) + 1)
|
||||
}
|
||||
const points = Array.from(map.entries())
|
||||
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
||||
.map(([date, count]) => ({ date, count }))
|
||||
return { metric, points }
|
||||
}
|
||||
|
||||
return [
|
||||
aggregate(
|
||||
users.map((u) => u.createdAt),
|
||||
'users_created'
|
||||
),
|
||||
aggregate(
|
||||
logbooks.map((l) => l.createdAt),
|
||||
'logbooks_created'
|
||||
),
|
||||
aggregate(
|
||||
photos.map((p) => p.updatedAt),
|
||||
'photos_updated'
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
router.get('/timeseries', requireUser, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const bucket = normalizeBucket(typeof req.query.bucket === 'string' ? req.query.bucket : undefined)
|
||||
const windowDays = parseWindowDays(typeof req.query.window === 'string' ? req.query.window : undefined)
|
||||
|
||||
const series = await buildTimeSeries(bucket, windowDays)
|
||||
res.json({ bucket, windowDays, series })
|
||||
} catch (error: unknown) {
|
||||
console.error('admin/timeseries error', error)
|
||||
res.status(500).json({ error: 'Failed to load admin time series' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
Reference in New Issue
Block a user