diff --git a/src/app/Http/Controllers/API/AuthController.php b/src/app/Http/Controllers/API/AuthController.php --- a/src/app/Http/Controllers/API/AuthController.php +++ b/src/app/Http/Controllers/API/AuthController.php @@ -20,6 +20,11 @@ $user = Auth::guard()->user(); $response = V4\UsersController::userResponse($user); + if (!empty(request()->input('refresh_token'))) { + // @phpstan-ignore-next-line + return $this->respondWithToken(Auth::guard()->refresh(), $response); + } + return response()->json($response); } @@ -34,13 +39,7 @@ // @phpstan-ignore-next-line $token = Auth::guard()->login($user); - return response()->json([ - 'status' => 'success', - 'access_token' => $token, - 'token_type' => 'bearer', - // @phpstan-ignore-next-line - 'expires_in' => Auth::guard()->factory()->getTTL() * 60, - ]); + return self::respondWithToken($token, ['status' => 'success']); } /** @@ -109,19 +108,18 @@ /** * Get the token array structure. * - * @param string $token Respond with this token. + * @param string $token Respond with this token. + * @param array $response Additional response data * * @return \Illuminate\Http\JsonResponse */ - protected function respondWithToken($token) + protected static function respondWithToken($token, array $response = []) { - return response()->json( - [ - 'access_token' => $token, - 'token_type' => 'bearer', - // @phpstan-ignore-next-line - 'expires_in' => Auth::guard()->factory()->getTTL() * 60 - ] - ); + $response['access_token'] = $token; + $response['token_type'] = 'bearer'; + // @phpstan-ignore-next-line + $response['expires_in'] = Auth::guard()->factory()->getTTL() * 60; + + return response()->json($response); } } diff --git a/src/app/Providers/Payment/Mollie.php b/src/app/Providers/Payment/Mollie.php --- a/src/app/Providers/Payment/Mollie.php +++ b/src/app/Providers/Payment/Mollie.php @@ -18,7 +18,11 @@ */ public function customerLink(Wallet $wallet): ?string { - $customer_id = self::mollieCustomerId($wallet); + $customer_id = self::mollieCustomerId($wallet, false); + + if (!$customer_id) { + return null; + } return sprintf( '%s', @@ -43,7 +47,7 @@ public function createMandate(Wallet $wallet, array $payment): ?array { // Register the user in Mollie, if not yet done - $customer_id = self::mollieCustomerId($wallet); + $customer_id = self::mollieCustomerId($wallet, true); $request = [ 'amount' => [ @@ -155,7 +159,7 @@ } // Register the user in Mollie, if not yet done - $customer_id = self::mollieCustomerId($wallet); + $customer_id = self::mollieCustomerId($wallet, true); // Note: Required fields: description, amount/currency, amount/value @@ -211,7 +215,7 @@ return null; } - $customer_id = self::mollieCustomerId($wallet); + $customer_id = self::mollieCustomerId($wallet, true); // Note: Required fields: description, amount/currency, amount/value @@ -353,15 +357,16 @@ * Create one if does not exist yet. * * @param \App\Wallet $wallet The wallet + * @param bool $create Create the customer if does not exist yet * - * @return string Mollie customer identifier + * @return ?string Mollie customer identifier */ - protected static function mollieCustomerId(Wallet $wallet): string + protected static function mollieCustomerId(Wallet $wallet, bool $create = false): ?string { $customer_id = $wallet->getSetting('mollie_id'); // Register the user in Mollie - if (empty($customer_id)) { + if (empty($customer_id) && $create) { $customer = mollie()->customers()->create([ 'name' => $wallet->owner->name(), 'email' => $wallet->id . '@private.' . \config('app.domain'), diff --git a/src/app/Providers/Payment/Stripe.php b/src/app/Providers/Payment/Stripe.php --- a/src/app/Providers/Payment/Stripe.php +++ b/src/app/Providers/Payment/Stripe.php @@ -29,7 +29,11 @@ */ public function customerLink(Wallet $wallet): ?string { - $customer_id = self::stripeCustomerId($wallet); + $customer_id = self::stripeCustomerId($wallet, false); + + if (!$customer_id) { + return null; + } $location = 'https://dashboard.stripe.com'; @@ -62,7 +66,7 @@ public function createMandate(Wallet $wallet, array $payment): ?array { // Register the user in Stripe, if not yet done - $customer_id = self::stripeCustomerId($wallet); + $customer_id = self::stripeCustomerId($wallet, true); $request = [ 'customer' => $customer_id, @@ -173,7 +177,7 @@ } // Register the user in Stripe, if not yet done - $customer_id = self::stripeCustomerId($wallet); + $customer_id = self::stripeCustomerId($wallet, true); $request = [ 'customer' => $customer_id, @@ -371,15 +375,16 @@ * Create one if does not exist yet. * * @param \App\Wallet $wallet The wallet + * @param bool $create Create the customer if does not exist yet * - * @return string Stripe customer identifier + * @return string|null Stripe customer identifier */ - protected static function stripeCustomerId(Wallet $wallet): string + protected static function stripeCustomerId(Wallet $wallet, bool $create = false): ?string { $customer_id = $wallet->getSetting('stripe_id'); // Register the user in Stripe - if (empty($customer_id)) { + if (empty($customer_id) && $create) { $customer = StripeAPI\Customer::create([ 'name' => $wallet->owner->name(), // Stripe will display the email on Checkout page, editable, diff --git a/src/resources/js/app.js b/src/resources/js/app.js --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -69,7 +69,6 @@ let timeout = response.expires_in || 0 // We'll refresh 60 seconds before the token expires - // or immediately when we have no expiration time (on token re-use) if (timeout > 60) { timeout -= 60 } @@ -82,7 +81,6 @@ axios.post('/api/auth/refresh').then(response => { this.loginUser(response.data, false, true) }) - }, timeout * 1000) }, // Set user state to "not logged in" @@ -104,7 +102,7 @@ startLoading() { this.isLoading = true // Lock the UI with the 'loading...' element - let loading = $('#app > .app-loader').show() + let loading = $('#app > .app-loader').removeClass('fadeOut') if (!loading.length) { $('#app').append($(loader)) } @@ -170,7 +168,7 @@ }) }, price(price, currency) { - return (price/100).toLocaleString('de-DE', { style: 'currency', currency: currency || 'CHF' }) + return ((price || 0) / 100).toLocaleString('de-DE', { style: 'currency', currency: currency || 'CHF' }) }, priceLabel(cost, units = 1, discount) { let index = '' diff --git a/src/resources/vue/App.vue b/src/resources/vue/App.vue --- a/src/resources/vue/App.vue +++ b/src/resources/vue/App.vue @@ -17,11 +17,11 @@ this.$root.startLoading() axios.defaults.headers.common.Authorization = 'Bearer ' + token - axios.get('/api/auth/info') + axios.get('/api/auth/info?refresh_token=1') .then(response => { this.isLoading = false this.$root.stopLoading() - this.$root.loginUser({ access_token: token }, false) + this.$root.loginUser(response.data, false) this.$store.state.authInfo = response.data }) .catch(error => { diff --git a/src/resources/vue/User/Info.vue b/src/resources/vue/User/Info.vue --- a/src/resources/vue/User/Info.vue +++ b/src/resources/vue/User/Info.vue @@ -193,10 +193,14 @@ this.discount_description = wallet.discount_description } + this.$root.startLoading() + if (this.user_id === 'new') { // do nothing (for now) axios.get('/api/v4/packages') .then(response => { + this.$root.stopLoading() + this.packages = response.data.filter(pkg => !pkg.isDomain) this.package_id = this.packages[0].id }) @@ -205,6 +209,8 @@ else { axios.get('/api/v4/users/' + this.user_id) .then(response => { + this.$root.stopLoading() + this.user = response.data this.user.first_name = response.data.settings.first_name this.user.last_name = response.data.settings.last_name diff --git a/src/tests/Browser/Admin/DashboardTest.php b/src/tests/Browser/Admin/DashboardTest.php --- a/src/tests/Browser/Admin/DashboardTest.php +++ b/src/tests/Browser/Admin/DashboardTest.php @@ -70,8 +70,10 @@ ->click('@search form button') ->assertMissing('@search table') ->waitForLocation('/user/' . $john->id) - ->waitFor('#user-info') - ->assertSeeIn('#user-info .card-title', $john->email); + ->waitUntilMissing('.app-loader') + ->whenAvailable('#user-info', function (Browser $browser) use ($john) { + $browser->assertSeeIn('.card-title', $john->email); + }); }); } } diff --git a/src/tests/Browser/Admin/UserFinancesTest.php b/src/tests/Browser/Admin/UserFinancesTest.php --- a/src/tests/Browser/Admin/UserFinancesTest.php +++ b/src/tests/Browser/Admin/UserFinancesTest.php @@ -30,6 +30,7 @@ $wallet->discount()->dissociate(); $wallet->balance = 0; $wallet->save(); + $wallet->setSettings(['mollie_id' => null, 'stripe_id' => null]); } /** @@ -40,7 +41,9 @@ // Assert Jack's Finances tab $this->browse(function (Browser $browser) { $jack = $this->getTestUser('jack@kolab.org'); - $jack->wallets()->first()->transactions()->delete(); + $wallet = $jack->wallets()->first(); + $wallet->transactions()->delete(); + $wallet->setSetting('stripe_id', 'abc'); $page = new UserPage($jack->id); $browser->visit(new Home()) @@ -54,12 +57,11 @@ ->assertSeeIn('.card-title:first-child', 'Account balance') ->assertSeeIn('.card-title:first-child .text-success', '0,00 CHF') ->with('form', function (Browser $browser) { - $payment_provider = ucfirst(\config('services.payment_provider')); $browser->assertElementsCount('.row', 2) ->assertSeeIn('.row:nth-child(1) label', 'Discount') ->assertSeeIn('.row:nth-child(1) #discount span', 'none') - ->assertSeeIn('.row:nth-child(2) label', $payment_provider . ' ID') - ->assertVisible('.row:nth-child(2) a'); + ->assertSeeIn('.row:nth-child(2) label', 'Stripe ID') + ->assertSeeIn('.row:nth-child(2) a', 'abc'); }) ->assertSeeIn('h2:nth-of-type(2)', 'Transactions') ->with('table', function (Browser $browser) { @@ -101,7 +103,7 @@ ->assertSeeIn('.card-title:first-child', 'Account balance') ->assertSeeIn('.card-title:first-child .text-danger', '-20,10 CHF') ->with('form', function (Browser $browser) { - $browser->assertElementsCount('.row', 2) + $browser->assertElementsCount('.row', 1) ->assertSeeIn('.row:nth-child(1) label', 'Discount') ->assertSeeIn('.row:nth-child(1) #discount span', '10% - Test voucher'); }) @@ -127,7 +129,7 @@ ->assertSeeIn('.card-title:first-child', 'Account balance') ->assertSeeIn('.card-title:first-child .text-success', '0,00 CHF') ->with('form', function (Browser $browser) { - $browser->assertElementsCount('.row', 2) + $browser->assertElementsCount('.row', 1) ->assertSeeIn('.row:nth-child(1) label', 'Discount') ->assertSeeIn('.row:nth-child(1) #discount span', 'none'); }) diff --git a/src/tests/Browser/Admin/UserTest.php b/src/tests/Browser/Admin/UserTest.php --- a/src/tests/Browser/Admin/UserTest.php +++ b/src/tests/Browser/Admin/UserTest.php @@ -33,6 +33,7 @@ } $wallet = $john->wallets()->first(); $wallet->discount()->dissociate(); + $wallet->save(); } /** @@ -50,6 +51,7 @@ } $wallet = $john->wallets()->first(); $wallet->discount()->dissociate(); + $wallet->save(); parent::tearDown(); } diff --git a/src/tests/Browser/DomainTest.php b/src/tests/Browser/DomainTest.php --- a/src/tests/Browser/DomainTest.php +++ b/src/tests/Browser/DomainTest.php @@ -118,6 +118,7 @@ ->click('@links a.link-domains') // On Domains List page click the domain entry ->on(new DomainList()) + ->waitFor('@table tbody tr') ->assertVisible('@table tbody tr:first-child td:first-child svg.fa-globe.text-success') ->assertText('@table tbody tr:first-child td:first-child svg title', 'Active') ->assertSeeIn('@table tbody tr:first-child td:first-child', 'kolab.org') diff --git a/src/tests/Browser/Pages/PaymentStripe.php b/src/tests/Browser/Pages/PaymentStripe.php --- a/src/tests/Browser/Pages/PaymentStripe.php +++ b/src/tests/Browser/Pages/PaymentStripe.php @@ -37,8 +37,8 @@ { return [ '@form' => '.App-Payment > form', - '@title' => '.App-Overview .ProductSummary-Info .Text', - '@amount' => '#ProductSummary-TotalAmount', + '@title' => '.App-Overview .ProductSummary', + '@amount' => '#ProductSummary-totalAmount', '@description' => '#ProductSummary-Description', '@email-input' => '.App-Payment #email', '@cardnumber-input' => '.App-Payment #cardNumber', diff --git a/src/tests/Browser/Pages/UserInfo.php b/src/tests/Browser/Pages/UserInfo.php --- a/src/tests/Browser/Pages/UserInfo.php +++ b/src/tests/Browser/Pages/UserInfo.php @@ -25,7 +25,8 @@ */ public function assert($browser) { - $browser->waitFor('@form'); + $browser->waitFor('@form') + ->waitUntilMissing('.app-loader'); } /** diff --git a/src/tests/Browser/PasswordResetTest.php b/src/tests/Browser/PasswordResetTest.php --- a/src/tests/Browser/PasswordResetTest.php +++ b/src/tests/Browser/PasswordResetTest.php @@ -146,9 +146,10 @@ $browser->waitFor('.toast-error'); - $step->assertVisible('#reset_short_code.is-invalid'); - $step->assertVisible('#reset_short_code + .invalid-feedback'); - $step->assertFocused('#reset_short_code'); + $step->waitFor('#reset_short_code.is-invalid') + ->assertVisible('#reset_short_code.is-invalid') + ->assertVisible('#reset_short_code + .invalid-feedback') + ->assertFocused('#reset_short_code'); $browser->click('.toast-error'); // remove the toast }); @@ -249,9 +250,10 @@ $browser->waitFor('.toast-error'); - $step->assertVisible('#reset_password.is-invalid'); - $step->assertVisible('#reset_password + .invalid-feedback'); - $step->assertFocused('#reset_password'); + $step->waitFor('#reset_password.is-invalid') + ->assertVisible('#reset_password.is-invalid') + ->assertVisible('#reset_password + .invalid-feedback') + ->assertFocused('#reset_password'); $browser->click('.toast-error'); // remove the toast }); 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 @@ -482,6 +482,7 @@ $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) { diff --git a/src/tests/Browser/StatusTest.php b/src/tests/Browser/StatusTest.php --- a/src/tests/Browser/StatusTest.php +++ b/src/tests/Browser/StatusTest.php @@ -158,6 +158,7 @@ $browser->on(new Dashboard()) ->click('@links a.link-domains') ->on(new DomainList()) + ->waitFor('@table tbody tr') // Assert domain status icon ->assertVisible('@table tbody tr:first-child td:first-child svg.fa-globe.text-danger') ->assertText('@table tbody tr:first-child td:first-child svg title', 'Not Ready') @@ -222,6 +223,7 @@ $browser->visit(new Dashboard()) ->click('@links a.link-users') ->on(new UserList()) + ->waitFor('@table tbody tr') // Assert user status icons ->assertVisible('@table tbody tr:first-child td:first-child svg.fa-user.text-success') ->assertText('@table tbody tr:first-child td:first-child svg title', 'Active') diff --git a/src/tests/Browser/UsersTest.php b/src/tests/Browser/UsersTest.php --- a/src/tests/Browser/UsersTest.php +++ b/src/tests/Browser/UsersTest.php @@ -107,7 +107,8 @@ ->click('@links .link-users') ->on(new UserList()) ->whenAvailable('@table', function (Browser $browser) { - $browser->assertElementsCount('tbody tr', 4) + $browser->waitFor('tbody tr') + ->assertElementsCount('tbody tr', 4) ->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org') ->assertSeeIn('tbody tr:nth-child(2) a', 'joe@kolab.org') ->assertSeeIn('tbody tr:nth-child(3) a', 'john@kolab.org') @@ -516,6 +517,7 @@ ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->visit(new UserList()) + ->waitFor('@table tr:nth-child(2)') ->click('@table tr:nth-child(2) a') ->on(new UserInfo()) ->with('@form', function (Browser $browser) { diff --git a/src/tests/Feature/Controller/Admin/WalletsTest.php b/src/tests/Feature/Controller/Admin/WalletsTest.php --- a/src/tests/Feature/Controller/Admin/WalletsTest.php +++ b/src/tests/Feature/Controller/Admin/WalletsTest.php @@ -63,7 +63,7 @@ $this->assertTrue(empty($json['description'])); $this->assertTrue(empty($json['discount_description'])); $this->assertTrue(!empty($json['provider'])); - $this->assertTrue(!empty($json['providerLink'])); + $this->assertTrue(empty($json['providerLink'])); $this->assertTrue(!empty($json['mandate'])); } diff --git a/src/tests/Feature/Controller/AuthTest.php b/src/tests/Feature/Controller/AuthTest.php --- a/src/tests/Feature/Controller/AuthTest.php +++ b/src/tests/Feature/Controller/AuthTest.php @@ -52,8 +52,27 @@ $this->assertTrue(is_array($json['statusInfo'])); $this->assertTrue(is_array($json['settings'])); $this->assertTrue(is_array($json['aliases'])); + $this->assertTrue(!isset($json['access_token'])); // Note: Details of the content are tested in testUserResponse() + + // Test token refresh via the info request + // First we log in as we need the token (actingAs() will not work) + $post = ['email' => 'john@kolab.org', 'password' => 'simple123']; + $response = $this->post("api/auth/login", $post); + $json = $response->json(); + $response = $this->withHeaders(['Authorization' => 'Bearer ' . $json['access_token']]) + ->get("api/auth/info?refresh_token=1"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertEquals('john@kolab.org', $json['email']); + $this->assertTrue(is_array($json['statusInfo'])); + $this->assertTrue(is_array($json['settings'])); + $this->assertTrue(is_array($json['aliases'])); + $this->assertTrue(!empty($json['access_token'])); + $this->assertTrue(!empty($json['expires_in'])); } /**