diff --git a/src/.eslintrc.js b/src/.eslintrc.js
index d715bd40..55281a0e 100644
--- a/src/.eslintrc.js
+++ b/src/.eslintrc.js
@@ -1,20 +1,21 @@
 module.exports = {
   extends: [
     // add more generic rulesets here, such as:
     // 'eslint:recommended',
     'plugin:vue/recommended'
   ],
   parserOptions: {
     parser: "@babel/eslint-parser",
     requireConfigFile: false
   },
   rules: {
     "vue/attributes-order": "off",
     "vue/html-indent": ["error", 4],
     "vue/html-self-closing": "off",
     "vue/max-attributes-per-line": "off",
+    "vue/no-unused-components": "off",
     "vue/no-v-html": "off",
     "vue/singleline-html-element-content-newline": "off",
     "vue/multiline-html-element-content-newline": "off"
   }
 }
diff --git a/src/app/Http/Controllers/API/V4/PaymentsController.php b/src/app/Http/Controllers/API/V4/PaymentsController.php
index 76e4e8cb..28b77266 100644
--- a/src/app/Http/Controllers/API/V4/PaymentsController.php
+++ b/src/app/Http/Controllers/API/V4/PaymentsController.php
@@ -1,480 +1,482 @@
 <?php
 
 namespace App\Http\Controllers\API\V4;
 
 use App\Http\Controllers\Controller;
 use App\Providers\PaymentProvider;
 use App\Tenant;
 use App\Wallet;
 use App\Payment;
 use Illuminate\Http\Request;
 use Illuminate\Support\Facades\Validator;
 
 class PaymentsController extends Controller
 {
     /**
      * Get the auto-payment mandate info.
      *
      * @return \Illuminate\Http\JsonResponse The response
      */
     public function mandate()
     {
         $user = $this->guard()->user();
 
         // TODO: Wallet selection
         $wallet = $user->wallets()->first();
 
         $mandate = self::walletMandate($wallet);
 
         return response()->json($mandate);
     }
 
     /**
      * Create a new auto-payment mandate.
      *
      * @param \Illuminate\Http\Request $request The API request.
      *
      * @return \Illuminate\Http\JsonResponse The response
      */
     public function mandateCreate(Request $request)
     {
         $user = $this->guard()->user();
 
         // TODO: Wallet selection
         $wallet = $user->wallets()->first();
 
         // Input validation
         if ($errors = self::mandateValidate($request, $wallet)) {
             return response()->json(['status' => 'error', 'errors' => $errors], 422);
         }
 
         $wallet->setSettings([
                 'mandate_amount' => $request->amount,
                 'mandate_balance' => $request->balance,
         ]);
 
         $mandate = [
             'currency' => $wallet->currency,
             'description' => Tenant::getConfig($user->tenant_id, 'app.name') . ' Auto-Payment Setup',
             'methodId' => $request->methodId
         ];
 
         // Normally the auto-payment setup operation is 0, if the balance is below the threshold
         // we'll top-up the wallet with the configured auto-payment amount
         if ($wallet->balance < intval($request->balance * 100)) {
             $mandate['amount'] = intval($request->amount * 100);
         }
 
         $provider = PaymentProvider::factory($wallet);
 
         $result = $provider->createMandate($wallet, $mandate);
 
         $result['status'] = 'success';
 
         return response()->json($result);
     }
 
     /**
      * Revoke the auto-payment mandate.
      *
      * @return \Illuminate\Http\JsonResponse The response
      */
     public function mandateDelete()
     {
         $user = $this->guard()->user();
 
         // TODO: Wallet selection
         $wallet = $user->wallets()->first();
 
         $provider = PaymentProvider::factory($wallet);
 
         $provider->deleteMandate($wallet);
 
         $wallet->setSetting('mandate_disabled', null);
 
         return response()->json([
                 'status' => 'success',
                 'message' => \trans('app.mandate-delete-success'),
         ]);
     }
 
     /**
      * Update a new auto-payment mandate.
      *
      * @param \Illuminate\Http\Request $request The API request.
      *
      * @return \Illuminate\Http\JsonResponse The response
      */
     public function mandateUpdate(Request $request)
     {
         $user = $this->guard()->user();
 
         // TODO: Wallet selection
         $wallet = $user->wallets()->first();
 
         // Input validation
         if ($errors = self::mandateValidate($request, $wallet)) {
             return response()->json(['status' => 'error', 'errors' => $errors], 422);
         }
 
         $wallet->setSettings([
                 'mandate_amount' => $request->amount,
                 'mandate_balance' => $request->balance,
                 // Re-enable the mandate to give it a chance to charge again
                 // after it has been disabled (e.g. because the mandate amount was too small)
                 'mandate_disabled' => null,
         ]);
 
         // Trigger auto-payment if the balance is below the threshold
         if ($wallet->balance < intval($request->balance * 100)) {
             \App\Jobs\WalletCharge::dispatch($wallet);
         }
 
         $result = self::walletMandate($wallet);
         $result['status'] = 'success';
         $result['message'] = \trans('app.mandate-update-success');
 
         return response()->json($result);
     }
 
     /**
      * Validate an auto-payment mandate request.
      *
      * @param \Illuminate\Http\Request $request The API request.
      * @param \App\Wallet              $wallet  The wallet
      *
      * @return array|null List of errors on error or Null on success
      */
     protected static function mandateValidate(Request $request, Wallet $wallet)
     {
         $rules = [
             'amount' => 'required|numeric',
             'balance' => 'required|numeric|min:0',
         ];
 
         // Check required fields
         $v = Validator::make($request->all(), $rules);
 
         // TODO: allow comma as a decimal point?
 
         if ($v->fails()) {
             return $v->errors()->toArray();
         }
 
         $amount = (int) ($request->amount * 100);
 
         // Validate the minimum value
         // It has to be at least minimum payment amount and must cover current debt
         if (
             $wallet->balance < 0
             && $wallet->balance * -1 > PaymentProvider::MIN_AMOUNT
             && $wallet->balance + $amount < 0
         ) {
             return ['amount' => \trans('validation.minamountdebt')];
         }
 
         if ($amount < PaymentProvider::MIN_AMOUNT) {
             $min = $wallet->money(PaymentProvider::MIN_AMOUNT);
             return ['amount' => \trans('validation.minamount', ['amount' => $min])];
         }
 
         return null;
     }
 
     /**
      * Create a new payment.
      *
      * @param \Illuminate\Http\Request $request The API request.
      *
      * @return \Illuminate\Http\JsonResponse The response
      */
     public function store(Request $request)
     {
         $user = $this->guard()->user();
 
         // TODO: Wallet selection
         $wallet = $user->wallets()->first();
 
         $rules = [
             'amount' => 'required|numeric',
         ];
 
         // Check required fields
         $v = Validator::make($request->all(), $rules);
 
         // TODO: allow comma as a decimal point?
 
         if ($v->fails()) {
             return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
         }
 
         $amount = (int) ($request->amount * 100);
 
         // Validate the minimum value
         if ($amount < PaymentProvider::MIN_AMOUNT) {
             $min = $wallet->money(PaymentProvider::MIN_AMOUNT);
             $errors = ['amount' => \trans('validation.minamount', ['amount' => $min])];
             return response()->json(['status' => 'error', 'errors' => $errors], 422);
         }
 
         $request = [
             'type' => PaymentProvider::TYPE_ONEOFF,
             'currency' => $request->currency,
             'amount' => $amount,
             'methodId' => $request->methodId,
             'description' => Tenant::getConfig($user->tenant_id, 'app.name') . ' Payment',
         ];
 
         $provider = PaymentProvider::factory($wallet);
 
         $result = $provider->payment($wallet, $request);
 
         $result['status'] = 'success';
 
         return response()->json($result);
     }
 
     /**
      * Delete a pending payment.
      *
      * @param \Illuminate\Http\Request $request The API request.
      *
      * @return \Illuminate\Http\JsonResponse The response
      */
     // TODO currently unused
     // public function cancel(Request $request)
     // {
     //     $user = $this->guard()->user();
 
     //     // TODO: Wallet selection
     //     $wallet = $user->wallets()->first();
 
     //     $paymentId = $request->payment;
 
     //     $user_owns_payment = Payment::where('id', $paymentId)
     //         ->where('wallet_id', $wallet->id)
     //         ->exists();
 
     //     if (!$user_owns_payment) {
     //         return $this->errorResponse(404);
     //     }
 
     //     $provider = PaymentProvider::factory($wallet);
     //     if ($provider->cancel($wallet, $paymentId)) {
     //         $result = ['status' => 'success'];
     //         return response()->json($result);
     //     }
 
     //     return $this->errorResponse(404);
     // }
 
     /**
      * Update payment status (and balance).
      *
      * @param string $provider Provider name
      *
      * @return \Illuminate\Http\Response The response
      */
     public function webhook($provider)
     {
         $code = 200;
 
         if ($provider = PaymentProvider::factory($provider)) {
             $code = $provider->webhook();
         }
 
         return response($code < 400 ? 'Success' : 'Server error', $code);
     }
 
     /**
      * Top up a wallet with a "recurring" payment.
      *
      * @param \App\Wallet $wallet The wallet to charge
      *
      * @return bool True if the payment has been initialized
      */
     public static function topUpWallet(Wallet $wallet): bool
     {
         $settings = $wallet->getSettings(['mandate_disabled', 'mandate_balance', 'mandate_amount']);
 
         if (!empty($settings['mandate_disabled'])) {
             return false;
         }
 
         $min_balance = (int) (floatval($settings['mandate_balance']) * 100);
         $amount = (int) (floatval($settings['mandate_amount']) * 100);
 
         // The wallet balance is greater than the auto-payment threshold
         if ($wallet->balance >= $min_balance) {
             // Do nothing
             return false;
         }
 
         $provider = PaymentProvider::factory($wallet);
         $mandate = (array) $provider->getMandate($wallet);
 
         if (empty($mandate['isValid'])) {
             return false;
         }
 
         // The defined top-up amount is not enough
         // Disable auto-payment and notify the user
         if ($wallet->balance + $amount < 0) {
             // Disable (not remove) the mandate
             $wallet->setSetting('mandate_disabled', 1);
             \App\Jobs\PaymentMandateDisabledEmail::dispatch($wallet);
             return false;
         }
 
         $request = [
             'type' => PaymentProvider::TYPE_RECURRING,
             'currency' => $wallet->currency,
             'amount' => $amount,
             'methodId' => PaymentProvider::METHOD_CREDITCARD,
             'description' => Tenant::getConfig($wallet->owner->tenant_id, 'app.name') . ' Recurring Payment',
         ];
 
         $result = $provider->payment($wallet, $request);
 
         return !empty($result);
     }
 
     /**
      * Returns auto-payment mandate info for the specified wallet
      *
      * @param \App\Wallet $wallet A wallet object
      *
      * @return array A mandate metadata
      */
     public static function walletMandate(Wallet $wallet): array
     {
         $provider = PaymentProvider::factory($wallet);
         $settings = $wallet->getSettings(['mandate_disabled', 'mandate_balance', 'mandate_amount']);
 
         // Get the Mandate info
         $mandate = (array) $provider->getMandate($wallet);
 
         $mandate['amount'] = (int) (PaymentProvider::MIN_AMOUNT / 100);
         $mandate['balance'] = 0;
         $mandate['isDisabled'] = !empty($mandate['id']) && $settings['mandate_disabled'];
 
         foreach (['amount', 'balance'] as $key) {
             if (($value = $settings["mandate_{$key}"]) !== null) {
                 $mandate[$key] = $value;
             }
         }
 
         return $mandate;
     }
 
 
     /**
      * List supported payment methods.
      *
      * @param \Illuminate\Http\Request $request The API request.
      *
      * @return \Illuminate\Http\JsonResponse The response
      */
     public function paymentMethods(Request $request)
     {
         $user = $this->guard()->user();
 
         // TODO: Wallet selection
         $wallet = $user->wallets()->first();
 
         $methods = PaymentProvider::paymentMethods($wallet, $request->type);
 
         \Log::debug("Provider methods" . var_export(json_encode($methods), true));
 
         return response()->json($methods);
     }
 
     /**
      * Check for pending payments.
      *
      * @param \Illuminate\Http\Request $request The API request.
      *
      * @return \Illuminate\Http\JsonResponse The response
      */
     public function hasPayments(Request $request)
     {
         $user = $this->guard()->user();
 
         // TODO: Wallet selection
         $wallet = $user->wallets()->first();
 
         $exists = Payment::where('wallet_id', $wallet->id)
             ->where('type', PaymentProvider::TYPE_ONEOFF)
             ->whereIn('status', [
-                PaymentProvider::STATUS_OPEN,
-                PaymentProvider::STATUS_PENDING,
-                PaymentProvider::STATUS_AUTHORIZED])
+                    PaymentProvider::STATUS_OPEN,
+                    PaymentProvider::STATUS_PENDING,
+                    PaymentProvider::STATUS_AUTHORIZED
+            ])
             ->exists();
 
         return response()->json([
             'status' => 'success',
             'hasPending' => $exists
         ]);
     }
 
     /**
      * List pending payments.
      *
      * @param \Illuminate\Http\Request $request The API request.
      *
      * @return \Illuminate\Http\JsonResponse The response
      */
     public function payments(Request $request)
     {
         $user = $this->guard()->user();
 
         // TODO: Wallet selection
         $wallet = $user->wallets()->first();
 
         $pageSize = 10;
         $page = intval(request()->input('page')) ?: 1;
         $hasMore = false;
         $result = Payment::where('wallet_id', $wallet->id)
             ->where('type', PaymentProvider::TYPE_ONEOFF)
             ->whereIn('status', [
-                PaymentProvider::STATUS_OPEN,
-                PaymentProvider::STATUS_PENDING,
-                PaymentProvider::STATUS_AUTHORIZED])
+                    PaymentProvider::STATUS_OPEN,
+                    PaymentProvider::STATUS_PENDING,
+                    PaymentProvider::STATUS_AUTHORIZED
+            ])
             ->orderBy('created_at', 'desc')
             ->limit($pageSize + 1)
             ->offset($pageSize * ($page - 1))
             ->get();
 
         if (count($result) > $pageSize) {
             $result->pop();
             $hasMore = true;
         }
 
         $result = $result->map(function ($item) use ($wallet) {
             $provider = PaymentProvider::factory($item->provider);
             $payment = $provider->getPayment($item->id);
             $entry = [
                 'id' => $item->id,
                 'createdAt' => $item->created_at->format('Y-m-d H:i'),
                 'type' => $item->type,
                 'description' => $item->description,
                 'amount' => $item->amount,
                 'currency' => $wallet->currency,
                 // note: $item->currency/$item->currency_amount might be different
                 'status' => $item->status,
                 'isCancelable' => $payment['isCancelable'],
                 'checkoutUrl' => $payment['checkoutUrl']
             ];
 
             return $entry;
         });
 
         return response()->json([
             'status' => 'success',
             'list' => $result,
             'count' => count($result),
             'hasMore' => $hasMore,
             'page' => $page,
         ]);
     }
 }
diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php
index f87417da..b511d406 100644
--- a/src/app/Http/Controllers/API/V4/UsersController.php
+++ b/src/app/Http/Controllers/API/V4/UsersController.php
@@ -1,835 +1,878 @@
 <?php
 
 namespace App\Http\Controllers\API\V4;
 
 use App\Http\Controllers\Controller;
 use App\Domain;
 use App\Group;
 use App\Rules\UserEmailDomain;
 use App\Rules\UserEmailLocal;
 use App\Sku;
 use App\User;
 use Carbon\Carbon;
 use Illuminate\Http\Request;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Validator;
 use Illuminate\Support\Str;
 
 class UsersController extends Controller
 {
     /** @const array List of user setting keys available for modification in UI */
     public const USER_SETTINGS = [
         'billing_address',
         'country',
         'currency',
         'external_email',
         'first_name',
         'last_name',
         'organization',
         'phone',
     ];
 
     /**
      * On user create it is filled with a user or group object to force-delete
      * before the creation of a new user record is possible.
      *
      * @var \App\User|\App\Group|null
      */
     protected $deleteBeforeCreate;
 
 
     /**
      * Delete a user.
      *
      * @param int $id User identifier
      *
      * @return \Illuminate\Http\JsonResponse The response
      */
     public function destroy($id)
     {
         $user = User::withEnvTenantContext()->find($id);
 
         if (empty($user)) {
             return $this->errorResponse(404);
         }
 
         // User can't remove himself until he's the controller
         if (!$this->guard()->user()->canDelete($user)) {
             return $this->errorResponse(403);
         }
 
         $user->delete();
 
         return response()->json([
                 'status' => 'success',
                 'message' => \trans('app.user-delete-success'),
         ]);
     }
 
     /**
      * Listing of users.
      *
      * The user-entitlements billed to the current user wallet(s)
      *
      * @return \Illuminate\Http\JsonResponse
      */
     public function index()
     {
         $user = $this->guard()->user();
+        $search = trim(request()->input('search'));
+        $page = intval(request()->input('page')) ?: 1;
+        $pageSize = 20;
+        $hasMore = false;
+
+        $result = $user->users();
+
+        // Search by user email, alias or name
+        if (strlen($search) > 0) {
+            // thanks to cloning we skip some extra queries in $user->users()
+            $allUsers1 = clone $result;
+            $allUsers2 = clone $result;
+
+            $result->whereLike('email', $search)
+                ->union(
+                    $allUsers1->join('user_aliases', 'users.id', '=', 'user_aliases.user_id')
+                        ->whereLike('alias', $search)
+                )
+                ->union(
+                    $allUsers2->join('user_settings', 'users.id', '=', 'user_settings.user_id')
+                        ->whereLike('value', $search)
+                        ->whereIn('key', ['first_name', 'last_name'])
+                );
+        }
+
+        $result = $result->orderBy('email')
+            ->limit($pageSize + 1)
+            ->offset($pageSize * ($page - 1))
+            ->get();
+
+        if (count($result) > $pageSize) {
+            $result->pop();
+            $hasMore = true;
+        }
+
+        // Process the result
+        $result = $result->map(
+            function ($user) {
+                $data = $user->toArray();
+                $data = array_merge($data, self::userStatuses($user));
+                return $data;
+            }
+        );
 
-        $result = $user->users()->orderBy('email')->get()->map(function ($user) {
-            $data = $user->toArray();
-            $data = array_merge($data, self::userStatuses($user));
-            return $data;
-        });
+        $result = [
+            'list' => $result,
+            'count' => count($result),
+            'hasMore' => $hasMore,
+        ];
 
         return response()->json($result);
     }
 
     /**
      * Set user config.
      *
      * @param int $id The user
      *
      * @return \Illuminate\Http\JsonResponse
      */
     public function setConfig($id)
     {
         $user = User::find($id);
 
         if (empty($user)) {
             return $this->errorResponse(404);
         }
 
         if (!$this->guard()->user()->canRead($user)) {
             return $this->errorResponse(403);
         }
 
         $errors = $user->setConfig(request()->input());
 
         if (!empty($errors)) {
             return response()->json(['status' => 'error', 'errors' => $errors], 422);
         }
 
         return response()->json([
                 'status' => 'success',
                 'message' => \trans('app.user-setconfig-success'),
         ]);
     }
 
     /**
      * Display information on the user account specified by $id.
      *
      * @param int $id The account to show information for.
      *
      * @return \Illuminate\Http\JsonResponse
      */
     public function show($id)
     {
         $user = User::withEnvTenantContext()->find($id);
 
         if (empty($user)) {
             return $this->errorResponse(404);
         }
 
         if (!$this->guard()->user()->canRead($user)) {
             return $this->errorResponse(403);
         }
 
         $response = $this->userResponse($user);
 
         $response['skus'] = \App\Entitlement::objectEntitlementsSummary($user);
         $response['config'] = $user->getConfig();
 
         return response()->json($response);
     }
 
     /**
      * Fetch user status (and reload setup process)
      *
      * @param int $id User identifier
      *
      * @return \Illuminate\Http\JsonResponse
      */
     public function status($id)
     {
         $user = User::withEnvTenantContext()->find($id);
 
         if (empty($user)) {
             return $this->errorResponse(404);
         }
 
         if (!$this->guard()->user()->canRead($user)) {
             return $this->errorResponse(403);
         }
 
         $response = self::statusInfo($user);
 
         if (!empty(request()->input('refresh'))) {
             $updated = false;
             $async = false;
             $last_step = 'none';
 
             foreach ($response['process'] as $idx => $step) {
                 $last_step = $step['label'];
 
                 if (!$step['state']) {
                     $exec = $this->execProcessStep($user, $step['label']);
 
                     if (!$exec) {
                         if ($exec === null) {
                             $async = true;
                         }
 
                         break;
                     }
 
                     $updated = true;
                 }
             }
 
             if ($updated) {
                 $response = self::statusInfo($user);
             }
 
             $success = $response['isReady'];
             $suffix = $success ? 'success' : 'error-' . $last_step;
 
             $response['status'] = $success ? 'success' : 'error';
             $response['message'] = \trans('app.process-' . $suffix);
 
             if ($async && !$success) {
                 $response['processState'] = 'waiting';
                 $response['status'] = 'success';
                 $response['message'] = \trans('app.process-async');
             }
         }
 
         $response = array_merge($response, self::userStatuses($user));
 
         return response()->json($response);
     }
 
     /**
      * User status (extended) information
      *
      * @param \App\User $user User object
      *
      * @return array Status information
      */
     public static function statusInfo(User $user): array
     {
         $process = [];
         $steps = [
             'user-new' => true,
             'user-ldap-ready' => $user->isLdapReady(),
             'user-imap-ready' => $user->isImapReady(),
         ];
 
         // Create a process check list
         foreach ($steps as $step_name => $state) {
             $step = [
                 'label' => $step_name,
                 'title' => \trans("app.process-{$step_name}"),
                 'state' => $state,
             ];
 
             $process[] = $step;
         }
 
         list ($local, $domain) = explode('@', $user->email);
         $domain = Domain::where('namespace', $domain)->first();
 
         // If that is not a public domain, add domain specific steps
         if ($domain && !$domain->isPublic()) {
             $domain_status = DomainsController::statusInfo($domain);
             $process = array_merge($process, $domain_status['process']);
         }
 
         $all = count($process);
         $checked = count(array_filter($process, function ($v) {
                 return $v['state'];
         }));
 
         $state = $all === $checked ? 'done' : 'running';
 
         // After 180 seconds assume the process is in failed state,
         // this should unlock the Refresh button in the UI
         if ($all !== $checked && $user->created_at->diffInSeconds(Carbon::now()) > 180) {
             $state = 'failed';
         }
 
         // Check if the user is a controller of his wallet
         $isController = $user->canDelete($user);
         $hasCustomDomain = $user->wallet()->entitlements()
             ->where('entitleable_type', Domain::class)
             ->count() > 0;
 
         // Get user's entitlements titles
         $skus = $user->entitlements()->select('skus.title')
             ->join('skus', 'skus.id', '=', 'entitlements.sku_id')
             ->get()
             ->pluck('title')
             ->sort()
             ->unique()
             ->values()
             ->all();
 
         return [
             'skus' => $skus,
             // TODO: This will change when we enable all users to create domains
             'enableDomains' => $isController && $hasCustomDomain,
             // TODO: Make 'enableDistlists' working for wallet controllers that aren't account owners
             'enableDistlists' => $isController && $hasCustomDomain && in_array('distlist', $skus),
             'enableUsers' => $isController,
             'enableWallets' => $isController,
             'process' => $process,
             'processState' => $state,
             'isReady' => $all === $checked,
         ];
     }
 
     /**
      * Create a new user record.
      *
      * @param \Illuminate\Http\Request $request The API request.
      *
      * @return \Illuminate\Http\JsonResponse The response
      */
     public function store(Request $request)
     {
         $current_user = $this->guard()->user();
         $owner = $current_user->wallet()->owner;
 
         if ($owner->id != $current_user->id) {
             return $this->errorResponse(403);
         }
 
         $this->deleteBeforeCreate = null;
 
         if ($error_response = $this->validateUserRequest($request, null, $settings)) {
             return $error_response;
         }
 
         if (empty($request->package) || !($package = \App\Package::withEnvTenantContext()->find($request->package))) {
             $errors = ['package' => \trans('validation.packagerequired')];
             return response()->json(['status' => 'error', 'errors' => $errors], 422);
         }
 
         if ($package->isDomain()) {
             $errors = ['package' => \trans('validation.packageinvalid')];
             return response()->json(['status' => 'error', 'errors' => $errors], 422);
         }
 
         DB::beginTransaction();
 
         // @phpstan-ignore-next-line
         if ($this->deleteBeforeCreate) {
             $this->deleteBeforeCreate->forceDelete();
         }
 
         // Create user record
         $user = User::create([
                 'email' => $request->email,
                 'password' => $request->password,
         ]);
 
         $owner->assignPackage($package, $user);
 
         if (!empty($settings)) {
             $user->setSettings($settings);
         }
 
         if (!empty($request->aliases)) {
             $user->setAliases($request->aliases);
         }
 
         DB::commit();
 
         return response()->json([
                 'status' => 'success',
                 'message' => \trans('app.user-create-success'),
         ]);
     }
 
     /**
      * Update user data.
      *
      * @param \Illuminate\Http\Request $request The API request.
      * @param string                   $id      User identifier
      *
      * @return \Illuminate\Http\JsonResponse The response
      */
     public function update(Request $request, $id)
     {
         $user = User::withEnvTenantContext()->find($id);
 
         if (empty($user)) {
             return $this->errorResponse(404);
         }
 
         $current_user = $this->guard()->user();
 
         // TODO: Decide what attributes a user can change on his own profile
         if (!$current_user->canUpdate($user)) {
             return $this->errorResponse(403);
         }
 
         if ($error_response = $this->validateUserRequest($request, $user, $settings)) {
             return $error_response;
         }
 
         // Entitlements, only controller can do that
         if ($request->skus !== null && !$current_user->canDelete($user)) {
             return $this->errorResponse(422, "You have no permission to change entitlements");
         }
 
         DB::beginTransaction();
 
         $this->updateEntitlements($user, $request->skus);
 
         if (!empty($settings)) {
             $user->setSettings($settings);
         }
 
         if (!empty($request->password)) {
             $user->password = $request->password;
             $user->save();
         }
 
         if (isset($request->aliases)) {
             $user->setAliases($request->aliases);
         }
 
         // TODO: Make sure that UserUpdate job is created in case of entitlements update
         //       and no password change. So, for example quota change is applied to LDAP
         // TODO: Review use of $user->save() in the above context
 
         DB::commit();
 
         $response = [
             'status' => 'success',
             'message' => \trans('app.user-update-success'),
         ];
 
         // For self-update refresh the statusInfo in the UI
         if ($user->id == $current_user->id) {
             $response['statusInfo'] = self::statusInfo($user);
         }
 
         return response()->json($response);
     }
 
     /**
      * Update user entitlements.
      *
      * @param \App\User $user  The user
      * @param array     $rSkus List of SKU IDs requested for the user in the form [id=>qty]
      */
     protected function updateEntitlements(User $user, $rSkus)
     {
         if (!is_array($rSkus)) {
             return;
         }
 
         // list of skus, [id=>obj]
         $skus = Sku::withEnvTenantContext()->get()->mapWithKeys(
             function ($sku) {
                 return [$sku->id => $sku];
             }
         );
 
         // existing entitlement's SKUs
         $eSkus = [];
 
         $user->entitlements()->groupBy('sku_id')
             ->selectRaw('count(*) as total, sku_id')->each(
                 function ($e) use (&$eSkus) {
                     $eSkus[$e->sku_id] = $e->total;
                 }
             );
 
         foreach ($skus as $skuID => $sku) {
             $e = array_key_exists($skuID, $eSkus) ? $eSkus[$skuID] : 0;
             $r = array_key_exists($skuID, $rSkus) ? $rSkus[$skuID] : 0;
 
             if ($sku->handler_class == \App\Handlers\Mailbox::class) {
                 if ($r != 1) {
                     throw new \Exception("Invalid quantity of mailboxes");
                 }
             }
 
             if ($e > $r) {
                 // remove those entitled more than existing
                 $user->removeSku($sku, ($e - $r));
             } elseif ($e < $r) {
                 // add those requested more than entitled
                 $user->assignSku($sku, ($r - $e));
             }
         }
     }
 
     /**
      * Create a response data array for specified user.
      *
      * @param \App\User $user User object
      *
      * @return array Response data
      */
     public static function userResponse(User $user): array
     {
         $response = $user->toArray();
 
         // Settings
         $response['settings'] = [];
         foreach ($user->settings()->whereIn('key', self::USER_SETTINGS)->get() as $item) {
             $response['settings'][$item->key] = $item->value;
         }
 
         // Aliases
         $response['aliases'] = [];
         foreach ($user->aliases as $item) {
             $response['aliases'][] = $item->alias;
         }
 
         // Status info
         $response['statusInfo'] = self::statusInfo($user);
 
         $response = array_merge($response, self::userStatuses($user));
 
         // Add more info to the wallet object output
         $map_func = function ($wallet) use ($user) {
             $result = $wallet->toArray();
 
             if ($wallet->discount) {
                 $result['discount'] = $wallet->discount->discount;
                 $result['discount_description'] = $wallet->discount->description;
             }
 
             if ($wallet->user_id != $user->id) {
                 $result['user_email'] = $wallet->owner->email;
             }
 
             $provider = \App\Providers\PaymentProvider::factory($wallet);
             $result['provider'] = $provider->name();
 
             return $result;
         };
 
         // Information about wallets and accounts for access checks
         $response['wallets'] = $user->wallets->map($map_func)->toArray();
         $response['accounts'] = $user->accounts->map($map_func)->toArray();
         $response['wallet'] = $map_func($user->wallet());
 
         return $response;
     }
 
     /**
      * Prepare user statuses for the UI
      *
      * @param \App\User $user User object
      *
      * @return array Statuses array
      */
     protected static function userStatuses(User $user): array
     {
         return [
             'isImapReady' => $user->isImapReady(),
             'isLdapReady' => $user->isLdapReady(),
             'isSuspended' => $user->isSuspended(),
             'isActive' => $user->isActive(),
             'isDeleted' => $user->isDeleted() || $user->trashed(),
         ];
     }
 
     /**
      * Validate user input
      *
      * @param \Illuminate\Http\Request $request  The API request.
      * @param \App\User|null           $user     User identifier
      * @param array                    $settings User settings (from the request)
      *
      * @return \Illuminate\Http\JsonResponse|null The error response on error
      */
     protected function validateUserRequest(Request $request, $user, &$settings = [])
     {
         $rules = [
             'external_email' => 'nullable|email',
             'phone' => 'string|nullable|max:64|regex:/^[0-9+() -]+$/',
             'first_name' => 'string|nullable|max:128',
             'last_name' => 'string|nullable|max:128',
             'organization' => 'string|nullable|max:512',
             'billing_address' => 'string|nullable|max:1024',
             'country' => 'string|nullable|alpha|size:2',
             'currency' => 'string|nullable|alpha|size:3',
             'aliases' => 'array|nullable',
         ];
 
         if (empty($user) || !empty($request->password) || !empty($request->password_confirmation)) {
             $rules['password'] = 'required|min:4|max:2048|confirmed';
         }
 
         $errors = [];
 
         // Validate input
         $v = Validator::make($request->all(), $rules);
 
         if ($v->fails()) {
             $errors = $v->errors()->toArray();
         }
 
         $controller = $user ? $user->wallet()->owner : $this->guard()->user();
 
         // For new user validate email address
         if (empty($user)) {
             $email = $request->email;
 
             if (empty($email)) {
                 $errors['email'] = \trans('validation.required', ['attribute' => 'email']);
             } elseif ($error = self::validateEmail($email, $controller, $this->deleteBeforeCreate)) {
                 $errors['email'] = $error;
             }
         }
 
         // Validate aliases input
         if (isset($request->aliases)) {
             $aliases = [];
             $existing_aliases = $user ? $user->aliases()->get()->pluck('alias')->toArray() : [];
 
             foreach ($request->aliases as $idx => $alias) {
                 if (is_string($alias) && !empty($alias)) {
                     // Alias cannot be the same as the email address (new user)
                     if (!empty($email) && Str::lower($alias) == Str::lower($email)) {
                         continue;
                     }
 
                     // validate new aliases
                     if (
                         !in_array($alias, $existing_aliases)
                         && ($error = self::validateAlias($alias, $controller))
                     ) {
                         if (!isset($errors['aliases'])) {
                             $errors['aliases'] = [];
                         }
                         $errors['aliases'][$idx] = $error;
                         continue;
                     }
 
                     $aliases[] = $alias;
                 }
             }
 
             $request->aliases = $aliases;
         }
 
         if (!empty($errors)) {
             return response()->json(['status' => 'error', 'errors' => $errors], 422);
         }
 
         // Update user settings
         $settings = $request->only(array_keys($rules));
         unset($settings['password'], $settings['aliases'], $settings['email']);
 
         return null;
     }
 
     /**
      * Execute (synchronously) specified step in a user setup process.
      *
      * @param \App\User $user User object
      * @param string    $step Step identifier (as in self::statusInfo())
      *
      * @return bool|null True if the execution succeeded, False if not, Null when
      *                   the job has been sent to the worker (result unknown)
      */
     public static function execProcessStep(User $user, string $step): ?bool
     {
         try {
             if (strpos($step, 'domain-') === 0) {
                 list ($local, $domain) = explode('@', $user->email);
                 $domain = Domain::where('namespace', $domain)->first();
 
                 return DomainsController::execProcessStep($domain, $step);
             }
 
             switch ($step) {
                 case 'user-ldap-ready':
                     // User not in LDAP, create it
                     $job = new \App\Jobs\User\CreateJob($user->id);
                     $job->handle();
 
                     $user->refresh();
 
                     return $user->isLdapReady();
 
                 case 'user-imap-ready':
                     // User not in IMAP? Verify again
                     // Do it synchronously if the imap admin credentials are available
                     // otherwise let the worker do the job
                     if (!\config('imap.admin_password')) {
                         \App\Jobs\User\VerifyJob::dispatch($user->id);
 
                         return null;
                     }
 
                     $job = new \App\Jobs\User\VerifyJob($user->id);
                     $job->handle();
 
                     $user->refresh();
 
                     return $user->isImapReady();
             }
         } catch (\Exception $e) {
             \Log::error($e);
         }
 
         return false;
     }
 
     /**
      * Email address validation for use as a user mailbox (login).
      *
      * @param string                    $email   Email address
      * @param \App\User                 $user    The account owner
      * @param null|\App\User|\App\Group $deleted Filled with an instance of a deleted user or group
      *                                           with the specified email address, if exists
      *
      * @return ?string Error message on validation error
      */
     public static function validateEmail(string $email, \App\User $user, &$deleted = null): ?string
     {
         $deleted = null;
 
         if (strpos($email, '@') === false) {
             return \trans('validation.entryinvalid', ['attribute' => 'email']);
         }
 
         list($login, $domain) = explode('@', Str::lower($email));
 
         if (strlen($login) === 0 || strlen($domain) === 0) {
             return \trans('validation.entryinvalid', ['attribute' => 'email']);
         }
 
         // Check if domain exists
         $domain = Domain::withEnvTenantContext()->where('namespace', $domain)->first();
 
         if (empty($domain)) {
             return \trans('validation.domaininvalid');
         }
 
         // Validate login part alone
         $v = Validator::make(
             ['email' => $login],
             ['email' => ['required', new UserEmailLocal(!$domain->isPublic())]]
         );
 
         if ($v->fails()) {
             return $v->errors()->toArray()['email'][0];
         }
 
         // Check if it is one of domains available to the user
         $domains = \collect($user->domains())->pluck('namespace')->all();
 
         if (!in_array($domain->namespace, $domains)) {
             return \trans('validation.entryexists', ['attribute' => 'domain']);
         }
 
         // Check if a user with specified address already exists
         if ($existing_user = User::emailExists($email, true)) {
             // If this is a deleted user in the same custom domain
             // we'll force delete him before
             if (!$domain->isPublic() && $existing_user->trashed()) {
                 $deleted = $existing_user;
             } else {
                 return \trans('validation.entryexists', ['attribute' => 'email']);
             }
         }
 
         // Check if an alias with specified address already exists.
         if (User::aliasExists($email)) {
             return \trans('validation.entryexists', ['attribute' => 'email']);
         }
 
         // Check if a group with specified address already exists
         if ($existing_group = Group::emailExists($email, true)) {
             // If this is a deleted group in the same custom domain
             // we'll force delete it before
             if (!$domain->isPublic() && $existing_group->trashed()) {
                 $deleted = $existing_group;
             } else {
                 return \trans('validation.entryexists', ['attribute' => 'email']);
             }
         }
 
         return null;
     }
 
     /**
      * Email address validation for use as an alias.
      *
      * @param string    $email Email address
      * @param \App\User $user  The account owner
      *
      * @return ?string Error message on validation error
      */
     public static function validateAlias(string $email, \App\User $user): ?string
     {
         if (strpos($email, '@') === false) {
             return \trans('validation.entryinvalid', ['attribute' => 'alias']);
         }
 
         list($login, $domain) = explode('@', Str::lower($email));
 
         if (strlen($login) === 0 || strlen($domain) === 0) {
             return \trans('validation.entryinvalid', ['attribute' => 'alias']);
         }
 
         // Check if domain exists
         $domain = Domain::withEnvTenantContext()->where('namespace', $domain)->first();
 
         if (empty($domain)) {
             return \trans('validation.domaininvalid');
         }
 
         // Validate login part alone
         $v = Validator::make(
             ['alias' => $login],
             ['alias' => ['required', new UserEmailLocal(!$domain->isPublic())]]
         );
 
         if ($v->fails()) {
             return $v->errors()->toArray()['alias'][0];
         }
 
         // Check if it is one of domains available to the user
         $domains = \collect($user->domains())->pluck('namespace')->all();
 
         if (!in_array($domain->namespace, $domains)) {
             return \trans('validation.entryexists', ['attribute' => 'domain']);
         }
 
         // Check if a user with specified address already exists
         if ($existing_user = User::emailExists($email, true)) {
             // Allow an alias in a custom domain to an address that was a user before
             if ($domain->isPublic() || !$existing_user->trashed()) {
                 return \trans('validation.entryexists', ['attribute' => 'alias']);
             }
         }
 
         // Check if an alias with specified address already exists
         if (User::aliasExists($email)) {
             // Allow assigning the same alias to a user in the same group account,
             // but only for non-public domains
             if ($domain->isPublic()) {
                 return \trans('validation.entryexists', ['attribute' => 'alias']);
             }
         }
 
         // Check if a group with specified address already exists
         if (Group::emailExists($email)) {
             return \trans('validation.entryexists', ['attribute' => 'alias']);
         }
 
         return null;
     }
 }
diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php
index 8c13a1f2..41b54ff5 100644
--- a/src/resources/lang/en/ui.php
+++ b/src/resources/lang/en/ui.php
@@ -1,430 +1,431 @@
 <?php
 
 /**
  * This file will be converted to a Vue-i18n compatible JSON format on build time
  *
  * Note: The Laravel localization features do not work here. Vue-i18n rules are different
  */
 
 return [
 
     'app' => [
         'faq' => "FAQ",
     ],
 
     'btn' => [
         'add' => "Add",
         'accept' => "Accept",
         'back' => "Back",
         'cancel' => "Cancel",
         'close' => "Close",
         'continue' => "Continue",
         'delete' => "Delete",
         'deny' => "Deny",
         'download' => "Download",
         'edit' => "Edit",
         'file' => "Choose file...",
         'moreinfo' => "More information",
         'refresh' => "Refresh",
         'reset' => "Reset",
         'resend' => "Resend",
         'save' => "Save",
         'search' => "Search",
         'signup' => "Sign Up",
         'submit' => "Submit",
         'suspend' => "Suspend",
         'unsuspend' => "Unsuspend",
         'verify' => "Verify",
     ],
 
     'dashboard' => [
         'beta' => "beta",
         'distlists' => "Distribution lists",
         'chat' => "Video chat",
         'domains' => "Domains",
         'invitations' => "Invitations",
         'profile' => "Your profile",
         'users' => "User accounts",
         'wallet' => "Wallet",
         'webmail' => "Webmail",
         'stats' => "Stats",
     ],
 
     'distlist' => [
         'list-title' => "Distribution list | Distribution lists",
         'create' => "Create list",
         'delete' => "Delete list",
         'email' => "Email",
         'list-empty' => "There are no distribution lists in this account.",
         'new' => "New distribution list",
         'recipients' => "Recipients",
     ],
 
     'domain' => [
         'delete' => "Delete domain",
         'delete-domain' => "Delete {domain}",
         'delete-text' => "Do you really want to delete this domain permanently?"
             . " This is only possible if there are no users, aliases or other objects in this domain."
             . " Please note that this action cannot be undone.",
         'dns-verify' => "Domain DNS verification sample:",
         'dns-config' => "Domain DNS configuration sample:",
         'namespace' => "Namespace",
         'spf-whitelist' => "SPF Whitelist",
         'spf-whitelist-text' => "The Sender Policy Framework allows a sender domain to disclose, through DNS, "
             . "which systems are allowed to send emails with an envelope sender address within said domain.",
         'spf-whitelist-ex' => "Here you can specify a list of allowed servers, for example: <var>.ess.barracuda.com</var>.",
         'verify' => "Domain verification",
         'verify-intro' => "In order to confirm that you're the actual holder of the domain, we need to run a verification process before finally activating it for email delivery.",
         'verify-dns' => "The domain <b>must have one of the following entries</b> in DNS:",
         'verify-dns-txt' => "TXT entry with value:",
         'verify-dns-cname' => "or CNAME entry:",
         'verify-outro' => "When this is done press the button below to start the verification.",
         'verify-sample' => "Here's a sample zone file for your domain:",
         'config' => "Domain configuration",
         'config-intro' => "In order to let {app} receive email traffic for your domain you need to adjust the DNS settings, more precisely the MX entries, accordingly.",
         'config-sample' => "Edit your domain's zone file and replace existing MX entries with the following values:",
         'config-hint' => "If you don't know how to set DNS entries for your domain, please contact the registration service where you registered the domain or your web hosting provider.",
         'create' => "Create domain",
         'new' => "New domain",
     ],
 
     'error' => [
         '400' => "Bad request",
         '401' => "Unauthorized",
         '403' => "Access denied",
         '404' => "Not found",
         '405' => "Method not allowed",
         '500' => "Internal server error",
         'unknown' => "Unknown Error",
         'server' => "Server Error",
         'form' => "Form validation error",
     ],
 
     'form' => [
         'amount' => "Amount",
         'code' => "Confirmation Code",
         'config' => "Configuration",
         'date' => "Date",
         'description' => "Description",
         'details' => "Details",
         'disabled' => "disabled",
         'domain' => "Domain",
         'email' => "Email Address",
         'enabled' => "enabled",
         'firstname' => "First Name",
         'general' => "General",
         'lastname' => "Last Name",
         'none' => "none",
         'or' => "or",
         'password' => "Password",
         'password-confirm' => "Confirm Password",
         'phone' => "Phone",
         'settings' => "Settings",
         'status' => "Status",
         'surname' => "Surname",
         'user' => "User",
         'primary-email' => "Primary Email",
         'id' => "ID",
         'created' => "Created",
         'deleted' => "Deleted",
     ],
 
     'invitation' => [
         'create' => "Create invite(s)",
         'create-title' => "Invite for a signup",
         'create-email' => "Enter an email address of the person you want to invite.",
         'create-csv' => "To send multiple invitations at once, provide a CSV (comma separated) file, or alternatively a plain-text file, containing one email address per line.",
         'empty-list' => "There are no invitations in the database.",
         'title' => "Signup invitations",
         'search' => "Email address or domain",
         'send' => "Send invite(s)",
         'status-completed' => "User signed up",
         'status-failed' => "Sending failed",
         'status-sent' => "Sent",
         'status-new' => "Not sent yet",
     ],
 
     'lang' => [
         'en' => "English",
         'de' => "German",
         'fr' => "French",
         'it' => "Italian",
     ],
 
     'login' => [
         '2fa' => "Second factor code",
         '2fa_desc' => "Second factor code is optional for users with no 2-Factor Authentication setup.",
         'forgot_password' => "Forgot password?",
         'header' => "Please sign in",
         'sign_in' => "Sign in",
         'webmail' => "Webmail"
     ],
 
     'meet' => [
         'title' => "Voice & Video Conferencing",
         'welcome' => "Welcome to our beta program for Voice & Video Conferencing.",
         'url' => "You have a room of your own at the URL below. This room is only open when you yourself are in attendance. Use this URL to invite people to join you.",
         'notice' => "This is a work in progress and more features will be added over time. Current features include:",
         'sharing' => "Screen Sharing",
         'sharing-text' => "Share your screen for presentations or show-and-tell.",
         'security' => "Room Security",
         'security-text' => "Increase the room security by setting a password that attendees will need to know"
             . " before they can enter, or lock the door so attendees will have to knock, and a moderator can accept or deny those requests.",
         'qa' => "Raise Hand (Q&A)",
         'qa-text' => "Silent audience members can raise their hand to facilitate a Question & Answer session with the panel members.",
         'moderation' => "Moderator Delegation",
         'moderation-text' => "Delegate moderator authority for the session, so that a speaker is not needlessly"
             . " interrupted with attendees knocking and other moderator duties.",
         'eject' => "Eject Attendees",
         'eject-text' => "Eject attendees from the session in order to force them to reconnect, or address policy"
             . " violations. Click the user icon for effective dismissal.",
         'silent' => "Silent Audience Members",
         'silent-text' => "For a webinar-style session, configure the room to force all new attendees to be silent audience members.",
         'interpreters' => "Language Specific Audio Channels",
         'interpreters-text' => "Designate a participant to interpret the original audio to a target language, for sessions"
             . " with multi-lingual attendees. The interpreter is expected to be able to relay the original audio, and override it.",
         'beta-notice' => "Keep in mind that this is still in beta and might come with some issues."
             . " Should you encounter any on your way, let us know by contacting support.",
 
         // Room options dialog
         'options' => "Room options",
         'password' => "Password",
         'password-none' => "none",
         'password-clear' => "Clear password",
         'password-set' => "Set password",
         'password-text' => "You can add a password to your meeting. Participants will have to provide the password before they are allowed to join the meeting.",
         'lock' => "Locked room",
         'lock-text' => "When the room is locked participants have to be approved by a moderator before they could join the meeting.",
         'nomedia' => "Subscribers only",
         'nomedia-text' => "Forces all participants to join as subscribers (with camera and microphone turned off)."
             . " Moderators will be able to promote them to publishers throughout the session.",
 
         // Room menu
         'partcnt' => "Number of participants",
         'menu-audio-mute' => "Mute audio",
         'menu-audio-unmute' => "Unmute audio",
         'menu-video-mute' => "Mute video",
         'menu-video-unmute' => "Unmute video",
         'menu-screen' => "Share screen",
         'menu-hand-lower' => "Lower hand",
         'menu-hand-raise' => "Raise hand",
         'menu-channel' => "Interpreted language channel",
         'menu-chat' => "Chat",
         'menu-fullscreen' => "Full screen",
         'menu-fullscreen-exit' => "Exit full screen",
         'menu-leave' => "Leave session",
 
         // Room setup screen
         'setup-title' => "Set up your session",
         'mic' => "Microphone",
         'cam' => "Camera",
         'nick' => "Nickname",
         'nick-placeholder' => "Your name",
         'join' => "JOIN",
         'joinnow' => "JOIN NOW",
         'imaowner' => "I'm the owner",
 
         // Room
         'qa' => "Q & A",
         'leave-title' => "Room closed",
         'leave-body' => "The session has been closed by the room owner.",
         'media-title' => "Media setup",
         'join-request' => "Join request",
         'join-requested' => "{user} requested to join.",
 
         // Status messages
         'status-init' => "Checking the room...",
         'status-323' => "The room is closed. Please, wait for the owner to start the session.",
         'status-324' => "The room is closed. It will be open for others after you join.",
         'status-325' => "The room is ready. Please, provide a valid password.",
         'status-326' => "The room is locked. Please, enter your name and try again.",
         'status-327' => "Waiting for permission to join the room.",
         'status-404' => "The room does not exist.",
         'status-429' => "Too many requests. Please, wait.",
         'status-500' => "Failed to connect to the room. Server error.",
 
         // Other menus
         'media-setup' => "Media setup",
         'perm' => "Permissions",
         'perm-av' => "Audio &amp; Video publishing",
         'perm-mod' => "Moderation",
         'lang-int' => "Language interpreter",
         'menu-options' => "Options",
     ],
 
     'menu' => [
         'cockpit' => "Cockpit",
         'login' => "Login",
         'logout' => "Logout",
         'signup' => "Signup",
         'toggle' => "Toggle navigation",
     ],
 
     'msg' => [
         'initializing' => "Initializing...",
         'loading' => "Loading...",
         'loading-failed' => "Failed to load data.",
         'notfound' => "Resource not found.",
         'info' => "Information",
         'error' => "Error",
         'warning' => "Warning",
         'success' => "Success",
     ],
 
     'nav' => [
         'more' => "Load more",
         'step' => "Step {i}/{n}",
     ],
 
     'password' => [
         'reset' => "Password Reset",
         'reset-step1' => "Enter your email address to reset your password.",
         'reset-step1-hint' => "You may need to check your spam folder or unblock {email}.",
         'reset-step2' => "We sent out a confirmation code to your external email address."
             . " Enter the code we sent you, or click the link in the message.",
     ],
 
     'signup' => [
         'email' => "Existing Email Address",
         'login' => "Login",
         'title' => "Sign Up",
         'step1' => "Sign up to start your free month.",
         'step2' => "We sent out a confirmation code to your email address. Enter the code we sent you, or click the link in the message.",
         'step3' => "Create your Kolab identity (you can choose additional addresses later).",
         'voucher' => "Voucher Code",
     ],
 
     'status' => [
         'prepare-account' => "We are preparing your account.",
         'prepare-domain' => "We are preparing the domain.",
         'prepare-distlist' => "We are preparing the distribution list.",
         'prepare-user' => "We are preparing the user account.",
         'prepare-hint' => "Some features may be missing or readonly at the moment.",
         'prepare-refresh' => "The process never ends? Press the \"Refresh\" button, please.",
         'ready-account' => "Your account is almost ready.",
         'ready-domain' => "The domain is almost ready.",
         'ready-distlist' => "The distribution list is almost ready.",
         'ready-user' => "The user account is almost ready.",
         'verify' => "Verify your domain to finish the setup process.",
         'verify-domain' => "Verify domain",
         'deleted' => "Deleted",
         'suspended' => "Suspended",
         'notready' => "Not Ready",
         'active' => "Active",
     ],
 
     'support' => [
         'title' => "Contact Support",
         'id' => "Customer number or email address you have with us",
         'id-pl' => "e.g. 12345678 or john@kolab.org",
         'id-hint' => "Leave blank if you are not a customer yet",
         'name' => "Name",
         'name-pl' => "how we should call you in our reply",
         'email' => "Working email address",
         'email-pl' => "make sure we can reach you at this address",
         'summary' => "Issue Summary",
         'summary-pl' => "one sentence that summarizes your issue",
         'expl' => "Issue Explanation",
     ],
 
     'user' => [
         '2fa-hint1' => "This will remove 2-Factor Authentication entitlement as well as the user-configured factors.",
         '2fa-hint2' => "Please, make sure to confirm the user identity properly.",
         'add-beta' => "Enable beta program",
         'address' => "Address",
         'aliases' => "Aliases",
         'aliases-email' => "Email Aliases",
         'aliases-none' => "This user has no email aliases.",
         'add-bonus' => "Add bonus",
         'add-bonus-title' => "Add a bonus to the wallet",
         'add-penalty' => "Add penalty",
         'add-penalty-title' => "Add a penalty to the wallet",
         'auto-payment' => "Auto-payment",
         'auto-payment-text' => "Fill up by <b>{amount}</b> when under <b>{balance}</b> using {method}",
         'country' => "Country",
         'create' => "Create user",
         'custno' => "Customer No.",
         'delete' => "Delete user",
         'delete-account' => "Delete this account?",
         'delete-email' => "Delete {email}",
         'delete-text' => "Do you really want to delete this user permanently?"
             . " This will delete all account data and withdraw the permission to access the email account."
             . " Please note that this action cannot be undone.",
         'discount' => "Discount",
         'discount-hint' => "applied discount",
         'discount-title' => "Account discount",
         'distlists' => "Distribution lists",
         'distlists-none' => "There are no distribution lists in this account.",
         'domains' => "Domains",
         'domains-none' => "There are no domains in this account.",
         'ext-email' => "External Email",
         'finances' => "Finances",
         'greylisting' => "Greylisting",
         'greylisting-text' => "Greylisting is a method of defending users against spam. Any incoming mail from an unrecognized sender "
             . "is temporarily rejected. The originating server should try again after a delay. "
             . "This time the email will be accepted. Spammers usually do not reattempt mail delivery.",
         'list-title' => "User accounts",
         'managed-by' => "Managed by",
         'new' => "New user account",
         'org' => "Organization",
         'package' => "Package",
         'price' => "Price",
         'profile-title' => "Your profile",
         'profile-delete' => "Delete account",
         'profile-delete-title' => "Delete this account?",
         'profile-delete-text1' => "This will delete the account as well as all domains, users and aliases associated with this account.",
         'profile-delete-warning' => "This operation is irreversible",
         'profile-delete-text2' => "As you will not be able to recover anything after this point, please make sure that you have migrated all data before proceeding.",
         'profile-delete-support' => "As we always strive to improve, we would like to ask for 2 minutes of your time. "
             . "The best tool for improvement is feedback from users, and we would like to ask "
             . "for a few words about your reasons for leaving our service. Please send your feedback to <a href=\"{href}\">{email}</a>.",
         'profile-delete-contact' => "Also feel free to contact {app} Support with any questions or concerns that you may have in this context.",
         'reset-2fa' => "Reset 2-Factor Auth",
         'reset-2fa-title' => "2-Factor Authentication Reset",
         'title' => "User account",
+        'search' => "User email address or name",
         'search-pl' => "User ID, email or domain",
         'skureq' => "{sku} requires {list}.",
         'subscription' => "Subscription",
         'subscriptions' => "Subscriptions",
         'subscriptions-none' => "This user has no subscriptions.",
         'users' => "Users",
         'users-none' => "There are no users in this account.",
     ],
 
     'wallet' => [
         'add-credit' => "Add credit",
         'auto-payment-cancel' => "Cancel auto-payment",
         'auto-payment-change' => "Change auto-payment",
         'auto-payment-failed' => "The setup of automatic payments failed. Restart the process to enable automatic top-ups.",
         'auto-payment-hint' => "Here is how it works: Every time your account runs low, we will charge your preferred payment method for an amount you choose."
             . " You can cancel or change the auto-payment option at any time.",
         'auto-payment-setup' => "Set up auto-payment",
         'auto-payment-disabled' => "The configured auto-payment has been disabled. Top up your wallet or raise the auto-payment amount.",
         'auto-payment-info' => "Auto-payment is <b>set</b> to fill up your account by <b>{amount}</b> every time your account balance gets under <b>{balance}</b>.",
         'auto-payment-inprogress' => "The setup of the automatic payment is still in progress.",
         'auto-payment-next' => "Next, you will be redirected to the checkout page, where you can provide your credit card details.",
         'auto-payment-disabled-next' => "The auto-payment is disabled. Immediately after you submit new settings we'll enable it and attempt to top up your wallet.",
         'auto-payment-update' => "Update auto-payment",
         'banktransfer-hint' => "Please note that a bank transfer can take several days to complete.",
         'currency-conv' => "Here is how it works: You specify the amount by which you want to top up your wallet in {wc}."
             . " We will then convert this to {pc}, and on the next page you will be provided with the bank-details to transfer the amount in {pc}.",
         'fill-up' => "Fill up by",
         'history' => "History",
         'month' => "month",
         'noperm' => "Only account owners can access a wallet.",
         'payment-amount-hint' => "Choose the amount by which you want to top up your wallet.",
         'payment-method' => "Method of payment: {method}",
         'payment-warning' => "You will be charged for {price}.",
         'pending-payments' => "Pending Payments",
         'pending-payments-warning' => "You have payments that are still in progress. See the \"Pending Payments\" tab below.",
         'pending-payments-none' => "There are no pending payments for this account.",
         'receipts' => "Receipts",
         'receipts-hint' => "Here you can download receipts (in PDF format) for payments in specified period. Select the period and press the Download button.",
         'receipts-none' => "There are no receipts for payments in this account. Please, note that you can download receipts after the month ends.",
         'title' => "Account balance",
         'top-up' => "Top up your wallet",
         'transactions' => "Transactions",
         'transactions-none' => "There are no transactions for this account.",
         'when-below' => "when account balance is below",
     ],
 ];
