casl_flutter

Flutter bindings for casl — an ability in the widget tree, so a screen draws what the user may actually do.

AbilityProvider(
  ability: ability,
  child: MaterialApp(...),
);
// hide it
Can('delete', article, child: DeleteButton(article: article));

// or keep it and say why not, which is usually kinder
CanBuilder(
  'delete',
  article,
  builder: (context, can) => Tooltip(
    message: can.reason ?? '',
    child: FilledButton(
      onPressed: can.allowed ? () => delete(article) : null,
      child: const Text('Delete'),
    ),
  ),
);

// or just ask
if (context.can('create', 'Article')) const NewArticleButton(),

package:casl is re-exported, so one import covers both.


Contents


Install

dependencies:
  casl_flutter: ^0.1.0
import 'package:casl_flutter/casl_flutter.dart';

Providing an ability

Put it above everything that might ask — usually the whole app, or the signed-in part of it:

class App extends StatelessWidget {
  const App({required this.ability, super.key});

  final Ability ability;

  @override
  Widget build(BuildContext context) => AbilityProvider(
    ability: ability,
    child: MaterialApp.router(routerConfig: router),
  );
}

Build the ability from whatever your server sent:

final ability = createMongoAbility(
  unpackRules(json['rules'] as List<Object?>),
);

Asking with no provider above throws a FlutterError naming what is missing. Answering "no" would be indistinguishable from a real refusal, and one of those is a bug while the other is a support ticket. Use AbilityProvider.maybeOf for a widget genuinely used on both sides of a sign-in.


It follows a change of rules

AbilityProvider listens to the ability rather than only holding it. An ability is mutable: update(rules) replaces the grant in place, which is what a token refresh does when a role has changed.

// somewhere in your refresh handler
ability.update(unpackRules(response['rules'] as List<Object?>));
// buttons appear and disappear; nothing has to navigate

Without that, a demoted user keeps their controls until something unrelated happens to rebuild the screen — and the screen they are staring at is exactly the one that will not.

Replacing the ability object works too, which is what signing in as somebody else does:

AbilityProvider(ability: abilityForNewUser, child: ...)

Hiding, disabling, and explaining

Three shapes, and the middle one is right more often than people expect.

Hide it

Can('create', 'Article', child: const NewArticleButton());

Right when the user has no reason to expect the control — an admin-only section of a settings page, a feature their plan does not include and never has.

Say something else in its place

Can(
  'invite',
  'User',
  otherwise: const Text('Ask an administrator to invite people.'),
  child: const InviteButton(),
);

Keep it, disable it, and explain

CanBuilder(
  'delete',
  article,
  builder: (context, can) => Tooltip(
    message: can.reason ?? '',
    child: IconButton(
      onPressed: can.allowed ? () => delete(article) : null,
      icon: const Icon(Icons.delete),
    ),
  ),
);

A control that vanishes is a control the user cannot ask about. Where they have reason to expect one — an owner looking at somebody else's document, an account that has run out of seats — a disabled control that explains itself prevents the support ticket a missing one causes.

can.reason is whatever the forbidding rule said, written by whoever wrote the rule. That may well be your server, in which case it arrives already in the user's language.

Show something because they cannot

Can(
  'invite',
  'User',
  not: true,
  child: const UpgradePrompt(),
);

For copy that only makes sense to someone who cannot do the thing. Writing it the other way round would put the real content in otherwise and read backwards.


Asking from anywhere

context.can('read', article)          // bool
context.cannot('read', article)       // bool
context.forbidden('delete', article)  // ForbiddenError?, carrying the reason
context.ability                       // the ability itself, subscribing
context.readAbility()                 // …without subscribing

Everything except readAbility subscribes, so the widget rebuilds when the rules change. That is what you want almost always. readAbility is for a callback or an initState, where a rebuild is impossible and pointless — by the time a tap is handled, the tap is over:

onPressed: () {
  final ability = context.readAbility();
  if (ability.can('delete', article)) delete(article);
},

Field-level UI

CanBuilder hands you the ability as well as the answer, for questions these widgets do not express:

CanBuilder(
  'update',
  article,
  builder: (context, can) => Column(
    children: [
      for (final field in permittedFieldsOf(
        can.ability,
        'update',
        article,
        allFields: Article.editableFields,
      ))
        FieldEditor(name: field),
    ],
  ),
);

Asking per field would give the right answer per field and the wrong list — a later rule can take a field back, so the answers do not compose. See casl.


Guarding a route

There is no route widget here on purpose: every router expresses redirects differently, and wrapping one would tie this package to it. A guard is a function of the ability, which you already have:

// go_router
GoRoute(
  path: '/admin',
  redirect: (context, state) =>
      context.readAbility().can('manage', 'all') ? null : '/forbidden',
  builder: (context, state) => const AdminPage(),
);

Use readAbility() in a redirect — a router callback is not a build, so there is nothing to subscribe. To re-evaluate redirects when rules change, feed your router's refreshListenable from the same signal you use to call ability.update(...).


Testing a permission-aware screen

Wrap the widget under test and pass the rules the case needs:

Future<void> pumpAs(WidgetTester tester, List<RawRule> rules) =>
    tester.pumpWidget(
      AbilityProvider(
        ability: createMongoAbility(rules),
        child: MaterialApp(home: ArticlePage(article: article)),
      ),
    );

testWidgets('an author sees the delete button', (tester) async {
  await pumpAs(tester, [
    RawRule.of(
      action: 'delete',
      subject: 'Article',
      conditions: {'authorId': 7},
    ),
  ]);

  expect(find.byIcon(Icons.delete), findsOneWidget);
});

To test that a screen follows a change of rules, keep the ability and update it mid-test:

final ability = createMongoAbility(const []);
await tester.pumpWidget(...);

ability.update([RawRule.of(action: 'read', subject: 'Article')]);
await tester.pump();

API reference

AbilityProvider puts an ability in the tree and republishes it when the rules change
AbilityProvider.of / .maybeOf read it, with or without throwing
AbilityScope the inherited widget, for an app that publishes its own
Can shows child when permitted, otherwise when not; not inverts
CanBuilder builds either way, given a CanResult
CanResult allowed, reason, refusal, ability
context.can / .cannot the question, subscribing
context.forbidden why not — the rule's own words
context.ability / .readAbility() the ability, with or without subscribing

Everything else — rules, conditions, fields, queries, the CASL.js wire format — is in casl, and re-exported from here.


Compared with @casl/react

@casl/react Here
<AbilityProvider value={ability}> AbilityProvider(ability: ...)
useAbility() context.ability
<Can I="read" a="Post"> Can('read', 'Post', child: ...)
<Can not …> Can(..., not: true)
<Can passThrough> with a function child CanBuilder
{ isAllowed, ability, reason } CanResult

React's prop aliases (I / do, a / an / this / on / of) exist to make JSX read as a sentence. Dart's positional arguments already do, so there is one spelling.

useAbility subscribes through useSyncExternalStore; AbilityProvider here does the same job with a StatefulWidget and an InheritedWidget, so the subscription is set up once at the provider rather than once per consumer.


Licence

MIT.

Libraries

casl_flutter
Flutter bindings for casl.