Skip to content

Commit efec725

Browse files
committed
Rework Vue Router AuthGuard to apply permission and redirect to error pages
1 parent ba3a4e1 commit efec725

File tree

13 files changed

+550
-389
lines changed

13 files changed

+550
-389
lines changed

package-lock.json

Lines changed: 404 additions & 361 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/skeleton/app/assets/router/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import AccountRoutes from '@userfrosting/sprinkle-account/routes'
22
import AdminRoutes from '@userfrosting/sprinkle-admin/routes'
3+
import ErrorRoutes from '@userfrosting/sprinkle-core/routes'
34
import { createRouter, createWebHistory } from 'vue-router'
45

56
const router = createRouter({
@@ -24,7 +25,8 @@ const router = createRouter({
2425
component: () => import('../views/AboutView.vue')
2526
},
2627
// Include sprinkles routes
27-
...AccountRoutes
28+
...AccountRoutes,
29+
...ErrorRoutes
2830
]
2931
},
3032
{

packages/sprinkle-account/app/assets/guards/authGuard.ts

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { watchEffect } from 'vue'
22
import { useAuthStore } from '../stores/auth'
3-
import type { Router } from 'vue-router'
3+
import type { RouteLocationRaw, Router } from 'vue-router'
44

55
export function useAuthGuard(router: Router) {
66
const auth = useAuthStore()
@@ -25,7 +25,18 @@ export function useAuthGuard(router: Router) {
2525
const applyAuthGuard = () => {
2626
const authGuard = getRouteAuth()
2727
if (authGuard !== null && !auth.isAuthenticated) {
28-
const redirectTo = authGuard.redirect ?? '/login'
28+
const redirectTo = authGuard.redirect ?? getErrorRoute('Unauthorized')
29+
redirect(redirectTo)
30+
}
31+
}
32+
33+
/**
34+
* Apply permission route guard
35+
*/
36+
const applyPermissionGuard = () => {
37+
const authGuard = getRouteAuth()
38+
if (authGuard?.permission !== undefined && !auth.checkAccess(authGuard.permission)) {
39+
const redirectTo = authGuard.redirect ?? getErrorRoute('Forbidden')
2940
redirect(redirectTo)
3041
}
3142
}
@@ -36,20 +47,38 @@ export function useAuthGuard(router: Router) {
3647
const applyGuestGuard = () => {
3748
const guestGuard = getRouteGuest()
3849
if (guestGuard !== null && auth.isAuthenticated) {
39-
const redirectTo = guestGuard.redirect ?? '/'
50+
const redirectTo = guestGuard.redirect ?? getErrorRoute('Unauthorized')
4051
redirect(redirectTo)
4152
}
4253
}
4354

4455
/**
4556
* Redirect to the specified route
4657
*/
47-
const redirect = (redirectTo: string | { name: string }) => {
48-
router.push(redirectTo)
58+
const redirect = (redirectTo: RouteLocationRaw) => {
59+
router.replace(redirectTo)
60+
}
61+
62+
/**
63+
* Get the error route location object for the specified route name.
64+
*
65+
* N.B.: Param is used to preserver the current path. Substring is used to
66+
* remove the first char to avoid the target URL starting with `//`. Query
67+
* and hash are used to preserve the current query and hash.
68+
* @see https://router.vuejs.org/guide/essentials/dynamic-matching.html#Catch-all-404-Not-found-Route
69+
*/
70+
const getErrorRoute = (name: string) => {
71+
return {
72+
name: name,
73+
params: { pathMatch: router.currentRoute.value.path.substring(1).split('/') },
74+
query: router.currentRoute.value.query,
75+
hash: router.currentRoute.value.hash
76+
}
4977
}
5078

5179
watchEffect(() => {
5280
applyAuthGuard()
81+
applyPermissionGuard()
5382
applyGuestGuard()
5483
})
5584
}

packages/sprinkle-account/app/assets/interfaces/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ export type { UserInterface } from './models/userInterface'
22
export type { GroupInterface } from './models/groupInterface'
33
export type { RoleInterface } from './models/roleInterface'
44
export type { PermissionInterface, UserPermissionsMapInterface } from './models/permissionInterface'
5-
export type { RouteGuard } from './routes'
5+
export type { RouteAuthGuard, RouteGuestGuard } from './routes'
66
export type { AuthCheckResponse } from './AuthCheckApi'
77
export type { ProfileEditRequest } from './ProfileEditApi'
88
export type { PasswordEditRequest } from './PasswordEditApi'

packages/sprinkle-account/app/assets/interfaces/routes.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,18 @@
99
*/
1010
import 'vue-router'
1111

12-
export interface RouteGuard {
13-
redirect: string | { name: string }
12+
export interface RouteAuthGuard {
13+
redirect?: string | { name: string }
14+
permission?: string
15+
}
16+
17+
export interface RouteGuestGuard {
18+
redirect?: string | { name: string }
1419
}
1520

1621
declare module 'vue-router' {
1722
interface RouteMeta {
18-
auth?: RouteGuard
19-
guest?: RouteGuard
23+
auth?: RouteAuthGuard
24+
guest?: RouteGuestGuard
2025
}
2126
}

packages/sprinkle-account/app/assets/tests/guards/authGuard.test.ts

Lines changed: 52 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,25 @@
22
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
33
import { useAuthGuard } from '../../guards/authGuard'
44
import * as Auth from '../../stores/auth'
5+
import type { RouteAuthGuard, RouteGuestGuard } from 'app/assets/interfaces'
56

67
// Default mock for the auth store and router
78
const mockAuthStore = {
89
isAuthenticated: false,
9-
check: vi.fn()
10+
checkAccess: vi.fn()
1011
}
1112

1213
const mockRouter = {
1314
currentRoute: {
1415
value: {
16+
path: '/foo/bar',
1517
meta: {
16-
auth: null as null | { redirect?: string },
17-
guest: null as null | { redirect?: string }
18+
auth: undefined as RouteAuthGuard | undefined,
19+
guest: undefined as RouteGuestGuard | undefined
1820
}
1921
}
2022
},
21-
push: vi.fn()
23+
replace: vi.fn()
2224
}
2325

2426
describe('authGuard useAuthGuard() method', () => {
@@ -37,59 +39,69 @@ describe('authGuard useAuthGuard() method', () => {
3739
useAuthGuard(mockRouter as any)
3840

3941
// Assert
40-
expect(mockRouter.push).not.toHaveBeenCalled()
42+
expect(mockRouter.replace).not.toHaveBeenCalled()
4143
})
4244

4345
test('should redirect to login if route requires auth and user is not authenticated', () => {
4446
// Arrange
4547
mockRouter.currentRoute.value.meta.auth = { redirect: '/login' }
46-
mockRouter.currentRoute.value.meta.guest = null
4748
mockAuthStore.isAuthenticated = false
4849

4950
// Act
5051
useAuthGuard(mockRouter as any)
5152

5253
// Assert
53-
expect(mockRouter.push).toHaveBeenCalledWith('/login')
54+
expect(mockRouter.replace).toHaveBeenCalled()
55+
expect(mockRouter.replace).toHaveBeenCalledWith('/login')
5456
})
5557

5658
test('should not redirect if route requires auth and user is authenticated', () => {
5759
// Arrange
5860
mockRouter.currentRoute.value.meta.auth = { redirect: '/login' }
59-
mockRouter.currentRoute.value.meta.guest = null
6061
mockAuthStore.isAuthenticated = true
6162

6263
// Act
6364
useAuthGuard(mockRouter as any)
6465

6566
// Assert
66-
expect(mockRouter.push).not.toHaveBeenCalled()
67+
expect(mockRouter.replace).not.toHaveBeenCalled()
6768
})
6869

69-
test('should not redirect if route is for guests and user is not authenticated', () => {
70+
test('should not redirect if route requires permission and user does not have it', () => {
7071
// Arrange
71-
mockRouter.currentRoute.value.meta.auth = null
72+
mockRouter.currentRoute.value.meta.auth = { permission: 'foo.bar', redirect: '/login' }
73+
mockAuthStore.isAuthenticated = true
74+
75+
// Act
76+
useAuthGuard(mockRouter as any)
77+
78+
// Assert
79+
expect(mockRouter.replace).toHaveBeenCalledWith('/login')
80+
})
81+
82+
test('should not redirect if route requires guests and user is not authenticated', () => {
83+
// Arrange
84+
mockRouter.currentRoute.value.meta.auth = undefined
7285
mockRouter.currentRoute.value.meta.guest = { redirect: '/' }
7386
mockAuthStore.isAuthenticated = false
7487

7588
// Act
7689
useAuthGuard(mockRouter as any)
7790

7891
// Assert
79-
expect(mockRouter.push).not.toHaveBeenCalled()
92+
expect(mockRouter.replace).not.toHaveBeenCalled()
8093
})
8194

8295
test('should redirect to home if route is for guests and user is authenticated', () => {
8396
// Arrange
84-
mockRouter.currentRoute.value.meta.auth = null
8597
mockRouter.currentRoute.value.meta.guest = { redirect: '/' }
8698
mockAuthStore.isAuthenticated = true
8799

88100
// Act
89101
useAuthGuard(mockRouter as any)
90102

91103
// Assert
92-
expect(mockRouter.push).toHaveBeenCalledWith('/')
104+
expect(mockRouter.replace).toHaveBeenCalledWith('/')
93105
})
94106

95107
test('should use default redirect for auth guard if no redirect specified', () => {
@@ -101,7 +113,27 @@ describe('authGuard useAuthGuard() method', () => {
101113
useAuthGuard(mockRouter as any)
102114

103115
// Assert
104-
expect(mockRouter.push).toHaveBeenCalledWith('/login')
116+
expect(mockRouter.replace).toHaveBeenCalledWith(
117+
expect.objectContaining({
118+
name: 'Unauthorized'
119+
})
120+
)
121+
})
122+
123+
test('should use default redirect for permission guard if no redirect specified', () => {
124+
// Arrange
125+
mockRouter.currentRoute.value.meta.auth = { permission: 'foo.bar' }
126+
mockAuthStore.isAuthenticated = false
127+
128+
// Act
129+
useAuthGuard(mockRouter as any)
130+
131+
// Assert
132+
expect(mockRouter.replace).toHaveBeenCalledWith(
133+
expect.objectContaining({
134+
name: 'Unauthorized'
135+
})
136+
)
105137
})
106138

107139
test('should use default redirect for guest guard if no redirect specified', () => {
@@ -113,6 +145,10 @@ describe('authGuard useAuthGuard() method', () => {
113145
useAuthGuard(mockRouter as any)
114146

115147
// Assert
116-
expect(mockRouter.push).toHaveBeenCalledWith('/')
148+
expect(mockRouter.replace).toHaveBeenCalledWith(
149+
expect.objectContaining({
150+
name: 'Unauthorized'
151+
})
152+
)
117153
})
118154
})
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* Default error routes.
3+
*
4+
* N.B.: The first of these routes serve as a catch-all, so 404 not found must
5+
* be first.
6+
*/
7+
export default [
8+
{
9+
path: '/:pathMatch(.*)*',
10+
name: 'NotFound',
11+
component: () => import('../views/404NotFound.vue')
12+
},
13+
{
14+
path: '/:pathMatch(.*)*',
15+
name: 'Unauthorized',
16+
component: () => import('../views/401Unauthorized.vue')
17+
},
18+
{
19+
path: '/:pathMatch(.*)*',
20+
name: 'Forbidden',
21+
component: () => import('../views/403Forbidden.vue')
22+
},
23+
{
24+
path: '/:pathMatch(.*)*',
25+
name: 'Error',
26+
component: () => import('../views/500ServerError.vue')
27+
}
28+
]
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<template>
2+
<h1>{{ $t('ERROR.401.TITLE') }}</h1>
3+
<p>{{ $t('ERROR.401.DESCRIPTION') }}</p>
4+
</template>
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<template>
2+
<h1>{{ $t('ERROR.403.TITLE') }}</h1>
3+
<p>{{ $t('ERROR.403.DESCRIPTION') }}</p>
4+
</template>
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<template>
2+
<h1>{{ $t('ERROR.404.TITLE') }}</h1>
3+
<p>{{ $t('ERROR.404.DESCRIPTION') }}</p>
4+
</template>

0 commit comments

Comments
 (0)