diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 4e82cec..d44bc97 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -1,18 +1,19 @@ { "name": "t42-jump-host", - "version": "1.4.0", + "version": "1.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "t42-jump-host", - "version": "1.4.0", + "version": "1.5.0", "license": "MIT", "dependencies": { "@fortawesome/fontawesome-free": "^7.3.0", "@simpleworkjs/app-stack": "^1.0.0", "@simpleworkjs/conf": "^1.2.0", "@simpleworkjs/directory-schema": "^1.0.0", + "@simpleworkjs/frontend": "^0.2.5", "@simpleworkjs/ldap": "^1.0.1", "@simpleworkjs/oidc-client": "^1.0.0", "@simpleworkjs/orm": "^0.2.8", @@ -176,6 +177,15 @@ "node": ">=18.0.0" } }, + "node_modules/@simpleworkjs/frontend": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.5.tgz", + "integrity": "sha512-PxR7UVPv3gRpdF0WsuAZplF1vYvKsEJQevVPhz9d72U+69vP/OH3tlaAXjtO/apMHfhT1viOPw2gMVOrPSxYZw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@simpleworkjs/ldap": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@simpleworkjs/ldap/-/ldap-1.0.1.tgz", diff --git a/nodejs/package.json b/nodejs/package.json index aa15769..d94e6a9 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -23,6 +23,7 @@ "@simpleworkjs/app-stack": "^1.0.0", "@simpleworkjs/conf": "^1.2.0", "@simpleworkjs/directory-schema": "^1.0.0", + "@simpleworkjs/frontend": "^0.2.5", "@simpleworkjs/ldap": "^1.0.1", "@simpleworkjs/oidc-client": "^1.0.0", "@simpleworkjs/orm": "^0.2.8", diff --git a/nodejs/public/lib/js/app-base.js b/nodejs/public/lib/js/app-base.js index 7aa874d..7fe4a17 100644 --- a/nodejs/public/lib/js/app-base.js +++ b/nodejs/public/lib/js/app-base.js @@ -363,7 +363,7 @@ app.auth = (function(app){ } if(requiredGroups && !await memberOf(requiredGroups, user)){ - app.util.actionMessage( + app.messages.action( `

You do not have permission to be here. @@ -520,68 +520,15 @@ app.util = (function(app){ return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' ')); }; - function actionMessage(message, $targetPassed, type, callback){ - message = message || ''; - - let $target = $targetPassed.closest('div.card').find('.actionMessage'); - if(!$target.length) $target = $($targetPassed.find('.actionMessage')[0]); - - type = type || 'info'; - callback = callback || function(){}; - - if($target.html() === message) return; - - if($target.html()){ - $target.slideUp('fast', function(){ - $target.html('') - $target.removeClass (function(index, className){ - return (className.match (/(^|\s)bg-\S+/g) || []).join(' '); - }); - if(message) return actionMessage(message, $target, type, callback); - $target.hide() - }) - }else{ - if(type) $target.addClass('bg-' + type); - - // Messages that bring their own buttons (actionConfirm) are left - // alone; everything else gets the standard dismiss button. - if(!message.includes('${message} - - ` - $target.html(message).slideDown('fast'); - } - setTimeout(callback,10) - } - - function actionConfirm(message, $target, type, callback){ - return new Promise((resolve, reject) =>{ - let id = crypto.randomUUID(); - message = ` -

- - ${message} - - - - -

- ` - actionMessage(message, $target, type); - $("body").on('click', `.confirm-${id}`, function(){ - actionMessage('', $target, type); - resolve(!!$(this).data('confirm')); - }); - }); - + // escapeHtml/actionMessage/actionConfirm moved to @simpleworkjs/frontend's + // app.util.escapeHtml and app.messages.action/confirm. + function escapeHtml(s){ + return String(s == null ? '' : s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); } $.fn.serializeObject = function() { @@ -640,8 +587,7 @@ app.util = (function(app){ return { downloadFile: downloadFile, getUrlParameter: getUrlParameter, - actionMessage: actionMessage, - actionConfirm, + escapeHtml: escapeHtml, } })(app); @@ -696,9 +642,9 @@ $( document ).ready(async function(){ $(this).closest('.card').slideUp('fast'); }); - $('.actionMessage').on('click', 'button.action-close', function(event){ - app.util.actionMessage(null, $(this)); - }); + // action-close click handling is wired by @simpleworkjs/frontend's + // app.messages.js (delegated on document, so it also covers messages + // rendered after this ready handler runs). setInterval(()=>{ $('.momentFromNow').each((idx, el)=>{ @@ -729,11 +675,11 @@ function formAJAX(btn){ var method = ($form.attr('method') || 'post').toLowerCase(); if($form.validate && !$form.validate()){ - app.util.actionMessage('Please fix the form errors.', $form, 'danger') + app.messages.action('Please fix the form errors.', $form, 'danger') return false; } - app.util.actionMessage( + app.messages.action( `
Loading...
`, @@ -742,7 +688,7 @@ function formAJAX(btn){ ); app.api[method]($form.attr('action'), formData, function(error, data){ - app.util.actionMessage(data.message, $form, error ? 'danger' : 'success'); //re-populate table + app.messages.action(data.message, $form, error ? 'danger' : 'success'); //re-populate table $form.validateClear(); if(!error){ $form.trigger("reset"); @@ -750,7 +696,7 @@ function formAJAX(btn){ }else{ console.log('formAJAX res error', error, data) if(data && data.name === 'ObjectValidateError'){ - app.util.actionMessage('Please fix the form errors', $form, 'danger'); //re-populate table + app.messages.action('Please fix the form errors', $form, 'danger'); //re-populate table } if(data && data.keys){ console.log('form key errors', data.keys) diff --git a/nodejs/public/lib/js/val.js b/nodejs/public/lib/js/val.js deleted file mode 100755 index fd3da8f..0000000 --- a/nodejs/public/lib/js/val.js +++ /dev/null @@ -1,201 +0,0 @@ -( function( $ ) { - var settings = { - rule: { - eq: function(value, options){ - var compare = $('[name=' + options + ']').val(); - - if ( value != compare ) { - return "Miss-match"; - } - } - }, - }; - - $.fn.validate = function(event) { - // let thisSettings = $.extend(true, settings, settingsObj); - let hasErrors = false; - - if(this.is('[validate]')) return this.validateField(event); - - if(!this.attr('isValid')){ - console.log('adding reset event') - this.on('reset', function(){ - $(this).attr('isValid', false); - $(this).validateClear(); - }) - } - - this.find('[validate]').each(function(){ - if(!$(this).validateField()) hasErrors = true; - }); - - this.attr('isValid', !hasErrors); - - if(hasErrors && event) event.preventDefault(); - - return !hasErrors; - }; - - $.fn.validateClear = function(){ - $(this).find('input').each(function(){ - $(this).removeClass('is-invalid'); - $(this).removeClass('is-valid'); - }) - } - - $.fn.validateField = function(){ - var attr = this.attr('validate').split(':'); //array of params - var rule = attr[0]; - var options = attr[1]; - var value = this.val(); //link to input value - var message; - - if(this.prop('disabled')) return true; - - - //checks if field is required, and length - if(!isNaN(options) && value.length < options){ - message = `Must be ${options} characters`; - } - - //checks if empty to stop processing - if(!isNaN(options) && value.length === 0) { - }else if(rule in settings.rule){ - message = settings.rule[rule].apply(this, [value, options]); - } - - this.validateMessage(message) - return !message; - } - - $.fn.validateMessage = function(message){ - if(message && message !== true){ - this.closest('.form-group').find('b.invalid-feedback').html(message); - this.addClass('is-invalid'); - }else{ - this.removeClass('is-invalid'); - this.addClass('is-valid'); - } - return this; - }; - - jQuery.extend({ - validateSettings: function( settingsObj ) { - $.extend( true, settings, settingsObj ); - }, - - validateInit: function( ettingsObj ) { - $( '[action]' ).on( 'submit', function ( event, settingsObj ){ - $( this ).validate( settingsObj, event ); - }); - } - }); - -}( jQuery )); - -// Host / target validation, mirrored from the backend (utils/hostname_validate.js): -// a bare hostname or IPv4 address, no protocol / "/" / ":" / whitespace. The -// incoming host may be a wildcard ("*.example.com"); the target may not. -(function(){ - var LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i; - // Either one bare label (Docker service names, /etc/hosts entries) or a - // dotted hostname with an alphabetic TLD. - var HOSTNAME = /^(?=.{1,253}$)(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}|[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)$/i; - var FORBIDDEN = /[\s/:]/; - - function isIPv4( value ) { - var parts = value.split( '.' ); - if ( parts.length !== 4 ) return false; - return parts.every( function( p ) { - return /^(0|[1-9]\d{0,2})$/.test( p ) && Number( p ) <= 255; - }); - } - - // Incoming-host pattern: labels may be normal, "*" (one fragment), or "**" - // (any number of fragments, incl. a bare "**" global catch-all). - function isHostPattern( value ) { - if ( value.length > 253 ) return false; - return value.split( '.' ).every( function( l ) { - return l === '*' || l === '**' || LABEL.test( l ); - }); - } - - function forbidden( value ) { - return FORBIDDEN.test( value ) || value.includes( '://' ); - } - - // Incoming host: IPv4 or a wildcard host pattern. - function checkHost( value ) { - if ( typeof value !== 'string' || value.length === 0 ) return "Required"; - if ( forbidden( value ) ) return 'No protocol, "/", or ":"'; - if ( isIPv4( value ) || isHostPattern( value ) ) return; - return "Enter a valid host or wildcard (*, **)"; - } - - // Downstream target: IPv4 or a strict hostname, no wildcard. - function checkTarget( value ) { - if ( typeof value !== 'string' || value.length === 0 ) return "Required"; - if ( forbidden( value ) ) return 'No protocol, "/", or ":"'; - if ( isIPv4( value ) || HOSTNAME.test( value ) ) return; - return "Enter a valid hostname or IP"; - } - - $.validateSettings({ - rule:{ - ip: function( value ) { - value = value.split( '.' ); - - if ( value.length != 4 ) { - return "Malformed IP"; - } - - $.each( value, function( key, value ) { - if( value > 255 || value < 0 ) { - return "Malformed IP"; - } - }); - }, - - // Incoming host name — hostname, IPv4, or wildcard pattern (*, **). - host: function( value ) { - return checkHost( value ); - }, - - // Downstream target — hostname or IPv4, no wildcard. - target: function( value ) { - return checkTarget( value ); - }, - - // Back-compat alias (no wildcard). - hostname: function( value ) { - return checkTarget( value ); - }, - - user: function( value ) { - var reg = /^[a-z0-9\_\-\@\.]{1,32}$/; - if ( reg.test( value ) === false ) { - return "Invalid"; - } - }, - - // Mirrors utils/password_policy.js: >= 8 chars, and either 12+ chars - // or at least 3 of {lowercase, uppercase, number, symbol}. - password: function( value ) { - if ( typeof value !== 'string' || value.length < 8 ) { - return "Password must be at least 8 characters"; - } - if ( value.length >= 12 ) return; - - var classes = 0; - if ( /[a-z]/.test( value ) ) classes++; - if ( /[A-Z]/.test( value ) ) classes++; - if ( /[0-9]/.test( value ) ) classes++; - if ( /[^A-Za-z0-9]/.test( value ) ) classes++; - - if ( classes < 3 ) { - return "Use 3 of: lowercase, uppercase, number, symbol (or 12+ chars)"; - } - } - } - }); -})(); \ No newline at end of file diff --git a/nodejs/routes/render.js b/nodejs/routes/render.js index 6e6ccd2..037382b 100644 --- a/nodejs/routes/render.js +++ b/nodejs/routes/render.js @@ -21,7 +21,7 @@ const values = { // as the sibling apps), and the app's own JS/CSS/img from public/. mountStaticModules(router, { root: path.join(__dirname, '..'), - deps: ['bootstrap', 'mustache', 'jquery', '@fortawesome', 'moment', 'jq-repeat'], + deps: ['bootstrap', 'mustache', 'jquery', '@fortawesome', 'moment', 'jq-repeat', '@simpleworkjs/frontend'], }); // Liveness probe — no auth. diff --git a/nodejs/views/top.ejs b/nodejs/views/top.ejs index cc3c1cf..1fe6879 100644 --- a/nodejs/views/top.ejs +++ b/nodejs/views/top.ejs @@ -21,9 +21,11 @@ - + + +