fix verify_url

This commit is contained in:
Chris
2025-10-07 14:31:25 +01:00
parent aee1f6f675
commit c06a0172d1
13 changed files with 241 additions and 156 deletions

View File

@@ -4,23 +4,36 @@ module Accounts
before_action :set_stack, only: [ :sync_clusters, :sync_registries ]
skip_before_action :authenticate_user!, only: [ :verify_url ]
def authenticated
stack_manager = current_account&.stack_manager
if stack_manager.nil? || current_user&.portainer_jwt.nil?
head :unauthorized
return
def verify_login
stack_manager = current_account.stack_manager
if stack_manager.nil?
head :not_found
end
portainer_client = Portainer::Client.new(stack_manager.provider_url, current_user.portainer_jwt)
if portainer_client.authenticated?
if stack_manager.stack.client.authenticated?
head :ok
else
head :unauthorized
end
end
def verify_url
url = params[:url]
begin
response = HTTParty.get(url, timeout: 5, verify: false)
if response.success?
head :ok
else
head :bad_gateway
end
rescue Net::ReadTimeout, SocketError, Errno::ECONNREFUSED
head :bad_gateway
rescue StandardError
head :internal_server_error
end
end
def index
redirect_to stack_manager_path
end
@@ -90,25 +103,6 @@ module Accounts
redirect_to providers_path, notice: "Registries synced successfully"
end
def verify_url
url = params[:url]
begin
response = HTTParty.get(url, timeout: 5, verify: false)
if response.success?
render json: { success: true }
else
render json: { success: false, message: "Server returned status #{response.code}" }
end
rescue Net::ReadTimeout
render json: { success: false, message: "Connection timeout - server took too long to respond" }
rescue SocketError, Errno::ECONNREFUSED
render json: { success: false, message: "Unable to connect - please check the URL" }
rescue StandardError => e
render json: { success: false, message: "Error: #{e.message}" }
end
end
private

View File

@@ -19,9 +19,17 @@ class Users::SessionsController < Devise::SessionsController
super do
# If the account has a stack manager that provides authentication,
# redirect to the custom account login URL after logout
if account&.stack_manager&.stack&.provides_authentication?
return redirect_to account_sign_in_path(account.slug), notice: "Signed out successfully."
redirect_url = if account&.stack_manager&.stack&.provides_authentication?
account_sign_in_path(account.slug)
else
root_path
end
respond_to do |format|
format.html { redirect_to redirect_url, notice: "Signed out successfully." }
format.json { render json: { redirect_url: redirect_url }, status: :ok }
end
return
end
end

View File

@@ -0,0 +1,57 @@
import { Controller } from "@hotwired/stimulus"
import { PortainerChecker } from "../../utils/portainer"
const AUTHENTICATION_VERIFICATION_METHOD = "authentication";
const URL_VERIFICATION_METHOD = "url";
export default class extends Controller {
static targets = [ "message", "verifyUrlSuccess", "verifyUrlError", "verifyUrlLoading" ]
static values = {
verificationMethod: String,
verifyUrl: String,
}
async connect() {
this.verifyUrlLoadingTarget.classList.remove('hidden')
const portainerChecker = new PortainerChecker();
let result = null;
if (this.verificationMethodValue === AUTHENTICATION_VERIFICATION_METHOD) {
result = await portainerChecker.verifyPortainerAuthentication();
} else if (this.verificationMethodValue === URL_VERIFICATION_METHOD) {
const url = this.verifyUrlValue;
result = await portainerChecker.verifyPortainerUrl(url);
}
if (result === PortainerChecker.STATUS_UNAUTHORIZED) {
this.logout();
} else if (result === PortainerChecker.STATUS_OK) {
this.verifyUrlSuccessTarget.classList.remove('hidden')
} else {
this.verifyUrlErrorTarget.classList.remove('hidden')
}
this.verifyUrlLoadingTarget.classList.add('hidden')
}
async logout() {
try {
const response = await fetch('/users/sign_out', {
method: 'DELETE',
headers: {
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json'
},
credentials: 'same-origin'
})
if (response.ok) {
const data = await response.json()
window.location.href = data.redirect_url
} else {
window.location.href = '/users/sign_in'
}
} catch (error) {
window.location.href = '/users/sign_in'
}
}
}

