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 <short-code>-<code> input
+        if (strpos($id, '-')) {
+            $id = explode('-', $id)[1];
+        }
+
+        $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->short_code . '-' . $code->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 <short-code>-<code> input
+            if (strpos($code, '-')) {
+                $code = explode('-', $code)[1];
+            }
+
+            $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.short_code + '-' + response.data.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->short_code . '-' . $code->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->short_code . '-' . $code->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 short_code+code as input parameter
+        $id = $jack_code->short_code . '-' . $jack_code->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->short_code . '-' . $code->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->short_code . '-' . $code->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());
     }
 }