Files
team-tryouts/app/static/js/main.js
GGThedandClaude Opus 5 fcb58e8a17 feat(csp): retirer unsafe-inline de script-src
SEC-WEB-001 / OPS-010, ferme. C'est cette directive qui laissait s'executer
le XSS stocke de SEC-XSS-001 au lieu de le bloquer.

Les cinq derniers gabarits sont migres : match_form 13, calendar 11,
teams 11, evaluate_player 9, view_tryout 8. Total sur le chantier : 82
gestionnaires en ligne retires dans 17 gabarits. Il n'en reste aucun.

Deux motifs generiques de plus dans main.js
  data-mirror             affichage direct de la valeur d'un curseur.
                          evaluate_player repetait le meme
                          oninput="this.nextElementSibling.textContent = ..."
                          sur ses neuf curseurs de note.
  data-submit-on-change   remplace onchange="this.form.submit()"

Markup genere dans des chaines JavaScript
  match_form construisait sept gestionnaires par concatenation, en y
  injectant l'identifiant du joueur. Le markup portait deja data-player-id :
  returnToPool et assignToTeam lisent desormais leurs arguments depuis
  l'element clique. Cela supprime a la fois l'attribut en ligne et la
  concatenation qui l'alimentait. Meme motif que dans coach_availability.

Bascule
  CSP_ALLOW_INLINE_SCRIPT passe a false. script-src vaut maintenant
  'self' 'nonce-<aleatoire par requete>' https://cdn.jsdelivr.net.
  La variable d'environnement reste, comme issue de secours si un
  deploiement rencontrait un gestionnaire oublie -- mais la laisser active
  revient a renoncer a la protection.

Le cliquet devient une garde
  Le budget par gabarit est vide et les tests deviennent absolus : aucun
  gestionnaire en ligne, et tout bloc <script> inline doit porter son
  nonce. Sans nonce, un bloc n'est simplement pas execute, et rien dans les
  journaux ne le signale -- d'ou le test.

Verifications
  22 pages parcourues avec les trois roles : toutes rendent en 200, aucune
  ne contient de gestionnaire en ligne, et chaque bloc inline porte bien le
  nonce de sa propre reponse. Syntaxe JavaScript de chaque gabarit verifiee
  par node --check.

193 tests. Le dernier xfail de SEC-WEB-001 reussissait, le marqueur est
retire. Il n'en reste qu'un : SEC-AUTH-006, enumeration de comptes.

style-src conserve 'unsafe-inline' : les attributs style="" sont partout et
ne sont pas un vecteur XSS a eux seuls. Migration distincte, non prioritaire.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-07 21:03:31 -04:00

598 lines
21 KiB
JavaScript

