From 310f57bc59bebd9213f4a5de128a775d5b09318e Mon Sep 17 00:00:00 2001 From: roble Date: Sat, 31 Oct 2026 17:07:22 +0000 Subject: [PATCH] feat: implement Capabilities system with API and billing groups, add EmptyState and DangerZone components --- .../saucebase-module-development/SKILL.md | 19 +++++ src/Capabilities.php | 78 +++++++++++++++++++ src/CoreServiceProvider.php | 1 + src/Facades/Capabilities.php | 16 ++++ .../js/components/ui/saucebase/DangerZone.tsx | 33 ++++++++ .../js/components/ui/saucebase/EmptyState.tsx | 38 +++++++++ .../react/js/components/ui/saucebase/index.ts | 2 + .../js/components/ui/saucebase/DangerZone.vue | 22 ++++++ .../js/components/ui/saucebase/EmptyState.vue | 26 +++++++ .../vue/js/components/ui/saucebase/index.ts | 2 + tests/Feature/CapabilitiesTest.php | 65 ++++++++++++++++ .../routes/capabilities.php | 7 ++ 12 files changed, 309 insertions(+) create mode 100644 src/Capabilities.php create mode 100644 src/Facades/Capabilities.php create mode 100644 stubs/stack/react/js/components/ui/saucebase/DangerZone.tsx create mode 100644 stubs/stack/react/js/components/ui/saucebase/EmptyState.tsx create mode 100644 stubs/stack/vue/js/components/ui/saucebase/DangerZone.vue create mode 100644 stubs/stack/vue/js/components/ui/saucebase/EmptyState.vue create mode 100644 tests/Feature/CapabilitiesTest.php create mode 100644 tests/Fixtures/modules/module-test-fixture/routes/capabilities.php diff --git a/resources/boost/skills/saucebase-module-development/SKILL.md b/resources/boost/skills/saucebase-module-development/SKILL.md index b04d435..fd6b733 100644 --- a/resources/boost/skills/saucebase-module-development/SKILL.md +++ b/resources/boost/skills/saucebase-module-development/SKILL.md @@ -131,6 +131,25 @@ $this->app->make(SitemapRegistry::class)->add(fn (Sitemap $sitemap) => $sitemap ->add(Post::published()->get())); ``` +## Capabilities + +`Saucebase\Core\Capabilities` is one catalog of what can be granted, grouped by who reads it: +`api` (what an API token may be scoped to, read by API Tokens) and `billing` (keys a product's +entitlements offer, read by Billing's product form). It only lists; each group's reader checks +access its own way. Core loads `routes/capabilities.php` from the app and every installed module +the first time a group is read, like navigation: + +```php +use Saucebase\Core\Facades\Capabilities; + +Capabilities::add('api', ['posts' => ['label' => 'Blog posts', 'description' => '…', 'read_only' => true]]); +Capabilities::add('billing', fn (): array => [...]); // from data: runs only when the group is read +``` + +A missing label falls back to the key. In tests, bind a fresh `Capabilities` (with a +`ModuleRegistry` that has no modules) to control the exact list; in E2E, add entries from a +`laravel.registerBootFunction()` helper. + ## Toasts `Saucebase\Core\Toast::success($message, $description)` (also `default`, `error`, `info`, diff --git a/src/Capabilities.php b/src/Capabilities.php new file mode 100644 index 0000000..c8046c9 --- /dev/null +++ b/src/Capabilities.php @@ -0,0 +1,78 @@ + ['label' => 'Blog posts', 'description' => '…', 'read_only' => true]]); + * Capabilities::add('billing', fn () => [...]); // from data: runs only when the group is read + */ +class Capabilities +{ + /** @var array>|Closure(): array>>> */ + private array $entries = []; + + private bool $loaded = false; + + public function __construct(private readonly ModuleRegistry $modules) {} + + /** @param array>|Closure(): array> $entries key => ['label' => …, 'description' => …] */ + public function add(string $group, array|Closure $entries): void + { + $this->entries[$group][] = $entries; + } + + /** + * Every entry in the group, labels translated (the key when there is none). + * Keys besides label and description (`read_only`) pass through for the + * group's reader. + * + * @return array> + */ + public function group(string $group): array + { + $this->load(); + + return collect($this->entries[$group] ?? []) + ->flatMap(fn (array|Closure $entries): array => $entries instanceof Closure ? $entries() : $entries) + ->map(fn (array $entry, string $key): array => [ + ...$entry, + 'label' => __($entry['label'] ?? $key), + 'description' => __($entry['description'] ?? ''), + ]) + ->all(); + } + + /** + * The app's file, then each module's. `require`, not `require_once`: a new + * application (each test) loads them again; the flag keeps it to once per instance. + */ + private function load(): void + { + if ($this->loaded) { + return; + } + + $this->loaded = true; + + $files = [base_path('routes/capabilities.php'), ...$this->modules->modules() + ->map(fn (ModuleConfig $module): string => $module->path('routes/capabilities.php')) + ->all()]; + + foreach ($files as $file) { + if (file_exists($file)) { + require $file; + } + } + } +} diff --git a/src/CoreServiceProvider.php b/src/CoreServiceProvider.php index 0d35123..dc1b24d 100644 --- a/src/CoreServiceProvider.php +++ b/src/CoreServiceProvider.php @@ -75,6 +75,7 @@ public function register(): void } $this->app->singleton(SitemapRegistry::class); + $this->app->singleton(Capabilities::class); $this->app->singleton(Home::class); } diff --git a/src/Facades/Capabilities.php b/src/Facades/Capabilities.php new file mode 100644 index 0000000..cf68ae5 --- /dev/null +++ b/src/Facades/Capabilities.php @@ -0,0 +1,16 @@ + & { showHeader?: boolean }) { + const t = useT(); + + return ( + + {showHeader && ( + + + + {title ?? t('Danger zone')} + + + )} + {children} + + ); +} diff --git a/stubs/stack/react/js/components/ui/saucebase/EmptyState.tsx b/stubs/stack/react/js/components/ui/saucebase/EmptyState.tsx new file mode 100644 index 0000000..8e1a048 --- /dev/null +++ b/stubs/stack/react/js/components/ui/saucebase/EmptyState.tsx @@ -0,0 +1,38 @@ +import { cn } from '@/lib/utils'; +import type { ComponentProps, ReactNode } from 'react'; + +type EmptyStateProps = ComponentProps<'div'> & { + title: string; + description?: string; + icon?: ReactNode; +}; + +export default function EmptyState({ + title, + description, + icon, + children, + className, + ...props +}: EmptyStateProps) { + return ( +
+ {icon && ( +
+ {icon} +
+ )} +

