Add user feature permissions and unified account management
Magent CI/CD / verify (push) Canceled after 1m19s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s

This commit is contained in:
2026-09-11 12:31:25 +12:00
parent e2be8b3872
commit ec0a866ef3
32 changed files with 650 additions and 214 deletions
+37
View File
@@ -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
}
+7 -46
View File
@@ -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">
+6 -26
View File
@@ -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>
</>
)
}