fix: harden identity, live access, drafts and setup (review only)

Source-reviewed hardening changes and regression tests. UNBUILT / UNTESTED: not a deployable release. Go formatting, compilation, automated regression tests, full frontend validation and staging acceptance remain required. See docs/hardening/VALIDATION.md. Main remains the original clean snapshot; no upstream Git history imported.
This commit is contained in:
2026-10-16 12:15:20 +00:00
parent e57610a27c
commit 63e0457886
86 changed files with 5174 additions and 1929 deletions

View File

@@ -1,37 +1,31 @@
// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics.
"use client"
import {
AlertTriangleIcon,
ArrowRightIcon,
CheckCircle2Icon,
Loader2Icon,
SparklesIcon,
} from "lucide-react"
import Link from "next/link"
import { useCallback, useEffect, useState } from "react"
import { toast } from "sonner"
import {
notifySetupReadinessUpdated,
SETUP_READINESS_UPDATED_EVENT,
} from "@/components/operator-readiness-indicator"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { useI18n } from "@/i18n/provider"
import {
applySetupQuickFix,
fetchSetupReadiness,
type SetupReadinessResponse,
} from "@/lib/api/setup-readiness"
export function SetupReadinessBanner({
onUpdated,
}: {
export function SetupReadinessBanner({ onUpdated }: {
onUpdated?: () => void
}) {
const t = useI18n()
const [readiness, setReadiness] = useState<SetupReadinessResponse | null>(null)
const [fixing, setFixing] = useState(false)
const loadReadiness = useCallback(async () => {
try {
@@ -46,29 +40,13 @@ export function SetupReadinessBanner({
void loadReadiness()
const handleUpdated = () => {
void loadReadiness()
onUpdated?.()
}
window.addEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated)
return () => {
window.removeEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated)
}
}, [loadReadiness])
async function handleQuickSetupAll() {
setFixing(true)
try {
const res = await applySetupQuickFix({ action: "quick_setup_all" })
setReadiness(res.readiness)
notifySetupReadinessUpdated()
onUpdated?.()
toast.success(t("setupWizard.quickFixSuccess"))
} catch (error) {
toast.error(
error instanceof Error ? error.message : t("common.operationFailed"),
)
} finally {
setFixing(false)
}
}
}, [loadReadiness, onUpdated])
if (!readiness) {
return null
@@ -137,24 +115,12 @@ export function SetupReadinessBanner({
<div className="flex shrink-0 flex-wrap items-center gap-2">
{canQuickSetupAll ? (
<Button
size="sm"
className="gap-1.5 rounded-md"
disabled={fixing}
onClick={() => void handleQuickSetupAll()}
>
{fixing ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<SparklesIcon className="size-4" />
)}
<span>
{fixing
? t("setupWizard.quickSetupRunning")
: t("setupWizard.quickSetupAll")}
</span>
<Button size="sm" className="gap-1.5 rounded-md" render={<Link href="/dashboard/setup" />}>
<SparklesIcon className="size-4" />
<span>{t("setupWizard.preview.review")}</span>
</Button>
) : null}
<Button
variant="outline"
size="sm"

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening/README.md.
// Modified for AgentDeskFixes: retain operator drafts and forward pending-send state.
"use client"
import { useEffect, useRef, useState } from "react"
@@ -11,11 +13,14 @@ import {
sendOperatorPresence,
useAgentConversationsStore,
} from "@/hooks/use-agent-conversation-realtime"
import type { MessageEditorDraftStore } from "@/lib/message-editor-drafts"
import { useI18n } from "@/i18n/provider"
import { fetchQuickReplyListAll, type AdminQuickReply } from "@/lib/api/admin"
import { suggestAgentReply } from "@/lib/api/agent"
type AgentMessageEditorProps = {
draftStore: MessageEditorDraftStore
sending?: boolean
disabled?: boolean
uploadingAsset?: boolean
onSend: (html: string) => Promise<void>
@@ -28,6 +33,8 @@ type AgentMessageEditorProps = {
* Supports quick replies picker and slash command (/) autocomplete.
*/
export function AgentMessageEditor({
draftStore,
sending = false,
disabled = false,
uploadingAsset = false,
onSend,
@@ -79,6 +86,8 @@ export function AgentMessageEditor({
<SharedMessageEditor
variant="agent"
conversationId={selectedConversationId}
draftStore={draftStore}
sending={sending}
disabled={disabled}
uploadingAsset={uploadingAsset}
quickReplies={{

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening/README.md.
// Modified for AgentDeskFixes: capture operator recipients and preserve failed message drafts.
"use client";
import {
@@ -61,6 +63,7 @@ import {
type AIWorkflowRun,
} from "@/lib/api/admin";
import { readSession } from "@/lib/auth";
import { createMessageEditorDraftStore } from "@/lib/message-editor-drafts";
import { renderIMMessageHTML } from "@/lib/im-message";
import {
agentConversationSelectors,
@@ -177,6 +180,8 @@ export function ChatPanel() {
null,
);
const shouldStickToBottomRef = useRef(true);
const [draftStore] = useState(createMessageEditorDraftStore);
useEffect(() => () => draftStore.clear(), [draftStore]);
const [claiming, setClaiming] = useState(false);
const [claimDialogOpen, setClaimDialogOpen] = useState(false);
const [transferDialogOpen, setTransferDialogOpen] = useState(false);
@@ -314,12 +319,19 @@ export function ChatPanel() {
};
const handleSend = async (html: string) => {
if (!conversation || sending || isClosedConversation) return;
// Bind the displayed draft to its recipient before the store reads selection.
const recipientId = conversation?.id;
try {
if (!recipientId || sending || isClosedConversation ||
useAgentConversationsStore.getState().selectedConversationId !== recipientId) {
throw new Error(t("conversation.sendMessageFailed"));
}
shouldStickToBottomRef.current = true;
await sendMessage(html);
const message = await sendMessage(html, recipientId);
if (!message) throw new Error(t("conversation.sendMessageFailed"));
} catch (error) {
toast.error(error instanceof Error ? error.message : t("conversation.sendMessageFailed"));
throw error;
}
};
@@ -495,20 +507,28 @@ export function ChatPanel() {
<div className="flex h-full min-h-0 flex-col">
<div className="min-h-0 flex-1">
<AgentMessageEditor
disabled={!conversation || sending}
draftStore={draftStore}
disabled={!conversation}
sending={sending}
uploadingAsset={uploadingAsset}
onSend={handleSend}
onUploadImage={async (file) => {
shouldStickToBottomRef.current = true;
const uploaded = await uploadImage(file);
return uploaded;
try {
return await uploadImage(file, conversation.id);
} catch (error) {
toast.error(error instanceof Error ? error.message : t("upload.imageUploadFailed"));
throw error;
}
}}
onSendAttachment={async (file) => {
shouldStickToBottomRef.current = true;
try {
await sendAttachment(file);
const message = await sendAttachment(file, conversation.id);
if (!message) throw new Error(t("conversation.sendAttachmentFailed"));
} catch (error) {
toast.error(error instanceof Error ? error.message : t("conversation.sendAttachmentFailed"));
throw error;
}
}}
/>

View File

@@ -1,3 +1,4 @@
// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics.
"use client"
import {
@@ -14,7 +15,7 @@ import {
XCircleIcon,
} from "lucide-react"
import Link from "next/link"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { toast } from "sonner"
import { DashboardPage } from "@/components/dashboard-page"
@@ -25,6 +26,7 @@ import {
import { OptionCombobox } from "@/components/option-combobox"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import {
Table,
TableBody,
@@ -39,6 +41,7 @@ import {
fetchSetupReadiness,
simulateSetupRoute,
type SetupQuickFixAction,
type SetupQuickFixResponse,
type SetupReadinessResponse,
type SetupSimulateRouteResponse,
type SetupStepStatus,
@@ -98,6 +101,15 @@ export function SetupWizardView() {
const [readiness, setReadiness] = useState<SetupReadinessResponse | null>(null)
const [loading, setLoading] = useState(true)
const [fixingAction, setFixingAction] = useState<string | null>(null)
const fixingRef = useRef(false)
const selectedChannelRef = useRef("")
const simulationRequestRef = useRef(0)
const readinessRequestRef = useRef(0)
const [pendingFix, setPendingFix] = useState<{
action: SetupQuickFixAction
entityId?: number
preview: SetupQuickFixResponse
} | null>(null)
const [selectedChannelId, setSelectedChannelId] = useState<string>("")
const [simulation, setSimulation] =
@@ -105,31 +117,38 @@ export function SetupWizardView() {
const [simulating, setSimulating] = useState(false)
const runSimulation = useCallback(async (channelIdNum?: number) => {
const requestId = ++simulationRequestRef.current
setSimulating(true)
setSimulation(null)
try {
const sim = await simulateSetupRoute({
channel_id: channelIdNum && channelIdNum > 0 ? channelIdNum : undefined,
bypass_ai: true,
bypass_ai: false,
})
setSimulation(sim)
if (requestId !== simulationRequestRef.current) return
setSimulation({ ...sim, candidate_operators: sim.candidate_operators ?? [] })
if (sim.channel_id > 0) {
selectedChannelRef.current = String(sim.channel_id)
setSelectedChannelId(String(sim.channel_id))
}
} catch (error) {
if (requestId !== simulationRequestRef.current) return
toast.error(
error instanceof Error ? error.message : t("common.operationFailed"),
)
} finally {
setSimulating(false)
if (requestId === simulationRequestRef.current) setSimulating(false)
}
}, [t])
const loadAll = useCallback(async () => {
const requestId = ++readinessRequestRef.current
setLoading(true)
try {
const data = await fetchSetupReadiness()
if (requestId !== readinessRequestRef.current) return
setReadiness(data)
const currentNum = Number(selectedChannelId)
const currentNum = Number(selectedChannelRef.current)
const targetChannelId =
currentNum > 0
? currentNum
@@ -141,13 +160,14 @@ export function SetupWizardView() {
}
await runSimulation(targetChannelId)
} catch (error) {
if (requestId !== readinessRequestRef.current) return
toast.error(
error instanceof Error ? error.message : t("common.loadFailed"),
)
} finally {
setLoading(false)
if (requestId === readinessRequestRef.current) setLoading(false)
}
}, [runSimulation, selectedChannelId, t])
}, [runSimulation, t])
useEffect(() => {
void loadAll()
@@ -157,30 +177,52 @@ export function SetupWizardView() {
window.addEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated)
return () => {
window.removeEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated)
simulationRequestRef.current++
readinessRequestRef.current++
}
}, [loadAll])
async function handleQuickFix(
action: SetupQuickFixAction,
entityId?: number,
) {
const key = entityId ? `${action}:${entityId}` : action
setFixingAction(key)
async function handleQuickFix(action: SetupQuickFixAction, entityId?: number) {
if (fixingRef.current) return
fixingRef.current = true
setFixingAction(entityId ? `${action}:${entityId}` : action)
try {
const res = await applySetupQuickFix({ action, entity_id: entityId })
setReadiness(res.readiness)
notifySetupReadinessUpdated()
toast.success(t("setupWizard.quickFixSuccess"))
const targetChannelId =
Number(selectedChannelId) ||
res.readiness.channels.find((ch) => ch.status === 0)?.id ||
0
await runSimulation(targetChannelId)
const preview = await applySetupQuickFix({ action, entity_id: entityId })
setPendingFix({ action, entityId, preview })
} catch (error) {
toast.error(
error instanceof Error ? error.message : t("common.operationFailed"),
)
toast.error(error instanceof Error ? error.message : t("common.operationFailed"))
} finally {
fixingRef.current = false
setFixingAction(null)
}
}
async function confirmQuickFix() {
if (!pendingFix || fixingRef.current) return
fixingRef.current = true
setFixingAction(pendingFix.action)
try {
const result = await applySetupQuickFix({
action: pendingFix.action,
entity_id: pendingFix.entityId,
apply: true,
preview_token: pendingFix.preview.preview_token,
})
if (!result.applied) throw new Error(t("setupWizard.preview.notApplied"))
if (result.readiness) setReadiness(result.readiness)
setPendingFix(null)
notifySetupReadinessUpdated()
if (result.warning_codes.length) {
toast.warning(result.warning_codes.map((code) => t(`setupWizard.repairWarnings.${code}`)).join(" "))
} else {
toast.success(t("setupWizard.quickFixSuccess"))
}
} catch (error) {
toast.error(error instanceof Error ? error.message : t("common.operationFailed"))
// A failed/stale preview must be reviewed again, not silently reapplied.
setPendingFix(null)
} finally {
fixingRef.current = false
setFixingAction(null)
}
}
@@ -418,7 +460,10 @@ export function SetupWizardView() {
</h2>
<div className="grid gap-3 md:grid-cols-2">
{readiness.steps.map((step) => {
const primaryQuickFix = step.quick_fix_actions[0]
const primaryQuickFix =
(step.key === "ai_agents" || step.key === "channels") && step.total_count > 0
? undefined
: step.quick_fix_actions[0]
return (
<div
key={step.key}
@@ -469,6 +514,11 @@ export function SetupWizardView() {
{t(`setupWizard.issues.${issue.code}`)}
</span>
</div>
{issue.quick_fix_action && issue.entity_id > 0 && issue.entity_type !== "operator" ? (
<Button size="sm" variant="outline" className="shrink-0 rounded-md" disabled={fixingAction !== null} onClick={() => void handleQuickFix(issue.quick_fix_action!, issue.entity_id)}>
{t("setupWizard.preview.review")}
</Button>
) : null}
</div>
))}
</div>
@@ -542,6 +592,7 @@ export function SetupWizardView() {
options={channelOptions}
value={selectedChannelId}
onChange={(val) => {
selectedChannelRef.current = val
setSelectedChannelId(val)
void runSimulation(Number(val))
}}
@@ -744,6 +795,39 @@ export function SetupWizardView() {
</div>
) : null}
</div>
<Dialog open={pendingFix !== null} onOpenChange={(open) => { if (!open && !fixingRef.current) setPendingFix(null) }}>
<DialogContent className="grid max-h-[85vh] grid-rows-[auto_minmax(0,1fr)_auto] rounded-md sm:max-w-2xl" showCloseButton={false}>
<DialogHeader>
<DialogTitle>{t("setupWizard.preview.title")}</DialogTitle>
<DialogDescription>{t("setupWizard.preview.description")}</DialogDescription>
</DialogHeader>
<div className="space-y-3 overflow-y-auto break-words">
{pendingFix?.preview.changes.length === 0 ? <p>{t("setupWizard.preview.noChanges")}</p> : null}
{pendingFix?.preview.changes.map((change, index) => (
<div key={`${change.entity_type}-${change.entity_id}-${index}`} className="space-y-2 rounded-md border p-3">
<p className="font-medium">{t(`setupWizard.preview.${change.operation}`)}: {change.entity_name}{change.entity_id > 0 ? ` (#${change.entity_id})` : ""}</p>
<dl className="space-y-1 text-xs">
{Object.entries(change.after).map(([field, value]) => (
<div key={field} className="grid gap-1 sm:grid-cols-[minmax(0,1fr)_minmax(0,2fr)]">
<dt className="text-muted-foreground">{t(`setupWizard.repairFields.${field}`)}</dt>
<dd>{change.before && field in change.before ? `${String(change.before[field])} → ` : ""}{String(value)}</dd>
</div>
))}
</dl>
</div>
))}
{pendingFix?.preview.warning_codes.map((code) => <p key={code} className="text-sm text-amber-700 dark:text-amber-300">{t(`setupWizard.repairWarnings.${code}`)}</p>)}
<Link className="text-sm underline" href="/dashboard/agent-team-schedules">{t("setupWizard.preview.manageSchedules")}</Link>
</div>
<DialogFooter>
<Button variant="outline" disabled={fixingAction !== null} onClick={() => setPendingFix(null)}>{t("common.cancel")}</Button>
<Button disabled={fixingAction !== null || !pendingFix?.preview.changes.length} onClick={() => void confirmQuickFix()}>
{fixingAction !== null ? <Loader2Icon className="size-4 animate-spin" /> : null}
{t("setupWizard.preview.apply")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</DashboardPage>
)
}

View File

@@ -1,8 +1,11 @@
// Modified in the independent hardening fork; see docs/hardening/README.md.
"use client"
import { CheckCircle2Icon } from "lucide-react"
import { MessageSquareIcon } from "lucide-react"
import { useI18n } from "@/i18n/provider"
import { Button } from "@/components/ui/button"
import { getStandaloneChatUrl } from "@/components/support-chat/close-navigation"
export default function Page() {
const t = useI18n()
@@ -10,15 +13,18 @@ export default function Page() {
return (
<main className="flex min-h-svh items-center justify-center bg-muted px-6 text-foreground">
<section className="grid max-w-sm justify-items-center gap-4 text-center">
<div className="flex size-14 items-center justify-center rounded-full bg-emerald-50 text-emerald-600 ring-1 ring-emerald-100 dark:bg-emerald-950/40 dark:text-emerald-300 dark:ring-emerald-900">
<CheckCircle2Icon className="size-7" />
<div className="flex size-14 items-center justify-center rounded-md border bg-card text-muted-foreground">
<MessageSquareIcon className="size-7" aria-hidden="true" />
</div>
<div className="grid gap-2">
<h1 className="text-lg font-semibold text-foreground">{t("supportChat.closedTitle")}</h1>
<h1 className="text-lg font-semibold text-foreground">{t("supportChat.dismissedTitle")}</h1>
<p className="text-sm leading-6 text-muted-foreground">
{t("supportChat.closedDescription")}
{t("supportChat.dismissedDescription")}
</p>
</div>
<Button onClick={() => window.location.replace(getStandaloneChatUrl(window.location.search))}>
{t("supportChat.returnToChat")}
</Button>
</section>
</main>
)

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening/README.md.
// Modified for AgentDeskFixes: prevent a late logout from redirecting a newer login.
"use client"
import {
@@ -11,7 +13,7 @@ import {
import { usePathname, useRouter } from "next/navigation"
import { SessionProvider, useSession } from "@/components/session-provider"
import { type AuthSession } from "@/lib/auth"
import { readSession, type AuthSession } from "@/lib/auth"
type AuthContextValue = {
session: AuthSession | null
@@ -46,17 +48,23 @@ function DashboardAuthProvider({ children }: { children: ReactNode }) {
try {
await endSession()
} finally {
startTransition(() => {
router.replace("/dashboard/login")
})
// A different tab or login flow may have established a new session meanwhile.
if (!readSession()) {
startTransition(() => {
router.replace("/dashboard/login")
})
}
}
}
useEffect(() => {
if (ready && !session && requiresAuth) {
startTransition(() => {
router.replace("/dashboard/login")
})
// A different tab or login flow may have established a new session meanwhile.
if (!readSession()) {
startTransition(() => {
router.replace("/dashboard/login")
})
}
}
}, [ready, requiresAuth, router, session])

View File

@@ -1,6 +1,8 @@
// Modified in the independent hardening fork; see docs/hardening/README.md.
// Modified for AgentDeskFixes: preserve scoped drafts and guard asynchronous editor operations.
"use client"
import { useEffect, useRef, useState, type ChangeEvent } from "react"
import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, type ChangeEvent } from "react"
import Placeholder from "@tiptap/extension-placeholder"
import { EditorContent, useEditor } from "@tiptap/react"
import StarterKit from "@tiptap/starter-kit"
@@ -32,10 +34,14 @@ import {
MessageImageExtension,
removeEditorImageByTitle,
revokeEditorObjectUrl,
revokeEditorObjectUrls,
setEditorImageUploadingByTitle,
type UploadedEditorImage,
} from "@/lib/im-editor-image"
import {
createMessageEditorDraftStore,
type MessageEditorDraft,
type MessageEditorDraftStore,
} from "@/lib/message-editor-drafts"
import { generateUUID } from "@/lib/utils"
import { useI18n } from "@/i18n/provider"
@@ -60,6 +66,9 @@ export type SharedMessageEditorAICopilot = {
export type SharedMessageEditorProps = {
variant: SharedMessageEditorVariant
conversationId?: number | null
draftScope?: string
draftStore?: MessageEditorDraftStore
sending?: boolean
disabled?: boolean
uploadingAsset?: boolean
manageLocalUploading?: boolean
@@ -79,6 +88,9 @@ export type SharedMessageEditorProps = {
export function SharedMessageEditor({
variant,
conversationId,
draftScope = "default",
draftStore,
sending = false,
disabled = false,
uploadingAsset = false,
manageLocalUploading = false,
@@ -91,6 +103,18 @@ export function SharedMessageEditor({
}: SharedMessageEditorProps) {
const t = useI18n()
const [localUploading, setLocalUploading] = useState(false)
const [localDraftStore] = useState(createMessageEditorDraftStore)
const drafts = draftStore ?? localDraftStore
const draftSending = useSyncExternalStore(drafts.subscribeSending, drafts.isSending, () => false)
const draftKey = String(conversationId ?? 0)
const displayedDraftRef = useRef<{ key: string; draft: MessageEditorDraft } | null>(null)
const contextRef = useRef({ draftKey, draftScope })
contextRef.current = { draftKey, draftScope }
const mountedRef = useRef(false)
const suggestionRequestRef = useRef(0)
const suggestionPendingRef = useRef(false)
const uploadingRef = useRef(false)
const mutationGenerationRef = useRef(0)
const [quickReplySearch, setQuickReplySearch] = useState("")
const [suggestedReply, setSuggestedReply] = useState<string | null>(null)
const [isGeneratingSuggestion, setIsGeneratingSuggestion] = useState(false)
@@ -105,11 +129,10 @@ export function SharedMessageEditor({
const onSendAttachmentRef = useRef(onSendAttachment)
const onTypingRef = useRef(onTyping)
const shouldRestoreFocusRef = useRef(false)
const objectUrlsRef = useRef<Set<string>>(new Set())
const uploadedImagesRef = useRef(new Map<string, UploadedMessageEditorImage>())
const placeholderRef = useRef(t("conversation.editorPlaceholder"))
const isCustomer = variant === "customer"
const isUploading = uploadingAsset || (manageLocalUploading && localUploading)
const isSending = sending || draftSending
placeholderRef.current = t("conversation.editorPlaceholder")
@@ -118,31 +141,21 @@ export function SharedMessageEditor({
}, [quickReplies])
useEffect(() => {
const objectUrls = objectUrlsRef.current
mountedRef.current = true
return () => {
revokeEditorObjectUrls(objectUrls)
mountedRef.current = false
suggestionRequestRef.current += 1
// A supplied store belongs to the parent and survives editor unmounts.
if (!draftStore) localDraftStore.clear()
}
}, [])
}, [draftStore, localDraftStore])
useEffect(() => {
useLayoutEffect(() => {
onSendRef.current = onSend
}, [onSend])
useEffect(() => {
onUploadImageRef.current = onUploadImage
}, [onUploadImage])
useEffect(() => {
onSendAttachmentRef.current = onSendAttachment
}, [onSendAttachment])
useEffect(() => {
onTypingRef.current = onTyping
}, [onTyping])
useEffect(() => {
setSuggestedReply(null)
}, [conversationId])
}, [onSend, onUploadImage, onSendAttachment, onTyping])
const editor = useEditor({
immediatelyRender: false,
@@ -162,6 +175,8 @@ export function SharedMessageEditor({
],
content: "",
onUpdate: ({ editor }) => {
const displayed = displayedDraftRef.current
if (displayed) drafts.update(displayed.draft, editor.getHTML())
if (!editor.isEmpty && isMeaningfulHTML(editor.getHTML())) {
onTypingRef.current?.()
}
@@ -227,7 +242,7 @@ export function SharedMessageEditor({
return false
},
handlePaste: (_view, event) => {
if (disabled || isUploading) {
if (disabled || isUploading || isSending) {
return false
}
const imageFile = getClipboardImageFile(event.clipboardData)
@@ -241,6 +256,49 @@ export function SharedMessageEditor({
},
})
useLayoutEffect(() => {
mutationGenerationRef.current += 1
uploadingRef.current = false
setLocalUploading(false)
drafts.setScope(draftScope)
}, [drafts, draftScope])
useLayoutEffect(() => {
if (!editor) return
const draft = drafts.get(draftKey)
displayedDraftRef.current = { key: draftKey, draft }
editor.commands.setContent(draft.html, { emitUpdate: false })
// Image metadata is retained separately while a placeholder upload settles.
for (const [title, uploaded] of draft.uploadedImages) {
markEditorImageUploadedByTitle(editor, title, uploaded, draft.uploadedImages)
}
suggestionRequestRef.current += 1
suggestionPendingRef.current = false
setSuggestedReply(null)
setIsGeneratingSuggestion(false)
setQuickReplySearch("")
isSlashTriggeredRef.current = false
quickRepliesRef.current?.onOpenChange(false)
}, [draftKey, draftScope, drafts, editor])
useLayoutEffect(() => {
if (!editor) return
// The parent-owned draft may finish sending after this editor was remounted.
return drafts.subscribe((draft) => {
if (editor.isDestroyed || displayedDraftRef.current?.draft !== draft) return
if (editor.getHTML() !== draft.html) editor.commands.setContent(draft.html, { emitUpdate: false })
for (const [title, uploaded] of draft.uploadedImages) {
markEditorImageUploadedByTitle(editor, title, uploaded, draft.uploadedImages)
}
if (!draft.html) {
setSuggestedReply(null)
setQuickReplySearch("")
isSlashTriggeredRef.current = false
quickRepliesRef.current?.onOpenChange(false)
}
})
}, [drafts, editor])
useEffect(() => {
if (!editor) {
return
@@ -252,42 +310,45 @@ export function SharedMessageEditor({
if (!editor || disabled || isUploading || !shouldRestoreFocusRef.current) {
return
}
requestAnimationFrame(() => {
editor.commands.focus()
const draft = displayedDraftRef.current?.draft
const frame = requestAnimationFrame(() => {
if (!editor.isDestroyed && displayedDraftRef.current?.draft === draft) editor.commands.focus()
})
return () => cancelAnimationFrame(frame)
}, [disabled, editor, isUploading])
async function handleSend() {
if (!editor || disabled || isUploading) {
return
}
const displayed = displayedDraftRef.current
if (!editor || !displayed || disabled || isUploading || isSending || drafts.isSending() || uploadingRef.current) return
if (displayed.key !== contextRef.current.draftKey) return
const rawHTML = editor.getHTML()
if (hasUploadingEditorImages(rawHTML, uploadedImagesRef.current)) {
return
}
const html = buildSendableEditorHTML(rawHTML, uploadedImagesRef.current)
if (!isMeaningfulHTML(html)) {
return
}
await onSendRef.current(html)
editor.commands.clearContent(true)
revokeEditorObjectUrls(objectUrlsRef.current)
uploadedImagesRef.current.clear()
setQuickReplySearch("")
isSlashTriggeredRef.current = false
quickRepliesRef.current?.onOpenChange(false)
setSuggestedReply(null)
if (!isCustomer) {
requestAnimationFrame(() => {
editor.commands.focus("end")
})
if (hasUploadingEditorImages(rawHTML, displayed.draft.uploadedImages)) return
const html = buildSendableEditorHTML(rawHTML, displayed.draft.uploadedImages)
if (!isMeaningfulHTML(html)) return
const sent = drafts.beginSend(displayed.key)
if (!sent) return
const send = onSendRef.current
try {
await send(html)
const cleared = drafts.finishSend(sent, true)
if (!cleared || !mountedRef.current || editor.isDestroyed || displayedDraftRef.current?.draft !== sent.draft) return
editor.commands.setContent("", { emitUpdate: false })
setQuickReplySearch("")
isSlashTriggeredRef.current = false
quickRepliesRef.current?.onOpenChange(false)
setSuggestedReply(null)
if (!isCustomer) focusEditorEnd()
} catch {
// The caller displays the error. Keep the entire draft, including images, for retry.
drafts.finishSend(sent, false)
}
}
async function handleSelectImage(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]
event.target.value = ""
if (!file || !editor || disabled || isUploading) {
if (!file || !editor || disabled || isUploading || isSending) {
restoreFocusIfNeeded()
return
}
@@ -295,68 +356,86 @@ export function SharedMessageEditor({
}
async function insertUploadedImage(file: File) {
if (!editor || disabled || isUploading) {
return
}
const displayed = displayedDraftRef.current
if (!editor || !displayed || disabled || isUploading || isSending || drafts.isSending() || uploadingRef.current) return
const { key, draft } = displayed
const upload = onUploadImageRef.current
const generation = mutationGenerationRef.current
const isVisible = () => mountedRef.current && !editor.isDestroyed && displayedDraftRef.current?.draft === draft
shouldRestoreFocusRef.current = true
uploadingRef.current = true
const objectUrl = URL.createObjectURL(file)
objectUrlsRef.current.add(objectUrl)
draft.objectUrls.add(objectUrl)
const placeholderId = `uploading-${generateUUID()}`
editor
.chain()
.focus()
.setImage({
src: objectUrl,
alt: file.name || "uploading-image",
title: placeholderId,
})
.run()
editor.chain().focus().setImage({
src: objectUrl,
alt: file.name || "uploading-image",
title: placeholderId,
}).run()
setEditorImageUploadingByTitle(editor, placeholderId)
const removePlaceholder = () => {
if (!drafts.contains(key, draft)) return
if (isVisible()) {
removeEditorImageByTitle(editor, placeholderId)
} else {
const template = document.createElement("template")
template.innerHTML = draft.html
for (const image of template.content.querySelectorAll("img")) {
if (image.getAttribute("title") === placeholderId) image.remove()
}
drafts.update(draft, template.innerHTML)
drafts.notify(draft)
}
revokeEditorObjectUrl(draft.objectUrls, objectUrl)
}
try {
setLocalUploading(true)
const uploaded = await onUploadImageRef.current(file)
const uploaded = await upload(file)
if (!drafts.contains(key, draft)) return
if (!uploaded?.assetId || !uploaded.provider || !uploaded.storageKey) {
removeEditorImageByTitle(editor, placeholderId)
revokeEditorObjectUrl(objectUrlsRef.current, objectUrl)
removePlaceholder()
return
}
markEditorImageUploadedByTitle(
editor,
placeholderId,
uploaded,
uploadedImagesRef.current
)
draft.uploadedImages.set(placeholderId, uploaded)
drafts.notify(draft)
} catch {
removePlaceholder()
} finally {
setLocalUploading(false)
requestAnimationFrame(() => {
if (!disabled && shouldRestoreFocusRef.current) {
editor.commands.focus()
}
})
if (generation === mutationGenerationRef.current) {
uploadingRef.current = false
if (mountedRef.current) setLocalUploading(false)
}
if (isVisible()) restoreFocusIfNeeded()
}
}
async function handleSelectAttachment(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]
event.target.value = ""
if (!file || disabled || isUploading) {
if (!file || disabled || isUploading || isSending || drafts.isSending() || uploadingRef.current) {
restoreFocusIfNeeded()
return
}
const draft = displayedDraftRef.current?.draft
const sendAttachment = onSendAttachmentRef.current
const generation = mutationGenerationRef.current
shouldRestoreFocusRef.current = editor?.isFocused ?? true
uploadingRef.current = true
try {
setLocalUploading(true)
await onSendAttachmentRef.current(file)
await sendAttachment(file)
} catch {
// The caller reports failed attachment sends; no text draft is discarded.
} finally {
setLocalUploading(false)
requestAnimationFrame(() => {
if (editor && !disabled && shouldRestoreFocusRef.current) {
editor.commands.focus()
}
})
if (generation === mutationGenerationRef.current) {
uploadingRef.current = false
if (mountedRef.current) setLocalUploading(false)
}
if (displayedDraftRef.current?.draft === draft) restoreFocusIfNeeded()
}
}
@@ -396,15 +475,23 @@ export function SharedMessageEditor({
quickRepliesRef.current?.onOpenChange(false)
setQuickReplySearch("")
isSlashTriggeredRef.current = false
focusEditorEnd()
}
function focusEditorEnd() {
const draft = displayedDraftRef.current?.draft
requestAnimationFrame(() => {
editor.commands.focus("end")
if (editor && mountedRef.current && !editor.isDestroyed && displayedDraftRef.current?.draft === draft) {
editor.commands.focus("end")
}
})
}
function restoreFocusIfNeeded() {
if (editor && shouldRestoreFocusRef.current) {
const draft = displayedDraftRef.current?.draft
requestAnimationFrame(() => {
editor.commands.focus()
if (mountedRef.current && !editor.isDestroyed && displayedDraftRef.current?.draft === draft) editor.commands.focus()
})
}
}
@@ -412,17 +499,23 @@ export function SharedMessageEditor({
const isGenerating = isGeneratingSuggestion || Boolean(aiCopilot?.loading)
async function handleTriggerSuggest() {
if (!aiCopilot || disabled || isUploading || isGenerating) {
return
}
if (!aiCopilot || disabled || isUploading || isGenerating || suggestionPendingRef.current) return
const requestId = ++suggestionRequestRef.current
suggestionPendingRef.current = true
const context = contextRef.current
setIsGeneratingSuggestion(true)
try {
const text = await aiCopilot.onSuggest()
if (text && text.trim()) {
if (mountedRef.current && requestId === suggestionRequestRef.current && context.draftKey === contextRef.current.draftKey && context.draftScope === contextRef.current.draftScope && text?.trim()) {
setSuggestedReply(text.trim())
}
} catch {
// The caller displays the suggestion error. Ignore any late failure here.
} finally {
setIsGeneratingSuggestion(false)
if (mountedRef.current && requestId === suggestionRequestRef.current) {
suggestionPendingRef.current = false
setIsGeneratingSuggestion(false)
}
}
}
@@ -432,9 +525,7 @@ export function SharedMessageEditor({
}
editor.chain().focus().insertContent(suggestedReply).run()
setSuggestedReply(null)
requestAnimationFrame(() => {
editor.commands.focus("end")
})
focusEditorEnd()
}
function handleDismissSuggestedReply() {
@@ -520,7 +611,7 @@ export function SharedMessageEditor({
shouldRestoreFocusRef.current = editor?.isFocused ?? true
imageInputRef.current?.click()
}}
disabled={disabled || isUploading}
disabled={disabled || isUploading || isSending}
aria-label={isUploading ? t("conversation.imageUploading") : t("conversation.sendImage")}
title={isUploading ? t("conversation.imageUploading") : t("conversation.sendImage")}
>
@@ -536,7 +627,7 @@ export function SharedMessageEditor({
shouldRestoreFocusRef.current = editor?.isFocused ?? true
attachmentInputRef.current?.click()
}}
disabled={disabled || isUploading}
disabled={disabled || isUploading || isSending}
aria-label={isUploading ? t("conversation.attachmentUploading") : t("conversation.sendAttachment")}
title={isUploading ? t("conversation.attachmentUploading") : t("conversation.sendAttachment")}
>
@@ -640,21 +731,23 @@ export function SharedMessageEditor({
type="button"
size="icon"
onClick={() => void handleSend()}
disabled={disabled || isUploading}
aria-busy={isSending}
disabled={disabled || isUploading || isSending}
aria-label={t("conversation.send")}
title={t("conversation.send")}
className="bg-primary text-white shadow-[0_10px_20px_color-mix(in_srgb,var(--primary)_24%,transparent)] hover:bg-primary hover:brightness-105"
>
<SendHorizonalIcon />
{isSending ? <Loader2Icon className="animate-spin" /> : <SendHorizonalIcon />}
</Button>
) : (
<Button
type="button"
size="sm"
onClick={() => void handleSend()}
disabled={disabled || isUploading}
aria-busy={isSending}
disabled={disabled || isUploading || isSending}
>
<SendIcon className="mr-1 size-4" />
{isSending ? <Loader2Icon className="mr-1 size-4 animate-spin" /> : <SendIcon className="mr-1 size-4" />}
{isUploading ? t("conversation.uploading") : t("conversation.send")}
</Button>
)}

View File

@@ -1,9 +1,9 @@
// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics.
"use client"
import {
AlertTriangleIcon,
CheckCircle2Icon,
Loader2Icon,
RefreshCwIcon,
ShieldAlertIcon,
SparklesIcon,
@@ -11,7 +11,6 @@ import {
} from "lucide-react"
import Link from "next/link"
import { useCallback, useEffect, useState } from "react"
import { toast } from "sonner"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
@@ -22,9 +21,7 @@ import {
} from "@/components/ui/popover"
import { useI18n } from "@/i18n/provider"
import {
applySetupQuickFix,
fetchSetupReadiness,
type SetupQuickFixAction,
type SetupReadinessResponse,
} from "@/lib/api/setup-readiness"
import { cn } from "@/lib/utils"
@@ -45,7 +42,6 @@ export function OperatorReadinessIndicator({
const t = useI18n()
const [readiness, setReadiness] = useState<SetupReadinessResponse | null>(null)
const [loading, setLoading] = useState(true)
const [fixing, setFixing] = useState(false)
const [open, setOpen] = useState(false)
const loadReadiness = useCallback(async () => {
@@ -66,30 +62,14 @@ export function OperatorReadinessIndicator({
}
window.addEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated)
const interval = window.setInterval(() => {
void loadReadiness()
}, 45000)
if (document.visibilityState === "visible") void loadReadiness()
}, 60000)
return () => {
window.removeEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated)
window.clearInterval(interval)
}
}, [loadReadiness])
async function handleQuickFix(action: SetupQuickFixAction) {
setFixing(true)
try {
const res = await applySetupQuickFix({ action })
setReadiness(res.readiness)
notifySetupReadinessUpdated()
toast.success(t("setupWizard.quickFixSuccess"))
} catch (error) {
toast.error(
error instanceof Error ? error.message : t("common.operationFailed"),
)
} finally {
setFixing(false)
}
}
if (loading && !readiness) {
return null
}
@@ -225,43 +205,10 @@ export function OperatorReadinessIndicator({
</div>
<div className="flex flex-col gap-1.5">
{!operatorOk && canEnableMyProfile ? (
<Button
size="sm"
className="w-full gap-1.5 rounded-md text-xs"
disabled={fixing}
onClick={() =>
void handleQuickFix(
chainOk || !canQuickSetupAll
? "enable_my_operator_profile"
: "quick_setup_all",
)
}
>
{fixing ? (
<Loader2Icon className="size-3.5 animate-spin" />
) : (
<SparklesIcon className="size-3.5" />
)}
<span>
{chainOk || !canQuickSetupAll
? t("setupWizard.enableMyReceptionBtn")
: t("setupWizard.quickSetupAll")}
</span>
</Button>
) : !chainOk && canQuickSetupAll ? (
<Button
size="sm"
className="w-full gap-1.5 rounded-md text-xs"
disabled={fixing}
onClick={() => void handleQuickFix("quick_setup_all")}
>
{fixing ? (
<Loader2Icon className="size-3.5 animate-spin" />
) : (
<SparklesIcon className="size-3.5" />
)}
<span>{t("setupWizard.quickSetupAll")}</span>
{(!operatorOk && canEnableMyProfile) || (!chainOk && canQuickSetupAll) ? (
<Button size="sm" className="w-full gap-1.5 rounded-md text-xs" render={<Link href="/dashboard/setup" />}>
<SparklesIcon className="size-3.5" />
<span>{t("setupWizard.preview.review")}</span>
</Button>
) : null}

View File

@@ -1,6 +1,8 @@
// Modified in the independent hardening fork; see docs/hardening/README.md.
// Modified for AgentDeskFixes: invalidate stale profile/sign-out work and clear private account state.
"use client"
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react"
import { Fragment, createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from "react"
import { fetchProfile, logout } from "@/lib/api/auth"
import {
@@ -11,6 +13,8 @@ import {
writeSession,
type AuthSession,
} from "@/lib/auth"
import { createSessionRequestGuard } from "@/lib/session-request-guard"
import { resetAgentConversations } from "@/lib/stores/agent-conversations"
type SessionContextValue = {
session: AuthSession | null
@@ -22,21 +26,41 @@ type SessionContextValue = {
const SessionContext = createContext<SessionContextValue | null>(null)
function accountKey(session: AuthSession | null) {
return session ? `${session.user.userType}:${session.user.id}` : "anonymous"
}
export function SessionProvider({ children }: { children: ReactNode }) {
const [session, setSession] = useState<AuthSession | null>(null)
const [ready, setReady] = useState(false)
const [signingOut, setSigningOut] = useState(false)
const [requestGuard] = useState(() => createSessionRequestGuard(readSession))
const identityRef = useRef<string | null>(null)
const signOutPendingRef = useRef(false)
const signOutGenerationRef = useRef(0)
const applySession = useCallback((next: AuthSession | null) => {
const nextIdentity = accountKey(next)
if (identityRef.current !== nextIdentity) {
resetAgentConversations()
identityRef.current = nextIdentity
}
setSession(next)
setReady(true)
}, [])
const refreshSession = useCallback(async () => {
const stored = readSession()
if (!stored) {
setSession(null)
setReady(true)
requestGuard.invalidate()
applySession(null)
return
}
const isCurrent = requestGuard.begin(stored)
try {
const profile = await fetchProfile()
if (!isCurrent()) return
const nextSession: AuthSession = {
...stored,
user: profile.user,
@@ -45,60 +69,76 @@ export function SessionProvider({ children }: { children: ReactNode }) {
accessToken: profile.accessToken || stored.accessToken,
expiresAt: profile.expiresAt || stored.expiresAt,
}
applySession(nextSession)
writeSession(nextSession)
setSession(nextSession)
} catch (error) {
if (!isCurrent()) return
const errorCode = (error as Error & { errorCode?: number }).errorCode
if (errorCode === 3000 || errorCode === 3002) {
clearSession()
setSession(null)
applySession(null)
} else {
setSession(stored)
applySession(stored)
}
} finally {
setReady(true)
if (isCurrent()) setReady(true)
}
}, [])
}, [applySession, requestGuard])
const signOut = useCallback(async () => {
if (signingOut) return
if (signOutPendingRef.current) return
signOutPendingRef.current = true
const generation = ++signOutGenerationRef.current
setSigningOut(true)
requestGuard.invalidate()
// Start the request with the old token, then immediately remove local access.
const pending = logout()
clearSession()
applySession(null)
try {
await logout()
await pending
} catch {
clearSession()
// Local sign-out already completed; a late error cannot clear a new login.
} finally {
setSession(null)
setSigningOut(false)
if (generation === signOutGenerationRef.current) {
signOutPendingRef.current = false
setSigningOut(false)
}
}
}, [signingOut])
useEffect(() => {
void refreshSession()
}, [refreshSession])
}, [applySession, requestGuard])
useEffect(() => {
const syncSession = () => {
setSession(readSession())
setReady(true)
requestGuard.invalidate()
const current = readSession()
if (current && signOutPendingRef.current) {
signOutGenerationRef.current += 1
signOutPendingRef.current = false
setSigningOut(false)
}
applySession(current)
}
const syncOtherTab = (event: StorageEvent) => {
if (event.key === "agent-desk-session") syncSession()
if (event.key === "agent-desk-session" || event.key === null) syncSession()
}
window.addEventListener(AUTH_SESSION_CHANGED_EVENT, syncSession)
window.addEventListener(AUTH_SESSION_EXPIRED_EVENT, syncSession)
window.addEventListener("storage", syncOtherTab)
applySession(readSession())
void refreshSession()
return () => {
requestGuard.invalidate()
signOutGenerationRef.current += 1
window.removeEventListener(AUTH_SESSION_CHANGED_EVENT, syncSession)
window.removeEventListener(AUTH_SESSION_EXPIRED_EVENT, syncSession)
window.removeEventListener("storage", syncOtherTab)
}
}, [])
}, [applySession, refreshSession, requestGuard])
return (
<SessionContext.Provider value={{ session, ready, signingOut, refreshSession, signOut }}>
{children}
<Fragment key={accountKey(session)}>{children}</Fragment>
</SessionContext.Provider>
)
}

View File

@@ -1,3 +1,4 @@
// Modified in the independent hardening fork; see docs/hardening/README.md.
"use client"
import {
@@ -113,12 +114,14 @@ function SupportChatPanelContent() {
useSupportChatSystemTheme()
const messageListRef = useRef<SupportChatMessageListHandle | null>(null)
const mountedRef = useRef(false)
const [isEmbedded, setIsEmbedded] = useState(false)
const [isCloseDialogOpen, setIsCloseDialogOpen] = useState(false)
const [isClosingConversation, setIsClosingConversation] = useState(false)
const [isNewRequestDialogOpen, setIsNewRequestDialogOpen] = useState(false)
const {
identityScope,
title,
subtitle,
themeColor,
@@ -129,6 +132,8 @@ function SupportChatPanelContent() {
loadOlderMessages,
status,
error,
sending,
uploadingAsset,
isOpen,
isVisible,
setIsOpen,
@@ -144,6 +149,7 @@ function SupportChatPanelContent() {
setConversationSatisfaction,
} = useSupportChatStore(
useShallow((state) => ({
identityScope: state.identityScope,
title: state.title,
subtitle: state.subtitle,
themeColor: state.themeColor,
@@ -154,6 +160,8 @@ function SupportChatPanelContent() {
loadOlderMessages: state.loadOlderMessages,
status: state.status,
error: state.error,
sending: state.sending,
uploadingAsset: state.uploadingAsset,
isOpen: state.isOpen,
isVisible: state.isVisible,
setIsOpen: state.setIsOpen,
@@ -182,6 +190,7 @@ function SupportChatPanelContent() {
if (document.visibilityState !== "visible") {
return
}
if (useSupportChatStore.getState().conversation?.id !== conversation.id) return
void markConversationRead().catch((readError) => {
console.error("Failed to mark support chat conversation read", readError)
})
@@ -190,9 +199,9 @@ function SupportChatPanelContent() {
useEffect(() => {
return bindSupportHostBridge({
onInit: () => {
if (useSupportChatStore.getState().isOpen) {
bootstrap()
}
// Clear a previous visitor's private state even while the host keeps
// this frame hidden; visibility must never race identity replacement.
void bootstrap()
},
onOpen: () => {
setIsOpen(true)
@@ -205,14 +214,16 @@ function SupportChatPanelContent() {
}, [bootstrap, setIsOpen, setIsVisible])
useEffect(() => {
bootstrap()
void bootstrap()
}, [isOpen, bootstrap])
useEffect(() => {
mountedRef.current = true
return () => {
if (!isOpen) {
disconnectSocket()
}
mountedRef.current = false
disconnectSocket()
}
}, [isOpen, bootstrap, disconnectSocket])
}, [disconnectSocket])
useEffect(() => {
maybeMarkConversationRead()
@@ -236,9 +247,21 @@ function SupportChatPanelContent() {
}
}, [maybeMarkConversationRead])
function requireDisplayedConversation() {
const current = useSupportChatStore.getState()
if (!conversation?.id || current.identityScope !== identityScope || current.conversation?.id !== conversation.id) {
throw new Error(t("supportChat.sendUnavailable"))
}
}
async function handleSend(content: string) {
requireDisplayedConversation()
const submittedConversationId = conversation?.id
await handleSendMessage(content)
messageListRef.current?.scrollToBottom()
const current = useSupportChatStore.getState()
if (current.identityScope === identityScope && current.conversation?.id === submittedConversationId) {
messageListRef.current?.scrollToBottom()
}
}
function handleCloseWindow() {
@@ -246,7 +269,8 @@ function SupportChatPanelContent() {
if (isEmbedded) {
requestSupportHostMinimize()
} else {
window.location.replace(getStandaloneClosedUrl())
setIsOpen(false)
window.location.replace(getStandaloneClosedUrl(window.location.search))
}
}
@@ -257,6 +281,7 @@ function SupportChatPanelContent() {
setIsClosingConversation(true)
try {
if (conversation?.id) {
requireDisplayedConversation()
await closeConversation()
}
setIsCloseDialogOpen(false)
@@ -351,15 +376,29 @@ function SupportChatPanelContent() {
conversationId={conversation.id}
customerToken={getCustomerSessionToken()}
initialSatisfaction={conversation.satisfaction}
onSubmitted={setConversationSatisfaction}
onSubmitted={(satisfaction) => {
if (mountedRef.current && useSupportChatStore.getState().identityScope === identityScope) {
setConversationSatisfaction(satisfaction)
}
}}
/>
</div>
) : null}
<CustomerMessageEditor
conversationId={conversation?.id}
draftScope={identityScope}
sending={sending}
uploadingAsset={uploadingAsset}
disabled={!conversation || conversation.status === 4}
onSend={handleSend}
onUploadImage={uploadMessageImage}
onSendAttachment={sendAttachment}
onUploadImage={async (file) => {
requireDisplayedConversation()
return uploadMessageImage(file)
}}
onSendAttachment={async (file) => {
requireDisplayedConversation()
await sendAttachment(file)
}}
/>
</div>
</div>

View File

@@ -1,3 +1,4 @@
// Modified in the independent hardening fork; see docs/hardening/README.md.
import assert from "node:assert/strict"
import test from "node:test"
import ts from "typescript"
@@ -14,6 +15,7 @@ async function loadCloseNavigation() {
fileName: "close-navigation.ts",
})
const sandbox = {
URLSearchParams,
exports: {},
module: { exports: {} },
}
@@ -27,3 +29,10 @@ test("standalone close navigates to the non-bootstrapping closed page", async ()
assert.equal(getStandaloneClosedUrl(), "/support/chat/closed")
})
test("dismiss and return retain the entry channel without accepting a redirect destination", async () => {
const { getStandaloneClosedUrl, getStandaloneChatUrl } = await loadCloseNavigation()
const search = "?channelId=help&externalId=visitor&returnUrl=https%3A%2F%2Fexample.org"
assert.equal(getStandaloneClosedUrl(search), `/support/chat/closed${search}`)
assert.equal(getStandaloneChatUrl(search), `/support/chat${search}`)
})

View File

@@ -1,5 +1,16 @@
// Modified in the independent hardening fork; see docs/hardening/README.md.
export const SUPPORT_CHAT_CLOSED_PATH = "/support/chat/closed"
export function getStandaloneClosedUrl() {
return SUPPORT_CHAT_CLOSED_PATH
// Keep the entry context when dismissing/returning. These are always local
// paths, never a caller-supplied redirect destination.
function querySuffix(search: string) {
return search ? `?${new URLSearchParams(search).toString()}` : ""
}
export function getStandaloneClosedUrl(search = "") {
return `${SUPPORT_CHAT_CLOSED_PATH}${querySuffix(search)}`
}
export function getStandaloneChatUrl(search = "") {
return `/support/chat${querySuffix(search)}`
}

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening/README.md.
// Modified for AgentDeskFixes: forward customer conversation identity and pending-send state.
"use client"
import {
@@ -6,6 +8,9 @@ import {
} from "@/components/chat/shared-message-editor"
type CustomerMessageEditorProps = {
conversationId?: number | null
draftScope?: string
sending?: boolean
disabled?: boolean
uploadingAsset?: boolean
onSend: (html: string) => Promise<void>
@@ -14,6 +19,9 @@ type CustomerMessageEditorProps = {
}
export function CustomerMessageEditor({
conversationId,
draftScope,
sending = false,
disabled = false,
uploadingAsset = false,
onSend,
@@ -23,6 +31,9 @@ export function CustomerMessageEditor({
return (
<SharedMessageEditor
variant="customer"
conversationId={conversationId}
draftScope={draftScope}
sending={sending}
disabled={disabled}
uploadingAsset={uploadingAsset}
manageLocalUploading

View File

@@ -1,8 +1,12 @@
// Modified in the independent hardening fork; see docs/hardening/README.md.
// Modified for AgentDeskFixes: bind realtime work to the current session and sanitize diagnostics.
"use client"
import { useEffect, useRef } from "react"
import { toast } from "sonner"
import { useSession } from "@/components/session-provider"
import { realtimeLogContext } from "@/lib/realtime-log-context"
import { createAdminWebSocketUrl } from "@/lib/api/admin"
import { type AgentMessage } from "@/lib/api/agent"
import { shouldReloadConversationListForRealtimePatch } from "@/lib/agent-conversation-realtime"
@@ -50,6 +54,8 @@ export function sendOperatorPresence(
export function useAgentConversationRealtime() {
const t = useI18n()
const { session } = useSession()
const accessToken = session?.accessToken
const selectedConversationId = useAgentConversationsStore(
(state) => state.selectedConversationId
)
@@ -59,19 +65,23 @@ export function useAgentConversationRealtime() {
const realtimeRef = useRef<AgentRealtimeConnection | null>(null)
const subscribedConversationIdRef = useRef<number | null>(null)
const selectedConversationIdRef = useRef<number | null>(selectedConversationId)
const currentUserIdRef = useRef<number>(readSession()?.user.id ?? 0)
const currentUserIdRef = useRef<number>(session?.user.id ?? 0)
currentUserIdRef.current = session?.user.id ?? 0
useEffect(() => {
selectedConversationIdRef.current = selectedConversationId
}, [selectedConversationId])
useEffect(() => {
if (!accessToken) return
const isCurrentSession = () => readSession()?.accessToken === accessToken
const realtime = createRealtimeConnectionManager({
createSocket: () => new WebSocket(createAdminWebSocketUrl()),
onStatusChange: setRealtimeStatus,
onStatusChange: (status) => { if (isCurrentSession()) setRealtimeStatus(status) },
onOpen: (socket) => {
if (!isCurrentSession()) return
console.info("[agent-realtime] websocket connected", {
url: socket.url,
...realtimeLogContext(socket.url),
})
const conversationId = selectedConversationIdRef.current
@@ -95,6 +105,7 @@ export function useAgentConversationRealtime() {
}
},
onMessage: (event, socket) => {
if (!isCurrentSession()) return
try {
const envelope = JSON.parse(event.data) as AgentRealtimeEnvelope
const eventType = envelope.type ?? ""
@@ -182,17 +193,16 @@ export function useAgentConversationRealtime() {
},
onClose: (event, socket) => {
console.log("[agent-realtime] websocket closed", {
url: socket.url,
...realtimeLogContext(socket.url),
readyState: socket.readyState,
code: event.code,
reason: event.reason,
wasClean: event.wasClean,
})
subscribedConversationIdRef.current = null
},
onError: (_event, socket) => {
console.log("[agent-realtime] websocket error", {
url: socket.url,
...realtimeLogContext(socket.url),
readyState: socket.readyState,
})
},
@@ -225,7 +235,7 @@ export function useAgentConversationRealtime() {
realtime.disconnect()
subscribedConversationIdRef.current = null
}
}, [setRealtimeStatus, t])
}, [accessToken, setRealtimeStatus, t])
useEffect(() => {
const socket = realtimeRef.current?.getSocket()

View File

@@ -1,4 +1,7 @@
// Modified in the independent hardening fork; see docs/hardening/README.md.
// Modified for AgentDeskFixes: prevent stale profile updates and logout from modifying a newer session.
import { clearSession, readSession, writeSession, type AuthSession } from "@/lib/auth"
import { isSameSession } from "@/lib/session-request-guard"
import { request } from "@/lib/api/client"
export type LoginRequest = {
@@ -41,17 +44,17 @@ export async function fetchProfile() {
}
export async function updateProfile(payload: UpdateProfileRequest) {
const stored = readSession()
const data = await request<AuthSession>("/api/auth/profile/update", {
method: "POST",
body: JSON.stringify(payload),
})
const stored = readSession()
const nextSession: AuthSession = {
...data,
accessToken: data.accessToken || stored?.accessToken || "",
expiresAt: data.expiresAt || stored?.expiresAt,
}
writeSession(nextSession)
if (isSameSession(stored, readSession())) writeSession(nextSession)
return nextSession
}
@@ -65,11 +68,12 @@ export function uploadProfileAvatar(file: File) {
}
export async function logout() {
const stored = readSession()
try {
await request("/api/auth/logout", {
method: "POST",
})
} finally {
clearSession()
if (isSameSession(stored, readSession())) clearSession()
}
}

View File

@@ -1,4 +1,7 @@
import { expireSession, readSession } from "@/lib/auth"
// Modified in the independent hardening fork; see docs/hardening/README.md.
// Modified for AgentDeskFixes: scope authentication expiry to the session that issued each request.
import { expireSession, readSession, type AuthSession } from "@/lib/auth"
import { isSameSession } from "@/lib/session-request-guard"
import { translateCurrentMessage } from "@/i18n/messages"
const API_BASE_URL =
@@ -50,10 +53,10 @@ function normalizeApiRequestError(error: unknown) {
)
}
async function parseResult<T>(response: Response) {
async function parseResult<T>(response: Response, requestSession: AuthSession | null) {
const payload = (await response.json()) as JsonResult<T>
if (!response.ok || !payload.success) {
if (payload.errorCode === 3000 || payload.errorCode === 3002) {
if ((payload.errorCode === 3000 || payload.errorCode === 3002) && isSameSession(requestSession, readSession())) {
expireSession()
}
throw new ApiRequestError(
@@ -65,11 +68,10 @@ async function parseResult<T>(response: Response) {
return payload.data
}
function buildRequestHeaders(headers: HeadersInit | undefined, skipAuth?: boolean, body?: BodyInit | null) {
const session = readSession()
function buildRequestHeaders(headers: HeadersInit | undefined, session: AuthSession | null, body?: BodyInit | null) {
const authHeaders = new Headers(headers)
if (!skipAuth && session?.accessToken) {
if (session?.accessToken) {
authHeaders.set("Authorization", `Bearer ${session.accessToken}`)
}
if (
@@ -101,7 +103,8 @@ export async function request<T>(
const { headers, skipAuth, baseUrl, onResponse, ...rest } = options
delete (rest as RequestOptions).baseUrl
delete (rest as RequestOptions).onResponse
const authHeaders = buildRequestHeaders(headers, skipAuth, rest.body)
const requestSession = skipAuth ? null : readSession()
const authHeaders = buildRequestHeaders(headers, requestSession, rest.body)
const requestBaseUrl = baseUrl !== undefined ? baseUrl : API_BASE_URL
try {
@@ -111,7 +114,7 @@ export async function request<T>(
cache: "no-store",
})
onResponse?.(response)
return await parseResult<T>(response)
return await parseResult<T>(response, requestSession)
} catch (error) {
throw normalizeApiRequestError(error)
}
@@ -124,7 +127,8 @@ export async function requestBlob(
const { headers, skipAuth, baseUrl, onResponse, ...rest } = options
delete (rest as RequestOptions).baseUrl
delete (rest as RequestOptions).onResponse
const authHeaders = buildRequestHeaders(headers, skipAuth, rest.body)
const requestSession = skipAuth ? null : readSession()
const authHeaders = buildRequestHeaders(headers, requestSession, rest.body)
const requestBaseUrl = baseUrl !== undefined ? baseUrl : API_BASE_URL
const response = await fetch(`${requestBaseUrl}${path}`, {
...rest,
@@ -136,7 +140,7 @@ export async function requestBlob(
const contentType = response.headers.get("Content-Type") ?? ""
if (contentType.includes("application/json")) {
try {
await parseResult<never>(response)
await parseResult<never>(response, requestSession)
} catch (error) {
throw error
}

View File

@@ -0,0 +1,173 @@
import assert from "node:assert/strict"
import { readFileSync } from "node:fs"
import test from "node:test"
import vm from "node:vm"
import ts from "typescript"
const storage = () => {
const values = new Map()
return { getItem: (key) => values.get(key) ?? null, setItem: (key, value) => values.set(key, value), removeItem: (key) => values.delete(key) }
}
const deferred = () => {
let resolve, reject
const promise = new Promise((yes, no) => { resolve = yes; reject = no })
return { promise, resolve, reject }
}
const token = (identityKey, channelCode = "support", mode = "guest", extra = {}) =>
`test.${Buffer.from(JSON.stringify({ identityKey, channelCode, identityScope: `v1:${mode}:test-channel-scope`, ...extra })).toString("base64url")}.not-a-real-signature`
const exchange = (identityKey = "guest:server-assigned", channelCode = "support", mode = "guest") => ({
identityKey, customerSessionToken: token(identityKey, channelCode, mode),
expiresAt: "2099-01-01T00:00:00Z", customer: { id: 1, name: "Visitor" },
})
function harness(request) {
let config = { baseUrl: "https://support.example", channelId: "support", externalId: "client-guest" }
const localStorage = storage(), sessionStorage = storage()
const source = readFileSync(new URL("./im.ts", import.meta.url), "utf8")
const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } })
const module = { exports: {} }
const mocks = {
"@/lib/api/client": { request },
"@/i18n/messages": { translateCurrentMessage: (key) => key },
"@/lib/sdk/runtime-config": { readSupportChatRuntimeConfig: () => config },
"@/lib/utils": { generateUUID: () => "browser-local-guest", parseApiTimestamp: Date.parse },
}
vm.runInNewContext(compiled.outputText, { module, exports: module.exports,
require: (name) => { if (!mocks[name]) throw new Error(`Unexpected import ${name}`); return mocks[name] },
process: { env: {} }, window: { localStorage, sessionStorage }, atob, TextDecoder, URLSearchParams })
return { api: module.exports, sessionStorage, setConfig: (next) => { config = { ...config, ...next } } }
}
test("concurrent session ensures share one guest exchange and cache server-owned identity", async () => {
const pending = deferred()
let calls = 0
const h = harness(() => { calls++; return pending.promise })
const first = h.api.ensureCustomerSession(), second = h.api.ensureCustomerSession()
assert.equal(calls, 1)
pending.resolve(exchange())
const [left, right] = await Promise.all([first, second])
assert.equal(left.identityKey, "guest:server-assigned")
assert.equal(right.identityKey, left.identityKey)
assert.equal(left.guestContext, "client-guest")
await h.api.ensureCustomerSession()
assert.equal(calls, 1)
})
test("guest token cannot be reused under a different initiating visitor context", async () => {
let calls = 0
const h = harness(async () => exchange(`guest:server-${++calls}`))
await h.api.ensureCustomerSession()
h.setConfig({ externalId: "someone-else" })
assert.equal(h.api.getCustomerSessionToken(), "")
const next = await h.api.ensureCustomerSession()
assert.equal(calls, 2)
assert.equal(next.identityKey, "guest:server-2")
})
test("legacy unscoped tokens are re-exchanged instead of sent to protected endpoints", async () => {
let calls = 0
const h = harness(async () => { calls++; return exchange() })
h.sessionStorage.setItem("cs_ai_agent_customer_session", JSON.stringify({ ...exchange("guest:client-guest"),
customerSessionToken: token("guest:client-guest", "support", "guest", { identityScope: undefined }),
baseUrl: "https://support.example", channelId: "support", guestContext: "client-guest" }))
assert.equal(h.api.getCustomerSessionToken(), "")
await h.api.ensureCustomerSession()
assert.equal(calls, 1)
})
test("late session exchange from old identity cannot overwrite new session", async () => {
const old = deferred()
let calls = 0
const h = harness(() => ++calls === 1 ? old.promise : Promise.resolve(exchange("guest:new-server")))
const first = h.api.ensureCustomerSession()
h.setConfig({ externalId: "new-visitor" })
const next = await h.api.ensureCustomerSession()
old.resolve(exchange("guest:old-server"))
await assert.rejects(first, /supportAuthenticationRequired/)
assert.equal(h.api.readCustomerSession().identityKey, next.identityKey)
})
test("A to B to A entry changes invalidate old same-value exchange", async () => {
const old = deferred()
let calls = 0
const h = harness(() => ++calls === 1 ? old.promise : Promise.resolve(exchange("guest:new-A")))
const first = h.api.ensureCustomerSession()
h.setConfig({ externalId: "B" })
h.api.getCustomerIdentityScope()
h.setConfig({ externalId: "client-guest" })
await h.api.ensureCustomerSession()
old.resolve(exchange("guest:old-A"))
await assert.rejects(first, /supportAuthenticationRequired/)
assert.equal(h.api.readCustomerSession().identityKey, "guest:new-A")
})
test("customer token rotation preserves scope, but replacement guest identity changes it", async () => {
let calls = 0
const h = harness(async () => exchange(`guest:server-${++calls}`))
await h.api.ensureCustomerSession()
const scope = h.api.getCustomerIdentityScope()
h.api.applyCustomerSessionRefresh({ customerSessionToken: token("guest:server-1", "support", "guest", { revision: 2 }), expiresAt: "2099-02-01T00:00:00Z" })
assert.equal(h.api.getCustomerIdentityScope(), scope)
await h.api.exchangeCustomerSession()
assert.notEqual(h.api.getCustomerIdentityScope(), scope)
})
test("refresh with another auth mode or channel cannot poison cached session", async () => {
const h = harness(async () => exchange())
await h.api.ensureCustomerSession()
const original = h.api.getCustomerSessionToken()
h.api.applyCustomerSessionRefresh({ customerSessionToken: token("guest:server-assigned", "support", "jwt"), expiresAt: "2099-02-01T00:00:00Z" })
assert.equal(h.api.getCustomerSessionToken(), original)
h.api.applyCustomerSessionRefresh({ customerSessionToken: token("guest:server-assigned", "other"), expiresAt: "2099-02-01T00:00:00Z" })
assert.equal(h.api.getCustomerSessionToken(), original)
})
test("signed identity still requires exact user ID and correct auth mode", async () => {
const h = harness(async () => exchange("user:wrong", "support", "hmac"))
h.setConfig({ externalId: "expected", externalIdSignature: "test-not-a-real-signature", externalIdSignedAt: 1 })
await assert.rejects(h.api.ensureCustomerSession(), /supportAuthenticationRequired/)
assert.equal(h.api.getCustomerSessionToken(), "")
})
test("matching renewal arriving during old token expiry window keeps guest identity", async () => {
const h = harness(async () => exchange())
await h.api.ensureCustomerSession()
const cached = h.api.readCustomerSession()
h.sessionStorage.setItem("cs_ai_agent_customer_session", JSON.stringify({ ...cached, expiresAt: "2000-01-01T00:00:00Z" }))
const renewed = token(cached.identityKey, "support", "guest", { revision: 2 })
h.api.applyCustomerSessionRefresh({ customerSessionToken: renewed, expiresAt: "2099-02-01T00:00:00Z" })
assert.equal(h.api.getCustomerSessionToken(), renewed)
})
test("near-expiry guest renews its existing identity instead of exchanging a fresh guest", async () => {
const routes = []
const h = harness(async (route, options) => {
routes.push(route)
if (route.endsWith("session_refresh")) {
assert.equal(options.method, "POST")
assert.ok(options.headers.Authorization.startsWith("Bearer "))
}
return exchange("guest:original")
})
await h.api.ensureCustomerSession()
const cached = h.api.readCustomerSession()
h.sessionStorage.setItem("cs_ai_agent_customer_session", JSON.stringify({ ...cached, expiresAt: new Date(Date.now() + 4000).toISOString() }))
const resumed = await h.api.ensureCustomerSession()
assert.equal(resumed.identityKey, cached.identityKey)
assert.deepEqual(routes, ["/api/customer/session_exchange", "/api/customer/session_refresh"])
})
test("failed near-expiry refresh never falls back to a different anonymous guest", async () => {
let calls = 0
const h = harness(async (route) => {
calls++
if (route.endsWith("session_refresh")) throw new Error("offline")
return exchange("guest:original")
})
await h.api.ensureCustomerSession()
const cached = h.api.readCustomerSession()
h.sessionStorage.setItem("cs_ai_agent_customer_session", JSON.stringify({ ...cached, expiresAt: new Date(Date.now() + 4000).toISOString() }))
await assert.rejects(h.api.ensureCustomerSession(), /offline/)
assert.equal(calls, 2)
assert.equal(h.api.readCustomerSession().identityKey, "guest:original")
})

View File

@@ -1,3 +1,4 @@
// Modified in the independent hardening fork; see docs/hardening/README.md.
import { request } from "@/lib/api/client"
import { translateCurrentMessage } from "@/i18n/messages"
import { readSupportChatRuntimeConfig } from "@/lib/sdk/runtime-config"
@@ -147,6 +148,8 @@ export type ImCustomerSessionExchangeResponse = {
export type ImCustomerSession = ImCustomerSessionExchangeResponse & {
channelId: string
baseUrl: string
/** Local binding only. Anonymous identity itself is assigned by the server. */
guestContext?: string
}
const GUEST_STORAGE_KEY = "cs_ai_agent_im_guest_id"
@@ -159,6 +162,18 @@ const OPEN_IM_CHANNEL_ID =
process.env.NEXT_PUBLIC_OPEN_IM_CHANNEL_ID?.trim() || ""
let entryIdentityExchangeKey = ""
let observedIdentityContext = ""
let identityGeneration = 0
let observedSessionIdentity = ""
let sessionIdentityGeneration = 0
let pendingSessionExchange: {
scope: string
promise: Promise<ImCustomerSession>
} | null = null
let pendingSessionRefresh: {
scope: string
promise: Promise<ImCustomerSession>
} | null = null
function buildGuestId() {
return `guest_${generateUUID()}`
@@ -208,6 +223,36 @@ function identityContextKey(config: RuntimeImConfig) {
])
}
// An opaque, non-secret scope for in-memory drafts and request fencing. Token
// renewal does not change it, but a new entry identity/channel/server does.
export function getCustomerEntryScope() {
const config = getRuntimeImConfig()
const context = JSON.stringify([
identityContextKey(config),
config.externalId || config.userToken ? "" : getGuestId(),
])
if (context !== observedIdentityContext) {
observedIdentityContext = context
identityGeneration += 1
}
return `customer-${identityGeneration}`
}
export function getCustomerIdentityScope() {
const entry = getCustomerEntryScope()
const session = readCustomerSession()
const config = getRuntimeImConfig()
const identity = JSON.stringify([
entry,
session && matchesSessionIdentity(session, config) ? session.identityKey : "",
])
if (identity !== observedSessionIdentity) {
observedSessionIdentity = identity
sessionIdentityGeneration += 1
}
return `${entry}-${sessionIdentityGeneration}`
}
function requireEntryToken(config: RuntimeImConfig) {
if (config.requireUserToken && !config.userToken) {
throw new Error(translateCurrentMessage("api.supportAuthenticationRequired"))
@@ -217,6 +262,12 @@ function requireEntryToken(config: RuntimeImConfig) {
function matchesSessionIdentity(session: ImCustomerSession, config: RuntimeImConfig) {
if (typeof session.identityKey !== "string") return false
if (session.baseUrl !== config.baseUrl || session.channelId !== config.channelId) return false
const claims = sessionTokenClaims(session.customerSessionToken)
const identityMode = config.userToken ? "jwt" : config.externalIdSignature ? "hmac" : "guest"
// Legacy tokens predate channel/auth-mode isolation and must be exchanged.
// This is only cache validation; the server verifies every token signature.
if (claims?.identityKey !== session.identityKey || claims.channelCode !== config.channelId ||
typeof claims.identityScope !== "string" || !claims.identityScope.startsWith(`v1:${identityMode}:`)) return false
if (config.userToken || config.requireUserToken) {
return (
!!config.userToken &&
@@ -226,10 +277,8 @@ function matchesSessionIdentity(session: ImCustomerSession, config: RuntimeImCon
)
}
const externalId = config.externalId || getGuestId()
return (
session.identityKey === `guest:${externalId}` ||
(!!config.externalIdSignature && session.identityKey === `user:${externalId}`)
)
if (config.externalIdSignature) return session.identityKey === `user:${externalId}`
return session.identityKey.startsWith("guest:") && session.guestContext === externalId
}
function usableCustomerSession(session: ImCustomerSession | null, config: RuntimeImConfig) {
@@ -242,7 +291,7 @@ function usableCustomerSession(session: ImCustomerSession | null, config: Runtim
return true
}
function sessionTokenClaims(token: string): { identityKey?: string; channelCode?: string } | null {
function sessionTokenClaims(token: string): { identityKey?: string; channelCode?: string; identityScope?: string } | null {
try {
const payload = token.split(".")[1]
if (!payload) return null
@@ -275,7 +324,7 @@ function isCustomerSessionValid(
if (identityKey && session.identityKey !== identityKey) {
return false
}
return parseExpiresAt(session.expiresAt) > Date.now() + 5000
return parseExpiresAt(session.expiresAt) > Date.now()
}
export function readCustomerSession(): ImCustomerSession | null {
@@ -320,12 +369,15 @@ export function applyCustomerSessionRefresh(payload?: {
}
const current = readCustomerSession()
const config = getRuntimeImConfig()
if (!current || !usableCustomerSession(current, config)) return
// The response may arrive during the expiry safety window. It is still a
// valid renewal of the same server-authorized identity, not a new identity.
if (!current || !matchesSessionIdentity(current, config)) return
// Decode only for cache consistency, never as proof of authenticity. The
// server still verifies signatures. A late old-account websocket/HTTP refresh
// must not overwrite the current account's stored customer session.
const claims = sessionTokenClaims(token)
if (claims?.identityKey !== current.identityKey || claims?.channelCode !== current.channelId) return
if (claims?.identityKey !== current.identityKey || claims?.channelCode !== current.channelId ||
claims?.identityScope !== sessionTokenClaims(current.customerSessionToken)?.identityScope) return
writeCustomerSession({
...current,
customerSessionToken: token,
@@ -395,7 +447,7 @@ function createRequestOptions(
} {
const config = getRuntimeImConfig()
requireEntryToken(config)
const contextKey = identityContextKey(config)
const identityScope = getCustomerIdentityScope()
return {
...init,
skipAuth: true,
@@ -404,7 +456,7 @@ function createRequestOptions(
...(init?.headers as Record<string, string> | undefined),
},
onResponse: (response) => {
if (contextKey === identityContextKey(getRuntimeImConfig())) {
if (identityScope === getCustomerIdentityScope()) {
applyCustomerSessionHeaders(response)
}
},
@@ -428,7 +480,7 @@ function toQueryString(query?: Record<string, string | number | undefined>) {
return output ? `?${output}` : ""
}
export async function exchangeCustomerSession() {
async function exchangeCustomerSessionForScope(identityScope: string) {
const config = getRuntimeImConfig()
requireEntryToken(config)
const contextKey = identityContextKey(config)
@@ -441,13 +493,14 @@ export async function exchangeCustomerSession() {
headers: createExchangeHeaders(),
}
)
if (contextKey !== identityContextKey(getRuntimeImConfig())) {
if (identityScope !== getCustomerEntryScope()) {
throw new Error(translateCurrentMessage("api.supportAuthenticationRequired"))
}
const session: ImCustomerSession = {
...result,
channelId: config.channelId,
baseUrl: config.baseUrl,
...(!config.userToken && !config.externalIdSignature ? { guestContext: config.externalId || getGuestId() } : {}),
}
if (!isCustomerSessionValid(session) || !matchesSessionIdentity(session, config)) {
throw new Error(translateCurrentMessage("api.supportAuthenticationRequired"))
@@ -457,14 +510,66 @@ export async function exchangeCustomerSession() {
return session
}
export function exchangeCustomerSession() {
const scope = getCustomerEntryScope()
if (pendingSessionExchange?.scope === scope) {
return pendingSessionExchange.promise
}
const promise = exchangeCustomerSessionForScope(scope)
pendingSessionExchange = { scope, promise }
void promise.then(
() => {
if (pendingSessionExchange?.promise === promise) pendingSessionExchange = null
},
() => {
if (pendingSessionExchange?.promise === promise) pendingSessionExchange = null
}
)
return promise
}
export async function ensureCustomerSession() {
const config = getRuntimeImConfig()
requireEntryToken(config)
const cached = readCustomerSession()
if (usableCustomerSession(cached, config)) return cached!
if (usableCustomerSession(cached, config)) {
if (parseExpiresAt(cached!.expiresAt) <= Date.now() + 30000) {
return refreshCustomerSession(cached!)
}
return cached!
}
return exchangeCustomerSession()
}
function refreshCustomerSession(cached: ImCustomerSession) {
const scope = getCustomerIdentityScope()
if (pendingSessionRefresh?.scope === scope) return pendingSessionRefresh.promise
const refresh = async () => {
// Refresh with proof of the existing identity. An unsigned exchange would
// intentionally create a different guest and abandon the current history.
const result = await request<ImCustomerSessionExchangeResponse>("/api/customer/session_refresh", {
...createRequestOptions({ method: "POST" }),
})
if (scope !== getCustomerIdentityScope()) {
throw new Error(translateCurrentMessage("api.supportAuthenticationRequired"))
}
const renewed: ImCustomerSession = { ...cached, ...result }
if (!isCustomerSessionValid(renewed) || !matchesSessionIdentity(renewed, getRuntimeImConfig()) ||
renewed.identityKey !== cached.identityKey || renewed.customer.id !== cached.customer.id) {
throw new Error(translateCurrentMessage("api.supportAuthenticationRequired"))
}
writeCustomerSession(renewed)
return renewed
}
const promise = refresh()
pendingSessionRefresh = { scope, promise }
const clear = () => {
if (pendingSessionRefresh?.promise === promise) pendingSessionRefresh = null
}
void promise.then(clear, clear)
return promise
}
export function fetchImConversationDetail(id: number) {
return request<ImConversationDetail>(`/api/conversation/${id}`, {
...createRequestOptions(),

View File

@@ -0,0 +1,115 @@
import assert from "node:assert/strict"
import test from "node:test"
import { isSameSession } from "../session-request-guard.ts"
import { deferred, loadTypeScriptModule } from "../test-support/load-typescript-module.mjs"
function fixture() {
let session = { user: { id: 1 }, accessToken: "test-session-a" }
let expirations = 0
const response = deferred()
const client = loadTypeScriptModule(new URL("./client.ts", import.meta.url), {
"@/lib/auth": {
readSession: () => session,
expireSession: () => { session = null; expirations += 1 },
},
"@/lib/session-request-guard": { isSameSession },
"@/i18n/messages": { translateCurrentMessage: (key) => key },
}, { process: { env: {} }, Headers, FormData, fetch: () => response.promise })
return {
client, response,
get session() { return session },
set session(next) { session = next },
get expirations() { return expirations },
}
}
function expiredResponse() {
return new Response(JSON.stringify({ success: false, errorCode: 3002, message: "Expired" }), {
status: 401, headers: { "Content-Type": "application/json" },
})
}
for (const method of ["request", "requestBlob"]) {
test(`${method}: old auth failure cannot expire a newer login`, async () => {
const f = fixture()
const pending = f.client[method]("/test")
f.session = { user: { id: 2 }, accessToken: "test-session-b" }
f.response.resolve(expiredResponse())
await assert.rejects(pending, { errorCode: 3002 })
assert.equal(f.session.user.id, 2)
assert.equal(f.expirations, 0)
})
test(`${method}: matching auth failure expires only its own session`, async () => {
const f = fixture()
const pending = f.client[method]("/test")
f.response.resolve(expiredResponse())
await assert.rejects(pending, { errorCode: 3002 })
assert.equal(f.session, null)
assert.equal(f.expirations, 1)
})
test(`${method}: unauthenticated failures do not expire a dashboard session`, async () => {
const f = fixture()
const pending = f.client[method]("/test", { skipAuth: true })
f.response.resolve(expiredResponse())
await assert.rejects(pending, { errorCode: 3002 })
assert.equal(f.expirations, 0)
})
}
test("old logout and profile update cannot overwrite a new account", async () => {
for (const action of ["logout", "updateProfile"]) {
let session = { user: { id: 1 }, accessToken: "test-session-a" }
const response = deferred()
let writes = 0
const auth = loadTypeScriptModule(new URL("./auth.ts", import.meta.url), {
"@/lib/auth": {
readSession: () => session,
clearSession: () => { session = null; writes += 1 },
writeSession: (next) => { session = next; writes += 1 },
},
"@/lib/session-request-guard": { isSameSession },
"@/lib/api/client": { request: () => response.promise },
})
const pending = auth[action]({ nickname: "Updated" })
session = { user: { id: 2 }, accessToken: "test-session-b" }
response.resolve({ user: { id: 1 }, accessToken: "test-session-a" })
await pending
assert.equal(session.user.id, 2)
assert.equal(writes, 0)
}
})
for (const newerLogin of [true, false]) {
test(`logout navigation honors current session (newer login: ${newerLogin})`, async () => {
let session = null
const pendingLogout = deferred()
const redirects = []
const auth = loadTypeScriptModule(new URL("../../components/auth-provider.tsx", import.meta.url), {
react: {
createContext: () => ({ Provider: "context" }),
startTransition: (callback) => callback(),
useCallback: (callback) => callback,
useEffect: () => {},
},
"react/jsx-runtime": { jsx: (type, props) => ({ type, props }) },
"next/navigation": {
usePathname: () => "/dashboard",
useRouter: () => ({ replace: (url) => redirects.push(url) }),
},
"@/components/session-provider": {
SessionProvider: "session",
useSession: () => ({ session, ready: true, signOut: () => pendingLogout.promise }),
},
"@/lib/auth": { readSession: () => session },
})
const dashboardElement = auth.AuthProvider({ children: null }).props.children
const context = dashboardElement.type(dashboardElement.props)
const signOut = context.props.value.signOut()
if (newerLogin) session = { user: { id: 2 }, accessToken: "test-session-b" }
pendingLogout.resolve()
await signOut
assert.equal(redirects.length, newerLogin ? 0 : 1)
})
}

View File

@@ -0,0 +1,67 @@
import assert from "node:assert/strict"
import test from "node:test"
import ts from "typescript"
import { readFile } from "node:fs/promises"
import vm from "node:vm"
async function loadSetupAPI(request) {
const source = await readFile(new URL("./setup-readiness.ts", import.meta.url), "utf8")
const compiled = ts.transpileModule(source, {
compilerOptions: { target: ts.ScriptTarget.ES2017, module: ts.ModuleKind.CommonJS },
fileName: "setup-readiness.ts",
})
const sandbox = {
exports: {},
module: { exports: {} },
require(name) {
assert.equal(name, "@/lib/api/client")
return { request }
},
}
sandbox.exports = sandbox.module.exports
vm.runInNewContext(compiled.outputText, sandbox)
return sandbox.module.exports
}
test("simulation normalizes null or absent arrays from older servers", async () => {
for (const response of [{ candidate_operators: null, hops: null }, {}]) {
const api = await loadSetupAPI(async () => response)
const result = await api.simulateSetupRoute({ channel_id: 7 })
assert.equal(Array.isArray(result.candidate_operators), true)
assert.equal(result.candidate_operators.length, 0)
assert.equal(Array.isArray(result.hops), true)
assert.equal(result.hops.length, 0)
}
})
test("simulation preserves returned candidates", async () => {
const candidates = [{ user_id: 17, display_name: "Operator", eligible: true }]
const api = await loadSetupAPI(async () => ({ candidate_operators: candidates, hops: [] }))
const result = await api.simulateSetupRoute({ channel_id: 7 })
assert.equal(result.candidate_operators, candidates)
})
test("quick fix preview does not manufacture apply intent", async () => {
const calls = []
const api = await loadSetupAPI(async (url, options) => {
calls.push({ url, body: JSON.parse(options.body) })
return { applied: false, changes: [], preview_token: "reviewed" }
})
await api.applySetupQuickFix({ action: "fix_ai_agent_handoff", entity_id: 17 })
assert.equal(calls[0].url, "/api/dashboard/setup/quick_fix")
assert.equal(calls[0].body.entity_id, 17)
assert.equal("apply" in calls[0].body, false)
assert.equal("preview_token" in calls[0].body, false)
})
test("explicit apply carries the reviewed target and fingerprint", async () => {
let sent
const api = await loadSetupAPI(async (_url, options) => {
sent = JSON.parse(options.body)
return { applied: true, changes: [], applied_actions: [] }
})
await api.applySetupQuickFix({ action: "fix_ai_agent_handoff", entity_id: 17, apply: true, preview_token: "current-preview" })
assert.equal(sent.apply, true)
assert.equal(sent.entity_id, 17)
assert.equal(sent.preview_token, "current-preview")
})

View File

@@ -1,3 +1,4 @@
// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics.
import { request } from "@/lib/api/client"
export type SetupStepStatus = "ok" | "warning" | "error"
@@ -101,31 +102,51 @@ export type SetupSimulateRouteResponse = {
candidate_operators: SimulatedOperatorCandidate[]
}
export type SetupQuickFixChange = {
entity_type: string
entity_id: number
entity_name: string
operation: "create" | "update"
before: Record<string, unknown> | null
after: Record<string, unknown>
}
export type SetupQuickFixResponse = {
applied: boolean
preview_token: string
changes: SetupQuickFixChange[]
warning_codes: string[]
applied_actions: string[]
readiness: SetupReadinessResponse
readiness?: SetupReadinessResponse
}
export function fetchSetupReadiness() {
return request<SetupReadinessResponse>("/api/dashboard/setup/readiness")
}
export function simulateSetupRoute(payload: {
export async function simulateSetupRoute(payload: {
channel_id?: number
bypass_ai?: boolean
}) {
return request<SetupSimulateRouteResponse>(
const result = await request<SetupSimulateRouteResponse>(
"/api/dashboard/setup/simulate_route",
{
method: "POST",
body: JSON.stringify(payload),
},
)
return {
...result,
hops: result.hops ?? [],
candidate_operators: result.candidate_operators ?? [],
}
}
export function applySetupQuickFix(payload: {
action: SetupQuickFixAction
entity_id?: number
apply?: boolean
preview_token?: string
}) {
return request<SetupQuickFixResponse>("/api/dashboard/setup/quick_fix", {
method: "POST",

View File

@@ -0,0 +1,99 @@
import assert from "node:assert/strict"
import test from "node:test"
import { createMessageEditorDraftStore } from "./message-editor-drafts.ts"
test("conversation drafts stay separate across A → B → A", () => {
const store = createMessageEditorDraftStore()
const a = store.get("1")
store.update(a, "<p>For A</p>")
const b = store.get("2")
assert.equal(b.html, "")
store.update(b, "<p>For B</p>")
assert.equal(store.get("1").html, "<p>For A</p>")
assert.equal(store.get("2").html, "<p>For B</p>")
})
test("failed send preserves text and uploaded image metadata, and can retry", () => {
const store = createMessageEditorDraftStore()
const draft = store.get("1")
store.update(draft, '<p>Message</p><img title="uploading-test">')
draft.uploadedImages.set("uploading-test", { assetId: "test", provider: "local", storageKey: "test", url: "/test" })
assert.equal(store.finishSend(store.beginSend("1"), false), false)
assert.equal(draft.html, '<p>Message</p><img title="uploading-test">')
assert.equal(draft.uploadedImages.size, 1)
assert.ok(store.beginSend("1"))
})
test("send is single-flight, success clears only the captured draft", () => {
const store = createMessageEditorDraftStore()
const a = store.get("1")
const b = store.get("2")
store.update(a, "For A")
store.update(b, "For B")
const sent = store.beginSend("1")
assert.equal(store.beginSend("1"), null)
assert.equal(store.beginSend("2"), null)
assert.equal(store.finishSend(sent, true), true)
assert.equal(a.html, "")
assert.equal(b.html, "For B")
assert.ok(store.beginSend("2"))
})
test("typing during a pending send survives success, even after returning to identical text", () => {
const store = createMessageEditorDraftStore()
const draft = store.get("1")
store.update(draft, "Initial")
const sent = store.beginSend("1")
store.update(draft, "Next draft")
assert.equal(store.finishSend(sent, true), false)
assert.equal(draft.html, "Next draft")
const nextSend = store.beginSend("1")
store.update(draft, "Changed")
store.update(draft, "Next draft")
assert.equal(store.finishSend(nextSend, true), false)
assert.equal(draft.html, "Next draft")
})
test("identity change clears private drafts and invalidates old completions", () => {
const store = createMessageEditorDraftStore()
store.setScope("account-a")
const previous = store.get("1")
store.update(previous, "Private")
const sent = store.beginSend("1")
store.setScope("account-b")
const next = store.get("1")
store.update(next, "New account")
const nextSent = store.beginSend("1")
assert.equal(store.contains("1", previous), false)
assert.equal(store.finishSend(sent, true), false)
assert.equal(next.html, "New account")
assert.equal(store.finishSend(nextSent, true), true)
})
test("a newly mounted editor observes completion from the previous editor instance", () => {
const store = createMessageEditorDraftStore()
const draft = store.get("1")
store.update(draft, "Pending")
const sent = store.beginSend("1")
let rendered = draft.html
const unsubscribe = store.subscribe((changed) => {
if (changed === draft) rendered = changed.html
})
store.finishSend(sent, true)
assert.equal(rendered, "")
unsubscribe()
})
test("pending status stays observable when switching or remounting editors", () => {
const store = createMessageEditorDraftStore()
store.get("1")
const states = []
const unsubscribe = store.subscribeSending(() => states.push(store.isSending()))
const sent = store.beginSend("1")
store.get("2")
assert.equal(store.isSending(), true)
store.finishSend(sent, false)
assert.deepEqual(states, [true, false])
unsubscribe()
})

View File

@@ -0,0 +1,95 @@
import type { UploadedEditorImage } from "./im-editor-image"
export type MessageEditorDraft = {
html: string
revision: number
objectUrls: Set<string>
uploadedImages: Map<string, UploadedEditorImage & { url: string }>
}
type PendingDraftSend = {
key: string
draft: MessageEditorDraft
revision: number
}
/** In-memory only: neither messages nor temporary image URLs belong in storage. */
export function createMessageEditorDraftStore() {
const drafts = new Map<string, MessageEditorDraft>()
let pendingSend: PendingDraftSend | null = null
let scope: string | undefined
const sendingListeners = new Set<() => void>()
const notifySending = () => { for (const listener of sendingListeners) listener() }
const listeners = new Set<(draft: MessageEditorDraft) => void>()
const notify = (draft: MessageEditorDraft) => { for (const listener of listeners) listener(draft) }
function release(draft: MessageEditorDraft) {
for (const url of draft.objectUrls) URL.revokeObjectURL(url)
draft.objectUrls.clear()
draft.uploadedImages.clear()
}
function clear() {
for (const draft of drafts.values()) release(draft)
drafts.clear()
pendingSend = null
notifySending()
}
return {
clear,
notify,
subscribe(listener: (draft: MessageEditorDraft) => void) {
listeners.add(listener)
return () => { listeners.delete(listener) }
},
isSending() { return pendingSend !== null },
subscribeSending(listener: () => void) {
sendingListeners.add(listener)
return () => { sendingListeners.delete(listener) }
},
setScope(nextScope: string) {
if (scope !== nextScope) {
clear()
scope = nextScope
}
},
get(key: string): MessageEditorDraft {
let draft = drafts.get(key)
if (!draft) {
draft = { html: "", revision: 0, objectUrls: new Set(), uploadedImages: new Map() }
drafts.set(key, draft)
}
return draft
},
contains(key: string, draft: MessageEditorDraft) {
return drafts.get(key) === draft
},
update(draft: MessageEditorDraft, html: string) {
draft.html = html
draft.revision += 1
},
beginSend(key: string): PendingDraftSend | null {
const draft = drafts.get(key)
if (!draft || pendingSend) return null
pendingSend = { key, draft, revision: draft.revision }
notifySending()
return pendingSend
},
finishSend(sent: PendingDraftSend, succeeded: boolean) {
if (pendingSend !== sent) return false
pendingSend = null
notifySending()
if (!succeeded || drafts.get(sent.key) !== sent.draft || sent.draft.revision !== sent.revision) {
return false
}
sent.draft.html = ""
sent.draft.revision += 1
release(sent.draft)
notify(sent.draft)
return true
},
}
}
export type MessageEditorDraftStore = ReturnType<typeof createMessageEditorDraftStore>

View File

@@ -0,0 +1,14 @@
import assert from "node:assert/strict"
import test from "node:test"
import { getMessageFileIdentity } from "./message-file-identity.ts"
const file = (content, name = "attachment.txt") => ({ name, type: "text/plain",
arrayBuffer: async () => new TextEncoder().encode(content).buffer })
test("reselecting identical file content keeps attachment retry identity", async () => {
assert.equal(await getMessageFileIdentity(file("same")), await getMessageFileIdentity(file("same")))
})
test("equal-name equal-size files with different bytes cannot share retry identity", async () => {
assert.notEqual(await getMessageFileIdentity(file("left")), await getMessageFileIdentity(file("next")))
})

View File

@@ -0,0 +1,13 @@
/**
* Retry identity for a selected attachment. A file picker returns a fresh File
* object on reselection; metadata alone can also collide across different bytes.
*/
export async function getMessageFileIdentity(file: Pick<File, "name" | "type" | "arrayBuffer">) {
const bytes = await file.arrayBuffer()
const subtle = globalThis.crypto?.subtle
// Self-hosted HTTP deployments may lack WebCrypto. Exact bytes provide a
// collision-free in-memory fallback, rather than silently weakening the key.
const digest = subtle ? await subtle.digest("SHA-256", bytes) : bytes
const key = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")
return JSON.stringify([file.name, file.type, subtle ? "sha256" : "bytes", key])
}

View File

@@ -1,3 +1,4 @@
// Modified in the independent hardening fork; see docs/hardening/README.md.
import assert from "node:assert/strict"
import test from "node:test"
@@ -97,3 +98,41 @@ test("beforeConnect failure triggers onConnectError and disconnect status", asyn
assert.ok(statusList.includes("disconnected"), "status should include disconnected")
manager.disconnect()
})
test("disconnect invalidates a pending beforeConnect even across reconnect", async () => {
let resolveOld
let attempts = 0, created = 0
const old = new Promise((resolve) => { resolveOld = resolve })
const manager = createRealtimeConnectionManager({
beforeConnect: () => ++attempts === 1 ? old : Promise.resolve(),
createSocket: () => { created++; return new MockWebSocket() },
})
const first = manager.connect()
manager.disconnect()
await manager.connect()
resolveOld()
await first
assert.equal(created, 1)
manager.disconnect()
})
test("late old socket close cannot clear current connection or notify current owner", async () => {
let closed = 0
const sockets = []
const manager = createRealtimeConnectionManager({
createSocket: () => {
const socket = new MockWebSocket()
// Simulate an asynchronously arriving close event after replacement.
socket.close = () => { socket.readyState = MockWebSocket.CLOSED }
sockets.push(socket)
return socket
},
onClose: () => { closed++ },
})
await manager.connect()
await manager.connect()
sockets[0].emit("close", {})
assert.equal(manager.getSocket(), sockets[1])
assert.equal(closed, 0)
manager.disconnect()
})

View File

@@ -1,3 +1,4 @@
// Modified in the independent hardening fork; see docs/hardening/README.md.
"use client"
export type RealtimeConnectionStatus = "connecting" | "connected" | "disconnected"
@@ -157,15 +158,13 @@ export function createRealtimeConnectionManager(
})
nextSocket.addEventListener("close", (event) => {
clearPingTimer()
const isCurrentSocket = socket === nextSocket
if (isCurrentSocket) {
setSocket(null)
}
options.onClose?.(event, nextSocket)
if (!isCurrentSocket) {
return
}
clearPingTimer()
setSocket(null)
options.onClose?.(event, nextSocket)
if (canReconnect()) {
scheduleReconnect()
} else {
@@ -184,6 +183,9 @@ export function createRealtimeConnectionManager(
}
const disconnect = (disconnectOptions?: DisconnectOptions) => {
// Also invalidate an in-flight beforeConnect, even if another connection
// is enabled immediately after this disconnect.
connectEpoch += 1
reconnectEnabled = disconnectOptions?.reconnect ?? false
clearTimers()
if (!reconnectEnabled) {

View File

@@ -0,0 +1,11 @@
import assert from "node:assert/strict"
import test from "node:test"
import { realtimeLogContext } from "./realtime-log-context.ts"
test("socket diagnostics contain only origin and path, never URL credentials or query", () => {
assert.deepEqual(realtimeLogContext("wss://user:password@example.test/api/ws/admin?accessToken=not-a-secret#fragment"), {
origin: "wss://example.test",
path: "/api/ws/admin",
})
assert.deepEqual(realtimeLogContext("invalid token-bearing input"), { origin: "", path: "" })
})

View File

@@ -0,0 +1,9 @@
/** Never include WebSocket query parameters, userinfo, or arbitrary close reasons in logs. */
export function realtimeLogContext(url: string) {
try {
const parsed = new URL(url)
return { origin: parsed.origin, path: parsed.pathname }
} catch {
return { origin: "", path: "" }
}
}

View File

@@ -0,0 +1,32 @@
import assert from "node:assert/strict"
import test from "node:test"
import { createSessionRequestGuard, isSameSession } from "./session-request-guard.ts"
const account = (id, accessToken) => ({ user: { id }, accessToken })
test("late profile success, error, and finally are stale after sign-out", () => {
let current = account(1, "test-session-a")
const guard = createSessionRequestGuard(() => current)
const isCurrent = guard.begin(current)
current = null
assert.equal(isCurrent(), false)
})
test("old account and rotated-token responses cannot clear the current session", () => {
const previous = account(1, "test-session-a")
assert.equal(isSameSession(previous, account(2, "test-session-b")), false)
assert.equal(isSameSession(previous, account(1, "test-session-rotated")), false)
assert.equal(isSameSession(null, account(1, "test-session-a")), false)
assert.equal(isSameSession(previous, previous), true)
})
test("only the latest profile request can commit, including same-token requests", () => {
const current = account(1, "test-session-a")
const guard = createSessionRequestGuard(() => current)
const first = guard.begin(current)
const second = guard.begin(current)
assert.equal(first(), false)
assert.equal(second(), true)
guard.invalidate()
assert.equal(second(), false)
})

View File

@@ -0,0 +1,20 @@
// Kept transport-independent so all late session completions use the same rule.
export type SessionIdentity = {
accessToken: string
user: { id: number }
}
export function isSameSession(expected: SessionIdentity | null, current: SessionIdentity | null) {
return Boolean(expected && current && expected.accessToken === current.accessToken && expected.user.id === current.user.id)
}
export function createSessionRequestGuard(readCurrent: () => SessionIdentity | null) {
let generation = 0
return {
invalidate() { generation += 1 },
begin(session: SessionIdentity) {
const requestGeneration = ++generation
return () => generation === requestGeneration && isSameSession(session, readCurrent())
},
}
}

View File

@@ -0,0 +1,127 @@
import assert from "node:assert/strict"
import test from "node:test"
import { deferred, loadTypeScriptModule } from "../test-support/load-typescript-module.mjs"
function createStore(initialize) {
let state
const setState = (update, replace = false) => {
const next = typeof update === "function" ? update(state) : update
state = replace ? next : { ...state, ...next }
}
state = initialize(setState, () => state)
const initial = state
return { getState: () => state, setState, getInitialState: () => initial }
}
function fixture(api = {}) {
let session = { user: { id: 1 }, accessToken: "test-session-a" }
const loaded = loadTypeScriptModule(new URL("./agent-conversations.ts", import.meta.url), {
zustand: { create: createStore },
"@/lib/api/agent": api,
"@/lib/auth": { readSession: () => session },
"@/lib/utils": { generateUUID: () => "test-id" },
"@/lib/im-message": { summarizeIMMessage: () => "test-summary" },
"@/lib/im-message-merge": {
cursorFromLoadedImMessages: (messages) => String(messages[0]?.id ?? ""),
hasMoreAfterLatestImMessageMerge: () => false,
mergeImMessagesByIdAsc: (left, right) => [...left, ...right],
parseImMessageCursorId: Number,
},
"@/lib/im-realtime-state": {
patchConversationList: (list) => list,
patchConversationListWithMessage: (list) => list,
},
"@/lib/agent-activity-state": {},
"@/lib/operator-presence-state": {},
})
return {
store: loaded.useAgentConversationsStore,
reset: loaded.resetAgentConversations,
set session(next) { session = next },
}
}
test("late conversation list cannot restore private data after reset/account change", async () => {
const old = deferred()
const f = fixture({ fetchAgentConversations: () => old.promise })
const loading = f.store.getState().loadConversations()
f.session = { user: { id: 2 }, accessToken: "test-session-b" }
f.reset()
f.store.setState({ conversations: [{ id: 20 }], selectedConversationId: 20 })
old.resolve({ results: [{ id: 10 }] })
await loading
assert.equal(f.store.getState().conversations[0].id, 20)
assert.equal(f.store.getState().selectedConversationId, 20)
})
test("same-account relogin invalidates old load generations too", async () => {
const old = deferred()
const f = fixture({ fetchAgentMessages: () => old.promise })
f.store.setState({ selectedConversationId: 10 })
const loading = f.store.getState().loadMessages(10)
f.reset()
f.store.setState({ selectedConversationId: 10, messages: [{ id: 200 }] })
old.resolve({ results: [{ id: 100 }] })
await loading
assert.equal(f.store.getState().messages[0].id, 200)
})
test("operator send binds its recipient, is single-flight, and does not patch the new selection", async () => {
const response = deferred()
const calls = []
const f = fixture({ sendAgentMessage: (payload) => { calls.push(payload); return response.promise } })
f.store.setState({ selectedConversationId: 10 })
assert.equal(await f.store.getState().sendMessage("Wrong recipient", 20), null)
const sending = f.store.getState().sendMessage("For A", 10)
assert.equal(await f.store.getState().sendMessage("Duplicate", 10), null)
f.store.setState({ selectedConversationId: 20, messages: [{ id: 200 }] })
response.resolve({ id: 100, conversationId: 10 })
await sending
assert.equal(calls.length, 1)
assert.equal(calls[0].conversationId, 10)
assert.equal(f.store.getState().messages[0].id, 200)
})
test("old send finally does not unlock a new account's pending send", async () => {
const old = deferred()
const f = fixture({ sendAgentMessage: () => old.promise })
f.store.setState({ selectedConversationId: 10 })
const sending = f.store.getState().sendMessage("For A", 10)
f.reset()
f.store.setState({ sending: true, selectedConversationId: 20 })
old.resolve({ id: 100, conversationId: 10 })
await sending
assert.equal(f.store.getState().sending, true)
})
test("attachment upload cannot send under a new account after identity change", async () => {
const upload = deferred()
let sends = 0
const f = fixture({
uploadAgentConversationAttachment: () => upload.promise,
sendAgentMessage: async () => { sends += 1; return { id: 100 } },
})
f.store.setState({ selectedConversationId: 10 })
const pending = f.store.getState().sendAttachment({}, 10)
f.session = { user: { id: 2 }, accessToken: "test-session-b" }
f.reset()
upload.resolve({ assetId: "test", filename: "test.txt" })
assert.equal(await pending, null)
assert.equal(sends, 0)
})
test("a fresh snapshot invalidates old pagination without leaving its loading flag stuck", async () => {
const oldPage = deferred()
const fresh = deferred()
const f = fixture({ fetchAgentMessages: (params) => params.cursor ? oldPage.promise : fresh.promise })
f.store.setState({ selectedConversationId: 10, messagesCursor: "100", messagesHasMore: true })
const older = f.store.getState().loadOlderMessages()
assert.equal(f.store.getState().messagesLoadingMore, true)
const refresh = f.store.getState().loadMessages(10)
fresh.resolve({ results: [{ id: 200 }], hasMore: true })
await refresh
oldPage.resolve({ results: [{ id: 50 }], hasMore: false })
await older
assert.equal(f.store.getState().messages[0].id, 200)
assert.equal(f.store.getState().messagesLoadingMore, false)
})

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening/README.md.
// Modified for AgentDeskFixes: guard private state, captured recipients, and stale async completions.
"use client"
import { create } from "zustand"
@@ -104,9 +106,9 @@ type AgentConversationsStore = {
loadOlderMessages: () => Promise<void>
syncLatestMessages: (conversationId: number) => Promise<void>
markSelectedConversationRead: () => Promise<void>
sendMessage: (html: string) => Promise<AgentMessage | null>
uploadImage: (file: File) => Promise<AgentAsset | null>
sendAttachment: (file: File) => Promise<AgentMessage | null>
sendMessage: (html: string, conversationId?: number) => Promise<AgentMessage | null>
uploadImage: (file: File, conversationId?: number) => Promise<AgentAsset | null>
sendAttachment: (file: File, conversationId?: number) => Promise<AgentMessage | null>
recallMessage: (messageId: number) => Promise<AgentMessage | null>
applyRealtimeMessageCreated: (message: AgentMessage) => void
applyRealtimeAgentActivity: (payload: AgentActivityEventPayload) => void
@@ -119,6 +121,22 @@ type AgentConversationsStore = {
let conversationsRequestSeq = 0
let messagesRequestSeq = 0
let sessionGeneration = 0
function captureSessionScope() {
const generation = sessionGeneration
const userId = readSession()?.user.id
return () =>
generation === sessionGeneration && Boolean(userId) && readSession()?.user.id === userId
}
/** Invalidates every outstanding request before removing private dashboard data. */
export function resetAgentConversations() {
sessionGeneration += 1
conversationsRequestSeq += 1
messagesRequestSeq += 1
useAgentConversationsStore.setState(useAgentConversationsStore.getInitialState(), true)
}
export const useAgentConversationsStore = create<AgentConversationsStore>((set, get) => ({
searchKeyword: "",
@@ -167,6 +185,8 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
},
loadConversations: async () => {
const isCurrentSession = captureSessionScope()
if (!isCurrentSession()) return
const requestSeq = ++conversationsRequestSeq
const store = get()
@@ -180,7 +200,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
)
const conversations = ensureArray(data.results)
if (requestSeq !== conversationsRequestSeq) {
if (requestSeq !== conversationsRequestSeq || !isCurrentSession()) {
return
}
@@ -216,7 +236,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
})
}
} catch (error) {
if (requestSeq === conversationsRequestSeq) {
if (requestSeq === conversationsRequestSeq && isCurrentSession()) {
set({ conversationsLoading: false })
}
throw error
@@ -246,10 +266,14 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
},
loadMessages: async (conversationId, options = {}) => {
const isCurrentSession = captureSessionScope()
if (!isCurrentSession() || get().selectedConversationId !== conversationId) return
const requestSeq = ++messagesRequestSeq
const store = get()
const shouldShowLoading =
options.forceLoading || store.messagesLoadedConversationId !== conversationId
// A fresh snapshot supersedes any older page request in the prior generation.
set({ messagesLoadingMore: false })
if (shouldShowLoading) {
set({
@@ -270,7 +294,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
limit: 50,
})
if (requestSeq !== messagesRequestSeq) {
if (requestSeq !== messagesRequestSeq || !isCurrentSession()) {
return
}
@@ -288,7 +312,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
messagesHasMore: Boolean(data.hasMore),
})
} catch (error) {
if (requestSeq === messagesRequestSeq) {
if (requestSeq === messagesRequestSeq && isCurrentSession()) {
set({ messagesLoading: false })
}
throw error
@@ -296,6 +320,9 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
},
loadOlderMessages: async () => {
const isCurrentSession = captureSessionScope()
if (!isCurrentSession()) return
const selectionGeneration = messagesRequestSeq
const conversationId = get().selectedConversationId
if (!conversationId || get().messagesLoadingMore || !get().messagesHasMore) {
return
@@ -312,7 +339,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
cursor: cursorId,
limit: 50,
})
if (get().selectedConversationId !== conversationId) {
if (!isCurrentSession() || selectionGeneration !== messagesRequestSeq || get().selectedConversationId !== conversationId) {
return
}
const incoming = ensureArray(data.results)
@@ -328,12 +355,17 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
}
})
} catch (error) {
set({ messagesLoadingMore: false })
if (isCurrentSession() && selectionGeneration === messagesRequestSeq && get().selectedConversationId === conversationId) {
set({ messagesLoadingMore: false })
}
throw error
}
},
syncLatestMessages: async (conversationId) => {
const isCurrentSession = captureSessionScope()
if (!isCurrentSession()) return
const selectionGeneration = messagesRequestSeq
if (conversationId <= 0) {
return
}
@@ -342,7 +374,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
conversationId,
limit: 50,
})
if (get().selectedConversationId !== conversationId) {
if (!isCurrentSession() || selectionGeneration !== messagesRequestSeq || get().selectedConversationId !== conversationId) {
return
}
const batch = ensureArray(data.results)
@@ -370,6 +402,8 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
},
markSelectedConversationRead: async () => {
const isCurrentSession = captureSessionScope()
if (!isCurrentSession()) return
const store = get()
const conversationId = store.selectedConversationId
const conversation = store.conversations.find((item) => item.id === conversationId)
@@ -390,6 +424,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
set({ readingMessageId: lastMessage.id })
try {
await markAgentMessageRead(conversationId, lastMessage.id)
if (!isCurrentSession() || get().readingMessageId !== lastMessage.id) return
set((current) => {
if (current.selectedConversationId !== conversationId) {
return { readingMessageId: 0 }
@@ -414,7 +449,9 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
}
})
} catch (error) {
set({ readingMessageId: 0 })
if (isCurrentSession() && get().readingMessageId === lastMessage.id) {
set({ readingMessageId: 0 })
}
throw error
}
},
@@ -513,7 +550,10 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
},
resyncRealtimeData: async (conversationId) => {
const isCurrentSession = captureSessionScope()
if (!isCurrentSession()) return
await get().loadConversations()
if (!isCurrentSession()) return
const selectedConversationId = get().selectedConversationId
const targetConversationId = conversationId ?? selectedConversationId
if (targetConversationId && selectedConversationId === targetConversationId) {
@@ -524,10 +564,15 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
}
},
sendMessage: async (html) => {
sendMessage: async (html, recipientId) => {
const isCurrentSession = captureSessionScope()
const trimmedContent = html.trim()
const { selectedConversationId, sending } = get()
if (!selectedConversationId || !trimmedContent || sending) {
if (
!isCurrentSession() || !selectedConversationId ||
(recipientId !== undefined && recipientId !== selectedConversationId) ||
!trimmedContent || sending || get().uploadingAsset
) {
return null
}
@@ -540,7 +585,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
clientMsgId: `agent_${generateUUID()}`,
})
if (get().selectedConversationId === selectedConversationId) {
if (isCurrentSession() && get().selectedConversationId === selectedConversationId) {
set((current) => ({
messages: current.messages.some((m) => m.id === message.id)
? current.messages.map((m) => (m.id === message.id ? message : m))
@@ -561,33 +606,45 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
return message
} finally {
set({ sending: false })
if (isCurrentSession()) set({ sending: false })
}
},
uploadImage: async (file) => {
uploadImage: async (file, recipientId) => {
const isCurrentSession = captureSessionScope()
const { selectedConversationId, sending, uploadingAsset } = get()
if (!selectedConversationId || sending || uploadingAsset) {
if (
!isCurrentSession() || !selectedConversationId ||
(recipientId !== undefined && recipientId !== selectedConversationId) ||
sending || uploadingAsset
) {
return null
}
set({ uploadingAsset: true })
try {
return await uploadAgentConversationImage(selectedConversationId, file)
const asset = await uploadAgentConversationImage(selectedConversationId, file)
return isCurrentSession() ? asset : null
} finally {
set({ uploadingAsset: false })
if (isCurrentSession()) set({ uploadingAsset: false })
}
},
sendAttachment: async (file) => {
sendAttachment: async (file, recipientId) => {
const isCurrentSession = captureSessionScope()
const { selectedConversationId, sending, uploadingAsset } = get()
if (!selectedConversationId || sending || uploadingAsset) {
if (
!isCurrentSession() || !selectedConversationId ||
(recipientId !== undefined && recipientId !== selectedConversationId) ||
sending || uploadingAsset
) {
return null
}
set({ uploadingAsset: true })
try {
const asset = await uploadAgentConversationAttachment(selectedConversationId, file)
if (!isCurrentSession()) return null
const message = await sendAgentMessage({
conversationId: selectedConversationId,
messageType: "attachment",
@@ -596,7 +653,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
clientMsgId: `agent_attachment_${generateUUID()}`,
})
if (get().selectedConversationId === selectedConversationId) {
if (isCurrentSession() && get().selectedConversationId === selectedConversationId) {
set((current) => ({
messages: current.messages.some((m) => m.id === message.id)
? current.messages.map((m) => (m.id === message.id ? message : m))
@@ -617,20 +674,22 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
return message
} finally {
set({ uploadingAsset: false })
if (isCurrentSession()) set({ uploadingAsset: false })
}
},
recallMessage: async (messageId) => {
const isCurrentSession = captureSessionScope()
const { selectedConversationId, recallingMessageId } = get()
if (!selectedConversationId || messageId <= 0 || recallingMessageId === messageId) {
if (!isCurrentSession() || !selectedConversationId || messageId <= 0 || recallingMessageId === messageId) {
return null
}
set({ recallingMessageId: messageId })
try {
const message = await recallAgentMessage(messageId)
if (get().selectedConversationId === selectedConversationId) {
if (!isCurrentSession()) return null
if (isCurrentSession() && get().selectedConversationId === selectedConversationId) {
set((current) => {
const nextMessages = current.messages.map((item) =>
item.id === message.id ? message : item
@@ -656,11 +715,11 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
}
})
} else {
set({ recallingMessageId: 0 })
if (isCurrentSession() && get().recallingMessageId === messageId) set({ recallingMessageId: 0 })
}
return message
} catch (error) {
set({ recallingMessageId: 0 })
if (isCurrentSession() && get().recallingMessageId === messageId) set({ recallingMessageId: 0 })
throw error
}
},

View File

@@ -0,0 +1,289 @@
import assert from "node:assert/strict"
import { readFileSync } from "node:fs"
import test from "node:test"
import vm from "node:vm"
import ts from "typescript"
const deferred = () => {
let resolve, reject
const promise = new Promise((yes, no) => { resolve = yes; reject = no })
return { promise, resolve, reject }
}
const conversation = (id, extra = {}) => ({ id, channelId: 1, customerName: "Visitor", status: 3,
serviceMode: 1, priority: 0, currentAssigneeId: 0, lastMessageId: 0,
customerUnreadCount: 0, agentUnreadCount: 0, customerLastReadMessageId: 0,
agentLastReadMessageId: 0, ...extra })
const message = (id, conversationId, extra = {}) => ({ id, conversationId,
senderType: "customer", senderId: 1, messageType: "html", content: `<p>${id}</p>`,
sendStatus: 1, customerRead: false, agentRead: false, ...extra })
const page = (results, hasMore = false) => ({ results, hasMore })
// Exercise the actual store, including its asynchronous continuations, without
// connecting a socket, browser, database, or service.
function harness(overrides = {}) {
let identity = "visitor-1", uuid = 0, state, realtimeOptions
let connectCount = 0, disconnectCount = 0
const timers = new Map()
const api = {
getCustomerIdentityScope: () => identity,
getCustomerEntryScope: () => identity,
ensureCustomerSession: async () => ({}),
fetchImWidgetConfig: async () => ({}),
fetchImConversationList: async () => page([conversation(1), conversation(2)]),
createOrMatchImConversation: async () => conversation(1),
fetchImMessages: async () => page([]),
createImConversation: async () => conversation(3),
markImMessageRead: async () => {},
closeImConversation: async () => {},
applyCustomerSessionRefresh: () => {},
...overrides,
}
const modules = new Map()
const mocks = {
zustand: { create: (initializer) => {
const set = (patch) => { state = { ...state, ...(typeof patch === "function" ? patch(state) : patch) } }
state = initializer(set, () => state)
return { getState: () => state, setState: set }
} },
"@/lib/api/im": api,
"@/lib/message-file-identity": { getMessageFileIdentity: async (file) => file.content },
"@/lib/im-realtime": { createImRealtimeConnection: () => {} },
"@/lib/im-message": { summarizeIMMessage: (item) => item.content, parseMessageAssetPayload: () => null },
"@/lib/utils": { generateUUID: () => `uuid-${++uuid}` },
"@/i18n/messages": { translateCurrentMessage: (key) => key },
"@/lib/sdk/runtime-config": { readSupportChatRuntimeConfig: () => ({}), setSupportChatRuntimeConfig: () => {} },
"@/lib/realtime-connection": { createRealtimeConnectionManager: (options) => {
realtimeOptions = options
return { connect: async () => { connectCount++ }, disconnect: () => { disconnectCount++ } }
} },
}
const load = (name) => {
if (mocks[name]) return mocks[name]
if (modules.has(name)) return modules.get(name)
const url = name.startsWith("@/")
? new URL(`../../${name.slice(2)}.ts`, import.meta.url)
: new URL(name, import.meta.url)
const source = readFileSync(url, "utf8")
const compiled = ts.transpileModule(source, { compilerOptions: {
target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS,
} })
const module = { exports: {} }
modules.set(name, module.exports)
vm.runInNewContext(compiled.outputText, { module, exports: module.exports, require: load,
console, setTimeout: (callback) => { const id = timers.size + 1; timers.set(id, callback); return id },
clearTimeout: (id) => timers.delete(id), WebSocket: { OPEN: 1, CONNECTING: 0 } })
return module.exports
}
const store = load("./support-chat.ts").useSupportChatStore
const seed = (extra = {}) => store.setState({ identityScope: identity, initialized: true,
conversations: [conversation(1), conversation(2)], activeConversationId: 1,
conversation: conversation(1), ...extra })
seed()
return { store, api, seed, timers, setIdentity: (value) => { identity = value },
emit: (type, data) => realtimeOptions.onMessage({ data: JSON.stringify({ type, data }) }),
get connectCount() { return connectCount }, get disconnectCount() { return disconnectCount } }
}
for (const loader of ["refreshMessages", "syncLatestMessages", "loadOlderMessages"]) {
test(`${loader} cannot paint a late response into a newly selected request`, async () => {
const late = deferred()
const h = harness({ fetchImMessages: ({ conversationId }) => conversationId === 1 ? late.promise : Promise.resolve(page([message(20, 2)])) })
h.seed({ messages: [message(10, 1)], messagesHasMore: true, messagesCursor: "10" })
const pending = h.store.getState()[loader]()
await h.store.getState().selectConversation(2)
late.resolve(page([message(5, 1)], true))
await pending
assert.equal(h.store.getState().conversation.id, 2)
assert.deepEqual(Array.from(h.store.getState().messages, (item) => item.conversationId), [2])
assert.equal(h.store.getState().messagesLoadingMore, false)
})
}
test("A to B to A selection invalidates the first A response", async () => {
const late = deferred()
let calls = 0
const h = harness({ fetchImMessages: ({ conversationId }) => conversationId === 1 && calls++ === 0
? late.promise : Promise.resolve(page([message(30, conversationId)])) })
const pending = h.store.getState().refreshMessages()
await h.store.getState().selectConversation(2)
await h.store.getState().selectConversation(1)
late.resolve(page([message(10, 1)]))
await pending
assert.deepEqual(Array.from(h.store.getState().messages, (item) => item.id), [30])
})
test("newer latest request wins and stale failure cannot replace its error state", async () => {
const old = deferred(), current = deferred()
let calls = 0
const h = harness({ fetchImMessages: () => ++calls === 1 ? old.promise : current.promise })
const first = h.store.getState().refreshMessages()
const second = h.store.getState().refreshMessages()
current.resolve(page([message(30, 1)]))
await second
old.reject(new Error("stale error"))
await first
assert.equal(h.store.getState().error, "")
assert.equal(h.store.getState().messages[0].id, 30)
})
test("read acknowledgment updates its original list row without marking the new view", async () => {
const read = deferred()
const h = harness({ markImMessageRead: () => read.promise, fetchImMessages: async () => page([message(20, 2)]) })
const first = conversation(1, { lastMessageId: 10, customerUnreadCount: 1 })
h.seed({ conversations: [first, conversation(2, { customerUnreadCount: 2 })], conversation: first, messages: [message(10, 1)] })
const pending = h.store.getState().markConversationRead()
await h.store.getState().selectConversation(2)
read.resolve()
await pending
const state = h.store.getState()
assert.equal(state.conversations.find((item) => item.id === 1).customerUnreadCount, 0)
assert.equal(state.conversation.customerUnreadCount, 2)
assert.equal(state.messages[0].customerRead, false)
})
test("read acknowledgment does not erase a newer unread reply", async () => {
const read = deferred()
const h = harness({ markImMessageRead: () => read.promise })
const first = conversation(1, { lastMessageId: 10, customerUnreadCount: 1 })
h.seed({ conversations: [first], conversation: first, messages: [message(10, 1)] })
const pending = h.store.getState().markConversationRead()
h.emit("conversation.updated", { conversationId: 1, lastMessageId: 11, customerUnreadCount: 2 })
read.resolve()
await pending
assert.equal(h.store.getState().conversation.customerUnreadCount, 2)
assert.equal(h.store.getState().conversation.customerLastReadMessageId, 10)
})
test("send is single-flight and failed logical send keeps its idempotency key", async () => {
const first = deferred(), sent = []
const h = harness({ sendImMessage: (payload) => { sent.push(payload); return sent.length === 1 ? first.promise : Promise.resolve(message(10, 1)) } })
const pending = h.store.getState().handleSendMessage("<p>Hello</p>")
await assert.rejects(h.store.getState().handleSendMessage("<p>Hello</p>"), /sendInProgress/)
assert.equal(sent.length, 1)
first.reject(new Error("response lost"))
await assert.rejects(pending, /response lost/)
await h.store.getState().handleSendMessage("<p>Hello</p>")
assert.equal(sent[0].clientMsgId, sent[1].clientMsgId)
await h.store.getState().handleSendMessage("<p>Hello</p>")
assert.notEqual(sent[1].clientMsgId, sent[2].clientMsgId)
})
test("delivered send after switching updates only original request summary", async () => {
const send = deferred()
const h = harness({ sendImMessage: () => send.promise, fetchImMessages: async () => page([message(20, 2)]) })
const pending = h.store.getState().handleSendMessage("hello")
await h.store.getState().selectConversation(2)
assert.equal(h.store.getState().sending, false)
send.resolve(message(10, 1))
await pending
const state = h.store.getState()
assert.equal(state.conversations.find((item) => item.id === 1).lastMessageId, 10)
assert.equal(state.conversation.lastMessageId, 0)
assert.equal(state.messages[0].conversationId, 2)
})
test("close response for old selection does not close or disconnect current request", async () => {
const close = deferred()
const h = harness({ closeImConversation: () => close.promise })
const pending = h.store.getState().closeConversation()
await h.store.getState().selectConversation(2)
close.resolve()
await pending
assert.equal(h.store.getState().conversation.status, 3)
assert.equal(h.store.getState().conversations.find((item) => item.id === 1).status, 4)
assert.equal(h.disconnectCount, 0)
})
test("identity change invalidates old delivered result even for same numeric conversation", async () => {
const send = deferred()
const h = harness({ sendImMessage: () => send.promise })
const pending = h.store.getState().handleSendMessage("private")
h.setIdentity("visitor-2")
h.seed()
send.resolve(message(10, 1))
await pending
assert.equal(h.store.getState().messages.length, 0)
assert.equal(h.store.getState().conversation.lastMessageId, 0)
})
test("unmount invalidates pending bootstrap and prevents sockets from reopening", async () => {
const config = deferred()
const h = harness({ fetchImWidgetConfig: () => config.promise })
h.seed({ initialized: false, isOpen: true, conversation: null, activeConversationId: 0 })
const pending = h.store.getState().bootstrap()
h.store.getState().disconnectSocket()
config.resolve({ title: "stale" })
await pending
assert.equal(h.store.getState().conversation, null)
assert.equal(h.connectCount, 0)
})
test("retry reboots a failed initial load, and concurrent bootstrap is single-flight", async () => {
let listCalls = 0
const config = deferred()
const h = harness({ fetchImWidgetConfig: () => config.promise,
fetchImConversationList: async () => { if (++listCalls === 1) throw new Error("offline"); return page([conversation(1)]) } })
h.seed({ initialized: false, isOpen: true, conversation: null, activeConversationId: 0 })
const first = h.store.getState().bootstrap()
assert.equal(h.store.getState().bootstrap(), first)
config.resolve({})
await first
assert.equal(h.store.getState().conversation, null)
await h.store.getState().retry()
assert.equal(h.store.getState().conversation.id, 1)
assert.equal(h.store.getState().error, "")
})
test("new request and request switching reconnect after transport was disconnected", async () => {
const h = harness()
h.seed({ isOpen: true, socket: null })
await h.store.getState().startNewConversation()
assert.equal(h.store.getState().conversation.id, 3)
assert.ok(h.connectCount > 0)
const connects = h.connectCount
await h.store.getState().selectConversation(2)
assert.ok(h.connectCount > connects)
})
test("list refresh cannot overwrite a delivered send that happened after its snapshot", async () => {
const list = deferred()
const h = harness({ fetchImConversationList: () => list.promise,
sendImMessage: async () => message(10, 1) })
const pending = h.store.getState().refreshConversations()
await h.store.getState().handleSendMessage("hello")
list.resolve(page([conversation(1), conversation(2)]))
await pending
assert.equal(h.store.getState().conversation.lastMessageId, 10)
})
test("image upload completes into captured draft even after selecting another request", async () => {
const upload = deferred()
const h = harness({ uploadImImage: () => upload.promise })
const pending = h.store.getState().uploadMessageImage({})
await h.store.getState().selectConversation(2)
const asset = { assetId: "image-1" }
upload.resolve(asset)
assert.equal(await pending, asset)
})
test("changed identity clears private state while closed, before reopening", async () => {
const h = harness()
h.seed({ messages: [message(10, 1)], isOpen: false })
h.setIdentity("visitor-2")
await h.store.getState().bootstrap()
assert.equal(h.store.getState().conversation, null)
assert.equal(h.store.getState().conversations.length, 0)
assert.equal(h.store.getState().messages.length, 0)
assert.equal(h.store.getState().identityScope, "visitor-2")
})
test("reselected attachment reuses upload and logical send key after uncertain failure", async () => {
const sent = []
let uploads = 0
const h = harness({ uploadImAttachment: async () => { uploads++; return { assetId: "asset-1", filename: "attachment.txt" } },
sendImMessage: async (payload) => { sent.push(payload); if (sent.length === 1) throw new Error("ack lost"); return message(10, 1) } })
await assert.rejects(h.store.getState().sendAttachment({ content: "same file" }), /ack lost/)
await h.store.getState().sendAttachment({ content: "same file" })
assert.equal(uploads, 1)
assert.equal(sent[0].clientMsgId, sent[1].clientMsgId)
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
import { readFileSync } from "node:fs"
import vm from "node:vm"
import ts from "typescript"
/** Runs the real module with explicitly supplied offline transports/dependencies. */
export function loadTypeScriptModule(url, dependencies, globals = {}) {
const { outputText } = ts.transpileModule(readFileSync(url, "utf8"), {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX },
fileName: url.pathname,
})
const module = { exports: {} }
const require = (name) => {
if (!Object.hasOwn(dependencies, name)) throw new Error(`Unmocked dependency: ${name}`)
return dependencies[name]
}
vm.runInNewContext(outputText, { module, exports: module.exports, require, ...globals }, { filename: url.pathname })
return module.exports
}
export function deferred() {
let resolve
let reject
const promise = new Promise((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, resolve, reject }
}

View File

@@ -583,7 +583,12 @@
"startFirstRequest": "Start your first request",
"openRequestCount": "{open} open of {total}",
"loadConversationsFailed": "Could not load your requests",
"createConversationFailed": "Could not create the request"
"createConversationFailed": "Could not create the request",
"dismissedTitle": "Chat window closed",
"dismissedDescription": "Your support request has not been ended. You can return to continue the conversation.",
"returnToChat": "Return to chat",
"sendUnavailable": "This conversation is unavailable. Return to it or retry before sending.",
"sendInProgress": "A message is already being sent."
},
"dashboardHome": {
"rangeToday": "Today",
@@ -3370,8 +3375,8 @@
"statusReady": "Ready to Receive Requests",
"statusNeedsSetup": "Setup Incomplete ({ready}/{total} steps ready)",
"stepsProgress": "{ready} of {total} steps ready",
"quickSetupAll": "Configure Everything in 1 Click",
"quickSetupRunning": "Applying setup...",
"quickSetupAll": "Review missing defaults",
"quickSetupRunning": "Preparing preview...",
"quickFixSuccess": "Configuration updated and routing re-checked",
"refresh": "Re-check Readiness",
"openSettings": "Open Settings",
@@ -3433,7 +3438,9 @@
"channel_no_agent": "Channel is not connected to an AI agent.",
"channel_agent_disabled": "Channel points to a disabled or missing AI agent.",
"channel_zero_rollout": "Channel has a 0% AI agent rollout rate.",
"no_end_to_end_path": "No channel can successfully route to an available human operator right now."
"no_end_to_end_path": "No channel can successfully route to an available human operator right now.",
"agent_unpublished": "The agent has no published revision. Publish it in agent settings before using it for a channel.",
"channel_agent_unpublished": "The channel points to an unpublished agent. Publish the agent or deliberately choose a published one."
},
"operatorBlockers": {
"not_authenticated": "You are not signed in.",
@@ -3446,22 +3453,22 @@
"at_capacity": "You have reached your maximum active conversation limit."
},
"quickFixLabels": {
"quick_setup_all": "Configure Everything in 1 Click",
"ensure_team_and_schedule": "Create/Enable Team & 24/7 Schedule",
"quick_setup_all": "Review missing defaults",
"ensure_team_and_schedule": "Review team setup",
"enable_my_operator_profile": "Enable My Reception & Set Idle",
"fix_ai_agent_handoff": "Fix AI Agent Handoff & Bind Teams",
"ensure_default_channel": "Create/Fix Default Web Channel"
},
"simulator": {
"title": "Operator Route Simulator",
"description": "Simulate what happens when a customer sends a message on a channel and see every decision hop in real time.",
"description": "Read-only check of current human-only routing eligibility. AI behavior and partial rollout are not simulated, and no message is sent.",
"selectChannelPlaceholder": "Select a channel to simulate...",
"runSimulation": "Simulate Dispatch",
"running": "Simulating...",
"outcomeSuccess": "Request WILL reach operator: {operator}",
"outcomeBlocked": "Request WILL NOT reach an operator",
"outcomeSuccess": "Eligible operator now: {operator}",
"outcomeBlocked": "Operator delivery not confirmed",
"outcomes": {
"assigned_to_operator": "Success: The conversation is assigned to an idle operator.",
"assigned_to_operator": "Human-only routing currently has an eligible operator. This is a read-only check, not a delivery guarantee.",
"blocked_no_channel": "Blocked: No channels exist or the requested channel was not found.",
"blocked_channel_disabled": "Blocked: This channel is disabled.",
"blocked_zero_rollout": "Blocked: Channel AI agent rollout rate is set to 0%.",
@@ -3469,7 +3476,10 @@
"blocked_ai_only_mode": "Blocked: AI agent is set to AI-Only mode and never hands off to human operators.",
"blocked_agent_no_teams": "Blocked: AI agent has no valid operator teams assigned for handoff.",
"queued_off_hours_no_schedule": "Queued: Target teams have no active shift schedule right now (Off-Hours).",
"queued_no_eligible_operator": "Queued: Shift is active, but no idle operators with available capacity are online."
"queued_no_eligible_operator": "Queued: Shift is active, but no idle operators with available capacity are online.",
"ai_execution_not_simulated": "AI behavior is not simulated; operator delivery is unverified.",
"partial_rollout_not_simulated": "Partial rollout is not simulated; operator delivery is unverified.",
"blocked_agent_unpublished": "Operator routing is not verified for an unpublished agent. Publish the agent first."
},
"hops": {
"channel": "Channel",
@@ -3491,7 +3501,10 @@
"blocked_agent_no_teams": "No active teams assigned to this agent",
"queued_off_hours_no_schedule": "No active shift schedule covers the current time (Off-Hours)",
"queued_no_eligible_operator": "No idle operators with available capacity in active teams",
"assigned_to_operator": "Assigned to eligible operator"
"assigned_to_operator": "Assigned to eligible operator",
"ai_execution_not_simulated": "AI behavior is not simulated; operator delivery is unverified.",
"partial_rollout_not_simulated": "Partial rollout is not simulated; operator delivery is unverified.",
"blocked_agent_unpublished": "Operator routing is not verified for an unpublished agent. Publish the agent first."
},
"candidateTableTitle": "Evaluated Operator Candidates",
"colOperator": "Operator",
@@ -3520,6 +3533,53 @@
"headerIndicator": {
"readyLabel": "Ready for Chats",
"blockedLabel": "Routing Blocked ({count})"
},
"preview": {
"review": "Review setup changes",
"title": "Review exact changes before applying",
"description": "Only the named records and fields below will change. No shifts are added. Unlisted configurations and rollout percentages remain unchanged. Applying may dispatch pending conversations after the changes commit.",
"noChanges": "No safe automatic changes are available. Use the relevant settings to choose a team, route or working hours.",
"apply": "Apply reviewed changes",
"create": "Create",
"update": "Update",
"notApplied": "The reviewed changes were not applied.",
"manageSchedules": "Manage explicit working hours"
},
"repairFields": {
"name": "Name",
"leader_user_id": "Team leader ID",
"status": "Enabled status (0 enabled, 1 disabled)",
"user_id": "User ID",
"team_id": "Team ID / new team name",
"team_ids": "Routing teams",
"service_status": "Availability (0 idle, 1 busy)",
"auto_assign_enabled": "Automatic assignment",
"max_concurrent_count": "Concurrent conversation limit",
"priority_level": "Assignment priority",
"receive_offline_message": "Receive offline messages",
"service_mode": "Service mode (1 AI only, 2 human only, 3 AI first)",
"rollout_percent": "Agent rollout (%)",
"handoff_mode": "Handoff mode (2 team queue)",
"fallback_mode": "Fallback mode",
"auto_handoff_on_failure": "Automatic handoff on failure",
"reply_timeout_seconds": "Reply timeout (seconds)",
"max_steps": "Maximum AI steps",
"channel_id": "Channel identifier",
"channel_type": "Channel type",
"ai_agent_id": "Agent ID / new agent name",
"ai_agent_rollout_percent": "Channel rollout (%)",
"config_json": "Channel configuration"
},
"repairWarnings": {
"schedules_unchanged": "Working hours were not changed. Configure shifts explicitly; being off duty is not a setup error.",
"active_team_required": "Select or enable a team in team settings before linking new records.",
"disabled_configuration_preserved": "The selected configuration is disabled or has zero rollout and was left unchanged.",
"agent_team_selection_required": "Choose the agent’s intended teams in agent settings; no teams were added automatically.",
"channel_selection_required": "Choose the intended agent and rollout in channel settings. Existing channel routing was preserved.",
"existing_configuration_preserved": "Existing configuration was preserved. Review any remaining readiness issues in the relevant settings.",
"dispatch_failed": "Changes were saved, but pending conversation dispatch failed. Review the queue; do not reapply the repair.",
"readiness_refresh_failed": "The readiness refresh failed. Reload diagnostics to see the current state; committed changes are not rolled back.",
"agent_publication_required": "Publish the intended agent in agent settings before creating a channel. Setup does not publish agents or bypass the publication requirement."
}
}
}

View File

@@ -0,0 +1,2 @@
SPDX-License-Identifier: Apache-2.0
SPDX-FileComment: Modified in the independent hardening fork. See docs/hardening/README.md and MODIFIED_FILES.md for source attribution and change notes.

View File

@@ -583,7 +583,12 @@
"startFirstRequest": "Создайте первое обращение",
"openRequestCount": "Открыто {open} из {total}",
"loadConversationsFailed": "Не удалось загрузить список обращений",
"createConversationFailed": "Не удалось создать обращение"
"createConversationFailed": "Не удалось создать обращение",
"dismissedTitle": "Окно чата закрыто",
"dismissedDescription": "Ваше обращение не завершено. Вы можете вернуться и продолжить разговор.",
"returnToChat": "Вернуться в чат",
"sendUnavailable": "Диалог недоступен. Вернитесь к нему или повторите попытку перед отправкой.",
"sendInProgress": "Сообщение уже отправляется."
},
"dashboardHome": {
"rangeToday": "Сегодня",
@@ -3370,8 +3375,8 @@
"statusReady": "Готово к приёму обращений",
"statusNeedsSetup": "Требуется настройка (готово {ready} из {total} шагов)",
"stepsProgress": "Готово {ready} из {total} шагов",
"quickSetupAll": "Настроить всё в 1 клик",
"quickSetupRunning": "Применение настроек...",
"quickSetupAll": "Проверить недостающие настройки",
"quickSetupRunning": "Подготовка плана...",
"quickFixSuccess": "Настройки применены, проверка маршрутизации обновлена",
"refresh": "Проверить снова",
"openSettings": "Открыть настройки",
@@ -3433,7 +3438,9 @@
"channel_no_agent": "Канал не подключен к AI-агенту.",
"channel_agent_disabled": "Канал указывает на отключенного или удаленного AI-агента.",
"channel_zero_rollout": "У канала охват AI-агента равен 0%.",
"no_end_to_end_path": "Ни один канал не может доставить запрос до доступного оператора прямо сейчас."
"no_end_to_end_path": "Ни один канал не может доставить запрос до доступного оператора прямо сейчас.",
"agent_unpublished": "У агента нет опубликованной версии. Опубликуйте его в настройках перед использованием в канале.",
"channel_agent_unpublished": "Канал связан с неопубликованным агентом. Опубликуйте его или выберите опубликованного агента."
},
"operatorBlockers": {
"not_authenticated": "Вы не вошли в систему.",
@@ -3446,22 +3453,22 @@
"at_capacity": "Вы достигли лимита одновременных диалогов."
},
"quickFixLabels": {
"quick_setup_all": "Настроить всё в 1 клик",
"ensure_team_and_schedule": "Создать/включить команду и расписание 24/7",
"quick_setup_all": "Проверить недостающие настройки",
"ensure_team_and_schedule": "Проверить настройку группы",
"enable_my_operator_profile": "Включить мой приём и статус «Свободен»",
"fix_ai_agent_handoff": "Настроить передачу оператору и привязать команды",
"ensure_default_channel": "Создать/исправить веб-канал по умолчанию"
},
"simulator": {
"title": "Симулятор маршрутизации к оператору",
"description": "Протестируйте путь входящего сообщения клиента через канал и узнайте результат на каждом узле маршрутизации.",
"description": "Проверка текущей доступности маршрута только с оператором без изменений. Поведение ИИ и частичный запуск не моделируются; сообщения не отправляются.",
"selectChannelPlaceholder": "Выберите канал для симуляции...",
"runSimulation": "Запустить симуляцию",
"running": "Симуляция...",
"outcomeSuccess": "Запрос ДОЙДЁТ до оператора: {operator}",
"outcomeBlocked": "Запрос НЕ ДОЙДЁТ до оператора",
"outcomeSuccess": "Сейчас доступен оператор: {operator}",
"outcomeBlocked": "Передача оператору не подтверждена",
"outcomes": {
"assigned_to_operator": "Успех: обращение назначено свободному оператору.",
"assigned_to_operator": "Для маршрута только с оператором сейчас есть подходящий сотрудник. Это проверка без изменений, не гарантия доставки.",
"blocked_no_channel": "Заблокировано: каналы не созданы или запрошенный канал не найден.",
"blocked_channel_disabled": "Заблокировано: данный канал отключен.",
"blocked_zero_rollout": "Заблокировано: охват AI-агента в канале равен 0%.",
@@ -3469,7 +3476,10 @@
"blocked_ai_only_mode": "Заблокировано: AI-агент работает в режиме «Только ИИ» и не переключает на человека.",
"blocked_agent_no_teams": "Заблокировано: у AI-агента не указаны активные команды для перевода.",
"queued_off_hours_no_schedule": "В очереди: у команд сейчас нет активного расписания смен (нерабочие часы).",
"queued_no_eligible_operator": "В очереди: смена активна, но нет свободных операторов с доступной емкостью."
"queued_no_eligible_operator": "В очереди: смена активна, но нет свободных операторов с доступной емкостью.",
"ai_execution_not_simulated": "Поведение ИИ не моделируется; передача оператору не подтверждена.",
"partial_rollout_not_simulated": "Частичный запуск не моделируется; передача оператору не подтверждена.",
"blocked_agent_unpublished": "Маршрутизация к оператору не подтверждена для неопубликованного агента. Сначала опубликуйте его."
},
"hops": {
"channel": "Канал",
@@ -3491,7 +3501,10 @@
"blocked_agent_no_teams": "Агенту не назначены активные команды",
"queued_off_hours_no_schedule": "Нет активного расписания на текущее время (нерабочие часы)",
"queued_no_eligible_operator": "В активных командах нет свободных операторов с доступной емкостью",
"assigned_to_operator": "Назначено подходящему оператору"
"assigned_to_operator": "Назначено подходящему оператору",
"ai_execution_not_simulated": "Поведение ИИ не моделируется; передача оператору не подтверждена.",
"partial_rollout_not_simulated": "Частичный запуск не моделируется; передача оператору не подтверждена.",
"blocked_agent_unpublished": "Маршрутизация к оператору не подтверждена для неопубликованного агента. Сначала опубликуйте его."
},
"candidateTableTitle": "Оценка кандидатов-операторов",
"colOperator": "Оператор",
@@ -3520,6 +3533,53 @@
"headerIndicator": {
"readyLabel": "Приём включен",
"blockedLabel": "Приём заблокирован ({count})"
},
"preview": {
"review": "Просмотреть изменения",
"title": "Проверка изменений перед применением",
"description": "Изменятся только указанные записи и поля. Смены не добавляются. Остальные настройки и доли запуска сохранятся. После сохранения ожидающие диалоги могут быть распределены.",
"noChanges": "Безопасных автоматических изменений нет. Выберите группу, маршрут или рабочие часы в соответствующих настройках.",
"apply": "Применить проверенные изменения",
"create": "Создать",
"update": "Изменить",
"notApplied": "Проверенные изменения не были применены.",
"manageSchedules": "Настроить рабочие часы"
},
"repairFields": {
"name": "Название",
"leader_user_id": "ID руководителя группы",
"status": "Статус (0 включён, 1 отключён)",
"user_id": "ID пользователя",
"team_id": "ID группы / название новой группы",
"team_ids": "Группы маршрутизации",
"service_status": "Доступность (0 свободен, 1 занят)",
"auto_assign_enabled": "Автоматическое назначение",
"max_concurrent_count": "Лимит одновременных диалогов",
"priority_level": "Приоритет назначения",
"receive_offline_message": "Принимать сообщения офлайн",
"service_mode": "Режим (1 только ИИ, 2 только оператор, 3 сначала ИИ)",
"rollout_percent": "Доля запуска агента (%)",
"handoff_mode": "Режим передачи (2 очередь группы)",
"fallback_mode": "Резервный режим",
"auto_handoff_on_failure": "Передавать оператору при сбое",
"reply_timeout_seconds": "Тайм-аут ответа (сек.)",
"max_steps": "Максимум шагов ИИ",
"channel_id": "Идентификатор канала",
"channel_type": "Тип канала",
"ai_agent_id": "ID агента / название нового агента",
"ai_agent_rollout_percent": "Доля запуска канала (%)",
"config_json": "Настройки канала"
},
"repairWarnings": {
"schedules_unchanged": "Рабочие часы не изменены. Настройте смены явно: время вне смены не является ошибкой настройки.",
"active_team_required": "Выберите или включите группу в настройках перед связыванием новых записей.",
"disabled_configuration_preserved": "Выбранная конфигурация отключена или имеет нулевую долю запуска и оставлена без изменений.",
"agent_team_selection_required": "Выберите нужные группы в настройках агента. Группы не добавлялись автоматически.",
"channel_selection_required": "Выберите агента и долю запуска в настройках канала. Существующая маршрутизация сохранена.",
"existing_configuration_preserved": "Существующие настройки сохранены. Проверьте оставшиеся проблемы готовности в соответствующих настройках.",
"dispatch_failed": "Изменения сохранены, но распределение ожидающих диалогов не удалось. Проверьте очередь, не применяйте исправление повторно.",
"readiness_refresh_failed": "Не удалось обновить диагностику. Загрузите её снова: сохранённые изменения не отменены.",
"agent_publication_required": "Опубликуйте нужного агента в его настройках перед созданием канала. Мастер не публикует агентов и не обходит это требование."
}
}
}

View File

@@ -0,0 +1,2 @@
SPDX-License-Identifier: Apache-2.0
SPDX-FileComment: Modified in the independent hardening fork. See docs/hardening/README.md and MODIFIED_FILES.md for source attribution and change notes.

View File

@@ -583,7 +583,12 @@
"startFirstRequest": "开始第一个请求",
"openRequestCount": "进行中 {open} / 共 {total}",
"loadConversationsFailed": "加载请求列表失败",
"createConversationFailed": "创建请求失败"
"createConversationFailed": "创建请求失败",
"dismissedTitle": "聊天窗口已关闭",
"dismissedDescription": "您的支持请求尚未结束,您可以返回继续对话。",
"returnToChat": "返回聊天",
"sendUnavailable": "当前对话不可用,请返回对话或重试后再发送。",
"sendInProgress": "正在发送消息,请稍候。"
},
"dashboardHome": {
"rangeToday": "今天",
@@ -3370,8 +3375,8 @@
"statusReady": "已就绪,可正常接待请求",
"statusNeedsSetup": "配置未就绪(已完成 {ready}/{total} 步)",
"stepsProgress": "已就绪 {ready} / {total} 步",
"quickSetupAll": "一键完成全部基础配置",
"quickSetupRunning": "正在应用配置...",
"quickSetupAll": "预览缺失的默认配置",
"quickSetupRunning": "正在准备预览…",
"quickFixSuccess": "配置已自动修复并重新检测",
"refresh": "重新检测",
"openSettings": "前往设置",
@@ -3433,7 +3438,9 @@
"channel_no_agent": "接入渠道未关联 AI 智能体。",
"channel_agent_disabled": "接入渠道关联的 AI 智能体已被禁用或不存在。",
"channel_zero_rollout": "接入渠道的 AI 智能体分流比例为 0%。",
"no_end_to_end_path": "当前没有任何渠道能成功连通到空闲可用的人工坐席。"
"no_end_to_end_path": "当前没有任何渠道能成功连通到空闲可用的人工坐席。",
"agent_unpublished": "Agent 尚无已发布版本,请先在 Agent 设置中发布后再用于渠道。",
"channel_agent_unpublished": "渠道指向未发布的 Agent。请发布该 Agent,或明确选择已发布的 Agent。"
},
"operatorBlockers": {
"not_authenticated": "您尚未登录系统。",
@@ -3446,22 +3453,22 @@
"at_capacity": "您的当前接待会话量已达上限。"
},
"quickFixLabels": {
"quick_setup_all": "一键完成全部基础配置",
"ensure_team_and_schedule": "创建/启用客服组及 24/7 排班",
"quick_setup_all": "预览缺失的默认配置",
"ensure_team_and_schedule": "预览客服组设置",
"enable_my_operator_profile": "启用我的坐席并设为空闲",
"fix_ai_agent_handoff": "修复 AI 转人工模式并绑定客服组",
"ensure_default_channel": "创建/修复默认 Web 渠道"
},
"simulator": {
"title": "人工坐席路由模拟器",
"description": "模拟客户通过指定渠道进线后的完整路由流转过程,逐级查看每个节点的调度决策。",
"description": "只读检查当前仅人工路由的接待条件。不模拟 AI 行为或部分灰度,不会发送消息。",
"selectChannelPlaceholder": "选择要模拟的渠道...",
"runSimulation": "开始模拟分发",
"running": "正在模拟...",
"outcomeSuccess": "请求将成功分配给坐席:{operator}",
"outcomeBlocked": "请求无法到达人工坐席",
"outcomeSuccess": "当前符合条件的客服:{operator}",
"outcomeBlocked": "未确认可送达人工客服",
"outcomes": {
"assigned_to_operator": "成功:会话将分配给处于空闲状态的就绪坐席。",
"assigned_to_operator": "仅人工路由当前有符合条件的客服。这是只读检查,不保证实际送达。",
"blocked_no_channel": "受阻:系统中没有可用渠道或指定的渠道不存在。",
"blocked_channel_disabled": "受阻:该接入渠道已被禁用。",
"blocked_zero_rollout": "受阻:该渠道的 AI 智能体分流比例为 0%。",
@@ -3469,7 +3476,10 @@
"blocked_ai_only_mode": "受阻:AI 智能体为纯 AI 模式,禁止转接人工。",
"blocked_agent_no_teams": "受阻:AI 智能体未配置有效的转接客服组。",
"queued_off_hours_no_schedule": "排队中:目标客服组当前无生效排班(非工作时间)。",
"queued_no_eligible_operator": "排队中:当前处于工作排班内,但暂无可接待空闲坐席。"
"queued_no_eligible_operator": "排队中:当前处于工作排班内,但暂无可接待空闲坐席。",
"ai_execution_not_simulated": "未模拟 AI 行为,无法确认是否到达人工客服。",
"partial_rollout_not_simulated": "未模拟部分灰度,无法确认是否到达人工客服。",
"blocked_agent_unpublished": "未确认未发布 Agent 的人工路由,请先发布 Agent。"
},
"hops": {
"channel": "接入渠道",
@@ -3491,7 +3501,10 @@
"blocked_agent_no_teams": "AI 智能体未关联有效客服组",
"queued_off_hours_no_schedule": "当前时刻无生效中的工作排班(非工作时段)",
"queued_no_eligible_operator": "当前客服组内暂无空闲且容量充足的坐席",
"assigned_to_operator": "成功分配给符合条件的坐席"
"assigned_to_operator": "成功分配给符合条件的坐席",
"ai_execution_not_simulated": "未模拟 AI 行为,无法确认是否到达人工客服。",
"partial_rollout_not_simulated": "未模拟部分灰度,无法确认是否到达人工客服。",
"blocked_agent_unpublished": "未确认未发布 Agent 的人工路由,请先发布 Agent。"
},
"candidateTableTitle": "参评候选坐席明细",
"colOperator": "坐席",
@@ -3520,6 +3533,53 @@
"headerIndicator": {
"readyLabel": "接单就绪",
"blockedLabel": "接单受阻 ({count})"
},
"preview": {
"review": "预览设置更改",
"title": "应用前确认具体更改",
"description": "仅更改下列具名记录和字段,不会添加排班。未列出的配置及灰度比例保持不变。更改提交后可能分配等待中的会话。",
"noChanges": "没有可安全自动应用的更改。请在对应设置中选择客服组、路由或工作时段。",
"apply": "应用已确认的更改",
"create": "创建",
"update": "更新",
"notApplied": "未应用已确认的更改。",
"manageSchedules": "管理明确的工作时段"
},
"repairFields": {
"name": "名称",
"leader_user_id": "组长 ID",
"status": "启用状态(0 启用,1 禁用)",
"user_id": "用户 ID",
"team_id": "客服组 ID / 新组名称",
"team_ids": "路由客服组",
"service_status": "接待状态(0 空闲,1 忙碌)",
"auto_assign_enabled": "自动分配",
"max_concurrent_count": "并发会话上限",
"priority_level": "分配优先级",
"receive_offline_message": "接收离线消息",
"service_mode": "服务模式(1 仅 AI,2 仅人工,3 AI 优先)",
"rollout_percent": "Agent 灰度比例(%)",
"handoff_mode": "转人工模式(2 客服组队列)",
"fallback_mode": "回退模式",
"auto_handoff_on_failure": "失败时自动转人工",
"reply_timeout_seconds": "回复超时(秒)",
"max_steps": "AI 最大步骤数",
"channel_id": "渠道标识",
"channel_type": "渠道类型",
"ai_agent_id": "Agent ID / 新 Agent 名称",
"ai_agent_rollout_percent": "渠道灰度比例(%)",
"config_json": "渠道配置"
},
"repairWarnings": {
"schedules_unchanged": "未更改工作时段。请明确配置排班;非值班时段不是配置错误。",
"active_team_required": "请先在客服组设置中选择或启用客服组,再关联新记录。",
"disabled_configuration_preserved": "所选配置已禁用或灰度比例为零,已保持不变。",
"agent_team_selection_required": "请在 Agent 设置中选择目标客服组;未自动添加任何组。",
"channel_selection_required": "请在渠道设置中选择目标 Agent 和灰度比例。已保留现有渠道路由。",
"existing_configuration_preserved": "已保留现有配置。请在对应设置中检查剩余的就绪问题。",
"dispatch_failed": "更改已保存,但等待会话分配失败。请检查队列,不要重复应用修复。",
"readiness_refresh_failed": "就绪状态刷新失败。请重新加载诊断以查看当前状态;已提交的更改未回滚。",
"agent_publication_required": "创建渠道前,请先在 Agent 设置中发布目标 Agent。设置向导不会发布 Agent 或绕过发布要求。"
}
}
}

View File

@@ -0,0 +1,2 @@
SPDX-License-Identifier: Apache-2.0
SPDX-FileComment: Modified in the independent hardening fork. See docs/hardening/README.md and MODIFIED_FILES.md for source attribution and change notes.