From f11404c5744c971a2ab96a398c20b5918cee99ac Mon Sep 17 00:00:00 2001 From: Stefan Lazic Date: Sat, 5 Sep 2026 09:11:24 +0100 Subject: [PATCH] Management UI --- .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 | 118 ++++++++----- 29 files changed, 876 insertions(+), 89 deletions(-) create mode 100644 app/Http/Controllers/ManagerBusinessController.php create mode 100644 app/Http/Controllers/ManagerDashboardController.php create mode 100644 app/Http/Controllers/OwnerInvitationController.php create mode 100644 app/Http/Controllers/PlatformAuthController.php create mode 100644 app/Http/Middleware/EnsureSuperAdmin.php create mode 100644 app/Services/Businesses/BusinessProvisioningResult.php create mode 100644 app/Services/Businesses/BusinessProvisioningService.php create mode 100644 app/Services/Tenant/DomainNormalizer.php create mode 100644 resources/views/auth/owner-invite.blade.php create mode 100644 resources/views/auth/platform-sign-in.blade.php create mode 100644 resources/views/components/layouts/manager.blade.php create mode 100644 resources/views/manager/businesses/create.blade.php create mode 100644 resources/views/manager/businesses/edit.blade.php create mode 100644 resources/views/manager/businesses/show.blade.php create mode 100644 resources/views/manager/dashboard.blade.php diff --git a/.env.example b/.env.example index e3e82cd..2cc5f50 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,6 @@ 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 bdfbc33..2ef2a3d 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,31 @@ php artisan serve ``` Configure the MySQL connection in `.env` before running migrations. The -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. +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 +``` ## Docker deployment @@ -59,6 +81,10 @@ 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 7cca55b..36423da 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -42,6 +42,10 @@ class AuthController extends Controller $request->session()->regenerate(); + if ($request->user()->isSuperAdmin) { + return redirect()->intended(route('manager.dashboard')); + } + return redirect()->intended(route('customer.dashboard')); } @@ -100,10 +104,11 @@ 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('public.home'); + return redirect()->route($wasSuperAdmin ? 'manager.login' : 'public.home'); } } diff --git a/app/Http/Controllers/InstallController.php b/app/Http/Controllers/InstallController.php index 36eb8db..49c1804 100644 --- a/app/Http/Controllers/InstallController.php +++ b/app/Http/Controllers/InstallController.php @@ -2,39 +2,75 @@ namespace App\Http\Controllers; -use App\Enums\Permission; use App\Enums\InstallationStatus; -use App\Models\{ApplicationInstallation, BookingRule, Business, BusinessDomain, BusinessSettings, Role, RolePermission, StaffProfile, User}; +use App\Models\ApplicationInstallation; +use App\Models\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(): View|RedirectResponse + public function create(Request $request): View|RedirectResponse { - if (ApplicationInstallation::query()->where('status', InstallationStatus::Installed)->exists()) return redirect()->route('public.home'); + if ($this->isInstalled()) { + return $request->user()?->isSuperAdmin + ? redirect()->route('manager.dashboard') + : redirect()->route('manager.login'); + } + return view('install'); } public function store(Request $request): RedirectResponse { - 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']]); + if ($this->isInstalled()) { + return redirect()->route('manager.login'); + } - 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]]); + $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; }); - return redirect()->route('install')->with('status', 'Installation complete. You can now sign in.'); + + 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; + } } } diff --git a/app/Http/Controllers/ManagerBusinessController.php b/app/Http/Controllers/ManagerBusinessController.php new file mode 100644 index 0000000..a89d5f2 --- /dev/null +++ b/app/Http/Controllers/ManagerBusinessController.php @@ -0,0 +1,127 @@ +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 new file mode 100644 index 0000000..1a08996 --- /dev/null +++ b/app/Http/Controllers/ManagerDashboardController.php @@ -0,0 +1,23 @@ +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 new file mode 100644 index 0000000..d607ae3 --- /dev/null +++ b/app/Http/Controllers/OwnerInvitationController.php @@ -0,0 +1,60 @@ +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 new file mode 100644 index 0000000..2a8ad89 --- /dev/null +++ b/app/Http/Controllers/PlatformAuthController.php @@ -0,0 +1,44 @@ +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 new file mode 100644 index 0000000..76c35bb --- /dev/null +++ b/app/Http/Middleware/EnsureSuperAdmin.php @@ -0,0 +1,21 @@ +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 55dd8a1..4e109f8 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,26 +14,25 @@ class ResolveTenant { public function handle(Request $request, Closure $next): Response { - $hostname = strtolower(rtrim($request->getHost(), '.')); + $hostname = null; 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 beb9be5..0c6ed52 100644 --- a/app/Models/Business.php +++ b/app/Models/Business.php @@ -31,6 +31,7 @@ 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 new file mode 100644 index 0000000..c6955e8 --- /dev/null +++ b/app/Services/Businesses/BusinessProvisioningResult.php @@ -0,0 +1,14 @@ + $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 new file mode 100644 index 0000000..b70f3cb --- /dev/null +++ b/app/Services/Tenant/DomainNormalizer.php @@ -0,0 +1,32 @@ + 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 8d518b4..3d0cc62 100644 --- a/app/Services/Tenant/TenantContext.php +++ b/app/Services/Tenant/TenantContext.php @@ -13,6 +13,11 @@ 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 3592241..75d5018 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -6,6 +6,7 @@ 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( @@ -17,6 +18,7 @@ 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 fe2aedc..961e54d 100644 --- a/config/app.php +++ b/config/app.php @@ -15,8 +15,6 @@ 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 afcbc54..ab13b4e 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -27,6 +27,12 @@ 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 new file mode 100644 index 0000000..0c40d18 --- /dev/null +++ b/resources/views/auth/owner-invite.blade.php @@ -0,0 +1,20 @@ + +
+

