feat(agent/admin+hub): RFC foundation — admin scaffold + Hub global components
Foundation slice for Mantis #843 php/Mod/Admin + php/Website/Hub RFC: * php/Mod/Admin/Boot.php — search registry, menu registry, form component layer, HasRateLimiting concern, reusable form/view primitives under Mod/Admin/Forms * php/Website/Hub/Boot.php — host-aware Hub route naming for secondary domains * WorkspaceSwitcher and GlobalSearch global Hub Livewire components * Foundation routed slice in Hub/Routes/admin.php: dashboard shell, workspace listing, site settings (with WordPress/webhook connector), account usage, platform user list+detail * Foundation tests under php/tests/Feature/Mod/Admin/ 53 PHP files. php -l clean. Pest unrunnable in sandbox (no vendor/). Foundation slice only — composer.json kept off-limits so namespace stays under Core\Mod\Agentic\... rather than standalone Core\Admin package. Deferred: Profile, Settings, ServiceManager, ServicesAdmin, Honeypot, Entitlement\{Dashboard,FeatureManager,PackageManager}, PromptManager, WaitlistManager, Console, Databases, Deployments, Content, ContentManager, ContentEditor, ActivityLog, Analytics, AIServices, BoostPurchase. Lane was under-instructed by supervisor with stop-at framing — follow-up tickets needed for remainder. Co-authored-by: Codex <noreply@openai.com> Closes tasks.lthn.sh/view.php?id=843
This commit is contained in:
parent
5385385314
commit
f96bd67bd6
53 changed files with 3144 additions and 0 deletions
63
php/Mod/Admin/Boot.php
Normal file
63
php/Mod/Admin/Boot.php
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Mod\Admin;
|
||||
|
||||
use Core\Mod\Agentic\Mod\Admin\Forms\View\Components\Button;
|
||||
use Core\Mod\Agentic\Mod\Admin\Forms\View\Components\Checkbox;
|
||||
use Core\Mod\Agentic\Mod\Admin\Forms\View\Components\FormGroup;
|
||||
use Core\Mod\Agentic\Mod\Admin\Forms\View\Components\Input;
|
||||
use Core\Mod\Agentic\Mod\Admin\Forms\View\Components\Select;
|
||||
use Core\Mod\Agentic\Mod\Admin\Forms\View\Components\Textarea;
|
||||
use Core\Mod\Agentic\Mod\Admin\Forms\View\Components\Toggle;
|
||||
use Core\Mod\Agentic\Mod\Admin\Menu\AdminMenuRegistry;
|
||||
use Core\Mod\Agentic\Mod\Admin\Search\Providers\AdminPageSearchProvider;
|
||||
use Core\Mod\Agentic\Mod\Admin\Search\SearchProviderRegistry;
|
||||
use Core\ModuleRegistry;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class Boot extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
if (class_exists(ModuleRegistry::class) && app()->bound(ModuleRegistry::class)) {
|
||||
app(ModuleRegistry::class)->addPaths([
|
||||
__DIR__.'/../../Website',
|
||||
]);
|
||||
}
|
||||
|
||||
$this->app->singleton(SearchProviderRegistry::class);
|
||||
$this->app->singleton(AdminMenuRegistry::class);
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
$this->loadViewsFrom(__DIR__.'/resources/views', 'core-forms');
|
||||
$this->loadTranslationsFrom(__DIR__.'/Lang', 'hub');
|
||||
|
||||
$this->registerFormComponents();
|
||||
$this->registerSearchProviders();
|
||||
}
|
||||
|
||||
protected function registerFormComponents(): void
|
||||
{
|
||||
Blade::component('core-forms.input', Input::class);
|
||||
Blade::component('core-forms.textarea', Textarea::class);
|
||||
Blade::component('core-forms.select', Select::class);
|
||||
Blade::component('core-forms.checkbox', Checkbox::class);
|
||||
Blade::component('core-forms.button', Button::class);
|
||||
Blade::component('core-forms.toggle', Toggle::class);
|
||||
Blade::component('core-forms.form-group', FormGroup::class);
|
||||
}
|
||||
|
||||
protected function registerSearchProviders(): void
|
||||
{
|
||||
$this->app->make(SearchProviderRegistry::class)->register(
|
||||
$this->app->make(AdminPageSearchProvider::class)
|
||||
);
|
||||
}
|
||||
}
|
||||
53
php/Mod/Admin/Forms/Concerns/HasAuthorizationProps.php
Normal file
53
php/Mod/Admin/Forms/Concerns/HasAuthorizationProps.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Mod\Admin\Forms\Concerns;
|
||||
|
||||
trait HasAuthorizationProps
|
||||
{
|
||||
public ?string $canGate = null;
|
||||
|
||||
public mixed $canResource = null;
|
||||
|
||||
public bool $canHide = false;
|
||||
|
||||
protected function resolveDisabledState(bool $disabled = false): bool
|
||||
{
|
||||
if ($disabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->canGate === null || $this->canResource === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ! $this->userCan();
|
||||
}
|
||||
|
||||
protected function resolveHiddenState(): bool
|
||||
{
|
||||
if (! $this->canHide) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->canGate === null || $this->canResource === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ! $this->userCan();
|
||||
}
|
||||
|
||||
protected function userCan(): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if ($user === null || ! method_exists($user, 'can')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) $user->can($this->canGate, $this->canResource);
|
||||
}
|
||||
}
|
||||
73
php/Mod/Admin/Forms/View/Components/Button.php
Normal file
73
php/Mod/Admin/Forms/View/Components/Button.php
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Mod\Admin\Forms\View\Components;
|
||||
|
||||
class Button extends FormComponent
|
||||
{
|
||||
public string $type;
|
||||
|
||||
public string $variant;
|
||||
|
||||
public string $size;
|
||||
|
||||
public ?string $icon;
|
||||
|
||||
public bool $loading;
|
||||
|
||||
public ?string $loadingText;
|
||||
|
||||
public string $variantClasses;
|
||||
|
||||
public string $sizeClasses;
|
||||
|
||||
public function __construct(
|
||||
string $type = 'button',
|
||||
string $variant = 'primary',
|
||||
string $size = 'md',
|
||||
?string $icon = null,
|
||||
bool $loading = false,
|
||||
?string $loadingText = null,
|
||||
bool $disabled = false,
|
||||
?string $canGate = null,
|
||||
mixed $canResource = null,
|
||||
bool $canHide = false,
|
||||
) {
|
||||
$this->type = $type;
|
||||
$this->variant = $variant;
|
||||
$this->size = $size;
|
||||
$this->icon = $icon;
|
||||
$this->loading = $loading;
|
||||
$this->loadingText = $loadingText;
|
||||
$this->initialiseAuthorisation($canGate, $canResource, $canHide, $disabled);
|
||||
$this->variantClasses = $this->resolveVariantClasses();
|
||||
$this->sizeClasses = $this->resolveSizeClasses();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('core-forms::components.forms.button');
|
||||
}
|
||||
|
||||
protected function resolveVariantClasses(): string
|
||||
{
|
||||
return match ($this->variant) {
|
||||
'secondary' => 'bg-zinc-100 text-zinc-900 hover:bg-zinc-200',
|
||||
'danger' => 'bg-red-600 text-white hover:bg-red-700',
|
||||
'ghost' => 'bg-transparent text-zinc-700 hover:bg-zinc-100',
|
||||
default => 'bg-violet-600 text-white hover:bg-violet-700',
|
||||
};
|
||||
}
|
||||
|
||||
protected function resolveSizeClasses(): string
|
||||
{
|
||||
return match ($this->size) {
|
||||
'sm' => 'px-3 py-1.5 text-sm',
|
||||
'lg' => 'px-5 py-3 text-base',
|
||||
default => 'px-4 py-2 text-sm',
|
||||
};
|
||||
}
|
||||
}
|
||||
36
php/Mod/Admin/Forms/View/Components/Checkbox.php
Normal file
36
php/Mod/Admin/Forms/View/Components/Checkbox.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Mod\Admin\Forms\View\Components;
|
||||
|
||||
class Checkbox extends FormComponent
|
||||
{
|
||||
public string $id;
|
||||
|
||||
public ?string $label;
|
||||
|
||||
public bool $required;
|
||||
|
||||
public function __construct(
|
||||
string $id,
|
||||
?string $label = null,
|
||||
bool $required = false,
|
||||
bool $disabled = false,
|
||||
?string $canGate = null,
|
||||
mixed $canResource = null,
|
||||
bool $canHide = false,
|
||||
) {
|
||||
$this->id = $id;
|
||||
$this->label = $label;
|
||||
$this->required = $required;
|
||||
$this->initialiseAuthorisation($canGate, $canResource, $canHide, $disabled);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('core-forms::components.forms.checkbox');
|
||||
}
|
||||
}
|
||||
37
php/Mod/Admin/Forms/View/Components/FormComponent.php
Normal file
37
php/Mod/Admin/Forms/View/Components/FormComponent.php
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Mod\Admin\Forms\View\Components;
|
||||
|
||||
use Core\Mod\Agentic\Mod\Admin\Forms\Concerns\HasAuthorizationProps;
|
||||
use Illuminate\View\Component;
|
||||
|
||||
abstract class FormComponent extends Component
|
||||
{
|
||||
use HasAuthorizationProps;
|
||||
|
||||
public bool $disabled = false;
|
||||
|
||||
public bool $hidden = false;
|
||||
|
||||
protected function initialiseAuthorisation(
|
||||
?string $canGate,
|
||||
mixed $canResource,
|
||||
bool $canHide,
|
||||
bool $disabled = false
|
||||
): void {
|
||||
$this->canGate = $canGate;
|
||||
$this->canResource = $canResource;
|
||||
$this->canHide = $canHide;
|
||||
$this->disabled = $this->resolveDisabledState($disabled);
|
||||
$this->hidden = $this->resolveHiddenState();
|
||||
}
|
||||
|
||||
public function shouldRender(): bool
|
||||
{
|
||||
return ! $this->hidden;
|
||||
}
|
||||
}
|
||||
40
php/Mod/Admin/Forms/View/Components/FormGroup.php
Normal file
40
php/Mod/Admin/Forms/View/Components/FormGroup.php
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Mod\Admin\Forms\View\Components;
|
||||
|
||||
class FormGroup extends FormComponent
|
||||
{
|
||||
public ?string $label;
|
||||
|
||||
public ?string $error;
|
||||
|
||||
public ?string $help;
|
||||
|
||||
public bool $required;
|
||||
|
||||
public function __construct(
|
||||
?string $label = null,
|
||||
?string $error = null,
|
||||
?string $help = null,
|
||||
bool $required = false,
|
||||
bool $disabled = false,
|
||||
?string $canGate = null,
|
||||
mixed $canResource = null,
|
||||
bool $canHide = false,
|
||||
) {
|
||||
$this->label = $label;
|
||||
$this->error = $error;
|
||||
$this->help = $help;
|
||||
$this->required = $required;
|
||||
$this->initialiseAuthorisation($canGate, $canResource, $canHide, $disabled);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('core-forms::components.forms.form-group');
|
||||
}
|
||||
}
|
||||
52
php/Mod/Admin/Forms/View/Components/Input.php
Normal file
52
php/Mod/Admin/Forms/View/Components/Input.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Mod\Admin\Forms\View\Components;
|
||||
|
||||
class Input extends FormComponent
|
||||
{
|
||||
public string $id;
|
||||
|
||||
public ?string $label;
|
||||
|
||||
public ?string $hint;
|
||||
|
||||
public string $type;
|
||||
|
||||
public ?string $placeholder;
|
||||
|
||||
public bool $required;
|
||||
|
||||
public bool $readonly;
|
||||
|
||||
public function __construct(
|
||||
string $id,
|
||||
?string $label = null,
|
||||
?string $hint = null,
|
||||
string $type = 'text',
|
||||
?string $placeholder = null,
|
||||
bool $required = false,
|
||||
bool $disabled = false,
|
||||
bool $readonly = false,
|
||||
?string $canGate = null,
|
||||
mixed $canResource = null,
|
||||
bool $canHide = false,
|
||||
) {
|
||||
$this->id = $id;
|
||||
$this->label = $label;
|
||||
$this->hint = $hint;
|
||||
$this->type = $type;
|
||||
$this->placeholder = $placeholder;
|
||||
$this->required = $required;
|
||||
$this->readonly = $readonly;
|
||||
$this->initialiseAuthorisation($canGate, $canResource, $canHide, $disabled);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('core-forms::components.forms.input');
|
||||
}
|
||||
}
|
||||
47
php/Mod/Admin/Forms/View/Components/Select.php
Normal file
47
php/Mod/Admin/Forms/View/Components/Select.php
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Mod\Admin\Forms\View\Components;
|
||||
|
||||
class Select extends FormComponent
|
||||
{
|
||||
public string $id;
|
||||
|
||||
public ?string $label;
|
||||
|
||||
/**
|
||||
* @var array<string, string>
|
||||
*/
|
||||
public array $options;
|
||||
|
||||
public bool $multiple;
|
||||
|
||||
public bool $required;
|
||||
|
||||
public function __construct(
|
||||
string $id,
|
||||
?string $label = null,
|
||||
array $options = [],
|
||||
bool $multiple = false,
|
||||
bool $required = false,
|
||||
bool $disabled = false,
|
||||
?string $canGate = null,
|
||||
mixed $canResource = null,
|
||||
bool $canHide = false,
|
||||
) {
|
||||
$this->id = $id;
|
||||
$this->label = $label;
|
||||
$this->options = $options;
|
||||
$this->multiple = $multiple;
|
||||
$this->required = $required;
|
||||
$this->initialiseAuthorisation($canGate, $canResource, $canHide, $disabled);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('core-forms::components.forms.select');
|
||||
}
|
||||
}
|
||||
48
php/Mod/Admin/Forms/View/Components/Textarea.php
Normal file
48
php/Mod/Admin/Forms/View/Components/Textarea.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Mod\Admin\Forms\View\Components;
|
||||
|
||||
class Textarea extends FormComponent
|
||||
{
|
||||
public string $id;
|
||||
|
||||
public ?string $label;
|
||||
|
||||
public ?string $hint;
|
||||
|
||||
public int $rows;
|
||||
|
||||
public ?string $placeholder;
|
||||
|
||||
public bool $required;
|
||||
|
||||
public function __construct(
|
||||
string $id,
|
||||
?string $label = null,
|
||||
?string $hint = null,
|
||||
int $rows = 4,
|
||||
?string $placeholder = null,
|
||||
bool $required = false,
|
||||
bool $disabled = false,
|
||||
?string $canGate = null,
|
||||
mixed $canResource = null,
|
||||
bool $canHide = false,
|
||||
) {
|
||||
$this->id = $id;
|
||||
$this->label = $label;
|
||||
$this->hint = $hint;
|
||||
$this->rows = $rows;
|
||||
$this->placeholder = $placeholder;
|
||||
$this->required = $required;
|
||||
$this->initialiseAuthorisation($canGate, $canResource, $canHide, $disabled);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('core-forms::components.forms.textarea');
|
||||
}
|
||||
}
|
||||
32
php/Mod/Admin/Forms/View/Components/Toggle.php
Normal file
32
php/Mod/Admin/Forms/View/Components/Toggle.php
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Mod\Admin\Forms\View\Components;
|
||||
|
||||
class Toggle extends FormComponent
|
||||
{
|
||||
public string $id;
|
||||
|
||||
public ?string $label;
|
||||
|
||||
public function __construct(
|
||||
string $id,
|
||||
?string $label = null,
|
||||
bool $disabled = false,
|
||||
?string $canGate = null,
|
||||
mixed $canResource = null,
|
||||
bool $canHide = false,
|
||||
) {
|
||||
$this->id = $id;
|
||||
$this->label = $label;
|
||||
$this->initialiseAuthorisation($canGate, $canResource, $canHide, $disabled);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('core-forms::components.forms.toggle');
|
||||
}
|
||||
}
|
||||
17
php/Mod/Admin/Lang/en_GB/hub.php
Normal file
17
php/Mod/Admin/Lang/en_GB/hub.php
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'dashboard' => 'Dashboard',
|
||||
'workspaces' => 'Workspaces',
|
||||
'usage' => 'Usage',
|
||||
'platform' => 'Platform',
|
||||
'search' => [
|
||||
'placeholder' => 'Search the Hub',
|
||||
'recent' => 'Recent searches',
|
||||
'clear_recent' => 'Clear',
|
||||
],
|
||||
];
|
||||
95
php/Mod/Admin/Menu/AdminMenuRegistry.php
Normal file
95
php/Mod/Admin/Menu/AdminMenuRegistry.php
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Mod\Admin\Menu;
|
||||
|
||||
use Core\Mod\Agentic\Mod\Admin\Menu\Contracts\AdminMenuProvider;
|
||||
|
||||
class AdminMenuRegistry
|
||||
{
|
||||
/**
|
||||
* @var array<int, AdminMenuProvider>
|
||||
*/
|
||||
protected array $providers = [];
|
||||
|
||||
/**
|
||||
* @var array<string, array<string, mixed>>
|
||||
*/
|
||||
protected array $groups = [
|
||||
'dashboard' => ['label' => 'Dashboard', 'standalone' => true],
|
||||
'workspaces' => ['label' => 'Workspaces', 'icon' => 'folders'],
|
||||
'services' => ['label' => 'Services', 'standalone' => true],
|
||||
'settings' => ['label' => 'Account', 'icon' => 'gear'],
|
||||
'admin' => ['label' => 'Admin', 'icon' => 'shield'],
|
||||
];
|
||||
|
||||
public function register(AdminMenuProvider $provider): void
|
||||
{
|
||||
$this->providers[] = $provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, AdminMenuProvider>
|
||||
*/
|
||||
public function providers(): array
|
||||
{
|
||||
return $this->providers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{meta: array<string, mixed>, items: array<int, array<string, mixed>>}>
|
||||
*/
|
||||
public function items(?object $user = null, ?object $workspace = null): array
|
||||
{
|
||||
$grouped = [];
|
||||
$isAdmin = method_exists($user, 'isHades') ? (bool) $user->isHades() : false;
|
||||
|
||||
foreach ($this->providers as $provider) {
|
||||
if (! $provider->canViewMenu($user, $workspace)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($provider->adminMenuItems() as $registration) {
|
||||
if (($registration['admin'] ?? false) && ! $isAdmin) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$group = (string) ($registration['group'] ?? 'services');
|
||||
$grouped[$group][] = [
|
||||
'priority' => (int) ($registration['priority'] ?? 50),
|
||||
'item' => $registration['item'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$resolved = [];
|
||||
|
||||
foreach ($grouped as $group => $items) {
|
||||
usort($items, static fn (array $left, array $right): int => $left['priority'] <=> $right['priority']);
|
||||
|
||||
$resolved[$group] = [
|
||||
'meta' => $this->groups[$group] ?? ['label' => ucfirst($group)],
|
||||
'items' => array_map(static fn (array $entry): array => $entry['item'](), $items),
|
||||
];
|
||||
}
|
||||
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compatibility wrapper for render paths that expect build().
|
||||
*
|
||||
* @return array<string, array{meta: array<string, mixed>, items: array<int, array<string, mixed>>}>
|
||||
*/
|
||||
public function build(?object $workspace = null, bool $isAdmin = false, ?object $user = null): array
|
||||
{
|
||||
if ($user !== null && ! $isAdmin && method_exists($user, 'isHades')) {
|
||||
$isAdmin = (bool) $user->isHades();
|
||||
}
|
||||
|
||||
return $this->items($user, $workspace);
|
||||
}
|
||||
}
|
||||
27
php/Mod/Admin/Menu/Contracts/AdminMenuProvider.php
Normal file
27
php/Mod/Admin/Menu/Contracts/AdminMenuProvider.php
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Mod\Admin\Menu\Contracts;
|
||||
|
||||
interface AdminMenuProvider
|
||||
{
|
||||
/**
|
||||
* @return array<int, array{
|
||||
* group: string,
|
||||
* priority: int,
|
||||
* admin?: bool,
|
||||
* item: \Closure
|
||||
* }>
|
||||
*/
|
||||
public function adminMenuItems(): array;
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function menuPermissions(): array;
|
||||
|
||||
public function canViewMenu(?object $user, ?object $workspace): bool;
|
||||
}
|
||||
19
php/Mod/Admin/Search/Contracts/SearchProvider.php
Normal file
19
php/Mod/Admin/Search/Contracts/SearchProvider.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Mod\Admin\Search\Contracts;
|
||||
|
||||
interface SearchProvider
|
||||
{
|
||||
/**
|
||||
* @return array<int, array<string, mixed>|object>
|
||||
*/
|
||||
public function search(string $query): array;
|
||||
|
||||
public function name(): string;
|
||||
|
||||
public function icon(): string;
|
||||
}
|
||||
105
php/Mod/Admin/Search/Providers/AdminPageSearchProvider.php
Normal file
105
php/Mod/Admin/Search/Providers/AdminPageSearchProvider.php
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Mod\Admin\Search\Providers;
|
||||
|
||||
use Core\Mod\Agentic\Mod\Admin\Search\Contracts\SearchProvider;
|
||||
use Core\Mod\Agentic\Mod\Admin\Search\SearchProviderRegistry;
|
||||
use Core\Mod\Agentic\Mod\Admin\Search\SearchResult;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
class AdminPageSearchProvider implements SearchProvider
|
||||
{
|
||||
public function __construct(
|
||||
protected SearchProviderRegistry $registry
|
||||
) {}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'Pages';
|
||||
}
|
||||
|
||||
public function icon(): string
|
||||
{
|
||||
return 'rectangle-stack';
|
||||
}
|
||||
|
||||
public function priority(): int
|
||||
{
|
||||
return 10;
|
||||
}
|
||||
|
||||
public function available(?object $user = null, ?object $workspace = null): bool
|
||||
{
|
||||
return $user !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, SearchResult>
|
||||
*/
|
||||
public function search(string $query): array
|
||||
{
|
||||
$pages = array_filter($this->pagesForCurrentUser(), function (array $page) use ($query): bool {
|
||||
return $this->registry->fuzzyMatch($query, $page['title'])
|
||||
|| $this->registry->fuzzyMatch($query, $page['description']);
|
||||
});
|
||||
|
||||
usort($pages, fn (array $left, array $right): int => $this->registry->relevanceScore($query, $right['title']) <=> $this->registry->relevanceScore($query, $left['title']));
|
||||
|
||||
return array_map(static fn (array $page): SearchResult => SearchResult::fromArray($page), $pages);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, string>>
|
||||
*/
|
||||
protected function pagesForCurrentUser(): array
|
||||
{
|
||||
$pages = [
|
||||
[
|
||||
'id' => 'dashboard',
|
||||
'type' => 'admin_page',
|
||||
'title' => 'Dashboard',
|
||||
'description' => 'Hub landing page',
|
||||
'url' => $this->safeRoute('hub.dashboard', '/hub'),
|
||||
'icon' => 'house',
|
||||
],
|
||||
[
|
||||
'id' => 'workspaces',
|
||||
'type' => 'admin_page',
|
||||
'title' => 'Workspaces',
|
||||
'description' => 'Workspace and tenant registry',
|
||||
'url' => $this->safeRoute('hub.sites', '/hub/workspaces'),
|
||||
'icon' => 'folders',
|
||||
],
|
||||
[
|
||||
'id' => 'usage',
|
||||
'type' => 'admin_page',
|
||||
'title' => 'Account Usage',
|
||||
'description' => 'Storage, boosts and AI service usage',
|
||||
'url' => $this->safeRoute('hub.account.usage', '/hub/account/usage'),
|
||||
'icon' => 'chart-pie',
|
||||
],
|
||||
];
|
||||
|
||||
if (auth()->user()?->isHades()) {
|
||||
$pages[] = [
|
||||
'id' => 'platform',
|
||||
'type' => 'admin_page',
|
||||
'title' => 'Platform',
|
||||
'description' => 'User search, tier management and verification',
|
||||
'url' => $this->safeRoute('hub.platform', '/hub/platform'),
|
||||
'icon' => 'server',
|
||||
];
|
||||
}
|
||||
|
||||
return $pages;
|
||||
}
|
||||
|
||||
protected function safeRoute(string $name, string $fallback): string
|
||||
{
|
||||
return Route::has($name) ? route($name) : $fallback;
|
||||
}
|
||||
}
|
||||
177
php/Mod/Admin/Search/SearchProviderRegistry.php
Normal file
177
php/Mod/Admin/Search/SearchProviderRegistry.php
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Mod\Admin\Search;
|
||||
|
||||
use Core\Mod\Agentic\Mod\Admin\Search\Contracts\SearchProvider;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class SearchProviderRegistry
|
||||
{
|
||||
/**
|
||||
* @var array<int, SearchProvider>
|
||||
*/
|
||||
protected array $providers = [];
|
||||
|
||||
public function register(SearchProvider $provider): void
|
||||
{
|
||||
$this->providers[] = $provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, SearchProvider> $providers
|
||||
*/
|
||||
public function registerMany(array $providers): void
|
||||
{
|
||||
foreach ($providers as $provider) {
|
||||
$this->register($provider);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, SearchProvider>
|
||||
*/
|
||||
public function providers(): array
|
||||
{
|
||||
return $this->providers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{label: string, icon: string, results: array<int, array<string, mixed>>}>
|
||||
*/
|
||||
public function search(string $query, ?object $user = null, ?object $workspace = null, int $limitPerProvider = 5): array
|
||||
{
|
||||
$grouped = [];
|
||||
|
||||
foreach ($this->availableProviders($user, $workspace) as $provider) {
|
||||
$results = array_slice($provider->search($query), 0, $limitPerProvider);
|
||||
|
||||
if ($results === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = Str::slug($provider->name(), '_');
|
||||
$grouped[$key] = [
|
||||
'label' => $provider->name(),
|
||||
'icon' => $provider->icon(),
|
||||
'results' => array_map(
|
||||
static fn (mixed $result): array => $result instanceof SearchResult
|
||||
? $result->toArray()
|
||||
: SearchResult::fromArray((array) $result)->toArray(),
|
||||
$results
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, SearchProvider>
|
||||
*/
|
||||
protected function availableProviders(?object $user = null, ?object $workspace = null): array
|
||||
{
|
||||
$providers = array_filter($this->providers, static function (SearchProvider $provider) use ($user, $workspace): bool {
|
||||
if (! method_exists($provider, 'available')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (bool) $provider->available($user, $workspace);
|
||||
});
|
||||
|
||||
usort($providers, static function (SearchProvider $left, SearchProvider $right): int {
|
||||
$leftPriority = method_exists($left, 'priority') ? (int) $left->priority() : 50;
|
||||
$rightPriority = method_exists($right, 'priority') ? (int) $right->priority() : 50;
|
||||
|
||||
return $leftPriority <=> $rightPriority;
|
||||
});
|
||||
|
||||
return array_values($providers);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{label: string, icon: string, results: array<int, array<string, mixed>>}> $grouped
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function flattenResults(array $grouped): array
|
||||
{
|
||||
$flat = [];
|
||||
|
||||
foreach ($grouped as $group) {
|
||||
foreach ($group['results'] as $result) {
|
||||
$flat[] = $result;
|
||||
}
|
||||
}
|
||||
|
||||
return $flat;
|
||||
}
|
||||
|
||||
public function fuzzyMatch(string $query, string $target): bool
|
||||
{
|
||||
$query = Str::lower(trim($query));
|
||||
$target = Str::lower(trim($target));
|
||||
|
||||
if ($query === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Str::contains($target, $query)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$words = preg_split('/\s+/', $target) ?: [];
|
||||
$queryChars = str_split($query);
|
||||
$wordIndex = 0;
|
||||
$charIndex = 0;
|
||||
|
||||
while ($charIndex < count($queryChars) && $wordIndex < count($words)) {
|
||||
if (Str::startsWith($words[$wordIndex], $queryChars[$charIndex])) {
|
||||
$charIndex++;
|
||||
}
|
||||
$wordIndex++;
|
||||
}
|
||||
|
||||
if ($charIndex === count($queryChars)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$targetIndex = 0;
|
||||
|
||||
foreach ($queryChars as $char) {
|
||||
$foundAt = strpos($target, $char, $targetIndex);
|
||||
if ($foundAt === false) {
|
||||
return false;
|
||||
}
|
||||
$targetIndex = $foundAt + 1;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function relevanceScore(string $query, string $target): int
|
||||
{
|
||||
$query = Str::lower(trim($query));
|
||||
$target = Str::lower(trim($target));
|
||||
|
||||
if ($query === '' || $target === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($query === $target) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
if (Str::startsWith($target, $query)) {
|
||||
return 90;
|
||||
}
|
||||
|
||||
if (Str::contains($target, $query)) {
|
||||
return 70;
|
||||
}
|
||||
|
||||
return $this->fuzzyMatch($query, $target) ? 60 : 0;
|
||||
}
|
||||
}
|
||||
68
php/Mod/Admin/Search/SearchResult.php
Normal file
68
php/Mod/Admin/Search/SearchResult.php
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Mod\Admin\Search;
|
||||
|
||||
use Illuminate\Contracts\Support\Arrayable;
|
||||
use JsonSerializable;
|
||||
|
||||
final class SearchResult implements Arrayable, JsonSerializable
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $metadata
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $id,
|
||||
public readonly string $type,
|
||||
public readonly string $title,
|
||||
public readonly string $description,
|
||||
public readonly string $url,
|
||||
public readonly string $icon,
|
||||
public readonly array $metadata = [],
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
return new self(
|
||||
id: (string) ($data['id'] ?? uniqid('search_', true)),
|
||||
type: (string) ($data['type'] ?? 'unknown'),
|
||||
title: (string) ($data['title'] ?? ''),
|
||||
description: (string) ($data['description'] ?? $data['subtitle'] ?? ''),
|
||||
url: (string) ($data['url'] ?? '#'),
|
||||
icon: (string) ($data['icon'] ?? 'document'),
|
||||
metadata: (array) ($data['metadata'] ?? $data['meta'] ?? []),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'type' => $this->type,
|
||||
'title' => $this->title,
|
||||
'description' => $this->description,
|
||||
'subtitle' => $this->description,
|
||||
'url' => $this->url,
|
||||
'icon' => $this->icon,
|
||||
'metadata' => $this->metadata,
|
||||
'meta' => $this->metadata,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return $this->toArray();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
<button
|
||||
type="{{ $type }}"
|
||||
@disabled($disabled)
|
||||
{{ $attributes->merge([
|
||||
'class' => 'inline-flex items-center justify-center gap-2 rounded-lg font-medium transition focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 '.$variantClasses.' '.$sizeClasses,
|
||||
]) }}
|
||||
>
|
||||
@if($loading)
|
||||
<span wire:loading.inline class="h-4 w-4 animate-spin rounded-full border-2 border-current border-r-transparent"></span>
|
||||
@endif
|
||||
|
||||
@if($icon)
|
||||
<span aria-hidden="true">{{ $icon }}</span>
|
||||
@endif
|
||||
|
||||
<span>{{ $slot }}</span>
|
||||
</button>
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<label class="inline-flex items-center gap-3 text-sm text-zinc-900">
|
||||
<input
|
||||
id="{{ $id }}"
|
||||
type="checkbox"
|
||||
@disabled($disabled)
|
||||
@required($required)
|
||||
{{ $attributes->merge([
|
||||
'class' => 'h-4 w-4 rounded border-zinc-300 text-violet-600 focus:ring-violet-500 disabled:bg-zinc-100',
|
||||
]) }}
|
||||
/>
|
||||
|
||||
@if($label)
|
||||
<span>{{ $label }}</span>
|
||||
@endif
|
||||
</label>
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<div {{ $attributes->merge(['class' => 'space-y-2']) }}>
|
||||
@if($label)
|
||||
<div class="text-sm font-medium text-zinc-900">{{ $label }}@if($required) * @endif</div>
|
||||
@endif
|
||||
|
||||
{{ $slot }}
|
||||
|
||||
@if($help)
|
||||
<div class="text-xs text-zinc-500">{{ $help }}</div>
|
||||
@endif
|
||||
|
||||
@if($error)
|
||||
<div class="text-sm text-red-600">{{ $error }}</div>
|
||||
@endif
|
||||
</div>
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<label class="block space-y-2">
|
||||
@if($label)
|
||||
<span class="text-sm font-medium text-zinc-900">{{ $label }}@if($required) * @endif</span>
|
||||
@endif
|
||||
|
||||
<input
|
||||
id="{{ $id }}"
|
||||
type="{{ $type }}"
|
||||
placeholder="{{ $placeholder }}"
|
||||
@disabled($disabled)
|
||||
@readonly($readonly)
|
||||
@required($required)
|
||||
{{ $attributes->merge([
|
||||
'class' => 'w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-200 disabled:bg-zinc-100 disabled:text-zinc-500',
|
||||
]) }}
|
||||
/>
|
||||
|
||||
@if($hint)
|
||||
<span class="text-xs text-zinc-500">{{ $hint }}</span>
|
||||
@endif
|
||||
</label>
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
<label class="block space-y-2">
|
||||
@if($label)
|
||||
<span class="text-sm font-medium text-zinc-900">{{ $label }}@if($required) * @endif</span>
|
||||
@endif
|
||||
|
||||
<select
|
||||
id="{{ $id }}"
|
||||
@disabled($disabled)
|
||||
@required($required)
|
||||
@if($multiple) multiple @endif
|
||||
{{ $attributes->merge([
|
||||
'class' => 'w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-200 disabled:bg-zinc-100 disabled:text-zinc-500',
|
||||
]) }}
|
||||
>
|
||||
@foreach($options as $value => $optionLabel)
|
||||
<option value="{{ $value }}">{{ $optionLabel }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</label>
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<label class="block space-y-2">
|
||||
@if($label)
|
||||
<span class="text-sm font-medium text-zinc-900">{{ $label }}@if($required) * @endif</span>
|
||||
@endif
|
||||
|
||||
<textarea
|
||||
id="{{ $id }}"
|
||||
rows="{{ $rows }}"
|
||||
placeholder="{{ $placeholder }}"
|
||||
@disabled($disabled)
|
||||
@required($required)
|
||||
{{ $attributes->merge([
|
||||
'class' => 'w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-200 disabled:bg-zinc-100 disabled:text-zinc-500',
|
||||
]) }}
|
||||
>{{ $slot }}</textarea>
|
||||
|
||||
@if($hint)
|
||||
<span class="text-xs text-zinc-500">{{ $hint }}</span>
|
||||
@endif
|
||||
</label>
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<label class="inline-flex items-center gap-3 text-sm text-zinc-900">
|
||||
<input
|
||||
id="{{ $id }}"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
@disabled($disabled)
|
||||
{{ $attributes->merge([
|
||||
'class' => 'h-5 w-9 rounded-full border-zinc-300 text-violet-600 focus:ring-violet-500 disabled:bg-zinc-100',
|
||||
]) }}
|
||||
/>
|
||||
|
||||
@if($label)
|
||||
<span>{{ $label }}</span>
|
||||
@endif
|
||||
</label>
|
||||
122
php/Website/Hub/Boot.php
Normal file
122
php/Website/Hub/Boot.php
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Website\Hub;
|
||||
|
||||
use Core\Events\AdminPanelBooting;
|
||||
use Core\Events\DomainResolving;
|
||||
use Core\Mod\Agentic\Mod\Admin\Menu\AdminMenuRegistry;
|
||||
use Core\Mod\Agentic\Mod\Admin\Menu\Contracts\AdminMenuProvider;
|
||||
use Core\Mod\Agentic\Website\Hub\Support\HubRouteNames;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class Boot extends ServiceProvider implements AdminMenuProvider
|
||||
{
|
||||
/**
|
||||
* @var array<string>
|
||||
*/
|
||||
public static array $domains = [
|
||||
'/^core\.(test|localhost)$/',
|
||||
'/^hub\.core\.(test|localhost)$/',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<class-string, string>
|
||||
*/
|
||||
public static array $listens = [
|
||||
DomainResolving::class => 'onDomainResolving',
|
||||
AdminPanelBooting::class => 'onAdminPanel',
|
||||
];
|
||||
|
||||
public function onDomainResolving(DomainResolving $event): void
|
||||
{
|
||||
foreach (static::$domains as $pattern) {
|
||||
if ($event->matches($pattern)) {
|
||||
$event->register(static::class);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function onAdminPanel(AdminPanelBooting $event): void
|
||||
{
|
||||
$event->views('hub', __DIR__.'/View/Blade');
|
||||
$event->translations('hub', dirname(__DIR__, 2).'/Mod/Admin/Lang');
|
||||
$event->livewire('hub.admin.workspace-switcher', View\Modal\Admin\WorkspaceSwitcher::class);
|
||||
$event->livewire('hub.admin.global-search', View\Modal\Admin\GlobalSearch::class);
|
||||
|
||||
app(AdminMenuRegistry::class)->register($this);
|
||||
|
||||
$domain = request()->getHost();
|
||||
$routePrefix = HubRouteNames::prefix($domain);
|
||||
|
||||
$event->routes(fn () => Route::prefix('hub')
|
||||
->name($routePrefix)
|
||||
->domain($domain)
|
||||
->group(__DIR__.'/Routes/admin.php'));
|
||||
}
|
||||
|
||||
public function adminMenuItems(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'group' => 'dashboard',
|
||||
'priority' => 10,
|
||||
'item' => fn (): array => [
|
||||
'label' => 'Dashboard',
|
||||
'icon' => 'house',
|
||||
'href' => HubRouteNames::url('dashboard', fallback: '/hub'),
|
||||
'active' => request()->routeIs(HubRouteNames::name('dashboard')),
|
||||
],
|
||||
],
|
||||
[
|
||||
'group' => 'workspaces',
|
||||
'priority' => 10,
|
||||
'item' => fn (): array => [
|
||||
'label' => 'Workspaces',
|
||||
'icon' => 'folders',
|
||||
'href' => HubRouteNames::url('sites', fallback: '/hub/workspaces'),
|
||||
'active' => request()->routeIs(HubRouteNames::name('sites'))
|
||||
|| request()->routeIs(HubRouteNames::name('sites.settings')),
|
||||
],
|
||||
],
|
||||
[
|
||||
'group' => 'settings',
|
||||
'priority' => 30,
|
||||
'item' => fn (): array => [
|
||||
'label' => 'Usage',
|
||||
'icon' => 'chart-pie',
|
||||
'href' => HubRouteNames::url('account.usage', fallback: '/hub/account/usage'),
|
||||
'active' => request()->routeIs(HubRouteNames::name('account.usage')),
|
||||
],
|
||||
],
|
||||
[
|
||||
'group' => 'admin',
|
||||
'priority' => 10,
|
||||
'admin' => true,
|
||||
'item' => fn (): array => [
|
||||
'label' => 'Platform',
|
||||
'icon' => 'server',
|
||||
'href' => HubRouteNames::url('platform', fallback: '/hub/platform'),
|
||||
'active' => request()->routeIs(HubRouteNames::name('platform'))
|
||||
|| request()->routeIs(HubRouteNames::name('platform.user')),
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function menuPermissions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function canViewMenu(?object $user, ?object $workspace): bool
|
||||
{
|
||||
return $user !== null;
|
||||
}
|
||||
}
|
||||
52
php/Website/Hub/Concerns/HasRateLimiting.php
Normal file
52
php/Website/Hub/Concerns/HasRateLimiting.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Website\Hub\Concerns;
|
||||
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
||||
trait HasRateLimiting
|
||||
{
|
||||
protected function rateLimit(
|
||||
string $action,
|
||||
int $maxAttempts,
|
||||
callable $callback,
|
||||
int $decaySeconds = 60
|
||||
): mixed {
|
||||
$key = sprintf('%s:%s', $action, auth()->id() ?? 'guest');
|
||||
$executed = false;
|
||||
$result = null;
|
||||
|
||||
RateLimiter::attempt($key, $maxAttempts, function () use (&$executed, &$result, $callback) {
|
||||
$executed = true;
|
||||
$result = $callback();
|
||||
}, $decaySeconds);
|
||||
|
||||
if (! $executed) {
|
||||
$this->onRateLimited($action, $key);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
protected function onRateLimited(string $action, string $key): void
|
||||
{
|
||||
$seconds = RateLimiter::availableIn($key);
|
||||
$message = sprintf('Too many %s attempts. Try again in %d seconds.', str_replace('-', ' ', $action), $seconds);
|
||||
|
||||
if (property_exists($this, 'actionMessage')) {
|
||||
$this->actionMessage = $message;
|
||||
} else {
|
||||
session()->flash('warning', $message);
|
||||
}
|
||||
|
||||
if (property_exists($this, 'actionType')) {
|
||||
$this->actionType = 'warning';
|
||||
}
|
||||
}
|
||||
}
|
||||
25
php/Website/Hub/Routes/admin.php
Normal file
25
php/Website/Hub/Routes/admin.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
use Core\Mod\Agentic\Website\Hub\View\Modal\Admin\AccountUsage;
|
||||
use Core\Mod\Agentic\Website\Hub\View\Modal\Admin\Dashboard;
|
||||
use Core\Mod\Agentic\Website\Hub\View\Modal\Admin\Platform;
|
||||
use Core\Mod\Agentic\Website\Hub\View\Modal\Admin\PlatformUser;
|
||||
use Core\Mod\Agentic\Website\Hub\View\Modal\Admin\SiteSettings;
|
||||
use Core\Mod\Agentic\Website\Hub\View\Modal\Admin\Sites;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/', Dashboard::class)->name('dashboard');
|
||||
Route::redirect('/dashboard', '/hub')->name('dashboard.redirect');
|
||||
Route::get('/workspaces', Sites::class)->name('sites');
|
||||
Route::redirect('/sites', '/hub/workspaces');
|
||||
Route::get('/workspaces/{workspace}/{tab?}', SiteSettings::class)
|
||||
->where('tab', 'services|general|deployment|environment|ssl|backups|danger')
|
||||
->name('sites.settings');
|
||||
Route::get('/account/usage', AccountUsage::class)->name('account.usage');
|
||||
Route::redirect('/usage', '/hub/account/usage');
|
||||
Route::redirect('/boosts', '/hub/account/usage?tab=boosts');
|
||||
Route::redirect('/ai-services', '/hub/account/usage?tab=ai');
|
||||
Route::get('/platform', Platform::class)->name('platform');
|
||||
Route::get('/platform/user/{id}', PlatformUser::class)->where('id', '[0-9]+')->name('platform.user');
|
||||
46
php/Website/Hub/Support/HubRouteNames.php
Normal file
46
php/Website/Hub/Support/HubRouteNames.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Website\Hub\Support;
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
final class HubRouteNames
|
||||
{
|
||||
public static function prefix(?string $host = null): string
|
||||
{
|
||||
$host ??= request()->getHost();
|
||||
|
||||
if ($host === null || $host === '') {
|
||||
return 'hub.';
|
||||
}
|
||||
|
||||
if ((bool) preg_match('/^core\.(test|localhost)$/', $host)) {
|
||||
return 'hub.';
|
||||
}
|
||||
|
||||
return str_replace('.', '_', $host).'.';
|
||||
}
|
||||
|
||||
public static function name(string $suffix, ?string $host = null): string
|
||||
{
|
||||
return self::prefix($host).$suffix;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $parameters
|
||||
*/
|
||||
public static function url(string $suffix, array $parameters = [], ?string $fallback = null, ?string $host = null): string
|
||||
{
|
||||
$name = self::name($suffix, $host);
|
||||
|
||||
if (Route::has($name)) {
|
||||
return route($name, $parameters);
|
||||
}
|
||||
|
||||
return $fallback ?? '#';
|
||||
}
|
||||
}
|
||||
48
php/Website/Hub/View/Blade/admin/account-usage.blade.php
Normal file
48
php/Website/Hub/View/Blade/admin/account-usage.blade.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<div class="space-y-6">
|
||||
<div class="rounded-2xl border border-zinc-200 bg-white p-6">
|
||||
<h2 class="text-lg font-semibold">Usage</h2>
|
||||
<p class="mt-1 text-sm text-zinc-600">Storage, API calls, seats and AI-service usage.</p>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
@foreach($this->allowedTabs as $tabKey)
|
||||
<a
|
||||
href="{{ \Core\Mod\Agentic\Website\Hub\Support\HubRouteNames::url('account.usage', ['tab' => $tabKey], '/hub/account/usage?tab='.$tabKey) }}"
|
||||
class="rounded-full px-3 py-1.5 text-sm {{ $tab === $tabKey ? 'bg-violet-100 text-violet-700' : 'bg-zinc-100 text-zinc-700 hover:bg-zinc-200' }}"
|
||||
>
|
||||
{{ ucfirst($tabKey) }}
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="rounded-2xl border border-zinc-200 bg-white p-5">
|
||||
<div class="text-sm text-zinc-500">Storage used</div>
|
||||
<div class="mt-2 text-2xl font-semibold">{{ $this->stats['storage_used'] }}</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-zinc-200 bg-white p-5">
|
||||
<div class="text-sm text-zinc-500">API calls</div>
|
||||
<div class="mt-2 text-2xl font-semibold">{{ number_format((int) $this->stats['api_calls']) }}</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-zinc-200 bg-white p-5">
|
||||
<div class="text-sm text-zinc-500">Seats</div>
|
||||
<div class="mt-2 text-2xl font-semibold">{{ $this->stats['seats'] }}</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-zinc-200 bg-white p-5">
|
||||
<div class="text-sm text-zinc-500">Boosts</div>
|
||||
<div class="mt-2 text-2xl font-semibold">{{ $this->stats['boosts'] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($tab === 'ai')
|
||||
<div class="rounded-2xl border border-zinc-200 bg-white p-6">
|
||||
<h3 class="text-base font-semibold">AI services</h3>
|
||||
<p class="mt-2 text-sm text-zinc-600">The routed AI-services view remains deferred, but the RFC redirect target is live and reserved here.</p>
|
||||
</div>
|
||||
@elseif($tab === 'boosts')
|
||||
<div class="rounded-2xl border border-zinc-200 bg-white p-6">
|
||||
<h3 class="text-base font-semibold">Boosts</h3>
|
||||
<p class="mt-2 text-sm text-zinc-600">Boost purchasing and history are deferred.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
23
php/Website/Hub/View/Blade/admin/dashboard.blade.php
Normal file
23
php/Website/Hub/View/Blade/admin/dashboard.blade.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
@php
|
||||
$cards = [
|
||||
['label' => 'Workspaces', 'body' => 'Switch between workspaces and open the per-site settings surface.', 'href' => \Core\Mod\Agentic\Website\Hub\Support\HubRouteNames::url('sites', fallback: '/hub/workspaces')],
|
||||
['label' => 'Usage', 'body' => 'Review storage, API calls, boosts and AI-service usage.', 'href' => \Core\Mod\Agentic\Website\Hub\Support\HubRouteNames::url('account.usage', fallback: '/hub/account/usage')],
|
||||
['label' => 'Platform', 'body' => 'Hades-only user operations: search, verify and inspect accounts.', 'href' => \Core\Mod\Agentic\Website\Hub\Support\HubRouteNames::url('platform', fallback: '/hub/platform')],
|
||||
];
|
||||
@endphp
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="rounded-2xl border border-zinc-200 bg-white p-6">
|
||||
<h2 class="text-lg font-semibold">Hub foundation</h2>
|
||||
<p class="mt-2 text-sm text-zinc-600">This slice wires the shared Hub shell, search surface, workspace switching and the first load-bearing Livewire pages.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
@foreach($cards as $card)
|
||||
<a href="{{ $card['href'] }}" class="rounded-2xl border border-zinc-200 bg-white p-5 transition hover:border-violet-300 hover:bg-violet-50">
|
||||
<div class="text-base font-semibold">{{ $card['label'] }}</div>
|
||||
<div class="mt-2 text-sm text-zinc-600">{{ $card['body'] }}</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
74
php/Website/Hub/View/Blade/admin/global-search.blade.php
Normal file
74
php/Website/Hub/View/Blade/admin/global-search.blade.php
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<div
|
||||
x-data="{ open: @entangle('open') }"
|
||||
x-show="open"
|
||||
x-cloak
|
||||
class="fixed inset-0 z-50 flex items-start justify-center bg-zinc-950/40 px-4 py-16"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-2xl overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-2xl"
|
||||
@click.outside="$wire.closeSearch()"
|
||||
@keydown.escape.window="$wire.closeSearch()"
|
||||
@keydown.arrow-up.window.prevent="$wire.navigateUp()"
|
||||
@keydown.arrow-down.window.prevent="$wire.navigateDown()"
|
||||
@keydown.enter.window.prevent="$wire.selectCurrent()"
|
||||
>
|
||||
<div class="border-b border-zinc-200 p-4">
|
||||
<input
|
||||
wire:model.live.debounce.200ms="query"
|
||||
type="text"
|
||||
placeholder="Search the Hub"
|
||||
class="w-full border-0 p-0 text-base text-zinc-900 focus:outline-none focus:ring-0"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
@if(strlen($query) >= 2)
|
||||
<div class="max-h-[28rem] overflow-y-auto">
|
||||
@php $currentIndex = 0; @endphp
|
||||
@forelse($this->results as $group)
|
||||
<div class="border-b border-zinc-100 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-zinc-500">
|
||||
{{ $group['label'] }}
|
||||
</div>
|
||||
|
||||
@foreach($group['results'] as $result)
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full border-b border-zinc-100 px-4 py-3 text-left {{ $selectedIndex === $currentIndex ? 'bg-violet-50' : 'hover:bg-zinc-50' }}"
|
||||
wire:click="navigateTo({{ json_encode($result) }})"
|
||||
>
|
||||
<div class="font-medium text-zinc-900">{{ $result['title'] }}</div>
|
||||
@if(!empty($result['subtitle']))
|
||||
<div class="text-sm text-zinc-600">{{ $result['subtitle'] }}</div>
|
||||
@endif
|
||||
</button>
|
||||
@php $currentIndex++; @endphp
|
||||
@endforeach
|
||||
@empty
|
||||
<div class="p-6 text-sm text-zinc-500">No results for “{{ $query }}”.</div>
|
||||
@endforelse
|
||||
</div>
|
||||
@elseif($this->showRecentSearches)
|
||||
<div class="max-h-[24rem] overflow-y-auto">
|
||||
<div class="flex items-center justify-between border-b border-zinc-100 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-zinc-500">
|
||||
<span>Recent searches</span>
|
||||
<button type="button" class="text-zinc-500 hover:text-zinc-900" wire:click="clearRecentSearches">Clear</button>
|
||||
</div>
|
||||
|
||||
@foreach($recentSearches as $index => $recent)
|
||||
<div class="flex items-center justify-between border-b border-zinc-100 px-4 py-3">
|
||||
<button type="button" class="text-left" wire:click="navigateToRecent({{ $index }})">
|
||||
<div class="font-medium text-zinc-900">{{ $recent['title'] }}</div>
|
||||
@if(!empty($recent['subtitle']))
|
||||
<div class="text-sm text-zinc-600">{{ $recent['subtitle'] }}</div>
|
||||
@endif
|
||||
</button>
|
||||
|
||||
<button type="button" class="text-sm text-zinc-500 hover:text-zinc-900" wire:click="removeRecentSearch({{ $index }})">Remove</button>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<div class="p-6 text-sm text-zinc-500">Type at least two characters to search registered Hub providers.</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
112
php/Website/Hub/View/Blade/admin/layouts/app.blade.php
Normal file
112
php/Website/Hub/View/Blade/admin/layouts/app.blade.php
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
@php
|
||||
$hubUser = auth()->user();
|
||||
$hubWorkspace = is_object($hubUser) && method_exists($hubUser, 'defaultHostWorkspace')
|
||||
? $hubUser->defaultHostWorkspace()
|
||||
: null;
|
||||
$menu = app(\Core\Mod\Agentic\Mod\Admin\Menu\AdminMenuRegistry::class)->items($hubUser, $hubWorkspace);
|
||||
@endphp
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>{{ $title ?? 'Hub' }}</title>
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body class="bg-zinc-50 text-zinc-900">
|
||||
<div class="min-h-screen lg:grid lg:grid-cols-[17rem_1fr]">
|
||||
<aside class="border-b border-zinc-200 bg-white p-6 lg:border-b-0 lg:border-r">
|
||||
<div class="mb-6 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-[0.2em] text-zinc-500">Hub</div>
|
||||
<div class="text-lg font-semibold">Admin Panel</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
@if(auth()->check())
|
||||
@livewire(\Core\Mod\Agentic\Website\Hub\View\Modal\Admin\WorkspaceSwitcher::class)
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<nav class="space-y-6">
|
||||
@foreach($menu as $group)
|
||||
<div class="space-y-2">
|
||||
@if(empty($group['meta']['standalone']))
|
||||
<div class="text-xs font-semibold uppercase tracking-[0.2em] text-zinc-500">
|
||||
{{ $group['meta']['label'] ?? 'Menu' }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="space-y-1">
|
||||
@foreach($group['items'] as $item)
|
||||
<a
|
||||
href="{{ $item['href'] ?? '#' }}"
|
||||
class="block rounded-lg px-3 py-2 text-sm {{ !empty($item['active']) ? 'bg-violet-50 text-violet-700' : 'text-zinc-700 hover:bg-zinc-100' }}"
|
||||
>
|
||||
{{ $item['label'] }}
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div class="min-w-0">
|
||||
<header class="border-b border-zinc-200 bg-white px-6 py-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold">{{ $title ?? 'Hub' }}</h1>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-zinc-300 px-3 py-2 text-sm text-zinc-700 hover:bg-zinc-100"
|
||||
onclick="window.Livewire && window.Livewire.dispatch('open-global-search')"
|
||||
>
|
||||
Search
|
||||
<span class="ml-2 rounded bg-zinc-100 px-1.5 py-0.5 text-xs text-zinc-500">Cmd+K</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="p-6">
|
||||
@if(session()->has('warning'))
|
||||
<div class="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
|
||||
{{ session('warning') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{ $slot }}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(auth()->check())
|
||||
@livewire(\Core\Mod\Agentic\Website\Hub\View\Modal\Admin\GlobalSearch::class)
|
||||
@endif
|
||||
|
||||
@livewireScripts
|
||||
<script>
|
||||
document.addEventListener('keydown', function (event) {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
|
||||
event.preventDefault();
|
||||
if (window.Livewire) {
|
||||
window.Livewire.dispatch('open-global-search');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('navigate-to-url', function (event) {
|
||||
if (window.Livewire && typeof window.Livewire.navigate === 'function') {
|
||||
window.Livewire.navigate(event.detail.url);
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = event.detail.url;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
80
php/Website/Hub/View/Blade/admin/platform-user.blade.php
Normal file
80
php/Website/Hub/View/Blade/admin/platform-user.blade.php
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
<div class="space-y-6">
|
||||
@if($actionMessage !== '')
|
||||
<div class="rounded-lg border px-4 py-3 text-sm {{ $actionType === 'success' ? 'border-green-200 bg-green-50 text-green-900' : 'border-amber-200 bg-amber-50 text-amber-900' }}">
|
||||
{{ $actionMessage }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="rounded-2xl border border-zinc-200 bg-white p-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">{{ $userRecord->name }}</h2>
|
||||
<p class="mt-1 text-sm text-zinc-600">{{ $userRecord->email }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@foreach(['overview', 'workspaces', 'security'] as $tab)
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full px-3 py-1.5 text-sm {{ $activeTab === $tab ? 'bg-violet-100 text-violet-700' : 'bg-zinc-100 text-zinc-700 hover:bg-zinc-200' }}"
|
||||
wire:click="setTab('{{ $tab }}')"
|
||||
>
|
||||
{{ ucfirst($tab) }}
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<div class="rounded-2xl border border-zinc-200 bg-white p-6">
|
||||
<h3 class="text-base font-semibold">Tier</h3>
|
||||
<div class="mt-4 space-y-4">
|
||||
<select wire:model.live="editingTier" class="w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm">
|
||||
<option value="free">Free</option>
|
||||
<option value="apollo">Apollo</option>
|
||||
<option value="hades">Hades</option>
|
||||
</select>
|
||||
<button type="button" class="rounded-lg bg-violet-600 px-4 py-2 text-sm font-medium text-white" wire:click="saveTier">Save tier</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-zinc-200 bg-white p-6">
|
||||
<h3 class="text-base font-semibold">Verification</h3>
|
||||
<div class="mt-4 space-y-4">
|
||||
<label class="inline-flex items-center gap-2 text-sm text-zinc-700">
|
||||
<input type="checkbox" wire:model.live="editingVerified">
|
||||
Email verified
|
||||
</label>
|
||||
<button type="button" class="rounded-lg border border-zinc-300 px-4 py-2 text-sm" wire:click="saveVerification">Save verification</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($activeTab === 'workspaces')
|
||||
<div class="rounded-2xl border border-zinc-200 bg-white p-6">
|
||||
<h3 class="text-base font-semibold">Workspaces</h3>
|
||||
<div class="mt-3 text-sm text-zinc-600">
|
||||
@php
|
||||
$workspaces = method_exists($userRecord, 'hostWorkspaces') ? $userRecord->hostWorkspaces : (method_exists($userRecord, 'workspaces') ? $userRecord->workspaces : collect());
|
||||
@endphp
|
||||
@forelse($workspaces as $workspace)
|
||||
<div class="rounded-lg border border-zinc-200 px-4 py-3">
|
||||
<div class="font-medium text-zinc-900">{{ $workspace->name ?? $workspace->slug }}</div>
|
||||
<div class="text-zinc-500">{{ $workspace->slug ?? 'workspace' }}</div>
|
||||
</div>
|
||||
@empty
|
||||
<div>No workspace records available.</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
@elseif($activeTab === 'security')
|
||||
<div class="rounded-2xl border border-zinc-200 bg-white p-6">
|
||||
<h3 class="text-base font-semibold">Security</h3>
|
||||
<div class="mt-3 text-sm text-zinc-600">
|
||||
<div>Created: {{ $userRecord->created_at?->toDayDateTimeString() ?? 'Unknown' }}</div>
|
||||
<div>Verified: {{ $userRecord->email_verified_at?->toDayDateTimeString() ?? 'Not verified' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
76
php/Website/Hub/View/Blade/admin/platform.blade.php
Normal file
76
php/Website/Hub/View/Blade/admin/platform.blade.php
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<div class="space-y-6">
|
||||
<div class="rounded-2xl border border-zinc-200 bg-white p-6">
|
||||
<div class="flex flex-wrap items-end gap-4">
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-zinc-700">Search</span>
|
||||
<input wire:model.live.debounce.250ms="search" type="search" class="mt-2 rounded-lg border border-zinc-300 px-3 py-2 text-sm">
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-zinc-700">Tier</span>
|
||||
<select wire:model.live="tierFilter" class="mt-2 rounded-lg border border-zinc-300 px-3 py-2 text-sm">
|
||||
<option value="">All tiers</option>
|
||||
<option value="free">Free</option>
|
||||
<option value="apollo">Apollo</option>
|
||||
<option value="hades">Hades</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-zinc-700">Verification</span>
|
||||
<select wire:model.live="verifiedFilter" class="mt-2 rounded-lg border border-zinc-300 px-3 py-2 text-sm">
|
||||
<option value="">All users</option>
|
||||
<option value="verified">Verified</option>
|
||||
<option value="unverified">Unverified</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($actionMessage !== '')
|
||||
<div class="rounded-lg border px-4 py-3 text-sm {{ $actionType === 'success' ? 'border-green-200 bg-green-50 text-green-900' : 'border-amber-200 bg-amber-50 text-amber-900' }}">
|
||||
{{ $actionMessage }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-zinc-200 bg-white">
|
||||
<table class="min-w-full divide-y divide-zinc-200 text-sm">
|
||||
<thead class="bg-zinc-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left"><button type="button" wire:click="sortBy('name')">Name</button></th>
|
||||
<th class="px-4 py-3 text-left"><button type="button" wire:click="sortBy('email')">Email</button></th>
|
||||
<th class="px-4 py-3 text-left">Tier</th>
|
||||
<th class="px-4 py-3 text-left">Verified</th>
|
||||
<th class="px-4 py-3 text-left"><button type="button" wire:click="sortBy('created_at')">Created</button></th>
|
||||
<th class="px-4 py-3 text-left">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-zinc-100">
|
||||
@forelse($users as $user)
|
||||
<tr>
|
||||
<td class="px-4 py-3">
|
||||
<a href="{{ \Core\Mod\Agentic\Website\Hub\Support\HubRouteNames::url('platform.user', ['id' => $user->id], '/hub/platform/user/'.$user->id) }}" class="font-medium text-violet-700">
|
||||
{{ $user->name ?? 'Unknown User' }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-zinc-600">{{ $user->email }}</td>
|
||||
<td class="px-4 py-3">{{ is_object($user->tier ?? null) ? $user->tier->value : ($user->tier ?? 'free') }}</td>
|
||||
<td class="px-4 py-3">{{ $user->email_verified_at ? 'Yes' : 'No' }}</td>
|
||||
<td class="px-4 py-3 text-zinc-600">{{ $user->created_at?->diffForHumans() ?? 'Unknown' }}</td>
|
||||
<td class="px-4 py-3">
|
||||
@if(!$user->email_verified_at)
|
||||
<button type="button" class="text-violet-700" wire:click="verifyEmail({{ $user->id }})">Verify</button>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="6" class="px-4 py-8 text-center text-zinc-500">No users matched the current filters.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div>{{ $users->links() }}</div>
|
||||
</div>
|
||||
54
php/Website/Hub/View/Blade/admin/site-settings.blade.php
Normal file
54
php/Website/Hub/View/Blade/admin/site-settings.blade.php
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<div class="space-y-6">
|
||||
<div class="rounded-2xl border border-zinc-200 bg-white p-6">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">{{ $this->workspace?->name ?? $workspaceSlug }}</h2>
|
||||
<p class="mt-1 text-sm text-zinc-600">Per-workspace configuration surface for tabs, deployment and connector settings.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
@foreach($this->tabs as $tabKey => $tabData)
|
||||
<a
|
||||
href="{{ $tabData['href'] }}"
|
||||
class="rounded-full px-3 py-1.5 text-sm {{ $tab === $tabKey ? 'bg-violet-100 text-violet-700' : 'bg-zinc-100 text-zinc-700 hover:bg-zinc-200' }}"
|
||||
>
|
||||
{{ $tabData['label'] }}
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-zinc-200 bg-white p-6">
|
||||
@if($tab === 'services')
|
||||
<h3 class="text-base font-semibold">Services</h3>
|
||||
<p class="mt-2 text-sm text-zinc-600">Service-specific admin pages are deferred. This foundation page keeps the tab routing and workspace authorisation in place.</p>
|
||||
@elseif($tab === 'general')
|
||||
<h3 class="text-base font-semibold">General</h3>
|
||||
<div class="mt-3 space-y-2 text-sm text-zinc-600">
|
||||
<div><span class="font-medium text-zinc-900">Slug:</span> {{ $workspaceSlug }}</div>
|
||||
<div><span class="font-medium text-zinc-900">Domain:</span> {{ $this->workspace?->domain ?? 'Not configured' }}</div>
|
||||
</div>
|
||||
@elseif($tab === 'deployment')
|
||||
<h3 class="text-base font-semibold">Deployment</h3>
|
||||
<p class="mt-2 text-sm text-zinc-600">Deployment history is deferred. This tab exists so the routed workspace settings surface matches the RFC.</p>
|
||||
@elseif($tab === 'environment')
|
||||
<h3 class="text-base font-semibold">Environment</h3>
|
||||
<p class="mt-2 text-sm text-zinc-600">Environment editing is deferred. Wiring remains ready for future Livewire forms.</p>
|
||||
@elseif($tab === 'ssl')
|
||||
<h3 class="text-base font-semibold">SSL & Security</h3>
|
||||
<p class="mt-2 text-sm text-zinc-600">The WordPress connector block below covers the webhook-oriented integration surface from the RFC foundation slice.</p>
|
||||
@if($this->workspace)
|
||||
<div class="mt-6">
|
||||
@livewire(\Core\Mod\Agentic\Website\Hub\View\Modal\Admin\WpConnectorSettings::class, ['workspace' => $this->workspace], key('wp-connector-'.$this->workspace->id))
|
||||
</div>
|
||||
@endif
|
||||
@elseif($tab === 'backups')
|
||||
<h3 class="text-base font-semibold">Backups</h3>
|
||||
<p class="mt-2 text-sm text-zinc-600">Backup management is deferred.</p>
|
||||
@else
|
||||
<h3 class="text-base font-semibold">Danger Zone</h3>
|
||||
<p class="mt-2 text-sm text-zinc-600">Destructive workspace operations are intentionally deferred in this partial delivery.</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
35
php/Website/Hub/View/Blade/admin/sites.blade.php
Normal file
35
php/Website/Hub/View/Blade/admin/sites.blade.php
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<div class="space-y-6">
|
||||
<div class="rounded-2xl border border-zinc-200 bg-white p-6">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">Workspaces</h2>
|
||||
<p class="mt-1 text-sm text-zinc-600">Entry point to the tenant and per-site settings flow.</p>
|
||||
</div>
|
||||
|
||||
<input
|
||||
wire:model.live.debounce.200ms="search"
|
||||
type="search"
|
||||
placeholder="Search workspaces"
|
||||
class="w-full max-w-xs rounded-lg border border-zinc-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
@forelse($this->filteredWorkspaces as $workspace)
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-2xl border border-zinc-200 bg-white p-5 text-left transition hover:border-violet-300 hover:bg-violet-50"
|
||||
wire:click="openWorkspace('{{ $workspace['slug'] }}')"
|
||||
>
|
||||
<div class="text-base font-semibold text-zinc-900">{{ $workspace['name'] }}</div>
|
||||
<div class="mt-1 text-sm text-zinc-600">{{ $workspace['description'] ?? 'Workspace settings and service configuration.' }}</div>
|
||||
<div class="mt-4 text-xs uppercase tracking-[0.2em] text-zinc-500">{{ $workspace['slug'] }}</div>
|
||||
</button>
|
||||
@empty
|
||||
<div class="rounded-2xl border border-dashed border-zinc-300 bg-white p-6 text-sm text-zinc-500">
|
||||
No workspaces matched the current search.
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
<div x-data="{ open: @entangle('open') }" class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded-lg border border-zinc-300 bg-white px-3 py-2 text-left text-sm"
|
||||
@click="open = !open"
|
||||
>
|
||||
<span>
|
||||
<span class="block font-medium text-zinc-900">{{ $current['name'] ?? 'Workspace' }}</span>
|
||||
<span class="block text-xs text-zinc-500">{{ $current['slug'] ?? '' }}</span>
|
||||
</span>
|
||||
<span class="text-zinc-400">▼</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
x-cloak
|
||||
x-show="open"
|
||||
@click.outside="open = false"
|
||||
class="absolute z-20 mt-2 w-full rounded-lg border border-zinc-200 bg-white p-2 shadow-lg"
|
||||
>
|
||||
@forelse($workspaces as $slug => $workspace)
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm hover:bg-zinc-100"
|
||||
wire:click="switchWorkspace('{{ $slug }}')"
|
||||
>
|
||||
<span>
|
||||
<span class="block font-medium text-zinc-900">{{ $workspace['name'] }}</span>
|
||||
<span class="block text-xs text-zinc-500">{{ $workspace['description'] ?? $workspace['slug'] }}</span>
|
||||
</span>
|
||||
@if(($current['slug'] ?? null) === $slug)
|
||||
<span class="text-violet-600">Current</span>
|
||||
@endif
|
||||
</button>
|
||||
@empty
|
||||
<div class="px-3 py-2 text-sm text-zinc-500">No workspaces available.</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
<div class="rounded-2xl border border-zinc-200 bg-zinc-50 p-5">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h4 class="text-base font-semibold">WordPress Connector</h4>
|
||||
<p class="mt-1 text-sm text-zinc-600">Webhook URL, secret and connection checks for the workspace WordPress integration.</p>
|
||||
</div>
|
||||
|
||||
<label class="inline-flex items-center gap-2 text-sm text-zinc-700">
|
||||
<input type="checkbox" wire:model.live="enabled">
|
||||
Enabled
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<label class="block space-y-2 text-sm">
|
||||
<span class="font-medium text-zinc-900">WordPress URL</span>
|
||||
<input wire:model.live="wordpressUrl" type="url" class="w-full rounded-lg border border-zinc-300 px-3 py-2">
|
||||
@error('wordpressUrl')
|
||||
<span class="text-red-600">{{ $message }}</span>
|
||||
@enderror
|
||||
</label>
|
||||
|
||||
<div class="space-y-2 text-sm">
|
||||
<div>
|
||||
<span class="font-medium text-zinc-900">Webhook URL</span>
|
||||
<div class="mt-1 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-zinc-600">{{ $this->webhookUrl ?: 'Generated after save' }}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="font-medium text-zinc-900">Webhook secret</span>
|
||||
<div class="mt-1 rounded-lg border border-zinc-200 bg-white px-3 py-2 font-mono text-xs text-zinc-600">{{ $this->webhookSecret ?: 'Generated after save' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
<button type="button" class="rounded-lg bg-violet-600 px-4 py-2 text-sm font-medium text-white" wire:click="save">Save connector</button>
|
||||
<button type="button" class="rounded-lg border border-zinc-300 px-4 py-2 text-sm" wire:click="regenerateSecret">Regenerate secret</button>
|
||||
<button type="button" class="rounded-lg border border-zinc-300 px-4 py-2 text-sm" wire:click="testConnection" wire:loading.attr="disabled">Test connection</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-3 md:grid-cols-3 text-sm text-zinc-600">
|
||||
<div><span class="font-medium text-zinc-900">Verified:</span> {{ $this->isVerified ? 'Yes' : 'No' }}</div>
|
||||
<div><span class="font-medium text-zinc-900">Last sync:</span> {{ $this->lastSync ?? 'Never' }}</div>
|
||||
<div><span class="font-medium text-zinc-900">Test result:</span> {{ $testResult ?? 'Not run' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
61
php/Website/Hub/View/Modal/Admin/AccountUsage.php
Normal file
61
php/Website/Hub/View/Modal/Admin/AccountUsage.php
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Website\Hub\View\Modal\Admin;
|
||||
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Title('Account Usage')]
|
||||
#[Layout('hub::admin.layouts.app')]
|
||||
class AccountUsage extends Component
|
||||
{
|
||||
#[Url(as: 'tab')]
|
||||
public string $tab = 'overview';
|
||||
|
||||
#[Computed]
|
||||
public function allowedTabs(): array
|
||||
{
|
||||
return ['overview', 'boosts', 'ai'];
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function stats(): array
|
||||
{
|
||||
$stats = auth()->user()?->cached_stats;
|
||||
|
||||
if (! is_array($stats)) {
|
||||
return [
|
||||
'storage_used' => '0 GB',
|
||||
'api_calls' => 0,
|
||||
'seats' => 1,
|
||||
'boosts' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
return array_replace([
|
||||
'storage_used' => '0 GB',
|
||||
'api_calls' => 0,
|
||||
'seats' => 1,
|
||||
'boosts' => 0,
|
||||
], $stats);
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
if (! in_array($this->tab, $this->allowedTabs(), true)) {
|
||||
$this->tab = 'overview';
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('hub::admin.account-usage');
|
||||
}
|
||||
}
|
||||
21
php/Website/Hub/View/Modal/Admin/Dashboard.php
Normal file
21
php/Website/Hub/View/Modal/Admin/Dashboard.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Website\Hub\View\Modal\Admin;
|
||||
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Title('Hub Dashboard')]
|
||||
#[Layout('hub::admin.layouts.app')]
|
||||
class Dashboard extends Component
|
||||
{
|
||||
public function render()
|
||||
{
|
||||
return view('hub::admin.dashboard');
|
||||
}
|
||||
}
|
||||
173
php/Website/Hub/View/Modal/Admin/GlobalSearch.php
Normal file
173
php/Website/Hub/View/Modal/Admin/GlobalSearch.php
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Website\Hub\View\Modal\Admin;
|
||||
|
||||
use Core\Mod\Agentic\Mod\Admin\Search\SearchProviderRegistry;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
class GlobalSearch extends Component
|
||||
{
|
||||
public bool $open = false;
|
||||
|
||||
public string $query = '';
|
||||
|
||||
public int $selectedIndex = 0;
|
||||
|
||||
public array $recentSearches = [];
|
||||
|
||||
protected int $maxRecentSearches = 5;
|
||||
|
||||
protected SearchProviderRegistry $registry;
|
||||
|
||||
public function boot(SearchProviderRegistry $registry): void
|
||||
{
|
||||
$this->registry = $registry;
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->recentSearches = session('global_search.recent', []);
|
||||
}
|
||||
|
||||
#[On('open-global-search')]
|
||||
public function openSearch(): void
|
||||
{
|
||||
$this->open = true;
|
||||
$this->query = '';
|
||||
$this->selectedIndex = 0;
|
||||
}
|
||||
|
||||
public function closeSearch(): void
|
||||
{
|
||||
$this->open = false;
|
||||
$this->query = '';
|
||||
$this->selectedIndex = 0;
|
||||
}
|
||||
|
||||
public function updatedQuery(): void
|
||||
{
|
||||
$this->selectedIndex = 0;
|
||||
}
|
||||
|
||||
public function navigateUp(): void
|
||||
{
|
||||
if ($this->selectedIndex > 0) {
|
||||
$this->selectedIndex--;
|
||||
}
|
||||
}
|
||||
|
||||
public function navigateDown(): void
|
||||
{
|
||||
if ($this->selectedIndex < count($this->flatResults) - 1) {
|
||||
$this->selectedIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
public function selectCurrent(): void
|
||||
{
|
||||
if (isset($this->flatResults[$this->selectedIndex])) {
|
||||
$this->navigateTo($this->flatResults[$this->selectedIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $result
|
||||
*/
|
||||
public function navigateTo(array $result): void
|
||||
{
|
||||
$this->addToRecentSearches($result);
|
||||
$this->closeSearch();
|
||||
$this->dispatch('navigate-to-url', url: $result['url']);
|
||||
}
|
||||
|
||||
public function navigateToRecent(int $index): void
|
||||
{
|
||||
if (! isset($this->recentSearches[$index])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->closeSearch();
|
||||
$this->dispatch('navigate-to-url', url: $this->recentSearches[$index]['url']);
|
||||
}
|
||||
|
||||
public function clearRecentSearches(): void
|
||||
{
|
||||
$this->recentSearches = [];
|
||||
session()->forget('global_search.recent');
|
||||
}
|
||||
|
||||
public function removeRecentSearch(int $index): void
|
||||
{
|
||||
if (! isset($this->recentSearches[$index])) {
|
||||
return;
|
||||
}
|
||||
|
||||
array_splice($this->recentSearches, $index, 1);
|
||||
session(['global_search.recent' => $this->recentSearches]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $result
|
||||
*/
|
||||
protected function addToRecentSearches(array $result): void
|
||||
{
|
||||
$this->recentSearches = array_values(array_filter(
|
||||
$this->recentSearches,
|
||||
static fn (array $recent): bool => $recent['id'] !== $result['id'] || $recent['type'] !== $result['type']
|
||||
));
|
||||
|
||||
array_unshift($this->recentSearches, [
|
||||
'id' => $result['id'],
|
||||
'title' => $result['title'],
|
||||
'subtitle' => $result['subtitle'] ?? '',
|
||||
'url' => $result['url'],
|
||||
'type' => $result['type'],
|
||||
'icon' => $result['icon'],
|
||||
]);
|
||||
|
||||
$this->recentSearches = array_slice($this->recentSearches, 0, $this->maxRecentSearches);
|
||||
session(['global_search.recent' => $this->recentSearches]);
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function results(): array
|
||||
{
|
||||
if (mb_strlen($this->query) < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
$workspace = method_exists($user, 'defaultHostWorkspace') ? $user->defaultHostWorkspace() : null;
|
||||
|
||||
return $this->registry->search($this->query, $user, $workspace);
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function flatResults(): array
|
||||
{
|
||||
return $this->registry->flattenResults($this->results);
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function hasResults(): bool
|
||||
{
|
||||
return $this->flatResults !== [];
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function showRecentSearches(): bool
|
||||
{
|
||||
return mb_strlen($this->query) < 2 && $this->recentSearches !== [];
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('hub::admin.global-search');
|
||||
}
|
||||
}
|
||||
120
php/Website/Hub/View/Modal/Admin/Platform.php
Normal file
120
php/Website/Hub/View/Modal/Admin/Platform.php
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Website\Hub\View\Modal\Admin;
|
||||
|
||||
use Core\Mod\Agentic\Website\Hub\Concerns\HasRateLimiting;
|
||||
use Core\Tenant\Models\User;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
#[Title('Platform')]
|
||||
#[Layout('hub::admin.layouts.app')]
|
||||
class Platform extends Component
|
||||
{
|
||||
use HasRateLimiting;
|
||||
use WithPagination;
|
||||
|
||||
public string $search = '';
|
||||
|
||||
public string $tierFilter = '';
|
||||
|
||||
public string $verifiedFilter = '';
|
||||
|
||||
public string $sortField = 'created_at';
|
||||
|
||||
public string $sortDirection = 'desc';
|
||||
|
||||
public string $actionMessage = '';
|
||||
|
||||
public string $actionType = '';
|
||||
|
||||
protected $queryString = [
|
||||
'search' => ['except' => ''],
|
||||
'tierFilter' => ['except' => ''],
|
||||
'verifiedFilter' => ['except' => ''],
|
||||
];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
if (! auth()->user()?->isHades()) {
|
||||
abort(403);
|
||||
}
|
||||
}
|
||||
|
||||
public function updatingSearch(): void
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function sortBy(string $field): void
|
||||
{
|
||||
if ($this->sortField === $field) {
|
||||
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->sortField = $field;
|
||||
$this->sortDirection = 'asc';
|
||||
}
|
||||
|
||||
public function verifyEmail(int $userId): void
|
||||
{
|
||||
$this->rateLimit('verify-email', 10, function () use ($userId): void {
|
||||
$user = class_exists(User::class) ? User::query()->find($userId) : null;
|
||||
|
||||
if ($user === null || $user->email_verified_at !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (method_exists($user, 'markEmailAsVerified')) {
|
||||
$user->markEmailAsVerified();
|
||||
} else {
|
||||
$user->email_verified_at = now();
|
||||
$user->save();
|
||||
}
|
||||
|
||||
$this->actionMessage = sprintf('Email verified for %s.', $user->email);
|
||||
$this->actionType = 'success';
|
||||
});
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('hub::admin.platform', [
|
||||
'users' => $this->users(),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function users(): LengthAwarePaginator
|
||||
{
|
||||
if (! class_exists(User::class)) {
|
||||
return new LengthAwarePaginator([], 0, 20, $this->getPage());
|
||||
}
|
||||
|
||||
return User::query()
|
||||
->when($this->search !== '', function ($query): void {
|
||||
$query->where(function ($inner): void {
|
||||
$inner->where('name', 'like', '%'.$this->search.'%')
|
||||
->orWhere('email', 'like', '%'.$this->search.'%');
|
||||
});
|
||||
})
|
||||
->when($this->tierFilter !== '', fn ($query) => $query->where('tier', $this->tierFilter))
|
||||
->when($this->verifiedFilter !== '', function ($query): void {
|
||||
if ($this->verifiedFilter === 'verified') {
|
||||
$query->whereNotNull('email_verified_at');
|
||||
} elseif ($this->verifiedFilter === 'unverified') {
|
||||
$query->whereNull('email_verified_at');
|
||||
}
|
||||
})
|
||||
->orderBy($this->sortField, $this->sortDirection)
|
||||
->paginate(20);
|
||||
}
|
||||
}
|
||||
83
php/Website/Hub/View/Modal/Admin/PlatformUser.php
Normal file
83
php/Website/Hub/View/Modal/Admin/PlatformUser.php
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Website\Hub\View\Modal\Admin;
|
||||
|
||||
use Core\Mod\Agentic\Website\Hub\Concerns\HasRateLimiting;
|
||||
use Core\Tenant\Enums\UserTier;
|
||||
use Core\Tenant\Models\User;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Title('Platform User')]
|
||||
#[Layout('hub::admin.layouts.app')]
|
||||
class PlatformUser extends Component
|
||||
{
|
||||
use HasRateLimiting;
|
||||
|
||||
public User $userRecord;
|
||||
|
||||
public string $editingTier = 'free';
|
||||
|
||||
public bool $editingVerified = false;
|
||||
|
||||
public string $actionMessage = '';
|
||||
|
||||
public string $actionType = '';
|
||||
|
||||
public string $activeTab = 'overview';
|
||||
|
||||
public function mount(int $id): void
|
||||
{
|
||||
if (! auth()->user()?->isHades()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$this->userRecord = User::query()->findOrFail($id);
|
||||
$this->editingTier = is_object($this->userRecord->tier)
|
||||
? (string) $this->userRecord->tier->value
|
||||
: (string) ($this->userRecord->tier ?? 'free');
|
||||
$this->editingVerified = $this->userRecord->email_verified_at !== null;
|
||||
}
|
||||
|
||||
public function setTab(string $tab): void
|
||||
{
|
||||
if (in_array($tab, ['overview', 'workspaces', 'security'], true)) {
|
||||
$this->activeTab = $tab;
|
||||
}
|
||||
}
|
||||
|
||||
public function saveTier(): void
|
||||
{
|
||||
$this->rateLimit('tier-change', 10, function (): void {
|
||||
if (class_exists(UserTier::class)) {
|
||||
$this->userRecord->tier = UserTier::from($this->editingTier);
|
||||
} else {
|
||||
$this->userRecord->tier = $this->editingTier;
|
||||
}
|
||||
|
||||
$this->userRecord->save();
|
||||
$this->actionMessage = sprintf('Tier updated to %s.', $this->editingTier);
|
||||
$this->actionType = 'success';
|
||||
});
|
||||
}
|
||||
|
||||
public function saveVerification(): void
|
||||
{
|
||||
$this->rateLimit('verify-user', 10, function (): void {
|
||||
$this->userRecord->email_verified_at = $this->editingVerified ? now() : null;
|
||||
$this->userRecord->save();
|
||||
$this->actionMessage = $this->editingVerified ? 'Email marked as verified.' : 'Email verification removed.';
|
||||
$this->actionType = 'success';
|
||||
});
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('hub::admin.platform-user');
|
||||
}
|
||||
}
|
||||
99
php/Website/Hub/View/Modal/Admin/SiteSettings.php
Normal file
99
php/Website/Hub/View/Modal/Admin/SiteSettings.php
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Website\Hub\View\Modal\Admin;
|
||||
|
||||
use Core\Mod\Agentic\Website\Hub\Support\HubRouteNames;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Title('Site Settings')]
|
||||
#[Layout('hub::admin.layouts.app')]
|
||||
class SiteSettings extends Component
|
||||
{
|
||||
public string $workspaceSlug = '';
|
||||
|
||||
public string $tab = 'services';
|
||||
|
||||
public function mount(string $workspace, ?string $tab = null): void
|
||||
{
|
||||
$this->workspaceSlug = $workspace;
|
||||
|
||||
if ($tab !== null && in_array($tab, $this->allowedTabs(), true)) {
|
||||
$this->tab = $tab;
|
||||
}
|
||||
|
||||
if ($this->workspace === null) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $this->authorisedForWorkspace()) {
|
||||
abort(403);
|
||||
}
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function workspace(): ?Workspace
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if ($user === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (method_exists($user, 'workspaces')) {
|
||||
return $user->workspaces()->where('slug', $this->workspaceSlug)->first();
|
||||
}
|
||||
|
||||
return class_exists(Workspace::class)
|
||||
? Workspace::query()->where('slug', $this->workspaceSlug)->first()
|
||||
: null;
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function tabs(): array
|
||||
{
|
||||
return collect($this->allowedTabs())->mapWithKeys(function (string $tab): array {
|
||||
return [$tab => [
|
||||
'label' => ucfirst($tab),
|
||||
'href' => HubRouteNames::url('sites.settings', ['workspace' => $this->workspaceSlug, 'tab' => $tab], fallback: '/hub/workspaces/'.$this->workspaceSlug.'/'.$tab),
|
||||
]];
|
||||
})->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
protected function allowedTabs(): array
|
||||
{
|
||||
return ['services', 'general', 'deployment', 'environment', 'ssl', 'backups', 'danger'];
|
||||
}
|
||||
|
||||
protected function authorisedForWorkspace(): bool
|
||||
{
|
||||
$workspace = $this->workspace;
|
||||
|
||||
if ($workspace === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (auth()->user()?->isHades()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$role = $workspace->pivot->role ?? null;
|
||||
|
||||
return $role === null || in_array($role, ['owner', 'admin'], true);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('hub::admin.site-settings');
|
||||
}
|
||||
}
|
||||
67
php/Website/Hub/View/Modal/Admin/Sites.php
Normal file
67
php/Website/Hub/View/Modal/Admin/Sites.php
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Website\Hub\View\Modal\Admin;
|
||||
|
||||
use Core\Mod\Agentic\Website\Hub\Support\HubRouteNames;
|
||||
use Core\Tenant\Services\WorkspaceService;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Title('Workspaces')]
|
||||
#[Layout('hub::admin.layouts.app')]
|
||||
class Sites extends Component
|
||||
{
|
||||
public string $search = '';
|
||||
|
||||
protected WorkspaceService $workspaceService;
|
||||
|
||||
public function boot(WorkspaceService $workspaceService): void
|
||||
{
|
||||
$this->workspaceService = $workspaceService;
|
||||
}
|
||||
|
||||
#[On('workspace-changed')]
|
||||
public function refreshWorkspaceList(): void
|
||||
{
|
||||
unset($this->workspaces);
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function workspaces(): array
|
||||
{
|
||||
return $this->workspaceService->all();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function filteredWorkspaces(): array
|
||||
{
|
||||
if ($this->search === '') {
|
||||
return $this->workspaces;
|
||||
}
|
||||
|
||||
$query = mb_strtolower($this->search);
|
||||
|
||||
return array_filter($this->workspaces, static function (array $workspace) use ($query): bool {
|
||||
return str_contains(mb_strtolower((string) ($workspace['name'] ?? '')), $query)
|
||||
|| str_contains(mb_strtolower((string) ($workspace['slug'] ?? '')), $query)
|
||||
|| str_contains(mb_strtolower((string) ($workspace['description'] ?? '')), $query);
|
||||
});
|
||||
}
|
||||
|
||||
public function openWorkspace(string $slug): void
|
||||
{
|
||||
$this->redirect(HubRouteNames::url('sites.settings', ['workspace' => $slug], fallback: '/hub/workspaces/'.$slug), navigate: true);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('hub::admin.sites');
|
||||
}
|
||||
}
|
||||
66
php/Website/Hub/View/Modal/Admin/WorkspaceSwitcher.php
Normal file
66
php/Website/Hub/View/Modal/Admin/WorkspaceSwitcher.php
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Website\Hub\View\Modal\Admin;
|
||||
|
||||
use Core\Mod\Agentic\Website\Hub\Support\HubRouteNames;
|
||||
use Core\Tenant\Services\WorkspaceService;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
class WorkspaceSwitcher extends Component
|
||||
{
|
||||
public array $workspaces = [];
|
||||
|
||||
public array $current = [];
|
||||
|
||||
public bool $open = false;
|
||||
|
||||
public string $returnUrl = '';
|
||||
|
||||
protected WorkspaceService $workspaceService;
|
||||
|
||||
public function boot(WorkspaceService $workspaceService): void
|
||||
{
|
||||
$this->workspaceService = $workspaceService;
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->refreshFromService();
|
||||
$this->returnUrl = url()->current();
|
||||
}
|
||||
|
||||
#[On('workspace-activated')]
|
||||
public function refreshWorkspaces(): void
|
||||
{
|
||||
$this->refreshFromService();
|
||||
}
|
||||
|
||||
public function switchWorkspace(string $slug): void
|
||||
{
|
||||
if (! $this->workspaceService->setCurrent($slug)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->refreshFromService();
|
||||
$this->open = false;
|
||||
|
||||
$this->dispatch('workspace-changed', workspace: $slug);
|
||||
$this->redirect($this->returnUrl !== '' ? $this->returnUrl : HubRouteNames::url('dashboard', fallback: '/hub'));
|
||||
}
|
||||
|
||||
protected function refreshFromService(): void
|
||||
{
|
||||
$this->workspaces = $this->workspaceService->all();
|
||||
$this->current = $this->workspaceService->current();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('hub::admin.workspace-switcher');
|
||||
}
|
||||
}
|
||||
137
php/Website/Hub/View/Modal/Admin/WpConnectorSettings.php
Normal file
137
php/Website/Hub/View/Modal/Admin/WpConnectorSettings.php
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Website\Hub\View\Modal\Admin;
|
||||
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
|
||||
class WpConnectorSettings extends Component
|
||||
{
|
||||
public Workspace $workspace;
|
||||
|
||||
public bool $enabled = false;
|
||||
|
||||
public string $wordpressUrl = '';
|
||||
|
||||
public bool $testing = false;
|
||||
|
||||
public ?string $testResult = null;
|
||||
|
||||
public bool $testSuccess = false;
|
||||
|
||||
public function mount(Workspace $workspace): void
|
||||
{
|
||||
$this->workspace = $workspace;
|
||||
$this->enabled = (bool) ($workspace->wp_connector_enabled ?? false);
|
||||
$this->wordpressUrl = (string) ($workspace->wp_connector_url ?? '');
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function webhookUrl(): string
|
||||
{
|
||||
return (string) ($this->workspace->wp_connector_webhook_url ?? '');
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function webhookSecret(): string
|
||||
{
|
||||
return (string) ($this->workspace->wp_connector_secret ?? '');
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function isVerified(): bool
|
||||
{
|
||||
return $this->workspace->wp_connector_verified_at !== null;
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function lastSync(): ?string
|
||||
{
|
||||
return $this->workspace->wp_connector_last_sync?->diffForHumans();
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$this->validate([
|
||||
'wordpressUrl' => ['nullable', 'url'],
|
||||
]);
|
||||
|
||||
if ($this->enabled && $this->wordpressUrl === '') {
|
||||
$this->addError('wordpressUrl', 'WordPress URL is required when the connector is enabled.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->enabled && method_exists($this->workspace, 'enableWpConnector')) {
|
||||
$this->workspace->enableWpConnector($this->wordpressUrl);
|
||||
} elseif (! $this->enabled && method_exists($this->workspace, 'disableWpConnector')) {
|
||||
$this->workspace->disableWpConnector();
|
||||
} else {
|
||||
$this->workspace->wp_connector_enabled = $this->enabled;
|
||||
$this->workspace->wp_connector_url = $this->enabled ? $this->wordpressUrl : null;
|
||||
|
||||
if ($this->enabled && empty($this->workspace->wp_connector_secret)) {
|
||||
$this->workspace->wp_connector_secret = Str::random(40);
|
||||
}
|
||||
|
||||
$this->workspace->save();
|
||||
}
|
||||
|
||||
$this->workspace->refresh();
|
||||
$this->dispatch('notify', message: 'WordPress connector updated.');
|
||||
}
|
||||
|
||||
public function regenerateSecret(): void
|
||||
{
|
||||
if (method_exists($this->workspace, 'generateWpConnectorSecret')) {
|
||||
$this->workspace->generateWpConnectorSecret();
|
||||
} else {
|
||||
$this->workspace->wp_connector_secret = Str::random(40);
|
||||
$this->workspace->save();
|
||||
}
|
||||
|
||||
$this->workspace->refresh();
|
||||
$this->dispatch('notify', message: 'Webhook secret regenerated.');
|
||||
}
|
||||
|
||||
public function testConnection(): void
|
||||
{
|
||||
$this->testing = true;
|
||||
$this->testSuccess = false;
|
||||
$this->testResult = null;
|
||||
|
||||
if ($this->wordpressUrl === '') {
|
||||
$this->testResult = 'WordPress URL is not configured.';
|
||||
$this->testing = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::timeout(10)->get(rtrim($this->wordpressUrl, '/').'/wp-json/wp/v2');
|
||||
|
||||
if ($response->successful()) {
|
||||
$this->testSuccess = true;
|
||||
$this->testResult = 'Connected to the WordPress REST API.';
|
||||
} else {
|
||||
$this->testResult = 'WordPress returned HTTP '.$response->status().'.';
|
||||
}
|
||||
} catch (\Throwable $throwable) {
|
||||
$this->testResult = 'Connection failed: '.$throwable->getMessage();
|
||||
}
|
||||
|
||||
$this->testing = false;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('hub::admin.wp-connector-settings');
|
||||
}
|
||||
}
|
||||
79
php/tests/Feature/Mod/Admin/AdminMenuRegistryTest.php
Normal file
79
php/tests/Feature/Mod/Admin/AdminMenuRegistryTest.php
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Tests\Feature\Mod\Admin;
|
||||
|
||||
use Core\Mod\Agentic\Mod\Admin\Menu\AdminMenuRegistry;
|
||||
use Core\Mod\Agentic\Mod\Admin\Menu\Contracts\AdminMenuProvider;
|
||||
use Tests\Fixtures\HadesUser;
|
||||
|
||||
class AdminMenuRegistryTest extends AdminTestCase
|
||||
{
|
||||
public function test_AdminMenuRegistry_items_Good_groups_and_orders_menu_items(): void
|
||||
{
|
||||
$registry = new AdminMenuRegistry;
|
||||
$registry->register($this->makeProvider());
|
||||
|
||||
$items = $registry->items($this->hadesUser);
|
||||
|
||||
$this->assertArrayHasKey('dashboard', $items);
|
||||
$this->assertArrayHasKey('admin', $items);
|
||||
$this->assertSame('Dashboard', $items['dashboard']['items'][0]['label']);
|
||||
$this->assertSame('Platform', $items['admin']['items'][0]['label']);
|
||||
}
|
||||
|
||||
public function test_AdminMenuRegistry_items_Bad_hides_admin_items_for_non_hades_users(): void
|
||||
{
|
||||
$registry = new AdminMenuRegistry;
|
||||
$registry->register($this->makeProvider());
|
||||
|
||||
$user = new class extends HadesUser
|
||||
{
|
||||
public function isHades(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
$items = $registry->items($user);
|
||||
|
||||
$this->assertArrayHasKey('dashboard', $items);
|
||||
$this->assertArrayNotHasKey('admin', $items);
|
||||
}
|
||||
|
||||
protected function makeProvider(): AdminMenuProvider
|
||||
{
|
||||
return new class implements AdminMenuProvider
|
||||
{
|
||||
public function adminMenuItems(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'group' => 'dashboard',
|
||||
'priority' => 10,
|
||||
'item' => fn (): array => ['label' => 'Dashboard', 'href' => '/hub'],
|
||||
],
|
||||
[
|
||||
'group' => 'admin',
|
||||
'priority' => 20,
|
||||
'admin' => true,
|
||||
'item' => fn (): array => ['label' => 'Platform', 'href' => '/hub/platform'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function menuPermissions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function canViewMenu(?object $user, ?object $workspace): bool
|
||||
{
|
||||
return $user !== null;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
39
php/tests/Feature/Mod/Admin/AdminTestCase.php
Normal file
39
php/tests/Feature/Mod/Admin/AdminTestCase.php
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Tests\Feature\Mod\Admin;
|
||||
|
||||
use Core\Mod\Agentic\Tests\Feature\Livewire\LivewireTestCase;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Livewire\Livewire;
|
||||
|
||||
abstract class AdminTestCase extends LivewireTestCase
|
||||
{
|
||||
protected function getPackageProviders($app): array
|
||||
{
|
||||
return array_merge(parent::getPackageProviders($app), [
|
||||
\Core\Mod\Agentic\Mod\Admin\Boot::class,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$basePath = dirname(__DIR__, 4);
|
||||
$this->app['view']->addNamespace('hub', $basePath.'/Website/Hub/View/Blade');
|
||||
|
||||
if (! Route::has('hub.dashboard')) {
|
||||
Route::middleware('web')
|
||||
->prefix('hub')
|
||||
->name('hub.')
|
||||
->group($basePath.'/Website/Hub/Routes/admin.php');
|
||||
}
|
||||
|
||||
Livewire::component('hub.admin.workspace-switcher', \Core\Mod\Agentic\Website\Hub\View\Modal\Admin\WorkspaceSwitcher::class);
|
||||
Livewire::component('hub.admin.global-search', \Core\Mod\Agentic\Website\Hub\View\Modal\Admin\GlobalSearch::class);
|
||||
}
|
||||
}
|
||||
63
php/tests/Feature/Mod/Admin/HasRateLimitingTest.php
Normal file
63
php/tests/Feature/Mod/Admin/HasRateLimitingTest.php
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Tests\Feature\Mod\Admin;
|
||||
|
||||
use Core\Mod\Agentic\Website\Hub\Concerns\HasRateLimiting;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
||||
class HasRateLimitingTest extends AdminTestCase
|
||||
{
|
||||
public function test_HasRateLimiting_rateLimit_Good_executes_callback_before_limit(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$component = new class
|
||||
{
|
||||
use HasRateLimiting;
|
||||
|
||||
public string $actionMessage = '';
|
||||
|
||||
public string $actionType = '';
|
||||
|
||||
public function attempt(): mixed
|
||||
{
|
||||
return $this->rateLimit('platform-test', 2, static fn (): string => 'ok');
|
||||
}
|
||||
};
|
||||
|
||||
RateLimiter::clear('platform-test:1');
|
||||
|
||||
$this->assertSame('ok', $component->attempt());
|
||||
$this->assertSame('ok', $component->attempt());
|
||||
}
|
||||
|
||||
public function test_HasRateLimiting_rateLimit_Bad_sets_warning_state_when_limited(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$component = new class
|
||||
{
|
||||
use HasRateLimiting;
|
||||
|
||||
public string $actionMessage = '';
|
||||
|
||||
public string $actionType = '';
|
||||
|
||||
public function attempt(): mixed
|
||||
{
|
||||
return $this->rateLimit('platform-test', 1, static fn (): string => 'ok');
|
||||
}
|
||||
};
|
||||
|
||||
RateLimiter::clear('platform-test:1');
|
||||
$component->attempt();
|
||||
|
||||
$this->assertNull($component->attempt());
|
||||
$this->assertSame('warning', $component->actionType);
|
||||
$this->assertStringContainsString('Too many platform test attempts', $component->actionMessage);
|
||||
}
|
||||
}
|
||||
57
php/tests/Feature/Mod/Admin/HubBootTest.php
Normal file
57
php/tests/Feature/Mod/Admin/HubBootTest.php
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Tests\Feature\Mod\Admin;
|
||||
|
||||
use Core\Events\AdminPanelBooting;
|
||||
use Core\Events\DomainResolving;
|
||||
use Core\Mod\Agentic\Mod\Admin\Menu\AdminMenuRegistry;
|
||||
use Core\Mod\Agentic\Website\Hub\Boot;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
class HubBootTest extends AdminTestCase
|
||||
{
|
||||
public function test_HubBoot_onDomainResolving_Good_registers_matching_domains(): void
|
||||
{
|
||||
$boot = new Boot($this->app);
|
||||
$event = new DomainResolving('hub.core.test');
|
||||
|
||||
$boot->onDomainResolving($event);
|
||||
|
||||
$this->assertSame(Boot::class, $event->matchedProvider());
|
||||
}
|
||||
|
||||
public function test_HubBoot_onDomainResolving_Bad_ignores_non_matching_domains(): void
|
||||
{
|
||||
$boot = new Boot($this->app);
|
||||
$event = new DomainResolving('example.com');
|
||||
|
||||
$boot->onDomainResolving($event);
|
||||
|
||||
$this->assertNull($event->matchedProvider());
|
||||
}
|
||||
|
||||
public function test_HubBoot_onAdminPanel_Good_registers_global_components_and_secondary_routes(): void
|
||||
{
|
||||
$boot = new Boot($this->app);
|
||||
$event = new AdminPanelBooting;
|
||||
|
||||
$this->app->instance('request', Request::create('http://hub.core.test/hub'));
|
||||
$boot->onAdminPanel($event);
|
||||
|
||||
$this->assertCount(1, $event->viewRequests());
|
||||
$this->assertCount(1, $event->translationRequests());
|
||||
$this->assertCount(2, $event->livewireRequests());
|
||||
$this->assertCount(1, $event->routeRequests());
|
||||
$this->assertCount(1, app(AdminMenuRegistry::class)->providers());
|
||||
|
||||
($event->routeRequests()[0])();
|
||||
|
||||
$this->assertTrue(Route::has('hub_core_test.dashboard'));
|
||||
$this->assertTrue(Route::has('hub_core_test.platform.user'));
|
||||
}
|
||||
}
|
||||
67
php/tests/Feature/Mod/Admin/SearchProviderRegistryTest.php
Normal file
67
php/tests/Feature/Mod/Admin/SearchProviderRegistryTest.php
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Tests\Feature\Mod\Admin;
|
||||
|
||||
use Core\Mod\Agentic\Mod\Admin\Search\Contracts\SearchProvider;
|
||||
use Core\Mod\Agentic\Mod\Admin\Search\SearchProviderRegistry;
|
||||
use Core\Mod\Agentic\Mod\Admin\Search\SearchResult;
|
||||
|
||||
class SearchProviderRegistryTest extends AdminTestCase
|
||||
{
|
||||
public function test_SearchProviderRegistry_register_Good(): void
|
||||
{
|
||||
$registry = new SearchProviderRegistry;
|
||||
$registry->register($this->makeProvider());
|
||||
|
||||
$this->assertCount(1, $registry->providers());
|
||||
}
|
||||
|
||||
public function test_SearchProviderRegistry_search_Good_groups_results(): void
|
||||
{
|
||||
$registry = new SearchProviderRegistry;
|
||||
$registry->register($this->makeProvider());
|
||||
|
||||
$results = $registry->search('dash', $this->hadesUser);
|
||||
|
||||
$this->assertArrayHasKey('pages', $results);
|
||||
$this->assertSame('Pages', $results['pages']['label']);
|
||||
$this->assertCount(2, $results['pages']['results']);
|
||||
}
|
||||
|
||||
public function test_SearchProviderRegistry_fuzzyMatch_Ugly_handles_abbreviations(): void
|
||||
{
|
||||
$registry = new SearchProviderRegistry;
|
||||
|
||||
$this->assertTrue($registry->fuzzyMatch('gs', 'Global Search'));
|
||||
$this->assertTrue($registry->fuzzyMatch('dbd', 'dashboard'));
|
||||
$this->assertFalse($registry->fuzzyMatch('zzz', 'dashboard'));
|
||||
}
|
||||
|
||||
protected function makeProvider(): SearchProvider
|
||||
{
|
||||
return new class implements SearchProvider
|
||||
{
|
||||
public function search(string $query): array
|
||||
{
|
||||
return [
|
||||
new SearchResult('1', 'admin_page', 'Dashboard', 'Hub landing page', '/hub', 'house'),
|
||||
new SearchResult('2', 'admin_page', 'Usage', 'Usage page', '/hub/account/usage', 'chart-pie'),
|
||||
];
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'Pages';
|
||||
}
|
||||
|
||||
public function icon(): string
|
||||
{
|
||||
return 'rectangle-stack';
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
69
php/tests/Feature/Mod/Admin/WorkspaceSwitcherTest.php
Normal file
69
php/tests/Feature/Mod/Admin/WorkspaceSwitcherTest.php
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Tests\Feature\Mod\Admin;
|
||||
|
||||
use Core\Mod\Agentic\Website\Hub\View\Modal\Admin\WorkspaceSwitcher;
|
||||
use Core\Tenant\Services\WorkspaceService;
|
||||
use Illuminate\Http\Request;
|
||||
use Livewire\Livewire;
|
||||
|
||||
class WorkspaceSwitcherTest extends AdminTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->app->instance('request', Request::create('http://core.test/hub/workspaces'));
|
||||
$this->app->instance(WorkspaceService::class, new class extends WorkspaceService
|
||||
{
|
||||
protected string $slug = 'alpha';
|
||||
|
||||
public function all(): array
|
||||
{
|
||||
return [
|
||||
'alpha' => ['name' => 'Alpha', 'slug' => 'alpha', 'description' => 'Default workspace'],
|
||||
'beta' => ['name' => 'Beta', 'slug' => 'beta', 'description' => 'Secondary workspace'],
|
||||
];
|
||||
}
|
||||
|
||||
public function current(): array
|
||||
{
|
||||
return $this->all()[$this->slug];
|
||||
}
|
||||
|
||||
public function setCurrent(string $slug): bool
|
||||
{
|
||||
if (! isset($this->all()[$slug])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->slug = $slug;
|
||||
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function test_WorkspaceSwitcher_mount_Good_captures_return_url(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(WorkspaceSwitcher::class)
|
||||
->assertSet('returnUrl', 'http://core.test/hub/workspaces')
|
||||
->assertSet('current.slug', 'alpha');
|
||||
}
|
||||
|
||||
public function test_WorkspaceSwitcher_switchWorkspace_Good_dispatches_event_and_redirects_back(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(WorkspaceSwitcher::class)
|
||||
->call('switchWorkspace', 'beta')
|
||||
->assertDispatched('workspace-changed', workspace: 'beta')
|
||||
->assertRedirect('http://core.test/hub/workspaces');
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue