Skip to content
Merged
7 changes: 6 additions & 1 deletion extensions/bitwarden/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Bitwarden Changelog

## [Logout] - 2022-04-06

- Added `Logout` action
- Vault Status shown while unlocking vault

## [Login Fix] - 2022-04-01

- Fix vault constantly locking
Expand Down Expand Up @@ -49,7 +54,7 @@

- Add `Lock Vault` command
- Show favorites at the top
- Automatically find Brew CLI in multiple brew directories (support Apple Silicon)
- Automatically find Bitwarden CLI in multiple brew directories (support Apple Silicon)

## [Added Bitwarden] - 2021-10-25

Expand Down
4 changes: 4 additions & 0 deletions extensions/bitwarden/assets/sf_symbols_lock.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 7 additions & 7 deletions extensions/bitwarden/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion extensions/bitwarden/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@
}
],
"dependencies": {
"@raycast/api": "^1.31.0",
"@raycast/api": "^1.32.0",
"execa": "^5.1.1",
"throttle-debounce": "^3.0.1"
},
Expand Down
10 changes: 7 additions & 3 deletions extensions/bitwarden/src/api.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import execa from "execa";
import { existsSync } from "fs";
import { dirname } from "path/posix";
import { Item, PasswordGeneratorOptions, VaultStatus } from "./types";
import { Item, PasswordGeneratorOptions, VaultState } from "./types";
import { getPasswordGeneratingArgs } from "./utils";

