Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## v1.1.3 - 2025-01-27

### Fixed

- **Logical operators without spaces**: Fixed issue where logical operators (`||`, `&&`, `|`, `&`) were not working when used without spaces around them (e.g., `properties.view-all||properties.view-own`)
- **Permission names with hyphens**: Updated regex pattern to properly support permission names containing hyphens (e.g., `properties.view-all`, `user-profile.edit`)
- **Operator normalization**: Improved operator normalization logic to prevent double replacement issues

### Added

- **Comprehensive test coverage**: Added extensive test suite for logical operators without spaces, covering various edge cases and scenarios
- **Hyphenated permission support**: Full support for permission names with hyphens in logical expressions

### Technical Details

- Updated regex pattern from `[a-zA-Z0-9_.*?]*` to `[a-zA-Z0-9_.*?-]*` to include hyphens
- Fixed operator normalization using negative lookbehind to prevent double replacement
- Added 12 new test cases covering no-spaces scenarios, hyphenated permissions, and edge cases

## v1.1.2 - 2025-09-19

### What's Changed
Expand Down
194 changes: 194 additions & 0 deletions __tests__/logical-operators-no-spaces.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { renderHook } from '@testing-library/react';
import { usePermissions } from '../hooks/use-permissions';

// Mock @inertiajs/react
jest.mock('@inertiajs/react', () => ({
usePage: jest.fn(),
}));

import { usePage } from '@inertiajs/react';
const mockUsePage = usePage as jest.MockedFunction<typeof usePage>;

