Skip to content

Commit a69ba92

Browse files
committed
feat(usePermissions): add new composable
1 parent 8aedd6b commit a69ba92

File tree

12 files changed

+481
-2
lines changed

12 files changed

+481
-2
lines changed

apps/docs/src/components/app/AppBar.vue

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<script setup lang="ts">
22
// Components
3-
import { Atom, useBreakpoints } from '@vuetify/v0'
3+
import { Atom, useBreakpoints, useFeatures, usePermissions } from '@vuetify/v0'
44
55
// Composables
66
import { useAppStore } from '@/stores/app'
@@ -18,6 +18,14 @@
1818
auth = useAuthStore()
1919
}
2020
const breakpoints = useBreakpoints()
21+
const permissions = usePermissions()
22+
const features = useFeatures()
23+
24+
const devmode = features.get('devmode')!
25+
26+
function onClickDevmode () {
27+
devmode.toggle()
28+
}
2129
</script>
2230

2331
<template>
@@ -44,6 +52,16 @@
4452
</div>
4553

4654
<div class="flex align-center items-center gap-3">
55+
<!-- update when latest @vuetify/one is released -->
56+
<button
57+
v-if="permissions.can((auth?.user as any)?.role, 'use', 'devmode')"
58+
class="text-white pa-1 inline-flex rounded opacity-90 hover:opacity-100"
59+
:class="devmode.isSelected.value ? 'bg-red' : 'bg-gray-400'"
60+
@click="onClickDevmode"
61+
>
62+
<AppIcon icon="dev" />
63+
</button>
64+
4765
<a
4866
class="bg-[#5661ea] text-white pa-1 inline-flex rounded opacity-90 hover:opacity-100"
4967
href="https://discord.gg/vK6T89eNP7"
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
---
2+
meta:
3+
title: usePermissions
4+
description: Role-based permissions composable for managing user access control with support for actions, subjects, and contexts.
5+
keywords: permissions, authorization, RBAC, role-based access control, access control, plugin, Vue, composable
6+
features:
7+
category: Plugin
8+
label: 'E: usePermissions'
9+
github: /composables/usePermissions/
10+
---
11+
12+
# usePermissions
13+
14+
Manage role-based permissions across your app. Register permissions for roles, actions, and subjects with optional context-aware conditions.
15+
16+
<DocsPageFeatures :frontmatter />
17+
18+
## Usage
19+
20+
Install the Permissions plugin once, then access the context anywhere via `createPermissions`.
21+
22+
```ts
23+
import { createApp } from 'vue'
24+
import { createPermissionsPlugin } from '@vuetify/v0'
25+
import App from './App.vue'
26+
27+
const app = createApp(App)
28+
29+
app.use(
30+
createPermissionsPlugin({
31+
permissions: {
32+
admin: [
33+
[['read', 'write'], 'user', true],
34+
[['read', 'write'], 'post', true],
35+
['delete', ['user', 'post'], true],
36+
],
37+
editor: [
38+
[['read', 'write'], 'post', true],
39+
['read', 'user', true],
40+
['delete', 'post', (context) => context.isOwner],
41+
],
42+
viewer: [
43+
['read', ['user', 'post'], true],
44+
],
45+
},
46+
})
47+
)
48+
49+
app.mount('#app')
50+
```
51+
52+
Now in any component, check permissions for specific roles:
53+
54+
```vue
55+
<script lang="ts" setup>
56+
import { usePermissions } from '@vuetify/v0'
57+
58+
const permissions = usePermissions()
59+
const currentUser = { role: 'editor', id: 'user123' }
60+
</script>
61+
62+
<template>
63+
<div>
64+
<button v-if="permissions.can('admin', 'delete', 'user')">
65+
Delete User (Admin Only)
66+
</button>
67+
68+
<button v-if="permissions.can('editor', 'write', 'post')">
69+
Edit Post
70+
</button>
71+
72+
<button
73+
v-if="permissions.can('editor', 'delete', 'post', { isOwner: true })"
74+
>
75+
Delete Own Post
76+
</button>
77+
</div>
78+
</template>
79+
```
80+
81+
Optionally register permissions at runtime:
82+
83+
```vue
84+
<script lang="ts" setup>
85+
import { usePermissions } from '@vuetify/v0'
86+
87+
const permissions = usePermissions()
88+
89+
// Register permission at runtime
90+
permissions.register({
91+
id: 'moderator.ban.user',
92+
value: (context) => context.userLevel < 3
93+
})
94+
95+
// Check the permission
96+
const canBan = permissions.can('moderator', 'ban', 'user', { userLevel: 2 })
97+
</script>
98+
```
99+
100+
## API
101+
102+
### Extensions
103+
104+
| Composable | Description |
105+
|---|---|
106+
| [useTokens](/composables/registration/use-tokens/) | Base tokens composable for managing token collections with namespaced access. |
107+
108+
### `usePermissions`
109+
110+
* **Type**
111+
```ts
112+
interface PermissionTicket extends TokenTicket {
113+
value: boolean | ((context: Record<string, any>) => boolean)
114+
}
115+
116+
interface PermissionContext<Z extends PermissionTicket = PermissionTicket> extends TokenContext<Z> {
117+
can (id: string, action: string, subject: string, context?: Record<string, any>): boolean
118+
}
119+
120+
interface PermissionPluginOptions {
121+
adapter?: PermissionAdapter
122+
permissions?: Record<ID, Array<[string | string[], string | string[], boolean | Function]>>
123+
}
124+
125+
interface PermissionOptions extends PermissionPluginOptions {}
126+
```
127+
* **Details**
128+
- `can (id: string, action: string, subject: string, context?: Record<string, any>): boolean`: Check if a role has permission to perform an action on a subject, optionally with context.
129+
130+
### `can`
131+
132+
- **Type**
133+
```ts
134+
function can (id: string, action: string, subject: string, context?: Record<string, any>): boolean
135+
```
136+
137+
- **Details**
138+
Check if a role has permission to perform a specific action on a subject. The method constructs a permission key in the format `{role}.{action}.{subject}` and evaluates the associated condition.
139+
140+
- **Parameters**
141+
- `id`: The role identifier (e.g., 'admin', 'editor', 'viewer')
142+
- `action`: The action to check (e.g., 'read', 'write', 'delete')
143+
- `subject`: The subject/resource (e.g., 'user', 'post', 'comment')
144+
- `context`: Optional context object for conditional permissions
145+
146+
- **Example**
147+
```ts
148+
// main.ts
149+
import { createApp } from 'vue'
150+
import { createPermissionsPlugin } from '@vuetify/v0'
151+
import App from './App.vue'
152+
153+
const app = createApp(App)
154+
155+
app.use(
156+
createPermissionsPlugin({
157+
permissions: {
158+
admin: [
159+
[['read', 'write', 'delete'], ['user', 'post'], true],
160+
],
161+
editor: [
162+
[['read', 'write'], 'post', true],
163+
['delete', 'post', (context) => context.isOwner],
164+
],
165+
viewer: [
166+
['read', ['user', 'post'], true],
167+
],
168+
},
169+
})
170+
)
171+
```
172+
```vue
173+
<!-- Component.vue -->
174+
<script lang="ts" setup>
175+
import { usePermissions } from '@vuetify/v0'
176+
177+
const permissions = usePermissions()
178+
179+
permissions.can('admin', 'delete', 'user') // true
180+
permissions.can('editor', 'write', 'post') // true
181+
permissions.can('editor', 'delete', 'post', { isOwner: true }) // true
182+
permissions.can('editor', 'delete', 'post', { isOwner: false }) // false
183+
permissions.can('viewer', 'write', 'post') // false
184+
</script>
185+
```
186+
187+
### Permission Structure
188+
189+
Permissions are defined as arrays of tuples in the format:
190+
```ts
191+
[actions, subjects, condition]
192+
```
193+
194+
- **actions**: String or array of action names
195+
- **subjects**: String or array of subject names
196+
- **condition**: Boolean value or function that receives context and returns boolean
197+
198+
### Custom Adapters
199+
200+
You can provide a custom adapter to implement different permission checking logic:
201+
202+
```ts
203+
import { PermissionAdapter } from '@vuetify/v0'
204+
205+
class CustomPermissionAdapter extends PermissionAdapter {
206+
can(role, action, subject, context, permissions) {
207+
// Custom permission logic here
208+
return true
209+
}
210+
}
211+
212+
app.use(
213+
createPermissionsPlugin({
214+
adapter: new CustomPermissionAdapter(),
215+
permissions: {
216+
// ... your permissions
217+
},
218+
})
219+
)
220+
```
221+
222+
## Examples
223+
224+
### Basic RBAC Setup
225+
226+
```ts
227+
const permissions = {
228+
admin: [
229+
[['create', 'read', 'update', 'delete'], ['user', 'post', 'comment'], true],
230+
],
231+
moderator: [
232+
[['read', 'update', 'delete'], ['post', 'comment'], true],
233+
['read', 'user', true],
234+
],
235+
user: [
236+
['read', ['post', 'comment'], true],
237+
[['create', 'update'], 'post', (context) => context.userId === context.authorId],
238+
],
239+
}
240+
```
241+
242+
### Context-Aware Permissions
243+
244+
```ts
245+
const permissions = {
246+
editor: [
247+
['edit', 'post', (context) => {
248+
return context.post.authorId === context.currentUserId ||
249+
context.currentUser.isAdmin
250+
}],
251+
['publish', 'post', (context) => {
252+
return context.post.status === 'draft' &&
253+
context.currentUser.department === 'editorial'
254+
}],
255+
],
256+
}
257+
```

