SmsSettingsForm.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  1. "use client";
  2. import { useState, useTransition } from "react";
  3. import { useTranslations } from "next-intl";
  4. import { useRouter } from "next/navigation";
  5. import { toast } from "sonner";
  6. import { Input } from "@/components/ui/input";
  7. import { Button } from "@/components/ui/button";
  8. import { Label } from "@/components/ui/label";
  9. import { Switch } from "@/components/ui/switch";
  10. import { AppCard } from "@/components/app-card";
  11. import { Loader2, Send, Info, Copy, Check } from "lucide-react";
  12. import { ORG_SMS_KEYS } from "../Schema/smsSettingsSchema";
  13. import {
  14. setSmsSettings,
  15. testSmsSend,
  16. } from "../Actions/smsSettingsActions";
  17. import {
  18. ReadOnlyBanner,
  19. SaveButton,
  20. ReadOnlyWrapper,
  21. } from "@/app/(authenticated)/settings/read-only-guard";
  22. type SmsProviderType = "twilio" | "vonage" | "telnyx";
  23. export function SmsSettingsForm({
  24. initial,
  25. appUrl,
  26. }: {
  27. initial: Record<string, string>;
  28. appUrl: string;
  29. }) {
  30. const t = useTranslations("settings");
  31. const router = useRouter();
  32. const [isPending, startTransition] = useTransition();
  33. const [isTesting, setIsTesting] = useState(false);
  34. const [copied, setCopied] = useState(false);
  35. const [testPhone, setTestPhone] = useState("");
  36. const hasProvider = !!initial[ORG_SMS_KEYS.SMS_PROVIDER];
  37. const [enabled, setEnabled] = useState(hasProvider);
  38. const [smsProvider, setSmsProvider] = useState<SmsProviderType>(
  39. (initial[ORG_SMS_KEYS.SMS_PROVIDER] as SmsProviderType) || "twilio",
  40. );
  41. // Shared
  42. const [phoneNumber, setPhoneNumber] = useState(
  43. initial[ORG_SMS_KEYS.SMS_PHONE_NUMBER] || "",
  44. );
  45. // Twilio
  46. const [twilioAccountSid, setTwilioAccountSid] = useState(
  47. initial[ORG_SMS_KEYS.SMS_TWILIO_ACCOUNT_SID] || "",
  48. );
  49. const [twilioAuthToken, setTwilioAuthToken] = useState(
  50. initial[ORG_SMS_KEYS.SMS_TWILIO_AUTH_TOKEN] || "",
  51. );
  52. // Vonage
  53. const [vonageApiKey, setVonageApiKey] = useState(
  54. initial[ORG_SMS_KEYS.SMS_VONAGE_API_KEY] || "",
  55. );
  56. const [vonageApiSecret, setVonageApiSecret] = useState(
  57. initial[ORG_SMS_KEYS.SMS_VONAGE_API_SECRET] || "",
  58. );
  59. // Telnyx
  60. const [telnyxApiKey, setTelnyxApiKey] = useState(
  61. initial[ORG_SMS_KEYS.SMS_TELNYX_API_KEY] || "",
  62. );
  63. const webhookSecret = initial[ORG_SMS_KEYS.SMS_WEBHOOK_SECRET] || "";
  64. const webhookUrl = webhookSecret
  65. ? `${appUrl}/api/webhooks/sms/${smsProvider}?org_secret=${webhookSecret}`
  66. : "";
  67. const handleCopyWebhook = () => {
  68. if (!webhookUrl) return;
  69. navigator.clipboard.writeText(webhookUrl);
  70. setCopied(true);
  71. toast.success(t("sms.webhookCopied"));
  72. setTimeout(() => setCopied(false), 2000);
  73. };
  74. const handleSave = () => {
  75. startTransition(async () => {
  76. if (!enabled) {
  77. const result = await setSmsSettings({
  78. [ORG_SMS_KEYS.SMS_PROVIDER]: "",
  79. });
  80. if (result.success) {
  81. toast.success(t("sms.savedDisabled"));
  82. router.refresh();
  83. } else {
  84. toast.error(result.error ?? t("sms.failedSave"));
  85. }
  86. return;
  87. }
  88. const data: Record<string, string> = {
  89. [ORG_SMS_KEYS.SMS_PROVIDER]: smsProvider,
  90. [ORG_SMS_KEYS.SMS_PHONE_NUMBER]: phoneNumber,
  91. // Twilio
  92. [ORG_SMS_KEYS.SMS_TWILIO_ACCOUNT_SID]: twilioAccountSid,
  93. [ORG_SMS_KEYS.SMS_TWILIO_AUTH_TOKEN]: twilioAuthToken,
  94. // Vonage
  95. [ORG_SMS_KEYS.SMS_VONAGE_API_KEY]: vonageApiKey,
  96. [ORG_SMS_KEYS.SMS_VONAGE_API_SECRET]: vonageApiSecret,
  97. // Telnyx
  98. [ORG_SMS_KEYS.SMS_TELNYX_API_KEY]: telnyxApiKey,
  99. };
  100. const result = await setSmsSettings(data);
  101. if (result.success) {
  102. toast.success(t("sms.saved"));
  103. router.refresh();
  104. } else {
  105. toast.error(result.error ?? t("sms.failedSave"));
  106. }
  107. });
  108. };
  109. const handleTestSms = async () => {
  110. if (!testPhone.trim()) {
  111. toast.error(t("sms.enterTestPhone"));
  112. return;
  113. }
  114. setIsTesting(true);
  115. try {
  116. const result = await testSmsSend(testPhone.trim());
  117. if (result.success) {
  118. toast.success(t("sms.testSentTo", { phone: result.data?.sentTo ?? '' }));
  119. } else {
  120. toast.error(result.error ?? t("sms.testFailed"));
  121. }
  122. } finally {
  123. setIsTesting(false);
  124. }
  125. };
  126. const isTestDisabled =
  127. isTesting ||
  128. !enabled ||
  129. !phoneNumber ||
  130. !testPhone.trim() ||
  131. (smsProvider === "twilio" && (!twilioAccountSid || !twilioAuthToken)) ||
  132. (smsProvider === "vonage" && (!vonageApiKey || !vonageApiSecret)) ||
  133. (smsProvider === "telnyx" && !telnyxApiKey);
  134. return (
  135. <div className="space-y-6">
  136. <ReadOnlyBanner />
  137. <ReadOnlyWrapper>
  138. <AppCard
  139. title={t("sms.title")}
  140. description={t("sms.description")}
  141. contentClassName="space-y-6"
  142. >
  143. <div className="flex items-center justify-between">
  144. <div className="space-y-0.5">
  145. <Label htmlFor="enable-sms">{t("sms.enableLabel")}</Label>
  146. <p className="text-xs text-muted-foreground">
  147. {t("sms.enableHint")}
  148. </p>
  149. </div>
  150. <Switch
  151. id="enable-sms"
  152. checked={enabled}
  153. onCheckedChange={setEnabled}
  154. />
  155. </div>
  156. {!enabled && (
  157. <div className="flex items-start gap-3 rounded-lg border bg-muted/50 p-4">
  158. <Info className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
  159. <p className="text-sm text-muted-foreground">
  160. {t("sms.disabledInfo")}
  161. </p>
  162. </div>
  163. )}
  164. {enabled && (
  165. <>
  166. {/* Provider selection */}
  167. <div className="flex flex-wrap gap-2">
  168. <Button
  169. type="button"
  170. variant={smsProvider === "twilio" ? "default" : "outline"}
  171. onClick={() => setSmsProvider("twilio")}
  172. className="flex-1"
  173. >
  174. Twilio
  175. </Button>
  176. <Button
  177. type="button"
  178. variant={smsProvider === "vonage" ? "default" : "outline"}
  179. onClick={() => setSmsProvider("vonage")}
  180. className="flex-1"
  181. >
  182. Vonage
  183. </Button>
  184. <Button
  185. type="button"
  186. variant={smsProvider === "telnyx" ? "default" : "outline"}
  187. onClick={() => setSmsProvider("telnyx")}
  188. className="flex-1"
  189. >
  190. Telnyx
  191. </Button>
  192. </div>
  193. {/* Phone number (shared) */}
  194. <div className="space-y-2">
  195. <Label htmlFor="sms-phone-number">
  196. {t("sms.phoneNumber")}
  197. </Label>
  198. <Input
  199. id="sms-phone-number"
  200. placeholder={t("sms.phoneNumberPlaceholder")}
  201. value={phoneNumber}
  202. onChange={(e) => setPhoneNumber(e.target.value)}
  203. />
  204. <p className="text-xs text-muted-foreground">
  205. {t("sms.phoneNumberHint")}
  206. </p>
  207. </div>
  208. {/* Twilio fields */}
  209. {smsProvider === "twilio" && (
  210. <div className="grid gap-4 sm:grid-cols-2">
  211. <div className="space-y-2">
  212. <Label htmlFor="twilio-sid">
  213. {t("sms.accountSid")}
  214. </Label>
  215. <Input
  216. id="twilio-sid"
  217. type="password"
  218. placeholder="AC••••••••••••••••••••••••••••••••"
  219. value={twilioAccountSid}
  220. onChange={(e) => setTwilioAccountSid(e.target.value)}
  221. />
  222. </div>
  223. <div className="space-y-2">
  224. <Label htmlFor="twilio-token">
  225. {t("sms.authToken")}
  226. </Label>
  227. <Input
  228. id="twilio-token"
  229. type="password"
  230. placeholder="••••••••••••••••••••••••••••••••"
  231. value={twilioAuthToken}
  232. onChange={(e) => setTwilioAuthToken(e.target.value)}
  233. />
  234. </div>
  235. </div>
  236. )}
  237. {/* Vonage fields */}
  238. {smsProvider === "vonage" && (
  239. <div className="grid gap-4 sm:grid-cols-2">
  240. <div className="space-y-2">
  241. <Label htmlFor="vonage-key">{t("sms.apiKey")}</Label>
  242. <Input
  243. id="vonage-key"
  244. type="password"
  245. placeholder="••••••••"
  246. value={vonageApiKey}
  247. onChange={(e) => setVonageApiKey(e.target.value)}
  248. />
  249. </div>
  250. <div className="space-y-2">
  251. <Label htmlFor="vonage-secret">
  252. {t("sms.apiSecret")}
  253. </Label>
  254. <Input
  255. id="vonage-secret"
  256. type="password"
  257. placeholder="••••••••••••••••"
  258. value={vonageApiSecret}
  259. onChange={(e) => setVonageApiSecret(e.target.value)}
  260. />
  261. </div>
  262. </div>
  263. )}
  264. {/* Telnyx fields */}
  265. {smsProvider === "telnyx" && (
  266. <div className="space-y-2">
  267. <Label htmlFor="telnyx-key">{t("sms.apiKey")}</Label>
  268. <Input
  269. id="telnyx-key"
  270. type="password"
  271. placeholder="KEY••••••••••••••••••••••••••••••••"
  272. value={telnyxApiKey}
  273. onChange={(e) => setTelnyxApiKey(e.target.value)}
  274. />
  275. </div>
  276. )}
  277. {/* Webhook URL */}
  278. {webhookSecret && (
  279. <div className="space-y-2">
  280. <Label>{t("sms.webhookUrl")}</Label>
  281. <div className="flex items-center gap-2">
  282. <Input
  283. readOnly
  284. value={webhookUrl}
  285. className="font-mono text-xs"
  286. />
  287. <Button
  288. type="button"
  289. variant="outline"
  290. size="icon"
  291. onClick={handleCopyWebhook}
  292. aria-label={t("sms.copyWebhookUrl")}
  293. >
  294. {copied ? (
  295. <Check className="h-4 w-4" />
  296. ) : (
  297. <Copy className="h-4 w-4" />
  298. )}
  299. </Button>
  300. </div>
  301. <p className="text-xs text-muted-foreground">
  302. {t("sms.webhookUrlHint", { provider: smsProvider })}
  303. </p>
  304. </div>
  305. )}
  306. {/* Test SMS */}
  307. <div className="space-y-3 pt-2">
  308. <div className="space-y-2">
  309. <Label htmlFor="test-phone">
  310. {t("sms.testPhoneNumber")}
  311. </Label>
  312. <div className="flex items-center gap-2">
  313. <Input
  314. id="test-phone"
  315. placeholder={t("sms.testPhonePlaceholder")}
  316. value={testPhone}
  317. onChange={(e) => setTestPhone(e.target.value)}
  318. className="max-w-xs"
  319. />
  320. <Button
  321. type="button"
  322. variant="outline"
  323. onClick={handleTestSms}
  324. disabled={isTestDisabled}
  325. >
  326. {isTesting ? (
  327. <Loader2 className="mr-2 h-4 w-4 animate-spin" />
  328. ) : (
  329. <Send className="mr-2 h-4 w-4" />
  330. )}
  331. {t("sms.sendTestSms")}
  332. </Button>
  333. </div>
  334. <p className="text-xs text-muted-foreground">
  335. {t("sms.testSmsHint")}
  336. </p>
  337. </div>
  338. </div>
  339. </>
  340. )}
  341. </AppCard>
  342. <SaveButton>
  343. <div className="flex justify-end">
  344. <Button onClick={handleSave} disabled={isPending}>
  345. {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
  346. {t("sms.saveSettings")}
  347. </Button>
  348. </div>
  349. </SaveButton>
  350. </ReadOnlyWrapper>
  351. </div>
  352. );
  353. }