From 58e5fd2b1d373c2fd417f79de6599b39f4ec3042 Mon Sep 17 00:00:00 2001 From: BOHA Date: Mon, 8 Jun 2026 16:02:26 +0200 Subject: [PATCH] feat(ai): server-side chat history + assistant UX fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server-side, per-user chat history (the thread now survives reload): - new ai_chat_messages table + migration; getChatHistory/appendChatMessages/ clearChatHistory; GET/POST/DELETE /api/admin/ai/history (ai.use-guarded); service + HTTP tests (isolation, ordering, clear, perm, validation). - DashAssistant loads the thread on mount and persists each turn; "Vymazat" clears it. gcTime:0 so each mount re-reads the DB (no stale-cache resurrection); composer gated until history settles + seed flag flipped on submit (no seed/submit race clobbering a freshly-sent turn). UX + correctness fixes from review/feedback: - review cards moved OUT of the scrollable thread into a "Faktury k potvrzení" section so Uložit is always reachable; chat auto-scroll no longer fires on field edits. - chat bubble text colour set on the Typography (GlobalStyles pins `p` to text.secondary, which was overriding the inherited bubble colour → unreadable). - never clear input/staged PDFs on a failed submit (rollback + keep), so a budget error can't wipe staged invoices; roll back the optimistic user turn. - stable attachment ids (no crypto.randomUUID — undefined over plain HTTP). - editable Datum splatnosti + Popis fields; client-side extraction summary. Security: DashProfile clipboard copy now uses an execCommand fallback (works over plain HTTP) and only shows the success toast when the copy actually succeeded — previously it falsely claimed to copy 2FA codes / the TOTP secret. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../migration.sql | 11 + prisma/schema.prisma | 10 + src/__tests__/ai.test.ts | 132 ++++ .../components/dashboard/DashAssistant.tsx | 625 +++++++++++++----- .../components/dashboard/DashProfile.tsx | 44 +- src/admin/lib/queries/ai.ts | 19 + src/routes/admin/ai.ts | 41 +- src/schemas/ai.schema.ts | 12 + src/services/ai.service.ts | 44 ++ 9 files changed, 771 insertions(+), 167 deletions(-) create mode 100644 prisma/migrations/20260608130000_add_ai_chat_history/migration.sql diff --git a/prisma/migrations/20260608130000_add_ai_chat_history/migration.sql b/prisma/migrations/20260608130000_add_ai_chat_history/migration.sql new file mode 100644 index 0000000..6cafc14 --- /dev/null +++ b/prisma/migrations/20260608130000_add_ai_chat_history/migration.sql @@ -0,0 +1,11 @@ +-- AI assistant (Phase 1): per-user chat history (server-side, permanent). + +CREATE TABLE `ai_chat_messages` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `user_id` INTEGER NOT NULL, + `role` VARCHAR(20) NOT NULL, + `content` TEXT NOT NULL, + `created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0), + PRIMARY KEY (`id`), + INDEX `idx_ai_chat_user` (`user_id`, `id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1240440..38f10f7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -89,6 +89,16 @@ model ai_usage { @@index([created_at], map: "idx_ai_usage_created") } +model ai_chat_messages { + id Int @id @default(autoincrement()) + user_id Int + role String @db.VarChar(20) + content String @db.Text + created_at DateTime @default(now()) @db.Timestamp(0) + + @@index([user_id, id], map: "idx_ai_chat_user") +} + model bank_accounts { id Int @id @default(autoincrement()) account_name String? @db.VarChar(255) diff --git a/src/__tests__/ai.test.ts b/src/__tests__/ai.test.ts index da0a7a0..048a10b 100644 --- a/src/__tests__/ai.test.ts +++ b/src/__tests__/ai.test.ts @@ -14,15 +14,28 @@ import { getBudgetUsd, assertBudgetAvailable, isConfigured, + getChatHistory, + appendChatMessages, + clearChatHistory, } from "../services/ai.service"; const KIND = "test_ai"; // marker so we only clean our own rows +// Synthetic, FK-free user ids for chat-history service tests (no users row needed). +const HIST_UID_A = 999999991; +const HIST_UID_B = 999999992; +const HIST_MARKER = "「test-hist」"; // marker for HTTP-test rows on real users beforeEach(async () => { await prisma.ai_usage.deleteMany({ where: { kind: KIND } }); }); afterAll(async () => { await prisma.ai_usage.deleteMany({ where: { kind: KIND } }); + await prisma.ai_chat_messages.deleteMany({ + where: { user_id: { in: [HIST_UID_A, HIST_UID_B] } }, + }); + await prisma.ai_chat_messages.deleteMany({ + where: { content: { contains: HIST_MARKER } }, + }); }); describe("ai.service cost + budget", () => { @@ -99,6 +112,46 @@ describe("ai.service cost + budget", () => { }); }); +describe("ai.service chat history", () => { + beforeEach(async () => { + await prisma.ai_chat_messages.deleteMany({ + where: { user_id: { in: [HIST_UID_A, HIST_UID_B] } }, + }); + }); + + it("appends and returns a user's thread oldest → newest", async () => { + await appendChatMessages(HIST_UID_A, [ + { role: "user", content: "ahoj" }, + { role: "assistant", content: "Dobrý den" }, + ]); + await appendChatMessages(HIST_UID_A, [ + { role: "user", content: "jak se máš" }, + ]); + const hist = await getChatHistory(HIST_UID_A); + expect(hist.map((m) => m.content)).toEqual([ + "ahoj", + "Dobrý den", + "jak se máš", + ]); + expect(hist[0].role).toBe("user"); + }); + + it("isolates history per user", async () => { + await appendChatMessages(HIST_UID_A, [{ role: "user", content: "A1" }]); + await appendChatMessages(HIST_UID_B, [{ role: "user", content: "B1" }]); + const a = await getChatHistory(HIST_UID_A); + const b = await getChatHistory(HIST_UID_B); + expect(a.map((m) => m.content)).toEqual(["A1"]); + expect(b.map((m) => m.content)).toEqual(["B1"]); + }); + + it("clears a user's thread", async () => { + await appendChatMessages(HIST_UID_A, [{ role: "user", content: "x" }]); + await clearChatHistory(HIST_UID_A); + expect(await getChatHistory(HIST_UID_A)).toEqual([]); + }); +}); + describe("HTTP /api/admin/ai", () => { let app: Awaited>; let adminToken: string; @@ -212,4 +265,83 @@ describe("HTTP /api/admin/ai", () => { expect(res.statusCode).toBe(503); (appConfig.anthropic as { apiKey: string }).apiKey = savedKey; }); + + it("GET /history requires ai.use", async () => { + const res = await app.inject({ + method: "GET", + url: "/api/admin/ai/history", + headers: { Authorization: `Bearer ${noPermToken}` }, + }); + expect(res.statusCode).toBe(403); + }); + + it("POST /history appends and GET /history returns the thread", async () => { + const post = await app.inject({ + method: "POST", + url: "/api/admin/ai/history", + headers: { + Authorization: `Bearer ${adminToken}`, + "Content-Type": "application/json", + }, + payload: { + messages: [ + { role: "user", content: `${HIST_MARKER} dotaz` }, + { role: "assistant", content: `${HIST_MARKER} odpoved` }, + ], + }, + }); + expect(post.statusCode).toBe(200); + + const get = await app.inject({ + method: "GET", + url: "/api/admin/ai/history", + headers: { Authorization: `Bearer ${adminToken}` }, + }); + expect(get.statusCode).toBe(200); + const contents = get + .json() + .data.messages.map((m: { content: string }) => m.content); + expect(contents).toContain(`${HIST_MARKER} dotaz`); + expect(contents).toContain(`${HIST_MARKER} odpoved`); + }); + + it("POST /history rejects an empty messages array", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/admin/ai/history", + headers: { + Authorization: `Bearer ${adminToken}`, + "Content-Type": "application/json", + }, + payload: { messages: [] }, + }); + expect(res.statusCode).toBe(400); + }); + + it("DELETE /history clears the thread", async () => { + await app.inject({ + method: "POST", + url: "/api/admin/ai/history", + headers: { + Authorization: `Bearer ${adminToken}`, + "Content-Type": "application/json", + }, + payload: { messages: [{ role: "user", content: `${HIST_MARKER} x` }] }, + }); + const del = await app.inject({ + method: "DELETE", + url: "/api/admin/ai/history", + headers: { Authorization: `Bearer ${adminToken}` }, + }); + expect(del.statusCode).toBe(200); + const get = await app.inject({ + method: "GET", + url: "/api/admin/ai/history", + headers: { Authorization: `Bearer ${adminToken}` }, + }); + const contents = get + .json() + .data.messages.map((m: { content: string }) => m.content); + expect(contents).not.toContain(`${HIST_MARKER} x`); + }); }); diff --git a/src/admin/components/dashboard/DashAssistant.tsx b/src/admin/components/dashboard/DashAssistant.tsx index db6d6ee..e9cd1bf 100644 --- a/src/admin/components/dashboard/DashAssistant.tsx +++ b/src/admin/components/dashboard/DashAssistant.tsx @@ -1,11 +1,17 @@ -import { useState, useRef } from "react"; +import { useState, useRef, useEffect } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; +import Chip from "@mui/material/Chip"; +import CircularProgress from "@mui/material/CircularProgress"; import { Card, Button, TextField } from "../../ui"; import apiFetch from "../../utils/api"; import { useAlert } from "../../context/AlertContext"; -import { aiUsageOptions, type ExtractedInvoice } from "../../lib/queries/ai"; +import { + aiUsageOptions, + aiHistoryOptions, + type ExtractedInvoice, +} from "../../lib/queries/ai"; interface ChatTurn { role: "user" | "assistant"; @@ -37,104 +43,240 @@ type EditableField = | "due_date" | "description"; +// A staged file carries a stable id so chip keys survive mid-list removal. +interface StagedFile { + id: string; + file: File; +} + +// Stable, monotonic id. NOT crypto.randomUUID() — that only exists in a secure +// context (HTTPS/localhost) and throws over plain HTTP on a LAN. A counter is +// unique within the session and context-independent. +let uidSeq = 0; +const nextUid = () => `ai-${(uidSeq += 1)}`; + +// How many recent turns to send the model as context (bounds tokens/cost). +const MODEL_CONTEXT = 20; +// Server schema caps a stored message at 8000 chars. +const MAX_STORE = 8000; + +const plural = (n: number) => + n === 1 ? "fakturu" : n >= 2 && n <= 4 ? "faktury" : "faktur"; + +/** A human, Czech summary of an extraction so the assistant "reports back". */ +function summaryNote(reviews: ReviewInvoice[]): string { + if (reviews.length === 0) return "V přílohách jsem nenašel žádnou fakturu."; + const suppliers = [ + ...new Set(reviews.map((r) => r.supplier_name).filter(Boolean)), + ]; + const supTxt = + suppliers.length === 1 + ? ` od ${suppliers[0]}` + : suppliers.length > 1 + ? ` od ${suppliers[0]} a dalších` + : ""; + const totals: Record = {}; + for (const r of reviews) { + const cur = r.currency || "CZK"; + totals[cur] = (totals[cur] || 0) + (Number(r.amount) || 0); + } + const totalsTxt = Object.entries(totals) + .map( + ([cur, sum]) => + `${sum.toLocaleString("cs-CZ", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} ${cur}`, + ) + .join(" + "); + return `Načetl jsem ${reviews.length} ${plural(reviews.length)}${supTxt}, celkem ${totalsTxt}. Zkontrolujte údaje a uložte.`; +} + export default function DashAssistant() { const alert = useAlert(); const qc = useQueryClient(); const { data: usage } = useQuery(aiUsageOptions()); + const { data: historyData, isPending: historyPending } = + useQuery(aiHistoryOptions()); + const [turns, setTurns] = useState([]); const [input, setInput] = useState(""); const [busy, setBusy] = useState(false); const [review, setReview] = useState([]); + const [attachments, setAttachments] = useState([]); const fileRef = useRef(null); + const threadRef = useRef(null); + const seeded = useRef(false); + + // Seed the thread from server history exactly once (first time it arrives). + useEffect(() => { + if (!seeded.current && historyData) { + seeded.current = true; + setTurns( + historyData.messages.map((m) => ({ role: m.role, content: m.content })), + ); + } + }, [historyData]); + + // Keep the thread scrolled to the latest message. Deliberately NOT keyed on + // `review` — editing a review-card field must not yank the thread to bottom. + useEffect(() => { + const el = threadRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [turns, busy]); // AI is hidden entirely when the backend isn't configured. if (usage && usage.configured === false) return null; - const sendChat = async () => { + // Best-effort: persist the given turns to the server thread. A persistence + // blip must not fail the user's interaction, but must not be swallowed. + const persist = (msgs: ChatTurn[]) => { + apiFetch("/api/admin/ai/history", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + messages: msgs.map((m) => ({ + role: m.role, + content: m.content.slice(0, MAX_STORE), + })), + }), + }) + .then((r) => { + if (!r.ok) return r.json().then((b) => Promise.reject(b?.error)); + }) + .catch((e) => console.error("[ai] history persist failed", e)); + }; + + // Attaching only *stages* files — nothing is sent until the user presses + // Odeslat. We append (multiple picks accumulate) and clear the input element. + const onFiles = (files: FileList | null) => { + if (!files || files.length === 0) return; + setAttachments((a) => [ + ...a, + ...Array.from(files).map((file) => ({ id: nextUid(), file })), + ]); + if (fileRef.current) fileRef.current.value = ""; + }; + + const removeAttachment = (id: string) => + setAttachments((a) => a.filter((s) => s.id !== id)); + + // Disabled until history has settled, so the user can't send a turn before + // the seed runs (which would otherwise let a late history load clobber it). + const canSubmit = + !historyPending && !busy && (!!input.trim() || attachments.length > 0); + + // Single submit (Odeslat). With staged attachments → extract invoices for + // review (the typed message is shown as a caption; the assistant's summary is + // the reply to it). Otherwise → a normal chat turn. + const submit = async () => { + if (busy) return; const text = input.trim(); - if (!text || busy) return; - const next = [...turns, { role: "user" as const, content: text }]; - setTurns(next); - setInput(""); + const files = attachments.map((a) => a.file); + if (!text && files.length === 0) return; + + // Any interaction means the thread is "live"; a late-arriving history load + // must not overwrite it (belt-and-suspenders alongside the canSubmit gate). + seeded.current = true; + const prevTurns = turns; // snapshot for rollback on failure setBusy(true); + + const userContent = [ + text, + files.length > 0 ? `📎 ${files.map((f) => f.name).join(", ")}` : "", + ] + .filter(Boolean) + .join("\n"); + const optimistic: ChatTurn[] = [ + ...turns, + { role: "user", content: userContent }, + ]; + setTurns(optimistic); + try { - const res = await apiFetch("/api/admin/ai/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ messages: next }), - }); - const body = await res.json(); - if (!res.ok) throw new Error(body?.error || "Chyba AI"); - setTurns((t) => [...t, { role: "assistant", content: body.data.reply }]); - qc.invalidateQueries({ queryKey: ["ai", "usage"] }); + if (files.length > 0) { + const fd = new FormData(); + files.forEach((f) => fd.append("files", f)); + const res = await apiFetch("/api/admin/ai/extract-invoices", { + method: "POST", + body: fd, + }); + const body = await res.json(); + if (!res.ok) throw new Error(body?.error || "Chyba čtení faktur"); + // The server iterates parts serially and pushes one result per file in + // submission order (truncating only if budget runs out mid-batch), so + // files[i] is reliably this result's File — more robust than matching by + // name, which would collide on duplicate filenames. + const reviews: ReviewInvoice[] = ( + body.data.invoices as Array<{ + file_name: string; + fields?: ExtractedInvoice; + error?: string; + }> + ) + .map((r, i): ReviewInvoice | null => { + const f = r.fields; + return f + ? { + uid: nextUid(), + supplier_name: f.supplier_name ?? "", + invoice_number: f.invoice_number ?? "", + amount: f.amount != null ? String(f.amount) : "", + currency: f.currency ?? "", + vat_rate: f.vat_rate != null ? String(f.vat_rate) : "", + issue_date: f.issue_date ?? "", + due_date: f.due_date ?? "", + description: f.description ?? "", + file_name: r.file_name, + file: files[i], + } + : null; + }) + .filter((x): x is ReviewInvoice => x !== null); + setReview((prev) => [...prev, ...reviews]); + const note = summaryNote(reviews); + setTurns((t) => [...t, { role: "assistant", content: note }]); + setInput(""); + setAttachments([]); + persist([ + { role: "user", content: userContent }, + { role: "assistant", content: note }, + ]); + qc.invalidateQueries({ queryKey: ["ai", "usage"] }); + } else { + // Send only recent context, and ensure it starts on a user turn (the + // Anthropic API requires the first message to be from the user). + let ctx = optimistic.slice(-MODEL_CONTEXT); + while (ctx.length && ctx[0].role !== "user") ctx = ctx.slice(1); + const res = await apiFetch("/api/admin/ai/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ messages: ctx }), + }); + const body = await res.json(); + if (!res.ok) throw new Error(body?.error || "Chyba AI"); + const reply: string = body.data.reply; + setTurns((t) => [...t, { role: "assistant", content: reply }]); + setInput(""); + persist([ + { role: "user", content: userContent }, + { role: "assistant", content: reply }, + ]); + qc.invalidateQueries({ queryKey: ["ai", "usage"] }); + } } catch (e) { - alert.error(e instanceof Error ? e.message : "Chyba AI"); + // Roll back the optimistic user turn and KEEP input + attachments so the + // user can retry without losing their typed text or staged PDFs. + setTurns(prevTurns); + const fallback = files.length > 0 ? "Chyba čtení faktur" : "Chyba AI"; + alert.error(e instanceof Error ? e.message : fallback); } finally { setBusy(false); } }; - const onFiles = async (files: FileList | null) => { - if (!files || files.length === 0 || busy) return; - setBusy(true); - try { - const fd = new FormData(); - Array.from(files).forEach((f) => fd.append("files", f)); - const res = await apiFetch("/api/admin/ai/extract-invoices", { - method: "POST", - body: fd, - }); - const body = await res.json(); - if (!res.ok) throw new Error(body?.error || "Chyba čtení faktur"); - // The server iterates the multipart parts serially and pushes one result - // per file in submission order (truncating only if the budget runs out - // mid-batch), so arr[i] is reliably this result's original File — more - // robust than name-matching, which would collide on duplicate filenames. - const arr = Array.from(files); - const reviews: ReviewInvoice[] = ( - body.data.invoices as Array<{ - file_name: string; - fields?: ExtractedInvoice; - error?: string; - }> - ) - .map((r, i): ReviewInvoice | null => { - const f = r.fields; - return f - ? { - uid: crypto.randomUUID(), - supplier_name: f.supplier_name ?? "", - invoice_number: f.invoice_number ?? "", - amount: f.amount != null ? String(f.amount) : "", - currency: f.currency ?? "", - vat_rate: f.vat_rate != null ? String(f.vat_rate) : "", - issue_date: f.issue_date ?? "", - due_date: f.due_date ?? "", - description: f.description ?? "", - file_name: r.file_name, - file: arr[i], - } - : null; - }) - .filter((x): x is ReviewInvoice => x !== null); - setReview((prev) => [...prev, ...reviews]); - setTurns((t) => [ - ...t, - { - role: "assistant", - content: `Načetl jsem ${reviews.length} faktur k potvrzení.`, - }, - ]); - qc.invalidateQueries({ queryKey: ["ai", "usage"] }); - } catch (e) { - alert.error(e instanceof Error ? e.message : "Chyba čtení faktur"); - } finally { - setBusy(false); - if (fileRef.current) fileRef.current.value = ""; - } - }; - - const saveInvoice = async (inv: ReviewInvoice, idx: number) => { + const saveInvoice = async (inv: ReviewInvoice) => { + if (busy) return; setBusy(true); try { const fd = new FormData(); @@ -161,7 +303,7 @@ export default function DashAssistant() { const body = await res.json(); if (!res.ok) throw new Error(body?.error || "Uložení selhalo"); alert.success("Faktura uložena"); - setReview((r) => r.filter((_, i) => i !== idx)); + setReview((r) => r.filter((x) => x.uid !== inv.uid)); qc.invalidateQueries({ queryKey: ["received-invoices"] }); } catch (e) { alert.error(e instanceof Error ? e.message : "Uložení selhalo"); @@ -170,45 +312,116 @@ export default function DashAssistant() { } }; - const patch = (idx: number, field: EditableField, value: string) => + const clearHistory = async () => { + if (busy) return; + setBusy(true); + try { + const res = await apiFetch("/api/admin/ai/history", { method: "DELETE" }); + if (!res.ok) throw new Error("Nepodařilo se vymazat historii"); + setTurns([]); + setReview([]); + } catch (e) { + alert.error(e instanceof Error ? e.message : "Chyba"); + } finally { + setBusy(false); + } + }; + + const patch = (uid: string, field: EditableField, value: string) => setReview((r) => - r.map((inv, i) => (i === idx ? { ...inv, [field]: value } : inv)), + r.map((inv) => (inv.uid === uid ? { ...inv, [field]: value } : inv)), ); + const fieldCols = { xs: "1fr", sm: "1fr 1fr" }; + return ( + {/* Header */} - - AI asistent - - {usage && ( - - Utraceno: ${usage.month_spend_usd.toFixed(2)} / $ - {usage.budget_usd.toFixed(2)} + + + AI + + + Asistent - )} + + + {usage && ( + + Utraceno: ${usage.month_spend_usd.toFixed(2)} / $ + {usage.budget_usd.toFixed(2)} + + )} + {turns.length > 0 && ( + + )} + + {/* Thread */} + {turns.length === 0 && !busy && ( + + + {historyPending + ? "Načítám…" + : "Zeptejte se, nebo přiložte fakturu (PDF) k automatickému importu."} + + + )} + {turns.map((t, i) => ( - + {/* Color MUST sit on the Typography: GlobalStyles pins `p` to + text.secondary, which beats a color merely inherited from the + Box. An sx class on the element wins over that element rule. */} + {t.content} ))} - {review.map((inv, idx) => ( - - - {inv.file_name} - - - patch(idx, "supplier_name", e.target.value)} - /> - patch(idx, "invoice_number", e.target.value)} - /> - patch(idx, "amount", e.target.value)} - /> - patch(idx, "currency", e.target.value)} - /> - patch(idx, "vat_rate", e.target.value)} - /> - patch(idx, "issue_date", e.target.value)} - /> - - - - - - - ))} + + {busy && ( + + + Pracuji… + + )} + {/* Pending invoices to confirm — rendered OUTSIDE the scrollable thread so + the Uložit button is always reachable (the page scrolls if there are + many), and editing a field never yanks the chat scroll. */} + {review.length > 0 && ( + + + Faktury k potvrzení ({review.length}) + + + {review.map((inv) => ( + + + + + {inv.file_name} + + + + + patch(inv.uid, "supplier_name", e.target.value) + } + /> + + patch(inv.uid, "invoice_number", e.target.value) + } + /> + patch(inv.uid, "amount", e.target.value)} + /> + patch(inv.uid, "currency", e.target.value)} + /> + patch(inv.uid, "vat_rate", e.target.value)} + /> + + patch(inv.uid, "issue_date", e.target.value) + } + /> + patch(inv.uid, "due_date", e.target.value)} + /> + + patch(inv.uid, "description", e.target.value) + } + /> + + + + + + + ))} + + + )} + + {/* Staged attachments */} + {attachments.length > 0 && ( + + {attachments.map((s) => ( + removeAttachment(s.id)} + /> + ))} + + )} + + {/* Composer */} fileRef.current?.click()} disabled={busy} > - Přiložit fakturu + Přiložit { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); - sendChat(); + submit(); } }} /> - diff --git a/src/admin/components/dashboard/DashProfile.tsx b/src/admin/components/dashboard/DashProfile.tsx index e8df687..4bc2258 100644 --- a/src/admin/components/dashboard/DashProfile.tsx +++ b/src/admin/components/dashboard/DashProfile.tsx @@ -16,6 +16,35 @@ import useDialogScrollLock from "../../ui/useDialogScrollLock"; const API_BASE = "/api/admin"; +// Copy helper. navigator.clipboard is SECURE-CONTEXT ONLY (undefined over plain +// HTTP on a LAN), so we fall back to the legacy execCommand path — which works +// in insecure contexts — and report whether the copy actually succeeded. The +// caller MUST gate its success toast on the returned boolean (otherwise it lies +// to the user about copying 2FA codes / the TOTP secret). +async function copyToClipboard(text: string): Promise { + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return true; + } + } catch { + // fall through to the legacy path + } + try { + const ta = document.createElement("textarea"); + ta.value = text; + ta.style.position = "fixed"; + ta.style.opacity = "0"; + document.body.appendChild(ta); + ta.select(); + const ok = document.execCommand("copy"); + document.body.removeChild(ta); + return ok; + } catch { + return false; + } +} + interface DashProfileProps { totpEnabled: boolean; totpLoading: boolean; @@ -480,9 +509,10 @@ export default function DashProfile({