714 lines
30 KiB
JavaScript
714 lines
30 KiB
JavaScript
import { useCallback, useEffect, useMemo, 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';
|
|
|
|
function isPendingSenderIdProfile(profile) {
|
|
const normalizedName = String(profile?.name || '').trim();
|
|
const senderId = String(profile?.provider?.senderId || '').trim();
|
|
return (profile?.isAutoNamed === true && !senderId) || normalizedName === PENDING_SENDER_ID_PROFILE_NAME;
|
|
}
|
|
|
|
function normalizeCurlForDisplay(value) {
|
|
if (!value) return '';
|
|
|
|
return String(value)
|
|
.trim()
|
|
.replace(/\r\n/g, '\n')
|
|
.replace(/\\r\\n/g, '\n')
|
|
.replace(/\\n/g, '\n')
|
|
.replace(/\\t/g, ' ')
|
|
.replace(/\\'/g, '\'')
|
|
.replace(/\\"/g, '"');
|
|
}
|
|
|
|
function stripWrappingQuotes(value) {
|
|
if (!value || value.length < 2) return value;
|
|
|
|
if (
|
|
(value.startsWith('\'') && value.endsWith('\''))
|
|
|| (value.startsWith('"') && value.endsWith('"'))
|
|
) {
|
|
return value.slice(1, -1);
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
function formatCurlCommand(normalizedCurl) {
|
|
if (!normalizedCurl) return '';
|
|
|
|
let output = normalizedCurl;
|
|
|
|
if (!output.includes('\n')) {
|
|
output = output
|
|
.replace(/^curl\s+/, 'curl\n ')
|
|
.replace(/\s+(--request|-X|--url|--header|-H|--data-raw|--data|-d|--compressed|--location|--insecure|--fail)\b/g, '\n $1');
|
|
}
|
|
|
|
return output.replace(/\n{3,}/g, '\n\n').trim();
|
|
}
|
|
|
|
function extractCurlBody(normalizedCurl) {
|
|
if (!normalizedCurl) return '';
|
|
|
|
const quotedMatch = normalizedCurl.match(
|
|
/(?:--data-raw|--data|-d)\s+(["'])([\s\S]*?)\1(?=\s+(?:--[a-z-]+|-\w)\b|$)/i,
|
|
);
|
|
if (quotedMatch?.[2]) return stripWrappingQuotes(quotedMatch[2].trim());
|
|
|
|
const braceMatch = normalizedCurl.match(
|
|
/(?:--data-raw|--data|-d)\s+({[\s\S]*})(?=\s+(?:--[a-z-]+|-\w)\b|$)/i,
|
|
);
|
|
|
|
return braceMatch?.[1]?.trim() || '';
|
|
}
|
|
|
|
function buildCurlViewModel(value) {
|
|
const normalizedCurl = normalizeCurlForDisplay(value);
|
|
const headers = [
|
|
...normalizedCurl.matchAll(/(?:--header|-H)\s+(?:"([^"]+)"|'([^']+)')/g),
|
|
]
|
|
.map((match) => (match[1] || match[2] || '').trim())
|
|
.filter(Boolean);
|
|
|
|
const methodMatch = normalizedCurl.match(/(?:--request|-X)\s+([A-Z]+)/i);
|
|
const method = (methodMatch?.[1] || (/(?:--data-raw|--data|-d)\b/i.test(normalizedCurl) ? 'POST' : 'GET')).toUpperCase();
|
|
const url = normalizedCurl.match(/https?:\/\/[^\s'"]+/i)?.[0] || '';
|
|
const rawBody = extractCurlBody(normalizedCurl);
|
|
|
|
let payload = stripWrappingQuotes(rawBody || '').trim();
|
|
let prettyPayload = '';
|
|
let payloadFormat = '';
|
|
|
|
if (payload) {
|
|
try {
|
|
const parsed = JSON.parse(payload);
|
|
prettyPayload = JSON.stringify(parsed, null, 2);
|
|
payloadFormat = 'json';
|
|
} catch {
|
|
prettyPayload = payload;
|
|
payloadFormat = 'text';
|
|
}
|
|
}
|
|
|
|
let host = '';
|
|
try {
|
|
host = url ? new URL(url).host : '';
|
|
} catch {
|
|
host = '';
|
|
}
|
|
|
|
const shellLines = [];
|
|
if (url) {
|
|
shellLines.push('curl \\');
|
|
shellLines.push(` --request ${method} \\`);
|
|
shellLines.push(` --url '${url}'${headers.length || rawBody ? ' \\' : ''}`);
|
|
headers.forEach((header, index) => {
|
|
const hasTrailingSection = index < headers.length - 1 || Boolean(rawBody);
|
|
shellLines.push(` --header '${header}'${hasTrailingSection ? ' \\' : ''}`);
|
|
});
|
|
if (rawBody) {
|
|
shellLines.push(` --data-raw '${payloadFormat === 'json' ? '<payload shown below>' : rawBody}'`);
|
|
}
|
|
}
|
|
|
|
return {
|
|
command: shellLines.length > 0 ? shellLines.join('\n') : formatCurlCommand(normalizedCurl),
|
|
headers,
|
|
host,
|
|
method,
|
|
payload: prettyPayload,
|
|
payloadFormat,
|
|
url,
|
|
};
|
|
}
|
|
|
|
function formatUpdatedAt(value) {
|
|
if (!value) return 'Not updated yet';
|
|
|
|
try {
|
|
return new Date(value).toLocaleString();
|
|
} catch {
|
|
return 'Not updated yet';
|
|
}
|
|
}
|
|
|
|
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 getInitialFormValues(inputs = []) {
|
|
return inputs.reduce((accumulator, input) => {
|
|
accumulator[input.key] = input.secret ? '' : (input.value || '');
|
|
return accumulator;
|
|
}, {});
|
|
}
|
|
|
|
function getProfileSummary(profile) {
|
|
const parts = [];
|
|
const provider = profile?.provider || {};
|
|
const missingCount = profile?.executionReadiness?.missingProfileInputs?.length || 0;
|
|
|
|
if (provider.providerName) parts.push(provider.providerName);
|
|
if (provider.senderId) parts.push(`Sender ${provider.senderId}`);
|
|
if (provider.dltEntityId) parts.push('DLT ready');
|
|
if (missingCount > 0) parts.push(`${missingCount} pending`);
|
|
|
|
return parts.join(' • ') || 'Profile saved. Complete the required fields to use it everywhere.';
|
|
}
|
|
|
|
function ProfileStatusPill({ complete }) {
|
|
return (
|
|
<span
|
|
className={`rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] ${
|
|
complete
|
|
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
|
|
: 'border-amber-200 bg-amber-50 text-amber-700'
|
|
}`}
|
|
>
|
|
{complete ? 'Ready' : 'Needs Fields'}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function InspectorRow({ label, value, valueClassName = '' }) {
|
|
return (
|
|
<div className="rounded-2xl border border-gray-200 bg-white px-4 py-3">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-400">{label}</p>
|
|
<p className={`mt-2 text-sm font-medium text-gray-900 ${valueClassName}`}>{value}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function Providers() {
|
|
const { businessId } = useParams();
|
|
const navigate = useNavigate();
|
|
const { refreshOnboardingState } = useBusiness();
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [profiles, setProfiles] = useState([]);
|
|
const [activeProfileId, setActiveProfileId] = useState('');
|
|
const [selectedProfileId, setSelectedProfileId] = useState('');
|
|
const [formValues, setFormValues] = useState({});
|
|
const [revealedProfiles, setRevealedProfiles] = useState({});
|
|
const [showSecretsByProfileId, setShowSecretsByProfileId] = useState({});
|
|
const [error, setError] = useState('');
|
|
const [success, setSuccess] = useState('');
|
|
|
|
const globalSmsPath = `/${businessId}/global-sms`;
|
|
|
|
const loadProfiles = useCallback(async () => {
|
|
try {
|
|
setLoading(true);
|
|
const res = await apiClient.get(`/api/businesses/${businessId}/global-sms/profiles`);
|
|
const fetchedProfiles = res.data?.profiles || [];
|
|
const nextActiveProfileId = String(res.data?.activeProfileId || '');
|
|
|
|
setProfiles(fetchedProfiles);
|
|
setActiveProfileId(nextActiveProfileId);
|
|
setSelectedProfileId((currentSelectedProfileId) => (
|
|
fetchedProfiles.some((profile) => profile.id === currentSelectedProfileId)
|
|
? currentSelectedProfileId
|
|
: ''
|
|
));
|
|
} catch (err) {
|
|
setError(err.response?.data?.error || 'Failed to load provider profiles');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [businessId]);
|
|
|
|
useEffect(() => {
|
|
loadProfiles();
|
|
}, [loadProfiles]);
|
|
|
|
const selectedProfile = useMemo(
|
|
() => profiles.find((profile) => profile.id === selectedProfileId) || null,
|
|
[profiles, selectedProfileId],
|
|
);
|
|
const selectedProfileInputs = selectedProfile?.profileInputs || [];
|
|
const isSelectedProfileRevealed = selectedProfile ? showSecretsByProfileId[selectedProfile.id] === true : false;
|
|
const selectedRevealData = selectedProfile ? revealedProfiles[selectedProfile.id] : null;
|
|
const selectedDisplayCurl = selectedProfile
|
|
? (isSelectedProfileRevealed
|
|
? (selectedRevealData?.rawCurl || selectedProfile.maskedCurl)
|
|
: selectedProfile.maskedCurl)
|
|
: '';
|
|
const selectedCurlView = useMemo(
|
|
() => buildCurlViewModel(selectedDisplayCurl),
|
|
[selectedDisplayCurl],
|
|
);
|
|
const missingInputCount = selectedProfile?.executionReadiness?.missingProfileInputs?.length || 0;
|
|
const curlWarnings = selectedProfile?.curlAnalysis?.warnings || [];
|
|
|
|
useEffect(() => {
|
|
if (!selectedProfile) {
|
|
setFormValues({});
|
|
return;
|
|
}
|
|
|
|
setFormValues(getInitialFormValues(selectedProfile.profileInputs));
|
|
}, [selectedProfile]);
|
|
|
|
const ensureRevealData = useCallback(async (profileId) => {
|
|
if (revealedProfiles[profileId]) return revealedProfiles[profileId];
|
|
|
|
const res = await apiClient.get(`/api/businesses/${businessId}/global-sms/profiles/${profileId}/reveal`);
|
|
setRevealedProfiles((current) => ({ ...current, [profileId]: res.data }));
|
|
return res.data;
|
|
}, [businessId, revealedProfiles]);
|
|
|
|
function handleSelectProfile(profileId) {
|
|
setSelectedProfileId(profileId);
|
|
setError('');
|
|
setSuccess('');
|
|
}
|
|
|
|
function handleReturnToList() {
|
|
setSelectedProfileId('');
|
|
setError('');
|
|
setSuccess('');
|
|
}
|
|
|
|
async function handleActivate(profile) {
|
|
if (!profile?.id) return;
|
|
|
|
try {
|
|
setError('');
|
|
setSuccess('');
|
|
await apiClient.post(`/api/businesses/${businessId}/global-sms/profiles/${profile.id}/activate`);
|
|
setSelectedProfileId(profile.id);
|
|
await loadProfiles();
|
|
await refreshOnboardingState(businessId).catch(() => null);
|
|
setSuccess(`${profile.name} is now the active profile.`);
|
|
} catch (err) {
|
|
setError(err.response?.data?.error || 'Failed to activate profile');
|
|
}
|
|
}
|
|
|
|
async function handleToggleReveal(profile) {
|
|
if (!profile?.id) return;
|
|
|
|
const shouldReveal = !showSecretsByProfileId[profile.id];
|
|
if (shouldReveal) {
|
|
try {
|
|
const revealData = await ensureRevealData(profile.id);
|
|
const revealedValues = (revealData?.profileInputs || []).reduce((accumulator, input) => {
|
|
accumulator[input.key] = input.value || '';
|
|
return accumulator;
|
|
}, {});
|
|
setFormValues((current) => ({ ...current, ...revealedValues }));
|
|
} catch (err) {
|
|
setError(err.response?.data?.error || 'Failed to reveal saved values');
|
|
return;
|
|
}
|
|
}
|
|
|
|
setShowSecretsByProfileId((current) => ({
|
|
...current,
|
|
[profile.id]: shouldReveal,
|
|
}));
|
|
}
|
|
|
|
async function handleCopyCurl(profile) {
|
|
if (!profile?.id) return;
|
|
|
|
try {
|
|
const revealData = await ensureRevealData(profile.id);
|
|
if (!revealData?.rawCurl) return;
|
|
|
|
await navigator.clipboard.writeText(revealData.rawCurl);
|
|
setSuccess(`Copied ${profile.name} cURL.`);
|
|
} catch (err) {
|
|
setError(err.response?.data?.error || 'Failed to copy the cURL command.');
|
|
}
|
|
}
|
|
|
|
async function handleSave(event) {
|
|
event.preventDefault();
|
|
if (!selectedProfile?.id) return;
|
|
|
|
setSaving(true);
|
|
setError('');
|
|
setSuccess('');
|
|
|
|
try {
|
|
const payload = buildProfilePatchPayload(selectedProfileInputs, formValues);
|
|
await apiClient.patch(`/api/businesses/${businessId}/global-sms/profiles/${selectedProfile.id}`, payload);
|
|
|
|
await loadProfiles();
|
|
await refreshOnboardingState(businessId).catch(() => null);
|
|
setSuccess(`Provider configuration saved for ${selectedProfile.name}.`);
|
|
} catch (err) {
|
|
setError(err.response?.data?.error || 'Failed to save configuration');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex h-64 items-center justify-center">
|
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-200 border-t-indigo-600" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="mx-auto max-w-6xl space-y-6 pb-12">
|
|
<div className="flex flex-col gap-4 border-b border-gray-200 pb-5 lg:flex-row lg:items-end lg:justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold tracking-tight text-gray-800">Provider Configuration</h1>
|
|
<p className="mt-1 text-sm font-medium text-gray-500">
|
|
Review each saved cURL profile and update the required provider or credential fields without changing the stored cURL.
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => navigate(globalSmsPath)}
|
|
className="inline-flex items-center justify-center rounded-lg border border-gray-200 px-4 py-2 text-sm font-semibold text-gray-700 transition hover:border-primary-blue hover:bg-white hover:text-primary-blue"
|
|
>
|
|
Manage cURLs
|
|
</button>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="flex items-center justify-between rounded-md border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700">
|
|
{error}
|
|
<button type="button" onClick={() => setError('')} className="font-bold text-gray-600 hover:text-gray-700">
|
|
×
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{success && (
|
|
<div className="flex items-center justify-between rounded-md border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700">
|
|
{success}
|
|
<button type="button" onClick={() => setSuccess('')} className="font-bold text-gray-600 hover:text-gray-700">
|
|
×
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{!selectedProfile ? (
|
|
<section className="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm">
|
|
<div className="border-b border-gray-200 px-5 py-4">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div>
|
|
<p className="text-sm font-semibold text-gray-900">Saved Profiles</p>
|
|
</div>
|
|
<span className="rounded-full border border-gray-200 bg-gray-50 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-gray-500">
|
|
{profiles.length} total
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{profiles.length === 0 ? (
|
|
<div className="px-5 py-8 text-center">
|
|
<p className="text-sm font-semibold text-gray-900">No saved profiles yet</p>
|
|
<p className="mt-2 text-sm text-gray-500">
|
|
Add and validate a cURL profile from Omni-channel SMS before configuring provider details here.
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => navigate(globalSmsPath)}
|
|
className="mt-4 inline-flex items-center justify-center rounded-lg bg-primary-blue px-4 py-2 text-sm font-semibold text-white transition hover:bg-primary-dark"
|
|
>
|
|
Go to Omni-channel SMS
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="p-3">
|
|
<div className="space-y-3">
|
|
{profiles.map((profile) => {
|
|
const isActive = profile.id === activeProfileId;
|
|
const complete = profile.executionReadiness?.isSetupComplete === true;
|
|
|
|
return (
|
|
<button
|
|
key={profile.id}
|
|
type="button"
|
|
onClick={() => handleSelectProfile(profile.id)}
|
|
className="w-full rounded-xl border border-gray-200 bg-white p-4 text-left transition hover:border-primary-blue hover:bg-gray-50"
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<p className={`truncate text-sm font-semibold ${isPendingSenderIdProfile(profile) ? 'text-error-text' : 'text-gray-900'}`}>{profile.name}</p>
|
|
<p className="mt-1 text-sm leading-relaxed text-gray-500">{getProfileSummary(profile)}</p>
|
|
</div>
|
|
<div className="flex shrink-0 flex-wrap justify-end gap-2">
|
|
{isActive && (
|
|
<span className="rounded-full border border-indigo-200 bg-indigo-50 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-primary-dark">
|
|
Active
|
|
</span>
|
|
)}
|
|
<ProfileStatusPill complete={complete} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs font-medium text-gray-500">
|
|
<span>Updated {formatUpdatedAt(profile.updatedAt)}</span>
|
|
{profile.provider?.senderId && <span>Sender {profile.provider.senderId}</span>}
|
|
{profile.provider?.dltEntityId && <span>DLT ready</span>}
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
) : (
|
|
<section className="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm">
|
|
<div className="border-b border-gray-200 px-6 py-5">
|
|
<div className="flex flex-col gap-4">
|
|
<button
|
|
type="button"
|
|
onClick={handleReturnToList}
|
|
className="inline-flex w-fit items-center gap-2 text-sm font-semibold text-gray-500 transition hover:text-primary-blue"
|
|
>
|
|
<span>Saved Profiles</span>
|
|
<span className="text-gray-300">/</span>
|
|
<span className="text-gray-900">{selectedProfile.name}</span>
|
|
</button>
|
|
|
|
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
|
<div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<h2 className="text-xl font-semibold tracking-tight text-gray-900">{selectedProfile.name}</h2>
|
|
{selectedProfile.id === activeProfileId && (
|
|
<span className="rounded-full border border-indigo-200 bg-indigo-50 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-primary-dark">
|
|
Active profile
|
|
</span>
|
|
)}
|
|
<ProfileStatusPill complete={selectedProfile.executionReadiness?.isSetupComplete === true} />
|
|
</div>
|
|
<p className="mt-2 text-sm text-gray-500">
|
|
The stored cURL is immutable after validation. You can review it, reveal it, and update the profile fields it depends on.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{selectedProfile.id !== activeProfileId && (
|
|
<button
|
|
type="button"
|
|
onClick={() => handleActivate(selectedProfile)}
|
|
className="rounded-lg bg-primary-blue px-4 py-2 text-sm font-semibold text-white transition hover:bg-primary-dark"
|
|
>
|
|
Set Active
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() => handleToggleReveal(selectedProfile)}
|
|
className="rounded-lg border border-gray-200 px-4 py-2 text-sm font-semibold text-gray-700 transition hover:border-primary-blue hover:bg-gray-50 hover:text-primary-blue"
|
|
>
|
|
{showSecretsByProfileId[selectedProfile.id] ? 'Hide Values' : 'Reveal Values'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleCopyCurl(selectedProfile)}
|
|
className="rounded-lg border border-gray-200 px-4 py-2 text-sm font-semibold text-gray-700 transition hover:border-primary-blue hover:bg-gray-50 hover:text-primary-blue"
|
|
>
|
|
Copy cURL
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-6 px-6 py-6">
|
|
<div className="grid gap-5 xl:grid-cols-[minmax(0,1.45fr)_360px] xl:items-start">
|
|
<div className="overflow-hidden rounded-[28px] border border-slate-200 bg-slate-950 shadow-[0_28px_60px_-42px_rgba(15,23,42,0.75)]">
|
|
<div className="border-b border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(96,165,250,0.2),_transparent_42%),linear-gradient(135deg,_rgba(15,23,42,0.98),_rgba(2,6,23,0.96))] px-5 py-5">
|
|
<div className="flex flex-wrap items-start gap-3">
|
|
<span className="rounded-full border border-sky-400/30 bg-sky-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100">
|
|
{selectedCurlView.method}
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-sm font-semibold text-white">
|
|
{selectedCurlView.url || 'Endpoint not detected from stored cURL'}
|
|
</p>
|
|
<p className="mt-1 text-xs font-medium text-slate-400">
|
|
{isSelectedProfileRevealed
|
|
? 'Saved values are currently rendered inside this request preview.'
|
|
: 'Sensitive values stay masked until you explicitly reveal them.'}
|
|
</p>
|
|
</div>
|
|
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300">
|
|
Updated {formatUpdatedAt(selectedProfile.updatedAt)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-5 px-5 py-5">
|
|
<div>
|
|
<p className="mb-3 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Shell View</p>
|
|
<pre className="max-h-[26rem] overflow-y-auto overscroll-contain rounded-2xl border border-white/8 bg-black/20 px-4 py-4 text-[13px] leading-7 text-slate-100 shadow-inner">
|
|
<code>{selectedCurlView.command || 'No cURL stored.'}</code>
|
|
</pre>
|
|
</div>
|
|
|
|
{selectedCurlView.payload && (
|
|
<div>
|
|
<div className="mb-3 flex items-center justify-between gap-3">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Request Payload</p>
|
|
<span className="rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-300">
|
|
{selectedCurlView.payloadFormat === 'json' ? 'JSON' : 'Text'}
|
|
</span>
|
|
</div>
|
|
<pre className="max-h-[22rem] overflow-y-auto overscroll-contain rounded-2xl border border-emerald-400/10 bg-emerald-400/5 px-4 py-4 text-[13px] leading-7 text-emerald-50 shadow-inner">
|
|
<code>{selectedCurlView.payload}</code>
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<aside className="space-y-4">
|
|
<div className="rounded-[28px] border border-gray-200 bg-gray-50 p-5">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<p className="text-sm font-semibold text-gray-900">Profile Inspector</p>
|
|
<p className="mt-1 text-sm text-gray-500">
|
|
Keep the request front and center, then reveal stored values only when you need to inspect or edit them.
|
|
</p>
|
|
</div>
|
|
<span className={`rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] ${isSelectedProfileRevealed ? 'border-indigo-200 bg-indigo-50 text-primary-dark' : 'border-gray-200 bg-white text-gray-500'}`}>
|
|
{isSelectedProfileRevealed ? 'Values visible' : 'Values hidden'}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="mt-4 grid gap-3">
|
|
<InspectorRow
|
|
label="Profile State"
|
|
value={selectedProfile.id === activeProfileId ? 'Currently active for generation' : 'Inactive profile'}
|
|
/>
|
|
<InspectorRow
|
|
label="Provider"
|
|
value={selectedProfile.provider?.providerName || selectedProfile.curlAnalysis?.providerName || 'Awaiting provider name'}
|
|
/>
|
|
<InspectorRow
|
|
label="Endpoint Host"
|
|
value={selectedCurlView.host || 'Not detected'}
|
|
/>
|
|
<InspectorRow
|
|
label="Auth Mode"
|
|
value={selectedProfile.curlAnalysis?.authMode || 'Not detected'}
|
|
/>
|
|
<InspectorRow
|
|
label="Profile Fields"
|
|
value={`${selectedProfileInputs.length} stored value${selectedProfileInputs.length === 1 ? '' : 's'}`}
|
|
/>
|
|
<InspectorRow
|
|
label="Setup"
|
|
value={selectedProfile.executionReadiness?.isSetupComplete
|
|
? 'All required profile inputs are complete.'
|
|
: `${missingInputCount} required field${missingInputCount === 1 ? '' : 's'} still missing`}
|
|
valueClassName={selectedProfile.executionReadiness?.isSetupComplete ? '' : 'text-amber-700'}
|
|
/>
|
|
</div>
|
|
|
|
{curlWarnings.length > 0 && (
|
|
<div className="mt-4 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-700">Warnings</p>
|
|
<ul className="mt-2 space-y-2 text-sm text-amber-900">
|
|
{curlWarnings.map((warning) => (
|
|
<li key={warning}>{warning}</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{isSelectedProfileRevealed ? (
|
|
<form onSubmit={handleSave} className="overflow-hidden rounded-[28px] border border-gray-200 bg-white shadow-sm">
|
|
<div className="border-b border-gray-200 px-5 py-4">
|
|
<p className="text-sm font-semibold text-gray-900">Stored Profile Values</p>
|
|
<p className="mt-1 text-sm text-gray-500">
|
|
These fields appear only in reveal mode and stay tied to this immutable cURL profile.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-4 px-5 py-5">
|
|
{selectedProfileInputs.length > 0 ? selectedProfileInputs.map((input) => (
|
|
<div key={input.key} className="rounded-2xl border border-gray-200 bg-gray-50 px-4 py-4">
|
|
<label className={`mb-1.5 block text-sm font-semibold ${input.required && !input.hasValue && !String(formValues[input.key] || '').trim() ? 'text-error-text' : 'text-gray-900'}`}>
|
|
{input.label} {input.required ? <span className="text-error-text">*</span> : null}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={formValues[input.key] || ''}
|
|
onChange={(event) => setFormValues((current) => ({
|
|
...current,
|
|
[input.key]: input.key === 'senderId'
|
|
? event.target.value.toUpperCase()
|
|
: event.target.value,
|
|
}))}
|
|
className={`w-full rounded-xl border bg-white px-4 py-2.5 text-sm font-medium text-gray-900 transition focus:border-transparent focus:outline-none focus:ring-2 ${input.required && !input.hasValue && !String(formValues[input.key] || '').trim() ? 'border-error-text focus:ring-error-text' : 'border-gray-200 focus:ring-primary-blue'}`}
|
|
placeholder={input.label}
|
|
/>
|
|
<p className="mt-2 text-xs font-medium text-gray-500">
|
|
{input.secret
|
|
? 'Sensitive value revealed for this inspection session.'
|
|
: input.source === 'embedded'
|
|
? 'Extracted from the accepted cURL and stored against this profile.'
|
|
: 'Stored on the profile before publish and runtime sends continue.'}
|
|
</p>
|
|
</div>
|
|
)) : (
|
|
<div className="rounded-2xl border border-gray-200 bg-gray-50 px-4 py-4 text-sm text-gray-600">
|
|
No profile-level stored values were extracted from this cURL.
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex justify-end border-t border-gray-200 bg-white px-5 py-4">
|
|
<button
|
|
type="submit"
|
|
disabled={saving}
|
|
className="flex items-center justify-center gap-2 rounded-lg bg-primary-blue px-6 py-2 text-sm font-semibold text-white transition hover:bg-primary-dark disabled:opacity-50"
|
|
>
|
|
{saving ? 'Saving…' : 'Save Changes'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
) : (
|
|
<div className="rounded-[28px] border border-dashed border-gray-300 bg-white px-5 py-5">
|
|
<p className="text-sm font-semibold text-gray-900">Values stay hidden by default</p>
|
|
<p className="mt-2 text-sm leading-relaxed text-gray-500">
|
|
Reveal mode will render stored values inside the cURL on the left and open the editable field inspector here.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|