diff --git a/src/app/Http/Controllers/API/SignupController.php b/src/app/Http/Controllers/API/SignupController.php --- a/src/app/Http/Controllers/API/SignupController.php +++ b/src/app/Http/Controllers/API/SignupController.php @@ -12,6 +12,7 @@ use App\Rules\UserEmailDomain; use App\Rules\UserEmailLocal; use App\SignupCode; +use App\SignupInvitation; use App\User; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -113,6 +114,33 @@ return response()->json(['status' => 'success', 'code' => $code->code]); } + /** + * Returns signup invitation information. + * + * @param string $id Signup invitation identifier + * + * @return \Illuminate\Http\JsonResponse|void + */ + public function invitation($id) + { + $invitation = SignupInvitation::withEnvTenant()->find($id); + + if (empty($invitation) || $invitation->isCompleted()) { + return $this->errorResponse(404); + } + + $has_domain = $this->getPlan()->hasDomain(); + + $result = [ + 'id' => $id, + 'is_domain' => $has_domain, + 'domains' => $has_domain ? [] : Domain::getPublicDomains(), + ]; + + return response()->json($result); + } + + /** * Validation of the verification code. * @@ -190,10 +218,50 @@ return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } - // Validate verification codes (again) - $v = $this->verify($request); - if ($v->status() !== 200) { - return $v; + // Signup via invitation + if ($request->invitation) { + $invitation = SignupInvitation::withEnvTenant()->find($request->invitation); + + if (empty($invitation) || $invitation->isCompleted()) { + return $this->errorResponse(404); + } + + // Check required fields + $v = Validator::make( + $request->all(), + [ + 'first_name' => 'max:128', + 'last_name' => 'max:128', + 'voucher' => 'max:32', + ] + ); + + $errors = $v->fails() ? $v->errors()->toArray() : []; + + if (!empty($errors)) { + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + + $settings = [ + 'external_email' => $invitation->email, + 'first_name' => $request->first_name, + 'last_name' => $request->last_name, + ]; + } else { + // Validate verification codes (again) + $v = $this->verify($request); + if ($v->status() !== 200) { + return $v; + } + + // Get user name/email from the verification code database + $code_data = $v->getData(); + + $settings = [ + 'external_email' => $code_data->email, + 'first_name' => $code_data->first_name, + 'last_name' => $code_data->last_name, + ]; } // Find the voucher discount @@ -219,10 +287,6 @@ return response()->json(['status' => 'error', 'errors' => $errors], 422); } - // Get user name/email from the verification code database - $code_data = $v->getData(); - $user_email = $code_data->email; - // We allow only ASCII, so we can safely lower-case the email address $login = Str::lower($login); $domain_name = Str::lower($domain_name); @@ -254,14 +318,19 @@ $user->assignPlan($plan, $domain); // Save the external email and plan in user settings - $user->setSettings([ - 'external_email' => $user_email, - 'first_name' => $code_data->first_name, - 'last_name' => $code_data->last_name, - ]); + $user->setSettings($settings); + + // Update the invitation + if (!empty($invitation)) { + $invitation->status = SignupInvitation::STATUS_COMPLETED; + $invitation->user_id = $user->id; + $invitation->save(); + } // Remove the verification code - $this->code->delete(); + if ($this->code) { + $this->code->delete(); + } DB::commit(); diff --git a/src/app/Http/Controllers/API/V4/Reseller/InvitationsController.php b/src/app/Http/Controllers/API/V4/Reseller/InvitationsController.php new file mode 100644 --- /dev/null +++ b/src/app/Http/Controllers/API/V4/Reseller/InvitationsController.php @@ -0,0 +1,251 @@ +errorResponse(404); + } + + /** + * Remove the specified invitation. + * + * @param int $id Invitation identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function destroy($id) + { + $invitation = SignupInvitation::withUserTenant()->find($id); + + if (empty($invitation)) { + return $this->errorResponse(404); + } + + $invitation->delete(); + + return response()->json([ + 'status' => 'success', + 'message' => trans('app.signup-invitation-delete-success'), + ]); + } + + /** + * Show the form for editing the specified resource. + * + * @param int $id Invitation identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function edit($id) + { + return $this->errorResponse(404); + } + + /** + * Display a listing of the resource. + * + * @return \Illuminate\Http\JsonResponse + */ + public function index() + { + $pageSize = 10; + $search = request()->input('search'); + $page = intval(request()->input('page')) ?: 1; + $hasMore = false; + + $result = SignupInvitation::withUserTenant() + ->latest() + ->limit($pageSize + 1) + ->offset($pageSize * ($page - 1)); + + if ($search) { + if (strpos($search, '@')) { + $result->where('email', $search); + } else { + $result->whereLike('email', $search); + } + } + + $result = $result->get(); + + if (count($result) > $pageSize) { + $result->pop(); + $hasMore = true; + } + + $result = $result->map(function ($invitation) { + return $this->invitationToArray($invitation); + }); + + return response()->json([ + 'status' => 'success', + 'list' => $result, + 'count' => count($result), + 'hasMore' => $hasMore, + 'page' => $page, + ]); + } + + /** + * Resend the specified invitation. + * + * @param int $id Invitation identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function resend($id) + { + $invitation = SignupInvitation::withUserTenant()->find($id); + + if (empty($invitation)) { + return $this->errorResponse(404); + } + + if ($invitation->isFailed() || $invitation->isSent()) { + // Note: The email sending job will be dispatched by the observer + $invitation->status = SignupInvitation::STATUS_NEW; + $invitation->save(); + } + + return response()->json([ + 'status' => 'success', + 'message' => trans('app.signup-invitation-resend-success'), + 'invitation' => $this->invitationToArray($invitation), + ]); + } + + /** + * Store a newly created resource in storage. + * + * @param \Illuminate\Http\Request $request + * + * @return \Illuminate\Http\JsonResponse + */ + public function store(Request $request) + { + $errors = []; + $invitations = []; + + if (!empty($request->file) && is_object($request->file)) { + // Expected a text/csv file with multiple email addresses + if (!$request->file->isValid()) { + $errors = ['file' => [$request->file->getErrorMessage()]]; + } else { + $fh = fopen($request->file->getPathname(), 'r'); + $line_number = 0; + $error = null; + + while ($line = fgetcsv($fh)) { + $line_number++; + + if (count($line) >= 1 && $line[0]) { + $email = trim($line[0]); + + if (strpos($email, '@')) { + $v = Validator::make(['email' => $email], ['email' => 'email:filter|required']); + + if ($v->fails()) { + $args = ['email' => $email, 'line' => $line_number]; + $error = trans('app.signup-invitations-csv-invalid-email', $args); + break; + } + + $invitations[] = ['email' => $email]; + } + } + } + + fclose($fh); + + if ($error) { + $errors = ['file' => $error]; + } elseif (empty($invitations)) { + $errors = ['file' => trans('app.signup-invitations-csv-empty')]; + } + } + } else { + // Expected 'email' field with an email address + $v = Validator::make($request->all(), ['email' => 'email|required']); + + if ($v->fails()) { + $errors = $v->errors()->toArray(); + } else { + $invitations[] = ['email' => $request->email]; + } + } + + if (!empty($errors)) { + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + + $count = 0; + foreach ($invitations as $idx => $invitation) { + SignupInvitation::create($invitation); + $count++; + } + + return response()->json([ + 'status' => 'success', + 'message' => \trans_choice('app.signup-invitations-created', $count, ['count' => $count]), + 'count' => $count, + ]); + } + + /** + * Display the specified resource. + * + * @param int $id Invitation identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function show($id) + { + return $this->errorResponse(404); + } + + /** + * Update the specified resource in storage. + * + * @param \Illuminate\Http\Request $request + * @param int $id + * + * @return \Illuminate\Http\JsonResponse + */ + public function update(Request $request, $id) + { + return $this->errorResponse(404); + } + + /** + * Convert an invitation object to an array for output + * + * @param \App\SignupInvitation $invitation The signup invitation object + * + * @return array + */ + protected static function invitationToArray(SignupInvitation $invitation): array + { + return [ + 'id' => $invitation->id, + 'email' => $invitation->email, + 'isNew' => $invitation->isNew(), + 'isSent' => $invitation->isSent(), + 'isFailed' => $invitation->isFailed(), + 'isCompleted' => $invitation->isCompleted(), + 'created' => $invitation->created_at->toDateTimeString(), + ]; + } +} diff --git a/src/app/Jobs/SignupInvitationEmail.php b/src/app/Jobs/SignupInvitationEmail.php new file mode 100644 --- /dev/null +++ b/src/app/Jobs/SignupInvitationEmail.php @@ -0,0 +1,75 @@ +invitation = $invitation; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + Mail::to($this->invitation->email)->send(new SignupInvitationMail($this->invitation)); + + // Update invitation status + $this->invitation->status = SignupInvitation::STATUS_SENT; + $this->invitation->save(); + } + + /** + * The job failed to process. + * + * @param \Exception $exception + * + * @return void + */ + public function failed(\Exception $exception) + { + if ($this->attempts() >= $this->tries) { + // Update invitation status + $this->invitation->status = SignupInvitation::STATUS_FAILED; + $this->invitation->save(); + } + } +} diff --git a/src/app/Mail/SignupInvitation.php b/src/app/Mail/SignupInvitation.php new file mode 100644 --- /dev/null +++ b/src/app/Mail/SignupInvitation.php @@ -0,0 +1,72 @@ +invitation = $invitation; + } + + /** + * Build the message. + * + * @return $this + */ + public function build() + { + $href = Utils::serviceUrl('/signup/invite/' . $this->invitation->id); + + $this->view('emails.html.signup_invitation') + ->text('emails.plain.signup_invitation') + ->subject(__('mail.signupinvitation-subject', ['site' => \config('app.name')])) + ->with([ + 'site' => \config('app.name'), + 'href' => $href, + ]); + + return $this; + } + + /** + * Render the mail template with fake data + * + * @param string $type Output format ('html' or 'text') + * + * @return string HTML or Plain Text output + */ + public static function fakeRender(string $type = 'html'): string + { + $invitation = new SI([ + 'email' => 'test@external.org', + ]); + + $invitation->id = Utils::uuidStr(); + + $mail = new self($invitation); + + return Helper::render($mail, $type); + } +} diff --git a/src/app/Observers/SignupInvitationObserver.php b/src/app/Observers/SignupInvitationObserver.php new file mode 100644 --- /dev/null +++ b/src/app/Observers/SignupInvitationObserver.php @@ -0,0 +1,65 @@ +{$invitation->getKeyName()} = $allegedly_unique; + break; + } + } + + $invitation->status = SI::STATUS_NEW; + + $invitation->tenant_id = \config('app.tenant_id'); + } + + /** + * Handle the invitation "created" event. + * + * @param \App\SignupInvitation $invitation The invitation object + * + * @return void + */ + public function created(SI $invitation) + { + \App\Jobs\SignupInvitationEmail::dispatch($invitation); + } + + /** + * Handle the invitation "updated" event. + * + * @param \App\SignupInvitation $invitation The invitation object + * + * @return void + */ + public function updated(SI $invitation) + { + $oldStatus = $invitation->getOriginal('status'); + + // Resend the invitation + if ( + $invitation->status == SI::STATUS_NEW + && ($oldStatus == SI::STATUS_FAILED || $oldStatus == SI::STATUS_SENT) + ) { + \App\Jobs\SignupInvitationEmail::dispatch($invitation); + } + } +} diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -36,6 +36,7 @@ \App\PackageSku::observe(\App\Observers\PackageSkuObserver::class); \App\Plan::observe(\App\Observers\PlanObserver::class); \App\SignupCode::observe(\App\Observers\SignupCodeObserver::class); + \App\SignupInvitation::observe(\App\Observers\SignupInvitationObserver::class); \App\Sku::observe(\App\Observers\SkuObserver::class); \App\Transaction::observe(\App\Observers\TransactionObserver::class); \App\User::observe(\App\Observers\UserObserver::class); @@ -84,5 +85,24 @@ /** @var Builder $this */ return $this->whereNull(($table ? "$table." : '') . 'tenant_id'); }); + + // Query builder 'whereLike' mocro + Builder::macro('whereLike', function (string $column, string $search, int $mode = 0) { + $search = addcslashes($search, '%_'); + + switch ($mode) { + case 2: + $search .= '%'; + break; + case 1: + $search = '%' . $search; + break; + default: + $search = '%' . $search . '%'; + } + + /** @var Builder $this */ + return $this->where($column, 'like', $search); + }); } } diff --git a/src/app/SignupInvitation.php b/src/app/SignupInvitation.php new file mode 100644 --- /dev/null +++ b/src/app/SignupInvitation.php @@ -0,0 +1,109 @@ +status & self::STATUS_COMPLETED) > 0; + } + + /** + * Returns whether this invitation sending failed. + * + * @return bool + */ + public function isFailed(): bool + { + return ($this->status & self::STATUS_FAILED) > 0; + } + + /** + * Returns whether this invitation is new. + * + * @return bool + */ + public function isNew(): bool + { + return ($this->status & self::STATUS_NEW) > 0; + } + + /** + * Returns whether this invitation has been sent. + * + * @return bool + */ + public function isSent(): bool + { + return ($this->status & self::STATUS_SENT) > 0; + } + + /** + * The tenant for this invitation. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function tenant() + { + return $this->belongsTo('App\Tenant', 'tenant_id', 'id'); + } + + /** + * The account to which the invitation was used for. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function user() + { + return $this->belongsTo('App\User', 'user_id', 'id'); + } +} diff --git a/src/app/Tenant.php b/src/app/Tenant.php --- a/src/app/Tenant.php +++ b/src/app/Tenant.php @@ -27,4 +27,14 @@ { return $this->hasMany('App\Discount'); } + + /** + * SignupInvitations assigned to this tenant. + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function signupInvitations() + { + return $this->hasMany('App\SignupInvitation'); + } } diff --git a/src/database/migrations/2021_03_26_080000_create_signup_invitations_table.php b/src/database/migrations/2021_03_26_080000_create_signup_invitations_table.php new file mode 100644 --- /dev/null +++ b/src/database/migrations/2021_03_26_080000_create_signup_invitations_table.php @@ -0,0 +1,49 @@ +string('id', 36); + $table->string('email'); + $table->smallInteger('status'); + $table->bigInteger('user_id')->nullable(); + $table->bigInteger('tenant_id')->unsigned()->nullable(); + $table->timestamps(); + + $table->primary('id'); + + $table->index('email'); + $table->index('created_at'); + + $table->foreign('tenant_id')->references('id')->on('tenants') + ->onUpdate('cascade')->onDelete('set null'); + $table->foreign('user_id')->references('id')->on('users') + ->onUpdate('cascade')->onDelete('set null'); + } + ); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('signup_invitations'); + } +} diff --git a/src/phpstan.neon b/src/phpstan.neon --- a/src/phpstan.neon +++ b/src/phpstan.neon @@ -7,10 +7,12 @@ - '#Access to an undefined property Illuminate\\Database\\Eloquent\\Model::\$id#' - '#Access to an undefined property Illuminate\\Database\\Eloquent\\Model::\$created_at#' - '#Call to an undefined method Illuminate\\Database\\Eloquent\\Model::toString\(\)#' - - '#Call to an undefined method Illuminate\\Database\\Eloquent\\Builder[^:]*::withEnvTenant\(\)#' - - '#Call to an undefined method Illuminate\\Database\\Eloquent\\Builder[^:]*::withUserTenant\(\)#' + - '#Call to an undefined [a-zA-Z0-9<>\\ ]+::withEnvTenant\(\)#' + - '#Call to an undefined [a-zA-Z0-9<>\\ ]+::withUserTenant\(\)#' - '#Call to an undefined method Tests\\Browser::#' level: 4 + parallel: + processTimeout: 300.0 paths: - app/ - tests/ diff --git a/src/resources/js/app.js b/src/resources/js/app.js --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -425,7 +425,7 @@ } if (input.length) { - // Create an error message\ + // Create an error message // API responses can use a string, array or object let msg_text = '' if ($.type(msg) !== 'string') { diff --git a/src/resources/js/reseller/routes.js b/src/resources/js/reseller/routes.js --- a/src/resources/js/reseller/routes.js +++ b/src/resources/js/reseller/routes.js @@ -1,5 +1,6 @@ import DashboardComponent from '../../vue/Reseller/Dashboard' import DomainComponent from '../../vue/Admin/Domain' +import InvitationsComponent from '../../vue/Reseller/Invitations' import LoginComponent from '../../vue/Login' import LogoutComponent from '../../vue/Logout' import PageComponent from '../../vue/Page' @@ -33,6 +34,12 @@ name: 'logout', component: LogoutComponent }, + { + path: '/invitations', + name: 'invitations', + component: InvitationsComponent, + meta: { requiresAuth: true } + }, /* { path: '/stats', diff --git a/src/resources/js/user/routes.js b/src/resources/js/user/routes.js --- a/src/resources/js/user/routes.js +++ b/src/resources/js/user/routes.js @@ -76,6 +76,11 @@ component: MeetComponent, meta: { requiresAuth: true } }, + { + path: '/signup/invite/:param', + name: 'signup-invite', + component: SignupComponent + }, { path: '/signup/:param?', alias: '/signup/voucher/:param', diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php --- a/src/resources/lang/en/app.php +++ b/src/resources/lang/en/app.php @@ -45,6 +45,12 @@ 'search-foundxdomains' => ':x domains have been found.', 'search-foundxusers' => ':x user accounts have been found.', + 'signup-invitations-created' => 'The invitation has been created.|:count invitations has been created.', + 'signup-invitations-csv-empty' => 'Failed to find any valid email addresses in the uploaded file.', + 'signup-invitations-csv-invalid-email' => 'Found an invalid email address (:email) on line :line.', + 'signup-invitation-delete-success' => 'Invitation deleted successfully.', + 'signup-invitation-resend-success' => 'Invitation added to the sending queue successfully.', + 'support-request-success' => 'Support request submitted successfully.', 'support-request-error' => 'Failed to submit the support request.', diff --git a/src/resources/lang/en/mail.php b/src/resources/lang/en/mail.php --- a/src/resources/lang/en/mail.php +++ b/src/resources/lang/en/mail.php @@ -75,6 +75,11 @@ 'signupcode-body1' => "This is your verification code for the :site registration process:", 'signupcode-body2' => "You can also click the link below to continue the registration process:", + 'signupinvitation-subject' => ":site Invitation", + 'signupinvitation-header' => "Hi,", + 'signupinvitation-body1' => "You have been invited to join :site. Click the link below to sign up.", + 'signupinvitation-body2' => "", + 'suspendeddebtor-subject' => ":site Account Suspended", 'suspendeddebtor-body' => "You have been behind on paying for your :site account " ."for over :days days. Your account has been suspended.", diff --git a/src/resources/themes/app.scss b/src/resources/themes/app.scss --- a/src/resources/themes/app.scss +++ b/src/resources/themes/app.scss @@ -115,7 +115,10 @@ } table { - td.buttons, + th { + white-space: nowrap; + } + td.email, td.price, td.datetime, @@ -124,6 +127,7 @@ white-space: nowrap; } + td.buttons, th.price, td.price { width: 1%; @@ -279,6 +283,13 @@ opacity: 0.6; } + // Some icons are too big, scale them down + &.link-invitations { + svg { + transform: scale(0.9); + } + } + .badge { position: absolute; top: 0.5rem; diff --git a/src/resources/views/emails/html/signup_invitation.blade.php b/src/resources/views/emails/html/signup_invitation.blade.php new file mode 100644 --- /dev/null +++ b/src/resources/views/emails/html/signup_invitation.blade.php @@ -0,0 +1,18 @@ + + + + + + +

{{ __('mail.signupinvitation-header') }}

+ +

{{ __('mail.signupinvitation-body1', ['site' => $site]) }}

+ +

{!! $href !!}

+ +

{{ __('mail.signupinvitation-body2') }}

+ +

{{ __('mail.footer1') }}

+

{{ __('mail.footer2', ['site' => $site]) }}

+ + diff --git a/src/resources/views/emails/plain/signup_invitation.blade.php b/src/resources/views/emails/plain/signup_invitation.blade.php new file mode 100644 --- /dev/null +++ b/src/resources/views/emails/plain/signup_invitation.blade.php @@ -0,0 +1,11 @@ +{!! __('mail.signupinvitation-header') !!} + +{!! __('mail.signupinvitation-body1', ['site' => $site]) !!} + +{!! $href !!} + +{!! __('mail.signupinvitation-body2') !!} + +-- +{!! __('mail.footer1') !!} +{!! __('mail.footer2', ['site' => $site]) !!} diff --git a/src/resources/vue/Reseller/Dashboard.vue b/src/resources/vue/Reseller/Dashboard.vue --- a/src/resources/vue/Reseller/Dashboard.vue +++ b/src/resources/vue/Reseller/Dashboard.vue @@ -2,12 +2,19 @@
+ + Invitations +
diff --git a/src/resources/vue/Signup.vue b/src/resources/vue/Signup.vue --- a/src/resources/vue/Signup.vue +++ b/src/resources/vue/Signup.vue @@ -1,6 +1,6 @@