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' => '