diff --git a/src/app/Http/Controllers/API/UsersController.php b/src/app/Http/Controllers/API/UsersController.php
--- a/src/app/Http/Controllers/API/UsersController.php
+++ b/src/app/Http/Controllers/API/UsersController.php
@@ -77,6 +77,15 @@
$user = $this->guard()->user();
$response = $user->toArray();
+ // Settings
+ // TODO: It might be reasonable to limit the list of settings here to these
+ // that are safe and are used in the UI
+ $response['settings'] = [];
+ foreach ($user->settings as $item) {
+ $response['settings'][$item->key] = $item->value;
+ }
+
+ // Status info
$response['statusInfo'] = self::statusInfo($user);
return response()->json($response);
@@ -165,15 +174,13 @@
*/
public function show($id)
{
- $user = Auth::user();
-
- if (!$user) {
+ if (!$this->hasAccess($id)) {
return abort(403);
}
- // TODO: check whether or not the user is allowed
- // for now, only allow self.
- if ($user->id != $id) {
+ $user = User::find($id);
+
+ if (empty($user)) {
return abort(404);
}
@@ -212,7 +219,7 @@
if (!$domain->isPublic()) {
$steps['domain-new'] = true;
$steps['domain-ldap-ready'] = 'isLdapReady';
-// $steps['domain-verified'] = 'isVerified';
+ $steps['domain-verified'] = 'isVerified';
$steps['domain-confirmed'] = 'isConfirmed';
}
@@ -239,6 +246,82 @@
];
}
+ /**
+ * Create a new user record.
+ *
+ * @param \Illuminate\Http\Request $request The API request.
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function store(Request $request)
+ {
+ // TODO
+ }
+
+ /**
+ * Update user data.
+ *
+ * @param \Illuminate\Http\Request $request The API request.
+ * @params string $id User identifier
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function update(Request $request, $id)
+ {
+ if (!$this->hasAccess($id)) {
+ return abort(403);
+ }
+
+ $user = User::find($id);
+
+ if (empty($user)) {
+ return abort(404);
+ }
+
+ $rules = [
+ 'external_email' => 'email',
+ 'phone' => 'string|max:64|regex:/^[0-9+() -]+$/',
+ 'first_name' => 'string|max:512',
+ 'last_name' => 'string|max:512',
+ 'billing_address' => 'string|max:1024',
+ 'country' => 'string|alpha|size:2',
+ 'currency' => 'string|alpha|size:3',
+ ];
+
+ if (
+ (isset($request->password) && strlen($request->password) > 0)
+ || (isset($request->password_confirmation) && strlen($request->password_confirmation) > 0)
+ ) {
+ $rules['password'] = 'required|min:4|max:2048|confirmed';
+ }
+
+ // Validate input
+ $v = Validator::make($request->all(), $rules);
+
+ if ($v->fails()) {
+ return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
+ }
+
+ // Update user settings
+ $settings = $request->only(array_keys($rules));
+ unset($settings['password']);
+
+ if (!empty($settings)) {
+ $user->setSettings($settings);
+ }
+
+ // Update user password
+ if (!empty($rules['password'])) {
+ $user->password = $request->password;
+ $user->save();
+ }
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => __('app.user-update-success'),
+ ]);
+ }
+
/**
* Get the guard to be used during authentication.
*
@@ -248,4 +331,21 @@
{
return Auth::guard();
}
+
+ /**
+ * Check if the current user has access to the specified user
+ *
+ * @param int $user_id User identifier
+ *
+ * @return bool True if current user has access, False otherwise
+ */
+ protected function hasAccess($user_id): bool
+ {
+ $current_user = $this->guard()->user();
+
+ // TODO: Admins, other users
+ // FIXME: This probably should be some kind of middleware/guard
+
+ return $current_user->id == $user_id;
+ }
}
diff --git a/src/app/Http/Kernel.php b/src/app/Http/Kernel.php
--- a/src/app/Http/Kernel.php
+++ b/src/app/Http/Kernel.php
@@ -19,7 +19,7 @@
\App\Http\Middleware\CheckForMaintenanceMode::class,
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
\App\Http\Middleware\TrimStrings::class,
- \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
+// \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
];
/**
diff --git a/src/database/seeds/UserSeeder.php b/src/database/seeds/UserSeeder.php
--- a/src/database/seeds/UserSeeder.php
+++ b/src/database/seeds/UserSeeder.php
@@ -26,7 +26,7 @@
$john = User::create(
[
- 'name' => "John Doe",
+ 'name' => 'John Doe',
'email' => 'john@kolab.org',
'password' => 'simple123',
'email_verified_at' => now()
@@ -35,10 +35,13 @@
$john->setSettings(
[
- "first_name" => "John",
- "last_name" => "Doe",
- "currency" => "USD",
- "country" => "US"
+ 'first_name' => 'John',
+ 'last_name' => 'Doe',
+ 'currency' => 'USD',
+ 'country' => 'US',
+ 'billing_address' => "601 13th Street NW\nSuite 900 South\nWashington, DC 20005",
+ 'external_email' => 'john.doe.external@gmail.com',
+ 'phone' => '+1 509-248-1111',
]
);
@@ -52,7 +55,7 @@
$jack = User::create(
[
- 'name' => "Jack Daniels",
+ 'name' => 'Jack Daniels',
'email' => 'jack@kolab.org',
'password' => 'simple123',
'email_verified_at' => now()
@@ -61,10 +64,10 @@
$jack->setSettings(
[
- "first_name" => "Jack",
- "last_name" => "Daniels",
- "currency" => "USD",
- "country" => "US"
+ 'first_name' => 'Jack',
+ 'last_name' => 'Daniels',
+ 'currency' => 'USD',
+ 'country' => 'US'
]
);
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
@@ -36,8 +36,9 @@
if (input.length) {
input.addClass('is-invalid')
- .parent().append($('
')
- .text($.type(msg) === 'string' ? msg : msg.join('
')))
+ input.parent().find('.invalid-feedback').remove()
+ input.parent().append($('
')
+ .text($.type(msg) === 'string' ? msg : msg.join('
')))
return false
}
diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php
--- a/src/resources/lang/en/app.php
+++ b/src/resources/lang/en/app.php
@@ -21,4 +21,5 @@
'process-domain-confirmed' => 'Custom domain ownership verified',
'domain-verify-success' => 'Domain verified successfully',
+ 'user-update-success' => 'User data updated successfully',
];
diff --git a/src/resources/sass/app.scss b/src/resources/sass/app.scss
--- a/src/resources/sass/app.scss
+++ b/src/resources/sass/app.scss
@@ -33,6 +33,8 @@
display: flex;
justify-content: center;
color: #636b6f;
+ z-index: 10;
+ background: white;
.code {
text-align: right;
@@ -71,3 +73,8 @@
padding: 1rem;
background-color: $menu-bg-color;
}
+
+.card-title {
+ font-size: 1.2rem;
+ font-weight: bold;
+}
diff --git a/src/resources/vue/components/Dashboard.vue b/src/resources/vue/components/Dashboard.vue
--- a/src/resources/vue/components/Dashboard.vue
+++ b/src/resources/vue/components/Dashboard.vue
@@ -15,6 +15,7 @@
+ Your profile
Domains
User accounts
diff --git a/src/resources/vue/components/User/Profile.vue b/src/resources/vue/components/User/Profile.vue
new file mode 100644
--- /dev/null
+++ b/src/resources/vue/components/User/Profile.vue
@@ -0,0 +1,101 @@
+
+
+
+
+
diff --git a/src/resources/vue/js/routes.js b/src/resources/vue/js/routes.js
--- a/src/resources/vue/js/routes.js
+++ b/src/resources/vue/js/routes.js
@@ -13,6 +13,7 @@
import SignupComponent from '../components/Signup'
import UserInfoComponent from '../components/User/Info'
import UserListComponent from '../components/User/List'
+import UserProfileComponent from '../components/User/Profile'
import store from './store'
@@ -54,6 +55,12 @@
name: 'password-reset',
component: PasswordResetComponent
},
+ {
+ path: '/profile',
+ name: 'profile',
+ component: UserProfileComponent,
+ meta: { requiresAuth: true }
+ },
{
path: '/signup/:param?',
name: 'signup',
diff --git a/src/tests/Browser/Components/Toast.php b/src/tests/Browser/Components/Toast.php
--- a/src/tests/Browser/Components/Toast.php
+++ b/src/tests/Browser/Components/Toast.php
@@ -77,7 +77,7 @@
}
/**
- * lose the toast with a click
+ * Close the toast with a click
*/
public function closeToast(Browser $browser)
{
diff --git a/src/tests/Browser/Pages/UserProfile.php b/src/tests/Browser/Pages/UserProfile.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/Pages/UserProfile.php
@@ -0,0 +1,46 @@
+assertPathIs($this->url())
+ ->waitUntilMissing('@app .app-loader')
+ ->assertSeeIn('#user-profile .card-title', 'Your profile');
+ }
+
+ /**
+ * Get the element shortcuts for the page.
+ *
+ * @return array
+ */
+ public function elements(): array
+ {
+ return [
+ '@app' => '#app',
+ '@form' => '#user-profile form',
+ ];
+ }
+}
diff --git a/src/tests/Browser/UserProfileTest.php b/src/tests/Browser/UserProfileTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/UserProfileTest.php
@@ -0,0 +1,127 @@
+ 'John',
+ 'last_name' => 'Doe',
+ 'currency' => 'USD',
+ 'country' => 'US',
+ 'billing_address' => "601 13th Street NW\nSuite 900 South\nWashington, DC 20005",
+ 'external_email' => 'john.doe.external@gmail.com',
+ 'phone' => '+1 509-248-1111',
+ ];
+
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ User::where('email', 'john@kolab.org')->first()->setSettings($this->profile);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ User::where('email', 'john@kolab.org')->first()->setSettings($this->profile);
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test profile page (unauthenticated)
+ */
+ public function testProfileUnauth(): void
+ {
+ // Test that the page requires authentication
+ $this->browse(function (Browser $browser) {
+ $browser->visit('/profile')->on(new Home());
+ });
+ }
+
+ /**
+ * Test profile page
+ */
+ public function testProfile(): void
+ {
+ $this->browse(function (Browser $browser) {
+ $browser->visit(new Home())
+ ->submitLogon('john@kolab.org', 'simple123', true)
+ ->on(new Dashboard())
+ ->assertSeeIn('@links .link-profile', 'Your profile')
+ ->click('@links .link-profile')
+ ->on(new UserProfile())
+ ->whenAvailable('@form', function (Browser $browser) {
+ // Assert form content
+ $browser->assertFocused('div.row:nth-child(1) input')
+ ->assertSeeIn('div.row:nth-child(1) label', 'First name')
+ ->assertValue('div.row:nth-child(1) input[type=text]', $this->profile['first_name'])
+ ->assertSeeIn('div.row:nth-child(2) label', 'Last name')
+ ->assertValue('div.row:nth-child(2) input[type=text]', $this->profile['last_name'])
+ ->assertSeeIn('div.row:nth-child(3) label', 'Phone')
+ ->assertValue('div.row:nth-child(3) input[type=text]', $this->profile['phone'])
+ ->assertSeeIn('div.row:nth-child(4) label', 'External email')
+ ->assertValue('div.row:nth-child(4) input[type=text]', $this->profile['external_email'])
+ ->assertSeeIn('div.row:nth-child(5) label', 'Address')
+ ->assertValue('div.row:nth-child(5) textarea', $this->profile['billing_address'])
+ ->assertSeeIn('div.row:nth-child(6) label', 'Country')
+ ->assertValue('div.row:nth-child(6) select', $this->profile['country'])
+ ->assertSeeIn('div.row:nth-child(7) label', 'Password')
+ ->assertValue('div.row:nth-child(7) input[type=password]', '')
+ ->assertSeeIn('div.row:nth-child(8) label', 'Confirm password')
+ ->assertValue('div.row:nth-child(8) input[type=password]', '')
+ ->assertSeeIn('button[type=submit]', 'Submit');
+
+ // Clear all fields and submit
+ // FIXME: Should any of these fields be required?
+ $browser->type('#first_name', '')
+ ->type('#last_name', '')
+ ->type('#phone', '')
+ ->type('#external_email', '')
+ ->type('#billing_address', '')
+ ->select('#country', '')
+ ->click('button[type=submit]');
+ })
+ ->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) {
+ $browser->assertToastTitle('')
+ ->assertToastMessage('User data updated successfully')
+ ->closeToast();
+ });
+
+
+ // Test error handling
+ $browser->with('@form', function (Browser $browser) {
+ $browser->type('#phone', 'aaaaaa')
+ ->type('#external_email', 'bbbbb')
+ ->click('button[type=submit]')
+ ->waitFor('#phone + .invalid-feedback')
+ ->assertSeeIn('#phone + .invalid-feedback', 'The phone format is invalid.')
+ ->assertSeeIn(
+ '#external_email + .invalid-feedback',
+ 'The external email must be a valid email address.'
+ )
+ ->assertFocused('#phone');
+ })
+ ->with(new Toast(Toast::TYPE_ERROR), function (Browser $browser) {
+ $browser->assertToastTitle('Error')
+ ->assertToastMessage('Form validation error')
+ ->closeToast();
+ });
+ });
+ }
+}
diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php
--- a/src/tests/Feature/Controller/UsersTest.php
+++ b/src/tests/Feature/Controller/UsersTest.php
@@ -5,6 +5,7 @@
use App\Http\Controllers\API\UsersController;
use App\Domain;
use App\User;
+use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
use Tests\TestCase;
@@ -22,6 +23,9 @@
$this->deleteTestDomain('userscontroller.com');
}
+ /**
+ * {@inheritDoc}
+ */
public function tearDown(): void
{
$this->deleteTestUser('UsersControllerTest1@userscontroller.com');
@@ -32,7 +36,7 @@
}
/**
- * Test fetching current user info
+ * Test fetching current user info (/api/auth/info)
*/
public function testInfo(): void
{
@@ -43,29 +47,29 @@
]);
$response = $this->actingAs($user)->get("api/auth/info");
+ $response->assertStatus(200);
+
$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']));
+ $this->assertEquals($user->getSetting('first_name'), $json['settings']['first_name']);
+ $this->assertEquals($user->getSetting('last_name'), $json['settings']['last_name']);
+ $this->assertEquals($user->getSetting('country'), $json['settings']['country']);
+ $this->assertEquals($user->getSetting('currency'), $json['settings']['currency']);
}
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);
+ // TODO
+ $this->markTestIncomplete();
}
+ /**
+ * Test /api/auth/login
+ */
public function testLogin(): string
{
// Request with no data
@@ -174,14 +178,14 @@
$user->status |= User::STATUS_ACTIVE;
$user->save();
-// $domain->status |= Domain::STATUS_VERIFIED;
+ $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->assertCount(7, $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']);
@@ -192,10 +196,10 @@
$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']);
+ $this->assertSame('domain-verified', $result['process'][5]['label']);
+ $this->assertSame(true, $result['process'][5]['state']);
+ $this->assertSame('domain-confirmed', $result['process'][6]['label']);
+ $this->assertSame(false, $result['process'][6]['state']);
$user->status |= User::STATUS_DELETED;
$user->save();
@@ -204,4 +208,123 @@
$this->assertSame('deleted', $result['status']);
}
+
+ /**
+ * Test fetching user data/profile (GET /api/v4/users/)
+ */
+ public function testShow(): void
+ {
+ $userA = $this->getTestUser('UserEntitlement2A@UserEntitlement.com');
+
+ // Test getting profile of self
+ $response = $this->actingAs($userA, 'api')->get("/api/v4/users/{$userA->id}");
+
+ $response->assertStatus(200);
+ $response->assertJson(['id' => $userA->id]);
+
+ // Test unauthorized access to a profile of other user
+ $user = $this->getTestUser('jack@kolab.org');
+ $response = $this->actingAs($user)->get("/api/v4/users/{$userA->id}");
+ $response->assertStatus(403);
+
+ // TODO: Test authorized access to a profile of other user
+ $this->markTestIncomplete();
+ }
+
+ /**
+ * Test user creation (POST /api/v4/users)
+ */
+ public function testStore(): void
+ {
+ // TODO
+ $this->markTestIncomplete();
+ }
+
+ /**
+ * Test user update (PUT /api/v4/users/)
+ */
+ public function testUpdate(): void
+ {
+ $userA = $this->getTestUser('UsersControllerTest1@userscontroller.com');
+ $userB = $this->getTestUser('jack@kolab.org');
+
+ // Test unauthorized update of other user profile
+ $response = $this->actingAs($userB)->get("/api/v4/users/{$userA->id}", []);
+ $response->assertStatus(403);
+
+ // Test updating of self
+ $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", []);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("User data updated successfully", $json['message']);
+ $this->assertCount(2, $json);
+
+ // Test some invalid data
+ $post = ['password' => '12345678', 'currency' => 'invalid'];
+ $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(2, $json);
+ $this->assertSame('The password confirmation does not match.', $json['errors']['password'][0]);
+ $this->assertSame('The currency must be 3 characters.', $json['errors']['currency'][0]);
+
+ // Test full profile update including password
+ $post = [
+ 'password' => 'simple',
+ 'password_confirmation' => 'simple',
+ 'first_name' => 'John2',
+ 'last_name' => 'Doe2',
+ 'phone' => '+123 123 123',
+ 'external_email' => 'external@gmail.com',
+ 'billing_address' => 'billing',
+ 'country' => 'CH',
+ 'currency' => 'CHF',
+ ];
+
+ $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post);
+ $json = $response->json();
+
+ $response->assertStatus(200);
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("User data updated successfully", $json['message']);
+ $this->assertCount(2, $json);
+ $this->assertTrue($userA->password != $userA->fresh()->password);
+ unset($post['password'], $post['password_confirmation']);
+ foreach ($post as $key => $value) {
+ $this->assertSame($value, $userA->getSetting($key));
+ }
+
+ // Test unsetting values
+ $post = [
+ 'first_name' => '',
+ 'last_name' => '',
+ 'phone' => '',
+ 'external_email' => '',
+ 'billing_address' => '',
+ 'country' => '',
+ 'currency' => '',
+ ];
+
+ $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post);
+ $json = $response->json();
+
+ $response->assertStatus(200);
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("User data updated successfully", $json['message']);
+ $this->assertCount(2, $json);
+ foreach ($post as $key => $value) {
+ $this->assertEquals($value, $userA->getSetting($key));
+ }
+
+ // TODO: Test authorized update of other user
+ $this->markTestIncomplete();
+ }
}