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
+2 -43
View File
@@ -15,21 +15,6 @@ from cryptography.hazmat.primitives import hashes
log = logging.getLogger("decky-vaultwarden") log = logging.getLogger("decky-vaultwarden")
_DEBUG_LOG = "/tmp/decky-vaultwarden-debug.log"
def _debug(msg: str):
"""Write debug info to file for easy inspection."""
import datetime
ts = datetime.datetime.now().isoformat()
line = f"[{ts}] {msg}\n"
try:
with open(_DEBUG_LOG, "a") as f:
f.write(line)
f.flush()
except Exception:
pass
from crypto import BitwardenCrypto, PinCrypto from crypto import BitwardenCrypto, PinCrypto
@@ -125,9 +110,7 @@ class BitwardenClient:
method, url, headers=headers, json=data, params=params, ssl=_get_ssl_context() method, url, headers=headers, json=data, params=params, ssl=_get_ssl_context()
) as resp: ) as resp:
text = await resp.text() text = await resp.text()
_debug(f"[REQUEST] {method} {url} -> {resp.status}")
if resp.status >= 400: if resp.status >= 400:
_debug(f"[REQUEST] error body: {text[:500]}")
try: try:
error_data = json.loads(text) error_data = json.loads(text)
message = error_data.get("error_model", {}).get( message = error_data.get("error_model", {}).get(
@@ -144,10 +127,7 @@ class BitwardenClient:
"""Get KDF settings for the user.""" """Get KDF settings for the user."""
url = f"{self._get_api_url()}/accounts/prelogin" url = f"{self._get_api_url()}/accounts/prelogin"
data = {"email": email} data = {"email": email}
_debug(f"[PRELOGIN] POST {url}")
_debug(f"[PRELOGIN] request data: {json.dumps(data)}")
result = await self._request("POST", url, data=data) result = await self._request("POST", url, data=data)
_debug(f"[PRELOGIN] response: {json.dumps(result)}")
self.kdf_type = result.get("kdf") or result.get("Kdf", 0) self.kdf_type = result.get("kdf") or result.get("Kdf", 0)
self.kdf_iterations = result.get("kdfIterations") or result.get("KdfIterations", 600000) self.kdf_iterations = result.get("kdfIterations") or result.get("KdfIterations", 600000)
@@ -155,7 +135,6 @@ class BitwardenClient:
self.kdf_parallelism = result.get("kdfParallelism") or result.get("KdfParallelism") self.kdf_parallelism = result.get("kdfParallelism") or result.get("KdfParallelism")
self.email = email self.email = email
_debug(f"[PRELOGIN] parsed kdf_type={self.kdf_type}, kdf_iterations={self.kdf_iterations}, kdf_memory={self.kdf_memory}, kdf_parallelism={self.kdf_parallelism}")
return result return result
@@ -163,7 +142,6 @@ class BitwardenClient:
self, email: str, password: str, two_factor_token: Optional[str] = None self, email: str, password: str, two_factor_token: Optional[str] = None
) -> dict: ) -> dict:
"""Login with email and master password.""" """Login with email and master password."""
_debug(f"[LOGIN] Starting password login for: {email}")
await self.prelogin(email) await self.prelogin(email)
# Derive master key # Derive master key
@@ -182,7 +160,6 @@ class BitwardenClient:
else: else:
raise ValueError(f"Unsupported KDF type: {self.kdf_type}") raise ValueError(f"Unsupported KDF type: {self.kdf_type}")
_debug(f"[LOGIN] master_key (first 8 bytes b64): {b64encode(master_key[:8]).decode()}")
# Hash master password for auth (Bitwarden uses PBKDF2 with 1 iteration, NOT HMAC) # Hash master password for auth (Bitwarden uses PBKDF2 with 1 iteration, NOT HMAC)
_pwd_hash_kdf = PBKDF2HMAC( _pwd_hash_kdf = PBKDF2HMAC(
@@ -194,7 +171,6 @@ class BitwardenClient:
) )
master_password_hash = b64encode(_pwd_hash_kdf.derive(master_key)).decode("utf-8") master_password_hash = b64encode(_pwd_hash_kdf.derive(master_key)).decode("utf-8")
_debug(f"[LOGIN] master_password_hash: {master_password_hash}")
# Build auth request # Build auth request
url = f"{self.identity_url}/connect/token" url = f"{self.identity_url}/connect/token"
@@ -217,9 +193,6 @@ class BitwardenClient:
data["two_factor_provider"] = "0" # Authenticator data["two_factor_provider"] = "0" # Authenticator
data["two_factor_remember"] = "1" data["two_factor_remember"] = "1"
_debug(f"[LOGIN] POST {url}")
_debug(f"[LOGIN] form data (excl password): {json.dumps({k: v for k, v in data.items() if k != 'password'}, indent=2)}")
_debug(f"[LOGIN] password field (master_password_hash): {data['password']}")
# Use form data instead of JSON # Use form data instead of JSON
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
@@ -227,12 +200,9 @@ class BitwardenClient:
url, data=data, headers=headers, ssl=_get_ssl_context() url, data=data, headers=headers, ssl=_get_ssl_context()
) as resp: ) as resp:
text = await resp.text() text = await resp.text()
_debug(f"[LOGIN] response status: {resp.status}")
_debug(f"[LOGIN] response body: {text[:500]}")
if resp.status >= 400: if resp.status >= 400:
try: try:
error_data = json.loads(text) error_data = json.loads(text)
_debug(f"[LOGIN] error response: {json.dumps(error_data, indent=2)}")
# Check if 2FA is required # Check if 2FA is required
if error_data.get("error") == "invalid_grant" and "twoFactor" in text: if error_data.get("error") == "invalid_grant" and "twoFactor" in text:
return {"two_factor_required": True} return {"two_factor_required": True}
@@ -240,32 +210,20 @@ class BitwardenClient:
"message", text "message", text
) )
except (json.JSONDecodeError, KeyError): except (json.JSONDecodeError, KeyError):
_debug(f"[LOGIN] raw error text: {text}")
message = text message = text
raise Exception(f"Login failed: {message}") raise Exception(f"Login failed: {message}")
result = json.loads(text) result = json.loads(text)
_debug("[LOGIN] SUCCESS - token received")
self.access_token = result.get("access_token") self.access_token = result.get("access_token")
self.refresh_token = result.get("refresh_token") self.refresh_token = result.get("refresh_token")
self.user_id = result.get("Profile", {}).get("id") or result.get("sub") self.user_id = result.get("Profile", {}).get("id") or result.get("sub")
# Get encrypted user key # Get encrypted user key
enc_user_key = result.get("Key") enc_user_key = result.get("Key")
_debug(f"[LOGIN] enc_user_key present: {enc_user_key is not None}")
if enc_user_key: if enc_user_key:
_debug(f"[LOGIN] enc_user_key (first 40): {enc_user_key[:40]}...")
_debug(f"[LOGIN] attempting to decrypt user key with master_key...")
try:
self.enc_key, self.mac_key = self.crypto.decrypt_user_key( self.enc_key, self.mac_key = self.crypto.decrypt_user_key(
enc_user_key, master_key enc_user_key, master_key
) )
_debug(f"[LOGIN] user key decrypted successfully!")
_debug(f"[LOGIN] enc_key (first 8 b64): {b64encode(self.enc_key[:8]).decode()}")
_debug(f"[LOGIN] mac_key (first 8 b64): {b64encode(self.mac_key[:8]).decode()}")
except Exception as e:
_debug(f"[LOGIN] user key decryption FAILED: {e}")
raise
return { return {
"success": True, "success": True,
"master_key": master_key, "master_key": master_key,
@@ -383,7 +341,8 @@ class BitwardenClient:
# Decrypt ciphers # Decrypt ciphers
decrypted_ciphers = [] decrypted_ciphers = []
for cipher in ciphers: for cipher in ciphers:
if cipher.get("type") in [0, 1, 2, 3]: # Login, Note, Card, Identity ctype = cipher.get("type")
if ctype in [1, 2, 3, 4]: # Login, Note, Card, Identity
decrypted = self.crypto.decrypt_cipher( decrypted = self.crypto.decrypt_cipher(
cipher, enc_key, mac_key cipher, enc_key, mac_key
) )
+5 -35
View File
@@ -10,18 +10,6 @@ import struct
from base64 import b64decode, b64encode from base64 import b64decode, b64encode
from typing import Optional, Tuple from typing import Optional, Tuple
_debug_log = "/tmp/decky-vaultwarden-debug.log"
def _debug(msg: str):
import datetime
ts = datetime.datetime.now().isoformat()
try:
with open(_debug_log, "a") as f:
f.write(f"[{ts}] {msg}\n")
f.flush()
except Exception:
pass
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import hashes, padding from cryptography.hazmat.primitives import hashes, padding
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
@@ -87,16 +75,9 @@ class BitwardenCrypto:
def stretch_master_key(self, master_key: bytes) -> Tuple[bytes, bytes]: def stretch_master_key(self, master_key: bytes) -> Tuple[bytes, bytes]:
"""Stretch master key into encryption key + MAC key using HKDF-expand.""" """Stretch master key into encryption key + MAC key using HKDF-expand."""
_debug(f"[CRYPTO] stretch_master_key: master_key (first 8 b64)={b64encode(master_key[:8]).decode()}")
_debug(f"[CRYPTO] stretch_master_key: master_key length={len(master_key)}")
# Bitwarden SDK: HKDF-expand(key, info) = HMAC-SHA256(key, info || 0x01) # Bitwarden SDK: HKDF-expand(key, info) = HMAC-SHA256(key, info || 0x01)
enc_key = hmac.new(master_key, b"enc\x01", hashlib.sha256).digest() enc_key = hmac.new(master_key, b"enc\x01", hashlib.sha256).digest()
mac_key = hmac.new(master_key, b"mac\x01", hashlib.sha256).digest() mac_key = hmac.new(master_key, b"mac\x01", hashlib.sha256).digest()
_debug(f"[CRYPTO] stretch: enc_key (first 8 b64)={b64encode(enc_key[:8]).decode()}")
_debug(f"[CRYPTO] stretch: mac_key (first 8 b64)={b64encode(mac_key[:8]).decode()}")
return enc_key, mac_key return enc_key, mac_key
def decrypt_aes_cbc_256( def decrypt_aes_cbc_256(
@@ -131,11 +112,9 @@ class BitwardenCrypto:
Format: TYPE.BASE64(IV) | BASE64(CT) | BASE64(MAC) Format: TYPE.BASE64(IV) | BASE64(CT) | BASE64(MAC)
or: TYPE.BASE64(IV).BASE64(CT).BASE64(MAC) or: TYPE.BASE64(IV).BASE64(CT).BASE64(MAC)
""" """
_debug(f"[CRYPTO] decrypt_cipher_string: type={enc_string[:2]}, full={enc_string[:50]}...")
# Determine cipher type # Determine cipher type
if enc_string.startswith(CIPHER_TYPE_AES_CBC_256_B64): if enc_string.startswith(CIPHER_TYPE_AES_CBC_256_B64):
parts = enc_string[2:].split("|") parts = enc_string[2:].split("|")
_debug(f"[CRYPTO] decrypt_cipher_string: type 2, parts count={len(parts)}")
if len(parts) == 2: if len(parts) == 2:
# Format: TYPE.BASE64(IV)|BASE64(CT) # Format: TYPE.BASE64(IV)|BASE64(CT)
iv_b64, ct_b64 = parts iv_b64, ct_b64 = parts
@@ -148,13 +127,7 @@ class BitwardenCrypto:
iv = b64decode(iv_b64) iv = b64decode(iv_b64)
ct = b64decode(ct_b64) ct = b64decode(ct_b64)
mac = b64decode(mac_b64) mac = b64decode(mac_b64)
_debug(f"[CRYPTO] decrypt_cipher_string: verifying MAC...")
computed_mac = self.hmac_sha256(mac_key, iv + ct) computed_mac = self.hmac_sha256(mac_key, iv + ct)
_debug(f"[CRYPTO] decrypt_cipher_string: computed_mac (b64)={b64encode(computed_mac).decode()}")
_debug(f"[CRYPTO] decrypt_cipher_string: expected_mac (b64)={mac_b64}")
_debug(f"[CRYPTO] decrypt_cipher_string: mac_keys_equal={computed_mac == mac}")
_debug(f"[CRYPTO] decrypt_cipher_string: enc_key (first 8 b64)={b64encode(enc_key[:8]).decode()}")
_debug(f"[CRYPTO] decrypt_cipher_string: mac_key (first 8 b64)={b64encode(mac_key[:8]).decode()}")
if not self.verify_mac(mac_key, iv + ct, mac): if not self.verify_mac(mac_key, iv + ct, mac):
raise ValueError("MAC verification failed") raise ValueError("MAC verification failed")
return self.decrypt_aes_cbc_256(enc_key, iv, ct) return self.decrypt_aes_cbc_256(enc_key, iv, ct)
@@ -185,13 +158,10 @@ class BitwardenCrypto:
The user key is encrypted with the stretched master key. The user key is encrypted with the stretched master key.
""" """
_debug(f"[CRYPTO] decrypt_user_key: encrypted_user_key (first 30)={encrypted_user_key[:30]}...")
stretched_enc, stretched_mac = self.stretch_master_key(master_key) stretched_enc, stretched_mac = self.stretch_master_key(master_key)
_debug(f"[CRYPTO] decrypt_user_key: calling decrypt_cipher_string...")
user_key = self.decrypt_cipher_string( user_key = self.decrypt_cipher_string(
encrypted_user_key, stretched_enc, stretched_mac encrypted_user_key, stretched_enc, stretched_mac
) )
_debug(f"[CRYPTO] decrypt_user_key: decrypted user_key length={len(user_key)}")
if len(user_key) == 64: if len(user_key) == 64:
# Has separate MAC key # Has separate MAC key
@@ -233,7 +203,7 @@ class BitwardenCrypto:
cipher_type = cipher_data.get("type") cipher_type = cipher_data.get("type")
if cipher_type == 0: # Login if cipher_type == 1: # Login
login = cipher_data.get("login", {}) login = cipher_data.get("login", {})
result["login"] = { result["login"] = {
"username": self._decrypt_field( "username": self._decrypt_field(
@@ -255,11 +225,11 @@ class BitwardenCrypto:
for u in login.get("uris", []) for u in login.get("uris", [])
], ],
} }
elif cipher_type == 1: # Secure Note elif cipher_type == 2: # Secure Note
result["notes"] = self._decrypt_field( result["notes"] = self._decrypt_field(
cipher_data.get("notes"), enc_key, mac_key cipher_data.get("notes"), enc_key, mac_key
) )
elif cipher_type == 2: # Card elif cipher_type == 3: # Card
card = cipher_data.get("card", {}) card = cipher_data.get("card", {})
result["card"] = { result["card"] = {
"cardholderName": self._decrypt_field( "cardholderName": self._decrypt_field(
@@ -278,7 +248,7 @@ class BitwardenCrypto:
card.get("expYear"), enc_key, mac_key card.get("expYear"), enc_key, mac_key
), ),
} }
elif cipher_type == 3: # Identity elif cipher_type == 4: # Identity
identity = cipher_data.get("identity", {}) identity = cipher_data.get("identity", {})
result["identity"] = { result["identity"] = {
"firstName": self._decrypt_field( "firstName": self._decrypt_field(
@@ -311,7 +281,7 @@ class BitwardenCrypto:
result["fields"] = fields result["fields"] = fields
# Decrypt notes (only for non-Note types, Notes already handled above) # Decrypt notes (only for non-Note types, Notes already handled above)
if cipher_data.get("notes") and cipher_type != 1: if cipher_data.get("notes") and cipher_type != 2:
result["notes"] = self._decrypt_field( result["notes"] = self._decrypt_field(
cipher_data.get("notes"), enc_key, mac_key cipher_data.get("notes"), enc_key, mac_key
) )
+6 -11
View File
@@ -242,32 +242,27 @@ class Plugin:
# ===== Credential Copy ===== # ===== Credential Copy =====
async def copy_password(self, cipher_id: str) -> dict: async def copy_password(self, cipher_id: str) -> dict:
"""Copy a password to clipboard with 60s auto-clear.""" """Get password for clipboard copy."""
from clipboard import copy_to_clipboard
password = self._get_cipher_field(cipher_id, "password") password = self._get_cipher_field(cipher_id, "password")
if password is None: if password is None:
return {"success": False, "error": "Password not found"} return {"success": False, "error": "Password not found"}
return await copy_to_clipboard(password, clear_after=60, label=f"password_{cipher_id}") return {"success": True, "value": password, "clear_after": 60}
async def copy_username(self, cipher_id: str) -> dict: async def copy_username(self, cipher_id: str) -> dict:
"""Copy a username to clipboard with 60s auto-clear.""" """Get username for clipboard copy."""
from clipboard import copy_to_clipboard
username = self._get_cipher_field(cipher_id, "username") username = self._get_cipher_field(cipher_id, "username")
if username is None: if username is None:
return {"success": False, "error": "Username not found"} return {"success": False, "error": "Username not found"}
return await copy_to_clipboard(username, clear_after=60, label=f"username_{cipher_id}") return {"success": True, "value": username, "clear_after": 60}
async def copy_totp(self, cipher_id: str) -> dict: async def copy_totp(self, cipher_id: str) -> dict:
"""Copy TOTP code to clipboard with 60s auto-clear.""" """Get TOTP code for clipboard copy."""
from clipboard import copy_to_clipboard
from totp import generate_totp, get_totp_remaining_seconds from totp import generate_totp, get_totp_remaining_seconds
totp_secret = self._get_cipher_field(cipher_id, "totp") totp_secret = self._get_cipher_field(cipher_id, "totp")
if totp_secret: if totp_secret:
code = generate_totp(totp_secret) code = generate_totp(totp_secret)
remaining = get_totp_remaining_seconds() remaining = get_totp_remaining_seconds()
result = await copy_to_clipboard(code, clear_after=min(remaining, 30), label=f"totp_{cipher_id}") return {"success": True, "value": code, "clear_after": min(remaining, 30), "remaining_seconds": remaining}
result["remaining_seconds"] = remaining
return result
return {"success": False, "error": "TOTP not configured"} return {"success": False, "error": "TOTP not configured"}
async def get_totp_code(self, cipher_id: str) -> dict: async def get_totp_code(self, cipher_id: str) -> dict:
+1
View File
@@ -59,6 +59,7 @@ export interface LoginResult {
export interface CopyResult { export interface CopyResult {
success?: boolean; success?: boolean;
value?: string;
clear_after?: number; clear_after?: number;
expires_at?: number; expires_at?: number;
remaining_seconds?: number; remaining_seconds?: number;
+65 -12
View File
@@ -1,5 +1,11 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useRef } from "react";
import { ButtonItem } from "@decky/ui"; import {
Focusable,
showContextMenu,
Menu,
MenuItem,
MenuGroup,
} from "@decky/ui";
import { VaultItem } from "../api/backend"; import { VaultItem } from "../api/backend";
interface ItemCardProps { interface ItemCardProps {
@@ -35,6 +41,8 @@ export function ItemCard({
}: ItemCardProps) { }: ItemCardProps) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [copying, setCopying] = useState<string | null>(null); const [copying, setCopying] = useState<string | null>(null);
const [focused, setFocused] = useState(false);
const cardRef = useRef<HTMLDivElement>(null);
const isCopied = (field: string) => const isCopied = (field: string) =>
copyStatus?.cipherId === item.id && copyStatus?.field === field; 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; const folderName = item.folderId ? folderMap[item.folderId] : null;
return ( return (
<div <Focusable
ref={cardRef}
onOKButton={showItemMenu}
onActivate={showItemMenu}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
flow-children="y"
onOKActionDescription="Copy Menu"
style={{ style={{
background: "#1e1e1e", background: "#1e1e1e",
borderRadius: "8px", borderRadius: "8px",
padding: "12px", padding: "12px",
marginBottom: "8px", marginBottom: "8px",
border: expanded ? "1px solid #1a9fff" : "1px solid #333", border: focused
? "1px solid #1a9fff"
: expanded
? "1px solid #1a9fff"
: "1px solid #333",
}} }}
> >
{/* Header */} {/* Header */}
<div <div
onClick={() => setExpanded(!expanded)}
style={{ style={{
cursor: "pointer",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: "8px", gap: "8px",
@@ -136,9 +192,6 @@ export function ItemCard({
{folderName} {folderName}
</span> </span>
)} )}
<span style={{ fontSize: "10px", color: "#666" }}>
{expanded ? "\u25B2" : "\u25BC"}
</span>
</div> </div>
{/* Expanded content */} {/* Expanded content */}
@@ -212,7 +265,7 @@ export function ItemCard({
<div key={idx} style={{ fontSize: "11px", marginBottom: "2px" }}> <div key={idx} style={{ fontSize: "11px", marginBottom: "2px" }}>
<span style={{ color: "#888" }}>{field.name}: </span> <span style={{ color: "#888" }}>{field.name}: </span>
<span style={{ color: "#ccc" }}> <span style={{ color: "#ccc" }}>
{field.hidden ? "••••••••" : field.value || ""} {field.hidden ? "\u2022\u2022\u2022\u2022\u2022\u2022\u2022" : field.value || ""}
</span> </span>
</div> </div>
))} ))}
@@ -244,7 +297,7 @@ export function ItemCard({
<div style={{ marginTop: "12px", fontSize: "12px", color: "#ccc" }}> <div style={{ marginTop: "12px", fontSize: "12px", color: "#ccc" }}>
{item.card.brand && <div>Brand: {item.card.brand}</div>} {item.card.brand && <div>Brand: {item.card.brand}</div>}
{item.card.number && ( {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 && ( {item.card.expMonth && item.card.expYear && (
<div> <div>
@@ -256,7 +309,7 @@ export function ItemCard({
)} )}
</div> </div>
)} )}
</div> </Focusable>
); );
} }
+8 -5
View File
@@ -1,11 +1,11 @@
import { useState, useEffect } from "react"; import { useState } from "react";
import { import {
PanelSection, PanelSection,
TextField, TextField,
ButtonItem, ButtonItem,
Spinner, Focusable,
} from "@decky/ui"; } from "@decky/ui";
import { VaultData, VaultItem, Folder } from "../api/backend"; import { VaultData, VaultItem } from "../api/backend";
import { ItemCard } from "./ItemCard"; import { ItemCard } from "./ItemCard";
interface VaultBrowserProps { interface VaultBrowserProps {
@@ -102,7 +102,10 @@ export function VaultBrowser({
)} )}
{/* Items */} {/* Items */}
<div style={{ maxHeight: "400px", overflowY: "auto" }}> <Focusable
flow-children="y"
style={{ maxHeight: "400px", overflowY: "auto" }}
>
{displayedItems.length === 0 ? ( {displayedItems.length === 0 ? (
<div <div
style={{ style={{
@@ -126,7 +129,7 @@ export function VaultBrowser({
/> />
)) ))
)} )}
</div> </Focusable>
{/* Actions */} {/* Actions */}
<ButtonItem layout="below" onClick={onLock}> <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) => { const copyPassword = useCallback(async (cipherId: string) => {
try { try {
const result = await vaultApi.copyPassword(cipherId); 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({ setCopyStatus({
field: "password", field: "password",
cipherId, 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; return true;
} }
setError(result.error || "Failed to copy"); setError(result.error || "Failed to copy");
@@ -211,13 +240,19 @@ export function useVault() {
const copyUsername = useCallback(async (cipherId: string) => { const copyUsername = useCallback(async (cipherId: string) => {
try { try {
const result = await vaultApi.copyUsername(cipherId); 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({ setCopyStatus({
field: "username", field: "username",
cipherId, 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; return true;
} }
setError(result.error || "Failed to copy"); setError(result.error || "Failed to copy");
@@ -231,13 +266,19 @@ export function useVault() {
const copyTotp = useCallback(async (cipherId: string) => { const copyTotp = useCallback(async (cipherId: string) => {
try { try {
const result = await vaultApi.copyTotp(cipherId); 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({ setCopyStatus({
field: "totp", field: "totp",
cipherId, 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; return true;
} }
setError(result.error || "Failed to copy TOTP"); setError(result.error || "Failed to copy TOTP");