feat(auth): add PIN session persistence, vault sync, and pre-fill login credentials
This commit is contained in:
@@ -124,6 +124,10 @@ class VaultApi {
|
||||
return this.call("get_vault_items");
|
||||
}
|
||||
|
||||
async syncVault(): Promise<{ success: boolean; vault_size?: number; error?: string }> {
|
||||
return this.call("sync_vault");
|
||||
}
|
||||
|
||||
async searchVault(query: string): Promise<{ success: boolean; results: VaultItem[] }> {
|
||||
return this.call("search_vault", query);
|
||||
}
|
||||
@@ -188,6 +192,15 @@ class VaultApi {
|
||||
async updateSettings(settings: any): Promise<{ success: boolean }> {
|
||||
return this.call("update_settings", settings);
|
||||
}
|
||||
|
||||
async getSavedCredentials(): Promise<{
|
||||
success: boolean;
|
||||
server_url: string;
|
||||
email: string;
|
||||
pin_enabled: boolean;
|
||||
}> {
|
||||
return this.call("get_saved_credentials");
|
||||
}
|
||||
}
|
||||
|
||||
export const vaultApi = new VaultApi();
|
||||
|
||||
@@ -24,6 +24,8 @@ interface LoginViewProps {
|
||||
error: string | null;
|
||||
twoFactorRequired?: boolean;
|
||||
needsMasterPassword?: boolean;
|
||||
defaultServerUrl?: string;
|
||||
defaultEmail?: string;
|
||||
}
|
||||
|
||||
export function LoginView({
|
||||
@@ -33,10 +35,12 @@ export function LoginView({
|
||||
error,
|
||||
twoFactorRequired,
|
||||
needsMasterPassword,
|
||||
defaultServerUrl,
|
||||
defaultEmail,
|
||||
}: LoginViewProps) {
|
||||
const [authMethod, setAuthMethod] = useState<"password" | "apikey">("password");
|
||||
const [serverUrl, setServerUrl] = useState("https://api.bitwarden.com");
|
||||
const [email, setEmail] = useState("");
|
||||
const [serverUrl, setServerUrl] = useState(defaultServerUrl || "https://api.bitwarden.com");
|
||||
const [email, setEmail] = useState(defaultEmail || "");
|
||||
const [password, setPassword] = useState("");
|
||||
const [twoFactorCode, setTwoFactorCode] = useState("");
|
||||
const [clientId, setClientId] = useState("");
|
||||
|
||||
@@ -3,11 +3,12 @@ import { PanelSection, TextField, ButtonItem, Spinner } from "@decky/ui";
|
||||
|
||||
interface PinSetupViewProps {
|
||||
onSetupPin: (pin: string) => Promise<boolean>;
|
||||
onBack: () => void;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function PinSetupView({ onSetupPin, loading, error }: PinSetupViewProps) {
|
||||
export function PinSetupView({ onSetupPin, onBack, loading, error }: PinSetupViewProps) {
|
||||
const [pin, setPin] = useState("");
|
||||
const [confirmPin, setConfirmPin] = useState("");
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
@@ -29,6 +30,7 @@ export function PinSetupView({ onSetupPin, loading, error }: PinSetupViewProps)
|
||||
if (success) {
|
||||
setPin("");
|
||||
setConfirmPin("");
|
||||
onBack();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -59,6 +61,14 @@ export function PinSetupView({ onSetupPin, loading, error }: PinSetupViewProps)
|
||||
{loading ? <Spinner width={16} height={16} /> : "Set Up PIN"}
|
||||
</ButtonItem>
|
||||
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
onClick={onBack}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</ButtonItem>
|
||||
|
||||
{(localError || error) && (
|
||||
<div style={{ color: "#ff4444", padding: "8px", fontSize: "12px" }}>
|
||||
{localError || error}
|
||||
|
||||
@@ -60,7 +60,7 @@ export function PinUnlockView({
|
||||
</ButtonItem>
|
||||
|
||||
<ButtonItem layout="below" onClick={onLogout} disabled={loading}>
|
||||
Login with Master Password
|
||||
Login as someone else
|
||||
</ButtonItem>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -19,6 +19,7 @@ interface VaultBrowserProps {
|
||||
onCopyTotp: (cipherId: string) => Promise<boolean>;
|
||||
onLock: () => void;
|
||||
onSetupPin: () => void;
|
||||
onSync: () => void;
|
||||
}
|
||||
|
||||
export function VaultBrowser({
|
||||
@@ -32,6 +33,7 @@ export function VaultBrowser({
|
||||
onCopyTotp,
|
||||
onLock,
|
||||
onSetupPin,
|
||||
onSync,
|
||||
}: VaultBrowserProps) {
|
||||
const [selectedFolder, setSelectedFolder] = useState<string | null>(null);
|
||||
const [localSearch, setLocalSearch] = useState(searchQuery);
|
||||
@@ -63,81 +65,89 @@ export function VaultBrowser({
|
||||
|
||||
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 */}
|
||||
<Focusable
|
||||
flow-children="y"
|
||||
style={{ maxHeight: "400px", overflowY: "auto" }}
|
||||
onSecondaryButton={onSync}
|
||||
onSecondaryActionDescription="Sync Vault"
|
||||
>
|
||||
{displayedItems.length === 0 ? (
|
||||
{/* Search */}
|
||||
<TextField
|
||||
label="Search"
|
||||
description={`${displayedItems.length} items`}
|
||||
value={localSearch}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleSearchChange(e.target.value)}
|
||||
/>
|
||||
|
||||
{/* Folder tabs */}
|
||||
{!searchQuery && (
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
padding: "24px",
|
||||
color: "#888",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "4px",
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
>
|
||||
{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}
|
||||
<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>
|
||||
)}
|
||||
</Focusable>
|
||||
|
||||
{/* Actions */}
|
||||
<ButtonItem layout="below" onClick={onLock}>
|
||||
Lock Vault
|
||||
</ButtonItem>
|
||||
<ButtonItem layout="below" onClick={onSetupPin}>
|
||||
Set Up PIN
|
||||
</ButtonItem>
|
||||
{/* Items */}
|
||||
<Focusable
|
||||
flow-children="y"
|
||||
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}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Focusable>
|
||||
|
||||
{/* Actions */}
|
||||
<ButtonItem layout="below" onClick={onSync}>
|
||||
Sync Vault
|
||||
</ButtonItem>
|
||||
<ButtonItem layout="below" onClick={onLock}>
|
||||
Lock Vault
|
||||
</ButtonItem>
|
||||
<ButtonItem layout="below" onClick={onSetupPin}>
|
||||
Set Up PIN
|
||||
</ButtonItem>
|
||||
</Focusable>
|
||||
</PanelSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export type VaultState =
|
||||
| "two_factor_required"
|
||||
| "needs_master_password"
|
||||
| "pin_only"
|
||||
| "pin_setup"
|
||||
| "loading"
|
||||
| "unlocked";
|
||||
|
||||
@@ -22,6 +23,10 @@ export function useVault() {
|
||||
cipherId: string;
|
||||
expiresAt: number;
|
||||
} | null>(null);
|
||||
const [savedCredentials, setSavedCredentials] = useState<{
|
||||
serverUrl: string;
|
||||
email: string;
|
||||
} | null>(null);
|
||||
|
||||
// Check initial status
|
||||
useEffect(() => {
|
||||
@@ -47,6 +52,19 @@ export function useVault() {
|
||||
} else {
|
||||
setState("logged_out");
|
||||
}
|
||||
|
||||
// Fetch saved credentials for pre-filling login form
|
||||
try {
|
||||
const creds = await vaultApi.getSavedCredentials();
|
||||
if (creds.success && (creds.email || creds.server_url)) {
|
||||
setSavedCredentials({
|
||||
serverUrl: creds.server_url,
|
||||
email: creds.email,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors fetching saved credentials
|
||||
}
|
||||
} catch (e) {
|
||||
setState("logged_out");
|
||||
}
|
||||
@@ -336,6 +354,27 @@ export function useVault() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const enterPinSetup = useCallback(() => {
|
||||
setState("pin_setup");
|
||||
}, []);
|
||||
|
||||
const syncVault = useCallback(async () => {
|
||||
setState("loading");
|
||||
setError(null);
|
||||
try {
|
||||
const syncResult = await vaultApi.syncVault();
|
||||
if (syncResult.success) {
|
||||
await loadVault();
|
||||
} else {
|
||||
setError(syncResult.error || "Sync failed");
|
||||
setState("unlocked");
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e.message || "Sync failed");
|
||||
setState("unlocked");
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
state,
|
||||
vaultData,
|
||||
@@ -345,6 +384,7 @@ export function useVault() {
|
||||
searchQuery,
|
||||
searchResults,
|
||||
copyStatus,
|
||||
savedCredentials,
|
||||
setSelectedFolder,
|
||||
loginPassword,
|
||||
loginApiKey,
|
||||
@@ -358,6 +398,8 @@ export function useVault() {
|
||||
logout,
|
||||
setupPin,
|
||||
removePin,
|
||||
enterPinSetup,
|
||||
syncVault,
|
||||
clearError: () => setError(null),
|
||||
};
|
||||
}
|
||||
|
||||
+31
-24
@@ -6,11 +6,10 @@ 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";
|
||||
import { PinSetupView } from "./components/PinSetupView";
|
||||
|
||||
function VaultwardenPlugin() {
|
||||
const vault = useVault();
|
||||
const [showPinSetup, setShowPinSetup] = useState(false);
|
||||
|
||||
// Handle keyboard input for PIN
|
||||
useEffect(() => {
|
||||
@@ -44,6 +43,8 @@ function VaultwardenPlugin() {
|
||||
onApiKeyLogin={vault.loginApiKey}
|
||||
loading={false}
|
||||
error={vault.error}
|
||||
defaultServerUrl={vault.savedCredentials?.serverUrl}
|
||||
defaultEmail={vault.savedCredentials?.email}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -56,6 +57,8 @@ function VaultwardenPlugin() {
|
||||
loading={false}
|
||||
error={vault.error}
|
||||
twoFactorRequired
|
||||
defaultServerUrl={vault.savedCredentials?.serverUrl}
|
||||
defaultEmail={vault.savedCredentials?.email}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -81,30 +84,32 @@ function VaultwardenPlugin() {
|
||||
);
|
||||
}
|
||||
|
||||
if (vault.state === "pin_setup") {
|
||||
return (
|
||||
<PinSetupView
|
||||
onSetupPin={vault.setupPin}
|
||||
onBack={() => vault.lockVault()}
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
<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={vault.enterPinSetup}
|
||||
onSync={vault.syncVault}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -114,6 +119,8 @@ function VaultwardenPlugin() {
|
||||
onApiKeyLogin={vault.loginApiKey}
|
||||
loading={false}
|
||||
error={vault.error}
|
||||
defaultServerUrl={vault.savedCredentials?.serverUrl}
|
||||
defaultEmail={vault.savedCredentials?.email}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user