diff --git a/src/app/Domain.php b/src/app/Domain.php --- a/src/app/Domain.php +++ b/src/app/Domain.php @@ -114,6 +114,17 @@ return $this->morphOne('App\Entitlement', 'entitleable'); } + /** + * Entitlements for this domain. + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function entitlements() + { + return $this->hasMany('App\Entitlement', 'entitleable_id', 'id') + ->where('entitleable_type', Domain::class); + } + /** * Return list of public+active domain names (for current tenant) */ diff --git a/src/app/Entitlement.php b/src/app/Entitlement.php --- a/src/app/Entitlement.php +++ b/src/app/Entitlement.php @@ -118,6 +118,33 @@ return $this->entitleable->email; } + /** + * Simplified Entitlement/SKU information for a specified entitleable object + * + * @param object $object Entitleable object + * + * @return array Skus list with some metadata + */ + public static function objectEntitlementsSummary($object): array + { + $skus = []; + + // TODO: I agree this format may need to be extended in future + + foreach ($object->entitlements as $ent) { + $sku = $ent->sku; + + if (!isset($skus[$sku->id])) { + $skus[$sku->id] = ['costs' => [], 'count' => 0]; + } + + $skus[$sku->id]['count']++; + $skus[$sku->id]['costs'][] = $ent->cost; + } + + return $skus; + } + /** * The SKU concerned. * diff --git a/src/app/Handlers/Base.php b/src/app/Handlers/Base.php --- a/src/app/Handlers/Base.php +++ b/src/app/Handlers/Base.php @@ -16,18 +16,18 @@ /** * Check if the SKU is available to the user. An SKU is available - * to the user when either it is active or there's already an + * to the user/domain when either it is active or there's already an * active entitlement. * - * @param \App\Sku $sku The SKU object - * @param \App\User $user The user object + * @param \App\Sku $sku The SKU object + * @param \App\User|\App\Domain $object The user or domain object * * @return bool */ - public static function isAvailable(\App\Sku $sku, \App\User $user): bool + public static function isAvailable(\App\Sku $sku, $object): bool { if (!$sku->active) { - if (!$user->entitlements()->where('sku_id', $sku->id)->first()) { + if (!$object->entitlements()->where('sku_id', $sku->id)->first()) { return false; } } diff --git a/src/app/Handlers/Beta/Base.php b/src/app/Handlers/Beta/Base.php --- a/src/app/Handlers/Beta/Base.php +++ b/src/app/Handlers/Beta/Base.php @@ -5,23 +5,27 @@ class Base extends \App\Handlers\Base { /** - * Check if the SKU is available to the user. + * Check if the SKU is available to the user/domain. * - * @param \App\Sku $sku The SKU object - * @param \App\User $user The user object + * @param \App\Sku $sku The SKU object + * @param \App\User|\App\Domain $object The user or domain object * * @return bool */ - public static function isAvailable(\App\Sku $sku, \App\User $user): bool + public static function isAvailable(\App\Sku $sku, $object): bool { // These SKUs must be: // 1) already assigned or // 2) active and a 'beta' entitlement must exist. + if (!$object instanceof \App\User) { + return false; + } + if ($sku->active) { - return $user->hasSku('beta'); + return $object->hasSku('beta'); } else { - if ($user->entitlements()->where('sku_id', $sku->id)->first()) { + if ($object->entitlements()->where('sku_id', $sku->id)->first()) { return true; } } diff --git a/src/app/Handlers/Distlist.php b/src/app/Handlers/Distlist.php --- a/src/app/Handlers/Distlist.php +++ b/src/app/Handlers/Distlist.php @@ -15,21 +15,21 @@ } /** - * Check if the SKU is available to the user. + * Check if the SKU is available to the user/domain. * - * @param \App\Sku $sku The SKU object - * @param \App\User $user The user object + * @param \App\Sku $sku The SKU object + * @param \App\User|\App\Domain $object The user or domain object * * @return bool */ - public static function isAvailable(\App\Sku $sku, \App\User $user): bool + public static function isAvailable(\App\Sku $sku, $object): bool { // This SKU must be: // - already assigned, or active and a 'beta' entitlement must exist // - and this is a group account owner (custom domain) - if (parent::isAvailable($sku, $user)) { - return $user->wallet()->entitlements() + if (parent::isAvailable($sku, $object)) { + return $object->wallet()->entitlements() ->where('entitleable_type', \App\Domain::class)->count() > 0; } diff --git a/src/app/Http/Controllers/API/V4/Admin/DomainsController.php b/src/app/Http/Controllers/API/V4/Admin/DomainsController.php --- a/src/app/Http/Controllers/API/V4/Admin/DomainsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/DomainsController.php @@ -54,6 +54,18 @@ return response()->json($result); } + /** + * Create a domain. + * + * @param \Illuminate\Http\Request $request + * + * @return \Illuminate\Http\JsonResponse + */ + public function store(Request $request) + { + return $this->errorResponse(404); + } + /** * Suspend the domain * 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 @@ -5,8 +5,11 @@ use App\Domain; use App\Http\Controllers\Controller; use App\Backends\LDAP; +use App\Rules\UserEmailDomain; use Carbon\Carbon; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Validator; class DomainsController extends Controller { @@ -28,6 +31,10 @@ } } + usort($list, function ($a, $b) { + return strcmp($a['namespace'], $b['namespace']); + }); + return response()->json($list); } @@ -130,9 +137,8 @@ ]); } - /** - * Store a newly created resource in storage. + * Create a domain. * * @param \Illuminate\Http\Request $request * @@ -140,7 +146,61 @@ */ public function store(Request $request) { - return $this->errorResponse(404); + $current_user = $this->guard()->user(); + $owner = $current_user->wallet()->owner; + + if ($owner->id != $current_user->id) { + return $this->errorResponse(403); + } + + // Validate the input + $v = Validator::make( + $request->all(), + [ + 'namespace' => ['required', 'string', new UserEmailDomain()] + ] + ); + + if ($v->fails()) { + return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); + } + + $namespace = \strtolower(request()->input('namespace')); + + // Domain already exists + if (Domain::withTrashed()->where('namespace', $namespace)->exists()) { + $errors = ['namespace' => \trans('validation.domainnotavailable')]; + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + + if (empty($request->package) || !($package = \App\Package::withEnvTenantContext()->find($request->package))) { + $errors = ['package' => \trans('validation.packagerequired')]; + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + + if (!$package->isDomain()) { + $errors = ['package' => \trans('validation.packageinvalid')]; + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + + DB::beginTransaction(); + + // TODO: Force-delete domain if it is soft-deleted and belongs to the same user + + // Create the domain + $domain = Domain::create([ + 'namespace' => $namespace, + 'type' => \App\Domain::TYPE_EXTERNAL, + ]); + + $domain->assignPackage($package, $owner); + + DB::commit(); + + return response()->json([ + 'status' => 'success', + 'message' => __('app.domain-create-success'), + ]); } /** @@ -179,8 +239,19 @@ // Status info $response['statusInfo'] = self::statusInfo($domain); + // Entitlements info + $response['skus'] = \App\Entitlement::objectEntitlementsSummary($domain); + $response = array_merge($response, self::domainStatuses($domain)); + // Some basic information about the domain wallet + $wallet = $domain->wallet(); + $response['wallet'] = $wallet->toArray(); + if ($wallet->discount) { + $response['wallet']['discount'] = $wallet->discount->discount; + $response['wallet']['discount_description'] = $wallet->discount->description; + } + return response()->json($response); } diff --git a/src/app/Http/Controllers/API/V4/SkusController.php b/src/app/Http/Controllers/API/V4/SkusController.php --- a/src/app/Http/Controllers/API/V4/SkusController.php +++ b/src/app/Http/Controllers/API/V4/SkusController.php @@ -32,6 +32,28 @@ return $this->errorResponse(404); } + /** + * Get a list of SKUs available to the domain. + * + * @param int $id Domain identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function domainSkus($id) + { + $domain = \App\Domain::find($id); + + if (!$this->checkTenant($domain)) { + return $this->errorResponse(404); + } + + if (!$this->guard()->user()->canRead($domain)) { + return $this->errorResponse(403); + } + + return $this->objectSkus($domain); + } + /** * Show the form for editing the specified sku. * @@ -129,23 +151,35 @@ return $this->errorResponse(403); } - $type = request()->input('type'); + return $this->objectSkus($user); + } + + /** + * Return SKUs available to the specified user/domain. + * + * @param object $object User or Domain object + * + * @return \Illuminate\Http\JsonResponse + */ + protected static function objectSkus($object) + { + $type = $object instanceof \App\Domain ? 'domain' : 'user'; $response = []; // Note: Order by title for consistent ordering in tests - $skus = Sku::withObjectTenantContext($user)->orderBy('title')->get(); + $skus = Sku::withObjectTenantContext($object)->orderBy('title')->get(); foreach ($skus as $sku) { if (!class_exists($sku->handler_class)) { continue; } - if (!$sku->handler_class::isAvailable($sku, $user)) { + if (!$sku->handler_class::isAvailable($sku, $object)) { continue; } - if ($data = $this->skuElement($sku)) { - if ($type && $type != $data['type']) { + if ($data = self::skuElement($sku)) { + if ($type != $data['type']) { continue; } @@ -168,7 +202,7 @@ * * @return array|null Metadata */ - protected function skuElement($sku): ?array + protected static function skuElement($sku): ?array { if (!class_exists($sku->handler_class)) { return null; 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 @@ -138,18 +138,7 @@ $response = $this->userResponse($user); - // Simplified Entitlement/SKU information, - // TODO: I agree this format may need to be extended in future - $response['skus'] = []; - foreach ($user->entitlements as $ent) { - $sku = $ent->sku; - if (!isset($response['skus'][$sku->id])) { - $response['skus'][$sku->id] = ['costs' => [], 'count' => 0]; - } - $response['skus'][$sku->id]['count']++; - $response['skus'][$sku->id]['costs'][] = $ent->cost; - } - + $response['skus'] = \App\Entitlement::objectEntitlementsSummary($user); $response['config'] = $user->getConfig(); return response()->json($response); diff --git a/src/app/Rules/UserEmailDomain.php b/src/app/Rules/UserEmailDomain.php --- a/src/app/Rules/UserEmailDomain.php +++ b/src/app/Rules/UserEmailDomain.php @@ -24,7 +24,7 @@ /** * Determine if the validation rule passes. * - * Validation of local part of an email address that's + * Validation of a domain part of an email address that's * going to be user's login. * * @param string $attribute Attribute name @@ -34,8 +34,19 @@ */ public function passes($attribute, $domain): bool { - // don't allow @localhost and other no-fqdn - if (empty($domain) || strpos($domain, '.') === false || stripos($domain, 'www.') === 0) { + // don't allow @localhost and other non-fqdn + if ( + empty($domain) + || !is_string($domain) + || strpos($domain, '.') === false + || stripos($domain, 'www.') === 0 + ) { + $this->message = \trans('validation.domaininvalid'); + return false; + } + + // Check the max length, according to the database column length + if (strlen($domain) > 191) { $this->message = \trans('validation.domaininvalid'); return false; } diff --git a/src/app/Rules/UserEmailLocal.php b/src/app/Rules/UserEmailLocal.php --- a/src/app/Rules/UserEmailLocal.php +++ b/src/app/Rules/UserEmailLocal.php @@ -34,7 +34,11 @@ public function passes($attribute, $login): bool { // Strict validation - if (!preg_match('/^[A-Za-z0-9_.-]+$/', $login)) { + if ( + empty($login) + || !is_string($login) + || !preg_match('/^[A-Za-z0-9_.-]+$/', $login) + ) { $this->message = \trans('validation.entryinvalid', ['attribute' => $attribute]); return false; } 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 @@ -400,6 +400,22 @@ return this.$t('status.active') }, + // Append some wallet properties to the object + userWalletProps(object) { + let wallet = store.state.authInfo.accounts[0] + + if (!wallet) { + wallet = store.state.authInfo.wallets[0] + } + + if (wallet) { + object.currency = wallet.currency + if (wallet.discount) { + object.discount = wallet.discount + object.discount_description = wallet.discount_description + } + } + }, updateBodyClass(name) { // Add 'class' attribute to the body, different for each page // so, we can apply page-specific styles 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 @@ -41,6 +41,7 @@ 'distlist-suspend-success' => 'Distribution list suspended successfully.', 'distlist-unsuspend-success' => 'Distribution list unsuspended successfully.', + 'domain-create-success' => 'Domain created successfully.', 'domain-verify-success' => 'Domain verified successfully.', 'domain-verify-error' => 'Domain ownership verification failed.', 'domain-suspend-success' => 'Domain suspended successfully.', 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 @@ -79,6 +79,8 @@ 'config-intro' => "In order to let {app} receive email traffic for your domain you need to adjust the DNS settings, more precisely the MX entries, accordingly.", 'config-sample' => "Edit your domain's zone file and replace existing MX entries with the following values:", 'config-hint' => "If you don't know how to set DNS entries for your domain, please contact the registration service where you registered the domain or your web hosting provider.", + 'create' => "Create domain", + 'new' => "New domain", ], 'error' => [ 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 @@ -80,9 +80,6 @@ } .nav-tabs { - flex-wrap: nowrap; - overflow-x: auto; - .nav-link { white-space: nowrap; padding: 0.5rem 0.75rem; 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 @@ -1,23 +1,19 @@