shortcut-next 0.2.2 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -2
- package/templates/base/@core/configs/clientConfig.ts +1 -1
- package/templates/base/@core/context/AuthContext.tsx +21 -28
- package/templates/base/@core/hooks/useAbility.ts +58 -0
- package/templates/base/app/(dashboard)/dashboard/page.tsx +104 -0
- package/templates/base/app/(dashboard)/layout.tsx +97 -0
- package/templates/base/app/home/page.tsx +112 -0
- package/templates/base/app/login/page.tsx +296 -0
- package/templates/base/app/unauthorized/page.tsx +120 -0
- package/templates/base/components/MSWProvider.tsx +54 -0
- package/templates/base/components/auth/LoginForm.tsx +279 -0
- package/templates/base/components/auth/SignupForm.tsx +348 -0
- package/templates/base/components/loaders/Spinner.tsx +5 -24
- package/templates/base/components/ui/ErrorMessage.tsx +17 -0
- package/templates/base/components/ui/FormFieldWrapper.tsx +27 -0
- package/templates/base/docs/AuthorizationDocumentation.md +348 -0
- package/templates/base/lib/abilities/checkAuthorization.ts +74 -0
- package/templates/base/lib/abilities/index.ts +27 -0
- package/templates/base/lib/abilities/roles.ts +75 -0
- package/templates/base/lib/abilities/routeMap.ts +35 -0
- package/templates/base/lib/abilities/routeMatcher.ts +117 -0
- package/templates/base/lib/abilities/types.ts +68 -0
- package/templates/base/lib/mocks/browser.ts +11 -0
- package/templates/base/lib/mocks/db.ts +124 -0
- package/templates/base/lib/mocks/handlers/auth.ts +203 -0
- package/templates/base/lib/mocks/handlers/index.ts +16 -0
- package/templates/base/lib/mocks/index.ts +34 -0
- package/templates/base/lib/mocks/jwt.ts +99 -0
- package/templates/base/middleware.ts +147 -0
- package/templates/base/package-lock.json +725 -2
- package/templates/base/package.json +13 -2
- package/templates/base/providers/AppProviders.tsx +8 -5
- package/templates/base/public/locales/ar.json +73 -0
- package/templates/base/public/locales/en.json +73 -0
- package/templates/base/public/mockServiceWorker.js +349 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { MongoAbility } from '@casl/ability'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Subject types for authorization
|
|
5
|
+
* These represent resource areas in the application
|
|
6
|
+
*/
|
|
7
|
+
export type Subjects =
|
|
8
|
+
| 'Home'
|
|
9
|
+
| 'Dashboard'
|
|
10
|
+
| 'Users'
|
|
11
|
+
| 'Settings'
|
|
12
|
+
| 'Reports'
|
|
13
|
+
| 'Tickets'
|
|
14
|
+
| 'all'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Action types for CASL permissions
|
|
18
|
+
* - read: View resources
|
|
19
|
+
* - create: Create new resources
|
|
20
|
+
* - update: Modify existing resources
|
|
21
|
+
* - delete: Remove resources
|
|
22
|
+
* - manage: Full access (all actions)
|
|
23
|
+
*/
|
|
24
|
+
export type Actions = 'read' | 'create' | 'update' | 'delete' | 'manage'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* CASL Ability type for the application
|
|
28
|
+
*/
|
|
29
|
+
export type AppAbility = MongoAbility<[Actions, Subjects]>
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Supported user roles
|
|
33
|
+
* Hierarchy: admin > manager > agent > viewer
|
|
34
|
+
*/
|
|
35
|
+
export type UserRole = 'admin' | 'manager' | 'agent' | 'viewer'
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Minimal user type for authorization checks
|
|
39
|
+
*/
|
|
40
|
+
export interface AuthUser {
|
|
41
|
+
id: string
|
|
42
|
+
role: UserRole
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Route permission mapping entry
|
|
47
|
+
* Maps a route pattern to required CASL permission
|
|
48
|
+
*/
|
|
49
|
+
export interface RoutePermission {
|
|
50
|
+
/** Route pattern (exact, [param], or wildcard /*) */
|
|
51
|
+
pattern: string
|
|
52
|
+
/** Required action to access the route */
|
|
53
|
+
action: Actions
|
|
54
|
+
/** Required subject for the route */
|
|
55
|
+
subject: Subjects
|
|
56
|
+
/** Optional description for documentation */
|
|
57
|
+
description?: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Result of matching a route against patterns
|
|
62
|
+
*/
|
|
63
|
+
export interface MatchedRoute {
|
|
64
|
+
/** The matched permission entry */
|
|
65
|
+
permission: RoutePermission
|
|
66
|
+
/** Extracted dynamic parameters */
|
|
67
|
+
params: Record<string, string>
|
|
68
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MSW Browser Worker
|
|
3
|
+
*
|
|
4
|
+
* Sets up MSW to intercept requests in the browser.
|
|
5
|
+
* This is used for development to mock API responses.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { setupWorker } from 'msw/browser'
|
|
9
|
+
import { handlers } from './handlers'
|
|
10
|
+
|
|
11
|
+
export const worker = setupWorker(...handlers)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mock Database for MSW
|
|
3
|
+
*
|
|
4
|
+
* Uses in-memory storage since MSW service workers don't have localStorage access.
|
|
5
|
+
* This simulates a database for development/testing purposes.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { UserRole } from '@/lib/abilities'
|
|
9
|
+
|
|
10
|
+
export interface MockUser {
|
|
11
|
+
id: string
|
|
12
|
+
email: string
|
|
13
|
+
password: string // In real app, this would be hashed
|
|
14
|
+
name: string
|
|
15
|
+
role: UserRole
|
|
16
|
+
phone?: string
|
|
17
|
+
companyName?: string
|
|
18
|
+
type?: 'BUSINESS' | 'CUSTOMER'
|
|
19
|
+
createdAt: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Default test users for each role
|
|
24
|
+
*/
|
|
25
|
+
const defaultUsers: MockUser[] = [
|
|
26
|
+
{
|
|
27
|
+
id: 'user_admin_001',
|
|
28
|
+
email: 'admin@test.com',
|
|
29
|
+
password: 'password123',
|
|
30
|
+
name: 'Admin User',
|
|
31
|
+
role: 'admin',
|
|
32
|
+
createdAt: new Date().toISOString()
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: 'user_manager_001',
|
|
36
|
+
email: 'manager@test.com',
|
|
37
|
+
password: 'password123',
|
|
38
|
+
name: 'Manager User',
|
|
39
|
+
role: 'manager',
|
|
40
|
+
createdAt: new Date().toISOString()
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: 'user_agent_001',
|
|
44
|
+
email: 'agent@test.com',
|
|
45
|
+
password: 'password123',
|
|
46
|
+
name: 'Agent User',
|
|
47
|
+
role: 'agent',
|
|
48
|
+
createdAt: new Date().toISOString()
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: 'user_viewer_001',
|
|
52
|
+
email: 'viewer@test.com',
|
|
53
|
+
password: 'password123',
|
|
54
|
+
name: 'Viewer User',
|
|
55
|
+
role: 'viewer',
|
|
56
|
+
createdAt: new Date().toISOString()
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
// In-memory database (works in service worker context)
|
|
61
|
+
let usersDb: MockUser[] = [...defaultUsers]
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Get all users from mock database
|
|
65
|
+
*/
|
|
66
|
+
export function getUsers(): MockUser[] {
|
|
67
|
+
return usersDb
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Find user by email
|
|
72
|
+
*/
|
|
73
|
+
export function findUserByEmail(email: string): MockUser | undefined {
|
|
74
|
+
return usersDb.find((u) => u.email.toLowerCase() === email.toLowerCase())
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Find user by ID
|
|
79
|
+
*/
|
|
80
|
+
export function findUserById(id: string): MockUser | undefined {
|
|
81
|
+
return usersDb.find((u) => u.id === id)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Create a new user
|
|
86
|
+
*/
|
|
87
|
+
export function createUser(userData: Omit<MockUser, 'id' | 'createdAt'>): MockUser {
|
|
88
|
+
const newUser: MockUser = {
|
|
89
|
+
...userData,
|
|
90
|
+
id: `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
|
91
|
+
createdAt: new Date().toISOString()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
usersDb.push(newUser)
|
|
95
|
+
return newUser
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Update user
|
|
100
|
+
*/
|
|
101
|
+
export function updateUser(id: string, updates: Partial<MockUser>): MockUser | null {
|
|
102
|
+
const index = usersDb.findIndex((u) => u.id === id)
|
|
103
|
+
|
|
104
|
+
if (index === -1) return null
|
|
105
|
+
|
|
106
|
+
usersDb[index] = { ...usersDb[index], ...updates }
|
|
107
|
+
return usersDb[index]
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Delete user
|
|
112
|
+
*/
|
|
113
|
+
export function deleteUser(id: string): boolean {
|
|
114
|
+
const initialLength = usersDb.length
|
|
115
|
+
usersDb = usersDb.filter((u) => u.id !== id)
|
|
116
|
+
return usersDb.length !== initialLength
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Reset database to default users
|
|
121
|
+
*/
|
|
122
|
+
export function resetDatabase(): void {
|
|
123
|
+
usersDb = [...defaultUsers]
|
|
124
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MSW Auth Handlers
|
|
3
|
+
*
|
|
4
|
+
* Mock API handlers for authentication endpoints.
|
|
5
|
+
* These simulate a real backend for development and testing.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { http, HttpResponse, delay } from 'msw'
|
|
9
|
+
import { findUserByEmail, createUser } from '../db'
|
|
10
|
+
import { createMockAccessToken, createMockRefreshToken, decodeMockToken } from '../jwt'
|
|
11
|
+
import type { UserRole } from '@/lib/abilities'
|
|
12
|
+
|
|
13
|
+
const API_BASE = '/api'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Simulate network delay
|
|
17
|
+
*/
|
|
18
|
+
const SIMULATED_DELAY = 500
|
|
19
|
+
|
|
20
|
+
export const authHandlers = [
|
|
21
|
+
/**
|
|
22
|
+
* POST /api/auth/login
|
|
23
|
+
*/
|
|
24
|
+
http.post(`${API_BASE}/auth/login`, async ({ request }) => {
|
|
25
|
+
await delay(SIMULATED_DELAY)
|
|
26
|
+
|
|
27
|
+
const body = (await request.json()) as { email: string; password: string }
|
|
28
|
+
|
|
29
|
+
if (!body.email || !body.password) {
|
|
30
|
+
return HttpResponse.json({ message: 'Email and password are required' }, { status: 400 })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const user = findUserByEmail(body.email)
|
|
34
|
+
|
|
35
|
+
if (!user) {
|
|
36
|
+
return HttpResponse.json({ message: 'Invalid email or password' }, { status: 401 })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (user.password !== body.password) {
|
|
40
|
+
return HttpResponse.json({ message: 'Invalid email or password' }, { status: 401 })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const accessToken = createMockAccessToken({
|
|
44
|
+
id: user.id,
|
|
45
|
+
email: user.email,
|
|
46
|
+
name: user.name,
|
|
47
|
+
role: user.role
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
const refreshToken = createMockRefreshToken(user.id)
|
|
51
|
+
|
|
52
|
+
// Return user without password
|
|
53
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
54
|
+
const { password, ...userWithoutPassword } = user
|
|
55
|
+
|
|
56
|
+
return HttpResponse.json({
|
|
57
|
+
user: userWithoutPassword,
|
|
58
|
+
accessToken,
|
|
59
|
+
refreshToken
|
|
60
|
+
})
|
|
61
|
+
}),
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* POST /api/auth/signup
|
|
65
|
+
*/
|
|
66
|
+
http.post(`${API_BASE}/auth/signup`, async ({ request }) => {
|
|
67
|
+
await delay(SIMULATED_DELAY)
|
|
68
|
+
|
|
69
|
+
const body = (await request.json()) as {
|
|
70
|
+
email: string
|
|
71
|
+
password: string
|
|
72
|
+
name: string
|
|
73
|
+
phone?: string
|
|
74
|
+
companyName?: string
|
|
75
|
+
type?: 'BUSINESS' | 'CUSTOMER'
|
|
76
|
+
role?: UserRole
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!body.email || !body.password || !body.name) {
|
|
80
|
+
return HttpResponse.json({ message: 'Email, password, and name are required' }, { status: 400 })
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Check if user already exists
|
|
84
|
+
const existingUser = findUserByEmail(body.email)
|
|
85
|
+
if (existingUser) {
|
|
86
|
+
return HttpResponse.json({ message: 'User with this email already exists' }, { status: 409 })
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Create new user (default role is 'viewer' for new signups)
|
|
90
|
+
const newUser = createUser({
|
|
91
|
+
email: body.email,
|
|
92
|
+
password: body.password,
|
|
93
|
+
name: body.name,
|
|
94
|
+
role: body.role || 'viewer', // Allow role override for testing
|
|
95
|
+
phone: body.phone,
|
|
96
|
+
companyName: body.companyName,
|
|
97
|
+
type: body.type
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
const accessToken = createMockAccessToken({
|
|
101
|
+
id: newUser.id,
|
|
102
|
+
email: newUser.email,
|
|
103
|
+
name: newUser.name,
|
|
104
|
+
role: newUser.role
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
const refreshToken = createMockRefreshToken(newUser.id)
|
|
108
|
+
|
|
109
|
+
// Return user without password
|
|
110
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
111
|
+
const { password, ...userWithoutPassword } = newUser
|
|
112
|
+
|
|
113
|
+
return HttpResponse.json({
|
|
114
|
+
user: userWithoutPassword,
|
|
115
|
+
accessToken,
|
|
116
|
+
refreshToken
|
|
117
|
+
})
|
|
118
|
+
}),
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* POST /api/auth/refresh
|
|
122
|
+
*/
|
|
123
|
+
http.post(`${API_BASE}/auth/refresh`, async ({ request }) => {
|
|
124
|
+
await delay(SIMULATED_DELAY)
|
|
125
|
+
|
|
126
|
+
const body = (await request.json()) as { refreshToken: string }
|
|
127
|
+
|
|
128
|
+
if (!body.refreshToken) {
|
|
129
|
+
return HttpResponse.json({ message: 'Refresh token is required' }, { status: 400 })
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const decoded = decodeMockToken(body.refreshToken)
|
|
133
|
+
|
|
134
|
+
if (!decoded || decoded.exp * 1000 < Date.now()) {
|
|
135
|
+
return HttpResponse.json({ message: 'Invalid or expired refresh token' }, { status: 401 })
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Find user by ID from token
|
|
139
|
+
const { findUserById } = await import('../db')
|
|
140
|
+
const user = findUserById(decoded.sub)
|
|
141
|
+
|
|
142
|
+
if (!user) {
|
|
143
|
+
return HttpResponse.json({ message: 'User not found' }, { status: 401 })
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const accessToken = createMockAccessToken({
|
|
147
|
+
id: user.id,
|
|
148
|
+
email: user.email,
|
|
149
|
+
name: user.name,
|
|
150
|
+
role: user.role
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
const refreshToken = createMockRefreshToken(user.id)
|
|
154
|
+
|
|
155
|
+
return HttpResponse.json({
|
|
156
|
+
accessToken,
|
|
157
|
+
refreshToken
|
|
158
|
+
})
|
|
159
|
+
}),
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* POST /api/auth/logout
|
|
163
|
+
*/
|
|
164
|
+
http.post(`${API_BASE}/auth/logout`, async () => {
|
|
165
|
+
await delay(SIMULATED_DELAY / 2)
|
|
166
|
+
|
|
167
|
+
// In a real app, you might invalidate the refresh token here
|
|
168
|
+
return HttpResponse.json({ message: 'Logged out successfully' })
|
|
169
|
+
}),
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* GET /api/auth/me
|
|
173
|
+
*/
|
|
174
|
+
http.get(`${API_BASE}/auth/me`, async ({ request }) => {
|
|
175
|
+
await delay(SIMULATED_DELAY / 2)
|
|
176
|
+
|
|
177
|
+
const authHeader = request.headers.get('Authorization')
|
|
178
|
+
|
|
179
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
180
|
+
return HttpResponse.json({ message: 'Unauthorized' }, { status: 401 })
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const token = authHeader.substring(7)
|
|
184
|
+
const decoded = decodeMockToken(token)
|
|
185
|
+
|
|
186
|
+
if (!decoded || decoded.exp * 1000 < Date.now()) {
|
|
187
|
+
return HttpResponse.json({ message: 'Token expired' }, { status: 401 })
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const { findUserById } = await import('../db')
|
|
191
|
+
const user = findUserById(decoded.sub)
|
|
192
|
+
|
|
193
|
+
if (!user) {
|
|
194
|
+
return HttpResponse.json({ message: 'User not found' }, { status: 404 })
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Return user without password
|
|
198
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
199
|
+
const { password, ...userWithoutPassword } = user
|
|
200
|
+
|
|
201
|
+
return HttpResponse.json({ user: userWithoutPassword })
|
|
202
|
+
})
|
|
203
|
+
]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MSW Handlers Index
|
|
3
|
+
*
|
|
4
|
+
* Combines all mock API handlers.
|
|
5
|
+
* Add new handler modules here as your app grows.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { authHandlers } from './auth'
|
|
9
|
+
|
|
10
|
+
export const handlers = [
|
|
11
|
+
...authHandlers
|
|
12
|
+
// Add more handlers here as needed:
|
|
13
|
+
// ...userHandlers,
|
|
14
|
+
// ...ticketHandlers,
|
|
15
|
+
// etc.
|
|
16
|
+
]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MSW Mock Service Worker Setup
|
|
3
|
+
*
|
|
4
|
+
* This module provides mock API functionality for development.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* 1. Call initMocks() early in your app (e.g., in a useEffect in your root layout)
|
|
8
|
+
* 2. The mock server will intercept API calls and return mock responses
|
|
9
|
+
*
|
|
10
|
+
* To disable mocks, set NEXT_PUBLIC_ENABLE_MOCKS=false in .env.local
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export async function initMocks() {
|
|
14
|
+
// Only run in browser and development
|
|
15
|
+
if (typeof window === 'undefined') return
|
|
16
|
+
if (process.env.NODE_ENV !== 'development') return
|
|
17
|
+
if (process.env.NEXT_PUBLIC_ENABLE_MOCKS === 'false') return
|
|
18
|
+
|
|
19
|
+
const { worker } = await import('./browser')
|
|
20
|
+
|
|
21
|
+
// Start the worker
|
|
22
|
+
await worker.start({
|
|
23
|
+
onUnhandledRequest: 'bypass', // Don't warn about unhandled requests
|
|
24
|
+
serviceWorker: {
|
|
25
|
+
url: '/mockServiceWorker.js'
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
console.log('[MSW] Mock Service Worker started')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Re-export utilities
|
|
33
|
+
export { resetDatabase, getUsers } from './db'
|
|
34
|
+
export type { MockUser } from './db'
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mock JWT utilities for development
|
|
3
|
+
*
|
|
4
|
+
* Creates JWTs that can be decoded by the middleware.
|
|
5
|
+
* In production, JWTs should be created and signed by your backend.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { UserRole } from '@/lib/abilities'
|
|
9
|
+
|
|
10
|
+
interface TokenPayload {
|
|
11
|
+
sub: string
|
|
12
|
+
email: string
|
|
13
|
+
name: string
|
|
14
|
+
role: UserRole
|
|
15
|
+
iat: number
|
|
16
|
+
exp: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Create a mock JWT token
|
|
21
|
+
*
|
|
22
|
+
* This creates a valid JWT structure that can be decoded.
|
|
23
|
+
* Note: This is NOT cryptographically signed - for development only.
|
|
24
|
+
*/
|
|
25
|
+
export function createMockAccessToken(user: {
|
|
26
|
+
id: string
|
|
27
|
+
email: string
|
|
28
|
+
name: string
|
|
29
|
+
role: UserRole
|
|
30
|
+
}): string {
|
|
31
|
+
const header = { alg: 'HS256', typ: 'JWT' }
|
|
32
|
+
|
|
33
|
+
const payload: TokenPayload = {
|
|
34
|
+
sub: user.id,
|
|
35
|
+
email: user.email,
|
|
36
|
+
name: user.name,
|
|
37
|
+
role: user.role,
|
|
38
|
+
iat: Math.floor(Date.now() / 1000),
|
|
39
|
+
exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 7 // 7 days
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const base64Header = base64UrlEncode(JSON.stringify(header))
|
|
43
|
+
const base64Payload = base64UrlEncode(JSON.stringify(payload))
|
|
44
|
+
const mockSignature = base64UrlEncode('mock_signature_for_development')
|
|
45
|
+
|
|
46
|
+
return `${base64Header}.${base64Payload}.${mockSignature}`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Create a mock refresh token
|
|
51
|
+
*/
|
|
52
|
+
export function createMockRefreshToken(userId: string): string {
|
|
53
|
+
const payload = {
|
|
54
|
+
sub: userId,
|
|
55
|
+
type: 'refresh',
|
|
56
|
+
iat: Math.floor(Date.now() / 1000),
|
|
57
|
+
exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 30 // 30 days
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const header = { alg: 'HS256', typ: 'JWT' }
|
|
61
|
+
const base64Header = base64UrlEncode(JSON.stringify(header))
|
|
62
|
+
const base64Payload = base64UrlEncode(JSON.stringify(payload))
|
|
63
|
+
const mockSignature = base64UrlEncode('mock_refresh_signature')
|
|
64
|
+
|
|
65
|
+
return `${base64Header}.${base64Payload}.${mockSignature}`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Decode a JWT token (without verification)
|
|
70
|
+
*/
|
|
71
|
+
export function decodeMockToken(token: string): TokenPayload | null {
|
|
72
|
+
try {
|
|
73
|
+
const parts = token.split('.')
|
|
74
|
+
if (parts.length !== 3) return null
|
|
75
|
+
|
|
76
|
+
const payload = JSON.parse(base64UrlDecode(parts[1]))
|
|
77
|
+
return payload
|
|
78
|
+
} catch {
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Base64 URL encode
|
|
85
|
+
*/
|
|
86
|
+
function base64UrlEncode(str: string): string {
|
|
87
|
+
return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Base64 URL decode
|
|
92
|
+
*/
|
|
93
|
+
function base64UrlDecode(str: string): string {
|
|
94
|
+
let base64 = str.replace(/-/g, '+').replace(/_/g, '/')
|
|
95
|
+
while (base64.length % 4) {
|
|
96
|
+
base64 += '='
|
|
97
|
+
}
|
|
98
|
+
return atob(base64)
|
|
99
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import type { NextRequest } from 'next/server'
|
|
3
|
+
import { decodeJwt } from 'jose'
|
|
4
|
+
import { checkAuthorization, isPublicRoute } from '@/lib/abilities'
|
|
5
|
+
import type { UserRole } from '@/lib/abilities'
|
|
6
|
+
import { authConfig } from './@core/configs/clientConfig'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Cookie name for access token
|
|
10
|
+
* Must match the value in clientConfig.ts
|
|
11
|
+
*/
|
|
12
|
+
const ACCESS_TOKEN_COOKIE = 'accessToken'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Routes that middleware should skip entirely
|
|
16
|
+
* These are static assets and API routes handled elsewhere
|
|
17
|
+
*/
|
|
18
|
+
const SKIP_ROUTES = ['/_next', '/api', '/favicon.ico', '/locales', '/images']
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Valid user roles for type checking
|
|
22
|
+
*/
|
|
23
|
+
const VALID_ROLES: UserRole[] = ['admin', 'manager', 'agent', 'viewer']
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* JWT payload structure expected from the auth system
|
|
27
|
+
*/
|
|
28
|
+
interface JWTPayload {
|
|
29
|
+
sub: string
|
|
30
|
+
email?: string
|
|
31
|
+
role?: string
|
|
32
|
+
name?: string
|
|
33
|
+
exp?: number
|
|
34
|
+
iat?: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Extract user role from JWT token
|
|
39
|
+
*
|
|
40
|
+
* Note: This only decodes the JWT, it does NOT verify the signature.
|
|
41
|
+
* Signature verification should happen in API routes.
|
|
42
|
+
* Middleware decoding is for early rejection of clearly unauthorized requests.
|
|
43
|
+
*
|
|
44
|
+
* @param token - JWT access token
|
|
45
|
+
* @returns UserRole if valid, null otherwise
|
|
46
|
+
*/
|
|
47
|
+
function getUserRoleFromToken(token: string): UserRole | null {
|
|
48
|
+
try {
|
|
49
|
+
const payload = decodeJwt(token) as JWTPayload
|
|
50
|
+
|
|
51
|
+
// Check if token is expired
|
|
52
|
+
if (payload.exp && payload.exp * 1000 < Date.now()) {
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Validate and return role
|
|
57
|
+
const role = payload.role as UserRole
|
|
58
|
+
if (VALID_ROLES.includes(role)) {
|
|
59
|
+
return role
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Default to viewer if no valid role found
|
|
63
|
+
return 'viewer'
|
|
64
|
+
} catch {
|
|
65
|
+
// Invalid JWT format
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Next.js Middleware
|
|
72
|
+
*
|
|
73
|
+
* This is the primary enforcement point for authorization.
|
|
74
|
+
* It runs before any page renders, on the Edge Runtime.
|
|
75
|
+
*
|
|
76
|
+
* Flow:
|
|
77
|
+
* 1. Skip static assets and API routes
|
|
78
|
+
* 2. Allow public routes without auth
|
|
79
|
+
* 3. Extract session from cookie
|
|
80
|
+
* 4. Check authorization
|
|
81
|
+
* 5. Redirect if unauthorized
|
|
82
|
+
*/
|
|
83
|
+
export function middleware(request: NextRequest) {
|
|
84
|
+
const { pathname } = request.nextUrl
|
|
85
|
+
|
|
86
|
+
// Skip middleware for static assets and API routes
|
|
87
|
+
if (SKIP_ROUTES.some(route => pathname.startsWith(route))) {
|
|
88
|
+
return NextResponse.next()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Get token from cookie (needed for both public and protected route checks)
|
|
92
|
+
const token = request.cookies.get(ACCESS_TOKEN_COOKIE)?.value
|
|
93
|
+
const userRole = token ? getUserRoleFromToken(token) : null
|
|
94
|
+
|
|
95
|
+
// Redirect authenticated users away from login page
|
|
96
|
+
if (token && userRole && pathname === '/login') {
|
|
97
|
+
const homeUrl = new URL(authConfig.homePageURL, request.url)
|
|
98
|
+
return NextResponse.redirect(homeUrl)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Allow public routes without further checks
|
|
102
|
+
if (isPublicRoute(pathname)) {
|
|
103
|
+
return NextResponse.next()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Check authorization
|
|
107
|
+
const result = checkAuthorization(pathname, userRole)
|
|
108
|
+
|
|
109
|
+
if (result.authorized) {
|
|
110
|
+
return NextResponse.next()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Handle unauthorized access
|
|
114
|
+
if (result.reason === 'unauthenticated') {
|
|
115
|
+
// Redirect to login with return URL
|
|
116
|
+
const loginUrl = new URL('/login', request.url)
|
|
117
|
+
loginUrl.searchParams.set('returnUrl', pathname)
|
|
118
|
+
return NextResponse.redirect(loginUrl)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (result.reason === 'forbidden') {
|
|
122
|
+
// Redirect to unauthorized page with context
|
|
123
|
+
const unauthorizedUrl = new URL('/unauthorized', request.url)
|
|
124
|
+
unauthorizedUrl.searchParams.set('from', pathname)
|
|
125
|
+
if (result.requiredSubject) {
|
|
126
|
+
unauthorizedUrl.searchParams.set('resource', result.requiredSubject)
|
|
127
|
+
}
|
|
128
|
+
if (result.requiredAction) {
|
|
129
|
+
unauthorizedUrl.searchParams.set('action', result.requiredAction)
|
|
130
|
+
}
|
|
131
|
+
return NextResponse.redirect(unauthorizedUrl)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return NextResponse.next()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Middleware matcher configuration
|
|
139
|
+
*
|
|
140
|
+
* Excludes:
|
|
141
|
+
* - _next/static (static files)
|
|
142
|
+
* - _next/image (image optimization)
|
|
143
|
+
* - favicon.ico, sitemap.xml, robots.txt (metadata files)
|
|
144
|
+
*/
|
|
145
|
+
export const config = {
|
|
146
|
+
matcher: ['/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|mockServiceWorker.js).*)']
|
|
147
|
+
}
|