View File

@@ -0,0 +1,65 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = [
"urlInput",
"verifyUrlSuccess",
"verifyUrlError",
"verifyUrlLoading",
]
async verifyUrl(event) {
const url = this.urlInputTarget.value.trim()
this.hideAllStatuses()
this.showLoading()
try {
const response = await fetch('/stack_manager/verify_url', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({ url: url })
})
if (response.status === 401) {
this.showError('Unauthorized')
return
}
if (response.ok) {
this.showSuccess()
} else {
this.showError('Unable to connect')
}
} catch (error) {
this.showError('Network error - please check the URL')
}
}
showLoading() {
this.hideAllStatuses()
this.verifyUrlLoadingTarget.classList.remove('hidden')
}
showSuccess() {
this.hideAllStatuses()
this.verifyUrlSuccessTarget.classList.remove('hidden')
}
showError(message) {
this.hideAllStatuses()
if (this.hasErrorMessageTarget && message) {
this.errorMessageTarget.textContent = message
}
this.verifyUrlErrorTarget.classList.remove('hidden')
}
hideAllStatuses() {
this.verifyUrlSuccessTarget.classList.add('hidden')
this.verifyUrlErrorTarget.classList.add('hidden')
this.verifyUrlLoadingTarget.classList.add('hidden')
}
}

View File

@@ -1,100 +0,0 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["urlInput", "verifyUrlSuccess", "verifyUrlError", "verifyUrlLoading", "errorMessage"]
static values = {
verifyUrl: String,
logoutOnFailure: Boolean
}
connect() {
if (this.urlInputTarget.value) {
this.verifyUrl()
}
}
async verifyUrl(event) {
const url = this.urlInputTarget.value.trim()
if (!url) {
this.hideAllStatuses()
return
}
this.showLoading()
try {
const response = await fetch(this.verifyUrlValue, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({ url: url })
})
const data = await response.json()
if (data.success) {
this.showSuccess()
} else {
this.showError(data.message || 'Unable to connect to Portainer')
if (this.logoutOnFailureValue) {
await this.logout()
}
}
} catch (error) {
this.showError('Network error - please check the URL')
if (this.logoutOnFailureValue) {
await this.logout()
}
}
}
showLoading() {
this.hideAllStatuses()
this.verifyUrlLoadingTarget.classList.remove('hidden')
}
showSuccess() {
this.hideAllStatuses()
this.verifyUrlSuccessTarget.classList.remove('hidden')
}
showError(message) {
this.hideAllStatuses()
if (this.hasErrorMessageTarget && message) {
this.errorMessageTarget.textContent = message
}
this.verifyUrlErrorTarget.classList.remove('hidden')
}
hideAllStatuses() {
this.verifyUrlSuccessTarget.classList.add('hidden')
this.verifyUrlErrorTarget.classList.add('hidden')
this.verifyUrlLoadingTarget.classList.add('hidden')
}
async logout() {
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content
const response = await fetch('/users/sign_out', {
method: 'DELETE',
headers: {
'X-CSRF-Token': csrfToken,
'Accept': 'text/vnd.turbo-stream.html, text/html, application/xhtml+xml',
'X-Requested-With': 'XMLHttpRequest'
},
credentials: 'same-origin'
})
if (response.ok) {
window.location.href = '/users/sign_in'
}
} catch (error) {
console.error('Logout failed:', error)
window.location.href = '/users/sign_in'
}
}
}

View File

