6 Commits
5.0.0 ... 5.0.3

Author SHA1 Message Date
Laurent Dinclaux
c194661fa3 fix: prevent username flash on load and remove select max-width 2026-05-06 20:11:10 +11:00
Laurent Dinclaux
e539c60e9b feat: use primary identity display name in switcher dropdown
The account switcher dropdown previously showed the IMAP username
(typically the email address) for the primary account. Now it falls
back to the identity's display name when available, matching the
behavior of secondary accounts which use their custom label.

Priority order: global_alias session var > primary identity display
name > IMAP username.
2026-05-06 17:37:50 +11:00
Laurent Dinclaux
bea25d8396 fix: prevent migration from running on fresh installs 2026-02-27 17:50:57 +11:00
Laurent Dinclaux
b4506a0a3e fix: disable mode select until email is entered 2026-02-27 17:44:15 +11:00
Laurent Dinclaux
54b96ef059 refactor: delegated form events and harden preconfig enforcement 2026-02-27 17:12:27 +11:00
Laurent Dinclaux
7de47160b6 fix: add dropdown chevron to account switcher 2026-02-27 17:10:26 +11:00
9 changed files with 189 additions and 90 deletions

View File

@@ -86,3 +86,5 @@ CREATE TABLE IF NOT EXISTS `ident_switch`
INDEX `IX_ident_switch_iid`(`iid`), INDEX `IX_ident_switch_iid`(`iid`),
INDEX `IX_ident_switch_parent_id`(`parent_id`) INDEX `IX_ident_switch_parent_id`(`parent_id`)
); );
INSERT INTO `system` (`name`, `value`) VALUES ('ident_switch-version', '2026021000');

View File

@@ -85,3 +85,5 @@ CREATE TABLE ident_switch
CREATE INDEX IX_ident_switch_user_id ON ident_switch(user_id); CREATE INDEX IX_ident_switch_user_id ON ident_switch(user_id);
CREATE INDEX IX_ident_switch_iid ON ident_switch(iid); CREATE INDEX IX_ident_switch_iid ON ident_switch(iid);
CREATE INDEX IX_ident_switch_parent_id ON ident_switch(parent_id); CREATE INDEX IX_ident_switch_parent_id ON ident_switch(parent_id);
INSERT INTO system (name, value) VALUES ('ident_switch-version', '2026021000');

View File

@@ -84,3 +84,5 @@ CREATE TABLE ident_switch
CREATE INDEX IX_ident_switch_user_id ON ident_switch(user_id); CREATE INDEX IX_ident_switch_user_id ON ident_switch(user_id);
CREATE INDEX IX_ident_switch_iid ON ident_switch(iid); CREATE INDEX IX_ident_switch_iid ON ident_switch(iid);
CREATE INDEX IX_ident_switch_parent_id ON ident_switch(parent_id); CREATE INDEX IX_ident_switch_parent_id ON ident_switch(parent_id);
INSERT INTO system (name, value) VALUES ('ident_switch-version', '2026021000');

View File

