Page MenuHomePhorge

D1870.1775254494.diff
No OneTemporary

Authored By
Unknown
Size
76 KB
Referenced Files
None
Subscribers
None

D1870.1775254494.diff

diff --git a/src/app/Domain.php b/src/app/Domain.php
--- a/src/app/Domain.php
+++ b/src/app/Domain.php
@@ -3,6 +3,8 @@
namespace App;
use App\Wallet;
+use App\Traits\DomainConfigTrait;
+use App\Traits\SettingsTrait;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
@@ -13,6 +15,8 @@
*/
class Domain extends Model
{
+ use DomainConfigTrait;
+ use SettingsTrait;
use SoftDeletes;
// we've simply never heard of this domain
@@ -361,6 +365,16 @@
return $mod === self::HASH_TEXT ? "$cname=$hash" : $hash;
}
+ /**
+ * Any (additional) properties of this domain.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\HasMany
+ */
+ public function settings()
+ {
+ return $this->hasMany('App\DomainSetting', 'domain_id');
+ }
+
/**
* Suspend this domain.
*
diff --git a/src/app/DomainSetting.php b/src/app/DomainSetting.php
new file mode 100644
--- /dev/null
+++ b/src/app/DomainSetting.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace App;
+
+use Illuminate\Database\Eloquent\Model;
+
+/**
+ * A collection of settings for a Domain.
+ *
+ * @property int $id
+ * @property int $domain_id
+ * @property string $key
+ * @property string $value
+ */
+class DomainSetting extends Model
+{
+ protected $fillable = [
+ 'domain_id', 'key', 'value'
+ ];
+
+ /**
+ * The domain to which this setting belongs.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function domain()
+ {
+ return $this->belongsTo(
+ '\App\Domain',
+ 'domain_id', /* local */
+ 'id' /* remote */
+ );
+ }
+}
diff --git a/src/app/Http/Controllers/API/V4/DomainsController.php b/src/app/Http/Controllers/API/V4/DomainsController.php
--- a/src/app/Http/Controllers/API/V4/DomainsController.php
+++ b/src/app/Http/Controllers/API/V4/DomainsController.php
@@ -96,6 +96,39 @@
return $this->errorResponse(404);
}
+ /**
+ * Set the domain configuration.
+ *
+ * @param int $id Domain identifier
+ *
+ * @return \Illuminate\Http\JsonResponse|void
+ */
+ public function setConfig($id)
+ {
+ $domain = Domain::find($id);
+
+ if (empty($domain)) {
+ return $this->errorResponse(404);
+ }
+
+ // Only owner (or admin) has access to the domain
+ if (!Auth::guard()->user()->canRead($domain)) {
+ return $this->errorResponse(403);
+ }
+
+ $errors = $domain->setConfig(request()->input());
+
+ if (!empty($errors)) {
+ return response()->json(['status' => 'error', 'errors' => $errors], 422);
+ }
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => \trans('app.domain-setconfig-success'),
+ ]);
+ }
+
+
/**
* Store a newly created resource in storage.
*
@@ -133,7 +166,10 @@
// Add DNS/MX configuration for the domain
$response['dns'] = self::getDNSConfig($domain);
- $response['config'] = self::getMXConfig($domain->namespace);
+ $response['mx'] = self::getMXConfig($domain->namespace);
+
+ // Domain configuration, e.g. spf whitelist
+ $response['config'] = $domain->getConfig();
// Status info
$response['statusInfo'] = self::statusInfo($domain);
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
@@ -77,6 +77,37 @@
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' => __('app.user-setconfig-success'),
+ ]);
+ }
+
/**
* Display information on the user account specified by $id.
*
@@ -109,6 +140,8 @@
];
}
+ $response['config'] = $user->getConfig();
+
return response()->json($response);
}
diff --git a/src/app/Traits/DomainConfigTrait.php b/src/app/Traits/DomainConfigTrait.php
new file mode 100644
--- /dev/null
+++ b/src/app/Traits/DomainConfigTrait.php
@@ -0,0 +1,60 @@
+<?php
+
+namespace App\Traits;
+
+trait DomainConfigTrait
+{
+ /**
+ * A helper to get the domain configuration.
+ */
+ public function getConfig(): array
+ {
+ $config = [];
+
+ $spf = $this->getSetting('spf_whitelist');
+
+ $config['spf_whitelist'] = $spf ? json_decode($spf, true) : [];
+
+ return $config;
+ }
+
+ /**
+ * A helper to update domain configuration.
+ *
+ * @param array $config An array of configuration options
+ *
+ * @return array A list of input validation errors
+ */
+ public function setConfig(array $config): array
+ {
+ $errors = [];
+
+ foreach ($config as $key => $value) {
+ // validate and save SPF whitelist entries
+ if ($key === 'spf_whitelist') {
+ if (!is_array($value)) {
+ $value = (array) $value;
+ }
+
+ foreach ($value as $i => $v) {
+ if (empty($v)) {
+ unset($value[$i]);
+ continue;
+ }
+
+ if ($v[0] !== '.' || !filter_var(substr($v, 1), FILTER_VALIDATE_DOMAIN)) {
+ $errors[$key][$i] = \trans('validation.spf-entry-invalid');
+ }
+ }
+
+ if (empty($errors[$key])) {
+ $this->setSetting($key, json_encode($value));
+ }
+ } else {
+ $errors[$key] = \trans('validation.invalid-config-parameter');
+ }
+ }
+
+ return $errors;
+ }
+}
diff --git a/src/app/Traits/UserConfigTrait.php b/src/app/Traits/UserConfigTrait.php
new file mode 100644
--- /dev/null
+++ b/src/app/Traits/UserConfigTrait.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace App\Traits;
+
+trait UserConfigTrait
+{
+ /**
+ * A helper to get the user configuration.
+ */
+ public function getConfig(): array
+ {
+ $config = [];
+
+ // TODO: Should we store the default value somewhere in config?
+
+ $config['greylisting'] = $this->getSetting('greylisting') !== 'false';
+
+ return $config;
+ }
+
+ /**
+ * A helper to update user configuration.
+ *
+ * @param array $config An array of configuration options
+ *
+ * @return array A list of input validation error messages
+ */
+ public function setConfig(array $config): array
+ {
+ $errors = [];
+
+ foreach ($config as $key => $value) {
+ if ($key == 'greylisting') {
+ $this->setSetting('greylisting', $value ? 'true' : 'false');
+ } else {
+ $errors[$key] = \trans('validation.invalid-config-parameter');
+ }
+ }
+
+ return $errors;
+ }
+}
diff --git a/src/app/User.php b/src/app/User.php
--- a/src/app/User.php
+++ b/src/app/User.php
@@ -5,6 +5,7 @@
use App\Entitlement;
use App\UserAlias;
use App\Sku;
+use App\Traits\UserConfigTrait;
use App\Traits\UserAliasesTrait;
use App\Traits\SettingsTrait;
use App\Wallet;
@@ -26,6 +27,7 @@
{
use Notifiable;
use NullableFields;
+ use UserConfigTrait;
use UserAliasesTrait;
use SettingsTrait;
use SoftDeletes;
diff --git a/src/database/migrations/2020_11_20_120000_add_domains_primary_key.php b/src/database/migrations/2020_11_20_120000_add_domains_primary_key.php
new file mode 100644
--- /dev/null
+++ b/src/database/migrations/2020_11_20_120000_add_domains_primary_key.php
@@ -0,0 +1,39 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+// phpcs:ignore
+class AddDomainsPrimaryKey extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::table(
+ 'domains',
+ function (Blueprint $table) {
+ $table->primary('id');
+ }
+ );
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::table(
+ 'domains',
+ function (Blueprint $table) {
+ $table->dropPrimary('id');
+ }
+ );
+ }
+}
diff --git a/src/database/migrations/2020_11_20_130000_create_domain_settings_table.php b/src/database/migrations/2020_11_20_130000_create_domain_settings_table.php
new file mode 100644
--- /dev/null
+++ b/src/database/migrations/2020_11_20_130000_create_domain_settings_table.php
@@ -0,0 +1,44 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+// phpcs:ignore
+class CreateDomainSettingsTable extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::create(
+ 'domain_settings',
+ function (Blueprint $table) {
+ $table->bigIncrements('id');
+ $table->bigInteger('domain_id');
+ $table->string('key');
+ $table->text('value');
+ $table->timestamp('created_at')->useCurrent();
+ $table->timestamp('updated_at')->useCurrent();
+
+ $table->foreign('domain_id')->references('id')->on('domains')
+ ->onDelete('cascade')->onUpdate('cascade');
+
+ $table->unique(['domain_id', 'key']);
+ }
+ );
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::dropIfExists('domain_settings');
+ }
+}
diff --git a/src/database/migrations/2020_11_20_140000_extend_settings_value_column.php b/src/database/migrations/2020_11_20_140000_extend_settings_value_column.php
new file mode 100644
--- /dev/null
+++ b/src/database/migrations/2020_11_20_140000_extend_settings_value_column.php
@@ -0,0 +1,41 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+// phpcs:ignore
+class ExtendSettingsValueColumn extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::table(
+ 'user_settings',
+ function (Blueprint $table) {
+ $table->text('value')->change();
+ }
+ );
+
+ Schema::table(
+ 'wallet_settings',
+ function (Blueprint $table) {
+ $table->text('value')->change();
+ }
+ );
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ // do nothing
+ }
+}
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
@@ -130,6 +130,10 @@
$('#app > .app-loader').addClass('fadeOut')
this.isLoading = false
},
+ tab(e) {
+ e.preventDefault()
+ $(e.target).tab('show')
+ },
errorPage(code, msg) {
// Until https://github.com/vuejs/vue-router/issues/977 is implemented
// we can't really use router to display error page as it has two side
diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php
--- a/src/resources/lang/en/app.php
+++ b/src/resources/lang/en/app.php
@@ -34,6 +34,7 @@
'domain-verify-error' => 'Domain ownership verification failed.',
'domain-suspend-success' => 'Domain suspended successfully.',
'domain-unsuspend-success' => 'Domain unsuspended successfully.',
+ 'domain-setconfig-success' => 'Domain settings updated successfully.',
'user-update-success' => 'User data updated successfully.',
'user-create-success' => 'User created successfully.',
@@ -41,6 +42,7 @@
'user-suspend-success' => 'User suspended successfully.',
'user-unsuspend-success' => 'User unsuspended successfully.',
'user-reset-2fa-success' => '2-Factor authentication reset successfully.',
+ 'user-setconfig-success' => 'User settings updated successfully.',
'search-foundxdomains' => ':x domains have been found.',
'search-foundxusers' => ':x user accounts have been found.',
diff --git a/src/resources/lang/en/validation.php b/src/resources/lang/en/validation.php
--- a/src/resources/lang/en/validation.php
+++ b/src/resources/lang/en/validation.php
@@ -134,6 +134,8 @@
'entryexists' => 'The specified :attribute is not available.',
'minamount' => 'Minimum amount for a single payment is :amount.',
'minamountdebt' => 'The specified amount does not cover the balance on the account.',
+ 'spf-entry-invalid' => 'The entry format is invalid. Expected a domain name starting with a dot.',
+ 'invalid-config-parameter' => 'The requested configuration parameter is not supported.',
/*
|--------------------------------------------------------------------------
diff --git a/src/resources/themes/app.scss b/src/resources/themes/app.scss
--- a/src/resources/themes/app.scss
+++ b/src/resources/themes/app.scss
@@ -305,13 +305,8 @@
padding: 0.5rem 0;
}
- .form-group {
- margin-bottom: 0.5rem;
- }
-
.nav-tabs {
flex-wrap: nowrap;
- overflow-x: auto;
.nav-link {
white-space: nowrap;
@@ -319,27 +314,6 @@
}
}
- .tab-content {
- margin-top: 0.5rem;
- }
-
- .col-form-label {
- color: #666;
- font-size: 95%;
- }
-
- .form-group.plaintext .col-form-label {
- padding-bottom: 0;
- }
-
- form.read-only.short label {
- width: 35%;
-
- & + * {
- width: 65%;
- }
- }
-
#app > div.container {
margin-bottom: 1rem;
margin-top: 1rem;
@@ -420,3 +394,9 @@
}
}
}
+
+@include media-breakpoint-down(sm) {
+ .tab-pane > .card-body {
+ padding: 0.5rem;
+ }
+}
diff --git a/src/resources/themes/forms.scss b/src/resources/themes/forms.scss
--- a/src/resources/themes/forms.scss
+++ b/src/resources/themes/forms.scss
@@ -45,3 +45,48 @@
margin-bottom: 0;
}
}
+
+// Various improvements for mobile
+@include media-breakpoint-down(sm) {
+ .form-group {
+ margin-bottom: 0.5rem;
+ }
+
+ .form-group.plaintext .col-form-label {
+ padding-bottom: 0;
+ }
+
+ form.read-only.short label {
+ width: 35%;
+
+ & + * {
+ width: 65%;
+ }
+ }
+}
+
+@include media-breakpoint-down(xs) {
+ .col-form-label {
+ color: #666;
+ font-size: 95%;
+ }
+
+ .form-group.checkbox {
+ position: relative;
+
+ & > div {
+ position: initial;
+ padding-top: 0 !important;
+
+ input {
+ position: absolute;
+ top: 0.5rem;
+ right: 1rem;
+ }
+ }
+
+ label {
+ padding-right: 2.5rem;
+ }
+ }
+}
diff --git a/src/resources/vue/Admin/Domain.vue b/src/resources/vue/Admin/Domain.vue
--- a/src/resources/vue/Admin/Domain.vue
+++ b/src/resources/vue/Admin/Domain.vue
@@ -31,10 +31,15 @@
</div>
<ul class="nav nav-tabs mt-3" role="tablist">
<li class="nav-item">
- <a class="nav-link active" id="tab-config" href="#domain-config" role="tab" aria-controls="domain-config" aria-selected="true">
+ <a class="nav-link active" id="tab-config" href="#domain-config" role="tab" aria-controls="domain-config" aria-selected="true" @click="$root.tab">
Configuration
</a>
</li>
+ <li class="nav-item">
+ <a class="nav-link" id="tab-settings" href="#domain-settings" role="tab" aria-controls="domain-settings" aria-selected="false" @click="$root.tab">
+ Settings
+ </a>
+ </li>
</ul>
<div class="tab-content">
<div class="tab-pane show active" id="domain-config" role="tabpanel" aria-labelledby="tab-config">
@@ -43,7 +48,23 @@
<p>Domain DNS verification sample:</p>
<p><pre id="dns-verify">{{ domain.dns.join("\n") }}</pre></p>
<p>Domain DNS configuration sample:</p>
- <p><pre id="dns-config">{{ domain.config.join("\n") }}</pre></p>
+ <p><pre id="dns-config">{{ domain.mx.join("\n") }}</pre></p>
+ </div>
+ </div>
+ </div>
+ <div class="tab-pane" id="domain-settings" role="tabpanel" aria-labelledby="tab-settings">
+ <div class="card-body">
+ <div class="card-text">
+ <form class="read-only short">
+ <div class="form-group row plaintext">
+ <label for="spf_whitelist" class="col-sm-4 col-form-label">SPF Whitelist</label>
+ <div class="col-sm-8">
+ <span class="form-control-plaintext" id="spf_whitelist">
+ {{ domain.config && domain.config.spf_whitelist.length ? domain.config.spf_whitelist.join(', ') : 'none' }}
+ </span>
+ </div>
+ </div>
+ </form>
</div>
</div>
</div>
diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue
--- a/src/resources/vue/Admin/User.vue
+++ b/src/resources/vue/Admin/User.vue
@@ -108,6 +108,11 @@
Users ({{ users.length }})
</a>
</li>
+ <li class="nav-item">
+ <a class="nav-link" id="tab-settings" href="#user-settings" role="tab" aria-controls="user-settings" aria-selected="false">
+ Settings
+ </a>
+ </li>
</ul>
<div class="tab-content">
<div class="tab-pane show active" id="user-finances" role="tabpanel" aria-labelledby="tab-finances">
@@ -259,6 +264,23 @@
</div>
</div>
</div>
+ <div class="tab-pane" id="user-settings" role="tabpanel" aria-labelledby="tab-settings">
+ <div class="card-body">
+ <div class="card-text">
+ <form class="read-only short">
+ <div class="form-group row plaintext">
+ <label for="greylisting" class="col-sm-4 col-form-label">Greylisting</label>
+ <div class="col-sm-8">
+ <span class="form-control-plaintext" id="greylisting">
+ <span v-if="user.config.greylisting" class="text-success">enabled</span>
+ <span v-else class="text-danger">disabled</span>
+ </span>
+ </div>
+ </div>
+ </form>
+ </div>
+ </div>
+ </div>
</div>
<div id="discount-dialog" class="modal" tabindex="-1" role="dialog">
@@ -405,6 +427,7 @@
users: [],
user: {
aliases: [],
+ config: {},
wallet: {},
skus: {},
}
@@ -494,10 +517,7 @@
.catch(this.$root.errorHandler)
},
mounted() {
- $(this.$el).find('ul.nav-tabs a').on('click', e => {
- e.preventDefault()
- $(e.target).tab('show')
- })
+ $(this.$el).find('ul.nav-tabs a').on('click', this.$root.tab)
},
methods: {
capitalize(str) {
diff --git a/src/resources/vue/Domain/Info.vue b/src/resources/vue/Domain/Info.vue
--- a/src/resources/vue/Domain/Info.vue
+++ b/src/resources/vue/Domain/Info.vue
@@ -2,34 +2,76 @@
<div class="container">
<status-component :status="status" @status-update="statusUpdate"></status-component>
- <div v-if="domain && !domain.isConfirmed" class="card" id="domain-verify">
+ <div v-if="domain" class="card">
<div class="card-body">
- <div class="card-title">Domain verification</div>
+ <div class="card-title">{{ domain.namespace }}</div>
<div class="card-text">
- <p>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.</p>
- <p>The domain <b>must have one of the following entries</b> in DNS:
- <ul>
- <li>TXT entry with value: <code>{{ domain.hash_text }}</code></li>
- <li>or CNAME entry: <code>{{ domain.hash_cname }}.{{ domain.namespace }}. IN CNAME {{ domain.hash_code }}.{{ domain.namespace }}.</code></li>
- </ul>
- When this is done press the button below to start the verification.</p>
- <p>Here's a sample zone file for your domain: <pre>{{ domain.dns.join("\n") }}</pre></p>
- <button class="btn btn-primary" type="button" @click="confirm"><svg-icon icon="sync-alt"></svg-icon> Verify</button>
- </div>
- </div>
- </div>
- <div v-if="domain && domain.isConfirmed" class="card" id="domain-config">
- <div class="card-body">
- <div class="card-title">Domain configuration</div>
- <div class="card-text">
- <p>In order to let {{ app_name }} receive email traffic for your domain you need to adjust
- the DNS settings, more precisely the MX entries, accordingly.</p>
- <p>Edit your domain's zone file and replace existing MX
- entries with the following values: <pre>{{ domain.config.join("\n") }}</pre></p>
- <p>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.</p>
+ <ul class="nav nav-tabs mt-3" role="tablist">
+ <li class="nav-item" v-if="!domain.isConfirmed">
+ <a class="nav-link active" id="tab-general" href="#general" role="tab" aria-controls="general" aria-selected="true" @click="$root.tab">
+ Domain verification
+ </a>
+ </li>
+ <li class="nav-item" v-if="domain.isConfirmed">
+ <a class="nav-link active" id="tab-general" href="#general" role="tab" aria-controls="general" aria-selected="true" @click="$root.tab">
+ Domain configuration
+ </a>
+ </li>
+ <li class="nav-item">
+ <a class="nav-link" id="tab-settings" href="#settings" role="tab" aria-controls="settings" aria-selected="false" @click="$root.tab">
+ Settings
+ </a>
+ </li>
+ </ul>
+ <div class="tab-content">
+ <div class="tab-pane show active" id="general" role="tabpanel" aria-labelledby="tab-general">
+ <div v-if="!domain.isConfirmed" class="card-body" id="domain-verify">
+ <div class="card-text">
+ <p>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.</p>
+ <p>The domain <b>must have one of the following entries</b> in DNS:
+ <ul>
+ <li>TXT entry with value: <code>{{ domain.hash_text }}</code></li>
+ <li>or CNAME entry: <code>{{ domain.hash_cname }}.{{ domain.namespace }}. IN CNAME {{ domain.hash_code }}.{{ domain.namespace }}.</code></li>
+ </ul>
+ When this is done press the button below to start the verification.</p>
+ <p>Here's a sample zone file for your domain: <pre>{{ domain.dns.join("\n") }}</pre></p>
+ <button class="btn btn-primary" type="button" @click="confirm"><svg-icon icon="sync-alt"></svg-icon> Verify</button>
+ </div>
+ </div>
+ <div v-if="domain.isConfirmed" class="card-body" id="domain-config">
+ <div class="card-text">
+ <p>In order to let {{ app_name }} receive email traffic for your domain you need to adjust
+ the DNS settings, more precisely the MX entries, accordingly.</p>
+ <p>Edit your domain's zone file and replace existing MX
+ entries with the following values: <pre>{{ domain.mx.join("\n") }}</pre></p>
+ <p>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.</p>
+ </div>
+ </div>
+ </div>
+ <div class="tab-pane" id="settings" role="tabpanel" aria-labelledby="tab-settings">
+ <div class="card-body">
+ <form @submit.prevent="submitSettings">
+ <div class="form-group row">
+ <label for="spf_whitelist" class="col-sm-4 col-form-label">SPF Whitelist</label>
+ <div class="col-sm-8">
+ <list-input id="spf_whitelist" name="spf_whitelist" :list="spf_whitelist"></list-input>
+ <small id="spf-hint" class="form-text text-muted">
+ 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.
+ <span class="d-block">
+ Here you can specify a list of allowed servers, for example: <var>.ess.barracuda.com</var>.
+ </span>
+ </small>
+ </div>
+ </div>
+ <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> Submit</button>
+ </form>
+ </div>
+ </div>
+ </div>
</div>
</div>
</div>
@@ -37,10 +79,12 @@
</template>
<script>
+ import ListInput from '../Widgets/ListInput'
import StatusComponent from '../Widgets/Status'
export default {
components: {
+ ListInput,
StatusComponent
},
data() {
@@ -48,6 +92,7 @@
domain_id: null,
domain: null,
app_name: window.config['app.name'],
+ spf_whitelist: [],
status: {}
}
},
@@ -59,6 +104,7 @@
.then(response => {
this.$root.stopLoading()
this.domain = response.data
+ this.spf_whitelist = this.domain.config.spf_whitelist || []
if (!this.domain.isConfirmed) {
$('#domain-verify button').focus()
@@ -87,6 +133,16 @@
},
statusUpdate(domain) {
this.domain = Object.assign({}, this.domain, domain)
+ },
+ submitSettings() {
+ this.$root.clearFormValidation($('#settings form'))
+
+ let post = { spf_whitelist: this.spf_whitelist }
+
+ axios.post('/api/v4/domains/' + this.domain_id + '/config', post)
+ .then(response => {
+ this.$toast.success(response.data.message)
+ })
}
}
}
diff --git a/src/resources/vue/User/Info.vue b/src/resources/vue/User/Info.vue
--- a/src/resources/vue/User/Info.vue
+++ b/src/resources/vue/User/Info.vue
@@ -7,151 +7,187 @@
<div class="card-title" v-if="user_id !== 'new'">User account</div>
<div class="card-title" v-if="user_id === 'new'">New user account</div>
<div class="card-text">
- <form @submit.prevent="submit">
- <div v-if="user_id !== 'new'" class="form-group row plaintext">
- <label for="first_name" class="col-sm-4 col-form-label">Status</label>
- <div class="col-sm-8">
- <span :class="$root.userStatusClass(user) + ' form-control-plaintext'" id="status">{{ $root.userStatusText(user) }}</span>
+ <ul class="nav nav-tabs mt-3" role="tablist">
+ <li class="nav-item">
+ <a class="nav-link active" id="tab-general" href="#general" role="tab" aria-controls="general" aria-selected="true" @click="$root.tab">
+ General
+ </a>
+ </li>
+ <li v-if="user_id !== 'new'" class="nav-item">
+ <a class="nav-link" id="tab-settings" href="#settings" role="tab" aria-controls="settings" aria-selected="false" @click="$root.tab">
+ Settings
+ </a>
+ </li>
+ </ul>
+ <div class="tab-content">
+ <div class="tab-pane show active" id="general" role="tabpanel" aria-labelledby="tab-general">
+ <div class="card-body">
+ <form @submit.prevent="submit">
+ <div v-if="user_id !== 'new'" class="form-group row plaintext">
+ <label for="first_name" class="col-sm-4 col-form-label">Status</label>
+ <div class="col-sm-8">
+ <span :class="$root.userStatusClass(user) + ' form-control-plaintext'" id="status">{{ $root.userStatusText(user) }}</span>
+ </div>
+ </div>
+ <div class="form-group row">
+ <label for="first_name" class="col-sm-4 col-form-label">First name</label>
+ <div class="col-sm-8">
+ <input type="text" class="form-control" id="first_name" v-model="user.first_name">
+ </div>
+ </div>
+ <div class="form-group row">
+ <label for="last_name" class="col-sm-4 col-form-label">Last name</label>
+ <div class="col-sm-8">
+ <input type="text" class="form-control" id="last_name" v-model="user.last_name">
+ </div>
+ </div>
+ <div class="form-group row">
+ <label for="organization" class="col-sm-4 col-form-label">Organization</label>
+ <div class="col-sm-8">
+ <input type="text" class="form-control" id="organization" v-model="user.organization">
+ </div>
+ </div>
+ <div class="form-group row">
+ <label for="email" class="col-sm-4 col-form-label">Email</label>
+ <div class="col-sm-8">
+ <input type="text" class="form-control" id="email" :disabled="user_id !== 'new'" required v-model="user.email">
+ </div>
+ </div>
+ <div class="form-group row">
+ <label for="aliases-input" class="col-sm-4 col-form-label">Email aliases</label>
+ <div class="col-sm-8">
+ <list-input id="aliases" :list="user.aliases"></list-input>
+ </div>
+ </div>
+ <div class="form-group row">
+ <label for="password" class="col-sm-4 col-form-label">Password</label>
+ <div class="col-sm-8">
+ <input type="password" class="form-control" id="password" v-model="user.password" :required="user_id === 'new'">
+ </div>
+ </div>
+ <div class="form-group row">
+ <label for="password_confirmaton" class="col-sm-4 col-form-label">Confirm password</label>
+ <div class="col-sm-8">
+ <input type="password" class="form-control" id="password_confirmation" v-model="user.password_confirmation" :required="user_id === 'new'">
+ </div>
+ </div>
+ <div v-if="user_id === 'new'" id="user-packages" class="form-group row">
+ <label class="col-sm-4 col-form-label">Package</label>
+ <div class="col-sm-8">
+ <table class="table table-sm form-list">
+ <thead class="thead-light sr-only">
+ <tr>
+ <th scope="col"></th>
+ <th scope="col">Package</th>
+ <th scope="col">Price</th>
+ <th scope="col"></th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="pkg in packages" :id="'p' + pkg.id" :key="pkg.id">
+ <td class="selection">
+ <input type="checkbox" @click="selectPackage"
+ :value="pkg.id"
+ :checked="pkg.id == package_id"
+ :id="'pkg-input-' + pkg.id"
+ >
+ </td>
+ <td class="name">
+ <label :for="'pkg-input-' + pkg.id">{{ pkg.name }}</label>
+ </td>
+ <td class="price text-nowrap">
+ {{ $root.priceLabel(pkg.cost, 1, discount) }}
+ </td>
+ <td class="buttons">
+ <button v-if="pkg.description" type="button" class="btn btn-link btn-lg p-0" v-tooltip.click="pkg.description">
+ <svg-icon icon="info-circle"></svg-icon>
+ <span class="sr-only">More information</span>
+ </button>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ <small v-if="discount > 0" class="hint">
+ <hr class="m-0">
+ &sup1; applied discount: {{ discount }}% - {{ discount_description }}
+ </small>
+ </div>
+ </div>
+ <div v-if="user_id !== 'new'" id="user-skus" class="form-group row">
+ <label class="col-sm-4 col-form-label">Subscriptions</label>
+ <div class="col-sm-8">
+ <table class="table table-sm form-list">
+ <thead class="thead-light sr-only">
+ <tr>
+ <th scope="col"></th>
+ <th scope="col">Subscription</th>
+ <th scope="col">Price</th>
+ <th scope="col"></th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="sku in skus" :id="'s' + sku.id" :key="sku.id">
+ <td class="selection">
+ <input type="checkbox" @input="onInputSku"
+ :value="sku.id"
+ :disabled="sku.readonly"
+ :checked="sku.enabled"
+ :id="'sku-input-' + sku.title"
+ >
+ </td>
+ <td class="name">
+ <label :for="'sku-input-' + sku.title">{{ sku.name }}</label>
+ <div v-if="sku.range" class="range-input">
+ <label class="text-nowrap">{{ sku.range.min }} {{ sku.range.unit }}</label>
+ <input
+ type="range" class="custom-range" @input="rangeUpdate"
+ :value="sku.value || sku.range.min"
+ :min="sku.range.min"
+ :max="sku.range.max"
+ >
+ </div>
+ </td>
+ <td class="price text-nowrap">
+ {{ $root.priceLabel(sku.cost, 1, discount) }}
+ </td>
+ <td class="buttons">
+ <button v-if="sku.description" type="button" class="btn btn-link btn-lg p-0" v-tooltip.click="sku.description">
+ <svg-icon icon="info-circle"></svg-icon>
+ <span class="sr-only">More information</span>
+ </button>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ <small v-if="discount > 0" class="hint">
+ <hr class="m-0">
+ &sup1; applied discount: {{ discount }}% - {{ discount_description }}
+ </small>
+ </div>
+ </div>
+ <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> Submit</button>
+ </form>
</div>
</div>
- <div class="form-group row">
- <label for="first_name" class="col-sm-4 col-form-label">First name</label>
- <div class="col-sm-8">
- <input type="text" class="form-control" id="first_name" v-model="user.first_name">
+ <div class="tab-pane" id="settings" role="tabpanel" aria-labelledby="tab-settings">
+ <div class="card-body">
+ <form @submit.prevent="submitSettings">
+ <div class="form-group row checkbox">
+ <label for="greylisting" class="col-sm-4 col-form-label">Greylisting</label>
+ <div class="col-sm-8 pt-2">
+ <input type="checkbox" id="greylisting" name="greylisting" value="1" :checked="user.config.greylisting">
+ <small id="greylisting-hint" class="form-text text-muted">
+ 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.
+ </small>
+ </div>
+ </div>
+ <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> Submit</button>
+ </form>
</div>
</div>
- <div class="form-group row">
- <label for="last_name" class="col-sm-4 col-form-label">Last name</label>
- <div class="col-sm-8">
- <input type="text" class="form-control" id="last_name" v-model="user.last_name">
- </div>
- </div>
- <div class="form-group row">
- <label for="organization" class="col-sm-4 col-form-label">Organization</label>
- <div class="col-sm-8">
- <input type="text" class="form-control" id="organization" v-model="user.organization">
- </div>
- </div>
- <div class="form-group row">
- <label for="email" class="col-sm-4 col-form-label">Email</label>
- <div class="col-sm-8">
- <input type="text" class="form-control" id="email" :disabled="user_id !== 'new'" required v-model="user.email">
- </div>
- </div>
- <div class="form-group row">
- <label for="aliases-input" class="col-sm-4 col-form-label">Email aliases</label>
- <div class="col-sm-8">
- <list-input id="aliases" :list="user.aliases"></list-input>
- </div>
- </div>
- <div class="form-group row">
- <label for="password" class="col-sm-4 col-form-label">Password</label>
- <div class="col-sm-8">
- <input type="password" class="form-control" id="password" v-model="user.password" :required="user_id === 'new'">
- </div>
- </div>
- <div class="form-group row">
- <label for="password_confirmaton" class="col-sm-4 col-form-label">Confirm password</label>
- <div class="col-sm-8">
- <input type="password" class="form-control" id="password_confirmation" v-model="user.password_confirmation" :required="user_id === 'new'">
- </div>
- </div>
- <div v-if="user_id === 'new'" id="user-packages" class="form-group row">
- <label class="col-sm-4 col-form-label">Package</label>
- <div class="col-sm-8">
- <table class="table table-sm form-list">
- <thead class="thead-light sr-only">
- <tr>
- <th scope="col"></th>
- <th scope="col">Package</th>
- <th scope="col">Price</th>
- <th scope="col"></th>
- </tr>
- </thead>
- <tbody>
- <tr v-for="pkg in packages" :id="'p' + pkg.id" :key="pkg.id">
- <td class="selection">
- <input type="checkbox" @click="selectPackage"
- :value="pkg.id"
- :checked="pkg.id == package_id"
- :id="'pkg-input-' + pkg.id"
- >
- </td>
- <td class="name">
- <label :for="'pkg-input-' + pkg.id">{{ pkg.name }}</label>
- </td>
- <td class="price text-nowrap">
- {{ $root.priceLabel(pkg.cost, 1, discount) }}
- </td>
- <td class="buttons">
- <button v-if="pkg.description" type="button" class="btn btn-link btn-lg p-0" v-tooltip.click="pkg.description">
- <svg-icon icon="info-circle"></svg-icon>
- <span class="sr-only">More information</span>
- </button>
- </td>
- </tr>
- </tbody>
- </table>
- <small v-if="discount > 0" class="hint">
- <hr class="m-0">
- &sup1; applied discount: {{ discount }}% - {{ discount_description }}
- </small>
- </div>
- </div>
- <div v-if="user_id !== 'new'" id="user-skus" class="form-group row">
- <label class="col-sm-4 col-form-label">Subscriptions</label>
- <div class="col-sm-8">
- <table class="table table-sm form-list">
- <thead class="thead-light sr-only">
- <tr>
- <th scope="col"></th>
- <th scope="col">Subscription</th>
- <th scope="col">Price</th>
- <th scope="col"></th>
- </tr>
- </thead>
- <tbody>
- <tr v-for="sku in skus" :id="'s' + sku.id" :key="sku.id">
- <td class="selection">
- <input type="checkbox" @input="onInputSku"
- :value="sku.id"
- :disabled="sku.readonly"
- :checked="sku.enabled"
- :id="'sku-input-' + sku.title"
- >
- </td>
- <td class="name">
- <label :for="'sku-input-' + sku.title">{{ sku.name }}</label>
- <div v-if="sku.range" class="range-input">
- <label class="text-nowrap">{{ sku.range.min }} {{ sku.range.unit }}</label>
- <input
- type="range" class="custom-range" @input="rangeUpdate"
- :value="sku.value || sku.range.min"
- :min="sku.range.min"
- :max="sku.range.max"
- >
- </div>
- </td>
- <td class="price text-nowrap">
- {{ $root.priceLabel(sku.cost, 1, discount) }}
- </td>
- <td class="buttons">
- <button v-if="sku.description" type="button" class="btn btn-link btn-lg p-0" v-tooltip.click="sku.description">
- <svg-icon icon="info-circle"></svg-icon>
- <span class="sr-only">More information</span>
- </button>
- </td>
- </tr>
- </tbody>
- </table>
- <small v-if="discount > 0" class="hint">
- <hr class="m-0">
- &sup1; applied discount: {{ discount }}% - {{ discount_description }}
- </small>
- </div>
- </div>
- <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> Submit</button>
- </form>
+ </div>
</div>
</div>
</div>
@@ -172,7 +208,7 @@
discount: 0,
discount_description: '',
user_id: null,
- user: { aliases: [] },
+ user: { aliases: [], config: [] },
packages: [],
package_id: null,
skus: [],
@@ -280,6 +316,15 @@
this.$router.push({ name: 'users' })
})
},
+ submitSettings() {
+ this.$root.clearFormValidation($('#settings form'))
+ let post = { greylisting: $('#greylisting').prop('checked') ? 1 : 0 }
+
+ axios.post('/api/v4/users/' + this.user_id + '/config', post)
+ .then(response => {
+ this.$toast.success(response.data.message)
+ })
+ },
onInputSku(e) {
let input = e.target
let sku = this.findSku(input.value)
diff --git a/src/resources/vue/Widgets/ListInput.vue b/src/resources/vue/Widgets/ListInput.vue
--- a/src/resources/vue/Widgets/ListInput.vue
+++ b/src/resources/vue/Widgets/ListInput.vue
@@ -49,10 +49,13 @@
if (focus !== false) {
this.input.focus()
}
+
+ this.$emit('change', this.$el)
}
},
deleteItem(index) {
this.$delete(this.list, index)
+ this.$emit('change', this.$el)
if (this.list.length == 1) {
$(this.$el).removeClass('is-invalid')
diff --git a/src/routes/api.php b/src/routes/api.php
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -62,11 +62,13 @@
Route::apiResource('domains', API\V4\DomainsController::class);
Route::get('domains/{id}/confirm', 'API\V4\DomainsController@confirm');
Route::get('domains/{id}/status', 'API\V4\DomainsController@status');
+ Route::post('domains/{id}/config', 'API\V4\DomainsController@setConfig');
Route::apiResource('entitlements', API\V4\EntitlementsController::class);
Route::apiResource('packages', API\V4\PackagesController::class);
Route::apiResource('skus', API\V4\SkusController::class);
Route::apiResource('users', API\V4\UsersController::class);
+ Route::post('users/{id}/config', 'API\V4\UsersController@setConfig');
Route::get('users/{id}/skus', 'API\V4\SkusController@userSkus');
Route::get('users/{id}/status', 'API\V4\UsersController@status');
diff --git a/src/tests/Browser/Admin/DomainTest.php b/src/tests/Browser/Admin/DomainTest.php
--- a/src/tests/Browser/Admin/DomainTest.php
+++ b/src/tests/Browser/Admin/DomainTest.php
@@ -28,6 +28,9 @@
*/
public function tearDown(): void
{
+ $domain = $this->getTestDomain('kolab.org');
+ $domain->setSetting('spf_whitelist', null);
+
parent::tearDown();
}
@@ -54,6 +57,8 @@
$john = $this->getTestUser('john@kolab.org');
$user_page = new UserPage($john->id);
+ $domain->setSetting('spf_whitelist', null);
+
// Goto the domain page
$browser->visit(new Home())
->submitLogon('jeroen@jeroen.jeroen', 'jeroen', true)
@@ -76,7 +81,7 @@
// Some tabs are loaded in background, wait a second
$browser->pause(500)
- ->assertElementsCount('@nav a', 1);
+ ->assertElementsCount('@nav a', 2);
// Assert Configuration tab
$browser->assertSeeIn('@nav #tab-config', 'Configuration')
@@ -84,6 +89,25 @@
$browser->assertSeeIn('pre#dns-verify', 'kolab-verify.kolab.org.')
->assertSeeIn('pre#dns-config', 'kolab.org.');
});
+
+ // Assert Settings tab
+ $browser->assertSeeIn('@nav #tab-settings', 'Settings')
+ ->click('@nav #tab-settings')
+ ->with('@domain-settings form', function (Browser $browser) {
+ $browser->assertElementsCount('.row', 1)
+ ->assertSeeIn('.row:first-child label', 'SPF Whitelist')
+ ->assertSeeIn('.row:first-child .form-control-plaintext', 'none');
+ });
+
+ // Assert non-empty SPF whitelist
+ $domain->setSetting('spf_whitelist', json_encode(['.test1.com', '.test2.com']));
+
+ $browser->refresh()
+ ->waitFor('@nav #tab-settings')
+ ->click('@nav #tab-settings')
+ ->with('@domain-settings form', function (Browser $browser) {
+ $browser->assertSeeIn('.row:first-child .form-control-plaintext', '.test1.com, .test2.com');
+ });
});
}
diff --git a/src/tests/Browser/Admin/UserTest.php b/src/tests/Browser/Admin/UserTest.php
--- a/src/tests/Browser/Admin/UserTest.php
+++ b/src/tests/Browser/Admin/UserTest.php
@@ -107,7 +107,7 @@
// Some tabs are loaded in background, wait a second
$browser->pause(500)
- ->assertElementsCount('@nav a', 5);
+ ->assertElementsCount('@nav a', 6);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
@@ -151,6 +151,15 @@
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no users in this account.');
});
+
+ // Assert Settings tab
+ $browser->assertSeeIn('@nav #tab-settings', 'Settings')
+ ->click('@nav #tab-settings')
+ ->whenAvailable('@user-settings form', function (Browser $browser) {
+ $browser->assertElementsCount('.row', 1)
+ ->assertSeeIn('.row:first-child label', 'Greylisting')
+ ->assertSeeIn('.row:first-child .text-success', 'enabled');
+ });
});
}
@@ -169,6 +178,7 @@
$wallet->discount()->associate($discount);
$wallet->debit(2010);
$wallet->save();
+ $john->setSetting('greylisting', null);
// Click the managed-by link on Jack's page
$browser->click('@user-info #manager a')
@@ -203,7 +213,7 @@
// Some tabs are loaded in background, wait a second
$browser->pause(500)
- ->assertElementsCount('@nav a', 5);
+ ->assertElementsCount('@nav a', 6);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
@@ -263,6 +273,7 @@
$this->browse(function (Browser $browser) {
$ned = $this->getTestUser('ned@kolab.org');
$page = new UserPage($ned->id);
+ $ned->setSetting('greylisting', 'false');
$browser->click('@user-users tbody tr:nth-child(4) td:first-child a')
->on($page);
@@ -276,7 +287,7 @@
// Some tabs are loaded in background, wait a second
$browser->pause(500)
- ->assertElementsCount('@nav a', 5);
+ ->assertElementsCount('@nav a', 6);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
@@ -324,6 +335,15 @@
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no users in this account.');
});
+
+ // Assert Settings tab
+ $browser->assertSeeIn('@nav #tab-settings', 'Settings')
+ ->click('@nav #tab-settings')
+ ->whenAvailable('@user-settings form', function (Browser $browser) {
+ $browser->assertElementsCount('.row', 1)
+ ->assertSeeIn('.row:first-child label', 'Greylisting')
+ ->assertSeeIn('.row:first-child .text-danger', 'disabled');
+ });
});
}
diff --git a/src/tests/Browser/Components/ListInput.php b/src/tests/Browser/Components/ListInput.php
--- a/src/tests/Browser/Components/ListInput.php
+++ b/src/tests/Browser/Components/ListInput.php
@@ -84,7 +84,7 @@
public function removeListEntry($browser, int $num)
{
$selector = '.input-group:nth-child(' . ($num + 1) . ') a.btn';
- $browser->click($selector)->assertMissing($selector);
+ $browser->click($selector);
}
/**
diff --git a/src/tests/Browser/DomainTest.php b/src/tests/Browser/DomainTest.php
--- a/src/tests/Browser/DomainTest.php
+++ b/src/tests/Browser/DomainTest.php
@@ -5,6 +5,7 @@
use App\Domain;
use App\User;
use Tests\Browser;
+use Tests\Browser\Components\ListInput;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\DomainInfo;
@@ -59,6 +60,8 @@
$domain->save();
}
+ $domain->setSetting('spf_whitelist', \json_encode(['.test.com']));
+
$browser->visit('/domain/' . $domain->id)
->on(new DomainInfo())
->whenAvailable('@verify', function ($browser) use ($domain) {
@@ -82,6 +85,47 @@
});
}
+ /**
+ * Test domain settings
+ */
+ public function testDomainSettings(): void
+ {
+ $this->browse(function ($browser) {
+ $domain = Domain::where('namespace', 'kolab.org')->first();
+ $domain->setSetting('spf_whitelist', \json_encode(['.test.com']));
+
+ $browser->visit('/domain/' . $domain->id)
+ ->on(new DomainInfo())
+ ->assertElementsCount('@nav a', 2)
+ ->assertSeeIn('@nav #tab-general', 'Domain configuration')
+ ->assertSeeIn('@nav #tab-settings', 'Settings')
+ ->click('@nav #tab-settings')
+ ->with('#settings form', function (Browser $browser) {
+ // Test whitelist widget
+ $widget = new ListInput('#spf_whitelist');
+
+ $browser->assertSeeIn('div.row:nth-child(1) label', 'SPF Whitelist')
+ ->assertVisible('div.row:nth-child(1) .list-input')
+ ->with($widget, function (Browser $browser) {
+ $browser->assertListInputValue(['.test.com'])
+ ->assertValue('@input', '')
+ ->addListEntry('invalid domain');
+ })
+ ->click('button[type=submit]')
+ ->assertToast(Toast::TYPE_ERROR, 'Form validation error')
+ ->with($widget, function (Browser $browser) {
+ $err = 'The entry format is invalid. Expected a domain name starting with a dot.';
+ $browser->assertFormError(2, $err, false)
+ ->removeListEntry(2)
+ ->removeListEntry(1)
+ ->addListEntry('.new.domain.tld');
+ })
+ ->click('button[type=submit]')
+ ->assertToast(Toast::TYPE_SUCCESS, 'Domain settings updated successfully.');
+ });
+ });
+ }
+
/**
* Test domains list page (unauthenticated)
*/
diff --git a/src/tests/Browser/Pages/Admin/Domain.php b/src/tests/Browser/Pages/Admin/Domain.php
--- a/src/tests/Browser/Pages/Admin/Domain.php
+++ b/src/tests/Browser/Pages/Admin/Domain.php
@@ -53,6 +53,7 @@
'@domain-info' => '#domain-info',
'@nav' => 'ul.nav-tabs',
'@domain-config' => '#domain-config',
+ '@domain-settings' => '#domain-settings',
];
}
}
diff --git a/src/tests/Browser/Pages/Admin/User.php b/src/tests/Browser/Pages/Admin/User.php
--- a/src/tests/Browser/Pages/Admin/User.php
+++ b/src/tests/Browser/Pages/Admin/User.php
@@ -58,6 +58,7 @@
'@user-subscriptions' => '#user-subscriptions',
'@user-domains' => '#user-domains',
'@user-users' => '#user-users',
+ '@user-settings' => '#user-settings',
];
}
}
diff --git a/src/tests/Browser/Pages/DomainInfo.php b/src/tests/Browser/Pages/DomainInfo.php
--- a/src/tests/Browser/Pages/DomainInfo.php
+++ b/src/tests/Browser/Pages/DomainInfo.php
@@ -38,8 +38,10 @@
return [
'@app' => '#app',
'@config' => '#domain-config',
- '@verify' => '#domain-verify',
+ '@nav' => 'ul.nav-tabs',
+ '@settings' => '#settings',
'@status' => '#status-box',
+ '@verify' => '#domain-verify',
];
}
}
diff --git a/src/tests/Browser/Pages/UserInfo.php b/src/tests/Browser/Pages/UserInfo.php
--- a/src/tests/Browser/Pages/UserInfo.php
+++ b/src/tests/Browser/Pages/UserInfo.php
@@ -39,7 +39,9 @@
return [
'@app' => '#app',
'@form' => '#user-info form',
+ '@nav' => 'ul.nav-tabs',
'@packages' => '#user-packages',
+ '@settings' => '#settings',
'@skus' => '#user-skus',
'@status' => '#status-box',
];
diff --git a/src/tests/Browser/UsersTest.php b/src/tests/Browser/UsersTest.php
--- a/src/tests/Browser/UsersTest.php
+++ b/src/tests/Browser/UsersTest.php
@@ -312,6 +312,33 @@
});
}
+ /**
+ * Test user settings tab
+ *
+ * @depends testInfo
+ */
+ public function testUserSettings(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $john->setSetting('greylisting', null);
+
+ $this->browse(function (Browser $browser) {
+ $browser->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('greylisting'));
+ }
+
/**
* Test user adding page
*
diff --git a/src/tests/Feature/Controller/DomainsTest.php b/src/tests/Feature/Controller/DomainsTest.php
--- a/src/tests/Feature/Controller/DomainsTest.php
+++ b/src/tests/Feature/Controller/DomainsTest.php
@@ -28,6 +28,9 @@
$this->deleteTestUser('test1@domainscontroller.com');
$this->deleteTestDomain('domainscontroller.com');
+ $domain = $this->getTestDomain('kolab.org');
+ $domain->settings()->whereIn('key', ['spf_whitelist'])->delete();
+
parent::tearDown();
}
@@ -124,6 +127,81 @@
$this->assertSame('kolab.org', $json[0]['namespace']);
}
+ /**
+ * Test domain config update (POST /api/v4/domains/<domain>/config)
+ */
+ public function testSetConfig(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $domain = $this->getTestDomain('kolab.org');
+ $domain->setSetting('spf_whitelist', null);
+
+ // Test unknown domain id
+ $post = ['spf_whitelist' => []];
+ $response = $this->actingAs($john)->post("/api/v4/domains/123/config", $post);
+ $json = $response->json();
+
+ $response->assertStatus(404);
+
+ // Test access by user not being a wallet controller
+ $post = ['spf_whitelist' => []];
+ $response = $this->actingAs($jack)->post("/api/v4/domains/{$domain->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/domains/{$domain->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($domain->fresh()->getSetting('spf_whitelist'));
+
+ // Test some valid data
+ $post = ['spf_whitelist' => ['.test.domain.com']];
+ $response = $this->actingAs($john)->post("/api/v4/domains/{$domain->id}/config", $post);
+
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('success', $json['status']);
+ $this->assertSame('Domain settings updated successfully.', $json['message']);
+
+ $expected = \json_encode($post['spf_whitelist']);
+ $this->assertSame($expected, $domain->fresh()->getSetting('spf_whitelist'));
+
+ // Test input validation
+ $post = ['spf_whitelist' => ['aaa']];
+ $response = $this->actingAs($john)->post("/api/v4/domains/{$domain->id}/config", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(1, $json['errors']);
+ $this->assertSame(
+ 'The entry format is invalid. Expected a domain name starting with a dot.',
+ $json['errors']['spf_whitelist'][0]
+ );
+
+ $this->assertSame($expected, $domain->fresh()->getSetting('spf_whitelist'));
+ }
+
/**
* Test fetching domain info
*/
@@ -155,8 +233,9 @@
$this->assertSame($domain->hash(Domain::HASH_TEXT), $json['hash_text']);
$this->assertSame($domain->hash(Domain::HASH_CNAME), $json['hash_cname']);
$this->assertSame($domain->hash(Domain::HASH_CODE), $json['hash_code']);
- $this->assertCount(4, $json['config']);
- $this->assertTrue(strpos(implode("\n", $json['config']), $domain->namespace) !== false);
+ $this->assertSame([], $json['config']['spf_whitelist']);
+ $this->assertCount(4, $json['mx']);
+ $this->assertTrue(strpos(implode("\n", $json['mx']), $domain->namespace) !== false);
$this->assertCount(8, $json['dns']);
$this->assertTrue(strpos(implode("\n", $json['dns']), $domain->namespace) !== false);
$this->assertTrue(strpos(implode("\n", $json['dns']), $domain->hash()) !== false);
diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php
--- a/src/tests/Feature/Controller/UsersTest.php
+++ b/src/tests/Feature/Controller/UsersTest.php
@@ -62,6 +62,7 @@
$wallet->discount()->dissociate();
$wallet->settings()->whereIn('key', ['mollie_id', 'stripe_id'])->delete();
$wallet->save();
+ $user->settings()->whereIn('key', ['greylisting'])->delete();
$user->status |= User::STATUS_IMAP_READY;
$user->save();
@@ -240,6 +241,7 @@
$this->assertTrue(is_array($json['statusInfo']));
$this->assertTrue(is_array($json['settings']));
$this->assertTrue(is_array($json['aliases']));
+ $this->assertTrue($json['config']['greylisting']);
$this->assertSame([], $json['skus']);
// Values below are tested by Unit tests
$this->assertArrayHasKey('isDeleted', $json);
@@ -457,6 +459,75 @@
$this->assertSame(['meet'], $result['betaSKUs']);
}
+ /**
+ * 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('greylisting', null);
+
+ // Test unknown user id
+ $post = ['greylisting' => 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 = ['greylisting' => 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('greylisting'));
+
+ // Test some valid data
+ $post = ['greylisting' => 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('greylisting'));
+
+ // Test some valid data
+ $post = ['greylisting' => 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('greylisting'));
+ }
+
/**
* Test user creation (POST /api/v4/users)
*/

File Metadata

Mime Type
text/plain
Expires
Fri, Apr 3, 10:14 PM (16 h, 42 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18821165
Default Alt Text
D1870.1775254494.diff (76 KB)

Event Timeline