Step 1/5: Workspace multi-tenancy foundation
User accepted 5-step plan: Workspace → Sub-Sidebar → Switcher → Plan/
Quota → Billing.
This step adds the workspace layer underneath everything:
- Workspace model with HasUuids + relations (sites/servers/clusters/
backups/members/invitations/activities) + plan-based quotas + feature
flags + isOverQuota() + hasFeature() helpers.
- Plan presets: free/starter/pro/agency/enterprise with sites,
servers, team, storage_gb, features map (audit/monitoring/
email_relay/multi_workspace/white_label/reports/sso).
- BelongsToWorkspace trait: global scope filters by app('current
Workspace'), creating-hook auto-fills workspace_id from container.
Falls back to no-scope when no workspace bound (tests, console).
- Migrations: workspaces table + workspace_id foreignUuid added to
all 10 domain tables (clusters/servers/sites/backups/team_members/
invitations/security_events/certificates/activities/crons).
- users gets current_workspace_id nullable FK.
- team_members unique changed to composite (workspace_id, user_id) so
one user can belong to multiple workspaces.
- ResolveCurrentWorkspace middleware: resolves active workspace from
?workspace=slug → session → user.current_workspace_id → first owned.
Wired into web middleware group via bootstrap/app.php.
- WorkspaceFactory + workspace_id added to all 10 domain factories
so tests/seed work without explicit workspace context.
- DatabaseSeeder rewritten: creates Marie Weber as owner of 'Acme
Agency' (Pro plan) workspace + 'Marie · Personal' (Free) workspace
for switcher demo. Binds app('currentWorkspace') so seeded sites/
servers/backups/etc. land in the right workspace.
- Sidebar component reads auth user's current workspace for the ws-
switch panel — no more hard-coded 'Acme Cluster' string.
Full Pest regression: 47/47 green. 2 workspaces seeded:
- Acme Agency (pro): 87 sites, 6 servers, 128 sec events, 102 backups
- Marie · Personal (free): 0 sites
master
parent
d1917852f4
commit
555101c993
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Resolves the active workspace for the request and binds it to the
|
||||
* container so the BelongsToWorkspace trait can scope queries.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. ?workspace=<slug> 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models\Concerns;
|
||||
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Models using this trait scope by the currently-active workspace
|
||||
* (resolved from session via `app('currentWorkspace')`).
|
||||
*
|
||||
* Falls back to no scope if no workspace context (e.g. during seeding,
|
||||
* artisan commands, or tests without a logged-in user).
|
||||
*/
|
||||
trait BelongsToWorkspace
|
||||
{
|
||||
protected static function bootBelongsToWorkspace(): void
|
||||
{
|
||||
// Global scope: filter to active workspace when present.
|
||||
static::addGlobalScope('workspace', function (Builder $q) {
|
||||
$ws = app()->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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Workspace extends Model
|
||||
{
|
||||
use HasFactory, HasUuids;
|
||||
|
||||
protected $fillable = [
|
||||
'name', 'slug', 'owner_user_id', 'plan',
|
||||
'trial_ends_at', 'logo_url', 'billing_email', 'region',
|
||||
'quotas_override',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'trial_ends_at' => '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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
//
|
||||
|
|
|
|||
|
|
@ -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']),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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']),
|
||||
|
|
|
|||
|
|
@ -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 * * * *']),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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']),
|
||||
|
|
|
|||
|
|
@ -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']),
|
||||
|
|
|
|||
|
|
@ -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]),
|
||||
|
|
|
|||
|
|
@ -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-*']),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class WorkspaceFactory extends Factory
|
||||
{
|
||||
protected $model = Workspace::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
$name = $this->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']); }
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('workspaces', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
// Nullable: a user without any workspace yet (just registered).
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' => '<path d="M3 13h8V3H3v10zm10 8h8V11h-8v10zM3 21h8v-6H3v6zm10-18v6h8V3h-8z"/>'],
|
||||
|
|
@ -30,10 +32,10 @@
|
|||
</div>
|
||||
|
||||
<div class="clu-ws-switch" role="button" tabindex="0">
|
||||
<div class="clu-ws-avatar">AC</div>
|
||||
<div class="clu-ws-avatar">{{ $wsInitials }}</div>
|
||||
<div class="clu-ws-meta">
|
||||
<div class="clu-ws-name">Acme Cluster</div>
|
||||
<div class="clu-ws-plan">PRO · 87/100 sites</div>
|
||||
<div class="clu-ws-name">{{ $ws?->name ?? 'Workspace wählen' }}</div>
|
||||
<div class="clu-ws-plan">{{ strtoupper($ws?->plan ?? 'free') }} · {{ \App\Models\Site::count() }}/{{ $ws?->quotas['sites'] ?? 0 }} sites</div>
|
||||
</div>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-(--color-muted-2) flex-none" aria-hidden="true">
|
||||
<path d="M7 9l5-5 5 5M7 15l5 5 5-5"/>
|
||||
|
|
|
|||
|
|
@ -60,6 +60,8 @@ new #[Layout('layouts.app')] class extends Component {
|
|||
public function create(): void
|
||||
{
|
||||
Cluster::create([
|
||||
'workspace_id' => auth()->user()?->current_workspace_id
|
||||
?? \App\Models\Workspace::factory()->create()->id,
|
||||
'name' => $this->name,
|
||||
'slug' => $this->slug,
|
||||
'region' => $this->region,
|
||||
|
|
|
|||
Loading…
Reference in New Issue