feat: wire global runtime injection and playground
This commit is contained in:
3
nuxt.d.ts
vendored
Normal file
3
nuxt.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
import './types/shims-nuxt-can.d.ts'
|
||||
|
||||
export {}
|
||||
@@ -36,8 +36,7 @@
|
||||
"dependencies": {
|
||||
"@nuxt/kit": "^4.2.1",
|
||||
"@vue/language-core": "^3.1.3",
|
||||
"diff": "^8.0.2",
|
||||
"htmlparser2": "^10.0.0"
|
||||
"diff": "^8.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxt/devtools": "^3.1.0",
|
||||
|
||||
@@ -1,24 +1,15 @@
|
||||
<template>
|
||||
<button v-if="true" id="btn-view" v-can="true">
|
||||
Voir le dossier 2
|
||||
</button>
|
||||
<div v-cannot>
|
||||
v-else
|
||||
<main class="page">
|
||||
<div v-can="can.baz.dskj">
|
||||
foo.bar
|
||||
</div>
|
||||
|
||||
<button v-if="isReady && can.employee.edit" id="btn-edit" v-can="true">
|
||||
Modifier le dossier
|
||||
</button>
|
||||
<div v-cannot>
|
||||
cannot
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const isReady = true
|
||||
const can = {
|
||||
employee: {
|
||||
view: 'view_employee',
|
||||
edit: 'edit_employee',
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -27,6 +18,13 @@ const can = {
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
section {
|
||||
margin-top: 1.5rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
@@ -35,7 +33,51 @@ section {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
background: transparent;
|
||||
border: 1px solid #cbd5f5;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: inline-flex;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.25rem 0;
|
||||
border-bottom: 1px solid #edf2f7;
|
||||
}
|
||||
|
||||
li:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.allowed {
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.denied {
|
||||
color: #b91c1c;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,11 +2,12 @@ export default defineNuxtConfig({
|
||||
modules: ['../src/module'],
|
||||
devtools: { enabled: true },
|
||||
nuxtCan: {
|
||||
reporter: true
|
||||
// permissions: {
|
||||
// employee: ['view', 'edit'],
|
||||
// contract: ['create'],
|
||||
// },
|
||||
reporter: true,
|
||||
permissions: {
|
||||
employee: ['view', 'edit'],
|
||||
contract: ['create'],
|
||||
baz: [],
|
||||
},
|
||||
// canFunctionImport: '~/permissions/__can__',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ export function __can__(path: string[]) {
|
||||
'contract.create',
|
||||
])
|
||||
|
||||
// return allowed.has(key)
|
||||
console.log('Checking permission:', key)
|
||||
return false
|
||||
const granted = allowed.has(key)
|
||||
console.log('Checking permission:', key, '->', granted)
|
||||
return granted
|
||||
}
|
||||
|
||||
@@ -1,30 +1,60 @@
|
||||
// modules/nuxt-can.ts
|
||||
import { defineNuxtModule, addVitePlugin } from '@nuxt/kit'
|
||||
import { addPlugin, addTemplate, addTypeTemplate, addVitePlugin, createResolver, defineNuxtModule, resolvePath } from '@nuxt/kit'
|
||||
|
||||
import { transformCan } from './runtime/transformer/transform-can'
|
||||
import { CAN_IMPORT_TEMPLATE_FILENAME, CONFIG_KEY, DEFAULT_CAN_FUNCTION_IMPORT, TYPES_TEMPLATE_FILENAME } from './module/constants'
|
||||
import { generateTypeDeclaration } from './module/typegen'
|
||||
import type { ModuleOptions } from './module/types'
|
||||
|
||||
export interface ModuleOptions {
|
||||
reporter: boolean
|
||||
}
|
||||
|
||||
export const CONFIG_KEY = 'nuxtCan'
|
||||
export { CONFIG_KEY }
|
||||
export type { ModuleOptions }
|
||||
|
||||
export default defineNuxtModule<ModuleOptions>({
|
||||
meta: {
|
||||
name: 'nuxt-can',
|
||||
configKey: CONFIG_KEY,
|
||||
compatibility: {
|
||||
nuxt: '^3.0.0 || ^4.0.0',
|
||||
},
|
||||
},
|
||||
|
||||
defaults: {
|
||||
permissions: {},
|
||||
canFunctionImport: DEFAULT_CAN_FUNCTION_IMPORT,
|
||||
reporter: false,
|
||||
},
|
||||
async setup(options, nuxt) {
|
||||
const resolver = createResolver(import.meta.url)
|
||||
const permissions = options.permissions ?? {}
|
||||
const canFunctionImport = options.canFunctionImport ?? DEFAULT_CAN_FUNCTION_IMPORT
|
||||
const reporter = options.reporter ?? false
|
||||
|
||||
const publicConfig = nuxt.options.runtimeConfig.public
|
||||
publicConfig[CONFIG_KEY] = {
|
||||
permissions,
|
||||
canFunctionImport,
|
||||
}
|
||||
|
||||
const resolvedCanFnImport = await resolvePath(canFunctionImport, {
|
||||
alias: nuxt.options.alias,
|
||||
cwd: nuxt.options.srcDir,
|
||||
})
|
||||
|
||||
addPlugin(resolver.resolve('./runtime/plugin'))
|
||||
|
||||
addTemplate({
|
||||
filename: CAN_IMPORT_TEMPLATE_FILENAME,
|
||||
getContents: () => `export { __can__ } from '${resolvedCanFnImport}'\n`,
|
||||
})
|
||||
|
||||
addTypeTemplate({
|
||||
filename: TYPES_TEMPLATE_FILENAME,
|
||||
getContents: () => generateTypeDeclaration(permissions),
|
||||
})
|
||||
|
||||
setup(options) {
|
||||
addVitePlugin({
|
||||
name: 'vite-plugin-nuxt-can',
|
||||
enforce: 'pre',
|
||||
transform(code, id) {
|
||||
return transformCan({ code, id, reporter: options.reporter })
|
||||
return transformCan({ code, id, reporter })
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
5
src/module/constants.ts
Normal file
5
src/module/constants.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const CONFIG_KEY = 'nuxtCan'
|
||||
export const CAN_IMPORT_TEMPLATE_FILENAME = 'nuxt-can/can-import.mjs'
|
||||
export const TYPES_TEMPLATE_FILENAME = 'types/nuxt-can.d.ts'
|
||||
export const DEFAULT_CAN_FUNCTION_IMPORT = '~/permissions/__can__'
|
||||
export const INDENT = ' '
|
||||
59
src/module/typegen.ts
Normal file
59
src/module/typegen.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { INDENT } from './constants'
|
||||
|
||||
const escapeKey = (key: string) => key.replace(/\\/g, '\\\\').replace(/'/g, '\\\'')
|
||||
|
||||
const formatActions = (actions: string[]) => {
|
||||
if (!actions.length) {
|
||||
return `${INDENT.repeat(2)}[action: string]: boolean`
|
||||
}
|
||||
|
||||
return actions
|
||||
.map(action => `${INDENT.repeat(2)}'${escapeKey(action)}': boolean`)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
const buildPermissionTree = (permissions: Record<string, string[]>) => {
|
||||
const entries = Object.entries(permissions)
|
||||
if (!entries.length) {
|
||||
return `any`
|
||||
}
|
||||
|
||||
const body = entries
|
||||
.map(([resource, actions]) => [
|
||||
`${INDENT}'${escapeKey(resource)}': {`,
|
||||
formatActions(actions),
|
||||
`${INDENT}}`,
|
||||
].join('\n'))
|
||||
.join('\n')
|
||||
|
||||
return `{
|
||||
${body}
|
||||
}`
|
||||
}
|
||||
|
||||
export const generateTypeDeclaration = (permissions: Record<string, string[]>) => {
|
||||
const permissionTree = buildPermissionTree(permissions)
|
||||
|
||||
return `/* eslint-disable */
|
||||
// Generated by nuxt-can – do not edit manually.
|
||||
type NuxtCanChecker = (path: string[]) => boolean | Promise<boolean>
|
||||
type NuxtCanPermissions = ${permissionTree}
|
||||
|
||||
declare module 'vue' {
|
||||
${INDENT}interface ComponentCustomProperties {
|
||||
${INDENT.repeat(2)}can: NuxtCanPermissions
|
||||
${INDENT.repeat(2)}$can: NuxtCanPermissions
|
||||
${INDENT.repeat(2)}__can__: NuxtCanChecker
|
||||
${INDENT}}
|
||||
}
|
||||
|
||||
declare module '#app' {
|
||||
${INDENT}interface NuxtApp {
|
||||
${INDENT.repeat(2)}$can: NuxtCanPermissions
|
||||
${INDENT.repeat(2)}$__can__: NuxtCanChecker
|
||||
${INDENT}}
|
||||
}
|
||||
|
||||
export {}
|
||||
`
|
||||
}
|
||||
17
src/module/types.ts
Normal file
17
src/module/types.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export interface ModuleOptions {
|
||||
permissions?: Record<string, string[]>
|
||||
/**
|
||||
* Module specifier to import the host-provided __can__ function from.
|
||||
* Example: '~/permissions/__can__'
|
||||
*/
|
||||
canFunctionImport?: string
|
||||
/**
|
||||
* Enable verbose template diff logging while developing the transformer.
|
||||
*/
|
||||
reporter?: boolean
|
||||
}
|
||||
|
||||
export interface NuxtCanRuntimeConfig {
|
||||
permissions: Record<string, string[]>
|
||||
canFunctionImport: string
|
||||
}
|
||||
@@ -1,5 +1,39 @@
|
||||
import { defineNuxtPlugin } from '#app'
|
||||
import { useRuntimeConfig } from '#imports'
|
||||
import { __can__ as hostCan } from '#build/nuxt-can/can-import.mjs'
|
||||
|
||||
export default defineNuxtPlugin((_nuxtApp) => {
|
||||
console.log('Plugin injected by my-module!')
|
||||
import { createCanProxy } from './utils/create-can-proxy'
|
||||
|
||||
interface NuxtCanRuntimeConfig {
|
||||
permissions: Record<string, string[]>
|
||||
canFunctionImport: string
|
||||
}
|
||||
|
||||
type NuxtCanChecker = (path: string[]) => boolean | Promise<boolean>
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
const moduleConfig = (runtimeConfig.public as { nuxtCan?: NuxtCanRuntimeConfig }).nuxtCan
|
||||
const canProxy = createCanProxy()
|
||||
const canFunction = hostCan as NuxtCanChecker
|
||||
|
||||
if (import.meta.dev && !moduleConfig?.canFunctionImport) {
|
||||
console.warn('[nuxt-can] `canFunctionImport` is missing. Configure it via `nuxtCan.canFunctionImport` in nuxt.config.ts.')
|
||||
}
|
||||
|
||||
nuxtApp.vueApp.config.globalProperties.can = canProxy
|
||||
nuxtApp.vueApp.config.globalProperties.$can = canProxy
|
||||
nuxtApp.vueApp.config.globalProperties.__can__ = canFunction
|
||||
|
||||
nuxtApp.provide('can', canProxy)
|
||||
nuxtApp.provide('__can__', canFunction)
|
||||
|
||||
return {
|
||||
provide: {
|
||||
nuxtCan: moduleConfig ?? {
|
||||
permissions: {},
|
||||
canFunctionImport: '',
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
type TemplateChildNode,
|
||||
type ElementNode,
|
||||
type DirectiveNode,
|
||||
type SourceLocation,
|
||||
NodeTypes,
|
||||
} from '@vue/compiler-dom'
|
||||
|
||||
@@ -20,6 +21,9 @@ interface PendingCan {
|
||||
expression: string
|
||||
}
|
||||
|
||||
const CAN_IDENTIFIER = new Set(['can', '$can'])
|
||||
const SEGMENT_PATTERN = /^[\w-]+$/u
|
||||
|
||||
export function collectCanPatches(ast: RootNode, templateStart: number, filename?: string): Patch[] {
|
||||
const ctx: WalkerContext = {
|
||||
templateStart,
|
||||
@@ -70,12 +74,16 @@ function handleElement(element: ElementNode, ctx: WalkerContext, pendingCan: Pen
|
||||
let canDirective: DirectiveNode | null = null
|
||||
let cannotDirective: DirectiveNode | null = null
|
||||
let ifDirective: DirectiveNode | null = null
|
||||
let elseDirective: DirectiveNode | null = null
|
||||
let elseIfDirective: DirectiveNode | null = null
|
||||
|
||||
for (const prop of element.props) {
|
||||
if (prop.type !== NodeTypes.DIRECTIVE) continue
|
||||
if (prop.name === 'can') canDirective = prop
|
||||
if (prop.name === 'cannot') cannotDirective = prop
|
||||
if (prop.name === 'if') ifDirective = prop
|
||||
if (prop.name === 'else') elseDirective = prop
|
||||
if (prop.name === 'else-if') elseIfDirective = prop
|
||||
}
|
||||
|
||||
if (canDirective && cannotDirective) {
|
||||
@@ -98,6 +106,7 @@ function handleElement(element: ElementNode, ctx: WalkerContext, pendingCan: Pen
|
||||
canDirective,
|
||||
ifDirective,
|
||||
ctx,
|
||||
hasElseLike: Boolean(elseDirective || elseIfDirective),
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -112,25 +121,41 @@ function transformCanDirective(params: {
|
||||
canDirective: DirectiveNode
|
||||
ifDirective: DirectiveNode | null
|
||||
ctx: WalkerContext
|
||||
hasElseLike: boolean
|
||||
}): string {
|
||||
const { canDirective, ifDirective, ctx } = params
|
||||
const { canDirective, ifDirective, ctx, hasElseLike } = params
|
||||
|
||||
if (hasElseLike) {
|
||||
raiseDirectiveError(ctx, canDirective.loc, '`v-can` cannot be used on `v-else` or `v-else-if` branches; wrap the conditional block in a <template> and apply `v-can` inside it.')
|
||||
}
|
||||
|
||||
if (canDirective.exp?.type !== NodeTypes.SIMPLE_EXPRESSION) {
|
||||
raiseDirectiveError(ctx, canDirective.loc, '`v-can` expects a static expression (for example `v-can="can.x.y"`).')
|
||||
}
|
||||
|
||||
const canExpression = canDirective.exp.content?.trim()
|
||||
if (!canExpression) {
|
||||
if (canDirective.arg) {
|
||||
raiseDirectiveError(ctx, canDirective.loc, '`v-can` does not accept arguments.')
|
||||
}
|
||||
|
||||
if (canDirective.modifiers.length) {
|
||||
raiseDirectiveError(ctx, canDirective.loc, '`v-can` does not accept modifiers.')
|
||||
}
|
||||
|
||||
const expressionContent = canDirective.exp.content?.trim()
|
||||
if (!expressionContent) {
|
||||
raiseDirectiveError(ctx, canDirective.loc, '`v-can` requires a valid permission expression such as `can.employee.view`.')
|
||||
}
|
||||
|
||||
const pathSegments = parseCanExpression(expressionContent, ctx, canDirective.loc)
|
||||
const canInvocation = buildCanInvocation(pathSegments)
|
||||
|
||||
if (ifDirective && ifDirective.exp?.type === NodeTypes.SIMPLE_EXPRESSION) {
|
||||
const ifExpression = (ifDirective.exp.content || 'true').trim() || 'true'
|
||||
|
||||
ctx.patches.push({
|
||||
start: ctx.templateStart + ifDirective.loc.start.offset,
|
||||
end: ctx.templateStart + ifDirective.loc.end.offset,
|
||||
text: `v-if="(${canExpression}) && (${ifExpression})"`,
|
||||
text: `v-if="(${ifExpression}) && ${canInvocation}"`,
|
||||
})
|
||||
|
||||
ctx.patches.push({
|
||||
@@ -143,11 +168,11 @@ function transformCanDirective(params: {
|
||||
ctx.patches.push({
|
||||
start: ctx.templateStart + canDirective.loc.start.offset,
|
||||
end: ctx.templateStart + canDirective.loc.end.offset,
|
||||
text: `v-if="${canExpression}"`,
|
||||
text: `v-if="${canInvocation}"`,
|
||||
})
|
||||
}
|
||||
|
||||
return canExpression
|
||||
return canInvocation
|
||||
}
|
||||
|
||||
function transformCannotDirective(params: {
|
||||
@@ -184,3 +209,36 @@ function transformCannotDirective(params: {
|
||||
text: `v-if="!(${pendingCan.expression})"`,
|
||||
})
|
||||
}
|
||||
|
||||
function parseCanExpression(expression: string, ctx: WalkerContext, loc: SourceLocation): string[] {
|
||||
const trimmed = expression.trim()
|
||||
if (!trimmed.length) {
|
||||
raiseDirectiveError(ctx, loc, '`v-can` requires a permission expression such as `can.resource.action`.')
|
||||
}
|
||||
|
||||
const parts = trimmed.split('.')
|
||||
const root = parts.shift()
|
||||
const segments = parts
|
||||
|
||||
if (!root || !CAN_IDENTIFIER.has(root)) {
|
||||
raiseDirectiveError(ctx, loc, '`v-can` expressions must start with `can.` or `$can.` (e.g. `v-can="can.employee.view"`).')
|
||||
}
|
||||
|
||||
if (segments.length < 2) {
|
||||
raiseDirectiveError(ctx, loc, '`v-can` expects at least a resource and action (for example `can.employee.view`).')
|
||||
}
|
||||
|
||||
if (segments.some(segment => !segment.length || !SEGMENT_PATTERN.test(segment))) {
|
||||
raiseDirectiveError(ctx, loc, '`v-can` only supports static dotted paths (letters, numbers, `_`, or `-`).')
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
function buildCanInvocation(segments: string[]): string {
|
||||
const escapedSegments = segments
|
||||
.map(segment => `'${segment.replace(/\\/g, '\\\\').replace(/'/g, '\\u0027')}'`)
|
||||
.join(', ')
|
||||
|
||||
return `__can__([${escapedSegments}])`
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export function transformCan({ code, id, reporter }: TransformInput) {
|
||||
const templateEnd = tpl.loc.end.offset
|
||||
const before = code.slice(templateStart, templateEnd)
|
||||
|
||||
if (!before.includes('v-can'))
|
||||
if (!before.includes('v-can') && !before.includes('v-cannot'))
|
||||
return null
|
||||
|
||||
const ast = parseTemplate(before, { comments: true })
|
||||
@@ -40,6 +40,12 @@ export function transformCan({ code, id, reporter }: TransformInput) {
|
||||
|
||||
return {
|
||||
code: nextCode,
|
||||
map: null,
|
||||
map: {
|
||||
version: 3,
|
||||
sources: [id],
|
||||
names: [],
|
||||
mappings: '',
|
||||
sourcesContent: [nextCode],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
39
src/runtime/utils/create-can-proxy.ts
Normal file
39
src/runtime/utils/create-can-proxy.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
const pathSymbol = Symbol('nuxt-can:path')
|
||||
|
||||
export interface CanPathProxy {
|
||||
[segment: string]: CanPathProxy
|
||||
[pathSymbol]: string[]
|
||||
}
|
||||
|
||||
const stringifyPath = (path: string[]) => path.join('.')
|
||||
|
||||
export function extractCanPath(value: unknown): string[] | null {
|
||||
if (value && typeof value === 'object' && pathSymbol in value) {
|
||||
return (value as CanPathProxy)[pathSymbol]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function createCanProxy(path: string[] = []): CanPathProxy {
|
||||
const target = () => path
|
||||
return new Proxy(target, {
|
||||
get(_target, prop) {
|
||||
if (prop === pathSymbol) {
|
||||
return path
|
||||
}
|
||||
if (prop === 'toString') {
|
||||
return () => stringifyPath(path)
|
||||
}
|
||||
if (prop === 'valueOf') {
|
||||
return () => path
|
||||
}
|
||||
if (prop === Symbol.toPrimitive) {
|
||||
return () => stringifyPath(path)
|
||||
}
|
||||
return createCanProxy([...path, String(prop)])
|
||||
},
|
||||
apply() {
|
||||
return path
|
||||
},
|
||||
}) as unknown as CanPathProxy
|
||||
}
|
||||
@@ -7,9 +7,23 @@ describe('ssr', async () => {
|
||||
rootDir: fileURLToPath(new URL('./fixtures/basic', import.meta.url)),
|
||||
})
|
||||
|
||||
it('renders the index page', async () => {
|
||||
// Get response to a server-rendered page with `$fetch`.
|
||||
it('renders the allowed branches when permissions pass', async () => {
|
||||
const html = await $fetch('/')
|
||||
expect(html).toContain('<div>basic</div>')
|
||||
expect(html).toContain('<h1>basic</h1>')
|
||||
expect(html).toContain('id="can-view"')
|
||||
expect(html).toContain('id="can-edit"')
|
||||
expect(html).toContain('Creation contrat')
|
||||
})
|
||||
|
||||
it('falls back to the `v-cannot` branch when the permission is missing', async () => {
|
||||
const html = await $fetch('/')
|
||||
expect(html).not.toContain('id="can-delete"')
|
||||
expect(html).toContain('id="cannot-delete"')
|
||||
expect(html).toContain('Suppression interdite')
|
||||
})
|
||||
|
||||
it('exposes the can proxy globally for template usage', async () => {
|
||||
const html = await $fetch('/')
|
||||
expect(html).toMatch(/id="path-display"[\s\S]*employee\.view/)
|
||||
})
|
||||
})
|
||||
|
||||
80
test/fixtures/basic/app.vue
vendored
80
test/fixtures/basic/app.vue
vendored
@@ -1,6 +1,82 @@
|
||||
<template>
|
||||
<div>basic</div>
|
||||
<main>
|
||||
<h1>basic</h1>
|
||||
|
||||
<section>
|
||||
<button
|
||||
id="can-view"
|
||||
v-can="canProxy.employee.view"
|
||||
>
|
||||
Voir
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="isReady"
|
||||
id="can-edit"
|
||||
v-can="canProxy.employee.edit"
|
||||
>
|
||||
Editer
|
||||
</button>
|
||||
<p
|
||||
id="cannot-edit"
|
||||
v-cannot
|
||||
>
|
||||
Refus
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<template v-if="showContracts">
|
||||
<p v-can="canProxy.contract.create">
|
||||
Creation contrat
|
||||
</p>
|
||||
<p v-cannot>
|
||||
Pas de creation
|
||||
</p>
|
||||
</template>
|
||||
<p v-else>
|
||||
Section contrats masquee, aucune directive appliquee.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Suppression</h2>
|
||||
<button
|
||||
id="can-delete"
|
||||
v-can="canProxy.employee.delete"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
<p
|
||||
id="cannot-delete"
|
||||
v-cannot
|
||||
>
|
||||
Suppression interdite
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<p id="path-display">
|
||||
{{ String(canProxy.employee.view) }}
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
const isReady = true
|
||||
const showContracts = true
|
||||
interface FixturePermissions {
|
||||
employee: {
|
||||
view: boolean
|
||||
edit: boolean
|
||||
delete: boolean
|
||||
}
|
||||
contract: {
|
||||
create: boolean
|
||||
}
|
||||
}
|
||||
|
||||
const nuxtApp = useNuxtApp()
|
||||
const canProxy = nuxtApp.$can as unknown as FixturePermissions
|
||||
</script>
|
||||
|
||||
11
test/fixtures/basic/nuxt.config.ts
vendored
11
test/fixtures/basic/nuxt.config.ts
vendored
@@ -1,7 +1,14 @@
|
||||
import MyModule from '../../../src/module'
|
||||
import NuxtCan from '../../../src/module'
|
||||
|
||||
export default defineNuxtConfig({
|
||||
modules: [
|
||||
MyModule,
|
||||
NuxtCan,
|
||||
],
|
||||
nuxtCan: {
|
||||
permissions: {
|
||||
employee: ['view', 'edit', 'delete'],
|
||||
contract: ['create'],
|
||||
},
|
||||
canFunctionImport: '~/permissions/__can__',
|
||||
},
|
||||
})
|
||||
|
||||
10
test/fixtures/basic/permissions/__can__.ts
vendored
Normal file
10
test/fixtures/basic/permissions/__can__.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
const granted = new Set([
|
||||
'employee.view',
|
||||
'employee.edit',
|
||||
'contract.create',
|
||||
])
|
||||
|
||||
export function __can__(path: string[]) {
|
||||
const key = Array.isArray(path) ? path.join('.') : String(path)
|
||||
return granted.has(key)
|
||||
}
|
||||
62
test/transform-can.test.ts
Normal file
62
test/transform-can.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { transformCan } from '../src/runtime/transformer/transform-can'
|
||||
|
||||
const TEST_FILE = `${process.cwd()}/components/Test.vue`
|
||||
|
||||
const buildSFC = (template: string) => `<template>\n${template}\n</template>`
|
||||
|
||||
const runTransform = (template: string) => {
|
||||
const result = transformCan({ code: buildSFC(template), id: TEST_FILE })
|
||||
return result?.code ?? ''
|
||||
}
|
||||
|
||||
describe('transformCan', () => {
|
||||
it('injects __can__ guards and a matching v-cannot block', () => {
|
||||
const code = runTransform(`
|
||||
<button v-can="can.employee.view">Voir</button>
|
||||
<p v-cannot>Refus</p>
|
||||
`)
|
||||
|
||||
expect(code).toContain(`v-if="__can__(['employee', 'view'])"`)
|
||||
expect(code).toContain(`v-if="!(__can__(['employee', 'view']))"`)
|
||||
})
|
||||
|
||||
it('merges existing v-if expressions with the generated guard', () => {
|
||||
const code = runTransform(`
|
||||
<div v-if="isReady" v-can="can.contract.create" />
|
||||
`)
|
||||
|
||||
expect(code).toContain(`v-if="(isReady) && __can__(['contract', 'create'])"`)
|
||||
})
|
||||
|
||||
it('throws when v-cannot is used without a preceding v-can', () => {
|
||||
const exec = () => transformCan({
|
||||
code: buildSFC('<p v-cannot>Denied</p>'),
|
||||
id: TEST_FILE,
|
||||
})
|
||||
|
||||
expect(exec).toThrow(/must immediately follow its `v-can`/)
|
||||
})
|
||||
|
||||
it('throws when the expression does not start with can.*', () => {
|
||||
const exec = () => transformCan({
|
||||
code: buildSFC('<button v-can="permissions.employee.view" />'),
|
||||
id: TEST_FILE,
|
||||
})
|
||||
|
||||
expect(exec).toThrow(/expressions must start with `can\.`/)
|
||||
})
|
||||
|
||||
it('throws when v-can is added to a v-else branch', () => {
|
||||
const exec = () => transformCan({
|
||||
code: buildSFC(`
|
||||
<div v-if="ready"></div>
|
||||
<div v-else v-can="can.employee.view"></div>
|
||||
`),
|
||||
id: TEST_FILE,
|
||||
})
|
||||
|
||||
expect(exec).toThrow(/cannot be used on `v-else`/)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user