feat(settings): Org Settings → Integrations with BYO Twilio SMS

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 17:43:53 +05:30
parent 462eb7f036
commit 8e2fe315ce
4 changed files with 237 additions and 1 deletions
+2
View File
@@ -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" ? <AiAssistant />
: active === "messenger" ? <MessengerSdk />
: active === "inbox" ? <InboxSdk />
: active === "settings" ? <Settings />
: active === "team" ? <TeamManagement />
: <ComingSoon title={title} icon={item?.icon ?? "dashboard"} onGo={setActive} />}
</ToastProvider>
+136
View File
@@ -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 (
<div className="view">
<PageHead
eyebrow="Configuration"
title="Org Settings"
subtitle="Integrations and workspace configuration"
icon="settings"
/>
<section className="settings-section">
<h3 className="settings-section-title">Integrations</h3>
<div className="settings-grid">
<TwilioCard />
<SmtpComingSoon />
</div>
</section>
</div>
);
}
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 (
<div className="settings-card">
<div className="settings-card-head">
<span className="settings-card-ic" aria-hidden="true"><Icon name="send" size={20} /></span>
<div className="settings-card-titles">
<div className="settings-card-name">
SMS <span className="settings-card-sub">· Twilio</span>
</div>
<div className="settings-card-desc">Send texts from your own Twilio number.</div>
</div>
{status.configured
? <Pill tone="green">Connected</Pill>
: <Pill tone="muted">Not connected</Pill>}
</div>
{status.configured && !editing ? (
<div className="settings-card-body">
<dl className="settings-kv">
<div><dt>From number</dt><dd>{status.fromNumber ?? "—"}</dd></div>
<div><dt>Account SID</dt><dd>{status.sidLast4 ? `AC ···· ${status.sidLast4}` : "—"}</dd></div>
<div><dt>Status</dt><dd>{status.enabled ? "Active" : "Disabled"}</dd></div>
</dl>
<Btn variant="outline" icon="settings" onClick={() => setEditing(true)}>Update credentials</Btn>
</div>
) : null}
{showForm ? (
<div className="settings-card-body">
<Field label="Account SID" required error={errors.accountSid} hint="Twilio Console → Account Info.">
<input className="ds-input" value={accountSid} onChange={(e) => setAccountSid(e.target.value)} placeholder="ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" autoComplete="off" />
</Field>
<Field label="Auth Token" required error={errors.authToken} hint="Encrypted on save and never shown again.">
<input className="ds-input" type="password" value={authToken} onChange={(e) => setAuthToken(e.target.value)} placeholder="••••••••••••••••••••••••••••••••" autoComplete="off" />
</Field>
<Field label="From number" required error={errors.fromNumber} hint="A Twilio number in E.164 format.">
<input className="ds-input" value={fromNumber} onChange={(e) => setFromNumber(e.target.value)} placeholder="+15551234567" autoComplete="off" />
</Field>
<div className="settings-card-actions">
<Btn icon="check-circle" onClick={save} disabled={saving}>{saving ? "Saving…" : status.configured ? "Update" : "Connect Twilio"}</Btn>
{status.configured ? <Btn variant="ghost" onClick={() => { setEditing(false); setErrors({}); }}>Cancel</Btn> : null}
</div>
</div>
) : null}
{!live ? <div className="settings-card-note">Demo mode credentials are stored locally and not sent to Twilio.</div> : null}
</div>
);
}
function SmtpComingSoon() {
return (
<div className="settings-card is-soon">
<div className="settings-card-head">
<span className="settings-card-ic" aria-hidden="true"><Icon name="mail" size={20} /></span>
<div className="settings-card-titles">
<div className="settings-card-name">Email <span className="settings-card-sub">· SMTP</span></div>
<div className="settings-card-desc">Bring your own SMTP server for outbound email.</div>
</div>
<Pill tone="muted">Coming soon</Pill>
</div>
</div>
);
}