Release v2.5.6
This commit is contained in:
@@ -8,6 +8,7 @@ module.exports = function registerAccountRoutes(app, deps) {
|
||||
const getAuditUserId = deps.getAuditUserId;
|
||||
const verifyPassword = deps.verifyPassword;
|
||||
const hashPassword = deps.hashPassword;
|
||||
const validatePasswordStrength = deps.validatePasswordStrength;
|
||||
const createUserSession = deps.createUserSession;
|
||||
const setSessionCookie = deps.setSessionCookie;
|
||||
|
||||
@@ -52,8 +53,9 @@ module.exports = function registerAccountRoutes(app, deps) {
|
||||
if (!verifyPassword(currentPassword, user)) {
|
||||
return res.status(400).send('Current password is incorrect.');
|
||||
}
|
||||
if (!newPassword || newPassword.length < 8) {
|
||||
return res.status(400).send('New password must be at least 8 characters.');
|
||||
const passwordStrengthMessage = validatePasswordStrength(newPassword);
|
||||
if (passwordStrengthMessage) {
|
||||
return res.status(400).send(passwordStrengthMessage);
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
return res.status(400).send('New passwords do not match.');
|
||||
@@ -68,7 +70,7 @@ module.exports = function registerAccountRoutes(app, deps) {
|
||||
|
||||
const token = await createUserSession(pool, user.id);
|
||||
setSessionCookie(res, token);
|
||||
res.redirect('/dashboard?message=' + encodeURIComponent('Password updated.'));
|
||||
res.redirect('/account?message=' + encodeURIComponent('Password updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
const requirePermission = deps.requirePermission;
|
||||
const hasAnyPermission = deps.hasAnyPermission;
|
||||
const { buildPagination } = require('#src/web/lib/pagination');
|
||||
const { buildDuplicateSlideName, buildDuplicateSlide } = require('#src/web/routes/signage/slides/duplicate');
|
||||
const { buildDuplicateCanvasSizeName, buildDuplicateCanvasSize } = require('#src/web/routes/signage/canvas-sizes/duplicate');
|
||||
|
||||
const LIST_PAGE_SIZE = 25;
|
||||
const IMAGE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
|
||||
@@ -163,6 +165,11 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
return Number(rows[0] && rows[0].slide_count) || 0;
|
||||
}
|
||||
|
||||
async function fetchCanvasSizeUsageCount(canvasSizeId) {
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM c_templates WHERE canvas_size_id = ?', [canvasSizeId]);
|
||||
return Number(rows[0] && rows[0].ref_count) || 0;
|
||||
}
|
||||
|
||||
async function fetchTemplateRegionUsage(template) {
|
||||
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE template_id = ?', [template.id]);
|
||||
const usage = new Set();
|
||||
@@ -188,6 +195,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
|
||||
return Array.from(usage);
|
||||
}
|
||||
const { buildDuplicateTemplateName, buildDuplicateTemplate } = require('../signage/templates/duplicate');
|
||||
|
||||
async function fetchSlidesByTemplateId(templateId) {
|
||||
const [slides] = await pool.query('SELECT id, thumbnail_path FROM c_slides WHERE template_id = ? ORDER BY id ASC', [templateId]);
|
||||
@@ -201,6 +209,27 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
.filter((regionKey) => regionKey && !nextKeys.has(regionKey));
|
||||
}
|
||||
|
||||
|
||||
app.get('/templates/:id/duplicate', requirePermission('templates.read'), requirePermission('templates.create'), async function (req, res, next) {
|
||||
try {
|
||||
const template = await common.fetchTemplateById(pool, Number(req.params.id));
|
||||
if (!template) {
|
||||
return res.status(404).send('Template not found');
|
||||
}
|
||||
|
||||
const canvasSizes = await common.fetchCanvasSizesData(pool);
|
||||
let duplicateName = buildDuplicateTemplateName(template.name);
|
||||
let duplicateIndex = 2;
|
||||
while (await common.fetchDuplicateName(pool, 'c_templates', duplicateName)) {
|
||||
duplicateName = buildDuplicateTemplateName(template.name) + ' (' + duplicateIndex + ')';
|
||||
duplicateIndex += 1;
|
||||
}
|
||||
|
||||
res.send(pages.renderTemplateAddPage(buildDuplicateTemplate(template, duplicateName), req.query.message ? String(req.query.message) : 'Review the copied values and save when ready.', canvasSizes.canvasSizes, req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
async function getTemplateRegionDeleteBlockMessage(template, nextRegions) {
|
||||
const removedRegionKeys = getRemovedTemplateRegionKeys(template && template.regions ? template.regions : [], nextRegions);
|
||||
if (!removedRegionKeys.length) {
|
||||
@@ -306,6 +335,27 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/slides/:id/duplicate', requirePermission('slides.read'), requirePermission('slides.create'), async function (req, res, next) {
|
||||
try {
|
||||
const slide = await common.fetchSlideById(pool, Number(req.params.id));
|
||||
if (!slide) {
|
||||
return res.status(404).send('Slide not found');
|
||||
}
|
||||
|
||||
const data = await fetchSlideFormData();
|
||||
let duplicateName = buildDuplicateSlideName(slide.title);
|
||||
let duplicateIndex = 2;
|
||||
while (await common.fetchDuplicateName(pool, 'c_slides', duplicateName, null, 'title')) {
|
||||
duplicateName = buildDuplicateSlideName(slide.title) + ' (' + duplicateIndex + ')';
|
||||
duplicateIndex += 1;
|
||||
}
|
||||
|
||||
res.send(pages.renderSlideAddPage(buildDuplicateSlide(slide, duplicateName), data, req.query.message ? String(req.query.message) : 'Review the copied values and save when ready.', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/slides/:id/edit', requirePermission('slides.update'), async function (req, res, next) {
|
||||
try {
|
||||
const slide = await common.fetchSlideById(pool, Number(req.params.id));
|
||||
@@ -342,12 +392,16 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
try {
|
||||
const uploadPaths = Array.isArray(req.body && req.body.uploadPaths)
|
||||
? req.body.uploadPaths
|
||||
: Array.isArray(req.body && req.body.paths)
|
||||
? req.body.paths
|
||||
: req.body && req.body.uploadPaths
|
||||
? [req.body.uploadPaths]
|
||||
: [];
|
||||
|
||||
if (!uploadPaths.length) {
|
||||
return res.status(400).json({ error: 'No upload paths were provided.' });
|
||||
}
|
||||
|
||||
await removeUnusedUploadFiles(pool, deps.uploadDir, uploadPaths);
|
||||
res.json({ ok: true });
|
||||
res.sendStatus(204);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -639,6 +693,26 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
res.send(pages.renderCanvasSizeAddPage(null, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
});
|
||||
|
||||
app.get('/canvas-sizes/:id/duplicate', requirePermission('canvas-sizes.read'), requirePermission('canvas-sizes.create'), async function (req, res, next) {
|
||||
try {
|
||||
const canvasSize = await common.fetchCanvasSizeById(pool, Number(req.params.id));
|
||||
if (!canvasSize) {
|
||||
return res.status(404).send('Canvas size not found');
|
||||
}
|
||||
|
||||
let duplicateName = buildDuplicateCanvasSizeName(canvasSize.name);
|
||||
let duplicateIndex = 2;
|
||||
while (await common.fetchDuplicateName(pool, 'c_canvas_sizes', duplicateName)) {
|
||||
duplicateName = buildDuplicateCanvasSizeName(canvasSize.name) + ' (' + duplicateIndex + ')';
|
||||
duplicateIndex += 1;
|
||||
}
|
||||
|
||||
res.send(pages.renderCanvasSizeAddPage(buildDuplicateCanvasSize(canvasSize, duplicateName), req.query.message ? String(req.query.message) : 'Review the copied values and save when ready.', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/canvas-sizes', requirePermission('canvas-sizes.create'), async function (req, res, next) {
|
||||
try {
|
||||
const payload = common.buildCanvasSizePayload(req, null);
|
||||
@@ -646,7 +720,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
return res.redirect('/canvas-sizes/new?message=' + encodeURIComponent('A canvas size with that name already exists.'));
|
||||
}
|
||||
if (await canvasSizeExists(payload.width, payload.height)) {
|
||||
return res.redirect('/canvas-sizes/new?message=' + encodeURIComponent('That canvas size already exists.'));
|
||||
return res.status(400).send('That canvas size already exists.');
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
const [result] = await pool.query('INSERT INTO c_canvas_sizes (name, width, height, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [payload.name, payload.width, payload.height, actorId, actorId]);
|
||||
@@ -666,8 +740,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
if (!canvasSize) {
|
||||
return res.status(404).send('Canvas size not found');
|
||||
}
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM c_templates WHERE canvas_size_id = ?', [canvasSize.id]);
|
||||
canvasSize.inUse = Number(rows[0] && rows[0].ref_count) > 0;
|
||||
canvasSize.inUse = (await fetchCanvasSizeUsageCount(canvasSize.id)) > 0;
|
||||
res.send(pages.renderCanvasSizeEditPage(canvasSize, null, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -680,12 +753,16 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
if (!canvasSize) {
|
||||
return res.status(404).send('Canvas size not found');
|
||||
}
|
||||
canvasSize.inUse = (await fetchCanvasSizeUsageCount(canvasSize.id)) > 0;
|
||||
const payload = common.buildCanvasSizePayload(req, canvasSize);
|
||||
if (canvasSize.inUse && (payload.width !== Number(canvasSize.width) || payload.height !== Number(canvasSize.height))) {
|
||||
return res.status(400).send('This canvas size is still used by one or more templates, so its dimensions cannot be changed.');
|
||||
}
|
||||
if (await common.fetchDuplicateName(pool, 'c_canvas_sizes', payload.name, canvasSize.id)) {
|
||||
return res.redirect('/canvas-sizes/' + canvasSize.id + '/edit?message=' + encodeURIComponent('A canvas size with that name already exists.'));
|
||||
}
|
||||
if (await canvasSizeExists(payload.width, payload.height, canvasSize.id)) {
|
||||
return res.redirect('/canvas-sizes/' + canvasSize.id + '/edit?message=' + encodeURIComponent('That canvas size already exists.'));
|
||||
return res.status(400).send('That canvas size already exists.');
|
||||
}
|
||||
await pool.query('UPDATE c_canvas_sizes SET name = ?, width = ?, height = ?, modified_by = ? WHERE id = ?', [payload.name, payload.width, payload.height, getAuditUserId(req), canvasSize.id]);
|
||||
redirectAfterSave(req, res, '/canvas-sizes', {
|
||||
|
||||
@@ -14,6 +14,18 @@ module.exports = function registerManageRoutes(app, deps) {
|
||||
const PLAYER_PUBLIC_BASE_URL = deps.playerPublicBaseUrl;
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
const SCREEN_NAME_MAX_LENGTH = 255;
|
||||
const SCREEN_SLUG_MAX_LENGTH = 255;
|
||||
|
||||
function readLimitedText(value, maxLength) {
|
||||
const text = String(value || '').trim();
|
||||
const limit = Number(maxLength);
|
||||
if (!Number.isFinite(limit) || limit < 1 || text.length <= limit) {
|
||||
return text;
|
||||
}
|
||||
return text.slice(0, limit);
|
||||
}
|
||||
|
||||
async function fetchAllScreenSlugs() {
|
||||
const [rows] = await pool.query('SELECT slug FROM d_screens ORDER BY slug ASC');
|
||||
return (rows || [])
|
||||
@@ -85,14 +97,18 @@ module.exports = function registerManageRoutes(app, deps) {
|
||||
|
||||
app.post('/screens', requirePermission('screens.create'), async function (req, res, next) {
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
const name = typeof common.validateMaxLength === 'function'
|
||||
? common.validateMaxLength(req.body.name || '', SCREEN_NAME_MAX_LENGTH, 'Screen name')
|
||||
: readLimitedText(req.body.name || '', 255);
|
||||
if (!name) {
|
||||
return res.status(400).send('Screen name is required.');
|
||||
}
|
||||
if (await common.fetchDuplicateName(pool, 'd_screens', name)) {
|
||||
return res.status(400).send('A screen with that name already exists.');
|
||||
}
|
||||
const slugInput = String(req.body.slug || '').trim();
|
||||
const slugInput = typeof common.validateMaxLength === 'function'
|
||||
? common.validateMaxLength(req.body.slug || '', SCREEN_SLUG_MAX_LENGTH, 'Screen URL')
|
||||
: readLimitedText(req.body.slug || '', 255);
|
||||
const playlistId = req.body.playlist_id ? Number(req.body.playlist_id) : null;
|
||||
const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name));
|
||||
const actorId = getAuditUserId(req);
|
||||
@@ -109,7 +125,9 @@ module.exports = function registerManageRoutes(app, deps) {
|
||||
|
||||
app.post('/screens/:id', requirePermission('screens.update'), async function (req, res, next) {
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
const name = typeof common.validateMaxLength === 'function'
|
||||
? common.validateMaxLength(req.body.name || '', SCREEN_NAME_MAX_LENGTH, 'Screen name')
|
||||
: readLimitedText(req.body.name || '', 255);
|
||||
if (!name) {
|
||||
return res.status(400).send('Screen name is required.');
|
||||
}
|
||||
@@ -120,7 +138,9 @@ module.exports = function registerManageRoutes(app, deps) {
|
||||
if (await common.fetchDuplicateName(pool, 'd_screens', name, screen.id)) {
|
||||
return res.status(400).send('A screen with that name already exists.');
|
||||
}
|
||||
const slugInput = String(req.body.slug || '').trim();
|
||||
const slugInput = typeof common.validateMaxLength === 'function'
|
||||
? common.validateMaxLength(req.body.slug || '', SCREEN_SLUG_MAX_LENGTH, 'Screen URL')
|
||||
: readLimitedText(req.body.slug || '', 255);
|
||||
const playlistId = req.body.playlist_id ? Number(req.body.playlist_id) : null;
|
||||
const previousPlaylistId = screen.playlist_id;
|
||||
const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name), screen.id);
|
||||
|
||||
@@ -11,12 +11,17 @@
|
||||
const normalizePermissionKeys = deps.normalizePermissionKeys;
|
||||
const { buildPagination } = require('../../lib/pagination');
|
||||
const requirePermission = deps.requirePermission;
|
||||
const { buildDuplicateRoleName, buildDuplicateRole } = require('../settings/rbac/duplicate');
|
||||
|
||||
const ROLE_NAME_MAX_LENGTH = 255;
|
||||
const ROLE_DESCRIPTION_MAX_LENGTH = 255;
|
||||
const ROLE_KEY_MAX_LENGTH = 100;
|
||||
|
||||
const LIST_PAGE_SIZE = 25;
|
||||
|
||||
function slugifyRoleKey(name) {
|
||||
const value = String(name || '').trim().toLowerCase();
|
||||
const slug = value.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
const slug = value.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, ROLE_KEY_MAX_LENGTH);
|
||||
return slug || 'role';
|
||||
}
|
||||
|
||||
@@ -65,10 +70,12 @@
|
||||
return (Array.isArray(permissionRows) ? permissionRows : []).map(function (permission) {
|
||||
const definition = permissionDefinitions.get(String(permission.permission_key || '').trim()) || null;
|
||||
return Object.assign({}, permission, {
|
||||
resourceKey: definition ? definition.sectionKey : String(permission.section_name || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
||||
resourceName: definition ? definition.name : permission.section_name,
|
||||
resourceKey: definition ? definition.resourceKey : String(permission.section_name || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
||||
resourceName: definition ? definition.resourceName : permission.section_name,
|
||||
categoryName: definition ? definition.sectionName : permission.section_name,
|
||||
sectionOrder: definition ? definition.sectionOrder : 999,
|
||||
resourceOrder: definition ? Number(definition.resourceOrder) || 999 : 999,
|
||||
permissionOrder: definition ? Number(definition.permissionOrder) || 999 : 999,
|
||||
actionKey: definition ? definition.actionKey : 'read',
|
||||
actionLabel: definition ? definition.actionName : getActionLabel(permission.actionKey),
|
||||
isSelected: selectedKeys.has(String(permission.permission_key || '').trim())
|
||||
@@ -107,9 +114,8 @@
|
||||
|
||||
groups.forEach(function (group) {
|
||||
group.permissions.sort(function (left, right) {
|
||||
const actionOrder = { create: 1, read: 2, update: 3, edit: 3, delete: 4, allow: 5 };
|
||||
const leftOrder = actionOrder[String(left.actionKey || '').trim()] || 99;
|
||||
const rightOrder = actionOrder[String(right.actionKey || '').trim()] || 99;
|
||||
const leftOrder = Number(left.permissionOrder) || 999;
|
||||
const rightOrder = Number(right.permissionOrder) || 999;
|
||||
if (leftOrder !== rightOrder) {
|
||||
return leftOrder - rightOrder;
|
||||
}
|
||||
@@ -136,8 +142,8 @@
|
||||
});
|
||||
return {
|
||||
formValues: {
|
||||
name: String(formValues && formValues.name || '').trim(),
|
||||
description: String(formValues && formValues.description || '').trim()
|
||||
name: common.validateMaxLength(formValues && formValues.name || '', ROLE_NAME_MAX_LENGTH, 'Role name'),
|
||||
description: common.validateMaxLength(formValues && formValues.description || '', ROLE_DESCRIPTION_MAX_LENGTH, 'Role description')
|
||||
},
|
||||
permissionGroups: buildPermissionGroups(mapPermissionsForView(permissionRows, selectedPermissionKeys)),
|
||||
users: mapUsersForView(data.users, selectedUserIds),
|
||||
@@ -197,10 +203,39 @@
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/rbac/:id/duplicate', requirePermission('rbac.read'), requirePermission('rbac.create'), async function (req, res, next) {
|
||||
try {
|
||||
const roleId = Number(req.params.id);
|
||||
if (!Number.isInteger(roleId) || roleId <= 0) {
|
||||
return res.status(400).send('Invalid role.');
|
||||
}
|
||||
|
||||
const role = await rbacData.fetchRoleById(pool, roleId);
|
||||
if (!role) {
|
||||
return res.status(404).send('Role not found.');
|
||||
}
|
||||
|
||||
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||
const search = common.getSearchQuery(req);
|
||||
const sort = common.getSortQuery(req);
|
||||
const direction = common.getSortDirectionQuery(req);
|
||||
const currentUserId = req.currentUser ? Number(req.currentUser.id) : null;
|
||||
const permissionKeys = await rbacData.fetchRolePermissionKeys(pool, roleId);
|
||||
const viewModel = await buildRoleCreateViewModel({
|
||||
name: buildDuplicateRoleName(role.name),
|
||||
description: buildDuplicateRole(role).description
|
||||
}, permissionKeys, [], page, search, sort, direction, currentUserId);
|
||||
|
||||
res.send(pages.renderRbacAddPage('Review the copied values and save when ready.', req.currentUser, viewModel.formValues, viewModel.permissionGroups, viewModel.users, viewModel.pagination, 'primary'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/rbac', requirePermission('rbac.create'), async function (req, res, next) {
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
const description = String(req.body.description || '').trim();
|
||||
const name = common.validateMaxLength(req.body.name || '', ROLE_NAME_MAX_LENGTH, 'Role name');
|
||||
const description = common.validateMaxLength(req.body.description || '', ROLE_DESCRIPTION_MAX_LENGTH, 'Role description');
|
||||
const selectedUserIds = Object.prototype.hasOwnProperty.call(req.body || {}, 'users_present')
|
||||
? readArrayField(req.body, ['user_ids[]', 'user_ids'])
|
||||
: [];
|
||||
@@ -311,8 +346,8 @@
|
||||
return res.status(404).send('Role not found.');
|
||||
}
|
||||
|
||||
const name = String(req.body.name || '').trim();
|
||||
const description = String(req.body.description || '').trim();
|
||||
const name = common.validateMaxLength(req.body.name || '', ROLE_NAME_MAX_LENGTH, 'Role name');
|
||||
const description = common.validateMaxLength(req.body.description || '', ROLE_DESCRIPTION_MAX_LENGTH, 'Role description');
|
||||
const shouldSyncPermissions = Object.prototype.hasOwnProperty.call(req.body || {}, 'permissions_present');
|
||||
const shouldSyncUsers = Object.prototype.hasOwnProperty.call(req.body || {}, 'users_present');
|
||||
const selectedPermissionKeys = shouldSyncPermissions
|
||||
|
||||
@@ -7,10 +7,15 @@
|
||||
const formatDashboardDate = deps.formatDashboardDate;
|
||||
const getAuditUserId = deps.getAuditUserId;
|
||||
const hashPassword = deps.hashPassword;
|
||||
const validatePasswordStrength = deps.validatePasswordStrength;
|
||||
const readArrayField = deps.readArrayField;
|
||||
const rbacData = deps.rbacData;
|
||||
const { buildPagination } = require('../../lib/pagination');
|
||||
const requirePermission = deps.requirePermission;
|
||||
const { buildDuplicateUserName, buildDuplicateUser } = require('../settings/users/duplicate');
|
||||
|
||||
const USER_NAME_MAX_LENGTH = 255;
|
||||
const USER_USERNAME_MAX_LENGTH = 255;
|
||||
|
||||
const LIST_PAGE_SIZE = 25;
|
||||
|
||||
@@ -43,10 +48,6 @@
|
||||
return Number.isInteger(roleId) && roleId > 0;
|
||||
})));
|
||||
|
||||
if (!normalizedRoleIds.length) {
|
||||
return { ok: false, message: 'Select at least one role.' };
|
||||
}
|
||||
|
||||
if (normalizedRoleIds.some(function (roleId) {
|
||||
return !validRoleIds.has(roleId);
|
||||
})) {
|
||||
@@ -88,6 +89,38 @@
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/users/:id/duplicate', requirePermission('users.read'), requirePermission('users.create'), async function (req, res, next) {
|
||||
try {
|
||||
const userId = Number(req.params.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
return res.status(400).send('Invalid user.');
|
||||
}
|
||||
|
||||
const user = await rbacData.fetchUserWithRoles(pool, userId);
|
||||
if (!user) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
if (Number(req.currentUser.id) === userId) {
|
||||
return res.redirect('/users?message=' + encodeURIComponent('Use Add user to create another login for yourself.'));
|
||||
}
|
||||
|
||||
const roles = await fetchRoleOptions();
|
||||
const duplicateUser = buildDuplicateUser(user, buildDuplicateUserName(user.name));
|
||||
|
||||
res.send(pages.renderUsersAddPage(
|
||||
'Review the copied values and save when ready.',
|
||||
req.currentUser,
|
||||
mapRolesForForm(roles, user.roleIds),
|
||||
{
|
||||
name: duplicateUser.name
|
||||
},
|
||||
'primary'
|
||||
));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/users/:id/edit', requirePermission('users.update'), async function (req, res, next) {
|
||||
try {
|
||||
const userId = Number(req.params.id);
|
||||
@@ -122,10 +155,11 @@
|
||||
app.post('/users', requirePermission('users.create'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
const username = String(req.body.username || user.username || '').trim();
|
||||
const name = common.validateMaxLength(req.body.name || '', USER_NAME_MAX_LENGTH, 'Name');
|
||||
const username = common.validateMaxLength(req.body.username || '', USER_USERNAME_MAX_LENGTH, 'Username');
|
||||
const password = String(req.body.password || '');
|
||||
const confirmPassword = String(req.body.confirm_password || '');
|
||||
const saveAction = String(req.body.save_action || req.body.action || '').trim().toLowerCase();
|
||||
const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']);
|
||||
const roleCheck = await validateRoleIds(selectedRoleIds);
|
||||
const formValues = {
|
||||
@@ -144,8 +178,9 @@
|
||||
if (!username) {
|
||||
return renderValidationError('Username is required.');
|
||||
}
|
||||
if (!password || password.length < 8) {
|
||||
return renderValidationError('Password must be at least 8 characters.');
|
||||
const passwordStrengthMessage = validatePasswordStrength(password);
|
||||
if (passwordStrengthMessage) {
|
||||
return renderValidationError(passwordStrengthMessage);
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
return renderValidationError('Passwords do not match.');
|
||||
@@ -171,6 +206,9 @@
|
||||
);
|
||||
await rbacData.syncUserRoles(connection, result.insertId, roleCheck.roleIds);
|
||||
await connection.commit();
|
||||
if (saveAction === 'new') {
|
||||
return res.redirect('/users/new?message=' + encodeURIComponent('User created.'));
|
||||
}
|
||||
res.redirect('/users/' + result.insertId + '/edit?message=' + encodeURIComponent('User created.'));
|
||||
} catch (error) {
|
||||
try {
|
||||
@@ -216,11 +254,12 @@
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const userId = Number(req.params.id);
|
||||
const name = String(req.body.name || '').trim();
|
||||
const name = common.validateMaxLength(req.body.name || '', USER_NAME_MAX_LENGTH, 'Name');
|
||||
const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']);
|
||||
const password = String(req.body.password || '');
|
||||
const confirmPassword = String(req.body.confirm_password || '');
|
||||
const shouldUpdatePassword = Boolean(password || confirmPassword);
|
||||
const saveAction = String(req.body.save_action || req.body.action || '').trim().toLowerCase();
|
||||
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
return res.status(400).send('Invalid user.');
|
||||
@@ -234,7 +273,7 @@
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
|
||||
const username = String(req.body.username || user.username || '').trim();
|
||||
const username = common.validateMaxLength(req.body.username || user.username || '', USER_USERNAME_MAX_LENGTH, 'Username');
|
||||
|
||||
const [countRows] = await pool.query('SELECT COUNT(*) AS user_count FROM a_users');
|
||||
const canDelete = !countRows.length || Number(countRows[0].user_count) > 1;
|
||||
@@ -256,8 +295,11 @@
|
||||
if (!username) {
|
||||
return renderValidationError('Username is required.');
|
||||
}
|
||||
if (shouldUpdatePassword && (!password || password.length < 8)) {
|
||||
return renderValidationError('Password must be at least 8 characters.');
|
||||
if (shouldUpdatePassword) {
|
||||
const passwordStrengthMessage = validatePasswordStrength(password);
|
||||
if (passwordStrengthMessage) {
|
||||
return renderValidationError(passwordStrengthMessage);
|
||||
}
|
||||
}
|
||||
if (shouldUpdatePassword && password !== confirmPassword) {
|
||||
return renderValidationError('Passwords do not match.');
|
||||
@@ -290,6 +332,9 @@
|
||||
await connection.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]);
|
||||
}
|
||||
await connection.commit();
|
||||
if (saveAction === 'new') {
|
||||
return res.redirect('/users/new?message=' + encodeURIComponent('User updated.'));
|
||||
}
|
||||
res.redirect('/users?message=' + encodeURIComponent('User updated.'));
|
||||
} catch (error) {
|
||||
try {
|
||||
@@ -308,6 +353,7 @@
|
||||
const userId = Number(req.params.id);
|
||||
const password = String(req.body.password || '');
|
||||
const confirmPassword = String(req.body.confirm_password || '');
|
||||
const editUrl = '/users/' + userId + '/edit';
|
||||
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
return res.status(400).send('Invalid user.');
|
||||
@@ -315,11 +361,12 @@
|
||||
if (Number(req.currentUser.id) === userId) {
|
||||
return res.redirect('/account?message=' + encodeURIComponent('Use My Account to change your own password.'));
|
||||
}
|
||||
if (!password || password.length < 8) {
|
||||
return res.redirect('/users?message=' + encodeURIComponent('Password must be at least 8 characters.'));
|
||||
const passwordStrengthMessage = validatePasswordStrength(password);
|
||||
if (passwordStrengthMessage) {
|
||||
return res.redirect(editUrl + '?message=' + encodeURIComponent(passwordStrengthMessage));
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
return res.redirect('/users?message=' + encodeURIComponent('Passwords do not match.'));
|
||||
return res.redirect(editUrl + '?message=' + encodeURIComponent('Passwords do not match.'));
|
||||
}
|
||||
|
||||
const [rows] = await pool.query('SELECT id FROM a_users WHERE id = ? LIMIT 1', [userId]);
|
||||
@@ -333,7 +380,7 @@
|
||||
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, getAuditUserId(req), userId]
|
||||
);
|
||||
await pool.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]);
|
||||
res.redirect('/users?message=' + encodeURIComponent('Password updated.'));
|
||||
res.redirect(editUrl + '?message=' + encodeURIComponent('Password updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Authentication route registration for the web app.
|
||||
|
||||
const { normalizeReturnToPath, getRequestOrigin } = require('../../lib/auth/session');
|
||||
|
||||
module.exports = function registerAuthRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const pages = deps.pages;
|
||||
@@ -13,19 +15,28 @@ module.exports = function registerAuthRoutes(app, deps) {
|
||||
const verifyPassword = deps.verifyPassword;
|
||||
const sessionCookieName = deps.sessionCookieName;
|
||||
|
||||
function getReturnTo(req) {
|
||||
return normalizeReturnToPath(req && (req.query && req.query.returnTo || req.body && req.body.returnTo), getRequestOrigin(req));
|
||||
}
|
||||
|
||||
function buildLoginRedirect(returnTo) {
|
||||
return returnTo ? '/login?returnTo=' + encodeURIComponent(returnTo) : '/login';
|
||||
}
|
||||
|
||||
app.get('/', function (req, res) {
|
||||
res.redirect(req.currentUser ? '/dashboard' : '/login');
|
||||
});
|
||||
|
||||
app.get('/login', function (req, res) {
|
||||
const returnTo = getReturnTo(req);
|
||||
if (req.currentUser) {
|
||||
return res.redirect('/dashboard');
|
||||
return res.redirect(returnTo || '/dashboard');
|
||||
}
|
||||
|
||||
const message = typeof consumeAuthMessageCookie === 'function'
|
||||
? consumeAuthMessageCookie(req, res)
|
||||
: (req.query.message ? String(req.query.message) : '');
|
||||
res.send(pages.renderLoginPage(message));
|
||||
res.send(pages.renderLoginPage(message, returnTo));
|
||||
});
|
||||
|
||||
app.post('/login', async function (req, res, next) {
|
||||
@@ -39,17 +50,19 @@ module.exports = function registerAuthRoutes(app, deps) {
|
||||
const [rows] = await pool.query('SELECT id, name, username, password_hash, password_salt, password_iterations FROM a_users WHERE username = ? LIMIT 1', [username]);
|
||||
const user = rows[0] || null;
|
||||
if (!user || !verifyPassword(password, user)) {
|
||||
const returnTo = getReturnTo(req);
|
||||
if (typeof setAuthMessageCookie === 'function') {
|
||||
setAuthMessageCookie(res, 'Invalid username or password.');
|
||||
return res.redirect('/login');
|
||||
return res.redirect(buildLoginRedirect(returnTo));
|
||||
}
|
||||
|
||||
return res.status(401).send(pages.renderLoginPage('Invalid username or password.'));
|
||||
return res.status(401).send(pages.renderLoginPage('Invalid username or password.', returnTo));
|
||||
}
|
||||
|
||||
const returnTo = getReturnTo(req);
|
||||
const token = await createUserSession(pool, user.id);
|
||||
setSessionCookie(res, token);
|
||||
res.redirect('/dashboard');
|
||||
res.redirect(returnTo || '/dashboard');
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
const { renderView } = require('../../view');
|
||||
|
||||
module.exports = function renderLoginPage(message) {
|
||||
module.exports = function renderLoginPage(message, returnTo) {
|
||||
return renderView('auth/login', {
|
||||
title: 'Sign in',
|
||||
authShell: true,
|
||||
bodyClass: 'login-page-body',
|
||||
message: message || '',
|
||||
returnTo: returnTo || '',
|
||||
messageVariant: 'warning'
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
// API source duplication helpers.
|
||||
|
||||
function buildDuplicateApiSourceName(sourceName) {
|
||||
const baseName = 'Copy of ' + String(sourceName || '').trim();
|
||||
return baseName;
|
||||
}
|
||||
|
||||
function buildDuplicateApiSource(apiSource, duplicateName) {
|
||||
return Object.assign({}, apiSource, {
|
||||
id: null,
|
||||
name: duplicateName,
|
||||
apiUrl: apiSource.api_url || '',
|
||||
lastPulledAt: null,
|
||||
lastPullError: '',
|
||||
lastResponseStatus: null,
|
||||
lastResponseContentType: '',
|
||||
lastResponseJson: '',
|
||||
authMethod: apiSource.auth_method || 'none',
|
||||
authUsername: apiSource.auth_username || '',
|
||||
authPassword: apiSource.auth_password || '',
|
||||
authBearerToken: apiSource.auth_bearer_token || '',
|
||||
authHeaderName: apiSource.auth_header_name || 'X-API-Key',
|
||||
authHeaderValue: apiSource.auth_header_value || '',
|
||||
itemsPath: apiSource.items_path || ''
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildDuplicateApiSourceName,
|
||||
buildDuplicateApiSource
|
||||
};
|
||||
@@ -35,8 +35,9 @@ function buildApiSourceFormViewModel(apiSource, message, currentUser, options, i
|
||||
inUse: Boolean(viewApiSource.inUse),
|
||||
lastResponseJson: viewOptions.lastResponseJson || viewApiSource.lastResponseJson || '',
|
||||
lastPullError: viewOptions.lastPullError || viewApiSource.lastPullError || '',
|
||||
messageVariant: viewOptions.messageVariant || '',
|
||||
isEdit: Boolean(isEdit),
|
||||
showSaveSecondaryActions: Boolean(isEdit),
|
||||
showSaveSecondaryActions: true,
|
||||
deleteDisabled: !isEdit || Boolean(viewApiSource.inUse),
|
||||
formAction: isEdit && viewApiSource.id ? '/data-sources/api-sources/' + viewApiSource.id : '/data-sources/api-sources',
|
||||
formAttrs: isEdit && viewApiSource.id ? 'data-async-save data-async-save-refresh-target="#api-source-response-panel" data-async-save-refresh-state-url="/data-sources/api-sources/' + viewApiSource.id + '/state" data-async-save-close-url="/data-sources/api-sources" data-async-save-new-url="/data-sources/api-sources/new"' : 'data-async-save data-async-save-close-url="/data-sources/api-sources" data-async-save-new-redirect="response-url" data-async-save-new-url="/data-sources/api-sources/new"',
|
||||
|
||||
@@ -4,6 +4,7 @@ const { buildPagination } = require('../../../lib/pagination');
|
||||
const renderApiSourcesPage = require('./list');
|
||||
const renderApiSourceAddPage = require('./add');
|
||||
const renderApiSourceEditPage = require('./edit');
|
||||
const { buildDuplicateApiSourceName, buildDuplicateApiSource } = require('./duplicate');
|
||||
|
||||
function toIsoTimestamp(value) {
|
||||
if (!value) {
|
||||
@@ -185,6 +186,29 @@ module.exports = function registerApiSourceRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/api-sources/:id/duplicate', requirePermission('api-sources.read'), requirePermission('api-sources.create'), async function (req, res, next) {
|
||||
try {
|
||||
const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id));
|
||||
if (!apiSource) {
|
||||
return res.status(404).send('API source not found');
|
||||
}
|
||||
|
||||
let duplicateName = buildDuplicateApiSourceName(apiSource.name);
|
||||
let duplicateIndex = 2;
|
||||
while (await common.fetchDuplicateName(pool, 'i_api_sources', duplicateName)) {
|
||||
duplicateName = buildDuplicateApiSourceName(apiSource.name) + ' (' + duplicateIndex + ')';
|
||||
duplicateIndex += 1;
|
||||
}
|
||||
|
||||
res.send(renderApiSourceAddPage(buildDuplicateApiSource(apiSource, duplicateName), req.query.message ? String(req.query.message) : 'Review the copied values and save when ready.', req.currentUser, {
|
||||
messageVariant: 'info',
|
||||
showSaveSecondaryActions: true
|
||||
}));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/api-sources/:id/state', requirePermission('api-sources.update'), function (req, res, next) {
|
||||
sendRefreshTaskState(req, res, dataSourceTasks, Number(req.params.id)).catch(next);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// RSS feed duplication helpers.
|
||||
|
||||
function buildDuplicateRssFeedName(feedName) {
|
||||
const baseName = 'Copy of ' + String(feedName || '').trim();
|
||||
return baseName;
|
||||
}
|
||||
|
||||
function buildDuplicateRssFeed(rssFeed, duplicateName) {
|
||||
return Object.assign({}, rssFeed, {
|
||||
id: null,
|
||||
name: duplicateName,
|
||||
feedUrl: rssFeed.feed_url || '',
|
||||
lastPulledAt: null,
|
||||
pullError: '',
|
||||
pulledItems: [],
|
||||
updateIntervalValue: rssFeed.update_interval_value,
|
||||
updateIntervalUnit: rssFeed.update_interval_unit || 'minutes',
|
||||
itemLimit: rssFeed.item_limit,
|
||||
feedUrlValue: rssFeed.feed_url || ''
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildDuplicateRssFeedName,
|
||||
buildDuplicateRssFeed
|
||||
};
|
||||
@@ -24,8 +24,9 @@ function buildRssFeedFormViewModel(rssFeed, message, currentUser, options, isEdi
|
||||
inUse: Boolean(viewRssFeed.inUse),
|
||||
pulledItems: viewOptions.pulledItems || viewRssFeed.pulledItems || [],
|
||||
pullError: viewOptions.pullError || viewRssFeed.pullError || '',
|
||||
messageVariant: viewOptions.messageVariant || '',
|
||||
isEdit: Boolean(isEdit),
|
||||
showSaveSecondaryActions: Boolean(isEdit),
|
||||
showSaveSecondaryActions: true,
|
||||
deleteDisabled: !isEdit || Boolean(viewRssFeed.inUse),
|
||||
formAction: isEdit && viewRssFeed.id ? '/data-sources/rss-feeds/' + viewRssFeed.id : '/data-sources/rss-feeds',
|
||||
formAttrs: isEdit && viewRssFeed.id ? 'data-async-save data-async-save-refresh-target="#rss-feed-items-panel" data-async-save-refresh-state-url="/data-sources/rss-feeds/' + viewRssFeed.id + '/state" data-async-save-close-url="/data-sources/rss-feeds" data-async-save-new-url="/data-sources/rss-feeds/new"' : 'data-async-save data-async-save-close-url="/data-sources/rss-feeds" data-async-save-new-redirect="response-url" data-async-save-new-url="/data-sources/rss-feeds/new"',
|
||||
|
||||
@@ -4,6 +4,7 @@ const { buildPagination } = require('../../../lib/pagination');
|
||||
const renderRssFeedsPage = require('./list');
|
||||
const renderRssFeedAddPage = require('./add');
|
||||
const renderRssFeedEditPage = require('./edit');
|
||||
const { buildDuplicateRssFeedName, buildDuplicateRssFeed } = require('./duplicate');
|
||||
|
||||
async function getDataSourceUsageMaps(pool, common) {
|
||||
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
|
||||
@@ -155,6 +156,29 @@ module.exports = function registerRssFeedRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/rss-feeds/:id/duplicate', requirePermission('rss-feeds.read'), requirePermission('rss-feeds.create'), async function (req, res, next) {
|
||||
try {
|
||||
const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id));
|
||||
if (!rssFeed) {
|
||||
return res.status(404).send('RSS feed not found');
|
||||
}
|
||||
|
||||
let duplicateName = buildDuplicateRssFeedName(rssFeed.name);
|
||||
let duplicateIndex = 2;
|
||||
while (await common.fetchDuplicateName(pool, 'i_rss_feeds', duplicateName)) {
|
||||
duplicateName = buildDuplicateRssFeedName(rssFeed.name) + ' (' + duplicateIndex + ')';
|
||||
duplicateIndex += 1;
|
||||
}
|
||||
|
||||
res.send(renderRssFeedAddPage(buildDuplicateRssFeed(rssFeed, duplicateName), req.query.message ? String(req.query.message) : 'Review the copied values and save when ready.', req.currentUser, {
|
||||
messageVariant: 'info',
|
||||
showSaveSecondaryActions: true
|
||||
}));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/rss-feeds/:id/state', requirePermission('rss-feeds.update'), function (req, res, next) {
|
||||
sendRefreshTaskState(req, res, dataSourceTasks, Number(req.params.id)).catch(next);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
const { renderView } = require('../../../view');
|
||||
const { buildTimetableGroupFormViewModel } = require('./form-view-model');
|
||||
|
||||
module.exports = function renderTimetableGroupAddPage(timetableGroup, timetableEntries, message, currentUser) {
|
||||
return renderView('data-sources/timetables/form', buildTimetableGroupFormViewModel(timetableGroup, timetableEntries, message, currentUser, false));
|
||||
module.exports = function renderTimetableGroupAddPage(timetableGroup, timetableEntries, message, currentUser, options) {
|
||||
return renderView('data-sources/timetables/form', buildTimetableGroupFormViewModel(timetableGroup, timetableEntries, message, currentUser, false, options));
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
// Timetable duplication helpers.
|
||||
|
||||
function buildDuplicateTimetableGroupName(groupName) {
|
||||
const baseName = 'Copy of ' + String(groupName || '').trim();
|
||||
return baseName;
|
||||
}
|
||||
|
||||
function buildDuplicateTimetableGroup(timetableGroup, duplicateName) {
|
||||
return Object.assign({}, timetableGroup, {
|
||||
id: null,
|
||||
name: duplicateName,
|
||||
shortDescription: timetableGroup.short_description || ''
|
||||
});
|
||||
}
|
||||
|
||||
function buildDuplicateTimetableEntry(timetableEntry) {
|
||||
return Object.assign({}, timetableEntry, {
|
||||
id: null,
|
||||
schedule_group_id: null
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildDuplicateTimetableGroupName,
|
||||
buildDuplicateTimetableGroup,
|
||||
buildDuplicateTimetableEntry
|
||||
};
|
||||
@@ -26,8 +26,9 @@ function formatDateTimeLocalValue(value) {
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
function buildTimetableGroupFormViewModel(timetableGroup, timetableEntries, message, currentUser, isEdit) {
|
||||
function buildTimetableGroupFormViewModel(timetableGroup, timetableEntries, message, currentUser, isEdit, options) {
|
||||
const viewTimetableGroup = Object.assign(buildDefaultTimetableGroup(), timetableGroup || {});
|
||||
const viewOptions = options || {};
|
||||
const viewTimetableEntries = Array.isArray(timetableEntries) && timetableEntries.length
|
||||
? timetableEntries.map(function (entry) {
|
||||
return Object.assign({}, entry, {
|
||||
@@ -42,11 +43,14 @@ function buildTimetableGroupFormViewModel(timetableGroup, timetableEntries, mess
|
||||
active: 'timetables',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
messageVariant: viewOptions.messageVariant || '',
|
||||
isEdit: Boolean(isEdit),
|
||||
timetableGroup: viewTimetableGroup,
|
||||
timetableEntries: viewTimetableEntries,
|
||||
inUse: Boolean(viewTimetableGroup.inUse),
|
||||
showSaveSecondaryActions: Boolean(isEdit),
|
||||
showSaveSecondaryActions: true,
|
||||
cancelConfirmMessage: "You've made changes. Are you sure you want to leave this page?",
|
||||
deleteConfirmMessage: isEdit ? 'Delete this timetable group?' : '',
|
||||
deleteDisabled: !isEdit || Boolean(viewTimetableGroup.inUse),
|
||||
formAction: isEdit && viewTimetableGroup.id ? '/data-sources/timetables/' + viewTimetableGroup.id : '/data-sources/timetables',
|
||||
formAttrs: 'data-async-save' + (isEdit ? ' data-async-save-close-url="/data-sources/timetables"' : ' data-async-save-new-redirect="response-url"') + ' data-async-save-new-url="/data-sources/timetables/new"',
|
||||
|
||||
@@ -4,6 +4,7 @@ const { buildPagination } = require('../../../lib/pagination');
|
||||
const renderTimetableGroupsPage = require('./list');
|
||||
const renderTimetableGroupAddPage = require('./add');
|
||||
const renderTimetableGroupEditPage = require('./edit');
|
||||
const { buildDuplicateTimetableGroupName, buildDuplicateTimetableGroup, buildDuplicateTimetableEntry } = require('./duplicate');
|
||||
|
||||
async function getDataSourceUsageMaps(pool, common) {
|
||||
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
|
||||
@@ -25,8 +26,8 @@ async function getDataSourceUsageMaps(pool, common) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(value, 'schedule_group_id')) {
|
||||
const timetableGroupId = Number(value.schedule_group_id);
|
||||
if (Object.prototype.hasOwnProperty.call(value, 'timetable_group_id')) {
|
||||
const timetableGroupId = Number(value.timetable_group_id);
|
||||
if (Number.isFinite(timetableGroupId)) {
|
||||
timetableGroupIds.add(timetableGroupId);
|
||||
}
|
||||
@@ -176,6 +177,32 @@ module.exports = function registerTimetableRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
app.get('/data-sources/timetables/:id/duplicate', requirePermission('timetables.read'), requirePermission('timetables.create'), async function (req, res, next) {
|
||||
try {
|
||||
const timetableGroup = await common.fetchTimetableGroupById(pool, Number(req.params.id));
|
||||
if (!timetableGroup) {
|
||||
return res.status(404).send('Timetable group not found');
|
||||
}
|
||||
|
||||
const timetableEntries = typeof common.fetchTimetableEntriesByGroupId === 'function'
|
||||
? await common.fetchTimetableEntriesByGroupId(pool, timetableGroup.id)
|
||||
: [];
|
||||
|
||||
let duplicateName = buildDuplicateTimetableGroupName(timetableGroup.name);
|
||||
let duplicateIndex = 2;
|
||||
while (await common.fetchDuplicateName(pool, 'i_schedule_groups', duplicateName)) {
|
||||
duplicateName = buildDuplicateTimetableGroupName(timetableGroup.name) + ' (' + duplicateIndex + ')';
|
||||
duplicateIndex += 1;
|
||||
}
|
||||
|
||||
res.send(renderTimetableGroupAddPage(buildDuplicateTimetableGroup(timetableGroup, duplicateName), timetableEntries.map(buildDuplicateTimetableEntry), req.query.message ? String(req.query.message) : 'Review the copied values and save when ready.', req.currentUser, {
|
||||
messageVariant: 'info'
|
||||
}));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
app.post('/data-sources/timetables', requirePermission('timetables.create'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
|
||||
@@ -59,6 +59,7 @@ function registerAuthAndAccountRoutes(app, deps) {
|
||||
getAuditUserId: deps.getAuditUserId,
|
||||
verifyPassword: deps.verifyPassword,
|
||||
hashPassword: deps.hashPassword,
|
||||
validatePasswordStrength: deps.validatePasswordStrength,
|
||||
createUserSession: deps.createUserSession,
|
||||
setSessionCookie: deps.setSessionCookie
|
||||
});
|
||||
@@ -70,6 +71,7 @@ function registerAuthAndAccountRoutes(app, deps) {
|
||||
formatDashboardDate: deps.formatDashboardDate,
|
||||
getAuditUserId: deps.getAuditUserId,
|
||||
hashPassword: deps.hashPassword,
|
||||
validatePasswordStrength: deps.validatePasswordStrength,
|
||||
readArrayField: deps.readArrayField,
|
||||
rbacData: deps.rbacData,
|
||||
requirePermission: deps.requirePermission
|
||||
|
||||
@@ -9,6 +9,12 @@ const TASKS_PER_PAGE = 25;
|
||||
const QUEUE_PAGE_URL = '/settings/tasks-background';
|
||||
const SCHEDULED_PAGE_URL = '/settings/tasks-scheduled';
|
||||
|
||||
const DATE_FILTER_FIELDS = {
|
||||
createdAt: 'queued',
|
||||
startedAt: 'started',
|
||||
finishedAt: 'finished'
|
||||
};
|
||||
|
||||
function formatIntervalLabel(intervalMs) {
|
||||
const value = Math.max(1, Number(intervalMs) || 0);
|
||||
if (value % 60000 === 0) {
|
||||
@@ -36,6 +42,54 @@ function normalizeSourceFilter(sourceType, sourceId) {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDateInput(value) {
|
||||
const normalizedValue = String(value || '').trim();
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(normalizedValue) ? normalizedValue : '';
|
||||
}
|
||||
|
||||
function normalizeDateField(value) {
|
||||
const normalizedField = String(value || '').trim();
|
||||
return DATE_FILTER_FIELDS[normalizedField] ? normalizedField : 'createdAt';
|
||||
}
|
||||
|
||||
function getIsoDateString(value) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function getTaskDateValue(task, dateField) {
|
||||
if (!task || !dateField) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return task[dateField] || '';
|
||||
}
|
||||
|
||||
function taskMatchesDateFilter(task, dateFilter) {
|
||||
if (!dateFilter || (!dateFilter.from && !dateFilter.to)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const taskDate = getIsoDateString(getTaskDateValue(task, dateFilter.field));
|
||||
if (!taskDate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (dateFilter.from && taskDate < dateFilter.from) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (dateFilter.to && taskDate > dateFilter.to) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function taskMatchesFilters(task, sourceFilter) {
|
||||
if (!sourceFilter || !sourceFilter.sourceType || !sourceFilter.sourceId) {
|
||||
return true;
|
||||
@@ -107,12 +161,14 @@ function buildQueuePageViewModel(data, message, currentUser) {
|
||||
queryState.sourceType = sourceFilter.sourceType;
|
||||
queryState.sourceId = sourceFilter.sourceId;
|
||||
const filteredTasks = tasks.filter(function (task) {
|
||||
return taskMatchesFilters(task, sourceFilter) && taskMatchesSearch(queryState.search, [
|
||||
return taskMatchesFilters(task, sourceFilter) && taskMatchesDateFilter(task, { field: 'createdAt', from: '', to: '' }) && taskMatchesSearch(queryState.search, [
|
||||
'title',
|
||||
'key',
|
||||
'category',
|
||||
'status',
|
||||
'errorMessage',
|
||||
'createdAt',
|
||||
'startedAt',
|
||||
'finishedAt',
|
||||
function (item) { return item && item.metadata && item.metadata.sourceName; },
|
||||
function (item) { return item && item.metadata && item.metadata.sourceType; },
|
||||
function (item) { return item && item.metadata && item.metadata.sourceId; }
|
||||
@@ -199,6 +255,11 @@ function buildQueuePageViewModel(data, message, currentUser) {
|
||||
summary: summary,
|
||||
statusCards: statusCards,
|
||||
activeSourceLabel: queryState.sourceType && queryState.sourceId ? `${queryState.sourceType} #${queryState.sourceId}` : '',
|
||||
search: queryState.search,
|
||||
sort: sort,
|
||||
direction: direction,
|
||||
sourceType: queryState.sourceType,
|
||||
sourceId: queryState.sourceId,
|
||||
clearFiltersUrl: QUEUE_PAGE_URL,
|
||||
hasActiveFilters: hasActiveFilters,
|
||||
pagination: pagination,
|
||||
|
||||
@@ -77,6 +77,8 @@ module.exports = function registerFontRoutes(app, deps) {
|
||||
const backgroundTaskQueue = deps.backgroundTaskQueue;
|
||||
const setAuthMessageCookie = deps.setAuthMessageCookie;
|
||||
|
||||
const FONT_FAMILY_MAX_LENGTH = 255;
|
||||
|
||||
if (!pool || !pages || !upload || !uploadDir) {
|
||||
throw new Error('registerFontRoutes requires pool, pages, upload, and uploadDir.');
|
||||
}
|
||||
@@ -106,7 +108,7 @@ module.exports = function registerFontRoutes(app, deps) {
|
||||
return res.status(400).send('No font file was uploaded.');
|
||||
}
|
||||
|
||||
const result = await fontLibrary.addFontFile(uploadDir, req.file, String(req.body && req.body.font_family || '').trim());
|
||||
const result = await fontLibrary.addFontFile(uploadDir, req.file, String(req.body && req.body.font_family || '').trim().slice(0, FONT_FAMILY_MAX_LENGTH));
|
||||
if (backgroundTaskQueue && typeof backgroundTaskQueue.enqueueTask === 'function') {
|
||||
await backgroundTaskQueue.enqueueTask({
|
||||
key: `font-sync:${result && result.font && result.font.id ? result.font.id : Date.now()}`,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// RBAC role duplicate helpers.
|
||||
|
||||
function buildDuplicateRoleName(roleName) {
|
||||
const source = String(roleName || 'Role').trim() || 'Role';
|
||||
return 'Copy of ' + source;
|
||||
}
|
||||
|
||||
function buildDuplicateRole(role, duplicateName) {
|
||||
const source = role || {};
|
||||
return {
|
||||
id: null,
|
||||
name: String(duplicateName || buildDuplicateRoleName(source.name)).trim(),
|
||||
description: String(source.description || '').trim()
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildDuplicateRoleName,
|
||||
buildDuplicateRole
|
||||
};
|
||||
@@ -1,5 +1,81 @@
|
||||
// Shared RBAC form view-model helpers.
|
||||
|
||||
function normalizeSectionId(value) {
|
||||
return String(value || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
||||
}
|
||||
|
||||
function buildPermissionSections(permissionGroups) {
|
||||
const sections = [];
|
||||
const sectionIndex = new Map();
|
||||
const actionDefinitions = [
|
||||
{ key: 'read', label: 'Read' },
|
||||
{ key: 'create', label: 'Create' },
|
||||
{ key: 'update', label: 'Update' },
|
||||
{ key: 'delete', label: 'Delete' },
|
||||
{ key: 'allow', label: 'Allow' }
|
||||
];
|
||||
|
||||
(Array.isArray(permissionGroups) ? permissionGroups : []).forEach(function (group) {
|
||||
const sectionTitle = String(group && group.categoryName || 'Permissions').trim() || 'Permissions';
|
||||
const sectionKey = normalizeSectionId(sectionTitle) || 'permissions';
|
||||
|
||||
if (!sectionIndex.has(sectionKey)) {
|
||||
sectionIndex.set(sectionKey, {
|
||||
id: sectionKey,
|
||||
title: sectionTitle,
|
||||
sectionOrder: Number(group && group.sectionOrder) || 999,
|
||||
groups: []
|
||||
});
|
||||
sections.push(sectionIndex.get(sectionKey));
|
||||
}
|
||||
|
||||
const section = sectionIndex.get(sectionKey);
|
||||
const groupPermissions = Array.isArray(group && group.permissions) ? group.permissions.slice() : [];
|
||||
const sectionGroup = Object.assign({}, group, {
|
||||
permissions: groupPermissions
|
||||
});
|
||||
|
||||
section.groups.push(sectionGroup);
|
||||
});
|
||||
|
||||
sections.forEach(function (section) {
|
||||
section.actions = actionDefinitions.map(function (actionDefinition) {
|
||||
return {
|
||||
key: actionDefinition.key,
|
||||
label: actionDefinition.label
|
||||
};
|
||||
});
|
||||
|
||||
section.groups.sort(function (left, right) {
|
||||
const leftOrder = Number(left.resourceOrder) || 999;
|
||||
const rightOrder = Number(right.resourceOrder) || 999;
|
||||
if (leftOrder !== rightOrder) {
|
||||
return leftOrder - rightOrder;
|
||||
}
|
||||
return String(left.title || '').localeCompare(String(right.title || ''));
|
||||
});
|
||||
|
||||
section.groups.forEach(function (group) {
|
||||
group.cells = section.actions.map(function (action) {
|
||||
return (Array.isArray(group.permissions) ? group.permissions : []).find(function (permission) {
|
||||
return String(permission.actionKey || '').trim().toLowerCase() === action.key;
|
||||
}) || null;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
sections.sort(function (left, right) {
|
||||
const leftOrder = Number(left.sectionOrder) || 999;
|
||||
const rightOrder = Number(right.sectionOrder) || 999;
|
||||
if (leftOrder !== rightOrder) {
|
||||
return leftOrder - rightOrder;
|
||||
}
|
||||
return String(left.title || '').localeCompare(String(right.title || ''));
|
||||
});
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
function buildRbacAddViewModel(message, currentUser, formValues, permissionGroups, users, pagination, messageVariant) {
|
||||
return {
|
||||
title: 'Create role',
|
||||
@@ -19,11 +95,12 @@ function buildRbacAddViewModel(message, currentUser, formValues, permissionGroup
|
||||
currentUser: currentUser || null,
|
||||
formValues: formValues || {},
|
||||
permissionGroups: permissionGroups || [],
|
||||
permissionSections: buildPermissionSections(permissionGroups),
|
||||
users: users || [],
|
||||
pagination: pagination || null,
|
||||
roleEditBasePath: '/rbac/new',
|
||||
scripts: ['js/rbac-permissions.js'],
|
||||
formAttrs: 'data-async-save data-async-save-close-url="/rbac" data-async-save-new-redirect="response-url"'
|
||||
formAttrs: 'data-async-save data-async-save-close-url="/rbac" data-async-save-new-redirect="response-url" data-async-save-new-url="/rbac/new"'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -46,10 +123,12 @@ function buildRbacEditViewModel(role, message, currentUser, permissionGroups, us
|
||||
role: role,
|
||||
inUse: Boolean(role && role.inUse),
|
||||
permissionGroups: permissionGroups || [],
|
||||
permissionSections: buildPermissionSections(permissionGroups),
|
||||
users: users || [],
|
||||
pagination: pagination || null,
|
||||
roleEditBasePath: '/rbac/' + Number(role && role.id) + '/edit',
|
||||
scripts: ['js/rbac-permissions.js']
|
||||
scripts: ['js/rbac-permissions.js'],
|
||||
formAttrs: 'data-async-save data-async-save-close-url="/rbac" data-async-save-new-url="/rbac/new"'
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// User duplicate helpers.
|
||||
|
||||
function buildDuplicateUserName(name) {
|
||||
const source = String(name || 'User').trim() || 'User';
|
||||
return 'Copy of ' + source;
|
||||
}
|
||||
|
||||
function buildDuplicateUser(user, duplicateName, duplicateUsername) {
|
||||
const source = user || {};
|
||||
return {
|
||||
id: null,
|
||||
name: String(duplicateName || buildDuplicateUserName(source.name)).trim(),
|
||||
username: String(duplicateUsername || '').trim()
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildDuplicateUserName,
|
||||
buildDuplicateUser
|
||||
};
|
||||
@@ -32,7 +32,7 @@ function buildUsersAddViewModel(message, currentUser, roles, formValues, message
|
||||
footerDeleteTitle: 'Delete is available after the user has been created.',
|
||||
footerShowDelete: true,
|
||||
message: message,
|
||||
messageVariant: messageVariant || 'primary',
|
||||
messageVariant: messageVariant || 'success',
|
||||
currentUser: currentUser || null,
|
||||
roles: mapRolesForForm(roles, []),
|
||||
formValues: formValues || {},
|
||||
@@ -55,11 +55,13 @@ function buildUsersEditViewModel(user, message, currentUser, roles) {
|
||||
footerDeleteConfirmMessage: 'Delete this user?',
|
||||
footerDeleteTitle: user && user.inUse ? 'Delete is disabled while this user is the only account left.' : '',
|
||||
footerShowDelete: true,
|
||||
showSaveSecondaryActions: true,
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
user: user,
|
||||
inUse: Boolean(user && user.inUse),
|
||||
roles: mapRolesForForm(roles, user && user.roleIds)
|
||||
roles: mapRolesForForm(roles, user && user.roleIds),
|
||||
formAttrs: 'data-async-save data-async-save-close-url="/users" data-async-save-new-url="/users/new"'
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// Announcement duplication helpers.
|
||||
|
||||
function buildDuplicateAnnouncementName(announcementName) {
|
||||
const baseName = 'Copy of ' + String(announcementName || '').trim();
|
||||
return baseName;
|
||||
}
|
||||
|
||||
function buildDuplicateAnnouncement(announcement, duplicateName) {
|
||||
return Object.assign({}, announcement, {
|
||||
id: null,
|
||||
short_label: duplicateName,
|
||||
expires_at: null,
|
||||
screen_ids: Array.isArray(announcement && announcement.screen_ids) ? announcement.screen_ids.slice() : []
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildDuplicateAnnouncementName,
|
||||
buildDuplicateAnnouncement
|
||||
};
|
||||
@@ -24,8 +24,11 @@ function buildAnnouncementFormViewModel(announcement, message, currentUser, opti
|
||||
const announcementColors = Array.isArray(options && options.announcementColors) ? options.announcementColors : [];
|
||||
const announcementIcons = Array.isArray(options && options.announcementIcons) ? options.announcementIcons : [];
|
||||
const announcementScreens = Array.isArray(options && options.announcementScreens) ? options.announcementScreens : [];
|
||||
const viewOptions = options || {};
|
||||
const viewAnnouncement = Object.assign({}, buildDefaultAnnouncement(), announcement || {});
|
||||
const canDelete = Boolean(currentUser && Array.isArray(currentUser.permissionKeys) && currentUser.permissionKeys.indexOf('announcements.delete') !== -1);
|
||||
const hasTargets = Array.isArray(viewAnnouncement.screen_ids) && viewAnnouncement.screen_ids.length > 0;
|
||||
const isActive = !(viewAnnouncement.expires_at && new Date(viewAnnouncement.expires_at).getTime() <= Date.now());
|
||||
|
||||
return {
|
||||
title: isEdit ? 'Edit announcement' : 'Add announcement',
|
||||
@@ -34,9 +37,29 @@ function buildAnnouncementFormViewModel(announcement, message, currentUser, opti
|
||||
currentUser: currentUser || null,
|
||||
announcement: viewAnnouncement,
|
||||
isEdit: Boolean(isEdit),
|
||||
showSaveSecondaryActions: Boolean(isEdit),
|
||||
showDeleteAction: Boolean(isEdit && canDelete),
|
||||
showSaveSecondaryActions: true,
|
||||
messageVariant: viewOptions.messageVariant || '',
|
||||
actionLabel: isActive ? 'Stop' : 'Play',
|
||||
actionIcon: isActive ? 'bi-stop-fill' : 'bi-play-fill',
|
||||
actionDisabled: !isActive && !hasTargets,
|
||||
actionClassName: !isActive && !hasTargets
|
||||
? 'btn-outline-info'
|
||||
: (isActive ? 'btn-outline-warning' : 'btn-info'),
|
||||
actionDisabledTitle: !isActive && !hasTargets
|
||||
? 'Select at least one screen group to play this announcement.'
|
||||
: '',
|
||||
actionConfirmMessage: isActive
|
||||
? 'Stop this announcement on the selected screens now?'
|
||||
: 'Send this announcement to the selected screens now?',
|
||||
actionPath: isEdit && viewAnnouncement.id
|
||||
? (isActive ? '/announcements/' + viewAnnouncement.id + '/stop' : '/announcements/' + viewAnnouncement.id + '/play')
|
||||
: '',
|
||||
showDeleteAction: Boolean(canDelete || !isEdit),
|
||||
deleteDisabled: !isEdit || !Boolean(viewAnnouncement && viewAnnouncement.canDelete),
|
||||
deleteConfirmMessage: isEdit ? 'Delete this announcement?' : '',
|
||||
deleteTitle: !isEdit
|
||||
? 'Delete is available after the announcement has been created.'
|
||||
: (!Boolean(viewAnnouncement && viewAnnouncement.canDelete) ? 'This announcement is currently active and cannot be deleted.' : ''),
|
||||
formAction: isEdit && viewAnnouncement && viewAnnouncement.id ? '/announcements/' + viewAnnouncement.id : '/announcements',
|
||||
formAttrs: 'data-async-save' + (isEdit ? ' data-async-save-close-url="/announcements"' : ' data-async-save-new-redirect="response-url"') + ' data-async-save-new-url="/announcements/new"',
|
||||
cancelUrl: '/announcements',
|
||||
|
||||
@@ -21,10 +21,15 @@ function formatStatusLabel(announcement) {
|
||||
|
||||
function formatActionButton(announcement) {
|
||||
const isActive = formatStatusLabel(announcement) === 'Active';
|
||||
const hasTargets = Math.max(0, Number(announcement && announcement.screen_target_count) || 0) > 0;
|
||||
return {
|
||||
label: isActive ? 'Stop' : 'Play',
|
||||
icon: isActive ? 'bi-stop-fill' : 'bi-play-fill',
|
||||
className: isActive ? 'btn-outline-warning' : 'btn-outline-success',
|
||||
disabled: !isActive && !hasTargets,
|
||||
className: !isActive && !hasTargets
|
||||
? 'btn-outline-info'
|
||||
: (isActive ? 'btn-outline-warning' : 'btn-info'),
|
||||
disabledTitle: !isActive && !hasTargets ? 'Select at least one screen group to play this announcement.' : '',
|
||||
confirmMessage: isActive
|
||||
? 'Stop this announcement on the selected screens now?'
|
||||
: 'Send this announcement to the selected screens now?',
|
||||
@@ -32,6 +37,10 @@ function formatActionButton(announcement) {
|
||||
};
|
||||
}
|
||||
|
||||
function isAnnouncementDeletionBlocked(announcement) {
|
||||
return formatStatusLabel(announcement) === 'Active';
|
||||
}
|
||||
|
||||
function formatIconLabel(iconKey) {
|
||||
const normalized = String(iconKey || '').trim().toLowerCase() || DEFAULT_ANNOUNCEMENT_ICON;
|
||||
return ANNOUNCEMENT_ICON_LABELS[normalized] || normalized;
|
||||
@@ -69,6 +78,7 @@ function formatScreenTargetsLabel(announcement) {
|
||||
module.exports = function renderAnnouncementsPage(data, message, currentUser) {
|
||||
const announcements = (data.announcements || []).map(function (announcement) {
|
||||
const actionButton = formatActionButton(announcement);
|
||||
const deleteDisabled = isAnnouncementDeletionBlocked(announcement);
|
||||
return Object.assign({}, announcement, {
|
||||
shortLabel: String(announcement.short_label || '').trim(),
|
||||
iconKey: String(announcement.icon_key || DEFAULT_ANNOUNCEMENT_ICON).trim().toLowerCase() || DEFAULT_ANNOUNCEMENT_ICON,
|
||||
@@ -77,9 +87,13 @@ module.exports = function renderAnnouncementsPage(data, message, currentUser) {
|
||||
expiresAtLabel: formatExpiresLabel(announcement.expires_at),
|
||||
screenTargetsLabel: formatScreenTargetsLabel(announcement),
|
||||
statusLabel: formatStatusLabel(announcement),
|
||||
deleteDisabled: deleteDisabled,
|
||||
deleteBlockedMessage: deleteDisabled ? 'This announcement is currently active and cannot be deleted.' : '',
|
||||
actionButtonLabel: actionButton.label,
|
||||
actionButtonIcon: actionButton.icon,
|
||||
actionButtonClassName: actionButton.className,
|
||||
actionButtonDisabled: actionButton.disabled,
|
||||
actionButtonDisabledTitle: actionButton.disabledTitle,
|
||||
actionButtonConfirmMessage: actionButton.confirmMessage,
|
||||
actionButtonPath: actionButton.actionPath
|
||||
});
|
||||
|
||||
@@ -11,12 +11,13 @@ module.exports = function registerAnnouncementRoutes(app, deps) {
|
||||
const { buildPagination } = require('../../../lib/pagination');
|
||||
const announcementData = require('#src/data/announcements');
|
||||
const announcementIcons = require('#src/data/announcement-icons');
|
||||
const { buildDuplicateAnnouncementName, buildDuplicateAnnouncement } = require('./duplicate');
|
||||
|
||||
const LIST_PAGE_SIZE = 25;
|
||||
const ANNOUNCEMENT_TYPES = [
|
||||
{ value: 'lower-third', label: 'Lower third' },
|
||||
{ value: 'fullscreen', label: 'Fullscreen' },
|
||||
{ value: 'top-banner', label: 'Top banner' }
|
||||
{ value: 'lower-third', label: 'Lower third', icon: 'arrow-bar-down' },
|
||||
{ value: 'fullscreen', label: 'Fullscreen', icon: 'arrows-fullscreen' },
|
||||
{ value: 'top-banner', label: 'Top banner', icon: 'arrow-bar-up' }
|
||||
];
|
||||
const ANNOUNCEMENT_COLORS = [
|
||||
{ value: 'primary', label: 'Primary' },
|
||||
@@ -63,8 +64,7 @@ module.exports = function registerAnnouncementRoutes(app, deps) {
|
||||
}
|
||||
|
||||
function isAnnouncementDeletionBlocked(announcement) {
|
||||
const screenIds = Array.isArray(announcement && announcement.screen_ids) ? announcement.screen_ids : [];
|
||||
return isAnnouncementActive(announcement) || screenIds.length > 0;
|
||||
return isAnnouncementActive(announcement);
|
||||
}
|
||||
|
||||
async function fetchAllScreenSlugs() {
|
||||
@@ -221,6 +221,10 @@ module.exports = function registerAnnouncementRoutes(app, deps) {
|
||||
const durationMode = String(normalized.durationMode || '').trim() || (durationSeconds > 0 ? 'duration' : 'until_disabled');
|
||||
const isActive = isAnnouncementActive(normalized);
|
||||
const canDelete = Boolean(normalized.id) && !isAnnouncementDeletionBlocked(normalized);
|
||||
const hasTargets = Array.isArray(normalized.screen_ids) && normalized.screen_ids.length > 0;
|
||||
const deleteBlockedMessage = canDelete
|
||||
? ''
|
||||
: 'This announcement is currently active and cannot be deleted.';
|
||||
|
||||
return Object.assign({}, normalized, {
|
||||
durationMode: durationMode,
|
||||
@@ -230,7 +234,11 @@ module.exports = function registerAnnouncementRoutes(app, deps) {
|
||||
isActive: isActive,
|
||||
actionLabel: isActive ? 'Stop' : 'Play',
|
||||
actionIcon: isActive ? 'bi-stop-fill' : 'bi-play-fill',
|
||||
actionClassName: isActive ? 'btn-outline-warning' : 'btn-outline-success',
|
||||
actionDisabled: !isActive && !hasTargets,
|
||||
actionClassName: !isActive && !hasTargets
|
||||
? 'btn-outline-info'
|
||||
: (isActive ? 'btn-outline-warning' : 'btn-info'),
|
||||
actionDisabledTitle: !isActive && !hasTargets ? 'Select at least one screen group to play this announcement.' : '',
|
||||
actionConfirmMessage: isActive
|
||||
? 'Stop this announcement on the selected screens now?'
|
||||
: 'Send this announcement to the selected screens now?',
|
||||
@@ -238,7 +246,7 @@ module.exports = function registerAnnouncementRoutes(app, deps) {
|
||||
? `/announcements/${normalized.id}/stop`
|
||||
: `/announcements/${normalized.id}/play`,
|
||||
canDelete: canDelete,
|
||||
deleteBlockedMessage: canDelete ? '' : 'This announcement is still in use by one or more screens.'
|
||||
deleteBlockedMessage: deleteBlockedMessage
|
||||
});
|
||||
}
|
||||
|
||||
@@ -301,6 +309,33 @@ module.exports = function registerAnnouncementRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/announcements/:id/duplicate', requirePermission('announcements.read'), requirePermission('announcements.create'), async function (req, res, next) {
|
||||
try {
|
||||
const announcement = await common.fetchAnnouncementById(pool, Number(req.params.id));
|
||||
if (!announcement) {
|
||||
return res.status(404).send('Announcement not found');
|
||||
}
|
||||
|
||||
const screens = await fetchAnnouncementScreens();
|
||||
let duplicateName = buildDuplicateAnnouncementName(announcement.short_label || announcement.message || 'Announcement');
|
||||
let duplicateIndex = 2;
|
||||
while (await common.fetchDuplicateName(pool, 'd_announcements', duplicateName, null, 'short_label')) {
|
||||
duplicateName = buildDuplicateAnnouncementName(announcement.short_label || announcement.message || 'Announcement') + ' (' + duplicateIndex + ')';
|
||||
duplicateIndex += 1;
|
||||
}
|
||||
|
||||
res.send(pages.renderAnnouncementAddPage(buildAnnouncementFormData(buildDuplicateAnnouncement(announcement, duplicateName)), req.query.message ? String(req.query.message) : 'Review the copied values and save when ready.', req.currentUser, {
|
||||
announcementTypes: ANNOUNCEMENT_TYPES,
|
||||
announcementColors: ANNOUNCEMENT_COLORS,
|
||||
announcementIcons: ANNOUNCEMENT_ICONS,
|
||||
announcementScreens: buildAnnouncementScreenOptions(screens, announcement.screen_ids || []),
|
||||
messageVariant: 'info'
|
||||
}));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/announcements', requirePermission('announcements.create'), async function (req, res, next) {
|
||||
try {
|
||||
const message = String(req.body.message || '').replace(/\r\n/g, '\n').trim();
|
||||
@@ -421,7 +456,7 @@ module.exports = function registerAnnouncementRoutes(app, deps) {
|
||||
}
|
||||
|
||||
if (isAnnouncementDeletionBlocked(announcement)) {
|
||||
return res.redirect('/announcements/' + announcement.id + '/edit?message=' + encodeURIComponent('This announcement is still in use by one or more screens.'));
|
||||
return res.redirect('/announcements/' + announcement.id + '/edit?message=' + encodeURIComponent('This announcement is currently active and cannot be deleted.'));
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM d_announcements WHERE id = ?', [announcement.id]);
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// Canvas size duplicate helpers.
|
||||
|
||||
function buildDuplicateCanvasSizeName(name) {
|
||||
const source = String(name || 'Canvas size').trim() || 'Canvas size';
|
||||
return 'Copy of ' + source;
|
||||
}
|
||||
|
||||
function buildDuplicateCanvasSize(canvasSize, duplicateName) {
|
||||
const source = canvasSize || {};
|
||||
return {
|
||||
id: null,
|
||||
name: String(duplicateName || buildDuplicateCanvasSizeName(source.name)).trim(),
|
||||
width: Math.max(1, Number(source.width || 1920)),
|
||||
height: Math.max(1, Number(source.height || 1080))
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildDuplicateCanvasSizeName,
|
||||
buildDuplicateCanvasSize
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
// Shared canvas size form view-model builder.
|
||||
|
||||
const { MAX_CANVAS_SIZE_DIMENSION } = require('#src/data');
|
||||
|
||||
function buildDefaultCanvasSize() {
|
||||
return {
|
||||
id: null,
|
||||
@@ -19,6 +21,8 @@ function buildCanvasSizeFormViewModel(canvasSize, message, currentUser, isEdit)
|
||||
currentUser: currentUser || null,
|
||||
canvasSize: viewCanvasSize,
|
||||
inUse: Boolean(viewCanvasSize && viewCanvasSize.inUse),
|
||||
dimensionsLocked: Boolean(viewCanvasSize && viewCanvasSize.inUse),
|
||||
maxDimension: MAX_CANVAS_SIZE_DIMENSION,
|
||||
isEdit: Boolean(isEdit),
|
||||
formAction: isEdit && viewCanvasSize ? '/canvas-sizes/' + viewCanvasSize.id : '/canvas-sizes',
|
||||
formAttrs: 'data-async-save' + (isEdit ? ' data-async-save-close-url="/canvas-sizes"' : ' data-async-save-new-redirect="response-url"') + ' data-async-save-new-url="/canvas-sizes/new"',
|
||||
|
||||
@@ -2,8 +2,15 @@
|
||||
|
||||
const renderPlaylistEditorPage = require('./form-page');
|
||||
|
||||
module.exports = async function renderPlaylistFormPage(message, currentUser, data, pool) {
|
||||
module.exports = async function renderPlaylistFormPage(playlistOrMessage, messageOrCurrentUser, currentUserOrData, dataOrPool, poolMaybe) {
|
||||
const hasPlaylistArgument = arguments.length >= 5;
|
||||
const playlist = hasPlaylistArgument ? playlistOrMessage : null;
|
||||
const message = hasPlaylistArgument ? messageOrCurrentUser : playlistOrMessage;
|
||||
const currentUser = hasPlaylistArgument ? currentUserOrData : messageOrCurrentUser;
|
||||
const data = hasPlaylistArgument ? dataOrPool : currentUserOrData;
|
||||
const pool = hasPlaylistArgument ? poolMaybe : dataOrPool;
|
||||
const adminData = data || (pool ? await require('#src/data').fetchAdminData(pool) : { slides: [] });
|
||||
return renderPlaylistEditorPage(null, adminData, message, currentUser, { isEdit: false });
|
||||
|
||||
return renderPlaylistEditorPage(playlist, adminData, message, currentUser, { isEdit: false });
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// Playlist duplicate helpers.
|
||||
|
||||
function buildDuplicatePlaylistName(name) {
|
||||
const source = String(name || 'Playlist').trim() || 'Playlist';
|
||||
return 'Copy of ' + source;
|
||||
}
|
||||
|
||||
function buildDuplicatePlaylist(playlist, duplicateName) {
|
||||
const source = playlist || {};
|
||||
return {
|
||||
id: source.id || null,
|
||||
name: String(duplicateName || buildDuplicatePlaylistName(source.name)).trim(),
|
||||
fade_between_slides: Boolean(source.fade_between_slides),
|
||||
skip_unavailable_rtmp: Boolean(source.skip_unavailable_rtmp),
|
||||
canvas_id: source.canvas_id || null
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildDuplicatePlaylistName,
|
||||
buildDuplicatePlaylist
|
||||
};
|
||||
@@ -7,6 +7,7 @@ module.exports = function registerPlaylistsRoutes(app, deps) {
|
||||
const fetchScreensByPlaylistId = deps.fetchScreensByPlaylistId;
|
||||
const { buildPagination } = require('../../../lib/pagination');
|
||||
const requirePermission = deps.requirePermission;
|
||||
const { buildDuplicatePlaylistName, buildDuplicatePlaylist } = require('./duplicate');
|
||||
|
||||
const LIST_PAGE_SIZE = 25;
|
||||
|
||||
@@ -51,6 +52,28 @@ module.exports = function registerPlaylistsRoutes(app, deps) {
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/playlists/:id/duplicate', requirePermission('playlists.read'), requirePermission('playlists.create'), async function (req, res, next) {
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
|
||||
const data = await common.fetchAdminData(pool);
|
||||
let duplicateName = buildDuplicatePlaylistName(playlist.name);
|
||||
let duplicateIndex = 2;
|
||||
while (await common.fetchDuplicateName(pool, 'c_playlists', duplicateName)) {
|
||||
duplicateName = buildDuplicatePlaylistName(playlist.name) + ' (' + duplicateIndex + ')';
|
||||
duplicateIndex += 1;
|
||||
}
|
||||
|
||||
const duplicatePlaylist = buildDuplicatePlaylist(playlist, duplicateName);
|
||||
res.send(await pages.renderPlaylistFormPage(duplicatePlaylist, req.query.message ? String(req.query.message) : 'Review the copied values and save when ready.', req.currentUser, data, pool));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/playlists/:id/edit', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
|
||||
|
||||
@@ -16,8 +16,9 @@ function buildScreenFormViewModel(screen, data, message, currentUser, isEdit) {
|
||||
screen: viewScreen,
|
||||
inUse: Boolean(viewScreen.inUse),
|
||||
isEdit: Boolean(isEdit),
|
||||
showSaveSecondaryActions: Boolean(isEdit),
|
||||
showSaveSecondaryActions: true,
|
||||
deleteDisabled: Boolean(isEdit) ? Boolean(viewScreen.inUse) : true,
|
||||
deleteConfirmMessage: Boolean(isEdit) ? 'Delete this screen?' : '',
|
||||
formAction: Boolean(isEdit) && viewScreen.id ? '/screens/' + viewScreen.id : '/screens',
|
||||
formAttrs: Boolean(isEdit)
|
||||
? 'data-async-save data-async-save-close-url="/screens" data-async-save-new-url="/screens/new"'
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
const { renderView } = require('../../../view');
|
||||
const { buildSlideFormViewModel } = require('./form-view-model');
|
||||
|
||||
module.exports = function renderSlideAddPage(data, message, currentUser) {
|
||||
return renderView('slides/form', buildSlideFormViewModel(data || {}, null, message, currentUser, false));
|
||||
module.exports = function renderSlideAddPage(slideOrData, dataOrMessage, messageOrCurrentUser, currentUserMaybe) {
|
||||
const hasSlideArgument = arguments.length >= 4;
|
||||
const slide = hasSlideArgument ? slideOrData : null;
|
||||
const data = hasSlideArgument ? dataOrMessage : slideOrData;
|
||||
const message = hasSlideArgument ? messageOrCurrentUser : dataOrMessage;
|
||||
const currentUser = hasSlideArgument ? currentUserMaybe : messageOrCurrentUser;
|
||||
return renderView('slides/form', buildSlideFormViewModel(data || {}, slide || null, message, currentUser, false));
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
// Slide duplicate helpers.
|
||||
|
||||
function buildDuplicateSlideName(title) {
|
||||
const source = String(title || 'Slide').trim() || 'Slide';
|
||||
return 'Copy of ' + source;
|
||||
}
|
||||
|
||||
function buildDuplicateSlide(slide, duplicateName) {
|
||||
const source = slide || {};
|
||||
const content = source.content && typeof source.content === 'object'
|
||||
? JSON.parse(JSON.stringify(source.content))
|
||||
: {};
|
||||
|
||||
return {
|
||||
id: null,
|
||||
title: String(duplicateName || buildDuplicateSlideName(source.title)).trim(),
|
||||
template_id: source.template_id || null,
|
||||
content: content
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildDuplicateSlideName,
|
||||
buildDuplicateSlide
|
||||
};
|
||||
@@ -53,7 +53,7 @@ function buildSlideFormViewModel(data, slide, message, currentUser, isEdit) {
|
||||
existingContent: viewSlide && viewSlide.content ? viewSlide.content : {}
|
||||
},
|
||||
assetVersion: assetVersion,
|
||||
slideEditorScripts: ['vendor/qrcode-generator/qrcode.js?v=' + assetVersion].concat(getRegionEditorScripts(assetVersion)),
|
||||
slideEditorScripts: ['vendor/qr-code-styling/qr-code-styling-loader.js?v=' + assetVersion].concat(getRegionEditorScripts(assetVersion)),
|
||||
currentUser: currentUser || null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// Template duplicate helpers.
|
||||
|
||||
function buildDuplicateTemplateName(name) {
|
||||
const source = String(name || 'Template').trim() || 'Template';
|
||||
return 'Copy of ' + source;
|
||||
}
|
||||
|
||||
function cloneRegion(region) {
|
||||
const cloned = JSON.parse(JSON.stringify(region || {}));
|
||||
delete cloned.id;
|
||||
delete cloned.template_id;
|
||||
delete cloned.created_at;
|
||||
delete cloned.modified_at;
|
||||
delete cloned.created_by;
|
||||
delete cloned.modified_by;
|
||||
return cloned;
|
||||
}
|
||||
|
||||
function buildDuplicateTemplate(template, duplicateName) {
|
||||
const source = template || {};
|
||||
return {
|
||||
id: null,
|
||||
name: String(duplicateName || buildDuplicateTemplateName(source.name)).trim(),
|
||||
canvas_size_id: source.canvas_size_id || null,
|
||||
canvas_size_width: source.canvas_size_width,
|
||||
canvas_size_height: source.canvas_size_height,
|
||||
background_color: source.background_color,
|
||||
background_image_path: source.background_image_path,
|
||||
region_usage: [],
|
||||
regions: Array.isArray(source.regions) ? source.regions.map(cloneRegion) : []
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildDuplicateTemplateName,
|
||||
buildDuplicateTemplate
|
||||
};
|
||||
@@ -55,15 +55,17 @@ function buildTemplateFormViewModel(template, message, canvasSizes, currentUser,
|
||||
template: current,
|
||||
inUse: Boolean(current.inUse),
|
||||
isEdit: Boolean(isEdit),
|
||||
showSaveSecondaryActions: Boolean(isEdit),
|
||||
showSaveSecondaryActions: true,
|
||||
deleteDisabled: !isEdit || Boolean(current.inUse),
|
||||
formAction: isEdit && current.id ? '/templates/' + current.id : '/templates',
|
||||
formAttrs: 'data-template-has-slides="' + (current.slide_count ? 'true' : 'false') + '" data-async-save' + (isEdit ? ' data-async-save-close-url="/templates"' : ' data-async-save-new-redirect="response-url"') + ' data-async-save-new-url="/templates/new"',
|
||||
formAttrs: 'data-template-has-slides="' + (current.slide_count ? 'true' : 'false') + '" data-async-save data-async-save-close-url="/templates"' + (isEdit ? '' : ' data-async-save-new-redirect="response-url"') + ' data-async-save-new-url="/templates/new"',
|
||||
cancelUrl: '/templates',
|
||||
cancelConfirmMessage: "You've made changes. Are you sure you want to leave this page?",
|
||||
deleteUrl: isEdit && current.id ? '/templates/' + current.id + '/delete' : '',
|
||||
deleteConfirmMessage: isEdit ? 'Delete this template?' : '',
|
||||
canvasSizes: canvasSizes || [],
|
||||
animationPresets: animationPresets,
|
||||
scripts: ['js/lib/modal.js?v=' + assetVersion, 'js/templates/animation-presets.js?v=' + assetVersion, 'vendor/qrcode-generator/qrcode.js?v=' + assetVersion].concat(getRegionEditorScripts(assetVersion), ['js/templates/template-designer-utils.js?v=' + assetVersion, 'js/templates/template-designer.js?v=' + assetVersion])
|
||||
scripts: ['js/lib/modal.js?v=' + assetVersion, 'js/templates/animation-presets.js?v=' + assetVersion, 'vendor/qr-code-styling/qr-code-styling-loader.js?v=' + assetVersion].concat(getRegionEditorScripts(assetVersion), ['js/templates/template-designer-utils.js?v=' + assetVersion, 'js/templates/template-designer.js?v=' + assetVersion])
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user