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,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