From 8e2fe315ce245aab5d8963a090c791d3c198ba73 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Wed, 22 Jul 2026 17:43:53 +0530 Subject: [PATCH] =?UTF-8?q?feat(settings):=20Org=20Settings=20=E2=86=92=20?= =?UTF-8?q?Integrations=20with=20BYO=20Twilio=20SMS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Settings tab renders an Integrations section; the SMS (Twilio) card lets a tenant admin connect their own Twilio account (Account SID / write-only Auth Token / E.164 from-number) via crm.settings.sms.configure, and shows the masked status (from-number + SID last-4) from crm.settings.sms.status. Demo mode stores hints locally. Email (SMTP) shown as the next provider on the same registry. Co-Authored-By: Claude Opus 4.8 --- src/app/dashboard/dashboard.css | 22 +++- src/components/dashboard/dashboard.tsx | 2 + src/components/dashboard/settings.tsx | 136 +++++++++++++++++++++++++ src/lib/sms-settings-api.ts | 78 ++++++++++++++ 4 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 src/components/dashboard/settings.tsx create mode 100644 src/lib/sms-settings-api.ts diff --git a/src/app/dashboard/dashboard.css b/src/app/dashboard/dashboard.css index cf5aa7c..d1b1a64 100644 --- a/src/app/dashboard/dashboard.css +++ b/src/app/dashboard/dashboard.css @@ -1155,4 +1155,24 @@ .dash-root .ai-bubble { max-width: 86%; } .dash-root .ai-view { height: calc(100vh - 150px); } } - \ No newline at end of file + + /* ---- Org Settings → Integrations ---- */ + .dash-root .settings-section { margin-top: 8px; } + .dash-root .settings-section-title { font-size: 13px; font-weight: 700; letter-spacing: 0.02em; text-transform: uppercase; color: var(--muted); margin: 0 0 14px; } + .dash-root .settings-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 16px; } + .dash-root .settings-card { border: 1px solid var(--border); background: var(--panel); border-radius: 16px; padding: 18px; display: flex; flex-direction: column; gap: 16px; } + .dash-root .settings-card.is-soon { opacity: 0.6; } + .dash-root .settings-card-head { display: flex; align-items: flex-start; gap: 12px; } + .dash-root .settings-card-ic { flex: 0 0 auto; width: 38px; height: 38px; display: grid; place-items: center; border-radius: 11px; background: color-mix(in srgb, var(--orange) 14%, transparent); color: var(--orange); } + .dash-root .settings-card-titles { flex: 1 1 auto; min-width: 0; } + .dash-root .settings-card-name { font-size: 15px; font-weight: 700; color: var(--text); } + .dash-root .settings-card-sub { font-weight: 500; color: var(--muted); } + .dash-root .settings-card-desc { font-size: 12.5px; color: var(--muted); margin-top: 2px; } + .dash-root .settings-card-body { display: flex; flex-direction: column; gap: 12px; } + .dash-root .settings-kv { display: grid; gap: 10px; margin: 0; } + .dash-root .settings-kv > div { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; border-bottom: 1px solid var(--border); padding-bottom: 8px; } + .dash-root .settings-kv > div:last-child { border-bottom: 0; padding-bottom: 0; } + .dash-root .settings-kv dt { font-size: 12.5px; color: var(--muted); } + .dash-root .settings-kv dd { margin: 0; font-size: 13px; font-weight: 600; color: var(--text); font-variant-numeric: tabular-nums; } + .dash-root .settings-card-actions { display: flex; gap: 8px; align-items: center; margin-top: 2px; } + .dash-root .settings-card-note { font-size: 12px; color: var(--muted); background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px; padding: 8px 12px; } diff --git a/src/components/dashboard/dashboard.tsx b/src/components/dashboard/dashboard.tsx index 9fa6bc6..cbf21fd 100644 --- a/src/components/dashboard/dashboard.tsx +++ b/src/components/dashboard/dashboard.tsx @@ -17,6 +17,7 @@ import { AiAssistant } from "./ai-assistant"; import { TeamManagement } from "./team-management"; import { MessengerSdk } from "./messenger-sdk"; import { InboxSdk } from "./inbox-sdk"; +import { Settings } from "./settings"; import "../../app/dashboard/dashboard.css"; export function Dashboard() { @@ -49,6 +50,7 @@ export function Dashboard() { : active === "ai" ? : active === "messenger" ? : active === "inbox" ? + : active === "settings" ? : active === "team" ? : } diff --git a/src/components/dashboard/settings.tsx b/src/components/dashboard/settings.tsx new file mode 100644 index 0000000..486d245 --- /dev/null +++ b/src/components/dashboard/settings.tsx @@ -0,0 +1,136 @@ +"use client"; + +// ============================================================ +// Org Settings → Integrations. Today: SMS (Twilio) — a tenant +// brings its OWN Twilio credentials, which be-crm seals in IIOS +// (per-scope) and resolves at send time. The auth token is +// write-only: sealed in IIOS, never read back, so status shows +// only masked hints (from-number + SID last-4). Email (SMTP) is +// the next provider on the same generic credential registry. +// ============================================================ + +import { useState } from "react"; +import { Btn, Field, Icon, PageHead, Pill, useToast } from "./ui"; +import { useSmsSettings } from "@/lib/sms-settings-api"; + +const SID_RE = /^AC[0-9a-fA-F]{32}$/; +const E164_RE = /^\+[1-9]\d{6,14}$/; + +export function Settings() { + return ( +
+ +
+

Integrations

+
+ + +
+
+
+ ); +} + +function TwilioCard() { + const toast = useToast(); + const { status, loading, live, configure } = useSmsSettings(); + const [editing, setEditing] = useState(false); + const [accountSid, setAccountSid] = useState(""); + const [authToken, setAuthToken] = useState(""); + const [fromNumber, setFromNumber] = useState(""); + const [errors, setErrors] = useState<{ accountSid?: string; authToken?: string; fromNumber?: string }>({}); + const [saving, setSaving] = useState(false); + + const showForm = editing || (!loading && !status.configured); + + function validate(): boolean { + const e: typeof errors = {}; + if (!SID_RE.test(accountSid.trim())) e.accountSid = "Must be a Twilio Account SID (AC + 32 hex chars)."; + if (!authToken.trim()) e.authToken = "Auth token is required."; + if (!E164_RE.test(fromNumber.trim())) e.fromNumber = "Must be E.164, e.g. +15551234567."; + setErrors(e); + return Object.keys(e).length === 0; + } + + async function save() { + if (!validate()) return; + setSaving(true); + try { + await configure({ accountSid: accountSid.trim(), authToken: authToken.trim(), fromNumber: fromNumber.trim() }); + toast.push({ tone: "success", title: "Twilio connected", desc: "Your SMS credentials are saved and encrypted." }); + setAccountSid(""); setAuthToken(""); setFromNumber(""); setErrors({}); setEditing(false); + } catch (err) { + toast.push({ tone: "error", title: "Couldn't save credentials", desc: (err as Error).message }); + } finally { + setSaving(false); + } + } + + return ( +
+
+ +
+
+ SMS · Twilio +
+
Send texts from your own Twilio number.
+
+ {status.configured + ? Connected + : Not connected} +
+ + {status.configured && !editing ? ( +
+
+
From number
{status.fromNumber ?? "—"}
+
Account SID
{status.sidLast4 ? `AC ···· ${status.sidLast4}` : "—"}
+
Status
{status.enabled ? "Active" : "Disabled"}
+
+ setEditing(true)}>Update credentials +
+ ) : null} + + {showForm ? ( +
+ + setAccountSid(e.target.value)} placeholder="ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" autoComplete="off" /> + + + setAuthToken(e.target.value)} placeholder="••••••••••••••••••••••••••••••••" autoComplete="off" /> + + + setFromNumber(e.target.value)} placeholder="+15551234567" autoComplete="off" /> + +
+ {saving ? "Saving…" : status.configured ? "Update" : "Connect Twilio"} + {status.configured ? { setEditing(false); setErrors({}); }}>Cancel : null} +
+
+ ) : null} + + {!live ?
Demo mode — credentials are stored locally and not sent to Twilio.
: null} +
+ ); +} + +function SmtpComingSoon() { + return ( +
+
+ +
+
Email · SMTP
+
Bring your own SMTP server for outbound email.
+
+ Coming soon +
+
+ ); +} diff --git a/src/lib/sms-settings-api.ts b/src/lib/sms-settings-api.ts new file mode 100644 index 0000000..7d05de3 --- /dev/null +++ b/src/lib/sms-settings-api.ts @@ -0,0 +1,78 @@ +"use client"; + +// SMS settings data layer. Serves EITHER the local mock (Shell not configured — the demo keeps +// working) OR the live be-crm data door (crm.settings.sms.*), behind one interface. +// +// Live contract (be-crm → IIOS BYO credential store): +// query crm.settings.sms.status {} -> { configured, enabled?, hints? } +// cmd crm.settings.sms.configure { accountSid, authToken, fromNumber } -> masked status +// The auth token is write-only: it is sealed in IIOS and NEVER returned — status carries only +// non-secret hints (from-number + SID last-4). + +import { useCallback, useState } from "react"; +import { useAppShell, useQuery } from "@abe-kap/appshell-sdk/react"; +import { isShellConfigured } from "./appshell"; + +export interface SmsCredentials { accountSid: string; authToken: string; fromNumber: string } + +export interface SmsStatus { + configured: boolean; + enabled: boolean; + fromNumber?: string; + sidLast4?: string; +} + +export interface SmsSettingsData { + live: boolean; + loading: boolean; + error: string | null; + status: SmsStatus; + configure: (input: SmsCredentials) => Promise; + refetch: () => void; +} + +interface StatusDTO { configured: boolean; enabled?: boolean; hints?: { fromNumber?: string; sidLast4?: string } } + +function toStatus(dto?: StatusDTO | null): SmsStatus { + return { + configured: !!dto?.configured, + enabled: dto?.enabled ?? false, + fromNumber: dto?.hints?.fromNumber, + sidLast4: dto?.hints?.sidLast4, + }; +} + +/* ---- Mock (demo mode) — stores only the non-secret hints, mirroring the masked live status ---- */ +function useMockSms(): SmsSettingsData { + const [status, setStatus] = useState({ configured: false, enabled: false }); + const configure = useCallback(async ({ accountSid, fromNumber }: SmsCredentials) => { + setStatus({ configured: true, enabled: true, fromNumber, sidLast4: accountSid.slice(-4) }); + }, []); + return { live: false, loading: false, error: null, status, configure, refetch: () => {} }; +} + +/* ---- Live (be-crm data door) ---- */ +function useLiveSms(): SmsSettingsData { + const { sdk } = useAppShell(); + const q = useQuery("crm.settings.sms.status", {}); + const configure = useCallback(async (input: SmsCredentials) => { + await sdk.command("crm.settings.sms.configure", { ...input }); + q.refetch(); + }, [sdk, q]); + return { + live: true, + loading: q.loading, + error: q.error ? String(q.error) : null, + status: toStatus(q.data), + configure, + refetch: q.refetch, + }; +} + +const SHELL = isShellConfigured(); + +export function useSmsSettings(): SmsSettingsData { + // SHELL is constant for the bundle's life (NEXT_PUBLIC_* is build-time), so the same hook path + // runs every render — Rules-of-Hooks safe. + return SHELL ? useLiveSms() : useMockSms(); +}