diff --git a/app/app/Http/Middleware/ResolveCurrentWorkspace.php b/app/app/Http/Middleware/ResolveCurrentWorkspace.php new file mode 100644 index 0000000..0ec612c --- /dev/null +++ b/app/app/Http/Middleware/ResolveCurrentWorkspace.php @@ -0,0 +1,60 @@ + query param (for switching) + * 2. Session 'workspace_id' (sticky after switch) + * 3. authenticated user's current_workspace_id + * 4. authenticated user's first owned workspace + */ +class ResolveCurrentWorkspace +{ + public function handle(Request $request, Closure $next): Response + { + $user = $request->user(); + if (! $user) { + return $next($request); + } + + $ws = null; + + if ($slug = $request->query('workspace')) { + $ws = \App\Models\Workspace::where('slug', $slug)->first(); + if ($ws) { + $request->session()->put('workspace_id', $ws->id); + $user->update(['current_workspace_id' => $ws->id]); + } + } + + if (! $ws && $sid = $request->session()->get('workspace_id')) { + $ws = \App\Models\Workspace::find($sid); + } + + if (! $ws && $user->current_workspace_id) { + $ws = $user->currentWorkspace; + } + + if (! $ws) { + $ws = $user->ownedWorkspaces()->first(); + if ($ws) { + $user->update(['current_workspace_id' => $ws->id]); + } + } + + if ($ws) { + app()->instance('currentWorkspace', $ws); + $request->session()->put('workspace_id', $ws->id); + } + + return $next($request); + } +} diff --git a/app/app/Models/Activity.php b/app/app/Models/Activity.php index ac30358..a1dc5d0 100644 --- a/app/app/Models/Activity.php +++ b/app/app/Models/Activity.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Models\Concerns\BelongsToWorkspace; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -9,9 +10,10 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class Activity extends Model { - use HasFactory, HasUuids; + use BelongsToWorkspace, HasFactory, HasUuids; protected $fillable = [ + 'workspace_id', 'user_id', 'type', 'level', 'message', 'subject', 'meta', 'occurred_at', ]; diff --git a/app/app/Models/Backup.php b/app/app/Models/Backup.php index 5f9c42d..96843f1 100644 --- a/app/app/Models/Backup.php +++ b/app/app/Models/Backup.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Models\Concerns\BelongsToWorkspace; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -9,9 +10,10 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class Backup extends Model { - use HasFactory, HasUuids; + use BelongsToWorkspace, HasFactory, HasUuids; protected $fillable = [ + 'workspace_id', 'site_id', 'type', 'size_bytes', 'status', 'storage_location', 'completed_at', ]; diff --git a/app/app/Models/Certificate.php b/app/app/Models/Certificate.php index 0386551..e04cb8f 100644 --- a/app/app/Models/Certificate.php +++ b/app/app/Models/Certificate.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Models\Concerns\BelongsToWorkspace; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -9,9 +10,10 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class Certificate extends Model { - use HasFactory, HasUuids; + use BelongsToWorkspace, HasFactory, HasUuids; protected $fillable = [ + 'workspace_id', 'site_id', 'domain', 'issuer', 'issued_at', 'expires_at', 'auto_renew', ]; diff --git a/app/app/Models/Cluster.php b/app/app/Models/Cluster.php index 65501a4..b9eac2a 100644 --- a/app/app/Models/Cluster.php +++ b/app/app/Models/Cluster.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Models\Concerns\BelongsToWorkspace; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -9,9 +10,10 @@ use Illuminate\Database\Eloquent\Relations\HasMany; class Cluster extends Model { - use HasFactory, HasUuids; + use BelongsToWorkspace, HasFactory, HasUuids; protected $fillable = [ + 'workspace_id', 'name', 'slug', 'region', 'env', 'monthly_price_cents', 'node_count', 'backups_enabled', ]; diff --git a/app/app/Models/Concerns/BelongsToWorkspace.php b/app/app/Models/Concerns/BelongsToWorkspace.php new file mode 100644 index 0000000..e950686 --- /dev/null +++ b/app/app/Models/Concerns/BelongsToWorkspace.php @@ -0,0 +1,43 @@ +bound('currentWorkspace') ? app('currentWorkspace') : null; + if ($ws instanceof Workspace) { + $q->where($q->getModel()->getTable() . '.workspace_id', $ws->id); + } + }); + + // Auto-fill workspace_id on create if not set. + static::creating(function ($model) { + if (! $model->workspace_id && app()->bound('currentWorkspace')) { + $ws = app('currentWorkspace'); + if ($ws instanceof Workspace) { + $model->workspace_id = $ws->id; + } + } + }); + } + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } +} diff --git a/app/app/Models/Cron.php b/app/app/Models/Cron.php index 07ad420..2956a4e 100644 --- a/app/app/Models/Cron.php +++ b/app/app/Models/Cron.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Models\Concerns\BelongsToWorkspace; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -9,9 +10,10 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class Cron extends Model { - use HasFactory, HasUuids; + use BelongsToWorkspace, HasFactory, HasUuids; protected $fillable = [ + 'workspace_id', 'site_id', 'name', 'expression', 'command', 'last_run_at', 'status', 'enabled', ]; diff --git a/app/app/Models/Invitation.php b/app/app/Models/Invitation.php index a69ba2d..2313fe1 100644 --- a/app/app/Models/Invitation.php +++ b/app/app/Models/Invitation.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Models\Concerns\BelongsToWorkspace; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -10,9 +11,10 @@ use Illuminate\Support\Str; class Invitation extends Model { - use HasFactory, HasUuids; + use BelongsToWorkspace, HasFactory, HasUuids; protected $fillable = [ + 'workspace_id', 'email', 'role', 'invited_by_user_id', 'token', 'sent_at', 'accepted_at', 'expires_at', ]; diff --git a/app/app/Models/SecurityEvent.php b/app/app/Models/SecurityEvent.php index 05a163b..4e5389e 100644 --- a/app/app/Models/SecurityEvent.php +++ b/app/app/Models/SecurityEvent.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Models\Concerns\BelongsToWorkspace; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -9,9 +10,10 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class SecurityEvent extends Model { - use HasFactory, HasUuids; + use BelongsToWorkspace, HasFactory, HasUuids; protected $fillable = [ + 'workspace_id', 'site_id', 'type', 'severity', 'blocked', 'source_ip', 'country', 'message', 'meta', 'occurred_at', ]; diff --git a/app/app/Models/Server.php b/app/app/Models/Server.php index 0b59487..36166b3 100644 --- a/app/app/Models/Server.php +++ b/app/app/Models/Server.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Models\Concerns\BelongsToWorkspace; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -10,9 +11,10 @@ use Illuminate\Database\Eloquent\Relations\HasMany; class Server extends Model { - use HasFactory, HasUuids; + use BelongsToWorkspace, HasFactory, HasUuids; protected $fillable = [ + 'workspace_id', 'cluster_id', 'name', 'region', 'country_flag', 'env', 'cpu_cores', 'ram_gb', 'disk_gb', 'cpu_load_pct', 'ram_used_pct', 'disk_used_pct', diff --git a/app/app/Models/Site.php b/app/app/Models/Site.php index f63cd28..d94bbfe 100644 --- a/app/app/Models/Site.php +++ b/app/app/Models/Site.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Models\Concerns\BelongsToWorkspace; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -11,9 +12,10 @@ use Illuminate\Database\Eloquent\Relations\HasOne; class Site extends Model { - use HasFactory, HasUuids; + use BelongsToWorkspace, HasFactory, HasUuids; protected $fillable = [ + 'workspace_id', 'server_id', 'domain', 'name', 'wp_version', 'php_version', 'status', 'visitors_24h', 'response_ms', diff --git a/app/app/Models/TeamMember.php b/app/app/Models/TeamMember.php index c740f12..5c40f6b 100644 --- a/app/app/Models/TeamMember.php +++ b/app/app/Models/TeamMember.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Models\Concerns\BelongsToWorkspace; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -9,9 +10,10 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class TeamMember extends Model { - use HasFactory, HasUuids; + use BelongsToWorkspace, HasFactory, HasUuids; protected $fillable = [ + 'workspace_id', 'user_id', 'role', 'scope', 'two_fa_enabled', 'last_active_at', ]; diff --git a/app/app/Models/User.php b/app/app/Models/User.php index d2d2e04..3dfb76c 100644 --- a/app/app/Models/User.php +++ b/app/app/Models/User.php @@ -23,8 +23,20 @@ class User extends Authenticatable 'name', 'email', 'password', + 'current_workspace_id', ]; + public function currentWorkspace(): \Illuminate\Database\Eloquent\Relations\BelongsTo + { + return $this->belongsTo(Workspace::class, 'current_workspace_id'); + } + + /** Workspaces this user owns. */ + public function ownedWorkspaces(): \Illuminate\Database\Eloquent\Relations\HasMany + { + return $this->hasMany(Workspace::class, 'owner_user_id'); + } + /** * The attributes that should be hidden for serialization. * diff --git a/app/app/Models/Workspace.php b/app/app/Models/Workspace.php new file mode 100644 index 0000000..e18c9f3 --- /dev/null +++ b/app/app/Models/Workspace.php @@ -0,0 +1,75 @@ + 'datetime', + 'quotas_override' => 'array', + ]; + + /** Plan-defaults (quotas before any override). */ + public const PLAN_QUOTAS = [ + 'free' => ['sites' => 1, 'servers' => 0, 'team' => 1, 'storage_gb' => 5, 'features' => []], + 'starter' => ['sites' => 10, 'servers' => 1, 'team' => 3, 'storage_gb' => 50, 'features' => ['staging', 'daily_backups']], + 'pro' => ['sites' => 100, 'servers' => 5, 'team' => 10, 'storage_gb' => 500, 'features' => ['staging', 'hourly_backups', 'audit', 'monitoring', 'email_relay']], + 'agency' => ['sites' => 1000, 'servers' => 999, 'team' => 25, 'storage_gb' => 5000, 'features' => ['staging', 'hourly_backups', 'audit', 'monitoring', 'email_relay', 'multi_workspace', 'white_label', 'reports', 'sso']], + 'enterprise' => ['sites' => 99999,'servers' => 9999,'team' => 9999,'storage_gb' => 50000, 'features' => ['*']], + ]; + + public function owner(): BelongsTo + { + return $this->belongsTo(User::class, 'owner_user_id'); + } + + public function sites(): HasMany { return $this->hasMany(Site::class); } + public function servers(): HasMany { return $this->hasMany(Server::class); } + public function clusters(): HasMany { return $this->hasMany(Cluster::class); } + public function backups(): HasMany { return $this->hasMany(Backup::class); } + public function members(): HasMany { return $this->hasMany(TeamMember::class); } + public function invitations(): HasMany { return $this->hasMany(Invitation::class); } + public function activities(): HasMany { return $this->hasMany(Activity::class); } + + public function getQuotasAttribute(): array + { + $base = self::PLAN_QUOTAS[$this->plan] ?? self::PLAN_QUOTAS['free']; + return array_merge($base, $this->quotas_override ?? []); + } + + public function hasFeature(string $feature): bool + { + $features = $this->quotas['features'] ?? []; + return in_array('*', $features, true) || in_array($feature, $features, true); + } + + public function isOverQuota(string $resource): bool + { + $count = match ($resource) { + 'sites' => $this->sites()->count(), + 'servers' => $this->servers()->count(), + 'team' => $this->members()->count(), + default => 0, + }; + return $count >= ($this->quotas[$resource] ?? 0); + } + + public function isOnTrial(): bool + { + return $this->trial_ends_at && $this->trial_ends_at->isFuture(); + } +} diff --git a/app/bootstrap/app.php b/app/bootstrap/app.php index 9cee609..5d4183f 100644 --- a/app/bootstrap/app.php +++ b/app/bootstrap/app.php @@ -18,6 +18,11 @@ return Application::configure(basePath: dirname(__DIR__)) \Illuminate\Http\Request::HEADER_X_FORWARDED_PORT | \Illuminate\Http\Request::HEADER_X_FORWARDED_PROTO ); + + // Bind active workspace for the rest of the request stack. + $middleware->web(append: [ + \App\Http\Middleware\ResolveCurrentWorkspace::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/app/database/factories/ActivityFactory.php b/app/database/factories/ActivityFactory.php index 54f2e17..8361099 100644 --- a/app/database/factories/ActivityFactory.php +++ b/app/database/factories/ActivityFactory.php @@ -2,6 +2,8 @@ namespace Database\Factories; + +use App\Models\Workspace; use App\Models\Activity; use Illuminate\Database\Eloquent\Factories\Factory; @@ -12,6 +14,7 @@ class ActivityFactory extends Factory public function definition(): array { return [ + 'workspace_id' => Workspace::factory(), 'user_id' => null, 'type' => $this->faker->randomElement(['deploy', 'backup', 'update', 'security', 'system']), 'level' => $this->faker->randomElement(['info', 'success', 'success', 'warning', 'error']), diff --git a/app/database/factories/BackupFactory.php b/app/database/factories/BackupFactory.php index aae4895..940bcce 100644 --- a/app/database/factories/BackupFactory.php +++ b/app/database/factories/BackupFactory.php @@ -2,6 +2,8 @@ namespace Database\Factories; + +use App\Models\Workspace; use App\Models\Backup; use App\Models\Site; use Illuminate\Database\Eloquent\Factories\Factory; @@ -14,6 +16,7 @@ class BackupFactory extends Factory { $completedAt = $this->faker->dateTimeBetween('-7 days', 'now'); return [ + 'workspace_id' => Workspace::factory(), 'site_id' => Site::factory(), 'type' => $this->faker->randomElement(['hourly', 'daily', 'manual', 'snapshot']), 'size_bytes' => $this->faker->numberBetween(50_000_000, 2_000_000_000), diff --git a/app/database/factories/CertificateFactory.php b/app/database/factories/CertificateFactory.php index fe95541..e5721dc 100644 --- a/app/database/factories/CertificateFactory.php +++ b/app/database/factories/CertificateFactory.php @@ -2,6 +2,8 @@ namespace Database\Factories; + +use App\Models\Workspace; use App\Models\Certificate; use App\Models\Site; use Illuminate\Database\Eloquent\Factories\Factory; @@ -14,6 +16,7 @@ class CertificateFactory extends Factory { $issued = $this->faker->dateTimeBetween('-60 days', '-1 days'); return [ + 'workspace_id' => Workspace::factory(), 'site_id' => Site::factory(), 'domain' => $this->faker->domainName(), 'issuer' => "Let's Encrypt", diff --git a/app/database/factories/ClusterFactory.php b/app/database/factories/ClusterFactory.php index 3d9ac09..a30f224 100644 --- a/app/database/factories/ClusterFactory.php +++ b/app/database/factories/ClusterFactory.php @@ -2,6 +2,8 @@ namespace Database\Factories; + +use App\Models\Workspace; use App\Models\Cluster; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Str; @@ -14,6 +16,7 @@ class ClusterFactory extends Factory { $name = $this->faker->unique()->company(); return [ + 'workspace_id' => Workspace::factory(), 'name' => $name, 'slug' => Str::slug($name), 'region' => $this->faker->randomElement(['eu-central-1', 'eu-west-1', 'us-east-1', 'ap-south-1']), diff --git a/app/database/factories/CronFactory.php b/app/database/factories/CronFactory.php index 4cf05b3..6620690 100644 --- a/app/database/factories/CronFactory.php +++ b/app/database/factories/CronFactory.php @@ -2,6 +2,8 @@ namespace Database\Factories; + +use App\Models\Workspace; use App\Models\Cron; use App\Models\Site; use Illuminate\Database\Eloquent\Factories\Factory; @@ -13,6 +15,7 @@ class CronFactory extends Factory public function definition(): array { return [ + 'workspace_id' => Workspace::factory(), 'site_id' => Site::factory(), 'name' => $this->faker->randomElement(['Backup nightly', 'Cache flush', 'WP cron', 'Sitemap rebuild']), 'expression' => $this->faker->randomElement(['0 * * * *', '*/15 * * * *', '0 2 * * *', '*/5 * * * *']), diff --git a/app/database/factories/InvitationFactory.php b/app/database/factories/InvitationFactory.php index 8f218b1..d957777 100644 --- a/app/database/factories/InvitationFactory.php +++ b/app/database/factories/InvitationFactory.php @@ -2,6 +2,8 @@ namespace Database\Factories; + +use App\Models\Workspace; use App\Models\Invitation; use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; @@ -13,6 +15,7 @@ class InvitationFactory extends Factory public function definition(): array { return [ + 'workspace_id' => Workspace::factory(), 'email' => $this->faker->unique()->safeEmail(), 'role' => $this->faker->randomElement(['admin', 'developer', 'viewer']), 'invited_by_user_id' => User::factory(), diff --git a/app/database/factories/SecurityEventFactory.php b/app/database/factories/SecurityEventFactory.php index d5a7829..75897bd 100644 --- a/app/database/factories/SecurityEventFactory.php +++ b/app/database/factories/SecurityEventFactory.php @@ -2,6 +2,8 @@ namespace Database\Factories; + +use App\Models\Workspace; use App\Models\SecurityEvent; use App\Models\Site; use Illuminate\Database\Eloquent\Factories\Factory; @@ -13,6 +15,7 @@ class SecurityEventFactory extends Factory public function definition(): array { return [ + 'workspace_id' => Workspace::factory(), 'site_id' => Site::factory(), 'type' => $this->faker->randomElement(['brute_force', 'tor_exit', 'malware', 'firewall', 'scan']), 'severity' => $this->faker->randomElement(['info', 'low', 'medium', 'high', 'critical']), diff --git a/app/database/factories/ServerFactory.php b/app/database/factories/ServerFactory.php index 4e55114..21f1593 100644 --- a/app/database/factories/ServerFactory.php +++ b/app/database/factories/ServerFactory.php @@ -2,6 +2,8 @@ namespace Database\Factories; + +use App\Models\Workspace; use App\Models\Cluster; use App\Models\Server; use Illuminate\Database\Eloquent\Factories\Factory; @@ -14,6 +16,7 @@ class ServerFactory extends Factory { $env = $this->faker->randomElement(['prod', 'staging', 'edge']); return [ + 'workspace_id' => Workspace::factory(), 'cluster_id' => Cluster::factory(), 'name' => $env . '-' . $this->faker->unique()->numerify('node-##'), 'region' => $this->faker->randomElement(['eu-central-1', 'eu-west-1', 'us-east-1', 'ap-south-1']), diff --git a/app/database/factories/SiteFactory.php b/app/database/factories/SiteFactory.php index b02bdaf..c5a131a 100644 --- a/app/database/factories/SiteFactory.php +++ b/app/database/factories/SiteFactory.php @@ -2,6 +2,8 @@ namespace Database\Factories; + +use App\Models\Workspace; use App\Models\Server; use App\Models\Site; use Illuminate\Database\Eloquent\Factories\Factory; @@ -14,6 +16,7 @@ class SiteFactory extends Factory { $domain = $this->faker->unique()->domainName(); return [ + 'workspace_id' => Workspace::factory(), 'server_id' => Server::factory(), 'domain' => $domain, 'name' => ucfirst(explode('.', $domain)[0]), diff --git a/app/database/factories/TeamMemberFactory.php b/app/database/factories/TeamMemberFactory.php index 3ba16cc..7481cd0 100644 --- a/app/database/factories/TeamMemberFactory.php +++ b/app/database/factories/TeamMemberFactory.php @@ -2,6 +2,8 @@ namespace Database\Factories; + +use App\Models\Workspace; use App\Models\TeamMember; use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; @@ -13,6 +15,7 @@ class TeamMemberFactory extends Factory public function definition(): array { return [ + 'workspace_id' => Workspace::factory(), 'user_id' => User::factory(), 'role' => $this->faker->randomElement(['admin', 'developer', 'developer', 'viewer']), 'scope' => $this->faker->randomElement(['All clusters', 'eu-prod-*', 'edge-*', 'staging-*']), diff --git a/app/database/factories/WorkspaceFactory.php b/app/database/factories/WorkspaceFactory.php new file mode 100644 index 0000000..fad7386 --- /dev/null +++ b/app/database/factories/WorkspaceFactory.php @@ -0,0 +1,31 @@ +faker->unique()->company(); + return [ + 'name' => $name, + 'slug' => Str::slug($name) . '-' . Str::random(4), + 'owner_user_id' => User::factory(), + 'plan' => 'pro', + 'region' => 'eu-central-1', + 'billing_email' => $this->faker->companyEmail(), + ]; + } + + public function free(): self { return $this->state(['plan' => 'free']); } + public function starter(): self { return $this->state(['plan' => 'starter']); } + public function pro(): self { return $this->state(['plan' => 'pro']); } + public function agency(): self { return $this->state(['plan' => 'agency']); } +} diff --git a/app/database/migrations/2026_05_31_190000_create_workspaces_table.php b/app/database/migrations/2026_05_31_190000_create_workspaces_table.php new file mode 100644 index 0000000..b7fd6fd --- /dev/null +++ b/app/database/migrations/2026_05_31_190000_create_workspaces_table.php @@ -0,0 +1,30 @@ +uuid('id')->primary(); + $table->string('name'); + $table->string('slug')->unique(); // acme-gmbh → acme-gmbh.clupilot.io + $table->foreignUuid('owner_user_id')->constrained('users')->cascadeOnDelete(); + $table->enum('plan', ['free', 'starter', 'pro', 'agency', 'enterprise'])->default('free'); + $table->timestamp('trial_ends_at')->nullable(); + $table->string('logo_url')->nullable(); + $table->string('billing_email')->nullable(); + $table->string('region', 32)->default('eu-central-1'); + $table->json('quotas_override')->nullable(); // per-workspace overrides + $table->timestamps(); + $table->index(['plan']); + }); + } + + public function down(): void + { + Schema::dropIfExists('workspaces'); + } +}; diff --git a/app/database/migrations/2026_05_31_195000_add_current_workspace_to_users.php b/app/database/migrations/2026_05_31_195000_add_current_workspace_to_users.php new file mode 100644 index 0000000..5c8639a --- /dev/null +++ b/app/database/migrations/2026_05_31_195000_add_current_workspace_to_users.php @@ -0,0 +1,24 @@ +foreignUuid('current_workspace_id')->nullable() + ->after('email_verified_at') + ->constrained('workspaces')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropConstrainedForeignId('current_workspace_id'); + }); + } +}; diff --git a/app/database/migrations/2026_05_31_200000_create_clusters_table.php b/app/database/migrations/2026_05_31_200000_create_clusters_table.php index b0c5d64..cf69406 100644 --- a/app/database/migrations/2026_05_31_200000_create_clusters_table.php +++ b/app/database/migrations/2026_05_31_200000_create_clusters_table.php @@ -9,6 +9,7 @@ return new class extends Migration { { Schema::create('clusters', function (Blueprint $table) { $table->uuid('id')->primary(); + $table->foreignUuid('workspace_id')->constrained()->cascadeOnDelete(); $table->string('name'); $table->string('slug')->unique(); $table->string('region', 32); // e.g. eu-central-1 diff --git a/app/database/migrations/2026_05_31_200001_create_servers_table.php b/app/database/migrations/2026_05_31_200001_create_servers_table.php index fe6c779..8915e02 100644 --- a/app/database/migrations/2026_05_31_200001_create_servers_table.php +++ b/app/database/migrations/2026_05_31_200001_create_servers_table.php @@ -9,6 +9,7 @@ return new class extends Migration { { Schema::create('servers', function (Blueprint $table) { $table->uuid('id')->primary(); + $table->foreignUuid('workspace_id')->constrained()->cascadeOnDelete(); $table->foreignUuid('cluster_id')->nullable()->constrained()->nullOnDelete(); $table->string('name', 64); // eu-prod-01 $table->string('region', 32); // eu-central-1 diff --git a/app/database/migrations/2026_05_31_200002_create_sites_table.php b/app/database/migrations/2026_05_31_200002_create_sites_table.php index 3c187fd..7fe40c9 100644 --- a/app/database/migrations/2026_05_31_200002_create_sites_table.php +++ b/app/database/migrations/2026_05_31_200002_create_sites_table.php @@ -9,6 +9,7 @@ return new class extends Migration { { Schema::create('sites', function (Blueprint $table) { $table->uuid('id')->primary(); + $table->foreignUuid('workspace_id')->constrained()->cascadeOnDelete(); $table->foreignUuid('server_id')->constrained()->cascadeOnDelete(); $table->string('domain')->unique(); $table->string('name')->nullable(); diff --git a/app/database/migrations/2026_05_31_200003_create_backups_table.php b/app/database/migrations/2026_05_31_200003_create_backups_table.php index 7d57467..b2ecbe8 100644 --- a/app/database/migrations/2026_05_31_200003_create_backups_table.php +++ b/app/database/migrations/2026_05_31_200003_create_backups_table.php @@ -9,6 +9,7 @@ return new class extends Migration { { Schema::create('backups', function (Blueprint $table) { $table->uuid('id')->primary(); + $table->foreignUuid('workspace_id')->constrained()->cascadeOnDelete(); $table->foreignUuid('site_id')->constrained()->cascadeOnDelete(); $table->enum('type', ['hourly', 'daily', 'manual', 'snapshot'])->default('daily'); $table->unsignedBigInteger('size_bytes')->default(0); diff --git a/app/database/migrations/2026_05_31_200004_create_team_members_table.php b/app/database/migrations/2026_05_31_200004_create_team_members_table.php index a7b5412..dea0920 100644 --- a/app/database/migrations/2026_05_31_200004_create_team_members_table.php +++ b/app/database/migrations/2026_05_31_200004_create_team_members_table.php @@ -9,13 +9,14 @@ return new class extends Migration { { Schema::create('team_members', function (Blueprint $table) { $table->uuid('id')->primary(); + $table->foreignUuid('workspace_id')->constrained()->cascadeOnDelete(); $table->foreignUuid('user_id')->constrained()->cascadeOnDelete(); $table->enum('role', ['owner', 'admin', 'developer', 'viewer'])->default('viewer'); $table->string('scope')->nullable(); // e.g. "All clusters", "eu-prod-*" $table->boolean('two_fa_enabled')->default(false); $table->timestamp('last_active_at')->nullable(); $table->timestamps(); - $table->unique('user_id'); + $table->unique(['workspace_id', 'user_id']); $table->index(['role']); }); } diff --git a/app/database/migrations/2026_05_31_200005_create_invitations_table.php b/app/database/migrations/2026_05_31_200005_create_invitations_table.php index e2acdc0..ae585e0 100644 --- a/app/database/migrations/2026_05_31_200005_create_invitations_table.php +++ b/app/database/migrations/2026_05_31_200005_create_invitations_table.php @@ -9,6 +9,7 @@ return new class extends Migration { { Schema::create('invitations', function (Blueprint $table) { $table->uuid('id')->primary(); + $table->foreignUuid('workspace_id')->constrained()->cascadeOnDelete(); $table->string('email'); $table->enum('role', ['admin', 'developer', 'viewer'])->default('developer'); $table->foreignUuid('invited_by_user_id')->nullable()->constrained('users')->nullOnDelete(); diff --git a/app/database/migrations/2026_05_31_200006_create_security_events_table.php b/app/database/migrations/2026_05_31_200006_create_security_events_table.php index 4c50da5..e68c26e 100644 --- a/app/database/migrations/2026_05_31_200006_create_security_events_table.php +++ b/app/database/migrations/2026_05_31_200006_create_security_events_table.php @@ -9,6 +9,7 @@ return new class extends Migration { { Schema::create('security_events', function (Blueprint $table) { $table->uuid('id')->primary(); + $table->foreignUuid('workspace_id')->constrained()->cascadeOnDelete(); $table->foreignUuid('site_id')->nullable()->constrained()->cascadeOnDelete(); $table->enum('type', ['brute_force', 'tor_exit', 'malware', 'cert_expiring', 'firewall', 'scan']) ->default('firewall'); diff --git a/app/database/migrations/2026_05_31_200007_create_certificates_table.php b/app/database/migrations/2026_05_31_200007_create_certificates_table.php index 86f2891..6f8fc41 100644 --- a/app/database/migrations/2026_05_31_200007_create_certificates_table.php +++ b/app/database/migrations/2026_05_31_200007_create_certificates_table.php @@ -9,6 +9,7 @@ return new class extends Migration { { Schema::create('certificates', function (Blueprint $table) { $table->uuid('id')->primary(); + $table->foreignUuid('workspace_id')->constrained()->cascadeOnDelete(); $table->foreignUuid('site_id')->constrained()->cascadeOnDelete(); $table->string('domain'); $table->string('issuer', 64)->default("Let's Encrypt"); diff --git a/app/database/migrations/2026_05_31_200008_create_activities_table.php b/app/database/migrations/2026_05_31_200008_create_activities_table.php index 4c9be7d..5be8a91 100644 --- a/app/database/migrations/2026_05_31_200008_create_activities_table.php +++ b/app/database/migrations/2026_05_31_200008_create_activities_table.php @@ -9,6 +9,7 @@ return new class extends Migration { { Schema::create('activities', function (Blueprint $table) { $table->uuid('id')->primary(); + $table->foreignUuid('workspace_id')->constrained()->cascadeOnDelete(); $table->foreignUuid('user_id')->nullable()->constrained()->nullOnDelete(); $table->enum('type', ['deploy', 'backup', 'update', 'security', 'system', 'login'])->default('system'); $table->enum('level', ['info', 'success', 'warning', 'error'])->default('info'); diff --git a/app/database/migrations/2026_05_31_200009_create_crons_table.php b/app/database/migrations/2026_05_31_200009_create_crons_table.php index 1e85f7d..216f918 100644 --- a/app/database/migrations/2026_05_31_200009_create_crons_table.php +++ b/app/database/migrations/2026_05_31_200009_create_crons_table.php @@ -9,6 +9,7 @@ return new class extends Migration { { Schema::create('crons', function (Blueprint $table) { $table->uuid('id')->primary(); + $table->foreignUuid('workspace_id')->constrained()->cascadeOnDelete(); $table->foreignUuid('site_id')->constrained()->cascadeOnDelete(); $table->string('name'); $table->string('expression', 64)->default('0 * * * *'); // cron expr diff --git a/app/database/seeders/DatabaseSeeder.php b/app/database/seeders/DatabaseSeeder.php index 9b74b41..0af1436 100644 --- a/app/database/seeders/DatabaseSeeder.php +++ b/app/database/seeders/DatabaseSeeder.php @@ -13,14 +13,15 @@ use App\Models\Server; use App\Models\Site; use App\Models\TeamMember; use App\Models\User; +use App\Models\Workspace; use Illuminate\Database\Seeder; class DatabaseSeeder extends Seeder { public function run(): void { - /* ───── primary user (matches template) ───── */ - $owner = User::updateOrCreate( + /* ───── Primary user ───── */ + $marie = User::updateOrCreate( ['email' => 'm.weber@acme-cluster.de'], [ 'name' => 'Marie Weber', @@ -29,9 +30,29 @@ class DatabaseSeeder extends Seeder ] ); - TeamMember::updateOrCreate( - ['user_id' => $owner->id], + /* ───── Workspace (Acme Agency) — Pro plan ───── */ + $workspace = Workspace::updateOrCreate( + ['slug' => 'acme-agency'], [ + 'name' => 'Acme Agency', + 'owner_user_id' => $marie->id, + 'plan' => 'pro', + 'trial_ends_at' => null, + 'billing_email' => 'billing@acme-agency.de', + 'region' => 'eu-central-1', + ] + ); + + $marie->update(['current_workspace_id' => $workspace->id]); + + /* Bind workspace so BelongsToWorkspace creating-hook auto-fills FK. */ + app()->instance('currentWorkspace', $workspace); + + /* ───── Team membership (owner) ───── */ + TeamMember::updateOrCreate( + ['user_id' => $marie->id], + [ + 'workspace_id' => $workspace->id, 'role' => 'owner', 'scope' => 'All clusters', 'two_fa_enabled' => true, @@ -39,19 +60,20 @@ class DatabaseSeeder extends Seeder ] ); - /* ───── dev login (kept from previous seed) ───── */ - User::updateOrCreate( + /* ───── Dev login ───── */ + $admin = User::updateOrCreate( ['email' => 'admin@clupilot.test'], [ 'name' => 'Admin', 'password' => bcrypt('clupilot'), 'email_verified_at' => now(), + 'current_workspace_id' => $workspace->id, ] ); - /* ───── 1 cluster: Acme Cluster ───── */ + /* ───── Cluster: Acme ───── */ $cluster = Cluster::firstOrCreate( - ['slug' => 'acme-cluster'], + ['workspace_id' => $workspace->id, 'slug' => 'acme-cluster'], [ 'name' => 'Acme Cluster', 'region' => 'eu-central-1', @@ -75,7 +97,7 @@ class DatabaseSeeder extends Seeder $serverModels = []; foreach ($servers as $s) { $serverModels[] = Server::firstOrCreate( - ['name' => $s['name']], + ['workspace_id' => $workspace->id, 'name' => $s['name']], [ 'cluster_id' => $cluster->id, 'region' => $s['region'], @@ -93,61 +115,71 @@ class DatabaseSeeder extends Seeder ); } - /* ───── 87 sites distributed across servers ───── */ - $distribution = [23, 19, 14, 18, 6, 7]; // sums to 87 - + /* ───── 87 sites ───── */ + $distribution = [23, 19, 14, 18, 6, 7]; foreach ($serverModels as $i => $server) { $target = $distribution[$i] ?? 5; $existing = $server->sites()->count(); if ($existing < $target) { Site::factory()->count($target - $existing) - ->create(['server_id' => $server->id]); + ->create(['workspace_id' => $workspace->id, 'server_id' => $server->id]); } } - $allSites = Site::query()->get(); + $allSites = Site::query()->where('workspace_id', $workspace->id)->get(); - /* ───── Certificates ───── */ foreach ($allSites as $site) { if (! $site->certificate) { Certificate::factory()->create([ - 'site_id' => $site->id, - 'domain' => '*.' . $site->domain, + 'workspace_id' => $workspace->id, + 'site_id' => $site->id, + 'domain' => '*.' . $site->domain, ]); } } - /* ───── Backups (sample) ───── */ $backupSites = $allSites->random(min(28, $allSites->count())); foreach ($backupSites as $site) { - Backup::factory()->count(rand(2, 6))->create(['site_id' => $site->id]); + Backup::factory()->count(rand(2, 6))->create(['workspace_id' => $workspace->id, 'site_id' => $site->id]); } - /* ───── Crons (sample) ───── */ foreach ($allSites->random(min(20, $allSites->count())) as $site) { - Cron::factory()->count(rand(1, 4))->create(['site_id' => $site->id]); + Cron::factory()->count(rand(1, 4))->create(['workspace_id' => $workspace->id, 'site_id' => $site->id]); } - /* ───── Security events: 128 in last 24h (matches template stat) ───── */ SecurityEvent::factory()->count(128) - ->create(['site_id' => $allSites->random()->id]); + ->create(['workspace_id' => $workspace->id, 'site_id' => $allSites->random()->id]); - /* ───── Team members (4 + owner) ───── */ - if (TeamMember::count() < 5) { - User::factory()->count(4)->create()->each(function (User $u) { - TeamMember::factory()->create(['user_id' => $u->id]); + if ($workspace->members()->count() < 5) { + User::factory()->count(4)->create()->each(function (User $u) use ($workspace) { + $u->update(['current_workspace_id' => $workspace->id]); + TeamMember::factory()->create(['workspace_id' => $workspace->id, 'user_id' => $u->id]); }); } - /* ───── Pending invitations ───── */ - if (Invitation::count() < 3) { + if ($workspace->invitations()->count() < 3) { Invitation::factory()->count(3)->create([ - 'invited_by_user_id' => $owner->id, + 'workspace_id' => $workspace->id, + 'invited_by_user_id' => $marie->id, 'accepted_at' => null, ]); } - /* ───── Activity feed (30 recent items) ───── */ - Activity::factory()->count(30)->create(['user_id' => $owner->id]); + Activity::factory()->count(30)->create(['workspace_id' => $workspace->id, 'user_id' => $marie->id]); + + /* ───── Demo: second workspace (Personal) — Free plan ───── */ + $personal = Workspace::firstOrCreate( + ['slug' => 'marie-personal'], + [ + 'name' => 'Marie · Personal', + 'owner_user_id' => $marie->id, + 'plan' => 'free', + ] + ); + + TeamMember::firstOrCreate( + ['workspace_id' => $personal->id, 'user_id' => $marie->id], + ['role' => 'owner', 'two_fa_enabled' => true, 'last_active_at' => now()], + ); } } diff --git a/app/resources/views/components/clu/sidebar.blade.php b/app/resources/views/components/clu/sidebar.blade.php index 0b9c70d..dcdede9 100644 --- a/app/resources/views/components/clu/sidebar.blade.php +++ b/app/resources/views/components/clu/sidebar.blade.php @@ -1,6 +1,8 @@ @props(['active' => null]) @php + $ws = auth()->user()?->currentWorkspace; + $wsInitials = $ws ? mb_strtoupper(mb_substr(preg_replace('/[^A-Za-z\s]/', '', $ws->name), 0, 2)) : '—'; $items = [ ['name' => 'dashboard', 'label' => 'Dashboard', 'route' => 'dashboard', 'svg' => ''], @@ -30,10 +32,10 @@
-
AC
+
{{ $wsInitials }}
-
Acme Cluster
-
PRO · 87/100 sites
+
{{ $ws?->name ?? 'Workspace wählen' }}
+
{{ strtoupper($ws?->plan ?? 'free') }} · {{ \App\Models\Site::count() }}/{{ $ws?->quotas['sites'] ?? 0 }} sites