diff --git a/src/app/Backends/IMAP.php b/src/app/Backends/IMAP.php index db4a1584..1de54587 100644 --- a/src/app/Backends/IMAP.php +++ b/src/app/Backends/IMAP.php @@ -1,712 +1,712 @@ 'lrs', 'read-write' => 'lrswitedn', '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. \App\Jobs\IMAP\AclCleanupJob::dispatch($group->name, $domainName); return true; } /** * Create a mailbox. * * @param \App\User $user User * * @return bool True if a mailbox was created successfully, False otherwise * @throws \Exception */ public static function createUser(User $user): bool { $config = self::getConfig(); $imap = self::initIMAP($config); $mailbox = self::toUTF7('user/' . $user->email); // Mailbox already exists if (self::folderExists($imap, $mailbox)) { $imap->closeConnection(); self::createDefaultFolders($user); return true; } // Create the mailbox if (!$imap->createFolder($mailbox)) { \Log::error("Failed to create mailbox {$mailbox}"); $imap->closeConnection(); return false; } // Wait until it's propagated (for Cyrus Murder setup) // FIXME: Do we still need this? if (strpos($imap->conn->data['GREETING'] ?? '', 'Cyrus IMAP Murder') !== false) { $tries = 30; while ($tries-- > 0) { $folders = $imap->listMailboxes('', $mailbox); if (is_array($folders) && count($folders)) { break; } sleep(1); $imap->closeConnection(); $imap = self::initIMAP($config); } } // Set quota $quota = $user->countEntitlementsBySku('storage') * 1048576; if ($quota) { $imap->setQuota($mailbox, ['storage' => $quota]); } self::createDefaultFolders($user); $imap->closeConnection(); return true; } /** * Create default folders for the user. * * @param \App\User $user User */ public static function createDefaultFolders(User $user): void { if ($defaultFolders = \config('imap.default_folders')) { $config = self::getConfig(); // Log in as user to set private annotations and subscription state $imap = self::initIMAP($config, $user->email); foreach ($defaultFolders as $name => $folderconfig) { try { $mailbox = self::toUTF7($name); self::createFolder($imap, $mailbox, true, $folderconfig['metadata']); } catch (\Exception $e) { - \Log::warning("Failed to create the default folder" . $e->getMessage()); + \Log::warning("Failed to create the default folder. " . $e->getMessage()); } } $imap->closeConnection(); } } /** * Delete a mailbox. * * @param \App\User $user User * * @return bool True if a mailbox was deleted successfully, False otherwise * @throws \Exception */ public static function deleteUser(User $user): bool { $config = self::getConfig(); $imap = self::initIMAP($config); $mailbox = self::toUTF7('user/' . $user->email); // To delete the mailbox cyrus-admin needs extra permissions $imap->setACL($mailbox, $config['user'], 'c'); // Delete the mailbox (no need to delete subfolders?) $result = $imap->deleteFolder($mailbox); if (!$result) { // Ignore the error if the folder doesn't exist (maybe it was removed already). if (!self::folderExists($imap, $mailbox)) { \Log::info("The mailbox to delete was already removed: $mailbox"); $result = true; } } $imap->closeConnection(); // Cleanup ACL \App\Jobs\IMAP\AclCleanupJob::dispatch($user->email); return $result; } /** * Update a mailbox (quota). * * @param \App\User $user User * * @return bool True if a mailbox was updated successfully, False otherwise * @throws \Exception */ public static function updateUser(User $user): bool { $config = self::getConfig(); $imap = self::initIMAP($config); $mailbox = self::toUTF7('user/' . $user->email); $result = true; // Set quota $quota = $user->countEntitlementsBySku('storage') * 1048576; if ($quota) { $result = $imap->setQuota($mailbox, ['storage' => $quota]); } $imap->closeConnection(); return $result; } /** * Create a resource. * * @param \App\Resource $resource Resource * * @return bool True if a resource was created successfully, False otherwise * @throws \Exception */ public static function createResource(Resource $resource): bool { $config = self::getConfig(); $imap = self::initIMAP($config); $settings = $resource->getSettings(['invitation_policy', 'folder']); $mailbox = self::toUTF7($settings['folder']); $acl = null; if (!empty($settings['invitation_policy'])) { if (preg_match('/^manual:(\S+@\S+)$/', $settings['invitation_policy'], $m)) { $acl = ["{$m[1]}, full"]; } } self::createFolder($imap, $mailbox, false, ['/shared/vendor/kolab/folder-type' => 'event'], $acl); $imap->closeConnection(); return true; } /** * Update a resource. * * @param \App\Resource $resource Resource * @param array $props Old resource properties * * @return bool True if a resource was updated successfully, False otherwise * @throws \Exception */ public static function updateResource(Resource $resource, array $props = []): bool { $config = self::getConfig(); $imap = self::initIMAP($config); $settings = $resource->getSettings(['invitation_policy', 'folder']); $folder = $settings['folder']; $mailbox = self::toUTF7($folder); // Rename the mailbox (only possible if we have the old folder) if (!empty($props['folder']) && $props['folder'] != $folder) { $oldMailbox = self::toUTF7($props['folder']); if (!$imap->renameFolder($oldMailbox, $mailbox)) { \Log::error("Failed to rename mailbox {$oldMailbox} to {$mailbox}"); $imap->closeConnection(); return false; } } // ACL $acl = []; if (!empty($settings['invitation_policy'])) { if (preg_match('/^manual:(\S+@\S+)$/', $settings['invitation_policy'], $m)) { $acl = ["{$m[1]}, full"]; } } self::aclUpdate($imap, $mailbox, $acl); $imap->closeConnection(); return true; } /** * Delete a resource. * * @param \App\Resource $resource Resource * * @return bool True if a resource was deleted successfully, False otherwise * @throws \Exception */ public static function deleteResource(Resource $resource): bool { $config = self::getConfig(); $imap = self::initIMAP($config); $settings = $resource->getSettings(['folder']); $mailbox = self::toUTF7($settings['folder']); // To delete the mailbox cyrus-admin needs extra permissions $imap->setACL($mailbox, $config['user'], 'c'); // Delete the mailbox (no need to delete subfolders?) $result = $imap->deleteFolder($mailbox); $imap->closeConnection(); return $result; } /** * Create a shared folder. * * @param \App\SharedFolder $folder Shared folder * * @return bool True if a falder was created successfully, False otherwise * @throws \Exception */ public static function createSharedFolder(SharedFolder $folder): bool { $config = self::getConfig(); $imap = self::initIMAP($config); $settings = $folder->getSettings(['acl', 'folder']); $acl = !empty($settings['acl']) ? json_decode($settings['acl'], true) : null; $mailbox = self::toUTF7($settings['folder']); self::createFolder($imap, $mailbox, false, ['/shared/vendor/kolab/folder-type' => $folder->type], $acl); $imap->closeConnection(); return true; } /** * Update a shared folder. * * @param \App\SharedFolder $folder Shared folder * @param array $props Old folder properties * * @return bool True if a falder was updated successfully, False otherwise * @throws \Exception */ public static function updateSharedFolder(SharedFolder $folder, array $props = []): bool { $config = self::getConfig(); $imap = self::initIMAP($config); $settings = $folder->getSettings(['acl', 'folder']); $acl = !empty($settings['acl']) ? json_decode($settings['acl'], true) : null; $folder = $settings['folder']; $mailbox = self::toUTF7($folder); // Rename the mailbox if (!empty($props['folder']) && $props['folder'] != $folder) { $oldMailbox = self::toUTF7($props['folder']); if (!$imap->renameFolder($oldMailbox, $mailbox)) { \Log::error("Failed to rename mailbox {$oldMailbox} to {$mailbox}"); $imap->closeConnection(); return false; } } // Note: Shared folder type does not change // ACL self::aclUpdate($imap, $mailbox, $acl); $imap->closeConnection(); return true; } /** * Delete a shared folder. * * @param \App\SharedFolder $folder Shared folder * * @return bool True if a falder was deleted successfully, False otherwise * @throws \Exception */ public static function deleteSharedFolder(SharedFolder $folder): bool { $config = self::getConfig(); $imap = self::initIMAP($config); $settings = $folder->getSettings(['folder']); $mailbox = self::toUTF7($settings['folder']); // To delete the mailbox cyrus-admin needs extra permissions $imap->setACL($mailbox, $config['user'], 'c'); // Delete the mailbox $result = $imap->deleteFolder($mailbox); $imap->closeConnection(); return $result; } /** * Check if a shared folder is set up. * * @param string $folder Folder name, e.g. shared/Resources/Name@domain.tld * * @return bool True if a folder exists and is set up, False otherwise */ public static function verifySharedFolder(string $folder): bool { $config = self::getConfig(); $imap = self::initIMAP($config); // Convert the folder from UTF8 to UTF7-IMAP if (\preg_match('#^(shared/|shared/Resources/)(.+)(@[^@]+)$#', $folder, $matches)) { $folderName = self::toUTF7($matches[2]); $folder = $matches[1] . $folderName . $matches[3]; } // FIXME: just listMailboxes() does not return shared folders at all $metadata = $imap->getMetadata($folder, ['/shared/vendor/kolab/folder-type']); $imap->closeConnection(); // Note: We have to use error code to distinguish an error from "no mailbox" response if ($imap->errornum === \rcube_imap_generic::ERROR_NO) { return false; } if ($imap->errornum !== \rcube_imap_generic::ERROR_OK) { throw new \Exception("Failed to get folder metadata from IMAP"); } return true; } /** * Convert UTF8 string to UTF7-IMAP encoding */ public static function toUTF7(string $string): string { return \mb_convert_encoding($string, 'UTF7-IMAP', 'UTF8'); } /** * Check if an account is set up * * @param string $username User login (email address) * * @return bool True if an account exists and is set up, False otherwise */ public static function verifyAccount(string $username): bool { $config = self::getConfig(); $imap = self::initIMAP($config); $mailbox = self::toUTF7('user/' . $username); // Mailbox already exists if (self::folderExists($imap, $mailbox)) { $imap->closeConnection(); return true; } $imap->closeConnection(); return false; } /** * Check if an account is set up * * @param string $username User login (email address) * * @return bool True if an account exists and is set up, False otherwise */ public static function verifyDefaultFolders(string $username): bool { $config = self::getConfig(); $imap = self::initIMAP($config, $username); foreach (\config('imap.default_folders') as $mb => $_metadata) { $mailbox = self::toUTF7($mb); if (!self::folderExists($imap, $mailbox)) { $imap->closeConnection(); return false; } } $imap->closeConnection(); return true; } /** * Check if we can connect to the imap server * * @return bool True on success */ public static function healthcheck(): bool { $config = self::getConfig(); $imap = self::initIMAP($config); $imap->closeConnection(); return true; } /** * Remove ACL for a specified user/group anywhere in the IMAP * * @param string $ident ACL identifier (user email or e.g. group name) * @param string $domain ACL domain */ public static function aclCleanup(string $ident, string $domain = ''): void { $config = self::getConfig(); $imap = self::initIMAP($config); if (strpos($ident, '@')) { $domain = explode('@', $ident, 2)[1]; } $callback = function ($folder) use ($imap, $ident) { $acl = $imap->getACL($folder); if (is_array($acl) && isset($acl[$ident])) { $imap->deleteACL($folder, $ident); } }; $folders = $imap->listMailboxes('', "user/*@{$domain}"); if (!is_array($folders)) { $imap->closeConnection(); throw new \Exception("Failed to get IMAP folders"); } array_walk($folders, $callback); $folders = $imap->listMailboxes('', "shared/*@{$domain}"); if (!is_array($folders)) { $imap->closeConnection(); throw new \Exception("Failed to get IMAP folders"); } array_walk($folders, $callback); $imap->closeConnection(); } /** * Create a folder and set some default properties * * @param \rcube_imap_generic $imap The imap instance * @param string $mailbox Mailbox name * @param bool $subscribe Subscribe to the folder * @param array $metadata Metadata to set on the folder * @param array $acl Acl to set on the folder * * @return bool True when having a folder created, False if it already existed. * @throws \Exception */ private static function createFolder($imap, string $mailbox, $subscribe = false, $metadata = null, $acl = null) { if (self::folderExists($imap, $mailbox)) { return false; } if (!$imap->createFolder($mailbox)) { throw new \Exception("Failed to create mailbox {$mailbox}"); } if ($acl) { self::aclUpdate($imap, $mailbox, $acl, true); } if ($subscribe) { $imap->subscribe($mailbox); } foreach ($metadata as $key => $value) { $imap->setMetadata($mailbox, [$key => $value]); } return true; } /** * Convert Kolab ACL into IMAP user->rights array */ private static function aclToImap($acl): array { if (empty($acl)) { return []; } return \collect($acl) ->mapWithKeys(function ($item, $key) { list($user, $rights) = explode(',', $item, 2); return [trim($user) => self::ACL_MAP[trim($rights)]]; }) ->all(); } /** * Update folder ACL */ private static function aclUpdate($imap, $mailbox, $acl, bool $isNew = false) { $imapAcl = $isNew ? [] : $imap->getACL($mailbox); if (is_array($imapAcl)) { foreach (self::aclToImap($acl) as $user => $rights) { if (empty($imapAcl[$user]) || implode('', $imapAcl[$user]) !== $rights) { $imap->setACL($mailbox, $user, $rights); } unset($imapAcl[$user]); } foreach ($imapAcl as $user => $rights) { $imap->deleteACL($mailbox, $user); } } } /** * Check if an IMAP folder exists */ private static function folderExists($imap, string $folder): bool { $folders = $imap->listMailboxes('', $folder); if (!is_array($folders)) { $imap->closeConnection(); throw new \Exception("Failed to get IMAP folders"); } return count($folders) > 0; } /** * Initialize connection to IMAP */ private static function initIMAP(array $config, string $login_as = null) { $imap = new \rcube_imap_generic(); if (\config('app.debug')) { $imap->setDebug(true, 'App\Backends\IMAP::logDebug'); } if ($login_as) { $config['options']['auth_cid'] = $config['user']; $config['options']['auth_pw'] = $config['password']; $config['options']['auth_type'] = 'PLAIN'; $config['user'] = $login_as; } $imap->connect($config['host'], $config['user'], $config['password'], $config['options']); if (!$imap->connected()) { $message = sprintf("Login failed for %s against %s. %s", $config['user'], $config['host'], $imap->error); \Log::error($message); throw new \Exception("Connection to IMAP failed"); } return $imap; } /** * Get LDAP configuration for specified access level */ private static function getConfig() { $uri = \parse_url(\config('imap.uri')); $default_port = 143; $ssl_mode = null; if (isset($uri['scheme'])) { if (preg_match('/^(ssl|imaps)/', $uri['scheme'])) { $default_port = 993; $ssl_mode = 'ssl'; } elseif ($uri['scheme'] === 'tls') { $ssl_mode = 'tls'; } } $config = [ 'host' => $uri['host'], 'user' => \config('imap.admin_login'), 'password' => \config('imap.admin_password'), 'options' => [ 'port' => !empty($uri['port']) ? $uri['port'] : $default_port, 'ssl_mode' => $ssl_mode, 'socket_options' => [ 'ssl' => [ 'verify_peer' => \config('imap.verify_peer'), 'verify_peer_name' => \config('imap.verify_peer'), 'verify_host' => \config('imap.verify_host') ], ], ], ]; return $config; } /** * Debug logging callback */ public static function logDebug($conn, $msg): void { $msg = '[IMAP] ' . $msg; \Log::debug($msg); } } diff --git a/src/app/Backends/LDAP.php b/src/app/Backends/LDAP.php index ca1111f7..b6fbd532 100644 --- a/src/app/Backends/LDAP.php +++ b/src/app/Backends/LDAP.php @@ -1,1419 +1,1417 @@ close(); self::$ldap = null; } } /** * Validates that ldap is available as configured. * * @throws \Exception */ public static function healthcheck(): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $mgmtRootDN = \config('ldap.admin.root_dn'); $hostedRootDN = \config('ldap.hosted.root_dn'); $result = $ldap->search($mgmtRootDN, '', 'base'); if (!$result || $result->count() != 1) { self::throwException($ldap, "Failed to find the configured management domain $mgmtRootDN"); } $result = $ldap->search($hostedRootDN, '', 'base'); if (!$result || $result->count() != 1) { self::throwException($ldap, "Failed to find the configured hosted domain $hostedRootDN"); } } /** * Create a domain in LDAP. * * @param \App\Domain $domain The domain to create. * * @throws \Exception */ public static function createDomain(Domain $domain): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $mgmtRootDN = \config('ldap.admin.root_dn'); $hostedRootDN = \config('ldap.hosted.root_dn'); $domainBaseDN = "ou={$domain->namespace},{$hostedRootDN}"; $aci = [ '(targetattr = "*")' . '(version 3.0; acl "Deny Unauthorized"; deny (all)' . '(userdn != "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . ' || ldap:///ou=People,' . $domainBaseDN . '??sub?(objectclass=inetorgperson)") ' . 'AND NOT roledn = "ldap:///cn=kolab-admin,' . $mgmtRootDN . '";)', '(targetattr != "userPassword")' . '(version 3.0;acl "Search Access";allow (read,compare,search)' . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . ' || ldap:///ou=People,' . $domainBaseDN . '??sub?(objectclass=inetorgperson)");)', '(targetattr = "*")' . '(version 3.0;acl "Kolab Administrators";allow (all)' . '(roledn = "ldap:///cn=kolab-admin,' . $domainBaseDN . ' || ldap:///cn=kolab-admin,' . $mgmtRootDN . '");)' ]; $entry = [ 'aci' => $aci, 'associateddomain' => $domain->namespace, 'inetdomainbasedn' => $domainBaseDN, 'objectclass' => [ 'top', 'domainrelatedobject', 'inetdomain' ], ]; $dn = "associateddomain={$domain->namespace},{$config['domain_base_dn']}"; self::setDomainAttributes($domain, $entry); if (!$ldap->get_entry($dn)) { self::addEntry( $ldap, $dn, $entry, "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" ); } // create ou, roles, ous $entry = [ 'description' => $domain->namespace, 'objectclass' => [ 'top', 'organizationalunit' ], 'ou' => $domain->namespace, ]; $entry['aci'] = array( '(targetattr = "*")' . '(version 3.0;acl "Deny Unauthorized"; deny (all)' . '(userdn != "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . ' || ldap:///ou=People,' . $domainBaseDN . '??sub?(objectclass=inetorgperson)") ' . 'AND NOT roledn = "ldap:///cn=kolab-admin,' . $mgmtRootDN . '";)', '(targetattr != "userPassword")' . '(version 3.0;acl "Search Access";allow (read,compare,search,write)' . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . ' || ldap:///ou=People,' . $domainBaseDN . '??sub?(objectclass=inetorgperson)");)', '(targetattr = "*")' . '(version 3.0;acl "Kolab Administrators";allow (all)' . '(roledn = "ldap:///cn=kolab-admin,' . $domainBaseDN . ' || ldap:///cn=kolab-admin,' . $mgmtRootDN . '");)', '(target = "ldap:///ou=*,' . $domainBaseDN . '")' . '(targetattr="objectclass || aci || ou")' . '(version 3.0;acl "Allow Domain sub-OU Registration"; allow (add)' . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . '");)', '(target = "ldap:///uid=*,ou=People,' . $domainBaseDN . '")(targetattr="*")' . '(version 3.0;acl "Allow Domain First User Registration"; allow (add)' . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . '");)', '(target = "ldap:///cn=*,' . $domainBaseDN . '")(targetattr="objectclass || cn")' . '(version 3.0;acl "Allow Domain Role Registration"; allow (add)' . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . '");)', ); if (!$ldap->get_entry($domainBaseDN)) { self::addEntry( $ldap, $domainBaseDN, $entry, "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" ); } foreach (['Groups', 'People', 'Resources', 'Shared Folders'] as $item) { $itemDN = "ou={$item},{$domainBaseDN}"; if (!$ldap->get_entry($itemDN)) { $itemEntry = [ 'ou' => $item, 'description' => $item, 'objectclass' => [ 'top', 'organizationalunit' ] ]; self::addEntry( $ldap, $itemDN, $itemEntry, "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" ); } } foreach (['kolab-admin'] as $item) { $itemDN = "cn={$item},{$domainBaseDN}"; if (!$ldap->get_entry($itemDN)) { $itemEntry = [ 'cn' => $item, 'description' => "{$item} role", 'objectclass' => [ 'top', 'ldapsubentry', 'nsmanagedroledefinition', 'nsroledefinition', 'nssimpleroledefinition' ] ]; self::addEntry( $ldap, $itemDN, $itemEntry, "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" ); } } // TODO: Assign kolab-admin role to the owner? if (empty(self::$ldap)) { $ldap->close(); } } /** * Create a group in LDAP. * * @param \App\Group $group The group to create. * * @throws \Exception */ public static function createGroup(Group $group): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $domainName = explode('@', $group->email, 2)[1]; $cn = $ldap->quote_string($group->name); $dn = "cn={$cn}," . self::baseDN($ldap, $domainName, 'Groups'); $entry = [ 'mail' => $group->email, 'objectclass' => [ 'top', 'groupofuniquenames', 'kolabgroupofuniquenames' ], ]; if (!self::getGroupEntry($ldap, $group->email)) { self::setGroupAttributes($ldap, $group, $entry); self::addEntry( $ldap, $dn, $entry, "Failed to create group {$group->email} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Create a resource in LDAP. * * @param \App\Resource $resource The resource to create. * * @throws \Exception */ public static function createResource(Resource $resource): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $domainName = explode('@', $resource->email, 2)[1]; $cn = $ldap->quote_string($resource->name); $dn = "cn={$cn}," . self::baseDN($ldap, $domainName, 'Resources'); $entry = [ 'mail' => $resource->email, 'objectclass' => [ 'top', 'kolabresource', 'kolabsharedfolder', 'mailrecipient', ], 'kolabfoldertype' => 'event', ]; if (!self::getResourceEntry($ldap, $resource->email)) { self::setResourceAttributes($ldap, $resource, $entry); self::addEntry( $ldap, $dn, $entry, "Failed to create resource {$resource->email} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Create a shared folder in LDAP. * * @param \App\SharedFolder $folder The shared folder to create. * * @throws \Exception */ public static function createSharedFolder(SharedFolder $folder): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $domainName = explode('@', $folder->email, 2)[1]; $cn = $ldap->quote_string($folder->name); $dn = "cn={$cn}," . self::baseDN($ldap, $domainName, 'Shared Folders'); $entry = [ 'mail' => $folder->email, 'objectclass' => [ 'top', 'kolabsharedfolder', 'mailrecipient', ], ]; if (!self::getSharedFolderEntry($ldap, $folder->email)) { self::setSharedFolderAttributes($ldap, $folder, $entry); self::addEntry( $ldap, $dn, $entry, "Failed to create shared folder {$folder->id} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Create a user in LDAP. * * Only need to add user if in any of the local domains? Figure that out here for now. Should * have Context-Based Access Controls before the job is queued though, probably. * * Use one of three modes; * * 1) The authenticated user account. * * * Only valid if the authenticated user is a domain admin. * * We don't know the originating user here. * * We certainly don't have its password anymore. * * 2) The hosted kolab account. * * 3) The Directory Manager account. * * @param \App\User $user The user account to create. * * @throws \Exception */ public static function createUser(User $user): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $entry = [ 'objectclass' => [ 'top', 'inetorgperson', 'inetuser', 'kolabinetorgperson', 'mailrecipient', 'person' ], 'mail' => $user->email, 'uid' => $user->email, 'nsroledn' => [] ]; if (!self::getUserEntry($ldap, $user->email, $dn)) { if (empty($dn)) { self::throwException($ldap, "Failed to create user {$user->email} in LDAP (" . __LINE__ . ")"); } self::setUserAttributes($user, $entry); self::addEntry( $ldap, $dn, $entry, "Failed to create user {$user->email} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Delete a domain from LDAP. * * @param \App\Domain $domain The domain to delete * * @throws \Exception */ public static function deleteDomain(Domain $domain): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $domainBaseDN = self::baseDN($ldap, $domain->namespace); if ($ldap->get_entry($domainBaseDN)) { $result = $ldap->delete_entry_recursive($domainBaseDN); if (!$result) { self::throwException( $ldap, "Failed to delete domain {$domain->namespace} from LDAP (" . __LINE__ . ")" ); } } if ($ldap_domain = $ldap->find_domain($domain->namespace)) { if ($ldap->get_entry($ldap_domain['dn'])) { $result = $ldap->delete_entry($ldap_domain['dn']); if (!$result) { self::throwException( $ldap, "Failed to delete domain {$domain->namespace} from LDAP (" . __LINE__ . ")" ); } } } if (empty(self::$ldap)) { $ldap->close(); } } /** * Delete a group from LDAP. * * @param \App\Group $group The group to delete. * * @throws \Exception */ public static function deleteGroup(Group $group): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); if (self::getGroupEntry($ldap, $group->email, $dn)) { $result = $ldap->delete_entry($dn); if (!$result) { self::throwException( $ldap, "Failed to delete group {$group->email} from LDAP (" . __LINE__ . ")" ); } } if (empty(self::$ldap)) { $ldap->close(); } } /** * Delete a resource from LDAP. * * @param \App\Resource $resource The resource to delete. * * @throws \Exception */ public static function deleteResource(Resource $resource): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); if (self::getResourceEntry($ldap, $resource->email, $dn)) { $result = $ldap->delete_entry($dn); if (!$result) { self::throwException( $ldap, "Failed to delete resource {$resource->email} from LDAP (" . __LINE__ . ")" ); } } if (empty(self::$ldap)) { $ldap->close(); } } /** * Delete a shared folder from LDAP. * * @param \App\SharedFolder $folder The shared folder to delete. * * @throws \Exception */ public static function deleteSharedFolder(SharedFolder $folder): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); if (self::getSharedFolderEntry($ldap, $folder->email, $dn)) { $result = $ldap->delete_entry($dn); if (!$result) { self::throwException( $ldap, "Failed to delete shared folder {$folder->id} from LDAP (" . __LINE__ . ")" ); } } if (empty(self::$ldap)) { $ldap->close(); } } /** * Delete a user from LDAP. * * @param \App\User $user The user account to delete. * * @throws \Exception */ public static function deleteUser(User $user): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); if (self::getUserEntry($ldap, $user->email, $dn)) { $result = $ldap->delete_entry($dn); if (!$result) { self::throwException( $ldap, "Failed to delete user {$user->email} from LDAP (" . __LINE__ . ")" ); } } if (empty(self::$ldap)) { $ldap->close(); } } /** * Get a domain data from LDAP. * * @param string $namespace The domain name * * @return array|false|null * @throws \Exception */ public static function getDomain(string $namespace) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $ldapDomain = $ldap->find_domain($namespace); if ($ldapDomain) { $domain = $ldap->get_entry($ldapDomain['dn']); } if (empty(self::$ldap)) { $ldap->close(); } return $domain ?? null; } /** * Get a group data from LDAP. * * @param string $email The group email. * * @return array|false|null * @throws \Exception */ public static function getGroup(string $email) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $group = self::getGroupEntry($ldap, $email, $dn); if (empty(self::$ldap)) { $ldap->close(); } return $group; } /** * Get a resource data from LDAP. * * @param string $email The resource email. * * @return array|false|null * @throws \Exception */ public static function getResource(string $email) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $resource = self::getResourceEntry($ldap, $email, $dn); if (empty(self::$ldap)) { $ldap->close(); } return $resource; } /** * Get a shared folder data from LDAP. * * @param string $email The resource email. * * @return array|false|null * @throws \Exception */ public static function getSharedFolder(string $email) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $folder = self::getSharedFolderEntry($ldap, $email, $dn); if (empty(self::$ldap)) { $ldap->close(); } return $folder; } /** * Get a user data from LDAP. * * @param string $email The user email. * * @return array|false|null * @throws \Exception */ public static function getUser(string $email) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $user = self::getUserEntry($ldap, $email, $dn, true); if (empty(self::$ldap)) { $ldap->close(); } return $user; } /** * Update a domain in LDAP. * * @param \App\Domain $domain The domain to update. * * @throws \Exception */ public static function updateDomain(Domain $domain): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $ldapDomain = $ldap->find_domain($domain->namespace); if (!$ldapDomain) { self::throwException( $ldap, "Failed to update domain {$domain->namespace} in LDAP (domain not found)" ); } $oldEntry = $ldap->get_entry($ldapDomain['dn']); $newEntry = $oldEntry; self::setDomainAttributes($domain, $newEntry); if (array_key_exists('inetdomainstatus', $newEntry)) { $newEntry['inetdomainstatus'] = (string) $newEntry['inetdomainstatus']; } $result = $ldap->modify_entry($ldapDomain['dn'], $oldEntry, $newEntry); if (!is_array($result)) { self::throwException( $ldap, "Failed to update domain {$domain->namespace} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Update a group in LDAP. * * @param \App\Group $group The group to update * * @throws \Exception */ public static function updateGroup(Group $group): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $newEntry = $oldEntry = self::getGroupEntry($ldap, $group->email, $dn); if (empty($oldEntry)) { self::throwException( $ldap, "Failed to update group {$group->email} in LDAP (group not found)" ); } self::setGroupAttributes($ldap, $group, $newEntry); $result = $ldap->modify_entry($dn, $oldEntry, $newEntry); if (!is_array($result)) { self::throwException( $ldap, "Failed to update group {$group->email} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Update a resource in LDAP. * * @param \App\Resource $resource The resource to update * * @throws \Exception */ public static function updateResource(Resource $resource): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $newEntry = $oldEntry = self::getResourceEntry($ldap, $resource->email, $dn); if (empty($oldEntry)) { self::throwException( $ldap, "Failed to update resource {$resource->email} in LDAP (resource not found)" ); } self::setResourceAttributes($ldap, $resource, $newEntry); $result = $ldap->modify_entry($dn, $oldEntry, $newEntry); if (!is_array($result)) { self::throwException( $ldap, "Failed to update resource {$resource->email} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Update a shared folder in LDAP. * * @param \App\SharedFolder $folder The shared folder to update * * @throws \Exception */ public static function updateSharedFolder(SharedFolder $folder): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $newEntry = $oldEntry = self::getSharedFolderEntry($ldap, $folder->email, $dn); if (empty($oldEntry)) { self::throwException( $ldap, "Failed to update shared folder {$folder->id} in LDAP (folder not found)" ); } self::setSharedFolderAttributes($ldap, $folder, $newEntry); $result = $ldap->modify_entry($dn, $oldEntry, $newEntry); if (!is_array($result)) { self::throwException( $ldap, "Failed to update shared folder {$folder->id} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Update a user in LDAP. * * @param \App\User $user The user account to update. * * @throws \Exception */ public static function updateUser(User $user): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $newEntry = $oldEntry = self::getUserEntry($ldap, $user->email, $dn, true); if (!$oldEntry) { self::throwException( $ldap, "Failed to update user {$user->email} in LDAP (user not found)" ); } self::setUserAttributes($user, $newEntry); if (array_key_exists('objectclass', $newEntry)) { if (!in_array('inetuser', $newEntry['objectclass'])) { $newEntry['objectclass'][] = 'inetuser'; } } if (array_key_exists('inetuserstatus', $newEntry)) { $newEntry['inetuserstatus'] = (string) $newEntry['inetuserstatus']; } if (array_key_exists('mailquota', $newEntry)) { $newEntry['mailquota'] = (string) $newEntry['mailquota']; } $result = $ldap->modify_entry($dn, $oldEntry, $newEntry); if (!is_array($result)) { self::throwException( $ldap, "Failed to update user {$user->email} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Initialize connection to LDAP */ private static function initLDAP(array $config, string $privilege = 'admin') { if (self::$ldap) { return self::$ldap; } $ldap = new \Net_LDAP3($config); $connected = $ldap->connect(); if (!$connected) { throw new \Exception("Failed to connect to LDAP"); } $bound = $ldap->bind( \config("ldap.{$privilege}.bind_dn"), \config("ldap.{$privilege}.bind_pw") ); if (!$bound) { throw new \Exception("Failed to bind to LDAP"); } return $ldap; } /** * Set domain attributes */ private static function setDomainAttributes(Domain $domain, array &$entry) { $entry['inetdomainstatus'] = $domain->status; } /** * Convert group member addresses in to valid entries. */ private static function setGroupAttributes($ldap, Group $group, &$entry) { $settings = $group->getSettings(['sender_policy']); // Make sure the policy does not contain duplicates, they aren't allowed // by the ldap definition of kolabAllowSMTPSender attribute $sender_policy = json_decode($settings['sender_policy'] ?: '[]', true); $sender_policy = array_values(array_unique(array_map('strtolower', $sender_policy))); $entry['kolaballowsmtpsender'] = $sender_policy; $entry['cn'] = $group->name; $entry['uniquemember'] = []; $groupDomain = explode('@', $group->email, 2)[1]; $domainBaseDN = self::baseDN($ldap, $groupDomain); $validMembers = []; foreach ($group->members as $member) { list($local, $domainName) = explode('@', $member); $memberDN = "uid={$member},ou=People,{$domainBaseDN}"; $memberEntry = $ldap->get_entry($memberDN); // if the member is in the local domain but doesn't exist, drop it if ($domainName == $groupDomain && !$memberEntry) { continue; } // add the member if not in the local domain if (!$memberEntry) { $memberEntry = [ 'cn' => $member, 'mail' => $member, 'objectclass' => [ 'top', 'inetorgperson', 'organizationalperson', 'person' ], 'sn' => 'unknown' ]; $ldap->add_entry($memberDN, $memberEntry); } $entry['uniquemember'][] = $memberDN; $validMembers[] = $member; } // Update members in sql (some might have been removed), // skip model events to not invoke another update job if ($group->members !== $validMembers) { $group->members = $validMembers; $group->saveQuietly(); } } /** * Set common resource attributes */ private static function setResourceAttributes($ldap, Resource $resource, &$entry) { $entry['cn'] = $resource->name; $entry['owner'] = null; $entry['kolabinvitationpolicy'] = null; $entry['acl'] = ''; $settings = $resource->getSettings(['invitation_policy', 'folder']); $entry['kolabtargetfolder'] = $settings['folder'] ?? ''; // Here's how Wallace's resources module works: // - if policy is ACT_MANUAL and owner mail specified: a tentative response is sent, event saved, // and mail sent to the owner to accept/decline the request. // - if policy is ACT_ACCEPT_AND_NOTIFY and owner mail specified: an accept response is sent, // event saved, and notification (not confirmation) mail sent to the owner. // - if there's no owner (policy irrelevant): an accept response is sent, event saved. // - if policy is ACT_REJECT: a decline response is sent // - note that the notification email is being send if COND_NOTIFY policy is set or saving failed. // - all above assume there's no conflict, if there's a conflict the decline response is sent automatically // (notification is sent if policy = ACT_ACCEPT_AND_NOTIFY). // - the only supported policies are: 'ACT_MANUAL', 'ACT_ACCEPT' (defined but not used anywhere), // 'ACT_REJECT', 'ACT_ACCEPT_AND_NOTIFY'. // For now we ignore the notifications feature if (!empty($settings['invitation_policy'])) { if ($settings['invitation_policy'] === 'accept') { $entry['kolabinvitationpolicy'] = 'ACT_ACCEPT'; } elseif ($settings['invitation_policy'] === 'reject') { $entry['kolabinvitationpolicy'] = 'ACT_REJECT'; } elseif (preg_match('/^manual:(\S+@\S+)$/', $settings['invitation_policy'], $m)) { if (self::getUserEntry($ldap, $m[1], $userDN)) { $entry['owner'] = $userDN; $entry['acl'] = $m[1] . ', full'; $entry['kolabinvitationpolicy'] = 'ACT_MANUAL'; } else { $entry['kolabinvitationpolicy'] = 'ACT_ACCEPT'; } } } } /** * Set common shared folder attributes */ private static function setSharedFolderAttributes($ldap, SharedFolder $folder, &$entry) { $settings = $folder->getSettings(['acl', 'folder']); $entry['cn'] = $folder->name; $entry['kolabfoldertype'] = $folder->type; $entry['kolabtargetfolder'] = $settings['folder'] ?? ''; $entry['acl'] = !empty($settings['acl']) ? json_decode($settings['acl'], true) : ''; $entry['alias'] = $folder->aliases()->pluck('alias')->all(); } /** * Set common user attributes */ private static function setUserAttributes(User $user, array &$entry) { $isDegraded = $user->isDegraded(true); $settings = $user->getSettings(['first_name', 'last_name', 'organization']); $firstName = $settings['first_name']; $lastName = $settings['last_name']; $cn = "unknown"; $displayname = ""; if ($firstName) { if ($lastName) { $cn = "{$firstName} {$lastName}"; $displayname = "{$lastName}, {$firstName}"; } else { $lastName = "unknown"; $cn = "{$firstName}"; $displayname = "{$firstName}"; } } else { $firstName = ""; if ($lastName) { $cn = "{$lastName}"; $displayname = "{$lastName}"; } else { $lastName = "unknown"; } } $entry['cn'] = $cn; $entry['displayname'] = $displayname; $entry['givenname'] = $firstName; $entry['sn'] = $lastName; $entry['userpassword'] = $user->password_ldap; $entry['inetuserstatus'] = $user->status; $entry['o'] = $settings['organization']; $entry['mailquota'] = 0; $entry['alias'] = $user->aliases()->pluck('alias')->all(); $roles = []; foreach ($user->entitlements as $entitlement) { - \Log::debug("Examining {$entitlement->sku->title}"); - switch ($entitlement->sku->title) { case "mailbox": break; case "storage": $entry['mailquota'] += 1048576; break; default: $roles[] = $entitlement->sku->title; break; } } $hostedRootDN = \config('ldap.hosted.root_dn'); $entry['nsroledn'] = []; if (in_array("2fa", $roles)) { $entry['nsroledn'][] = "cn=2fa-user,{$hostedRootDN}"; } if ($isDegraded) { $entry['nsroledn'][] = "cn=degraded-user,{$hostedRootDN}"; $entry['mailquota'] = \config('app.storage.min_qty') * 1048576; } else { if (in_array("activesync", $roles)) { $entry['nsroledn'][] = "cn=activesync-user,{$hostedRootDN}"; } if (!in_array("groupware", $roles)) { $entry['nsroledn'][] = "cn=imap-user,{$hostedRootDN}"; } } } /** * Get LDAP configuration for specified access level */ private static function getConfig(string $privilege) { $config = [ 'domain_base_dn' => \config('ldap.domain_base_dn'), 'domain_filter' => \config('ldap.domain_filter'), 'domain_name_attribute' => \config('ldap.domain_name_attribute'), 'hosts' => \config('ldap.hosts'), 'sort' => false, 'vlv' => false, 'log_hook' => 'App\Backends\LDAP::logHook', ]; return $config; } /** * Get group entry from LDAP. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $email Group email (mail) * @param string $dn Reference to group DN * * @return null|array Group entry, NULL if not found */ private static function getGroupEntry($ldap, $email, &$dn = null) { $domainName = explode('@', $email, 2)[1]; $base_dn = self::baseDN($ldap, $domainName, 'Groups'); $attrs = ['dn', 'cn', 'mail', 'uniquemember', 'objectclass', 'kolaballowsmtpsender']; // For groups we're using search() instead of get_entry() because // a group name is not constant, so e.g. on update we might have // the new name, but not the old one. Email address is constant. return self::searchEntry($ldap, $base_dn, "(mail=$email)", $attrs, $dn); } /** * Get a resource entry from LDAP. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $email Resource email (mail) * @param string $dn Reference to the resource DN * * @return null|array Resource entry, NULL if not found */ private static function getResourceEntry($ldap, $email, &$dn = null) { $domainName = explode('@', $email, 2)[1]; $base_dn = self::baseDN($ldap, $domainName, 'Resources'); $attrs = ['dn', 'cn', 'mail', 'objectclass', 'kolabtargetfolder', 'kolabfoldertype', 'kolabinvitationpolicy', 'owner', 'acl']; // For resources we're using search() instead of get_entry() because // a resource name is not constant, so e.g. on update we might have // the new name, but not the old one. Email address is constant. return self::searchEntry($ldap, $base_dn, "(mail=$email)", $attrs, $dn); } /** * Get a shared folder entry from LDAP. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $email Resource email (mail) * @param string $dn Reference to the shared folder DN * * @return null|array Shared folder entry, NULL if not found */ private static function getSharedFolderEntry($ldap, $email, &$dn = null) { $domainName = explode('@', $email, 2)[1]; $base_dn = self::baseDN($ldap, $domainName, 'Shared Folders'); $attrs = ['dn', 'cn', 'mail', 'objectclass', 'kolabtargetfolder', 'kolabfoldertype', 'acl', 'alias']; // For shared folders we're using search() instead of get_entry() because // a folder name is not constant, so e.g. on update we might have // the new name, but not the old one. Email address is constant. return self::searchEntry($ldap, $base_dn, "(mail=$email)", $attrs, $dn); } /** * Get user entry from LDAP. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $email User email (uid) * @param string $dn Reference to user DN * @param bool $full Get extra attributes, e.g. nsroledn * * @return null|array User entry, NULL if not found */ private static function getUserEntry($ldap, $email, &$dn = null, $full = false) { $domainName = explode('@', $email, 2)[1]; $dn = "uid={$email}," . self::baseDN($ldap, $domainName, 'People'); $entry = $ldap->get_entry($dn); if ($entry && $full) { if (!array_key_exists('nsroledn', $entry)) { $roles = $ldap->get_entry_attributes($dn, ['nsroledn']); if (!empty($roles)) { $entry['nsroledn'] = (array) $roles['nsroledn']; } } } return $entry ?: null; } /** * Logging callback */ public static function logHook($level, $msg): void { if ( ( $level == LOG_INFO || $level == LOG_DEBUG || $level == LOG_NOTICE ) && !\config('app.debug') ) { return; } switch ($level) { case LOG_CRIT: $function = 'critical'; break; case LOG_EMERG: $function = 'emergency'; break; case LOG_ERR: $function = 'error'; break; case LOG_ALERT: $function = 'alert'; break; case LOG_WARNING: $function = 'warning'; break; case LOG_INFO: $function = 'info'; break; case LOG_DEBUG: $function = 'debug'; break; case LOG_NOTICE: $function = 'notice'; break; default: $function = 'info'; } if (is_array($msg)) { $msg = implode("\n", $msg); } $msg = '[LDAP] ' . $msg; \Log::{$function}($msg); } /** * A wrapper for Net_LDAP3::add_entry() with error handler * * @param \Net_LDAP3 $ldap Ldap connection * @param string $dn Entry DN * @param array $entry Entry attributes * @param ?string $errorMsg A message to throw as an exception on error * * @throws \Exception */ private static function addEntry($ldap, string $dn, array $entry, $errorMsg = null) { // try/catch because Laravel converts warnings into exceptions // and we want more human-friendly error message than that try { $result = $ldap->add_entry($dn, $entry); } catch (\Exception $e) { $result = false; } if (!$result) { if (!$errorMsg) { $errorMsg = "LDAP Error (" . __LINE__ . ")"; } if (isset($e)) { $errorMsg .= ": " . $e->getMessage(); } self::throwException($ldap, $errorMsg); } } /** * Find a single entry in LDAP by using search. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $base_dn Base DN * @param string $filter Search filter * @param array $attrs Result attributes * @param string $dn Reference to a DN of the found entry * * @return null|array LDAP entry, NULL if not found */ private static function searchEntry($ldap, $base_dn, $filter, $attrs, &$dn = null) { $result = $ldap->search($base_dn, $filter, 'sub', $attrs); if ($result && $result->count() == 1) { $entries = $result->entries(true); $dn = key($entries); $entry = $entries[$dn]; $entry['dn'] = $dn; return $entry; } return null; } /** * Throw exception and close the connection when needed * * @param \Net_LDAP3 $ldap Ldap connection * @param string $message Exception message * * @throws \Exception */ private static function throwException($ldap, string $message): void { if (empty(self::$ldap)) { $ldap->close(); } throw new \Exception($message); } /** * Create a base DN string for a specified object. * Note: It makes sense with an existing domain only. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $domainName Domain namespace * @param ?string $ouName Optional name of the sub-tree (OU) * * @return string Full base DN */ private static function baseDN($ldap, string $domainName, string $ouName = null): string { $dn = $ldap->domain_root_dn($domainName); if ($ouName) { $dn = "ou={$ouName},{$dn}"; } return $dn; } } diff --git a/src/resources/js/user/routes.js b/src/resources/js/user/routes.js index e4f2b77e..eef2321c 100644 --- a/src/resources/js/user/routes.js +++ b/src/resources/js/user/routes.js @@ -1,207 +1,199 @@ import LoginComponent from '../../vue/Login' import LogoutComponent from '../../vue/Logout' import PageComponent from '../../vue/Page' import PasswordResetComponent from '../../vue/PasswordReset' import SignupComponent from '../../vue/Signup' // Here's a list of lazy-loaded components // Note: you can pack multiple components into the same chunk, webpackChunkName // is also used to get a sensible file name instead of numbers const CompanionAppInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/CompanionApp/Info') const CompanionAppListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/CompanionApp/List') const DashboardComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Dashboard') const DistlistInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Distlist/Info') const DistlistListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Distlist/List') const DomainInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Domain/Info') const DomainListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Domain/List') const FileInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/File/Info') const FileListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/File/List') const PaymentStatusComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Payment/Status') +const PoliciesComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Policies') const ResourceInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Resource/Info') const ResourceListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Resource/List') const RoomInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Room/Info') const RoomListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Room/List') -const SettingsComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Settings') const SharedFolderInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/SharedFolder/Info') const SharedFolderListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/SharedFolder/List') const UserInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/User/Info') const UserListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/User/List') -const UserProfileComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/User/Profile') -const UserProfileDeleteComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/User/ProfileDelete') const WalletComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Wallet') const MeetComponent = () => import(/* webpackChunkName: "../user/meet" */ '../../vue/Meet/Room.vue') const routes = [ { path: '/dashboard', name: 'dashboard', component: DashboardComponent, meta: { requiresAuth: true } }, { path: '/distlist/:list', name: 'distlist', component: DistlistInfoComponent, meta: { requiresAuth: true, perm: 'distlists' } }, { path: '/distlists', name: 'distlists', component: DistlistListComponent, meta: { requiresAuth: true, perm: 'distlists' } }, { path: '/companion/:companion', name: 'companion', component: CompanionAppInfoComponent, meta: { requiresAuth: true, perm: 'companionapps' } }, { path: '/companions', name: 'companions', component: CompanionAppListComponent, meta: { requiresAuth: true, perm: 'companionapps' } }, { path: '/domain/:domain', name: 'domain', component: DomainInfoComponent, meta: { requiresAuth: true, perm: 'domains' } }, { path: '/domains', name: 'domains', component: DomainListComponent, meta: { requiresAuth: true, perm: 'domains' } }, { path: '/file/:file', name: 'file', component: FileInfoComponent, meta: { requiresAuth: true /*, perm: 'files' */ } }, { path: '/files/:parent?', name: 'files', component: FileListComponent, meta: { requiresAuth: true, perm: 'files' } }, { path: '/login', name: 'login', component: LoginComponent }, { path: '/logout', name: 'logout', component: LogoutComponent }, { name: 'meet', path: '/meet/:room', component: MeetComponent, meta: { loading: true } }, { path: '/password-reset/:code?', name: 'password-reset', component: PasswordResetComponent }, { path: '/payment/status', name: 'payment-status', component: PaymentStatusComponent, meta: { requiresAuth: true } }, - { - path: '/profile', - name: 'profile', - component: UserProfileComponent, - meta: { requiresAuth: true } - }, - { - path: '/profile/delete', - name: 'profile-delete', - component: UserProfileDeleteComponent, - meta: { requiresAuth: true } - }, { path: '/resource/:resource', name: 'resource', component: ResourceInfoComponent, meta: { requiresAuth: true, perm: 'resources' } }, { path: '/resources', name: 'resources', component: ResourceListComponent, meta: { requiresAuth: true, perm: 'resources' } }, { path: '/room/:room', name: 'room', component: RoomInfoComponent, meta: { requiresAuth: true, perm: 'rooms' } }, { path: '/rooms', name: 'rooms', component: RoomListComponent, meta: { requiresAuth: true, perm: 'rooms' } }, + { + path: '/policies', + name: 'policies', + component: PoliciesComponent, + meta: { requiresAuth: true, perm: 'settings' } + }, { path: '/settings', name: 'settings', - component: SettingsComponent, - meta: { requiresAuth: true, perm: 'settings' } + component: UserInfoComponent, + meta: { requiresAuth: true } }, { path: '/shared-folder/:folder', name: 'shared-folder', component: SharedFolderInfoComponent, meta: { requiresAuth: true, perm: 'folders' } }, { path: '/shared-folders', name: 'shared-folders', component: SharedFolderListComponent, meta: { requiresAuth: true, perm: 'folders' } }, { path: '/signup/invite/:param', name: 'signup-invite', component: SignupComponent }, { path: '/signup/:param?', alias: '/signup/voucher/:param', name: 'signup', component: SignupComponent }, { path: '/user/:user', name: 'user', component: UserInfoComponent, meta: { requiresAuth: true, perm: 'users' } }, { path: '/users', name: 'users', component: UserListComponent, meta: { requiresAuth: true, perm: 'users' } }, { path: '/wallet', name: 'wallet', component: WalletComponent, meta: { requiresAuth: true, perm: 'wallets' } }, { name: '404', path: '*', component: PageComponent } ] export default routes diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php index dcf99b03..20f9c4bb 100644 --- a/src/resources/lang/en/ui.php +++ b/src/resources/lang/en/ui.php @@ -1,572 +1,572 @@ [ 'faq' => "FAQ", ], 'btn' => [ 'add' => "Add", 'accept' => "Accept", 'back' => "Back", 'cancel' => "Cancel", 'close' => "Close", 'continue' => "Continue", 'copy' => "Copy", 'delete' => "Delete", 'deny' => "Deny", 'download' => "Download", 'edit' => "Edit", 'file' => "Choose file...", 'moreinfo' => "More information", 'refresh' => "Refresh", 'reset' => "Reset", 'resend' => "Resend", 'resync' => "Resync", 'save' => "Save", 'search' => "Search", 'share' => "Share", 'signup' => "Sign Up", 'submit' => "Submit", 'subscribe' => "Subscribe", 'suspend' => "Suspend", 'tryagain' => "Try again", 'unsuspend' => "Unsuspend", 'verify' => "Verify", ], 'companion' => [ 'title' => "Companion Apps", 'companion' => "Companion App", 'name' => "Name", 'create' => "Pair new device", 'create-recovery-device' => "Prepare recovery code", 'description' => "Use the Companion App on your mobile phone as multi-factor authentication device.", 'download-description' => "You may download the Companion App for Android here: " . "Download", 'description-detailed' => "Here is how this works: " . "Pairing a device will automatically enable multi-factor autentication for all login attempts. " . "This includes not only the Cockpit, but also logins via Webmail, IMAP, SMPT, DAV and ActiveSync. " . "Any authentication attempt will result in a notification on your device, " . "that you can use to confirm if it was you, or deny otherwise. " . "Once confirmed, the same username + IP address combination will be whitelisted for 8 hours. " . "Unpair all your active devices to disable multi-factor authentication again.", 'description-warning' => "Warning: Loosing access to all your multi-factor authentication devices, " . "will permanently lock you out of your account with no course for recovery. " . "Always make sure you have a recovery QR-Code printed to pair a recovery device.", 'new' => "Pair new device", 'recovery' => "Prepare recovery device", 'paired' => "Paired devices", 'print' => "Print for backup", 'pairing-instructions' => "Pair your device using the following QR-Code.", 'recovery-device' => "Recovery Device", 'new-device' => "New Device", 'deviceid' => "Device ID", 'list-empty' => "There are currently no devices", 'delete' => "Delete/Unpair", 'delete-companion' => "Delete/Unpair", 'delete-text' => "You are about to delete this entry and unpair any paired companion app. " . "This cannot be undone, but you can pair the device again.", 'pairing-successful' => "Your companion app is paired and ready to be used " . "as a multi-factor authentication device.", ], 'dashboard' => [ 'beta' => "beta", 'distlists' => "Distribution lists", 'chat' => "Video chat", 'companion' => "Companion app", 'domains' => "Domains", 'files' => "Files", 'invitations' => "Invitations", + 'myaccount' => "My account", + 'policies' => "Policies", 'profile' => "Your profile", 'resources' => "Resources", - 'settings' => "Settings", 'shared-folders' => "Shared folders", 'users' => "User accounts", 'wallet' => "Wallet", 'webmail' => "Webmail", 'stats' => "Stats", ], 'distlist' => [ 'list-title' => "Distribution list | Distribution lists", 'create' => "Create list", 'delete' => "Delete list", 'email' => "Email", 'list-empty' => "There are no distribution lists in this account.", 'name' => "Name", 'new' => "New distribution list", 'recipients' => "Recipients", 'sender-policy' => "Sender Access List", 'sender-policy-text' => "With this list you can specify who can send mail to the distribution list." . " You can put a complete email address (jane@kolab.org), domain (kolab.org) or suffix (.org) that the sender email address is compared to." . " If the list is empty, mail from anyone is allowed.", ], 'domain' => [ 'delete' => "Delete domain", 'delete-domain' => "Delete {domain}", 'delete-text' => "Do you really want to delete this domain permanently?" . " This is only possible if there are no users, aliases or other objects in this domain." . " Please note that this action cannot be undone.", 'dns-verify' => "Domain DNS verification sample:", 'dns-config' => "Domain DNS configuration sample:", 'list-empty' => "There are no domains in this account.", 'namespace' => "Namespace", 'spf-whitelist' => "SPF Whitelist", 'spf-whitelist-text' => "The Sender Policy Framework allows a sender domain to disclose, through DNS, " . "which systems are allowed to send emails with an envelope sender address within said domain.", 'spf-whitelist-ex' => "Here you can specify a list of allowed servers, for example: .ess.barracuda.com.", 'verify' => "Domain verification", 'verify-intro' => "In order to confirm that you're the actual holder of the domain, we need to run a verification process before finally activating it for email delivery.", 'verify-dns' => "The domain must have one of the following entries in DNS:", 'verify-dns-txt' => "TXT entry with value:", 'verify-dns-cname' => "or CNAME entry:", 'verify-outro' => "When this is done press the button below to start the verification.", 'verify-sample' => "Here's a sample zone file for your domain:", 'config' => "Domain configuration", 'config-intro' => "In order to let {app} receive email traffic for your domain you need to adjust the DNS settings, more precisely the MX entries, accordingly.", 'config-sample' => "Edit your domain's zone file and replace existing MX entries with the following values:", 'config-hint' => "If you don't know how to set DNS entries for your domain, please contact the registration service where you registered the domain or your web hosting provider.", 'create' => "Create domain", 'new' => "New domain", ], 'error' => [ '400' => "Bad request", '401' => "Unauthorized", '403' => "Access denied", '404' => "Not found", '405' => "Method not allowed", '500' => "Internal server error", 'unknown' => "Unknown Error", 'server' => "Server Error", 'form' => "Form validation error", ], 'file' => [ 'create' => "Create file", 'delete' => "Delete file", 'list-empty' => "There are no files in this account.", 'mimetype' => "Mimetype", 'mtime' => "Modified", 'new' => "New file", 'search' => "File name", 'sharing' => "Sharing", 'sharing-links-text' => "You can share the file with other users by giving them read-only access " . "to the file via a unique link.", ], 'collection' => [ 'create' => "Create collection", 'new' => "New Collection", 'name' => "Name", ], 'form' => [ 'acl' => "Access rights", 'acl-full' => "All", 'acl-read-only' => "Read-only", 'acl-read-write' => "Read-write", 'amount' => "Amount", 'anyone' => "Anyone", 'code' => "Confirmation Code", 'config' => "Configuration", 'companion' => "Companion App", 'date' => "Date", 'description' => "Description", 'details' => "Details", 'disabled' => "disabled", 'domain' => "Domain", 'email' => "Email Address", 'emails' => "Email Addresses", 'enabled' => "enabled", 'firstname' => "First Name", 'general' => "General", 'geolocation' => "Your current location: {location}", 'lastname' => "Last Name", 'name' => "Name", 'months' => "months", 'none' => "none", 'norestrictions' => "No restrictions", 'or' => "or", 'password' => "Password", 'password-confirm' => "Confirm Password", + 'personal' => "Personal information", 'phone' => "Phone", 'selectcountries' => "Select countries", 'settings' => "Settings", 'shared-folder' => "Shared Folder", 'size' => "Size", 'status' => "Status", 'subscriptions' => "Subscriptions", 'surname' => "Surname", 'type' => "Type", 'unknown' => "unknown", 'user' => "User", 'primary-email' => "Primary Email", 'id' => "ID", 'created' => "Created", 'deleted' => "Deleted", ], 'invitation' => [ 'create' => "Create invite(s)", 'create-title' => "Invite for a signup", 'create-email' => "Enter an email address of the person you want to invite.", 'create-csv' => "To send multiple invitations at once, provide a CSV (comma separated) file, or alternatively a plain-text file, containing one email address per line.", 'list-empty' => "There are no invitations in the database.", 'title' => "Signup invitations", 'search' => "Email address or domain", 'send' => "Send invite(s)", 'status-completed' => "User signed up", 'status-failed' => "Sending failed", 'status-sent' => "Sent", 'status-new' => "Not sent yet", ], 'lang' => [ 'en' => "English", 'de' => "German", 'fr' => "French", 'it' => "Italian", ], 'login' => [ '2fa' => "Second factor code", '2fa_desc' => "Second factor code is optional for users with no 2-Factor Authentication setup.", 'forgot_password' => "Forgot password?", 'header' => "Please sign in", 'sign_in' => "Sign in", 'signing_in' => "Signing in...", 'webmail' => "Webmail" ], 'meet' => [ // Room options dialog 'options' => "Room options", 'password' => "Password", 'password-none' => "none", 'password-clear' => "Clear password", 'password-set' => "Set password", 'password-text' => "You can add a password to your meeting. Participants will have to provide the password before they are allowed to join the meeting.", 'lock' => "Locked room", 'lock-text' => "When the room is locked participants have to be approved by a moderator before they could join the meeting.", 'nomedia' => "Subscribers only", 'nomedia-text' => "Forces all participants to join as subscribers (with camera and microphone turned off)." . " Moderators will be able to promote them to publishers throughout the session.", // Room menu 'partcnt' => "Number of participants", 'menu-audio-mute' => "Mute audio", 'menu-audio-unmute' => "Unmute audio", 'menu-video-mute' => "Mute video", 'menu-video-unmute' => "Unmute video", 'menu-screen' => "Share screen", 'menu-hand-lower' => "Lower hand", 'menu-hand-raise' => "Raise hand", 'menu-channel' => "Interpreted language channel", 'menu-chat' => "Chat", 'menu-fullscreen' => "Full screen", 'menu-fullscreen-exit' => "Exit full screen", 'menu-leave' => "Leave session", // Room setup screen 'setup-title' => "Set up your session", 'mic' => "Microphone", 'cam' => "Camera", 'nick' => "Nickname", 'nick-placeholder' => "Your name", 'join' => "JOIN", 'joinnow' => "JOIN NOW", 'imaowner' => "I'm the owner", // Room 'qa' => "Q & A", 'leave-title' => "Room closed", 'leave-body' => "The session has been closed by the room owner.", 'media-title' => "Media setup", 'join-request' => "Join request", 'join-requested' => "{user} requested to join.", // Status messages 'status-init' => "Checking the room...", 'status-323' => "The room is closed. Please, wait for the owner to start the session.", 'status-324' => "The room is closed. It will be open for others after you join.", 'status-325' => "The room is ready. Please, provide a valid password.", 'status-326' => "The room is locked. Please, enter your name and try again.", 'status-327' => "Waiting for permission to join the room.", 'status-404' => "The room does not exist.", 'status-429' => "Too many requests. Please, wait.", 'status-500' => "Failed to connect to the room. Server error.", // Other menus 'media-setup' => "Media setup", 'perm' => "Permissions", 'perm-av' => "Audio & Video publishing", 'perm-mod' => "Moderation", 'lang-int' => "Language interpreter", 'menu-options' => "Options", ], 'menu' => [ 'cockpit' => "Cockpit", 'login' => "Login", 'logout' => "Logout", 'signup' => "Signup", 'toggle' => "Toggle navigation", ], 'msg' => [ 'initializing' => "Initializing...", 'loading' => "Loading...", 'loading-failed' => "Failed to load data.", 'notfound' => "Resource not found.", 'info' => "Information", 'error' => "Error", 'uploading' => "Uploading...", 'warning' => "Warning", 'success' => "Success", ], 'nav' => [ 'more' => "Load more", 'step' => "Step {i}/{n}", ], 'password' => [ 'link-invalid' => "The password reset code is expired or invalid.", 'reset' => "Password Reset", 'reset-step1' => "Enter your email address to reset your password.", 'reset-step1-hint' => "You may need to check your spam folder or unblock {email}.", 'reset-step2' => "We sent out a confirmation code to your external email address." . " Enter the code we sent you, or click the link in the message.", ], + 'policies' => [ + 'password-policy' => "Password Policy", + 'password-retention' => "Password Retention", + 'password-max-age' => "Require a password change every", + ], + 'resource' => [ 'create' => "Create resource", 'delete' => "Delete resource", 'invitation-policy' => "Invitation policy", 'invitation-policy-text' => "Event invitations for a resource are normally accepted automatically" . " if there is no conflicting event on the requested time slot. Invitation policy allows" . " for rejecting such requests or to require a manual acceptance from a specified user.", 'ipolicy-manual' => "Manual (tentative)", 'ipolicy-accept' => "Accept", 'ipolicy-reject' => "Reject", 'list-title' => "Resource | Resources", 'list-empty' => "There are no resources in this account.", 'new' => "New resource", ], 'room' => [ 'create' => "Create room", 'delete' => "Delete room", 'copy-location' => "Copy room location", 'description-hint' => "This is an optional short description for the room, so you can find it more easily on the list.", 'goto' => "Enter the room", 'list-empty' => "There are no conference rooms in this account.", 'list-empty-nocontroller' => "Do you need a room? Ask your account owner to create one and share it with you.", 'list-title' => "Voice & video conferencing rooms", 'moderators' => "Moderators", 'moderators-text' => "You can share your room with other users. They will become the room moderators with all moderator powers and ability to open the room without your presence.", 'new' => "New room", 'new-hint' => "We'll generate a unique name for the room that will then allow you to access the room.", 'title' => "Room: {name}", 'url' => "You can access the room at the URL below. Use this URL to invite people to join you. This room is only open when you (or another room moderator) is in attendance.", ], - 'settings' => [ - 'password-policy' => "Password Policy", - 'password-retention' => "Password Retention", - 'password-max-age' => "Require a password change every", - ], - 'shf' => [ 'aliases-none' => "This shared folder has no email aliases.", 'create' => "Create folder", 'delete' => "Delete folder", 'acl-text' => "Defines user permissions to access the shared folder.", 'list-title' => "Shared folder | Shared folders", 'list-empty' => "There are no shared folders in this account.", 'new' => "New shared folder", 'type-mail' => "Mail", 'type-event' => "Calendar", 'type-contact' => "Address Book", 'type-task' => "Tasks", 'type-note' => "Notes", 'type-file' => "Files", ], 'signup' => [ 'email' => "Existing Email Address", 'login' => "Login", 'title' => "Sign Up", 'step1' => "Sign up to start your free month.", 'step2' => "We sent out a confirmation code to your email address. Enter the code we sent you, or click the link in the message.", 'step3' => "Create your {app} identity (you can choose additional addresses later).", 'created' => "The account is about to be created!", 'token' => "Signup authorization token", 'voucher' => "Voucher Code", ], 'status' => [ 'prepare-account' => "We are preparing your account.", 'prepare-domain' => "We are preparing the domain.", 'prepare-distlist' => "We are preparing the distribution list.", 'prepare-resource' => "We are preparing the resource.", 'prepare-shared-folder' => "We are preparing the shared folder.", 'prepare-user' => "We are preparing the user account.", 'prepare-hint' => "Some features may be missing or readonly at the moment.", 'prepare-refresh' => "The process never ends? Press the \"Refresh\" button, please.", 'ready-account' => "Your account is almost ready.", 'ready-domain' => "The domain is almost ready.", 'ready-distlist' => "The distribution list is almost ready.", 'ready-resource' => "The resource is almost ready.", 'ready-shared-folder' => "The shared-folder is almost ready.", 'ready-user' => "The user account is almost ready.", 'verify' => "Verify your domain to finish the setup process.", 'verify-domain' => "Verify domain", 'degraded' => "Degraded", 'deleted' => "Deleted", 'restricted' => "Restricted", 'suspended' => "Suspended", 'notready' => "Not Ready", 'active' => "Active", ], 'support' => [ 'title' => "Contact Support", 'id' => "Customer number or email address you have with us", 'id-pl' => "e.g. 12345678 or the affected email address", 'id-hint' => "Leave blank if you are not a customer yet", 'name' => "Name", 'name-pl' => "how we should call you in our reply", 'email' => "Working email address", 'email-pl' => "make sure we can reach you at this address", 'summary' => "Issue Summary", 'summary-pl' => "one sentence that summarizes your issue", 'expl' => "Issue Explanation", ], 'user' => [ '2fa-hint1' => "This will remove 2-Factor Authentication entitlement as well as the user-configured factors.", '2fa-hint2' => "Please, make sure to confirm the user identity properly.", 'add-beta' => "Enable beta program", 'address' => "Address", 'aliases' => "Aliases", 'aliases-none' => "This user has no email aliases.", 'add-bonus' => "Add bonus", 'add-bonus-title' => "Add a bonus to the wallet", 'add-penalty' => "Add penalty", 'add-penalty-title' => "Add a penalty to the wallet", 'auto-payment' => "Auto-payment", 'auto-payment-text' => "Fill up by {amount} when under {balance} using {method}", 'country' => "Country", 'create' => "Create user", 'custno' => "Customer No.", 'degraded-warning' => "The account is degraded. Some features have been disabled.", 'degraded-hint' => "Please, make a payment.", 'delete' => "Delete user", - 'delete-account' => "Delete this account?", 'delete-email' => "Delete {email}", 'delete-text' => "Do you really want to delete this user permanently?" . " This will delete all account data and withdraw the permission to access the email account." . " Please note that this action cannot be undone.", 'discount' => "Discount", 'discount-hint' => "applied discount", 'discount-title' => "Account discount", 'distlists' => "Distribution lists", 'domains' => "Domains", 'ext-email' => "External Email", 'email-aliases' => "Email Aliases", 'finances' => "Finances", 'geolimit' => "Geo-lockin", 'geolimit-text' => "Defines a list of locations that are allowed for logon. You will not be able to login from a country that is not listed here.", 'greylisting' => "Greylisting", 'greylisting-text' => "Greylisting is a method of defending users against spam. Any incoming mail from an unrecognized sender " . "is temporarily rejected. The originating server should try again after a delay. " . "This time the email will be accepted. Spammers usually do not reattempt mail delivery.", 'imapproxy' => "IMAP proxy", 'imapproxy-text' => "Enables IMAP proxy that filters out non-mail groupware folders, so your IMAP clients do not see them.", 'list-title' => "User accounts", 'list-empty' => "There are no users in this account.", 'managed-by' => "Managed by", 'new' => "New user account", 'org' => "Organization", 'package' => "Package", 'pass-input' => "Enter password", 'pass-link' => "Set via link", 'pass-link-label' => "Link:", 'pass-link-hint' => "Press Submit to activate the link", 'passwordpolicy' => "Password Policy", 'price' => "Price", - 'profile-title' => "Your profile", 'profile-delete' => "Delete account", 'profile-delete-title' => "Delete this account?", 'profile-delete-text1' => "This will delete the account as well as all domains, users and aliases associated with this account.", 'profile-delete-warning' => "This operation is irreversible", 'profile-delete-text2' => "As you will not be able to recover anything after this point, please make sure that you have migrated all data before proceeding.", 'profile-delete-support' => "As we always strive to improve, we would like to ask for 2 minutes of your time. " . "The best tool for improvement is feedback from users, and we would like to ask " . "for a few words about your reasons for leaving our service. Please send your feedback to {email}.", 'profile-delete-contact' => "Also feel free to contact {app} Support with any questions or concerns that you may have in this context.", 'reset-2fa' => "Reset 2-Factor Auth", 'reset-2fa-title' => "2-Factor Authentication Reset", 'resources' => "Resources", 'title' => "User account", 'search' => "User email address or name", 'search-pl' => "User ID, email or domain", 'skureq' => "{sku} requires {list}.", 'subscription' => "Subscription", 'subscriptions-none' => "This user has no subscriptions.", 'users' => "Users", ], 'wallet' => [ 'add-credit' => "Add credit", 'auto-payment-cancel' => "Cancel auto-payment", 'auto-payment-change' => "Change auto-payment", 'auto-payment-failed' => "The setup of automatic payments failed. Restart the process to enable automatic top-ups.", 'auto-payment-hint' => "Here is how it works: Every time your account runs low, we will charge your preferred payment method for an amount you choose." . " You can cancel or change the auto-payment option at any time.", 'auto-payment-setup' => "Set up auto-payment", 'auto-payment-disabled' => "The configured auto-payment has been disabled. Top up your wallet or raise the auto-payment amount.", 'auto-payment-info' => "Auto-payment is set to fill up your account by {amount} every time your account balance gets under {balance}.", 'auto-payment-inprogress' => "The setup of the automatic payment is still in progress.", 'auto-payment-next' => "Next, you will be redirected to the checkout page, where you can provide your credit card details.", 'auto-payment-disabled-next' => "The auto-payment is disabled. Immediately after you submit new settings we'll enable it and attempt to top up your wallet.", 'auto-payment-update' => "Update auto-payment", 'banktransfer-hint' => "Please note that a bank transfer can take several days to complete.", 'coinbase-hint' => "Here is how it works: You specify the amount by which you want to top up your wallet in {wc}." . " We will then create a charge on Coinbase for the specified amount that you can pay using Bitcoin.", 'currency-conv' => "Here is how it works: You specify the amount by which you want to top up your wallet in {wc}." . " We will then convert this to {pc}, and on the next page you will be provided with the bank-details to transfer the amount in {pc}.", 'fill-up' => "Fill up by", 'history' => "History", 'locked-text' => "The account is locked until you set up auto-payment successfully.", 'month' => "month", 'noperm' => "Only account owners can access a wallet.", 'norefund' => "The money in your wallet is non-refundable.", 'payment-amount-hint' => "Choose the amount by which you want to top up your wallet.", 'payment-method' => "Method of payment: {method}", 'payment-warning' => "You will be charged for {price}.", 'pending-payments' => "Pending Payments", 'pending-payments-warning' => "You have payments that are still in progress. See the \"Pending Payments\" tab below.", 'pending-payments-none' => "There are no pending payments for this account.", 'receipts' => "Receipts", 'receipts-hint' => "Here you can download receipts (in PDF format) for payments in specified period. Select the period and press the Download button.", 'receipts-none' => "There are no receipts for payments in this account. Please, note that you can download receipts after the month ends.", 'title' => "Account balance", 'top-up' => "Top up your wallet", 'transactions' => "Transactions", 'transactions-none' => "There are no transactions for this account.", 'when-below' => "when account balance is below", ], ]; diff --git a/src/resources/lang/fr/ui.php b/src/resources/lang/fr/ui.php index 723127b3..a62f8743 100644 --- a/src/resources/lang/fr/ui.php +++ b/src/resources/lang/fr/ui.php @@ -1,479 +1,478 @@ [ 'faq' => "FAQ", ], 'btn' => [ 'add' => "Ajouter", 'accept' => "Accepter", 'back' => "Back", 'cancel' => "Annuler", 'close' => "Fermer", 'continue' => "Continuer", 'delete' => "Supprimer", 'deny' => "Refuser", 'download' => "Télécharger", 'edit' => "Modifier", 'file' => "Choisir le ficher...", 'moreinfo' => "Plus d'information", 'refresh' => "Actualiser", 'reset' => "Réinitialiser", 'resend' => "Envoyer à nouveau", 'save' => "Sauvegarder", 'search' => "Chercher", 'signup' => "S'inscrire", 'submit' => "Soumettre", 'suspend' => "Suspendre", 'unsuspend' => "Débloquer", 'verify' => "Vérifier", ], 'dashboard' => [ 'beta' => "bêta", 'distlists' => "Listes de distribution", 'chat' => "Chat Vidéo", 'domains' => "Domaines", 'invitations' => "Invitations", 'profile' => "Votre profil", 'resources' => "Ressources", 'users' => "D'utilisateurs", 'wallet' => "Portefeuille", 'webmail' => "Webmail", 'stats' => "Statistiques", ], 'distlist' => [ 'list-title' => "Liste de distribution | Listes de Distribution", 'create' => "Créer une liste", 'delete' => "Suprimmer une list", 'email' => "Courriel", 'list-empty' => "il n'y a pas de listes de distribution dans ce compte.", 'name' => "Nom", 'new' => "Nouvelle liste de distribution", 'recipients' => "Destinataires", 'sender-policy' => "Liste d'Accès d'Expéditeur", 'sender-policy-text' => "Cette liste vous permet de spécifier qui peut envoyer du courrier à la liste de distribution." . " Vous pouvez mettre une adresse e-mail complète (jane@kolab.org), un domaine (kolab.org) ou un suffixe (.org)" . " auquel l'adresse électronique de l'expéditeur est assimilée." . " Si la liste est vide, le courriels de quiconque est autorisé." ], 'domain' => [ 'dns-verify' => "Exemple de vérification du DNS d'un domaine:", 'dns-config' => "Exemple de configuration du DNS d'un domaine:", 'list-empty' => "Il y a pas de domaines dans ce compte.", 'namespace' => "Espace de noms", 'verify' => "Vérification du domaine", 'verify-intro' => "Afin de confirmer que vous êtes bien le titulaire du domaine, nous devons exécuter un processus de vérification avant de l'activer définitivement pour la livraison d'e-mails.", 'verify-dns' => "Le domaine doit avoir l'une des entrées suivantes dans le DNS:", 'verify-dns-txt' => "Entrée TXT avec valeur:", 'verify-dns-cname' => "ou entrée CNAME:", 'verify-outro' => "Lorsque cela est fait, appuyez sur le bouton ci-dessous pour lancer la vérification.", 'verify-sample' => "Voici un fichier de zone simple pour votre domaine:", 'config' => "Configuration du domaine", 'config-intro' => "Afin de permettre à {app} de recevoir le trafic de messagerie pour votre domaine, vous devez ajuster les paramètres DNS, plus précisément les entrées MX, en conséquence.", 'config-sample' => "Modifiez le fichier de zone de votre domaine et remplacez les entrées MX existantes par les valeurs suivantes:", 'config-hint' => "Si vous ne savez pas comment définir les entrées DNS pour votre domaine, veuillez contacter le service d'enregistrement auprès duquel vous avez enregistré le domaine ou votre fournisseur d'hébergement Web.", 'spf-whitelist' => "SPF Whitelist", 'spf-whitelist-text' => "Le Sender Policy Framework permet à un domaine expéditeur de dévoiler, par le biais de DNS," . " quels systèmes sont autorisés à envoyer des e-mails avec une adresse d'expéditeur d'enveloppe dans le domaine en question.", 'spf-whitelist-ex' => "Vous pouvez ici spécifier une liste de serveurs autorisés, par exemple: .ess.barracuda.com.", 'create' => "Créer domaine", 'new' => "Nouveau domaine", 'delete' => "Supprimer domaine", 'delete-domain' => "Supprimer {domain}", 'delete-text' => "Voulez-vous vraiment supprimer ce domaine de façon permanente?" . " Ceci n'est possible que s'il n'y a pas d'utilisateurs, d'alias ou d'autres objets dans ce domaine." . " Veuillez noter que cette action ne peut pas être inversée.", ], 'error' => [ '400' => "Mauvaide demande", '401' => "Non autorisé", '403' => "Accès refusé", '404' => "Pas trouvé", '405' => "Méthode non autorisée", '500' => "Erreur de serveur interne", 'unknown' => "Erreur inconnu", 'server' => "Erreur de serveur", 'form' => "Erreur de validation du formulaire", ], 'form' => [ 'acl' => "Droits d'accès", 'acl-full' => "Tout", 'acl-read-only' => "Lecture seulement", 'acl-read-write' => "Lecture-écriture", 'amount' => "Montant", 'anyone' => "Chacun", 'code' => "Le code de confirmation", 'config' => "Configuration", 'date' => "Date", 'description' => "Description", 'details' => "Détails", 'domain' => "Domaine", 'email' => "Adresse e-mail", 'firstname' => "Prénom", 'lastname' => "Nom de famille", 'none' => "aucun", 'or' => "ou", 'password' => "Mot de passe", 'password-confirm' => "Confirmer le mot de passe", 'phone' => "Téléphone", 'shared-folder' => "Dossier partagé", 'status' => "État", 'subscriptions' => "Subscriptions", 'surname' => "Nom de famille", 'type' => "Type", 'user' => "Utilisateur", 'primary-email' => "Email principal", 'id' => "ID", 'created' => "Créé", 'deleted' => "Supprimé", 'disabled' => "Désactivé", 'enabled' => "Activé", 'general' => "Général", 'settings' => "Paramètres", ], 'invitation' => [ 'create' => "Créez des invitation(s)", 'create-title' => "Invitation à une inscription", 'create-email' => "Saisissez l'adresse électronique de la personne que vous souhaitez inviter.", 'create-csv' => "Pour envoyer plusieurs invitations à la fois, fournissez un fichier CSV (séparé par des virgules) ou un fichier en texte brut, contenant une adresse e-mail par ligne.", 'list-empty' => "Il y a aucune invitation dans la mémoire de données.", 'title' => "Invitation d'inscription", 'search' => "Adresse E-mail ou domaine", 'send' => "Envoyer invitation(s)", 'status-completed' => "Utilisateur s'est inscrit", 'status-failed' => "L'envoi a échoué", 'status-sent' => "Envoyé", 'status-new' => "Pas encore envoyé", ], 'lang' => [ 'en' => "Anglais", 'de' => "Allemand", 'fr' => "Français", 'it' => "Italien", ], 'login' => [ '2fa' => "Code du 2ème facteur", '2fa_desc' => "Le code du 2ème facteur est facultatif pour les utilisateurs qui n'ont pas configuré l'authentification à deux facteurs.", 'forgot_password' => "Mot de passe oublié?", 'header' => "Veuillez vous connecter", 'sign_in' => "Se connecter", 'webmail' => "Webmail" ], 'meet' => [ 'welcome' => "Bienvenue dans notre programme bêta pour les conférences vocales et vidéo.", 'notice' => "Il s'agit d'un travail en évolution et d'autres fonctions seront ajoutées au fil du temps. Les fonctions actuelles sont les suivantes:", 'sharing' => "Partage d'écran", 'sharing-text' => "Partagez votre écran pour des présentations ou des exposés.", 'security' => "sécurité de chambre", 'security-text' => "Renforcez la sécurité de la salle en définissant un mot de passe que les participants devront connaître." . " avant de pouvoir entrer, ou verrouiller la porte afin que les participants doivent frapper, et un modérateur peut accepter ou refuser ces demandes.", 'qa-title' => "Lever la main (Q&A)", 'qa-text' => "Les membres du public silencieux peuvent lever la main pour animer une séance de questions-réponses avec les membres du panel.", 'moderation' => "Délégation des Modérateurs", 'moderation-text' => "Déléguer l'autorité du modérateur pour la séance, afin qu'un orateur ne soit pas inutilement" . " interrompu par l'arrivée des participants et d'autres tâches du modérateur.", 'eject' => "Éjecter les participants", 'eject-text' => "Éjectez les participants de la session afin de les obliger à se reconnecter ou de remédier aux violations des règles." . " Cliquez sur l'icône de l'utilisateur pour un renvoi effectif.", 'silent' => "Membres du Public en Silence", 'silent-text' => "Pour une séance de type webinaire, configurez la salle pour obliger tous les nouveaux participants à être des spectateurs silencieux.", 'interpreters' => "Canaux d'Audio Spécifiques de Langues", 'interpreters-text' => "Désignez un participant pour interpréter l'audio original dans une langue cible, pour les sessions avec des participants multilingues." . " L'interprète doit être capable de relayer l'audio original et de le remplacer.", 'beta-notice' => "Rappelez-vous qu'il s'agit d'une version bêta et pourrait entraîner des problèmes." . " Au cas où vous rencontreriez des problèmes, n'hésitez pas à nous en faire part en contactant le support.", // Room options dialog 'options' => "Options de salle", 'password' => "Mot de passe", 'password-none' => "aucun", 'password-clear' => "Effacer mot de passe", 'password-set' => "Définir le mot de passe", 'password-text' => "Vous pouvez ajouter un mot de passe à votre session. Les participants devront fournir le mot de passe avant d'être autorisés à rejoindre la session.", 'lock' => "Salle verrouillée", 'lock-text' => "Lorsque la salle est verrouillée, les participants doivent être approuvés par un modérateur avant de pouvoir rejoindre la réunion.", 'nomedia' => "Réservé aux abonnés", 'nomedia-text' => "Force tous les participants à se joindre en tant qu'abonnés (avec caméra et microphone désactivés)" . "Les modérateurs pourront les promouvoir en tant qu'éditeurs tout au long de la session.", // Room menu 'partcnt' => "Nombres de participants", 'menu-audio-mute' => "Désactiver le son", 'menu-audio-unmute' => "Activer le son", 'menu-video-mute' => "Désactiver la vidéo", 'menu-video-unmute' => "Activer la vidéo", 'menu-screen' => "Partager l'écran", 'menu-hand-lower' => "Baisser la main", 'menu-hand-raise' => "Lever la main", 'menu-channel' => "Canal de langue interprétée", 'menu-chat' => "Le Chat", 'menu-fullscreen' => "Plein écran", 'menu-fullscreen-exit' => "Sortir en plein écran", 'menu-leave' => "Quitter la session", // Room setup screen 'setup-title' => "Préparez votre session", 'mic' => "Microphone", 'cam' => "Caméra", 'nick' => "Surnom", 'nick-placeholder' => "Votre nom", 'join' => "JOINDRE", 'joinnow' => "JOINDRE MAINTENANT", 'imaowner' => "Je suis le propriétaire", // Room 'qa' => "Q & A", 'leave-title' => "Salle fermée", 'leave-body' => "La session a été fermée par le propriétaire de la salle.", 'media-title' => "Configuration des médias", 'join-request' => "Demande de rejoindre", 'join-requested' => "{user} demandé à rejoindre.", // Status messages 'status-init' => "Vérification de la salle...", 'status-323' => "La salle est fermée. Veuillez attendre le démarrage de la session par le propriétaire.", 'status-324' => "La salle est fermée. Elle sera ouverte aux autres participants après votre adhésion.", 'status-325' => "La salle est prête. Veuillez entrer un mot de passe valide.", 'status-326' => "La salle est fermée. Veuillez entrer votre nom et réessayer.", 'status-327' => "En attendant la permission de joindre la salle.", 'status-404' => "La salle n'existe pas.", 'status-429' => "Trop de demande. Veuillez, patienter.", 'status-500' => "La connexion à la salle a échoué. Erreur de serveur.", // Other menus 'media-setup' => "configuration des médias", 'perm' => "Permissions", 'perm-av' => "Publication d'audio et vidéo", 'perm-mod' => "Modération", 'lang-int' => "Interprète de langue", 'menu-options' => "Options", ], 'menu' => [ 'cockpit' => "Cockpit", 'login' => "Connecter", 'logout' => "Deconnecter", 'signup' => "S'inscrire", 'toggle' => "Basculer la navigation", ], 'msg' => [ 'initializing' => "Initialisation...", 'loading' => "Chargement...", 'loading-failed' => "Échec du chargement des données.", 'notfound' => "Resource introuvable.", 'info' => "Information", 'error' => "Erreur", 'warning' => "Avertissement", 'success' => "Succès", ], 'nav' => [ 'more' => "Charger plus", 'step' => "Étape {i}/{n}", ], 'password' => [ 'reset' => "Réinitialiser le mot de passe", 'reset-step1' => "Entrez votre adresse e-mail pour réinitialiser votre mot de passe.", 'reset-step1-hint' => "Veuillez vérifier votre dossier de spam ou débloquer {email}.", 'reset-step2' => "Nous avons envoyé un code de confirmation à votre adresse e-mail externe." . " Entrez le code que nous vous avons envoyé, ou cliquez sur le lien dans le message.", ], 'resource' => [ 'create' => "Créer une ressource", 'delete' => "Supprimer une ressource", 'invitation-policy' => "Procédure d'invitation", 'invitation-policy-text' => "Les invitations à des événements pour une ressource sont généralement acceptées automatiquement" . " si aucun événement n'est en conflit avec le temps demandé. La procédure d'invitation le permet" . " de rejeter ces demandes ou d'exiger une acceptation manuelle d'un utilisateur spécifique.", 'ipolicy-manual' => "Manuel (provisoire)", 'ipolicy-accept' => "Accepter", 'ipolicy-reject' => "Rejecter", 'list-title' => "Ressource | Ressources", 'list-empty' => "Il y a aucune ressource sur ce compte.", 'new' => "Nouvelle ressource", ], 'shf' => [ 'create' => "Créer un dossier", 'delete' => "Supprimer un dossier", 'acl-text' => "Permet de définir les droits d'accès des utilisateurs au dossier partagé..", 'list-title' => "Dossier partagé | Dossiers partagés", 'list-empty' => "Il y a aucun dossier partagé dans ce compte.", 'new' => "Nouvelle dossier", 'type-mail' => "Courriel", 'type-event' => "Calendrier", 'type-contact' => "Carnet d'Adresses", 'type-task' => "Tâches", 'type-note' => "Notes", 'type-file' => "Fichiers", ], 'signup' => [ 'email' => "Adresse e-mail actuelle", 'login' => "connecter", 'title' => "S'inscrire", 'step1' => "Inscrivez-vous pour commencer votre mois gratuit.", 'step2' => "Nous avons envoyé un code de confirmation à votre adresse e-mail. Entrez le code que nous vous avons envoyé, ou cliquez sur le lien dans le message.", 'step3' => "Créez votre identité {app} (vous pourrez choisir des adresses supplémentaires plus tard).", 'voucher' => "Coupon Code", ], 'status' => [ 'prepare-account' => "Votre compte est en cours de préparation.", 'prepare-domain' => "Le domain est en cours de préparation.", 'prepare-distlist' => "La liste de distribution est en cours de préparation.", 'prepare-shared-folder' => "Le dossier portagé est en cours de préparation.", 'prepare-user' => "Le compte d'utilisateur est en cours de préparation.", 'prepare-hint' => "Certaines fonctionnalités peuvent être manquantes ou en lecture seule pour le moment.", 'prepare-refresh' => "Le processus ne se termine jamais? Appuyez sur le bouton \"Refresh\", s'il vous plaît.", 'prepare-resource' => "Nous préparons la ressource.", 'ready-account' => "Votre compte est presque prêt.", 'ready-domain' => "Le domaine est presque prêt.", 'ready-distlist' => "La liste de distribution est presque prête.", 'ready-resource' => "La ressource est presque prête.", 'ready-shared-folder' => "Le dossier partagé est presque prêt.", 'ready-user' => "Le compte d'utilisateur est presque prêt.", 'verify' => "Veuillez vérifier votre domaine pour terminer le processus de configuration.", 'verify-domain' => "Vérifier domaine", 'degraded' => "Dégradé", 'deleted' => "Supprimé", 'suspended' => "Suspendu", 'notready' => "Pas Prêt", 'active' => "Actif", ], 'support' => [ 'title' => "Contacter Support", 'id' => "Numéro de client ou adresse é-mail que vous avez chez nous.", 'id-hint' => "Laissez vide si vous n'êtes pas encore client", 'name' => "Nom", 'name-pl' => "comment nous devons vous adresser dans notre réponse", 'email' => "adresse e-mail qui fonctionne", 'email-pl' => "assurez-vous que nous pouvons vous atteindre à cette adresse", 'summary' => "Résumé du problème", 'summary-pl' => "une phrase qui résume votre situation", 'expl' => "Analyse du problème", ], 'user' => [ '2fa-hint1' => "Cela éliminera le droit à l'authentification à 2-Facteurs ainsi que les éléments configurés par l'utilisateur.", '2fa-hint2' => "Veuillez vous assurer que l'identité de l'utilisateur est correctement confirmée.", 'add-beta' => "Activer le programme bêta", 'address' => "Adresse", 'aliases' => "Alias", 'aliases-none' => "Cet utilisateur n'aucune alias e-mail.", 'add-bonus' => "Ajouter un bonus", 'add-bonus-title' => "Ajouter un bonus au portefeuille", 'add-penalty' => "Ajouter une pénalité", 'add-penalty-title' => "Ajouter une pénalité au portefeuille", 'auto-payment' => "Auto-paiement", 'auto-payment-text' => "Recharger par {amount} quand le montant est inférieur à {balance} utilisant {method}", 'country' => "Pays", 'create' => "Créer un utilisateur", 'custno' => "No. de Client.", 'degraded-warning' => "Le compte est dégradé. Certaines fonctionnalités ont été désactivées.", 'degraded-hint' => "Veuillez effectuer un paiement.", 'delete' => "Supprimer Utilisateur", 'delete-email' => "Supprimer {email}", 'delete-text' => "Voulez-vous vraiment supprimer cet utilisateur de façon permanente?" . " Cela supprimera toutes les données du compte et retirera la permission d'accéder au compte d'e-email." . " Veuillez noter que cette action ne peut pas être révoquée.", 'discount' => "Rabais", 'discount-hint' => "rabais appliqué", 'discount-title' => "Rabais de compte", 'distlists' => "Listes de Distribution", 'domains' => "Domaines", 'email-aliases' => "Alias E-mail", 'ext-email' => "E-mail externe", 'finances' => "Finances", 'greylisting' => "Greylisting", 'greylisting-text' => "La greylisting est une méthode de défense des utilisateurs contre le spam." . " Tout e-mail entrant provenant d'un expéditeur non reconnu est temporairement rejeté." . " Le serveur d'origine doit réessayer après un délai cette fois-ci, le mail sera accepté." . " Les spammeurs ne réessayent généralement pas de remettre le mail.", 'list-title' => "Comptes d'utilisateur", 'list-empty' => "Il n'y a aucun utilisateur dans ce compte.", 'managed-by' => "Géré par", 'new' => "Nouveau compte d'utilisateur", 'org' => "Organisation", 'package' => "Paquet", 'price' => "Prix", - 'profile-title' => "Votre profile", 'profile-delete' => "Supprimer compte", 'profile-delete-title' => "Supprimer ce compte?", 'profile-delete-text1' => "Cela supprimera le compte ainsi que tous les domaines, utilisateurs et alias associés à ce compte.", 'profile-delete-warning' => "Cette opération est irrévocable", 'profile-delete-text2' => "Comme vous ne pourrez plus rien récupérer après ce point, assurez-vous d'avoir migré toutes les données avant de poursuivre.", 'profile-delete-support' => "Étant donné que nous nous attachons à toujours nous améliorer, nous aimerions vous demander 2 minutes de votre temps. " . "Le meilleur moyen de nous améliorer est le feedback des utilisateurs, et nous voudrions vous demander" . "quelques mots sur les raisons pour lesquelles vous avez quitté notre service. Veuillez envoyer vos commentaires au {email}.", 'profile-delete-contact' => "Par ailleurs, n'hésitez pas à contacter le support de {app} pour toute question ou souci que vous pourriez avoir dans ce contexte.", 'reset-2fa' => "Réinitialiser l'authentification à 2-Facteurs.", 'reset-2fa-title' => "Réinitialisation de l'Authentification à 2-Facteurs", 'resources' => "Ressources", 'title' => "Compte d'utilisateur", 'search' => "Adresse e-mail ou nom de l'utilisateur", 'search-pl' => "ID utilisateur, e-mail ou domamine", 'skureq' => "{sku} demande {list}.", 'subscription' => "Subscription", 'subscriptions-none' => "Cet utilisateur n'a pas de subscriptions.", 'users' => "Utilisateurs", ], 'wallet' => [ 'add-credit' => "Ajouter un crédit", 'auto-payment-cancel' => "Annuler l'auto-paiement", 'auto-payment-change' => "Changer l'auto-paiement", 'auto-payment-failed' => "La configuration des paiements automatiques a échoué. Redémarrer le processus pour activer les top-ups automatiques.", 'auto-payment-hint' => "Cela fonctionne de la manière suivante: Chaque fois que votre compte est épuisé, nous débiterons votre méthode de paiement préférée d'un montant que vous aurez défini." . " Vous pouvez annuler ou modifier l'option de paiement automatique à tout moment.", 'auto-payment-setup' => "configurer l'auto-paiement", 'auto-payment-disabled' => "L'auto-paiement configuré a été désactivé. Rechargez votre porte-monnaie ou augmentez le montant d'auto-paiement.", 'auto-payment-info' => "L'auto-paiement est set pour recharger votre compte par {amount} lorsque le solde de votre compte devient inférieur à {balance}.", 'auto-payment-inprogress' => "La configuration d'auto-paiement est toujours en cours.", 'auto-payment-next' => "Ensuite, vous serez redirigé vers la page de paiement, où vous pourrez fournir les coordonnées de votre carte de crédit.", 'auto-payment-disabled-next' => "L'auto-paiement est désactivé. Dès que vous aurez soumis de nouveaux paramètres, nous l'activerons et essaierons de recharger votre portefeuille.", 'auto-payment-update' => "Mise à jour de l'auto-paiement.", 'banktransfer-hint' => "Veuillez noter qu'un virement bancaire peut nécessiter plusieurs jours avant d'être effectué.", 'currency-conv' => "Le principe est le suivant: Vous spécifiez le montant dont vous voulez recharger votre portefeuille en {wc}." . " Nous convertirons ensuite ce montant en {pc}, et sur la page suivante, vous obtiendrez les coordonnées bancaires pour transférer le montant en {pc}.", 'fill-up' => "Recharger par", 'history' => "Histoire", 'month' => "mois", 'noperm' => "Seuls les propriétaires de compte peuvent accéder à un portefeuille.", 'payment-amount-hint' => "Choisissez le montant dont vous voulez recharger votre portefeuille.", 'payment-method' => "Mode de paiement: {method}", 'payment-warning' => "Vous serez facturé pour {price}.", 'pending-payments' => "Paiements en attente", 'pending-payments-warning' => "Vous avez des paiements qui sont encore en cours. Voir l'onglet \"Paiements en attente\" ci-dessous.", 'pending-payments-none' => "Il y a aucun paiement en attente pour ce compte.", 'receipts' => "Reçus", 'receipts-hint' => "Vous pouvez télécharger ici les reçus (au format PDF) pour les paiements de la période spécifiée. Sélectionnez la période et appuyez sur le bouton Télécharger.", 'receipts-none' => "Il y a aucun reçu pour les paiements de ce compte. Veuillez noter que vous pouvez télécharger les reçus après la fin du mois.", 'title' => "Solde du compte", 'top-up' => "Rechargez votre portefeuille", 'transactions' => "Transactions", 'transactions-none' => "Il y a aucun transaction pour ce compte.", 'when-below' => "lorsque le solde du compte est inférieur à", ], ]; diff --git a/src/resources/themes/app.scss b/src/resources/themes/app.scss index 6f89c396..fc2b481c 100644 --- a/src/resources/themes/app.scss +++ b/src/resources/themes/app.scss @@ -1,579 +1,580 @@ html, body, body > .outer-container { height: 100%; } #app { display: flex; flex-direction: column; min-height: 100%; overflow: hidden; & > nav { flex-shrink: 0; z-index: 12; } & > div.container { flex-grow: 1; margin-top: 2rem; margin-bottom: 2rem; } & > .filler { flex-grow: 1; } & > div.container + .filler { display: none; } } .error-page { position: absolute; top: 0; height: 100%; width: 100%; align-content: center; align-items: center; display: flex; flex-wrap: wrap; justify-content: center; color: #636b6f; z-index: 10; background: white; .code { text-align: right; border-right: 2px solid; font-size: 26px; padding: 0 15px; } .message { font-size: 18px; padding: 0 15px; } .hint { margin-top: 3em; text-align: center; width: 100%; } } .app-loader { background-color: $body-bg; height: 100%; width: 100%; position: absolute; top: 0; left: 0; display: flex; align-items: center; justify-content: center; z-index: 8; .spinner-border { width: 120px; height: 120px; border-width: 15px; color: #b2aa99; } &.small .spinner-border { width: 25px; height: 25px; border-width: 3px; } &.fadeOut { visibility: hidden; opacity: 0; transition: visibility 300ms linear, opacity 300ms linear; } } pre { margin: 1rem 0; padding: 1rem; background-color: $menu-bg-color; } .card-title { font-size: 1.2rem; font-weight: bold; } tfoot.table-fake-body { background-color: #f8f8f8; color: grey; text-align: center; td { vertical-align: middle; height: 8em; border: 0; } tbody:not(:empty) + & { display: none; } } table { th { white-space: nowrap; } td .btn-link { vertical-align: initial; } td.email, td.price, td.datetime, td.selection { width: 1%; white-space: nowrap; } td.buttons, th.price, td.price, th.size, td.size { width: 1%; text-align: right; white-space: nowrap; } &.form-list { margin: 0; td { border: 0; &:first-child { padding-left: 0; } &:last-child { padding-right: 0; } } button { line-height: 1; } } .btn-action { line-height: 1; padding: 0; } &.files { table-layout: fixed; td { white-space: nowrap; } td.name { overflow: hidden; text-overflow: ellipsis; } /* td.size, th.size { width: 80px; } td.mtime, th.mtime { width: 140px; @include media-breakpoint-down(sm) { display: none; } } */ td.buttons, th.buttons { width: 50px; } } } .table > :not(:first-child) { // Remove Bootstrap's 2px border border-width: 0; } .list-details { min-height: 1em; & > ul { margin: 0; padding-left: 1.2em; } } .plan-selector { .plan-header { display: flex; } .plan-ico { margin:auto; font-size: 3.8rem; color: $main-color; border: 3px solid $main-color; width: 6rem; height: 6rem; border-radius: 50%; } } .status-message { display: flex; align-items: center; justify-content: center; .app-loader { width: auto; position: initial; .spinner-border { color: $body-color; } } svg { font-size: 1.5em; } :first-child { margin-right: 0.4em; } } .form-separator { position: relative; margin: 1em 0; display: flex; justify-content: center; hr { border-color: #999; margin: 0; position: absolute; top: 0.75em; width: 100%; } span { background: #fff; padding: 0 1em; z-index: 1; } } .modal { .modal-dialog, .modal-content { max-height: calc(100vh - 3.5rem); } .modal-body { overflow: auto !important; } &.fullscreen { .modal-dialog { height: 100%; width: 100%; max-width: calc(100vw - 1rem); } .modal-content { height: 100%; max-height: 100% !important; } .modal-body { padding: 0; margin: 1em; overflow: hidden !important; } } } .credit-cards { img { width: 4em; height: 2.8em; padding: 0.4em; border: 1px solid lightgrey; border-radius: 0.4em; margin-right: 0.5em; } } #status-box { background-color: lighten($green, 35); .progress { background-color: #fff; height: 10px; } .progress-label { font-size: 0.9em; } .progress-bar { background-color: $green; } &.process-failed { background-color: lighten($orange, 30); .progress-bar { background-color: $red; } } } @keyframes blinker { 50% { opacity: 0; } } .blinker { animation: blinker 750ms step-start infinite; } #dashboard-nav { display: flex; flex-wrap: wrap; justify-content: center; & > a { padding: 1rem; text-align: center; white-space: nowrap; margin: 0.25rem; text-decoration: none; width: 150px; &.disabled { pointer-events: none; opacity: 0.6; } // Some icons are too big, scale them down &.link-companionapp, &.link-domains, + &.link-policies, &.link-resources, - &.link-settings, &.link-wallet, &.link-invitations { svg { transform: scale(0.8); } } &.link-distlists, &.link-files, + &.link-settings, &.link-shared-folders { svg { transform: scale(0.9); } } .badge { position: absolute; top: 0.5rem; right: 0.5rem; } } svg { width: 6rem; height: 6rem; margin: auto; } } #payment-method-selection { display: flex; flex-wrap: wrap; justify-content: center; & > a { padding: 1rem; text-align: center; white-space: nowrap; margin: 0.25rem; text-decoration: none; width: 150px; } svg { width: 6rem; height: 6rem; margin: auto; } .link-banktransfer svg { transform: scale(.8); } } #summary-summary { padding: 0.5rem; table { width: 100%; } tr { &.total { font-weight: bold; } &.vat-summary { font-size: small; } } td { padding: 0.25em; &.money { white-space: nowrap; text-align: right; } } } #logon-form { flex-basis: auto; // Bootstrap issue? See logon page with width < 992 } #logon-form-footer { a:not(:first-child) { margin-left: 2em; } } // Various improvements for mobile @include media-breakpoint-down(sm) { .card, .card-footer { border: 0; } .card-body { padding: 0.5rem 0; } .nav-tabs { flex-wrap: nowrap; .nav-link { white-space: nowrap; padding: 0.5rem 0.75rem; } } #app > div.container { margin-bottom: 1rem; margin-top: 1rem; max-width: 100%; } #header-menu-navbar { padding: 0; } #dashboard-nav > a { width: 135px; } .table-sm:not(.form-list) { tbody td { padding: 0.75rem 0.5rem; svg { vertical-align: -0.175em; } & > svg { font-size: 125%; margin-right: 0.25rem; } } } .table.transactions { thead { display: none; } tbody { tr { position: relative; display: flex; flex-wrap: wrap; } td { width: auto; border: 0; padding: 0.5rem; &.datetime { width: 50%; padding-left: 0; } &.description { order: 3; width: 100%; border-bottom: 1px solid $border-color; color: $secondary; padding: 0 1.5em 0.5rem 0; margin-top: -0.25em; } &.selection { position: absolute; right: 0; border: 0; top: 1.7em; padding-right: 0; } &.price { width: 50%; padding-right: 0; } &.email { display: none; } } } } } @include media-breakpoint-down(sm) { .tab-pane > .card-body { padding: 0.5rem; } } diff --git a/src/resources/vue/Dashboard.vue b/src/resources/vue/Dashboard.vue index 61e61b97..9aec8651 100644 --- a/src/resources/vue/Dashboard.vue +++ b/src/resources/vue/Dashboard.vue @@ -1,105 +1,106 @@ diff --git a/src/resources/vue/Settings.vue b/src/resources/vue/Policies.vue similarity index 94% rename from src/resources/vue/Settings.vue rename to src/resources/vue/Policies.vue index b3ef3a7c..b570a53a 100644 --- a/src/resources/vue/Settings.vue +++ b/src/resources/vue/Policies.vue @@ -1,119 +1,119 @@ diff --git a/src/resources/vue/User/Info.vue b/src/resources/vue/User/Info.vue index bb5c8274..5a7083ce 100644 --- a/src/resources/vue/User/Info.vue +++ b/src/resources/vue/User/Info.vue @@ -1,323 +1,438 @@ diff --git a/src/resources/vue/User/Profile.vue b/src/resources/vue/User/Profile.vue deleted file mode 100644 index ce985ce2..00000000 --- a/src/resources/vue/User/Profile.vue +++ /dev/null @@ -1,115 +0,0 @@ - - - diff --git a/src/resources/vue/User/ProfileDelete.vue b/src/resources/vue/User/ProfileDelete.vue deleted file mode 100644 index 835e61d4..00000000 --- a/src/resources/vue/User/ProfileDelete.vue +++ /dev/null @@ -1,48 +0,0 @@ - - - diff --git a/src/tests/Browser/DomainTest.php b/src/tests/Browser/DomainTest.php index f6c032b1..900aa59a 100644 --- a/src/tests/Browser/DomainTest.php +++ b/src/tests/Browser/DomainTest.php @@ -1,357 +1,356 @@ deleteTestDomain('testdomain.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestDomain('testdomain.com'); parent::tearDown(); } /** * Test domain info page (unauthenticated) */ public function testDomainInfoUnauth(): void { // Test that the page requires authentication $this->browse(function ($browser) { $browser->visit('/domain/123')->on(new Home()); }); } /** * Test domains list page (unauthenticated) */ public function testDomainListUnauth(): void { // Test that the page requires authentication $this->browse(function ($browser) { $browser->visit('/domains')->on(new Home()); }); } /** * Test domain info page (non-existing domain id) * @group skipci */ public function testDomainInfo404(): void { $this->browse(function ($browser) { // FIXME: I couldn't make loginAs() method working // Note: Here we're also testing that unauthenticated request // is passed to logon form and then "redirected" to the requested page $browser->visit('/domain/123') ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123') ->assertErrorPage(404); }); } /** * Test domain info page (existing domain) * * @depends testDomainInfo404 * @group skipci */ public function testDomainInfo(): void { $this->browse(function ($browser) { // Unconfirmed domain $domain = Domain::where('namespace', 'kolab.org')->first(); if ($domain->isConfirmed()) { $domain->status ^= Domain::STATUS_CONFIRMED; $domain->save(); } $domain->setSetting('spf_whitelist', \json_encode(['.test.com'])); $browser->visit('/domain/' . $domain->id) ->on(new DomainInfo()) ->assertSeeIn('.card-title', 'Domain') ->whenAvailable('@general', function ($browser) use ($domain) { $browser->assertSeeIn('form div:nth-child(1) label', 'Status') ->assertSeeIn('form div:nth-child(1) #status.text-danger', 'Not Ready') ->assertSeeIn('form div:nth-child(2) label', 'Name') ->assertValue('form div:nth-child(2) input:disabled', $domain->namespace) ->assertSeeIn('form div:nth-child(3) label', 'Subscriptions'); }) ->whenAvailable('@general form div:nth-child(3) table', function ($browser) { $browser->assertElementsCount('tbody tr', 1) ->assertVisible('tbody tr td.selection input:checked:disabled') ->assertSeeIn('tbody tr td.name', 'External Domain') ->assertSeeIn('tbody tr td.price', '0,00 CHF/month') ->assertTip( 'tbody tr td.buttons button', 'Host a domain that is externally registered' ); }) ->whenAvailable('@verify', function ($browser) use ($domain) { $browser->assertSeeIn('pre', $domain->namespace) ->assertSeeIn('pre', $domain->hash()) ->click('button') ->assertToast(Toast::TYPE_SUCCESS, 'Domain verified successfully.'); // TODO: Test scenario when a domain confirmation failed }) ->whenAvailable('@config', function ($browser) use ($domain) { $browser->assertSeeIn('pre', $domain->namespace); }) ->assertMissing('@general button[type=submit]') ->assertMissing('@verify'); // Check that confirmed domain page contains only the config box $browser->visit('/domain/' . $domain->id) ->on(new DomainInfo()) ->assertMissing('@verify') ->assertPresent('@config'); }); } /** * Test domain settings * @group skipci */ public function testDomainSettings(): void { $this->browse(function ($browser) { $domain = Domain::where('namespace', 'kolab.org')->first(); $domain->setSetting('spf_whitelist', \json_encode(['.test.com'])); $browser->visit('/domain/' . $domain->id) ->on(new DomainInfo()) ->assertElementsCount('@nav a', 2) ->assertSeeIn('@nav #tab-general', 'General') ->assertSeeIn('@nav #tab-settings', 'Settings') ->click('@nav #tab-settings') ->with('#settings form', function (Browser $browser) { // Test whitelist widget $widget = new ListInput('#spf_whitelist'); $browser->assertSeeIn('div.row:nth-child(1) label', 'SPF Whitelist') ->assertVisible('div.row:nth-child(1) .list-input') ->with($widget, function (Browser $browser) { $browser->assertListInputValue(['.test.com']) ->assertValue('@input', '') ->addListEntry('invalid domain'); }) ->click('button[type=submit]') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->with($widget, function (Browser $browser) { $err = 'The entry format is invalid. Expected a domain name starting with a dot.'; $browser->assertFormError(2, $err, false) ->removeListEntry(2) ->removeListEntry(1) ->addListEntry('.new.domain.tld'); }) ->click('button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'Domain settings updated successfully.'); }); }); } /** * Test domains list page * * @depends testDomainListUnauth * @group skipci */ public function testDomainList(): void { $this->browse(function ($browser) { // Login the user $browser->visit('/login') ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) // On dashboard click the "Domains" link ->on(new Dashboard()) ->assertSeeIn('@links a.link-domains', 'Domains') ->click('@links a.link-domains') // On Domains List page click the domain entry ->on(new DomainList()) ->waitFor('@table tbody tr') ->assertVisible('@table tbody tr:first-child td:first-child svg.fa-globe.text-success') ->assertText('@table tbody tr:first-child td:first-child svg title', 'Active') ->assertSeeIn('@table tbody tr:first-child td:first-child', 'kolab.org') ->assertMissing('@table tfoot') ->click('@table tbody tr:first-child td:first-child a') // On Domain Info page verify that's the clicked domain ->on(new DomainInfo()) ->whenAvailable('@config', function ($browser) { $browser->assertSeeIn('pre', 'kolab.org'); }); }); // TODO: Test domains list acting as Ned (John's "delegatee") } /** * Test domains list page (user with no domains) */ public function testDomainListEmpty(): void { $this->browse(function ($browser) { // Login the user $browser->visit('/login') ->on(new Home()) ->submitLogon('jack@kolab.org', 'simple123', true) ->on(new Dashboard()) - ->assertVisible('@links a.link-profile') + ->assertVisible('@links a.link-settings') ->assertMissing('@links a.link-domains') ->assertMissing('@links a.link-users') ->assertMissing('@links a.link-wallet'); /* // On dashboard click the "Domains" link ->assertSeeIn('@links a.link-domains', 'Domains') ->click('@links a.link-domains') // On Domains List page click the domain entry ->on(new DomainList()) ->assertMissing('@table tbody') ->assertSeeIn('tfoot td', 'There are no domains in this account.'); */ }); } /** * Test domain creation page * @group skipci */ public function testDomainCreate(): void { $this->browse(function ($browser) { $browser->visit('/login') ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->visit('/domains') ->on(new DomainList()) ->assertSeeIn('.card-title button.btn-success', 'Create domain') ->click('.card-title button.btn-success') ->on(new DomainInfo()) ->assertSeeIn('.card-title', 'New domain') ->assertElementsCount('@nav li', 1) ->assertSeeIn('@nav li:first-child', 'General') ->whenAvailable('@general', function ($browser) { $browser->assertSeeIn('form div:nth-child(1) label', 'Name') ->assertValue('form div:nth-child(1) input:not(:disabled)', '') ->assertFocused('form div:nth-child(1) input') ->assertSeeIn('form div:nth-child(2) label', 'Package') ->assertMissing('form div:nth-child(3)'); }) ->whenAvailable('@general form div:nth-child(2) table', function ($browser) { $browser->assertElementsCount('tbody tr', 1) ->assertVisible('tbody tr td.selection input:checked[readonly]') ->assertSeeIn('tbody tr td.name', 'Domain Hosting') ->assertSeeIn('tbody tr td.price', '0,00 CHF/month') ->assertTip( 'tbody tr td.buttons button', 'Use your own, existing domain.' ); }) ->assertSeeIn('@general button.btn-primary[type=submit]', 'Submit') ->assertMissing('@config') ->assertMissing('@verify') ->assertMissing('@settings') ->assertMissing('@status') // Test error handling ->click('button[type=submit]') ->waitFor('#namespace + .invalid-feedback') ->assertSeeIn('#namespace + .invalid-feedback', 'The namespace field is required.') ->assertFocused('#namespace') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->type('@general form div:nth-child(1) input', 'testdomain..com') ->click('button[type=submit]') ->waitFor('#namespace + .invalid-feedback') ->assertSeeIn('#namespace + .invalid-feedback', 'The specified domain is invalid.') ->assertFocused('#namespace') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') // Test success ->type('@general form div:nth-child(1) input', 'testdomain.com') ->click('button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'Domain created successfully.') ->on(new DomainList()) ->assertSeeIn('@table tr:nth-child(2) a', 'testdomain.com'); }); } /** * Test domain deletion * @group skipci */ public function testDomainDelete(): void { // Create the domain to delete $john = $this->getTestUser('john@kolab.org'); $domain = $this->getTestDomain('testdomain.com', ['type' => Domain::TYPE_EXTERNAL]); $packageDomain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $domain->assignPackage($packageDomain, $john); $this->browse(function ($browser) { $browser->visit('/login') ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123') ->visit('/domains') ->on(new DomainList()) ->assertElementsCount('@table tbody tr', 2) ->assertSeeIn('@table tr:nth-child(2) a', 'testdomain.com') ->click('@table tbody tr:nth-child(2) a') ->on(new DomainInfo()) ->waitFor('button.button-delete') ->assertSeeIn('button.button-delete', 'Delete domain') ->click('button.button-delete') ->with(new Dialog('#delete-warning'), function ($browser) { $browser->assertSeeIn('@title', 'Delete testdomain.com') ->assertFocused('@button-cancel') ->assertSeeIn('@button-cancel', 'Cancel') ->assertSeeIn('@button-action', 'Delete') ->click('@button-cancel'); }) ->waitUntilMissing('#delete-warning') ->click('button.button-delete') ->with(new Dialog('#delete-warning'), function (Browser $browser) { $browser->click('@button-action'); }) ->waitUntilMissing('#delete-warning') ->assertToast(Toast::TYPE_SUCCESS, 'Domain deleted successfully.') ->on(new DomainList()) ->assertElementsCount('@table tbody tr', 1); // Test error handling on deleting a non-empty domain $err = 'Unable to delete a domain with assigned users or other objects.'; $browser->click('@table tbody tr:nth-child(1) a') ->on(new DomainInfo()) ->waitFor('button.button-delete') ->click('button.button-delete') ->with(new Dialog('#delete-warning'), function ($browser) { $browser->click('@button-action'); }) ->assertToast(Toast::TYPE_ERROR, $err); }); } } diff --git a/src/tests/Browser/LogonTest.php b/src/tests/Browser/LogonTest.php index 85581558..f727c043 100644 --- a/src/tests/Browser/LogonTest.php +++ b/src/tests/Browser/LogonTest.php @@ -1,292 +1,292 @@ browse(function (Browser $browser) { $browser->visit(new Home()) ->within(new Menu(), function ($browser) { $browser->assertMenuItems(['signup', 'support', 'login', 'lang']) ->assertSeeIn('#footer-copyright', \config('app.company.copyright')) ->assertSeeIn('#footer-copyright', date('Y')); }); if ($browser->isDesktop()) { $browser->within(new Menu('footer'), function ($browser) { $browser->assertMenuItems(['signup', 'support', 'login']); }); } else { $browser->assertMissing('#footer-menu .navbar-nav'); } $browser->assertSeeLink('Forgot password?') ->assertSeeLink('Webmail'); }); } /** * Test language menu, and language change */ public function testLocales(): void { $this->browse(function (Browser $browser) { if (!$browser->isDesktop()) { $this->markTestIncomplete(); } $browser->visit(new Home()) // ->plainCookie('language', '') ->within(new Menu(), function ($browser) { $browser->assertSeeIn('@lang', 'EN') ->click('@lang'); }) // Switch English -> German ->whenAvailable('nav .dropdown-menu', function (Browser $browser) { $browser->assertElementsCount('a', 3) ->assertSeeIn('a:nth-child(1)', 'EN - English') ->assertSeeIn('a:nth-child(2)', 'DE - German') ->assertSeeIn('a:nth-child(3)', 'FR - French') ->click('a:nth-child(2)'); }) ->waitUntilMissing('nav .dropdown-menu') ->within(new Menu(), function ($browser) { $browser->assertSeeIn('@lang', 'DE'); }) ->waitForTextIn('#header-menu .link-login', 'EINLOGGEN') ->assertSeeIn('#footer-menu .link-login', 'Einloggen') ->assertSeeIn('@logon-button', 'Anmelden') // refresh the page to see if it uses the lang previously set ->refresh() ->waitForTextIn('#header-menu .link-login', 'EINLOGGEN') ->assertSeeIn('#footer-menu .link-login', 'Einloggen') ->assertSeeIn('@logon-button', 'Anmelden') ->within(new Menu(), function ($browser) { $browser->assertSeeIn('@lang', 'DE') ->click('@lang'); }) // Switch German -> English ->whenAvailable('nav .dropdown-menu', function (Browser $browser) { $browser->assertSeeIn('a:nth-child(1)', 'Englisch') ->click('a:nth-child(1)'); }) ->waitUntilMissing('nav .dropdown-menu') ->within(new Menu(), function ($browser) { $browser->assertSeeIn('@lang', 'EN'); }) ->waitForTextIn('#header-menu .link-login', 'LOGIN'); }); } /** * Test redirect to /login if user is unauthenticated */ public function testRequiredAuth(): void { $this->browse(function (Browser $browser) { $browser->visit('/dashboard'); // Checks if we're really on the login page $browser->waitForLocation('/login') ->on(new Home()); }); } /** * Logon with wrong password/user test */ public function testLogonWrongCredentials(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'wrong'); // Error message $browser->assertToast(Toast::TYPE_ERROR, 'Invalid username or password.'); // Checks if we're still on the logon page $browser->on(new Home()); }); } /** * Successful logon test */ public function testLogonSuccessful(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) // Checks if we're really on Dashboard page ->on(new Dashboard()) - ->assertVisible('@links a.link-profile') + ->assertVisible('@links a.link-settings') ->assertVisible('@links a.link-domains') ->assertVisible('@links a.link-users') ->assertVisible('@links a.link-wallet') ->assertVisible('@links a.link-webmail') ->within(new Menu(), function ($browser) { $browser->assertMenuItems(['support', 'dashboard', 'logout', 'lang']); }); if ($browser->isDesktop()) { $browser->within(new Menu('footer'), function ($browser) { $browser->assertMenuItems(['support', 'dashboard', 'logout']); }); } else { $browser->assertMissing('#footer-menu .navbar-nav'); } $browser->assertUser('john@kolab.org'); // Assert no "Account status" for this account $browser->assertMissing('@status'); // Goto /domains and assert that the link on logo element // leads to the dashboard $browser->visit('/domains') ->waitForText('Domains') ->click('a.navbar-brand') ->on(new Dashboard()); // Test that visiting '/' with logged in user does not open logon form // but "redirects" to the dashboard $browser->visit('/') ->waitForLocation('/dashboard') ->on(new Dashboard()); }); } /** * Logout test * * @depends testLogonSuccessful */ public function testLogout(): void { $this->browse(function (Browser $browser) { $browser->on(new Dashboard()); // Click the Logout button $browser->within(new Menu(), function ($browser) { $browser->clickMenuItem('logout'); }); // We expect the logon page $browser->waitForLocation('/login') ->on(new Home()); // with default menu $browser->within(new Menu(), function ($browser) { $browser->assertMenuItems(['signup', 'support', 'login', 'lang']); }); // Success toast message $browser->assertToast(Toast::TYPE_SUCCESS, 'Successfully logged out'); }); } /** * Logout by URL test */ public function testLogoutByURL(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'simple123', true); // Checks if we're really on Dashboard page $browser->on(new Dashboard()); // Use /logout url, and expect the logon page $browser->visit('/logout') ->waitForLocation('/login') ->on(new Home()); // with default menu $browser->within(new Menu(), function ($browser) { $browser->assertMenuItems(['signup', 'support', 'login', 'lang']); }); // Success toast message $browser->assertToast(Toast::TYPE_SUCCESS, 'Successfully logged out'); }); } /** * Test 2-Factor Authentication * * @depends testLogoutByURL */ public function test2FA(): void { $this->browse(function (Browser $browser) { // Test missing 2fa code $browser->on(new Home()) ->type('@email-input', 'ned@kolab.org') ->type('@password-input', 'simple123') ->press('form button') ->waitFor('@second-factor-input.is-invalid + .invalid-feedback') ->assertSeeIn( '@second-factor-input.is-invalid + .invalid-feedback', 'Second factor code is required.' ) ->assertFocused('@second-factor-input') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); // Test invalid code $browser->type('@second-factor-input', '123456') ->press('form button') ->waitUntilMissing('@second-factor-input.is-invalid') ->waitFor('@second-factor-input.is-invalid + .invalid-feedback') ->assertSeeIn( '@second-factor-input.is-invalid + .invalid-feedback', 'Second factor code is invalid.' ) ->assertFocused('@second-factor-input') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); $code = \App\Auth\SecondFactor::code('ned@kolab.org'); // Test valid (TOTP) code $browser->type('@second-factor-input', $code) ->press('form button') ->waitUntilMissing('@second-factor-input.is-invalid') ->waitForLocation('/dashboard') ->on(new Dashboard()); }); } /** * Test redirect to the requested page after logon * * @depends test2FA */ public function testAfterLogonRedirect(): void { $this->browse(function (Browser $browser) { - // User is logged in - $browser->visit(new UserProfile()); - - // Test redirect if the token is invalid - $browser->script("localStorage.setItem('token', '123')"); - $browser->refresh() + // User is logged in, visit the My account page + $browser->visit('/settings') + // invalidate the session token + ->execScript("localStorage.setItem('token', '123')") + // refresh the page + ->refresh() ->on(new Home()) + // log in the user ->submitLogon('john@kolab.org', 'simple123', false) - ->waitForLocation('/profile'); + // wait for a "redirect" to the My account page + ->waitForLocation('/settings'); }); } } diff --git a/src/tests/Browser/Pages/Settings.php b/src/tests/Browser/Pages/Policies.php similarity index 87% rename from src/tests/Browser/Pages/Settings.php rename to src/tests/Browser/Pages/Policies.php index 42e208b7..97a87dbe 100644 --- a/src/tests/Browser/Pages/Settings.php +++ b/src/tests/Browser/Pages/Policies.php @@ -1,44 +1,44 @@ waitFor('@form') ->waitUntilMissing('.app-loader'); } /** * Get the element shortcuts for the page. * * @return array */ public function elements(): array { return [ '@app' => '#app', - '@form' => '#settings form', + '@form' => '#policies form', ]; } } diff --git a/src/tests/Browser/Pages/UserInfo.php b/src/tests/Browser/Pages/UserInfo.php index ef29b276..c2a4b425 100644 --- a/src/tests/Browser/Pages/UserInfo.php +++ b/src/tests/Browser/Pages/UserInfo.php @@ -1,50 +1,50 @@ waitFor('@form') + $browser->waitFor('@general form') ->waitUntilMissing('.app-loader'); } /** * Get the element shortcuts for the page. * * @return array */ public function elements(): array { return [ '@app' => '#app', - '@form' => '#user-info form', '@nav' => 'ul.nav-tabs', '@packages' => '#user-packages', '@settings' => '#settings', '@general' => '#general', + '@personal' => '#personal', '@skus' => '#user-skus', '@status' => '#status-box', ]; } } diff --git a/src/tests/Browser/Pages/UserProfile.php b/src/tests/Browser/Pages/UserProfile.php deleted file mode 100644 index 9c01833d..00000000 --- a/src/tests/Browser/Pages/UserProfile.php +++ /dev/null @@ -1,45 +0,0 @@ -assertPathIs($this->url()) - ->waitUntilMissing('@app .app-loader') - ->assertSeeIn('#user-profile .card-title', 'Your profile'); - } - - /** - * Get the element shortcuts for the page. - * - * @return array - */ - public function elements(): array - { - return [ - '@app' => '#app', - '@form' => '#user-profile form', - ]; - } -} diff --git a/src/tests/Browser/PasswordResetTest.php b/src/tests/Browser/PasswordResetTest.php index 4bee4f98..06a39941 100644 --- a/src/tests/Browser/PasswordResetTest.php +++ b/src/tests/Browser/PasswordResetTest.php @@ -1,331 +1,330 @@ deleteTestUser('passwordresettestdusk@' . \config('app.domain')); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('passwordresettestdusk@' . \config('app.domain')); parent::tearDown(); } /** * Test the link from logon to password-reset page */ public function testLinkOnLogon(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->assertSeeLink('Forgot password?') ->clickLink('Forgot password?') ->on(new PasswordReset()) ->assertVisible('@step1'); }); } /** * Test 1st step of password-reset */ public function testStep1(): void { $user = $this->getTestUser('passwordresettestdusk@' . \config('app.domain')); $user->setSetting('external_email', 'external@domain.tld'); $this->browse(function (Browser $browser) { $browser->visit(new PasswordReset()); $browser->assertVisible('@step1'); // Here we expect email input and submit button $browser->with('@step1', function ($step) { $step->assertVisible('#reset_email'); $step->assertFocused('#reset_email'); $step->assertVisible('[type=submit]'); }); // Submit empty form $browser->with('@step1', function ($step) { $step->click('[type=submit]'); $step->assertFocused('#reset_email'); }); // Submit invalid email // We expect email input to have is-invalid class added, with .invalid-feedback element $browser->with('@step1', function ($step) use ($browser) { $step->type('#reset_email', '@test'); $step->click('[type=submit]'); $step->waitFor('#reset_email.is-invalid'); $step->waitFor('#reset_email + .invalid-feedback'); $browser->waitFor('.toast-error'); $browser->click('.toast-error'); // remove the toast }); // Submit valid data $browser->with('@step1', function ($step) { $step->type('#reset_email', 'passwordresettestdusk@' . \config('app.domain')); $step->click('[type=submit]'); $step->assertMissing('#reset_email.is-invalid'); $step->assertMissing('#reset_email + .invalid-feedback'); }); $browser->waitUntilMissing('@step2 #reset_code[value=""]'); $browser->waitFor('@step2'); $browser->assertMissing('@step1'); }); } /** * Test 2nd Step of the password reset process * * @depends testStep1 */ public function testStep2(): void { $user = $this->getTestUser('passwordresettestdusk@' . \config('app.domain')); $user->setSetting('external_email', 'external@domain.tld'); $this->browse(function (Browser $browser) { $browser->assertVisible('@step2'); // Here we expect one text input, Back and Continue buttons $browser->with('@step2', function ($step) { $step->assertVisible('#reset_short_code'); $step->assertFocused('#reset_short_code'); $step->assertVisible('[type=button]'); $step->assertVisible('[type=submit]'); }); // Test Back button functionality $browser->click('@step2 [type=button]'); $browser->waitFor('@step1'); $browser->assertFocused('@step1 #reset_email'); $browser->assertMissing('@step2'); // Submit valid Step 1 data (again) $browser->with('@step1', function ($step) { $step->type('#reset_email', 'passwordresettestdusk@' . \config('app.domain')); $step->click('[type=submit]'); }); $browser->waitFor('@step2'); $browser->assertMissing('@step1'); // Submit invalid code // We expect code input to have is-invalid class added, with .invalid-feedback element $browser->with('@step2', function ($step) use ($browser) { $step->type('#reset_short_code', 'XXXXX'); $step->click('[type=submit]'); $browser->waitFor('.toast-error'); $step->waitFor('#reset_short_code.is-invalid') ->assertVisible('#reset_short_code.is-invalid') ->assertVisible('#reset_short_code + .invalid-feedback') ->assertFocused('#reset_short_code'); $browser->click('.toast-error'); // remove the toast }); // Submit valid code // We expect error state on code input to be removed, and Step 3 form visible $browser->with('@step2', function ($step) { // Get the code and short_code from database // FIXME: Find a nice way to read javascript data without using hidden inputs $code = $step->value('#reset_code'); $this->assertNotEmpty($code); $code = VerificationCode::find($code); $step->type('#reset_short_code', $code->short_code); $step->click('[type=submit]'); $step->assertMissing('#reset_short_code.is-invalid'); $step->assertMissing('#reset_short_code + .invalid-feedback'); }); $browser->waitFor('@step3'); $browser->assertMissing('@step2'); }); } /** * Test 3rd Step of the password reset process * * @depends testStep2 */ public function testStep3(): void { $user = $this->getTestUser('passwordresettestdusk@' . \config('app.domain')); $user->setSetting('external_email', 'external@domain.tld'); $user->setSetting('password_policy', 'upper,digit'); $this->browse(function (Browser $browser) { $browser->assertVisible('@step3') ->clearToasts(); // Here we expect 2 text inputs, Back and Continue buttons $browser->with('@step3', function (Browser $step) { $step->assertVisible('#reset_password') ->assertVisible('#reset_password_confirmation') ->assertVisible('[type=button]') ->assertVisible('[type=submit]') ->assertFocused('#reset_password'); }); // Test Back button $browser->click('@step3 [type=button]') ->waitFor('@step2') ->assertFocused('@step2 #reset_short_code') ->assertMissing('@step3') ->assertMissing('@step1'); // TODO: Test form reset when going back // Because the verification code is removed in tearDown() // we'll start from the beginning (Step 1) $browser->click('@step2 [type=button]') ->waitFor('@step1') ->assertFocused('@step1 #reset_email') ->assertMissing('@step3') ->assertMissing('@step2'); // Submit valid data $browser->with('@step1', function ($step) { $step->type('#reset_email', 'passwordresettestdusk@' . \config('app.domain')); $step->click('[type=submit]'); }); $browser->waitFor('@step2') ->waitUntilMissing('@step2 #reset_code[value=""]'); // Submit valid code again $browser->with('@step2', function ($step) { $code = $step->value('#reset_code'); $this->assertNotEmpty($code); $code = VerificationCode::find($code); $step->type('#reset_short_code', $code->short_code); $step->click('[type=submit]'); }); $browser->waitFor('@step3'); // Submit invalid data $browser->with('@step3', function ($step) use ($browser) { $step->assertFocused('#reset_password') ->whenAvailable('#reset_password_policy', function (Browser $browser) { $browser->assertElementsCount('li', 2) ->assertMissing('li:first-child svg.text-success') ->assertSeeIn('li:first-child small', "Password contains an upper-case character") ->assertMissing('li:last-child svg.text-success') ->assertSeeIn('li:last-child small', "Password contains a digit"); }) ->type('#reset_password', 'A2345678') ->type('#reset_password_confirmation', '123456789') ->with('#reset_password_policy', function (Browser $browser) { $browser->waitFor('li:first-child svg.text-success') ->waitFor('li:last-child svg.text-success'); }); $step->click('[type=submit]'); $browser->waitFor('.toast-error'); $step->waitFor('#reset_password.is-invalid') ->assertVisible('#reset_password_input .invalid-feedback') ->assertFocused('#reset_password'); $browser->click('.toast-error'); // remove the toast }); // Submit valid data $browser->with('@step3', function ($step) { $step->type('#reset_password_confirmation', 'A2345678') ->click('[type=submit]'); }); $browser->waitUntilMissing('@step3'); // At this point we should be auto-logged-in to dashboard $browser->on(new Dashboard()); // FIXME: Is it enough to be sure user is logged in? }); } /** * Test password-reset via a link */ public function testResetViaLink(): void { $user = $this->getTestUser('passwordresettestdusk@' . \config('app.domain')); $user->setSetting('external_email', 'external@domain.tld'); $code = new VerificationCode(['mode' => 'password-reset']); $user->verificationcodes()->save($code); $this->browse(function (Browser $browser) use ($code) { // Test a valid link $browser->visit("/password-reset/{$code->short_code}-{$code->code}") ->on(new PasswordReset()) ->waitFor('@step3') ->assertMissing('@step1') ->assertMissing('@step2') ->with('@step3', function ($step) { $step->type('#reset_password', 'A2345678') ->type('#reset_password_confirmation', 'A2345678') ->click('[type=submit]'); }) ->waitUntilMissing('@step3') // At this point we should be auto-logged-in to dashboard ->on(new Dashboard()) ->within(new Menu(), function ($browser) { $browser->clickMenuItem('logout'); }); $this->assertNull(VerificationCode::find($code->code)); // Test an invalid link $browser->visit("/password-reset/{$code->short_code}-{$code->code}") ->assertErrorPage(404, 'The password reset code is expired or invalid.'); }); } /** * Test password reset process for a user with 2FA enabled. */ public function testResetWith2FA(): void { $this->markTestIncomplete(); } } diff --git a/src/tests/Browser/SettingsTest.php b/src/tests/Browser/PoliciesTest.php similarity index 88% copy from src/tests/Browser/SettingsTest.php copy to src/tests/Browser/PoliciesTest.php index 25505bc4..f3a02c0a 100644 --- a/src/tests/Browser/SettingsTest.php +++ b/src/tests/Browser/PoliciesTest.php @@ -1,120 +1,120 @@ browse(function (Browser $browser) { - $browser->visit('/settings')->on(new Home()); + $browser->visit('/policies')->on(new Home()); }); } /** - * Test settings "box" on Dashboard + * Test Policies "box" on Dashboard */ public function testDashboard(): void { $this->browse(function (Browser $browser) { // Test a user that is not an account owner $browser->visit(new Home()) ->submitLogon('jack@kolab.org', 'simple123', true) ->on(new Dashboard()) - ->assertMissing('@links .link-settings') - ->visit('/settings') + ->assertMissing('@links .link-policies') + ->visit('/policies') ->assertErrorPage(403) ->within(new Menu(), function (Browser $browser) { $browser->clickMenuItem('logout'); }); // Test the account owner $browser->waitForLocation('/login') ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->on(new Dashboard()) - ->assertSeeIn('@links .link-settings svg + span', 'Settings'); + ->assertSeeIn('@links .link-policies svg + span', 'Policies'); }); } /** - * Test Settings page + * Test Policies page * * @depends testDashboard */ - public function testSettings(): void + public function testPolicies(): void { $john = $this->getTestUser('john@kolab.org'); $john->setSetting('password_policy', 'min:5,max:100,lower'); $john->setSetting('max_password_age', null); $this->browse(function (Browser $browser) { - $browser->click('@links .link-settings') - ->on(new Settings()) - ->assertSeeIn('#settings .card-title', 'Settings') + $browser->click('@links .link-policies') + ->on(new Policies()) + ->assertSeeIn('#policies .card-title', 'Policies') // Password policy ->assertSeeIn('@form .row:nth-child(1) > label', 'Password Policy') ->with('@form #password_policy', function (Browser $browser) { $browser->assertElementsCount('li', 7) ->assertSeeIn('li:nth-child(1) label', 'Minimum password length') ->assertChecked('li:nth-child(1) input[type=checkbox]') ->assertDisabled('li:nth-child(1) input[type=checkbox]') ->assertValue('li:nth-child(1) input[type=text]', '5') ->assertSeeIn('li:nth-child(2) label', 'Maximum password length') ->assertChecked('li:nth-child(2) input[type=checkbox]') ->assertDisabled('li:nth-child(2) input[type=checkbox]') ->assertValue('li:nth-child(2) input[type=text]', '100') ->assertSeeIn('li:nth-child(3) label', 'Password contains a lower-case character') ->assertChecked('li:nth-child(3) input[type=checkbox]') ->assertMissing('li:nth-child(3) input[type=text]') ->assertSeeIn('li:nth-child(4) label', 'Password contains an upper-case character') ->assertNotChecked('li:nth-child(4) input[type=checkbox]') ->assertMissing('li:nth-child(4) input[type=text]') ->assertSeeIn('li:nth-child(5) label', 'Password contains a digit') ->assertNotChecked('li:nth-child(5) input[type=checkbox]') ->assertMissing('li:nth-child(5) input[type=text]') ->assertSeeIn('li:nth-child(6) label', 'Password contains a special character') ->assertNotChecked('li:nth-child(6) input[type=checkbox]') ->assertMissing('li:nth-child(6) input[type=text]') ->assertSeeIn('li:nth-child(7) label', 'Password cannot be the same as the last') ->assertNotChecked('li:nth-child(7) input[type=checkbox]') ->assertMissing('li:nth-child(7) input[type=text]') ->assertSelected('li:nth-child(7) select', 3) ->assertSelectHasOptions('li:nth-child(7) select', [1,2,3,4,5,6]) // Change the policy ->type('li:nth-child(1) input[type=text]', '11') ->type('li:nth-child(2) input[type=text]', '120') ->click('li:nth-child(3) input[type=checkbox]') ->click('li:nth-child(4) input[type=checkbox]'); }) ->assertSeeIn('@form .row:nth-child(2) > label', 'Password Retention') ->with('@form #password_retention', function (Browser $browser) { $browser->assertElementsCount('li', 1) ->assertSeeIn('li:nth-child(1) label', 'Require a password change every') ->assertNotChecked('li:nth-child(1) input[type=checkbox]') ->assertSelected('li:nth-child(1) select', 3) ->assertSelectHasOptions('li:nth-child(1) select', [3, 6, 9, 12]) // change the policy ->check('li:nth-child(1) input[type=checkbox]') ->select('li:nth-child(1) select', 6); }) ->click('button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'User settings updated successfully.'); }); $this->assertSame('min:11,max:120,upper', $john->getSetting('password_policy')); $this->assertSame('6', $john->getSetting('max_password_age')); } } diff --git a/src/tests/Browser/SettingsTest.php b/src/tests/Browser/SettingsTest.php index 25505bc4..cac08394 100644 --- a/src/tests/Browser/SettingsTest.php +++ b/src/tests/Browser/SettingsTest.php @@ -1,120 +1,252 @@ 'John', + 'last_name' => 'Doe', + 'currency' => 'USD', + 'country' => 'US', + 'billing_address' => "601 13th Street NW\nSuite 900 South\nWashington, DC 20005", + 'external_email' => 'john.doe.external@gmail.com', + 'phone' => '+1 509-248-1111', + 'organization' => 'Kolab Developers', + ]; + + /** + * {@inheritDoc} + */ + public function setUp(): void + { + parent::setUp(); + + User::where('email', 'john@kolab.org')->first()->setSettings($this->profile); + $this->deleteTestUser('profile-delete@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + User::where('email', 'john@kolab.org')->first()->setSettings($this->profile); + $this->deleteTestUser('profile-delete@kolabnow.com'); + + parent::tearDown(); + } + /** * Test settings page (unauthenticated) */ public function testSettingsUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $browser->visit('/settings')->on(new Home()); }); } /** - * Test settings "box" on Dashboard + * Test settings page (wallet controller) */ - public function testDashboard(): void + public function testSettingsController(): void { $this->browse(function (Browser $browser) { - // Test a user that is not an account owner + $user = $this->getTestUser('john@kolab.org'); + $user->setSetting('password_policy', 'min:10,upper,digit'); + $browser->visit(new Home()) - ->submitLogon('jack@kolab.org', 'simple123', true) - ->on(new Dashboard()) - ->assertMissing('@links .link-settings') - ->visit('/settings') - ->assertErrorPage(403) - ->within(new Menu(), function (Browser $browser) { - $browser->clickMenuItem('logout'); - }); - - // Test the account owner - $browser->waitForLocation('/login') - ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->on(new Dashboard()) - ->assertSeeIn('@links .link-settings svg + span', 'Settings'); + ->assertSeeIn('@links .link-settings', 'My account') + ->click('@links .link-settings') + ->on(new UserInfo()) + ->assertSeeIn('#user-info button.button-delete', 'Delete account') + ->assertSeeIn('#user-info .card-title', 'My account') + ->assertSeeIn('@nav #tab-general', 'General') + ->with('@general', function (Browser $browser) use ($user) { + $browser->assertSeeIn('div.row:nth-child(1) label', 'Status (Customer No.)') + ->assertSeeIn('div.row:nth-child(1) #status', 'Active') + ->assertSeeIn('div.row:nth-child(1) #userid', "({$user->id})") + ->assertSeeIn('div.row:nth-child(2) label', 'Email') + ->assertValue('div.row:nth-child(2) input[type=text]', $user->email) + ->assertDisabled('div.row:nth-child(2) input[type=text]') + ->assertSeeIn('div.row:nth-child(3) label', 'Email Aliases') + ->assertVisible('div.row:nth-child(3) .list-input') + ->with(new ListInput('#aliases'), function (Browser $browser) { + $browser->assertListInputValue(['john.doe@kolab.org']) + ->assertValue('@input', ''); + }) + ->assertSeeIn('div.row:nth-child(4) label', 'Password') + ->assertValue('div.row:nth-child(4) input#password', '') + ->assertValue('div.row:nth-child(4) input#password_confirmation', '') + ->assertAttribute('#password', 'placeholder', 'Password') + ->assertAttribute('#password_confirmation', 'placeholder', 'Confirm Password') + ->assertMissing('div.row:nth-child(4) .btn-group') + ->assertMissing('div.row:nth-child(4) #password-link') + ->assertSeeIn('div.row:nth-child(5) label', 'Subscriptions') + ->assertVisible('div.row:nth-child(5) table'); + }) + ->assertSeeIn('@nav #tab-settings', 'Settings') + ->click('@nav #tab-settings') + ->with('@settings', function (Browser $browser) { + $browser->assertSeeIn('div.row:nth-child(1) label', 'Greylisting') + ->click('div.row:nth-child(1) input[type=checkbox]'); + }) + ->assertSeeIn('@nav #tab-personal', 'Personal information') + ->click('@nav #tab-personal') + ->with('@personal', function (Browser $browser) { + $browser->assertSeeIn('div.row:nth-child(1) label', 'First Name') + ->assertValue('div.row:nth-child(1) input[type=text]', $this->profile['first_name']) + ->assertSeeIn('div.row:nth-child(2) label', 'Last Name') + ->assertValue('div.row:nth-child(2) input[type=text]', $this->profile['last_name']) + ->assertSeeIn('div.row:nth-child(3) label', 'Organization') + ->assertValue('div.row:nth-child(3) input[type=text]', $this->profile['organization']) + ->assertSeeIn('div.row:nth-child(4) label', 'Phone') + ->assertValue('div.row:nth-child(4) input[type=text]', $this->profile['phone']) + ->assertSeeIn('div.row:nth-child(5) label', 'External Email') + ->assertValue('div.row:nth-child(5) input[type=text]', $this->profile['external_email']) + ->assertSeeIn('div.row:nth-child(6) label', 'Address') + ->assertValue('div.row:nth-child(6) textarea', $this->profile['billing_address']) + ->assertSeeIn('div.row:nth-child(7) label', 'Country') + ->assertValue('div.row:nth-child(7) select', $this->profile['country']) + // Set some fields and submit + ->type('#first_name', 'Arnie') + ->vueClear('#last_name') + ->click('button[type=submit]'); + }) + ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); }); } /** - * Test Settings page - * - * @depends testDashboard + * Test settings page (non-controller user) */ - public function testSettings(): void + public function testProfileNonController(): void { - $john = $this->getTestUser('john@kolab.org'); - $john->setSetting('password_policy', 'min:5,max:100,lower'); - $john->setSetting('max_password_age', null); + $user = $this->getTestUser('john@kolab.org'); + $user->setSetting('password_policy', 'min:10,upper,digit'); + // Test acting as non-controller $this->browse(function (Browser $browser) { - $browser->click('@links .link-settings') - ->on(new Settings()) - ->assertSeeIn('#settings .card-title', 'Settings') - // Password policy - ->assertSeeIn('@form .row:nth-child(1) > label', 'Password Policy') - ->with('@form #password_policy', function (Browser $browser) { - $browser->assertElementsCount('li', 7) - ->assertSeeIn('li:nth-child(1) label', 'Minimum password length') - ->assertChecked('li:nth-child(1) input[type=checkbox]') - ->assertDisabled('li:nth-child(1) input[type=checkbox]') - ->assertValue('li:nth-child(1) input[type=text]', '5') - ->assertSeeIn('li:nth-child(2) label', 'Maximum password length') - ->assertChecked('li:nth-child(2) input[type=checkbox]') - ->assertDisabled('li:nth-child(2) input[type=checkbox]') - ->assertValue('li:nth-child(2) input[type=text]', '100') - ->assertSeeIn('li:nth-child(3) label', 'Password contains a lower-case character') - ->assertChecked('li:nth-child(3) input[type=checkbox]') - ->assertMissing('li:nth-child(3) input[type=text]') - ->assertSeeIn('li:nth-child(4) label', 'Password contains an upper-case character') - ->assertNotChecked('li:nth-child(4) input[type=checkbox]') - ->assertMissing('li:nth-child(4) input[type=text]') - ->assertSeeIn('li:nth-child(5) label', 'Password contains a digit') - ->assertNotChecked('li:nth-child(5) input[type=checkbox]') - ->assertMissing('li:nth-child(5) input[type=text]') - ->assertSeeIn('li:nth-child(6) label', 'Password contains a special character') - ->assertNotChecked('li:nth-child(6) input[type=checkbox]') - ->assertMissing('li:nth-child(6) input[type=text]') - ->assertSeeIn('li:nth-child(7) label', 'Password cannot be the same as the last') - ->assertNotChecked('li:nth-child(7) input[type=checkbox]') - ->assertMissing('li:nth-child(7) input[type=text]') - ->assertSelected('li:nth-child(7) select', 3) - ->assertSelectHasOptions('li:nth-child(7) select', [1,2,3,4,5,6]) - // Change the policy - ->type('li:nth-child(1) input[type=text]', '11') - ->type('li:nth-child(2) input[type=text]', '120') - ->click('li:nth-child(3) input[type=checkbox]') - ->click('li:nth-child(4) input[type=checkbox]'); + $browser->visit(new Home()) + ->submitLogon('jack@kolab.org', 'simple123', true) + ->on(new Dashboard()) + ->assertSeeIn('@links .link-settings', 'My account') + ->click('@links .link-settings') + ->on(new UserInfo()) + ->assertMissing('#user-info button.button-delete') + ->assertSeeIn('#user-info .card-title', 'My account') + ->assertSeeIn('@nav #tab-general', 'General') + ->with('@general', function (Browser $browser) { + $browser->assertSeeIn('div.row:nth-child(1) label', 'Email') + ->assertValue('div.row:nth-child(1) input[type=text]', 'jack@kolab.org') + ->assertSeeIn('div.row:nth-child(2) label', 'Password') + ->assertValue('div.row:nth-child(2) input#password', '') + ->assertValue('div.row:nth-child(2) input#password_confirmation', '') + ->assertAttribute('#password', 'placeholder', 'Password') + ->assertAttribute('#password_confirmation', 'placeholder', 'Confirm Password') + ->assertMissing('div.row:nth-child(2) .btn-group') + ->assertMissing('div.row:nth-child(2) #password-link') + ->assertMissing('div.row:nth-child(3)') + ->whenAvailable('#password_policy', function (Browser $browser) { + $browser->assertElementsCount('li', 3) + ->assertMissing('li:nth-child(1) svg.text-success') + ->assertSeeIn('li:nth-child(1) small', "Minimum password length: 10 characters") + ->assertMissing('li:nth-child(2) svg.text-success') + ->assertSeeIn('li:nth-child(2) small', "Password contains an upper-case character") + ->assertMissing('li:nth-child(3) svg.text-success') + ->assertSeeIn('li:nth-child(3) small', "Password contains a digit"); + }); + }) + ->assertMissing('@nav #tab-settings') + ->assertSeeIn('@nav #tab-personal', 'Personal information') + ->click('@nav #tab-personal') + ->with('@personal', function (Browser $browser) { + $browser->assertSeeIn('div.row:nth-child(1) label', 'First Name') + ->assertValue('div.row:nth-child(1) input[type=text]', 'Jack') + ->assertSeeIn('div.row:nth-child(2) label', 'Last Name') + ->assertValue('div.row:nth-child(2) input[type=text]', 'Daniels') + ->assertSeeIn('div.row:nth-child(3) label', 'Organization') + ->assertSeeIn('div.row:nth-child(4) label', 'Phone') + ->assertSeeIn('div.row:nth-child(5) label', 'External Email') + ->assertSeeIn('div.row:nth-child(6) label', 'Address') + ->assertSeeIn('div.row:nth-child(7) label', 'Country') + ->click('button[type=submit]'); }) - ->assertSeeIn('@form .row:nth-child(2) > label', 'Password Retention') - ->with('@form #password_retention', function (Browser $browser) { - $browser->assertElementsCount('li', 1) - ->assertSeeIn('li:nth-child(1) label', 'Require a password change every') - ->assertNotChecked('li:nth-child(1) input[type=checkbox]') - ->assertSelected('li:nth-child(1) select', 3) - ->assertSelectHasOptions('li:nth-child(1) select', [3, 6, 9, 12]) - // change the policy - ->check('li:nth-child(1) input[type=checkbox]') - ->select('li:nth-child(1) select', 6); + ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); + }); + + $user = $this->getTestUser('profile-delete@kolabnow.com', ['password' => 'simple123']); + $oldpassword = $user->password; + + // Test password change + $this->browse(function (Browser $browser) use ($user) { + $browser->visit(new Home()) + ->submitLogon($user->email, 'simple123', true) + ->on(new Dashboard()) + ->click('@links .link-settings') + ->on(new UserInfo()) + ->assertSeeIn('@nav #tab-general', 'General') + ->with('@general', function (Browser $browser) { + $browser + ->type('input#password', '12345678') + ->type('input#password_confirmation', '12345678') + ->click('button[type=submit]'); }) - ->click('button[type=submit]') - ->assertToast(Toast::TYPE_SUCCESS, 'User settings updated successfully.'); + ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); }); - $this->assertSame('min:11,max:120,upper', $john->getSetting('password_policy')); - $this->assertSame('6', $john->getSetting('max_password_age')); + $this->assertTrue($oldpassword != $user->fresh()->password); + } + + /** + * Test deleting an account + */ + public function testAccountDelete(): void + { + $this->browse(function (Browser $browser) { + $user = $this->getTestUser('profile-delete@kolabnow.com', ['password' => 'simple123']); + + $browser->visit(new Home()) + ->submitLogon('profile-delete@kolabnow.com', 'simple123', true) + ->on(new Dashboard()) + ->assertSeeIn('@links .link-settings', 'My account') + ->click('@links .link-settings') + ->on(new UserInfo()) + ->assertSeeIn('#user-info button.button-delete', 'Delete account') + ->click('#user-info button.button-delete') + ->with(new Dialog('#delete-warning'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'Delete this account?') + ->assertSeeIn('@body', 'This will delete the account as well as all domains') + ->assertSeeIn('@body strong', 'This operation is irreversible') + ->assertFocused('@button-cancel') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@button-action', 'Delete account') + ->click('@button-cancel'); + }) + ->waitUntilMissing('#delete-warning') + ->click('#user-info button.button-delete') + ->with(new Dialog('#delete-warning'), function (Browser $browser) { + $browser->click('@button-action'); + }) + ->waitUntilMissing('#delete-warning') + ->assertToast(Toast::TYPE_SUCCESS, 'User deleted successfully.') + ->on(new Home()); + + $this->assertTrue($user->fresh()->trashed()); + }); } } diff --git a/src/tests/Browser/SignupTest.php b/src/tests/Browser/SignupTest.php index 3968f4d3..0b4ad1c3 100644 --- a/src/tests/Browser/SignupTest.php +++ b/src/tests/Browser/SignupTest.php @@ -1,914 +1,913 @@ deleteTestUser('signuptestdusk@' . \config('app.domain')); $this->deleteTestUser('admin@user-domain-signup.com'); $this->deleteTestDomain('user-domain-signup.com'); Plan::whereNot('mode', Plan::MODE_EMAIL)->update(['mode' => Plan::MODE_EMAIL]); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('signuptestdusk@' . \config('app.domain')); $this->deleteTestUser('admin@user-domain-signup.com'); $this->deleteTestDomain('user-domain-signup.com'); SignupInvitation::truncate(); Plan::whereNot('mode', Plan::MODE_EMAIL)->update(['mode' => Plan::MODE_EMAIL]); Discount::where('discount', 100)->update(['code' => null]); @unlink(storage_path('signup-tokens.txt')); parent::tearDown(); } /** * Test signup code verification with a link */ public function testSignupCodeByLink(): void { // Test invalid code (invalid format) $this->browse(function (Browser $browser) { // Register Signup page element selectors we'll be using $browser->onWithoutAssert(new Signup()); // TODO: Test what happens if user is logged in $browser->visit('/signup/invalid-code'); // TODO: According to https://github.com/vuejs/vue-router/issues/977 // it is not yet easily possible to display error page component (route) // without changing the URL // TODO: Instead of css selector we should probably define page/component // and use it instead $browser->waitFor('#error-page'); }); // Test invalid code (valid format) $this->browse(function (Browser $browser) { $browser->visit('/signup/XXXXX-code'); // FIXME: User will not be able to continue anyway, so we should // either display 1st step or 404 error page $browser->waitFor('@step1') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); }); // Test valid code $this->browse(function (Browser $browser) { $code = SignupCode::create([ 'email' => 'User@example.org', 'first_name' => 'User', 'last_name' => 'Name', 'plan' => 'individual', 'voucher' => '', ]); $browser->visit('/signup/' . $code->short_code . '-' . $code->code) ->waitFor('@step3') ->assertMissing('@step1') ->assertMissing('@step2'); // FIXME: Find a nice way to read javascript data without using hidden inputs $this->assertSame($code->code, $browser->value('@step2 #signup_code')); // TODO: Test if the signup process can be completed }); } /** * Test signup "welcome" page */ public function testSignupStep0(): void { $this->browse(function (Browser $browser) { $browser->visit(new Signup()); $browser->assertVisible('@step0') ->assertMissing('@step1') ->assertMissing('@step2') ->assertMissing('@step3'); $browser->within(new Menu(), function ($browser) { $browser->assertMenuItems(['support', 'signup', 'login', 'lang'], 'signup'); }); $browser->waitFor('@step0 .plan-selector .card'); // Assert first plan box and press the button $browser->with('@step0 .plan-selector .plan-individual', function ($step) { $step->assertVisible('button') ->assertSeeIn('button', 'Individual Account') ->assertVisible('.plan-description') ->click('button'); }); $browser->waitForLocation('/signup/individual') ->assertVisible('@step1') ->assertSeeIn('.card-title', 'Sign Up - Step 1/3') ->assertMissing('@step0') ->assertMissing('@step2') ->assertMissing('@step3') ->assertFocused('@step1 #signup_first_name'); // Click Back button $browser->click('@step1 [type=button]') ->waitForLocation('/signup') ->assertVisible('@step0') ->assertMissing('@step1') ->assertMissing('@step2') ->assertMissing('@step3'); // Choose the group account plan $browser->click('@step0 .plan-selector .plan-group button') ->waitForLocation('/signup/group') ->assertVisible('@step1') ->assertMissing('@step0') ->assertMissing('@step2') ->assertMissing('@step3') ->assertFocused('@step1 #signup_first_name'); // TODO: Test if 'plan' variable is set properly in vue component }); } /** * Test 1st step of the signup process */ public function testSignupStep1(): void { $this->browse(function (Browser $browser) { $browser->visit('/signup/individual') ->onWithoutAssert(new Signup()); // Here we expect two text inputs and Back and Continue buttons $browser->with('@step1', function ($step) { $step->waitFor('#signup_last_name') ->assertSeeIn('.card-title', 'Sign Up - Step 1/3') ->assertVisible('#signup_first_name') ->assertFocused('#signup_first_name') ->assertVisible('#signup_email') ->assertVisible('[type=button]') ->assertVisible('[type=submit]'); }); // Submit empty form // Email is required, so after pressing Submit // we expect focus to be moved to the email input $browser->with('@step1', function ($step) { $step->click('[type=submit]'); $step->assertFocused('#signup_email'); }); $browser->within(new Menu(), function ($browser) { $browser->assertMenuItems(['support', 'signup', 'login', 'lang'], 'signup'); }); // Submit invalid email, and first_name // We expect both inputs to have is-invalid class added, with .invalid-feedback element $browser->with('@step1', function ($step) { $step->type('#signup_first_name', str_repeat('a', 250)) ->type('#signup_email', '@test') ->click('[type=submit]') ->waitFor('#signup_email.is-invalid') ->assertVisible('#signup_first_name.is-invalid') ->assertVisible('#signup_email + .invalid-feedback') ->assertVisible('#signup_last_name + .invalid-feedback') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); }); // Submit valid data // We expect error state on email input to be removed, and Step 2 form visible $browser->with('@step1', function ($step) { $step->type('#signup_first_name', 'Test') ->type('#signup_last_name', 'User') ->type('#signup_email', 'BrowserSignupTestUser1@kolab.org') ->click('[type=submit]') ->assertMissing('#signup_email.is-invalid') ->assertMissing('#signup_email + .invalid-feedback'); }); $browser->waitUntilMissing('@step2 #signup_code[value=""]'); $browser->waitFor('@step2'); $browser->assertMissing('@step1'); }); } /** * Test 2nd Step of the signup process * * @depends testSignupStep1 */ public function testSignupStep2(): void { $this->browse(function (Browser $browser) { $browser->assertVisible('@step2') ->assertSeeIn('@step2 .card-title', 'Sign Up - Step 2/3') ->assertMissing('@step0') ->assertMissing('@step1') ->assertMissing('@step3'); // Here we expect one text input, Back and Continue buttons $browser->with('@step2', function ($step) { $step->assertVisible('#signup_short_code') ->assertFocused('#signup_short_code') ->assertVisible('[type=button]') ->assertVisible('[type=submit]'); }); // Test Back button functionality $browser->click('@step2 [type=button]') ->waitFor('@step1') ->assertFocused('@step1 #signup_first_name') ->assertMissing('@step2'); // Submit valid Step 1 data (again) $browser->with('@step1', function ($step) { $step->type('#signup_first_name', 'User') ->type('#signup_last_name', 'User') ->type('#signup_email', 'BrowserSignupTestUser1@kolab.org') ->click('[type=submit]'); }); $browser->waitFor('@step2'); $browser->assertMissing('@step1'); // Submit invalid code // We expect code input to have is-invalid class added, with .invalid-feedback element $browser->with('@step2', function ($step) { $step->type('#signup_short_code', 'XXXXX'); $step->click('[type=submit]'); $step->waitFor('#signup_short_code.is-invalid') ->assertVisible('#signup_short_code + .invalid-feedback') ->assertFocused('#signup_short_code') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); }); // Submit valid code // We expect error state on code input to be removed, and Step 3 form visible $browser->with('@step2', function ($step) { // Get the code and short_code from database // FIXME: Find a nice way to read javascript data without using hidden inputs $code = $step->value('#signup_code'); $this->assertNotEmpty($code); $code = SignupCode::find($code); $step->type('#signup_short_code', $code->short_code); $step->click('[type=submit]'); $step->assertMissing('#signup_short_code.is-invalid'); $step->assertMissing('#signup_short_code + .invalid-feedback'); }); $browser->waitFor('@step3'); $browser->assertMissing('@step2'); }); } /** * Test 3rd Step of the signup process * * @depends testSignupStep2 */ public function testSignupStep3(): void { $this->browse(function (Browser $browser) { $browser->assertVisible('@step3'); // Here we expect 3 text inputs, Back and Continue buttons $browser->with('@step3', function ($step) { $domains = Domain::getPublicDomains(); $domains_count = count($domains); $step->assertSeeIn('.card-title', 'Sign Up - Step 3/3') ->assertMissing('#signup_last_name') ->assertMissing('#signup_first_name') ->assertVisible('#signup_login') ->assertVisible('#signup_password') ->assertVisible('#signup_password_confirmation') ->assertVisible('select#signup_domain') ->assertElementsCount('select#signup_domain option', $domains_count, false) ->assertText('select#signup_domain option:nth-child(1)', $domains[0]) ->assertValue('select#signup_domain option:nth-child(1)', $domains[0]) ->assertText('select#signup_domain option:nth-child(2)', $domains[1]) ->assertValue('select#signup_domain option:nth-child(2)', $domains[1]) ->assertVisible('[type=button]') ->assertVisible('[type=submit]') ->assertSeeIn('[type=submit]', 'Submit') ->assertFocused('#signup_login') ->assertSelected('select#signup_domain', \config('app.domain')) ->assertValue('#signup_login', '') ->assertValue('#signup_password', '') ->assertValue('#signup_password_confirmation', '') ->with('#signup_password_policy', function (Browser $browser) { $browser->assertElementsCount('li', 2) ->assertMissing('li:first-child svg.text-success') ->assertSeeIn('li:first-child small', "Minimum password length: 6 characters") ->assertMissing('li:last-child svg.text-success') ->assertSeeIn('li:last-child small', "Maximum password length: 255 characters"); }); // TODO: Test domain selector }); // Test Back button $browser->click('@step3 [type=button]'); $browser->waitFor('@step2'); $browser->assertFocused('@step2 #signup_short_code'); $browser->assertMissing('@step3'); // TODO: Test form reset when going back // Submit valid code again $browser->with('@step2', function ($step) { $code = $step->value('#signup_code'); $this->assertNotEmpty($code); $code = SignupCode::find($code); $step->type('#signup_short_code', $code->short_code); $step->click('[type=submit]'); }); $browser->waitFor('@step3'); // Submit invalid data $browser->with('@step3', function ($step) { $step->assertFocused('#signup_login') ->type('#signup_login', '*') ->type('#signup_password', '12345678') ->type('#signup_password_confirmation', '123456789') ->with('#signup_password_policy', function (Browser $browser) { $browser->waitFor('li:first-child svg.text-success') ->waitFor('li:last-child svg.text-success'); }) ->click('[type=submit]') ->waitFor('#signup_login.is-invalid') ->assertVisible('#signup_domain + .invalid-feedback') ->assertVisible('#signup_password.is-invalid') ->assertVisible('#signup_password_input .invalid-feedback') ->assertFocused('#signup_login') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); }); // Submit invalid data (valid login, invalid password) $browser->with('@step3', function ($step) { $step->type('#signup_login', 'SignupTestDusk') ->click('[type=submit]') ->waitFor('#signup_password.is-invalid') ->assertVisible('#signup_password_input .invalid-feedback') ->assertMissing('#signup_login.is-invalid') ->assertMissing('#signup_domain + .invalid-feedback') ->assertFocused('#signup_password') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); }); // Submit valid data $browser->with('@step3', function ($step) { $step->type('#signup_password_confirmation', '12345678'); $step->click('[type=submit]'); }); // At this point we should be auto-logged-in to dashboard $browser->waitUntilMissing('@step3') ->waitUntilMissing('.app-loader') ->on(new Dashboard()) ->assertUser('signuptestdusk@' . \config('app.domain')) - ->assertVisible('@links a.link-profile') + ->assertVisible('@links a.link-settings') ->assertMissing('@links a.link-domains') ->assertVisible('@links a.link-users') ->assertVisible('@links a.link-wallet'); // Logout the user $browser->within(new Menu(), function ($browser) { $browser->clickMenuItem('logout'); }); }); } /** * Test signup for a group account */ public function testSignupGroup(): void { $this->browse(function (Browser $browser) { $browser->visit(new Signup()); // Choose the group account plan $browser->waitFor('@step0 .plan-group button') ->click('@step0 .plan-group button'); // Submit valid data // We expect error state on email input to be removed, and Step 2 form visible $browser->whenAvailable('@step1', function ($step) { $step->type('#signup_first_name', 'Test') ->type('#signup_last_name', 'User') ->type('#signup_email', 'BrowserSignupTestUser1@kolab.org') ->click('[type=submit]'); }); // Submit valid code $browser->whenAvailable('@step2', function ($step) { // Get the code and short_code from database // FIXME: Find a nice way to read javascript data without using hidden inputs $code = $step->value('#signup_code'); $code = SignupCode::find($code); $step->type('#signup_short_code', $code->short_code) ->click('[type=submit]'); }); // Here we expect 4 text inputs, Back and Continue buttons $browser->whenAvailable('@step3', function ($step) { $step->assertVisible('#signup_login') ->assertVisible('#signup_password') ->assertVisible('#signup_password_confirmation') ->assertVisible('input#signup_domain') ->assertVisible('[type=button]') ->assertVisible('[type=submit]') ->assertFocused('#signup_login') ->assertValue('input#signup_domain', '') ->assertValue('#signup_login', '') ->assertValue('#signup_password', '') ->assertValue('#signup_password_confirmation', ''); }); // Submit invalid login and password data $browser->with('@step3', function ($step) { $step->assertFocused('#signup_login') ->type('#signup_login', '*') ->type('#signup_domain', 'test.com') ->type('#signup_password', '12345678') ->type('#signup_password_confirmation', '123456789') ->click('[type=submit]') ->waitFor('#signup_login.is-invalid') ->assertVisible('#signup_domain + .invalid-feedback') ->assertVisible('#signup_password.is-invalid') ->assertVisible('#signup_password_input .invalid-feedback') ->assertFocused('#signup_login') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); }); // Submit invalid domain $browser->with('@step3', function ($step) { $step->type('#signup_login', 'admin') ->type('#signup_domain', 'aaa') ->type('#signup_password', '12345678') ->type('#signup_password_confirmation', '12345678') ->click('[type=submit]') ->waitUntilMissing('#signup_login.is-invalid') ->waitFor('#signup_domain.is-invalid + .invalid-feedback') ->assertMissing('#signup_password.is-invalid') ->assertMissing('#signup_password_input .invalid-feedback') ->assertFocused('#signup_domain') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); }); // Submit invalid domain $browser->with('@step3', function ($step) { $step->type('#signup_domain', 'user-domain-signup.com') ->click('[type=submit]'); }); // At this point we should be auto-logged-in to dashboard $browser->waitUntilMissing('@step3') ->waitUntilMissing('.app-loader') ->on(new Dashboard()) ->assertUser('admin@user-domain-signup.com') - ->assertVisible('@links a.link-profile') + ->assertVisible('@links a.link-settings') ->assertVisible('@links a.link-domains') ->assertVisible('@links a.link-users') ->assertVisible('@links a.link-wallet'); $browser->within(new Menu(), function ($browser) { $browser->clickMenuItem('logout'); }); }); } /** * Test signup with a mandate plan, also the UI lock * * @group mollie */ public function testSignupMandate(): void { // Test the individual plan $plan = Plan::withEnvTenantContext()->where('title', 'individual')->first(); $plan->mode = Plan::MODE_MANDATE; $plan->save(); $this->browse(function (Browser $browser) { $browser->withConfig(['services.payment_provider' => 'mollie']) ->visit(new Signup()) ->waitFor('@step0 .plan-individual button') ->click('@step0 .plan-individual button') // Test Back button ->whenAvailable('@step3', function ($browser) { $browser->click('button[type=button]'); }) ->whenAvailable('@step0', function ($browser) { $browser->click('.plan-individual button'); }) // Test submit ->whenAvailable('@step3', function ($browser) { $domains = Domain::getPublicDomains(); $domains_count = count($domains); $browser->assertMissing('.card-title') ->assertElementsCount('select#signup_domain option', $domains_count, false) ->assertText('select#signup_domain option:nth-child(1)', $domains[0]) ->assertValue('select#signup_domain option:nth-child(1)', $domains[0]) ->type('#signup_login', 'signuptestdusk') ->type('#signup_password', '12345678') ->type('#signup_password_confirmation', '12345678') ->click('[type=submit]'); }) ->whenAvailable('@step4', function ($browser) { $browser->assertSeeIn('h4', 'The account is about to be created!') ->assertSeeIn('h5', 'You are choosing a monthly subscription') ->assertVisible('#summary-content') ->assertElementsCount('#summary-content + p.credit-cards img', 2) ->assertVisible('#summary-summary') ->assertSeeIn('button.btn-primary', 'Subscribe') ->assertSeeIn('button.btn-secondary', 'Back') ->click('button.btn-secondary'); }) ->whenAvailable('@step3', function ($browser) { $browser->assertValue('#signup_login', 'signuptestdusk') ->click('[type=submit]'); }) ->whenAvailable('@step4', function ($browser) { $browser->click('button.btn-primary'); }) ->on(new PaymentMollie()) ->assertSeeIn('@title', 'Auto-Payment Setup') ->assertMissing('@amount') ->submitPayment('open') ->on(new PaymentStatus()) ->assertSeeIn('@lock-alert', 'The account is locked') ->assertSeeIn('@content', 'Checking the status...') ->assertSeeIn('@button', 'Try again'); }); $user = User::where('email', 'signuptestdusk@' . \config('app.domain'))->first(); $this->assertSame($plan->id, $user->getSetting('plan_id')); $this->assertFalse($user->isActive()); // Refresh and see that the account is still locked $this->browse(function (Browser $browser) use ($user) { $browser->visit('/dashboard') ->on(new PaymentStatus()) ->assertSeeIn('@lock-alert', 'The account is locked') ->assertSeeIn('@content', 'Checking the status...'); // Mark the payment paid, and activate the user in background, // expect unlock and redirect to the dashboard // TODO: Move this to a separate tests file for PaymentStatus page $payment = $user->wallets()->first()->payments()->first(); $payment->credit('Test'); $payment->status = \App\Payment::STATUS_PAID; $payment->save(); $this->assertTrue($user->fresh()->isActive()); $browser->waitForLocation('/dashboard', 10) ->within(new Menu(), function ($browser) { $browser->clickMenuItem('logout'); }); }); // TODO: Test the 'Try again' button on /payment/status page } /** * Test signup with a mandate plan with a discount=100% */ public function testSignupMandateDiscount100Percent(): void { // Test the individual plan $plan = Plan::withEnvTenantContext()->where('title', 'individual')->first(); $plan->mode = Plan::MODE_MANDATE; $plan->save(); $discount = Discount::where('discount', 100)->first(); $discount->code = 'FREE'; $discount->save(); $this->browse(function (Browser $browser) { $browser->visit(new Signup()) ->waitFor('@step0 .plan-individual button') ->click('@step0 .plan-individual button') ->whenAvailable('@step0', function ($browser) { $browser->click('.plan-individual button'); }) ->whenAvailable('@step3', function ($browser) { $browser->type('#signup_login', 'signuptestdusk') ->type('#signup_password', '12345678') ->type('#signup_password_confirmation', '12345678') ->type('#signup_voucher', 'FREE') ->click('[type=submit]'); }) ->whenAvailable('@step4', function ($browser) { $browser->assertSeeIn('h4', 'The account is about to be created!') ->assertSeeIn('#summary-content', 'You are signing up for an account with 100% discount.') ->assertMissing('#summary-summary') ->assertSeeIn('button.btn-primary', 'Subscribe') ->assertSeeIn('button.btn-secondary', 'Back') ->click('button.btn-primary'); }) ->waitUntilMissing('@step4') ->on(new Dashboard()) ->within(new Menu(), function ($browser) { $browser->clickMenuItem('logout'); }); }); $user = User::where('email', 'signuptestdusk@' . \config('app.domain'))->first(); $this->assertSame($plan->id, $user->getSetting('plan_id')); $this->assertTrue($user->isActive()); $this->assertFalse($user->isRestricted()); $this->assertSame($discount->id, $user->wallets->first()->discount_id); } /** * Test signup with a token plan */ public function testSignupToken(): void { // Test the individual plan Plan::where('title', 'individual')->update(['mode' => Plan::MODE_TOKEN]); // Register some valid tokens $tokens = ['1234567890', 'abcdefghijk']; file_put_contents(storage_path('signup-tokens.txt'), implode("\n", $tokens)); $this->browse(function (Browser $browser) use ($tokens) { $browser->visit(new Signup()) ->waitFor('@step0 .plan-individual button') ->click('@step0 .plan-individual button') // Step 1 ->whenAvailable('@step1', function ($browser) use ($tokens) { $browser->assertSeeIn('.card-title', 'Sign Up - Step 1/2') ->type('#signup_first_name', 'Test') ->type('#signup_last_name', 'User') ->assertMissing('#signup_email') ->type('#signup_token', '1234') // invalid token ->click('[type=submit]') ->waitFor('#signup_token.is-invalid') ->assertVisible('#signup_token + .invalid-feedback') ->assertFocused('#signup_token') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') // valid token ->type('#signup_token', $tokens[0]) ->click('[type=submit]'); }) // Step 2 ->whenAvailable('@step3', function ($browser) { $domains = Domain::getPublicDomains(); $domains_count = count($domains); $browser->assertSeeIn('.card-title', 'Sign Up - Step 2/2') ->assertElementsCount('select#signup_domain option', $domains_count, false) ->assertText('select#signup_domain option:nth-child(1)', $domains[0]) ->assertValue('select#signup_domain option:nth-child(1)', $domains[0]) ->type('#signup_login', 'signuptestdusk') ->type('#signup_password', '12345678') ->type('#signup_password_confirmation', '12345678') ->click('[type=submit]'); }) ->waitUntilMissing('@step3') ->on(new Dashboard()) ->within(new Menu(), function ($browser) { $browser->clickMenuItem('logout'); }); }); $user = User::where('email', 'signuptestdusk@' . \config('app.domain'))->first(); $this->assertSame($tokens[0], $user->getSetting('signup_token')); $this->assertSame(null, $user->getSetting('external_email')); // Test the group plan Plan::where('title', 'group')->update(['mode' => Plan::MODE_TOKEN]); $this->browse(function (Browser $browser) use ($tokens) { $browser->visit(new Signup()) ->waitFor('@step0 .plan-group button') ->click('@step0 .plan-group button') // Step 1 ->whenAvailable('@step1', function ($browser) use ($tokens) { $browser->assertSeeIn('.card-title', 'Sign Up - Step 1/2') ->type('#signup_first_name', 'Test') ->type('#signup_last_name', 'User') ->assertMissing('#signup_email') ->type('#signup_token', '1234') // invalid token ->click('[type=submit]') ->waitFor('#signup_token.is-invalid') ->assertVisible('#signup_token + .invalid-feedback') ->assertFocused('#signup_token') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') // valid token ->type('#signup_token', $tokens[1]) ->click('[type=submit]'); }) // Step 2 ->whenAvailable('@step3', function ($browser) { $browser->assertSeeIn('.card-title', 'Sign Up - Step 2/2') ->type('input#signup_domain', 'user-domain-signup.com') ->type('#signup_login', 'admin') ->type('#signup_password', '12345678') ->type('#signup_password_confirmation', '12345678') ->click('[type=submit]'); }) ->waitUntilMissing('@step3') ->on(new Dashboard()) ->within(new Menu(), function ($browser) { $browser->clickMenuItem('logout'); }); }); $user = User::where('email', 'admin@user-domain-signup.com')->first(); $this->assertSame($tokens[1], $user->getSetting('signup_token')); $this->assertSame(null, $user->getSetting('external_email')); } /** * Test signup with voucher */ public function testSignupVoucherLink(): void { $this->browse(function (Browser $browser) { $browser->visit('/signup/voucher/TEST') ->onWithoutAssert(new Signup()) ->waitUntilMissing('.app-loader') ->waitFor('@step0') ->click('.plan-individual button') ->whenAvailable('@step1', function (Browser $browser) { $browser->type('#signup_first_name', 'Test') ->type('#signup_last_name', 'User') ->type('#signup_email', 'BrowserSignupTestUser1@kolab.org') ->click('[type=submit]'); }) ->whenAvailable('@step2', function (Browser $browser) { // Get the code and short_code from database // FIXME: Find a nice way to read javascript data without using hidden inputs $code = $browser->value('#signup_code'); $this->assertNotEmpty($code); $code = SignupCode::find($code); $browser->type('#signup_short_code', $code->short_code) ->click('[type=submit]'); }) ->whenAvailable('@step3', function (Browser $browser) { // Assert that the code is filled in the input // Change it and test error handling $browser->assertValue('#signup_voucher', 'TEST') ->type('#signup_voucher', 'TESTXX') ->type('#signup_login', 'signuptestdusk') ->type('#signup_password', '123456789') ->type('#signup_password_confirmation', '123456789') ->click('[type=submit]') ->waitFor('#signup_voucher.is-invalid') ->assertVisible('#signup_voucher + .invalid-feedback') ->assertFocused('#signup_voucher') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') // Submit the correct code ->type('#signup_voucher', 'TEST') ->click('[type=submit]'); }) ->waitUntilMissing('@step3') ->waitUntilMissing('.app-loader') ->on(new Dashboard()) ->assertUser('signuptestdusk@' . \config('app.domain')) // Logout the user ->within(new Menu(), function ($browser) { $browser->clickMenuItem('logout'); }); }); $user = $this->getTestUser('signuptestdusk@' . \config('app.domain')); $discount = Discount::where('code', 'TEST')->first(); $this->assertSame($discount->id, $user->wallets()->first()->discount_id); } /** * Test signup via invitation link */ public function testSignupInvitation(): void { // Test non-existing invitation $this->browse(function (Browser $browser) { $browser->visit('/signup/invite/TEST') ->onWithoutAssert(new Signup()) ->waitFor('#app > #error-page') ->assertErrorPage(404); }); $invitation = SignupInvitation::create(['email' => 'test@domain.org']); $this->browse(function (Browser $browser) use ($invitation) { $browser->visit('/signup/invite/' . $invitation->id) ->onWithoutAssert(new Signup()) ->waitUntilMissing('.app-loader') ->with('@step3', function ($step) { $domains_count = count(Domain::getPublicDomains()); $step->assertMissing('.card-title') ->assertVisible('#signup_last_name') ->assertVisible('#signup_first_name') ->assertVisible('#signup_login') ->assertVisible('#signup_password') ->assertVisible('#signup_password_confirmation') ->assertVisible('select#signup_domain') ->assertElementsCount('select#signup_domain option', $domains_count, false) ->assertVisible('[type=submit]') ->assertMissing('[type=button]') // Back button ->assertSeeIn('[type=submit]', 'Sign Up') ->assertFocused('#signup_first_name') ->assertValue('select#signup_domain', \config('app.domain')) ->assertValue('#signup_first_name', '') ->assertValue('#signup_last_name', '') ->assertValue('#signup_login', '') ->assertValue('#signup_password', '') ->assertValue('#signup_password_confirmation', ''); // Submit invalid data $step->type('#signup_login', '*') ->type('#signup_password', '12345678') ->type('#signup_password_confirmation', '123456789') ->click('[type=submit]') ->waitFor('#signup_login.is-invalid') ->assertVisible('#signup_domain + .invalid-feedback') ->assertVisible('#signup_password.is-invalid') ->assertVisible('#signup_password_input .invalid-feedback') ->assertFocused('#signup_login') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); // Submit valid data $step->type('#signup_password_confirmation', '12345678') ->type('#signup_login', 'signuptestdusk') ->type('#signup_first_name', 'First') ->type('#signup_last_name', 'Last') ->click('[type=submit]'); }) // At this point we should be auto-logged-in to dashboard ->waitUntilMissing('@step3') ->waitUntilMissing('.app-loader') ->on(new Dashboard()) ->assertUser('signuptestdusk@' . \config('app.domain')) // Logout the user ->within(new Menu(), function ($browser) { $browser->clickMenuItem('logout'); }); }); $invitation->refresh(); $user = User::where('email', 'signuptestdusk@' . \config('app.domain'))->first(); $this->assertTrue($invitation->isCompleted()); $this->assertSame($user->id, $invitation->user_id); $this->assertSame('First', $user->getSetting('first_name')); $this->assertSame('Last', $user->getSetting('last_name')); $this->assertSame($invitation->email, $user->getSetting('external_email')); } } diff --git a/src/tests/Browser/UserProfileTest.php b/src/tests/Browser/UserProfileTest.php deleted file mode 100644 index c7123cbc..00000000 --- a/src/tests/Browser/UserProfileTest.php +++ /dev/null @@ -1,228 +0,0 @@ - 'John', - 'last_name' => 'Doe', - 'currency' => 'USD', - 'country' => 'US', - 'billing_address' => "601 13th Street NW\nSuite 900 South\nWashington, DC 20005", - 'external_email' => 'john.doe.external@gmail.com', - 'phone' => '+1 509-248-1111', - 'organization' => 'Kolab Developers', - ]; - - /** - * {@inheritDoc} - */ - public function setUp(): void - { - parent::setUp(); - - User::where('email', 'john@kolab.org')->first()->setSettings($this->profile); - $this->deleteTestUser('profile-delete@kolabnow.com'); - } - - /** - * {@inheritDoc} - */ - public function tearDown(): void - { - User::where('email', 'john@kolab.org')->first()->setSettings($this->profile); - $this->deleteTestUser('profile-delete@kolabnow.com'); - - parent::tearDown(); - } - - /** - * Test profile page (unauthenticated) - */ - public function testProfileUnauth(): void - { - // Test that the page requires authentication - $this->browse(function (Browser $browser) { - $browser->visit('/profile')->on(new Home()); - }); - } - - /** - * Test profile page - */ - public function testProfile(): void - { - $user = $this->getTestUser('john@kolab.org'); - $user->setSetting('password_policy', 'min:10,upper,digit'); - - $this->browse(function (Browser $browser) { - $browser->visit(new Home()) - ->submitLogon('john@kolab.org', 'simple123', true) - ->on(new Dashboard()) - ->assertSeeIn('@links .link-profile', 'Your profile') - ->click('@links .link-profile') - ->on(new UserProfile()) - ->assertSeeIn('#user-profile .profile-delete', 'Delete account') - ->whenAvailable('@form', function (Browser $browser) { - $user = User::where('email', 'john@kolab.org')->first(); - // Assert form content - $browser->assertFocused('div.row:nth-child(2) input') - ->assertSeeIn('div.row:nth-child(1) label', 'Customer No.') - ->assertSeeIn('div.row:nth-child(1) .form-control-plaintext', $user->id) - ->assertSeeIn('div.row:nth-child(2) label', 'First Name') - ->assertValue('div.row:nth-child(2) input[type=text]', $this->profile['first_name']) - ->assertSeeIn('div.row:nth-child(3) label', 'Last Name') - ->assertValue('div.row:nth-child(3) input[type=text]', $this->profile['last_name']) - ->assertSeeIn('div.row:nth-child(4) label', 'Organization') - ->assertValue('div.row:nth-child(4) input[type=text]', $this->profile['organization']) - ->assertSeeIn('div.row:nth-child(5) label', 'Phone') - ->assertValue('div.row:nth-child(5) input[type=text]', $this->profile['phone']) - ->assertSeeIn('div.row:nth-child(6) label', 'External Email') - ->assertValue('div.row:nth-child(6) input[type=text]', $this->profile['external_email']) - ->assertSeeIn('div.row:nth-child(7) label', 'Address') - ->assertValue('div.row:nth-child(7) textarea', $this->profile['billing_address']) - ->assertSeeIn('div.row:nth-child(8) label', 'Country') - ->assertValue('div.row:nth-child(8) select', $this->profile['country']) - ->assertSeeIn('div.row:nth-child(9) label', 'Password') - ->assertValue('div.row:nth-child(9) input#password', '') - ->assertValue('div.row:nth-child(9) input#password_confirmation', '') - ->assertAttribute('#password', 'placeholder', 'Password') - ->assertAttribute('#password_confirmation', 'placeholder', 'Confirm Password') - ->whenAvailable('#password_policy', function (Browser $browser) { - $browser->assertElementsCount('li', 3) - ->assertMissing('li:nth-child(1) svg.text-success') - ->assertSeeIn('li:nth-child(1) small', "Minimum password length: 10 characters") - ->assertMissing('li:nth-child(2) svg.text-success') - ->assertSeeIn('li:nth-child(2) small', "Password contains an upper-case character") - ->assertMissing('li:nth-child(3) svg.text-success') - ->assertSeeIn('li:nth-child(3) small', "Password contains a digit"); - }) - ->assertSeeIn('button[type=submit]', 'Submit'); - - // Test password policy checking - $browser->type('#password', '1A') - ->whenAvailable('#password_policy', function (Browser $browser) { - $browser->waitFor('li:nth-child(2) svg.text-success') - ->waitFor('li:nth-child(3) svg.text-success') - ->assertMissing('li:nth-child(1) svg.text-success'); - }) - ->vueClear('#password'); - - // Test form error handling - $browser->type('#phone', 'aaaaaa') - ->type('#external_email', 'bbbbb') - ->click('button[type=submit]') - ->waitFor('#phone + .invalid-feedback') - ->assertSeeIn('#phone + .invalid-feedback', 'The phone format is invalid.') - ->assertSeeIn( - '#external_email + .invalid-feedback', - 'The external email must be a valid email address.' - ) - ->assertFocused('#phone') - ->assertToast(Toast::TYPE_ERROR, 'Form validation error') - ->clearToasts(); - - // Clear all fields and submit - // FIXME: Should any of these fields be required? - $browser->vueClear('#first_name') - ->vueClear('#last_name') - ->vueClear('#organization') - ->vueClear('#phone') - ->vueClear('#external_email') - ->vueClear('#billing_address') - ->click('button[type=submit]') - ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); - }) - // On success we're redirected to Dashboard - ->on(new Dashboard()); - }); - } - - /** - * Test profile of non-controller user - */ - public function testProfileNonController(): void - { - $user = $this->getTestUser('john@kolab.org'); - $user->setSetting('password_policy', 'min:10,upper,digit'); - - // Test acting as non-controller - $this->browse(function (Browser $browser) { - $browser->visit('/logout') - ->visit(new Home()) - ->submitLogon('jack@kolab.org', 'simple123', true) - ->on(new Dashboard()) - ->assertSeeIn('@links .link-profile', 'Your profile') - ->click('@links .link-profile') - ->on(new UserProfile()) - ->assertMissing('#user-profile .profile-delete') - ->whenAvailable('@form', function (Browser $browser) { - // TODO: decide on what fields the non-controller user should be able - // to see/change - }) - // Check that the account policy is used - ->whenAvailable('#password_policy', function (Browser $browser) { - $browser->assertElementsCount('li', 3) - ->assertMissing('li:nth-child(1) svg.text-success') - ->assertSeeIn('li:nth-child(1) small', "Minimum password length: 10 characters") - ->assertMissing('li:nth-child(2) svg.text-success') - ->assertSeeIn('li:nth-child(2) small', "Password contains an upper-case character") - ->assertMissing('li:nth-child(3) svg.text-success') - ->assertSeeIn('li:nth-child(3) small', "Password contains a digit"); - }); - - // Test that /profile/delete page is not accessible - $browser->visit('/profile/delete') - ->assertErrorPage(403); - }); - } - - /** - * Test profile delete page - */ - public function testProfileDelete(): void - { - $user = $this->getTestUser('profile-delete@kolabnow.com', ['password' => 'simple123']); - - $this->browse(function (Browser $browser) use ($user) { - $browser->visit('/logout') - ->on(new Home()) - ->submitLogon('profile-delete@kolabnow.com', 'simple123', true) - ->on(new Dashboard()) - ->assertSeeIn('@links .link-profile', 'Your profile') - ->click('@links .link-profile') - ->on(new UserProfile()) - ->click('#user-profile .profile-delete') - ->waitForLocation('/profile/delete') - ->assertSeeIn('#user-delete .card-title', 'Delete this account?') - ->assertSeeIn('#user-delete .button-cancel', 'Cancel') - ->assertSeeIn('#user-delete .card-text', 'This operation is irreversible') - ->assertFocused('#user-delete .button-cancel') - ->click('#user-delete .button-cancel') - ->waitForLocation('/profile') - ->on(new UserProfile()); - - // Test deleting the user - $browser->click('#user-profile .profile-delete') - ->waitForLocation('/profile/delete') - ->click('#user-delete .button-delete') - ->waitForLocation('/login') - ->assertToast(Toast::TYPE_SUCCESS, 'User deleted successfully.'); - - $this->assertTrue($user->fresh()->trashed()); - }); - } - - // TODO: Test that Ned (John's "delegatee") can delete himself - // TODO: Test that Ned (John's "delegatee") can/can't delete John ? -} diff --git a/src/tests/Browser/UsersTest.php b/src/tests/Browser/UsersTest.php index 845ab989..ce2f66b5 100644 --- a/src/tests/Browser/UsersTest.php +++ b/src/tests/Browser/UsersTest.php @@ -1,812 +1,858 @@ 'John', 'last_name' => 'Doe', 'organization' => 'Kolab Developers', 'limit_geo' => null, + 'currency' => 'USD', + 'country' => 'US', + 'billing_address' => "601 13th Street NW\nSuite 900 South\nWashington, DC 20005", + 'external_email' => 'john.doe.external@gmail.com', + 'phone' => '+1 509-248-1111', ]; /** * {@inheritDoc} */ public function setUp(): void { parent::setUp(); $this->deleteTestUser('julia.roberts@kolab.org'); $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(); $activesync_sku = Sku::withEnvTenantContext()->where('title', 'activesync')->first(); $storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first(); Entitlement::where('entitleable_id', $john->id)->where('sku_id', $activesync_sku->id)->delete(); Entitlement::where('cost', '>=', 5000)->delete(); Entitlement::where('cost', '=', 25)->where('sku_id', $storage_sku->id)->delete(); $wallet = $john->wallets()->first(); $wallet->discount()->dissociate(); $wallet->currency = 'CHF'; $wallet->save(); $this->clearBetaEntitlements(); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('julia.roberts@kolab.org'); $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(); $activesync_sku = Sku::withEnvTenantContext()->where('title', 'activesync')->first(); $storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first(); Entitlement::where('entitleable_id', $john->id)->where('sku_id', $activesync_sku->id)->delete(); Entitlement::where('cost', '>=', 5000)->delete(); Entitlement::where('cost', '=', 25)->where('sku_id', $storage_sku->id)->delete(); $wallet = $john->wallets()->first(); $wallet->discount()->dissociate(); $wallet->currency = 'CHF'; $wallet->save(); $this->clearBetaEntitlements(); parent::tearDown(); } /** - * Test user account editing page (not profile page) + * Test user page - General tab */ - public function testInfo(): void + public function testUserGeneralTab(): void { $this->browse(function (Browser $browser) { $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $john->verificationcodes()->delete(); $jack->verificationcodes()->delete(); $john->setSetting('password_policy', 'min:10,upper,digit'); // Test that the page requires authentication $browser->visit('/user/' . $john->id) ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123', false) ->on(new UserInfo()) ->assertSeeIn('#user-info .card-title', 'User account') + ->assertSeeIn('@nav #tab-general', 'General') ->with('@general', function (Browser $browser) { - // Assert form content + // Assert the General tab content $browser->assertSeeIn('div.row:nth-child(1) label', 'Status') ->assertSeeIn('div.row:nth-child(1) #status', 'Active') - ->assertFocused('div.row:nth-child(2) input') - ->assertSeeIn('div.row:nth-child(2) label', 'First Name') - ->assertValue('div.row:nth-child(2) input[type=text]', $this->profile['first_name']) - ->assertSeeIn('div.row:nth-child(3) label', 'Last Name') - ->assertValue('div.row:nth-child(3) input[type=text]', $this->profile['last_name']) - ->assertSeeIn('div.row:nth-child(4) label', 'Organization') - ->assertValue('div.row:nth-child(4) input[type=text]', $this->profile['organization']) - ->assertSeeIn('div.row:nth-child(5) label', 'Email') - ->assertValue('div.row:nth-child(5) input[type=text]', 'john@kolab.org') - ->assertDisabled('div.row:nth-child(5) input[type=text]') - ->assertSeeIn('div.row:nth-child(6) label', 'Email Aliases') - ->assertVisible('div.row:nth-child(6) .list-input') + ->assertSeeIn('div.row:nth-child(2) label', 'Email') + ->assertValue('div.row:nth-child(2) input[type=text]', 'john@kolab.org') + ->assertDisabled('div.row:nth-child(2) input[type=text]') + ->assertSeeIn('div.row:nth-child(3) label', 'Email Aliases') + ->assertVisible('div.row:nth-child(3) .list-input') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertListInputValue(['john.doe@kolab.org']) ->assertValue('@input', ''); }) - ->assertSeeIn('div.row:nth-child(7) label', 'Password') - ->assertValue('div.row:nth-child(7) input#password', '') - ->assertValue('div.row:nth-child(7) input#password_confirmation', '') + ->assertSeeIn('div.row:nth-child(4) label', 'Password') + ->assertValue('div.row:nth-child(4) input#password', '') + ->assertValue('div.row:nth-child(4) input#password_confirmation', '') ->assertAttribute('#password', 'placeholder', 'Password') ->assertAttribute('#password_confirmation', 'placeholder', 'Confirm Password') - ->assertMissing('div.row:nth-child(7) .btn-group') - ->assertMissing('div.row:nth-child(7) #password-link') - ->assertSeeIn('button[type=submit]', 'Submit') - // Clear some fields and submit - ->vueClear('#first_name') - ->vueClear('#last_name') - ->click('button[type=submit]'); - }) - ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.') - ->on(new UserList()) - ->click('@table tr:nth-child(3) a') - ->on(new UserInfo()) - ->assertSeeIn('#user-info .card-title', 'User account') - ->with('@general', function (Browser $browser) { + ->assertMissing('div.row:nth-child(4) .btn-group') + ->assertMissing('div.row:nth-child(4) #password-link'); + // Test error handling (password) $browser->type('#password', 'aaaaaA') ->vueClear('#password_confirmation') ->whenAvailable('#password_policy', function (Browser $browser) { $browser->assertElementsCount('li', 3) ->assertMissing('li:nth-child(1) svg.text-success') ->assertSeeIn('li:nth-child(1) small', "Minimum password length: 10 characters") ->waitFor('li:nth-child(2) svg.text-success') ->assertSeeIn('li:nth-child(2) small', "Password contains an upper-case character") ->assertMissing('li:nth-child(3) svg.text-success') ->assertSeeIn('li:nth-child(3) small', "Password contains a digit"); }) ->click('button[type=submit]') ->waitFor('#password_confirmation + .invalid-feedback') ->assertSeeIn( '#password_confirmation + .invalid-feedback', 'The password confirmation does not match.' ) ->assertFocused('#password') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); // TODO: Test password change // Test form error handling (aliases) $browser->vueClear('#password') ->vueClear('#password_confirmation') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->addListEntry('invalid address'); }) ->scrollTo('button[type=submit]')->pause(500) ->click('button[type=submit]') - ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); - - $browser->with(new ListInput('#aliases'), function (Browser $browser) { - $browser->assertFormError(2, 'The specified alias is invalid.', false); - }); - - // Test adding aliases - $browser->with(new ListInput('#aliases'), function (Browser $browser) { - $browser->removeListEntry(2) - ->addListEntry('john.test@kolab.org'); - }) + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + ->with(new ListInput('#aliases'), function (Browser $browser) { + $browser->assertFormError(2, 'The specified alias is invalid.', false) + // Test adding aliases + ->removeListEntry(2) + ->addListEntry('john.test@kolab.org'); + }) ->click('button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); }) ->on(new UserList()) ->click('@table tr:nth-child(3) a') ->on(new UserInfo()); $alias = $john->aliases()->where('alias', 'john.test@kolab.org')->first(); $this->assertTrue(!empty($alias)); // Test subscriptions $browser->with('@general', function (Browser $browser) { - $browser->assertSeeIn('div.row:nth-child(8) label', 'Subscriptions') - ->assertVisible('@skus.row:nth-child(8)') + $browser->assertSeeIn('div.row:nth-child(5) label', 'Subscriptions') + ->assertVisible('@skus.row:nth-child(5)') ->with('@skus', function ($browser) { $browser->assertElementsCount('tbody tr', 5) // Mailbox SKU ->assertSeeIn('tbody tr:nth-child(1) td.name', 'User Mailbox') ->assertSeeIn('tbody tr:nth-child(1) td.price', '5,00 CHF/month') ->assertChecked('tbody tr:nth-child(1) td.selection input') ->assertDisabled('tbody tr:nth-child(1) td.selection input') ->assertTip( 'tbody tr:nth-child(1) td.buttons button', 'Just a mailbox' ) // Storage SKU ->assertSeeIn('tbody tr:nth-child(2) td.name', 'Storage Quota') ->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month') ->assertChecked('tbody tr:nth-child(2) td.selection input') ->assertDisabled('tbody tr:nth-child(2) td.selection input') ->assertTip( 'tbody tr:nth-child(2) td.buttons button', 'Some wiggle room' ) ->with(new QuotaInput('tbody tr:nth-child(2) .range-input'), function ($browser) { $browser->assertQuotaValue(5)->setQuotaValue(6); }) ->assertSeeIn('tr:nth-child(2) td.price', '0,25 CHF/month') // groupware SKU ->assertSeeIn('tbody tr:nth-child(3) td.name', 'Groupware Features') ->assertSeeIn('tbody tr:nth-child(3) td.price', '4,90 CHF/month') ->assertChecked('tbody tr:nth-child(3) td.selection input') ->assertEnabled('tbody tr:nth-child(3) td.selection input') ->assertTip( 'tbody tr:nth-child(3) td.buttons button', 'Groupware functions like Calendar, Tasks, Notes, etc.' ) // ActiveSync SKU ->assertSeeIn('tbody tr:nth-child(4) td.name', 'Activesync') ->assertSeeIn('tbody tr:nth-child(4) td.price', '0,00 CHF/month') ->assertNotChecked('tbody tr:nth-child(4) td.selection input') ->assertEnabled('tbody tr:nth-child(4) td.selection input') ->assertTip( 'tbody tr:nth-child(4) td.buttons button', 'Mobile synchronization' ) // 2FA SKU ->assertSeeIn('tbody tr:nth-child(5) td.name', '2-Factor Authentication') ->assertSeeIn('tbody tr:nth-child(5) td.price', '0,00 CHF/month') ->assertNotChecked('tbody tr:nth-child(5) td.selection input') ->assertEnabled('tbody tr:nth-child(5) td.selection input') ->assertTip( 'tbody tr:nth-child(5) td.buttons button', 'Two factor authentication for webmail and administration panel' ) ->click('tbody tr:nth-child(4) td.selection input'); }) ->assertMissing('@skus table + .hint') ->click('button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); }) ->on(new UserList()) ->click('@table tr:nth-child(3) a') ->on(new UserInfo()); $expected = ['activesync', 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage']; $this->assertEntitlements($john->fresh(), $expected); // Test subscriptions interaction $browser->with('@general', function (Browser $browser) { $browser->with('@skus', function ($browser) { // Uncheck 'groupware', expect activesync unchecked $browser->click('#sku-input-groupware') ->assertNotChecked('#sku-input-groupware') ->assertNotChecked('#sku-input-activesync') ->assertEnabled('#sku-input-activesync') ->assertNotReadonly('#sku-input-activesync') // Check 'activesync', expect an alert ->click('#sku-input-activesync') ->assertDialogOpened('Activesync requires Groupware Features.') ->acceptDialog() ->assertNotChecked('#sku-input-activesync') // Check '2FA', expect 'activesync' unchecked and readonly ->click('#sku-input-2fa') ->assertChecked('#sku-input-2fa') ->assertNotChecked('#sku-input-activesync') ->assertReadonly('#sku-input-activesync') // Uncheck '2FA' ->click('#sku-input-2fa') ->assertNotChecked('#sku-input-2fa') ->assertNotReadonly('#sku-input-activesync'); }); }); // Test password reset link delete and create $code = new \App\VerificationCode(['mode' => 'password-reset']); $jack->verificationcodes()->save($code); $browser->visit('/user/' . $jack->id) ->on(new UserInfo()) ->with('@general', function (Browser $browser) use ($jack, $john, $code) { // Test displaying an existing password reset link $link = Browser::$baseUrl . '/password-reset/' . $code->short_code . '-' . $code->code; - $browser->assertSeeIn('div.row:nth-child(7) label', 'Password') + $browser->assertSeeIn('div.row:nth-child(4) label', 'Password') ->assertMissing('#password') ->assertMissing('#password_confirmation') ->assertMissing('#pass-mode-link:checked') ->assertMissing('#pass-mode-input:checked') ->assertSeeIn('#password-link code', $link) ->assertVisible('#password-link button.text-danger') ->assertVisible('#password-link button:not(.text-danger)') ->assertAttribute('#password-link button:not(.text-danger)', 'title', 'Copy') ->assertAttribute('#password-link button.text-danger', 'title', 'Delete') ->assertMissing('#password-link div.form-text'); // Test deleting an existing password reset link $browser->click('#password-link button.text-danger') ->assertToast(Toast::TYPE_SUCCESS, 'Password reset code deleted successfully.') ->assertMissing('#password-link') ->assertMissing('#pass-mode-link:checked') ->assertMissing('#pass-mode-input:checked') ->assertMissing('#password'); $this->assertSame(0, $jack->verificationcodes()->count()); // Test creating a password reset link $link = preg_replace('|/[a-z0-9A-Z-]+$|', '', $link) . '/'; $browser->click('#pass-mode-link + label') ->assertMissing('#password') ->assertMissing('#password_confirmation') ->waitFor('#password-link code') ->assertSeeIn('#password-link code', $link) ->assertSeeIn('#password-link div.form-text', "Press Submit to activate the link") ->pause(100); // Test copy to clipboard /* TODO: Figure out how to give permission to do this operation $code = $john->verificationcodes()->first(); $link .= $code->short_code . '-' . $code->code; $browser->assertMissing('#password-link button.text-danger') ->click('#password-link button:not(.text-danger)') ->keys('#organization', ['{left_control}', 'v']) ->assertAttribute('#organization', 'value', $link) ->vueClear('#organization'); */ // Finally submit the form $browser->click('button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); $this->assertSame(1, $jack->verificationcodes()->where('active', true)->count()); $this->assertSame(0, $john->verificationcodes()->count()); }); }); } /** - * Test user settings tab + * Test user page - General tab + * + * @depends testUserGeneralTab + */ + public function testUserPersonalTab(): void + { + $this->browse(function (Browser $browser) { + $john = $this->getTestUser('john@kolab.org'); + $jack = $this->getTestUser('jack@kolab.org'); + $jack->setSetting('organization', null); + + // Test the account controller + $browser->visit('/user/' . $john->id) + ->on(new UserInfo()) + ->assertSeeIn('@nav #tab-personal', 'Personal information') + ->click('#tab-personal') + ->with('@personal', function (Browser $browser) { + $browser->assertSeeIn('div.row:nth-child(1) label', 'First Name') + ->assertValue('div.row:nth-child(1) input[type=text]', $this->profile['first_name']) + ->assertSeeIn('div.row:nth-child(2) label', 'Last Name') + ->assertValue('div.row:nth-child(2) input[type=text]', $this->profile['last_name']) + ->assertSeeIn('div.row:nth-child(3) label', 'Organization') + ->assertValue('div.row:nth-child(3) input[type=text]', $this->profile['organization']) + ->assertSeeIn('div.row:nth-child(4) label', 'Phone') + ->assertValue('div.row:nth-child(4) input[type=text]', $this->profile['phone']) + ->assertSeeIn('div.row:nth-child(5) label', 'External Email') + ->assertValue('div.row:nth-child(5) input[type=text]', $this->profile['external_email']) + ->assertSeeIn('div.row:nth-child(6) label', 'Address') + ->assertValue('div.row:nth-child(6) textarea', $this->profile['billing_address']) + ->assertSeeIn('div.row:nth-child(7) label', 'Country') + ->assertValue('div.row:nth-child(7) select', $this->profile['country']) + // Set some fields and submit + ->type('#first_name', 'Arnie') + ->vueClear('#last_name') + ->click('button[type=submit]'); + }) + ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.') + ->on(new UserList()); + + $this->assertSame('Arnie', $john->getSetting('first_name')); + $this->assertSame(null, $john->getSetting('last_name')); + + // Test the non-controller user + $browser->visit('/user/' . $jack->id) + ->on(new UserInfo()) + ->click('#tab-personal') + ->with('@personal', function (Browser $browser) { + $browser->assertSeeIn('div.row:nth-child(1) label', 'First Name') + ->assertValue('div.row:nth-child(1) input[type=text]', 'Jack') + ->assertSeeIn('div.row:nth-child(2) label', 'Last Name') + ->assertValue('div.row:nth-child(2) input[type=text]', 'Daniels') + ->assertSeeIn('div.row:nth-child(3) label', 'Organization') + ->assertValue('div.row:nth-child(3) input[type=text]', '') + // Set some fields and submit + ->type('#organization', 'Test') + ->click('button[type=submit]'); + }) + ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.') + ->on(new UserList()); + + $this->assertSame('Test', $jack->getSetting('organization')); + }); + } + + /** + * Test user page - Settings tab * - * @depends testInfo + * @depends testUserPersonalTab */ - public function testUserSettings(): void + public function testUserSettingsTab(): void { $john = $this->getTestUser('john@kolab.org'); $john->setSetting('greylist_enabled', null); $john->setSetting('guam_enabled', null); $john->setSetting('limit_geo', null); $this->browse(function (Browser $browser) use ($john) { $browser->visit('/user/' . $john->id) ->on(new UserInfo()) - ->assertElementsCount('@nav a', 2) - ->assertSeeIn('@nav #tab-general', 'General') ->assertSeeIn('@nav #tab-settings', 'Settings') ->click('@nav #tab-settings') - ->with('#settings form', function (Browser $browser) { + ->with('@settings', 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') ->click('button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'User settings updated successfully.'); }); }); $this->assertSame('false', $john->getSetting('greylist_enabled')); $this->addBetaEntitlement($john); $this->browse(function (Browser $browser) use ($john) { $browser->refresh() ->on(new UserInfo()) ->click('@nav #tab-settings') - ->with('#settings form', function (Browser $browser) use ($john) { + ->with('@settings', 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') ->assertSeeIn('div.row:nth-child(3) label', 'Geo-lockin') ->with(new CountrySelect('#limit_geo'), function ($browser) { $browser->assertCountries([]) ->setCountries(['DE', 'PL']) ->assertCountries(['DE', 'PL']); }) ->click('div.row:nth-child(2) input') ->click('button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'User settings updated successfully.'); $this->assertSame('["DE","PL"]', $john->getSetting('limit_geo')); $this->assertSame('true', $john->getSetting('guam_enabled')); $browser ->with(new CountrySelect('#limit_geo'), function ($browser) { $browser->setCountries([]) ->assertCountries([]); }) ->click('div.row:nth-child(2) input') ->click('button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'User settings updated successfully.'); $this->assertSame(null, $john->getSetting('limit_geo')); $this->assertSame(null, $john->getSetting('guam_enabled')); }); }); } /** * Test user adding page - * - * @depends testInfo */ public function testNewUser(): void { $john = $this->getTestUser('john@kolab.org'); $john->setSetting('password_policy', null); $this->browse(function (Browser $browser) { $browser->visit(new UserList()) ->assertSeeIn('button.user-new', 'Create user') ->click('button.user-new') ->on(new UserInfo()) ->assertSeeIn('#user-info .card-title', 'New user account') + ->assertMissing('@nav #tab-settings') + ->assertMissing('@nav #tab-personal') ->with('@general', function (Browser $browser) { // Assert form content $browser->assertFocused('div.row:nth-child(1) input') ->assertSeeIn('div.row:nth-child(1) label', 'First Name') ->assertValue('div.row:nth-child(1) input[type=text]', '') ->assertSeeIn('div.row:nth-child(2) label', 'Last Name') ->assertValue('div.row:nth-child(2) input[type=text]', '') ->assertSeeIn('div.row:nth-child(3) label', 'Organization') ->assertValue('div.row:nth-child(3) input[type=text]', '') ->assertSeeIn('div.row:nth-child(4) label', 'Email') ->assertValue('div.row:nth-child(4) input[type=text]', '') ->assertEnabled('div.row:nth-child(4) input[type=text]') ->assertSeeIn('div.row:nth-child(5) label', 'Email Aliases') ->assertVisible('div.row:nth-child(5) .list-input') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertListInputValue([]) ->assertValue('@input', ''); }) ->assertSeeIn('div.row:nth-child(6) label', 'Password') ->assertValue('div.row:nth-child(6) input#password', '') ->assertValue('div.row:nth-child(6) input#password_confirmation', '') ->assertAttribute('#password', 'placeholder', 'Password') ->assertAttribute('#password_confirmation', 'placeholder', 'Confirm Password') ->assertSeeIn('div.row:nth-child(6) .btn-group input:first-child + label', 'Enter password') ->assertSeeIn('div.row:nth-child(6) .btn-group input:not(:first-child) + label', 'Set via link') ->assertChecked('div.row:nth-child(6) .btn-group input:first-child') ->assertMissing('div.row:nth-child(6) #password-link') ->assertSeeIn('div.row:nth-child(7) label', 'Package') // assert packages list widget, select "Lite Account" ->with('@packages', function ($browser) { $browser->assertElementsCount('tbody tr', 2) ->assertSeeIn('tbody tr:nth-child(1)', 'Groupware Account') ->assertSeeIn('tbody tr:nth-child(2)', 'Lite Account') ->assertSeeIn('tbody tr:nth-child(1) .price', '9,90 CHF/month') ->assertSeeIn('tbody tr:nth-child(2) .price', '5,00 CHF/month') ->assertChecked('tbody tr:nth-child(1) input') ->click('tbody tr:nth-child(2) input') ->assertNotChecked('tbody tr:nth-child(1) input') ->assertChecked('tbody tr:nth-child(2) input'); }) ->assertMissing('@packages table + .hint') ->assertSeeIn('button[type=submit]', 'Submit'); // Test browser-side required fields and error handling $browser->click('button[type=submit]') ->assertFocused('#email') ->type('#email', 'invalid email') ->type('#password', 'simple123') ->type('#password_confirmation', 'simple') ->click('button[type=submit]') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->assertSeeIn('#email + .invalid-feedback', 'The specified email is invalid.') ->assertSeeIn( '#password_confirmation + .invalid-feedback', 'The password confirmation does not match.' ); }); // Test form error handling (aliases) $browser->with('@general', function (Browser $browser) { $browser->type('#email', 'julia.roberts@kolab.org') ->type('#password_confirmation', 'simple123') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->addListEntry('invalid address'); }) ->click('button[type=submit]') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertFormError(1, 'The specified alias is invalid.', false); }); }); // Successful account creation $browser->with('@general', function (Browser $browser) { $browser->type('#first_name', 'Julia') ->type('#last_name', 'Roberts') ->type('#organization', 'Test Org') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->removeListEntry(1) ->addListEntry('julia.roberts2@kolab.org'); }) ->click('button[type=submit]'); }) ->assertToast(Toast::TYPE_SUCCESS, 'User created successfully.') // check redirection to users list ->on(new UserList()) ->whenAvailable('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 5) ->assertSeeIn('tbody tr:nth-child(4) a', 'julia.roberts@kolab.org'); }); $julia = User::where('email', 'julia.roberts@kolab.org')->first(); $alias = UserAlias::where('user_id', $julia->id)->where('alias', 'julia.roberts2@kolab.org')->first(); $this->assertTrue(!empty($alias)); $this->assertEntitlements($julia, ['mailbox', 'storage', 'storage', 'storage', 'storage', 'storage']); $this->assertSame('Julia', $julia->getSetting('first_name')); $this->assertSame('Roberts', $julia->getSetting('last_name')); $this->assertSame('Test Org', $julia->getSetting('organization')); // Some additional tests for the list input widget $browser->click('@table tbody tr:nth-child(4) a') ->on(new UserInfo()) ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertListInputValue(['julia.roberts2@kolab.org']) ->addListEntry('invalid address') ->type('.input-group:nth-child(2) input', '@kolab.org') ->keys('.input-group:nth-child(2) input', '{enter}'); }) // TODO: Investigate why this click does not work, for now we // submit the form with Enter key above //->click('@general button[type=submit]') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertVisible('.input-group:nth-child(2) input.is-invalid') ->assertVisible('.input-group:nth-child(3) input.is-invalid') ->type('.input-group:nth-child(2) input', 'julia.roberts3@kolab.org') ->type('.input-group:nth-child(3) input', 'julia.roberts4@kolab.org') ->keys('.input-group:nth-child(3) input', '{enter}'); }) // TODO: Investigate why this click does not work, for now we // submit the form with Enter key above //->click('@general button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); $julia = User::where('email', 'julia.roberts@kolab.org')->first(); $aliases = $julia->aliases()->orderBy('alias')->get()->pluck('alias')->all(); $this->assertSame(['julia.roberts3@kolab.org', 'julia.roberts4@kolab.org'], $aliases); }); } /** * Test user delete * * @depends testNewUser */ public function testDeleteUser(): void { // First create a new user $john = $this->getTestUser('john@kolab.org'); $julia = $this->getTestUser('julia.roberts@kolab.org'); $package_kolab = \App\Package::where('title', 'kolab')->first(); $john->assignPackage($package_kolab, $julia); // Test deleting non-controller user $this->browse(function (Browser $browser) use ($julia) { $browser->visit('/user/' . $julia->id) ->on(new UserInfo()) ->assertSeeIn('button.button-delete', 'Delete user') ->click('button.button-delete') ->with(new Dialog('#delete-warning'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Delete julia.roberts@kolab.org') ->assertFocused('@button-cancel') ->assertSeeIn('@button-cancel', 'Cancel') ->assertSeeIn('@button-action', 'Delete') ->click('@button-cancel'); }) ->waitUntilMissing('#delete-warning') ->click('button.button-delete') ->with(new Dialog('#delete-warning'), function (Browser $browser) { $browser->click('@button-action'); }) ->waitUntilMissing('#delete-warning') ->assertToast(Toast::TYPE_SUCCESS, 'User deleted successfully.') ->on(new UserList()) ->with('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 4) ->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org') ->assertSeeIn('tbody tr:nth-child(2) a', 'joe@kolab.org') ->assertSeeIn('tbody tr:nth-child(3) a', 'john@kolab.org') ->assertSeeIn('tbody tr:nth-child(4) a', 'ned@kolab.org'); }); $julia = User::where('email', 'julia.roberts@kolab.org')->first(); $this->assertTrue(empty($julia)); }); // Test that non-controller user cannot see/delete himself on the users list $this->browse(function (Browser $browser) { $browser->visit('/logout') ->on(new Home()) ->submitLogon('jack@kolab.org', 'simple123', true) ->visit('/users') ->assertErrorPage(403); }); // Test that controller user (Ned) can see all the users $this->browse(function (Browser $browser) { $browser->visit('/logout') ->on(new Home()) ->submitLogon('ned@kolab.org', 'simple123', true) ->visit(new UserList()) ->whenAvailable('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 4); }); // TODO: Test the delete action in details }); // TODO: Test what happens with the logged in user session after he's been deleted by another user } /** * Test discounted sku/package prices in the UI */ public function testDiscountedPrices(): void { // Add 10% discount $discount = Discount::where('code', 'TEST')->first(); $john = User::where('email', 'john@kolab.org')->first(); $wallet = $john->wallet(); $wallet->discount()->associate($discount); $wallet->save(); // SKUs on user edit page $this->browse(function (Browser $browser) { $browser->visit('/logout') ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->visit(new UserList()) ->waitFor('@table tr:nth-child(2)') ->click('@table tr:nth-child(2) a') // joe@kolab.org ->on(new UserInfo()) ->with('@general', function (Browser $browser) { $browser->whenAvailable('@skus', function (Browser $browser) { $quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input'); $browser->waitFor('tbody tr') ->assertElementsCount('tbody tr', 5) // Mailbox SKU ->assertSeeIn('tbody tr:nth-child(1) td.price', '4,50 CHF/month¹') // Storage SKU ->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month¹') ->with($quota_input, function (Browser $browser) { $browser->setQuotaValue(100); }) ->assertSeeIn('tr:nth-child(2) td.price', '21,37 CHF/month¹') // Groupware SKU ->assertSeeIn('tbody tr:nth-child(3) td.price', '4,41 CHF/month¹') // ActiveSync SKU ->assertSeeIn('tbody tr:nth-child(4) td.price', '0,00 CHF/month¹') // 2FA SKU ->assertSeeIn('tbody tr:nth-child(5) td.price', '0,00 CHF/month¹'); }) ->assertSeeIn('@skus table + .hint', '¹ applied discount: 10% - Test voucher'); }); }); // Packages on new user page $this->browse(function (Browser $browser) { $browser->visit(new UserList()) ->click('button.user-new') ->on(new UserInfo()) ->with('@general', function (Browser $browser) { $browser->whenAvailable('@packages', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 2) ->assertSeeIn('tbody tr:nth-child(1) .price', '8,91 CHF/month¹') // Groupware ->assertSeeIn('tbody tr:nth-child(2) .price', '4,50 CHF/month¹'); // Lite }) ->assertSeeIn('@packages table + .hint', '¹ applied discount: 10% - Test voucher'); }); }); // Test using entitlement cost instead of the SKU cost $this->browse(function (Browser $browser) use ($wallet) { $joe = User::where('email', 'joe@kolab.org')->first(); $beta_sku = Sku::withEnvTenantContext()->where('title', 'beta')->first(); $storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first(); // Add an extra storage and beta entitlement with different prices Entitlement::create([ 'wallet_id' => $wallet->id, 'sku_id' => $beta_sku->id, 'cost' => 5010, 'entitleable_id' => $joe->id, 'entitleable_type' => User::class ]); Entitlement::create([ 'wallet_id' => $wallet->id, 'sku_id' => $storage_sku->id, 'cost' => 5000, 'entitleable_id' => $joe->id, 'entitleable_type' => User::class ]); $browser->visit('/user/' . $joe->id) ->on(new UserInfo()) ->with('@general', function (Browser $browser) { $browser->whenAvailable('@skus', function (Browser $browser) { $quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input'); $browser->waitFor('tbody tr') // Beta SKU ->assertSeeIn('tbody tr:nth-child(6) td.price', '45,09 CHF/month¹') // Storage SKU ->assertSeeIn('tr:nth-child(2) td.price', '45,00 CHF/month¹') ->with($quota_input, function (Browser $browser) { $browser->setQuotaValue(7); }) ->assertSeeIn('tr:nth-child(2) td.price', '45,22 CHF/month¹') ->with($quota_input, function (Browser $browser) { $browser->setQuotaValue(5); }) ->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month¹'); }) ->assertSeeIn('@skus table + .hint', '¹ applied discount: 10% - Test voucher'); }); }); } /** * Test non-default currency in the UI */ public function testCurrency(): void { // Add 10% discount $john = User::where('email', 'john@kolab.org')->first(); $wallet = $john->wallet(); $wallet->balance = -1000; $wallet->currency = 'EUR'; $wallet->save(); // On Dashboard and the wallet page $this->browse(function (Browser $browser) { $browser->visit('/logout') ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->on(new Dashboard()) ->assertSeeIn('@links .link-wallet .badge', '-10,00 €') ->click('@links .link-wallet') ->on(new WalletPage()) ->assertSeeIn('#wallet .card-title', 'Account balance -10,00 €'); }); // SKUs on user edit page $this->browse(function (Browser $browser) { $browser->visit(new UserList()) ->waitFor('@table tr:nth-child(2)') ->click('@table tr:nth-child(2) a') // joe@kolab.org ->on(new UserInfo()) ->with('@general', function (Browser $browser) { $browser->whenAvailable('@skus', function (Browser $browser) { $quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input'); $browser->waitFor('tbody tr') ->assertElementsCount('tbody tr', 5) // Mailbox SKU ->assertSeeIn('tbody tr:nth-child(1) td.price', '5,00 €/month') // Storage SKU ->assertSeeIn('tr:nth-child(2) td.price', '0,00 €/month') ->with($quota_input, function (Browser $browser) { $browser->setQuotaValue(100); }) ->assertSeeIn('tr:nth-child(2) td.price', '23,75 €/month'); }); }); }); // Packages on new user page $this->browse(function (Browser $browser) { $browser->visit(new UserList()) ->click('button.user-new') ->on(new UserInfo()) ->with('@general', function (Browser $browser) { $browser->whenAvailable('@packages', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 2) ->assertSeeIn('tbody tr:nth-child(1) .price', '9,90 €/month') // Groupware ->assertSeeIn('tbody tr:nth-child(2) .price', '5,00 €/month'); // Lite }); }); }); } }