فهرست منبع

feat: SSO integration for Dealer Portal (Java/Tomcat → Chatbot)

- startSession accepts optional ssoToken (JWT signed with shared secret)
- Verifies JWT using DEALER_PORTAL_SSO_SECRET (HS256, Base64 key)
- Extracts customer_id (sub), company, sales_rep, email from payload
- Binds customerId + salesRep to conversation on session start
- Welcome message personalised: "Welcome back, <company>!" for SSO users
- Invalid/expired tokens fall through silently as anonymous visitors
- ChatbotWidgetLive reads ?sso_token= URL param and passes to mutation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tony T 5 روز پیش
والد
کامیت
0acd252bd5
4فایلهای تغییر یافته به همراه146 افزوده شده و 6 حذف شده
  1. 8 2
      client/src/components/ChatbotWidgetLive.tsx
  2. 2 0
      package.json
  3. 102 0
      pnpm-lock.yaml
  4. 34 4
      server/routers.ts

+ 8 - 2
client/src/components/ChatbotWidgetLive.tsx

@@ -2,7 +2,7 @@
  * Live Chatbot Widget — Tidio-inspired greeting with quick-reply buttons
  * Shows: "What can we help you with today?" + Orders, Shipping, Returning, Cancelling buttons
  */
-import { useState, useRef, useEffect } from "react";
+import { useState, useRef, useEffect, useMemo } from "react";
 import { useTranslation } from "react-i18next";
 import { trpc } from "@/lib/trpc";
 import { Bot, X, Send, Loader2, User, Headphones, Sparkles, ChevronDown, MoreVertical, Star } from "lucide-react";
