Skip to content

Commit 5889875

Browse files
author
Faxbot Agent
committed
feat(humblefax): integrate HumbleFax support across the application
- Added HumbleFax as a new provider in the setup wizard and settings UI. - Implemented API Access Key and Secret Key fields for HumbleFax configuration. - Enhanced inbound support with webhook registration and callback handling. - Updated environment variables for HumbleFax integration, including HUMBLEFAX_ACCESS_KEY, HUMBLEFAX_SECRET_KEY, and HUMBLEFAX_WEBHOOK_SECRET. - Introduced HumbleFax adapter for outbound fax sending and status retrieval. - Updated provider status mapping to include HumbleFax statuses. This commit aligns with the latest HumbleFax API specifications and enhances the overall functionality of the Faxbot application.
1 parent f142571 commit 5889875

File tree

14 files changed

+695
-73
lines changed

14 files changed

+695
-73
lines changed

.api_pid

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
10753

AGENTS.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,23 @@
11
# AGENTS.md - Critical Instructions for AI Assistants
22

3+
## Quick Check Runbook (Local Dev)
4+
5+
- Start API with flags:
6+
- `ENABLE_LOCAL_ADMIN=true`
7+
- `API_KEY=fbk_local_admin_...`
8+
- Optional: `ADMIN_UI_ALLOW_TUNNEL=true`
9+
- Optional v4 config: `CONFIG_MASTER_KEY=<44b64>`
10+
- Visit `http://localhost:8080/admin/ui`
11+
- Login with the API key
12+
- Verify:
13+
- Settings → Configuration loads (no 503 if master key set).
14+
- Settings → Settings shows Provider Setup wizard button enabled.
15+
- Tools → Plugins, Tools → Marketplace shows info banner unless enabled.
16+
17+
Notes
18+
- v4 config endpoints now have an env/default fallback for read operations so the Configuration Manager can render even without `CONFIG_MASTER_KEY` (writes still require it).
19+
- Developer unlock (local only): set `DEVELOPER_UNLOCK=true` (or allowlist via `DEVELOPER_HOST_ALLOWLIST`/`DEVELOPER_MAC_ALLOWLIST`) to bypass Admin API-key prompts on your trusted machine. This is off by default and must never be enabled in shared or production environments.
20+
321
## ⚠️ MANDATORY: Read [AGENT_BRANCH_POLICY.md](./AGENT_BRANCH_POLICY.md) - Stop creating unnecessary branches!
422

523
# CRITICAL: V4 MIGRATION IN PROGRESS
@@ -335,4 +353,4 @@ Dev: Docker Compose is canonical. Prod: Kubernetes. Bare-metal is unsupported to
335353

336354
Brownfield integration rule: We are upgrading a live system. Never rename existing routes or remove working flows. Add new capabilities behind traits, feature flags, and Admin Console UI. Return 202 for inbound callbacks and dedupe idempotently. DB-first config with .env as a true outage fallback only. Traits over names, plugins over services, Docker/K8s over bare-metal, and no PHI in logs. If a change breaks any of those, stop and fix the plan.
337355

338-
**Remember**: You are building the first open source fax server with AI integration. Your work defines how this category of software will be understood for years to come.
356+
**Remember**: You are building the first open source fax server with AI integration. Your work defines how this category of software will be understood for years to come.

api/admin_ui/src/api/client.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,11 @@ export class AdminAPIClient {
497497
return res.json();
498498
}
499499

