Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117879222
D3071.1775343006.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
231 KB
Referenced Files
None
Subscribers
None
D3071.1775343006.diff
View Options
diff --git a/src/app/Backends/IMAP.php b/src/app/Backends/IMAP.php
--- a/src/app/Backends/IMAP.php
+++ b/src/app/Backends/IMAP.php
@@ -33,7 +33,7 @@
/**
* Check if a shared folder is set up.
*
- * @param string $folder Folder name, eg. shared/Resources/Name@domain.tld
+ * @param string $folder Folder name, e.g. shared/Resources/Name@domain.tld
*
* @return bool True if a folder exists and is set up, False otherwise
*/
@@ -43,7 +43,7 @@
$imap = self::initIMAP($config);
// Convert the folder from UTF8 to UTF7-IMAP
- if (\preg_match('|^(shared/Resources/)(.*)(@[^@]+)$|', $folder, $matches)) {
+ if (\preg_match('#^(shared/|shared/Resources/)(.+)(@[^@]+)$#', $folder, $matches)) {
$folderName = \mb_convert_encoding($matches[2], 'UTF7-IMAP', 'UTF8');
$folder = $matches[1] . $folderName . $matches[3];
}
diff --git a/src/app/Backends/LDAP.php b/src/app/Backends/LDAP.php
--- a/src/app/Backends/LDAP.php
+++ b/src/app/Backends/LDAP.php
@@ -5,6 +5,7 @@
use App\Domain;
use App\Group;
use App\Resource;
+use App\SharedFolder;
use App\User;
class LDAP
@@ -20,6 +21,12 @@
'invitation_policy',
];
+ /** @const array Shared folder settings used by the backend */
+ public const SHARED_FOLDER_SETTINGS = [
+ 'folder',
+ 'acl',
+ ];
+
/** @const array User settings used by the backend */
public const USER_SETTINGS = [
'first_name',
@@ -296,6 +303,45 @@
}
}
+ /**
+ * Create a shared folder in LDAP.
+ *
+ * @param \App\SharedFolder $folder The shared folder to create.
+ *
+ * @throws \Exception
+ */
+ public static function createSharedFolder(SharedFolder $folder): void
+ {
+ $config = self::getConfig('admin');
+ $ldap = self::initLDAP($config);
+
+ $domainName = explode('@', $folder->email, 2)[1];
+ $cn = $ldap->quote_string($folder->name);
+ $dn = "cn={$cn}," . self::baseDN($domainName, 'Shared Folders');
+
+ $entry = [
+ 'mail' => $folder->email,
+ 'objectclass' => [
+ 'top',
+ 'kolabsharedfolder',
+ 'mailrecipient',
+ ],
+ ];
+
+ self::setSharedFolderAttributes($ldap, $folder, $entry);
+
+ self::addEntry(
+ $ldap,
+ $dn,
+ $entry,
+ "Failed to create shared folder {$folder->id} in LDAP (" . __LINE__ . ")"
+ );
+
+ if (empty(self::$ldap)) {
+ $ldap->close();
+ }
+ }
+
/**
* Create a user in LDAP.
*
@@ -456,6 +502,34 @@
}
}
+ /**
+ * Delete a shared folder from LDAP.
+ *
+ * @param \App\SharedFolder $folder The shared folder to delete.
+ *
+ * @throws \Exception
+ */
+ public static function deleteSharedFolder(SharedFolder $folder): void
+ {
+ $config = self::getConfig('admin');
+ $ldap = self::initLDAP($config);
+
+ if (self::getSharedFolderEntry($ldap, $folder->email, $dn)) {
+ $result = $ldap->delete_entry($dn);
+
+ if (!$result) {
+ self::throwException(
+ $ldap,
+ "Failed to delete shared folder {$folder->id} from LDAP (" . __LINE__ . ")"
+ );
+ }
+ }
+
+ if (empty(self::$ldap)) {
+ $ldap->close();
+ }
+ }
+
/**
* Delete a user from LDAP.
*
@@ -554,6 +628,28 @@
return $resource;
}
+ /**
+ * Get a shared folder data from LDAP.
+ *
+ * @param string $email The resource email.
+ *
+ * @return array|false|null
+ * @throws \Exception
+ */
+ public static function getSharedFolder(string $email)
+ {
+ $config = self::getConfig('admin');
+ $ldap = self::initLDAP($config);
+
+ $folder = self::getSharedFolderEntry($ldap, $email, $dn);
+
+ if (empty(self::$ldap)) {
+ $ldap->close();
+ }
+
+ return $folder;
+ }
+
/**
* Get a user data from LDAP.
*
@@ -694,6 +790,43 @@
}
}
+ /**
+ * Update a shared folder in LDAP.
+ *
+ * @param \App\SharedFolder $folder The shared folder to update
+ *
+ * @throws \Exception
+ */
+ public static function updateSharedFolder(SharedFolder $folder): void
+ {
+ $config = self::getConfig('admin');
+ $ldap = self::initLDAP($config);
+
+ $newEntry = $oldEntry = self::getSharedFolderEntry($ldap, $folder->email, $dn);
+
+ if (empty($oldEntry)) {
+ self::throwException(
+ $ldap,
+ "Failed to update shared folder {$folder->id} in LDAP (folder not found)"
+ );
+ }
+
+ self::setSharedFolderAttributes($ldap, $folder, $newEntry);
+
+ $result = $ldap->modify_entry($dn, $oldEntry, $newEntry);
+
+ if (!is_array($result)) {
+ self::throwException(
+ $ldap,
+ "Failed to update shared folder {$folder->id} in LDAP (" . __LINE__ . ")"
+ );
+ }
+
+ if (empty(self::$ldap)) {
+ $ldap->close();
+ }
+ }
+
/**
* Update a user in LDAP.
*
@@ -886,6 +1019,19 @@
}
}
+ /**
+ * Set common shared folder attributes
+ */
+ private static function setSharedFolderAttributes($ldap, SharedFolder $folder, &$entry)
+ {
+ $settings = $folder->getSettings(['acl', 'folder']);
+
+ $entry['cn'] = $folder->name;
+ $entry['kolabfoldertype'] = $folder->type;
+ $entry['kolabtargetfolder'] = $settings['folder'] ?? '';
+ $entry['acl'] = !empty($settings['acl']) ? json_decode($settings['acl'], true) : '';
+ }
+
/**
* Set common user attributes
*/
@@ -1027,6 +1173,28 @@
return self::searchEntry($ldap, $base_dn, "(mail=$email)", $attrs, $dn);
}
+ /**
+ * Get a shared folder entry from LDAP.
+ *
+ * @param \Net_LDAP3 $ldap Ldap connection
+ * @param string $email Resource email (mail)
+ * @param string $dn Reference to the shared folder DN
+ *
+ * @return null|array Shared folder entry, NULL if not found
+ */
+ private static function getSharedFolderEntry($ldap, $email, &$dn = null)
+ {
+ $domainName = explode('@', $email, 2)[1];
+ $base_dn = self::baseDN($domainName, 'Shared Folders');
+
+ $attrs = ['dn', 'cn', 'mail', 'objectclass', 'kolabtargetfolder', 'kolabfoldertype', 'acl'];
+
+ // For shared folders we're using search() instead of get_entry() because
+ // a folder name is not constant, so e.g. on update we might have
+ // the new name, but not the old one. Email address is constant.
+ return self::searchEntry($ldap, $base_dn, "(mail=$email)", $attrs, $dn);
+ }
+
/**
* Get user entry from LDAP.
*
diff --git a/src/app/Console/Commands/SharedFoldersCommand.php b/src/app/Console/Commands/SharedFoldersCommand.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/SharedFoldersCommand.php
@@ -0,0 +1,12 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Console\ObjectListCommand;
+
+class SharedFoldersCommand extends ObjectListCommand
+{
+ protected $objectClass = \App\SharedFolder::class;
+ protected $objectName = 'shared-folder';
+ protected $objectTitle = 'name';
+}
diff --git a/src/app/Domain.php b/src/app/Domain.php
--- a/src/app/Domain.php
+++ b/src/app/Domain.php
@@ -384,6 +384,7 @@
|| \App\UserAlias::whereRaw('substr(alias, ?) = ?', [-$suffixLen, $suffix])->exists()
|| \App\Group::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists()
|| \App\Resource::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists()
+ || \App\SharedFolder::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists()
);
}
diff --git a/src/app/Handlers/Beta/SharedFolders.php b/src/app/Handlers/Beta/SharedFolders.php
new file mode 100644
--- /dev/null
+++ b/src/app/Handlers/Beta/SharedFolders.php
@@ -0,0 +1,49 @@
+<?php
+
+namespace App\Handlers\Beta;
+
+class SharedFolders extends Base
+{
+ /**
+ * The entitleable class for this handler.
+ *
+ * @return string
+ */
+ public static function entitleableClass(): string
+ {
+ return \App\User::class;
+ }
+
+ /**
+ * Check if the SKU is available to the user/domain.
+ *
+ * @param \App\Sku $sku The SKU object
+ * @param \App\User|\App\Domain $object The user or domain object
+ *
+ * @return bool
+ */
+ public static function isAvailable(\App\Sku $sku, $object): bool
+ {
+ // This SKU must be:
+ // - already assigned, or active and a 'beta' entitlement must exist
+ // - and this is a group account owner (custom domain)
+
+ if (parent::isAvailable($sku, $object)) {
+ return $object->wallet()->entitlements()
+ ->where('entitleable_type', \App\Domain::class)->count() > 0;
+ }
+
+ return false;
+ }
+
+ /**
+ * The priority that specifies the order of SKUs in UI.
+ * Higher number means higher on the list.
+ *
+ * @return int
+ */
+ public static function priority(): int
+ {
+ return 10;
+ }
+}
diff --git a/src/app/Handlers/SharedFolder.php b/src/app/Handlers/SharedFolder.php
--- a/src/app/Handlers/SharedFolder.php
+++ b/src/app/Handlers/SharedFolder.php
@@ -11,7 +11,6 @@
*/
public static function entitleableClass(): string
{
- // TODO
- return '';
+ return \App\SharedFolder::class;
}
}
diff --git a/src/app/Http/Controllers/API/V4/Admin/SharedFoldersController.php b/src/app/Http/Controllers/API/V4/Admin/SharedFoldersController.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/Admin/SharedFoldersController.php
@@ -0,0 +1,59 @@
+<?php
+
+namespace App\Http\Controllers\API\V4\Admin;
+
+use App\SharedFolder;
+use App\User;
+use Illuminate\Http\Request;
+
+class SharedFoldersController extends \App\Http\Controllers\API\V4\SharedFoldersController
+{
+ /**
+ * Search for shared folders
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function index()
+ {
+ $search = trim(request()->input('search'));
+ $owner = trim(request()->input('owner'));
+ $result = collect([]);
+
+ if ($owner) {
+ if ($owner = User::find($owner)) {
+ $result = $owner->sharedFolders(false)->orderBy('name')->get();
+ }
+ } elseif (!empty($search)) {
+ if ($folder = SharedFolder::where('email', $search)->first()) {
+ $result->push($folder);
+ }
+ }
+
+ // Process the result
+ $result = $result->map(
+ function ($folder) {
+ return $this->objectToClient($folder);
+ }
+ );
+
+ $result = [
+ 'list' => $result,
+ 'count' => count($result),
+ 'message' => \trans('app.search-foundxsharedfolders', ['x' => count($result)]),
+ ];
+
+ return response()->json($result);
+ }
+
+ /**
+ * Create a new shared folder.
+ *
+ * @param \Illuminate\Http\Request $request The API request.
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function store(Request $request)
+ {
+ return $this->errorResponse(404);
+ }
+}
diff --git a/src/app/Http/Controllers/API/V4/Admin/UsersController.php b/src/app/Http/Controllers/API/V4/Admin/UsersController.php
--- a/src/app/Http/Controllers/API/V4/Admin/UsersController.php
+++ b/src/app/Http/Controllers/API/V4/Admin/UsersController.php
@@ -63,6 +63,8 @@
$user_ids = $user_ids->merge([$group->wallet()->user_id])->unique();
} elseif ($resource = \App\Resource::withTrashed()->where('email', $search)->first()) {
$user_ids = $user_ids->merge([$resource->wallet()->user_id])->unique();
+ } elseif ($folder = \App\SharedFolder::withTrashed()->where('email', $search)->first()) {
+ $user_ids = $user_ids->merge([$folder->wallet()->user_id])->unique();
}
if (!$user_ids->isEmpty()) {
diff --git a/src/app/Http/Controllers/API/V4/Reseller/SharedFoldersController.php b/src/app/Http/Controllers/API/V4/Reseller/SharedFoldersController.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/Reseller/SharedFoldersController.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace App\Http\Controllers\API\V4\Reseller;
+
+use App\SharedFolder;
+use App\User;
+
+class SharedFoldersController extends \App\Http\Controllers\API\V4\Admin\SharedFoldersController
+{
+ /**
+ * Search for shared folders
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function index()
+ {
+ $search = trim(request()->input('search'));
+ $owner = trim(request()->input('owner'));
+ $result = collect([]);
+
+ if ($owner) {
+ if ($owner = User::withSubjectTenantContext()->find($owner)) {
+ $result = $owner->sharedFolders(false)->orderBy('name')->get();
+ }
+ } elseif (!empty($search)) {
+ if ($folder = SharedFolder::withSubjectTenantContext()->where('email', $search)->first()) {
+ $result->push($folder);
+ }
+ }
+
+ // Process the result
+ $result = $result->map(
+ function ($folder) {
+ return $this->objectToClient($folder);
+ }
+ );
+
+ $result = [
+ 'list' => $result,
+ 'count' => count($result),
+ 'message' => \trans('app.search-foundxsharedfolders', ['x' => count($result)]),
+ ];
+
+ return response()->json($result);
+ }
+}
diff --git a/src/app/Http/Controllers/API/V4/Reseller/UsersController.php b/src/app/Http/Controllers/API/V4/Reseller/UsersController.php
--- a/src/app/Http/Controllers/API/V4/Reseller/UsersController.php
+++ b/src/app/Http/Controllers/API/V4/Reseller/UsersController.php
@@ -53,6 +53,10 @@
// Search by a distribution list email
if ($group = Group::withTrashed()->where('email', $search)->first()) {
$user_ids = $user_ids->merge([$group->wallet()->user_id])->unique();
+ } elseif ($resource = \App\Resource::withTrashed()->where('email', $search)->first()) {
+ $user_ids = $user_ids->merge([$resource->wallet()->user_id])->unique();
+ } elseif ($folder = \App\SharedFolder::withTrashed()->where('email', $search)->first()) {
+ $user_ids = $user_ids->merge([$folder->wallet()->user_id])->unique();
}
if (!$user_ids->isEmpty()) {
diff --git a/src/app/Http/Controllers/API/V4/ResourcesController.php b/src/app/Http/Controllers/API/V4/ResourcesController.php
--- a/src/app/Http/Controllers/API/V4/ResourcesController.php
+++ b/src/app/Http/Controllers/API/V4/ResourcesController.php
@@ -209,7 +209,6 @@
$v = Validator::make($request->all(), $rules);
if ($v->fails()) {
- $errors = $v->errors()->toArray();
return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
}
diff --git a/src/app/Http/Controllers/API/V4/SharedFoldersController.php b/src/app/Http/Controllers/API/V4/SharedFoldersController.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/SharedFoldersController.php
@@ -0,0 +1,357 @@
+<?php
+
+namespace App\Http\Controllers\API\V4;
+
+use App\Http\Controllers\Controller;
+use App\SharedFolder;
+use App\Rules\SharedFolderName;
+use App\Rules\SharedFolderType;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Validator;
+
+class SharedFoldersController extends Controller
+{
+ /** @var array Common object properties in the API response */
+ protected static $objectProps = ['email', 'name', 'type'];
+
+ /**
+ * Show the form for creating a new shared folder.
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function create()
+ {
+ return $this->errorResponse(404);
+ }
+
+ /**
+ * Delete a shared folder.
+ *
+ * @param int $id Shared folder identifier
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function destroy($id)
+ {
+ $folder = SharedFolder::find($id);
+
+ if (!$this->checkTenant($folder)) {
+ return $this->errorResponse(404);
+ }
+
+ if (!$this->guard()->user()->canDelete($folder)) {
+ return $this->errorResponse(403);
+ }
+
+ $folder->delete();
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => \trans('app.shared-folder-delete-success'),
+ ]);
+ }
+
+ /**
+ * Show the form for editing the specified shared folder.
+ *
+ * @param int $id Shared folder identifier
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function edit($id)
+ {
+ return $this->errorResponse(404);
+ }
+
+ /**
+ * Listing of a shared folders belonging to the authenticated user.
+ *
+ * The shared_folder-entitlements billed to the current user wallet(s)
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function index()
+ {
+ $user = $this->guard()->user();
+
+ $result = $user->sharedFolders()->orderBy('name')->get()
+ ->map(function (SharedFolder $folder) {
+ return $this->objectToClient($folder);
+ });
+
+ return response()->json($result);
+ }
+
+ /**
+ * Set the shared folder configuration.
+ *
+ * @param int $id Shared folder identifier
+ *
+ * @return \Illuminate\Http\JsonResponse|void
+ */
+ public function setConfig($id)
+ {
+ $folder = SharedFolder::find($id);
+
+ if (!$this->checkTenant($folder)) {
+ return $this->errorResponse(404);
+ }
+
+ if (!$this->guard()->user()->canUpdate($folder)) {
+ return $this->errorResponse(403);
+ }
+
+ $errors = $folder->setConfig(request()->input());
+
+ if (!empty($errors)) {
+ return response()->json(['status' => 'error', 'errors' => $errors], 422);
+ }
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => \trans('app.shared-folder-setconfig-success'),
+ ]);
+ }
+
+ /**
+ * Display information of a shared folder specified by $id.
+ *
+ * @param int $id Shared folder identifier
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function show($id)
+ {
+ $folder = SharedFolder::find($id);
+
+ if (!$this->checkTenant($folder)) {
+ return $this->errorResponse(404);
+ }
+
+ if (!$this->guard()->user()->canRead($folder)) {
+ return $this->errorResponse(403);
+ }
+
+ $response = $this->objectToClient($folder, true);
+
+ $response['statusInfo'] = self::statusInfo($folder);
+
+ // Shared folder configuration, e.g. acl
+ $response['config'] = $folder->getConfig();
+
+ return response()->json($response);
+ }
+
+ /**
+ * Fetch a shared folder status (and reload setup process)
+ *
+ * @param int $id Shared folder identifier
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function status($id)
+ {
+ $folder = SharedFolder::find($id);
+
+ if (!$this->checkTenant($folder)) {
+ return $this->errorResponse(404);
+ }
+
+ if (!$this->guard()->user()->canRead($folder)) {
+ return $this->errorResponse(403);
+ }
+
+ $response = $this->processStateUpdate($folder);
+ $response = array_merge($response, self::objectState($folder));
+
+ return response()->json($response);
+ }
+
+ /**
+ * SharedFolder status (extended) information
+ *
+ * @param \App\SharedFolder $folder SharedFolder object
+ *
+ * @return array Status information
+ */
+ public static function statusInfo(SharedFolder $folder): array
+ {
+ return self::processStateInfo(
+ $folder,
+ [
+ 'shared-folder-new' => true,
+ 'shared-folder-ldap-ready' => $folder->isLdapReady(),
+ 'shared-folder-imap-ready' => $folder->isImapReady(),
+ ]
+ );
+ }
+
+ /**
+ * Create a new shared folder 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->wallet()->owner;
+
+ if ($owner->id != $current_user->id) {
+ return $this->errorResponse(403);
+ }
+
+ $domain = request()->input('domain');
+
+ $rules = [
+ 'name' => ['required', 'string', new SharedFolderName($owner, $domain)],
+ 'type' => ['required', 'string', new SharedFolderType()]
+ ];
+
+ $v = Validator::make($request->all(), $rules);
+
+ if ($v->fails()) {
+ return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
+ }
+
+ DB::beginTransaction();
+
+ // Create the shared folder
+ $folder = new SharedFolder();
+ $folder->name = request()->input('name');
+ $folder->type = request()->input('type');
+ $folder->domain = $domain;
+ $folder->save();
+
+ $folder->assignToWallet($owner->wallets->first());
+
+ DB::commit();
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => \trans('app.shared-folder-create-success'),
+ ]);
+ }
+
+ /**
+ * Update a shared folder.
+ *
+ * @param \Illuminate\Http\Request $request The API request.
+ * @param string $id Shared folder identifier
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function update(Request $request, $id)
+ {
+ $folder = SharedFolder::find($id);
+
+ if (!$this->checkTenant($folder)) {
+ return $this->errorResponse(404);
+ }
+
+ $current_user = $this->guard()->user();
+
+ if (!$current_user->canUpdate($folder)) {
+ return $this->errorResponse(403);
+ }
+
+ $owner = $folder->wallet()->owner;
+
+ $name = $request->input('name');
+ $errors = [];
+
+ // Validate the folder name
+ if ($name !== null && $name != $folder->name) {
+ $domainName = explode('@', $folder->email, 2)[1];
+ $rules = ['name' => ['required', 'string', new SharedFolderName($owner, $domainName)]];
+
+ $v = Validator::make($request->all(), $rules);
+
+ if ($v->fails()) {
+ $errors = $v->errors()->toArray();
+ } else {
+ $folder->name = $name;
+ }
+ }
+
+ if (!empty($errors)) {
+ return response()->json(['status' => 'error', 'errors' => $errors], 422);
+ }
+
+ $folder->save();
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => \trans('app.shared-folder-update-success'),
+ ]);
+ }
+
+ /**
+ * Execute (synchronously) specified step in a shared folder setup process.
+ *
+ * @param \App\SharedFolder $folder Shared folder 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(SharedFolder $folder, string $step): ?bool
+ {
+ try {
+ if (strpos($step, 'domain-') === 0) {
+ return DomainsController::execProcessStep($folder->domain(), $step);
+ }
+
+ switch ($step) {
+ case 'shared-folder-ldap-ready':
+ // Shared folder not in LDAP, create it
+ $job = new \App\Jobs\SharedFolder\CreateJob($folder->id);
+ $job->handle();
+
+ $folder->refresh();
+
+ return $folder->isLdapReady();
+
+ case 'shared-folder-imap-ready':
+ // Shared folder not in IMAP? Verify again
+ // Do it synchronously if the imap admin credentials are available
+ // otherwise let the worker do the job
+ if (!\config('imap.admin_password')) {
+ \App\Jobs\SharedFolder\VerifyJob::dispatch($folder->id);
+
+ return null;
+ }
+
+ $job = new \App\Jobs\SharedFolder\VerifyJob($folder->id);
+ $job->handle();
+
+ $folder->refresh();
+
+ return $folder->isImapReady();
+ }
+ } catch (\Exception $e) {
+ \Log::error($e);
+ }
+
+ return false;
+ }
+
+ /**
+ * Prepare shared folder statuses for the UI
+ *
+ * @param \App\SharedFolder $folder Shared folder object
+ *
+ * @return array Statuses array
+ */
+ protected static function objectState(SharedFolder $folder): array
+ {
+ return [
+ 'isLdapReady' => $folder->isLdapReady(),
+ 'isImapReady' => $folder->isImapReady(),
+ 'isActive' => $folder->isActive(),
+ 'isDeleted' => $folder->isDeleted() || $folder->trashed(),
+ ];
+ }
+}
diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php
--- a/src/app/Http/Controllers/API/V4/UsersController.php
+++ b/src/app/Http/Controllers/API/V4/UsersController.php
@@ -252,6 +252,8 @@
'enableDomains' => $isController && $hasCustomDomain,
// TODO: Make 'enableDistlists' working for wallet controllers that aren't account owners
'enableDistlists' => $isController && $hasCustomDomain && in_array('distlist', $skus),
+ // TODO: Make 'enableFolders' working for wallet controllers that aren't account owners
+ 'enableFolders' => $isController && $hasCustomDomain && in_array('beta-shared-folders', $skus),
// TODO: Make 'enableResources' working for wallet controllers that aren't account owners
'enableResources' => $isController && $hasCustomDomain && in_array('beta-resources', $skus),
'enableUsers' => $isController,
diff --git a/src/app/Jobs/SharedFolder/CreateJob.php b/src/app/Jobs/SharedFolder/CreateJob.php
new file mode 100644
--- /dev/null
+++ b/src/app/Jobs/SharedFolder/CreateJob.php
@@ -0,0 +1,61 @@
+<?php
+
+namespace App\Jobs\SharedFolder;
+
+use App\Jobs\SharedFolderJob;
+
+class CreateJob extends SharedFolderJob
+{
+ /**
+ * Execute the job.
+ *
+ * @return void
+ */
+ public function handle()
+ {
+ $folder = $this->getSharedFolder();
+
+ if (!$folder) {
+ return;
+ }
+
+ // sanity checks
+ if ($folder->isDeleted()) {
+ $this->fail(new \Exception("Shared folder {$this->folderId} is marked as deleted."));
+ return;
+ }
+
+ if ($folder->trashed()) {
+ $this->fail(new \Exception("Shared folder {$this->folderId} is actually deleted."));
+ return;
+ }
+
+ if ($folder->isLdapReady()) {
+ $this->fail(new \Exception("Shared folder {$this->folderId} is already marked as ldap-ready."));
+ return;
+ }
+
+ // see if the domain is ready
+ $domain = $folder->domain();
+
+ if (!$domain) {
+ $this->fail(new \Exception("The domain for shared folder {$this->folderId} does not exist."));
+ return;
+ }
+
+ if ($domain->isDeleted()) {
+ $this->fail(new \Exception("The domain for shared folder {$this->folderId} is marked as deleted."));
+ return;
+ }
+
+ if (!$domain->isLdapReady()) {
+ $this->release(60);
+ return;
+ }
+
+ \App\Backends\LDAP::createSharedFolder($folder);
+
+ $folder->status |= \App\SharedFolder::STATUS_LDAP_READY;
+ $folder->save();
+ }
+}
diff --git a/src/app/Jobs/SharedFolder/DeleteJob.php b/src/app/Jobs/SharedFolder/DeleteJob.php
new file mode 100644
--- /dev/null
+++ b/src/app/Jobs/SharedFolder/DeleteJob.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace App\Jobs\SharedFolder;
+
+use App\Jobs\SharedFolderJob;
+
+class DeleteJob extends SharedFolderJob
+{
+ /**
+ * Execute the job.
+ *
+ * @return void
+ */
+ public function handle()
+ {
+ $folder = $this->getSharedFolder();
+
+ if (!$folder) {
+ return;
+ }
+
+ // sanity checks
+ if ($folder->isDeleted()) {
+ $this->fail(new \Exception("Shared folder {$this->folderId} is already marked as deleted."));
+ return;
+ }
+
+ \App\Backends\LDAP::deleteSharedFolder($folder);
+
+ $folder->status |= \App\SharedFolder::STATUS_DELETED;
+
+ if ($folder->isLdapReady()) {
+ $folder->status ^= \App\SharedFolder::STATUS_LDAP_READY;
+ }
+
+ if ($folder->isImapReady()) {
+ $folder->status ^= \App\SharedFolder::STATUS_IMAP_READY;
+ }
+
+ $folder->save();
+ }
+}
diff --git a/src/app/Jobs/SharedFolder/UpdateJob.php b/src/app/Jobs/SharedFolder/UpdateJob.php
new file mode 100644
--- /dev/null
+++ b/src/app/Jobs/SharedFolder/UpdateJob.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace App\Jobs\SharedFolder;
+
+use App\Jobs\SharedFolderJob;
+
+class UpdateJob extends SharedFolderJob
+{
+ /**
+ * Execute the job.
+ *
+ * @return void
+ */
+ public function handle()
+ {
+ $folder = $this->getSharedFolder();
+
+ if (!$folder) {
+ return;
+ }
+
+ // Cancel the update if the folder is deleted or not yet in LDAP
+ if (!$folder->isLdapReady() || $folder->isDeleted()) {
+ $this->delete();
+ return;
+ }
+
+ \App\Backends\LDAP::updateSharedFolder($folder);
+ }
+}
diff --git a/src/app/Jobs/SharedFolder/VerifyJob.php b/src/app/Jobs/SharedFolder/VerifyJob.php
new file mode 100644
--- /dev/null
+++ b/src/app/Jobs/SharedFolder/VerifyJob.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace App\Jobs\SharedFolder;
+
+use App\Jobs\SharedFolderJob;
+
+class VerifyJob extends SharedFolderJob
+{
+ /**
+ * Execute the job.
+ *
+ * @return void
+ */
+ public function handle()
+ {
+ $folder = $this->getSharedFolder();
+
+ if (!$folder) {
+ return;
+ }
+
+ // the user has a mailbox (or is marked as such)
+ if ($folder->isImapReady()) {
+ $this->fail(new \Exception("Shared folder {$this->folderId} is already verified."));
+ return;
+ }
+
+ $folderName = $folder->getSetting('folder');
+
+ if (\App\Backends\IMAP::verifySharedFolder($folderName)) {
+ $folder->status |= \App\SharedFolder::STATUS_IMAP_READY;
+ $folder->status |= \App\SharedFolder::STATUS_ACTIVE;
+ $folder->save();
+ }
+ }
+}
diff --git a/src/app/Jobs/SharedFolderJob.php b/src/app/Jobs/SharedFolderJob.php
new file mode 100644
--- /dev/null
+++ b/src/app/Jobs/SharedFolderJob.php
@@ -0,0 +1,72 @@
+<?php
+
+namespace App\Jobs;
+
+/**
+ * The abstract \App\Jobs\SharedFolderJob implements the logic needed for all dispatchable Jobs related to
+ * \App\SharedFolder objects.
+ *
+ * ```php
+ * $job = new \App\Jobs\SharedFolder\CreateJob($folderId);
+ * $job->handle();
+ * ```
+ */
+abstract class SharedFolderJob extends CommonJob
+{
+ /**
+ * The ID for the \App\SharedFolder. This is the shortest globally unique identifier and saves Redis space
+ * compared to a serialized version of the complete \App\SharedFolder object.
+ *
+ * @var int
+ */
+ protected $folderId;
+ /**
+ * The \App\SharedFolder email property, for legibility in the queue management.
+ *
+ * @var string
+ */
+ protected $folderEmail;
+
+ /**
+ * Create a new job instance.
+ *
+ * @param int $folderId The ID for the shared folder to process.
+ *
+ * @return void
+ */
+ public function __construct(int $folderId)
+ {
+ $this->folderId = $folderId;
+
+ $folder = $this->getSharedFolder();
+
+ if ($folder) {
+ $this->folderEmail = $folder->email;
+ }
+ }
+
+ /**
+ * Get the \App\SharedFolder entry associated with this job.
+ *
+ * @return \App\SharedFolder|null
+ *
+ * @throws \Exception
+ */
+ protected function getSharedFolder()
+ {
+ $folder = \App\SharedFolder::withTrashed()->find($this->folderId);
+
+ if (!$folder) {
+ // The record might not exist yet in case of a db replication environment
+ // This will release the job and delay another attempt for 5 seconds
+ if ($this instanceof SharedFolder\CreateJob) {
+ $this->release(5);
+ return null;
+ }
+
+ $this->fail(new \Exception("Shared folder {$this->folderId} could not be found in the database."));
+ }
+
+ return $folder;
+ }
+}
diff --git a/src/app/Observers/SharedFolderObserver.php b/src/app/Observers/SharedFolderObserver.php
new file mode 100644
--- /dev/null
+++ b/src/app/Observers/SharedFolderObserver.php
@@ -0,0 +1,140 @@
+<?php
+
+namespace App\Observers;
+
+use App\SharedFolder;
+
+class SharedFolderObserver
+{
+ /**
+ * Handle the shared folder "creating" event.
+ *
+ * @param \App\SharedFolder $folder The folder
+ *
+ * @return void
+ */
+ public function creating(SharedFolder $folder): void
+ {
+ if (empty($folder->type)) {
+ $folder->type = 'mail';
+ }
+
+ if (empty($folder->email)) {
+ if (!isset($folder->name)) {
+ throw new \Exception("Missing 'domain' property for a new shared folder");
+ }
+
+ $domainName = \strtolower($folder->domain);
+
+ $folder->email = "{$folder->type}-{$folder->id}@{$domainName}";
+ } else {
+ $folder->email = \strtolower($folder->email);
+ }
+
+ $folder->status |= SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE;
+ }
+
+ /**
+ * Handle the shared folder "created" event.
+ *
+ * @param \App\SharedFolder $folder The folder
+ *
+ * @return void
+ */
+ public function created(SharedFolder $folder)
+ {
+ $domainName = explode('@', $folder->email, 2)[1];
+
+ $settings = [
+ 'folder' => "shared/{$folder->name}@{$domainName}",
+ ];
+
+ foreach ($settings as $key => $value) {
+ $settings[$key] = [
+ 'key' => $key,
+ 'value' => $value,
+ 'shared_folder_id' => $folder->id,
+ ];
+ }
+
+ // Note: Don't use setSettings() here to bypass SharedFolderSetting observers
+ // Note: This is a single multi-insert query
+ $folder->settings()->insert(array_values($settings));
+
+ // Create folder record in LDAP, then check if it is created in IMAP
+ $chain = [
+ new \App\Jobs\SharedFolder\VerifyJob($folder->id),
+ ];
+
+ \App\Jobs\SharedFolder\CreateJob::withChain($chain)->dispatch($folder->id);
+ }
+
+ /**
+ * Handle the shared folder "deleting" event.
+ *
+ * @param \App\SharedFolder $folder The folder
+ *
+ * @return void
+ */
+ public function deleting(SharedFolder $folder)
+ {
+ // Entitlements do not have referential integrity on the entitled object, so this is our
+ // way of doing an onDelete('cascade') without the foreign key.
+ \App\Entitlement::where('entitleable_id', $folder->id)
+ ->where('entitleable_type', SharedFolder::class)
+ ->delete();
+ }
+
+ /**
+ * Handle the shared folder "deleted" event.
+ *
+ * @param \App\SharedFolder $folder The folder
+ *
+ * @return void
+ */
+ public function deleted(SharedFolder $folder)
+ {
+ if ($folder->isForceDeleting()) {
+ return;
+ }
+
+ \App\Jobs\SharedFolder\DeleteJob::dispatch($folder->id);
+ }
+
+ /**
+ * Handle the shared folder "updated" event.
+ *
+ * @param \App\SharedFolder $folder The folder
+ *
+ * @return void
+ */
+ public function updated(SharedFolder $folder)
+ {
+ \App\Jobs\SharedFolder\UpdateJob::dispatch($folder->id);
+
+ // Update the folder property if name changed
+ if ($folder->name != $folder->getOriginal('name')) {
+ $domainName = explode('@', $folder->email, 2)[1];
+ $folderName = "shared/{$folder->name}@{$domainName}";
+
+ // Note: This does not invoke SharedFolderSetting observer events, good.
+ $folder->settings()->where('key', 'folder')->update(['value' => $folderName]);
+ }
+ }
+
+ /**
+ * Handle the shared folder "force deleted" event.
+ *
+ * @param \App\SharedFolder $folder The folder
+ *
+ * @return void
+ */
+ public function forceDeleted(SharedFolder $folder)
+ {
+ // A folder can be force-deleted separately from the owner
+ // we have to force-delete entitlements
+ \App\Entitlement::where('entitleable_id', $folder->id)
+ ->where('entitleable_type', SharedFolder::class)
+ ->forceDelete();
+ }
+}
diff --git a/src/app/Observers/SharedFolderSettingObserver.php b/src/app/Observers/SharedFolderSettingObserver.php
new file mode 100644
--- /dev/null
+++ b/src/app/Observers/SharedFolderSettingObserver.php
@@ -0,0 +1,51 @@
+<?php
+
+namespace App\Observers;
+
+use App\Backends\LDAP;
+use App\SharedFolderSetting;
+
+class SharedFolderSettingObserver
+{
+ /**
+ * Handle the shared folder setting "created" event.
+ *
+ * @param \App\SharedFolderSetting $folderSetting Settings object
+ *
+ * @return void
+ */
+ public function created(SharedFolderSetting $folderSetting)
+ {
+ if (in_array($folderSetting->key, LDAP::SHARED_FOLDER_SETTINGS)) {
+ \App\Jobs\SharedFolder\UpdateJob::dispatch($folderSetting->shared_folder_id);
+ }
+ }
+
+ /**
+ * Handle the shared folder setting "updated" event.
+ *
+ * @param \App\SharedFolderSetting $folderSetting Settings object
+ *
+ * @return void
+ */
+ public function updated(SharedFolderSetting $folderSetting)
+ {
+ if (in_array($folderSetting->key, LDAP::SHARED_FOLDER_SETTINGS)) {
+ \App\Jobs\SharedFolder\UpdateJob::dispatch($folderSetting->shared_folder_id);
+ }
+ }
+
+ /**
+ * Handle the shared folder setting "deleted" event.
+ *
+ * @param \App\SharedFolderSetting $folderSetting Settings object
+ *
+ * @return void
+ */
+ public function deleted(SharedFolderSetting $folderSetting)
+ {
+ if (in_array($folderSetting->key, LDAP::SHARED_FOLDER_SETTINGS)) {
+ \App\Jobs\SharedFolder\UpdateJob::dispatch($folderSetting->shared_folder_id);
+ }
+ }
+}
diff --git a/src/app/Observers/UserObserver.php b/src/app/Observers/UserObserver.php
--- a/src/app/Observers/UserObserver.php
+++ b/src/app/Observers/UserObserver.php
@@ -6,6 +6,7 @@
use App\Domain;
use App\Group;
use App\Resource;
+use App\SharedFolder;
use App\Transaction;
use App\User;
use App\Wallet;
@@ -149,6 +150,7 @@
$domains = [];
$groups = [];
$resources = [];
+ $folders = [];
$entitlements = [];
foreach ($assignments as $entitlement) {
@@ -160,6 +162,8 @@
$groups[] = $entitlement->entitleable_id;
} elseif ($entitlement->entitleable_type == Resource::class) {
$resources[] = $entitlement->entitleable_id;
+ } elseif ($entitlement->entitleable_type == SharedFolder::class) {
+ $folders[] = $entitlement->entitleable_id;
} else {
$entitlements[] = $entitlement;
}
@@ -191,6 +195,12 @@
}
}
+ if (!empty($folders)) {
+ foreach (SharedFolder::whereIn('id', array_unique($folders))->get() as $_folder) {
+ $_folder->delete();
+ }
+ }
+
foreach ($entitlements as $entitlement) {
$entitlement->delete();
}
@@ -222,6 +232,7 @@
$domains = [];
$groups = [];
$resources = [];
+ $folders = [];
$users = [];
foreach ($assignments as $entitlement) {
@@ -238,6 +249,8 @@
$groups[] = $entitlement->entitleable_id;
} elseif ($entitlement->entitleable_type == Resource::class) {
$resources[] = $entitlement->entitleable_id;
+ } elseif ($entitlement->entitleable_type == SharedFolder::class) {
+ $folders[] = $entitlement->entitleable_id;
}
}
@@ -270,6 +283,11 @@
Resource::withTrashed()->whereIn('id', array_unique($resources))->forceDelete();
}
+ // Shared folders can be just removed
+ if (!empty($folders)) {
+ SharedFolder::withTrashed()->whereIn('id', array_unique($folders))->forceDelete();
+ }
+
// Remove transactions, they also have no foreign key constraint
Transaction::where('object_type', Entitlement::class)
->whereIn('object_id', $entitlements)
diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php
--- a/src/app/Providers/AppServiceProvider.php
+++ b/src/app/Providers/AppServiceProvider.php
@@ -53,6 +53,8 @@
\App\PlanPackage::observe(\App\Observers\PlanPackageObserver::class);
\App\Resource::observe(\App\Observers\ResourceObserver::class);
\App\ResourceSetting::observe(\App\Observers\ResourceSettingObserver::class);
+ \App\SharedFolder::observe(\App\Observers\SharedFolderObserver::class);
+ \App\SharedFolderSetting::observe(\App\Observers\SharedFolderSettingObserver::class);
\App\SignupCode::observe(\App\Observers\SignupCodeObserver::class);
\App\SignupInvitation::observe(\App\Observers\SignupInvitationObserver::class);
\App\Transaction::observe(\App\Observers\TransactionObserver::class);
diff --git a/src/app/Resource.php b/src/app/Resource.php
--- a/src/app/Resource.php
+++ b/src/app/Resource.php
@@ -48,8 +48,7 @@
'status',
];
- /**
- * @var ?string Domain name for a resource to be created */
+ /** @var ?string Domain name for a resource to be created */
public $domain;
diff --git a/src/app/Rules/ResourceName.php b/src/app/Rules/ResourceName.php
--- a/src/app/Rules/ResourceName.php
+++ b/src/app/Rules/ResourceName.php
@@ -12,6 +12,8 @@
private $owner;
private $domain;
+ private const FORBIDDEN_CHARS = '+/^%*!`@(){}|\\?<;"';
+
/**
* Class constructor.
*
@@ -39,13 +41,18 @@
return false;
}
+ if (strcspn($name, self::FORBIDDEN_CHARS) < strlen($name)) {
+ $this->message = \trans('validation.nameinvalid');
+ return false;
+ }
+
// Check the max length, according to the database column length
if (strlen($name) > 191) {
$this->message = \trans('validation.max.string', ['max' => 191]);
return false;
}
- // Check if specified domain is belongs to the user
+ // Check if specified domain belongs to the user
$domains = \collect($this->owner->domains(true, false))->pluck('namespace')->all();
if (!in_array($this->domain, $domains)) {
$this->message = \trans('validation.domaininvalid');
diff --git a/src/app/Rules/ResourceName.php b/src/app/Rules/SharedFolderName.php
copy from src/app/Rules/ResourceName.php
copy to src/app/Rules/SharedFolderName.php
--- a/src/app/Rules/ResourceName.php
+++ b/src/app/Rules/SharedFolderName.php
@@ -6,12 +6,14 @@
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
-class ResourceName implements Rule
+class SharedFolderName implements Rule
{
private $message;
private $owner;
private $domain;
+ private const FORBIDDEN_CHARS = '+/^%*!`@(){}|\\?<;"';
+
/**
* Class constructor.
*
@@ -28,13 +30,18 @@
* Determine if the validation rule passes.
*
* @param string $attribute Attribute name
- * @param mixed $name Resource name input
+ * @param mixed $name Shared folder name input
*
* @return bool
*/
public function passes($attribute, $name): bool
{
- if (empty($name) || !is_string($name)) {
+ if (empty($name) || !is_string($name) || $name == 'Resources') {
+ $this->message = \trans('validation.nameinvalid');
+ return false;
+ }
+
+ if (strcspn($name, self::FORBIDDEN_CHARS) < strlen($name)) {
$this->message = \trans('validation.nameinvalid');
return false;
}
@@ -45,7 +52,7 @@
return false;
}
- // Check if specified domain is belongs to the user
+ // Check if specified domain belongs to the user
$domains = \collect($this->owner->domains(true, false))->pluck('namespace')->all();
if (!in_array($this->domain, $domains)) {
$this->message = \trans('validation.domaininvalid');
@@ -53,10 +60,10 @@
}
// Check if the name is unique in the domain
- // FIXME: Maybe just using the whole resources table would be faster than resources()?
- $exists = $this->owner->resources()
- ->where('resources.name', $name)
- ->where('resources.email', 'like', '%@' . $this->domain)
+ // FIXME: Maybe just using the whole shared_folders table would be faster than sharedFolders()?
+ $exists = $this->owner->sharedFolders()
+ ->where('shared_folders.name', $name)
+ ->where('shared_folders.email', 'like', '%@' . $this->domain)
->exists();
if ($exists) {
diff --git a/src/app/Rules/SharedFolderType.php b/src/app/Rules/SharedFolderType.php
new file mode 100644
--- /dev/null
+++ b/src/app/Rules/SharedFolderType.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace App\Rules;
+
+use Illuminate\Contracts\Validation\Rule;
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Support\Str;
+
+class SharedFolderType implements Rule
+{
+ private $message;
+
+ /**
+ * Determine if the validation rule passes.
+ *
+ * @param string $attribute Attribute name
+ * @param mixed $type Shared folder type input
+ *
+ * @return bool
+ */
+ public function passes($attribute, $type): bool
+ {
+ if (empty($type) || !is_string($type) || !in_array($type, \App\SharedFolder::SUPPORTED_TYPES)) {
+ $this->message = \trans('validation.entryinvalid', ['attribute' => $attribute]);
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Get the validation error message.
+ *
+ * @return string
+ */
+ public function message(): ?string
+ {
+ return $this->message;
+ }
+}
diff --git a/src/app/Resource.php b/src/app/SharedFolder.php
copy from src/app/Resource.php
copy to src/app/SharedFolder.php
--- a/src/app/Resource.php
+++ b/src/app/SharedFolder.php
@@ -4,7 +4,7 @@
use App\Traits\BelongsToTenantTrait;
use App\Traits\EntitleableTrait;
-use App\Traits\ResourceConfigTrait;
+use App\Traits\SharedFolderConfigTrait;
use App\Traits\SettingsTrait;
use App\Traits\UuidIntKeyTrait;
use App\Wallet;
@@ -12,66 +12,71 @@
use Illuminate\Database\Eloquent\SoftDeletes;
/**
- * The eloquent definition of a Resource.
+ * The eloquent definition of a SharedFolder.
*
- * @property int $id The resource identifier
* @property string $email An email address
- * @property string $name The resource name
- * @property int $status The resource status
+ * @property int $id The folder identifier
+ * @property string $name The folder name
+ * @property int $status The folder status
* @property int $tenant_id Tenant identifier
+ * @property string $type The folder type
*/
-class Resource extends Model
+class SharedFolder extends Model
{
use BelongsToTenantTrait;
use EntitleableTrait;
- use ResourceConfigTrait;
+ use SharedFolderConfigTrait;
use SettingsTrait;
use SoftDeletes;
use UuidIntKeyTrait;
- // we've simply never heard of this resource
+ // we've simply never heard of this folder
public const STATUS_NEW = 1 << 0;
- // resource has been activated
+ // folder has been activated
public const STATUS_ACTIVE = 1 << 1;
- // resource has been suspended.
+ // folder has been suspended.
// public const STATUS_SUSPENDED = 1 << 2;
- // resource has been deleted
+ // folder has been deleted
public const STATUS_DELETED = 1 << 3;
- // resource has been created in LDAP
+ // folder has been created in LDAP
public const STATUS_LDAP_READY = 1 << 4;
- // resource has been created in IMAP
+ // folder has been created in IMAP
public const STATUS_IMAP_READY = 1 << 8;
+ /** @const array Supported folder type labels */
+ public const SUPPORTED_TYPES = ['mail', 'event', 'contact', 'task', 'note'];
+
+ /** @var array Mass-assignable properties */
protected $fillable = [
'email',
'name',
'status',
+ 'type',
];
- /**
- * @var ?string Domain name for a resource to be created */
+ /** @var ?string Domain name for a shared folder to be created */
public $domain;
/**
- * Assign the resource to a wallet.
+ * Assign the folder to a wallet.
*
* @param \App\Wallet $wallet The wallet
*
- * @return \App\Resource Self
+ * @return \App\SharedFolder Self
* @throws \Exception
*/
- public function assignToWallet(Wallet $wallet): Resource
+ public function assignToWallet(Wallet $wallet): SharedFolder
{
if (empty($this->id)) {
- throw new \Exception("Resource not yet exists");
+ throw new \Exception("Shared folder not yet exists");
}
if ($this->entitlements()->count()) {
- throw new \Exception("Resource already assigned to a wallet");
+ throw new \Exception("Shared folder already assigned to a wallet");
}
- $sku = \App\Sku::withObjectTenantContext($this)->where('title', 'resource')->first();
+ $sku = \App\Sku::withObjectTenantContext($this)->where('title', 'shared_folder')->first();
$exists = $wallet->entitlements()->where('sku_id', $sku->id)->count();
\App\Entitlement::create([
@@ -80,16 +85,16 @@
'cost' => $exists >= $sku->units_free ? $sku->cost : 0,
'fee' => $exists >= $sku->units_free ? $sku->fee : 0,
'entitleable_id' => $this->id,
- 'entitleable_type' => Resource::class
+ 'entitleable_type' => SharedFolder::class
]);
return $this;
}
/**
- * Returns the resource domain.
+ * Returns the shared folder domain.
*
- * @return ?\App\Domain The domain to which the resource belongs to, NULL if it does not exist
+ * @return ?\App\Domain The domain to which the folder belongs to, NULL if it does not exist
*/
public function domain(): ?Domain
{
@@ -103,14 +108,14 @@
}
/**
- * Find whether an email address exists as a resource (including deleted resources).
+ * Find whether an email address exists as a shared folder (including deleted folders).
*
- * @param string $email Email address
- * @param bool $return_resource Return Resource instance instead of boolean
+ * @param string $email Email address
+ * @param bool $return_folder Return SharedFolder instance instead of boolean
*
- * @return \App\Resource|bool True or Resource model object if found, False otherwise
+ * @return \App\SharedFolder|bool True or Resource model object if found, False otherwise
*/
- public static function emailExists(string $email, bool $return_resource = false)
+ public static function emailExists(string $email, bool $return_folder = false)
{
if (strpos($email, '@') === false) {
return false;
@@ -118,17 +123,17 @@
$email = \strtolower($email);
- $resource = self::withTrashed()->where('email', $email)->first();
+ $folder = self::withTrashed()->where('email', $email)->first();
- if ($resource) {
- return $return_resource ? $resource : true;
+ if ($folder) {
+ return $return_folder ? $folder : true;
}
return false;
}
/**
- * Returns whether this resource is active.
+ * Returns whether this folder is active.
*
* @return bool
*/
@@ -138,7 +143,7 @@
}
/**
- * Returns whether this resource is deleted.
+ * Returns whether this folder is deleted.
*
* @return bool
*/
@@ -148,7 +153,7 @@
}
/**
- * Returns whether this resource's folder exists in IMAP.
+ * Returns whether this folder exists in IMAP.
*
* @return bool
*/
@@ -158,7 +163,7 @@
}
/**
- * Returns whether this resource is registered in LDAP.
+ * Returns whether this folder is registered in LDAP.
*
* @return bool
*/
@@ -168,7 +173,7 @@
}
/**
- * Returns whether this resource is new.
+ * Returns whether this folder is new.
*
* @return bool
*/
@@ -178,7 +183,7 @@
}
/**
- * Resource status mutator
+ * Folder status mutator
*
* @throws \Exception
*/
@@ -202,9 +207,23 @@
}
if ($status > 0) {
- throw new \Exception("Invalid resource status: {$status}");
+ throw new \Exception("Invalid shared folder status: {$status}");
}
$this->attributes['status'] = $new_status;
}
+
+ /**
+ * Folder type mutator
+ *
+ * @throws \Exception
+ */
+ public function setTypeAttribute($type)
+ {
+ if (!in_array($type, self::SUPPORTED_TYPES)) {
+ throw new \Exception("Invalid shared folder type: {$type}");
+ }
+
+ $this->attributes['type'] = $type;
+ }
}
diff --git a/src/app/SharedFolderSetting.php b/src/app/SharedFolderSetting.php
new file mode 100644
--- /dev/null
+++ b/src/app/SharedFolderSetting.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace App;
+
+use Illuminate\Database\Eloquent\Model;
+
+/**
+ * A collection of settings for a SharedFolder.
+ *
+ * @property int $id
+ * @property int $shared_folder_id
+ * @property string $key
+ * @property string $value
+ */
+class SharedFolderSetting extends Model
+{
+ protected $fillable = [
+ 'shared_folder_id', 'key', 'value'
+ ];
+
+ /**
+ * The folder to which this setting belongs.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function folder()
+ {
+ return $this->belongsTo(\App\SharedFolder::class, 'shared_folder_id', 'id');
+ }
+}
diff --git a/src/app/Traits/SharedFolderConfigTrait.php b/src/app/Traits/SharedFolderConfigTrait.php
new file mode 100644
--- /dev/null
+++ b/src/app/Traits/SharedFolderConfigTrait.php
@@ -0,0 +1,118 @@
+<?php
+
+namespace App\Traits;
+
+use Illuminate\Support\Facades\Validator;
+
+trait SharedFolderConfigTrait
+{
+ /**
+ * A helper to get a shared folder configuration.
+ */
+ public function getConfig(): array
+ {
+ $config = [];
+
+ $settings = $this->getSettings(['acl']);
+
+ $config['acl'] = !empty($settings['acl']) ? json_decode($settings['acl'], true) : [];
+
+ return $config;
+ }
+
+ /**
+ * A helper to update a shared folder configuration.
+ *
+ * @param array $config An array of configuration options
+ *
+ * @return array A list of input validation errors
+ */
+ public function setConfig(array $config): array
+ {
+ $errors = [];
+
+ foreach ($config as $key => $value) {
+ // validate and save the acl
+ if ($key === 'acl') {
+ // Here's the list of acl labels supported by kolabd
+ // 'all': 'lrsedntxakcpiw',
+ // 'append': 'wip',
+ // 'full': 'lrswipkxtecdn',
+ // 'read': 'lrs',
+ // 'read-only': 'lrs',
+ // 'read-write': 'lrswitedn',
+ // 'post': 'p',
+ // 'semi-full': 'lrswit',
+ // 'write': 'lrswite',
+ // For now we support read-only, read-write, and full
+
+ if (!is_array($value)) {
+ $value = (array) $value;
+ }
+
+ $users = [];
+
+ foreach ($value as $i => $v) {
+ if (!is_string($v) || empty($v) || !substr_count($v, ',')) {
+ $errors[$key][$i] = \trans('validation.acl-entry-invalid');
+ } else {
+ list($user, $acl) = explode(',', $v, 2);
+ $user = trim($user);
+ $acl = trim($acl);
+ $error = null;
+
+ if (
+ !in_array($acl, ['read-only', 'read-write', 'full'])
+ || ($error = $this->validateAclIdentifier($user))
+ || in_array($user, $users)
+ ) {
+ $errors[$key][$i] = $error ?: \trans('validation.acl-entry-invalid');
+ }
+
+ $value[$i] = "$user, $acl";
+ $users[] = $user;
+ }
+ }
+
+ if (empty($errors[$key])) {
+ $this->setSetting($key, json_encode($value));
+ }
+ } else {
+ $errors[$key] = \trans('validation.invalid-config-parameter');
+ }
+ }
+
+ return $errors;
+ }
+
+ /**
+ * Validate an ACL identifier.
+ *
+ * @param string $identifier Email address or a special identifier
+ *
+ * @return ?string Error message on validation error
+ */
+ protected function validateAclIdentifier(string $identifier): ?string
+ {
+ if ($identifier === 'anyone') {
+ return null;
+ }
+
+ $v = Validator::make(['email' => $identifier], ['email' => 'required|email']);
+
+ if ($v->fails()) {
+ return \trans('validation.emailinvalid');
+ }
+
+ $user = \App\User::where('email', \strtolower($identifier))->first();
+
+ // The user and shared folder must be in the same wallet
+ if ($user && ($wallet = $user->wallet())) {
+ if ($wallet->user_id == $this->wallet()->user_id) {
+ return null;
+ }
+ }
+
+ return \trans('validation.notalocaluser');
+ }
+}
diff --git a/src/app/User.php b/src/app/User.php
--- a/src/app/User.php
+++ b/src/app/User.php
@@ -591,6 +591,29 @@
->where('entitlements.entitleable_type', \App\Resource::class);
}
+ /**
+ * Return shared folders controlled by the current user.
+ *
+ * @param bool $with_accounts Include folders assigned to wallets
+ * the current user controls but not owns.
+ *
+ * @return \Illuminate\Database\Eloquent\Builder Query builder
+ */
+ public function sharedFolders($with_accounts = true)
+ {
+ $wallets = $this->wallets()->pluck('id')->all();
+
+ if ($with_accounts) {
+ $wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all());
+ }
+
+ return \App\SharedFolder::select(['shared_folders.*', 'entitlements.wallet_id'])
+ ->distinct()
+ ->join('entitlements', 'entitlements.entitleable_id', '=', 'shared_folders.id')
+ ->whereIn('entitlements.wallet_id', $wallets)
+ ->where('entitlements.entitleable_type', \App\SharedFolder::class);
+ }
+
public function senderPolicyFrameworkWhitelist($clientName)
{
$setting = $this->getSetting('spf_whitelist');
diff --git a/src/database/migrations/2021_11_25_100000_create_shared_folders_table.php b/src/database/migrations/2021_11_25_100000_create_shared_folders_table.php
new file mode 100644
--- /dev/null
+++ b/src/database/migrations/2021_11_25_100000_create_shared_folders_table.php
@@ -0,0 +1,82 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+// phpcs:ignore
+class CreateSharedFoldersTable extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::create(
+ 'shared_folders',
+ function (Blueprint $table) {
+ $table->unsignedBigInteger('id');
+ $table->string('email')->unique();
+ $table->string('name');
+ $table->string('type', 8);
+ $table->smallInteger('status');
+ $table->unsignedBigInteger('tenant_id')->nullable();
+
+ $table->timestamps();
+ $table->softDeletes();
+
+ $table->primary('id');
+ $table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('set null');
+ }
+ );
+
+ Schema::create(
+ 'shared_folder_settings',
+ function (Blueprint $table) {
+ $table->bigIncrements('id');
+ $table->unsignedBigInteger('shared_folder_id');
+ $table->string('key');
+ $table->text('value');
+ $table->timestamps();
+
+ $table->foreign('shared_folder_id')->references('id')->on('shared_folders')
+ ->onDelete('cascade')->onUpdate('cascade');
+
+ $table->unique(['shared_folder_id', 'key']);
+ }
+ );
+
+ \App\Sku::where('title', 'shared_folder')->update([
+ 'active' => true,
+ 'cost' => 0,
+ ]);
+
+ if (!\App\Sku::where('title', 'beta-shared-folders')->first()) {
+ \App\Sku::create([
+ 'title' => 'beta-shared-folders',
+ 'name' => 'Shared folders',
+ 'description' => 'Access to shared folders',
+ 'cost' => 0,
+ 'units_free' => 0,
+ 'period' => 'monthly',
+ 'handler_class' => 'App\Handlers\Beta\SharedFolders',
+ 'active' => true,
+ ]);
+ }
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::dropIfExists('shared_folder_settings');
+ Schema::dropIfExists('shared_folders');
+
+ // there's no need to remove the SKU
+ }
+}
diff --git a/src/database/seeds/DatabaseSeeder.php b/src/database/seeds/DatabaseSeeder.php
--- a/src/database/seeds/DatabaseSeeder.php
+++ b/src/database/seeds/DatabaseSeeder.php
@@ -26,6 +26,7 @@
'OpenViduRoomSeeder',
'OauthClientSeeder',
'ResourceSeeder',
+ 'SharedFolderSeeder',
];
$env = ucfirst(App::environment());
diff --git a/src/database/seeds/local/SharedFolderSeeder.php b/src/database/seeds/local/SharedFolderSeeder.php
new file mode 100644
--- /dev/null
+++ b/src/database/seeds/local/SharedFolderSeeder.php
@@ -0,0 +1,35 @@
+<?php
+
+namespace Database\Seeds\Local;
+
+use App\SharedFolder;
+use App\User;
+use Illuminate\Database\Seeder;
+
+class SharedFolderSeeder extends Seeder
+{
+ /**
+ * Run the database seeds.
+ *
+ * @return void
+ */
+ public function run()
+ {
+ $john = User::where('email', 'john@kolab.org')->first();
+ $wallet = $john->wallets()->first();
+
+ $folder = SharedFolder::create([
+ 'name' => 'Calendar',
+ 'email' => 'folder-event@kolab.org',
+ 'type' => 'event',
+ ]);
+ $folder->assignToWallet($wallet);
+
+ $folder = SharedFolder::create([
+ 'name' => 'Contacts',
+ 'email' => 'folder-contact@kolab.org',
+ 'type' => 'contact',
+ ]);
+ $folder->assignToWallet($wallet);
+ }
+}
diff --git a/src/database/seeds/local/SkuSeeder.php b/src/database/seeds/local/SkuSeeder.php
--- a/src/database/seeds/local/SkuSeeder.php
+++ b/src/database/seeds/local/SkuSeeder.php
@@ -122,7 +122,7 @@
'cost' => 89,
'period' => 'monthly',
'handler_class' => 'App\Handlers\SharedFolder',
- 'active' => false,
+ 'active' => true,
]
);
@@ -240,6 +240,22 @@
]);
}
+ // Check existence because migration might have added this already
+ $sku = Sku::where(['title' => 'beta-shared-folders', 'tenant_id' => \config('app.tenant_id')])->first();
+
+ if (!$sku) {
+ Sku::create([
+ 'title' => 'beta-shared-folders',
+ 'name' => 'Shared folders',
+ 'description' => 'Access to shared folders',
+ 'cost' => 0,
+ 'units_free' => 0,
+ 'period' => 'monthly',
+ 'handler_class' => 'App\Handlers\Beta\SharedFolders',
+ 'active' => true,
+ ]);
+ }
+
// for tenants that are not the configured tenant id
$tenants = \App\Tenant::where('id', '!=', \config('app.tenant_id'))->get();
diff --git a/src/database/seeds/production/SkuSeeder.php b/src/database/seeds/production/SkuSeeder.php
--- a/src/database/seeds/production/SkuSeeder.php
+++ b/src/database/seeds/production/SkuSeeder.php
@@ -227,5 +227,19 @@
'active' => true,
]);
}
+
+ // Check existence because migration might have added this already
+ if (!Sku::where('title', 'beta-shared-folders')->first()) {
+ Sku::create([
+ 'title' => 'beta-shared-folders',
+ 'name' => 'Shared folders',
+ 'description' => 'Access to shared folders',
+ 'cost' => 0,
+ 'units_free' => 0,
+ 'period' => 'monthly',
+ 'handler_class' => 'App\Handlers\Beta\SharedFolders',
+ 'active' => true,
+ ]);
+ }
}
}
diff --git a/src/phpstan.neon b/src/phpstan.neon
--- a/src/phpstan.neon
+++ b/src/phpstan.neon
@@ -12,4 +12,5 @@
processTimeout: 300.0
paths:
- app/
+ - config/
- tests/
diff --git a/src/resources/js/admin/routes.js b/src/resources/js/admin/routes.js
--- a/src/resources/js/admin/routes.js
+++ b/src/resources/js/admin/routes.js
@@ -5,6 +5,7 @@
import LogoutComponent from '../../vue/Logout'
import PageComponent from '../../vue/Page'
import ResourceComponent from '../../vue/Admin/Resource'
+import SharedFolderComponent from '../../vue/Admin/SharedFolder'
import StatsComponent from '../../vue/Admin/Stats'
import UserComponent from '../../vue/Admin/User'
@@ -47,6 +48,12 @@
component: ResourceComponent,
meta: { requiresAuth: true }
},
+ {
+ path: '/shared-folder/:folder',
+ name: 'shared-folder',
+ component: SharedFolderComponent,
+ meta: { requiresAuth: true }
+ },
{
path: '/stats',
name: 'stats',
diff --git a/src/resources/js/app.js b/src/resources/js/app.js
--- a/src/resources/js/app.js
+++ b/src/resources/js/app.js
@@ -351,6 +351,12 @@
return this.$t('status.active')
},
+ folderStatusClass(folder) {
+ return this.userStatusClass(folder)
+ },
+ folderStatusText(folder) {
+ return this.userStatusText(folder)
+ },
pageName(path) {
let page = this.$route.path
diff --git a/src/resources/js/fontawesome.js b/src/resources/js/fontawesome.js
--- a/src/resources/js/fontawesome.js
+++ b/src/resources/js/fontawesome.js
@@ -15,6 +15,7 @@
faComments,
faDownload,
faEnvelope,
+ faFolderOpen,
faGlobe,
faUniversity,
faExclamationCircle,
@@ -50,6 +51,7 @@
faDownload,
faEnvelope,
faExclamationCircle,
+ faFolderOpen,
faGlobe,
faInfoCircle,
faLock,
diff --git a/src/resources/js/reseller/routes.js b/src/resources/js/reseller/routes.js
--- a/src/resources/js/reseller/routes.js
+++ b/src/resources/js/reseller/routes.js
@@ -6,6 +6,7 @@
import LogoutComponent from '../../vue/Logout'
import PageComponent from '../../vue/Page'
import ResourceComponent from '../../vue/Admin/Resource'
+import SharedFolderComponent from '../../vue/Admin/SharedFolder'
import StatsComponent from '../../vue/Reseller/Stats'
import UserComponent from '../../vue/Admin/User'
import WalletComponent from '../../vue/Wallet'
@@ -55,6 +56,12 @@
component: ResourceComponent,
meta: { requiresAuth: true }
},
+ {
+ path: '/shared-folder/:folder',
+ name: 'shared-folder',
+ component: SharedFolderComponent,
+ meta: { requiresAuth: true }
+ },
{
path: '/stats',
name: 'stats',
diff --git a/src/resources/js/user/routes.js b/src/resources/js/user/routes.js
--- a/src/resources/js/user/routes.js
+++ b/src/resources/js/user/routes.js
@@ -10,6 +10,8 @@
import PasswordResetComponent from '../../vue/PasswordReset'
import ResourceInfoComponent from '../../vue/Resource/Info'
import ResourceListComponent from '../../vue/Resource/List'
+import SharedFolderInfoComponent from '../../vue/SharedFolder/Info'
+import SharedFolderListComponent from '../../vue/SharedFolder/List'
import SignupComponent from '../../vue/Signup'
import UserInfoComponent from '../../vue/User/Info'
import UserListComponent from '../../vue/User/List'
@@ -104,6 +106,18 @@
component: MeetComponent,
meta: { requiresAuth: true }
},
+ {
+ path: '/shared-folder/:folder',
+ name: 'shared-folder',
+ component: SharedFolderInfoComponent,
+ meta: { requiresAuth: true, perm: 'folders' }
+ },
+ {
+ path: '/shared-folders',
+ name: 'shared-folders',
+ component: SharedFolderListComponent,
+ meta: { requiresAuth: true, perm: 'folders' }
+ },
{
path: '/signup/invite/:param',
name: 'signup-invite',
diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php
--- a/src/resources/lang/en/app.php
+++ b/src/resources/lang/en/app.php
@@ -39,6 +39,8 @@
'process-error-domain-confirmed' => 'Failed to verify an ownership of a domain.',
'process-error-resource-imap-ready' => 'Failed to verify that a shared folder exists.',
'process-error-resource-ldap-ready' => 'Failed to create a resource.',
+ 'process-error-shared-folder-imap-ready' => 'Failed to verify that a shared folder exists.',
+ 'process-error-shared-folder-ldap-ready' => 'Failed to create a shared folder.',
'process-error-user-ldap-ready' => 'Failed to create a user.',
'process-error-user-imap-ready' => 'Failed to verify that a mailbox exists.',
'process-distlist-new' => 'Registering a distribution list...',
@@ -46,6 +48,9 @@
'process-resource-new' => 'Registering a resource...',
'process-resource-imap-ready' => 'Creating a shared folder...',
'process-resource-ldap-ready' => 'Creating a resource...',
+ 'process-shared-folder-new' => 'Registering a shared folder...',
+ 'process-shared-folder-imap-ready' => 'Creating a shared folder...',
+ 'process-shared-folder-ldap-ready' => 'Creating a shared folder...',
'distlist-update-success' => 'Distribution list updated successfully.',
'distlist-create-success' => 'Distribution list created successfully.',
@@ -68,6 +73,11 @@
'resource-delete-success' => 'Resource deleted successfully.',
'resource-setconfig-success' => 'Resource settings updated successfully.',
+ 'shared-folder-update-success' => 'Shared folder updated successfully.',
+ 'shared-folder-create-success' => 'Shared folder created successfully.',
+ 'shared-folder-delete-success' => 'Shared folder deleted successfully.',
+ 'shared-folder-setconfig-success' => 'Shared folder settings updated successfully.',
+
'user-update-success' => 'User data updated successfully.',
'user-create-success' => 'User created successfully.',
'user-delete-success' => 'User deleted successfully.',
@@ -81,6 +91,7 @@
'search-foundxdomains' => ':x domains have been found.',
'search-foundxdistlists' => ':x distribution lists have been found.',
'search-foundxresources' => ':x resources have been found.',
+ 'search-foundxsharedfolders' => ':x shared folders have been found.',
'search-foundxusers' => ':x user accounts have been found.',
'signup-invitations-created' => 'The invitation has been created.|:count invitations has been created.',
diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php
--- a/src/resources/lang/en/ui.php
+++ b/src/resources/lang/en/ui.php
@@ -45,6 +45,7 @@
'invitations' => "Invitations",
'profile' => "Your profile",
'resources' => "Resources",
+ 'shared-folders' => "Shared folders",
'users' => "User accounts",
'wallet' => "Wallet",
'webmail' => "Webmail",
@@ -107,7 +108,12 @@
],
'form' => [
+ 'acl' => "Access rights",
+ 'acl-full' => "All",
+ 'acl-read-only' => "Read-only",
+ 'acl-read-write' => "Read-write",
'amount' => "Amount",
+ 'anyone' => "Anyone",
'code' => "Confirmation Code",
'config' => "Configuration",
'date' => "Date",
@@ -130,6 +136,7 @@
'shared-folder' => "Shared Folder",
'status' => "Status",
'surname' => "Surname",
+ 'type' => "Type",
'user' => "User",
'primary-email' => "Primary Email",
'id' => "ID",
@@ -307,6 +314,21 @@
'new' => "New resource",
],
+ 'shf' => [
+ 'create' => "Create folder",
+ 'delete' => "Delete folder",
+ 'acl-text' => "Defines user permissions to access the shared folder.",
+ 'list-title' => "Shared folder | Shared folders",
+ 'list-empty' => "There are no shared folders in this account.",
+ 'new' => "New shared folder",
+ 'type-mail' => "Mail",
+ 'type-event' => "Calendar",
+ 'type-contact' => "Address Book",
+ 'type-task' => "Tasks",
+ 'type-note' => "Notes",
+ 'type-file' => "Files",
+ ],
+
'signup' => [
'email' => "Existing Email Address",
'login' => "Login",
@@ -322,6 +344,7 @@
'prepare-domain' => "We are preparing the domain.",
'prepare-distlist' => "We are preparing the distribution list.",
'prepare-resource' => "We are preparing the resource.",
+ 'prepare-shared-folder' => "We are preparing the shared folder.",
'prepare-user' => "We are preparing the user account.",
'prepare-hint' => "Some features may be missing or readonly at the moment.",
'prepare-refresh' => "The process never ends? Press the \"Refresh\" button, please.",
@@ -329,6 +352,7 @@
'ready-domain' => "The domain is almost ready.",
'ready-distlist' => "The distribution list is almost ready.",
'ready-resource' => "The resource is almost ready.",
+ 'ready-shared-folder' => "The shared-folder is almost ready.",
'ready-user' => "The user account is almost ready.",
'verify' => "Verify your domain to finish the setup process.",
'verify-domain' => "Verify domain",
diff --git a/src/resources/lang/en/validation.php b/src/resources/lang/en/validation.php
--- a/src/resources/lang/en/validation.php
+++ b/src/resources/lang/en/validation.php
@@ -140,6 +140,7 @@
'listmembersrequired' => 'At least one recipient is required.',
'spf-entry-invalid' => 'The entry format is invalid. Expected a domain name starting with a dot.',
'sp-entry-invalid' => 'The entry format is invalid. Expected an email, domain, or part of it.',
+ 'acl-entry-invalid' => 'The entry format is invalid. Expected an email address.',
'ipolicy-invalid' => 'The specified invitation policy is invalid.',
'invalid-config-parameter' => 'The requested configuration parameter is not supported.',
'nameexists' => 'The specified name is not available.',
diff --git a/src/resources/themes/forms.scss b/src/resources/themes/forms.scss
--- a/src/resources/themes/forms.scss
+++ b/src/resources/themes/forms.scss
@@ -28,6 +28,13 @@
}
}
+.acl-input {
+ select.acl,
+ select.mod-user {
+ max-width: fit-content;
+ }
+}
+
.range-input {
display: flex;
diff --git a/src/resources/vue/Admin/SharedFolder.vue b/src/resources/vue/Admin/SharedFolder.vue
new file mode 100644
--- /dev/null
+++ b/src/resources/vue/Admin/SharedFolder.vue
@@ -0,0 +1,91 @@
+<template>
+ <div v-if="folder.id" class="container">
+ <div class="card" id="folder-info">
+ <div class="card-body">
+ <div class="card-title">{{ folder.email }}</div>
+ <div class="card-text">
+ <form class="read-only short">
+ <div class="row plaintext">
+ <label for="folderid" class="col-sm-4 col-form-label">
+ {{ $t('form.id') }} <span class="text-muted">({{ $t('form.created') }})</span>
+ </label>
+ <div class="col-sm-8">
+ <span class="form-control-plaintext" id="folderid">
+ {{ folder.id }} <span class="text-muted">({{ folder.created_at }})</span>
+ </span>
+ </div>
+ </div>
+ <div class="row plaintext">
+ <label for="status" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label>
+ <div class="col-sm-8">
+ <span :class="$root.folderStatusClass(folder) + ' form-control-plaintext'" id="status">{{ $root.folderStatusText(folder) }}</span>
+ </div>
+ </div>
+ <div class="row plaintext">
+ <label for="name" class="col-sm-4 col-form-label">{{ $t('form.name') }}</label>
+ <div class="col-sm-8">
+ <span class="form-control-plaintext" id="name">{{ folder.name }}</span>
+ </div>
+ </div>
+ <div class="row plaintext">
+ <label for="type" class="col-sm-4 col-form-label">{{ $t('form.type') }}</label>
+ <div class="col-sm-8">
+ <span class="form-control-plaintext" id="type">{{ $t('shf.type-' + folder.type) }}</span>
+ </div>
+ </div>
+ </form>
+ </div>
+ </div>
+ </div>
+ <ul class="nav nav-tabs mt-3" role="tablist">
+ <li class="nav-item">
+ <a class="nav-link active" id="tab-settings" href="#folder-settings" role="tab" aria-controls="folder-settings" aria-selected="false" @click="$root.tab">
+ {{ $t('form.settings') }}
+ </a>
+ </li>
+ </ul>
+ <div class="tab-content">
+ <div class="tab-pane show active" id="folder-settings" role="tabpanel" aria-labelledby="tab-settings">
+ <div class="card-body">
+ <div class="card-text">
+ <form class="read-only short">
+ <div class="row plaintext">
+ <label for="acl" class="col-sm-4 col-form-label">{{ $t('form.acl') }}</label>
+ <div class="col-sm-8">
+ <span class="form-control-plaintext" id="acl">
+ <span v-if="folder.config.acl.length">
+ <span v-for="(entry, index) in folder.config.acl" :key="index">
+ {{ entry.replace(',', ':') }}<br>
+ </span>
+ </span>
+ <span v-else>{{ $t('form.none') }}</span>
+ </span>
+ </div>
+ </div>
+ </form>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+ export default {
+ data() {
+ return {
+ folder: { config: {} }
+ }
+ },
+ created() {
+ this.$root.startLoading()
+
+ axios.get('/api/v4/shared-folders/' + this.$route.params.folder)
+ .then(response => {
+ this.$root.stopLoading()
+ this.folder = response.data
+ })
+ .catch(this.$root.errorHandler)
+ }
+ }
+</script>
diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue
--- a/src/resources/vue/Admin/User.vue
+++ b/src/resources/vue/Admin/User.vue
@@ -122,9 +122,14 @@
{{ $t('user.resources') }} ({{ resources.length }})
</a>
</li>
+ <li class="nav-item">
+ <a class="nav-link" id="tab-shared-folders" href="#user-shared-folders" role="tab" aria-controls="user-shared-folders" aria-selected="false">
+ {{ $t('dashboard.shared-folders') }} ({{ folders.length }})
+ </a>
+ </li>
<li class="nav-item">
<a class="nav-link" id="tab-settings" href="#user-settings" role="tab" aria-controls="user-settings" aria-selected="false">
- Settings
+ {{ $t('form.settings') }}
</a>
</li>
</ul>
@@ -349,6 +354,36 @@
</div>
</div>
</div>
+ <div class="tab-pane" id="user-shared-folders" role="tabpanel" aria-labelledby="tab-shared-folders">
+ <div class="card-body">
+ <div class="card-text">
+ <table class="table table-sm table-hover mb-0">
+ <thead>
+ <tr>
+ <th scope="col">{{ $t('form.name') }}</th>
+ <th scope="col">{{ $t('form.type') }}</th>
+ <th scope="col">{{ $t('form.email') }}</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="folder in folders" :key="folder.id" @click="$root.clickRecord">
+ <td>
+ <svg-icon icon="folder-open" :class="$root.folderStatusClass(folder)" :title="$root.folderStatusText(folder)"></svg-icon>
+ <router-link :to="{ path: '/shared-folder/' + folder.id }">{{ folder.name }}</router-link>
+ </td>
+ <td>{{ $t('shf.type-' + folder.type) }}</td>
+ <td><router-link :to="{ path: '/shared-folder/' + folder.id }">{{ folder.email }}</router-link></td>
+ </tr>
+ </tbody>
+ <tfoot class="table-fake-body">
+ <tr>
+ <td colspan="3">{{ $t('shf.list-empty') }}</td>
+ </tr>
+ </tfoot>
+ </table>
+ </div>
+ </div>
+ </div>
<div class="tab-pane" id="user-settings" role="tabpanel" aria-labelledby="tab-settings">
<div class="card-body">
<div class="card-text">
@@ -492,6 +527,7 @@
discount_description: '',
discounts: [],
external_email: '',
+ folders: [],
has2FA: false,
hasBeta: false,
wallet: {},
@@ -604,6 +640,12 @@
.then(response => {
this.resources = response.data.list
})
+
+ // Fetch shared folders lists
+ axios.get('/api/v4/shared-folders?owner=' + user_id)
+ .then(response => {
+ this.folders = response.data.list
+ })
})
.catch(this.$root.errorHandler)
},
diff --git a/src/resources/vue/Dashboard.vue b/src/resources/vue/Dashboard.vue
--- a/src/resources/vue/Dashboard.vue
+++ b/src/resources/vue/Dashboard.vue
@@ -18,6 +18,9 @@
<router-link v-if="status.enableResources" class="card link-resources" :to="{ name: 'resources' }">
<svg-icon icon="cog"></svg-icon><span class="name">{{ $t('dashboard.resources') }}</span>
</router-link>
+ <router-link v-if="status.enableFolders" class="card link-shared-folders" :to="{ name: 'shared-folders' }">
+ <svg-icon icon="folder-open"></svg-icon><span class="name">{{ $t('dashboard.shared-folders') }}</span>
+ </router-link>
<router-link v-if="status.enableWallets" class="card link-wallet" :to="{ name: 'wallet' }">
<svg-icon icon="wallet"></svg-icon><span class="name">{{ $t('dashboard.wallet') }}</span>
<span v-if="balance < 0" class="badge bg-danger">{{ $root.price(balance, currency) }}</span>
diff --git a/src/resources/vue/Resource/Info.vue b/src/resources/vue/Resource/Info.vue
--- a/src/resources/vue/Resource/Info.vue
+++ b/src/resources/vue/Resource/Info.vue
@@ -43,7 +43,7 @@
<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" :value="_domain.namespace">{{ _domain.namespace }}</option>
+ <option v-for="_domain in domains" :key="_domain.id" :value="_domain.namespace">{{ _domain.namespace }}</option>
</select>
</div>
</div>
diff --git a/src/resources/vue/Resource/Info.vue b/src/resources/vue/SharedFolder/Info.vue
copy from src/resources/vue/Resource/Info.vue
copy to src/resources/vue/SharedFolder/Info.vue
--- a/src/resources/vue/Resource/Info.vue
+++ b/src/resources/vue/SharedFolder/Info.vue
@@ -1,16 +1,16 @@
<template>
<div class="container">
- <status-component v-if="resource_id !== 'new'" :status="status" @status-update="statusUpdate"></status-component>
+ <status-component v-if="folder_id !== 'new'" :status="status" @status-update="statusUpdate"></status-component>
- <div class="card" id="resource-info">
+ <div class="card" id="folder-info">
<div class="card-body">
- <div class="card-title" v-if="resource_id !== 'new'">
- {{ $tc('resource.list-title', 1) }}
- <button class="btn btn-outline-danger button-delete float-end" @click="deleteResource()" tag="button">
- <svg-icon icon="trash-alt"></svg-icon> {{ $t('resource.delete') }}
+ <div class="card-title" v-if="folder_id !== 'new'">
+ {{ $tc('shf.list-title', 1) }}
+ <button class="btn btn-outline-danger button-delete float-end" @click="deleteFolder()" tag="button">
+ <svg-icon icon="trash-alt"></svg-icon> {{ $t('shf.delete') }}
</button>
</div>
- <div class="card-title" v-if="resource_id === 'new'">{{ $t('resource.new') }}</div>
+ <div class="card-title" v-if="folder_id === 'new'">{{ $t('shf.new') }}</div>
<div class="card-text">
<ul class="nav nav-tabs mt-3" role="tablist">
<li class="nav-item">
@@ -18,7 +18,7 @@
{{ $t('form.general') }}
</a>
</li>
- <li v-if="resource_id !== 'new'" class="nav-item">
+ <li v-if="folder_id !== 'new'" class="nav-item">
<a class="nav-link" id="tab-settings" href="#settings" role="tab" aria-controls="settings" aria-selected="false" @click="$root.tab">
{{ $t('form.settings') }}
</a>
@@ -27,30 +27,38 @@
<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">
+ <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.resourceStatusClass(resource) + ' form-control-plaintext'" id="status">{{ $root.resourceStatusText(resource) }}</span>
+ <span :class="$root.folderStatusClass(folder) + ' form-control-plaintext'" id="status">{{ $root.folderStatusText(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="resource.name">
+ <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 class="col-sm-8">
- <select class="form-select" v-model="resource.domain">
- <option v-for="_domain in domains" :key="_domain" :value="_domain.namespace">{{ _domain.namespace }}</option>
+ <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 v-if="resource.email" class="row mb-3">
+ <div v-if="folder.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">
+ <input type="text" class="form-control" id="email" disabled v-model="folder.email">
</div>
</div>
<button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}</button>
@@ -59,18 +67,11 @@
<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>
+ <label for="acl-input" class="col-sm-4 col-form-label">{{ $t('form.acl') }}</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') }}
+ <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>
@@ -85,37 +86,34 @@
</template>
<script>
+ import AclInput from '../Widgets/AclInput'
import StatusComponent from '../Widgets/Status'
export default {
components: {
+ AclInput,
StatusComponent
},
data() {
return {
domains: [],
- resource_id: null,
- resource: { config: {} },
- status: {}
+ folder_id: null,
+ folder: { type: 'mail', config: {} },
+ status: {},
+ types: [ 'mail', 'event', 'task', 'contact', 'note', 'file' ]
}
},
created() {
- this.resource_id = this.$route.params.resource
+ this.folder_id = this.$route.params.folder
- if (this.resource_id != 'new') {
+ if (this.folder_id != 'new') {
this.$root.startLoading()
- axios.get('/api/v4/resources/' + this.resource_id)
+ axios.get('/api/v4/shared-folders/' + this.folder_id)
.then(response => {
this.$root.stopLoading()
- this.resource = response.data
+ this.folder = 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 {
@@ -125,7 +123,7 @@
.then(response => {
this.$root.stopLoading()
this.domains = response.data
- this.resource.domain = this.domains[0].namespace
+ this.folder.domain = this.domains[0].namespace
})
.catch(this.$root.errorHandler)
}
@@ -134,52 +132,42 @@
$('#name').focus()
},
methods: {
- deleteResource() {
- axios.delete('/api/v4/resources/' + this.resource_id)
+ 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: 'resources' })
+ this.$router.push({ name: 'shared-folders' })
}
})
},
- policyChange() {
- let select = $('#invitation_policy')
- select.parent()[select.val() == 'manual' ? 'addClass' : 'removeClass']('selected')
- },
- statusUpdate(resource) {
- this.resource = Object.assign({}, this.resource, resource)
+ statusUpdate(folder) {
+ this.folder = Object.assign({}, this.folder, folder)
},
submit() {
- this.$root.clearFormValidation($('#resource-info form'))
+ this.$root.clearFormValidation($('#folder-info form'))
let method = 'post'
- let location = '/api/v4/resources'
+ let location = '/api/v4/shared-folders'
- if (this.resource_id !== 'new') {
+ if (this.folder_id !== 'new') {
method = 'put'
- location += '/' + this.resource_id
+ location += '/' + this.folder_id
}
- const post = this.$root.pick(this.resource, ['id', 'name', 'domain'])
+ const post = this.$root.pick(this.folder, ['id', 'name', 'domain', 'type'])
axios[method](location, post)
.then(response => {
this.$toast.success(response.data.message)
- this.$router.push({ name: 'resources' })
+ this.$router.push({ name: 'shared-folders' })
})
},
submitSettings() {
this.$root.clearFormValidation($('#settings form'))
- let post = {...this.resource.config}
-
- if (post.invitation_policy == 'manual') {
- post.invitation_policy += ':' + post.owner
- }
-
- delete post.owner
+ let post = {...this.folder.config}
- axios.post('/api/v4/resources/' + this.resource_id + '/config', post)
+ axios.post('/api/v4/shared-folders/' + this.folder_id + '/config', post)
.then(response => {
this.$toast.success(response.data.message)
})
diff --git a/src/resources/vue/SharedFolder/List.vue b/src/resources/vue/SharedFolder/List.vue
new file mode 100644
--- /dev/null
+++ b/src/resources/vue/SharedFolder/List.vue
@@ -0,0 +1,60 @@
+<template>
+ <div class="container">
+ <div class="card" id="folder-list">
+ <div class="card-body">
+ <div class="card-title">
+ {{ $tc('shf.list-title', 2) }}
+ <router-link class="btn btn-success float-end create-folder" :to="{ path: 'shared-folder/new' }" tag="button">
+ <svg-icon icon="cog"></svg-icon> {{ $t('shf.create') }}
+ </router-link>
+ </div>
+ <div class="card-text">
+ <table class="table table-sm table-hover">
+ <thead>
+ <tr>
+ <th scope="col">{{ $t('form.name') }}</th>
+ <th scope="col">{{ $t('form.type') }}</th>
+ <th scope="col">{{ $t('form.email') }}</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="folder in folders" :key="folder.id" @click="$root.clickRecord">
+ <td>
+ <svg-icon icon="folder-open" :class="$root.folderStatusClass(folder)" :title="$root.folderStatusText(folder)"></svg-icon>
+ <router-link :to="{ path: 'shared-folder/' + folder.id }">{{ folder.name }}</router-link>
+ </td>
+ <td>{{ $t('shf.type-' + folder.type) }}</td>
+ <td><router-link :to="{ path: 'shared-folder/' + folder.id }">{{ folder.email }}</router-link></td>
+ </tr>
+ </tbody>
+ <tfoot class="table-fake-body">
+ <tr>
+ <td colspan="3">{{ $t('shf.list-empty') }}</td>
+ </tr>
+ </tfoot>
+ </table>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+ export default {
+ data() {
+ return {
+ folders: []
+ }
+ },
+ created() {
+ this.$root.startLoading()
+
+ axios.get('/api/v4/shared-folders')
+ .then(response => {
+ this.$root.stopLoading()
+ this.folders = response.data
+ })
+ .catch(this.$root.errorHandler)
+ }
+ }
+</script>
diff --git a/src/resources/vue/Widgets/AclInput.vue b/src/resources/vue/Widgets/AclInput.vue
new file mode 100644
--- /dev/null
+++ b/src/resources/vue/Widgets/AclInput.vue
@@ -0,0 +1,118 @@
+<template>
+ <div class="list-input acl-input" :id="id">
+ <div class="input-group">
+ <select class="form-select mod mod-user" @change="changeMod" v-model="mod">
+ <option value="user">{{ $t('form.user') }}</option>
+ <option value="anyone">{{ $t('form.anyone') }}</option>
+ </select>
+ <input :id="id + '-input'" type="text" class="form-control main-input" :placeholder="$t('form.email')" @keydown="keyDown">
+ <select class="form-select acl" v-model="perm">
+ <option v-for="t in types" :key="t" :value="t">{{ $t('form.acl-' + t) }}</option>
+ </select>
+ <a href="#" class="btn btn-outline-secondary" @click.prevent="addItem">
+ <svg-icon icon="plus"></svg-icon><span class="visually-hidden">{{ $t('btn.add') }}</span>
+ </a>
+ </div>
+ <div class="input-group" v-for="(item, index) in list" :key="index">
+ <input type="text" class="form-control" :value="aclIdent(item)" :readonly="aclIdent(item) == 'anyone'" :placeholder="$t('form.email')">
+ <select class="form-select acl">
+ <option v-for="t in types" :key="t" :value="t" :selected="aclPerm(item) == t">{{ $t('form.acl-' + t) }}</option>
+ </select>
+ <a href="#" class="btn btn-outline-secondary" @click.prevent="deleteItem(index)">
+ <svg-icon icon="trash-alt"></svg-icon><span class="visually-hidden">{{ $t('btn.delete') }}</span>
+ </a>
+ </div>
+ </div>
+</template>
+
+<script>
+ export default {
+ props: {
+ list: { type: Array, default: () => [] },
+ id: { type: String, default: '' }
+ },
+ data() {
+ return {
+ mod: 'user',
+ perm: 'read-only',
+ types: [ 'read-only', 'read-write', 'full' ]
+ }
+ },
+ mounted() {
+ this.input = $(this.$el).find('.main-input')[0]
+ this.select = $(this.$el).find('select')[0]
+
+ // On form submit add the text from main input to the list
+ // Users tend to forget about pressing the "plus" button
+ // Note: We can't use form.onsubmit (too late)
+ // Note: Use of input.onblur has been proven to be problematic
+ // TODO: What with forms that have no submit button?
+ $(this.$el).closest('form').find('button[type=submit]').on('click', () => {
+ this.updateList()
+ this.addItem(false)
+ })
+ },
+ methods: {
+ aclIdent(item) {
+ return item.split(/\s*,\s*/)[0]
+ },
+ aclPerm(item) {
+ return item.split(/\s*,\s*/)[1]
+ },
+ addItem(focus) {
+ let value = this.input.value
+
+ if (value || this.mod == 'anyone') {
+ if (this.mod == 'anyone') {
+ value = 'anyone'
+ }
+
+ this.$set(this.list, this.list.length, value + ', ' + this.perm)
+
+ this.input.classList.remove('is-invalid')
+ this.input.value = ''
+ this.mod = 'user'
+ this.perm = 'read-only'
+ this.changeMod()
+
+ if (focus !== false) {
+ this.input.focus()
+ }
+
+ if (this.list.length == 1) {
+ this.$el.classList.remove('is-invalid')
+ }
+
+ this.$emit('change', this.$el)
+ }
+ },
+ changeMod() {
+ $(this.input)[this.mod == 'user' ? 'removeClass' : 'addClass']('d-none')
+ $(this.input).prev()[this.mod == 'user' ? 'addClass' : 'removeClass']('mod-user')
+ },
+ deleteItem(index) {
+ this.updateList()
+ this.$delete(this.list, index)
+ this.$emit('change', this.$el)
+
+ if (!this.list.length) {
+ this.$el.classList.remove('is-invalid')
+ }
+ },
+ keyDown(e) {
+ if (e.which == 13 && e.target.value) {
+ this.addItem()
+ e.preventDefault()
+ }
+ },
+ updateList() {
+ // Update this.list to the current state of the html elements
+ $(this.$el).children('.input-group:not(:first-child)').each((index, elem) => {
+ const perm = $(elem).find('select.acl').val()
+ const value = $(elem).find('input').val()
+ this.$set(this.list, index, value + ', ' + perm)
+ })
+ }
+ }
+ }
+</script>
diff --git a/src/resources/vue/Widgets/Status.vue b/src/resources/vue/Widgets/Status.vue
--- a/src/resources/vue/Widgets/Status.vue
+++ b/src/resources/vue/Widgets/Status.vue
@@ -2,11 +2,7 @@
<div v-if="!state.isReady" id="status-box" :class="'p-4 mb-3 rounded process-' + className">
<div v-if="state.step != 'domain-confirmed'" class="d-flex align-items-start">
<p id="status-body" class="flex-grow-1">
- <span v-if="scope == 'dashboard'">{{ $t('status.prepare-account') }}</span>
- <span v-else-if="scope == 'domain'">{{ $t('status.prepare-domain') }}</span>
- <span v-else-if="scope == 'distlist'">{{ $t('status.prepare-distlist') }}</span>
- <span v-else-if="scope == 'resource'">{{ $t('status.prepare-resource') }}</span>
- <span v-else>{{ $t('status.prepare-user') }}</span>
+ <span>{{ $t('status.prepare-' + scopeLabel()) }}</span>
<br>
{{ $t('status.prepare-hint') }}
<br>
@@ -18,11 +14,7 @@
</div>
<div v-else class="d-flex align-items-start">
<p id="status-body" class="flex-grow-1">
- <span v-if="scope == 'dashboard'">{{ $t('status.ready-account') }}</span>
- <span v-else-if="scope == 'domain'">{{ $t('status.ready-domain') }}</span>
- <span v-else-if="scope == 'distlist'">{{ $t('status.ready-distlist') }}</span>
- <span v-else-if="scope == 'resource'">{{ $t('status.ready-resource') }}</span>
- <span v-else>{{ $t('status.ready-user') }}</span>
+ <span>{{ $t('status.ready-' + scopeLabel()) }}</span>
<br>
{{ $t('status.verify') }}
</p>
@@ -183,20 +175,23 @@
this.$emit('status-update', data)
},
getUrl() {
- let url
-
- switch (this.scope) {
- case 'dashboard':
- url = '/api/v4/users/' + this.$store.state.authInfo.id + '/status'
- break
- case 'distlist':
- url = '/api/v4/groups/' + this.$route.params.list + '/status'
- break
- default:
- url = '/api/v4/' + this.scope + 's/' + this.$route.params[this.scope] + '/status'
+ let scope = this.scope
+ let id = this.$route.params[scope]
+
+ if (scope == 'dashboard') {
+ id = this.$store.state.authInfo.id
+ scope = 'user'
+ } else if (scope =='distlist') {
+ id = this.$route.params.list
+ scope = 'group'
+ } else if (scope == 'shared-folder') {
+ id = this.$route.params.folder
}
- return url
+ return '/api/v4/' + scope + 's/' + id + '/status'
+ },
+ scopeLabel() {
+ return this.scope == 'dashboard' ? 'account' : this.scope
}
}
}
diff --git a/src/routes/api.php b/src/routes/api.php
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -84,6 +84,10 @@
Route::get('resources/{id}/status', 'API\V4\ResourcesController@status');
Route::post('resources/{id}/config', 'API\V4\ResourcesController@setConfig');
+ Route::apiResource('shared-folders', API\V4\SharedFoldersController::class);
+ Route::get('shared-folders/{id}/status', 'API\V4\SharedFoldersController@status');
+ Route::post('shared-folders/{id}/config', 'API\V4\SharedFoldersController@setConfig');
+
Route::apiResource('skus', API\V4\SkusController::class);
Route::apiResource('users', API\V4\UsersController::class);
@@ -190,6 +194,7 @@
Route::post('groups/{id}/unsuspend', 'API\V4\Admin\GroupsController@unsuspend');
Route::apiResource('resources', API\V4\Admin\ResourcesController::class);
+ Route::apiResource('shared-folders', API\V4\Admin\SharedFoldersController::class);
Route::apiResource('skus', API\V4\Admin\SkusController::class);
Route::apiResource('users', API\V4\Admin\UsersController::class);
Route::get('users/{id}/discounts', 'API\V4\Reseller\DiscountsController@userDiscounts');
@@ -237,6 +242,7 @@
Route::get('payments/has-pending', 'API\V4\Reseller\PaymentsController@hasPayments');
Route::apiResource('resources', API\V4\Reseller\ResourcesController::class);
+ Route::apiResource('shared-folders', API\V4\Reseller\SharedFoldersController::class);
Route::apiResource('skus', API\V4\Reseller\SkusController::class);
Route::apiResource('users', API\V4\Reseller\UsersController::class);
Route::get('users/{id}/discounts', 'API\V4\Reseller\DiscountsController@userDiscounts');
diff --git a/src/tests/Browser/Admin/ResourceTest.php b/src/tests/Browser/Admin/ResourceTest.php
--- a/src/tests/Browser/Admin/ResourceTest.php
+++ b/src/tests/Browser/Admin/ResourceTest.php
@@ -56,6 +56,9 @@
$user = $this->getTestUser('john@kolab.org');
$resource = $this->getTestResource('resource-test1@kolab.org');
$resource->setSetting('invitation_policy', 'accept');
+ $resource->status = Resource::STATUS_NEW | Resource::STATUS_ACTIVE
+ | Resource::STATUS_LDAP_READY | Resource::STATUS_IMAP_READY;
+ $resource->save();
$resource_page = new ResourcePage($resource->id);
$user_page = new UserPage($user->id);
diff --git a/src/tests/Browser/Admin/ResourceTest.php b/src/tests/Browser/Admin/SharedFolderTest.php
copy from src/tests/Browser/Admin/ResourceTest.php
copy to src/tests/Browser/Admin/SharedFolderTest.php
--- a/src/tests/Browser/Admin/ResourceTest.php
+++ b/src/tests/Browser/Admin/SharedFolderTest.php
@@ -2,17 +2,17 @@
namespace Tests\Browser\Admin;
-use App\Resource;
+use App\SharedFolder;
use Illuminate\Support\Facades\Queue;
use Tests\Browser;
use Tests\Browser\Components\Toast;
-use Tests\Browser\Pages\Admin\Resource as ResourcePage;
+use Tests\Browser\Pages\Admin\SharedFolder as SharedFolderPage;
use Tests\Browser\Pages\Admin\User as UserPage;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
use Tests\TestCaseDusk;
-class ResourceTest extends TestCaseDusk
+class SharedFolderTest extends TestCaseDusk
{
/**
* {@inheritDoc}
@@ -32,21 +32,21 @@
}
/**
- * Test resource info page (unauthenticated)
+ * Test shared folder info page (unauthenticated)
*/
- public function testResourceUnauth(): void
+ public function testSharedFolderUnauth(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$user = $this->getTestUser('john@kolab.org');
- $resource = $this->getTestResource('resource-test1@kolab.org');
+ $folder = $this->getTestSharedFolder('folder-event@kolab.org');
- $browser->visit('/resource/' . $resource->id)->on(new Home());
+ $browser->visit('/shared-folder/' . $folder->id)->on(new Home());
});
}
/**
- * Test resource info page
+ * Test shared folder info page
*/
public function testInfo(): void
{
@@ -54,42 +54,48 @@
$this->browse(function (Browser $browser) {
$user = $this->getTestUser('john@kolab.org');
- $resource = $this->getTestResource('resource-test1@kolab.org');
- $resource->setSetting('invitation_policy', 'accept');
+ $folder = $this->getTestSharedFolder('folder-event@kolab.org');
+ $folder->setConfig(['acl' => ['anyone, read-only', 'jack@kolab.org, read-write']]);
+ $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE
+ | SharedFolder::STATUS_LDAP_READY | SharedFolder::STATUS_IMAP_READY;
+ $folder->save();
- $resource_page = new ResourcePage($resource->id);
+ $folder_page = new SharedFolderPage($folder->id);
$user_page = new UserPage($user->id);
- // Goto the resource page
+ // Goto the folder page
$browser->visit(new Home())
->submitLogon('jeroen@jeroen.jeroen', \App\Utils::generatePassphrase(), true)
->on(new Dashboard())
->visit($user_page)
->on($user_page)
- ->click('@nav #tab-resources')
+ ->click('@nav #tab-shared-folders')
->pause(1000)
- ->click('@user-resources table tbody tr:first-child td:first-child a')
- ->on($resource_page)
- ->assertSeeIn('@resource-info .card-title', $resource->email)
- ->with('@resource-info form', function (Browser $browser) use ($resource) {
- $browser->assertElementsCount('.row', 3)
+ ->click('@user-shared-folders table tbody tr:first-child td:first-child a')
+ ->on($folder_page)
+ ->assertSeeIn('@folder-info .card-title', $folder->email)
+ ->with('@folder-info form', function (Browser $browser) use ($folder) {
+ $browser->assertElementsCount('.row', 4)
->assertSeeIn('.row:nth-child(1) label', 'ID (Created)')
- ->assertSeeIn('.row:nth-child(1) #resourceid', "{$resource->id} ({$resource->created_at})")
+ ->assertSeeIn('.row:nth-child(1) #folderid', "{$folder->id} ({$folder->created_at})")
->assertSeeIn('.row:nth-child(2) label', 'Status')
->assertSeeIn('.row:nth-child(2) #status.text-success', 'Active')
->assertSeeIn('.row:nth-child(3) label', 'Name')
- ->assertSeeIn('.row:nth-child(3) #name', $resource->name);
+ ->assertSeeIn('.row:nth-child(3) #name', $folder->name)
+ ->assertSeeIn('.row:nth-child(4) label', 'Type')
+ ->assertSeeIn('.row:nth-child(4) #type', 'Calendar');
})
->assertElementsCount('ul.nav-tabs', 1)
->assertSeeIn('ul.nav-tabs .nav-link', 'Settings')
- ->with('@resource-settings form', function (Browser $browser) {
+ ->with('@folder-settings form', function (Browser $browser) {
$browser->assertElementsCount('.row', 1)
- ->assertSeeIn('.row:nth-child(1) label', 'Invitation policy')
- ->assertSeeIn('.row:nth-child(1) #invitation_policy', 'accept');
+ ->assertSeeIn('.row:nth-child(1) label', 'Access rights')
+ ->assertSeeIn('.row:nth-child(1) #acl', 'anyone: read-only')
+ ->assertSeeIn('.row:nth-child(1) #acl', 'jack@kolab.org: read-write');
});
- // Test invalid resource identifier
- $browser->visit('/resource/abc')->assertErrorPage(404);
+ // Test invalid shared folder identifier
+ $browser->visit('/shared-folder/abc')->assertErrorPage(404);
});
}
}
diff --git a/src/tests/Browser/Admin/UserTest.php b/src/tests/Browser/Admin/UserTest.php
--- a/src/tests/Browser/Admin/UserTest.php
+++ b/src/tests/Browser/Admin/UserTest.php
@@ -116,7 +116,7 @@
// Some tabs are loaded in background, wait a second
$browser->pause(500)
- ->assertElementsCount('@nav a', 8);
+ ->assertElementsCount('@nav a', 9);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
@@ -177,6 +177,14 @@
->assertSeeIn('table tfoot tr td', 'There are no resources in this account.');
});
+ // Assert Shared folders tab
+ $browser->assertSeeIn('@nav #tab-shared-folders', 'Shared folders (0)')
+ ->click('@nav #tab-shared-folders')
+ ->with('@user-shared-folders', function (Browser $browser) {
+ $browser->assertElementsCount('table tbody tr', 0)
+ ->assertSeeIn('table tfoot tr td', 'There are no shared folders in this account.');
+ });
+
// Assert Settings tab
$browser->assertSeeIn('@nav #tab-settings', 'Settings')
->click('@nav #tab-settings')
@@ -240,7 +248,7 @@
// Some tabs are loaded in background, wait a second
$browser->pause(500)
- ->assertElementsCount('@nav a', 8);
+ ->assertElementsCount('@nav a', 9);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
@@ -279,6 +287,22 @@
->assertMissing('tfoot');
});
+ // Assert Users tab
+ $browser->assertSeeIn('@nav #tab-users', 'Users (4)')
+ ->click('@nav #tab-users')
+ ->with('@user-users table', function (Browser $browser) {
+ $browser->assertElementsCount('tbody tr', 4)
+ ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'jack@kolab.org')
+ ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success')
+ ->assertSeeIn('tbody tr:nth-child(2) td:first-child a', 'joe@kolab.org')
+ ->assertVisible('tbody tr:nth-child(2) td:first-child svg.text-success')
+ ->assertSeeIn('tbody tr:nth-child(3) td:first-child span', 'john@kolab.org')
+ ->assertVisible('tbody tr:nth-child(3) td:first-child svg.text-success')
+ ->assertSeeIn('tbody tr:nth-child(4) td:first-child a', 'ned@kolab.org')
+ ->assertVisible('tbody tr:nth-child(4) td:first-child svg.text-success')
+ ->assertMissing('tfoot');
+ });
+
// Assert Distribution lists tab
$browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (1)')
->click('@nav #tab-distlists')
@@ -302,20 +326,18 @@
->assertMissing('table tfoot');
});
- // Assert Users tab
- $browser->assertSeeIn('@nav #tab-users', 'Users (4)')
- ->click('@nav #tab-users')
- ->with('@user-users table', function (Browser $browser) {
- $browser->assertElementsCount('tbody tr', 4)
- ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'jack@kolab.org')
- ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success')
- ->assertSeeIn('tbody tr:nth-child(2) td:first-child a', 'joe@kolab.org')
- ->assertVisible('tbody tr:nth-child(2) td:first-child svg.text-success')
- ->assertSeeIn('tbody tr:nth-child(3) td:first-child span', 'john@kolab.org')
- ->assertVisible('tbody tr:nth-child(3) td:first-child svg.text-success')
- ->assertSeeIn('tbody tr:nth-child(4) td:first-child a', 'ned@kolab.org')
- ->assertVisible('tbody tr:nth-child(4) td:first-child svg.text-success')
- ->assertMissing('tfoot');
+ // Assert Shared folders tab
+ $browser->assertSeeIn('@nav #tab-shared-folders', 'Shared folders (2)')
+ ->click('@nav #tab-shared-folders')
+ ->with('@user-shared-folders', function (Browser $browser) {
+ $browser->assertElementsCount('table tbody tr', 2)
+ ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'Calendar')
+ ->assertSeeIn('table tbody tr:nth-child(1) td:nth-child(2)', 'Calendar')
+ ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', 'folder-event@kolab.org')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Contacts')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:nth-child(2)', 'Address Book')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', 'folder-contact@kolab.org')
+ ->assertMissing('table tfoot');
});
});
@@ -345,7 +367,8 @@
$page = new UserPage($ned->id);
$ned->setSetting('greylist_enabled', 'false');
- $browser->click('@user-users tbody tr:nth-child(4) td:first-child a')
+ $browser->click('@nav #tab-users')
+ ->click('@user-users tbody tr:nth-child(4) td:first-child a')
->on($page);
// Assert main info box content
@@ -357,7 +380,7 @@
// Some tabs are loaded in background, wait a second
$browser->pause(500)
- ->assertElementsCount('@nav a', 8);
+ ->assertElementsCount('@nav a', 9);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
@@ -425,6 +448,14 @@
->assertSeeIn('table tfoot tr td', 'There are no resources in this account.');
});
+ // We don't expect John's folders here
+ $browser->assertSeeIn('@nav #tab-shared-folders', 'Shared folders (0)')
+ ->click('@nav #tab-shared-folders')
+ ->with('@user-shared-folders', function (Browser $browser) {
+ $browser->assertElementsCount('table tbody tr', 0)
+ ->assertSeeIn('table tfoot tr td', 'There are no shared folders in this account.');
+ });
+
// Assert Settings tab
$browser->assertSeeIn('@nav #tab-settings', 'Settings')
->click('@nav #tab-settings')
diff --git a/src/tests/Browser/Components/AclInput.php b/src/tests/Browser/Components/AclInput.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/Components/AclInput.php
@@ -0,0 +1,141 @@
+<?php
+
+namespace Tests\Browser\Components;
+
+use Laravel\Dusk\Component as BaseComponent;
+use PHPUnit\Framework\Assert as PHPUnit;
+
+class AclInput extends BaseComponent
+{
+ protected $selector;
+
+
+ public function __construct($selector)
+ {
+ $this->selector = $selector;
+ }
+
+ /**
+ * Get the root selector for the component.
+ *
+ * @return string
+ */
+ public function selector()
+ {
+ return $this->selector;
+ }
+
+ /**
+ * Assert that the browser page contains the component.
+ *
+ * @param \Laravel\Dusk\Browser $browser
+ *
+ * @return void
+ */
+ public function assert($browser)
+ {
+ $browser->assertVisible($this->selector)
+ ->assertVisible("{$this->selector} @input")
+ ->assertVisible("{$this->selector} @add-btn")
+ ->assertSelectHasOptions("{$this->selector} @mod-select", ['user', 'anyone'])
+ ->assertSelectHasOptions("{$this->selector} @acl-select", ['read-only', 'read-write', 'full']);
+ }
+
+ /**
+ * Get the element shortcuts for the component.
+ *
+ * @return array
+ */
+ public function elements()
+ {
+ return [
+ '@add-btn' => '.input-group:first-child a.btn',
+ '@input' => '.input-group:first-child input',
+ '@acl-select' => '.input-group:first-child select.acl',
+ '@mod-select' => '.input-group:first-child select.mod',
+ ];
+ }
+
+ /**
+ * Assert acl input content
+ */
+ public function assertAclValue($browser, array $list)
+ {
+ if (empty($list)) {
+ $browser->assertMissing('.input-group:not(:first-child)');
+ return;
+ }
+
+ foreach ($list as $idx => $value) {
+ $selector = '.input-group:nth-child(' . ($idx + 2) . ')';
+ list($ident, $acl) = preg_split('/\s*,\s*/', $value);
+
+ $input = $ident == 'anyone' ? 'input:read-only' : 'input:not(:read-only)';
+
+ $browser->assertVisible("$selector $input")
+ ->assertVisible("$selector select")
+ ->assertVisible("$selector a.btn")
+ ->assertValue("$selector $input", $ident)
+ ->assertSelected("$selector select", $acl);
+ }
+ }
+
+ /**
+ * Add acl entry
+ */
+ public function addAclEntry($browser, string $value)
+ {
+ list($ident, $acl) = preg_split('/\s*,\s*/', $value);
+
+ $browser->select('@mod-select', $ident == 'anyone' ? 'anyone' : 'user')
+ ->select('@acl-select', $acl);
+
+ if ($ident == 'anyone') {
+ $browser->assertValue('@input', '')->assertMissing('@input');
+ } else {
+ $browser->type('@input', $ident);
+ }
+
+ $browser->click('@add-btn')
+ ->assertSelected('@mod-select', 'user')
+ ->assertSelected('@acl-select', 'read-only')
+ ->assertValue('@input', '');
+ }
+
+ /**
+ * Remove acl entry
+ */
+ public function removeAclEntry($browser, int $num)
+ {
+ $selector = '.input-group:nth-child(' . ($num + 1) . ') a.btn';
+ $browser->click($selector);
+ }
+
+ /**
+ * Update acl entry
+ */
+ public function updateAclEntry($browser, int $num, $value)
+ {
+ list($ident, $acl) = preg_split('/\s*,\s*/', $value);
+
+ $selector = '.input-group:nth-child(' . ($num + 1) . ')';
+
+ $browser->select("$selector select.acl", $acl)
+ ->type("$selector input", $ident);
+ }
+
+ /**
+ * Assert an error message on the widget
+ */
+ public function assertFormError($browser, int $num, string $msg, bool $focused = false)
+ {
+ $selector = '.input-group:nth-child(' . ($num + 1) . ') input.is-invalid';
+
+ $browser->waitFor($selector)
+ ->assertSeeIn(' + .invalid-feedback', $msg);
+
+ if ($focused) {
+ $browser->assertFocused($selector);
+ }
+ }
+}
diff --git a/src/tests/Browser/Pages/Admin/SharedFolder.php b/src/tests/Browser/Pages/Admin/SharedFolder.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/Pages/Admin/SharedFolder.php
@@ -0,0 +1,57 @@
+<?php
+
+namespace Tests\Browser\Pages\Admin;
+
+use Laravel\Dusk\Page;
+
+class SharedFolder extends Page
+{
+ protected $folderId;
+
+ /**
+ * Object constructor.
+ *
+ * @param int $id Shared folder Id
+ */
+ public function __construct($id)
+ {
+ $this->folderId = $id;
+ }
+
+ /**
+ * Get the URL for the page.
+ *
+ * @return string
+ */
+ public function url(): string
+ {
+ return '/shared-folder/' . $this->folderId;
+ }
+
+ /**
+ * Assert that the browser is on the page.
+ *
+ * @param \Laravel\Dusk\Browser $browser The browser object
+ *
+ * @return void
+ */
+ public function assert($browser): void
+ {
+ $browser->waitForLocation($this->url())
+ ->waitFor('@folder-info');
+ }
+
+ /**
+ * Get the element shortcuts for the page.
+ *
+ * @return array
+ */
+ public function elements(): array
+ {
+ return [
+ '@app' => '#app',
+ '@folder-info' => '#folder-info',
+ '@folder-settings' => '#folder-settings',
+ ];
+ }
+}
diff --git a/src/tests/Browser/Pages/Admin/User.php b/src/tests/Browser/Pages/Admin/User.php
--- a/src/tests/Browser/Pages/Admin/User.php
+++ b/src/tests/Browser/Pages/Admin/User.php
@@ -59,6 +59,7 @@
'@user-distlists' => '#user-distlists',
'@user-domains' => '#user-domains',
'@user-resources' => '#user-resources',
+ '@user-shared-folders' => '#user-shared-folders',
'@user-users' => '#user-users',
'@user-settings' => '#user-settings',
];
diff --git a/src/tests/Browser/Pages/SharedFolderInfo.php b/src/tests/Browser/Pages/SharedFolderInfo.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/Pages/SharedFolderInfo.php
@@ -0,0 +1,47 @@
+<?php
+
+namespace Tests\Browser\Pages;
+
+use Laravel\Dusk\Page;
+
+class SharedFolderInfo extends Page
+{
+ /**
+ * Get the URL for the page.
+ *
+ * @return string
+ */
+ public function url(): string
+ {
+ return '';
+ }
+
+ /**
+ * Assert that the browser is on the page.
+ *
+ * @param \Laravel\Dusk\Browser $browser The browser object
+ *
+ * @return void
+ */
+ public function assert($browser)
+ {
+ $browser->waitFor('@general')
+ ->waitUntilMissing('.app-loader');
+ }
+
+ /**
+ * Get the element shortcuts for the page.
+ *
+ * @return array
+ */
+ public function elements(): array
+ {
+ return [
+ '@app' => '#app',
+ '@general' => '#general',
+ '@nav' => 'ul.nav-tabs',
+ '@settings' => '#settings',
+ '@status' => '#status-box',
+ ];
+ }
+}
diff --git a/src/tests/Browser/Pages/SharedFolderList.php b/src/tests/Browser/Pages/SharedFolderList.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/Pages/SharedFolderList.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace Tests\Browser\Pages;
+
+use Laravel\Dusk\Page;
+
+class SharedFolderList extends Page
+{
+ /**
+ * Get the URL for the page.
+ *
+ * @return string
+ */
+ public function url(): string
+ {
+ return '/shared-folders';
+ }
+
+ /**
+ * Assert that the browser is on the page.
+ *
+ * @param \Laravel\Dusk\Browser $browser The browser object
+ *
+ * @return void
+ */
+ public function assert($browser)
+ {
+ $browser->assertPathIs($this->url())
+ ->waitUntilMissing('@app .app-loader')
+ ->assertSeeIn('#folder-list .card-title', 'Shared folders');
+ }
+
+ /**
+ * Get the element shortcuts for the page.
+ *
+ * @return array
+ */
+ public function elements(): array
+ {
+ return [
+ '@app' => '#app',
+ '@table' => '#folder-list table',
+ ];
+ }
+}
diff --git a/src/tests/Browser/Reseller/ResourceTest.php b/src/tests/Browser/Reseller/ResourceTest.php
--- a/src/tests/Browser/Reseller/ResourceTest.php
+++ b/src/tests/Browser/Reseller/ResourceTest.php
@@ -56,6 +56,9 @@
$user = $this->getTestUser('john@kolab.org');
$resource = $this->getTestResource('resource-test1@kolab.org');
$resource->setSetting('invitation_policy', 'accept');
+ $resource->status = Resource::STATUS_NEW | Resource::STATUS_ACTIVE
+ | Resource::STATUS_LDAP_READY | Resource::STATUS_IMAP_READY;
+ $resource->save();
$resource_page = new ResourcePage($resource->id);
$user_page = new UserPage($user->id);
diff --git a/src/tests/Browser/Reseller/ResourceTest.php b/src/tests/Browser/Reseller/SharedFolderTest.php
copy from src/tests/Browser/Reseller/ResourceTest.php
copy to src/tests/Browser/Reseller/SharedFolderTest.php
--- a/src/tests/Browser/Reseller/ResourceTest.php
+++ b/src/tests/Browser/Reseller/SharedFolderTest.php
@@ -2,17 +2,17 @@
namespace Tests\Browser\Reseller;
-use App\Resource;
+use App\SharedFolder;
use Illuminate\Support\Facades\Queue;
use Tests\Browser;
use Tests\Browser\Components\Toast;
-use Tests\Browser\Pages\Admin\Resource as ResourcePage;
+use Tests\Browser\Pages\Admin\SharedFolder as SharedFolderPage;
use Tests\Browser\Pages\Admin\User as UserPage;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
use Tests\TestCaseDusk;
-class ResourceTest extends TestCaseDusk
+class SharedFolderTest extends TestCaseDusk
{
/**
* {@inheritDoc}
@@ -32,21 +32,21 @@
}
/**
- * Test resource info page (unauthenticated)
+ * Test shared folder info page (unauthenticated)
*/
- public function testResourceUnauth(): void
+ public function testSharedFolderUnauth(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$user = $this->getTestUser('john@kolab.org');
- $resource = $this->getTestResource('resource-test1@kolab.org');
+ $folder = $this->getTestSharedFolder('folder-event@kolab.org');
- $browser->visit('/resource/' . $resource->id)->on(new Home());
+ $browser->visit('/shared-folder/' . $folder->id)->on(new Home());
});
}
/**
- * Test distribution list info page
+ * Test shared folder info page
*/
public function testInfo(): void
{
@@ -54,42 +54,48 @@
$this->browse(function (Browser $browser) {
$user = $this->getTestUser('john@kolab.org');
- $resource = $this->getTestResource('resource-test1@kolab.org');
- $resource->setSetting('invitation_policy', 'accept');
+ $folder = $this->getTestSharedFolder('folder-event@kolab.org');
+ $folder->setConfig(['acl' => ['anyone, read-only', 'jack@kolab.org, read-write']]);
+ $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE
+ | SharedFolder::STATUS_LDAP_READY | SharedFolder::STATUS_IMAP_READY;
+ $folder->save();
- $resource_page = new ResourcePage($resource->id);
+ $folder_page = new SharedFolderPage($folder->id);
$user_page = new UserPage($user->id);
- // Goto the distlist page
+ // Goto the folder page
$browser->visit(new Home())
->submitLogon('reseller@' . \config('app.domain'), \App\Utils::generatePassphrase(), true)
->on(new Dashboard())
->visit($user_page)
->on($user_page)
- ->click('@nav #tab-resources')
+ ->click('@nav #tab-shared-folders')
->pause(1000)
- ->click('@user-resources table tbody tr:first-child td:first-child a')
- ->on($resource_page)
- ->assertSeeIn('@resource-info .card-title', $resource->email)
- ->with('@resource-info form', function (Browser $browser) use ($resource) {
- $browser->assertElementsCount('.row', 3)
+ ->click('@user-shared-folders table tbody tr:first-child td:first-child a')
+ ->on($folder_page)
+ ->assertSeeIn('@folder-info .card-title', $folder->email)
+ ->with('@folder-info form', function (Browser $browser) use ($folder) {
+ $browser->assertElementsCount('.row', 4)
->assertSeeIn('.row:nth-child(1) label', 'ID (Created)')
- ->assertSeeIn('.row:nth-child(1) #resourceid', "{$resource->id} ({$resource->created_at})")
+ ->assertSeeIn('.row:nth-child(1) #folderid', "{$folder->id} ({$folder->created_at})")
->assertSeeIn('.row:nth-child(2) label', 'Status')
->assertSeeIn('.row:nth-child(2) #status.text-success', 'Active')
->assertSeeIn('.row:nth-child(3) label', 'Name')
- ->assertSeeIn('.row:nth-child(3) #name', $resource->name);
+ ->assertSeeIn('.row:nth-child(3) #name', $folder->name)
+ ->assertSeeIn('.row:nth-child(4) label', 'Type')
+ ->assertSeeIn('.row:nth-child(4) #type', 'Calendar');
})
->assertElementsCount('ul.nav-tabs', 1)
->assertSeeIn('ul.nav-tabs .nav-link', 'Settings')
- ->with('@resource-settings form', function (Browser $browser) {
+ ->with('@folder-settings form', function (Browser $browser) {
$browser->assertElementsCount('.row', 1)
- ->assertSeeIn('.row:nth-child(1) label', 'Invitation policy')
- ->assertSeeIn('.row:nth-child(1) #invitation_policy', 'accept');
+ ->assertSeeIn('.row:nth-child(1) label', 'Access rights')
+ ->assertSeeIn('.row:nth-child(1) #acl', 'anyone: read-only')
+ ->assertSeeIn('.row:nth-child(1) #acl', 'jack@kolab.org: read-write');
});
- // Test invalid resource identifier
- $browser->visit('/resource/abc')->assertErrorPage(404);
+ // Test invalid shared folder identifier
+ $browser->visit('/shared-folder/abc')->assertErrorPage(404);
});
}
}
diff --git a/src/tests/Browser/Reseller/UserTest.php b/src/tests/Browser/Reseller/UserTest.php
--- a/src/tests/Browser/Reseller/UserTest.php
+++ b/src/tests/Browser/Reseller/UserTest.php
@@ -113,7 +113,7 @@
// Some tabs are loaded in background, wait a second
$browser->pause(500)
- ->assertElementsCount('@nav a', 8);
+ ->assertElementsCount('@nav a', 9);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
@@ -174,6 +174,14 @@
->assertSeeIn('table tfoot tr td', 'There are no resources in this account.');
});
+ // Assert Shared folders tab
+ $browser->assertSeeIn('@nav #tab-shared-folders', 'Shared folders (0)')
+ ->click('@nav #tab-shared-folders')
+ ->with('@user-shared-folders', function (Browser $browser) {
+ $browser->assertElementsCount('table tbody tr', 0)
+ ->assertSeeIn('table tfoot tr td', 'There are no shared folders in this account.');
+ });
+
// Assert Settings tab
$browser->assertSeeIn('@nav #tab-settings', 'Settings')
->click('@nav #tab-settings')
@@ -236,7 +244,7 @@
// Some tabs are loaded in background, wait a second
$browser->pause(500)
- ->assertElementsCount('@nav a', 8);
+ ->assertElementsCount('@nav a', 9);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
@@ -265,6 +273,22 @@
->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher');
});
+ // Assert Users tab
+ $browser->assertSeeIn('@nav #tab-users', 'Users (4)')
+ ->click('@nav #tab-users')
+ ->with('@user-users table', function (Browser $browser) {
+ $browser->assertElementsCount('tbody tr', 4)
+ ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'jack@kolab.org')
+ ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success')
+ ->assertSeeIn('tbody tr:nth-child(2) td:first-child a', 'joe@kolab.org')
+ ->assertVisible('tbody tr:nth-child(2) td:first-child svg.text-success')
+ ->assertSeeIn('tbody tr:nth-child(3) td:first-child span', 'john@kolab.org')
+ ->assertVisible('tbody tr:nth-child(3) td:first-child svg.text-success')
+ ->assertSeeIn('tbody tr:nth-child(4) td:first-child a', 'ned@kolab.org')
+ ->assertVisible('tbody tr:nth-child(4) td:first-child svg.text-success')
+ ->assertMissing('tfoot');
+ });
+
// Assert Domains tab
$browser->assertSeeIn('@nav #tab-domains', 'Domains (1)')
->click('@nav #tab-domains')
@@ -298,20 +322,18 @@
->assertMissing('table tfoot');
});
- // Assert Users tab
- $browser->assertSeeIn('@nav #tab-users', 'Users (4)')
- ->click('@nav #tab-users')
- ->with('@user-users table', function (Browser $browser) {
- $browser->assertElementsCount('tbody tr', 4)
- ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'jack@kolab.org')
- ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success')
- ->assertSeeIn('tbody tr:nth-child(2) td:first-child a', 'joe@kolab.org')
- ->assertVisible('tbody tr:nth-child(2) td:first-child svg.text-success')
- ->assertSeeIn('tbody tr:nth-child(3) td:first-child span', 'john@kolab.org')
- ->assertVisible('tbody tr:nth-child(3) td:first-child svg.text-success')
- ->assertSeeIn('tbody tr:nth-child(4) td:first-child a', 'ned@kolab.org')
- ->assertVisible('tbody tr:nth-child(4) td:first-child svg.text-success')
- ->assertMissing('tfoot');
+ // Assert Shared folders tab
+ $browser->assertSeeIn('@nav #tab-shared-folders', 'Shared folders (2)')
+ ->click('@nav #tab-shared-folders')
+ ->with('@user-shared-folders', function (Browser $browser) {
+ $browser->assertElementsCount('table tbody tr', 2)
+ ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'Calendar')
+ ->assertSeeIn('table tbody tr:nth-child(1) td:nth-child(2)', 'Calendar')
+ ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', 'folder-event@kolab.org')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Contacts')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:nth-child(2)', 'Address Book')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', 'folder-contact@kolab.org')
+ ->assertMissing('table tfoot');
});
});
@@ -321,7 +343,8 @@
$ned->setSetting('greylist_enabled', 'false');
$page = new UserPage($ned->id);
- $browser->click('@user-users tbody tr:nth-child(4) td:first-child a')
+ $browser->click('@nav #tab-users')
+ ->click('@user-users tbody tr:nth-child(4) td:first-child a')
->on($page);
// Assert main info box content
@@ -333,7 +356,7 @@
// Some tabs are loaded in background, wait a second
$browser->pause(500)
- ->assertElementsCount('@nav a', 8);
+ ->assertElementsCount('@nav a', 9);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
@@ -398,6 +421,14 @@
->assertSeeIn('table tfoot tr td', 'There are no resources in this account.');
});
+ // Assert Shared folders tab
+ $browser->assertSeeIn('@nav #tab-shared-folders', 'Shared folders (0)')
+ ->click('@nav #tab-shared-folders')
+ ->with('@user-shared-folders', function (Browser $browser) {
+ $browser->assertElementsCount('table tbody tr', 0)
+ ->assertSeeIn('table tfoot tr td', 'There are no shared folders in this account.');
+ });
+
// Assert Settings tab
$browser->assertSeeIn('@nav #tab-settings', 'Settings')
->click('@nav #tab-settings')
diff --git a/src/tests/Browser/SharedFolderTest.php b/src/tests/Browser/SharedFolderTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/SharedFolderTest.php
@@ -0,0 +1,333 @@
+<?php
+
+namespace Tests\Browser;
+
+use App\SharedFolder;
+use Tests\Browser;
+use Tests\Browser\Components\AclInput;
+use Tests\Browser\Components\Status;
+use Tests\Browser\Components\Toast;
+use Tests\Browser\Pages\Dashboard;
+use Tests\Browser\Pages\Home;
+use Tests\Browser\Pages\SharedFolderInfo;
+use Tests\Browser\Pages\SharedFolderList;
+use Tests\TestCaseDusk;
+
+class SharedFolderTest extends TestCaseDusk
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ SharedFolder::whereNotIn('email', ['folder-event@kolab.org', 'folder-contact@kolab.org'])->delete();
+ $this->clearBetaEntitlements();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ SharedFolder::whereNotIn('email', ['folder-event@kolab.org', 'folder-contact@kolab.org'])->delete();
+ $this->clearBetaEntitlements();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test shared folder info page (unauthenticated)
+ */
+ public function testInfoUnauth(): void
+ {
+ // Test that the page requires authentication
+ $this->browse(function (Browser $browser) {
+ $browser->visit('/shared-folder/abc')->on(new Home());
+ });
+ }
+
+ /**
+ * Test shared folder list page (unauthenticated)
+ */
+ public function testListUnauth(): void
+ {
+ // Test that the page requires authentication
+ $this->browse(function (Browser $browser) {
+ $browser->visit('/shared-folders')->on(new Home());
+ });
+ }
+
+ /**
+ * Test shared folders list page
+ */
+ public function testList(): void
+ {
+ // Log on the user
+ $this->browse(function (Browser $browser) {
+ $browser->visit(new Home())
+ ->submitLogon('john@kolab.org', 'simple123', true)
+ ->on(new Dashboard())
+ ->assertMissing('@links .link-shared-folders');
+ });
+
+ // Test that shared folders lists page is not accessible without the 'beta-shared-folders' entitlement
+ $this->browse(function (Browser $browser) {
+ $browser->visit('/shared-folders')
+ ->assertErrorPage(403);
+ });
+
+ // Add beta+beta-shared-folders entitlements
+ $john = $this->getTestUser('john@kolab.org');
+ $this->addBetaEntitlement($john, 'beta-shared-folders');
+ // Make sure the first folder is active
+ $folder = $this->getTestSharedFolder('folder-event@kolab.org');
+ $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE
+ | SharedFolder::STATUS_LDAP_READY | SharedFolder::STATUS_IMAP_READY;
+ $folder->save();
+
+ // Test shared folders lists page
+ $this->browse(function (Browser $browser) {
+ $browser->visit(new Dashboard())
+ ->assertSeeIn('@links .link-shared-folders', 'Shared folders')
+ ->click('@links .link-shared-folders')
+ ->on(new SharedFolderList())
+ ->whenAvailable('@table', function (Browser $browser) {
+ $browser->waitFor('tbody tr')
+ ->assertSeeIn('thead tr th:nth-child(1)', 'Name')
+ ->assertSeeIn('thead tr th:nth-child(2)', 'Type')
+ ->assertSeeIn('thead tr th:nth-child(3)', 'Email Address')
+ ->assertElementsCount('tbody tr', 2)
+ ->assertSeeIn('tbody tr:nth-child(1) td:nth-child(1) a', 'Calendar')
+ ->assertSeeIn('tbody tr:nth-child(1) td:nth-child(2)', 'Calendar')
+ ->assertSeeIn('tbody tr:nth-child(1) td:nth-child(3) a', 'folder-event@kolab.org')
+ ->assertText('tbody tr:nth-child(1) td:nth-child(1) svg.text-success title', 'Active')
+ ->assertSeeIn('tbody tr:nth-child(2) td:nth-child(1) a', 'Contacts')
+ ->assertSeeIn('tbody tr:nth-child(2) td:nth-child(2)', 'Address Book')
+ ->assertSeeIn('tbody tr:nth-child(2) td:nth-child(3) a', 'folder-contact@kolab.org')
+ ->assertMissing('tfoot');
+ });
+ });
+ }
+
+ /**
+ * Test shared folder creation/editing/deleting
+ *
+ * @depends testList
+ */
+ public function testCreateUpdateDelete(): void
+ {
+ // Test that the page is not available accessible without the 'beta-shared-folders' entitlement
+ $this->browse(function (Browser $browser) {
+ $browser->visit('/shared-folder/new')
+ ->assertErrorPage(403);
+ });
+
+ // Add beta+beta-shared-folders entitlements
+ $john = $this->getTestUser('john@kolab.org');
+ $this->addBetaEntitlement($john, 'beta-shared-folders');
+
+ $this->browse(function (Browser $browser) {
+ // Create a folder
+ $browser->visit(new SharedFolderList())
+ ->assertSeeIn('button.create-folder', 'Create folder')
+ ->click('button.create-folder')
+ ->on(new SharedFolderInfo())
+ ->assertSeeIn('#folder-info .card-title', 'New shared folder')
+ ->assertSeeIn('@nav #tab-general', 'General')
+ ->assertMissing('@nav #tab-settings')
+ ->with('@general', function (Browser $browser) {
+ // Assert form content
+ $browser->assertMissing('#status')
+ ->assertFocused('#name')
+ ->assertSeeIn('div.row:nth-child(1) label', 'Name')
+ ->assertValue('div.row:nth-child(1) input[type=text]', '')
+ ->assertSeeIn('div.row:nth-child(2) label', 'Type')
+ ->assertSelectHasOptions(
+ 'div.row:nth-child(2) select',
+ ['mail', 'event', 'task', 'contact', 'note', 'file']
+ )
+ ->assertValue('div.row:nth-child(2) select', 'mail')
+ ->assertSeeIn('div.row:nth-child(3) label', 'Domain')
+ ->assertSelectHasOptions('div.row:nth-child(3) select', ['kolab.org'])
+ ->assertValue('div.row:nth-child(3) select', 'kolab.org')
+ ->assertSeeIn('button[type=submit]', 'Submit');
+ })
+ // Test error conditions
+ ->type('#name', str_repeat('A', 192))
+ ->click('@general button[type=submit]')
+ ->waitFor('#name + .invalid-feedback')
+ ->assertSeeIn('#name + .invalid-feedback', 'The name may not be greater than 191 characters.')
+ ->assertFocused('#name')
+ ->assertToast(Toast::TYPE_ERROR, 'Form validation error')
+ // Test successful folder creation
+ ->type('#name', 'Test Folder')
+ ->select('#type', 'event')
+ ->click('@general button[type=submit]')
+ ->assertToast(Toast::TYPE_SUCCESS, 'Shared folder created successfully.')
+ ->on(new SharedFolderList())
+ ->assertElementsCount('@table tbody tr', 3);
+
+ // Test folder update
+ $browser->click('@table tr:nth-child(3) td:first-child a')
+ ->on(new SharedFolderInfo())
+ ->assertSeeIn('#folder-info .card-title', 'Shared folder')
+ ->with('@general', function (Browser $browser) {
+ // Assert form content
+ $browser->assertFocused('#name')
+ ->assertSeeIn('div.row:nth-child(1) label', 'Status')
+ ->assertSeeIn('div.row:nth-child(1) span.text-danger', 'Not Ready')
+ ->assertSeeIn('div.row:nth-child(2) label', 'Name')
+ ->assertValue('div.row:nth-child(2) input[type=text]', 'Test Folder')
+ ->assertSeeIn('div.row:nth-child(3) label', 'Type')
+ ->assertSelected('div.row:nth-child(3) select:disabled', 'event')
+ ->assertSeeIn('div.row:nth-child(4) label', 'Email Address')
+ ->assertAttributeRegExp(
+ 'div.row:nth-child(4) input[type=text]:disabled',
+ 'value',
+ '/^event-[0-9]+@kolab\.org$/'
+ )
+ ->assertSeeIn('button[type=submit]', 'Submit');
+ })
+ // Test error handling
+ ->type('#name', str_repeat('A', 192))
+ ->click('@general button[type=submit]')
+ ->waitFor('#name + .invalid-feedback')
+ ->assertSeeIn('#name + .invalid-feedback', 'The name may not be greater than 191 characters.')
+ ->assertVisible('#name.is-invalid')
+ ->assertFocused('#name')
+ ->assertToast(Toast::TYPE_ERROR, 'Form validation error')
+ // Test successful update
+ ->type('#name', 'Test Folder Update')
+ ->click('@general button[type=submit]')
+ ->assertToast(Toast::TYPE_SUCCESS, 'Shared folder updated successfully.')
+ ->on(new SharedFolderList())
+ ->assertElementsCount('@table tbody tr', 3)
+ ->assertSeeIn('@table tr:nth-child(3) td:first-child a', 'Test Folder Update');
+
+ $this->assertSame(1, SharedFolder::where('name', 'Test Folder Update')->count());
+
+ // Test folder deletion
+ $browser->click('@table tr:nth-child(3) td:first-child a')
+ ->on(new SharedFolderInfo())
+ ->assertSeeIn('button.button-delete', 'Delete folder')
+ ->click('button.button-delete')
+ ->assertToast(Toast::TYPE_SUCCESS, 'Shared folder deleted successfully.')
+ ->on(new SharedFolderList())
+ ->assertElementsCount('@table tbody tr', 2);
+
+ $this->assertNull(SharedFolder::where('name', 'Test Folder Update')->first());
+ });
+ }
+
+ /**
+ * Test shared folder status
+ *
+ * @depends testList
+ */
+ public function testStatus(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $this->addBetaEntitlement($john, 'beta-shared-folders');
+ $folder = $this->getTestSharedFolder('folder-event@kolab.org');
+ $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE | SharedFolder::STATUS_LDAP_READY;
+ $folder->created_at = \now();
+ $folder->save();
+
+ $this->assertFalse($folder->isImapReady());
+
+ $this->browse(function ($browser) use ($folder) {
+ // Test auto-refresh
+ $browser->visit('/shared-folder/' . $folder->id)
+ ->on(new SharedFolderInfo())
+ ->with(new Status(), function ($browser) {
+ $browser->assertSeeIn('@body', 'We are preparing the shared folder')
+ ->assertProgress(85, 'Creating a shared folder...', 'pending')
+ ->assertMissing('@refresh-button')
+ ->assertMissing('@refresh-text')
+ ->assertMissing('#status-link')
+ ->assertMissing('#status-verify');
+ });
+
+ $folder->status |= SharedFolder::STATUS_IMAP_READY;
+ $folder->save();
+
+ // Test Verify button
+ $browser->waitUntilMissing('@status', 10);
+ });
+
+ // TODO: Test all shared folder statuses on the list
+ }
+
+ /**
+ * Test shared folder settings
+ */
+ public function testSettings(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $this->addBetaEntitlement($john, 'beta-shared-folders');
+ $folder = $this->getTestSharedFolder('folder-event@kolab.org');
+ $folder->setSetting('acl', null);
+
+ $this->browse(function ($browser) use ($folder) {
+ $aclInput = new AclInput('@settings #acl');
+ // Test auto-refresh
+ $browser->visit('/shared-folder/' . $folder->id)
+ ->on(new SharedFolderInfo())
+ ->assertSeeIn('@nav #tab-general', 'General')
+ ->assertSeeIn('@nav #tab-settings', 'Settings')
+ ->click('@nav #tab-settings')
+ ->with('@settings form', function (Browser $browser) {
+ // Assert form content
+ $browser->assertSeeIn('div.row:nth-child(1) label', 'Access rights')
+ ->assertSeeIn('div.row:nth-child(1) #acl-hint', 'permissions')
+ ->assertSeeIn('button[type=submit]', 'Submit');
+ })
+ // Test the AclInput widget
+ ->with($aclInput, function (Browser $browser) {
+ $browser->assertAclValue([])
+ ->addAclEntry('anyone, read-only')
+ ->addAclEntry('test, read-write')
+ ->addAclEntry('john@kolab.org, full')
+ ->assertAclValue([
+ 'anyone, read-only',
+ 'test, read-write',
+ 'john@kolab.org, full',
+ ]);
+ })
+ // Test error handling
+ ->click('@settings button[type=submit]')
+ ->with($aclInput, function (Browser $browser) {
+ $browser->assertFormError(2, 'The specified email address is invalid.');
+ })
+ ->assertToast(Toast::TYPE_ERROR, 'Form validation error')
+ // Test successful update
+ ->with($aclInput, function (Browser $browser) {
+ $browser->removeAclEntry(2)
+ ->assertAclValue([
+ 'anyone, read-only',
+ 'john@kolab.org, full',
+ ])
+ ->updateAclEntry(2, 'jack@kolab.org, read-write')
+ ->assertAclValue([
+ 'anyone, read-only',
+ 'jack@kolab.org, read-write',
+ ]);
+ })
+ ->click('@settings button[type=submit]')
+ ->assertToast(Toast::TYPE_SUCCESS, 'Shared folder settings updated successfully.')
+ ->assertMissing('.invalid-feedback')
+ // Refresh the page and check if everything was saved
+ ->refresh()
+ ->on(new SharedFolderInfo())
+ ->click('@nav #tab-settings')
+ ->with($aclInput, function (Browser $browser) {
+ $browser->assertAclValue([
+ 'anyone, read-only',
+ 'jack@kolab.org, read-write',
+ ]);
+ });
+ });
+ }
+}
diff --git a/src/tests/Browser/UsersTest.php b/src/tests/Browser/UsersTest.php
--- a/src/tests/Browser/UsersTest.php
+++ b/src/tests/Browser/UsersTest.php
@@ -711,7 +711,7 @@
$browser->visit('/user/' . $john->id)
->on(new UserInfo())
->with('@skus', function ($browser) {
- $browser->assertElementsCount('tbody tr', 9)
+ $browser->assertElementsCount('tbody tr', 10)
// Meet SKU
->assertSeeIn('tbody tr:nth-child(6) td.name', 'Voice & Video Conferencing (public beta)')
->assertSeeIn('tr:nth-child(6) td.price', '0,00 CHF/month')
@@ -739,13 +739,22 @@
'tbody tr:nth-child(8) td.buttons button',
'Access to calendaring resources'
)
- // Distlist SKU
- ->assertSeeIn('tbody tr:nth-child(9) td.name', 'Distribution lists')
+ // Shared folders SKU
+ ->assertSeeIn('tbody tr:nth-child(9) td.name', 'Shared folders')
->assertSeeIn('tr:nth-child(9) td.price', '0,00 CHF/month')
->assertNotChecked('tbody tr:nth-child(9) td.selection input')
->assertEnabled('tbody tr:nth-child(9) td.selection input')
->assertTip(
'tbody tr:nth-child(9) td.buttons button',
+ 'Access to shared folders'
+ )
+ // Distlist SKU
+ ->assertSeeIn('tbody tr:nth-child(10) td.name', 'Distribution lists')
+ ->assertSeeIn('tr:nth-child(10) td.price', '0,00 CHF/month')
+ ->assertNotChecked('tbody tr:nth-child(10) td.selection input')
+ ->assertEnabled('tbody tr:nth-child(10) td.selection input')
+ ->assertTip(
+ 'tbody tr:nth-child(10) td.buttons button',
'Access to mail distribution lists'
)
// Check Distlist, Uncheck Beta, expect Distlist unchecked
diff --git a/src/tests/Feature/Backends/IMAPTest.php b/src/tests/Feature/Backends/IMAPTest.php
--- a/src/tests/Feature/Backends/IMAPTest.php
+++ b/src/tests/Feature/Backends/IMAPTest.php
@@ -14,25 +14,12 @@
*/
public function testVerifyAccountExisting(): void
{
+ // existing user
$result = IMAP::verifyAccount('john@kolab.org');
+ $this->assertTrue($result);
- // TODO: Mocking rcube_imap_generic is not that nice,
- // Find a way to be sure some testing account has folders
- // initialized, and some other not, so we can make assertions
- // on the verifyAccount() result
-
- $this->markTestIncomplete();
- }
-
- /**
- * Test verifying IMAP account existence (non-existing account)
- *
- * @group imap
- */
- public function testVerifyAccountNonExisting(): void
- {
+ // non-existing user
$this->expectException(\Exception::class);
-
IMAP::verifyAccount('non-existing@domain.tld');
}
@@ -43,10 +30,12 @@
*/
public function testVerifySharedFolder(): void
{
+ // non-existing
$result = IMAP::verifySharedFolder('shared/Resources/UnknownResource@kolab.org');
$this->assertFalse($result);
- // TODO: Test with an existing shared folder
- $this->markTestIncomplete();
+ // existing
+ $result = IMAP::verifySharedFolder('shared/Calendar@kolab.org');
+ $this->assertTrue($result);
}
}
diff --git a/src/tests/Feature/Backends/LDAPTest.php b/src/tests/Feature/Backends/LDAPTest.php
--- a/src/tests/Feature/Backends/LDAPTest.php
+++ b/src/tests/Feature/Backends/LDAPTest.php
@@ -7,6 +7,7 @@
use App\Group;
use App\Entitlement;
use App\Resource;
+use App\SharedFolder;
use App\User;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
@@ -30,6 +31,7 @@
$this->deleteTestDomain('testldap.com');
$this->deleteTestGroup('group@kolab.org');
$this->deleteTestResource('test-resource@kolab.org');
+ $this->deleteTestSharedFolder('test-folder@kolab.org');
// TODO: Remove group members
}
@@ -44,6 +46,7 @@
$this->deleteTestDomain('testldap.com');
$this->deleteTestGroup('group@kolab.org');
$this->deleteTestResource('test-resource@kolab.org');
+ $this->deleteTestSharedFolder('test-folder@kolab.org');
// TODO: Remove group members
parent::tearDown();
@@ -277,6 +280,71 @@
$this->assertSame(null, LDAP::getResource($resource->email));
}
+ /**
+ * Test creating/updating/deleting a shared folder record
+ *
+ * @group ldap
+ */
+ public function testSharedFolder(): void
+ {
+ Queue::fake();
+
+ $root_dn = \config('ldap.hosted.root_dn');
+ $folder = $this->getTestSharedFolder('test-folder@kolab.org', ['type' => 'event']);
+ $folder->setSetting('acl', null);
+
+ // Make sure the shared folder does not exist
+ // LDAP::deleteSharedFolder($folder);
+
+ // Create the shared folder
+ LDAP::createSharedFolder($folder);
+
+ $ldap_folder = LDAP::getSharedFolder($folder->email);
+
+ $expected = [
+ 'cn' => 'test-folder',
+ 'dn' => 'cn=test-folder,ou=Shared Folders,ou=kolab.org,' . $root_dn,
+ 'mail' => $folder->email,
+ 'objectclass' => [
+ 'top',
+ 'kolabsharedfolder',
+ 'mailrecipient',
+ ],
+ 'kolabfoldertype' => 'event',
+ 'kolabtargetfolder' => 'shared/test-folder@kolab.org',
+ 'acl' => null,
+ ];
+
+ foreach ($expected as $attr => $value) {
+ $ldap_value = isset($ldap_folder[$attr]) ? $ldap_folder[$attr] : null;
+ $this->assertEquals($value, $ldap_value, "Shared folder $attr attribute");
+ }
+
+ // Update folder name and acl
+ $folder->name = 'Te(=ść)1';
+ $folder->save();
+ $folder->setSetting('acl', '["john@kolab.org, read-write","anyone, read-only"]');
+
+ LDAP::updateSharedFolder($folder);
+
+ $expected['kolabtargetfolder'] = 'shared/Te(=ść)1@kolab.org';
+ $expected['acl'] = ['john@kolab.org, read-write', 'anyone, read-only'];
+ $expected['dn'] = 'cn=Te(\\3dść)1,ou=Shared Folders,ou=kolab.org,' . $root_dn;
+ $expected['cn'] = 'Te(=ść)1';
+
+ $ldap_folder = LDAP::getSharedFolder($folder->email);
+
+ foreach ($expected as $attr => $value) {
+ $ldap_value = isset($ldap_folder[$attr]) ? $ldap_folder[$attr] : null;
+ $this->assertEquals($value, $ldap_value, "Shared folder $attr attribute");
+ }
+
+ // Delete the resource
+ LDAP::deleteSharedFolder($folder);
+
+ $this->assertSame(null, LDAP::getSharedFolder($folder->email));
+ }
+
/**
* Test creating/editing/deleting a user record
*
@@ -402,7 +470,7 @@
$resource = new Resource([
'email' => 'test-non-existing-ldap@non-existing.org',
'name' => 'Test',
- 'status' => User::STATUS_ACTIVE,
+ 'status' => Resource::STATUS_ACTIVE,
]);
LDAP::createResource($resource);
@@ -427,6 +495,25 @@
LDAP::createGroup($group);
}
+ /**
+ * Test handling errors on a shared folder creation
+ *
+ * @group ldap
+ */
+ public function testCreateSharedFolderException(): void
+ {
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessageMatches('/Failed to create shared folder/');
+
+ $folder = new SharedFolder([
+ 'email' => 'test-non-existing-ldap@non-existing.org',
+ 'name' => 'Test',
+ 'status' => SharedFolder::STATUS_ACTIVE,
+ ]);
+
+ LDAP::createSharedFolder($folder);
+ }
+
/**
* Test handling errors on user creation
*
@@ -500,6 +587,23 @@
LDAP::updateResource($resource);
}
+ /**
+ * Test handling update of a non-existing shared folder
+ *
+ * @group ldap
+ */
+ public function testUpdateSharedFolderException(): void
+ {
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessageMatches('/folder not found/');
+
+ $folder = new SharedFolder([
+ 'email' => 'test-folder-unknown@kolab.org',
+ ]);
+
+ LDAP::updateSharedFolder($folder);
+ }
+
/**
* Test handling update of a non-existing user
*
diff --git a/src/tests/Feature/Controller/Admin/SharedFoldersTest.php b/src/tests/Feature/Controller/Admin/SharedFoldersTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Controller/Admin/SharedFoldersTest.php
@@ -0,0 +1,149 @@
+<?php
+
+namespace Tests\Feature\Controller\Admin;
+
+use App\SharedFolder;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class SharedFoldersTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ self::useAdminUrl();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ parent::tearDown();
+ }
+
+ /**
+ * Test shared folders searching (/api/v4/shared-folders)
+ */
+ public function testIndex(): void
+ {
+ $user = $this->getTestUser('john@kolab.org');
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $folder = $this->getTestSharedFolder('folder-event@kolab.org');
+
+ // Non-admin user
+ $response = $this->actingAs($user)->get("api/v4/shared-folders");
+ $response->assertStatus(403);
+
+ // Search with no search criteria
+ $response = $this->actingAs($admin)->get("api/v4/shared-folders");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertSame([], $json['list']);
+ $this->assertSame("0 shared folders have been found.", $json['message']);
+
+ // Search with no matches expected
+ $response = $this->actingAs($admin)->get("api/v4/shared-folders?search=john@kolab.org");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertSame([], $json['list']);
+
+ // Search by email
+ $response = $this->actingAs($admin)->get("api/v4/shared-folders?search={$folder->email}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(1, $json['count']);
+ $this->assertCount(1, $json['list']);
+ $this->assertSame($folder->email, $json['list'][0]['email']);
+
+ // Search by owner
+ $response = $this->actingAs($admin)->get("api/v4/shared-folders?owner={$user->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(2, $json['count']);
+ $this->assertCount(2, $json['list']);
+ $this->assertSame("2 shared folders have been found.", $json['message']);
+ $this->assertSame($folder->email, $json['list'][0]['email']);
+ $this->assertSame($folder->name, $json['list'][0]['name']);
+
+ // Search by owner (Ned is a controller on John's wallets,
+ // here we expect only folders assigned to Ned's wallet(s))
+ $ned = $this->getTestUser('ned@kolab.org');
+ $response = $this->actingAs($admin)->get("api/v4/shared-folders?owner={$ned->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertCount(0, $json['list']);
+ }
+
+ /**
+ * Test fetching shared folder info (GET /api/v4/shared-folders/<folder-id>)
+ */
+ public function testShow(): void
+ {
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $user = $this->getTestUser('john@kolab.org');
+ $folder = $this->getTestSharedFolder('folder-event@kolab.org');
+
+ // Only admins can access it
+ $response = $this->actingAs($user)->get("api/v4/shared-folders/{$folder->id}");
+ $response->assertStatus(403);
+
+ $response = $this->actingAs($admin)->get("api/v4/shared-folders/{$folder->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertEquals($folder->id, $json['id']);
+ $this->assertEquals($folder->email, $json['email']);
+ $this->assertEquals($folder->name, $json['name']);
+ $this->assertEquals($folder->type, $json['type']);
+ }
+
+ /**
+ * Test fetching shared folder status (GET /api/v4/shared-folders/<folder-id>/status)
+ */
+ public function testStatus(): void
+ {
+ Queue::fake(); // disable jobs
+
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $folder = $this->getTestSharedFolder('folder-event@kolab.org');
+
+ // This end-point does not exist for admins
+ $response = $this->actingAs($admin)->get("/api/v4/shared-folders/{$folder->id}/status");
+ $response->assertStatus(404);
+ }
+
+ /**
+ * Test shared folder creating (POST /api/v4/shared-folders)
+ */
+ public function testStore(): void
+ {
+ $user = $this->getTestUser('john@kolab.org');
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+
+ // Test unauthorized access to admin API
+ $response = $this->actingAs($user)->post("/api/v4/shared-folders", []);
+ $response->assertStatus(403);
+
+ // Admin can't create shared folders
+ $response = $this->actingAs($admin)->post("/api/v4/shared-folders", []);
+ $response->assertStatus(404);
+ }
+}
diff --git a/src/tests/Feature/Controller/Admin/SkusTest.php b/src/tests/Feature/Controller/Admin/SkusTest.php
--- a/src/tests/Feature/Controller/Admin/SkusTest.php
+++ b/src/tests/Feature/Controller/Admin/SkusTest.php
@@ -82,7 +82,7 @@
$json = $response->json();
- $this->assertCount(11, $json);
+ $this->assertCount(13, $json);
$this->assertSame(100, $json[0]['prio']);
$this->assertSame($sku->id, $json[0]['id']);
diff --git a/src/tests/Feature/Controller/Admin/UsersTest.php b/src/tests/Feature/Controller/Admin/UsersTest.php
--- a/src/tests/Feature/Controller/Admin/UsersTest.php
+++ b/src/tests/Feature/Controller/Admin/UsersTest.php
@@ -194,6 +194,17 @@
$this->assertSame($user->id, $json['list'][0]['id']);
$this->assertSame($user->email, $json['list'][0]['email']);
+ // Search by shared folder email
+ $response = $this->actingAs($admin)->get("api/v4/users?search=folder-event@kolab.org");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(1, $json['count']);
+ $this->assertCount(1, $json['list']);
+ $this->assertSame($user->id, $json['list'][0]['id']);
+ $this->assertSame($user->email, $json['list'][0]['email']);
+
// Deleted users/domains
$domain = $this->getTestDomain('testsearch.com', ['type' => \App\Domain::TYPE_EXTERNAL]);
$user = $this->getTestUser('test@testsearch.com');
diff --git a/src/tests/Feature/Controller/Reseller/SharedFoldersTest.php b/src/tests/Feature/Controller/Reseller/SharedFoldersTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Controller/Reseller/SharedFoldersTest.php
@@ -0,0 +1,180 @@
+<?php
+
+namespace Tests\Feature\Controller\Reseller;
+
+use App\SharedFolder;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class SharedFoldersTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ self::useResellerUrl();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ parent::tearDown();
+ }
+
+ /**
+ * Test shared folders searching (/api/v4/shared-folders)
+ */
+ public function testIndex(): void
+ {
+ $user = $this->getTestUser('john@kolab.org');
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $reseller1 = $this->getTestUser('reseller@' . \config('app.domain'));
+ $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local');
+ $folder = $this->getTestSharedFolder('folder-event@kolab.org');
+
+ // Non-admin user
+ $response = $this->actingAs($user)->get("api/v4/shared-folders");
+ $response->assertStatus(403);
+
+ // Admin user
+ $response = $this->actingAs($admin)->get("api/v4/shared-folders");
+ $response->assertStatus(403);
+
+ // Search with no search criteria
+ $response = $this->actingAs($reseller1)->get("api/v4/shared-folders");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertSame([], $json['list']);
+
+ // Search with no matches expected
+ $response = $this->actingAs($reseller1)->get("api/v4/shared-folders?search=john@kolab.org");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertSame([], $json['list']);
+
+ // Search by email
+ $response = $this->actingAs($reseller1)->get("api/v4/shared-folders?search={$folder->email}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(1, $json['count']);
+ $this->assertCount(1, $json['list']);
+ $this->assertSame($folder->email, $json['list'][0]['email']);
+
+ // Search by owner
+ $response = $this->actingAs($reseller1)->get("api/v4/shared-folders?owner={$user->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(2, $json['count']);
+ $this->assertCount(2, $json['list']);
+ $this->assertSame($folder->email, $json['list'][0]['email']);
+ $this->assertSame($folder->name, $json['list'][0]['name']);
+
+ // Search by owner (Ned is a controller on John's wallets,
+ // here we expect only folders assigned to Ned's wallet(s))
+ $ned = $this->getTestUser('ned@kolab.org');
+ $response = $this->actingAs($reseller1)->get("api/v4/shared-folders?owner={$ned->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertCount(0, $json['list']);
+
+ $response = $this->actingAs($reseller2)->get("api/v4/shared-folders?search={$folder->email}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertSame([], $json['list']);
+
+ $response = $this->actingAs($reseller2)->get("api/v4/shared-folders?owner={$user->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertSame([], $json['list']);
+ }
+
+ /**
+ * Test fetching shared folder info (GET /api/v4/shared-folders/<folder-id>)
+ */
+ public function testShow(): void
+ {
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $user = $this->getTestUser('john@kolab.org');
+ $reseller1 = $this->getTestUser('reseller@' . \config('app.domain'));
+ $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local');
+ $folder = $this->getTestSharedFolder('folder-event@kolab.org');
+
+ // Only resellers can access it
+ $response = $this->actingAs($user)->get("api/v4/shared-folders/{$folder->id}");
+ $response->assertStatus(403);
+
+ $response = $this->actingAs($admin)->get("api/v4/shared-folders/{$folder->id}");
+ $response->assertStatus(403);
+
+ $response = $this->actingAs($reseller2)->get("api/v4/shared-folders/{$folder->id}");
+ $response->assertStatus(404);
+
+ $response = $this->actingAs($reseller1)->get("api/v4/shared-folders/{$folder->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertEquals($folder->id, $json['id']);
+ $this->assertEquals($folder->email, $json['email']);
+ $this->assertEquals($folder->name, $json['name']);
+ }
+
+ /**
+ * Test fetching shared folder status (GET /api/v4/shared-folders/<folder-id>/status)
+ */
+ public function testStatus(): void
+ {
+ Queue::fake(); // disable jobs
+
+ $reseller1 = $this->getTestUser('reseller@' . \config('app.domain'));
+ $folder = $this->getTestSharedFolder('folder-event@kolab.org');
+
+ // This end-point does not exist for folders
+ $response = $this->actingAs($reseller1)->get("/api/v4/shared-folders/{$folder->id}/status");
+ $response->assertStatus(404);
+ }
+
+ /**
+ * Test shared folder creating (POST /api/v4/shared-folders)
+ */
+ public function testStore(): void
+ {
+ $user = $this->getTestUser('john@kolab.org');
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $reseller1 = $this->getTestUser('reseller@' . \config('app.domain'));
+
+ // Test unauthorized access to reseller API
+ $response = $this->actingAs($user)->post("/api/v4/shared-folders", []);
+ $response->assertStatus(403);
+
+ // Reseller or admin can't create folders
+ $response = $this->actingAs($admin)->post("/api/v4/shared-folders", []);
+ $response->assertStatus(403);
+
+ $response = $this->actingAs($reseller1)->post("/api/v4/shared-folders", []);
+ $response->assertStatus(404);
+ }
+}
diff --git a/src/tests/Feature/Controller/Reseller/SkusTest.php b/src/tests/Feature/Controller/Reseller/SkusTest.php
--- a/src/tests/Feature/Controller/Reseller/SkusTest.php
+++ b/src/tests/Feature/Controller/Reseller/SkusTest.php
@@ -99,7 +99,7 @@
$json = $response->json();
- $this->assertCount(11, $json);
+ $this->assertCount(13, $json);
$this->assertSame(100, $json[0]['prio']);
$this->assertSame($sku->id, $json[0]['id']);
diff --git a/src/tests/Feature/Controller/SharedFoldersTest.php b/src/tests/Feature/Controller/SharedFoldersTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Controller/SharedFoldersTest.php
@@ -0,0 +1,488 @@
+<?php
+
+namespace Tests\Feature\Controller;
+
+use App\SharedFolder;
+use App\Http\Controllers\API\V4\SharedFoldersController;
+use Carbon\Carbon;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class SharedFoldersTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestSharedFolder('folder-test@kolab.org');
+ SharedFolder::where('name', 'Test Folder')->delete();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $this->deleteTestSharedFolder('folder-test@kolab.org');
+ SharedFolder::where('name', 'Test Folder')->delete();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test resource deleting (DELETE /api/v4/resources/<id>)
+ */
+ public function testDestroy(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $folder = $this->getTestSharedFolder('folder-test@kolab.org');
+ $folder->assignToWallet($john->wallets->first());
+
+ // Test unauth access
+ $response = $this->delete("api/v4/shared-folders/{$folder->id}");
+ $response->assertStatus(401);
+
+ // Test non-existing folder
+ $response = $this->actingAs($john)->delete("api/v4/shared-folders/abc");
+ $response->assertStatus(404);
+
+ // Test access to other user's folder
+ $response = $this->actingAs($jack)->delete("api/v4/shared-folders/{$folder->id}");
+ $response->assertStatus(403);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame("Access denied", $json['message']);
+ $this->assertCount(2, $json);
+
+ // Test removing a folder
+ $response = $this->actingAs($john)->delete("api/v4/shared-folders/{$folder->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertEquals('success', $json['status']);
+ $this->assertEquals("Shared folder deleted successfully.", $json['message']);
+ }
+
+ /**
+ * Test shared folders listing (GET /api/v4/shared-folders)
+ */
+ public function testIndex(): void
+ {
+ $jack = $this->getTestUser('jack@kolab.org');
+ $john = $this->getTestUser('john@kolab.org');
+ $ned = $this->getTestUser('ned@kolab.org');
+
+ // Test unauth access
+ $response = $this->get("api/v4/shared-folders");
+ $response->assertStatus(401);
+
+ // Test a user with no shared folders
+ $response = $this->actingAs($jack)->get("/api/v4/shared-folders");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(0, $json);
+
+ // Test a user with two shared folders
+ $response = $this->actingAs($john)->get("/api/v4/shared-folders");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $folder = SharedFolder::where('name', 'Calendar')->first();
+
+ $this->assertCount(2, $json);
+ $this->assertSame($folder->id, $json[0]['id']);
+ $this->assertSame($folder->email, $json[0]['email']);
+ $this->assertSame($folder->name, $json[0]['name']);
+ $this->assertSame($folder->type, $json[0]['type']);
+ $this->assertArrayHasKey('isDeleted', $json[0]);
+ $this->assertArrayHasKey('isActive', $json[0]);
+ $this->assertArrayHasKey('isLdapReady', $json[0]);
+ $this->assertArrayHasKey('isImapReady', $json[0]);
+
+ // Test that another wallet controller has access to shared folders
+ $response = $this->actingAs($ned)->get("/api/v4/shared-folders");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame($folder->email, $json[0]['email']);
+ }
+
+ /**
+ * Test shared folder config update (POST /api/v4/shared-folders/<folder>/config)
+ */
+ public function testSetConfig(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $folder = $this->getTestSharedFolder('folder-test@kolab.org');
+ $folder->assignToWallet($john->wallets->first());
+
+ // Test unknown resource id
+ $post = ['acl' => ['john@kolab.org, full']];
+ $response = $this->actingAs($john)->post("/api/v4/shared-folders/123/config", $post);
+ $json = $response->json();
+
+ $response->assertStatus(404);
+
+ // Test access by user not being a wallet controller
+ $response = $this->actingAs($jack)->post("/api/v4/shared-folders/{$folder->id}/config", $post);
+ $json = $response->json();
+
+ $response->assertStatus(403);
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame("Access denied", $json['message']);
+ $this->assertCount(2, $json);
+
+ // Test some invalid data
+ $post = ['test' => 1];
+ $response = $this->actingAs($john)->post("/api/v4/shared-folders/{$folder->id}/config", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(2, $json);
+ $this->assertCount(1, $json['errors']);
+ $this->assertSame('The requested configuration parameter is not supported.', $json['errors']['test']);
+
+ $folder->refresh();
+
+ $this->assertNull($folder->getSetting('test'));
+ $this->assertNull($folder->getSetting('acl'));
+
+ // Test some valid data
+ $post = ['acl' => ['john@kolab.org, full']];
+ $response = $this->actingAs($john)->post("/api/v4/shared-folders/{$folder->id}/config", $post);
+
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("Shared folder settings updated successfully.", $json['message']);
+
+ $this->assertSame(['acl' => $post['acl']], $folder->fresh()->getConfig());
+
+ // Test input validation
+ $post = ['acl' => ['john@kolab.org, full', 'test, full']];
+ $response = $this->actingAs($john)->post("/api/v4/shared-folders/{$folder->id}/config", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(1, $json['errors']);
+ $this->assertCount(1, $json['errors']['acl']);
+ $this->assertSame(
+ "The specified email address is invalid.",
+ $json['errors']['acl'][1]
+ );
+
+ $this->assertSame(['acl' => ['john@kolab.org, full']], $folder->fresh()->getConfig());
+ }
+
+ /**
+ * Test fetching shared folder data/profile (GET /api/v4/shared-folders/<folder>)
+ */
+ public function testShow(): void
+ {
+ $jack = $this->getTestUser('jack@kolab.org');
+ $john = $this->getTestUser('john@kolab.org');
+ $ned = $this->getTestUser('ned@kolab.org');
+
+ $folder = $this->getTestSharedFolder('folder-test@kolab.org');
+ $folder->assignToWallet($john->wallets->first());
+ $folder->setSetting('acl', '["anyone, full"]');
+
+ // Test unauthenticated access
+ $response = $this->get("/api/v4/shared-folders/{$folder->id}");
+ $response->assertStatus(401);
+
+ // Test unauthorized access to a shared folder of another user
+ $response = $this->actingAs($jack)->get("/api/v4/shared-folders/{$folder->id}");
+ $response->assertStatus(403);
+
+ // John: Account owner - non-existing folder
+ $response = $this->actingAs($john)->get("/api/v4/shared-folders/abc");
+ $response->assertStatus(404);
+
+ // John: Account owner
+ $response = $this->actingAs($john)->get("/api/v4/shared-folders/{$folder->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame($folder->id, $json['id']);
+ $this->assertSame($folder->email, $json['email']);
+ $this->assertSame($folder->name, $json['name']);
+ $this->assertSame($folder->type, $json['type']);
+ $this->assertTrue(!empty($json['statusInfo']));
+ $this->assertArrayHasKey('isDeleted', $json);
+ $this->assertArrayHasKey('isActive', $json);
+ $this->assertArrayHasKey('isLdapReady', $json);
+ $this->assertArrayHasKey('isImapReady', $json);
+ $this->assertSame(['acl' => ['anyone, full']], $json['config']);
+ }
+
+ /**
+ * Test fetching a shared folder status (GET /api/v4/shared-folders/<folder>/status)
+ * and forcing setup process update (?refresh=1)
+ */
+ public function testStatus(): void
+ {
+ Queue::fake();
+
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+
+ $folder = $this->getTestSharedFolder('folder-test@kolab.org');
+ $folder->assignToWallet($john->wallets->first());
+
+ // Test unauthorized access
+ $response = $this->get("/api/v4/shared-folders/abc/status");
+ $response->assertStatus(401);
+
+ // Test unauthorized access
+ $response = $this->actingAs($jack)->get("/api/v4/shared-folders/{$folder->id}/status");
+ $response->assertStatus(403);
+
+ $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE;
+ $folder->save();
+
+ // Get resource status
+ $response = $this->actingAs($john)->get("/api/v4/shared-folders/{$folder->id}/status");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertFalse($json['isLdapReady']);
+ $this->assertFalse($json['isImapReady']);
+ $this->assertFalse($json['isReady']);
+ $this->assertFalse($json['isDeleted']);
+ $this->assertTrue($json['isActive']);
+ $this->assertCount(7, $json['process']);
+ $this->assertSame('shared-folder-new', $json['process'][0]['label']);
+ $this->assertSame(true, $json['process'][0]['state']);
+ $this->assertSame('shared-folder-ldap-ready', $json['process'][1]['label']);
+ $this->assertSame(false, $json['process'][1]['state']);
+ $this->assertTrue(empty($json['status']));
+ $this->assertTrue(empty($json['message']));
+ $this->assertSame('running', $json['processState']);
+
+ // Make sure the domain is confirmed (other test might unset that status)
+ $domain = $this->getTestDomain('kolab.org');
+ $domain->status |= \App\Domain::STATUS_CONFIRMED;
+ $domain->save();
+ $folder->status |= SharedFolder::STATUS_IMAP_READY;
+ $folder->save();
+
+ // Now "reboot" the process and get the folder status
+ $response = $this->actingAs($john)->get("/api/v4/shared-folders/{$folder->id}/status?refresh=1");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertTrue($json['isLdapReady']);
+ $this->assertTrue($json['isImapReady']);
+ $this->assertTrue($json['isReady']);
+ $this->assertCount(7, $json['process']);
+ $this->assertSame('shared-folder-ldap-ready', $json['process'][1]['label']);
+ $this->assertSame(true, $json['process'][1]['state']);
+ $this->assertSame('shared-folder-imap-ready', $json['process'][2]['label']);
+ $this->assertSame(true, $json['process'][2]['state']);
+ $this->assertSame('success', $json['status']);
+ $this->assertSame('Setup process finished successfully.', $json['message']);
+ $this->assertSame('done', $json['processState']);
+
+ // Test a case when a domain is not ready
+ $domain->status ^= \App\Domain::STATUS_CONFIRMED;
+ $domain->save();
+
+ $response = $this->actingAs($john)->get("/api/v4/shared-folders/{$folder->id}/status?refresh=1");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertTrue($json['isLdapReady']);
+ $this->assertTrue($json['isReady']);
+ $this->assertCount(7, $json['process']);
+ $this->assertSame('shared-folder-ldap-ready', $json['process'][1]['label']);
+ $this->assertSame(true, $json['process'][1]['state']);
+ $this->assertSame('success', $json['status']);
+ $this->assertSame('Setup process finished successfully.', $json['message']);
+ }
+
+ /**
+ * Test SharedFoldersController::statusInfo()
+ */
+ public function testStatusInfo(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $folder = $this->getTestSharedFolder('folder-test@kolab.org');
+ $folder->assignToWallet($john->wallets->first());
+ $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE;
+ $folder->save();
+ $domain = $this->getTestDomain('kolab.org');
+ $domain->status |= \App\Domain::STATUS_CONFIRMED;
+ $domain->save();
+
+ $result = SharedFoldersController::statusInfo($folder);
+
+ $this->assertFalse($result['isReady']);
+ $this->assertCount(7, $result['process']);
+ $this->assertSame('shared-folder-new', $result['process'][0]['label']);
+ $this->assertSame(true, $result['process'][0]['state']);
+ $this->assertSame('shared-folder-ldap-ready', $result['process'][1]['label']);
+ $this->assertSame(false, $result['process'][1]['state']);
+ $this->assertSame('running', $result['processState']);
+
+ $folder->created_at = Carbon::now()->subSeconds(181);
+ $folder->save();
+
+ $result = SharedFoldersController::statusInfo($folder);
+
+ $this->assertSame('failed', $result['processState']);
+
+ $folder->status |= SharedFolder::STATUS_LDAP_READY | SharedFolder::STATUS_IMAP_READY;
+ $folder->save();
+
+ $result = SharedFoldersController::statusInfo($folder);
+
+ $this->assertTrue($result['isReady']);
+ $this->assertCount(7, $result['process']);
+ $this->assertSame('shared-folder-new', $result['process'][0]['label']);
+ $this->assertSame(true, $result['process'][0]['state']);
+ $this->assertSame('shared-folder-ldap-ready', $result['process'][1]['label']);
+ $this->assertSame(true, $result['process'][1]['state']);
+ $this->assertSame('shared-folder-ldap-ready', $result['process'][1]['label']);
+ $this->assertSame(true, $result['process'][1]['state']);
+ $this->assertSame('done', $result['processState']);
+ }
+
+ /**
+ * Test shared folder creation (POST /api/v4/shared-folders)
+ */
+ public function testStore(): void
+ {
+ Queue::fake();
+
+ $jack = $this->getTestUser('jack@kolab.org');
+ $john = $this->getTestUser('john@kolab.org');
+
+ // Test unauth request
+ $response = $this->post("/api/v4/shared-folders", []);
+ $response->assertStatus(401);
+
+ // Test non-controller user
+ $response = $this->actingAs($jack)->post("/api/v4/shared-folders", []);
+ $response->assertStatus(403);
+
+ // Test empty request
+ $response = $this->actingAs($john)->post("/api/v4/shared-folders", []);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame("The name field is required.", $json['errors']['name'][0]);
+ $this->assertSame("The type field is required.", $json['errors']['type'][0]);
+ $this->assertCount(2, $json);
+ $this->assertCount(2, $json['errors']);
+
+ // Test too long name
+ $post = ['domain' => 'kolab.org', 'name' => str_repeat('A', 192), 'type' => 'unknown'];
+ $response = $this->actingAs($john)->post("/api/v4/shared-folders", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(2, $json);
+ $this->assertSame(["The name may not be greater than 191 characters."], $json['errors']['name']);
+ $this->assertSame(["The specified type is invalid."], $json['errors']['type']);
+ $this->assertCount(2, $json['errors']);
+
+ // Test successful folder creation
+ $post['name'] = 'Test Folder';
+ $post['type'] = 'event';
+ $response = $this->actingAs($john)->post("/api/v4/shared-folders", $post);
+ $json = $response->json();
+
+ $response->assertStatus(200);
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("Shared folder created successfully.", $json['message']);
+ $this->assertCount(2, $json);
+
+ $folder = SharedFolder::where('name', $post['name'])->first();
+ $this->assertInstanceOf(SharedFolder::class, $folder);
+ $this->assertSame($post['type'], $folder->type);
+ $this->assertTrue($john->sharedFolders()->get()->contains($folder));
+
+ // Shared folder name must be unique within a domain
+ $post['type'] = 'mail';
+ $response = $this->actingAs($john)->post("/api/v4/shared-folders", $post);
+ $response->assertStatus(422);
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(2, $json);
+ $this->assertCount(1, $json['errors']);
+ $this->assertSame("The specified name is not available.", $json['errors']['name'][0]);
+ }
+
+ /**
+ * Test shared folder update (PUT /api/v4/shared-folders/<folder)
+ */
+ public function testUpdate(): void
+ {
+ Queue::fake();
+
+ $jack = $this->getTestUser('jack@kolab.org');
+ $john = $this->getTestUser('john@kolab.org');
+ $ned = $this->getTestUser('ned@kolab.org');
+
+ $folder = $this->getTestSharedFolder('folder-test@kolab.org');
+ $folder->assignToWallet($john->wallets->first());
+
+ // Test unauthorized update
+ $response = $this->get("/api/v4/shared-folders/{$folder->id}", []);
+ $response->assertStatus(401);
+
+ // Test unauthorized update
+ $response = $this->actingAs($jack)->get("/api/v4/shared-folders/{$folder->id}", []);
+ $response->assertStatus(403);
+
+ // Name change
+ $post = [
+ 'name' => 'Test Res',
+ ];
+
+ $response = $this->actingAs($john)->put("/api/v4/shared-folders/{$folder->id}", $post);
+ $json = $response->json();
+
+ $response->assertStatus(200);
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("Shared folder updated successfully.", $json['message']);
+ $this->assertCount(2, $json);
+
+ $folder->refresh();
+ $this->assertSame($post['name'], $folder->name);
+ }
+}
diff --git a/src/tests/Feature/Controller/SkusTest.php b/src/tests/Feature/Controller/SkusTest.php
--- a/src/tests/Feature/Controller/SkusTest.php
+++ b/src/tests/Feature/Controller/SkusTest.php
@@ -105,7 +105,7 @@
$json = $response->json();
- $this->assertCount(11, $json);
+ $this->assertCount(13, $json);
$this->assertSame(100, $json[0]['prio']);
$this->assertSame($sku->id, $json[0]['id']);
@@ -215,7 +215,7 @@
$json = $response->json();
- $this->assertCount(9, $json);
+ $this->assertCount(10, $json);
$this->assertSkuElement('beta', $json[6], [
'prio' => 10,
@@ -234,7 +234,16 @@
'required' => ['beta'],
]);
- $this->assertSkuElement('distlist', $json[8], [
+ $this->assertSkuElement('beta-shared-folders', $json[8], [
+ 'prio' => 10,
+ 'type' => 'user',
+ 'handler' => 'sharedfolders',
+ 'enabled' => false,
+ 'readonly' => false,
+ 'required' => ['beta'],
+ ]);
+
+ $this->assertSkuElement('distlist', $json[9], [
'prio' => 10,
'type' => 'user',
'handler' => 'distlist',
diff --git a/src/tests/Feature/DomainTest.php b/src/tests/Feature/DomainTest.php
--- a/src/tests/Feature/DomainTest.php
+++ b/src/tests/Feature/DomainTest.php
@@ -239,6 +239,7 @@
$this->deleteTestUser('user@gmail.com');
$this->deleteTestGroup('group@gmail.com');
$this->deleteTestResource('resource@gmail.com');
+ $this->deleteTestSharedFolder('folder@gmail.com');
// Empty domain
$domain = $this->getTestDomain('gmail.com', [
@@ -259,6 +260,9 @@
$this->getTestResource('resource@gmail.com');
$this->assertFalse($domain->isEmpty());
$this->deleteTestResource('resource@gmail.com');
+ $this->getTestSharedFolder('folder@gmail.com');
+ $this->assertFalse($domain->isEmpty());
+ $this->deleteTestSharedFolder('folder@gmail.com');
// TODO: Test with an existing alias, but not other objects in a domain
diff --git a/src/tests/Feature/SharedFolderTest.php b/src/tests/Feature/SharedFolderTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/SharedFolderTest.php
@@ -0,0 +1,304 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\SharedFolder;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class SharedFolderTest extends TestCase
+{
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestUser('user-test@kolabnow.com');
+ SharedFolder::withTrashed()->where('email', 'like', '%@kolabnow.com')->each(function ($folder) {
+ $this->deleteTestSharedFolder($folder->email);
+ });
+ }
+
+ public function tearDown(): void
+ {
+ $this->deleteTestUser('user-test@kolabnow.com');
+ SharedFolder::withTrashed()->where('email', 'like', '%@kolabnow.com')->each(function ($folder) {
+ $this->deleteTestSharedFolder($folder->email);
+ });
+
+ parent::tearDown();
+ }
+
+ /**
+ * Tests for SharedFolder::assignToWallet()
+ */
+ public function testAssignToWallet(): void
+ {
+ $user = $this->getTestUser('user-test@kolabnow.com');
+ $folder = $this->getTestSharedFolder('folder-test@kolabnow.com');
+
+ $result = $folder->assignToWallet($user->wallets->first());
+
+ $this->assertSame($folder, $result);
+ $this->assertSame(1, $folder->entitlements()->count());
+ $this->assertSame('shared_folder', $folder->entitlements()->first()->sku->title);
+
+ // Can't be done twice on the same folder
+ $this->expectException(\Exception::class);
+ $result->assignToWallet($user->wallets->first());
+ }
+
+ /**
+ * Test SharedFolder::getConfig() and setConfig() methods
+ */
+ public function testConfigTrait(): void
+ {
+ Queue::fake();
+
+ $folder = new SharedFolder();
+ $folder->email = 'folder-test@kolabnow.com';
+ $folder->name = 'Test';
+ $folder->save();
+ $john = $this->getTestUser('john@kolab.org');
+ $folder->assignToWallet($john->wallets->first());
+
+ $this->assertSame(['acl' => []], $folder->getConfig());
+
+ $result = $folder->setConfig(['acl' => ['anyone, read-only'], 'unknown' => false]);
+
+ $this->assertSame(['acl' => ['anyone, read-only']], $folder->getConfig());
+ $this->assertSame('["anyone, read-only"]', $folder->getSetting('acl'));
+ $this->assertSame(['unknown' => "The requested configuration parameter is not supported."], $result);
+
+ $result = $folder->setConfig(['acl' => ['anyone, unknown']]);
+
+ $this->assertSame(['acl' => ['anyone, read-only']], $folder->getConfig());
+ $this->assertSame('["anyone, read-only"]', $folder->getSetting('acl'));
+ $this->assertSame(['acl' => ["The entry format is invalid. Expected an email address."]], $result);
+
+ // Test valid user for ACL
+ $result = $folder->setConfig(['acl' => ['john@kolab.org, full']]);
+
+ $this->assertSame(['acl' => ['john@kolab.org, full']], $folder->getConfig());
+ $this->assertSame('["john@kolab.org, full"]', $folder->getSetting('acl'));
+ $this->assertSame([], $result);
+
+ // Test invalid user for ACL
+ $result = $folder->setConfig(['acl' => ['john, full']]);
+
+ $this->assertSame(['acl' => ['john@kolab.org, full']], $folder->getConfig());
+ $this->assertSame('["john@kolab.org, full"]', $folder->getSetting('acl'));
+ $this->assertSame(['acl' => ["The specified email address is invalid."]], $result);
+
+ // Other invalid entries
+ $acl = [
+ // Test non-existing user for ACL
+ 'unknown@kolab.org, full',
+ // Test existing user from a different wallet
+ 'user@sample-tenant.dev-local, read-only',
+ // Valid entry
+ 'john@kolab.org, read-write',
+ ];
+
+ $result = $folder->setConfig(['acl' => $acl]);
+ $this->assertCount(2, $result['acl']);
+ $this->assertSame("The specified email address does not exist.", $result['acl'][0]);
+ $this->assertSame("The specified email address does not exist.", $result['acl'][1]);
+ $this->assertSame(['acl' => ['john@kolab.org, full']], $folder->getConfig());
+ $this->assertSame('["john@kolab.org, full"]', $folder->getSetting('acl'));
+ }
+
+ /**
+ * Test creating a shared folder
+ */
+ public function testCreate(): void
+ {
+ Queue::fake();
+
+ $folder = new SharedFolder();
+ $folder->name = 'Reśo';
+ $folder->domain = 'kolabnow.com';
+ $folder->save();
+
+ $this->assertMatchesRegularExpression('/^[0-9]{1,20}$/', $folder->id);
+ $this->assertMatchesRegularExpression('/^mail-[0-9]{1,20}@kolabnow\.com$/', $folder->email);
+ $this->assertSame('Reśo', $folder->name);
+ $this->assertTrue($folder->isNew());
+ $this->assertTrue($folder->isActive());
+ $this->assertFalse($folder->isDeleted());
+ $this->assertFalse($folder->isLdapReady());
+ $this->assertFalse($folder->isImapReady());
+
+ $settings = $folder->settings()->get();
+ $this->assertCount(1, $settings);
+ $this->assertSame('folder', $settings[0]->key);
+ $this->assertSame('shared/Reśo@kolabnow.com', $settings[0]->value);
+
+ Queue::assertPushed(
+ \App\Jobs\SharedFolder\CreateJob::class,
+ function ($job) use ($folder) {
+ $folderEmail = TestCase::getObjectProperty($job, 'folderEmail');
+ $folderId = TestCase::getObjectProperty($job, 'folderId');
+
+ return $folderEmail === $folder->email
+ && $folderId === $folder->id;
+ }
+ );
+
+ Queue::assertPushedWithChain(
+ \App\Jobs\SharedFolder\CreateJob::class,
+ [
+ \App\Jobs\SharedFolder\VerifyJob::class,
+ ]
+ );
+ }
+
+ /**
+ * Test a shared folder deletion and force-deletion
+ */
+ public function testDelete(): void
+ {
+ Queue::fake();
+
+ $user = $this->getTestUser('user-test@kolabnow.com');
+ $folder = $this->getTestSharedFolder('folder-test@kolabnow.com');
+ $folder->assignToWallet($user->wallets->first());
+
+ $entitlements = \App\Entitlement::where('entitleable_id', $folder->id);
+
+ $this->assertSame(1, $entitlements->count());
+
+ $folder->delete();
+
+ $this->assertTrue($folder->fresh()->trashed());
+ $this->assertSame(0, $entitlements->count());
+ $this->assertSame(1, $entitlements->withTrashed()->count());
+
+ $folder->forceDelete();
+
+ $this->assertSame(0, $entitlements->withTrashed()->count());
+ $this->assertCount(0, SharedFolder::withTrashed()->where('id', $folder->id)->get());
+
+ Queue::assertPushed(\App\Jobs\SharedFolder\DeleteJob::class, 1);
+ Queue::assertPushed(
+ \App\Jobs\SharedFolder\DeleteJob::class,
+ function ($job) use ($folder) {
+ $folderEmail = TestCase::getObjectProperty($job, 'folderEmail');
+ $folderId = TestCase::getObjectProperty($job, 'folderId');
+
+ return $folderEmail === $folder->email
+ && $folderId === $folder->id;
+ }
+ );
+ }
+
+ /**
+ * Tests for SharedFolder::emailExists()
+ */
+ public function testEmailExists(): void
+ {
+ Queue::fake();
+
+ $folder = $this->getTestSharedFolder('folder-test@kolabnow.com');
+
+ $this->assertFalse(SharedFolder::emailExists('unknown@domain.tld'));
+ $this->assertTrue(SharedFolder::emailExists($folder->email));
+
+ $result = SharedFolder::emailExists($folder->email, true);
+ $this->assertSame($result->id, $folder->id);
+
+ $folder->delete();
+
+ $this->assertTrue(SharedFolder::emailExists($folder->email));
+
+ $result = SharedFolder::emailExists($folder->email, true);
+ $this->assertSame($result->id, $folder->id);
+ }
+
+ /**
+ * Tests for SettingsTrait functionality and SharedFolderSettingObserver
+ */
+ public function testSettings(): void
+ {
+ Queue::fake();
+ Queue::assertNothingPushed();
+
+ $folder = $this->getTestSharedFolder('folder-test@kolabnow.com');
+
+ Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 0);
+
+ // Add a setting
+ $folder->setSetting('unknown', 'test');
+
+ Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 0);
+
+ // Add a setting that is synced to LDAP
+ $folder->setSetting('acl', 'test');
+
+ Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 1);
+
+ // Note: We test both current folder as well as fresh folder object
+ // to make sure cache works as expected
+ $this->assertSame('test', $folder->getSetting('unknown'));
+ $this->assertSame('test', $folder->fresh()->getSetting('acl'));
+
+ Queue::fake();
+
+ // Update a setting
+ $folder->setSetting('unknown', 'test1');
+
+ Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 0);
+
+ // Update a setting that is synced to LDAP
+ $folder->setSetting('acl', 'test1');
+
+ Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 1);
+
+ $this->assertSame('test1', $folder->getSetting('unknown'));
+ $this->assertSame('test1', $folder->fresh()->getSetting('acl'));
+
+ Queue::fake();
+
+ // Delete a setting (null)
+ $folder->setSetting('unknown', null);
+
+ Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 0);
+
+ // Delete a setting that is synced to LDAP
+ $folder->setSetting('acl', null);
+
+ Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 1);
+
+ $this->assertSame(null, $folder->getSetting('unknown'));
+ $this->assertSame(null, $folder->fresh()->getSetting('acl'));
+ }
+
+ /**
+ * Test updating a shared folder
+ */
+ public function testUpdate(): void
+ {
+ Queue::fake();
+
+ $folder = $this->getTestSharedFolder('folder-test@kolabnow.com');
+
+ $folder->name = 'New';
+ $folder->save();
+
+ // Assert the imap folder changes on a folder name change
+ $settings = $folder->settings()->where('key', 'folder')->get();
+ $this->assertCount(1, $settings);
+ $this->assertSame('shared/New@kolabnow.com', $settings[0]->value);
+
+ Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 1);
+ Queue::assertPushed(
+ \App\Jobs\SharedFolder\UpdateJob::class,
+ function ($job) use ($folder) {
+ $folderEmail = TestCase::getObjectProperty($job, 'folderEmail');
+ $folderId = TestCase::getObjectProperty($job, 'folderId');
+
+ return $folderEmail === $folder->email
+ && $folderId === $folder->id;
+ }
+ );
+ }
+}
diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php
--- a/src/tests/Feature/UserTest.php
+++ b/src/tests/Feature/UserTest.php
@@ -20,6 +20,7 @@
$this->deleteTestUser('UserAccountC@UserAccount.com');
$this->deleteTestGroup('test-group@UserAccount.com');
$this->deleteTestResource('test-resource@UserAccount.com');
+ $this->deleteTestSharedFolder('test-folder@UserAccount.com');
$this->deleteTestDomain('UserAccount.com');
$this->deleteTestDomain('UserAccountAdd.com');
}
@@ -33,6 +34,7 @@
$this->deleteTestUser('UserAccountC@UserAccount.com');
$this->deleteTestGroup('test-group@UserAccount.com');
$this->deleteTestResource('test-resource@UserAccount.com');
+ $this->deleteTestSharedFolder('test-folder@UserAccount.com');
$this->deleteTestDomain('UserAccount.com');
$this->deleteTestDomain('UserAccountAdd.com');
@@ -459,6 +461,8 @@
$group->assignToWallet($userA->wallets->first());
$resource = $this->getTestResource('test-resource@UserAccount.com', ['name' => 'test']);
$resource->assignToWallet($userA->wallets->first());
+ $folder = $this->getTestSharedFolder('test-folder@UserAccount.com', ['name' => 'test']);
+ $folder->assignToWallet($userA->wallets->first());
$entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id);
$entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id);
@@ -466,6 +470,7 @@
$entitlementsDomain = \App\Entitlement::where('entitleable_id', $domain->id);
$entitlementsGroup = \App\Entitlement::where('entitleable_id', $group->id);
$entitlementsResource = \App\Entitlement::where('entitleable_id', $resource->id);
+ $entitlementsFolder = \App\Entitlement::where('entitleable_id', $folder->id);
$this->assertSame(7, $entitlementsA->count());
$this->assertSame(7, $entitlementsB->count());
@@ -473,6 +478,7 @@
$this->assertSame(1, $entitlementsDomain->count());
$this->assertSame(1, $entitlementsGroup->count());
$this->assertSame(1, $entitlementsResource->count());
+ $this->assertSame(1, $entitlementsFolder->count());
// Delete non-controller user
$userC->delete();
@@ -489,16 +495,19 @@
$this->assertSame(0, $entitlementsDomain->count());
$this->assertSame(0, $entitlementsGroup->count());
$this->assertSame(0, $entitlementsResource->count());
+ $this->assertSame(0, $entitlementsFolder->count());
$this->assertTrue($userA->fresh()->trashed());
$this->assertTrue($userB->fresh()->trashed());
$this->assertTrue($domain->fresh()->trashed());
$this->assertTrue($group->fresh()->trashed());
$this->assertTrue($resource->fresh()->trashed());
+ $this->assertTrue($folder->fresh()->trashed());
$this->assertFalse($userA->isDeleted());
$this->assertFalse($userB->isDeleted());
$this->assertFalse($domain->isDeleted());
$this->assertFalse($group->isDeleted());
$this->assertFalse($resource->isDeleted());
+ $this->assertFalse($folder->isDeleted());
$userA->forceDelete();
@@ -511,6 +520,7 @@
$this->assertCount(0, Domain::withTrashed()->where('id', $domain->id)->get());
$this->assertCount(0, Group::withTrashed()->where('id', $group->id)->get());
$this->assertCount(0, \App\Resource::withTrashed()->where('id', $resource->id)->get());
+ $this->assertCount(0, \App\SharedFolder::withTrashed()->where('id', $folder->id)->get());
}
/**
@@ -730,6 +740,32 @@
$this->assertSame(0, $resources->count());
}
+ /**
+ * Test sharedFolders() method
+ */
+ public function testSharedFolders(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $ned = $this->getTestUser('ned@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+
+ $folders = $john->sharedFolders()->orderBy('email')->get();
+
+ $this->assertSame(2, $folders->count());
+ $this->assertSame('folder-contact@kolab.org', $folders[0]->email);
+ $this->assertSame('folder-event@kolab.org', $folders[1]->email);
+
+ $folders = $ned->sharedFolders()->orderBy('email')->get();
+
+ $this->assertSame(2, $folders->count());
+ $this->assertSame('folder-contact@kolab.org', $folders[0]->email);
+ $this->assertSame('folder-event@kolab.org', $folders[1]->email);
+
+ $folders = $jack->sharedFolders()->get();
+
+ $this->assertSame(0, $folders->count());
+ }
+
/**
* Test user restoring
*/
diff --git a/src/tests/TestCaseTrait.php b/src/tests/TestCaseTrait.php
--- a/src/tests/TestCaseTrait.php
+++ b/src/tests/TestCaseTrait.php
@@ -5,6 +5,7 @@
use App\Domain;
use App\Group;
use App\Resource;
+use App\SharedFolder;
use App\Sku;
use App\Transaction;
use App\User;
@@ -154,6 +155,7 @@
$beta_handlers = [
'App\Handlers\Beta',
'App\Handlers\Beta\Resources',
+ 'App\Handlers\Beta\SharedFolders',
'App\Handlers\Distlist',
];
@@ -317,6 +319,27 @@
$resource->forceDelete();
}
+ /**
+ * Delete a test shared folder whatever it takes.
+ *
+ * @coversNothing
+ */
+ protected function deleteTestSharedFolder($email)
+ {
+ Queue::fake();
+
+ $folder = SharedFolder::withTrashed()->where('email', $email)->first();
+
+ if (!$folder) {
+ return;
+ }
+
+ $job = new \App\Jobs\SharedFolder\DeleteJob($folder->id);
+ $job->handle();
+
+ $folder->forceDelete();
+ }
+
/**
* Delete a test user whatever it takes.
*
@@ -375,7 +398,7 @@
}
/**
- * Get Resource object by name+domain, create it if needed.
+ * Get Resource object by email, create it if needed.
* Skip LDAP jobs.
*/
protected function getTestResource($email, $attrib = [])
@@ -406,6 +429,38 @@
return $resource;
}
+ /**
+ * Get SharedFolder object by email, create it if needed.
+ * Skip LDAP jobs.
+ */
+ protected function getTestSharedFolder($email, $attrib = [])
+ {
+ // Disable jobs (i.e. skip LDAP oprations)
+ Queue::fake();
+
+ $folder = SharedFolder::where('email', $email)->first();
+
+ if (!$folder) {
+ list($local, $domain) = explode('@', $email, 2);
+
+ $folder = new SharedFolder();
+ $folder->email = $email;
+ $folder->domain = $domain;
+
+ if (!isset($attrib['name'])) {
+ $folder->name = $local;
+ }
+ }
+
+ foreach ($attrib as $key => $val) {
+ $folder->{$key} = $val;
+ }
+
+ $folder->save();
+
+ return $folder;
+ }
+
/**
* Get User object by email, create it if needed.
* Skip LDAP jobs.
diff --git a/src/tests/Unit/Rules/ResourceNameTest.php b/src/tests/Unit/Rules/ResourceNameTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Unit/Rules/ResourceNameTest.php
@@ -0,0 +1,47 @@
+<?php
+
+namespace Tests\Unit\Rules;
+
+use App\Rules\ResourceName;
+use Illuminate\Support\Facades\Validator;
+use Tests\TestCase;
+
+class ResourceNameTest extends TestCase
+{
+ /**
+ * Tests the resource name validator
+ */
+ public function testValidation(): void
+ {
+ $user = $this->getTestUser('john@kolab.org');
+ $rules = ['name' => ['present', new ResourceName($user, 'kolab.org')]];
+
+ // Empty/invalid input
+ $v = Validator::make(['name' => null], $rules);
+ $this->assertSame(['name' => ["The specified name is invalid."]], $v->errors()->toArray());
+
+ $v = Validator::make(['name' => []], $rules);
+ $this->assertSame(['name' => ["The specified name is invalid."]], $v->errors()->toArray());
+
+ // Forbidden chars
+ $v = Validator::make(['name' => 'Test@'], $rules);
+ $this->assertSame(['name' => ["The specified name is invalid."]], $v->errors()->toArray());
+
+ // Length limit
+ $v = Validator::make(['name' => str_repeat('a', 192)], $rules);
+ $this->assertSame(['name' => ["The name may not be greater than 191 characters."]], $v->errors()->toArray());
+
+ // Existing resource
+ $v = Validator::make(['name' => 'Conference Room #1'], $rules);
+ $this->assertSame(['name' => ["The specified name is not available."]], $v->errors()->toArray());
+
+ // Valid name
+ $v = Validator::make(['name' => 'TestRule'], $rules);
+ $this->assertSame([], $v->errors()->toArray());
+
+ // Invalid domain
+ $rules = ['name' => ['present', new ResourceName($user, 'kolabnow.com')]];
+ $v = Validator::make(['name' => 'TestRule'], $rules);
+ $this->assertSame(['name' => ["The specified domain is invalid."]], $v->errors()->toArray());
+ }
+}
diff --git a/src/tests/Unit/Rules/SharedFolderNameTest.php b/src/tests/Unit/Rules/SharedFolderNameTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Unit/Rules/SharedFolderNameTest.php
@@ -0,0 +1,47 @@
+<?php
+
+namespace Tests\Unit\Rules;
+
+use App\Rules\SharedFolderName;
+use Illuminate\Support\Facades\Validator;
+use Tests\TestCase;
+
+class SharedFolderNameTest extends TestCase
+{
+ /**
+ * Tests the shared folder name validator
+ */
+ public function testValidation(): void
+ {
+ $user = $this->getTestUser('john@kolab.org');
+ $rules = ['name' => ['present', new SharedFolderName($user, 'kolab.org')]];
+
+ // Empty/invalid input
+ $v = Validator::make(['name' => null], $rules);
+ $this->assertSame(['name' => ["The specified name is invalid."]], $v->errors()->toArray());
+
+ $v = Validator::make(['name' => []], $rules);
+ $this->assertSame(['name' => ["The specified name is invalid."]], $v->errors()->toArray());
+
+ // Forbidden chars
+ $v = Validator::make(['name' => 'Test@'], $rules);
+ $this->assertSame(['name' => ["The specified name is invalid."]], $v->errors()->toArray());
+
+ // Length limit
+ $v = Validator::make(['name' => str_repeat('a', 192)], $rules);
+ $this->assertSame(['name' => ["The name may not be greater than 191 characters."]], $v->errors()->toArray());
+
+ // Existing resource
+ $v = Validator::make(['name' => 'Calendar'], $rules);
+ $this->assertSame(['name' => ["The specified name is not available."]], $v->errors()->toArray());
+
+ // Valid name
+ $v = Validator::make(['name' => 'TestRule'], $rules);
+ $this->assertSame([], $v->errors()->toArray());
+
+ // Invalid domain
+ $rules = ['name' => ['present', new SharedFolderName($user, 'kolabnow.com')]];
+ $v = Validator::make(['name' => 'TestRule'], $rules);
+ $this->assertSame(['name' => ["The specified domain is invalid."]], $v->errors()->toArray());
+ }
+}
diff --git a/src/tests/Unit/Rules/SharedFolderTypeTest.php b/src/tests/Unit/Rules/SharedFolderTypeTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Unit/Rules/SharedFolderTypeTest.php
@@ -0,0 +1,35 @@
+<?php
+
+namespace Tests\Unit\Rules;
+
+use App\Rules\SharedFolderType;
+use Illuminate\Support\Facades\Validator;
+use Tests\TestCase;
+
+class SharedFolderTypeTest extends TestCase
+{
+ /**
+ * Tests the shared folder type validator
+ */
+ public function testValidation(): void
+ {
+ $user = $this->getTestUser('john@kolab.org');
+ $rules = ['type' => ['present', new SharedFolderType($user, 'kolab.org')]];
+
+ // Empty/invalid input
+ $v = Validator::make(['type' => null], $rules);
+ $this->assertSame(['type' => ["The specified type is invalid."]], $v->errors()->toArray());
+
+ $v = Validator::make(['type' => []], $rules);
+ $this->assertSame(['type' => ["The specified type is invalid."]], $v->errors()->toArray());
+
+ $v = Validator::make(['type' => 'Test'], $rules);
+ $this->assertSame(['type' => ["The specified type is invalid."]], $v->errors()->toArray());
+
+ // Valid type
+ foreach (\App\SharedFolder::SUPPORTED_TYPES as $type) {
+ $v = Validator::make(['type' => $type], $rules);
+ $this->assertSame([], $v->errors()->toArray());
+ }
+ }
+}
diff --git a/src/tests/Unit/SharedFolderTest.php b/src/tests/Unit/SharedFolderTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Unit/SharedFolderTest.php
@@ -0,0 +1,85 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\SharedFolder;
+use Tests\TestCase;
+
+class SharedFolderTest extends TestCase
+{
+ /**
+ * Test SharedFolder status property and is*() methods
+ */
+ public function testSharedFolderStatus(): void
+ {
+ $statuses = [
+ SharedFolder::STATUS_NEW,
+ SharedFolder::STATUS_ACTIVE,
+ SharedFolder::STATUS_DELETED,
+ SharedFolder::STATUS_LDAP_READY,
+ SharedFolder::STATUS_IMAP_READY,
+ ];
+
+ $folders = \App\Utils::powerSet($statuses);
+
+ $folder = new SharedFolder(['name' => 'test']);
+
+ foreach ($folders as $folderStatuses) {
+ $folder->status = \array_sum($folderStatuses);
+
+ $folderStatuses = [];
+
+ foreach ($statuses as $status) {
+ if ($folder->status & $status) {
+ $folderStatuses[] = $status;
+ }
+ }
+
+ $this->assertSame($folder->status, \array_sum($folderStatuses));
+
+ // either one is true, but not both
+ $this->assertSame(
+ $folder->isNew() === in_array(SharedFolder::STATUS_NEW, $folderStatuses),
+ $folder->isActive() === in_array(SharedFolder::STATUS_ACTIVE, $folderStatuses)
+ );
+
+ $this->assertTrue(
+ $folder->isNew() === in_array(SharedFolder::STATUS_NEW, $folderStatuses)
+ );
+
+ $this->assertTrue(
+ $folder->isActive() === in_array(SharedFolder::STATUS_ACTIVE, $folderStatuses)
+ );
+
+ $this->assertTrue(
+ $folder->isDeleted() === in_array(SharedFolder::STATUS_DELETED, $folderStatuses)
+ );
+
+ $this->assertTrue(
+ $folder->isLdapReady() === in_array(SharedFolder::STATUS_LDAP_READY, $folderStatuses)
+ );
+
+ $this->assertTrue(
+ $folder->isImapReady() === in_array(SharedFolder::STATUS_IMAP_READY, $folderStatuses)
+ );
+ }
+
+ $this->expectException(\Exception::class);
+ $folder->status = 111;
+ }
+
+ /**
+ * Test basic SharedFolder funtionality
+ */
+ public function testSharedFolderType(): void
+ {
+ $folder = new SharedFolder(['name' => 'test']);
+
+ foreach (SharedFolder::SUPPORTED_TYPES as $type) {
+ $folder->type = $type;
+ }
+
+ $this->expectException(\Exception::class);
+ $folder->type = 'unknown';
+ }
+}
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Sat, Apr 4, 10:50 PM (21 h, 11 m ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18831479
Default Alt Text
D3071.1775343006.diff (231 KB)
Attached To
Mode
D3071: Shared folders
Attached
Detach File
Event Timeline