Files
team-tryouts/app/static/js/main.js
T
GGThedandClaude Opus 5 15bfebf4fc feat(csp): infrastructure de sortie de unsafe-inline, et couche partagee migree
SEC-WEB-001 / OPS-010. script-src porte toujours 'unsafe-inline' : c'est
pour cela que le XSS stocke de SEC-XSS-001 s'executait au lieu d'etre
bloque. Le retirer n'est pas un changement d'une ligne.

Ce qui bloque reellement
  Un nonce autorise des elements <script> ; il ne peut rien pour un
  attribut onclick="...". Mesure faite : 76 gestionnaires en ligne repartis
  dans 15 gabarits. Tant qu'il en reste un, la politique ne peut pas etre
  durcie.

  Piege supplementaire, documente dans build_csp() : en CSP niveau 3, un
  navigateur ignore 'unsafe-inline' des qu'un nonce est present. Emettre
  les deux ne serait donc pas une transition douce -- ce serait couper
  d'un coup tous les scripts en ligne et tous les onclick, et uniquement
  sur les navigateurs recents. La bascule doit etre atomique, d'ou un
  drapeau unique : CSP_ALLOW_INLINE_SCRIPT.

Infrastructure posee
  build_csp() assemble l'en-tete selon le drapeau. Un nonce est genere par
  requete et n'est emis que lorsque l'inline est interdit. Les 15 blocs
  <script> portent deja nonce="{{ csp_nonce }}", inerte aujourd'hui : la
  bascule finale sera un changement de configuration, pas de gabarits.

Couche partagee migree en premier
  base.html et macros.html sont rendus sur absolument toutes les pages. Six
  gestionnaires retires, remplaces par des attributs data-action et un
  ecouteur delegue unique dans main.js. La delegation plutot qu'un
  ecouteur par widget : le contenu injecte dynamiquement herite du
  comportement sans re-attachement.

Un cliquet plutot qu'une promesse
  tests/test_csp.py fixe un budget par gabarit qui ne peut que baisser.
  Ajouter un gestionnaire en ligne fait echouer la suite ; en retirer sans
  mettre le budget a jour aussi, ce qui force a enregistrer la progression
  dans le diff. A zero, il ne reste qu'a basculer le drapeau.

  Le cliquet a d'ailleurs corrige mon propre relevé : mon grep initial
  comptait 83 gestionnaires, la mesure exacte en donne 76 -- le motif ne
  verifiait pas l'espace avant l'attribut.

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

192 tests.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-07 20:44:16 -04:00

496 lines
18 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();
},
};
document.addEventListener('click', function (event) {
const trigger = event.target.closest('[data-action]');
if (!trigger) {
return;
}
const handler = DATA_ACTIONS[trigger.getAttribute('data-action')];
if (handler) {
handler(trigger, event);
}
});