chore: init decky vaultwarden plugin scaffold

This commit is contained in:
2026-08-25 18:02:07 +02:00
commit 3d066c5297
27 changed files with 5479 additions and 0 deletions
+192
View File
@@ -0,0 +1,192 @@
/**
* Frontend <-> Backend API bridge.
* Calls Python backend methods via Decky's call function.
*/
import { call } from "@decky/api";
// Types
export interface VaultItem {
id: string;
type: number;
name: string;
folderId?: string;
organizationId?: string;
favorite?: boolean;
revisionDate?: string;
login?: {
username?: string;
password?: string;
totp?: string;
uris?: { uri?: string; match?: number }[];
};
notes?: string;
card?: {
cardholderName?: string;
brand?: string;
number?: string;
expMonth?: string;
expYear?: string;
};
identity?: {
firstName?: string;
lastName?: string;
email?: string;
phone?: string;
};
fields?: { name?: string; value?: string; type?: number; hidden?: boolean }[];
error?: boolean;
}
export interface Folder {
id: string;
name: string;
revisionDate?: string;
}
export interface VaultData {
ciphers: VaultItem[];
folders: Folder[];
folder_map: Record<string, string>;
collections: any[];
}
export interface LoginResult {
success?: boolean;
two_factor_required?: boolean;
needs_unlock?: boolean;
error?: string;
}
export interface CopyResult {
success?: boolean;
clear_after?: number;
expires_at?: number;
remaining_seconds?: number;
error?: string;
}
export interface ApiKeyLoginResult {
success?: boolean;
needs_master_password?: boolean;
message?: string;
error?: string;
}
// API wrapper using new Decky call API
class VaultApi {
private async call<T>(method: string, ...args: any[]): Promise<T> {
try {
const result = await call(method, ...args);
return result as T;
} catch (error: any) {
throw new Error(error.message || "Backend error");
}
}
// Authentication
async loginPassword(
serverUrl: string,
email: string,
password: string,
twoFactorToken?: string
): Promise<LoginResult> {
return this.call<LoginResult>(
"login_password",
serverUrl,
email,
password,
twoFactorToken
);
}
async loginApiKey(
serverUrl: string,
clientId: string,
clientSecret: string,
email: string
): Promise<ApiKeyLoginResult> {
return this.call<ApiKeyLoginResult>(
"login_api_key",
serverUrl,
clientId,
clientSecret,
email
);
}
async unlockWithMasterPassword(password: string): Promise<LoginResult> {
return this.call<LoginResult>("unlock_with_master_password", password);
}
// Vault
async getVaultItems(): Promise<{ success: boolean; data: VaultData }> {
return this.call("get_vault_items");
}
async searchVault(query: string): Promise<{ success: boolean; results: VaultItem[] }> {
return this.call("search_vault", query);
}
// Credential copy
async copyPassword(cipherId: string): Promise<CopyResult> {
return this.call<CopyResult>("copy_password", cipherId);
}
async copyUsername(cipherId: string): Promise<CopyResult> {
return this.call<CopyResult>("copy_username", cipherId);
}
async copyTotp(cipherId: string): Promise<CopyResult> {
return this.call<CopyResult>("copy_totp", cipherId);
}
async getTotpCode(cipherId: string): Promise<{ success: boolean; code?: string; remaining_seconds?: number; error?: string }> {
return this.call("get_totp_code", cipherId);
}
// PIN
async setupPin(pin: string): Promise<{ success: boolean; error?: string }> {
return this.call("setup_pin", pin);
}
async unlockWithPin(pin: string): Promise<LoginResult> {
return this.call<LoginResult>("unlock_with_pin", pin);
}
async removePin(): Promise<{ success: boolean }> {
return this.call("remove_pin");
}
async isPinEnabled(): Promise<{ enabled: boolean }> {
return this.call("is_pin_enabled");
}
// Session
async lockVault(): Promise<{ success: boolean }> {
return this.call("lock_vault");
}
async logout(): Promise<{ success: boolean }> {
return this.call("logout");
}
async getStatus(): Promise<{
session_active: boolean;
vault_loaded: boolean;
cipher_count: number;
folder_count: number;
}> {
return this.call("get_status");
}
// Settings
async getSettings(): Promise<{ success: boolean; settings: any }> {
return this.call("get_settings");
}
async updateSettings(settings: any): Promise<{ success: boolean }> {
return this.call("update_settings", settings);
}
}
export const vaultApi = new VaultApi();
+303
View File
@@ -0,0 +1,303 @@
import { useState, useEffect } from "react";
import { ButtonItem } from "@decky/ui";
import { VaultItem } from "../api/backend";
interface ItemCardProps {
item: VaultItem;
folderMap: Record<string, string>;
copyStatus: { field: string; cipherId: string; expiresAt: number } | null;
onCopyPassword: (cipherId: string) => Promise<boolean>;
onCopyUsername: (cipherId: string) => Promise<boolean>;
onCopyTotp: (cipherId: string) => Promise<boolean>;
}
const CIPHER_TYPE_NAMES: Record<number, string> = {
1: "Login",
2: "Secure Note",
3: "Card",
4: "Identity",
};
const CIPHER_TYPE_ICONS: Record<number, string> = {
1: "\uD83D\uDD11", // key
2: "\uD83D\uDCDD", // note
3: "\uD83D\uDCB3", // card
4: "\uD83D\uDC64", // person
};
export function ItemCard({
item,
folderMap,
copyStatus,
onCopyPassword,
onCopyUsername,
onCopyTotp,
}: ItemCardProps) {
const [expanded, setExpanded] = useState(false);
const [copying, setCopying] = useState<string | null>(null);
const isCopied = (field: string) =>
copyStatus?.cipherId === item.id && copyStatus?.field === field;
const getCopyExpiresIn = () => {
if (!copyStatus) return 0;
return Math.max(0, Math.ceil(copyStatus.expiresAt - Date.now() / 1000));
};
const [expiresIn, setExpiresIn] = useState(getCopyExpiresIn());
useEffect(() => {
const interval = setInterval(() => {
setExpiresIn(getCopyExpiresIn());
}, 1000);
return () => clearInterval(interval);
}, [copyStatus]);
const handleCopy = async (field: string) => {
setCopying(field);
try {
switch (field) {
case "password":
await onCopyPassword(item.id);
break;
case "username":
await onCopyUsername(item.id);
break;
case "totp":
await onCopyTotp(item.id);
break;
}
} finally {
setCopying(null);
}
};
const folderName = item.folderId ? folderMap[item.folderId] : null;
return (
<div
style={{
background: "#1e1e1e",
borderRadius: "8px",
padding: "12px",
marginBottom: "8px",
border: expanded ? "1px solid #1a9fff" : "1px solid #333",
}}
>
{/* Header */}
<div
onClick={() => setExpanded(!expanded)}
style={{
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<span style={{ fontSize: "18px" }}>
{CIPHER_TYPE_ICONS[item.type] || "\uD83D\uDD12"}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontWeight: "bold",
fontSize: "13px",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{item.name || "[unnamed]"}
</div>
{item.login?.username && (
<div
style={{
fontSize: "11px",
color: "#888",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{item.login.username}
</div>
)}
</div>
{folderName && (
<span
style={{
fontSize: "10px",
color: "#666",
background: "#2a2a2a",
padding: "2px 6px",
borderRadius: "4px",
}}
>
{folderName}
</span>
)}
<span style={{ fontSize: "10px", color: "#666" }}>
{expanded ? "\u25B2" : "\u25BC"}
</span>
</div>
{/* Expanded content */}
{expanded && item.login && (
<div style={{ marginTop: "12px", display: "flex", flexDirection: "column", gap: "6px" }}>
{/* URI */}
{item.login.uris && item.login.uris.length > 0 && (
<div
style={{
fontSize: "10px",
color: "#666",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{item.login.uris[0].uri || ""}
</div>
)}
{/* Copy buttons */}
<div style={{ display: "flex", gap: "6px", flexWrap: "wrap" }}>
{item.login.username && (
<CopyButton
label="Username"
field="username"
isCopied={isCopied("username")}
expiresIn={isCopied("username") ? expiresIn : 0}
loading={copying === "username"}
onClick={() => handleCopy("username")}
/>
)}
{item.login.password && (
<CopyButton
label="Password"
field="password"
isCopied={isCopied("password")}
expiresIn={isCopied("password") ? expiresIn : 0}
loading={copying === "password"}
onClick={() => handleCopy("password")}
/>
)}
{item.login.totp && (
<CopyButton
label="TOTP"
field="totp"
isCopied={isCopied("totp")}
expiresIn={isCopied("totp") ? expiresIn : 0}
loading={copying === "totp"}
onClick={() => handleCopy("totp")}
/>
)}
</div>
{/* Custom fields */}
{item.fields && item.fields.length > 0 && (
<div
style={{
marginTop: "8px",
padding: "8px",
background: "#2a2a2a",
borderRadius: "4px",
}}
>
<div style={{ fontSize: "10px", color: "#888", marginBottom: "4px" }}>
Custom Fields
</div>
{item.fields.map((field, idx) => (
<div key={idx} style={{ fontSize: "11px", marginBottom: "2px" }}>
<span style={{ color: "#888" }}>{field.name}: </span>
<span style={{ color: "#ccc" }}>
{field.hidden ? "••••••••" : field.value || ""}
</span>
</div>
))}
</div>
)}
{/* Notes */}
{item.notes && (
<div
style={{
marginTop: "8px",
padding: "8px",
background: "#2a2a2a",
borderRadius: "4px",
fontSize: "11px",
color: "#888",
maxHeight: "60px",
overflow: "hidden",
}}
>
{item.notes}
</div>
)}
</div>
)}
{/* Card info */}
{expanded && item.card && (
<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>
)}
{item.card.expMonth && item.card.expYear && (
<div>
Expires: {item.card.expMonth}/{item.card.expYear}
</div>
)}
{item.card.cardholderName && (
<div>Holder: {item.card.cardholderName}</div>
)}
</div>
)}
</div>
);
}
function CopyButton({
label,
field,
isCopied,
expiresIn,
loading,
onClick,
}: {
label: string;
field: string;
isCopied: boolean;
expiresIn: number;
loading: boolean;
onClick: () => void;
}) {
return (
<button
onClick={(e) => {
e.stopPropagation();
onClick();
}}
disabled={loading}
style={{
background: isCopied ? "#2ecc71" : "#1a9fff",
color: "#fff",
border: "none",
borderRadius: "4px",
padding: "6px 10px",
fontSize: "11px",
cursor: loading ? "wait" : "pointer",
minWidth: "70px",
}}
>
{loading
? "..."
: isCopied
? `${expiresIn}s`
: label}
</button>
);
}
+137
View File
@@ -0,0 +1,137 @@
import { useState } from "react";
import {
PanelSection,
TextField,
ButtonItem,
ToggleField,
Spinner,
} from "@decky/ui";
interface LoginViewProps {
onLogin: (
serverUrl: string,
email: string,
password: string,
twoFactorToken?: string
) => Promise<boolean>;
onApiKeyLogin: (
serverUrl: string,
clientId: string,
clientSecret: string,
email: string
) => Promise<boolean>;
loading: boolean;
error: string | null;
twoFactorRequired?: boolean;
needsMasterPassword?: boolean;
}
export function LoginView({
onLogin,
onApiKeyLogin,
loading,
error,
twoFactorRequired,
needsMasterPassword,
}: LoginViewProps) {
const [authMethod, setAuthMethod] = useState<"password" | "apikey">("password");
const [serverUrl, setServerUrl] = useState("https://api.bitwarden.com");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [twoFactorCode, setTwoFactorCode] = useState("");
const [clientId, setClientId] = useState("");
const [clientSecret, setClientSecret] = useState("");
const handlePasswordLogin = async () => {
if (!email || !password) return;
await onLogin(serverUrl, email, password, twoFactorCode || undefined);
};
const handleApiKeyLogin = async () => {
if (!clientId || !clientSecret || !email) return;
await onApiKeyLogin(serverUrl, clientId, clientSecret, email);
};
return (
<PanelSection title="Vaultwarden Login">
{/* Server URL */}
<TextField
label="Server URL"
description="Bitwarden cloud: https://api.bitwarden.com | Vaultwarden: https://your-domain.com"
value={serverUrl}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setServerUrl(e.target.value)}
/>
{/* Auth Method Toggle */}
<ToggleField
label="Use API Key"
description="Toggle between password login and API key authentication"
checked={authMethod === "apikey"}
onChange={(v) => setAuthMethod(v ? "apikey" : "password")}
/>
{/* Email (common to both methods) */}
<TextField
label="Email"
value={email}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEmail(e.target.value)}
/>
{authMethod === "password" ? (
<>
<TextField
label="Master Password"
value={password}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPassword(e.target.value)}
/>
{twoFactorRequired && (
<TextField
label="Two-Factor Code"
description="Enter your 2FA code from authenticator app"
value={twoFactorCode}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTwoFactorCode(e.target.value)}
/>
)}
<ButtonItem
layout="below"
onClick={handlePasswordLogin}
disabled={loading || !email || !password}
>
{loading ? <Spinner width={16} height={16} /> : "Login with Password"}
</ButtonItem>
</>
) : (
<>
<TextField
label="Client ID"
description="From Vaultwarden Settings > Security > Keys > View API Key"
value={clientId}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setClientId(e.target.value)}
/>
<TextField
label="Client Secret"
value={clientSecret}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setClientSecret(e.target.value)}
/>
<ButtonItem
layout="below"
onClick={handleApiKeyLogin}
disabled={loading || !clientId || !clientSecret || !email}
>
{loading ? <Spinner width={16} height={16} /> : "Login with API Key"}
</ButtonItem>
</>
)}
{error && (
<div style={{ color: "#ff4444", padding: "8px", fontSize: "12px" }}>
{error}
</div>
)}
</PanelSection>
);
}
+139
View File
@@ -0,0 +1,139 @@
import { useState } from "react";
import { TextField, Spinner } from "@decky/ui";
interface PinSetupModalProps {
isOpen: boolean;
onClose: () => void;
onSetupPin: (pin: string) => Promise<boolean>;
loading: boolean;
error: string | null;
}
export function PinSetupModal({
isOpen,
onClose,
onSetupPin,
loading,
error,
}: PinSetupModalProps) {
const [pin, setPin] = useState("");
const [confirmPin, setConfirmPin] = useState("");
const [localError, setLocalError] = useState<string | null>(null);
if (!isOpen) return null;
const handleSubmit = async () => {
setLocalError(null);
if (pin.length < 4) {
setLocalError("PIN must be at least 4 characters");
return;
}
if (pin !== confirmPin) {
setLocalError("PINs do not match");
return;
}
const success = await onSetupPin(pin);
if (success) {
setPin("");
setConfirmPin("");
onClose();
}
};
return (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
background: "rgba(0, 0, 0, 0.7)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
}}
onClick={onClose}
>
<div
style={{
background: "#1e1e1e",
borderRadius: "12px",
padding: "24px",
width: "320px",
maxHeight: "80vh",
overflow: "auto",
}}
onClick={(e) => e.stopPropagation()}
>
<h3 style={{ margin: "0 0 16px 0", color: "#fff" }}>Set Up PIN</h3>
<p style={{ fontSize: "12px", color: "#888", marginBottom: "16px" }}>
A PIN allows you to unlock your vault without entering your master
password each time. The PIN protects your encryption key locally.
</p>
<TextField
label="New PIN"
value={pin}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPin(e.target.value)}
/>
<TextField
label="Confirm PIN"
value={confirmPin}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setConfirmPin(e.target.value)}
/>
<div style={{ display: "flex", gap: "8px", marginTop: "16px" }}>
<button
onClick={onClose}
style={{
flex: 1,
padding: "10px",
background: "#333",
color: "#fff",
border: "none",
borderRadius: "6px",
cursor: "pointer",
}}
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={loading || pin.length < 4}
style={{
flex: 1,
padding: "10px",
background: "#1a9fff",
color: "#fff",
border: "none",
borderRadius: "6px",
cursor: loading ? "wait" : "pointer",
}}
>
{loading ? <Spinner width={16} height={16} /> : "Set PIN"}
</button>
</div>
{(localError || error) && (
<div
style={{
color: "#ff4444",
padding: "8px",
fontSize: "12px",
marginTop: "8px",
}}
>
{localError || error}
</div>
)}
</div>
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
import { useState } from "react";
import { PanelSection, TextField, ButtonItem, Spinner } from "@decky/ui";
interface PinSetupViewProps {
onSetupPin: (pin: string) => Promise<boolean>;
loading: boolean;
error: string | null;
}
export function PinSetupView({ onSetupPin, loading, error }: PinSetupViewProps) {
const [pin, setPin] = useState("");
const [confirmPin, setConfirmPin] = useState("");
const [localError, setLocalError] = useState<string | null>(null);
const handleSubmit = async () => {
setLocalError(null);
if (pin.length < 4) {
setLocalError("PIN must be at least 4 characters");
return;
}
if (pin !== confirmPin) {
setLocalError("PINs do not match");
return;
}
const success = await onSetupPin(pin);
if (success) {
setPin("");
setConfirmPin("");
}
};
return (
<PanelSection title="Set Up PIN">
<div style={{ marginBottom: "12px", fontSize: "12px", color: "#888" }}>
A PIN allows you to unlock your vault without entering your master password
each time. The PIN protects your encryption key locally.
</div>
<TextField
label="New PIN"
value={pin}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPin(e.target.value)}
/>
<TextField
label="Confirm PIN"
value={confirmPin}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setConfirmPin(e.target.value)}
/>
<ButtonItem
layout="below"
onClick={handleSubmit}
disabled={loading || pin.length < 4}
>
{loading ? <Spinner width={16} height={16} /> : "Set Up PIN"}
</ButtonItem>
{(localError || error) && (
<div style={{ color: "#ff4444", padding: "8px", fontSize: "12px" }}>
{localError || error}
</div>
)}
</PanelSection>
);
}
+73
View File
@@ -0,0 +1,73 @@
import { useState } from "react";
import { PanelSection, TextField, ButtonItem, Spinner } from "@decky/ui";
interface PinUnlockViewProps {
onUnlock: (pin: string) => Promise<boolean>;
onLogout: () => Promise<void>;
loading: boolean;
error: string | null;
}
export function PinUnlockView({
onUnlock,
onLogout,
loading,
error,
}: PinUnlockViewProps) {
const [pin, setPin] = useState("");
const handleUnlock = async () => {
if (!pin) return;
await onUnlock(pin);
setPin("");
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
handleUnlock();
}
};
return (
<PanelSection title="Vault Locked">
<div
style={{
textAlign: "center",
padding: "16px",
marginBottom: "12px",
}}
>
<div style={{ fontSize: "24px", marginBottom: "8px" }}>&#128274;</div>
<div style={{ fontSize: "14px", color: "#ccc" }}>
Enter your PIN to unlock
</div>
</div>
<div onKeyDown={handleKeyDown}>
<TextField
label="PIN"
value={pin}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPin(e.target.value)}
/>
</div>
<ButtonItem
layout="below"
onClick={handleUnlock}
disabled={loading || !pin}
>
{loading ? <Spinner width={16} height={16} /> : "Unlock"}
</ButtonItem>
<ButtonItem layout="below" onClick={onLogout} disabled={loading}>
Login with Master Password
</ButtonItem>
{error && (
<div style={{ color: "#ff4444", padding: "8px", fontSize: "12px" }}>
{error}
</div>
)}
</PanelSection>
);
}
+170
View File
@@ -0,0 +1,170 @@
import { useState, useEffect } from "react";
import {
PanelSection,
TextField,
ButtonItem,
Spinner,
} from "@decky/ui";
import { VaultData, VaultItem, Folder } from "../api/backend";
import { ItemCard } from "./ItemCard";
interface VaultBrowserProps {
vaultData: VaultData;
searchQuery: string;
searchResults: VaultItem[] | null;
copyStatus: { field: string; cipherId: string; expiresAt: number } | null;
onSearch: (query: string) => void;
onCopyPassword: (cipherId: string) => Promise<boolean>;
onCopyUsername: (cipherId: string) => Promise<boolean>;
onCopyTotp: (cipherId: string) => Promise<boolean>;
onLock: () => void;
onSetupPin: () => void;
}
export function VaultBrowser({
vaultData,
searchQuery,
searchResults,
copyStatus,
onSearch,
onCopyPassword,
onCopyUsername,
onCopyTotp,
onLock,
onSetupPin,
}: VaultBrowserProps) {
const [selectedFolder, setSelectedFolder] = useState<string | null>(null);
const [localSearch, setLocalSearch] = useState(searchQuery);
// Get items for current view
const getDisplayedItems = (): VaultItem[] => {
if (searchResults) {
return searchResults;
}
let items = vaultData.ciphers.filter((c) => !c.error);
if (selectedFolder) {
items = items.filter((c) => c.folderId === selectedFolder);
}
// Sort by name
items.sort((a, b) => (a.name || "").localeCompare(b.name || ""));
return items;
};
const displayedItems = getDisplayedItems();
const handleSearchChange = (value: string) => {
setLocalSearch(value);
onSearch(value);
};
return (
<PanelSection title="Vault">
{/* Search */}
<TextField
label="Search"
description={`${displayedItems.length} items`}
value={localSearch}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleSearchChange(e.target.value)}
/>
{/* Folder tabs */}
{!searchQuery && (
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "4px",
marginBottom: "8px",
}}
>
<FolderTab
name="All"
count={vaultData.ciphers.filter((c) => !c.error).length}
selected={selectedFolder === null}
onClick={() => setSelectedFolder(null)}
/>
{vaultData.folders.map((folder) => (
<FolderTab
key={folder.id}
name={folder.name}
count={vaultData.ciphers.filter(
(c) => c.folderId === folder.id && !c.error
).length}
selected={selectedFolder === folder.id}
onClick={() => setSelectedFolder(folder.id)}
/>
))}
</div>
)}
{/* Items */}
<div style={{ maxHeight: "400px", overflowY: "auto" }}>
{displayedItems.length === 0 ? (
<div
style={{
textAlign: "center",
padding: "24px",
color: "#888",
}}
>
{searchQuery ? "No items found" : "No items in this folder"}
</div>
) : (
displayedItems.map((item) => (
<ItemCard
key={item.id}
item={item}
folderMap={vaultData.folder_map}
copyStatus={copyStatus}
onCopyPassword={onCopyPassword}
onCopyUsername={onCopyUsername}
onCopyTotp={onCopyTotp}
/>
))
)}
</div>
{/* Actions */}
<ButtonItem layout="below" onClick={onLock}>
Lock Vault
</ButtonItem>
<ButtonItem layout="below" onClick={onSetupPin}>
Set Up PIN
</ButtonItem>
</PanelSection>
);
}
function FolderTab({
name,
count,
selected,
onClick,
}: {
name: string;
count: number;
selected: boolean;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
style={{
background: selected ? "#1a9fff" : "#333",
color: selected ? "#fff" : "#ccc",
border: "none",
borderRadius: "4px",
padding: "4px 8px",
fontSize: "11px",
cursor: "pointer",
whiteSpace: "nowrap",
}}
>
{name} ({count})
</button>
);
}
+322
View File
@@ -0,0 +1,322 @@
import { useState, useCallback, useEffect } from "react";
import { vaultApi, VaultData, VaultItem } from "../api/backend";
export type VaultState =
| "logged_out"
| "two_factor_required"
| "needs_master_password"
| "pin_only"
| "loading"
| "unlocked";
export function useVault() {
const [state, setState] = useState<VaultState>("logged_out");
const [vaultData, setVaultData] = useState<VaultData | null>(null);
const [error, setError] = useState<string | null>(null);
const [pinEnabled, setPinEnabled] = useState(false);
const [selectedFolder, setSelectedFolder] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<VaultItem[] | null>(null);
const [copyStatus, setCopyStatus] = useState<{
field: string;
cipherId: string;
expiresAt: number;
} | null>(null);
// Check initial status
useEffect(() => {
checkStatus();
}, []);
const checkStatus = async () => {
try {
const status = await vaultApi.getStatus();
const pin = await vaultApi.isPinEnabled();
setPinEnabled(pin.enabled);
if (status.vault_loaded) {
setState("unlocked");
const result = await vaultApi.getVaultItems();
if (result.success) {
setVaultData(result.data);
}
} else if (status.session_active) {
setState("needs_master_password");
} else if (pin.enabled) {
setState("pin_only");
} else {
setState("logged_out");
}
} catch (e) {
setState("logged_out");
}
};
const loginPassword = useCallback(
async (serverUrl: string, email: string, password: string, twoFactorToken?: string) => {
setState("loading");
setError(null);
try {
const result = await vaultApi.loginPassword(
serverUrl,
email,
password,
twoFactorToken
);
if (result.two_factor_required) {
setState("two_factor_required");
return false;
}
if (result.needs_unlock) {
// Need to unlock with password
const unlockResult = await vaultApi.unlockWithMasterPassword(password);
if (unlockResult.success) {
await loadVault();
return true;
}
}
if (result.success) {
await loadVault();
return true;
}
setError(result.error || "Login failed");
setState("logged_out");
return false;
} catch (e: any) {
setError(e.message);
setState("logged_out");
return false;
}
},
[]
);
const loginApiKey = useCallback(
async (serverUrl: string, clientId: string, clientSecret: string, email: string) => {
setState("loading");
setError(null);
try {
const result = await vaultApi.loginApiKey(
serverUrl,
clientId,
clientSecret,
email
);
if (result.needs_master_password) {
setState("needs_master_password");
return true;
}
if (result.success) {
setState("needs_master_password");
return true;
}
setError(result.error || "API key login failed");
setState("logged_out");
return false;
} catch (e: any) {
setError(e.message);
setState("logged_out");
return false;
}
},
[]
);
const unlockWithPassword = useCallback(async (password: string) => {
setState("loading");
setError(null);
try {
const result = await vaultApi.unlockWithMasterPassword(password);
if (result.success) {
await loadVault();
return true;
}
setError(result.error || "Unlock failed");
setState("needs_master_password");
return false;
} catch (e: any) {
setError(e.message);
setState("needs_master_password");
return false;
}
}, []);
const unlockWithPin = useCallback(async (pin: string) => {
setState("loading");
setError(null);
try {
const result = await vaultApi.unlockWithPin(pin);
if (result.success) {
await loadVault();
return true;
}
setError(result.error || "PIN unlock failed");
setState("pin_only");
return false;
} catch (e: any) {
setError(e.message);
setState("pin_only");
return false;
}
}, []);
const loadVault = async () => {
try {
const result = await vaultApi.getVaultItems();
if (result.success) {
setVaultData(result.data);
setState("unlocked");
}
} catch (e: any) {
setError(e.message);
}
};
const search = useCallback(async (query: string) => {
setSearchQuery(query);
if (!query.trim()) {
setSearchResults(null);
return;
}
try {
const result = await vaultApi.searchVault(query);
if (result.success) {
setSearchResults(result.results);
}
} catch (e: any) {
setError(e.message);
}
}, []);
const copyPassword = useCallback(async (cipherId: string) => {
try {
const result = await vaultApi.copyPassword(cipherId);
if (result.success) {
setCopyStatus({
field: "password",
cipherId,
expiresAt: result.expires_at || Date.now() / 1000 + 60,
});
setTimeout(() => setCopyStatus(null), 60000);
return true;
}
setError(result.error || "Failed to copy");
return false;
} catch (e: any) {
setError(e.message);
return false;
}
}, []);
const copyUsername = useCallback(async (cipherId: string) => {
try {
const result = await vaultApi.copyUsername(cipherId);
if (result.success) {
setCopyStatus({
field: "username",
cipherId,
expiresAt: result.expires_at || Date.now() / 1000 + 60,
});
setTimeout(() => setCopyStatus(null), 60000);
return true;
}
setError(result.error || "Failed to copy");
return false;
} catch (e: any) {
setError(e.message);
return false;
}
}, []);
const copyTotp = useCallback(async (cipherId: string) => {
try {
const result = await vaultApi.copyTotp(cipherId);
if (result.success) {
setCopyStatus({
field: "totp",
cipherId,
expiresAt: result.expires_at || Date.now() / 1000 + 30,
});
setTimeout(() => setCopyStatus(null), 30000);
return true;
}
setError(result.error || "Failed to copy TOTP");
return false;
} catch (e: any) {
setError(e.message);
return false;
}
}, []);
const lockVault = useCallback(async () => {
await vaultApi.lockVault();
setVaultData(null);
setCopyStatus(null);
setSearchResults(null);
setSearchQuery("");
const pin = await vaultApi.isPinEnabled();
setPinEnabled(pin.enabled);
setState(pin.enabled ? "pin_only" : "logged_out");
}, []);
const logout = useCallback(async () => {
await vaultApi.logout();
setVaultData(null);
setCopyStatus(null);
setSearchResults(null);
setSearchQuery("");
setPinEnabled(false);
setState("logged_out");
}, []);
const setupPin = useCallback(async (pin: string) => {
try {
const result = await vaultApi.setupPin(pin);
if (result.success) {
setPinEnabled(true);
return true;
}
setError(result.error || "Failed to set up PIN");
return false;
} catch (e: any) {
setError(e.message);
return false;
}
}, []);
const removePin = useCallback(async () => {
try {
await vaultApi.removePin();
setPinEnabled(false);
return true;
} catch (e: any) {
setError(e.message);
return false;
}
}, []);
return {
state,
vaultData,
error,
pinEnabled,
selectedFolder,
searchQuery,
searchResults,
copyStatus,
setSelectedFolder,
loginPassword,
loginApiKey,
unlockWithPassword,
unlockWithPin,
search,
copyPassword,
copyUsername,
copyTotp,
lockVault,
logout,
setupPin,
removePin,
clearError: () => setError(null),
};
}
+223
View File
@@ -0,0 +1,223 @@
import { useEffect, useState } from "react";
import { definePlugin } from "@decky/api";
import { PanelSection, Spinner, TextField } from "@decky/ui";
import { useVault } from "./hooks/useVault";
import { LoginView } from "./components/LoginView";
import { PinUnlockView } from "./components/PinUnlockView";
import { VaultBrowser } from "./components/VaultBrowser";
import { PinSetupModal } from "./components/PinSetupModal";
function VaultwardenPlugin() {
const vault = useVault();
const [showPinSetup, setShowPinSetup] = useState(false);
// Handle keyboard input for PIN
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Lock vault on Ctrl+Shift+L
if (e.ctrlKey && e.shiftKey && e.key === "L") {
vault.lockVault();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [vault.lockVault]);
// Main render
if (vault.state === "loading") {
return (
<PanelSection title="Vaultwarden">
<div style={{ textAlign: "center", padding: "24px" }}>
<Spinner width={32} height={32} />
<div style={{ marginTop: "12px", color: "#888" }}>Loading...</div>
</div>
</PanelSection>
);
}
if (vault.state === "logged_out") {
return (
<LoginView
onLogin={vault.loginPassword}
onApiKeyLogin={vault.loginApiKey}
loading={false}
error={vault.error}
/>
);
}
if (vault.state === "two_factor_required") {
return (
<LoginView
onLogin={vault.loginPassword}
onApiKeyLogin={vault.loginApiKey}
loading={false}
error={vault.error}
twoFactorRequired
/>
);
}
if (vault.state === "needs_master_password") {
return (
<MasterPasswordPrompt
onSubmit={vault.unlockWithPassword}
loading={false}
error={vault.error}
/>
);
}
if (vault.state === "pin_only") {
return (
<PinUnlockView
onUnlock={vault.unlockWithPin}
onLogout={vault.logout}
loading={false}
error={vault.error}
/>
);
}
if (vault.state === "unlocked" && vault.vaultData) {
return (
<>
<VaultBrowser
vaultData={vault.vaultData}
searchQuery={vault.searchQuery}
searchResults={vault.searchResults}
copyStatus={vault.copyStatus}
onSearch={vault.search}
onCopyPassword={vault.copyPassword}
onCopyUsername={vault.copyUsername}
onCopyTotp={vault.copyTotp}
onLock={vault.lockVault}
onSetupPin={() => setShowPinSetup(true)}
/>
<PinSetupModal
isOpen={showPinSetup}
onClose={() => setShowPinSetup(false)}
onSetupPin={vault.setupPin}
loading={false}
error={vault.error}
/>
</>
);
}
return (
<LoginView
onLogin={vault.loginPassword}
onApiKeyLogin={vault.loginApiKey}
loading={false}
error={vault.error}
/>
);
}
function MasterPasswordPrompt({
onSubmit,
loading,
error,
}: {
onSubmit: (password: string) => Promise<boolean>;
loading: boolean;
error: string | null;
}) {
const [password, setPassword] = useState("");
const handleSubmit = async () => {
if (!password) return;
await onSubmit(password);
setPassword("");
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
handleSubmit();
}
};
return (
<PanelSection title="Unlock Vault">
<div
style={{
textAlign: "center",
padding: "16px",
marginBottom: "12px",
}}
>
<div style={{ fontSize: "24px", marginBottom: "8px" }}>&#128272;</div>
<div style={{ fontSize: "14px", color: "#ccc" }}>
Enter your master password to unlock
</div>
</div>
<div onKeyDown={handleKeyDown}>
<TextField
label="Master Password"
value={password}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPassword(e.target.value)}
/>
</div>
<button
onClick={handleSubmit}
disabled={loading || !password}
style={{
width: "100%",
padding: "10px",
background: "#1a9fff",
color: "#fff",
border: "none",
borderRadius: "6px",
fontSize: "14px",
cursor: loading ? "wait" : "pointer",
marginTop: "8px",
}}
>
{loading ? "Unlocking..." : "Unlock"}
</button>
{error && (
<div
style={{
color: "#ff4444",
padding: "8px",
fontSize: "12px",
marginTop: "8px",
}}
>
{error}
</div>
)}
</PanelSection>
);
}
export default definePlugin(() => ({
name: "Vaultwarden",
icon: (
<svg
viewBox="0 0 100 100"
style={{
width: "24px",
height: "24px",
fill: "#1a9fff",
}}
>
<path d="M50 5 L90 25 L90 75 L50 95 L10 75 L10 25 Z" />
<path
d="M50 20 L75 35 L75 65 L50 80 L25 65 L25 35 Z"
fill="#fff"
/>
</svg>
),
content: <VaultwardenPlugin />,
onDismount: () => {
console.log("Vaultwarden plugin dismounted");
},
}));