diff --git a/src/app/Http/Controllers/API/SignupController.php b/src/app/Http/Controllers/API/SignupController.php index b1a69e2f..2e663b6f 100644 --- a/src/app/Http/Controllers/API/SignupController.php +++ b/src/app/Http/Controllers/API/SignupController.php @@ -1,380 +1,449 @@ orderByDesc('title')->get()->map(function ($plan) use (&$plans) { $plans[] = [ 'title' => $plan->title, 'name' => $plan->name, 'button' => __('app.planbutton', ['plan' => $plan->name]), 'description' => $plan->description, ]; }); return response()->json(['status' => 'success', 'plans' => $plans]); } /** * Starts signup process. * * Verifies user name and email/phone, sends verification email/sms message. * Returns the verification code. * * @param \Illuminate\Http\Request $request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function init(Request $request) { // Check required fields $v = Validator::make( $request->all(), [ 'email' => 'required', 'first_name' => 'max:128', 'last_name' => 'max:128', 'plan' => 'nullable|alpha_num|max:128', 'voucher' => 'max:32', ] ); $is_phone = false; $errors = $v->fails() ? $v->errors()->toArray() : []; // Validate user email (or phone) if (empty($errors['email'])) { if ($error = $this->validatePhoneOrEmail($request->email, $is_phone)) { $errors['email'] = $error; } } if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } // Generate the verification code $code = SignupCode::create([ 'data' => [ 'email' => $request->email, 'first_name' => $request->first_name, 'last_name' => $request->last_name, 'plan' => $request->plan, 'voucher' => $request->voucher, ] ]); // Send email/sms message if ($is_phone) { SignupVerificationSMS::dispatch($code); } else { SignupVerificationEmail::dispatch($code); } 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. * * @param \Illuminate\Http\Request $request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function verify(Request $request) { // Validate the request args $v = Validator::make( $request->all(), [ 'code' => 'required', 'short_code' => 'required', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } // Validate the verification code $code = SignupCode::find($request->code); if ( empty($code) || $code->isExpired() || Str::upper($request->short_code) !== Str::upper($code->short_code) ) { $errors = ['short_code' => "The code is invalid or expired."]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } // For signup last-step mode remember the code object, so we can delete it // with single SQL query (->delete()) instead of two (::destroy()) $this->code = $code; $has_domain = $this->getPlan()->hasDomain(); // Return user name and email/phone/voucher from the codes database, // domains list for selection and "plan type" flag return response()->json([ 'status' => 'success', 'email' => $code->data['email'], 'first_name' => $code->data['first_name'], 'last_name' => $code->data['last_name'], 'voucher' => $code->data['voucher'], 'is_domain' => $has_domain, 'domains' => $has_domain ? [] : Domain::getPublicDomains(), ]); } /** * Finishes the signup process by creating the user account. * * @param \Illuminate\Http\Request $request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function signup(Request $request) { // Validate input $v = Validator::make( $request->all(), [ 'login' => 'required|min:2', 'password' => 'required|min:4|confirmed', 'domain' => 'required', 'voucher' => 'max:32', ] ); if ($v->fails()) { 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 if ($request->voucher) { $discount = Discount::where('code', \strtoupper($request->voucher)) ->where('active', true)->first(); if (!$discount) { $errors = ['voucher' => \trans('validation.voucherinvalid')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } } // Get the plan $plan = $this->getPlan(); $is_domain = $plan->hasDomain(); $login = $request->login; $domain_name = $request->domain; // Validate login if ($errors = self::validateLogin($login, $domain_name, $is_domain)) { 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); $domain = null; DB::beginTransaction(); // Create domain record if ($is_domain) { $domain = Domain::create([ 'namespace' => $domain_name, 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); } // Create user record $user = User::create([ 'email' => $login . '@' . $domain_name, 'password' => $request->password, ]); if (!empty($discount)) { $wallet = $user->wallets()->first(); $wallet->discount()->associate($discount); $wallet->save(); } $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(); return AuthController::logonResponse($user); } /** * Returns plan for the signup process * * @returns \App\Plan Plan object selected for current signup process */ protected function getPlan() { if (!$this->plan) { // Get the plan if specified and exists... if ($this->code && $this->code->data['plan']) { $plan = Plan::where('title', $this->code->data['plan'])->first(); } // ...otherwise use the default plan if (empty($plan)) { // TODO: Get default plan title from config $plan = Plan::where('title', 'individual')->first(); } $this->plan = $plan; } return $this->plan; } /** * Checks if the input string is a valid email address or a phone number * * @param string $input Email address or phone number * @param bool $is_phone Will have been set to True if the string is valid phone number * * @return string Error message on validation error */ protected static function validatePhoneOrEmail($input, &$is_phone = false): ?string { $is_phone = false; $v = Validator::make( ['email' => $input], ['email' => ['required', 'string', new ExternalEmail()]] ); if ($v->fails()) { return $v->errors()->toArray()['email'][0]; } // TODO: Phone number support /* $input = str_replace(array('-', ' '), '', $input); if (!preg_match('/^\+?[0-9]{9,12}$/', $input)) { return \trans('validation.noemailorphone'); } $is_phone = true; */ return null; } /** * Login (kolab identity) validation * * @param string $login Login (local part of an email address) * @param string $domain Domain name * @param bool $external Enables additional checks for domain part * * @return array Error messages on validation error */ protected static function validateLogin($login, $domain, $external = false): ?array { // Validate login part alone $v = Validator::make( ['login' => $login], ['login' => ['required', 'string', new UserEmailLocal($external)]] ); if ($v->fails()) { return ['login' => $v->errors()->toArray()['login'][0]]; } $domains = $external ? null : Domain::getPublicDomains(); // Validate the domain $v = Validator::make( ['domain' => $domain], ['domain' => ['required', 'string', new UserEmailDomain($domains)]] ); if ($v->fails()) { return ['domain' => $v->errors()->toArray()['domain'][0]]; } $domain = Str::lower($domain); // Check if domain is already registered with us if ($external) { if (Domain::where('namespace', $domain)->first()) { return ['domain' => \trans('validation.domainexists')]; } } // Check if user with specified login already exists $email = $login . '@' . $domain; if (User::emailExists($email) || User::aliasExists($email) || \App\Group::emailExists($email)) { return ['login' => \trans('validation.loginexists')]; } return null; } } 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 index 00000000..dadb008b --- /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 index 00000000..e23a9590 --- /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 index 00000000..1bda7b2f --- /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 index 00000000..f598bf0c --- /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 index 278e42e2..c2d01d03 100644 --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -1,88 +1,108 @@ sql, implode(', ', $query->bindings))); }); } // Register some template helpers Blade::directive('theme_asset', function ($path) { $path = trim($path, '/\'"'); return ""; }); // Query builder 'withEnvTenant' macro Builder::macro('withEnvTenant', function (string $table = null) { $tenant_id = \config('app.tenant_id'); if ($tenant_id) { /** @var Builder $this */ return $this->where(($table ? "$table." : '') . 'tenant_id', $tenant_id); } /** @var Builder $this */ return $this->whereNull(($table ? "$table." : '') . 'tenant_id'); }); // Query builder 'withUserTenant' macro Builder::macro('withUserTenant', function (string $table = null) { $tenant_id = auth()->user()->tenant_id; if ($tenant_id) { /** @var Builder $this */ return $this->where(($table ? "$table." : '') . 'tenant_id', $tenant_id); } /** @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 index 00000000..f8053463 --- /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 index ee8fe130..d67212fb 100644 --- a/src/app/Tenant.php +++ b/src/app/Tenant.php @@ -1,30 +1,40 @@ 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 index 00000000..b9948b83 --- /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 index 99cf9e6d..3bba3e84 100644 --- a/src/phpstan.neon +++ b/src/phpstan.neon @@ -1,15 +1,17 @@ includes: - ./vendor/nunomaduro/larastan/extension.neon parameters: ignoreErrors: - '#Access to an undefined property Illuminate\\Contracts\\Auth\\Authenticatable#' - '#Access to an undefined property [a-zA-Z\\]+::\$pivot#' - '#Access to an undefined property Illuminate\\Database\\Eloquent\\Model::#' - - '#Call to an undefined method Illuminate\\Database\\Eloquent\\Model::#' - - '#Call to an undefined method Illuminate\\Database\\Eloquent\\Builder[^:]*::withEnvTenant\(\)#' - - '#Call to an undefined method Illuminate\\Database\\Eloquent\\Builder[^:]*::withUserTenant\(\)#' + - '#Call to an undefined method Illuminate\\Database\\Eloquent\\Model::toString\(\)#' + - '#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 index f9a4ad75..2d6236fe 100644 --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -1,477 +1,477 @@ /** * First we will load all of this project's JavaScript dependencies which * includes Vue and other libraries. It is a great starting point when * building robust, powerful web applications using Vue and Laravel. */ require('./bootstrap') import AppComponent from '../vue/App' import MenuComponent from '../vue/Widgets/Menu' import SupportForm from '../vue/Widgets/SupportForm' import store from './store' const loader = '
Loading
' let isLoading = 0 // Lock the UI with the 'loading...' element const startLoading = () => { isLoading++ let loading = $('#app > .app-loader').removeClass('fadeOut') if (!loading.length) { $('#app').append($(loader)) } } // Hide "loading" overlay const stopLoading = () => { if (isLoading > 0) { $('#app > .app-loader').addClass('fadeOut') isLoading--; } } let loadingRoute // Note: This has to be before the app is created // Note: You cannot use app inside of the function window.router.beforeEach((to, from, next) => { // check if the route requires authentication and user is not logged in if (to.matched.some(route => route.meta.requiresAuth) && !store.state.isLoggedIn) { // remember the original request, to use after login store.state.afterLogin = to; // redirect to login page next({ name: 'login' }) return } if (to.meta.loading) { startLoading() loadingRoute = to.name } next() }) window.router.afterEach((to, from) => { if (to.name && loadingRoute === to.name) { stopLoading() loadingRoute = null } // When changing a page remove old: // - error page // - modal backdrop $('#error-page,.modal-backdrop.show').remove() }) const app = new Vue({ el: '#app', components: { AppComponent, MenuComponent, }, store, router: window.router, data() { return { isUser: !window.isAdmin && !window.isReseller, appName: window.config['app.name'], appUrl: window.config['app.url'], themeDir: '/themes/' + window.config['app.theme'] } }, methods: { // Clear (bootstrap) form validation state clearFormValidation(form) { $(form).find('.is-invalid').removeClass('is-invalid') $(form).find('.invalid-feedback').remove() }, hasRoute(name) { return this.$router.resolve({ name: name }).resolved.matched.length > 0 }, hasSKU(name) { const authInfo = store.state.authInfo return authInfo.statusInfo.skus && authInfo.statusInfo.skus.indexOf(name) != -1 }, isController(wallet_id) { if (wallet_id && store.state.authInfo) { let i for (i = 0; i < store.state.authInfo.wallets.length; i++) { if (wallet_id == store.state.authInfo.wallets[i].id) { return true } } for (i = 0; i < store.state.authInfo.accounts.length; i++) { if (wallet_id == store.state.authInfo.accounts[i].id) { return true } } } return false }, // Set user state to "logged in" loginUser(response, dashboard, update) { if (!update) { store.commit('logoutUser') // destroy old state data store.commit('loginUser') } localStorage.setItem('token', response.access_token) axios.defaults.headers.common.Authorization = 'Bearer ' + response.access_token if (response.email) { store.state.authInfo = response } if (dashboard !== false) { this.$router.push(store.state.afterLogin || { name: 'dashboard' }) } store.state.afterLogin = null // Refresh the token before it expires let timeout = response.expires_in || 0 // We'll refresh 60 seconds before the token expires if (timeout > 60) { timeout -= 60 } // TODO: We probably should try a few times in case of an error // TODO: We probably should prevent axios from doing any requests // while the token is being refreshed this.refreshTimeout = setTimeout(() => { axios.post('/api/auth/refresh').then(response => { this.loginUser(response.data, false, true) }) }, timeout * 1000) }, // Set user state to "not logged in" logoutUser(redirect) { store.commit('logoutUser') localStorage.setItem('token', '') delete axios.defaults.headers.common.Authorization if (redirect !== false) { this.$router.push({ name: 'login' }) } clearTimeout(this.refreshTimeout) }, logo(mode) { let src = this.appUrl + this.themeDir + '/images/logo_' + (mode || 'header') + '.png' return `${this.appName}` }, // Display "loading" overlay inside of the specified element addLoader(elem) { $(elem).css({position: 'relative'}).append($(loader).addClass('small')) }, // Remove loader element added in addLoader() removeLoader(elem) { $(elem).find('.app-loader').remove() }, startLoading, stopLoading, isLoading() { return isLoading > 0 }, errorPage(code, msg) { // Until https://github.com/vuejs/vue-router/issues/977 is implemented // we can't really use router to display error page as it has two side // effects: it changes the URL and adds the error page to browser history. // For now we'll be replacing current view with error page "manually". const map = { 400: "Bad request", 401: "Unauthorized", 403: "Access denied", 404: "Not found", 405: "Method not allowed", 500: "Internal server error" } if (!msg) msg = map[code] || "Unknown Error" const error_page = `
${code}
${msg}
` $('#error-page').remove() $('#app').append(error_page) app.updateBodyClass('error') }, errorHandler(error) { this.stopLoading() if (!error.response) { // TODO: probably network connection error } else if (error.response.status === 401) { // Remember requested route to come back to it after log in if (this.$route.meta.requiresAuth) { store.state.afterLogin = this.$route this.logoutUser() } else { this.logoutUser(false) } } else { this.errorPage(error.response.status, error.response.statusText) } }, downloadFile(url) { // TODO: This might not be a best way for big files as the content // will be stored (temporarily) in browser memory // TODO: This method does not show the download progress in the browser // but it could be implemented in the UI, axios has 'progress' property axios.get(url, { responseType: 'blob' }) .then(response => { const link = document.createElement('a') const contentDisposition = response.headers['content-disposition'] let filename = 'unknown' if (contentDisposition) { const match = contentDisposition.match(/filename="(.+)"/); if (match.length === 2) { filename = match[1]; } } link.href = window.URL.createObjectURL(response.data) link.download = filename link.click() }) }, price(price, currency) { return ((price || 0) / 100).toLocaleString('de-DE', { style: 'currency', currency: currency || 'CHF' }) }, priceLabel(cost, units = 1, discount) { let index = '' if (units < 0) { units = 1 } if (discount) { cost = Math.floor(cost * ((100 - discount) / 100)) index = '\u00B9' } return this.price(cost * units) + '/month' + index }, clickRecord(event) { if (!/^(a|button|svg|path)$/i.test(event.target.nodeName)) { let link = $(event.target).closest('tr').find('a')[0] if (link) { link.click() } } }, domainStatusClass(domain) { if (domain.isDeleted) { return 'text-muted' } if (domain.isSuspended) { return 'text-warning' } if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) { return 'text-danger' } return 'text-success' }, domainStatusText(domain) { if (domain.isDeleted) { return 'Deleted' } if (domain.isSuspended) { return 'Suspended' } if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) { return 'Not Ready' } return 'Active' }, pageName(path) { let page = this.$route.path // check if it is a "menu page", find the page name // otherwise we'll use the real path as page name window.config.menu.every(item => { if (item.location == page && item.page) { page = item.page return false } }) page = page.replace(/^\//, '') return page ? page : '404' }, supportDialog(container) { let dialog = $('#support-dialog') // FIXME: Find a nicer way of doing this if (!dialog.length) { let form = new Vue(SupportForm) form.$mount($('
').appendTo(container)[0]) form.$root = this form.$toast = this.$toast dialog = $(form.$el) } dialog.on('shown.bs.modal', () => { dialog.find('input').first().focus() }).modal() }, userStatusClass(user) { if (user.isDeleted) { return 'text-muted' } if (user.isSuspended) { return 'text-warning' } if (!user.isImapReady || !user.isLdapReady) { return 'text-danger' } return 'text-success' }, userStatusText(user) { if (user.isDeleted) { return 'Deleted' } if (user.isSuspended) { return 'Suspended' } if (!user.isImapReady || !user.isLdapReady) { return 'Not Ready' } return 'Active' }, updateBodyClass(name) { // Add 'class' attribute to the body, different for each page // so, we can apply page-specific styles let className = 'page-' + (name || this.pageName()).replace(/\/.*$/, '') $(document.body).removeClass().addClass(className) } } }) // Add a axios request interceptor window.axios.interceptors.request.use( config => { // This is the only way I found to change configuration options // on a running application. We need this for browser testing. config.headers['X-Test-Payment-Provider'] = window.config.paymentProvider return config }, error => { // Do something with request error return Promise.reject(error) } ) // Add a axios response interceptor for general/validation error handler window.axios.interceptors.response.use( response => { if (response.config.onFinish) { response.config.onFinish() } return response }, error => { let error_msg let status = error.response ? error.response.status : 200 // Do not display the error in a toast message, pass the error as-is if (error.config.ignoreErrors) { return Promise.reject(error) } if (error.config.onFinish) { error.config.onFinish() } if (error.response && status == 422) { error_msg = "Form validation error" const modal = $('div.modal.show') $(modal.length ? modal : 'form').each((i, form) => { form = $(form) $.each(error.response.data.errors || {}, (idx, msg) => { const input_name = (form.data('validation-prefix') || form.find('form').first().data('validation-prefix') || '') + idx let input = form.find('#' + input_name) if (!input.length) { input = form.find('[name="' + input_name + '"]'); } 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') { $.each(msg, (index, str) => { msg_text += str + ' ' }) } else { msg_text = msg } let feedback = $('
').text(msg_text) if (input.is('.list-input')) { // List input widget input.children(':not(:first-child)').each((index, element) => { if (msg[index]) { $(element).find('input').addClass('is-invalid') } }) input.addClass('is-invalid').next('.invalid-feedback').remove() input.after(feedback) } else { // Standard form element input.addClass('is-invalid') input.parent().find('.invalid-feedback').remove() input.parent().append(feedback) } } }) form.find('.is-invalid:not(.listinput-widget)').first().focus() }) } else if (error.response && error.response.data) { error_msg = error.response.data.message } else { error_msg = error.request ? error.request.statusText : error.message } app.$toast.error(error_msg || "Server Error") // Pass the error as-is return Promise.reject(error) } ) diff --git a/src/resources/js/reseller/routes.js b/src/resources/js/reseller/routes.js index 184a8433..b40b78f2 100644 --- a/src/resources/js/reseller/routes.js +++ b/src/resources/js/reseller/routes.js @@ -1,57 +1,64 @@ 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' //import StatsComponent from '../../vue/Reseller/Stats' import UserComponent from '../../vue/Admin/User' const routes = [ { path: '/', redirect: { name: 'dashboard' } }, { path: '/dashboard', name: 'dashboard', component: DashboardComponent, meta: { requiresAuth: true } }, { path: '/domain/:domain', name: 'domain', component: DomainComponent, meta: { requiresAuth: true } }, { path: '/login', name: 'login', component: LoginComponent }, { path: '/logout', name: 'logout', component: LogoutComponent }, + { + path: '/invitations', + name: 'invitations', + component: InvitationsComponent, + meta: { requiresAuth: true } + }, /* { path: '/stats', name: 'stats', component: StatsComponent, meta: { requiresAuth: true } }, */ { path: '/user/:user', name: 'user', component: UserComponent, meta: { requiresAuth: true } }, { name: '404', path: '*', component: PageComponent } ] export default routes diff --git a/src/resources/js/user/routes.js b/src/resources/js/user/routes.js index e7ee3227..12a70eb6 100644 --- a/src/resources/js/user/routes.js +++ b/src/resources/js/user/routes.js @@ -1,110 +1,115 @@ import DashboardComponent from '../../vue/Dashboard' import DomainInfoComponent from '../../vue/Domain/Info' import DomainListComponent from '../../vue/Domain/List' import LoginComponent from '../../vue/Login' import LogoutComponent from '../../vue/Logout' import MeetComponent from '../../vue/Rooms' import PageComponent from '../../vue/Page' import PasswordResetComponent from '../../vue/PasswordReset' import SignupComponent from '../../vue/Signup' import UserInfoComponent from '../../vue/User/Info' import UserListComponent from '../../vue/User/List' import UserProfileComponent from '../../vue/User/Profile' import UserProfileDeleteComponent from '../../vue/User/ProfileDelete' import WalletComponent from '../../vue/Wallet' // Here's a list of lazy-loaded components // Note: you can pack multiple components into the same chunk, webpackChunkName // is also used to get a sensible file name instead of numbers const RoomComponent = () => import(/* webpackChunkName: "room" */ '../../vue/Meet/Room.vue') const routes = [ { path: '/dashboard', name: 'dashboard', component: DashboardComponent, meta: { requiresAuth: true } }, { path: '/domain/:domain', name: 'domain', component: DomainInfoComponent, meta: { requiresAuth: true } }, { path: '/domains', name: 'domains', component: DomainListComponent, meta: { requiresAuth: true } }, { path: '/login', name: 'login', component: LoginComponent }, { path: '/logout', name: 'logout', component: LogoutComponent }, { path: '/password-reset/:code?', name: 'password-reset', component: PasswordResetComponent }, { path: '/profile', name: 'profile', component: UserProfileComponent, meta: { requiresAuth: true } }, { path: '/profile/delete', name: 'profile-delete', component: UserProfileDeleteComponent, meta: { requiresAuth: true } }, { component: RoomComponent, name: 'room', path: '/meet/:room', meta: { loading: true } }, { path: '/rooms', name: 'rooms', component: MeetComponent, meta: { requiresAuth: true } }, + { + path: '/signup/invite/:param', + name: 'signup-invite', + component: SignupComponent + }, { path: '/signup/:param?', alias: '/signup/voucher/:param', name: 'signup', component: SignupComponent }, { path: '/user/:user', name: 'user', component: UserInfoComponent, meta: { requiresAuth: true } }, { path: '/users', name: 'users', component: UserListComponent, meta: { requiresAuth: true } }, { path: '/wallet', name: 'wallet', component: WalletComponent, meta: { requiresAuth: true } }, { name: '404', path: '*', component: PageComponent } ] export default routes diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php index 70e3ccd2..eddabe34 100644 --- a/src/resources/lang/en/app.php +++ b/src/resources/lang/en/app.php @@ -1,60 +1,66 @@ 'The auto-payment has been removed.', 'mandate-update-success' => 'The auto-payment has been updated.', 'planbutton' => 'Choose :plan', 'process-async' => 'Setup process has been pushed. Please wait.', 'process-user-new' => 'Registering a user...', 'process-user-ldap-ready' => 'Creating a user...', 'process-user-imap-ready' => 'Creating a mailbox...', 'process-domain-new' => 'Registering a custom domain...', 'process-domain-ldap-ready' => 'Creating a custom domain...', 'process-domain-verified' => 'Verifying a custom domain...', 'process-domain-confirmed' => 'Verifying an ownership of a custom domain...', 'process-success' => 'Setup process finished successfully.', 'process-error-user-ldap-ready' => 'Failed to create a user.', 'process-error-user-imap-ready' => 'Failed to verify that a mailbox exists.', 'process-error-domain-ldap-ready' => 'Failed to create a domain.', 'process-error-domain-verified' => 'Failed to verify a domain.', 'process-error-domain-confirmed' => 'Failed to verify an ownership of a domain.', 'domain-verify-success' => 'Domain verified successfully.', 'domain-verify-error' => 'Domain ownership verification failed.', 'domain-suspend-success' => 'Domain suspended successfully.', 'domain-unsuspend-success' => 'Domain unsuspended successfully.', 'user-update-success' => 'User data updated successfully.', 'user-create-success' => 'User created successfully.', 'user-delete-success' => 'User deleted successfully.', 'user-suspend-success' => 'User suspended successfully.', 'user-unsuspend-success' => 'User unsuspended successfully.', 'user-reset-2fa-success' => '2-Factor authentication reset successfully.', '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.', 'wallet-award-success' => 'The bonus has been added to the wallet successfully.', 'wallet-penalty-success' => 'The penalty has been added to the wallet successfully.', 'wallet-update-success' => 'User wallet updated successfully.', 'wallet-notice-date' => 'With your current subscriptions your account balance will last until about :date (:days).', 'wallet-notice-nocredit' => 'You are out of credit, top up your balance now.', 'wallet-notice-today' => 'You will run out of credit today, top up your balance now.', 'wallet-notice-trial' => 'You are in your free trial period.', 'wallet-notice-trial-end' => 'Your free trial is about to end, top up to continue.', ]; diff --git a/src/resources/lang/en/mail.php b/src/resources/lang/en/mail.php index 687be129..187b2a2c 100644 --- a/src/resources/lang/en/mail.php +++ b/src/resources/lang/en/mail.php @@ -1,84 +1,89 @@ "Dear :name,", 'footer1' => "Best regards,", 'footer2' => "Your :site Team", 'more-info-html' => "See here for more information.", 'more-info-text' => "See :href for more information.", 'negativebalance-subject' => ":site Payment Required", 'negativebalance-body' => "This is a notification to let you know that your :site account balance has run into the negative and requires your attention. " . "Consider setting up an automatic payment to avoid messages like this in the future.", 'negativebalance-body-ext' => "Settle up to keep your account running:", 'negativebalancereminder-subject' => ":site Payment Reminder", 'negativebalancereminder-body' => "It has probably skipped your attention that you are behind on paying for your :site account. " . "Consider setting up an automatic payment to avoid messages like this in the future.", 'negativebalancereminder-body-ext' => "Settle up to keep your account running:", 'negativebalancereminder-body-warning' => "Please, be aware that your account will be suspended " . "if your account balance is not settled by :date.", 'negativebalancesuspended-subject' => ":site Account Suspended", 'negativebalancesuspended-body' => "Your :site account has been suspended for having a negative balance for too long. " . "Consider setting up an automatic payment to avoid messages like this in the future.", 'negativebalancesuspended-body-ext' => "Settle up now to unsuspend your account:", 'negativebalancesuspended-body-warning' => "Please, be aware that your account and all its data will be deleted " . "if your account balance is not settled by :date.", 'negativebalancebeforedelete-subject' => ":site Final Warning", 'negativebalancebeforedelete-body' => "This is a final reminder to settle your :site account balance. " . "Your account and all its data will be deleted if your account balance is not settled by :date.", 'negativebalancebeforedelete-body-ext' => "Settle up now to keep your account:", 'passwordreset-subject' => ":site Password Reset", 'passwordreset-body1' => "Someone recently asked to change your :site password.", 'passwordreset-body2' => "If this was you, use this verification code to complete the process:", 'passwordreset-body3' => "You can also click the link below:", 'passwordreset-body4' => "If you did not make such a request, you can either ignore this message or get in touch with us about this incident.", 'paymentmandatedisabled-subject' => ":site Auto-payment Problem", 'paymentmandatedisabled-body' => "Your :site account balance is negative " . "and the configured amount for automatically topping up the balance does not cover " . "the costs of subscriptions consumed.", 'paymentmandatedisabled-body-ext' => "Charging you multiple times for the same amount in short succession " . "could lead to issues with the payment provider. " . "In order to not cause any problems, we suspended auto-payment for your account. " . "To resolve this issue, login to your account settings and adjust your auto-payment amount.", 'paymentfailure-subject' => ":site Payment Failed", 'paymentfailure-body' => "Something went wrong with auto-payment for your :site account.\n" . "We tried to charge you via your preferred payment method, but the charge did not go through.", 'paymentfailure-body-ext' => "In order to not cause any further issues, we suspended auto-payment for your account. " . "To resolve this issue, login to your account settings at", 'paymentfailure-body-rest' => "There you can pay manually for your account and " . "change your auto-payment settings.", 'paymentsuccess-subject' => ":site Payment Succeeded", 'paymentsuccess-body' => "The auto-payment for your :site account went through without issues. " . "You can check your new account balance and more details here:", 'support' => "Special circumstances? Something is wrong with a charge?\n" . ":site Support is here to help.", 'signupcode-subject' => ":site Registration", '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.", 'suspendeddebtor-middle' => "Settle up now to reactivate your account.", 'suspendeddebtor-cancel' => "Don't want to be our customer anymore? " . "Here is how you can cancel your account:", ]; diff --git a/src/resources/themes/app.scss b/src/resources/themes/app.scss index b3944964..792f3044 100644 --- a/src/resources/themes/app.scss +++ b/src/resources/themes/app.scss @@ -1,431 +1,442 @@ html, body, body > .outer-container { height: 100%; } #app { display: flex; flex-direction: column; min-height: 100%; overflow: hidden; & > nav { flex-shrink: 0; z-index: 12; } & > div.container { flex-grow: 1; margin-top: 2rem; margin-bottom: 2rem; } & > .filler { flex-grow: 1; } & > div.container + .filler { display: none; } } .error-page { position: absolute; top: 0; height: 100%; width: 100%; align-items: center; display: flex; justify-content: center; color: #636b6f; z-index: 10; background: white; .code { text-align: right; border-right: 2px solid; font-size: 26px; padding: 0 15px; } .message { font-size: 18px; padding: 0 15px; } } .app-loader { background-color: $body-bg; height: 100%; width: 100%; position: absolute; top: 0; left: 0; display: flex; align-items: center; justify-content: center; z-index: 8; .spinner-border { width: 120px; height: 120px; border-width: 15px; color: #b2aa99; } &.small .spinner-border { width: 25px; height: 25px; border-width: 3px; } &.fadeOut { visibility: hidden; opacity: 0; transition: visibility 300ms linear, opacity 300ms linear; } } pre { margin: 1rem 0; padding: 1rem; background-color: $menu-bg-color; } .card-title { font-size: 1.2rem; font-weight: bold; } tfoot.table-fake-body { background-color: #f8f8f8; color: grey; text-align: center; td { vertical-align: middle; height: 8em; border: 0; } tbody:not(:empty) + & { display: none; } } table { - td.buttons, + th { + white-space: nowrap; + } + td.email, td.price, td.datetime, td.selection { width: 1%; white-space: nowrap; } + td.buttons, th.price, td.price { width: 1%; text-align: right; white-space: nowrap; } &.form-list { margin: 0; td { border: 0; &:first-child { padding-left: 0; } &:last-child { padding-right: 0; } } button { line-height: 1; } } .btn-action { line-height: 1; padding: 0; } } .list-details { min-height: 1em; & > ul { margin: 0; padding-left: 1.2em; } } .plan-selector { .plan-header { display: flex; } .plan-ico { margin:auto; font-size: 3.8rem; color: #f1a539; border: 3px solid #f1a539; width: 6rem; height: 6rem; border-radius: 50%; } } .status-message { display: flex; align-items: center; justify-content: center; .app-loader { width: auto; position: initial; .spinner-border { color: $body-color; } } svg { font-size: 1.5em; } :first-child { margin-right: 0.4em; } } .form-separator { position: relative; margin: 1em 0; display: flex; justify-content: center; hr { border-color: #999; margin: 0; position: absolute; top: 0.75em; width: 100%; } span { background: #fff; padding: 0 1em; z-index: 1; } } #status-box { background-color: lighten($green, 35); .progress { background-color: #fff; height: 10px; } .progress-label { font-size: 0.9em; } .progress-bar { background-color: $green; } &.process-failed { background-color: lighten($orange, 30); .progress-bar { background-color: $red; } } } @keyframes blinker { 50% { opacity: 0; } } .blinker { animation: blinker 750ms step-start infinite; } #dashboard-nav { display: flex; flex-wrap: wrap; justify-content: center; & > a { padding: 1rem; text-align: center; white-space: nowrap; margin: 0.25rem; text-decoration: none; width: 150px; &.disabled { pointer-events: none; opacity: 0.6; } + // Some icons are too big, scale them down + &.link-invitations { + svg { + transform: scale(0.9); + } + } + .badge { position: absolute; top: 0.5rem; right: 0.5rem; } } svg { width: 6rem; height: 6rem; margin: auto; } } #logon-form { flex-basis: auto; // Bootstrap issue? See logon page with width < 992 } #logon-form-footer { a:not(:first-child) { margin-left: 2em; } } // Various improvements for mobile @include media-breakpoint-down(sm) { .card, .card-footer { border: 0; } .card-body { padding: 0.5rem 0; } .form-group { margin-bottom: 0.5rem; } .nav-tabs { flex-wrap: nowrap; overflow-x: auto; .nav-link { white-space: nowrap; padding: 0.5rem 0.75rem; } } .tab-content { margin-top: 0.5rem; } .col-form-label { color: #666; font-size: 95%; } .form-group.plaintext .col-form-label { padding-bottom: 0; } form.read-only.short label { width: 35%; & + * { width: 65%; } } #app > div.container { margin-bottom: 1rem; margin-top: 1rem; max-width: 100%; } #header-menu-navbar { padding: 0; } #dashboard-nav > a { width: 135px; } .table-sm:not(.form-list) { tbody td { padding: 0.75rem 0.5rem; svg { vertical-align: -0.175em; } & > svg { font-size: 125%; margin-right: 0.25rem; } } } .table.transactions { thead { display: none; } tbody { tr { position: relative; display: flex; flex-wrap: wrap; } td { width: auto; border: 0; padding: 0.5rem; &.datetime { width: 50%; padding-left: 0; } &.description { order: 3; width: 100%; border-bottom: 1px solid $border-color; color: $secondary; padding: 0 1.5em 0.5rem 0; margin-top: -0.25em; } &.selection { position: absolute; right: 0; border: 0; top: 1.7em; padding-right: 0; } &.price { width: 50%; padding-right: 0; } &.email { display: none; } } } } } 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 index 00000000..7b2a6596 --- /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 index 00000000..1788b4a4 --- /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 index c50013f8..7cbe8fba 100644 --- a/src/resources/vue/Reseller/Dashboard.vue +++ b/src/resources/vue/Reseller/Dashboard.vue @@ -1,17 +1,24 @@ diff --git a/src/resources/vue/Reseller/Invitations.vue b/src/resources/vue/Reseller/Invitations.vue new file mode 100644 index 00000000..c2c89312 --- /dev/null +++ b/src/resources/vue/Reseller/Invitations.vue @@ -0,0 +1,283 @@ + + + diff --git a/src/resources/vue/Signup.vue b/src/resources/vue/Signup.vue index fa02dc76..059efe44 100644 --- a/src/resources/vue/Signup.vue +++ b/src/resources/vue/Signup.vue @@ -1,264 +1,305 @@ diff --git a/src/routes/api.php b/src/routes/api.php index 307a5ce9..c6dd6bdd 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -1,184 +1,186 @@ 'api', 'prefix' => $prefix . 'api/auth' ], function ($router) { Route::post('login', 'API\AuthController@login'); Route::group( ['middleware' => 'auth:api'], function ($router) { Route::get('info', 'API\AuthController@info'); Route::post('logout', 'API\AuthController@logout'); Route::post('refresh', 'API\AuthController@refresh'); } ); } ); Route::group( [ 'domain' => \config('app.domain'), 'middleware' => 'api', 'prefix' => $prefix . 'api/auth' ], function ($router) { Route::post('password-reset/init', 'API\PasswordResetController@init'); 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'); } ); Route::group( [ 'domain' => \config('app.domain'), 'middleware' => 'auth:api', 'prefix' => $prefix . 'api/v4' ], function () { Route::apiResource('domains', API\V4\DomainsController::class); Route::get('domains/{id}/confirm', 'API\V4\DomainsController@confirm'); Route::get('domains/{id}/status', 'API\V4\DomainsController@status'); Route::apiResource('packages', API\V4\PackagesController::class); Route::apiResource('skus', API\V4\SkusController::class); Route::apiResource('users', API\V4\UsersController::class); Route::get('users/{id}/skus', 'API\V4\SkusController@userSkus'); Route::get('users/{id}/status', 'API\V4\UsersController@status'); Route::apiResource('wallets', API\V4\WalletsController::class); Route::get('wallets/{id}/transactions', 'API\V4\WalletsController@transactions'); Route::get('wallets/{id}/receipts', 'API\V4\WalletsController@receipts'); Route::get('wallets/{id}/receipts/{receipt}', 'API\V4\WalletsController@receiptDownload'); Route::post('payments', 'API\V4\PaymentsController@store'); Route::get('payments/mandate', 'API\V4\PaymentsController@mandate'); Route::post('payments/mandate', 'API\V4\PaymentsController@mandateCreate'); Route::put('payments/mandate', 'API\V4\PaymentsController@mandateUpdate'); Route::delete('payments/mandate', 'API\V4\PaymentsController@mandateDelete'); Route::get('openvidu/rooms', 'API\V4\OpenViduController@index'); Route::post('openvidu/rooms/{id}/close', 'API\V4\OpenViduController@closeRoom'); Route::post('openvidu/rooms/{id}/config', 'API\V4\OpenViduController@setRoomConfig'); // FIXME: I'm not sure about this one, should we use DELETE request maybe? Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection'); Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection'); Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest'); Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest'); } ); // Note: In Laravel 7.x we could just use withoutMiddleware() instead of a separate group Route::group( [ 'domain' => \config('app.domain'), 'prefix' => $prefix . 'api/v4' ], function () { Route::post('openvidu/rooms/{id}', 'API\V4\OpenViduController@joinRoom'); Route::post('openvidu/rooms/{id}/connections', 'API\V4\OpenViduController@createConnection'); // FIXME: I'm not sure about this one, should we use DELETE request maybe? Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection'); Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection'); Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest'); Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest'); } ); Route::group( [ 'domain' => \config('app.domain'), 'middleware' => 'api', 'prefix' => $prefix . 'api/v4' ], function ($router) { Route::post('support/request', 'API\V4\SupportController@request'); } ); Route::group( [ 'domain' => \config('app.domain'), 'prefix' => $prefix . 'api/webhooks', ], function () { Route::post('payment/{provider}', 'API\V4\PaymentsController@webhook'); Route::post('meet/openvidu', 'API\V4\OpenViduController@webhook'); } ); Route::group( [ 'domain' => 'admin.' . \config('app.domain'), 'middleware' => ['auth:api', 'admin'], 'prefix' => $prefix . 'api/v4', ], function () { Route::apiResource('domains', API\V4\Admin\DomainsController::class); Route::post('domains/{id}/suspend', 'API\V4\Admin\DomainsController@suspend'); Route::post('domains/{id}/unsuspend', 'API\V4\Admin\DomainsController@unsuspend'); Route::apiResource('packages', API\V4\Admin\PackagesController::class); Route::apiResource('skus', API\V4\Admin\SkusController::class); Route::apiResource('users', API\V4\Admin\UsersController::class); Route::post('users/{id}/reset2FA', 'API\V4\Admin\UsersController@reset2FA'); Route::get('users/{id}/skus', 'API\V4\Admin\SkusController@userSkus'); Route::post('users/{id}/suspend', 'API\V4\Admin\UsersController@suspend'); Route::post('users/{id}/unsuspend', 'API\V4\Admin\UsersController@unsuspend'); Route::apiResource('wallets', API\V4\Admin\WalletsController::class); Route::post('wallets/{id}/one-off', 'API\V4\Admin\WalletsController@oneOff'); Route::get('wallets/{id}/transactions', 'API\V4\Admin\WalletsController@transactions'); Route::apiResource('discounts', API\V4\Admin\DiscountsController::class); Route::get('stats/chart/{chart}', 'API\V4\Admin\StatsController@chart'); } ); Route::group( [ 'domain' => 'reseller.' . \config('app.domain'), 'middleware' => ['auth:api', 'reseller'], 'prefix' => $prefix . 'api/v4', ], function () { Route::apiResource('domains', API\V4\Reseller\DomainsController::class); Route::post('domains/{id}/suspend', 'API\V4\Reseller\DomainsController@suspend'); Route::post('domains/{id}/unsuspend', 'API\V4\Reseller\DomainsController@unsuspend'); - + 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); Route::post('users/{id}/reset2FA', 'API\V4\Reseller\UsersController@reset2FA'); Route::get('users/{id}/skus', 'API\V4\Reseller\SkusController@userSkus'); Route::post('users/{id}/suspend', 'API\V4\Reseller\UsersController@suspend'); Route::post('users/{id}/unsuspend', 'API\V4\Reseller\UsersController@unsuspend'); Route::apiResource('wallets', API\V4\Reseller\WalletsController::class); Route::post('wallets/{id}/one-off', 'API\V4\Reseller\WalletsController@oneOff'); Route::get('wallets/{id}/transactions', 'API\V4\Reseller\WalletsController@transactions'); Route::apiResource('discounts', API\V4\Reseller\DiscountsController::class); } ); diff --git a/src/tests/Browser/Pages/Reseller/Invitations.php b/src/tests/Browser/Pages/Reseller/Invitations.php new file mode 100644 index 00000000..8837c21c --- /dev/null +++ b/src/tests/Browser/Pages/Reseller/Invitations.php @@ -0,0 +1,49 @@ +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 index 00000000..8d43bb90 --- /dev/null +++ b/src/tests/Browser/Reseller/InvitationsTest.php @@ -0,0 +1,222 @@ +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 index eed0948d..31655561 100644 --- a/src/tests/Browser/SignupTest.php +++ b/src/tests/Browser/SignupTest.php @@ -1,545 +1,634 @@ deleteTestUser('signuptestdusk@' . \config('app.domain')); $this->deleteTestUser('admin@user-domain-signup.com'); $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(); } /** * Test signup code verification with a link */ public function testSignupCodeByLink(): void { // Test invalid code (invalid format) $this->browse(function (Browser $browser) { // Register Signup page element selectors we'll be using $browser->onWithoutAssert(new Signup()); // TODO: Test what happens if user is logged in $browser->visit('/signup/invalid-code'); // TODO: According to https://github.com/vuejs/vue-router/issues/977 // it is not yet easily possible to display error page component (route) // without changing the URL // TODO: Instead of css selector we should probably define page/component // and use it instead $browser->waitFor('#error-page'); }); // Test invalid code (valid format) $this->browse(function (Browser $browser) { $browser->visit('/signup/XXXXX-code'); // FIXME: User will not be able to continue anyway, so we should // either display 1st step or 404 error page $browser->waitFor('@step1') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); }); // Test valid code $this->browse(function (Browser $browser) { $code = SignupCode::create([ 'data' => [ 'email' => 'User@example.org', 'first_name' => 'User', 'last_name' => 'Name', 'plan' => 'individual', 'voucher' => '', ] ]); $browser->visit('/signup/' . $code->short_code . '-' . $code->code) ->waitFor('@step3') ->assertMissing('@step1') ->assertMissing('@step2'); // FIXME: Find a nice way to read javascript data without using hidden inputs $this->assertSame($code->code, $browser->value('@step2 #signup_code')); // TODO: Test if the signup process can be completed }); } /** * Test signup "welcome" page */ public function testSignupStep0(): void { $this->browse(function (Browser $browser) { $browser->visit(new Signup()); $browser->assertVisible('@step0') ->assertMissing('@step1') ->assertMissing('@step2') ->assertMissing('@step3'); $browser->within(new Menu(), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login'], 'signup'); }); $browser->waitFor('@step0 .plan-selector > .card'); // Assert first plan box and press the button $browser->with('@step0 .plan-selector > .plan-individual', function ($step) { $step->assertVisible('button') ->assertSeeIn('button', 'Individual Account') ->assertVisible('.plan-description') ->click('button'); }); $browser->waitForLocation('/signup/individual') ->assertVisible('@step1') ->assertMissing('@step0') ->assertMissing('@step2') ->assertMissing('@step3') ->assertFocused('@step1 #signup_first_name'); // Click Back button $browser->click('@step1 [type=button]') ->waitForLocation('/signup') ->assertVisible('@step0') ->assertMissing('@step1') ->assertMissing('@step2') ->assertMissing('@step3'); // Choose the group account plan $browser->click('@step0 .plan-selector > .plan-group button') ->waitForLocation('/signup/group') ->assertVisible('@step1') ->assertMissing('@step0') ->assertMissing('@step2') ->assertMissing('@step3') ->assertFocused('@step1 #signup_first_name'); // TODO: Test if 'plan' variable is set properly in vue component }); } /** * Test 1st step of the signup process */ public function testSignupStep1(): void { $this->browse(function (Browser $browser) { $browser->visit('/signup/individual') ->onWithoutAssert(new Signup()); // Here we expect two text inputs and Back and Continue buttons $browser->with('@step1', function ($step) { $step->assertVisible('#signup_last_name') ->assertVisible('#signup_first_name') ->assertFocused('#signup_first_name') ->assertVisible('#signup_email') ->assertVisible('[type=button]') ->assertVisible('[type=submit]'); }); // Submit empty form // Email is required, so after pressing Submit // we expect focus to be moved to the email input $browser->with('@step1', function ($step) { $step->click('[type=submit]'); $step->assertFocused('#signup_email'); }); $browser->within(new Menu(), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login'], 'signup'); }); // Submit invalid email, and first_name // We expect both inputs to have is-invalid class added, with .invalid-feedback element $browser->with('@step1', function ($step) { $step->type('#signup_first_name', str_repeat('a', 250)) ->type('#signup_email', '@test') ->click('[type=submit]') ->waitFor('#signup_email.is-invalid') ->assertVisible('#signup_first_name.is-invalid') ->assertVisible('#signup_email + .invalid-feedback') ->assertVisible('#signup_last_name + .invalid-feedback') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); }); // Submit valid data // We expect error state on email input to be removed, and Step 2 form visible $browser->with('@step1', function ($step) { $step->type('#signup_first_name', 'Test') ->type('#signup_last_name', 'User') ->type('#signup_email', 'BrowserSignupTestUser1@kolab.org') ->click('[type=submit]') ->assertMissing('#signup_email.is-invalid') ->assertMissing('#signup_email + .invalid-feedback'); }); $browser->waitUntilMissing('@step2 #signup_code[value=""]'); $browser->waitFor('@step2'); $browser->assertMissing('@step1'); }); } /** * Test 2nd Step of the signup process * * @depends testSignupStep1 */ public function testSignupStep2(): void { $this->browse(function (Browser $browser) { $browser->assertVisible('@step2') ->assertMissing('@step0') ->assertMissing('@step1') ->assertMissing('@step3'); // Here we expect one text input, Back and Continue buttons $browser->with('@step2', function ($step) { $step->assertVisible('#signup_short_code') ->assertFocused('#signup_short_code') ->assertVisible('[type=button]') ->assertVisible('[type=submit]'); }); // Test Back button functionality $browser->click('@step2 [type=button]') ->waitFor('@step1') ->assertFocused('@step1 #signup_first_name') ->assertMissing('@step2'); // Submit valid Step 1 data (again) $browser->with('@step1', function ($step) { $step->type('#signup_first_name', 'User') ->type('#signup_last_name', 'User') ->type('#signup_email', 'BrowserSignupTestUser1@kolab.org') ->click('[type=submit]'); }); $browser->waitFor('@step2'); $browser->assertMissing('@step1'); // Submit invalid code // We expect code input to have is-invalid class added, with .invalid-feedback element $browser->with('@step2', function ($step) { $step->type('#signup_short_code', 'XXXXX'); $step->click('[type=submit]'); $step->waitFor('#signup_short_code.is-invalid') ->assertVisible('#signup_short_code + .invalid-feedback') ->assertFocused('#signup_short_code') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); }); // Submit valid code // We expect error state on code input to be removed, and Step 3 form visible $browser->with('@step2', function ($step) { // Get the code and short_code from database // FIXME: Find a nice way to read javascript data without using hidden inputs $code = $step->value('#signup_code'); $this->assertNotEmpty($code); $code = SignupCode::find($code); $step->type('#signup_short_code', $code->short_code); $step->click('[type=submit]'); $step->assertMissing('#signup_short_code.is-invalid'); $step->assertMissing('#signup_short_code + .invalid-feedback'); }); $browser->waitFor('@step3'); $browser->assertMissing('@step2'); }); } /** * Test 3rd Step of the signup process * * @depends testSignupStep2 */ public function testSignupStep3(): void { $this->browse(function (Browser $browser) { $browser->assertVisible('@step3'); // 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 }); // Test Back button $browser->click('@step3 [type=button]'); $browser->waitFor('@step2'); $browser->assertFocused('@step2 #signup_short_code'); $browser->assertMissing('@step3'); // TODO: Test form reset when going back // Submit valid code again $browser->with('@step2', function ($step) { $code = $step->value('#signup_code'); $this->assertNotEmpty($code); $code = SignupCode::find($code); $step->type('#signup_short_code', $code->short_code); $step->click('[type=submit]'); }); $browser->waitFor('@step3'); // Submit invalid data $browser->with('@step3', function ($step) { $step->assertFocused('#signup_login') ->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 invalid data (valid login, invalid password) $browser->with('@step3', function ($step) { $step->type('#signup_login', 'SignupTestDusk') ->click('[type=submit]') ->waitFor('#signup_password.is-invalid') ->assertVisible('#signup_password + .invalid-feedback') ->assertMissing('#signup_login.is-invalid') ->assertMissing('#signup_domain + .invalid-feedback') ->assertFocused('#signup_password') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); }); // Submit valid data $browser->with('@step3', function ($step) { $step->type('#signup_confirm', '12345678'); $step->click('[type=submit]'); }); // At this point we should be auto-logged-in to dashboard $browser->waitUntilMissing('@step3') ->waitUntilMissing('.app-loader') ->on(new Dashboard()) ->assertUser('signuptestdusk@' . \config('app.domain')) ->assertVisible('@links a.link-profile') ->assertMissing('@links a.link-domains') ->assertVisible('@links a.link-users') ->assertVisible('@links a.link-wallet'); // Logout the user $browser->within(new Menu(), function ($browser) { $browser->clickMenuItem('logout'); }); }); } /** * Test signup for a group account */ public function testSignupGroup(): void { $this->browse(function (Browser $browser) { $browser->visit(new Signup()); // Choose the group account plan $browser->waitFor('@step0 .plan-group button') ->click('@step0 .plan-group button'); // Submit valid data // We expect error state on email input to be removed, and Step 2 form visible $browser->whenAvailable('@step1', function ($step) { $step->type('#signup_first_name', 'Test') ->type('#signup_last_name', 'User') ->type('#signup_email', 'BrowserSignupTestUser1@kolab.org') ->click('[type=submit]'); }); // Submit valid code $browser->whenAvailable('@step2', function ($step) { // Get the code and short_code from database // FIXME: Find a nice way to read javascript data without using hidden inputs $code = $step->value('#signup_code'); $code = SignupCode::find($code); $step->type('#signup_short_code', $code->short_code) ->click('[type=submit]'); }); // Here we expect 4 text inputs, Back and Continue buttons $browser->whenAvailable('@step3', function ($step) { $step->assertVisible('#signup_login') ->assertVisible('#signup_password') ->assertVisible('#signup_confirm') ->assertVisible('input#signup_domain') ->assertVisible('[type=button]') ->assertVisible('[type=submit]') ->assertFocused('#signup_login') ->assertValue('input#signup_domain', '') ->assertValue('#signup_login', '') ->assertValue('#signup_password', '') ->assertValue('#signup_confirm', ''); }); // Submit invalid login and password data $browser->with('@step3', function ($step) { $step->assertFocused('#signup_login') ->type('#signup_login', '*') ->type('#signup_domain', 'test.com') ->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 invalid domain $browser->with('@step3', function ($step) { $step->type('#signup_login', 'admin') ->type('#signup_domain', 'aaa') ->type('#signup_password', '12345678') ->type('#signup_confirm', '12345678') ->click('[type=submit]') ->waitUntilMissing('#signup_login.is-invalid') ->waitFor('#signup_domain.is-invalid + .invalid-feedback') ->assertMissing('#signup_password.is-invalid') ->assertMissing('#signup_password + .invalid-feedback') ->assertFocused('#signup_domain') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); }); // Submit invalid domain $browser->with('@step3', function ($step) { $step->type('#signup_domain', 'user-domain-signup.com') ->click('[type=submit]'); }); // At this point we should be auto-logged-in to dashboard $browser->waitUntilMissing('@step3') ->waitUntilMissing('.app-loader') ->on(new Dashboard()) ->assertUser('admin@user-domain-signup.com') ->assertVisible('@links a.link-profile') ->assertVisible('@links a.link-domains') ->assertVisible('@links a.link-users') ->assertVisible('@links a.link-wallet'); $browser->within(new Menu(), function ($browser) { $browser->clickMenuItem('logout'); }); }); } /** * Test signup with voucher */ public function testSignupVoucherLink(): void { $this->browse(function (Browser $browser) { $browser->visit('/signup/voucher/TEST') ->onWithoutAssert(new Signup()) ->waitUntilMissing('.app-loader') ->waitFor('@step0') ->click('.plan-individual button') ->whenAvailable('@step1', function (Browser $browser) { $browser->type('#signup_first_name', 'Test') ->type('#signup_last_name', 'User') ->type('#signup_email', 'BrowserSignupTestUser1@kolab.org') ->click('[type=submit]'); }) ->whenAvailable('@step2', function (Browser $browser) { // Get the code and short_code from database // FIXME: Find a nice way to read javascript data without using hidden inputs $code = $browser->value('#signup_code'); $this->assertNotEmpty($code); $code = SignupCode::find($code); $browser->type('#signup_short_code', $code->short_code) ->click('[type=submit]'); }) ->whenAvailable('@step3', function (Browser $browser) { // Assert that the code is filled in the input // Change it and test error handling $browser->assertValue('#signup_voucher', 'TEST') ->type('#signup_voucher', 'TESTXX') ->type('#signup_login', 'signuptestdusk') ->type('#signup_password', '123456789') ->type('#signup_confirm', '123456789') ->click('[type=submit]') ->waitFor('#signup_voucher.is-invalid') ->assertVisible('#signup_voucher + .invalid-feedback') ->assertFocused('#signup_voucher') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') // Submit the correct code ->type('#signup_voucher', 'TEST') ->click('[type=submit]'); }) ->waitUntilMissing('@step3') ->waitUntilMissing('.app-loader') ->on(new Dashboard()) ->assertUser('signuptestdusk@' . \config('app.domain')) // Logout the user ->within(new Menu(), function ($browser) { $browser->clickMenuItem('logout'); }); }); $user = $this->getTestUser('signuptestdusk@' . \config('app.domain')); $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 index 00000000..bfc383ab --- /dev/null +++ b/src/tests/Feature/Controller/Reseller/InvitationsTest.php @@ -0,0 +1,348 @@ + 1]); + + parent::tearDown(); + } + + /** + * Test deleting invitations (DELETE /api/v4/invitations/) + */ + 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//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 index 704f2697..9905c646 100644 --- a/src/tests/Feature/Controller/SignupTest.php +++ b/src/tests/Feature/Controller/SignupTest.php @@ -1,710 +1,782 @@ domain = $this->getPublicDomain(); $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(); } /** * {@inheritDoc} */ public function tearDown(): void { $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(); } /** * Return a public domain for signup tests */ private function getPublicDomain(): string { if (!$this->domain) { $this->refreshApplication(); $public_domains = Domain::getPublicDomains(); $this->domain = reset($public_domains); if (empty($this->domain)) { $this->domain = 'signup-domain.com'; Domain::create([ 'namespace' => $this->domain, 'status' => Domain::STATUS_ACTIVE, 'type' => Domain::TYPE_PUBLIC, ]); } } return $this->domain; } /** * Test fetching plans for signup - * - * @return void */ - public function testSignupPlans() + public function testSignupPlans(): void { $response = $this->get('/api/auth/signup/plans'); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertCount(2, $json['plans']); $this->assertArrayHasKey('title', $json['plans'][0]); $this->assertArrayHasKey('name', $json['plans'][0]); $this->assertArrayHasKey('description', $json['plans'][0]); $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 = []; $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('email', $json['errors']); // Data with missing name $data = [ 'email' => 'UsersApiControllerTest1@UsersApiControllerTest.com', 'first_name' => str_repeat('a', 250), 'last_name' => str_repeat('a', 250), ]; $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('first_name', $json['errors']); $this->assertArrayHasKey('last_name', $json['errors']); // Data with invalid email (but not phone number) $data = [ 'email' => '@example.org', 'first_name' => 'Signup', 'last_name' => 'User', ]; $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('email', $json['errors']); // Sanity check on voucher code, last/first name is optional $data = [ 'voucher' => '123456789012345678901234567890123', 'email' => 'valid@email.com', ]; $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('voucher', $json['errors']); // TODO: Test phone validation } /** * Test signup initialization with valid input - * - * @return array */ - public function testSignupInitValidInput() + public function testSignupInitValidInput(): array { Queue::fake(); // Assert that no jobs were pushed... Queue::assertNothingPushed(); $data = [ 'email' => 'testuser@external.com', 'first_name' => 'Signup', 'last_name' => 'User', 'plan' => 'individual', ]; $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(200); $this->assertCount(2, $json); $this->assertSame('success', $json['status']); $this->assertNotEmpty($json['code']); // Assert the email sending job was pushed once Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, 1); // Assert the job has proper data assigned Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, function ($job) use ($data, $json) { $code = TestCase::getObjectProperty($job, 'code'); return $code->code === $json['code'] && $code->data['plan'] === $data['plan'] && $code->data['email'] === $data['email'] && $code->data['first_name'] === $data['first_name'] && $code->data['last_name'] === $data['last_name']; }); // Try the same with voucher $data['voucher'] = 'TEST'; $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(200); $this->assertCount(2, $json); $this->assertSame('success', $json['status']); $this->assertNotEmpty($json['code']); // Assert the job has proper data assigned Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, function ($job) use ($data, $json) { $code = TestCase::getObjectProperty($job, 'code'); return $code->code === $json['code'] && $code->data['plan'] === $data['plan'] && $code->data['email'] === $data['email'] && $code->data['voucher'] === $data['voucher'] && $code->data['first_name'] === $data['first_name'] && $code->data['last_name'] === $data['last_name']; }); return [ 'code' => $json['code'], 'email' => $data['email'], 'first_name' => $data['first_name'], 'last_name' => $data['last_name'], 'plan' => $data['plan'], 'voucher' => $data['voucher'] ]; } /** * 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 = []; $response = $this->post('/api/auth/signup/verify', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('code', $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); // Data with existing code but missing short_code $data = [ 'code' => $result['code'], ]; $response = $this->post('/api/auth/signup/verify', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); // Data with invalid short_code $data = [ 'code' => $result['code'], 'short_code' => 'XXXX', ]; $response = $this->post('/api/auth/signup/verify', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); // TODO: Test expired code } /** * 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 = [ 'code' => $code->code, 'short_code' => $code->short_code, ]; $response = $this->post('/api/auth/signup/verify', $data); $json = $response->json(); $response->assertStatus(200); $this->assertCount(7, $json); $this->assertSame('success', $json['status']); $this->assertSame($result['email'], $json['email']); $this->assertSame($result['first_name'], $json['first_name']); $this->assertSame($result['last_name'], $json['last_name']); $this->assertSame($result['voucher'], $json['voucher']); $this->assertSame(false, $json['is_domain']); $this->assertTrue(is_array($json['domains']) && !empty($json['domains'])); return $result; } /** * 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 = []; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(3, $json['errors']); $this->assertArrayHasKey('login', $json['errors']); $this->assertArrayHasKey('password', $json['errors']); $this->assertArrayHasKey('domain', $json['errors']); $domain = $this->getPublicDomain(); // Passwords do not match and missing domain $data = [ 'login' => 'test', 'password' => 'test', 'password_confirmation' => 'test2', ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('password', $json['errors']); $this->assertArrayHasKey('domain', $json['errors']); $domain = $this->getPublicDomain(); // Login too short $data = [ 'login' => '1', 'domain' => $domain, 'password' => 'test', 'password_confirmation' => 'test', ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('login', $json['errors']); // Missing codes $data = [ 'login' => 'login-valid', 'domain' => $domain, 'password' => 'test', 'password_confirmation' => 'test', ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('code', $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); // Data with invalid short_code $data = [ 'login' => 'TestLogin', 'domain' => $domain, 'password' => 'test', 'password_confirmation' => 'test', 'code' => $result['code'], 'short_code' => 'XXXX', ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); $code = SignupCode::find($result['code']); // Data with invalid voucher $data = [ 'login' => 'TestLogin', 'domain' => $domain, 'password' => 'test', 'password_confirmation' => 'test', 'code' => $result['code'], 'short_code' => $code->short_code, 'voucher' => 'XXX', ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('voucher', $json['errors']); // Valid code, invalid login $data = [ 'login' => 'żżżżżż', 'domain' => $domain, 'password' => 'test', 'password_confirmation' => 'test', 'code' => $result['code'], 'short_code' => $code->short_code, ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('login', $json['errors']); } /** * 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(); $domain = $this->getPublicDomain(); $identity = \strtolower('SignupLogin@') . $domain; $code = SignupCode::find($result['code']); $data = [ 'login' => 'SignupLogin', 'domain' => $domain, 'password' => 'test', 'password_confirmation' => 'test', 'code' => $code->code, 'short_code' => $code->short_code, 'voucher' => 'TEST', ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame('bearer', $json['token_type']); $this->assertTrue(!empty($json['expires_in']) && is_int($json['expires_in']) && $json['expires_in'] > 0); $this->assertNotEmpty($json['access_token']); $this->assertSame($identity, $json['email']); Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1); Queue::assertPushed( \App\Jobs\User\CreateJob::class, function ($job) use ($data) { $userEmail = TestCase::getObjectProperty($job, 'userEmail'); return $userEmail === \strtolower($data['login'] . '@' . $data['domain']); } ); // Check if the code has been removed $this->assertNull(SignupCode::where('code', $result['code'])->first()); // Check if the user has been created $user = User::where('email', $identity)->first(); $this->assertNotEmpty($user); $this->assertSame($identity, $user->email); // Check user settings $this->assertSame($result['first_name'], $user->getSetting('first_name')); $this->assertSame($result['last_name'], $user->getSetting('last_name')); $this->assertSame($result['email'], $user->getSetting('external_email')); // Discount $discount = Discount::where('code', 'TEST')->first(); $this->assertSame($discount->id, $user->wallets()->first()->discount_id); // TODO: Check SKUs/Plan // TODO: Check if the access token works } /** * Test signup for a group (custom domain) account - * - * @return void */ - public function testSignupGroupAccount() + public function testSignupGroupAccount(): void { Queue::fake(); // Initial signup request $user_data = $data = [ 'email' => 'testuser@external.com', 'first_name' => 'Signup', 'last_name' => 'User', 'plan' => 'group', ]; $response = $this->withoutMiddleware()->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(200); $this->assertCount(2, $json); $this->assertSame('success', $json['status']); $this->assertNotEmpty($json['code']); // Assert the email sending job was pushed once Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, 1); // Assert the job has proper data assigned Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, function ($job) use ($data, $json) { $code = TestCase::getObjectProperty($job, 'code'); return $code->code === $json['code'] && $code->data['plan'] === $data['plan'] && $code->data['email'] === $data['email'] && $code->data['first_name'] === $data['first_name'] && $code->data['last_name'] === $data['last_name']; }); // Verify the code $code = SignupCode::find($json['code']); $data = [ 'code' => $code->code, 'short_code' => $code->short_code, ]; $response = $this->post('/api/auth/signup/verify', $data); $result = $response->json(); $response->assertStatus(200); $this->assertCount(7, $result); $this->assertSame('success', $result['status']); $this->assertSame($user_data['email'], $result['email']); $this->assertSame($user_data['first_name'], $result['first_name']); $this->assertSame($user_data['last_name'], $result['last_name']); $this->assertSame(null, $result['voucher']); $this->assertSame(true, $result['is_domain']); $this->assertSame([], $result['domains']); // Final signup request $login = 'admin'; $domain = 'external.com'; $data = [ 'login' => $login, 'domain' => $domain, 'password' => 'test', 'password_confirmation' => 'test', 'code' => $code->code, 'short_code' => $code->short_code, ]; $response = $this->post('/api/auth/signup', $data); $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("$login@$domain", $result['email']); Queue::assertPushed(\App\Jobs\Domain\CreateJob::class, 1); Queue::assertPushed( \App\Jobs\Domain\CreateJob::class, function ($job) use ($domain) { $domainNamespace = TestCase::getObjectProperty($job, 'domainNamespace'); return $domainNamespace === $domain; } ); Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1); Queue::assertPushed( \App\Jobs\User\CreateJob::class, function ($job) use ($data) { $userEmail = TestCase::getObjectProperty($job, 'userEmail'); return $userEmail === $data['login'] . '@' . $data['domain']; } ); // Check if the code has been removed $this->assertNull(SignupCode::find($code->id)); // Check if the user has been created $user = User::where('email', $login . '@' . $domain)->first(); $this->assertNotEmpty($user); // Check user settings $this->assertSame($user_data['email'], $user->getSetting('external_email')); $this->assertSame($user_data['first_name'], $user->getSetting('first_name')); $this->assertSame($user_data['last_name'], $user->getSetting('last_name')); // TODO: Check domain record // TODO: Check SKUs/Plan // 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() * * @return array Arguments for testValidateLogin() */ public function dataValidateLogin(): array { $domain = $this->getPublicDomain(); return [ // Individual account ['', $domain, false, ['login' => 'The login field is required.']], ['test123456', 'localhost', false, ['domain' => 'The specified domain is invalid.']], ['test123456', 'unknown-domain.org', false, ['domain' => 'The specified domain is invalid.']], ['test.test', $domain, false, null], ['test_test', $domain, false, null], ['test-test', $domain, false, null], ['admin', $domain, false, ['login' => 'The specified login is not available.']], ['administrator', $domain, false, ['login' => 'The specified login is not available.']], ['sales', $domain, false, ['login' => 'The specified login is not available.']], ['root', $domain, false, ['login' => 'The specified login is not available.']], // TODO existing (public domain) user // ['signuplogin', $domain, false, ['login' => 'The specified login is not available.']], // Domain account ['admin', 'kolabsys.com', true, null], ['testnonsystemdomain', 'invalid', true, ['domain' => 'The specified domain is invalid.']], ['testnonsystemdomain', '.com', true, ['domain' => 'The specified domain is invalid.']], // existing custom domain ['jack', 'kolab.org', true, ['domain' => 'The specified domain is not available.']], ]; } /** * Signup login/domain validation. * * Note: Technically these include unit tests, but let's keep it here for now. * FIXME: Shall we do a http request for each case? * * @dataProvider dataValidateLogin */ public function testValidateLogin($login, $domain, $external, $expected_result): void { $result = $this->invokeMethod(new SignupController(), 'validateLogin', [$login, $domain, $external]); $this->assertSame($expected_result, $result); } /** * Signup login/domain validation, more cases * * Note: Technically these include unit tests, but let's keep it here for now. */ public function testValidateLoginMore(): void { $group = $this->getTestGroup('group-test@kolabnow.com'); $login = 'group-test'; $domain = 'kolabnow.com'; $external = false; $result = $this->invokeMethod(new SignupController(), 'validateLogin', [$login, $domain, $external]); $this->assertSame(['login' => 'The specified login is not available.'], $result); } } diff --git a/src/tests/Feature/Jobs/SignupInvitationEmailTest.php b/src/tests/Feature/Jobs/SignupInvitationEmailTest.php new file mode 100644 index 00000000..e91c7a3e --- /dev/null +++ b/src/tests/Feature/Jobs/SignupInvitationEmailTest.php @@ -0,0 +1,68 @@ +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 index 00000000..b32c63ad --- /dev/null +++ b/src/tests/Feature/SignupInvitationTest.php @@ -0,0 +1,118 @@ + '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 index 00000000..c27b4188 --- /dev/null +++ b/src/tests/Unit/Mail/SignupInvitationTest.php @@ -0,0 +1,44 @@ + 'abc', + 'email' => 'test@email', + ]); + + $mail = $this->fakeMail(new SignupInvitation($invitation)); + + $html = $mail['html']; + $plain = $mail['plain']; + + $url = Utils::serviceUrl('/signup/invite/' . $invitation->id); + $link = "$url"; + $appName = \config('app.name'); + + $this->assertMailSubject("$appName Invitation", $mail['message']); + + $this->assertStringStartsWith('', $html); + $this->assertTrue(strpos($html, $link) > 0); + $this->assertTrue(strpos($html, "invited to join $appName") > 0); + + $this->assertStringStartsWith("Hi,", $plain); + $this->assertTrue(strpos($plain, "invited to join $appName") > 0); + $this->assertTrue(strpos($plain, $url) > 0); + } +} diff --git a/src/tests/Unit/SignupInvitationTest.php b/src/tests/Unit/SignupInvitationTest.php new file mode 100644 index 00000000..640a4644 --- /dev/null +++ b/src/tests/Unit/SignupInvitationTest.php @@ -0,0 +1,35 @@ +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 index 00000000..ed3af847 --- /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 index 00000000..e69de29b