Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F236986
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
128 KB
Referenced Files
None
Subscribers
None
View Options
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'"> ({{ $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'"> ({{ 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> <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
Details
Attached
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)
Attached To
Mode
R2 kolab
Attached
Detach File
Event Timeline
Log In to Comment