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 (
+
+ );
+}
+
+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 ? (
+
+ ) : 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();
+}