From 33fb14b773b5008250e688b72ae7915918f8c907 Mon Sep 17 00:00:00 2001 From: Stefan Lazic Date: Sat, 5 Sep 2026 09:31:59 +0100 Subject: [PATCH] Revert "Management UI" This reverts commit f11404c5744c971a2ab96a398c20b5918cee99ac. --- .env.example | 1 + README.md | 32 +--- app/Http/Controllers/AuthController.php | 7 +- app/Http/Controllers/InstallController.php | 70 ++------ .../Controllers/ManagerBusinessController.php | 127 -------------- .../ManagerDashboardController.php | 23 --- .../Controllers/OwnerInvitationController.php | 60 ------- .../Controllers/PlatformAuthController.php | 44 ----- app/Http/Middleware/EnsureSuperAdmin.php | 21 --- app/Http/Middleware/ResolveTenant.php | 15 +- app/Models/Business.php | 1 - .../Businesses/BusinessProvisioningResult.php | 14 -- .../BusinessProvisioningService.php | 156 ------------------ app/Services/Tenant/DomainNormalizer.php | 32 ---- app/Services/Tenant/TenantContext.php | 5 - bootstrap/app.php | 2 - config/app.php | 2 + database/seeders/DatabaseSeeder.php | 6 - resources/views/auth/owner-invite.blade.php | 20 --- .../views/auth/platform-sign-in.blade.php | 20 --- .../components/layouts/manager.blade.php | 21 --- resources/views/install.blade.php | 27 +-- .../views/manager/businesses/create.blade.php | 22 --- .../views/manager/businesses/edit.blade.php | 8 - .../views/manager/businesses/show.blade.php | 10 -- resources/views/manager/dashboard.blade.php | 39 ----- routes/web.php | 20 +-- tests/Feature/InstallationTest.php | 42 ++--- tests/Feature/TenantAndAuthTest.php | 128 ++++++-------- 29 files changed, 94 insertions(+), 881 deletions(-) delete mode 100644 app/Http/Controllers/ManagerBusinessController.php delete mode 100644 app/Http/Controllers/ManagerDashboardController.php delete mode 100644 app/Http/Controllers/OwnerInvitationController.php delete mode 100644 app/Http/Controllers/PlatformAuthController.php delete mode 100644 app/Http/Middleware/EnsureSuperAdmin.php delete mode 100644 app/Services/Businesses/BusinessProvisioningResult.php delete mode 100644 app/Services/Businesses/BusinessProvisioningService.php delete mode 100644 app/Services/Tenant/DomainNormalizer.php delete mode 100644 resources/views/auth/owner-invite.blade.php delete mode 100644 resources/views/auth/platform-sign-in.blade.php delete mode 100644 resources/views/components/layouts/manager.blade.php delete mode 100644 resources/views/manager/businesses/create.blade.php delete mode 100644 resources/views/manager/businesses/edit.blade.php delete mode 100644 resources/views/manager/businesses/show.blade.php delete mode 100644 resources/views/manager/dashboard.blade.php diff --git a/.env.example b/.env.example index 2cc5f50..e3e82cd 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,7 @@ APP_ENV=local APP_KEY= APP_DEBUG=true APP_URL=http://localhost:8085 +APP_DEFAULT_BUSINESS_SLUG= APP_LOCALE=en APP_FALLBACK_LOCALE=en diff --git a/README.md b/README.md index 2ef2a3d..bdfbc33 100644 --- a/README.md +++ b/README.md @@ -21,31 +21,9 @@ php artisan serve ``` Configure the MySQL connection in `.env` before running migrations. The -application resolves a business only from the request hostname through the -`business_domains` table; it never guesses a tenant from the first business -in the database. For local multi-tenant work, map each configured hostname to -127.0.0.1 in `/etc/hosts`. - -## Platform and tenant setup - -Migrations create schema only. They do not create a demo business or a -business owner. After migrating, open `/install` once and create the platform -administrator. That account can sign in at `/manager/sign-in`, create -businesses, edit their primary domains, and share the one-time owner setup -link generated during provisioning. - -Business provisioning creates the tenant domain, settings, booking rules, -opening hours, system roles, owner staff profile, and seven-day owner -invitation in one transaction. The owner chooses a password through that -invitation; platform administrators and business owners are separate account -types. - -If demo data is useful for local development, opt into the existing seeder -explicitly: - -```bash -NEXTCHAIR_SEED_DEMO=true php artisan db:seed -``` +application resolves a business from the request hostname through the +`business_domains` table. Local development can use a configured local domain +or the `APP_DEFAULT_BUSINESS_SLUG` fallback. ## Docker deployment @@ -81,10 +59,6 @@ docker compose exec app php artisan key:generate docker compose exec app php artisan migrate ``` -Then visit `http://localhost:8085/install` to create the first platform -administrator. Do not run migrations or create tenants from container startup; -the tenant owner is provisioned manually from Manager. - Migrations are intentionally not run by container startup. To inspect the application logs: diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index 36423da..7cca55b 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -42,10 +42,6 @@ class AuthController extends Controller $request->session()->regenerate(); - if ($request->user()->isSuperAdmin) { - return redirect()->intended(route('manager.dashboard')); - } - return redirect()->intended(route('customer.dashboard')); } @@ -104,11 +100,10 @@ class AuthController extends Controller public function logout(Request $request): RedirectResponse { - $wasSuperAdmin = $request->user()?->isSuperAdmin === true; Auth::logout(); $request->session()->invalidate(); $request->session()->regenerateToken(); - return redirect()->route($wasSuperAdmin ? 'manager.login' : 'public.home'); + return redirect()->route('public.home'); } } diff --git a/app/Http/Controllers/InstallController.php b/app/Http/Controllers/InstallController.php index 49c1804..36eb8db 100644 --- a/app/Http/Controllers/InstallController.php +++ b/app/Http/Controllers/InstallController.php @@ -2,75 +2,39 @@ namespace App\Http\Controllers; +use App\Enums\Permission; use App\Enums\InstallationStatus; -use App\Models\ApplicationInstallation; -use App\Models\User; +use App\Models\{ApplicationInstallation, BookingRule, Business, BusinessDomain, BusinessSettings, Role, RolePermission, StaffProfile, User}; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\View\View; class InstallController extends Controller { - public function create(Request $request): View|RedirectResponse + public function create(): View|RedirectResponse { - if ($this->isInstalled()) { - return $request->user()?->isSuperAdmin - ? redirect()->route('manager.dashboard') - : redirect()->route('manager.login'); - } - + if (ApplicationInstallation::query()->where('status', InstallationStatus::Installed)->exists()) return redirect()->route('public.home'); return view('install'); } public function store(Request $request): RedirectResponse { - if ($this->isInstalled()) { - return redirect()->route('manager.login'); - } + if (ApplicationInstallation::query()->where('status', InstallationStatus::Installed)->exists()) return redirect()->route('public.home'); + $data = $request->validate(['businessName' => ['required', 'string', 'max:190'], 'slug' => ['required', 'alpha_dash', 'max:80', 'unique:Business,slug'], 'adminName' => ['required', 'string', 'max:190'], 'adminEmail' => ['required', 'email', 'max:190', 'unique:User,email'], 'password' => ['required', 'string', 'min:8', 'confirmed'], 'domain' => ['nullable', 'string', 'max:190']]); - $data = $request->validate([ - 'adminName' => ['required', 'string', 'max:160'], - 'adminEmail' => ['required', 'email', 'max:190', 'unique:User,email'], - 'password' => ['required', 'string', 'min:8', 'confirmed'], - ]); - - $admin = DB::transaction(function () use ($data): User { - $admin = User::create([ - 'name' => trim($data['adminName']), - 'email' => strtolower(trim($data['adminEmail'])), - 'passwordHash' => Hash::make($data['password']), - 'isSuperAdmin' => true, - 'emailVerified' => now(), - ]); - - ApplicationInstallation::create([ - 'id' => 'singleton', - 'status' => InstallationStatus::Installed, - 'installedAt' => now(), - 'installedVersion' => 'laravel-1', - 'initialAdminUserId' => $admin->id, - 'primaryBusinessId' => null, - 'metadata' => ['installer' => 'platform-admin-only'], - ]); - - return $admin; + DB::transaction(function () use ($data, $request): void { + $business = Business::create(['slug' => strtolower($data['slug']), 'name' => $data['businessName'], 'shortName' => $data['businessName'], 'timezone' => config('app.timezone', 'Europe/London'), 'currency' => 'GBP', 'locale' => 'en-GB']); + BusinessDomain::create(['businessId' => $business->id, 'hostname' => strtolower($data['domain'] ?: $request->getHost()), 'isPrimary' => true, 'isActive' => true]); + BusinessSettings::create(['businessId' => $business->id]); + BookingRule::create(['businessId' => $business->id, 'allowGuestBooking' => true]); + $role = Role::create(['businessId' => $business->id, 'name' => 'Owner', 'isSystem' => true]); + foreach (Permission::cases() as $permission) RolePermission::create(['roleId' => $role->id, 'permission' => $permission]); + $user = User::create(['name' => $data['adminName'], 'email' => strtolower($data['adminEmail']), 'passwordHash' => Hash::make($data['password']), 'emailVerified' => now()]); + $staff = StaffProfile::create(['userId' => $user->id, 'businessId' => $business->id, 'roleId' => $role->id, 'status' => 'ACTIVE', 'isBookableOnline' => false]); + ApplicationInstallation::create(['id' => 'singleton', 'status' => InstallationStatus::Installed, 'installedAt' => now(), 'installedVersion' => 'laravel-1', 'initialAdminUserId' => $user->id, 'primaryBusinessId' => $business->id, 'metadata' => ['starter' => true]]); }); - - Auth::login($admin); - $request->session()->regenerate(); - - return redirect()->route('manager.dashboard')->with('status', 'Platform setup complete. Create your first business from Manager.'); - } - - private function isInstalled(): bool - { - try { - return ApplicationInstallation::query()->where('status', InstallationStatus::Installed)->exists(); - } catch (\Throwable) { - return false; - } + return redirect()->route('install')->with('status', 'Installation complete. You can now sign in.'); } } diff --git a/app/Http/Controllers/ManagerBusinessController.php b/app/Http/Controllers/ManagerBusinessController.php deleted file mode 100644 index a89d5f2..0000000 --- a/app/Http/Controllers/ManagerBusinessController.php +++ /dev/null @@ -1,127 +0,0 @@ -validate([ - 'name' => ['required', 'string', 'max:160'], - 'shortName' => ['nullable', 'string', 'max:80'], - 'slug' => ['required', 'alpha_dash', 'max:80', 'unique:Business,slug'], - 'domain' => ['required', 'string', 'max:253'], - 'email' => ['nullable', 'email', 'max:190'], - 'phone' => ['nullable', 'string', 'max:40'], - 'timezone' => ['required', 'timezone'], - 'currency' => ['required', 'string', 'size:3'], - 'ownerName' => ['required', 'string', 'max:160'], - 'ownerEmail' => ['required', 'email', 'max:190', 'unique:User,email'], - ]); - - try { - $data['domain'] = $domains->normalize($data['domain']); - } catch (InvalidArgumentException $exception) { - return back()->withErrors(['domain' => $exception->getMessage()])->withInput(); - } - - if (BusinessDomain::query()->where('hostname', $data['domain'])->exists()) { - return back()->withErrors(['domain' => 'That hostname is already assigned to another business.'])->withInput(); - } - - try { - $result = $provisioner->provision($data, [ - 'name' => $data['ownerName'], - 'email' => $data['ownerEmail'], - ], $request->user()); - } catch (InvalidArgumentException $exception) { - return back()->withErrors(['domain' => $exception->getMessage()])->withInput(); - } - - $hostname = $result->business->primaryDomain()->value('hostname'); - $invitationUrl = request()->getScheme().'://'.$hostname.'/invite/'.$result->ownerInvitation->token; - - return redirect()->route('manager.businesses.show', $result->business) - ->with('status', 'Business created. The owner can finish setup using the invitation link below.') - ->with('owner_invitation_url', $invitationUrl); - } - - public function show(Business $business): View - { - $business->load(['domains', 'settings', 'bookingRule', 'hours', 'staff.user', 'staff.role']); - - return view('manager.businesses.show', compact('business')); - } - - public function edit(Business $business): View - { - $business->load('primaryDomain'); - - return view('manager.businesses.edit', compact('business')); - } - - public function update(Request $request, Business $business, DomainNormalizer $domains): RedirectResponse - { - $data = $request->validate([ - 'name' => ['required', 'string', 'max:160'], - 'shortName' => ['nullable', 'string', 'max:80'], - 'slug' => ['required', 'alpha_dash', 'max:80', 'unique:Business,slug,'.$business->id.',id'], - 'domain' => ['required', 'string', 'max:253'], - 'email' => ['nullable', 'email', 'max:190'], - 'phone' => ['nullable', 'string', 'max:40'], - 'timezone' => ['required', 'timezone'], - 'currency' => ['required', 'string', 'size:3'], - ]); - - try { - $hostname = $domains->normalize($data['domain']); - } catch (InvalidArgumentException $exception) { - return back()->withErrors(['domain' => $exception->getMessage()])->withInput(); - } - - $domainTaken = BusinessDomain::query() - ->where('hostname', $hostname) - ->where('businessId', '!=', $business->id) - ->exists(); - - if ($domainTaken) { - return back()->withErrors(['domain' => 'That hostname is already assigned to another business.'])->withInput(); - } - - DB::transaction(function () use ($business, $data, $hostname): void { - $business->update([ - 'name' => trim($data['name']), - 'shortName' => filled($data['shortName'] ?? null) ? trim($data['shortName']) : null, - 'slug' => strtolower(trim($data['slug'])), - 'email' => filled($data['email'] ?? null) ? strtolower(trim($data['email'])) : null, - 'phone' => filled($data['phone'] ?? null) ? trim($data['phone']) : null, - 'timezone' => $data['timezone'], - 'currency' => strtoupper($data['currency']), - ]); - - $primary = $business->domains()->where('isPrimary', true)->first(); - if ($primary) { - $primary->update(['hostname' => $hostname]); - } else { - BusinessDomain::create(['businessId' => $business->id, 'hostname' => $hostname, 'isPrimary' => true, 'isActive' => true]); - } - }); - - return redirect()->route('manager.businesses.show', $business)->with('status', 'Business details updated.'); - } -} diff --git a/app/Http/Controllers/ManagerDashboardController.php b/app/Http/Controllers/ManagerDashboardController.php deleted file mode 100644 index 1a08996..0000000 --- a/app/Http/Controllers/ManagerDashboardController.php +++ /dev/null @@ -1,23 +0,0 @@ -with(['primaryDomain', 'staff.user', 'staff.role']) - ->withCount(['staff', 'domains']) - ->latest('createdAt') - ->get(); - - return view('manager.dashboard', [ - 'businesses' => $businesses, - 'businessCount' => $businesses->count(), - ]); - } -} diff --git a/app/Http/Controllers/OwnerInvitationController.php b/app/Http/Controllers/OwnerInvitationController.php deleted file mode 100644 index d607ae3..0000000 --- a/app/Http/Controllers/OwnerInvitationController.php +++ /dev/null @@ -1,60 +0,0 @@ -findValidInvitation($token); - - return view('auth.owner-invite', [ - 'invitation' => $invitation, - 'business' => $invitation->business, - ]); - } - - public function accept(Request $request, string $token): RedirectResponse - { - $data = $request->validate([ - 'password' => ['required', 'string', 'min:8', 'confirmed'], - ]); - - $invitation = $this->findValidInvitation($token); - - DB::transaction(function () use ($invitation, $data): void { - $invitation->staff->user->update([ - 'passwordHash' => Hash::make($data['password']), - 'emailVerified' => now(), - ]); - $invitation->update(['acceptedAt' => now()]); - }); - - Auth::login($invitation->staff->user->fresh()); - $request->session()->regenerate(); - - $hostname = $invitation->business->primaryDomain?->hostname; - $destination = $hostname - ? request()->getScheme().'://'.$hostname.'/owner' - : route('owner.dashboard'); - - return redirect()->to($destination)->with('status', 'Your owner workspace is ready.'); - } - - private function findValidInvitation(string $token): StaffInvitation - { - $invitation = StaffInvitation::query()->with(['business.primaryDomain', 'staff.user'])->where('token', $token)->firstOrFail(); - - abort_if($invitation->acceptedAt || $invitation->expiresAt->isPast(), 410, 'This invitation has expired or has already been used.'); - - return $invitation; - } -} diff --git a/app/Http/Controllers/PlatformAuthController.php b/app/Http/Controllers/PlatformAuthController.php deleted file mode 100644 index 2a8ad89..0000000 --- a/app/Http/Controllers/PlatformAuthController.php +++ /dev/null @@ -1,44 +0,0 @@ -user()?->isSuperAdmin) { - return redirect()->route('manager.dashboard'); - } - - if (! ApplicationInstallation::query()->where('status', InstallationStatus::Installed)->exists()) { - return redirect()->route('install'); - } - - return view('auth.platform-sign-in'); - } - - public function login(Request $request): RedirectResponse - { - $credentials = $request->validate([ - 'email' => ['required', 'email', 'max:190'], - 'password' => ['required', 'string'], - ]); - - if (! Auth::attempt($credentials, $request->boolean('remember')) || ! $request->user()?->isSuperAdmin) { - Auth::logout(); - - return back()->withErrors(['email' => 'Those platform administrator details do not match our records.'])->onlyInput('email'); - } - - $request->session()->regenerate(); - - return redirect()->intended(route('manager.dashboard')); - } -} diff --git a/app/Http/Middleware/EnsureSuperAdmin.php b/app/Http/Middleware/EnsureSuperAdmin.php deleted file mode 100644 index 76c35bb..0000000 --- a/app/Http/Middleware/EnsureSuperAdmin.php +++ /dev/null @@ -1,21 +0,0 @@ -user()) { - return redirect()->route('manager.login'); - } - - abort_unless($request->user()->isSuperAdmin === true, 403); - - return $next($request); - } -} diff --git a/app/Http/Middleware/ResolveTenant.php b/app/Http/Middleware/ResolveTenant.php index 4e109f8..55dd8a1 100644 --- a/app/Http/Middleware/ResolveTenant.php +++ b/app/Http/Middleware/ResolveTenant.php @@ -2,9 +2,9 @@ namespace App\Http\Middleware; +use App\Models\Business; use App\Models\BusinessDomain; use App\Services\Tenant\TenantContext; -use App\Services\Tenant\DomainNormalizer; use Closure; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; @@ -14,25 +14,26 @@ class ResolveTenant { public function handle(Request $request, Closure $next): Response { - $hostname = null; + $hostname = strtolower(rtrim($request->getHost(), '.')); try { - $hostname = app(DomainNormalizer::class)->normalize($request->getHost()); $business = BusinessDomain::query() ->where('hostname', $hostname) ->where('isActive', true) ->with('business') ->first()?->business; + if (! $business && app()->environment('local')) { + $slug = config('app.default_business_slug'); + $business = $slug + ? Business::query()->where('slug', $slug)->first() + : null; + } } catch (Throwable) { // The install page and framework smoke tests must remain // reachable before the tenant schema has been created. $business = null; } - if (! $business) { - app(TenantContext::class)->forget(); - } - if ($business) { app(TenantContext::class)->set($business); } diff --git a/app/Models/Business.php b/app/Models/Business.php index 0c6ed52..beb9be5 100644 --- a/app/Models/Business.php +++ b/app/Models/Business.php @@ -31,7 +31,6 @@ class Business extends Model } public function domains(): HasMany { return $this->hasMany(BusinessDomain::class, 'businessId'); } - public function primaryDomain(): HasOne { return $this->hasOne(BusinessDomain::class, 'businessId')->where('isPrimary', true); } public function locations(): HasMany { return $this->hasMany(BusinessLocation::class, 'businessId'); } public function hours(): HasMany { return $this->hasMany(BusinessHours::class, 'businessId'); } public function settings(): HasOne { return $this->hasOne(BusinessSettings::class, 'businessId'); } diff --git a/app/Services/Businesses/BusinessProvisioningResult.php b/app/Services/Businesses/BusinessProvisioningResult.php deleted file mode 100644 index c6955e8..0000000 --- a/app/Services/Businesses/BusinessProvisioningResult.php +++ /dev/null @@ -1,14 +0,0 @@ - $businessData - * @param array $ownerData - */ - public function provision(array $businessData, array $ownerData, ?User $invitedBy = null): BusinessProvisioningResult - { - $hostname = $this->domains->normalize((string) $businessData['domain']); - - return DB::transaction(function () use ($businessData, $ownerData, $hostname, $invitedBy): BusinessProvisioningResult { - $slug = strtolower(trim((string) $businessData['slug'])); - - if (Business::query()->where('slug', $slug)->exists()) { - throw new \InvalidArgumentException('That business slug is already in use.'); - } - - if (BusinessDomain::query()->where('hostname', $hostname)->exists()) { - throw new \InvalidArgumentException('That hostname is already assigned to another business.'); - } - - $business = Business::create([ - 'slug' => $slug, - 'name' => trim((string) $businessData['name']), - 'shortName' => filled($businessData['shortName'] ?? null) ? trim((string) $businessData['shortName']) : null, - 'description' => filled($businessData['description'] ?? null) ? trim((string) $businessData['description']) : null, - 'phone' => filled($businessData['phone'] ?? null) ? trim((string) $businessData['phone']) : null, - 'email' => strtolower(trim((string) ($businessData['email'] ?? ''))), - 'timezone' => $businessData['timezone'] ?? 'Europe/London', - 'currency' => strtoupper((string) ($businessData['currency'] ?? 'GBP')), - 'locale' => $businessData['locale'] ?? 'en-GB', - ]); - - BusinessDomain::create([ - 'businessId' => $business->id, - 'hostname' => $hostname, - 'isPrimary' => true, - 'isActive' => true, - ]); - - BusinessSettings::create([ - 'businessId' => $business->id, - 'cancellationPolicy' => 'Please give us 24 hours notice if you need to cancel.', - ]); - - BookingRule::create([ - 'businessId' => $business->id, - 'minNoticeMinutes' => 120, - 'maxAdvanceDays' => 60, - 'cancellationWindowHours' => 24, - 'rescheduleWindowHours' => 24, - 'slotIntervalMinutes' => 30, - 'bufferAfterMinutes' => 5, - 'customerCanChooseStaff' => true, - 'allowGuestBooking' => true, - ]); - - foreach ([ - 'MONDAY' => [600, 1140], - 'TUESDAY' => [600, 1140], - 'WEDNESDAY' => [600, 1140], - 'THURSDAY' => [600, 1140], - 'FRIDAY' => [600, 1200], - 'SATURDAY' => [540, 1020], - 'SUNDAY' => [null, null], - ] as $day => [$opens, $closes]) { - BusinessHours::create([ - 'businessId' => $business->id, - 'dayOfWeek' => $day, - 'isClosed' => $opens === null, - 'opensAtMinutes' => $opens, - 'closesAtMinutes' => $closes, - ]); - } - - $ownerRole = $this->createRole($business, 'Owner', Permission::cases()); - $barberRole = $this->createRole($business, 'Barber', [ - Permission::ManageOwnAppointments, - Permission::CheckInCustomers, - Permission::ManageOwnAvailability, - Permission::BookOnBehalfOfCustomer, - Permission::ManageLoyaltyStamps, - ]); - - $owner = User::create([ - 'name' => trim((string) $ownerData['name']), - 'email' => strtolower(trim((string) $ownerData['email'])), - 'passwordHash' => null, - 'isSuperAdmin' => false, - ]); - - $staff = StaffProfile::create([ - 'userId' => $owner->id, - 'businessId' => $business->id, - 'roleId' => $ownerRole->id, - 'status' => 'ACTIVE', - 'isBookableOnline' => true, - ]); - - $invitation = StaffInvitation::create([ - 'businessId' => $business->id, - 'staffId' => $staff->id, - 'email' => $owner->email, - 'token' => Str::random(64), - 'invitedByUserId' => $invitedBy?->id, - 'expiresAt' => now()->addDays(7), - ]); - - // Keep the local variable explicit so static analysers understand - // that the default staff role is intentionally provisioned too. - unset($barberRole); - - return new BusinessProvisioningResult($business, $invitation); - }); - } - - /** - * @param array $permissions - */ - private function createRole(Business $business, string $name, array $permissions): Role - { - $role = Role::create(['businessId' => $business->id, 'name' => $name, 'isSystem' => true]); - - foreach ($permissions as $permission) { - RolePermission::create(['roleId' => $role->id, 'permission' => $permission->value]); - } - - return $role; - } -} diff --git a/app/Services/Tenant/DomainNormalizer.php b/app/Services/Tenant/DomainNormalizer.php deleted file mode 100644 index b70f3cb..0000000 --- a/app/Services/Tenant/DomainNormalizer.php +++ /dev/null @@ -1,32 +0,0 @@ - 253 || preg_match('/\s/', $host) || ! preg_match('/^[a-z0-9.-]+$/', $host)) { - throw new InvalidArgumentException('Enter a valid hostname, such as book.example.com.'); - } - - return $host; - } -} diff --git a/app/Services/Tenant/TenantContext.php b/app/Services/Tenant/TenantContext.php index 3d0cc62..8d518b4 100644 --- a/app/Services/Tenant/TenantContext.php +++ b/app/Services/Tenant/TenantContext.php @@ -13,11 +13,6 @@ final class TenantContext $this->business = $business; } - public function forget(): void - { - $this->business = null; - } - public function business(): ?Business { return $this->business; diff --git a/bootstrap/app.php b/bootstrap/app.php index 75d5018..3592241 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -6,7 +6,6 @@ use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Http\Request; use App\Http\Middleware\ResolveTenant; use App\Http\Middleware\EnsureStaffPermission; -use App\Http\Middleware\EnsureSuperAdmin; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( @@ -18,7 +17,6 @@ return Application::configure(basePath: dirname(__DIR__)) $middleware->alias([ 'tenant' => ResolveTenant::class, 'staff.permission' => EnsureStaffPermission::class, - 'platform.admin' => EnsureSuperAdmin::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/config/app.php b/config/app.php index 961e54d..fe2aedc 100644 --- a/config/app.php +++ b/config/app.php @@ -15,6 +15,8 @@ return [ 'name' => env('APP_NAME', 'NextChair'), + 'default_business_slug' => env('APP_DEFAULT_BUSINESS_SLUG'), + /* |-------------------------------------------------------------------------- | Application Environment diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index ab13b4e..afcbc54 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -27,12 +27,6 @@ class DatabaseSeeder extends Seeder { public function run(): void { - // Demo data is opt-in. Application installation and migrations must - // never create a business or an owner implicitly. - if (! app()->environment('local') || ! env('NEXTCHAIR_SEED_DEMO', false)) { - return; - } - $business = Business::updateOrCreate( ['slug' => 'demo-barbershop'], ['name' => 'Blake Heritage Barbershop', 'shortName' => 'Blake Heritage', 'description' => 'Classic cuts, modern service, and a simple way to book your next visit.', 'phone' => '+44 20 7946 0000', 'email' => 'hello@blakeheritage.test', 'timezone' => 'Europe/London', 'currency' => 'GBP', 'locale' => 'en-GB'], diff --git a/resources/views/auth/owner-invite.blade.php b/resources/views/auth/owner-invite.blade.php deleted file mode 100644 index 0c40d18..0000000 --- a/resources/views/auth/owner-invite.blade.php +++ /dev/null @@ -1,20 +0,0 @@ - -
-

