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")
|
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]}...")
|
self.enc_key, self.mac_key = self.crypto.decrypt_user_key(
|
||||||
_debug(f"[LOGIN] attempting to decrypt user key with master_key...")
|
enc_user_key, 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
|
|
||||||
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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user