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:
@@ -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"
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user