{{ $business->name }}

-

Finish your owner setup

-

Choose a password for {{ $invitation->staff->user->email }} to access the business workspace.

- @if ($errors->any()) -
{{ $errors->first() }}
- @endif -
- @csrf - - - Open owner workspace -
-
-
diff --git a/resources/views/auth/platform-sign-in.blade.php b/resources/views/auth/platform-sign-in.blade.php deleted file mode 100644 index 731b61d..0000000 --- a/resources/views/auth/platform-sign-in.blade.php +++ /dev/null @@ -1,20 +0,0 @@ - -
-

NextChair Manager

-

Platform sign in

-

Sign in with the platform administrator account to manage businesses.

- @if ($errors->any()) -
{{ $errors->first() }}
- @endif -
- @csrf - - - Sign in -
-
-
diff --git a/resources/views/components/layouts/manager.blade.php b/resources/views/components/layouts/manager.blade.php deleted file mode 100644 index 58e8303..0000000 --- a/resources/views/components/layouts/manager.blade.php +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - Manager · {{ config('app.name') }} - @vite(['resources/css/app.css', 'resources/js/app.js']) - - -
-
- NextChair Manager -
- -
@csrf
-
-
-
- {{ $slot }} - - diff --git a/resources/views/install.blade.php b/resources/views/install.blade.php index 2d7bbb8..9734f65 100644 --- a/resources/views/install.blade.php +++ b/resources/views/install.blade.php @@ -1,26 +1 @@ - -
-

