Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117863211
D2428.1775320368.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
100 KB
Referenced Files
None
Subscribers
None
D2428.1775320368.diff
View Options
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 @@
+<?php
+
+namespace App\Http\Controllers\API\V4\Reseller;
+
+use App\Http\Controllers\Controller;
+use App\SignupInvitation;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Validator;
+
+class InvitationsController extends Controller
+{
+ /**
+ * Show the form for creating a new resource.
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function create()
+ {
+ return $this->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 @@
+<?php
+
+namespace App\Jobs;
+
+use App\SignupInvitation;
+use App\Mail\SignupInvitation as SignupInvitationMail;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Support\Facades\Mail;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Queue\InteractsWithQueue;
+
+class SignupInvitationEmail implements ShouldQueue
+{
+ use Dispatchable;
+ use InteractsWithQueue;
+ use Queueable;
+ use SerializesModels;
+
+ /** @var int The number of times the job may be attempted. */
+ public $tries = 3;
+
+ /** @var bool Delete the job if its models no longer exist. */
+ public $deleteWhenMissingModels = true;
+
+ /** @var int The number of seconds to wait before retrying the job. */
+ public $retryAfter = 10;
+
+ /** @var SignupInvitation Signup invitation object */
+ protected $invitation;
+
+
+ /**
+ * Create a new job instance.
+ *
+ * @param SignupInvitation $invitation Invitation object
+ *
+ * @return void
+ */
+ public function __construct(SignupInvitation $invitation)
+ {
+ $this->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 @@
+<?php
+
+namespace App\Mail;
+
+use App\SignupInvitation as SI;
+use App\Utils;
+use Illuminate\Bus\Queueable;
+use Illuminate\Mail\Mailable;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Str;
+
+class SignupInvitation extends Mailable
+{
+ use Queueable;
+ use SerializesModels;
+
+ /** @var \App\SignupInvitation A signup invitation object */
+ protected $invitation;
+
+
+ /**
+ * Create a new message instance.
+ *
+ * @param \App\SignupInvitation $invitation A signup invitation object
+ *
+ * @return void
+ */
+ public function __construct(SI $invitation)
+ {
+ $this->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 @@
+<?php
+
+namespace App\Observers;
+
+use App\SignupInvitation as SI;
+
+/**
+ * This is an observer for the SignupInvitation model definition.
+ */
+class SignupInvitationObserver
+{
+ /**
+ * Ensure the invitation ID is a custom ID (uuid).
+ *
+ * @param \App\SignupInvitation $invitation The invitation object
+ *
+ * @return void
+ */
+ public function creating(SI $invitation)
+ {
+ while (true) {
+ $allegedly_unique = \App\Utils::uuidStr();
+ if (!SI::find($allegedly_unique)) {
+ $invitation->{$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 @@
+<?php
+
+namespace App;
+
+use Carbon\Carbon;
+use Illuminate\Database\Eloquent\Model;
+
+/**
+ * The eloquent definition of a signup invitation.
+ *
+ * @property string $email
+ * @property string $id
+ * @property ?int $tenant_id
+ * @property ?\App\Tenant $tenant
+ * @property ?\App\User $user
+ */
+class SignupInvitation extends Model
+{
+ // just created
+ public const STATUS_NEW = 1 << 0;
+ // it's been sent successfully
+ public const STATUS_SENT = 1 << 1;
+ // sending failed
+ public const STATUS_FAILED = 1 << 2;
+ // the user signed up
+ public const STATUS_COMPLETED = 1 << 3;
+
+
+ /**
+ * Indicates if the IDs are auto-incrementing.
+ *
+ * @var bool
+ */
+ public $incrementing = false;
+
+ /**
+ * The "type" of the auto-incrementing ID.
+ *
+ * @var string
+ */
+ protected $keyType = 'string';
+
+ /**
+ * The attributes that are mass assignable.
+ *
+ * @var array
+ */
+ protected $fillable = ['email'];
+
+ /**
+ * Returns whether this invitation process completed (user signed up)
+ *
+ * @return bool
+ */
+ public function isCompleted(): bool
+ {
+ return ($this->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 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+// phpcs:ignore
+class CreateSignupInvitationsTable extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::create(
+ 'signup_invitations',
+ function (Blueprint $table) {
+ $table->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' => "TODO",
+ 'signupinvitation-body1' => "TODO",
+ 'signupinvitation-body2' => "TODO",
+
'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 @@
+<!DOCTYPE html>
+<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
+ <head>
+ <meta charset="utf-8">
+ </head>
+ <body>
+ <p>{{ __('mail.signupinvitation-header') }}</p>
+
+ <p>{{ __('mail.signupinvitation-body1', ['site' => $site]) }}</p>
+
+ <p><a href="{!! $href !!}">{!! $href !!}</a></p>
+
+ <p>{{ __('mail.signupinvitation-body2') }}</p>
+
+ <p>{{ __('mail.footer1') }}</p>
+ <p>{{ __('mail.footer2', ['site' => $site]) }}</p>
+ </body>
+</html>
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 @@
<div class="container" dusk="dashboard-component">
<user-search></user-search>
<div id="dashboard-nav" class="mt-3">
+ <router-link class="card link-invitations" :to="{ name: 'invitations' }">
+ <svg-icon icon="envelope-open-text"></svg-icon><span class="name">Invitations</span>
+ </router-link>
</div>
</div>
</template>
<script>
import UserSearch from '../Widgets/UserSearch'
+ import { library } from '@fortawesome/fontawesome-svg-core'
+ import { faEnvelopeOpenText } from '@fortawesome/free-solid-svg-icons'
+
+ library.add(faEnvelopeOpenText)
export default {
components: {
diff --git a/src/resources/vue/Reseller/Invitations.vue b/src/resources/vue/Reseller/Invitations.vue
new file mode 100644
--- /dev/null
+++ b/src/resources/vue/Reseller/Invitations.vue
@@ -0,0 +1,283 @@
+<template>
+ <div class="container">
+ <div class="card" id="invitations">
+ <div class="card-body">
+ <div class="card-title">
+ Signup Invitations
+ </div>
+ <div class="card-text">
+ <div class="mb-2 d-flex">
+ <form @submit.prevent="searchInvitations" id="search-form" class="input-group" style="flex:1">
+ <input class="form-control" type="text" placeholder="Email address or domain" v-model="search">
+ <div class="input-group-append">
+ <button type="submit" class="btn btn-primary"><svg-icon icon="search"></svg-icon> Search</button>
+ </div>
+ </form>
+ <div>
+ <button class="btn btn-success create-invite ml-1" @click="inviteUserDialog">
+ <svg-icon icon="envelope-open-text"></svg-icon> Create invite(s)
+ </button>
+ </div>
+ </div>
+
+ <table id="invitations-list" class="table table-sm table-hover">
+ <thead class="thead-light">
+ <tr>
+ <th scope="col">External Email</th>
+ <th scope="col">Created</th>
+ <th scope="col"></th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="inv in invitations" :id="'i' + inv.id" :key="inv.id">
+ <td class="email">
+ <svg-icon icon="envelope-open-text" :class="statusClass(inv)" :title="statusText(inv)"></svg-icon>
+ <span>{{ inv.email }}</span>
+ </td>
+ <td class="datetime">
+ {{ inv.created }}
+ </td>
+ <td class="buttons">
+ <button class="btn text-danger button-delete p-0 ml-1" @click="deleteInvite(inv.id)">
+ <svg-icon icon="trash-alt"></svg-icon>
+ <span class="btn-label">Delete</span>
+ </button>
+ <button class="btn button-resend p-0 ml-1" :disabled="inv.isNew || inv.isCompleted" @click="resendInvite(inv.id)">
+ <svg-icon icon="redo"></svg-icon>
+ <span class="btn-label">Resend</span>
+ </button>
+ </td>
+ </tr>
+ </tbody>
+ <tfoot class="table-fake-body">
+ <tr>
+ <td colspan="3">There are no invitations in the database.</td>
+ </tr>
+ </tfoot>
+ </table>
+ <div class="text-center p-3" id="more-loader" v-if="hasMore">
+ <button class="btn btn-secondary" @click="loadInvitations(true)">Load more</button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div id="invite-create" class="modal" tabindex="-1" role="dialog">
+ <div class="modal-dialog" role="document">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 class="modal-title">Invite for a signup</h5>
+ <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+ <span aria-hidden="true">×</span>
+ </button>
+ </div>
+ <div class="modal-body">
+ <form>
+ <p>Enter an email address of the person you want to invite.</p>
+ <div>
+ <input id="email" type="text" class="form-control" name="email">
+ </div>
+ <div class="form-separator"><hr><span>or</span></div>
+ <p>
+ To send multiple invitations at once, provide a CSV (comma separated) file,
+ or alternatively a plain-text file, containing one email address per line.
+ </p>
+ <div class="custom-file">
+ <input id="file" type="file" class="custom-file-input" name="csv" @change="fileChange">
+ <label class="custom-file-label" for="file">Choose file...</label>
+ </div>
+ </form>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-secondary modal-cancel" data-dismiss="modal">Cancel</button>
+ <button type="button" class="btn btn-primary modal-action" @click="inviteUser()">
+ <svg-icon icon="paper-plane"></svg-icon> Send invite(s)
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+ import { library } from '@fortawesome/fontawesome-svg-core'
+ import { faEnvelopeOpenText, faPaperPlane, faRedo } from '@fortawesome/free-solid-svg-icons'
+
+ library.add(faEnvelopeOpenText, faPaperPlane, faRedo)
+
+ export default {
+ data() {
+ return {
+ invitations: [],
+ hasMore: false,
+ page: 1,
+ search: ''
+ }
+ },
+ mounted() {
+ this.$root.startLoading()
+ this.loadInvitations(null, () => this.$root.stopLoading())
+ },
+ methods: {
+ deleteInvite(id) {
+ axios.delete('/api/v4/invitations/' + id)
+ .then(response => {
+ if (response.data.status == 'success') {
+ this.$toast.success(response.data.message)
+
+ // Remove the invitation record from the list
+ const index = this.invitations.findIndex(item => item.id == id)
+ this.invitations.splice(index, 1)
+ }
+ })
+ },
+ fileChange(e) {
+ let label = 'Choose file...'
+ let files = e.target.files
+
+ if (files.length) {
+ label = files[0].name
+ if (files.length > 1) {
+ label += ', ...'
+ }
+ }
+
+ $(e.target).next().text(label)
+ },
+ inviteUser() {
+ let dialog = $('#invite-create')
+ let post = new FormData()
+ let params = { headers: { 'Content-Type': 'multipart/form-data' } }
+
+ post.append('email', dialog.find('#email').val())
+
+ this.$root.clearFormValidation(dialog.find('form'))
+
+ // Append the file to POST data
+ let files = dialog.find('#file').get(0).files
+ if (files.length) {
+ post.append('file', files[0])
+ }
+
+ axios.post('/api/v4/invitations', post, params)
+ .then(response => {
+ if (response.data.status == 'success') {
+ dialog.modal('hide')
+ this.$toast.success(response.data.message)
+ if (response.data.count) {
+ this.loadInvitations({ reset: true })
+ }
+ }
+ })
+ },
+ inviteUserDialog() {
+ let dialog = $('#invite-create')
+ let form = dialog.find('form')
+
+ form.get(0).reset()
+ this.fileChange({ target: form.find('#file')[0] }) // resets file input label
+ this.$root.clearFormValidation(form)
+
+ dialog.on('shown.bs.modal', () => {
+ dialog.find('input').get(0).focus()
+ }).modal()
+ },
+ loadInvitations(params, callback) {
+ let loader
+ let get = {}
+
+ if (params) {
+ if (params.reset) {
+ this.invitations = []
+ this.page = 0
+ }
+
+ get.page = params.page || (this.page + 1)
+
+ if (typeof params === 'object' && 'search' in params) {
+ get.search = params.search
+ this.currentSearch = params.search
+ } else {
+ get.search = this.currentSearch
+ }
+
+ loader = $(get.page > 1 ? '#more-loader' : '#invitations-list tfoot td')
+ } else {
+ this.currentSearch = null
+ }
+
+ this.$root.addLoader(loader)
+
+ axios.get('/api/v4/invitations', { params: get })
+ .then(response => {
+ this.$root.removeLoader(loader)
+
+ // Note: In Vue we can't just use .concat()
+ for (let i in response.data.list) {
+ this.$set(this.invitations, this.invitations.length, response.data.list[i])
+ }
+ this.hasMore = response.data.hasMore
+ this.page = response.data.page || 1
+
+ if (callback) {
+ callback()
+ }
+ })
+ .catch(error => {
+ this.$root.removeLoader(loader)
+
+ if (callback) {
+ callback()
+ }
+ })
+ },
+ resendInvite(id) {
+ axios.post('/api/v4/invitations/' + id + '/resend')
+ .then(response => {
+ if (response.data.status == 'success') {
+ this.$toast.success(response.data.message)
+
+ // Update the invitation record
+ const index = this.invitations.findIndex(item => item.id == id)
+ this.invitations.splice(index, 1)
+ this.$set(this.invitations, index, response.data.invitation)
+ }
+ })
+ },
+ searchInvitations() {
+ this.loadInvitations({ reset: true, search: this.search })
+ },
+ statusClass(invitation) {
+ if (invitation.isCompleted) {
+ return 'text-success'
+ }
+
+ if (invitation.isFailed) {
+ return 'text-danger'
+ }
+
+ if (invitation.isSent) {
+ return 'text-primary'
+ }
+
+ return ''
+ },
+ statusText(invitation) {
+ if (invitation.isCompleted) {
+ return 'User signed up'
+ }
+
+ if (invitation.isFailed) {
+ return 'Sending failed'
+ }
+
+ if (invitation.isSent) {
+ return 'Sent'
+ }
+
+ return 'Not sent yet'
+ }
+ }
+ }
+</script>
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 @@
<template>
<div class="container">
- <div id="step0">
+ <div id="step0" v-if="!invitation">
<div class="plan-selector card-deck">
<div v-for="item in plans" :key="item.id" :class="'card bg-light plan-' + item.title">
<div class="card-header plan-header">
@@ -16,7 +16,7 @@
</div>
</div>
- <div class="card d-none" id="step1">
+ <div class="card d-none" id="step1" v-if="!invitation">
<div class="card-body">
<h4 class="card-title">Sign Up - Step 1/3</h4>
<p class="card-text">
@@ -39,7 +39,7 @@
</div>
</div>
- <div class="card d-none" id="step2">
+ <div class="card d-none" id="step2" v-if="!invitation">
<div class="card-body">
<h4 class="card-title">Sign Up - Step 2/3</h4>
<p class="card-text">
@@ -60,20 +60,28 @@
<div class="card d-none" id="step3">
<div class="card-body">
- <h4 class="card-title">Sign Up - Step 3/3</h4>
+ <h4 v-if="!invitation" class="card-title">Sign Up - Step 3/3</h4>
<p class="card-text">
Create your Kolab identity (you can choose additional addresses later).
</p>
<form @submit.prevent="submitStep3" data-validation-prefix="signup_">
+ <div class="form-group" v-if="invitation">
+ <div class="input-group">
+ <input type="text" class="form-control" id="signup_first_name" placeholder="First Name" autofocus v-model="first_name">
+ <input type="text" class="form-control rounded-right" id="signup_last_name" placeholder="Surname" v-model="last_name">
+ </div>
+ </div>
<div class="form-group">
<label for="signup_login" class="sr-only"></label>
<div class="input-group">
<input type="text" class="form-control" id="signup_login" required v-model="login" placeholder="Login">
- <span class="input-group-append">
+ <span class="input-group-append input-group-prepend">
<span class="input-group-text">@</span>
</span>
<input v-if="is_domain" type="text" class="form-control rounded-right" id="signup_domain" required v-model="domain" placeholder="Domain">
- <select v-else class="custom-select rounded-right" id="signup_domain" required v-model="domain"></select>
+ <select v-else class="custom-select rounded-right" id="signup_domain" required v-model="domain">
+ <option v-for="domain in domains" :key="domain" :value="domain">{{ domain }}</option>
+ </select>
</div>
</div>
<div class="form-group">
@@ -88,8 +96,10 @@
<label for="signup_voucher" class="sr-only">Voucher code</label>
<input type="text" class="form-control" id="signup_voucher" placeholder="Voucher code" v-model="voucher">
</div>
- <button class="btn btn-secondary" type="button" @click="stepBack">Back</button>
- <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> Submit</button>
+ <button v-if="!invitation" class="btn btn-secondary" type="button" @click="stepBack">Back</button>
+ <button class="btn btn-primary" type="submit">
+ <svg-icon icon="check"></svg-icon> <span v-if="invitation">Sign Up</span><span v-else>Submit</span>
+ </button>
</form>
</div>
</div>
@@ -109,8 +119,10 @@
password: '',
password_confirmation: '',
domain: '',
- plan: null,
+ domains: [],
+ invitation: null,
is_domain: false,
+ plan: null,
plan_icons: {
individual: 'user',
group: 'users'
@@ -122,7 +134,25 @@
mounted() {
let param = this.$route.params.param;
- if (param) {
+ if (this.$route.name == 'signup-invite') {
+ this.$root.startLoading()
+ axios.get('/api/auth/signup/invitations/' + param)
+ .then(response => {
+ this.invitation = response.data
+ this.login = response.data.login
+ this.voucher = response.data.voucher
+ this.first_name = response.data.first_name
+ this.last_name = response.data.last_name
+ this.plan = response.data.plan
+ this.is_domain = response.data.is_domain
+ this.setDomain(response.data)
+ this.$root.stopLoading()
+ this.displayForm(3, true)
+ })
+ .catch(error => {
+ this.$root.errorHandler(error)
+ })
+ } else if (param) {
if (this.$route.path.indexOf('/signup/voucher/') === 0) {
// Voucher (discount) code
this.voucher = param
@@ -199,13 +229,7 @@
// Fill the domain selector with available domains
if (!this.is_domain) {
- let options = []
- $('select#signup_domain').html('')
- $.each(response.data.domains, (i, v) => {
- options.push($('<option>').text(v).attr('value', v))
- })
- $('select#signup_domain').append(options)
- this.domain = window.config['app.domain']
+ this.setDomain(response.data)
}
}).catch(error => {
if (bylink === true) {
@@ -219,15 +243,25 @@
submitStep3() {
this.$root.clearFormValidation($('#step3 form'))
- axios.post('/api/auth/signup', {
- code: this.code,
- short_code: this.short_code,
+ let post = {
login: this.login,
domain: this.domain,
password: this.password,
password_confirmation: this.password_confirmation,
voucher: this.voucher
- }).then(response => {
+ }
+
+ if (this.invitation) {
+ post.invitation = this.invitation.id
+ post.plan = this.plan
+ post.first_name = this.first_name
+ post.last_name = this.last_name
+ } else {
+ post.code = this.code
+ post.short_code = this.short_code
+ }
+
+ axios.post('/api/auth/signup', post).then(response => {
// auto-login and goto dashboard
this.$root.loginUser(response.data)
})
@@ -258,6 +292,13 @@
if (focus) {
$('#step' + step).find('input').first().focus()
}
+ },
+ setDomain(response) {
+ if (response.domains) {
+ this.domains = response.domains
+ }
+
+ this.domain = response.domain || window.config['app.domain']
}
}
}
diff --git a/src/routes/api.php b/src/routes/api.php
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -45,8 +45,9 @@
Route::post('password-reset/verify', 'API\PasswordResetController@verify');
Route::post('password-reset', 'API\PasswordResetController@reset');
- Route::get('signup/plans', 'API\SignupController@plans');
Route::post('signup/init', 'API\SignupController@init');
+ Route::get('signup/invitations/{id}', 'API\SignupController@invitation');
+ Route::get('signup/plans', 'API\SignupController@plans');
Route::post('signup/verify', 'API\SignupController@verify');
Route::post('signup', 'API\SignupController@signup');
}
@@ -172,6 +173,8 @@
Route::get('domains/{id}/confirm', 'API\V4\Reseller\DomainsController@confirm');
Route::apiResource('entitlements', API\V4\Reseller\EntitlementsController::class);
+ Route::apiResource('invitations', API\V4\Reseller\InvitationsController::class);
+ Route::post('invitations/{id}/resend', 'API\V4\Reseller\InvitationsController@resend');
Route::apiResource('packages', API\V4\Reseller\PackagesController::class);
Route::apiResource('skus', API\V4\Reseller\SkusController::class);
Route::apiResource('users', API\V4\Reseller\UsersController::class);
diff --git a/src/tests/Browser/Pages/Reseller/Invitations.php b/src/tests/Browser/Pages/Reseller/Invitations.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/Pages/Reseller/Invitations.php
@@ -0,0 +1,49 @@
+<?php
+
+namespace Tests\Browser\Pages\Reseller;
+
+use Laravel\Dusk\Page;
+
+class Invitations extends Page
+{
+ /**
+ * Get the URL for the page.
+ *
+ * @return string
+ */
+ public function url(): string
+ {
+ return '/invitations';
+ }
+
+ /**
+ * Assert that the browser is on the page.
+ *
+ * @param \Laravel\Dusk\Browser $browser The browser object
+ *
+ * @return void
+ */
+ public function assert($browser)
+ {
+ $browser->assertPathIs($this->url())
+ ->waitUntilMissing('@app .app-loader')
+ ->assertSeeIn('#invitations .card-title', 'Signup Invitations');
+ }
+
+ /**
+ * Get the element shortcuts for the page.
+ *
+ * @return array
+ */
+ public function elements(): array
+ {
+ return [
+ '@app' => '#app',
+ '@create-button' => '.card-text button.create-invite',
+ '@create-dialog' => '#invite-create',
+ '@search-button' => '#search-form button',
+ '@search-input' => '#search-form input',
+ '@table' => '#invitations-list',
+ ];
+ }
+}
diff --git a/src/tests/Browser/Reseller/InvitationsTest.php b/src/tests/Browser/Reseller/InvitationsTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/Reseller/InvitationsTest.php
@@ -0,0 +1,222 @@
+<?php
+
+namespace Tests\Browser\Reseller;
+
+use App\SignupInvitation;
+use Illuminate\Support\Facades\Queue;
+use Tests\Browser;
+use Tests\Browser\Components\Dialog;
+use Tests\Browser\Components\Menu;
+use Tests\Browser\Components\Toast;
+use Tests\Browser\Pages\Dashboard;
+use Tests\Browser\Pages\Home;
+use Tests\Browser\Pages\Reseller\Invitations;
+use Tests\TestCaseDusk;
+
+class InvitationsTest extends TestCaseDusk
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ self::useResellerUrl();
+ SignupInvitation::truncate();
+ }
+
+ /**
+ * Test invitations page (unauthenticated)
+ */
+ public function testInvitationsUnauth(): void
+ {
+ // Test that the page requires authentication
+ $this->browse(function (Browser $browser) {
+ $browser->visit('/invitations')->on(new Home());
+ });
+ }
+
+ /**
+ * Test Invitations creation
+ */
+ public function testInvitationCreate(): void
+ {
+ $this->browse(function (Browser $browser) {
+ $date_regexp = '/^20[0-9]{2}-/';
+
+ $browser->visit(new Home())
+ ->submitLogon('reseller@kolabnow.com', 'reseller', true)
+ ->on(new Dashboard())
+ ->assertSeeIn('@links .link-invitations', 'Invitations')
+ ->click('@links .link-invitations')
+ ->on(new Invitations())
+ ->assertElementsCount('@table tbody tr', 0)
+ ->assertMissing('#more-loader')
+ ->assertSeeIn('@table tfoot td', "There are no invitations in the database.")
+ ->assertSeeIn('@create-button', 'Create invite(s)');
+
+ // Create a single invite with email address input
+ $browser->click('@create-button')
+ ->with(new Dialog('#invite-create'), function (Browser $browser) {
+ $browser->assertSeeIn('@title', 'Invite for a signup')
+ ->assertFocused('@body input#email')
+ ->assertValue('@body input#email', '')
+ ->type('@body input#email', 'test')
+ ->assertSeeIn('@button-action', 'Send invite(s)')
+ ->click('@button-action')
+ ->assertToast(Toast::TYPE_ERROR, "Form validation error")
+ ->waitFor('@body input#email.is-invalid')
+ ->assertSeeIn(
+ '@body input#email.is-invalid + .invalid-feedback',
+ "The email must be a valid email address."
+ )
+ ->type('@body input#email', 'test@domain.tld')
+ ->click('@button-action');
+ })
+ ->assertToast(Toast::TYPE_SUCCESS, "The invitation has been created.")
+ ->waitUntilMissing('#invite-create')
+ ->waitUntilMissing('@table .app-loader')
+ ->assertElementsCount('@table tbody tr', 1)
+ ->assertMissing('@table tfoot')
+ ->assertSeeIn('@table tbody tr td.email', 'test@domain.tld')
+ ->assertText('@table tbody tr td.email title', 'Not sent yet')
+ ->assertTextRegExp('@table tbody tr td.datetime', $date_regexp)
+ ->assertVisible('@table tbody tr td.buttons button.button-delete')
+ ->assertVisible('@table tbody tr td.buttons button.button-resend:disabled');
+
+ sleep(1);
+
+ // Create invites from a file
+ $browser->click('@create-button')
+ ->with(new Dialog('#invite-create'), function (Browser $browser) {
+ $browser->assertFocused('@body input#email')
+ ->assertValue('@body input#email', '')
+ ->assertMissing('@body input#email.is-invalid')
+ // Submit an empty file
+ ->attach('@body input#file', __DIR__ . '/../../data/empty.csv')
+ ->assertSeeIn('@body input#file + label', 'empty.csv')
+ ->click('@button-action')
+ ->assertToast(Toast::TYPE_ERROR, "Form validation error")
+ // ->waitFor('input#file.is-invalid')
+ ->assertSeeIn(
+ '@body input#file.is-invalid + label + .invalid-feedback',
+ "Failed to find any valid email addresses in the uploaded file."
+ )
+ // Submit non-empty file
+ ->attach('@body input#file', __DIR__ . '/../../data/email.csv')
+ ->click('@button-action');
+ })
+ ->assertToast(Toast::TYPE_SUCCESS, "2 invitations has been created.")
+ ->waitUntilMissing('#invite-create')
+ ->waitUntilMissing('@table .app-loader')
+ ->assertElementsCount('@table tbody tr', 3)
+ ->assertTextRegExp('@table tbody tr:nth-child(1) td.email', '/email[12]@test\.com$/')
+ ->assertTextRegExp('@table tbody tr:nth-child(2) td.email', '/email[12]@test\.com$/');
+ });
+ }
+
+ /**
+ * Test Invitations deletion and resending
+ */
+ public function testInvitationDeleteAndResend(): void
+ {
+ $this->browse(function (Browser $browser) {
+ Queue::fake();
+ $i1 = SignupInvitation::create(['email' => 'test1@domain.org']);
+ $i2 = SignupInvitation::create(['email' => 'test2@domain.org']);
+ SignupInvitation::where('id', $i2->id)
+ ->update(['created_at' => now()->subHours('2'), 'status' => SignupInvitation::STATUS_FAILED]);
+
+ // Test deleting
+ $browser->visit(new Invitations())
+ // ->submitLogon('reseller@kolabnow.com', 'reseller', true)
+ ->assertElementsCount('@table tbody tr', 2)
+ ->click('@table tbody tr:first-child button.button-delete')
+ ->assertToast(Toast::TYPE_SUCCESS, "Invitation deleted successfully.")
+ ->assertElementsCount('@table tbody tr', 1);
+
+ // Test resending
+ $browser->click('@table tbody tr:first-child button.button-resend')
+ ->assertToast(Toast::TYPE_SUCCESS, "Invitation added to the sending queue successfully.")
+ ->assertElementsCount('@table tbody tr', 1);
+ });
+ }
+
+ /**
+ * Test Invitations list (paging and searching)
+ */
+ public function testInvitationsList(): void
+ {
+ $this->browse(function (Browser $browser) {
+ Queue::fake();
+ $i1 = SignupInvitation::create(['email' => 'email1@ext.com']);
+ $i2 = SignupInvitation::create(['email' => 'email2@ext.com']);
+ $i3 = SignupInvitation::create(['email' => 'email3@ext.com']);
+ $i4 = SignupInvitation::create(['email' => 'email4@other.com']);
+ $i5 = SignupInvitation::create(['email' => 'email5@other.com']);
+ $i6 = SignupInvitation::create(['email' => 'email6@other.com']);
+ $i7 = SignupInvitation::create(['email' => 'email7@other.com']);
+ $i8 = SignupInvitation::create(['email' => 'email8@other.com']);
+ $i9 = SignupInvitation::create(['email' => 'email9@other.com']);
+ $i10 = SignupInvitation::create(['email' => 'email10@other.com']);
+ $i11 = SignupInvitation::create(['email' => 'email11@other.com']);
+
+ SignupInvitation::query()->update(['created_at' => now()->subDays('1')]);
+ SignupInvitation::where('id', $i1->id)
+ ->update(['created_at' => now()->subHours('2'), 'status' => SignupInvitation::STATUS_FAILED]);
+ SignupInvitation::where('id', $i2->id)
+ ->update(['created_at' => now()->subHours('3'), 'status' => SignupInvitation::STATUS_SENT]);
+ SignupInvitation::where('id', $i3->id)
+ ->update(['created_at' => now()->subHours('4'), 'status' => SignupInvitation::STATUS_COMPLETED]);
+ SignupInvitation::where('id', $i11->id)->update(['created_at' => now()->subDays('3')]);
+
+ // Test paging (load more) feature
+ $browser->visit(new Invitations())
+ // ->submitLogon('reseller@kolabnow.com', 'reseller', true)
+ ->assertElementsCount('@table tbody tr', 10)
+ ->assertSeeIn('#more-loader button', 'Load more')
+ ->with('@table tbody', function ($browser) use ($i1, $i2, $i3) {
+ $browser->assertSeeIn('tr:nth-child(1) td.email', $i1->email)
+ ->assertText('tr:nth-child(1) td.email svg.text-danger title', 'Sending failed')
+ ->assertVisible('tr:nth-child(1) td.buttons button.button-delete')
+ ->assertVisible('tr:nth-child(1) td.buttons button.button-resend:not(:disabled)')
+ ->assertSeeIn('tr:nth-child(2) td.email', $i2->email)
+ ->assertText('tr:nth-child(2) td.email svg.text-primary title', 'Sent')
+ ->assertVisible('tr:nth-child(2) td.buttons button.button-delete')
+ ->assertVisible('tr:nth-child(2) td.buttons button.button-resend:not(:disabled)')
+ ->assertSeeIn('tr:nth-child(3) td.email', $i3->email)
+ ->assertText('tr:nth-child(3) td.email svg.text-success title', 'User signed up')
+ ->assertVisible('tr:nth-child(3) td.buttons button.button-delete')
+ ->assertVisible('tr:nth-child(3) td.buttons button.button-resend:disabled')
+ ->assertText('tr:nth-child(4) td.email svg title', 'Not sent yet')
+ ->assertVisible('tr:nth-child(4) td.buttons button.button-delete')
+ ->assertVisible('tr:nth-child(4) td.buttons button.button-resend:disabled');
+ })
+ ->click('#more-loader button')
+ ->whenAvailable('@table tbody tr:nth-child(11)', function ($browser) use ($i11) {
+ $browser->assertSeeIn('td.email', $i11->email);
+ })
+ ->assertMissing('#more-loader button');
+
+ // Test searching (by domain)
+ $browser->type('@search-input', 'ext.com')
+ ->click('@search-button')
+ ->waitUntilMissing('@table .app-loader')
+ ->assertElementsCount('@table tbody tr', 3)
+ ->assertMissing('#more-loader button')
+ // search by full email
+ ->type('@search-input', 'email7@other.com')
+ ->keys('@search-input', '{enter}')
+ ->waitUntilMissing('@table .app-loader')
+ ->assertElementsCount('@table tbody tr', 1)
+ ->assertSeeIn('@table tbody tr:nth-child(1) td.email', 'email7@other.com')
+ ->assertMissing('#more-loader button')
+ // reset search
+ ->vueClear('#search-form input')
+ ->keys('@search-input', '{enter}')
+ ->waitUntilMissing('@table .app-loader')
+ ->assertElementsCount('@table tbody tr', 10)
+ ->assertVisible('#more-loader button');
+ });
+ }
+}
diff --git a/src/tests/Browser/SignupTest.php b/src/tests/Browser/SignupTest.php
--- a/src/tests/Browser/SignupTest.php
+++ b/src/tests/Browser/SignupTest.php
@@ -5,6 +5,7 @@
use App\Discount;
use App\Domain;
use App\SignupCode;
+use App\SignupInvitation;
use App\User;
use Tests\Browser;
use Tests\Browser\Components\Menu;
@@ -28,11 +29,15 @@
$this->deleteTestDomain('user-domain-signup.com');
}
+ /**
+ * {@inheritDoc}
+ */
public function tearDown(): void
{
$this->deleteTestUser('signuptestdusk@' . \config('app.domain'));
$this->deleteTestUser('admin@user-domain-signup.com');
$this->deleteTestDomain('user-domain-signup.com');
+ SignupInvitation::truncate();
parent::tearDown();
}
@@ -294,17 +299,22 @@
// Here we expect 3 text inputs, Back and Continue buttons
$browser->with('@step3', function ($step) {
- $step->assertVisible('#signup_login');
- $step->assertVisible('#signup_password');
- $step->assertVisible('#signup_confirm');
- $step->assertVisible('select#signup_domain');
- $step->assertVisible('[type=button]');
- $step->assertVisible('[type=submit]');
- $step->assertFocused('#signup_login');
- $step->assertValue('select#signup_domain', \config('app.domain'));
- $step->assertValue('#signup_login', '');
- $step->assertValue('#signup_password', '');
- $step->assertValue('#signup_confirm', '');
+ $step->assertSeeIn('.card-title', 'Sign Up - Step 3/3')
+ ->assertMissing('#signup_last_name')
+ ->assertMissing('#signup_first_name')
+ ->assertVisible('#signup_login')
+ ->assertVisible('#signup_password')
+ ->assertVisible('#signup_confirm')
+ ->assertVisible('select#signup_domain')
+ ->assertElementsCount('select#signup_domain option', 13, false)
+ ->assertVisible('[type=button]')
+ ->assertVisible('[type=submit]')
+ ->assertSeeIn('[type=submit]', 'Submit')
+ ->assertFocused('#signup_login')
+ ->assertValue('select#signup_domain', \config('app.domain'))
+ ->assertValue('#signup_login', '')
+ ->assertValue('#signup_password', '')
+ ->assertValue('#signup_confirm', '');
// TODO: Test domain selector
});
@@ -542,4 +552,83 @@
$discount = Discount::where('code', 'TEST')->first();
$this->assertSame($discount->id, $user->wallets()->first()->discount_id);
}
+
+ /**
+ * Test signup via invitation link
+ */
+ public function testSignupInvitation(): void
+ {
+ // Test non-existing invitation
+ $this->browse(function (Browser $browser) {
+ $browser->visit('/signup/invite/TEST')
+ ->onWithoutAssert(new Signup())
+ ->waitFor('#app > #error-page')
+ ->assertErrorPage(404);
+ });
+
+ $invitation = SignupInvitation::create(['email' => 'test@domain.org']);
+
+ $this->browse(function (Browser $browser) use ($invitation) {
+ $browser->visit('/signup/invite/' . $invitation->id)
+ ->onWithoutAssert(new Signup())
+ ->waitUntilMissing('.app-loader')
+ ->with('@step3', function ($step) {
+ $step->assertMissing('.card-title')
+ ->assertVisible('#signup_last_name')
+ ->assertVisible('#signup_first_name')
+ ->assertVisible('#signup_login')
+ ->assertVisible('#signup_password')
+ ->assertVisible('#signup_confirm')
+ ->assertVisible('select#signup_domain')
+ ->assertElementsCount('select#signup_domain option', 13, false)
+ ->assertVisible('[type=submit]')
+ ->assertMissing('[type=button]') // Back button
+ ->assertSeeIn('[type=submit]', 'Sign Up')
+ ->assertFocused('#signup_first_name')
+ ->assertValue('select#signup_domain', \config('app.domain'))
+ ->assertValue('#signup_first_name', '')
+ ->assertValue('#signup_last_name', '')
+ ->assertValue('#signup_login', '')
+ ->assertValue('#signup_password', '')
+ ->assertValue('#signup_confirm', '');
+
+ // Submit invalid data
+ $step->type('#signup_login', '*')
+ ->type('#signup_password', '12345678')
+ ->type('#signup_confirm', '123456789')
+ ->click('[type=submit]')
+ ->waitFor('#signup_login.is-invalid')
+ ->assertVisible('#signup_domain + .invalid-feedback')
+ ->assertVisible('#signup_password.is-invalid')
+ ->assertVisible('#signup_password + .invalid-feedback')
+ ->assertFocused('#signup_login')
+ ->assertToast(Toast::TYPE_ERROR, 'Form validation error');
+
+ // Submit valid data
+ $step->type('#signup_confirm', '12345678')
+ ->type('#signup_login', 'signuptestdusk')
+ ->type('#signup_first_name', 'First')
+ ->type('#signup_last_name', 'Last')
+ ->click('[type=submit]');
+ })
+ // At this point we should be auto-logged-in to dashboard
+ ->waitUntilMissing('@step3')
+ ->waitUntilMissing('.app-loader')
+ ->on(new Dashboard())
+ ->assertUser('signuptestdusk@' . \config('app.domain'))
+ // Logout the user
+ ->within(new Menu(), function ($browser) {
+ $browser->clickMenuItem('logout');
+ });
+ });
+
+ $invitation->refresh();
+ $user = User::where('email', 'signuptestdusk@' . \config('app.domain'))->first();
+
+ $this->assertTrue($invitation->isCompleted());
+ $this->assertSame($user->id, $invitation->user_id);
+ $this->assertSame('First', $user->getSetting('first_name'));
+ $this->assertSame('Last', $user->getSetting('last_name'));
+ $this->assertSame($invitation->email, $user->getSetting('external_email'));
+ }
}
diff --git a/src/tests/Feature/Controller/Reseller/InvitationsTest.php b/src/tests/Feature/Controller/Reseller/InvitationsTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Controller/Reseller/InvitationsTest.php
@@ -0,0 +1,348 @@
+<?php
+
+namespace Tests\Feature\Controller\Reseller;
+
+use App\SignupInvitation;
+use App\Tenant;
+use Illuminate\Http\Testing\File;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class InvitationsTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ SignupInvitation::truncate();
+
+ self::useResellerUrl();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ SignupInvitation::truncate();
+
+ \config(['app.tenant_id' => 1]);
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test deleting invitations (DELETE /api/v4/invitations/<id>)
+ */
+ public function testDestroy(): void
+ {
+ Queue::fake();
+
+ $user = $this->getTestUser('john@kolab.org');
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $reseller = $this->getTestUser('reseller@reseller.com');
+ $reseller2 = $this->getTestUser('reseller@kolabnow.com');
+ $tenant = Tenant::where('title', 'Sample Tenant')->first();
+
+ \config(['app.tenant_id' => $tenant->id]);
+
+ $inv = SignupInvitation::create(['email' => 'email1@ext.com']);
+
+ // Non-admin user
+ $response = $this->actingAs($user)->delete("api/v4/invitations/{$inv->id}");
+ $response->assertStatus(403);
+
+ // Admin user
+ $response = $this->actingAs($admin)->delete("api/v4/invitations/{$inv->id}");
+ $response->assertStatus(403);
+
+ // Reseller user, but different tenant
+ $response = $this->actingAs($reseller2)->delete("api/v4/invitations/{$inv->id}");
+ $response->assertStatus(403);
+
+ // Reseller - non-existing invitation identifier
+ $response = $this->actingAs($reseller)->delete("api/v4/invitations/abd");
+ $response->assertStatus(404);
+
+ // Reseller - existing invitation
+ $response = $this->actingAs($reseller)->delete("api/v4/invitations/{$inv->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("Invitation deleted successfully.", $json['message']);
+ $this->assertSame(null, SignupInvitation::find($inv->id));
+ }
+
+ /**
+ * Test listing invitations (GET /api/v4/invitations)
+ */
+ public function testIndex(): void
+ {
+ Queue::fake();
+
+ $user = $this->getTestUser('john@kolab.org');
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $reseller = $this->getTestUser('reseller@reseller.com');
+ $reseller2 = $this->getTestUser('reseller@kolabnow.com');
+ $tenant = Tenant::where('title', 'Sample Tenant')->first();
+
+ \config(['app.tenant_id' => $tenant->id]);
+
+ // Non-admin user
+ $response = $this->actingAs($user)->get("api/v4/invitations");
+ $response->assertStatus(403);
+
+ // Admin user
+ $response = $this->actingAs($admin)->get("api/v4/invitations");
+ $response->assertStatus(403);
+
+ // Reseller user, but different tenant
+ $response = $this->actingAs($reseller2)->get("api/v4/invitations");
+ $response->assertStatus(403);
+
+ // Reseller (empty list)
+ $response = $this->actingAs($reseller)->get("api/v4/invitations");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertSame([], $json['list']);
+ $this->assertSame(1, $json['page']);
+ $this->assertFalse($json['hasMore']);
+
+ // Add some invitations
+ $i1 = SignupInvitation::create(['email' => 'email1@ext.com']);
+ $i2 = SignupInvitation::create(['email' => 'email2@ext.com']);
+ $i3 = SignupInvitation::create(['email' => 'email3@ext.com']);
+ $i4 = SignupInvitation::create(['email' => 'email4@other.com']);
+ $i5 = SignupInvitation::create(['email' => 'email5@other.com']);
+ $i6 = SignupInvitation::create(['email' => 'email6@other.com']);
+ $i7 = SignupInvitation::create(['email' => 'email7@other.com']);
+ $i8 = SignupInvitation::create(['email' => 'email8@other.com']);
+ $i9 = SignupInvitation::create(['email' => 'email9@other.com']);
+ $i10 = SignupInvitation::create(['email' => 'email10@other.com']);
+ $i11 = SignupInvitation::create(['email' => 'email11@other.com']);
+ $i12 = SignupInvitation::create(['email' => 'email12@test.com']);
+ $i13 = SignupInvitation::create(['email' => 'email13@ext.com']);
+
+ SignupInvitation::query()->update(['created_at' => now()->subDays('1')]);
+ SignupInvitation::where('id', $i1->id)
+ ->update(['created_at' => now()->subHours('2'), 'status' => SignupInvitation::STATUS_FAILED]);
+ SignupInvitation::where('id', $i2->id)
+ ->update(['created_at' => now()->subHours('3'), 'status' => SignupInvitation::STATUS_SENT]);
+ SignupInvitation::where('id', $i11->id)->update(['created_at' => now()->subDays('3')]);
+ SignupInvitation::where('id', $i12->id)->update(['tenant_id' => 1]);
+ SignupInvitation::where('id', $i13->id)->update(['tenant_id' => 1]);
+
+ $response = $this->actingAs($reseller)->get("api/v4/invitations");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(10, $json['count']);
+ $this->assertSame(1, $json['page']);
+ $this->assertTrue($json['hasMore']);
+ $this->assertSame($i1->id, $json['list'][0]['id']);
+ $this->assertSame($i1->email, $json['list'][0]['email']);
+ $this->assertSame(true, $json['list'][0]['isFailed']);
+ $this->assertSame(false, $json['list'][0]['isNew']);
+ $this->assertSame(false, $json['list'][0]['isSent']);
+ $this->assertSame(false, $json['list'][0]['isCompleted']);
+ $this->assertSame($i2->id, $json['list'][1]['id']);
+ $this->assertSame($i2->email, $json['list'][1]['email']);
+ $this->assertFalse(in_array($i12->email, array_column($json['list'], 'email')));
+ $this->assertFalse(in_array($i13->email, array_column($json['list'], 'email')));
+
+ $response = $this->actingAs($reseller)->get("api/v4/invitations?page=2");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(1, $json['count']);
+ $this->assertSame(2, $json['page']);
+ $this->assertFalse($json['hasMore']);
+ $this->assertSame($i11->id, $json['list'][0]['id']);
+
+ // Test searching (email address)
+ $response = $this->actingAs($reseller)->get("api/v4/invitations?search=email3@ext.com");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(1, $json['count']);
+ $this->assertSame(1, $json['page']);
+ $this->assertFalse($json['hasMore']);
+ $this->assertSame($i3->id, $json['list'][0]['id']);
+
+ // Test searching (domain)
+ $response = $this->actingAs($reseller)->get("api/v4/invitations?search=ext.com");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(3, $json['count']);
+ $this->assertSame(1, $json['page']);
+ $this->assertFalse($json['hasMore']);
+ $this->assertSame($i1->id, $json['list'][0]['id']);
+ }
+
+ /**
+ * Test resending invitations (POST /api/v4/invitations/<id>/resend)
+ */
+ public function testResend(): void
+ {
+ Queue::fake();
+
+ $user = $this->getTestUser('john@kolab.org');
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $reseller = $this->getTestUser('reseller@reseller.com');
+ $reseller2 = $this->getTestUser('reseller@kolabnow.com');
+ $tenant = Tenant::where('title', 'Sample Tenant')->first();
+
+ \config(['app.tenant_id' => $tenant->id]);
+
+ $inv = SignupInvitation::create(['email' => 'email1@ext.com']);
+ SignupInvitation::where('id', $inv->id)->update(['status' => SignupInvitation::STATUS_FAILED]);
+
+ // Non-admin user
+ $response = $this->actingAs($user)->post("api/v4/invitations/{$inv->id}/resend");
+ $response->assertStatus(403);
+
+ // Admin user
+ $response = $this->actingAs($admin)->post("api/v4/invitations/{$inv->id}/resend");
+ $response->assertStatus(403);
+
+ // Reseller user, but different tenant
+ $response = $this->actingAs($reseller2)->post("api/v4/invitations/{$inv->id}/resend");
+ $response->assertStatus(403);
+
+ // Reseller - non-existing invitation identifier
+ $response = $this->actingAs($reseller)->post("api/v4/invitations/abd/resend");
+ $response->assertStatus(404);
+
+ // Reseller - existing invitation
+ $response = $this->actingAs($reseller)->post("api/v4/invitations/{$inv->id}/resend");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("Invitation added to the sending queue successfully.", $json['message']);
+ $this->assertTrue($inv->fresh()->isNew());
+ }
+
+ /**
+ * Test creating invitations (POST /api/v4/invitations)
+ */
+ public function testStore(): void
+ {
+ Queue::fake();
+
+ $user = $this->getTestUser('john@kolab.org');
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $reseller = $this->getTestUser('reseller@reseller.com');
+ $reseller2 = $this->getTestUser('reseller@kolabnow.com');
+ $tenant = Tenant::where('title', 'Sample Tenant')->first();
+
+ \config(['app.tenant_id' => $tenant->id]);
+
+ // Non-admin user
+ $response = $this->actingAs($user)->post("api/v4/invitations", []);
+ $response->assertStatus(403);
+
+ // Admin user
+ $response = $this->actingAs($admin)->post("api/v4/invitations", []);
+ $response->assertStatus(403);
+
+ // Reseller user, but different tenant
+ $response = $this->actingAs($reseller2)->post("api/v4/invitations", []);
+ $response->assertStatus(403);
+
+ // Reseller (empty post)
+ $response = $this->actingAs($reseller)->post("api/v4/invitations", []);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(1, $json['errors']);
+ $this->assertSame("The email field is required.", $json['errors']['email'][0]);
+
+ // Invalid email address
+ $post = ['email' => 'test'];
+ $response = $this->actingAs($reseller)->post("api/v4/invitations", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(1, $json['errors']);
+ $this->assertSame("The email must be a valid email address.", $json['errors']['email'][0]);
+
+ // Valid email address
+ $post = ['email' => 'test@external.org'];
+ $response = $this->actingAs($reseller)->post("api/v4/invitations", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("The invitation has been created.", $json['message']);
+ $this->assertSame(1, $json['count']);
+ $this->assertSame(1, SignupInvitation::count());
+
+ // Test file input (empty file)
+ $tmpfile = tmpfile();
+ fwrite($tmpfile, "");
+ $file = new File('test.csv', $tmpfile);
+ $post = ['file' => $file];
+ $response = $this->actingAs($reseller)->post("api/v4/invitations", $post);
+
+ fclose($tmpfile);
+
+ $response->assertStatus(422);
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame("Failed to find any valid email addresses in the uploaded file.", $json['errors']['file']);
+
+ // Test file input with an invalid email address
+ $tmpfile = tmpfile();
+ fwrite($tmpfile, "t1@domain.tld\r\nt2@domain");
+ $file = new File('test.csv', $tmpfile);
+ $post = ['file' => $file];
+ $response = $this->actingAs($reseller)->post("api/v4/invitations", $post);
+
+ fclose($tmpfile);
+
+ $response->assertStatus(422);
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame("Found an invalid email address (t2@domain) on line 2.", $json['errors']['file']);
+
+ // Test file input (two addresses)
+ $tmpfile = tmpfile();
+ fwrite($tmpfile, "t1@domain.tld\r\nt2@domain.tld");
+ $file = new File('test.csv', $tmpfile);
+ $post = ['file' => $file];
+ $response = $this->actingAs($reseller)->post("api/v4/invitations", $post);
+
+ fclose($tmpfile);
+
+ $response->assertStatus(200);
+ $json = $response->json();
+
+ $this->assertSame(1, SignupInvitation::where('email', 't1@domain.tld')->count());
+ $this->assertSame(1, SignupInvitation::where('email', 't2@domain.tld')->count());
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("2 invitations has been created.", $json['message']);
+ $this->assertSame(2, $json['count']);
+ }
+}
diff --git a/src/tests/Feature/Controller/SignupTest.php b/src/tests/Feature/Controller/SignupTest.php
--- a/src/tests/Feature/Controller/SignupTest.php
+++ b/src/tests/Feature/Controller/SignupTest.php
@@ -6,6 +6,7 @@
use App\Discount;
use App\Domain;
use App\SignupCode;
+use App\SignupInvitation as SI;
use App\User;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
@@ -28,11 +29,13 @@
$this->deleteTestUser("SignupControllerTest1@$this->domain");
$this->deleteTestUser("signuplogin@$this->domain");
$this->deleteTestUser("admin@external.com");
+ $this->deleteTestUser("test-inv@kolabnow.com");
$this->deleteTestDomain('external.com');
$this->deleteTestDomain('signup-domain.com');
$this->deleteTestGroup('group-test@kolabnow.com');
+ SI::truncate();
}
/**
@@ -43,11 +46,13 @@
$this->deleteTestUser("SignupControllerTest1@$this->domain");
$this->deleteTestUser("signuplogin@$this->domain");
$this->deleteTestUser("admin@external.com");
+ $this->deleteTestUser("test-inv@kolabnow.com");
$this->deleteTestDomain('external.com');
$this->deleteTestDomain('signup-domain.com');
$this->deleteTestGroup('group-test@kolabnow.com');
+ SI::truncate();
parent::tearDown();
}
@@ -77,10 +82,8 @@
/**
* Test fetching plans for signup
- *
- * @return void
*/
- public function testSignupPlans()
+ public function testSignupPlans(): void
{
$response = $this->get('/api/auth/signup/plans');
$json = $response->json();
@@ -95,12 +98,37 @@
$this->assertArrayHasKey('button', $json['plans'][0]);
}
+ /**
+ * Test fetching invitation
+ */
+ public function testSignupInvitations(): void
+ {
+ Queue::fake();
+
+ $invitation = SI::create(['email' => 'email1@ext.com']);
+
+ // Test existing invitation
+ $response = $this->get("/api/auth/signup/invitations/{$invitation->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame($invitation->id, $json['id']);
+
+ // Test non-existing invitation
+ $response = $this->get("/api/auth/signup/invitations/abc");
+ $response->assertStatus(404);
+
+ // Test completed invitation
+ SI::where('id', $invitation->id)->update(['status' => SI::STATUS_COMPLETED]);
+ $response = $this->get("/api/auth/signup/invitations/{$invitation->id}");
+ $response->assertStatus(404);
+ }
+
/**
* Test signup initialization with invalid input
- *
- * @return void
*/
- public function testSignupInitInvalidInput()
+ public function testSignupInitInvalidInput(): void
{
// Empty input data
$data = [];
@@ -167,10 +195,8 @@
/**
* Test signup initialization with valid input
- *
- * @return array
*/
- public function testSignupInitValidInput()
+ public function testSignupInitValidInput(): array
{
Queue::fake();
@@ -243,9 +269,8 @@
* Test signup code verification with invalid input
*
* @depends testSignupInitValidInput
- * @return void
*/
- public function testSignupVerifyInvalidInput(array $result)
+ public function testSignupVerifyInvalidInput(array $result): void
{
// Empty data
$data = [];
@@ -293,10 +318,8 @@
* Test signup code verification with valid input
*
* @depends testSignupInitValidInput
- *
- * @return array
*/
- public function testSignupVerifyValidInput(array $result)
+ public function testSignupVerifyValidInput(array $result): array
{
$code = SignupCode::find($result['code']);
$data = [
@@ -324,9 +347,8 @@
* Test last signup step with invalid input
*
* @depends testSignupVerifyValidInput
- * @return void
*/
- public function testSignupInvalidInput(array $result)
+ public function testSignupInvalidInput(array $result): void
{
// Empty data
$data = [];
@@ -456,9 +478,8 @@
* Test last signup step with valid input (user creation)
*
* @depends testSignupVerifyValidInput
- * @return void
*/
- public function testSignupValidInput(array $result)
+ public function testSignupValidInput(array $result): void
{
$queue = Queue::fake();
@@ -520,10 +541,8 @@
/**
* Test signup for a group (custom domain) account
- *
- * @return void
*/
- public function testSignupGroupAccount()
+ public function testSignupGroupAccount(): void
{
Queue::fake();
@@ -641,6 +660,59 @@
// TODO: Check if the access token works
}
+ /**
+ * Test signup via invitation
+ */
+ public function testSignupViaInvitation(): void
+ {
+ Queue::fake();
+
+ $invitation = SI::create(['email' => 'email1@ext.com']);
+
+ $post = [
+ 'invitation' => 'abc',
+ 'first_name' => 'Signup',
+ 'last_name' => 'User',
+ 'login' => 'test-inv',
+ 'domain' => 'kolabnow.com',
+ 'password' => 'test',
+ 'password_confirmation' => 'test',
+ ];
+
+ // Test invalid invitation identifier
+ $response = $this->post('/api/auth/signup', $post);
+ $response->assertStatus(404);
+
+ // Test valid input
+ $post['invitation'] = $invitation->id;
+ $response = $this->post('/api/auth/signup', $post);
+ $result = $response->json();
+
+ $response->assertStatus(200);
+ $this->assertSame('success', $result['status']);
+ $this->assertSame('bearer', $result['token_type']);
+ $this->assertTrue(!empty($result['expires_in']) && is_int($result['expires_in']) && $result['expires_in'] > 0);
+ $this->assertNotEmpty($result['access_token']);
+ $this->assertSame('test-inv@kolabnow.com', $result['email']);
+
+ // Check if the user has been created
+ $user = User::where('email', 'test-inv@kolabnow.com')->first();
+
+ $this->assertNotEmpty($user);
+
+ // Check user settings
+ $this->assertSame($invitation->email, $user->getSetting('external_email'));
+ $this->assertSame($post['first_name'], $user->getSetting('first_name'));
+ $this->assertSame($post['last_name'], $user->getSetting('last_name'));
+
+ $invitation->refresh();
+
+ $this->assertSame($user->id, $invitation->user_id);
+ $this->assertTrue($invitation->isCompleted());
+
+ // TODO: Test POST params validation
+ }
+
/**
* List of login/domain validation cases for testValidateLogin()
*
diff --git a/src/tests/Feature/Jobs/SignupInvitationEmailTest.php b/src/tests/Feature/Jobs/SignupInvitationEmailTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Jobs/SignupInvitationEmailTest.php
@@ -0,0 +1,68 @@
+<?php
+
+namespace Tests\Feature\Jobs;
+
+use App\Jobs\SignupInvitationEmail;
+use App\Mail\SignupInvitation;
+use App\SignupInvitation as SI;
+use Illuminate\Queue\Events\JobFailed;
+use Illuminate\Support\Facades\Mail;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class SignupInvitationEmailTest extends TestCase
+{
+ private $invitation;
+
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ Queue::fake();
+
+ $this->invitation = SI::create(['email' => 'SignupInvitationEmailTest@external.com']);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $this->invitation->delete();
+ }
+
+ /**
+ * Test job handle
+ */
+ public function testSignupInvitationEmailHandle(): void
+ {
+ Mail::fake();
+
+ // Assert that no jobs were pushed...
+ Mail::assertNothingSent();
+
+ $job = new SignupInvitationEmail($this->invitation);
+ $job->handle();
+
+ // Assert the email sending job was pushed once
+ Mail::assertSent(SignupInvitation::class, 1);
+
+ // Assert the mail was sent to the code's email
+ Mail::assertSent(SignupInvitation::class, function ($mail) {
+ return $mail->hasTo($this->invitation->email);
+ });
+
+ $this->assertTrue($this->invitation->isSent());
+ }
+
+ /**
+ * Test job failure handling
+ */
+ public function testSignupInvitationEmailFailure(): void
+ {
+ $this->markTestIncomplete();
+ }
+}
diff --git a/src/tests/Feature/SignupInvitationTest.php b/src/tests/Feature/SignupInvitationTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/SignupInvitationTest.php
@@ -0,0 +1,118 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\SignupInvitation as SI;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class SignupInvitationTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ SI::truncate();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ SI::truncate();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test SignupInvitation creation
+ */
+ public function testCreate(): void
+ {
+ Queue::fake();
+
+ $invitation = SI::create(['email' => 'test@domain.org']);
+
+ $this->assertSame('test@domain.org', $invitation->email);
+ $this->assertSame(SI::STATUS_NEW, $invitation->status);
+ $this->assertSame(\config('app.tenant_id'), $invitation->tenant_id);
+ $this->assertTrue(preg_match('/^[a-f0-9-]{36}$/', $invitation->id) > 0);
+
+ Queue::assertPushed(\App\Jobs\SignupInvitationEmail::class, 1);
+
+ Queue::assertPushed(
+ \App\Jobs\SignupInvitationEmail::class,
+ function ($job) use ($invitation) {
+ $inv = TestCase::getObjectProperty($job, 'invitation');
+
+ return $inv->id === $invitation->id && $inv->email === $invitation->email;
+ }
+ );
+
+ $inst = SI::find($invitation->id);
+
+ $this->assertInstanceOf(SI::class, $inst);
+ $this->assertSame($inst->id, $invitation->id);
+ $this->assertSame($inst->email, $invitation->email);
+ }
+
+ /**
+ * Test SignupInvitation update
+ */
+ public function testUpdate(): void
+ {
+ Queue::fake();
+
+ $invitation = SI::create(['email' => 'test@domain.org']);
+
+ Queue::fake();
+
+ // Test that these status changes do not dispatch the email sending job
+ foreach ([SI::STATUS_FAILED, SI::STATUS_SENT, SI::STATUS_COMPLETED, SI::STATUS_NEW] as $status) {
+ $invitation->status = $status;
+ $invitation->save();
+ }
+
+ Queue::assertNothingPushed();
+
+ // SENT -> NEW should resend the invitation
+ SI::where('id', $invitation->id)->update(['status' => SI::STATUS_SENT]);
+ $invitation->refresh();
+ $invitation->status = SI::STATUS_NEW;
+ $invitation->save();
+
+ Queue::assertPushed(\App\Jobs\SignupInvitationEmail::class, 1);
+
+ Queue::assertPushed(
+ \App\Jobs\SignupInvitationEmail::class,
+ function ($job) use ($invitation) {
+ $inv = TestCase::getObjectProperty($job, 'invitation');
+
+ return $inv->id === $invitation->id && $inv->email === $invitation->email;
+ }
+ );
+
+ Queue::fake();
+
+ // FAILED -> NEW should resend the invitation
+ SI::where('id', $invitation->id)->update(['status' => SI::STATUS_FAILED]);
+ $invitation->refresh();
+ $invitation->status = SI::STATUS_NEW;
+ $invitation->save();
+
+ Queue::assertPushed(\App\Jobs\SignupInvitationEmail::class, 1);
+
+ Queue::assertPushed(
+ \App\Jobs\SignupInvitationEmail::class,
+ function ($job) use ($invitation) {
+ $inv = TestCase::getObjectProperty($job, 'invitation');
+
+ return $inv->id === $invitation->id && $inv->email === $invitation->email;
+ }
+ );
+ }
+}
diff --git a/src/tests/Unit/Mail/SignupInvitationTest.php b/src/tests/Unit/Mail/SignupInvitationTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Unit/Mail/SignupInvitationTest.php
@@ -0,0 +1,43 @@
+<?php
+
+namespace Tests\Unit\Mail;
+
+use App\Mail\SignupInvitation;
+use App\SignupInvitation as SI;
+use App\Utils;
+use Tests\MailInterceptTrait;
+use Tests\TestCase;
+
+class SignupInvitationTest extends TestCase
+{
+ use MailInterceptTrait;
+
+ /**
+ * Test email content
+ */
+ public function testBuild(): void
+ {
+ $invitation = new SI([
+ 'id' => 'abc',
+ 'email' => 'test@email',
+ ]);
+
+ $mail = $this->fakeMail(new SignupInvitation($invitation));
+
+ $html = $mail['html'];
+ $plain = $mail['plain'];
+
+ $url = Utils::serviceUrl('/signup/invite/' . $invitation->id);
+ $link = "<a href=\"$url\">$url</a>";
+ $appName = \config('app.name');
+
+ $this->assertMailSubject("$appName Invitation", $mail['message']);
+
+ $this->assertStringStartsWith('<!DOCTYPE html>', $html);
+ $this->assertTrue(strpos($html, $link) > 0);
+ $this->assertTrue(strpos($html, 'TODO') > 0);
+
+ $this->assertStringStartsWith('TODO', $plain);
+ $this->assertTrue(strpos($plain, $url) > 0);
+ }
+}
diff --git a/src/tests/Unit/SignupInvitationTest.php b/src/tests/Unit/SignupInvitationTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Unit/SignupInvitationTest.php
@@ -0,0 +1,35 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\SignupInvitation;
+use Tests\TestCase;
+
+class SignupInvitationTest extends TestCase
+{
+ /**
+ * Test is*() methods
+ *
+ * @return void
+ */
+ public function testStatus()
+ {
+ $invitation = new SignupInvitation();
+
+ $statuses = [
+ SignupInvitation::STATUS_NEW,
+ SignupInvitation::STATUS_SENT,
+ SignupInvitation::STATUS_FAILED,
+ SignupInvitation::STATUS_COMPLETED,
+ ];
+
+ foreach ($statuses as $status) {
+ $invitation->status = $status;
+
+ $this->assertSame($status === SignupInvitation::STATUS_NEW, $invitation->isNew());
+ $this->assertSame($status === SignupInvitation::STATUS_SENT, $invitation->isSent());
+ $this->assertSame($status === SignupInvitation::STATUS_FAILED, $invitation->isFailed());
+ $this->assertSame($status === SignupInvitation::STATUS_COMPLETED, $invitation->isCompleted());
+ }
+ }
+}
diff --git a/src/tests/data/email.csv b/src/tests/data/email.csv
new file mode 100644
--- /dev/null
+++ b/src/tests/data/email.csv
@@ -0,0 +1,2 @@
+email1@test.com
+email2@test.com
diff --git a/src/tests/data/empty.csv b/src/tests/data/empty.csv
new file mode 100644
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Sat, Apr 4, 4:32 PM (16 h, 48 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18830387
Default Alt Text
D2428.1775320368.diff (100 KB)
Attached To
Mode
D2428: Signup Invitations
Attached
Detach File
Event Timeline