diff --git a/packages/casl-ability/spec/error.spec.ts b/packages/casl-ability/spec/error.spec.ts index 81856fe52..e34d5d9f2 100644 --- a/packages/casl-ability/spec/error.spec.ts +++ b/packages/casl-ability/spec/error.spec.ts @@ -7,11 +7,51 @@ describe('`ForbiddenError` class', () => { expect(() => error.throwUnlessCan('archive', 'Post')).toThrow(ForbiddenError as unknown as Error) }) + it('does not raise forbidden exception on disallowed action when inverted', () => { + const { error } = setup() + expect(() => error.throwUnlessCannot('archive', 'Post')).not.toThrow(ForbiddenError as unknown as Error) + }) + it('does not raise forbidden exception on allowed action', () => { const { error } = setup() expect(() => error.throwUnlessCan('read', 'Post')).not.toThrow(ForbiddenError as unknown as Error) }) + it('does not produce error on allowed action', () => { + const { error } = setup() + expect(error.unlessCan('read', 'Post')).toBeUndefined() + }) + + it('does not produce error on forbidden action when inverted', () => { + const { error } = setup() + expect(error.unlessCannot('archive', 'Post')).toBeUndefined() + }) + + it('produces an error on allowed action when inverted', () => { + const { error } = setup() + expect(error.unlessCannot('read', 'Post')).not.toBeUndefined() + }) + + it("error is inverted when producing the error via 'unlessCannot'", () => { + const { error } = setup() + expect(error.unlessCannot('read', 'Post')?.inverted).toBe(true) + }) + + it("error is not inverted when producing the error via 'unlessCan'", () => { + const { error } = setup() + expect(error.unlessCan('archive', 'Post')?.inverted).toBe(false) + }) + + it('produces an error on forbidden action', () => { + const { error } = setup() + expect(error.unlessCan('archive', 'Post')).not.toBeUndefined() + }) + + it('raises forbidden exception on allowed action when inverted', () => { + const { error } = setup() + expect(() => error.throwUnlessCannot('read', 'Post')).toThrow(ForbiddenError as unknown as Error) + }) + it('raises error with context information', () => { let thrownError: ForbiddenError | undefined const { error } = setup() diff --git a/packages/casl-ability/src/ForbiddenError.ts b/packages/casl-ability/src/ForbiddenError.ts index ff455503d..f99f2c9ec 100644 --- a/packages/casl-ability/src/ForbiddenError.ts +++ b/packages/casl-ability/src/ForbiddenError.ts @@ -1,11 +1,12 @@ -import { AnyAbility } from './PureAbility'; -import { Normalize, Subject } from './types'; -import { Generics } from './RuleIndex'; -import { getSubjectTypeName } from './utils'; +import { AnyAbility } from "./PureAbility"; +import { Generics } from "./RuleIndex"; +import { Normalize, Subject } from "./types"; +import { getSubjectTypeName } from "./utils"; -export type GetErrorMessage = (error: ForbiddenError) => string; +export type GetErrorMessage = (error: ForbiddenError, inverted?: boolean) => string; /** @deprecated will be removed in the next major release */ -export const getDefaultErrorMessage: GetErrorMessage = error => `Cannot execute "${error.action}" on "${error.subjectType}"`; +export const getDefaultErrorMessage: GetErrorMessage = (error) => + `${error.inverted ? 'Can' : "Cannot"} execute "${error.action}" on "${error.subjectType}"`; const NativeError = function NError(this: Error, message: string) { this.message = message; @@ -13,17 +14,20 @@ const NativeError = function NError(this: Error, message: string) { NativeError.prototype = Object.create(Error.prototype); + export class ForbiddenError extends NativeError { public readonly ability!: T; - public action!: Normalize['abilities']>[0]; - public subject!: Generics['abilities'][1]; + public action!: Normalize["abilities"]>[0]; + public subject!: Generics["abilities"][1]; public field?: string; public subjectType!: string; + public inverted!: boolean; static _defaultErrorMessage = getDefaultErrorMessage; static setDefaultMessage(messageOrFn: string | GetErrorMessage) { - this._defaultErrorMessage = typeof messageOrFn === 'string' ? () => messageOrFn : messageOrFn; + this._defaultErrorMessage = + typeof messageOrFn === "string" ? () => messageOrFn : messageOrFn; } static from(ability: U): ForbiddenError { @@ -31,11 +35,11 @@ export class ForbiddenError extends NativeError { } private constructor(ability: T) { - super(''); + super(""); this.ability = ability; - if (typeof Error.captureStackTrace === 'function') { - this.name = 'ForbiddenError'; + if (typeof Error.captureStackTrace === "function") { + this.name = "ForbiddenError"; Error.captureStackTrace(this, this.constructor); } } @@ -45,28 +49,83 @@ export class ForbiddenError extends NativeError { return this; } - throwUnlessCan(...args: Parameters): void; - throwUnlessCan(action: string, subject?: Subject, field?: string): void { - const error = (this as any).unlessCan(action, subject, field); - if (error) throw error; - } - - unlessCan(...args: Parameters): this | undefined; - unlessCan(action: string, subject?: Subject, field?: string): this | undefined { + private handleRule( + action: string, + inverted: boolean, + subject?: Subject, + field?: string, + ): this | undefined { const rule = this.ability.relevantRuleFor(action, subject, field); - if (rule && !rule.inverted) { + if (inverted && (!rule || rule.inverted)) { + return; + } else if (!inverted && rule && !rule.inverted) { return; } this.action = action; this.subject = subject; - this.subjectType = getSubjectTypeName(this.ability.detectSubjectType(subject)); + this.subjectType = getSubjectTypeName( + this.ability.detectSubjectType(subject) + ); this.field = field; + this.inverted = inverted; - const reason = rule ? rule.reason : ''; + const reason = rule ? rule.reason : ""; // eslint-disable-next-line no-underscore-dangle - this.message = this.message || reason || (this.constructor as any)._defaultErrorMessage(this); + this.message = + this.message || + reason || + (this.constructor as any)._defaultErrorMessage(this, !!inverted); return this; // eslint-disable-line consistent-return } + + private unless(inverted: boolean, ...args: Parameters ): this | undefined + private unless( + inverted: boolean, + action: string, + subject?: Subject, + field?: string + ): this | undefined { + return this.handleRule(action, inverted, subject, field,); + } + + private throwUnless(inverted: boolean, ...args: Parameters | Parameters): void + private throwUnless( + inverted: boolean, + action: string, + subject?: Subject, + field?: string + ): void { + const error = (this as any).unless(inverted, action, subject, field); + if (error) throw error; + } + + throwUnlessCan(...args: Parameters): void; + throwUnlessCan(action: string, subject?: Subject, field?: string): void { + (this as any).throwUnless(false, action, subject, field) + } + + unlessCan(...args: Parameters): this | undefined; + unlessCan( + action: string, + subject?: Subject, + field?: string + ): this | undefined { + return (this as any).unless(false, action, subject, field); + } + + throwUnlessCannot(...args: Parameters): void; + throwUnlessCannot(action: string, subject?: Subject, field?: string): void { + return (this as any).throwUnless(true, action, subject, field); + } + + unlessCannot(...args: Parameters): this | undefined; + unlessCannot( + action: string, + subject?: Subject, + field?: string + ): this | undefined { + return (this as any).unless(true, action, subject, field); + } } diff --git a/packages/casl-react/spec/Can.spec.js b/packages/casl-react/spec/Can.spec.js index 8f8bf8a01..c8a30d4cf 100644 --- a/packages/casl-react/spec/Can.spec.js +++ b/packages/casl-react/spec/Can.spec.js @@ -1,23 +1,41 @@ +import { defineAbility, ForbiddenError } from '@casl/ability' import { createElement as e } from 'react' -import { defineAbility } from '@casl/ability' import renderer from 'react-test-renderer' import { Can } from '../src' describe('`Can` component', () => { let ability let children + let cantChopWoodReason = 'You are not a lumberjack' beforeEach(() => { children = spy(() => null) - ability = defineAbility(can => can('read', 'Post')) + ability = defineAbility((can, cannot) => { + can('read', 'Post') + cannot('chop', 'Wood').because(cantChopWoodReason) + }) + }) it('passes ability check value and instance as arguments to "children" function', () => { renderer.create(e(Can, { I: 'read', a: 'Post', ability }, children)) - expect(children).to.have.been.called.with.exactly(ability.can('read', 'Post'), ability) + expect(children).to.have.been.called.with.exactly(ability.can('read', 'Post'), ability, undefined) + }) + + it('passes forbidden reason message to "children" function when not allowed', () => { + + renderer.create(e(Can, { I: 'chop', a: 'Wood', ability, passThrough: true }, children)) + expect(children).to.have.been.called.with.exactly(ability.can('chop', 'Wood'), ability, ForbiddenError.from(ability).unlessCan('chop', 'Wood')?.message) }) + it('Does not pass forbidden reason message to "children" function when allowed', () => { + renderer.create(e(Can, { not: true, I: 'chop', a: 'Wood', ability, passThrough: true }, children)) + + expect(children).to.have.been.called.with.exactly(ability.cannot('chop', 'Wood'), ability, undefined) + }) + + it('has public "allowed" property which returns boolean indicating whether children will be rendered', () => { const canComponent = renderer.create(e(Can, { I: 'read', a: 'Post', ability }, children)) renderer.create(e(Can, { not: true, I: 'run', a: 'Marathon', ability }, children)) @@ -26,6 +44,14 @@ describe('`Can` component', () => { expect(canComponent.getInstance().allowed).to.equal(ability.cannot('run', 'Marathon')) }) + it('has public "forbiddenReason" property which returns the message for ForbiddenError ', () => { + const canComponent = renderer.create(e(Can, { I: 'read', a: 'Post', ability }, children)) + renderer.create(e(Can, { not: true, I: 'run', a: 'Marathon', ability }, children)) + + expect(canComponent.getInstance().forbiddenReason).to.equal(ForbiddenError.from(ability).unlessCan('read', 'Post')?.message) + expect(canComponent.getInstance().forbiddenReason).to.equal(ForbiddenError.from(ability).unlessCannot('run', 'Marathon')?.message) + }) + it('unsubscribes from ability updates when unmounted', () => { const component = renderer.create(e(Can, { I: 'read', a: 'Post', ability }, children)) diff --git a/packages/casl-react/src/Can.ts b/packages/casl-react/src/Can.ts index d2e18d1e2..7635cf558 100644 --- a/packages/casl-react/src/Can.ts +++ b/packages/casl-react/src/Can.ts @@ -1,13 +1,14 @@ -import { PureComponent, ReactNode } from 'react'; import { - Unsubscribe, + Abilities, AbilityTuple, - SubjectType, AnyAbility, + ForbiddenError, Generics, - Abilities, IfString, -} from '@casl/ability'; + SubjectType, + Unsubscribe, +} from "@casl/ability"; +import { PureComponent, ReactNode } from "react"; const noop = () => {}; @@ -15,31 +16,50 @@ type AbilityCanProps< T extends Abilities, Else = IfString > = T extends AbilityTuple - ? { do: T[0], on: T[1], field?: string } | - { I: T[0], a: Extract, field?: string } | - { I: T[0], an: Extract, field?: string } | - { I: T[0], this: Exclude, field?: string } + ? + | { do: T[0]; on: T[1]; field?: string } + | { I: T[0]; a: Extract; field?: string } + | { I: T[0]; an: Extract; field?: string } + | { I: T[0]; this: Exclude; field?: string } : Else; interface ExtraProps { - not?: boolean - passThrough?: boolean + not?: boolean; + passThrough?: boolean; } +type RenderChildrenParameters = + | [allowed: true, ability: T, forbiddenReason: undefined] + | [allowed: false, ability: T, forbiddenReason: string]; + interface CanExtraProps extends ExtraProps { - ability: T - children: ReactNode | ((isAllowed: boolean, ability: T) => ReactNode) + ability: T; + children: ReactNode | ((...args: RenderChildrenParameters) => ReactNode); } interface BoundCanExtraProps extends ExtraProps { - ability?: T - children: ReactNode | ((isAllowed: boolean, ability: T) => ReactNode) + ability?: T; + children: ReactNode | ((...args: RenderChildrenParameters) => ReactNode); } -export type CanProps = - AbilityCanProps['abilities']> & CanExtraProps; -export type BoundCanProps = - AbilityCanProps['abilities']> & BoundCanExtraProps; +type CanRenderWithReason = + | { + allowed: true; + forbiddenReason: undefined; + } + | { + allowed: false; + forbiddenReason: string; + }; + +export type CanProps = AbilityCanProps< + Generics["abilities"] +> & + CanExtraProps; +export type BoundCanProps = AbilityCanProps< + Generics["abilities"] +> & + BoundCanExtraProps; export class Can< T extends AnyAbility, @@ -47,6 +67,7 @@ export class Can< > extends PureComponent : CanProps> { private _isAllowed: boolean = false; private _ability: T | null = null; + private _forbiddenReason: string | undefined = undefined; private _unsubscribeFromAbility: Unsubscribe = noop; componentWillUnmount() { @@ -60,10 +81,13 @@ export class Can< this._unsubscribeFromAbility(); this._ability = null; + this._forbiddenReason = undefined; if (ability) { this._ability = ability; - this._unsubscribeFromAbility = ability.on('updated', () => this.forceUpdate()); + this._unsubscribeFromAbility = ability.on("updated", () => + this.forceUpdate() + ); } } @@ -71,25 +95,55 @@ export class Can< return this._isAllowed; } - private _canRender(): boolean { + get forbiddenReason() { + return this._forbiddenReason; + } + + private _getCanRenderWithReason(): CanRenderWithReason { const props: any = this.props; const subject = props.of || props.a || props.an || props.this || props.on; - const can = props.not ? 'cannot' : 'can'; - - return props.ability[can](props.I || props.do, subject, props.field); + const check = props.not ? "cannot" : "can"; + + const args = [props.I || props.do, subject, props.field]; + const error = + check === "can" + ? ForbiddenError.from(props.ability).unlessCan(...args) + : ForbiddenError.from(props.ability).unlessCannot(...args); + + if (error) { + return { + allowed: false, + forbiddenReason: error.message, + }; + } else { + return { + allowed: true, + forbiddenReason: undefined, + }; + } } render() { this._connectToAbility(this.props.ability); - this._isAllowed = this._canRender(); - return this.props.passThrough || this._isAllowed ? this._renderChildren() : null; + const { allowed, forbiddenReason } = this._getCanRenderWithReason(); + this._isAllowed = allowed; + this._forbiddenReason = forbiddenReason; + return this.props.passThrough || this._isAllowed + ? this._renderChildren() + : null; } private _renderChildren() { const { children, ability } = this.props; - const elements = typeof children === 'function' - ? children(this._isAllowed, ability as any) - : children; + + const args = [ + this._isAllowed, + ability as any, + this._forbiddenReason, + ] as RenderChildrenParameters; + + const elements = + typeof children === "function" ? children(...args) : children; return elements as ReactNode; }