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 DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
|
||||||
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
|
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
|
||||||
import ProfileHeaderButton from './components/ProfileHeaderButton.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 { useTranslation } from 'react-i18next'
|
||||||
import { cycleAppLanguage } from './utils/i18nLanguages.js'
|
import { cycleAppLanguage } from './utils/i18nLanguages.js'
|
||||||
import {
|
import {
|
||||||
@@ -94,6 +96,7 @@ function App() {
|
|||||||
// Public demo mode (no account required)
|
// Public demo mode (no account required)
|
||||||
const [isDemoMode, setIsDemoMode] = useState(() => window.location.pathname === '/demo')
|
const [isDemoMode, setIsDemoMode] = useState(() => window.location.pathname === '/demo')
|
||||||
const [isAdminRoute, setIsAdminRoute] = useState(() => window.location.pathname.startsWith('/admin'))
|
const [isAdminRoute, setIsAdminRoute] = useState(() => window.location.pathname.startsWith('/admin'))
|
||||||
|
const [isAdminUser, setIsAdminUser] = useState(false)
|
||||||
|
|
||||||
const syncQueueCount = useLiveQuery(
|
const syncQueueCount = useLiveQuery(
|
||||||
() => activeLogbookId ? db.syncQueue.where({ logbookId: activeLogbookId }).count() : db.syncQueue.count(),
|
() => 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(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated) return
|
if (!isAuthenticated) {
|
||||||
|
setIsAdminUser(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
const userId = localStorage.getItem('active_userid')
|
const userId = localStorage.getItem('active_userid')
|
||||||
if (!userId) return
|
if (!userId) return
|
||||||
void syncAppearancePrefs(userId)
|
void syncAppearancePrefs(userId)
|
||||||
void migrateLegacyCrewToPoolIfNeeded().then(() => syncPersonPool())
|
void migrateLegacyCrewToPoolIfNeeded().then(() => syncPersonPool())
|
||||||
void migrateLegacyYachtsToPoolIfNeeded().then(() => syncVesselPool())
|
void migrateLegacyYachtsToPoolIfNeeded().then(() => syncVesselPool())
|
||||||
}, [isAuthenticated])
|
void refreshAdminAccess()
|
||||||
|
}, [isAuthenticated, refreshAdminAccess])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleOnline = () => {
|
const handleOnline = () => {
|
||||||
@@ -249,6 +261,7 @@ function App() {
|
|||||||
|
|
||||||
const clearAuthenticatedAppState = useCallback(() => {
|
const clearAuthenticatedAppState = useCallback(() => {
|
||||||
setIsAuthenticated(false)
|
setIsAuthenticated(false)
|
||||||
|
setIsAdminUser(false)
|
||||||
setActiveLogbookId(null)
|
setActiveLogbookId(null)
|
||||||
setActiveLogbookTitle(null)
|
setActiveLogbookTitle(null)
|
||||||
setShowUserProfile(false)
|
setShowUserProfile(false)
|
||||||
@@ -343,6 +356,14 @@ function App() {
|
|||||||
setIsAcceptingInvite(false)
|
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) => {
|
const selectLogbook = useCallback((id: string, title: string) => {
|
||||||
setActiveLogbookId(id)
|
setActiveLogbookId(id)
|
||||||
setActiveLogbookTitle(title)
|
setActiveLogbookTitle(title)
|
||||||
@@ -507,6 +528,7 @@ function App() {
|
|||||||
if (!(await confirmLeave())) return
|
if (!(await confirmLeave())) return
|
||||||
void logoutUser()
|
void logoutUser()
|
||||||
setIsAuthenticated(false)
|
setIsAuthenticated(false)
|
||||||
|
setIsAdminUser(false)
|
||||||
setActiveLogbookId(null)
|
setActiveLogbookId(null)
|
||||||
setActiveLogbookTitle(null)
|
setActiveLogbookTitle(null)
|
||||||
setShowUserProfile(false)
|
setShowUserProfile(false)
|
||||||
@@ -621,6 +643,7 @@ function App() {
|
|||||||
onSelectLogbook={selectLogbook}
|
onSelectLogbook={selectLogbook}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
onOpenProfile={() => setShowUserProfile(true)}
|
onOpenProfile={() => setShowUserProfile(true)}
|
||||||
|
onOpenAdmin={isAdminUser ? openAdmin : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -671,6 +694,8 @@ function App() {
|
|||||||
<Languages size={18} />
|
<Languages size={18} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{isAdminUser && <AdminHeaderButton onClick={openAdmin} />}
|
||||||
|
|
||||||
<ProfileHeaderButton onClick={() => setShowUserProfile(true)} />
|
<ProfileHeaderButton onClick={() => setShowUserProfile(true)} />
|
||||||
|
|
||||||
<DisclaimerHeaderButton />
|
<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 DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
|
||||||
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
|
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
|
||||||
import ProfileHeaderButton from './ProfileHeaderButton.tsx'
|
import ProfileHeaderButton from './ProfileHeaderButton.tsx'
|
||||||
|
import AdminHeaderButton from './AdminHeaderButton.tsx'
|
||||||
|
|
||||||
interface LogbookDashboardProps {
|
interface LogbookDashboardProps {
|
||||||
onSelectLogbook: (id: string, title: string) => void
|
onSelectLogbook: (id: string, title: string) => void
|
||||||
onLogout: () => void
|
onLogout: () => void
|
||||||
onOpenProfile: () => void
|
onOpenProfile: () => void
|
||||||
|
onOpenAdmin?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type LogbookSortKey = 'name' | 'date'
|
type LogbookSortKey = 'name' | 'date'
|
||||||
@@ -42,7 +44,7 @@ function sortLogbooks(
|
|||||||
return sorted
|
return sorted
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProfile }: LogbookDashboardProps) {
|
export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProfile, onOpenAdmin }: LogbookDashboardProps) {
|
||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
const { showConfirm } = useDialog()
|
const { showConfirm } = useDialog()
|
||||||
const [logbooks, setLogbooks] = useState<DecryptedLogbook[]>([])
|
const [logbooks, setLogbooks] = useState<DecryptedLogbook[]>([])
|
||||||
@@ -388,6 +390,8 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
|
|
||||||
<ProfileHeaderButton onClick={onOpenProfile} />
|
<ProfileHeaderButton onClick={onOpenProfile} />
|
||||||
|
|
||||||
|
{onOpenAdmin && <AdminHeaderButton onClick={onOpenAdmin} />}
|
||||||
|
|
||||||
{/* Lang toggle */}
|
{/* Lang toggle */}
|
||||||
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
|
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
|
||||||
<Languages size={18} />
|
<Languages size={18} />
|
||||||
|
|||||||
@@ -43,7 +43,8 @@
|
|||||||
"deviation": "Tabel over distraktioner",
|
"deviation": "Tabel over distraktioner",
|
||||||
"logs": "Indlæg i logbogen",
|
"logs": "Indlæg i logbogen",
|
||||||
"stats": "Statistik",
|
"stats": "Statistik",
|
||||||
"settings": "Indstillinger"
|
"settings": "Indstillinger",
|
||||||
|
"admin": "Admin"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"welcome": "Velkommen til Kapteins Daagbok.",
|
"welcome": "Velkommen til Kapteins Daagbok.",
|
||||||
|
|||||||
@@ -43,7 +43,8 @@
|
|||||||
"deviation": "Ablenkungstabelle",
|
"deviation": "Ablenkungstabelle",
|
||||||
"logs": "Logbucheinträge",
|
"logs": "Logbucheinträge",
|
||||||
"stats": "Statistik",
|
"stats": "Statistik",
|
||||||
"settings": "Einstellungen"
|
"settings": "Einstellungen",
|
||||||
|
"admin": "Admin"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"welcome": "Willkommen bei Kapteins Daagbok",
|
"welcome": "Willkommen bei Kapteins Daagbok",
|
||||||
|
|||||||
@@ -43,7 +43,8 @@
|
|||||||
"deviation": "Deviation Table",
|
"deviation": "Deviation Table",
|
||||||
"logs": "Logbook Entries",
|
"logs": "Logbook Entries",
|
||||||
"stats": "Statistics",
|
"stats": "Statistics",
|
||||||
"settings": "Settings"
|
"settings": "Settings",
|
||||||
|
"admin": "Admin"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"welcome": "Welcome to Kapteins Daagbok",
|
"welcome": "Welcome to Kapteins Daagbok",
|
||||||
|
|||||||
@@ -43,7 +43,8 @@
|
|||||||
"deviation": "Tabell over distraksjoner",
|
"deviation": "Tabell over distraksjoner",
|
||||||
"logs": "Loggbokoppføringer",
|
"logs": "Loggbokoppføringer",
|
||||||
"stats": "Statistikk",
|
"stats": "Statistikk",
|
||||||
"settings": "Innstillinger"
|
"settings": "Innstillinger",
|
||||||
|
"admin": "Admin"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"welcome": "Velkommen til Kapteins Daagbok",
|
"welcome": "Velkommen til Kapteins Daagbok",
|
||||||
|
|||||||
@@ -43,7 +43,8 @@
|
|||||||
"deviation": "Distraktionsbord",
|
"deviation": "Distraktionsbord",
|
||||||
"logs": "Loggboksanteckningar",
|
"logs": "Loggboksanteckningar",
|
||||||
"stats": "Statistik",
|
"stats": "Statistik",
|
||||||
"settings": "Inställningar"
|
"settings": "Inställningar",
|
||||||
|
"admin": "Admin"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"welcome": "Välkommen till Kapteins Daagbok",
|
"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'
|
const ADMIN_BASE = '/api/admin'
|
||||||
|
|
||||||
@@ -40,6 +40,19 @@ export async function fetchAdminMe(): Promise<AdminMe> {
|
|||||||
return await apiJson<AdminMe>(`${ADMIN_BASE}/me`)
|
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> {
|
export async function fetchAdminSummary(): Promise<AdminSummary> {
|
||||||
return await apiJson<AdminSummary>(`${ADMIN_BASE}/summary`)
|
return await apiJson<AdminSummary>(`${ADMIN_BASE}/summary`)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user