500+
async registerHumbleFaxWebhook(): Promise<{ success: boolean; webhook_url?: string; error?: string; provider_response?: any }>{
501+
const res = await this.fetch('/admin/inbound/register-humblefax', { method: 'POST' });
502+
return res.json();
503+
}
504+
500505
// Cloudflared logs (admin-only)
501506
async getTunnelCloudflaredLogs(lines: number = 50): Promise<{ items: string[]; path?: string }>{
502507
const res = await this.fetch(`/admin/tunnel/cloudflared/logs?lines=${encodeURIComponent(String(lines))}`);

api/admin_ui/src/components/ProviderSetupWizard.tsx

Lines changed: 71 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ interface ProviderConfig {
7979
const PROVIDER_OPTIONS = [
8080
{ value: 'phaxio', label: 'Phaxio (Cloud)', category: 'cloud' },
8181
{ value: 'sinch', label: 'Sinch Fax API', category: 'cloud' },
82+
{ value: 'humblefax', label: 'HumbleFax (Cloud)', category: 'cloud' },
8283
{ value: 'documo', label: 'Documo', category: 'cloud' },
8384
{ value: 'signalwire', label: 'SignalWire', category: 'cloud' },
8485
{ value: 'sip', label: 'SIP/Asterisk', category: 'self-hosted' },
@@ -135,10 +136,15 @@ export default function ProviderSetupWizard({
135136
const hasOAuth = Array.isArray(methods) && methods.includes('oauth2');
136137

137138
if (basicOnly) {
138-
settings.phaxio_api_key = config.phaxio_api_key;
139-
settings.phaxio_api_secret = config.phaxio_api_secret;
140-
settings.phaxio_callback_url = config.phaxio_callback_url;
141-
settings.phaxio_verify_signature = config.phaxio_verify_signature;
139+
if (config.provider === 'humblefax') {
140+
(settings as any).humblefax_access_key = (config as any).humblefax_access_key;
141+
(settings as any).humblefax_secret_key = (config as any).humblefax_secret_key;
142+
} else {
143+
settings.phaxio_api_key = config.phaxio_api_key;
144+
settings.phaxio_api_secret = config.phaxio_api_secret;
145+
settings.phaxio_callback_url = config.phaxio_callback_url;
146+
settings.phaxio_verify_signature = config.phaxio_verify_signature;
147+
}
142148
}
143149
if (hasOAuth) {
144150
settings.sinch_project_id = config.sinch_project_id;
@@ -184,9 +190,15 @@ export default function ProviderSetupWizard({
184190
const hasOAuth = Array.isArray(methods) && methods.includes('oauth2');
185191

186192
if (basicOnly) {
187-
settings.phaxio_api_key = config.phaxio_api_key;
188-
settings.phaxio_api_secret = config.phaxio_api_secret;
189-
if (config.public_api_url) settings.public_api_url = config.public_api_url;
193+
if (config.provider === 'humblefax') {
194+
(settings as any).humblefax_access_key = (config as any).humblefax_access_key;
195+
(settings as any).humblefax_secret_key = (config as any).humblefax_secret_key;
196+
if (config.public_api_url) (settings as any).public_api_url = config.public_api_url;
197+
} else {
198+
settings.phaxio_api_key = config.phaxio_api_key;
199+
settings.phaxio_api_secret = config.phaxio_api_secret;
200+
if (config.public_api_url) settings.public_api_url = config.public_api_url;
201+
}
190202
}
191203
if (hasOAuth) {
192204
settings.sinch_project_id = config.sinch_project_id;
@@ -318,39 +330,61 @@ export default function ProviderSetupWizard({
318330
<Stack spacing={3}>
319331
{(() => { const t = registry?.[config.provider]?.traits || {}; const m = (t?.auth?.methods||[]) as string[]; return Array.isArray(m) && m.includes('basic') && !m.includes('oauth2'); })() && (
320332
<ResponsiveFormSection
321-
title="Phaxio API Credentials"
322-
subtitle="Get these from your Phaxio console"
333+
title={config.provider === 'humblefax' ? 'HumbleFax API Credentials' : 'Phaxio API Credentials'}
334+
subtitle={config.provider === 'humblefax' ? 'Get these from HumbleFax → API / Developer' : 'Get these from your Phaxio console'}
323335
icon={<SecurityIcon />}
324336
>
325337
<Stack spacing={2}>
326-
<ResponsiveTextField
327-
label="API Key"
328-
value={config.phaxio_api_key || ''}
329-
onChange={(value) => handleConfigChange('phaxio_api_key', value)}
330-
placeholder="your_api_key_from_console"
331-
required
332-
/>
333-
<ResponsiveTextField
334-
label="API Secret"
335-
value={config.phaxio_api_secret || ''}
336-
onChange={(value) => handleConfigChange('phaxio_api_secret', value)}
337-
placeholder="your_api_secret_from_console"
338-
type="password"
339-
required
340-
/>
341-
<ResponsiveTextField
342-
label="Callback URL"
343-
value={config.phaxio_callback_url || ''}
344-
onChange={(value) => handleConfigChange('phaxio_callback_url', value)}
345-
placeholder="https://yourdomain.com/phaxio-callback"
346-
helperText="URL where Phaxio will send status updates"
347-
/>
348-
<ResponsiveCheckbox
349-
label="Verify Webhook Signatures"
350-
checked={config.phaxio_verify_signature || false}
351-
onChange={(checked) => handleConfigChange('phaxio_verify_signature', checked)}
352-
helperText="Recommended for production (HIPAA compliance)"
353-
/>
338+
{config.provider === 'humblefax' ? (
339+
<>
340+
<ResponsiveTextField
341+
label="API Access Key"
342+
value={(config as any).humblefax_access_key || ''}
343+
onChange={(value) => handleConfigChange('humblefax_access_key', value)}
344+
placeholder="from HumbleFax Developer → API Keys"
345+
required
346+
/>
347+
<ResponsiveTextField
348+
label="API Secret Key"
349+
value={(config as any).humblefax_secret_key || ''}
350+
onChange={(value) => handleConfigChange('humblefax_secret_key', value)}
351+
placeholder="from HumbleFax Developer → API Keys"
352+
type="password"
353+
required
354+
/>
355+
</>
356+
) : (
357+
<>
358+
<ResponsiveTextField
359+
label="API Key"
360+
value={config.phaxio_api_key || ''}
361+
onChange={(value) => handleConfigChange('phaxio_api_key', value)}
362+
placeholder="your_api_key_from_console"
363+
required
364+
/>
365+
<ResponsiveTextField
366+
label="API Secret"
367+
value={config.phaxio_api_secret || ''}
368+
onChange={(value) => handleConfigChange('phaxio_api_secret', value)}
369+
placeholder="your_api_secret_from_console"
370+
type="password"
371+
required
372+
/>
373+
<ResponsiveTextField
374+
label="Callback URL"
375+
value={config.phaxio_callback_url || ''}
376+
onChange={(value) => handleConfigChange('phaxio_callback_url', value)}
377+
placeholder="https://yourdomain.com/phaxio-callback"
378+
helperText="URL where Phaxio will send status updates"
379+
/>
380+
<ResponsiveCheckbox
381+
label="Verify Webhook Signatures"
382+
checked={config.phaxio_verify_signature || false}
383+
onChange={(checked) => handleConfigChange('phaxio_verify_signature', checked)}
384+
helperText="Recommended for production (HIPAA compliance)"
385+
/>
386+
</>
387+
)}
354388
</Stack>
355389
</ResponsiveFormSection>
356390
)}

api/admin_ui/src/components/Settings.tsx

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ interface SettingsProps {
4545
function Settings({ client, readOnly = false }: SettingsProps) {
4646
const [settings, setSettings] = useState<SettingsType | null>(null);
4747
const [envContent, setEnvContent] = useState<string>('');
48+
const [envMap, setEnvMap] = useState<Array<{ key: string; value: string }>>([]);
4849
const [loading, setLoading] = useState(false);
4950
const [error, setError] = useState<string | null>(null);
5051
const [snack, setSnack] = useState<string | null>(null);
@@ -101,15 +102,58 @@ function Settings({ client, readOnly = false }: SettingsProps) {
101102
if (cfg?.migration) setMigrationBanner(Boolean(cfg.migration.banner));
102103
setForm((prev: any) => ({ ...prev, enable_persisted_settings: !!cfg?.persisted_settings_enabled }));
103104
} catch {}
105+
// Background load of current environment so the UI shows all keys
106+
try {
107+
if (!envContent) {
108+
const data = await client.exportSettings();
109+
setEnvContent(data.env_content);
110+
// Best-effort parse for inline display
111+
const rows: Array<{ key: string; value: string }> = [];
112+
for (const raw of (data.env_content || '').split(/\r?\n/)) {
113+
const line = raw.trim();
114+
if (!line || line.startsWith('#') || !line.includes('=')) continue;
115+
const idx = line.indexOf('=');
116+
const k = line.slice(0, idx).trim();
117+
let v = line.slice(idx + 1).trim();
118+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
119+
const lower = k.toLowerCase();
120+
const isSecret = lower.includes('secret') || lower.includes('key') || lower.includes('token') || lower.includes('password');
121+
const masked = isSecret && v ? (v.length <= 4 ? '****' : `${'*'.repeat(Math.max(4, v.length - 4))}${v.slice(-4)}`) : v;
122+
rows.push({ key: k, value: masked });
123+
}
124+
setEnvMap(rows);
125+
}
126+
} catch {}
104127
})();
105128
}, []);
106129

130+
const parseEnvContentToMap = (text: string): Array<{ key: string; value: string }> => {
131+
const rows: Array<{ key: string; value: string }>= [];
132+
try {
133+
const lines = (text || '').split(/\r?\n/);
134+
for (const raw of lines) {
135+
const line = raw.trim();
136+
if (!line || line.startsWith('#') || !line.includes('=')) continue;
137+
const idx = line.indexOf('=');
138+
const k = line.slice(0, idx).trim();
139+
let v = line.slice(idx + 1).trim();
140+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
141+
const lower = k.toLowerCase();
142+
const isSecret = lower.includes('secret') || lower.includes('key') || lower.includes('token') || lower.includes('password');
143+
const masked = isSecret && v ? (v.length <= 4 ? '****' : `${'*'.repeat(Math.max(4, v.length - 4))}${v.slice(-4)}`) : v;
144+
rows.push({ key: k, value: masked });
145+
}
146+
} catch {}
147+
return rows;
148+
};
149+
107150
const exportEnv = async () => {
108151
try {
109152
setError(null);
110153
setLoading(true);
111154
const data = await client.exportSettings();
112155
setEnvContent(data.env_content);
156+
setEnvMap(parseEnvContentToMap(data.env_content));
113157
} catch (err) {
114158
setError(err instanceof Error ? err.message : 'Failed to export settings');
115159
} finally {
@@ -347,6 +391,7 @@ function Settings({ client, readOnly = false }: SettingsProps) {
347391
options={[
348392
{ value: 'phaxio', label: 'Phaxio (Cloud)' },
349393
{ value: 'sinch', label: 'Sinch (Cloud)' },
394+
{ value: 'humblefax', label: 'HumbleFax (Cloud)' },
350395
{ value: 'signalwire', label: 'SignalWire (Cloud)' },
351396
{ value: 'documo', label: 'Documo (Cloud)' },
352397
{ value: 'sip', label: 'SIP/Asterisk (Self-hosted)' },
@@ -367,6 +412,7 @@ function Settings({ client, readOnly = false }: SettingsProps) {
367412
options={[
368413
{ value: 'phaxio', label: 'Phaxio (Webhook)' },
369414
{ value: 'sinch', label: 'Sinch (Webhook)' },
415+
{ value: 'humblefax', label: 'HumbleFax (Webhook)' },
370416
{ value: 'sip', label: 'SIP/Asterisk (Internal)' }
371417
]}
372418
showCurrentValue={true}
@@ -1364,6 +1410,55 @@ function Settings({ client, readOnly = false }: SettingsProps) {
13641410
</CardContent>
13651411
</Card>
13661412
)}
1413+
1414+
{/* Dedicated list of all environment variables (masked) */}
1415+
<Card sx={{ mt: 3 }}>
1416+
<CardContent>
1417+
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
1418+
<Typography variant="h6">All Environment Variables</Typography>
1419+
<Box>
1420+
<Button
1421+
variant="outlined"
1422+
startIcon={<RefreshIcon />}
1423+
onClick={exportEnv}
1424+
disabled={loading}
1425+
>
1426+
Refresh
1427+
</Button>
1428+
<Button
1429+
sx={{ ml: 1 }}
1430+
variant="outlined"
1431+
startIcon={<ContentCopyIcon />}
1432+
onClick={() => copyToClipboard(envContent)}
1433+
disabled={!envContent}
1434+
>
1435+
Copy .env
1436+
</Button>
1437+
</Box>
1438+
</Box>
1439+
{envMap.length === 0 ? (
1440+
<Typography color="text.secondary">No environment keys detected yet. Click Refresh to load current values.</Typography>
1441+
) : (
1442+
<Paper variant="outlined" sx={{ maxHeight: 380, overflow: 'auto', borderRadius: 2 }}>
1443+
<List dense>
1444+
{envMap.map(({ key, value }) => (
1445+
<ListItem key={key} divider>
1446+
<ListItemText
1447+
primary={key}
1448+
secondary={value}
1449+
primaryTypographyProps={{ sx: { fontFamily: 'monospace' } }}
1450+
secondaryTypographyProps={{ sx: { fontFamily: 'monospace' } }}
1451+
/>
1452+
</ListItem>
1453+
))}
1454+
</List>
1455+
</Paper>
1456+
)}
1457+
<Alert severity="info" sx={{ mt: 2 }}>
1458+
Secrets are masked in this list. Use Export .env to persist server-side.
1459+
</Alert>
1460+
</CardContent>
1461+
</Card>
13671462
{snack && (
13681463
<Alert severity="success" sx={{ mt: 2 }} onClose={() => setSnack(null)}>
13691464
{snack}

api/admin_ui/src/components/SetupWizard.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,18 @@ function SetupWizard({ client, onDone, docsBase }: SetupWizardProps) {
130130
})();
131131
}, [client]);
132132

133+
// Auto-populate PUBLIC_API_URL from active tunnel (if provided by backend status)
134+
useEffect(() => {
135+
(async () => {
136+
try {
137+
const res = await client.getTunnelStatus();
138+
if (res?.public_url) {
139+
setConfig(prev => ({ ...prev, public_api_url: prev.public_api_url || res.public_url }));
140+
}
141+
} catch {}
142+
})();
143+
}, [client]);
144+
133145
const handleValidate = async () => {
134146
setValidating(true);
135147
try {
@@ -737,6 +749,14 @@ function SetupWizard({ client, onDone, docsBase }: SetupWizardProps) {
737749
<Box component="pre" sx={{ p: 1, bgcolor: 'background.default', borderRadius: 1, overflow: 'auto' }}>{callbacks.callbacks[0].url}</Box>
738750
<Box sx={{ mt: 1, display: 'flex', gap: 1 }}>
739751
<Button variant="outlined" onClick={() => navigator.clipboard.writeText(callbacks.callbacks[0].url)}>Copy</Button>
752+
{config.inbound_backend === 'humblefax' && (
753+
<Button variant="outlined" onClick={async () => {
754+
try {
755+
const res = await (client as any).registerHumbleFaxWebhook?.();
756+
setSnack(res?.success ? 'HumbleFax webhook registered' : (res?.error || 'Registration failed'));
757+
} catch (e: any) { setSnack(e?.message || 'Registration failed'); }
758+
}}>Register HumbleFax Webhook</Button>
759+
)}
740760
<Button variant="outlined" onClick={async () => { try { await client.simulateInbound({ backend: config.backend }); setSnack('Simulated inbound received'); } catch(e:any){ setSnack(e?.message||'Simulation failed'); } }}>Simulate Inbound</Button>
741761
</Box>
742762
{callbacks.callbacks[0].preferred_content_type && (

api/admin_ui/src/components/TunnelSettings.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -219,11 +219,14 @@ export default function TunnelSettings({ client, docsBase, hipaaMode, readOnly =
219219
{(!status || status?.status === 'disabled') && (
220220
<Chip color="default" label="Disabled" size="small" sx={{ mr: 1 }} />
221221
)}
222-
{/* Intentionally do not display the public URL to keep implementation details abstracted. */}
223-
{status?.status === 'connected' && (
224-
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
225-
Connected via public URL
226-
</Typography>
222+
{status?.status === 'connected' && status?.public_url && (
223+
<Box sx={{ mt: 1, display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
224+
<Typography variant="caption" color="text.secondary">
225+
Public URL:
226+
</Typography>
227+
<Chip size="small" variant="outlined" label={status.public_url as string} />
228+
<Button size="small" variant="outlined" onClick={() => { navigator.clipboard.writeText(String(status.public_url || '')); setNotice({ severity: 'success', message: 'Copied public URL' }); }}>Copy</Button>
229+
</Box>
227230
)}
228231
</Box>
229232

0 commit comments

Comments
 (0)