diff --git a/src/app/Http/Controllers/API/SignupController.php b/src/app/Http/Controllers/API/SignupController.php --- a/src/app/Http/Controllers/API/SignupController.php +++ b/src/app/Http/Controllers/API/SignupController.php @@ -4,11 +4,11 @@ use App\Http\Controllers\Controller; use App\Jobs\SignupVerificationEmail; -use App\Jobs\SignupVerificationSMS; use App\Discount; use App\Domain; use App\Plan; use App\Rules\SignupExternalEmail; +use App\Rules\SignupToken; use App\Rules\Password; use App\Rules\UserEmailDomain; use App\Rules\UserEmailLocal; @@ -39,11 +39,18 @@ // Use reverse order just to have individual on left, group on right ;) Plan::withEnvTenantContext()->orderByDesc('title')->get() ->map(function ($plan) use (&$plans) { + // Allow themes to set custom button label + $button = \trans('theme::app.planbutton-' . $plan->title); + if ($button == 'theme::app.planbutton-' . $plan->title) { + $button = \trans('app.planbutton', ['plan' => $plan->name]); + } + $plans[] = [ 'title' => $plan->title, 'name' => $plan->name, - 'button' => \trans('app.planbutton', ['plan' => $plan->name]), + 'button' => $button, 'description' => $plan->description, + 'mode' => $plan->mode ?: 'email', ]; }); @@ -62,49 +69,55 @@ */ 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', - ] - ); + $rules = [ + 'first_name' => 'max:128', + 'last_name' => 'max:128', + 'voucher' => 'max:32', + ]; - $is_phone = false; - $errors = $v->fails() ? $v->errors()->toArray() : []; + $plan = $this->getPlan(); - // Validate user email (or phone) - if (empty($errors['email'])) { - if ($error = $this->validatePhoneOrEmail($request->email, $is_phone)) { - $errors['email'] = $error; - } + if ($plan->mode == 'token') { + $rules['token'] = ['required', 'string', new SignupToken()]; + } else { + $rules['email'] = ['required', 'string', new SignupExternalEmail()]; } - if (!empty($errors)) { - return response()->json(['status' => 'error', 'errors' => $errors], 422); + // Check required fields, validate input + $v = Validator::make($request->all(), $rules); + + if ($v->fails()) { + return response()->json(['status' => 'error', 'errors' => $v->errors()->toArray()], 422); } // Generate the verification code $code = SignupCode::create([ - 'email' => $request->email, + 'email' => $plan->mode == 'token' ? $request->token : $request->email, 'first_name' => $request->first_name, 'last_name' => $request->last_name, - 'plan' => $request->plan, + 'plan' => $plan->title, 'voucher' => $request->voucher, ]); - // Send email/sms message - if ($is_phone) { - SignupVerificationSMS::dispatch($code); + $response = [ + 'status' => 'success', + 'code' => $code->code, + 'mode' => $plan->mode ?: 'email', + ]; + + if ($plan->mode == 'token') { + // Token verification, jump to the last step + $has_domain = $plan->hasDomain(); + + $response['short_code'] = $code->short_code; + $response['is_domain'] = $has_domain; + $response['domains'] = $has_domain ? [] : Domain::getPublicDomains(); } else { + // External email verification, send an email message SignupVerificationEmail::dispatch($code); } - return response()->json(['status' => 'success', 'code' => $code->code]); + return response()->json($response); } /** @@ -251,10 +264,15 @@ $code_data = $v->getData(); $settings = [ - 'external_email' => $code_data->email, 'first_name' => $code_data->first_name, 'last_name' => $code_data->last_name, ]; + + if ($this->getPlan()->mode == 'token') { + $settings['signup_token'] = $code_data->email; + } else { + $settings['external_email'] = $code_data->email; + } } // Find the voucher discount @@ -343,6 +361,8 @@ // Get the plan if specified and exists... if ($request->code && $request->code->plan) { $plan = Plan::withEnvTenantContext()->where('title', $request->code->plan)->first(); + } elseif ($request->plan) { + $plan = Plan::withEnvTenantContext()->where('title', $request->plan)->first(); } // ...otherwise use the default plan @@ -357,40 +377,6 @@ return $request->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 SignupExternalEmail()]] - ); - - 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 * diff --git a/src/app/Http/Controllers/ContentController.php b/src/app/Http/Controllers/ContentController.php --- a/src/app/Http/Controllers/ContentController.php +++ b/src/app/Http/Controllers/ContentController.php @@ -27,8 +27,6 @@ abort(404); } - self::loadLocale($theme); - return view($view)->with('env', \App\Utils::uiEnv()); } @@ -63,8 +61,6 @@ // Localization if (!empty($faq)) { - self::loadLocale($theme_name); - foreach ($faq as $idx => $item) { if (!empty($item['label'])) { $faq[$idx]['title'] = \trans('theme::faq.' . $item['label']); @@ -158,16 +154,4 @@ return $menu; } - - /** - * Register localization files from the theme. - * - * @param string $theme Theme name - */ - protected static function loadLocale(string $theme): void - { - $path = resource_path(sprintf('themes/%s/lang', $theme)); - - \app('translator')->addNamespace('theme', $path); - } } diff --git a/src/app/Http/Middleware/Locale.php b/src/app/Http/Middleware/Locale.php --- a/src/app/Http/Middleware/Locale.php +++ b/src/app/Http/Middleware/Locale.php @@ -64,6 +64,10 @@ app()->setLocale($lang); } + // Allow skins to define/overwrite some localization + $theme = \config('app.theme'); + \app('translator')->addNamespace('theme', \resource_path("themes/{$theme}/lang")); + return $next($request); } } diff --git a/src/app/Observers/SignupCodeObserver.php b/src/app/Observers/SignupCodeObserver.php --- a/src/app/Observers/SignupCodeObserver.php +++ b/src/app/Observers/SignupCodeObserver.php @@ -47,7 +47,7 @@ $code->expires_at = Carbon::now()->addHours($exp_hours); $code->ip_address = request()->ip(); - if ($code->email) { + if ($code->email && strpos($code->email, '@')) { $parts = explode('@', $code->email); $code->local_part = $parts[0]; diff --git a/src/app/Plan.php b/src/app/Plan.php --- a/src/app/Plan.php +++ b/src/app/Plan.php @@ -19,6 +19,7 @@ * @property int $discount_qty * @property int $discount_rate * @property string $id + * @property string $mode Plan signup mode (email|token) * @property string $name * @property \App\Package[] $packages * @property datetime $promo_from @@ -37,6 +38,7 @@ /** @var array The attributes that are mass assignable */ protected $fillable = [ 'title', + 'mode', 'name', 'description', // a start and end datetime for this promotion diff --git a/src/app/Rules/SignupToken.php b/src/app/Rules/SignupToken.php new file mode 100644 --- /dev/null +++ b/src/app/Rules/SignupToken.php @@ -0,0 +1,69 @@ + 191) { + $this->message = \trans('validation.signuptokeninvalid'); + return false; + } + + // Check the list of tokens for token existence + $file = storage_path('signup-tokens.txt'); + $list = []; + $token = \strtoupper($token); + + if (file_exists($file)) { + $list = file($file); + $list = array_map('trim', $list); + $list = array_map('strtoupper', $list); + } else { + \Log::error("Signup tokens file ({$file}) does not exist"); + } + + if (!in_array($token, $list)) { + $this->message = \trans('validation.signuptokeninvalid'); + return false; + } + + // Check if the token has been already used for registration (exclude deleted users) + $used = \App\User::select() + ->join('user_settings', 'users.id', '=', 'user_settings.user_id') + ->where('user_settings.key', 'signup_token') + ->where('user_settings.value', $token) + ->exists(); + + if ($used) { + $this->message = \trans('validation.signuptokeninvalid'); + return false; + } + + return true; + } + + /** + * Get the validation error message. + * + * @return string + */ + public function message(): ?string + { + return $this->message; + } +} diff --git a/src/database/migrations/2022_09_01_100000_plans_mode.php b/src/database/migrations/2022_09_01_100000_plans_mode.php new file mode 100644 --- /dev/null +++ b/src/database/migrations/2022_09_01_100000_plans_mode.php @@ -0,0 +1,38 @@ +string('mode', 32)->default('email'); + } + ); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table( + 'plans', + function (Blueprint $table) { + $table->dropColumn('mode'); + } + ); + } +}; diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php --- a/src/resources/lang/en/ui.php +++ b/src/resources/lang/en/ui.php @@ -380,6 +380,7 @@ 'step1' => "Sign up to start your free month.", 'step2' => "We sent out a confirmation code to your email address. Enter the code we sent you, or click the link in the message.", 'step3' => "Create your Kolab identity (you can choose additional addresses later).", + 'token' => "Signup authorization token", 'voucher' => "Voucher Code", ], diff --git a/src/resources/lang/en/validation.php b/src/resources/lang/en/validation.php --- a/src/resources/lang/en/validation.php +++ b/src/resources/lang/en/validation.php @@ -170,6 +170,7 @@ 'password-policy-min-len-error' => 'Minimum password length cannot be less than :min.', 'password-policy-max-len-error' => 'Maximum password length cannot be more than :max.', 'password-policy-last-error' => 'The minimum value for last N passwords is :last.', + 'signuptokeninvalid' => 'The signup token is invalid.', /* |-------------------------------------------------------------------------- diff --git a/src/resources/themes/app.scss b/src/resources/themes/app.scss --- a/src/resources/themes/app.scss +++ b/src/resources/themes/app.scss @@ -229,8 +229,8 @@ .plan-ico { margin:auto; font-size: 3.8rem; - color: #f1a539; - border: 3px solid #f1a539; + color: $main-color; + border: 3px solid $main-color; width: 6rem; height: 6rem; border-radius: 50%; diff --git a/src/resources/vue/Signup.vue b/src/resources/vue/Signup.vue --- a/src/resources/vue/Signup.vue +++ b/src/resources/vue/Signup.vue @@ -20,7 +20,7 @@
-

