From f02665f6900eed959019cac5a1599c94366ddaed Mon Sep 17 00:00:00 2001 From: Dan Milne Date: Fri, 7 Nov 2025 16:58:28 +1100 Subject: [PATCH] Consolidate all the error messages - add some stimulus controller. --- .../controllers/flash_controller.js | 85 ++++++++++++++++ .../controllers/form_errors_controller.js | 89 +++++++++++++++++ .../controllers/hello_controller.js | 7 -- .../controllers/json_validator_controller.js | 81 +++++++++++++++ .../controllers/role_management_controller.js | 51 ---------- app/views/admin/applications/_form.html.erb | 40 ++++---- app/views/admin/groups/_form.html.erb | 42 ++++---- app/views/admin/users/_form.html.erb | 42 ++++---- app/views/passwords/new.html.erb | 2 +- app/views/sessions/new.html.erb | 2 +- app/views/shared/_flash.html.erb | 99 +++++++++++++++---- app/views/shared/_form_errors.html.erb | 31 ++++-- app/views/users/new.html.erb | 13 +-- 13 files changed, 417 insertions(+), 167 deletions(-) create mode 100644 app/javascript/controllers/flash_controller.js create mode 100644 app/javascript/controllers/form_errors_controller.js delete mode 100644 app/javascript/controllers/hello_controller.js create mode 100644 app/javascript/controllers/json_validator_controller.js delete mode 100644 app/javascript/controllers/role_management_controller.js 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? %> -
-
-
-

- <%= pluralize(application.errors.count, "error") %> prohibited this application from being saved: -

-
-
    - <% application.errors.full_messages.each do |message| %> -
  • <%= message %>
  • - <% end %> -
-
-
-
-
- <% end %> +<%= form_with(model: [:admin, application], class: "space-y-6", data: { controller: "application-form form-errors" }) do |form| %> + <%= render "shared/form_errors", form: form %>
<%= form.label :name, class: "block text-sm font-medium text-gray-700" %> @@ -73,12 +56,25 @@

Domain pattern to match. Use * for wildcard subdomains (e.g., *.example.com matches app.example.com, api.example.com, etc.)

-
+
<%= form.label :headers_config, "Custom Headers Configuration (JSON)", class: "block text-sm font-medium text-gray-700" %> - <%= form.text_area :headers_config, rows: 10, 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: '{"user": "Remote-User", "groups": "Remote-Groups"}' %> + <%= form.text_area :headers_config, value: (application.headers_config.present? && application.headers_config.any? ? JSON.pretty_generate(application.headers_config) : ""), rows: 10, + 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: '{"user": "Remote-User", "groups": "Remote-Groups"}', + data: { + action: "input->json-validator#validate blur->json-validator#format", + json_validator_target: "textarea" + } %>
-

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

+
Show available header keys and what data they send
diff --git a/app/views/admin/groups/_form.html.erb b/app/views/admin/groups/_form.html.erb index c7565ae..ba64adb 100644 --- a/app/views/admin/groups/_form.html.erb +++ b/app/views/admin/groups/_form.html.erb @@ -1,22 +1,5 @@ -<%= form_with(model: [:admin, group], class: "space-y-6") do |form| %> - <% if group.errors.any? %> -
-
-
-

- <%= pluralize(group.errors.count, "error") %> prohibited this group from being saved: -

-
-
    - <% group.errors.full_messages.each do |message| %> -
  • <%= message %>
  • - <% end %> -
-
-
-
-
- <% end %> +<%= form_with(model: [:admin, group], class: "space-y-6", data: { controller: "form-errors" }) do |form| %> + <%= render "shared/form_errors", form: form %>
<%= form.label :name, class: "block text-sm font-medium text-gray-700" %> @@ -49,10 +32,25 @@

Select which users should be members of this group.

-
+
<%= form.label :custom_claims, "Custom Claims (JSON)", class: "block text-sm font-medium text-gray-700" %> - <%= 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"]}' %> -

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.

+
+ + +
+
+
+
diff --git a/app/views/admin/users/_form.html.erb b/app/views/admin/users/_form.html.erb index 3ed2485..68bc915 100644 --- a/app/views/admin/users/_form.html.erb +++ b/app/views/admin/users/_form.html.erb @@ -1,22 +1,5 @@ -<%= form_with(model: [:admin, user], class: "space-y-6") do |form| %> - <% if user.errors.any? %> -
-
-
-

- <%= pluralize(user.errors.count, "error") %> prohibited this user from being saved: -

-
-
    - <% user.errors.full_messages.each do |message| %> -
  • <%= message %>
  • - <% end %> -
-
-
-
-
- <% end %> +<%= form_with(model: [:admin, user], class: "space-y-6", data: { controller: "form-errors" }) do |form| %> + <%= render "shared/form_errors", form: form %>
<%= form.label :email_address, class: "block text-sm font-medium text-gray-700" %> @@ -52,10 +35,25 @@ <% end %>
-
+
<%= form.label :custom_claims, "Custom Claims (JSON)", class: "block text-sm font-medium text-gray-700" %> - <%= 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"}' %> -

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.

+
+ + +
+
+
+
diff --git a/app/views/passwords/new.html.erb b/app/views/passwords/new.html.erb index 8360e02..c12f7d9 100644 --- a/app/views/passwords/new.html.erb +++ b/app/views/passwords/new.html.erb @@ -5,7 +5,7 @@

Forgot your password?

- <%= form_with url: passwords_path, class: "contents" do |form| %> + <%= form_with url: passwords_path, class: "contents", data: { controller: "form-errors" } do |form| %>
<%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index 98dc6e7..a5b27d1 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -3,7 +3,7 @@

Sign in to Clinch

- <%= form_with url: signin_path, class: "contents" do |form| %> + <%= form_with url: signin_path, class: "contents", data: { controller: "form-errors" } do |form| %> <%= hidden_field_tag :rd, params[:rd] if params[:rd].present? %>
<%= form.label :email_address, "Email Address", class: "block font-medium text-sm text-gray-700" %> diff --git a/app/views/shared/_flash.html.erb b/app/views/shared/_flash.html.erb index 1d888e6..3efdd34 100644 --- a/app/views/shared/_flash.html.erb +++ b/app/views/shared/_flash.html.erb @@ -1,29 +1,86 @@ -<% if flash[:alert] %> -