Page MenuHomePhorge

D3301.1774870790.diff
No OneTemporary

Authored By
Unknown
Size
41 KB
Referenced Files
None
Subscribers
None

D3301.1774870790.diff

diff --git a/src/app/Http/Controllers/API/PasswordResetController.php b/src/app/Http/Controllers/API/PasswordResetController.php
--- a/src/app/Http/Controllers/API/PasswordResetController.php
+++ b/src/app/Http/Controllers/API/PasswordResetController.php
@@ -83,7 +83,7 @@
}
// Validate the verification code
- $code = VerificationCode::find($request->code);
+ $code = VerificationCode::where('code', $request->code)->where('active', true)->first();
if (
empty($code)
@@ -140,4 +140,67 @@
return AuthController::logonResponse($user, $request->password);
}
+
+ /**
+ * Create a verification code for the current user.
+ *
+ * @param \Illuminate\Http\Request $request HTTP request
+ *
+ * @return \Illuminate\Http\JsonResponse JSON response
+ */
+ public function codeCreate(Request $request)
+ {
+ // Generate the verification code
+ $code = new VerificationCode();
+ $code->mode = 'password-reset';
+
+ // These codes are valid for 24 hours
+ $code->expires_at = now()->addHours(24);
+
+ // The code is inactive until it is submitted via a different endpoint
+ $code->active = false;
+
+ $this->guard()->user()->verificationcodes()->save($code);
+
+ return response()->json([
+ 'status' => 'success',
+ 'code' => $code->code,
+ 'short_code' => $code->short_code,
+ 'expires_at' => $code->expires_at->toDateTimeString(),
+ ]);
+ }
+
+ /**
+ * Delete a verification code.
+ *
+ * @param string $id Code identifier
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function codeDelete($id)
+ {
+ // Accept <code>-<short-code> input
+ if (strpos($id, '-')) {
+ $id = explode('-', $id)[0];
+ }
+
+ $code = VerificationCode::find($id);
+
+ if (!$code) {
+ return $this->errorResponse(404);
+ }
+
+ $current_user = $this->guard()->user();
+
+ if (empty($code->user) || !$current_user->canUpdate($code->user)) {
+ return $this->errorResponse(403);
+ }
+
+ $code->delete();
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => \trans('app.password-reset-code-delete-success'),
+ ]);
+ }
}
diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php
--- a/src/app/Http/Controllers/API/V4/UsersController.php
+++ b/src/app/Http/Controllers/API/V4/UsersController.php
@@ -45,6 +45,8 @@
/** @var array Common object properties in the API response */
protected $objectProps = ['email'];
+ /** @var ?\App\VerificationCode Password reset code to activate on user create/update */
+ protected $passCode;
/**
* Listing of users.
@@ -131,6 +133,14 @@
$response['skus'] = \App\Entitlement::objectEntitlementsSummary($user);
$response['config'] = $user->getConfig();
+ $code = $user->verificationcodes()->where('active', true)
+ ->where('expires_at', '>', \Carbon\Carbon::now())
+ ->first();
+
+ if ($code) {
+ $response['passwordLinkCode'] = $code->code . '-' . $code->short_code;
+ }
+
return response()->json($response);
}
@@ -230,6 +240,8 @@
'password' => $request->password,
]);
+ $this->activatePassCode($user);
+
$owner->assignPackage($package, $user);
if (!empty($settings)) {
@@ -293,6 +305,8 @@
$user->save();
}
+ $this->activatePassCode($user);
+
if (isset($request->aliases)) {
$user->setAliases($request->aliases);
}
@@ -457,8 +471,28 @@
'aliases' => 'array|nullable',
];
+ // Handle generated password reset code
+ if ($code = $request->input('passwordLinkCode')) {
+ // Accept <code>-<short-code> input
+ if (strpos($code, '-')) {
+ $code = explode('-', $code)[0];
+ }
+
+ $this->passCode = $this->guard()->user()->verificationcodes()
+ ->where('code', $code)->where('active', false)->first();
+
+ // Generate a password for a new user with password reset link
+ // FIXME: Should/can we have a user with no password set?
+ if ($this->passCode && empty($user)) {
+ $request->password = $request->password_confirmation = Str::random(16);
+ $ignorePassword = true;
+ }
+ }
+
if (empty($user) || !empty($request->password) || !empty($request->password_confirmation)) {
- $rules['password'] = 'required|min:4|max:2048|confirmed';
+ if (empty($ignorePassword)) {
+ $rules['password'] = 'required|min:6|max:255|confirmed';
+ }
}
$errors = [];
@@ -723,4 +757,19 @@
return null;
}
+
+ /**
+ * Activate password reset code (if set), and assign it to a user.
+ *
+ * @param \App\User $user The user
+ */
+ protected function activatePassCode(User $user): void
+ {
+ // Activate the password reset code
+ if ($this->passCode) {
+ $this->passCode->user_id = $user->id;
+ $this->passCode->active = true;
+ $this->passCode->save();
+ }
+ }
}
diff --git a/src/app/Observers/VerificationCodeObserver.php b/src/app/Observers/VerificationCodeObserver.php
--- a/src/app/Observers/VerificationCodeObserver.php
+++ b/src/app/Observers/VerificationCodeObserver.php
@@ -34,6 +34,15 @@
}
}
- $code->expires_at = Carbon::now()->addHours($exp_hours);
+ if (empty($code->expires_at)) {
+ $code->expires_at = Carbon::now()->addHours($exp_hours);
+ }
+
+ // Verification codes are active by default
+ // Note: This is not required, but this way we make sure the property value
+ // is a boolean not null after create() call, if it wasn't specified there.
+ if (!isset($code->active)) {
+ $code->active = true;
+ }
}
}
diff --git a/src/app/VerificationCode.php b/src/app/VerificationCode.php
--- a/src/app/VerificationCode.php
+++ b/src/app/VerificationCode.php
@@ -8,11 +8,13 @@
/**
* The eloquent definition of a VerificationCode
*
- * @property string $code
- * @property string $mode
- * @property \App\User $user
- * @property int $user_id
- * @property string $short_code
+ * @property bool $active Active status
+ * @property string $code The code
+ * @property \Carbon\Carbon $expires_at Expiration date-time
+ * @property string $mode Mode, e.g. password-reset
+ * @property \App\User $user User object
+ * @property int $user_id User identifier
+ * @property string $short_code Short code
*/
class VerificationCode extends Model
{
@@ -53,18 +55,21 @@
public $timestamps = false;
/**
- * The attributes that are mass assignable.
+ * Casts properties as type
*
* @var array
*/
- protected $fillable = ['user_id', 'code', 'short_code', 'mode', 'expires_at'];
+ protected $casts = [
+ 'active' => 'boolean',
+ 'expires_at' => 'datetime',
+ ];
/**
- * The attributes that should be mutated to dates.
+ * The attributes that are mass assignable.
*
* @var array
*/
- protected $dates = ['expires_at'];
+ protected $fillable = ['user_id', 'code', 'short_code', 'mode', 'expires_at', 'active'];
/**
@@ -91,7 +96,7 @@
}
/**
- * The user to which this setting belongs.
+ * The user to which this code belongs.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
diff --git a/src/database/migrations/2022_01_13_100000_verification_code_active_column.php b/src/database/migrations/2022_01_13_100000_verification_code_active_column.php
new file mode 100644
--- /dev/null
+++ b/src/database/migrations/2022_01_13_100000_verification_code_active_column.php
@@ -0,0 +1,39 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+// phpcs:ignore
+class VerificationCodeActiveColumn extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::table(
+ 'verification_codes',
+ function (Blueprint $table) {
+ $table->boolean('active')->default(true);
+ }
+ );
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::table(
+ 'verification_codes',
+ function (Blueprint $table) {
+ $table->dropColumn('active');
+ }
+ );
+ }
+}
diff --git a/src/resources/js/fontawesome.js b/src/resources/js/fontawesome.js
--- a/src/resources/js/fontawesome.js
+++ b/src/resources/js/fontawesome.js
@@ -4,6 +4,7 @@
//import { } from '@fortawesome/free-brands-svg-icons'
import {
faCheckSquare,
+ faClipboard,
faCreditCard,
faSquare,
} from '@fortawesome/free-regular-svg-icons'
@@ -43,6 +44,7 @@
faCheck,
faCheckCircle,
faCheckSquare,
+ faClipboard,
faCog,
faComments,
faCreditCard,
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
@@ -109,6 +109,8 @@
'wallet-penalty-success' => 'The penalty has been added to the wallet successfully.',
'wallet-update-success' => 'User wallet updated successfully.',
+ 'password-reset-code-delete-success' => 'Password reset code deleted 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.',
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
@@ -19,6 +19,7 @@
'cancel' => "Cancel",
'close' => "Close",
'continue' => "Continue",
+ 'copy' => "Copy",
'delete' => "Delete",
'deny' => "Deny",
'download' => "Download",
@@ -419,6 +420,9 @@
'new' => "New user account",
'org' => "Organization",
'package' => "Package",
+ 'pass-input' => "Enter password",
+ 'pass-link' => "Set via link",
+ 'pass-link-label' => "Link:",
'price' => "Price",
'profile-title' => "Your profile",
'profile-delete' => "Delete account",
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
@@ -68,13 +68,33 @@
<div class="row mb-3">
<label for="password" class="col-sm-4 col-form-label">{{ $t('form.password') }}</label>
<div class="col-sm-8">
- <input type="password" class="form-control" id="password" v-model="user.password" :required="user_id === 'new'">
- </div>
- </div>
- <div class="row mb-3">
- <label for="password_confirmaton" class="col-sm-4 col-form-label">{{ $t('form.password-confirm') }}</label>
- <div class="col-sm-8">
- <input type="password" class="form-control" id="password_confirmation" v-model="user.password_confirmation" :required="user_id === 'new'">
+ <div v-if="!isSelf" class="btn-group w-100" role="group">
+ <input type="checkbox" id="pass-mode-input" value="input" class="btn-check" @change="setPasswordMode" :checked="passwordMode == 'input'">
+ <label class="btn btn-outline-secondary" for="pass-mode-input">{{ $t('user.pass-input') }}</label>
+ <input type="checkbox" id="pass-mode-link" value="link" class="btn-check" @change="setPasswordMode">
+ <label class="btn btn-outline-secondary" for="pass-mode-link">{{ $t('user.pass-link') }}</label>
+ </div>
+ <div v-if="passwordMode == 'input'" :class="isSelf ? '' : 'mt-2'">
+ <input id="password" type="password" class="form-control"
+ v-model="user.password"
+ :placeholder="$t('form.password')"
+ >
+ <input id="password_confirmation" type="password" class="form-control mt-2"
+ v-model="user.password_confirmation"
+ :placeholder="$t('form.password-confirm')"
+ >
+ </div>
+ <div id="password-link" v-if="passwordMode == 'link' || user.passwordLinkCode" class="mt-2">
+ <span>{{ $t('user.pass-link-label') }}</span>&nbsp;<code>{{ passwordLink }}</code>
+ <span class="d-inline-block">
+ <button class="btn btn-link p-1" type="button" :title="$t('btn.copy')" @click="passwordLinkCopy">
+ <svg-icon :icon="['far', 'clipboard']"></svg-icon>
+ </button>
+ <button v-if="user.passwordLinkCode" class="btn btn-link text-danger p-1" type="button" :title="$t('btn.delete')" @click="passwordLinkDelete">
+ <svg-icon icon="trash-alt"></svg-icon>
+ </button>
+ </span>
+ </div>
</div>
</div>
<div v-if="user_id === 'new'" id="user-packages" class="row mb-3">
@@ -144,11 +164,21 @@
},
data() {
return {
+ passwordLinkCode: '',
+ passwordMode: '',
user_id: null,
user: { aliases: [], config: [] },
status: {}
}
},
+ computed: {
+ isSelf: function () {
+ return this.user_id == this.$store.state.authInfo.id
+ },
+ passwordLink: function () {
+ return this.$root.appUrl + '/password-reset/' + this.passwordLinkCode
+ }
+ },
created() {
this.user_id = this.$route.params.user
@@ -164,8 +194,16 @@
this.user.last_name = response.data.settings.last_name
this.user.organization = response.data.settings.organization
this.status = response.data.statusInfo
+
+ this.passwordLinkCode = this.user.passwordLinkCode
})
.catch(this.$root.errorHandler)
+
+ if (this.isSelf) {
+ this.passwordMode = 'input'
+ }
+ } else {
+ this.passwordMode = 'input'
}
},
mounted() {
@@ -175,11 +213,64 @@
})
},
methods: {
+ passwordLinkCopy() {
+ navigator.clipboard.writeText($('#password-link code').text());
+ },
+ passwordLinkDelete() {
+ this.passwordMode = ''
+ $('#pass-mode-link')[0].checked = false
+
+ // Delete the code for real
+ axios.delete('/api/v4/password-reset/code/' + this.passwordLinkCode)
+ .then(response => {
+ this.passwordLinkCode = ''
+ this.user.passwordLinkCode = ''
+ if (response.data.status == 'success') {
+ this.$toast.success(response.data.message)
+ }
+ })
+ },
+ setPasswordMode(event) {
+ const mode = event.target.checked ? event.target.value : ''
+
+ // In the "new user" mode the password mode cannot be unchecked
+ if (!mode && this.user_id === 'new') {
+ event.target.checked = true
+ return
+ }
+
+ this.passwordMode = mode
+
+ if (!event.target.checked) {
+ return
+ }
+
+ $('#pass-mode-' + (mode == 'link' ? 'input' : 'link'))[0].checked = false
+
+ // Note: we use $nextTick() becouse we have to wait for the HTML elements to exist
+ this.$nextTick().then(() => {
+ if (mode == 'link' && !this.passwordLinkCode) {
+ const element = $('#password-link')
+ this.$root.addLoader(element)
+ axios.post('/api/v4/password-reset/code', [])
+ .then(response => {
+ this.$root.removeLoader(element)
+ this.passwordLinkCode = response.data.code + '-' + response.data.short_code
+ })
+ .catch(error => {
+ this.$root.removeLoader(element)
+ })
+ } else if (mode == 'input') {
+ $('#password').focus();
+ }
+ })
+ },
submit() {
this.$root.clearFormValidation($('#general form'))
let method = 'post'
let location = '/api/v4/users'
+ let post = this.$root.pick(this.user, ['aliases', 'email', 'first_name', 'last_name', 'organization'])
if (this.user_id !== 'new') {
method = 'put'
@@ -192,12 +283,19 @@
skus[id] = range || 1
})
- this.user.skus = skus
+ post.skus = skus
} else {
- this.user.package = $('#user-packages input:checked').val()
+ post.package = $('#user-packages input:checked').val()
+ }
+
+ if (this.passwordMode == 'link' && this.passwordLinkCode) {
+ post.passwordLinkCode = this.passwordLinkCode
+ } else if (this.passwordMode == 'input') {
+ post.password = this.user.password
+ post.password_confirmation = this.user.password_confirmation
}
- axios[method](location, this.user)
+ axios[method](location, post)
.then(response => {
if (response.data.statusInfo) {
this.$store.state.authInfo.statusInfo = response.data.statusInfo
diff --git a/src/resources/vue/User/Profile.vue b/src/resources/vue/User/Profile.vue
--- a/src/resources/vue/User/Profile.vue
+++ b/src/resources/vue/User/Profile.vue
@@ -68,13 +68,14 @@
<div class="row mb-3">
<label for="password" class="col-sm-4 col-form-label">{{ $t('form.password') }}</label>
<div class="col-sm-8">
- <input type="password" class="form-control" id="password" v-model="profile.password">
- </div>
- </div>
- <div class="row mb-3">
- <label for="password_confirmaton" class="col-sm-4 col-form-label">{{ $t('form.password-confirm') }}</label>
- <div class="col-sm-8">
- <input type="password" class="form-control" id="password_confirmation" v-model="profile.password_confirmation">
+ <input type="password" class="form-control" id="password"
+ v-model="profile.password"
+ :placeholder="$t('form.password')"
+ >
+ <input type="password" class="form-control mt-2" id="password_confirmation"
+ v-model="profile.password_confirmation"
+ :placeholder="$t('form.password-confirm')"
+ >
</div>
</div>
<button class="btn btn-primary button-submit mt-2" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}</button>
diff --git a/src/routes/api.php b/src/routes/api.php
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -100,6 +100,9 @@
Route::get('wallets/{id}/receipts', 'API\V4\WalletsController@receipts');
Route::get('wallets/{id}/receipts/{receipt}', 'API\V4\WalletsController@receiptDownload');
+ Route::post('password-reset/code', 'API\PasswordResetController@codeCreate');
+ Route::delete('password-reset/code/{id}', 'API\PasswordResetController@codeDelete');
+
Route::post('payments', 'API\V4\PaymentsController@store');
//Route::delete('payments', 'API\V4\PaymentsController@cancel');
Route::get('payments/mandate', 'API\V4\PaymentsController@mandate');
diff --git a/src/tests/Browser/UserProfileTest.php b/src/tests/Browser/UserProfileTest.php
--- a/src/tests/Browser/UserProfileTest.php
+++ b/src/tests/Browser/UserProfileTest.php
@@ -91,9 +91,10 @@
->assertSeeIn('div.row:nth-child(8) label', 'Country')
->assertValue('div.row:nth-child(8) select', $this->profile['country'])
->assertSeeIn('div.row:nth-child(9) label', 'Password')
- ->assertValue('div.row:nth-child(9) input[type=password]', '')
- ->assertSeeIn('div.row:nth-child(10) label', 'Confirm Password')
- ->assertValue('div.row:nth-child(10) input[type=password]', '')
+ ->assertValue('div.row:nth-child(9) input#password', '')
+ ->assertValue('div.row:nth-child(9) input#password_confirmation', '')
+ ->assertAttribute('#password', 'placeholder', 'Password')
+ ->assertAttribute('#password_confirmation', 'placeholder', 'Confirm Password')
->assertSeeIn('button[type=submit]', 'Submit');
// Test form error handling
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
@@ -93,10 +93,13 @@
public function testInfo(): void
{
$this->browse(function (Browser $browser) {
- $user = User::where('email', 'john@kolab.org')->first();
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $john->verificationcodes()->delete();
+ $jack->verificationcodes()->delete();
// Test that the page requires authentication
- $browser->visit('/user/' . $user->id)
+ $browser->visit('/user/' . $john->id)
->on(new Home())
->submitLogon('john@kolab.org', 'simple123', false)
->on(new UserInfo())
@@ -122,9 +125,12 @@
->assertValue('@input', '');
})
->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]', '')
+ ->assertValue('div.row:nth-child(7) input#password', '')
+ ->assertValue('div.row:nth-child(7) input#password_confirmation', '')
+ ->assertAttribute('#password', 'placeholder', 'Password')
+ ->assertAttribute('#password_confirmation', 'placeholder', 'Confirm Password')
+ ->assertMissing('div.row:nth-child(7) .btn-group')
+ ->assertMissing('div.row:nth-child(7) #password-link')
->assertSeeIn('button[type=submit]', 'Submit')
// Clear some fields and submit
->vueClear('#first_name')
@@ -141,8 +147,11 @@
$browser->type('#password', 'aaaaaa')
->vueClear('#password_confirmation')
->click('button[type=submit]')
- ->waitFor('#password + .invalid-feedback')
- ->assertSeeIn('#password + .invalid-feedback', 'The password confirmation does not match.')
+ ->waitFor('#password_confirmation + .invalid-feedback')
+ ->assertSeeIn(
+ '#password_confirmation + .invalid-feedback',
+ 'The password confirmation does not match.'
+ )
->assertFocused('#password')
->assertToast(Toast::TYPE_ERROR, 'Form validation error');
@@ -173,14 +182,13 @@
->click('@table tr:nth-child(3) a')
->on(new UserInfo());
- $john = User::where('email', 'john@kolab.org')->first();
- $alias = UserAlias::where('user_id', $john->id)->where('alias', 'john.test@kolab.org')->first();
+ $alias = $john->aliases()->where('alias', 'john.test@kolab.org')->first();
$this->assertTrue(!empty($alias));
// Test subscriptions
$browser->with('@general', function (Browser $browser) {
- $browser->assertSeeIn('div.row:nth-child(9) label', 'Subscriptions')
- ->assertVisible('@skus.row:nth-child(9)')
+ $browser->assertSeeIn('div.row:nth-child(8) label', 'Subscriptions')
+ ->assertVisible('@skus.row:nth-child(8)')
->with('@skus', function ($browser) {
$browser->assertElementsCount('tbody tr', 6)
// Mailbox SKU
@@ -253,7 +261,7 @@
$expected = ['activesync', 'groupware', 'mailbox',
'storage', 'storage', 'storage', 'storage', 'storage', 'storage'];
- $this->assertEntitlements($john, $expected);
+ $this->assertEntitlements($john->fresh(), $expected);
// Test subscriptions interaction
$browser->with('@general', function (Browser $browser) {
@@ -285,6 +293,64 @@
->assertNotReadonly('#sku-input-activesync');
});
});
+
+ // Test password reset link delete and create
+ $code = new \App\VerificationCode(['mode' => 'password-reset']);
+ $jack->verificationcodes()->save($code);
+
+ $browser->visit('/user/' . $jack->id)
+ ->on(new UserInfo())
+ ->with('@general', function (Browser $browser) use ($jack, $john, $code) {
+ // Test displaying an existing password reset link
+ $link = Browser::$baseUrl . '/password-reset/' . $code->code . '-' . $code->short_code;
+ $browser->assertSeeIn('div.row:nth-child(7) label', 'Password')
+ ->assertMissing('#password')
+ ->assertMissing('#password_confirmation')
+ ->assertMissing('#pass-mode-link:checked')
+ ->assertMissing('#pass-mode-input:checked')
+ ->assertSeeIn('#password-link code', $link)
+ ->assertVisible('#password-link button.text-danger')
+ ->assertVisible('#password-link button:not(.text-danger)')
+ ->assertAttribute('#password-link button:not(.text-danger)', 'title', 'Copy')
+ ->assertAttribute('#password-link button.text-danger', 'title', 'Delete');
+
+ // Test deleting an existing password reset link
+ $browser->click('#password-link button.text-danger')
+ ->assertToast(Toast::TYPE_SUCCESS, 'Password reset code deleted successfully.')
+ ->assertMissing('#password-link')
+ ->assertMissing('#pass-mode-link:checked')
+ ->assertMissing('#pass-mode-input:checked')
+ ->assertMissing('#password');
+
+ $this->assertSame(0, $jack->verificationcodes()->count());
+
+ // Test creating a password reset link
+ $link = preg_replace('|/[a-z0-9A-Z-]+$|', '', $link) . '/';
+ $browser->click('#pass-mode-link + label')
+ ->assertMissing('#password')
+ ->assertMissing('#password_confirmation')
+ ->waitFor('#password-link code')
+ ->assertSeeIn('#password-link code', $link);
+
+ // Test copy to clipboard
+ /* TODO: Figure out how to give permission to do this operation
+ $code = $john->verificationcodes()->first();
+ $link .= $code->code . '-' . $code->short_code;
+
+ $browser->assertMissing('#password-link button.text-danger')
+ ->click('#password-link button:not(.text-danger)')
+ ->keys('#organization', ['{left_control}', 'v'])
+ ->assertAttribute('#organization', 'value', $link)
+ ->vueClear('#organization');
+ */
+
+ // Finally submit the form
+ $browser->click('button[type=submit]')
+ ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
+
+ $this->assertSame(1, $jack->verificationcodes()->where('active', true)->count());
+ $this->assertSame(0, $john->verificationcodes()->count());
+ });
});
}
@@ -348,10 +414,15 @@
->assertValue('@input', '');
})
->assertSeeIn('div.row:nth-child(6) label', 'Password')
- ->assertValue('div.row:nth-child(6) input[type=password]', '')
- ->assertSeeIn('div.row:nth-child(7) label', 'Confirm Password')
- ->assertValue('div.row:nth-child(7) input[type=password]', '')
- ->assertSeeIn('div.row:nth-child(8) label', 'Package')
+ ->assertValue('div.row:nth-child(6) input#password', '')
+ ->assertValue('div.row:nth-child(6) input#password_confirmation', '')
+ ->assertAttribute('#password', 'placeholder', 'Password')
+ ->assertAttribute('#password_confirmation', 'placeholder', 'Confirm Password')
+ ->assertSeeIn('div.row:nth-child(6) .btn-group input:first-child + label', 'Enter password')
+ ->assertSeeIn('div.row:nth-child(6) .btn-group input:not(:first-child) + label', 'Set via link')
+ ->assertChecked('div.row:nth-child(6) .btn-group input:first-child')
+ ->assertMissing('div.row:nth-child(6) #password-link')
+ ->assertSeeIn('div.row:nth-child(7) label', 'Package')
// assert packages list widget, select "Lite Account"
->with('@packages', function ($browser) {
$browser->assertElementsCount('tbody tr', 2)
@@ -371,16 +442,15 @@
$browser->click('button[type=submit]')
->assertFocused('#email')
->type('#email', 'invalid email')
- ->click('button[type=submit]')
- ->assertFocused('#password')
->type('#password', 'simple123')
- ->click('button[type=submit]')
- ->assertFocused('#password_confirmation')
->type('#password_confirmation', 'simple')
->click('button[type=submit]')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->assertSeeIn('#email + .invalid-feedback', 'The specified email is invalid.')
- ->assertSeeIn('#password + .invalid-feedback', 'The password confirmation does not match.');
+ ->assertSeeIn(
+ '#password_confirmation + .invalid-feedback',
+ 'The password confirmation does not match.'
+ );
});
// Test form error handling (aliases)
diff --git a/src/tests/Feature/Controller/PasswordResetTest.php b/src/tests/Feature/Controller/PasswordResetTest.php
--- a/src/tests/Feature/Controller/PasswordResetTest.php
+++ b/src/tests/Feature/Controller/PasswordResetTest.php
@@ -330,4 +330,82 @@
// TODO: Check if the access token works
}
+
+ /**
+ * Test creating a password verification code
+ *
+ * @return void
+ */
+ public function testCodeCreate()
+ {
+ $user = $this->getTestUser('john@kolab.org');
+ $user->verificationcodes()->delete();
+
+ $response = $this->actingAs($user)->post('/api/v4/password-reset/code', []);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $code = $user->verificationcodes()->first();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame($code->code, $json['code']);
+ $this->assertSame($code->short_code, $json['short_code']);
+ $this->assertStringContainsString(now()->addHours(24)->toDateString(), $json['expires_at']);
+ }
+
+ /**
+ * Test deleting a password verification code
+ *
+ * @return void
+ */
+ public function testCodeDelete()
+ {
+ $user = $this->getTestUser('passwordresettest@' . \config('app.domain'));
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $john->verificationcodes()->delete();
+ $jack->verificationcodes()->delete();
+
+ $john_code = new VerificationCode(['mode' => 'password-reset']);
+ $john->verificationcodes()->save($john_code);
+ $jack_code = new VerificationCode(['mode' => 'password-reset']);
+ $jack->verificationcodes()->save($jack_code);
+ $user_code = new VerificationCode(['mode' => 'password-reset']);
+ $user->verificationcodes()->save($user_code);
+
+ // Unauth access
+ $response = $this->delete('/api/v4/password-reset/code/' . $user_code->code);
+ $response->assertStatus(401);
+
+ // Non-existing code
+ $response = $this->actingAs($john)->delete('/api/v4/password-reset/code/123');
+ $response->assertStatus(404);
+
+ // Existing code belonging to another user not controlled by the acting user
+ $response = $this->actingAs($john)->delete('/api/v4/password-reset/code/' . $user_code->code);
+ $response->assertStatus(403);
+
+ // Deleting owned code
+ $response = $this->actingAs($john)->delete('/api/v4/password-reset/code/' . $john_code->code);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $john->verificationcodes()->count());
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("Password reset code deleted successfully.", $json['message']);
+
+ // Deleting code of another user owned by the acting user
+ // also use code+short-code as input parameter
+ $id = $jack_code->code . '-' . $jack_code->short_code;
+ $response = $this->actingAs($john)->delete('/api/v4/password-reset/code/' . $id);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $jack->verificationcodes()->count());
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("Password reset code deleted successfully.", $json['message']);
+ }
}
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
@@ -704,7 +704,7 @@
'storage', 'storage', 'storage', 'storage', 'storage']);
// Assert the wallet to which the new user should be assigned to
$wallet = $user->wallet();
- $this->assertSame($john->wallets()->first()->id, $wallet->id);
+ $this->assertSame($john->wallets->first()->id, $wallet->id);
// Attempt to create a user previously deleted
$user->delete();
@@ -729,9 +729,41 @@
$this->assertEntitlements($user, ['groupware', 'mailbox',
'storage', 'storage', 'storage', 'storage', 'storage']);
- // Test acting as account controller (not owner)
+ // Test password reset link "mode"
+ $code = new \App\VerificationCode(['mode' => 'password-reset', 'active' => false]);
+ $john->verificationcodes()->save($code);
- $this->markTestIncomplete();
+ $post = [
+ 'first_name' => 'John2',
+ 'last_name' => 'Doe2',
+ 'email' => 'deleted@kolab.org',
+ 'organization' => '',
+ 'aliases' => [],
+ 'passwordLinkCode' => $code->code . '-' . $code->short_code,
+ 'package' => $package_kolab->id,
+ ];
+
+ $response = $this->actingAs($john)->post("/api/v4/users", $post);
+ $json = $response->json();
+
+ $response->assertStatus(200);
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("User created successfully.", $json['message']);
+ $this->assertCount(2, $json);
+
+ $user = $this->getTestUser('deleted@kolab.org');
+ $code->refresh();
+
+ $this->assertSame($user->id, $code->user_id);
+ $this->assertTrue($code->active);
+ $this->assertTrue(is_string($user->password) && strlen($user->password) >= 60);
+
+ // Test acting as account controller not owner, which is not yet supported
+ $john->wallets->first()->addController($user);
+
+ $response = $this->actingAs($user)->post("/api/v4/users", []);
+ $response->assertStatus(403);
}
/**
@@ -928,6 +960,23 @@
$this->assertSame([0, 0, 0, 0, 0, 25], $storage_cost);
$this->assertTrue(empty($json['statusInfo']));
+
+ // Test password reset link "mode"
+ $code = new \App\VerificationCode(['mode' => 'password-reset', 'active' => false]);
+ $owner->verificationcodes()->save($code);
+
+ $post = ['passwordLinkCode' => $code->code . '-' . $code->short_code];
+
+ $response = $this->actingAs($owner)->put("/api/v4/users/{$user->id}", $post);
+ $json = $response->json();
+
+ $response->assertStatus(200);
+
+ $code->refresh();
+
+ $this->assertSame($user->id, $code->user_id);
+ $this->assertTrue($code->active);
+ $this->assertSame($user->password, $user->fresh()->password);
}
/**
diff --git a/src/tests/Feature/VerificationCodeTest.php b/src/tests/Feature/VerificationCodeTest.php
--- a/src/tests/Feature/VerificationCodeTest.php
+++ b/src/tests/Feature/VerificationCodeTest.php
@@ -45,6 +45,7 @@
$this->assertFalse($code->isExpired());
$this->assertTrue(strlen($code->code) === VerificationCode::CODE_LENGTH);
$this->assertTrue(strlen($code->short_code) === $code_length);
+ $this->assertTrue($code->active);
$this->assertSame($data['mode'], $code->mode);
$this->assertEquals($user->id, $code->user->id);
$this->assertInstanceOf(\DateTime::class, $code->expires_at);
@@ -54,5 +55,12 @@
$this->assertInstanceOf(VerificationCode::class, $inst);
$this->assertSame($inst->code, $code->code);
+
+ // Custom active flag and custom expires_at
+ $data['expires_at'] = Carbon::now()->addDays(10);
+ $data['active'] = false;
+ $code = VerificationCode::create($data);
+ $this->assertFalse($code->active);
+ $this->assertSame($code->expires_at->toDateTimeString(), $data['expires_at']->toDateTimeString());
}
}

File Metadata

Mime Type
text/plain
Expires
Mon, Mar 30, 11:39 AM (3 d, 47 m ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18806532
Default Alt Text
D3301.1774870790.diff (41 KB)

Event Timeline