{title}

+ {description && ( +

{description}

+ )} + {children &&
{children}
} +
+ ); +} diff --git a/stubs/stack/react/js/components/ui/saucebase/index.ts b/stubs/stack/react/js/components/ui/saucebase/index.ts index c39a6c1..1f76af8 100644 --- a/stubs/stack/react/js/components/ui/saucebase/index.ts +++ b/stubs/stack/react/js/components/ui/saucebase/index.ts @@ -243,3 +243,5 @@ export { ModuleCard } from './ModuleCard'; export { ModuleModal } from './ModuleModal'; export { default as PageHero } from './PageHero'; export { CopyField } from './CopyField'; +export { default as EmptyState } from './EmptyState'; +export { default as DangerZone } from './DangerZone'; diff --git a/stubs/stack/vue/js/components/ui/saucebase/DangerZone.vue b/stubs/stack/vue/js/components/ui/saucebase/DangerZone.vue new file mode 100644 index 0000000..1596006 --- /dev/null +++ b/stubs/stack/vue/js/components/ui/saucebase/DangerZone.vue @@ -0,0 +1,22 @@ + + + diff --git a/stubs/stack/vue/js/components/ui/saucebase/EmptyState.vue b/stubs/stack/vue/js/components/ui/saucebase/EmptyState.vue new file mode 100644 index 0000000..e394774 --- /dev/null +++ b/stubs/stack/vue/js/components/ui/saucebase/EmptyState.vue @@ -0,0 +1,26 @@ + + + diff --git a/stubs/stack/vue/js/components/ui/saucebase/index.ts b/stubs/stack/vue/js/components/ui/saucebase/index.ts index b27fd7f..889cf8c 100644 --- a/stubs/stack/vue/js/components/ui/saucebase/index.ts +++ b/stubs/stack/vue/js/components/ui/saucebase/index.ts @@ -288,3 +288,5 @@ export { default as ModuleCard } from './ModuleCard.vue'; export { default as ModuleModal } from './ModuleModal.vue'; export { default as PageHero } from './PageHero.vue'; export { default as CopyField } from './CopyField.vue'; +export { default as EmptyState } from './EmptyState.vue'; +export { default as DangerZone } from './DangerZone.vue'; diff --git a/tests/Feature/CapabilitiesTest.php b/tests/Feature/CapabilitiesTest.php new file mode 100644 index 0000000..ebe6c99 --- /dev/null +++ b/tests/Feature/CapabilitiesTest.php @@ -0,0 +1,65 @@ +app->make(Capabilities::class); + + $this->assertSame( + ['posts' => ['label' => 'Posts', 'description' => 'Read posts', 'read_only' => true]], + $capabilities->group('api'), + ); + $this->assertSame([], $capabilities->group('unknown')); + } + + /** Entries from data (a row per package) are a callback, run only when the group is read. */ + public function test_entries_can_be_added_lazily(): void + { + $called = false; + + CapabilitiesFacade::add('billing', function () use (&$called): array { + $called = true; + + return ['packages:acme/invoices' => ['label' => 'acme/invoices']]; + }); + + $this->assertFalse($called); + $this->assertSame( + ['packages:acme/invoices' => ['label' => 'acme/invoices', 'description' => '']], + CapabilitiesFacade::group('billing'), + ); + $this->assertTrue($called); + } + + public function test_an_entry_without_a_label_is_labelled_by_its_key(): void + { + CapabilitiesFacade::add('billing', ['webhooks' => []]); + + $this->assertSame(['webhooks' => ['label' => 'webhooks', 'description' => '']], CapabilitiesFacade::group('billing')); + } + + /** Each application loads the files afresh: nothing carries over from the last one. */ + public function test_files_load_once_per_application(): void + { + $capabilities = $this->app->make(Capabilities::class); + $capabilities->group('api'); + + $this->assertCount(1, $capabilities->group('api')); + $this->assertSame($capabilities, $this->app->make(Capabilities::class)); + } +} diff --git a/tests/Fixtures/modules/module-test-fixture/routes/capabilities.php b/tests/Fixtures/modules/module-test-fixture/routes/capabilities.php new file mode 100644 index 0000000..d8b7a7f --- /dev/null +++ b/tests/Fixtures/modules/module-test-fixture/routes/capabilities.php @@ -0,0 +1,7 @@ + ['label' => 'Posts', 'description' => 'Read posts', 'read_only' => true], +]);