Compare commits
2
Commits
20fb2d29e2
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06f99b0c17 | ||
|
|
f2757c9b1c |
@@ -40,6 +40,8 @@ class Inspector extends Component
|
|||||||
|
|
||||||
public bool $responseSaved = false;
|
public bool $responseSaved = false;
|
||||||
|
|
||||||
|
public bool $showEndpointSettings = false;
|
||||||
|
|
||||||
public function mount(WebhookEndpoint $endpoint): void
|
public function mount(WebhookEndpoint $endpoint): void
|
||||||
{
|
{
|
||||||
Gate::authorize('view', $endpoint);
|
Gate::authorize('view', $endpoint);
|
||||||
@@ -73,6 +75,16 @@ class Inspector extends Component
|
|||||||
{
|
{
|
||||||
if (($event['endpointId'] ?? null) === $this->endpointId) {
|
if (($event['endpointId'] ?? null) === $this->endpointId) {
|
||||||
$this->resetPage();
|
$this->resetPage();
|
||||||
|
|
||||||
|
$requestId = $event['requestId'] ?? null;
|
||||||
|
|
||||||
|
if (is_string($requestId) && $requestId !== '') {
|
||||||
|
$this->dispatch(
|
||||||
|
'xhr-request-available',
|
||||||
|
endpointId: $this->endpointId,
|
||||||
|
requestId: $requestId,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +110,31 @@ class Inspector extends Component
|
|||||||
$this->selectedRequestId = null;
|
$this->selectedRequestId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* endpointId: string,
|
||||||
|
* requestId: string,
|
||||||
|
* method: string,
|
||||||
|
* requestUri: string,
|
||||||
|
* headers: array<string, array<int, string>|string>,
|
||||||
|
* body: string,
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function xhrRequestPayload(string $requestId): array
|
||||||
|
{
|
||||||
|
$webhookRequest = $this->endpoint()->requests()->findOrFail($requestId);
|
||||||
|
Gate::authorize('view', $webhookRequest);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'endpointId' => (string) $this->endpointId,
|
||||||
|
'requestId' => (string) $webhookRequest->getKey(),
|
||||||
|
'method' => $webhookRequest->method,
|
||||||
|
'requestUri' => $webhookRequest->request_uri,
|
||||||
|
'headers' => $webhookRequest->headers ?? [],
|
||||||
|
'body' => base64_encode((string) ($webhookRequest->body ?? '')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function deleteRequest(string $requestId, DeleteWebhookRequestAction $deleteWebhookRequest): void
|
public function deleteRequest(string $requestId, DeleteWebhookRequestAction $deleteWebhookRequest): void
|
||||||
{
|
{
|
||||||
$webhookRequest = $this->endpoint()->requests()->findOrFail($requestId);
|
$webhookRequest = $this->endpoint()->requests()->findOrFail($requestId);
|
||||||
|
|||||||
@@ -7,3 +7,4 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import './echo';
|
import './echo';
|
||||||
|
import './xhr-redirect';
|
||||||
|
|||||||
@@ -0,0 +1,549 @@
|
|||||||
|
const storagePrefix = 'webhook-inspector:xhr-redirect:';
|
||||||
|
const forwardedRequestIds = new Set();
|
||||||
|
const inMemoryConfigurations = new Map();
|
||||||
|
const forbiddenRequestHeaders = new Set([
|
||||||
|
'accept-charset',
|
||||||
|
'accept-encoding',
|
||||||
|
'access-control-request-headers',
|
||||||
|
'access-control-request-method',
|
||||||
|
'connection',
|
||||||
|
'content-length',
|
||||||
|
'cookie',
|
||||||
|
'cookie2',
|
||||||
|
'date',
|
||||||
|
'dnt',
|
||||||
|
'expect',
|
||||||
|
'host',
|
||||||
|
'keep-alive',
|
||||||
|
'origin',
|
||||||
|
'referer',
|
||||||
|
'te',
|
||||||
|
'trailer',
|
||||||
|
'transfer-encoding',
|
||||||
|
'upgrade',
|
||||||
|
'user-agent',
|
||||||
|
'via',
|
||||||
|
]);
|
||||||
|
|
||||||
|
let livewireListenerRegistered = false;
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', initializeXhrRedirect);
|
||||||
|
document.addEventListener('click', handleDocumentClick);
|
||||||
|
document.addEventListener('input', handleDocumentInput);
|
||||||
|
document.addEventListener('submit', handleDocumentSubmit);
|
||||||
|
document.addEventListener('keydown', handleDocumentKeydown);
|
||||||
|
document.addEventListener('livewire:init', initializeXhrRedirect);
|
||||||
|
|
||||||
|
function initializeXhrRedirect() {
|
||||||
|
hydrateSettings();
|
||||||
|
|
||||||
|
if (livewireListenerRegistered || ! window.Livewire) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
livewireListenerRegistered = true;
|
||||||
|
window.Livewire.on('xhr-request-available', handleAvailableRequest);
|
||||||
|
window.Livewire.hook('morphed', hydrateSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hydrateSettings() {
|
||||||
|
document.querySelectorAll('[data-xhr-redirect-settings]').forEach((settings) => {
|
||||||
|
const endpointId = endpointIdFor(settings);
|
||||||
|
|
||||||
|
if (! endpointId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = settings.querySelector('[data-xhr-redirect-config-form]');
|
||||||
|
const targetInput = settings.querySelector('[data-xhr-redirect-target]');
|
||||||
|
const enabledInput = settings.querySelector('[data-xhr-redirect-enabled]');
|
||||||
|
|
||||||
|
if (! form || ! targetInput || ! enabledInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuration = readConfiguration(endpointId);
|
||||||
|
|
||||||
|
if (form.dataset.xhrRedirectDirty !== 'true') {
|
||||||
|
targetInput.value = configuration.target;
|
||||||
|
}
|
||||||
|
|
||||||
|
enabledInput.checked = configuration.enabled;
|
||||||
|
updateSettingsStatus(settings, configuration);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDocumentClick(event) {
|
||||||
|
const element = event.target instanceof Element ? event.target : null;
|
||||||
|
|
||||||
|
if (! element) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const openButton = element.closest('[data-xhr-redirect-open]');
|
||||||
|
|
||||||
|
if (openButton) {
|
||||||
|
const modal = openButton.closest('[data-xhr-redirect-endpoint]')?.querySelector('[data-xhr-redirect-modal]');
|
||||||
|
|
||||||
|
if (! modal) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
modal.xhrRequestPayload = readButtonPayload(openButton);
|
||||||
|
openModal(modal);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeButton = element.closest('[data-xhr-redirect-close]');
|
||||||
|
|
||||||
|
if (closeButton) {
|
||||||
|
closeModal(closeButton.closest('[data-xhr-redirect-modal]'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDocumentInput(event) {
|
||||||
|
const element = event.target instanceof HTMLInputElement ? event.target : null;
|
||||||
|
const form = element?.closest('[data-xhr-redirect-config-form]');
|
||||||
|
|
||||||
|
if (form) {
|
||||||
|
form.dataset.xhrRedirectDirty = 'true';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDocumentSubmit(event) {
|
||||||
|
const form = event.target instanceof HTMLFormElement ? event.target : null;
|
||||||
|
|
||||||
|
if (! form) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const configurationForm = form.closest('[data-xhr-redirect-config-form]');
|
||||||
|
|
||||||
|
if (configurationForm) {
|
||||||
|
event.preventDefault();
|
||||||
|
saveConfiguration(configurationForm);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendForm = form.closest('[data-xhr-redirect-send-form]');
|
||||||
|
|
||||||
|
if (sendForm) {
|
||||||
|
event.preventDefault();
|
||||||
|
sendSelectedRequest(sendForm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDocumentKeydown(event) {
|
||||||
|
if (event.key !== 'Escape') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modal = document.querySelector('[data-xhr-redirect-modal]:not([hidden])');
|
||||||
|
|
||||||
|
if (modal) {
|
||||||
|
closeModal(modal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveConfiguration(form) {
|
||||||
|
const settings = form.closest('[data-xhr-redirect-settings]');
|
||||||
|
const endpointId = endpointIdFor(settings);
|
||||||
|
const targetInput = form.querySelector('[data-xhr-redirect-target]');
|
||||||
|
const enabledInput = form.querySelector('[data-xhr-redirect-enabled]');
|
||||||
|
const target = targetInput?.value.trim() ?? '';
|
||||||
|
const enabled = enabledInput?.checked ?? false;
|
||||||
|
|
||||||
|
if (! endpointId || ! targetInput || ! enabledInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enabled && ! isHttpUrl(target)) {
|
||||||
|
const error = new Error('Enter a valid http:// or https:// target URL.');
|
||||||
|
|
||||||
|
console.error('[Webhook Inspector] XHR redirect configuration failed.', {
|
||||||
|
endpointId,
|
||||||
|
target,
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
updateFeedback(settings, error.message, 'error');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuration = { enabled, target };
|
||||||
|
writeConfiguration(endpointId, configuration);
|
||||||
|
form.dataset.xhrRedirectDirty = 'false';
|
||||||
|
updateSettingsStatus(settings, configuration);
|
||||||
|
updateFeedback(
|
||||||
|
settings,
|
||||||
|
enabled ? 'XHR redirect is active for new requests in this tab.' : 'XHR redirect disabled.',
|
||||||
|
enabled ? 'success' : 'neutral',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAvailableRequest({ endpointId, requestId }) {
|
||||||
|
if (! endpointId || ! requestId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuration = readConfiguration(endpointId);
|
||||||
|
|
||||||
|
if (! configuration.enabled || ! isHttpUrl(configuration.target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestKey = `${endpointId}:${requestId}`;
|
||||||
|
|
||||||
|
if (forwardedRequestIds.has(requestKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
forwardedRequestIds.add(requestKey);
|
||||||
|
|
||||||
|
const root = findEndpointRoot(endpointId);
|
||||||
|
let wireElement = root;
|
||||||
|
|
||||||
|
while (wireElement && ! wireElement.hasAttribute('wire:id')) {
|
||||||
|
wireElement = wireElement.parentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wireId = wireElement?.getAttribute('wire:id');
|
||||||
|
const component = wireId ? window.Livewire?.find(wireId) : null;
|
||||||
|
|
||||||
|
if (! component) {
|
||||||
|
logXhrError(
|
||||||
|
{ endpointId, requestId },
|
||||||
|
configuration.target,
|
||||||
|
new Error('The Livewire component is not available to load the request payload.'),
|
||||||
|
'automatic',
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await component.xhrRequestPayload(requestId);
|
||||||
|
const result = await sendRequest(payload, configuration.target);
|
||||||
|
const settings = root?.querySelector('[data-xhr-redirect-settings]');
|
||||||
|
|
||||||
|
logXhrResult(payload, configuration.target, result, 'automatic');
|
||||||
|
|
||||||
|
if (settings) {
|
||||||
|
updateFeedback(settings, `Forwarded ${payload.method} request (${result.status}).`, 'success');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const settings = root?.querySelector('[data-xhr-redirect-settings]');
|
||||||
|
|
||||||
|
logXhrError({ endpointId, requestId }, configuration.target, error, 'automatic');
|
||||||
|
|
||||||
|
if (settings) {
|
||||||
|
updateFeedback(settings, error.message || 'XHR redirect failed. Check the target and its CORS response.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendSelectedRequest(form) {
|
||||||
|
const modal = form.closest('[data-xhr-redirect-modal]');
|
||||||
|
const targetInput = form.querySelector('[data-xhr-redirect-target]');
|
||||||
|
const sendButton = form.querySelector('[data-xhr-redirect-send]');
|
||||||
|
const statusElement = form.querySelector('[data-xhr-redirect-status]');
|
||||||
|
const target = targetInput?.value.trim() ?? '';
|
||||||
|
const payload = modal?.xhrRequestPayload;
|
||||||
|
|
||||||
|
if (! modal || ! targetInput || ! sendButton || ! statusElement || ! payload) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! isHttpUrl(target)) {
|
||||||
|
const error = new Error('Enter a valid http:// or https:// target URL.');
|
||||||
|
|
||||||
|
logXhrError(payload, target, error, 'manual');
|
||||||
|
updateStatus(statusElement, error.message, 'error');
|
||||||
|
targetInput.focus();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendButton.disabled = true;
|
||||||
|
updateStatus(statusElement, `Sending ${payload.method} request…`, 'neutral');
|
||||||
|
|
||||||
|
sendRequest(payload, target)
|
||||||
|
.then((result) => {
|
||||||
|
showResponse(modal, result);
|
||||||
|
logXhrResult(payload, target, result, 'manual');
|
||||||
|
updateStatus(statusElement, `Request completed with ${result.status} ${result.statusText}.`, result.status >= 200 && result.status < 400 ? 'success' : 'error');
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
hideResponse(modal);
|
||||||
|
logXhrError(payload, target, error, 'manual');
|
||||||
|
updateStatus(statusElement, error.message || 'XHR redirect failed. Check the target and its CORS response.', 'error');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
sendButton.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendRequest(payload, target) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
const targetUrl = appendRequestQuery(target, payload.requestUri);
|
||||||
|
|
||||||
|
try {
|
||||||
|
xhr.open(payload.method, targetUrl, true);
|
||||||
|
xhr.timeout = 30_000;
|
||||||
|
|
||||||
|
Object.entries(payload.headers ?? {}).forEach(([name, values]) => {
|
||||||
|
if (isForbiddenRequestHeader(name)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
xhr.setRequestHeader(name, Array.isArray(values) ? values.join(', ') : String(values));
|
||||||
|
} catch {
|
||||||
|
// Browsers reject a few additional headers depending on their context.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
reject(new Error('The target URL or HTTP method is not supported by the browser.'));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
xhr.onload = () => resolve({
|
||||||
|
status: xhr.status,
|
||||||
|
statusText: xhr.statusText || 'Unknown status',
|
||||||
|
headers: xhr.getAllResponseHeaders().trim(),
|
||||||
|
body: xhr.responseText,
|
||||||
|
});
|
||||||
|
xhr.onerror = () => reject(new Error('The browser could not reach the target. Check its CORS headers and whether it is running.'));
|
||||||
|
xhr.ontimeout = () => reject(new Error('The target did not respond within 30 seconds.'));
|
||||||
|
xhr.onabort = () => reject(new Error('The XHR request was aborted.'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = decodeBase64ToBytes(payload.body ?? '');
|
||||||
|
const method = payload.method.toUpperCase();
|
||||||
|
|
||||||
|
xhr.send(['GET', 'HEAD'].includes(method) || body.byteLength === 0 ? null : body);
|
||||||
|
} catch {
|
||||||
|
reject(new Error('The captured request body could not be decoded.'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function logXhrResult(payload, target, result, mode) {
|
||||||
|
const details = {
|
||||||
|
mode,
|
||||||
|
endpointId: payload?.endpointId ?? null,
|
||||||
|
requestId: payload?.requestId ?? null,
|
||||||
|
method: payload?.method ?? null,
|
||||||
|
target: appendRequestQuery(target, payload?.requestUri),
|
||||||
|
status: result.status,
|
||||||
|
statusText: result.statusText,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (result.status >= 400 || result.status === 0) {
|
||||||
|
console.error('[Webhook Inspector] XHR forwarding failed.', details);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info('[Webhook Inspector] XHR forwarding succeeded.', details);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logXhrError(payload, target, error, mode) {
|
||||||
|
console.error('[Webhook Inspector] XHR forwarding failed.', {
|
||||||
|
mode,
|
||||||
|
endpointId: payload?.endpointId ?? null,
|
||||||
|
requestId: payload?.requestId ?? null,
|
||||||
|
method: payload?.method ?? null,
|
||||||
|
target: appendRequestQuery(target, payload?.requestUri),
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readButtonPayload(button) {
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
endpointId: button.dataset.xhrRequestEndpoint,
|
||||||
|
requestId: button.dataset.xhrRequestId,
|
||||||
|
method: button.dataset.xhrRequestMethod,
|
||||||
|
requestUri: button.dataset.xhrRequestUri,
|
||||||
|
headers: JSON.parse(decodeBase64ToString(button.dataset.xhrRequestHeaders ?? 'e30=')),
|
||||||
|
body: button.dataset.xhrRequestBody ?? '',
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendRequestQuery(target, requestUri) {
|
||||||
|
const queryStart = String(requestUri ?? '').indexOf('?');
|
||||||
|
|
||||||
|
if (queryStart === -1) {
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = String(requestUri).slice(queryStart + 1);
|
||||||
|
|
||||||
|
if (query === '') {
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashStart = target.indexOf('#');
|
||||||
|
const hash = hashStart === -1 ? '' : target.slice(hashStart);
|
||||||
|
const targetWithoutHash = hashStart === -1 ? target : target.slice(0, hashStart);
|
||||||
|
const separator = targetWithoutHash.includes('?')
|
||||||
|
? (targetWithoutHash.endsWith('?') || targetWithoutHash.endsWith('&') ? '' : '&')
|
||||||
|
: '?';
|
||||||
|
|
||||||
|
return `${targetWithoutHash}${separator}${query}${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isForbiddenRequestHeader(name) {
|
||||||
|
const normalizedName = name.toLowerCase();
|
||||||
|
|
||||||
|
return forbiddenRequestHeaders.has(normalizedName)
|
||||||
|
|| normalizedName.startsWith('proxy-')
|
||||||
|
|| normalizedName.startsWith('sec-');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHttpUrl(value) {
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
|
||||||
|
return ['http:', 'https:'].includes(url.protocol);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readConfiguration(endpointId) {
|
||||||
|
const fallback = inMemoryConfigurations.get(endpointId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = window.sessionStorage.getItem(storagePrefix + endpointId);
|
||||||
|
|
||||||
|
if (stored) {
|
||||||
|
const configuration = JSON.parse(stored);
|
||||||
|
|
||||||
|
if (typeof configuration.enabled === 'boolean' && typeof configuration.target === 'string') {
|
||||||
|
return configuration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Session storage may be unavailable in privacy-restricted browsers.
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback ?? { enabled: false, target: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeConfiguration(endpointId, configuration) {
|
||||||
|
inMemoryConfigurations.set(endpointId, configuration);
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.sessionStorage.setItem(storagePrefix + endpointId, JSON.stringify(configuration));
|
||||||
|
} catch {
|
||||||
|
// Keep the in-memory configuration for the current page when storage is unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSettingsStatus(settings, configuration) {
|
||||||
|
const activeBadge = settings?.querySelector('[data-xhr-redirect-active]');
|
||||||
|
|
||||||
|
if (activeBadge) {
|
||||||
|
activeBadge.hidden = ! configuration.enabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFeedback(settings, message, tone) {
|
||||||
|
const feedback = settings?.querySelector('[data-xhr-redirect-feedback]');
|
||||||
|
|
||||||
|
if (! feedback) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
feedback.textContent = message;
|
||||||
|
feedback.classList.remove('text-slate-500', 'text-emerald-300', 'text-rose-300');
|
||||||
|
feedback.classList.add({ success: 'text-emerald-300', error: 'text-rose-300', neutral: 'text-slate-500' }[tone] ?? 'text-slate-500');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatus(statusElement, message, tone) {
|
||||||
|
statusElement.textContent = message;
|
||||||
|
statusElement.classList.remove('text-slate-400', 'text-cyan-200', 'text-emerald-200', 'text-rose-200');
|
||||||
|
statusElement.classList.add({ success: 'text-emerald-200', error: 'text-rose-200', neutral: 'text-slate-400' }[tone] ?? 'text-slate-400');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showResponse(modal, result) {
|
||||||
|
const response = modal.querySelector('[data-xhr-redirect-response]');
|
||||||
|
const status = modal.querySelector('[data-xhr-response-status]');
|
||||||
|
const headers = modal.querySelector('[data-xhr-response-headers]');
|
||||||
|
const body = modal.querySelector('[data-xhr-response-body]');
|
||||||
|
|
||||||
|
if (! response || ! status || ! headers || ! body) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.hidden = false;
|
||||||
|
status.textContent = `${result.status} ${result.statusText}`;
|
||||||
|
headers.textContent = limitText(result.headers || '(No response headers exposed by CORS.)', 8_000);
|
||||||
|
body.textContent = limitText(result.body || '(Empty response body.)', 32_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideResponse(modal) {
|
||||||
|
const response = modal.querySelector('[data-xhr-redirect-response]');
|
||||||
|
|
||||||
|
if (response) {
|
||||||
|
response.hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openModal(modal) {
|
||||||
|
const targetInput = modal.querySelector('[data-xhr-redirect-target]');
|
||||||
|
const endpointId = endpointIdFor(modal);
|
||||||
|
const configuration = endpointId ? readConfiguration(endpointId) : { enabled: false, target: '' };
|
||||||
|
|
||||||
|
if (targetInput && ! targetInput.value) {
|
||||||
|
targetInput.value = configuration.target;
|
||||||
|
}
|
||||||
|
|
||||||
|
modal.hidden = false;
|
||||||
|
hideResponse(modal);
|
||||||
|
updateStatus(modal.querySelector('[data-xhr-redirect-status]'), 'Ready to send.', 'neutral');
|
||||||
|
targetInput?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal(modal) {
|
||||||
|
if (modal) {
|
||||||
|
modal.hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findEndpointRoot(endpointId) {
|
||||||
|
return Array.from(document.querySelectorAll('[data-xhr-redirect-endpoint]'))
|
||||||
|
.find((root) => root.dataset.xhrRedirectEndpoint === endpointId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function endpointIdFor(element) {
|
||||||
|
return element?.closest('[data-xhr-redirect-endpoint]')?.dataset.xhrRedirectEndpoint ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeBase64ToBytes(value) {
|
||||||
|
const binary = atob(value);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
|
||||||
|
for (let index = 0; index < binary.length; index++) {
|
||||||
|
bytes[index] = binary.charCodeAt(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeBase64ToString(value) {
|
||||||
|
return new TextDecoder().decode(decodeBase64ToBytes(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function limitText(value, limit) {
|
||||||
|
return value.length > limit ? `${value.slice(0, limit)}\n\n[Response truncated]` : value;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
@extends('layouts.app')
|
@extends('layouts.app')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="mx-auto max-w-7xl px-6 py-10 lg:px-8">
|
<div class="mx-auto px-6 py-10 lg:px-8">
|
||||||
<livewire:dashboard />
|
<livewire:dashboard />
|
||||||
</div>
|
</div>
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
@extends('layouts.app')
|
@extends('layouts.app')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="mx-auto max-w-7xl px-6 py-10 lg:px-8">
|
<div class="mx-auto px-6 py-10 lg:px-8">
|
||||||
<livewire:inspector :endpoint="$endpoint" />
|
<livewire:inspector :endpoint="$endpoint" />
|
||||||
</div>
|
</div>
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen bg-slate-950 font-sans text-slate-100 antialiased">
|
<body class="min-h-screen bg-slate-950 font-sans text-slate-100 antialiased">
|
||||||
<header class="border-b border-white/10 bg-slate-950/90 backdrop-blur">
|
<header class="border-b border-white/10 bg-slate-950/90 backdrop-blur">
|
||||||
<div class="mx-auto flex max-w-7xl items-center justify-between gap-6 px-6 py-4 lg:px-8">
|
<div class="mx-auto flex items-center justify-between gap-6 px-6 py-4 lg:px-8">
|
||||||
<a href="{{ route('home') }}" class="flex items-center gap-3 font-semibold tracking-tight text-white">
|
<a href="{{ route('home') }}" class="flex items-center gap-3 font-semibold tracking-tight text-white">
|
||||||
<span class="flex size-9 items-center justify-center rounded-xl bg-cyan-400 font-mono text-sm font-bold text-slate-950">WI</span>
|
<span class="flex size-9 items-center justify-center rounded-xl bg-cyan-400 font-mono text-sm font-bold text-slate-950">WI</span>
|
||||||
<span>Webhook Inspector</span>
|
<span>Webhook Inspector</span>
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
@if (session('status'))
|
@if (session('status'))
|
||||||
<div class="mx-auto max-w-7xl px-6 pt-6 lg:px-8">
|
<div class="mx-auto px-6 pt-6 lg:px-8">
|
||||||
<div class="rounded-xl border border-emerald-400/30 bg-emerald-400/10 px-4 py-3 text-sm text-emerald-200">
|
<div class="rounded-xl border border-emerald-400/30 bg-emerald-400/10 px-4 py-3 text-sm text-emerald-200">
|
||||||
{{ session('status') }}
|
{{ session('status') }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<div wire:poll.15s class="space-y-6">
|
<div wire:poll.15s wire:keydown.escape="$set('showEndpointSettings', false)" data-xhr-redirect-endpoint="{{ $endpoint->id }}" class="space-y-6">
|
||||||
<div class="flex flex-col justify-between gap-6 lg:flex-row lg:items-end">
|
<div class="flex flex-col justify-between gap-6 lg:flex-row lg:items-end">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="flex flex-wrap items-center gap-3 text-sm text-slate-400">
|
<div class="flex flex-wrap items-center gap-3 text-sm text-slate-400">
|
||||||
@@ -12,30 +12,36 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<button type="button" wire:click="$toggle('showEndpointSettings')" class="cursor-pointer rounded-xl border border-cyan-300/25 bg-cyan-300/10 px-4 py-2.5 text-sm font-medium text-cyan-200 transition hover:border-cyan-300/50 hover:bg-cyan-300/15">
|
||||||
|
{{ $showEndpointSettings ? 'Close settings' : 'Endpoint settings' }}
|
||||||
|
</button>
|
||||||
@if ($endpoint->is_public)
|
@if ($endpoint->is_public)
|
||||||
<form action="{{ route('inspect.destroy', ['token' => $endpoint->token]) }}" method="POST">
|
<form action="{{ route('inspect.destroy', ['token' => $endpoint->token]) }}" method="POST">
|
||||||
@csrf
|
@csrf
|
||||||
@method('DELETE')
|
@method('DELETE')
|
||||||
<button type="submit" class="rounded-xl border border-rose-300/20 px-4 py-2.5 text-sm font-medium text-rose-300 transition hover:bg-rose-400/10" onclick="return confirm('Delete this endpoint and all captured requests?')">Delete URL</button>
|
<button type="submit" class="cursor-pointer rounded-xl border border-rose-300/20 px-4 py-2.5 text-sm font-medium text-rose-300 transition hover:bg-rose-400/10" onclick="return confirm('Delete this endpoint and all captured requests?')">Delete URL</button>
|
||||||
</form>
|
</form>
|
||||||
@else
|
@else
|
||||||
<button type="button" wire:click="deleteEndpoint" wire:confirm="Delete this endpoint and all captured requests?" class="rounded-xl border border-rose-300/20 px-4 py-2.5 text-sm font-medium text-rose-300 transition hover:bg-rose-400/10">Delete URL</button>
|
<button type="button" wire:click="deleteEndpoint" wire:confirm="Delete this endpoint and all captured requests?" class="cursor-pointer rounded-xl border border-rose-300/20 px-4 py-2.5 text-sm font-medium text-rose-300 transition hover:bg-rose-400/10">Delete URL</button>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-4 xl:grid-cols-[1fr_20rem]">
|
<div class="grid gap-4 lg:grid-cols-[22rem_minmax(0,1fr)] lg:items-start">
|
||||||
<section class="min-w-0 rounded-2xl border border-white/10 bg-white/[0.04] p-5 sm:p-6">
|
<section class="min-w-0 rounded-2xl border border-white/10 bg-white/[0.04] p-4 sm:p-5 lg:sticky lg:top-6 lg:flex lg:h-[calc(100vh-13rem)] lg:min-h-[32rem] lg:flex-col">
|
||||||
<div class="flex flex-col justify-between gap-4 border-b border-white/10 pb-5 sm:flex-row sm:items-center">
|
<div class="shrink-0 border-b border-white/10 pb-4">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="font-medium text-white">Incoming requests</h2>
|
<h2 class="font-medium text-white">Incoming requests</h2>
|
||||||
<p class="mt-1 text-sm text-slate-500">New requests are stored immediately and appear here live.</p>
|
<p class="mt-1 text-sm text-slate-500">Live request history</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2 sm:flex-row">
|
<span class="rounded-full border border-white/10 px-2 py-1 text-[11px] text-slate-500">{{ $webhookRequests->total() }} total</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 grid gap-2">
|
||||||
<label class="sr-only" for="request-search">Search requests</label>
|
<label class="sr-only" for="request-search">Search requests</label>
|
||||||
<input id="request-search" type="search" wire:model.live.debounce.350ms="search" placeholder="Search URI, IP, agent" class="rounded-lg border border-white/10 bg-slate-950/70 px-3 py-2 text-sm text-white outline-none placeholder:text-slate-600 focus:border-cyan-300/60">
|
<input id="request-search" type="search" wire:model.live.debounce.350ms="search" placeholder="Search URI, IP, agent" class="w-full rounded-lg border border-white/10 bg-slate-950/70 px-3 py-2 text-sm text-white outline-none placeholder:text-slate-600 focus:border-cyan-300/60">
|
||||||
<label class="sr-only" for="method-filter">Filter method</label>
|
<label class="sr-only" for="method-filter">Filter method</label>
|
||||||
<select id="method-filter" wire:model.live="methodFilter" class="rounded-lg border border-white/10 bg-slate-950/70 px-3 py-2 text-sm text-white outline-none focus:border-cyan-300/60">
|
<select id="method-filter" wire:model.live="methodFilter" class="w-full rounded-lg border border-white/10 bg-slate-950/70 px-3 py-2 text-sm text-white outline-none focus:border-cyan-300/60">
|
||||||
@foreach (['ALL', 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'] as $method)
|
@foreach (['ALL', 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'] as $method)
|
||||||
<option value="{{ $method }}">{{ $method === 'ALL' ? 'All methods' : $method }}</option>
|
<option value="{{ $method }}">{{ $method === 'ALL' ? 'All methods' : $method }}</option>
|
||||||
@endforeach
|
@endforeach
|
||||||
@@ -44,44 +50,210 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if ($webhookRequests->isEmpty())
|
@if ($webhookRequests->isEmpty())
|
||||||
<div class="py-16 text-center">
|
<div class="flex min-h-[20rem] flex-1 flex-col items-center justify-center px-3 text-center">
|
||||||
<p class="font-mono text-sm text-slate-500">waiting for request...</p>
|
<p class="font-mono text-sm text-slate-500">waiting for request...</p>
|
||||||
<p class="mt-2 text-sm text-slate-600">Send a request to the webhook URL below to see it here.</p>
|
<p class="mt-2 text-sm leading-6 text-slate-600">Open endpoint settings to find the URL for the next request.</p>
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
<div class="mt-5 overflow-x-auto">
|
<div class="mt-4 min-h-0 flex-1 overflow-y-auto pr-1">
|
||||||
<table class="w-full min-w-[38rem] text-left text-sm">
|
<div class="space-y-2">
|
||||||
<thead class="text-xs uppercase tracking-wider text-slate-600">
|
|
||||||
<tr>
|
|
||||||
<th class="pb-3 pr-4 font-medium">Method</th>
|
|
||||||
<th class="pb-3 pr-4 font-medium">URI</th>
|
|
||||||
<th class="pb-3 pr-4 font-medium">Type</th>
|
|
||||||
<th class="pb-3 pr-4 font-medium">Size</th>
|
|
||||||
<th class="pb-3 text-right font-medium">Received</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-white/5">
|
|
||||||
@foreach ($webhookRequests as $webhookRequest)
|
@foreach ($webhookRequests as $webhookRequest)
|
||||||
<tr wire:key="request-{{ $webhookRequest->id }}" class="group cursor-pointer transition hover:bg-white/[0.03]" wire:click="selectRequest('{{ $webhookRequest->id }}')">
|
<button
|
||||||
<td class="py-4 pr-4 align-top"><span class="rounded-md bg-cyan-300/10 px-2 py-1 font-mono text-xs font-medium text-cyan-200">{{ $webhookRequest->method }}</span></td>
|
type="button"
|
||||||
<td class="max-w-[24rem] truncate py-4 pr-4 font-mono text-xs text-slate-300">{{ $webhookRequest->request_uri }}</td>
|
wire:key="request-{{ $webhookRequest->id }}"
|
||||||
<td class="py-4 pr-4 text-xs text-slate-500">{{ $webhookRequest->content_type ?: '—' }}</td>
|
wire:click="selectRequest('{{ $webhookRequest->id }}')"
|
||||||
<td class="py-4 pr-4 font-mono text-xs text-slate-500">{{ $webhookRequest->body_size }} B</td>
|
class="w-full cursor-pointer rounded-xl border px-3 py-3 text-left transition {{ $selectedRequestId === $webhookRequest->id ? 'border-cyan-300/50 bg-cyan-300/[0.08]' : 'border-white/10 bg-white/[0.02] hover:border-white/20 hover:bg-white/[0.04]' }}"
|
||||||
<td class="py-4 text-right text-xs text-slate-500">{{ $webhookRequest->received_at?->diffForHumans() }}</td>
|
>
|
||||||
</tr>
|
<div class="flex items-center justify-between gap-3">
|
||||||
@endforeach
|
<span class="rounded-md bg-cyan-300/10 px-2 py-1 font-mono text-[11px] font-medium text-cyan-200">{{ $webhookRequest->method }}</span>
|
||||||
</tbody>
|
<span class="shrink-0 text-[11px] text-slate-600">{{ $webhookRequest->received_at?->diffForHumans() }}</span>
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-5">{{ $webhookRequests->links() }}</div>
|
<div class="mt-2 truncate font-mono text-xs text-slate-300">{{ $webhookRequest->request_uri }}</div>
|
||||||
|
<div class="mt-2 flex items-center justify-between gap-3 text-[11px] text-slate-600">
|
||||||
|
<span class="truncate">{{ $webhookRequest->content_type ?: 'No content type' }}</span>
|
||||||
|
<span class="shrink-0 font-mono">{{ $webhookRequest->body_size }} B</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 shrink-0 overflow-x-auto border-t border-white/10 pt-4">{{ $webhookRequests->links() }}</div>
|
||||||
@endif
|
@endif
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside class="space-y-4">
|
<section class="min-w-0 overflow-hidden rounded-2xl border border-white/10 bg-white/[0.04] lg:h-[calc(100vh-13rem)] lg:overflow-y-auto">
|
||||||
<section class="rounded-2xl border border-white/10 bg-white/[0.04] p-5">
|
@if ($selectedRequest)
|
||||||
<h2 class="font-medium text-white">Send requests here</h2>
|
<div class="flex flex-col justify-between gap-4 border-b border-white/10 p-5 sm:flex-row sm:items-start sm:p-6">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<span class="rounded-md bg-cyan-300/10 px-2 py-1 font-mono text-xs font-medium text-cyan-200">{{ $selectedRequest->method }}</span>
|
||||||
|
<span class="text-xs uppercase tracking-wider text-slate-600">Request analysis</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="mt-3 break-all font-mono text-sm text-white">{{ $selectedRequest->request_uri }}</h2>
|
||||||
|
<p class="mt-2 font-mono text-xs text-slate-600">{{ $selectedRequest->id }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-xhr-redirect-open
|
||||||
|
data-xhr-request-method="{{ $selectedRequest->method }}"
|
||||||
|
data-xhr-request-uri="{{ $selectedRequest->request_uri }}"
|
||||||
|
data-xhr-request-headers="{{ base64_encode(json_encode($selectedRequest->headers ?? [], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE) ?: '{}') }}"
|
||||||
|
data-xhr-request-body="{{ base64_encode((string) ($selectedRequest->body ?? '')) }}"
|
||||||
|
class="cursor-pointer rounded-lg border border-cyan-300/25 bg-cyan-300/10 px-3 py-2 text-sm text-cyan-200 transition hover:border-cyan-300/50 hover:bg-cyan-300/15"
|
||||||
|
>
|
||||||
|
Send via XHR
|
||||||
|
</button>
|
||||||
|
<button type="button" wire:click="deleteRequest('{{ $selectedRequest->id }}')" wire:confirm="Delete this request?" class="cursor-pointer text-sm text-rose-300 transition hover:text-rose-200">Delete request</button>
|
||||||
|
<button type="button" wire:click="clearSelectedRequest" class="cursor-pointer rounded-lg border border-white/10 px-3 py-2 text-sm text-slate-300 transition hover:border-white/25 hover:text-white">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-6 p-5 sm:p-6">
|
||||||
|
<div class="grid gap-6 lg:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xs font-medium uppercase tracking-wider text-slate-500">Request metadata</h3>
|
||||||
|
<dl class="mt-3 divide-y divide-white/5 rounded-xl border border-white/10 bg-slate-950/40 px-4 text-sm">
|
||||||
|
<div class="flex justify-between gap-4 py-3"><dt class="text-slate-500">Received</dt><dd class="text-right text-slate-200">{{ $selectedRequest->received_at?->format('Y-m-d H:i:s T') }}</dd></div>
|
||||||
|
<div class="flex justify-between gap-4 py-3"><dt class="text-slate-500">Content-Type</dt><dd class="max-w-[65%] break-all text-right font-mono text-xs text-slate-200">{{ $selectedRequest->content_type ?: '—' }}</dd></div>
|
||||||
|
<div class="flex justify-between gap-4 py-3"><dt class="text-slate-500">Size</dt><dd class="text-right text-slate-200">{{ $selectedRequest->body_size }} bytes</dd></div>
|
||||||
|
<div class="flex justify-between gap-4 py-3"><dt class="text-slate-500">IP address</dt><dd class="text-right font-mono text-xs text-slate-200">{{ $selectedRequest->ip_address ?: '—' }}</dd></div>
|
||||||
|
<div class="flex justify-between gap-4 py-3"><dt class="text-slate-500">User-Agent</dt><dd class="max-w-[65%] break-all text-right text-xs text-slate-200">{{ $selectedRequest->user_agent ?: '—' }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xs font-medium uppercase tracking-wider text-slate-500">Query parameters</h3>
|
||||||
|
<pre class="mt-3 max-h-64 overflow-auto rounded-xl border border-white/10 bg-slate-950/70 p-4 font-mono text-xs leading-6 text-slate-300">{{ json_encode($selectedRequest->query_parameters ?? [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-6 lg:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xs font-medium uppercase tracking-wider text-slate-500">Headers</h3>
|
||||||
|
<div class="mt-3 max-h-80 overflow-auto rounded-xl border border-white/10 bg-slate-950/70 p-4 font-mono text-xs leading-6">
|
||||||
|
@forelse ($selectedRequest->headers ?? [] as $name => $values)
|
||||||
|
<div class="flex gap-3"><span class="shrink-0 text-cyan-300">{{ $name }}:</span><span class="break-all text-slate-300">{{ implode(', ', (array) $values) }}</span></div>
|
||||||
|
@empty
|
||||||
|
<span class="text-slate-600">No headers captured.</span>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xs font-medium uppercase tracking-wider text-slate-500">Body</h3>
|
||||||
|
<pre class="mt-3 max-h-80 overflow-auto whitespace-pre-wrap break-words rounded-xl border border-white/10 bg-slate-950/70 p-4 font-mono text-xs leading-6 text-emerald-200">{{ $selectedRequest->body ?? '' }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($selectedRequest->json_payload !== null)
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xs font-medium uppercase tracking-wider text-slate-500">Parsed JSON</h3>
|
||||||
|
<pre class="mt-3 max-h-80 overflow-auto rounded-xl border border-white/10 bg-slate-950/70 p-4 font-mono text-xs leading-6 text-emerald-200">{{ json_encode($selectedRequest->json_payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}</pre>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="flex min-h-[32rem] flex-col items-center justify-center p-8 text-center lg:min-h-[calc(100vh-13rem)]">
|
||||||
|
<div class="flex size-12 items-center justify-center rounded-2xl bg-cyan-300/10 font-mono text-sm text-cyan-300">//</div>
|
||||||
|
<p class="mt-5 text-xs font-medium uppercase tracking-[0.18em] text-cyan-300">Request analysis</p>
|
||||||
|
<h2 class="mt-3 text-xl font-medium text-white">Select an incoming request</h2>
|
||||||
|
<p class="mt-2 max-w-md text-sm leading-6 text-slate-500">Choose a request from the left to inspect its headers, query parameters, body, and parsed payload here.</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($selectedRequest)
|
||||||
|
<div data-xhr-redirect-modal hidden class="fixed inset-0 z-50 overflow-y-auto p-4 sm:p-6" role="dialog" aria-modal="true" aria-labelledby="xhr-redirect-title">
|
||||||
|
<button type="button" data-xhr-redirect-close class="absolute inset-0 size-full cursor-pointer bg-slate-950/80 backdrop-blur-sm" aria-label="Close XHR redirect"></button>
|
||||||
|
|
||||||
|
<div class="relative z-10 mx-auto my-4 w-full max-w-2xl overflow-hidden rounded-2xl border border-white/15 bg-slate-900 shadow-2xl shadow-black/50 sm:my-8">
|
||||||
|
<div class="flex items-start justify-between gap-4 border-b border-white/10 p-5 sm:p-6">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium uppercase tracking-[0.18em] text-cyan-300">Browser forwarding</p>
|
||||||
|
<h2 id="xhr-redirect-title" class="mt-2 text-xl font-semibold text-white">Send request via XHR</h2>
|
||||||
|
<p class="mt-2 text-sm leading-6 text-slate-500">The browser sends this request directly to the target. The target must allow CORS.</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" data-xhr-redirect-close class="shrink-0 cursor-pointer rounded-lg border border-white/10 px-3 py-2 text-sm text-slate-300 transition hover:border-white/25 hover:text-white">Close</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form data-xhr-redirect-send-form class="space-y-5 p-5 sm:p-6">
|
||||||
|
<div>
|
||||||
|
<label for="xhr-redirect-target" class="text-xs font-medium uppercase tracking-wider text-slate-500">Target URL</label>
|
||||||
|
<input id="xhr-redirect-target" type="url" data-xhr-redirect-target required autocomplete="off" placeholder="http://localhost:8080/webhook" class="mt-2 w-full rounded-lg border border-white/10 bg-slate-950/70 px-3 py-2.5 font-mono text-sm text-white outline-none placeholder:text-slate-600 focus:border-cyan-300/60">
|
||||||
|
<p class="mt-2 text-xs leading-5 text-slate-600">Use an http:// or https:// URL. Query parameters from the captured request are appended to the target.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div data-xhr-redirect-status role="status" class="rounded-lg border border-white/10 bg-slate-950/50 px-3 py-2.5 text-sm text-slate-400">Ready to send.</div>
|
||||||
|
|
||||||
|
<div data-xhr-redirect-response hidden class="space-y-3 rounded-xl border border-white/10 bg-slate-950/50 p-4">
|
||||||
|
<div class="flex items-center justify-between gap-3 text-sm">
|
||||||
|
<span class="text-slate-500">Response</span>
|
||||||
|
<span data-xhr-response-status class="font-mono text-slate-200"></span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-[11px] uppercase tracking-wider text-slate-600">Headers</p>
|
||||||
|
<pre data-xhr-response-headers class="mt-2 max-h-32 overflow-auto whitespace-pre-wrap break-words font-mono text-xs leading-5 text-slate-400"></pre>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-[11px] uppercase tracking-wider text-slate-600">Body</p>
|
||||||
|
<pre data-xhr-response-body class="mt-2 max-h-48 overflow-auto whitespace-pre-wrap break-words font-mono text-xs leading-5 text-emerald-200"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
<button type="button" data-xhr-redirect-close class="cursor-pointer rounded-lg border border-white/10 px-3 py-2.5 text-sm text-slate-300 transition hover:border-white/25 hover:text-white">Cancel</button>
|
||||||
|
<button type="submit" data-xhr-redirect-send class="cursor-pointer rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-slate-950 transition hover:bg-cyan-50 disabled:cursor-not-allowed disabled:opacity-50">Send via XHR</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($showEndpointSettings)
|
||||||
|
<div class="fixed inset-0 z-50 overflow-y-auto p-4 sm:p-6" role="dialog" aria-modal="true" aria-labelledby="endpoint-settings-title">
|
||||||
|
<button type="button" wire:click="$set('showEndpointSettings', false)" class="absolute inset-0 size-full cursor-pointer bg-slate-950/80 backdrop-blur-sm" aria-label="Close endpoint settings"></button>
|
||||||
|
|
||||||
|
<div class="relative z-10 mx-auto my-4 w-full max-w-5xl overflow-hidden rounded-2xl border border-white/15 bg-slate-900 shadow-2xl shadow-black/50 sm:my-8">
|
||||||
|
<div class="flex items-start justify-between gap-4 border-b border-white/10 p-5 sm:p-6">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium uppercase tracking-[0.18em] text-cyan-300">Endpoint configuration</p>
|
||||||
|
<h2 id="endpoint-settings-title" class="mt-2 text-xl font-semibold text-white">Endpoint settings</h2>
|
||||||
|
<p class="mt-2 text-sm text-slate-500">Manage the URL for incoming calls and the response sent back to private endpoints.</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" wire:click="$set('showEndpointSettings', false)" class="shrink-0 cursor-pointer rounded-lg border border-white/10 px-3 py-2 text-sm text-slate-300 transition hover:border-white/25 hover:text-white">Close</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-6 p-5 sm:p-6 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
|
||||||
|
<section data-xhr-redirect-settings class="rounded-xl border border-cyan-300/15 bg-cyan-300/[0.04] p-5 xl:col-span-2">
|
||||||
|
<div class="flex flex-col justify-between gap-4 sm:flex-row sm:items-start">
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium text-white">XHR redirect</h3>
|
||||||
|
<p class="mt-1 max-w-2xl text-sm leading-6 text-slate-500">Forward new incoming requests from this browser tab to a local or remote endpoint. Forwarding runs in the browser and stops when this tab is closed.</p>
|
||||||
|
</div>
|
||||||
|
<span data-xhr-redirect-active hidden class="shrink-0 rounded-full border border-emerald-300/20 bg-emerald-300/10 px-2.5 py-1 text-xs text-emerald-200">Active</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form data-xhr-redirect-config-form class="mt-5 grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto_auto] lg:items-end">
|
||||||
|
<div>
|
||||||
|
<label for="xhr-redirect-config-target" class="text-xs font-medium uppercase tracking-wider text-slate-500">Target URL</label>
|
||||||
|
<input id="xhr-redirect-config-target" type="url" data-xhr-redirect-target autocomplete="off" placeholder="http://localhost:8080/webhook" class="mt-2 w-full rounded-lg border border-white/10 bg-slate-950/70 px-3 py-2.5 font-mono text-sm text-white outline-none placeholder:text-slate-600 focus:border-cyan-300/60">
|
||||||
|
</div>
|
||||||
|
<label class="flex cursor-pointer items-center gap-2 pb-2.5 text-sm text-slate-300">
|
||||||
|
<input type="checkbox" data-xhr-redirect-enabled class="size-4 rounded border-white/20 bg-slate-950 text-cyan-300 focus:ring-cyan-300/40">
|
||||||
|
Enable forwarding
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="cursor-pointer rounded-lg bg-cyan-300 px-4 py-2.5 text-sm font-semibold text-slate-950 transition hover:bg-cyan-200">Save XHR settings</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p data-xhr-redirect-feedback role="status" class="mt-3 min-h-5 text-xs text-slate-500"></p>
|
||||||
|
<p class="mt-3 text-xs leading-5 text-slate-600">The target should answer preflight requests and include <code class="font-mono text-slate-400">Access-Control-Allow-Origin</code>, <code class="font-mono text-slate-400">Access-Control-Allow-Methods</code>, and <code class="font-mono text-slate-400">Access-Control-Allow-Headers</code>.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded-xl border border-white/10 bg-slate-950/40 p-5">
|
||||||
|
<h3 class="font-medium text-white">Send requests here</h3>
|
||||||
<p class="mt-1 text-sm text-slate-500">Any HTTP method is accepted.</p>
|
<p class="mt-1 text-sm text-slate-500">Any HTTP method is accepted.</p>
|
||||||
<div class="mt-4 space-y-3">
|
<div class="mt-5 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-1 text-[11px] uppercase tracking-wider text-slate-600">Webhook URL</div>
|
<div class="mb-1 text-[11px] uppercase tracking-wider text-slate-600">Webhook URL</div>
|
||||||
<code class="block break-all rounded-lg bg-slate-950/80 p-3 text-xs leading-5 text-cyan-200">{{ $endpoint->webhookUrl() }}</code>
|
<code class="block break-all rounded-lg bg-slate-950/80 p-3 text-xs leading-5 text-cyan-200">{{ $endpoint->webhookUrl() }}</code>
|
||||||
@@ -94,10 +266,10 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
@if (! $endpoint->is_public)
|
@if (! $endpoint->is_public)
|
||||||
<section class="rounded-2xl border border-white/10 bg-white/[0.04] p-5">
|
<section class="rounded-xl border border-white/10 bg-slate-950/40 p-5">
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="font-medium text-white">Response</h2>
|
<h3 class="font-medium text-white">Response</h3>
|
||||||
<p class="mt-1 text-sm text-slate-500">Configure what the sender receives.</p>
|
<p class="mt-1 text-sm text-slate-500">Configure what the sender receives.</p>
|
||||||
</div>
|
</div>
|
||||||
@if ($responseSaved)
|
@if ($responseSaved)
|
||||||
@@ -112,79 +284,25 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="response-headers" class="text-xs font-medium uppercase tracking-wider text-slate-500">Headers (JSON)</label>
|
<label for="response-headers" class="text-xs font-medium uppercase tracking-wider text-slate-500">Headers (JSON)</label>
|
||||||
<textarea id="response-headers" wire:model="responseHeadersJson" rows="4" spellcheck="false" class="mt-2 w-full rounded-lg border border-white/10 bg-slate-950/70 px-3 py-2 font-mono text-xs text-white outline-none focus:border-cyan-300/60">{{ $responseHeadersJson }}</textarea>
|
<textarea id="response-headers" wire:model="responseHeadersJson" rows="5" spellcheck="false" class="mt-2 w-full rounded-lg border border-white/10 bg-slate-950/70 px-3 py-2 font-mono text-xs text-white outline-none focus:border-cyan-300/60">{{ $responseHeadersJson }}</textarea>
|
||||||
@error('responseHeadersJson') <p class="mt-1 text-xs text-rose-300">{{ $message }}</p> @enderror
|
@error('responseHeadersJson') <p class="mt-1 text-xs text-rose-300">{{ $message }}</p> @enderror
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="response-body" class="text-xs font-medium uppercase tracking-wider text-slate-500">Body</label>
|
<label for="response-body" class="text-xs font-medium uppercase tracking-wider text-slate-500">Body</label>
|
||||||
<textarea id="response-body" wire:model="responseBody" rows="5" spellcheck="false" class="mt-2 w-full rounded-lg border border-white/10 bg-slate-950/70 px-3 py-2 font-mono text-xs text-white outline-none focus:border-cyan-300/60">{{ $responseBody }}</textarea>
|
<textarea id="response-body" wire:model="responseBody" rows="7" spellcheck="false" class="mt-2 w-full rounded-lg border border-white/10 bg-slate-950/70 px-3 py-2 font-mono text-xs text-white outline-none focus:border-cyan-300/60">{{ $responseBody }}</textarea>
|
||||||
@error('responseBody') <p class="mt-1 text-xs text-rose-300">{{ $message }}</p> @enderror
|
@error('responseBody') <p class="mt-1 text-xs text-rose-300">{{ $message }}</p> @enderror
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="w-full rounded-lg bg-white px-3 py-2.5 text-sm font-semibold text-slate-950 transition hover:bg-cyan-50">Save response</button>
|
<button type="submit" class="w-full cursor-pointer rounded-lg bg-white px-3 py-2.5 text-sm font-semibold text-slate-950 transition hover:bg-cyan-50">Save response</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
@else
|
@else
|
||||||
<section class="rounded-2xl border border-amber-300/15 bg-amber-300/[0.05] p-5 text-sm leading-6 text-amber-100/80">
|
<section class="rounded-xl border border-amber-300/15 bg-amber-300/[0.05] p-5 text-sm leading-6 text-amber-100/80">
|
||||||
This temporary endpoint expires after {{ $endpoint->expires_at?->diffForHumans(null, true) }}. The default response is <code class="font-mono text-amber-200">200 {}</code>.
|
<h3 class="font-medium text-amber-100">Response</h3>
|
||||||
</section>
|
<p class="mt-2">This temporary endpoint expires after {{ $endpoint->expires_at?->diffForHumans(null, true) }}. The default response is <code class="font-mono text-amber-200">200 {}</code>.</p>
|
||||||
@endif
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if ($selectedRequest)
|
|
||||||
<section class="rounded-2xl border border-white/10 bg-white/[0.04] p-5 sm:p-6">
|
|
||||||
<div class="flex flex-col justify-between gap-4 border-b border-white/10 pb-5 sm:flex-row sm:items-start">
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center gap-3"><span class="rounded-md bg-cyan-300/10 px-2 py-1 font-mono text-xs font-medium text-cyan-200">{{ $selectedRequest->method }}</span><span class="font-mono text-xs text-slate-500">{{ $selectedRequest->id }}</span></div>
|
|
||||||
<h2 class="mt-3 break-all font-mono text-sm text-white">{{ $selectedRequest->request_uri }}</h2>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<button type="button" wire:click="deleteRequest('{{ $selectedRequest->id }}')" wire:confirm="Delete this request?" class="text-sm text-rose-300 transition hover:text-rose-200">Delete request</button>
|
|
||||||
<button type="button" wire:click="clearSelectedRequest" class="rounded-lg border border-white/10 px-3 py-2 text-sm text-slate-300 transition hover:border-white/25 hover:text-white">Close</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6 grid gap-6 lg:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<h3 class="text-xs font-medium uppercase tracking-wider text-slate-500">Request metadata</h3>
|
|
||||||
<dl class="mt-3 divide-y divide-white/5 rounded-xl border border-white/10 bg-slate-950/40 px-4 text-sm">
|
|
||||||
<div class="flex justify-between gap-4 py-3"><dt class="text-slate-500">Received</dt><dd class="text-right text-slate-200">{{ $selectedRequest->received_at?->format('Y-m-d H:i:s T') }}</dd></div>
|
|
||||||
<div class="flex justify-between gap-4 py-3"><dt class="text-slate-500">Content-Type</dt><dd class="max-w-[65%] break-all text-right font-mono text-xs text-slate-200">{{ $selectedRequest->content_type ?: '—' }}</dd></div>
|
|
||||||
<div class="flex justify-between gap-4 py-3"><dt class="text-slate-500">Size</dt><dd class="text-right text-slate-200">{{ $selectedRequest->body_size }} bytes</dd></div>
|
|
||||||
<div class="flex justify-between gap-4 py-3"><dt class="text-slate-500">IP address</dt><dd class="text-right font-mono text-xs text-slate-200">{{ $selectedRequest->ip_address ?: '—' }}</dd></div>
|
|
||||||
<div class="flex justify-between gap-4 py-3"><dt class="text-slate-500">User-Agent</dt><dd class="max-w-[65%] break-all text-right text-xs text-slate-200">{{ $selectedRequest->user_agent ?: '—' }}</dd></div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 class="text-xs font-medium uppercase tracking-wider text-slate-500">Query parameters</h3>
|
|
||||||
<pre class="mt-3 max-h-48 overflow-auto rounded-xl border border-white/10 bg-slate-950/70 p-4 font-mono text-xs leading-6 text-slate-300">{{ json_encode($selectedRequest->query_parameters ?? [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6 grid gap-6 lg:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<h3 class="text-xs font-medium uppercase tracking-wider text-slate-500">Headers</h3>
|
|
||||||
<div class="mt-3 max-h-72 overflow-auto rounded-xl border border-white/10 bg-slate-950/70 p-4 font-mono text-xs leading-6">
|
|
||||||
@forelse ($selectedRequest->headers ?? [] as $name => $values)
|
|
||||||
<div class="flex gap-3"><span class="shrink-0 text-cyan-300">{{ $name }}:</span><span class="break-all text-slate-300">{{ implode(', ', (array) $values) }}</span></div>
|
|
||||||
@empty
|
|
||||||
<span class="text-slate-600">No headers captured.</span>
|
|
||||||
@endforelse
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 class="text-xs font-medium uppercase tracking-wider text-slate-500">Body</h3>
|
|
||||||
<pre class="mt-3 max-h-72 overflow-auto rounded-xl border border-white/10 bg-slate-950/70 p-4 font-mono text-xs leading-6 text-emerald-200">{{ $selectedRequest->body ?? '' }}</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if ($selectedRequest->json_payload !== null)
|
|
||||||
<div class="mt-6">
|
|
||||||
<h3 class="text-xs font-medium uppercase tracking-wider text-slate-500">Parsed JSON</h3>
|
|
||||||
<pre class="mt-3 max-h-72 overflow-auto rounded-xl border border-white/10 bg-slate-950/70 p-4 font-mono text-xs leading-6 text-emerald-200">{{ json_encode($selectedRequest->json_payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}</pre>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</section>
|
</section>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<div class="relative isolate overflow-hidden">
|
<div class="relative isolate overflow-hidden">
|
||||||
<div class="absolute inset-x-0 top-0 -z-10 h-[34rem] bg-[radial-gradient(circle_at_top_right,rgba(34,211,238,0.16),transparent_45%),radial-gradient(circle_at_top_left,rgba(99,102,241,0.16),transparent_42%)]"></div>
|
<div class="absolute inset-x-0 top-0 -z-10 h-[34rem] bg-[radial-gradient(circle_at_top_right,rgba(34,211,238,0.16),transparent_45%),radial-gradient(circle_at_top_left,rgba(99,102,241,0.16),transparent_42%)]"></div>
|
||||||
|
|
||||||
<div class="mx-auto grid max-w-7xl gap-16 px-6 py-20 lg:grid-cols-[1.1fr_0.9fr] lg:items-center lg:px-8 lg:py-28">
|
<div class="mx-auto grid gap-16 px-6 py-20 lg:grid-cols-[1.1fr_0.9fr] lg:items-center lg:px-8 lg:py-28">
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-6 inline-flex items-center gap-2 rounded-full border border-cyan-300/20 bg-cyan-300/10 px-3 py-1 text-xs font-medium uppercase tracking-[0.18em] text-cyan-200">
|
<div class="mb-6 inline-flex items-center gap-2 rounded-full border border-cyan-300/20 bg-cyan-300/10 px-3 py-1 text-xs font-medium uppercase tracking-[0.18em] text-cyan-200">
|
||||||
<span class="size-1.5 rounded-full bg-cyan-300"></span>
|
<span class="size-1.5 rounded-full bg-cyan-300"></span>
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mx-auto grid max-w-7xl gap-4 px-6 pb-20 sm:grid-cols-3 lg:px-8">
|
<div class="mx-auto grid gap-4 px-6 pb-20 sm:grid-cols-3 lg:px-8">
|
||||||
@foreach ([['Temporary URLs', 'Start without an account.'], ['Full request context', 'Headers, query and raw body.'], ['Live updates', 'Requests appear as they arrive.']] as [$title, $description])
|
@foreach ([['Temporary URLs', 'Start without an account.'], ['Full request context', 'Headers, query and raw body.'], ['Live updates', 'Requests appear as they arrive.']] as [$title, $description])
|
||||||
<div class="rounded-2xl border border-white/10 bg-white/[0.03] p-5">
|
<div class="rounded-2xl border border-white/10 bg-white/[0.03] p-5">
|
||||||
<h2 class="font-medium text-white">{{ $title }}</h2>
|
<h2 class="font-medium text-white">{{ $title }}</h2>
|
||||||
|
|||||||
@@ -115,6 +115,51 @@ class WebhookInspectorTest extends TestCase
|
|||||||
$this->assertSame('queued', $endpoint->response_body);
|
$this->assertSame('queued', $endpoint->response_body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_private_endpoint_settings_are_available_in_a_modal(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$endpoint = WebhookEndpoint::factory()->ownedBy($user)->create();
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(Inspector::class, ['endpoint' => $endpoint])
|
||||||
|
->set('showEndpointSettings', true)
|
||||||
|
->assertSee('Endpoint settings')
|
||||||
|
->assertSee('Send requests here')
|
||||||
|
->assertSee('Save response');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_selected_requests_expose_browser_xhr_forwarding_controls(): void
|
||||||
|
{
|
||||||
|
$endpoint = WebhookEndpoint::factory()->create();
|
||||||
|
$webhookRequest = WebhookRequest::factory()->forEndpoint($endpoint)->create([
|
||||||
|
'method' => 'POST',
|
||||||
|
'request_uri' => '/hook/test?source=browser',
|
||||||
|
'headers' => ['content-type' => ['application/json'], 'x-request-id' => ['req-123']],
|
||||||
|
'body' => '{"event":"created"}',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$payload = new Inspector;
|
||||||
|
$payload->mount($endpoint);
|
||||||
|
|
||||||
|
$this->assertSame($endpoint->getKey(), $payload->xhrRequestPayload($webhookRequest->getKey())['endpointId']);
|
||||||
|
$this->assertSame(base64_encode($webhookRequest->body), $payload->xhrRequestPayload($webhookRequest->getKey())['body']);
|
||||||
|
|
||||||
|
Livewire::test(Inspector::class, ['endpoint' => $endpoint])
|
||||||
|
->call('selectRequest', $webhookRequest->getKey())
|
||||||
|
->assertSee('Send via XHR')
|
||||||
|
->assertSee('data-xhr-redirect-modal', false)
|
||||||
|
->assertSee('data-xhr-request-headers', false)
|
||||||
|
->call('requestReceived', [
|
||||||
|
'endpointId' => (string) $endpoint->getKey(),
|
||||||
|
'requestId' => (string) $webhookRequest->getKey(),
|
||||||
|
])
|
||||||
|
->assertDispatched(
|
||||||
|
'xhr-request-available',
|
||||||
|
endpointId: (string) $endpoint->getKey(),
|
||||||
|
requestId: (string) $webhookRequest->getKey(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_request_body_is_escaped_in_the_inspector(): void
|
public function test_request_body_is_escaped_in_the_inspector(): void
|
||||||
{
|
{
|
||||||
$endpoint = WebhookEndpoint::factory()->create();
|
$endpoint = WebhookEndpoint::factory()->create();
|
||||||
|
|||||||
Reference in New Issue
Block a user