feat: force password reset + waitlist
This commit is contained in:
parent
952d335789
commit
88b3005589
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
use App\Jobs\CheckResaleLicenseJob;
|
use App\Jobs\CheckResaleLicenseJob;
|
||||||
use App\Jobs\CheckResaleLicenseKeys;
|
use App\Jobs\CheckResaleLicenseKeys;
|
||||||
|
use App\Jobs\CleanupInstanceStuffsJob;
|
||||||
use App\Jobs\DatabaseBackupJob;
|
use App\Jobs\DatabaseBackupJob;
|
||||||
use App\Jobs\DockerCleanupJob;
|
use App\Jobs\DockerCleanupJob;
|
||||||
use App\Jobs\InstanceApplicationsStatusJob;
|
use App\Jobs\InstanceApplicationsStatusJob;
|
||||||
@ -22,12 +23,14 @@ protected function schedule(Schedule $schedule): void
|
|||||||
$schedule->command('horizon:snapshot')->everyMinute();
|
$schedule->command('horizon:snapshot')->everyMinute();
|
||||||
$schedule->job(new InstanceApplicationsStatusJob)->everyMinute();
|
$schedule->job(new InstanceApplicationsStatusJob)->everyMinute();
|
||||||
$schedule->job(new ProxyCheckJob)->everyFiveMinutes();
|
$schedule->job(new ProxyCheckJob)->everyFiveMinutes();
|
||||||
|
$schedule->job(new CleanupInstanceStuffsJob)->everyMinute();
|
||||||
|
|
||||||
// $schedule->job(new CheckResaleLicenseJob)->hourly();
|
// $schedule->job(new CheckResaleLicenseJob)->hourly();
|
||||||
// $schedule->job(new DockerCleanupJob)->everyOddHour();
|
// $schedule->job(new DockerCleanupJob)->everyOddHour();
|
||||||
// $schedule->job(new InstanceAutoUpdateJob(true))->everyMinute();
|
// $schedule->job(new InstanceAutoUpdateJob(true))->everyMinute();
|
||||||
} else {
|
} else {
|
||||||
$schedule->command('horizon:snapshot')->everyFiveMinutes();
|
$schedule->command('horizon:snapshot')->everyFiveMinutes();
|
||||||
|
$schedule->job(new CleanupInstanceStuffsJob)->everyMinute();
|
||||||
$schedule->job(new InstanceApplicationsStatusJob)->everyMinute();
|
$schedule->job(new InstanceApplicationsStatusJob)->everyMinute();
|
||||||
$schedule->job(new CheckResaleLicenseJob)->hourly();
|
$schedule->job(new CheckResaleLicenseJob)->hourly();
|
||||||
$schedule->job(new ProxyCheckJob)->everyFiveMinutes();
|
$schedule->job(new ProxyCheckJob)->everyFiveMinutes();
|
||||||
|
@ -9,6 +9,7 @@
|
|||||||
use App\Models\StandalonePostgresql;
|
use App\Models\StandalonePostgresql;
|
||||||
use App\Models\TeamInvitation;
|
use App\Models\TeamInvitation;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\Waitlist;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||||
use Illuminate\Routing\Controller as BaseController;
|
use Illuminate\Routing\Controller as BaseController;
|
||||||
@ -18,6 +19,12 @@ class Controller extends BaseController
|
|||||||
{
|
{
|
||||||
use AuthorizesRequests, ValidatesRequests;
|
use AuthorizesRequests, ValidatesRequests;
|
||||||
|
|
||||||
|
public function waitlist() {
|
||||||
|
$waiting_in_line = Waitlist::whereVerified(true)->count();
|
||||||
|
return view('auth.waitlist', [
|
||||||
|
'waiting_in_line' => $waiting_in_line,
|
||||||
|
]);
|
||||||
|
}
|
||||||
public function subscription()
|
public function subscription()
|
||||||
{
|
{
|
||||||
if (!is_cloud()) {
|
if (!is_cloud()) {
|
||||||
@ -38,6 +45,9 @@ public function license()
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function force_passoword_reset() {
|
||||||
|
return view('auth.force-password-reset');
|
||||||
|
}
|
||||||
public function dashboard()
|
public function dashboard()
|
||||||
{
|
{
|
||||||
$projects = Project::ownedByCurrentTeam()->get();
|
$projects = Project::ownedByCurrentTeam()->get();
|
||||||
|
@ -37,6 +37,7 @@ class Kernel extends HttpKernel
|
|||||||
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
|
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
|
||||||
\App\Http\Middleware\VerifyCsrfToken::class,
|
\App\Http\Middleware\VerifyCsrfToken::class,
|
||||||
\Illuminate\Routing\Middleware\SubstituteBindings::class,
|
\Illuminate\Routing\Middleware\SubstituteBindings::class,
|
||||||
|
\App\Http\Middleware\CheckForcePasswordReset::class,
|
||||||
\App\Http\Middleware\SubscriptionValid::class,
|
\App\Http\Middleware\SubscriptionValid::class,
|
||||||
|
|
||||||
],
|
],
|
||||||
|
36
app/Http/Livewire/ForcePasswordReset.php
Normal file
36
app/Http/Livewire/ForcePasswordReset.php
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Livewire;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class ForcePasswordReset extends Component
|
||||||
|
{
|
||||||
|
public string $email;
|
||||||
|
public string $password;
|
||||||
|
public string $password_confirmation;
|
||||||
|
|
||||||
|
protected $rules = [
|
||||||
|
'email' => 'required|email',
|
||||||
|
'password' => 'required|min:8',
|
||||||
|
'password_confirmation' => 'required|same:password',
|
||||||
|
];
|
||||||
|
public function mount() {
|
||||||
|
$this->email = auth()->user()->email;
|
||||||
|
}
|
||||||
|
public function submit() {
|
||||||
|
try {
|
||||||
|
$this->validate();
|
||||||
|
auth()->user()->forceFill([
|
||||||
|
'password' => Hash::make($this->password),
|
||||||
|
'force_password_reset' => false,
|
||||||
|
])->save();
|
||||||
|
auth()->logout();
|
||||||
|
return redirect()->route('login')->with('status', 'Your initial password has been set.');
|
||||||
|
} catch(\Exception $e) {
|
||||||
|
return general_error_handler(err:$e, that:$this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
49
app/Http/Livewire/Waitlist.php
Normal file
49
app/Http/Livewire/Waitlist.php
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Livewire;
|
||||||
|
|
||||||
|
use App\Jobs\SendConfirmationForWaitlistJob;
|
||||||
|
use App\Models\Waitlist as ModelsWaitlist;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class Waitlist extends Component
|
||||||
|
{
|
||||||
|
public string $email;
|
||||||
|
public int $waiting_in_line = 0;
|
||||||
|
|
||||||
|
protected $rules = [
|
||||||
|
'email' => 'required|email',
|
||||||
|
];
|
||||||
|
public function mount()
|
||||||
|
{
|
||||||
|
if (is_dev()) {
|
||||||
|
$this->email = 'test@example.com';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public function submit()
|
||||||
|
{
|
||||||
|
$this->validate();
|
||||||
|
try {
|
||||||
|
$found = ModelsWaitlist::where('email', $this->email)->first();
|
||||||
|
ray($found);
|
||||||
|
if ($found) {
|
||||||
|
if (!$found->verified) {
|
||||||
|
$this->emit('error', 'You are already on the waitlist. <br>Please check your email to verify your email address.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->emit('error', 'You are already on the waitlist.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$waitlist = ModelsWaitlist::create([
|
||||||
|
'email' => $this->email,
|
||||||
|
'type' => 'registration',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->emit('success', 'You have been added to the waitlist.');
|
||||||
|
dispatch(new SendConfirmationForWaitlistJob($this->email, $waitlist->uuid));
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return general_error_handler(err: $e, that: $this);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
29
app/Http/Middleware/CheckForcePasswordReset.php
Normal file
29
app/Http/Middleware/CheckForcePasswordReset.php
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class CheckForcePasswordReset
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
if (auth()->user()) {
|
||||||
|
$force_password_reset = auth()->user()->force_password_reset;
|
||||||
|
if ($force_password_reset) {
|
||||||
|
if ($request->routeIs('auth.force-password-reset') || $request->path() === 'livewire/message/force-password-reset') {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
return redirect()->route('auth.force-password-reset');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
@ -24,7 +24,6 @@ public function handle(Request $request, Closure $next, string ...$guards): Resp
|
|||||||
return redirect(RouteServiceProvider::HOME);
|
return redirect(RouteServiceProvider::HOME);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -10,7 +10,6 @@ class SubscriptionValid
|
|||||||
{
|
{
|
||||||
public function handle(Request $request, Closure $next): Response
|
public function handle(Request $request, Closure $next): Response
|
||||||
{
|
{
|
||||||
|
|
||||||
if (!auth()->user() || !is_cloud()) {
|
if (!auth()->user() || !is_cloud()) {
|
||||||
if ($request->path() === 'subscription') {
|
if ($request->path() === 'subscription') {
|
||||||
return redirect('/');
|
return redirect('/');
|
||||||
@ -36,7 +35,10 @@ public function handle(Request $request, Closure $next): Response
|
|||||||
'subscription',
|
'subscription',
|
||||||
'login',
|
'login',
|
||||||
'register',
|
'register',
|
||||||
|
'waitlist',
|
||||||
|
'force-password-reset',
|
||||||
'logout',
|
'logout',
|
||||||
|
'livewire/message/force-password-reset',
|
||||||
'livewire/message/check-license',
|
'livewire/message/check-license',
|
||||||
'livewire/message/switch-team',
|
'livewire/message/switch-team',
|
||||||
];
|
];
|
||||||
|
43
app/Jobs/CleanupInstanceStuffsJob.php
Normal file
43
app/Jobs/CleanupInstanceStuffsJob.php
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Waitlist;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class CleanupInstanceStuffsJob implements ShouldQueue, ShouldBeUnique
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// public function uniqueId(): string
|
||||||
|
// {
|
||||||
|
// return $this->container_name;
|
||||||
|
// }
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->cleanup_waitlist();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
ray($e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cleanup_waitlist()
|
||||||
|
{
|
||||||
|
$waitlist = Waitlist::whereVerified(false)->where('created_at', '<', now()->subMinutes(config('constants.waitlist.confirmation_valid_for_minutes')))->get();
|
||||||
|
foreach ($waitlist as $item) {
|
||||||
|
$item->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
59
app/Jobs/SendConfirmationForWaitlistJob.php
Executable file
59
app/Jobs/SendConfirmationForWaitlistJob.php
Executable file
@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\InstanceSettings;
|
||||||
|
use App\Models\Waitlist;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Mail\Message;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
|
||||||
|
class SendConfirmationForWaitlistJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(public string $email, public string $uuid)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle()
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$settings = InstanceSettings::get();
|
||||||
|
|
||||||
|
|
||||||
|
set_transanctional_email_settings($settings);
|
||||||
|
$mail = new MailMessage();
|
||||||
|
|
||||||
|
$confirmation_url = base_url() . '/webhooks/waitlist/confirm?email=' . $this->email . '&confirmation_code=' . $this->uuid;
|
||||||
|
$cancel_url = base_url() . '/webhooks/waitlist/cancel?email=' . $this->email . '&confirmation_code=' . $this->uuid;
|
||||||
|
|
||||||
|
$mail->view('emails.waitlist-confirmation',
|
||||||
|
[
|
||||||
|
'confirmation_url' => $confirmation_url,
|
||||||
|
'cancel_url' => $cancel_url,
|
||||||
|
]);
|
||||||
|
$mail->subject('You are on the waitlist!');
|
||||||
|
Mail::send(
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
fn(Message $message) => $message
|
||||||
|
->from(
|
||||||
|
data_get($settings, 'smtp_from_address'),
|
||||||
|
data_get($settings, 'smtp_from_name')
|
||||||
|
)
|
||||||
|
->to($this->email)
|
||||||
|
->subject($mail->subject)
|
||||||
|
->html((string) $mail->render())
|
||||||
|
);
|
||||||
|
} catch (\Throwable $th) {
|
||||||
|
ray($th->getMessage());
|
||||||
|
throw $th;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -3,6 +3,7 @@
|
|||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Notifications\Channels\SendsEmail;
|
use App\Notifications\Channels\SendsEmail;
|
||||||
|
use App\Notifications\TransactionalEmails\ResetPassword as TransactionalEmailsResetPassword;
|
||||||
use App\Notifications\TrnsactionalEmails\ResetPassword;
|
use App\Notifications\TrnsactionalEmails\ResetPassword;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
@ -14,18 +15,14 @@ class User extends Authenticatable implements SendsEmail
|
|||||||
{
|
{
|
||||||
use HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable;
|
use HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $guarded = [];
|
||||||
'id',
|
|
||||||
'name',
|
|
||||||
'email',
|
|
||||||
'password',
|
|
||||||
];
|
|
||||||
protected $hidden = [
|
protected $hidden = [
|
||||||
'password',
|
'password',
|
||||||
'remember_token',
|
'remember_token',
|
||||||
];
|
];
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'email_verified_at' => 'datetime',
|
'email_verified_at' => 'datetime',
|
||||||
|
'force_password_reset' => 'boolean',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected static function boot()
|
protected static function boot()
|
||||||
@ -57,7 +54,7 @@ public function getRecepients($notification)
|
|||||||
|
|
||||||
public function sendPasswordResetNotification($token): void
|
public function sendPasswordResetNotification($token): void
|
||||||
{
|
{
|
||||||
$this->notify(new ResetPassword($token));
|
$this->notify(new TransactionalEmailsResetPassword($token));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isAdmin()
|
public function isAdmin()
|
||||||
|
11
app/Models/Waitlist.php
Normal file
11
app/Models/Waitlist.php
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
|
||||||
|
class Waitlist extends BaseModel
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
protected $guarded = [];
|
||||||
|
}
|
@ -14,6 +14,7 @@
|
|||||||
use Illuminate\Support\Facades\RateLimiter;
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
use Laravel\Fortify\Contracts\RegisterResponse;
|
use Laravel\Fortify\Contracts\RegisterResponse;
|
||||||
|
use Laravel\Fortify\Features;
|
||||||
use Laravel\Fortify\Fortify;
|
use Laravel\Fortify\Fortify;
|
||||||
|
|
||||||
class FortifyServiceProvider extends ServiceProvider
|
class FortifyServiceProvider extends ServiceProvider
|
||||||
@ -41,13 +42,19 @@ public function toResponse($request)
|
|||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
|
|
||||||
Fortify::createUsersUsing(CreateNewUser::class);
|
Fortify::createUsersUsing(CreateNewUser::class);
|
||||||
Fortify::registerView(function () {
|
Fortify::registerView(function () {
|
||||||
|
ray('asd');
|
||||||
$settings = InstanceSettings::get();
|
$settings = InstanceSettings::get();
|
||||||
if (!$settings->is_registration_enabled) {
|
if (!$settings->is_registration_enabled) {
|
||||||
return redirect()->route('login');
|
return redirect()->route('login');
|
||||||
}
|
}
|
||||||
|
if (config('coolify.waitlist')) {
|
||||||
|
return view('auth.waitlist');
|
||||||
|
} else {
|
||||||
return view('auth.register');
|
return view('auth.register');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Fortify::loginView(function () {
|
Fortify::loginView(function () {
|
||||||
|
@ -101,9 +101,11 @@ function is_transactional_emails_active(): bool
|
|||||||
return data_get(InstanceSettings::get(), 'smtp_enabled');
|
return data_get(InstanceSettings::get(), 'smtp_enabled');
|
||||||
}
|
}
|
||||||
|
|
||||||
function set_transanctional_email_settings(): void
|
function set_transanctional_email_settings(InstanceSettings|null $settings = null): void
|
||||||
{
|
{
|
||||||
|
if (!$settings) {
|
||||||
$settings = InstanceSettings::get();
|
$settings = InstanceSettings::get();
|
||||||
|
}
|
||||||
$password = data_get($settings, 'smtp_password');
|
$password = data_get($settings, 'smtp_password');
|
||||||
if (isset($password)) {
|
if (isset($password)) {
|
||||||
$password = decrypt($password);
|
$password = decrypt($password);
|
||||||
|
@ -1,5 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
return [
|
return [
|
||||||
|
'waitlist' => [
|
||||||
|
'confirmation_valid_for_minutes' => 10,
|
||||||
|
],
|
||||||
'invitation' => [
|
'invitation' => [
|
||||||
'link' => [
|
'link' => [
|
||||||
'base_url' => '/invitations/',
|
'base_url' => '/invitations/',
|
||||||
@ -11,6 +14,6 @@
|
|||||||
'basic' => 1,
|
'basic' => 1,
|
||||||
'pro' => 3,
|
'pro' => 3,
|
||||||
'ultimate' => 9999999999999999999,
|
'ultimate' => 9999999999999999999,
|
||||||
]
|
],
|
||||||
]
|
],
|
||||||
];
|
];
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'self_hosted' => env('SELF_HOSTED', true),
|
'self_hosted' => env('SELF_HOSTED', true),
|
||||||
|
'waitlist' => env('WAITLIST', false),
|
||||||
'license_url' => 'https://license.coolify.io',
|
'license_url' => 'https://license.coolify.io',
|
||||||
'lemon_squeezy_webhook_secret' => env('LEMON_SQUEEZY_WEBHOOK_SECRET', null),
|
'lemon_squeezy_webhook_secret' => env('LEMON_SQUEEZY_WEBHOOK_SECRET', null),
|
||||||
'lemon_squeezy_checkout_id_monthly_basic' => env('LEMON_SQUEEZY_CHECKOUT_ID_MONTHLY_BASIC', null),
|
'lemon_squeezy_checkout_id_monthly_basic' => env('LEMON_SQUEEZY_CHECKOUT_ID_MONTHLY_BASIC', null),
|
||||||
|
@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('waitlists', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('uuid');
|
||||||
|
$table->string('type');
|
||||||
|
$table->string('email')->unique();
|
||||||
|
$table->boolean('verified')->default(false);
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('waitlists');
|
||||||
|
}
|
||||||
|
};
|
28
database/migrations/2023_08_15_111125_update_users_table.php
Normal file
28
database/migrations/2023_08_15_111125_update_users_table.php
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->boolean('force_password_reset')->default(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('force_password_reset');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
17
database/seeders/WaitlistSeeder.php
Normal file
17
database/seeders/WaitlistSeeder.php
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
class WaitlistSeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the database seeds.
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
}
|
3
resources/views/auth/force-password-reset.blade.php
Normal file
3
resources/views/auth/force-password-reset.blade.php
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<x-layout-simple>
|
||||||
|
<livewire:force-password-reset />
|
||||||
|
</x-layout-simple>
|
@ -8,10 +8,18 @@
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<h1>{{ __('auth.login') }}</h1>
|
<h1>{{ __('auth.login') }}</h1>
|
||||||
@if ($is_registration_enabled)
|
@if ($is_registration_enabled)
|
||||||
<a href="/register" class="text-xs normal-case hover:no-underline btn btn-sm bg-coollabs-gradient">
|
@if (config('coolify.waitlist'))
|
||||||
|
<a href="/waitlist"
|
||||||
|
class="text-xs normal-case hover:no-underline btn btn-sm bg-coollabs-gradient">
|
||||||
|
Join the waitlist
|
||||||
|
</a>
|
||||||
|
@else
|
||||||
|
<a href="/register"
|
||||||
|
class="text-xs normal-case hover:no-underline btn btn-sm bg-coollabs-gradient">
|
||||||
{{ __('auth.register_now') }}
|
{{ __('auth.register_now') }}
|
||||||
</a>
|
</a>
|
||||||
@endif
|
@endif
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<form action="/login" method="POST" class="flex flex-col gap-2">
|
<form action="/login" method="POST" class="flex flex-col gap-2">
|
||||||
|
@ -1,14 +1,13 @@
|
|||||||
<x-layout-simple>
|
<x-layout-simple>
|
||||||
<div class="flex items-center justify-center h-screen mx-auto">
|
<div class="min-h-screen hero">
|
||||||
<div>
|
<div>
|
||||||
<div class="flex flex-col items-center pb-8">
|
<div class="flex flex-col items-center pb-8">
|
||||||
<a href="{{ route('dashboard') }}">
|
<a href="{{ route('dashboard') }}">
|
||||||
<div class="text-5xl font-bold tracking-tight text-center text-white">Coolify</div>
|
<div class="text-5xl font-bold tracking-tight text-center text-white">Coolify</div>
|
||||||
</a>
|
</a>
|
||||||
<x-version />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center justify-center pb-4 text-center">
|
||||||
<h1>{{ __('auth.reset_password') }}</h1>
|
<h2>{{ __('auth.reset_password') }}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<form action="/reset-password" method="POST" class="flex flex-col gap-2">
|
<form action="/reset-password" method="POST" class="flex flex-col gap-2">
|
||||||
@ -16,7 +15,7 @@
|
|||||||
<input hidden id="token" name="token" value="{{ request()->route('token') }}">
|
<input hidden id="token" name="token" value="{{ request()->route('token') }}">
|
||||||
<input hidden value="{{ request()->query('email') }}" type="email" name="email"
|
<input hidden value="{{ request()->query('email') }}" type="email" name="email"
|
||||||
label="{{ __('input.email') }}" />
|
label="{{ __('input.email') }}" />
|
||||||
<div class="flex gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<x-forms.input required type="password" id="password" name="password"
|
<x-forms.input required type="password" id="password" name="password"
|
||||||
label="{{ __('input.password') }}" autofocus />
|
label="{{ __('input.password') }}" autofocus />
|
||||||
<x-forms.input required type="password" id="password_confirmation" name="password_confirmation"
|
<x-forms.input required type="password" id="password_confirmation" name="password_confirmation"
|
||||||
|
3
resources/views/auth/waitlist.blade.php
Normal file
3
resources/views/auth/waitlist.blade.php
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<x-layout-simple>
|
||||||
|
<livewire:waitlist :waiting_in_line="$waiting_in_line" />
|
||||||
|
</x-layout-simple>
|
@ -25,14 +25,15 @@ class="absolute inset-y-0 left-0 flex items-center pl-2 cursor-pointer hover:tex
|
|||||||
<input {{ $attributes->merge(['class' => $defaultClass . ' pl-10']) }} @required($required)
|
<input {{ $attributes->merge(['class' => $defaultClass . ' pl-10']) }} @required($required)
|
||||||
wire:model.defer={{ $id }} wire:dirty.class.remove='text-white'
|
wire:model.defer={{ $id }} wire:dirty.class.remove='text-white'
|
||||||
wire:dirty.class="input-warning" wire:loading.attr="disabled" type="{{ $type }}"
|
wire:dirty.class="input-warning" wire:loading.attr="disabled" type="{{ $type }}"
|
||||||
@readonly($readonly) @disabled($disabled) id="{{ $id }}" name="{{ $name }}">
|
@readonly($readonly) @disabled($disabled) id="{{ $id }}" name="{{ $name }}"
|
||||||
|
placeholder="{{ $attributes->get('placeholder') }}">
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
<input {{ $attributes->merge(['class' => $defaultClass]) }} @required($required) @readonly($readonly)
|
<input {{ $attributes->merge(['class' => $defaultClass]) }} @required($required) @readonly($readonly)
|
||||||
wire:model.defer={{ $id }} wire:dirty.class.remove='text-white' wire:dirty.class="input-warning"
|
wire:model.defer={{ $id }} wire:dirty.class.remove='text-white' wire:dirty.class="input-warning"
|
||||||
wire:loading.attr="disabled" type="{{ $type }}" @disabled($disabled)
|
wire:loading.attr="disabled" type="{{ $type }}" @disabled($disabled)
|
||||||
id="{{ $id }}" name="{{ $name }}">
|
id="{{ $id }}" name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}">
|
||||||
@endif
|
@endif
|
||||||
@if (!$label && $helper)
|
@if (!$label && $helper)
|
||||||
<x-helper :helper="$helper" />
|
<x-helper :helper="$helper" />
|
||||||
|
@ -24,11 +24,25 @@
|
|||||||
|
|
||||||
<body>
|
<body>
|
||||||
@livewireScripts
|
@livewireScripts
|
||||||
|
<x-toaster-hub />
|
||||||
<main>
|
<main>
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
</main>
|
</main>
|
||||||
<x-version class="fixed left-2 bottom-1" />
|
<x-version class="fixed left-2 bottom-1" />
|
||||||
<script>
|
<script>
|
||||||
|
Livewire.on('info', (message) => {
|
||||||
|
if (message) Toaster.info(message)
|
||||||
|
})
|
||||||
|
Livewire.on('error', (message) => {
|
||||||
|
if (message) Toaster.error(message)
|
||||||
|
})
|
||||||
|
Livewire.on('warning', (message) => {
|
||||||
|
if (message) Toaster.warning(message)
|
||||||
|
})
|
||||||
|
Livewire.on('success', (message) => {
|
||||||
|
if (message) Toaster.success(message)
|
||||||
|
})
|
||||||
|
|
||||||
function changePasswordFieldType(event) {
|
function changePasswordFieldType(event) {
|
||||||
let element = event.target
|
let element = event.target
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
@ -47,6 +61,7 @@ function changePasswordFieldType(event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
@ -0,0 +1,8 @@
|
|||||||
|
@if ($pull_request_id !== 0)
|
||||||
|
Pull Request #{{ $pull_request_id }} of {{ $name }} (<a target="_blank"
|
||||||
|
href="{{ $fqdn }}">{{ $fqdn }}</a>) deployment failed:
|
||||||
|
@else
|
||||||
|
Deployment failed of {{ $name }} (<a target="_blank" href="{{ $fqdn }}">{{ $fqdn }}</a>):
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<a target="_blank" href="{{ $deployment_url }}">View Deployment Logs</a><br><br>
|
@ -1,7 +0,0 @@
|
|||||||
@if ($pull_request_id !== 0)
|
|
||||||
Pull Request #{{ $pull_request_id }} of {{ $name }} (<a target="_blank" href="{{ $fqdn }}">{{ $fqdn }}</a>) deployment failed:
|
|
||||||
@else
|
|
||||||
Deployment failed of {{ $name }} (<a target="_blank" href="{{ $fqdn }}">{{ $fqdn }}</a>):
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<a target="_blank" href="{{ $deployment_url }}">View Deployment Logs</a><br><br>
|
|
@ -0,0 +1,8 @@
|
|||||||
|
@if ($pull_request_id === 0)
|
||||||
|
A new version of <a target="_blank" href="{{ $fqdn }}">{{ $fqdn }}</a> is available:
|
||||||
|
@else
|
||||||
|
Pull request #{{ $pull_request_id }} of {{ $name }} deployed successfully: <a target="_blank"
|
||||||
|
href="{{ $fqdn }}">Application Link</a> |
|
||||||
|
@endif
|
||||||
|
<a target="_blank" href="{{ $deployment_url }}">View
|
||||||
|
Deployment Logs</a><br><br>
|
@ -1,7 +0,0 @@
|
|||||||
@if ($pull_request_id === 0)
|
|
||||||
A new version of <a target="_blank" href="{{ $fqdn }}">{{ $fqdn }}</a> is available:
|
|
||||||
@else
|
|
||||||
Pull request #{{ $pull_request_id }} of {{ $name }} deployed successfully: <a target="_blank" href="{{ $fqdn }}">Application Link</a> |
|
|
||||||
@endif
|
|
||||||
<a target="_blank" href="{{ $deployment_url }}">View
|
|
||||||
Deployment Logs</a><br><br>
|
|
@ -1,8 +1,6 @@
|
|||||||
Hello,<br><br>
|
|
||||||
|
|
||||||
A password reset requested for your email address on "{{ config('app.name') }}".<br><br>
|
A password reset requested for your email address on "{{ config('app.name') }}".<br><br>
|
||||||
|
|
||||||
Please click the following link to reset your password: <a target="_blank" href="{{ $url }}">Password
|
Please click the following link to reset your password: <a target="_blank" href="{{ $url }}">Password
|
||||||
Reset</a><br><br>
|
Reset</a><br><br>
|
||||||
|
|
||||||
This password reset link will expire in "{{ $count }}" minutes.
|
This password reset link will expire in {{ $count }} minutes.
|
||||||
|
4
resources/views/emails/waitlist-confirmation.blade.php
Normal file
4
resources/views/emails/waitlist-confirmation.blade.php
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
Someone added this email to the Coolify Cloud's waitlist.
|
||||||
|
<br>
|
||||||
|
<a href="{{ $confirmation_url }}">Click here to confirm</a>! The link will expire in {{config('constants.waitlist.confirmation_valid_for_minutes')}} minutes.<br><br>
|
||||||
|
You have no idea what <a href="https://cloud.coolify.io">Coolify Cloud</a> is or this waitlist? <a href="{{ $cancel_url }}">Click here to remove</a> you from the waitlist.
|
18
resources/views/livewire/force-password-reset.blade.php
Normal file
18
resources/views/livewire/force-password-reset.blade.php
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<div class="min-h-screen hero">
|
||||||
|
<div class="w-96 min-w-fit">
|
||||||
|
<div class="flex flex-col items-center pb-8">
|
||||||
|
<a href="{{ route('dashboard') }}">
|
||||||
|
<div class="text-5xl font-bold tracking-tight text-center text-white">Coolify</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-center pb-4 text-center">
|
||||||
|
<h2>Set your initial password</h2>
|
||||||
|
</div>
|
||||||
|
<form class="flex flex-col gap-2" wire:submit.prevent='submit'>
|
||||||
|
<x-forms.input id="email" type="email" placeholder="Email" readonly />
|
||||||
|
<x-forms.input id="password" type="password" placeholder="New Password" />
|
||||||
|
<x-forms.input id="password_confirmation" type="password" placeholder="Confirm New Password" />
|
||||||
|
<x-forms.button type="submit">Reset Password</x-forms.button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
23
resources/views/livewire/waitlist.blade.php
Normal file
23
resources/views/livewire/waitlist.blade.php
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<div class="min-h-screen hero">
|
||||||
|
<div class="w-96 min-w-fit">
|
||||||
|
<div class="flex items-center justify-center pb-4 text-center">
|
||||||
|
<h2>Start self-hosting in the
|
||||||
|
<svg class="inline-block w-8 h-8 text-warning width="512" height="512" viewBox="0 0 20 20"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g fill="currentColor" fill-rule="evenodd" clip-rule="evenodd">
|
||||||
|
<path
|
||||||
|
d="M13 4h-1a4.002 4.002 0 0 0-3.874 3H8a4 4 0 1 0 0 8h8a4 4 0 0 0 .899-7.899A4.002 4.002 0 0 0 13 4Z"
|
||||||
|
opacity=".2" />
|
||||||
|
<path
|
||||||
|
d="M11 3h-1a4.002 4.002 0 0 0-3.874 3H6a4 4 0 1 0 0 8h8a4 4 0 0 0 .899-7.899A4.002 4.002 0 0 0 11 3ZM6.901 7l.193-.75A3.002 3.002 0 0 1 10 4h1c1.405 0 2.614.975 2.924 2.325l.14.61l.61.141A3.001 3.001 0 0 1 14 13H6a3 3 0 1 1 0-6h.901Z" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<form class="flex items-end gap-2" wire:submit.prevent='submit'>
|
||||||
|
<x-forms.input id="email" type="email" label="Email" placeholder="youareawesome@protonmail.com" />
|
||||||
|
<x-forms.button type="submit">Join Waitlist</x-forms.button>
|
||||||
|
</form>
|
||||||
|
Waiting: {{$waiting_in_line}}
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -38,6 +38,8 @@
|
|||||||
}
|
}
|
||||||
return response()->json(['message' => 'Transactional emails are not active'], 400);
|
return response()->json(['message' => 'Transactional emails are not active'], 400);
|
||||||
})->name('password.forgot');
|
})->name('password.forgot');
|
||||||
|
Route::get('/waitlist', [Controller::class, 'waitlist'])->name('auth.waitlist');
|
||||||
|
|
||||||
Route::prefix('magic')->middleware(['auth'])->group(function () {
|
Route::prefix('magic')->middleware(['auth'])->group(function () {
|
||||||
Route::get('/servers', [MagicController::class, 'servers']);
|
Route::get('/servers', [MagicController::class, 'servers']);
|
||||||
Route::get('/destinations', [MagicController::class, 'destinations']);
|
Route::get('/destinations', [MagicController::class, 'destinations']);
|
||||||
@ -91,6 +93,7 @@
|
|||||||
|
|
||||||
Route::middleware(['auth'])->group(function () {
|
Route::middleware(['auth'])->group(function () {
|
||||||
Route::get('/', [Controller::class, 'dashboard'])->name('dashboard');
|
Route::get('/', [Controller::class, 'dashboard'])->name('dashboard');
|
||||||
|
Route::get('/force-password-reset', [Controller::class, 'force_passoword_reset'])->name('auth.force-password-reset');
|
||||||
Route::get('/subscription', [Controller::class, 'subscription'])->name('subscription');
|
Route::get('/subscription', [Controller::class, 'subscription'])->name('subscription');
|
||||||
Route::get('/settings', [Controller::class, 'settings'])->name('settings.configuration');
|
Route::get('/settings', [Controller::class, 'settings'])->name('settings.configuration');
|
||||||
Route::get('/settings/license', [Controller::class, 'license'])->name('settings.license');
|
Route::get('/settings/license', [Controller::class, 'license'])->name('settings.license');
|
||||||
|
@ -6,6 +6,7 @@
|
|||||||
use App\Models\PrivateKey;
|
use App\Models\PrivateKey;
|
||||||
use App\Models\Subscription;
|
use App\Models\Subscription;
|
||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
|
use App\Models\Waitlist;
|
||||||
use App\Models\Webhook;
|
use App\Models\Webhook;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
@ -139,7 +140,7 @@
|
|||||||
ApplicationPreview::create([
|
ApplicationPreview::create([
|
||||||
'application_id' => $application->id,
|
'application_id' => $application->id,
|
||||||
'pull_request_id' => $pull_request_id,
|
'pull_request_id' => $pull_request_id,
|
||||||
'pull_request_html_url' => $pull_request_html_url
|
'pull_request_html_url' => $pull_request_html_url,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
queue_application_deployment(
|
queue_application_deployment(
|
||||||
@ -175,6 +176,38 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (is_cloud()) {
|
if (is_cloud()) {
|
||||||
|
Route::get('/waitlist/confirm', function () {
|
||||||
|
$email = request()->get('email');
|
||||||
|
$confirmation_code = request()->get('confirmation_code');
|
||||||
|
ray($email, $confirmation_code);
|
||||||
|
try {
|
||||||
|
$found = Waitlist::where('uuid', $confirmation_code)->where('email', $email)->first();
|
||||||
|
if ($found && !$found->verified && $found->created_at > now()->subMinutes(config('constants.waitlist.confirmation_valid_for_minutes'))) {
|
||||||
|
$found->verified = true;
|
||||||
|
$found->save();
|
||||||
|
return 'Thank you for confirming your email address. We will notify you when you are next in line.';
|
||||||
|
}
|
||||||
|
return redirect()->route('dashboard');
|
||||||
|
} catch (error) {
|
||||||
|
return redirect()->route('dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
})->name('webhooks.waitlist.confirm');
|
||||||
|
Route::get('/waitlist/cancel', function () {
|
||||||
|
$email = request()->get('email');
|
||||||
|
$confirmation_code = request()->get('confirmation_code');
|
||||||
|
try {
|
||||||
|
$found = Waitlist::where('uuid', $confirmation_code)->where('email', $email)->first();
|
||||||
|
if ($found && !$found->verified) {
|
||||||
|
$found->delete();
|
||||||
|
return 'Your email address has been removed from the waitlist.';
|
||||||
|
}
|
||||||
|
return redirect()->route('dashboard');
|
||||||
|
} catch (error) {
|
||||||
|
return redirect()->route('dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
})->name('webhooks.waitlist.cancel');
|
||||||
Route::post('/payments/events', function () {
|
Route::post('/payments/events', function () {
|
||||||
try {
|
try {
|
||||||
$secret = config('coolify.lemon_squeezy_webhook_secret');
|
$secret = config('coolify.lemon_squeezy_webhook_secret');
|
||||||
@ -188,7 +221,7 @@
|
|||||||
|
|
||||||
$webhook = Webhook::create([
|
$webhook = Webhook::create([
|
||||||
'type' => 'lemonsqueezy',
|
'type' => 'lemonsqueezy',
|
||||||
'payload' => $payload
|
'payload' => $payload,
|
||||||
]);
|
]);
|
||||||
$event = data_get($payload, 'meta.event_name');
|
$event = data_get($payload, 'meta.event_name');
|
||||||
ray('Subscription event: ' . $event);
|
ray('Subscription event: ' . $event);
|
||||||
@ -256,7 +289,7 @@
|
|||||||
ray($e->getMessage());
|
ray($e->getMessage());
|
||||||
$webhook->update([
|
$webhook->update([
|
||||||
'status' => 'failed',
|
'status' => 'failed',
|
||||||
'failure_reason' => $e->getMessage()
|
'failure_reason' => $e->getMessage(),
|
||||||
]);
|
]);
|
||||||
} finally {
|
} finally {
|
||||||
return response('OK');
|
return response('OK');
|
||||||
|
Loading…
Reference in New Issue
Block a user