refactor: move clipboard to frontend, fix cipher type mapping, add context menu

This commit is contained in:
2026-08-26 15:28:35 +02:00
parent a3e9f9ab0e
commit deba9a419d
7 changed files with 140 additions and 118 deletions
+1
View File
@@ -59,6 +59,7 @@ export interface LoginResult {
export interface CopyResult {
success?: boolean;
value?: string;
clear_after?: number;
expires_at?: number;
remaining_seconds?: number;
+65 -12
View File
@@ -1,5 +1,11 @@
import { useState, useEffect } from "react";
import { ButtonItem } from "@decky/ui";
import { useState, useEffect, useRef } from "react";
import {
Focusable,
showContextMenu,
Menu,
MenuItem,
MenuGroup,
} from "@decky/ui";
import { VaultItem } from "../api/backend";
interface ItemCardProps {
@@ -35,6 +41,8 @@ export function ItemCard({
}: ItemCardProps) {
const [expanded, setExpanded] = useState(false);
const [copying, setCopying] = useState<string | null>(null);
const [focused, setFocused] = useState(false);
const cardRef = useRef<HTMLDivElement>(null);
const isCopied = (field: string) =>
copyStatus?.cipherId === item.id && copyStatus?.field === field;
@@ -72,23 +80,71 @@ export function ItemCard({
}
};
const showItemMenu = (e?: Event) => {
const hasUsername = !!item.login?.username;
const hasPassword = !!item.login?.password;
const hasTotp = !!item.login?.totp;
if (!hasUsername && !hasPassword && !hasTotp) return;
const parent = e ? (e.target as HTMLElement) : (cardRef.current ?? undefined);
showContextMenu(
<Menu label={item.name || "Actions"}>
<MenuGroup label="Copy">
{hasUsername && (
<MenuItem
onSelected={() => handleCopy("username")}
>
Copy Username
</MenuItem>
)}
{hasPassword && (
<MenuItem
onSelected={() => handleCopy("password")}
>
Copy Password
</MenuItem>
)}
{hasTotp && (
<MenuItem
onSelected={() => handleCopy("totp")}
>
Copy TOTP
</MenuItem>
)}
</MenuGroup>
</Menu>,
parent
);
};
const folderName = item.folderId ? folderMap[item.folderId] : null;
return (
<div
<Focusable
ref={cardRef}
onOKButton={showItemMenu}
onActivate={showItemMenu}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
flow-children="y"
onOKActionDescription="Copy Menu"
style={{
background: "#1e1e1e",
borderRadius: "8px",
padding: "12px",
marginBottom: "8px",
border: expanded ? "1px solid #1a9fff" : "1px solid #333",
border: focused
? "1px solid #1a9fff"
: expanded
? "1px solid #1a9fff"
: "1px solid #333",
}}
>
{/* Header */}
<div
onClick={() => setExpanded(!expanded)}
style={{
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "8px",
@@ -136,9 +192,6 @@ export function ItemCard({
{folderName}
</span>
)}
<span style={{ fontSize: "10px", color: "#666" }}>
{expanded ? "\u25B2" : "\u25BC"}
</span>
</div>
{/* Expanded content */}
@@ -212,7 +265,7 @@ export function ItemCard({
<div key={idx} style={{ fontSize: "11px", marginBottom: "2px" }}>
<span style={{ color: "#888" }}>{field.name}: </span>
<span style={{ color: "#ccc" }}>
{field.hidden ? "••••••••" : field.value || ""}
{field.hidden ? "\u2022\u2022\u2022\u2022\u2022\u2022\u2022" : field.value || ""}
</span>
</div>
))}
@@ -244,7 +297,7 @@ export function ItemCard({
<div style={{ marginTop: "12px", fontSize: "12px", color: "#ccc" }}>
{item.card.brand && <div>Brand: {item.card.brand}</div>}
{item.card.number && (
<div>Number: •••• •••• •••• {item.card.number.slice(-4)}</div>
<div>Number: \u2022\u2022\u2022\u2022 \u2022\u2022\u2022\u2022 \u2022\u2022\u2022\u2022 {item.card.number.slice(-4)}</div>
)}
{item.card.expMonth && item.card.expYear && (
<div>
@@ -256,7 +309,7 @@ export function ItemCard({
)}
</div>
)}
</div>
</Focusable>
);
}
+8 -5
View File
@@ -1,11 +1,11 @@
import { useState, useEffect } from "react";
import { useState } from "react";
import {
PanelSection,
TextField,
ButtonItem,
Spinner,
Focusable,
} from "@decky/ui";
import { VaultData, VaultItem, Folder } from "../api/backend";
import { VaultData, VaultItem } from "../api/backend";
import { ItemCard } from "./ItemCard";
interface VaultBrowserProps {
@@ -102,7 +102,10 @@ export function VaultBrowser({
)}
{/* Items */}
<div style={{ maxHeight: "400px", overflowY: "auto" }}>
<Focusable
flow-children="y"
style={{ maxHeight: "400px", overflowY: "auto" }}
>
{displayedItems.length === 0 ? (
<div
style={{
@@ -126,7 +129,7 @@ export function VaultBrowser({
/>
))
)}
</div>
</Focusable>
{/* Actions */}
<ButtonItem layout="below" onClick={onLock}>
+50 -9
View File
@@ -188,16 +188,45 @@ export function useVault() {
}
}, []);
const copyToClipboard = async (text: string): Promise<boolean> => {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// Fallback: create temp input, select, execCommand
try {
const tempInput = document.createElement("input");
tempInput.value = text;
tempInput.style.position = "absolute";
tempInput.style.left = "-9999px";
document.body.appendChild(tempInput);
tempInput.focus();
tempInput.select();
const ok = document.execCommand("copy");
document.body.removeChild(tempInput);
return ok;
} catch {
return false;
}
}
};
const copyPassword = useCallback(async (cipherId: string) => {
try {
const result = await vaultApi.copyPassword(cipherId);
if (result.success) {
if (result.success && result.value) {
const copied = await copyToClipboard(result.value);
if (!copied) {
setError("Failed to copy to clipboard");
return false;
}
const clearAfter = result.clear_after || 60;
setCopyStatus({
field: "password",
cipherId,
expiresAt: result.expires_at || Date.now() / 1000 + 60,
expiresAt: Date.now() / 1000 + clearAfter,
});
setTimeout(() => setCopyStatus(null), 60000);
setTimeout(() => setCopyStatus(null), clearAfter * 1000);
return true;
}
setError(result.error || "Failed to copy");
@@ -211,13 +240,19 @@ export function useVault() {
const copyUsername = useCallback(async (cipherId: string) => {
try {
const result = await vaultApi.copyUsername(cipherId);
if (result.success) {
if (result.success && result.value) {
const copied = await copyToClipboard(result.value);
if (!copied) {
setError("Failed to copy to clipboard");
return false;
}
const clearAfter = result.clear_after || 60;
setCopyStatus({
field: "username",
cipherId,
expiresAt: result.expires_at || Date.now() / 1000 + 60,
expiresAt: Date.now() / 1000 + clearAfter,
});
setTimeout(() => setCopyStatus(null), 60000);
setTimeout(() => setCopyStatus(null), clearAfter * 1000);
return true;
}
setError(result.error || "Failed to copy");
@@ -231,13 +266,19 @@ export function useVault() {
const copyTotp = useCallback(async (cipherId: string) => {
try {
const result = await vaultApi.copyTotp(cipherId);
if (result.success) {
if (result.success && result.value) {
const copied = await copyToClipboard(result.value);
if (!copied) {
setError("Failed to copy to clipboard");
return false;
}
const clearAfter = result.clear_after || 30;
setCopyStatus({
field: "totp",
cipherId,
expiresAt: result.expires_at || Date.now() / 1000 + 30,
expiresAt: Date.now() / 1000 + clearAfter,
});
setTimeout(() => setCopyStatus(null), 30000);
setTimeout(() => setCopyStatus(null), clearAfter * 1000);
return true;
}
setError(result.error || "Failed to copy TOTP");