v0.3.8-pre.00-fix.001
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-store-desk/frontend/sass/_app.scss
|
||||
// version: 1
|
||||
// version: 2
|
||||
|
||||
$app-header-height: 72px;
|
||||
$app-footer-height: 42px;
|
||||
@@ -90,3 +90,28 @@ body {
|
||||
.app-store-table-container {
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
|
||||
.app-copyable-long-text {
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
gap: .375rem;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-copyable-long-text__value {
|
||||
cursor: help;
|
||||
display: inline-block;
|
||||
max-width: 24ch;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-copyable-long-text__copy {
|
||||
flex: 0 0 auto;
|
||||
line-height: 1;
|
||||
padding: .25rem .375rem;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-store-desk/frontend/ts/main.ts
|
||||
// version: 4
|
||||
// version: 5
|
||||
|
||||
import DataTable from "datatables.net-bs5";
|
||||
import "datatables.net-bs5/css/dataTables.bootstrap5.css";
|
||||
@@ -50,6 +50,102 @@ const viewTitles: Record<ViewId, string> = {
|
||||
let transactionTable: StoreDataTable | null = null;
|
||||
let accountTable: StoreDataTable | null = null;
|
||||
|
||||
const COPY_FEEDBACK_MILLISECONDS = 1200;
|
||||
const LONG_TEXT_HEAD_CHARACTERS = 12;
|
||||
const LONG_TEXT_TAIL_CHARACTERS = 8;
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
}
|
||||
|
||||
function truncateLongText(value: string): string {
|
||||
const threshold = LONG_TEXT_HEAD_CHARACTERS + LONG_TEXT_TAIL_CHARACTERS + 1;
|
||||
if (value.length <= threshold) {
|
||||
return value;
|
||||
}
|
||||
return `${value.slice(0, LONG_TEXT_HEAD_CHARACTERS)}…${value.slice(-LONG_TEXT_TAIL_CHARACTERS)}`;
|
||||
}
|
||||
|
||||
function renderCopyableLongText(value: string, fieldId: string): string {
|
||||
const escapedValue = escapeHtml(value);
|
||||
const escapedVisible = escapeHtml(truncateLongText(value));
|
||||
const escapedFieldId = escapeHtml(fieldId);
|
||||
return `<span class="app-copyable-long-text"><span class="app-copyable-long-text__value font-monospace" title="${escapedValue}">${escapedVisible}</span><button class="btn btn-sm btn-outline-secondary app-copyable-long-text__copy" type="button" data-copy-long-text="${escapedValue}" data-copy-field="${escapedFieldId}" title="Copier la valeur complète" aria-label="Copier la valeur complète"><i class="fa-regular fa-copy" aria-hidden="true"></i></button></span>`;
|
||||
}
|
||||
|
||||
function setCopyableLongText(elementId: string, value: string, fieldId: string): void {
|
||||
const element = document.querySelector<HTMLElement>(`#${elementId}`);
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
element.replaceChildren();
|
||||
const wrapper = document.createElement("span");
|
||||
wrapper.className = "app-copyable-long-text";
|
||||
const text = document.createElement("span");
|
||||
text.className = "app-copyable-long-text__value font-monospace";
|
||||
text.textContent = truncateLongText(value);
|
||||
text.title = value;
|
||||
const button = document.createElement("button");
|
||||
button.className = "btn btn-sm btn-outline-secondary app-copyable-long-text__copy";
|
||||
button.type = "button";
|
||||
button.dataset.copyLongText = value;
|
||||
button.dataset.copyField = fieldId;
|
||||
button.title = "Copier la valeur complète";
|
||||
button.setAttribute("aria-label", "Copier la valeur complète");
|
||||
button.innerHTML = '<i class="fa-regular fa-copy" aria-hidden="true"></i>';
|
||||
wrapper.append(text, button);
|
||||
element.append(wrapper);
|
||||
}
|
||||
|
||||
async function writeClipboardText(value: string): Promise<boolean> {
|
||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
return true;
|
||||
} catch {
|
||||
// Fall back to the document copy command for WebViews without Clipboard API permission.
|
||||
}
|
||||
}
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = value;
|
||||
textarea.setAttribute("readonly", "");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.left = "-10000px";
|
||||
textarea.style.top = "-10000px";
|
||||
document.body.append(textarea);
|
||||
textarea.select();
|
||||
const copied = document.execCommand("copy");
|
||||
textarea.remove();
|
||||
return copied;
|
||||
}
|
||||
|
||||
async function copyLongText(button: HTMLButtonElement, value: string, fieldId: string): Promise<void> {
|
||||
frontendDebug("main", "Store Desk long text copy requested", { fieldId });
|
||||
const copied = await writeClipboardText(value);
|
||||
if (!copied) {
|
||||
frontendError("main", "Store Desk long text copy failed", { fieldId });
|
||||
return;
|
||||
}
|
||||
const icon = button.querySelector<HTMLElement>("i");
|
||||
const previousClassName = icon?.className ?? "";
|
||||
if (icon) {
|
||||
icon.className = "fa-solid fa-check";
|
||||
}
|
||||
button.title = "Copié";
|
||||
frontendDebug("main", "Store Desk long text copy completed", { fieldId });
|
||||
window.setTimeout(() => {
|
||||
if (icon) {
|
||||
icon.className = previousClassName;
|
||||
}
|
||||
button.title = "Copier la valeur complète";
|
||||
}, COPY_FEEDBACK_MILLISECONDS);
|
||||
}
|
||||
|
||||
function isViewId(value: string | undefined): value is ViewId {
|
||||
return value === "overview" || value === "transactions" || value === "accounts" || value === "diagnostics";
|
||||
}
|
||||
@@ -187,20 +283,20 @@ function initializeTransactionTable(): void {
|
||||
},
|
||||
autoWidth: false,
|
||||
columns: [
|
||||
{ className: "font-monospace", data: "signature" },
|
||||
{ data: "signature", render: (data, type) => (type === "display" ? renderCopyableLongText(String(data), "transaction-signature") : String(data)) },
|
||||
{ className: "font-monospace", data: "slotDecimal" },
|
||||
{ data: "blockTimeUnixMillisDecimal", defaultContent: "—" },
|
||||
{ data: "formatId" },
|
||||
{ data: "formatVersion" },
|
||||
{ data: "payloadSizeDecimal", defaultContent: "—" },
|
||||
{ className: "font-monospace", data: "contentHash" },
|
||||
{ data: "contentHash", render: (data, type) => (type === "display" ? renderCopyableLongText(String(data), "transaction-content-hash") : String(data)) },
|
||||
{ data: "retentionState", render: data => retentionBadge(String(data)) },
|
||||
{
|
||||
data: null,
|
||||
defaultContent: "",
|
||||
render: (_data, _type, row) => {
|
||||
const transaction = row as StoreTransactionRowDto;
|
||||
return `<button class="btn btn-sm btn-outline-primary" type="button" data-transaction-detail="${transaction.signature}"><i class="fa-solid fa-magnifying-glass me-1" aria-hidden="true"></i>Détail</button>`;
|
||||
return `<button class="btn btn-sm btn-outline-primary" type="button" data-transaction-detail="${escapeHtml(transaction.signature)}"><i class="fa-solid fa-magnifying-glass me-1" aria-hidden="true"></i>Détail</button>`;
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -335,11 +431,11 @@ function clearTransactionDetail(): void {
|
||||
}
|
||||
|
||||
function renderTransactionDetail(detail: StoreTransactionDetailDto): void {
|
||||
setText("transactionDetailSignature", detail.signature);
|
||||
setCopyableLongText("transactionDetailSignature", detail.signature, "transaction-detail-signature");
|
||||
setText("transactionDetailSlot", detail.slotDecimal);
|
||||
setText("transactionDetailBlockTime", detail.blockTimeUnixMillisDecimal ?? "—");
|
||||
setText("transactionDetailFormat", `${detail.formatId} v${detail.formatVersion}`);
|
||||
setText("transactionDetailContentHash", detail.contentHash);
|
||||
setCopyableLongText("transactionDetailContentHash", detail.contentHash, "transaction-detail-content-hash");
|
||||
setText("transactionDetailRetention", detail.retentionState);
|
||||
setText("transactionDetailPayloadSize", detail.payloadSizeDecimal ?? "—");
|
||||
setText("transactionDetailPayloadPreview", detail.payloadPreviewHex ?? "Payload non disponible dans cet état de rétention.");
|
||||
@@ -352,7 +448,7 @@ async function openTransactionDetail(signature: string): Promise<void> {
|
||||
return;
|
||||
}
|
||||
clearTransactionDetail();
|
||||
setText("transactionDetailSignature", signature);
|
||||
setCopyableLongText("transactionDetailSignature", signature, "transaction-detail-signature");
|
||||
frontendDebug("main", "Store Desk transaction detail load requested");
|
||||
const modal = Modal.getOrCreateInstance(modalElement);
|
||||
modal.show();
|
||||
@@ -410,6 +506,15 @@ function installInteractions(): void {
|
||||
if (!(eventTarget instanceof Element)) {
|
||||
return;
|
||||
}
|
||||
const copyButton = eventTarget.closest<HTMLButtonElement>("[data-copy-long-text]");
|
||||
if (copyButton) {
|
||||
const value = copyButton.dataset.copyLongText;
|
||||
const fieldId = copyButton.dataset.copyField ?? "long-text";
|
||||
if (value) {
|
||||
void copyLongText(copyButton, value, fieldId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const detailButton = eventTarget.closest<HTMLElement>("[data-transaction-detail]");
|
||||
if (detailButton) {
|
||||
const signature = detailButton.dataset.transactionDetail;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-store-desk/tests/desktop_contract.rs
|
||||
// version: 4
|
||||
// version: 5
|
||||
|
||||
//! Desktop template and frontend contract tests for the Store Desk scaffold.
|
||||
|
||||
@@ -88,8 +88,7 @@ fn pre_007_shell_exposes_transaction_filters_detail_and_keeps_account_skeleton_u
|
||||
}
|
||||
assert!(html.contains("id=\"rawTransactionsTable\""));
|
||||
assert!(html.contains("id=\"rawAccountsTable\""));
|
||||
for overview_id in ["refreshOverview", "overviewStoreProfile", "overviewStoreTarget", "overviewStoreHealth", "overviewStorePool", "overviewStoreMigration"]
|
||||
{
|
||||
for overview_id in ["refreshOverview", "overviewStoreProfile", "overviewStoreTarget", "overviewStoreHealth", "overviewStorePool", "overviewStoreMigration"] {
|
||||
assert!(html.contains(&format!("id=\"{overview_id}\"")), "missing Store Overview field: {overview_id}");
|
||||
}
|
||||
for transaction_id in ["refreshTransactions", "transactionSlotMin", "transactionSlotMax", "transactionDirection", "transactionDetailModal"] {
|
||||
@@ -197,3 +196,20 @@ fn pre_007_transaction_ipc_maps_datatables_offset_limit_counts_and_detail_withou
|
||||
assert!(!dto.contains("RawPageCursor"));
|
||||
assert!(!frontend.contains("storePager"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_007_fix_001_long_transaction_identifiers_are_truncated_tooltipped_and_copyable() {
|
||||
let frontend = include_str!("../frontend/ts/main.ts");
|
||||
let sass = include_str!("../frontend/sass/_app.scss");
|
||||
assert!(frontend.contains("truncateLongText"));
|
||||
assert!(frontend.contains("renderCopyableLongText(String(data), "transaction-signature")"));
|
||||
assert!(frontend.contains("renderCopyableLongText(String(data), "transaction-content-hash")"));
|
||||
assert!(frontend.contains("setCopyableLongText("transactionDetailSignature""));
|
||||
assert!(frontend.contains("setCopyableLongText("transactionDetailContentHash""));
|
||||
assert!(frontend.contains("title="${escapedValue}""));
|
||||
assert!(frontend.contains("data-copy-long-text"));
|
||||
assert!(frontend.contains("navigator.clipboard.writeText"));
|
||||
assert!(frontend.contains("document.execCommand("copy")"));
|
||||
assert!(sass.contains(".app-copyable-long-text__value"));
|
||||
assert!(sass.contains("text-overflow: ellipsis"));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-store-desk/tests/desktop_security.rs
|
||||
// version: 5
|
||||
// version: 6
|
||||
|
||||
//! Security boundary tests for the Store Desk scaffold.
|
||||
|
||||
@@ -128,3 +128,22 @@ fn pre_007_transaction_request_rejects_datatables_search_order_draw_and_physical
|
||||
assert!(runtime.contains("\"ascending\" =>"));
|
||||
assert!(runtime.contains("\"descending\" =>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_007_fix_001_copy_tracing_never_logs_long_identifier_values() {
|
||||
let frontend = include_str!("../frontend/ts/main.ts");
|
||||
assert!(frontend.contains("Store Desk long text copy requested"));
|
||||
assert!(frontend.contains("Store Desk long text copy completed"));
|
||||
assert!(frontend.contains("Store Desk long text copy failed"));
|
||||
assert!(frontend.contains("{ fieldId }"));
|
||||
for forbidden in [
|
||||
"copy requested", { value",
|
||||
"copy completed", { value",
|
||||
"copy failed", { value",
|
||||
"copy requested", { signature",
|
||||
"copy completed", { signature",
|
||||
"copy failed", { signature",
|
||||
] {
|
||||
assert!(!frontend.contains(forbidden), "long identifier value leaked into copy tracing: {forbidden}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user