Revert "Management UI"

This reverts commit f11404c574.
This commit is contained in:
Stefan Lazic committed 2026-09-05 09:31:59 +01:00
1 parent 3e052e56b1
commit 33fb14b773
29 files changed
+94 -881

No files matched your search

+1
View File
@@ -3,6 +3,7 @@ APP_ENV=local
APP_KEY= APP_KEY=
APP_DEBUG=true APP_DEBUG=true
APP_URL=http://localhost:8085 APP_URL=http://localhost:8085
APP_DEFAULT_BUSINESS_SLUG=
APP_LOCALE=en APP_LOCALE=en
APP_FALLBACK_LOCALE=en APP_FALLBACK_LOCALE=en
+3 -29
View File
@@ -21,31 +21,9 @@ php artisan serve
``` ```
Configure the MySQL connection in `.env` before running migrations. The Configure the MySQL connection in `.env` before running migrations. The
application resolves a business only from the request hostname through the application resolves a business from the request hostname through the
`business_domains` table; it never guesses a tenant from the first business `business_domains` table. Local development can use a configured local domain
in the database. For local multi-tenant work, map each configured hostname to or the `APP_DEFAULT_BUSINESS_SLUG` fallback.
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 ## Docker deployment
@@ -81,10 +59,6 @@ docker compose exec app php artisan key:generate
docker compose exec app php artisan migrate 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 Migrations are intentionally not run by container startup. To inspect the
application logs: application logs:
+1 -6
View File
@@ -42,10 +42,6 @@ class AuthController extends Controller
$request->session()->regenerate(); $request->session()->regenerate();
if ($request->user()->isSuperAdmin) {
return redirect()->intended(route('manager.dashboard'));
}
return redirect()->intended(route('customer.dashboard')); return redirect()->intended(route('customer.dashboard'));
} }
@@ -104,11 +100,10 @@ class AuthController extends Controller
public function logout(Request $request): RedirectResponse public function logout(Request $request): RedirectResponse
{ {
$wasSuperAdmin = $request->user()?->isSuperAdmin === true;
Auth::logout(); Auth::logout();
$request->session()->invalidate(); $request->session()->invalidate();
$request->session()->regenerateToken(); $request->session()->regenerateToken();
return redirect()->route($wasSuperAdmin ? 'manager.login' : 'public.home'); return redirect()->route('public.home');
} }
} }
+17 -53
View File
@@ -2,75 +2,39 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Enums\Permission;
use App\Enums\InstallationStatus; use App\Enums\InstallationStatus;
use App\Models\ApplicationInstallation; use App\Models\{ApplicationInstallation, BookingRule, Business, BusinessDomain, BusinessSettings, Role, RolePermission, StaffProfile, User};
use App\Models\User;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\View\View; use Illuminate\View\View;
class InstallController extends Controller class InstallController extends Controller
{ {
public function create(Request $request): View|RedirectResponse public function create(): View|RedirectResponse
{ {
if ($this->isInstalled()) { if (ApplicationInstallation::query()->where('status', InstallationStatus::Installed)->exists()) return redirect()->route('public.home');
return $request->user()?->isSuperAdmin
? redirect()->route('manager.dashboard')
: redirect()->route('manager.login');
}
return view('install'); return view('install');
} }
public function store(Request $request): RedirectResponse public function store(Request $request): RedirectResponse
{ {
if ($this->isInstalled()) { if (ApplicationInstallation::query()->where('status', InstallationStatus::Installed)->exists()) return redirect()->route('public.home');
return redirect()->route('manager.login'); $data = $request->validate(['businessName' => ['required', 'string', 'max:190'], 'slug' => ['required', 'alpha_dash', 'max:80', 'unique:Business,slug'], 'adminName' => ['required', 'string', 'max:190'], 'adminEmail' => ['required', 'email', 'max:190', 'unique:User,email'], 'password' => ['required', 'string', 'min:8', 'confirmed'], 'domain' => ['nullable', 'string', 'max:190']]);
}
$data = $request->validate([ DB::transaction(function () use ($data, $request): void {
'adminName' => ['required', 'string', 'max:160'], $business = Business::create(['slug' => strtolower($data['slug']), 'name' => $data['businessName'], 'shortName' => $data['businessName'], 'timezone' => config('app.timezone', 'Europe/London'), 'currency' => 'GBP', 'locale' => 'en-GB']);
'adminEmail' => ['required', 'email', 'max:190', 'unique:User,email'], BusinessDomain::create(['businessId' => $business->id, 'hostname' => strtolower($data['domain'] ?: $request->getHost()), 'isPrimary' => true, 'isActive' => true]);
'password' => ['required', 'string', 'min:8', 'confirmed'], BusinessSettings::create(['businessId' => $business->id]);
]); BookingRule::create(['businessId' => $business->id, 'allowGuestBooking' => true]);
$role = Role::create(['businessId' => $business->id, 'name' => 'Owner', 'isSystem' => true]);
$admin = DB::transaction(function () use ($data): User { foreach (Permission::cases() as $permission) RolePermission::create(['roleId' => $role->id, 'permission' => $permission]);
$admin = User::create([ $user = User::create(['name' => $data['adminName'], 'email' => strtolower($data['adminEmail']), 'passwordHash' => Hash::make($data['password']), 'emailVerified' => now()]);
'name' => trim($data['adminName']), $staff = StaffProfile::create(['userId' => $user->id, 'businessId' => $business->id, 'roleId' => $role->id, 'status' => 'ACTIVE', 'isBookableOnline' => false]);
'email' => strtolower(trim($data['adminEmail'])), ApplicationInstallation::create(['id' => 'singleton', 'status' => InstallationStatus::Installed, 'installedAt' => now(), 'installedVersion' => 'laravel-1', 'initialAdminUserId' => $user->id, 'primaryBusinessId' => $business->id, 'metadata' => ['starter' => true]]);
'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;
}
} }
} }
@@ -1,127 +0,0 @@
<?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.');
}
}
@@ -1,23 +0,0 @@
<?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(),
]);
}
}
@@ -1,60 +0,0 @@
<?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;
}
}
@@ -1,44 +0,0 @@
<?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
@@ -1,21 +0,0 @@
<?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);
}
}
+8 -7
View File
@@ -2,9 +2,9 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Models\Business;
use App\Models\BusinessDomain; use App\Models\BusinessDomain;
use App\Services\Tenant\TenantContext; use App\Services\Tenant\TenantContext;
use App\Services\Tenant\DomainNormalizer;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -14,25 +14,26 @@ class ResolveTenant
{ {
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {
$hostname = null; $hostname = strtolower(rtrim($request->getHost(), '.'));
try { try {
$hostname = app(DomainNormalizer::class)->normalize($request->getHost());
$business = BusinessDomain::query() $business = BusinessDomain::query()
->where('hostname', $hostname) ->where('hostname', $hostname)
->where('isActive', true) ->where('isActive', true)
->with('business') ->with('business')
->first()?->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) { } catch (Throwable) {
// The install page and framework smoke tests must remain // The install page and framework smoke tests must remain
// reachable before the tenant schema has been created. // reachable before the tenant schema has been created.
$business = null; $business = null;
} }
if (! $business) {
app(TenantContext::class)->forget();
}
if ($business) { if ($business) {
app(TenantContext::class)->set($business); app(TenantContext::class)->set($business);
} }
-1
View File
@@ -31,7 +31,6 @@ class Business extends Model
} }
public function domains(): HasMany { return $this->hasMany(BusinessDomain::class, 'businessId'); } 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 locations(): HasMany { return $this->hasMany(BusinessLocation::class, 'businessId'); }
public function hours(): HasMany { return $this->hasMany(BusinessHours::class, 'businessId'); } public function hours(): HasMany { return $this->hasMany(BusinessHours::class, 'businessId'); }
public function settings(): HasOne { return $this->hasOne(BusinessSettings::class, 'businessId'); } public function settings(): HasOne { return $this->hasOne(BusinessSettings::class, 'businessId'); }
@@ -1,14 +0,0 @@
<?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,
) {}
}
@@ -1,156 +0,0 @@
<?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
@@ -1,32 +0,0 @@
<?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,11 +13,6 @@ final class TenantContext
$this->business = $business; $this->business = $business;
} }
public function forget(): void
{
$this->business = null;
}
public function business(): ?Business public function business(): ?Business
{ {
return $this->business; return $this->business;
-2
View File
@@ -6,7 +6,6 @@ use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Http\Middleware\ResolveTenant; use App\Http\Middleware\ResolveTenant;
use App\Http\Middleware\EnsureStaffPermission; use App\Http\Middleware\EnsureStaffPermission;
use App\Http\Middleware\EnsureSuperAdmin;
return Application::configure(basePath: dirname(__DIR__)) return Application::configure(basePath: dirname(__DIR__))
->withRouting( ->withRouting(
@@ -18,7 +17,6 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->alias([ $middleware->alias([
'tenant' => ResolveTenant::class, 'tenant' => ResolveTenant::class,
'staff.permission' => EnsureStaffPermission::class, 'staff.permission' => EnsureStaffPermission::class,
'platform.admin' => EnsureSuperAdmin::class,
]); ]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
+2
View File
@@ -15,6 +15,8 @@ return [
'name' => env('APP_NAME', 'NextChair'), 'name' => env('APP_NAME', 'NextChair'),
'default_business_slug' => env('APP_DEFAULT_BUSINESS_SLUG'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Application Environment | Application Environment
-6
View File
@@ -27,12 +27,6 @@ class DatabaseSeeder extends Seeder
{ {
public function run(): void 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( $business = Business::updateOrCreate(
['slug' => 'demo-barbershop'], ['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'], ['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'],
@@ -1,20 +0,0 @@
<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>
@@ -1,20 +0,0 @@
<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>
@@ -1,21 +0,0 @@
<!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>
+1 -26
View File
@@ -1,26 +1 @@
<x-layouts.public :business="null"> <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>
<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>
@@ -1,22 +0,0 @@
<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>
@@ -1,8 +0,0 @@
<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>
@@ -1,10 +0,0 @@
<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>
@@ -1,39 +0,0 @@
<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>
+3 -17
View File
@@ -15,10 +15,6 @@ use App\Http\Controllers\CustomerAppointmentsController;
use App\Http\Controllers\OwnerTeamController; use App\Http\Controllers\OwnerTeamController;
use App\Http\Controllers\OwnerHoursController; use App\Http\Controllers\OwnerHoursController;
use App\Http\Controllers\InstallController; 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\CustomerProfileController;
use App\Http\Controllers\OwnerReportsController; use App\Http\Controllers\OwnerReportsController;
use App\Http\Controllers\OwnerReviewsController; use App\Http\Controllers\OwnerReviewsController;
@@ -27,12 +23,6 @@ use Illuminate\Support\Facades\Route;
Route::get('/install', [InstallController::class, 'create'])->name('install'); Route::get('/install', [InstallController::class, 'create'])->name('install');
Route::post('/install', [InstallController::class, 'store'])->name('install.store'); 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::middleware('tenant')->group(function (): void {
Route::get('/', PublicBusinessController::class)->name('public.home'); Route::get('/', PublicBusinessController::class)->name('public.home');
Route::get('/welcome', [AuthController::class, 'showWelcome'])->name('auth.welcome'); Route::get('/welcome', [AuthController::class, 'showWelcome'])->name('auth.welcome');
@@ -48,6 +38,7 @@ Route::middleware('tenant')->group(function (): void {
Route::get('/create-account', [AuthController::class, 'showRegister'])->name('register'); Route::get('/create-account', [AuthController::class, 'showRegister'])->name('register');
Route::get('/forgot-password', [AuthController::class, 'showForgot'])->name('auth.forgot'); Route::get('/forgot-password', [AuthController::class, 'showForgot'])->name('auth.forgot');
Route::post('/forgot-password', [AuthController::class, 'forgot'])->name('auth.forgot.store'); 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::get('/book', [BookingController::class, 'services'])->name('booking.services');
Route::post('/book', [BookingController::class, 'selectServices'])->name('booking.services.store'); Route::post('/book', [BookingController::class, 'selectServices'])->name('booking.services.store');
Route::get('/book/barber-time', [BookingController::class, 'time'])->name('booking.time'); Route::get('/book/barber-time', [BookingController::class, 'time'])->name('booking.time');
@@ -88,11 +79,6 @@ Route::middleware('tenant')->group(function (): void {
Route::post('/owner/settings/reviews/{review}/toggle', [OwnerReviewsController::class, 'toggle'])->middleware(['auth', 'staff.permission:MANAGE_REVIEWS'])->name('owner.reviews.toggle'); Route::post('/owner/settings/reviews/{review}/toggle', [OwnerReviewsController::class, 'toggle'])->middleware(['auth', 'staff.permission:MANAGE_REVIEWS'])->name('owner.reviews.toggle');
}); });
Route::prefix('manager')->name('manager.')->middleware('platform.admin')->group(function (): void { Route::prefix('manager')->name('manager.')->group(function (): void {
Route::get('/', ManagerDashboardController::class)->name('dashboard'); Route::view('/', 'manager.placeholder')->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');
}); });
+12 -30
View File
@@ -3,8 +3,6 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Models\ApplicationInstallation; use App\Models\ApplicationInstallation;
use App\Models\Business;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase; use Tests\TestCase;
@@ -12,41 +10,25 @@ class InstallationTest extends TestCase
{ {
use RefreshDatabase; use RefreshDatabase;
public function test_a_clean_database_creates_only_a_platform_super_admin(): void public function test_a_clean_database_can_be_installed_once(): void
{ {
$this->get('/install')->assertOk()->assertSee('Create the platform administrator'); $this->get('/install')->assertOk()->assertSee('Create your business');
$response = $this->post('/install', [ $response = $this->post('/install', [
'adminName' => 'Platform Admin', 'businessName' => 'Installed Shop',
'adminEmail' => 'admin@example.test', 'slug' => 'installed-shop',
'domain' => 'localhost',
'adminName' => 'First Owner',
'adminEmail' => 'first-owner@example.test',
'password' => 'password123', 'password' => 'password123',
'password_confirmation' => 'password123', 'password_confirmation' => 'password123',
]); ]);
$response->assertRedirect(route('manager.dashboard')); $response->assertRedirect(route('install'));
$this->assertDatabaseHas('ApplicationInstallation', [ $this->assertDatabaseHas('ApplicationInstallation', ['id' => 'singleton', 'status' => 'INSTALLED']);
'id' => 'singleton', $this->assertDatabaseHas('Business', ['slug' => 'installed-shop']);
'status' => 'INSTALLED', $this->assertDatabaseHas('User', ['email' => 'first-owner@example.test']);
'primaryBusinessId' => null, $this->get('/install')->assertRedirect(route('public.home'));
]);
$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()); $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']);
}
} }
+46 -82
View File
@@ -6,7 +6,6 @@ use App\Models\Business;
use App\Models\BusinessDomain; use App\Models\BusinessDomain;
use App\Models\CustomerProfile; use App\Models\CustomerProfile;
use App\Models\User; use App\Models\User;
use App\Services\Businesses\BusinessProvisioningService;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase; use Tests\TestCase;
@@ -14,98 +13,63 @@ class TenantAndAuthTest extends TestCase
{ {
use RefreshDatabase; use RefreshDatabase;
public function test_manager_is_restricted_to_platform_super_admins(): void
{
$this->get('/manager')->assertRedirect(route('manager.login'));
$member = User::factory()->create(['isSuperAdmin' => false]);
$this->actingAs($member)->get('/manager')->assertForbidden();
$admin = User::factory()->create(['isSuperAdmin' => true]);
$this->actingAs($admin)->get('/manager')->assertOk()->assertSee('Businesses');
}
public function test_manager_provisions_an_isolated_business_and_owner_invitation(): void
{
$admin = User::factory()->create(['isSuperAdmin' => true]);
$response = $this->actingAs($admin)->post('/manager/businesses', [
'name' => 'New Chair Studio',
'shortName' => 'New Chair',
'slug' => 'new-chair-studio',
'domain' => 'new-chair.test',
'email' => 'hello@new-chair.test',
'timezone' => 'Europe/London',
'currency' => 'GBP',
'ownerName' => 'Owner Person',
'ownerEmail' => 'owner@new-chair.test',
]);
$business = Business::where('slug', 'new-chair-studio')->firstOrFail();
$response->assertRedirect(route('manager.businesses.show', $business));
$this->assertDatabaseHas('BusinessSettings', ['businessId' => $business->id]);
$this->assertDatabaseHas('BookingRule', ['businessId' => $business->id]);
$this->assertSame(7, $business->hours()->count());
$this->assertDatabaseHas('Role', ['businessId' => $business->id, 'name' => 'Owner']);
$this->assertDatabaseHas('StaffInvitation', ['businessId' => $business->id, 'email' => 'owner@new-chair.test']);
$this->assertDatabaseMissing('User', ['email' => 'owner@new-chair.test', 'isSuperAdmin' => 1]);
}
public function test_owner_invitation_is_one_time_and_redirects_to_the_business_domain(): void
{
$result = $this->app->make(BusinessProvisioningService::class)->provision([
'name' => 'Invite Shop', 'slug' => 'invite-shop', 'domain' => 'invite.test',
], ['name' => 'Invited Owner', 'email' => 'invited-owner@example.test']);
$this->get('http://invite.test/invite/'.$result->ownerInvitation->token)
->assertOk()
->assertSee('Finish your owner setup');
$this->post('http://invite.test/invite/'.$result->ownerInvitation->token, [
'password' => 'password123',
'password_confirmation' => 'password123',
])->assertRedirect('http://invite.test/owner');
$this->assertNotNull($result->ownerInvitation->fresh()->acceptedAt);
$this->assertAuthenticatedAs(User::where('email', 'invited-owner@example.test')->firstOrFail());
$this->get('http://invite.test/invite/'.$result->ownerInvitation->token)->assertStatus(410);
}
public function test_the_host_selects_only_its_business(): void public function test_the_host_selects_only_its_business(): void
{ {
$provisioner = $this->app->make(BusinessProvisioningService::class); $this->seed();
$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'); $other = Business::create([
$this->get('http://second.test/')->assertOk()->assertSee('Second Barbershop')->assertDontSee('First Barbershop'); 'slug' => 'other-barbershop',
$this->assertNotSame($first->business->id, $second->business->id); 'name' => 'Other Barbershop',
'timezone' => 'Europe/London',
'currency' => 'GBP',
'locale' => 'en-GB',
]);
BusinessDomain::create([
'businessId' => $other->id,
'hostname' => 'other.test',
'isPrimary' => true,
'isActive' => true,
]);
$this->get('http://localhost/')
->assertOk()
->assertSee('Blake Heritage Barbershop')
->assertSee('Classic Haircut')
->assertDontSeeText('Other Barbershop');
$this->get('http://other.test/')
->assertOk()
->assertSee('Other Barbershop')
->assertDontSeeText('Classic Haircut');
} }
public function test_registration_creates_a_customer_profile_in_the_current_tenant(): void public function test_registration_creates_a_customer_profile_in_the_current_tenant(): void
{ {
$business = $this->app->make(BusinessProvisioningService::class)->provision([ $this->seed();
'name' => 'Customer Shop', 'slug' => 'customer-shop', 'domain' => 'customer.test',
], ['name' => 'Shop Owner', 'email' => 'shop-owner@example.test'])->business;
$response = $this->post('http://customer.test/register', [ $response = $this->withHeaders(['Host' => 'localhost'])
'firstName' => 'Taylor', ->post('/register', [
'lastName' => 'Member', 'firstName' => 'Taylor',
'email' => 'taylor@example.test', 'lastName' => 'Member',
'phone' => '020 7000 0000', 'email' => 'taylor@example.test',
'password' => 'password123', 'phone' => '020 7000 0000',
'password_confirmation' => 'password123', 'password' => 'password123',
]); 'password_confirmation' => 'password123',
]);
$response->assertRedirect(route('customer.dashboard')); $response->assertRedirect(route('customer.dashboard'));
$this->assertAuthenticated(); $this->assertAuthenticated();
$this->assertDatabaseHas('CustomerProfile', ['email' => 'taylor@example.test', 'isGuest' => 0, 'businessId' => $business->id]);
$this->get('http://customer.test/account')->assertOk()->assertSee('Good to see you, Taylor'); $this->assertDatabaseHas('User', ['email' => 'taylor@example.test']);
$this->get('http://customer.test/owner')->assertForbidden(); $this->assertDatabaseHas('CustomerProfile', [
'email' => 'taylor@example.test',
'isGuest' => 0,
'businessId' => Business::where('slug', 'demo-barbershop')->value('id'),
]);
$this->assertSame(1, CustomerProfile::where('email', 'taylor@example.test')->count()); $this->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());
} }
} }