@@ -0,0 +1,50 @@
export class PortainerChecker {
static STATUS_OK = "ok";
static STATUS_UNAUTHORIZED = "unauthorized";
static STATUS_ERROR = "error";
csrfToken() {
return document.querySelector('meta[name="csrf-token"]').content
}
async verifyPortainerAuthentication() {
const response = await fetch('/stack_manager/verify_login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': this.csrfToken()
}
})
return this.toResult(response);
}
toResult(response) {
if (response.status === 401) {
return PortainerChecker.STATUS_UNAUTHORIZED;
}
if (response.status === 502) {
return PortainerChecker.STATUS_ERROR;
}
if (response.ok) {
return PortainerChecker.STATUS_OK;
}
return PortainerChecker.STATUS_ERROR;
}
async verifyPortainerUrl(url) {
const response = await fetch('/stack_manager/verify_url', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': this.csrfToken()
},
body: JSON.stringify({ url: url })
})
return this.toResult(response);
}
}

View File

@@ -28,7 +28,7 @@
<div class="font-medium"><%= stack_manager.stack_manager_type.humanize %></div>
</div>
<div data-controller="stack-manager-url" data-stack-manager-url-verify-url-value="<%= verify_url_stack_manager_path %>">
<div data-controller="stack-manager--url-input">
<div class="text-sm text-base-content/70">Provider URL</div>
<div class="flex items-center gap-2">
<div class="font-medium">
@@ -40,10 +40,10 @@
:provider_url,
stack_manager.provider_url,
class: "hidden input input-bordered w-full",
data: { stack_manager_url_target: "urlInput" }
data: { "stack-manager--url-input-target": "urlInput" }
) %>
</div>
<button class="btn btn-ghost btn-sm" data-action="stack-manager-url#verifyUrl">Test Connection <iconify-icon icon="lucide:refresh-cw"></iconify-icon></button>
<button class="btn btn-ghost btn-sm" data-action="stack-manager--url-input#verifyUrl">Test Connection <iconify-icon icon="lucide:refresh-cw"></iconify-icon></button>
<%= render "accounts/stack_managers/url_connection_statuses" %>
</div>
</div>

View File

@@ -1,11 +1,11 @@
<div data-controller="stack-manager-url" data-stack-manager-url-verify-url-value="<%= verify_url_stack_manager_path %>">
<div data-controller="stack-manager--url-input">
<%= form.label :provider_url, "Portainer URL" %>
<%= form.text_field(
:provider_url,
name: "stack_manager[provider_url]",
class: "input input-bordered w-full",
placeholder: "http://portainer.portainer.svc.cluster.local:9000",
data: { stack_manager_url_target: "urlInput", action: "blur->stack-manager-url#verifyUrl" },
data: { "stack-manager--url-input-target": "urlInput", action: "blur->stack-manager--url-input#verifyUrl" },
) %>
<div class="label justify-end">
<span class="label-text-alt">

View File

@@ -1,12 +1,12 @@
<div class="text-success flex items-center gap-x-0.5 hidden" data-stack-manager-url-target="verifyUrlSuccess">
<div class="text-success flex items-center gap-x-0.5 hidden" data-stack-manager--url-input-target="verifyUrlSuccess">
Connection successful
<iconify-icon icon="lucide:circle-check"></iconify-icon>
</div>
<div class="text-error flex items-center gap-x-0.5 hidden" data-stack-manager-url-target="verifyUrlError">
<span data-stack-manager-url-target="errorMessage">Connection failed</span>
<div class="text-error flex items-center gap-x-0.5 hidden" data-stack-manager--url-input-target="verifyUrlError">
<span data-stack-manager--url-input-target="errorMessage">Connection failed</span>
<iconify-icon icon="lucide:circle-x"></iconify-icon>
</div>
<div class="text-gray-500 flex items-center gap-x-0.5 hidden" data-stack-manager-url-target="verifyUrlLoading">
<div class="text-gray-500 flex items-center gap-x-0.5 hidden" data-stack-manager--url-input-target="verifyUrlLoading">
Loading
<iconify-icon class="animate-spin" icon="lucide:loader-circle"></iconify-icon>
</div>

View File