export class Bitwarden {
Expand Down Expand Up @@ -30,6 +30,10 @@ export class Bitwarden {
await this.exec(["login", "--apikey"]);
}

async logout(): Promise<void> {
await this.exec(["logout"]);
}

async listItems(sessionToken: string): Promise<Item[]> {
const { stdout } = await this.exec(["list", "items", "--session", sessionToken]);
const items = JSON.parse(stdout);
Expand All @@ -52,9 +56,9 @@ export class Bitwarden {
await this.exec(["lock"]);
}

async status(): Promise<VaultStatus> {
async status(): Promise<VaultState> {
const { stdout } = await this.exec(["status"]);
return JSON.parse(stdout).status;
return JSON.parse(stdout);
}

async generatePassword(options?: PasswordGeneratorOptions): Promise<string> {
Expand Down
29 changes: 23 additions & 6 deletions extensions/bitwarden/src/components.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { showToast, Form, ActionPanel, Toast, Action, Detail } from "@raycast/api";
import { OpenInBrowserAction } from "@raycast/api/types/api/components/actions/OpenInBrowserAction";
import { useEffect, useState } from "react";
import { Bitwarden } from "./api";

export function TroubleshootingGuide(): JSX.Element {
Expand All @@ -26,23 +26,39 @@ export function TroubleshootingGuide(): JSX.Element {

export function UnlockForm(props: { onUnlock: (token: string) => void; bitwardenApi: Bitwarden }): JSX.Element {
const { bitwardenApi, onUnlock } = props;
const [vaultStatus, setVaultStatus] = useState("...");

useEffect(() => {
bitwardenApi.status().then((vaultState) => {
if (vaultState.status == "unauthenticated") {
setVaultStatus("Logged out");
} else {
setVaultStatus(`Locked (${vaultState.userEmail})`);
}
});
}, []);

async function onSubmit(values: { password: string }) {
if (values.password.length == 0) {
showToast(Toast.Style.Failure, "Failed to unlock vault.", "Missing password.");
return;
}
try {
const toast = await showToast(Toast.Style.Animated, "Unlocking Vault...", "Please wait");
const status = await bitwardenApi.status();
if (status == "unauthenticated") {
const toast = await showToast(Toast.Style.Animated, "Unlocking Vault...", "Please wait.");
const state = await bitwardenApi.status();
if (state.status == "unauthenticated") {
try {
await bitwardenApi.login();
} catch (error) {
showToast(Toast.Style.Failure, "Failed to unlock vault.", "Please your API Key and Secret.");
showToast(Toast.Style.Failure, "Failed to unlock vault.", "Please check your API Key and Secret.");
return;
}
}
const sessionToken = await bitwardenApi.unlock(values.password);
toast.hide();
onUnlock(sessionToken);
} catch (error) {
showToast(Toast.Style.Failure, "Failed to unlock vault", "Invalid credentials");
showToast(Toast.Style.Failure, "Failed to unlock vault.", "Invalid credentials.");
}
}
return (
Expand All @@ -53,6 +69,7 @@ export function UnlockForm(props: { onUnlock: (token: string) => void; bitwarden
</ActionPanel>
}
>
<Form.Description title="Vault Status" text={vaultStatus} />
<Form.PasswordField id="password" title="Master Password" />
</Form>
);
Expand Down
139 changes: 87 additions & 52 deletions extensions/bitwarden/src/search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
LocalStorage,
} from "@raycast/api";
import { Item } from "./types";
import React, { Fragment, useEffect, useMemo, useState } from "react";
import { Fragment, useEffect, useMemo, useState } from "react";
import { codeBlock, titleCase, faviconUrl, extractKeywords } from "./utils";
import { Bitwarden } from "./api";
import { SESSION_KEY } from "./const";
Expand Down Expand Up @@ -94,15 +94,36 @@ export function ItemList(props: { api: Bitwarden }) {
}
}, [session.token, session.active]);

async function refreshItems() {
async function syncItems() {
if (session.token) {
const toast = await showToast(Toast.Style.Animated, "Syncing Items...");
await bitwardenApi.sync(session.token);
await loadItems(session.token);
await toast.hide();
try {
await bitwardenApi.sync(session.token);
await loadItems(session.token);
await toast.hide();
} catch (error) {
await bitwardenApi.logout();
await session.deleteToken();
toast.style = Toast.Style.Failure;
toast.message = "Failed to sync. Please try logging in again.";
}
}
}

async function lockVault() {
const toast = await showToast({ title: "Locking Vault...", style: Toast.Style.Animated });
await bitwardenApi.lock();
await session.deleteToken();
await toast.hide();
}

async function logoutVault() {
const toast = await showToast({ title: "Logging Out...", style: Toast.Style.Animated });
await bitwardenApi.logout();
await session.deleteToken();
await toast.hide();
}

if (state.isLocked) {
return (
<UnlockForm
Expand All @@ -115,41 +136,45 @@ export function ItemList(props: { api: Bitwarden }) {
);
}

const vaultEmpty = state.items.length == 0;

return (
<List isLoading={state.isLoading}>
{state.items ? (
<Fragment>
{state.items
.sort((a, b) => {
if (a.favorite && b.favorite) return 0;
return a.favorite ? -1 : 1;
})
.map((item) => (
<BitwardenItem
key={item.id}
item={item}
lockVault={async () => {
const toast = await showToast({ title: "Locking Vault...", style: Toast.Style.Animated });
await bitwardenApi.lock();
await session.deleteToken();
await toast.hide();
}}
refreshItems={refreshItems}
copyTotp={copyTotp}
/>
))}
<List.EmptyView
icon={{ source: "bitwarden-64.png" }}
title="No matching items found."
description="Hit the refresh button to sync your vault."
actions={
{state.items
.sort((a, b) => {
if (a.favorite && b.favorite) return 0;
return a.favorite ? -1 : 1;
})
.map((item) => (
<BitwardenItem
key={item.id}
item={item}
lockVault={lockVault}
logoutVault={logoutVault}
syncItems={syncItems}
copyTotp={copyTotp}
/>
))}
{state.isLoading ? (
<List.EmptyView icon={Icon.TwoArrowsClockwise} title="Loading..." description="Please wait." />
) : (
<List.EmptyView
icon={{ source: "bitwarden-64.png" }}
title={vaultEmpty ? "Vault empty." : "No matching items found."}
description={
vaultEmpty
? "Hit the sync button to sync your vault or try logging in again."
: "Hit the sync button to sync your vault."
}
actions={
!state.isLoading && (
<ActionPanel>
<Action icon={Icon.ArrowClockwise} title={"Refresh Items"} onAction={refreshItems} />
<VaultActions syncItems={syncItems} lockVault={lockVault} logoutVault={logoutVault} />
</ActionPanel>
}
/>
</Fragment>
) : undefined}
)
}
/>
)}
</List>
);
}
Expand All @@ -167,11 +192,12 @@ function getIcon(item: Item) {

function BitwardenItem(props: {
item: Item;
refreshItems?: () => void;
syncItems: () => void;
lockVault: () => void;
logoutVault: () => void;
copyTotp: (id: string) => void;
}) {
const { item, refreshItems, copyTotp, lockVault } = props;
const { item, syncItems, lockVault, logoutVault, copyTotp } = props;
const { notes, identity, login, fields, card } = item;

const keywords = useMemo(() => extractKeywords(item), [item]);
Expand All @@ -186,7 +212,9 @@ function BitwardenItem(props: {
id={item.id}
title={item.name}
keywords={keywords}
accessoryIcon={item.favorite ? { source: Icon.Star, tintColor: Color.Yellow } : undefined}
accessories={
item.favorite ? [{ icon: { source: Icon.Star, tintColor: Color.Yellow }, tooltip: "Favorite" }] : undefined
}
icon={getIcon(item)}
subtitle={item.login?.username || undefined}
actions={
Expand Down Expand Up @@ -249,18 +277,7 @@ function BitwardenItem(props: {
</ActionPanel.Submenu>
</ActionPanel.Section>
<ActionPanel.Section>
<Action
title="Refresh Items"
shortcut={{ modifiers: ["cmd"], key: "r" }}
icon={Icon.ArrowClockwise}
onAction={refreshItems}
/>
<Action
icon={Icon.XmarkCircle}
title="Lock Vault"
shortcut={{ modifiers: ["cmd", "shift"], key: "l" }}
onAction={lockVault}
/>
<VaultActions syncItems={syncItems} lockVault={lockVault} logoutVault={logoutVault} />
</ActionPanel.Section>
</ActionPanel>
}
Expand All @@ -272,7 +289,25 @@ function PasswordActions(props: { password: string }) {
const copyAction = <Action.CopyToClipboard key="copy" title="Copy Password" content={props.password} />;
const pasteAction = <Action.Paste key="paste" title="Paste Password" content={props.password} />;

return <Fragment>{primaryAction == "copy" ? [copyAction, pasteAction] : [pasteAction, copyAction]}</Fragment>;
}

function VaultActions(props: { syncItems: () => void; lockVault: () => void; logoutVault: () => void }) {
return (
<React.Fragment>{primaryAction == "copy" ? [copyAction, pasteAction] : [pasteAction, copyAction]}</React.Fragment>
<Fragment>
<Action
title="Sync Vault"
shortcut={{ modifiers: ["cmd"], key: "r" }}
icon={Icon.ArrowClockwise}
onAction={props.syncItems}
/>
<Action
icon={{ source: "sf_symbols_lock.svg", tintColor: Color.PrimaryText }} // Does not immediately follow theme
title="Lock Vault"
shortcut={{ modifiers: ["cmd", "shift"], key: "l" }}
onAction={props.lockVault}
/>
<Action title="Logout Vault" icon={Icon.XmarkCircle} onAction={props.logoutVault} />
</Fragment>
);
}
4 changes: 4 additions & 0 deletions extensions/bitwarden/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
export type VaultStatus = "unauthenticated" | "locked" | "unlocked";
export type VaultState = {
userEmail?: string;
status: VaultStatus;
};

export interface Item {
object: "item";
Expand Down