ee76088f86
views/top.ejs, views/bottom.ejs and public/lib/js/app-base.js are now byte-identical across sso-manager-node, proxy and jump-host. Everything per-app moved into utils/ui.js, exposed to every render as `ui` via app.locals (nav items + their group gates, footer repo/docs/ToS links, favicon, profile/logout targets, update-banner on/off + label). Client framework changes: - One gating model everywhere: app-base.js reveals .group-required-<cn> for each of the current user user/me groups. sso-manager-node sends LDAP DNs in memberOf, the OIDC clients send CNs in groups; both normalise to CNs, and the clients isAdmin flag becomes a synthetic `admin` group, so proxy nav-admin items are now group-required-admin. - user/me is fetched once per page load and cached (app.auth.loadUser); nav, forceLogin and group-required elements all read that one promise. - isLoggedIn is dual-mode (Promise + node-style callback), so the async and callback call styles both work from one shared top.ejs. - forceLogin no longer uses $.holdReady (removed in jQuery 4): it redirects to /login?redirect=<path>, and still enforces required groups. - logOut only clears the session; the caller decides where to go next. - post/put/delete are dual-mode Promise/callback, which also removes the undefined `callback2` reference that threw on a non-function callback. Dependencies: jquery ^4.0.0 and ejs ^3.1.10 in all three apps. jump-host specifics: - .group-required base rule added to styles.css (no gated nav items yet). - #spa-shell drops its inline margin-top; styles.css already sets it, and the shared shell adjusts it when a banner is shown. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
755 lines
20 KiB
JavaScript
755 lines
20 KiB
JavaScript
// Shared client framework for the theta42 apps.
|
|
//
|
|
// This file is byte-identical across sso-manager-node, proxy and jump-host —
|
|
// per-app behaviour comes from the server (the `ui` locals in views/top.ejs and
|
|
// the /api/user/me response), never from edits to this file. Edit all three
|
|
// copies together.
|
|
//
|
|
// jQuery 4 safe: no $.isFunction, no $.holdReady.
|
|
|
|
var app = {};
|
|
|
|
app.pubsub = (function(){
|
|
app.topics = {};
|
|
|
|
app.subscribe = function(topic, listener){
|
|
if(topic instanceof RegExp){
|
|
listener.match = topic;
|
|
topic = "__REGEX__";
|
|
}
|
|
|
|
// create the topic if not yet created
|
|
if(!app.topics[topic]) app.topics[topic] = [];
|
|
|
|
// add the listener
|
|
app.topics[topic].push(listener);
|
|
}
|
|
|
|
app.matchTopics = function(topic){
|
|
topic = topic || '';
|
|
var matches = [... app.topics[topic] ? app.topics[topic] : []];
|
|
|
|
if(!app.topics['__REGEX__']) return matches;
|
|
|
|
for(var listener of app.topics['__REGEX__']){
|
|
if(topic.match(listener.match)) matches.push(listener);
|
|
}
|
|
|
|
return matches;
|
|
}
|
|
|
|
app.publish = function(topic, data){
|
|
|
|
// send the event to all listeners
|
|
app.matchTopics(topic).forEach(function(listener){
|
|
setTimeout(function(data, topic){
|
|
listener(data || {}, topic);
|
|
}, 0, data, topic);
|
|
});
|
|
}
|
|
|
|
return this;
|
|
})(app);
|
|
|
|
app.socket = (function(app){
|
|
// $.getScript('/socket.io/socket.io.js')
|
|
// <script type="text/javascript" src="/socket.io/socket.io.js"></script>
|
|
|
|
var socket;
|
|
$(document).ready(function(){
|
|
socket = io({
|
|
auth: {
|
|
token: app.auth.getToken()
|
|
}
|
|
});
|
|
// socket.emit('chat message', $('#m').val());
|
|
socket.on('P2PSub', function(msg){
|
|
msg.data.__noSocket = true;
|
|
app.publish(msg.topic, msg.data);
|
|
});
|
|
|
|
app.subscribe(/./g, function(data, topic){
|
|
// console.log('local_pubs', data, topic)
|
|
if(data.__noSocket) return;
|
|
// console.log('local_pubs 2', data, topic)
|
|
|
|
socket.emit('P2PSub', { topic, data });
|
|
});
|
|
})
|
|
|
|
return socket;
|
|
|
|
})(app);
|
|
|
|
app.api = (function(app){
|
|
var baseURL = '/api/'
|
|
|
|
// post/put/delete are dual-mode: pass a callback for the node-style
|
|
// (error, data, status) form, or omit it to get a Promise that resolves
|
|
// with the parsed body and rejects with the error body. get/options return
|
|
// the jqXHR, which is itself thenable, so `await app.api.get(...)` works.
|
|
|
|
function body(method, url, data, callback){
|
|
if(typeof callback !== 'function'){
|
|
return new Promise(function(resolve, reject){
|
|
$.ajax({
|
|
type: method,
|
|
url: baseURL+url,
|
|
headers: { 'auth-token': app.auth.getToken() },
|
|
data: JSON.stringify(data),
|
|
contentType: 'application/json; charset=utf-8',
|
|
dataType: 'json',
|
|
}).done(resolve).fail(function(xhr){ reject(xhr.responseJSON || {}); });
|
|
});
|
|
}
|
|
return $.ajax({
|
|
type: method,
|
|
url: baseURL+url,
|
|
headers:{
|
|
'auth-token': app.auth.getToken()
|
|
},
|
|
data: JSON.stringify(data),
|
|
contentType: "application/json; charset=utf-8",
|
|
dataType: "json",
|
|
complete: function(res, text){
|
|
callback(
|
|
text !== 'success' ? res.statusText : null,
|
|
JSON.parse(res.responseText),
|
|
res.status
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
function post(url, data, callback){
|
|
return body('POST', url, data, callback);
|
|
}
|
|
|
|
function put(url, data, callback){
|
|
return body('PUT', url, data, callback);
|
|
}
|
|
|
|
function remove(url, callback){
|
|
if(typeof callback !== 'function'){
|
|
return new Promise(function(resolve, reject){
|
|
$.ajax({
|
|
type: 'DELETE',
|
|
url: baseURL+url,
|
|
headers: { 'auth-token': app.auth.getToken() },
|
|
contentType: 'application/json; charset=utf-8',
|
|
dataType: 'json',
|
|
}).done(resolve).fail(function(xhr){ reject(xhr.responseJSON || {}); });
|
|
});
|
|
}
|
|
return $.ajax({
|
|
type: 'DELETE',
|
|
url: baseURL+url,
|
|
headers:{
|
|
'auth-token': app.auth.getToken()
|
|
},
|
|
contentType: "application/json; charset=utf-8",
|
|
dataType: "json",
|
|
complete: function(res, text){
|
|
callback(
|
|
text !== 'success' ? res.statusText : null,
|
|
JSON.parse(res.responseText),
|
|
res.status
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
function options(url, callback){
|
|
return $.ajax({
|
|
type: 'OPTIONS',
|
|
url: baseURL+url,
|
|
headers:{
|
|
'auth-token': app.auth.getToken()
|
|
},
|
|
contentType: "application/json; charset=utf-8",
|
|
dataType: "json",
|
|
complete: function(res, text){
|
|
callback ? callback(
|
|
text !== 'success' ? res.statusText : null,
|
|
JSON.parse(res.responseText),
|
|
res.status
|
|
) : function(){}
|
|
}
|
|
});
|
|
}
|
|
|
|
function get(url, callback){
|
|
return $.ajax({
|
|
type: 'GET',
|
|
url: baseURL+url,
|
|
headers:{
|
|
'auth-token': app.auth.getToken()
|
|
},
|
|
contentType: "application/json; charset=utf-8",
|
|
dataType: "json",
|
|
complete: function(res, text){
|
|
callback ? callback(
|
|
text !== 'success' ? res.statusText : null,
|
|
JSON.parse(res.responseText),
|
|
res.status
|
|
) : function(){}
|
|
}
|
|
});
|
|
}
|
|
|
|
return {post: post, get: get, put: put, delete: remove, options: options,}
|
|
})(app)
|
|
|
|
app.auth = (function(app){
|
|
// One in-flight/cached GET /api/user/me per page load. Every gating
|
|
// decision (nav items, per-view forceLogin, group-required elements) reads
|
|
// this same promise instead of re-fetching.
|
|
var userPromise = null;
|
|
|
|
function setToken(token){
|
|
localStorage.setItem('APIToken', token);
|
|
}
|
|
|
|
function getToken(){
|
|
return localStorage.getItem('APIToken');
|
|
}
|
|
|
|
async function getUser(){
|
|
try{
|
|
return await app.api.get('user/me');
|
|
}catch(error){
|
|
if(error && error.status === 401) return null;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Cached current user, or false when there's no token at all. Callers that
|
|
// need a fresh copy (after a login or a profile change) pass force.
|
|
function loadUser(force){
|
|
if(force || !userPromise){
|
|
userPromise = getToken() ? getUser() : Promise.resolve(null);
|
|
userPromise = userPromise.then(function(user){
|
|
app.auth.user = app.auth.perms = user || null;
|
|
return user;
|
|
});
|
|
}
|
|
return userPromise;
|
|
}
|
|
|
|
// The apps report group membership two ways: sso-manager-node returns LDAP
|
|
// DNs in `memberOf`, the OIDC clients return plain CNs in `groups`. Both
|
|
// normalise to a list of CNs. `isAdmin` (the clients' effective-rights flag)
|
|
// is exposed as a synthetic `admin` group so one gating model covers both.
|
|
function groupCNs(user){
|
|
var raw = (user && (user.memberOf || user.groups)) || [];
|
|
if(!Array.isArray(raw)) raw = [raw];
|
|
var names = raw.map(function(group){
|
|
return String(group).split(',')[0].replace(/^cn=/i, '');
|
|
});
|
|
if(user && user.isAdmin && names.indexOf('admin') === -1) names.push('admin');
|
|
return names;
|
|
}
|
|
|
|
async function memberOf(groupNameToFind, user){
|
|
user = user || await loadUser();
|
|
if(!user) return false;
|
|
groupNameToFind = Array.isArray(groupNameToFind) ? groupNameToFind : [groupNameToFind];
|
|
|
|
return groupCNs(user).some(function(group){
|
|
return groupNameToFind.includes(group);
|
|
});
|
|
}
|
|
|
|
// True when the logged-in user is a global admin (per user/me). Sync — only
|
|
// meaningful once isLoggedIn/forceLogin has resolved.
|
|
function isAdmin(){
|
|
return !!(app.auth.perms && app.auth.perms.isAdmin);
|
|
}
|
|
|
|
// Dual-mode: returns a Promise resolving to the user (or false), and calls
|
|
// an optional node-style callback with the same result.
|
|
function isLoggedIn(callback){
|
|
var promise = loadUser().then(function(user){
|
|
return user || false;
|
|
});
|
|
|
|
if(typeof callback === 'function'){
|
|
promise.then(function(user){
|
|
callback(null, user);
|
|
}, function(error){
|
|
callback(error, false);
|
|
});
|
|
}
|
|
|
|
return promise;
|
|
}
|
|
|
|
function logIn(args, callback){
|
|
app.api.post('auth/login', args, function(error, data){
|
|
if(data.login){
|
|
setToken(data.token);
|
|
}
|
|
loadUser(true);
|
|
callback(error, !!data.token);
|
|
});
|
|
}
|
|
|
|
// Clears the session only — the caller decides where to go next (the nav's
|
|
// Log Out button uses ui.logoutRedirect).
|
|
function logOut(callback){
|
|
localStorage.removeItem('APIToken');
|
|
userPromise = null;
|
|
app.auth.user = app.auth.perms = null;
|
|
if(typeof callback === 'function') callback();
|
|
}
|
|
|
|
// Constrain a redirect target to a same-origin absolute path. Rejects
|
|
// absolute URLs (open redirect), protocol-relative "//host" and "/\host",
|
|
// and non-path schemes like "javascript:" (XSS). Falls back to "/".
|
|
function safeInternalPath(path){
|
|
if(typeof path !== 'string' || path.charAt(0) !== '/'
|
|
|| path.charAt(1) === '/' || path.charAt(1) === '\\'){
|
|
return '/';
|
|
}
|
|
return path;
|
|
}
|
|
|
|
// Consume an app token handed back by the OIDC callback via the URL
|
|
// fragment (#token=…&redirect=…). Stores it, strips the fragment, and
|
|
// forwards to the intended page. Returns true if a token was consumed.
|
|
function consumeTokenFragment(){
|
|
if(!location.hash) return false;
|
|
var params = new URLSearchParams(location.hash.replace(/^#/, ''));
|
|
var token = params.get('token');
|
|
if(!token) return false;
|
|
|
|
setToken(token);
|
|
// redirect comes from the URL fragment (attacker-controllable); only
|
|
// allow a same-origin path so it can't become an open redirect / XSS.
|
|
var redirect = safeInternalPath(params.get('redirect') || '/');
|
|
// Drop the token from the address bar before navigating on.
|
|
history.replaceState(null, '', location.pathname + location.search);
|
|
window.location.href = redirect;
|
|
return true;
|
|
}
|
|
|
|
// Page-level gate. jQuery 4 removed $.holdReady, so an unauthenticated or
|
|
// unauthorised user is kept off the page by a redirect / an error panel
|
|
// rather than by pausing document ready.
|
|
//
|
|
// `requiredGroups` is a group CN or an OR-list of them; the synthetic
|
|
// `admin` group covers the OIDC clients' isAdmin flag.
|
|
async function forceLogin(requiredGroups){
|
|
var user = await loadUser();
|
|
|
|
if(!user){
|
|
logOut(function(){});
|
|
location.replace('/login?redirect=' + encodeURIComponent(
|
|
location.pathname + location.search
|
|
));
|
|
return false;
|
|
}
|
|
|
|
if(user.onboardingRequired && location.pathname !== '/onboarding'){
|
|
location.replace('/onboarding');
|
|
return false;
|
|
}
|
|
|
|
if(requiredGroups && !await memberOf(requiredGroups, user)){
|
|
app.util.actionMessage(
|
|
`<h1>
|
|
<i class="fa-solid fa-triangle-exclamation"></i>
|
|
<b>You do not have permission to be here.</b>
|
|
<i class="fa-solid fa-triangle-exclamation"></i>
|
|
</h1>`,
|
|
$('#spa-shell'),
|
|
'danger',
|
|
);
|
|
throw new Error("User does not have permission");
|
|
}
|
|
|
|
return user;
|
|
}
|
|
|
|
// Where to go after a successful login: the ?redirect= query param, or the
|
|
// legacy /login/<path> suffix form, constrained to a same-origin path.
|
|
function logInRedirect(){
|
|
var params = new URLSearchParams(location.search);
|
|
var target = params.get('redirect')
|
|
|| location.href.replace(location.origin + '/login', '').split('?')[0]
|
|
|| '/';
|
|
window.location.href = safeInternalPath(target);
|
|
}
|
|
|
|
return {
|
|
getToken: getToken,
|
|
setToken: setToken,
|
|
getUser: getUser,
|
|
loadUser: loadUser,
|
|
groupCNs: groupCNs,
|
|
memberOf: memberOf,
|
|
isAdmin: isAdmin,
|
|
isLoggedIn: isLoggedIn,
|
|
safeInternalPath: safeInternalPath,
|
|
consumeTokenFragment: consumeTokenFragment,
|
|
user: null,
|
|
perms: null,
|
|
logIn: logIn,
|
|
logOut: logOut,
|
|
forceLogin,
|
|
logInRedirect,
|
|
}
|
|
|
|
})(app);
|
|
|
|
// Back-compat alias for views that awaited the cached user directly.
|
|
Object.defineProperty(app.auth, 'asyncUser', {
|
|
get: function(){ return app.auth.loadUser(); },
|
|
});
|
|
|
|
app.user = (function(app){
|
|
function list(callback){
|
|
app.api.get('user/?detail=true', function(error, data){
|
|
callback(error, data);
|
|
})
|
|
}
|
|
|
|
function add(args, callback){
|
|
app.api.post('user/', args, function(error, data){
|
|
callback(error, data);
|
|
});
|
|
}
|
|
|
|
function remove(args, callback){
|
|
app.api.delete('user/'+ args.username, function(error, data){
|
|
callback(error, data);
|
|
});
|
|
}
|
|
|
|
function changePassword(args, callback){
|
|
app.api.put('users/'+ arg.username || '', args, function(error, data){
|
|
callback(error, data);
|
|
});
|
|
}
|
|
|
|
return {list, remove};
|
|
|
|
})(app);
|
|
|
|
// Local (app-managed) permissions and groups. Only the OIDC-client apps serve
|
|
// these endpoints; the calls are inert elsewhere.
|
|
app.permission = (function(app){
|
|
function list(callback){
|
|
app.api.get('permission/', function(error, data){
|
|
callback(error, data);
|
|
});
|
|
}
|
|
|
|
function subjects(callback){
|
|
app.api.get('permission/subjects', function(error, data){
|
|
callback(error, data);
|
|
});
|
|
}
|
|
|
|
function add(args, callback){
|
|
app.api.post('permission/', args, function(error, data){
|
|
callback(error, data);
|
|
});
|
|
}
|
|
|
|
function remove(id, callback){
|
|
app.api.delete('permission/' + encodeURIComponent(id), function(error, data){
|
|
callback(error, data);
|
|
});
|
|
}
|
|
|
|
return {list, subjects, add, remove};
|
|
|
|
})(app);
|
|
|
|
app.group = (function(app){
|
|
function list(callback){
|
|
app.api.get('group/', function(error, data){
|
|
callback(error, data);
|
|
});
|
|
}
|
|
|
|
function add(args, callback){
|
|
app.api.post('group/', args, function(error, data){
|
|
callback(error, data);
|
|
});
|
|
}
|
|
|
|
function remove(name, callback){
|
|
app.api.delete('group/' + encodeURIComponent(name), function(error, data){
|
|
callback(error, data);
|
|
});
|
|
}
|
|
|
|
function addMember(name, username, callback){
|
|
app.api.post('group/' + encodeURIComponent(name) + '/members', {username}, function(error, data){
|
|
callback(error, data);
|
|
});
|
|
}
|
|
|
|
function removeMember(name, username, callback){
|
|
app.api.delete('group/' + encodeURIComponent(name) + '/members/' + encodeURIComponent(username), function(error, data){
|
|
callback(error, data);
|
|
});
|
|
}
|
|
|
|
return {list, add, remove, addMember, removeMember};
|
|
|
|
})(app);
|
|
|
|
app.util = (function(app){
|
|
|
|
function getUrlParameter(name){
|
|
name = name.replace(/[\[]/, '\\[').replace(/[\]]/, '\\]');
|
|
var regex = new RegExp('[\\?&]' + name + '=([^&#]*)');
|
|
var results = regex.exec(location.search);
|
|
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('<button')) message = `
|
|
<span class="align-middle">${message}</span>
|
|
<button class="action-close btn btn-sm btn-outline-dark float-end">
|
|
<i class="fa-solid fa-xmark"></i>
|
|
</button>
|
|
`
|
|
$target.html(message).slideDown('fast');
|
|
}
|
|
setTimeout(callback,10)
|
|
}
|
|
|
|
function actionConfirm(message, $target, type, callback){
|
|
return new Promise((resolve, reject) =>{
|
|
let id = crypto.randomUUID();
|
|
message = `
|
|
<h4 class"align-middle" >
|
|
<i class="fa-solid fa-triangle-exclamation"></i>
|
|
<b>${message}</b>
|
|
<span class="float-end">
|
|
<button type="button" class="btn btn-success confirm-${id}" data-confirm="true">
|
|
<i class="fa-solid fa-circle-check"></i>
|
|
Confirm
|
|
</button>
|
|
<button type="button" class="btn btn-danger confirm-${id}">
|
|
<i class="fa-solid fa-circle-stop"></i>
|
|
Cancel
|
|
</button>
|
|
</span>
|
|
</h4>
|
|
`
|
|
actionMessage(message, $target, type);
|
|
$("body").on('click', `.confirm-${id}`, function(){
|
|
actionMessage('', $target, type);
|
|
resolve(!!$(this).data('confirm'));
|
|
});
|
|
});
|
|
|
|
}
|
|
|
|
$.fn.serializeObject = function() {
|
|
var obj = {};
|
|
|
|
// Get the form values and work over them
|
|
for (let {name, value} of $(this).serializeArray()) {
|
|
console.log(name, value)
|
|
if (obj[name] === undefined) {
|
|
if (!value
|
|
&& !$(this).parent().find(`[name="${name}"]`).attr('value')
|
|
// Keep empty <textarea>s so a cleared field is submitted (and
|
|
// can reset a list, e.g. the per-host IP/header controls).
|
|
&& !$(this).filter(`textarea[name="${name}"]`).length
|
|
){
|
|
continue;
|
|
}
|
|
|
|
obj[name] = value;
|
|
|
|
let type = $(this).parent().find(`[name="${name}"]`).attr('type');
|
|
if (['number', 'range'].includes(type)) {
|
|
obj[name] = Number(value);
|
|
}
|
|
|
|
if (['radio'].includes(type) && ['true', 'false'].includes(value)) {
|
|
obj[name] = value == 'true' ? true : false;
|
|
}
|
|
} else {
|
|
if (!(obj[name] instanceof Array)) {
|
|
obj[name] = [obj[name]];
|
|
}
|
|
obj[name].push(value);
|
|
}
|
|
|
|
}
|
|
|
|
return obj;
|
|
};
|
|
|
|
function downloadFile(filename, text){
|
|
// https://stackoverflow.com/a/18197341
|
|
|
|
var element = document.createElement('a');
|
|
element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text));
|
|
element.setAttribute('download', filename);
|
|
|
|
element.style.display = 'none';
|
|
document.body.appendChild(element);
|
|
|
|
element.click();
|
|
|
|
document.body.removeChild(element);
|
|
}
|
|
|
|
return {
|
|
downloadFile: downloadFile,
|
|
getUrlParameter: getUrlParameter,
|
|
actionMessage: actionMessage,
|
|
actionConfirm,
|
|
}
|
|
})(app);
|
|
|
|
// Reveal every .group-required-<cn> element the current user's groups entitle
|
|
// them to. Elements carrying .group-required start hidden (styles.css), so a
|
|
// user who is in no groups — or who isn't logged in — simply never sees them.
|
|
app.auth.applyGroupVisibility = function(user){
|
|
var groups = app.auth.groupCNs(user);
|
|
if(!groups.length) return;
|
|
|
|
var style = document.getElementById('group-required-rules');
|
|
if(!style){
|
|
style = document.createElement('style');
|
|
style.id = 'group-required-rules';
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
for(var group of groups){
|
|
try{
|
|
style.sheet.insertRule(
|
|
`.group-required-${CSS.escape(group)} { display: revert !important; }`,
|
|
style.sheet.cssRules.length
|
|
);
|
|
}catch(error){
|
|
// A group whose CN isn't a usable CSS identifier just gates nothing.
|
|
}
|
|
}
|
|
};
|
|
|
|
$( document ).ready(async function(){
|
|
|
|
// Show content the user's groups entitle them to.
|
|
app.auth.applyGroupVisibility(await app.auth.loadUser());
|
|
|
|
$('div.row').fadeIn('slow'); //show the page
|
|
|
|
//panel button's
|
|
$('.fa-arrows-v').click(function(){
|
|
$(this).closest('.card').find('.card-body').slideToggle('fast');
|
|
});
|
|
|
|
$('.fa-circle-minus').click(function(){
|
|
let $body = $(this).closest('.card').find('.card-body');
|
|
if($body.hasClass('d-none')){
|
|
$body.removeClass("d-none").removeClass('d-md-block');
|
|
if($body.is(":visible")) $body.hide();
|
|
}
|
|
$body.slideToggle('fast');
|
|
});
|
|
|
|
$('.fa-circle-xmark').click(function(){
|
|
$(this).closest('.card').slideUp('fast');
|
|
});
|
|
|
|
$('.actionMessage').on('click', 'button.action-close', function(event){
|
|
app.util.actionMessage(null, $(this));
|
|
});
|
|
|
|
setInterval(()=>{
|
|
$('.momentFromNow').each((idx, el)=>{
|
|
var $el = $(el);
|
|
try{
|
|
$el.html(moment($(el).data('date')).fromNow());
|
|
}catch{}
|
|
})
|
|
}, 30000,);
|
|
});
|
|
|
|
(function($){
|
|
$.fn.scrollTo = function(){
|
|
const yOffset = Number($('#spa-shell').css('margin-top').replace('px', ''));
|
|
const y = this[0].getBoundingClientRect().top + window.scrollY - yOffset;
|
|
|
|
console.log('y', y)
|
|
window.scrollTo({top: y, behavior: 'smooth'});
|
|
};
|
|
|
|
})(jQuery);
|
|
|
|
//ajax form submit
|
|
function formAJAX(btn){
|
|
event.preventDefault(btn); // avoid to execute the actual submit of the form.
|
|
var $form = $(btn || event.target).closest('[action]'); // gets the 'form' parent
|
|
var formData = $form.find('[name]').serializeObject(); // builds query formDataing
|
|
var method = ($form.attr('method') || 'post').toLowerCase();
|
|
|
|
if($form.validate && !$form.validate()){
|
|
app.util.actionMessage('Please fix the form errors.', $form, 'danger')
|
|
return false;
|
|
}
|
|
|
|
app.util.actionMessage(
|
|
`<div class="spinner-border" role="status">
|
|
<span class="visually-hidden">Loading...</span>
|
|
</div>`,
|
|
$form,
|
|
'info'
|
|
);
|
|
|
|
app.api[method]($form.attr('action'), formData, function(error, data){
|
|
app.util.actionMessage(data.message, $form, error ? 'danger' : 'success'); //re-populate table
|
|
$form.validateClear();
|
|
if(!error){
|
|
$form.trigger("reset");
|
|
eval($form.attr('evalAJAX')); //gets JS to run after completion
|
|
}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
|
|
}
|
|
if(data && data.keys){
|
|
console.log('form key errors', data.keys)
|
|
for(let keyError of data.keys){
|
|
$form.find(`[name=${keyError.key}]`).validateMessage(keyError.message);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|