Page MenuHomePhorge

D4130.1775433404.diff
No OneTemporary

Authored By
Unknown
Size
44 KB
Referenced Files
None
Subscribers
None

D4130.1775433404.diff

diff --git a/src/app/Http/Controllers/API/SignupController.php b/src/app/Http/Controllers/API/SignupController.php
--- a/src/app/Http/Controllers/API/SignupController.php
+++ b/src/app/Http/Controllers/API/SignupController.php
@@ -37,7 +37,8 @@
$plans = [];
// Use reverse order just to have individual on left, group on right ;)
- Plan::withEnvTenantContext()->orderByDesc('title')->get()
+ // But prefer monthly on left, yearly on right
+ Plan::withEnvTenantContext()->orderBy('months')->orderByDesc('title')->get()
->map(function ($plan) use (&$plans) {
// Allow themes to set custom button label
$button = \trans('theme::app.planbutton-' . $plan->title);
@@ -51,12 +52,25 @@
'button' => $button,
'description' => $plan->description,
'mode' => $plan->mode ?: 'email',
+ 'isDomain' => $plan->hasDomain(),
];
});
return response()->json(['status' => 'success', 'plans' => $plans]);
}
+ /**
+ * Returns list of public domains for signup.
+ *
+ * @param \Illuminate\Http\Request $request HTTP request
+ *
+ * @return \Illuminate\Http\JsonResponse JSON response
+ */
+ public function domains(Request $request)
+ {
+ return response()->json(['status' => 'success', 'domains' => Domain::getPublicDomains()]);
+ }
+
/**
* Starts signup process.
*
@@ -230,8 +244,19 @@
return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
}
- // Signup via invitation
- if ($request->invitation) {
+
+ $settings = [];
+
+ // Plan parameter is required/allowed in mandate mode
+ if (!empty($request->plan) && empty($request->code) && empty($request->invitation)) {
+ $plan = Plan::withEnvTenantContext()->where('title', $request->plan)->first();
+
+ if (!$plan || $plan->mode != 'mandate') {
+ $msg = \trans('validation.exists', ['attribute' => 'plan']);
+ return response()->json(['status' => 'error', 'errors' => ['plan' => $msg]], 422);
+ }
+ } elseif ($request->invitation) {
+ // Signup via invitation
$invitation = SignupInvitation::withEnvTenantContext()->find($request->invitation);
if (empty($invitation) || $invitation->isCompleted()) {
@@ -244,7 +269,6 @@
[
'first_name' => 'max:128',
'last_name' => 'max:128',
- 'voucher' => 'max:32',
]
);
@@ -266,6 +290,8 @@
return $v;
}
+ $plan = $this->getPlan();
+
// Get user name/email from the verification code database
$code_data = $v->getData();
@@ -274,7 +300,7 @@
'last_name' => $code_data->last_name,
];
- if ($this->getPlan()->mode == 'token') {
+ if ($plan->mode == 'token') {
$settings['signup_token'] = $code_data->email;
} else {
$settings['external_email'] = $code_data->email;
@@ -292,10 +318,11 @@
}
}
- // Get the plan
- $plan = $this->getPlan();
- $is_domain = $plan->hasDomain();
+ if (empty($plan)) {
+ $plan = $this->getPlan();
+ }
+ $is_domain = $plan->hasDomain();
$login = $request->login;
$domain_name = $request->domain;
@@ -355,7 +382,14 @@
DB::commit();
- return AuthController::logonResponse($user, $request->password);
+ $response = AuthController::logonResponse($user, $request->password);
+
+ // Redirect the user to the specified page
+ // $data = $response->getData(true);
+ // $data['redirect'] = 'wallet';
+ // $response->setData($data);
+
+ return $response;
}
/**
@@ -369,7 +403,7 @@
if (!$request->plan || !$request->plan instanceof Plan) {
// Get the plan if specified and exists...
- if ($request->code && $request->code->plan) {
+ if (($request->code instanceof SignupCode) && $request->code->plan) {
$plan = Plan::withEnvTenantContext()->where('title', $request->code->plan)->first();
} elseif ($request->plan) {
$plan = Plan::withEnvTenantContext()->where('title', $request->plan)->first();
diff --git a/src/app/Http/Controllers/API/V4/PaymentsController.php b/src/app/Http/Controllers/API/V4/PaymentsController.php
--- a/src/app/Http/Controllers/API/V4/PaymentsController.php
+++ b/src/app/Http/Controllers/API/V4/PaymentsController.php
@@ -166,18 +166,25 @@
$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 <= Payment::MIN_AMOUNT * -1
- && $wallet->balance + $amount < 0
- ) {
- return ['amount' => \trans('validation.minamountdebt')];
+ // It has to be at least minimum payment amount and must cover current debt,
+ // and must be more than a yearly/monthly payment (according to the plan)
+ $min = Payment::MIN_AMOUNT;
+ $label = 'minamount';
+
+ if (($plan = $wallet->plan()) && $plan->months >= 1) {
+ $planCost = (int) ceil($plan->cost() * $plan->months);
+ if ($planCost > $min) {
+ $min = $planCost;
+ }
}
- if ($amount < Payment::MIN_AMOUNT) {
- $min = $wallet->money(Payment::MIN_AMOUNT);
- return ['amount' => \trans('validation.minamount', ['amount' => $min])];
+ if ($wallet->balance < 0 && $wallet->balance < $min * -1) {
+ $min = $wallet->balance * -1;
+ $label = 'minamountdebt';
+ }
+
+ if ($amount < $min) {
+ return ['amount' => \trans("validation.{$label}", ['amount' => $wallet->money($min)])];
}
return null;
@@ -366,7 +373,7 @@
// Get the Mandate info
$mandate = (array) $provider->getMandate($wallet);
- $mandate['amount'] = (int) (Payment::MIN_AMOUNT / 100);
+ $mandate['amount'] = $mandate['minAmount'] = (int) ceil(Payment::MIN_AMOUNT / 100);
$mandate['balance'] = 0;
$mandate['isDisabled'] = !empty($mandate['id']) && $settings['mandate_disabled'];
@@ -376,6 +383,19 @@
}
}
+ // If this is a multi-month plan, we calculate the expected amount to be payed.
+ if (($plan = $wallet->plan()) && $plan->months >= 1) {
+ $planCost = (int) ceil(($plan->cost() * $plan->months) / 100);
+ if ($planCost > $mandate['minAmount']) {
+ $mandate['minAmount'] = $planCost;
+ }
+ }
+
+ // Unrestrict the wallet owner if mandate is valid
+ if (!empty($mandate['isValid']) && $wallet->owner->isRestricted()) {
+ $wallet->owner->unrestrict();
+ }
+
return $mandate;
}
diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php
--- a/src/app/Http/Controllers/API/V4/UsersController.php
+++ b/src/app/Http/Controllers/API/V4/UsersController.php
@@ -184,6 +184,8 @@
$hasBeta = in_array('beta', $skus);
+ $plan = $isController ? $user->wallet()->plan() : null;
+
$result = [
'skus' => $skus,
'enableBeta' => in_array('beta', $skus),
@@ -200,6 +202,8 @@
'enableSettings' => $isController,
'enableUsers' => $isController,
'enableWallets' => $isController,
+ 'enableWalletMandates' => $isController,
+ 'enableWalletPayments' => !$plan || $plan->mode != 'mandate',
'enableCompanionapps' => $hasBeta,
];
@@ -349,6 +353,11 @@
{
$response = array_merge($user->toArray(), self::objectState($user));
+ $wallet = $user->wallet();
+
+ // IsLocked flag to lock the user to the Wallet page only
+ $response['isLocked'] = ($user->isRestricted() && ($plan = $wallet->plan()) && $plan->mode == 'mandate');
+
// Settings
$response['settings'] = [];
foreach ($user->settings()->whereIn('key', self::USER_SETTINGS)->get() as $item) {
@@ -380,7 +389,7 @@
// 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());
+ $response['wallet'] = $map_func($wallet);
return $response;
}
diff --git a/src/app/Http/Controllers/ContentController.php b/src/app/Http/Controllers/ContentController.php
--- a/src/app/Http/Controllers/ContentController.php
+++ b/src/app/Http/Controllers/ContentController.php
@@ -147,7 +147,7 @@
}
// Unset properties that we don't need on the client side
- unset($item['admin'], $item['label']);
+ unset($item['admin']);
$menu[$idx] = $item;
}
diff --git a/src/app/Observers/WalletObserver.php b/src/app/Observers/WalletObserver.php
--- a/src/app/Observers/WalletObserver.php
+++ b/src/app/Observers/WalletObserver.php
@@ -111,12 +111,7 @@
// Remove RESTRICTED flag from the wallet owner and all users in the wallet
if ($wallet->balance > $wallet->getOriginal('balance') && $wallet->owner && $wallet->owner->isRestricted()) {
- $wallet->owner->unrestrict();
-
- User::whereIn('id', $wallet->entitlements()->select('entitleable_id')->where('entitleable_type', User::class))
- ->each(function ($user) {
- $user->unrestrict();
- });
+ $wallet->owner->unrestrict(true);
}
}
}
diff --git a/src/app/Package.php b/src/app/Package.php
--- a/src/app/Package.php
+++ b/src/app/Package.php
@@ -67,7 +67,7 @@
$units = $sku->pivot->qty - $sku->units_free;
if ($units < 0) {
- \Log::debug("Package {$this->id} is misconfigured for more free units than qty.");
+ \Log::warning("Package {$this->id} is misconfigured for more free units than qty.");
$units = 0;
}
diff --git a/src/app/Payment.php b/src/app/Payment.php
--- a/src/app/Payment.php
+++ b/src/app/Payment.php
@@ -117,6 +117,11 @@
if ($this->wallet->balance >= 0) {
$this->wallet->setSetting('mandate_disabled', null);
}
+
+ // Remove RESTRICTED flag from the wallet owner and all users in the wallet
+ if ($this->wallet->owner && $this->wallet->owner->isRestricted()) {
+ $this->wallet->owner->unrestrict(true);
+ }
}
/**
diff --git a/src/app/Plan.php b/src/app/Plan.php
--- a/src/app/Plan.php
+++ b/src/app/Plan.php
@@ -49,6 +49,8 @@
'discount_qty',
// the rate of the discount for this plan
'discount_rate',
+ // minimum number of months this plan is for
+ 'months',
// number of free months (trial)
'free_months',
];
@@ -59,6 +61,7 @@
'promo_to' => 'datetime:Y-m-d H:i:s',
'discount_qty' => 'integer',
'discount_rate' => 'integer',
+ 'months' => 'integer',
'free_months' => 'integer'
];
diff --git a/src/app/Providers/Payment/Mollie.php b/src/app/Providers/Payment/Mollie.php
--- a/src/app/Providers/Payment/Mollie.php
+++ b/src/app/Providers/Payment/Mollie.php
@@ -360,7 +360,7 @@
if ($mollie_payment->isPaid()) {
// The payment is paid. Update the balance, and notify the user
- if ($payment->status != Payment::STATUS_PAID && $payment->amount > 0) {
+ if ($payment->status != Payment::STATUS_PAID && $payment->amount >= 0) {
$credit = true;
$notify = $payment->type == Payment::TYPE_RECURRING;
}
diff --git a/src/app/Providers/Payment/Stripe.php b/src/app/Providers/Payment/Stripe.php
--- a/src/app/Providers/Payment/Stripe.php
+++ b/src/app/Providers/Payment/Stripe.php
@@ -369,6 +369,9 @@
$payment->wallet->setSetting('stripe_mandate_id', $intent->id);
$threshold = intval((float) $payment->wallet->getSetting('mandate_balance') * 100);
+ // Call credit() so wallet/account state is updated
+ $this->creditPayment($payment, $intent);
+
// Top-up the wallet if balance is below the threshold
if ($payment->wallet->balance < $threshold && $payment->status != Payment::STATUS_PAID) {
\App\Jobs\WalletCharge::dispatch($payment->wallet);
diff --git a/src/app/User.php b/src/app/User.php
--- a/src/app/User.php
+++ b/src/app/User.php
@@ -547,16 +547,27 @@
/**
* Un-restrict this user.
*
+ * @param bool $deep Unrestrinct also all users in the account
+ *
* @return void
*/
- public function unrestrict(): void
+ public function unrestrict(bool $deep = false): void
{
- if (!$this->isRestricted()) {
- return;
+ if ($this->isRestricted()) {
+ $this->status ^= User::STATUS_RESTRICTED;
+ $this->save();
+ }
+
+ // Remove the flag from all users in the user's wallets
+ if ($deep) {
+ $this->wallets->each(function ($wallet) {
+ User::whereIn('id', $wallet->entitlements()->select('entitleable_id')
+ ->where('entitleable_type', User::class))
+ ->each(function ($user) {
+ $user->unrestrict();
+ });
+ });
}
-
- $this->status ^= User::STATUS_RESTRICTED;
- $this->save();
}
/**
diff --git a/src/database/migrations/2023_02_17_100000_vat_rates_table.php b/src/database/migrations/2023_02_17_100000_vat_rates_table.php
--- a/src/database/migrations/2023_02_17_100000_vat_rates_table.php
+++ b/src/database/migrations/2023_02_17_100000_vat_rates_table.php
@@ -78,6 +78,7 @@
Schema::table(
'payments',
function (Blueprint $table) {
+ $table->dropForeign(['vat_rate_id']);
$table->dropColumn('vat_rate_id');
$table->dropColumn('credit_amount');
}
diff --git a/src/database/migrations/2023_03_01_100000_plans_months.php b/src/database/migrations/2023_03_01_100000_plans_months.php
new file mode 100644
--- /dev/null
+++ b/src/database/migrations/2023_03_01_100000_plans_months.php
@@ -0,0 +1,38 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::table(
+ 'plans',
+ function (Blueprint $table) {
+ $table->tinyInteger('months')->unsigned()->default(1);
+ }
+ );
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::table(
+ 'plans',
+ function (Blueprint $table) {
+ $table->dropColumn('months');
+ }
+ );
+ }
+};
diff --git a/src/resources/build/before.php b/src/resources/build/before.php
--- a/src/resources/build/before.php
+++ b/src/resources/build/before.php
@@ -37,6 +37,20 @@
}
}
+foreach (glob("{$rootDir}/resources/themes/*/lang/*/ui.php") as $file) {
+ $content = include $file;
+
+ if (is_array($content)) {
+ preg_match('|([a-zA-Z]+)/lang/([a-z]+)/ui\.php$|', $file, $matches);
+
+ $theme = $matches[1];
+ $file = "{$rootDir}/resources/build/js/{$theme}-{$matches[2]}.json";
+ $opts = JSON_PRETTY_PRINT | JSON_INVALID_UTF8_SUBSTITUTE | JSON_UNESCAPED_UNICODE;
+
+ file_put_contents($file, json_encode($content, $opts));
+ }
+}
+
echo "OK\n";
// Move some theme-specific resources from resources/themes/ to public/themes/
@@ -54,14 +68,19 @@
mkdir("{$rootDir}/public/themes/{$theme}");
}
- if (!file_exists("{$rootDir}/public/themes/{$theme}/images")) {
- mkdir("{$rootDir}/public/themes/{$theme}/images");
- }
+ // TODO: Public dirs (glob patterns) should be in theme's meta.json
+
+ foreach (['images', 'fonts'] as $subDir) {
+ if (file_exists("{$rootDir}/resources/themes/{$theme}/{$subDir}")) {
+ if (!file_exists("{$rootDir}/public/themes/{$theme}/{$subDir}")) {
+ mkdir("{$rootDir}/public/themes/{$theme}/{$subDir}");
+ }
- foreach (glob("{$file}/images/*") as $file) {
- $path = explode('/', $file);
- $image = $path[count($path)-1];
- copy($file, "{$rootDir}/public/themes/{$theme}/images/{$image}");
+ foreach (glob("{$rootDir}/resources/themes/{$theme}/{$subDir}/*") as $file) {
+ $filename = pathinfo($file, PATHINFO_BASENAME);
+ copy($file, "{$rootDir}/public/themes/{$theme}/{$subDir}/{$filename}");
+ }
+ }
}
}
diff --git a/src/resources/js/app.js b/src/resources/js/app.js
--- a/src/resources/js/app.js
+++ b/src/resources/js/app.js
@@ -14,7 +14,8 @@
const routerState = {
afterLogin: null,
- isLoggedIn: !!localStorage.getItem('token')
+ isLoggedIn: !!localStorage.getItem('token'),
+ isLocked: false
}
let loadingRoute
@@ -29,7 +30,12 @@
// redirect to login page
next({ name: 'login' })
+ return
+ }
+ if (routerState.isLocked && to.meta.requiresAuth && !['login', 'wallet'].includes(to.name)) {
+ // redirect to the wallet page
+ next({ name: 'wallet' })
return
}
@@ -139,8 +145,13 @@
this.authInfo = response
}
+ routerState.isLocked = this.authInfo && this.authInfo.isLocked
+
if (dashboard !== false) {
- this.$router.push(routerState.afterLogin || { name: 'dashboard' })
+ this.$router.push(routerState.afterLogin || { name: response.redirect || 'dashboard' })
+ } else if (routerState.isLocked && this.$route.name != 'wallet' && this.$route.meta.requiresAuth) {
+ // Always redirect locked user, here we can be after router's beforeEach handler
+ this.$router.push({ name: 'wallet' })
}
routerState.afterLogin = null
@@ -321,6 +332,10 @@
return this.$t('status.active')
},
+ unlock() {
+ routerState.isLocked = this.authInfo.isLocked = false
+ this.$router.push({ name: 'dashboard' })
+ },
// Append some wallet properties to the object
userWalletProps(object) {
let wallet = this.authInfo.accounts[0]
diff --git a/src/resources/js/locale.js b/src/resources/js/locale.js
--- a/src/resources/js/locale.js
+++ b/src/resources/js/locale.js
@@ -17,6 +17,7 @@
let currentLanguage
const loadedLanguages = ['en'] // our default language that is preloaded
+const loadedThemeLanguages = []
const setI18nLanguage = (lang) => {
i18n.locale = lang
@@ -32,7 +33,25 @@
const age = 10 * 60 * 60 * 24 * 365
document.cookie = 'language=' + lang + '; max-age=' + age + '; path=/; secure'
- return lang
+ // Load additional localization from the theme
+ return loadThemeLang(lang)
+}
+
+const loadThemeLang = (lang) => {
+ if (loadedThemeLanguages.includes(lang)) {
+ return
+ }
+
+ const theme = window.config['app.theme']
+
+ if (theme && theme != 'default') {
+ return import(/* webpackChunkName: "locale/[request]" */ `../build/js/${theme}-${lang}.json`)
+ .then(messages => {
+ i18n.mergeLocaleMessage(lang, messages.default)
+ loadedThemeLanguages.push(lang)
+ })
+ .catch(error => { /* ignore errors */ })
+ }
}
export const getLang = () => {
@@ -61,6 +80,6 @@
.then(messages => {
i18n.setLocaleMessage(lang, messages.default)
loadedLanguages.push(lang)
- return setI18nLanguage(lang)
+ return Promise.resolve(setI18nLanguage(lang))
})
}
diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php
--- a/src/resources/lang/en/ui.php
+++ b/src/resources/lang/en/ui.php
@@ -539,6 +539,7 @@
. " 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",
+ 'locked-text' => "The account is locked until you set up auto-payment successfully.",
'month' => "month",
'noperm' => "Only account owners can access a wallet.",
'norefund' => "The money in your wallet is non-refundable.",
diff --git a/src/resources/vue/Signup.vue b/src/resources/vue/Signup.vue
--- a/src/resources/vue/Signup.vue
+++ b/src/resources/vue/Signup.vue
@@ -2,11 +2,11 @@
<div class="container">
<div id="step0" v-if="!invitation">
<div class="plan-selector row row-cols-sm-2 g-3">
- <div v-for="item in plans" :key="item.id">
+ <div v-for="item in plans" :key="item.id" :id="'plan-' + item.title">
<div :class="'card bg-light plan-' + item.title">
<div class="card-header plan-header">
<div class="plan-ico text-center">
- <svg-icon :icon="plan_icons[item.title]"></svg-icon>
+ <svg-icon :icon="plan_icons[item.title] || 'user'"></svg-icon>
</div>
</div>
<div class="card-body text-center">
@@ -65,7 +65,7 @@
<div class="card d-none" id="step3">
<div class="card-body">
- <h4 v-if="!invitation" class="card-title">{{ $t('signup.title') }} - {{ $t('nav.step', { i: steps, n: steps }) }}</h4>
+ <h4 v-if="!invitation && steps > 1" class="card-title">{{ $t('signup.title') }} - {{ $t('nav.step', { i: steps, n: steps }) }}</h4>
<p class="card-text">
{{ $t('signup.step3', { app: $root.appName }) }}
</p>
@@ -144,7 +144,15 @@
},
computed: {
steps() {
- return this.mode == 'token' ? 2 : 3
+ switch (this.mode) {
+ case 'token':
+ return 2
+ case 'mandate':
+ return 1
+ case 'email':
+ default:
+ return 3
+ }
}
},
mounted() {
@@ -197,7 +205,24 @@
if (plan) {
this.plan = title
this.mode = plan.mode
- this.displayForm(1, true)
+ this.is_domain = plan.isDomain
+ this.domain = ''
+
+ let step = 1
+
+ if (plan.mode == 'mandate') {
+ step = 3
+ if (!plan.isDomain || !this.domains.length) {
+ axios.get('/api/auth/signup/domains')
+ .then(response => {
+ this.displayForm(step, true)
+ this.setDomain(response.data)
+ })
+ return
+ }
+ }
+
+ this.displayForm(step, true)
}
},
step0(plan) {
@@ -272,13 +297,12 @@
this.$root.clearFormValidation($('#step3 form'))
let post = {
- ...this.$root.pick(this, ['login', 'domain', 'voucher']),
+ ...this.$root.pick(this, ['login', 'domain', 'voucher', 'plan']),
...this.pass
}
if (this.invitation) {
post.invitation = this.invitation.id
- post.plan = this.plan
post.first_name = this.first_name
post.last_name = this.last_name
} else {
@@ -304,6 +328,10 @@
step = 1
}
+ if (this.mode == 'mandate') {
+ step = 0
+ }
+
$('#step' + step).removeClass('d-none').find('input').first().focus()
if (!step) {
@@ -331,7 +359,14 @@
this.domains = response.domains
}
- this.domain = response.domain || window.config['app.domain']
+ this.domain = response.domain
+
+ if (!this.domain) {
+ this.domain = window.config['app.domain']
+ if (this.domains.length && !this.domains.includes(this.domain)) {
+ this.domain = this.domains[0]
+ }
+ }
}
}
}
diff --git a/src/resources/vue/Wallet.vue b/src/resources/vue/Wallet.vue
--- a/src/resources/vue/Wallet.vue
+++ b/src/resources/vue/Wallet.vue
@@ -1,5 +1,8 @@
<template>
<div class="container" dusk="wallet-component">
+ <p v-if="$root.authInfo.isLocked" id="lock-alert" class="alert alert-warning">
+ {{ $t('wallet.locked-text') }}
+ </p>
<div v-if="wallet.id" id="wallet" class="card">
<div class="card-body">
<div class="card-title">{{ $t('wallet.title') }} <span :class="wallet.balance < 0 ? 'text-danger' : 'text-success'">{{ $root.price(wallet.balance, wallet.currency) }}</span></div>
@@ -9,10 +12,10 @@
<div v-if="showPendingPayments" class="alert alert-warning">
{{ $t('wallet.pending-payments-warning') }}
</div>
- <p>
+ <p v-if="$root.hasPermission('walletPayments')">
<btn class="btn-primary" @click="paymentMethodForm('manual')">{{ $t('wallet.add-credit') }}</btn>
</p>
- <div id="mandate-form" v-if="!mandate.isValid && !mandate.isPending">
+ <div id="mandate-form" v-if="!mandate.isValid && !mandate.isPending && $root.hasPermission('walletMandates')">
<template v-if="mandate.id && !mandate.isValid">
<div class="alert alert-danger">
{{ $t('wallet.auto-payment-failed') }}
@@ -21,7 +24,7 @@
</template>
<btn class="btn-primary" @click="paymentMethodForm('auto')">{{ $t('wallet.auto-payment-setup') }}</btn>
</div>
- <div id="mandate-info" v-else>
+ <div id="mandate-info" v-else-if="$root.hasPermission('walletMandates')">
<div v-if="mandate.isDisabled" class="disabled-mandate alert alert-danger">
{{ $t('wallet.auto-payment-disabled') }}
</div>
@@ -218,6 +221,9 @@
return tabs
}
},
+ beforeDestroyed() {
+ clearTimeout(this.refreshRequest)
+ },
mounted() {
$('#wallet button').focus()
@@ -249,15 +255,30 @@
this.$refs.tabs.clickHandler('payments', () => { this.loadPayments = true })
},
methods: {
- loadMandate() {
+ loadMandate(refresh) {
const loader = '#mandate-form'
this.$root.stopLoading(loader)
- if (!this.mandate.id || this.mandate.isPending) {
- axios.get('/api/v4/payments/mandate', { loader })
+ if (!this.mandate.id || this.mandate.isPending || refresh) {
+ axios.get('/api/v4/payments/mandate', refresh ? {} : { loader })
.then(response => {
this.mandate = response.data
+
+ if (this.mandate.minAmount) {
+ if (this.mandate.minAmount > this.mandate.amount) {
+ this.mandate.amount = this.mandate.minAmount
+ }
+ }
+
+ if (this.$root.authInfo.isLocked) {
+ if (this.mandate.isValid) {
+ this.$root.unlock()
+ } else {
+ clearTimeout(this.refreshRequest)
+ this.refreshRequest = setTimeout(() => { this.loadMandate(true) }, 10 * 1000)
+ }
+ }
})
}
},
@@ -372,6 +393,10 @@
axios.get('/api/v4/payments/methods', { params: { type }, loader })
.then(response => {
this.paymentMethods = response.data
+ if (this.paymentMethods.length == 1) {
+ this.nextForm = 'auto';
+ this.selectPaymentMethod(this.paymentMethods[0]);
+ }
})
})
},
diff --git a/src/resources/vue/Widgets/Menu.vue b/src/resources/vue/Widgets/Menu.vue
--- a/src/resources/vue/Widgets/Menu.vue
+++ b/src/resources/vue/Widgets/Menu.vue
@@ -10,21 +10,21 @@
</button>
<div :id="mode + '-menu-navbar'" :class="mode == 'header' ? 'collapse navbar-collapse justify-content-end' : ''">
<ul class="navbar-nav justify-content-end">
- <li class="nav-item" v-for="item in menu" :key="item.index">
- <a v-if="item.href" :class="'nav-link link-' + item.index" :href="item.href">{{ item.title }}</a>
+ <li class="nav-item" v-for="item in menu" :key="item.label">
+ <a v-if="item.href" :class="'nav-link link-' + item.label" :href="item.href">{{ menuItemTitle(item) }}</a>
<router-link v-if="item.to"
- :class="'nav-link link-' + item.index"
+ :class="'nav-link link-' + item.label"
active-class="active"
:to="item.to"
:exact="item.exact"
>
- {{ item.title }}
+ {{ menuItemTitle(item) }}
</router-link>
</li>
- <li class="nav-item" v-if="!loggedIn && $root.isUser">
+ <li class="nav-item" v-if="!loggedIn && $root.isUser && !hasMenuItem('signup')">
<router-link class="nav-link link-signup" active-class="active" :to="{name: 'signup'}">{{ $t('menu.signup') }}</router-link>
</li>
- <li class="nav-item" v-if="loggedIn">
+ <li class="nav-item" v-if="loggedIn && !hasMenuItem('dashboard')">
<router-link class="nav-link link-dashboard" active-class="active" :to="{name: 'dashboard'}">{{ $t('menu.cockpit') }}</router-link>
</li>
<li class="nav-item" v-if="loggedIn">
@@ -70,7 +70,13 @@
},
computed: {
loggedIn() { return !!this.$root.authInfo },
- menu() { return this.menuList.filter(item => !item.footer || this.mode == 'footer') },
+ menu() {
+ // Filter menu by its position on the page, and user authentication state
+ return this.menuList.filter(item => {
+ return (!item.footer || this.mode == 'footer')
+ && (!('authenticated' in item) || this.loggedIn === item.authenticated)
+ })
+ },
route() { return this.$route.name }
},
mounted() {
@@ -79,19 +85,14 @@
methods: {
loadMenu() {
let menu = []
- const lang = this.getLang()
const loggedIn = this.loggedIn
window.config.menu.forEach(item => {
- item.title = item['title-' + lang] || item['title-en'] || item.title
-
- if (!item.location || !item.title) {
+ if (!item.location || !item.label) {
console.error("Invalid menu entry", item)
return
}
- // TODO: Different menu for different loggedIn state
-
if (item.location.match(/^https?:/)) {
item.href = item.location
} else {
@@ -99,19 +100,24 @@
}
item.exact = item.location == '/'
- item.index = item.page || item.title.toLowerCase().replace(/\s+/g, '')
menu.push(item)
})
return menu
},
+ hasMenuItem(label) {
+ return this.menuList.find(item => item.label == label)
+ },
+ menuItemTitle(item) {
+ const lang = this.getLang()
+ return item['title-' + lang] || item['title-en'] || item.title || this.$t('menu.' + item.label)
+ },
getLang() {
return getLang()
},
setLang(language) {
setLang(language)
- this.menuList = this.loadMenu()
}
}
}
diff --git a/src/routes/api.php b/src/routes/api.php
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -49,6 +49,7 @@
Route::post('password-reset/verify', [API\PasswordResetController::class, 'verify']);
Route::post('password-reset', [API\PasswordResetController::class, 'reset']);
+ Route::get('signup/domains', [API\SignupController::class, 'domains']);
Route::post('signup/init', [API\SignupController::class, 'init']);
Route::get('signup/invitations/{id}', [API\SignupController::class, 'invitation']);
Route::get('signup/plans', [API\SignupController::class, 'plans']);
diff --git a/src/tests/Browser/SignupTest.php b/src/tests/Browser/SignupTest.php
--- a/src/tests/Browser/SignupTest.php
+++ b/src/tests/Browser/SignupTest.php
@@ -12,7 +12,9 @@
use Tests\Browser\Components\Menu;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Dashboard;
+use Tests\Browser\Pages\Home;
use Tests\Browser\Pages\Signup;
+use Tests\Browser\Pages\Wallet;
use Tests\TestCaseDusk;
use Illuminate\Foundation\Testing\DatabaseMigrations;
@@ -29,7 +31,7 @@
$this->deleteTestUser('admin@user-domain-signup.com');
$this->deleteTestDomain('user-domain-signup.com');
- Plan::where('mode', 'token')->update(['mode' => 'email']);
+ Plan::whereIn('mode', ['token', 'mandate'])->update(['mode' => 'email']);
}
/**
@@ -42,7 +44,7 @@
$this->deleteTestDomain('user-domain-signup.com');
SignupInvitation::truncate();
- Plan::where('mode', 'token')->update(['mode' => 'email']);
+ Plan::whereIn('mode', ['token', 'mandate'])->update(['mode' => 'email']);
@unlink(storage_path('signup-tokens.txt'));
@@ -518,6 +520,67 @@
});
}
+ /**
+ * Test signup with a mandate plan, also the wallet lock
+ */
+ public function testSignupMandate(): void
+ {
+ // Test the individual plan
+ $plan = Plan::withEnvTenantContext()->where('title', 'individual')->first();
+ $plan->mode = 'mandate';
+ $plan->save();
+
+ $this->browse(function (Browser $browser) {
+ $browser->visit(new Signup())
+ ->waitFor('@step0 .plan-individual button')
+ ->click('@step0 .plan-individual button')
+ // Test Back button
+ ->whenAvailable('@step3', function ($browser) {
+ $browser->click('button[type=button]');
+ })
+ ->whenAvailable('@step0', function ($browser) {
+ $browser->click('.plan-individual button');
+ })
+ // Test submit
+ ->whenAvailable('@step3', function ($browser) {
+ $domains = Domain::getPublicDomains();
+ $domains_count = count($domains);
+
+ $browser->assertMissing('.card-title')
+ ->assertElementsCount('select#signup_domain option', $domains_count, false)
+ ->assertText('select#signup_domain option:nth-child(1)', $domains[0])
+ ->assertValue('select#signup_domain option:nth-child(1)', $domains[0])
+ ->type('#signup_login', 'signuptestdusk')
+ ->type('#signup_password', '12345678')
+ ->type('#signup_password_confirmation', '12345678')
+ ->click('[type=submit]');
+ })
+ ->waitUntilMissing('@step3')
+ ->on(new Wallet())
+ ->assertSeeIn('#lock-alert', "The account is locked")
+ ->within(new Menu(), function ($browser) {
+ $browser->clickMenuItem('logout');
+ });
+ });
+
+ $user = User::where('email', 'signuptestdusk@' . \config('app.domain'))->first();
+ $this->assertSame($plan->id, $user->getSetting('plan_id'));
+
+ // Login again and see that the account is still locked
+ $this->browse(function (Browser $browser) use ($user) {
+ $browser->on(new Home())
+ ->submitLogon($user->email, '12345678', false)
+ ->waitForLocation('/wallet')
+ ->on(new Wallet())
+ ->assertSeeIn('#lock-alert', "The account is locked")
+ ->within(new Menu(), function ($browser) {
+ $browser->clickMenuItem('logout');
+ });
+
+ // TODO: Test automatic UI unlock after creating a valid auto-payment mandate
+ });
+ }
+
/**
* Test signup with a token plan
*/
diff --git a/src/tests/Feature/Controller/PaymentsMollieEuroTest.php b/src/tests/Feature/Controller/PaymentsMollieEuroTest.php
--- a/src/tests/Feature/Controller/PaymentsMollieEuroTest.php
+++ b/src/tests/Feature/Controller/PaymentsMollieEuroTest.php
@@ -501,7 +501,7 @@
$this->assertSame(2010, $transaction->amount);
$this->assertSame(
- "Auto-payment transaction {$payment->id} using Mastercard (**** **** **** 9399)",
+ "Auto-payment transaction {$payment->id} using Mastercard (**** **** **** 6787)",
$transaction->description
);
diff --git a/src/tests/Feature/Controller/PaymentsMollieTest.php b/src/tests/Feature/Controller/PaymentsMollieTest.php
--- a/src/tests/Feature/Controller/PaymentsMollieTest.php
+++ b/src/tests/Feature/Controller/PaymentsMollieTest.php
@@ -569,7 +569,7 @@
$this->assertSame(2010, $transaction->amount);
$this->assertSame(
- "Auto-payment transaction {$payment->id} using Mastercard (**** **** **** 9399)",
+ "Auto-payment transaction {$payment->id} using Mastercard (**** **** **** 6787)",
$transaction->description
);
diff --git a/src/tests/Feature/Controller/SignupTest.php b/src/tests/Feature/Controller/SignupTest.php
--- a/src/tests/Feature/Controller/SignupTest.php
+++ b/src/tests/Feature/Controller/SignupTest.php
@@ -5,6 +5,8 @@
use App\Http\Controllers\API\SignupController;
use App\Discount;
use App\Domain;
+use App\Plan;
+use App\Package;
use App\SignupCode;
use App\SignupInvitation as SI;
use App\User;
@@ -36,6 +38,7 @@
$this->deleteTestGroup('group-test@kolabnow.com');
SI::truncate();
+ Plan::where('title', 'test')->delete();
}
/**
@@ -53,6 +56,7 @@
$this->deleteTestGroup('group-test@kolabnow.com');
SI::truncate();
+ Plan::where('title', 'test')->delete();
parent::tearDown();
}
@@ -761,6 +765,62 @@
// TODO: Check if the access token works
}
+ /**
+ * Test signup with mode=mandate
+ */
+ public function testSignupMandateMode(): void
+ {
+ Queue::fake();
+
+ $plan = Plan::create([
+ 'title' => 'test',
+ 'name' => 'Test Account',
+ 'description' => 'Test',
+ 'free_months' => 1,
+ 'discount_qty' => 0,
+ 'discount_rate' => 0,
+ 'mode' => 'mandate',
+ ]);
+
+ $packages = [
+ Package::where(['title' => 'kolab', 'tenant_id' => \config('app.tenant_id')])->first()
+ ];
+
+ $plan->packages()->saveMany($packages);
+
+ $post = [
+ 'plan' => 'abc',
+ 'login' => 'test-inv',
+ 'domain' => 'kolabnow.com',
+ 'password' => 'testtest',
+ 'password_confirmation' => 'testtest',
+ ];
+
+ // Test invalid plan identifier
+ $response = $this->post('/api/auth/signup', $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(1, $json['errors']);
+ $this->assertSame("The selected plan is invalid.", $json['errors']['plan']);
+
+ // Test valid input
+ $post['plan'] = $plan->title;
+ $response = $this->post('/api/auth/signup', $post);
+ $json = $response->json();
+
+ $response->assertStatus(200);
+ $this->assertSame('success', $json['status']);
+ $this->assertNotEmpty($json['access_token']);
+ $this->assertSame('test-inv@kolabnow.com', $json['email']);
+ $this->assertTrue($json['isLocked']);
+ $user = User::where('email', 'test-inv@kolabnow.com')->first();
+ $this->assertNotEmpty($user);
+ $this->assertSame($plan->id, $user->getSetting('plan_id'));
+ }
+
/**
* Test signup via invitation
*/
diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php
--- a/src/tests/Feature/UserTest.php
+++ b/src/tests/Feature/UserTest.php
@@ -1160,12 +1160,24 @@
// Test an account with users, domain
$user = $this->getTestUser('UserAccountA@UserAccount.com');
+ $userB = $this->getTestUser('UserAccountB@UserAccount.com');
+ $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first();
+ $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first();
+ $domain = $this->getTestDomain('UserAccount.com', [
+ 'status' => Domain::STATUS_NEW,
+ 'type' => Domain::TYPE_HOSTED,
+ ]);
+ $user->assignPackage($package_kolab);
+ $domain->assignPackage($package_domain, $user);
+ $user->assignPackage($package_kolab, $userB);
$this->assertFalse($user->isRestricted());
+ $this->assertFalse($userB->isRestricted());
$user->restrict();
$this->assertTrue($user->fresh()->isRestricted());
+ $this->assertFalse($userB->fresh()->isRestricted());
Queue::assertPushed(
\App\Jobs\User\UpdateJob::class,
@@ -1173,18 +1185,35 @@
return TestCase::getObjectProperty($job, 'userId') == $user->id;
});
+ $userB->restrict();
+ $this->assertTrue($userB->fresh()->isRestricted());
+
Queue::fake(); // reset queue state
$user->refresh();
$user->unrestrict();
$this->assertFalse($user->fresh()->isRestricted());
+ $this->assertTrue($userB->fresh()->isRestricted());
Queue::assertPushed(
\App\Jobs\User\UpdateJob::class,
function ($job) use ($user) {
return TestCase::getObjectProperty($job, 'userId') == $user->id;
});
+
+ Queue::fake(); // reset queue state
+
+ $user->unrestrict(true);
+
+ $this->assertFalse($user->fresh()->isRestricted());
+ $this->assertFalse($userB->fresh()->isRestricted());
+
+ Queue::assertPushed(
+ \App\Jobs\User\UpdateJob::class,
+ function ($job) use ($userB) {
+ return TestCase::getObjectProperty($job, 'userId') == $userB->id;
+ });
}
/**

File Metadata

Mime Type
text/plain
Expires
Sun, Apr 5, 11:56 PM (7 h, 49 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18834986
Default Alt Text
D4130.1775433404.diff (44 KB)

Event Timeline