4 Commits
5.0.0 ... 5.0.2

Author SHA1 Message Date
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
7 changed files with 155 additions and 82 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

@@ -99,14 +99,14 @@ 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', '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,18 @@
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;
}
/* Unread badge */ /* Unread badge */
.ident-switch-badge { .ident-switch-badge {
flex-shrink: 0; flex-shrink: 0;

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);
} }