diff --git a/src/app/Http/Controllers/API/UsersController.php b/src/app/Http/Controllers/API/UsersController.php
index 09cdbdbd..fff51ec8 100644
--- a/src/app/Http/Controllers/API/UsersController.php
+++ b/src/app/Http/Controllers/API/UsersController.php
@@ -1,247 +1,250 @@
middleware('auth:api', ['except' => ['login']]);
}
/**
* Helper method for other controllers with user auto-logon
* functionality
*
* @param \App\User $user User model object
*/
public static function logonResponse(User $user)
{
$token = auth()->login($user);
return response()->json([
'status' => 'success',
'access_token' => $token,
'token_type' => 'bearer',
'expires_in' => Auth::guard()->factory()->getTTL() * 60,
]);
}
/**
* Display a listing of the resources.
*
* The user themself, and other user entitlements.
*
* @return \Illuminate\Http\Response
*/
public function index()
{
$user = Auth::user();
if (!$user) {
return response()->json(['error' => 'unauthorized'], 401);
}
$result = [$user];
$user->entitlements()->each(
function ($entitlement) {
$result[] = User::find($entitlement->user_id);
}
);
return response()->json($result);
}
/**
* Get the authenticated User
*
* @return \Illuminate\Http\JsonResponse
*/
public function info()
{
$user = $this->guard()->user();
$response = $user->toArray();
$response['statusInfo'] = self::statusInfo($user);
return response()->json($response);
}
/**
* Get a JWT token via given credentials.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse
*/
public function login(Request $request)
{
$credentials = $request->only('email', 'password');
if ($token = $this->guard()->attempt($credentials)) {
return $this->respondWithToken($token);
}
return response()->json(['error' => 'Unauthorized'], 401);
}
/**
* Log the user out (Invalidate the token)
*
* @return \Illuminate\Http\JsonResponse
*/
public function logout()
{
$this->guard()->logout();
- return response()->json(['message' => 'Successfully logged out']);
+ return response()->json([
+ 'status' => 'success',
+ 'message' => __('auth.logoutsuccess')
+ ]);
}
/**
* Refresh a token.
*
* @return \Illuminate\Http\JsonResponse
*/
public function refresh()
{
return $this->respondWithToken($this->guard()->refresh());
}
/**
* Get the token array structure.
*
* @param string $token Respond with this token.
*
* @return \Illuminate\Http\JsonResponse
*/
protected function respondWithToken($token)
{
return response()->json(
[
'access_token' => $token,
'token_type' => 'bearer',
'expires_in' => $this->guard()->factory()->getTTL() * 60
]
);
}
/**
* Display the specified resource.
*
* @param int $id The account to show information for.
*
* @return \Illuminate\Http\Response
*/
public function show($id)
{
$user = Auth::user();
if (!$user) {
return abort(403);
}
$result = false;
$user->entitlements()->each(
function ($entitlement) {
if ($entitlement->user_id == $id) {
$result = true;
}
}
);
if ($user->id == $id) {
$result = true;
}
if (!$result) {
return abort(404);
}
return \App\User::find($id);
}
/**
* User status (extended) information
*
* @param \App\User $user User object
*
* @return array Status information
*/
public static function statusInfo(User $user): array
{
$status = 'new';
$process = [];
$steps = [
'user-new' => true,
'user-ldap-ready' => 'isLdapReady',
'user-imap-ready' => 'isImapReady',
];
if ($user->isDeleted()) {
$status = 'deleted';
} elseif ($user->isSuspended()) {
$status = 'suspended';
} elseif ($user->isActive()) {
$status = 'active';
}
list ($local, $domain) = explode('@', $user->email);
$domain = Domain::where('namespace', $domain)->first();
// If that is not a public domain, add domain specific steps
if (!$domain->isPublic()) {
$steps['domain-new'] = true;
$steps['domain-ldap-ready'] = 'isLdapReady';
// $steps['domain-verified'] = 'isVerified';
$steps['domain-confirmed'] = 'isConfirmed';
}
// Create a process check list
foreach ($steps as $step_name => $func) {
$object = strpos($step_name, 'user-') === 0 ? $user : $domain;
$step = [
'label' => $step_name,
'title' => __("app.process-{$step_name}"),
'state' => is_bool($func) ? $func : $object->{$func}(),
];
if ($step_name == 'domain-confirmed' && !$step['state']) {
$step['link'] = "/domain/{$domain->id}";
}
$process[] = $step;
}
return [
'process' => $process,
'status' => $status,
];
}
/**
* Get the guard to be used during authentication.
*
* @return \Illuminate\Contracts\Auth\Guard
*/
public function guard()
{
return Auth::guard();
}
}
diff --git a/src/app/Http/Middleware/RequestLogger.php b/src/app/Http/Middleware/RequestLogger.php
index 45f61f32..aa3e9ccd 100644
--- a/src/app/Http/Middleware/RequestLogger.php
+++ b/src/app/Http/Middleware/RequestLogger.php
@@ -1,24 +1,25 @@
fullUrl();
$method = $request->getMethod();
\Log::debug("C: $method $url -> " . var_export($request->bearerToken(), true));
- \Log::debug("S: " . var_export($response->getContent(), true));
+ // On error response this is so noisy that makes the log unusable
+ // \Log::debug("S: " . var_export($response->getContent(), true));
}
}
}
diff --git a/src/resources/js/app.js b/src/resources/js/app.js
index 9c005ae3..45878e5f 100644
--- a/src/resources/js/app.js
+++ b/src/resources/js/app.js
@@ -1,139 +1,139 @@
/**
* 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')
window.Vue = require('vue')
import AppComponent from '../vue/components/App'
import MenuComponent from '../vue/components/Menu'
import router from '../vue/js/routes.js'
import store from '../vue/js/store'
import VueToastr from '@deveodk/vue-toastr'
// Add a response interceptor for general/validation error handler
// This have to be before Vue and Router setup. Otherwise we would
// not be able to handle axios responses initiated from inside
// components created/mounted handlers (e.g. signup code verification link)
window.axios.interceptors.response.use(
response => {
// Do nothing
return response
},
error => {
var error_msg
if (error.response && error.response.status == 422) {
error_msg = "Form validation error"
$.each(error.response.data.errors || {}, (idx, msg) => {
$('form').each((i, form) => {
const input_name = ($(form).data('validation-prefix') || '') + idx
const input = $('#' + input_name)
if (input.length) {
input.addClass('is-invalid')
.parent().append($('
')
.text($.type(msg) === 'string' ? msg : msg.join(' ')))
return false
}
});
})
$('form .is-invalid').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.$toastr('error', error_msg || "Server Error", 'Error')
// Pass the error as-is
return Promise.reject(error)
}
)
const app = new Vue({
el: '#app',
components: {
'app-component': AppComponent,
'menu-component': MenuComponent
},
store,
router,
data() {
return {
isLoading: true
}
},
methods: {
// Clear (bootstrap) form validation state
clearFormValidation(form) {
$(form).find('.is-invalid').removeClass('is-invalid')
$(form).find('.invalid-feedback').remove()
},
// Set user state to "logged in"
loginUser(token, dashboard) {
store.commit('loginUser')
localStorage.setItem('token', token)
axios.defaults.headers.common.Authorization = 'Bearer ' + token
if (dashboard !== false) {
router.push(store.state.afterLogin || { name: 'dashboard' })
}
store.state.afterLogin = null
},
// Set user state to "not logged in"
logoutUser() {
store.commit('logoutUser')
localStorage.setItem('token', '')
delete axios.defaults.headers.common.Authorization
router.push({ name: 'login' })
},
// Display "loading" overlay (to be used by route components)
startLoading() {
this.isLoading = true
// Lock the UI with the 'loading...' element
$('#app').append($('
Loading
'))
},
// Hide "loading" overlay
stopLoading() {
$('#app > .app-loader').fadeOut()
this.isLoading = false
},
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}
`
$('#app').children(':not(nav)').remove()
$('#app').append(error_page)
}
}
})
Vue.use(VueToastr, {
defaultPosition: 'toast-bottom-right',
- defaultTimeout: 50000
+ defaultTimeout: 5000
})
diff --git a/src/resources/lang/en/auth.php b/src/resources/lang/en/auth.php
index e5506df2..1581abd4 100644
--- a/src/resources/lang/en/auth.php
+++ b/src/resources/lang/en/auth.php
@@ -1,19 +1,19 @@
'These credentials do not match our records.',
'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',
-
+ 'logoutsuccess' => 'Successfully logged out',
];
diff --git a/src/resources/vue/components/Login.vue b/src/resources/vue/components/Login.vue
index 7b234403..339f9afa 100644
--- a/src/resources/vue/components/Login.vue
+++ b/src/resources/vue/components/Login.vue
@@ -1,83 +1,85 @@
diff --git a/src/resources/vue/components/Logout.vue b/src/resources/vue/components/Logout.vue
index b69bd801..c1f3a8cc 100644
--- a/src/resources/vue/components/Logout.vue
+++ b/src/resources/vue/components/Logout.vue
@@ -1,9 +1,14 @@
diff --git a/src/resources/vue/components/Menu.vue b/src/resources/vue/components/Menu.vue
index 81c85359..60cd40b8 100644
--- a/src/resources/vue/components/Menu.vue
+++ b/src/resources/vue/components/Menu.vue
@@ -1,56 +1,60 @@
diff --git a/src/tests/Browser/Components/Toast.php b/src/tests/Browser/Components/Toast.php
new file mode 100644
index 00000000..e01b11d7
--- /dev/null
+++ b/src/tests/Browser/Components/Toast.php
@@ -0,0 +1,86 @@
+type = $type;
+ }
+
+ /**
+ * Get the root selector for the component.
+ *
+ * @return string
+ */
+ public function selector()
+ {
+ return '.toast-container > .toast.toast-' . $this->type;
+ }
+
+ /**
+ * Assert that the browser page contains the component.
+ *
+ * @param Browser $browser
+ *
+ * @return void
+ */
+ public function assert(Browser $browser)
+ {
+ $browser->waitFor($this->selector());
+ }
+
+ /**
+ * Get the element shortcuts for the component.
+ *
+ * @return array
+ */
+ public function elements()
+ {
+ return [
+ '@title' => ".toast-title",
+ '@message' => ".toast-message",
+ ];
+ }
+
+ /**
+ * Assert title of the toast element
+ */
+ public function assertToastTitle(Browser $browser, string $title)
+ {
+ if (empty($title)) {
+ $browser->assertMissing('@title');
+ } else {
+ $browser->assertSeeIn('@title', $title);
+ }
+ }
+
+ /**
+ * Assert message of the toast element
+ */
+ public function assertToastMessage(Browser $browser, string $message)
+ {
+ $browser->assertSeeIn('@message', $message);
+ }
+
+ /**
+ * lose the toast with a click
+ */
+ public function closeToast(Browser $browser)
+ {
+ $browser->click();
+ }
+}
diff --git a/src/tests/Browser/LogonTest.php b/src/tests/Browser/LogonTest.php
index da1ea234..af6bf0bd 100644
--- a/src/tests/Browser/LogonTest.php
+++ b/src/tests/Browser/LogonTest.php
@@ -1,113 +1,140 @@
browse(function (Browser $browser) {
$browser->visit(new Home());
$browser->within(new Menu(), function ($browser) {
$browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'webmail']);
});
});
}
/**
* Test redirect to /login if user is unauthenticated
- *
- * @return void
*/
- public function testLogonRedirect()
+ public function testLogonRedirect(): void
{
$this->browse(function (Browser $browser) {
$browser->visit('/dashboard');
// Checks if we're really on the login page
$browser->waitForLocation('/login')
->on(new Home());
});
}
/**
* Logon with wrong password/user test
- *
- * @return void
*/
- public function testLogonWrongCredentials()
+ public function testLogonWrongCredentials(): void
{
$this->browse(function (Browser $browser) {
$browser->visit(new Home())
->submitLogon('john@kolab.org', 'wrong');
// Checks if we're still on the logon page
// FIXME: This assertion might be prone to timing issues
// I guess we should wait until some error message appears
$browser->on(new Home());
});
}
/**
* Successful logon test
- *
- * @return void
*/
- public function testLogonSuccessful()
+ public function testLogonSuccessful(): void
{
$this->browse(function (Browser $browser) {
$browser->visit(new Home())
->submitLogon('john@kolab.org', 'simple123', true);
// Checks if we're really on Dashboard page
$browser->on(new Dashboard());
$browser->within(new Menu(), function ($browser) {
$browser->assertMenuItems(['support', 'contact', 'webmail', 'logout']);
});
});
}
/**
* Logout test
*
* @depends testLogonSuccessful
- * @return void
*/
- public function testLogout()
+ public function testLogout(): void
{
$this->browse(function (Browser $browser) {
$browser->on(new Dashboard());
- // FIXME: Here we're testing click on Logout button
- // We should also test if accessing /Logout url has the same effect
+ // Click the Logout button
$browser->within(new Menu(), function ($browser) {
$browser->click('.link-logout');
});
- // We expect the logoon page
+ // We expect the logon page
$browser->waitForLocation('/login')
->on(new Home());
// with default menu
$browser->within(new Menu(), function ($browser) {
$browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'webmail']);
});
- // TODO: Test if the session is really destroyed
+ // Success toast message
+ $browser->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) {
+ $browser->assertToastTitle('')
+ ->assertToastMessage('Successfully logged out')
+ ->closeToast();
+ });
+ });
+ }
+
+ /**
+ * Logout by URL test
+ */
+ public function testLogoutByURL(): void
+ {
+ $this->browse(function (Browser $browser) {
+ $browser->visit(new Home())
+ ->submitLogon('john@kolab.org', 'simple123', true);
+
+ // Checks if we're really on Dashboard page
+ $browser->on(new Dashboard());
+
+ // Use /logout url, and expect the logon page
+ $browser->visit('/logout')
+ ->waitForLocation('/login')
+ ->on(new Home());
+
+ // with default menu
+ $browser->within(new Menu(), function ($browser) {
+ $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'webmail']);
+ });
+
+ // Success toast message
+ $browser->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) {
+ $browser->assertToastTitle('')
+ ->assertToastMessage('Successfully logged out')
+ ->closeToast();
+ });
});
}
}
diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php
index 0677b28a..fb04451e 100644
--- a/src/tests/Feature/Controller/UsersTest.php
+++ b/src/tests/Feature/Controller/UsersTest.php
@@ -1,145 +1,174 @@
delete();
Domain::where('namespace', 'userscontroller.com')->delete();
}
/**
* Test fetching current user info
*/
public function testInfo(): void
{
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$domain = $this->getTestDomain('userscontroller.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_PUBLIC,
]);
$response = $this->actingAs($user)->get("api/auth/info");
$json = $response->json();
$response->assertStatus(200);
$this->assertEquals($user->id, $json['id']);
$this->assertEquals($user->email, $json['email']);
$this->assertEquals(User::STATUS_NEW, $json['status']);
$this->assertTrue(is_array($json['statusInfo']));
}
public function testIndex(): void
{
$userA = $this->getTestUser('UserEntitlement2A@UserEntitlement.com');
$response = $this->actingAs($userA, 'api')->get("/api/v4/users/{$userA->id}");
$response->assertStatus(200);
$response->assertJson(['id' => $userA->id]);
$user = factory(User::class)->create();
$response = $this->actingAs($user)->get("/api/v4/users/{$userA->id}");
$response->assertStatus(404);
}
- public function testLogin(): void
+ public function testLogin(): string
{
- // TODO
- $this->markTestIncomplete();
+ $post = ['email' => 'john@kolab.org', 'password' => 'simple123'];
+ $response = $this->post("api/auth/login", $post);
+ $json = $response->json();
+
+ $response->assertStatus(200);
+ $this->assertTrue(!empty($json['access_token']));
+ $this->assertEquals(\config('jwt.ttl') * 60, $json['expires_in']);
+ $this->assertEquals('bearer', $json['token_type']);
+
+ return $json['access_token'];
}
- public function testLogout(): void
+ /**
+ * Test /api/auth/logout
+ *
+ * @depends testLogin
+ */
+ public function testLogout($token): void
{
- // TODO
- $this->markTestIncomplete();
+ // Request with no token, testing that it requires auth
+ // TODO: This throws some errors and returns unexpected code 500
+ // $response = $this->post("api/auth/logout");
+ // $response->assertStatus(401);
+
+ // Request with valid token
+ $response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->post("api/auth/logout");
+
+ $json = $response->json();
+
+ $response->assertStatus(200);
+ $this->assertEquals('success', $json['status']);
+ $this->assertEquals('Successfully logged out', $json['message']);
+
+ // Check if it really destroyed the token?
+ // TODO: This throws some errors and returns unexpected code 500
+ // $response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->get("api/auth/info");
+ // $response->assertStatus(401);
}
public function testRefresh(): void
{
// TODO
$this->markTestIncomplete();
}
public function testStatusInfo(): void
{
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$domain = $this->getTestDomain('userscontroller.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_PUBLIC,
]);
$user->status = User::STATUS_NEW;
$user->save();
$result = UsersController::statusInfo($user);
$this->assertSame('new', $result['status']);
$this->assertCount(3, $result['process']);
$this->assertSame('user-new', $result['process'][0]['label']);
$this->assertSame(true, $result['process'][0]['state']);
$this->assertSame('user-ldap-ready', $result['process'][1]['label']);
$this->assertSame(false, $result['process'][1]['state']);
$this->assertSame('user-imap-ready', $result['process'][2]['label']);
$this->assertSame(false, $result['process'][2]['state']);
$user->status |= User::STATUS_LDAP_READY | User::STATUS_IMAP_READY;
$user->save();
$result = UsersController::statusInfo($user);
$this->assertSame('new', $result['status']);
$this->assertCount(3, $result['process']);
$this->assertSame('user-new', $result['process'][0]['label']);
$this->assertSame(true, $result['process'][0]['state']);
$this->assertSame('user-ldap-ready', $result['process'][1]['label']);
$this->assertSame(true, $result['process'][1]['state']);
$this->assertSame('user-imap-ready', $result['process'][2]['label']);
$this->assertSame(true, $result['process'][2]['state']);
$user->status |= User::STATUS_ACTIVE;
$user->save();
// $domain->status |= Domain::STATUS_VERIFIED;
$domain->type = Domain::TYPE_EXTERNAL;
$domain->save();
$result = UsersController::statusInfo($user);
$this->assertSame('active', $result['status']);
$this->assertCount(6, $result['process']);
$this->assertSame('user-new', $result['process'][0]['label']);
$this->assertSame(true, $result['process'][0]['state']);
$this->assertSame('user-ldap-ready', $result['process'][1]['label']);
$this->assertSame(true, $result['process'][1]['state']);
$this->assertSame('user-imap-ready', $result['process'][2]['label']);
$this->assertSame(true, $result['process'][2]['state']);
$this->assertSame('domain-new', $result['process'][3]['label']);
$this->assertSame(true, $result['process'][3]['state']);
$this->assertSame('domain-ldap-ready', $result['process'][4]['label']);
$this->assertSame(false, $result['process'][4]['state']);
// $this->assertSame('domain-verified', $result['process'][5]['label']);
// $this->assertSame(true, $result['process'][5]['state']);
$this->assertSame('domain-confirmed', $result['process'][5]['label']);
$this->assertSame(false, $result['process'][5]['state']);
$user->status |= User::STATUS_DELETED;
$user->save();
$result = UsersController::statusInfo($user);
$this->assertSame('deleted', $result['status']);
}
}