@@ -1,24 +1,38 @@
<%
verify_url ||= verify_url_stack_manager_path
logout_on_failure ||= false
verification_method ||= "url"
logout_on_unauthorized ||= false
%>
<div class="border-t border-base-300/50 transition-all duration-300 group-hover:border-base-300"
data-controller="stack-manager-url"
data-stack-manager-url-verify-url-value="<%= verify_url %>"
data-stack-manager-url-logout-on-failure-value="<%= logout_on_failure %>">
<input type="hidden" data-stack-manager-url-target="urlInput" value="<%= stack_manager.provider_url %>" />
<div
class="border-t border-base-300/50 transition-all duration-300 group-hover:border-base-300"
data-controller="stack-manager--badge"
data-stack-manager--badge-verification-method-value="<%= verification_method %>"
data-stack-manager--badge-verify-url-value="<%= stack_manager.provider_url %>"
>
<div class="flex items-center justify-between">
<%= link_to stack_manager.provider_url, target: "_blank", rel: "noopener", class: "group/badge inline-flex" do %>
<div class="flex items-center gap-1.5 text-xs px-2 py-1 rounded-md bg-blue-500/10 text-blue-400 border border-blue-500/20 transition-all duration-300 hover:bg-blue-500/25 hover:border-blue-500/40 hover:text-blue-300 cursor-pointer">
<iconify-icon icon="lucide:server" width="14" height="14"></iconify-icon>
<span class="font-medium">Managed via Portainer</span>
<div class="tooltip tooltip-bottom flex hidden" data-tip="Portainer is reachable" data-stack-manager-url-target="verifyUrlSuccess">
<div
class="tooltip tooltip-bottom flex hidden"
data-tip="Portainer is reachable"
data-stack-manager--badge-target="verifyUrlSuccess"
>
<iconify-icon icon="lucide:external-link" width="12" height="12" class="opacity-80 group-hover/badge:opacity-100 transition-opacity duration-200"></iconify-icon>
</div>
<div class="tooltip tooltip-bottom flex hidden" data-tip="Portainer URL cannot be reached" data-stack-manager-url-target="verifyUrlError">
<div
class="tooltip tooltip-bottom flex hidden"
data-tip="Portainer URL cannot be reached"
data-stack-manager--badge-target="verifyUrlError"
>
<iconify-icon icon="lucide:alert-circle" width="12" height="12" class="text-red-400 opacity-80 group-hover/badge:opacity-100 transition-opacity duration-200"></iconify-icon>
</div>
<iconify-icon icon="lucide:loader-2" width="12" height="12" class="opacity-80 animate-spin hidden" data-stack-manager-url-target="verifyUrlLoading"></iconify-icon>
<iconify-icon
icon="lucide:loader-2"
width="12"
height="12"
class="opacity-80 animate-spin"
data-stack-manager--badge-target="verifyUrlLoading"></iconify-icon>
</div>
<% end %>
</div>

View File

@@ -9,8 +9,8 @@
<div class="flex items-center justify-center">
<%= render "devise/sessions/portainer_badge",
stack_manager: current_account.stack_manager,
verify_url: authenticated_stack_manager_path,
logout_on_failure: true %>
verification_method: "authentication",
logout_on_unauthorized: true %>
</div>
<% end %>
<div class="flex items-center justify-center">

View File

@@ -1,10 +1,7 @@
<div class="flex flex-row gap-x-4 items-stretch">
<div class="w-64 space-y-4">
<% if current_account.stack_manager.present? && current_account.stack_manager.stack.provides_authentication? %>
<div class="rounded bg-base-200 p-4">
<div class="mb-4">
<%= render "devise/sessions/portainer_badge", stack_manager: current_account.stack_manager %>
</div>
<div class="rounded bg-base-200 pl-4 pr-2 py-4">
<h3 class="text-sm font-semibold mb-3">Account Login URL</h3>
<div class="flex items-center gap-2 mb-2">
<input type="text"

View File

@@ -28,7 +28,7 @@ Rails.application.routes.draw do
resource :stack_manager, only: %i[show new create edit update destroy], controller: 'accounts/stack_managers' do
collection do
post :verify_url
get :authenticated
post :verify_login
post :sync_clusters
post :sync_registries
end