NextChair setup

-

Create the platform administrator

-

This first account manages the NextChair platform. Businesses and their owners are created later from the protected Manager area.

- @if ($errors->any()) -
{{ $errors->first() }}
- @endif -
- @csrf - - - - - Finish platform setup -
-
-
+

NextChair setup

Create your business

Set up the first business owner and start configuring your booking workspace.

@if (session('status'))
{{ session('status') }}
Sign in@else @if ($errors->any())
{{ $errors->first() }}
@endif
@csrf
Finish setup
@endif
diff --git a/resources/views/manager/businesses/create.blade.php b/resources/views/manager/businesses/create.blade.php deleted file mode 100644 index 82fa35a..0000000 --- a/resources/views/manager/businesses/create.blade.php +++ /dev/null @@ -1,22 +0,0 @@ - -
- ← Businesses -

New tenant

Create a business

Provision the tenant, its defaults, and the owner’s secure setup invitation together.

- @if ($errors->any())
{{ $errors->first() }}
@endif -
- @csrf -

Business details

- - - - - - - - -
-

Business owner

The owner receives a one-time link to choose their password.

-
Provision business
-
-
-
diff --git a/resources/views/manager/businesses/edit.blade.php b/resources/views/manager/businesses/edit.blade.php deleted file mode 100644 index 256e5aa..0000000 --- a/resources/views/manager/businesses/edit.blade.php +++ /dev/null @@ -1,8 +0,0 @@ - -
- ← {{ $business->name }} -

