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 @@