refactor: consolidate migrations and clean up core packages
- Remove old incremental migrations (now consolidated into create_* files) - Clean up cached view files - Various fixes across core-api, core-mcp, core-php packages Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
1f1c8d0496
commit
65dd9af950
80 changed files with 3415 additions and 3474 deletions
|
|
@ -1,5 +1,7 @@
|
|||
# Core PHP Framework
|
||||
|
||||
|
||||
|
||||
A modular monolith framework for Laravel with event-driven architecture and lazy module loading.
|
||||
|
||||
## Installation
|
||||
|
|
|
|||
145
packages/core-admin/TODO.md
Normal file
145
packages/core-admin/TODO.md
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
# Core-Admin TODO
|
||||
|
||||
## Form Authorization Components
|
||||
|
||||
**Priority:** Medium
|
||||
**Context:** Authorization checks scattered throughout views with `@can` directives.
|
||||
|
||||
### Solution
|
||||
|
||||
Build authorization into form components themselves.
|
||||
|
||||
### Implementation
|
||||
|
||||
```blade
|
||||
{{-- resources/views/components/forms/input.blade.php --}}
|
||||
@props([
|
||||
'id',
|
||||
'label' => null,
|
||||
'canGate' => null,
|
||||
'canResource' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
$disabled = $attributes->get('disabled', false);
|
||||
if ($canGate && $canResource && !$disabled) {
|
||||
$disabled = !auth()->user()?->can($canGate, $canResource);
|
||||
}
|
||||
@endphp
|
||||
|
||||
<input {{ $attributes->merge(['disabled' => $disabled]) }} />
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```blade
|
||||
<x-forms.input canGate="update" :canResource="$biolink" id="name" label="Name" />
|
||||
<x-forms.button canGate="update" :canResource="$biolink">Save</x-forms.button>
|
||||
```
|
||||
|
||||
### Components to Create
|
||||
|
||||
- `input.blade.php`
|
||||
- `textarea.blade.php`
|
||||
- `select.blade.php`
|
||||
- `checkbox.blade.php`
|
||||
- `button.blade.php`
|
||||
- `toggle.blade.php`
|
||||
|
||||
---
|
||||
|
||||
## Global Search (⌘K)
|
||||
|
||||
**Priority:** Medium
|
||||
**Context:** No unified search across resources.
|
||||
|
||||
### Implementation
|
||||
|
||||
```php
|
||||
class GlobalSearch extends Component
|
||||
{
|
||||
public bool $open = false;
|
||||
public string $query = '';
|
||||
public array $results = [];
|
||||
|
||||
public function updatedQuery()
|
||||
{
|
||||
if (strlen($this->query) < 2) return;
|
||||
|
||||
$this->results = $this->searchProviders();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Features
|
||||
|
||||
- ⌘K keyboard shortcut
|
||||
- Arrow key navigation
|
||||
- Enter to select
|
||||
- Module-provided search providers
|
||||
- Recent searches
|
||||
|
||||
### Requirements
|
||||
|
||||
- `SearchProvider` interface for modules to implement
|
||||
- Auto-discover providers from registered modules
|
||||
- Fuzzy matching support
|
||||
|
||||
---
|
||||
|
||||
## Real-time WebSocket (Soketi)
|
||||
|
||||
**Priority:** Low
|
||||
**Context:** No real-time updates. Users must refresh.
|
||||
|
||||
### Implementation
|
||||
|
||||
Self-hosted Soketi + Laravel Echo.
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
soketi:
|
||||
image: 'quay.io/soketi/soketi:latest'
|
||||
ports:
|
||||
- '6001:6001'
|
||||
```
|
||||
|
||||
```php
|
||||
// Broadcasting events
|
||||
broadcast(new ResourceUpdated($resource));
|
||||
|
||||
// Livewire listening
|
||||
protected function getListeners()
|
||||
{
|
||||
return [
|
||||
"echo-private:workspace.{$this->workspace->id},resource.updated" => 'refresh',
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### Notes
|
||||
|
||||
- DO NOT route through Bunny CDN (charges per connection)
|
||||
- Use private channels for workspace-scoped events
|
||||
|
||||
---
|
||||
|
||||
## Enhanced Form Components
|
||||
|
||||
**Priority:** Medium
|
||||
**Context:** Extend Flux Pro components with additional features.
|
||||
|
||||
### Features to Add
|
||||
|
||||
- Dark mode consistency
|
||||
- Automatic error display
|
||||
- Helper text support
|
||||
- Disabled states from authorization
|
||||
- `instantSave` for real-time persistence
|
||||
|
||||
### Components
|
||||
|
||||
```blade
|
||||
<x-forms.input id="name" label="Name" helper="Enter a display name" />
|
||||
<x-forms.toggle id="is_public" label="Public" instantSave />
|
||||
```
|
||||
89
packages/core-api/TODO.md
Normal file
89
packages/core-api/TODO.md
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
# Core-API TODO
|
||||
|
||||
## Webhook Signing (Outbound)
|
||||
|
||||
**Priority:** Medium
|
||||
**Context:** No request signing for outbound webhooks. Recipients cannot verify requests came from our platform.
|
||||
|
||||
### Implementation
|
||||
|
||||
```php
|
||||
// When sending webhooks
|
||||
$payload = json_encode($data);
|
||||
$signature = hash_hmac('sha256', $payload, $webhookSecret);
|
||||
|
||||
$response = Http::withHeaders([
|
||||
'X-Signature' => $signature,
|
||||
'X-Timestamp' => now()->timestamp,
|
||||
])->post($url, $data);
|
||||
```
|
||||
|
||||
### Requirements
|
||||
|
||||
- Generate per-endpoint webhook secrets
|
||||
- Sign all outbound webhook requests
|
||||
- Include timestamp to prevent replay attacks
|
||||
- Document verification for recipients
|
||||
|
||||
---
|
||||
|
||||
## OpenAPI/Swagger Documentation
|
||||
|
||||
**Priority:** Low
|
||||
**Context:** No auto-generated API documentation.
|
||||
|
||||
### Options
|
||||
|
||||
1. **dedoc/scramble** - Auto-generates from routes/controllers
|
||||
2. **darkaonline/l5-swagger** - Annotation-based
|
||||
3. **Custom** - Generate from route definitions
|
||||
|
||||
### Requirements
|
||||
|
||||
- Auto-discover API routes from modules
|
||||
- Support module-specific doc sections
|
||||
- Serve at `/api/docs` endpoint
|
||||
- Include authentication examples
|
||||
|
||||
---
|
||||
|
||||
## API Key Security
|
||||
|
||||
**Priority:** Medium (Security)
|
||||
**Context:** API keys use SHA-256 without salt.
|
||||
|
||||
### Current
|
||||
|
||||
```php
|
||||
$hashedKey = hash('sha256', $rawKey);
|
||||
```
|
||||
|
||||
### Recommended
|
||||
|
||||
```php
|
||||
// Use Argon2 or bcrypt
|
||||
$hashedKey = Hash::make($rawKey);
|
||||
|
||||
// Verify
|
||||
Hash::check($providedKey, $storedHash);
|
||||
```
|
||||
|
||||
### Notes
|
||||
|
||||
- Migration needed for existing keys
|
||||
- Consider key rotation mechanism
|
||||
- Add key scopes/permissions
|
||||
|
||||
---
|
||||
|
||||
## Rate Limiting Improvements
|
||||
|
||||
**Priority:** Medium
|
||||
**Context:** Basic rate limiting exists but needs granularity.
|
||||
|
||||
### Requirements
|
||||
|
||||
- Per-endpoint rate limits
|
||||
- Per-workspace rate limits
|
||||
- Burst allowance configuration
|
||||
- Rate limit headers in responses
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<?php
|
||||
|
||||
namespace Core\Mod\Api\Controllers\Concerns;
|
||||
namespace Core\Mod\Api\Concerns;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Api\Controllers\Concerns;
|
||||
namespace Core\Mod\Api\Concerns;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Core\Mod\Tenant\Models\User;
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Api\Middleware;
|
||||
namespace Mod\Api\Middleware;
|
||||
|
||||
use Core\Mod\Api\Models\ApiKey;
|
||||
use Mod\Api\Models\ApiKey;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Api\Middleware;
|
||||
namespace Mod\Api\Middleware;
|
||||
|
||||
use Core\Mod\Api\Models\ApiKey;
|
||||
use Mod\Api\Models\ApiKey;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mod\Api\Middleware;
|
||||
namespace Core\Mod\Api\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Api\Middleware;
|
||||
|
||||
use Core\Mod\Api\Models\ApiKey;
|
||||
use Mod\Api\Models\ApiKey;
|
||||
use Closure;
|
||||
use Illuminate\Cache\RateLimiter;
|
||||
use Illuminate\Http\Request;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mod\Api\Middleware;
|
||||
namespace Core\Mod\Api\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
|
|
|
|||
|
|
@ -23,6 +23,11 @@ class Boot extends ServiceProvider
|
|||
|
||||
protected function registerRoutes(): void
|
||||
{
|
||||
// Skip domain binding during console commands (no request available)
|
||||
if ($this->app->runningInConsole()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Route::middleware('web')
|
||||
->domain(request()->getHost())
|
||||
->group(__DIR__.'/Routes/web.php');
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Website\Api\Controllers;
|
||||
|
||||
use Core\Website\Api\Services\OpenApiGenerator;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\View\View;
|
||||
use Website\Api\Services\OpenApiGenerator;
|
||||
|
||||
class DocsController
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Core\Website\Api\Controllers\DocsController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Website\Api\Controllers\DocsController;
|
||||
|
||||
// Documentation landing
|
||||
Route::get('/', [DocsController::class, 'index'])->name('api.docs');
|
||||
|
|
@ -29,5 +29,7 @@ Route::get('/scalar', [DocsController::class, 'scalar'])->name('api.scalar');
|
|||
// ReDoc (three-panel API reference)
|
||||
Route::get('/redoc', [DocsController::class, 'redoc'])->name('api.redoc');
|
||||
|
||||
// OpenAPI spec
|
||||
Route::get('/openapi.json', [DocsController::class, 'openapi'])->name('api.openapi.json');
|
||||
// OpenAPI spec (rate limited - expensive to generate)
|
||||
Route::get('/openapi.json', [DocsController::class, 'openapi'])
|
||||
->middleware('throttle:60,1')
|
||||
->name('api.openapi.json');
|
||||
|
|
|
|||
|
|
@ -5,15 +5,46 @@ declare(strict_types=1);
|
|||
namespace Core\Website\Api\Services;
|
||||
|
||||
use Illuminate\Routing\Route;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Route as RouteFacade;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class OpenApiGenerator
|
||||
{
|
||||
/**
|
||||
* Cache duration in seconds (1 hour in production, 0 in local).
|
||||
*/
|
||||
protected function getCacheDuration(): int
|
||||
{
|
||||
return app()->isProduction() ? 3600 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate OpenAPI 3.0 specification from Laravel routes.
|
||||
*/
|
||||
public function generate(): array
|
||||
{
|
||||
$duration = $this->getCacheDuration();
|
||||
|
||||
if ($duration === 0) {
|
||||
return $this->buildSpec();
|
||||
}
|
||||
|
||||
return Cache::remember('openapi:spec', $duration, fn () => $this->buildSpec());
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the cached OpenAPI spec.
|
||||
*/
|
||||
public function clearCache(): void
|
||||
{
|
||||
Cache::forget('openapi:spec');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full OpenAPI specification.
|
||||
*/
|
||||
protected function buildSpec(): array
|
||||
{
|
||||
return [
|
||||
'openapi' => '3.0.0',
|
||||
|
|
@ -28,10 +59,14 @@ class OpenApiGenerator
|
|||
protected function buildInfo(): array
|
||||
{
|
||||
return [
|
||||
'title' => config('app.name', 'API'),
|
||||
'description' => config('core.api.description', 'API Documentation'),
|
||||
'version' => config('core.api.version', '1.0.0'),
|
||||
'contact' => config('core.api.contact', []),
|
||||
'title' => config('app.name').' API',
|
||||
'description' => 'Unified API for Host UK services including commerce, analytics, push notifications, support, and MCP.',
|
||||
'version' => config('api.version', '1.0.0'),
|
||||
'contact' => [
|
||||
'name' => config('app.name').' Support',
|
||||
'url' => config('app.url').'/contact',
|
||||
'email' => config('mail.from.address', 'support@host.uk.com'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -47,7 +82,24 @@ class OpenApiGenerator
|
|||
|
||||
protected function buildTags(): array
|
||||
{
|
||||
return config('core.api.tags', []);
|
||||
return [
|
||||
['name' => 'Analytics', 'description' => 'Website analytics and tracking'],
|
||||
['name' => 'Bio', 'description' => 'Bio link pages, blocks, and QR codes'],
|
||||
['name' => 'Chat Widget', 'description' => 'Public chat widget API'],
|
||||
['name' => 'Commerce', 'description' => 'Billing, orders, invoices, subscriptions, and provisioning'],
|
||||
['name' => 'Content', 'description' => 'AI content generation and briefs'],
|
||||
['name' => 'Entitlements', 'description' => 'Feature entitlements and usage'],
|
||||
['name' => 'MCP', 'description' => 'Model Context Protocol HTTP bridge'],
|
||||
['name' => 'Notify', 'description' => 'Push notification management'],
|
||||
['name' => 'Pixel', 'description' => 'Unified pixel tracking'],
|
||||
['name' => 'SEO', 'description' => 'SEO report and analysis endpoints'],
|
||||
['name' => 'Social', 'description' => 'Social media management'],
|
||||
['name' => 'Support', 'description' => 'Helpdesk API'],
|
||||
['name' => 'Tenant', 'description' => 'Workspaces and multi-tenancy'],
|
||||
['name' => 'Trees', 'description' => 'Trees for Agents statistics'],
|
||||
['name' => 'Trust', 'description' => 'Social proof widgets'],
|
||||
['name' => 'Webhooks', 'description' => 'Incoming webhook endpoints for external services'],
|
||||
];
|
||||
}
|
||||
|
||||
protected function buildPaths(): array
|
||||
|
|
@ -148,8 +200,33 @@ class OpenApiGenerator
|
|||
$uri = $route->uri();
|
||||
$name = $route->getName() ?? '';
|
||||
|
||||
// Match by route name prefix - configurable
|
||||
$tagMap = config('core.api.tag_map.routes', []);
|
||||
// Match by route name prefix
|
||||
$tagMap = [
|
||||
'api.webhook' => 'Webhooks',
|
||||
'api.trees' => 'Trees',
|
||||
'api.seo' => 'SEO',
|
||||
'api.pixel' => 'Pixel',
|
||||
'api.commerce' => 'Commerce',
|
||||
'api.entitlements' => 'Entitlements',
|
||||
'api.support.chat' => 'Chat Widget',
|
||||
'api.support' => 'Support',
|
||||
'api.mcp' => 'MCP',
|
||||
'api.social' => 'Social',
|
||||
'api.notify' => 'Notify',
|
||||
'api.bio' => 'Bio',
|
||||
'api.blocks' => 'Bio',
|
||||
'api.shortlinks' => 'Bio',
|
||||
'api.qr' => 'Bio',
|
||||
'api.workspaces' => 'Tenant',
|
||||
'api.key.workspaces' => 'Tenant',
|
||||
'api.key.bio' => 'Bio',
|
||||
'api.key.blocks' => 'Bio',
|
||||
'api.key.shortlinks' => 'Bio',
|
||||
'api.key.qr' => 'Bio',
|
||||
'api.content' => 'Content',
|
||||
'api.key.content' => 'Content',
|
||||
'api.trust' => 'Trust',
|
||||
];
|
||||
|
||||
foreach ($tagMap as $prefix => $tag) {
|
||||
if (str_starts_with($name, $prefix)) {
|
||||
|
|
@ -159,7 +236,27 @@ class OpenApiGenerator
|
|||
|
||||
// Match by URI prefix (check start of path after 'api/')
|
||||
$path = preg_replace('#^api/#', '', $uri);
|
||||
$uriTagMap = config('core.api.tag_map.uris', []);
|
||||
$uriTagMap = [
|
||||
'webhooks' => 'Webhooks',
|
||||
'trees' => 'Trees',
|
||||
'pixel' => 'Pixel',
|
||||
'provisioning' => 'Commerce',
|
||||
'commerce' => 'Commerce',
|
||||
'entitlements' => 'Entitlements',
|
||||
'support/chat' => 'Chat Widget',
|
||||
'support' => 'Support',
|
||||
'mcp' => 'MCP',
|
||||
'bio' => 'Bio',
|
||||
'shortlinks' => 'Bio',
|
||||
'qr' => 'Bio',
|
||||
'blocks' => 'Bio',
|
||||
'workspaces' => 'Tenant',
|
||||
'analytics' => 'Analytics',
|
||||
'social' => 'Social',
|
||||
'trust' => 'Trust',
|
||||
'notify' => 'Notify',
|
||||
'content' => 'Content',
|
||||
];
|
||||
|
||||
foreach ($uriTagMap as $prefix => $tag) {
|
||||
if (str_starts_with($path, $prefix)) {
|
||||
|
|
@ -216,8 +313,12 @@ class OpenApiGenerator
|
|||
return [['bearerAuth' => []]];
|
||||
}
|
||||
|
||||
if (in_array('commerce.api', $middleware)) {
|
||||
return [['apiKeyAuth' => []]];
|
||||
}
|
||||
|
||||
foreach ($middleware as $m) {
|
||||
if (str_contains($m, 'ApiKey')) {
|
||||
if (str_contains($m, 'McpApiKeyAuth')) {
|
||||
return [['apiKeyAuth' => []]];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
90
packages/core-mcp/TODO.md
Normal file
90
packages/core-mcp/TODO.md
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
# Core-MCP TODO
|
||||
|
||||
## MCP Playground UI
|
||||
|
||||
**Priority:** Low
|
||||
**Context:** Interactive UI for testing MCP tools.
|
||||
|
||||
### Requirements
|
||||
|
||||
- Tool browser with documentation
|
||||
- Input form builder from tool schemas
|
||||
- Response viewer with formatting
|
||||
- Session/conversation persistence
|
||||
- Example prompts per tool
|
||||
|
||||
---
|
||||
|
||||
## Workspace Context Security
|
||||
|
||||
**Priority:** High (Security)
|
||||
**Context:** MCP falls back to `workspace_id = 1` when no context provided.
|
||||
|
||||
### Current Issue
|
||||
|
||||
```php
|
||||
// Dangerous fallback
|
||||
$workspaceId = $context->workspaceId ?? 1;
|
||||
```
|
||||
|
||||
### Solution
|
||||
|
||||
```php
|
||||
// Throw instead of fallback
|
||||
if (!$context->workspaceId) {
|
||||
throw new MissingWorkspaceContextException(
|
||||
'MCP tool requires workspace context'
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Requirements
|
||||
|
||||
- Remove all hardcoded workspace fallbacks
|
||||
- Require explicit workspace context for all workspace-scoped tools
|
||||
- Add context validation middleware
|
||||
- Audit all tools for proper scoping
|
||||
|
||||
---
|
||||
|
||||
## Tool Usage Analytics
|
||||
|
||||
**Priority:** Low
|
||||
**Context:** Track tool usage patterns for optimisation.
|
||||
|
||||
### Requirements
|
||||
|
||||
- Per-tool call counts
|
||||
- Average response times
|
||||
- Error rates by tool
|
||||
- Popular tool combinations
|
||||
- Dashboard in admin
|
||||
|
||||
---
|
||||
|
||||
## Query Security
|
||||
|
||||
**Priority:** Critical (Security)
|
||||
**Context:** QueryDatabase tool regex check bypassed by UNION/stacked queries.
|
||||
|
||||
### Current Issue
|
||||
|
||||
Regex-based SQL validation is insufficient.
|
||||
|
||||
### Solution
|
||||
|
||||
1. **Read-only database user** - Primary defence
|
||||
2. **Query whitelist** - Only allow specific query patterns
|
||||
3. **Parameterised views** - Expose data through views, not raw queries
|
||||
|
||||
### Implementation
|
||||
|
||||
```php
|
||||
// Use read-only connection
|
||||
DB::connection('readonly')->select($query);
|
||||
|
||||
// Or whitelist approach
|
||||
if (!$this->isWhitelistedQuery($query)) {
|
||||
throw new ForbiddenQueryException();
|
||||
}
|
||||
```
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mod\Mcp\Console\Commands;
|
||||
namespace Core\Mod\Mcp\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Mod\Mcp\Models\McpApiRequest;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mod\Mcp\Console\Commands;
|
||||
namespace Core\Mod\Mcp\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Mod\Mcp\Services\McpMetricsService;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mod\Mcp\Services;
|
||||
namespace Core\Mod\Mcp\Services;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mod\Mcp\Services;
|
||||
namespace Core\Mod\Mcp\Services;
|
||||
|
||||
/**
|
||||
* Data Redactor - redacts sensitive information from tool call logs.
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mod\Mcp\Services;
|
||||
namespace Core\Mod\Mcp\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ namespace Core\Website\Mcp\Controllers;
|
|||
use Core\Front\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Mod\Mcp\Models\McpToolCall;
|
||||
use Mod\Mcp\Services\OpenApiGenerator;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
/**
|
||||
|
|
@ -149,6 +151,119 @@ class McpRegistryController extends Controller
|
|||
return view('mcp::web.connect', [
|
||||
'servers' => $servers,
|
||||
'templates' => $registry['connection_templates'] ?? [],
|
||||
'workspace' => $request->attributes->get('mcp_workspace'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard: /dashboard
|
||||
*
|
||||
* Shows MCP usage for the authenticated workspace.
|
||||
*/
|
||||
public function dashboard(Request $request)
|
||||
{
|
||||
$workspace = $request->attributes->get('mcp_workspace');
|
||||
$entitlement = $request->attributes->get('mcp_entitlement');
|
||||
|
||||
// Get tool call stats for this workspace
|
||||
$stats = $this->getWorkspaceStats($workspace);
|
||||
|
||||
return view('mcp::web.dashboard', [
|
||||
'workspace' => $workspace,
|
||||
'entitlement' => $entitlement,
|
||||
'stats' => $stats,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* API Keys management: /keys
|
||||
*
|
||||
* Manage API keys for MCP access.
|
||||
*/
|
||||
public function keys(Request $request)
|
||||
{
|
||||
$workspace = $request->attributes->get('mcp_workspace');
|
||||
|
||||
return view('mcp::web.keys', [
|
||||
'workspace' => $workspace,
|
||||
'keys' => $workspace->apiKeys ?? collect(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MCP usage stats for a workspace.
|
||||
*/
|
||||
protected function getWorkspaceStats($workspace): array
|
||||
{
|
||||
$since = now()->subDays(30);
|
||||
|
||||
// Use aggregate queries instead of loading all records into memory
|
||||
$baseQuery = McpToolCall::where('created_at', '>=', $since);
|
||||
|
||||
if ($workspace) {
|
||||
$baseQuery->where('workspace_id', $workspace->id);
|
||||
}
|
||||
|
||||
$totalCalls = (clone $baseQuery)->count();
|
||||
$successfulCalls = (clone $baseQuery)->where('success', true)->count();
|
||||
|
||||
$byServer = (clone $baseQuery)
|
||||
->selectRaw('server_id, COUNT(*) as count')
|
||||
->groupBy('server_id')
|
||||
->orderByDesc('count')
|
||||
->limit(5)
|
||||
->pluck('count', 'server_id')
|
||||
->all();
|
||||
|
||||
$byDay = (clone $baseQuery)
|
||||
->selectRaw('DATE(created_at) as date, COUNT(*) as count')
|
||||
->groupBy('date')
|
||||
->orderBy('date')
|
||||
->pluck('count', 'date')
|
||||
->all();
|
||||
|
||||
return [
|
||||
'total_calls' => $totalCalls,
|
||||
'successful_calls' => $successfulCalls,
|
||||
'by_server' => $byServer,
|
||||
'by_day' => $byDay,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage analytics endpoint: /servers/{id}/analytics
|
||||
*
|
||||
* Shows tool usage stats for a specific server.
|
||||
*/
|
||||
public function analytics(Request $request, string $id)
|
||||
{
|
||||
$server = $this->loadServerFull($id);
|
||||
|
||||
if (! $server) {
|
||||
if ($this->wantsJson($request)) {
|
||||
return response()->json(['error' => 'Server not found'], 404);
|
||||
}
|
||||
abort(404, 'Server not found');
|
||||
}
|
||||
|
||||
// Validate days parameter - bound to reasonable range
|
||||
$days = min(max($request->integer('days', 7), 1), 90);
|
||||
|
||||
// Get tool call stats for this server
|
||||
$stats = $this->getServerAnalytics($id, $days);
|
||||
|
||||
if ($this->wantsJson($request)) {
|
||||
return response()->json([
|
||||
'server_id' => $id,
|
||||
'period_days' => $days,
|
||||
'stats' => $stats,
|
||||
]);
|
||||
}
|
||||
|
||||
return view('mcp::web.analytics', [
|
||||
'server' => $server,
|
||||
'stats' => $stats,
|
||||
'days' => $days,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -159,24 +274,80 @@ class McpRegistryController extends Controller
|
|||
*/
|
||||
public function openapi(Request $request)
|
||||
{
|
||||
$generator = new OpenApiGenerator;
|
||||
$format = $request->query('format', 'json');
|
||||
|
||||
// Return empty spec for now - implement OpenApiGenerator if needed
|
||||
$spec = [
|
||||
'openapi' => '3.0.0',
|
||||
'info' => [
|
||||
'title' => 'MCP API',
|
||||
'version' => '1.0.0',
|
||||
],
|
||||
'paths' => [],
|
||||
];
|
||||
|
||||
if ($format === 'yaml' || str_ends_with($request->path(), '.yaml')) {
|
||||
return response(Yaml::dump($spec, 10))
|
||||
return response($generator->toYaml())
|
||||
->header('Content-Type', 'application/x-yaml');
|
||||
}
|
||||
|
||||
return response()->json($spec);
|
||||
return response()->json($generator->generate());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get analytics for a specific server.
|
||||
*/
|
||||
protected function getServerAnalytics(string $serverId, int $days = 7): array
|
||||
{
|
||||
$since = now()->subDays($days);
|
||||
|
||||
$baseQuery = McpToolCall::forServer($serverId)
|
||||
->where('created_at', '>=', $since);
|
||||
|
||||
// Get aggregate stats without loading all records into memory
|
||||
$totalCalls = (clone $baseQuery)->count();
|
||||
$successfulCalls = (clone $baseQuery)->where('success', true)->count();
|
||||
$failedCalls = $totalCalls - $successfulCalls;
|
||||
$avgDuration = (clone $baseQuery)->avg('duration_ms') ?? 0;
|
||||
|
||||
// Tool breakdown with aggregates
|
||||
$byTool = (clone $baseQuery)
|
||||
->selectRaw('tool_name, COUNT(*) as calls, SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as success_count, AVG(duration_ms) as avg_duration')
|
||||
->groupBy('tool_name')
|
||||
->orderByDesc('calls')
|
||||
->limit(10)
|
||||
->get()
|
||||
->mapWithKeys(fn ($row) => [
|
||||
$row->tool_name => [
|
||||
'calls' => (int) $row->calls,
|
||||
'success_rate' => $row->calls > 0
|
||||
? round($row->success_count / $row->calls * 100, 1)
|
||||
: 0,
|
||||
'avg_duration_ms' => round($row->avg_duration ?? 0),
|
||||
],
|
||||
])
|
||||
->all();
|
||||
|
||||
// Daily breakdown
|
||||
$byDay = (clone $baseQuery)
|
||||
->selectRaw('DATE(created_at) as date, COUNT(*) as count')
|
||||
->groupBy('date')
|
||||
->orderBy('date')
|
||||
->pluck('count', 'date')
|
||||
->all();
|
||||
|
||||
// Error breakdown
|
||||
$errors = (clone $baseQuery)
|
||||
->where('success', false)
|
||||
->whereNotNull('error_code')
|
||||
->selectRaw('error_code, COUNT(*) as count')
|
||||
->groupBy('error_code')
|
||||
->orderByDesc('count')
|
||||
->limit(5)
|
||||
->pluck('count', 'error_code')
|
||||
->all();
|
||||
|
||||
return [
|
||||
'total_calls' => $totalCalls,
|
||||
'successful_calls' => $successfulCalls,
|
||||
'failed_calls' => $failedCalls,
|
||||
'success_rate' => $totalCalls > 0 ? round($successfulCalls / $totalCalls * 100, 1) : 0,
|
||||
'avg_duration_ms' => round($avgDuration),
|
||||
'by_tool' => $byTool,
|
||||
'by_day' => $byDay,
|
||||
'errors' => $errors,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -200,6 +371,14 @@ class McpRegistryController extends Controller
|
|||
*/
|
||||
protected function loadServerYaml(string $id): ?array
|
||||
{
|
||||
// Sanitise server ID to prevent path traversal attacks
|
||||
$id = basename($id, '.yaml');
|
||||
|
||||
// Validate ID format (alphanumeric with hyphens only)
|
||||
if (! preg_match('/^[a-z0-9-]+$/', $id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Cache::remember("mcp:server:{$id}", $this->getCacheTtl(), function () use ($id) {
|
||||
$path = resource_path("mcp/servers/{$id}.yaml");
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
<?php
|
||||
|
||||
use Core\Website\Mcp\Controllers\McpRegistryController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Mod\Mcp\Middleware\McpAuthenticate;
|
||||
use Website\Mcp\Controllers\McpRegistryController;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
@ -22,19 +23,23 @@ Route::domain($mcpDomain)->name('mcp.')->group(function () {
|
|||
|
||||
// Landing page
|
||||
Route::get('/', [McpRegistryController::class, 'landing'])
|
||||
->middleware(McpAuthenticate::class.':optional')
|
||||
->name('landing');
|
||||
|
||||
// Server list (HTML/JSON based on Accept header)
|
||||
Route::get('servers', [McpRegistryController::class, 'index'])
|
||||
->middleware(McpAuthenticate::class.':optional')
|
||||
->name('servers.index');
|
||||
|
||||
// Server detail (supports .json extension)
|
||||
Route::get('servers/{id}', [McpRegistryController::class, 'show'])
|
||||
->middleware(McpAuthenticate::class.':optional')
|
||||
->name('servers.show')
|
||||
->where('id', '[a-z0-9-]+(?:\.json)?');
|
||||
|
||||
// Connection config page
|
||||
Route::get('connect', [McpRegistryController::class, 'connect'])
|
||||
->middleware(McpAuthenticate::class.':optional')
|
||||
->name('connect');
|
||||
|
||||
// OpenAPI spec
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||
namespace Core\Website\Mcp\View\Modal;
|
||||
|
||||
use Livewire\Component;
|
||||
use Core\Mod\Api\Services\ApiSnippetService;
|
||||
|
||||
/**
|
||||
* Interactive API Explorer
|
||||
|
|
@ -20,7 +21,7 @@ class ApiExplorer extends Component
|
|||
|
||||
public string $selectedLanguage = 'curl';
|
||||
|
||||
public string $baseUrl = 'https://api.host.uk.com';
|
||||
public string $baseUrl = '';
|
||||
|
||||
// Request configuration
|
||||
public string $method = 'GET';
|
||||
|
|
@ -54,10 +55,63 @@ class ApiExplorer extends Component
|
|||
'description' => 'Create a new workspace',
|
||||
'body' => ['name' => 'My Workspace', 'description' => 'A new workspace'],
|
||||
],
|
||||
[
|
||||
'name' => 'Get Workspace',
|
||||
'method' => 'GET',
|
||||
'path' => '/api/v1/workspaces/{id}',
|
||||
'description' => 'Get a specific workspace by ID',
|
||||
'body' => null,
|
||||
],
|
||||
[
|
||||
'name' => 'List Bio Links',
|
||||
'method' => 'GET',
|
||||
'path' => '/api/v1/biolinks',
|
||||
'description' => 'Get all bio links in the workspace',
|
||||
'body' => null,
|
||||
],
|
||||
[
|
||||
'name' => 'Create Bio Link',
|
||||
'method' => 'POST',
|
||||
'path' => '/api/v1/biolinks',
|
||||
'description' => 'Create a new bio link page',
|
||||
'body' => ['title' => 'My Links', 'slug' => 'mylinks', 'theme' => 'default'],
|
||||
],
|
||||
[
|
||||
'name' => 'List Short Links',
|
||||
'method' => 'GET',
|
||||
'path' => '/api/v1/links',
|
||||
'description' => 'Get all short links',
|
||||
'body' => null,
|
||||
],
|
||||
[
|
||||
'name' => 'Create Short Link',
|
||||
'method' => 'POST',
|
||||
'path' => '/api/v1/links',
|
||||
'description' => 'Create a new short link',
|
||||
'body' => ['url' => 'https://example.com', 'slug' => 'example'],
|
||||
],
|
||||
[
|
||||
'name' => 'Get Analytics',
|
||||
'method' => 'GET',
|
||||
'path' => '/api/v1/analytics/summary',
|
||||
'description' => 'Get analytics summary for the workspace',
|
||||
'body' => null,
|
||||
],
|
||||
];
|
||||
|
||||
protected ApiSnippetService $snippetService;
|
||||
|
||||
public function boot(ApiSnippetService $snippetService): void
|
||||
{
|
||||
$this->snippetService = $snippetService;
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
// Set base URL from config
|
||||
$this->baseUrl = config('api.base_url', config('app.url'));
|
||||
|
||||
// Pre-select first endpoint
|
||||
if (! empty($this->endpoints)) {
|
||||
$this->selectEndpoint(0);
|
||||
}
|
||||
|
|
@ -82,57 +136,135 @@ class ApiExplorer extends Component
|
|||
|
||||
public function getCodeSnippet(): string
|
||||
{
|
||||
$key = $this->apiKey ?: 'YOUR_API_KEY';
|
||||
$url = rtrim($this->baseUrl, '/').'/'.ltrim($this->path, '/');
|
||||
|
||||
return match ($this->selectedLanguage) {
|
||||
'curl' => $this->getCurlSnippet($url, $key),
|
||||
'php' => $this->getPhpSnippet($url, $key),
|
||||
'javascript' => $this->getJsSnippet($url, $key),
|
||||
default => $this->getCurlSnippet($url, $key),
|
||||
};
|
||||
}
|
||||
|
||||
protected function getCurlSnippet(string $url, string $key): string
|
||||
{
|
||||
$cmd = "curl -X {$this->method} \"{$url}\" \\\n";
|
||||
$cmd .= " -H \"Authorization: Bearer {$key}\" \\\n";
|
||||
$cmd .= " -H \"Content-Type: application/json\" \\\n";
|
||||
$cmd .= " -H \"Accept: application/json\"";
|
||||
$headers = [
|
||||
'Authorization' => 'Bearer '.($this->apiKey ?: 'YOUR_API_KEY'),
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json',
|
||||
];
|
||||
|
||||
$body = null;
|
||||
if (in_array($this->method, ['POST', 'PUT', 'PATCH']) && $this->bodyJson !== '{}') {
|
||||
$cmd .= " \\\n -d '{$this->bodyJson}'";
|
||||
$body = json_decode($this->bodyJson, true);
|
||||
}
|
||||
|
||||
return $cmd;
|
||||
return $this->snippetService->generate(
|
||||
$this->selectedLanguage,
|
||||
$this->method,
|
||||
$this->path,
|
||||
$headers,
|
||||
$body,
|
||||
$this->baseUrl
|
||||
);
|
||||
}
|
||||
|
||||
protected function getPhpSnippet(string $url, string $key): string
|
||||
public function getAllSnippets(): array
|
||||
{
|
||||
return <<<PHP
|
||||
\$response = Http::withToken('{$key}')
|
||||
->acceptJson()
|
||||
->{$this->method}('{$url}');
|
||||
PHP;
|
||||
$headers = [
|
||||
'Authorization' => 'Bearer '.($this->apiKey ?: 'YOUR_API_KEY'),
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json',
|
||||
];
|
||||
|
||||
$body = null;
|
||||
if (in_array($this->method, ['POST', 'PUT', 'PATCH']) && $this->bodyJson !== '{}') {
|
||||
$body = json_decode($this->bodyJson, true);
|
||||
}
|
||||
|
||||
return $this->snippetService->generateAll(
|
||||
$this->method,
|
||||
$this->path,
|
||||
$headers,
|
||||
$body,
|
||||
$this->baseUrl
|
||||
);
|
||||
}
|
||||
|
||||
protected function getJsSnippet(string $url, string $key): string
|
||||
public function copyToClipboard(): void
|
||||
{
|
||||
return <<<JS
|
||||
const response = await fetch('{$url}', {
|
||||
method: '{$this->method}',
|
||||
headers: {
|
||||
'Authorization': 'Bearer {$key}',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
JS;
|
||||
$this->dispatch('copy-to-clipboard', code: $this->getCodeSnippet());
|
||||
}
|
||||
|
||||
public function sendRequest(): void
|
||||
{
|
||||
if (empty($this->apiKey)) {
|
||||
$this->error = 'Please enter your API key to send requests';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->isLoading = true;
|
||||
$this->response = null;
|
||||
$this->error = null;
|
||||
|
||||
try {
|
||||
$startTime = microtime(true);
|
||||
|
||||
$url = rtrim($this->baseUrl, '/').'/'.ltrim($this->path, '/');
|
||||
|
||||
$options = [
|
||||
'http' => [
|
||||
'method' => $this->method,
|
||||
'header' => [
|
||||
"Authorization: Bearer {$this->apiKey}",
|
||||
'Content-Type: application/json',
|
||||
'Accept: application/json',
|
||||
],
|
||||
'timeout' => 30,
|
||||
'ignore_errors' => true,
|
||||
],
|
||||
];
|
||||
|
||||
if (in_array($this->method, ['POST', 'PUT', 'PATCH']) && $this->bodyJson !== '{}') {
|
||||
$options['http']['content'] = $this->bodyJson;
|
||||
}
|
||||
|
||||
$context = stream_context_create($options);
|
||||
$result = @file_get_contents($url, false, $context);
|
||||
|
||||
$this->responseTime = (int) round((microtime(true) - $startTime) * 1000);
|
||||
|
||||
if ($result === false) {
|
||||
$this->error = 'Request failed - check your API key and endpoint';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse response headers
|
||||
$statusCode = 200;
|
||||
if (isset($http_response_header[0])) {
|
||||
preg_match('/HTTP\/\d+\.?\d* (\d+)/', $http_response_header[0], $matches);
|
||||
$statusCode = (int) ($matches[1] ?? 200);
|
||||
}
|
||||
|
||||
$this->response = [
|
||||
'status' => $statusCode,
|
||||
'body' => json_decode($result, true) ?? $result,
|
||||
'headers' => $http_response_header ?? [],
|
||||
];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->error = $e->getMessage();
|
||||
} finally {
|
||||
$this->isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
public function formatBody(): void
|
||||
{
|
||||
try {
|
||||
$decoded = json_decode($this->bodyJson, true);
|
||||
if (json_last_error() === JSON_ERROR_NONE) {
|
||||
$this->bodyJson = json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('mcp::web.api-explorer', [
|
||||
'languages' => ['curl', 'php', 'javascript'],
|
||||
'languages' => ApiSnippetService::getLanguages(),
|
||||
'snippet' => $this->getCodeSnippet(),
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ declare(strict_types=1);
|
|||
namespace Core\Website\Mcp\View\Modal;
|
||||
|
||||
use Livewire\Component;
|
||||
use Core\Mod\Api\Models\ApiKey;
|
||||
use Mod\Tenant\Models\Workspace;
|
||||
|
||||
/**
|
||||
* MCP API Key Manager.
|
||||
|
|
@ -14,6 +16,8 @@ use Livewire\Component;
|
|||
*/
|
||||
class ApiKeyManager extends Component
|
||||
{
|
||||
public Workspace $workspace;
|
||||
|
||||
// Create form state
|
||||
public bool $showCreateModal = false;
|
||||
|
||||
|
|
@ -28,6 +32,11 @@ class ApiKeyManager extends Component
|
|||
|
||||
public bool $showNewKeyModal = false;
|
||||
|
||||
public function mount(Workspace $workspace): void
|
||||
{
|
||||
$this->workspace = $workspace;
|
||||
}
|
||||
|
||||
public function openCreateModal(): void
|
||||
{
|
||||
$this->showCreateModal = true;
|
||||
|
|
@ -47,10 +56,22 @@ class ApiKeyManager extends Component
|
|||
'newKeyName' => 'required|string|max:100',
|
||||
]);
|
||||
|
||||
// Implement key creation in your application
|
||||
// $result = ApiKey::generate(...);
|
||||
// $this->newPlainKey = $result['plain_key'];
|
||||
$expiresAt = match ($this->newKeyExpiry) {
|
||||
'30days' => now()->addDays(30),
|
||||
'90days' => now()->addDays(90),
|
||||
'1year' => now()->addYear(),
|
||||
default => null,
|
||||
};
|
||||
|
||||
$result = ApiKey::generate(
|
||||
workspaceId: $this->workspace->id,
|
||||
userId: auth()->id(),
|
||||
name: $this->newKeyName,
|
||||
scopes: $this->newKeyScopes,
|
||||
expiresAt: $expiresAt,
|
||||
);
|
||||
|
||||
$this->newPlainKey = $result['plain_key'];
|
||||
$this->showCreateModal = false;
|
||||
$this->showNewKeyModal = true;
|
||||
|
||||
|
|
@ -63,6 +84,14 @@ class ApiKeyManager extends Component
|
|||
$this->showNewKeyModal = false;
|
||||
}
|
||||
|
||||
public function revokeKey(int $keyId): void
|
||||
{
|
||||
$key = $this->workspace->apiKeys()->findOrFail($keyId);
|
||||
$key->revoke();
|
||||
|
||||
session()->flash('message', 'API key revoked.');
|
||||
}
|
||||
|
||||
public function toggleScope(string $scope): void
|
||||
{
|
||||
if (in_array($scope, $this->newKeyScopes)) {
|
||||
|
|
@ -75,7 +104,7 @@ class ApiKeyManager extends Component
|
|||
public function render()
|
||||
{
|
||||
return view('mcp::web.api-key-manager', [
|
||||
'keys' => collect(), // Override to provide real keys
|
||||
'keys' => $this->workspace->apiKeys()->orderByDesc('created_at')->get(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,30 +7,178 @@ namespace Core\Website\Mcp\View\Modal;
|
|||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
use Mod\Uptelligence\Models\AnalysisLog;
|
||||
use Mod\Uptelligence\Models\Asset;
|
||||
use Mod\Uptelligence\Models\Pattern;
|
||||
use Mod\Uptelligence\Models\UpstreamTodo;
|
||||
use Mod\Uptelligence\Models\Vendor;
|
||||
|
||||
/**
|
||||
* MCP Dashboard
|
||||
*
|
||||
* Overview of MCP servers, tools, and usage stats.
|
||||
* @todo This file incorrectly references Uptelligence models. Needs to be rewritten
|
||||
* to show MCP servers, tools, and usage stats instead.
|
||||
*
|
||||
* Unified view of vendors, todos, assets, and patterns.
|
||||
*/
|
||||
#[Layout('mcp::layouts.app')]
|
||||
#[Layout('hub::admin.layouts.app')]
|
||||
class Dashboard extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
public string $serverFilter = '';
|
||||
public string $vendorFilter = '';
|
||||
|
||||
public string $statusFilter = '';
|
||||
public string $typeFilter = '';
|
||||
|
||||
public string $statusFilter = 'pending';
|
||||
|
||||
public string $effortFilter = '';
|
||||
|
||||
public bool $quickWinsOnly = false;
|
||||
|
||||
public function updatingVendorFilter(): void
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function updatingTypeFilter(): void
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function updatingStatusFilter(): void
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function getVendorsProperty()
|
||||
{
|
||||
try {
|
||||
return Vendor::active()->withCount(['todos', 'releases'])->get();
|
||||
} catch (\Illuminate\Database\QueryException $e) {
|
||||
return collect();
|
||||
}
|
||||
}
|
||||
|
||||
public function getStatsProperty(): array
|
||||
{
|
||||
// Override in your application to provide real stats
|
||||
return [
|
||||
'total_servers' => 0,
|
||||
'total_tools' => 0,
|
||||
'total_calls' => 0,
|
||||
'success_rate' => 0,
|
||||
];
|
||||
try {
|
||||
return [
|
||||
'total_vendors' => Vendor::active()->count(),
|
||||
'pending_todos' => UpstreamTodo::pending()->count(),
|
||||
'quick_wins' => UpstreamTodo::quickWins()->count(),
|
||||
'security_updates' => UpstreamTodo::pending()->where('type', 'security')->count(),
|
||||
'recent_releases' => \Mod\Uptelligence\Models\VersionRelease::recent(7)->count(),
|
||||
'in_progress' => UpstreamTodo::inProgress()->count(),
|
||||
];
|
||||
} catch (\Illuminate\Database\QueryException $e) {
|
||||
return [
|
||||
'total_vendors' => 0,
|
||||
'pending_todos' => 0,
|
||||
'quick_wins' => 0,
|
||||
'security_updates' => 0,
|
||||
'recent_releases' => 0,
|
||||
'in_progress' => 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
public function getTodosProperty()
|
||||
{
|
||||
try {
|
||||
$query = UpstreamTodo::with('vendor')
|
||||
->orderByDesc('priority')
|
||||
->orderBy('effort');
|
||||
|
||||
if ($this->vendorFilter) {
|
||||
$query->where('vendor_id', $this->vendorFilter);
|
||||
}
|
||||
|
||||
if ($this->typeFilter) {
|
||||
$query->where('type', $this->typeFilter);
|
||||
}
|
||||
|
||||
if ($this->statusFilter) {
|
||||
$query->where('status', $this->statusFilter);
|
||||
}
|
||||
|
||||
if ($this->effortFilter) {
|
||||
$query->where('effort', $this->effortFilter);
|
||||
}
|
||||
|
||||
if ($this->quickWinsOnly) {
|
||||
$query->where('effort', 'low')->where('priority', '>=', 5);
|
||||
}
|
||||
|
||||
return $query->paginate(15);
|
||||
} catch (\Illuminate\Database\QueryException $e) {
|
||||
return new \Illuminate\Pagination\LengthAwarePaginator([], 0, 15);
|
||||
}
|
||||
}
|
||||
|
||||
public function getRecentLogsProperty()
|
||||
{
|
||||
try {
|
||||
return AnalysisLog::with('vendor')
|
||||
->latest()
|
||||
->limit(10)
|
||||
->get();
|
||||
} catch (\Illuminate\Database\QueryException $e) {
|
||||
return collect();
|
||||
}
|
||||
}
|
||||
|
||||
public function getAssetsProperty()
|
||||
{
|
||||
try {
|
||||
return Asset::active()->orderBy('type')->get();
|
||||
} catch (\Illuminate\Database\QueryException $e) {
|
||||
return collect();
|
||||
}
|
||||
}
|
||||
|
||||
public function getPatternsProperty()
|
||||
{
|
||||
try {
|
||||
return Pattern::active()->orderBy('category')->limit(6)->get();
|
||||
} catch (\Illuminate\Database\QueryException $e) {
|
||||
return collect();
|
||||
}
|
||||
}
|
||||
|
||||
public function getAssetStatsProperty(): array
|
||||
{
|
||||
try {
|
||||
return [
|
||||
'total' => Asset::active()->count(),
|
||||
'updates_available' => Asset::active()->needsUpdate()->count(),
|
||||
'patterns' => Pattern::active()->count(),
|
||||
];
|
||||
} catch (\Illuminate\Database\QueryException $e) {
|
||||
return [
|
||||
'total' => 0,
|
||||
'updates_available' => 0,
|
||||
'patterns' => 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
public function markInProgress(int $todoId): void
|
||||
{
|
||||
$todo = UpstreamTodo::findOrFail($todoId);
|
||||
$todo->markInProgress();
|
||||
}
|
||||
|
||||
public function markPorted(int $todoId): void
|
||||
{
|
||||
$todo = UpstreamTodo::findOrFail($todoId);
|
||||
$todo->markPorted();
|
||||
}
|
||||
|
||||
public function markSkipped(int $todoId): void
|
||||
{
|
||||
$todo = UpstreamTodo::findOrFail($todoId);
|
||||
$todo->markSkipped();
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -6,22 +6,31 @@ namespace Core\Website\Mcp\View\Modal;
|
|||
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
use Core\Mod\Mcp\Services\McpMetricsService;
|
||||
|
||||
/**
|
||||
* MCP Metrics Dashboard
|
||||
*
|
||||
* Displays analytics and metrics for MCP tool usage.
|
||||
*/
|
||||
#[Layout('mcp::layouts.app')]
|
||||
#[Layout('components.layouts.mcp')]
|
||||
class McpMetrics extends Component
|
||||
{
|
||||
public int $days = 7;
|
||||
|
||||
public string $activeTab = 'overview';
|
||||
|
||||
protected McpMetricsService $metricsService;
|
||||
|
||||
public function boot(McpMetricsService $metricsService): void
|
||||
{
|
||||
$this->metricsService = $metricsService;
|
||||
}
|
||||
|
||||
public function setDays(int $days): void
|
||||
{
|
||||
$this->days = $days;
|
||||
// Bound days to a reasonable range (1-90)
|
||||
$this->days = min(max($days, 1), 90);
|
||||
}
|
||||
|
||||
public function setTab(string $tab): void
|
||||
|
|
@ -31,12 +40,47 @@ class McpMetrics extends Component
|
|||
|
||||
public function getOverviewProperty(): array
|
||||
{
|
||||
// Override in your application
|
||||
return [
|
||||
'total_calls' => 0,
|
||||
'success_rate' => 0,
|
||||
'avg_duration' => 0,
|
||||
];
|
||||
return app(McpMetricsService::class)->getOverview($this->days);
|
||||
}
|
||||
|
||||
public function getDailyTrendProperty(): array
|
||||
{
|
||||
return app(McpMetricsService::class)->getDailyTrend($this->days);
|
||||
}
|
||||
|
||||
public function getTopToolsProperty(): array
|
||||
{
|
||||
return app(McpMetricsService::class)->getTopTools($this->days, 10);
|
||||
}
|
||||
|
||||
public function getServerStatsProperty(): array
|
||||
{
|
||||
return app(McpMetricsService::class)->getServerStats($this->days);
|
||||
}
|
||||
|
||||
public function getRecentCallsProperty(): array
|
||||
{
|
||||
return app(McpMetricsService::class)->getRecentCalls(20);
|
||||
}
|
||||
|
||||
public function getErrorBreakdownProperty(): array
|
||||
{
|
||||
return app(McpMetricsService::class)->getErrorBreakdown($this->days);
|
||||
}
|
||||
|
||||
public function getToolPerformanceProperty(): array
|
||||
{
|
||||
return app(McpMetricsService::class)->getToolPerformance($this->days, 10);
|
||||
}
|
||||
|
||||
public function getHourlyDistributionProperty(): array
|
||||
{
|
||||
return app(McpMetricsService::class)->getHourlyDistribution();
|
||||
}
|
||||
|
||||
public function getPlanActivityProperty(): array
|
||||
{
|
||||
return app(McpMetricsService::class)->getPlanActivity($this->days, 10);
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -5,8 +5,11 @@ declare(strict_types=1);
|
|||
namespace Core\Website\Mcp\View\Modal;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
use Core\Mod\Mcp\Models\McpToolCall;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
/**
|
||||
|
|
@ -15,7 +18,7 @@ use Symfony\Component\Yaml\Yaml;
|
|||
* A browser-based UI for testing MCP tool calls.
|
||||
* Allows users to select a server, pick a tool, and execute it with custom parameters.
|
||||
*/
|
||||
#[Layout('mcp::layouts.app')]
|
||||
#[Layout('components.layouts.mcp')]
|
||||
class McpPlayground extends Component
|
||||
{
|
||||
public string $selectedServer = '';
|
||||
|
|
@ -63,6 +66,7 @@ class McpPlayground extends Component
|
|||
|
||||
public function updatedSelectedTool(): void
|
||||
{
|
||||
// Pre-fill example parameters based on tool definition
|
||||
$this->prefillParameters();
|
||||
$this->lastResult = null;
|
||||
$this->lastError = null;
|
||||
|
|
@ -72,6 +76,15 @@ class McpPlayground extends Component
|
|||
{
|
||||
$this->validate();
|
||||
|
||||
// Rate limit: 10 executions per minute per user/IP
|
||||
$rateLimitKey = 'mcp-playground:'.$this->getRateLimitKey();
|
||||
if (RateLimiter::tooManyAttempts($rateLimitKey, 10)) {
|
||||
$this->lastError = 'Too many requests. Please wait before trying again.';
|
||||
|
||||
return;
|
||||
}
|
||||
RateLimiter::hit($rateLimitKey, 60);
|
||||
|
||||
$this->isExecuting = true;
|
||||
$this->lastResult = null;
|
||||
$this->lastError = null;
|
||||
|
|
@ -84,13 +97,16 @@ class McpPlayground extends Component
|
|||
return;
|
||||
}
|
||||
|
||||
// Mock execution for demo
|
||||
$this->lastResult = [
|
||||
'status' => 'success',
|
||||
'message' => 'Tool execution simulated',
|
||||
'params' => $params,
|
||||
];
|
||||
$this->executionTime = rand(50, 200);
|
||||
$startTime = microtime(true);
|
||||
$result = $this->callTool($this->selectedServer, $this->selectedTool, $params);
|
||||
$this->executionTime = (int) round((microtime(true) - $startTime) * 1000);
|
||||
|
||||
if (isset($result['error'])) {
|
||||
$this->lastError = $result['error'];
|
||||
$this->lastResult = $result;
|
||||
} else {
|
||||
$this->lastResult = $result;
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->lastError = $e->getMessage();
|
||||
|
|
@ -99,6 +115,18 @@ class McpPlayground extends Component
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rate limit key based on user or IP.
|
||||
*/
|
||||
protected function getRateLimitKey(): string
|
||||
{
|
||||
if (auth()->check()) {
|
||||
return 'user:'.auth()->id();
|
||||
}
|
||||
|
||||
return 'ip:'.request()->ip();
|
||||
}
|
||||
|
||||
public function formatJson(): void
|
||||
{
|
||||
try {
|
||||
|
|
@ -164,6 +192,7 @@ class McpPlayground extends Component
|
|||
return;
|
||||
}
|
||||
|
||||
// Build example params from parameter definitions
|
||||
$params = [];
|
||||
foreach ($tool['parameters'] as $paramName => $paramDef) {
|
||||
if (is_array($paramDef)) {
|
||||
|
|
@ -174,6 +203,7 @@ class McpPlayground extends Component
|
|||
if ($default !== null) {
|
||||
$params[$paramName] = $default;
|
||||
} elseif ($required) {
|
||||
// Add placeholder
|
||||
$params[$paramName] = match ($type) {
|
||||
'boolean' => false,
|
||||
'integer', 'number' => 0,
|
||||
|
|
@ -187,6 +217,99 @@ class McpPlayground extends Component
|
|||
$this->inputJson = json_encode($params, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
protected function callTool(string $serverId, string $toolName, array $params): array
|
||||
{
|
||||
$server = $this->loadServerYaml($serverId);
|
||||
|
||||
if (! $server) {
|
||||
return ['error' => 'Server not found'];
|
||||
}
|
||||
|
||||
$connection = $server['connection'] ?? [];
|
||||
$type = $connection['type'] ?? 'stdio';
|
||||
|
||||
if ($type !== 'stdio') {
|
||||
return ['error' => "Connection type '{$type}' not supported in playground"];
|
||||
}
|
||||
|
||||
$command = $connection['command'] ?? null;
|
||||
$args = $connection['args'] ?? [];
|
||||
$cwd = $this->resolveEnvVars($connection['cwd'] ?? getcwd());
|
||||
|
||||
if (! $command) {
|
||||
return ['error' => 'No command configured for this server'];
|
||||
}
|
||||
|
||||
// Build MCP tool call request
|
||||
$request = json_encode([
|
||||
'jsonrpc' => '2.0',
|
||||
'method' => 'tools/call',
|
||||
'params' => [
|
||||
'name' => $toolName,
|
||||
'arguments' => $params,
|
||||
],
|
||||
'id' => 1,
|
||||
]);
|
||||
|
||||
try {
|
||||
$startTime = microtime(true);
|
||||
|
||||
$fullCommand = array_merge([$command], $args);
|
||||
$process = new Process($fullCommand, $cwd);
|
||||
$process->setInput($request);
|
||||
$process->setTimeout(30);
|
||||
|
||||
$process->run();
|
||||
|
||||
$duration = (int) round((microtime(true) - $startTime) * 1000);
|
||||
$output = $process->getOutput();
|
||||
|
||||
// Log the tool call
|
||||
McpToolCall::log(
|
||||
serverId: $serverId,
|
||||
toolName: $toolName,
|
||||
params: $params,
|
||||
success: $process->isSuccessful(),
|
||||
durationMs: $duration,
|
||||
errorMessage: $process->isSuccessful() ? null : $process->getErrorOutput(),
|
||||
);
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
return [
|
||||
'error' => 'Process failed',
|
||||
'exit_code' => $process->getExitCode(),
|
||||
'stderr' => $process->getErrorOutput(),
|
||||
];
|
||||
}
|
||||
|
||||
// Parse JSON-RPC response
|
||||
$lines = explode("\n", trim($output));
|
||||
foreach ($lines as $line) {
|
||||
$response = json_decode($line, true);
|
||||
if ($response) {
|
||||
if (isset($response['error'])) {
|
||||
return [
|
||||
'error' => $response['error']['message'] ?? 'Unknown error',
|
||||
'code' => $response['error']['code'] ?? null,
|
||||
'data' => $response['error']['data'] ?? null,
|
||||
];
|
||||
}
|
||||
if (isset($response['result'])) {
|
||||
return $response['result'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'error' => 'No valid response received',
|
||||
'raw_output' => $output,
|
||||
];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
protected function loadRegistry(): array
|
||||
{
|
||||
return Cache::remember('mcp:registry', 0, function () {
|
||||
|
|
@ -201,6 +324,14 @@ class McpPlayground extends Component
|
|||
|
||||
protected function loadServerYaml(string $id): ?array
|
||||
{
|
||||
// Sanitise server ID to prevent path traversal attacks
|
||||
$id = basename($id, '.yaml');
|
||||
|
||||
// Validate ID format (alphanumeric with hyphens only)
|
||||
if (! preg_match('/^[a-z0-9-]+$/', $id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$path = resource_path("mcp/servers/{$id}.yaml");
|
||||
if (! file_exists($path)) {
|
||||
return null;
|
||||
|
|
@ -209,6 +340,17 @@ class McpPlayground extends Component
|
|||
return Yaml::parseFile($path);
|
||||
}
|
||||
|
||||
protected function resolveEnvVars(string $value): string
|
||||
{
|
||||
return preg_replace_callback('/\$\{([^}]+)\}/', function ($matches) {
|
||||
$parts = explode(':-', $matches[1], 2);
|
||||
$var = $parts[0];
|
||||
$default = $parts[1] ?? '';
|
||||
|
||||
return env($var, $default);
|
||||
}, $value);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('mcp::web.mcp-playground');
|
||||
|
|
|
|||
|
|
@ -4,14 +4,17 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Website\Mcp\View\Modal;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
use Core\Mod\Api\Models\ApiKey;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
/**
|
||||
* MCP Playground - interactive tool testing in the browser.
|
||||
*/
|
||||
#[Layout('mcp::layouts.app')]
|
||||
#[Layout('components.layouts.mcp')]
|
||||
class Playground extends Component
|
||||
{
|
||||
public string $selectedServer = '';
|
||||
|
|
@ -28,6 +31,10 @@ class Playground extends Component
|
|||
|
||||
public ?string $error = null;
|
||||
|
||||
public ?string $keyStatus = null;
|
||||
|
||||
public ?array $keyInfo = null;
|
||||
|
||||
public array $servers = [];
|
||||
|
||||
public array $tools = [];
|
||||
|
|
@ -92,6 +99,7 @@ class Playground extends Component
|
|||
try {
|
||||
$this->toolSchema = collect($this->tools)->firstWhere('name', $this->selectedTool);
|
||||
|
||||
// Pre-fill arguments with defaults
|
||||
$params = $this->toolSchema['inputSchema']['properties'] ?? [];
|
||||
foreach ($params as $name => $schema) {
|
||||
$this->arguments[$name] = $schema['default'] ?? '';
|
||||
|
|
@ -102,26 +110,110 @@ class Playground extends Component
|
|||
}
|
||||
}
|
||||
|
||||
public function updatedApiKey(): void
|
||||
{
|
||||
// Clear key status when key changes
|
||||
$this->keyStatus = null;
|
||||
$this->keyInfo = null;
|
||||
}
|
||||
|
||||
public function validateKey(): void
|
||||
{
|
||||
$this->keyStatus = null;
|
||||
$this->keyInfo = null;
|
||||
|
||||
if (empty($this->apiKey)) {
|
||||
$this->keyStatus = 'empty';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$key = ApiKey::findByPlainKey($this->apiKey);
|
||||
|
||||
if (! $key) {
|
||||
$this->keyStatus = 'invalid';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($key->isExpired()) {
|
||||
$this->keyStatus = 'expired';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->keyStatus = 'valid';
|
||||
$this->keyInfo = [
|
||||
'name' => $key->name,
|
||||
'scopes' => $key->scopes,
|
||||
'server_scopes' => $key->getAllowedServers(),
|
||||
'workspace' => $key->workspace?->name ?? 'Unknown',
|
||||
'last_used' => $key->last_used_at?->diffForHumans() ?? 'Never',
|
||||
];
|
||||
}
|
||||
|
||||
public function isAuthenticated(): bool
|
||||
{
|
||||
return auth()->check();
|
||||
}
|
||||
|
||||
public function execute(): void
|
||||
{
|
||||
if (! $this->selectedServer || ! $this->selectedTool) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Rate limit: 10 executions per minute per user/IP
|
||||
$rateLimitKey = 'mcp-playground-api:'.$this->getRateLimitKey();
|
||||
if (RateLimiter::tooManyAttempts($rateLimitKey, 10)) {
|
||||
$this->error = 'Too many requests. Please wait before trying again.';
|
||||
|
||||
return;
|
||||
}
|
||||
RateLimiter::hit($rateLimitKey, 60);
|
||||
|
||||
$this->loading = true;
|
||||
$this->response = '';
|
||||
$this->error = null;
|
||||
|
||||
try {
|
||||
// Filter out empty arguments
|
||||
$args = array_filter($this->arguments, fn ($v) => $v !== '' && $v !== null);
|
||||
|
||||
// Convert numeric strings to numbers where appropriate
|
||||
foreach ($args as $key => $value) {
|
||||
if (is_numeric($value)) {
|
||||
$args[$key] = str_contains($value, '.') ? (float) $value : (int) $value;
|
||||
}
|
||||
if ($value === 'true') {
|
||||
$args[$key] = true;
|
||||
}
|
||||
if ($value === 'false') {
|
||||
$args[$key] = false;
|
||||
}
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'server' => $this->selectedServer,
|
||||
'tool' => $this->selectedTool,
|
||||
'arguments' => $args,
|
||||
];
|
||||
|
||||
// Show request format (actual execution requires API key + backend)
|
||||
// If we have an API key, make a real request
|
||||
if (! empty($this->apiKey) && $this->keyStatus === 'valid') {
|
||||
$response = Http::withToken($this->apiKey)
|
||||
->timeout(30)
|
||||
->post(config('app.url').'/api/v1/mcp/tools/call', $payload);
|
||||
|
||||
$this->response = json_encode([
|
||||
'status' => $response->status(),
|
||||
'response' => $response->json(),
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, just show request format
|
||||
$this->response = json_encode([
|
||||
'request' => $payload,
|
||||
'note' => 'Add an API key above to execute this request live.',
|
||||
|
|
@ -142,9 +234,12 @@ class Playground extends Component
|
|||
|
||||
public function render()
|
||||
{
|
||||
$isAuthenticated = $this->isAuthenticated();
|
||||
$workspace = $isAuthenticated ? auth()->user()?->defaultHostWorkspace() : null;
|
||||
|
||||
return view('mcp::web.playground', [
|
||||
'isAuthenticated' => auth()->check(),
|
||||
'workspace' => null,
|
||||
'isAuthenticated' => $isAuthenticated,
|
||||
'workspace' => $workspace,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -157,11 +252,31 @@ class Playground extends Component
|
|||
|
||||
protected function loadServerFull(string $id): ?array
|
||||
{
|
||||
// Sanitise server ID to prevent path traversal attacks
|
||||
$id = basename($id, '.yaml');
|
||||
|
||||
// Validate ID format (alphanumeric with hyphens only)
|
||||
if (! preg_match('/^[a-z0-9-]+$/', $id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$path = resource_path("mcp/servers/{$id}.yaml");
|
||||
|
||||
return file_exists($path) ? Yaml::parseFile($path) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rate limit key based on user or IP.
|
||||
*/
|
||||
protected function getRateLimitKey(): string
|
||||
{
|
||||
if (auth()->check()) {
|
||||
return 'user:'.auth()->id();
|
||||
}
|
||||
|
||||
return 'ip:'.request()->ip();
|
||||
}
|
||||
|
||||
protected function loadServerSummary(string $id): ?array
|
||||
{
|
||||
$server = $this->loadServerFull($id);
|
||||
|
|
|
|||
|
|
@ -7,11 +7,12 @@ namespace Core\Website\Mcp\View\Modal;
|
|||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
use Core\Mod\Mcp\Models\McpApiRequest;
|
||||
|
||||
/**
|
||||
* MCP Request Log - view and replay API requests.
|
||||
*/
|
||||
#[Layout('mcp::layouts.app')]
|
||||
#[Layout('components.layouts.mcp')]
|
||||
class RequestLog extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
|
@ -22,6 +23,8 @@ class RequestLog extends Component
|
|||
|
||||
public ?int $selectedRequestId = null;
|
||||
|
||||
public ?McpApiRequest $selectedRequest = null;
|
||||
|
||||
public function updatedServerFilter(): void
|
||||
{
|
||||
$this->resetPage();
|
||||
|
|
@ -34,20 +37,64 @@ class RequestLog extends Component
|
|||
|
||||
public function selectRequest(int $id): void
|
||||
{
|
||||
$workspace = auth()->user()?->defaultHostWorkspace();
|
||||
|
||||
// Only allow selecting requests that belong to the user's workspace
|
||||
$request = McpApiRequest::query()
|
||||
->when($workspace, fn ($q) => $q->forWorkspace($workspace->id))
|
||||
->find($id);
|
||||
|
||||
if (! $request) {
|
||||
$this->selectedRequestId = null;
|
||||
$this->selectedRequest = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->selectedRequestId = $id;
|
||||
$this->selectedRequest = $request;
|
||||
}
|
||||
|
||||
public function closeDetail(): void
|
||||
{
|
||||
$this->selectedRequestId = null;
|
||||
$this->selectedRequest = null;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
// Override to provide real request data
|
||||
$workspace = auth()->user()?->defaultHostWorkspace();
|
||||
|
||||
$query = McpApiRequest::query()
|
||||
->orderByDesc('created_at');
|
||||
|
||||
if ($workspace) {
|
||||
$query->forWorkspace($workspace->id);
|
||||
}
|
||||
|
||||
if ($this->serverFilter) {
|
||||
$query->forServer($this->serverFilter);
|
||||
}
|
||||
|
||||
if ($this->statusFilter === 'success') {
|
||||
$query->successful();
|
||||
} elseif ($this->statusFilter === 'failed') {
|
||||
$query->failed();
|
||||
}
|
||||
|
||||
$requests = $query->paginate(20);
|
||||
|
||||
// Get unique servers for filter dropdown
|
||||
$servers = McpApiRequest::query()
|
||||
->when($workspace, fn ($q) => $q->forWorkspace($workspace->id))
|
||||
->distinct()
|
||||
->pluck('server_id')
|
||||
->filter()
|
||||
->values();
|
||||
|
||||
return view('mcp::web.request-log', [
|
||||
'requests' => collect(),
|
||||
'servers' => collect(),
|
||||
'requests' => $requests,
|
||||
'servers' => $servers,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Website\Mcp\View\Modal;
|
||||
|
||||
use Core\Search\Unified as UnifiedSearchService;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
|
|
@ -14,7 +15,7 @@ use Livewire\Component;
|
|||
* Single search interface across all system components:
|
||||
* MCP tools, API endpoints, patterns, assets, todos, and plans.
|
||||
*/
|
||||
#[Layout('mcp::layouts.app')]
|
||||
#[Layout('components.layouts.mcp')]
|
||||
class UnifiedSearch extends Component
|
||||
{
|
||||
public string $query = '';
|
||||
|
|
@ -23,6 +24,13 @@ class UnifiedSearch extends Component
|
|||
|
||||
public int $limit = 50;
|
||||
|
||||
protected UnifiedSearchService $searchService;
|
||||
|
||||
public function boot(UnifiedSearchService $searchService): void
|
||||
{
|
||||
$this->searchService = $searchService;
|
||||
}
|
||||
|
||||
public function updatedQuery(): void
|
||||
{
|
||||
// Debounce handled by wire:model.debounce
|
||||
|
|
@ -48,17 +56,23 @@ class UnifiedSearch extends Component
|
|||
return collect();
|
||||
}
|
||||
|
||||
// Override in your application to provide real search
|
||||
return collect();
|
||||
return $this->searchService->search($this->query, $this->selectedTypes, $this->limit);
|
||||
}
|
||||
|
||||
public function getTypesProperty(): array
|
||||
{
|
||||
return [
|
||||
'mcp_tool' => 'MCP Tools',
|
||||
'api_endpoint' => 'API Endpoints',
|
||||
'pattern' => 'Patterns',
|
||||
];
|
||||
return UnifiedSearchService::getTypes();
|
||||
}
|
||||
|
||||
public function getResultCountsByTypeProperty(): array
|
||||
{
|
||||
if (strlen($this->query) < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$allResults = $this->searchService->search($this->query, [], 200);
|
||||
|
||||
return $allResults->groupBy('type')->map->count()->toArray();
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,32 @@
|
|||
# Core-PHP TODO
|
||||
|
||||
## Implemented
|
||||
|
||||
### Actions Pattern ✓
|
||||
|
||||
`Core\Actions\Action` trait for single-purpose business logic classes.
|
||||
|
||||
```php
|
||||
use Core\Actions\Action;
|
||||
|
||||
class CreateThing
|
||||
{
|
||||
use Action;
|
||||
|
||||
public function handle(User $user, array $data): Thing
|
||||
{
|
||||
// Complex business logic here
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
$thing = CreateThing::run($user, $data);
|
||||
```
|
||||
|
||||
**Location:** `src/Core/Actions/Action.php`, `src/Core/Actions/Actionable.php`
|
||||
|
||||
---
|
||||
|
||||
## Seeder Auto-Discovery
|
||||
|
||||
**Priority:** Medium
|
||||
|
|
@ -37,3 +64,233 @@ class PackageSeeder extends Seeder
|
|||
- Current Host Hub DatabaseSeeder has ~20 seeders with implicit ordering
|
||||
- Key dependencies: features → packages → workspaces → system user → content
|
||||
- Could use Laravel's service container to resolve seeder graph
|
||||
|
||||
---
|
||||
|
||||
## Team-Scoped Caching
|
||||
|
||||
**Priority:** Medium
|
||||
**Context:** Repeated queries for workspace-scoped resources. Cache workspace-scoped queries with auto-invalidation.
|
||||
|
||||
### Implementation
|
||||
|
||||
Extend `BelongsToWorkspace` trait:
|
||||
|
||||
```php
|
||||
trait BelongsToWorkspace
|
||||
{
|
||||
public static function ownedByCurrentWorkspaceCached(int $ttl = 300)
|
||||
{
|
||||
$workspace = currentWorkspace();
|
||||
if (!$workspace) return collect();
|
||||
|
||||
return Cache::remember(
|
||||
static::workspaceCacheKey($workspace->id),
|
||||
$ttl,
|
||||
fn() => static::ownedByCurrentWorkspace()->get()
|
||||
);
|
||||
}
|
||||
|
||||
protected static function bootBelongsToWorkspace(): void
|
||||
{
|
||||
static::saved(fn($m) => static::clearWorkspaceCache($m->workspace_id));
|
||||
static::deleted(fn($m) => static::clearWorkspaceCache($m->workspace_id));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```php
|
||||
// Cached for 5 minutes, auto-clears on changes
|
||||
$biolinks = Biolink::ownedByCurrentWorkspaceCached();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Activity Logging
|
||||
|
||||
**Priority:** Low
|
||||
**Context:** No audit trail of user actions across modules.
|
||||
|
||||
### Implementation
|
||||
|
||||
Add `spatie/laravel-activitylog` integration:
|
||||
|
||||
```php
|
||||
// Core trait for models
|
||||
trait LogsActivity
|
||||
{
|
||||
use \Spatie\Activitylog\Traits\LogsActivity;
|
||||
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
->logOnlyDirty()
|
||||
->dontSubmitEmptyLogs();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Requirements
|
||||
|
||||
- Base trait modules can use
|
||||
- Activity viewer Livewire component for admin
|
||||
- Workspace-scoped activity queries
|
||||
|
||||
---
|
||||
|
||||
## Multi-Tenant Data Isolation
|
||||
|
||||
**Priority:** High (Security)
|
||||
**Context:** Multiple modules have workspace isolation issues.
|
||||
|
||||
### Issues
|
||||
|
||||
1. **Fallback workspace_id** - Some code falls back to `workspace_id = 1` when no context
|
||||
2. **Global queries** - Some commands query globally without workspace scope
|
||||
3. **Session trust** - Using `session('workspace_id', 1)` with hardcoded fallback
|
||||
|
||||
### Solution
|
||||
|
||||
- `BelongsToWorkspace` trait should throw exception when workspace context is missing (not fallback)
|
||||
- Add `WorkspaceScope` global scope that throws on missing context
|
||||
- Audit all models for proper scoping
|
||||
- Add middleware that ensures workspace context before any workspace-scoped operation
|
||||
|
||||
---
|
||||
|
||||
## Bouncer Request Whitelisting
|
||||
|
||||
**Priority:** Medium
|
||||
**Context:** Every controller action must be explicitly permitted. Unknown actions are blocked (production) or prompt for approval (training mode).
|
||||
|
||||
**Philosophy:** If it wasn't trained, it doesn't exist.
|
||||
|
||||
### Concept
|
||||
|
||||
```
|
||||
Training Mode (Development):
|
||||
1. Developer hits /admin/products
|
||||
2. Clicks "Create Product"
|
||||
3. System: "BLOCKED - No permission defined for:"
|
||||
- Role: admin
|
||||
- Action: product.create
|
||||
- Route: POST /admin/products
|
||||
4. Developer clicks [Allow for admin]
|
||||
5. Permission recorded
|
||||
6. Continue working
|
||||
|
||||
Production Mode:
|
||||
If permission not in whitelist → 403 Forbidden
|
||||
No exceptions. No fallbacks. No "default allow".
|
||||
```
|
||||
|
||||
### Database Schema
|
||||
|
||||
```php
|
||||
// core_action_permissions
|
||||
Schema::create('core_action_permissions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('action'); // product.create, order.refund
|
||||
$table->string('scope')->nullable(); // Resource type or specific ID
|
||||
$table->string('guard')->default('web'); // web, api, admin
|
||||
$table->string('role')->nullable(); // admin, editor, or null for any auth
|
||||
$table->boolean('allowed')->default(false);
|
||||
$table->string('source'); // 'trained', 'seeded', 'manual'
|
||||
$table->string('trained_route')->nullable();
|
||||
$table->foreignId('trained_by')->nullable();
|
||||
$table->timestamp('trained_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['action', 'scope', 'guard', 'role']);
|
||||
});
|
||||
|
||||
// core_action_requests (audit log)
|
||||
Schema::create('core_action_requests', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('method');
|
||||
$table->string('route');
|
||||
$table->string('action');
|
||||
$table->string('scope')->nullable();
|
||||
$table->string('guard');
|
||||
$table->string('role')->nullable();
|
||||
$table->foreignId('user_id')->nullable();
|
||||
$table->string('ip_address')->nullable();
|
||||
$table->string('status'); // allowed, denied, pending
|
||||
$table->boolean('was_trained')->default(false);
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['action', 'status']);
|
||||
});
|
||||
```
|
||||
|
||||
### Action Resolution
|
||||
|
||||
```php
|
||||
// Explicit via route attribute
|
||||
Route::post('/products', [ProductController::class, 'store'])
|
||||
->action('product.create');
|
||||
|
||||
// Or via controller attribute
|
||||
#[Action('product.create')]
|
||||
public function store(Request $request) { ... }
|
||||
|
||||
// Or auto-resolved from controller@method
|
||||
// ProductController@store → product.store
|
||||
```
|
||||
|
||||
### Integration with Existing Auth
|
||||
|
||||
```
|
||||
Request
|
||||
│
|
||||
▼
|
||||
BouncerGate (action whitelisting)
|
||||
│ "Is this action permitted at all?"
|
||||
▼
|
||||
Laravel Gate/Policy (authorisation)
|
||||
│ "Can THIS USER do this to THIS RESOURCE?"
|
||||
▼
|
||||
Controller
|
||||
```
|
||||
|
||||
### Implementation Phases
|
||||
|
||||
**Phase 1: Core**
|
||||
- [ ] Database migrations
|
||||
- [ ] `ActionPermission` model
|
||||
- [ ] `BouncerService` with `check()` method
|
||||
- [ ] `BouncerGate` middleware
|
||||
|
||||
**Phase 2: Training Mode**
|
||||
- [ ] Training UI (modal prompt)
|
||||
- [ ] Training controller/routes
|
||||
- [ ] Request logging
|
||||
|
||||
**Phase 3: Tooling**
|
||||
- [ ] `bouncer:export` command
|
||||
- [ ] `bouncer:list` command
|
||||
- [ ] Admin UI for viewing/editing permissions
|
||||
|
||||
**Phase 4: Integration**
|
||||
- [ ] Apply to admin routes
|
||||
- [ ] Apply to API routes
|
||||
- [ ] Documentation
|
||||
|
||||
### Artisan Commands
|
||||
|
||||
```bash
|
||||
php artisan bouncer:export # Export trained permissions to seeder
|
||||
php artisan bouncer:seed # Import from seeder
|
||||
php artisan bouncer:list # List all defined actions
|
||||
php artisan bouncer:reset # Clear training data
|
||||
```
|
||||
|
||||
### Benefits
|
||||
|
||||
1. **Complete audit trail** - Know exactly what actions exist in your app
|
||||
2. **No forgotten routes** - If it's not trained, it doesn't work
|
||||
3. **Role-based by default** - Actions scoped to guards and roles
|
||||
4. **Deployment safety** - Export/import permissions between environments
|
||||
5. **Discovery tool** - Training mode maps your entire app's surface area
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
"php": "^8.2",
|
||||
"laravel/framework": "^11.0|^12.0",
|
||||
"laravel/pennant": "^1.0",
|
||||
"livewire/livewire": "^3.0"
|
||||
"livewire/livewire": "^3.0|^4.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
|
|
|||
52
packages/core-php/src/Core/Actions/Action.php
Normal file
52
packages/core-php/src/Core/Actions/Action.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Actions;
|
||||
|
||||
/**
|
||||
* Base Action trait for single-purpose business logic classes.
|
||||
*
|
||||
* Actions are small, focused classes that do one thing well.
|
||||
* They extract complex logic from controllers and Livewire components.
|
||||
*
|
||||
* Convention:
|
||||
* - One action per file
|
||||
* - Named after what it does: CreateBiolink, PublishPost, SendInvoice
|
||||
* - Single public method: handle() or __invoke()
|
||||
* - Dependencies injected via constructor
|
||||
* - Static run() helper for convenience
|
||||
*
|
||||
* Usage:
|
||||
* // Via dependency injection
|
||||
* public function __construct(private CreateBiolink $createBiolink) {}
|
||||
* $biolink = $this->createBiolink->handle($user, $data);
|
||||
*
|
||||
* // Via static helper
|
||||
* $biolink = CreateBiolink::run($user, $data);
|
||||
*
|
||||
* // Via app container
|
||||
* $biolink = app(CreateBiolink::class)->handle($user, $data);
|
||||
*
|
||||
* Directory structure:
|
||||
* app/Mod/{Module}/Actions/
|
||||
* ├── CreateThing.php
|
||||
* ├── UpdateThing.php
|
||||
* ├── DeleteThing.php
|
||||
* └── Thing/
|
||||
* ├── PublishThing.php
|
||||
* └── ArchiveThing.php
|
||||
*/
|
||||
trait Action
|
||||
{
|
||||
/**
|
||||
* Run the action via the container.
|
||||
*
|
||||
* Resolves the action from the container (with dependencies)
|
||||
* and calls handle() with the provided arguments.
|
||||
*/
|
||||
public static function run(mixed ...$args): mixed
|
||||
{
|
||||
return app(static::class)->handle(...$args);
|
||||
}
|
||||
}
|
||||
19
packages/core-php/src/Core/Actions/Actionable.php
Normal file
19
packages/core-php/src/Core/Actions/Actionable.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Actions;
|
||||
|
||||
/**
|
||||
* Interface for actions that want explicit contracts.
|
||||
*
|
||||
* Optional - most actions just use the Action trait.
|
||||
* Use this when you need to type-hint against an action interface.
|
||||
*/
|
||||
interface Actionable
|
||||
{
|
||||
/**
|
||||
* Execute the action.
|
||||
*/
|
||||
public function handle(mixed ...$args): mixed;
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
<?php
|
||||
/*
|
||||
* Core PHP Framework
|
||||
*
|
||||
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||
* See LICENSE file for details.
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('blocked_ips', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('ip_address', 45)->unique();
|
||||
$table->string('reason', 50)->default('manual');
|
||||
$table->timestamp('blocked_at');
|
||||
$table->timestamp('expires_at')->nullable();
|
||||
$table->text('notes')->nullable();
|
||||
|
||||
$table->index('expires_at');
|
||||
$table->index('reason');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('blocked_ips');
|
||||
}
|
||||
};
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
<?php
|
||||
/*
|
||||
* Core PHP Framework
|
||||
*
|
||||
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||
* See LICENSE file for details.
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('seo_redirects', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('from_path', 500);
|
||||
$table->string('to_path', 500);
|
||||
$table->smallInteger('status_code')->default(301);
|
||||
$table->boolean('active')->default(true);
|
||||
$table->unsignedInteger('hit_count')->default(0);
|
||||
$table->timestamp('last_hit_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique('from_path');
|
||||
$table->index('active');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('seo_redirects');
|
||||
}
|
||||
};
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
<?php
|
||||
/*
|
||||
* Core PHP Framework
|
||||
*
|
||||
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||
* See LICENSE file for details.
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('honeypot_hits')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('honeypot_hits', 'severity')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('honeypot_hits', function (Blueprint $table) {
|
||||
$table->string('severity', 20)->default('warning')->after('bot_name');
|
||||
$table->index('severity');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('honeypot_hits', function (Blueprint $table) {
|
||||
$table->dropIndex(['severity']);
|
||||
$table->dropColumn('severity');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
<?php
|
||||
/*
|
||||
* Core PHP Framework
|
||||
*
|
||||
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||
* See LICENSE file for details.
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('blocked_ips', function (Blueprint $table) {
|
||||
$table->string('status', 20)->default('approved')->after('reason');
|
||||
$table->index('status');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('blocked_ips', function (Blueprint $table) {
|
||||
$table->dropIndex(['status']);
|
||||
$table->dropColumn('status');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
<?php
|
||||
/*
|
||||
* Core PHP Framework
|
||||
*
|
||||
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||
* See LICENSE file for details.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// M1: Config key definitions
|
||||
Schema::create('config_keys', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('code')->unique();
|
||||
$table->foreignId('parent_id')->nullable()
|
||||
->constrained('config_keys')
|
||||
->nullOnDelete();
|
||||
$table->string('type')->default('string');
|
||||
$table->string('category')->index();
|
||||
$table->string('description')->nullable();
|
||||
$table->json('default_value')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['category', 'code']);
|
||||
});
|
||||
|
||||
// M2: Config profiles (scope containers)
|
||||
Schema::create('config_profiles', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('scope_type')->index();
|
||||
$table->unsignedBigInteger('scope_id')->nullable()->index();
|
||||
$table->foreignId('parent_profile_id')->nullable()
|
||||
->constrained('config_profiles')
|
||||
->nullOnDelete();
|
||||
$table->integer('priority')->default(0);
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['scope_type', 'scope_id']);
|
||||
$table->unique(['scope_type', 'scope_id', 'priority']);
|
||||
});
|
||||
|
||||
// Junction: Config values (profile <> key with value)
|
||||
Schema::create('config_values', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('profile_id')
|
||||
->constrained('config_profiles')
|
||||
->cascadeOnDelete();
|
||||
$table->foreignId('key_id')
|
||||
->constrained('config_keys')
|
||||
->cascadeOnDelete();
|
||||
$table->json('value')->nullable();
|
||||
$table->boolean('locked')->default(false);
|
||||
$table->foreignId('inherited_from')->nullable()
|
||||
->constrained('config_profiles')
|
||||
->nullOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['profile_id', 'key_id']);
|
||||
$table->index(['key_id', 'locked']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('config_values');
|
||||
Schema::dropIfExists('config_profiles');
|
||||
Schema::dropIfExists('config_keys');
|
||||
}
|
||||
};
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
<?php
|
||||
/*
|
||||
* Core PHP Framework
|
||||
*
|
||||
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||
* See LICENSE file for details.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Channel model - voice/context substrate
|
||||
if (! Schema::hasTable('config_channels')) {
|
||||
Schema::create('config_channels', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('code');
|
||||
$table->string('name');
|
||||
$table->foreignId('parent_id')->nullable()
|
||||
->constrained('config_channels')
|
||||
->nullOnDelete();
|
||||
$table->foreignId('workspace_id')->nullable()
|
||||
->constrained('workspaces')
|
||||
->cascadeOnDelete();
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
// System channels have unique codes
|
||||
// Workspace channels can reuse codes (override system)
|
||||
$table->unique(['code', 'workspace_id']);
|
||||
$table->index('parent_id');
|
||||
});
|
||||
}
|
||||
|
||||
// Skip config_values alterations if table doesn't exist
|
||||
if (! Schema::hasTable('config_values')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if already migrated
|
||||
if (Schema::hasColumn('config_values', 'channel_id')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add channel dimension to config values
|
||||
Schema::table('config_values', function (Blueprint $table) {
|
||||
// Add a standalone index on profile_id so FK can use it
|
||||
// (before we drop the unique constraint that FK was using)
|
||||
$table->index('profile_id', 'config_values_profile_id_index');
|
||||
|
||||
// Add channel_id column
|
||||
$table->foreignId('channel_id')->nullable()
|
||||
->after('key_id')
|
||||
->constrained('config_channels')
|
||||
->nullOnDelete();
|
||||
});
|
||||
|
||||
// Update unique constraint in separate statement
|
||||
// (dropping after index created to avoid FK issues)
|
||||
Schema::table('config_values', function (Blueprint $table) {
|
||||
$table->dropUnique(['profile_id', 'key_id']);
|
||||
$table->unique(['profile_id', 'key_id', 'channel_id'], 'config_values_profile_key_channel_unique');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('config_values', function (Blueprint $table) {
|
||||
$table->dropUnique('config_values_profile_key_channel_unique');
|
||||
$table->dropConstrainedForeignId('channel_id');
|
||||
$table->dropIndex('config_values_profile_id_index');
|
||||
$table->unique(['profile_id', 'key_id']);
|
||||
});
|
||||
|
||||
Schema::dropIfExists('config_channels');
|
||||
}
|
||||
};
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
<?php
|
||||
/*
|
||||
* Core PHP Framework
|
||||
*
|
||||
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||
* See LICENSE file for details.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* config_resolved is the materialised resolution table.
|
||||
* All reads hit this table directly - no computation at read time.
|
||||
* Prime operation populates this table by running resolution logic.
|
||||
*
|
||||
* Uses a generated scope_key column for unique lookups since
|
||||
* MariaDB doesn't support nullable columns in unique constraints.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('config_resolved', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
// The full address: workspace + channel + key
|
||||
$table->unsignedBigInteger('workspace_id')->nullable();
|
||||
$table->unsignedBigInteger('channel_id')->nullable();
|
||||
$table->string('key_code');
|
||||
|
||||
// Generated scope key for unique lookups (NULL becomes 'N')
|
||||
// This allows nullable columns while maintaining uniqueness
|
||||
$table->string('scope_key')->storedAs(
|
||||
"CONCAT(COALESCE(workspace_id, 'N'), ':', COALESCE(channel_id, 'N'), ':', key_code)"
|
||||
);
|
||||
|
||||
// The resolved value
|
||||
$table->json('value')->nullable();
|
||||
$table->string('type')->default('string');
|
||||
$table->boolean('locked')->default(false);
|
||||
|
||||
// Audit: where did this value come from?
|
||||
$table->unsignedBigInteger('source_profile_id')->nullable();
|
||||
$table->unsignedBigInteger('source_channel_id')->nullable();
|
||||
$table->boolean('virtual')->default(false);
|
||||
|
||||
$table->timestamp('computed_at');
|
||||
|
||||
// Unique on generated scope_key - O(1) lookup
|
||||
$table->unique('scope_key', 'config_resolved_lookup');
|
||||
|
||||
// Index for scope queries
|
||||
$table->index(['workspace_id', 'channel_id'], 'config_resolved_scope_idx');
|
||||
|
||||
$table->foreign('source_profile_id')
|
||||
->references('id')
|
||||
->on('config_profiles')
|
||||
->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('config_resolved');
|
||||
}
|
||||
};
|
||||
|
|
@ -10,7 +10,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Core\Input\Input;
|
||||
|
||||
/**
|
||||
* Application initialisation - the true entry point.
|
||||
|
|
@ -22,6 +22,9 @@ use Illuminate\Http\Request;
|
|||
*
|
||||
* This replaces Laravel's bootstrap/app.php pattern with
|
||||
* explicit provider loading via Core\Boot.
|
||||
*
|
||||
* The Input::capture() method provides a WAF layer that sanitises
|
||||
* all input ($_GET, $_POST) before Laravel sees it.
|
||||
*/
|
||||
class Init
|
||||
{
|
||||
|
|
@ -36,9 +39,13 @@ class Init
|
|||
require $maintenance;
|
||||
}
|
||||
|
||||
// Capture request and hand to Laravel
|
||||
$request = Request::capture();
|
||||
Boot::app()->handleRequest($request);
|
||||
// Capture and filter input - WAF layer
|
||||
// This sanitises $_GET and $_POST before creating the request
|
||||
$request = Input::capture();
|
||||
|
||||
// Hand clean request to Laravel
|
||||
// Use App\Boot if it exists (app customizations), otherwise Core\Boot
|
||||
self::boot()::app()->handleRequest($request);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -46,8 +53,19 @@ class Init
|
|||
*/
|
||||
public static function handleForTesting(): mixed
|
||||
{
|
||||
$request = Request::capture();
|
||||
$request = Input::capture();
|
||||
|
||||
return Boot::app()->handle($request);
|
||||
return self::boot()::app()->handle($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Boot class to use.
|
||||
*
|
||||
* Prefers App\Boot if it exists, allowing apps to customise
|
||||
* providers, middleware, and exception handling.
|
||||
*/
|
||||
protected static function boot(): string
|
||||
{
|
||||
return class_exists('App\\Boot') ? 'App\\Boot' : Boot::class;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,11 @@ class Boot extends ServiceProvider
|
|||
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(
|
||||
\Core\Mod\Tenant\Contracts\TwoFactorAuthenticationProvider::class,
|
||||
\Core\Mod\Tenant\Services\TotpService::class
|
||||
);
|
||||
|
||||
$this->app->singleton(
|
||||
\Core\Mod\Tenant\Services\EntitlementService::class,
|
||||
\Core\Mod\Tenant\Services\EntitlementService::class
|
||||
|
|
|
|||
|
|
@ -4,14 +4,15 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Tenant\Concerns;
|
||||
|
||||
use Core\Mod\Tenant\Contracts\TwoFactorAuthenticationProvider;
|
||||
use Core\Mod\Tenant\Models\UserTwoFactorAuth;
|
||||
use Core\Mod\Tenant\Services\TotpService;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
/**
|
||||
* Trait for two-factor authentication support.
|
||||
*
|
||||
* This is a native implementation replacing Mixpost's 2FA.
|
||||
* Currently stubbed - full implementation to follow.
|
||||
* Provides TOTP-based 2FA using the TotpService.
|
||||
*/
|
||||
trait TwoFactorAuthenticatable
|
||||
{
|
||||
|
|
@ -75,8 +76,14 @@ trait TwoFactorAuthenticatable
|
|||
*/
|
||||
public function twoFactorQrCodeSvg(): string
|
||||
{
|
||||
// Stub - will implement with bacon/bacon-qr-code
|
||||
return '';
|
||||
$secret = $this->twoFactorAuthSecretKey();
|
||||
if (! $secret) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$url = $this->twoFactorQrCodeUrl();
|
||||
|
||||
return $this->getTotpService()->qrCodeSvg($url);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -84,11 +91,53 @@ trait TwoFactorAuthenticatable
|
|||
*/
|
||||
public function twoFactorQrCodeUrl(): string
|
||||
{
|
||||
$appName = rawurlencode(config('app.name'));
|
||||
$email = rawurlencode($this->email);
|
||||
$secret = $this->twoFactorAuthSecretKey();
|
||||
return $this->getTotpService()->qrCodeUrl(
|
||||
config('app.name'),
|
||||
$this->email,
|
||||
$this->twoFactorAuthSecretKey()
|
||||
);
|
||||
}
|
||||
|
||||
return "otpauth://totp/{$appName}:{$email}?secret={$secret}&issuer={$appName}";
|
||||
/**
|
||||
* Verify a TOTP code.
|
||||
*/
|
||||
public function verifyTwoFactorCode(string $code): bool
|
||||
{
|
||||
$secret = $this->twoFactorAuthSecretKey();
|
||||
if (! $secret) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->getTotpService()->verify($secret, $code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new two-factor secret.
|
||||
*/
|
||||
public function generateTwoFactorSecret(): string
|
||||
{
|
||||
return $this->getTotpService()->generateSecretKey();
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a recovery code.
|
||||
*
|
||||
* @return bool True if the recovery code was valid and used
|
||||
*/
|
||||
public function verifyRecoveryCode(string $code): bool
|
||||
{
|
||||
$codes = $this->twoFactorRecoveryCodes();
|
||||
$code = strtoupper(trim($code));
|
||||
|
||||
$index = array_search($code, $codes);
|
||||
|
||||
if ($index !== false) {
|
||||
$this->twoFactorReplaceRecoveryCode($code);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -98,4 +147,104 @@ trait TwoFactorAuthenticatable
|
|||
{
|
||||
return strtoupper(bin2hex(random_bytes(5))).'-'.strtoupper(bin2hex(random_bytes(5)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a set of recovery codes.
|
||||
*
|
||||
* @param int $count Number of codes to generate
|
||||
*/
|
||||
public function generateRecoveryCodes(int $count = 8): array
|
||||
{
|
||||
$codes = [];
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$codes[] = $this->generateRecoveryCode();
|
||||
}
|
||||
|
||||
return $codes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable two-factor authentication for this user.
|
||||
*
|
||||
* Creates the 2FA record with a new secret but does not confirm it yet.
|
||||
* The user must verify a code before 2FA is fully enabled.
|
||||
*
|
||||
* @return string The secret key for QR code generation
|
||||
*/
|
||||
public function enableTwoFactorAuth(): string
|
||||
{
|
||||
$secret = $this->generateTwoFactorSecret();
|
||||
|
||||
$this->twoFactorAuth()->updateOrCreate(
|
||||
['user_id' => $this->id],
|
||||
[
|
||||
'secret_key' => $secret,
|
||||
'recovery_codes' => null,
|
||||
'confirmed_at' => null,
|
||||
]
|
||||
);
|
||||
|
||||
$this->load('twoFactorAuth');
|
||||
|
||||
return $secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm two-factor authentication after verifying a code.
|
||||
*
|
||||
* @return array The recovery codes
|
||||
*/
|
||||
public function confirmTwoFactorAuth(): array
|
||||
{
|
||||
if (! $this->twoFactorAuth || ! $this->twoFactorAuth->secret_key) {
|
||||
throw new \RuntimeException('Two-factor authentication has not been initialised.');
|
||||
}
|
||||
|
||||
$recoveryCodes = $this->generateRecoveryCodes();
|
||||
|
||||
$this->twoFactorAuth->update([
|
||||
'recovery_codes' => $recoveryCodes,
|
||||
'confirmed_at' => now(),
|
||||
]);
|
||||
|
||||
return $recoveryCodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable two-factor authentication for this user.
|
||||
*/
|
||||
public function disableTwoFactorAuth(): void
|
||||
{
|
||||
$this->twoFactorAuth?->delete();
|
||||
$this->unsetRelation('twoFactorAuth');
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate recovery codes.
|
||||
*
|
||||
* @return array The new recovery codes
|
||||
*/
|
||||
public function regenerateTwoFactorRecoveryCodes(): array
|
||||
{
|
||||
if (! $this->hasTwoFactorAuthEnabled()) {
|
||||
throw new \RuntimeException('Two-factor authentication is not enabled.');
|
||||
}
|
||||
|
||||
$recoveryCodes = $this->generateRecoveryCodes();
|
||||
|
||||
$this->twoFactorAuth->update([
|
||||
'recovery_codes' => $recoveryCodes,
|
||||
]);
|
||||
|
||||
return $recoveryCodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the TOTP service instance.
|
||||
*/
|
||||
protected function getTotpService(): TwoFactorAuthenticationProvider
|
||||
{
|
||||
return app(TwoFactorAuthenticationProvider::class);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Contracts;
|
||||
|
||||
/**
|
||||
* Contract for two-factor authentication providers.
|
||||
*
|
||||
* Handles TOTP (Time-based One-Time Password) generation and verification
|
||||
* for user accounts. Typically implemented using libraries like Google Authenticator.
|
||||
*/
|
||||
interface TwoFactorAuthenticationProvider
|
||||
{
|
||||
/**
|
||||
* Generate a new secret key for TOTP.
|
||||
*/
|
||||
public function generateSecretKey(): string;
|
||||
|
||||
/**
|
||||
* Generate QR code URL for authenticator app setup.
|
||||
*
|
||||
* @param string $name Application/account name
|
||||
* @param string $email User email
|
||||
* @param string $secret TOTP secret key
|
||||
*/
|
||||
public function qrCodeUrl(string $name, string $email, string $secret): string;
|
||||
|
||||
/**
|
||||
* Verify a TOTP code against the secret.
|
||||
*
|
||||
* @param string $secret TOTP secret key
|
||||
* @param string $code User-provided 6-digit code
|
||||
*/
|
||||
public function verify(string $secret, string $code): bool;
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mod\Api\Controllers;
|
||||
namespace Core\Mod\Tenant\Controllers;
|
||||
|
||||
use Core\Front\Controller;
|
||||
use Mod\Tenant\Models\EntitlementLog;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mod\Api\Controllers;
|
||||
namespace Core\Mod\Tenant\Controllers;
|
||||
|
||||
use Core\Front\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
|
|
|||
130
packages/core-php/src/Mod/Tenant/Jobs/ProcessAccountDeletion.php
Normal file
130
packages/core-php/src/Mod/Tenant/Jobs/ProcessAccountDeletion.php
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Jobs;
|
||||
|
||||
use Core\Mod\Tenant\Models\AccountDeletionRequest;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Process a single account deletion request.
|
||||
*
|
||||
* This job handles the actual deletion of a user account and all
|
||||
* associated data. It's designed to be run either via queue dispatch
|
||||
* or by the scheduled ProcessAccountDeletions command.
|
||||
*/
|
||||
class ProcessAccountDeletion implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* The number of times the job may be attempted.
|
||||
*/
|
||||
public int $tries = 3;
|
||||
|
||||
/**
|
||||
* The number of seconds to wait before retrying the job.
|
||||
*/
|
||||
public int $backoff = 60;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public AccountDeletionRequest $deletionRequest
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
// Reload to ensure we have fresh data (may have been deleted)
|
||||
$request = AccountDeletionRequest::find($this->deletionRequest->id);
|
||||
|
||||
if (! $request) {
|
||||
Log::info('Skipping account deletion - request no longer exists', [
|
||||
'deletion_request_id' => $this->deletionRequest->id,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify the request is still valid for deletion
|
||||
if (! $request->isActive()) {
|
||||
Log::info('Skipping account deletion - request no longer active', [
|
||||
'deletion_request_id' => $request->id,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$user = $request->user;
|
||||
|
||||
if (! $user) {
|
||||
Log::warning('User not found for deletion request', [
|
||||
'deletion_request_id' => $request->id,
|
||||
]);
|
||||
$request->complete();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Update local reference
|
||||
$this->deletionRequest = $request;
|
||||
|
||||
$userId = $user->id;
|
||||
|
||||
DB::transaction(function () use ($user) {
|
||||
// Mark request as completed
|
||||
$this->deletionRequest->complete();
|
||||
|
||||
// Delete all workspaces owned by the user
|
||||
if (method_exists($user, 'ownedWorkspaces')) {
|
||||
$user->ownedWorkspaces()->each(function ($workspace) {
|
||||
$workspace->delete();
|
||||
});
|
||||
}
|
||||
|
||||
// Hard delete user account
|
||||
$user->forceDelete();
|
||||
});
|
||||
|
||||
Log::info('Account deleted successfully', [
|
||||
'user_id' => $userId,
|
||||
'deletion_request_id' => $this->deletionRequest->id,
|
||||
'via' => 'job',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a job failure.
|
||||
*/
|
||||
public function failed(\Throwable $exception): void
|
||||
{
|
||||
Log::error('Failed to process account deletion', [
|
||||
'deletion_request_id' => $this->deletionRequest->id,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the tags that should be assigned to the job.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function tags(): array
|
||||
{
|
||||
return [
|
||||
'account-deletion',
|
||||
'user:'.$this->deletionRequest->user_id,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('workspaces', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('slug')->unique();
|
||||
$table->string('domain')->nullable();
|
||||
$table->string('icon')->nullable();
|
||||
$table->string('color')->nullable();
|
||||
$table->text('description')->nullable();
|
||||
$table->string('type')->default('default');
|
||||
$table->json('settings')->nullable();
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->integer('sort_order')->default(0);
|
||||
|
||||
// WP Connector fields
|
||||
$table->boolean('wp_connector_enabled')->default(false);
|
||||
$table->string('wp_connector_url')->nullable();
|
||||
$table->string('wp_connector_secret')->nullable();
|
||||
$table->timestamp('wp_connector_verified_at')->nullable();
|
||||
$table->timestamp('wp_connector_last_sync')->nullable();
|
||||
$table->json('wp_connector_config')->nullable();
|
||||
|
||||
// Billing fields
|
||||
$table->string('stripe_customer_id')->nullable();
|
||||
$table->string('btcpay_customer_id')->nullable();
|
||||
$table->string('billing_name')->nullable();
|
||||
$table->string('billing_email')->nullable();
|
||||
$table->string('billing_address_line1')->nullable();
|
||||
$table->string('billing_address_line2')->nullable();
|
||||
$table->string('billing_city')->nullable();
|
||||
$table->string('billing_state')->nullable();
|
||||
$table->string('billing_postal_code')->nullable();
|
||||
$table->string('billing_country')->nullable();
|
||||
$table->string('vat_number')->nullable();
|
||||
$table->string('tax_id')->nullable();
|
||||
$table->boolean('tax_exempt')->default(false);
|
||||
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('user_workspace', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('role')->default('member');
|
||||
$table->boolean('is_default')->default(false);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['user_id', 'workspace_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('user_workspace');
|
||||
Schema::dropIfExists('workspaces');
|
||||
}
|
||||
};
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('user_two_factor_auth', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('secret_key')->nullable();
|
||||
$table->json('recovery_codes')->nullable();
|
||||
$table->timestamp('confirmed_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique('user_id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('user_two_factor_auth');
|
||||
}
|
||||
};
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('namespaces', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->uuid('uuid')->unique();
|
||||
$table->string('name', 128);
|
||||
$table->string('slug', 64);
|
||||
$table->string('description', 512)->nullable();
|
||||
$table->string('icon', 64)->default('folder');
|
||||
$table->string('color', 16)->default('zinc');
|
||||
|
||||
// Polymorphic owner (User::class or Workspace::class)
|
||||
$table->morphs('owner');
|
||||
|
||||
// Workspace context for billing aggregation (optional)
|
||||
// User-owned namespaces can have a workspace for billing
|
||||
$table->foreignId('workspace_id')->nullable()
|
||||
->constrained()->nullOnDelete();
|
||||
|
||||
$table->json('settings')->nullable();
|
||||
$table->boolean('is_default')->default(false);
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->smallInteger('sort_order')->default(0);
|
||||
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
// Each owner can only have one namespace with a given slug
|
||||
$table->unique(['owner_type', 'owner_id', 'slug']);
|
||||
$table->index(['workspace_id', 'is_active']);
|
||||
$table->index(['owner_type', 'owner_id', 'is_active']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('namespaces');
|
||||
}
|
||||
};
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('entitlement_namespace_packages', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('namespace_id')
|
||||
->constrained('namespaces')
|
||||
->cascadeOnDelete();
|
||||
$table->foreignId('package_id')
|
||||
->constrained('entitlement_packages')
|
||||
->cascadeOnDelete();
|
||||
$table->string('status', 20)->default('active');
|
||||
$table->timestamp('starts_at')->nullable();
|
||||
$table->timestamp('expires_at')->nullable();
|
||||
$table->timestamp('billing_cycle_anchor')->nullable();
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->index(['namespace_id', 'status']);
|
||||
$table->index(['package_id', 'status']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('entitlement_namespace_packages');
|
||||
}
|
||||
};
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Add namespace_id to entitlement_boosts
|
||||
Schema::table('entitlement_boosts', function (Blueprint $table) {
|
||||
$table->foreignId('namespace_id')->nullable()
|
||||
->after('workspace_id')
|
||||
->constrained('namespaces')
|
||||
->nullOnDelete();
|
||||
|
||||
$table->index(['namespace_id', 'feature_code', 'status']);
|
||||
});
|
||||
|
||||
// Add namespace_id to entitlement_usage_records
|
||||
Schema::table('entitlement_usage_records', function (Blueprint $table) {
|
||||
$table->foreignId('namespace_id')->nullable()
|
||||
->after('workspace_id')
|
||||
->constrained('namespaces')
|
||||
->nullOnDelete();
|
||||
|
||||
$table->index(['namespace_id', 'feature_code', 'recorded_at']);
|
||||
});
|
||||
|
||||
// Add namespace_id to entitlement_logs
|
||||
Schema::table('entitlement_logs', function (Blueprint $table) {
|
||||
$table->foreignId('namespace_id')->nullable()
|
||||
->after('workspace_id')
|
||||
->constrained('namespaces')
|
||||
->nullOnDelete();
|
||||
|
||||
$table->index(['namespace_id', 'action', 'created_at']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('entitlement_boosts', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('namespace_id');
|
||||
});
|
||||
|
||||
Schema::table('entitlement_usage_records', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('namespace_id');
|
||||
});
|
||||
|
||||
Schema::table('entitlement_logs', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('namespace_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -14,6 +14,7 @@ class Boost extends Model
|
|||
|
||||
protected $fillable = [
|
||||
'workspace_id',
|
||||
'namespace_id',
|
||||
'user_id',
|
||||
'feature_code',
|
||||
'boost_type',
|
||||
|
|
@ -72,6 +73,14 @@ class Boost extends Model
|
|||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* The namespace this boost belongs to.
|
||||
*/
|
||||
public function namespace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Namespace_::class, 'namespace_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* The user this boost belongs to (for user-level boosts like vanity URLs).
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ class EntitlementLog extends Model
|
|||
|
||||
protected $fillable = [
|
||||
'workspace_id',
|
||||
'namespace_id',
|
||||
'action',
|
||||
'entity_type',
|
||||
'entity_id',
|
||||
|
|
@ -80,6 +81,14 @@ class EntitlementLog extends Model
|
|||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* The namespace this log belongs to.
|
||||
*/
|
||||
public function namespace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Namespace_::class, 'namespace_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* The user who triggered this action.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ class UsageRecord extends Model
|
|||
|
||||
protected $fillable = [
|
||||
'workspace_id',
|
||||
'namespace_id',
|
||||
'feature_code',
|
||||
'quantity',
|
||||
'user_id',
|
||||
|
|
@ -36,6 +37,14 @@ class UsageRecord extends Model
|
|||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* The namespace this usage belongs to.
|
||||
*/
|
||||
public function namespace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Namespace_::class, 'namespace_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* The user who incurred this usage.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Pennant\Concerns\HasFeatures;
|
||||
|
|
@ -175,6 +176,68 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||
->first() ?? $this->hostWorkspaces()->first();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Namespace Relationships
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get all namespaces owned directly by this user.
|
||||
*/
|
||||
public function namespaces(): MorphMany
|
||||
{
|
||||
return $this->morphMany(Namespace_::class, 'owner');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's default namespace.
|
||||
*
|
||||
* Priority:
|
||||
* 1. User's default namespace (is_default = true)
|
||||
* 2. First active user-owned namespace
|
||||
* 3. First namespace from user's default workspace
|
||||
*/
|
||||
public function defaultNamespace(): ?Namespace_
|
||||
{
|
||||
// Try user's explicit default
|
||||
$default = $this->namespaces()
|
||||
->where('is_default', true)
|
||||
->active()
|
||||
->first();
|
||||
|
||||
if ($default) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
// Try first user-owned namespace
|
||||
$userOwned = $this->namespaces()
|
||||
->active()
|
||||
->ordered()
|
||||
->first();
|
||||
|
||||
if ($userOwned) {
|
||||
return $userOwned;
|
||||
}
|
||||
|
||||
// Try namespace from user's default workspace
|
||||
$workspace = $this->defaultHostWorkspace();
|
||||
if ($workspace) {
|
||||
return $workspace->namespaces()
|
||||
->active()
|
||||
->ordered()
|
||||
->first();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all namespaces accessible by this user (owned + via workspaces).
|
||||
*/
|
||||
public function accessibleNamespaces(): \Illuminate\Database\Eloquent\Builder
|
||||
{
|
||||
return Namespace_::accessibleBy($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user's email has been verified.
|
||||
* Hades accounts are always considered verified.
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||
/**
|
||||
* User two-factor authentication record.
|
||||
*
|
||||
* Native implementation replacing Mixpost's 2FA model.
|
||||
* Stores TOTP secrets and recovery codes for 2FA.
|
||||
*/
|
||||
class UserTwoFactorAuth extends Model
|
||||
{
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Core\Mod\Tenant\Services\EntitlementResult;
|
||||
use Core\Mod\Tenant\Services\EntitlementService;
|
||||
|
||||
|
|
@ -103,6 +104,30 @@ class Workspace extends Model
|
|||
return $this->hasMany(WorkspacePackage::class);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Namespace Relationships
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get all namespaces owned by this workspace.
|
||||
*/
|
||||
public function namespaces(): MorphMany
|
||||
{
|
||||
return $this->morphMany(Namespace_::class, 'owner');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the workspace's default namespace.
|
||||
*/
|
||||
public function defaultNamespace(): ?Namespace_
|
||||
{
|
||||
return $this->namespaces()
|
||||
->where('is_default', true)
|
||||
->active()
|
||||
->first()
|
||||
?? $this->namespaces()->active()->ordered()->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* The package definitions assigned to this workspace.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ namespace Core\Mod\Tenant\Services;
|
|||
use Core\Mod\Tenant\Models\Boost;
|
||||
use Core\Mod\Tenant\Models\EntitlementLog;
|
||||
use Core\Mod\Tenant\Models\Feature;
|
||||
use Core\Mod\Tenant\Models\Namespace_;
|
||||
use Core\Mod\Tenant\Models\NamespacePackage;
|
||||
use Core\Mod\Tenant\Models\Package;
|
||||
use Core\Mod\Tenant\Models\UsageRecord;
|
||||
use Core\Mod\Tenant\Models\Workspace;
|
||||
|
|
@ -78,6 +80,119 @@ class EntitlementService
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a namespace can use a feature.
|
||||
*
|
||||
* Entitlement cascade:
|
||||
* 1. Check namespace-level packages first
|
||||
* 2. Fall back to workspace pool (if namespace has workspace context)
|
||||
* 3. Fall back to user tier (for user-owned namespaces without workspace)
|
||||
*/
|
||||
public function canForNamespace(Namespace_ $namespace, string $featureCode, int $quantity = 1): EntitlementResult
|
||||
{
|
||||
$feature = $this->getFeature($featureCode);
|
||||
|
||||
if (! $feature) {
|
||||
return EntitlementResult::denied(
|
||||
reason: "Feature '{$featureCode}' does not exist.",
|
||||
featureCode: $featureCode
|
||||
);
|
||||
}
|
||||
|
||||
// Get the pool feature code (parent if hierarchical)
|
||||
$poolFeatureCode = $feature->getPoolFeatureCode();
|
||||
|
||||
// Try namespace-level limit first
|
||||
$totalLimit = $this->getNamespaceTotalLimit($namespace, $poolFeatureCode);
|
||||
|
||||
// If not found at namespace level, try workspace fallback
|
||||
if ($totalLimit === null && $namespace->workspace_id) {
|
||||
$workspace = $namespace->workspace;
|
||||
if ($workspace) {
|
||||
$totalLimit = $this->getTotalLimit($workspace, $poolFeatureCode);
|
||||
}
|
||||
}
|
||||
|
||||
// If still not found, try user tier fallback for user-owned namespaces
|
||||
if ($totalLimit === null && $namespace->isOwnedByUser()) {
|
||||
$user = $namespace->getOwnerUser();
|
||||
if ($user) {
|
||||
// Check if user's tier includes this feature
|
||||
if ($feature->isBoolean()) {
|
||||
$hasFeature = $user->hasFeature($featureCode);
|
||||
if ($hasFeature) {
|
||||
return EntitlementResult::allowed(featureCode: $featureCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($totalLimit === null) {
|
||||
return EntitlementResult::denied(
|
||||
reason: "Your plan does not include {$feature->name}.",
|
||||
featureCode: $featureCode
|
||||
);
|
||||
}
|
||||
|
||||
// Check for unlimited
|
||||
if ($totalLimit === -1) {
|
||||
return EntitlementResult::unlimited($featureCode);
|
||||
}
|
||||
|
||||
// For boolean features, just check if enabled
|
||||
if ($feature->isBoolean()) {
|
||||
return EntitlementResult::allowed(featureCode: $featureCode);
|
||||
}
|
||||
|
||||
// Get current usage
|
||||
$currentUsage = $this->getNamespaceCurrentUsage($namespace, $poolFeatureCode, $feature);
|
||||
|
||||
// Check if quantity would exceed limit
|
||||
if ($currentUsage + $quantity > $totalLimit) {
|
||||
return EntitlementResult::denied(
|
||||
reason: "You've reached your {$feature->name} limit ({$totalLimit}).",
|
||||
limit: $totalLimit,
|
||||
used: $currentUsage,
|
||||
featureCode: $featureCode
|
||||
);
|
||||
}
|
||||
|
||||
return EntitlementResult::allowed(
|
||||
limit: $totalLimit,
|
||||
used: $currentUsage,
|
||||
featureCode: $featureCode
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record usage of a feature for a namespace.
|
||||
*/
|
||||
public function recordNamespaceUsage(
|
||||
Namespace_ $namespace,
|
||||
string $featureCode,
|
||||
int $quantity = 1,
|
||||
?User $user = null,
|
||||
?array $metadata = null
|
||||
): UsageRecord {
|
||||
$feature = $this->getFeature($featureCode);
|
||||
$poolFeatureCode = $feature?->getPoolFeatureCode() ?? $featureCode;
|
||||
|
||||
$record = UsageRecord::create([
|
||||
'namespace_id' => $namespace->id,
|
||||
'workspace_id' => $namespace->workspace_id,
|
||||
'feature_code' => $poolFeatureCode,
|
||||
'quantity' => $quantity,
|
||||
'user_id' => $user?->id,
|
||||
'metadata' => $metadata,
|
||||
'recorded_at' => now(),
|
||||
]);
|
||||
|
||||
// Invalidate cache
|
||||
$this->invalidateNamespaceCache($namespace);
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record usage of a feature.
|
||||
*/
|
||||
|
|
@ -477,4 +592,230 @@ class EntitlementService
|
|||
|
||||
$this->invalidateCache($workspace);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Namespace-specific methods
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get the total limit for a feature from namespace-level packages + boosts.
|
||||
*
|
||||
* Returns null if feature not included, -1 if unlimited.
|
||||
*/
|
||||
protected function getNamespaceTotalLimit(Namespace_ $namespace, string $featureCode): ?int
|
||||
{
|
||||
$cacheKey = "entitlement:ns:{$namespace->id}:limit:{$featureCode}";
|
||||
|
||||
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($namespace, $featureCode) {
|
||||
$feature = $this->getFeature($featureCode);
|
||||
|
||||
if (! $feature) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$totalLimit = 0;
|
||||
$hasFeature = false;
|
||||
|
||||
// Sum limits from active namespace packages
|
||||
$packages = $namespace->namespacePackages()
|
||||
->with('package.features')
|
||||
->active()
|
||||
->notExpired()
|
||||
->get();
|
||||
|
||||
foreach ($packages as $namespacePackage) {
|
||||
$packageFeature = $namespacePackage->package->features
|
||||
->where('code', $featureCode)
|
||||
->first();
|
||||
|
||||
if ($packageFeature) {
|
||||
$hasFeature = true;
|
||||
|
||||
// Check if unlimited in this package
|
||||
if ($packageFeature->type === Feature::TYPE_UNLIMITED) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Add limit value (null = boolean, no limit to add)
|
||||
$limitValue = $packageFeature->pivot->limit_value;
|
||||
if ($limitValue !== null) {
|
||||
$totalLimit += $limitValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add limits from active namespace-level boosts
|
||||
$boosts = $namespace->boosts()
|
||||
->forFeature($featureCode)
|
||||
->usable()
|
||||
->get();
|
||||
|
||||
foreach ($boosts as $boost) {
|
||||
$hasFeature = true;
|
||||
|
||||
if ($boost->boost_type === Boost::BOOST_TYPE_UNLIMITED) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if ($boost->boost_type === Boost::BOOST_TYPE_ADD_LIMIT) {
|
||||
$remaining = $boost->getRemainingLimit();
|
||||
if ($remaining !== null) {
|
||||
$totalLimit += $remaining;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $hasFeature ? $totalLimit : null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current usage for a feature at namespace level.
|
||||
*/
|
||||
protected function getNamespaceCurrentUsage(Namespace_ $namespace, string $featureCode, Feature $feature): int
|
||||
{
|
||||
$cacheKey = "entitlement:ns:{$namespace->id}:usage:{$featureCode}";
|
||||
|
||||
return Cache::remember($cacheKey, 60, function () use ($namespace, $featureCode, $feature) {
|
||||
// Determine the time window for usage calculation
|
||||
if ($feature->resetsMonthly()) {
|
||||
// Get billing cycle anchor from the primary package
|
||||
$primaryPackage = $namespace->namespacePackages()
|
||||
->whereHas('package', fn ($q) => $q->where('is_base_package', true))
|
||||
->active()
|
||||
->first();
|
||||
|
||||
$cycleStart = $primaryPackage
|
||||
? $primaryPackage->getCurrentCycleStart()
|
||||
: now()->startOfMonth();
|
||||
|
||||
return UsageRecord::where('namespace_id', $namespace->id)
|
||||
->where('feature_code', $featureCode)
|
||||
->where('recorded_at', '>=', $cycleStart)
|
||||
->sum('quantity');
|
||||
}
|
||||
|
||||
if ($feature->resetsRolling()) {
|
||||
$days = $feature->rolling_window_days ?? 30;
|
||||
$since = now()->subDays($days);
|
||||
|
||||
return UsageRecord::where('namespace_id', $namespace->id)
|
||||
->where('feature_code', $featureCode)
|
||||
->where('recorded_at', '>=', $since)
|
||||
->sum('quantity');
|
||||
}
|
||||
|
||||
// No reset - all time usage
|
||||
return UsageRecord::where('namespace_id', $namespace->id)
|
||||
->where('feature_code', $featureCode)
|
||||
->sum('quantity');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get usage summary for a namespace.
|
||||
*/
|
||||
public function getNamespaceUsageSummary(Namespace_ $namespace): Collection
|
||||
{
|
||||
$features = Feature::active()->orderBy('category')->orderBy('sort_order')->get();
|
||||
$summary = collect();
|
||||
|
||||
foreach ($features as $feature) {
|
||||
$result = $this->canForNamespace($namespace, $feature->code);
|
||||
|
||||
$summary->push([
|
||||
'feature' => $feature,
|
||||
'code' => $feature->code,
|
||||
'name' => $feature->name,
|
||||
'category' => $feature->category,
|
||||
'type' => $feature->type,
|
||||
'allowed' => $result->isAllowed(),
|
||||
'limit' => $result->limit,
|
||||
'used' => $result->used,
|
||||
'remaining' => $result->remaining,
|
||||
'unlimited' => $result->isUnlimited(),
|
||||
'percentage' => $result->getUsagePercentage(),
|
||||
'near_limit' => $result->isNearLimit(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $summary->groupBy('category');
|
||||
}
|
||||
|
||||
/**
|
||||
* Provision a package for a namespace.
|
||||
*/
|
||||
public function provisionNamespacePackage(
|
||||
Namespace_ $namespace,
|
||||
string $packageCode,
|
||||
array $options = []
|
||||
): NamespacePackage {
|
||||
$package = Package::where('code', $packageCode)->firstOrFail();
|
||||
|
||||
// Check if this is a base package and namespace already has one
|
||||
if ($package->is_base_package) {
|
||||
$existingBase = $namespace->namespacePackages()
|
||||
->whereHas('package', fn ($q) => $q->where('is_base_package', true))
|
||||
->active()
|
||||
->first();
|
||||
|
||||
if ($existingBase) {
|
||||
// Cancel existing base package
|
||||
$existingBase->cancel(now());
|
||||
}
|
||||
}
|
||||
|
||||
$namespacePackage = NamespacePackage::create([
|
||||
'namespace_id' => $namespace->id,
|
||||
'package_id' => $package->id,
|
||||
'status' => NamespacePackage::STATUS_ACTIVE,
|
||||
'starts_at' => $options['starts_at'] ?? now(),
|
||||
'expires_at' => $options['expires_at'] ?? null,
|
||||
'billing_cycle_anchor' => $options['billing_cycle_anchor'] ?? now(),
|
||||
'metadata' => $options['metadata'] ?? null,
|
||||
]);
|
||||
|
||||
$this->invalidateNamespaceCache($namespace);
|
||||
|
||||
return $namespacePackage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provision a boost for a namespace.
|
||||
*/
|
||||
public function provisionNamespaceBoost(
|
||||
Namespace_ $namespace,
|
||||
string $featureCode,
|
||||
array $options = []
|
||||
): Boost {
|
||||
$boost = Boost::create([
|
||||
'namespace_id' => $namespace->id,
|
||||
'workspace_id' => $namespace->workspace_id,
|
||||
'feature_code' => $featureCode,
|
||||
'boost_type' => $options['boost_type'] ?? Boost::BOOST_TYPE_ADD_LIMIT,
|
||||
'duration_type' => $options['duration_type'] ?? Boost::DURATION_CYCLE_BOUND,
|
||||
'limit_value' => $options['limit_value'] ?? null,
|
||||
'consumed_quantity' => 0,
|
||||
'status' => Boost::STATUS_ACTIVE,
|
||||
'starts_at' => $options['starts_at'] ?? now(),
|
||||
'expires_at' => $options['expires_at'] ?? null,
|
||||
'metadata' => $options['metadata'] ?? null,
|
||||
]);
|
||||
|
||||
$this->invalidateNamespaceCache($namespace);
|
||||
|
||||
return $boost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate all entitlement caches for a namespace.
|
||||
*/
|
||||
public function invalidateNamespaceCache(Namespace_ $namespace): void
|
||||
{
|
||||
$features = Feature::pluck('code');
|
||||
foreach ($features as $code) {
|
||||
Cache::forget("entitlement:ns:{$namespace->id}:limit:{$code}");
|
||||
Cache::forget("entitlement:ns:{$namespace->id}:usage:{$code}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
194
packages/core-php/src/Mod/Tenant/Services/TotpService.php
Normal file
194
packages/core-php/src/Mod/Tenant/Services/TotpService.php
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Services;
|
||||
|
||||
use chillerlan\QRCode\QRCode;
|
||||
use chillerlan\QRCode\QROptions;
|
||||
use Core\Mod\Tenant\Contracts\TwoFactorAuthenticationProvider;
|
||||
|
||||
/**
|
||||
* TOTP (Time-based One-Time Password) service.
|
||||
*
|
||||
* Implements RFC 6238 TOTP algorithm for two-factor authentication.
|
||||
* Uses chillerlan/php-qrcode for QR generation.
|
||||
*/
|
||||
class TotpService implements TwoFactorAuthenticationProvider
|
||||
{
|
||||
/**
|
||||
* The number of seconds a TOTP code is valid.
|
||||
*/
|
||||
protected const int TIME_STEP = 30;
|
||||
|
||||
/**
|
||||
* The number of digits in a TOTP code.
|
||||
*/
|
||||
protected const int CODE_LENGTH = 6;
|
||||
|
||||
/**
|
||||
* The hash algorithm to use.
|
||||
*/
|
||||
protected const string ALGORITHM = 'sha1';
|
||||
|
||||
/**
|
||||
* Number of time periods to check in each direction for clock drift.
|
||||
*/
|
||||
protected const int WINDOW = 1;
|
||||
|
||||
/**
|
||||
* Generate a new secret key for TOTP.
|
||||
*
|
||||
* Generates a 160-bit secret encoded in base32.
|
||||
*/
|
||||
public function generateSecretKey(): string
|
||||
{
|
||||
$secret = random_bytes(20); // 160 bits
|
||||
|
||||
return $this->base32Encode($secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate QR code URL for authenticator app setup.
|
||||
*
|
||||
* @param string $name Application/account name
|
||||
* @param string $email User email
|
||||
* @param string $secret TOTP secret key
|
||||
*/
|
||||
public function qrCodeUrl(string $name, string $email, string $secret): string
|
||||
{
|
||||
$encodedName = rawurlencode($name);
|
||||
$encodedEmail = rawurlencode($email);
|
||||
|
||||
return "otpauth://totp/{$encodedName}:{$encodedEmail}?secret={$secret}&issuer={$encodedName}&algorithm=SHA1&digits=6&period=30";
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a QR code SVG for the given URL.
|
||||
*/
|
||||
public function qrCodeSvg(string $url): string
|
||||
{
|
||||
$options = new QROptions([
|
||||
'outputType' => QRCode::OUTPUT_MARKUP_SVG,
|
||||
'eccLevel' => QRCode::ECC_M,
|
||||
'imageBase64' => false,
|
||||
'addQuietzone' => true,
|
||||
'quietzoneSize' => 2,
|
||||
'drawLightModules' => false,
|
||||
'svgViewBoxSize' => 200,
|
||||
]);
|
||||
|
||||
return (new QRCode($options))->render($url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a TOTP code against the secret.
|
||||
*
|
||||
* @param string $secret TOTP secret key (base32 encoded)
|
||||
* @param string $code User-provided 6-digit code
|
||||
*/
|
||||
public function verify(string $secret, string $code): bool
|
||||
{
|
||||
// Remove any spaces or dashes from the code
|
||||
$code = preg_replace('/[^0-9]/', '', $code);
|
||||
|
||||
if (strlen($code) !== self::CODE_LENGTH) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$secretBytes = $this->base32Decode($secret);
|
||||
$timestamp = time();
|
||||
|
||||
// Check current time and adjacent windows for clock drift
|
||||
for ($i = -self::WINDOW; $i <= self::WINDOW; $i++) {
|
||||
$calculatedCode = $this->generateCode($secretBytes, $timestamp + ($i * self::TIME_STEP));
|
||||
|
||||
if (hash_equals($calculatedCode, $code)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a TOTP code for a given timestamp.
|
||||
*/
|
||||
protected function generateCode(string $secretBytes, int $timestamp): string
|
||||
{
|
||||
$counter = (int) floor($timestamp / self::TIME_STEP);
|
||||
|
||||
// Pack counter as 64-bit big-endian
|
||||
$counterBytes = pack('N*', 0, $counter);
|
||||
|
||||
// Generate HMAC
|
||||
$hash = hash_hmac(self::ALGORITHM, $counterBytes, $secretBytes, true);
|
||||
|
||||
// Dynamic truncation
|
||||
$offset = ord($hash[strlen($hash) - 1]) & 0x0F;
|
||||
$binary =
|
||||
((ord($hash[$offset]) & 0x7F) << 24) |
|
||||
((ord($hash[$offset + 1]) & 0xFF) << 16) |
|
||||
((ord($hash[$offset + 2]) & 0xFF) << 8) |
|
||||
(ord($hash[$offset + 3]) & 0xFF);
|
||||
|
||||
$otp = $binary % (10 ** self::CODE_LENGTH);
|
||||
|
||||
return str_pad((string) $otp, self::CODE_LENGTH, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode bytes as base32.
|
||||
*/
|
||||
protected function base32Encode(string $data): string
|
||||
{
|
||||
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
$binary = '';
|
||||
|
||||
foreach (str_split($data) as $char) {
|
||||
$binary .= str_pad(decbin(ord($char)), 8, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
$encoded = '';
|
||||
$chunks = str_split($binary, 5);
|
||||
|
||||
foreach ($chunks as $chunk) {
|
||||
if (strlen($chunk) < 5) {
|
||||
$chunk = str_pad($chunk, 5, '0', STR_PAD_RIGHT);
|
||||
}
|
||||
$encoded .= $alphabet[bindec($chunk)];
|
||||
}
|
||||
|
||||
return $encoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode base32 to bytes.
|
||||
*/
|
||||
protected function base32Decode(string $data): string
|
||||
{
|
||||
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
$data = strtoupper($data);
|
||||
$data = rtrim($data, '=');
|
||||
|
||||
$binary = '';
|
||||
foreach (str_split($data) as $char) {
|
||||
$index = strpos($alphabet, $char);
|
||||
if ($index === false) {
|
||||
continue;
|
||||
}
|
||||
$binary .= str_pad(decbin($index), 5, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
$decoded = '';
|
||||
$chunks = str_split($binary, 8);
|
||||
|
||||
foreach ($chunks as $chunk) {
|
||||
if (strlen($chunk) === 8) {
|
||||
$decoded .= chr(bindec($chunk));
|
||||
}
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,334 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Core\Mod\Tenant\Jobs\ProcessAccountDeletion;
|
||||
use Core\Mod\Tenant\Models\AccountDeletionRequest;
|
||||
use Core\Mod\Tenant\Models\User;
|
||||
use Core\Mod\Tenant\Models\Workspace;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
beforeEach(function () {
|
||||
Cache::flush();
|
||||
$this->user = User::factory()->create();
|
||||
$this->workspace = Workspace::factory()->create();
|
||||
$this->workspace->users()->attach($this->user->id, [
|
||||
'role' => 'owner',
|
||||
'is_default' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
describe('AccountDeletionRequest Model', function () {
|
||||
describe('createForUser()', function () {
|
||||
it('creates a new deletion request', function () {
|
||||
$request = AccountDeletionRequest::createForUser($this->user);
|
||||
|
||||
expect($request)->toBeInstanceOf(AccountDeletionRequest::class)
|
||||
->and($request->user_id)->toBe($this->user->id)
|
||||
->and($request->token)->toHaveLength(64)
|
||||
->and($request->completed_at)->toBeNull()
|
||||
->and($request->cancelled_at)->toBeNull();
|
||||
});
|
||||
|
||||
it('sets expiry based on configured grace period', function () {
|
||||
config(['tenant.deletion.grace_period_days' => 14]);
|
||||
|
||||
$this->travelTo(now()->startOfDay());
|
||||
$request = AccountDeletionRequest::createForUser($this->user);
|
||||
|
||||
// Expiry should be 14 days in the future
|
||||
expect((int) abs($request->expires_at->startOfDay()->diffInDays(now()->startOfDay())))->toBe(14);
|
||||
});
|
||||
|
||||
it('stores optional reason', function () {
|
||||
$reason = 'Switching to competitor';
|
||||
|
||||
$request = AccountDeletionRequest::createForUser($this->user, $reason);
|
||||
|
||||
expect($request->reason)->toBe($reason);
|
||||
});
|
||||
|
||||
it('cancels existing pending requests', function () {
|
||||
$oldRequest = AccountDeletionRequest::createForUser($this->user);
|
||||
$oldRequestId = $oldRequest->id;
|
||||
|
||||
$newRequest = AccountDeletionRequest::createForUser($this->user);
|
||||
|
||||
expect(AccountDeletionRequest::find($oldRequestId))->toBeNull()
|
||||
->and($newRequest->id)->not->toBe($oldRequestId);
|
||||
});
|
||||
|
||||
it('does not affect completed requests', function () {
|
||||
$completedRequest = AccountDeletionRequest::createForUser($this->user);
|
||||
$completedRequest->complete();
|
||||
|
||||
$newRequest = AccountDeletionRequest::createForUser($this->user);
|
||||
|
||||
expect(AccountDeletionRequest::find($completedRequest->id))->not->toBeNull()
|
||||
->and($newRequest->id)->not->toBe($completedRequest->id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findValidByToken()', function () {
|
||||
it('finds valid request by token', function () {
|
||||
$request = AccountDeletionRequest::createForUser($this->user);
|
||||
|
||||
$found = AccountDeletionRequest::findValidByToken($request->token);
|
||||
|
||||
expect($found)->not->toBeNull()
|
||||
->and($found->id)->toBe($request->id);
|
||||
});
|
||||
|
||||
it('returns null for completed request', function () {
|
||||
$request = AccountDeletionRequest::createForUser($this->user);
|
||||
$request->complete();
|
||||
|
||||
$found = AccountDeletionRequest::findValidByToken($request->token);
|
||||
|
||||
expect($found)->toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for cancelled request', function () {
|
||||
$request = AccountDeletionRequest::createForUser($this->user);
|
||||
$request->cancel();
|
||||
|
||||
$found = AccountDeletionRequest::findValidByToken($request->token);
|
||||
|
||||
expect($found)->toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for invalid token', function () {
|
||||
AccountDeletionRequest::createForUser($this->user);
|
||||
|
||||
$found = AccountDeletionRequest::findValidByToken('invalid-token');
|
||||
|
||||
expect($found)->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pendingAutoDelete()', function () {
|
||||
it('returns requests past expiry date', function () {
|
||||
$request = AccountDeletionRequest::createForUser($this->user);
|
||||
$request->update(['expires_at' => now()->subDay()]);
|
||||
|
||||
$pending = AccountDeletionRequest::pendingAutoDelete()->get();
|
||||
|
||||
expect($pending)->toHaveCount(1)
|
||||
->and($pending->first()->id)->toBe($request->id);
|
||||
});
|
||||
|
||||
it('excludes requests not yet expired', function () {
|
||||
AccountDeletionRequest::createForUser($this->user);
|
||||
|
||||
$pending = AccountDeletionRequest::pendingAutoDelete()->get();
|
||||
|
||||
expect($pending)->toHaveCount(0);
|
||||
});
|
||||
|
||||
it('excludes completed requests', function () {
|
||||
$request = AccountDeletionRequest::createForUser($this->user);
|
||||
$request->update(['expires_at' => now()->subDay()]);
|
||||
$request->complete();
|
||||
|
||||
$pending = AccountDeletionRequest::pendingAutoDelete()->get();
|
||||
|
||||
expect($pending)->toHaveCount(0);
|
||||
});
|
||||
|
||||
it('excludes cancelled requests', function () {
|
||||
$request = AccountDeletionRequest::createForUser($this->user);
|
||||
$request->update(['expires_at' => now()->subDay()]);
|
||||
$request->cancel();
|
||||
|
||||
$pending = AccountDeletionRequest::pendingAutoDelete()->get();
|
||||
|
||||
expect($pending)->toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('state methods', function () {
|
||||
it('isActive returns true for pending requests', function () {
|
||||
$request = AccountDeletionRequest::createForUser($this->user);
|
||||
|
||||
expect($request->isActive())->toBeTrue();
|
||||
});
|
||||
|
||||
it('isActive returns false after completion', function () {
|
||||
$request = AccountDeletionRequest::createForUser($this->user);
|
||||
$request->complete();
|
||||
|
||||
expect($request->isActive())->toBeFalse();
|
||||
});
|
||||
|
||||
it('isActive returns false after cancellation', function () {
|
||||
$request = AccountDeletionRequest::createForUser($this->user);
|
||||
$request->cancel();
|
||||
|
||||
expect($request->isActive())->toBeFalse();
|
||||
});
|
||||
|
||||
it('isPending returns true for future expiry', function () {
|
||||
$request = AccountDeletionRequest::createForUser($this->user);
|
||||
|
||||
expect($request->isPending())->toBeTrue();
|
||||
});
|
||||
|
||||
it('isReadyForAutoDeletion returns true for past expiry', function () {
|
||||
$request = AccountDeletionRequest::createForUser($this->user);
|
||||
$request->update(['expires_at' => now()->subDay()]);
|
||||
|
||||
expect($request->isReadyForAutoDeletion())->toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
describe('time helpers', function () {
|
||||
it('calculates days remaining approximately', function () {
|
||||
$this->travelTo(now()->startOfDay());
|
||||
|
||||
$request = AccountDeletionRequest::createForUser($this->user);
|
||||
$request->update(['expires_at' => now()->startOfDay()->addDays(5)]);
|
||||
|
||||
// Use startOfDay to avoid timing issues
|
||||
expect($request->daysRemaining())->toBeGreaterThanOrEqual(4)
|
||||
->and($request->daysRemaining())->toBeLessThanOrEqual(5);
|
||||
});
|
||||
|
||||
it('calculates hours remaining approximately', function () {
|
||||
$this->travelTo(now()->startOfHour());
|
||||
|
||||
$request = AccountDeletionRequest::createForUser($this->user);
|
||||
$request->update(['expires_at' => now()->startOfHour()->addHours(48)]);
|
||||
|
||||
expect($request->hoursRemaining())->toBeGreaterThanOrEqual(47)
|
||||
->and($request->hoursRemaining())->toBeLessThanOrEqual(48);
|
||||
});
|
||||
|
||||
it('returns zero for past expiry', function () {
|
||||
$request = AccountDeletionRequest::createForUser($this->user);
|
||||
$request->update(['expires_at' => now()->subDays(2)]);
|
||||
|
||||
expect($request->daysRemaining())->toBe(0)
|
||||
->and($request->hoursRemaining())->toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL helpers', function () {
|
||||
it('generates confirmation URL with token', function () {
|
||||
$request = AccountDeletionRequest::createForUser($this->user);
|
||||
|
||||
$url = $request->confirmationUrl();
|
||||
|
||||
expect($url)->toContain($request->token)
|
||||
->and($url)->toContain('account/delete');
|
||||
});
|
||||
|
||||
it('generates cancel URL with token', function () {
|
||||
$request = AccountDeletionRequest::createForUser($this->user);
|
||||
|
||||
$url = $request->cancelUrl();
|
||||
|
||||
expect($url)->toContain($request->token)
|
||||
->and($url)->toContain('cancel');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProcessAccountDeletion Job', function () {
|
||||
it('deletes user account', function () {
|
||||
$request = AccountDeletionRequest::createForUser($this->user);
|
||||
$request->update(['expires_at' => now()->subDay()]);
|
||||
|
||||
$job = new ProcessAccountDeletion($request);
|
||||
$job->handle();
|
||||
|
||||
// User should be deleted
|
||||
expect(User::find($this->user->id))->toBeNull();
|
||||
|
||||
// Note: AccountDeletionRequest is also deleted due to CASCADE constraint
|
||||
// This is expected behaviour as we want the request deleted when user is deleted
|
||||
});
|
||||
|
||||
it('deletes user workspaces', function () {
|
||||
$request = AccountDeletionRequest::createForUser($this->user);
|
||||
$request->update(['expires_at' => now()->subDay()]);
|
||||
$workspaceId = $this->workspace->id;
|
||||
|
||||
$job = new ProcessAccountDeletion($request);
|
||||
$job->handle();
|
||||
|
||||
expect(Workspace::find($workspaceId))->toBeNull();
|
||||
});
|
||||
|
||||
it('skips if request no longer active', function () {
|
||||
$request = AccountDeletionRequest::createForUser($this->user);
|
||||
$request->cancel();
|
||||
|
||||
$job = new ProcessAccountDeletion($request);
|
||||
$job->handle();
|
||||
|
||||
expect(User::find($this->user->id))->not->toBeNull();
|
||||
});
|
||||
|
||||
it('handles missing user gracefully', function () {
|
||||
$request = AccountDeletionRequest::createForUser($this->user);
|
||||
$this->user->forceDelete();
|
||||
|
||||
// Request is deleted due to CASCADE, job should handle this gracefully
|
||||
$job = new ProcessAccountDeletion($request);
|
||||
|
||||
// Should not throw
|
||||
$job->handle();
|
||||
|
||||
// Just verify user is still gone
|
||||
expect(User::find($this->user->id))->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProcessAccountDeletions Command', function () {
|
||||
it('processes expired deletion requests', function () {
|
||||
$request = AccountDeletionRequest::createForUser($this->user);
|
||||
$request->update(['expires_at' => now()->subDay()]);
|
||||
|
||||
$this->artisan('accounts:process-deletions')
|
||||
->assertSuccessful()
|
||||
->expectsOutputToContain('1 deleted');
|
||||
|
||||
expect(User::find($this->user->id))->toBeNull();
|
||||
});
|
||||
|
||||
it('skips non-expired requests', function () {
|
||||
AccountDeletionRequest::createForUser($this->user);
|
||||
|
||||
$this->artisan('accounts:process-deletions')
|
||||
->assertSuccessful()
|
||||
->expectsOutputToContain('No pending account deletions');
|
||||
|
||||
expect(User::find($this->user->id))->not->toBeNull();
|
||||
});
|
||||
|
||||
it('supports dry-run mode', function () {
|
||||
$request = AccountDeletionRequest::createForUser($this->user);
|
||||
$request->update(['expires_at' => now()->subDay()]);
|
||||
|
||||
$this->artisan('accounts:process-deletions', ['--dry-run' => true])
|
||||
->assertSuccessful()
|
||||
->expectsOutputToContain('DRY RUN');
|
||||
|
||||
// User should still exist
|
||||
expect(User::find($this->user->id))->not->toBeNull();
|
||||
});
|
||||
|
||||
it('supports queue mode', function () {
|
||||
Queue::fake();
|
||||
|
||||
$request = AccountDeletionRequest::createForUser($this->user);
|
||||
$request->update(['expires_at' => now()->subDay()]);
|
||||
|
||||
$this->artisan('accounts:process-deletions', ['--queue' => true])
|
||||
->assertSuccessful()
|
||||
->expectsOutputToContain('queued');
|
||||
|
||||
Queue::assertPushed(ProcessAccountDeletion::class);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,334 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Core\Mod\Tenant\Models\User;
|
||||
use Core\Mod\Tenant\Models\UserTwoFactorAuth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
beforeEach(function () {
|
||||
Cache::flush();
|
||||
$this->user = User::factory()->create();
|
||||
});
|
||||
|
||||
describe('TwoFactorAuthenticatable Trait', function () {
|
||||
describe('twoFactorAuth() relationship', function () {
|
||||
it('returns HasOne relationship', function () {
|
||||
expect($this->user->twoFactorAuth())->toBeInstanceOf(
|
||||
\Illuminate\Database\Eloquent\Relations\HasOne::class
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null when no 2FA record exists', function () {
|
||||
expect($this->user->twoFactorAuth)->toBeNull();
|
||||
});
|
||||
|
||||
it('returns 2FA record when it exists', function () {
|
||||
$twoFactorAuth = UserTwoFactorAuth::create([
|
||||
'user_id' => $this->user->id,
|
||||
'secret_key' => 'JBSWY3DPEHPK3PXP',
|
||||
'recovery_codes' => ['code1', 'code2'],
|
||||
'confirmed_at' => now(),
|
||||
]);
|
||||
|
||||
$this->user->refresh();
|
||||
|
||||
expect($this->user->twoFactorAuth)->toBeInstanceOf(UserTwoFactorAuth::class)
|
||||
->and($this->user->twoFactorAuth->id)->toBe($twoFactorAuth->id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasTwoFactorAuthEnabled()', function () {
|
||||
it('returns false when no 2FA record exists', function () {
|
||||
expect($this->user->hasTwoFactorAuthEnabled())->toBeFalse();
|
||||
});
|
||||
|
||||
it('returns false when 2FA record exists but secret_key is null', function () {
|
||||
UserTwoFactorAuth::create([
|
||||
'user_id' => $this->user->id,
|
||||
'secret_key' => null,
|
||||
'recovery_codes' => [],
|
||||
'confirmed_at' => now(),
|
||||
]);
|
||||
|
||||
$this->user->refresh();
|
||||
|
||||
expect($this->user->hasTwoFactorAuthEnabled())->toBeFalse();
|
||||
});
|
||||
|
||||
it('returns false when 2FA record exists but confirmed_at is null', function () {
|
||||
UserTwoFactorAuth::create([
|
||||
'user_id' => $this->user->id,
|
||||
'secret_key' => 'JBSWY3DPEHPK3PXP',
|
||||
'recovery_codes' => [],
|
||||
'confirmed_at' => null,
|
||||
]);
|
||||
|
||||
$this->user->refresh();
|
||||
|
||||
expect($this->user->hasTwoFactorAuthEnabled())->toBeFalse();
|
||||
});
|
||||
|
||||
it('returns true when 2FA is fully enabled', function () {
|
||||
UserTwoFactorAuth::create([
|
||||
'user_id' => $this->user->id,
|
||||
'secret_key' => 'JBSWY3DPEHPK3PXP',
|
||||
'recovery_codes' => ['code1', 'code2'],
|
||||
'confirmed_at' => now(),
|
||||
]);
|
||||
|
||||
$this->user->refresh();
|
||||
|
||||
expect($this->user->hasTwoFactorAuthEnabled())->toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
describe('twoFactorAuthSecretKey()', function () {
|
||||
it('returns null when no 2FA record exists', function () {
|
||||
expect($this->user->twoFactorAuthSecretKey())->toBeNull();
|
||||
});
|
||||
|
||||
it('returns the secret key when 2FA record exists', function () {
|
||||
$secretKey = 'JBSWY3DPEHPK3PXP';
|
||||
|
||||
UserTwoFactorAuth::create([
|
||||
'user_id' => $this->user->id,
|
||||
'secret_key' => $secretKey,
|
||||
'recovery_codes' => [],
|
||||
]);
|
||||
|
||||
$this->user->refresh();
|
||||
|
||||
expect($this->user->twoFactorAuthSecretKey())->toBe($secretKey);
|
||||
});
|
||||
});
|
||||
|
||||
describe('twoFactorRecoveryCodes()', function () {
|
||||
it('returns empty array when no 2FA record exists', function () {
|
||||
expect($this->user->twoFactorRecoveryCodes())->toBe([]);
|
||||
});
|
||||
|
||||
it('returns empty array when recovery_codes is null', function () {
|
||||
UserTwoFactorAuth::create([
|
||||
'user_id' => $this->user->id,
|
||||
'secret_key' => 'JBSWY3DPEHPK3PXP',
|
||||
'recovery_codes' => null,
|
||||
]);
|
||||
|
||||
$this->user->refresh();
|
||||
|
||||
expect($this->user->twoFactorRecoveryCodes())->toBe([]);
|
||||
});
|
||||
|
||||
it('returns recovery codes as array', function () {
|
||||
$codes = ['CODE1-CODE1', 'CODE2-CODE2', 'CODE3-CODE3'];
|
||||
|
||||
UserTwoFactorAuth::create([
|
||||
'user_id' => $this->user->id,
|
||||
'secret_key' => 'JBSWY3DPEHPK3PXP',
|
||||
'recovery_codes' => $codes,
|
||||
]);
|
||||
|
||||
$this->user->refresh();
|
||||
|
||||
expect($this->user->twoFactorRecoveryCodes())->toBe($codes);
|
||||
});
|
||||
});
|
||||
|
||||
describe('twoFactorReplaceRecoveryCode()', function () {
|
||||
it('does nothing when no 2FA record exists', function () {
|
||||
// Should not throw
|
||||
$this->user->twoFactorReplaceRecoveryCode('nonexistent');
|
||||
|
||||
expect($this->user->twoFactorAuth)->toBeNull();
|
||||
});
|
||||
|
||||
it('does nothing when code is not found in recovery codes', function () {
|
||||
$codes = ['CODE1-CODE1', 'CODE2-CODE2', 'CODE3-CODE3'];
|
||||
|
||||
UserTwoFactorAuth::create([
|
||||
'user_id' => $this->user->id,
|
||||
'secret_key' => 'JBSWY3DPEHPK3PXP',
|
||||
'recovery_codes' => $codes,
|
||||
]);
|
||||
|
||||
$this->user->refresh();
|
||||
|
||||
$this->user->twoFactorReplaceRecoveryCode('NONEXISTENT');
|
||||
|
||||
$this->user->refresh();
|
||||
|
||||
expect($this->user->twoFactorRecoveryCodes())->toBe($codes);
|
||||
});
|
||||
|
||||
it('replaces a used recovery code with a new one', function () {
|
||||
$codes = ['CODE1-CODE1', 'CODE2-CODE2', 'CODE3-CODE3'];
|
||||
|
||||
UserTwoFactorAuth::create([
|
||||
'user_id' => $this->user->id,
|
||||
'secret_key' => 'JBSWY3DPEHPK3PXP',
|
||||
'recovery_codes' => $codes,
|
||||
]);
|
||||
|
||||
$this->user->refresh();
|
||||
|
||||
$this->user->twoFactorReplaceRecoveryCode('CODE2-CODE2');
|
||||
|
||||
$this->user->refresh();
|
||||
$newCodes = $this->user->twoFactorRecoveryCodes();
|
||||
|
||||
// Should still have 3 codes
|
||||
expect($newCodes)->toHaveCount(3)
|
||||
// First and third codes should be unchanged
|
||||
->and($newCodes[0])->toBe('CODE1-CODE1')
|
||||
->and($newCodes[2])->toBe('CODE3-CODE3')
|
||||
// Second code should be different and in the expected format
|
||||
->and($newCodes[1])->not->toBe('CODE2-CODE2')
|
||||
->and($newCodes[1])->toMatch('/^[A-F0-9]{10}-[A-F0-9]{10}$/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('twoFactorQrCodeUrl()', function () {
|
||||
it('generates valid TOTP URL', function () {
|
||||
$secretKey = 'JBSWY3DPEHPK3PXP';
|
||||
|
||||
UserTwoFactorAuth::create([
|
||||
'user_id' => $this->user->id,
|
||||
'secret_key' => $secretKey,
|
||||
'recovery_codes' => [],
|
||||
]);
|
||||
|
||||
$this->user->refresh();
|
||||
|
||||
$url = $this->user->twoFactorQrCodeUrl();
|
||||
|
||||
expect($url)->toStartWith('otpauth://totp/')
|
||||
->and($url)->toContain($secretKey)
|
||||
->and($url)->toContain(rawurlencode($this->user->email))
|
||||
->and($url)->toContain('issuer=');
|
||||
});
|
||||
|
||||
it('includes app name in the URL', function () {
|
||||
$appName = config('app.name');
|
||||
|
||||
UserTwoFactorAuth::create([
|
||||
'user_id' => $this->user->id,
|
||||
'secret_key' => 'JBSWY3DPEHPK3PXP',
|
||||
'recovery_codes' => [],
|
||||
]);
|
||||
|
||||
$this->user->refresh();
|
||||
|
||||
$url = $this->user->twoFactorQrCodeUrl();
|
||||
|
||||
expect($url)->toContain(rawurlencode($appName));
|
||||
});
|
||||
});
|
||||
|
||||
describe('twoFactorQrCodeSvg()', function () {
|
||||
it('returns empty string when no secret exists', function () {
|
||||
expect($this->user->twoFactorQrCodeSvg())->toBe('');
|
||||
});
|
||||
|
||||
it('returns SVG content when secret exists', function () {
|
||||
UserTwoFactorAuth::create([
|
||||
'user_id' => $this->user->id,
|
||||
'secret_key' => 'JBSWY3DPEHPK3PXP',
|
||||
'recovery_codes' => [],
|
||||
]);
|
||||
|
||||
$this->user->refresh();
|
||||
|
||||
$svg = $this->user->twoFactorQrCodeSvg();
|
||||
|
||||
expect($svg)->toStartWith('<svg')
|
||||
->and($svg)->toContain('</svg>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateRecoveryCode() via twoFactorReplaceRecoveryCode()', function () {
|
||||
it('generates codes in the expected format', function () {
|
||||
$codes = ['TESTCODE1'];
|
||||
|
||||
UserTwoFactorAuth::create([
|
||||
'user_id' => $this->user->id,
|
||||
'secret_key' => 'JBSWY3DPEHPK3PXP',
|
||||
'recovery_codes' => $codes,
|
||||
]);
|
||||
|
||||
$this->user->refresh();
|
||||
|
||||
$this->user->twoFactorReplaceRecoveryCode('TESTCODE1');
|
||||
|
||||
$this->user->refresh();
|
||||
$newCode = $this->user->twoFactorRecoveryCodes()[0];
|
||||
|
||||
// Format: 10 uppercase hex chars - 10 uppercase hex chars
|
||||
expect($newCode)->toMatch('/^[A-F0-9]{10}-[A-F0-9]{10}$/');
|
||||
});
|
||||
|
||||
it('generates unique codes', function () {
|
||||
$codes = ['CODE1', 'CODE2', 'CODE3'];
|
||||
|
||||
UserTwoFactorAuth::create([
|
||||
'user_id' => $this->user->id,
|
||||
'secret_key' => 'JBSWY3DPEHPK3PXP',
|
||||
'recovery_codes' => $codes,
|
||||
]);
|
||||
|
||||
$this->user->refresh();
|
||||
|
||||
// Replace all codes
|
||||
$this->user->twoFactorReplaceRecoveryCode('CODE1');
|
||||
$this->user->refresh();
|
||||
$this->user->twoFactorReplaceRecoveryCode('CODE2');
|
||||
$this->user->refresh();
|
||||
$this->user->twoFactorReplaceRecoveryCode('CODE3');
|
||||
$this->user->refresh();
|
||||
|
||||
$newCodes = $this->user->twoFactorRecoveryCodes();
|
||||
|
||||
// All codes should be unique
|
||||
expect(array_unique($newCodes))->toHaveCount(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('UserTwoFactorAuth Model', function () {
|
||||
it('belongs to a user', function () {
|
||||
$twoFactorAuth = UserTwoFactorAuth::create([
|
||||
'user_id' => $this->user->id,
|
||||
'secret_key' => 'JBSWY3DPEHPK3PXP',
|
||||
'recovery_codes' => [],
|
||||
]);
|
||||
|
||||
expect($twoFactorAuth->user)->toBeInstanceOf(User::class)
|
||||
->and($twoFactorAuth->user->id)->toBe($this->user->id);
|
||||
});
|
||||
|
||||
it('casts recovery_codes to collection', function () {
|
||||
$codes = ['CODE1', 'CODE2'];
|
||||
|
||||
$twoFactorAuth = UserTwoFactorAuth::create([
|
||||
'user_id' => $this->user->id,
|
||||
'secret_key' => 'JBSWY3DPEHPK3PXP',
|
||||
'recovery_codes' => $codes,
|
||||
]);
|
||||
|
||||
expect($twoFactorAuth->recovery_codes)->toBeInstanceOf(\Illuminate\Support\Collection::class)
|
||||
->and($twoFactorAuth->recovery_codes->toArray())->toBe($codes);
|
||||
});
|
||||
|
||||
it('casts confirmed_at to datetime', function () {
|
||||
$confirmedAt = now();
|
||||
|
||||
$twoFactorAuth = UserTwoFactorAuth::create([
|
||||
'user_id' => $this->user->id,
|
||||
'secret_key' => 'JBSWY3DPEHPK3PXP',
|
||||
'recovery_codes' => [],
|
||||
'confirmed_at' => $confirmedAt,
|
||||
]);
|
||||
|
||||
expect($twoFactorAuth->confirmed_at)->toBeInstanceOf(\Carbon\Carbon::class);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,417 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Core bio/web tables - biolinks, domains, themes, blocks, etc.
|
||||
* Includes HLCRF support (parent_id for hierarchical biolinks).
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::disableForeignKeyConstraints();
|
||||
|
||||
// 1. Biolink Domains
|
||||
Schema::create('biolink_domains', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('host', 256)->unique();
|
||||
$table->string('scheme', 8)->default('https');
|
||||
$table->foreignId('biolink_id')->nullable();
|
||||
$table->string('custom_index_url', 512)->nullable();
|
||||
$table->string('custom_not_found_url', 512)->nullable();
|
||||
$table->boolean('is_enabled')->default(false);
|
||||
$table->enum('verification_status', ['pending', 'verified', 'failed'])->default('pending');
|
||||
$table->string('verification_token', 64)->nullable();
|
||||
$table->timestamp('verified_at')->nullable();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->index(['user_id', 'is_enabled']);
|
||||
$table->index(['workspace_id', 'is_enabled']);
|
||||
});
|
||||
|
||||
// 2. Biolink Projects
|
||||
Schema::create('biolink_projects', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('name', 128);
|
||||
$table->string('color', 16)->default('#6366f1');
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->index(['user_id', 'created_at']);
|
||||
$table->index(['workspace_id', 'created_at']);
|
||||
});
|
||||
|
||||
// 3. Biolink Themes
|
||||
Schema::create('biolink_themes', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->foreignId('workspace_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->string('name', 64);
|
||||
$table->string('slug', 64)->unique();
|
||||
$table->json('settings');
|
||||
$table->boolean('is_system')->default(false);
|
||||
$table->boolean('is_premium')->default(false);
|
||||
$table->boolean('is_gallery')->default(false);
|
||||
$table->string('category', 32)->nullable();
|
||||
$table->string('preview_image', 255)->nullable();
|
||||
$table->text('description')->nullable();
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->unsignedSmallInteger('sort_order')->default(0);
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->index(['is_system', 'is_active', 'sort_order']);
|
||||
$table->index(['user_id', 'is_active']);
|
||||
$table->index(['workspace_id', 'is_active']);
|
||||
$table->index(['is_gallery', 'is_active', 'category', 'sort_order'], 'gallery_filter_index');
|
||||
});
|
||||
|
||||
// 4. Biolinks
|
||||
Schema::create('biolinks', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('project_id')->nullable()->constrained('biolink_projects')->nullOnDelete();
|
||||
$table->foreignId('domain_id')->nullable()->constrained('biolink_domains')->nullOnDelete();
|
||||
$table->foreignId('theme_id')->nullable()->constrained('biolink_themes')->nullOnDelete();
|
||||
$table->foreignId('namespace_id')->nullable()->constrained('namespaces')->nullOnDelete();
|
||||
$table->foreignId('parent_id')->nullable()->constrained('biolinks')->nullOnDelete();
|
||||
$table->string('type', 32)->default('biolink');
|
||||
$table->string('url', 256);
|
||||
$table->string('location_url', 2048)->nullable();
|
||||
$table->json('settings')->nullable();
|
||||
$table->json('email_report_settings')->nullable();
|
||||
$table->unsignedBigInteger('clicks')->default(0);
|
||||
$table->unsignedBigInteger('unique_clicks')->default(0);
|
||||
$table->timestamp('start_date')->nullable();
|
||||
$table->timestamp('end_date')->nullable();
|
||||
$table->boolean('is_enabled')->default(true);
|
||||
$table->boolean('is_verified')->default(false);
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
$table->timestamp('last_click_at')->nullable();
|
||||
|
||||
$table->unique(['domain_id', 'url']);
|
||||
$table->index(['user_id', 'type', 'is_enabled']);
|
||||
$table->index(['user_id', 'project_id']);
|
||||
$table->index(['workspace_id', 'type']);
|
||||
$table->index('parent_id');
|
||||
});
|
||||
|
||||
// Add constraint to domains table
|
||||
Schema::table('biolink_domains', function (Blueprint $table) {
|
||||
$table->foreign('biolink_id')->references('id')->on('biolinks')->nullOnDelete();
|
||||
});
|
||||
|
||||
// 5. Biolink Blocks
|
||||
Schema::create('biolink_blocks', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('biolink_id')->constrained('biolinks')->cascadeOnDelete();
|
||||
$table->string('type', 32);
|
||||
$table->string('location_url', 512)->nullable();
|
||||
$table->json('settings')->nullable();
|
||||
$table->unsignedSmallInteger('order')->default(0);
|
||||
$table->unsignedBigInteger('clicks')->default(0);
|
||||
$table->timestamp('start_date')->nullable();
|
||||
$table->timestamp('end_date')->nullable();
|
||||
$table->boolean('is_enabled')->default(true);
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['biolink_id', 'is_enabled', 'order']);
|
||||
});
|
||||
|
||||
// 6. Biolink Pixels
|
||||
Schema::create('biolink_pixels', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('type', 32);
|
||||
$table->string('name', 64);
|
||||
$table->string('pixel_id', 128);
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->index(['user_id', 'type']);
|
||||
$table->index(['workspace_id', 'type']);
|
||||
});
|
||||
|
||||
// 7. Biolink Pixel Pivot
|
||||
Schema::create('biolink_pixel', function (Blueprint $table) {
|
||||
$table->foreignId('biolink_id')->constrained('biolinks')->cascadeOnDelete();
|
||||
$table->foreignId('pixel_id')->constrained('biolink_pixels')->cascadeOnDelete();
|
||||
$table->primary(['biolink_id', 'pixel_id']);
|
||||
});
|
||||
|
||||
// 8. Click Stats (Aggregated)
|
||||
Schema::create('biolink_click_stats', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('biolink_id')->constrained('biolinks')->cascadeOnDelete();
|
||||
$table->foreignId('block_id')->nullable()->constrained('biolink_blocks')->nullOnDelete();
|
||||
$table->date('date');
|
||||
$table->unsignedTinyInteger('hour')->nullable();
|
||||
$table->unsignedInteger('clicks')->default(0);
|
||||
$table->unsignedInteger('unique_clicks')->default(0);
|
||||
$table->char('country_code', 2)->nullable();
|
||||
$table->enum('device_type', ['desktop', 'mobile', 'tablet', 'other'])->nullable();
|
||||
$table->string('referrer_host', 256)->nullable();
|
||||
$table->string('utm_source', 64)->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['biolink_id', 'block_id', 'date', 'hour', 'country_code', 'device_type', 'referrer_host', 'utm_source'], 'biolink_stats_unique');
|
||||
$table->index(['biolink_id', 'date']);
|
||||
$table->index(['biolink_id', 'date', 'country_code']);
|
||||
});
|
||||
|
||||
// 9. Clicks (Raw)
|
||||
Schema::create('biolink_clicks', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('biolink_id')->constrained('biolinks')->cascadeOnDelete();
|
||||
$table->foreignId('block_id')->nullable()->constrained('biolink_blocks')->nullOnDelete();
|
||||
$table->string('visitor_hash', 64)->nullable();
|
||||
$table->char('country_code', 2)->nullable();
|
||||
$table->string('region', 64)->nullable();
|
||||
$table->enum('device_type', ['desktop', 'mobile', 'tablet', 'other'])->default('other');
|
||||
$table->string('os_name', 32)->nullable();
|
||||
$table->string('browser_name', 32)->nullable();
|
||||
$table->string('referrer_host', 256)->nullable();
|
||||
$table->string('utm_source', 64)->nullable();
|
||||
$table->string('utm_medium', 64)->nullable();
|
||||
$table->string('utm_campaign', 64)->nullable();
|
||||
$table->boolean('is_unique')->default(false);
|
||||
$table->timestamp('created_at');
|
||||
|
||||
$table->index(['biolink_id', 'created_at']);
|
||||
$table->index(['biolink_id', 'country_code']);
|
||||
$table->index(['biolink_id', 'device_type']);
|
||||
$table->index(['biolink_id', 'referrer_host']);
|
||||
$table->index(['block_id', 'created_at']);
|
||||
});
|
||||
|
||||
// 10. Notification Handlers
|
||||
Schema::create('biolink_notification_handlers', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('biolink_id')->constrained('biolinks')->cascadeOnDelete();
|
||||
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('name', 128);
|
||||
$table->enum('type', ['webhook', 'email', 'slack', 'discord', 'telegram']);
|
||||
$table->json('settings');
|
||||
$table->json('events')->default(json_encode(['click']));
|
||||
$table->boolean('is_enabled')->default(true);
|
||||
$table->unsignedInteger('trigger_count')->default(0);
|
||||
$table->timestamp('last_triggered_at')->nullable();
|
||||
$table->timestamp('last_failed_at')->nullable();
|
||||
$table->unsignedSmallInteger('consecutive_failures')->default(0);
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->index(['biolink_id', 'is_enabled']);
|
||||
$table->index(['workspace_id', 'type']);
|
||||
});
|
||||
|
||||
// 11. Push Configs
|
||||
Schema::create('biolink_push_configs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('biolink_id')->unique()->constrained('biolinks')->cascadeOnDelete();
|
||||
$table->text('vapid_public_key');
|
||||
$table->text('vapid_private_key');
|
||||
$table->string('default_icon_url', 512)->nullable();
|
||||
$table->boolean('prompt_enabled')->default(true);
|
||||
$table->unsignedSmallInteger('prompt_delay_seconds')->default(5);
|
||||
$table->unsignedSmallInteger('prompt_min_pageviews')->default(2);
|
||||
$table->boolean('is_enabled')->default(true);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
// 12. Push Subscribers
|
||||
Schema::create('biolink_push_subscribers', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('biolink_id')->constrained('biolinks')->cascadeOnDelete();
|
||||
$table->string('subscriber_hash', 64)->unique();
|
||||
$table->text('endpoint');
|
||||
$table->string('key_auth', 128);
|
||||
$table->string('key_p256dh', 128);
|
||||
$table->char('country_code', 2)->nullable();
|
||||
$table->string('city_name', 64)->nullable();
|
||||
$table->string('os_name', 32)->nullable();
|
||||
$table->string('browser_name', 32)->nullable();
|
||||
$table->string('browser_language', 16)->nullable();
|
||||
$table->enum('device_type', ['desktop', 'mobile', 'tablet', 'other'])->default('other');
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamp('last_notification_at')->nullable();
|
||||
$table->unsignedInteger('notifications_received')->default(0);
|
||||
$table->timestamp('subscribed_at');
|
||||
$table->timestamp('unsubscribed_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['biolink_id', 'is_active']);
|
||||
$table->index(['biolink_id', 'country_code']);
|
||||
$table->index(['biolink_id', 'device_type']);
|
||||
});
|
||||
|
||||
// 13. Push Notifications
|
||||
Schema::create('biolink_push_notifications', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('biolink_id')->constrained('biolinks')->cascadeOnDelete();
|
||||
$table->string('title', 64);
|
||||
$table->string('body', 256)->nullable();
|
||||
$table->string('url', 512)->nullable();
|
||||
$table->string('icon_url', 512)->nullable();
|
||||
$table->string('badge_url', 512)->nullable();
|
||||
$table->enum('segment', ['all', 'desktop', 'mobile', 'country'])->default('all');
|
||||
$table->string('segment_value', 64)->nullable();
|
||||
$table->unsignedInteger('total_subscribers')->default(0);
|
||||
$table->unsignedInteger('sent_count')->default(0);
|
||||
$table->unsignedInteger('delivered_count')->default(0);
|
||||
$table->unsignedInteger('clicked_count')->default(0);
|
||||
$table->unsignedInteger('failed_count')->default(0);
|
||||
$table->enum('status', ['draft', 'scheduled', 'sending', 'sent', 'failed'])->default('draft');
|
||||
$table->timestamp('scheduled_at')->nullable();
|
||||
$table->timestamp('sent_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['biolink_id', 'status']);
|
||||
$table->index(['status', 'scheduled_at']);
|
||||
});
|
||||
|
||||
// 14. Push Deliveries
|
||||
Schema::create('biolink_push_deliveries', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('notification_id')->constrained('biolink_push_notifications')->cascadeOnDelete();
|
||||
$table->foreignId('subscriber_id')->constrained('biolink_push_subscribers')->cascadeOnDelete();
|
||||
$table->enum('status', ['pending', 'sent', 'delivered', 'clicked', 'failed'])->default('pending');
|
||||
$table->string('error_message', 256)->nullable();
|
||||
$table->unsignedTinyInteger('retry_count')->default(0);
|
||||
$table->timestamp('sent_at')->nullable();
|
||||
$table->timestamp('delivered_at')->nullable();
|
||||
$table->timestamp('clicked_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['notification_id', 'subscriber_id']);
|
||||
$table->index(['notification_id', 'status']);
|
||||
});
|
||||
|
||||
// 15. PWAs
|
||||
Schema::create('biolink_pwas', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('biolink_id')->unique()->constrained('biolinks')->cascadeOnDelete();
|
||||
$table->string('name', 128);
|
||||
$table->string('short_name', 32)->nullable();
|
||||
$table->string('description', 256)->nullable();
|
||||
$table->string('theme_color', 16)->default('#6366f1');
|
||||
$table->string('background_color', 16)->default('#ffffff');
|
||||
$table->enum('display', ['standalone', 'fullscreen', 'minimal-ui', 'browser'])->default('standalone');
|
||||
$table->enum('orientation', ['any', 'natural', 'portrait', 'landscape'])->default('any');
|
||||
$table->string('icon_url', 512)->nullable();
|
||||
$table->string('icon_maskable_url', 512)->nullable();
|
||||
$table->json('screenshots')->nullable();
|
||||
$table->json('shortcuts')->nullable();
|
||||
$table->string('start_url', 512)->nullable();
|
||||
$table->string('scope', 512)->nullable();
|
||||
$table->string('lang', 8)->default('en');
|
||||
$table->enum('dir', ['ltr', 'rtl', 'auto'])->default('auto');
|
||||
$table->unsignedInteger('installs')->default(0);
|
||||
$table->boolean('is_enabled')->default(true);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
// 16. Submissions
|
||||
Schema::create('biolink_submissions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('biolink_id')->constrained('biolinks')->cascadeOnDelete();
|
||||
$table->foreignId('block_id')->constrained('biolink_blocks')->cascadeOnDelete();
|
||||
$table->enum('type', ['email', 'phone', 'contact']);
|
||||
$table->json('data');
|
||||
$table->string('ip_hash', 64)->nullable();
|
||||
$table->char('country_code', 2)->nullable();
|
||||
$table->boolean('notification_sent')->default(false);
|
||||
$table->timestamp('notified_at')->nullable();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->index(['biolink_id', 'created_at']);
|
||||
$table->index(['block_id', 'created_at']);
|
||||
$table->index(['biolink_id', 'type']);
|
||||
$table->index('type');
|
||||
});
|
||||
|
||||
// 17. Templates
|
||||
Schema::create('biolink_templates', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->foreignId('workspace_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->string('name', 128);
|
||||
$table->string('slug', 128)->unique();
|
||||
$table->string('category', 64);
|
||||
$table->text('description')->nullable();
|
||||
$table->json('blocks_json');
|
||||
$table->json('settings_json');
|
||||
$table->json('placeholders')->nullable();
|
||||
$table->string('preview_image', 255)->nullable();
|
||||
$table->json('tags')->nullable();
|
||||
$table->boolean('is_system')->default(false);
|
||||
$table->boolean('is_premium')->default(false);
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->unsignedSmallInteger('sort_order')->default(0);
|
||||
$table->unsignedInteger('usage_count')->default(0);
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->index(['category', 'is_active', 'sort_order']);
|
||||
$table->index(['is_system', 'is_active', 'sort_order']);
|
||||
$table->index(['user_id', 'is_active']);
|
||||
$table->index(['workspace_id', 'is_active']);
|
||||
$table->index('category');
|
||||
});
|
||||
|
||||
// 18. Theme Favourites
|
||||
Schema::create('theme_favourites', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('theme_id')->constrained('biolink_themes')->cascadeOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['user_id', 'theme_id']);
|
||||
$table->index(['user_id', 'created_at']);
|
||||
});
|
||||
|
||||
Schema::enableForeignKeyConstraints();
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::disableForeignKeyConstraints();
|
||||
Schema::dropIfExists('theme_favourites');
|
||||
Schema::dropIfExists('biolink_templates');
|
||||
Schema::dropIfExists('biolink_submissions');
|
||||
Schema::dropIfExists('biolink_pwas');
|
||||
Schema::dropIfExists('biolink_push_deliveries');
|
||||
Schema::dropIfExists('biolink_push_notifications');
|
||||
Schema::dropIfExists('biolink_push_subscribers');
|
||||
Schema::dropIfExists('biolink_push_configs');
|
||||
Schema::dropIfExists('biolink_notification_handlers');
|
||||
Schema::dropIfExists('biolink_clicks');
|
||||
Schema::dropIfExists('biolink_click_stats');
|
||||
Schema::dropIfExists('biolink_pixel');
|
||||
Schema::dropIfExists('biolink_pixels');
|
||||
Schema::dropIfExists('biolink_blocks');
|
||||
Schema::table('biolink_domains', function (Blueprint $table) {
|
||||
$table->dropForeign(['biolink_id']);
|
||||
});
|
||||
Schema::dropIfExists('biolinks');
|
||||
Schema::dropIfExists('biolink_themes');
|
||||
Schema::dropIfExists('biolink_projects');
|
||||
Schema::dropIfExists('biolink_domains');
|
||||
Schema::enableForeignKeyConstraints();
|
||||
}
|
||||
};
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
<?php $attributes ??= new \Illuminate\View\ComponentAttributeBag;
|
||||
|
||||
$__newAttributes = [];
|
||||
$__propNames = \Illuminate\View\ComponentAttributeBag::extractPropNames(([
|
||||
'title',
|
||||
'icon' => null,
|
||||
'active' => false,
|
||||
'color' => 'gray',
|
||||
'expanded' => null,
|
||||
]));
|
||||
|
||||
foreach ($attributes->all() as $__key => $__value) {
|
||||
if (in_array($__key, $__propNames)) {
|
||||
$$__key = $$__key ?? $__value;
|
||||
} else {
|
||||
$__newAttributes[$__key] = $__value;
|
||||
}
|
||||
}
|
||||
|
||||
$attributes = new \Illuminate\View\ComponentAttributeBag($__newAttributes);
|
||||
|
||||
unset($__propNames);
|
||||
unset($__newAttributes);
|
||||
|
||||
foreach (array_filter(([
|
||||
'title',
|
||||
'icon' => null,
|
||||
'active' => false,
|
||||
'color' => 'gray',
|
||||
'expanded' => null,
|
||||
]), 'is_string', ARRAY_FILTER_USE_KEY) as $__key => $__value) {
|
||||
$$__key = $$__key ?? $__value;
|
||||
}
|
||||
|
||||
$__defined_vars = get_defined_vars();
|
||||
|
||||
foreach ($attributes->all() as $__key => $__value) {
|
||||
if (array_key_exists($__key, $__defined_vars)) unset($$__key);
|
||||
}
|
||||
|
||||
unset($__defined_vars, $__key, $__value); ?>
|
||||
|
||||
<?php
|
||||
$isExpanded = $expanded ?? $active;
|
||||
?>
|
||||
|
||||
<li class="mb-0.5" x-data="{ expanded: <?php echo e($isExpanded ? 'true' : 'false'); ?> }">
|
||||
<a class="block text-gray-800 dark:text-gray-100 truncate transition hover:text-gray-900 dark:hover:text-white pl-4 pr-3 py-2 rounded-lg <?php if($active): ?> bg-linear-to-r from-violet-500/[0.12] dark:from-violet-500/[0.24] to-violet-500/[0.04] <?php endif; ?>"
|
||||
href="#"
|
||||
@click.prevent="if (window.innerWidth >= 640 && window.innerWidth < 1024 && !sidebarOpen) { $dispatch('open-sidebar'); } else { expanded = !expanded; }">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<?php if(\Livewire\Mechanisms\ExtendBlade\ExtendBlade::isRenderingLivewireComponent()): ?><!--[if BLOCK]><![endif]--><?php endif; ?><?php if($icon): ?>
|
||||
<?php if (isset($component)) { $__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac = $component; } ?>
|
||||
<?php if (isset($attributes)) { $__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac = $attributes; } ?>
|
||||
<?php $component = Illuminate\View\AnonymousComponent::resolve(['view' => '8def1252668913628243c4d363bee1ef::icon','data' => ['name' => $icon,'class' => 'shrink-0 '.e($active ? 'text-violet-500' : 'text-' . $color . '-500').'']] + (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag ? $attributes->all() : [])); ?>
|
||||
<?php $component->withName('core::icon'); ?>
|
||||
<?php if ($component->shouldRender()): ?>
|
||||
<?php $__env->startComponent($component->resolveView(), $component->data()); ?>
|
||||
<?php if (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag): ?>
|
||||
<?php $attributes = $attributes->except(\Illuminate\View\AnonymousComponent::ignoredParameterNames()); ?>
|
||||
<?php endif; ?>
|
||||
<?php $component->withAttributes(['name' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($icon),'class' => 'shrink-0 '.e($active ? 'text-violet-500' : 'text-' . $color . '-500').'']); ?>
|
||||
<?php echo $__env->renderComponent(); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac)): ?>
|
||||
<?php $attributes = $__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac; ?>
|
||||
<?php unset($__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac)): ?>
|
||||
<?php $component = $__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac; ?>
|
||||
<?php unset($__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac); ?>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?><?php if(\Livewire\Mechanisms\ExtendBlade\ExtendBlade::isRenderingLivewireComponent()): ?><!--[if ENDBLOCK]><![endif]--><?php endif; ?>
|
||||
<span class="text-sm font-medium <?php if($icon): ?> ml-4 <?php endif; ?> duration-200"
|
||||
:class="{ 'sm:opacity-0 lg:opacity-100': !sidebarOpen, 'opacity-100': sidebarOpen }"><?php echo e($title); ?></span>
|
||||
</div>
|
||||
<div class="flex shrink-0 ml-2 duration-200"
|
||||
:class="{ 'sm:opacity-0 lg:opacity-100': !sidebarOpen, 'opacity-100': sidebarOpen }">
|
||||
<svg class="w-3 h-3 shrink-0 fill-current text-gray-400 dark:text-gray-500 transition-transform duration-200" :class="{ 'rotate-180': expanded }" viewBox="0 0 12 12">
|
||||
<path d="M5.9 11.4L.5 6l1.4-1.4 4 4 4-4L11.3 6z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<div :class="{ 'sm:hidden lg:block': !sidebarOpen, 'block': sidebarOpen }" x-show="expanded" x-cloak>
|
||||
<ul class="pl-10 mt-1 space-y-1">
|
||||
<?php echo e($slot); ?>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
<?php /**PATH /Users/snider/Code/lab/core-php/packages/core-php/src/Core/Front/Admin/Blade/components/nav-menu.blade.php ENDPATH**/ ?>
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
|
||||
<?php $attributes ??= new \Illuminate\View\ComponentAttributeBag;
|
||||
|
||||
$__newAttributes = [];
|
||||
$__propNames = \Illuminate\View\ComponentAttributeBag::extractPropNames(([
|
||||
'name',
|
||||
'style' => null, // Override: solid, regular, light, thin, duotone, brands, jelly
|
||||
'size' => null, // Size class: xs, sm, lg, xl, 2xl, etc.
|
||||
'spin' => false, // Animate spinning
|
||||
'pulse' => false, // Animate pulsing
|
||||
'flip' => null, // horizontal, vertical, both
|
||||
'rotate' => null, // 90, 180, 270
|
||||
'fw' => false, // Fixed width
|
||||
]));
|
||||
|
||||
foreach ($attributes->all() as $__key => $__value) {
|
||||
if (in_array($__key, $__propNames)) {
|
||||
$$__key = $$__key ?? $__value;
|
||||
} else {
|
||||
$__newAttributes[$__key] = $__value;
|
||||
}
|
||||
}
|
||||
|
||||
$attributes = new \Illuminate\View\ComponentAttributeBag($__newAttributes);
|
||||
|
||||
unset($__propNames);
|
||||
unset($__newAttributes);
|
||||
|
||||
foreach (array_filter(([
|
||||
'name',
|
||||
'style' => null, // Override: solid, regular, light, thin, duotone, brands, jelly
|
||||
'size' => null, // Size class: xs, sm, lg, xl, 2xl, etc.
|
||||
'spin' => false, // Animate spinning
|
||||
'pulse' => false, // Animate pulsing
|
||||
'flip' => null, // horizontal, vertical, both
|
||||
'rotate' => null, // 90, 180, 270
|
||||
'fw' => false, // Fixed width
|
||||
]), 'is_string', ARRAY_FILTER_USE_KEY) as $__key => $__value) {
|
||||
$$__key = $$__key ?? $__value;
|
||||
}
|
||||
|
||||
$__defined_vars = get_defined_vars();
|
||||
|
||||
foreach ($attributes->all() as $__key => $__value) {
|
||||
if (array_key_exists($__key, $__defined_vars)) unset($$__key);
|
||||
}
|
||||
|
||||
unset($__defined_vars, $__key, $__value); ?>
|
||||
|
||||
<?php
|
||||
use Core\Pro;
|
||||
|
||||
// Brand icons - always use fa-brands (available in Free)
|
||||
$brandIcons = [
|
||||
// Social
|
||||
'facebook', 'facebook-f', 'facebook-messenger', 'instagram', 'twitter', 'x-twitter',
|
||||
'tiktok', 'youtube', 'linkedin', 'linkedin-in', 'pinterest', 'pinterest-p',
|
||||
'snapchat', 'snapchat-ghost', 'whatsapp', 'telegram', 'telegram-plane',
|
||||
'discord', 'twitch', 'reddit', 'reddit-alien', 'threads', 'mastodon', 'bluesky',
|
||||
// Media
|
||||
'spotify', 'soundcloud', 'apple', 'itunes', 'itunes-note', 'bandcamp',
|
||||
'deezer', 'napster', 'audible', 'vimeo', 'vimeo-v', 'dailymotion',
|
||||
// Dev/Tech
|
||||
'github', 'github-alt', 'gitlab', 'bitbucket', 'dribbble', 'behance',
|
||||
'figma', 'sketch', 'codepen', 'jsfiddle', 'stack-overflow',
|
||||
'npm', 'node', 'node-js', 'js', 'php', 'python', 'java', 'rust',
|
||||
'react', 'vuejs', 'angular', 'laravel', 'symfony', 'docker',
|
||||
'aws', 'google', 'microsoft',
|
||||
// Commerce
|
||||
'shopify', 'etsy', 'amazon', 'ebay', 'paypal', 'stripe', 'cc-stripe',
|
||||
'cc-visa', 'cc-mastercard', 'cc-amex', 'cc-paypal', 'cc-apple-pay',
|
||||
'bitcoin', 'btc', 'ethereum', 'monero',
|
||||
// Communication
|
||||
'slack', 'slack-hash', 'skype', 'viber', 'line', 'wechat', 'qq',
|
||||
// Other
|
||||
'wordpress', 'wordpress-simple', 'medium', 'blogger', 'tumblr',
|
||||
'patreon', 'kickstarter', 'product-hunt', 'airbnb', 'uber', 'lyft',
|
||||
'yelp', 'tripadvisor',
|
||||
];
|
||||
|
||||
// Jelly style icons - full list from FA Pro+ metadata
|
||||
// Generated from ~/Code/lib/fontawesome/metadata/icon-families.json
|
||||
$jellyIcons = [
|
||||
'address-card', 'alarm-clock', 'anchor', 'angle-down', 'angle-left',
|
||||
'angle-right', 'angle-up', 'arrow-down', 'arrow-down-to-line',
|
||||
'arrow-down-wide-short', 'arrow-left', 'arrow-right',
|
||||
'arrow-right-arrow-left', 'arrow-right-from-bracket',
|
||||
'arrow-right-to-bracket', 'arrow-rotate-left', 'arrow-rotate-right',
|
||||
'arrow-up', 'arrow-up-from-bracket', 'arrow-up-from-line',
|
||||
'arrow-up-right-from-square', 'arrow-up-wide-short', 'arrows-rotate',
|
||||
'at', 'backward', 'backward-step', 'bag-shopping', 'bars',
|
||||
'battery-bolt', 'battery-empty', 'battery-half', 'battery-low',
|
||||
'battery-three-quarters', 'bed', 'bell', 'block-quote', 'bold', 'bolt',
|
||||
'bomb', 'book', 'book-open', 'bookmark', 'box', 'box-archive', 'bug',
|
||||
'building', 'bus', 'cake-candles', 'calendar', 'camera', 'camera-slash',
|
||||
'car', 'cart-shopping', 'chart-bar', 'chart-pie', 'check', 'circle',
|
||||
'circle-check', 'circle-half-stroke', 'circle-info', 'circle-plus',
|
||||
'circle-question', 'circle-user', 'circle-xmark', 'city', 'clipboard',
|
||||
'clock', 'clone', 'cloud', 'code', 'command', 'comment', 'comment-dots',
|
||||
'comments', 'compact-disc', 'compass', 'compress', 'credit-card',
|
||||
'crown', 'database', 'desktop', 'door-closed', 'droplet', 'ellipsis',
|
||||
'envelope', 'equals', 'expand', 'eye', 'eye-slash', 'face-frown',
|
||||
'face-grin', 'face-meh', 'face-smile', 'file', 'files', 'film',
|
||||
'filter', 'fire', 'fish', 'flag', 'flower', 'folder', 'folders', 'font',
|
||||
'font-awesome', 'font-case', 'forward', 'forward-step', 'gamepad',
|
||||
'gauge', 'gear', 'gift', 'globe', 'grid', 'hand', 'headphones', 'heart',
|
||||
'heart-half', 'hourglass', 'house', 'image', 'images', 'inbox',
|
||||
'italic', 'key', 'landmark', 'language', 'laptop', 'layer-group',
|
||||
'leaf', 'life-ring', 'lightbulb', 'link', 'list', 'list-ol',
|
||||
'location-arrow', 'location-dot', 'lock', 'lock-open',
|
||||
'magnifying-glass', 'magnifying-glass-minus', 'magnifying-glass-plus',
|
||||
'map', 'martini-glass', 'microphone', 'microphone-slash', 'minus',
|
||||
'mobile', 'money-bill', 'moon', 'mug-hot', 'music', 'newspaper',
|
||||
'notdef', 'palette', 'paper-plane', 'paperclip', 'pause', 'paw',
|
||||
'pencil', 'percent', 'person-biking', 'phone', 'phone-slash', 'plane',
|
||||
'play', 'play-pause', 'plus', 'print', 'question', 'quote-left',
|
||||
'rectangle', 'rectangle-tall', 'rectangle-vertical', 'rectangle-wide',
|
||||
'scissors', 'share-nodes', 'shield', 'shield-halved', 'ship', 'shirt',
|
||||
'shop', 'sidebar', 'sidebar-flip', 'signal-bars', 'signal-bars-fair',
|
||||
'signal-bars-good', 'signal-bars-slash', 'signal-bars-weak', 'skull',
|
||||
'sliders', 'snowflake', 'sort', 'sparkles', 'square', 'square-code',
|
||||
'star', 'star-half', 'stop', 'stopwatch', 'strikethrough', 'suitcase',
|
||||
'sun', 'tag', 'terminal', 'thumbs-down', 'thumbs-up', 'thumbtack',
|
||||
'ticket', 'train', 'trash', 'tree', 'triangle', 'triangle-exclamation',
|
||||
'trophy', 'truck', 'tv-retro', 'umbrella', 'universal-access', 'user',
|
||||
'users', 'utensils', 'video', 'video-slash', 'volume', 'volume-low',
|
||||
'volume-off', 'volume-slash', 'volume-xmark', 'wand-magic-sparkles',
|
||||
'wheelchair-move', 'wifi', 'wifi-fair', 'wifi-slash', 'wifi-weak',
|
||||
'wrench', 'xmark',
|
||||
];
|
||||
|
||||
// Pro-only style fallbacks (when FA Pro not available)
|
||||
$proStyleFallbacks = [
|
||||
'light' => 'regular',
|
||||
'thin' => 'regular',
|
||||
'duotone' => 'solid',
|
||||
'sharp' => 'solid',
|
||||
'jelly' => 'solid',
|
||||
];
|
||||
|
||||
$hasFaPro = Pro::hasFontAwesomePro();
|
||||
|
||||
// Determine raw style
|
||||
if ($style) {
|
||||
$rawStyle = match($style) {
|
||||
'brands', 'brand' => 'brands',
|
||||
default => $style,
|
||||
};
|
||||
} elseif (in_array($name, $brandIcons)) {
|
||||
$rawStyle = 'brands';
|
||||
} elseif (in_array($name, $jellyIcons)) {
|
||||
$rawStyle = 'jelly';
|
||||
} else {
|
||||
$rawStyle = 'solid';
|
||||
}
|
||||
|
||||
// Apply fallback if Pro not available
|
||||
$finalStyle = $rawStyle;
|
||||
if (!$hasFaPro && isset($proStyleFallbacks[$rawStyle])) {
|
||||
$finalStyle = $proStyleFallbacks[$rawStyle];
|
||||
}
|
||||
|
||||
$iconStyle = "fa-{$finalStyle}";
|
||||
|
||||
// Build classes
|
||||
$classes = collect([
|
||||
$iconStyle,
|
||||
"fa-{$name}",
|
||||
$size ? "fa-{$size}" : null,
|
||||
$spin ? 'fa-spin' : null,
|
||||
$pulse ? 'fa-pulse' : null,
|
||||
$flip ? "fa-flip-{$flip}" : null,
|
||||
$rotate ? "fa-rotate-{$rotate}" : null,
|
||||
$fw ? 'fa-fw' : null,
|
||||
])->filter()->implode(' ');
|
||||
?>
|
||||
|
||||
<i <?php echo e($attributes->class($classes)); ?> aria-hidden="true"></i>
|
||||
<?php /**PATH /Users/snider/Code/lab/core-php/packages/core-php/src/Core/Front/Components/View/Blade/icon.blade.php ENDPATH**/ ?>
|
||||
|
|
@ -1,506 +0,0 @@
|
|||
<?php
|
||||
$user = auth()->user();
|
||||
$showDevBar = $user && method_exists($user, 'isHades') && $user->isHades();
|
||||
|
||||
// Performance metrics
|
||||
$queryCount = count(DB::getQueryLog());
|
||||
$startTime = defined('LARAVEL_START') ? LARAVEL_START : microtime(true);
|
||||
$loadTime = number_format((microtime(true) - $startTime) * 1000, 2);
|
||||
$memoryUsage = number_format(memory_get_peak_usage(true) / 1024 / 1024, 1);
|
||||
|
||||
// Check available dev tools
|
||||
$hasHorizon = class_exists(\Laravel\Horizon\Horizon::class);
|
||||
$hasPulse = class_exists(\Laravel\Pulse\Pulse::class);
|
||||
$hasTelescope = class_exists(\Laravel\Telescope\Telescope::class) && config('telescope.enabled', false);
|
||||
?>
|
||||
|
||||
<?php if(\Livewire\Mechanisms\ExtendBlade\ExtendBlade::isRenderingLivewireComponent()): ?><!--[if BLOCK]><![endif]--><?php endif; ?><?php if($showDevBar): ?>
|
||||
<div
|
||||
x-data="{
|
||||
expanded: false,
|
||||
activePanel: null,
|
||||
logs: [],
|
||||
routes: [],
|
||||
routeFilter: '',
|
||||
session: {},
|
||||
loadingLogs: false,
|
||||
loadingRoutes: false,
|
||||
|
||||
togglePanel(panel) {
|
||||
if (this.activePanel === panel) {
|
||||
this.activePanel = null;
|
||||
} else {
|
||||
this.activePanel = panel;
|
||||
if (panel === 'logs' && this.logs.length === 0) this.loadLogs();
|
||||
if (panel === 'routes' && this.routes.length === 0) this.loadRoutes();
|
||||
if (panel === 'session') this.loadSession();
|
||||
}
|
||||
},
|
||||
|
||||
async loadLogs() {
|
||||
this.loadingLogs = true;
|
||||
try {
|
||||
const res = await fetch('/hub/api/dev/logs');
|
||||
this.logs = await res.json();
|
||||
} catch (e) {
|
||||
this.logs = [{ level: 'error', message: 'Failed to load logs', time: new Date().toISOString() }];
|
||||
}
|
||||
this.loadingLogs = false;
|
||||
},
|
||||
|
||||
async loadRoutes() {
|
||||
this.loadingRoutes = true;
|
||||
try {
|
||||
const res = await fetch('/hub/api/dev/routes');
|
||||
this.routes = await res.json();
|
||||
} catch (e) {
|
||||
this.routes = [];
|
||||
}
|
||||
this.loadingRoutes = false;
|
||||
},
|
||||
|
||||
async loadSession() {
|
||||
try {
|
||||
const res = await fetch('/hub/api/dev/session');
|
||||
this.session = await res.json();
|
||||
} catch (e) {
|
||||
this.session = { error: 'Failed to load session' };
|
||||
}
|
||||
},
|
||||
|
||||
async clearCache(type) {
|
||||
try {
|
||||
const res = await fetch('/hub/api/dev/clear/' + type, { method: 'POST', headers: { 'X-CSRF-TOKEN': '<?php echo e(csrf_token()); ?>' }});
|
||||
const data = await res.json();
|
||||
alert(data.message || 'Done!');
|
||||
} catch (e) {
|
||||
alert('Failed: ' + e.message);
|
||||
}
|
||||
}
|
||||
}"
|
||||
class="fixed bottom-0 left-0 right-0 z-50"
|
||||
>
|
||||
<!-- Expandable Panel Area -->
|
||||
<div
|
||||
x-show="activePanel"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 translate-y-4"
|
||||
x-transition:enter-end="opacity-100 translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 translate-y-0"
|
||||
x-transition:leave-end="opacity-0 translate-y-4"
|
||||
class="border-t border-violet-500/50 shadow-2xl"
|
||||
style="background-color: #0a0a0f; max-height: 53vh; overflow-y: auto;"
|
||||
>
|
||||
<!-- Logs Panel -->
|
||||
<div x-show="activePanel === 'logs'" class="p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-violet-400 font-semibold text-sm">Recent Logs</h3>
|
||||
<button @click="loadLogs()" class="text-xs text-gray-400 hover:text-white">
|
||||
<i class="fa-solid fa-refresh" :class="{ 'animate-spin': loadingLogs }"></i> Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-1 font-mono text-xs">
|
||||
<template x-if="loadingLogs">
|
||||
<div class="text-gray-500">Loading...</div>
|
||||
</template>
|
||||
<template x-if="!loadingLogs && logs.length === 0">
|
||||
<div class="text-gray-500">No recent logs</div>
|
||||
</template>
|
||||
<template x-for="log in logs" :key="log.time">
|
||||
<div class="flex items-start gap-2 py-1 border-b border-gray-800">
|
||||
<span
|
||||
class="px-1.5 py-0.5 rounded text-[10px] uppercase font-bold"
|
||||
:class="{
|
||||
'bg-red-500/20 text-red-400': log.level === 'error',
|
||||
'bg-yellow-500/20 text-yellow-400': log.level === 'warning',
|
||||
'bg-blue-500/20 text-blue-400': log.level === 'info',
|
||||
'bg-gray-500/20 text-gray-400': !['error', 'warning', 'info'].includes(log.level)
|
||||
}"
|
||||
x-text="log.level"
|
||||
></span>
|
||||
<span class="text-gray-500" x-text="log.time"></span>
|
||||
<span class="text-gray-300 flex-1 truncate" x-text="log.message"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Routes Panel -->
|
||||
<div x-show="activePanel === 'routes'" class="p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-violet-400 font-semibold text-sm">Routes</h3>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter routes..."
|
||||
class="bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-white w-48"
|
||||
x-model="routeFilter"
|
||||
>
|
||||
</div>
|
||||
<div class="space-y-1 font-mono text-xs max-h-48 overflow-y-auto">
|
||||
<template x-if="loadingRoutes">
|
||||
<div class="text-gray-500">Loading...</div>
|
||||
</template>
|
||||
<template x-for="route in routes.filter(r => !routeFilter || r.uri.includes(routeFilter) || (r.name && r.name.includes(routeFilter)))" :key="route.uri + route.method">
|
||||
<div class="flex items-center gap-2 py-1 border-b border-gray-800">
|
||||
<span
|
||||
class="px-1.5 py-0.5 rounded text-[10px] uppercase font-bold w-14 text-center"
|
||||
:class="{
|
||||
'bg-green-500/20 text-green-400': route.method === 'GET',
|
||||
'bg-blue-500/20 text-blue-400': route.method === 'POST',
|
||||
'bg-yellow-500/20 text-yellow-400': route.method === 'PUT' || route.method === 'PATCH',
|
||||
'bg-red-500/20 text-red-400': route.method === 'DELETE',
|
||||
}"
|
||||
x-text="route.method"
|
||||
></span>
|
||||
<span class="text-gray-300" x-text="route.uri"></span>
|
||||
<span class="text-gray-500 text-[10px]" x-text="route.name || ''"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session Panel -->
|
||||
<div x-show="activePanel === 'session'" class="p-4">
|
||||
<h3 class="text-violet-400 font-semibold text-sm mb-3">Session & Request</h3>
|
||||
<div class="grid grid-cols-2 gap-4 text-xs font-mono">
|
||||
<div>
|
||||
<div class="text-gray-500 mb-1">Session ID</div>
|
||||
<div class="text-gray-300 truncate" x-text="session.id || '-'"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-gray-500 mb-1">User Agent</div>
|
||||
<div class="text-gray-300 truncate" x-text="session.user_agent || '-'"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-gray-500 mb-1">IP Address</div>
|
||||
<div class="text-gray-300" x-text="session.ip || '-'"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-gray-500 mb-1">PHP Version</div>
|
||||
<div class="text-gray-300"><?php echo e(PHP_VERSION); ?></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-gray-500 mb-1">Laravel Version</div>
|
||||
<div class="text-gray-300"><?php echo e(app()->version()); ?></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-gray-500 mb-1">Environment</div>
|
||||
<div class="text-gray-300"><?php echo e(app()->environment()); ?></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cache Panel -->
|
||||
<div x-show="activePanel === 'cache'" class="p-4">
|
||||
<h3 class="text-violet-400 font-semibold text-sm mb-3">Cache Management</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button @click="clearCache('cache')" class="px-3 py-1.5 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded text-xs transition-colors">
|
||||
<i class="fa-solid fa-trash mr-1"></i> Clear Cache
|
||||
</button>
|
||||
<button @click="clearCache('config')" class="px-3 py-1.5 bg-yellow-500/20 hover:bg-yellow-500/30 text-yellow-400 rounded text-xs transition-colors">
|
||||
<i class="fa-solid fa-gear mr-1"></i> Clear Config
|
||||
</button>
|
||||
<button @click="clearCache('view')" class="px-3 py-1.5 bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 rounded text-xs transition-colors">
|
||||
<i class="fa-solid fa-eye mr-1"></i> Clear Views
|
||||
</button>
|
||||
<button @click="clearCache('route')" class="px-3 py-1.5 bg-green-500/20 hover:bg-green-500/30 text-green-400 rounded text-xs transition-colors">
|
||||
<i class="fa-solid fa-route mr-1"></i> Clear Routes
|
||||
</button>
|
||||
<button @click="clearCache('all')" class="px-3 py-1.5 bg-violet-500/20 hover:bg-violet-500/30 text-violet-400 rounded text-xs transition-colors">
|
||||
<i class="fa-solid fa-bomb mr-1"></i> Clear All
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-gray-500 text-xs mt-3">
|
||||
<i class="fa-solid fa-info-circle mr-1"></i>
|
||||
These actions run artisan cache commands on the server.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Appearance Panel -->
|
||||
<div x-show="activePanel === 'appearance'" class="p-4" x-data="{
|
||||
iconStyle: localStorage.getItem('icon-style') || 'fa-notdog fa-solid',
|
||||
iconSize: localStorage.getItem('icon-size') || 'fa-lg',
|
||||
setStyle(style) {
|
||||
this.iconStyle = style;
|
||||
localStorage.setItem('icon-style', style);
|
||||
document.cookie = 'icon-style=' + style + '; path=/; SameSite=Lax';
|
||||
location.reload();
|
||||
},
|
||||
setSize(size) {
|
||||
this.iconSize = size;
|
||||
localStorage.setItem('icon-size', size);
|
||||
document.cookie = 'icon-size=' + size + '; path=/; SameSite=Lax';
|
||||
location.reload();
|
||||
}
|
||||
}">
|
||||
<!-- Classic Families -->
|
||||
<h3 class="text-violet-400 font-semibold text-sm mb-2">Classic</h3>
|
||||
<div class="grid grid-cols-4 md:grid-cols-5 lg:grid-cols-10 gap-2 mb-4">
|
||||
<button @click="setStyle('fa-solid')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-solid' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||
<i class="fa-solid fa-house text-xl text-gray-300"></i>
|
||||
<span class="text-[10px] text-gray-400">Solid</span>
|
||||
</button>
|
||||
<button @click="setStyle('fa-regular')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-regular' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||
<i class="fa-regular fa-house text-xl text-gray-300"></i>
|
||||
<span class="text-[10px] text-gray-400">Regular</span>
|
||||
</button>
|
||||
<button @click="setStyle('fa-light')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-light' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||
<i class="fa-light fa-house text-xl text-gray-300"></i>
|
||||
<span class="text-[10px] text-gray-400">Light</span>
|
||||
</button>
|
||||
<button @click="setStyle('fa-thin')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-thin' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||
<i class="fa-thin fa-house text-xl text-gray-300"></i>
|
||||
<span class="text-[10px] text-gray-400">Thin</span>
|
||||
</button>
|
||||
<button @click="setStyle('fa-duotone')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-duotone' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||
<i class="fa-duotone fa-house text-xl text-gray-300"></i>
|
||||
<span class="text-[10px] text-gray-400">Duotone</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Sharp Families -->
|
||||
<h3 class="text-violet-400 font-semibold text-sm mb-2">Sharp</h3>
|
||||
<div class="grid grid-cols-4 md:grid-cols-5 lg:grid-cols-10 gap-2 mb-4">
|
||||
<button @click="setStyle('fa-sharp fa-solid')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-sharp fa-solid' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||
<i class="fa-sharp fa-solid fa-house text-xl text-gray-300"></i>
|
||||
<span class="text-[10px] text-gray-400">Solid</span>
|
||||
</button>
|
||||
<button @click="setStyle('fa-sharp fa-regular')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-sharp fa-regular' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||
<i class="fa-sharp fa-regular fa-house text-xl text-gray-300"></i>
|
||||
<span class="text-[10px] text-gray-400">Regular</span>
|
||||
</button>
|
||||
<button @click="setStyle('fa-sharp fa-light')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-sharp fa-light' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||
<i class="fa-sharp fa-light fa-house text-xl text-gray-300"></i>
|
||||
<span class="text-[10px] text-gray-400">Light</span>
|
||||
</button>
|
||||
<button @click="setStyle('fa-sharp fa-thin')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-sharp fa-thin' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||
<i class="fa-sharp fa-thin fa-house text-xl text-gray-300"></i>
|
||||
<span class="text-[10px] text-gray-400">Thin</span>
|
||||
</button>
|
||||
<button @click="setStyle('fa-sharp-duotone-solid')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-sharp-duotone-solid' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||
<i class="fa-sharp-duotone-solid fa-house text-xl text-gray-300"></i>
|
||||
<span class="text-[10px] text-gray-400">Duo Solid</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Specialty Families -->
|
||||
<h3 class="text-violet-400 font-semibold text-sm mb-2">Specialty</h3>
|
||||
<div class="grid grid-cols-4 md:grid-cols-5 lg:grid-cols-10 gap-2 mb-4">
|
||||
<button @click="setStyle('fa-jelly fa-regular')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-jelly fa-regular' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||
<i class="fa-jelly fa-regular fa-house text-xl text-gray-300"></i>
|
||||
<span class="text-[10px] text-gray-400">Jelly</span>
|
||||
</button>
|
||||
<button @click="setStyle('fa-jelly-fill fa-regular')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-jelly-fill fa-regular' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||
<i class="fa-jelly-fill fa-regular fa-house text-xl text-gray-300"></i>
|
||||
<span class="text-[10px] text-gray-400">Jelly Fill</span>
|
||||
</button>
|
||||
<button @click="setStyle('fa-jelly-duo fa-regular')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-jelly-duo fa-regular' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||
<i class="fa-jelly-duo fa-regular fa-house text-xl text-gray-300"></i>
|
||||
<span class="text-[10px] text-gray-400">Jelly Duo</span>
|
||||
</button>
|
||||
<button @click="setStyle('fa-notdog fa-solid')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-notdog fa-solid' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||
<i class="fa-notdog fa-solid fa-house text-xl text-gray-300"></i>
|
||||
<span class="text-[10px] text-gray-400">Notdog</span>
|
||||
</button>
|
||||
<button @click="setStyle('fa-notdog-duo fa-solid')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-notdog-duo fa-solid' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||
<i class="fa-notdog-duo fa-solid fa-house text-xl text-gray-300"></i>
|
||||
<span class="text-[10px] text-gray-400">Notdog Duo</span>
|
||||
</button>
|
||||
<button @click="setStyle('fa-slab fa-regular')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-slab fa-regular' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||
<i class="fa-slab fa-regular fa-house text-xl text-gray-300"></i>
|
||||
<span class="text-[10px] text-gray-400">Slab</span>
|
||||
</button>
|
||||
<button @click="setStyle('fa-slab-press fa-regular')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-slab-press fa-regular' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||
<i class="fa-slab-press fa-regular fa-house text-xl text-gray-300"></i>
|
||||
<span class="text-[10px] text-gray-400">Slab Press</span>
|
||||
</button>
|
||||
<button @click="setStyle('fa-utility fa-semibold')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-utility fa-semibold' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||
<i class="fa-utility fa-semibold fa-house text-xl text-gray-300"></i>
|
||||
<span class="text-[10px] text-gray-400">Utility</span>
|
||||
</button>
|
||||
<button @click="setStyle('fa-utility-fill fa-semibold')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-utility-fill fa-semibold' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||
<i class="fa-utility-fill fa-semibold fa-house text-xl text-gray-300"></i>
|
||||
<span class="text-[10px] text-gray-400">Utility Fill</span>
|
||||
</button>
|
||||
<button @click="setStyle('fa-utility-duo fa-semibold')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-utility-duo fa-semibold' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||
<i class="fa-utility-duo fa-semibold fa-house text-xl text-gray-300"></i>
|
||||
<span class="text-[10px] text-gray-400">Utility Duo</span>
|
||||
</button>
|
||||
<button @click="setStyle('fa-whiteboard fa-semibold')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-whiteboard fa-semibold' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||
<i class="fa-whiteboard fa-semibold fa-house text-xl text-gray-300"></i>
|
||||
<span class="text-[10px] text-gray-400">Whiteboard</span>
|
||||
</button>
|
||||
<button @click="setStyle('fa-chisel fa-regular')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-chisel fa-regular' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||
<i class="fa-chisel fa-regular fa-house text-xl text-gray-300"></i>
|
||||
<span class="text-[10px] text-gray-400">Chisel</span>
|
||||
</button>
|
||||
<button @click="setStyle('fa-etch fa-solid')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-etch fa-solid' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||
<i class="fa-etch fa-solid fa-house text-xl text-gray-300"></i>
|
||||
<span class="text-[10px] text-gray-400">Etch</span>
|
||||
</button>
|
||||
<button @click="setStyle('fa-thumbprint fa-light')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-thumbprint fa-light' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||
<i class="fa-thumbprint fa-light fa-house text-xl text-gray-300"></i>
|
||||
<span class="text-[10px] text-gray-400">Thumbprint</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Icon Size -->
|
||||
<h3 class="text-violet-400 font-semibold text-sm mb-2">Size</h3>
|
||||
<div class="flex flex-wrap gap-2 mb-3">
|
||||
<button @click="setSize('')" class="px-3 py-1.5 rounded-lg border text-xs transition-colors" :class="iconSize === '' ? 'border-violet-500 bg-violet-500/10 text-violet-300' : 'border-gray-700 text-gray-400 hover:border-gray-600'">
|
||||
Default
|
||||
</button>
|
||||
<button @click="setSize('fa-2xs')" class="px-3 py-1.5 rounded-lg border text-xs transition-colors" :class="iconSize === 'fa-2xs' ? 'border-violet-500 bg-violet-500/10 text-violet-300' : 'border-gray-700 text-gray-400 hover:border-gray-600'">
|
||||
<i class="fa-solid fa-house fa-2xs mr-1"></i> 2XS
|
||||
</button>
|
||||
<button @click="setSize('fa-xs')" class="px-3 py-1.5 rounded-lg border text-xs transition-colors" :class="iconSize === 'fa-xs' ? 'border-violet-500 bg-violet-500/10 text-violet-300' : 'border-gray-700 text-gray-400 hover:border-gray-600'">
|
||||
<i class="fa-solid fa-house fa-xs mr-1"></i> XS
|
||||
</button>
|
||||
<button @click="setSize('fa-sm')" class="px-3 py-1.5 rounded-lg border text-xs transition-colors" :class="iconSize === 'fa-sm' ? 'border-violet-500 bg-violet-500/10 text-violet-300' : 'border-gray-700 text-gray-400 hover:border-gray-600'">
|
||||
<i class="fa-solid fa-house fa-sm mr-1"></i> SM
|
||||
</button>
|
||||
<button @click="setSize('fa-lg')" class="px-3 py-1.5 rounded-lg border text-xs transition-colors" :class="iconSize === 'fa-lg' ? 'border-violet-500 bg-violet-500/10 text-violet-300' : 'border-gray-700 text-gray-400 hover:border-gray-600'">
|
||||
<i class="fa-solid fa-house fa-lg mr-1"></i> LG
|
||||
</button>
|
||||
<button @click="setSize('fa-xl')" class="px-3 py-1.5 rounded-lg border text-xs transition-colors" :class="iconSize === 'fa-xl' ? 'border-violet-500 bg-violet-500/10 text-violet-300' : 'border-gray-700 text-gray-400 hover:border-gray-600'">
|
||||
<i class="fa-solid fa-house fa-xl mr-1"></i> XL
|
||||
</button>
|
||||
<button @click="setSize('fa-2xl')" class="px-3 py-1.5 rounded-lg border text-xs transition-colors" :class="iconSize === 'fa-2xl' ? 'border-violet-500 bg-violet-500/10 text-violet-300' : 'border-gray-700 text-gray-400 hover:border-gray-600'">
|
||||
<i class="fa-solid fa-house fa-2xl mr-1"></i> 2XL
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-gray-500 text-xs">
|
||||
<i class="fa-solid fa-info-circle mr-1"></i>
|
||||
Current: <code class="text-violet-400" x-text="iconStyle"></code>
|
||||
<span x-show="iconSize"> + <code class="text-violet-400" x-text="iconSize"></code></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Bar -->
|
||||
<div class="border-t border-violet-500/50 text-white text-xs font-mono shadow-lg" style="background-color: #0a0a0f;">
|
||||
<div class="flex items-center justify-between px-4 py-2">
|
||||
<!-- Left: Environment & User Info -->
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="px-2 py-0.5 bg-red-500/20 text-red-400 rounded text-[10px] font-semibold uppercase">
|
||||
<?php echo e(app()->environment()); ?>
|
||||
|
||||
</span>
|
||||
<span class="text-gray-600">|</span>
|
||||
<span class="text-violet-300">
|
||||
<i class="fa-solid fa-bolt mr-1"></i>Hades
|
||||
</span>
|
||||
</div>
|
||||
<div class="hidden sm:flex items-center gap-2 text-gray-400">
|
||||
<i class="fa-solid fa-user text-xs"></i>
|
||||
<span><?php echo e($user->name); ?></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Panel Toggle Buttons (positioned left of center) -->
|
||||
<div class="flex items-center gap-2 ml-8">
|
||||
<button
|
||||
@click="togglePanel('logs')"
|
||||
class="flex items-center justify-center w-9 h-9 rounded-lg transition-colors"
|
||||
:class="activePanel === 'logs' ? 'bg-violet-500/30 text-violet-300' : 'hover:bg-gray-800 text-gray-400 hover:text-white'"
|
||||
title="View Logs"
|
||||
>
|
||||
<i class="fa-solid fa-scroll text-lg"></i>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="togglePanel('routes')"
|
||||
class="flex items-center justify-center w-9 h-9 rounded-lg transition-colors"
|
||||
:class="activePanel === 'routes' ? 'bg-violet-500/30 text-violet-300' : 'hover:bg-gray-800 text-gray-400 hover:text-white'"
|
||||
title="View Routes"
|
||||
>
|
||||
<i class="fa-solid fa-route text-lg"></i>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="togglePanel('session')"
|
||||
class="flex items-center justify-center w-9 h-9 rounded-lg transition-colors"
|
||||
:class="activePanel === 'session' ? 'bg-violet-500/30 text-violet-300' : 'hover:bg-gray-800 text-gray-400 hover:text-white'"
|
||||
title="Session Info"
|
||||
>
|
||||
<i class="fa-solid fa-fingerprint text-lg"></i>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="togglePanel('cache')"
|
||||
class="flex items-center justify-center w-9 h-9 rounded-lg transition-colors"
|
||||
:class="activePanel === 'cache' ? 'bg-violet-500/30 text-violet-300' : 'hover:bg-gray-800 text-gray-400 hover:text-white'"
|
||||
title="Cache Management"
|
||||
>
|
||||
<i class="fa-solid fa-database text-lg"></i>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="togglePanel('appearance')"
|
||||
class="flex items-center justify-center w-9 h-9 rounded-lg transition-colors"
|
||||
:class="activePanel === 'appearance' ? 'bg-violet-500/30 text-violet-300' : 'hover:bg-gray-800 text-gray-400 hover:text-white'"
|
||||
title="Appearance & Icons"
|
||||
>
|
||||
<i class="fa-solid fa-palette text-lg"></i>
|
||||
</button>
|
||||
|
||||
<span class="text-gray-700 mx-2">|</span>
|
||||
|
||||
<!-- External Dev Tools -->
|
||||
<?php if(\Livewire\Mechanisms\ExtendBlade\ExtendBlade::isRenderingLivewireComponent()): ?><!--[if BLOCK]><![endif]--><?php endif; ?><?php if($hasHorizon): ?>
|
||||
<a href="/horizon" target="_blank" class="flex items-center justify-center w-9 h-9 rounded-lg hover:bg-green-500/20 text-gray-400 hover:text-green-400 transition-colors" title="Laravel Horizon">
|
||||
<i class="fa-solid fa-chart-line text-lg"></i>
|
||||
</a>
|
||||
<?php endif; ?><?php if(\Livewire\Mechanisms\ExtendBlade\ExtendBlade::isRenderingLivewireComponent()): ?><!--[if ENDBLOCK]><![endif]--><?php endif; ?>
|
||||
|
||||
<?php if(\Livewire\Mechanisms\ExtendBlade\ExtendBlade::isRenderingLivewireComponent()): ?><!--[if BLOCK]><![endif]--><?php endif; ?><?php if($hasPulse): ?>
|
||||
<a href="/pulse" target="_blank" class="flex items-center justify-center w-9 h-9 rounded-lg hover:bg-pink-500/20 text-gray-400 hover:text-pink-400 transition-colors" title="Laravel Pulse">
|
||||
<i class="fa-solid fa-heart-pulse text-lg"></i>
|
||||
</a>
|
||||
<?php endif; ?><?php if(\Livewire\Mechanisms\ExtendBlade\ExtendBlade::isRenderingLivewireComponent()): ?><!--[if ENDBLOCK]><![endif]--><?php endif; ?>
|
||||
|
||||
<?php if(\Livewire\Mechanisms\ExtendBlade\ExtendBlade::isRenderingLivewireComponent()): ?><!--[if BLOCK]><![endif]--><?php endif; ?><?php if($hasTelescope): ?>
|
||||
<a href="/telescope" target="_blank" class="flex items-center justify-center w-9 h-9 rounded-lg hover:bg-indigo-500/20 text-gray-400 hover:text-indigo-400 transition-colors" title="Laravel Telescope">
|
||||
<i class="fa-solid fa-satellite-dish text-lg"></i>
|
||||
</a>
|
||||
<?php endif; ?><?php if(\Livewire\Mechanisms\ExtendBlade\ExtendBlade::isRenderingLivewireComponent()): ?><!--[if ENDBLOCK]><![endif]--><?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Right: Performance Stats & Close -->
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="hidden md:flex items-center gap-3 text-gray-400">
|
||||
<span title="Database queries">
|
||||
<i class="fa-solid fa-database text-violet-400"></i>
|
||||
<?php echo e($queryCount); ?>q
|
||||
</span>
|
||||
<span title="Page load time">
|
||||
<i class="fa-solid fa-clock text-violet-400"></i>
|
||||
<?php echo e($loadTime); ?>ms
|
||||
</span>
|
||||
<span title="Peak memory usage">
|
||||
<i class="fa-solid fa-memory text-violet-400"></i>
|
||||
<?php echo e($memoryUsage); ?>MB
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="$el.closest('.fixed').classList.add('hidden')"
|
||||
class="flex items-center justify-center w-6 h-6 bg-gray-700/50 hover:bg-red-500/30 hover:text-red-400 rounded transition-colors"
|
||||
title="Hide dev bar (refresh to restore)"
|
||||
>
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add bottom padding to content when dev bar is visible -->
|
||||
<style>
|
||||
body { padding-bottom: 2.75rem; }
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
<?php endif; ?><?php if(\Livewire\Mechanisms\ExtendBlade\ExtendBlade::isRenderingLivewireComponent()): ?><!--[if ENDBLOCK]><![endif]--><?php endif; ?><?php /**PATH /Users/snider/Code/lab/core-php/packages/core-admin/src/Website/Hub/View/Blade/admin/components/developer-bar.blade.php ENDPATH**/ ?>
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
<ul class="space-y-1">
|
||||
<?php if(\Livewire\Mechanisms\ExtendBlade\ExtendBlade::isRenderingLivewireComponent()): ?><!--[if BLOCK]><![endif]--><?php endif; ?><?php $__currentLoopData = $items; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $item): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>
|
||||
<?php if(\Livewire\Mechanisms\ExtendBlade\ExtendBlade::isRenderingLivewireComponent()): ?><!--[if BLOCK]><![endif]--><?php endif; ?><?php if(!empty($item['divider'])): ?>
|
||||
|
||||
<li class="py-2">
|
||||
<hr class="border-gray-200 dark:border-gray-700" />
|
||||
</li>
|
||||
<?php elseif(!empty($item['children'])): ?>
|
||||
|
||||
<li>
|
||||
<?php if (isset($component)) { $__componentOriginal682b96ac110668cc64da2afbea515f52 = $component; } ?>
|
||||
<?php if (isset($attributes)) { $__attributesOriginal682b96ac110668cc64da2afbea515f52 = $attributes; } ?>
|
||||
<?php $component = Illuminate\View\AnonymousComponent::resolve(['view' => 'admin::components.nav-menu','data' => ['title' => $item['label'],'icon' => $item['icon'] ?? null,'active' => $item['active'] ?? false,'color' => $item['color'] ?? 'gray']] + (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag ? $attributes->all() : [])); ?>
|
||||
<?php $component->withName('admin::nav-menu'); ?>
|
||||
<?php if ($component->shouldRender()): ?>
|
||||
<?php $__env->startComponent($component->resolveView(), $component->data()); ?>
|
||||
<?php if (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag): ?>
|
||||
<?php $attributes = $attributes->except(\Illuminate\View\AnonymousComponent::ignoredParameterNames()); ?>
|
||||
<?php endif; ?>
|
||||
<?php $component->withAttributes(['title' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($item['label']),'icon' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($item['icon'] ?? null),'active' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($item['active'] ?? false),'color' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($item['color'] ?? 'gray')]); ?>
|
||||
<?php if(\Livewire\Mechanisms\ExtendBlade\ExtendBlade::isRenderingLivewireComponent()): ?><!--[if BLOCK]><![endif]--><?php endif; ?><?php $__currentLoopData = $item['children']; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $child): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>
|
||||
<?php if(\Livewire\Mechanisms\ExtendBlade\ExtendBlade::isRenderingLivewireComponent()): ?><!--[if BLOCK]><![endif]--><?php endif; ?><?php if(!empty($child['section'])): ?>
|
||||
|
||||
<?php
|
||||
$sectionIconClass = match($child['color'] ?? 'gray') {
|
||||
'violet' => 'text-violet-500',
|
||||
'blue' => 'text-blue-500',
|
||||
'green' => 'text-green-500',
|
||||
'red' => 'text-red-500',
|
||||
'amber' => 'text-amber-500',
|
||||
'emerald' => 'text-emerald-500',
|
||||
'cyan' => 'text-cyan-500',
|
||||
'pink' => 'text-pink-500',
|
||||
default => 'text-gray-500',
|
||||
};
|
||||
?>
|
||||
<li class="pt-3 pb-1 first:pt-1 flex items-center gap-2">
|
||||
<?php if(\Livewire\Mechanisms\ExtendBlade\ExtendBlade::isRenderingLivewireComponent()): ?><!--[if BLOCK]><![endif]--><?php endif; ?><?php if(!empty($child['icon'])): ?>
|
||||
<?php if (isset($component)) { $__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac = $component; } ?>
|
||||
<?php if (isset($attributes)) { $__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac = $attributes; } ?>
|
||||
<?php $component = Illuminate\View\AnonymousComponent::resolve(['view' => '8def1252668913628243c4d363bee1ef::icon','data' => ['name' => $child['icon'],'class' => 'size-4 shrink-0 '.e($sectionIconClass).'']] + (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag ? $attributes->all() : [])); ?>
|
||||
<?php $component->withName('core::icon'); ?>
|
||||
<?php if ($component->shouldRender()): ?>
|
||||
<?php $__env->startComponent($component->resolveView(), $component->data()); ?>
|
||||
<?php if (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag): ?>
|
||||
<?php $attributes = $attributes->except(\Illuminate\View\AnonymousComponent::ignoredParameterNames()); ?>
|
||||
<?php endif; ?>
|
||||
<?php $component->withAttributes(['name' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($child['icon']),'class' => 'size-4 shrink-0 '.e($sectionIconClass).'']); ?>
|
||||
<?php echo $__env->renderComponent(); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac)): ?>
|
||||
<?php $attributes = $__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac; ?>
|
||||
<?php unset($__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac)): ?>
|
||||
<?php $component = $__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac; ?>
|
||||
<?php unset($__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac); ?>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?><?php if(\Livewire\Mechanisms\ExtendBlade\ExtendBlade::isRenderingLivewireComponent()): ?><!--[if ENDBLOCK]><![endif]--><?php endif; ?>
|
||||
<span class="text-xs font-semibold uppercase tracking-wider <?php echo e($sectionIconClass); ?>">
|
||||
<?php echo e($child['section']); ?>
|
||||
|
||||
</span>
|
||||
</li>
|
||||
<?php else: ?>
|
||||
<?php if (isset($component)) { $__componentOriginalb4eba500ec155d6509aada51e9da8cc6 = $component; } ?>
|
||||
<?php if (isset($attributes)) { $__attributesOriginalb4eba500ec155d6509aada51e9da8cc6 = $attributes; } ?>
|
||||
<?php $component = Illuminate\View\AnonymousComponent::resolve(['view' => 'admin::components.nav-link','data' => ['href' => $child['href'] ?? '#','active' => $child['active'] ?? false,'badge' => $child['badge'] ?? null,'icon' => $child['icon'] ?? null,'color' => $child['color'] ?? null]] + (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag ? $attributes->all() : [])); ?>
|
||||
<?php $component->withName('admin::nav-link'); ?>
|
||||
<?php if ($component->shouldRender()): ?>
|
||||
<?php $__env->startComponent($component->resolveView(), $component->data()); ?>
|
||||
<?php if (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag): ?>
|
||||
<?php $attributes = $attributes->except(\Illuminate\View\AnonymousComponent::ignoredParameterNames()); ?>
|
||||
<?php endif; ?>
|
||||
<?php $component->withAttributes(['href' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($child['href'] ?? '#'),'active' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($child['active'] ?? false),'badge' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($child['badge'] ?? null),'icon' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($child['icon'] ?? null),'color' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($child['color'] ?? null)]); ?><?php echo e($child['label']); ?> <?php echo $__env->renderComponent(); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__attributesOriginalb4eba500ec155d6509aada51e9da8cc6)): ?>
|
||||
<?php $attributes = $__attributesOriginalb4eba500ec155d6509aada51e9da8cc6; ?>
|
||||
<?php unset($__attributesOriginalb4eba500ec155d6509aada51e9da8cc6); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__componentOriginalb4eba500ec155d6509aada51e9da8cc6)): ?>
|
||||
<?php $component = $__componentOriginalb4eba500ec155d6509aada51e9da8cc6; ?>
|
||||
<?php unset($__componentOriginalb4eba500ec155d6509aada51e9da8cc6); ?>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?><?php if(\Livewire\Mechanisms\ExtendBlade\ExtendBlade::isRenderingLivewireComponent()): ?><!--[if ENDBLOCK]><![endif]--><?php endif; ?>
|
||||
<?php endforeach; $__env->popLoop(); $loop = $__env->getLastLoop(); ?><?php if(\Livewire\Mechanisms\ExtendBlade\ExtendBlade::isRenderingLivewireComponent()): ?><!--[if ENDBLOCK]><![endif]--><?php endif; ?>
|
||||
<?php echo $__env->renderComponent(); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__attributesOriginal682b96ac110668cc64da2afbea515f52)): ?>
|
||||
<?php $attributes = $__attributesOriginal682b96ac110668cc64da2afbea515f52; ?>
|
||||
<?php unset($__attributesOriginal682b96ac110668cc64da2afbea515f52); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__componentOriginal682b96ac110668cc64da2afbea515f52)): ?>
|
||||
<?php $component = $__componentOriginal682b96ac110668cc64da2afbea515f52; ?>
|
||||
<?php unset($__componentOriginal682b96ac110668cc64da2afbea515f52); ?>
|
||||
<?php endif; ?>
|
||||
</li>
|
||||
<?php else: ?>
|
||||
|
||||
<li>
|
||||
<?php if (isset($component)) { $__componentOriginalbc8510acb3a8c4c112b7b802c754914c = $component; } ?>
|
||||
<?php if (isset($attributes)) { $__attributesOriginalbc8510acb3a8c4c112b7b802c754914c = $attributes; } ?>
|
||||
<?php $component = Illuminate\View\AnonymousComponent::resolve(['view' => 'admin::components.nav-item','data' => ['href' => $item['href'] ?? '#','icon' => $item['icon'] ?? null,'active' => $item['active'] ?? false,'color' => $item['color'] ?? 'gray','badge' => $item['badge'] ?? null]] + (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag ? $attributes->all() : [])); ?>
|
||||
<?php $component->withName('admin::nav-item'); ?>
|
||||
<?php if ($component->shouldRender()): ?>
|
||||
<?php $__env->startComponent($component->resolveView(), $component->data()); ?>
|
||||
<?php if (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag): ?>
|
||||
<?php $attributes = $attributes->except(\Illuminate\View\AnonymousComponent::ignoredParameterNames()); ?>
|
||||
<?php endif; ?>
|
||||
<?php $component->withAttributes(['href' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($item['href'] ?? '#'),'icon' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($item['icon'] ?? null),'active' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($item['active'] ?? false),'color' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($item['color'] ?? 'gray'),'badge' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($item['badge'] ?? null)]); ?><?php echo e($item['label']); ?> <?php echo $__env->renderComponent(); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__attributesOriginalbc8510acb3a8c4c112b7b802c754914c)): ?>
|
||||
<?php $attributes = $__attributesOriginalbc8510acb3a8c4c112b7b802c754914c; ?>
|
||||
<?php unset($__attributesOriginalbc8510acb3a8c4c112b7b802c754914c); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__componentOriginalbc8510acb3a8c4c112b7b802c754914c)): ?>
|
||||
<?php $component = $__componentOriginalbc8510acb3a8c4c112b7b802c754914c; ?>
|
||||
<?php unset($__componentOriginalbc8510acb3a8c4c112b7b802c754914c); ?>
|
||||
<?php endif; ?>
|
||||
</li>
|
||||
<?php endif; ?><?php if(\Livewire\Mechanisms\ExtendBlade\ExtendBlade::isRenderingLivewireComponent()): ?><!--[if ENDBLOCK]><![endif]--><?php endif; ?>
|
||||
<?php endforeach; $__env->popLoop(); $loop = $__env->getLastLoop(); ?><?php if(\Livewire\Mechanisms\ExtendBlade\ExtendBlade::isRenderingLivewireComponent()): ?><!--[if ENDBLOCK]><![endif]--><?php endif; ?>
|
||||
</ul>
|
||||
<?php /**PATH /Users/snider/Code/lab/core-php/packages/core-php/src/Core/Front/Admin/Blade/components/sidemenu.blade.php ENDPATH**/ ?>
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
|
||||
|
||||
<?php $attributes ??= new \Illuminate\View\ComponentAttributeBag;
|
||||
|
||||
$__newAttributes = [];
|
||||
$__propNames = \Illuminate\View\ComponentAttributeBag::extractPropNames(([
|
||||
'as' => null,
|
||||
]));
|
||||
|
||||
foreach ($attributes->all() as $__key => $__value) {
|
||||
if (in_array($__key, $__propNames)) {
|
||||
$$__key = $$__key ?? $__value;
|
||||
} else {
|
||||
$__newAttributes[$__key] = $__value;
|
||||
}
|
||||
}
|
||||
|
||||
$attributes = new \Illuminate\View\ComponentAttributeBag($__newAttributes);
|
||||
|
||||
unset($__propNames);
|
||||
unset($__newAttributes);
|
||||
|
||||
foreach (array_filter(([
|
||||
'as' => null,
|
||||
]), 'is_string', ARRAY_FILTER_USE_KEY) as $__key => $__value) {
|
||||
$$__key = $$__key ?? $__value;
|
||||
}
|
||||
|
||||
$__defined_vars = get_defined_vars();
|
||||
|
||||
foreach ($attributes->all() as $__key => $__value) {
|
||||
if (array_key_exists($__key, $__defined_vars)) unset($$__key);
|
||||
}
|
||||
|
||||
unset($__defined_vars, $__key, $__value); ?>
|
||||
|
||||
<?php if ($as === 'button'): ?>
|
||||
<button <?php echo e($attributes->merge(['type' => 'button'])); ?>>
|
||||
<?php echo e($slot); ?>
|
||||
|
||||
</button>
|
||||
<?php else: ?>
|
||||
<div <?php echo e($attributes); ?>>
|
||||
<?php echo e($slot); ?>
|
||||
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php /**PATH /Users/snider/Code/lab/core-php/vendor/livewire/flux/src/../stubs/resources/views/flux/button-or-div.blade.php ENDPATH**/ ?>
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
<?php $attributes ??= new \Illuminate\View\ComponentAttributeBag;
|
||||
|
||||
$__newAttributes = [];
|
||||
$__propNames = \Illuminate\View\ComponentAttributeBag::extractPropNames(([
|
||||
'href',
|
||||
'active' => false,
|
||||
'badge' => null,
|
||||
'icon' => null,
|
||||
'color' => null,
|
||||
]));
|
||||
|
||||
foreach ($attributes->all() as $__key => $__value) {
|
||||
if (in_array($__key, $__propNames)) {
|
||||
$$__key = $$__key ?? $__value;
|
||||
} else {
|
||||
$__newAttributes[$__key] = $__value;
|
||||
}
|
||||
}
|
||||
|
||||
$attributes = new \Illuminate\View\ComponentAttributeBag($__newAttributes);
|
||||
|
||||
unset($__propNames);
|
||||
unset($__newAttributes);
|
||||
|
||||
foreach (array_filter(([
|
||||
'href',
|
||||
'active' => false,
|
||||
'badge' => null,
|
||||
'icon' => null,
|
||||
'color' => null,
|
||||
]), 'is_string', ARRAY_FILTER_USE_KEY) as $__key => $__value) {
|
||||
$$__key = $$__key ?? $__value;
|
||||
}
|
||||
|
||||
$__defined_vars = get_defined_vars();
|
||||
|
||||
foreach ($attributes->all() as $__key => $__value) {
|
||||
if (array_key_exists($__key, $__defined_vars)) unset($$__key);
|
||||
}
|
||||
|
||||
unset($__defined_vars, $__key, $__value); ?>
|
||||
|
||||
<li>
|
||||
<a class="flex items-center justify-between text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 truncate transition text-sm py-1 <?php if($active): ?> !text-violet-500 <?php endif; ?>" href="<?php echo e($href); ?>">
|
||||
<span class="flex items-center gap-2">
|
||||
<?php if(\Livewire\Mechanisms\ExtendBlade\ExtendBlade::isRenderingLivewireComponent()): ?><!--[if BLOCK]><![endif]--><?php endif; ?><?php if($icon): ?>
|
||||
<?php if (isset($component)) { $__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac = $component; } ?>
|
||||
<?php if (isset($attributes)) { $__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac = $attributes; } ?>
|
||||
<?php $component = Illuminate\View\AnonymousComponent::resolve(['view' => '8def1252668913628243c4d363bee1ef::icon','data' => ['name' => $icon,'class' => 'size-4 shrink-0 '.e($color ? 'text-' . $color . '-500' : '').'']] + (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag ? $attributes->all() : [])); ?>
|
||||
<?php $component->withName('core::icon'); ?>
|
||||
<?php if ($component->shouldRender()): ?>
|
||||
<?php $__env->startComponent($component->resolveView(), $component->data()); ?>
|
||||
<?php if (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag): ?>
|
||||
<?php $attributes = $attributes->except(\Illuminate\View\AnonymousComponent::ignoredParameterNames()); ?>
|
||||
<?php endif; ?>
|
||||
<?php $component->withAttributes(['name' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($icon),'class' => 'size-4 shrink-0 '.e($color ? 'text-' . $color . '-500' : '').'']); ?>
|
||||
<?php echo $__env->renderComponent(); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac)): ?>
|
||||
<?php $attributes = $__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac; ?>
|
||||
<?php unset($__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac)): ?>
|
||||
<?php $component = $__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac; ?>
|
||||
<?php unset($__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac); ?>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?><?php if(\Livewire\Mechanisms\ExtendBlade\ExtendBlade::isRenderingLivewireComponent()): ?><!--[if ENDBLOCK]><![endif]--><?php endif; ?>
|
||||
<?php echo e($slot); ?>
|
||||
|
||||
</span>
|
||||
<?php if(\Livewire\Mechanisms\ExtendBlade\ExtendBlade::isRenderingLivewireComponent()): ?><!--[if BLOCK]><![endif]--><?php endif; ?><?php if($badge): ?>
|
||||
<span class="text-xs bg-amber-500 text-white px-1.5 py-0.5 rounded-full"><?php echo e($badge); ?></span>
|
||||
<?php endif; ?><?php if(\Livewire\Mechanisms\ExtendBlade\ExtendBlade::isRenderingLivewireComponent()): ?><!--[if ENDBLOCK]><![endif]--><?php endif; ?>
|
||||
</a>
|
||||
</li>
|
||||
<?php /**PATH /Users/snider/Code/lab/core-php/packages/core-php/src/Core/Front/Admin/Blade/components/nav-link.blade.php ENDPATH**/ ?>
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
<?php if (isset($component)) { $__componentOriginalef86c4657bdf6f09444c7ff0dcf7933f = $component; } ?>
|
||||
<?php if (isset($attributes)) { $__attributesOriginalef86c4657bdf6f09444c7ff0dcf7933f = $attributes; } ?>
|
||||
<?php $component = Illuminate\View\AnonymousComponent::resolve(['view' => 'admin::components.sidebar','data' => ['logo' => '/images/host-uk-raven.svg','logoText' => 'Host Hub','logoRoute' => route('hub.dashboard')]] + (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag ? $attributes->all() : [])); ?>
|
||||
<?php $component->withName('admin::sidebar'); ?>
|
||||
<?php if ($component->shouldRender()): ?>
|
||||
<?php $__env->startComponent($component->resolveView(), $component->data()); ?>
|
||||
<?php if (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag): ?>
|
||||
<?php $attributes = $attributes->except(\Illuminate\View\AnonymousComponent::ignoredParameterNames()); ?>
|
||||
<?php endif; ?>
|
||||
<?php $component->withAttributes(['logo' => '/images/host-uk-raven.svg','logoText' => 'Host Hub','logoRoute' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute(route('hub.dashboard'))]); ?>
|
||||
<?php if (isset($component)) { $__componentOriginaldf3c161788667d188981a4c6b1bfdb29 = $component; } ?>
|
||||
<?php if (isset($attributes)) { $__attributesOriginaldf3c161788667d188981a4c6b1bfdb29 = $attributes; } ?>
|
||||
<?php $component = Core\Front\Admin\View\Components\Sidemenu::resolve([] + (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag ? $attributes->all() : [])); ?>
|
||||
<?php $component->withName('admin-sidemenu'); ?>
|
||||
<?php if ($component->shouldRender()): ?>
|
||||
<?php $__env->startComponent($component->resolveView(), $component->data()); ?>
|
||||
<?php if (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag): ?>
|
||||
<?php $attributes = $attributes->except(\Core\Front\Admin\View\Components\Sidemenu::ignoredParameterNames()); ?>
|
||||
<?php endif; ?>
|
||||
<?php $component->withAttributes([]); ?>
|
||||
<?php echo $__env->renderComponent(); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__attributesOriginaldf3c161788667d188981a4c6b1bfdb29)): ?>
|
||||
<?php $attributes = $__attributesOriginaldf3c161788667d188981a4c6b1bfdb29; ?>
|
||||
<?php unset($__attributesOriginaldf3c161788667d188981a4c6b1bfdb29); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__componentOriginaldf3c161788667d188981a4c6b1bfdb29)): ?>
|
||||
<?php $component = $__componentOriginaldf3c161788667d188981a4c6b1bfdb29; ?>
|
||||
<?php unset($__componentOriginaldf3c161788667d188981a4c6b1bfdb29); ?>
|
||||
<?php endif; ?>
|
||||
<?php echo $__env->renderComponent(); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__attributesOriginalef86c4657bdf6f09444c7ff0dcf7933f)): ?>
|
||||
<?php $attributes = $__attributesOriginalef86c4657bdf6f09444c7ff0dcf7933f; ?>
|
||||
<?php unset($__attributesOriginalef86c4657bdf6f09444c7ff0dcf7933f); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__componentOriginalef86c4657bdf6f09444c7ff0dcf7933f)): ?>
|
||||
<?php $component = $__componentOriginalef86c4657bdf6f09444c7ff0dcf7933f; ?>
|
||||
<?php unset($__componentOriginalef86c4657bdf6f09444c7ff0dcf7933f); ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php /**PATH /Users/snider/Code/lab/core-php/packages/core-admin/src/Website/Hub/View/Blade/admin/components/sidebar.blade.php ENDPATH**/ ?>
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
<?php $attributes ??= new \Illuminate\View\ComponentAttributeBag;
|
||||
|
||||
$__newAttributes = [];
|
||||
$__propNames = \Illuminate\View\ComponentAttributeBag::extractPropNames(([
|
||||
'logo' => null,
|
||||
'logoRoute' => '/',
|
||||
'logoText' => 'Admin',
|
||||
]));
|
||||
|
||||
foreach ($attributes->all() as $__key => $__value) {
|
||||
if (in_array($__key, $__propNames)) {
|
||||
$$__key = $$__key ?? $__value;
|
||||
} else {
|
||||
$__newAttributes[$__key] = $__value;
|
||||
}
|
||||
}
|
||||
|
||||
$attributes = new \Illuminate\View\ComponentAttributeBag($__newAttributes);
|
||||
|
||||
unset($__propNames);
|
||||
unset($__newAttributes);
|
||||
|
||||
foreach (array_filter(([
|
||||
'logo' => null,
|
||||
'logoRoute' => '/',
|
||||
'logoText' => 'Admin',
|
||||
]), 'is_string', ARRAY_FILTER_USE_KEY) as $__key => $__value) {
|
||||
$$__key = $$__key ?? $__value;
|
||||
}
|
||||
|
||||
$__defined_vars = get_defined_vars();
|
||||
|
||||
foreach ($attributes->all() as $__key => $__value) {
|
||||
if (array_key_exists($__key, $__defined_vars)) unset($$__key);
|
||||
}
|
||||
|
||||
unset($__defined_vars, $__key, $__value); ?>
|
||||
|
||||
<div class="min-w-fit">
|
||||
<!-- Sidebar backdrop (mobile + tablet overlay) -->
|
||||
<div
|
||||
class="fixed inset-0 bg-gray-900/30 z-40 lg:hidden transition-opacity duration-200"
|
||||
:class="sidebarOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'"
|
||||
aria-hidden="true"
|
||||
x-cloak
|
||||
></div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div
|
||||
id="sidebar"
|
||||
class="flex sm:flex! flex-col fixed z-40 left-0 top-0 h-[100dvh] overflow-y-scroll sm:overflow-y-auto no-scrollbar w-64 sm:w-20 lg:w-64 shrink-0 bg-white dark:bg-gray-800 p-4 transition-all duration-200 ease-in-out border-r border-gray-200 dark:border-gray-700/60"
|
||||
:class="{
|
||||
'max-sm:translate-x-0': sidebarOpen,
|
||||
'max-sm:-translate-x-64': !sidebarOpen,
|
||||
'sm:w-64 lg:w-64': sidebarOpen,
|
||||
'sm:w-20 lg:w-64': !sidebarOpen
|
||||
}"
|
||||
@click.outside="sidebarOpen = false"
|
||||
@keydown.escape.window="sidebarOpen = false"
|
||||
>
|
||||
|
||||
<!-- Sidebar header -->
|
||||
<div class="flex justify-between mb-10 pr-3 sm:px-2">
|
||||
<!-- Close button (mobile + tablet overlay) -->
|
||||
<button class="lg:hidden text-gray-500 hover:text-gray-400" :class="{ 'sm:hidden': !sidebarOpen }" @click.stop="sidebarOpen = false" aria-controls="sidebar" :aria-expanded="sidebarOpen">
|
||||
<span class="sr-only">Close sidebar</span>
|
||||
<svg class="w-6 h-6 fill-current" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.7 18.7l1.4-1.4L7.8 13H20v-2H7.8l4.3-4.3-1.4-1.4L4 12z" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Logo -->
|
||||
<a class="block" href="<?php echo e($logoRoute); ?>">
|
||||
<div class="flex items-center gap-2">
|
||||
<?php if(\Livewire\Mechanisms\ExtendBlade\ExtendBlade::isRenderingLivewireComponent()): ?><!--[if BLOCK]><![endif]--><?php endif; ?><?php if($logo): ?>
|
||||
<img src="<?php echo e($logo); ?>" alt="<?php echo e($logoText); ?>" class="w-8 h-8">
|
||||
<?php endif; ?><?php if(\Livewire\Mechanisms\ExtendBlade\ExtendBlade::isRenderingLivewireComponent()): ?><!--[if ENDBLOCK]><![endif]--><?php endif; ?>
|
||||
<span class="text-lg font-bold text-gray-800 dark:text-gray-100 duration-200"
|
||||
:class="{ 'sm:opacity-0 lg:opacity-100': !sidebarOpen, 'opacity-100': sidebarOpen }"><?php echo e($logoText); ?></span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Navigation content (provided by module) -->
|
||||
<div class="space-y-8">
|
||||
<?php echo e($slot); ?>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<?php /**PATH /Users/snider/Code/lab/core-php/packages/core-php/src/Core/Front/Admin/Blade/components/sidebar.blade.php ENDPATH**/ ?>
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
<?php $attributes ??= new \Illuminate\View\ComponentAttributeBag;
|
||||
|
||||
$__newAttributes = [];
|
||||
$__propNames = \Illuminate\View\ComponentAttributeBag::extractPropNames(([
|
||||
'href',
|
||||
'icon' => null,
|
||||
'active' => false,
|
||||
'color' => 'gray',
|
||||
'badge' => null,
|
||||
]));
|
||||
|
||||
foreach ($attributes->all() as $__key => $__value) {
|
||||
if (in_array($__key, $__propNames)) {
|
||||
$$__key = $$__key ?? $__value;
|
||||
} else {
|
||||
$__newAttributes[$__key] = $__value;
|
||||
}
|
||||
}
|
||||
|
||||
$attributes = new \Illuminate\View\ComponentAttributeBag($__newAttributes);
|
||||
|
||||
unset($__propNames);
|
||||
unset($__newAttributes);
|
||||
|
||||
foreach (array_filter(([
|
||||
'href',
|
||||
'icon' => null,
|
||||
'active' => false,
|
||||
'color' => 'gray',
|
||||
'badge' => null,
|
||||
]), 'is_string', ARRAY_FILTER_USE_KEY) as $__key => $__value) {
|
||||
$$__key = $$__key ?? $__value;
|
||||
}
|
||||
|
||||
$__defined_vars = get_defined_vars();
|
||||
|
||||
foreach ($attributes->all() as $__key => $__value) {
|
||||
if (array_key_exists($__key, $__defined_vars)) unset($$__key);
|
||||
}
|
||||
|
||||
unset($__defined_vars, $__key, $__value); ?>
|
||||
|
||||
<li class="pl-4 pr-3 py-2 rounded-lg mb-0.5 last:mb-0 bg-linear-to-r <?php if($active): ?> from-violet-500/[0.12] dark:from-violet-500/[0.24] to-violet-500/[0.04] <?php endif; ?>" x-data>
|
||||
<a class="block text-gray-800 dark:text-gray-100 truncate transition <?php if(!$active): ?> hover:text-gray-900 dark:hover:text-white <?php endif; ?>"
|
||||
href="<?php echo e($href); ?>"
|
||||
@click="if (window.innerWidth >= 640 && window.innerWidth < 1024 && !sidebarOpen) { $event.preventDefault(); $dispatch('open-sidebar'); }">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<?php if(\Livewire\Mechanisms\ExtendBlade\ExtendBlade::isRenderingLivewireComponent()): ?><!--[if BLOCK]><![endif]--><?php endif; ?><?php if($icon): ?>
|
||||
<?php if (isset($component)) { $__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac = $component; } ?>
|
||||
<?php if (isset($attributes)) { $__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac = $attributes; } ?>
|
||||
<?php $component = Illuminate\View\AnonymousComponent::resolve(['view' => '8def1252668913628243c4d363bee1ef::icon','data' => ['name' => $icon,'class' => 'shrink-0 '.e($active ? 'text-violet-500' : 'text-' . $color . '-500').'']] + (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag ? $attributes->all() : [])); ?>
|
||||
<?php $component->withName('core::icon'); ?>
|
||||
<?php if ($component->shouldRender()): ?>
|
||||
<?php $__env->startComponent($component->resolveView(), $component->data()); ?>
|
||||
<?php if (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag): ?>
|
||||
<?php $attributes = $attributes->except(\Illuminate\View\AnonymousComponent::ignoredParameterNames()); ?>
|
||||
<?php endif; ?>
|
||||
<?php $component->withAttributes(['name' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($icon),'class' => 'shrink-0 '.e($active ? 'text-violet-500' : 'text-' . $color . '-500').'']); ?>
|
||||
<?php echo $__env->renderComponent(); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac)): ?>
|
||||
<?php $attributes = $__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac; ?>
|
||||
<?php unset($__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac)): ?>
|
||||
<?php $component = $__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac; ?>
|
||||
<?php unset($__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac); ?>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?><?php if(\Livewire\Mechanisms\ExtendBlade\ExtendBlade::isRenderingLivewireComponent()): ?><!--[if ENDBLOCK]><![endif]--><?php endif; ?>
|
||||
<span class="text-sm font-medium <?php if($icon): ?> ml-4 <?php endif; ?> duration-200"
|
||||
:class="{ 'sm:opacity-0 lg:opacity-100': !sidebarOpen, 'opacity-100': sidebarOpen }"><?php echo e($slot); ?></span>
|
||||
</div>
|
||||
<?php if(\Livewire\Mechanisms\ExtendBlade\ExtendBlade::isRenderingLivewireComponent()): ?><!--[if BLOCK]><![endif]--><?php endif; ?><?php if($badge): ?>
|
||||
<span class="text-xs bg-amber-500 text-white px-1.5 py-0.5 rounded-full duration-200"
|
||||
:class="{ 'sm:opacity-0 lg:opacity-100': !sidebarOpen, 'opacity-100': sidebarOpen }"><?php echo e($badge); ?></span>
|
||||
<?php endif; ?><?php if(\Livewire\Mechanisms\ExtendBlade\ExtendBlade::isRenderingLivewireComponent()): ?><!--[if ENDBLOCK]><![endif]--><?php endif; ?>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
<?php /**PATH /Users/snider/Code/lab/core-php/packages/core-php/src/Core/Front/Admin/Blade/components/nav-item.blade.php ENDPATH**/ ?>
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
|
||||
|
||||
<?php $attributes ??= new \Illuminate\View\ComponentAttributeBag;
|
||||
|
||||
$__newAttributes = [];
|
||||
$__propNames = \Illuminate\View\ComponentAttributeBag::extractPropNames(([
|
||||
'position' => 'bottom end',
|
||||
]));
|
||||
|
||||
foreach ($attributes->all() as $__key => $__value) {
|
||||
if (in_array($__key, $__propNames)) {
|
||||
$$__key = $$__key ?? $__value;
|
||||
} else {
|
||||
$__newAttributes[$__key] = $__value;
|
||||
}
|
||||
}
|
||||
|
||||
$attributes = new \Illuminate\View\ComponentAttributeBag($__newAttributes);
|
||||
|
||||
unset($__propNames);
|
||||
unset($__newAttributes);
|
||||
|
||||
foreach (array_filter(([
|
||||
'position' => 'bottom end',
|
||||
]), 'is_string', ARRAY_FILTER_USE_KEY) as $__key => $__value) {
|
||||
$$__key = $$__key ?? $__value;
|
||||
}
|
||||
|
||||
$__defined_vars = get_defined_vars();
|
||||
|
||||
foreach ($attributes->all() as $__key => $__value) {
|
||||
if (array_key_exists($__key, $__defined_vars)) unset($$__key);
|
||||
}
|
||||
|
||||
unset($__defined_vars, $__key, $__value); ?>
|
||||
|
||||
<ui-toast x-data x-on:toast-show.document="! $el.closest('ui-toast-group') && $el.showToast($event.detail)" popover="manual" position="<?php echo e($position); ?>" wire:ignore>
|
||||
<template>
|
||||
<div <?php echo e($attributes->only(['class'])->class('max-w-sm in-[ui-toast-group]:max-w-auto in-[ui-toast-group]:w-xs sm:in-[ui-toast-group]:w-sm')); ?> data-variant="" data-flux-toast-dialog>
|
||||
<div class="p-2 flex rounded-xl shadow-lg bg-white border border-zinc-200 border-b-zinc-300/80 dark:bg-zinc-700 dark:border-zinc-600">
|
||||
<div class="flex-1 flex items-start gap-4 overflow-hidden">
|
||||
<div class="flex-1 py-1.5 ps-2.5 flex gap-2">
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="hidden [[data-flux-toast-dialog][data-variant=success]_&]:block shrink-0 mt-0.5 size-4 text-lime-600 dark:text-lime-400">
|
||||
<path fill-rule="evenodd" d="M8 15A7 7 0 1 0 8 1a7 7 0 0 0 0 14Zm3.844-8.791a.75.75 0 0 0-1.188-.918l-3.7 4.79-1.649-1.833a.75.75 0 1 0-1.114 1.004l2.25 2.5a.75.75 0 0 0 1.15-.043l4.25-5.5Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="hidden [[data-flux-toast-dialog][data-variant=warning]_&]:block shrink-0 mt-0.5 size-4 text-amber-500 dark:text-amber-400">
|
||||
<path fill-rule="evenodd" d="M6.701 2.25c.577-1 2.02-1 2.598 0l5.196 9a1.5 1.5 0 0 1-1.299 2.25H2.804a1.5 1.5 0 0 1-1.3-2.25l5.197-9ZM8 4a.75.75 0 0 1 .75.75v3a.75.75 0 1 1-1.5 0v-3A.75.75 0 0 1 8 4Zm0 8a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="hidden [[data-flux-toast-dialog][data-variant=danger]_&]:block shrink-0 mt-0.5 size-4 text-rose-500 dark:text-rose-400">
|
||||
<path fill-rule="evenodd" d="M8 15A7 7 0 1 0 8 1a7 7 0 0 0 0 14ZM8 4a.75.75 0 0 1 .75.75v3a.75.75 0 0 1-1.5 0v-3A.75.75 0 0 1 8 4Zm0 8a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
||||
<div>
|
||||
|
||||
<div class="font-medium text-sm text-zinc-800 dark:text-white [&:not(:empty)+div]:font-normal [&:not(:empty)+div]:text-zinc-500 [&:not(:empty)+div]:dark:text-zinc-300 [&:not(:empty)]:pb-2"><slot name="heading"></slot></div>
|
||||
|
||||
|
||||
<div class="font-medium text-sm text-zinc-800 dark:text-white"><slot name="text"></slot></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<ui-close class="flex items-center">
|
||||
<button type="button" class="inline-flex items-center font-medium justify-center gap-2 truncate disabled:opacity-50 dark:disabled:opacity-75 disabled:cursor-default h-8 text-sm rounded-md w-8 bg-transparent hover:bg-zinc-800/5 dark:hover:bg-white/15 text-zinc-400 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-white" as="button">
|
||||
<div>
|
||||
<svg class="[:where(&)]:size-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" data-slot="icon">
|
||||
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</ui-close>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ui-toast>
|
||||
<?php /**PATH /Users/snider/Code/lab/core-php/vendor/livewire/flux-pro/src/../stubs/resources/views/flux/toast/index.blade.php ENDPATH**/ ?>
|
||||
|
|
@ -1,367 +0,0 @@
|
|||
<header class="sticky top-0 before:absolute before:inset-0 before:backdrop-blur-md max-sm:before:bg-white/90 dark:max-sm:before:bg-gray-800/90 before:-z-10 z-30 before:bg-white after:absolute after:h-px after:inset-x-0 after:top-full after:bg-gray-200 dark:after:bg-gray-700/60 after:-z-10 dark:before:bg-gray-800">
|
||||
<div class="px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
|
||||
<!-- Header: Left side -->
|
||||
<div class="flex items-center gap-4">
|
||||
|
||||
<!-- Hamburger button -->
|
||||
<button
|
||||
class="text-gray-500 hover:text-gray-600 dark:hover:text-gray-400 sm:hidden"
|
||||
@click.stop="sidebarOpen = !sidebarOpen"
|
||||
aria-controls="sidebar"
|
||||
:aria-expanded="sidebarOpen"
|
||||
>
|
||||
<span class="sr-only">Open sidebar</span>
|
||||
<svg class="w-6 h-6 fill-current" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="4" y="5" width="16" height="2" />
|
||||
<rect x="4" y="11" width="16" height="2" />
|
||||
<rect x="4" y="17" width="16" height="2" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Workspace Switcher -->
|
||||
<?php
|
||||
$__split = function ($name, $params = []) {
|
||||
return [$name, $params];
|
||||
};
|
||||
[$__name, $__params] = $__split('hub.admin.workspace-switcher', []);
|
||||
|
||||
$key = null;
|
||||
|
||||
$key ??= \Livewire\Features\SupportCompiledWireKeys\SupportCompiledWireKeys::generateKey('lw-337131364-0', null);
|
||||
|
||||
$__html = app('livewire')->mount($__name, $__params, $key);
|
||||
|
||||
echo $__html;
|
||||
|
||||
unset($__html);
|
||||
unset($__name);
|
||||
unset($__params);
|
||||
unset($__split);
|
||||
if (isset($__slots)) unset($__slots);
|
||||
?>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Header: Right side -->
|
||||
<div class="flex items-center space-x-1">
|
||||
|
||||
<!-- Search button -->
|
||||
<button class="flex items-center justify-center w-11 h-11 hover:bg-gray-100 lg:hover:bg-gray-200 dark:hover:bg-gray-700/50 dark:lg:hover:bg-gray-800 rounded-full transition-colors">
|
||||
<span class="sr-only">Search</span>
|
||||
<?php if (isset($component)) { $__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac = $component; } ?>
|
||||
<?php if (isset($attributes)) { $__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac = $attributes; } ?>
|
||||
<?php $component = Illuminate\View\AnonymousComponent::resolve(['view' => '8def1252668913628243c4d363bee1ef::icon','data' => ['name' => 'magnifying-glass','size' => 'fa-lg','class' => 'text-gray-500 dark:text-gray-400']] + (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag ? $attributes->all() : [])); ?>
|
||||
<?php $component->withName('core::icon'); ?>
|
||||
<?php if ($component->shouldRender()): ?>
|
||||
<?php $__env->startComponent($component->resolveView(), $component->data()); ?>
|
||||
<?php if (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag): ?>
|
||||
<?php $attributes = $attributes->except(\Illuminate\View\AnonymousComponent::ignoredParameterNames()); ?>
|
||||
<?php endif; ?>
|
||||
<?php $component->withAttributes(['name' => 'magnifying-glass','size' => 'fa-lg','class' => 'text-gray-500 dark:text-gray-400']); ?>
|
||||
<?php echo $__env->renderComponent(); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac)): ?>
|
||||
<?php $attributes = $__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac; ?>
|
||||
<?php unset($__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac)): ?>
|
||||
<?php $component = $__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac; ?>
|
||||
<?php unset($__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac); ?>
|
||||
<?php endif; ?>
|
||||
</button>
|
||||
|
||||
<!-- Notifications button -->
|
||||
<div class="relative inline-flex" x-data="{ open: false }">
|
||||
<button
|
||||
class="relative flex items-center justify-center w-11 h-11 hover:bg-gray-100 lg:hover:bg-gray-200 dark:hover:bg-gray-700/50 dark:lg:hover:bg-gray-800 rounded-full transition-colors"
|
||||
:class="{ 'bg-gray-200 dark:bg-gray-700': open }"
|
||||
aria-haspopup="true"
|
||||
@click.prevent="open = !open"
|
||||
:aria-expanded="open"
|
||||
>
|
||||
<span class="sr-only">Notifications</span>
|
||||
<?php if (isset($component)) { $__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac = $component; } ?>
|
||||
<?php if (isset($attributes)) { $__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac = $attributes; } ?>
|
||||
<?php $component = Illuminate\View\AnonymousComponent::resolve(['view' => '8def1252668913628243c4d363bee1ef::icon','data' => ['name' => 'bell','size' => 'fa-lg','class' => 'text-gray-500 dark:text-gray-400']] + (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag ? $attributes->all() : [])); ?>
|
||||
<?php $component->withName('core::icon'); ?>
|
||||
<?php if ($component->shouldRender()): ?>
|
||||
<?php $__env->startComponent($component->resolveView(), $component->data()); ?>
|
||||
<?php if (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag): ?>
|
||||
<?php $attributes = $attributes->except(\Illuminate\View\AnonymousComponent::ignoredParameterNames()); ?>
|
||||
<?php endif; ?>
|
||||
<?php $component->withAttributes(['name' => 'bell','size' => 'fa-lg','class' => 'text-gray-500 dark:text-gray-400']); ?>
|
||||
<?php echo $__env->renderComponent(); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac)): ?>
|
||||
<?php $attributes = $__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac; ?>
|
||||
<?php unset($__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac)): ?>
|
||||
<?php $component = $__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac; ?>
|
||||
<?php unset($__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($component)) { $__componentOriginal4cc377eda9b63b796b6668ee7832d023 = $component; } ?>
|
||||
<?php if (isset($attributes)) { $__attributesOriginal4cc377eda9b63b796b6668ee7832d023 = $attributes; } ?>
|
||||
<?php $component = Illuminate\View\AnonymousComponent::resolve(['view' => 'e60dd9d2c3a62d619c9acb38f20d5aa5::badge.index','data' => ['color' => 'red','size' => 'sm','class' => 'absolute -top-0.5 -right-0.5 min-w-5 h-5 flex items-center justify-center']] + (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag ? $attributes->all() : [])); ?>
|
||||
<?php $component->withName('flux::badge'); ?>
|
||||
<?php if ($component->shouldRender()): ?>
|
||||
<?php $__env->startComponent($component->resolveView(), $component->data()); ?>
|
||||
<?php if (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag): ?>
|
||||
<?php $attributes = $attributes->except(\Illuminate\View\AnonymousComponent::ignoredParameterNames()); ?>
|
||||
<?php endif; ?>
|
||||
<?php $component->withAttributes(['color' => 'red','size' => 'sm','class' => 'absolute -top-0.5 -right-0.5 min-w-5 h-5 flex items-center justify-center']); ?>2 <?php echo $__env->renderComponent(); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__attributesOriginal4cc377eda9b63b796b6668ee7832d023)): ?>
|
||||
<?php $attributes = $__attributesOriginal4cc377eda9b63b796b6668ee7832d023; ?>
|
||||
<?php unset($__attributesOriginal4cc377eda9b63b796b6668ee7832d023); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__componentOriginal4cc377eda9b63b796b6668ee7832d023)): ?>
|
||||
<?php $component = $__componentOriginal4cc377eda9b63b796b6668ee7832d023; ?>
|
||||
<?php unset($__componentOriginal4cc377eda9b63b796b6668ee7832d023); ?>
|
||||
<?php endif; ?>
|
||||
</button>
|
||||
<div
|
||||
class="origin-top-right z-10 absolute top-full -mr-48 sm:mr-0 min-w-80 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700/60 py-1.5 rounded-lg shadow-lg overflow-hidden mt-1 right-0"
|
||||
@click.outside="open = false"
|
||||
@keydown.escape.window="open = false"
|
||||
x-show="open"
|
||||
x-transition:enter="transition ease-out duration-200 transform"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 translate-y-0"
|
||||
x-transition:leave="transition ease-out duration-200"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
x-cloak
|
||||
>
|
||||
<div class="flex items-center justify-between pt-1.5 pb-2 px-4">
|
||||
<span class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase">Notifications</span>
|
||||
<button class="text-xs text-violet-500 hover:text-violet-600 dark:hover:text-violet-400">Mark all read</button>
|
||||
</div>
|
||||
<ul>
|
||||
<li class="border-b border-gray-200 dark:border-gray-700/60 last:border-0">
|
||||
<a class="block py-2 px-4 hover:bg-gray-50 dark:hover:bg-gray-700/20" href="<?php echo e(route('hub.deployments')); ?>" @click="open = false">
|
||||
<span class="block text-sm mb-2">New deployment completed for <span class="font-medium text-gray-800 dark:text-gray-100">Bio</span></span>
|
||||
<span class="block text-xs font-medium text-gray-400 dark:text-gray-500">2 hours ago</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="border-b border-gray-200 dark:border-gray-700/60 last:border-0">
|
||||
<a class="block py-2 px-4 hover:bg-gray-50 dark:hover:bg-gray-700/20" href="<?php echo e(route('hub.databases')); ?>" @click="open = false">
|
||||
<span class="block text-sm mb-2">Database backup successful for <span class="font-medium text-gray-800 dark:text-gray-100">Social</span></span>
|
||||
<span class="block text-xs font-medium text-gray-400 dark:text-gray-500">5 hours ago</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dark mode toggle -->
|
||||
<button
|
||||
class="flex items-center justify-center w-11 h-11 hover:bg-gray-100 lg:hover:bg-gray-200 dark:hover:bg-gray-700/50 dark:lg:hover:bg-gray-800 rounded-full transition-colors"
|
||||
x-data="{ isDark: document.documentElement.classList.contains('dark') }"
|
||||
@click="
|
||||
isDark = !isDark;
|
||||
document.documentElement.classList.toggle('dark', isDark);
|
||||
document.documentElement.style.colorScheme = isDark ? 'dark' : 'light';
|
||||
localStorage.setItem('dark-mode', isDark);
|
||||
localStorage.setItem('flux.appearance', isDark ? 'dark' : 'light');
|
||||
document.cookie = 'dark-mode=' + isDark + '; path=/; SameSite=Lax';
|
||||
"
|
||||
>
|
||||
<?php if (isset($component)) { $__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac = $component; } ?>
|
||||
<?php if (isset($attributes)) { $__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac = $attributes; } ?>
|
||||
<?php $component = Illuminate\View\AnonymousComponent::resolve(['view' => '8def1252668913628243c4d363bee1ef::icon','data' => ['name' => 'sun-bright','size' => 'fa-lg','class' => 'text-gray-500','xShow' => '!isDark']] + (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag ? $attributes->all() : [])); ?>
|
||||
<?php $component->withName('core::icon'); ?>
|
||||
<?php if ($component->shouldRender()): ?>
|
||||
<?php $__env->startComponent($component->resolveView(), $component->data()); ?>
|
||||
<?php if (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag): ?>
|
||||
<?php $attributes = $attributes->except(\Illuminate\View\AnonymousComponent::ignoredParameterNames()); ?>
|
||||
<?php endif; ?>
|
||||
<?php $component->withAttributes(['name' => 'sun-bright','size' => 'fa-lg','class' => 'text-gray-500','x-show' => '!isDark']); ?>
|
||||
<?php echo $__env->renderComponent(); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac)): ?>
|
||||
<?php $attributes = $__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac; ?>
|
||||
<?php unset($__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac)): ?>
|
||||
<?php $component = $__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac; ?>
|
||||
<?php unset($__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($component)) { $__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac = $component; } ?>
|
||||
<?php if (isset($attributes)) { $__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac = $attributes; } ?>
|
||||
<?php $component = Illuminate\View\AnonymousComponent::resolve(['view' => '8def1252668913628243c4d363bee1ef::icon','data' => ['name' => 'moon-stars','size' => 'fa-lg','class' => 'text-gray-400','xShow' => 'isDark','xCloak' => true]] + (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag ? $attributes->all() : [])); ?>
|
||||
<?php $component->withName('core::icon'); ?>
|
||||
<?php if ($component->shouldRender()): ?>
|
||||
<?php $__env->startComponent($component->resolveView(), $component->data()); ?>
|
||||
<?php if (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag): ?>
|
||||
<?php $attributes = $attributes->except(\Illuminate\View\AnonymousComponent::ignoredParameterNames()); ?>
|
||||
<?php endif; ?>
|
||||
<?php $component->withAttributes(['name' => 'moon-stars','size' => 'fa-lg','class' => 'text-gray-400','x-show' => 'isDark','x-cloak' => true]); ?>
|
||||
<?php echo $__env->renderComponent(); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac)): ?>
|
||||
<?php $attributes = $__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac; ?>
|
||||
<?php unset($__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac)): ?>
|
||||
<?php $component = $__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac; ?>
|
||||
<?php unset($__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac); ?>
|
||||
<?php endif; ?>
|
||||
<span class="sr-only">Toggle dark mode</span>
|
||||
</button>
|
||||
|
||||
<!-- Divider -->
|
||||
<hr class="w-px h-6 bg-gray-200 dark:bg-gray-700/60 border-none" />
|
||||
|
||||
<!-- User button -->
|
||||
<?php
|
||||
$user = auth()->user();
|
||||
$userName = $user?->name ?? 'Guest';
|
||||
$userEmail = $user?->email ?? '';
|
||||
$userTier = ($user && method_exists($user, 'getTier')) ? ($user->getTier()?->label() ?? 'Free') : 'Free';
|
||||
$userInitials = collect(explode(' ', $userName))->map(fn($n) => strtoupper(substr($n, 0, 1)))->take(2)->join('');
|
||||
?>
|
||||
<div class="relative inline-flex" x-data="{ open: false }">
|
||||
<button
|
||||
class="inline-flex justify-center items-center group"
|
||||
aria-haspopup="true"
|
||||
@click.prevent="open = !open"
|
||||
:aria-expanded="open"
|
||||
>
|
||||
<div class="w-8 h-8 rounded-full bg-violet-500 flex items-center justify-center text-white text-xs font-semibold">
|
||||
<?php echo e($userInitials); ?>
|
||||
|
||||
</div>
|
||||
<div class="flex items-center truncate">
|
||||
<span class="truncate ml-2 text-sm font-medium text-gray-600 dark:text-gray-100 group-hover:text-gray-800 dark:group-hover:text-white"><?php echo e($userName); ?></span>
|
||||
<svg class="w-3 h-3 shrink-0 ml-1 fill-current text-gray-400 dark:text-gray-500" viewBox="0 0 12 12">
|
||||
<path d="M5.9 11.4L.5 6l1.4-1.4 4 4 4-4L11.3 6z" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
class="origin-top-right z-10 absolute top-full min-w-44 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700/60 py-1.5 rounded-lg shadow-lg overflow-hidden mt-1 right-0"
|
||||
@click.outside="open = false"
|
||||
@keydown.escape.window="open = false"
|
||||
x-show="open"
|
||||
x-transition:enter="transition ease-out duration-200 transform"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 translate-y-0"
|
||||
x-transition:leave="transition ease-out duration-200"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
x-cloak
|
||||
>
|
||||
<div class="pt-0.5 pb-2 px-3 mb-1 border-b border-gray-200 dark:border-gray-700/60">
|
||||
<div class="font-medium text-gray-800 dark:text-gray-100"><?php echo e($userName); ?></div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400"><?php echo e($userEmail); ?></div>
|
||||
</div>
|
||||
<ul>
|
||||
<li>
|
||||
<a class="font-medium text-sm text-violet-500 hover:text-violet-600 dark:hover:text-violet-400 flex items-center py-1.5 px-3" href="<?php echo e(route('hub.account')); ?>" @click="open = false">
|
||||
<?php if (isset($component)) { $__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac = $component; } ?>
|
||||
<?php if (isset($attributes)) { $__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac = $attributes; } ?>
|
||||
<?php $component = Illuminate\View\AnonymousComponent::resolve(['view' => '8def1252668913628243c4d363bee1ef::icon','data' => ['name' => 'user','class' => 'w-5 mr-2']] + (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag ? $attributes->all() : [])); ?>
|
||||
<?php $component->withName('core::icon'); ?>
|
||||
<?php if ($component->shouldRender()): ?>
|
||||
<?php $__env->startComponent($component->resolveView(), $component->data()); ?>
|
||||
<?php if (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag): ?>
|
||||
<?php $attributes = $attributes->except(\Illuminate\View\AnonymousComponent::ignoredParameterNames()); ?>
|
||||
<?php endif; ?>
|
||||
<?php $component->withAttributes(['name' => 'user','class' => 'w-5 mr-2']); ?>
|
||||
<?php echo $__env->renderComponent(); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac)): ?>
|
||||
<?php $attributes = $__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac; ?>
|
||||
<?php unset($__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac)): ?>
|
||||
<?php $component = $__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac; ?>
|
||||
<?php unset($__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac); ?>
|
||||
<?php endif; ?> Profile
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="font-medium text-sm text-violet-500 hover:text-violet-600 dark:hover:text-violet-400 flex items-center py-1.5 px-3" href="<?php echo e(route('hub.account.settings')); ?>" @click="open = false">
|
||||
<?php if (isset($component)) { $__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac = $component; } ?>
|
||||
<?php if (isset($attributes)) { $__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac = $attributes; } ?>
|
||||
<?php $component = Illuminate\View\AnonymousComponent::resolve(['view' => '8def1252668913628243c4d363bee1ef::icon','data' => ['name' => 'gear','class' => 'w-5 mr-2']] + (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag ? $attributes->all() : [])); ?>
|
||||
<?php $component->withName('core::icon'); ?>
|
||||
<?php if ($component->shouldRender()): ?>
|
||||
<?php $__env->startComponent($component->resolveView(), $component->data()); ?>
|
||||
<?php if (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag): ?>
|
||||
<?php $attributes = $attributes->except(\Illuminate\View\AnonymousComponent::ignoredParameterNames()); ?>
|
||||
<?php endif; ?>
|
||||
<?php $component->withAttributes(['name' => 'gear','class' => 'w-5 mr-2']); ?>
|
||||
<?php echo $__env->renderComponent(); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac)): ?>
|
||||
<?php $attributes = $__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac; ?>
|
||||
<?php unset($__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac)): ?>
|
||||
<?php $component = $__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac; ?>
|
||||
<?php unset($__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac); ?>
|
||||
<?php endif; ?> Settings
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="font-medium text-sm text-violet-500 hover:text-violet-600 dark:hover:text-violet-400 flex items-center py-1.5 px-3" href="/" @click="open = false">
|
||||
<?php if (isset($component)) { $__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac = $component; } ?>
|
||||
<?php if (isset($attributes)) { $__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac = $attributes; } ?>
|
||||
<?php $component = Illuminate\View\AnonymousComponent::resolve(['view' => '8def1252668913628243c4d363bee1ef::icon','data' => ['name' => 'arrow-left','class' => 'w-5 mr-2']] + (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag ? $attributes->all() : [])); ?>
|
||||
<?php $component->withName('core::icon'); ?>
|
||||
<?php if ($component->shouldRender()): ?>
|
||||
<?php $__env->startComponent($component->resolveView(), $component->data()); ?>
|
||||
<?php if (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag): ?>
|
||||
<?php $attributes = $attributes->except(\Illuminate\View\AnonymousComponent::ignoredParameterNames()); ?>
|
||||
<?php endif; ?>
|
||||
<?php $component->withAttributes(['name' => 'arrow-left','class' => 'w-5 mr-2']); ?>
|
||||
<?php echo $__env->renderComponent(); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac)): ?>
|
||||
<?php $attributes = $__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac; ?>
|
||||
<?php unset($__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac)): ?>
|
||||
<?php $component = $__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac; ?>
|
||||
<?php unset($__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac); ?>
|
||||
<?php endif; ?> Back to Site
|
||||
</a>
|
||||
</li>
|
||||
<li class="border-t border-gray-200 dark:border-gray-700/60 mt-1 pt-1">
|
||||
<a class="font-medium text-sm text-violet-500 hover:text-violet-600 dark:hover:text-violet-400 flex items-center py-1.5 px-3" href="/logout">
|
||||
<?php if (isset($component)) { $__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac = $component; } ?>
|
||||
<?php if (isset($attributes)) { $__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac = $attributes; } ?>
|
||||
<?php $component = Illuminate\View\AnonymousComponent::resolve(['view' => '8def1252668913628243c4d363bee1ef::icon','data' => ['name' => 'right-from-bracket','class' => 'w-5 mr-2']] + (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag ? $attributes->all() : [])); ?>
|
||||
<?php $component->withName('core::icon'); ?>
|
||||
<?php if ($component->shouldRender()): ?>
|
||||
<?php $__env->startComponent($component->resolveView(), $component->data()); ?>
|
||||
<?php if (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag): ?>
|
||||
<?php $attributes = $attributes->except(\Illuminate\View\AnonymousComponent::ignoredParameterNames()); ?>
|
||||
<?php endif; ?>
|
||||
<?php $component->withAttributes(['name' => 'right-from-bracket','class' => 'w-5 mr-2']); ?>
|
||||
<?php echo $__env->renderComponent(); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac)): ?>
|
||||
<?php $attributes = $__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac; ?>
|
||||
<?php unset($__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac)): ?>
|
||||
<?php $component = $__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac; ?>
|
||||
<?php unset($__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac); ?>
|
||||
<?php endif; ?> Sign Out
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<?php /**PATH /Users/snider/Code/lab/core-php/packages/core-admin/src/Website/Hub/View/Blade/admin/components/header.blade.php ENDPATH**/ ?>
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
<?php
|
||||
$darkMode = request()->cookie('dark-mode') === 'true';
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="<?php echo e(str_replace('_', '-', app()->getLocale())); ?>" class="overscroll-none <?php echo e($darkMode ? 'dark' : ''); ?>"
|
||||
style="color-scheme: <?php echo e($darkMode ? 'dark' : 'light'); ?>">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="<?php echo e(csrf_token()); ?>">
|
||||
|
||||
<title><?php echo e($title ?? 'Admin'); ?> - <?php echo e(config('app.name', 'Host Hub')); ?></title>
|
||||
|
||||
|
||||
<style>
|
||||
html {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
background-color: #111827;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
|
||||
(function () {
|
||||
// Dark mode - sync our key with Flux's key
|
||||
var darkMode = localStorage.getItem('dark-mode');
|
||||
if (darkMode === 'true') {
|
||||
// Sync to Flux's appearance key so the Flux directive doesn't override
|
||||
localStorage.setItem('flux.appearance', 'dark');
|
||||
} else if (darkMode === 'false') {
|
||||
localStorage.setItem('flux.appearance', 'light');
|
||||
}
|
||||
// Set cookie for PHP
|
||||
document.cookie = 'dark-mode=' + (darkMode || 'false') + '; path=/; SameSite=Lax';
|
||||
|
||||
// Icon settings
|
||||
var iconStyle = localStorage.getItem('icon-style') || 'fa-notdog fa-solid';
|
||||
var iconSize = localStorage.getItem('icon-size') || 'fa-lg';
|
||||
document.cookie = 'icon-style=' + iconStyle + '; path=/; SameSite=Lax';
|
||||
document.cookie = 'icon-size=' + iconSize + '; path=/; SameSite=Lax';
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Fonts -->
|
||||
<?php echo $__env->make('layouts::partials.fonts', array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?>
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<?php if(\Livewire\Mechanisms\ExtendBlade\ExtendBlade::isRenderingLivewireComponent()): ?><!--[if BLOCK]><![endif]--><?php endif; ?><?php if(file_exists(public_path('vendor/fontawesome/css/all.min.css'))): ?>
|
||||
<link rel="stylesheet" href="/vendor/fontawesome/css/all.min.css?v=<?php echo e(filemtime(public_path('vendor/fontawesome/css/all.min.css'))); ?>">
|
||||
<?php else: ?>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
<?php endif; ?><?php if(\Livewire\Mechanisms\ExtendBlade\ExtendBlade::isRenderingLivewireComponent()): ?><!--[if ENDBLOCK]><![endif]--><?php endif; ?>
|
||||
|
||||
<!-- Scripts -->
|
||||
<?php echo app('Illuminate\Foundation\Vite')(['resources/css/admin.css', 'resources/js/app.js']); ?>
|
||||
|
||||
<!-- Flux -->
|
||||
<?php echo app('flux')->fluxAppearance(); ?>
|
||||
|
||||
</head>
|
||||
<body
|
||||
class="font-inter antialiased bg-gray-100 dark:bg-gray-900 text-gray-600 dark:text-gray-400 overscroll-none"
|
||||
x-data="{ sidebarOpen: false }"
|
||||
@open-sidebar.window="sidebarOpen = true"
|
||||
>
|
||||
|
||||
|
||||
<!-- Page wrapper -->
|
||||
<div class="flex h-[100dvh] overflow-hidden overscroll-none">
|
||||
|
||||
<?php echo $__env->make('hub::admin.components.sidebar', array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?>
|
||||
|
||||
<!-- Content area (offset for fixed sidebar) -->
|
||||
<div
|
||||
class="relative flex flex-col flex-1 overflow-y-auto overflow-x-hidden overscroll-none ml-0 sm:ml-20 lg:ml-64"
|
||||
x-ref="contentarea">
|
||||
|
||||
<?php echo $__env->make('hub::admin.components.header', array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?>
|
||||
|
||||
<main class="grow px-4 sm:px-6 lg:px-8 py-8 w-full max-w-9xl mx-auto">
|
||||
<?php echo e($slot); ?>
|
||||
|
||||
</main>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Toast Notifications -->
|
||||
<?php app("livewire")->forceAssetInjection(); ?><div x-persist="<?php echo e('toast'); ?>">
|
||||
<?php if (isset($component)) { $__componentOriginal6e0689304ed9fe6f1f826bea0820c41b = $component; } ?>
|
||||
<?php if (isset($attributes)) { $__attributesOriginal6e0689304ed9fe6f1f826bea0820c41b = $attributes; } ?>
|
||||
<?php $component = Illuminate\View\AnonymousComponent::resolve(['view' => 'e60dd9d2c3a62d619c9acb38f20d5aa5::toast.index','data' => ['position' => 'bottom end']] + (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag ? $attributes->all() : [])); ?>
|
||||
<?php $component->withName('flux::toast'); ?>
|
||||
<?php if ($component->shouldRender()): ?>
|
||||
<?php $__env->startComponent($component->resolveView(), $component->data()); ?>
|
||||
<?php if (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag): ?>
|
||||
<?php $attributes = $attributes->except(\Illuminate\View\AnonymousComponent::ignoredParameterNames()); ?>
|
||||
<?php endif; ?>
|
||||
<?php $component->withAttributes(['position' => 'bottom end']); ?>
|
||||
<?php echo $__env->renderComponent(); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__attributesOriginal6e0689304ed9fe6f1f826bea0820c41b)): ?>
|
||||
<?php $attributes = $__attributesOriginal6e0689304ed9fe6f1f826bea0820c41b; ?>
|
||||
<?php unset($__attributesOriginal6e0689304ed9fe6f1f826bea0820c41b); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__componentOriginal6e0689304ed9fe6f1f826bea0820c41b)): ?>
|
||||
<?php $component = $__componentOriginal6e0689304ed9fe6f1f826bea0820c41b; ?>
|
||||
<?php unset($__componentOriginal6e0689304ed9fe6f1f826bea0820c41b); ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Developer Bar (Hades accounts only) -->
|
||||
<?php echo $__env->make('hub::admin.components.developer-bar', array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?>
|
||||
|
||||
<!-- Flux Scripts -->
|
||||
<?php app('livewire')->forceAssetInjection(); ?>
|
||||
<?php echo app('flux')->scripts(); ?>
|
||||
|
||||
|
||||
<?php echo $__env->yieldPushContent('scripts'); ?>
|
||||
|
||||
<script>
|
||||
// Light/Dark mode toggle (guarded for Livewire navigation)
|
||||
(function() {
|
||||
if (window.__lightSwitchInitialized) return;
|
||||
window.__lightSwitchInitialized = true;
|
||||
|
||||
const lightSwitch = document.querySelector('.light-switch');
|
||||
if (lightSwitch) {
|
||||
lightSwitch.addEventListener('change', () => {
|
||||
const {checked} = lightSwitch;
|
||||
document.documentElement.classList.toggle('dark', checked);
|
||||
document.documentElement.style.colorScheme = checked ? 'dark' : 'light';
|
||||
localStorage.setItem('dark-mode', checked);
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
<?php /**PATH /Users/snider/Code/lab/core-php/packages/core-admin/src/Website/Hub/View/Blade/admin/layouts/app.blade.php ENDPATH**/ ?>
|
||||
|
|
@ -1,135 +0,0 @@
|
|||
<div class="relative" x-data="{ open: <?php if ((object) ('open') instanceof \Livewire\WireDirective) : ?>window.Livewire.find('<?php echo e($__livewire->getId()); ?>').entangle('<?php echo e('open'->value()); ?>')<?php echo e('open'->hasModifier('live') ? '.live' : ''); ?><?php else : ?>window.Livewire.find('<?php echo e($__livewire->getId()); ?>').entangle('<?php echo e('open'); ?>')<?php endif; ?> }">
|
||||
<!-- Trigger Button -->
|
||||
<button
|
||||
@click="open = !open"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-700/50 hover:bg-gray-200 dark:hover:bg-gray-700 transition"
|
||||
>
|
||||
<div class="w-6 h-6 rounded-md bg-<?php echo e($current['color']); ?>-500/20 flex items-center justify-center">
|
||||
<?php if (isset($component)) { $__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac = $component; } ?>
|
||||
<?php if (isset($attributes)) { $__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac = $attributes; } ?>
|
||||
<?php $component = Illuminate\View\AnonymousComponent::resolve(['view' => '8def1252668913628243c4d363bee1ef::icon','data' => ['name' => $current['icon'],'class' => 'text-'.e($current['color']).'-500 text-xs']] + (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag ? $attributes->all() : [])); ?>
|
||||
<?php $component->withName('core::icon'); ?>
|
||||
<?php if ($component->shouldRender()): ?>
|
||||
<?php $__env->startComponent($component->resolveView(), $component->data()); ?>
|
||||
<?php if (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag): ?>
|
||||
<?php $attributes = $attributes->except(\Illuminate\View\AnonymousComponent::ignoredParameterNames()); ?>
|
||||
<?php endif; ?>
|
||||
<?php $component->withAttributes(['name' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($current['icon']),'class' => 'text-'.e($current['color']).'-500 text-xs']); ?>
|
||||
<?php echo $__env->renderComponent(); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac)): ?>
|
||||
<?php $attributes = $__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac; ?>
|
||||
<?php unset($__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac)): ?>
|
||||
<?php $component = $__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac; ?>
|
||||
<?php unset($__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac); ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-200"><?php echo e($current['name']); ?></span>
|
||||
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="open ? 'rotate-180' : ''" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Dropdown -->
|
||||
<div
|
||||
x-show="open"
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
@click.outside="open = false"
|
||||
class="absolute left-0 mt-2 w-64 bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden z-50"
|
||||
x-cloak
|
||||
>
|
||||
<div class="px-4 py-3 border-b border-gray-100 dark:border-gray-700">
|
||||
<p class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider"><?php echo e(__('hub::hub.workspace_switcher.title')); ?></p>
|
||||
</div>
|
||||
<div class="py-2">
|
||||
<?php if(\Livewire\Mechanisms\ExtendBlade\ExtendBlade::isRenderingLivewireComponent()): ?><!--[if BLOCK]><![endif]--><?php endif; ?><?php $__currentLoopData = $workspaces; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $slug => $workspace): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>
|
||||
<button
|
||||
wire:click="switchWorkspace('<?php echo e($slug); ?>')"
|
||||
class="w-full flex items-center gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition <?php echo e($current['slug'] === $slug ? 'bg-gray-50 dark:bg-gray-700/30' : ''); ?>"
|
||||
>
|
||||
<div class="w-8 h-8 rounded-lg bg-<?php echo e($workspace['color']); ?>-500/20 flex items-center justify-center shrink-0">
|
||||
<?php if (isset($component)) { $__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac = $component; } ?>
|
||||
<?php if (isset($attributes)) { $__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac = $attributes; } ?>
|
||||
<?php $component = Illuminate\View\AnonymousComponent::resolve(['view' => '8def1252668913628243c4d363bee1ef::icon','data' => ['name' => $workspace['icon'],'class' => 'text-'.e($workspace['color']).'-500']] + (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag ? $attributes->all() : [])); ?>
|
||||
<?php $component->withName('core::icon'); ?>
|
||||
<?php if ($component->shouldRender()): ?>
|
||||
<?php $__env->startComponent($component->resolveView(), $component->data()); ?>
|
||||
<?php if (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag): ?>
|
||||
<?php $attributes = $attributes->except(\Illuminate\View\AnonymousComponent::ignoredParameterNames()); ?>
|
||||
<?php endif; ?>
|
||||
<?php $component->withAttributes(['name' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($workspace['icon']),'class' => 'text-'.e($workspace['color']).'-500']); ?>
|
||||
<?php echo $__env->renderComponent(); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac)): ?>
|
||||
<?php $attributes = $__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac; ?>
|
||||
<?php unset($__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac)): ?>
|
||||
<?php $component = $__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac; ?>
|
||||
<?php unset($__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac); ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="flex-1 text-left">
|
||||
<div class="text-sm font-medium text-gray-800 dark:text-gray-100"><?php echo e($workspace['name']); ?></div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400"><?php echo e($workspace['description']); ?></div>
|
||||
</div>
|
||||
<?php if(\Livewire\Mechanisms\ExtendBlade\ExtendBlade::isRenderingLivewireComponent()): ?><!--[if BLOCK]><![endif]--><?php endif; ?><?php if($current['slug'] === $slug): ?>
|
||||
<?php if (isset($component)) { $__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac = $component; } ?>
|
||||
<?php if (isset($attributes)) { $__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac = $attributes; } ?>
|
||||
<?php $component = Illuminate\View\AnonymousComponent::resolve(['view' => '8def1252668913628243c4d363bee1ef::icon','data' => ['name' => 'check','class' => 'text-'.e($workspace['color']).'-500']] + (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag ? $attributes->all() : [])); ?>
|
||||
<?php $component->withName('core::icon'); ?>
|
||||
<?php if ($component->shouldRender()): ?>
|
||||
<?php $__env->startComponent($component->resolveView(), $component->data()); ?>
|
||||
<?php if (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag): ?>
|
||||
<?php $attributes = $attributes->except(\Illuminate\View\AnonymousComponent::ignoredParameterNames()); ?>
|
||||
<?php endif; ?>
|
||||
<?php $component->withAttributes(['name' => 'check','class' => 'text-'.e($workspace['color']).'-500']); ?>
|
||||
<?php echo $__env->renderComponent(); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac)): ?>
|
||||
<?php $attributes = $__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac; ?>
|
||||
<?php unset($__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac)): ?>
|
||||
<?php $component = $__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac; ?>
|
||||
<?php unset($__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac); ?>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?><?php if(\Livewire\Mechanisms\ExtendBlade\ExtendBlade::isRenderingLivewireComponent()): ?><!--[if ENDBLOCK]><![endif]--><?php endif; ?>
|
||||
</button>
|
||||
<?php endforeach; $__env->popLoop(); $loop = $__env->getLastLoop(); ?><?php if(\Livewire\Mechanisms\ExtendBlade\ExtendBlade::isRenderingLivewireComponent()): ?><!--[if ENDBLOCK]><![endif]--><?php endif; ?>
|
||||
</div>
|
||||
<div class="px-4 py-3 border-t border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/20">
|
||||
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<?php if (isset($component)) { $__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac = $component; } ?>
|
||||
<?php if (isset($attributes)) { $__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac = $attributes; } ?>
|
||||
<?php $component = Illuminate\View\AnonymousComponent::resolve(['view' => '8def1252668913628243c4d363bee1ef::icon','data' => ['name' => 'globe']] + (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag ? $attributes->all() : [])); ?>
|
||||
<?php $component->withName('core::icon'); ?>
|
||||
<?php if ($component->shouldRender()): ?>
|
||||
<?php $__env->startComponent($component->resolveView(), $component->data()); ?>
|
||||
<?php if (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag): ?>
|
||||
<?php $attributes = $attributes->except(\Illuminate\View\AnonymousComponent::ignoredParameterNames()); ?>
|
||||
<?php endif; ?>
|
||||
<?php $component->withAttributes(['name' => 'globe']); ?>
|
||||
<?php echo $__env->renderComponent(); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac)): ?>
|
||||
<?php $attributes = $__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac; ?>
|
||||
<?php unset($__attributesOriginalddaaa69e63e341eb9a1697dbf04d7aac); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac)): ?>
|
||||
<?php $component = $__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac; ?>
|
||||
<?php unset($__componentOriginalddaaa69e63e341eb9a1697dbf04d7aac); ?>
|
||||
<?php endif; ?>
|
||||
<span class="truncate"><?php echo e($current['domain']); ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php /**PATH /Users/snider/Code/lab/core-php/packages/core-admin/src/Website/Hub/View/Blade/admin/workspace-switcher.blade.php ENDPATH**/ ?>
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
<div class="px-4 sm:px-6 lg:px-8 py-8 w-full max-w-5xl mx-auto">
|
||||
|
||||
<div class="mb-8">
|
||||
<h1 class="text-2xl font-bold text-zinc-100">Welcome to <?php echo e(config('app.name', 'Core PHP')); ?></h1>
|
||||
<p class="text-zinc-400 mt-1">Your application is ready to use.</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<div class="bg-zinc-800/50 rounded-xl p-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 bg-violet-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-violet-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-zinc-400">Users</p>
|
||||
<p class="text-2xl font-semibold text-zinc-100"><?php echo e(\Core\Mod\Tenant\Models\User::count()); ?></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-zinc-800/50 rounded-xl p-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 bg-green-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-zinc-400">Status</p>
|
||||
<p class="text-2xl font-semibold text-green-400">Active</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-zinc-800/50 rounded-xl p-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 bg-blue-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-zinc-400">Laravel</p>
|
||||
<p class="text-2xl font-semibold text-zinc-100"><?php echo e(app()->version()); ?></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="bg-zinc-800/50 rounded-xl p-6 mb-8">
|
||||
<h2 class="text-lg font-semibold text-zinc-100 mb-4">Quick Actions</h2>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<a href="<?php echo e(route('hub.account')); ?>" class="flex items-center gap-4 p-4 bg-zinc-900/50 rounded-lg hover:bg-zinc-700/50 transition">
|
||||
<div class="w-10 h-10 bg-violet-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-violet-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-zinc-100">Your Profile</p>
|
||||
<p class="text-sm text-zinc-400">Manage your account</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="/" class="flex items-center gap-4 p-4 bg-zinc-900/50 rounded-lg hover:bg-zinc-700/50 transition">
|
||||
<div class="w-10 h-10 bg-green-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-zinc-100">View Site</p>
|
||||
<p class="text-sm text-zinc-400">Go to homepage</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="bg-zinc-800/50 rounded-xl p-6">
|
||||
<h2 class="text-lg font-semibold text-zinc-100 mb-4">Logged in as</h2>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 bg-violet-600 rounded-full flex items-center justify-center text-white font-semibold">
|
||||
<?php echo e(substr(auth()->user()->name ?? 'U', 0, 1)); ?>
|
||||
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-zinc-100"><?php echo e(auth()->user()->name ?? 'User'); ?></p>
|
||||
<p class="text-sm text-zinc-400"><?php echo e(auth()->user()->email ?? ''); ?></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php /**PATH /Users/snider/Code/lab/core-php/packages/core-admin/src/Website/Hub/View/Blade/admin/dashboard.blade.php ENDPATH**/ ?>
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
src: url('/fonts/InterVariable.woff2') format('woff2-variations');
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
</style>
|
||||
<?php /**PATH /Users/snider/Code/lab/core-php/packages/core-php/src/Core/Front/Components/View/Blade/layouts/partials/fonts.blade.php ENDPATH**/ ?>
|
||||
|
|
@ -1,194 +0,0 @@
|
|||
|
||||
|
||||
<?php $iconTrailing ??= $attributes->pluck('icon:trailing'); ?>
|
||||
<?php $iconVariant ??= $attributes->pluck('icon:variant'); ?>
|
||||
|
||||
<?php $attributes ??= new \Illuminate\View\ComponentAttributeBag;
|
||||
|
||||
$__newAttributes = [];
|
||||
$__propNames = \Illuminate\View\ComponentAttributeBag::extractPropNames(([
|
||||
'iconVariant' => 'micro',
|
||||
'iconTrailing' => null,
|
||||
'variant' => null,
|
||||
'color' => null,
|
||||
'inset' => null,
|
||||
'size' => null,
|
||||
'icon' => null,
|
||||
]));
|
||||
|
||||
foreach ($attributes->all() as $__key => $__value) {
|
||||
if (in_array($__key, $__propNames)) {
|
||||
$$__key = $$__key ?? $__value;
|
||||
} else {
|
||||
$__newAttributes[$__key] = $__value;
|
||||
}
|
||||
}
|
||||
|
||||
$attributes = new \Illuminate\View\ComponentAttributeBag($__newAttributes);
|
||||
|
||||
unset($__propNames);
|
||||
unset($__newAttributes);
|
||||
|
||||
foreach (array_filter(([
|
||||
'iconVariant' => 'micro',
|
||||
'iconTrailing' => null,
|
||||
'variant' => null,
|
||||
'color' => null,
|
||||
'inset' => null,
|
||||
'size' => null,
|
||||
'icon' => null,
|
||||
]), 'is_string', ARRAY_FILTER_USE_KEY) as $__key => $__value) {
|
||||
$$__key = $$__key ?? $__value;
|
||||
}
|
||||
|
||||
$__defined_vars = get_defined_vars();
|
||||
|
||||
foreach ($attributes->all() as $__key => $__value) {
|
||||
if (array_key_exists($__key, $__defined_vars)) unset($$__key);
|
||||
}
|
||||
|
||||
unset($__defined_vars, $__key, $__value); ?>
|
||||
|
||||
<?php
|
||||
$insetClasses = Flux::applyInset($inset, top: '-mt-1', right: '-me-2', bottom: '-mb-1', left: '-ms-2');
|
||||
|
||||
// When using the outline icon variant, we need to size it down to match the default icon sizes...
|
||||
$iconClasses = Flux::classes()->add($iconVariant === 'outline' ? 'size-4' : '');
|
||||
|
||||
$classes = Flux::classes()
|
||||
->add('inline-flex items-center font-medium whitespace-nowrap')
|
||||
->add($insetClasses)
|
||||
->add('[print-color-adjust:exact]')
|
||||
->add(match ($size) {
|
||||
'lg' => 'text-sm py-1.5 **:data-flux-badge-icon:me-2',
|
||||
default => 'text-sm py-1 **:data-flux-badge-icon:me-1.5',
|
||||
'sm' => 'text-xs py-1 **:data-flux-badge-icon:size-3 **:data-flux-badge-icon:me-1',
|
||||
})
|
||||
->add(match ($variant) {
|
||||
'pill' => 'rounded-full px-3',
|
||||
default => 'rounded-md px-2',
|
||||
})
|
||||
/**
|
||||
* We can't compile classes for each color because of variants color to color and Tailwind's JIT compiler.
|
||||
* We instead need to write out each one by hand. Sorry...
|
||||
*/
|
||||
->add($variant === 'solid' ? match ($color) {
|
||||
default => 'text-white dark:text-white bg-zinc-600 dark:bg-zinc-600 [&:is(button)]:hover:bg-zinc-700 dark:[button]:hover:bg-zinc-500',
|
||||
'red' => 'text-white dark:text-white bg-red-500 dark:bg-red-600 [&:is(button)]:hover:bg-red-600 dark:[button]:hover:bg-red-500',
|
||||
'orange' => 'text-white dark:text-white bg-orange-500 dark:bg-orange-600 [&:is(button)]:hover:bg-orange-600 dark:[button]:hover:bg-orange-500',
|
||||
'amber' => 'text-white dark:text-zinc-950 bg-amber-500 dark:bg-amber-500 [&:is(button)]:hover:bg-amber-600 dark:[button]:hover:bg-amber-400',
|
||||
'yellow' => 'text-white dark:text-zinc-950 bg-yellow-500 dark:bg-yellow-400 [&:is(button)]:hover:bg-yellow-600 dark:[button]:hover:bg-yellow-300',
|
||||
'lime' => 'text-white dark:text-white bg-lime-500 dark:bg-lime-600 [&:is(button)]:hover:bg-lime-600 dark:[button]:hover:bg-lime-500',
|
||||
'green' => 'text-white dark:text-white bg-green-500 dark:bg-green-600 [&:is(button)]:hover:bg-green-600 dark:[button]:hover:bg-green-500',
|
||||
'emerald' => 'text-white dark:text-white bg-emerald-500 dark:bg-emerald-600 [&:is(button)]:hover:bg-emerald-600 dark:[button]:hover:bg-emerald-500',
|
||||
'teal' => 'text-white dark:text-white bg-teal-500 dark:bg-teal-600 [&:is(button)]:hover:bg-teal-600 dark:[button]:hover:bg-teal-500',
|
||||
'cyan' => 'text-white dark:text-white bg-cyan-500 dark:bg-cyan-600 [&:is(button)]:hover:bg-cyan-600 dark:[button]:hover:bg-cyan-500',
|
||||
'sky' => 'text-white dark:text-white bg-sky-500 dark:bg-sky-600 [&:is(button)]:hover:bg-sky-600 dark:[button]:hover:bg-sky-500',
|
||||
'blue' => 'text-white dark:text-white bg-blue-500 dark:bg-blue-600 [&:is(button)]:hover:bg-blue-600 dark:[button]:hover:bg-blue-500',
|
||||
'indigo' => 'text-white dark:text-white bg-indigo-500 dark:bg-indigo-600 [&:is(button)]:hover:bg-indigo-600 dark:[button]:hover:bg-indigo-500',
|
||||
'violet' => 'text-white dark:text-white bg-violet-500 dark:bg-violet-600 [&:is(button)]:hover:bg-violet-600 dark:[button]:hover:bg-violet-500',
|
||||
'purple' => 'text-white dark:text-white bg-purple-500 dark:bg-purple-600 [&:is(button)]:hover:bg-purple-600 dark:[button]:hover:bg-purple-500',
|
||||
'fuchsia' => 'text-white dark:text-white bg-fuchsia-500 dark:bg-fuchsia-600 [&:is(button)]:hover:bg-fuchsia-600 dark:[button]:hover:bg-fuchsia-500',
|
||||
'pink' => 'text-white dark:text-white bg-pink-500 dark:bg-pink-600 [&:is(button)]:hover:bg-pink-600 dark:[button]:hover:bg-pink-500',
|
||||
'rose' => 'text-white dark:text-white bg-rose-500 dark:bg-rose-600 [&:is(button)]:hover:bg-rose-600 dark:[button]:hover:bg-rose-500',
|
||||
} : match ($color) {
|
||||
default => 'text-zinc-700 [&_button]:text-zinc-700! dark:text-zinc-200 dark:[&_button]:text-zinc-200! bg-zinc-400/15 dark:bg-zinc-400/40 [&:is(button)]:hover:bg-zinc-400/25 dark:[button]:hover:bg-zinc-400/50',
|
||||
'red' => 'text-red-700 [&_button]:text-red-700! dark:text-red-200 dark:[&_button]:text-red-200! bg-red-400/20 dark:bg-red-400/40 [&:is(button)]:hover:bg-red-400/30 dark:[button]:hover:bg-red-400/50',
|
||||
'orange' => 'text-orange-700 [&_button]:text-orange-700! dark:text-orange-200 dark:[&_button]:text-orange-200! bg-orange-400/20 dark:bg-orange-400/40 [&:is(button)]:hover:bg-orange-400/30 dark:[button]:hover:bg-orange-400/50',
|
||||
'amber' => 'text-amber-700 [&_button]:text-amber-700! dark:text-amber-200 dark:[&_button]:text-amber-200! bg-amber-400/25 dark:bg-amber-400/40 [&:is(button)]:hover:bg-amber-400/40 dark:[button]:hover:bg-amber-400/50',
|
||||
'yellow' => 'text-yellow-800 [&_button]:text-yellow-800! dark:text-yellow-200 dark:[&_button]:text-yellow-200! bg-yellow-400/25 dark:bg-yellow-400/40 [&:is(button)]:hover:bg-yellow-400/40 dark:[button]:hover:bg-yellow-400/50',
|
||||
'lime' => 'text-lime-800 [&_button]:text-lime-800! dark:text-lime-200 dark:[&_button]:text-lime-200! bg-lime-400/25 dark:bg-lime-400/40 [&:is(button)]:hover:bg-lime-400/35 dark:[button]:hover:bg-lime-400/50',
|
||||
'green' => 'text-green-800 [&_button]:text-green-800! dark:text-green-200 dark:[&_button]:text-green-200! bg-green-400/20 dark:bg-green-400/40 [&:is(button)]:hover:bg-green-400/30 dark:[button]:hover:bg-green-400/50',
|
||||
'emerald' => 'text-emerald-800 [&_button]:text-emerald-800! dark:text-emerald-200 dark:[&_button]:text-emerald-200! bg-emerald-400/20 dark:bg-emerald-400/40 [&:is(button)]:hover:bg-emerald-400/30 dark:[button]:hover:bg-emerald-400/50',
|
||||
'teal' => 'text-teal-800 [&_button]:text-teal-800! dark:text-teal-200 dark:[&_button]:text-teal-200! bg-teal-400/20 dark:bg-teal-400/40 [&:is(button)]:hover:bg-teal-400/30 dark:[button]:hover:bg-teal-400/50',
|
||||
'cyan' => 'text-cyan-800 [&_button]:text-cyan-800! dark:text-cyan-200 dark:[&_button]:text-cyan-200! bg-cyan-400/20 dark:bg-cyan-400/40 [&:is(button)]:hover:bg-cyan-400/30 dark:[button]:hover:bg-cyan-400/50',
|
||||
'sky' => 'text-sky-800 [&_button]:text-sky-800! dark:text-sky-200 dark:[&_button]:text-sky-200! bg-sky-400/20 dark:bg-sky-400/40 [&:is(button)]:hover:bg-sky-400/30 dark:[button]:hover:bg-sky-400/50',
|
||||
'blue' => 'text-blue-800 [&_button]:text-blue-800! dark:text-blue-200 dark:[&_button]:text-blue-200! bg-blue-400/20 dark:bg-blue-400/40 [&:is(button)]:hover:bg-blue-400/30 dark:[button]:hover:bg-blue-400/50',
|
||||
'indigo' => 'text-indigo-700 [&_button]:text-indigo-700! dark:text-indigo-200 dark:[&_button]:text-indigo-200! bg-indigo-400/20 dark:bg-indigo-400/40 [&:is(button)]:hover:bg-indigo-400/30 dark:[button]:hover:bg-indigo-400/50',
|
||||
'violet' => 'text-violet-700 [&_button]:text-violet-700! dark:text-violet-200 dark:[&_button]:text-violet-200! bg-violet-400/20 dark:bg-violet-400/40 [&:is(button)]:hover:bg-violet-400/30 dark:[button]:hover:bg-violet-400/50',
|
||||
'purple' => 'text-purple-700 [&_button]:text-purple-700! dark:text-purple-200 dark:[&_button]:text-purple-200! bg-purple-400/20 dark:bg-purple-400/40 [&:is(button)]:hover:bg-purple-400/30 dark:[button]:hover:bg-purple-400/50',
|
||||
'fuchsia' => 'text-fuchsia-700 [&_button]:text-fuchsia-700! dark:text-fuchsia-200 dark:[&_button]:text-fuchsia-200! bg-fuchsia-400/20 dark:bg-fuchsia-400/40 [&:is(button)]:hover:bg-fuchsia-400/30 dark:[button]:hover:bg-fuchsia-400/50',
|
||||
'pink' => 'text-pink-700 [&_button]:text-pink-700! dark:text-pink-200 dark:[&_button]:text-pink-200! bg-pink-400/20 dark:bg-pink-400/40 [&:is(button)]:hover:bg-pink-400/30 dark:[button]:hover:bg-pink-400/50',
|
||||
'rose' => 'text-rose-700 [&_button]:text-rose-700! dark:text-rose-200 dark:[&_button]:text-rose-200! bg-rose-400/20 dark:bg-rose-400/40 [&:is(button)]:hover:bg-rose-400/30 dark:[button]:hover:bg-rose-400/50',
|
||||
});
|
||||
?>
|
||||
|
||||
<?php if (isset($component)) { $__componentOriginala5f31b21bdf3246ae55ae993fe8931c7 = $component; } ?>
|
||||
<?php if (isset($attributes)) { $__attributesOriginala5f31b21bdf3246ae55ae993fe8931c7 = $attributes; } ?>
|
||||
<?php $component = Illuminate\View\AnonymousComponent::resolve(['view' => 'e60dd9d2c3a62d619c9acb38f20d5aa5::button-or-div','data' => ['attributes' => $attributes->class($classes),'dataFluxBadge' => true]] + (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag ? $attributes->all() : [])); ?>
|
||||
<?php $component->withName('flux::button-or-div'); ?>
|
||||
<?php if ($component->shouldRender()): ?>
|
||||
<?php $__env->startComponent($component->resolveView(), $component->data()); ?>
|
||||
<?php if (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag): ?>
|
||||
<?php $attributes = $attributes->except(\Illuminate\View\AnonymousComponent::ignoredParameterNames()); ?>
|
||||
<?php endif; ?>
|
||||
<?php $component->withAttributes(['attributes' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($attributes->class($classes)),'data-flux-badge' => true]); ?>
|
||||
<?php if (is_string($icon) && $icon !== ''): ?>
|
||||
<?php if (isset($component)) { $__componentOriginalc7d5f44bf2a2d803ed0b55f72f1f82e2 = $component; } ?>
|
||||
<?php if (isset($attributes)) { $__attributesOriginalc7d5f44bf2a2d803ed0b55f72f1f82e2 = $attributes; } ?>
|
||||
<?php $component = Illuminate\View\AnonymousComponent::resolve(['view' => 'e60dd9d2c3a62d619c9acb38f20d5aa5::icon.index','data' => ['icon' => $icon,'variant' => $iconVariant,'class' => $iconClasses,'dataFluxBadgeIcon' => true]] + (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag ? $attributes->all() : [])); ?>
|
||||
<?php $component->withName('flux::icon'); ?>
|
||||
<?php if ($component->shouldRender()): ?>
|
||||
<?php $__env->startComponent($component->resolveView(), $component->data()); ?>
|
||||
<?php if (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag): ?>
|
||||
<?php $attributes = $attributes->except(\Illuminate\View\AnonymousComponent::ignoredParameterNames()); ?>
|
||||
<?php endif; ?>
|
||||
<?php $component->withAttributes(['icon' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($icon),'variant' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($iconVariant),'class' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($iconClasses),'data-flux-badge-icon' => true]); ?>
|
||||
<?php echo $__env->renderComponent(); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__attributesOriginalc7d5f44bf2a2d803ed0b55f72f1f82e2)): ?>
|
||||
<?php $attributes = $__attributesOriginalc7d5f44bf2a2d803ed0b55f72f1f82e2; ?>
|
||||
<?php unset($__attributesOriginalc7d5f44bf2a2d803ed0b55f72f1f82e2); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__componentOriginalc7d5f44bf2a2d803ed0b55f72f1f82e2)): ?>
|
||||
<?php $component = $__componentOriginalc7d5f44bf2a2d803ed0b55f72f1f82e2; ?>
|
||||
<?php unset($__componentOriginalc7d5f44bf2a2d803ed0b55f72f1f82e2); ?>
|
||||
<?php endif; ?>
|
||||
<?php else: ?>
|
||||
<?php echo e($icon); ?>
|
||||
|
||||
<?php endif; ?>
|
||||
|
||||
<?php echo e($slot); ?>
|
||||
|
||||
|
||||
<?php if ($iconTrailing): ?>
|
||||
<div class="ps-1 flex items-center" data-flux-badge-icon:trailing>
|
||||
<?php if (is_string($iconTrailing)): ?>
|
||||
<?php if (isset($component)) { $__componentOriginalc7d5f44bf2a2d803ed0b55f72f1f82e2 = $component; } ?>
|
||||
<?php if (isset($attributes)) { $__attributesOriginalc7d5f44bf2a2d803ed0b55f72f1f82e2 = $attributes; } ?>
|
||||
<?php $component = Illuminate\View\AnonymousComponent::resolve(['view' => 'e60dd9d2c3a62d619c9acb38f20d5aa5::icon.index','data' => ['icon' => $iconTrailing,'variant' => $iconVariant,'class' => $iconClasses]] + (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag ? $attributes->all() : [])); ?>
|
||||
<?php $component->withName('flux::icon'); ?>
|
||||
<?php if ($component->shouldRender()): ?>
|
||||
<?php $__env->startComponent($component->resolveView(), $component->data()); ?>
|
||||
<?php if (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag): ?>
|
||||
<?php $attributes = $attributes->except(\Illuminate\View\AnonymousComponent::ignoredParameterNames()); ?>
|
||||
<?php endif; ?>
|
||||
<?php $component->withAttributes(['icon' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($iconTrailing),'variant' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($iconVariant),'class' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($iconClasses)]); ?>
|
||||
<?php echo $__env->renderComponent(); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__attributesOriginalc7d5f44bf2a2d803ed0b55f72f1f82e2)): ?>
|
||||
<?php $attributes = $__attributesOriginalc7d5f44bf2a2d803ed0b55f72f1f82e2; ?>
|
||||
<?php unset($__attributesOriginalc7d5f44bf2a2d803ed0b55f72f1f82e2); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__componentOriginalc7d5f44bf2a2d803ed0b55f72f1f82e2)): ?>
|
||||
<?php $component = $__componentOriginalc7d5f44bf2a2d803ed0b55f72f1f82e2; ?>
|
||||
<?php unset($__componentOriginalc7d5f44bf2a2d803ed0b55f72f1f82e2); ?>
|
||||
<?php endif; ?>
|
||||
<?php else: ?>
|
||||
<?php echo e($iconTrailing); ?>
|
||||
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php echo $__env->renderComponent(); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__attributesOriginala5f31b21bdf3246ae55ae993fe8931c7)): ?>
|
||||
<?php $attributes = $__attributesOriginala5f31b21bdf3246ae55ae993fe8931c7; ?>
|
||||
<?php unset($__attributesOriginala5f31b21bdf3246ae55ae993fe8931c7); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($__componentOriginala5f31b21bdf3246ae55ae993fe8931c7)): ?>
|
||||
<?php $component = $__componentOriginala5f31b21bdf3246ae55ae993fe8931c7; ?>
|
||||
<?php unset($__componentOriginala5f31b21bdf3246ae55ae993fe8931c7); ?>
|
||||
<?php endif; ?>
|
||||
<?php /**PATH /Users/snider/Code/lab/core-php/vendor/livewire/flux/src/../stubs/resources/views/flux/badge/index.blade.php ENDPATH**/ ?>
|
||||
Loading…
Add table
Reference in a new issue