diff --git a/app/Http/Controllers/Settings/SubscriptionController.php b/app/Http/Controllers/Settings/SubscriptionController.php index 149f1d2533..bd94b5c7cf 100644 --- a/app/Http/Controllers/Settings/SubscriptionController.php +++ b/app/Http/Controllers/Settings/SubscriptionController.php @@ -72,7 +72,7 @@ public function index() )); } - public function change(Request $request) + public function change(Request $request, Tier $tier) { $user = $request->user(); $period = $request->get('period', 'monthly'); diff --git a/app/Http/Controllers/User/EmailValidationController.php b/app/Http/Controllers/User/EmailValidationController.php new file mode 100644 index 0000000000..57eeb203d4 --- /dev/null +++ b/app/Http/Controllers/User/EmailValidationController.php @@ -0,0 +1,32 @@ +get('token'); + + /** @var UserValidation $validation */ + $validation = UserValidation::where('user_id', $user->id) + ->where('token', $token) + ->first(); + + if ($validation->exists) { + $validation->is_valid = true; + $validation->saveQuietly(); + } else { + response()->redirectTo(route('settings.subscription'))->withError(__('emails/validation.error')); + } + + return response()->redirectTo(route('settings.subscription'))->withSuccess(__('emails/validation.success')); + } +} diff --git a/app/Jobs/Emails/Subscriptions/EmailValidationJob.php b/app/Jobs/Emails/Subscriptions/EmailValidationJob.php new file mode 100644 index 0000000000..6711f35309 --- /dev/null +++ b/app/Jobs/Emails/Subscriptions/EmailValidationJob.php @@ -0,0 +1,53 @@ +user = $user->id; + $this->token = $token; + } + + /** + * @throws \Illuminate\Contracts\Container\BindingResolutionException + */ + public function handle() + { + // User deleted their account already? Sure thing + /** @var User|null $user */ + $user = User::find($this->user); + if (empty($user)) { + return; + } + + // Send an email to the user + Mail::to($user->email) + ->locale($user->locale) + ->send( + new ValidationEmail($user, $this->token) + ); + } +} diff --git a/app/Mail/Subscription/User/ValidationEmail.php b/app/Mail/Subscription/User/ValidationEmail.php new file mode 100644 index 0000000000..cffa1f9677 --- /dev/null +++ b/app/Mail/Subscription/User/ValidationEmail.php @@ -0,0 +1,49 @@ +user = $user; + $this->token = $token; + } + + /** + * Build the message. + * + * @return $this + */ + public function build() + { + return $this + ->from(['address' => config('app.email'), 'name' => 'Kanka Team']) + ->subject(__('emails/subscriptions/validation.title')) + ->view('emails.subscriptions.validation.user-html') + ->tag('validation'); + } +} diff --git a/app/Models/UserFlag.php b/app/Models/UserFlag.php index f82c2ed228..87d139e5af 100644 --- a/app/Models/UserFlag.php +++ b/app/Models/UserFlag.php @@ -16,6 +16,7 @@ class UserFlag extends Model public const FLAG_INACTIVE_1 = 'inactive_1'; public const FLAG_INACTIVE_2 = 'inactive_2'; + public const FLAG_EMAIL = 'email'; public function user() { diff --git a/app/Models/UserValidation.php b/app/Models/UserValidation.php new file mode 100644 index 0000000000..2d74232793 --- /dev/null +++ b/app/Models/UserValidation.php @@ -0,0 +1,36 @@ +belongsTo(User::class); + } + + /** + * Automatically prune old elements from the db + * @return \Illuminate\Database\Eloquent\Builder + */ + public function prunable() + { + return static::where('is_valid', false)->where('created_at', '<=', now()->subDays(1)); + } +} diff --git a/app/User.php b/app/User.php index 30f5d4ee20..176a7a239d 100644 --- a/app/User.php +++ b/app/User.php @@ -17,8 +17,11 @@ use App\Models\Scopes\UserScope; use App\Models\UserLog; use App\Models\UserSetting; +use App\Models\UserFlag; +use App\Models\UserValidation; use App\Models\Relations\UserRelations; use Carbon\Carbon; +use App\Jobs\Emails\Subscriptions\EmailValidationJob; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Collection; @@ -433,6 +436,12 @@ public function isFrauding(): bool if (!empty($this->provider)) { return false; } + + $validation = UserValidation::where('user_id', $this->id)->where('is_valid', true)->first(); + if ($validation) { + return false; + } + // If the account was created recently, add some small checks /*if ($this->created_at->isAfter(Carbon::now()->subHour())) { // User's name is directly in the campaign name @@ -453,6 +462,35 @@ public function isFrauding(): bool ->count() >= 2; } + public function requiresEmail(): self + { + $token = UserValidation::where('user_id', $this->id)->first(); + if ($token && $token->is_valid) { + return $this; + } + //Check for existing token + $flag = UserFlag::where('user_id', $this->id)->where('flag', UserFlag::FLAG_EMAIL)->first(); + + if (!$flag) { + $flag = new UserFlag(); + $flag->user_id = $this->id; + $flag->flag = UserFlag::FLAG_EMAIL; + $flag->save(); + } + + if (!$token) { + $token = new UserValidation(); + $token->token = Str::uuid(); + $token->user_id = $this->id; + $token->is_valid = false; + $token->save(); + } + + EmailValidationJob::dispatch($this, $token->token); + + return $this; + } + /** * List of campaigns the user is the only admin of. This is used for the automatic purge warning emails */ diff --git a/database/migrations/2024_01_22_203110_create_user_validations_table.php b/database/migrations/2024_01_22_203110_create_user_validations_table.php new file mode 100644 index 0000000000..0d09e9d31a --- /dev/null +++ b/database/migrations/2024_01_22_203110_create_user_validations_table.php @@ -0,0 +1,35 @@ +id(); + $table->uuid('token'); + $table->unsignedInteger('user_id'); + $table->boolean('is_valid'); + $table->timestamps(); + + $table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete(); + $table->index(['is_valid']); + + + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('user_validations'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 0918ef9a8b..5b62c76603 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -25,5 +25,6 @@ public function run() $this->call(PostLayoutTableSeeder::class); $this->call(FeatureCategorySeeder::class); $this->call(FeatureStatusSeeder::class); + $this->call(TierSeeder::class); } } diff --git a/lang/en/emails/validation.php b/lang/en/emails/validation.php new file mode 100644 index 0000000000..53a784175e --- /dev/null +++ b/lang/en/emails/validation.php @@ -0,0 +1,7 @@ + 'Email validation required for subscription, please check your inbox to continue the validation process.', + 'success' => 'Email succesfully validated.', + 'error' => 'Validation failed, please try again.', +]; diff --git a/resources/views/emails/subscriptions/validation/user-html.blade.php b/resources/views/emails/subscriptions/validation/user-html.blade.php new file mode 100644 index 0000000000..c6e49893ff --- /dev/null +++ b/resources/views/emails/subscriptions/validation/user-html.blade.php @@ -0,0 +1,22 @@ +@extends('emails.base', [ + 'utmSource' => 'subscription', + 'utmCampaign' => 'failed-charge' +]) + +@section('content') +
+ Email Validation +
++ {{ __('emails/subscriptions/upcoming.dear', ['name' => $user->name]) }}, +
+ +This is an automatic notification.
+To validate the email for your Kanka account click here. This link will expire in 24 hours.
+If the above link doesnt work, open the following URL in your web browser {{ 'https://app.kanka.io/users/' . $user->id . '/validation?token=' . $token }}
+
+ {{ __('emails/subscriptions/upcoming.closing') }}
+ The Kanka Team
+