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 resources/boost/skills/saucebase-module-development/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
78 changes: 78 additions & 0 deletions src/Capabilities.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php

namespace Saucebase\Core;

use Closure;
use InterNACHI\Modular\Support\ModuleConfig;
use InterNACHI\Modular\Support\ModuleRegistry;

/**
* One catalog of what can be granted, grouped by who reads it: `api` for what a
* token may be scoped to, `billing` for what a product may include. It only lists;
* each group's reader decides how its entries are checked.
*
* The app and each module declare theirs in `routes/capabilities.php`, like
* navigation, so an uninstalled module offers nothing:
*
* Capabilities::add('api', ['posts' => ['label' => 'Blog posts', 'description' => '…', 'read_only' => true]]);
* Capabilities::add('billing', fn () => [...]); // from data: runs only when the group is read
*/
class Capabilities
{
/** @var array<string, list<array<string, array<string, mixed>>|Closure(): array<string, array<string, mixed>>>> */
private array $entries = [];

private bool $loaded = false;

public function __construct(private readonly ModuleRegistry $modules) {}

Check warning on line 27 in src/Capabilities.php

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Move this open curly brace to the beginning of the next line.

See more on https://sonarcloud.io/project/issues?id=saucebase-dev_core&issues=AaGS_uzweoeKgQYq20EG&open=AaGS_uzweoeKgQYq20EG&pullRequest=34

/** @param array<string, array<string, mixed>>|Closure(): array<string, array<string, mixed>> $entries key => ['label' => …, 'description' => …] */

Check warning on line 29 in src/Capabilities.php

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Split this 154 characters long line (which is greater than 120 authorized).

See more on https://sonarcloud.io/project/issues?id=saucebase-dev_core&issues=AaGS_uzweoeKgQYq20EF&open=AaGS_uzweoeKgQYq20EF&pullRequest=34
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<string, array<string, mixed>>
*/
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;
}
}
}
}
1 change: 1 addition & 0 deletions src/CoreServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ public function register(): void
}

$this->app->singleton(SitemapRegistry::class);
$this->app->singleton(Capabilities::class);
$this->app->singleton(Home::class);
}

Expand Down
16 changes: 16 additions & 0 deletions src/Facades/Capabilities.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace Saucebase\Core\Facades;

use Illuminate\Support\Facades\Facade;

/**
* @mixin \Saucebase\Core\Capabilities
*/
class Capabilities extends Facade
{
protected static function getFacadeAccessor(): string
{
return \Saucebase\Core\Capabilities::class;
}
}
33 changes: 33 additions & 0 deletions stubs/stack/react/js/components/ui/saucebase/DangerZone.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import { useT } from '@saucebase/react/i18n';
import { TriangleAlert } from 'lucide-react';
import type { ComponentProps } from 'react';

export default function DangerZone({
title,
showHeader = true,
children,
className,
...props
}: ComponentProps<typeof Card> & { showHeader?: boolean }) {
const t = useT();

return (
<Card
className={cn('border-destructive/50', className)}
data-testid="danger-zone"
{...props}
>
{showHeader && (
<CardHeader>
<CardTitle className="text-destructive flex items-center gap-2">
<TriangleAlert className="size-4" />
{title ?? t('Danger zone')}
</CardTitle>
</CardHeader>
)}
<CardContent className="space-y-6">{children}</CardContent>
</Card>
);
}
38 changes: 38 additions & 0 deletions stubs/stack/react/js/components/ui/saucebase/EmptyState.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className={cn(
'flex flex-col items-center gap-2 rounded-lg border border-dashed px-6 py-10 text-center',
className,
)}
{...props}
>
{icon && (
<div className="bg-muted text-muted-foreground flex size-10 items-center justify-center rounded-full [&>svg]:size-5">
{icon}
</div>
)}
<p className="font-medium">{title}</p>
{description && (
<p className="text-muted-foreground text-sm">{description}</p>
)}
{children && <div className="mt-2">{children}</div>}
</div>
);
}
2 changes: 2 additions & 0 deletions stubs/stack/react/js/components/ui/saucebase/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
22 changes: 22 additions & 0 deletions stubs/stack/vue/js/components/ui/saucebase/DangerZone.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { TriangleAlert } from '@lucide/vue';

withDefaults(defineProps<{ title?: string; showHeader?: boolean }>(), {
showHeader: true,
});
</script>

<template>
<Card class="border-destructive/50" data-testid="danger-zone">
<CardHeader v-if="showHeader">
<CardTitle class="text-destructive flex items-center gap-2">
<TriangleAlert class="size-4" />
{{ title ?? $t('Danger zone') }}
</CardTitle>
</CardHeader>
<CardContent class="space-y-6">
<slot />
</CardContent>
</Card>
</template>
26 changes: 26 additions & 0 deletions stubs/stack/vue/js/components/ui/saucebase/EmptyState.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<script setup lang="ts">
defineProps<{
title: string;
description?: string;
}>();
</script>

<template>
<div
class="flex flex-col items-center gap-2 rounded-lg border border-dashed px-6 py-10 text-center"
>
<div
v-if="$slots.icon"
class="bg-muted text-muted-foreground flex size-10 items-center justify-center rounded-full [&>svg]:size-5"
>
<slot name="icon" />
</div>
<p class="font-medium">{{ title }}</p>
<p v-if="description" class="text-muted-foreground text-sm">
{{ description }}
</p>
<div v-if="$slots.default" class="mt-2">
<slot />
</div>
</div>
</template>
2 changes: 2 additions & 0 deletions stubs/stack/vue/js/components/ui/saucebase/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
65 changes: 65 additions & 0 deletions tests/Feature/CapabilitiesTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

namespace Saucebase\Core\Tests\Feature;

use Saucebase\Core\Capabilities;
use Saucebase\Core\Facades\Capabilities as CapabilitiesFacade;
use Saucebase\Core\Tests\Concerns\InteractsWithFixtureModules;
use Saucebase\Core\Tests\TestCase;

/**
* One catalog of what can be granted, grouped by who reads it: `api` for token
* scopes, `billing` for what a product includes. Each module declares its own in
* `routes/capabilities.php`, like navigation.
*/
class CapabilitiesTest extends TestCase
{
use InteractsWithFixtureModules;

public function test_a_group_lists_what_modules_declare_in_their_capabilities_file(): void
{
$capabilities = $this->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));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php

use Saucebase\Core\Facades\Capabilities;

Capabilities::add('api', [
'posts' => ['label' => 'Posts', 'description' => 'Read posts', 'read_only' => true],
]);
Loading