From 56b64b612be035b9db08bcd50c95c6f07b183a9f Mon Sep 17 00:00:00 2001 From: Urban Krepel Date: Tue, 3 Nov 2026 08:17:57 +0100 Subject: [PATCH] feat: upgrade ACL generation and runtime to CASL 7 --- README.md | 2 + bun.lock | 30 ++++-- native/src/render.rs | 4 +- package.json | 10 +- src/generators/const/acl.const.ts | 4 +- src/generators/generate/generateAcl.ts | 4 +- src/generators/generated-casl.test.ts | 122 +++++++++++++++++++++++++ src/lib/acl/Can.tsx | 27 ++++-- src/lib/acl/ability.context.tsx | 14 ++- src/lib/acl/ability.test.ts | 98 ++++++++++++++++++++ src/lib/acl/appAbility.types.ts | 4 +- 11 files changed, 282 insertions(+), 37 deletions(-) create mode 100644 src/generators/generated-casl.test.ts create mode 100644 src/lib/acl/ability.test.ts diff --git a/README.md b/README.md index 44439ce8..3092782b 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ Axios is required when using the Axios transport (the default) or the `/axios` entry point. Projects using the native transport can omit Axios: generate with `--restClient native` and import `NativeRestClient` from `@povio/openapi-codegen-cli/native`. The root exports remain available for compatibility and support tree-shaking in consumer bundles. For direct runtime imports without bundling, use `/native` and `/errors` to avoid loading the Axios client. The `/axios` entry point is also available for Axios-specific imports. CLI-only usage and the `/generator`, `/tiny`, `/vite`, and `/metro` entry points do not require Axios. +ACL features require `@casl/ability@^7.1.1` and `@casl/react@^7.1.0`. When migrating to CASL 7, regenerate the client so `AppAbility` uses `Ability` instead of `PureAbility`, and update any handwritten CASL types. The runtime continues to use `createMongoAbility` for condition matching. CASL 7 treats empty conditions (`{}`) as matching everything. The `Can` helper keeps its `use` tuple and `(isAllowed, ability)` render callback. + Use this tool to generate code (Zod schemas, TypeScript types, API definitions, and React queries) from an OpenAPI v3 specification. API definitions use a REST client wrapper with either Axios or the native transport. React queries are generated in alignment with our code standards, without the need for explicit types. The tool partially leverages code from [openapi-zod-client](https://github.com/astahmer/openapi-zod-client) repository. diff --git a/bun.lock b/bun.lock index 906e8737..1e93b27f 100644 --- a/bun.lock +++ b/bun.lock @@ -15,13 +15,14 @@ "yargs": "^18.0.0", }, "devDependencies": { - "@casl/ability": "6.8.0", - "@casl/react": "5.0.1", + "@casl/ability": "7.1.1", + "@casl/react": "7.1.0", "@tanstack/react-query": "~5.90.21", "@types/bun": "1.4.1", "@types/node": "^25.3.0", "@types/prompt-sync": "^4.2.3", "@types/react": "^19.2.14", + "@types/react-test-renderer": "^19.1.0", "@types/yargs": "^17.0.35", "@vitejs/plugin-react": "^5.1.4", "axios": "^1.13.1", @@ -30,6 +31,7 @@ "oxlint": "1.50.0", "oxlint-tsgolint": "^0.15.0", "react": "^19.2.4", + "react-test-renderer": "19.2.4", "tsdown": "^0.22.14", "tsx": "^4.21.0", "type-fest": "^5.4.4", @@ -40,8 +42,8 @@ "zod": "^4.5.0", }, "peerDependencies": { - "@casl/ability": "^6.7.3", - "@casl/react": "^5.0.0", + "@casl/ability": "^7.1.1", + "@casl/react": "^7.1.0", "@tanstack/react-query": "^5.90.21", "axios": "^1.13.1", "i18next": "^25.8.11", @@ -101,9 +103,9 @@ "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], - "@casl/ability": ["@casl/ability@6.8.0", "", { "dependencies": { "@ucast/mongo2js": "^1.3.0" } }, "sha512-Ipt4mzI4gSgnomFdaPjaLgY2MWuXqAEZLrU6qqWBB7khGiBBuuEp6ytYDnq09bRXqcjaeeHiaCvCGFbBA2SpvA=="], + "@casl/ability": ["@casl/ability@7.1.1", "", { "dependencies": { "@ucast/mongo2js": "^2.0.0" } }, "sha512-1WL90aW65z7RyaoFhOrfUua6e8gk5I2DS7WDjTTI5/qtGnFcnesX4LvfXkqO21LexTswLq71s4Lfs93eI45z8A=="], - "@casl/react": ["@casl/react@5.0.1", "", { "peerDependencies": { "@casl/ability": "^4.0.0 || ^5.1.0 || ^6.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-8E3GkvwlxEW+bkxWTdvjn6SALNJZa9khNk591xTpU27nPK/nzgfqsVfEvrFjYIQ2LODrxXsWLmw7iHPEI0/TaQ=="], + "@casl/react": ["@casl/react@7.1.0", "", { "peerDependencies": { "@casl/ability": "^4.0.0 || ^5.1.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0" } }, "sha512-z0TkQNReAGGOGhPhtfrLBB2uJTxS2b55rBZH4aEw7dYUQQSYMj688NwCT75SwfyiKFnmwn0+B+II/DsoJKNe4w=="], "@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" } }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], @@ -385,6 +387,8 @@ "@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="], + "@types/react-test-renderer": ["@types/react-test-renderer@19.3.0", "", { "dependencies": { "@types/react": "*" } }, "sha512-TX4p2D4Z2oigM3SXU7+QyLSUnSR0vR1S1Pfxm/qFL9nHQ3jclgi6ioGoWQmlIr1DeKRVmPJrmNlhgM0JlWlLUg=="], + "@types/yargs": ["@types/yargs@17.0.35", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg=="], "@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="], @@ -429,13 +433,13 @@ "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], - "@ucast/core": ["@ucast/core@1.10.2", "", {}, "sha512-ons5CwXZ/51wrUPfoduC+cO7AS1/wRb0ybpQJ9RrssossDxVy4t49QxWoWgfBDvVKsz9VXzBk9z0wqTdZ+Cq8g=="], + "@ucast/core": ["@ucast/core@2.0.0", "", {}, "sha512-4XVx6LzPXZGvnZO5jp39cm/G4UvuwvEdtmg+9+4+zl6uFkCcB7UJacvtMYeBE56GJVT99Zqy6Pii7dGJq3Kz9Q=="], - "@ucast/js": ["@ucast/js@3.1.0", "", { "dependencies": { "@ucast/core": "1.10.2" } }, "sha512-eJ7yQeYtMK85UZjxoxBEbTWx6UMxEXKbjVyp+NlzrT5oMKV5Gpo/9bjTl3r7msaXTVC8iD9NJacqJ8yp7joX+Q=="], + "@ucast/js": ["@ucast/js@4.0.1", "", { "dependencies": { "@ucast/core": "2.0.0" } }, "sha512-9O5xPBvwEWQk2WvO69Eh2WJB8QljVZ2vRVdFvfnKjlZwWXcYxp1lqLBhwXBU1AtuSgCvKhJPkXdjKJggUmAmQQ=="], - "@ucast/mongo": ["@ucast/mongo@2.4.3", "", { "dependencies": { "@ucast/core": "^1.4.1" } }, "sha512-XcI8LclrHWP83H+7H2anGCEeDq0n+12FU2mXCTz6/Tva9/9ddK/iacvvhCyW6cijAAOILmt0tWplRyRhVyZLsA=="], + "@ucast/mongo": ["@ucast/mongo@3.0.0", "", { "dependencies": { "@ucast/core": "2.0.0" } }, "sha512-kwuSH+kdB4GCR0LGhy/PEDm4PCflur89AlK82kNiYD0FvsA8A/p+0sx7m+/R8mMFAlmlkAd3VXp7sM/cLLYWYg=="], - "@ucast/mongo2js": ["@ucast/mongo2js@1.4.1", "", { "dependencies": { "@ucast/core": "1.10.2", "@ucast/js": "3.1.0", "@ucast/mongo": "2.4.3" } }, "sha512-9aeg5cmqwRQnKCXHN6I17wk83Rcm487bHelaG8T4vfpWneAI469wSI3Srnbu+PuZ5znWRbnwtVq9RgPL+bN6CA=="], + "@ucast/mongo2js": ["@ucast/mongo2js@2.0.0", "", { "dependencies": { "@ucast/core": "2.0.0", "@ucast/js": "4.0.1", "@ucast/mongo": "3.0.0" } }, "sha512-vNBZzRnsfLr/TSxEoxz6W6hHQ5tmWsfEeC0nCq5z8RezC1AqIRy3cfHm8AGvlGtcn+cTSFQcZremfqnz6wm+nQ=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="], @@ -775,8 +779,12 @@ "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="], + "react-is": ["react-is@19.3.1", "", {}, "sha512-RRieF6IyxQBkcK6pP8xOHprLTPW6QjRkbGdImtqNme+/hYVwCZkLQLdlZGOhUqXwkS9IEynaGvPJtv6XB/cz0Q=="], + "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], + "react-test-renderer": ["react-test-renderer@19.2.4", "", { "dependencies": { "react-is": "^19.2.4", "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-Ttl5D7Rnmi6JGMUpri4UjB4BAN0FPs4yRDnu2XSsigCWOLm11o8GwRlVsh27ER+4WFqsGtrBuuv5zumUaRCmKw=="], + "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], @@ -791,6 +799,8 @@ "rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], diff --git a/native/src/render.rs b/native/src/render.rs index 47fb3577..bb406f3e 100644 --- a/native/src/render.rs +++ b/native/src/render.rs @@ -2428,7 +2428,7 @@ fn render_app_acl(endpoints: &[Value]) -> Option { } let forced = if additional { ", ForcedSubject" } else { "" }; let mut lines = vec![ - format!("import type {{ PureAbility, AbilityTuple{forced} }} from \"@casl/ability\";"), + format!("import type {{ Ability, AbilityTuple{forced} }} from \"@casl/ability\";"), String::new(), "export type AppAbilities = ".into(), ]; @@ -2439,7 +2439,7 @@ fn render_app_acl(endpoints: &[Value]) -> Option { )); } lines.push(String::new()); - lines.push("export type AppAbility = PureAbility;".into()); + lines.push("export type AppAbility = Ability;".into()); Some(lines.join("\n")) } diff --git a/package.json b/package.json index 806ae674..4d01003b 100644 --- a/package.json +++ b/package.json @@ -125,13 +125,14 @@ "yargs": "^18.0.0" }, "devDependencies": { - "@casl/ability": "6.8.0", - "@casl/react": "5.0.1", + "@casl/ability": "7.1.1", + "@casl/react": "7.1.0", "@tanstack/react-query": "~5.90.21", "@types/bun": "1.4.1", "@types/node": "^25.3.0", "@types/prompt-sync": "^4.2.3", "@types/react": "^19.2.14", + "@types/react-test-renderer": "^19.1.0", "@types/yargs": "^17.0.35", "@vitejs/plugin-react": "^5.1.4", "axios": "^1.13.1", @@ -140,6 +141,7 @@ "oxlint": "1.50.0", "oxlint-tsgolint": "^0.15.0", "react": "^19.2.4", + "react-test-renderer": "19.2.4", "tsdown": "^0.22.14", "tsx": "^4.21.0", "type-fest": "^5.4.4", @@ -150,8 +152,8 @@ "zod": "^4.5.0" }, "peerDependencies": { - "@casl/ability": "^6.7.3", - "@casl/react": "^5.0.0", + "@casl/ability": "^7.1.1", + "@casl/react": "^7.1.0", "@tanstack/react-query": "^5.90.21", "axios": "^1.13.1", "i18next": "^25.8.11", diff --git a/src/generators/const/acl.const.ts b/src/generators/const/acl.const.ts index 9c0cff1f..7cee7075 100644 --- a/src/generators/const/acl.const.ts +++ b/src/generators/const/acl.const.ts @@ -7,7 +7,7 @@ export const ACL_CHECK_HOOK = "useAclCheck"; export const CASL_ABILITY_BINDING = { abilityTuple: "AbilityTuple", - pureAbility: "PureAbility", + ability: "Ability", forcedSubject: "ForcedSubject", subjectType: "Subject", subject: "subject", @@ -15,7 +15,7 @@ export const CASL_ABILITY_BINDING = { export const CASL_ABILITY_IMPORT: Import = { bindings: [ CASL_ABILITY_BINDING.abilityTuple, - CASL_ABILITY_BINDING.pureAbility, + CASL_ABILITY_BINDING.ability, CASL_ABILITY_BINDING.forcedSubject, CASL_ABILITY_BINDING.subjectType, CASL_ABILITY_BINDING.subject, diff --git a/src/generators/generate/generateAcl.ts b/src/generators/generate/generateAcl.ts index f0bf321f..7981edaa 100644 --- a/src/generators/generate/generateAcl.ts +++ b/src/generators/generate/generateAcl.ts @@ -82,7 +82,7 @@ export function generateAppAcl({ resolver, data }: Omit;`); + lines.push(`export type AppAbility = ${CASL_ABILITY_BINDING.ability}<${ACL_APP_ABILITIES}>;`); return lines.join("\n"); } diff --git a/src/generators/generated-casl.test.ts b/src/generators/generated-casl.test.ts new file mode 100644 index 00000000..ae8100c2 --- /dev/null +++ b/src/generators/generated-casl.test.ts @@ -0,0 +1,122 @@ +import { execFileSync } from "node:child_process"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +import type { OpenAPIV3 } from "openapi-types"; +import { describe, expect, it } from "vitest"; + +import { compileNativeData } from "../native/native-bindings"; + +import { DEFAULT_GENERATE_OPTIONS } from "./const/options.const"; +import { generateCodeFromOpenAPIDoc } from "./generateCodeFromOpenAPIDoc"; + +function fixture(withAcl: boolean): OpenAPIV3.Document { + return { + openapi: "3.0.3", + info: { title: "CASL 7", version: "1" }, + paths: { + "/items/{workspaceId}": { + get: { + tags: ["items"], + operationId: "readItem", + parameters: [{ name: "workspaceId", in: "path", required: true, schema: { type: "string" } }], + ...(withAcl + ? { "x-acl": [{ action: "read", subject: "Item", conditions: { workspaceId: "$params.workspaceId" } }] } + : {}), + responses: { "204": { description: "OK" } }, + }, + }, + }, + } as OpenAPIV3.Document; +} + +describe("generated CASL 7 ACL", () => { + it.each([false, true])( + "compiles and executes generated ACL (withAcl=%s)", + async (withAcl) => { + const output = await mkdtemp(join(process.cwd(), ".casl-generated-")); + try { + const document = fixture(withAcl); + const source = JSON.stringify(document); + const nativeSource = process.env.OPENAPI_CODEGEN_NATIVE === "1" ? { source, yaml: false } : undefined; + const options = { + ...DEFAULT_GENERATE_OPTIONS, + output, + acl: true, + checkAcl: false, + tsNamespaces: false, + importPath: "relative" as const, + }; + const files = generateCodeFromOpenAPIDoc(document, options, undefined, nativeSource).filter((file) => + /app\.ability\.ts$|\.acl\.ts$/.test(file.fileName), + ); + const appAbility = files.find((file) => file.fileName.endsWith("app.ability.ts")); + expect(appAbility).toBeDefined(); + expect(appAbility!.content).not.toContain("PureAbility"); + if (nativeSource && withAcl) { + const native = compileNativeData(source, false, JSON.stringify(options)).data as { + renderedShared: Record; + }; + expect(native.renderedShared.appAcl).toContain("AppAbility = Ability<"); + expect(appAbility!.content).toBe(native.renderedShared.appAcl); + } + const domainAcl = files.find((file) => file.fileName.endsWith(".acl.ts")); + expect(Boolean(domainAcl)).toBe(withAcl); + for (const file of files) { + await mkdir(dirname(file.fileName), { recursive: true }); + await writeFile(file.fileName, file.content); + } + await writeFile( + join(output, "consumer.ts"), + ` +import { strict as assert } from "node:assert"; +import { AbilityBuilder, createMongoAbility } from "@casl/ability"; +import { packRules, unpackRules } from "@casl/ability/extra"; +import type { AppAbility } from "./acl/app.ability"; +${withAcl ? 'import { canUseReadItem } from "./items/items.acl";' : ""} +const builder = new AbilityBuilder(createMongoAbility); +builder.can("read", "Item", { workspaceId: "mine" }); +const ability = builder.build(); +ability.update(unpackRules(packRules(ability.rules))); +assert.equal(ability.can("read", "Item"), true); +${withAcl ? "" : 'assert.equal(ability.can("delete", "Item"), false);'} +${ + withAcl + ? ` +assert.equal(ability.can(...canUseReadItem({ workspaceId: "mine" })), true); +assert.equal(ability.can(...canUseReadItem({ workspaceId: "other" })), false); +// @ts-expect-error Generated abilities restrict actions. +const invalid: Parameters[0] = "delete"; +void invalid; +` + : "" +} +`, + ); + await writeFile( + join(output, "tsconfig.json"), + JSON.stringify({ + compilerOptions: { + strict: true, + noEmit: true, + target: "ESNext", + module: "ESNext", + moduleResolution: "bundler", + skipLibCheck: true, + types: ["node"], + }, + include: ["**/*.ts"], + }), + ); + execFileSync("bun", ["run", "tsc", "-p", join(output, "tsconfig.json")], { + encoding: "utf8", + stdio: "inherit", + }); + execFileSync("bun", ["run", join(output, "consumer.ts")], { encoding: "utf8", stdio: "inherit" }); + } finally { + await rm(output, { recursive: true, force: true }); + } + }, + 30_000, + ); +}); diff --git a/src/lib/acl/Can.tsx b/src/lib/acl/Can.tsx index 4cf2efe2..d9e70c20 100644 --- a/src/lib/acl/Can.tsx +++ b/src/lib/acl/Can.tsx @@ -1,19 +1,26 @@ -import type { AbilityTuple, PureAbility } from "@casl/ability"; -import { type BoundCanProps, createContextualCan } from "@casl/react"; +import type { ReactNode } from "react"; -import { AbilityContext } from "./ability.context"; -import type { AppAbilities } from "./appAbility.types"; +import { Can as CaslCan, type CanProps as CaslCanProps } from "@casl/react"; -type CanAbility = PureAbility>; - -const ContextualCan = createContextualCan(AbilityContext.Consumer); +import type { AppAbilities, AppAbility } from "./appAbility.types"; type CanProps = { use: TAppAbilities; -} & Omit, "do" | "I" | "on" | "a" | "an" | "this">; + children: ReactNode | ((isAllowed: boolean, ability: AppAbility) => ReactNode); +} & Omit, "do" | "I" | "on" | "a" | "an" | "this" | "children">; -export const Can = ({ use, ...props }: CanProps) => { +export const Can = ({ + use, + children, + ...props +}: CanProps) => { const [action, subject] = use; - return ; + return ( + {...props} do={action} on={subject}> + {typeof children === "function" + ? ({ isAllowed, ability }) => (props.passThrough || isAllowed ? children(isAllowed, ability) : null) + : children} + + ); }; diff --git a/src/lib/acl/ability.context.tsx b/src/lib/acl/ability.context.tsx index 9e4607ba..4ecac18a 100644 --- a/src/lib/acl/ability.context.tsx +++ b/src/lib/acl/ability.context.tsx @@ -1,8 +1,8 @@ import { type PropsWithChildren, createContext, useMemo } from "react"; -import { AbilityBuilder, type PureAbility, type RawRuleOf, createMongoAbility } from "@casl/ability"; +import { AbilityBuilder, type Ability, type RawRuleOf, createMongoAbility } from "@casl/ability"; import { type PackRule, unpackRules } from "@casl/ability/extra"; -import { useAbility as useCaslAbility } from "@casl/react"; +import { AbilityProvider, useAbility as useCaslAbility } from "@casl/react"; import { AuthContext } from "../auth/auth.context"; import type { AppAbilities, AppAbility } from "./appAbility.types"; @@ -36,11 +36,15 @@ export namespace AbilityContext { return build(); }, [user]); - return {children}; + return ( + + {children} + + ); }; export const useAbility = () => { - const ability = useCaslAbility(Context); - return ability as PureAbility; + const ability = useCaslAbility(); + return ability as Ability; }; } diff --git a/src/lib/acl/ability.test.ts b/src/lib/acl/ability.test.ts new file mode 100644 index 00000000..707cb99d --- /dev/null +++ b/src/lib/acl/ability.test.ts @@ -0,0 +1,98 @@ +import { createElement } from "react"; + +import { subject, type RawRuleOf } from "@casl/ability"; +import { packRules } from "@casl/ability/extra"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { describe, expect, it } from "vitest"; + +import { AuthContext } from "../auth/auth.context"; +import { AbilityContext } from "./ability.context"; +import { Can } from "./Can"; +import type { AppAbility } from "./appAbility.types"; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const noop = () => {}; + +describe("CASL 7 runtime integration", () => { + it("provides the same ability to hooks, Consumer and Can, and updates packed conditional rules", async () => { + let ability!: AppAbility; + let consumedAbility!: AppAbility; + const callbacks: boolean[] = []; + function Probe() { + ability = AbilityContext.useAbility(); + return createElement(AbilityContext.Consumer, { + children: (value: AppAbility) => { + consumedAbility = value; + return createElement(Can, { + use: ["read", subject("Item", { workspaceId: "mine" })], + passThrough: true, + children: (isAllowed: boolean, currentAbility: AppAbility) => { + expect(currentAbility).toBe(ability); + callbacks.push(isAllowed); + return createElement("span", null, String(isAllowed)); + }, + }); + }, + }); + } + const tree = (rules: RawRuleOf[]) => + createElement(AuthContext.Provider, { + isAuthenticated: true, + isInitializing: false, + logout: noop, + user: { aclRules: packRules(rules) }, + children: createElement(AbilityContext.Provider, { children: createElement(Probe) }), + }); + let renderer!: ReactTestRenderer; + try { + await act(async () => { + renderer = create(tree([{ action: "read", subject: "Item", conditions: { workspaceId: "mine" } }])); + }); + expect(consumedAbility).toBe(ability); + expect(ability.can("read", subject("Item", { workspaceId: "mine" }))).toBe(true); + expect(ability.can("read", subject("Item", { workspaceId: "other" }))).toBe(false); + expect(renderer.toJSON()).toMatchObject({ children: ["true"] }); + await act(async () => renderer.update(tree([]))); + expect(renderer.toJSON()).toMatchObject({ children: ["false"] }); + expect(callbacks).toContain(true); + expect(callbacks.at(-1)).toBe(false); + await act(async () => renderer.update(tree([{ action: "read", subject: "Item", conditions: {} }]))); + expect(ability.can("read", subject("Item", { workspaceId: "other" }))).toBe(true); + expect(renderer.toJSON()).toMatchObject({ children: ["true"] }); + await act(async () => { + ability.update([]); + }); + expect(renderer.toJSON()).toMatchObject({ children: ["false"] }); + expect(consumedAbility).toBe(ability); + } finally { + await act(async () => renderer?.unmount()); + } + }); + + it("renders Can children for allow, deny, and not checks", async () => { + let renderer!: ReactTestRenderer; + try { + await act(async () => { + renderer = create( + createElement(AuthContext.Provider, { + isAuthenticated: true, + isInitializing: false, + logout: noop, + user: { aclRules: packRules([{ action: "read", subject: "Item" }]) }, + children: createElement(AbilityContext.Provider, { + children: [ + createElement(Can, { key: "allow", use: ["read", "Item"], children: "allowed" }), + createElement(Can, { key: "deny", use: ["delete", "Item"], children: "denied" }), + createElement(Can, { key: "not", use: ["delete", "Item"], not: true, children: "inverted" }), + ], + }), + }), + ); + }); + expect(renderer.toJSON()).toEqual(["allowed", "inverted"]); + } finally { + await act(async () => renderer?.unmount()); + } + }); +}); diff --git a/src/lib/acl/appAbility.types.ts b/src/lib/acl/appAbility.types.ts index 4a63911a..0ce3806e 100644 --- a/src/lib/acl/appAbility.types.ts +++ b/src/lib/acl/appAbility.types.ts @@ -1,5 +1,5 @@ -import type { AbilityTuple, PureAbility, Subject } from "@casl/ability"; +import type { AbilityTuple, Ability, Subject } from "@casl/ability"; export type AppAbilities = AbilityTuple; -export type AppAbility = PureAbility; +export type AppAbility = Ability;