Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117921945
D4130.1775433404.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
44 KB
Referenced Files
None
Subscribers
None
D4130.1775433404.diff
View Options
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
Details
Attached
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)
Attached To
Mode
D4130: Signup 'mandate' mode, and other improvements
Attached
Detach File
Event Timeline