/**
* Team Tryouts - Main JavaScript Module
*
* This module provides core UI functionality including:
* - Dark mode toggle and persistence
* - Sidebar mobile toggle
* - Draggable dashboard blocks
* - Auto-dismissing alerts
*/
/**
* Toggle dark mode theme.
*
* Switches between light and dark themes, updates the toggle button icon,
* and persists the preference in localStorage.
*/
function toggleDarkMode() {
const body = document.documentElement;
const isDark = body.getAttribute('data-theme') === 'dark';
const toggle = document.getElementById('darkModeToggle');
if (isDark) {
body.removeAttribute('data-theme');
localStorage.setItem('theme', 'light');
toggle.innerHTML = '<i class="fas fa-moon"></i>';
} else {
body.setAttribute('data-theme', 'dark');
localStorage.setItem('theme', 'dark');
toggle.innerHTML = '<i class="fas fa-sun"></i>';
}
}
/**
* Load saved theme preference from localStorage.
*
* Called on page load to restore the user's preferred theme.
*/
function loadTheme() {
const savedTheme = localStorage.getItem('theme');
const toggle = document.getElementById('darkModeToggle');
if (savedTheme === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
if (toggle) {
toggle.innerHTML = '<i class="fas fa-sun"></i>';
}
}
}
/**
* Toggle sidebar visibility on mobile devices.
*
* Adds/removes 'open' class on sidebar to show/hide it.
*/
function toggleSidebar() {
const sidebar = document.getElementById('sidebar');
sidebar.classList.toggle('open');
}
/**
* Handle click outside sidebar to close it on mobile.
*
* Listens for document clicks and closes sidebar when clicking
* outside of it, but only on screens smaller than 768px.
*/
document.addEventListener('click', function(event) {
const sidebar = document.getElementById('sidebar');
const toggle = document.getElementById('sidebarToggle');
if (window.innerWidth <= 768) {
if (!sidebar.contains(event.target) && !toggle.contains(event.target)) {
sidebar.classList.remove('open');
}
}
});
/**
* Initialize confirmation dialogs for elements with data-confirm attribute.
*
* Adds click handler to show confirmation dialog before submitting forms.
*/
document.querySelectorAll('[data-confirm]').forEach(function(el) {
el.addEventListener('click', function(e) {
if (!confirm(this.getAttribute('data-confirm'))) {
e.preventDefault();
}
});
});
/**
* Initialize draggable blocks functionality on dashboard pages.
*
* Adds drag handles to card headers and enables drag-and-drop
* reordering of cards. Saves layout to localStorage.
*/
function initDraggableBlocks() {
const grids = document.querySelectorAll('.dashboard-grid');
grids.forEach(function(grid) {
const cards = grid.querySelectorAll('.card');
// Add drag handles to card headers
cards.forEach(function(card, index) {
const header = card.querySelector('.card-header');
if (header) {
// Add unique ID to card if not present
if (!card.id) {
card.id = 'block-' + index;
}
// Create drag handle
const dragHandle = document.createElement('div');
dragHandle.className = 'drag-handle';
dragHandle.innerHTML = '<i class="fas fa-grip-vertical"></i>';
dragHandle.title = 'Drag to reorder';
// Add draggable class to header
header.classList.add('draggable');
header.prepend(dragHandle);
// Make header draggable
makeHeaderDraggable(header, card, grid);
}
});
// Load saved layout
loadLayout(grid);
});
}
/**
* Make a card header draggable.
*
* @param {HTMLElement} header - The card header element
* @param {HTMLElement} card - The card element being dragged
* @param {HTMLElement} grid - The parent grid container
*/
function makeHeaderDraggable(header, card, grid) {
let draggedElement = null;
let placeholder = null;
let ghost = null;
let animationFrame = null;
header.addEventListener('mousedown', function(e) {
// Only start drag from drag handle
if (!e.target.closest('.drag-handle')) return;
e.preventDefault();
draggedElement = card;
// Get card dimensions
const rect = card.getBoundingClientRect();
const offsetX = e.clientX - rect.left;
const offsetY = e.clientY - rect.top;
// Store original data-position to restore later
const originalDataPosition = card.getAttribute('data-position');
// Remove data-position during drag to allow free placement
if (originalDataPosition) {
card.removeAttribute('data-position');
}
// Create ghost element (floating preview)
ghost = document.createElement('div');
ghost.className = 'sortable-ghost';
ghost.style.position = 'fixed';
ghost.style.pointerEvents = 'none';
ghost.style.zIndex = '99999';
ghost.style.width = rect.width + 'px';
ghost.style.height = rect.height + 'px';
ghost.style.backgroundColor = 'var(--card-bg, white)';
ghost.style.border = '2px solid var(--primary)';
ghost.style.borderRadius = 'var(--radius)';
ghost.style.opacity = '0.9';
ghost.style.boxShadow = 'var(--shadow-lg)';
ghost.style.cursor = 'grabbing';
document.body.appendChild(ghost);
// Create placeholder element (shows drop position)
placeholder = document.createElement('div');
placeholder.className = 'sortable-placeholder';
placeholder.style.minHeight = rect.height + 'px';
card.parentNode.insertBefore(placeholder, card);
// Keep original card visible but add dragging style
card.classList.add('dragging');
card.style.opacity = '0.5';
card.style.transform = 'scale(0.98)';
// Initial position
ghost.style.top = (e.clientY - offsetY) + 'px';
ghost.style.left = (e.clientX - offsetX) + 'px';
/**
* Update ghost position during drag.
* @param {number} clientY - Mouse Y coordinate
* @param {number} clientX - Mouse X coordinate
*/
function updateGhostPosition(clientY, clientX) {
// Position ghost directly at cursor position
ghost.style.top = (clientY - offsetY) + 'px';
ghost.style.left = (clientX - offsetX) + 'px';
}
// Get grid layout info for 2D position tracking
const gridRect = grid.getBoundingClientRect();
const gridStyle = window.getComputedStyle(grid);
const gridGap = parseInt(gridStyle.gap) || 20;
/**
* Calculate which column the X position falls into.
* @param {number} x - X coordinate relative to grid
* @returns {number} Column index
*/
function getColumnFromX(x) {
// Calculate which column the x position falls into
const relativeX = x - gridRect.left;
const colWidth = (gridRect.width + gridGap) / Math.max(1, Math.floor(gridRect.width / 300)); // Estimate columns based on min 300px width
return Math.floor(relativeX / (colWidth + gridGap));
}
/**
* Handle mouse movement during drag.
* @param {MouseEvent} e - Mouse event
*/
function onMouseMove(e) {
e.preventDefault();
// Cancel any pending animation frame
if (animationFrame) {
cancelAnimationFrame(animationFrame);
}
// Use requestAnimationFrame for smooth updates
animationFrame = requestAnimationFrame(function() {
updateGhostPosition(e.clientY, e.clientX);
// Use a point slightly offset from cursor to avoid ghost interference
// This ensures we detect the card under the cursor, not the ghost
const checkX = e.clientX;
const checkY = e.clientY + 10; // 10px below cursor for better detection
// Find the element under the offset point
const elementUnderCursor = document.elementFromPoint(checkX, checkY);
// Check if we're directly over the placeholder (skip processing to avoid flicker)
if (elementUnderCursor && elementUnderCursor.closest('.sortable-placeholder')) {
return; // Already over placeholder, don't change position
}
// Find the card that contains or is the element under cursor
let targetCard = null;
if (elementUnderCursor) {
targetCard = elementUnderCursor.closest('.card');
// Make sure it's not the dragged card
if (targetCard && targetCard.classList.contains('dragging')) {
targetCard = null;
}
}
// If we found a valid target card, determine insert position
if (targetCard) {
const targetRect = targetCard.getBoundingClientRect();
const targetMiddleY = targetRect.top + targetRect.height / 2;
// If cursor is above the card's middle, insert before it
// If cursor is below, insert after it
// Use checkY for consistent comparison with detection point
if (checkY < targetMiddleY) {
// Check if placeholder is already in the correct position
if (targetCard.nextElementSibling !== placeholder) {
grid.insertBefore(placeholder, targetCard);
}
} else {
// Insert after the target card
const nextCard = targetCard.nextElementSibling;
// If next sibling is the placeholder or the dragged element, it's already in the right position
if (nextCard === placeholder || nextCard === draggedElement) {
// Already in correct position, don't move
} else if (nextCard && nextCard.classList.contains('card')) {
grid.insertBefore(placeholder, nextCard);
} else {
grid.appendChild(placeholder);
}
}
} else {
// No target card found - check if we should move to end
// This handles the case where we're dragging over empty space or the grid background
// If cursor is within the grid bounds, move to end
if (e.clientX >= gridRect.left && e.clientX <= gridRect.right &&
e.clientY >= gridRect.top && e.clientY <= gridRect.bottom) {
// Check if we're over the dragged element (which is hidden but still in DOM)
const isOverDraggedElement = elementUnderCursor &&
(elementUnderCursor.closest('.card.dragging') ||
elementUnderCursor.closest('.sortable-placeholder'));
if (!isOverDraggedElement) {
// Move placeholder to end if not already there
const lastCard = grid.querySelector('.card:last-of-type');
if (lastCard && lastCard.nextElementSibling !== placeholder) {
grid.appendChild(placeholder);
}
}
}
}
});
}
/**
* Handle mouse release to complete drag.
* @param {MouseEvent} e - Mouse event
*/
function onMouseUp(e) {
if (animationFrame) {
cancelAnimationFrame(animationFrame);
}
if (draggedElement) {
draggedElement.classList.remove('dragging');
// Restore original card styles
draggedElement.style.opacity = '';
draggedElement.style.transform = '';
// Ensure the card is placed in the correct position
// The placeholder shows where the card should go
if (placeholder && placeholder.parentNode) {
// Move the dragged element to the placeholder position
placeholder.parentNode.replaceChild(draggedElement, placeholder);
} else {
// If no placeholder exists, append to the end
grid.appendChild(draggedElement);
}
// Save layout
saveLayout(grid);
}
if (ghost && ghost.parentNode) {
ghost.parentNode.removeChild(ghost);
}
if (placeholder && placeholder.parentNode) {
placeholder.parentNode.removeChild(placeholder);
}
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
}
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
});
}
/**
* Save the current card layout order to localStorage.
*
* @param {HTMLElement} grid - The grid container to save layout for
*/
function saveLayout(grid) {
const pageKey = getPageKey();
const cards = grid.querySelectorAll('.card');
const order = Array.from(cards).map(card => card.id);
localStorage.setItem('blockLayout_' + pageKey, JSON.stringify(order));
}
/**
* Load saved card layout from localStorage.
*
* @param {HTMLElement} grid - The grid container to load layout for
*/
function loadLayout(grid) {
const pageKey = getPageKey();
const saved = localStorage.getItem('blockLayout_' + pageKey);
if (saved) {
try {
const order = JSON.parse(saved);
const cards = Array.from(grid.querySelectorAll('.card'));
// Sort cards according to saved order
order.forEach(function(id) {
const card = cards.find(c => c.id === id);
if (card) {
grid.appendChild(card);
}
});
} catch (e) {
console.error('Failed to load layout:', e);
}
}
}
/**
* Get the current page key for layout storage.
*
* @returns {string} Page identifier based on URL path
*/
function getPageKey() {
const path = window.location.pathname;
if (path.includes('/tryouts/')) {
return 'tryout';
}
if (path.includes('/profile')) {
return 'profile';
}
return 'dashboard';
}
/**
* Initialize on DOM ready.
*
* Loads theme preference and initializes draggable blocks.
*/
document.addEventListener('DOMContentLoaded', function() {
// Load theme preference
loadTheme();
// Initialize draggable blocks
initDraggableBlocks();
/**
* Auto-dismiss flash alerts after 5 seconds.
*/
const alerts = document.querySelectorAll('.alert-dismissible');
alerts.forEach(function(alert) {
setTimeout(function() {
if (alert.parentElement) {
alert.style.opacity = '0';
alert.style.transition = 'opacity 0.3s ease';
setTimeout(function() {
if (alert.parentElement) {
alert.remove();
}
}, 300);
}
}, 5000);
});
});
/* =========================================================================
Declarative behaviours — replacing inline event handlers
=========================================================================
A Content Security Policy without 'unsafe-inline' blocks `onclick="..."`
attributes, and a nonce does not help: nonces apply to <script> elements,
never to event handler attributes. Dropping 'unsafe-inline' therefore
requires removing every one of them first.
Rather than one listener per widget, behaviours are declared in the
markup with a data-action attribute and dispatched from a single
delegated listener. New markup gets the behaviour for free, and nothing
has to be re-bound after content is replaced dynamically.
<button data-action="toggle-sidebar">
<button data-action="dismiss-alert">
<div data-action="hide-modal" data-modal-id="confirmDelete">
Migration status is tracked by tests/test_csp.py.
========================================================================= */
const DATA_ACTIONS = {
'toggle-sidebar': function () {
toggleSidebar();
},
'toggle-dark-mode': function () {
toggleDarkMode();
},
'dismiss-alert': function (element) {
const alert = element.closest('.alert');
if (alert) {
alert.remove();
}
},
'hide-modal': function (element) {
const id = element.getAttribute('data-modal-id');
if (id && typeof hideModal === 'function') {
hideModal(id);
}
},
'history-back': function (element, event) {
event.preventDefault();
history.back();
},
// Removes the nearest ancestor matching data-remove, or the parent.
'remove-element': function (element) {
const selector = element.getAttribute('data-remove');
const target = selector ? element.closest(selector) : element.parentElement;
if (target) {
target.remove();
}
},
};
/**
* Register behaviours defined by a single page.
*
* Page-local functions live in that page's script block, so they cannot be
* listed in DATA_ACTIONS above. Each page declares its own:
*
* registerActions({ 'clear-availability': clearAllAvailability });
*
* @param {Object} map - action name to handler(element, event).
*/
function registerActions(map) {
Object.assign(DATA_ACTIONS, map);
}
function dispatchAction(attribute, event) {
const trigger = event.target.closest('[' + attribute + ']');
if (!trigger) {
return;
}
const handler = DATA_ACTIONS[trigger.getAttribute(attribute)];
if (handler) {
handler(trigger, event);
}
}
document.addEventListener('click', function (event) {
dispatchAction('data-action', event);
});
// Separate attribute rather than one shared with click: a <select> would
// otherwise fire its handler on the click that opens it.
document.addEventListener('change', function (event) {
dispatchAction('data-change', event);
});
/**
* Confirmation before a destructive submit.
*
* <form data-confirm="Delete this match?">
*
* Replaces onsubmit="return confirm(...)", and keeps the wording in the
* markup where it can be translated.
*/
document.addEventListener('submit', function (event) {
const form = event.target.closest('[data-confirm]');
if (form && !window.confirm(form.getAttribute('data-confirm'))) {
event.preventDefault();
}
});
/**
* Live value display next to a range input.
*
* <input type="range" data-mirror>
* <span>5</span>
*
* Replaces oninput="this.nextElementSibling.textContent = this.value",
* which the evaluation form repeated on all nine score sliders.
* data-mirror may name a selector; empty means the next sibling.
*/
document.addEventListener('input', function (event) {
const input = event.target.closest('[data-mirror]');
if (!input) {
return;
}
const selector = input.getAttribute('data-mirror');
const target = selector
? document.querySelector(selector)
: input.nextElementSibling;
if (target) {
target.textContent = input.value;
}
});
/**
* Submit the surrounding form when a control changes.
*
* <select data-submit-on-change>
*
* Replaces onchange="this.form.submit()".
*/
document.addEventListener('change', function (event) {
const control = event.target.closest('[data-submit-on-change]');
if (control && control.form) {
control.form.submit();
}
});
/**
* Navigate on selection.
*
* <select data-navigate="/team-matches/{value}/create">
*
* {value} is replaced by the chosen option, URL-encoded. An empty
* selection navigates nowhere.
*/
document.addEventListener('change', function (event) {
const select = event.target.closest('[data-navigate]');
if (!select || !select.value) {
return;
}
window.location.href = select.getAttribute('data-navigate')
.replace('{value}', encodeURIComponent(select.value));
});