Tenant settings

Edit business

- @if ($errors->any())
{{ $errors->first() }}
@endif -
@csrf @method('PUT')
Save changes
-
-
diff --git a/resources/views/manager/businesses/show.blade.php b/resources/views/manager/businesses/show.blade.php deleted file mode 100644 index a1732d3..0000000 --- a/resources/views/manager/businesses/show.blade.php +++ /dev/null @@ -1,10 +0,0 @@ - -
- ← Businesses -

Business tenant

{{ $business->name }}

{{ $business->slug }}

Edit business
- @if (session('status'))
{{ session('status') }}
@endif - @if (session('owner_invitation_url'))

Owner setup link

Share this link securely with the business owner. It expires after seven days and can only be used once.

{{ session('owner_invitation_url') }}
@endif -

Primary domain

{{ $business->primaryDomain?->hostname ?: 'Not configured' }}

Timezone

{{ $business->timezone }}

Defaults

{{ $business->hours->count() }} days · {{ $business->staff->count() }} staff

-

Domains

@foreach ($business->domains as $domain)

{{ $domain->hostname }}

{{ $domain->isPrimary ? 'Primary' : 'Additional' }}

{{ $domain->isActive ? 'Active' : 'Inactive' }}
@endforeach

Staff and owners

@foreach ($business->staff as $member)

