Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117763275
D3301.1775219084.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
41 KB
Referenced Files
None
Subscribers
None
D3301.1775219084.diff
View Options
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> <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
Details
Attached
Mime Type
text/plain
Expires
Fri, Apr 3, 12:24 PM (17 h, 41 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18806532
Default Alt Text
D3301.1775219084.diff (41 KB)
Attached To
Mode
D3301: Password reset link
Attached
Detach File
Event Timeline