apps/docs/src/plugins/icons.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,14 @@ import {
1616
mdiOpenInNew,
1717
mdiPencil,
1818
mdiTagOutline,
19+
mdiDevTo,
1920
} from '@mdi/js'
2021

2122
// Types
2223
import type { App } from 'vue'
2324

2425
export const [useIconContext, provideIconContext, context] = createTokensContext('v0:icons', {
26+
'dev': mdiDevTo,
2527
'cog': mdiCog,
2628
'alert': mdiAlert,
2729
'pencil': mdiPencil,

apps/docs/src/plugins/zero.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// Vuetify0
2-
import { createBreakpointsPlugin, createHydrationPlugin, createLoggerPlugin, createThemePlugin } from '@vuetify/v0'
2+
import { createBreakpointsPlugin, createFeaturesPlugin, createHydrationPlugin, createLoggerPlugin, createPermissionsPlugin, createThemePlugin } from '@vuetify/v0'
33

44
// Plugins
55
import { createIconPlugin } from './icons'
@@ -12,6 +12,23 @@ export default function zero (app: App) {
1212
app.use(createLoggerPlugin())
1313
app.use(createHydrationPlugin())
1414
app.use(createBreakpointsPlugin())
15+
app.use(
16+
createFeaturesPlugin({
17+
features: {
18+
devmode: {
19+
$value: false,
20+
$description: 'Enables development mode with additional logging and warnings',
21+
},
22+
},
23+
}),
24+
)
25+
app.use(
26+
createPermissionsPlugin({
27+
permissions: {
28+
super: [['use', 'devmode']],
29+
},
30+
}),
31+
)
1532
app.use(
1633
createThemePlugin({
1734
default: 'light',

apps/docs/src/stores/app.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ export const useAppStore = defineStore('app', {
8484
{ name: 'useFeatures', to: '/composables/plugins/use-features' },
8585
{ name: 'useLocale', to: '/composables/plugins/use-locale' },
8686
{ name: 'useLogger', to: '/composables/plugins/use-logger' },
87+
{ name: 'usePermissions', to: '/composables/plugins/use-permissions' },
8788
{ name: 'useStorage', to: '/composables/plugins/use-storage' },
8889
{ name: 'useTheme', to: '/composables/plugins/use-theme' },
8990
],

apps/docs/src/typed-router.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ declare module 'vue-router/auto-routes' {
3838
'/composables/plugins/use-hydration': RouteRecordInfo<'/composables/plugins/use-hydration', '/composables/plugins/use-hydration', Record<never, never>, Record<never, never>>,
3939
'/composables/plugins/use-locale': RouteRecordInfo<'/composables/plugins/use-locale', '/composables/plugins/use-locale', Record<never, never>, Record<never, never>>,
4040
'/composables/plugins/use-logger': RouteRecordInfo<'/composables/plugins/use-logger', '/composables/plugins/use-logger', Record<never, never>, Record<never, never>>,
41+
'/composables/plugins/use-permissions': RouteRecordInfo<'/composables/plugins/use-permissions', '/composables/plugins/use-permissions', Record<never, never>, Record<never, never>>,
4142
'/composables/plugins/use-storage': RouteRecordInfo<'/composables/plugins/use-storage', '/composables/plugins/use-storage', Record<never, never>, Record<never, never>>,
4243
'/composables/plugins/use-theme': RouteRecordInfo<'/composables/plugins/use-theme', '/composables/plugins/use-theme', Record<never, never>, Record<never, never>>,
4344
'/composables/registration/use-registry': RouteRecordInfo<'/composables/registration/use-registry', '/composables/registration/use-registry', Record<never, never>, Record<never, never>>,
@@ -156,6 +157,10 @@ declare module 'vue-router/auto-routes' {
156157
routes: '/composables/plugins/use-logger'
157158
views: never
158159
}
160+
'src/pages/composables/plugins/use-permissions.md': {
161+
routes: '/composables/plugins/use-permissions'
162+
views: never
163+
}
159164
'src/pages/composables/plugins/use-storage.md': {
160165
routes: '/composables/plugins/use-storage'
161166
views: never

packages/0/src/composables/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export * from './useKeydown'
1111
export * from './useLocale'
1212
export * from './useLogger'
1313
export * from './useMutationObserver'
14+
export * from './usePermissions'
1415
export * from './useProxyModel'
1516
export * from './useRegistry'
1617
export * from './useResizeObserver'

0 commit comments

Comments
 (0)