Management UI
CI / ci (push) Canceled after 0s

This commit is contained in:
Stefan Lazic committed 2026-09-05 09:11:24 +01:00
1 parent cd6abeca6e
commit f11404c574
29 files changed
+876 -89

No files matched your search

-1
View File
@@ -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
+29 -3
View File
@@ -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:
+6 -1
View File
@@ -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');
}
}
+53 -17
View File
@@ -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;
}
}
}
@@ -0,0 +1,127 @@
<?php
namespace App\Http\Controllers;
use App\Models\Business;
use App\Models\BusinessDomain;
use App\Services\Businesses\BusinessProvisioningService;
use App\Services\Tenant\DomainNormalizer;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
use InvalidArgumentException;
class ManagerBusinessController extends Controller
{
public function create(): View
{
return view('manager.businesses.create');
}
public function store(Request $request, BusinessProvisioningService $provisioner, 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'],
'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.');
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Http\Controllers;
use App\Models\Business;
use Illuminate\View\View;
class ManagerDashboardController extends Controller
{
public function __invoke(): View
{
$businesses = Business::query()
->with(['primaryDomain', 'staff.user', 'staff.role'])
->withCount(['staff', 'domains'])
->latest('createdAt')
->get();
return view('manager.dashboard', [
'businesses' => $businesses,
'businessCount' => $businesses->count(),
]);
}
}
@@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers;
use App\Models\StaffInvitation;
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 OwnerInvitationController extends Controller
{
public function show(string $token): View|RedirectResponse
{
$invitation = $this->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;
}
}
@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers;
use App\Enums\InstallationStatus;
use App\Models\ApplicationInstallation;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class PlatformAuthController extends Controller
{
public function showLogin(Request $request): View|RedirectResponse
{
if ($request->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'));
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureSuperAdmin
{
public function handle(Request $request, Closure $next): Response
{
if (! $request->user()) {
return redirect()->route('manager.login');
}
abort_unless($request->user()->isSuperAdmin === true, 403);
return $next($request);
}
}
+7 -8
View File
@@ -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);
}
+1
View File
@@ -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'); }
@@ -0,0 +1,14 @@
<?php
namespace App\Services\Businesses;
use App\Models\Business;
use App\Models\StaffInvitation;
final readonly class BusinessProvisioningResult
{
public function __construct(
public Business $business,
public StaffInvitation $ownerInvitation,
) {}
}
@@ -0,0 +1,156 @@
<?php
namespace App\Services\Businesses;
use App\Enums\Permission;
use App\Models\BookingRule;
use App\Models\Business;
use App\Models\BusinessDomain;
use App\Models\BusinessHours;
use App\Models\BusinessSettings;
use App\Models\Role;
use App\Models\RolePermission;
use App\Models\StaffInvitation;
use App\Models\StaffProfile;
use App\Models\User;
use App\Services\Tenant\DomainNormalizer;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
final class BusinessProvisioningService
{
public function __construct(private readonly DomainNormalizer $domains) {}
/**
* Create a complete, isolated tenant and an owner onboarding invitation.
*
* The owner is deliberately created without a password. They can only
* enter the business workspace after completing the one-time invitation.
*
* @param array<string, mixed> $businessData
* @param array<string, mixed> $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<int, Permission> $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;
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Services\Tenant;
use InvalidArgumentException;
final class DomainNormalizer
{
public function normalize(string $value): string
{
$value = trim($value);
if ($value === '') {
throw new InvalidArgumentException('A domain is required.');
}
$candidate = str_contains($value, '://') ? $value : 'http://'.$value;
$host = parse_url($candidate, PHP_URL_HOST);
if (! is_string($host) || $host === '' || str_contains($host, '/')) {
throw new InvalidArgumentException('Enter a valid hostname, such as book.example.com.');
}
$host = strtolower(rtrim($host, '.'));
if (strlen($host) > 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;
}
}
+5
View File
@@ -13,6 +13,11 @@ final class TenantContext
$this->business = $business;
}
public function forget(): void
{
$this->business = null;
}
public function business(): ?Business
{
return $this->business;
+2
View File
@@ -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 {
-2
View File
@@ -15,8 +15,6 @@ return [
'name' => env('APP_NAME', 'NextChair'),
'default_business_slug' => env('APP_DEFAULT_BUSINESS_SLUG'),
/*
|--------------------------------------------------------------------------
| Application Environment
+6
View File
@@ -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'],
@@ -0,0 +1,20 @@
<x-layouts.public :business="$business">
<main class="mx-auto flex min-h-screen max-w-md flex-col justify-center px-6 py-16">
<p class="text-[10px] font-bold uppercase tracking-[0.3em] text-accent-foreground">{{ $business->name }}</p>
<h1 class="mt-3 text-4xl font-black tracking-tight">Finish your owner setup</h1>
<p class="mt-4 text-sm leading-6 text-muted">Choose a password for {{ $invitation->staff->user->email }} to access the business workspace.</p>
@if ($errors->any())
<div class="mt-6 rounded-tile border border-red-200 bg-red-50 p-4 text-sm text-red-700">{{ $errors->first() }}</div>
@endif
<form method="POST" action="{{ route('owner.invite.accept', $invitation->token) }}" class="mt-8 space-y-5">
@csrf
<label class="block text-sm font-bold">Password
<input name="password" type="password" minlength="8" required class="mt-2 w-full rounded-tile border border-line bg-white px-4 py-3">
</label>
<label class="block text-sm font-bold">Confirm password
<input name="password_confirmation" type="password" minlength="8" required class="mt-2 w-full rounded-tile border border-line bg-white px-4 py-3">
</label>
<x-ui.button type="submit" class="w-full">Open owner workspace</x-ui.button>
</form>
</main>
</x-layouts.public>
@@ -0,0 +1,20 @@
<x-layouts.public :business="null">
<main class="mx-auto flex min-h-screen max-w-md flex-col justify-center px-6 py-16">
<p class="text-[10px] font-bold uppercase tracking-[0.3em] text-accent-foreground">NextChair Manager</p>
<h1 class="mt-3 text-4xl font-black tracking-tight">Platform sign in</h1>
<p class="mt-4 text-sm leading-6 text-muted">Sign in with the platform administrator account to manage businesses.</p>
@if ($errors->any())
<div class="mt-6 rounded-tile border border-red-200 bg-red-50 p-4 text-sm text-red-700">{{ $errors->first() }}</div>
@endif
<form method="POST" action="{{ route('manager.login.store') }}" class="mt-8 space-y-5">
@csrf
<label class="block text-sm font-bold">Email
<input name="email" value="{{ old('email') }}" type="email" required autofocus class="mt-2 w-full rounded-tile border border-line bg-white px-4 py-3">
</label>
<label class="block text-sm font-bold">Password
<input name="password" type="password" required class="mt-2 w-full rounded-tile border border-line bg-white px-4 py-3">
</label>
<x-ui.button type="submit" class="w-full">Sign in</x-ui.button>
</form>
</main>
</x-layouts.public>
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Manager · {{ config('app.name') }}</title>
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="min-h-screen bg-stone text-ink">
<header class="border-b border-line bg-white">
<div class="mx-auto flex max-w-7xl items-center justify-between px-5 py-5 sm:px-8 lg:px-12">
<a href="{{ route('manager.dashboard') }}" class="text-sm font-black tracking-tight">NextChair <span class="font-normal text-muted">Manager</span></a>
<div class="flex items-center gap-4 text-xs font-bold uppercase tracking-widest">
<span class="hidden text-muted sm:inline">{{ auth()->user()->name }}</span>
<form method="POST" action="{{ route('auth.signout') }}">@csrf<button class="text-muted hover:text-ink">Sign out</button></form>
</div>
</div>
</header>
{{ $slot }}
</body>
</html>
+26 -1
View File
@@ -1 +1,26 @@
<x-layouts.public :business="null"><main class="mx-auto flex min-h-screen max-w-xl flex-col justify-center px-5 py-16 lg:px-10"><p class="text-[10px] font-bold uppercase tracking-[0.3em] text-accent-foreground">NextChair setup</p><h1 class="mt-3 text-4xl font-black tracking-tight">Create your business</h1><p class="mt-4 text-sm leading-6 text-muted">Set up the first business owner and start configuring your booking workspace.</p>@if (session('status'))<div class="mt-6 rounded-tile bg-accent p-4 text-sm font-bold text-ink">{{ session('status') }}</div><a href="{{ route('auth.signin') }}" class="mt-6 text-sm font-bold text-accent-foreground underline">Sign in</a>@else @if ($errors->any())<div class="mt-6 rounded-tile border border-red-200 bg-red-50 p-4 text-sm text-red-700">{{ $errors->first() }}</div>@endif<form method="POST" action="{{ route('install.store') }}" class="mt-8 space-y-5">@csrf<label class="block text-sm font-bold">Business name<input name="businessName" required class="mt-2 w-full rounded-tile border border-line bg-white px-4 py-3"></label><label class="block text-sm font-bold">Business slug<input name="slug" required placeholder="my-barbershop" class="mt-2 w-full rounded-tile border border-line bg-white px-4 py-3"></label><label class="block text-sm font-bold">Domain <span class="font-normal text-muted">(optional)</span><input name="domain" placeholder="book.example.com" class="mt-2 w-full rounded-tile border border-line bg-white px-4 py-3"></label><div class="grid gap-5 sm:grid-cols-2"><label class="block text-sm font-bold">Owner name<input name="adminName" required class="mt-2 w-full rounded-tile border border-line bg-white px-4 py-3"></label><label class="block text-sm font-bold">Owner email<input name="adminEmail" type="email" required class="mt-2 w-full rounded-tile border border-line bg-white px-4 py-3"></label></div><label class="block text-sm font-bold">Password<input name="password" type="password" required class="mt-2 w-full rounded-tile border border-line bg-white px-4 py-3"></label><label class="block text-sm font-bold">Confirm password<input name="password_confirmation" type="password" required class="mt-2 w-full rounded-tile border border-line bg-white px-4 py-3"></label><x-ui.button type="submit" class="w-full">Finish setup</x-ui.button></form>@endif</main></x-layouts.public>
<x-layouts.public :business="null">
<main class="mx-auto flex min-h-screen max-w-xl flex-col justify-center px-5 py-16 lg:px-10">
<p class="text-[10px] font-bold uppercase tracking-[0.3em] text-accent-foreground">NextChair setup</p>
<h1 class="mt-3 text-4xl font-black tracking-tight">Create the platform administrator</h1>
<p class="mt-4 text-sm leading-6 text-muted">This first account manages the NextChair platform. Businesses and their owners are created later from the protected Manager area.</p>
@if ($errors->any())
<div class="mt-6 rounded-tile border border-red-200 bg-red-50 p-4 text-sm text-red-700">{{ $errors->first() }}</div>
@endif
<form method="POST" action="{{ route('install.store') }}" class="mt-8 space-y-5">
@csrf
<label class="block text-sm font-bold">Your name
<input name="adminName" value="{{ old('adminName') }}" required class="mt-2 w-full rounded-tile border border-line bg-white px-4 py-3">
</label>
<label class="block text-sm font-bold">Email
<input name="adminEmail" value="{{ old('adminEmail') }}" type="email" required class="mt-2 w-full rounded-tile border border-line bg-white px-4 py-3">
</label>
<label class="block text-sm font-bold">Password
<input name="password" type="password" required class="mt-2 w-full rounded-tile border border-line bg-white px-4 py-3">
</label>
<label class="block text-sm font-bold">Confirm password
<input name="password_confirmation" type="password" required class="mt-2 w-full rounded-tile border border-line bg-white px-4 py-3">
</label>
<x-ui.button type="submit" class="w-full">Finish platform setup</x-ui.button>
</form>
</main>
</x-layouts.public>
@@ -0,0 +1,22 @@
<x-layouts.manager>
<main class="mx-auto max-w-4xl px-5 py-10 sm:px-8 lg:px-12 lg:py-14">
<a href="{{ route('manager.dashboard') }}" class="text-xs font-bold uppercase tracking-widest text-muted">← Businesses</a>
<div class="mt-10"><p class="text-[10px] font-bold uppercase tracking-[0.3em] text-accent-foreground">New tenant</p><h1 class="mt-3 text-4xl font-black tracking-tight">Create a business</h1><p class="mt-3 max-w-2xl text-sm leading-6 text-muted">Provision the tenant, its defaults, and the owner’s secure setup invitation together.</p></div>
@if ($errors->any())<div class="mt-8 rounded-tile border border-red-200 bg-red-50 p-4 text-sm text-red-700">{{ $errors->first() }}</div>@endif
<form method="POST" action="{{ route('manager.businesses.store') }}" class="mt-8 space-y-8">
@csrf
<section class="rounded-jumbo border border-line bg-white p-6 sm:p-8"><h2 class="text-lg font-black">Business details</h2><div class="mt-6 grid gap-5 sm:grid-cols-2">
<label class="block text-sm font-bold sm:col-span-2">Business name<input name="name" value="{{ old('name') }}" required class="mt-2 w-full rounded-tile border border-line px-4 py-3"></label>
<label class="block text-sm font-bold">Short name <span class="font-normal text-muted">(optional)</span><input name="shortName" value="{{ old('shortName') }}" class="mt-2 w-full rounded-tile border border-line px-4 py-3"></label>
<label class="block text-sm font-bold">Slug<input name="slug" value="{{ old('slug') }}" placeholder="blake-heritage" required class="mt-2 w-full rounded-tile border border-line px-4 py-3"></label>
<label class="block text-sm font-bold sm:col-span-2">Primary domain<input name="domain" value="{{ old('domain') }}" placeholder="book.example.com" required class="mt-2 w-full rounded-tile border border-line px-4 py-3"><span class="mt-2 block text-xs font-normal text-muted">This hostname routes public and owner requests to this tenant.</span></label>
<label class="block text-sm font-bold">Business email<input name="email" value="{{ old('email') }}" type="email" class="mt-2 w-full rounded-tile border border-line px-4 py-3"></label>
<label class="block text-sm font-bold">Phone<input name="phone" value="{{ old('phone') }}" class="mt-2 w-full rounded-tile border border-line px-4 py-3"></label>
<label class="block text-sm font-bold">Timezone<select name="timezone" class="mt-2 w-full rounded-tile border border-line bg-white px-4 py-3">@foreach (DateTimeZone::listIdentifiers() as $timezone)<option value="{{ $timezone }}" @selected(old('timezone', 'Europe/London') === $timezone)>{{ $timezone }}</option>@endforeach</select></label>
<label class="block text-sm font-bold">Currency<input name="currency" value="{{ old('currency', 'GBP') }}" maxlength="3" required class="mt-2 w-full rounded-tile border border-line px-4 py-3"></label>
</div></section>
<section class="rounded-jumbo border border-line bg-white p-6 sm:p-8"><h2 class="text-lg font-black">Business owner</h2><p class="mt-2 text-sm text-muted">The owner receives a one-time link to choose their password.</p><div class="mt-6 grid gap-5 sm:grid-cols-2"><label class="block text-sm font-bold">Owner name<input name="ownerName" value="{{ old('ownerName') }}" required class="mt-2 w-full rounded-tile border border-line px-4 py-3"></label><label class="block text-sm font-bold">Owner email<input name="ownerEmail" value="{{ old('ownerEmail') }}" type="email" required class="mt-2 w-full rounded-tile border border-line px-4 py-3"></label></div></section>
<div class="flex justify-end"><x-ui.button type="submit">Provision business</x-ui.button></div>
</form>
</main>
</x-layouts.manager>
@@ -0,0 +1,8 @@
<x-layouts.manager>
<main class="mx-auto max-w-4xl px-5 py-10 sm:px-8 lg:px-12 lg:py-14">
<a href="{{ route('manager.businesses.show', $business) }}" class="text-xs font-bold uppercase tracking-widest text-muted">← {{ $business->name }}</a>
<div class="mt-10"><p class="text-[10px] font-bold uppercase tracking-[0.3em] text-accent-foreground">Tenant settings</p><h1 class="mt-3 text-4xl font-black tracking-tight">Edit business</h1></div>
@if ($errors->any())<div class="mt-8 rounded-tile border border-red-200 bg-red-50 p-4 text-sm text-red-700">{{ $errors->first() }}</div>@endif
<form method="POST" action="{{ route('manager.businesses.update', $business) }}" class="mt-8 rounded-jumbo border border-line bg-white p-6 sm:p-8">@csrf @method('PUT')<div class="grid gap-5 sm:grid-cols-2"><label class="block text-sm font-bold sm:col-span-2">Business name<input name="name" value="{{ old('name', $business->name) }}" required class="mt-2 w-full rounded-tile border border-line px-4 py-3"></label><label class="block text-sm font-bold">Short name<input name="shortName" value="{{ old('shortName', $business->shortName) }}" class="mt-2 w-full rounded-tile border border-line px-4 py-3"></label><label class="block text-sm font-bold">Slug<input name="slug" value="{{ old('slug', $business->slug) }}" required class="mt-2 w-full rounded-tile border border-line px-4 py-3"></label><label class="block text-sm font-bold sm:col-span-2">Primary domain<input name="domain" value="{{ old('domain', $business->primaryDomain?->hostname) }}" required class="mt-2 w-full rounded-tile border border-line px-4 py-3"></label><label class="block text-sm font-bold">Business email<input name="email" value="{{ old('email', $business->email) }}" type="email" class="mt-2 w-full rounded-tile border border-line px-4 py-3"></label><label class="block text-sm font-bold">Phone<input name="phone" value="{{ old('phone', $business->phone) }}" class="mt-2 w-full rounded-tile border border-line px-4 py-3"></label><label class="block text-sm font-bold">Timezone<select name="timezone" class="mt-2 w-full rounded-tile border border-line bg-white px-4 py-3">@foreach (DateTimeZone::listIdentifiers() as $timezone)<option value="{{ $timezone }}" @selected(old('timezone', $business->timezone) === $timezone)>{{ $timezone }}</option>@endforeach</select></label><label class="block text-sm font-bold">Currency<input name="currency" value="{{ old('currency', $business->currency) }}" maxlength="3" required class="mt-2 w-full rounded-tile border border-line px-4 py-3"></label></div><div class="mt-8 flex justify-end"><x-ui.button type="submit">Save changes</x-ui.button></div></form>
</main>
</x-layouts.manager>
@@ -0,0 +1,10 @@
<x-layouts.manager>
<main class="mx-auto max-w-6xl px-5 py-10 sm:px-8 lg:px-12 lg:py-14">
<a href="{{ route('manager.dashboard') }}" class="text-xs font-bold uppercase tracking-widest text-muted">← Businesses</a>
<div class="mt-10 flex flex-col justify-between gap-5 sm:flex-row sm:items-end"><div><p class="text-[10px] font-bold uppercase tracking-[0.3em] text-accent-foreground">Business tenant</p><h1 class="mt-3 text-4xl font-black tracking-tight">{{ $business->name }}</h1><p class="mt-3 text-sm text-muted">{{ $business->slug }}</p></div><x-ui.button variant="secondary" href="{{ route('manager.businesses.edit', $business) }}">Edit business</x-ui.button></div>
@if (session('status'))<div class="mt-8 rounded-tile bg-accent p-4 text-sm font-bold text-ink">{{ session('status') }}</div>@endif
@if (session('owner_invitation_url'))<div class="mt-4 rounded-jumbo border border-amber-200 bg-amber-50 p-6"><p class="text-xs font-bold uppercase tracking-widest text-amber-900">Owner setup link</p><p class="mt-2 text-sm text-amber-900">Share this link securely with the business owner. It expires after seven days and can only be used once.</p><code class="mt-4 block break-all rounded-tile bg-white p-3 text-xs text-ink">{{ session('owner_invitation_url') }}</code></div>@endif
<section class="mt-10 grid gap-4 sm:grid-cols-3"><x-ui.card class="p-5"><p class="text-[10px] font-bold uppercase tracking-widest text-muted">Primary domain</p><p class="mt-3 font-black">{{ $business->primaryDomain?->hostname ?: 'Not configured' }}</p></x-ui.card><x-ui.card class="p-5"><p class="text-[10px] font-bold uppercase tracking-widest text-muted">Timezone</p><p class="mt-3 font-black">{{ $business->timezone }}</p></x-ui.card><x-ui.card class="p-5"><p class="text-[10px] font-bold uppercase tracking-widest text-muted">Defaults</p><p class="mt-3 font-black">{{ $business->hours->count() }} days · {{ $business->staff->count() }} staff</p></x-ui.card></section>
<div class="mt-10 grid gap-8 lg:grid-cols-2"><section><h2 class="text-lg font-black">Domains</h2><div class="mt-4 space-y-3">@foreach ($business->domains as $domain)<div class="flex items-center justify-between rounded-tile border border-line bg-white p-4"><div><p class="font-bold">{{ $domain->hostname }}</p><p class="mt-1 text-xs text-muted">{{ $domain->isPrimary ? 'Primary' : 'Additional' }}</p></div><x-ui.badge tone="{{ $domain->isActive ? 'accent' : 'default' }}">{{ $domain->isActive ? 'Active' : 'Inactive' }}</x-ui.badge></div>@endforeach</div></section><section><h2 class="text-lg font-black">Staff and owners</h2><div class="mt-4 space-y-3">@foreach ($business->staff as $member)<div class="rounded-tile border border-line bg-white p-4"><p class="font-bold">{{ $member->user?->name ?: $member->user?->email }}</p><p class="mt-1 text-xs text-muted">{{ $member->role?->name }} · {{ $member->user?->email }}</p></div>@endforeach</div></section></div>
</main>
</x-layouts.manager>
@@ -0,0 +1,39 @@
<x-layouts.manager>
<main class="mx-auto max-w-7xl px-5 py-10 sm:px-8 lg:px-12 lg:py-14">
<div class="flex flex-col justify-between gap-5 sm:flex-row sm:items-end">
<div>
<p class="text-[10px] font-bold uppercase tracking-[0.3em] text-accent-foreground">Platform operations</p>
<h1 class="mt-3 text-4xl font-black tracking-tight">Businesses</h1>
<p class="mt-3 text-sm text-muted">Create and maintain isolated NextChair tenants.</p>
</div>
<x-ui.button href="{{ route('manager.businesses.create') }}">Create business</x-ui.button>
</div>
@if (session('status'))
<div class="mt-8 rounded-tile bg-accent p-4 text-sm font-bold text-ink">{{ session('status') }}</div>
@endif
<section class="mt-10 grid gap-4 sm:grid-cols-3">
<x-ui.card class="p-5"><p class="text-[10px] font-bold uppercase tracking-widest text-muted">Businesses</p><p class="mt-3 text-3xl font-black">{{ $businessCount }}</p></x-ui.card>
<x-ui.card class="p-5"><p class="text-[10px] font-bold uppercase tracking-widest text-muted">Tenant model</p><p class="mt-3 text-lg font-black">Domain routed</p></x-ui.card>
<x-ui.card class="p-5"><p class="text-[10px] font-bold uppercase tracking-widest text-muted">Provisioning</p><p class="mt-3 text-lg font-black">Owner invited</p></x-ui.card>
</section>
<section class="mt-10 space-y-3">
@forelse ($businesses as $business)
@php($owner = $business->staff->first(fn ($member) => $member->role?->name === 'Owner'))
<a href="{{ route('manager.businesses.show', $business) }}" class="block rounded-jumbo border border-line bg-white p-6 transition hover:border-ink">
<div class="flex flex-col justify-between gap-5 sm:flex-row sm:items-center">
<div>
<div class="flex flex-wrap items-center gap-3"><h2 class="text-xl font-black">{{ $business->name }}</h2><x-ui.badge tone="accent">Active</x-ui.badge></div>
<p class="mt-2 text-sm text-muted">{{ $business->primaryDomain?->hostname ?: 'No primary domain' }} · {{ $business->slug }}</p>
</div>
<div class="text-left text-sm sm:text-right"><p class="font-bold">{{ $owner?->user?->email ?: 'Owner setup pending' }}</p><p class="mt-1 text-xs text-muted">{{ $business->staff_count }} staff · {{ $business->domains_count }} domain{{ $business->domains_count === 1 ? '' : 's' }}</p></div>
</div>
</a>
@empty
<div class="rounded-jumbo border border-dashed border-line bg-white p-12 text-center"><p class="text-lg font-bold">No businesses yet</p><p class="mt-2 text-sm text-muted">Create the first tenant to begin onboarding an owner.</p><a href="{{ route('manager.businesses.create') }}" class="mt-5 inline-block text-xs font-bold uppercase tracking-widest underline">Create a business</a></div>
@endforelse
</section>
</main>
</x-layouts.manager>
+17 -3
View File
@@ -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');
});
+30 -12
View File
@@ -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']);
}
}
+77 -41
View File
@@ -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());
}
}