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