Page MenuHomePhorge

No OneTemporary

diff --git a/src/app/Http/Controllers/API/V4/RoomsController.php b/src/app/Http/Controllers/API/V4/RoomsController.php
index f428f926..32e6e38f 100644
--- a/src/app/Http/Controllers/API/V4/RoomsController.php
+++ b/src/app/Http/Controllers/API/V4/RoomsController.php
@@ -1,314 +1,312 @@
<?php
namespace App\Http\Controllers\API\V4;
use App\Http\Controllers\RelationController;
use App\Meet\Room;
use App\Permission;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
class RoomsController extends RelationController
{
/** @var string Resource localization label */
protected $label = 'room';
/** @var string Resource model name */
protected $model = Room::class;
/** @var array Resource listing order (column names) */
protected $order = ['name'];
/** @var array Common object properties in the API response */
protected $objectProps = ['name', 'description'];
/**
* Delete a room
*
* @param string $id Room identifier
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function destroy($id)
{
$room = $this->inputRoom($id);
if (is_int($room)) {
return $this->errorResponse($room);
}
$room->delete();
return response()->json([
'status' => 'success',
'message' => self::trans("app.room-delete-success"),
]);
}
/**
* Listing of rooms that belong to the authenticated user.
*
* @return \Illuminate\Http\JsonResponse
*/
public function index()
{
$user = $this->guard()->user();
$shared = Room::whereIn('id', function ($query) use ($user) {
$query->select('permissible_id')
->from('permissions')
->where('permissible_type', Room::class)
->where('user', $user->email);
});
// Create a "private" room for the user
if (!$user->rooms()->count()) {
$room = Room::create();
$room->assignToWallet($user->wallets()->first());
}
$rooms = $user->rooms(true)->union($shared)->orderBy('name')->get()
->map(function ($room) {
return $this->objectToClient($room);
});
$result = [
'list' => $rooms,
'count' => count($rooms),
];
return response()->json($result);
}
/**
* Set the room configuration.
*
* @param int|string $id Room identifier (or name)
*
* @return \Illuminate\Http\JsonResponse|void
*/
public function setConfig($id)
{
$room = $this->inputRoom($id, Permission::ADMIN, $permission);
if (is_int($room)) {
return $this->errorResponse($room);
}
$request = request()->input();
// Room sharees can't manage room ACL
if ($permission) {
unset($request['acl']);
}
$errors = $room->setConfig($request);
if (!empty($errors)) {
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
return response()->json([
'status' => 'success',
'message' => self::trans("app.room-setconfig-success"),
]);
}
/**
* Display information of a room specified by $id.
*
* @param string $id The room to show information for.
*
* @return \Illuminate\Http\JsonResponse
*/
public function show($id)
{
$room = $this->inputRoom($id, Permission::READ, $permission);
if (is_int($room)) {
return $this->errorResponse($room);
}
$wallet = $room->wallet();
$user = $this->guard()->user();
$response = $this->objectToClient($room, true);
unset($response['session_id']);
$response['config'] = $room->getConfig();
// Room sharees can't manage/see room ACL
if ($permission) {
unset($response['config']['acl']);
}
$response['skus'] = \App\Entitlement::objectEntitlementsSummary($room);
$response['wallet'] = $wallet->toArray();
if ($wallet->discount) {
$response['wallet']['discount'] = $wallet->discount->discount;
$response['wallet']['discount_description'] = $wallet->discount->description;
}
$isOwner = $user->canDelete($room);
$response['canUpdate'] = $isOwner || $room->permissions()->where('user', $user->email)->exists();
$response['canDelete'] = $isOwner && $user->wallet()->isController($user);
$response['canShare'] = $isOwner && $room->hasSKU('group-room');
$response['isOwner'] = $isOwner;
return response()->json($response);
}
/**
* Get a list of SKUs available to the room.
*
* @param int $id Room identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function skus($id)
{
$room = $this->inputRoom($id);
if (is_int($room)) {
return $this->errorResponse($room);
}
return SkusController::objectSkus($room);
}
/**
* Create a new room.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function store(Request $request)
{
$user = $this->guard()->user();
$wallet = $user->wallet();
if (!$wallet->isController($user)) {
return $this->errorResponse(403);
}
// Validate the input
$v = Validator::make(
$request->all(),
[
'description' => 'nullable|string|max:191'
]
);
if ($v->fails()) {
return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
}
DB::beginTransaction();
$room = Room::create([
'description' => $request->input('description'),
]);
if (!empty($request->skus)) {
SkusController::updateEntitlements($room, $request->skus, $wallet);
} else {
$room->assignToWallet($wallet);
}
DB::commit();
return response()->json([
'status' => 'success',
'message' => self::trans("app.room-create-success"),
]);
}
/**
* Update a room.
*
* @param \Illuminate\Http\Request $request The API request.
* @param string $id Room identifier
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function update(Request $request, $id)
{
$room = $this->inputRoom($id, Permission::ADMIN);
if (is_int($room)) {
return $this->errorResponse($room);
}
// Validate the input
$v = Validator::make(
request()->all(),
[
'description' => 'nullable|string|max:191'
]
);
if ($v->fails()) {
return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
}
DB::beginTransaction();
$room->description = request()->input('description');
$room->save();
- if (!empty($request->skus)) {
- SkusController::updateEntitlements($room, $request->skus);
- }
+ SkusController::updateEntitlements($room, $request->skus);
if (!$room->hasSKU('group-room')) {
$room->setSetting('acl', null);
}
DB::commit();
return response()->json([
'status' => 'success',
'message' => self::trans("app.room-update-success"),
]);
}
/**
* Get the input room object, check permissions.
*
* @param int|string $id Room identifier (or name)
* @param ?int $rights Required access rights
* @param ?\App\Permission $permission Room permission reference if the user has permissions
* to the room and is not the owner
*
* @return \App\Meet\Room|int File object or error code
*/
protected function inputRoom($id, $rights = 0, &$permission = null): int|Room
{
if (!is_numeric($id)) {
$room = Room::where('name', $id)->first();
} else {
$room = Room::find($id);
}
if (!$room) {
return 404;
}
$user = $this->guard()->user();
// Room owner (or another wallet controller)?
if ($room->wallet()->isController($user)) {
return $room;
}
if ($rights) {
$permission = $room->permissions()->where('user', $user->email)->first();
if ($permission && $permission->rights & $rights) {
return $room;
}
}
return 403;
}
}
diff --git a/src/app/Http/Controllers/API/V4/SkusController.php b/src/app/Http/Controllers/API/V4/SkusController.php
index bd02b6c7..1936f1d2 100644
--- a/src/app/Http/Controllers/API/V4/SkusController.php
+++ b/src/app/Http/Controllers/API/V4/SkusController.php
@@ -1,206 +1,208 @@
<?php
namespace App\Http\Controllers\API\V4;
use App\Http\Controllers\ResourceController;
use App\Sku;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
class SkusController extends ResourceController
{
/**
* Get a list of active SKUs.
*
* @return \Illuminate\Http\JsonResponse
*/
public function index()
{
$type = request()->input('type');
// Note: Order by title for consistent ordering in tests
$response = Sku::withSubjectTenantContext()->where('active', true)->orderBy('title')
->get()
->transform(function ($sku) {
return $this->skuElement($sku);
})
->filter(function ($sku) use ($type) {
return $sku && (!$type || $sku['type'] === $type);
})
->sortByDesc('prio')
->values();
if ($type) {
$wallet = $this->guard()->user()->wallet();
// Figure out the cost for a new object of the specified type
$response = $response->map(function ($sku) use ($wallet) {
$sku['nextCost'] = $sku['cost'];
if ($sku['cost'] && $sku['units_free']) {
$count = $wallet->entitlements()->where('sku_id', $sku['id'])->count();
if ($count < $sku['units_free']) {
$sku['nextCost'] = 0;
}
}
return $sku;
});
}
return response()->json($response->all());
}
/**
* Return SKUs available to the specified entitleable object.
*
* @param object $object Entitleable object
*
* @return \Illuminate\Http\JsonResponse
*/
public static function objectSkus($object)
{
$response = [];
// Note: Order by title for consistent ordering in tests
$skus = Sku::withObjectTenantContext($object)->orderBy('title')->get();
foreach ($skus as $sku) {
if (!class_exists($sku->handler_class)) {
continue;
}
if ($object::class != $sku->handler_class::entitleableClass()) {
continue;
}
if (!$sku->handler_class::isAvailable($sku, $object)) {
continue;
}
if ($data = self::skuElement($sku)) {
if (!empty($data['controllerOnly'])) {
$user = Auth::guard()->user();
if (!$user->wallet()->isController($user)) {
continue;
}
}
$response[] = $data;
}
}
usort($response, function ($a, $b) {
return ($b['prio'] <=> $a['prio']);
});
return response()->json($response);
}
/**
* Include SKUs/Wallet information in the object's response.
*
* @param object $object User/Domain/etc object
* @param array $response The response to put the data into
*/
public static function objectEntitlements($object, &$response = []): void
{
// Object's entitlements information
$response['skus'] = \App\Entitlement::objectEntitlementsSummary($object);
// Some basic information about the object's wallet
$wallet = $object->wallet();
$response['wallet'] = $wallet->toArray();
if ($wallet->discount) {
$response['wallet']['discount'] = $wallet->discount->discount;
$response['wallet']['discount_description'] = $wallet->discount->description;
}
}
/**
* Update object entitlements.
*
* @param object $object The object for update
* @param array $rSkus List of SKU IDs requested for the object in the form [id=>qty]
* @param ?\App\Wallet $wallet The target wallet
*/
public static function updateEntitlements($object, $rSkus, $wallet = null): void
{
if (!is_array($rSkus)) {
return;
}
- // list of skus, [id=>obj]
- $skus = Sku::withEnvTenantContext()->get()->mapWithKeys(
+ if (!\config('app.with_subscriptions')) {
+ throw new \Exception("Subscriptions disabled");
+ }
+
+ // available SKUs, [id => obj]
+ $skus = Sku::withObjectTenantContext($object)->get()->mapWithKeys(
function ($sku) {
return [$sku->id => $sku];
}
);
- // existing entitlement's SKUs
- $eSkus = [];
-
- $object->entitlements()->groupBy('sku_id')
- ->selectRaw('count(*) as total, sku_id')->each(
- function ($e) use (&$eSkus) {
- $eSkus[$e->sku_id] = $e->total;
- }
- );
+ // existing object SKUs, [id => total]
+ $eSkus = $object->entitlements()->groupBy('sku_id')->selectRaw('count(*) as total, sku_id')->get()->mapWithKeys(
+ function ($e) {
+ return [$e->sku_id => $e->total];
+ }
+ )->all();
+ // compare current and requested state and apply changes (add/remove entitlements)
foreach ($skus as $skuID => $sku) {
$e = array_key_exists($skuID, $eSkus) ? $eSkus[$skuID] : 0;
$r = array_key_exists($skuID, $rSkus) ? $rSkus[$skuID] : 0;
if (!class_exists($sku->handler_class) || !is_a($object, $sku->handler_class::entitleableClass())) {
continue;
}
if ($sku->handler_class == \App\Handlers\Mailbox::class) {
if ($r != 1) {
throw new \Exception("Invalid quantity of mailboxes");
}
}
if ($e > $r) {
// remove those entitled more than existing
$object->removeSku($sku, ($e - $r));
} elseif ($e < $r) {
// add those requested more than entitled
$object->assignSku($sku, ($r - $e), $wallet);
}
}
}
/**
* Convert SKU information to metadata used by UI to
* display the form control
*
* @param \App\Sku $sku SKU object
*
* @return array|null Metadata
*/
protected static function skuElement($sku): ?array
{
if (!class_exists($sku->handler_class)) {
\Log::warning("Missing handler {$sku->handler_class}");
return null;
}
$data = array_merge($sku->toArray(), $sku->handler_class::metadata($sku));
// ignore incomplete handlers
if (empty($data['type'])) {
\Log::warning("Incomplete handler {$sku->handler_class}");
return null;
}
// Use localized value, toArray() does not get them right
$data['name'] = $sku->name;
$data['description'] = $sku->description;
unset($data['handler_class'], $data['created_at'], $data['updated_at'], $data['fee'], $data['tenant_id']);
return $data;
}
}
diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php
index b38ec49e..d5a71dd0 100644
--- a/src/app/Http/Controllers/API/V4/UsersController.php
+++ b/src/app/Http/Controllers/API/V4/UsersController.php
@@ -1,711 +1,712 @@
<?php
namespace App\Http\Controllers\API\V4;
use App\Http\Controllers\RelationController;
use App\Domain;
use App\Plan;
use App\Rules\Password;
use App\Rules\UserEmailDomain;
use App\Rules\UserEmailLocal;
use App\Sku;
use App\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
class UsersController extends RelationController
{
/** @const array List of user setting keys available for modification in UI */
public const USER_SETTINGS = [
'billing_address',
'country',
'currency',
'external_email',
'first_name',
'last_name',
'organization',
'phone',
];
/**
* On user create it is filled with a user or group object to force-delete
* before the creation of a new user record is possible.
*
* @var \App\User|\App\Group|null
*/
protected $deleteBeforeCreate;
/** @var string Resource localization label */
protected $label = 'user';
/** @var string Resource model name */
protected $model = User::class;
/** @var array Common object properties in the API response */
protected $objectProps = ['email'];
/** @var ?\App\VerificationCode Password reset code to activate on user create/update */
protected $passCode;
/**
* Listing of users.
*
* The user-entitlements billed to the current user wallet(s)
*
* @return \Illuminate\Http\JsonResponse
*/
public function index()
{
$user = $this->guard()->user();
$search = trim(request()->input('search'));
$page = intval(request()->input('page')) ?: 1;
$pageSize = 20;
$hasMore = false;
$result = $user->users();
// Search by user email, alias or name
if (strlen($search) > 0) {
// thanks to cloning we skip some extra queries in $user->users()
$allUsers1 = clone $result;
$allUsers2 = clone $result;
$result->whereLike('email', $search)
->union(
$allUsers1->join('user_aliases', 'users.id', '=', 'user_aliases.user_id')
->whereLike('alias', $search)
)
->union(
$allUsers2->join('user_settings', 'users.id', '=', 'user_settings.user_id')
->whereLike('value', $search)
->whereIn('key', ['first_name', 'last_name'])
);
}
$result = $result->orderBy('email')
->limit($pageSize + 1)
->offset($pageSize * ($page - 1))
->get();
if (count($result) > $pageSize) {
$result->pop();
$hasMore = true;
}
// Process the result
$result = $result->map(
function ($user) {
return $this->objectToClient($user);
}
);
$result = [
'list' => $result,
'count' => count($result),
'hasMore' => $hasMore,
];
return response()->json($result);
}
/**
* Display information on the user account specified by $id.
*
* @param string $id The account to show information for.
*
* @return \Illuminate\Http\JsonResponse
*/
public function show($id)
{
$user = User::find($id);
if (!$this->checkTenant($user)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canRead($user)) {
return $this->errorResponse(403);
}
$response = $this->userResponse($user);
$response['skus'] = \App\Entitlement::objectEntitlementsSummary($user);
$response['config'] = $user->getConfig();
$response['aliases'] = $user->aliases()->pluck('alias')->all();
$code = $user->verificationcodes()->where('active', true)
->where('expires_at', '>', \Carbon\Carbon::now())
->first();
if ($code) {
$response['passwordLinkCode'] = $code->short_code . '-' . $code->code;
}
return response()->json($response);
}
/**
* User status (extended) information
*
* @param \App\User $user User object
*
* @return array Status information
*/
public static function statusInfo($user): array
{
$process = self::processStateInfo(
$user,
[
'user-new' => true,
'user-ldap-ready' => $user->isLdapReady(),
'user-imap-ready' => $user->isImapReady(),
]
);
// Check if the user is a controller of his wallet
$isController = $user->canDelete($user);
$isDegraded = $user->isDegraded();
$hasMeet = !$isDegraded && Sku::withObjectTenantContext($user)->where('title', 'room')->exists();
// Enable all features if there are no skus for domain-hosting
$hasCustomDomain = $user->wallet()->entitlements()
->where('entitleable_type', Domain::class)
->count() > 0 || !Sku::withObjectTenantContext($user)->where('title', 'domain-hosting')->exists();
// Get user's entitlements titles
$skus = $user->entitlements()->select('skus.title')
->join('skus', 'skus.id', '=', 'entitlements.sku_id')
->get()
->pluck('title')
->sort()
->unique()
->values()
->all();
$hasBeta = in_array('beta', $skus) || !Sku::withObjectTenantContext($user)->where('title', 'beta')->exists();
$plan = $isController ? $user->wallet()->plan() : null;
$result = [
'skus' => $skus,
'enableBeta' => $hasBeta,
// TODO: This will change when we enable all users to create domains
'enableDomains' => $isController && $hasCustomDomain,
// TODO: Make 'enableDistlists' working for wallet controllers that aren't account owners
'enableDistlists' => $isController && $hasCustomDomain && $hasBeta,
'enableFiles' => !$isDegraded && $hasBeta && \config('app.with_files'),
// TODO: Make 'enableFolders' working for wallet controllers that aren't account owners
'enableFolders' => $isController && $hasCustomDomain && $hasBeta,
// TODO: Make 'enableResources' working for wallet controllers that aren't account owners
'enableResources' => $isController && $hasCustomDomain && $hasBeta,
'enableRooms' => $hasMeet,
'enableSettings' => $isController,
+ 'enableSubscriptions' => $isController && \config('app.with_subscriptions'),
'enableUsers' => $isController,
'enableWallets' => $isController && \config('app.with_wallet'),
'enableWalletMandates' => $isController,
'enableWalletPayments' => $isController && (!$plan || $plan->mode != Plan::MODE_MANDATE),
'enableCompanionapps' => $hasBeta,
];
return array_merge($process, $result);
}
/**
* Create a new user record.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function store(Request $request)
{
$current_user = $this->guard()->user();
$owner = $current_user->walletOwner();
if ($owner->id != $current_user->id) {
return $this->errorResponse(403);
}
$this->deleteBeforeCreate = null;
if ($error_response = $this->validateUserRequest($request, null, $settings)) {
return $error_response;
}
if (empty($request->package) || !($package = \App\Package::withEnvTenantContext()->find($request->package))) {
$errors = ['package' => self::trans('validation.packagerequired')];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
if ($package->isDomain()) {
$errors = ['package' => self::trans('validation.packageinvalid')];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
DB::beginTransaction();
// @phpstan-ignore-next-line
if ($this->deleteBeforeCreate) {
$this->deleteBeforeCreate->forceDelete();
}
// Create user record
$user = User::create([
'email' => $request->email,
'password' => $request->password,
'status' => $owner->isRestricted() ? User::STATUS_RESTRICTED : 0,
]);
$this->activatePassCode($user);
$owner->assignPackage($package, $user);
if (!empty($settings)) {
$user->setSettings($settings);
}
if (!empty($request->aliases)) {
$user->setAliases($request->aliases);
}
DB::commit();
return response()->json([
'status' => 'success',
'message' => self::trans('app.user-create-success'),
]);
}
/**
* Update user data.
*
* @param \Illuminate\Http\Request $request The API request.
* @param string $id User identifier
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function update(Request $request, $id)
{
$user = User::withEnvTenantContext()->find($id);
if (empty($user)) {
return $this->errorResponse(404);
}
$current_user = $this->guard()->user();
// TODO: Decide what attributes a user can change on his own profile
if (!$current_user->canUpdate($user)) {
return $this->errorResponse(403);
}
if ($error_response = $this->validateUserRequest($request, $user, $settings)) {
return $error_response;
}
// Entitlements, only controller can do that
if ($request->skus !== null && !$current_user->canDelete($user)) {
return $this->errorResponse(422, "You have no permission to change entitlements");
}
DB::beginTransaction();
SkusController::updateEntitlements($user, $request->skus);
if (!empty($settings)) {
$user->setSettings($settings);
}
if (!empty($request->password)) {
$user->password = $request->password;
$user->save();
}
$this->activatePassCode($user);
if (isset($request->aliases)) {
$user->setAliases($request->aliases);
}
DB::commit();
$response = [
'status' => 'success',
'message' => self::trans('app.user-update-success'),
];
// For self-update refresh the statusInfo in the UI
if ($user->id == $current_user->id) {
$response['statusInfo'] = self::statusInfo($user);
}
return response()->json($response);
}
/**
* Create a response data array for specified user.
*
* @param \App\User $user User object
*
* @return array Response data
*/
public static function userResponse(User $user): array
{
$response = array_merge($user->toArray(), self::objectState($user));
$wallet = $user->wallet();
// IsLocked flag to lock the user to the Wallet page only
$response['isLocked'] = (!$user->isActive() && ($plan = $wallet->plan()) && $plan->mode == Plan::MODE_MANDATE);
// Settings
$response['settings'] = [];
foreach ($user->settings()->whereIn('key', self::USER_SETTINGS)->get() as $item) {
$response['settings'][$item->key] = $item->value;
}
// Status info
$response['statusInfo'] = self::statusInfo($user);
// Add more info to the wallet object output
$map_func = function ($wallet) use ($user) {
$result = $wallet->toArray();
if ($wallet->discount) {
$result['discount'] = $wallet->discount->discount;
$result['discount_description'] = $wallet->discount->description;
}
if ($wallet->user_id != $user->id) {
$result['user_email'] = $wallet->owner->email;
}
$provider = \App\Providers\PaymentProvider::factory($wallet);
$result['provider'] = $provider->name();
return $result;
};
// Information about wallets and accounts for access checks
$response['wallets'] = $user->wallets->map($map_func)->toArray();
$response['accounts'] = $user->accounts->map($map_func)->toArray();
$response['wallet'] = $map_func($wallet);
return $response;
}
/**
* Prepare user statuses for the UI
*
* @param \App\User $user User object
*
* @return array Statuses array
*/
protected static function objectState($user): array
{
$state = parent::objectState($user);
$state['isAccountDegraded'] = $user->isDegraded(true);
return $state;
}
/**
* Validate user input
*
* @param \Illuminate\Http\Request $request The API request.
* @param \App\User|null $user User identifier
* @param array $settings User settings (from the request)
*
* @return \Illuminate\Http\JsonResponse|null The error response on error
*/
protected function validateUserRequest(Request $request, $user, &$settings = [])
{
$rules = [
'external_email' => 'nullable|email',
'phone' => 'string|nullable|max:64|regex:/^[0-9+() -]+$/',
'first_name' => 'string|nullable|max:128',
'last_name' => 'string|nullable|max:128',
'organization' => 'string|nullable|max:512',
'billing_address' => 'string|nullable|max:1024',
'country' => 'string|nullable|alpha|size:2',
'currency' => 'string|nullable|alpha|size:3',
'aliases' => 'array|nullable',
];
$controller = ($user ?: $this->guard()->user())->walletOwner();
// Handle generated password reset code
if ($code = $request->input('passwordLinkCode')) {
// Accept <short-code>-<code> input
if (strpos($code, '-')) {
$code = explode('-', $code)[1];
}
$this->passCode = $this->guard()->user()->verificationcodes()
->where('code', $code)->where('active', false)->first();
// Generate a password for a new user with password reset link
// FIXME: Should/can we have a user with no password set?
if ($this->passCode && empty($user)) {
$request->password = $request->password_confirmation = Str::random(16);
$ignorePassword = true;
}
}
if (empty($user) || !empty($request->password) || !empty($request->password_confirmation)) {
if (empty($ignorePassword)) {
$rules['password'] = ['required', 'confirmed', new Password($controller)];
}
}
$errors = [];
// Validate input
$v = Validator::make($request->all(), $rules);
if ($v->fails()) {
$errors = $v->errors()->toArray();
}
// For new user validate email address
if (empty($user)) {
$email = $request->email;
if (empty($email)) {
$errors['email'] = self::trans('validation.required', ['attribute' => 'email']);
} elseif ($error = self::validateEmail($email, $controller, $this->deleteBeforeCreate)) {
$errors['email'] = $error;
}
}
// Validate aliases input
if (isset($request->aliases)) {
$aliases = [];
$existing_aliases = $user ? $user->aliases()->get()->pluck('alias')->toArray() : [];
foreach ($request->aliases as $idx => $alias) {
if (is_string($alias) && !empty($alias)) {
// Alias cannot be the same as the email address (new user)
if (!empty($email) && Str::lower($alias) == Str::lower($email)) {
continue;
}
// validate new aliases
if (
!in_array($alias, $existing_aliases)
&& ($error = self::validateAlias($alias, $controller))
) {
if (!isset($errors['aliases'])) {
$errors['aliases'] = [];
}
$errors['aliases'][$idx] = $error;
continue;
}
$aliases[] = $alias;
}
}
$request->aliases = $aliases;
}
if (!empty($errors)) {
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
// Update user settings
$settings = $request->only(array_keys($rules));
unset($settings['password'], $settings['aliases'], $settings['email']);
return null;
}
/**
* Execute (synchronously) specified step in a user setup process.
*
* @param \App\User $user User object
* @param string $step Step identifier (as in self::statusInfo())
*
* @return bool|null True if the execution succeeded, False if not, Null when
* the job has been sent to the worker (result unknown)
*/
public static function execProcessStep(User $user, string $step): ?bool
{
try {
if (strpos($step, 'domain-') === 0) {
return DomainsController::execProcessStep($user->domain(), $step);
}
switch ($step) {
case 'user-ldap-ready':
case 'user-imap-ready':
// Use worker to do the job, frontend might not have the IMAP admin credentials
\App\Jobs\User\CreateJob::dispatch($user->id);
return null;
}
} catch (\Exception $e) {
\Log::error($e);
}
return false;
}
/**
* Email address validation for use as a user mailbox (login).
*
* @param string $email Email address
* @param \App\User $user The account owner
* @param null|\App\User|\App\Group $deleted Filled with an instance of a deleted user or group
* with the specified email address, if exists
*
* @return ?string Error message on validation error
*/
public static function validateEmail(string $email, \App\User $user, &$deleted = null): ?string
{
$deleted = null;
if (strpos($email, '@') === false) {
return self::trans('validation.entryinvalid', ['attribute' => 'email']);
}
list($login, $domain) = explode('@', Str::lower($email));
if (strlen($login) === 0 || strlen($domain) === 0) {
return self::trans('validation.entryinvalid', ['attribute' => 'email']);
}
// Check if domain exists
$domain = Domain::withObjectTenantContext($user)->where('namespace', $domain)->first();
if (empty($domain)) {
return self::trans('validation.domaininvalid');
}
// Validate login part alone
$v = Validator::make(
['email' => $login],
['email' => ['required', new UserEmailLocal(!$domain->isPublic())]]
);
if ($v->fails()) {
return $v->errors()->toArray()['email'][0];
}
// Check if it is one of domains available to the user
if (!$domain->isPublic() && $user->id != $domain->walletOwner()->id) {
return self::trans('validation.entryexists', ['attribute' => 'domain']);
}
// Check if a user/group/resource/shared folder with specified address already exists
if (
($existing = User::emailExists($email, true))
|| ($existing = \App\Group::emailExists($email, true))
|| ($existing = \App\Resource::emailExists($email, true))
|| ($existing = \App\SharedFolder::emailExists($email, true))
) {
// If this is a deleted user/group/resource/folder in the same custom domain
// we'll force delete it before creating the target user
if (!$domain->isPublic() && $existing->trashed()) {
$deleted = $existing;
} else {
return self::trans('validation.entryexists', ['attribute' => 'email']);
}
}
// Check if an alias with specified address already exists.
if (User::aliasExists($email) || \App\SharedFolder::aliasExists($email)) {
return self::trans('validation.entryexists', ['attribute' => 'email']);
}
return null;
}
/**
* Email address validation for use as an alias.
*
* @param string $email Email address
* @param \App\User $user The account owner
*
* @return ?string Error message on validation error
*/
public static function validateAlias(string $email, \App\User $user): ?string
{
if (strpos($email, '@') === false) {
return self::trans('validation.entryinvalid', ['attribute' => 'alias']);
}
list($login, $domain) = explode('@', Str::lower($email));
if (strlen($login) === 0 || strlen($domain) === 0) {
return self::trans('validation.entryinvalid', ['attribute' => 'alias']);
}
// Check if domain exists
$domain = Domain::withObjectTenantContext($user)->where('namespace', $domain)->first();
if (empty($domain)) {
return self::trans('validation.domaininvalid');
}
// Validate login part alone
$v = Validator::make(
['alias' => $login],
['alias' => ['required', new UserEmailLocal(!$domain->isPublic())]]
);
if ($v->fails()) {
return $v->errors()->toArray()['alias'][0];
}
// Check if it is one of domains available to the user
if (!$domain->isPublic() && $user->id != $domain->walletOwner()->id) {
return self::trans('validation.entryexists', ['attribute' => 'domain']);
}
// Check if a user with specified address already exists
if ($existing_user = User::emailExists($email, true)) {
// Allow an alias in a custom domain to an address that was a user before
if ($domain->isPublic() || !$existing_user->trashed()) {
return self::trans('validation.entryexists', ['attribute' => 'alias']);
}
}
// Check if a group/resource/shared folder with specified address already exists
if (
\App\Group::emailExists($email)
|| \App\Resource::emailExists($email)
|| \App\SharedFolder::emailExists($email)
) {
return self::trans('validation.entryexists', ['attribute' => 'alias']);
}
// Check if an alias with specified address already exists
if (User::aliasExists($email) || \App\SharedFolder::aliasExists($email)) {
// Allow assigning the same alias to a user in the same group account,
// but only for non-public domains
if ($domain->isPublic()) {
return self::trans('validation.entryexists', ['attribute' => 'alias']);
}
}
return null;
}
/**
* Activate password reset code (if set), and assign it to a user.
*
* @param \App\User $user The user
*/
protected function activatePassCode(User $user): void
{
// Activate the password reset code
if ($this->passCode) {
$this->passCode->user_id = $user->id;
$this->passCode->active = true;
$this->passCode->save();
}
}
}
diff --git a/src/config/app.php b/src/config/app.php
index 191ab120..7ae1aad5 100644
--- a/src/config/app.php
+++ b/src/config/app.php
@@ -1,284 +1,284 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Name
|--------------------------------------------------------------------------
|
| This value is the name of your application. This value is used when the
| framework needs to place the application's name in a notification or
| any other location as required by the application or its packages.
|
*/
'name' => env('APP_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
| Application Environment
|--------------------------------------------------------------------------
|
| This value determines the "environment" your application is currently
| running in. This may determine how you prefer to configure various
| services the application utilizes. Set this in your ".env" file.
|
*/
'env' => env('APP_ENV', 'production'),
/*
|--------------------------------------------------------------------------
| Application Debug Mode
|--------------------------------------------------------------------------
|
| When your application is in debug mode, detailed error messages with
| stack traces will be shown on every error that occurs within your
| application. If disabled, a simple generic error page is shown.
|
*/
'debug' => env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Application URL
|--------------------------------------------------------------------------
|
| This URL is used by the console to properly generate URLs when using
| the Artisan command line tool. You should set this to the root of
| your application so that it is used when running Artisan tasks.
*/
'url' => env('APP_URL', 'http://localhost'),
'passphrase' => env('APP_PASSPHRASE', null),
'public_url' => env('APP_PUBLIC_URL', env('APP_URL', 'http://localhost')),
'asset_url' => env('ASSET_URL'),
'support_url' => env('SUPPORT_URL', null),
'support_email' => env('SUPPORT_EMAIL', null),
'webmail_url' => env('WEBMAIL_URL', null),
'theme' => env('APP_THEME', 'default'),
'tenant_id' => env('APP_TENANT_ID', null),
'currency' => \strtoupper(env('APP_CURRENCY', 'CHF')),
/*
|--------------------------------------------------------------------------
| Application Domain
|--------------------------------------------------------------------------
|
| System domain used for user signup (kolab identity)
*/
'domain' => env('APP_DOMAIN', 'domain.tld'),
'website_domain' => env('APP_WEBSITE_DOMAIN', env('APP_DOMAIN', 'domain.tld')),
'services_domain' => env(
'APP_SERVICES_DOMAIN',
"services." . env('APP_WEBSITE_DOMAIN', env('APP_DOMAIN', 'domain.tld'))
),
/*
|--------------------------------------------------------------------------
| Application Timezone
|--------------------------------------------------------------------------
|
| Here you may specify the default timezone for your application, which
| will be used by the PHP date and date-time functions. We have gone
| ahead and set this to a sensible default for you out of the box.
|
*/
'timezone' => 'UTC',
/*
|--------------------------------------------------------------------------
| Application Locale Configuration
|--------------------------------------------------------------------------
|
| The application locale determines the default locale that will be used
| by the translation service provider. You are free to set this value
| to any of the locales which will be supported by the application.
|
*/
'locale' => env('APP_LOCALE', 'en'),
/*
|--------------------------------------------------------------------------
| Application Fallback Locale
|--------------------------------------------------------------------------
|
| The fallback locale determines the locale to use when the current one
| is not available. You may change the value to correspond to any of
| the language folders that are provided through your application.
|
*/
'fallback_locale' => 'en',
/*
|--------------------------------------------------------------------------
| Faker Locale
|--------------------------------------------------------------------------
|
| This locale will be used by the Faker PHP library when generating fake
| data for your database seeds. For example, this will be used to get
| localized telephone numbers, street address information and more.
|
*/
'faker_locale' => 'en_US',
/*
|--------------------------------------------------------------------------
| Encryption Key
|--------------------------------------------------------------------------
|
| This key is used by the Illuminate encrypter service and should be set
| to a random, 32 character string, otherwise these encrypted strings
| will not be safe. Please do this before deploying an application!
|
*/
'key' => env('APP_KEY'),
'cipher' => 'AES-256-CBC',
/*
|--------------------------------------------------------------------------
| Autoloaded Service Providers
|--------------------------------------------------------------------------
|
| The service providers listed here will be automatically loaded on the
| request to your application. Feel free to add your own services to
| this array to grant expanded functionality to your applications.
|
*/
'providers' => [
/*
* Laravel Framework Service Providers...
*/
Illuminate\Auth\AuthServiceProvider::class,
Illuminate\Broadcasting\BroadcastServiceProvider::class,
Illuminate\Bus\BusServiceProvider::class,
Illuminate\Cache\CacheServiceProvider::class,
Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class,
Illuminate\Cookie\CookieServiceProvider::class,
Illuminate\Database\DatabaseServiceProvider::class,
Illuminate\Encryption\EncryptionServiceProvider::class,
Illuminate\Filesystem\FilesystemServiceProvider::class,
Illuminate\Foundation\Providers\FoundationServiceProvider::class,
Illuminate\Hashing\HashServiceProvider::class,
Illuminate\Mail\MailServiceProvider::class,
Illuminate\Notifications\NotificationServiceProvider::class,
Illuminate\Pagination\PaginationServiceProvider::class,
Illuminate\Pipeline\PipelineServiceProvider::class,
Illuminate\Queue\QueueServiceProvider::class,
Illuminate\Redis\RedisServiceProvider::class,
Illuminate\Auth\Passwords\PasswordResetServiceProvider::class,
Illuminate\Session\SessionServiceProvider::class,
Illuminate\Translation\TranslationServiceProvider::class,
Illuminate\Validation\ValidationServiceProvider::class,
Illuminate\View\ViewServiceProvider::class,
/*
* Application Service Providers...
*/
App\Providers\AppServiceProvider::class,
App\Providers\AuthServiceProvider::class,
// App\Providers\BroadcastServiceProvider::class,
App\Providers\EventServiceProvider::class,
App\Providers\HorizonServiceProvider::class,
App\Providers\PassportServiceProvider::class,
App\Providers\RouteServiceProvider::class,
],
/*
|--------------------------------------------------------------------------
| Class Aliases
|--------------------------------------------------------------------------
|
| This array of class aliases will be registered when this application
| is started. However, feel free to register as many as you wish as
| the aliases are "lazy" loaded so they don't hinder performance.
|
*/
'aliases' => \Illuminate\Support\Facades\Facade::defaultAliases()->toArray(),
'headers' => [
'csp' => env('APP_HEADER_CSP', ""),
'xfo' => env('APP_HEADER_XFO', ""),
],
// Locations of knowledge base articles
'kb' => [
// An article about suspended accounts
'account_suspended' => env('KB_ACCOUNT_SUSPENDED'),
// An article about a way to delete an owned account
'account_delete' => env('KB_ACCOUNT_DELETE'),
// An article about the payment system
'payment_system' => env('KB_PAYMENT_SYSTEM'),
],
'company' => [
'name' => env('COMPANY_NAME'),
'address' => env('COMPANY_ADDRESS'),
'details' => env('COMPANY_DETAILS'),
'email' => env('COMPANY_EMAIL'),
'logo' => env('COMPANY_LOGO'),
'footer' => env('COMPANY_FOOTER', env('COMPANY_DETAILS')),
'copyright' => 'Apheleia IT AG',
],
'storage' => [
'min_qty' => (int) env('STORAGE_MIN_QTY', 5), // in GB
],
'vat' => [
'mode' => (int) env('VAT_MODE', 0),
],
'password_policy' => env('PASSWORD_POLICY') ?: 'min:6,max:255',
'payment' => [
'methods_oneoff' => env('PAYMENT_METHODS_ONEOFF', 'creditcard,paypal,banktransfer,bitcoin'),
'methods_recurring' => env('PAYMENT_METHODS_RECURRING', 'creditcard'),
],
-
'with_ldap' => (bool) env('APP_LDAP', true),
'with_imap' => (bool) env('APP_IMAP', false),
'with_admin' => (bool) env('APP_WITH_ADMIN', false),
'with_files' => (bool) env('APP_WITH_FILES', false),
'with_reseller' => (bool) env('APP_WITH_RESELLER', false),
'with_services' => (bool) env('APP_WITH_SERVICES', false),
- 'with_wallet' => (bool) env('APP_WITH_WALLET', true),
'with_signup' => (bool) env('APP_WITH_SIGNUP', true),
+ 'with_subscriptions' => (bool) env('APP_WITH_SUBSCRIPTIONS', true),
+ 'with_wallet' => (bool) env('APP_WITH_WALLET', true),
'signup' => [
'email_limit' => (int) env('SIGNUP_LIMIT_EMAIL', 0),
'ip_limit' => (int) env('SIGNUP_LIMIT_IP', 0),
],
'woat_ns1' => env('WOAT_NS1', 'ns01.' . env('APP_DOMAIN')),
'woat_ns2' => env('WOAT_NS2', 'ns02.' . env('APP_DOMAIN')),
'ratelimit_whitelist' => explode(',', env('RATELIMIT_WHITELIST', '')),
'companion_download_link' => env(
'COMPANION_DOWNLOAD_LINK',
"https://mirror.apheleia-it.ch/pub/companion-app-beta.apk"
)
];
diff --git a/src/resources/vue/Distlist/Info.vue b/src/resources/vue/Distlist/Info.vue
index 11778b74..4ede472e 100644
--- a/src/resources/vue/Distlist/Info.vue
+++ b/src/resources/vue/Distlist/Info.vue
@@ -1,147 +1,147 @@
<template>
<div class="container">
<status-component v-if="list_id !== 'new'" :status="status" @status-update="statusUpdate"></status-component>
<div class="card" id="distlist-info">
<div class="card-body">
<div class="card-title" v-if="list_id !== 'new'">
{{ $tc('distlist.list-title', 1) }}
<btn class="btn-outline-danger button-delete float-end" @click="deleteList()" icon="trash-can">{{ $t('distlist.delete') }}</btn>
</div>
<div class="card-title" v-else>{{ $t('distlist.new') }}</div>
<div class="card-text">
<tabs class="mt-3" :tabs="list_id === 'new' ? ['form.general'] : ['form.general','form.settings']"></tabs>
<div class="tab-content">
<div class="tab-pane show active" id="general" role="tabpanel" aria-labelledby="tab-general">
<form @submit.prevent="submit" class="card-body">
<div v-if="list_id !== 'new'" class="row plaintext mb-3">
<label for="status" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label>
<div class="col-sm-8">
<span :class="$root.statusClass(list) + ' form-control-plaintext'" id="status">{{ $root.statusText(list) }}</span>
</div>
</div>
<div class="row mb-3">
<label for="name" class="col-sm-4 col-form-label">{{ $t('distlist.name') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="name" required v-model="list.name">
</div>
</div>
<div class="row mb-3">
<label for="email" class="col-sm-4 col-form-label">{{ $t('form.email') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="email" :disabled="list_id !== 'new'" required v-model="list.email">
</div>
</div>
<div class="row mb-3">
<label for="members-input" class="col-sm-4 col-form-label">{{ $t('distlist.recipients') }}</label>
<div class="col-sm-8">
<list-input id="members" :list="list.members"></list-input>
</div>
</div>
- <div v-if="list_id === 'new' || list.id" id="distlist-skus" class="row mb-3">
+ <div v-if="$root.hasPermission('subscriptions') && (list_id === 'new' || list.id)" id="distlist-skus" class="row mb-3">
<label class="col-sm-4 col-form-label">{{ $t('form.subscriptions') }}</label>
<subscription-select class="col-sm-8 pt-sm-1" ref="skus" :object="list" type="group" :readonly="true"></subscription-select>
</div>
<btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
</form>
</div>
<div class="tab-pane" id="settings" role="tabpanel" aria-labelledby="tab-settings">
<form @submit.prevent="submitSettings" class="card-body">
<div class="row mb-3">
<label for="sender-policy-input" class="col-sm-4 col-form-label">{{ $t('distlist.sender-policy') }}</label>
<div class="col-sm-8 pt-2">
<list-input id="sender-policy" :list="list.config.sender_policy" class="mb-1"></list-input>
<small id="sender-policy-hint" class="text-muted">
{{ $t('distlist.sender-policy-text') }}
</small>
</div>
</div>
<btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import ListInput from '../Widgets/ListInput'
import StatusComponent from '../Widgets/Status'
import SubscriptionSelect from '../Widgets/SubscriptionSelect'
export default {
components: {
ListInput,
StatusComponent,
SubscriptionSelect
},
data() {
return {
list_id: null,
list: { members: [], config: {} },
status: {}
}
},
created() {
this.list_id = this.$route.params.list
if (this.list_id != 'new') {
axios.get('/api/v4/groups/' + this.list_id, { loader: true })
.then(response => {
this.list = response.data
this.status = response.data.statusInfo
})
.catch(this.$root.errorHandler)
}
},
mounted() {
$('#name').focus()
},
methods: {
deleteList() {
axios.delete('/api/v4/groups/' + this.list_id)
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.$router.push({ name: 'distlists' })
}
})
},
statusUpdate(list) {
this.list = Object.assign({}, this.list, list)
},
submit() {
this.$root.clearFormValidation($('#list-info form'))
let method = 'post'
let location = '/api/v4/groups'
let post = this.$root.pick(this.list, ['name', 'email', 'members'])
if (this.list_id !== 'new') {
method = 'put'
location += '/' + this.list_id
}
// post.skus = this.$refs.skus.getSkus()
axios[method](location, post)
.then(response => {
this.$toast.success(response.data.message)
this.$router.push({ name: 'distlists' })
})
},
submitSettings() {
this.$root.clearFormValidation($('#settings form'))
const post = this.$root.pick(this.list.config, [ 'sender_policy' ])
axios.post('/api/v4/groups/' + this.list_id + '/config', post)
.then(response => {
this.$toast.success(response.data.message)
})
}
}
}
</script>
diff --git a/src/resources/vue/Domain/Info.vue b/src/resources/vue/Domain/Info.vue
index fb646518..dcc27e67 100644
--- a/src/resources/vue/Domain/Info.vue
+++ b/src/resources/vue/Domain/Info.vue
@@ -1,196 +1,196 @@
<template>
<div class="container">
<status-component v-if="domain_id !== 'new'" :status="status" @status-update="statusUpdate"></status-component>
<div class="card">
<div class="card-body">
<div class="card-title" v-if="domain_id === 'new'">{{ $t('domain.new') }}</div>
<div class="card-title" v-else>{{ $t('form.domain') }}
<btn class="btn-outline-danger button-delete float-end" @click="$refs.deleteDialog.show()" icon="trash-can">{{ $t('domain.delete') }}</btn>
</div>
<div class="card-text">
<tabs class="mt-3" :tabs="domain_id === 'new' ? ['form.general'] : ['form.general','form.settings']"></tabs>
<div class="tab-content">
<div class="tab-pane show active" id="general" role="tabpanel" aria-labelledby="tab-general">
<form @submit.prevent="submit" class="card-body">
<div v-if="domain.id" class="row plaintext mb-3">
<label for="status" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label>
<div class="col-sm-8">
<span :class="$root.statusClass(domain) + ' form-control-plaintext'" id="status">{{ $root.statusText(domain) }}</span>
</div>
</div>
<div class="row mb-3">
<label for="name" class="col-sm-4 col-form-label">{{ $t('domain.namespace') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="namespace" v-model="domain.namespace" :disabled="domain.id">
</div>
</div>
<div v-if="!domain.id" id="domain-packages" class="row">
<label class="col-sm-4 col-form-label">{{ $t('user.package') }}</label>
<package-select class="col-sm-8 pt-sm-1" type="domain"></package-select>
</div>
- <div v-if="domain.id" id="domain-skus" class="row">
+ <div v-if="$root.hasPermission('subscriptions') && domain.id" id="domain-skus" class="row">
<label class="col-sm-4 col-form-label">{{ $t('form.subscriptions') }}</label>
<subscription-select v-if="domain.id" class="col-sm-8 pt-sm-1" ref="skus" type="domain" :object="domain" :readonly="true"></subscription-select>
</div>
<btn v-if="!domain.id" class="btn-primary mt-3" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
</form>
<hr class="m-0" v-if="domain.id">
<div v-if="domain.id && !domain.isConfirmed" class="card-body" id="domain-verify">
<h5 class="mb-3">{{ $t('domain.verify') }}</h5>
<div class="card-text">
<p>{{ $t('domain.verify-intro') }}</p>
<p>
<span v-html="$t('domain.verify-dns')"></span>
<ul>
<li>{{ $t('domain.verify-dns-txt') }} <code>{{ domain.hash_text }}</code></li>
<li>{{ $t('domain.verify-dns-cname') }} <code>{{ domain.hash_cname }}.{{ domain.namespace }}. IN CNAME {{ domain.hash_code }}.{{ domain.namespace }}.</code></li>
</ul>
<span>{{ $t('domain.verify-outro') }}</span>
</p>
<p>{{ $t('domain.verify-sample') }} <pre>{{ domain.dns.join("\n") }}</pre></p>
<btn class="btn-primary" @click="confirm" icon="rotate">{{ $t('btn.verify') }}</btn>
</div>
</div>
<div v-if="domain.isConfirmed" class="card-body" id="domain-config">
<h5 class="mb-3">{{ $t('domain.config') }}</h5>
<div class="card-text">
<p>{{ $t('domain.config-intro', { app: $root.appName }) }}</p>
<p>{{ $t('domain.config-sample') }} <pre>{{ domain.mx.join("\n") }}</pre></p>
<p>{{ $t('domain.config-hint') }}</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="row mb-3">
<label for="spf_whitelist" class="col-sm-4 col-form-label">{{ $t('domain.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="text-muted d-block mt-2">
{{ $t('domain.spf-whitelist-text') }}
<span class="d-block" v-html="$t('domain.spf-whitelist-ex')"></span>
</small>
</div>
</div>
<btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
<modal-dialog id="delete-warning" ref="deleteDialog" @click="deleteDomain()" :buttons="['delete']" :cancel-focus="true"
:title="$t('domain.delete-domain', { domain: domain.namespace })"
>
<p>{{ $t('domain.delete-text') }}</p>
</modal-dialog>
</div>
</template>
<script>
import ListInput from '../Widgets/ListInput'
import ModalDialog from '../Widgets/ModalDialog'
import PackageSelect from '../Widgets/PackageSelect'
import StatusComponent from '../Widgets/Status'
import SubscriptionSelect from '../Widgets/SubscriptionSelect'
import { library } from '@fortawesome/fontawesome-svg-core'
library.add(
require('@fortawesome/free-solid-svg-icons/faRotate').definition,
)
export default {
components: {
ListInput,
ModalDialog,
PackageSelect,
StatusComponent,
SubscriptionSelect
},
data() {
return {
domain_id: null,
domain: {},
spf_whitelist: [],
status: {}
}
},
created() {
this.domain_id = this.$route.params.domain
if (this.domain_id !== 'new') {
axios.get('/api/v4/domains/' + this.domain_id, { loader: true })
.then(response => {
this.domain = response.data
this.spf_whitelist = this.domain.config.spf_whitelist || []
if (!this.domain.isConfirmed) {
$('#domain-verify button').focus()
}
this.status = response.data.statusInfo
})
.catch(this.$root.errorHandler)
}
},
mounted() {
$('#namespace').focus()
},
methods: {
confirm() {
axios.get('/api/v4/domains/' + this.domain_id + '/confirm')
.then(response => {
if (response.data.status == 'success') {
this.domain.isConfirmed = true
this.status = response.data.statusInfo
}
if (response.data.message) {
this.$toast[response.data.status](response.data.message)
}
})
},
deleteDomain() {
// Delete the domain from the confirm dialog
axios.delete('/api/v4/domains/' + this.domain_id)
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.$router.push({ name: 'domains' })
}
})
},
statusUpdate(domain) {
this.domain = Object.assign({}, this.domain, domain)
},
submit() {
this.$root.clearFormValidation($('#general form'))
let post = this.$root.pick(this.domain, ['namespace'])
post.package = $('#domain-packages input:checked').val()
axios.post('/api/v4/domains', post)
.then(response => {
this.$toast.success(response.data.message)
this.$router.push({ name: 'domains' })
})
},
submitSettings() {
this.$root.clearFormValidation($('#settings form'))
const post = this.$root.pick(this, ['spf_whitelist'])
axios.post('/api/v4/domains/' + this.domain_id + '/config', post)
.then(response => {
this.$toast.success(response.data.message)
})
}
}
}
</script>
diff --git a/src/resources/vue/Resource/Info.vue b/src/resources/vue/Resource/Info.vue
index e74f01b6..f713f2af 100644
--- a/src/resources/vue/Resource/Info.vue
+++ b/src/resources/vue/Resource/Info.vue
@@ -1,178 +1,178 @@
<template>
<div class="container">
<status-component v-if="resource_id !== 'new'" :status="status" @status-update="statusUpdate"></status-component>
<div class="card" id="resource-info">
<div class="card-body">
<div class="card-title" v-if="resource_id !== 'new'">
{{ $tc('resource.list-title', 1) }}
<btn class="btn-outline-danger button-delete float-end" @click="deleteResource()" icon="trash-can">{{ $t('resource.delete') }}</btn>
</div>
<div class="card-title" v-if="resource_id === 'new'">{{ $t('resource.new') }}</div>
<div class="card-text">
<tabs class="mt-3" :tabs="resource_id === 'new' ? ['form.general'] : ['form.general','form.settings']"></tabs>
<div class="tab-content">
<div class="tab-pane show active" id="general" role="tabpanel" aria-labelledby="tab-general">
<form @submit.prevent="submit" class="card-body">
<div v-if="resource_id !== 'new'" class="row plaintext mb-3">
<label for="status" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label>
<div class="col-sm-8">
<span :class="$root.statusClass(resource) + ' form-control-plaintext'" id="status">{{ $root.statusText(resource) }}</span>
</div>
</div>
<div class="row mb-3">
<label for="name" class="col-sm-4 col-form-label">{{ $t('form.name') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="name" v-model="resource.name">
</div>
</div>
<div v-if="domains.length" class="row mb-3">
<label for="domain" class="col-sm-4 col-form-label">{{ $t('form.domain') }}</label>
<div class="col-sm-8">
<select class="form-select" v-model="resource.domain">
<option v-for="_domain in domains" :key="_domain.id" :value="_domain.namespace">{{ _domain.namespace }}</option>
</select>
</div>
</div>
<div v-if="resource.email" class="row mb-3">
<label for="email" class="col-sm-4 col-form-label">{{ $t('form.email') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="email" disabled v-model="resource.email">
</div>
</div>
- <div v-if="resource_id === 'new' || resource.id" id="resource-skus" class="row mb-3">
+ <div v-if="$root.hasPermission('subscriptions') && (resource_id === 'new' || resource.id)" id="resource-skus" class="row mb-3">
<label class="col-sm-4 col-form-label">{{ $t('form.subscriptions') }}</label>
<subscription-select class="col-sm-8 pt-sm-1" ref="skus" :object="resource" type="resource" :readonly="true"></subscription-select>
</div>
<btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
</form>
</div>
<div class="tab-pane" id="settings" role="tabpanel" aria-labelledby="tab-settings">
<form @submit.prevent="submitSettings" class="card-body">
<div class="row mb-3">
<label for="invitation_policy" class="col-sm-4 col-form-label">{{ $t('resource.invitation-policy') }}</label>
<div class="col-sm-8">
<div class="input-group input-group-select mb-1">
<select class="form-select" id="invitation_policy" v-model="resource.config.invitation_policy" @change="policyChange">
<option value="accept">{{ $t('resource.ipolicy-accept') }}</option>
<option value="manual">{{ $t('resource.ipolicy-manual') }}</option>
<option value="reject">{{ $t('resource.ipolicy-reject') }}</option>
</select>
<input type="text" class="form-control" id="owner" v-model="resource.config.owner" :placeholder="$t('form.email')">
</div>
<small id="invitation-policy-hint" class="text-muted">
{{ $t('resource.invitation-policy-text') }}
</small>
</div>
</div>
<btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import StatusComponent from '../Widgets/Status'
import SubscriptionSelect from '../Widgets/SubscriptionSelect'
export default {
components: {
StatusComponent,
SubscriptionSelect
},
data() {
return {
domains: [],
resource_id: null,
resource: { config: {} },
status: {}
}
},
created() {
this.resource_id = this.$route.params.resource
if (this.resource_id != 'new') {
axios.get('/api/v4/resources/' + this.resource_id, { loader: true })
.then(response => {
this.resource = response.data
this.status = response.data.statusInfo
if (this.resource.config.invitation_policy.match(/^manual:(.+)$/)) {
this.resource.config.owner = RegExp.$1
this.resource.config.invitation_policy = 'manual'
}
this.$nextTick().then(() => { this.policyChange() })
})
.catch(this.$root.errorHandler)
} else {
axios.get('/api/v4/domains', { loader: true })
.then(response => {
this.domains = response.data.list
this.resource.domain = this.domains[0].namespace
})
.catch(this.$root.errorHandler)
}
},
mounted() {
$('#name').focus()
},
methods: {
deleteResource() {
axios.delete('/api/v4/resources/' + this.resource_id)
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.$router.push({ name: 'resources' })
}
})
},
policyChange() {
let select = $('#invitation_policy')
select.parent()[select.val() == 'manual' ? 'addClass' : 'removeClass']('selected')
},
statusUpdate(resource) {
this.resource = Object.assign({}, this.resource, resource)
},
submit() {
this.$root.clearFormValidation($('#resource-info form'))
let method = 'post'
let location = '/api/v4/resources'
let post = this.$root.pick(this.resource, ['id', 'name', 'domain'])
if (this.resource_id !== 'new') {
method = 'put'
location += '/' + this.resource_id
}
// post.skus = this.$refs.skus.getSkus()
axios[method](location, post)
.then(response => {
this.$toast.success(response.data.message)
this.$router.push({ name: 'resources' })
})
},
submitSettings() {
this.$root.clearFormValidation($('#settings form'))
let post = this.$root.pick(this.resource.config, ['invitation_policy', 'owner'])
if (post.invitation_policy == 'manual') {
post.invitation_policy += ':' + post.owner
}
delete post.owner
axios.post('/api/v4/resources/' + this.resource_id + '/config', post)
.then(response => {
this.$toast.success(response.data.message)
})
}
}
}
</script>
diff --git a/src/resources/vue/Room/Info.vue b/src/resources/vue/Room/Info.vue
index 7dafc1bc..33918691 100644
--- a/src/resources/vue/Room/Info.vue
+++ b/src/resources/vue/Room/Info.vue
@@ -1,166 +1,166 @@
<template>
<div class="container">
<div id="room-info" class="card">
<div class="card-body">
<div class="card-title" v-if="room.id">
{{ $t('room.title', { name: room.name }) }}
<btn v-if="room.canDelete" class="btn-outline-danger button-delete float-end" @click="roomDelete" icon="trash-can">{{ $t('room.delete') }}</btn>
</div>
<div class="card-title" v-else>{{ $t('room.new') }}</div>
<div class="card-text">
<div id="room-intro" class="pt-2">
<p v-if="room.id">{{ $t('room.url') }}</p>
<p v-if="room.id" class="text-center"><router-link :to="roomRoute">{{ href }}</router-link></p>
<p v-if="!room.id">{{ $t('room.new-hint') }}</p>
</div>
<tabs class="mt-3" :tabs="tabs"></tabs>
<div class="tab-content">
<div v-if="!room.id || room.isOwner" class="tab-pane show active" id="general" role="tabpanel" aria-labelledby="tab-general">
<form @submit.prevent="submit" class="card-body">
<div class="row mb-3">
<label for="description" class="col-sm-4 col-form-label">{{ $t('form.description') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="description" v-model="room.description">
<small class="form-text">{{ $t('room.description-hint') }}</small>
</div>
</div>
- <div v-if="room_id === 'new' || room.isOwner" id="room-skus" class="row mb-3">
+ <div v-if="$root.hasPermission('subscriptions') && (room_id === 'new' || room.isOwner)" id="room-skus" class="row mb-3">
<label class="col-sm-4 col-form-label">{{ $t('form.subscriptions') }}</label>
<subscription-select class="col-sm-8 pt-sm-1" ref="skus" :object="room" type="room"></subscription-select>
</div>
<btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
</form>
</div>
<div v-if="room.canUpdate" :class="'tab-pane' + (!tabs.includes('form.general') ? ' show active' : '')" id="settings" role="tabpanel" aria-labelledby="tab-settings">
<form @submit.prevent="submitSettings" class="card-body">
<div class="row mb-3">
<label for="password" class="col-sm-4 col-form-label">{{ $t('form.password') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" v-model="room.config.password">
<span class="form-text">{{ $t('meet.password-text') }}</span>
</div>
</div>
<div class="row mb-3">
<label for="room-lock-input" class="col-sm-4 col-form-label">{{ $t('meet.lock') }}</label>
<div class="col-sm-8">
<input type="checkbox" id="room-lock-input" class="form-check-input d-block" v-model="room.config.locked">
<small class="form-text">{{ $t('meet.lock-text') }}</small>
</div>
</div>
<div class="row mb-3">
<label for="room-nomedia-input" class="col-sm-4 col-form-label">{{ $t('meet.nomedia') }}</label>
<div class="col-sm-8">
<input type="checkbox" id="room-nomedia-input" class="form-check-input d-block" v-model="room.config.nomedia">
<small class="form-text">{{ $t('meet.nomedia-text') }}</small>
</div>
</div>
<div v-if="room.canShare" class="row mb-3">
<label for="acl-input" class="col-sm-4 col-form-label">{{ $t('room.moderators') }}</label>
<div class="col-sm-8">
<acl-input id="acl" v-model="room.config.acl" :list="room.config.acl" :useronly="true" :types="['full']"></acl-input>
<small class="form-text">{{ $t('room.moderators-text') }}</small>
</div>
</div>
<btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import AclInput from '../Widgets/AclInput'
import SubscriptionSelect from '../Widgets/SubscriptionSelect'
export default {
components: {
AclInput,
SubscriptionSelect
},
data() {
return {
href: '',
room_id: '',
room: { config: { acl: [] } },
roomRoute: ''
}
},
computed: {
tabs() {
let tabs = []
if (!this.room.id || this.room.isOwner) {
tabs.push('form.general')
}
if (this.room.canUpdate) {
tabs.push('form.settings')
}
return tabs
},
},
created() {
this.room_id = this.$route.params.room
if (this.room_id != 'new') {
axios.get('/api/v4/rooms/' + this.room_id, { loader: true })
.then(response => {
this.room = response.data
this.roomRoute = '/meet/' + encodeURI(this.room.name)
this.href = window.config['app.url'] + this.roomRoute
})
.catch(this.$root.errorHandler)
}
},
mounted() {
$('#description').focus()
},
methods: {
roomDelete() {
axios.delete('/api/v4/rooms/' + this.room.id)
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.$router.push('/rooms')
}
})
},
submit() {
this.$root.clearFormValidation($('#general form'))
let method = 'post'
let location = '/api/v4/rooms'
let post = this.$root.pick(this.room, ['description'])
if (this.room.id) {
method = 'put'
location += '/' + this.room.id
}
if (this.$refs.skus) {
post.skus = this.$refs.skus.getSkus()
}
axios[method](location, post)
.then(response => {
this.$toast.success(response.data.message)
this.$router.push('/rooms')
})
},
submitSettings() {
this.$root.clearFormValidation($('#settings form'))
const post = this.$root.pick(this.room.config, [ 'password', 'acl', 'locked', 'nomedia' ])
axios.post('/api/v4/rooms/' + this.room.id + '/config', post)
.then(response => {
this.$toast.success(response.data.message)
})
}
}
}
</script>
diff --git a/src/resources/vue/SharedFolder/Info.vue b/src/resources/vue/SharedFolder/Info.vue
index f59f6b73..2c4f3e7b 100644
--- a/src/resources/vue/SharedFolder/Info.vue
+++ b/src/resources/vue/SharedFolder/Info.vue
@@ -1,172 +1,172 @@
<template>
<div class="container">
<status-component v-if="folder_id !== 'new'" :status="status" @status-update="statusUpdate"></status-component>
<div class="card" id="folder-info">
<div class="card-body">
<div class="card-title" v-if="folder_id !== 'new'">
{{ $tc('shf.list-title', 1) }}
<btn class="btn-outline-danger button-delete float-end" @click="deleteFolder()" icon="trash-can">{{ $t('shf.delete') }}</btn>
</div>
<div class="card-title" v-if="folder_id === 'new'">{{ $t('shf.new') }}</div>
<div class="card-text">
<tabs class="mt-3" :tabs="folder_id === 'new' ? ['form.general'] : ['form.general', 'form.settings']"></tabs>
<div class="tab-content">
<div class="tab-pane show active" id="general" role="tabpanel" aria-labelledby="tab-general">
<form @submit.prevent="submit" class="card-body">
<div v-if="folder_id !== 'new'" class="row plaintext mb-3">
<label for="status" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label>
<div class="col-sm-8">
<span :class="$root.statusClass(folder) + ' form-control-plaintext'" id="status">{{ $root.statusText(folder) }}</span>
</div>
</div>
<div class="row mb-3">
<label for="name" class="col-sm-4 col-form-label">{{ $t('form.name') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="name" v-model="folder.name">
</div>
</div>
<div class="row mb-3">
<label for="type" class="col-sm-4 col-form-label">{{ $t('form.type') }}</label>
<div class="col-sm-8">
<select id="type" class="form-select" v-model="folder.type" :disabled="folder_id !== 'new'">
<option v-for="type in types" :key="type" :value="type">{{ $t('shf.type-' + type) }}</option>
</select>
</div>
</div>
<div v-if="domains.length" class="row mb-3">
<label for="domain" class="col-sm-4 col-form-label">{{ $t('form.domain') }}</label>
<div v-if="domains.length" class="col-sm-8">
<select class="form-select" v-model="folder.domain">
<option v-for="_domain in domains" :key="_domain.id" :value="_domain.namespace">{{ _domain.namespace }}</option>
</select>
</div>
</div>
<div class="row mb-3" v-if="folder.type == 'mail'">
<label for="aliases-input" class="col-sm-4 col-form-label">{{ $t('form.emails') }}</label>
<div class="col-sm-8">
<list-input id="aliases" :list="folder.aliases"></list-input>
</div>
</div>
- <div v-if="folder_id === 'new' || folder.id" id="folder-skus" class="row mb-3">
+ <div v-if="$root.hasPermission('subscriptions') && (folder_id === 'new' || folder.id)" id="folder-skus" class="row mb-3">
<label class="col-sm-4 col-form-label">{{ $t('form.subscriptions') }}</label>
<subscription-select class="col-sm-8 pt-sm-1" ref="skus" :object="folder" type="shared-folder" :readonly="true"></subscription-select>
</div>
<btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
</form>
</div>
<div class="tab-pane" id="settings" role="tabpanel" aria-labelledby="tab-settings">
<form @submit.prevent="submitSettings" class="card-body">
<div class="row mb-3">
<label for="acl-input" class="col-sm-4 col-form-label">{{ $t('form.acl') }}</label>
<div class="col-sm-8">
<acl-input id="acl" v-model="folder.config.acl" :list="folder.config.acl" class="mb-1"></acl-input>
<small id="acl-hint" class="text-muted">
{{ $t('shf.acl-text') }}
</small>
</div>
</div>
<btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import AclInput from '../Widgets/AclInput'
import ListInput from '../Widgets/ListInput'
import StatusComponent from '../Widgets/Status'
import SubscriptionSelect from '../Widgets/SubscriptionSelect'
export default {
components: {
AclInput,
ListInput,
StatusComponent,
SubscriptionSelect
},
data() {
return {
domains: [],
folder_id: null,
folder: { type: 'mail', config: {}, aliases: [] },
status: {},
types: [ 'mail', 'event', 'task', 'contact', 'note', 'file' ]
}
},
created() {
this.folder_id = this.$route.params.folder
if (this.folder_id != 'new') {
axios.get('/api/v4/shared-folders/' + this.folder_id, { loader: true })
.then(response => {
this.folder = response.data
this.status = response.data.statusInfo
})
.catch(this.$root.errorHandler)
} else {
axios.get('/api/v4/domains', { loader: true })
.then(response => {
this.domains = response.data.list
this.folder.domain = this.domains[0].namespace
})
.catch(this.$root.errorHandler)
}
},
mounted() {
$('#name').focus()
},
methods: {
deleteFolder() {
axios.delete('/api/v4/shared-folders/' + this.folder_id)
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.$router.push({ name: 'shared-folders' })
}
})
},
statusUpdate(folder) {
this.folder = Object.assign({}, this.folder, folder)
},
submit() {
this.$root.clearFormValidation($('#folder-info form'))
let method = 'post'
let location = '/api/v4/shared-folders'
let post = this.$root.pick(this.folder, ['id', 'name', 'domain', 'type', 'aliases'])
if (this.folder_id !== 'new') {
method = 'put'
location += '/' + this.folder_id
}
if (post.type != 'mail') {
delete post.aliases
}
// post.skus = this.$refs.skus.getSkus()
axios[method](location, post)
.then(response => {
this.$toast.success(response.data.message)
this.$router.push({ name: 'shared-folders' })
})
},
submitSettings() {
this.$root.clearFormValidation($('#settings form'))
let post = this.$root.pick(this.folder.config, ['acl'])
axios.post('/api/v4/shared-folders/' + this.folder_id + '/config', post)
.then(response => {
this.$toast.success(response.data.message)
})
}
}
}
</script>
diff --git a/src/resources/vue/User/Info.vue b/src/resources/vue/User/Info.vue
index 5a7083ce..ceae0389 100644
--- a/src/resources/vue/User/Info.vue
+++ b/src/resources/vue/User/Info.vue
@@ -1,438 +1,438 @@
<template>
<div class="container">
<status-component v-if="user_id !== 'new'" :status="status" @status-update="statusUpdate"></status-component>
<div class="card" id="user-info">
<div class="card-body">
<div class="card-title" v-if="user_id === 'new'">{{ $t('user.new') }}</div>
<div class="card-title" v-else>{{ $t($route.name == 'settings' ? 'dashboard.myaccount' : 'user.title') }}
<btn v-if="isController" icon="trash-can" class="btn-outline-danger button-delete float-end" @click="$refs.deleteWarning.show()">
{{ $t(isSelf ? 'user.profile-delete' : 'user.delete') }}
</btn>
</div>
<div class="card-text">
<tabs class="mt-3" :tabs="tabs"></tabs>
<div class="tab-content">
<div class="tab-pane active" id="general" role="tabpanel" aria-labelledby="tab-general">
<form @submit.prevent="submit" class="card-body">
<div v-if="user_id !== 'new' && isController" class="row plaintext mb-3">
<label for="status" class="col-sm-4 col-form-label">
<span>{{ $t('form.status') }}</span>
<span v-if="$route.name === 'settings'">&nbsp;({{ $t('user.custno') }})</span>
</label>
<div class="col-sm-8">
<span class="form-control-plaintext">
<span id="status" :class="$root.statusClass(user)">{{ $root.statusText(user) }}</span>
<span id="userid" v-if="$route.name === 'settings'">&nbsp;({{ user_id }})</span>
</span>
</div>
</div>
<div class="row mb-3" v-if="user_id === 'new'">
<label for="first_name" class="col-sm-4 col-form-label">{{ $t('form.firstname') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="first_name" v-model="user.first_name">
</div>
</div>
<div class="row mb-3" v-if="user_id === 'new'">
<label for="last_name" class="col-sm-4 col-form-label">{{ $t('form.lastname') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="last_name" v-model="user.last_name">
</div>
</div>
<div class="row mb-3" v-if="user_id === 'new'">
<label for="organization" class="col-sm-4 col-form-label">{{ $t('user.org') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="organization" v-model="user.organization">
</div>
</div>
<div class="row mb-3">
<label for="email" class="col-sm-4 col-form-label">{{ $t('form.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="row mb-3" v-if="isController">
<label for="aliases-input" class="col-sm-4 col-form-label">{{ $t('user.email-aliases') }}</label>
<div class="col-sm-8">
<list-input id="aliases" :list="user.aliases"></list-input>
</div>
</div>
<div class="row mb-3">
<label for="password" class="col-sm-4 col-form-label">{{ $t('form.password') }}</label>
<div class="col-sm-8">
<div v-if="!isSelf" class="btn-group w-100" role="group">
<input type="checkbox" id="pass-mode-input" value="input" class="btn-check" @change="setPasswordMode" :checked="passwordMode == 'input'">
<label class="btn btn-outline-secondary" for="pass-mode-input">{{ $t('user.pass-input') }}</label>
<input type="checkbox" id="pass-mode-link" value="link" class="btn-check" @change="setPasswordMode">
<label class="btn btn-outline-secondary" for="pass-mode-link">{{ $t('user.pass-link') }}</label>
</div>
<password-input v-if="passwordMode == 'input'" :class="isSelf ? '' : 'mt-2'" v-model="user"></password-input>
<div id="password-link" v-if="isController && (passwordMode == 'link' || user.passwordLinkCode)" class="mt-2">
<span>{{ $t('user.pass-link-label') }}</span>&nbsp;<code>{{ passwordLink }}</code>
<span class="d-inline-block">
<btn class="btn-link p-1" :icon="['far', 'clipboard']" :title="$t('btn.copy')" @click="passwordLinkCopy"></btn>
<btn v-if="user.passwordLinkCode" class="btn-link text-danger p-1" icon="trash-can" :title="$t('btn.delete')" @click="passwordLinkDelete"></btn>
</span>
<div v-if="!user.passwordLinkCode" class="form-text m-0">{{ $t('user.pass-link-hint') }}</div>
</div>
</div>
</div>
<div v-if="user_id === 'new'" id="user-packages" class="row mb-3">
<label class="col-sm-4 col-form-label">{{ $t('user.package') }}</label>
<package-select class="col-sm-8 pt-sm-1"></package-select>
</div>
- <div v-if="user_id !== 'new' && isController" id="user-skus" class="row mb-3">
+ <div v-if="user_id !== 'new' && $root.hasPermission('subscriptions')" id="user-skus" class="row mb-3">
<label class="col-sm-4 col-form-label">{{ $t('form.subscriptions') }}</label>
<subscription-select v-if="user.id" class="col-sm-8 pt-sm-1" :object="user" ref="skus"></subscription-select>
</div>
<btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
</form>
</div>
<div v-if="isController" class="tab-pane" id="settings" role="tabpanel" aria-labelledby="tab-settings">
<form @submit.prevent="submitSettings" class="card-body">
<div class="row checkbox mb-3">
<label for="greylist_enabled" class="col-sm-4 col-form-label">{{ $t('user.greylisting') }}</label>
<div class="col-sm-8 pt-2">
<input type="checkbox" id="greylist_enabled" name="greylist_enabled" value="1" class="form-check-input d-block mb-2" :checked="user.config.greylist_enabled">
<small id="greylisting-hint" class="text-muted">
{{ $t('user.greylisting-text') }}
</small>
</div>
</div>
<div v-if="$root.hasPermission('beta')" class="row checkbox mb-3">
<label for="guam_enabled" class="col-sm-4 col-form-label">
{{ $t('user.imapproxy') }}
<sup class="badge bg-primary">{{ $t('dashboard.beta') }}</sup>
</label>
<div class="col-sm-8 pt-2">
<input type="checkbox" id="guam_enabled" name="guam_enabled" value="1" class="form-check-input d-block mb-2" :checked="user.config.guam_enabled">
<small id="guam-hint" class="text-muted">
{{ $t('user.imapproxy-text') }}
</small>
</div>
</div>
<div v-if="$root.hasPermission('beta')" class="row mb-3">
<label for="limit_geo" class="col-sm-4 col-form-label">
{{ $t('user.geolimit') }}
<sup class="badge bg-primary">{{ $t('dashboard.beta') }}</sup>
</label>
<div class="col-sm-8 pt-2">
<country-select id="limit_geo" v-model="user.config.limit_geo"></country-select>
<small id="geolimit-hint" class="text-muted">
{{ $t('user.geolimit-text') }}
</small>
</div>
</div>
<btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
</form>
</div>
<div class="tab-pane" id="personal" role="tabpanel" aria-labelledby="tab-personal">
<form @submit.prevent="submitPersonalSettings" class="card-body">
<div class="row mb-3">
<label for="first_name" class="col-sm-4 col-form-label">{{ $t('form.firstname') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="first_name" v-model="user.first_name">
</div>
</div>
<div class="row mb-3">
<label for="last_name" class="col-sm-4 col-form-label">{{ $t('form.lastname') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="last_name" v-model="user.last_name">
</div>
</div>
<div class="row mb-3">
<label for="organization" class="col-sm-4 col-form-label">{{ $t('user.org') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="organization" v-model="user.organization">
</div>
</div>
<div class="row mb-3">
<label for="phone" class="col-sm-4 col-form-label">{{ $t('form.phone') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="phone" v-model="user.phone">
</div>
</div>
<div class="row mb-3">
<label for="external_email" class="col-sm-4 col-form-label">{{ $t('user.ext-email') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="external_email" v-model="user.external_email">
</div>
</div>
<div class="row mb-3">
<label for="billing_address" class="col-sm-4 col-form-label">{{ $t('user.address') }}</label>
<div class="col-sm-8">
<textarea class="form-control" id="billing_address" rows="3" v-model="user.billing_address"></textarea>
</div>
</div>
<div class="row mb-3">
<label for="country" class="col-sm-4 col-form-label">{{ $t('user.country') }}</label>
<div class="col-sm-8">
<select class="form-select" id="country" v-model="user.country">
<option value="">-</option>
<option v-for="(item, code) in countries" :value="code" :key="code">{{ item[1] }}</option>
</select>
</div>
</div>
<btn class="btn-primary button-submit mt-2" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
</form>
</div>
</div>
</div>
</div>
</div>
<modal-dialog id="delete-warning" ref="deleteWarning" :buttons="[deleteButton]" :cancel-focus="true" @click="deleteUser()"
:title="$t(isSelf ? 'user.profile-delete-title' : 'user.delete-email', { email: user.email })"
>
<div v-if="isSelf">
<p>{{ $t('user.profile-delete-text1') }} <strong>{{ $t('user.profile-delete-warning') }}</strong>.</p>
<p>{{ $t('user.profile-delete-text2') }}</p>
<p v-if="supportEmail" v-html="$t('user.profile-delete-support', { href: 'mailto:' + supportEmail, email: supportEmail })"></p>
<p>{{ $t('user.profile-delete-contact', { app: $root.appName }) }}</p>
</div>
<p v-else>{{ $t('user.delete-text') }}</p>
</modal-dialog>
</div>
</template>
<script>
import CountrySelect from '../Widgets/CountrySelect'
import ListInput from '../Widgets/ListInput'
import ModalDialog from '../Widgets/ModalDialog'
import PackageSelect from '../Widgets/PackageSelect'
import PasswordInput from '../Widgets/PasswordInput'
import StatusComponent from '../Widgets/Status'
import SubscriptionSelect from '../Widgets/SubscriptionSelect'
import { library } from '@fortawesome/fontawesome-svg-core'
library.add(
require('@fortawesome/free-regular-svg-icons/faClipboard').definition,
)
export default {
components: {
CountrySelect,
ListInput,
ModalDialog,
PackageSelect,
PasswordInput,
StatusComponent,
SubscriptionSelect
},
data() {
return {
countries: window.config.countries,
isSelf: false,
passwordLinkCode: '',
passwordMode: '',
user_id: null,
user: { aliases: [], config: [] },
supportEmail: window.config['app.support_email'],
status: {},
successRoute: { name: 'users' }
}
},
computed: {
deleteButton: function () {
return {
className: 'btn-danger modal-action',
dismiss: 'modal',
label: this.isSelf ? 'user.profile-delete' : 'btn.delete',
icon: 'trash-can'
}
},
isController: function () {
return this.$root.hasPermission('users')
},
passwordLink: function () {
return this.$root.appUrl + '/password-reset/' + this.passwordLinkCode
},
tabs: function () {
let tabs = ['form.general']
if (this.user_id === 'new') {
return tabs
}
if (this.isController) {
tabs.push('form.settings')
}
tabs.push('form.personal')
return tabs
}
},
created() {
if (this.$route.name === 'settings') {
this.user_id = this.$root.authInfo.id
this.successRoute = null
} else {
this.user_id = this.$route.params.user
}
this.isSelf = this.user_id == this.$root.authInfo.id
if (this.user_id !== 'new') {
axios.get('/api/v4/users/' + this.user_id, { loader: true })
.then(response => {
this.user = { ...response.data, ...response.data.settings }
this.status = response.data.statusInfo
this.passwordLinkCode = this.user.passwordLinkCode
})
.catch(this.$root.errorHandler)
if (this.isSelf) {
this.passwordMode = 'input'
}
} else {
this.passwordMode = 'input'
}
},
mounted() {
$('#first_name').focus()
},
methods: {
passwordLinkCopy() {
navigator.clipboard.writeText($('#password-link code').text());
},
passwordLinkDelete() {
this.passwordMode = ''
$('#pass-mode-link')[0].checked = false
// Delete the code for real
axios.delete('/api/v4/password-reset/code/' + this.passwordLinkCode)
.then(response => {
this.passwordLinkCode = ''
this.user.passwordLinkCode = ''
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
}
})
},
setPasswordMode(event) {
const mode = event.target.checked ? event.target.value : ''
// In the "new user" mode the password mode cannot be unchecked
if (!mode && this.user_id === 'new') {
event.target.checked = true
return
}
this.passwordMode = mode
if (!event.target.checked) {
return
}
$('#pass-mode-' + (mode == 'link' ? 'input' : 'link'))[0].checked = false
// Note: we use $nextTick() because we have to wait for the HTML elements to exist
this.$nextTick().then(() => {
if (mode == 'link' && !this.passwordLinkCode) {
axios.post('/api/v4/password-reset/code', {}, { loader: '#password-link' })
.then(response => {
this.passwordLinkCode = response.data.short_code + '-' + response.data.code
})
} else if (mode == 'input') {
$('#password').focus();
}
})
},
submit() {
this.$root.clearFormValidation($('#general form'))
let props = this.isController ? ['aliases'] : []
if (this.user_id === 'new') {
props = props.concat(['email', 'first_name', 'last_name', 'organization'])
}
let method = 'post'
let location = '/api/v4/users'
let post = this.$root.pick(this.user, props)
if (this.user_id !== 'new') {
method = 'put'
location += '/' + this.user_id
if (this.$refs.skus) {
post.skus = this.$refs.skus.getSkus()
}
} else {
post.package = $('#user-packages input:checked').val()
}
if (this.passwordMode == 'link' && this.passwordLinkCode) {
post.passwordLinkCode = this.passwordLinkCode
} else if (this.passwordMode == 'input') {
post.password = this.user.password
post.password_confirmation = this.user.password_confirmation
}
axios[method](location, post)
.then(response => {
if (response.data.statusInfo) {
this.$root.authInfo.statusInfo = response.data.statusInfo
}
this.$toast.success(response.data.message)
if (this.successRoute) {
this.$router.push(this.successRoute)
}
})
},
submitPersonalSettings() {
this.$root.clearFormValidation($('#personal form'))
let post = this.$root.pick(this.user, ['first_name', 'last_name', 'organization', 'phone',
'country', 'external_email', 'billing_address'])
axios.put('/api/v4/users' + '/' + this.user_id, post)
.then(response => {
if (response.data.statusInfo) {
this.$root.authInfo.statusInfo = response.data.statusInfo
}
this.$toast.success(response.data.message)
if (this.successRoute) {
this.$router.push(this.successRoute)
}
})
},
submitSettings() {
this.$root.clearFormValidation($('#settings form'))
let post = this.$root.pick(this.user.config, ['limit_geo'])
const checklist = ['greylist_enabled', 'guam_enabled']
checklist.forEach(name => {
if ($('#' + name).length) {
post[name] = $('#' + name).prop('checked') ? 1 : 0
}
})
axios.post('/api/v4/users/' + this.user_id + '/config', post)
.then(response => {
this.$toast.success(response.data.message)
})
},
statusUpdate(user) {
this.user = Object.assign({}, this.user, user)
},
deleteUser() {
// Delete the user from the confirm dialog
axios.delete('/api/v4/users/' + this.user_id)
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
if (this.isSelf) {
this.$root.logoutUser()
} else {
this.$router.push(this.successRoute)
}
}
})
}
}
}
</script>
diff --git a/src/tests/Feature/Controller/SkusTest.php b/src/tests/Feature/Controller/SkusTest.php
index 73d2d9e0..8c9acae2 100644
--- a/src/tests/Feature/Controller/SkusTest.php
+++ b/src/tests/Feature/Controller/SkusTest.php
@@ -1,103 +1,171 @@
<?php
namespace Tests\Feature\Controller;
+use App\Http\Controllers\API\V4\SkusController;
use App\Sku;
use App\Tenant;
use Tests\TestCase;
class SkusTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('jane@kolabnow.com');
$this->clearBetaEntitlements();
Sku::where('title', 'test')->delete();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('jane@kolabnow.com');
$this->clearBetaEntitlements();
Sku::where('title', 'test')->delete();
parent::tearDown();
}
/**
* Test fetching SKUs list
*/
public function testIndex(): void
{
// Unauth access not allowed
$response = $this->get("api/v4/skus");
$response->assertStatus(401);
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$sku = Sku::withEnvTenantContext()->where('title', 'mailbox')->first();
// Create an sku for another tenant, to make sure it is not included in the result
$nsku = Sku::create([
'title' => 'test',
'name' => 'Test',
'description' => '',
'active' => true,
'cost' => 100,
'handler_class' => 'App\Handlers\Mailbox',
]);
$tenant = Tenant::whereNotIn('id', [\config('app.tenant_id')])->first();
$nsku->tenant_id = $tenant->id;
$nsku->save();
$response = $this->actingAs($john)->get("api/v4/skus");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(11, $json);
$this->assertSame(100, $json[0]['prio']);
$this->assertSame($sku->id, $json[0]['id']);
$this->assertSame($sku->title, $json[0]['title']);
$this->assertSame($sku->name, $json[0]['name']);
$this->assertSame($sku->description, $json[0]['description']);
$this->assertSame($sku->cost, $json[0]['cost']);
$this->assertSame($sku->units_free, $json[0]['units_free']);
$this->assertSame($sku->period, $json[0]['period']);
$this->assertSame($sku->active, $json[0]['active']);
$this->assertSame('user', $json[0]['type']);
$this->assertSame('Mailbox', $json[0]['handler']);
// Test the type filter, and nextCost property (user with one domain)
$response = $this->actingAs($john)->get("api/v4/skus?type=domain");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(1, $json);
$this->assertSame('domain-hosting', $json[0]['title']);
$this->assertSame(100, $json[0]['nextCost']); // second domain costs 100
// Test the type filter, and nextCost property (user with no domain)
$jane = $this->getTestUser('jane@kolabnow.com');
$kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first();
$jane->assignPackage($kolab);
$response = $this->actingAs($jane)->get("api/v4/skus?type=domain");
$json = $response->json();
$this->assertCount(1, $json);
$this->assertSame('domain-hosting', $json[0]['title']);
$this->assertSame(0, $json[0]['nextCost']); // first domain costs 0
}
+
+ /**
+ * Test updateEntitlements() method
+ */
+ public function testUpdateEntitlements(): void
+ {
+ $jane = $this->getTestUser('jane@kolabnow.com');
+ $wallet = $jane->wallets()->first();
+ $mailbox_sku = Sku::withEnvTenantContext()->where('title', 'mailbox')->first();
+ $storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first();
+
+ // Invalid empty input
+ SkusController::updateEntitlements($jane, null, $wallet);
+
+ $this->assertSame(0, $wallet->entitlements()->count());
+
+ // Add mailbox SKU
+ SkusController::updateEntitlements($jane, [$mailbox_sku->id => 1], $wallet);
+
+ $this->assertSame(1, $wallet->entitlements()->count());
+ $this->assertSame($mailbox_sku->id, $wallet->entitlements()->first()->sku_id);
+
+ // Add 2 storage SKUs
+ $skus = [$mailbox_sku->id => 1, $storage_sku->id => 2];
+ SkusController::updateEntitlements($jane, $skus, $wallet);
+
+ $this->assertSame(1, $wallet->entitlements()->where('sku_id', $mailbox_sku->id)->count());
+ $this->assertSame(2, $wallet->entitlements()->where('sku_id', $storage_sku->id)->count());
+
+ // Add two more storage SKUs
+ $skus = [$mailbox_sku->id => 1, $storage_sku->id => 7];
+ SkusController::updateEntitlements($jane, $skus, $wallet);
+
+ $this->assertSame(1, $wallet->entitlements()->where('sku_id', $mailbox_sku->id)->count());
+ $this->assertSame(7, $wallet->entitlements()->where('sku_id', $storage_sku->id)->count());
+
+ // Remove two storage SKUs
+ $skus = [$mailbox_sku->id => 1, $storage_sku->id => 3];
+ SkusController::updateEntitlements($jane, $skus, $wallet);
+
+ $this->assertSame(1, $wallet->entitlements()->where('sku_id', $mailbox_sku->id)->count());
+ // Note: 5 not 4 because of free_units=5
+ $this->assertSame(5, $wallet->entitlements()->where('sku_id', $storage_sku->id)->count());
+
+ // Request SKU that can't be assigned to a User object
+ // Such SKUs are being ignored silently
+ $group_sku = Sku::withEnvTenantContext()->where('title', 'group')->first();
+ $skus = [$mailbox_sku->id => 1, $storage_sku->id => 5, $group_sku->id => 1];
+ SkusController::updateEntitlements($jane, $skus, $wallet);
+
+ $this->assertSame(0, $wallet->entitlements()->where('sku_id', $group_sku->id)->count());
+
+ // Error - add extra mailbox SKU
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessage('Invalid quantity of mailboxes');
+
+ $skus = [$mailbox_sku->id => 2, $storage_sku->id => 5];
+ SkusController::updateEntitlements($jane, $skus, $wallet);
+
+ // Error - disabled subscriptions
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessage('Subscriptions disabled');
+
+ \config(['app.with_subscriptions' => false]);
+ $skus = [$mailbox_sku->id => 1];
+ SkusController::updateEntitlements($jane, $skus, $wallet);
+ }
}

File Metadata

Mime Type
text/x-diff
Expires
Fri, May 16, 11:00 AM (17 h, 35 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
178357
Default Alt Text
(128 KB)

Event Timeline