Page MenuHomePhorge

D5172.1775365797.diff
No OneTemporary

Authored By
Unknown
Size
122 KB
Referenced Files
None
Subscribers
None

D5172.1775365797.diff

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>&nbsp;<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

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)

Event Timeline