import { defineSettingsFrontComponent } from 'twenty-sdk/define'; import React, { useEffect, useRef, useState } from 'react'; const SETTINGS = [ { key: 'INTAKE_APP_LABEL', label: 'App Label', defaultValue: 'Intake', description: 'The name used in generated content — note titles, opportunity names, and system messages. Change to match your team\'s terminology, e.g. "Leads", "Inbound", "Pipeline".', }, { key: 'INTAKE_DEFAULT_OPP_STAGE', label: 'Default Opportunity Stage', defaultValue: 'NEW', description: 'Stage assigned to automatically created Opportunities. Must match an existing stage value in your workspace (e.g. NEW, SCREENING, MEETING, PROPOSAL, CUSTOMER).', }, { key: 'INTAKE_FIELD_CREATION_ENABLED', label: 'Auto-create Custom Fields', defaultValue: 'true', description: 'When true, Intake automatically creates custom ext fields on Person and Company records when it encounters unknown payload keys. Set to false to route all unknown fields to the note instead.', }, { key: 'INTAKE_MAX_EXT_FIELDS', label: 'Max Custom Fields Per Object', defaultValue: '50', description: 'Maximum number of ext fields allowed per object. Once the limit is reached, any additional unknown fields from that object go to the overflow note instead of extending the schema.', }, { key: 'INTAKE_DEDUP_WINDOW_MINUTES', label: 'Deduplication Window (minutes)', defaultValue: '5', description: 'Payloads with identical content received within this window are treated as duplicates and skipped. Set to 0 to disable deduplication and accept every request.', }, { key: 'INTAKE_REQUIRE_HMAC', label: 'Require Signed Webhooks', defaultValue: 'false', description: 'When true, every Intake Source must have a webhook secret and all requests must be signed with HMAC-SHA256. Unsigned or incorrectly signed requests are rejected with 401 regardless of source config.', }, { key: 'INTAKE_MERGE_POLICY', label: 'Merge Policy', defaultValue: 'PRESERVE', description: 'What an update does to a field that already holds a value. PRESERVE fills blanks and leaves everything else alone, so a returning enquiry cannot overwrite a name someone corrected by hand. NEWEST_WINS lets the payload overwrite, which is how versions before 0.4.0 behaved. Under either setting, a blank value never overwrites.', }, { key: 'INTAKE_SPAM_FILTER_ENABLED', label: 'Spam Filtering', defaultValue: 'false', description: 'Score inbound payloads and hold anything at or above the threshold for review, before any record is created. Off by default. Watch the Spam Score on a few days of logs before switching it on. Honeypot fields work whether or not this is enabled.', }, { key: 'INTAKE_SPAM_SCORE_THRESHOLD', label: 'Spam Score Threshold', defaultValue: '5', description: 'The score at which a payload is quarantined. Individual signals are worth 2 to 4 points each, so the default requires two or more to agree. Lower is stricter.', }, { key: 'INTAKE_HONEYPOT_FIELD', label: 'Honeypot Field', defaultValue: '', description: 'Name of a form field hidden from people by CSS. Any payload arriving with it filled was filled by a script, and is quarantined immediately. Leave empty to disable. An individual source can override this.', }, { key: 'INTAKE_RAW_PAYLOAD_RETENTION', label: 'Raw Payload Retention', defaultValue: 'FULL', description: 'FULL keeps each payload on its log, which is what retry and replay run from. NONE keeps nothing and disables both. Passwords, tokens and card numbers are stripped before storage under either setting; everything else the sender submitted is kept as sent.', }, { key: 'INTAKE_RAW_PAYLOAD_MAX_BYTES', label: 'Max Stored Payload (bytes)', defaultValue: '65000', description: 'Largest payload kept for replay. Anything larger is still ingested normally, but not stored, and its log records that replay is unavailable.', }, { key: 'INTAKE_REPLAY_MAX_BATCH', label: 'Max Replay Batch', defaultValue: '50', description: 'How many logs one bulk replay may process. Every replayed log writes to the CRM, so this caps how much a single call can change. The hard ceiling is 500.', }, ] as const; type SettingKey = (typeof SETTINGS)[number]['key']; const InfoIcon: React.FC<{ description: string }> = ({ description }) => { const [visible, setVisible] = useState(false); const ref = useRef(null); return (
{visible && (
{description}
)}
); }; const SettingsPanel: React.FC = () => { const [values, setValues] = useState>( Object.fromEntries(SETTINGS.map(s => [s.key, s.defaultValue])), ); const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); useEffect(() => { const apiUrl = (window as any).__TWENTY_API_URL__ ?? ''; const token = (window as any).__TWENTY_API_TOKEN__ ?? ''; if (!apiUrl || !token) return; fetch(`${apiUrl}/graphql`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ query: `{ appTokens { edges { node { name } } } currentWorkspace { id } }` }), }) .then(r => r.json()) .then(d => { // Try to get current values from app variables endpoint return fetch(`${apiUrl}/graphql`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ query: `{ applications(filter: { name: { eq: "twenty-app-intake" } }) { edges { node { id applicationVariables } } } }`, }), }); }) .then(r => r.json()) .then(d => { const vars = d?.data?.applications?.edges?.[0]?.node?.applicationVariables ?? {}; if (typeof vars === 'object') { setValues(prev => ({ ...prev, ...Object.fromEntries( Object.entries(vars).filter(([k]) => prev[k] !== undefined).map(([k, v]) => [k, String(v)]) )})); } }) .catch(() => { /* use defaults */ }); }, []); const save = async () => { setStatus('saving'); const apiUrl = (window as any).__TWENTY_API_URL__ ?? ''; const token = (window as any).__TWENTY_API_TOKEN__ ?? ''; try { const app = await fetch(`${apiUrl}/graphql`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ query: `{ applications(filter: { name: { eq: "twenty-app-intake" } }) { edges { node { id } } } }`, }), }).then(r => r.json()); const appId = app?.data?.applications?.edges?.[0]?.node?.id; if (!appId) throw new Error('App not found'); await fetch(`${apiUrl}/graphql`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ query: `mutation UpdateApp($id: ID!, $data: ApplicationUpdateInput!) { updateApplication(id: $id, data: $data) { id } }`, variables: { id: appId, data: { applicationVariables: values } }, }), }); setStatus('saved'); setTimeout(() => setStatus('idle'), 2500); } catch { setStatus('error'); setTimeout(() => setStatus('idle'), 3000); } }; return (

Configuration

Set your application configuration variables. Hover the i icon for details.

{SETTINGS.map(s => (
setValues(prev => ({ ...prev, [s.key]: e.target.value }))} style={{ width: '100%', padding: '7px 10px', fontSize: 13, border: '1px solid #d1d5db', borderRadius: 6, outline: 'none', background: '#fff', boxSizing: 'border-box', fontFamily: 'inherit', }} onFocus={e => { e.target.style.borderColor = '#6366f1'; e.target.style.boxShadow = '0 0 0 3px rgba(99,102,241,0.1)'; }} onBlur={e => { e.target.style.borderColor = '#d1d5db'; e.target.style.boxShadow = 'none'; }} />
{s.key}
))}
); }; export default defineSettingsFrontComponent({ universalIdentifier: '4e4cfb3b-508c-413b-a1b2-192a51c31ed4', name: 'intake-settings-panel', description: 'Custom settings panel with inline documentation for all Intake configuration variables.', component: SettingsPanel, });