feat: add in-app admin navigation for whitelisted users

Detect admin access after login and expose a header button that opens /admin via client-side routing so the session stays unlocked when returning to the app.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-05 10:03:46 +02:00
parent e1cb2754c4
commit 43cf589613
9 changed files with 79 additions and 9 deletions
+27 -2
View File
@@ -50,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 {
@@ -94,6 +96,7 @@ 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 syncQueueCount = useLiveQuery(
() => activeLogbookId ? db.syncQueue.where({ logbookId: activeLogbookId }).count() : db.syncQueue.count(),
@@ -162,14 +165,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 = () => {
@@ -249,6 +261,7 @@ function App() {
const clearAuthenticatedAppState = useCallback(() => {
setIsAuthenticated(false)
setIsAdminUser(false)
setActiveLogbookId(null)
setActiveLogbookTitle(null)
setShowUserProfile(false)
@@ -343,6 +356,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)
@@ -507,6 +528,7 @@ function App() {
if (!(await confirmLeave())) return
void logoutUser()
setIsAuthenticated(false)
setIsAdminUser(false)
setActiveLogbookId(null)
setActiveLogbookTitle(null)
setShowUserProfile(false)
@@ -621,6 +643,7 @@ function App() {
onSelectLogbook={selectLogbook}
onLogout={handleLogout}
onOpenProfile={() => setShowUserProfile(true)}
onOpenAdmin={isAdminUser ? openAdmin : undefined}
/>
</div>
)
@@ -671,6 +694,8 @@ function App() {
<Languages size={18} />
</button>
{isAdminUser && <AdminHeaderButton onClick={openAdmin} />}
<ProfileHeaderButton onClick={() => setShowUserProfile(true)} />
<DisclaimerHeaderButton />
@@ -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>
)
}
+5 -1
View File
@@ -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} />
+2 -1
View File
@@ -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.",
+2 -1
View File
@@ -43,7 +43,8 @@
"deviation": "Ablenkungstabelle",
"logs": "Logbucheinträge",
"stats": "Statistik",
"settings": "Einstellungen"
"settings": "Einstellungen",
"admin": "Admin"
},
"auth": {
"welcome": "Willkommen bei Kapteins Daagbok",
+2 -1
View File
@@ -43,7 +43,8 @@
"deviation": "Deviation Table",
"logs": "Logbook Entries",
"stats": "Statistics",
"settings": "Settings"
"settings": "Settings",
"admin": "Admin"
},
"auth": {
"welcome": "Welcome to Kapteins Daagbok",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+14 -1
View File
@@ -1,4 +1,4 @@
import { apiJson } from './api.js'
import { ApiError, apiJson } from './api.js'
const ADMIN_BASE = '/api/admin'
@@ -40,6 +40,19 @@ 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`)
}