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:
+27
-2
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -43,7 +43,8 @@
|
||||
"deviation": "Ablenkungstabelle",
|
||||
"logs": "Logbucheinträge",
|
||||
"stats": "Statistik",
|
||||
"settings": "Einstellungen"
|
||||
"settings": "Einstellungen",
|
||||
"admin": "Admin"
|
||||
},
|
||||
"auth": {
|
||||
"welcome": "Willkommen bei Kapteins Daagbok",
|
||||
|
||||
@@ -43,7 +43,8 @@
|
||||
"deviation": "Deviation Table",
|
||||
"logs": "Logbook Entries",
|
||||
"stats": "Statistics",
|
||||
"settings": "Settings"
|
||||
"settings": "Settings",
|
||||
"admin": "Admin"
|
||||
},
|
||||
"auth": {
|
||||
"welcome": "Welcome to Kapteins Daagbok",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user