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

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