1702 lines
69 KiB
JavaScript
1702 lines
69 KiB
JavaScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { useNavigate, useParams } from 'react-router-dom';
|
|
import apiClient from '../api/client';
|
|
import { useBusiness } from '../context/BusinessContext';
|
|
|
|
const BASE_PROFILE_KEYS = new Set(['providerName', 'senderId', 'dltEntityId']);
|
|
const PENDING_SENDER_ID_PROFILE_NAME = 'Pending Sender ID';
|
|
const WORKSPACE_TABS = [
|
|
{ id: 'authorization', label: 'Authorization' },
|
|
{ id: 'headers', label: 'Headers' },
|
|
{ id: 'body', label: 'Body' },
|
|
{ id: 'curl', label: 'Raw cURL' },
|
|
];
|
|
const CURL_DATA_FLAGS = new Set(['--data', '--data-raw', '--data-binary', '-d']);
|
|
|
|
function formatAuthMode(value) {
|
|
const normalized = String(value || '').trim().toLowerCase();
|
|
if (!normalized) return 'Not inferred';
|
|
if (normalized === 'api_key') return 'API Key';
|
|
if (normalized === 'bearer') return 'Bearer Token';
|
|
if (normalized === 'basic') return 'Basic Auth';
|
|
return normalized.charAt(0).toUpperCase() + normalized.slice(1);
|
|
}
|
|
|
|
function buildProfilePatchPayload(inputs = [], values = {}) {
|
|
const provider = {};
|
|
const profileInputValues = {};
|
|
|
|
inputs.forEach((input) => {
|
|
const rawValue = String(values[input.key] ?? '').trim();
|
|
if (!rawValue) return;
|
|
|
|
if (BASE_PROFILE_KEYS.has(input.key)) {
|
|
provider[input.key] = input.key === 'senderId' ? rawValue.toUpperCase() : rawValue;
|
|
return;
|
|
}
|
|
|
|
profileInputValues[input.key] = rawValue;
|
|
});
|
|
|
|
return {
|
|
...(Object.keys(provider).length > 0 ? { provider } : {}),
|
|
...(Object.keys(profileInputValues).length > 0 ? { profileInputValues } : {}),
|
|
};
|
|
}
|
|
|
|
function getInputInitialValues(inputs = []) {
|
|
return inputs.reduce((accumulator, input) => {
|
|
accumulator[input.key] = input.value || '';
|
|
return accumulator;
|
|
}, {});
|
|
}
|
|
|
|
function mergeProfileInputs(...groups) {
|
|
const mergedInputs = new Map();
|
|
|
|
groups.forEach((group) => {
|
|
(Array.isArray(group) ? group : []).forEach((input) => {
|
|
const key = String(input?.key || '').trim();
|
|
if (!key || input?.source === 'runtime') return;
|
|
|
|
const current = mergedInputs.get(key) || {};
|
|
mergedInputs.set(key, {
|
|
...current,
|
|
...input,
|
|
key,
|
|
label: input?.label || current.label || key,
|
|
required: input?.required !== false || current.required === true,
|
|
secret: input?.secret === true || current.secret === true,
|
|
hasValue: input?.hasValue === true || current.hasValue === true,
|
|
maskedValue: input?.maskedValue || current.maskedValue || '',
|
|
value: Object.prototype.hasOwnProperty.call(input || {}, 'value')
|
|
? input.value
|
|
: (current.value || ''),
|
|
});
|
|
});
|
|
});
|
|
|
|
return Array.from(mergedInputs.values());
|
|
}
|
|
|
|
function isRelevantPersistentProfileInput(input, missingInputKeys = new Set()) {
|
|
if (!input || input.source === 'runtime') return false;
|
|
if (input.secret !== true) return false;
|
|
|
|
const token = String(input.token || '').trim();
|
|
if (token) return true;
|
|
|
|
return missingInputKeys.has(String(input.key || '').trim());
|
|
}
|
|
|
|
function skipShellIndentation(input, index) {
|
|
let cursor = index;
|
|
|
|
while (cursor < input.length && /[\t \f\v\u00a0]/.test(input[cursor])) {
|
|
cursor += 1;
|
|
}
|
|
|
|
return cursor;
|
|
}
|
|
|
|
function normalizeCurlCommand(command) {
|
|
const input = String(command || '')
|
|
.replace(/\r\n/g, '\n')
|
|
.replace(/\r/g, '\n');
|
|
|
|
let output = '';
|
|
let quote = null;
|
|
|
|
for (let index = 0; index < input.length; index += 1) {
|
|
const char = input[index];
|
|
|
|
if (quote === '\'') {
|
|
output += char;
|
|
if (char === '\'') quote = null;
|
|
continue;
|
|
}
|
|
|
|
if (quote === '"') {
|
|
output += char;
|
|
|
|
if (char === '\\' && index + 1 < input.length) {
|
|
output += input[index + 1];
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (char === '"') {
|
|
quote = null;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (char === '\'' || char === '"') {
|
|
quote = char;
|
|
output += char;
|
|
continue;
|
|
}
|
|
|
|
if (char === '\\') {
|
|
const nextChar = input[index + 1];
|
|
if (nextChar === '\n') {
|
|
output += ' ';
|
|
index = skipShellIndentation(input, index + 2) - 1;
|
|
continue;
|
|
}
|
|
|
|
if (nextChar === 'n') {
|
|
output += ' ';
|
|
index = skipShellIndentation(input, index + 2) - 1;
|
|
continue;
|
|
}
|
|
|
|
if (nextChar === 'r' && input[index + 2] === 'n') {
|
|
output += ' ';
|
|
index = skipShellIndentation(input, index + 3) - 1;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
output += char;
|
|
}
|
|
|
|
return output.trim();
|
|
}
|
|
|
|
function tokenizeCurlCommand(command) {
|
|
const input = normalizeCurlCommand(command);
|
|
const tokens = [];
|
|
let current = '';
|
|
let quote = null;
|
|
let escaping = false;
|
|
|
|
for (let index = 0; index < input.length; index += 1) {
|
|
const char = input[index];
|
|
|
|
if (escaping) {
|
|
current += char;
|
|
escaping = false;
|
|
continue;
|
|
}
|
|
|
|
if (quote === '\'') {
|
|
if (char === '\'') {
|
|
quote = null;
|
|
} else {
|
|
current += char;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (quote === '"') {
|
|
if (char === '"') {
|
|
quote = null;
|
|
continue;
|
|
}
|
|
|
|
if (char === '\\') {
|
|
const nextChar = input[index + 1];
|
|
if (nextChar) {
|
|
current += nextChar;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
current += char;
|
|
continue;
|
|
}
|
|
|
|
if (char === '\\') {
|
|
escaping = true;
|
|
continue;
|
|
}
|
|
|
|
if (char === '\'' || char === '"') {
|
|
quote = char;
|
|
continue;
|
|
}
|
|
|
|
if (/\s/.test(char)) {
|
|
if (current) {
|
|
tokens.push(current);
|
|
current = '';
|
|
}
|
|
continue;
|
|
}
|
|
|
|
current += char;
|
|
}
|
|
|
|
if (current) {
|
|
tokens.push(current);
|
|
}
|
|
|
|
return tokens;
|
|
}
|
|
|
|
function getCurlDataArguments(args = []) {
|
|
const dataArgs = [];
|
|
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const argument = args[index];
|
|
|
|
if (CURL_DATA_FLAGS.has(argument) && index + 1 < args.length) {
|
|
dataArgs.push(String(args[index + 1] || ''));
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
const flag = Array.from(CURL_DATA_FLAGS).find((entry) => argument.startsWith(`${entry}=`));
|
|
if (flag) {
|
|
dataArgs.push(argument.slice(flag.length + 1));
|
|
}
|
|
}
|
|
|
|
return dataArgs;
|
|
}
|
|
|
|
function parseStructuredBodyValue(value = '') {
|
|
const trimmed = String(value || '').trim();
|
|
if (!trimmed) return null;
|
|
|
|
try {
|
|
return JSON.parse(trimmed);
|
|
} catch {
|
|
if (!(trimmed.startsWith('"') && trimmed.endsWith('"'))) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
try {
|
|
const parsedString = JSON.parse(trimmed);
|
|
if (typeof parsedString !== 'string') {
|
|
return parsedString;
|
|
}
|
|
return JSON.parse(parsedString);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function isLikelyFormEncoded(value = '') {
|
|
const normalized = String(value || '').trim();
|
|
return normalized.includes('=') && !normalized.startsWith('{') && !normalized.startsWith('[');
|
|
}
|
|
|
|
function buildBodyPreviewFromCurl(curlStr = '') {
|
|
const source = String(curlStr || '').trim();
|
|
if (!source) {
|
|
return { type: 'empty', label: 'Body', content: '' };
|
|
}
|
|
|
|
if (!source.toLowerCase().startsWith('curl')) {
|
|
return { type: 'empty', label: 'Body', content: '' };
|
|
}
|
|
|
|
try {
|
|
const tokens = tokenizeCurlCommand(source);
|
|
if (tokens.length === 0 || tokens[0] !== 'curl') {
|
|
return { type: 'empty', label: 'Body', content: '' };
|
|
}
|
|
|
|
const dataArgs = getCurlDataArguments(tokens.slice(1));
|
|
if (dataArgs.length === 0) {
|
|
return { type: 'none', label: 'No Body', content: 'No request body detected.' };
|
|
}
|
|
|
|
if (dataArgs.length === 1) {
|
|
const structuredValue = parseStructuredBodyValue(dataArgs[0]);
|
|
if (structuredValue !== null) {
|
|
return {
|
|
type: 'json',
|
|
label: 'JSON Body',
|
|
content: JSON.stringify(structuredValue, null, 2),
|
|
};
|
|
}
|
|
}
|
|
|
|
const allFormEncoded = dataArgs.every((entry) => isLikelyFormEncoded(entry) && parseStructuredBodyValue(entry) === null);
|
|
if (allFormEncoded) {
|
|
const formLines = [];
|
|
dataArgs.forEach((entry) => {
|
|
const params = new URLSearchParams(String(entry || ''));
|
|
Array.from(params.entries()).forEach(([key, value]) => {
|
|
formLines.push(`${key}=${value}`);
|
|
});
|
|
});
|
|
|
|
return {
|
|
type: 'form',
|
|
label: 'Form Body',
|
|
content: formLines.join('\n'),
|
|
};
|
|
}
|
|
|
|
return {
|
|
type: 'text',
|
|
label: dataArgs.length === 1 ? 'Raw Body' : 'Combined Body',
|
|
content: dataArgs.join('\n\n'),
|
|
};
|
|
} catch {
|
|
return {
|
|
type: 'invalid',
|
|
label: 'Body',
|
|
content: 'Unable to parse the request body from this cURL.',
|
|
};
|
|
}
|
|
}
|
|
|
|
function cloneRequestPreview(requestPreview = null) {
|
|
if (!requestPreview || typeof requestPreview !== 'object') {
|
|
return {
|
|
method: 'POST',
|
|
url: '',
|
|
maskedUrl: '',
|
|
urlMasked: false,
|
|
headers: [],
|
|
};
|
|
}
|
|
|
|
return {
|
|
method: requestPreview.method || 'POST',
|
|
url: requestPreview.url || '',
|
|
maskedUrl: requestPreview.maskedUrl || requestPreview.url || '',
|
|
urlMasked: requestPreview.urlMasked === true,
|
|
headers: Array.isArray(requestPreview.headers)
|
|
? requestPreview.headers.map((header, index) => ({
|
|
id: header?.id || `header-${index}`,
|
|
key: header?.key || '',
|
|
value: header?.value || '',
|
|
maskedValue: header?.maskedValue || header?.value || '',
|
|
masked: header?.masked === true,
|
|
secret: header?.secret === true,
|
|
enabled: header?.enabled !== false,
|
|
}))
|
|
: [],
|
|
};
|
|
}
|
|
|
|
function areRequestPreviewsEqual(left = null, right = null) {
|
|
const leftRequest = cloneRequestPreview(left);
|
|
const rightRequest = cloneRequestPreview(right);
|
|
|
|
if (leftRequest.url !== rightRequest.url) return false;
|
|
if (leftRequest.headers.length !== rightRequest.headers.length) return false;
|
|
|
|
return leftRequest.headers.every((header, index) => {
|
|
const comparison = rightRequest.headers[index];
|
|
if (!comparison) return false;
|
|
|
|
return header.id === comparison.id
|
|
&& header.key === comparison.key
|
|
&& header.value === comparison.value
|
|
&& header.enabled === comparison.enabled;
|
|
});
|
|
}
|
|
|
|
function hasInputFormChanges(initialValues = {}, currentValues = {}) {
|
|
const keys = new Set([
|
|
...Object.keys(initialValues || {}),
|
|
...Object.keys(currentValues || {}),
|
|
]);
|
|
|
|
for (const key of keys) {
|
|
if (String(initialValues?.[key] ?? '') !== String(currentValues?.[key] ?? '')) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function mergeRevealIntoRequestPreview(targetRequest, revealRequest, options = {}) {
|
|
const baseRequest = cloneRequestPreview(targetRequest);
|
|
const revealedRequest = cloneRequestPreview(revealRequest);
|
|
const force = options.force === true;
|
|
|
|
return {
|
|
...baseRequest,
|
|
url: force || baseRequest.urlMasked ? (revealedRequest.url || baseRequest.url) : baseRequest.url,
|
|
maskedUrl: revealedRequest.maskedUrl || baseRequest.maskedUrl,
|
|
urlMasked: force ? false : baseRequest.urlMasked,
|
|
headers: baseRequest.headers.map((header, index) => {
|
|
const revealedHeader = revealedRequest.headers.find((candidate) => candidate.id === header.id) || revealedRequest.headers[index];
|
|
if (!revealedHeader) return header;
|
|
const remainsMaskable = header.masked === true || revealedHeader.masked === true;
|
|
|
|
if (force || remainsMaskable) {
|
|
return {
|
|
...header,
|
|
value: revealedHeader.value,
|
|
maskedValue: revealedHeader.maskedValue || header.maskedValue,
|
|
masked: remainsMaskable,
|
|
secret: revealedHeader.secret ?? header.secret,
|
|
};
|
|
}
|
|
|
|
return {
|
|
...header,
|
|
maskedValue: revealedHeader.maskedValue || header.maskedValue,
|
|
secret: revealedHeader.secret ?? header.secret,
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
|
|
function DeleteProfileModal({ preview, loading, deleting, onCancel, onConfirm }) {
|
|
if (!preview) return null;
|
|
|
|
const impactedTemplates = Array.isArray(preview.impactedTemplates) ? preview.impactedTemplates : [];
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/55 p-4 backdrop-blur-sm">
|
|
<div className="w-full max-w-xl rounded-2xl border border-gray-200 bg-white shadow-xl">
|
|
<div className="border-b border-gray-200 px-6 py-5">
|
|
<h3 className="text-lg font-bold text-gray-900">Delete cURL Profile</h3>
|
|
<p className="mt-1 text-sm text-gray-500">
|
|
{preview.profile?.name || 'This profile'} will be deleted. Bound templates will be removed, but event definitions will stay.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-4 px-6 py-5">
|
|
{loading ? (
|
|
<div className="flex items-center gap-3 rounded-xl border border-gray-200 bg-gray-50 px-4 py-4 text-sm text-gray-600">
|
|
<span className="h-5 w-5 animate-spin rounded-full border-2 border-gray-200 border-t-primary-blue" />
|
|
Loading bound templates…
|
|
</div>
|
|
) : impactedTemplates.length > 0 ? (
|
|
<>
|
|
<p className="text-sm font-semibold text-gray-900">Affected templates</p>
|
|
<div className="max-h-72 space-y-3 overflow-y-auto">
|
|
{impactedTemplates.map((template) => (
|
|
<div key={`${template.eventSlug}-${template.status}`} className="rounded-xl border border-gray-200 bg-gray-50 px-4 py-3">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<p className="font-medium text-gray-900">{template.eventLabel || template.eventSlug}</p>
|
|
<span className="rounded-full border border-gray-200 bg-white px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-gray-500">
|
|
{template.status || 'generated'}
|
|
</span>
|
|
</div>
|
|
{template.templateId && (
|
|
<p className="mt-2 font-mono text-xs text-gray-500">DLT Template ID: {template.templateId}</p>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="rounded-xl border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-600">
|
|
No templates are currently bound to this profile.
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex gap-3 border-t border-gray-200 px-6 py-4">
|
|
<button
|
|
type="button"
|
|
onClick={onCancel}
|
|
disabled={loading || deleting}
|
|
className="flex-1 rounded-lg border border-gray-300 px-4 py-2 text-sm font-semibold text-gray-700 transition hover:bg-gray-50 disabled:opacity-50"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onConfirm}
|
|
disabled={loading || deleting}
|
|
className="flex-1 rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-red-700 disabled:opacity-50"
|
|
>
|
|
{deleting ? 'Deleting…' : 'Delete Profile'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function GlobalSms() {
|
|
const { businessId } = useParams();
|
|
const navigate = useNavigate();
|
|
const { isSetupComplete, setHasGlobalSms, setIsSetupComplete } = useBusiness();
|
|
const hasLoadedProfilesRef = useRef(false);
|
|
const requestPreviewVersionRef = useRef({});
|
|
const urlEditorRef = useRef(null);
|
|
const [initialLoading, setInitialLoading] = useState(true);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [profiles, setProfiles] = useState([]);
|
|
const [activeProfileId, setActiveProfileId] = useState(null);
|
|
const [selectedProfileId, setSelectedProfileId] = useState(null);
|
|
const [workspaceTab, setWorkspaceTab] = useState('authorization');
|
|
const [attemptedSave, setAttemptedSave] = useState(false);
|
|
const [showImportModal, setShowImportModal] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [savingInputs, setSavingInputs] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [success, setSuccess] = useState('');
|
|
const [formCurl, setFormCurl] = useState('');
|
|
const [formSetActive, setFormSetActive] = useState(true);
|
|
const [inputForm, setInputForm] = useState({});
|
|
const [requestDrafts, setRequestDrafts] = useState({});
|
|
const [savedRequestPreviews, setSavedRequestPreviews] = useState({});
|
|
const [editingUrlProfileId, setEditingUrlProfileId] = useState('');
|
|
const [editingHeader, setEditingHeader] = useState(null);
|
|
const [revealedProfiles, setRevealedProfiles] = useState({});
|
|
const [curlVisibleProfileIds, setCurlVisibleProfileIds] = useState({});
|
|
const [headerVisibleProfileIds, setHeaderVisibleProfileIds] = useState({});
|
|
const [inputVisibility, setInputVisibility] = useState({});
|
|
const [deletePreview, setDeletePreview] = useState(null);
|
|
const [loadingDeleteImpactId, setLoadingDeleteImpactId] = useState('');
|
|
const [deletingProfileId, setDeletingProfileId] = useState('');
|
|
|
|
const selectedProfile = useMemo(
|
|
() => profiles.find((profile) => profile.id === selectedProfileId)
|
|
|| profiles.find((profile) => profile.id === activeProfileId)
|
|
|| profiles[0]
|
|
|| null,
|
|
[profiles, selectedProfileId, activeProfileId],
|
|
);
|
|
const profileInputs = useMemo(
|
|
() => (selectedProfile?.profileInputs || []).filter((input) => input.source !== 'runtime'),
|
|
[selectedProfile],
|
|
);
|
|
const missingInputs = useMemo(
|
|
() => selectedProfile?.executionReadiness?.missingProfileInputs || [],
|
|
[selectedProfile],
|
|
);
|
|
const missingInputKeys = useMemo(
|
|
() => new Set(missingInputs.map((input) => input.key)),
|
|
[missingInputs],
|
|
);
|
|
const authorizationInputs = useMemo(
|
|
() => mergeProfileInputs(
|
|
profileInputs.filter((input) => isRelevantPersistentProfileInput(input, missingInputKeys)),
|
|
missingInputs,
|
|
),
|
|
[profileInputs, missingInputKeys, missingInputs],
|
|
);
|
|
const isSelectedProfileActive = selectedProfile?.id === activeProfileId;
|
|
const initialInputValues = useMemo(() => getInputInitialValues(authorizationInputs), [authorizationInputs]);
|
|
const selectedSavedRequest = useMemo(
|
|
() => (selectedProfile ? cloneRequestPreview(savedRequestPreviews[selectedProfile.id] || selectedProfile.requestPreview) : null),
|
|
[savedRequestPreviews, selectedProfile],
|
|
);
|
|
const selectedRequestDraft = useMemo(
|
|
() => (selectedProfile ? cloneRequestPreview(requestDrafts[selectedProfile.id] || selectedProfile.requestPreview) : null),
|
|
[requestDrafts, selectedProfile],
|
|
);
|
|
const hasRequestChanges = useMemo(
|
|
() => (selectedProfile ? !areRequestPreviewsEqual(selectedRequestDraft, selectedSavedRequest) : false),
|
|
[selectedProfile, selectedRequestDraft, selectedSavedRequest],
|
|
);
|
|
const hasInputChanges = useMemo(
|
|
() => (selectedProfile ? hasInputFormChanges(initialInputValues, inputForm) : false),
|
|
[initialInputValues, inputForm, selectedProfile],
|
|
);
|
|
const hasPendingProfileChanges = selectedProfile ? (hasRequestChanges || hasInputChanges) : false;
|
|
const eventsPath = `/${businessId}/events`;
|
|
|
|
const loadProfiles = useCallback(async ({ background = false } = {}) => {
|
|
try {
|
|
if (background) {
|
|
setRefreshing(true);
|
|
} else {
|
|
setInitialLoading(true);
|
|
}
|
|
|
|
const response = await apiClient.get(`/api/businesses/${businessId}/global-sms/profiles`);
|
|
const fetchedProfiles = response.data?.profiles || [];
|
|
const nextActiveProfileId = response.data?.activeProfileId || null;
|
|
const nextActiveProfile = fetchedProfiles.find((profile) => profile.id === nextActiveProfileId) || null;
|
|
const nextIsSetupComplete = nextActiveProfile?.executionReadiness?.isSetupComplete === true;
|
|
|
|
setProfiles(fetchedProfiles);
|
|
setActiveProfileId(nextActiveProfileId);
|
|
setHasGlobalSms(fetchedProfiles.length > 0);
|
|
setIsSetupComplete(nextIsSetupComplete);
|
|
setError('');
|
|
|
|
return {
|
|
profiles: fetchedProfiles,
|
|
activeProfile: nextActiveProfile,
|
|
activeProfileId: nextActiveProfileId,
|
|
hasProfile: !!nextActiveProfile,
|
|
complete: nextIsSetupComplete,
|
|
};
|
|
} catch {
|
|
setError('Failed to load cURL profiles');
|
|
setHasGlobalSms(false);
|
|
setIsSetupComplete(false);
|
|
return {
|
|
profiles: [],
|
|
activeProfile: null,
|
|
activeProfileId: null,
|
|
hasProfile: false,
|
|
complete: false,
|
|
};
|
|
} finally {
|
|
if (background) {
|
|
setRefreshing(false);
|
|
} else {
|
|
hasLoadedProfilesRef.current = true;
|
|
setInitialLoading(false);
|
|
}
|
|
}
|
|
}, [businessId, setHasGlobalSms, setIsSetupComplete]);
|
|
|
|
useEffect(() => {
|
|
loadProfiles({ background: hasLoadedProfilesRef.current });
|
|
}, [loadProfiles]);
|
|
|
|
useEffect(() => {
|
|
if (!profiles.length) {
|
|
setSelectedProfileId(null);
|
|
return;
|
|
}
|
|
|
|
if (selectedProfileId && profiles.some((profile) => profile.id === selectedProfileId)) {
|
|
return;
|
|
}
|
|
|
|
setSelectedProfileId(activeProfileId || profiles[0]?.id || null);
|
|
}, [profiles, activeProfileId, selectedProfileId]);
|
|
|
|
useEffect(() => {
|
|
setAttemptedSave(false);
|
|
setInputForm(initialInputValues);
|
|
}, [initialInputValues, selectedProfile?.id]);
|
|
|
|
useEffect(() => {
|
|
if (!success) return undefined;
|
|
|
|
const timeoutId = window.setTimeout(() => setSuccess(''), 2800);
|
|
return () => window.clearTimeout(timeoutId);
|
|
}, [success]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedProfile?.id) return;
|
|
|
|
const nextVersion = selectedProfile.updatedAt || `${selectedProfile.id}:${selectedProfile.requestPreview?.headers?.length || 0}`;
|
|
if (requestPreviewVersionRef.current[selectedProfile.id] === nextVersion) return;
|
|
|
|
const nextRequestPreview = cloneRequestPreview(selectedProfile.requestPreview);
|
|
requestPreviewVersionRef.current[selectedProfile.id] = nextVersion;
|
|
setSavedRequestPreviews((current) => ({
|
|
...current,
|
|
[selectedProfile.id]: nextRequestPreview,
|
|
}));
|
|
setRequestDrafts((current) => ({
|
|
...current,
|
|
[selectedProfile.id]: cloneRequestPreview(nextRequestPreview),
|
|
}));
|
|
setRevealedProfiles((current) => {
|
|
if (!current[selectedProfile.id]) return current;
|
|
const nextState = { ...current };
|
|
delete nextState[selectedProfile.id];
|
|
return nextState;
|
|
});
|
|
setCurlVisibleProfileIds((current) => {
|
|
if (!current[selectedProfile.id]) return current;
|
|
const nextState = { ...current };
|
|
delete nextState[selectedProfile.id];
|
|
return nextState;
|
|
});
|
|
setHeaderVisibleProfileIds((current) => {
|
|
if (!current[selectedProfile.id]) return current;
|
|
const nextState = { ...current };
|
|
delete nextState[selectedProfile.id];
|
|
return nextState;
|
|
});
|
|
}, [selectedProfile]);
|
|
|
|
const clearTransientRevealState = useCallback(() => {
|
|
setCurlVisibleProfileIds({});
|
|
setHeaderVisibleProfileIds({});
|
|
setInputVisibility({});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
setEditingHeader(null);
|
|
setEditingUrlProfileId('');
|
|
clearTransientRevealState();
|
|
}, [clearTransientRevealState, workspaceTab, selectedProfile?.id]);
|
|
|
|
useEffect(() => {
|
|
const handleHide = () => {
|
|
setEditingHeader(null);
|
|
setEditingUrlProfileId('');
|
|
clearTransientRevealState();
|
|
};
|
|
|
|
const handleVisibilityChange = () => {
|
|
if (document.hidden) {
|
|
handleHide();
|
|
}
|
|
};
|
|
|
|
window.addEventListener('blur', handleHide);
|
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
|
|
return () => {
|
|
window.removeEventListener('blur', handleHide);
|
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
};
|
|
}, [clearTransientRevealState]);
|
|
|
|
useEffect(() => {
|
|
if (!editingUrlProfileId || !urlEditorRef.current) return;
|
|
urlEditorRef.current.focus();
|
|
urlEditorRef.current.select();
|
|
}, [editingUrlProfileId, selectedProfile?.id, selectedRequestDraft?.url, selectedRequestDraft?.maskedUrl, selectedRequestDraft?.urlMasked]);
|
|
|
|
const ensureRevealData = useCallback(async (profileId) => {
|
|
if (revealedProfiles[profileId]) return revealedProfiles[profileId];
|
|
|
|
const response = await apiClient.get(`/api/businesses/${businessId}/global-sms/profiles/${profileId}/reveal`);
|
|
setRevealedProfiles((current) => ({ ...current, [profileId]: response.data }));
|
|
return response.data;
|
|
}, [businessId, revealedProfiles]);
|
|
|
|
const applyRevealedRequestPreview = useCallback((profileId, revealRequestPreview) => {
|
|
if (!profileId || !revealRequestPreview) return;
|
|
|
|
setSavedRequestPreviews((current) => ({
|
|
...current,
|
|
[profileId]: mergeRevealIntoRequestPreview(current[profileId], revealRequestPreview, { force: true }),
|
|
}));
|
|
setRequestDrafts((current) => ({
|
|
...current,
|
|
[profileId]: mergeRevealIntoRequestPreview(current[profileId], revealRequestPreview),
|
|
}));
|
|
}, []);
|
|
|
|
const updateRequestDraft = useCallback((profileId, updater) => {
|
|
if (!profileId) return;
|
|
|
|
setRequestDrafts((current) => {
|
|
const baseDraft = cloneRequestPreview(current[profileId] || selectedProfile?.requestPreview);
|
|
const nextDraft = typeof updater === 'function' ? updater(baseDraft) : updater;
|
|
return {
|
|
...current,
|
|
[profileId]: cloneRequestPreview(nextDraft),
|
|
};
|
|
});
|
|
}, [selectedProfile]);
|
|
|
|
const mergeRequestPayloadWithReveal = useCallback((draftRequest, savedRequest, revealRequest) => {
|
|
const draft = cloneRequestPreview(draftRequest);
|
|
const saved = cloneRequestPreview(savedRequest);
|
|
const revealed = cloneRequestPreview(revealRequest);
|
|
|
|
return {
|
|
url: saved.urlMasked && draft.url === saved.url ? (revealed.url || draft.url) : draft.url,
|
|
headers: draft.headers.map((header, index) => {
|
|
const savedHeader = saved.headers.find((candidate) => candidate.id === header.id) || saved.headers[index];
|
|
const revealedHeader = revealed.headers.find((candidate) => candidate.id === header.id) || revealed.headers[index];
|
|
const shouldRestoreRevealedValue = savedHeader?.masked === true && header.value === savedHeader.value;
|
|
|
|
return {
|
|
id: header.id,
|
|
key: header.key,
|
|
value: shouldRestoreRevealedValue ? (revealedHeader?.value || header.value) : header.value,
|
|
enabled: header.enabled !== false,
|
|
};
|
|
}),
|
|
};
|
|
}, []);
|
|
|
|
async function handleSubmit(event) {
|
|
event.preventDefault();
|
|
if (!formCurl.trim()) return;
|
|
|
|
setSaving(true);
|
|
setError('');
|
|
setSuccess('');
|
|
const shouldAutoAdvance = !isSetupComplete;
|
|
|
|
try {
|
|
const response = await apiClient.post(`/api/businesses/${businessId}/global-sms/profiles`, {
|
|
rawCurl: formCurl.trim(),
|
|
setActive: formSetActive,
|
|
});
|
|
const createdProfileId = response.data?.id || response.data?.profile?.id || null;
|
|
|
|
setFormCurl('');
|
|
setFormSetActive(true);
|
|
setSuccess('Profile created successfully.');
|
|
setShowImportModal(false);
|
|
|
|
const nextState = await loadProfiles({ background: true });
|
|
const nextSelectedProfileId = createdProfileId || nextState.activeProfileId || nextState.profiles[0]?.id || null;
|
|
const createdProfile = nextState.profiles.find((profile) => profile.id === nextSelectedProfileId) || null;
|
|
|
|
setSelectedProfileId(nextSelectedProfileId);
|
|
setWorkspaceTab(createdProfile?.executionReadiness?.missingProfileInputs?.length ? 'authorization' : 'headers');
|
|
|
|
if (shouldAutoAdvance && nextState.complete) {
|
|
navigate(eventsPath);
|
|
}
|
|
} catch (saveError) {
|
|
setError(saveError.response?.data?.error || 'Failed to save cURL profile');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handleActivate(profileId) {
|
|
const shouldAutoAdvance = !isSetupComplete;
|
|
setError('');
|
|
setSuccess('');
|
|
|
|
try {
|
|
await apiClient.post(`/api/businesses/${businessId}/global-sms/profiles/${profileId}/activate`);
|
|
const nextState = await loadProfiles({ background: true });
|
|
setSelectedProfileId(profileId);
|
|
setSuccess('Active profile updated.');
|
|
|
|
if (shouldAutoAdvance && nextState.complete) {
|
|
navigate(eventsPath);
|
|
}
|
|
} catch (activateError) {
|
|
setError(activateError.response?.data?.error || 'Failed to activate profile');
|
|
}
|
|
}
|
|
|
|
async function handleCopyCurl(profile) {
|
|
try {
|
|
const revealData = await ensureRevealData(profile.id);
|
|
const textToCopy = revealData?.rawCurl || profile.maskedCurl || '';
|
|
if (!textToCopy) return;
|
|
|
|
await navigator.clipboard.writeText(textToCopy);
|
|
setSuccess(`Copied ${profile.name} cURL.`);
|
|
} catch {
|
|
setError('Failed to copy the cURL command.');
|
|
}
|
|
}
|
|
|
|
async function handleToggleCurl(profile) {
|
|
setError('');
|
|
|
|
if (!curlVisibleProfileIds[profile.id]) {
|
|
try {
|
|
const revealData = await ensureRevealData(profile.id);
|
|
applyRevealedRequestPreview(profile.id, revealData?.requestPreview);
|
|
} catch (revealError) {
|
|
setError(revealError.response?.data?.error || 'Failed to reveal stored cURL');
|
|
return;
|
|
}
|
|
}
|
|
|
|
setCurlVisibleProfileIds((current) => ({
|
|
...current,
|
|
[profile.id]: !current[profile.id],
|
|
}));
|
|
}
|
|
|
|
async function handleToggleHeaderReveal(profile, header) {
|
|
if (!profile?.id || !header?.id || header.masked !== true) return;
|
|
|
|
setError('');
|
|
|
|
const currentVisibility = headerVisibleProfileIds[profile.id]?.[header.id] === true;
|
|
if (!currentVisibility) {
|
|
try {
|
|
const revealData = await ensureRevealData(profile.id);
|
|
applyRevealedRequestPreview(profile.id, revealData?.requestPreview);
|
|
} catch (revealError) {
|
|
setError(revealError.response?.data?.error || 'Failed to reveal stored header value');
|
|
return;
|
|
}
|
|
}
|
|
|
|
setHeaderVisibleProfileIds((current) => ({
|
|
...current,
|
|
[profile.id]: {
|
|
...(current[profile.id] || {}),
|
|
[header.id]: !currentVisibility,
|
|
},
|
|
}));
|
|
}
|
|
|
|
async function handleToggleInputVisibility(input) {
|
|
if (!selectedProfile?.id || !input?.key || input.secret !== true) return;
|
|
|
|
const isVisible = inputVisibility[input.key] === true;
|
|
if (!isVisible && input.hasValue) {
|
|
try {
|
|
await ensureRevealData(selectedProfile.id);
|
|
} catch (revealError) {
|
|
setError(revealError.response?.data?.error || 'Failed to reveal stored authorization value');
|
|
return;
|
|
}
|
|
}
|
|
|
|
setInputVisibility((current) => ({
|
|
...current,
|
|
[input.key]: !isVisible,
|
|
}));
|
|
}
|
|
|
|
async function handleBeginUrlEdit() {
|
|
if (!selectedProfile?.id) return;
|
|
|
|
if (selectedSavedRequest?.urlMasked) {
|
|
try {
|
|
const revealData = await ensureRevealData(selectedProfile.id);
|
|
applyRevealedRequestPreview(selectedProfile.id, revealData?.requestPreview);
|
|
} catch (revealError) {
|
|
setError(revealError.response?.data?.error || 'Failed to reveal stored request URL');
|
|
return;
|
|
}
|
|
}
|
|
|
|
setEditingUrlProfileId(selectedProfile.id);
|
|
}
|
|
|
|
async function handleBeginHeaderEdit(headerId, field, header) {
|
|
if (!selectedProfile?.id) return;
|
|
|
|
if (field === 'value' && header?.masked) {
|
|
try {
|
|
const revealData = await ensureRevealData(selectedProfile.id);
|
|
applyRevealedRequestPreview(selectedProfile.id, revealData?.requestPreview);
|
|
} catch (revealError) {
|
|
setError(revealError.response?.data?.error || 'Failed to reveal stored header value');
|
|
return;
|
|
}
|
|
|
|
setHeaderVisibleProfileIds((current) => ({
|
|
...current,
|
|
[selectedProfile.id]: {
|
|
...(current[selectedProfile.id] || {}),
|
|
[headerId]: true,
|
|
},
|
|
}));
|
|
}
|
|
|
|
setEditingHeader({
|
|
profileId: selectedProfile.id,
|
|
headerId,
|
|
field,
|
|
});
|
|
}
|
|
|
|
async function handleSaveSelectedProfile() {
|
|
if (!selectedProfile?.id) return;
|
|
if (!hasPendingProfileChanges) return;
|
|
|
|
setSavingInputs(true);
|
|
setError('');
|
|
setSuccess('');
|
|
const shouldAutoAdvance = !isSetupComplete && isSelectedProfileActive;
|
|
|
|
try {
|
|
const payload = {};
|
|
|
|
if (hasRequestChanges && selectedRequestDraft && selectedSavedRequest) {
|
|
let requestPayload = {
|
|
url: selectedRequestDraft.url,
|
|
headers: selectedRequestDraft.headers.map((header) => ({
|
|
id: header.id,
|
|
key: header.key,
|
|
value: header.value,
|
|
enabled: header.enabled !== false,
|
|
})),
|
|
};
|
|
|
|
const needsRevealMerge = selectedSavedRequest.urlMasked || selectedSavedRequest.headers.some((header) => header.masked);
|
|
if (needsRevealMerge) {
|
|
const revealData = await ensureRevealData(selectedProfile.id);
|
|
applyRevealedRequestPreview(selectedProfile.id, revealData?.requestPreview);
|
|
requestPayload = mergeRequestPayloadWithReveal(selectedRequestDraft, selectedSavedRequest, revealData?.requestPreview);
|
|
}
|
|
|
|
payload.request = requestPayload;
|
|
}
|
|
|
|
if (hasInputChanges) {
|
|
Object.assign(payload, buildProfilePatchPayload(authorizationInputs, inputForm));
|
|
}
|
|
|
|
if (!Object.keys(payload).length) {
|
|
return;
|
|
}
|
|
|
|
await apiClient.patch(`/api/businesses/${businessId}/global-sms/profiles/${selectedProfile.id}`, payload);
|
|
const nextState = await loadProfiles({ background: true });
|
|
setSelectedProfileId(selectedProfile.id);
|
|
setSuccess('Changes saved.');
|
|
|
|
if (shouldAutoAdvance && nextState.complete) {
|
|
navigate(eventsPath);
|
|
}
|
|
} catch (patchError) {
|
|
setError(patchError.response?.data?.error || 'Failed to save profile changes');
|
|
} finally {
|
|
setSavingInputs(false);
|
|
}
|
|
}
|
|
|
|
async function handleDeleteRequest(profile) {
|
|
setError('');
|
|
setDeletePreview({
|
|
profile: {
|
|
id: profile.id,
|
|
name: profile.name,
|
|
},
|
|
impactedTemplates: null,
|
|
});
|
|
setLoadingDeleteImpactId(profile.id);
|
|
|
|
try {
|
|
const response = await apiClient.get(`/api/businesses/${businessId}/global-sms/profiles/${profile.id}/delete-impact`);
|
|
setDeletePreview(response.data);
|
|
} catch (deleteImpactError) {
|
|
setDeletePreview(null);
|
|
setError(deleteImpactError.response?.data?.error || 'Failed to load delete impact');
|
|
} finally {
|
|
setLoadingDeleteImpactId('');
|
|
}
|
|
}
|
|
|
|
async function handleDeleteConfirm() {
|
|
if (!deletePreview?.profile?.id) return;
|
|
|
|
const deletedProfileId = deletePreview.profile.id;
|
|
setDeletingProfileId(deletedProfileId);
|
|
setError('');
|
|
setSuccess('');
|
|
|
|
try {
|
|
await apiClient.delete(`/api/businesses/${businessId}/global-sms/profiles/${deletedProfileId}`);
|
|
setDeletePreview(null);
|
|
setRevealedProfiles((current) => {
|
|
const nextState = { ...current };
|
|
delete nextState[deletedProfileId];
|
|
return nextState;
|
|
});
|
|
setCurlVisibleProfileIds((current) => {
|
|
const nextState = { ...current };
|
|
delete nextState[deletedProfileId];
|
|
return nextState;
|
|
});
|
|
setHeaderVisibleProfileIds((current) => {
|
|
const nextState = { ...current };
|
|
delete nextState[deletedProfileId];
|
|
return nextState;
|
|
});
|
|
setSavedRequestPreviews((current) => {
|
|
const nextState = { ...current };
|
|
delete nextState[deletedProfileId];
|
|
return nextState;
|
|
});
|
|
setRequestDrafts((current) => {
|
|
const nextState = { ...current };
|
|
delete nextState[deletedProfileId];
|
|
return nextState;
|
|
});
|
|
const nextState = await loadProfiles({ background: true });
|
|
setSelectedProfileId(nextState.activeProfileId || nextState.profiles[0]?.id || null);
|
|
setSuccess('Profile deleted successfully.');
|
|
} catch (deleteError) {
|
|
setError(deleteError.response?.data?.error || 'Failed to delete profile');
|
|
} finally {
|
|
setDeletingProfileId('');
|
|
}
|
|
}
|
|
|
|
const selectedRevealData = selectedProfile ? revealedProfiles[selectedProfile.id] : null;
|
|
const isSelectedCurlVisible = selectedProfile ? curlVisibleProfileIds[selectedProfile.id] === true : false;
|
|
const selectedDisplayCurl = selectedProfile
|
|
? (isSelectedCurlVisible
|
|
? (selectedRevealData?.rawCurl || selectedProfile.maskedCurl || selectedProfile.curlAnalysis?.normalizedCurlTemplate || '')
|
|
: (selectedProfile.maskedCurl || selectedProfile.curlAnalysis?.normalizedCurlTemplate || ''))
|
|
: '';
|
|
const selectedBodyPreview = useMemo(
|
|
() => buildBodyPreviewFromCurl(selectedProfile ? selectedDisplayCurl : formCurl),
|
|
[formCurl, selectedDisplayCurl, selectedProfile],
|
|
);
|
|
const selectedRevealProfileInputs = useMemo(
|
|
() => Object.fromEntries(
|
|
(selectedRevealData?.profileInputs || []).map((input) => [input.key, input]),
|
|
),
|
|
[selectedRevealData],
|
|
);
|
|
const selectedHeaderVisibility = selectedProfile ? (headerVisibleProfileIds[selectedProfile.id] || {}) : {};
|
|
const selectedRequestUrl = selectedRequestDraft
|
|
? ((editingUrlProfileId === selectedProfile?.id || !selectedRequestDraft.urlMasked)
|
|
? selectedRequestDraft.url
|
|
: selectedRequestDraft.maskedUrl)
|
|
: '';
|
|
|
|
const handleValidateAndSave = async (event) => {
|
|
event.preventDefault();
|
|
if (saving || savingInputs) return;
|
|
|
|
if (selectedProfile) {
|
|
const hasEmpty = missingInputs.some((input) => !String(inputForm[input.key] || '').trim());
|
|
if (hasEmpty) {
|
|
setAttemptedSave(true);
|
|
setWorkspaceTab('authorization');
|
|
return;
|
|
}
|
|
|
|
await handleSaveSelectedProfile();
|
|
return;
|
|
}
|
|
|
|
if (!selectedProfile && formCurl) {
|
|
await handleSubmit(new Event('submit'));
|
|
}
|
|
};
|
|
|
|
const beautifyCurl = (curlStr) => {
|
|
if (!curlStr) return 'No cURL stored.';
|
|
let out = curlStr
|
|
.replace(/ (-X|--request) /g, ' \\\n $1 ')
|
|
.replace(/ (--url) /g, ' \\\n $1 ')
|
|
.replace(/ (-H|--header) /g, ' \\\n $1 ')
|
|
.replace(/ (-d|--data-raw|--data-binary|--data) /g, ' \\\n $1 ');
|
|
out = out.replace(/(-d|--data-raw|--data-binary|--data)\s+'([^]*?)'/m, (match, flag, body) => {
|
|
try { return `${flag} '\n${JSON.stringify(JSON.parse(body), null, 2)}\n'`; }
|
|
catch { return match; }
|
|
});
|
|
out = out.replace(/(-d|--data-raw|--data-binary|--data)\s+"([^]*?)"/m, (match, flag, body) => {
|
|
try { return `${flag} "\n${JSON.stringify(JSON.parse(body.replace(/\\"/g, '"')), null, 2)}\n"`; }
|
|
catch { return match; }
|
|
});
|
|
return out;
|
|
};
|
|
|
|
if (initialLoading) {
|
|
return (
|
|
<div className="flex h-64 items-center justify-center">
|
|
<span className="h-8 w-8 animate-spin rounded-full border-4 border-spinner-track border-t-primary-blue" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<DeleteProfileModal
|
|
preview={deletePreview}
|
|
loading={loadingDeleteImpactId === deletePreview?.profile?.id}
|
|
deleting={deletingProfileId === deletePreview?.profile?.id}
|
|
onCancel={() => setDeletePreview(null)}
|
|
onConfirm={handleDeleteConfirm}
|
|
/>
|
|
|
|
{showImportModal && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/60 p-4 backdrop-blur-sm shadow-2xl">
|
|
<div className="w-full max-w-2xl overflow-hidden rounded-3xl border border-gray-200 bg-white shadow-xl">
|
|
<div className="border-b border-gray-100 bg-gray-50/50 px-6 py-5">
|
|
<p className="mb-1 text-[11px] font-bold uppercase tracking-[0.2em] text-text-muted">Request Builder</p>
|
|
<h3 className="text-xl font-bold tracking-tight text-gray-900">Import Provider cURL</h3>
|
|
</div>
|
|
<div className="px-6 py-6">
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
<div className="grid gap-5 md:grid-cols-[minmax(0,1fr)_auto]">
|
|
<label className="self-end rounded-xl border border-gray-200 bg-gray-50 px-4 py-3 text-sm font-semibold text-text-primary shadow-sm transition hover:bg-gray-100">
|
|
<span className="flex cursor-pointer items-center gap-3">
|
|
<input
|
|
type="checkbox"
|
|
className="h-4 w-4 rounded border-gray-300 text-primary-blue focus:ring-primary-blue"
|
|
checked={formSetActive}
|
|
onChange={(event) => setFormSetActive(event.target.checked)}
|
|
/>
|
|
Set active directly
|
|
</span>
|
|
</label>
|
|
</div>
|
|
<div>
|
|
<label className="mb-2 block text-xs font-bold uppercase tracking-[0.1em] text-text-muted">cURL Data</label>
|
|
<div className="group overflow-hidden rounded-2xl border border-gray-800 bg-gray-900 shadow-inner">
|
|
<textarea
|
|
value={formCurl}
|
|
onChange={(event) => setFormCurl(event.target.value)}
|
|
placeholder="curl --request POST --url ..."
|
|
className="h-64 w-full resize-none bg-transparent px-5 py-5 font-mono text-xs leading-relaxed text-gray-100 outline-none transition placeholder:text-gray-600 focus:bg-gray-950"
|
|
required
|
|
spellCheck="false"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-3 border-t border-gray-100 pt-6">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowImportModal(false)}
|
|
disabled={saving}
|
|
className="flex-1 rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm font-bold text-gray-600 shadow-sm transition hover:bg-gray-50 disabled:opacity-50"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={saving}
|
|
className="flex-1 rounded-xl bg-primary-blue px-4 py-3 text-sm font-bold text-white shadow-sm transition hover:bg-primary-dark disabled:opacity-50"
|
|
>
|
|
{saving ? 'Validating…' : 'Import'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex h-[calc(100vh-[var(--header-height,64px)])] min-h-[700px] w-full border-t border-gray-200 bg-white font-sans text-[13px] text-gray-800">
|
|
<aside className="flex w-[280px] shrink-0 flex-col border-r border-gray-200 bg-gray-50/30 pt-1">
|
|
<div className="flex-1 overflow-y-auto px-2 space-y-4">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setFormCurl('');
|
|
setShowImportModal(true);
|
|
}}
|
|
className="w-full rounded px-2 py-1.5 text-left text-gray-600 transition hover:bg-gray-100"
|
|
>
|
|
<span className="flex items-center gap-2">
|
|
<svg className="h-3.5 w-3.5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
|
</svg>
|
|
<span className="text-xs font-medium">New Request</span>
|
|
</span>
|
|
</button>
|
|
|
|
<div>
|
|
<div className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-[11px] font-semibold text-gray-500 transition hover:bg-gray-100/50">
|
|
<svg className="h-3.5 w-3.5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
Saved Profiles
|
|
</div>
|
|
<div className="space-y-0.5 py-1 pl-5 pr-1">
|
|
{profiles.map((profile) => {
|
|
const isSelected = profile.id === selectedProfile?.id;
|
|
const isActive = profile.id === activeProfileId;
|
|
const needsFields = profile.executionReadiness?.isSetupComplete !== true;
|
|
|
|
return (
|
|
<button
|
|
key={profile.id}
|
|
type="button"
|
|
onClick={() => setSelectedProfileId(profile.id)}
|
|
className={`group flex w-full items-center gap-2.5 rounded px-2 py-1.5 text-left transition ${
|
|
isSelected ? 'bg-blue-50/70 font-medium text-blue-800' : 'text-gray-700 hover:bg-gray-100'
|
|
}`}
|
|
>
|
|
<span className={`text-[10px] font-bold ${isSelected ? 'text-green-600' : 'text-green-600/80 group-hover:text-green-600'}`}>POST</span>
|
|
<span className="truncate text-xs">{profile.name || profile.provider?.providerName || 'Unknown Request'}</span>
|
|
{isActive && (
|
|
<div
|
|
className="ml-auto h-1.5 w-1.5 rounded-full bg-blue-500 shadow-[0_0_4px_rgba(59,130,246,0.5)]"
|
|
title="Active Delivery Profile"
|
|
/>
|
|
)}
|
|
{!isActive && needsFields && (
|
|
<div
|
|
className="ml-auto h-1.5 w-1.5 rounded-full bg-orange-400"
|
|
title="Needs Required Inputs"
|
|
/>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
{profiles.length === 0 && (
|
|
<div className="px-2 py-2 text-xs text-gray-400">No requests saved yet.</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
<main className="relative flex min-w-0 flex-1 flex-col bg-white">
|
|
<div className="absolute right-4 top-2 z-10 flex gap-2">
|
|
{error && <span className="rounded border border-red-200 bg-red-50 px-3 py-1 text-xs font-medium text-red-600">Error: {error}</span>}
|
|
{success && <span className="rounded border border-green-200 bg-green-50 px-3 py-1 text-xs font-medium text-green-700">{success}</span>}
|
|
{refreshing && <span className="px-3 py-1 text-xs font-medium text-gray-500">Syncing...</span>}
|
|
</div>
|
|
|
|
<div className="group flex items-center gap-3 border-b border-gray-200 bg-white px-4 py-4">
|
|
<div className="flex flex-1 items-center rounded border border-gray-300 bg-gray-50/50 px-1.5 py-1 transition focus-within:border-gray-400 focus-within:shadow-[0_0_0_2px_rgba(200,200,200,0.2)]">
|
|
<select
|
|
disabled={!!selectedProfile}
|
|
value={selectedRequestDraft?.method || 'POST'}
|
|
className="cursor-pointer appearance-none bg-transparent px-2 py-1 text-[13px] font-bold text-green-600 outline-none"
|
|
>
|
|
<option>POST</option>
|
|
<option>GET</option>
|
|
</select>
|
|
<div className="mx-2 h-5 w-px bg-gray-200" />
|
|
|
|
{!selectedProfile ? (
|
|
<input
|
|
value={formCurl}
|
|
onChange={(event) => setFormCurl(event.target.value)}
|
|
className="flex-1 bg-transparent px-2 py-1 font-mono text-[13px] text-gray-800 outline-none placeholder:text-gray-400"
|
|
placeholder="Enter URL or paste raw cURL text here..."
|
|
spellCheck="false"
|
|
/>
|
|
) : (
|
|
<input
|
|
ref={urlEditorRef}
|
|
readOnly={editingUrlProfileId !== selectedProfile.id}
|
|
value={selectedRequestUrl}
|
|
onClick={handleBeginUrlEdit}
|
|
onChange={(event) => updateRequestDraft(selectedProfile.id, (current) => ({
|
|
...current,
|
|
url: event.target.value,
|
|
urlMasked: false,
|
|
}))}
|
|
onBlur={() => setEditingUrlProfileId('')}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter') {
|
|
event.currentTarget.blur();
|
|
}
|
|
if (event.key === 'Escape') {
|
|
updateRequestDraft(selectedProfile.id, selectedSavedRequest);
|
|
setEditingUrlProfileId('');
|
|
}
|
|
}}
|
|
className="flex-1 cursor-text bg-transparent px-2 py-1 font-mono text-[13px] text-gray-800 outline-none"
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{(!selectedProfile || hasPendingProfileChanges) && (
|
|
<button
|
|
type="button"
|
|
onClick={handleValidateAndSave}
|
|
disabled={saving || savingInputs || (!selectedProfile && !formCurl.trim())}
|
|
className="flex min-w-[80px] items-center justify-center rounded-[4px] bg-[#0066cc] px-5 py-[7px] text-sm font-medium text-white transition hover:bg-[#0052a3] disabled:opacity-50"
|
|
>
|
|
{(saving || savingInputs) ? '...' : (selectedProfile ? 'Save' : 'Import')}
|
|
</button>
|
|
)}
|
|
|
|
{selectedProfile && (
|
|
<div className="ml-1 flex items-center gap-1.5">
|
|
<button
|
|
type="button"
|
|
onClick={() => handleCopyCurl(selectedProfile)}
|
|
className="rounded p-1.5 text-gray-500 transition hover:bg-gray-100 hover:text-gray-800"
|
|
title="Copy Raw cURL"
|
|
>
|
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleDeleteRequest(selectedProfile)}
|
|
className="rounded p-1.5 text-gray-500 transition hover:bg-red-50 hover:text-red-600"
|
|
title="Delete Profile"
|
|
>
|
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
|
</svg>
|
|
</button>
|
|
{!isSelectedProfileActive && <div className="mx-1 h-5 w-px bg-gray-200" />}
|
|
{!isSelectedProfileActive && (
|
|
<button
|
|
type="button"
|
|
onClick={() => handleActivate(selectedProfile.id)}
|
|
className="ml-1 rounded bg-gray-100 px-3 py-1.5 text-xs font-semibold text-gray-600 transition hover:bg-gray-200 hover:text-gray-900"
|
|
>
|
|
Make Active
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center border-b border-gray-200 px-5 text-[13px]">
|
|
{WORKSPACE_TABS.map((tab) => (
|
|
<button
|
|
key={tab.id}
|
|
type="button"
|
|
onClick={() => setWorkspaceTab(tab.id)}
|
|
className={`border-b-[3px] px-3 py-2.5 font-medium transition ${
|
|
workspaceTab === tab.id ? 'border-orange-500 text-gray-900' : 'border-transparent text-gray-500 hover:text-gray-800'
|
|
}`}
|
|
>
|
|
{tab.label}
|
|
{tab.id === 'headers' && <span className="ml-1 font-normal text-gray-400">({selectedRequestDraft?.headers?.length || 0})</span>}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="flex flex-1 flex-col overflow-y-auto bg-white">
|
|
{workspaceTab === 'authorization' && (
|
|
<div className="flex h-full text-[13px]">
|
|
<div className="w-64 shrink-0 border-r border-gray-200 p-4">
|
|
<label className="mb-2 block font-bold text-gray-700">Auth Type</label>
|
|
<div className="w-full rounded-[3px] border border-gray-300 bg-gray-50 px-3 py-1.5 text-[13px] text-gray-800">
|
|
{selectedProfile ? formatAuthMode(selectedProfile.curlAnalysis?.authMode) : 'No Auth'}
|
|
</div>
|
|
<p className="mt-8 pr-2 text-[12px] leading-relaxed text-gray-500">
|
|
Review and edit the saved authorization and setup values for this request. Runtime fields do not belong in this step.
|
|
</p>
|
|
</div>
|
|
<div className="flex-1 p-6">
|
|
{authorizationInputs.map((input) => {
|
|
const isError = attemptedSave && missingInputKeys.has(input.key) && !String(inputForm[input.key] || '').trim();
|
|
const isVisible = inputVisibility[input.key] === true;
|
|
const draftValue = String(inputForm[input.key] ?? '');
|
|
const revealedValue = String(selectedRevealProfileInputs[input.key]?.value ?? input.value ?? '');
|
|
const displayValue = input.secret
|
|
? (
|
|
isVisible
|
|
? (draftValue || revealedValue)
|
|
: (draftValue || input.maskedValue || '')
|
|
)
|
|
: (draftValue || input.value || '');
|
|
const isReadOnly = input.secret && !isVisible && input.hasValue === true && !draftValue;
|
|
|
|
return (
|
|
<div key={input.key} className="mb-4 flex max-w-2xl items-center">
|
|
<span className="w-32 shrink-0 font-medium text-gray-700">{input.label || 'Value'}</span>
|
|
<div className={`relative flex-1 overflow-hidden rounded-[3px] transition ${isError ? 'ring-1 ring-red-400' : 'ring-1 ring-amber-400/80 focus-within:ring-amber-500'}`}>
|
|
<input
|
|
type={input.secret && !isVisible ? 'password' : 'text'}
|
|
readOnly={isReadOnly}
|
|
value={displayValue}
|
|
onChange={(event) => {
|
|
setAttemptedSave(false);
|
|
setInputForm((current) => ({ ...current, [input.key]: event.target.value }));
|
|
}}
|
|
className={`w-full px-3 py-1.5 pr-10 font-mono text-[13px] outline-none ${isError ? 'bg-red-50 text-red-900' : 'bg-white text-gray-800'}`}
|
|
placeholder={isError ? 'Required' : ''}
|
|
/>
|
|
<div className="absolute right-2 top-0 bottom-0 flex items-center gap-1.5">
|
|
{input.secret && (
|
|
<button
|
|
type="button"
|
|
onClick={() => handleToggleInputVisibility(input)}
|
|
className="text-gray-400 transition hover:text-gray-700"
|
|
tabIndex="-1"
|
|
>
|
|
{isVisible ? (
|
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
|
</svg>
|
|
) : (
|
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
)}
|
|
{isError && (
|
|
<svg className="h-4 w-4 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
|
</svg>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
{!selectedProfile && (
|
|
<div className="italic text-gray-400">Select or import a profile to manage Authorization.</div>
|
|
)}
|
|
{selectedProfile && authorizationInputs.length === 0 && (
|
|
<div className="italic text-gray-400">No saved authorization or setup values are defined for this request.</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{workspaceTab === 'headers' && (
|
|
<div className="flex w-full flex-col">
|
|
<div className="flex items-center gap-3 border-b border-gray-100 bg-gray-50/30 px-5 py-2">
|
|
<span className="text-[13px] font-bold text-gray-800">Headers</span>
|
|
</div>
|
|
<table className="table-fixed w-full border-collapse text-left">
|
|
<thead>
|
|
<tr className="border-b border-gray-200 text-[12px] font-semibold text-gray-500">
|
|
<th className="w-[40px] px-3 py-2 text-center"></th>
|
|
<th className="w-[30%] border-l border-gray-100 px-4 py-2">Key</th>
|
|
<th className="w-[45%] border-l border-gray-100 px-4 py-2">Value</th>
|
|
<th className="w-[20%] border-l border-gray-100 px-4 py-2">Description</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{selectedRequestDraft?.headers.map((header, index) => {
|
|
const savedHeader = selectedSavedRequest?.headers.find((candidate) => candidate.id === header.id) || selectedSavedRequest?.headers[index];
|
|
const isEditingKey = editingHeader?.profileId === selectedProfile?.id && editingHeader?.headerId === header.id && editingHeader?.field === 'key';
|
|
const isEditingValue = editingHeader?.profileId === selectedProfile?.id && editingHeader?.headerId === header.id && editingHeader?.field === 'value';
|
|
const isHeaderVisible = selectedHeaderVisibility[header.id] === true;
|
|
const displayValue = header.masked && !isEditingValue && !isHeaderVisible ? (header.maskedValue || header.value) : header.value;
|
|
|
|
return (
|
|
<tr key={header.id} className="group border-b border-gray-100 hover:bg-gray-50/30">
|
|
<td className="px-3 py-2 text-center align-middle">
|
|
<input type="checkbox" checked={header.enabled !== false} readOnly className="pointer-events-none rounded-[3px] border-gray-300 accent-blue-600" />
|
|
</td>
|
|
<td className="border-l border-gray-100 px-4 py-2 align-middle">
|
|
{isEditingKey ? (
|
|
<input
|
|
autoFocus
|
|
type="text"
|
|
value={header.key}
|
|
onChange={(event) => updateRequestDraft(selectedProfile.id, (current) => ({
|
|
...current,
|
|
headers: current.headers.map((entry) => (
|
|
entry.id === header.id
|
|
? { ...entry, key: event.target.value }
|
|
: entry
|
|
)),
|
|
}))}
|
|
onBlur={() => setEditingHeader(null)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter') {
|
|
event.currentTarget.blur();
|
|
}
|
|
if (event.key === 'Escape') {
|
|
updateRequestDraft(selectedProfile.id, (current) => ({
|
|
...current,
|
|
headers: current.headers.map((entry) => (
|
|
entry.id === header.id
|
|
? { ...entry, key: savedHeader?.key || entry.key }
|
|
: entry
|
|
)),
|
|
}));
|
|
setEditingHeader(null);
|
|
}
|
|
}}
|
|
className="w-full bg-transparent px-0 py-0 font-mono text-[13px] text-gray-800 outline-none"
|
|
/>
|
|
) : (
|
|
<button type="button" onClick={() => handleBeginHeaderEdit(header.id, 'key', header)} className="flex w-full items-center gap-1 text-left">
|
|
<span className="font-mono text-[13px] text-gray-800">{header.key}</span>
|
|
</button>
|
|
)}
|
|
</td>
|
|
<td className="border-l border-gray-100 px-4 py-2 align-middle">
|
|
<div className="relative flex items-center">
|
|
{isEditingValue ? (
|
|
<input
|
|
autoFocus
|
|
type={header.secret && !isHeaderVisible ? 'password' : 'text'}
|
|
value={header.value}
|
|
onChange={(event) => updateRequestDraft(selectedProfile.id, (current) => ({
|
|
...current,
|
|
headers: current.headers.map((entry) => (
|
|
entry.id === header.id
|
|
? { ...entry, value: event.target.value, masked: false }
|
|
: entry
|
|
)),
|
|
}))}
|
|
onBlur={() => setEditingHeader(null)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter') {
|
|
event.currentTarget.blur();
|
|
}
|
|
if (event.key === 'Escape') {
|
|
updateRequestDraft(selectedProfile.id, (current) => ({
|
|
...current,
|
|
headers: current.headers.map((entry) => (
|
|
entry.id === header.id
|
|
? {
|
|
...entry,
|
|
value: savedHeader?.value || entry.value,
|
|
masked: savedHeader?.masked === true,
|
|
maskedValue: savedHeader?.maskedValue || entry.maskedValue,
|
|
}
|
|
: entry
|
|
)),
|
|
}));
|
|
setEditingHeader(null);
|
|
}
|
|
}}
|
|
className="w-full bg-transparent px-0 py-0 font-mono text-[13px] text-gray-800 outline-none"
|
|
/>
|
|
) : (
|
|
<button type="button" onClick={() => handleBeginHeaderEdit(header.id, 'value', header)} className="flex-1 text-left">
|
|
<span className={`font-mono text-[13px] ${header.secret ? 'select-none text-gray-500' : 'text-gray-800'}`}>{displayValue}</span>
|
|
</button>
|
|
)}
|
|
{(header.masked || isHeaderVisible) && (
|
|
<button
|
|
type="button"
|
|
onClick={() => handleToggleHeaderReveal(selectedProfile, header)}
|
|
className="ml-2 rounded p-1 text-gray-400 opacity-0 transition hover:text-gray-700 group-hover:opacity-100"
|
|
title={isHeaderVisible ? 'Hide' : 'Reveal'}
|
|
>
|
|
{isHeaderVisible ? (
|
|
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
|
</svg>
|
|
) : (
|
|
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="border-l border-gray-100 px-4 py-2 align-middle"></td>
|
|
</tr>
|
|
);
|
|
})}
|
|
{!selectedProfile && (
|
|
<tr><td colSpan={4} className="px-4 py-6 text-center text-[13px] italic text-gray-400">No profile loaded.</td></tr>
|
|
)}
|
|
{selectedProfile && (selectedRequestDraft?.headers?.length || 0) === 0 && (
|
|
<tr><td colSpan={4} className="px-4 py-6 text-center text-[13px] italic text-gray-400">No headers detected.</td></tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{workspaceTab === 'curl' && (
|
|
<div className="h-full p-4">
|
|
{selectedProfile ? (
|
|
<div className="flex h-full flex-col overflow-hidden rounded border border-gray-200 bg-white">
|
|
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-3 py-1.5 font-mono text-[11px] text-gray-500 shadow-sm">
|
|
<span className="pl-1">1</span>
|
|
<button type="button" onClick={() => handleToggleCurl(selectedProfile)} className="transition hover:text-gray-800">
|
|
{isSelectedCurlVisible ? 'Hide Secrets' : 'Reveal Data'}
|
|
</button>
|
|
</div>
|
|
<pre className="flex-1 overflow-auto whitespace-pre-wrap break-all p-4 font-mono text-[12px] leading-relaxed text-gray-700">
|
|
{beautifyCurl(selectedDisplayCurl)}
|
|
</pre>
|
|
</div>
|
|
) : (
|
|
<div className="px-2 py-2 text-[13px] italic text-gray-400">Paste your cURL in the URL bar above and click Import to create a request.</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{workspaceTab === 'body' && (
|
|
<div className="h-full p-4">
|
|
{selectedProfile ? (
|
|
<div className="flex h-full flex-col overflow-hidden rounded border border-gray-200 bg-white">
|
|
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-3 py-1.5 font-mono text-[11px] text-gray-500 shadow-sm">
|
|
<span className="pl-1">{selectedBodyPreview.label}</span>
|
|
</div>
|
|
<pre className="flex-1 overflow-auto whitespace-pre-wrap break-all p-4 font-mono text-[12px] leading-relaxed text-gray-700">
|
|
{selectedBodyPreview.content || 'No request body detected.'}
|
|
</pre>
|
|
</div>
|
|
) : (
|
|
<div className="px-2 py-2 text-[13px] italic text-gray-400">
|
|
Paste your cURL in the URL bar above and click Import to preview the request body.
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</main>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|