diff --git a/src/resources/vue/Reseller/Invitations.vue b/src/resources/vue/Reseller/Invitations.vue
index 49be5ccd..4ec609fd 100644
--- a/src/resources/vue/Reseller/Invitations.vue
+++ b/src/resources/vue/Reseller/Invitations.vue
@@ -1,280 +1,223 @@
 <template>
     <div class="container">
         <div class="card" id="invitations">
             <div class="card-body">
                 <div class="card-title">
                     {{ $t('invitation.title') }}
                 </div>
                 <div class="card-text">
                     <div class="mb-2 d-flex">
-                        <form @submit.prevent="searchInvitations" id="search-form" class="input-group" style="flex:1">
-                            <input class="form-control" type="text" :placeholder="$t('invitation.search')" v-model="search">
-                            <button type="submit" class="btn btn-primary"><svg-icon icon="search"></svg-icon> {{ $t('btn.search') }}</button>
-                        </form>
+                        <list-search :placeholder="$t('invitation.search')" :on-search="searchInvitations"></list-search>
                         <div>
                             <button class="btn btn-success create-invite ms-1" @click="inviteUserDialog">
                                 <svg-icon icon="envelope-open-text"></svg-icon> {{ $t('invitation.create') }}
                             </button>
                         </div>
                     </div>
 
                     <table id="invitations-list" class="table table-sm table-hover">
                         <thead>
                             <tr>
                                 <th scope="col">{{ $t('user.ext-email') }}</th>
                                 <th scope="col">{{ $t('form.created') }}</th>
                                 <th scope="col"></th>
                             </tr>
                         </thead>
                         <tbody>
                             <tr v-for="inv in invitations" :id="'i' + inv.id" :key="inv.id">
                                 <td class="email">
                                     <svg-icon icon="envelope-open-text" :class="statusClass(inv)" :title="$t('invitation.status-' + statusLabel(inv))"></svg-icon>
                                     <span>{{ inv.email }}</span>
                                 </td>
                                 <td class="datetime">
                                     {{ inv.created }}
                                 </td>
                                 <td class="buttons">
                                     <button class="btn text-danger button-delete p-0 ms-1" @click="deleteInvite(inv.id)">
                                         <svg-icon icon="trash-alt"></svg-icon>
                                         <span class="btn-label">{{ $t('btn.delete') }}</span>
                                     </button>
                                     <button class="btn button-resend p-0 ms-1" :disabled="inv.isNew || inv.isCompleted" @click="resendInvite(inv.id)">
                                         <svg-icon icon="redo"></svg-icon>
                                         <span class="btn-label">{{ $t('btn.resend') }}</span>
                                     </button>
                                 </td>
                             </tr>
                         </tbody>
-                        <tfoot class="table-fake-body">
-                            <tr>
-                                <td colspan="3">{{ $t('invitation.empty-list') }}</td>
-                            </tr>
-                        </tfoot>
+                        <list-foot :text="$t('invitation.empty-list')" colspan="3"></list-foot>
                     </table>
-                    <div class="text-center p-3" id="more-loader" v-if="hasMore">
-                        <button class="btn btn-secondary" @click="loadInvitations(true)">{{ $t('nav.more') }}</button>
-                    </div>
+                    <list-more v-if="hasMore" :on-click="loadInvitations"></list-more>
                 </div>
             </div>
         </div>
 
         <div id="invite-create" class="modal" tabindex="-1" role="dialog">
             <div class="modal-dialog" role="document">
                 <div class="modal-content">
                     <div class="modal-header">
                         <h5 class="modal-title">{{ $t('invitation.create-title') }}</h5>
                         <button type="button" class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></button>
                     </div>
                     <div class="modal-body">
                         <form>
                             <p>{{ $t('invitation.create-email') }}</p>
                             <div>
                                 <input id="email" type="text" class="form-control" name="email">
                             </div>
                             <div class="form-separator"><hr><span>{{ $t('form.or') }}</span></div>
                             <p>{{ $t('invitation.create-csv') }}</p>
                             <div>
                                 <input id="file" type="file" class="form-control" name="csv">
                             </div>
                         </form>
                     </div>
                     <div class="modal-footer">
                         <button type="button" class="btn btn-secondary modal-cancel" data-bs-dismiss="modal">{{ $t('btn.cancel') }}</button>
                         <button type="button" class="btn btn-primary modal-action" @click="inviteUser()">
                             <svg-icon icon="paper-plane"></svg-icon> {{ $t('invitation.send') }}
                         </button>
                     </div>
                 </div>
             </div>
         </div>
     </div>
 </template>
 
 <script>
     import { Modal } from 'bootstrap'
     import { library } from '@fortawesome/fontawesome-svg-core'
     import { faEnvelopeOpenText, faPaperPlane, faRedo } from '@fortawesome/free-solid-svg-icons'