{{ $member->user?->name ?: $member->user?->email }}

{{ $member->role?->name }} · {{ $member->user?->email }}

@endforeach
-
-
diff --git a/resources/views/manager/dashboard.blade.php b/resources/views/manager/dashboard.blade.php deleted file mode 100644 index 0fc68b5..0000000 --- a/resources/views/manager/dashboard.blade.php +++ /dev/null @@ -1,39 +0,0 @@ - -
-
-
-

Platform operations

-

Businesses

-

Create and maintain isolated NextChair tenants.

-
- Create business -
- - @if (session('status')) -
{{ session('status') }}
- @endif - -
-

Businesses

{{ $businessCount }}

-

Tenant model

Domain routed

-

Provisioning

Owner invited

-
- -
- @forelse ($businesses as $business) - @php($owner = $business->staff->first(fn ($member) => $member->role?->name === 'Owner')) - -
-
-

{{ $business->name }}

Active
-

{{ $business->primaryDomain?->hostname ?: 'No primary domain' }} · {{ $business->slug }}

-
-

{{ $owner?->user?->email ?: 'Owner setup pending' }}

{{ $business->staff_count }} staff · {{ $business->domains_count }} domain{{ $business->domains_count === 1 ? '' : 's' }}

-
-
- @empty -

No businesses yet