{{ $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 new file mode 100644 index 0000000..731b61d --- /dev/null +++ b/resources/views/auth/platform-sign-in.blade.php @@ -0,0 +1,20 @@ + +
+

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 new file mode 100644 index 0000000..58e8303 --- /dev/null +++ b/resources/views/components/layouts/manager.blade.php @@ -0,0 +1,21 @@ + + + + + + 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 9734f65..2d7bbb8 100644 --- a/resources/views/install.blade.php +++ b/resources/views/install.blade.php @@ -1 +1,26 @@ -

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
+ +
+

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 +
+
+
diff --git a/resources/views/manager/businesses/create.blade.php b/resources/views/manager/businesses/create.blade.php new file mode 100644 index 0000000..82fa35a --- /dev/null +++ b/resources/views/manager/businesses/create.blade.php @@ -0,0 +1,22 @@ + +
+ ← 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 new file mode 100644 index 0000000..256e5aa --- /dev/null +++ b/resources/views/manager/businesses/edit.blade.php @@ -0,0 +1,8 @@ + +
+ ← {{ $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 new file mode 100644 index 0000000..a1732d3 --- /dev/null +++ b/resources/views/manager/businesses/show.blade.php @@ -0,0 +1,10 @@ + +
+ ← 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 new file mode 100644 index 0000000..0fc68b5 --- /dev/null +++ b/resources/views/manager/dashboard.blade.php @@ -0,0 +1,39 @@ + +
+
+
+

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 1dee872..1f5e061 100644 --- a/routes/web.php +++ b/routes/web.php @@ -15,6 +15,10 @@ 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; @@ -23,6 +27,12 @@ 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'); @@ -38,7 +48,6 @@ 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'); @@ -79,6 +88,11 @@ 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.')->group(function (): void { - Route::view('/', 'manager.placeholder')->name('dashboard'); +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'); }); diff --git a/tests/Feature/InstallationTest.php b/tests/Feature/InstallationTest.php index 3cb5e3a..6d1b8a3 100644 --- a/tests/Feature/InstallationTest.php +++ b/tests/Feature/InstallationTest.php @@ -3,6 +3,8 @@ namespace Tests\Feature; use App\Models\ApplicationInstallation; +use App\Models\Business; +use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -10,25 +12,41 @@ class InstallationTest extends TestCase { use RefreshDatabase; - public function test_a_clean_database_can_be_installed_once(): void + public function test_a_clean_database_creates_only_a_platform_super_admin(): void { - $this->get('/install')->assertOk()->assertSee('Create your business'); + $this->get('/install')->assertOk()->assertSee('Create the platform administrator'); $response = $this->post('/install', [ - 'businessName' => 'Installed Shop', - 'slug' => 'installed-shop', - 'domain' => 'localhost', - 'adminName' => 'First Owner', - 'adminEmail' => 'first-owner@example.test', + 'adminName' => 'Platform Admin', + 'adminEmail' => 'admin@example.test', 'password' => 'password123', 'password_confirmation' => 'password123', ]); - $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')); + $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')); $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 afbb9e6..0eb1baa 100644 --- a/tests/Feature/TenantAndAuthTest.php +++ b/tests/Feature/TenantAndAuthTest.php @@ -6,6 +6,7 @@ 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; @@ -13,63 +14,98 @@ class TenantAndAuthTest extends TestCase { use RefreshDatabase; - public function test_the_host_selects_only_its_business(): void + public function test_manager_is_restricted_to_platform_super_admins(): void { - $this->seed(); + $this->get('/manager')->assertRedirect(route('manager.login')); - $other = Business::create([ - 'slug' => 'other-barbershop', - 'name' => 'Other Barbershop', + $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', - 'locale' => 'en-GB', - ]); - BusinessDomain::create([ - 'businessId' => $other->id, - 'hostname' => 'other.test', - 'isPrimary' => true, - 'isActive' => true, + 'ownerName' => 'Owner Person', + 'ownerEmail' => 'owner@new-chair.test', ]); - $this->get('http://localhost/') - ->assertOk() - ->assertSee('Blake Heritage Barbershop') - ->assertSee('Classic Haircut') - ->assertDontSeeText('Other Barbershop'); + $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]); + } - $this->get('http://other.test/') + 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('Other Barbershop') - ->assertDontSeeText('Classic Haircut'); + ->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->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); } public function test_registration_creates_a_customer_profile_in_the_current_tenant(): void { - $this->seed(); + $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; - $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 = $this->post('http://customer.test/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('User', ['email' => 'taylor@example.test']); - $this->assertDatabaseHas('CustomerProfile', [ - 'email' => 'taylor@example.test', - 'isGuest' => 0, - 'businessId' => Business::where('slug', 'demo-barbershop')->value('id'), - ]); - + $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->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()); } }