refactor: move clipboard to frontend, fix cipher type mapping, add context menu
This commit is contained in:
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user