Create the first tenant to begin onboarding an owner.

Create a business
- @endforelse -
-
-
diff --git a/routes/web.php b/routes/web.php index 1f5e061..1dee872 100644 --- a/routes/web.php +++ b/routes/web.php @@ -15,10 +15,6 @@ use App\Http\Controllers\CustomerAppointmentsController; use App\Http\Controllers\OwnerTeamController; use App\Http\Controllers\OwnerHoursController; use App\Http\Controllers\InstallController; -use App\Http\Controllers\PlatformAuthController; -use App\Http\Controllers\ManagerDashboardController; -use App\Http\Controllers\ManagerBusinessController; -use App\Http\Controllers\OwnerInvitationController; use App\Http\Controllers\CustomerProfileController; use App\Http\Controllers\OwnerReportsController; use App\Http\Controllers\OwnerReviewsController; @@ -27,12 +23,6 @@ use Illuminate\Support\Facades\Route; Route::get('/install', [InstallController::class, 'create'])->name('install'); Route::post('/install', [InstallController::class, 'store'])->name('install.store'); -Route::get('/manager/sign-in', [PlatformAuthController::class, 'showLogin'])->name('manager.login'); -Route::post('/manager/sign-in', [PlatformAuthController::class, 'login'])->name('manager.login.store'); -Route::get('/invite/{token}', [OwnerInvitationController::class, 'show'])->name('owner.invite'); -Route::post('/invite/{token}', [OwnerInvitationController::class, 'accept'])->name('owner.invite.accept'); -Route::post('/sign-out', [AuthController::class, 'logout'])->middleware('auth')->name('auth.signout'); - Route::middleware('tenant')->group(function (): void { Route::get('/', PublicBusinessController::class)->name('public.home'); Route::get('/welcome', [AuthController::class, 'showWelcome'])->name('auth.welcome'); @@ -48,6 +38,7 @@ Route::middleware('tenant')->group(function (): void { Route::get('/create-account', [AuthController::class, 'showRegister'])->name('register'); Route::get('/forgot-password', [AuthController::class, 'showForgot'])->name('auth.forgot'); Route::post('/forgot-password', [AuthController::class, 'forgot'])->name('auth.forgot.store'); + Route::post('/sign-out', [AuthController::class, 'logout'])->middleware('auth')->name('auth.signout'); Route::get('/book', [BookingController::class, 'services'])->name('booking.services'); Route::post('/book', [BookingController::class, 'selectServices'])->name('booking.services.store'); Route::get('/book/barber-time', [BookingController::class, 'time'])->name('booking.time'); @@ -88,11 +79,6 @@ Route::middleware('tenant')->group(function (): void { Route::post('/owner/settings/reviews/{review}/toggle', [OwnerReviewsController::class, 'toggle'])->middleware(['auth', 'staff.permission:MANAGE_REVIEWS'])->name('owner.reviews.toggle'); }); -Route::prefix('manager')->name('manager.')->middleware('platform.admin')->group(function (): void { - Route::get('/', ManagerDashboardController::class)->name('dashboard'); - Route::get('/businesses/create', [ManagerBusinessController::class, 'create'])->name('businesses.create'); - Route::post('/businesses', [ManagerBusinessController::class, 'store'])->name('businesses.store'); - Route::get('/businesses/{business}', [ManagerBusinessController::class, 'show'])->name('businesses.show'); - Route::get('/businesses/{business}/edit', [ManagerBusinessController::class, 'edit'])->name('businesses.edit'); - Route::put('/businesses/{business}', [ManagerBusinessController::class, 'update'])->name('businesses.update'); +Route::prefix('manager')->name('manager.')->group(function (): void { + Route::view('/', 'manager.placeholder')->name('dashboard'); }); diff --git a/tests/Feature/InstallationTest.php b/tests/Feature/InstallationTest.php index 6d1b8a3..3cb5e3a 100644 --- a/tests/Feature/InstallationTest.php +++ b/tests/Feature/InstallationTest.php @@ -3,8 +3,6 @@ namespace Tests\Feature; use App\Models\ApplicationInstallation; -use App\Models\Business; -use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -12,41 +10,25 @@ class InstallationTest extends TestCase { use RefreshDatabase; - public function test_a_clean_database_creates_only_a_platform_super_admin(): void + public function test_a_clean_database_can_be_installed_once(): void { - $this->get('/install')->assertOk()->assertSee('Create the platform administrator'); + $this->get('/install')->assertOk()->assertSee('Create your business'); $response = $this->post('/install', [ - 'adminName' => 'Platform Admin', - 'adminEmail' => 'admin@example.test', + 'businessName' => 'Installed Shop', + 'slug' => 'installed-shop', + 'domain' => 'localhost', + 'adminName' => 'First Owner', + 'adminEmail' => 'first-owner@example.test', 'password' => 'password123', 'password_confirmation' => 'password123', ]); - $response->assertRedirect(route('manager.dashboard')); - $this->assertDatabaseHas('ApplicationInstallation', [ - 'id' => 'singleton', - 'status' => 'INSTALLED', - 'primaryBusinessId' => null, - ]); - $this->assertDatabaseHas('User', ['email' => 'admin@example.test', 'isSuperAdmin' => 1]); - $this->assertSame(0, Business::count()); - $this->get('/install')->assertRedirect(route('manager.dashboard')); + $response->assertRedirect(route('install')); + $this->assertDatabaseHas('ApplicationInstallation', ['id' => 'singleton', 'status' => 'INSTALLED']); + $this->assertDatabaseHas('Business', ['slug' => 'installed-shop']); + $this->assertDatabaseHas('User', ['email' => 'first-owner@example.test']); + $this->get('/install')->assertRedirect(route('public.home')); $this->assertSame(1, ApplicationInstallation::count()); } - - public function test_installation_cannot_be_repeated(): void - { - $admin = User::factory()->create(['isSuperAdmin' => true]); - ApplicationInstallation::create(['id' => 'singleton', 'status' => 'INSTALLED', 'installedAt' => now(), 'initialAdminUserId' => $admin->id]); - - $this->post('/install', [ - 'adminName' => 'Another Admin', - 'adminEmail' => 'another@example.test', - 'password' => 'password123', - 'password_confirmation' => 'password123', - ])->assertRedirect(route('manager.login')); - - $this->assertDatabaseMissing('User', ['email' => 'another@example.test']); - } } diff --git a/tests/Feature/TenantAndAuthTest.php b/tests/Feature/TenantAndAuthTest.php index 0eb1baa..afbb9e6 100644 --- a/tests/Feature/TenantAndAuthTest.php +++ b/tests/Feature/TenantAndAuthTest.php @@ -6,7 +6,6 @@ use App\Models\Business; use App\Models\BusinessDomain; use App\Models\CustomerProfile; use App\Models\User; -use App\Services\Businesses\BusinessProvisioningService; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -14,98 +13,63 @@ class TenantAndAuthTest extends TestCase { use RefreshDatabase; - public function test_manager_is_restricted_to_platform_super_admins(): void - { - $this->get('/manager')->assertRedirect(route('manager.login')); - - $member = User::factory()->create(['isSuperAdmin' => false]); - $this->actingAs($member)->get('/manager')->assertForbidden(); - - $admin = User::factory()->create(['isSuperAdmin' => true]); - $this->actingAs($admin)->get('/manager')->assertOk()->assertSee('Businesses'); - } - - public function test_manager_provisions_an_isolated_business_and_owner_invitation(): void - { - $admin = User::factory()->create(['isSuperAdmin' => true]); - - $response = $this->actingAs($admin)->post('/manager/businesses', [ - 'name' => 'New Chair Studio', - 'shortName' => 'New Chair', - 'slug' => 'new-chair-studio', - 'domain' => 'new-chair.test', - 'email' => 'hello@new-chair.test', - 'timezone' => 'Europe/London', - 'currency' => 'GBP', - 'ownerName' => 'Owner Person', - 'ownerEmail' => 'owner@new-chair.test', - ]); - - $business = Business::where('slug', 'new-chair-studio')->firstOrFail(); - $response->assertRedirect(route('manager.businesses.show', $business)); - $this->assertDatabaseHas('BusinessSettings', ['businessId' => $business->id]); - $this->assertDatabaseHas('BookingRule', ['businessId' => $business->id]); - $this->assertSame(7, $business->hours()->count()); - $this->assertDatabaseHas('Role', ['businessId' => $business->id, 'name' => 'Owner']); - $this->assertDatabaseHas('StaffInvitation', ['businessId' => $business->id, 'email' => 'owner@new-chair.test']); - $this->assertDatabaseMissing('User', ['email' => 'owner@new-chair.test', 'isSuperAdmin' => 1]); - } - - public function test_owner_invitation_is_one_time_and_redirects_to_the_business_domain(): void - { - $result = $this->app->make(BusinessProvisioningService::class)->provision([ - 'name' => 'Invite Shop', 'slug' => 'invite-shop', 'domain' => 'invite.test', - ], ['name' => 'Invited Owner', 'email' => 'invited-owner@example.test']); - - $this->get('http://invite.test/invite/'.$result->ownerInvitation->token) - ->assertOk() - ->assertSee('Finish your owner setup'); - - $this->post('http://invite.test/invite/'.$result->ownerInvitation->token, [ - 'password' => 'password123', - 'password_confirmation' => 'password123', - ])->assertRedirect('http://invite.test/owner'); - - $this->assertNotNull($result->ownerInvitation->fresh()->acceptedAt); - $this->assertAuthenticatedAs(User::where('email', 'invited-owner@example.test')->firstOrFail()); - $this->get('http://invite.test/invite/'.$result->ownerInvitation->token)->assertStatus(410); - } - public function test_the_host_selects_only_its_business(): void { - $provisioner = $this->app->make(BusinessProvisioningService::class); - $first = $provisioner->provision([ - 'name' => 'First Barbershop', 'slug' => 'first-barbershop', 'domain' => 'first.test', - ], ['name' => 'First Owner', 'email' => 'first-owner@example.test']); - $second = $provisioner->provision([ - 'name' => 'Second Barbershop', 'slug' => 'second-barbershop', 'domain' => 'second.test', - ], ['name' => 'Second Owner', 'email' => 'second-owner@example.test']); + $this->seed(); - $this->get('http://first.test/')->assertOk()->assertSee('First Barbershop')->assertDontSee('Second Barbershop'); - $this->get('http://second.test/')->assertOk()->assertSee('Second Barbershop')->assertDontSee('First Barbershop'); - $this->assertNotSame($first->business->id, $second->business->id); + $other = Business::create([ + 'slug' => 'other-barbershop', + 'name' => 'Other Barbershop', + 'timezone' => 'Europe/London', + 'currency' => 'GBP', + 'locale' => 'en-GB', + ]); + BusinessDomain::create([ + 'businessId' => $other->id, + 'hostname' => 'other.test', + 'isPrimary' => true, + 'isActive' => true, + ]); + + $this->get('http://localhost/') + ->assertOk() + ->assertSee('Blake Heritage Barbershop') + ->assertSee('Classic Haircut') + ->assertDontSeeText('Other Barbershop'); + + $this->get('http://other.test/') + ->assertOk() + ->assertSee('Other Barbershop') + ->assertDontSeeText('Classic Haircut'); } public function test_registration_creates_a_customer_profile_in_the_current_tenant(): void { - $business = $this->app->make(BusinessProvisioningService::class)->provision([ - 'name' => 'Customer Shop', 'slug' => 'customer-shop', 'domain' => 'customer.test', - ], ['name' => 'Shop Owner', 'email' => 'shop-owner@example.test'])->business; + $this->seed(); - $response = $this->post('http://customer.test/register', [ - 'firstName' => 'Taylor', - 'lastName' => 'Member', - 'email' => 'taylor@example.test', - 'phone' => '020 7000 0000', - 'password' => 'password123', - 'password_confirmation' => 'password123', - ]); + $response = $this->withHeaders(['Host' => 'localhost']) + ->post('/register', [ + 'firstName' => 'Taylor', + 'lastName' => 'Member', + 'email' => 'taylor@example.test', + 'phone' => '020 7000 0000', + 'password' => 'password123', + 'password_confirmation' => 'password123', + ]); $response->assertRedirect(route('customer.dashboard')); $this->assertAuthenticated(); - $this->assertDatabaseHas('CustomerProfile', ['email' => 'taylor@example.test', 'isGuest' => 0, 'businessId' => $business->id]); - $this->get('http://customer.test/account')->assertOk()->assertSee('Good to see you, Taylor'); - $this->get('http://customer.test/owner')->assertForbidden(); + + $this->assertDatabaseHas('User', ['email' => 'taylor@example.test']); + $this->assertDatabaseHas('CustomerProfile', [ + 'email' => 'taylor@example.test', + 'isGuest' => 0, + 'businessId' => Business::where('slug', 'demo-barbershop')->value('id'), + ]); + $this->assertSame(1, CustomerProfile::where('email', 'taylor@example.test')->count()); + $this->withHeaders(['Host' => 'localhost'])->get('/account')->assertOk()->assertSee('Good to see you, Taylor'); + $this->withHeaders(['Host' => 'localhost'])->get('/owner')->assertForbidden(); + $this->assertNotNull(User::where('email', 'taylor@example.test')->first()); } }