@@ -19,62 +19,30 @@ var ident_switch_portDefaults = {
sieve: { '': 4190, tls: 4190, ssl: 4190 } sieve: { '': 4190, tls: 4190, ssl: 4190 }
}; };
$(function() { /**
* Flag to ensure delegated event handlers are registered only once.
*/
var ident_switch_delegated = false;
/**
* Initialize the identity form: apply mode visibility, preconfig, placeholders.
* Called on each form render (initial page load + AJAX identity loads).
*/
function plugin_switchIdent_init() {
// Register delegated event handlers once (survive DOM replacements)
if (!ident_switch_delegated) {
ident_switch_delegated = true;
plugin_switchIdent_bindDelegatedEvents();
}
// Apply initial mode visibility // Apply initial mode visibility
var initialMode = $("SELECT[name='_ident_switch.form.common.mode']").val() || 'primary'; var initialMode = $("SELECT[name='_ident_switch.form.common.mode']").val() || 'primary';
plugin_switchIdent_mode_onChange(initialMode); plugin_switchIdent_mode_onChange(initialMode);
plugin_switchIdent_processPreconfig();
// Bind security change handlers // Apply initial auth visibility for SMTP and Sieve
$.each(['imap', 'smtp', 'sieve'], function(i, proto) {
var secSel = "SELECT[name='_ident_switch.form." + proto + ".security']";
$(secSel).on('change', function() {
plugin_switchIdent_onSecurityChange(proto, $(this).val());
});
});
// Bind blur handlers for smart placeholder clearing
$.each(['imap', 'smtp', 'sieve'], function(i, proto) {
var portFld = $("INPUT[name='_ident_switch.form." + proto + ".port']");
portFld.on('blur', function() {
plugin_switchIdent_clearIfDefault($(this));
});
var hostFld = $("INPUT[name='_ident_switch.form." + proto + ".host']");
hostFld.on('blur', function() {
plugin_switchIdent_clearIfDefault($(this));
});
});
// IMAP host → update SMTP/Sieve host placeholders
$("INPUT[name='_ident_switch.form.imap.host']").on('change blur', function() {
var imapHost = $(this).val() || 'localhost';
$("INPUT[name='_ident_switch.form.smtp.host']").attr('placeholder', imapHost);
$("INPUT[name='_ident_switch.form.sieve.host']").attr('placeholder', imapHost);
});
// Bind auth change handlers for SMTP and Sieve custom credentials
$.each(['smtp', 'sieve'], function(i, proto) { $.each(['smtp', 'sieve'], function(i, proto) {
var authSel = "SELECT[name='_ident_switch.form." + proto + ".auth']"; var authVal = $("SELECT[name='_ident_switch.form." + proto + ".auth']").val();
$(authSel).on('change', function() { plugin_switchIdent_onAuthChange(proto, authVal);
plugin_switchIdent_onAuthChange(proto, $(this).val());
});
// Apply initial visibility
plugin_switchIdent_onAuthChange(proto, $(authSel).val());
});
// Delimiter mode handler
$("SELECT[name='_ident_switch.form.imap.delimiter_mode']").on('change', function() {
if ($(this).val() === 'manual') {
$('#ident-switch-delimiter-input').show();
} else {
$('#ident-switch-delimiter-input').hide();
$("INPUT[name='_ident_switch.form.imap.delimiter']").val('');
}
});
// Watch email field for dynamic preconfig application
$("INPUT[name='_email']").on('change blur', function() {
plugin_switchIdent_onEmailChange($(this).val());
}); });
// Set initial placeholders from current field values // Set initial placeholders from current field values
@@ -87,6 +55,72 @@ $(function() {
$("INPUT[name='_ident_switch.form.smtp.host']").attr('placeholder', initialImapHost); $("INPUT[name='_ident_switch.form.smtp.host']").attr('placeholder', initialImapHost);
$("INPUT[name='_ident_switch.form.sieve.host']").attr('placeholder', initialImapHost); $("INPUT[name='_ident_switch.form.sieve.host']").attr('placeholder', initialImapHost);
} }
// Disable mode select until a valid email is entered
var $modeSelect = $("SELECT[name='_ident_switch.form.common.mode']");
if (initialEmail && initialEmail.indexOf('@') > 0) {
$modeSelect.prop('disabled', false);
plugin_switchIdent_onEmailChange(initialEmail);
} else {
$modeSelect.prop('disabled', true);
}
}
/**
* Register delegated event handlers on the document.
* These survive DOM replacements (AJAX form reloads).
*/
function plugin_switchIdent_bindDelegatedEvents() {
// Security change handlers
$.each(['imap', 'smtp', 'sieve'], function(i, proto) {
$(document).on('change', "SELECT[name='_ident_switch.form." + proto + ".security']", function() {
plugin_switchIdent_onSecurityChange(proto, $(this).val());
});
});
// Blur handlers for smart placeholder clearing
$.each(['imap', 'smtp', 'sieve'], function(i, proto) {
$(document).on('blur', "INPUT[name='_ident_switch.form." + proto + ".port']", function() {
plugin_switchIdent_clearIfDefault($(this));
});
$(document).on('blur', "INPUT[name='_ident_switch.form." + proto + ".host']", function() {
plugin_switchIdent_clearIfDefault($(this));
});
});
// IMAP host → update SMTP/Sieve host placeholders
$(document).on('change blur', "INPUT[name='_ident_switch.form.imap.host']", function() {
var imapHost = $(this).val() || 'localhost';
$("INPUT[name='_ident_switch.form.smtp.host']").attr('placeholder', imapHost);
$("INPUT[name='_ident_switch.form.sieve.host']").attr('placeholder', imapHost);
});
// Auth change handlers for SMTP and Sieve custom credentials
$.each(['smtp', 'sieve'], function(i, proto) {
$(document).on('change', "SELECT[name='_ident_switch.form." + proto + ".auth']", function() {
plugin_switchIdent_onAuthChange(proto, $(this).val());
});
});
// Delimiter mode handler
$(document).on('change', "SELECT[name='_ident_switch.form.imap.delimiter_mode']", function() {
if ($(this).val() === 'manual') {
$('#ident-switch-delimiter-input').show();
} else {
$('#ident-switch-delimiter-input').hide();
$("INPUT[name='_ident_switch.form.imap.delimiter']").val('');
}
});
// Watch email field for dynamic preconfig application
$(document).on('change blur', "INPUT[name='_email']", function() {
plugin_switchIdent_onEmailChange($(this).val());
});
}
// Run init on initial page load
$(function() {
plugin_switchIdent_init();
}); });
/** /**
@@ -193,32 +227,43 @@ function plugin_switchIdent_onAuthChange(proto, authVal) {
} }
} }
/**
* Apply or remove preconfig readonly state on form fields.
* Reads the hidden readonly field value and enables/disables fields accordingly.
*/
function plugin_switchIdent_processPreconfig() { function plugin_switchIdent_processPreconfig() {
var disFld = $("INPUT[name='_ident_switch.form.common.readonly']"); var disFld = $("INPUT[name='_ident_switch.form.common.readonly']");
disFld.parentsUntil("TABLE", "TR").hide(); disFld.parentsUntil("TABLE", "TR").hide();
var disVal = parseInt(disFld.val(), 10) || 0; var disVal = parseInt(disFld.val(), 10) || 0;
// All fields that can be locked by preconfig
var lockedFields = [
"INPUT[name='_ident_switch.form.imap.host']",
"SELECT[name='_ident_switch.form.imap.security']",
"INPUT[name='_ident_switch.form.imap.port']",
"INPUT[name='_ident_switch.form.smtp.host']",
"SELECT[name='_ident_switch.form.smtp.security']",
"INPUT[name='_ident_switch.form.smtp.port']",
"SELECT[name='_ident_switch.form.smtp.auth']",
"INPUT[name='_ident_switch.form.smtp.username']",
"INPUT[name='_ident_switch.form.smtp.password']",
"SELECT[name='_ident_switch.form.imap.delimiter_mode']",
"INPUT[name='_ident_switch.form.imap.delimiter']",
"INPUT[name='_ident_switch.form.sieve.host']",
"SELECT[name='_ident_switch.form.sieve.security']",
"INPUT[name='_ident_switch.form.sieve.port']",
"SELECT[name='_ident_switch.form.sieve.auth']",
"INPUT[name='_ident_switch.form.sieve.username']",
"INPUT[name='_ident_switch.form.sieve.password']"
];
// Reset all to enabled first (handles navigation from readonly to non-readonly)
$.each(lockedFields, function(_, sel) { $(sel).prop("disabled", false); });
$("INPUT[name='_ident_switch.form.imap.username']").prop("disabled", false);
if (disVal > 0) { if (disVal > 0) {
$("INPUT[name='_ident_switch.form.imap.host']").prop("disabled", true); $.each(lockedFields, function(_, sel) { $(sel).prop("disabled", true); });
$("SELECT[name='_ident_switch.form.imap.security']").prop("disabled", true);
$("INPUT[name='_ident_switch.form.imap.port']").prop("disabled", true);
$("INPUT[name='_ident_switch.form.smtp.host']").prop("disabled", true);
$("SELECT[name='_ident_switch.form.smtp.security']").prop("disabled", true);
$("INPUT[name='_ident_switch.form.smtp.port']").prop("disabled", true);
$("SELECT[name='_ident_switch.form.smtp.auth']").prop("disabled", true);
$("INPUT[name='_ident_switch.form.smtp.username']").prop("disabled", true);
$("INPUT[name='_ident_switch.form.smtp.password']").prop("disabled", true);
$("SELECT[name='_ident_switch.form.imap.delimiter_mode']").prop("disabled", true);
$("INPUT[name='_ident_switch.form.imap.delimiter']").prop("disabled", true);
$("INPUT[name='_ident_switch.form.sieve.host']").prop("disabled", true);
$("SELECT[name='_ident_switch.form.sieve.security']").prop("disabled", true);
$("INPUT[name='_ident_switch.form.sieve.port']").prop("disabled", true);
$("SELECT[name='_ident_switch.form.sieve.auth']").prop("disabled", true);
$("INPUT[name='_ident_switch.form.sieve.username']").prop("disabled", true);
$("INPUT[name='_ident_switch.form.sieve.password']").prop("disabled", true);
} }
if (disVal === 2) { if (disVal === 2) {
$("INPUT[name='_ident_switch.form.imap.username']").prop("disabled", true); $("INPUT[name='_ident_switch.form.imap.username']").prop("disabled", true);
@@ -236,6 +281,9 @@ function plugin_switchIdent_onEmailChange(email) {
var domain = email.substring(atPos + 1).toLowerCase(); var domain = email.substring(atPos + 1).toLowerCase();
if (!domain) return; if (!domain) return;
// Enable mode select now that we have a valid email
$("SELECT[name='_ident_switch.form.common.mode']").prop('disabled', false);
// Update label and username placeholders to match current email // Update label and username placeholders to match current email
$("INPUT[name='_ident_switch.form.common.label']").attr('placeholder', email); $("INPUT[name='_ident_switch.form.common.label']").attr('placeholder', email);

View File

@@ -67,7 +67,6 @@ function plugin_switchIdent_addCbLarry($wrapper, $sw) {
$sw.css({ $sw.css({
'font-weight': 'bold', 'font-weight': 'bold',
'max-width': '200px',
'text-overflow': 'ellipsis' 'text-overflow': 'ellipsis'
}); });
@@ -99,14 +98,13 @@ function plugin_switchIdent_addCbElastic($wrapper, $sw) {
} }
$sw.css({ $sw.css({
'background': 'transparent', 'background-color': 'transparent',
'border': 'none', 'border': 'none',
'font-weight': 'bold', 'font-weight': 'bold',
'color': 'inherit', 'color': 'inherit',
'box-shadow': 'none', 'box-shadow': 'none',
'max-width': '200px',
'text-overflow': 'ellipsis', 'text-overflow': 'ellipsis',
'padding': '0 0.25em' 'padding': '0 1.2em 0 0.25em'
}); });
// Hide original username text and elements // Hide original username text and elements

View File

@@ -15,6 +15,44 @@
width: 100%; width: 100%;
} }
/* Account switcher <select>: hide native arrow, show SVG chevron */
.ident-switch-wrapper select {
cursor: pointer;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23ccc'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.3em center;
background-size: 10px 6px;
}
/*
* Prevent flash of original username before JS prepends the switcher.
* The JS hides these elements at DOMReady, but the CSS rules below hide them
* earlier (during initial render) — only loaded when the switcher will actually
* be shown, so users without secondary accounts keep the default username display.
*/
/* Elastic skin: switcher is prepended inside .header-title.username.
Hide the original username via visibility on the parent (affects the text
node), then re-show the wrapper. Unlike color/font-size, this preserves the
parent's flex/line-height context, keeping the wrapper vertically centered. */
.header-title.username {
visibility: hidden;
}
.header-title.username > *:not(.ident-switch-wrapper) {
display: none;
}
.header-title.username > .ident-switch-wrapper {
visibility: visible;
}
/* Larry skin: switcher is prepended next to .username inside #topline .topright */
#topline .topright > .username {
display: none;
}
/* Unread badge */ /* Unread badge */
.ident-switch-badge { .ident-switch-badge {
flex-shrink: 0; flex-shrink: 0;

View File

@@ -158,10 +158,6 @@ class ident_switch extends rcube_plugin
default => null, default => null,
}; };
if ($rc->task === 'mail') {
$this->include_stylesheet('ident_switch.css');
}
return $args; return $args;
} }
@@ -188,7 +184,9 @@ class ident_switch extends rcube_plugin
$iid = intval($iid_s); $iid = intval($iid_s);
} }
$accNames = [$_SESSION['global_alias'] ?? $rc->user->data['username']]; $primaryIdentity = $rc->user->get_identity();
$primaryName = !empty($primaryIdentity['name']) ? $primaryIdentity['name'] : null;
$accNames = [$_SESSION['global_alias'] ?? $primaryName ?? $rc->user->data['username']];
$accValues = [-1]; $accValues = [-1];
$accSelected = -1; $accSelected = -1;
$iidMap = [0 => -1]; // primary account: iid 0 → select value -1 $iidMap = [0 => -1]; // primary account: iid 0 → select value -1
@@ -216,6 +214,7 @@ class ident_switch extends rcube_plugin
return; return;
} }
$this->include_stylesheet('ident_switch.css');
$this->include_script('ident_switch-switch.js'); $this->include_script('ident_switch-switch.js');
// Pass config to JS environment // Pass config to JS environment

