Merge pull request #538 from luckyPipewrench/sso-upgrades

Multi-provider SSO with admin UI and SAML support
This commit is contained in:
soky srm
2026-01-12 15:38:59 +01:00
committed by GitHub
50 changed files with 3273 additions and 34 deletions

View File

@@ -0,0 +1,226 @@
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="admin-sso-form"
export default class extends Controller {
static targets = ["callbackUrl", "testResult", "samlCallbackUrl"]
connect() {
// Initialize field visibility on page load
this.toggleFields()
// Initialize callback URL
this.updateCallbackUrl()
}
updateCallbackUrl() {
const nameInput = this.element.querySelector('input[name*="[name]"]')
const callbackDisplay = this.callbackUrlTarget
if (!nameInput || !callbackDisplay) return
const providerName = nameInput.value.trim() || 'PROVIDER_NAME'
const baseUrl = window.location.origin
callbackDisplay.textContent = `${baseUrl}/auth/${providerName}/callback`
}
toggleFields() {
const strategySelect = this.element.querySelector('select[name*="[strategy]"]')
if (!strategySelect) return
const strategy = strategySelect.value
const isOidc = strategy === "openid_connect"
const isSaml = strategy === "saml"
// Toggle OIDC fields
const oidcFields = this.element.querySelectorAll('[data-oidc-field]')
oidcFields.forEach(field => {
if (isOidc) {
field.classList.remove('hidden')
} else {
field.classList.add('hidden')
}
})
// Toggle SAML fields
const samlFields = this.element.querySelectorAll('[data-saml-field]')
samlFields.forEach(field => {
if (isSaml) {
field.classList.remove('hidden')
} else {
field.classList.add('hidden')
}
})
// Update SAML callback URL if present
if (this.hasSamlCallbackUrlTarget) {
this.updateSamlCallbackUrl()
}
}
updateSamlCallbackUrl() {
const nameInput = this.element.querySelector('input[name*="[name]"]')
if (!nameInput || !this.hasSamlCallbackUrlTarget) return
const providerName = nameInput.value.trim() || 'PROVIDER_NAME'
const baseUrl = window.location.origin
this.samlCallbackUrlTarget.textContent = `${baseUrl}/auth/${providerName}/callback`
}
copySamlCallback(event) {
event.preventDefault()
if (!this.hasSamlCallbackUrlTarget) return
const callbackUrl = this.samlCallbackUrlTarget.textContent
navigator.clipboard.writeText(callbackUrl).then(() => {
const button = event.currentTarget
const originalText = button.innerHTML
button.innerHTML = '<svg class="w-4 h-4 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg> Copied!'
button.classList.add('text-green-600')
setTimeout(() => {
button.innerHTML = originalText
button.classList.remove('text-green-600')
}, 2000)
}).catch(err => {
console.error('Failed to copy:', err)
alert('Failed to copy to clipboard')
})
}
async validateIssuer(event) {
const issuerInput = event.target
const issuer = issuerInput.value.trim()
if (!issuer) return
try {
// Construct discovery URL
const discoveryUrl = issuer.endsWith('/')
? `${issuer}.well-known/openid-configuration`
: `${issuer}/.well-known/openid-configuration`
// Show loading state
issuerInput.classList.add('border-yellow-300')
const response = await fetch(discoveryUrl, {
method: 'GET',
headers: { 'Accept': 'application/json' }
})
if (response.ok) {
const data = await response.json()
if (data.issuer) {
// Valid OIDC discovery endpoint
issuerInput.classList.remove('border-yellow-300', 'border-red-300')
issuerInput.classList.add('border-green-300')
this.showValidationMessage(issuerInput, 'Valid OIDC issuer', 'success')
} else {
throw new Error('Invalid discovery response')
}
} else {
throw new Error(`Discovery endpoint returned ${response.status}`)
}
} catch (error) {
// CORS errors are expected when validating from browser - show as warning not error
issuerInput.classList.remove('border-yellow-300', 'border-green-300')
issuerInput.classList.add('border-amber-300')
this.showValidationMessage(issuerInput, "Could not validate from browser (CORS). Provider can still be saved.", 'warning')
}
}
copyCallback(event) {
event.preventDefault()
const callbackDisplay = this.callbackUrlTarget
if (!callbackDisplay) return
const callbackUrl = callbackDisplay.textContent
// Copy to clipboard
navigator.clipboard.writeText(callbackUrl).then(() => {
// Show success feedback
const button = event.currentTarget
const originalText = button.innerHTML
button.innerHTML = '<svg class="w-4 h-4 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg> Copied!'
button.classList.add('text-green-600')
setTimeout(() => {
button.innerHTML = originalText
button.classList.remove('text-green-600')
}, 2000)
}).catch(err => {
console.error('Failed to copy:', err)
alert('Failed to copy to clipboard')
})
}
showValidationMessage(input, message, type) {
// Remove any existing validation message
const existingMessage = input.parentElement.querySelector('.validation-message')
if (existingMessage) {
existingMessage.remove()
}
// Create new validation message
const messageEl = document.createElement('p')
const colorClass = type === 'success' ? 'text-green-600' : type === 'warning' ? 'text-amber-600' : 'text-red-600'
messageEl.className = `validation-message mt-1 text-sm ${colorClass}`
messageEl.textContent = message
input.parentElement.appendChild(messageEl)
// Auto-remove after 5 seconds (except warnings which stay)
if (type !== 'warning') {
setTimeout(() => {
messageEl.remove()
input.classList.remove('border-green-300', 'border-red-300', 'border-amber-300')
}, 5000)
}
}
async testConnection(event) {
const button = event.currentTarget
const testUrl = button.dataset.adminSsoFormTestUrlValue
const resultEl = this.testResultTarget
if (!testUrl) return
// Show loading state
button.disabled = true
button.textContent = 'Testing...'
resultEl.textContent = ''
resultEl.className = 'ml-2 text-sm'
try {
const response = await fetch(testUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content
}
})
const data = await response.json()
if (data.success) {
resultEl.textContent = `${data.message}`
resultEl.classList.add('text-green-600')
} else {
resultEl.textContent = `${data.message}`
resultEl.classList.add('text-red-600')
}
// Show details in console for debugging
if (data.details && Object.keys(data.details).length > 0) {
console.log('SSO Test Connection Details:', data.details)
}
} catch (error) {
resultEl.textContent = `✗ Request failed: ${error.message}`
resultEl.classList.add('text-red-600')
} finally {
button.disabled = false
button.textContent = 'Test Connection'
}
}
}