diff --git a/client/src/App.jsx b/client/src/App.jsx index 25bf72c..0042759 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -5,15 +5,13 @@ import apiClient from './api/client'; import BusinessReviewModal from './components/BusinessReviewModal'; import Sidebar from './components/Sidebar'; import Businesses from './pages/Businesses'; -import Providers from './pages/Providers'; import GlobalSms from './pages/GlobalSms'; import Analytics from './pages/Analytics'; import Events from './pages/Events'; import Templates from './pages/Templates'; -import { Link } from 'react-router-dom'; function SubLayout({ children }) { - const { activeBusiness, activeBusinessId, hasGlobalSms } = useBusiness(); + const { activeBusiness, activeBusinessId } = useBusiness(); const [reviewBusiness, setReviewBusiness] = useState(null); const [reviewLoading, setReviewLoading] = useState(false); const [reviewError, setReviewError] = useState(''); @@ -48,17 +46,7 @@ function SubLayout({ children }) { reviewError={reviewError} />
-
- {hasGlobalSms && ( - - - - )} -
+
{children}
@@ -106,9 +94,6 @@ export default function App() { } /> - - } /> } /> diff --git a/client/src/components/Sidebar.jsx b/client/src/components/Sidebar.jsx index 7c18236..4db0258 100644 --- a/client/src/components/Sidebar.jsx +++ b/client/src/components/Sidebar.jsx @@ -70,7 +70,6 @@ export default function Sidebar({ onOpenReview, reviewLoading = false, reviewErr activeBusiness, activeBusinessId, clearBusiness, - hasGlobalSms, isSetupComplete, hasSelectedTemplates, } = useBusiness(); @@ -88,13 +87,6 @@ export default function Sidebar({ onOpenReview, reviewLoading = false, reviewErr const isEventsRoute = location.pathname === eventsPath; const isTemplatesRoute = location.pathname === templatesPath; - const omniSubsteps = [ - { id: 'profile', label: 'Add / Select Profile', done: hasGlobalSms, active: isGlobalSmsRoute && !hasGlobalSms }, - { id: 'validate', label: 'Validate cURL', done: hasGlobalSms, active: false }, - { id: 'fields', label: 'Complete Fields', done: isSetupComplete, active: isGlobalSmsRoute && hasGlobalSms && !isSetupComplete }, - { id: 'ready', label: 'Ready', done: isSetupComplete, active: isGlobalSmsRoute && isSetupComplete }, - ]; - const stepItems = [ { id: 'globalSms', @@ -103,8 +95,7 @@ export default function Sidebar({ onOpenReview, reviewLoading = false, reviewErr enabled: true, done: isSetupComplete && !isGlobalSmsRoute, active: isGlobalSmsRoute, - expanded: isGlobalSmsRoute, - substeps: omniSubsteps, + expanded: false, }, { id: 'events', diff --git a/client/src/pages/GlobalSms.jsx b/client/src/pages/GlobalSms.jsx index c721d51..6ebbd04 100644 --- a/client/src/pages/GlobalSms.jsx +++ b/client/src/pages/GlobalSms.jsx @@ -5,15 +5,21 @@ 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 formatUpdatedAt(value) { - if (!value) return 'Not updated yet'; - - try { - return new Date(value).toLocaleString(); - } catch { - return 'Not updated yet'; - } +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 = {}) { @@ -45,26 +51,401 @@ function getInputInitialValues(inputs = []) { }, {}); } -function getProfileSummary(profile) { - const parts = []; - const provider = profile?.provider || {}; - const missingCount = profile?.executionReadiness?.missingProfileInputs?.length || 0; +function mergeProfileInputs(...groups) { + const mergedInputs = new Map(); - 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} required field${missingCount === 1 ? '' : 's'} pending`); + groups.forEach((group) => { + (Array.isArray(group) ? group : []).forEach((input) => { + const key = String(input?.key || '').trim(); + if (!key || input?.source === 'runtime') return; - return parts.join(' • ') || 'Profile saved. Complete the remaining setup fields to continue.'; + 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 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 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 DeleteProfileModal({ preview, deleting, onCancel, onConfirm }) { +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 : []; @@ -80,7 +461,12 @@ function DeleteProfileModal({ preview, deleting, onCancel, onConfirm }) {
- {impactedTemplates.length > 0 ? ( + {loading ? ( +
+ + Loading bound templates… +
+ ) : impactedTemplates.length > 0 ? ( <>

Affected templates

@@ -110,7 +496,7 @@ function DeleteProfileModal({ preview, deleting, onCancel, onConfirm }) { -
- )} - - {success && ( -
- {success} - -
- )} - - {activeProfile ? ( -
-
-

- Active Setup:{' '} - - {activeProfile.name} - -

- - {activeProfile.executionReadiness?.isSetupComplete ? 'Setup Complete' : 'Missing Information'} - + {showImportModal && ( +
+
+
+

Request Builder

+

Import Provider cURL

- -
-
-

Current Profile Summary

-
    -
  • - Provider - - {activeProfile.provider?.providerName || Missing} +
    +
    +
    +
  • -
  • - Sender ID - - {activeProfile.provider?.senderId || Missing} - -
  • -
  • - DLT Entity ID - - {activeProfile.provider?.dltEntityId || Missing} - -
  • -
  • -

    Setup Status

    -

    {getProfileSummary(activeProfile)}

    -
  • -
-
- - {!activeProfile.executionReadiness?.isSetupComplete ? ( -
-

Complete the required fields

- - {missingInputs.map((input) => ( -
- - setInputForm((current) => ({ - ...current, - [input.key]: input.key === 'senderId' - ? event.target.value.toUpperCase() - : event.target.value, - }))} - className="w-full rounded border border-border-main bg-page-bg px-3 py-2 text-sm text-text-primary focus:ring-1 focus:ring-primary-blue" - placeholder={input.label} - required={input.required !== false} - /> -
- ))} - - +
- ) : ( -
-

Your active cURL profile is fully configured.

- -
- )} -
-
- ) : hasProfiles ? ( -
- Select an active cURL profile to continue. Your saved profiles are still available below. -
- ) : null} - - {hasProfiles && ( -
-

Saved Profiles

- {profiles.map((profile) => { - const isActive = profile.id === activeProfileId; - const isVisible = visibleProfileIds[profile.id] === true; - const revealedProfile = revealedProfiles[profile.id]; - const displayCurl = isVisible ? (revealedProfile?.rawCurl || profile.maskedCurl) : profile.maskedCurl; - - return ( -
-
-
-
-

{profile.name}

- {isActive && ( - - Active Profile - - )} - - {profile.executionReadiness?.isSetupComplete ? 'Ready' : 'Needs Fields'} - -
-

Updated: {formatUpdatedAt(profile.updatedAt)}

-

{getProfileSummary(profile)}

-
-
-

Stored cURL

- -
-
-                          {displayCurl || 'No cURL stored.'}
-                        
-
-
- -
- {!isActive && ( - - )} - - -
+
+ +
+