refactor: move clipboard to frontend, fix cipher type mapping, add context menu
This commit is contained in:
+5
-46
@@ -15,21 +15,6 @@ from cryptography.hazmat.primitives import hashes
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -125,9 +110,7 @@ class BitwardenClient:
|
||||
method, url, headers=headers, json=data, params=params, ssl=_get_ssl_context()
|
||||
) as resp:
|
||||
text = await resp.text()
|
||||
_debug(f"[REQUEST] {method} {url} -> {resp.status}")
|
||||
if resp.status >= 400:
|
||||
_debug(f"[REQUEST] error body: {text[:500]}")
|
||||
try:
|
||||
error_data = json.loads(text)
|
||||
message = error_data.get("error_model", {}).get(
|
||||
@@ -144,10 +127,7 @@ class BitwardenClient:
|
||||
"""Get KDF settings for the user."""
|
||||
url = f"{self._get_api_url()}/accounts/prelogin"
|
||||
data = {"email": email}
|
||||
_debug(f"[PRELOGIN] POST {url}")
|
||||
_debug(f"[PRELOGIN] request data: {json.dumps(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_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.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
|
||||
|
||||
@@ -163,7 +142,6 @@ class BitwardenClient:
|
||||
self, email: str, password: str, two_factor_token: Optional[str] = None
|
||||
) -> dict:
|
||||
"""Login with email and master password."""
|
||||
_debug(f"[LOGIN] Starting password login for: {email}")
|
||||
await self.prelogin(email)
|
||||
|
||||
# Derive master key
|
||||
@@ -182,7 +160,6 @@ class BitwardenClient:
|
||||
else:
|
||||
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)
|
||||
_pwd_hash_kdf = PBKDF2HMAC(
|
||||
@@ -194,7 +171,6 @@ class BitwardenClient:
|
||||
)
|
||||
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
|
||||
url = f"{self.identity_url}/connect/token"
|
||||
@@ -217,9 +193,6 @@ class BitwardenClient:
|
||||
data["two_factor_provider"] = "0" # Authenticator
|
||||
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
|
||||
async with aiohttp.ClientSession() as session:
|
||||
@@ -227,12 +200,9 @@ class BitwardenClient:
|
||||
url, data=data, headers=headers, ssl=_get_ssl_context()
|
||||
) as resp:
|
||||
text = await resp.text()
|
||||
_debug(f"[LOGIN] response status: {resp.status}")
|
||||
_debug(f"[LOGIN] response body: {text[:500]}")
|
||||
if resp.status >= 400:
|
||||
try:
|
||||
error_data = json.loads(text)
|
||||
_debug(f"[LOGIN] error response: {json.dumps(error_data, indent=2)}")
|
||||
# Check if 2FA is required
|
||||
if error_data.get("error") == "invalid_grant" and "twoFactor" in text:
|
||||
return {"two_factor_required": True}
|
||||
@@ -240,32 +210,20 @@ class BitwardenClient:
|
||||
"message", text
|
||||
)
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
_debug(f"[LOGIN] raw error text: {text}")
|
||||
message = text
|
||||
raise Exception(f"Login failed: {message}")
|
||||
result = json.loads(text)
|
||||
|
||||
_debug("[LOGIN] SUCCESS - token received")
|
||||
self.access_token = result.get("access_token")
|
||||
self.refresh_token = result.get("refresh_token")
|
||||
self.user_id = result.get("Profile", {}).get("id") or result.get("sub")
|
||||
|
||||
# Get encrypted user key
|
||||
enc_user_key = result.get("Key")
|
||||
_debug(f"[LOGIN] enc_user_key present: {enc_user_key is not None}")
|
||||
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(
|
||||
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
|
||||
self.enc_key, self.mac_key = self.crypto.decrypt_user_key(
|
||||
enc_user_key, master_key
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"master_key": master_key,
|
||||
@@ -383,7 +341,8 @@ class BitwardenClient:
|
||||
# Decrypt ciphers
|
||||
decrypted_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(
|
||||
cipher, enc_key, mac_key
|
||||
)
|
||||
|
||||
@@ -10,18 +10,6 @@ import struct
|
||||
from base64 import b64decode, b64encode
|
||||
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 import hashes, padding
|
||||
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]:
|
||||
"""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)
|
||||
enc_key = hmac.new(master_key, b"enc\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
|
||||
|
||||
def decrypt_aes_cbc_256(
|
||||
@@ -131,11 +112,9 @@ class BitwardenCrypto:
|
||||
Format: 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
|
||||
if enc_string.startswith(CIPHER_TYPE_AES_CBC_256_B64):
|
||||
parts = enc_string[2:].split("|")
|
||||
_debug(f"[CRYPTO] decrypt_cipher_string: type 2, parts count={len(parts)}")
|
||||
if len(parts) == 2:
|
||||
# Format: TYPE.BASE64(IV)|BASE64(CT)
|
||||
iv_b64, ct_b64 = parts
|
||||
@@ -148,13 +127,7 @@ class BitwardenCrypto:
|
||||
iv = b64decode(iv_b64)
|
||||
ct = b64decode(ct_b64)
|
||||
mac = b64decode(mac_b64)
|
||||
_debug(f"[CRYPTO] decrypt_cipher_string: verifying MAC...")
|
||||
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):
|
||||
raise ValueError("MAC verification failed")
|
||||
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.
|
||||
"""
|
||||
_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)
|
||||
_debug(f"[CRYPTO] decrypt_user_key: calling decrypt_cipher_string...")
|
||||
user_key = self.decrypt_cipher_string(
|
||||
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:
|
||||
# Has separate MAC key
|
||||
@@ -233,7 +203,7 @@ class BitwardenCrypto:
|
||||
|
||||
cipher_type = cipher_data.get("type")
|
||||
|
||||
if cipher_type == 0: # Login
|
||||
if cipher_type == 1: # Login
|
||||
login = cipher_data.get("login", {})
|
||||
result["login"] = {
|
||||
"username": self._decrypt_field(
|
||||
@@ -255,11 +225,11 @@ class BitwardenCrypto:
|
||||
for u in login.get("uris", [])
|
||||
],
|
||||
}
|
||||
elif cipher_type == 1: # Secure Note
|
||||
elif cipher_type == 2: # Secure Note
|
||||
result["notes"] = self._decrypt_field(
|
||||
cipher_data.get("notes"), enc_key, mac_key
|
||||
)
|
||||
elif cipher_type == 2: # Card
|
||||
elif cipher_type == 3: # Card
|
||||
card = cipher_data.get("card", {})
|
||||
result["card"] = {
|
||||
"cardholderName": self._decrypt_field(
|
||||
@@ -278,7 +248,7 @@ class BitwardenCrypto:
|
||||
card.get("expYear"), enc_key, mac_key
|
||||
),
|
||||
}
|
||||
elif cipher_type == 3: # Identity
|
||||
elif cipher_type == 4: # Identity
|
||||
identity = cipher_data.get("identity", {})
|
||||
result["identity"] = {
|
||||
"firstName": self._decrypt_field(
|
||||
@@ -311,7 +281,7 @@ class BitwardenCrypto:
|
||||
result["fields"] = fields
|
||||
|
||||
# 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(
|
||||
cipher_data.get("notes"), enc_key, mac_key
|
||||
)
|
||||
|
||||
@@ -242,32 +242,27 @@ class Plugin:
|
||||
# ===== Credential Copy =====
|
||||
|
||||
async def copy_password(self, cipher_id: str) -> dict:
|
||||
"""Copy a password to clipboard with 60s auto-clear."""
|
||||
from clipboard import copy_to_clipboard
|
||||
"""Get password for clipboard copy."""
|
||||
password = self._get_cipher_field(cipher_id, "password")
|
||||
if password is None:
|
||||
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:
|
||||
"""Copy a username to clipboard with 60s auto-clear."""
|
||||
from clipboard import copy_to_clipboard
|
||||
"""Get username for clipboard copy."""
|
||||
username = self._get_cipher_field(cipher_id, "username")
|
||||
if username is None:
|
||||
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:
|
||||
"""Copy TOTP code to clipboard with 60s auto-clear."""
|
||||
from clipboard import copy_to_clipboard
|
||||
"""Get TOTP code for clipboard copy."""
|
||||
from totp import generate_totp, get_totp_remaining_seconds
|
||||
totp_secret = self._get_cipher_field(cipher_id, "totp")
|
||||
if totp_secret:
|
||||
code = generate_totp(totp_secret)
|
||||
remaining = get_totp_remaining_seconds()
|
||||
result = await copy_to_clipboard(code, clear_after=min(remaining, 30), label=f"totp_{cipher_id}")
|
||||
result["remaining_seconds"] = remaining
|
||||
return result
|
||||
return {"success": True, "value": code, "clear_after": min(remaining, 30), "remaining_seconds": remaining}
|
||||
return {"success": False, "error": "TOTP not configured"}
|
||||
|
||||
async def get_totp_code(self, cipher_id: str) -> dict:
|
||||
|
||||
@@ -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