Add user feature permissions and unified account management
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
'use client'
|
||||
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { authFetch, getApiBase, getToken } from '../lib/auth'
|
||||
import { canAccess, featureForPath, type FeatureAccess } from '../lib/features'
|
||||
|
||||
export function useFeatureUser() {
|
||||
const pathname = usePathname()
|
||||
const [state, setState] = useState<{ path: string; user: { role?: string; features?: FeatureAccess; invite_management_enabled?: boolean } | null }>({ path: '', user: null })
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
const load = async () => {
|
||||
if (!getToken()) { if (active) setState({ path: pathname, user: null }); return }
|
||||
try {
|
||||
const response = await authFetch(`${getApiBase()}/auth/me`)
|
||||
const user = response.ok ? await response.json() : null
|
||||
if (active) setState({ path: pathname, user })
|
||||
} catch { if (active) setState({ path: pathname, user: null }) }
|
||||
}
|
||||
void load()
|
||||
window.addEventListener('focus', load)
|
||||
return () => { active = false; window.removeEventListener('focus', load) }
|
||||
}, [pathname])
|
||||
return { user: state.user, ready: state.path === pathname }
|
||||
}
|
||||
|
||||
export default function FeatureGate({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname()
|
||||
const { user, ready } = useFeatureUser()
|
||||
const feature = featureForPath(pathname)
|
||||
if (!feature) return children
|
||||
if (!ready) return <main className="card">Loading account access...</main>
|
||||
if (!getToken()) return children
|
||||
if (!canAccess(user, feature)) return <main className="card"><h1>Feature unavailable</h1><p>Your account does not have access to this feature. Ask an administrator if you need it enabled.</p><a href="/profile">Go to my profile</a></main>
|
||||
return children
|
||||
}
|
||||
@@ -1,54 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||
import { canAccess, featureForPath } from '../lib/features'
|
||||
import { useFeatureUser } from './FeatureGate'
|
||||
|
||||
export default function HeaderActions() {
|
||||
const [signedIn, setSignedIn] = useState(false)
|
||||
const [role, setRole] = useState<string | null>(null)
|
||||
const [showRequestsNav, setShowRequestsNav] = useState(true)
|
||||
const pathname = usePathname()
|
||||
|
||||
useEffect(() => {
|
||||
const token = getToken()
|
||||
setSignedIn(Boolean(token))
|
||||
if (!token) {
|
||||
setShowRequestsNav(true)
|
||||
return
|
||||
}
|
||||
const load = async () => {
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const [response, siteResponse] = await Promise.all([
|
||||
authFetch(`${baseUrl}/auth/me`),
|
||||
fetch(`${baseUrl}/site/public`).catch(() => null),
|
||||
])
|
||||
if (!response.ok) {
|
||||
clearToken()
|
||||
setSignedIn(false)
|
||||
setRole(null)
|
||||
return
|
||||
}
|
||||
const data = await response.json()
|
||||
setRole(data?.role ?? null)
|
||||
if (siteResponse?.ok) {
|
||||
const siteData = await siteResponse.json()
|
||||
setShowRequestsNav(siteData?.navigation?.showRequests !== false)
|
||||
} else {
|
||||
setShowRequestsNav(true)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setShowRequestsNav(true)
|
||||
}
|
||||
}
|
||||
void load()
|
||||
}, [])
|
||||
|
||||
if (!signedIn) {
|
||||
return null
|
||||
}
|
||||
const { user, ready } = useFeatureUser()
|
||||
const role = user?.role ?? null
|
||||
const showRequestsNav = canAccess(user, 'new_requests')
|
||||
if (!ready || !user) return null
|
||||
|
||||
const roleItems =
|
||||
role === null
|
||||
@@ -104,7 +65,7 @@ export default function HeaderActions() {
|
||||
const items = [
|
||||
...commonItems,
|
||||
...roleItems,
|
||||
]
|
||||
].filter((item) => canAccess(user, featureForPath(item.href)))
|
||||
|
||||
return (
|
||||
<nav className="header-actions" aria-label="Primary">
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { authFetch, getApiBase, getToken } from '../lib/auth'
|
||||
import { getToken } from '../lib/auth'
|
||||
import { canAccess, featureForPath } from '../lib/features'
|
||||
import { useFeatureUser } from './FeatureGate'
|
||||
|
||||
type NavigationItem = {
|
||||
href: string
|
||||
@@ -38,36 +39,16 @@ function NavigationIcon({ name }: { name: NavigationItem['icon'] }) {
|
||||
|
||||
export default function WorkspaceNavigation() {
|
||||
const pathname = usePathname()
|
||||
const [role, setRole] = useState<string | null>(null)
|
||||
const [ready, setReady] = useState(false)
|
||||
const [showRequestsNav, setShowRequestsNav] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
setReady(true)
|
||||
return
|
||||
}
|
||||
Promise.all([
|
||||
authFetch(`${getApiBase()}/auth/me`),
|
||||
fetch(`${getApiBase()}/site/public`).catch(() => null),
|
||||
])
|
||||
.then(async ([response, siteResponse]) => {
|
||||
if (response.ok) setRole((await response.json())?.role ?? 'user')
|
||||
if (siteResponse?.ok) setShowRequestsNav((await siteResponse.json())?.navigation?.showRequests !== false)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => setReady(true))
|
||||
}, [])
|
||||
const { user, ready } = useFeatureUser()
|
||||
const role = user?.role
|
||||
|
||||
if (!ready || !getToken() || HIDDEN_ROUTES.some((route) => pathname.startsWith(route))) {
|
||||
return null
|
||||
}
|
||||
|
||||
const items = NAVIGATION.filter((item) => (!item.adminOnly || role === 'admin') && (showRequestsNav || item.href !== '/new-requests'))
|
||||
const items = NAVIGATION.filter((item) => (!item.adminOnly || role === 'admin') && canAccess(user, featureForPath(item.href)))
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="workspace-mobile-nav" aria-label="Mobile navigation">
|
||||
{items.map((item) => (
|
||||
<a key={item.href} href={item.href} className={item.match(pathname) ? 'is-active' : undefined}>
|
||||
@@ -75,6 +56,5 @@ export default function WorkspaceNavigation() {
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user