Compare commits
5 Commits
4f5974dd37
...
2025.02
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e0b2c28eb | ||
|
|
f02665f690 | ||
|
|
631b2b53bb | ||
|
|
6049429a41 | ||
|
|
2b15aa2c40 |
24
.env.example
24
.env.example
@@ -68,3 +68,27 @@ CLINCH_ALLOW_LOCALHOST=true
|
||||
|
||||
# Optional: Set custom port
|
||||
# PORT=9000
|
||||
|
||||
# Sentry Configuration (Optional)
|
||||
# Enable error tracking and performance monitoring
|
||||
# Leave SENTRY_DSN empty to disable Sentry completely
|
||||
#
|
||||
# Production: Get your DSN from https://sentry.io/settings/projects/
|
||||
# SENTRY_DSN=https://your-dsn@sentry.io/project-id
|
||||
#
|
||||
# Optional: Override Sentry environment (defaults to Rails.env)
|
||||
# SENTRY_ENVIRONMENT=production
|
||||
#
|
||||
# Optional: Override Sentry release (defaults to Git commit hash)
|
||||
# SENTRY_RELEASE=v1.0.0
|
||||
#
|
||||
# Optional: Performance monitoring sample rate (0.0 to 1.0, default 0.2)
|
||||
# Higher values provide more data but cost more
|
||||
# SENTRY_TRACES_SAMPLE_RATE=0.2
|
||||
#
|
||||
# Optional: Continuous profiling sample rate (0.0 to 1.0, default 0.0)
|
||||
# Very resource intensive, only enable for performance investigations
|
||||
# SENTRY_PROFILES_SAMPLE_RATE=0.0
|
||||
#
|
||||
# Development: Enable Sentry in development for testing
|
||||
# SENTRY_ENABLED_IN_DEVELOPMENT=true
|
||||
|
||||
4
Gemfile
4
Gemfile
@@ -37,6 +37,10 @@ gem "webauthn", "~> 3.0"
|
||||
# Public Suffix List for domain parsing
|
||||
gem "public_suffix", "~> 6.0"
|
||||
|
||||
# Error tracking and performance monitoring (optional, configured via SENTRY_DSN)
|
||||
gem "sentry-ruby", "~> 5.18"
|
||||
gem "sentry-rails", "~> 5.18"
|
||||
|
||||
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
|
||||
gem "tzinfo-data", platforms: %i[ windows jruby ]
|
||||
|
||||
|
||||
@@ -333,6 +333,12 @@ GEM
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
rubyzip (>= 1.2.2, < 4.0)
|
||||
websocket (~> 1.0)
|
||||
sentry-rails (5.28.0)
|
||||
railties (>= 5.0)
|
||||
sentry-ruby (~> 5.28.0)
|
||||
sentry-ruby (5.28.0)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
solid_cable (3.0.12)
|
||||
actioncable (>= 7.2)
|
||||
activejob (>= 7.2)
|
||||
@@ -443,6 +449,8 @@ DEPENDENCIES
|
||||
rqrcode (~> 3.1)
|
||||
rubocop-rails-omakase
|
||||
selenium-webdriver
|
||||
sentry-rails (~> 5.18)
|
||||
sentry-ruby (~> 5.18)
|
||||
solid_cable
|
||||
solid_cache
|
||||
sqlite3 (>= 2.1)
|
||||
|
||||
@@ -100,7 +100,10 @@ module Admin
|
||||
params.require(:application).permit(
|
||||
:name, :slug, :app_type, :active, :redirect_uris, :description, :metadata,
|
||||
:domain_pattern, :landing_url, headers_config: {}
|
||||
)
|
||||
).tap do |whitelisted|
|
||||
# Remove client_secret from params if present (shouldn't be updated via form)
|
||||
whitelisted.delete(:client_secret)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,19 +8,38 @@ module Api
|
||||
def violation_report
|
||||
# Parse CSP violation report
|
||||
report_data = JSON.parse(request.body.read)
|
||||
csp_report = report_data['csp-report']
|
||||
|
||||
# Log the violation for security monitoring
|
||||
Rails.logger.warn "CSP Violation Report:"
|
||||
Rails.logger.warn " Blocked URI: #{report_data.dig('csp-report', 'blocked-uri')}"
|
||||
Rails.logger.warn " Document URI: #{report_data.dig('csp-report', 'document-uri')}"
|
||||
Rails.logger.warn " Referrer: #{report_data.dig('csp-report', 'referrer')}"
|
||||
Rails.logger.warn " Violated Directive: #{report_data.dig('csp-report', 'violated-directive')}"
|
||||
Rails.logger.warn " Original Policy: #{report_data.dig('csp-report', 'original-policy')}"
|
||||
Rails.logger.warn " Blocked URI: #{csp_report['blocked-uri']}"
|
||||
Rails.logger.warn " Document URI: #{csp_report['document-uri']}"
|
||||
Rails.logger.warn " Referrer: #{csp_report['referrer']}"
|
||||
Rails.logger.warn " Violated Directive: #{csp_report['violated-directive']}"
|
||||
Rails.logger.warn " Original Policy: #{csp_report['original-policy']}"
|
||||
Rails.logger.warn " User Agent: #{request.user_agent}"
|
||||
Rails.logger.warn " IP Address: #{request.remote_ip}"
|
||||
|
||||
# In production, you might want to send this to a security monitoring service
|
||||
# For now, we'll just log it and return a success response
|
||||
# Emit structured event for CSP violation
|
||||
# This allows multiple subscribers to process the event (Sentry, local logging, etc.)
|
||||
Rails.event.notify("csp.violation", {
|
||||
blocked_uri: csp_report['blocked-uri'],
|
||||
document_uri: csp_report['document-uri'],
|
||||
referrer: csp_report['referrer'],
|
||||
violated_directive: csp_report['violated-directive'],
|
||||
original_policy: csp_report['original-policy'],
|
||||
disposition: csp_report['disposition'],
|
||||
effective_directive: csp_report['effective-directive'],
|
||||
source_file: csp_report['source-file'],
|
||||
line_number: csp_report['line-number'],
|
||||
column_number: csp_report['column-number'],
|
||||
status_code: csp_report['status-code'],
|
||||
user_agent: request.user_agent,
|
||||
ip_address: request.remote_ip,
|
||||
current_user_id: Current.user&.id,
|
||||
timestamp: Time.current,
|
||||
session_id: Current.session&.id
|
||||
})
|
||||
|
||||
head :no_content
|
||||
rescue JSON::ParserError => e
|
||||
|
||||
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,21 +1,48 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["sidebarOverlay", "button"];
|
||||
static targets = ["sidebarOverlay"];
|
||||
|
||||
connect() {
|
||||
// Initialize mobile sidebar functionality
|
||||
// Add escape key listener to close sidebar
|
||||
this.boundHandleEscape = this.handleEscape.bind(this);
|
||||
document.addEventListener('keydown', this.boundHandleEscape);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
// Clean up event listeners
|
||||
document.removeEventListener('keydown', this.boundHandleEscape);
|
||||
}
|
||||
|
||||
openSidebar() {
|
||||
if (this.hasSidebarOverlayTarget) {
|
||||
this.sidebarOverlayTarget.classList.remove('hidden');
|
||||
// Prevent body scroll when sidebar is open
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
}
|
||||
|
||||
closeSidebar() {
|
||||
if (this.hasSidebarOverlayTarget) {
|
||||
this.sidebarOverlayTarget.classList.add('hidden');
|
||||
// Restore body scroll
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Close sidebar when clicking on the overlay background
|
||||
closeOnBackgroundClick(event) {
|
||||
// Check if the click is on the overlay background (the semi-transparent layer)
|
||||
if (event.target === this.sidebarOverlayTarget || event.target.classList.contains('bg-gray-900/80')) {
|
||||
this.closeSidebar();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle escape key to close sidebar
|
||||
handleEscape(event) {
|
||||
if (event.key === 'Escape' && this.hasSidebarOverlayTarget && !this.sidebarOverlayTarget.classList.contains('hidden')) {
|
||||
this.closeSidebar();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ class Application < ApplicationRecord
|
||||
validates :app_type, presence: true,
|
||||
inclusion: { in: %w[oidc forward_auth] }
|
||||
validates :client_id, uniqueness: { allow_nil: true }
|
||||
validates :client_secret, presence: true, if: -> { oidc? && new_record? }
|
||||
validates :client_secret, presence: true, on: :create, 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" }
|
||||
|
||||
|
||||
@@ -1,22 +1,5 @@
|
||||
<%= form_with(model: [:admin, application], class: "space-y-6", data: { controller: "application-form" }) do |form| %>
|
||||
<% if application.errors.any? %>
|
||||
<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 %>
|
||||
<%= form_with(model: [:admin, application], class: "space-y-6", data: { controller: "application-form form-errors" }) do |form| %>
|
||||
<%= render "shared/form_errors", form: form %>
|
||||
|
||||
<div>
|
||||
<%= 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>
|
||||
</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.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">
|
||||
<p class="font-medium">Optional: Customize header names sent to your application.</p>
|
||||
<div class="flex items-center justify-between">
|
||||
<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>
|
||||
<div data-json-validator-target="status" class="text-xs font-medium"></div>
|
||||
<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>
|
||||
<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| %>
|
||||
<% if group.errors.any? %>
|
||||
<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 %>
|
||||
<%= form_with(model: [:admin, group], class: "space-y-6", data: { controller: "form-errors" }) do |form| %>
|
||||
<%= render "shared/form_errors", form: form %>
|
||||
|
||||
<div>
|
||||
<%= 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>
|
||||
</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.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"]}' %>
|
||||
<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>
|
||||
<%= 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"
|
||||
} %>
|
||||
<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 class="flex gap-3">
|
||||
|
||||
@@ -1,22 +1,5 @@
|
||||
<%= form_with(model: [:admin, user], class: "space-y-6") do |form| %>
|
||||
<% if user.errors.any? %>
|
||||
<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 %>
|
||||
<%= form_with(model: [:admin, user], class: "space-y-6", data: { controller: "form-errors" }) do |form| %>
|
||||
<%= render "shared/form_errors", form: form %>
|
||||
|
||||
<div>
|
||||
<%= form.label :email_address, class: "block text-sm font-medium text-gray-700" %>
|
||||
@@ -52,10 +35,25 @@
|
||||
<% end %>
|
||||
</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.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"}' %>
|
||||
<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>
|
||||
<%= 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"
|
||||
} %>
|
||||
<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 class="flex gap-3">
|
||||
|
||||
@@ -25,27 +25,29 @@
|
||||
|
||||
<body>
|
||||
<% if authenticated? %>
|
||||
<%= render "shared/sidebar" %>
|
||||
<div class="lg:pl-64" data-controller="mobile-sidebar">
|
||||
<!-- Mobile menu button -->
|
||||
<div class="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-gray-200 bg-white px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:hidden">
|
||||
<button type="button"
|
||||
class="-m-2.5 p-2.5 text-gray-700"
|
||||
id="mobile-menu-button"
|
||||
data-action="click->mobile-sidebar#openSidebar">
|
||||
<span class="sr-only">Open sidebar</span>
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<main class="py-10">
|
||||
<div class="px-4 sm:px-6 lg:px-8">
|
||||
<%= render "shared/flash" %>
|
||||
<%= yield %>
|
||||
<div data-controller="mobile-sidebar">
|
||||
<%= render "shared/sidebar" %>
|
||||
<div class="lg:pl-64">
|
||||
<!-- Mobile menu button -->
|
||||
<div class="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-gray-200 bg-white px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:hidden">
|
||||
<button type="button"
|
||||
class="-m-2.5 p-2.5 text-gray-700"
|
||||
id="mobile-menu-button"
|
||||
data-action="click->mobile-sidebar#openSidebar">
|
||||
<span class="sr-only">Open sidebar</span>
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<main class="py-10">
|
||||
<div class="px-4 sm:px-6 lg:px-8">
|
||||
<%= render "shared/flash" %>
|
||||
<%= yield %>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<!-- Public layout (signup/signin) -->
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
<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">
|
||||
<%= 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>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<h1 class="font-bold text-4xl">Sign in to Clinch</h1>
|
||||
</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? %>
|
||||
<div class="my-5">
|
||||
<%= form.label :email_address, "Email Address", class: "block font-medium text-sm text-gray-700" %>
|
||||
|
||||
@@ -1,29 +1,86 @@
|
||||
<% if flash[:alert] %>
|
||||
<div class="mb-4 rounded-lg bg-red-50 p-4" role="alert">
|
||||
<%# Enhanced Flash Messages with Support for Multiple Types and Auto-Dismiss %>
|
||||
<% 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-shrink-0">
|
||||
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<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"/>
|
||||
<div class="shrink-0">
|
||||
<svg class="h-5 w-5 <%= icon_class %>" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="<%= icon_path %>" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm font-medium text-red-800"><%= flash[:alert] %></p>
|
||||
<div class="ml-3 flex-1">
|
||||
<p class="text-sm font-medium <%= text_class %>"><%= message %></p>
|
||||
</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>
|
||||
<% end %>
|
||||
|
||||
<% if flash[:notice] %>
|
||||
<div class="mb-4 rounded-lg bg-green-50 p-4" role="alert">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<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"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm font-medium text-green-800"><%= flash[:notice] %></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<%# Helper method for border colors %>
|
||||
<%
|
||||
def border_class_for(type)
|
||||
case type.to_s
|
||||
when 'notice' then 'border-green-200'
|
||||
when 'alert', 'error' then 'border-red-200'
|
||||
when 'warning' then 'border-yellow-200'
|
||||
when 'info' then 'border-blue-200'
|
||||
else 'border-gray-200'
|
||||
end
|
||||
end
|
||||
%>
|
||||
|
||||
@@ -1,23 +1,36 @@
|
||||
<% if form.object.errors.any? %>
|
||||
<div class="rounded-md bg-red-50 p-4">
|
||||
<%# Usage: <%= render "shared/form_errors", object: @user %> %>
|
||||
<%# 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-shrink-0">
|
||||
<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>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-800">
|
||||
There were <%= pluralize(form.object.errors.count, "error") %> with your submission:
|
||||
<div class="ml-3 flex-1">
|
||||
<h3 id="form-errors-title" class="text-sm font-medium text-red-800">
|
||||
<%= pluralize(form_object.errors.count, "error") %> prohibited this <%= form_object.class.name.downcase.gsub(/^admin::/, '') %> from being saved:
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-red-700">
|
||||
<ul class="list-disc space-y-1 pl-5">
|
||||
<% form.object.errors.full_messages.each do |message| %>
|
||||
<div class="mt-2">
|
||||
<ul class="list-disc space-y-1 pl-5 text-sm text-red-700">
|
||||
<% form_object.errors.full_messages.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</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>
|
||||
<% end %>
|
||||
@@ -90,7 +90,7 @@
|
||||
|
||||
<!-- Sign Out -->
|
||||
<li>
|
||||
<%= link_to signout_path, data: { turbo_method: :delete }, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-red-600 hover:text-red-700 hover:bg-red-50" do %>
|
||||
<%= link_to signout_path, data: { turbo_method: :delete, action: "click->mobile-sidebar#closeSidebar" }, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-red-600 hover:text-red-700 hover:bg-red-50" do %>
|
||||
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
|
||||
</svg>
|
||||
@@ -105,7 +105,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Mobile sidebar overlay -->
|
||||
<div class="relative z-50 lg:hidden hidden" data-mobile-sidebar-target="sidebarOverlay" id="mobile-sidebar-overlay">
|
||||
<div class="relative z-50 lg:hidden hidden"
|
||||
data-mobile-sidebar-target="sidebarOverlay"
|
||||
id="mobile-sidebar-overlay"
|
||||
data-action="click->mobile-sidebar#closeOnBackgroundClick">
|
||||
<div class="fixed inset-0 bg-gray-900/80"></div>
|
||||
<div class="fixed inset-0 flex">
|
||||
<div class="relative mr-16 flex w-full max-w-xs flex-1">
|
||||
@@ -141,7 +144,7 @@
|
||||
<!-- Same nav items as desktop -->
|
||||
<ul role="list" class="-mx-2 space-y-1">
|
||||
<li>
|
||||
<%= link_to root_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %>
|
||||
<%= link_to root_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/' ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
|
||||
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
|
||||
</svg>
|
||||
@@ -150,7 +153,7 @@
|
||||
</li>
|
||||
<% if user.admin? %>
|
||||
<li>
|
||||
<%= link_to admin_users_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %>
|
||||
<%= link_to admin_users_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/users') ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
|
||||
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
||||
</svg>
|
||||
@@ -158,7 +161,7 @@
|
||||
<% end %>
|
||||
</li>
|
||||
<li>
|
||||
<%= link_to admin_applications_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %>
|
||||
<%= link_to admin_applications_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/applications') ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
|
||||
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
|
||||
</svg>
|
||||
@@ -166,7 +169,7 @@
|
||||
<% end %>
|
||||
</li>
|
||||
<li>
|
||||
<%= link_to admin_groups_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %>
|
||||
<%= link_to admin_groups_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/groups') ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
|
||||
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
|
||||
</svg>
|
||||
@@ -175,7 +178,7 @@
|
||||
</li>
|
||||
<% end %>
|
||||
<li>
|
||||
<%= link_to profile_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %>
|
||||
<%= link_to profile_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/profile' ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
|
||||
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
@@ -183,7 +186,7 @@
|
||||
<% end %>
|
||||
</li>
|
||||
<li>
|
||||
<%= link_to active_sessions_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %>
|
||||
<%= link_to active_sessions_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/active_sessions' ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
|
||||
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18" />
|
||||
</svg>
|
||||
@@ -191,7 +194,7 @@
|
||||
<% end %>
|
||||
</li>
|
||||
<li>
|
||||
<%= link_to signout_path, data: { turbo_method: :delete }, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-red-600 hover:text-red-700 hover:bg-red-50" do %>
|
||||
<%= link_to signout_path, data: { turbo_method: :delete, action: "click->mobile-sidebar#closeSidebar" }, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-red-600 hover:text-red-700 hover:bg-red-50" do %>
|
||||
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
|
||||
</svg>
|
||||
|
||||
@@ -4,17 +4,8 @@
|
||||
<p class="mt-2 text-gray-600">Create your admin account to get started</p>
|
||||
</div>
|
||||
|
||||
<%= form_with model: @user, url: signup_path, class: "contents" do |form| %>
|
||||
<% if @user.errors.any? %>
|
||||
<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 %>
|
||||
<%= form_with model: @user, url: signup_path, class: "contents", data: { controller: "form-errors" } do |form| %>
|
||||
<%= render "shared/form_errors", form: form %>
|
||||
|
||||
<div class="my-5">
|
||||
<%= form.label :email_address, class: "block font-medium text-sm text-gray-700" %>
|
||||
|
||||
@@ -83,4 +83,14 @@ Rails.application.configure do
|
||||
|
||||
# Apply autocorrection by RuboCop to files generated by `bin/rails generate`.
|
||||
# config.generators.apply_rubocop_autocorrect_after_generate!
|
||||
|
||||
# Sentry configuration for development
|
||||
# Only enabled if SENTRY_DSN environment variable is set and explicitly enabled
|
||||
if ENV["SENTRY_DSN"].present? && ENV["SENTRY_ENABLED_IN_DEVELOPMENT"] == "true"
|
||||
config.sentry.enabled = true
|
||||
|
||||
# High sample rates for development debugging
|
||||
config.sentry.traces_sample_rate = ENV.fetch("SENTRY_TRACES_SAMPLE_RATE", 0.5).to_f
|
||||
config.sentry.profiles_sample_rate = ENV.fetch("SENTRY_PROFILES_SAMPLE_RATE", 0.2).to_f
|
||||
end
|
||||
end
|
||||
|
||||
@@ -133,4 +133,18 @@ Rails.application.configure do
|
||||
|
||||
# Skip DNS rebinding protection for the default health check endpoint.
|
||||
config.host_authorization = { exclude: ->(request) { request.path == "/up" } }
|
||||
|
||||
# Sentry configuration for production
|
||||
# Only enabled if SENTRY_DSN environment variable is set
|
||||
if ENV["SENTRY_DSN"].present?
|
||||
config.sentry.enabled = true
|
||||
|
||||
# Performance monitoring: sample 20% of transactions for traces
|
||||
# Adjust based on your traffic volume and Sentry plan limits
|
||||
config.sentry.traces_sample_rate = ENV.fetch("SENTRY_TRACES_SAMPLE_RATE", 0.2).to_f
|
||||
|
||||
# Continuous profiling: disabled by default in production due to cost
|
||||
# Enable temporarily for performance investigations if needed
|
||||
config.sentry.profiles_sample_rate = ENV.fetch("SENTRY_PROFILES_SAMPLE_RATE", 0.0).to_f
|
||||
end
|
||||
end
|
||||
|
||||
@@ -50,4 +50,8 @@ Rails.application.configure do
|
||||
|
||||
# Raise error when a before_action's only/except options reference missing actions.
|
||||
config.action_controller.raise_on_missing_callback_actions = true
|
||||
|
||||
# Disable Sentry in test environment to avoid interference with tests
|
||||
# Sentry can be explicitly enabled for integration testing if needed
|
||||
config.sentry.enabled = false
|
||||
end
|
||||
|
||||
@@ -53,6 +53,7 @@ Rails.application.configure do
|
||||
# Additional security headers for WebAuthn
|
||||
# Required for WebAuthn to work properly
|
||||
policy.require_trusted_types_for :none
|
||||
policy.report_uri "/api/csp-violation-report"
|
||||
end
|
||||
|
||||
# Start with CSP in report-only mode for testing
|
||||
|
||||
122
config/initializers/csp_local_logger.rb
Normal file
122
config/initializers/csp_local_logger.rb
Normal file
@@ -0,0 +1,122 @@
|
||||
# Local file logger for CSP violations
|
||||
# Provides local logging even when Sentry is not configured
|
||||
|
||||
Rails.application.config.after_initialize do
|
||||
# Create a dedicated logger for CSP violations
|
||||
csp_log_path = Rails.root.join("log", "csp_violations.log")
|
||||
|
||||
# Configure log rotation
|
||||
csp_logger = Logger.new(
|
||||
csp_log_path,
|
||||
'daily', # Rotate daily
|
||||
30 # Keep 30 old log files
|
||||
)
|
||||
|
||||
csp_logger.level = Logger::INFO
|
||||
|
||||
# Format: [TIMESTAMP] LEVEL MESSAGE
|
||||
csp_logger.formatter = proc do |severity, datetime, progname, msg|
|
||||
"[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity} #{msg}\n"
|
||||
end
|
||||
|
||||
module CspViolationLocalLogger
|
||||
def self.emit(event)
|
||||
csp_data = event[:payload] || {}
|
||||
|
||||
# Build a structured log message
|
||||
violated_directive = csp_data[:violated_directive] || "unknown"
|
||||
blocked_uri = csp_data[:blocked_uri] || "unknown"
|
||||
document_uri = csp_data[:document_uri] || "unknown"
|
||||
|
||||
# Create a comprehensive log entry
|
||||
log_message = "CSP VIOLATION DETECTED\n"
|
||||
log_message += " Directive: #{violated_directive}\n"
|
||||
log_message += " Blocked URI: #{blocked_uri}\n"
|
||||
log_message += " Document URI: #{document_uri}\n"
|
||||
log_message += " User Agent: #{csp_data[:user_agent]}\n"
|
||||
log_message += " IP Address: #{csp_data[:ip_address]}\n"
|
||||
log_message += " Timestamp: #{csp_data[:timestamp]}\n"
|
||||
|
||||
if csp_data[:current_user_id].present?
|
||||
log_message += " Authenticated User ID: #{csp_data[:current_user_id]}\n"
|
||||
log_message += " Session ID: #{csp_data[:session_id]}\n"
|
||||
else
|
||||
log_message += " User: Anonymous\n"
|
||||
end
|
||||
|
||||
# Add additional details if available
|
||||
if csp_data[:source_file].present?
|
||||
log_message += " Source File: #{csp_data[:source_file]}"
|
||||
log_message += ":#{csp_data[:line_number]}" if csp_data[:line_number].present?
|
||||
log_message += ":#{csp_data[:column_number]}" if csp_data[:column_number].present?
|
||||
log_message += "\n"
|
||||
end
|
||||
|
||||
if csp_data[:referrer].present?
|
||||
log_message += " Referrer: #{csp_data[:referrer]}\n"
|
||||
end
|
||||
|
||||
# Determine severity for log level
|
||||
level = determine_log_level(csp_data[:violated_directive])
|
||||
|
||||
self.csp_logger.log(level, log_message)
|
||||
|
||||
# Also log to main Rails logger for visibility
|
||||
Rails.logger.info "CSP violation logged to csp_violations.log: #{violated_directive} - #{blocked_uri}"
|
||||
|
||||
rescue => e
|
||||
# Ensure logger errors don't break the CSP reporting flow
|
||||
Rails.logger.error "Failed to log CSP violation to file: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n") if Rails.env.development?
|
||||
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
|
||||
|
||||
def self.determine_log_level(violated_directive)
|
||||
return Logger::INFO unless violated_directive.present?
|
||||
|
||||
case violated_directive.to_sym
|
||||
when :script_src, :script_src_elem, :script_src_attr, :frame_src, :child_src
|
||||
Logger::WARN # Higher priority violations
|
||||
when :connect_src, :default_src, :style_src, :style_src_elem, :style_src_attr
|
||||
Logger::INFO # Medium priority violations
|
||||
else
|
||||
Logger::DEBUG # Lower priority violations
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Register the local logger subscriber
|
||||
Rails.event.subscribe(CspViolationLocalLogger)
|
||||
|
||||
Rails.logger.info "CSP violation local logger registered - logging to: #{csp_log_path}"
|
||||
|
||||
# Ensure the log file is created and writable
|
||||
begin
|
||||
# Create log file if it doesn't exist
|
||||
FileUtils.touch(csp_log_path) unless File.exist?(csp_log_path)
|
||||
|
||||
# Test write to ensure permissions are correct
|
||||
csp_logger.info "CSP Logger initialized at #{Time.current}"
|
||||
|
||||
rescue => e
|
||||
Rails.logger.error "Failed to initialize CSP local logger: #{e.message}"
|
||||
Rails.logger.error "CSP violations will only be sent to Sentry (if configured)"
|
||||
end
|
||||
end
|
||||
140
config/initializers/sentry.rb
Normal file
140
config/initializers/sentry.rb
Normal file
@@ -0,0 +1,140 @@
|
||||
# Sentry configuration for error tracking and performance monitoring
|
||||
# Only initializes if SENTRY_DSN environment variable is set
|
||||
|
||||
return unless ENV["SENTRY_DSN"].present?
|
||||
|
||||
Rails.application.configure do
|
||||
config.sentry.dsn = ENV["SENTRY_DSN"]
|
||||
|
||||
# Set environment (defaults to Rails.env)
|
||||
config.sentry.environment = ENV["SENTRY_ENVIRONMENT"] || Rails.env
|
||||
|
||||
# Set release version from Git or environment variable
|
||||
config.sentry.release = ENV["SENTRY_RELEASE"] || `git rev-parse HEAD 2>/dev/null`.strip.presence || nil
|
||||
|
||||
# Sample rate for performance monitoring (0.0 to 1.0)
|
||||
config.sentry.traces_sample_rate = ENV.fetch("SENTRY_TRACES_SAMPLE_RATE", 0.1).to_f
|
||||
|
||||
# Enable profiling in development/staging, disable in production unless explicitly enabled
|
||||
config.sentry.profiles_sample_rate = if Rails.env.production?
|
||||
ENV.fetch("SENTRY_PROFILES_SAMPLE_RATE", 0.0).to_f
|
||||
else
|
||||
ENV.fetch("SENTRY_PROFILES_SAMPLE_RATE", 0.5).to_f
|
||||
end
|
||||
|
||||
# Include additional context
|
||||
config.sentry.before_send = lambda do |event, hint|
|
||||
# Filter out sensitive information
|
||||
if event.context[:extra]
|
||||
event.context[:extra].reject! { |key, value|
|
||||
key.to_s.match?(/password|secret|token|key/i) || value.to_s.match?(/password|secret/i)
|
||||
}
|
||||
end
|
||||
|
||||
# Filter sensitive parameters
|
||||
if event.context[:request]
|
||||
event.context[:request].reject! { |key, value|
|
||||
key.to_s.match?(/password|secret|token|key|authorization/i)
|
||||
}
|
||||
end
|
||||
|
||||
event
|
||||
end
|
||||
|
||||
# Include breadcrumbs for debugging
|
||||
config.sentry.breadcrumbs_logger = [:active_support_logger, :http_logger]
|
||||
|
||||
# Send session data for user context
|
||||
config.sentry.user_context = lambda do
|
||||
if Current.user.present?
|
||||
{
|
||||
id: Current.user.id,
|
||||
email: Current.user.email_address,
|
||||
admin: Current.user.admin?
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
# Ignore common non-critical exceptions
|
||||
config.sentry.excluded_exceptions += [
|
||||
"ActionController::RoutingError",
|
||||
"ActionController::InvalidAuthenticityToken",
|
||||
"ActionController::UnknownFormat",
|
||||
"ActionDispatch::Http::Parameters::ParseError",
|
||||
"Rack::QueryParser::InvalidParameterError",
|
||||
"Rack::Timeout::RequestTimeoutException",
|
||||
"ActiveRecord::RecordNotFound"
|
||||
]
|
||||
|
||||
# Add CSP-specific tags for security events
|
||||
config.sentry.tags = lambda do
|
||||
{
|
||||
# Add application context
|
||||
app_name: "clinch",
|
||||
app_environment: Rails.env,
|
||||
# Add CSP policy status
|
||||
csp_enabled: defined?(Rails.application.config.content_security_policy) &&
|
||||
Rails.application.config.content_security_policy.present?
|
||||
}
|
||||
end
|
||||
|
||||
# Enhance before_send to handle CSP events properly
|
||||
config.sentry.before_send = lambda do |event, hint|
|
||||
# Filter out sensitive information
|
||||
if event.context[:extra]
|
||||
event.context[:extra].reject! { |key, value|
|
||||
key.to_s.match?(/password|secret|token|key/i) || value.to_s.match?(/password|secret/i)
|
||||
}
|
||||
end
|
||||
|
||||
# Filter sensitive parameters
|
||||
if event.context[:request]
|
||||
event.context[:request].reject! { |key, value|
|
||||
key.to_s.match?(/password|secret|token|key|authorization/i)
|
||||
}
|
||||
end
|
||||
|
||||
# Special handling for CSP violations
|
||||
if event.tags&.dig(:csp_violation)
|
||||
# Ensure CSP violations have proper security context
|
||||
event.context[:server] = event.context[:server] || {}
|
||||
event.context[:server][:name] = "clinch-auth-service"
|
||||
event.context[:server][:environment] = Rails.env
|
||||
|
||||
# Add additional security context
|
||||
event.context[:extra] ||= {}
|
||||
event.context[:extra][:security_context] = {
|
||||
csp_reporting: true,
|
||||
user_authenticated: event.context[:user].present?,
|
||||
request_origin: event.context[:request]&.dig(:headers, "Origin"),
|
||||
request_referer: event.context[:request]&.dig(:headers, "Referer")
|
||||
}
|
||||
end
|
||||
|
||||
event
|
||||
end
|
||||
|
||||
# Add CSP-specific breadcrumbs for security events
|
||||
config.sentry.before_breadcrumb = lambda do |breadcrumb, hint|
|
||||
# Filter out sensitive breadcrumb data
|
||||
if breadcrumb[:data]
|
||||
breadcrumb[:data].reject! { |key, value|
|
||||
key.to_s.match?(/password|secret|token|key|authorization/i) ||
|
||||
value.to_s.match?(/password|secret/i)
|
||||
}
|
||||
end
|
||||
|
||||
# Mark CSP-related events
|
||||
if breadcrumb[:message]&.include?("CSP Violation") ||
|
||||
breadcrumb[:category]&.include?("csp")
|
||||
breadcrumb[:data] ||= {}
|
||||
breadcrumb[:data][:security_event] = true
|
||||
breadcrumb[:data][:csp_violation] = true
|
||||
end
|
||||
|
||||
breadcrumb
|
||||
end
|
||||
|
||||
# Only send errors in production unless explicitly enabled
|
||||
config.sentry.enabled = Rails.env.production? || ENV["SENTRY_ENABLED_IN_DEVELOPMENT"] == "true"
|
||||
end
|
||||
120
config/initializers/sentry_subscriber.rb
Normal file
120
config/initializers/sentry_subscriber.rb
Normal file
@@ -0,0 +1,120 @@
|
||||
# Sentry subscriber for CSP violations via Structured Event Reporting
|
||||
# This subscriber only sends events to Sentry if Sentry is properly initialized
|
||||
|
||||
Rails.application.config.after_initialize do
|
||||
# Only register the subscriber if Sentry is available and configured
|
||||
if defined?(Sentry) && Sentry.initialized?
|
||||
|
||||
module CspViolationSentrySubscriber
|
||||
def self.emit(event)
|
||||
# Extract relevant CSP violation data
|
||||
csp_data = event[:payload] || {}
|
||||
|
||||
# Build a descriptive message for Sentry
|
||||
violated_directive = csp_data[:violated_directive]
|
||||
blocked_uri = csp_data[:blocked_uri]
|
||||
document_uri = csp_data[:document_uri]
|
||||
|
||||
message = "CSP Violation: #{violated_directive}"
|
||||
message += " - Blocked: #{blocked_uri}" if blocked_uri.present?
|
||||
message += " - On: #{document_uri}" if document_uri.present?
|
||||
|
||||
# Extract domain from blocked_uri for better classification
|
||||
blocked_domain = extract_domain(blocked_uri) if blocked_uri.present?
|
||||
|
||||
# Determine severity based on violation type
|
||||
level = determine_severity(violated_directive, blocked_uri)
|
||||
|
||||
# Send to Sentry with rich context
|
||||
Sentry.capture_message(
|
||||
message,
|
||||
level: level,
|
||||
tags: {
|
||||
csp_violation: true,
|
||||
violated_directive: violated_directive,
|
||||
blocked_domain: blocked_domain,
|
||||
document_domain: extract_domain(document_uri),
|
||||
user_authenticated: csp_data[:current_user_id].present?
|
||||
},
|
||||
extra: {
|
||||
# Full CSP report data
|
||||
csp_violation_details: csp_data,
|
||||
# Additional context for security analysis
|
||||
request_context: {
|
||||
user_agent: csp_data[:user_agent],
|
||||
ip_address: csp_data[:ip_address],
|
||||
session_id: csp_data[:session_id],
|
||||
timestamp: csp_data[:timestamp]
|
||||
}
|
||||
},
|
||||
user: csp_data[:current_user_id] ? { id: csp_data[:current_user_id] } : nil
|
||||
)
|
||||
|
||||
# Log to Rails logger for redundancy
|
||||
Rails.logger.info "CSP violation sent to Sentry: #{message}"
|
||||
rescue => e
|
||||
# Ensure subscriber errors don't break the CSP reporting flow
|
||||
Rails.logger.error "Failed to send CSP violation to Sentry: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n") if Rails.env.development?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Extract domain from URI for better analysis
|
||||
def self.extract_domain(uri)
|
||||
return nil if uri.blank?
|
||||
|
||||
begin
|
||||
parsed = URI.parse(uri)
|
||||
parsed.host
|
||||
rescue URI::InvalidURIError
|
||||
# Handle cases where URI might be malformed or just a path
|
||||
if uri.start_with?('/')
|
||||
nil # It's a relative path, no domain
|
||||
else
|
||||
uri.split('/').first # Best effort extraction
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Determine severity level based on violation type
|
||||
def self.determine_severity(violated_directive, blocked_uri)
|
||||
return :warning unless violated_directive.present?
|
||||
|
||||
case violated_directive.to_sym
|
||||
when :script_src, :script_src_elem, :script_src_attr
|
||||
# Script violations are highest priority (XSS risk)
|
||||
:error
|
||||
when :style_src, :style_src_elem, :style_src_attr
|
||||
# Style violations are moderate risk
|
||||
:warning
|
||||
when :img_src
|
||||
# Image violations are typically lower priority
|
||||
:info
|
||||
when :connect_src
|
||||
# Network violations are important
|
||||
:warning
|
||||
when :font_src, :media_src
|
||||
# Font/media violations are lower priority
|
||||
:info
|
||||
when :frame_src, :child_src
|
||||
# Frame violations can be security critical
|
||||
:error
|
||||
when :default_src
|
||||
# Default src violations are important
|
||||
:warning
|
||||
else
|
||||
# Unknown or custom directives
|
||||
:warning
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Register the subscriber for CSP violation events
|
||||
Rails.event.subscribe(CspViolationSentrySubscriber)
|
||||
|
||||
Rails.logger.info "CSP violation Sentry subscriber registered"
|
||||
else
|
||||
Rails.logger.info "Sentry not initialized - CSP violations will only be logged locally"
|
||||
end
|
||||
end
|
||||
@@ -44,10 +44,7 @@ Then set it securely:
|
||||
# Generate key
|
||||
bin/generate_oidc_key > oidc_private_key.pem
|
||||
|
||||
# Option A: Using kamal env push (Kamal 2.0+)
|
||||
kamal env push OIDC_PRIVATE_KEY="$(cat oidc_private_key.pem)"
|
||||
|
||||
# Option B: Add to .kamal/secrets
|
||||
# Add to .kamal/secrets
|
||||
echo "OIDC_PRIVATE_KEY=$(cat oidc_private_key.pem)" >> .kamal/secrets
|
||||
```
|
||||
|
||||
@@ -60,57 +57,6 @@ bin/rails runner "puts OidcJwtService.send(:private_key).present? ? 'Key loaded'
|
||||
|
||||
---
|
||||
|
||||
## Option 2: Rails Credentials (Simpler but less flexible)
|
||||
|
||||
### 1. Generate the key
|
||||
|
||||
```bash
|
||||
openssl genrsa -out oidc_private_key.pem 2048
|
||||
```
|
||||
|
||||
### 2. Add to Rails credentials
|
||||
|
||||
```bash
|
||||
EDITOR="nano" bin/rails credentials:edit
|
||||
```
|
||||
|
||||
Add this section:
|
||||
|
||||
```yaml
|
||||
oidc_private_key: |
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIEpAIBAAKCAQEAyZ0qaICMiLVWSFs+ef9Xok3fzy0p6k/7D5TQzmxf7C2vQG7s
|
||||
2Odmi8iAHLoaUBaFj70qTbaconWyMr8s+ah+qZwrwolTLUe23VrceVXvInU57hBL
|
||||
...
|
||||
-----END RSA PRIVATE KEY-----
|
||||
```
|
||||
|
||||
**Important:** Use the `|` pipe character for multi-line, and indent the key content with 2 spaces.
|
||||
|
||||
### 3. Save and verify
|
||||
|
||||
```bash
|
||||
# Verify credentials file
|
||||
cat config/credentials.yml.enc # Should show encrypted data
|
||||
|
||||
# Test in console
|
||||
bin/rails runner "puts OidcJwtService.send(:private_key).present? ? 'Key loaded' : 'Key missing'"
|
||||
```
|
||||
|
||||
### 4. For deployment
|
||||
|
||||
The `config/credentials.yml.enc` file is committed to git. You need to:
|
||||
|
||||
1. **Set RAILS_MASTER_KEY** env variable in production
|
||||
2. Get the key from `config/master.key` (don't commit this!)
|
||||
|
||||
```bash
|
||||
# In Kamal
|
||||
kamal env push RAILS_MASTER_KEY="$(cat config/master.key)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Comparison
|
||||
|
||||
| Feature | ENV Variable | Rails Credentials |
|
||||
@@ -145,31 +91,7 @@ kamal env push RAILS_MASTER_KEY="$(cat config/master.key)"
|
||||
|
||||
## Key Rotation (Advanced)
|
||||
|
||||
If you need to rotate keys (security incident, etc.):
|
||||
|
||||
### 1. Generate new key
|
||||
|
||||
```bash
|
||||
openssl genrsa -out oidc_private_key_new.pem 2048
|
||||
```
|
||||
|
||||
### 2. Add NEW key alongside old (dual-key setup)
|
||||
|
||||
This requires code changes to support multiple keys in JWKS. For now, rotation means:
|
||||
|
||||
**Warning:** Rotating the key will **invalidate all existing OIDC sessions**. Users will need to log in again.
|
||||
|
||||
### 3. Update OIDC_PRIVATE_KEY
|
||||
|
||||
```bash
|
||||
kamal env push OIDC_PRIVATE_KEY="$(cat oidc_private_key_new.pem)"
|
||||
```
|
||||
|
||||
### 4. Restart application
|
||||
|
||||
```bash
|
||||
kamal deploy
|
||||
```
|
||||
Todo
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user