chore: init decky vaultwarden plugin scaffold
This commit is contained in:
@@ -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();
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" }}>🔒</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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" }}>🔐</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");
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user