describe('Logical Operators Without Spaces', () => {
beforeEach(() => {
jest.clearAllMocks();
});

const mockPageProps = (userPermissions: string[] = []) => {
mockUsePage.mockReturnValue({
props: {
auth: {
user: {
permissions: userPermissions,
},
},
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
};

describe('OR operators without spaces', () => {
it('should handle || without spaces around operators', () => {
mockPageProps(['properties.view-all', 'properties.view-own']);

const { result } = renderHook(() => usePermissions());

// Test various OR expressions without spaces
expect(result.current.hasPermission('properties.view-all||properties.view-own')).toBe(true);
expect(result.current.hasPermission('users.create||posts.view')).toBe(false); // Neither permission exists
expect(result.current.hasPermission('properties.view-all||users.create')).toBe(true); // One permission exists
});

it('should handle single | without spaces around operators', () => {
mockPageProps(['properties.view-all', 'properties.view-own']);

const { result } = renderHook(() => usePermissions());

// Test single | expressions without spaces
expect(result.current.hasPermission('properties.view-all|properties.view-own')).toBe(true);
expect(result.current.hasPermission('properties.view-all|users.create')).toBe(true); // One permission exists
expect(result.current.hasPermission('users.create|posts.view')).toBe(false); // Neither permission exists
});
});

describe('AND operators without spaces', () => {
it('should handle && without spaces around operators', () => {
mockPageProps(['properties.view-all', 'properties.view-own']);

const { result } = renderHook(() => usePermissions());

// Test various AND expressions without spaces
expect(result.current.hasPermission('properties.view-all&&properties.view-own')).toBe(true);
expect(result.current.hasPermission('properties.view-all&&users.create')).toBe(false); // One permission missing
expect(result.current.hasPermission('users.create&&posts.view')).toBe(false); // Both permissions missing
});

it('should handle single & without spaces around operators', () => {
mockPageProps(['properties.view-all', 'properties.view-own']);

const { result } = renderHook(() => usePermissions());

// Test single & expressions without spaces
expect(result.current.hasPermission('properties.view-all&properties.view-own')).toBe(true);
expect(result.current.hasPermission('properties.view-all&users.create')).toBe(false); // One permission missing
expect(result.current.hasPermission('users.create&posts.view')).toBe(false); // Both permissions missing
});
});

describe('Complex expressions without spaces', () => {
it('should handle parentheses with no spaces around operators', () => {
mockPageProps(['properties.view-all', 'admin.access']);

const { result } = renderHook(() => usePermissions());

// Test complex expressions without spaces
expect(result.current.hasPermission('(properties.view-all||users.create)&&admin.access')).toBe(true);
expect(result.current.hasPermission('(properties.view-all||users.create)&&admin.delete')).toBe(false);
expect(result.current.hasPermission('(users.create||posts.view)&&admin.access')).toBe(false);
});

it('should handle mixed operators without spaces', () => {
mockPageProps(['properties.view-all', 'admin.access', 'reports.read']);

const { result } = renderHook(() => usePermissions());

// Test mixed operators without spaces
expect(result.current.hasPermission('properties.view-all&&admin.access||reports.read')).toBe(true);
expect(result.current.hasPermission('properties.view-all&admin.access|reports.read')).toBe(true);
expect(result.current.hasPermission('users.create&&admin.access||reports.read')).toBe(true); // reports.read exists
});
});

describe('Permission names with hyphens', () => {
it('should handle permission names with hyphens in expressions without spaces', () => {
mockPageProps(['user-profile.edit', 'api-access.read', 'system-config.update']);

const { result } = renderHook(() => usePermissions());

// Test hyphenated permission names without spaces
expect(result.current.hasPermission('user-profile.edit||api-access.read')).toBe(true);
expect(result.current.hasPermission('user-profile.edit&&api-access.read')).toBe(true);
expect(result.current.hasPermission('user-profile.edit&&system-config.update')).toBe(true);
expect(result.current.hasPermission('user-profile.edit&&users.create')).toBe(false); // users.create doesn't exist
});

it('should handle complex hyphenated permission expressions', () => {
mockPageProps(['user-profile.edit', 'api-access.read', 'admin-panel.access']);

const { result } = renderHook(() => usePermissions());

// Test complex expressions with hyphenated permissions
expect(result.current.hasPermission('(user-profile.edit||api-access.read)&&admin-panel.access')).toBe(true);
expect(result.current.hasPermission('user-profile.edit&&(api-access.read||system-config.update)')).toBe(true);
expect(result.current.hasPermission('(user-profile.edit||api-access.read)&&system-config.update')).toBe(false);
});
});

describe('Boolean literals without spaces', () => {
it('should handle boolean literals in expressions without spaces', () => {
mockPageProps(['properties.view-all']);

const { result } = renderHook(() => usePermissions());

// Test boolean literals without spaces
expect(result.current.hasPermission('true||properties.view-all')).toBe(true);
expect(result.current.hasPermission('false||properties.view-all')).toBe(true);
expect(result.current.hasPermission('true&&properties.view-all')).toBe(true);
expect(result.current.hasPermission('false&&properties.view-all')).toBe(false);
expect(result.current.hasPermission('properties.view-all||true')).toBe(true);
expect(result.current.hasPermission('properties.view-all&&false')).toBe(false);
});
});

describe('Edge cases without spaces', () => {
it('should handle expressions with multiple consecutive operators', () => {
mockPageProps(['properties.view-all', 'properties.view-own']);

const { result } = renderHook(() => usePermissions());

// These should be handled gracefully (though they're not valid expressions)
expect(result.current.hasPermission('properties.view-all||||properties.view-own')).toBe(false); // Invalid
expect(result.current.hasPermission('properties.view-all&&&&properties.view-own')).toBe(false); // Invalid
});

it('should handle expressions starting or ending with operators', () => {
mockPageProps(['properties.view-all']);

const { result } = renderHook(() => usePermissions());

// These should be handled gracefully
expect(result.current.hasPermission('||properties.view-all')).toBe(false); // Invalid
expect(result.current.hasPermission('properties.view-all||')).toBe(false); // Invalid
expect(result.current.hasPermission('&&properties.view-all')).toBe(false); // Invalid
expect(result.current.hasPermission('properties.view-all&&')).toBe(false); // Invalid
});
});

describe('Comparison with spaced expressions', () => {
it('should produce the same results with and without spaces', () => {
mockPageProps(['properties.view-all', 'properties.view-own', 'admin.access']);

const { result } = renderHook(() => usePermissions());

// Test that expressions with and without spaces produce the same results
const expressions = [
'properties.view-all||properties.view-own',
'properties.view-all || properties.view-own',
'properties.view-all&&admin.access',
'properties.view-all && admin.access',
'properties.view-all|properties.view-own',
'properties.view-all | properties.view-own',
'properties.view-all&admin.access',
'properties.view-all & admin.access',
'(properties.view-all||properties.view-own)&&admin.access',
'(properties.view-all || properties.view-own) && admin.access',
];

expressions.forEach(expr => {
const hasPermission = result.current.hasPermission(expr);
expect(hasPermission).toBeDefined();
expect(typeof hasPermission).toBe('boolean');
});
});
});
});
20 changes: 10 additions & 10 deletions hooks/use-permissions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,27 +96,27 @@ export function usePermissions(permissions?: string[]) {
if (expression.trim() === 'false') return false;

// Normalize logical operators to JavaScript equivalents
// Process single operators first, then ensure double operators are correct
// First, add spaces around operators to ensure proper parsing
let jsExpression = expression
.replace(/\|(?!\|)/g, '||') // Single | becomes ||
.replace(/&(?!&)/g, '&&'); // Single & becomes &&
.replace(/\|\|/g, ' || ') // Add spaces around ||
.replace(/&&/g, ' && ') // Add spaces around &&
.replace(/(?<!\|)\|(?!\|)/g, ' || ') // Single | becomes || with spaces (not preceded by |)
.replace(/(?<!&)&(?!&)/g, ' && '); // Single & becomes && with spaces (not preceded by &)

// Now ensure we don't have triple operators from the above replacement
jsExpression = jsExpression
.replace(/\|\|\|/g, '||') // Triple | becomes ||
.replace(/&&&/g, '&&'); // Triple & becomes &&
// Clean up multiple spaces
jsExpression = jsExpression.replace(/\s+/g, ' ').trim();

// Find all permission patterns in the expression
// This regex matches permission patterns in the expression.
// Breakdown of alternation groups:
// \* - matches a standalone '*' wildcard
// \? - matches a standalone '?' wildcard
// [a-zA-Z_][a-zA-Z0-9_.*?]*(?:\.[a-zA-Z0-9_.*?]*)*
// - matches standard permission strings, e.g. 'users.create', 'posts.*'
// [a-zA-Z_][a-zA-Z0-9_.*?-]*(?:\.[a-zA-Z0-9_.*?-]*)*
// - matches standard permission strings, e.g. 'users.create', 'posts.*', 'properties.view-all'
// true|false - matches boolean literals 'true' or 'false'
// The (?![|&]) negative lookahead ensures we don't match permission patterns that are immediately followed by a logical operator.
const permissionRegex =
/(?:\*|\?|[a-zA-Z_][a-zA-Z0-9_.*?]*(?:\.[a-zA-Z0-9_.*?]*)*|true|false)(?![|&])/g;
/(?:\*|\?|[a-zA-Z_][a-zA-Z0-9_.*?-]*(?:\.[a-zA-Z0-9_.*?-]*)*|true|false)(?![|&])/g;
const permissions = jsExpression.match(permissionRegex) || [];

// Replace each permission with its boolean evaluation
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@devwizard/laravel-react-permissions",
"version": "1.1.2",
"version": "1.1.3",
"type": "module",
"description": "🔐 Modern, Laravel-inspired permissions system for React/Inertia.js with advanced pattern matching, boolean expressions, and zero dependencies. Features wildcard patterns, custom permissions, and full TypeScript support.",
"main": "dist/index.js",
Expand Down
Loading