Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 30 additions & 21 deletions app/components/backup_codes_display.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
1 change: 0 additions & 1 deletion app/components/banner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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!" }
Expand Down
12 changes: 11 additions & 1 deletion app/components/login/code_input.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
33 changes: 17 additions & 16 deletions app/components/sidebar.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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
Expand Down Expand Up @@ -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
13 changes: 11 additions & 2 deletions app/components/window.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,23 @@ 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
div class: "window-body" do
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
28 changes: 11 additions & 17 deletions app/frontend/entrypoints/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))
})
48 changes: 48 additions & 0 deletions app/frontend/entrypoints/backend.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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) })
44 changes: 43 additions & 1 deletion app/frontend/js/alpine.js
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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()
20 changes: 20 additions & 0 deletions app/frontend/js/confirm-dialog.js
Original file line number Diff line number Diff line change
@@ -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()
}
})
Loading
Loading