{{ $t('signup.title') }} - {{ $t('nav.step', { i: 1, n: 3 }) }}

+

{{ $t('signup.title') }} - {{ $t('nav.step', { i: 1, n: steps }) }}

{{ $t('signup.step1') }}

@@ -31,7 +31,11 @@
-
+
+ + +
+
@@ -43,7 +47,7 @@
-

{{ $t('signup.title') }} - {{ $t('nav.step', { i: 2, n: 3 }) }}

+

{{ $t('signup.title') }} - {{ $t('nav.step', { i: 2, n: steps }) }}

{{ $t('signup.step2') }}

@@ -61,7 +65,7 @@
-

{{ $t('signup.title') }} - {{ $t('nav.step', { i: 3, n: 3 }) }}

+

{{ $t('signup.title') }} - {{ $t('nav.step', { i: steps, n: steps }) }}

{{ $t('signup.step3') }}

@@ -105,7 +109,8 @@ import { library } from '@fortawesome/fontawesome-svg-core' library.add( - require('@fortawesome/free-solid-svg-icons/faUsers').definition, + require('@fortawesome/free-solid-svg-icons/faMobileRetro').definition, + require('@fortawesome/free-solid-svg-icons/faUsers').definition ) export default { @@ -125,15 +130,23 @@ domains: [], invitation: null, is_domain: false, + mode: 'email', plan: null, plan_icons: { individual: 'user', - group: 'users' + group: 'users', + phone: 'mobile-retro' }, plans: [], + token: '', voucher: '' } }, + computed: { + steps() { + return this.mode == 'token' ? 2 : 3 + } + }, mounted() { let param = this.$route.params.param; @@ -165,8 +178,7 @@ this.submitStep2(true) } else if (/^([a-zA-Z_]+)$/.test(param)) { // Plan title provided, save it and display Step 1 - this.plan = param - this.displayForm(1, true) + this.step0(param) } else { this.$root.errorPage(404) } @@ -177,30 +189,48 @@ methods: { selectPlan(plan) { this.$router.push({path: '/signup/' + plan}) - this.plan = plan - this.displayForm(1, true) + this.selectPlanByTitle(plan) }, // Composes plan selection page - step0() { + selectPlanByTitle(title) { + const plan = this.plans.filter(plan => plan.title == title)[0] + if (plan) { + this.plan = title + this.mode = plan.mode + this.displayForm(1, true) + } + }, + step0(plan) { if (!this.plans.length) { axios.get('/api/auth/signup/plans', { loader: true }).then(response => { this.plans = response.data.plans + this.selectPlanByTitle(plan) }) .catch(error => { this.$root.errorHandler(error) }) + } else { + this.selectPlanByTitle(plan) } }, // Submits data to the API, validates and gets verification code submitStep1() { this.$root.clearFormValidation($('#step1 form')) - const post = this.$root.pick(this, ['email', 'last_name', 'first_name', 'plan', 'voucher']) + const post = this.$root.pick(this, ['email', 'last_name', 'first_name', 'plan', 'token', 'voucher']) axios.post('/api/auth/signup/init', post) .then(response => { - this.displayForm(2, true) this.code = response.data.code + this.short_code = response.data.short_code + this.mode = response.data.mode + this.is_domain = response.data.is_domain + this.displayForm(this.mode == 'token' ? 3 : 2, true) + + // Fill the domain selector with available domains + if (!this.is_domain) { + this.setDomain(response.data) + } }) }, // Submits the code to the API for verification @@ -222,6 +252,7 @@ this.email = response.data.email this.is_domain = response.data.is_domain this.voucher = response.data.voucher + this.domain = '' // Fill the domain selector with available domains if (!this.is_domain) { @@ -262,12 +293,20 @@ }, // Moves the user a step back in registration form stepBack(e) { - var card = $(e.target).closest('.card') + const card = $(e.target).closest('.card') + let step = card.attr('id').replace('step', '') - card.prev().removeClass('d-none').find('input').first().focus() card.addClass('d-none').find('form')[0].reset() - if (card.attr('id') == 'step1') { + step -= 1 + + if (step == 2 && this.mode == 'token') { + step = 1 + } + + $('#step' + step).removeClass('d-none').find('input').first().focus() + + if (!step) { this.step0() this.$router.replace({path: '/signup'}) } @@ -281,7 +320,7 @@ return this.step0() } - $('#step' + step).removeClass('d-none') + $('#step' + step).removeClass('d-none').find('form')[0].reset() if (focus) { $('#step' + step).find('input').first().focus() diff --git a/src/tests/Browser/SignupTest.php b/src/tests/Browser/SignupTest.php --- a/src/tests/Browser/SignupTest.php +++ b/src/tests/Browser/SignupTest.php @@ -4,6 +4,7 @@ use App\Discount; use App\Domain; +use App\Plan; use App\SignupCode; use App\SignupInvitation; use App\User; @@ -27,6 +28,8 @@ $this->deleteTestUser('signuptestdusk@' . \config('app.domain')); $this->deleteTestUser('admin@user-domain-signup.com'); $this->deleteTestDomain('user-domain-signup.com'); + + Plan::where('mode', 'token')->update(['mode' => 'email']); } /** @@ -39,6 +42,10 @@ $this->deleteTestDomain('user-domain-signup.com'); SignupInvitation::truncate(); + Plan::where('mode', 'token')->update(['mode' => 'email']); + + @unlink(storage_path('signup-tokens.txt')); + parent::tearDown(); } @@ -125,6 +132,7 @@ $browser->waitForLocation('/signup/individual') ->assertVisible('@step1') + ->assertSeeIn('.card-title', 'Sign Up - Step 1/3') ->assertMissing('@step0') ->assertMissing('@step2') ->assertMissing('@step3') @@ -162,7 +170,8 @@ // Here we expect two text inputs and Back and Continue buttons $browser->with('@step1', function ($step) { - $step->assertVisible('#signup_last_name') + $step->waitFor('#signup_last_name') + ->assertSeeIn('.card-title', 'Sign Up - Step 1/3') ->assertVisible('#signup_first_name') ->assertFocused('#signup_first_name') ->assertVisible('#signup_email') @@ -221,6 +230,7 @@ { $this->browse(function (Browser $browser) { $browser->assertVisible('@step2') + ->assertSeeIn('@step2 .card-title', 'Sign Up - Step 2/3') ->assertMissing('@step0') ->assertMissing('@step1') ->assertMissing('@step3'); @@ -508,6 +518,109 @@ }); } + /** + * Test signup with a token plan + */ + public function testSignupToken(): void + { + // Test the individual plan + Plan::where('title', 'individual')->update(['mode' => 'token']); + + // Register some valid tokens + $tokens = ['1234567890', 'abcdefghijk']; + file_put_contents(storage_path('signup-tokens.txt'), implode("\n", $tokens)); + + $this->browse(function (Browser $browser) use ($tokens) { + $browser->visit(new Signup()) + ->waitFor('@step0 .plan-individual button') + ->click('@step0 .plan-individual button') + // Step 1 + ->whenAvailable('@step1', function ($browser) use ($tokens) { + $browser->assertSeeIn('.card-title', 'Sign Up - Step 1/2') + ->type('#signup_first_name', 'Test') + ->type('#signup_last_name', 'User') + ->assertMissing('#signup_email') + ->type('#signup_token', '1234') + // invalid token + ->click('[type=submit]') + ->waitFor('#signup_token.is-invalid') + ->assertVisible('#signup_token + .invalid-feedback') + ->assertFocused('#signup_token') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + // valid token + ->type('#signup_token', $tokens[0]) + ->click('[type=submit]'); + }) + // Step 2 + ->whenAvailable('@step3', function ($browser) { + $domains = Domain::getPublicDomains(); + $domains_count = count($domains); + + $browser->assertSeeIn('.card-title', 'Sign Up - Step 2/2') + ->assertElementsCount('select#signup_domain option', $domains_count, false) + ->assertText('select#signup_domain option:nth-child(1)', $domains[0]) + ->assertValue('select#signup_domain option:nth-child(1)', $domains[0]) + ->type('#signup_login', 'signuptestdusk') + ->type('#signup_password', '12345678') + ->type('#signup_password_confirmation', '12345678') + ->click('[type=submit]'); + }) + ->waitUntilMissing('@step3') + ->on(new Dashboard()) + ->within(new Menu(), function ($browser) { + $browser->clickMenuItem('logout'); + }); + }); + + $user = User::where('email', 'signuptestdusk@' . \config('app.domain'))->first(); + $this->assertSame($tokens[0], $user->getSetting('signup_token')); + $this->assertSame(null, $user->getSetting('external_email')); + + // Test the group plan + Plan::where('title', 'group')->update(['mode' => 'token']); + + $this->browse(function (Browser $browser) use ($tokens) { + $browser->visit(new Signup()) + ->waitFor('@step0 .plan-group button') + ->click('@step0 .plan-group button') + // Step 1 + ->whenAvailable('@step1', function ($browser) use ($tokens) { + $browser->assertSeeIn('.card-title', 'Sign Up - Step 1/2') + ->type('#signup_first_name', 'Test') + ->type('#signup_last_name', 'User') + ->assertMissing('#signup_email') + ->type('#signup_token', '1234') + // invalid token + ->click('[type=submit]') + ->waitFor('#signup_token.is-invalid') + ->assertVisible('#signup_token + .invalid-feedback') + ->assertFocused('#signup_token') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + // valid token + ->type('#signup_token', $tokens[1]) + ->click('[type=submit]'); + }) + // Step 2 + ->whenAvailable('@step3', function ($browser) { + $browser->assertSeeIn('.card-title', 'Sign Up - Step 2/2') + ->type('input#signup_domain', 'user-domain-signup.com') + ->type('#signup_login', 'admin') + ->type('#signup_password', '12345678') + ->type('#signup_password_confirmation', '12345678') + ->click('[type=submit]'); + }) + ->waitUntilMissing('@step3') + ->on(new Dashboard()) + ->within(new Menu(), function ($browser) { + $browser->clickMenuItem('logout'); + }); + }); + + $user = User::where('email', 'admin@user-domain-signup.com')->first(); + $this->assertSame($tokens[1], $user->getSetting('signup_token')); + $this->assertSame(null, $user->getSetting('external_email')); + } + /** * Test signup with voucher */ diff --git a/src/tests/Feature/Controller/SignupTest.php b/src/tests/Feature/Controller/SignupTest.php --- a/src/tests/Feature/Controller/SignupTest.php +++ b/src/tests/Feature/Controller/SignupTest.php @@ -204,7 +204,7 @@ $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); - $this->assertSame("The specified email address is invalid.", $json['errors']['email']); + $this->assertSame(["The specified email address is invalid."], $json['errors']['email']); SignupCode::truncate(); @@ -231,7 +231,7 @@ $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); // TODO: This probably should be a different message? - $this->assertSame("The specified email address is invalid.", $json['errors']['email']); + $this->assertSame(["The specified email address is invalid."], $json['errors']['email']); // IP address limit check $data = [ @@ -258,7 +258,7 @@ $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); // TODO: This probably should be a different message? - $this->assertSame("The specified email address is invalid.", $json['errors']['email']); + $this->assertSame(["The specified email address is invalid."], $json['errors']['email']); // TODO: Test phone validation } @@ -284,8 +284,10 @@ $json = $response->json(); $response->assertStatus(200); - $this->assertCount(2, $json); + + $this->assertCount(3, $json); $this->assertSame('success', $json['status']); + $this->assertSame('email', $json['mode']); $this->assertNotEmpty($json['code']); // Assert the email sending job was pushed once @@ -309,8 +311,9 @@ $json = $response->json(); $response->assertStatus(200); - $this->assertCount(2, $json); + $this->assertCount(3, $json); $this->assertSame('success', $json['status']); + $this->assertSame('email', $json['mode']); $this->assertNotEmpty($json['code']); // Assert the job has proper data assigned @@ -629,8 +632,9 @@ $json = $response->json(); $response->assertStatus(200); - $this->assertCount(2, $json); + $this->assertCount(3, $json); $this->assertSame('success', $json['status']); + $this->assertSame('email', $json['mode']); $this->assertNotEmpty($json['code']); // Assert the email sending job was pushed once diff --git a/src/tests/Feature/PlanTest.php b/src/tests/Feature/PlanTest.php --- a/src/tests/Feature/PlanTest.php +++ b/src/tests/Feature/PlanTest.php @@ -107,6 +107,9 @@ $this->assertTrue($plan->cost() == $package_costs); } + /** + * Tests for Plan::tenant() + */ public function testTenant(): void { $plan = Plan::withEnvTenantContext()->where('title', 'individual')->first(); diff --git a/src/tests/Unit/Rules/SignupTokenTest.php b/src/tests/Unit/Rules/SignupTokenTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Unit/Rules/SignupTokenTest.php @@ -0,0 +1,60 @@ +getTestUser('john@kolab.org'); + $john->settings()->where('key', 'signup_token')->delete(); + + parent::tearDown(); + } + + /** + * Tests the resource name validator + */ + public function testValidation(): void + { + $tokens = ['1234567890', 'abcdefghijk']; + file_put_contents(storage_path('signup-tokens.txt'), implode("\n", $tokens)); + + $rules = ['token' => [new SignupToken()]]; + + // Empty input + $v = Validator::make(['token' => null], $rules); + $this->assertSame(['token' => ["The signup token is invalid."]], $v->errors()->toArray()); + + // Length limit + $v = Validator::make(['token' => str_repeat('a', 192)], $rules); + $this->assertSame(['token' => ["The signup token is invalid."]], $v->errors()->toArray()); + + // Non-existing token + $v = Validator::make(['token' => '123'], $rules); + $this->assertSame(['token' => ["The signup token is invalid."]], $v->errors()->toArray()); + + // Valid tokens + $v = Validator::make(['token' => $tokens[0]], $rules); + $this->assertSame([], $v->errors()->toArray()); + + $v = Validator::make(['token' => strtoupper($tokens[1])], $rules); + $this->assertSame([], $v->errors()->toArray()); + + // Tokens already used + $john = $this->getTestUser('john@kolab.org'); + $john->setSetting('signup_token', $tokens[0]); + + $v = Validator::make(['token' => $tokens[0]], $rules); + $this->assertSame(['token' => ["The signup token is invalid."]], $v->errors()->toArray()); + } +}