mirror of
https://github.com/archtechx/tenancy.git
synced 2025-12-12 13:54:03 +00:00
User impersonation
This commit is contained in:
parent
52476d6298
commit
10a5b80d44
9 changed files with 432 additions and 2 deletions
|
|
@ -0,0 +1,39 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
class CreateTenantUserImpersonationTokensTable extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('tenant_user_impersonation_tokens', function (Blueprint $table) {
|
||||||
|
$table->string('token', 128)->primary();
|
||||||
|
$table->string('tenant_id');
|
||||||
|
$table->string('user_id');
|
||||||
|
$table->string('auth_guard');
|
||||||
|
$table->string('redirect_url');
|
||||||
|
$table->timestamp('created_at');
|
||||||
|
|
||||||
|
$table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('tenant_user_impersonation_tokens');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,7 +16,7 @@ class CreateTenantsTable extends Migration
|
||||||
public function up(): void
|
public function up(): void
|
||||||
{
|
{
|
||||||
Schema::create('tenants', function (Blueprint $table) {
|
Schema::create('tenants', function (Blueprint $table) {
|
||||||
$table->string('id', 36)->primary(); // 36 characters is the default uuid length
|
$table->string('id')->primary();
|
||||||
|
|
||||||
// your custom columns may go here
|
// your custom columns may go here
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ class CreateDomainsTable extends Migration
|
||||||
Schema::create('domains', function (Blueprint $table) {
|
Schema::create('domains', function (Blueprint $table) {
|
||||||
$table->increments('id');
|
$table->increments('id');
|
||||||
$table->string('domain', 255)->unique();
|
$table->string('domain', 255)->unique();
|
||||||
$table->string('tenant_id', 36);
|
$table->string('tenant_id');
|
||||||
|
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
$table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade');
|
$table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade');
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ use Stancl\Tenancy\Events\DomainSaved;
|
||||||
use Stancl\Tenancy\Events\DomainUpdated;
|
use Stancl\Tenancy\Events\DomainUpdated;
|
||||||
use Stancl\Tenancy\Exceptions\DomainOccupiedByOtherTenantException;
|
use Stancl\Tenancy\Exceptions\DomainOccupiedByOtherTenantException;
|
||||||
use Stancl\Tenancy\Contracts;
|
use Stancl\Tenancy\Contracts;
|
||||||
|
use Stancl\Tenancy\Database\Concerns\CentralConnection;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @property string $domain
|
* @property string $domain
|
||||||
|
|
@ -19,6 +20,8 @@ use Stancl\Tenancy\Contracts;
|
||||||
*/
|
*/
|
||||||
class Domain extends Model implements Contracts\Domain
|
class Domain extends Model implements Contracts\Domain
|
||||||
{
|
{
|
||||||
|
use CentralConnection;
|
||||||
|
|
||||||
public $guarded = [];
|
public $guarded = [];
|
||||||
public $casts = [
|
public $casts = [
|
||||||
'is_primary' => 'bool',
|
'is_primary' => 'bool',
|
||||||
|
|
|
||||||
41
src/Database/Models/ImpersonationToken.php
Normal file
41
src/Database/Models/ImpersonationToken.php
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Stancl\Tenancy\Database\Models;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Stancl\Tenancy\Database\Concerns\CentralConnection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $token
|
||||||
|
* @param string $tenant_id
|
||||||
|
* @param string $user_id
|
||||||
|
* @param string $auth_guard
|
||||||
|
* @param string $redirect_url
|
||||||
|
* @param Carbon $created_at
|
||||||
|
*/
|
||||||
|
class ImpersonationToken extends Model
|
||||||
|
{
|
||||||
|
use CentralConnection;
|
||||||
|
|
||||||
|
protected $guarded = [];
|
||||||
|
public $timestamps = false;
|
||||||
|
protected $primaryKey = 'token';
|
||||||
|
public $incrementing = false;
|
||||||
|
protected $table = 'tenant_user_impersonation_tokens';
|
||||||
|
protected $dates = [
|
||||||
|
'created_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
public static function boot()
|
||||||
|
{
|
||||||
|
parent::boot();
|
||||||
|
|
||||||
|
static::creating(function ($model) {
|
||||||
|
$model->created_at = $model->created_at ?? $model->freshTimestamp();
|
||||||
|
$model->token = $model->token ?? Str::random(128);
|
||||||
|
$model->auth_guard = $model->auth_guard ?? config('auth.defaults.guard');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/Features/UserImpersonation.php
Normal file
54
src/Features/UserImpersonation.php
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Stancl\Tenancy\Features;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Stancl\Tenancy\Contracts\Feature;
|
||||||
|
use Stancl\Tenancy\Database\Models\ImpersonationToken;
|
||||||
|
use Stancl\Tenancy\Contracts\Tenant;
|
||||||
|
use Stancl\Tenancy\Tenancy;
|
||||||
|
|
||||||
|
class UserImpersonation implements Feature
|
||||||
|
{
|
||||||
|
public static $ttl = 60; // seconds
|
||||||
|
|
||||||
|
public function bootstrap(Tenancy $tenancy): void
|
||||||
|
{
|
||||||
|
$tenancy->macro('impersonate', function (Tenant $tenant, string $userId, string $redirectUrl, string $authGuard = null): ImpersonationToken
|
||||||
|
{
|
||||||
|
return ImpersonationToken::create([
|
||||||
|
'tenant_id' => $tenant->getTenantKey(),
|
||||||
|
'user_id' => $userId,
|
||||||
|
'redirect_url' => $redirectUrl,
|
||||||
|
'auth_guard' => $authGuard,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Impersonate a user and get an HTTP redirect response.
|
||||||
|
*
|
||||||
|
* @param string|ImpersonationToken $token
|
||||||
|
* @return RedirectResponse
|
||||||
|
*/
|
||||||
|
public static function makeResponse($token): RedirectResponse
|
||||||
|
{
|
||||||
|
$token = $token instanceof ImpersonationToken ? $token : ImpersonationToken::findOrFail($token);
|
||||||
|
|
||||||
|
if (((string) $token->tenant_id) !== ((string) tenant('id'))) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($token->created_at->diffInSeconds(Carbon::now()) > static::$ttl) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
Auth::guard($token->auth_guard)->loginUsingId($token->user_id);
|
||||||
|
|
||||||
|
$token->delete();
|
||||||
|
|
||||||
|
return redirect($token->redirect_url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,11 +4,14 @@ namespace Stancl\Tenancy;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Traits\Macroable;
|
||||||
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
|
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
|
||||||
use Stancl\Tenancy\Contracts\Tenant;
|
use Stancl\Tenancy\Contracts\Tenant;
|
||||||
|
|
||||||
class Tenancy
|
class Tenancy
|
||||||
{
|
{
|
||||||
|
use Macroable;
|
||||||
|
|
||||||
/** @var Tenant|Model|null */
|
/** @var Tenant|Model|null */
|
||||||
public $tenant;
|
public $tenant;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,14 @@ class PathIdentificationTest extends TestCase
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function tearDown(): void
|
||||||
|
{
|
||||||
|
parent::tearDown();
|
||||||
|
|
||||||
|
// Global state cleanup
|
||||||
|
PathTenantResolver::$tenantParameterName = 'tenant';
|
||||||
|
}
|
||||||
|
|
||||||
/** @test */
|
/** @test */
|
||||||
public function tenant_can_be_identified_by_path()
|
public function tenant_can_be_identified_by_path()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
282
tests/TenantUserImpersonationTest.php
Normal file
282
tests/TenantUserImpersonationTest.php
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Stancl\Tenancy\Tests;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Carbon\CarbonInterval;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Auth\SessionGuard;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
|
||||||
|
use Stancl\Tenancy\Database\Models\ImpersonationToken;
|
||||||
|
use Stancl\Tenancy\Events\TenancyEnded;
|
||||||
|
use Stancl\Tenancy\Events\TenancyInitialized;
|
||||||
|
use Stancl\Tenancy\Events\TenantCreated;
|
||||||
|
use Stancl\Tenancy\Jobs\CreateDatabase;
|
||||||
|
use Stancl\Tenancy\Listeners\BootstrapTenancy;
|
||||||
|
use Stancl\Tenancy\Listeners\JobPipeline;
|
||||||
|
use Stancl\Tenancy\Listeners\RevertToCentralContext;
|
||||||
|
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
|
||||||
|
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||||
|
use Illuminate\Foundation\Auth\User as Authenticable;
|
||||||
|
use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Stancl\Tenancy\Features\UserImpersonation;
|
||||||
|
|
||||||
|
class TenantUserImpersonationTest extends TestCase
|
||||||
|
{
|
||||||
|
protected function migrateTenants()
|
||||||
|
{
|
||||||
|
$this->artisan('tenants:migrate')->assertExitCode(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->artisan('migrate', [
|
||||||
|
'--path' => __DIR__ . '/../assets/2019_09_15_000020_create_tenant_user_impersonation_tokens_table.php',
|
||||||
|
'--realpath' => true,
|
||||||
|
])->assertExitCode(0);
|
||||||
|
|
||||||
|
config([
|
||||||
|
'tenancy.bootstrappers' => [
|
||||||
|
DatabaseTenancyBootstrapper::class,
|
||||||
|
],
|
||||||
|
'tenancy.features' => [
|
||||||
|
UserImpersonation::class,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Event::listen(
|
||||||
|
TenantCreated::class,
|
||||||
|
JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
|
||||||
|
return $event->tenant;
|
||||||
|
})->toListener()
|
||||||
|
);
|
||||||
|
|
||||||
|
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
|
||||||
|
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
|
||||||
|
|
||||||
|
config(['auth.providers.users.model' => ImpersonationUser::class]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function makeLoginRoute()
|
||||||
|
{
|
||||||
|
Route::get('/login', function () {
|
||||||
|
return 'Please log in';
|
||||||
|
})->name('login');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRoutes($loginRoute = true, $authGuard = 'web'): Closure
|
||||||
|
{
|
||||||
|
return function () use ($loginRoute, $authGuard) {
|
||||||
|
if ($loginRoute) {
|
||||||
|
$this->makeLoginRoute();
|
||||||
|
}
|
||||||
|
|
||||||
|
Route::get('/dashboard', function () use ($authGuard) {
|
||||||
|
return 'You are logged in as ' . auth()->guard($authGuard)->user()->name;
|
||||||
|
})->middleware('auth:' . $authGuard);
|
||||||
|
|
||||||
|
Route::get('/impersonate/{token}', function ($token) {
|
||||||
|
return UserImpersonation::makeResponse($token);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function tenant_user_can_be_impersonated_on_a_tenant_domain()
|
||||||
|
{
|
||||||
|
Route::middleware(InitializeTenancyByDomain::class)->group($this->getRoutes());
|
||||||
|
|
||||||
|
$tenant = Tenant::create();
|
||||||
|
$tenant->domains()->create([
|
||||||
|
'domain' => 'foo.localhost',
|
||||||
|
]);
|
||||||
|
$this->migrateTenants();
|
||||||
|
$user = $tenant->run(function () {
|
||||||
|
return ImpersonationUser::create([
|
||||||
|
'name' => 'Joe',
|
||||||
|
'email' => 'joe@local',
|
||||||
|
'password' => bcrypt('secret'),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// We try to visit the dashboard directly, before impersonating the user.
|
||||||
|
$this->get('http://foo.localhost/dashboard')
|
||||||
|
->assertRedirect('http://foo.localhost/login');
|
||||||
|
|
||||||
|
// We impersonate the user
|
||||||
|
$token = tenancy()->impersonate($tenant, $user->id, '/dashboard');
|
||||||
|
$this->get('http://foo.localhost/impersonate/' . $token->token)
|
||||||
|
->assertRedirect('http://foo.localhost/dashboard');
|
||||||
|
|
||||||
|
// Now we try to visit the dashboard directly, after impersonating the user.
|
||||||
|
$this->get('http://foo.localhost/dashboard')
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('You are logged in as Joe');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function tenant_user_can_be_impersonated_on_a_tenant_path()
|
||||||
|
{
|
||||||
|
$this->makeLoginRoute();
|
||||||
|
|
||||||
|
Route::middleware(InitializeTenancyByPath::class)->prefix('/{tenant}')->group($this->getRoutes(false));
|
||||||
|
|
||||||
|
$tenant = Tenant::create([
|
||||||
|
'id' => 'acme',
|
||||||
|
'tenancy_db_name' => 'db' . Str::random(16),
|
||||||
|
]);
|
||||||
|
$this->migrateTenants();
|
||||||
|
$user = $tenant->run(function () {
|
||||||
|
return ImpersonationUser::create([
|
||||||
|
'name' => 'Joe',
|
||||||
|
'email' => 'joe@local',
|
||||||
|
'password' => bcrypt('secret'),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// We try to visit the dashboard directly, before impersonating the user.
|
||||||
|
$this->get('/acme/dashboard')
|
||||||
|
->assertRedirect('/login');
|
||||||
|
|
||||||
|
// We impersonate the user
|
||||||
|
$token = tenancy()->impersonate($tenant, $user->id, '/acme/dashboard');
|
||||||
|
$this->get('/acme/impersonate/' . $token->token)
|
||||||
|
->assertRedirect('/acme/dashboard');
|
||||||
|
|
||||||
|
// Now we try to visit the dashboard directly, after impersonating the user.
|
||||||
|
$this->get('/acme/dashboard')
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('You are logged in as Joe');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function tokens_have_a_limited_ttl()
|
||||||
|
{
|
||||||
|
Route::middleware(InitializeTenancyByDomain::class)->group($this->getRoutes());
|
||||||
|
|
||||||
|
$tenant = Tenant::create();
|
||||||
|
$tenant->domains()->create([
|
||||||
|
'domain' => 'foo.localhost',
|
||||||
|
]);
|
||||||
|
$this->migrateTenants();
|
||||||
|
$user = $tenant->run(function () {
|
||||||
|
return ImpersonationUser::create([
|
||||||
|
'name' => 'Joe',
|
||||||
|
'email' => 'joe@local',
|
||||||
|
'password' => bcrypt('secret'),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// We impersonate the user
|
||||||
|
$token = tenancy()->impersonate($tenant, $user->id, '/dashboard');
|
||||||
|
$token->update([
|
||||||
|
'created_at' => Carbon::now()->subtract(CarbonInterval::make('100s')),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->followingRedirects()
|
||||||
|
->get('http://foo.localhost/impersonate/' . $token->token)
|
||||||
|
->assertStatus(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function tokens_are_deleted_after_use()
|
||||||
|
{
|
||||||
|
Route::middleware(InitializeTenancyByDomain::class)->group($this->getRoutes());
|
||||||
|
|
||||||
|
$tenant = Tenant::create();
|
||||||
|
$tenant->domains()->create([
|
||||||
|
'domain' => 'foo.localhost',
|
||||||
|
]);
|
||||||
|
$this->migrateTenants();
|
||||||
|
$user = $tenant->run(function () {
|
||||||
|
return ImpersonationUser::create([
|
||||||
|
'name' => 'Joe',
|
||||||
|
'email' => 'joe@local',
|
||||||
|
'password' => bcrypt('secret'),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// We impersonate the user
|
||||||
|
$token = tenancy()->impersonate($tenant, $user->id, '/dashboard');
|
||||||
|
|
||||||
|
$this->assertNotNull(ImpersonationToken::find($token->token));
|
||||||
|
|
||||||
|
$this->followingRedirects()
|
||||||
|
->get('http://foo.localhost/impersonate/' . $token->token)
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('You are logged in as Joe');
|
||||||
|
|
||||||
|
$this->assertNull(ImpersonationToken::find($token->token));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function impersonation_works_with_multiple_models_and_guards()
|
||||||
|
{
|
||||||
|
config([
|
||||||
|
'auth.guards.another' => [
|
||||||
|
'driver' => 'session',
|
||||||
|
'provider' => 'another_users',
|
||||||
|
],
|
||||||
|
'auth.providers.another_users' => [
|
||||||
|
'driver' => 'eloquent',
|
||||||
|
'model' => AnotherImpersonationUser::class,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Auth::extend('another', function ($app, $name, array $config) {
|
||||||
|
return new SessionGuard($name, Auth::createUserProvider($config['provider']), session());
|
||||||
|
});
|
||||||
|
|
||||||
|
Route::middleware(InitializeTenancyByDomain::class)->group($this->getRoutes(true, 'another'));
|
||||||
|
|
||||||
|
$tenant = Tenant::create();
|
||||||
|
$tenant->domains()->create([
|
||||||
|
'domain' => 'foo.localhost',
|
||||||
|
]);
|
||||||
|
$this->migrateTenants();
|
||||||
|
$user = $tenant->run(function () {
|
||||||
|
return AnotherImpersonationUser::create([
|
||||||
|
'name' => 'Joe',
|
||||||
|
'email' => 'joe@local',
|
||||||
|
'password' => bcrypt('secret'),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// We try to visit the dashboard directly, before impersonating the user.
|
||||||
|
$this->get('http://foo.localhost/dashboard')
|
||||||
|
->assertRedirect('http://foo.localhost/login');
|
||||||
|
|
||||||
|
// We impersonate the user
|
||||||
|
$token = tenancy()->impersonate($tenant, $user->id, '/dashboard', 'another');
|
||||||
|
$this->get('http://foo.localhost/impersonate/' . $token->token)
|
||||||
|
->assertRedirect('http://foo.localhost/dashboard');
|
||||||
|
|
||||||
|
// Now we try to visit the dashboard directly, after impersonating the user.
|
||||||
|
$this->get('http://foo.localhost/dashboard')
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('You are logged in as Joe');
|
||||||
|
|
||||||
|
Tenant::first()->run(function () {
|
||||||
|
$this->assertSame('Joe', auth()->guard('another')->user()->name);
|
||||||
|
$this->assertSame(null, auth()->guard('web')->user());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ImpersonationUser extends Authenticable
|
||||||
|
{
|
||||||
|
protected $guarded = [];
|
||||||
|
protected $table = 'users';
|
||||||
|
}
|
||||||
|
|
||||||
|
class AnotherImpersonationUser extends Authenticable
|
||||||
|
{
|
||||||
|
protected $guarded = [];
|
||||||
|
protected $table = 'users';
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue