From bf104a9983394e46286ab0a06b8f46b7daf21a3d Mon Sep 17 00:00:00 2001 From: Dan Milne Date: Tue, 4 Nov 2025 17:06:53 +1100 Subject: [PATCH] Fix CSP errors - migrate inline JS to stimulus controllers. Add a URL for applications so users can discover them --- .env.example | 24 +++++ .../admin/applications_controller.rb | 2 +- app/controllers/dashboard_controller.rb | 5 + .../controllers/login_form_controller.js | 92 +++++++++++++++++++ .../controllers/modal_controller.js | 46 ++++++++++ app/models/application.rb | 1 + app/views/admin/applications/_form.html.erb | 6 ++ app/views/admin/applications/show.html.erb | 10 ++ app/views/dashboard/index.html.erb | 58 ++++++++++++ app/views/profiles/show.html.erb | 46 +++++----- app/views/sessions/new.html.erb | 81 +--------------- ...4054909_add_landing_url_to_applications.rb | 5 + db/schema.rb | 3 +- 13 files changed, 277 insertions(+), 102 deletions(-) create mode 100644 app/javascript/controllers/login_form_controller.js create mode 100644 app/javascript/controllers/modal_controller.js create mode 100644 db/migrate/20251104054909_add_landing_url_to_applications.rb diff --git a/.env.example b/.env.example index af82f42..e199dac 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,30 @@ SMTP_ENABLE_STARTTLS=true CLINCH_HOST=http://localhost:3000 CLINCH_FROM_EMAIL=noreply@example.com +# WebAuthn / Passkey Configuration +# Required for passkeys to work in production (HTTPS required) +# +# CLINCH_RP_ID is the Relying Party Identifier - the domain that owns the passkeys +# - If your site is auth.example.com, use either "auth.example.com" or "example.com" +# - Using parent domain (e.g., "example.com") allows passkeys to work across all subdomains +# - Using subdomain (e.g., "auth.example.com") restricts passkeys to that specific subdomain +# +# CLINCH_RP_NAME is shown to users when creating/using passkeys +# +# Examples: +# For https://auth.example.com: +# CLINCH_HOST=https://auth.example.com +# CLINCH_RP_ID=example.com +# CLINCH_RP_NAME="Example Company" +# +# For https://sso.mycompany.com: +# CLINCH_HOST=https://sso.mycompany.com +# CLINCH_RP_ID=mycompany.com +# CLINCH_RP_NAME="My Company Identity" +# +CLINCH_RP_ID=localhost +CLINCH_RP_NAME="Clinch Identity Provider" + # DNS Rebinding Protection Configuration # Set to service name (e.g., 'clinch') if running in same Docker compose as Caddy CLINCH_DOCKER_SERVICE_NAME= diff --git a/app/controllers/admin/applications_controller.rb b/app/controllers/admin/applications_controller.rb index 2243e2c..866fd65 100644 --- a/app/controllers/admin/applications_controller.rb +++ b/app/controllers/admin/applications_controller.rb @@ -99,7 +99,7 @@ module Admin def application_params params.require(:application).permit( :name, :slug, :app_type, :active, :redirect_uris, :description, :metadata, - :domain_pattern, headers_config: {} + :domain_pattern, :landing_url, headers_config: {} ) end end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index ce1b60d..f8a83c2 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -8,5 +8,10 @@ class DashboardController < ApplicationController # User must be authenticated @user = Current.session.user + + # Load user's accessible applications + @applications = Application.active.select do |app| + app.user_allowed?(@user) + end end end diff --git a/app/javascript/controllers/login_form_controller.js b/app/javascript/controllers/login_form_controller.js new file mode 100644 index 0000000..19f9ded --- /dev/null +++ b/app/javascript/controllers/login_form_controller.js @@ -0,0 +1,92 @@ +import { Controller } from "@hotwired/stimulus" + +// Handles login form UI changes based on WebAuthn availability +export default class extends Controller { + static targets = ["webauthnSection", "passwordSection", "statusMessage", "loadingOverlay"] + + connect() { + // Listen for WebAuthn availability events from the webauthn controller + this.element.addEventListener('webauthn:webauthn-available', this.handleWebAuthnAvailable.bind(this)); + + // Listen for WebAuthn registration events (from profile page) + this.element.addEventListener('webauthn:passkey-registered', this.handlePasskeyRegistered.bind(this)); + + // Listen for authentication start/end to show/hide loading + document.addEventListener('webauthn:authenticate-start', this.showLoading.bind(this)); + document.addEventListener('webauthn:authenticate-end', this.hideLoading.bind(this)); + } + + disconnect() { + // Clean up event listeners + document.removeEventListener('webauthn:authenticate-start', this.showLoading.bind(this)); + document.removeEventListener('webauthn:authenticate-end', this.hideLoading.bind(this)); + } + + handleWebAuthnAvailable(event) { + const detail = event.detail; + + if (!this.hasWebauthnSectionTarget || !this.hasPasswordSectionTarget) { + return; + } + + if (detail.hasWebauthn) { + this.webauthnSectionTarget.classList.remove('hidden'); + + // If WebAuthn is required, hide password section + if (detail.requiresWebauthn) { + this.passwordSectionTarget.classList.add('hidden'); + } else { + // Show both options with a divider + this.passwordSectionTarget.classList.add('border-t', 'pt-4', 'mt-4'); + this.addOrDivider(); + } + } + } + + handlePasskeyRegistered(event) { + if (!this.hasStatusMessageTarget) { + return; + } + + // Show success message + this.statusMessageTarget.className = 'mt-4 p-3 rounded-md bg-green-50 text-green-800 border border-green-200'; + this.statusMessageTarget.textContent = 'Passkey registered successfully!'; + this.statusMessageTarget.classList.remove('hidden'); + + // Hide after 3 seconds + setTimeout(() => { + this.statusMessageTarget.classList.add('hidden'); + }, 3000); + } + + showLoading() { + if (this.hasLoadingOverlayTarget) { + this.loadingOverlayTarget.classList.remove('hidden'); + } + } + + hideLoading() { + if (this.hasLoadingOverlayTarget) { + this.loadingOverlayTarget.classList.add('hidden'); + } + } + + addOrDivider() { + // Check if divider already exists + if (this.element.querySelector('.login-divider')) { + return; + } + + const orDiv = document.createElement('div'); + orDiv.className = 'relative my-4 login-divider'; + orDiv.innerHTML = ` +
+
+
+
+ Or +
+ `; + this.webauthnSectionTarget.parentNode.insertBefore(orDiv, this.passwordSectionTarget); + } +} diff --git a/app/javascript/controllers/modal_controller.js b/app/javascript/controllers/modal_controller.js new file mode 100644 index 0000000..4d5beca --- /dev/null +++ b/app/javascript/controllers/modal_controller.js @@ -0,0 +1,46 @@ +import { Controller } from "@hotwired/stimulus" + +// Generic modal controller for showing/hiding modal dialogs +export default class extends Controller { + static targets = ["dialog"] + + show(event) { + // If called from a button with data-modal-id, find and show that modal + const modalId = event.currentTarget?.dataset?.modalId; + if (modalId) { + const modal = document.getElementById(modalId); + if (modal) { + modal.classList.remove("hidden"); + } + } else if (this.hasDialogTarget) { + // Otherwise show the dialog target + this.dialogTarget.classList.remove("hidden"); + } else { + // Or show this element itself + this.element.classList.remove("hidden"); + } + } + + hide() { + if (this.hasDialogTarget) { + this.dialogTarget.classList.add("hidden"); + } else { + this.element.classList.add("hidden"); + } + } + + // Close modal when clicking backdrop + closeOnBackdrop(event) { + // Only close if clicking directly on the backdrop (not child elements) + if (event.target === this.element || event.target.classList.contains('modal-backdrop')) { + this.hide(); + } + } + + // Close modal on Escape key + closeOnEscape(event) { + if (event.key === "Escape") { + this.hide(); + } + } +} diff --git a/app/models/application.rb b/app/models/application.rb index 3b94911..d4a44b2 100644 --- a/app/models/application.rb +++ b/app/models/application.rb @@ -15,6 +15,7 @@ class Application < ApplicationRecord validates :client_id, uniqueness: { allow_nil: true } validates :client_secret, presence: true, if: :oidc? validates :domain_pattern, presence: true, uniqueness: { case_sensitive: false }, if: :forward_auth? + validates :landing_url, format: { with: URI::regexp(%w[http https]), allow_nil: true, message: "must be a valid URL" } normalizes :slug, with: ->(slug) { slug.strip.downcase } normalizes :domain_pattern, with: ->(pattern) { pattern&.strip&.downcase } diff --git a/app/views/admin/applications/_form.html.erb b/app/views/admin/applications/_form.html.erb index 2de21d9..9e50c98 100644 --- a/app/views/admin/applications/_form.html.erb +++ b/app/views/admin/applications/_form.html.erb @@ -34,6 +34,12 @@ <%= form.text_area :description, rows: 3, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Optional description of this application" %> +
+ <%= form.label :landing_url, "Landing URL", class: "block text-sm font-medium text-gray-700" %> + <%= form.url_field :landing_url, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "https://app.example.com" %> +

The main URL users will visit to access this application. This will be shown as a link on their dashboard.

+
+
<%= form.label :app_type, "Application Type", class: "block text-sm font-medium text-gray-700" %> <%= form.select :app_type, [["OpenID Connect (OIDC)", "oidc"], ["Forward Auth (Reverse Proxy)", "forward_auth"]], {}, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", disabled: application.persisted? %> diff --git a/app/views/admin/applications/show.html.erb b/app/views/admin/applications/show.html.erb index 5c69212..6df7e50 100644 --- a/app/views/admin/applications/show.html.erb +++ b/app/views/admin/applications/show.html.erb @@ -59,6 +59,16 @@ <% end %>
+
+
Landing URL
+
+ <% if @application.landing_url.present? %> + <%= link_to @application.landing_url, @application.landing_url, target: "_blank", rel: "noopener noreferrer", class: "text-blue-600 hover:text-blue-800 underline" %> + <% else %> + Not configured + <% end %> +
+
diff --git a/app/views/dashboard/index.html.erb b/app/views/dashboard/index.html.erb index b474e6e..9ecd7ea 100644 --- a/app/views/dashboard/index.html.erb +++ b/app/views/dashboard/index.html.erb @@ -93,6 +93,64 @@ <% end %> + +
+

Your Applications

+ + <% if @applications.any? %> +
+ <% @applications.each do |app| %> +
+
+
+

+ <%= app.name %> +

+ + <%= app.app_type.humanize %> + +
+ +

+ <% if app.oidc? %> + OIDC Application + <% else %> + ForwardAuth Protected Application + <% end %> +

+ + <% if app.landing_url.present? %> + <%= link_to "Open Application", app.landing_url, + target: "_blank", + rel: "noopener noreferrer", + class: "w-full flex justify-center items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition" %> + <% else %> +
+ No landing URL configured +
+ <% end %> +
+
+ <% end %> +
+ <% else %> +
+ + + +

No applications available

+

+ You don't have access to any applications yet. Contact your administrator if you think this is an error. +

+
+ <% end %> +
+ <% if @user.admin? %>

Admin Quick Actions

diff --git a/app/views/profiles/show.html.erb b/app/views/profiles/show.html.erb index 422e14c..ec8f9aa 100644 --- a/app/views/profiles/show.html.erb +++ b/app/views/profiles/show.html.erb @@ -102,10 +102,16 @@
- -
@@ -119,7 +125,10 @@ -