Skip to content

Commit 13b1f4d

Browse files
authored
refactor: unify dashboard menu permission routing (#88)
1 parent a3fba99 commit 13b1f4d

File tree

14 files changed

+220
-82
lines changed

14 files changed

+220
-82
lines changed

app/(auth)/auth/login/page.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { useRouter } from "next/navigation"
66
import { useTranslation } from "react-i18next"
77
import { LoginForm, type LoginMethod } from "@/components/auth/login-form"
88
import { useAuth } from "@/contexts/auth-context"
9+
import { useFirstAccessibleDashboardRoute } from "@/hooks/use-first-accessible-dashboard-route"
910
import { useMessage } from "@/lib/feedback/message"
1011
import { configManager } from "@/lib/config"
1112
import { fetchOidcProviders, initiateOidcLogin } from "@/lib/oidc"
@@ -24,6 +25,7 @@ function LoginPageContent() {
2425
const searchParams = useSearchParams()
2526
const message = useMessage()
2627
const { login, isAuthenticated } = useAuth()
28+
const { route: firstAccessibleRoute, isReady: hasResolvedFirstRoute } = useFirstAccessibleDashboardRoute()
2729
const { t } = useTranslation()
2830

2931
const [method, setMethod] = useState<LoginMethod>("accessKeyAndSecretKey")
@@ -39,10 +41,9 @@ function LoginPageContent() {
3941
const [oidcProviders, setOidcProviders] = useState<OidcProvider[]>([])
4042

4143
useEffect(() => {
42-
if (isAuthenticated) {
43-
router.replace("/browser")
44-
}
45-
}, [isAuthenticated, router])
44+
if (!isAuthenticated || !hasResolvedFirstRoute || !firstAccessibleRoute) return
45+
router.replace(firstAccessibleRoute)
46+
}, [isAuthenticated, hasResolvedFirstRoute, firstAccessibleRoute, router])
4647

4748
useEffect(() => {
4849
if (searchParams.get("unauthorized") === "true") {
@@ -70,7 +71,6 @@ function LoginPageContent() {
7071
await login(credentials, currentConfig)
7172

7273
message.success(t("Login Success"))
73-
router.replace("/browser")
7474
} catch {
7575
message.error(t("Login Failed"))
7676
}

app/(auth)/auth/oidc-callback/page.tsx

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { useEffect, useRef, useState } from "react"
44
import { useRouter } from "next/navigation"
55
import { useAuth } from "@/contexts/auth-context"
6+
import { useFirstAccessibleDashboardRoute } from "@/hooks/use-first-accessible-dashboard-route"
67
import { useMessage } from "@/lib/feedback/message"
78
import { parseOidcCallback } from "@/lib/oidc"
89
import { isSafeRedirectPath } from "@/lib/routes"
@@ -11,11 +12,12 @@ import { useTranslation } from "react-i18next"
1112
export default function OidcCallbackPage() {
1213
const router = useRouter()
1314
const { loginWithStsCredentials, isAuthenticated } = useAuth()
15+
const { route: firstAccessibleRoute, isReady: hasResolvedFirstRoute } = useFirstAccessibleDashboardRoute()
1416
const message = useMessage()
1517
const { t } = useTranslation()
1618
const processed = useRef(false)
1719
const [credentialsSet, setCredentialsSet] = useState(false)
18-
const redirectPath = useRef("/browser")
20+
const redirectPath = useRef("/")
1921

2022
// Step 1: Parse hash and store credentials
2123
useEffect(() => {
@@ -31,7 +33,7 @@ export default function OidcCallbackPage() {
3133
return
3234
}
3335

34-
redirectPath.current = isSafeRedirectPath(credentials.redirect, "/browser")
36+
redirectPath.current = isSafeRedirectPath(credentials.redirect, "/")
3537

3638
loginWithStsCredentials({
3739
AccessKeyId: credentials.accessKey,
@@ -51,10 +53,17 @@ export default function OidcCallbackPage() {
5153

5254
// Step 2: Wait for auth state to update before navigating
5355
useEffect(() => {
54-
if (credentialsSet && isAuthenticated) {
56+
if (!credentialsSet || !isAuthenticated) return
57+
58+
if (redirectPath.current !== "/") {
5559
router.replace(redirectPath.current)
60+
return
61+
}
62+
63+
if (hasResolvedFirstRoute && firstAccessibleRoute) {
64+
router.replace(firstAccessibleRoute)
5665
}
57-
}, [credentialsSet, isAuthenticated, router])
66+
}, [credentialsSet, isAuthenticated, hasResolvedFirstRoute, firstAccessibleRoute, router])
5867

5968
return (
6069
<div className="flex min-h-screen items-center justify-center bg-gray-100 dark:bg-neutral-800">

app/(dashboard)/403/page.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,25 @@ import { useRouter } from "next/navigation"
44
import { useTranslation } from "react-i18next"
55
import { Empty, EmptyContent, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from "@/components/ui/empty"
66
import { Button } from "@/components/ui/button"
7+
import { useAuth } from "@/contexts/auth-context"
8+
import { useFirstAccessibleDashboardRoute } from "@/hooks/use-first-accessible-dashboard-route"
9+
import { DASHBOARD_ROUTE_FALLBACK } from "@/lib/dashboard-route-meta"
710

811
export default function ForbiddenPage() {
912
const { t } = useTranslation()
1013
const router = useRouter()
14+
const { logoutAndRedirect } = useAuth()
15+
const { route } = useFirstAccessibleDashboardRoute()
16+
const fallbackNormalized = DASHBOARD_ROUTE_FALLBACK.replace(/\/+$/, "")
17+
const routeNormalized = route?.replace(/\/+$/, "")
18+
const hasSafeHomeRoute = Boolean(routeNormalized && routeNormalized !== fallbackNormalized)
1119

1220
const handleBack = () => {
13-
router.replace("/browser")
21+
if (!hasSafeHomeRoute || !route) {
22+
logoutAndRedirect()
23+
return
24+
}
25+
router.replace(route)
1426
}
1527

1628
return (
@@ -40,7 +52,7 @@ export default function ForbiddenPage() {
4052
</EmptyDescription>
4153
</EmptyHeader>
4254
<Button variant="outline" className="mt-6" onClick={handleBack}>
43-
{t("Back to Home")}
55+
{hasSafeHomeRoute ? t("Back to Home") : t("Back to Login")}
4456
</Button>
4557
</EmptyContent>
4658
</Empty>

app/(dashboard)/page.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
1-
import { redirect } from "next/navigation"
1+
"use client"
2+
3+
import { useEffect } from "react"
4+
import { useRouter } from "next/navigation"
5+
import { useFirstAccessibleDashboardRoute } from "@/hooks/use-first-accessible-dashboard-route"
26

37
export default function HomePage() {
4-
redirect("/browser")
8+
const router = useRouter()
9+
const { route, isReady } = useFirstAccessibleDashboardRoute()
10+
11+
useEffect(() => {
12+
if (!isReady || !route) return
13+
router.replace(route)
14+
}, [isReady, route, router])
15+
16+
return null
517
}

app/(dashboard)/status/page.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import dayjs from "dayjs"
1919
import relativeTime from "dayjs/plugin/relativeTime"
2020
import { useMemo } from "react"
2121
import { useTranslation } from "react-i18next"
22+
import { usePermissions } from "@/hooks/use-permissions"
2223
import { PerformanceSummaryCards } from "../_components/performance-summary-cards"
2324
import { PerformanceUsageCard } from "../_components/performance-usage-card"
2425
import { PerformanceInfrastructureCard } from "../_components/performance-infrastructure-card"
@@ -29,7 +30,9 @@ dayjs.extend(relativeTime)
2930

3031
export default function PerformancePage() {
3132
const { t } = useTranslation()
33+
const { canAccessPath } = usePermissions()
3234
const { systemInfo, metricsInfo, datausageinfo, storageinfo, loading, error, refetch } = usePerformanceData()
35+
const browserHref = canAccessPath("/browser") ? "/browser" : undefined
3336

3437
const numberFormatter = useMemo(() => new Intl.NumberFormat(), [])
3538

@@ -58,14 +61,14 @@ export default function PerformancePage() {
5861
display: numberFormatter.format(systemInfo?.buckets?.count ?? 0),
5962
icon: RiArchiveLine,
6063
caption: null as string | null,
61-
href: "/browser",
64+
href: browserHref,
6265
},
6366
{
6467
label: t("Objects"),
6568
display: numberFormatter.format(systemInfo?.objects?.count ?? 0),
6669
icon: RiStackLine,
6770
caption: null as string | null,
68-
href: "/browser",
71+
href: browserHref,
6972
},
7073
{
7174
label: t("Total Capacity"),
@@ -77,7 +80,7 @@ export default function PerformancePage() {
7780
href: undefined as string | undefined,
7881
},
7982
],
80-
[systemInfo, datausageinfo, numberFormatter, t],
83+
[systemInfo, datausageinfo, numberFormatter, t, browserHref],
8184
)
8285

8386
const fromLastStartTime = useMemo(() => {

components/app-sidebar.tsx

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ import { getIconComponent } from "@/lib/icon-map"
2727
import navs from "@/config/navs"
2828
import type { NavItem } from "@/types/app-config"
2929
import { usePermissions } from "@/hooks/use-permissions"
30+
import { useFirstAccessibleDashboardRoute } from "@/hooks/use-first-accessible-dashboard-route"
31+
import { canAccessDashboardRoute } from "@/lib/dashboard-route-meta"
3032
import { SidebarVersion } from "@/components/sidebars/version"
3133
import { useDirection } from "@/components/ui/direction"
3234
import { getThemeManifest } from "@/lib/theme/manifest"
@@ -65,23 +67,29 @@ export function AppSidebar() {
6567
const dir = useDirection()
6668
const brandInitial = APP_NAME.charAt(0).toUpperCase() ?? "R"
6769

68-
const { isAdmin, canAccessPath } = usePermissions()
70+
const { isAdmin, canAccessPath, hasResolvedAdmin, hasFetchedPolicy, isLoading } = usePermissions()
71+
const { route: homeRoute } = useFirstAccessibleDashboardRoute()
72+
const isPermissionsReady = hasResolvedAdmin && (isAdmin || (!isLoading && hasFetchedPolicy))
6973

70-
const navGroups: NavItem[][] = []
71-
let current: NavItem[] = []
74+
if (!isPermissionsReady) {
75+
return null
76+
}
77+
78+
const visibleNavs = navs.flatMap((nav) => {
79+
if (nav.type === "divider") {
80+
return [nav]
81+
}
7282

73-
for (const nav of navs) {
7483
let visibleChildren: NavItem[] = []
7584
if (nav.children?.length) {
7685
visibleChildren = nav.children.filter((child) => {
77-
if (child.to && !canAccessPath(child.to)) return false
78-
if (child.isAdminOnly && !isAdmin && !child.to) return false
86+
if (!child.to || isExternal(child)) return true
87+
if (!canAccessDashboardRoute(child.to, { isAdmin, canAccessPath })) return false
7988
return true
8089
})
81-
if (visibleChildren.length === 0 && !nav.to) continue
90+
if (visibleChildren.length === 0 && !nav.to) return []
8291
} else {
83-
if (nav.to && !canAccessPath(nav.to)) continue
84-
if (!nav.to && nav.isAdminOnly && !isAdmin) continue
92+
if (nav.to && !isExternal(nav) && !canAccessDashboardRoute(nav.to, { isAdmin, canAccessPath })) return []
8593
}
8694

8795
const navItem = { ...nav }
@@ -91,18 +99,30 @@ export function AppSidebar() {
9199
delete navItem.children
92100
}
93101

102+
return [navItem]
103+
})
104+
105+
const navGroups: NavItem[][] = []
106+
let current: NavItem[] = []
107+
108+
for (let index = 0; index < visibleNavs.length; index++) {
109+
const nav = visibleNavs[index]
110+
if (!nav) continue
111+
94112
if (nav.type === "divider") {
95-
if (current.length) {
113+
const hasItemsBefore = current.length > 0
114+
const hasItemsAfter = visibleNavs.slice(index + 1).some((item) => item.type !== "divider")
115+
if (hasItemsBefore && hasItemsAfter) {
96116
navGroups.push(current)
97117
current = []
98118
}
99119
continue
100120
}
101121

102-
current.push(navItem)
122+
current.push(nav)
103123
}
104124

105-
if (current.length) {
125+
if (current.length > 0) {
106126
navGroups.push(current)
107127
}
108128

@@ -127,7 +147,7 @@ export function AppSidebar() {
127147
className="**:data-[sidebar=menu-button]:text-start! **:data-[sidebar=menu-sub-button]:text-start!"
128148
>
129149
<SidebarHeader>
130-
<Link href="/" className="flex items-center gap-3">
150+
<Link href={homeRoute ?? "/"} className="flex items-center gap-3">
131151
{isCollapsed ? (
132152
<div className="flex size-8 items-center justify-center rounded-lg bg-primary text-md font-semibold text-primary-foreground">
133153
<span>{brandInitial}</span>

components/dashboard-auth-guard.tsx

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@ import { useAuth } from "@/contexts/auth-context"
66
import { useApiReady } from "@/contexts/api-context"
77
import { useS3Ready } from "@/contexts/s3-context"
88
import { usePermissions } from "@/hooks/use-permissions"
9+
import { canAccessDashboardRoute } from "@/lib/dashboard-route-meta"
910

1011
export function DashboardAuthGuard({ children }: { children: React.ReactNode }) {
1112
const router = useRouter()
1213
const pathname = usePathname()
1314
const { isAuthenticated } = useAuth()
1415
const { isReady: apiReady } = useApiReady()
1516
const { isReady: s3Ready } = useS3Ready()
16-
const { isAdmin, userPolicy, isLoading, hasFetchedPolicy, fetchUserPolicy, canAccessPath } = usePermissions()
17+
const { isAdmin, isLoading, hasFetchedPolicy, hasResolvedAdmin, canAccessPath } = usePermissions()
1718

1819
const isReady = apiReady && s3Ready
1920

@@ -24,29 +25,36 @@ export function DashboardAuthGuard({ children }: { children: React.ReactNode })
2425
}, [isAuthenticated, isReady, router])
2526

2627
useEffect(() => {
27-
if (!isReady || !isAuthenticated || isAdmin) return
28-
if (!userPolicy && !isLoading) {
29-
void fetchUserPolicy()
30-
}
31-
}, [isReady, isAuthenticated, isAdmin, userPolicy, isLoading, fetchUserPolicy])
32-
33-
useEffect(() => {
34-
if (!isReady || !isAuthenticated || isAdmin) return
35-
if (isLoading || !hasFetchedPolicy) return
36-
if (!canAccessPath(pathname)) {
28+
if (!isReady || !isAuthenticated || !hasResolvedAdmin) return
29+
if (!isAdmin && (isLoading || !hasFetchedPolicy)) return
30+
if (!canAccessDashboardRoute(pathname, { isAdmin, canAccessPath })) {
3731
router.replace("/403/")
3832
}
39-
}, [isReady, isAuthenticated, isAdmin, isLoading, userPolicy, hasFetchedPolicy, canAccessPath, pathname, router])
33+
}, [
34+
isReady,
35+
isAuthenticated,
36+
hasResolvedAdmin,
37+
isAdmin,
38+
isLoading,
39+
hasFetchedPolicy,
40+
canAccessPath,
41+
pathname,
42+
router,
43+
])
4044

4145
if (!isReady || !isAuthenticated) {
4246
return null
4347
}
4448

49+
if (!hasResolvedAdmin) {
50+
return null
51+
}
52+
4553
if (!isAdmin && (isLoading || !hasFetchedPolicy)) {
4654
return null
4755
}
4856

49-
if (!isAdmin && !canAccessPath(pathname)) {
57+
if (!canAccessDashboardRoute(pathname, { isAdmin, canAccessPath })) {
5058
return null
5159
}
5260

components/user/dropdown.tsx

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import { Button } from "@/components/ui/button"
1111
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
1212
import { useAuth } from "@/contexts/auth-context"
1313
import { usePermissions } from "@/hooks/use-permissions"
14-
import { useUsers } from "@/hooks/use-users"
1514
import { ChangePassword } from "./change-password"
1615
import { useSidebar } from "@/components/ui/sidebar"
1716
import { getThemeManifest } from "@/lib/theme/manifest"
@@ -41,9 +40,8 @@ export function UserDropdown() {
4140
const { t } = useTranslation()
4241
const router = useRouter()
4342
const { resolvedTheme } = useTheme()
44-
const { logout, isAdmin, setIsAdmin } = useAuth()
43+
const { logout, isAdmin } = useAuth()
4544
const { userInfo } = usePermissions()
46-
const { isAdminUser } = useUsers()
4745
const { state } = useSidebar()
4846
const isCollapsed = state === "collapsed"
4947
const theme = getThemeManifest()
@@ -57,14 +55,6 @@ export function UserDropdown() {
5755
setAvatar(resolveAvatarPath(preferredAvatarPath))
5856
}, [preferredAvatarPath])
5957

60-
useEffect(() => {
61-
isAdminUser().then((adminInfo) => {
62-
if (adminInfo) {
63-
setIsAdmin(adminInfo.is_admin ?? false)
64-
}
65-
})
66-
}, [isAdminUser, setIsAdmin])
67-
6858
const handleChangePassword = () => {
6959
setChangePasswordVisible(true)
7060
}

0 commit comments

Comments
 (0)