@@ -31,6 +31,12 @@ export default function ChatbotWidgetLive() {
   const scrollContainerRef = useRef<HTMLDivElement>(null);
   const isNearBottomRef = useRef(true);
 
+  // Read SSO token injected by Dealer Portal redirect (?sso_token=<jwt>)
+  const ssoToken = useMemo(() => {
+    const params = new URLSearchParams(window.location.search);
+    return params.get("sso_token") ?? undefined;
+  }, []);
+
   const startSession = trpc.chat.startSession.useMutation({
     onSuccess: (data) => {
       setSessionId(data.sessionId);
@@ -91,7 +97,7 @@ export default function ChatbotWidgetLive() {
   const handleOpen = () => {
     setIsOpen(true);
     if (!sessionId) {
-      startSession.mutate({});
+      startSession.mutate({ ssoToken });
     }
   };
 

+ 2 - 0
package.json

@@ -63,6 +63,7 @@
     "i18next-browser-languagedetector": "^8.2.1",
     "input-otp": "^1.4.2",
     "jose": "6.1.0",
+    "jsonwebtoken": "^9.0.3",
     "lucide-react": "^0.453.0",
     "nanoid": "^5.1.5",
     "next-themes": "^0.4.6",
@@ -90,6 +91,7 @@
     "@types/bcryptjs": "^3.0.0",
     "@types/express": "4.17.21",
     "@types/google.maps": "^3.58.1",
+    "@types/jsonwebtoken": "^9.0.10",
     "@types/node": "^24.7.0",
     "@types/react": "^19.2.1",
     "@types/react-dom": "^19.2.1",

+ 102 - 0
pnpm-lock.yaml

@@ -166,6 +166,9 @@ importers:
       jose:
         specifier: 6.1.0
         version: 6.1.0
+      jsonwebtoken:
+        specifier: ^9.0.3
+        version: 9.0.3
       lucide-react:
         specifier: ^0.453.0
         version: 0.453.0(react@19.2.1)
@@ -242,6 +245,9 @@ importers:
       '@types/google.maps':
         specifier: ^3.58.1
         version: 3.58.1
+      '@types/jsonwebtoken':
+        specifier: ^9.0.10
+        version: 9.0.10
       '@types/node':
         specifier: ^24.7.0
         version: 24.7.0
@@ -2473,6 +2479,9 @@ packages:
   '@types/http-errors@2.0.5':
     resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
 
+  '@types/jsonwebtoken@9.0.10':
+    resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
+
   '@types/katex@0.16.7':
     resolution: {integrity: sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==}
 
@@ -2621,6 +2630,9 @@ packages:
     engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
     hasBin: true
 
+  buffer-equal-constant-time@1.0.1:
+    resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
+
   buffer-from@1.1.2:
     resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
 
@@ -3087,6 +3099,9 @@ packages:
     resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
     engines: {node: '>= 0.4'}
 
+  ecdsa-sig-formatter@1.0.11:
+    resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
+
   ee-first@1.1.1:
     resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
 
@@ -3461,6 +3476,16 @@ packages:
     engines: {node: '>=6'}
     hasBin: true
 
+  jsonwebtoken@9.0.3:
+    resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
+    engines: {node: '>=12', npm: '>=6'}
+
+  jwa@2.0.1:
+    resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
+
+  jws@4.0.1:
+    resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
+
   katex@0.16.25:
     resolution: {integrity: sha512-woHRUZ/iF23GBP1dkDQMh1QBad9dmr8/PAwNA54VrSOVYgI12MAcE14TqnDdQOdzyEonGzMepYnqBMYdsoAr8Q==}
     hasBin: true
@@ -3552,6 +3577,27 @@ packages:
   lodash-es@4.17.21:
     resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
 
+  lodash.includes@4.3.0:
+    resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
+
+  lodash.isboolean@3.0.3:
+    resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
+
+  lodash.isinteger@4.0.4:
+    resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
+
+  lodash.isnumber@3.0.3:
+    resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
+
+  lodash.isplainobject@4.0.6:
+    resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
+
+  lodash.isstring@4.0.1:
+    resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
+
+  lodash.once@4.1.1:
+    resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
+
   lodash@4.17.21:
     resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
 
@@ -4132,6 +4178,11 @@ packages:
     resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
     hasBin: true
 
+  semver@7.7.4:
+    resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
+    engines: {node: '>=10'}
+    hasBin: true
+
   send@0.19.0:
     resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==}
     engines: {node: '>= 0.8.0'}
@@ -6931,6 +6982,11 @@ snapshots:
 
   '@types/http-errors@2.0.5': {}
 
+  '@types/jsonwebtoken@9.0.10':
+    dependencies:
+      '@types/ms': 2.1.0
+      '@types/node': 24.7.0
+
   '@types/katex@0.16.7': {}
 
   '@types/mdast@4.0.4':
@@ -7106,6 +7162,8 @@ snapshots:
       node-releases: 2.0.23
       update-browserslist-db: 1.1.3(browserslist@4.26.3)
 
+  buffer-equal-constant-time@1.0.1: {}
+
   buffer-from@1.1.2: {}
 
   bytes@3.1.2: {}
@@ -7480,6 +7538,10 @@ snapshots:
       es-errors: 1.3.0
       gopd: 1.2.0
 
+  ecdsa-sig-formatter@1.0.11:
+    dependencies:
+      safe-buffer: 5.2.1
+
   ee-first@1.1.1: {}
 
   electron-to-chromium@1.5.232: {}
@@ -8000,6 +8062,30 @@ snapshots:
 
   json5@2.2.3: {}
 
+  jsonwebtoken@9.0.3:
+    dependencies:
+      jws: 4.0.1
+      lodash.includes: 4.3.0
+      lodash.isboolean: 3.0.3
+      lodash.isinteger: 4.0.4
+      lodash.isnumber: 3.0.3
+      lodash.isplainobject: 4.0.6
+      lodash.isstring: 4.0.1
+      lodash.once: 4.1.1
+      ms: 2.1.3
+      semver: 7.7.4
+
+  jwa@2.0.1:
+    dependencies:
+      buffer-equal-constant-time: 1.0.1
+      ecdsa-sig-formatter: 1.0.11
+      safe-buffer: 5.2.1
+
+  jws@4.0.1:
+    dependencies:
+      jwa: 2.0.1
+      safe-buffer: 5.2.1
+
   katex@0.16.25:
     dependencies:
       commander: 8.3.0
@@ -8073,6 +8159,20 @@ snapshots:
 
   lodash-es@4.17.21: {}
 
+  lodash.includes@4.3.0: {}
+
+  lodash.isboolean@3.0.3: {}
+
+  lodash.isinteger@4.0.4: {}
+
+  lodash.isnumber@3.0.3: {}
+
+  lodash.isplainobject@4.0.6: {}
+
+  lodash.isstring@4.0.1: {}
+
+  lodash.once@4.1.1: {}
+
   lodash@4.17.21: {}
 
   long@5.3.2:
@@ -8937,6 +9037,8 @@ snapshots:
 
   semver@6.3.1: {}
 
+  semver@7.7.4: {}
+
   send@0.19.0:
     dependencies:
       debug: 2.6.9

+ 34 - 4
server/routers.ts

@@ -8,6 +8,7 @@ import { publicProcedure, protectedProcedure, agentProcedure, adminProcedure, ro
 import { invokeLLM } from "./_core/llm";
 import { notifyOwner } from "./_core/notification";
 import bcrypt from "bcryptjs";
+import jwt from "jsonwebtoken";
 import {
   createConversation, getConversations, getConversationsAdvanced, getConversationById,
   getConversationBySessionId, updateConversationStatus, getConversationStats,
@@ -236,20 +237,49 @@ export const appRouter = router({
       .input(z.object({
         visitorName: z.string().optional(),
         visitorEmail: z.string().email().optional(),
+        ssoToken: z.string().optional(),
       }).optional())
       .mutation(async ({ input, ctx }) => {
+        // ── Verify Portal SSO token (if provided) ──────────────────────
+        let ssoCustomerId: string | undefined;
+        let ssoCompany:    string | undefined;
+        let ssoSalesRep:   string | undefined;
+        let ssoEmail:      string | undefined;
+
+        if (input?.ssoToken && ENV.dealerPortalSsoSecret) {
+          try {
+            const payload = jwt.verify(
+              input.ssoToken,
+              Buffer.from(ENV.dealerPortalSsoSecret, "base64"),
+            ) as jwt.JwtPayload;
+            ssoCustomerId = payload.sub        as string | undefined;
+            ssoCompany    = payload.company    as string | undefined;
+            ssoSalesRep   = payload.sales_rep  as string | undefined;
+            ssoEmail      = payload.email      as string | undefined;
+          } catch {
+            // Expired or invalid token — continue as anonymous visitor
+          }
+        }
+        // ───────────────────────────────────────────────────────────────
+
         const sessionId = nanoid(16);
         const conversation = await createConversation({
           sessionId,
-          visitorName: ctx.user?.name || input?.visitorName || "Visitor",
-          visitorEmail: ctx.user?.email || input?.visitorEmail,
-          customerId: ctx.user?.erpContactCid ?? undefined,
+          visitorName:  ctx.user?.name  || ssoCompany    || input?.visitorName  || "Visitor",
+          visitorEmail: ctx.user?.email || ssoEmail       || input?.visitorEmail,
+          customerId:   ctx.user?.erpContactCid || ssoCustomerId || undefined,
+          salesRep:     ssoSalesRep || undefined,
           status: "active",
         });
+
+        const welcomeMsg = ssoCompany
+          ? `Welcome back, **${ssoCompany}**! I'm **Ellie**, your Homelegance assistant. How can I help you today?`
+          : "Welcome to Homelegance! I'm **Ellie**, your AI furniture assistant.";
+
         await addMessage({
           conversationId: conversation.id,
           sender: "bot",
-          content: "Welcome to Homelegance! I'm **Ellie**, your AI furniture assistant.",
+          content: welcomeMsg,
           metadata: {
             quickReplies: ["🔥 Hot Deals", "📦 Order Status", "🛋️ Product Catalog"],
           },