+    import ListTools from '../Widgets/ListTools'
 
     library.add(faEnvelopeOpenText, faPaperPlane, faRedo)
 
     export default {
+        mixins: [ ListTools ],
         data() {
             return {
-                invitations: [],
-                hasMore: false,
-                page: 1,
-                search: ''
+                invitations: []
             }
         },
         mounted() {
-            this.$root.startLoading()
-            this.loadInvitations(null, () => this.$root.stopLoading())
+            this.loadInvitations({ init: true })
 
             $('#invite-create')[0].addEventListener('shown.bs.modal', event => {
                 $('input', event.target).first().focus()
             })
         },
         methods: {
             deleteInvite(id) {
                 axios.delete('/api/v4/invitations/' + id)
                     .then(response => {
                         if (response.data.status == 'success') {
                             this.$toast.success(response.data.message)
 
                             // Remove the invitation record from the list
                             const index = this.invitations.findIndex(item => item.id == id)
                             this.invitations.splice(index, 1)
                         }
                     })
             },
             fileChange(e) {
                 let label = this.$t('btn.file')
                 let files = e.target.files
 
                 if (files.length) {
                     label = files[0].name
                     if (files.length > 1) {
                         label += ', ...'
                     }
                 }
 
                 $(e.target).next().text(label)
             },
             inviteUser() {
                 let dialog = $('#invite-create')
                 let post = new FormData()
                 let params = { headers: { 'Content-Type': 'multipart/form-data' } }
 
                 post.append('email', dialog.find('#email').val())
 
                 this.$root.clearFormValidation(dialog.find('form'))
 
                 // Append the file to POST data
                 let files = dialog.find('#file').get(0).files
                 if (files.length) {
                     post.append('file', files[0])
                 }
 
                 axios.post('/api/v4/invitations', post, params)
                     .then(response => {
                         if (response.data.status == 'success') {
                             this.dialog.hide()
                             this.$toast.success(response.data.message)
                             if (response.data.count) {
                                 this.loadInvitations({ reset: true })
                             }
                         }
                     })
             },
             inviteUserDialog() {
                 const dialog = $('#invite-create')[0]
                 const form = $('form', dialog)
 
                 form.get(0).reset()
                 this.fileChange({ target: form.find('#file')[0] }) // resets file input label
                 this.$root.clearFormValidation(form)
 
                 this.dialog = new Modal(dialog)
                 this.dialog.show()
             },
-            loadInvitations(params, callback) {
-                let loader
-                let get = {}
-
-                if (params) {
-                    if (params.reset) {
-                        this.invitations = []
-                        this.page = 0
-                    }
-
-                    get.page = params.page || (this.page + 1)
-
-                    if (typeof params === 'object' && 'search' in params) {
-                        get.search = params.search
-                        this.currentSearch = params.search
-                    } else {
-                        get.search = this.currentSearch
-                    }
-
-                    loader = $(get.page > 1 ? '#more-loader' : '#invitations-list tfoot td')
-                } else {
-                    this.currentSearch = null
-                }
-
-                this.$root.addLoader(loader)
-
-                axios.get('/api/v4/invitations', { params: get })
-                    .then(response => {
-                        this.$root.removeLoader(loader)
-
-                        // Note: In Vue we can't just use .concat()
-                        for (let i in response.data.list) {
-                            this.$set(this.invitations, this.invitations.length, response.data.list[i])
-                        }
-                        this.hasMore = response.data.hasMore
-                        this.page = response.data.page || 1
-
-                        if (callback) {
-                            callback()
-                        }
-                    })
-                    .catch(error => {
-                        this.$root.removeLoader(loader)
-
-                        if (callback) {
-                            callback()
-                        }
-                    })
+            loadInvitations(params) {
+                this.listSearch('invitations', '/api/v4/invitations', params)
             },
             resendInvite(id) {
                 axios.post('/api/v4/invitations/' + id + '/resend')
                     .then(response => {
                         if (response.data.status == 'success') {
                             this.$toast.success(response.data.message)
 
                             // Update the invitation record
                             const index = this.invitations.findIndex(item => item.id == id)
                             if (index > -1) {
                                 this.$set(this.invitations, index, response.data.invitation)
                             }
                         }
                     })
             },
-            searchInvitations() {
-                this.loadInvitations({ reset: true, search: this.search })
+            searchInvitations(search) {
+                this.loadInvitations({ reset: true, search })
             },
             statusClass(invitation) {
                 if (invitation.isCompleted) {
                     return 'text-success'
                 }
 
                 if (invitation.isFailed) {
                     return 'text-danger'
                 }
 
                 if (invitation.isSent) {
                     return 'text-primary'
                 }
 
                 return ''
             },
             statusLabel(invitation) {
                 if (invitation.isCompleted) {
                     return 'completed'
                 }
 
                 if (invitation.isFailed) {
                     return 'failed'
                 }
 
                 if (invitation.isSent) {
                     return 'sent'
                 }
 
                 return 'new'
             }
         }
     }
 </script>
diff --git a/src/resources/vue/User/List.vue b/src/resources/vue/User/List.vue
index 17b76660..a0f2f131 100644
--- a/src/resources/vue/User/List.vue
+++ b/src/resources/vue/User/List.vue
@@ -1,57 +1,62 @@
 <template>
     <div class="container">
         <div class="card" id="user-list">
             <div class="card-body">
                 <div class="card-title">
                     {{ $t('user.list-title') }}
-                    <router-link class="btn btn-success float-end create-user" :to="{ path: 'user/new' }" tag="button">
-                        <svg-icon icon="user"></svg-icon> {{ $t('user.create') }}
-                    </router-link>
                 </div>
                 <div class="card-text">
-                    <table class="table table-sm table-hover">
+                    <div class="mb-2 d-flex">
+                        <list-search :placeholder="$t('user.search')" :on-search="searchUsers"></list-search>
+                        <div>
+                            <router-link class="btn btn-success ms-1 create-user" :to="{ path: 'user/new' }" tag="button">
+                                <svg-icon icon="user"></svg-icon> {{ $t('user.create') }}
+                            </router-link>
+                        </div>
+                    </div>
+                    <table id="users-list" class="table table-sm table-hover">
                         <thead>
                             <tr>
                                 <th scope="col">{{ $t('form.primary-email') }}</th>
                             </tr>
                         </thead>
                         <tbody>
                             <tr v-for="user in users" :id="'user' + user.id" :key="user.id" @click="$root.clickRecord">
                                 <td>
                                     <svg-icon icon="user" :class="$root.userStatusClass(user)" :title="$root.userStatusText(user)"></svg-icon>
                                     <router-link :to="{ path: 'user/' + user.id }">{{ user.email }}</router-link>
                                 </td>
                             </tr>
                         </tbody>
-                        <tfoot class="table-fake-body">
-                            <tr>
-                                <td>{{ $t('user.users-none') }}</td>
-                            </tr>
-                        </tfoot>
+                        <list-foot :text="$t('user.users-none')"></list-foot>
                     </table>
+                    <list-more v-if="hasMore" :on-click="loadUsers"></list-more>
                 </div>
             </div>
         </div>
     </div>
 </template>
 
 <script>
+    import ListTools from '../Widgets/ListTools'
+
     export default {
+        mixins: [ ListTools ],
         data() {
             return {
-                users: [],
-                current_user: null
+                users: []
             }
         },
-        created() {
-            this.$root.startLoading()
-
-            axios.get('/api/v4/users')
-                .then(response => {
-                    this.$root.stopLoading()
-                    this.users = response.data
-                })
-                .catch(this.$root.errorHandler)
+        mounted() {
+            this.loadUsers({ init: true })
+        },
+        methods: {
+            loadUsers(params) {
+                this.listSearch('users', '/api/v4/users', params)
+            },
+            searchUsers(search) {
+                this.loadUsers({ reset: true, search })
+            }
         }
     }
 </script>
diff --git a/src/resources/vue/Widgets/ListTools.vue b/src/resources/vue/Widgets/ListTools.vue
new file mode 100644
index 00000000..f4f7f15c
--- /dev/null
+++ b/src/resources/vue/Widgets/ListTools.vue
@@ -0,0 +1,116 @@
+<template>
+    <div></div>
+</template>
+
+<script>
+
+    const ListSearch = {
+        props: {
+            onSearch: { type: Function, default: () => {} },
+            placeholder: { type: String, default: '' }
+        },
+        data() {
+            return {
+                search: ''
+            }
+        },
+        template: `<form @submit.prevent="onSearch(search)" id="search-form" class="input-group" style="flex:1">
+                <input class="form-control" type="text" :placeholder="placeholder" v-model="search">
+                <button type="submit" class="btn btn-primary"><svg-icon icon="search"></svg-icon> {{ $t('btn.search') }}</button>
+            </form>`
+    }
+
+    const ListFoot = {
+        props: {
+            colspan: { type: Number, default: 1 },
+            text: { type: String, default: '' }
+        },
+        template: `<tfoot class="table-fake-body"><tr><td :colspan="colspan">{{ text }}</td></tr></tfoot>`
+    }
+
+    const ListMore = {
+        props: {
+            onClick: { type: Function, default: () => {} }
+        },
+        template: `<div class="text-center p-3 more-loader">
+                <button class="btn btn-secondary" @click="onClick({})">{{ $t('nav.more') }}</button>
+            </div>`
+    }
+
+    export default {
+        components: {
+            ListFoot,
+            ListMore,
+            ListSearch
+        },
+        data() {
+            return {
+                currentSearch: '',
+                hasMore: false,
+                page: 1
+            }
+        },
+        methods: {
+            listSearch(name, url, params) {
+                let loader
+                let get = {}
+
+                if (params) {
+                    if (params.reset || params.init) {
+                        this[name] = []
+                        this.page = 0
+                    }
+
+                    get.page = params.page || (this.page + 1)
+
+                    if ('search' in params) {
+                        get.search = params.search
+                        this.currentSearch = params.search
+                        this.hasMore = false
+                    } else {
+                        get.search = this.currentSearch
+                    }
+
+                    if (!params.init) {
+                        loader = $(this.$el).find('.more-loader')
+                        if (!loader.length || get.page == 1) {
+                            loader = $(this.$el).find('tfoot td')
+                        }
+                    }
+                } else {
+                    this.currentSearch = null
+                }
+
+                if (params && params.init) {
+                    this.$root.startLoading()
+                } else {
+                    this.$root.addLoader(loader)
+                }
+
+                const finish = () => {
+                    if (params && params.init) {
+                        this.$root.stopLoading()
+                    } else {
+                        this.$root.removeLoader(loader)
+                    }
+                }
+
+                axios.get(url, { params: get })
+                    .then(response => {
+                        // Note: In Vue we can't just use .concat()
+                        for (let i in response.data.list) {
+                            this.$set(this[name], this[name].length, response.data.list[i])
+                        }
+
+                        this.hasMore = response.data.hasMore
+                        this.page = response.data.page || 1
+
+                        finish()
+                    })
+                    .catch(error => {
+                        finish()
+                    })
+            }
+        }
+    }
+</script>
diff --git a/src/resources/vue/Widgets/PaymentLog.vue b/src/resources/vue/Widgets/PaymentLog.vue
index fe661904..0ea78d9b 100644
--- a/src/resources/vue/Widgets/PaymentLog.vue
+++ b/src/resources/vue/Widgets/PaymentLog.vue
@@ -1,76 +1,48 @@
 <template>
     <div>
         <table class="table table-sm m-0 payments">
             <thead>
                 <tr>
                     <th scope="col">{{ $t('form.date') }}</th>
                     <th scope="col">{{ $t('form.description') }}</th>
                     <th scope="col"></th>
                     <th scope="col" class="price">{{ $t('form.amount') }}</th>
                 </tr>
             </thead>
             <tbody>
                 <tr v-for="payment in payments" :id="'log' + payment.id" :key="payment.id">
                     <td class="datetime">{{ payment.createdAt }}</td>
                     <td class="description">{{ payment.description }}</td>
                     <td><a v-if="payment.checkoutUrl" :href="payment.checkoutUrl">{{ $t('form.details') }}</a></td>
                     <td class="price text-success">{{ amount(payment) }}</td>
                 </tr>
             </tbody>
-            <tfoot class="table-fake-body">
-                <tr>
-                    <td colspan="4">{{ $t('wallet.pending-payments-none') }}</td>
-                </tr>
-            </tfoot>
+            <list-foot :text="$t('wallet.pending-payments-none')" colspan="4"></list-foot>
         </table>
-        <div class="text-center p-3" id="payments-loader" v-if="hasMore">
-            <button class="btn btn-secondary" @click="loadLog(true)">{{ $t('nav.more') }}</button>
-        </div>
+        <list-more v-if="hasMore" :on-click="loadLog"></list-more>
     </div>
 </template>
 
 <script>
+    import ListTools from './ListTools'
+
     export default {
-        props: {
-        },
+        mixins: [ ListTools ],
         data() {
             return {
-                payments: [],
-                hasMore: false,
-                page: 1
+                payments: []
             }
         },
         mounted() {
-            this.loadLog()
+            this.loadLog({ reset: true })
         },
         methods: {
-            loadLog(more) {
-                let loader = $(this.$el)
-                let param = ''
-
-                if (more) {
-                    param = '?page=' + (this.page + 1)
-                    loader = $('#payments-loader')
-                }
-
-                this.$root.addLoader(loader)
-                axios.get('/api/v4/payments/pending' + param)
-                    .then(response => {
-                        this.$root.removeLoader(loader)
-                        // Note: In Vue we can't just use .concat()
-                        for (let i in response.data.list) {
-                            this.$set(this.payments, this.payments.length, response.data.list[i])
-                        }
-                        this.hasMore = response.data.hasMore
-                        this.page = response.data.page || 1
-                    })
-                    .catch(error => {
-                        this.$root.removeLoader(loader)
-                    })
+            loadLog(params) {
+                this.listSearch('payments', '/api/v4/payments/pending', params)
             },
             amount(payment) {
                 return this.$root.price(payment.amount, payment.currency)
             }
         }
     }
 </script>
diff --git a/src/resources/vue/Widgets/TransactionLog.vue b/src/resources/vue/Widgets/TransactionLog.vue
index 0b974654..e62e4f0b 100644
--- a/src/resources/vue/Widgets/TransactionLog.vue
+++ b/src/resources/vue/Widgets/TransactionLog.vue
@@ -1,122 +1,94 @@
 <template>
     <div>
         <table class="table table-sm m-0 transactions">
             <thead>
                 <tr>
                     <th scope="col">{{ $t('form.date') }}</th>
                     <th scope="col" v-if="isAdmin">{{ $t('form.user') }}</th>
                     <th scope="col"></th>
                     <th scope="col">{{ $t('form.description') }}</th>
                     <th scope="col" class="price">{{ $t('form.amount') }}</th>
                 </tr>
             </thead>
             <tbody>
                 <tr v-for="transaction in transactions" :id="'log' + transaction.id" :key="transaction.id">
                     <td class="datetime">{{ transaction.createdAt }}</td>
                     <td class="email" v-if="isAdmin">{{ transaction.user }}</td>
                     <td class="selection">
                         <button class="btn btn-lg btn-link btn-action" title="Details" type="button"
                                 v-if="transaction.hasDetails"
                                 @click="loadTransaction(transaction.id)"
                         >
                             <svg-icon icon="info-circle"></svg-icon>
                         </button>
                     </td>
                     <td class="description">{{ description(transaction) }}</td>
                     <td :class="'price ' + className(transaction)">{{ amount(transaction) }}</td>
                 </tr>
             </tbody>
-            <tfoot class="table-fake-body">
-                <tr>
-                    <td :colspan="isAdmin ? 5 : 4">{{ $t('wallet.transactions-none') }}</td>
-                </tr>
-            </tfoot>
+            <list-foot :text="$t('wallet.transactions-none')" :colspan="isAdmin ? 5 : 4"></list-foot>
         </table>
-        <div class="text-center p-3" id="transactions-loader" v-if="hasMore">
-            <button class="btn btn-secondary" @click="loadLog(true)">{{ $t('nav.more') }}</button>
-        </div>
+        <list-more v-if="hasMore" :on-click="loadLog"></list-more>
     </div>
 </template>
 
 <script>
+    import ListTools from './ListTools'
+
     export default {
+        mixins: [ ListTools ],
         props: {
             walletId: { type: String, default: null },
             isAdmin: { type: Boolean, default: false },
         },
         data() {
             return {
-                transactions: [],
-                hasMore: false,
-                page: 1
+                transactions: []
             }
         },
         mounted() {
-            this.loadLog()
+            this.loadLog({ reset: true })
         },
         methods: {
-            loadLog(more) {
-                if (!this.walletId) {
-                    return
-                }
-
-                let loader = $(this.$el)
-                let param = ''
-
-                if (more) {
-                    param = '?page=' + (this.page + 1)
-                    loader = $('#transactions-loader')
+            loadLog(params) {
+                if (this.walletId) {
+                    this.listSearch('transactions', '/api/v4/wallets/' + this.walletId + '/transactions', params)
                 }
-
-                this.$root.addLoader(loader)
-                axios.get('/api/v4/wallets/' + this.walletId + '/transactions' + param)
-                    .then(response => {
-                        this.$root.removeLoader(loader)
-                        // Note: In Vue we can't just use .concat()
-                        for (let i in response.data.list) {
-                            this.$set(this.transactions, this.transactions.length, response.data.list[i])
-                        }
-                        this.hasMore = response.data.hasMore
-                        this.page = response.data.page || 1
-                    })
-                    .catch(error => {
-                        this.$root.removeLoader(loader)
-                    })
             },
             loadTransaction(id) {
                 let record = $('#log' + id)
                 let cell = record.find('td.description')
                 let details = $('<div class="list-details"><ul></ul><div>').appendTo(cell)
 
                 this.$root.addLoader(cell)
                 axios.get('/api/v4/wallets/' + this.walletId + '/transactions' + '?transaction=' + id)
                     .then(response => {
                         this.$root.removeLoader(cell)
                         record.find('button').remove()
                         let list = details.find('ul')
                         response.data.list.forEach(elem => {
                            list.append($('<li>').text(this.description(elem)))
                         })
                     })
                     .catch(error => {
                         this.$root.removeLoader(cell)
                     })
             },
             amount(transaction) {
                 return this.$root.price(transaction.amount, transaction.currency)
             },
             className(transaction) {
                 return transaction.amount < 0 ? 'text-danger' : 'text-success';
             },
             description(transaction) {
                 let desc = transaction.description
 
                 if (/^(billed|created|deleted)$/.test(transaction.type)) {
                     desc += ' (' + this.$root.price(transaction.amount) + ')'
                 }
 
                 return desc
             }
         }
     }
 </script>
diff --git a/src/tests/Browser.php b/src/tests/Browser.php
index 24174cbc..2d71ecde 100644
--- a/src/tests/Browser.php
+++ b/src/tests/Browser.php
@@ -1,267 +1,265 @@
 <?php
 
 namespace Tests;
 
 use Facebook\WebDriver\WebDriverKeys;
 use PHPUnit\Framework\Assert;
 use Tests\Browser\Components\Error;
 use Tests\Browser\Components\Toast;
 
 /**
  * Laravel Dusk Browser extensions
  */
 class Browser extends \Laravel\Dusk\Browser
 {
     /**
      * Assert that the given element attribute contains specified text.
      */
     public function assertAttributeRegExp($selector, $attribute, $regexp)
     {
         $element = $this->resolver->findOrFail($selector);
         $value   = (string) $element->getAttribute($attribute);
         $error   = "No expected text in [$selector][$attribute]. Found: $value";
 
         Assert::assertMatchesRegularExpression($regexp, $value, $error);
 
         return $this;
     }
 
     /**
      * Assert number of (visible) elements
      */
     public function assertElementsCount($selector, $expected_count, $visible = true)
     {
         $elements = $this->elements($selector);
         $count = count($elements);
 
         if ($visible) {
             foreach ($elements as $element) {
                 if (!$element->isDisplayed()) {
                     $count--;
                 }
             }
         }
 
         Assert::assertEquals($expected_count, $count, "Count of [$selector] elements is not $expected_count");
 
         return $this;
     }
 
     /**
      * Assert Tip element content
      */
     public function assertTip($selector, $content)
     {
         return $this->click($selector)
             ->withinBody(function ($browser) use ($content) {
                 $browser->waitFor('div.tooltip .tooltip-inner')
                     ->assertSeeIn('div.tooltip .tooltip-inner', $content);
             })
             ->click($selector);
     }
 
     /**
      * Assert Toast element content (and close it)
      */
     public function assertToast(string $type, string $message, $title = null)
     {
         return $this->withinBody(function ($browser) use ($type, $title, $message) {
             $browser->with(new Toast($type), function (Browser $browser) use ($title, $message) {
                 $browser->assertToastTitle($title)
                     ->assertToastMessage($message)
                         ->closeToast();
             });
         });
     }
 
     /**
      * Assert specified error page is displayed.
      */
     public function assertErrorPage(int $error_code, string $hint = '')
     {
         $this->with(new Error($error_code, $hint), function ($browser) {
             // empty, assertions will be made by the Error component itself
         });
 
         return $this;
     }
 
     /**
      * Assert that the given element has specified class assigned.
      */
     public function assertHasClass($selector, $class_name)
     {
         $element = $this->resolver->findOrFail($selector);
         $classes = explode(' ', (string) $element->getAttribute('class'));
 
         Assert::assertContains($class_name, $classes, "[$selector] has no class '{$class_name}'");
 
         return $this;
     }
 
     /**
      * Assert that the given element is readonly
      */
     public function assertReadonly($selector)
     {
         $element = $this->resolver->findOrFail($selector);
         $value = $element->getAttribute('readonly');
 
         Assert::assertTrue($value == 'true', "Element [$selector] is not readonly");
 
         return $this;
     }
 
     /**
      * Assert that the given element is not readonly
      */
     public function assertNotReadonly($selector)
     {
         $element = $this->resolver->findOrFail($selector);
         $value = $element->getAttribute('readonly');
 
         Assert::assertTrue($value != 'true', "Element [$selector] is not readonly");
 
         return $this;
     }
 
     /**
      * Assert that the given element contains specified text,
      * no matter it's displayed or not.
      */
     public function assertText($selector, $text)
     {
         $element = $this->resolver->findOrFail($selector);
 
         if ($text === '') {
             Assert::assertTrue((string) $element->getText() === $text, "Element's text is not empty [$selector]");
         } else {
             Assert::assertTrue(strpos($element->getText(), $text) !== false, "No expected text in [$selector]");
         }
 
         return $this;
     }
 
     /**
      * Assert that the given element contains specified text,
      * no matter it's displayed or not - using a regular expression.
      */
     public function assertTextRegExp($selector, $regexp)
     {
         $element = $this->resolver->findOrFail($selector);
 
         Assert::assertMatchesRegularExpression($regexp, $element->getText(), "No expected text in [$selector]");
 
         return $this;
     }
 
     /**
      * Remove all toast messages
      */
     public function clearToasts()
     {
         $this->script("\$('.toast-container > *').remove()");
 
         return $this;
     }
 
     /**
      * Wait until a button becomes enabled and click it
      */
     public function clickWhenEnabled($selector)
     {
         return $this->waitFor($selector . ':not([disabled])')->click($selector);
     }
 
     /**
      * Check if in Phone mode
      */
     public static function isPhone()
     {
         return getenv('TESTS_MODE') == 'phone';
     }
 
     /**
      * Check if in Tablet mode
      */
     public static function isTablet()
     {
         return getenv('TESTS_MODE') == 'tablet';
     }
 
     /**
      * Check if in Desktop mode
      */
     public static function isDesktop()
     {
         return !self::isPhone() && !self::isTablet();
     }
 
     /**
      * Returns content of a downloaded file
      */
     public function readDownloadedFile($filename, $sleep = 5)
     {
         $filename = __DIR__ . "/Browser/downloads/$filename";
 
         // Give the browser a chance to finish download
         if (!file_exists($filename) && $sleep) {
             sleep($sleep);
         }
 
         Assert::assertFileExists($filename);
 
         return file_get_contents($filename);
     }
 
     /**
      * Removes downloaded file
      */
     public function removeDownloadedFile($filename)
     {
         @unlink(__DIR__ . "/Browser/downloads/$filename");
 
         return $this;
     }
 
     /**
      * Clears the input field and related vue v-model data.
      */
     public function vueClear($selector)
     {
-        if ($this->resolver->prefix != 'body') {
-            $selector = $this->resolver->prefix . ' ' . $selector;
-        }
+        $selector = $this->resolver->format($selector);
 
         // The existing clear(), and type() with empty string do not work.
         // We have to clear the field and dispatch 'input' event programatically.
 
         $this->script(
             "var element = document.querySelector('$selector');"
             . "element.value = '';"
             . "element.dispatchEvent(new Event('input'))"
         );
 
         return $this;
     }
 
     /**
      * Execute code within body context.
      * Useful to execute code that selects elements outside of a component context
      */
     public function withinBody($callback)
     {
         if ($this->resolver->prefix != 'body') {
             $orig_prefix = $this->resolver->prefix;
             $this->resolver->prefix = 'body';
         }
 
         call_user_func($callback, $this);
 
         if (isset($orig_prefix)) {
             $this->resolver->prefix = $orig_prefix;
         }
 
         return $this;
     }
 }
diff --git a/src/tests/Browser/Pages/UserList.php b/src/tests/Browser/Pages/UserList.php
index 9a7c0702..d772d328 100644
--- a/src/tests/Browser/Pages/UserList.php
+++ b/src/tests/Browser/Pages/UserList.php
@@ -1,45 +1,46 @@
 <?php
 
 namespace Tests\Browser\Pages;
 
 use Laravel\Dusk\Page;
 
 class UserList extends Page
 {
     /**
      * Get the URL for the page.
      *
      * @return string
      */
     public function url(): string
     {
         return '/users';
     }
 
     /**
      * Assert that the browser is on the page.
      *
      * @param \Laravel\Dusk\Browser $browser The browser object
      *
      * @return void
      */
     public function assert($browser)
     {
         $browser->assertPathIs($this->url())
             ->waitUntilMissing('@app .app-loader')
             ->assertSeeIn('#user-list .card-title', 'User accounts');
     }
 
     /**
      * Get the element shortcuts for the page.
      *
      * @return array
      */
     public function elements(): array
     {
         return [
             '@app' => '#app',
+            '@search' => '#search-form',
             '@table' => '#user-list table',
         ];
     }
 }
diff --git a/src/tests/Browser/Reseller/InvitationsTest.php b/src/tests/Browser/Reseller/InvitationsTest.php
index 5f96dc50..a940e951 100644
--- a/src/tests/Browser/Reseller/InvitationsTest.php
+++ b/src/tests/Browser/Reseller/InvitationsTest.php
@@ -1,225 +1,225 @@
 <?php
 
 namespace Tests\Browser\Reseller;
 
 use App\SignupInvitation;
 use Illuminate\Support\Facades\Queue;
 use Tests\Browser;
 use Tests\Browser\Components\Dialog;
 use Tests\Browser\Components\Menu;
 use Tests\Browser\Components\Toast;
 use Tests\Browser\Pages\Dashboard;
 use Tests\Browser\Pages\Home;
 use Tests\Browser\Pages\Reseller\Invitations;
 use Tests\TestCaseDusk;
 
 class InvitationsTest extends TestCaseDusk
 {
     /**
      * {@inheritDoc}
      */
     public function setUp(): void
     {
         parent::setUp();
         self::useResellerUrl();
         SignupInvitation::truncate();
     }
 
     /**
      * Test invitations page (unauthenticated)
      */
     public function testInvitationsUnauth(): void
     {
         // Test that the page requires authentication
         $this->browse(function (Browser $browser) {
             $browser->visit('/invitations')->on(new Home());
         });
     }
 
     /**
      * Test Invitations creation
      */
     public function testInvitationCreate(): void
     {
         $this->browse(function (Browser $browser) {
             $date_regexp = '/^20[0-9]{2}-/';
 
             $browser->visit(new Home())
                 ->submitLogon('reseller@' . \config('app.domain'), \App\Utils::generatePassphrase(), true)
                 ->on(new Dashboard())
                 ->assertSeeIn('@links .link-invitations', 'Invitations')
                 ->click('@links .link-invitations')
                 ->on(new Invitations())
                 ->assertElementsCount('@table tbody tr', 0)
-                ->assertMissing('#more-loader')
+                ->assertMissing('.more-loader')
                 ->assertSeeIn('@table tfoot td', "There are no invitations in the database.")
                 ->assertSeeIn('@create-button', 'Create invite(s)');
 
             // Create a single invite with email address input
             $browser->click('@create-button')
                 ->with(new Dialog('#invite-create'), function (Browser $browser) {
                     $browser->assertSeeIn('@title', 'Invite for a signup')
                         ->assertFocused('@body input#email')
                         ->assertValue('@body input#email', '')
                         ->type('@body input#email', 'test')
                         ->assertSeeIn('@button-action', 'Send invite(s)')
                         ->click('@button-action')
                         ->assertToast(Toast::TYPE_ERROR, "Form validation error")
                         ->waitFor('@body input#email.is-invalid')
                         ->assertSeeIn(
                             '@body input#email.is-invalid + .invalid-feedback',
                             "The email must be a valid email address."
                         )
                         ->type('@body input#email', 'test@domain.tld')
                         ->click('@button-action');
                 })
                 ->assertToast(Toast::TYPE_SUCCESS, "The invitation has been created.")
                 ->waitUntilMissing('#invite-create')
                 ->waitUntilMissing('@table .app-loader')
                 ->assertElementsCount('@table tbody tr', 1)
                 ->assertMissing('@table tfoot')
                 ->assertSeeIn('@table tbody tr td.email', 'test@domain.tld')
                 ->assertText('@table tbody tr td.email title', 'Not sent yet')
                 ->assertTextRegExp('@table tbody tr td.datetime', $date_regexp)
                 ->assertVisible('@table tbody tr td.buttons button.button-delete')
                 ->assertVisible('@table tbody tr td.buttons button.button-resend:disabled');
 
             sleep(1);
 
             // Create invites from a file
             $browser->click('@create-button')
                 ->with(new Dialog('#invite-create'), function (Browser $browser) {
                     $browser->assertFocused('@body input#email')
                         ->assertValue('@body input#email', '')
                         ->assertMissing('@body input#email.is-invalid')
                         // Submit an empty file
                         ->attach('@body input#file', __DIR__ . '/../../data/empty.csv')
                         ->click('@button-action')
                         ->assertToast(Toast::TYPE_ERROR, "Form validation error")
                         // ->waitFor('input#file.is-invalid')
                         ->assertSeeIn(
                             '@body input#file.is-invalid + .invalid-feedback',
                             "Failed to find any valid email addresses in the uploaded file."
                         )
                         // Submit non-empty file
                         ->attach('@body input#file', __DIR__ . '/../../data/email.csv')
                         ->click('@button-action');
                 })
                 ->assertToast(Toast::TYPE_SUCCESS, "2 invitations has been created.")
                 ->waitUntilMissing('#invite-create')
                 ->waitUntilMissing('@table .app-loader')
                 ->assertElementsCount('@table tbody tr', 3)
                 ->assertTextRegExp('@table tbody tr:nth-child(1) td.email', '/email[12]@test\.com$/')
                 ->assertTextRegExp('@table tbody tr:nth-child(2) td.email', '/email[12]@test\.com$/');
         });
     }
 
     /**
      * Test Invitations deletion and resending
      */
     public function testInvitationDeleteAndResend(): void
     {
         $this->browse(function (Browser $browser) {
             Queue::fake();
             $i1 = SignupInvitation::create(['email' => 'test1@domain.org']);
             $i2 = SignupInvitation::create(['email' => 'test2@domain.org']);
             SignupInvitation::where('id', $i1->id)->update(['status' => SignupInvitation::STATUS_FAILED]);
             SignupInvitation::where('id', $i2->id)->update(['created_at' => now()->subHours('2')]);
 
             $browser->visit(new Invitations())
                 ->assertElementsCount('@table tbody tr', 2);
 
             // Test resending
             $browser->assertSeeIn('@table tbody tr:first-child td.email', 'test1@domain.org')
                 ->click('@table tbody tr:first-child button.button-resend')
                 ->assertToast(Toast::TYPE_SUCCESS, "Invitation added to the sending queue successfully.")
                 ->assertVisible('@table tbody tr:first-child button.button-resend:disabled')
                 ->assertElementsCount('@table tbody tr', 2);
 
             // Test deleting
             $browser->assertSeeIn('@table tbody tr:last-child td.email', 'test2@domain.org')
                 ->click('@table tbody tr:last-child button.button-delete')
                 ->assertToast(Toast::TYPE_SUCCESS, "Invitation deleted successfully.")
                 ->assertElementsCount('@table tbody tr', 1)
                 ->assertSeeIn('@table tbody tr:first-child td.email', 'test1@domain.org');
         });
     }
 
     /**
      * Test Invitations list (paging and searching)
      */
     public function testInvitationsList(): void
     {
         $this->browse(function (Browser $browser) {
             Queue::fake();
             $i1 = SignupInvitation::create(['email' => 'email1@ext.com']);
             $i2 = SignupInvitation::create(['email' => 'email2@ext.com']);
             $i3 = SignupInvitation::create(['email' => 'email3@ext.com']);
             $i4 = SignupInvitation::create(['email' => 'email4@other.com']);
             $i5 = SignupInvitation::create(['email' => 'email5@other.com']);
             $i6 = SignupInvitation::create(['email' => 'email6@other.com']);
             $i7 = SignupInvitation::create(['email' => 'email7@other.com']);
             $i8 = SignupInvitation::create(['email' => 'email8@other.com']);
             $i9 = SignupInvitation::create(['email' => 'email9@other.com']);
             $i10 = SignupInvitation::create(['email' => 'email10@other.com']);
             $i11 = SignupInvitation::create(['email' => 'email11@other.com']);
 
             SignupInvitation::query()->update(['created_at' => now()->subDays('1')]);
             SignupInvitation::where('id', $i1->id)
                 ->update(['created_at' => now()->subHours('2'), 'status' => SignupInvitation::STATUS_FAILED]);
             SignupInvitation::where('id', $i2->id)
                 ->update(['created_at' => now()->subHours('3'), 'status' => SignupInvitation::STATUS_SENT]);
             SignupInvitation::where('id', $i3->id)
                 ->update(['created_at' => now()->subHours('4'), 'status' => SignupInvitation::STATUS_COMPLETED]);
             SignupInvitation::where('id', $i11->id)->update(['created_at' => now()->subDays('3')]);
 
             // Test paging (load more) feature
             $browser->visit(new Invitations())
                 // ->submitLogon('reseller@' . \config('app.domain'), \App\Utils::generatePassphrase(), true)
                 ->assertElementsCount('@table tbody tr', 10)
-                ->assertSeeIn('#more-loader button', 'Load more')
+                ->assertSeeIn('.more-loader button', 'Load more')
                 ->with('@table tbody', function ($browser) use ($i1, $i2, $i3) {
                     $browser->assertSeeIn('tr:nth-child(1) td.email', $i1->email)
                         ->assertText('tr:nth-child(1) td.email svg.text-danger title', 'Sending failed')
                         ->assertVisible('tr:nth-child(1) td.buttons button.button-delete')
                         ->assertVisible('tr:nth-child(1) td.buttons button.button-resend:not(:disabled)')
                         ->assertSeeIn('tr:nth-child(2) td.email', $i2->email)
                         ->assertText('tr:nth-child(2) td.email svg.text-primary title', 'Sent')
                         ->assertVisible('tr:nth-child(2) td.buttons button.button-delete')
                         ->assertVisible('tr:nth-child(2) td.buttons button.button-resend:not(:disabled)')
                         ->assertSeeIn('tr:nth-child(3) td.email', $i3->email)
                         ->assertText('tr:nth-child(3) td.email svg.text-success title', 'User signed up')
                         ->assertVisible('tr:nth-child(3) td.buttons button.button-delete')
                         ->assertVisible('tr:nth-child(3) td.buttons button.button-resend:disabled')
                         ->assertText('tr:nth-child(4) td.email svg title', 'Not sent yet')
                         ->assertVisible('tr:nth-child(4) td.buttons button.button-delete')
                         ->assertVisible('tr:nth-child(4) td.buttons button.button-resend:disabled');
                 })
-                ->click('#more-loader button')
+                ->click('.more-loader button')
                 ->whenAvailable('@table tbody tr:nth-child(11)', function ($browser) use ($i11) {
                     $browser->assertSeeIn('td.email', $i11->email);
                 })
-                ->assertMissing('#more-loader button');
+                ->assertMissing('.more-loader button');
 
             // Test searching (by domain)
             $browser->type('@search-input', 'ext.com')
                 ->click('@search-button')
                 ->waitUntilMissing('@table .app-loader')
                 ->assertElementsCount('@table tbody tr', 3)
-                ->assertMissing('#more-loader button')
+                ->assertMissing('.more-loader button')
                 // search by full email
                 ->type('@search-input', 'email7@other.com')
                 ->keys('@search-input', '{enter}')
                 ->waitUntilMissing('@table .app-loader')
                 ->assertElementsCount('@table tbody tr', 1)
                 ->assertSeeIn('@table tbody tr:nth-child(1) td.email', 'email7@other.com')
-                ->assertMissing('#more-loader button')
+                ->assertMissing('.more-loader button')
                 // reset search
                 ->vueClear('#search-form input')
                 ->keys('@search-input', '{enter}')
                 ->waitUntilMissing('@table .app-loader')
                 ->assertElementsCount('@table tbody tr', 10)
-                ->assertVisible('#more-loader button');
+                ->assertVisible('.more-loader button');
         });
     }
 }
diff --git a/src/tests/Browser/UserListTest.php b/src/tests/Browser/UserListTest.php
new file mode 100644
index 00000000..6cc03115
--- /dev/null
+++ b/src/tests/Browser/UserListTest.php
@@ -0,0 +1,107 @@
+<?php
+
+namespace Tests\Browser;
+
+use App\User;
+use Tests\Browser;
+use Tests\Browser\Components\Toast;
+use Tests\Browser\Pages\Dashboard;
+use Tests\Browser\Pages\Home;
+use Tests\Browser\Pages\UserInfo;
+use Tests\Browser\Pages\UserList;
+use Tests\TestCaseDusk;
+
+class UserListTest extends TestCaseDusk
+{
+    /**
+     * {@inheritDoc}
+     */
+    public function setUp(): void
+    {
+        parent::setUp();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function tearDown(): void
+    {
+        parent::tearDown();
+    }
+
+    /**
+     * Test users list page (unauthenticated)
+     */
+    public function testListUnauth(): void
+    {
+        // Test that the page requires authentication
+        $this->browse(function (Browser $browser) {
+            $browser->visit('/users')->on(new Home());
+        });
+    }
+
+    /**
+     * Test users list page
+     */
+    public function testList(): void
+    {
+        $this->browse(function (Browser $browser) {
+            // Test that the page requires authentication
+            // Test the list
+            $browser->visit(new Home())
+                ->submitLogon('john@kolab.org', 'simple123', true)
+                ->on(new Dashboard())
+                ->assertSeeIn('@links .link-users', 'User accounts')
+                ->click('@links .link-users')
+                ->on(new UserList())
+                ->whenAvailable('@table', function (Browser $browser) {
+                    $browser->waitFor('tbody tr')
+                        ->assertElementsCount('tbody tr', 4)
+                        ->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org')
+                        ->assertSeeIn('tbody tr:nth-child(2) a', 'joe@kolab.org')
+                        ->assertSeeIn('tbody tr:nth-child(3) a', 'john@kolab.org')
+                        ->assertSeeIn('tbody tr:nth-child(4) a', 'ned@kolab.org')
+                        ->assertMissing('tfoot');
+                });
+
+            // Test searching
+            $browser->assertValue('@search input', '')
+                ->assertAttribute('@search input', 'placeholder', 'User email address or name')
+                ->assertSeeIn('@search button', 'Search')
+                ->type('@search input', 'jo')
+                ->click('@search button')
+                ->waitUntilMissing('@app .app-loader')
+                ->whenAvailable('@table', function (Browser $browser) {
+                    $browser->waitFor('tbody tr')
+                        ->assertElementsCount('tbody tr', 2)
+                        ->assertSeeIn('tbody tr:nth-child(1) a', 'joe@kolab.org')
+                        ->assertSeeIn('tbody tr:nth-child(2) a', 'john@kolab.org')
+                        ->assertMissing('tfoot');
+                })
+                // test empty result
+                ->type('@search input', 'jojo')
+                ->click('@search button')
+                ->waitUntilMissing('@app .app-loader')
+                ->whenAvailable('@table', function (Browser $browser) {
+                    $browser->waitFor('tfoot tr')
+                        ->assertSeeIn('tfoot tr', "There are no users in this account.");
+                })
+                // reset search
+                ->vueClear('@search input')
+                ->keys('@search input', '{enter}')
+                ->waitUntilMissing('@app .app-loader')
+                ->whenAvailable('@table', function (Browser $browser) {
+                    $browser->waitFor('tbody tr')->assertElementsCount('tbody tr', 4);
+                });
+
+            // TODO: Test paging
+
+            $browser->click('@table tr:nth-child(3)')
+                ->on(new UserInfo())
+                ->assertSeeIn('#user-info .card-title', 'User account')
+                ->with('@general', function (Browser $browser) {
+                    $browser->assertValue('#email', 'john@kolab.org');
+                });
+        });
+    }
+}
diff --git a/src/tests/Browser/UsersTest.php b/src/tests/Browser/UsersTest.php
index 89ce6760..7b898e12 100644
--- a/src/tests/Browser/UsersTest.php
+++ b/src/tests/Browser/UsersTest.php
@@ -1,835 +1,788 @@
 <?php
 
 namespace Tests\Browser;
 
 use App\Discount;
 use App\Entitlement;
 use App\Sku;
 use App\User;
 use App\UserAlias;
 use Tests\Browser;
 use Tests\Browser\Components\Dialog;
 use Tests\Browser\Components\ListInput;
 use Tests\Browser\Components\QuotaInput;
 use Tests\Browser\Components\Toast;
 use Tests\Browser\Pages\Dashboard;
 use Tests\Browser\Pages\Home;
 use Tests\Browser\Pages\UserInfo;
 use Tests\Browser\Pages\UserList;
 use Tests\Browser\Pages\Wallet as WalletPage;
 use Tests\TestCaseDusk;
 use Illuminate\Foundation\Testing\DatabaseMigrations;
 
 class UsersTest extends TestCaseDusk
 {
     private $profile = [
         'first_name' => 'John',
         'last_name' => 'Doe',
         'organization' => 'Kolab Developers',
     ];
 
     /**
      * {@inheritDoc}
      */
     public function setUp(): void
     {
         parent::setUp();
 
         $this->deleteTestUser('julia.roberts@kolab.org');
 
         $john = User::where('email', 'john@kolab.org')->first();
         $john->setSettings($this->profile);
         UserAlias::where('user_id', $john->id)
             ->where('alias', 'john.test@kolab.org')->delete();
 
         $activesync_sku = Sku::withEnvTenantContext()->where('title', 'activesync')->first();
         $storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first();
 
         Entitlement::where('entitleable_id', $john->id)->where('sku_id', $activesync_sku->id)->delete();
         Entitlement::where('cost', '>=', 5000)->delete();
         Entitlement::where('cost', '=', 25)->where('sku_id', $storage_sku->id)->delete();
 
         $wallet = $john->wallets()->first();
         $wallet->discount()->dissociate();
         $wallet->currency = 'CHF';
         $wallet->save();
 
         $this->clearBetaEntitlements();
         $this->clearMeetEntitlements();
     }
 
     /**
      * {@inheritDoc}
      */
     public function tearDown(): void
     {
         $this->deleteTestUser('julia.roberts@kolab.org');
 
         $john = User::where('email', 'john@kolab.org')->first();
         $john->setSettings($this->profile);
         UserAlias::where('user_id', $john->id)
             ->where('alias', 'john.test@kolab.org')->delete();
 
         $activesync_sku = Sku::withEnvTenantContext()->where('title', 'activesync')->first();
         $storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first();
 
         Entitlement::where('entitleable_id', $john->id)->where('sku_id', $activesync_sku->id)->delete();
         Entitlement::where('cost', '>=', 5000)->delete();
         Entitlement::where('cost', '=', 25)->where('sku_id', $storage_sku->id)->delete();
 
         $wallet = $john->wallets()->first();
         $wallet->discount()->dissociate();
         $wallet->save();
 
         $this->clearBetaEntitlements();
         $this->clearMeetEntitlements();
 
         parent::tearDown();
     }
 
-    /**
-     * Test user info page (unauthenticated)
-     */
-    public function testInfoUnauth(): void
-    {
-        // Test that the page requires authentication
-        $this->browse(function (Browser $browser) {
-            $user = User::where('email', 'john@kolab.org')->first();
-
-            $browser->visit('/user/' . $user->id)->on(new Home());
-        });
-    }
-
-    /**
-     * Test users list page (unauthenticated)
-     */
-    public function testListUnauth(): void
-    {
-        // Test that the page requires authentication
-        $this->browse(function (Browser $browser) {
-            $browser->visit('/users')->on(new Home());
-        });
-    }
-
-    /**
-     * Test users list page
-     */
-    public function testList(): void
-    {
-        // Test that the page requires authentication
-        $this->browse(function (Browser $browser) {
-            $browser->visit(new Home())
-                ->submitLogon('john@kolab.org', 'simple123', true)
-                ->on(new Dashboard())
-                ->assertSeeIn('@links .link-users', 'User accounts')
-                ->click('@links .link-users')
-                ->on(new UserList())
-                ->whenAvailable('@table', function (Browser $browser) {
-                    $browser->waitFor('tbody tr')
-                        ->assertElementsCount('tbody tr', 4)
-                        ->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org')
-                        ->assertSeeIn('tbody tr:nth-child(2) a', 'joe@kolab.org')
-                        ->assertSeeIn('tbody tr:nth-child(3) a', 'john@kolab.org')
-                        ->assertSeeIn('tbody tr:nth-child(4) a', 'ned@kolab.org')
-                        ->assertMissing('tfoot');
-                });
-        });
-    }
-
     /**
      * Test user account editing page (not profile page)
-     *
-     * @depends testList
      */
     public function testInfo(): void
     {
         $this->browse(function (Browser $browser) {
-            $browser->on(new UserList())
-                ->click('@table tr:nth-child(3) a')
+            $user = User::where('email', 'john@kolab.org')->first();
+
+            // Test that the page requires authentication
+            $browser->visit('/user/' . $user->id)
+                ->on(new Home())
+                ->submitLogon('john@kolab.org', 'simple123', false)
                 ->on(new UserInfo())
                 ->assertSeeIn('#user-info .card-title', 'User account')
                 ->with('@general', function (Browser $browser) {
                     // Assert form content
                     $browser->assertSeeIn('div.row:nth-child(1) label', 'Status')
                         ->assertSeeIn('div.row:nth-child(1) #status', 'Active')
                         ->assertFocused('div.row:nth-child(2) input')
                         ->assertSeeIn('div.row:nth-child(2) label', 'First Name')
                         ->assertValue('div.row:nth-child(2) input[type=text]', $this->profile['first_name'])
                         ->assertSeeIn('div.row:nth-child(3) label', 'Last Name')
                         ->assertValue('div.row:nth-child(3) input[type=text]', $this->profile['last_name'])
                         ->assertSeeIn('div.row:nth-child(4) label', 'Organization')
                         ->assertValue('div.row:nth-child(4) input[type=text]', $this->profile['organization'])
                         ->assertSeeIn('div.row:nth-child(5) label', 'Email')
                         ->assertValue('div.row:nth-child(5) input[type=text]', 'john@kolab.org')
                         ->assertDisabled('div.row:nth-child(5) input[type=text]')
                         ->assertSeeIn('div.row:nth-child(6) label', 'Email Aliases')
                         ->assertVisible('div.row:nth-child(6) .list-input')
                         ->with(new ListInput('#aliases'), function (Browser $browser) {
                             $browser->assertListInputValue(['john.doe@kolab.org'])
                                 ->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]', '')
                         ->assertSeeIn('button[type=submit]', 'Submit')
                         // Clear some fields and submit
                         ->vueClear('#first_name')
                         ->vueClear('#last_name')
                         ->click('button[type=submit]');
                 })
                 ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.')
                 ->on(new UserList())
                 ->click('@table tr:nth-child(3) a')
                 ->on(new UserInfo())
                 ->assertSeeIn('#user-info .card-title', 'User account')
                 ->with('@general', function (Browser $browser) {
                     // Test error handling (password)
                     $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.')
                         ->assertFocused('#password')
                         ->assertToast(Toast::TYPE_ERROR, 'Form validation error');
 
                     // TODO: Test password change
 
                     // Test form error handling (aliases)
                     $browser->vueClear('#password')
                         ->vueClear('#password_confirmation')
                         ->with(new ListInput('#aliases'), function (Browser $browser) {
                             $browser->addListEntry('invalid address');
                         })
                         ->click('button[type=submit]')
                         ->assertToast(Toast::TYPE_ERROR, 'Form validation error');
 
                     $browser->with(new ListInput('#aliases'), function (Browser $browser) {
                         $browser->assertFormError(2, 'The specified alias is invalid.', false);
                     });
 
                     // Test adding aliases
                     $browser->with(new ListInput('#aliases'), function (Browser $browser) {
                         $browser->removeListEntry(2)
                             ->addListEntry('john.test@kolab.org');
                     })
                         ->click('button[type=submit]')
                         ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
                 })
                 ->on(new UserList())
                 ->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();
             $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)')
                     ->with('@skus', function ($browser) {
                         $browser->assertElementsCount('tbody tr', 6)
                             // Mailbox SKU
                             ->assertSeeIn('tbody tr:nth-child(1) td.name', 'User Mailbox')
                             ->assertSeeIn('tbody tr:nth-child(1) td.price', '5,00 CHF/month')
                             ->assertChecked('tbody tr:nth-child(1) td.selection input')
                             ->assertDisabled('tbody tr:nth-child(1) td.selection input')
                             ->assertTip(
                                 'tbody tr:nth-child(1) td.buttons button',
                                 'Just a mailbox'
                             )
                             // Storage SKU
                             ->assertSeeIn('tbody tr:nth-child(2) td.name', 'Storage Quota')
                             ->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month')
                             ->assertChecked('tbody tr:nth-child(2) td.selection input')
                             ->assertDisabled('tbody tr:nth-child(2) td.selection input')
                             ->assertTip(
                                 'tbody tr:nth-child(2) td.buttons button',
                                 'Some wiggle room'
                             )
                             ->with(new QuotaInput('tbody tr:nth-child(2) .range-input'), function ($browser) {
                                 $browser->assertQuotaValue(5)->setQuotaValue(6);
                             })
                             ->assertSeeIn('tr:nth-child(2) td.price', '0,25 CHF/month')
                             // groupware SKU
                             ->assertSeeIn('tbody tr:nth-child(3) td.name', 'Groupware Features')
                             ->assertSeeIn('tbody tr:nth-child(3) td.price', '4,90 CHF/month')
                             ->assertChecked('tbody tr:nth-child(3) td.selection input')
                             ->assertEnabled('tbody tr:nth-child(3) td.selection input')
                             ->assertTip(
                                 'tbody tr:nth-child(3) td.buttons button',
                                 'Groupware functions like Calendar, Tasks, Notes, etc.'
                             )
                             // ActiveSync SKU
                             ->assertSeeIn('tbody tr:nth-child(4) td.name', 'Activesync')
                             ->assertSeeIn('tbody tr:nth-child(4) td.price', '0,00 CHF/month')
                             ->assertNotChecked('tbody tr:nth-child(4) td.selection input')
                             ->assertEnabled('tbody tr:nth-child(4) td.selection input')
                             ->assertTip(
                                 'tbody tr:nth-child(4) td.buttons button',
                                 'Mobile synchronization'
                             )
                             // 2FA SKU
                             ->assertSeeIn('tbody tr:nth-child(5) td.name', '2-Factor Authentication')
                             ->assertSeeIn('tbody tr:nth-child(5) td.price', '0,00 CHF/month')
                             ->assertNotChecked('tbody tr:nth-child(5) td.selection input')
                             ->assertEnabled('tbody tr:nth-child(5) td.selection input')
                             ->assertTip(
                                 'tbody tr:nth-child(5) td.buttons button',
                                 'Two factor authentication for webmail and administration panel'
                             )
                             // Meet SKU
                             ->assertSeeIn('tbody tr:nth-child(6) td.name', 'Voice & Video Conferencing (public beta)')
                             ->assertSeeIn('tbody tr:nth-child(6) td.price', '0,00 CHF/month')
                             ->assertNotChecked('tbody tr:nth-child(6) td.selection input')
                             ->assertEnabled('tbody tr:nth-child(6) td.selection input')
                             ->assertTip(
                                 'tbody tr:nth-child(6) td.buttons button',
                                 'Video conferencing tool'
                             )
                             ->click('tbody tr:nth-child(4) td.selection input');
                     })
                     ->assertMissing('@skus table + .hint')
                     ->click('button[type=submit]')
                     ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
             })
                 ->on(new UserList())
                 ->click('@table tr:nth-child(3) a')
                 ->on(new UserInfo());
 
             $expected = ['activesync', 'groupware', 'mailbox',
                 'storage', 'storage', 'storage', 'storage', 'storage', 'storage'];
             $this->assertEntitlements($john, $expected);
 
             // Test subscriptions interaction
             $browser->with('@general', function (Browser $browser) {
                 $browser->with('@skus', function ($browser) {
                     // Uncheck 'groupware', expect activesync unchecked
                     $browser->click('#sku-input-groupware')
                         ->assertNotChecked('#sku-input-groupware')
                         ->assertNotChecked('#sku-input-activesync')
                         ->assertEnabled('#sku-input-activesync')
                         ->assertNotReadonly('#sku-input-activesync')
                         // Check 'activesync', expect an alert
                         ->click('#sku-input-activesync')
                         ->assertDialogOpened('Activesync requires Groupware Features.')
                         ->acceptDialog()
                         ->assertNotChecked('#sku-input-activesync')
                         // Check 'meet', expect an alert
                         ->click('#sku-input-meet')
                         ->assertDialogOpened('Voice & Video Conferencing (public beta) requires Groupware Features.')
                         ->acceptDialog()
                         ->assertNotChecked('#sku-input-meet')
                         // Check '2FA', expect 'activesync' unchecked and readonly
                         ->click('#sku-input-2fa')
                         ->assertChecked('#sku-input-2fa')
                         ->assertNotChecked('#sku-input-activesync')
                         ->assertReadonly('#sku-input-activesync')
                         // Uncheck '2FA'
                         ->click('#sku-input-2fa')
                         ->assertNotChecked('#sku-input-2fa')
                         ->assertNotReadonly('#sku-input-activesync');
                 });
             });
         });
     }
 
     /**
      * Test user settings tab
      *
      * @depends testInfo
      */
     public function testUserSettings(): void
     {
         $john = $this->getTestUser('john@kolab.org');
         $john->setSetting('greylist_enabled', null);
 
         $this->browse(function (Browser $browser) use ($john) {
             $browser->visit('/user/' . $john->id)
                 ->on(new UserInfo())
                 ->assertElementsCount('@nav a', 2)
                 ->assertSeeIn('@nav #tab-general', 'General')
                 ->assertSeeIn('@nav #tab-settings', 'Settings')
                 ->click('@nav #tab-settings')
                 ->with('#settings form', function (Browser $browser) {
                     $browser->assertSeeIn('div.row:nth-child(1) label', 'Greylisting')
                         ->click('div.row:nth-child(1) input[type=checkbox]:checked')
                         ->click('button[type=submit]')
                         ->assertToast(Toast::TYPE_SUCCESS, 'User settings updated successfully.');
                 });
         });
 
         $this->assertSame('false', $john->fresh()->getSetting('greylist_enabled'));
     }
 
     /**
      * Test user adding page
      *
-     * @depends testList
+     * @depends testInfo
      */
     public function testNewUser(): void
     {
         $this->browse(function (Browser $browser) {
             $browser->visit(new UserList())
                 ->assertSeeIn('button.create-user', 'Create user')
                 ->click('button.create-user')
                 ->on(new UserInfo())
                 ->assertSeeIn('#user-info .card-title', 'New user account')
                 ->with('@general', function (Browser $browser) {
                     // Assert form content
                     $browser->assertFocused('div.row:nth-child(1) input')
                         ->assertSeeIn('div.row:nth-child(1) label', 'First Name')
                         ->assertValue('div.row:nth-child(1) input[type=text]', '')
                         ->assertSeeIn('div.row:nth-child(2) label', 'Last Name')
                         ->assertValue('div.row:nth-child(2) input[type=text]', '')
                         ->assertSeeIn('div.row:nth-child(3) label', 'Organization')
                         ->assertValue('div.row:nth-child(3) input[type=text]', '')
                         ->assertSeeIn('div.row:nth-child(4) label', 'Email')
                         ->assertValue('div.row:nth-child(4) input[type=text]', '')
                         ->assertEnabled('div.row:nth-child(4) input[type=text]')
                         ->assertSeeIn('div.row:nth-child(5) label', 'Email Aliases')
                         ->assertVisible('div.row:nth-child(5) .list-input')
                         ->with(new ListInput('#aliases'), function (Browser $browser) {
                             $browser->assertListInputValue([])
                                 ->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')
                         // assert packages list widget, select "Lite Account"
                         ->with('@packages', function ($browser) {
                             $browser->assertElementsCount('tbody tr', 2)
                                 ->assertSeeIn('tbody tr:nth-child(1)', 'Groupware Account')
                                 ->assertSeeIn('tbody tr:nth-child(2)', 'Lite Account')
                                 ->assertSeeIn('tbody tr:nth-child(1) .price', '9,90 CHF/month')
                                 ->assertSeeIn('tbody tr:nth-child(2) .price', '5,00 CHF/month')
                                 ->assertChecked('tbody tr:nth-child(1) input')
                                 ->click('tbody tr:nth-child(2) input')
                                 ->assertNotChecked('tbody tr:nth-child(1) input')
                                 ->assertChecked('tbody tr:nth-child(2) input');
                         })
                         ->assertMissing('@packages table + .hint')
                         ->assertSeeIn('button[type=submit]', 'Submit');
 
                     // Test browser-side required fields and error handling
                     $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.');
                 });
 
             // Test form error handling (aliases)
             $browser->with('@general', function (Browser $browser) {
                 $browser->type('#email', 'julia.roberts@kolab.org')
                     ->type('#password_confirmation', 'simple123')
                     ->with(new ListInput('#aliases'), function (Browser $browser) {
                         $browser->addListEntry('invalid address');
                     })
                     ->click('button[type=submit]')
                     ->assertToast(Toast::TYPE_ERROR, 'Form validation error')
                     ->with(new ListInput('#aliases'), function (Browser $browser) {
                         $browser->assertFormError(1, 'The specified alias is invalid.', false);
                     });
             });
 
             // Successful account creation
             $browser->with('@general', function (Browser $browser) {
                 $browser->type('#first_name', 'Julia')
                     ->type('#last_name', 'Roberts')
                     ->type('#organization', 'Test Org')
                     ->with(new ListInput('#aliases'), function (Browser $browser) {
                         $browser->removeListEntry(1)
                             ->addListEntry('julia.roberts2@kolab.org');
                     })
                     ->click('button[type=submit]');
             })
             ->assertToast(Toast::TYPE_SUCCESS, 'User created successfully.')
             // check redirection to users list
             ->on(new UserList())
                 ->whenAvailable('@table', function (Browser $browser) {
                     $browser->assertElementsCount('tbody tr', 5)
                         ->assertSeeIn('tbody tr:nth-child(4) a', 'julia.roberts@kolab.org');
                 });
 
             $julia = User::where('email', 'julia.roberts@kolab.org')->first();
             $alias = UserAlias::where('user_id', $julia->id)->where('alias', 'julia.roberts2@kolab.org')->first();
 
             $this->assertTrue(!empty($alias));
             $this->assertEntitlements($julia, ['mailbox', 'storage', 'storage', 'storage', 'storage', 'storage']);
             $this->assertSame('Julia', $julia->getSetting('first_name'));
             $this->assertSame('Roberts', $julia->getSetting('last_name'));
             $this->assertSame('Test Org', $julia->getSetting('organization'));
 
             // Some additional tests for the list input widget
             $browser->click('@table tbody tr:nth-child(4) a')
                 ->on(new UserInfo())
                 ->with(new ListInput('#aliases'), function (Browser $browser) {
                     $browser->assertListInputValue(['julia.roberts2@kolab.org'])
                         ->addListEntry('invalid address')
                         ->type('.input-group:nth-child(2) input', '@kolab.org')
                         ->keys('.input-group:nth-child(2) input', '{enter}');
                 })
                 // TODO: Investigate why this click does not work, for now we
                 // submit the form with Enter key above
                 //->click('@general button[type=submit]')
                 ->assertToast(Toast::TYPE_ERROR, 'Form validation error')
                 ->with(new ListInput('#aliases'), function (Browser $browser) {
                     $browser->assertVisible('.input-group:nth-child(2) input.is-invalid')
                         ->assertVisible('.input-group:nth-child(3) input.is-invalid')
                         ->type('.input-group:nth-child(2) input', 'julia.roberts3@kolab.org')
                         ->type('.input-group:nth-child(3) input', 'julia.roberts4@kolab.org')
                         ->keys('.input-group:nth-child(3) input', '{enter}');
                 })
                 // TODO: Investigate why this click does not work, for now we
                 // submit the form with Enter key above
                 //->click('@general button[type=submit]')
                 ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
 
             $julia = User::where('email', 'julia.roberts@kolab.org')->first();
             $aliases = $julia->aliases()->orderBy('alias')->get()->pluck('alias')->all();
 
             $this->assertSame(['julia.roberts3@kolab.org', 'julia.roberts4@kolab.org'], $aliases);
         });
     }
 
     /**
      * Test user delete
      *
      * @depends testNewUser
      */
     public function testDeleteUser(): void
     {
         // First create a new user
         $john = $this->getTestUser('john@kolab.org');
         $julia = $this->getTestUser('julia.roberts@kolab.org');
         $package_kolab = \App\Package::where('title', 'kolab')->first();
         $john->assignPackage($package_kolab, $julia);
 
         // Test deleting non-controller user
         $this->browse(function (Browser $browser) use ($julia) {
             $browser->visit('/user/' . $julia->id)
                 ->on(new UserInfo())
                 ->assertSeeIn('button.button-delete', 'Delete user')
                 ->click('button.button-delete')
                 ->with(new Dialog('#delete-warning'), function (Browser $browser) {
                     $browser->assertSeeIn('@title', 'Delete julia.roberts@kolab.org')
                         ->assertFocused('@button-cancel')
                         ->assertSeeIn('@button-cancel', 'Cancel')
                         ->assertSeeIn('@button-action', 'Delete')
                         ->click('@button-cancel');
                 })
                 ->waitUntilMissing('#delete-warning')
                 ->click('button.button-delete')
                 ->with(new Dialog('#delete-warning'), function (Browser $browser) {
                     $browser->click('@button-action');
                 })
                 ->waitUntilMissing('#delete-warning')
                 ->assertToast(Toast::TYPE_SUCCESS, 'User deleted successfully.')
                 ->on(new UserList())
                 ->with('@table', function (Browser $browser) {
                     $browser->assertElementsCount('tbody tr', 4)
                         ->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org')
                         ->assertSeeIn('tbody tr:nth-child(2) a', 'joe@kolab.org')
                         ->assertSeeIn('tbody tr:nth-child(3) a', 'john@kolab.org')
                         ->assertSeeIn('tbody tr:nth-child(4) a', 'ned@kolab.org');
                 });
 
             $julia = User::where('email', 'julia.roberts@kolab.org')->first();
             $this->assertTrue(empty($julia));
         });
 
         // Test that non-controller user cannot see/delete himself on the users list
         $this->browse(function (Browser $browser) {
             $browser->visit('/logout')
                 ->on(new Home())
                 ->submitLogon('jack@kolab.org', 'simple123', true)
                 ->visit('/users')
                 ->assertErrorPage(403);
         });
 
         // Test that controller user (Ned) can see all the users
         $this->browse(function (Browser $browser) {
             $browser->visit('/logout')
                 ->on(new Home())
                 ->submitLogon('ned@kolab.org', 'simple123', true)
                 ->visit(new UserList())
                 ->whenAvailable('@table', function (Browser $browser) {
                     $browser->assertElementsCount('tbody tr', 4);
                 });
 
                 // TODO: Test the delete action in details
         });
 
         // TODO: Test what happens with the logged in user session after he's been deleted by another user
     }
 
     /**
      * Test discounted sku/package prices in the UI
      */
     public function testDiscountedPrices(): void
     {
         // Add 10% discount
         $discount = Discount::where('code', 'TEST')->first();
         $john = User::where('email', 'john@kolab.org')->first();
         $wallet = $john->wallet();
         $wallet->discount()->associate($discount);
         $wallet->save();
 
         // SKUs on user edit page
         $this->browse(function (Browser $browser) {
             $browser->visit('/logout')
                 ->on(new Home())
                 ->submitLogon('john@kolab.org', 'simple123', true)
                 ->visit(new UserList())
                 ->waitFor('@table tr:nth-child(2)')
                 ->click('@table tr:nth-child(2) a') // joe@kolab.org
                 ->on(new UserInfo())
                 ->with('@general', function (Browser $browser) {
                     $browser->whenAvailable('@skus', function (Browser $browser) {
                         $quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input');
                         $browser->waitFor('tbody tr')
                             ->assertElementsCount('tbody tr', 6)
                             // Mailbox SKU
                             ->assertSeeIn('tbody tr:nth-child(1) td.price', '4,50 CHF/month¹')
                             // Storage SKU
                             ->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month¹')
                             ->with($quota_input, function (Browser $browser) {
                                 $browser->setQuotaValue(100);
                             })
                             ->assertSeeIn('tr:nth-child(2) td.price', '21,37 CHF/month¹')
                             // groupware SKU
                             ->assertSeeIn('tbody tr:nth-child(3) td.price', '4,41 CHF/month¹')
                             // ActiveSync SKU
                             ->assertSeeIn('tbody tr:nth-child(4) td.price', '0,00 CHF/month¹')
                             // 2FA SKU
                             ->assertSeeIn('tbody tr:nth-child(5) td.price', '0,00 CHF/month¹');
                     })
                     ->assertSeeIn('@skus table + .hint', '¹ applied discount: 10% - Test voucher');
                 });
         });
 
         // Packages on new user page
         $this->browse(function (Browser $browser) {
             $browser->visit(new UserList())
                 ->click('button.create-user')
                 ->on(new UserInfo())
                 ->with('@general', function (Browser $browser) {
                     $browser->whenAvailable('@packages', function (Browser $browser) {
                         $browser->assertElementsCount('tbody tr', 2)
                             ->assertSeeIn('tbody tr:nth-child(1) .price', '8,91 CHF/month¹') // Groupware
                             ->assertSeeIn('tbody tr:nth-child(2) .price', '4,50 CHF/month¹'); // Lite
                     })
                     ->assertSeeIn('@packages table + .hint', '¹ applied discount: 10% - Test voucher');
                 });
         });
 
         // Test using entitlement cost instead of the SKU cost
         $this->browse(function (Browser $browser) use ($wallet) {
             $joe = User::where('email', 'joe@kolab.org')->first();
             $beta_sku = Sku::withEnvTenantContext()->where('title', 'beta')->first();
             $storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first();
 
             // Add an extra storage and beta entitlement with different prices
             Entitlement::create([
                     'wallet_id' => $wallet->id,
                     'sku_id' => $beta_sku->id,
                     'cost' => 5010,
                     'entitleable_id' => $joe->id,
                     'entitleable_type' => User::class
             ]);
             Entitlement::create([
                     'wallet_id' => $wallet->id,
                     'sku_id' => $storage_sku->id,
                     'cost' => 5000,
                     'entitleable_id' => $joe->id,
                     'entitleable_type' => User::class
             ]);
 
             $browser->visit('/user/' . $joe->id)
                 ->on(new UserInfo())
                 ->with('@general', function (Browser $browser) {
                     $browser->whenAvailable('@skus', function (Browser $browser) {
                         $quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input');
                         $browser->waitFor('tbody tr')
                             // Beta SKU
                             ->assertSeeIn('tbody tr:nth-child(7) td.price', '45,09 CHF/month¹')
                             // Storage SKU
                             ->assertSeeIn('tr:nth-child(2) td.price', '45,00 CHF/month¹')
                             ->with($quota_input, function (Browser $browser) {
                                 $browser->setQuotaValue(7);
                             })
                             ->assertSeeIn('tr:nth-child(2) td.price', '45,22 CHF/month¹')
                             ->with($quota_input, function (Browser $browser) {
                                 $browser->setQuotaValue(5);
                             })
                             ->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month¹');
                     })
                     ->assertSeeIn('@skus table + .hint', '¹ applied discount: 10% - Test voucher');
                 });
         });
     }
 
     /**
      * Test non-default currency in the UI
      */
     public function testCurrency(): void
     {
         // Add 10% discount
         $john = User::where('email', 'john@kolab.org')->first();
         $wallet = $john->wallet();
         $wallet->balance = -1000;
         $wallet->currency = 'EUR';
         $wallet->save();
 
         // On Dashboard and the wallet page
         $this->browse(function (Browser $browser) {
             $browser->visit('/logout')
                 ->on(new Home())
                 ->submitLogon('john@kolab.org', 'simple123', true)
                 ->on(new Dashboard())
                 ->assertSeeIn('@links .link-wallet .badge', '-10,00 €')
                 ->click('@links .link-wallet')
                 ->on(new WalletPage())
                 ->assertSeeIn('#wallet .card-title', 'Account balance -10,00 €');
         });
 
         // SKUs on user edit page
         $this->browse(function (Browser $browser) {
             $browser->visit(new UserList())
                 ->waitFor('@table tr:nth-child(2)')
                 ->click('@table tr:nth-child(2) a') // joe@kolab.org
                 ->on(new UserInfo())
                 ->with('@general', function (Browser $browser) {
                     $browser->whenAvailable('@skus', function (Browser $browser) {
                         $quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input');
                         $browser->waitFor('tbody tr')
                             ->assertElementsCount('tbody tr', 6)
                             // Mailbox SKU
                             ->assertSeeIn('tbody tr:nth-child(1) td.price', '5,00 €/month')
                             // Storage SKU
                             ->assertSeeIn('tr:nth-child(2) td.price', '0,00 €/month')
                             ->with($quota_input, function (Browser $browser) {
                                 $browser->setQuotaValue(100);
                             })
                             ->assertSeeIn('tr:nth-child(2) td.price', '23,75 €/month');
                     });
                 });
         });
 
         // Packages on new user page
         $this->browse(function (Browser $browser) {
             $browser->visit(new UserList())
                 ->click('button.create-user')
                 ->on(new UserInfo())
                 ->with('@general', function (Browser $browser) {
                     $browser->whenAvailable('@packages', function (Browser $browser) {
                         $browser->assertElementsCount('tbody tr', 2)
                             ->assertSeeIn('tbody tr:nth-child(1) .price', '9,90 €/month') // Groupware
                             ->assertSeeIn('tbody tr:nth-child(2) .price', '5,00 €/month'); // Lite
                     });
                 });
         });
     }
 
     /**
      * Test beta entitlements
      *
-     * @depends testList
+     * @depends testInfo
      */
     public function testBetaEntitlements(): void
     {
         $this->browse(function (Browser $browser) {
             $john = User::where('email', 'john@kolab.org')->first();
             $sku = Sku::withEnvTenantContext()->where('title', 'beta')->first();
             $john->assignSku($sku);
 
             $browser->visit('/user/' . $john->id)
                 ->on(new UserInfo())
                 ->with('@skus', function ($browser) {
                     $browser->assertElementsCount('tbody tr', 8)
                         // Meet SKU
                         ->assertSeeIn('tbody tr:nth-child(6) td.name', 'Voice & Video Conferencing (public beta)')
                         ->assertSeeIn('tr:nth-child(6) td.price', '0,00 CHF/month')
                         ->assertNotChecked('tbody tr:nth-child(6) td.selection input')
                         ->assertEnabled('tbody tr:nth-child(6) td.selection input')
                         ->assertTip(
                             'tbody tr:nth-child(6) td.buttons button',
                             'Video conferencing tool'
                         )
                         // Beta SKU
                         ->assertSeeIn('tbody tr:nth-child(7) td.name', 'Private Beta (invitation only)')
                         ->assertSeeIn('tbody tr:nth-child(7) td.price', '0,00 CHF/month')
                         ->assertChecked('tbody tr:nth-child(7) td.selection input')
                         ->assertEnabled('tbody tr:nth-child(7) td.selection input')
                         ->assertTip(
                             'tbody tr:nth-child(7) td.buttons button',
                             'Access to the private beta program subscriptions'
                         )
                         // Distlist SKU
                         ->assertSeeIn('tbody tr:nth-child(8) td.name', 'Distribution lists')
                         ->assertSeeIn('tr:nth-child(8) td.price', '0,00 CHF/month')
                         ->assertNotChecked('tbody tr:nth-child(8) td.selection input')
                         ->assertEnabled('tbody tr:nth-child(8) td.selection input')
                         ->assertTip(
                             'tbody tr:nth-child(8) td.buttons button',
                             'Access to mail distribution lists'
                         )
                         // Check Distlist, Uncheck Beta, expect Distlist unchecked
                         ->click('#sku-input-distlist')
                         ->click('#sku-input-beta')
                         ->assertNotChecked('#sku-input-beta')
                         ->assertNotChecked('#sku-input-distlist')
                         // Click Distlist expect an alert
                         ->click('#sku-input-distlist')
                         ->assertDialogOpened('Distribution lists requires Private Beta (invitation only).')
                         ->acceptDialog()
                         // Enable Beta and Distlist and submit
                         ->click('#sku-input-beta')
                         ->click('#sku-input-distlist');
                 })
                 ->click('@general button[type=submit]')
                 ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
 
             $expected = [
                 'beta',
                 'distlist',
                 'groupware',
                 'mailbox',
                 'storage', 'storage', 'storage', 'storage', 'storage'
             ];
 
             $this->assertEntitlements($john, $expected);
 
             $browser->visit('/user/' . $john->id)
                 ->on(new UserInfo())
                 ->waitFor('#sku-input-beta')
                 ->click('#sku-input-beta')
                 ->click('@general button[type=submit]')
                 ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
 
             $expected = [
                 'groupware',
                 'mailbox',
                 'storage', 'storage', 'storage', 'storage', 'storage'
             ];
 
             $this->assertEntitlements($john, $expected);
         });
 
         // TODO: Test that the Distlist SKU is not available for users that aren't a group account owners
         // TODO: Test that entitlements change has immediate effect on the available items in dashboard
         //       i.e. does not require a page reload nor re-login.
     }
 }
diff --git a/src/tests/Browser/WalletTest.php b/src/tests/Browser/WalletTest.php
index 0ba73cc4..df2149ee 100644
--- a/src/tests/Browser/WalletTest.php
+++ b/src/tests/Browser/WalletTest.php
@@ -1,276 +1,275 @@
 <?php
 
 namespace Tests\Browser;
 
 use App\Payment;
 use App\Providers\PaymentProvider;
 use App\Transaction;
 use App\Wallet;
 use Carbon\Carbon;
 use Tests\Browser;
 use Tests\Browser\Pages\Dashboard;
 use Tests\Browser\Pages\Home;
 use Tests\Browser\Pages\Wallet as WalletPage;
 use Tests\TestCaseDusk;
 
 class WalletTest extends TestCaseDusk
 {
     /**
      * {@inheritDoc}
      */
     public function setUp(): void
     {
         parent::setUp();
 
         $this->deleteTestUser('wallets-controller@kolabnow.com');
 
         $john = $this->getTestUser('john@kolab.org');
         Wallet::where('user_id', $john->id)->update(['balance' => -1234]);
     }
 
     /**
      * {@inheritDoc}
      */
     public function tearDown(): void
     {
         $this->deleteTestUser('wallets-controller@kolabnow.com');
 
         $john = $this->getTestUser('john@kolab.org');
         Wallet::where('user_id', $john->id)->update(['balance' => 0]);
 
-
         parent::tearDown();
     }
 
     /**
      * Test wallet page (unauthenticated)
      */
     public function testWalletUnauth(): void
     {
         // Test that the page requires authentication
         $this->browse(function (Browser $browser) {
             $browser->visit('/wallet')->on(new Home());
         });
     }
 
     /**
      * Test wallet "box" on Dashboard
      */
     public function testDashboard(): void
     {
         // Test that the page requires authentication
         $this->browse(function (Browser $browser) {
             $browser->visit(new Home())
                 ->submitLogon('john@kolab.org', 'simple123', true)
                 ->on(new Dashboard())
                 ->assertSeeIn('@links .link-wallet .name', 'Wallet')
                 ->assertSeeIn('@links .link-wallet .badge', '-12,34 CHF');
         });
     }
 
     /**
      * Test wallet page
      *
      * @depends testDashboard
      */
     public function testWallet(): void
     {
         $this->browse(function (Browser $browser) {
             $browser->click('@links .link-wallet')
                 ->on(new WalletPage())
                 ->assertSeeIn('#wallet .card-title', 'Account balance -12,34 CHF')
                 ->assertSeeIn('#wallet .card-title .text-danger', '-12,34 CHF')
                 ->assertSeeIn('#wallet .card-text', 'You are out of credit');
         });
     }
 
     /**
      * Test Receipts tab
      */
     public function testReceipts(): void
     {
         $user = $this->getTestUser('wallets-controller@kolabnow.com', ['password' => 'simple123']);
         $wallet = $user->wallets()->first();
         $wallet->payments()->delete();
 
         // Log out John and log in the test user
         $this->browse(function (Browser $browser) {
             $browser->visit('/logout')
                 ->waitForLocation('/login')
                 ->on(new Home())
                 ->submitLogon('wallets-controller@kolabnow.com', 'simple123', true);
         });
 
         // Assert Receipts tab content when there's no receipts available
         $this->browse(function (Browser $browser) {
             $browser->on(new Dashboard())
                 ->click('@links .link-wallet')
                 ->on(new WalletPage())
                 ->assertSeeIn('#wallet .card-title', 'Account balance 0,00 CHF')
                 ->assertSeeIn('#wallet .card-title .text-success', '0,00 CHF')
                 ->assertSeeIn('#wallet .card-text', 'You are in your free trial period.')
                 ->assertSeeIn('@nav #tab-receipts', 'Receipts')
                 ->with('@receipts-tab', function (Browser $browser) {
                     $browser->waitUntilMissing('.app-loader')
                         ->assertSeeIn('p', 'There are no receipts for payments')
                         ->assertDontSeeIn('p', 'Here you can download')
                         ->assertMissing('select')
                         ->assertMissing('button');
                 });
         });
 
         // Create some sample payments
         $receipts = [];
         $date = Carbon::create(intval(date('Y')) - 1, 3, 30);
         $payment = Payment::create([
                 'id' => 'AAA1',
                 'status' => PaymentProvider::STATUS_PAID,
                 'type' => PaymentProvider::TYPE_ONEOFF,
                 'description' => 'Paid in March',
                 'wallet_id' => $wallet->id,
                 'provider' => 'stripe',
                 'amount' => 1111,
                 'currency_amount' => 1111,
                 'currency' => 'CHF',
         ]);
         $payment->updated_at = $date;
         $payment->save();
         $receipts[] = $date->format('Y-m');
 
         $date = Carbon::create(intval(date('Y')) - 1, 4, 30);
         $payment = Payment::create([
                 'id' => 'AAA2',
                 'status' => PaymentProvider::STATUS_PAID,
                 'type' => PaymentProvider::TYPE_ONEOFF,
                 'description' => 'Paid in April',
                 'wallet_id' => $wallet->id,
                 'provider' => 'stripe',
                 'amount' => 1111,
                 'currency_amount' => 1111,
                 'currency' => 'CHF',
         ]);
         $payment->updated_at = $date;
         $payment->save();
         $receipts[] = $date->format('Y-m');
 
         // Assert Receipts tab with receipts available
         $this->browse(function (Browser $browser) use ($receipts) {
             $browser->refresh()
                 ->on(new WalletPage())
                 ->assertSeeIn('@nav #tab-receipts', 'Receipts')
                 ->with('@receipts-tab', function (Browser $browser) use ($receipts) {
                     $browser->waitUntilMissing('.app-loader')
                         ->assertDontSeeIn('p', 'There are no receipts for payments')
                         ->assertSeeIn('p', 'Here you can download')
                         ->assertSeeIn('button', 'Download')
                         ->assertElementsCount('select > option', 2)
                         ->assertSeeIn('select > option:nth-child(1)', $receipts[1])
                         ->assertSeeIn('select > option:nth-child(2)', $receipts[0]);
 
                     // Download a receipt file
                     $browser->select('select', $receipts[0])
                         ->click('button')
                         ->pause(2000);
 
                     $files = glob(__DIR__ . '/downloads/*.pdf');
 
                     $filename = pathinfo($files[0], PATHINFO_BASENAME);
                     $this->assertTrue(strpos($filename, $receipts[0]) !== false);
 
                     $content = $browser->readDownloadedFile($filename, 0);
                     $this->assertStringStartsWith("%PDF-1.", $content);
 
                     $browser->removeDownloadedFile($filename);
                 });
         });
     }
 
     /**
      * Test History tab
      */
     public function testHistory(): void
     {
         $user = $this->getTestUser('wallets-controller@kolabnow.com', ['password' => 'simple123']);
 
         // Log out John and log in the test user
         $this->browse(function (Browser $browser) {
             $browser->visit('/logout')
                 ->waitForLocation('/login')
                 ->on(new Home())
                 ->submitLogon('wallets-controller@kolabnow.com', 'simple123', true);
         });
 
         $package_kolab = \App\Package::where('title', 'kolab')->first();
         $user->assignPackage($package_kolab);
         $wallet = $user->wallets()->first();
 
         // Create some sample transactions
         $transactions = $this->createTestTransactions($wallet);
         $transactions = array_reverse($transactions);
         $pages = array_chunk($transactions, 10 /* page size*/);
 
         $this->browse(function (Browser $browser) use ($pages) {
             $browser->on(new Dashboard())
                 ->click('@links .link-wallet')
                 ->on(new WalletPage())
                 ->assertSeeIn('@nav #tab-history', 'History')
                 ->click('@nav #tab-history')
                 ->with('@history-tab', function (Browser $browser) use ($pages) {
                     $browser->waitUntilMissing('.app-loader')
                         ->assertElementsCount('table tbody tr', 10)
                         ->assertMissing('table td.email')
-                        ->assertSeeIn('#transactions-loader button', 'Load more');
+                        ->assertSeeIn('.more-loader button', 'Load more');
 
                     foreach ($pages[0] as $idx => $transaction) {
                         $selector = 'table tbody tr:nth-child(' . ($idx + 1) . ')';
                         $priceStyle = $transaction->type == Transaction::WALLET_AWARD ? 'text-success' : 'text-danger';
                         $browser->assertSeeIn("$selector td.description", $transaction->shortDescription())
                             ->assertMissing("$selector td.selection button")
                             ->assertVisible("$selector td.price.{$priceStyle}");
                         // TODO: Test more transaction details
                     }
 
                     // Load the next page
-                    $browser->click('#transactions-loader button')
+                    $browser->click('.more-loader button')
                         ->waitUntilMissing('.app-loader')
                         ->assertElementsCount('table tbody tr', 12)
-                        ->assertMissing('#transactions-loader button');
+                        ->assertMissing('.more-loader button');
 
                     $debitEntry = null;
                     foreach ($pages[1] as $idx => $transaction) {
                         $selector = 'table tbody tr:nth-child(' . ($idx + 1 + 10) . ')';
                         $priceStyle = $transaction->type == Transaction::WALLET_CREDIT ? 'text-success' : 'text-danger';
                         $browser->assertSeeIn("$selector td.description", $transaction->shortDescription());
 
                         if ($transaction->type == Transaction::WALLET_DEBIT) {
                             $debitEntry = $selector;
                         } else {
                             $browser->assertMissing("$selector td.selection button");
                         }
                     }
 
                     // Load sub-transactions
                     $browser->click("$debitEntry td.selection button")
                         ->waitUntilMissing('.app-loader')
                         ->assertElementsCount("$debitEntry td.description ul li", 2)
                         ->assertMissing("$debitEntry td.selection button");
                 });
         });
     }
 
     /**
      * Test that non-controller user has no access to wallet
      */
     public function testAccessDenied(): void
     {
         $this->browse(function (Browser $browser) {
             $browser->visit('/logout')
                 ->on(new Home())
                 ->submitLogon('jack@kolab.org', 'simple123', true)
                 ->on(new Dashboard())
                 ->assertMissing('@links .link-wallet')
                 ->visit('/wallet')
                 ->assertErrorPage(403, "Only account owners can access a wallet.");
         });
     }
 }
diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php
index e00019b6..459213f6 100644
--- a/src/tests/Feature/Controller/UsersTest.php
+++ b/src/tests/Feature/Controller/UsersTest.php
@@ -1,1357 +1,1398 @@
 <?php
 
 namespace Tests\Feature\Controller;
 
 use App\Discount;
 use App\Domain;
 use App\Http\Controllers\API\V4\UsersController;
 use App\Package;
 use App\Sku;
 use App\User;
 use App\Wallet;
 use Carbon\Carbon;
 use Illuminate\Support\Facades\Queue;
 use Illuminate\Support\Str;
 use Tests\TestCase;
 
 class UsersTest extends TestCase
 {
     /**
      * {@inheritDoc}
      */
     public function setUp(): void
     {
         parent::setUp();
 
         $this->deleteTestUser('jane@kolabnow.com');
         $this->deleteTestUser('UsersControllerTest1@userscontroller.com');
         $this->deleteTestUser('UsersControllerTest2@userscontroller.com');
         $this->deleteTestUser('UsersControllerTest3@userscontroller.com');
         $this->deleteTestUser('UserEntitlement2A@UserEntitlement.com');
         $this->deleteTestUser('john2.doe2@kolab.org');
         $this->deleteTestUser('deleted@kolab.org');
         $this->deleteTestUser('deleted@kolabnow.com');
         $this->deleteTestDomain('userscontroller.com');
         $this->deleteTestGroup('group-test@kolabnow.com');
         $this->deleteTestGroup('group-test@kolab.org');
 
         $user = $this->getTestUser('john@kolab.org');
         $wallet = $user->wallets()->first();
         $wallet->discount()->dissociate();
         $wallet->settings()->whereIn('key', ['mollie_id', 'stripe_id'])->delete();
         $wallet->save();
         $user->status |= User::STATUS_IMAP_READY;
         $user->save();
     }
 
     /**
      * {@inheritDoc}
      */
     public function tearDown(): void
     {
         $this->deleteTestUser('jane@kolabnow.com');
         $this->deleteTestUser('UsersControllerTest1@userscontroller.com');
         $this->deleteTestUser('UsersControllerTest2@userscontroller.com');
         $this->deleteTestUser('UsersControllerTest3@userscontroller.com');
         $this->deleteTestUser('UserEntitlement2A@UserEntitlement.com');
         $this->deleteTestUser('john2.doe2@kolab.org');
         $this->deleteTestUser('deleted@kolab.org');
         $this->deleteTestUser('deleted@kolabnow.com');
         $this->deleteTestDomain('userscontroller.com');
         $this->deleteTestGroup('group-test@kolabnow.com');
         $this->deleteTestGroup('group-test@kolab.org');
 
         $user = $this->getTestUser('john@kolab.org');
         $wallet = $user->wallets()->first();
         $wallet->discount()->dissociate();
         $wallet->settings()->whereIn('key', ['mollie_id', 'stripe_id'])->delete();
         $wallet->save();
         $user->settings()->whereIn('key', ['greylist_enabled'])->delete();
         $user->status |= User::STATUS_IMAP_READY;
         $user->save();
 
         parent::tearDown();
     }
 
     /**
      * Test user deleting (DELETE /api/v4/users/<id>)
      */
     public function testDestroy(): void
     {
         // First create some users/accounts to delete
         $package_kolab = \App\Package::where('title', 'kolab')->first();
         $package_domain = \App\Package::where('title', 'domain-hosting')->first();
 
         $john = $this->getTestUser('john@kolab.org');
         $user1 = $this->getTestUser('UsersControllerTest1@userscontroller.com');
         $user2 = $this->getTestUser('UsersControllerTest2@userscontroller.com');
         $user3 = $this->getTestUser('UsersControllerTest3@userscontroller.com');
         $domain = $this->getTestDomain('userscontroller.com', [
                 'status' => Domain::STATUS_NEW,
                 'type' => Domain::TYPE_PUBLIC,
         ]);
         $user1->assignPackage($package_kolab);
         $domain->assignPackage($package_domain, $user1);
         $user1->assignPackage($package_kolab, $user2);
         $user1->assignPackage($package_kolab, $user3);
 
         // Test unauth access
         $response = $this->delete("api/v4/users/{$user2->id}");
         $response->assertStatus(401);
 
         // Test access to other user/account
         $response = $this->actingAs($john)->delete("api/v4/users/{$user2->id}");
         $response->assertStatus(403);
         $response = $this->actingAs($john)->delete("api/v4/users/{$user1->id}");
         $response->assertStatus(403);
         $json = $response->json();
 
         $this->assertSame('error', $json['status']);
         $this->assertSame("Access denied", $json['message']);
         $this->assertCount(2, $json);
 
         // Test that non-controller cannot remove himself
         $response = $this->actingAs($user3)->delete("api/v4/users/{$user3->id}");
         $response->assertStatus(403);
 
         // Test removing a non-controller user
         $response = $this->actingAs($user1)->delete("api/v4/users/{$user3->id}");
         $response->assertStatus(200);
 
         $json = $response->json();
 
         $this->assertEquals('success', $json['status']);
         $this->assertEquals('User deleted successfully.', $json['message']);
 
         // Test removing self (an account with users)
         $response = $this->actingAs($user1)->delete("api/v4/users/{$user1->id}");
         $response->assertStatus(200);
 
         $json = $response->json();
 
         $this->assertEquals('success', $json['status']);
         $this->assertEquals('User deleted successfully.', $json['message']);
     }
 
     /**
      * Test user deleting (DELETE /api/v4/users/<id>)
      */
     public function testDestroyByController(): void
     {
         // Create an account with additional controller - $user2
         $package_kolab = \App\Package::where('title', 'kolab')->first();
         $package_domain = \App\Package::where('title', 'domain-hosting')->first();
         $user1 = $this->getTestUser('UsersControllerTest1@userscontroller.com');
         $user2 = $this->getTestUser('UsersControllerTest2@userscontroller.com');
         $user3 = $this->getTestUser('UsersControllerTest3@userscontroller.com');
         $domain = $this->getTestDomain('userscontroller.com', [
                 'status' => Domain::STATUS_NEW,
                 'type' => Domain::TYPE_PUBLIC,
         ]);
         $user1->assignPackage($package_kolab);
         $domain->assignPackage($package_domain, $user1);
         $user1->assignPackage($package_kolab, $user2);
         $user1->assignPackage($package_kolab, $user3);
         $user1->wallets()->first()->addController($user2);
 
         // TODO/FIXME:
         //   For now controller can delete himself, as well as
         //   the whole account he has control to, including the owner
         //   Probably he should not be able to do none of those
         //   However, this is not 0-regression scenario as we
         //   do not fully support additional controllers.
 
         //$response = $this->actingAs($user2)->delete("api/v4/users/{$user2->id}");
         //$response->assertStatus(403);
 
         $response = $this->actingAs($user2)->delete("api/v4/users/{$user3->id}");
         $response->assertStatus(200);
 
         $response = $this->actingAs($user2)->delete("api/v4/users/{$user1->id}");
         $response->assertStatus(200);
 
         // Note: More detailed assertions in testDestroy() above
 
         $this->assertTrue($user1->fresh()->trashed());
         $this->assertTrue($user2->fresh()->trashed());
         $this->assertTrue($user3->fresh()->trashed());
     }
 
     /**
      * Test user listing (GET /api/v4/users)
      */
     public function testIndex(): void
     {
         // Test unauth access
         $response = $this->get("api/v4/users");
         $response->assertStatus(401);
 
         $jack = $this->getTestUser('jack@kolab.org');
         $joe = $this->getTestUser('joe@kolab.org');
         $john = $this->getTestUser('john@kolab.org');
         $ned = $this->getTestUser('ned@kolab.org');
 
         $response = $this->actingAs($jack)->get("/api/v4/users");
         $response->assertStatus(200);
 
         $json = $response->json();
 
-        $this->assertCount(0, $json);
+        $this->assertSame(false, $json['hasMore']);
+        $this->assertSame(0, $json['count']);
+        $this->assertCount(0, $json['list']);
 
         $response = $this->actingAs($john)->get("/api/v4/users");
         $response->assertStatus(200);
 
         $json = $response->json();
 
-        $this->assertCount(4, $json);
-        $this->assertSame($jack->email, $json[0]['email']);
-        $this->assertSame($joe->email, $json[1]['email']);
-        $this->assertSame($john->email, $json[2]['email']);
-        $this->assertSame($ned->email, $json[3]['email']);
+        $this->assertSame(false, $json['hasMore']);
+        $this->assertSame(4, $json['count']);
+        $this->assertCount(4, $json['list']);
+        $this->assertSame($jack->email, $json['list'][0]['email']);
+        $this->assertSame($joe->email, $json['list'][1]['email']);
+        $this->assertSame($john->email, $json['list'][2]['email']);
+        $this->assertSame($ned->email, $json['list'][3]['email']);
         // Values below are tested by Unit tests
-        $this->assertArrayHasKey('isDeleted', $json[0]);
-        $this->assertArrayHasKey('isSuspended', $json[0]);
-        $this->assertArrayHasKey('isActive', $json[0]);
-        $this->assertArrayHasKey('isLdapReady', $json[0]);
-        $this->assertArrayHasKey('isImapReady', $json[0]);
+        $this->assertArrayHasKey('isDeleted', $json['list'][0]);
+        $this->assertArrayHasKey('isSuspended', $json['list'][0]);
+        $this->assertArrayHasKey('isActive', $json['list'][0]);
+        $this->assertArrayHasKey('isLdapReady', $json['list'][0]);
+        $this->assertArrayHasKey('isImapReady', $json['list'][0]);
 
         $response = $this->actingAs($ned)->get("/api/v4/users");
         $response->assertStatus(200);
 
         $json = $response->json();
 
-        $this->assertCount(4, $json);
-        $this->assertSame($jack->email, $json[0]['email']);
-        $this->assertSame($joe->email, $json[1]['email']);
-        $this->assertSame($john->email, $json[2]['email']);
-        $this->assertSame($ned->email, $json[3]['email']);
+        $this->assertSame(false, $json['hasMore']);
+        $this->assertSame(4, $json['count']);
+        $this->assertCount(4, $json['list']);
+        $this->assertSame($jack->email, $json['list'][0]['email']);
+        $this->assertSame($joe->email, $json['list'][1]['email']);
+        $this->assertSame($john->email, $json['list'][2]['email']);
+        $this->assertSame($ned->email, $json['list'][3]['email']);
+
+        // Search by user email
+        $response = $this->actingAs($john)->get("/api/v4/users?search=jack@k");
+        $response->assertStatus(200);
+
+        $json = $response->json();
+
+        $this->assertSame(false, $json['hasMore']);
+        $this->assertSame(1, $json['count']);
+        $this->assertCount(1, $json['list']);
+        $this->assertSame($jack->email, $json['list'][0]['email']);
+
+        // Search by alias
+        $response = $this->actingAs($john)->get("/api/v4/users?search=monster");
+        $response->assertStatus(200);
+
+        $json = $response->json();
+
+        $this->assertSame(false, $json['hasMore']);
+        $this->assertSame(1, $json['count']);
+        $this->assertCount(1, $json['list']);
+        $this->assertSame($joe->email, $json['list'][0]['email']);
+
+        // Search by name
+        $response = $this->actingAs($john)->get("/api/v4/users?search=land");
+        $response->assertStatus(200);
+
+        $json = $response->json();
+
+        $this->assertSame(false, $json['hasMore']);
+        $this->assertSame(1, $json['count']);
+        $this->assertCount(1, $json['list']);
+        $this->assertSame($ned->email, $json['list'][0]['email']);
+
+        // TODO: Test paging
     }
 
     /**
      * Test fetching user data/profile (GET /api/v4/users/<user-id>)
      */
     public function testShow(): void
     {
         $userA = $this->getTestUser('UserEntitlement2A@UserEntitlement.com');
 
         // Test getting profile of self
         $response = $this->actingAs($userA)->get("/api/v4/users/{$userA->id}");
 
         $json = $response->json();
 
         $response->assertStatus(200);
         $this->assertEquals($userA->id, $json['id']);
         $this->assertEquals($userA->email, $json['email']);
         $this->assertTrue(is_array($json['statusInfo']));
         $this->assertTrue(is_array($json['settings']));
         $this->assertTrue(is_array($json['aliases']));
         $this->assertTrue($json['config']['greylist_enabled']);
         $this->assertSame([], $json['skus']);
         // Values below are tested by Unit tests
         $this->assertArrayHasKey('isDeleted', $json);
         $this->assertArrayHasKey('isSuspended', $json);
         $this->assertArrayHasKey('isActive', $json);
         $this->assertArrayHasKey('isLdapReady', $json);
         $this->assertArrayHasKey('isImapReady', $json);
 
         $john = $this->getTestUser('john@kolab.org');
         $jack = $this->getTestUser('jack@kolab.org');
         $ned = $this->getTestUser('ned@kolab.org');
 
         // Test unauthorized access to a profile of other user
         $response = $this->actingAs($jack)->get("/api/v4/users/{$userA->id}");
         $response->assertStatus(403);
 
         // Test authorized access to a profile of other user
         // Ned: Additional account controller
         $response = $this->actingAs($ned)->get("/api/v4/users/{$john->id}");
         $response->assertStatus(200);
         $response = $this->actingAs($ned)->get("/api/v4/users/{$jack->id}");
         $response->assertStatus(200);
 
         // John: Account owner
         $response = $this->actingAs($john)->get("/api/v4/users/{$jack->id}");
         $response->assertStatus(200);
         $response = $this->actingAs($john)->get("/api/v4/users/{$ned->id}");
         $response->assertStatus(200);
 
         $json = $response->json();
 
         $storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first();
         $groupware_sku = Sku::withEnvTenantContext()->where('title', 'groupware')->first();
         $mailbox_sku = Sku::withEnvTenantContext()->where('title', 'mailbox')->first();
         $secondfactor_sku = Sku::withEnvTenantContext()->where('title', '2fa')->first();
 
         $this->assertCount(5, $json['skus']);
 
         $this->assertSame(5, $json['skus'][$storage_sku->id]['count']);
         $this->assertSame([0,0,0,0,0], $json['skus'][$storage_sku->id]['costs']);
         $this->assertSame(1, $json['skus'][$groupware_sku->id]['count']);
         $this->assertSame([490], $json['skus'][$groupware_sku->id]['costs']);
         $this->assertSame(1, $json['skus'][$mailbox_sku->id]['count']);
         $this->assertSame([500], $json['skus'][$mailbox_sku->id]['costs']);
         $this->assertSame(1, $json['skus'][$secondfactor_sku->id]['count']);
         $this->assertSame([0], $json['skus'][$secondfactor_sku->id]['costs']);
     }
 
     /**
      * Test fetching user status (GET /api/v4/users/<user-id>/status)
      * and forcing setup process update (?refresh=1)
      *
      * @group imap
      * @group dns
      */
     public function testStatus(): void
     {
         Queue::fake();
 
         $john = $this->getTestUser('john@kolab.org');
         $jack = $this->getTestUser('jack@kolab.org');
 
         // Test unauthorized access
         $response = $this->actingAs($jack)->get("/api/v4/users/{$john->id}/status");
         $response->assertStatus(403);
 
         if ($john->isImapReady()) {
             $john->status ^= User::STATUS_IMAP_READY;
             $john->save();
         }
 
         // Get user status
         $response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status");
         $response->assertStatus(200);
 
         $json = $response->json();
 
         $this->assertFalse($json['isImapReady']);
         $this->assertFalse($json['isReady']);
         $this->assertCount(7, $json['process']);
         $this->assertSame('user-imap-ready', $json['process'][2]['label']);
         $this->assertSame(false, $json['process'][2]['state']);
         $this->assertTrue(empty($json['status']));
         $this->assertTrue(empty($json['message']));
 
         // Make sure the domain is confirmed (other test might unset that status)
         $domain = $this->getTestDomain('kolab.org');
         $domain->status |= Domain::STATUS_CONFIRMED;
         $domain->save();
 
         // Now "reboot" the process and verify the user in imap synchronously
         $response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status?refresh=1");
         $response->assertStatus(200);
 
         $json = $response->json();
 
         $this->assertTrue($json['isImapReady']);
         $this->assertTrue($json['isReady']);
         $this->assertCount(7, $json['process']);
         $this->assertSame('user-imap-ready', $json['process'][2]['label']);
         $this->assertSame(true, $json['process'][2]['state']);
         $this->assertSame('success', $json['status']);
         $this->assertSame('Setup process finished successfully.', $json['message']);
 
         Queue::size(1);
 
         // Test case for when the verify job is dispatched to the worker
         $john->refresh();
         $john->status ^= User::STATUS_IMAP_READY;
         $john->save();
 
         \config(['imap.admin_password' => null]);
 
         $response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status?refresh=1");
         $response->assertStatus(200);
 
         $json = $response->json();
 
         $this->assertFalse($json['isImapReady']);
         $this->assertFalse($json['isReady']);
         $this->assertSame('success', $json['status']);
         $this->assertSame('waiting', $json['processState']);
         $this->assertSame('Setup process has been pushed. Please wait.', $json['message']);
 
         Queue::assertPushed(\App\Jobs\User\VerifyJob::class, 1);
     }
 
     /**
      * Test UsersController::statusInfo()
      */
     public function testStatusInfo(): void
     {
         $user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
         $domain = $this->getTestDomain('userscontroller.com', [
                 'status' => Domain::STATUS_NEW,
                 'type' => Domain::TYPE_PUBLIC,
         ]);
 
         $user->created_at = Carbon::now();
         $user->status = User::STATUS_NEW;
         $user->save();
 
         $result = UsersController::statusInfo($user);
 
         $this->assertFalse($result['isReady']);
         $this->assertSame([], $result['skus']);
         $this->assertCount(3, $result['process']);
         $this->assertSame('user-new', $result['process'][0]['label']);
         $this->assertSame(true, $result['process'][0]['state']);
         $this->assertSame('user-ldap-ready', $result['process'][1]['label']);
         $this->assertSame(false, $result['process'][1]['state']);
         $this->assertSame('user-imap-ready', $result['process'][2]['label']);
         $this->assertSame(false, $result['process'][2]['state']);
         $this->assertSame('running', $result['processState']);
 
         $user->created_at = Carbon::now()->subSeconds(181);
         $user->save();
 
         $result = UsersController::statusInfo($user);
 
         $this->assertSame('failed', $result['processState']);
 
         $user->status |= User::STATUS_LDAP_READY | User::STATUS_IMAP_READY;
         $user->save();
 
         $result = UsersController::statusInfo($user);
 
         $this->assertTrue($result['isReady']);
         $this->assertCount(3, $result['process']);
         $this->assertSame('user-new', $result['process'][0]['label']);
         $this->assertSame(true, $result['process'][0]['state']);
         $this->assertSame('user-ldap-ready', $result['process'][1]['label']);
         $this->assertSame(true, $result['process'][1]['state']);
         $this->assertSame('user-imap-ready', $result['process'][2]['label']);
         $this->assertSame(true, $result['process'][2]['state']);
         $this->assertSame('done', $result['processState']);
 
         $domain->status |= Domain::STATUS_VERIFIED;
         $domain->type = Domain::TYPE_EXTERNAL;
         $domain->save();
 
         $result = UsersController::statusInfo($user);
 
         $this->assertFalse($result['isReady']);
         $this->assertSame([], $result['skus']);
         $this->assertCount(7, $result['process']);
         $this->assertSame('user-new', $result['process'][0]['label']);
         $this->assertSame(true, $result['process'][0]['state']);
         $this->assertSame('user-ldap-ready', $result['process'][1]['label']);
         $this->assertSame(true, $result['process'][1]['state']);
         $this->assertSame('user-imap-ready', $result['process'][2]['label']);
         $this->assertSame(true, $result['process'][2]['state']);
         $this->assertSame('domain-new', $result['process'][3]['label']);
         $this->assertSame(true, $result['process'][3]['state']);
         $this->assertSame('domain-ldap-ready', $result['process'][4]['label']);
         $this->assertSame(false, $result['process'][4]['state']);
         $this->assertSame('domain-verified', $result['process'][5]['label']);
         $this->assertSame(true, $result['process'][5]['state']);
         $this->assertSame('domain-confirmed', $result['process'][6]['label']);
         $this->assertSame(false, $result['process'][6]['state']);
 
         // Test 'skus' property
         $user->assignSku(Sku::withEnvTenantContext()->where('title', 'beta')->first());
 
         $result = UsersController::statusInfo($user);
 
         $this->assertSame(['beta'], $result['skus']);
 
         $user->assignSku(Sku::withEnvTenantContext()->where('title', 'meet')->first());
 
         $result = UsersController::statusInfo($user);
 
         $this->assertSame(['beta', 'meet'], $result['skus']);
 
         $user->assignSku(Sku::withEnvTenantContext()->where('title', 'meet')->first());
 
         $result = UsersController::statusInfo($user);
 
         $this->assertSame(['beta', 'meet'], $result['skus']);
     }
 
     /**
      * Test user config update (POST /api/v4/users/<user>/config)
      */
     public function testSetConfig(): void
     {
         $jack = $this->getTestUser('jack@kolab.org');
         $john = $this->getTestUser('john@kolab.org');
 
         $john->setSetting('greylist_enabled', null);
 
         // Test unknown user id
         $post = ['greylist_enabled' => 1];
         $response = $this->actingAs($john)->post("/api/v4/users/123/config", $post);
         $json = $response->json();
 
         $response->assertStatus(404);
 
         // Test access by user not being a wallet controller
         $post = ['greylist_enabled' => 1];
         $response = $this->actingAs($jack)->post("/api/v4/users/{$john->id}/config", $post);
         $json = $response->json();
 
         $response->assertStatus(403);
 
         $this->assertSame('error', $json['status']);
         $this->assertSame("Access denied", $json['message']);
         $this->assertCount(2, $json);
 
         // Test some invalid data
         $post = ['grey' => 1];
         $response = $this->actingAs($john)->post("/api/v4/users/{$john->id}/config", $post);
         $response->assertStatus(422);
 
         $json = $response->json();
 
         $this->assertSame('error', $json['status']);
         $this->assertCount(2, $json);
         $this->assertCount(1, $json['errors']);
         $this->assertSame('The requested configuration parameter is not supported.', $json['errors']['grey']);
 
         $this->assertNull($john->fresh()->getSetting('greylist_enabled'));
 
         // Test some valid data
         $post = ['greylist_enabled' => 1];
         $response = $this->actingAs($john)->post("/api/v4/users/{$john->id}/config", $post);
         $response->assertStatus(200);
 
         $json = $response->json();
 
         $this->assertCount(2, $json);
         $this->assertSame('success', $json['status']);
         $this->assertSame('User settings updated successfully.', $json['message']);
 
         $this->assertSame('true', $john->fresh()->getSetting('greylist_enabled'));
 
         // Test some valid data
         $post = ['greylist_enabled' => 0];
         $response = $this->actingAs($john)->post("/api/v4/users/{$john->id}/config", $post);
         $response->assertStatus(200);
 
         $json = $response->json();
 
         $this->assertCount(2, $json);
         $this->assertSame('success', $json['status']);
         $this->assertSame('User settings updated successfully.', $json['message']);
 
         $this->assertSame('false', $john->fresh()->getSetting('greylist_enabled'));
     }
 
     /**
      * Test user creation (POST /api/v4/users)
      */
     public function testStore(): void
     {
         Queue::fake();
 
         $jack = $this->getTestUser('jack@kolab.org');
         $john = $this->getTestUser('john@kolab.org');
         $deleted_priv = $this->getTestUser('deleted@kolab.org');
         $deleted_priv->delete();
 
         // Test empty request
         $response = $this->actingAs($john)->post("/api/v4/users", []);
         $response->assertStatus(422);
 
         $json = $response->json();
 
         $this->assertSame('error', $json['status']);
         $this->assertSame("The email field is required.", $json['errors']['email']);
         $this->assertSame("The password field is required.", $json['errors']['password'][0]);
         $this->assertCount(2, $json);
 
         // Test access by user not being a wallet controller
         $post = ['first_name' => 'Test'];
         $response = $this->actingAs($jack)->post("/api/v4/users", $post);
         $json = $response->json();
 
         $response->assertStatus(403);
 
         $this->assertSame('error', $json['status']);
         $this->assertSame("Access denied", $json['message']);
         $this->assertCount(2, $json);
 
         // Test some invalid data
         $post = ['password' => '12345678', 'email' => 'invalid'];
         $response = $this->actingAs($john)->post("/api/v4/users", $post);
         $response->assertStatus(422);
 
         $json = $response->json();
 
         $this->assertSame('error', $json['status']);
         $this->assertCount(2, $json);
         $this->assertSame('The password confirmation does not match.', $json['errors']['password'][0]);
         $this->assertSame('The specified email is invalid.', $json['errors']['email']);
 
         // Test existing user email
         $post = [
             'password' => 'simple',
             'password_confirmation' => 'simple',
             'first_name' => 'John2',
             'last_name' => 'Doe2',
             'email' => 'jack.daniels@kolab.org',
         ];
 
         $response = $this->actingAs($john)->post("/api/v4/users", $post);
         $response->assertStatus(422);
 
         $json = $response->json();
 
         $this->assertSame('error', $json['status']);
         $this->assertCount(2, $json);
         $this->assertSame('The specified email is not available.', $json['errors']['email']);
 
         $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first();
         $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first();
 
         $post = [
             'password' => 'simple',
             'password_confirmation' => 'simple',
             'first_name' => 'John2',
             'last_name' => 'Doe2',
             'email' => 'john2.doe2@kolab.org',
             'organization' => 'TestOrg',
             'aliases' => ['useralias1@kolab.org', 'deleted@kolab.org'],
         ];
 
         // Missing package
         $response = $this->actingAs($john)->post("/api/v4/users", $post);
         $json = $response->json();
 
         $response->assertStatus(422);
 
         $this->assertSame('error', $json['status']);
         $this->assertSame("Package is required.", $json['errors']['package']);
         $this->assertCount(2, $json);
 
         // Invalid package
         $post['package'] = $package_domain->id;
         $response = $this->actingAs($john)->post("/api/v4/users", $post);
         $json = $response->json();
 
         $response->assertStatus(422);
 
         $this->assertSame('error', $json['status']);
         $this->assertSame("Invalid package selected.", $json['errors']['package']);
         $this->assertCount(2, $json);
 
         // Test full and valid data
         $post['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 = User::where('email', 'john2.doe2@kolab.org')->first();
         $this->assertInstanceOf(User::class, $user);
         $this->assertSame('John2', $user->getSetting('first_name'));
         $this->assertSame('Doe2', $user->getSetting('last_name'));
         $this->assertSame('TestOrg', $user->getSetting('organization'));
         $aliases = $user->aliases()->orderBy('alias')->get();
         $this->assertCount(2, $aliases);
         $this->assertSame('deleted@kolab.org', $aliases[0]->alias);
         $this->assertSame('useralias1@kolab.org', $aliases[1]->alias);
         // Assert the new user entitlements
         $this->assertEntitlements($user, ['groupware', 'mailbox',
             '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);
 
         // Attempt to create a user previously deleted
         $user->delete();
 
         $post['package'] = $package_kolab->id;
         $post['aliases'] = [];
         $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 = User::where('email', 'john2.doe2@kolab.org')->first();
         $this->assertInstanceOf(User::class, $user);
         $this->assertSame('John2', $user->getSetting('first_name'));
         $this->assertSame('Doe2', $user->getSetting('last_name'));
         $this->assertSame('TestOrg', $user->getSetting('organization'));
         $this->assertCount(0, $user->aliases()->get());
         $this->assertEntitlements($user, ['groupware', 'mailbox',
             'storage', 'storage', 'storage', 'storage', 'storage']);
 
         // Test acting as account controller (not owner)
 
         $this->markTestIncomplete();
     }
 
     /**
      * Test user update (PUT /api/v4/users/<user-id>)
      */
     public function testUpdate(): void
     {
         $userA = $this->getTestUser('UsersControllerTest1@userscontroller.com');
         $jack = $this->getTestUser('jack@kolab.org');
         $john = $this->getTestUser('john@kolab.org');
         $ned = $this->getTestUser('ned@kolab.org');
         $domain = $this->getTestDomain(
             'userscontroller.com',
             ['status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL]
         );
 
         // Test unauthorized update of other user profile
         $response = $this->actingAs($jack)->get("/api/v4/users/{$userA->id}", []);
         $response->assertStatus(403);
 
         // Test authorized update of account owner by account controller
         $response = $this->actingAs($ned)->get("/api/v4/users/{$john->id}", []);
         $response->assertStatus(200);
 
         // Test updating of self (empty request)
         $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", []);
         $response->assertStatus(200);
 
         $json = $response->json();
 
         $this->assertSame('success', $json['status']);
         $this->assertSame("User data updated successfully.", $json['message']);
         $this->assertTrue(!empty($json['statusInfo']));
         $this->assertCount(3, $json);
 
         // Test some invalid data
         $post = ['password' => '12345678', 'currency' => 'invalid'];
         $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post);
         $response->assertStatus(422);
 
         $json = $response->json();
 
         $this->assertSame('error', $json['status']);
         $this->assertCount(2, $json);
         $this->assertSame('The password confirmation does not match.', $json['errors']['password'][0]);
         $this->assertSame('The currency must be 3 characters.', $json['errors']['currency'][0]);
 
         // Test full profile update including password
         $post = [
             'password' => 'simple',
             'password_confirmation' => 'simple',
             'first_name' => 'John2',
             'last_name' => 'Doe2',
             'organization' => 'TestOrg',
             'phone' => '+123 123 123',
             'external_email' => 'external@gmail.com',
             'billing_address' => 'billing',
             'country' => 'CH',
             'currency' => 'CHF',
             'aliases' => ['useralias1@' . \config('app.domain'), 'useralias2@' . \config('app.domain')]
         ];
 
         $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post);
         $json = $response->json();
 
         $response->assertStatus(200);
 
         $this->assertSame('success', $json['status']);
         $this->assertSame("User data updated successfully.", $json['message']);
         $this->assertTrue(!empty($json['statusInfo']));
         $this->assertCount(3, $json);
         $this->assertTrue($userA->password != $userA->fresh()->password);
         unset($post['password'], $post['password_confirmation'], $post['aliases']);
         foreach ($post as $key => $value) {
             $this->assertSame($value, $userA->getSetting($key));
         }
         $aliases = $userA->aliases()->orderBy('alias')->get();
         $this->assertCount(2, $aliases);
         $this->assertSame('useralias1@' . \config('app.domain'), $aliases[0]->alias);
         $this->assertSame('useralias2@' . \config('app.domain'), $aliases[1]->alias);
 
         // Test unsetting values
         $post = [
             'first_name' => '',
             'last_name' => '',
             'organization' => '',
             'phone' => '',
             'external_email' => '',
             'billing_address' => '',
             'country' => '',
             'currency' => '',
             'aliases' => ['useralias2@' . \config('app.domain')]
         ];
 
         $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post);
         $json = $response->json();
 
         $response->assertStatus(200);
 
         $this->assertSame('success', $json['status']);
         $this->assertSame("User data updated successfully.", $json['message']);
         $this->assertTrue(!empty($json['statusInfo']));
         $this->assertCount(3, $json);
         unset($post['aliases']);
         foreach ($post as $key => $value) {
             $this->assertNull($userA->getSetting($key));
         }
         $aliases = $userA->aliases()->get();
         $this->assertCount(1, $aliases);
         $this->assertSame('useralias2@' . \config('app.domain'), $aliases[0]->alias);
 
         // Test error on some invalid aliases missing password confirmation
         $post = [
             'password' => 'simple123',
             'aliases' => [
                 'useralias2@' . \config('app.domain'),
                 'useralias1@kolab.org',
                 '@kolab.org',
             ]
         ];
 
         $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post);
         $json = $response->json();
 
         $response->assertStatus(422);
 
         $this->assertSame('error', $json['status']);
         $this->assertCount(2, $json['errors']);
         $this->assertCount(2, $json['errors']['aliases']);
         $this->assertSame("The specified domain is not available.", $json['errors']['aliases'][1]);
         $this->assertSame("The specified alias is invalid.", $json['errors']['aliases'][2]);
         $this->assertSame("The password confirmation does not match.", $json['errors']['password'][0]);
 
         // Test authorized update of other user
         $response = $this->actingAs($ned)->put("/api/v4/users/{$jack->id}", []);
         $response->assertStatus(200);
 
         $json = $response->json();
 
         $this->assertTrue(empty($json['statusInfo']));
 
         // TODO: Test error on aliases with invalid/non-existing/other-user's domain
 
         // Create entitlements and additional user for following tests
         $owner = $this->getTestUser('UsersControllerTest1@userscontroller.com');
         $user = $this->getTestUser('UsersControllerTest2@userscontroller.com');
         $package_domain = Package::withEnvTenantContext()->where('title', 'domain-hosting')->first();
         $package_kolab = Package::withEnvTenantContext()->where('title', 'kolab')->first();
         $package_lite = Package::withEnvTenantContext()->where('title', 'lite')->first();
         $sku_mailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first();
         $sku_storage = Sku::withEnvTenantContext()->where('title', 'storage')->first();
         $sku_groupware = Sku::withEnvTenantContext()->where('title', 'groupware')->first();
 
         $domain = $this->getTestDomain(
             'userscontroller.com',
             [
                 'status' => Domain::STATUS_NEW,
                 'type' => Domain::TYPE_EXTERNAL,
             ]
         );
 
         $domain->assignPackage($package_domain, $owner);
         $owner->assignPackage($package_kolab);
         $owner->assignPackage($package_lite, $user);
 
         // Non-controller cannot update his own entitlements
         $post = ['skus' => []];
         $response = $this->actingAs($user)->put("/api/v4/users/{$user->id}", $post);
         $response->assertStatus(422);
 
         // Test updating entitlements
         $post = [
             'skus' => [
                 $sku_mailbox->id => 1,
                 $sku_storage->id => 6,
                 $sku_groupware->id => 1,
             ],
         ];
 
         $response = $this->actingAs($owner)->put("/api/v4/users/{$user->id}", $post);
         $response->assertStatus(200);
 
         $json = $response->json();
 
         $storage_cost = $user->entitlements()
             ->where('sku_id', $sku_storage->id)
             ->orderBy('cost')
             ->pluck('cost')->all();
 
         $this->assertEntitlements(
             $user,
             ['groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage']
         );
 
         $this->assertSame([0, 0, 0, 0, 0, 25], $storage_cost);
         $this->assertTrue(empty($json['statusInfo']));
     }
 
     /**
      * Test UsersController::updateEntitlements()
      */
     public function testUpdateEntitlements(): void
     {
         $jane = $this->getTestUser('jane@kolabnow.com');
 
         $kolab = Package::withEnvTenantContext()->where('title', 'kolab')->first();
         $storage = Sku::withEnvTenantContext()->where('title', 'storage')->first();
         $activesync = Sku::withEnvTenantContext()->where('title', 'activesync')->first();
         $groupware = Sku::withEnvTenantContext()->where('title', 'groupware')->first();
         $mailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first();
 
         // standard package, 1 mailbox, 1 groupware, 2 storage
         $jane->assignPackage($kolab);
 
         // add 2 storage, 1 activesync
         $post = [
             'skus' => [
                 $mailbox->id => 1,
                 $groupware->id => 1,
                 $storage->id => 7,
                 $activesync->id => 1
             ]
         ];
 
         $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post);
         $response->assertStatus(200);
 
         $this->assertEntitlements(
             $jane,
             [
                 'activesync',
                 'groupware',
                 'mailbox',
                 'storage',
                 'storage',
                 'storage',
                 'storage',
                 'storage',
                 'storage',
                 'storage'
             ]
         );
 
         // add 2 storage, remove 1 activesync
         $post = [
             'skus' => [
                 $mailbox->id => 1,
                 $groupware->id => 1,
                 $storage->id => 9,
                 $activesync->id => 0
             ]
         ];
 
         $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post);
         $response->assertStatus(200);
 
         $this->assertEntitlements(
             $jane,
             [
                 'groupware',
                 'mailbox',
                 'storage',
                 'storage',
                 'storage',
                 'storage',
                 'storage',
                 'storage',
                 'storage',
                 'storage',
                 'storage'
             ]
         );
 
         // add mailbox
         $post = [
             'skus' => [
                 $mailbox->id => 2,
                 $groupware->id => 1,
                 $storage->id => 9,
                 $activesync->id => 0
             ]
         ];
 
         $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post);
         $response->assertStatus(500);
 
         $this->assertEntitlements(
             $jane,
             [
                 'groupware',
                 'mailbox',
                 'storage',
                 'storage',
                 'storage',
                 'storage',
                 'storage',
                 'storage',
                 'storage',
                 'storage',
                 'storage'
             ]
         );
 
         // remove mailbox
         $post = [
             'skus' => [
                 $mailbox->id => 0,
                 $groupware->id => 1,
                 $storage->id => 9,
                 $activesync->id => 0
             ]
         ];
 
         $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post);
         $response->assertStatus(500);
 
         $this->assertEntitlements(
             $jane,
             [
                 'groupware',
                 'mailbox',
                 'storage',
                 'storage',
                 'storage',
                 'storage',
                 'storage',
                 'storage',
                 'storage',
                 'storage',
                 'storage'
             ]
         );
 
         // less than free storage
         $post = [
             'skus' => [
                 $mailbox->id => 1,
                 $groupware->id => 1,
                 $storage->id => 1,
                 $activesync->id => 0
             ]
         ];
 
         $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post);
         $response->assertStatus(200);
 
         $this->assertEntitlements(
             $jane,
             [
                 'groupware',
                 'mailbox',
                 'storage',
                 'storage',
                 'storage',
                 'storage',
                 'storage'
             ]
         );
     }
 
     /**
      * Test user data response used in show and info actions
      */
     public function testUserResponse(): void
     {
         $provider = \config('services.payment_provider') ?: 'mollie';
         $user = $this->getTestUser('john@kolab.org');
         $wallet = $user->wallets()->first();
         $wallet->setSettings(['mollie_id' => null, 'stripe_id' => null]);
         $result = $this->invokeMethod(new UsersController(), 'userResponse', [$user]);
 
         $this->assertEquals($user->id, $result['id']);
         $this->assertEquals($user->email, $result['email']);
         $this->assertEquals($user->status, $result['status']);
         $this->assertTrue(is_array($result['statusInfo']));
 
         $this->assertTrue(is_array($result['aliases']));
         $this->assertCount(1, $result['aliases']);
         $this->assertSame('john.doe@kolab.org', $result['aliases'][0]);
 
         $this->assertTrue(is_array($result['settings']));
         $this->assertSame('US', $result['settings']['country']);
         $this->assertSame('USD', $result['settings']['currency']);
 
         $this->assertTrue(is_array($result['accounts']));
         $this->assertTrue(is_array($result['wallets']));
         $this->assertCount(0, $result['accounts']);
         $this->assertCount(1, $result['wallets']);
         $this->assertSame($wallet->id, $result['wallet']['id']);
         $this->assertArrayNotHasKey('discount', $result['wallet']);
 
         $this->assertTrue($result['statusInfo']['enableDomains']);
         $this->assertTrue($result['statusInfo']['enableWallets']);
         $this->assertTrue($result['statusInfo']['enableUsers']);
 
         // Ned is John's wallet controller
         $ned = $this->getTestUser('ned@kolab.org');
         $ned_wallet = $ned->wallets()->first();
         $result = $this->invokeMethod(new UsersController(), 'userResponse', [$ned]);
 
         $this->assertEquals($ned->id, $result['id']);
         $this->assertEquals($ned->email, $result['email']);
         $this->assertTrue(is_array($result['accounts']));
         $this->assertTrue(is_array($result['wallets']));
         $this->assertCount(1, $result['accounts']);
         $this->assertCount(1, $result['wallets']);
         $this->assertSame($wallet->id, $result['wallet']['id']);
         $this->assertSame($wallet->id, $result['accounts'][0]['id']);
         $this->assertSame($ned_wallet->id, $result['wallets'][0]['id']);
         $this->assertSame($provider, $result['wallet']['provider']);
         $this->assertSame($provider, $result['wallets'][0]['provider']);
 
         $this->assertTrue($result['statusInfo']['enableDomains']);
         $this->assertTrue($result['statusInfo']['enableWallets']);
         $this->assertTrue($result['statusInfo']['enableUsers']);
 
         // Test discount in a response
         $discount = Discount::where('code', 'TEST')->first();
         $wallet->discount()->associate($discount);
         $wallet->save();
         $mod_provider = $provider == 'mollie' ? 'stripe' : 'mollie';
         $wallet->setSetting($mod_provider . '_id', 123);
         $user->refresh();
 
         $result = $this->invokeMethod(new UsersController(), 'userResponse', [$user]);
 
         $this->assertEquals($user->id, $result['id']);
         $this->assertSame($discount->id, $result['wallet']['discount_id']);
         $this->assertSame($discount->discount, $result['wallet']['discount']);
         $this->assertSame($discount->description, $result['wallet']['discount_description']);
         $this->assertSame($mod_provider, $result['wallet']['provider']);
         $this->assertSame($discount->id, $result['wallets'][0]['discount_id']);
         $this->assertSame($discount->discount, $result['wallets'][0]['discount']);
         $this->assertSame($discount->description, $result['wallets'][0]['discount_description']);
         $this->assertSame($mod_provider, $result['wallets'][0]['provider']);
 
         // Jack is not a John's wallet controller
         $jack = $this->getTestUser('jack@kolab.org');
         $result = $this->invokeMethod(new UsersController(), 'userResponse', [$jack]);
 
         $this->assertFalse($result['statusInfo']['enableDomains']);
         $this->assertFalse($result['statusInfo']['enableWallets']);
         $this->assertFalse($result['statusInfo']['enableUsers']);
     }
 
     /**
      * List of email address validation cases for testValidateEmail()
      *
      * @return array Arguments for testValidateEmail()
      */
     public function dataValidateEmail(): array
     {
         $this->refreshApplication();
         $public_domains = Domain::getPublicDomains();
         $domain = reset($public_domains);
 
         $john = $this->getTestUser('john@kolab.org');
         $jack = $this->getTestUser('jack@kolab.org');
         $user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
 
         return [
             // Invalid format
             ["$domain", $john, 'The specified email is invalid.'],
             [".@$domain", $john, 'The specified email is invalid.'],
             ["test123456@localhost", $john, 'The specified domain is invalid.'],
             ["test123456@unknown-domain.org", $john, 'The specified domain is invalid.'],
 
             ["$domain", $john, 'The specified email is invalid.'],
             [".@$domain", $john, 'The specified email is invalid.'],
 
             // forbidden local part on public domains
             ["admin@$domain", $john, 'The specified email is not available.'],
             ["administrator@$domain", $john, 'The specified email is not available.'],
 
             // forbidden (other user's domain)
             ["testtest@kolab.org", $user, 'The specified domain is not available.'],
 
             // existing alias of other user, to be a user email
             ["jack.daniels@kolab.org", $john, 'The specified email is not available.'],
 
             // valid (user domain)
             ["admin@kolab.org", $john, null],
 
             // valid (public domain)
             ["test.test@$domain", $john, null],
         ];
     }
 
     /**
      * User email address validation.
      *
      * Note: Technically these include unit tests, but let's keep it here for now.
      * FIXME: Shall we do a http request for each case?
      *
      * @dataProvider dataValidateEmail
      */
     public function testValidateEmail($email, $user, $expected_result): void
     {
         $result = UsersController::validateEmail($email, $user);
         $this->assertSame($expected_result, $result);
     }
 
     /**
      * User email validation - tests for $deleted argument
      *
      * Note: Technically these include unit tests, but let's keep it here for now.
      * FIXME: Shall we do a http request for each case?
      */
     public function testValidateEmailDeleted(): void
     {
         Queue::fake();
 
         $john = $this->getTestUser('john@kolab.org');
         $deleted_priv = $this->getTestUser('deleted@kolab.org');
         $deleted_priv->delete();
         $deleted_pub = $this->getTestUser('deleted@kolabnow.com');
         $deleted_pub->delete();
 
         $result = UsersController::validateEmail('deleted@kolab.org', $john, $deleted);
         $this->assertSame(null, $result);
         $this->assertSame($deleted_priv->id, $deleted->id);
 
         $result = UsersController::validateEmail('deleted@kolabnow.com', $john, $deleted);
         $this->assertSame('The specified email is not available.', $result);
         $this->assertSame(null, $deleted);
 
         $result = UsersController::validateEmail('jack@kolab.org', $john, $deleted);
         $this->assertSame('The specified email is not available.', $result);
         $this->assertSame(null, $deleted);
     }
 
     /**
      * User email validation - tests for an address being a group email address
      *
      * Note: Technically these include unit tests, but let's keep it here for now.
      * FIXME: Shall we do a http request for each case?
      */
     public function testValidateEmailGroup(): void
     {
         Queue::fake();
 
         $john = $this->getTestUser('john@kolab.org');
         $pub_group = $this->getTestGroup('group-test@kolabnow.com');
         $priv_group = $this->getTestGroup('group-test@kolab.org');
 
         // A group in a public domain, existing
         $result = UsersController::validateEmail($pub_group->email, $john, $deleted);
         $this->assertSame('The specified email is not available.', $result);
         $this->assertNull($deleted);
 
         $pub_group->delete();
 
         // A group in a public domain, deleted
         $result = UsersController::validateEmail($pub_group->email, $john, $deleted);
         $this->assertSame('The specified email is not available.', $result);
         $this->assertNull($deleted);
 
         // A group in a private domain, existing
         $result = UsersController::validateEmail($priv_group->email, $john, $deleted);
         $this->assertSame('The specified email is not available.', $result);
         $this->assertNull($deleted);
 
         $priv_group->delete();
 
         // A group in a private domain, deleted
         $result = UsersController::validateEmail($priv_group->email, $john, $deleted);
         $this->assertSame(null, $result);
         $this->assertSame($priv_group->id, $deleted->id);
     }
 
     /**
      * List of alias validation cases for testValidateAlias()
      *
      * @return array Arguments for testValidateAlias()
      */
     public function dataValidateAlias(): array
     {
         $this->refreshApplication();
         $public_domains = Domain::getPublicDomains();
         $domain = reset($public_domains);
 
         $john = $this->getTestUser('john@kolab.org');
         $user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
 
         return [
             // Invalid format
             ["$domain", $john, 'The specified alias is invalid.'],
             [".@$domain", $john, 'The specified alias is invalid.'],
             ["test123456@localhost", $john, 'The specified domain is invalid.'],
             ["test123456@unknown-domain.org", $john, 'The specified domain is invalid.'],
 
             ["$domain", $john, 'The specified alias is invalid.'],
             [".@$domain", $john, 'The specified alias is invalid.'],
 
             // forbidden local part on public domains
             ["admin@$domain", $john, 'The specified alias is not available.'],
             ["administrator@$domain", $john, 'The specified alias is not available.'],
 
             // forbidden (other user's domain)
             ["testtest@kolab.org", $user, 'The specified domain is not available.'],
 
             // existing alias of other user, to be an alias, user in the same group account
             ["jack.daniels@kolab.org", $john, null],
 
             // existing user
             ["jack@kolab.org", $john, 'The specified alias is not available.'],
 
             // valid (user domain)
             ["admin@kolab.org", $john, null],
 
             // valid (public domain)
             ["test.test@$domain", $john, null],
         ];
     }
 
     /**
      * User email alias validation.
      *
      * Note: Technically these include unit tests, but let's keep it here for now.
      * FIXME: Shall we do a http request for each case?
      *
      * @dataProvider dataValidateAlias
      */
     public function testValidateAlias($alias, $user, $expected_result): void
     {
         $result = UsersController::validateAlias($alias, $user);
         $this->assertSame($expected_result, $result);
     }
 
     /**
      * User alias validation - more cases.
      *
      * Note: Technically these include unit tests, but let's keep it here for now.
      * FIXME: Shall we do a http request for each case?
      */
     public function testValidateAlias2(): void
     {
         Queue::fake();
 
         $john = $this->getTestUser('john@kolab.org');
         $jack = $this->getTestUser('jack@kolab.org');
         $user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
         $deleted_priv = $this->getTestUser('deleted@kolab.org');
         $deleted_priv->setAliases(['deleted-alias@kolab.org']);
         $deleted_priv->delete();
         $deleted_pub = $this->getTestUser('deleted@kolabnow.com');
         $deleted_pub->setAliases(['deleted-alias@kolabnow.com']);
         $deleted_pub->delete();
         $group = $this->getTestGroup('group-test@kolabnow.com');
 
         // An alias that was a user email before is allowed, but only for custom domains
         $result = UsersController::validateAlias('deleted@kolab.org', $john);
         $this->assertSame(null, $result);
 
         $result = UsersController::validateAlias('deleted-alias@kolab.org', $john);
         $this->assertSame(null, $result);
 
         $result = UsersController::validateAlias('deleted@kolabnow.com', $john);
         $this->assertSame('The specified alias is not available.', $result);
 
         $result = UsersController::validateAlias('deleted-alias@kolabnow.com', $john);
         $this->assertSame('The specified alias is not available.', $result);
 
         // A grpoup with the same email address exists
         $result = UsersController::validateAlias($group->email, $john);
         $this->assertSame('The specified alias is not available.', $result);
     }
 }