diff --git a/CHANGELOG.md b/CHANGELOG.md index dc2dcc2..bf2e90e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,49 @@ Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [0.10.0] — 2026-03-16 +### Added + +**Space Management & Configuration** ([Issue #45](https://github.com/byte5digital/numen/issues/45)) + +Multi-tenant space management enabling isolated configurations, settings, and API credentials per space. Each space is a completely isolated instance with separate content, personas, pipelines, users, and API configurations. + +**Features:** +- **Multi-tenancy:** Each space is a standalone instance with complete data isolation +- **Encrypted API config:** Store provider credentials (API keys, tokens) per-space with transparent encryption at rest using Laravel's `Crypt` facade +- **Space switching:** Admins can switch between spaces from the admin navigation bar; current space is tracked via `X-Space-Id` header in admin requests +- **Request-scoped space:** `ResolveCurrentSpace` middleware automatically determines active space from header (admins) or defaults to first/primary space (normal users) +- **Data isolation:** All content, briefs, pipelines, personas, webhooks, and settings queries automatically scope to the current space +- **Robust deletion:** Prevents deletion of the last space (blocking at DB level); uses database transactions + `lockForUpdate()` to prevent TOCTOU race conditions +- **Admin UI:** Full CRUD interface at `/admin/spaces` with Create, Read, Update, Delete operations +- **Security hardening:** `EnsureUserIsAdmin` middleware guards all `/admin/*` routes unconditionally; API config values are masked in edit forms (decrypted only on write) + +**Migration:** +- New `spaces` table with ULID primary key, name, slug, description, default_locale, settings (JSON), api_config (encrypted), created_at, updated_at +- All tenant-scoped tables (content, personas, pipelines, etc.) have `space_id` foreign key + +**Admin Routes:** +- `GET /admin/spaces` — List all spaces +- `GET /admin/spaces/create` — Show create form +- `POST /admin/spaces` — Store new space +- `GET /admin/spaces/{space}/edit` — Show edit form +- `PATCH /admin/spaces/{space}` — Update space +- `DELETE /admin/spaces/{space}` — Delete space (blocked if only one exists) +- `POST /admin/spaces/switch` — Switch current space (header-based) + +**Environment variables:** +- `SPACE_DEFAULT_LOCALE` — Default locale for new spaces (default: `en`) + +**Security notes:** +- API config encrypted at rest; decryption happens only in accessor, never exposed in API responses +- `X-Space-Id` header is admin-only; normal users cannot override their assigned space +- Null space detection in middleware aborts with HTTP 503 (Service Unavailable) +- Delete-last-space prevention uses database-level locking to prevent race conditions + +**Breaking changes:** None. + +--- ## [0.9.0] — 2026-03-15 ### Added diff --git a/README.md b/README.md index da1ced5..60b004c 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,16 @@ Manage webhook endpoints and event subscriptions directly from the admin panel ( ### Plugin & Extension System First-class plugin architecture. Extend pipelines, register custom LLM providers, add admin UI, and react to content events — all from a self-contained plugin package. +### Space Management & Configuration +**New in v0.10.0.** Multi-tenant space management with isolated configurations, settings, and API credentials per space. +- **Multi-tenancy:** Each space is a separate instance with its own content, personas, pipelines, and users +- **Encrypted API config:** Store provider credentials (API keys) per-space with transparent encryption at rest +- **Space switching:** Admins can switch between spaces from the admin nav; sessions are scoped to the current space +- **Isolated data:** Content, briefs, pipelines, and users belong to a single space; queries automatically filter by the active space +- **Robust deletion:** Cannot delete the last space; uses database transactions + row-level locks to prevent TOCTOU race conditions +- **Admin UI:** Full CRUD interface at `/admin/spaces` for managing spaces, their settings, and configurations +- **Middleware hardening:** `EnsureUserIsAdmin` guards all `/admin/*` routes; `ResolveCurrentSpace` enforces space isolation in requests + ### Plugin & Extension System First-class plugin architecture. Extend pipelines, register custom LLM providers, add admin UI, and react to content events — all from a self-contained plugin package. diff --git a/app/Http/Controllers/Admin/BriefAdminController.php b/app/Http/Controllers/Admin/BriefAdminController.php index 6152417..52acdaa 100644 --- a/app/Http/Controllers/Admin/BriefAdminController.php +++ b/app/Http/Controllers/Admin/BriefAdminController.php @@ -7,7 +7,6 @@ use App\Models\ContentPipeline; use App\Models\ContentType; use App\Models\Persona; -use App\Models\Space; use App\Pipelines\PipelineExecutor; use Illuminate\Http\Request; use Inertia\Inertia; @@ -25,9 +24,9 @@ public function index() ]); } - public function create() + public function create(\Illuminate\Http\Request $request) { - $space = Space::first(); + $space = $request->space(); return Inertia::render('Briefs/Create', [ 'contentTypes' => ContentType::where('space_id', $space?->id)->get(['name', 'slug']), diff --git a/app/Http/Controllers/Admin/DashboardController.php b/app/Http/Controllers/Admin/DashboardController.php index 03f470a..af10ee3 100644 --- a/app/Http/Controllers/Admin/DashboardController.php +++ b/app/Http/Controllers/Admin/DashboardController.php @@ -5,7 +5,6 @@ use App\Http\Controllers\Controller; use App\Models\Content; use App\Models\PipelineRun; -use App\Models\Space; use App\Services\AI\Providers\AnthropicProvider; use App\Services\AI\Providers\AzureOpenAIProvider; use App\Services\AI\Providers\OpenAIProvider; @@ -19,6 +18,7 @@ public function index( AnthropicProvider $anthropic, OpenAIProvider $openai, AzureOpenAIProvider $azure, + \Illuminate\Http\Request $request, ) { $defaultProvider = config('numen.default_provider', 'anthropic'); $fallbackChain = config('numen.fallback_chain', ['anthropic', 'openai', 'azure']); @@ -90,7 +90,7 @@ public function index( 'costToday' => $costTracker->getDailySpend(), 'providers' => $providers, 'fallbackChain' => $fallbackChain, - 'defaultSpaceId' => (Space::first() !== null ? Space::first()->id : ''), + 'defaultSpaceId' => ($request->space() !== null ? $request->space()->id : ''), ]); } } diff --git a/app/Http/Controllers/Admin/GraphController.php b/app/Http/Controllers/Admin/GraphController.php index 9d59bb3..1560fd7 100644 --- a/app/Http/Controllers/Admin/GraphController.php +++ b/app/Http/Controllers/Admin/GraphController.php @@ -4,14 +4,15 @@ use App\Http\Controllers\Controller; use App\Models\Space; +use Illuminate\Http\Request; use Inertia\Inertia; class GraphController extends Controller { - public function index(): \Inertia\Response + public function index(Request $request): \Inertia\Response { /** @var Space|null $firstSpace */ - $firstSpace = Space::first(); + $firstSpace = $request->space(); $spaceId = $firstSpace !== null ? $firstSpace->id : ''; return Inertia::render('Graph/Index', [ diff --git a/app/Http/Controllers/Admin/LocaleAdminController.php b/app/Http/Controllers/Admin/LocaleAdminController.php index 7deb66c..e8ff56e 100644 --- a/app/Http/Controllers/Admin/LocaleAdminController.php +++ b/app/Http/Controllers/Admin/LocaleAdminController.php @@ -3,8 +3,8 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; -use App\Models\Space; use App\Services\LocaleService; +use Illuminate\Http\Request; use Inertia\Inertia; use Inertia\Response; @@ -12,9 +12,9 @@ class LocaleAdminController extends Controller { public function __construct(private readonly LocaleService $localeService) {} - public function index(): Response + public function index(Request $request): Response { - $space = Space::first(); + $space = $request->space(); $locales = $space ? $this->localeService->getLocalesForSpace($space) : collect(); $supportedRaw = $this->localeService->getSupportedLocales(); diff --git a/app/Http/Controllers/Admin/PageAdminController.php b/app/Http/Controllers/Admin/PageAdminController.php index 476d7f3..5c7fb87 100644 --- a/app/Http/Controllers/Admin/PageAdminController.php +++ b/app/Http/Controllers/Admin/PageAdminController.php @@ -7,7 +7,6 @@ use App\Models\ContentPipeline; use App\Models\Page; use App\Models\PageComponent; -use App\Models\Space; use App\Pipelines\PipelineExecutor; use Illuminate\Http\Request; use Inertia\Inertia; @@ -136,7 +135,7 @@ public function generateComponent(Request $request, string $id, string $componen { $component = PageComponent::where('page_id', $id)->findOrFail($componentId); $page = $component->page; - $space = Space::first(); + $space = $request->space(); $validated = $request->validate([ 'brief_description' => 'required|string|max:2000', diff --git a/app/Http/Controllers/Admin/SpaceAdminController.php b/app/Http/Controllers/Admin/SpaceAdminController.php new file mode 100644 index 0000000..2c6e4be --- /dev/null +++ b/app/Http/Controllers/Admin/SpaceAdminController.php @@ -0,0 +1,146 @@ +attributes->get('space'); + + $spaces = Space::orderBy('created_at', 'asc') + ->get() + ->map(fn (Space $space) => [ + 'id' => $space->id, + 'name' => $space->name, + 'slug' => $space->slug, + 'description' => $space->description, + 'default_locale' => $space->default_locale, + 'created_at' => $space->created_at->toIso8601String(), + 'is_current' => $currentSpace && $currentSpace->id === $space->id, + ]); + + return Inertia::render('Admin/Spaces/Index', [ + 'spaces' => $spaces, + ]); + } + + /** + * Show the form for creating a new space. + */ + public function create(): Response + { + return Inertia::render('Admin/Spaces/Create'); + } + + /** + * Store a newly created space. + */ + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('spaces', 'slug')], + 'description' => ['nullable', 'string', 'max:1000'], + 'default_locale' => ['required', 'string', 'max:10'], + ]); + + Space::create($validated); + + return redirect()->route('admin.spaces.index') + ->with('success', 'Space created successfully.'); + } + + /** + * Show the form for editing the specified space. + * + * api_config values are masked (keys preserved, values replaced with '***') + * to prevent sensitive credentials from being exposed to the browser. + */ + public function edit(Space $space): Response + { + $apiConfig = $space->api_config; + $maskedApiConfig = null; + + if (is_array($apiConfig)) { + $maskedApiConfig = array_map(fn () => '***', $apiConfig); + } + + return Inertia::render('Admin/Spaces/Edit', [ + 'space' => [ + 'id' => $space->id, + 'name' => $space->name, + 'slug' => $space->slug, + 'description' => $space->description, + 'default_locale' => $space->default_locale, + 'settings' => $space->settings, + 'api_config' => $maskedApiConfig, + ], + ]); + } + + /** + * Update the specified space. + */ + public function update(Request $request, Space $space): RedirectResponse + { + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('spaces', 'slug')->ignore($space->id)], + 'description' => ['nullable', 'string', 'max:1000'], + 'default_locale' => ['required', 'string', 'max:10'], + 'settings' => ['nullable', 'array'], + 'api_config' => ['nullable', 'array'], + ]); + + // If api_config was submitted as all-masked values (all '***'), don't overwrite stored secrets + if (isset($validated['api_config']) && is_array($validated['api_config'])) { + $allMasked = collect($validated['api_config'])->every(fn ($v) => $v === '***'); + if ($allMasked) { + unset($validated['api_config']); + } + } + + $space->update($validated); + + return redirect()->route('admin.spaces.index') + ->with('success', 'Space updated successfully.'); + } + + /** + * Delete the specified space, blocking if it is the last one. + * Uses a database transaction with a row lock to prevent TOCTOU race conditions. + */ + public function destroy(string $id): RedirectResponse + { + $earlyReturn = null; + + DB::transaction(function () use ($id, &$earlyReturn): void { + $space = Space::lockForUpdate()->findOrFail($id); + + if (Space::count() <= 1) { + $earlyReturn = redirect()->route('admin.spaces.index') + ->with('error', 'Cannot delete the last space.'); + + return; + } + + $space->delete(); + }); + + return $earlyReturn ?? redirect()->route('admin.spaces.index') + ->with('success', 'Space deleted successfully.'); + } +} diff --git a/app/Http/Controllers/Admin/SpaceSwitcherController.php b/app/Http/Controllers/Admin/SpaceSwitcherController.php new file mode 100644 index 0000000..2e9416f --- /dev/null +++ b/app/Http/Controllers/Admin/SpaceSwitcherController.php @@ -0,0 +1,25 @@ +validate([ + 'space_id' => ['required', 'string', 'exists:spaces,id'], + ]); + + session(['current_space_id' => $validated['space_id']]); + + return redirect()->back(); + } +} diff --git a/app/Http/Controllers/Admin/TaxonomyAdminController.php b/app/Http/Controllers/Admin/TaxonomyAdminController.php index 0c17755..699936b 100644 --- a/app/Http/Controllers/Admin/TaxonomyAdminController.php +++ b/app/Http/Controllers/Admin/TaxonomyAdminController.php @@ -21,9 +21,9 @@ public function __construct(private readonly TaxonomyService $taxonomy) {} /** * List all vocabularies for the default space. */ - public function index(): Response + public function index(\Illuminate\Http\Request $request): Response { - $space = Space::first(); + $space = $request->space(); $vocabularies = $space ? Vocabulary::forSpace($space->id)->withCount('terms')->ordered()->get() diff --git a/app/Http/Controllers/Admin/WebhookAdminController.php b/app/Http/Controllers/Admin/WebhookAdminController.php index c06f938..ee16ec2 100644 --- a/app/Http/Controllers/Admin/WebhookAdminController.php +++ b/app/Http/Controllers/Admin/WebhookAdminController.php @@ -51,12 +51,7 @@ public function index(Request $request): Response */ public function store(Request $request): RedirectResponse { - // @phpstan-ignore-next-line - $spaceId = $request->user()->roles()->first()?->pivot->space_id; - - if (! $spaceId) { - abort(403, 'No accessible space found.'); - } + $spaceId = $request->space()?->id ?? abort(403, 'No space context.'); $this->authz->authorize($request->user(), 'webhooks.manage', $spaceId); @@ -196,14 +191,7 @@ public function redeliver(Request $request, string $id, string $deliveryId): Jso */ private function resolveSpaceId(Request $request): string { - // @phpstan-ignore-next-line - $spaceId = $request->user()->roles()->first()?->pivot->space_id; - - if (! $spaceId) { - abort(403, 'No accessible space found.'); - } - - return $spaceId; + return $request->space()?->id ?? abort(403, 'No space context.'); } /** diff --git a/app/Http/Middleware/EnsureUserIsAdmin.php b/app/Http/Middleware/EnsureUserIsAdmin.php index 42ce6f6..8ca08b4 100644 --- a/app/Http/Middleware/EnsureUserIsAdmin.php +++ b/app/Http/Middleware/EnsureUserIsAdmin.php @@ -11,8 +11,9 @@ class EnsureUserIsAdmin /** * Handle an incoming request. * - * For the dashboard (/admin), require admin role. - * For other admin routes, just require authentication (specific routes use permission middleware). + * Protects the entire admin route group (not just the root path). + * Allows access if the user is a system admin OR has at least one RBAC role assigned. + * Individual routes use the `permission` middleware for fine-grained access control. */ public function handle(Request $request, Closure $next): Response { @@ -22,13 +23,17 @@ public function handle(Request $request, Closure $next): Response abort(403, 'Unauthorized. Authentication required.'); } - // Dashboard requires admin role - if ($request->path() === 'admin' || $request->path() === 'admin/') { - if (! $user->isAdmin()) { - abort(403, 'Unauthorized. Admin access required.'); - } + // Allow admins unconditionally + if ($user->isAdmin()) { + return $next($request); } - return $next($request); + // Allow RBAC users who have at least one role assigned (permission middleware + // handles fine-grained access for each individual route) + if ($user->roles()->exists()) { + return $next($request); + } + + abort(403, 'Unauthorized. Admin access required.'); } } diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 15d9193..b31b84e 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -3,6 +3,7 @@ namespace App\Http\Middleware; use App\Models\Plugin; +use App\Models\Space; use Illuminate\Http\Request; use Inertia\Middleware; @@ -38,6 +39,16 @@ public function share(Request $request): array ->values() ->toArray(), ], + 'currentSpace' => fn () => $request->attributes->has('space') && $request->attributes->get('space') + ? [ + 'id' => $request->attributes->get('space')->id, + 'name' => $request->attributes->get('space')->name, + 'slug' => $request->attributes->get('space')->slug, + ] + : null, + 'spaces' => fn () => $request->user() + ? Space::all()->map(fn ($s) => ['id' => $s->id, 'name' => $s->name, 'slug' => $s->slug])->toArray() + : [], ]; } } diff --git a/app/Http/Middleware/ResolveCurrentSpace.php b/app/Http/Middleware/ResolveCurrentSpace.php new file mode 100644 index 0000000..c55b966 --- /dev/null +++ b/app/Http/Middleware/ResolveCurrentSpace.php @@ -0,0 +1,58 @@ +header('X-Space-Id'); + if ($spaceIdHeader && $request->user()?->isAdmin()) { + $space = Space::find($spaceIdHeader); + } + } + + // 3. First space fallback + if (! $space) { + $space = Space::first(); + } + + // Abort if still no space — no null allowed through + if (! $space) { + abort(503, 'No space configured. Please create a space first.'); + } + + // Bind to request attributes and persist in session + $request->attributes->set('space', $space); + session(['current_space_id' => $space->id]); + + return $next($request); + } +} diff --git a/app/Http/Middleware/ResolveSpace.php b/app/Http/Middleware/ResolveSpace.php new file mode 100644 index 0000000..62961bf --- /dev/null +++ b/app/Http/Middleware/ResolveSpace.php @@ -0,0 +1,56 @@ +header('X-Space-Id'); + if ($spaceIdHeader) { + $space = Space::find($spaceIdHeader); + } + + // 2. Check session + if (! $space) { + $sessionSpaceId = session('current_space_id'); + if ($sessionSpaceId) { + $space = Space::find($sessionSpaceId); + } + } + + // 3. Fallback to first space + if (! $space) { + $space = Space::orderBy('created_at', 'asc')->first(); + } + + // 4. No space configured + if (! $space) { + abort(503, 'No space configured'); + } + + // 5. Bind and persist in session + app()->instance('current_space', $space); + session(['current_space_id' => $space->id]); + + return $next($request); + } +} diff --git a/app/Models/Space.php b/app/Models/Space.php index f4a489e..a8ee633 100755 --- a/app/Models/Space.php +++ b/app/Models/Space.php @@ -6,11 +6,14 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Support\Facades\Crypt; /** * @property string $id * @property string $name * @property string $slug + * @property string|null $description + * @property string $default_locale * @property array|null $settings * @property array|null $api_config * @property \Carbon\Carbon $created_at @@ -29,13 +32,46 @@ class Space extends Model { use HasFactory, HasUlids; - protected $fillable = ['name', 'slug', 'settings', 'api_config']; + protected $fillable = ['name', 'slug', 'description', 'default_locale', 'settings', 'api_config']; protected $casts = [ 'settings' => 'array', - 'api_config' => 'array', + // api_config is handled via encrypted accessors/mutators below ]; + /** + * Decrypt api_config when reading. + * Stored as an encrypted JSON string; returned as an array. + * + * @return array|null + */ + public function getApiConfigAttribute(?string $value): ?array + { + if ($value === null) { + return null; + } + + $decrypted = Crypt::decryptString($value); + + return json_decode($decrypted, true); + } + + /** + * Encrypt api_config when writing. + * + * @param array|null $value + */ + public function setApiConfigAttribute(?array $value): void + { + if ($value === null) { + $this->attributes['api_config'] = null; + + return; + } + + $this->attributes['api_config'] = Crypt::encryptString(json_encode($value)); + } + public function contentTypes(): HasMany { return $this->hasMany(ContentType::class); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8e66457..62a51eb 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -35,6 +35,7 @@ use App\Services\Search\SearchRanker; use App\Services\Search\SearchService; use App\Services\Search\SemanticSearchDriver; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Gate; use Illuminate\Support\ServiceProvider; @@ -117,6 +118,9 @@ public function boot(): void // Register content access policies Gate::policy(Content::class, ContentPolicy::class); + // Make $request->space() available in all controllers + Request::macro('space', fn () => $this->attributes->get('space')); + // Load DB settings into config (overrides .env defaults) Setting::loadIntoConfig(); // Boot plugin system diff --git a/bootstrap/app.php b/bootstrap/app.php index f973f74..9e8f69b 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -14,6 +14,7 @@ ->withMiddleware(function (Middleware $middleware) { $middleware->web(append: [ \App\Http\Middleware\HandleInertiaRequests::class, + \App\Http\Middleware\ResolveCurrentSpace::class, ]); // Global API rate limiting: 60 requests per minute @@ -25,6 +26,7 @@ $middleware->alias([ 'admin' => \App\Http\Middleware\EnsureUserIsAdmin::class, 'permission' => \App\Http\Middleware\RequirePermission::class, + 'resolve-space' => \App\Http\Middleware\ResolveCurrentSpace::class, 'set-locale' => \App\Http\Middleware\SetLocaleFromRequest::class, ]); }) diff --git a/database/migrations/2026_03_16_000001_add_description_default_locale_to_spaces_table.php b/database/migrations/2026_03_16_000001_add_description_default_locale_to_spaces_table.php new file mode 100644 index 0000000..75af8e8 --- /dev/null +++ b/database/migrations/2026_03_16_000001_add_description_default_locale_to_spaces_table.php @@ -0,0 +1,25 @@ +text('description')->nullable()->after('slug'); + $table->string('default_locale', 10)->default('en')->after('description'); + }); + } + } + + public function down(): void + { + Schema::table('spaces', function (Blueprint $table) { + $table->dropColumn(['description', 'default_locale']); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 7468979..155bde7 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -12,6 +12,7 @@ class DatabaseSeeder extends Seeder public function run(): void { $this->call([ + DefaultSpaceSeeder::class, RoleSeeder::class, DemoSeeder::class, PageSeeder::class, diff --git a/database/seeders/DefaultSpaceSeeder.php b/database/seeders/DefaultSpaceSeeder.php new file mode 100644 index 0000000..8810292 --- /dev/null +++ b/database/seeders/DefaultSpaceSeeder.php @@ -0,0 +1,20 @@ + 'default'], + ['name' => 'Default', 'default_locale' => 'en'] + ); + } +} diff --git a/phpstan.neon b/phpstan.neon index be6a7ce..3a41f97 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -11,6 +11,7 @@ parameters: ignoreErrors: - '#Access to an undefined property .*(User|Role)::.*pivot#' - '#Parameter.*callback of method.*map.*expects callable.*Closure.*given#' + - '#Using nullsafe property access.*on left side of.*is unnecessary#' phpVersion: 80200 parallel: maximumNumberOfProcesses: 1 diff --git a/resources/js/Components/SpaceSwitcher.vue b/resources/js/Components/SpaceSwitcher.vue new file mode 100644 index 0000000..5ab6a5a --- /dev/null +++ b/resources/js/Components/SpaceSwitcher.vue @@ -0,0 +1,53 @@ + + + diff --git a/resources/js/Layouts/MainLayout.vue b/resources/js/Layouts/MainLayout.vue index f60893a..ae80b40 100644 --- a/resources/js/Layouts/MainLayout.vue +++ b/resources/js/Layouts/MainLayout.vue @@ -1,16 +1,30 @@ + +