Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117907498
D5172.1775391883.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
88 KB
Referenced Files
None
Subscribers
None
D5172.1775391883.diff
View Options
diff --git a/src/app/Backends/DAV.php b/src/app/Backends/DAV.php
--- a/src/app/Backends/DAV.php
+++ b/src/app/Backends/DAV.php
@@ -2,6 +2,7 @@
namespace App\Backends;
+use App\User;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
@@ -177,31 +178,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 +331,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 +367,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 +399,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)
+ {
+ $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,7 +531,77 @@
}
/**
- * Set folder sharing invites (draft-pot-webdav-resource-sharing)
+ * Share default DAV folders with specified user (delegatee)
+ *
+ * @param User $user The user
+ * @param User $to The delegated user
+ * @param array $acl ACL Permissions per folder type
+ *
+ * @throws \Exception
+ */
+ public static function shareDefaultFolders(User $user, User $to, array $acl): void
+ {
+ if (!\config('services.dav.uri')) {
+ return;
+ }
+
+ $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;
+ }
+
+ if (!empty($acl[$type])) {
+ $folders[] = [
+ 'href' => $folder['type'] . 's' . '/user/' . $user->email . '/' . $folder['path'],
+ 'acl' => $acl[$type] == self::SHARING_READ_WRITE ? self::SHARING_READ_WRITE : self::SHARING_READ,
+ ];
+ }
+ }
+
+ if (empty($folders)) {
+ return;
+ }
+
+ $dav = self::getClientForUser($user);
+
+ // Create sharing invitations
+ foreach ($folders as $folder) {
+ if (!$dav->shareResource($folder['href'], [$to->email => $folder['acl']])) {
+ throw new \Exception("Failed to share DAV folder {$folder['href']}");
+ }
+ }
+
+ // Accept sharing invitations
+ $dav = self::getClientForUser($to);
+
+ // 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}");
+ }
+ }
+ }
+ }
+
+ /**
+ * Set folder sharing invites (draft-pot-webdav-resource-sharing)
*
* @param string $location Resource (folder) location
* @param array $sharees Map of sharee => privilege
@@ -585,6 +709,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
*/
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
@@ -19,6 +19,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 +90,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 = [];
@@ -198,7 +208,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 +220,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/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,71 @@
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}");
+ 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}");
+ 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,41 @@
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();
+
+ $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) {
+ $delegator_name = $identity->name;
+ }
+ }
+
+ // Create the identity
+ $db->table(self::IDENTITIES_TABLE)->insert([
+ 'user_id' => $delegatee_id,
+ 'email' => $delegator->email,
+ 'name' => $delegator_name,
+ 'changed' => now()->toDateTimeString(),
+ ]);
+ }
+
/**
* Remove all files from the Enigma filestore.
*
@@ -241,7 +277,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/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);
+ }
+
+ /**
+ * 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 = [];
+
+ $delegatee = User::where('email', $request->email)->first();
+
+ if (
+ !$delegatee
+ || $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;
+ $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/Observers/DelegationObserver.php b/src/app/Observers/DelegationObserver.php
new file mode 100644
--- /dev/null
+++ b/src/app/Observers/DelegationObserver.php
@@ -0,0 +1,30 @@
+<?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
+ {
+ // TODO: Remove ACL of the delegatee on delegator's folders
+ // TODO: Remove delegatee's identities
+ // \App\Jobs\User\Delegation\ResetJob::dispatch($delegation->delegatee_id);
+ }
+}
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/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php
--- a/src/app/Providers/AppServiceProvider.php
+++ b/src/app/Providers/AppServiceProvider.php
@@ -42,6 +42,7 @@
*/
public function boot(): void
{
+ \App\Delegation::observe(\App\Observers\DelegationObserver::class);
\App\Domain::observe(\App\Observers\DomainObserver::class);
\App\Entitlement::observe(\App\Observers\EntitlementObserver::class);
\App\EventLog::observe(\App\Observers\EventLogObserver::class);
diff --git a/src/app/Traits/EmailPropertyTrait.php b/src/app/Traits/EmailPropertyTrait.php
--- a/src/app/Traits/EmailPropertyTrait.php
+++ b/src/app/Traits/EmailPropertyTrait.php
@@ -2,6 +2,8 @@
namespace App\Traits;
+use App\Domain;
+
trait EmailPropertyTrait
{
/** @var ?string Domain name for the to-be-created object */
@@ -42,19 +44,34 @@
/**
* Returns the object's domain (including soft-deleted).
*
- * @return ?\App\Domain The domain to which the object belongs to, NULL if it does not exist
+ * @return ?Domain The domain to which the object belongs to, NULL if it does not exist
*/
- public function domain(): ?\App\Domain
+ public function domain(): ?Domain
+ {
+ if ($domain = $this->domainNamespace()) {
+ return Domain::withTrashed()->where('namespace', $domain)->first();
+ }
+
+ return null;
+ }
+
+ /**
+ * Returns the object's domain namespace.
+ *
+ * @return ?string The domain to which the object belongs to if it has email property is set
+ */
+ public function domainNamespace(): ?string
{
if (empty($this->email) && isset($this->domainName)) {
- $domainName = $this->domainName;
- } elseif (strpos($this->email, '@')) {
- list($local, $domainName) = explode('@', $this->email);
- } else {
- return null;
+ return $this->domainName;
+ }
+
+ if (strpos($this->email, '@')) {
+ [$local, $domain] = explode('@', $this->email);
+ return $domain;
}
- return \App\Domain::withTrashed()->where('namespace', $domainName)->first();
+ return null;
}
/**
diff --git a/src/app/User.php b/src/app/User.php
--- a/src/app/User.php
+++ b/src/app/User.php
@@ -289,6 +289,31 @@
$this->save();
}
+ /**
+ * Users that this user is delegatee of.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
+ */
+ public function delegators()
+ {
+ return $this->belongsToMany(User::class, 'delegations', 'delegatee_id', 'user_id')
+ ->as('delegation')
+ ->using(Delegation::class);
+ }
+
+ /**
+ * Users that are delegatees of this user.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
+ */
+ public function delegatees()
+ {
+ return $this->belongsToMany(User::class, 'delegations', 'user_id', 'delegatee_id')
+ ->as('delegation')
+ ->using(Delegation::class)
+ ->withPivot('options');
+ }
+
/**
* List the domains to which this user is entitled.
*
diff --git a/src/config/app.php b/src/config/app.php
--- a/src/config/app.php
+++ b/src/config/app.php
@@ -251,6 +251,7 @@
'with_signup' => (bool) env('APP_WITH_SIGNUP', true),
'with_subscriptions' => (bool) env('APP_WITH_SUBSCRIPTIONS', true),
'with_wallet' => (bool) env('APP_WITH_WALLET', true),
+ 'with_delegation' => (bool) env('APP_WITH_DELEGATION', true),
'with_distlists' => (bool) env('APP_WITH_DISTLISTS', true),
'with_shared_folders' => (bool) env('APP_WITH_SHARED_FOLDERS', true),
diff --git a/src/database/migrations/2025_03_28_100000_create_delegations_table.php b/src/database/migrations/2025_03_28_100000_create_delegations_table.php
new file mode 100644
--- /dev/null
+++ b/src/database/migrations/2025_03_28_100000_create_delegations_table.php
@@ -0,0 +1,41 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+ /**
+ * Run the migrations.
+ */
+ public function up(): void
+ {
+ Schema::create(
+ 'delegations',
+ function (Blueprint $table) {
+ $table->bigIncrements('id');
+ $table->bigInteger('user_id');
+ $table->bigInteger('delegatee_id');
+ $table->text('options')->nullable(); // json
+ $table->timestamp('created_at')->useCurrent();
+ $table->timestamp('activated_at')->nullable();
+
+ $table->unique(['user_id', 'delegatee_id']);
+
+ $table->foreign('user_id')->references('id')->on('users')
+ ->onDelete('cascade')->onUpdate('cascade');
+ $table->foreign('delegatee_id')->references('id')->on('users')
+ ->onDelete('cascade')->onUpdate('cascade');
+ }
+ );
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('delegations');
+ }
+};
diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php
--- a/src/resources/lang/en/app.php
+++ b/src/resources/lang/en/app.php
@@ -72,6 +72,9 @@
'distlist-unsuspend-success' => 'Distribution list unsuspended successfully.',
'distlist-setconfig-success' => 'Distribution list settings updated successfully.',
+ 'delegation-create-success' => 'Delegation created successfully.',
+ 'delegation-delete-success' => 'Delegation deleted successfully.',
+
'domain-create-success' => 'Domain created successfully.',
'domain-delete-success' => 'Domain deleted successfully.',
'domain-notempty-error' => 'Unable to delete a domain with assigned users or other objects.',
diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php
--- a/src/resources/lang/en/ui.php
+++ b/src/resources/lang/en/ui.php
@@ -209,6 +209,7 @@
'lastname' => "Last Name",
'less' => "Less",
'name' => "Name",
+ 'mainopts' => "Main Options",
'months' => "months",
'more' => "More",
'none' => "none",
@@ -498,6 +499,16 @@
'custno' => "Customer No.",
'degraded-warning' => "The account is degraded. Some features have been disabled.",
'degraded-hint' => "Please, make a payment.",
+ 'delegation' => "Delegation",
+ 'delegation-create' => "Add delegate",
+ 'delegation-mail' => "Mail",
+ 'delegation-event' => "Calendar",
+ 'delegation-task' => "Tasks",
+ 'delegation-contact' => "Contacts",
+ 'delegation-none' => "There are no delegates.",
+ 'delegation-desc' => "Delegates can send mail in your behalf and manage your mail/calendar/tasks/contacts according to the given permissions."
+ . " This includes e.g. inviting people to meetings and responding to such requests.",
+ 'delegation-perm' => "This delegate has the following permissions:",
'delete' => "Delete user",
'delete-email' => "Delete {email}",
'delete-text' => "Do you really want to delete this user permanently?"
diff --git a/src/resources/lang/en/validation.php b/src/resources/lang/en/validation.php
--- a/src/resources/lang/en/validation.php
+++ b/src/resources/lang/en/validation.php
@@ -136,6 +136,8 @@
'emailinvalid' => 'The specified email address is invalid.',
'domaininvalid' => 'The specified domain is invalid.',
'domainnotavailable' => 'The specified domain is not available.',
+ 'delegateeinvalid' => 'The specified email address is not a valid delegation target.',
+ 'delegationoptioninvalid' => 'The specified delegation options are invalid.',
'logininvalid' => 'The specified login is invalid.',
'loginexists' => 'The specified login is not available.',
'domainexists' => 'The specified domain is not available.',
diff --git a/src/resources/themes/forms.scss b/src/resources/themes/forms.scss
--- a/src/resources/themes/forms.scss
+++ b/src/resources/themes/forms.scss
@@ -116,6 +116,16 @@
& > button + button {
margin-left: .5em;
}
+
+ .accordion-header > & {
+ position: absolute;
+ display: flex;
+ align-items: center;
+ top: 0;
+ right: 3.25rem;
+ height: 2.5rem;
+ z-index: 3;
+ }
}
// Various improvements for mobile
diff --git a/src/resources/themes/variables.scss b/src/resources/themes/variables.scss
--- a/src/resources/themes/variables.scss
+++ b/src/resources/themes/variables.scss
@@ -1,3 +1,10 @@
+/* Colors */
+
+$kolab-blue: #65c2ee;
+$light: #f6f5f3;
+
+$main-color: $kolab-blue;
+
/* Body */
$body-bg: #fff;
@@ -8,12 +15,12 @@
$font-size-base: 0.9rem;
$line-height-base: 1.5;
-/* Colors */
-
-$kolab-blue: #65c2ee;
-$light: #f6f5f3;
+/* Bootstrap components */
-$main-color: $kolab-blue;
+$accordion-padding-y: 1rem;
+$accordion-padding-x: 1rem;
+$accordion-button-padding-y: 0.7rem;
+$accordion-button-active-bg: $light;
/* App colors */
diff --git a/src/resources/vue/User/Info.vue b/src/resources/vue/User/Info.vue
--- a/src/resources/vue/User/Info.vue
+++ b/src/resources/vue/User/Info.vue
@@ -11,7 +11,7 @@
</btn>
</div>
<div class="card-text">
- <tabs class="mt-3" :tabs="tabs"></tabs>
+ <tabs class="mt-3" :tabs="tabs" ref="tabs"></tabs>
<div class="tab-content">
<div class="tab-pane active" id="general" role="tabpanel" aria-labelledby="tab-general">
<form @submit.prevent="submit" class="card-body">
@@ -88,43 +88,57 @@
<btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
</form>
</div>
- <div v-if="isController" class="tab-pane" id="settings" role="tabpanel" aria-labelledby="tab-settings">
- <form @submit.prevent="submitSettings" class="card-body">
- <div class="row checkbox mb-3">
- <label for="greylist_enabled" class="col-sm-4 col-form-label">{{ $t('user.greylisting') }}</label>
- <div class="col-sm-8 pt-2">
- <input type="checkbox" id="greylist_enabled" name="greylist_enabled" value="1" class="form-check-input d-block mb-2" :checked="user.config.greylist_enabled">
- <small id="greylisting-hint" class="text-muted">
- {{ $t('user.greylisting-text') }}
- </small>
- </div>
- </div>
- <div v-if="$root.hasPermission('beta')" class="row checkbox mb-3">
- <label for="guam_enabled" class="col-sm-4 col-form-label">
- {{ $t('user.imapproxy') }}
- <sup class="badge bg-primary">{{ $t('dashboard.beta') }}</sup>
- </label>
- <div class="col-sm-8 pt-2">
- <input type="checkbox" id="guam_enabled" name="guam_enabled" value="1" class="form-check-input d-block mb-2" :checked="user.config.guam_enabled">
- <small id="guam-hint" class="text-muted">
- {{ $t('user.imapproxy-text') }}
- </small>
- </div>
- </div>
- <div v-if="$root.hasPermission('beta')" class="row mb-3">
- <label for="limit_geo" class="col-sm-4 col-form-label">
- {{ $t('user.geolimit') }}
- <sup class="badge bg-primary">{{ $t('dashboard.beta') }}</sup>
- </label>
- <div class="col-sm-8 pt-2">
- <country-select id="limit_geo" v-model="user.config.limit_geo"></country-select>
- <small id="geolimit-hint" class="text-muted">
- {{ $t('user.geolimit-text') }}
- </small>
- </div>
- </div>
- <btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
- </form>
+ <div v-if="Object.keys(settingsSections).length > 0" class="tab-pane" id="settings" role="tabpanel" aria-labelledby="tab-settings">
+ <accordion class="mt-3" id="settings-all" :names="settingsSections" :buttons="settingsButtons">
+ <template #options v-if="settingsSections.options">
+ <form @submit.prevent="submitSettings">
+ <div class="row checkbox mb-3">
+ <label for="greylist_enabled" class="col-sm-4 col-form-label">{{ $t('user.greylisting') }}</label>
+ <div class="col-sm-8 pt-2">
+ <input type="checkbox" id="greylist_enabled" name="greylist_enabled" value="1" class="form-check-input d-block mb-2" :checked="user.config.greylist_enabled">
+ <small id="greylisting-hint" class="text-muted">
+ {{ $t('user.greylisting-text') }}
+ </small>
+ </div>
+ </div>
+ <div v-if="$root.hasPermission('beta')" class="row checkbox mb-3">
+ <label for="guam_enabled" class="col-sm-4 col-form-label">
+ {{ $t('user.imapproxy') }}
+ <sup class="badge bg-primary">{{ $t('dashboard.beta') }}</sup>
+ </label>
+ <div class="col-sm-8 pt-2">
+ <input type="checkbox" id="guam_enabled" name="guam_enabled" value="1" class="form-check-input d-block mb-2" :checked="user.config.guam_enabled">
+ <small id="guam-hint" class="text-muted">
+ {{ $t('user.imapproxy-text') }}
+ </small>
+ </div>
+ </div>
+ <div v-if="$root.hasPermission('beta')" class="row mb-3">
+ <label for="limit_geo" class="col-sm-4 col-form-label">
+ {{ $t('user.geolimit') }}
+ <sup class="badge bg-primary">{{ $t('dashboard.beta') }}</sup>
+ </label>
+ <div class="col-sm-8 pt-2">
+ <country-select id="limit_geo" v-model="user.config.limit_geo"></country-select>
+ <small id="geolimit-hint" class="text-muted">
+ {{ $t('user.geolimit-text') }}
+ </small>
+ </div>
+ </div>
+ <btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
+ </form>
+ </template>
+ <template #delegation v-if="settingsSections.delegation">
+ <list-table :list="delegations" :setup="delegationListSetup" class="mb-0">
+ <template #email="{ item }">
+ <svg-icon icon="user-tie"></svg-icon> <span>{{ item.email }}</span>
+ </template>
+ <template #buttons="{ item }">
+ <btn class="text-danger button-delete p-0 ms-1" @click="delegationDelete(item.email)" icon="trash-can" :title="$t('btn.delete')"></btn>
+ </template>
+ </list-table>
+ </template>
+ </accordion>
</div>
<div class="tab-pane" id="personal" role="tabpanel" aria-labelledby="tab-personal">
<form @submit.prevent="submitPersonalSettings" class="card-body">
@@ -191,12 +205,43 @@
</div>
<p v-else>{{ $t('user.delete-text') }}</p>
</modal-dialog>
+ <modal-dialog id="delegation-create" ref="delegationDialog" :buttons="['save']" :cancel-focus="true" @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 +252,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 +274,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 +320,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 +343,7 @@
return tabs
}
- if (this.isController) {
+ if (Object.keys(this.settingsSections).length > 0) {
tabs.push('form.settings')
}
@@ -291,6 +380,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 +521,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">
+ <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 :id="slotName" :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/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,58 @@
$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 default folders for a user (delegation).
+ *
+ * @depends testInitDefaultFolders
+ * @group imap
+ * @group dav
+ */
+ public function testShareDefaultFolders($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);
}
}
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,60 @@
$this->assertFalse(IMAP::verifyAccount($user->email));
}
+ /**
+ * Test sharing default folders (for delegation)
+ *
+ * @group imap
+ */
+ public function testShareDefaultFolders(): 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->listMailboxes('', "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));
+
+ $imap->closeConnection();
+ }
+
/**
* Test creating/updating/deleting a resource
*
@@ -217,9 +257,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 +275,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 +304,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 +342,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 +479,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
@@ -15,6 +15,7 @@
parent::setUp();
$this->deleteTestUser('roundcube@' . \config('app.domain'));
+ $this->deleteTestUser('roundcube-delegatee@' . \config('app.domain'));
}
/**
@@ -23,6 +24,7 @@
public function tearDown(): void
{
$this->deleteTestUser('roundcube@' . \config('app.domain'));
+ $this->deleteTestUser('roundcube-delegatee@' . \config('app.domain'));
parent::tearDown();
}
@@ -64,4 +66,60 @@
$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?
+ }
}
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
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Sun, Apr 5, 12:24 PM (16 h, 19 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18832431
Default Alt Text
D5172.1775391883.diff (88 KB)
Attached To
Mode
D5172: Delegation
Attached
Detach File
Event Timeline