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,27 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Box, Typography } from '@mui/material'
|
|
4
|
+
import type { ReactNode } from 'react'
|
|
5
|
+
|
|
6
|
+
interface FormFieldWrapperProps {
|
|
7
|
+
title: string
|
|
8
|
+
children: ReactNode
|
|
9
|
+
required?: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default function FormFieldWrapper({ title, children, required }: FormFieldWrapperProps) {
|
|
13
|
+
return (
|
|
14
|
+
<Box>
|
|
15
|
+
<Typography
|
|
16
|
+
variant="body2"
|
|
17
|
+
fontWeight={500}
|
|
18
|
+
color="text.secondary"
|
|
19
|
+
sx={{ mb: 0.5 }}
|
|
20
|
+
>
|
|
21
|
+
{title}
|
|
22
|
+
{required && <span style={{ color: 'red' }}> *</span>}
|
|
23
|
+
</Typography>
|
|
24
|
+
{children}
|
|
25
|
+
</Box>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
# Adding Protected Pages
|
|
2
|
+
|
|
3
|
+
This guide explains how to add new pages with authentication and authorization.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
### Step 1: Create Your Page
|
|
8
|
+
|
|
9
|
+
Create a new page file **anywhere** in the `app/` folder:
|
|
10
|
+
|
|
11
|
+
```tsx
|
|
12
|
+
// app/invoices/page.tsx (or anywhere you want!)
|
|
13
|
+
'use client'
|
|
14
|
+
|
|
15
|
+
import { Container, Typography } from '@mui/material'
|
|
16
|
+
|
|
17
|
+
export default function InvoicesPage() {
|
|
18
|
+
return (
|
|
19
|
+
<Container maxWidth="lg" sx={{ py: 4 }}>
|
|
20
|
+
<Typography variant="h4">Invoices</Typography>
|
|
21
|
+
{/* Your page content */}
|
|
22
|
+
</Container>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
> **Important:** Always add `'use client'` at the top when using MUI components.
|
|
28
|
+
|
|
29
|
+
**Note:** The file location doesn't matter for protection. You can place pages at:
|
|
30
|
+
|
|
31
|
+
- `app/invoices/page.tsx` → `/invoices`
|
|
32
|
+
- `app/(dashboard)/invoices/page.tsx` → `/invoices` (same URL, just organized)
|
|
33
|
+
- `app/admin/invoices/page.tsx` → `/admin/invoices`
|
|
34
|
+
|
|
35
|
+
Protection is determined by `routeMap.ts`, not folder structure.
|
|
36
|
+
|
|
37
|
+
### Step 2: Add Permission to Route Map
|
|
38
|
+
|
|
39
|
+
Open `lib/abilities/routeMap.ts` and add your route:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
export const routePermissions: RoutePermission[] = [
|
|
43
|
+
// ... existing routes ...
|
|
44
|
+
|
|
45
|
+
// Add your new route
|
|
46
|
+
{
|
|
47
|
+
pattern: '/dashboard/invoices',
|
|
48
|
+
action: 'read',
|
|
49
|
+
subject: 'Invoices', // New subject
|
|
50
|
+
description: 'View invoices list',
|
|
51
|
+
},
|
|
52
|
+
]
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Step 3: Add Subject to Types (if new)
|
|
56
|
+
|
|
57
|
+
If you created a new subject (like `'Invoices'`), add it to `lib/abilities/types.ts`:
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
export type Subjects =
|
|
61
|
+
| 'Dashboard'
|
|
62
|
+
| 'Users'
|
|
63
|
+
| 'Settings'
|
|
64
|
+
| 'Reports'
|
|
65
|
+
| 'Tickets'
|
|
66
|
+
| 'Invoices' // Add your new subject
|
|
67
|
+
| 'all'
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Step 4: Define Role Permissions
|
|
71
|
+
|
|
72
|
+
Open `lib/abilities/roles.ts` and add permissions for each role:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
case 'manager':
|
|
76
|
+
can('read', 'all')
|
|
77
|
+
can('manage', 'Users')
|
|
78
|
+
can('manage', 'Tickets')
|
|
79
|
+
can('manage', 'Reports')
|
|
80
|
+
can('manage', 'Invoices') // Add permission
|
|
81
|
+
cannot('manage', 'Settings')
|
|
82
|
+
break
|
|
83
|
+
|
|
84
|
+
case 'agent':
|
|
85
|
+
can('read', 'Dashboard')
|
|
86
|
+
can('read', 'Tickets')
|
|
87
|
+
can('read', 'Reports')
|
|
88
|
+
can('read', 'Invoices') // Add permission
|
|
89
|
+
// ...
|
|
90
|
+
break
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**Done!** Your page is now protected.
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## Route Pattern Examples
|
|
98
|
+
|
|
99
|
+
| Pattern | Matches | Use Case |
|
|
100
|
+
|---------|---------|----------|
|
|
101
|
+
| `/dashboard/invoices` | Exact path only | List page |
|
|
102
|
+
| `/dashboard/invoices/[id]` | `/dashboard/invoices/123` | Detail page |
|
|
103
|
+
| `/dashboard/invoices/*` | Any nested path | Section with sub-pages |
|
|
104
|
+
|
|
105
|
+
### Dynamic Route Example
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
// For /dashboard/invoices/[id]
|
|
109
|
+
{
|
|
110
|
+
pattern: '/dashboard/invoices/[id]',
|
|
111
|
+
action: 'read',
|
|
112
|
+
subject: 'Invoices',
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
// For edit page /dashboard/invoices/[id]/edit
|
|
116
|
+
{
|
|
117
|
+
pattern: '/dashboard/invoices/[id]/edit',
|
|
118
|
+
action: 'update',
|
|
119
|
+
subject: 'Invoices',
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
// For all nested pages under /invoices, including the page itself
|
|
123
|
+
{
|
|
124
|
+
pattern: '/dashboard/invoices/*',
|
|
125
|
+
action: 'update',
|
|
126
|
+
subject: 'Invoices',
|
|
127
|
+
},
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Actions Reference
|
|
133
|
+
|
|
134
|
+
| Action | Meaning | Typical Use |
|
|
135
|
+
|--------|---------|-------------|
|
|
136
|
+
| `read` | View/list resources | List pages, detail pages |
|
|
137
|
+
| `create` | Create new resources | "New" or "Add" pages |
|
|
138
|
+
| `update` | Modify resources | Edit pages |
|
|
139
|
+
| `delete` | Remove resources | Delete functionality |
|
|
140
|
+
| `manage` | All of the above | Full access |
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## Hiding UI Based on Permissions
|
|
145
|
+
|
|
146
|
+
To show/hide navigation or buttons based on user's role:
|
|
147
|
+
|
|
148
|
+
```tsx
|
|
149
|
+
'use client'
|
|
150
|
+
|
|
151
|
+
import { useAbility } from '@/@core/hooks/useAbility'
|
|
152
|
+
|
|
153
|
+
function Navigation() {
|
|
154
|
+
const ability = useAbility()
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<nav>
|
|
158
|
+
{ability.can('read', 'Invoices') && (
|
|
159
|
+
<Link href="/dashboard/invoices">Invoices</Link>
|
|
160
|
+
)}
|
|
161
|
+
|
|
162
|
+
{ability.can('create', 'Invoices') && (
|
|
163
|
+
<Button>New Invoice</Button>
|
|
164
|
+
)}
|
|
165
|
+
</nav>
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Or use the simpler `useCan` hook:
|
|
171
|
+
|
|
172
|
+
```tsx
|
|
173
|
+
import { useCan } from '@/@core/hooks/useAbility'
|
|
174
|
+
|
|
175
|
+
function InvoiceActions() {
|
|
176
|
+
const canCreate = useCan('create', 'Invoices')
|
|
177
|
+
const canDelete = useCan('delete', 'Invoices')
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<>
|
|
181
|
+
{canCreate && <Button>New Invoice</Button>}
|
|
182
|
+
{canDelete && <Button color="error">Delete</Button>}
|
|
183
|
+
</>
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## Complete Example: Adding an "Orders" Section
|
|
191
|
+
|
|
192
|
+
### 1. Create the pages
|
|
193
|
+
|
|
194
|
+
```
|
|
195
|
+
app/(dashboard)/dashboard/orders/
|
|
196
|
+
├── page.tsx # /dashboard/orders (list)
|
|
197
|
+
├── [id]/
|
|
198
|
+
│ └── page.tsx # /dashboard/orders/123 (detail)
|
|
199
|
+
└── new/
|
|
200
|
+
└── page.tsx # /dashboard/orders/new (create)
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### 2. Update types.ts
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
export type Subjects =
|
|
207
|
+
| 'Dashboard'
|
|
208
|
+
| 'Users'
|
|
209
|
+
| 'Settings'
|
|
210
|
+
| 'Reports'
|
|
211
|
+
| 'Tickets'
|
|
212
|
+
| 'Orders' // Added
|
|
213
|
+
| 'all'
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### 3. Update routeMap.ts
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
// Orders - manager and admin
|
|
220
|
+
{ pattern: '/dashboard/orders', action: 'read', subject: 'Orders' },
|
|
221
|
+
{ pattern: '/dashboard/orders/[id]', action: 'read', subject: 'Orders' },
|
|
222
|
+
{ pattern: '/dashboard/orders/new', action: 'create', subject: 'Orders' },
|
|
223
|
+
{ pattern: '/dashboard/orders/[id]/edit', action: 'update', subject: 'Orders' },
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
### 4. Update roles.ts
|
|
227
|
+
|
|
228
|
+
```ts
|
|
229
|
+
case 'admin':
|
|
230
|
+
can('manage', 'all') // Already has access
|
|
231
|
+
break
|
|
232
|
+
|
|
233
|
+
case 'manager':
|
|
234
|
+
can('read', 'all')
|
|
235
|
+
can('manage', 'Orders') // Full access to orders
|
|
236
|
+
break
|
|
237
|
+
|
|
238
|
+
case 'agent':
|
|
239
|
+
can('read', 'Orders') // Can only view
|
|
240
|
+
break
|
|
241
|
+
|
|
242
|
+
case 'viewer':
|
|
243
|
+
// No access to orders
|
|
244
|
+
break
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
## Understanding Roles
|
|
250
|
+
|
|
251
|
+
### Default Roles
|
|
252
|
+
|
|
253
|
+
The authorization system includes four roles with hierarchical permissions. Find them in `lib/abilities/roles.ts`:
|
|
254
|
+
|
|
255
|
+
| Role | Description | Access Level |
|
|
256
|
+
|------|-------------|--------------|
|
|
257
|
+
| `admin` | Full system access | `can('manage', 'all')` - everything |
|
|
258
|
+
| `manager` | Team management | Read all, manage Users/Tickets/Reports |
|
|
259
|
+
| `agent` | Operational user | Read Dashboard/Tickets/Reports, manage Tickets |
|
|
260
|
+
| `viewer` | Read-only access | Read Dashboard/Reports only |
|
|
261
|
+
|
|
262
|
+
### Where Roles are Defined
|
|
263
|
+
|
|
264
|
+
**Role types:** `lib/abilities/types.ts`
|
|
265
|
+
```ts
|
|
266
|
+
export type UserRole = 'admin' | 'manager' | 'agent' | 'viewer'
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
**Role permissions:** `lib/abilities/roles.ts`
|
|
270
|
+
```ts
|
|
271
|
+
export function defineAbilitiesFor(role: UserRole): AppAbility {
|
|
272
|
+
const { can, cannot, build } = new AbilityBuilder<AppAbility>(createMongoAbility)
|
|
273
|
+
|
|
274
|
+
switch (role) {
|
|
275
|
+
case 'admin':
|
|
276
|
+
can('manage', 'all')
|
|
277
|
+
break
|
|
278
|
+
case 'manager':
|
|
279
|
+
can('read', 'all')
|
|
280
|
+
can('manage', 'Users')
|
|
281
|
+
can('manage', 'Tickets')
|
|
282
|
+
can('manage', 'Reports')
|
|
283
|
+
cannot('manage', 'Settings')
|
|
284
|
+
break
|
|
285
|
+
// ... other roles
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return build()
|
|
289
|
+
}
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
### Adding a New Role
|
|
293
|
+
|
|
294
|
+
1. **Add the role type** in `lib/abilities/types.ts`:
|
|
295
|
+
```ts
|
|
296
|
+
export type UserRole = 'admin' | 'manager' | 'agent' | 'viewer' | 'support'
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
2. **Define permissions** in `lib/abilities/roles.ts`:
|
|
300
|
+
```ts
|
|
301
|
+
case 'support':
|
|
302
|
+
can('read', 'Dashboard')
|
|
303
|
+
can('read', 'Tickets')
|
|
304
|
+
can('create', 'Tickets')
|
|
305
|
+
break
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
3. **Update your JWT** - ensure the backend includes the new role in the token payload.
|
|
309
|
+
|
|
310
|
+
### Modifying Existing Role Permissions
|
|
311
|
+
|
|
312
|
+
Open `lib/abilities/roles.ts` and modify the `switch` cases:
|
|
313
|
+
|
|
314
|
+
```ts
|
|
315
|
+
// Example: Give agents access to create invoices
|
|
316
|
+
case 'agent':
|
|
317
|
+
can('read', 'Dashboard')
|
|
318
|
+
can('read', 'Tickets')
|
|
319
|
+
can('manage', 'Tickets')
|
|
320
|
+
can('read', 'Reports')
|
|
321
|
+
can('create', 'Invoices') // Added new permission
|
|
322
|
+
break
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
### Role Permission Matrix
|
|
326
|
+
|
|
327
|
+
Current default permissions:
|
|
328
|
+
|
|
329
|
+
| Subject | admin | manager | agent | viewer |
|
|
330
|
+
|---------|-------|---------|-------|--------|
|
|
331
|
+
| Dashboard | manage | read | read | read |
|
|
332
|
+
| Users | manage | manage | - | - |
|
|
333
|
+
| Tickets | manage | manage | manage | - |
|
|
334
|
+
| Reports | manage | manage | read | read |
|
|
335
|
+
| Settings | manage | - | - | - |
|
|
336
|
+
|
|
337
|
+
> **Tip:** Use `can('manage', 'Subject')` to grant all actions (read, create, update, delete) at once.
|
|
338
|
+
|
|
339
|
+
---
|
|
340
|
+
|
|
341
|
+
## Summary
|
|
342
|
+
|
|
343
|
+
1. **Create page** in `app/(dashboard)/dashboard/`
|
|
344
|
+
2. **Add route** to `lib/abilities/routeMap.ts`
|
|
345
|
+
3. **Add subject** to `lib/abilities/types.ts` (if new)
|
|
346
|
+
4. **Define permissions** in `lib/abilities/roles.ts`
|
|
347
|
+
|
|
348
|
+
The middleware automatically enforces these rules - no changes needed to individual pages!
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { UserRole, Actions, Subjects } from './types'
|
|
2
|
+
import { matchRoute, isPublicRoute, isAuthenticatedOnlyRoute } from './routeMatcher'
|
|
3
|
+
import { canAccess } from './roles'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Result of an authorization check
|
|
7
|
+
*/
|
|
8
|
+
export interface AuthorizationResult {
|
|
9
|
+
/** Whether access is authorized */
|
|
10
|
+
authorized: boolean
|
|
11
|
+
/** Reason for the result */
|
|
12
|
+
reason?: 'unauthenticated' | 'forbidden' | 'public' | 'authenticated'
|
|
13
|
+
/** The action that was required (for forbidden results) */
|
|
14
|
+
requiredAction?: Actions
|
|
15
|
+
/** The subject that was required (for forbidden results) */
|
|
16
|
+
requiredSubject?: Subjects
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Check if a user can access a given pathname
|
|
21
|
+
*
|
|
22
|
+
* This is the main authorization orchestration function.
|
|
23
|
+
* It checks:
|
|
24
|
+
* 1. If the route is public (always allowed)
|
|
25
|
+
* 2. If the user is authenticated
|
|
26
|
+
* 3. If the route has specific permissions and user satisfies them
|
|
27
|
+
*
|
|
28
|
+
* @param pathname - The URL pathname to check
|
|
29
|
+
* @param userRole - The user's role, or null if not authenticated
|
|
30
|
+
* @returns AuthorizationResult with authorized status and reason
|
|
31
|
+
*/
|
|
32
|
+
export function checkAuthorization(
|
|
33
|
+
pathname: string,
|
|
34
|
+
userRole: UserRole | null
|
|
35
|
+
): AuthorizationResult {
|
|
36
|
+
// 1. Public routes - always allowed
|
|
37
|
+
if (isPublicRoute(pathname)) {
|
|
38
|
+
return { authorized: true, reason: 'public' }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 2. No user = unauthenticated
|
|
42
|
+
if (!userRole) {
|
|
43
|
+
return { authorized: false, reason: 'unauthenticated' }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 3. Routes that only need authentication (no specific permission)
|
|
47
|
+
if (isAuthenticatedOnlyRoute(pathname)) {
|
|
48
|
+
return { authorized: true, reason: 'authenticated' }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 4. Check route-specific permissions
|
|
52
|
+
const matched = matchRoute(pathname)
|
|
53
|
+
|
|
54
|
+
// No matching route permission = allow by default
|
|
55
|
+
// This means the route exists but has no specific permission requirement
|
|
56
|
+
if (!matched) {
|
|
57
|
+
return { authorized: true, reason: 'authenticated' }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const { permission } = matched
|
|
61
|
+
const hasPermission = canAccess(userRole, permission.action, permission.subject)
|
|
62
|
+
|
|
63
|
+
if (hasPermission) {
|
|
64
|
+
return { authorized: true }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// User doesn't have required permission
|
|
68
|
+
return {
|
|
69
|
+
authorized: false,
|
|
70
|
+
reason: 'forbidden',
|
|
71
|
+
requiredAction: permission.action,
|
|
72
|
+
requiredSubject: permission.subject,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Types
|
|
2
|
+
export type {
|
|
3
|
+
Subjects,
|
|
4
|
+
Actions,
|
|
5
|
+
AppAbility,
|
|
6
|
+
AuthUser,
|
|
7
|
+
UserRole,
|
|
8
|
+
RoutePermission,
|
|
9
|
+
MatchedRoute,
|
|
10
|
+
} from './types'
|
|
11
|
+
|
|
12
|
+
// Role definitions
|
|
13
|
+
export { defineAbilitiesFor, canAccess } from './roles'
|
|
14
|
+
|
|
15
|
+
// Route configuration
|
|
16
|
+
export { routePermissions, publicRoutes, authenticatedOnlyRoutes } from './routeMap'
|
|
17
|
+
|
|
18
|
+
// Route matching
|
|
19
|
+
export {
|
|
20
|
+
matchRoute,
|
|
21
|
+
isPublicRoute,
|
|
22
|
+
isAuthenticatedOnlyRoute,
|
|
23
|
+
isProtectedRoute,
|
|
24
|
+
} from './routeMatcher'
|
|
25
|
+
|
|
26
|
+
// Authorization check
|
|
27
|
+
export { checkAuthorization, type AuthorizationResult } from './checkAuthorization'
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { AbilityBuilder, createMongoAbility } from '@casl/ability'
|
|
2
|
+
import type { AppAbility, UserRole, Actions, Subjects } from './types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Define CASL abilities for each role
|
|
6
|
+
*
|
|
7
|
+
* Role hierarchy: admin > manager > agent > viewer
|
|
8
|
+
*
|
|
9
|
+
* @param role - The user's role
|
|
10
|
+
* @returns CASL Ability instance with permissions for that role
|
|
11
|
+
*/
|
|
12
|
+
export function defineAbilitiesFor(role: UserRole): AppAbility {
|
|
13
|
+
const { can, cannot, build } = new AbilityBuilder<AppAbility>(createMongoAbility)
|
|
14
|
+
|
|
15
|
+
switch (role) {
|
|
16
|
+
case 'admin':
|
|
17
|
+
// Admin has full access to everything
|
|
18
|
+
can('manage', 'all')
|
|
19
|
+
break
|
|
20
|
+
|
|
21
|
+
case 'manager':
|
|
22
|
+
// Manager can read everything
|
|
23
|
+
can('read', 'all')
|
|
24
|
+
// Manager can manage Users, Tickets, and Reports
|
|
25
|
+
can('manage', 'Users')
|
|
26
|
+
can('manage', 'Tickets')
|
|
27
|
+
can('manage', 'Reports')
|
|
28
|
+
// Manager cannot access Settings
|
|
29
|
+
cannot('manage', 'Settings')
|
|
30
|
+
break
|
|
31
|
+
|
|
32
|
+
case 'agent':
|
|
33
|
+
// Agent can read Home, Dashboard, Tickets, and Reports
|
|
34
|
+
can('read', 'Home')
|
|
35
|
+
can('read', 'Dashboard')
|
|
36
|
+
can('read', 'Tickets')
|
|
37
|
+
can('read', 'Reports')
|
|
38
|
+
// Agent can manage (CRUD) Tickets
|
|
39
|
+
can('manage', 'Tickets')
|
|
40
|
+
// Agent cannot access Users or Settings
|
|
41
|
+
cannot('read', 'Users')
|
|
42
|
+
cannot('read', 'Settings')
|
|
43
|
+
break
|
|
44
|
+
|
|
45
|
+
case 'viewer':
|
|
46
|
+
// Viewer can read Home, Dashboard and Reports
|
|
47
|
+
can('read', 'Home')
|
|
48
|
+
can('read', 'Dashboard')
|
|
49
|
+
can('read', 'Reports')
|
|
50
|
+
// Viewer cannot access anything else
|
|
51
|
+
cannot('read', 'Users')
|
|
52
|
+
cannot('read', 'Tickets')
|
|
53
|
+
cannot('read', 'Settings')
|
|
54
|
+
break
|
|
55
|
+
|
|
56
|
+
default:
|
|
57
|
+
// Unknown roles get no permissions
|
|
58
|
+
break
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return build()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Quick check if a role can access a subject with given action
|
|
66
|
+
*
|
|
67
|
+
* @param role - User's role
|
|
68
|
+
* @param action - The action to check
|
|
69
|
+
* @param subject - The subject to check against
|
|
70
|
+
* @returns true if the role has permission
|
|
71
|
+
*/
|
|
72
|
+
export function canAccess(role: UserRole, action: Actions, subject: Subjects): boolean {
|
|
73
|
+
const ability = defineAbilitiesFor(role)
|
|
74
|
+
return ability.can(action, subject)
|
|
75
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { RoutePermission } from './types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Centralized route to permission mapping
|
|
5
|
+
*
|
|
6
|
+
* Pattern types supported:
|
|
7
|
+
* - Exact: '/dashboard' matches only '/dashboard'
|
|
8
|
+
* - Dynamic: '/users/[id]' matches '/users/123', '/users/abc'
|
|
9
|
+
* - Wildcard: '/settings/*' matches '/settings/profile', '/settings/a/b/c'
|
|
10
|
+
*
|
|
11
|
+
* To add a new protected route:
|
|
12
|
+
* 1. Add an entry here with the pattern, action, and subject
|
|
13
|
+
* 2. That's it - middleware handles the rest
|
|
14
|
+
*/
|
|
15
|
+
export const routePermissions: RoutePermission[] = [
|
|
16
|
+
// Home - accessible to all authenticated users
|
|
17
|
+
{
|
|
18
|
+
pattern: '/home',
|
|
19
|
+
action: 'read',
|
|
20
|
+
subject: 'Home',
|
|
21
|
+
description: 'Home page for authenticated users'
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Public routes that don't require authentication
|
|
27
|
+
* These bypass all auth checks
|
|
28
|
+
*/
|
|
29
|
+
export const publicRoutes: string[] = ['/', '/login', '/signup', '/forgot-password', '/reset-password', '/unauthorized']
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Routes that only require authentication (any role can access)
|
|
33
|
+
* No specific permission check needed
|
|
34
|
+
*/
|
|
35
|
+
export const authenticatedOnlyRoutes: string[] = ['/home']
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { MatchedRoute } from './types'
|
|
2
|
+
import { routePermissions, publicRoutes, authenticatedOnlyRoutes } from './routeMap'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Convert route pattern to regex for matching
|
|
6
|
+
*
|
|
7
|
+
* Handles:
|
|
8
|
+
* - [param] → named capture group (?<param>[^/]+)
|
|
9
|
+
* - /* → wildcard match (?:/.*)?
|
|
10
|
+
* - Escapes other regex special characters
|
|
11
|
+
*/
|
|
12
|
+
function patternToRegex(pattern: string): RegExp {
|
|
13
|
+
const regexStr = pattern
|
|
14
|
+
// Escape special regex characters (except [ ] and *)
|
|
15
|
+
.replace(/[.+?^${}()|\\]/g, '\\$&')
|
|
16
|
+
// Convert [param] to named capture group
|
|
17
|
+
.replace(/\[(\w+)\]/g, '(?<$1>[^/]+)')
|
|
18
|
+
// Convert /* wildcard to match anything after
|
|
19
|
+
.replace(/\/\*$/, '(?:/.*)?')
|
|
20
|
+
|
|
21
|
+
return new RegExp(`^${regexStr}$`)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Calculate match priority score
|
|
26
|
+
* Higher score = more specific match
|
|
27
|
+
*
|
|
28
|
+
* - Exact matches get highest priority
|
|
29
|
+
* - Dynamic segments get medium priority
|
|
30
|
+
* - Wildcards get lowest priority
|
|
31
|
+
*/
|
|
32
|
+
function getMatchScore(pattern: string): number {
|
|
33
|
+
let score = 100
|
|
34
|
+
|
|
35
|
+
// Count dynamic segments (lower score)
|
|
36
|
+
const dynamicCount = (pattern.match(/\[\w+\]/g) || []).length
|
|
37
|
+
score -= dynamicCount * 10
|
|
38
|
+
|
|
39
|
+
// Wildcard patterns get lowest score
|
|
40
|
+
if (pattern.endsWith('/*')) {
|
|
41
|
+
score -= 50
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return score
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Match a pathname against all route patterns
|
|
49
|
+
*
|
|
50
|
+
* @param pathname - The URL pathname to match
|
|
51
|
+
* @returns MatchedRoute with permission and params, or null if no match
|
|
52
|
+
*/
|
|
53
|
+
export function matchRoute(pathname: string): MatchedRoute | null {
|
|
54
|
+
const matches: Array<{ route: MatchedRoute; score: number }> = []
|
|
55
|
+
|
|
56
|
+
for (const permission of routePermissions) {
|
|
57
|
+
const regex = patternToRegex(permission.pattern)
|
|
58
|
+
const match = pathname.match(regex)
|
|
59
|
+
|
|
60
|
+
if (match) {
|
|
61
|
+
matches.push({
|
|
62
|
+
route: {
|
|
63
|
+
permission,
|
|
64
|
+
params: match.groups || {},
|
|
65
|
+
},
|
|
66
|
+
score: getMatchScore(permission.pattern),
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Return highest scoring match (most specific)
|
|
72
|
+
if (matches.length > 0) {
|
|
73
|
+
matches.sort((a, b) => b.score - a.score)
|
|
74
|
+
return matches[0].route
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Check if a route is public (no auth required)
|
|
82
|
+
*
|
|
83
|
+
* @param pathname - The URL pathname to check
|
|
84
|
+
* @returns true if the route is public
|
|
85
|
+
*/
|
|
86
|
+
export function isPublicRoute(pathname: string): boolean {
|
|
87
|
+
return publicRoutes.some((route) => {
|
|
88
|
+
// Exact match
|
|
89
|
+
if (pathname === route) return true
|
|
90
|
+
|
|
91
|
+
// Check if pathname starts with public route (for sub-paths)
|
|
92
|
+
// e.g., /about matches /about/team
|
|
93
|
+
if (pathname.startsWith(route + '/')) return true
|
|
94
|
+
|
|
95
|
+
return false
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Check if route only requires authentication (no specific permission)
|
|
101
|
+
*
|
|
102
|
+
* @param pathname - The URL pathname to check
|
|
103
|
+
* @returns true if route just needs auth, no specific permission
|
|
104
|
+
*/
|
|
105
|
+
export function isAuthenticatedOnlyRoute(pathname: string): boolean {
|
|
106
|
+
return authenticatedOnlyRoutes.includes(pathname)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Check if route is protected (has specific permission requirements)
|
|
111
|
+
*
|
|
112
|
+
* @param pathname - The URL pathname to check
|
|
113
|
+
* @returns true if route has permission requirements
|
|
114
|
+
*/
|
|
115
|
+
export function isProtectedRoute(pathname: string): boolean {
|
|
116
|
+
return matchRoute(pathname) !== null
|
|
117
|
+
}
|