diff --git a/app/javascript/controllers/flash_controller.js b/app/javascript/controllers/flash_controller.js new file mode 100644 index 0000000..200cb8f --- /dev/null +++ b/app/javascript/controllers/flash_controller.js @@ -0,0 +1,85 @@ +import { Controller } from "@hotwired/stimulus" + +/** + * Manages flash message display, auto-dismissal, and user interactions + * Supports different flash types with appropriate styling and behavior + */ +export default class extends Controller { + static values = { + autoDismiss: String, // "false" or delay in milliseconds + type: String + } + + connect() { + // Auto-dismiss if enabled + if (this.autoDismissValue && this.autoDismissValue !== "false") { + this.scheduleAutoDismiss() + } + + // Smooth entrance animation + this.element.classList.add('transition-all', 'duration-300', 'ease-out') + this.element.style.opacity = '0' + this.element.style.transform = 'translateY(-10px)' + + // Animate in + requestAnimationFrame(() => { + this.element.style.opacity = '1' + this.element.style.transform = 'translateY(0)' + }) + } + + /** + * Dismisses the flash message with smooth animation + */ + dismiss() { + // Add dismiss animation + this.element.classList.add('transition-all', 'duration-300', 'ease-in') + this.element.style.opacity = '0' + this.element.style.transform = 'translateY(-10px)' + + // Remove from DOM after animation + setTimeout(() => { + this.element.remove() + }, 300) + } + + /** + * Schedules auto-dismissal based on the configured delay + */ + scheduleAutoDismiss() { + const delay = parseInt(this.autoDismissValue) + if (delay > 0) { + setTimeout(() => { + this.dismiss() + }, delay) + } + } + + /** + * Pause auto-dismissal on hover (for user reading) + */ + mouseEnter() { + if (this.autoDismissTimer) { + clearTimeout(this.autoDismissTimer) + this.autoDismissTimer = null + } + } + + /** + * Resume auto-dismissal when hover ends + */ + mouseLeave() { + if (this.autoDismissValue && this.autoDismissValue !== "false") { + this.scheduleAutoDismiss() + } + } + + /** + * Handle keyboard interactions + */ + keydown(event) { + if (event.key === 'Escape' || event.key === 'Enter') { + this.dismiss() + } + } +} \ No newline at end of file diff --git a/app/javascript/controllers/form_errors_controller.js b/app/javascript/controllers/form_errors_controller.js new file mode 100644 index 0000000..5650d9b --- /dev/null +++ b/app/javascript/controllers/form_errors_controller.js @@ -0,0 +1,89 @@ +import { Controller } from "@hotwired/stimulus" + +/** + * Manages form error display and dismissal + * Provides consistent error handling across all forms + */ +export default class extends Controller { + static targets = ["container"] + + /** + * Dismisses the error container with a smooth fade-out animation + */ + dismiss() { + if (!this.hasContainerTarget) return + + // Add transition classes + this.containerTarget.classList.add('transition-all', 'duration-300', 'opacity-0', 'transform', 'scale-95') + + // Remove from DOM after animation completes + setTimeout(() => { + this.containerTarget.remove() + }, 300) + } + + /** + * Shows server-side validation errors after form submission + * Auto-focuses the first error field for better accessibility + */ + connect() { + // Auto-focus first error field if errors exist + this.focusFirstErrorField() + + // Scroll to errors if needed + this.scrollToErrors() + } + + /** + * Focuses the first field with validation errors + */ + focusFirstErrorField() { + if (!this.hasContainerTarget) return + + // Find first form field with errors (look for error classes or aria-invalid) + const form = this.element.closest('form') + if (!form) return + + const errorField = form.querySelector('[aria-invalid="true"], .border-red-500, .ring-red-500') + if (errorField) { + setTimeout(() => { + errorField.focus() + errorField.scrollIntoView({ behavior: 'smooth', block: 'center' }) + }, 100) + } + } + + /** + * Scrolls error container into view if it's not visible + */ + scrollToErrors() { + if (!this.hasContainerTarget) return + + const rect = this.containerTarget.getBoundingClientRect() + const isInViewport = rect.top >= 0 && rect.left >= 0 && + rect.bottom <= window.innerHeight && + rect.right <= window.innerWidth + + if (!isInViewport) { + setTimeout(() => { + this.containerTarget.scrollIntoView({ + behavior: 'smooth', + block: 'start', + inline: 'nearest' + }) + }, 100) + } + } + + /** + * Auto-dismisses success messages after a delay + * Can be called from other controllers + */ + autoDismiss(delay = 5000) { + if (!this.hasContainerTarget) return + + setTimeout(() => { + this.dismiss() + }, delay) + } +} \ No newline at end of file diff --git a/app/javascript/controllers/hello_controller.js b/app/javascript/controllers/hello_controller.js deleted file mode 100644 index 5975c07..0000000 --- a/app/javascript/controllers/hello_controller.js +++ /dev/null @@ -1,7 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -export default class extends Controller { - connect() { - this.element.textContent = "Hello World!" - } -} diff --git a/app/javascript/controllers/json_validator_controller.js b/app/javascript/controllers/json_validator_controller.js new file mode 100644 index 0000000..e3ad2f4 --- /dev/null +++ b/app/javascript/controllers/json_validator_controller.js @@ -0,0 +1,81 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["textarea", "status"] + static classes = ["valid", "invalid", "validStatus", "invalidStatus"] + + connect() { + this.validate() + } + + validate() { + const value = this.textareaTarget.value.trim() + + if (!value) { + this.clearStatus() + return true + } + + try { + JSON.parse(value) + this.showValid() + return true + } catch (error) { + this.showInvalid(error.message) + return false + } + } + + format() { + const value = this.textareaTarget.value.trim() + + if (!value) return + + try { + const parsed = JSON.parse(value) + const formatted = JSON.stringify(parsed, null, 2) + this.textareaTarget.value = formatted + this.showValid() + } catch (error) { + this.showInvalid(error.message) + } + } + + clearStatus() { + this.textareaTarget.classList.remove(...this.invalidClasses) + this.textareaTarget.classList.remove(...this.validClasses) + if (this.hasStatusTarget) { + this.statusTarget.textContent = "" + this.statusTarget.classList.remove(...this.validStatusClasses, ...this.invalidStatusClasses) + } + } + + showValid() { + this.textareaTarget.classList.remove(...this.invalidClasses) + this.textareaTarget.classList.add(...this.validClasses) + if (this.hasStatusTarget) { + this.statusTarget.textContent = "✓ Valid JSON" + this.statusTarget.classList.remove(...this.invalidStatusClasses) + this.statusTarget.classList.add(...this.validStatusClasses) + } + } + + showInvalid(errorMessage) { + this.textareaTarget.classList.remove(...this.validClasses) + this.textareaTarget.classList.add(...this.invalidClasses) + if (this.hasStatusTarget) { + this.statusTarget.textContent = `✗ Invalid JSON: ${errorMessage}` + this.statusTarget.classList.remove(...this.validStatusClasses) + this.statusTarget.classList.add(...this.invalidStatusClasses) + } + } + + insertSample(event) { + event.preventDefault() + const sample = event.params.json || event.target.dataset.jsonSample + if (sample) { + this.textareaTarget.value = sample + this.format() + } + } +} \ No newline at end of file diff --git a/app/javascript/controllers/role_management_controller.js b/app/javascript/controllers/role_management_controller.js deleted file mode 100644 index 95b2cd0..0000000 --- a/app/javascript/controllers/role_management_controller.js +++ /dev/null @@ -1,51 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -export default class extends Controller { - static targets = ["userSelect", "assignLink", "editForm"] - - connect() { - console.log("Role management controller connected") - } - - assignRole(event) { - event.preventDefault() - - const link = event.currentTarget - const roleId = link.dataset.roleId - const select = document.getElementById(`assign-user-${roleId}`) - - if (!select.value) { - alert("Please select a user") - return - } - - // Update the href with the selected user ID - const originalHref = link.href - const newHref = originalHref.replace("PLACEHOLDER", select.value) - - // Navigate to the updated URL - window.location.href = newHref - } - - toggleEdit(event) { - event.preventDefault() - - const roleId = event.currentTarget.dataset.roleId - const editForm = document.getElementById(`edit-role-${roleId}`) - - if (editForm) { - editForm.classList.toggle("hidden") - } - } - - hideEdit(event) { - event.preventDefault() - - const roleId = event.currentTarget.dataset.roleId - const editForm = document.getElementById(`edit-role-${roleId}`) - - if (editForm) { - editForm.classList.add("hidden") - } - } -} \ No newline at end of file diff --git a/app/views/admin/applications/_form.html.erb b/app/views/admin/applications/_form.html.erb index e253afd..3885dbe 100644 --- a/app/views/admin/applications/_form.html.erb +++ b/app/views/admin/applications/_form.html.erb @@ -1,22 +1,5 @@ -<%= form_with(model: [:admin, application], class: "space-y-6", data: { controller: "application-form" }) do |form| %> - <% if application.errors.any? %> -
Domain pattern to match. Use * for wildcard subdomains (e.g., *.example.com matches app.example.com, api.example.com, etc.)
Optional: Customize header names sent to your application.
+Optional: Customize header names sent to your application.
+Default headers: X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Groups, X-Remote-Admin
+Select which users should be members of this group.
Optional: Custom claims to add to OIDC tokens for all members. These will be merged with user-level claims.
+ <%= form.text_area :custom_claims, value: (group.custom_claims.present? ? JSON.pretty_generate(group.custom_claims) : ""), rows: 8, + class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", + placeholder: '{"roles": ["admin", "editor"]}', + data: { + action: "input->json-validator#validate blur->json-validator#format", + json_validator_target: "textarea" + } %> +Optional: Custom claims to add to OIDC tokens for all members. These will be merged with user-level claims.
+Optional: User-specific custom claims to add to OIDC tokens. These override group-level claims.
+ <%= form.text_area :custom_claims, value: (user.custom_claims.present? ? JSON.pretty_generate(user.custom_claims) : ""), rows: 8, + class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", + placeholder: '{"department": "engineering", "level": "senior"}', + data: { + action: "input->json-validator#validate blur->json-validator#format", + json_validator_target: "textarea" + } %> +Optional: User-specific custom claims to add to OIDC tokens. These override group-level claims.
+<%= flash[:notice] %>
-Create your admin account to get started