Compare commits
2 Commits
631b2b53bb
...
2025.02
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e0b2c28eb | ||
|
|
f02665f690 |
85
app/javascript/controllers/flash_controller.js
Normal file
85
app/javascript/controllers/flash_controller.js
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
89
app/javascript/controllers/form_errors_controller.js
Normal file
89
app/javascript/controllers/form_errors_controller.js
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { Controller } from "@hotwired/stimulus"
|
|
||||||
|
|
||||||
export default class extends Controller {
|
|
||||||
connect() {
|
|
||||||
this.element.textContent = "Hello World!"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
81
app/javascript/controllers/json_validator_controller.js
Normal file
81
app/javascript/controllers/json_validator_controller.js
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +1,5 @@
|
|||||||
<%= form_with(model: [:admin, application], class: "space-y-6", data: { controller: "application-form" }) do |form| %>
|
<%= form_with(model: [:admin, application], class: "space-y-6", data: { controller: "application-form form-errors" }) do |form| %>
|
||||||
<% if application.errors.any? %>
|
<%= render "shared/form_errors", form: form %>
|
||||||
<div class="rounded-md bg-red-50 p-4">
|
|
||||||
<div class="flex">
|
|
||||||
<div class="ml-3">
|
|
||||||
<h3 class="text-sm font-medium text-red-800">
|
|
||||||
<%= pluralize(application.errors.count, "error") %> prohibited this application from being saved:
|
|
||||||
</h3>
|
|
||||||
<div class="mt-2 text-sm text-red-700">
|
|
||||||
<ul class="list-disc pl-5 space-y-1">
|
|
||||||
<% application.errors.full_messages.each do |message| %>
|
|
||||||
<li><%= message %></li>
|
|
||||||
<% end %>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :name, class: "block text-sm font-medium text-gray-700" %>
|
<%= form.label :name, class: "block text-sm font-medium text-gray-700" %>
|
||||||
@@ -73,12 +56,25 @@
|
|||||||
<p class="mt-1 text-sm text-gray-500">Domain pattern to match. Use * for wildcard subdomains (e.g., *.example.com matches app.example.com, api.example.com, etc.)</p>
|
<p class="mt-1 text-sm text-gray-500">Domain pattern to match. Use * for wildcard subdomains (e.g., *.example.com matches app.example.com, api.example.com, etc.)</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div data-controller="json-validator" data-json-validator-valid-class="border-green-500 focus:border-green-500 focus:ring-green-500" data-json-validator-invalid-class="border-red-500 focus:border-red-500 focus:ring-red-500" data-json-validator-valid-status-class="text-green-600" data-json-validator-invalid-status-class="text-red-600">
|
||||||
<%= form.label :headers_config, "Custom Headers Configuration (JSON)", class: "block text-sm font-medium text-gray-700" %>
|
<%= 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"
|
||||||
|
} %>
|
||||||
<div class="mt-2 text-sm text-gray-600 space-y-1">
|
<div class="mt-2 text-sm text-gray-600 space-y-1">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
<p class="font-medium">Optional: Customize header names sent to your application.</p>
|
<p class="font-medium">Optional: Customize header names sent to your application.</p>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button type="button" data-action="json-validator#format" class="text-xs bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded">Format JSON</button>
|
||||||
|
<button type="button" data-action="json-validator#insertSample" data-json-sample='{"user": "Remote-User", "groups": "Remote-Groups", "email": "Remote-Email", "name": "Remote-Name", "admin": "Remote-Admin"}' class="text-xs bg-blue-100 hover:bg-blue-200 text-blue-700 px-2 py-1 rounded">Insert Example</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<p><strong>Default headers:</strong> X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Groups, X-Remote-Admin</p>
|
<p><strong>Default headers:</strong> X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Groups, X-Remote-Admin</p>
|
||||||
|
<div data-json-validator-target="status" class="text-xs font-medium"></div>
|
||||||
<details class="mt-2">
|
<details class="mt-2">
|
||||||
<summary class="cursor-pointer text-blue-600 hover:text-blue-800">Show available header keys and what data they send</summary>
|
<summary class="cursor-pointer text-blue-600 hover:text-blue-800">Show available header keys and what data they send</summary>
|
||||||
<div class="mt-2 ml-4 space-y-1 text-xs">
|
<div class="mt-2 ml-4 space-y-1 text-xs">
|
||||||
|
|||||||
@@ -1,22 +1,5 @@
|
|||||||
<%= form_with(model: [:admin, group], class: "space-y-6") do |form| %>
|
<%= form_with(model: [:admin, group], class: "space-y-6", data: { controller: "form-errors" }) do |form| %>
|
||||||
<% if group.errors.any? %>
|
<%= render "shared/form_errors", form: form %>
|
||||||
<div class="rounded-md bg-red-50 p-4">
|
|
||||||
<div class="flex">
|
|
||||||
<div class="ml-3">
|
|
||||||
<h3 class="text-sm font-medium text-red-800">
|
|
||||||
<%= pluralize(group.errors.count, "error") %> prohibited this group from being saved:
|
|
||||||
</h3>
|
|
||||||
<div class="mt-2 text-sm text-red-700">
|
|
||||||
<ul class="list-disc pl-5 space-y-1">
|
|
||||||
<% group.errors.full_messages.each do |message| %>
|
|
||||||
<li><%= message %></li>
|
|
||||||
<% end %>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :name, class: "block text-sm font-medium text-gray-700" %>
|
<%= form.label :name, class: "block text-sm font-medium text-gray-700" %>
|
||||||
@@ -49,10 +32,25 @@
|
|||||||
<p class="mt-1 text-sm text-gray-500">Select which users should be members of this group.</p>
|
<p class="mt-1 text-sm text-gray-500">Select which users should be members of this group.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div data-controller="json-validator" data-json-validator-valid-class="border-green-500 focus:border-green-500 focus:ring-green-500" data-json-validator-invalid-class="border-red-500 focus:border-red-500 focus:ring-red-500" data-json-validator-valid-status-class="text-green-600" data-json-validator-invalid-status-class="text-red-600">
|
||||||
<%= form.label :custom_claims, "Custom Claims (JSON)", class: "block text-sm font-medium text-gray-700" %>
|
<%= 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"]}' %>
|
<%= form.text_area :custom_claims, value: (group.custom_claims.present? ? JSON.pretty_generate(group.custom_claims) : ""), rows: 8,
|
||||||
<p class="mt-1 text-sm text-gray-500">Optional: Custom claims to add to OIDC tokens for all members. These will be merged with user-level claims.</p>
|
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"
|
||||||
|
} %>
|
||||||
|
<div class="mt-2 text-sm text-gray-600 space-y-1">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p>Optional: Custom claims to add to OIDC tokens for all members. These will be merged with user-level claims.</p>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button type="button" data-action="json-validator#format" class="text-xs bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded">Format JSON</button>
|
||||||
|
<button type="button" data-action="json-validator#insertSample" data-json-sample='{"roles": ["admin", "editor"], "permissions": ["read", "write"], "team": "backend"}' class="text-xs bg-blue-100 hover:bg-blue-200 text-blue-700 px-2 py-1 rounded">Insert Example</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div data-json-validator-target="status" class="text-xs font-medium"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
|
|||||||
@@ -1,22 +1,5 @@
|
|||||||
<%= form_with(model: [:admin, user], class: "space-y-6") do |form| %>
|
<%= form_with(model: [:admin, user], class: "space-y-6", data: { controller: "form-errors" }) do |form| %>
|
||||||
<% if user.errors.any? %>
|
<%= render "shared/form_errors", form: form %>
|
||||||
<div class="rounded-md bg-red-50 p-4">
|
|
||||||
<div class="flex">
|
|
||||||
<div class="ml-3">
|
|
||||||
<h3 class="text-sm font-medium text-red-800">
|
|
||||||
<%= pluralize(user.errors.count, "error") %> prohibited this user from being saved:
|
|
||||||
</h3>
|
|
||||||
<div class="mt-2 text-sm text-red-700">
|
|
||||||
<ul class="list-disc pl-5 space-y-1">
|
|
||||||
<% user.errors.full_messages.each do |message| %>
|
|
||||||
<li><%= message %></li>
|
|
||||||
<% end %>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :email_address, class: "block text-sm font-medium text-gray-700" %>
|
<%= form.label :email_address, class: "block text-sm font-medium text-gray-700" %>
|
||||||
@@ -52,10 +35,25 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div data-controller="json-validator" data-json-validator-valid-class="border-green-500 focus:border-green-500 focus:ring-green-500" data-json-validator-invalid-class="border-red-500 focus:border-red-500 focus:ring-red-500" data-json-validator-valid-status-class="text-green-600" data-json-validator-invalid-status-class="text-red-600">
|
||||||
<%= form.label :custom_claims, "Custom Claims (JSON)", class: "block text-sm font-medium text-gray-700" %>
|
<%= 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"}' %>
|
<%= form.text_area :custom_claims, value: (user.custom_claims.present? ? JSON.pretty_generate(user.custom_claims) : ""), rows: 8,
|
||||||
<p class="mt-1 text-sm text-gray-500">Optional: User-specific custom claims to add to OIDC tokens. These override group-level claims.</p>
|
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"
|
||||||
|
} %>
|
||||||
|
<div class="mt-2 text-sm text-gray-600 space-y-1">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p>Optional: User-specific custom claims to add to OIDC tokens. These override group-level claims.</p>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button type="button" data-action="json-validator#format" class="text-xs bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded">Format JSON</button>
|
||||||
|
<button type="button" data-action="json-validator#insertSample" data-json-sample='{"department": "engineering", "level": "senior", "location": "remote"}' class="text-xs bg-blue-100 hover:bg-blue-200 text-blue-700 px-2 py-1 rounded">Insert Example</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div data-json-validator-target="status" class="text-xs font-medium"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
<h1 class="font-bold text-4xl">Forgot your password?</h1>
|
<h1 class="font-bold text-4xl">Forgot your password?</h1>
|
||||||
|
|
||||||
<%= form_with url: passwords_path, class: "contents" do |form| %>
|
<%= form_with url: passwords_path, class: "contents", data: { controller: "form-errors" } do |form| %>
|
||||||
<div class="my-5">
|
<div class="my-5">
|
||||||
<%= 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" %>
|
<%= 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" %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<h1 class="font-bold text-4xl">Sign in to Clinch</h1>
|
<h1 class="font-bold text-4xl">Sign in to Clinch</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= 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? %>
|
<%= hidden_field_tag :rd, params[:rd] if params[:rd].present? %>
|
||||||
<div class="my-5">
|
<div class="my-5">
|
||||||
<%= form.label :email_address, "Email Address", class: "block font-medium text-sm text-gray-700" %>
|
<%= form.label :email_address, "Email Address", class: "block font-medium text-sm text-gray-700" %>
|
||||||
|
|||||||
@@ -1,29 +1,86 @@
|
|||||||
<% if flash[:alert] %>
|
<%# Enhanced Flash Messages with Support for Multiple Types and Auto-Dismiss %>
|
||||||
<div class="mb-4 rounded-lg bg-red-50 p-4" role="alert">
|
<% flash.each do |type, message| %>
|
||||||
|
<% next if message.blank? %>
|
||||||
|
|
||||||
|
<%
|
||||||
|
# Map flash types to styling
|
||||||
|
case type.to_s
|
||||||
|
when 'notice'
|
||||||
|
bg_class = 'bg-green-50'
|
||||||
|
text_class = 'text-green-800'
|
||||||
|
icon_class = 'text-green-400'
|
||||||
|
icon_path = 'M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z'
|
||||||
|
auto_dismiss = true
|
||||||
|
when 'alert', 'error'
|
||||||
|
bg_class = 'bg-red-50'
|
||||||
|
text_class = 'text-red-800'
|
||||||
|
icon_class = 'text-red-400'
|
||||||
|
icon_path = 'M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z'
|
||||||
|
auto_dismiss = false
|
||||||
|
when 'warning'
|
||||||
|
bg_class = 'bg-yellow-50'
|
||||||
|
text_class = 'text-yellow-800'
|
||||||
|
icon_class = 'text-yellow-400'
|
||||||
|
icon_path = 'M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z'
|
||||||
|
auto_dismiss = false
|
||||||
|
when 'info'
|
||||||
|
bg_class = 'bg-blue-50'
|
||||||
|
text_class = 'text-blue-800'
|
||||||
|
icon_class = 'text-blue-400'
|
||||||
|
icon_path = 'M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z'
|
||||||
|
auto_dismiss = true
|
||||||
|
else
|
||||||
|
# Default styling for unknown types
|
||||||
|
bg_class = 'bg-gray-50'
|
||||||
|
text_class = 'text-gray-800'
|
||||||
|
icon_class = 'text-gray-400'
|
||||||
|
icon_path = 'M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z'
|
||||||
|
auto_dismiss = false
|
||||||
|
end
|
||||||
|
%>
|
||||||
|
|
||||||
|
<div class="mb-4 rounded-lg <%= bg_class %> p-4 border border-opacity-20 <%= border_class_for(type) %>"
|
||||||
|
role="alert"
|
||||||
|
data-controller="flash"
|
||||||
|
data-flash-auto-dismiss-value="<%= auto_dismiss ? '5000' : 'false' %>"
|
||||||
|
data-flash-type-value="<%= type %>">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div class="flex-shrink-0">
|
<div class="shrink-0">
|
||||||
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
<svg class="h-5 w-5 <%= icon_class %>" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
<path fill-rule="evenodd" d="<%= icon_path %>" clip-rule="evenodd"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-3">
|
<div class="ml-3 flex-1">
|
||||||
<p class="text-sm font-medium text-red-800"><%= flash[:alert] %></p>
|
<p class="text-sm font-medium <%= text_class %>"><%= message %></p>
|
||||||
</div>
|
</div>
|
||||||
|
<% if auto_dismiss || type.to_s != 'alert' %>
|
||||||
|
<div class="ml-auto pl-3">
|
||||||
|
<div class="-mx-1.5 -my-1.5">
|
||||||
|
<button type="button"
|
||||||
|
data-action="click->flash#dismiss"
|
||||||
|
class="inline-flex rounded-md <%= bg_class %> p-1.5 <%= icon_class %> hover:bg-opacity-70 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-<%= bg_class.gsub('bg-', '') %>"
|
||||||
|
aria-label="Dismiss">
|
||||||
|
<span class="sr-only">Dismiss</span>
|
||||||
|
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<% if flash[:notice] %>
|
<%# Helper method for border colors %>
|
||||||
<div class="mb-4 rounded-lg bg-green-50 p-4" role="alert">
|
<%
|
||||||
<div class="flex">
|
def border_class_for(type)
|
||||||
<div class="flex-shrink-0">
|
case type.to_s
|
||||||
<svg class="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
|
when 'notice' then 'border-green-200'
|
||||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
when 'alert', 'error' then 'border-red-200'
|
||||||
</svg>
|
when 'warning' then 'border-yellow-200'
|
||||||
</div>
|
when 'info' then 'border-blue-200'
|
||||||
<div class="ml-3">
|
else 'border-gray-200'
|
||||||
<p class="text-sm font-medium text-green-800"><%= flash[:notice] %></p>
|
end
|
||||||
</div>
|
end
|
||||||
</div>
|
%>
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|||||||
@@ -1,23 +1,36 @@
|
|||||||
<% if form.object.errors.any? %>
|
<%# Usage: <%= render "shared/form_errors", object: @user %> %>
|
||||||
<div class="rounded-md bg-red-50 p-4">
|
<%# Usage: <%= render "shared/form_errors", form: form %> %>
|
||||||
|
|
||||||
|
<% form_object = form.respond_to?(:object) ? form.object : (object || form) %>
|
||||||
|
<% if form_object&.errors&.any? %>
|
||||||
|
<div class="rounded-md bg-red-50 p-4 mb-6 border border-red-200" role="alert" aria-labelledby="form-errors-title" data-form-errors-target="container">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd" />
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-3">
|
<div class="ml-3 flex-1">
|
||||||
<h3 class="text-sm font-medium text-red-800">
|
<h3 id="form-errors-title" class="text-sm font-medium text-red-800">
|
||||||
There were <%= pluralize(form.object.errors.count, "error") %> with your submission:
|
<%= pluralize(form_object.errors.count, "error") %> prohibited this <%= form_object.class.name.downcase.gsub(/^admin::/, '') %> from being saved:
|
||||||
</h3>
|
</h3>
|
||||||
<div class="mt-2 text-sm text-red-700">
|
<div class="mt-2">
|
||||||
<ul class="list-disc space-y-1 pl-5">
|
<ul class="list-disc space-y-1 pl-5 text-sm text-red-700">
|
||||||
<% form.object.errors.full_messages.each do |message| %>
|
<% form_object.errors.full_messages.each do |message| %>
|
||||||
<li><%= message %></li>
|
<li><%= message %></li>
|
||||||
<% end %>
|
<% end %>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="ml-auto pl-3">
|
||||||
|
<div class="-mx-1.5 -my-1.5">
|
||||||
|
<button type="button" data-action="click->form-errors#dismiss" class="inline-flex rounded-md bg-red-50 p-1.5 text-red-500 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-red-600 focus:ring-offset-2 focus:ring-offset-red-50" aria-label="Dismiss">
|
||||||
|
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -4,17 +4,8 @@
|
|||||||
<p class="mt-2 text-gray-600">Create your admin account to get started</p>
|
<p class="mt-2 text-gray-600">Create your admin account to get started</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= form_with model: @user, url: signup_path, class: "contents" do |form| %>
|
<%= form_with model: @user, url: signup_path, class: "contents", data: { controller: "form-errors" } do |form| %>
|
||||||
<% if @user.errors.any? %>
|
<%= render "shared/form_errors", form: form %>
|
||||||
<div class="bg-red-50 text-red-500 px-3 py-2 font-medium rounded-lg mt-3">
|
|
||||||
<h2><%= pluralize(@user.errors.count, "error") %> prohibited this account from being saved:</h2>
|
|
||||||
<ul class="list-disc list-inside">
|
|
||||||
<% @user.errors.each do |error| %>
|
|
||||||
<li><%= error.full_message %></li>
|
|
||||||
<% end %>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<div class="my-5">
|
<div class="my-5">
|
||||||
<%= form.label :email_address, class: "block font-medium text-sm text-gray-700" %>
|
<%= form.label :email_address, class: "block font-medium text-sm text-gray-700" %>
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ Rails.application.config.after_initialize do
|
|||||||
# Determine severity for log level
|
# Determine severity for log level
|
||||||
level = determine_log_level(csp_data[:violated_directive])
|
level = determine_log_level(csp_data[:violated_directive])
|
||||||
|
|
||||||
csp_logger.log(level, log_message)
|
self.csp_logger.log(level, log_message)
|
||||||
|
|
||||||
# Also log to main Rails logger for visibility
|
# Also log to main Rails logger for visibility
|
||||||
Rails.logger.info "CSP violation logged to csp_violations.log: #{violated_directive} - #{blocked_uri}"
|
Rails.logger.info "CSP violation logged to csp_violations.log: #{violated_directive} - #{blocked_uri}"
|
||||||
@@ -70,6 +70,22 @@ Rails.application.config.after_initialize do
|
|||||||
Rails.logger.error e.backtrace.join("\n") if Rails.env.development?
|
Rails.logger.error e.backtrace.join("\n") if Rails.env.development?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.csp_logger
|
||||||
|
@csp_logger ||= begin
|
||||||
|
csp_log_path = Rails.root.join("log", "csp_violations.log")
|
||||||
|
logger = Logger.new(
|
||||||
|
csp_log_path,
|
||||||
|
'daily', # Rotate daily
|
||||||
|
30 # Keep 30 old log files
|
||||||
|
)
|
||||||
|
logger.level = Logger::INFO
|
||||||
|
logger.formatter = proc do |severity, datetime, progname, msg|
|
||||||
|
"[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity} #{msg}\n"
|
||||||
|
end
|
||||||
|
logger
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def self.determine_log_level(violated_directive)
|
def self.determine_log_level(violated_directive)
|
||||||
|
|||||||
Reference in New Issue
Block a user