View File

@@ -177,6 +177,9 @@ class IdentSwitchChecker
$host = 'tls://' . $host; $host = 'tls://' . $host;
} }
$primaryIdentity = $rc->user->get_identity();
$primaryName = !empty($primaryIdentity['name']) ? $primaryIdentity['name'] : null;
return [ return [
'iid' => 0, 'iid' => 0,
'imap_host' => $host, 'imap_host' => $host,
@@ -185,7 +188,7 @@ class IdentSwitchChecker
'username' => $rc->user->data['username'], 'username' => $rc->user->data['username'],
'password' => $_SESSION['password' . $postfix], 'password' => $_SESSION['password' . $postfix],
'email' => $rc->user->data['username'], 'email' => $rc->user->data['username'],
'label' => $_SESSION['global_alias'] ?? $rc->user->data['username'], 'label' => $_SESSION['global_alias'] ?? $primaryName ?? $rc->user->data['username'],
'notify_basic' => null, 'notify_basic' => null,
'notify_sound' => null, 'notify_sound' => null,
'notify_desktop' => null, 'notify_desktop' => null,

View File

@@ -736,6 +736,9 @@ class IdentSwitchForm
]; ];
} }
// Re-run JS init on each form render (handles AJAX identity loads)
$rc->output->add_script('plugin_switchIdent_init();', 'docready');
return $args; return $args;
} }
@@ -926,11 +929,11 @@ class IdentSwitchForm
} }
/** /**
* Fill in missing validated data from preconfig when identity is readonly. * Enforce readonly preconfig values on validated data.
* *
* When a preconfig has readonly=true, form fields are disabled in the * When a preconfig has readonly=true, this overwrites the validated
* browser and not submitted via POST. This fills the missing values * data with preconfig values regardless of what the user submitted.
* from the preconfig on the server side. * This prevents tampering via browser devtools (re-enabling disabled fields).
* *
* @param array $data Validated data from validate(), modified in place. * @param array $data Validated data from validate(), modified in place.
* @param string $email Email address of the identity. * @param string $email Email address of the identity.
@@ -947,26 +950,30 @@ class IdentSwitchForm
$record = ['email' => $email]; $record = ['email' => $email];
$preconfig->apply($record); $preconfig->apply($record);
// Map simple preconfig record keys to validated data keys // Force preconfig values (overwrite any tampered POST data)
$map = [ $map = [
'ident_switch.form.imap.port' => 'imap.port', 'ident_switch.form.imap.port' => 'imap.port',
'ident_switch.form.imap.delimiter' => 'imap.delimiter', 'ident_switch.form.imap.delimiter' => 'imap.delimiter',
'ident_switch.form.imap.username' => 'imap.user',
'ident_switch.form.smtp.port' => 'smtp.port', 'ident_switch.form.smtp.port' => 'smtp.port',
'ident_switch.form.sieve.port' => 'sieve.port', 'ident_switch.form.sieve.port' => 'sieve.port',
]; ];
foreach ($map as $recordKey => $dataKey) { foreach ($map as $recordKey => $dataKey) {
if (empty($data[$dataKey]) && isset($record[$recordKey])) { if (isset($record[$recordKey])) {
$data[$dataKey] = $record[$recordKey]; $data[$dataKey] = $record[$recordKey];
} }
} }
// Compose hosts with security scheme (preconfig stores them separately) // Force username if preconfig defines it
if (isset($record['ident_switch.form.imap.username'])) {
$data['imap.user'] = $record['ident_switch.form.imap.username'];
}
// Force hosts with security scheme
foreach (['imap', 'smtp', 'sieve'] as $proto) { foreach (['imap', 'smtp', 'sieve'] as $proto) {
$hostKey = "ident_switch.form.{$proto}.host"; $hostKey = "ident_switch.form.{$proto}.host";
$secKey = "ident_switch.form.{$proto}.security"; $secKey = "ident_switch.form.{$proto}.security";
if (empty($data["{$proto}.host"]) && !empty($record[$hostKey])) { if (!empty($record[$hostKey])) {
$security = $record[$secKey] ?? ''; $security = $record[$secKey] ?? '';
$data["{$proto}.host"] = self::compose_host_scheme($record[$hostKey], $security); $data["{$proto}.host"] = self::compose_host_scheme($record[$hostKey], $security);
} }