diff --git a/plugins/kolab_2fa/kolab_2fa.php b/plugins/kolab_2fa/kolab_2fa.php index ec3f847c..f1f78788 100644 --- a/plugins/kolab_2fa/kolab_2fa.php +++ b/plugins/kolab_2fa/kolab_2fa.php @@ -1,767 +1,767 @@ * * Copyright (C) 2015, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_2fa extends rcube_plugin { public $task = '(login|settings)'; protected $login_verified = null; protected $login_factors = array(); protected $drivers = array(); protected $storage; /** * Plugin init */ public function init() { $this->load_config(); $this->add_hook('startup', array($this, 'startup')); } /** * Startup hook */ public function startup($args) { $rcmail = rcmail::get_instance(); // register library namespace to autoloader $loader = include(INSTALL_PATH . 'vendor/autoload.php'); $loader->set('Kolab2FA', array($this->home . '/lib')); if ($args['task'] === 'login' && $this->api->output) { $this->add_texts('localization/', false); $this->add_hook('authenticate', array($this, 'authenticate')); // process 2nd factor auth step after regular login if ($args['action'] === 'plugin.kolab-2fa-login' /* || !empty($_SESSION['kolab_2fa_factors']) */) { return $this->login_verify($args); } } else if ($args['task'] === 'settings') { $this->add_texts('localization/', !$this->api->output->ajax_call); $this->add_hook('settings_actions', array($this, 'settings_actions')); $this->register_action('plugin.kolab-2fa', array($this, 'settings_view')); $this->register_action('plugin.kolab-2fa-data', array($this, 'settings_data')); $this->register_action('plugin.kolab-2fa-save', array($this, 'settings_save')); $this->register_action('plugin.kolab-2fa-verify', array($this, 'settings_verify')); } return $args; } /** * Handler for 'authenticate' plugin hook. * * ATTENTION: needs to be called *after* kolab_auth::authenticate() */ public function authenticate($args) { // nothing to be done for me if ($args['abort'] || $this->login_verified !== null) { return $args; } $rcmail = rcmail::get_instance(); // parse $host URL $a_host = parse_url($args['host']); $hostname = $_SESSION['hostname'] = $a_host['host'] ?: $args['host']; // 1. find user record (and its prefs) before IMAP login if ($user = rcube_user::query($args['user'], $hostname)) { $rcmail->config->set_user_prefs($user->get_prefs()); } // 2a. let plugins provide the list of active authentication factors $lookup = $rcmail->plugins->exec_hook('kolab_2fa_lookup', array( 'user' => $args['user'], 'host' => $hostname, 'factors' => $rcmail->config->get('kolab_2fa_factors'), 'check' => $rcmail->config->get('kolab_2fa_check', true), )); if (isset($lookup['factors'])) { $factors = (array)$lookup['factors']; } // 2b. check storage if this user has 2FA enabled else if ($lookup['check'] !== false && ($storage = $this->get_storage($args['user']))) { $factors = (array)$storage->enumerate(); } if (count($factors) > 0) { $args['abort'] = true; $factors = array_unique($factors); // 3. flag session for 2nd factor verification $_SESSION['kolab_2fa_time'] = time(); $_SESSION['kolab_2fa_nonce'] = bin2hex(openssl_random_pseudo_bytes(32)); $_SESSION['kolab_2fa_factors'] = $factors; $_SESSION['username'] = $args['user']; $_SESSION['host'] = $args['host']; $_SESSION['password'] = $rcmail->encrypt($args['pass']); // 4. render to 2nd auth step $this->login_step($factors); } return $args; } /** * Handler for the additional login step requesting the 2FA verification code */ public function login_step($factors) { // replace handler for login form $this->login_factors = array_values($factors); $this->api->output->add_handler('loginform', array($this, 'auth_form')); // focus the code input field on load $this->api->output->add_script('$("input.kolab2facode").first().select();', 'docready'); $this->api->output->send('login'); } /** * Process the 2nd factor code verification form submission */ public function login_verify($args) { $rcmail = rcmail::get_instance(); $time = $_SESSION['kolab_2fa_time']; $nonce = $_SESSION['kolab_2fa_nonce']; $factors = (array)$_SESSION['kolab_2fa_factors']; $this->login_verified = false; $expired = $time < time() - $rcmail->config->get('kolab_2fa_timeout', 120); if (!empty($factors) && !empty($nonce) && !$expired) { // TODO: check signature // try to verify each configured factor foreach ($factors as $factor) { list($method) = explode(':', $factor, 2); // verify the submitted code $code = rcube_utils::get_input_value("_${nonce}_${method}", rcube_utils::INPUT_POST); $this->login_verified = $this->verify_factor_auth($factor, $code); // accept first successful method if ($this->login_verified) { break; } } } if ($this->login_verified) { // restore POST data from session $_POST['_user'] = $_SESSION['username']; $_POST['_host'] = $_SESSION['host']; $_POST['_pass'] = $rcmail->decrypt($_SESSION['password']); } // proceed with regular login ... $args['action'] = 'login'; // session data will be reset in index.php thus additional // auth attempts with intercepted data will be rejected // $rcmail->kill_session(); // we can't display any custom error messages on failed login // but that's actually desired to expose as little information as possible return $args; } /** * Helper method to verify the given method/code tuple */ protected function verify_factor_auth($method, $code) { if (strlen($code) && ($driver = $this->get_driver($method))) { // set properties from login $driver->username = $_SESSION['username']; try { // verify the submitted code return $driver->verify($code, $_SESSION['kolab_2fa_time']); } catch (Exception $e) { rcube::raise_error($e, true, false); } } return false; } /** * Render 2nd factor authentication form in place of the regular login form */ public function auth_form($attrib = array()) { $form_name = !empty($attrib['form']) ? $attrib['form'] : 'form'; $nonce = $_SESSION['kolab_2fa_nonce']; $methods = array_unique(array_map(function($factor) { list($method, $id) = explode(':', $factor); return $method; }, $this->login_factors )); // forward these values as the regular login screen would submit them $input_task = new html_hiddenfield(array('name' => '_task', 'value' => 'login')); $input_action = new html_hiddenfield(array('name' => '_action', 'value' => 'plugin.kolab-2fa-login')); $input_tzone = new html_hiddenfield(array('name' => '_timezone', 'id' => 'rcmlogintz', 'value' => rcube_utils::get_input_value('_timezone', rcube_utils::INPUT_POST))); $input_url = new html_hiddenfield(array('name' => '_url', 'id' => 'rcmloginurl', 'value' => rcube_utils::get_input_value('_url', rcube_utils::INPUT_POST))); // create HTML table with two cols $table = new html_table(array('cols' => 2)); $required = count($methods) > 1 ? null : 'required'; // render input for each configured auth method foreach ($methods as $i => $method) { if ($row++ > 0) { $table->add(array('colspan' => 2, 'class' => 'title hint', 'style' => 'text-align:center'), $this->gettext('or')); } $field_id = "rcmlogin2fa$method"; $input_code = new html_inputfield(array('name' => "_${nonce}_${method}", 'class' => 'kolab2facode', 'id' => $field_id, 'required' => $required, 'autocomplete' => 'off') + $attrib); $table->add('title', html::label($field_id, html::quote($this->gettext($method)))); $table->add('input', $input_code->show('')); } $out = $input_task->show(); $out .= $input_action->show(); $out .= $input_tzone->show(); $out .= $input_url->show(); $out .= $table->show(); // add submit button if (rcube_utils::get_boolean($attrib['submit'])) { $submit = new html_inputfield(array('type' => 'submit', 'id' => 'rcmloginsubmit', 'class' => 'button mainaction', 'value' => $this->gettext('continue'))); $out .= html::p('formbuttons', $submit->show()); } // surround html output with a form tag if (empty($attrib['form'])) { $out = $this->api->output->form_tag(array('name' => $form_name, 'method' => 'post'), $out); } return $out; } /** * Load driver class for the given authentication factor * * @param string $factor Factor identifier (:) * @return Kolab2FA\Driver\Base */ public function get_driver($factor) { list($method) = explode(':', $factor, 2); $rcmail = rcmail::get_instance(); if ($this->drivers[$factor]) { return $this->drivers[$factor]; } $config = $rcmail->config->get('kolab_2fa_' . $method, array()); // use product name as "issuer"" if (empty($config['issuer'])) { $config['issuer'] = $rcmail->config->get('product_name'); } try { // TODO: use external auth service if configured $driver = \Kolab2FA\Driver\Base::factory($factor, $config); // attach storage $driver->storage = $this->get_storage(); if ($rcmail->user->ID) { $driver->username = $rcmail->get_user_name(); } $this->drivers[$factor] = $driver; return $driver; } catch (Exception $e) { $error = strval($e); } rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => $error), true, false); return false; } /** * Getter for a storage instance singleton */ public function get_storage($for = null) { if (!isset($this->storage) || (!empty($for) && $this->storage->username !== $for)) { $rcmail = rcmail::get_instance(); try { $this->storage = \Kolab2FA\Storage\Base::factory( $rcmail->config->get('kolab_2fa_storage', 'roundcube'), $rcmail->config->get('kolab_2fa_storage_config', array()) ); $this->storage->set_username($for); $this->storage->set_logger(new \Kolab2FA\Log\RcubeLogger()); // set user properties from active session if (!empty($_SESSION['kolab_dn'])) { $this->storage->userdn = $_SESSION['kolab_dn']; } } catch (Exception $e) { $this->storage = false; rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => $error), true, false); } } return $this->storage; } /** * Handler for 'settings_actions' hook */ public function settings_actions($args) { // register as settings action $args['actions'][] = array( 'action' => 'plugin.kolab-2fa', - 'class' => '2factorauth', + 'class' => 'twofactorauth', 'label' => 'settingslist', 'title' => 'settingstitle', 'domain' => 'kolab_2fa', ); return $args; } /** * Handler for settings/plugin.kolab-2fa requests */ public function settings_view() { $this->register_handler('plugin.settingsform', array($this, 'settings_form')); $this->register_handler('plugin.settingslist', array($this, 'settings_list')); $this->register_handler('plugin.factoradder', array($this, 'settings_factoradder')); $this->register_handler('plugin.highsecuritydialog', array($this, 'settings_highsecuritydialog')); $this->include_script('kolab2fa.js'); - $this->include_stylesheet($this->local_skin_path() . '/kolab2fa.css'); + $this->include_stylesheet($this->local_skin_path() . '/kolab2fa.css', true); if ($this->check_secure_mode()) { $this->api->output->set_env('session_secured', $_SESSION['kolab_2fa_secure_mode']); } $this->api->output->add_label('save','cancel'); $this->api->output->set_pagetitle($this->gettext('settingstitle')); $this->api->output->send('kolab_2fa.config'); } /** * Render the menu to add another authentication factor */ public function settings_factoradder($attrib) { $rcmail = rcmail::get_instance(); $select = new html_select(array('id' => 'kolab2fa-add')); $select->add($this->gettext('addfactor') . '...', ''); foreach ((array)$rcmail->config->get('kolab_2fa_drivers', array()) as $method) { $select->add($this->gettext($method), $method); } return $select->show(); } /** * Render a list of active factor this user has configured */ public function settings_list($attrib = array()) { $attrib['id'] = 'kolab2fa-factors'; $table = new html_table(array('cols' => 3)); $table->add_header('name', $this->gettext('factor')); $table->add_header('created', $this->gettext('created')); $table->add_header('actions', ''); return $table->show($attrib); } /** * Render the settings form template object */ public function settings_form($attrib = array()) { $rcmail = rcmail::get_instance(); $storage = $this->get_storage($rcmail->get_user_name()); $factors = $storage ? (array)$storage->enumerate() : array(); $drivers = (array)$rcmail->config->get('kolab_2fa_drivers', array()); $env_methods = array(); foreach ($drivers as $j => $method) { $out .= $this->settings_factor($method, $attrib); $env_methods[$method] = array( 'name' => $this->gettext($method), 'active' => 0, ); } $me = $this; $factors = array_combine( $factors, array_map(function($id) use ($me, &$env_methods) { $props = array('id' => $id); if ($driver = $me->get_driver($id)) { $props += $this->format_props($driver->props()); $props['method'] = $driver->method; $props['name'] = $me->gettext($driver->method); $env_methods[$driver->method]['active']++; } return $props; }, $factors) ); $this->api->output->set_env('kolab_2fa_methods', $env_methods); $this->api->output->set_env('kolab_2fa_factors', !empty($factors) ? $factors : null); return html::div(array('id' => 'kolab2fapropform'), $out); } /** * Render the settings UI for the given method/driver */ protected function settings_factor($method, $attrib) { $out = ''; $rcmail = rcmail::get_instance(); $attrib += array('class' => 'propform'); if ($driver = $this->get_driver($method)) { $table = new html_table(array('cols' => 2, 'class' => $attrib['class'])); foreach ($driver->props() as $field => $prop) { if (!$prop['editable']) { continue; } switch ($prop['type']) { case 'boolean': case 'checkbox': $input = new html_checkbox(array('value' => '1')); break; case 'enum': case 'select': $input = new html_select(array('disabled' => $prop['readonly'])); $input->add(array_map(array($this, 'gettext'), $prop['options']), $prop['options']); break; default: $input = new html_inputfield(array('size' => $prop['size'] ?: 30, 'disabled' => !$prop['editable'])); } $explain_label = $field . 'explain' . $method; $explain_html = $rcmail->text_exists($explain_label, 'kolab_2fa') ? html::p('explain', $this->gettext($explain_label)) : ''; $field_id = 'rcmk2fa' . $method . $field; $table->add('title', html::label($field_id, $this->gettext($field))); $table->add(null, $input->show('', array('id' => $field_id, 'name' => "_prop[$field]")) . $explain_html); } // add row for displaying the QR code if (method_exists($driver, 'get_provisioning_uri')) { $table->add('title', $this->gettext('qrcode')); $table->add(null, html::p('explain', $this->gettext("qrcodeexplain$method") ) . html::p(null, html::tag('img', array('src' => 'data:image/gif;base64,R0lGODlhDwAPAIAAAMDAwAAAACH5BAEAAAAALAAAAAAPAA8AQAINhI+py+0Po5y02otnAQA7', 'class' => 'qrcode', 'rel' => $method)) ) ); // add row for testing the factor $field_id = 'rcmk2faverify' . $method; $table->add('title', html::label($field_id, $this->gettext('verifycode'))); $table->add(null, html::tag('input', array('type' => 'text', 'name' => '_verify_code', 'id' => $field_id, 'class' => 'k2fa-verify', 'size' => 20, 'required' => true)) . html::p('explain', $this->gettext("verifycodeexplain$method")) ); } $input_id = new html_hiddenfield(array('name' => '_prop[id]', 'value' => '')); $out .= html::tag('form', array( 'method' => 'post', 'action' => '#', 'id' => 'kolab2fa-prop-' . $method, 'style' => 'display:none', ), html::tag('fieldset', array(), html::tag('legend', array(), $this->gettext($method)) . html::div('factorprop', $table->show()) . $input_id->show() ) ); } return $out; } /** * Render th */ public function settings_highsecuritydialog($attrib = array()) { $attrib += array('id' => 'kolab2fa-highsecuritydialog'); $field_id = 'rcmk2facode'; $input = new html_inputfield(array('name' => '_code', 'id' => $field_id, 'class' => 'verifycode', 'size' => 20)); return html::div($attrib, html::p('explain', $this->gettext('highsecuritydialog')) . html::div('propform', html::label($field_id, '$name') . $input->show('')) ); } /** * Handler for settings/plugin.kolab-2fa-save requests */ public function settings_save() { $method = rcube_utils::get_input_value('_method', rcube_utils::INPUT_POST); $data = @json_decode(rcube_utils::get_input_value('_data', rcube_utils::INPUT_POST), true); $rcmail = rcmail::get_instance(); $storage = $this->get_storage($rcmail->get_user_name()); $success = false; $errors = 0; $save_data = array(); if ($driver = $this->get_driver($method)) { if ($data === false) { if ($this->check_secure_mode()) { // remove method from active factors and clear stored settings $success = $driver->clear(); } else { $errors++; } } else { // verify the submitted code before saving $verify_code = rcube_utils::get_input_value('_verify_code', rcube_utils::INPUT_POST); $timestamp = intval(rcube_utils::get_input_value('_timestamp', rcube_utils::INPUT_POST)); if (!empty($verify_code)) { if (!$driver->verify($verify_code, $timestamp)) { $this->api->output->command('plugin.verify_response', array( 'id' => $driver->id, 'method' => $driver->method, 'success' => false, 'message' => str_replace('$method', $this->gettext($driver->method), $this->gettext('codeverificationfailed')) )); $this->api->output->send(); } } foreach ($data as $prop => $value) { if (!$driver->set($prop, $value)) { $errors++; } } $driver->set('active', true); } // commit changes to the user properties if (!$errors) { if ($success = $driver->commit()) { $save_data = $data !== false ? $this->format_props($driver->props()) : array(); } else { $errors++; } } } if ($success) { $this->api->output->show_message($data === false ? $this->gettext('factorremovesuccess') : $this->gettext('factorsavesuccess'), 'confirmation'); $this->api->output->command('plugin.save_success', array( 'method' => $method, 'active' => $data !== false, 'id' => $driver->id) + $save_data); } else if ($errors) { $this->api->output->show_message($this->gettext('factorsaveerror'), 'error'); $this->api->output->command('plugin.reset_form', $method); } $this->api->output->send(); } /** * Handler for settings/plugin.kolab-2fa-data requests */ public function settings_data() { $method = rcube_utils::get_input_value('_method', rcube_utils::INPUT_POST); if ($driver = $this->get_driver($method)) { $data = array('method' => $method, 'id' => $driver->id); foreach ($driver->props(true) as $field => $prop) { $data[$field] = $prop['text'] ?: $prop['value']; } // generate QR code for provisioning URI if (method_exists($driver, 'get_provisioning_uri')) { try { $uri = $driver->get_provisioning_uri(); $qr = new Endroid\QrCode\QrCode(); $qr->setText($uri) ->setSize(240) ->setPadding(10) ->setErrorCorrection('high') ->setForegroundColor(array('r' => 0, 'g' => 0, 'b' => 0, 'a' => 0)) ->setBackgroundColor(array('r' => 255, 'g' => 255, 'b' => 255, 'a' => 0)); $data['qrcode'] = base64_encode($qr->get()); } catch (Exception $e) { rcube::raise_error($e, true, false); } } $this->api->output->command('plugin.render_data', $data); } $this->api->output->send(); } /** * Handler for settings/plugin.kolab-2fa-verify requests */ public function settings_verify() { $method = rcube_utils::get_input_value('_method', rcube_utils::INPUT_POST); $timestamp = intval(rcube_utils::get_input_value('_timestamp', rcube_utils::INPUT_POST)); $success = false; if ($driver = $this->get_driver($method)) { $data = @json_decode(rcube_utils::get_input_value('_data', rcube_utils::INPUT_POST), true); if (is_array($data)) { foreach ($data as $key => $value) { if ($value !== '******') { $driver->$key = $value; } } } $success = $driver->verify(rcube_utils::get_input_value('_code', rcube_utils::INPUT_POST), $timestamp); $method = $driver->method; } // put session into high-security mode if ($success && !empty($_POST['_session'])) { $_SESSION['kolab_2fa_secure_mode'] = time(); } $this->api->output->command('plugin.verify_response', array( 'method' => $method, 'success' => $success, 'message' => str_replace('$method', $this->gettext($method), $this->gettext($success ? 'codeverificationpassed' : 'codeverificationfailed')) )); $this->api->output->send(); } /** * */ protected function format_props($props) { $rcmail = rcmail::get_instance(); $values = array(); foreach ($props as $key => $prop) { switch ($prop['type']) { case 'datetime': $value = $rcmail->format_date($prop['value']); break; default: $value = $prop['value']; } $values[$key] = $value; } return $values; } /** * */ protected function check_secure_mode() { $valid = ($_SESSION['kolab_2fa_secure_mode'] && $_SESSION['kolab_2fa_secure_mode'] > time() - 180); return $valid; } } \ No newline at end of file diff --git a/plugins/kolab_activesync/kolab_activesync.php b/plugins/kolab_activesync/kolab_activesync.php index 80c1052e..38791182 100644 --- a/plugins/kolab_activesync/kolab_activesync.php +++ b/plugins/kolab_activesync/kolab_activesync.php @@ -1,572 +1,572 @@ * @author Thomas Bruederli * * Copyright (C) 2011-2013, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_activesync extends rcube_plugin { public $task = 'settings'; public $urlbase; public $backend; private $rc; private $ui; private $folder_meta; private $root_meta; const ROOT_MAILBOX = 'INBOX'; const ASYNC_KEY = '/private/vendor/kolab/activesync'; /** * Plugin initialization. */ public function init() { $this->rc = rcube::get_instance(); $this->require_plugin('jqueryui'); $this->require_plugin('libkolab'); $this->register_action('plugin.activesync', array($this, 'config_view')); $this->register_action('plugin.activesync-config', array($this, 'config_frame')); $this->register_action('plugin.activesync-json', array($this, 'json_command')); $this->add_hook('settings_actions', array($this, 'settings_actions')); $this->add_hook('folder_form', array($this, 'folder_form')); $this->add_texts('localization/'); if (preg_match('/^(plugin.activesync|edit-folder|save-folder)/', $this->rc->action)) { $this->add_label('devicedeleteconfirm', 'savingdata'); $this->include_script('kolab_activesync.js'); } } /** * Adds Activesync section in Settings */ function settings_actions($args) { $args['actions'][] = array( 'action' => 'plugin.activesync', 'class' => 'activesync', 'label' => 'tabtitle', 'domain' => 'kolab_activesync', 'title' => 'activesynctitle', ); return $args; } /** * Handler for folder info/edit form (folder_form hook). * Adds ActiveSync section. */ function folder_form($args) { $mbox_imap = $args['options']['name']; // Edited folder name (empty in create-folder mode) if (!strlen($mbox_imap)) { return $args; } $devices = $this->list_devices(); // no registered devices if (empty($devices)) { return $args; } list($type, ) = explode('.', (string) kolab_storage::folder_type($mbox_imap)); if ($type && !in_array($type, array('mail', 'event', 'contact', 'task', 'note'))) { return $args; } require_once $this->home . '/kolab_activesync_ui.php'; $this->ui = new kolab_activesync_ui($this); if ($content = $this->ui->folder_options_table($mbox_imap, $devices, $type)) { $args['form']['activesync'] = array( - 'name' => rcube::Q($this->gettext('tabtitle')), + 'name' => $this->gettext('tabtitle'), 'content' => $content, ); } return $args; } /** * Handle JSON requests */ public function json_command() { $cmd = rcube_utils::get_input_value('cmd', rcube_utils::INPUT_POST); $imei = rcube_utils::get_input_value('id', rcube_utils::INPUT_POST); switch ($cmd) { case 'save': $devices = $this->list_devices(); $device = $devices[$imei]; $subscriptions = (array) rcube_utils::get_input_value('subscribed', rcube_utils::INPUT_POST); $devicealias = rcube_utils::get_input_value('devicealias', rcube_utils::INPUT_POST, true); $device['ALIAS'] = $devicealias; $err = !$this->device_update($device, $imei); if (!$err) { // iterate over folders list and update metadata if necessary // old subscriptions foreach (array_keys($this->folder_meta()) as $folder) { $err |= !$this->folder_set($folder, $imei, intval($subscriptions[$folder])); unset($subscriptions[$folder]); } // new subscription foreach ($subscriptions as $folder => $flag) { $err |= !$this->folder_set($folder, $imei, intval($flag)); } $this->rc->output->command('plugin.activesync_save_complete', array( 'success' => !$err, 'id' => $imei, 'alias' => rcube::Q($devicealias))); } if ($err) $this->rc->output->show_message($this->gettext('savingerror'), 'error'); else $this->rc->output->show_message($this->gettext('successfullysaved'), 'confirmation'); break; case 'delete': $success = $this->device_delete($imei); if ($success) { $this->rc->output->show_message($this->gettext('successfullydeleted'), 'confirmation'); $this->rc->output->command('plugin.activesync_save_complete', array( 'success' => true, 'id' => $imei, 'delete' => true)); } else $this->rc->output->show_message($this->gettext('savingerror'), 'error'); break; case 'update': $subscription = (int) rcube_utils::get_input_value('flag', rcube_utils::INPUT_POST); $folder = rcube_utils::get_input_value('folder', rcube_utils::INPUT_POST); $err = !$this->folder_set($folder, $imei, $subscription); if ($err) $this->rc->output->show_message($this->gettext('savingerror'), 'error'); else $this->rc->output->show_message($this->gettext('successfullysaved'), 'confirmation'); break; } $this->rc->output->send(); } /** * Render main UI for devices configuration */ public function config_view() { $storage = $this->rc->get_storage(); // checks if IMAP server supports any of METADATA, ANNOTATEMORE, ANNOTATEMORE2 if (!($storage->get_capability('METADATA') || $storage->get_capability('ANNOTATEMORE') || $storage->get_capability('ANNOTATEMORE2'))) { $this->rc->output->show_message($this->gettext('notsupported'), 'error'); } require_once $this->home . '/kolab_activesync_ui.php'; $this->ui = new kolab_activesync_ui($this); $this->register_handler('plugin.devicelist', array($this->ui, 'device_list')); $this->rc->output->send('kolab_activesync.config'); } /** * Render device configuration form */ public function config_frame() { $storage = $this->rc->get_storage(); // checks if IMAP server supports any of METADATA, ANNOTATEMORE, ANNOTATEMORE2 if (!($storage->get_capability('METADATA') || $storage->get_capability('ANNOTATEMORE') || $storage->get_capability('ANNOTATEMORE2'))) { $this->rc->output->show_message($this->gettext('notsupported'), 'error'); } require_once $this->home . '/kolab_activesync_ui.php'; $this->ui = new kolab_activesync_ui($this); if (!empty($_GET['_init'])) { return $this->ui->init_message(); } $this->register_handler('plugin.deviceconfigform', array($this->ui, 'device_config_form')); $this->register_handler('plugin.foldersubscriptions', array($this->ui, 'folder_subscriptions')); $imei = rcube_utils::get_input_value('_id', rcube_utils::INPUT_GPC); $devices = $this->list_devices(); if ($device = $devices[$imei]) { $this->ui->device = $device; $this->ui->device['_id'] = $imei; $this->rc->output->set_env('active_device', $imei); $this->rc->output->command('parent.enable_command','plugin.delete-device', true); } else { $this->rc->output->show_message($this->gettext('devicenotfound'), 'error'); } $this->rc->output->send('kolab_activesync.configedit'); } /** * Get list of all folders available for sync * * @return array List of mailbox folders */ public function list_folders() { $storage = $this->rc->get_storage(); return $storage->list_folders(); } /** * List known devices * * @return array Device list as hash array */ public function list_devices() { if ($this->root_meta === null) { $storage = $this->rc->get_storage(); // @TODO: consider server annotation instead of INBOX if ($meta = $storage->get_metadata(self::ROOT_MAILBOX, self::ASYNC_KEY)) { $this->root_meta = $this->unserialize_metadata($meta[self::ROOT_MAILBOX][self::ASYNC_KEY]); } else { $this->root_meta = array(); } } if (!empty($this->root_meta['DEVICE']) && is_array($this->root_meta['DEVICE'])) { return $this->root_meta['DEVICE']; } return array(); } /** * Getter for folder metadata * * @return array Hash array with meta data for each folder */ public function folder_meta() { if (!isset($this->folder_meta)) { $this->folder_meta = array(); $storage = $this->rc->get_storage(); // get folders activesync config $folderdata = $storage->get_metadata("*", self::ASYNC_KEY); foreach ($folderdata as $folder => $meta) { if ($asyncdata = $meta[self::ASYNC_KEY]) { if ($metadata = $this->unserialize_metadata($asyncdata)) { $this->folder_meta[$folder] = $metadata; } } } } return $this->folder_meta; } /** * Sets ActiveSync subscription flag on a folder * * @param string $name Folder name (UTF7-IMAP) * @param string $deviceid Device identifier * @param int $flag Flag value (0|1|2) */ public function folder_set($name, $deviceid, $flag) { if (empty($deviceid)) { return false; } // get folders activesync config $metadata = $this->folder_meta(); $metadata = $metadata[$name]; if ($flag) { if (empty($metadata)) { $metadata = array(); } if (empty($metadata['FOLDER'])) { $metadata['FOLDER'] = array(); } if (empty($metadata['FOLDER'][$deviceid])) { $metadata['FOLDER'][$deviceid] = array(); } // Z-Push uses: // 1 - synchronize, no alarms // 2 - synchronize with alarms $metadata['FOLDER'][$deviceid]['S'] = $flag; } if (!$flag) { unset($metadata['FOLDER'][$deviceid]['S']); if (empty($metadata['FOLDER'][$deviceid])) { unset($metadata['FOLDER'][$deviceid]); } if (empty($metadata['FOLDER'])) { unset($metadata['FOLDER']); } if (empty($metadata)) { $metadata = null; } } // Return if nothing's been changed if (!self::data_array_diff($this->folder_meta[$name], $metadata)) { return true; } $this->folder_meta[$name] = $metadata; $storage = $this->rc->get_storage(); return $storage->set_metadata($name, array( self::ASYNC_KEY => $this->serialize_metadata($metadata))); } /** * Device update * * @param array $device Device data * @param string $id Device ID * * @return bool True on success, False on failure */ public function device_update($device, $id) { $devices_list = $this->list_devices(); $old_device = $devices_list[$id]; if (!$old_device) { return false; } // Do nothing if nothing is changed if (!self::data_array_diff($old_device, $device)) { return true; } $device = array_merge($old_device, $device); $metadata = $this->root_meta; $metadata['DEVICE'][$id] = $device; $metadata = array(self::ASYNC_KEY => $this->serialize_metadata($metadata)); $storage = $this->rc->get_storage(); $result = $storage->set_metadata(self::ROOT_MAILBOX, $metadata); if ($result) { // Update local cache $this->root_meta['DEVICE'][$id] = $device; } return $result; } /** * Device delete. * * @param string $id Device ID * * @return bool True on success, False on failure */ public function device_delete($id) { $devices_list = $this->list_devices(); $old_device = $devices_list[$id]; if (!$old_device) { return false; } unset($this->root_meta['DEVICE'][$id], $this->root_meta['FOLDER'][$id]); if (empty($this->root_meta['DEVICE'])) { unset($this->root_meta['DEVICE']); } if (empty($this->root_meta['FOLDER'])) { unset($this->root_meta['FOLDER']); } $metadata = $this->serialize_metadata($this->root_meta); $metadata = array(self::ASYNC_KEY => $metadata); $storage = $this->rc->get_storage(); // update meta data $result = $storage->set_metadata(self::ROOT_MAILBOX, $metadata); if ($result) { // remove device annotation for every folder foreach ($this->folder_meta() as $folder => $meta) { // skip root folder (already handled above) if ($folder == self::ROOT_MAILBOX) continue; if (!empty($meta['FOLDER']) && isset($meta['FOLDER'][$id])) { unset($meta['FOLDER'][$id]); if (empty($meta['FOLDER'])) { unset($this->folder_meta[$folder]['FOLDER']); unset($meta['FOLDER']); } if (empty($meta)) { unset($this->folder_meta[$folder]); $meta = null; } $metadata = array(self::ASYNC_KEY => $this->serialize_metadata($meta)); $res = $storage->set_metadata($folder, $metadata); if ($res && $meta) { $this->folder_meta[$folder] = $meta; } } } // remove device data from syncroton database $db = $this->rc->get_dbh(); $table = $db->table_name('syncroton_device'); if (in_array($table, $db->list_tables())) { $db->query("DELETE FROM $table WHERE owner_id = ? AND deviceid = ?", $this->rc->user->ID, $id); } } return $result; } /** * Device information (from syncroton database) * * @param string $id Device ID * * @return array Device data */ public function device_info($id) { $db = $this->rc->get_dbh(); $table = $db->table_name('syncroton_device'); if (in_array($table, $db->list_tables())) { $fields = array('devicetype', 'acsversion', 'useragent', 'friendlyname', 'os', 'oslanguage', 'phonenumber'); $result = $db->query("SELECT " . $db->array2list($fields, 'ident') . " FROM $table WHERE owner_id = ? AND id = ?", $this->rc->user->ID, $id); if ($result && ($sql_arr = $db->fetch_assoc($result))) { return $sql_arr; } } } /** * Helper method to decode saved IMAP metadata */ private function unserialize_metadata($str) { if (!empty($str)) { $data = @json_decode($str, true); return $data; } return null; } /** * Helper method to encode IMAP metadata for saving */ private function serialize_metadata($data) { if (!empty($data) && is_array($data)) { $data = json_encode($data); return $data; } return null; } /** * Compares two arrays * * @param array $array1 * @param array $array2 * * @return bool True if arrays differs, False otherwise */ private static function data_array_diff($array1, $array2) { if (!is_array($array1) || !is_array($array2)) { return $array1 != $array2; } if (count($array1) != count($array2)) { return true; } foreach ($array1 as $key => $val) { if (!array_key_exists($key, $array2)) { return true; } if ($val !== $array2[$key]) { return true; } } return false; } } diff --git a/plugins/kolab_activesync/kolab_activesync_ui.php b/plugins/kolab_activesync/kolab_activesync_ui.php index 4b491ada..4959e4a9 100644 --- a/plugins/kolab_activesync/kolab_activesync_ui.php +++ b/plugins/kolab_activesync/kolab_activesync_ui.php @@ -1,277 +1,299 @@ * @author Aleksander Machniak * * Copyright (C) 2011-2013, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_activesync_ui { private $rc; private $plugin; public $device = array(); const SETUP_URL = 'http://docs.kolab.org/client-configuration'; public function __construct($plugin) { $this->plugin = $plugin; $this->rc = rcube::get_instance(); $skin_path = $this->plugin->local_skin_path() . '/'; $this->skin_path = 'plugins/kolab_activesync/' . $skin_path; - $this->plugin->include_stylesheet($skin_path . 'config.css'); + $this->plugin->include_stylesheet($skin_path . 'config.css', true); } public function device_list($attrib = array()) { $attrib += array('id' => 'devices-list'); $devices = $this->plugin->list_devices(); $table = new html_table(); foreach ($devices as $id => $device) { $name = $device['ALIAS'] ? $device['ALIAS'] : $id; $table->add_row(array('id' => 'rcmrow' . $id)); - $table->add(null, html::span('devicealias', rcube::Q($name)) . html::span('devicetype', rcube::Q($device['TYPE']))); + $table->add(null, html::span('devicealias', rcube::Q($name)) + . ' ' . html::span('devicetype secondary', rcube::Q($device['TYPE']))); } $this->rc->output->add_gui_object('devicelist', $attrib['id']); $this->rc->output->set_env('devicecount', count($devices)); $this->rc->output->include_script('list.js'); return $table->show($attrib); } public function device_config_form($attrib = array()) { $table = new html_table(array('cols' => 2)); $field_id = 'config-device-alias'; $input = new html_inputfield(array('name' => 'devicealias', 'id' => $field_id, 'size' => 40)); $table->add('title', html::label($field_id, $this->plugin->gettext('devicealias'))); $table->add(null, $input->show($this->device['ALIAS'] ? $this->device['ALIAS'] : $this->device['_id'])); // read-only device information $info = $this->plugin->device_info($this->device['ID']); if (!empty($info)) { foreach ($info as $key => $value) { if ($value) { - $table->add('title', rcube::Q($this->plugin->gettext($key))); + $table->add('title', html::label(null, rcube::Q($this->plugin->gettext($key)))); $table->add(null, rcube::Q($value)); } } } if ($attrib['form']) { $this->rc->output->add_gui_object('editform', $attrib['form']); } return $table->show($attrib); } public function folder_subscriptions($attrib = array()) { - if (!$attrib['id']) + if (!$attrib['id']) { $attrib['id'] = 'foldersubscriptions'; + } // group folders by type (show only known types) $folder_groups = array('mail' => array(), 'contact' => array(), 'event' => array(), 'task' => array(), 'note' => array()); $folder_types = kolab_storage::folders_typedata(); + $use_fieldsets = rcube_utils::get_boolean($attrib['use-fieldsets']); $imei = $this->device['_id']; $subscribed = array(); if ($imei) { $folder_meta = $this->plugin->folder_meta(); } foreach ($this->plugin->list_folders() as $folder) { if ($folder_types[$folder]) { list($type, ) = explode('.', $folder_types[$folder]); } else { $type = 'mail'; } if (is_array($folder_groups[$type])) { $folder_groups[$type][] = $folder; if (!empty($folder_meta) && ($meta = $folder_meta[$folder]) && $meta['FOLDER'] && $meta['FOLDER'][$imei]['S'] ) { $subscribed[$folder] = intval($meta['FOLDER'][$imei]['S']); } } } // build block for every folder type foreach ($folder_groups as $type => $group) { if (empty($group)) { continue; } + $attrib['type'] = $type; - $html .= html::div('subscriptionblock', - html::tag('h3', $type, $this->plugin->gettext($type)) . - $this->folder_subscriptions_block($group, $attrib, $subscribed)); + $table = $this->folder_subscriptions_block($group, $attrib, $subscribed); + $label = $this->plugin->gettext($type); + + if ($use_fieldsets) { + $html .= html::tag('fieldset', 'subscriptionblock', html::tag('legend', $type, $label) . $table); + } + else { + $html .= html::div('subscriptionblock', html::tag('h3', $type, $label) . $table); + } } $this->rc->output->add_gui_object('subscriptionslist', $attrib['id']); return html::div($attrib, $html); } public function folder_subscriptions_block($a_folders, $attrib, $subscribed) { $alarms = ($attrib['type'] == 'event' || $attrib['type'] == 'task'); $table = new html_table(array('cellspacing' => 0)); - $table->add_header(array('class' => 'subscription', 'title' => $this->plugin->gettext('synchronize'), 'tabindex' => 0), + $table->add_header(array( + 'class' => 'subscription checkbox-cell', + 'title' => $this->plugin->gettext('synchronize'), + 'tabindex' => 0 + ), $attrib['syncicon'] ? html::img(array('src' => $this->skin_path . $attrib['syncicon'])) : - $this->plugin->gettext('synchronize')); + $this->plugin->gettext('synchronize') + ); + if ($alarms) { - $table->add_header(array('class' => 'alarm', 'title' => $this->plugin->gettext('withalarms'), 'tabindex' => 0), + $table->add_header(array( + 'class' => 'alarm checkbox-cell', + 'title' => $this->plugin->gettext('withalarms'), + 'tabindex' => 0 + ), $attrib['alarmicon'] ? html::img(array('src' => $this->skin_path . $attrib['alarmicon'])) : - $this->plugin->gettext('withalarms')); + $this->plugin->gettext('withalarms') + ); } + $table->add_header('foldername', $this->plugin->gettext('folder')); $checkbox_sync = new html_checkbox(array('name' => 'subscribed[]', 'class' => 'subscription')); $checkbox_alarm = new html_checkbox(array('name' => 'alarm[]', 'class' => 'alarm')); $names = array(); foreach ($a_folders as $folder) { $foldername = $origname = preg_replace('/^INBOX »\s+/', '', kolab_storage::object_prettyname($folder)); // find folder prefix to truncate (the same code as in kolab_addressbook plugin) for ($i = count($names)-1; $i >= 0; $i--) { if (strpos($foldername, $names[$i].' » ') === 0) { $length = strlen($names[$i].' » '); $prefix = substr($foldername, 0, $length); $count = count(explode(' » ', $prefix)); $foldername = str_repeat('  ', $count-1) . '» ' . substr($foldername, $length); break; } } $folder_id = 'rcmf' . rcube_utils::html_identifier($folder); $names[] = $origname; $classes = array('mailbox'); if ($folder_class = $this->rc->folder_classname($folder)) { $foldername = html::quote($this->rc->gettext($folder_class)); $classes[] = $folder_class; } $table->add_row(); - $table->add('subscription', $checkbox_sync->show( + $table->add('subscription checkbox-cell', $checkbox_sync->show( !empty($subscribed[$folder]) ? $folder : null, array('value' => $folder, 'id' => $folder_id))); if ($alarms) { - $table->add('alarm', $checkbox_alarm->show( + $table->add('alarm checkbox-cell', $checkbox_alarm->show( intval($subscribed[$folder]) > 1 ? $folder : null, array('value' => $folder, 'id' => $folder_id.'_alarm'))); } $table->add(join(' ', $classes), html::label($folder_id, $foldername)); } return $table->show(); } public function folder_options_table($folder_name, $devices, $type) { $alarms = $type == 'event' || $type == 'task'; $meta = $this->plugin->folder_meta(); $folder_data = (array) ($meta[$folder_name] ? $meta[$folder_name]['FOLDER'] : null); $table = new html_table(array('cellspacing' => 0, 'id' => 'folder-sync-options', 'class' => 'records-table')); // table header $table->add_header(array('class' => 'device'), $this->plugin->gettext('devicealias')); $table->add_header(array('class' => 'subscription'), $this->plugin->gettext('synchronize')); if ($alarms) { $table->add_header(array('class' => 'alarm'), $this->plugin->gettext('withalarms')); } // table records foreach ($devices as $id => $device) { $info = $this->plugin->device_info($device['ID']); $name = $id; $title = ''; $checkbox = new html_checkbox(array('name' => "_subscriptions[$id]", 'value' => 1, 'onchange' => 'return activesync_object.update_sync_data(this)')); if (!empty($info)) { $_name = trim($info['friendlyname'] . ' ' . $info['os']); $title = $info['useragent']; if ($_name) { $name .= " ($_name)"; } } $table->add_row(); $table->add(array('class' => 'device', 'title' => $title), $name); - $table->add('subscription', $checkbox->show(!empty($folder_data[$id]['S']) ? 1 : 0)); + $table->add('subscription checkbox-cell', $checkbox->show(!empty($folder_data[$id]['S']) ? 1 : 0)); if ($alarms) { $checkbox_alarm = new html_checkbox(array('name' => "_alarms[$id]", 'value' => 1, 'onchange' => 'return activesync_object.update_sync_data(this)')); - $table->add('alarm', $checkbox_alarm->show($folder_data[$id]['S'] > 1 ? 1 : 0)); + $table->add('alarm checkbox-cell', $checkbox_alarm->show($folder_data[$id]['S'] > 1 ? 1 : 0)); } } return $table->show(); } /** * Displays initial page (when no devices are registered) */ function init_message() { $this->plugin->load_config(); $this->rc->output->add_handlers(array( 'initmessage' => array($this, 'init_message_content') )); $this->rc->output->send('kolab_activesync.configempty'); } /** * Handler for initmessage template object */ function init_message_content() { $url = $this->rc->config->get('activesync_setup_url', self::SETUP_URL); $vars = array('url' => $url); $msg = $this->plugin->gettext(array('name' => 'nodevices', 'vars' => $vars)); return $msg; } } diff --git a/plugins/kolab_activesync/localization/en_US.inc b/plugins/kolab_activesync/localization/en_US.inc index 683488f4..01d6f47f 100644 --- a/plugins/kolab_activesync/localization/en_US.inc +++ b/plugins/kolab_activesync/localization/en_US.inc @@ -1,35 +1,35 @@
In order to register a device, please connect it to the server first, using instructions from this page. Afterwards the device should become available for configuration here.'; $labels['savingdata'] = 'Saving data...'; $labels['savingerror'] = 'Failed to save configuration'; $labels['notsupported'] = 'Your server does not support metadata/annotations'; $labels['devicedeleteconfirm'] = 'Do you really want to delete the configuration for this device?'; $labels['successfullydeleted'] = 'The device configuration was successfully removed'; $labels['devicenotfound'] = 'Unable to read device configuration'; $labels['devicetype'] = 'Device type'; $labels['acsversion'] = 'Protocol version'; $labels['useragent'] = 'User agent'; $labels['friendlyname'] = 'Friendly name'; $labels['os'] = 'Operating system'; $labels['oslanguage'] = 'OS language'; $labels['phonenumber'] = 'Phone number'; $labels['arialabeldeviceframe'] = 'Device synchronization settings form'; ?> diff --git a/plugins/kolab_activesync/skins/elastic/templates/config.html b/plugins/kolab_activesync/skins/elastic/templates/config.html new file mode 100644 index 00000000..af9d54d9 --- /dev/null +++ b/plugins/kolab_activesync/skins/elastic/templates/config.html @@ -0,0 +1,39 @@ + + + + +

+ + +
+
+ + + + +
+
+ +
+ +
+ + +
+

+ + +
+ + diff --git a/plugins/kolab_activesync/skins/elastic/templates/configedit.html b/plugins/kolab_activesync/skins/elastic/templates/configedit.html new file mode 100644 index 00000000..a1f1284e --- /dev/null +++ b/plugins/kolab_activesync/skins/elastic/templates/configedit.html @@ -0,0 +1,23 @@ + + +

+ +
+
+
+ + +
+
+ + +
+
+
+ +
+ +
+ + diff --git a/plugins/kolab_activesync/skins/elastic/templates/configempty.html b/plugins/kolab_activesync/skins/elastic/templates/configempty.html new file mode 100644 index 00000000..047b1074 --- /dev/null +++ b/plugins/kolab_activesync/skins/elastic/templates/configempty.html @@ -0,0 +1,11 @@ + + +

+ +
+
+ +
+
+ + diff --git a/plugins/kolab_delegation/kolab_delegation.js b/plugins/kolab_delegation/kolab_delegation.js index 652427a2..f6e320b6 100644 --- a/plugins/kolab_delegation/kolab_delegation.js +++ b/plugins/kolab_delegation/kolab_delegation.js @@ -1,341 +1,336 @@ /** * Client scripts for the Kolab Delegation configuration utitlity * * @author Aleksander Machniak * @author Thomas Bruederli * * @licstart The following is the entire license notice for the * JavaScript code in this file. * * Copyright (C) 2011-2016, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . * * @licend The above is the entire license notice * for the JavaScript code in this file. */ window.rcmail && rcmail.addEventListener('init', function(evt) { if (rcmail.env.task == 'mail' || rcmail.env.task == 'calendar' || rcmail.env.task == 'tasks') { // set delegator context for calendar/tasklist requests on invitation message rcmail.addEventListener('requestcalendar/event', function(o) { rcmail.event_delegator_request(o); }) .addEventListener('requestcalendar/mailimportitip', function(o) { rcmail.event_delegator_request(o); }) .addEventListener('requestcalendar/itip-status', function(o) { rcmail.event_delegator_request(o); }) .addEventListener('requestcalendar/itip-remove', function(o) { rcmail.event_delegator_request(o); }) .addEventListener('requesttasks/task', function(o) { rcmail.event_delegator_request(o); }) .addEventListener('requesttasks/mailimportitip', function(o) { rcmail.event_delegator_request(o); }) .addEventListener('requesttasks/itip-status', function(o) { rcmail.event_delegator_request(o); }) .addEventListener('requesttasks/itip-remove', function(o) { rcmail.event_delegator_request(o); }); // Calendar UI if (rcmail.env.delegators && window.rcube_calendar_ui) { rcmail.calendar_identity_init('calendar'); // delegator context for calendar event form rcmail.addEventListener('calendar-event-init', function(o) { return rcmail.calendar_event_init(o, 'calendar'); }); // change organizer identity on calendar folder change $('#edit-calendar').change(function() { rcmail.calendar_folder_change(this); }); } // Tasks UI else if (rcmail.env.delegators && window.rcube_tasklist_ui) { rcmail.calendar_identity_init('tasklist'); // delegator context for task form rcmail.addEventListener('tasklist-task-init', function(o) { return rcmail.calendar_event_init(o, 'tasklist'); }); // change organizer identity on tasks folder change $('#taskedit-tasklist').change(function() { rcmail.calendar_folder_change(this); }); } } else if (rcmail.env.task != 'settings') return; if (/^plugin.delegation/.test(rcmail.env.action)) { rcmail.addEventListener('plugin.delegate_save_complete', function(e) { rcmail.delegate_save_complete(e); }); if (rcmail.gui_objects.delegatelist) { rcmail.delegatelist = new rcube_list_widget(rcmail.gui_objects.delegatelist, { multiselect:true, draggable:false, keyboard:true }); rcmail.delegatelist.addEventListener('select', function(o) { rcmail.select_delegate(o); }) .init(); rcmail.enable_command('delegate-add', true); } else { rcmail.enable_command('delegate-save', true); var input = $('#delegate'); // delegate autocompletion if (input.length) { rcmail.init_address_input_events(input, {action: 'settings/plugin.delegation-autocomplete'}); rcmail.env.recipients_delimiter = ''; input.focus(); } // folders list $('input.write').change(function(e) { if (this.checked) $('input.read', this.parentNode.parentNode).prop('checked', true); }); $('input.read').change(function(e) { if (!this.checked) $('input.write', this.parentNode.parentNode).prop('checked', false); }); var fn = function(elem) { var classname = elem.className, list = $(elem).closest('table').find('input.' + classname), check = list.not(':checked').length > 0; list.prop('checked', check).change(); }; $('th.read,th.write').click(function() { fn(this); }) .keydown(function(e) { if (e.which == 13 || e.which == 32) fn(this); }); } } }); // delegates list onclick even handler rcube_webmail.prototype.select_delegate = function(list) { this.env.active_delegate = list.get_single_selection(); if (this.env.active_delegate) this.delegate_select(this.env.active_delegate); else if (this.env.contentframe) this.show_contentframe(false); }; // select delegate rcube_webmail.prototype.delegate_select = function(id) { var win, target = window, url = '&_action=plugin.delegation'; if (id) url += '&_id='+urlencode(id); else { this.show_contentframe(false); return; } if (win = this.get_frame_window(this.env.contentframe)) { target = win; url += '&_framed=1'; } if (String(target.location.href).indexOf(url) >= 0) this.show_contentframe(true); else this.location_href(this.env.comm_path+url, target, true); }; // display new delegate form rcube_webmail.prototype.delegate_add = function() { var win, target = window, url = '&_action=plugin.delegation'; this.delegatelist.clear_selection(); this.env.active_delegate = null; this.show_contentframe(false); if (win = this.get_frame_window(this.env.contentframe)) { target = win; url += '&_framed=1'; } this.location_href(this.env.comm_path+url, target, true); }; // handler for delete commands rcube_webmail.prototype.delegate_delete = function() { if (!this.env.active_delegate) return; - var $dialog = $("#delegate-delete-dialog").addClass('uidialog'), - buttons = {}; - - buttons[this.gettext('no', 'kolab_delegation')] = function() { - $dialog.dialog('close'); - }; - buttons[this.gettext('yes', 'kolab_delegation')] = function() { - $dialog.dialog('close'); - var lock = rcmail.set_busy(true, 'kolab_delegation.savingdata'); - rcmail.http_post('plugin.delegation-delete', {id: rcmail.env.active_delegate, - acl: $("#delegate-delete-dialog input:checked").length}, lock); - } + var content = $("#delegate-delete-dialog").addClass('uidialog').clone(), + title = this.gettext('deleteconfirm', 'kolab_delegation'), + save_func = function() { + var lock = rcmail.set_busy(true, 'kolab_delegation.savingdata'), + props = {id: rcmail.env.active_delegate, acl: $("#delegate-delete-dialog input:checked").length}; + rcmail.http_post('plugin.delegation-delete', props, lock); + return true; + }, + opts = { + resizable: false, + closeOnEscape: true, + width: 400, + button: 'kolab_delegation.yes', + cancel_button: 'kolab_delegation.no' + }; // open jquery UI dialog - $dialog.dialog({ - modal: true, - resizable: false, - closeOnEscape: true, - title: this.gettext('deleteconfirm', 'kolab_delegation'), - close: function() { $dialog.dialog('destroy').hide(); }, - buttons: buttons, - width: 400 - }).show(); + this.simple_dialog(content, title, save_func, opts); }; // submit delegate form to the server rcube_webmail.prototype.delegate_save = function() { var data = {id: this.env.active_delegate}, lock = this.set_busy(true, 'kolab_delegation.savingdata'); // new delegate if (!data.id) { data.newid = $('#delegate').val().replace(/(^\s+|[\s,]+$)/, ''); if (data.newid.match(/\s*\(([^)]+)\)$/)) data.newid = RegExp.$1; } data.folders = {}; $('input.read').each(function(i, elem) { data.folders[elem.value] = this.checked ? 1 : 0; }); $('input.write:checked').each(function(i, elem) { data.folders[elem.value] = 2; }); this.http_post('plugin.delegation-save', data, lock); }; // callback function when saving/deleting has completed successfully rcube_webmail.prototype.delegate_save_complete = function(p) { // delegate created if (p.created) { var input = $('#delegate'), row = $(''), rc = this.is_framed() ? parent.rcmail : this; // remove delegate input input.parent().append($('').text(p.name)); input.remove(); // add delegate row to the list row.attr('id', 'rcmrow'+p.created); $('td', row).text(p.name); rc.delegatelist.insert_row(row.get(0)); rc.delegatelist.highlight_row(p.created); this.env.active_delegate = p.created; rc.env.active_delegate = p.created; rc.enable_command('delegate-delete', true); } // delegate updated else if (p.updated) { // do nothing } // delegate deleted else if (p.deleted) { this.env.active_delegate = null; this.delegate_select(); this.delegatelist.remove_row(p.deleted); this.enable_command('delegate-delete', false); } }; rcube_webmail.prototype.event_delegator_request = function(data) { if (!this.env.delegator_context) return; if (typeof data === 'object') data._context = this.env.delegator_context; else data += '&_context=' + this.env.delegator_context; return data; }; // callback for calendar event/task form initialization rcube_webmail.prototype.calendar_event_init = function(data, type) { var folder = data.o[type == 'calendar' ? 'calendar' : 'list'] // set identity for delegator context this.env[type + '_settings'].identity = this.calendar_folder_delegator(folder, type); }; // returns delegator's identity data according to selected calendar/tasks folder rcube_webmail.prototype.calendar_folder_delegator = function(folder, type) { var d, delegator, settings = this.env[type + '_settings'], list = this.env[type == 'calendar' ? 'calendars' : 'tasklists']; // derive delegator from the calendar owner property if (list[folder] && list[folder].owner) { delegator = list[folder].owner.replace(/@.+$/, ''); } if (delegator && (d = this.env.delegators[delegator])) { // find delegator's identity id if (!d.identity_id) $.each(settings.identities, function(i, v) { if (d.email == v) { d.identity_id = i; return false; } }); d.uid = delegator; } else d = this.env.original_identity; this.env.delegator_context = d.uid; return d; }; // handler for calendar/tasklist folder change rcube_webmail.prototype.calendar_folder_change = function(element) { var folder = $(element).val(), type = element.id.indexOf('task') > -1 ? 'tasklist' : 'calendar', sname = type + '_settings', select = $('#edit-identities-list'), old = this.env[sname].identity; this.env[sname].identity = this.calendar_folder_delegator(folder, type); // change organizer identity in identity selector if (select.length && old != this.env[sname].identity) { var id = this.env[sname].identity.identity_id; select.val(id || select.find('option').first().val()).change(); } }; // modify default identity of the user rcube_webmail.prototype.calendar_identity_init = function(type) { var identity = this.env[type + '_settings'].identity, emails = identity.emails.split(';'); // remove delegators' emails from list of emails of the current user emails = $.map(emails, function(v) { for (var n in rcmail.env.delegators) if (rcmail.env.delegators[n].emails.indexOf(';'+v) > -1) return null; return v; }); identity.emails = emails.join(';'); this.env.original_identity = identity; }; diff --git a/plugins/kolab_delegation/kolab_delegation.php b/plugins/kolab_delegation/kolab_delegation.php index 85e670bb..306564aa 100644 --- a/plugins/kolab_delegation/kolab_delegation.php +++ b/plugins/kolab_delegation/kolab_delegation.php @@ -1,568 +1,576 @@ * @author Thomas Bruederli * * Copyright (C) 2011-2012, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_delegation extends rcube_plugin { public $task = 'login|mail|settings|calendar|tasks'; private $rc; private $engine; /** * Plugin initialization. */ public function init() { $this->rc = rcube::get_instance(); $this->require_plugin('libkolab'); $this->require_plugin('kolab_auth'); // on-login delegation initialization $this->add_hook('login_after', array($this, 'login_hook')); // on-check-recent delegation support $this->add_hook('check_recent', array($this, 'check_recent_hook')); // on-message-send delegation support $this->add_hook('message_before_send', array($this, 'message_before_send')); // delegation support in Calendar and Tasklist plugins $this->add_hook('message_load', array($this, 'message_load')); $this->add_hook('calendar_user_emails', array($this, 'calendar_user_emails')); $this->add_hook('calendar_list_filter', array($this, 'calendar_list_filter')); $this->add_hook('calendar_load_itip', array($this, 'calendar_load_itip')); $this->add_hook('tasklist_list_filter', array($this, 'tasklist_list_filter')); // delegation support in kolab_auth plugin $this->add_hook('kolab_auth_emails', array($this, 'kolab_auth_emails')); if ($this->rc->task == 'settings') { // delegation management interface $this->register_action('plugin.delegation', array($this, 'controller_ui')); $this->register_action('plugin.delegation-delete', array($this, 'controller_action')); $this->register_action('plugin.delegation-save', array($this, 'controller_action')); $this->register_action('plugin.delegation-autocomplete', array($this, 'controller_action')); $this->add_hook('settings_actions', array($this, 'settings_actions')); if ($this->rc->output->type == 'html' && ($this->rc->action == 'plugin.delegation' || empty($_REQUEST['_framed'])) ) { $this->add_texts('localization/', array('deleteconfirm', 'savingdata', 'yes', 'no')); if ($this->rc->action == 'plugin.delegation') { $this->include_script('kolab_delegation.js'); } $this->skin_path = $this->local_skin_path(); - $this->include_stylesheet($this->skin_path . '/style.css'); + $this->include_stylesheet($this->skin_path . '/style.css', true); } } // Calendar/Tasklist plugin UI bindings else if (($this->rc->task == 'calendar' || $this->rc->task == 'tasks') && empty($_REQUEST['_framed']) ) { if ($this->rc->output->type == 'html') { $this->calendar_ui(); } } } /** * Adds Delegation section in Settings */ function settings_actions($args) { $args['actions'][] = array( 'action' => 'plugin.delegation', 'class' => 'delegation', 'label' => 'tabtitle', 'domain' => 'kolab_delegation', 'title' => 'delegationtitle', ); return $args; } /** * Engine object getter */ private function engine() { if (!$this->engine) { require_once $this->home . '/kolab_delegation_engine.php'; $this->load_config(); $this->engine = new kolab_delegation_engine(); } return $this->engine; } /** * On-login action */ public function login_hook($args) { // Manage (create) identities for delegator's email addresses // and subscribe to delegator's folders. Also remove identities // after delegation is removed $engine = $this->engine(); $engine->delegation_init(); return $args; } /** * Check-recent action */ public function check_recent_hook($args) { // Checking for new messages shall be extended to Inbox folders of all // delegators if 'check_all_folders' is set to false. if ($this->rc->task != 'mail') { return $args; } if (!empty($args['all'])) { return $args; } if (empty($_SESSION['delegators'])) { return $args; } $storage = $this->rc->get_storage(); $other_ns = $storage->get_namespace('other'); $folders = $storage->list_folders_subscribed('', '*', 'mail'); foreach (array_keys($_SESSION['delegators']) as $uid) { foreach ($other_ns as $ns) { $folder = $ns[0] . $uid; if (in_array($folder, $folders) && !in_array($folder, $args['folders'])) { $args['folders'][] = $folder; } } } return $args; } /** * Mail send action */ public function message_before_send($args) { // Checking headers of email being send, we'll add // Sender: header if mail is send on behalf of someone else if (!empty($_SESSION['delegators'])) { $engine = $this->engine(); $engine->delegator_delivery_filter($args); } return $args; } /** * E-mail message loading action */ public function message_load($args) { // This is a place where we detect delegate context // So we can handle event invitations on behalf of delegator // @TODO: should we do this only in delegators' folders? // skip invalid messages or Kolab objects (for better performance) if (empty($args['object']->headers) || $args['object']->headers->get('x-kolab-type', false)) { return $args; } $engine = $this->engine(); $context = $engine->delegator_context_from_message($args['object']); if ($context) { $this->rc->output->set_env('delegator_context', $context); $this->include_script('kolab_delegation.js'); } return $args; } /** * calendar::get_user_emails() handler */ public function calendar_user_emails($args) { // In delegator context we'll use delegator's addresses // instead of current user addresses if (!empty($_SESSION['delegators'])) { $engine = $this->engine(); $engine->delegator_emails_filter($args); } return $args; } /** * calendar_driver::list_calendars() handler */ public function calendar_list_filter($args) { // In delegator context we'll use delegator's folders // instead of current user folders if (!empty($_SESSION['delegators'])) { $engine = $this->engine(); $engine->delegator_folder_filter($args, 'calendars'); } return $args; } /** * tasklist_driver::get_lists() handler */ public function tasklist_list_filter($args) { // In delegator context we'll use delegator's folders // instead of current user folders if (!empty($_SESSION['delegators'])) { $engine = $this->engine(); $engine->delegator_folder_filter($args, 'tasklists'); } return $args; } /** * calendar::load_itip() handler */ public function calendar_load_itip($args) { // In delegator context we'll use delegator's address/name // for invitation responses if (!empty($_SESSION['delegators'])) { $engine = $this->engine(); $engine->delegator_identity_filter($args); } return $args; } /** * Delegation support in Calendar/Tasks plugin UI */ public function calendar_ui() { // Initialize handling of delegators' identities in event form if (!empty($_SESSION['delegators'])) { $engine = $this->engine(); $this->rc->output->set_env('namespace', $engine->namespace_js()); $this->rc->output->set_env('delegators', $engine->list_delegators_js()); $this->include_script('kolab_delegation.js'); } } /** * Delegation support in kolab_auth plugin */ public function kolab_auth_emails($args) { // Add delegators addresses to address selector in user identity form if (!empty($_SESSION['delegators'])) { // @TODO: Consider not adding all delegator addresses to the list. // Instead add only address of currently edited identity foreach ($_SESSION['delegators'] as $emails) { $args['emails'] = array_merge($args['emails'], $emails); } $args['emails'] = array_unique($args['emails']); sort($args['emails']); } return $args; } /** * Delegation UI handler */ public function controller_ui() { // main interface (delegates list) if (empty($_REQUEST['_framed'])) { $this->register_handler('plugin.delegatelist', array($this, 'delegate_list')); $this->rc->output->include_script('list.js'); $this->rc->output->send('kolab_delegation.settings'); } // delegate frame else { $this->register_handler('plugin.delegateform', array($this, 'delegate_form')); $this->register_handler('plugin.delegatefolders', array($this, 'delegate_folders')); $this->rc->output->set_env('autocomplete_max', (int)$this->rc->config->get('autocomplete_max', 15)); $this->rc->output->set_env('autocomplete_min_length', $this->rc->config->get('autocomplete_min_length')); $this->rc->output->add_label('autocompletechars', 'autocompletemore'); $this->rc->output->send('kolab_delegation.editform'); } } /** * Delegation action handler */ public function controller_action() { $this->add_texts('localization/'); $engine = $this->engine(); // Delegate delete if ($this->rc->action == 'plugin.delegation-delete') { $id = rcube_utils::get_input_value('id', rcube_utils::INPUT_GPC); $error = $engine->delegate_delete($id, (bool) rcube_utils::get_input_value('acl', rcube_utils::INPUT_GPC)); if (!$error) { $this->rc->output->show_message($this->gettext('deletesuccess'), 'confirmation'); $this->rc->output->command('plugin.delegate_save_complete', array('deleted' => $id)); } else { $this->rc->output->show_message($this->gettext($error), 'error'); } } // Delegate add/update else if ($this->rc->action == 'plugin.delegation-save') { $id = rcube_utils::get_input_value('id', rcube_utils::INPUT_GPC); $acl = rcube_utils::get_input_value('folders', rcube_utils::INPUT_GPC); // update if ($id) { $delegate = $engine->delegate_get($id); $error = $engine->delegate_acl_update($delegate['uid'], $acl); if (!$error) { $this->rc->output->show_message($this->gettext('updatesuccess'), 'confirmation'); $this->rc->output->command('plugin.delegate_save_complete', array('updated' => $id)); } else { $this->rc->output->show_message($this->gettext($error), 'error'); } } // new else { $login = rcube_utils::get_input_value('newid', rcube_utils::INPUT_GPC); $delegate = $engine->delegate_get_by_name($login); $error = $engine->delegate_add($delegate, $acl); if (!$error) { $this->rc->output->show_message($this->gettext('createsuccess'), 'confirmation'); $this->rc->output->command('plugin.delegate_save_complete', array( 'created' => $delegate['ID'], 'name' => $delegate['name'], )); } else { $this->rc->output->show_message($this->gettext($error), 'error'); } } } // Delegate autocompletion else if ($this->rc->action == 'plugin.delegation-autocomplete') { $search = rcube_utils::get_input_value('_search', rcube_utils::INPUT_GPC, true); $reqid = rcube_utils::get_input_value('_reqid', rcube_utils::INPUT_GPC); $users = $engine->list_users($search); $this->rc->output->command('ksearch_query_results', $users, $search, $reqid); } $this->rc->output->send(); } /** * Template object of delegates list */ public function delegate_list($attrib = array()) { $attrib += array('id' => 'delegate-list'); $engine = $this->engine(); $list = $engine->list_delegates(); $table = new html_table(); // sort delegates list asort($list, SORT_LOCALE_STRING); foreach ($list as $id => $delegate) { $table->add_row(array('id' => 'rcmrow' . $id)); $table->add(null, rcube::Q($delegate)); } $this->rc->output->add_gui_object('delegatelist', $attrib['id']); $this->rc->output->set_env('delegatecount', count($list)); return $table->show($attrib); } /** * Template object of delegate form */ public function delegate_form($attrib = array()) { $engine = $this->engine(); $table = new html_table(array('cols' => 2)); $id = rcube_utils::get_input_value('_id', rcube_utils::INPUT_GPC); $field_id = 'delegate'; if ($id) { $delegate = $engine->delegate_get($id); } if ($delegate) { $input = new html_hiddenfield(array('name' => $field_id, 'id' => $field_id, 'size' => 40)); $input = rcube::Q($delegate['name']) . $input->show($id); $this->rc->output->set_env('active_delegate', $id); $this->rc->output->command('parent.enable_command','delegate-delete', true); } else { $input = new html_inputfield(array('name' => $field_id, 'id' => $field_id, 'size' => 40)); $input = $input->show(); } $table->add('title', html::label($field_id, $this->gettext('delegate'))); $table->add(null, $input); if ($attrib['form']) { $this->rc->output->add_gui_object('editform', $attrib['form']); } return $table->show($attrib); } /** * Template object of folders list */ public function delegate_folders($attrib = array()) { if (!$attrib['id']) { $attrib['id'] = 'delegatefolders'; } $engine = $this->engine(); $id = rcube_utils::get_input_value('_id', rcube_utils::INPUT_GPC); if ($id) { $delegate = $engine->delegate_get($id); } $folder_data = $engine->list_folders($delegate['uid']); + $use_fieldsets = rcube_utils::get_boolean($attrib['use-fieldsets']); $rights = array(); $folder_groups = array(); foreach ($folder_data as $folder_name => $folder) { $folder_groups[$folder['type']][] = $folder_name; $rights[$folder_name] = $folder['rights']; } // build block for every folder type foreach ($folder_groups as $type => $group) { if (empty($group)) { continue; } + $attrib['type'] = $type; - $html .= html::div('foldersblock', - html::tag('h3', $type, $this->gettext($type)) . - $this->delegate_folders_block($group, $attrib, $rights)); + $table = $this->delegate_folders_block($group, $attrib, $rights); + $label = $this->gettext($type); + + if ($use_fieldsets) { + $html .= html::tag('fieldset', 'foldersblock', html::tag('legend', $type, $label) . $table); + } + else { + $html .= html::div('foldersblock', html::tag('h3', $type, $label) . $table); + } } $this->rc->output->add_gui_object('folderslist', $attrib['id']); return html::div($attrib, $html); } /** * List of folders in specified group */ private function delegate_folders_block($a_folders, $attrib, $rights) { $path = 'plugins/kolab_delegation/' . $this->skin_path . '/'; $read_ico = $attrib['readicon'] ? html::img(array('src' => $path . $attrib['readicon'], 'title' => $this->gettext('read'))) : ''; $write_ico = $attrib['writeicon'] ? html::img(array('src' => $path . $attrib['writeicon'], 'title' => $this->gettext('write'))) : ''; $table = new html_table(array('cellspacing' => 0)); - $table->add_header(array('class' => 'read', 'title' => $this->gettext('read'), 'tabindex' => 0), $read_ico); - $table->add_header(array('class' => 'write', 'title' => $this->gettext('write'), 'tabindex' => 0), $write_ico); + $table->add_header(array('class' => 'read checkbox-cell', 'title' => $this->gettext('read'), 'tabindex' => 0), $read_ico); + $table->add_header(array('class' => 'write checkbox-cell', 'title' => $this->gettext('write'), 'tabindex' => 0), $write_ico); $table->add_header('foldername', $this->rc->gettext('folder')); $checkbox_read = new html_checkbox(array('name' => 'read[]', 'class' => 'read')); $checkbox_write = new html_checkbox(array('name' => 'write[]', 'class' => 'write')); $names = array(); foreach ($a_folders as $folder) { $foldername = $origname = preg_replace('/^INBOX »\s+/', '', kolab_storage::object_prettyname($folder)); // find folder prefix to truncate (the same code as in kolab_addressbook plugin) for ($i = count($names)-1; $i >= 0; $i--) { if (strpos($foldername, $names[$i].' » ') === 0) { $length = strlen($names[$i].' » '); $prefix = substr($foldername, 0, $length); $count = count(explode(' » ', $prefix)); $foldername = str_repeat('  ', $count-1) . '» ' . substr($foldername, $length); break; } } $folder_id = 'rcmf' . rcube_utils::html_identifier($folder); $names[] = $origname; $classes = array('mailbox'); if ($folder_class = $this->rc->folder_classname($folder)) { $foldername = html::quote($this->rc->gettext($folder_class)); $classes[] = $folder_class; } $table->add_row(); - $table->add('read', $checkbox_read->show( + $table->add('read checkbox-cell', $checkbox_read->show( $rights[$folder] >= kolab_delegation_engine::ACL_READ ? $folder : null, array('value' => $folder))); - $table->add('write', $checkbox_write->show( + $table->add('write checkbox-cell', $checkbox_write->show( $rights[$folder] >= kolab_delegation_engine::ACL_WRITE ? $folder : null, array('value' => $folder, 'id' => $folder_id))); $table->add(join(' ', $classes), html::label($folder_id, $foldername)); } return $table->show(); } } diff --git a/plugins/kolab_delegation/localization/en_US.inc b/plugins/kolab_delegation/localization/en_US.inc index 94ceb9e7..cf69e6a2 100644 --- a/plugins/kolab_delegation/localization/en_US.inc +++ b/plugins/kolab_delegation/localization/en_US.inc @@ -1,42 +1,44 @@ diff --git a/plugins/kolab_delegation/skins/elastic/templates/editform.html b/plugins/kolab_delegation/skins/elastic/templates/editform.html new file mode 100644 index 00000000..d75ee69d --- /dev/null +++ b/plugins/kolab_delegation/skins/elastic/templates/editform.html @@ -0,0 +1,23 @@ + + +

+ +
+
+
+ + +
+
+ + +
+
+
+ +
+ +
+ + diff --git a/plugins/kolab_delegation/skins/elastic/templates/settings.html b/plugins/kolab_delegation/skins/elastic/templates/settings.html new file mode 100644 index 00000000..28c9c798 --- /dev/null +++ b/plugins/kolab_delegation/skins/elastic/templates/settings.html @@ -0,0 +1,48 @@ + + + + +

+ + +
+
+ + + + +
+
+ +
+ +
+ + +
+

+ + +
+ + + +