diff --git a/app/components/backup_codes_display.rb b/app/components/backup_codes_display.rb index 95ddb73d..c5aabb90 100644 --- a/app/components/backup_codes_display.rb +++ b/app/components/backup_codes_display.rb @@ -13,7 +13,7 @@ def initialize(codes:, warning_title:, warning_detail:, heading: nil, show_confi end def view_template(&block) - style do + style(nonce: helpers.content_security_policy_nonce) do raw safe <<~CSS @media print { @page { @@ -111,7 +111,7 @@ def view_template(&block) CSS end - style do + style(nonce: helpers.content_security_policy_nonce) do raw safe ".print-only-banner { display: none; }" end @@ -146,7 +146,10 @@ def view_template(&block) div(class: "backup-codes-actions") do button( type: "button", - onclick: safe("navigator.clipboard.writeText('#{@codes.join('\n')}').then(() => { this.textContent = '#{t('identity_backup_codes.copied')}'; setTimeout(() => this.textContent = '#{t('identity_backup_codes.copy_all')}', 2000); })") + data_action: "copy-codes", + data_codes: @codes.join("\n"), + data_label_copied: t("identity_backup_codes.copied"), + data_label_default: t("identity_backup_codes.copy_all") ) do t "identity_backup_codes.copy_all" end @@ -162,13 +165,30 @@ def view_template(&block) button( type: "button", - onclick: safe("window.print()"), + data_action: "print-page", class: "secondary" ) do t "identity_backup_codes.print" end end + script(nonce: helpers.content_security_policy_nonce) do + raw safe <<~JS + document.querySelectorAll('[data-action="copy-codes"]').forEach(function(btn) { + btn.addEventListener('click', function() { + var codes = btn.dataset.codes; + navigator.clipboard.writeText(codes); + var original = btn.textContent; + btn.textContent = btn.dataset.labelCopied; + setTimeout(function() { btn.textContent = btn.dataset.labelDefault; }, 2000); + }); + }); + document.querySelectorAll('[data-action="print-page"]').forEach(function(btn) { + btn.addEventListener('click', function() { window.print(); }); + }); + JS + end + if @show_confirmation div(class: "backup-codes-confirmation") do label do @@ -179,25 +199,14 @@ def view_template(&block) render_block(@confirmation_button_block) if @confirmation_button_block end - script do + script(nonce: helpers.content_security_policy_nonce) do raw safe <<~JS (function() { - const checkbox = document.getElementById('codes-saved-check'); - const finishBtn = document.getElementById('finish-setup-btn'); - if (checkbox && finishBtn) { - checkbox.addEventListener('change', () => { - if (checkbox.checked) { - finishBtn.disabled = false; - finishBtn.style.pointerEvents = 'auto'; - finishBtn.style.opacity = '1'; - finishBtn.removeAttribute('data-disabled'); - } else { - finishBtn.disabled = true; - finishBtn.style.pointerEvents = 'none'; - finishBtn.style.opacity = '0.5'; - finishBtn.setAttribute('data-disabled', 'true'); - } - }); + var cb = document.getElementById('codes-saved-check'); + var btn = cb && cb.closest('.backup-codes-confirmation').querySelector('input[type=submit], button[type=submit]'); + if (cb && btn) { + btn.disabled = !cb.checked; + cb.addEventListener('change', function() { btn.disabled = !cb.checked; }); } })(); JS diff --git a/app/components/banner.rb b/app/components/banner.rb index 9132c6d8..0de7a0f0 100644 --- a/app/components/banner.rb +++ b/app/components/banner.rb @@ -25,7 +25,6 @@ def render_event_id code( class: "error-id-code cursor-pointer hover:opacity-80", data_error_id: @event_id, - onclick: safe("copyErrorId(this)"), title: "Click to copy" ) { @event_id } span(class: "copy-feedback ml-1", hidden: true) { " ✓ Copied!" } diff --git a/app/components/login/code_input.rb b/app/components/login/code_input.rb index 91b24ba4..b08e14b5 100644 --- a/app/components/login/code_input.rb +++ b/app/components/login/code_input.rb @@ -11,7 +11,7 @@ def initialize(form:, field_name: :code, prefix: nil, placeholder: "XXX-XXX", al end def view_template - div(class: "code-input-container", onclick: safe("document.getElementById('#{@input_id}').focus()")) do + div(class: "code-input-container", data_focus_target: @input_id) do span(class: "code-prefix") { @prefix } if @prefix raw @form.text_field @field_name, required: true, @@ -26,5 +26,15 @@ def view_template "x-model": @alpine_model, data: { behavior: "otp" } end + script(nonce: helpers.content_security_policy_nonce) do + raw safe <<~JS + document.querySelectorAll('[data-focus-target]').forEach(function(el) { + el.addEventListener('click', function() { + var target = document.getElementById(el.dataset.focusTarget); + if (target) target.focus(); + }); + }); + JS + end end end diff --git a/app/components/sidebar.rb b/app/components/sidebar.rb index 59bf5da7..feaac2fa 100644 --- a/app/components/sidebar.rb +++ b/app/components/sidebar.rb @@ -22,10 +22,24 @@ def view_template render_navigation render_user_section if signed_in? end - render_toggle_script end + def render_toggle_script + script(nonce: helpers.content_security_policy_nonce) do + raw safe <<~JS + document.querySelectorAll('[data-action="toggle-sidebar"]').forEach(function(el) { + el.addEventListener('click', function() { + var sidebar = document.getElementById('sidebar'); + var overlay = document.querySelector('.sidebar-overlay'); + sidebar.classList.toggle('mobile-open'); + overlay.classList.toggle('active'); + }); + }); + JS + end + end + private def nav_items @@ -66,7 +80,7 @@ def nav_items end def render_mobile_toggle - button(class: "sidebar-toggle", onclick: safe("toggleSidebar()"), "aria-label": "Toggle sidebar") do + button(class: "sidebar-toggle", data_action: "toggle-sidebar", data_target: "sidebar", "aria-label": "Toggle sidebar") do svg( width: "24", height: "24", @@ -84,7 +98,7 @@ def render_mobile_toggle end end - def render_overlay = div(class: "sidebar-overlay", onclick: safe("toggleSidebar()")) + def render_overlay = div(class: "sidebar-overlay", data_action: "toggle-sidebar", data_target: "sidebar") def render_navigation div(class: "sidebar-nav") do @@ -155,17 +169,4 @@ def render_sidebar_brand end render Components::EnvironmentBanner.new end - - def render_toggle_script - script do - raw safe <<~JS - function toggleSidebar() { - const sidebar = document.getElementById('sidebar'); - const overlay = document.querySelector('.sidebar-overlay'); - sidebar.classList.toggle('mobile-open'); - overlay.classList.toggle('active'); - } - JS - end - end end diff --git a/app/components/window.rb b/app/components/window.rb index 4c0fdfe7..92de0061 100644 --- a/app/components/window.rb +++ b/app/components/window.rb @@ -14,8 +14,8 @@ def view_template div(class: "title-bar-text") { @window_title } if @close_url || @maximize_url div class: "title-bar-buttons" do - button(data_maximize: "", onclick: safe("window.location.href='#{@maximize_url}'")) if @maximize_url - button(data_close: "", onclick: safe("window.location.href='#{@close_url}'")) if @close_url + button(data_maximize: "", data_navigate_url: @maximize_url) if @maximize_url + button(data_close: "", data_navigate_url: @close_url) if @close_url end end end @@ -23,5 +23,14 @@ def view_template yield end end + script(nonce: helpers.content_security_policy_nonce) do + raw safe <<~JS + document.querySelectorAll('[data-navigate-url]').forEach(function(btn) { + btn.addEventListener('click', function() { + window.location.href = btn.dataset.navigateUrl; + }); + }); + JS + end end end diff --git a/app/frontend/entrypoints/application.js b/app/frontend/entrypoints/application.js index 2e33ac9f..12005333 100644 --- a/app/frontend/entrypoints/application.js +++ b/app/frontend/entrypoints/application.js @@ -4,6 +4,7 @@ import "../js/click-to-copy"; import "../js/otp-input.js"; import "../js/persona-verify.js"; import "../js/camera-capture.js"; +import "../js/confirm-dialog.js"; import htmx from "htmx.org" window.htmx = htmx @@ -16,23 +17,16 @@ document.addEventListener('htmx:configRequest', (event) => { } }); -// Copy error ID to clipboard -window.copyErrorId = function(element) { - const errorId = element.dataset.errorId; - const feedback = element.nextElementSibling || element.parentElement.querySelector('.copy-feedback'); - navigator.clipboard.writeText(errorId).then(() => { +// Copy error ID to clipboard +document.addEventListener('click', function (e) { + const el = e.target.closest('[data-error-id]') + if (!el) return + navigator.clipboard.writeText(el.dataset.errorId).then(() => { + const feedback = el.nextElementSibling || el.parentElement?.querySelector('.copy-feedback') if (feedback) { - feedback.hidden = false; - setTimeout(() => { feedback.hidden = true; }, 2000); + feedback.hidden = false + setTimeout(() => { feedback.hidden = true }, 2000) } - }).catch(err => { - console.error('Failed to copy:', err); - }); -}; - -// Delegated listener for Phlex-rendered banners (can't use inline onclick) -document.addEventListener('click', function(e) { - const element = e.target.closest('[data-error-id]'); - if (element && !element.hasAttribute('onclick')) copyErrorId(element); -}); + }).catch(err => console.error('Failed to copy:', err)) +}) \ No newline at end of file diff --git a/app/frontend/entrypoints/backend.js b/app/frontend/entrypoints/backend.js index 9d585a44..5c1e3ee7 100644 --- a/app/frontend/entrypoints/backend.js +++ b/app/frontend/entrypoints/backend.js @@ -7,7 +7,51 @@ import KeyboardShortcuts from '../components/svelte/KeyboardShortcuts.svelte'; import IdentityPicker from '../components/svelte/IdentityPicker.svelte'; import NavigableList from '../components/svelte/NavigableList.svelte'; import ThemeToggle from '../components/svelte/ThemeToggle.svelte'; +import '../js/confirm-dialog.js'; + +// Delegated click handler for backend-specific actions +document.addEventListener('click', function (e) { + const el = e.target.closest('[data-action]') + if (!el) return + const action = el.dataset.action + if (action === 'open-kbar') { + e.preventDefault() + window.openKbar?.() + } else if (action === 'open-hints') { + e.preventDefault() + window.openHints?.() + } else if (action === 'dispatch-key') { + e.preventDefault() + document.dispatchEvent(new KeyboardEvent('keydown', { key: el.dataset.key, bubbles: true })) + } else if (action === 'rotate' || action === 'rotate-case') { + const img = document.getElementById(el.dataset.target) + const r = parseInt(img.dataset.rotation || 0) + 90 + img.dataset.rotation = r + img.style.transform = 'rotate(' + r + 'deg)' + } +}) + +// Image load/error handlers for review documents +function initReviewImages(root) { + root.querySelectorAll('.review-doc-image, .case-doc-image').forEach(function (img) { + if (img.dataset.initDone) return + img.dataset.initDone = '1' + function onLoad() { + document.getElementById(img.dataset.loader).style.display = 'none' + document.getElementById(img.dataset.container).style.display = 'block' + } + function onError() { + document.getElementById(img.dataset.loader).textContent = 'error loading image' + } + if (img.complete) { + if (img.naturalWidth > 0) onLoad(); else onError() + } else { + img.addEventListener('load', onLoad) + img.addEventListener('error', onError) + } + }) +} window.htmx = htmx; document.addEventListener('DOMContentLoaded', () => { @@ -76,4 +120,8 @@ document.addEventListener('DOMContentLoaded', () => { try { tabs = JSON.parse(tabsJson); } catch (e) {} mountSvelte(TabView, { target: el, props: { tabs } }); }); + + initReviewImages(document) }); + +document.addEventListener('htmx:afterSwap', function (e) { initReviewImages(e.detail.target) }) diff --git a/app/frontend/js/alpine.js b/app/frontend/js/alpine.js index 70b01271..f2c3e28c 100644 --- a/app/frontend/js/alpine.js +++ b/app/frontend/js/alpine.js @@ -1,4 +1,4 @@ -import Alpine from 'alpinejs' +import Alpine from '@alpinejs/csp' import { webauthnRegister } from './webauthn-registration.js' import { webauthnAuth } from './webauthn-authentication.js' import { stepUpWebauthn } from './webauthn-step-up.js' @@ -9,5 +9,47 @@ Alpine.data('webauthnAuth', webauthnAuth) Alpine.data('stepUpWebauthn', stepUpWebauthn) Alpine.data('scopeEditor', scopeEditor) +Alpine.data('oauthCountdown', (initialCount) => ({ + countdown: initialCount, + authLabel: '', + ready: initialCount <= 0, + init() { + this.authLabel = this.$el.dataset.authLabel || '' + if (this.countdown > 0) { + const interval = setInterval(() => { + this.countdown-- + if (this.countdown <= 0) { + this.ready = true + clearInterval(interval) + } + }, 1000) + } + }, + get buttonLabel() { + return this.ready ? this.authLabel : 'Wait ' + this.countdown + 's...' + } +})) + +Alpine.data('documentForm', (initialType) => ({ + selectedType: initialType, + transcriptFileCount: 0, + studentIdFileCount: 0, + governmentIdFileCount: 0, + submitted: false, + get canSubmit() { + if (this.selectedType === 'transcript') { + return this.transcriptFileCount >= 1 && this.studentIdFileCount >= 1 + } + return this.governmentIdFileCount >= 1 + } +})) + +Alpine.data('codeInput', () => ({ + codeValue: '', + get isComplete() { + return this.codeValue.replace(/\D/g, '').length === 6 + } +})) + window.Alpine = Alpine Alpine.start() \ No newline at end of file diff --git a/app/frontend/js/confirm-dialog.js b/app/frontend/js/confirm-dialog.js new file mode 100644 index 00000000..bf25c0a1 --- /dev/null +++ b/app/frontend/js/confirm-dialog.js @@ -0,0 +1,20 @@ +// Confirm dialog for data-confirm attributes. +// Click targets (buttons, links): shows confirm on click, prevents default if cancelled. +// Form targets: shows confirm on submit, prevents submit if cancelled. + +document.addEventListener('click', function (e) { + const el = e.target.closest('[data-confirm]') + if (el && el.tagName !== 'FORM') { + if (!confirm(el.dataset.confirm)) { + e.preventDefault() + e.stopImmediatePropagation() + } + } +}) + +document.addEventListener('submit', function (e) { + const message = e.target.dataset.confirm + if (message && !confirm(message)) { + e.preventDefault() + } +}) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index c29bc604..3dac366e 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -67,6 +67,6 @@ def google_places_api_script_tag api_key = ENV["GOOGLE_PLACES_API_KEY"] return unless api_key.present? - tag.script(src: "https://maps.googleapis.com/maps/api/js?key=#{api_key}&loading=async&libraries=places&callback=onGoogleMapsLoaded", async: true, defer: true) + tag.script(src: "https://maps.googleapis.com/maps/api/js?key=#{api_key}&loading=async&libraries=places&callback=onGoogleMapsLoaded", async: true, defer: true, nonce: true) end end diff --git a/app/views/backend/identities/show.html.erb b/app/views/backend/identities/show.html.erb index 2c1a47c8..896574dd 100644 --- a/app/views/backend/identities/show.html.erb +++ b/app/views/backend/identities/show.html.erb @@ -225,7 +225,7 @@ <% if !sess.expired? %> <% break_glass_tool(element: "span") do %> <%= form_with url: revoke_session_backend_identity_path(@identity, session_id: sess.id), method: :post, local: true, style: "display:inline" do %> - + <% end %> <% end %> <% end %> @@ -269,7 +269,7 @@ - + <% end %> <% end %> diff --git a/app/views/backend/shared/_review_document_files.html.erb b/app/views/backend/shared/_review_document_files.html.erb index 625d61a5..66a7d278 100644 --- a/app/views/backend/shared/_review_document_files.html.erb +++ b/app/views/backend/shared/_review_document_files.html.erb @@ -18,14 +18,13 @@ <%= file.content_type.in?(%w[image/heic image/heif]) ? "converting heic..." : "loading..." %> <% elsif file.content_type == "application/pdf" %>