Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117892592
D5172.1775365797.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
122 KB
Referenced Files
None
Subscribers
None
D5172.1775365797.diff
View Options
diff --git a/src/app/Backends/DAV.php b/src/app/Backends/DAV.php
--- a/src/app/Backends/DAV.php
+++ b/src/app/Backends/DAV.php
@@ -2,6 +2,7 @@
namespace App\Backends;
+use App\User;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
@@ -18,12 +19,6 @@
self::TYPE_VCARD => 'urn:ietf:params:xml:ns:carddav',
];
- public const SHARING_READ = 'read';
- public const SHARING_READ_WRITE = 'read-write';
- public const SHARING_NO_ACCESS = 'no-access';
- public const SHARING_OWNER = 'shared-owner';
- public const SHARING_NOT_SHARED = 'not-shared';
-
protected $url;
protected $user;
protected $password;
@@ -177,31 +172,10 @@
return false;
}
- $ns = 'xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/"';
- $props = '';
-
- if ($component != self::TYPE_VCARD) {
- $ns .= ' xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:a="http://apple.com/ns/ical/" xmlns:k="Kolab:"';
- $props = '<c:supported-calendar-component-set />'
- . '<a:calendar-color />'
- . '<k:alarms />';
- }
-
- $body = '<?xml version="1.0" encoding="utf-8"?>'
- . '<d:propfind ' . $ns . '>'
- . '<d:prop>'
- . '<d:resourcetype />'
- . '<d:displayname />'
- . '<d:owner/>'
- . '<cs:getctag />'
- . $props
- . '</d:prop>'
- . '</d:propfind>';
-
// Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it)
$headers = ['Depth' => 1, 'Prefer' => 'return-minimal'];
- $response = $this->request($root_href, 'PROPFIND', $body, $headers);
+ $response = $this->request($root_href, 'PROPFIND', DAV\Folder::propfindXML(), $headers);
if (empty($response)) {
\Log::error("Failed to get folders list from the DAV server.");
@@ -351,14 +325,32 @@
return $response !== false;
}
+ /**
+ * Get a single DAV notification
+ *
+ * @param string $location Notification href
+ *
+ * @return ?DAV\Notification Notification data on success
+ */
+ public function getNotification($location)
+ {
+ $response = $this->request($location, 'GET');
+
+ if ($response && ($element = $response->getElementsByTagName('notification')->item(0))) {
+ return DAV\Notification::fromDomElement($element, $location);
+ }
+
+ return null;
+ }
+
/**
* Initialize default DAV folders (collections)
*
- * @param \App\User $user User object
+ * @param User $user User object
*
* @throws \Exception
*/
- public static function initDefaultFolders(\App\User $user): void
+ public static function initDefaultFolders(User $user): void
{
if (!\config('services.dav.uri')) {
return;
@@ -369,16 +361,7 @@
return;
}
- // Cyrus DAV does not support proxy authorization via DAV. Even though it has
- // the Authorize-As header, it is used only for cummunication with Murder backends.
- // We use a one-time token instead. It's valid for 10 seconds, assume it's enough time.
- $password = \App\Auth\Utils::tokenCreate((string) $user->id);
-
- if ($password === null) {
- throw new \Exception("Failed to create an authentication token for DAV");
- }
-
- $dav = new self($user->email, $password);
+ $dav = self::getClientForUser($user);
foreach ($folders as $props) {
$folder = new DAV\Folder();
@@ -410,6 +393,71 @@
}
}
+ /**
+ * Accept/Deny a share invitation (draft-pot-webdav-resource-sharing)
+ *
+ * @param string $location Notification location
+ * @param DAV\InviteReply $reply Invite reply
+ *
+ * @return bool True on success, False on error
+ */
+ public function inviteReply($location, $reply): bool
+ {
+ $headers = ['Content-Type' => $reply::CTYPE];
+
+ $response = $this->request($location, 'POST', $reply->toXML(), $headers);
+
+ return $response !== false;
+ }
+
+ /**
+ * Fetch DAV notifications
+ *
+ * @param array $types Notification types to return
+ *
+ * @return array<DAV\Notification> Notification objects
+ */
+ public function listNotifications(array $types = []): array
+ {
+ $root_href = $this->getHome(self::TYPE_NOTIFICATION);
+
+ if ($root_href === null) {
+ return [];
+ }
+
+ // FIXME: As far as I can see there's no other way to get only the notifications we're interested in
+
+ $body = DAV\Notification::propfindXML();
+
+ $response = $this->request($root_href, 'PROPFIND', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']);
+
+ if (empty($response)) {
+ return [];
+ }
+
+ $objects = [];
+
+ foreach ($response->getElementsByTagName('response') as $element) {
+ $type = $element->getElementsByTagName('notificationtype')->item(0);
+ if (!$type || !$type->firstChild) {
+ // skip non-notification elements (e.g. a parent folder)
+ continue;
+ }
+
+ $type = $type->firstChild->localName;
+
+ if (empty($types) || in_array($type, $types)) {
+ if ($href = $element->getElementsByTagName('href')->item(0)) {
+ if ($n = $this->getNotification($href->nodeValue)) {
+ $objects[] = $n;
+ }
+ }
+ }
+ }
+
+ return $objects;
+ }
+
/**
* Check server options (and authentication)
*
@@ -477,57 +525,145 @@
}
/**
- * Set folder sharing invites (draft-pot-webdav-resource-sharing)
+ * Share default DAV folders with specified user (delegatee)
*
- * @param string $location Resource (folder) location
- * @param array $sharees Map of sharee => privilege
+ * @param User $user The user
+ * @param User $to The delegated user
+ * @param array $acl ACL Permissions per folder type
*
- * @return bool True on success, False on error
+ * @throws \Exception
*/
- public function shareResource(string $location, array $sharees): bool
+ public static function shareDefaultFolders(User $user, User $to, array $acl): void
{
- // TODO: This might need to be configurable or discovered somehow
- $path = '/principals/user/';
- if ($host_path = parse_url($this->url, PHP_URL_PATH)) {
- $path = '/' . trim($host_path, '/') . $path;
+ if (!\config('services.dav.uri')) {
+ return;
}
- $props = '';
+ $folders = [];
+ foreach (\config('services.dav.default_folders') as $folder) {
+ if ($folder['type'] == 'addressbook') {
+ $type = 'contact';
+ } elseif (in_array('VTODO', $folder['components'] ?? [])) {
+ $type = 'task';
+ } elseif (in_array('VEVENT', $folder['components'] ?? [])) {
+ $type = 'event';
+ } else {
+ continue;
+ }
- foreach ($sharees as $href => $sharee) {
- if (!is_array($sharee)) {
- $sharee = ['access' => $sharee];
+ if (!empty($acl[$type])) {
+ $folders[] = [
+ 'href' => $folder['type'] . 's' . '/user/' . $user->email . '/' . $folder['path'],
+ 'acl' => $acl[$type] == 'read-write'
+ ? DAV\ShareResource::ACCESS_READ_WRITE : DAV\ShareResource::ACCESS_READ,
+ ];
}
+ }
- $href = $path . $href;
- $props .= '<d:sharee>'
- . '<d:href>' . htmlspecialchars($href, ENT_XML1, 'UTF-8') . '</d:href>'
- . '<d:share-access><d:' . ($sharee['access'] ?? self::SHARING_NO_ACCESS) . '/></d:share-access>'
- . '<d:' . ($sharee['status'] ?? 'noresponse') . '/>';
+ if (empty($folders)) {
+ return;
+ }
- if (isset($sharee['comment']) && strlen($sharee['comment'])) {
- $props .= '<d:comment>' . htmlspecialchars($sharee['comment'], ENT_XML1, 'UTF-8') . '</d:comment>';
- }
+ $dav = self::getClientForUser($user);
- if (isset($sharee['displayname']) && strlen($sharee['displayname'])) {
- $props .= '<d:prop><d:displayname>'
- . htmlspecialchars($sharee['comment'], ENT_XML1, 'UTF-8')
- . '</d:displayname></d:prop>';
+ // Create sharing invitations
+ foreach ($folders as $folder) {
+ $share_resource = new DAV\ShareResource();
+ $share_resource->href = $folder['href'];
+ $share_resource->sharees = [$dav->principalLocation($to->email) => $folder['acl']];
+ if (!$dav->shareResource($share_resource)) {
+ throw new \Exception("Failed to share DAV folder {$folder['href']}");
}
-
- $props .= '</d:sharee>';
}
- $headers = ['Content-Type' => 'application/davsharing+xml; charset=utf-8'];
+ // Accept sharing invitations
+ $dav = self::getClientForUser($to);
- $body = '<?xml version="1.0" encoding="utf-8"?>'
- . '<d:share-resource xmlns:d="DAV:">' . $props . '</d:share-resource>';
+ // FIXME/TODO: It would be nice to be able to fetch only notifications that are:
+ // - created by the $user
+ // - are invite notification with invite-noresponse, or only these created in last minute
+ // Right now we'll do this filtering here
+ foreach ($dav->listNotifications([DAV\Notification::NOTIFICATION_SHARE_INVITE]) as $n) {
+ if (
+ $n->status == $n::INVITE_NORESPONSE
+ && strpos((string) $n->principal, "/user/{$user->email}")
+ ) {
+ $reply = new DAV\InviteReply();
+ $reply->type = $reply::INVITE_ACCEPTED;
+
+ if (!$dav->inviteReply($n->href, $reply)) {
+ throw new \Exception("Failed to accept DAV share invitation {$n->href}");
+ }
+ }
+ }
+ }
- $response = $this->request($location, 'POST', $body, $headers);
+ /**
+ * Set folder sharing invites (draft-pot-webdav-resource-sharing)
+ *
+ * @param DAV\ShareResource $resource Share resource
+ *
+ * @return bool True on success, False on error
+ */
+ public function shareResource(DAV\ShareResource $resource): bool
+ {
+ $headers = ['Content-Type' => $resource::CTYPE];
+
+ $response = $this->request($resource->href, 'POST', $resource->toXML(), $headers);
return $response !== false;
}
+ /**
+ * Unshare folders.
+ *
+ * @param User $user Folders' owner
+ * @param string $email Delegated user
+ *
+ * @throws \Exception
+ */
+ public static function unshareFolders(User $user, string $email): void
+ {
+ $dav = self::getClientForUser($user);
+
+ foreach ([self::TYPE_VEVENT, self::TYPE_VTODO, self::TYPE_VCARD] as $type) {
+ foreach ($dav->listFolders($type) as $folder) {
+ if ($folder->owner === $user->email && isset($folder->invites["mailto:{$email}"])) {
+ $share_resource = new DAV\ShareResource();
+ $share_resource->href = $folder->href;
+ $share_resource->sharees = [$dav->principalLocation($email) => $share_resource::ACCESS_NONE];
+ if (!$dav->shareResource($share_resource)) {
+ throw new \Exception("Failed to unshare DAV folder {$folder->href}");
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Unsubscribe folders shared by other users.
+ *
+ * @param User $user Account owner
+ * @param string $email Other user email address
+ *
+ * @throws \Exception
+ */
+ public static function unsubscribeSharedFolders(User $user, string $email): void
+ {
+ $dav = self::getClientForUser($user);
+
+ foreach ([self::TYPE_VEVENT, self::TYPE_VTODO, self::TYPE_VCARD] as $type) {
+ foreach ($dav->listFolders($type) as $folder) {
+ if ($folder->owner === $email && $user->email != $email) {
+ $response = $dav->request($folder->href, 'DELETE');
+ if ($response === false) {
+ throw new \Exception("Failed to unsubscribe DAV folder {$folder->href}");
+ }
+ }
+ }
+ }
+ }
+
/**
* Fetch DAV objects data from a folder
*
@@ -585,6 +721,23 @@
return $objects;
}
+ /**
+ * Create DAV client instance for a user (using generated auth token as password)
+ */
+ protected static function getClientForUser(User $user): DAV
+ {
+ // Cyrus DAV does not support proxy authorization via DAV. Even though it has
+ // the Authorize-As header, it is used only for cummunication with Murder backends.
+ // We use a one-time token instead. It's valid for 10 seconds, assume it's enough time.
+ $password = \App\Auth\Utils::tokenCreate((string) $user->id);
+
+ if ($password === null) {
+ throw new \Exception("Failed to create an authentication token for DAV");
+ }
+
+ return new self($user->email, $password);
+ }
+
/**
* Parse XML content
*/
@@ -660,10 +813,27 @@
return $object;
}
+ /**
+ * Convert user email address into a principal location (href)
+ *
+ * @param string $email Email address
+ */
+ public function principalLocation(string $email): string
+ {
+ // TODO: This might need to be configurable or discovered somehow,
+ // maybe get it from current-user-principal property that we read in discover()
+ $path = '/principals/user/';
+ if ($host_path = parse_url($this->url, PHP_URL_PATH)) {
+ $path = '/' . trim($host_path, '/') . $path;
+ }
+
+ return $path . $email;
+ }
+
/**
* Execute HTTP request to a DAV server
*/
- protected function request($path, $method, $body = '', $headers = [])
+ public function request($path, $method, $body = '', $headers = [])
{
$debug = \config('app.debug');
$url = $this->url;
diff --git a/src/app/Backends/DAV/Folder.php b/src/app/Backends/DAV/Folder.php
--- a/src/app/Backends/DAV/Folder.php
+++ b/src/app/Backends/DAV/Folder.php
@@ -4,6 +4,11 @@
class Folder
{
+ public const SHARE_ACCESS_NONE = 'not-shared';
+ public const SHARE_ACCESS_SHARED = 'shared-owner';
+ public const SHARE_ACCESS_READ = 'read';
+ public const SHARE_ACCESS_READ_WRITE = 'read-write';
+
/** @var ?string Folder location (href property) */
public $href;
@@ -19,6 +24,9 @@
/** @var array Supported resource types (resourcetype property) */
public $types = [];
+ /** @var ?string Access rights on a shared folder (share-access property) */
+ public $shareAccess;
+
/** @var ?string Folder color (calendar-color property) */
public $color;
@@ -87,6 +95,13 @@
}
}
+ // 'share-access' from draft-pot-webdav-resource-sharing
+ if ($share = $element->getElementsByTagName('share-access')->item(0)) {
+ if ($share->firstChild) {
+ $folder->shareAccess = $share->firstChild->localName;
+ }
+ }
+
// 'invite' from draft-pot-webdav-resource-sharing
if ($invite_element = $element->getElementsByTagName('invite')->item(0)) {
$invites = [];
@@ -106,7 +121,7 @@
if ($access = $sharee->getElementsByTagName('share-access')->item(0)) {
$access = $access->firstChild->localName;
} else {
- $access = \App\Backends\DAV::SHARING_NOT_SHARED;
+ $access = self::SHARE_ACCESS_NONE;
}
$props = [
@@ -198,7 +213,7 @@
{
$ns = implode(' ', [
'xmlns:d="DAV:"',
- // 'xmlns:cs="http://calendarserver.org/ns/"',
+ 'xmlns:cs="http://calendarserver.org/ns/"',
'xmlns:c="urn:ietf:params:xml:ns:caldav"',
'xmlns:a="http://apple.com/ns/ical/"',
// 'xmlns:k="Kolab:"'
@@ -210,11 +225,13 @@
. '<d:prop>'
. '<a:calendar-color/>'
. '<c:supported-calendar-component-set/>'
- // . '<cs:getctag/>'
+ . '<cs:getctag/>'
// . '<d:acl/>'
// . '<d:current-user-privilege-set/>'
. '<d:resourcetype/>'
. '<d:displayname/>'
+ . '<d:share-access/>' // draft-pot-webdav-resource-sharing-04
+ . '<d:owner/>'
. '<d:invite/>'
// . '<k:alarms/>'
. '</d:prop>'
diff --git a/src/app/Backends/DAV/InviteReply.php b/src/app/Backends/DAV/InviteReply.php
new file mode 100644
--- /dev/null
+++ b/src/app/Backends/DAV/InviteReply.php
@@ -0,0 +1,57 @@
+<?php
+
+namespace App\Backends\DAV;
+
+class InviteReply
+{
+ public const CTYPE = 'application/davsharing+xml; charset=utf-8';
+
+ public const INVITE_ACCEPTED = 'accepted';
+ public const INVITE_DECLINED = 'declined';
+
+ /** @var ?string Invite reply type */
+ public $type;
+
+ /** @var ?string Invite reply comment */
+ public $comment;
+
+
+ /**
+ * Create Notification object from a DOMElement element
+ *
+ * @param \DOMElement $element DOM element with notification properties
+ *
+ * @return Notification
+ */
+ public static function fromDomElement(\DOMElement $element)
+ {
+ throw new \Exception("Not implemented");
+ }
+
+ /**
+ * Convert an invite reply into an XML string to use in a request
+ */
+ public function toXML(): string
+ {
+ $reply = '<d:invite-' . ($this->type ?: self::INVITE_ACCEPTED) . '/>';
+
+ // Note: <create-in> and <slug> are ignored by Cyrus
+
+ if (!empty($this->comment)) {
+ $reply .= '<d:comment>' . htmlspecialchars($this->comment, ENT_XML1, 'UTF-8') . '</d:comment>';
+ }
+
+ return '<?xml version="1.0" encoding="utf-8"?>'
+ . '<d:invite-reply xmlns:d="DAV:">' . $reply . '</d:invite-reply>';
+ }
+
+ /**
+ * Get XML string for PROPFIND query on a invite reply
+ *
+ * @return string
+ */
+ public static function propfindXML()
+ {
+ throw new \Exception("Not implemented");
+ }
+}
diff --git a/src/app/Backends/DAV/Notification.php b/src/app/Backends/DAV/Notification.php
new file mode 100644
--- /dev/null
+++ b/src/app/Backends/DAV/Notification.php
@@ -0,0 +1,114 @@
+<?php
+
+namespace App\Backends\DAV;
+
+class Notification
+{
+ public const NOTIFICATION_SHARE_INVITE = 'share-invite-notification';
+ public const NOTIFICATION_SHARE_REPLY = 'share-reply-notification';
+
+ public const INVITE_NORESPONSE = 'invite-noresponse';
+ public const INVITE_ACCEPTED = 'invite-accepted';
+ public const INVITE_DECLINED = 'invite-declined';
+ public const INVITE_INVALID = 'invite-invalid';
+ public const INVITE_DELETED = 'invite-deleted';
+
+ public const INVITE_STATES = [
+ self::INVITE_NORESPONSE,
+ self::INVITE_ACCEPTED,
+ self::INVITE_DECLINED,
+ self::INVITE_INVALID,
+ self::INVITE_DELETED,
+ ];
+
+ /** @var ?string Notification (invitation) share-access property */
+ public $access;
+
+ /** @var ?string Notification location */
+ public $href;
+
+ /** @var ?string Notification type */
+ public $type;
+
+ /** @var ?string Notification (invitation) status */
+ public $status;
+
+ /** @var ?string Notification (invitation) principal (organizer) */
+ public $principal;
+
+
+ /**
+ * Create Notification object from a DOMElement element
+ *
+ * @param \DOMElement $element DOM element with notification properties
+ * @param string $href Notification location
+ *
+ * @return Notification
+ */
+ public static function fromDomElement(\DOMElement $element, string $href)
+ {
+ $notification = new self();
+ $notification->href = $href;
+
+ if ($type = $element->getElementsByTagName('notificationtype')->item(0)) {
+ if ($type->firstChild) {
+ $notification->type = $type->firstChild->localName;
+ }
+ }
+
+ if ($access = $element->getElementsByTagName('access')->item(0)) {
+ if ($access->firstChild) {
+ $notification->access = $access->firstChild->localName; // 'read' or 'read-write'
+ }
+ }
+
+ foreach (self::INVITE_STATES as $name) {
+ if ($node = $element->getElementsByTagName($name)->item(0)) {
+ $notification->status = $node->localName;
+ }
+ }
+
+ if ($organizer = $element->getElementsByTagName('organizer')->item(0)) {
+ if ($href = $organizer->getElementsByTagName('href')->item(0)) {
+ $notification->principal = $href->nodeValue;
+ }
+ // There should be also 'displayname', but Cyrus uses 'common-name',
+ // we'll ignore it for now anyway.
+ } elseif ($principal = $element->getElementsByTagName('principal')->item(0)) {
+ if ($href = $principal->getElementsByTagName('href')->item(0)) {
+ $notification->principal = $href->nodeValue;
+ }
+ // There should be also 'displayname', but Cyrus uses 'common-name',
+ // we'll ignore it for now anyway.
+ }
+
+ return $notification;
+ }
+
+ /**
+ * Parse notification properties input into XML string to use in a request
+ *
+ * @return string
+ */
+ public function toXML($tag)
+ {
+ // TODO:
+ throw new \Exception("Not implemented");
+ }
+
+ /**
+ * Get XML string for PROPFIND query on a notification
+ *
+ * @return string
+ */
+ public static function propfindXML()
+ {
+ // Note: With <d:allprop/> notificationtype is not returned, but it's essential
+ return '<?xml version="1.0" encoding="utf-8"?>'
+ . '<d:propfind xmlns:d="DAV:">'
+ . '<d:prop>'
+ . '<d:notificationtype/>'
+ . '</d:prop>'
+ . '</d:propfind>';
+ }
+}
diff --git a/src/app/Backends/DAV/ShareResource.php b/src/app/Backends/DAV/ShareResource.php
new file mode 100644
--- /dev/null
+++ b/src/app/Backends/DAV/ShareResource.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace App\Backends\DAV;
+
+class ShareResource
+{
+ public const CTYPE = 'application/davsharing+xml; charset=utf-8';
+
+ public const ACCESS_NONE = 'no-access';
+ public const ACCESS_READ = 'read';
+ public const ACCESS_READ_WRITE = 'read-write';
+
+ /** @var ?string Resource (folder) location */
+ public $href;
+
+ /** @var ?array Resource sharees list */
+ public $sharees;
+
+
+ /**
+ * Create Share Resource object from a DOMElement element
+ *
+ * @param \DOMElement $element DOM element with notification properties
+ *
+ * @return Notification
+ */
+ public static function fromDomElement(\DOMElement $element)
+ {
+ throw new \Exception("Not implemented");
+ }
+
+ /**
+ * Convert a share-resource into an XML string to use in a request
+ */
+ public function toXML(): string
+ {
+ // TODO: Sharee should be an object not array
+
+ $props = '';
+ foreach ($this->sharees as $href => $sharee) {
+ if (!is_array($sharee)) {
+ $sharee = ['access' => $sharee];
+ }
+
+ $props .= '<d:sharee>'
+ . '<d:href>' . htmlspecialchars($href, ENT_XML1, 'UTF-8') . '</d:href>'
+ . '<d:share-access><d:' . ($sharee['access'] ?? self::ACCESS_NONE) . '/></d:share-access>';
+
+ if (isset($sharee['comment']) && strlen($sharee['comment'])) {
+ $props .= '<d:comment>' . htmlspecialchars($sharee['comment'], ENT_XML1, 'UTF-8') . '</d:comment>';
+ }
+
+ if (isset($sharee['displayname']) && strlen($sharee['displayname'])) {
+ $props .= '<d:prop><d:displayname>'
+ . htmlspecialchars($sharee['comment'], ENT_XML1, 'UTF-8')
+ . '</d:displayname></d:prop>';
+ }
+
+ $props .= '</d:sharee>';
+ }
+
+ return '<?xml version="1.0" encoding="utf-8"?>'
+ . '<d:share-resource xmlns:d="DAV:">' . $props . '</d:share-resource>';
+ }
+
+ /**
+ * Get XML string for PROPFIND query
+ *
+ * @return string
+ */
+ public static function propfindXML()
+ {
+ throw new \Exception("Not implemented");
+ }
+}
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
@@ -28,7 +28,6 @@
/** @const array User settings used by the backend */
public const USER_SETTINGS = [];
-
/** @const array Maps Kolab permissions to IMAP permissions */
private const ACL_MAP = [
'read-only' => 'lrs',
@@ -36,28 +35,6 @@
'full' => 'lrswipkxtecdn',
];
- /**
- * Delete a group.
- *
- * @param \App\Group $group Group
- *
- * @return bool True if a group was deleted successfully, False otherwise
- * @throws \Exception
- */
- public static function deleteGroup(Group $group): bool
- {
- $domainName = explode('@', $group->email, 2)[1];
-
- // Cleanup ACL
- // FIXME: Since all groups in Kolab4 have email address,
- // should we consider using it in ACL instead of the name?
- // Also we need to decide what to do and configure IMAP appropriately,
- // right now groups in ACL does not work for me at all.
- // Commented out in favor of a nightly cleanup job, for performance reasons
- // \App\Jobs\IMAP\AclCleanupJob::dispatch($group->name, $domainName);
-
- return true;
- }
/**
* Create a mailbox.
@@ -139,6 +116,29 @@
}
}
+ /**
+ * Delete a group.
+ *
+ * @param \App\Group $group Group
+ *
+ * @return bool True if a group was deleted successfully, False otherwise
+ * @throws \Exception
+ */
+ public static function deleteGroup(Group $group): bool
+ {
+ $domainName = explode('@', $group->email, 2)[1];
+
+ // Cleanup ACL
+ // FIXME: Since all groups in Kolab4 have email address,
+ // should we consider using it in ACL instead of the name?
+ // Also we need to decide what to do and configure IMAP appropriately,
+ // right now groups in ACL does not work for me at all.
+ // Commented out in favor of a nightly cleanup job, for performance reasons
+ // \App\Jobs\IMAP\AclCleanupJob::dispatch($group->name, $domainName);
+
+ return true;
+ }
+
/**
* Delete a mailbox
*
@@ -508,6 +508,131 @@
return \mb_convert_encoding($string, 'UTF7-IMAP', 'UTF8');
}
+ /**
+ * Share default folders.
+ *
+ * @param User $user Folders' owner
+ * @param User $to Delegated user
+ * @param array $acl ACL Permissions per folder type
+ *
+ * @return bool True on success, False otherwise
+ */
+ public static function shareDefaultFolders(User $user, User $to, array $acl): bool
+ {
+ $folders = [];
+
+ if (!empty($acl['mail'])) {
+ $folders['INBOX'] = $acl['mail'];
+ }
+
+ foreach (\config('services.imap.default_folders') as $folder_name => $props) {
+ [$type, ] = explode('.', $props['metadata']['/private/vendor/kolab/folder-type'] ?? 'mail');
+ if (!empty($acl[$type])) {
+ $folders[$folder_name] = $acl[$type];
+ }
+ }
+
+ if (empty($folders)) {
+ return true;
+ }
+
+ $config = self::getConfig();
+ $imap = self::initIMAP($config, $user->email);
+
+ // Share folders with the delegatee
+ foreach ($folders as $folder => $rights) {
+ $mailbox = self::toUTF7($folder);
+ $rights = self::aclToImap(["{$to->email},{$rights}"])[$to->email];
+ if (!$imap->setACL($mailbox, $to->email, $rights)) {
+ \Log::error("Failed to share {$mailbox} with {$to->email}");
+ $imap->closeConnection();
+ return false;
+ }
+ }
+
+ $imap->closeConnection();
+
+ // Subscribe folders for the delegatee
+ $imap = self::initIMAP($config, $to->email);
+ [$local, ] = explode('@', $user->email);
+
+ foreach ($folders as $folder => $rights) {
+ // Note: This code assumes that "Other Users/" is the namespace prefix
+ // and that user is indicated by the email address's local part.
+ // It may not be sufficient if we ever wanted to do cross-domain sharing.
+ $mailbox = $folder == 'INBOX'
+ ? "Other Users/{$local}"
+ : self::toUTF7("Other Users/{$local}/{$folder}");
+
+ if (!$imap->subscribe($mailbox)) {
+ \Log::error("Failed to subscribe {$mailbox} for {$to->email}");
+ $imap->closeConnection();
+ return false;
+ }
+ }
+
+ $imap->closeConnection();
+ return true;
+ }
+
+ /**
+ * Unshare folders.
+ *
+ * @param User $user Folders' owner
+ * @param string $email Delegated user
+ *
+ * @return bool True on success, False otherwise
+ */
+ public static function unshareFolders(User $user, string $email): bool
+ {
+ $config = self::getConfig();
+ $imap = self::initIMAP($config, $user->email);
+
+ // FIXME: should we unshare all or only default folders (ones that we auto-share on delegation)?
+ foreach ($imap->listMailboxes('', '*') as $mailbox) {
+ if (!str_starts_with($mailbox, 'Other Users/')) {
+ if (!$imap->deleteACL($mailbox, $email)) {
+ \Log::error("Failed to unshare {$mailbox} with {$email}");
+ $imap->closeConnection();
+ return false;
+ }
+ }
+ }
+
+ $imap->closeConnection();
+ return true;
+ }
+
+ /**
+ * Unsubscribe folders shared by other users.
+ *
+ * @param User $user Account owner
+ * @param string $email Other user email address
+ *
+ * @return bool True on success, False otherwise
+ */
+ public static function unsubscribeSharedFolders(User $user, string $email): bool
+ {
+ $config = self::getConfig();
+ $imap = self::initIMAP($config, $user->email);
+ [$local, ] = explode('@', $email);
+
+ // FIXME: should we unsubscribe all or only default folders (ones that we auto-subscribe on delegation)?
+ $root = "Other Users/{$local}";
+ foreach ($imap->listSubscribed($root, '*') as $mailbox) {
+ if ($mailbox == $root || str_starts_with($mailbox, $root . '/')) {
+ if (!$imap->unsubscribe($mailbox)) {
+ \Log::error("Failed to unsubscribe {$mailbox} by {$user->email}");
+ $imap->closeConnection();
+ return false;
+ }
+ }
+ }
+
+ $imap->closeConnection();
+ return true;
+ }
+
/**
* Check if an account is set up
*
diff --git a/src/app/Backends/Roundcube.php b/src/app/Backends/Roundcube.php
--- a/src/app/Backends/Roundcube.php
+++ b/src/app/Backends/Roundcube.php
@@ -2,6 +2,7 @@
namespace App\Backends;
+use App\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
@@ -31,6 +32,48 @@
return DB::connection('roundcube');
}
+ /**
+ * Create delegator's identities for the delegatee
+ *
+ * @param User $delegatee The delegatee
+ * @param User $delegator The delegator
+ */
+ public static function createDelegatedIdentities(User $delegatee, User $delegator): void
+ {
+ $delegatee_id = self::userId($delegatee->email);
+ $delegator_id = self::userId($delegator->email, false);
+ $delegator_name = $delegator->name();
+ $org_name = $delegator->getSetting('organization');
+
+ $db = self::dbh();
+
+ if ($delegator_id) {
+ // Get signature/name from the delegator's default identity
+ $identity = $db->table(self::IDENTITIES_TABLE)->where('user_id', $delegator_id)
+ ->where('email', $delegator->email)
+ ->orderBy('standard', 'desc')
+ ->first();
+
+ if ($identity) {
+ if ($identity->name) {
+ $delegator_name = $identity->name;
+ }
+ if ($identity->organization) {
+ $org_name = $identity->organization;
+ }
+ }
+ }
+
+ // Create the identity
+ $db->table(self::IDENTITIES_TABLE)->insert([
+ 'user_id' => $delegatee_id,
+ 'email' => $delegator->email,
+ 'name' => (string) $delegator_name,
+ 'organization' => (string) $org_name,
+ 'changed' => now()->toDateTimeString(),
+ ]);
+ }
+
/**
* Remove all files from the Enigma filestore.
*
@@ -208,6 +251,33 @@
$db->table(self::USERS_TABLE)->where('username', \strtolower($email))->delete();
}
+ /**
+ * Validate user identities, remove these these that user no longer has access to.
+ *
+ * @param User $user User
+ */
+ public static function resetIdentities(User $user): void
+ {
+ $user_id = self::userId($user->email, false);
+
+ if (!$user_id) {
+ return;
+ }
+
+ // Collect email addresses
+ $users = $user->delegators()->pluck('email', 'user_id')->all();
+ $users[$user->id] = $user->email;
+ $aliases = \App\UserAlias::whereIn('user_id', array_keys($users))->pluck('alias')->all();
+ $all_addresses = array_merge(array_values($users), $aliases);
+
+ // Delete excessive identities
+ $db = self::dbh();
+ $db->table(self::IDENTITIES_TABLE)
+ ->where('user_id', $user_id)
+ ->whereNotIn('email', $all_addresses)
+ ->delete();
+ }
+
/**
* Find the Roundcube user identifier for the specified user.
*
@@ -241,7 +311,7 @@
'user_id'
);
- $username = \App\User::where('email', $email)->first()->name();
+ $username = User::where('email', $email)->first()->name();
$db->table(self::IDENTITIES_TABLE)->insert([
'user_id' => $user_id,
diff --git a/src/app/DataMigrator/Driver/DAV.php b/src/app/DataMigrator/Driver/DAV.php
--- a/src/app/DataMigrator/Driver/DAV.php
+++ b/src/app/DataMigrator/Driver/DAV.php
@@ -6,6 +6,7 @@
use App\Backends\DAV\Opaque as DAVOpaque;
use App\Backends\DAV\Folder as DAVFolder;
use App\Backends\DAV\Search as DAVSearch;
+use App\Backends\DAV\ShareResource as DAVShareResource;
use App\DataMigrator\Account;
use App\DataMigrator\Engine;
use App\DataMigrator\Interface\Folder;
@@ -175,15 +176,19 @@
foreach (User::whereIn('email', $emails)->pluck('email') as $email) {
$rights = $folder->acl[$email];
+ $principal = $this->client->principalLocation($email);
if (in_array('w', $rights) || in_array('a', $rights)) {
- $acl[$email] = DAVClient::SHARING_READ_WRITE;
+ $acl[$principal] = DAVShareResource::ACCESS_READ_WRITE;
} elseif (in_array('r', $rights)) {
- $acl[$email] = DAVClient::SHARING_READ;
+ $acl[$principal] = DAVShareResource::ACCESS_READ;
}
}
if (!empty($acl)) {
- if ($this->client->shareResource($href, $acl) === false) {
+ $share_resource = new DAVShareResource();
+ $share_resource->href = $href;
+ $share_resource->sharees = $acl;
+ if ($this->client->shareResource($share_resource) === false) {
\Log::warning("Failed to set sharees on the folder: {$href}");
}
}
diff --git a/src/app/Delegation.php b/src/app/Delegation.php
new file mode 100644
--- /dev/null
+++ b/src/app/Delegation.php
@@ -0,0 +1,77 @@
+<?php
+
+namespace App;
+
+use Illuminate\Database\Eloquent\Relations\Pivot;
+
+/**
+ * Definition of Delegation (user to user relation).
+ *
+ * @property ?\Carbon\Carbon $activated_at
+ * @property int $delegatee_id
+ * @property int $user_id
+ * @property ?array $options
+ */
+class Delegation extends Pivot
+{
+ /** @var array<string, string> The attributes that should be cast */
+ protected $casts = [
+ 'created_at' => 'datetime:Y-m-d H:i:s',
+ 'activated_at' => 'datetime:Y-m-d H:i:s',
+ 'options' => 'array',
+ ];
+
+ /** @var array<int, string> The attributes that are mass assignable */
+ protected $fillable = [
+ 'user_id',
+ 'delegatee_id',
+ 'options',
+ ];
+
+ /** @var array<int, string> The attributes that can be null */
+ protected $nullable = [
+ 'options',
+ ];
+
+ /** @var string Database table name */
+ protected $table = 'delegations';
+
+ /** @var bool Enable primary key autoincrement (required here for Pivots) */
+ public $incrementing = true;
+
+ /** @var bool Indicates if the model should be timestamped. */
+ public $timestamps = false;
+
+
+ /**
+ * The delegator user
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function user()
+ {
+ return $this->belongsTo(User::class, 'user_id');
+ }
+
+ /**
+ * The delegatee user
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function delegatee()
+ {
+ return $this->belongsTo(User::class, 'delegatee_id');
+ }
+
+ /**
+ * Validate delegation option value
+ *
+ * @param string $name Option name
+ * @param mixed $value Option valie
+ */
+ public static function validateOption($name, $value): bool
+ {
+ return in_array($name, ['mail', 'contact', 'event', 'task'])
+ && in_array($value, ['read-only', 'read-write']);
+ }
+}
diff --git a/src/app/Http/Controllers/API/V4/User/DelegationTrait.php b/src/app/Http/Controllers/API/V4/User/DelegationTrait.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/User/DelegationTrait.php
@@ -0,0 +1,200 @@
+<?php
+
+namespace App\Http\Controllers\API\V4\User;
+
+use App\Delegation;
+use App\User;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Validator;
+
+trait DelegationTrait
+{
+ /**
+ * Listing of delegations.
+ *
+ * @param string $id The user to get delegatees for
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function delegations($id)
+ {
+ $user = User::find($id);
+
+ if (!$this->checkTenant($user)) {
+ return $this->errorResponse(404);
+ }
+
+ $current_user = $this->guard()->user();
+ if ($user->id != $current_user->id && !$current_user->canRead($user)) {
+ return $this->errorResponse(403);
+ }
+
+ $result = $user->delegatees()->orderBy('email')->get()
+ ->map(function (User $user) {
+ return [
+ 'email' => $user->email,
+ 'options' => $user->delegation->options ?? [],
+ ];
+ });
+
+ $result = [
+ 'list' => $result,
+ 'count' => count($result),
+ 'hasMore' => false, // TODO
+ ];
+
+ return response()->json($result);
+ }
+
+ /**
+ * Get a delegation info for the current user (for use by a webmail plugin)
+ *
+ * @param string $id The user to get delegators for
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function delegators($id)
+ {
+ $user = User::find($id);
+
+ if (!$this->checkTenant($user) || $user->role) {
+ return $this->errorResponse(404);
+ }
+
+ $current_user = $this->guard()->user();
+ if ($user->id != $current_user->id && !$current_user->canDelete($user)) {
+ return $this->errorResponse(403);
+ }
+
+ $delegators = $user->delegators()->orderBy('email')->get()
+ ->map(function (User $user) {
+ return [
+ 'email' => $user->email,
+ 'aliases' => $user->aliases()->pluck('alias')
+ ];
+ });
+
+ return response()->json([
+ 'list' => $delegators,
+ 'count' => count($delegators),
+ 'hasMore' => false, // TODO
+ ]);
+ }
+
+ /**
+ * Delete a delegation.
+ *
+ * @param string $id User identifier
+ * @param string $email Delegatee's email address
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function deleteDelegation($id, $email)
+ {
+ $user = User::find($id);
+
+ if (!$this->checkTenant($user)) {
+ return $this->errorResponse(404);
+ }
+
+ $current_user = $this->guard()->user();
+ if ($user->id != $current_user->id && !$current_user->canDelete($user)) {
+ return $this->errorResponse(403);
+ }
+
+ $delegatee = User::where('email', $email)->first();
+
+ if (!$delegatee) {
+ return $this->errorResponse(404);
+ }
+
+ $delegation = Delegation::where('user_id', $user->id)->where('delegatee_id', $delegatee->id)->first();
+
+ if (!$delegation) {
+ return $this->errorResponse(404);
+ }
+
+ $delegation->delete();
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => \trans('app.delegation-delete-success'),
+ ]);
+ }
+
+ /**
+ * Create a new delegation.
+ *
+ * @param string $id User identifier
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function createDelegation($id)
+ {
+ $user = User::find($id);
+
+ if (!$this->checkTenant($user)) {
+ return $this->errorResponse(404);
+ }
+
+ $current_user = $this->guard()->user();
+ if ($user->id != $current_user->id && !$current_user->canDelete($user)) {
+ return $this->errorResponse(403);
+ }
+
+ $request = request();
+ $rules = [
+ 'email' => 'required|email',
+ 'options' => 'required|array',
+ ];
+
+ // Validate input
+ $v = Validator::make($request->all(), $rules);
+
+ if ($v->fails()) {
+ $errors = $v->errors()->toArray();
+ return response()->json(['status' => 'error', 'errors' => $errors], 422);
+ }
+
+ $errors = [];
+ $options = [];
+ $request->email = strtolower($request->email);
+
+ if (
+ $request->email == $user->email
+ || !($delegatee = User::where('email', $request->email)->first())
+ || $delegatee->domainNamespace() != $user->domainNamespace()
+ || $user->delegatees()->where('delegatee_id', $delegatee->id)->exists()
+ ) {
+ $errors['email'] = [self::trans('validation.delegateeinvalid')];
+ }
+
+ foreach ($request->options as $key => $value) {
+ if (empty($value)) {
+ continue;
+ }
+
+ if (!Delegation::validateOption($key, $value)) {
+ $errors['options'] = [self::trans('validation.delegationoptioninvalid')];
+ break;
+ }
+
+ $options[$key] = $value;
+ }
+
+ if (!empty($errors)) {
+ return response()->json(['status' => 'error', 'errors' => $errors], 422);
+ }
+
+ $delegation = new Delegation();
+ $delegation->user_id = $user->id;
+ $delegation->delegatee_id = $delegatee->id; // @phpstan-ignore-line
+ $delegation->options = $options;
+ $delegation->save();
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => self::trans('app.delegation-create-success'),
+ ]);
+ }
+}
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
@@ -2,6 +2,7 @@
namespace App\Http\Controllers\API\V4;
+use App\Http\Controllers\API\V4\User\DelegationTrait;
use App\Http\Controllers\RelationController;
use App\Domain;
use App\License;
@@ -17,6 +18,8 @@
class UsersController extends RelationController
{
+ use DelegationTrait;
+
/** @const array List of user setting keys available for modification in UI */
public const USER_SETTINGS = [
'billing_address',
@@ -247,6 +250,7 @@
$result = [
'skus' => $skus,
'enableBeta' => $hasBeta,
+ 'enableDelegation' => \config('app.with_delegation'),
'enableDomains' => $isController && ($hasCustomDomain || $plan?->hasDomain()),
'enableDistlists' => $isController && $hasCustomDomain && \config('app.with_distlists'),
'enableFiles' => !$isDegraded && $hasBeta && \config('app.with_files'),
diff --git a/src/app/Jobs/User/Delegation/CreateJob.php b/src/app/Jobs/User/Delegation/CreateJob.php
new file mode 100644
--- /dev/null
+++ b/src/app/Jobs/User/Delegation/CreateJob.php
@@ -0,0 +1,74 @@
+<?php
+
+namespace App\Jobs\User\Delegation;
+
+use App\Delegation;
+use App\Jobs\UserJob;
+
+class CreateJob extends UserJob
+{
+ /** @var int $delegationId Delegation identifier */
+ protected $delegationId;
+
+
+ /**
+ * Create a new job instance.
+ *
+ * @param int $delegationId The ID for the delegation to create.
+ */
+ public function __construct(int $delegationId)
+ {
+ $this->delegationId = $delegationId;
+
+ $delegation = Delegation::find($delegationId);
+
+ if ($delegation->user) {
+ $this->userId = $delegation->user->id;
+ $this->userEmail = $delegation->user->email;
+ }
+ }
+
+ /**
+ * Execute the job.
+ *
+ * @return void
+ * @throws \Exception
+ */
+ public function handle()
+ {
+ $this->logJobStart("{$this->delegationId} ({$this->userEmail})");
+
+ $delegation = Delegation::find($this->delegationId);
+
+ if (!$delegation || $delegation->activated_at) {
+ return;
+ }
+
+ $user = $delegation->user;
+ $delegatee = $delegation->delegatee;
+
+ if (!$user || !$delegatee || $user->role || $delegatee->role || $user->trashed() || $delegatee->trashed()) {
+ return;
+ }
+
+ if (!$user->isImapReady() || !$delegatee->isImapReady()) {
+ $this->release(60);
+ return;
+ }
+
+ // Create identities in Roundcube
+ if (\config('database.connections.roundcube')) {
+ \App\Backends\Roundcube::createDelegatedIdentities($delegatee, $user);
+ }
+
+ // Share IMAP and DAV folders
+ if (!\App\Backends\IMAP::shareDefaultFolders($user, $delegatee, $delegation->options)) {
+ throw new \Exception("Failed to set IMAP delegation for user {$this->userEmail}.");
+ }
+
+ \App\Backends\DAV::shareDefaultFolders($user, $delegatee, $delegation->options);
+
+ $delegation->activated_at = \now();
+ $delegation->save();
+ }
+}
diff --git a/src/app/Jobs/User/Delegation/DeleteJob.php b/src/app/Jobs/User/Delegation/DeleteJob.php
new file mode 100644
--- /dev/null
+++ b/src/app/Jobs/User/Delegation/DeleteJob.php
@@ -0,0 +1,83 @@
+<?php
+
+namespace App\Jobs\User\Delegation;
+
+use App\Delegation;
+use App\User;
+use App\Jobs\CommonJob;
+
+class DeleteJob extends CommonJob
+{
+ /** @var string Delegator's email address */
+ protected $delegatorEmail;
+
+ /** @var string Delegatee's email address */
+ protected $delegateeEmail;
+
+
+ /**
+ * Create a new job instance.
+ *
+ * @param string $delegatorEmail Delegator's email address.
+ * @param string $delegateeEmail Delegatee's email address.
+ */
+ public function __construct(string $delegatorEmail, string $delegateeEmail)
+ {
+ // Note: We're using email not id because the user may not exists anymore
+ $this->delegatorEmail = $delegatorEmail;
+ $this->delegateeEmail = $delegateeEmail;
+ }
+
+ /**
+ * Execute the job.
+ *
+ * @return void
+ */
+ public function handle()
+ {
+ $this->logJobStart("{$this->delegatorEmail}/{$this->delegateeEmail}");
+
+ $this->delegationCleanup($this->delegatorEmail, $this->delegateeEmail);
+ }
+
+ /**
+ * Cleanup delegation relation between two users
+ */
+ public static function delegationCleanup(string $delegator_email, string $delegatee_email)
+ {
+ $delegator = User::where('email', $delegator_email)->withTrashed()->first();
+ $delegatee = User::where('email', $delegatee_email)->withTrashed()->first();
+
+ // Make sure that the same delegation wasn't re-created in meantime
+ if (
+ $delegator
+ && $delegatee
+ && Delegation::where('user_id', $delegator->id)->where('delegatee_id', $delegatee->id)->exists()
+ ) {
+ return;
+ }
+
+ // Remove identities
+ if ($delegatee && !$delegatee->isDeleted() && \config('database.connections.roundcube')) {
+ \App\Backends\Roundcube::resetIdentities($delegatee);
+ }
+
+ // Unsubscribe folders shared by the delegator
+ if ($delegatee && $delegatee->isImapReady()) {
+ if (!\App\Backends\IMAP::unsubscribeSharedFolders($delegatee, $delegator_email)) {
+ throw new \Exception("Failed to unsubscribe IMAP folders for user {$delegatee_email}.");
+ }
+
+ \App\Backends\DAV::unsubscribeSharedFolders($delegatee, $delegator_email);
+ }
+
+ // Remove folder permissions for the delegatee
+ if ($delegator && $delegator->isImapReady()) {
+ if (!\App\Backends\IMAP::unshareFolders($delegator, $delegatee_email)) {
+ throw new \Exception("Failed to unshare IMAP folders for user {$delegator_email}.");
+ }
+
+ \App\Backends\DAV::unshareFolders($delegator, $delegatee_email);
+ }
+ }
+}
diff --git a/src/app/Observers/DelegationObserver.php b/src/app/Observers/DelegationObserver.php
new file mode 100644
--- /dev/null
+++ b/src/app/Observers/DelegationObserver.php
@@ -0,0 +1,28 @@
+<?php
+
+namespace App\Observers;
+
+use App\Delegation;
+
+class DelegationObserver
+{
+ /**
+ * Handle the delegation "created" event.
+ *
+ * @param Delegation $delegation The delegation
+ */
+ public function created(Delegation $delegation): void
+ {
+ \App\Jobs\User\Delegation\CreateJob::dispatch($delegation->id);
+ }
+
+ /**
+ * Handle the delegation "deleted" event.
+ *
+ * @param Delegation $delegation The delegation
+ */
+ public function deleted(Delegation $delegation): void
+ {
+ \App\Jobs\User\Delegation\DeleteJob::dispatch($delegation->user->email, $delegation->delegatee->email);
+ }
+}
diff --git a/src/app/Observers/GroupObserver.php b/src/app/Observers/GroupObserver.php
--- a/src/app/Observers/GroupObserver.php
+++ b/src/app/Observers/GroupObserver.php
@@ -8,7 +8,7 @@
class GroupObserver
{
/**
- * Handle the group "created" event.
+ * Handle the group "creating" event.
*
* @param \App\Group $group The group
*
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
@@ -2,6 +2,7 @@
namespace App\Observers;
+use App\Delegation;
use App\Group;
use App\User;
use App\Wallet;
@@ -109,6 +110,24 @@
});
}
+ // Remove delegation relations
+ $ids = Delegation::where('user_id', $user->id)->orWhere('delegatee_id', $user->id)->get()
+ ->map(function ($delegation) use ($user) {
+ $delegator = $delegation->user_id == $user->id
+ ? $user : $delegation->user()->withTrashed()->first();
+ $delegatee = $delegation->delegatee_id == $user->id
+ ? $user : $delegation->delegatee()->withTrashed()->first();
+
+ \App\Jobs\User\Delegation\DeleteJob::dispatch($delegator->email, $delegatee->email);
+
+ return $delegation->id;
+ })
+ ->all();
+
+ if (!empty($ids)) {
+ Delegation::whereIn('id', $ids)->delete();
+ }
+
// TODO: Remove Permission records for the user
// TODO: Remove file permissions for the user
}
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
@@ -42,6 +42,7 @@
*/
public function boot(): void
{
+ \App\Delegation::observe(\App\Observers\DelegationObserver::class);
\App\Domain::observe(\App\Observers\DomainObserver::class);
\App\Entitlement::observe(\App\Observers\EntitlementObserver::class);
\App\EventLog::observe(\App\Observers\EventLogObserver::class);
diff --git a/src/app/Traits/EmailPropertyTrait.php b/src/app/Traits/EmailPropertyTrait.php
--- a/src/app/Traits/EmailPropertyTrait.php
+++ b/src/app/Traits/EmailPropertyTrait.php
@@ -2,6 +2,8 @@
namespace App\Traits;
+use App\Domain;
+
trait EmailPropertyTrait
{
/** @var ?string Domain name for the to-be-created object */
@@ -42,19 +44,34 @@
/**
* Returns the object's domain (including soft-deleted).
*
- * @return ?\App\Domain The domain to which the object belongs to, NULL if it does not exist
+ * @return ?Domain The domain to which the object belongs to, NULL if it does not exist
*/
- public function domain(): ?\App\Domain
+ public function domain(): ?Domain
+ {
+ if ($domain = $this->domainNamespace()) {
+ return Domain::withTrashed()->where('namespace', $domain)->first();
+ }
+
+ return null;
+ }
+
+ /**
+ * Returns the object's domain namespace.
+ *
+ * @return ?string The domain to which the object belongs to if it has email property is set
+ */
+ public function domainNamespace(): ?string
{
if (empty($this->email) && isset($this->domainName)) {
- $domainName = $this->domainName;
- } elseif (strpos($this->email, '@')) {
- list($local, $domainName) = explode('@', $this->email);
- } else {
- return null;
+ return $this->domainName;
+ }
+
+ if (strpos($this->email, '@')) {
+ [$local, $domain] = explode('@', $this->email);
+ return $domain;
}
- return \App\Domain::withTrashed()->where('namespace', $domainName)->first();
+ return null;
}
/**
diff --git a/src/app/User.php b/src/app/User.php
--- a/src/app/User.php
+++ b/src/app/User.php
@@ -289,6 +289,31 @@
$this->save();
}
+ /**
+ * Users that this user is delegatee of.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
+ */
+ public function delegators()
+ {
+ return $this->belongsToMany(User::class, 'delegations', 'delegatee_id', 'user_id')
+ ->as('delegation')
+ ->using(Delegation::class);
+ }
+
+ /**
+ * Users that are delegatees of this user.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
+ */
+ public function delegatees()
+ {
+ return $this->belongsToMany(User::class, 'delegations', 'user_id', 'delegatee_id')
+ ->as('delegation')
+ ->using(Delegation::class)
+ ->withPivot('options');
+ }
+
/**
* List the domains to which this user is entitled.
*
diff --git a/src/config/app.php b/src/config/app.php
--- a/src/config/app.php
+++ b/src/config/app.php
@@ -251,6 +251,7 @@
'with_signup' => (bool) env('APP_WITH_SIGNUP', true),
'with_subscriptions' => (bool) env('APP_WITH_SUBSCRIPTIONS', true),
'with_wallet' => (bool) env('APP_WITH_WALLET', true),
+ 'with_delegation' => (bool) env('APP_WITH_DELEGATION', true),
'with_distlists' => (bool) env('APP_WITH_DISTLISTS', true),
'with_shared_folders' => (bool) env('APP_WITH_SHARED_FOLDERS', true),
diff --git a/src/database/migrations/2025_03_28_100000_create_delegations_table.php b/src/database/migrations/2025_03_28_100000_create_delegations_table.php
new file mode 100644
--- /dev/null
+++ b/src/database/migrations/2025_03_28_100000_create_delegations_table.php
@@ -0,0 +1,41 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+ /**
+ * Run the migrations.
+ */
+ public function up(): void
+ {
+ Schema::create(
+ 'delegations',
+ function (Blueprint $table) {
+ $table->bigIncrements('id');
+ $table->bigInteger('user_id');
+ $table->bigInteger('delegatee_id');
+ $table->text('options')->nullable(); // json
+ $table->timestamp('created_at')->useCurrent();
+ $table->timestamp('activated_at')->nullable();
+
+ $table->unique(['user_id', 'delegatee_id']);
+
+ $table->foreign('user_id')->references('id')->on('users')
+ ->onDelete('cascade')->onUpdate('cascade');
+ $table->foreign('delegatee_id')->references('id')->on('users')
+ ->onDelete('cascade')->onUpdate('cascade');
+ }
+ );
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('delegations');
+ }
+};
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
@@ -72,6 +72,9 @@
'distlist-unsuspend-success' => 'Distribution list unsuspended successfully.',
'distlist-setconfig-success' => 'Distribution list settings updated successfully.',
+ 'delegation-create-success' => 'Delegation created successfully.',
+ 'delegation-delete-success' => 'Delegation deleted successfully.',
+
'domain-create-success' => 'Domain created successfully.',
'domain-delete-success' => 'Domain deleted successfully.',
'domain-notempty-error' => 'Unable to delete a domain with assigned users or other objects.',
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
@@ -209,6 +209,7 @@
'lastname' => "Last Name",
'less' => "Less",
'name' => "Name",
+ 'mainopts' => "Main Options",
'months' => "months",
'more' => "More",
'none' => "none",
@@ -498,6 +499,16 @@
'custno' => "Customer No.",
'degraded-warning' => "The account is degraded. Some features have been disabled.",
'degraded-hint' => "Please, make a payment.",
+ 'delegation' => "Delegation",
+ 'delegation-create' => "Add delegate",
+ 'delegation-mail' => "Mail",
+ 'delegation-event' => "Calendar",
+ 'delegation-task' => "Tasks",
+ 'delegation-contact' => "Contacts",
+ 'delegation-none' => "There are no delegates.",
+ 'delegation-desc' => "Delegates can send mail in your behalf and manage your mail/calendar/tasks/contacts according to the given permissions."
+ . " This includes e.g. inviting people to meetings and responding to such requests.",
+ 'delegation-perm' => "This delegate has the following permissions:",
'delete' => "Delete user",
'delete-email' => "Delete {email}",
'delete-text' => "Do you really want to delete this user permanently?"
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
@@ -136,6 +136,8 @@
'emailinvalid' => 'The specified email address is invalid.',
'domaininvalid' => 'The specified domain is invalid.',
'domainnotavailable' => 'The specified domain is not available.',
+ 'delegateeinvalid' => 'The specified email address is not a valid delegation target.',
+ 'delegationoptioninvalid' => 'The specified delegation options are invalid.',
'logininvalid' => 'The specified login is invalid.',
'loginexists' => 'The specified login is not available.',
'domainexists' => 'The specified domain 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
@@ -116,6 +116,16 @@
& > button + button {
margin-left: .5em;
}
+
+ .accordion-header > & {
+ position: absolute;
+ display: flex;
+ align-items: center;
+ top: 0;
+ right: 3.25rem;
+ height: 2.5rem;
+ z-index: 3;
+ }
}
// Various improvements for mobile
diff --git a/src/resources/themes/variables.scss b/src/resources/themes/variables.scss
--- a/src/resources/themes/variables.scss
+++ b/src/resources/themes/variables.scss
@@ -1,3 +1,10 @@
+/* Colors */
+
+$kolab-blue: #65c2ee;
+$light: #f6f5f3;
+
+$main-color: $kolab-blue;
+
/* Body */
$body-bg: #fff;
@@ -8,12 +15,12 @@
$font-size-base: 0.9rem;
$line-height-base: 1.5;
-/* Colors */
-
-$kolab-blue: #65c2ee;
-$light: #f6f5f3;
+/* Bootstrap components */
-$main-color: $kolab-blue;
+$accordion-padding-y: 1rem;
+$accordion-padding-x: 1rem;
+$accordion-button-padding-y: 0.7rem;
+$accordion-button-active-bg: $light;
/* App colors */
diff --git a/src/resources/vue/User/Info.vue b/src/resources/vue/User/Info.vue
--- a/src/resources/vue/User/Info.vue
+++ b/src/resources/vue/User/Info.vue
@@ -11,7 +11,7 @@
</btn>
</div>
<div class="card-text">
- <tabs class="mt-3" :tabs="tabs"></tabs>
+ <tabs class="mt-3" :tabs="tabs" ref="tabs"></tabs>
<div class="tab-content">
<div class="tab-pane active" id="general" role="tabpanel" aria-labelledby="tab-general">
<form @submit.prevent="submit" class="card-body">
@@ -88,43 +88,57 @@
<btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
</form>
</div>
- <div v-if="isController" class="tab-pane" id="settings" role="tabpanel" aria-labelledby="tab-settings">
- <form @submit.prevent="submitSettings" class="card-body">
- <div class="row checkbox mb-3">
- <label for="greylist_enabled" class="col-sm-4 col-form-label">{{ $t('user.greylisting') }}</label>
- <div class="col-sm-8 pt-2">
- <input type="checkbox" id="greylist_enabled" name="greylist_enabled" value="1" class="form-check-input d-block mb-2" :checked="user.config.greylist_enabled">
- <small id="greylisting-hint" class="text-muted">
- {{ $t('user.greylisting-text') }}
- </small>
- </div>
- </div>
- <div v-if="$root.hasPermission('beta')" class="row checkbox mb-3">
- <label for="guam_enabled" class="col-sm-4 col-form-label">
- {{ $t('user.imapproxy') }}
- <sup class="badge bg-primary">{{ $t('dashboard.beta') }}</sup>
- </label>
- <div class="col-sm-8 pt-2">
- <input type="checkbox" id="guam_enabled" name="guam_enabled" value="1" class="form-check-input d-block mb-2" :checked="user.config.guam_enabled">
- <small id="guam-hint" class="text-muted">
- {{ $t('user.imapproxy-text') }}
- </small>
- </div>
- </div>
- <div v-if="$root.hasPermission('beta')" class="row mb-3">
- <label for="limit_geo" class="col-sm-4 col-form-label">
- {{ $t('user.geolimit') }}
- <sup class="badge bg-primary">{{ $t('dashboard.beta') }}</sup>
- </label>
- <div class="col-sm-8 pt-2">
- <country-select id="limit_geo" v-model="user.config.limit_geo"></country-select>
- <small id="geolimit-hint" class="text-muted">
- {{ $t('user.geolimit-text') }}
- </small>
- </div>
- </div>
- <btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
- </form>
+ <div v-if="Object.keys(settingsSections).length > 0" class="tab-pane" id="settings" role="tabpanel" aria-labelledby="tab-settings">
+ <accordion class="mt-3" id="settings-all" :names="settingsSections" :buttons="settingsButtons">
+ <template #options v-if="settingsSections.options">
+ <form @submit.prevent="submitSettings">
+ <div class="row checkbox mb-3">
+ <label for="greylist_enabled" class="col-sm-4 col-form-label">{{ $t('user.greylisting') }}</label>
+ <div class="col-sm-8 pt-2">
+ <input type="checkbox" id="greylist_enabled" name="greylist_enabled" value="1" class="form-check-input d-block mb-2" :checked="user.config.greylist_enabled">
+ <small id="greylisting-hint" class="text-muted">
+ {{ $t('user.greylisting-text') }}
+ </small>
+ </div>
+ </div>
+ <div v-if="$root.hasPermission('beta')" class="row checkbox mb-3">
+ <label for="guam_enabled" class="col-sm-4 col-form-label">
+ {{ $t('user.imapproxy') }}
+ <sup class="badge bg-primary">{{ $t('dashboard.beta') }}</sup>
+ </label>
+ <div class="col-sm-8 pt-2">
+ <input type="checkbox" id="guam_enabled" name="guam_enabled" value="1" class="form-check-input d-block mb-2" :checked="user.config.guam_enabled">
+ <small id="guam-hint" class="text-muted">
+ {{ $t('user.imapproxy-text') }}
+ </small>
+ </div>
+ </div>
+ <div v-if="$root.hasPermission('beta')" class="row mb-3">
+ <label for="limit_geo" class="col-sm-4 col-form-label">
+ {{ $t('user.geolimit') }}
+ <sup class="badge bg-primary">{{ $t('dashboard.beta') }}</sup>
+ </label>
+ <div class="col-sm-8 pt-2">
+ <country-select id="limit_geo" v-model="user.config.limit_geo"></country-select>
+ <small id="geolimit-hint" class="text-muted">
+ {{ $t('user.geolimit-text') }}
+ </small>
+ </div>
+ </div>
+ <btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
+ </form>
+ </template>
+ <template #delegation v-if="settingsSections.delegation">
+ <list-table :list="delegations" :setup="delegationListSetup" class="mb-0">
+ <template #email="{ item }">
+ <svg-icon icon="user-tie"></svg-icon> <span>{{ item.email }}</span>
+ </template>
+ <template #buttons="{ item }">
+ <btn class="text-danger button-delete p-0 ms-1" @click="delegationDelete(item.email)" icon="trash-can" :title="$t('btn.delete')"></btn>
+ </template>
+ </list-table>
+ </template>
+ </accordion>
</div>
<div class="tab-pane" id="personal" role="tabpanel" aria-labelledby="tab-personal">
<form @submit.prevent="submitPersonalSettings" class="card-body">
@@ -191,12 +205,41 @@
</div>
<p v-else>{{ $t('user.delete-text') }}</p>
</modal-dialog>
+ <modal-dialog id="delegation-create" ref="delegationDialog" :buttons="['save']" @click="delegationCreate()" :title="$t('user.delegation-create')">
+ <form class="card-body" data-validation-prefix="delegation-">
+ <div class="row mb-3">
+ <label for="delegation-email" class="col-sm-4 col-form-label">{{ $t('form.user') }}</label>
+ <div class="col-sm-8">
+ <input type="text" class="form-control" id="delegation-email" v-model="delegatee" :placeholder="$t('form.email')">
+ </div>
+ </div>
+ <div class="row">
+ <label class="col-form-label">{{ $t('user.delegation-perm') }}</label>
+ </div>
+ <div class="row mb-2" v-for="(icon, type) in delegationTypes" :key="`delegation-${type}-row`">
+ <label for="delegation-" class="col-4 col-form-label">
+ <svg-icon :icon="icon" class="fs-3 me-2" style="width:1em"></svg-icon>
+ <span class="align-text-bottom">{{ $t(`user.delegation-${type}`) }}</span>
+ </label>
+ <div class="col-8">
+ <select type="text" class="form-select" :id="`delegation-${type}`">
+ <option value="" selected>- {{ $t('form.none') }} -</option>
+ <option value="read-only">{{ $t('form.acl-read-only') }}</option>
+ <option value="read-write">{{ $t('form.acl-read-write') }}</option>
+ </select>
+ </div>
+ </div>
+ <div class="row form-text"><span>{{ $t('user.delegation-desc') }}</span></div>
+ </form>
+ </modal-dialog>
</div>
</template>
<script>
+ import Accordion from '../Widgets/Accordion'
import CountrySelect from '../Widgets/CountrySelect'
import ListInput from '../Widgets/ListInput'
+ import { ListTable } from '../Widgets/ListTools'
import ModalDialog from '../Widgets/ModalDialog'
import PackageSelect from '../Widgets/PackageSelect'
import PasswordInput from '../Widgets/PasswordInput'
@@ -207,12 +250,19 @@
library.add(
require('@fortawesome/free-regular-svg-icons/faClipboard').definition,
+ require('@fortawesome/free-solid-svg-icons/faCalendarCheck').definition,
+ require('@fortawesome/free-solid-svg-icons/faCalendarDays').definition,
+ require('@fortawesome/free-solid-svg-icons/faEnvelope').definition,
+ require('@fortawesome/free-solid-svg-icons/faUserTie').definition,
+ require('@fortawesome/free-solid-svg-icons/faUsers').definition,
)
export default {
components: {
+ Accordion,
CountrySelect,
ListInput,
+ ListTable,
ModalDialog,
PackageSelect,
PasswordInput,
@@ -222,11 +272,38 @@
data() {
return {
countries: window.config.countries,
+ delegatee: null,
+ delegations: null,
+ delegationListSetup: {
+ buttons: true,
+ columns: [
+ {
+ prop: 'email',
+ contentSlot: 'email'
+ },
+ ],
+ footLabel: 'user.delegation-none'
+ },
+ delegationTypes: {
+ mail: 'envelope',
+ event: 'calendar-days',
+ task: 'calendar-check',
+ contact: 'users'
+ },
isSelf: false,
passwordLinkCode: '',
passwordMode: '',
user_id: null,
user: { aliases: [], config: [] },
+ settingsButtons: {
+ delegation: [
+ {
+ icon: 'user-tie',
+ label: this.$t('user.delegation-create'),
+ click: () => this.$refs.delegationDialog.show()
+ }
+ ],
+ },
supportEmail: window.config['app.support_email'],
status: {},
successRoute: { name: 'users' }
@@ -241,6 +318,16 @@
icon: 'trash-can'
}
},
+ settingsSections: function () {
+ let opts = {}
+ if (this.isController) {
+ opts.options = this.$t('form.mainopts')
+ }
+ if ((this.isController || this.isSelf) && this.$root.authInfo.statusInfo.enableDelegation) {
+ opts.delegation = this.$t('user.delegation')
+ }
+ return opts
+ },
isController: function () {
return this.$root.hasPermission('users')
},
@@ -254,7 +341,7 @@
return tabs
}
- if (this.isController) {
+ if (Object.keys(this.settingsSections).length > 0) {
tabs.push('form.settings')
}
@@ -291,6 +378,20 @@
},
mounted() {
$('#first_name').focus()
+
+ if (this.settingsSections.delegation) {
+ this.$refs.tabs.clickHandler('settings', () => {
+ if (this.delegations === null) {
+ this.delegationList()
+ }
+ })
+ }
+
+ this.$refs.delegationDialog.events({
+ show: (event) => {
+ this.delegatee = null
+ }
+ })
},
methods: {
passwordLinkCopy() {
@@ -418,6 +519,40 @@
statusUpdate(user) {
this.user = Object.assign({}, this.user, user)
},
+ delegationCreate() {
+ let post = {email: this.delegatee, options: {}}
+
+ $('#delegation-create select').each(function () {
+ post.options[this.id.split('-')[1]] = this.value;
+ });
+
+ axios.post('/api/v4/users/' + this.user_id + '/delegations', post)
+ .then(response => {
+ if (response.data.status == 'success') {
+ this.$toast.success(response.data.message)
+ this.$refs.delegationDialog.hide();
+ this.delegationList(true)
+ }
+ })
+ },
+ delegationDelete(email) {
+ axios.delete('/api/v4/users/' + this.user_id + '/delegations/' + email)
+ .then(response => {
+ if (response.data.status == 'success') {
+ this.$toast.success(response.data.message)
+ this.delegationList(true)
+ }
+ })
+ },
+ delegationList(reset) {
+ if (reset) {
+ this.delegations = null
+ }
+ axios.get('/api/v4/users/' + this.user_id + '/delegations', { loader: '#delegation' })
+ .then(response => {
+ this.delegations = response.data.list
+ })
+ },
deleteUser() {
// Delete the user from the confirm dialog
axios.delete('/api/v4/users/' + this.user_id)
diff --git a/src/resources/vue/Widgets/Accordion.vue b/src/resources/vue/Widgets/Accordion.vue
new file mode 100644
--- /dev/null
+++ b/src/resources/vue/Widgets/Accordion.vue
@@ -0,0 +1,44 @@
+<template>
+ <div class="accordion" :id="id">
+ <div v-for="(slot, slotName) in $slots" class="accordion-item position-relative" :key="slotName" :id="slotName">
+ <h2 class="accordion-header">
+ <button type="button" data-bs-toggle="collapse" :data-bs-target="'#' + slotName"
+ :class="'accordion-button' + (isFirst(slotName) ? '' : ' collapsed')"
+ :aria-expanded="isFirst(slotName) ? 'true' : 'false'"
+ :aria-controls="slotName"
+ >
+ {{ names[slotName] }}
+ <sup v-if="beta.includes(slotName)" class="badge bg-primary">{{ $t('dashboard.beta') }}</sup>
+ </button>
+ <div class="buttons">
+ <btn v-for="(button, idx) in buttons[slotName]" :key="idx" :icon="button.icon" class="btn-sm btn-outline-secondary" @click="button.click()">
+ {{ button.label }}
+ </btn>
+ </div>
+ </h2>
+ <div :class="'accordion-collapse collapse' + (isFirst(slotName) ? ' show' : '')" :data-bs-parent="'#' + id">
+ <div class="accordion-body">
+ <slot :name="slotName"></slot>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+ export default {
+ props: {
+ beta: { type: Array, default: () => [] },
+ buttons: { type: Object, default: () => {} },
+ id: { type: String, default: 'accordion' },
+ names: { type: Object, default: () => {} },
+ },
+ methods: {
+ isFirst(slotName) {
+ for (let name in this.$slots) {
+ return name == slotName
+ }
+ }
+ }
+ }
+</script>
diff --git a/src/routes/api.php b/src/routes/api.php
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -154,6 +154,12 @@
Route::get('users/{id}/skus', [API\V4\UsersController::class, 'skus']);
Route::get('users/{id}/status', [API\V4\UsersController::class, 'status']);
+ if (\config('app.with_delegation')) {
+ Route::get('users/{id}/delegations', [API\V4\UsersController::class, 'delegations']);
+ Route::post('users/{id}/delegations', [API\V4\UsersController::class, 'createDelegation']);
+ Route::delete('users/{id}/delegations/{email}', [API\V4\UsersController::class, 'deleteDelegation']);
+ Route::get('users/{id}/delegators', [API\V4\UsersController::class, 'delegators']);
+ }
Route::apiResource('wallets', API\V4\WalletsController::class);
Route::get('wallets/{id}/transactions', [API\V4\WalletsController::class, 'transactions']);
diff --git a/src/tests/Browser/Pages/UserInfo.php b/src/tests/Browser/Pages/UserInfo.php
--- a/src/tests/Browser/Pages/UserInfo.php
+++ b/src/tests/Browser/Pages/UserInfo.php
@@ -41,6 +41,10 @@
'@nav' => 'ul.nav-tabs',
'@packages' => '#user-packages',
'@settings' => '#settings',
+ '@setting-options' => '#options .accordion-body',
+ '@setting-options-head' => '#options .accordion-header',
+ '@setting-delegation' => '#delegation .accordion-body',
+ '@setting-delegation-head' => '#delegation .accordion-header',
'@general' => '#general',
'@personal' => '#personal',
'@skus' => '#user-skus',
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
@@ -2,6 +2,7 @@
namespace Tests\Browser;
+use App\Delegation;
use App\Discount;
use App\Entitlement;
use App\Sku;
@@ -72,8 +73,10 @@
$john = User::where('email', 'john@kolab.org')->first();
$john->setSettings($this->profile);
- UserAlias::where('user_id', $john->id)
- ->where('alias', 'john.test@kolab.org')->delete();
+ $john->aliases()->where('alias', 'john.test@kolab.org')->delete();
+ $john->delegators()->each(function ($user) {
+ $user->delegation->delete();
+ });
$activesync_sku = Sku::withEnvTenantContext()->where('title', 'activesync')->first();
$storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first();
@@ -329,7 +332,8 @@
*/
// Finally submit the form
- $browser->click('button[type=submit]')
+ $browser->scrollTo('button[type=submit]')->pause(500)
+ ->click('button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
$this->assertSame(1, $jack->verificationcodes()->where('active', true)->count());
@@ -420,7 +424,8 @@
->on(new UserInfo())
->assertSeeIn('@nav #tab-settings', 'Settings')
->click('@nav #tab-settings')
- ->with('@settings', function (Browser $browser) {
+ ->assertSeeIn('@setting-options-head', 'Main Options')
+ ->with('@setting-options', function (Browser $browser) {
$browser->assertSeeIn('div.row:nth-child(1) label', 'Greylisting')
->assertMissing('div.row:nth-child(2)') // guam and geo-lockin settings are hidden
->click('div.row:nth-child(1) input[type=checkbox]:checked')
@@ -437,7 +442,7 @@
$browser->refresh()
->on(new UserInfo())
->click('@nav #tab-settings')
- ->with('@settings', function (Browser $browser) use ($john) {
+ ->with('@setting-options', function (Browser $browser) use ($john) {
$browser->assertSeeIn('div.row:nth-child(1) label', 'Greylisting')
->assertSeeIn('div.row:nth-child(2) label', 'IMAP proxy')
->assertNotChecked('div.row:nth-child(2) input')
@@ -855,4 +860,77 @@
});
});
}
+
+ /**
+ * Test delegation settings
+ */
+ public function testUserDelegation(): void
+ {
+ $jack = $this->getTestUser('jack@kolab.org');
+ $jack->delegatees()->each(function ($user) {
+ $user->delegation->delete();
+ });
+
+ $this->browse(function (Browser $browser) use ($jack) {
+ $browser->visit('/login')
+ ->on(new Home())
+ ->submitLogon($jack->email, 'simple123', true)
+ ->on(new Dashboard())
+ ->click('@links .link-settings')
+ ->on(new UserInfo())
+ ->click('@nav #tab-settings')
+ // Note: Jack should not see Main Options
+ ->assertMissing('@setting-options')
+ ->assertMissing('@setting-options-head')
+ ->assertSeeIn('@setting-delegation-head', 'Delegation')
+ // ->click('@settings .accordion-item:nth-child(2) .accordion-button')
+ ->whenAvailable('@setting-delegation', function (Browser $browser) {
+ $browser->assertSeeIn('table tfoot td', 'There are no delegates.')
+ ->assertMissing('table tbody tr');
+ })
+ ->assertSeeIn('@setting-delegation-head .buttons button', 'Add delegate')
+ ->click('@setting-delegation-head .buttons button')
+ ->with(new Dialog('#delegation-create'), function (Browser $browser) {
+ $browser->assertSeeIn('@title', 'Add delegate')
+ ->assertFocused('#delegation-email')
+ ->assertValue('#delegation-email', '')
+ ->assertSelected('#delegation-mail', '')
+ ->assertSelectHasOptions('#delegation-mail', ['', 'read-only', 'read-write'])
+ ->assertSelected('#delegation-event', '')
+ ->assertSelectHasOptions('#delegation-event', ['', 'read-only', 'read-write'])
+ ->assertSelected('#delegation-task', '')
+ ->assertSelectHasOptions('#delegation-task', ['', 'read-only', 'read-write'])
+ ->assertSelected('#delegation-contact', '')
+ ->assertSelectHasOptions('#delegation-contact', ['', 'read-only', 'read-write'])
+ ->assertVisible('.row.form-text')
+ ->type('#delegation-email', 'john@kolab.org')
+ ->select('#delegation-mail', 'read-only')
+ ->select('#delegation-contact', 'read-write')
+ ->assertSeeIn('@button-cancel', 'Cancel')
+ ->assertSeeIn('@button-action', 'Save')
+ ->click('@button-action');
+ })
+ ->waitUntilMissing('#delegation-create')
+ ->assertToast(Toast::TYPE_SUCCESS, 'Delegation created successfully.');
+
+ // TODO: Test error handling
+ // TODO: Test acting as a wallet controller
+
+ $delegatee = $jack->delegatees()->first();
+ $this->assertSame('john@kolab.org', $delegatee->email);
+ $this->assertSame(['mail' => 'read-only', 'contact' => 'read-write'], $delegatee->delegation->options);
+
+ // Remove delegation
+ $browser->waitFor('@setting-delegation table tbody tr')
+ ->whenAvailable('@setting-delegation', function (Browser $browser) {
+ $browser->assertMissing('table tfoot td')
+ ->assertSeeIn('table tbody tr td:first-child', 'john@kolab.org')
+ ->click('table button.text-danger');
+ })
+ ->waitUntilMissing('@setting-delegation table tbody tr')
+ ->assertToast(Toast::TYPE_SUCCESS, 'Delegation deleted successfully.');
+
+ $this->assertSame(0, $jack->delegatees()->count());
+ });
+ }
}
diff --git a/src/tests/Feature/Backends/DAVTest.php b/src/tests/Feature/Backends/DAVTest.php
--- a/src/tests/Feature/Backends/DAVTest.php
+++ b/src/tests/Feature/Backends/DAVTest.php
@@ -10,6 +10,7 @@
class DAVTest extends TestCase
{
private $user;
+ private $user2;
/**
* {@inheritDoc}
@@ -31,26 +32,27 @@
if ($this->user) {
$this->deleteTestUser($this->user->email);
}
+ if ($this->user2) {
+ $this->deleteTestUser($this->user2->email);
+ }
parent::tearDown();
}
-
/**
* Test initializing default folders for a user.
*
* @group imap
* @group dav
*/
- public function testInitDefaultFolders(): void
+ public function testInitDefaultFolders(): array
{
Queue::fake();
- $props = ['password' => 'test-pass'];
- $this->user = $user = $this->getTestUser('davtest-' . time() . '@' . \config('app.domain'), $props);
-
- // Create the IMAP mailbox, it is required otherwise DAV requests will fail
- \config(['services.imap.default_folders' => null]);
- IMAP::createUser($user);
+ $ts = str_replace('.', '', (string) microtime(true));
+ $user = $this->getTestUser(
+ "davtest-{$ts}@" . \config('app.domain'),
+ $props = ['password' => 'test-pass']
+ );
$dav_folders = [
[
@@ -72,7 +74,10 @@
],
];
+ // Create the IMAP mailbox, it is required otherwise DAV requests will fail
+ \config(['services.imap.default_folders' => null]);
\config(['services.dav.default_folders' => $dav_folders]);
+ IMAP::createUser($user);
DAV::initDefaultFolders($user);
$dav = new DAV($user->email, $props['password']);
@@ -100,5 +105,96 @@
$this->assertSame(['VTODO'], $folders[0]->components);
$this->assertSame(['collection', 'calendar'], $folders[0]->types);
$this->assertSame('Tasks-Test', $folders[0]->name);
+
+ return [$dav_folders, $user];
+ }
+
+ /**
+ * Test sharing/unsharing folders for a user (delegation).
+ *
+ * @depends testInitDefaultFolders
+ * @group imap
+ * @group dav
+ */
+ public function testShareAndUnshareFolders($args): void
+ {
+ Queue::fake();
+
+ $ts = str_replace('.', '', (string) microtime(true));
+ $this->user2 = $user2 = $this->getTestUser(
+ "davtest2-{$ts}@" . \config('app.domain'),
+ $props = ['password' => 'test-pass']
+ );
+ $this->user = $user = $args[1];
+ $dav_folders = $args[0];
+
+ // Create the IMAP mailbox, it is required otherwise DAV requests will fail
+ \config(['services.imap.default_folders' => null]);
+ \config(['services.dav.default_folders' => $dav_folders]);
+ IMAP::createUser($user2);
+ DAV::initDefaultFolders($user2);
+
+ // Test delegation of calendar and addressbook folders only
+ DAV::shareDefaultFolders($user, $user2, ['event' => 'read-only', 'contact' => 'read-write']);
+
+ $dav = new DAV($user2->email, $props['password']);
+
+ $folders = array_values(array_filter(
+ $dav->listFolders(DAV::TYPE_VCARD),
+ fn ($folder) => $folder->owner === $user->email
+ ));
+ $this->assertCount(1, $folders);
+ $this->assertSame('read-write', $folders[0]->shareAccess);
+
+ $folders = array_values(array_filter(
+ $dav->listFolders(DAV::TYPE_VEVENT),
+ fn ($folder) => $folder->owner === $user->email
+ ));
+ $this->assertCount(1, $folders);
+ $this->assertSame('read', $folders[0]->shareAccess);
+
+ $folders = array_values(array_filter(
+ $dav->listFolders(DAV::TYPE_VTODO),
+ fn ($folder) => $folder->owner === $user->email
+ ));
+ $this->assertCount(0, $folders);
+
+ // Test unsubscribing from other user folders
+ DAV::unsubscribeSharedFolders($user2, $user->email);
+
+ $dav = new DAV($user2->email, $props['password']);
+
+ $folders = array_values(array_filter(
+ $dav->listFolders(DAV::TYPE_VCARD),
+ fn ($folder) => $folder->owner === $user->email
+ ));
+ $this->assertCount(0, $folders);
+
+ $folders = array_values(array_filter(
+ $dav->listFolders(DAV::TYPE_VEVENT),
+ fn ($folder) => $folder->owner === $user->email
+ ));
+ $this->assertCount(0, $folders);
+
+ // Test unsharing folders
+ DAV::unshareFolders($user, $user2->email);
+
+ $dav = new DAV($user->email, $props['password']);
+
+ $folders = array_values(array_filter(
+ $dav->listFolders(DAV::TYPE_VCARD),
+ fn ($folder) => $folder->owner != $user->email
+ || $folder->shareAccess != DAV\Folder::SHARE_ACCESS_NONE
+ || !empty($folder->invites)
+ ));
+ $this->assertCount(0, $folders);
+
+ $folders = array_values(array_filter(
+ $dav->listFolders(DAV::TYPE_VEVENT),
+ fn ($folder) => $folder->owner != $user->email
+ || $folder->shareAccess != DAV\Folder::SHARE_ACCESS_NONE
+ || !empty($folder->invites)
+ ));
+ $this->assertCount(0, $folders);
}
}
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
@@ -3,7 +3,6 @@
namespace Tests\Feature\Backends;
use App\Backends\IMAP;
-use App\Backends\LDAP;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
@@ -11,6 +10,7 @@
{
private $imap;
private $user;
+ private $user2;
private $group;
private $resource;
private $folder;
@@ -40,6 +40,9 @@
if ($this->user) {
$this->deleteTestUser($this->user->email);
}
+ if ($this->user2) {
+ $this->deleteTestUser($this->user2->email);
+ }
if ($this->group) {
$this->deleteTestGroup($this->group->email);
}
@@ -57,20 +60,14 @@
* Test aclCleanup()
*
* @group imap
- * @group ldap
*/
public function testAclCleanup(): void
{
Queue::fake();
- $this->user = $user = $this->getTestUser('test-' . time() . '@kolab.org', [], true);
- $this->group = $group = $this->getTestGroup('test-group-' . time() . '@kolab.org');
-
- // SETACL requires that the user/group exists in LDAP
- if (\config('app.with_ldap')) {
- LDAP::createUser($user);
- }
- // LDAP::createGroup($group);
+ $ts = str_replace('.', '', (string) microtime(true));
+ $this->user = $user = $this->getTestUser("test-{$ts}@kolab.org", [], true);
+ $this->group = $group = $this->getTestGroup("test-group-{$ts}@kolab.org");
// First, set some ACLs that we'll expect to be removed later
$imap = $this->getImap();
@@ -104,20 +101,14 @@
* Test aclCleanupDomain()
*
* @group imap
- * @group ldap
*/
public function testAclCleanupDomain(): void
{
Queue::fake();
- $this->user = $user = $this->getTestUser('test-' . time() . '@kolab.org', [], true);
- $this->group = $group = $this->getTestGroup('test-group-' . time() . '@kolab.org');
-
- // SETACL requires that the user/group exists in LDAP
- if (\config('app.with_ldap')) {
- LDAP::createUser($user);
- }
- // LDAP::createGroup($group);
+ $ts = str_replace('.', '', (string) microtime(true));
+ $this->user = $user = $this->getTestUser("test-{$ts}@kolab.org", [], true);
+ $this->group = $group = $this->getTestGroup("test-group-{$ts}@kolab.org");
// First, set some ACLs that we'll expect to be removed later
$imap = $this->getImap();
@@ -157,21 +148,16 @@
* Test creating/updating/deleting an IMAP account
*
* @group imap
- * @group ldap
*/
public function testUsers(): void
{
Queue::fake();
- $this->user = $user = $this->getTestUser('test-' . time() . '@' . \config('app.domain'), []);
+ $ts = str_replace('.', '', (string) microtime(true));
+ $this->user = $user = $this->getTestUser("test-{$ts}@" . \config('app.domain'), []);
$storage = \App\Sku::withObjectTenantContext($user)->where('title', 'storage')->first();
$user->assignSku($storage, 1, $user->wallets->first());
- // User must be in ldap, so imap auth works
- if (\config('app.with_ldap')) {
- LDAP::createUser($user);
- }
-
$expectedQuota = [
'user/' . $user->email => [
'storage' => [
@@ -208,6 +194,76 @@
$this->assertFalse(IMAP::verifyAccount($user->email));
}
+ /**
+ * Test sharing and unsharing folders (for delegation)
+ *
+ * @group imap
+ */
+ public function testShareAndUnshareFolders(): void
+ {
+ Queue::fake();
+
+ $ts = str_replace('.', '', (string) microtime(true));
+ $this->user = $user = $this->getTestUser("test-{$ts}@" . \config('app.domain'), []);
+ $this->user2 = $user2 = $this->getTestUser("test2-{$ts}@" . \config('app.domain'), []);
+
+ // Create the mailbox
+ $result = IMAP::createUser($user);
+ $this->assertTrue($result);
+
+ $imap = $this->getImap();
+
+ // Test delegation without mail folders permissions
+ $result = IMAP::shareDefaultFolders($user, $user2, ['event' => 'read-write']);
+ $this->assertTrue($result);
+
+ $acl = $imap->getACL("user/{$user->email}");
+ $this->assertArrayNotHasKey($user2->email, $acl);
+
+ // Test proper delegation case
+ $result = IMAP::shareDefaultFolders($user, $user2, ['mail' => 'read-write']);
+ $this->assertTrue($result);
+
+ $acl = $imap->getACL("user/{$user->email}");
+ $this->assertSame('lrswitedn', implode('', $acl[$user2->email]));
+
+ foreach (array_keys(\config('services.imap.default_folders')) as $folder) {
+ // User folder as seen by cyrus-admin
+ $folder = str_replace('@', "/{$folder}@", "user/{$user->email}");
+ $acl = $imap->getACL($folder);
+ $this->assertSame('lrswitedn', implode('', $acl[$user2->email]));
+ }
+
+ $imap = $this->getImap($user2->email);
+ $subscribed = $imap->listSubscribed('', "Other Users/*");
+ $expected = ["Other Users/test-{$ts}"];
+ foreach (array_keys(\config('services.imap.default_folders')) as $folder) {
+ $expected[] = "Other Users/test-{$ts}/{$folder}";
+ }
+
+ asort($subscribed);
+ asort($expected);
+ $this->assertSame(array_values($expected), array_values($subscribed));
+
+ // Test unsubscribing these folders
+ $result = IMAP::unsubscribeSharedFolders($user2, $user->email);
+ $this->assertTrue($result);
+ $this->assertSame([], $imap->listSubscribed("Other Users/test-{$ts}", '*'));
+
+ $imap->closeConnection();
+ $imap = $this->getImap();
+
+ // Test unsharing these folders
+ $result = IMAP::unshareFolders($user, $user2->email);
+ $this->assertTrue($result);
+ $this->assertNotContains($user2->email, $imap->getACL("user/{$user->email}"));
+ foreach (array_keys(\config('services.imap.default_folders')) as $folder) {
+ // User folder as seen by cyrus-admin
+ $folder = str_replace('@', "/{$folder}@", "user/{$user->email}");
+ $this->assertNotContains($user2->email, $imap->getACL($folder));
+ }
+ }
+
/**
* Test creating/updating/deleting a resource
*
@@ -217,9 +273,10 @@
{
Queue::fake();
+ $ts = str_replace('.', '', (string) microtime(true));
$this->resource = $resource = $this->getTestResource(
- 'test-resource-' . time() . '@kolab.org',
- ['name' => 'Resource ©' . time()]
+ "test-resource-{$ts}@kolab.org",
+ ['name' => "Resource © {$ts}"]
);
$resource->setSetting('invitation_policy', 'manual:john@kolab.org');
@@ -234,7 +291,7 @@
$this->assertSame($expectedAcl, $acl);
// Update the resource (rename)
- $resource->name = 'Resource1 ©' . time();
+ $resource->name = "Resource1 © {$ts}";
$resource->save();
$newImapFolder = $resource->getSetting('folder');
@@ -263,9 +320,10 @@
{
Queue::fake();
+ $ts = str_replace('.', '', (string) microtime(true));
$this->folder = $folder = $this->getTestSharedFolder(
- 'test-folder-' . time() . '@kolab.org',
- ['name' => 'SharedFolder ©' . time()]
+ "test-folder-{$ts}@kolab.org",
+ ['name' => "SharedFolder © {$ts}"]
);
$folder->setSetting('acl', json_encode(['john@kolab.org, full', 'jack@kolab.org, read-only']));
@@ -300,7 +358,7 @@
$this->assertSame($expectedAcl, $acl);
// Update the shared folder (rename)
- $folder->name = 'SharedFolder1 ©' . time();
+ $folder->name = "SharedFolder1 © {$ts}";
$folder->save();
$newImapFolder = $folder->getSetting('folder');
@@ -437,6 +495,7 @@
if ($loginAs) {
return $init->invokeArgs(null, [$config, $loginAs]);
}
+
return $this->imap = $init->invokeArgs(null, [$config]);
}
}
diff --git a/src/tests/Feature/Backends/RoundcubeTest.php b/src/tests/Feature/Backends/RoundcubeTest.php
--- a/src/tests/Feature/Backends/RoundcubeTest.php
+++ b/src/tests/Feature/Backends/RoundcubeTest.php
@@ -3,6 +3,8 @@
namespace Tests\Feature\Backends;
use App\Backends\Roundcube;
+use App\Delegation;
+use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class RoundcubeTest extends TestCase
@@ -15,6 +17,7 @@
parent::setUp();
$this->deleteTestUser('roundcube@' . \config('app.domain'));
+ $this->deleteTestUser('roundcube-delegatee@' . \config('app.domain'));
}
/**
@@ -23,6 +26,7 @@
public function tearDown(): void
{
$this->deleteTestUser('roundcube@' . \config('app.domain'));
+ $this->deleteTestUser('roundcube-delegatee@' . \config('app.domain'));
parent::tearDown();
}
@@ -64,4 +68,122 @@
$this->assertNull($db->table('users')->where('username', $user->email)->first());
$this->assertNull($db->table('identities')->where('user_id', $rcuser->user_id)->first());
}
+
+ /**
+ * Test creating delegated identities
+ *
+ * @group roundcube
+ */
+ public function testCreateDelegatedIdentities(): void
+ {
+ $delegatee = $this->getTestUser('roundcube-delegatee@' . \config('app.domain'));
+ $user = $this->getTestUser('roundcube@' . \config('app.domain'));
+ $user->setSetting('first_name', 'First');
+ $user->setSetting('last_name', 'Last');
+
+ $db = Roundcube::dbh();
+ $db->table('users')->whereIn('username', [$user->email, $delegatee->email])->delete();
+
+ // Test with both user records not existing (yet)
+ Roundcube::createDelegatedIdentities($delegatee, $user);
+
+ $this->assertNotNull($delegatee_id = Roundcube::userId($delegatee->email, false));
+ $idents = $db->table('identities')->where('user_id', $delegatee_id)
+ ->where('email', $user->email)->get();
+ $this->assertCount(1, $idents);
+ $this->assertSame($user->email, $idents[0]->email);
+ $this->assertSame('First Last', $idents[0]->name);
+ $this->assertSame(0, $idents[0]->standard);
+ $this->assertSame(null, $idents[0]->signature);
+
+ // Test with no delegator user record (yet)
+ $db->table('identities')->where('user_id', $delegatee_id)->where('email', $user->email)->delete();
+ Roundcube::createDelegatedIdentities($delegatee, $user);
+
+ $idents = $db->table('identities')->where('user_id', $delegatee_id)
+ ->where('email', $user->email)->get();
+ $this->assertCount(1, $idents);
+ $this->assertSame($user->email, $idents[0]->email);
+ $this->assertSame('First Last', $idents[0]->name);
+ $this->assertSame(0, $idents[0]->standard);
+ $this->assertSame(null, $idents[0]->signature);
+
+ // Test with delegator user record existing and his identity too
+ $db->table('identities')->where('user_id', $delegatee_id)->where('email', $user->email)->delete();
+ $this->assertNotNull($user_id = Roundcube::userId($user->email));
+ $db->table('identities')->where('user_id', $user_id)->update(['name' => 'Test']);
+
+ Roundcube::createDelegatedIdentities($delegatee, $user);
+
+ $idents = $db->table('identities')->where('user_id', $delegatee_id)
+ ->where('email', $user->email)->get();
+ $this->assertCount(1, $idents);
+ $this->assertSame($user->email, $idents[0]->email);
+ $this->assertSame('Test', $idents[0]->name);
+ $this->assertSame(null, $idents[0]->signature);
+
+ // TODO: signatures copying?
+ }
+
+ /**
+ * Test resetting (delegated) identities
+ *
+ * @group roundcube
+ */
+ public function testResetIdentities(): void
+ {
+ Queue::fake();
+
+ // Create two users with aliases and delegation relation
+ $delegatee = $this->getTestUser('roundcube-delegatee@' . \config('app.domain'));
+ $user = $this->getTestUser('roundcube@' . \config('app.domain'));
+ $user->aliases()->create(['alias' => 'alias@' . \config('app.domain')]);
+ $delegatee->aliases()->create(['alias' => 'alias-delegatee@' . \config('app.domain')]);
+
+ $delegation = Delegation::create([
+ 'user_id' => $user->id,
+ 'delegatee_id' => $delegatee->id,
+ ]);
+
+ // Create identities
+ $db = Roundcube::dbh();
+ $db->table('users')->whereIn('username', [$user->email, $delegatee->email])->delete();
+ $id = Roundcube::userId($delegatee->email);
+
+ $emails = [
+ $user->email,
+ 'alias@' . \config('app.domain'),
+ $delegatee->email,
+ 'alias-delegatee@' . \config('app.domain'),
+ ];
+ sort($emails);
+
+ foreach ($emails as $email) {
+ // Note: default identity will be created by userId() above
+ if ($email != $delegatee->email) {
+ $db->table('identities')->insert([
+ 'user_id' => $id,
+ 'email' => $email,
+ 'name' => 'Test',
+ 'changed' => now()->toDateTimeString(),
+ ]);
+ }
+ }
+
+ $idents = $db->table('identities')->where('user_id', $id)->orderBy('email')->pluck('email')->all();
+ $this->assertSame($emails, $idents);
+
+ // Test with existing delegation (identities should stay intact)
+ Roundcube::resetIdentities($delegatee);
+
+ $idents = $db->table('identities')->where('user_id', $id)->orderBy('email')->pluck('email')->all();
+ $this->assertSame($emails, $idents);
+
+ // Test with no delegation
+ $delegation->delete();
+ Roundcube::resetIdentities($delegatee);
+
+ $idents = $db->table('identities')->where('user_id', $id)->orderBy('email')->pluck('email')->all();
+ $this->assertSame(['alias-delegatee@' . \config('app.domain'), $delegatee->email], $idents);
+ }
}
diff --git a/src/tests/Feature/Controller/User/DelegationTest.php b/src/tests/Feature/Controller/User/DelegationTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Controller/User/DelegationTest.php
@@ -0,0 +1,240 @@
+<?php
+
+namespace Tests\Feature\Controller\User;
+
+use App\Delegation;
+use App\User;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class DelegationTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestUser('deleted@kolabnow.com');
+ Delegation::query()->delete();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $this->deleteTestUser('deleted@kolabnow.com');
+ Delegation::query()->delete();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test delegation creation (POST /api/v4/users/<id>/delegations)
+ */
+ public function testCreateDelegation(): void
+ {
+ Queue::fake();
+
+ $john = $this->getTestUser('john@kolab.org');
+ $jane = $this->getTestUser('jane@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $ned = $this->getTestUser('ned@kolab.org');
+
+ // Test unauth access
+ $response = $this->post("api/v4/users/{$john->id}/delegations", []);
+ $response->assertStatus(401);
+
+ // Test access to other user/account
+ $response = $this->actingAs($jack)->post("api/v4/users/{$john->id}/delegations", []);
+ $response->assertStatus(403);
+
+ // Test request made by the delegator user
+ $response = $this->actingAs($john)->post("api/v4/users/{$john->id}/delegations", []);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame(["The email field is required."], $json['errors']['email']);
+ $this->assertSame(["The options field is required."], $json['errors']['options']);
+
+ // Delegatee in another domain (and account) and invalid options
+ $post = ['email' => 'fred@' . \config('app.domain'), 'options' => ['mail' => 're']];
+ $response = $this->actingAs($john)->post("api/v4/users/{$john->id}/delegations", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame(["The specified email address is not a valid delegation target."], $json['errors']['email']);
+
+ // Invalid options
+ $post = ['email' => $jane->email, 'options' => ['ufo' => 're']];
+ $response = $this->actingAs($john)->post("api/v4/users/{$john->id}/delegations", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame(["The specified delegation options are invalid."], $json['errors']['options']);
+
+ // Valid input
+ $post = ['email' => $jane->email, 'options' => ['mail' => 'read-only']];
+ $response = $this->actingAs($john)->post("api/v4/users/{$john->id}/delegations", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("Delegation created successfully.", $json['message']);
+
+ $delegatee = $john->delegatees()->first();
+ $this->assertSame($jane->email, $delegatee->email);
+ $this->assertSame(['mail' => 'read-only'], $delegatee->delegation->options);
+
+ // Valid input (action taken by another wallet controller)
+ $post = ['email' => $jack->email, 'options' => ['mail' => 'read-only']];
+ $response = $this->actingAs($ned)->post("api/v4/users/{$john->id}/delegations", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame(2, $john->delegatees()->count());
+ }
+
+ /**
+ * Test listing delegations (GET /api/v4/users/<id>/delegations)
+ */
+ public function testDelegations(): void
+ {
+ Queue::fake();
+
+ $john = $this->getTestUser('john@kolab.org');
+ $jane = $this->getTestUser('jane@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $ned = $this->getTestUser('ned@kolab.org');
+ $user = $this->getTestUser('deleted@kolabnow.com');
+
+ Delegation::create(['user_id' => $john->id, 'delegatee_id' => $jack->id]);
+ Delegation::create(['user_id' => $john->id, 'delegatee_id' => $jane->id, 'options' => ['mail' => 'r']]);
+
+ // Test unauth access
+ $response = $this->get("api/v4/users/{$john->id}/delegations");
+ $response->assertStatus(401);
+
+ // Test access to other user/account
+ $response = $this->actingAs($user)->get("api/v4/users/{$john->id}/delegations");
+ $response->assertStatus(403);
+
+ // Test that non-controller cannot access
+ $response = $this->actingAs($jack)->get("api/v4/users/{$john->id}/delegations");
+ $response->assertStatus(403);
+
+ // Test request made by the delegator user
+ $response = $this->actingAs($john)->get("api/v4/users/{$john->id}/delegations");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json['list']);
+ $this->assertSame(2, $json['count']);
+ $this->assertSame($jack->email, $json['list'][0]['email']);
+ $this->assertSame([], $json['list'][0]['options']);
+ $this->assertSame($jane->email, $json['list'][1]['email']);
+ $this->assertSame(['mail' => 'r'], $json['list'][1]['options']);
+
+ // Test request made by the delegators wallet controller
+ $response = $this->actingAs($ned)->get("api/v4/users/{$john->id}/delegations");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json['list']);
+ }
+
+ /**
+ * Test listing delegators (GET /api/v4/users/<id>/delegators)
+ */
+ public function testDelegators(): void
+ {
+ Queue::fake();
+
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+
+ Delegation::create(['user_id' => $john->id, 'delegatee_id' => $jack->id, 'options' => ['mail' => 'r']]);
+
+ // Test unauth access
+ $response = $this->get("api/v4/users/{$john->id}/delegators");
+ $response->assertStatus(401);
+
+ // Test that non-controller cannot access other user
+ $response = $this->actingAs($jack)->get("api/v4/users/{$john->id}/delegators");
+ $response->assertStatus(403);
+
+ // Test request made by the delegatee user
+ $response = $this->actingAs($jack)->get("api/v4/users/{$jack->id}/delegators");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(1, $json['list']);
+ $this->assertSame(1, $json['count']);
+ $this->assertSame($john->email, $json['list'][0]['email']);
+ $this->assertSame(['john.doe@kolab.org'], $json['list'][0]['aliases']);
+
+ // Test request made by the delegatee's owner
+ $response = $this->actingAs($john)->get("api/v4/users/{$jack->id}/delegators");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(1, $json['list']);
+ $this->assertSame(1, $json['count']);
+ $this->assertSame($john->email, $json['list'][0]['email']);
+ $this->assertSame(['john.doe@kolab.org'], $json['list'][0]['aliases']);
+ }
+
+ /**
+ * Test delegatee deleting (DELETE /api/v4/users/<id>/delegations/<email>)
+ */
+ public function testDeleteDelegation(): void
+ {
+ Queue::fake();
+
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $user = $this->getTestUser('deleted@kolabnow.com');
+
+ Delegation::create(['user_id' => $john->id, 'delegatee_id' => $jack->id]);
+
+ // Test unauth access
+ $response = $this->delete("api/v4/users/{$john->id}/delegations/{$jack->email}");
+ $response->assertStatus(401);
+
+ // Test access to other user/account
+ $response = $this->actingAs($user)->delete("api/v4/users/{$john->id}/delegations/{$jack->email}");
+ $response->assertStatus(403);
+
+ // Test that non-controller cannot remove himself
+ $response = $this->actingAs($jack)->delete("api/v4/users/{$john->id}/delegations/{$jack->email}");
+ $response->assertStatus(403);
+
+ // Test non-existing delegation
+ $response = $this->actingAs($john)->delete("api/v4/users/{$john->id}/delegations/unknown@kolabnow.com");
+ $response->assertStatus(404);
+
+ // Test successful delegation removal
+ $response = $this->actingAs($john)->delete("api/v4/users/{$john->id}/delegations/{$jack->email}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertEquals('success', $json['status']);
+ $this->assertEquals('Delegation deleted successfully.', $json['message']);
+ }
+}
diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php
--- a/src/tests/Feature/Controller/UsersTest.php
+++ b/src/tests/Feature/Controller/UsersTest.php
@@ -685,6 +685,7 @@
$this->assertFalse($result['enableFolders']);
$this->assertFalse($result['enableDistlists']);
$this->assertFalse($result['enableResources']);
+ $this->assertTrue($result['enableDelegation']);
}
/**
@@ -1430,6 +1431,7 @@
$this->assertTrue($result['statusInfo']['enableSettings']);
$this->assertTrue($result['statusInfo']['enableDistlists']);
$this->assertTrue($result['statusInfo']['enableFolders']);
+ $this->assertTrue($result['statusInfo']['enableDelegation']);
// Ned is John's wallet controller
$plan = Plan::withEnvTenantContext()->where('title', 'individual')->first();
@@ -1461,6 +1463,7 @@
$this->assertTrue($result['statusInfo']['enableSettings']);
$this->assertTrue($result['statusInfo']['enableDistlists']);
$this->assertTrue($result['statusInfo']['enableFolders']);
+ $this->assertTrue($result['statusInfo']['enableDelegation']);
// Test discount in a response
$discount = Discount::where('code', 'TEST')->first();
@@ -1497,6 +1500,7 @@
$this->assertFalse($result['statusInfo']['enableSettings']);
$this->assertFalse($result['statusInfo']['enableDistlists']);
$this->assertFalse($result['statusInfo']['enableFolders']);
+ $this->assertTrue($result['statusInfo']['enableDelegation']);
$this->assertFalse($result['isLocked']);
// Test locked user
diff --git a/src/tests/Feature/DelegationTest.php b/src/tests/Feature/DelegationTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/DelegationTest.php
@@ -0,0 +1,64 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\Delegation;
+use App\User;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class DelegationTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestUser('UserAccountA@UserAccount.com');
+ $this->deleteTestUser('UserAccountB@UserAccount.com');
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $this->deleteTestUser('UserAccountA@UserAccount.com');
+ $this->deleteTestUser('UserAccountB@UserAccount.com');
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test user deletion regarding delegation relations
+ */
+ public function testDeleteWithDelegation(): void
+ {
+ Queue::fake();
+
+ // Test removing delegatee
+ $userA = $this->getTestUser('UserAccountA@UserAccount.com');
+ $userB = $this->getTestUser('UserAccountB@UserAccount.com');
+
+ $delegation = new Delegation();
+ $delegation->user_id = $userA->id;
+ $delegation->delegatee_id = $userB->id;
+ $delegation->save();
+
+ $delegation->delete();
+
+ $this->assertNull(Delegation::find($delegation->id));
+
+ Queue::assertPushed(\App\Jobs\User\Delegation\DeleteJob::class, 1);
+ Queue::assertPushed(
+ \App\Jobs\User\Delegation\DeleteJob::class,
+ function ($job) use ($userA, $userB) {
+ $delegator = TestCase::getObjectProperty($job, 'delegatorEmail');
+ $delegatee = TestCase::getObjectProperty($job, 'delegateeEmail');
+ return $delegator === $userA->email && $delegatee === $userB->email;
+ }
+ );
+ }
+}
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
@@ -2,6 +2,7 @@
namespace Tests\Feature;
+use App\Delegation;
use App\Domain;
use App\EventLog;
use App\Group;
@@ -949,6 +950,62 @@
Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 2);
}
+ /**
+ * Test user deletion regarding delegation relations
+ */
+ public function testDeleteWithDelegation(): void
+ {
+ Queue::fake();
+
+ // Test removing delegatee
+ $userA = $this->getTestUser('UserAccountA@UserAccount.com');
+ $userB = $this->getTestUser('UserAccountB@UserAccount.com');
+
+ $delegation = new Delegation();
+ $delegation->user_id = $userA->id;
+ $delegation->delegatee_id = $userB->id;
+ $delegation->save();
+
+ $userB->delete();
+
+ $this->assertNull(Delegation::find($delegation->id));
+
+ Queue::assertPushed(\App\Jobs\User\Delegation\DeleteJob::class, 1);
+ Queue::assertPushed(
+ \App\Jobs\User\Delegation\DeleteJob::class,
+ function ($job) use ($userA, $userB) {
+ $delegator = TestCase::getObjectProperty($job, 'delegatorEmail');
+ $delegatee = TestCase::getObjectProperty($job, 'delegateeEmail');
+ return $delegator === $userA->email && $delegatee === $userB->email;
+ }
+ );
+
+ $userB->deleted_at = null;
+ $userB->save();
+
+ Queue::fake();
+
+ // Test removing delegator
+ $delegation = new Delegation();
+ $delegation->user_id = $userA->id;
+ $delegation->delegatee_id = $userB->id;
+ $delegation->save();
+
+ $userA->delete();
+
+ $this->assertNull(Delegation::find($delegation->id));
+
+ Queue::assertPushed(\App\Jobs\User\Delegation\DeleteJob::class, 1);
+ Queue::assertPushed(
+ \App\Jobs\User\Delegation\DeleteJob::class,
+ function ($job) use ($userA, $userB) {
+ $delegator = TestCase::getObjectProperty($job, 'delegatorEmail');
+ $delegatee = TestCase::getObjectProperty($job, 'delegateeEmail');
+ return $delegator === $userA->email && $delegatee === $userB->email;
+ }
+ );
+ }
+
/**
* Test user deletion with PGP/WOAT enabled
*/
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Sun, Apr 5, 5:09 AM (13 h, 40 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18832433
Default Alt Text
D5172.1775365797.diff (122 KB)
Attached To
Mode
D5172: Delegation
Attached
Detach File
Event Timeline