bitty-tui 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
File without changes
@@ -34,6 +34,7 @@ export interface Cipher {
34
34
  }[];
35
35
  username?: string;
36
36
  password?: string;
37
+ totp?: string | null;
37
38
  };
38
39
  identity?: {
39
40
  address1: string | null;
@@ -111,6 +112,7 @@ export declare class Client {
111
112
  login(email: string, password: string): Promise<void>;
112
113
  checkToken(): Promise<void>;
113
114
  syncRefresh(): Promise<SyncResponse | null>;
115
+ decryptOrgKeys(): void;
114
116
  getDecryptionKey(cipher: Partial<Cipher>): Key | undefined;
115
117
  getDecryptedSync({ forceRefresh }?: {
116
118
  forceRefresh?: boolean | undefined;
@@ -145,6 +147,7 @@ export declare class Client {
145
147
  }[];
146
148
  username?: string;
147
149
  password?: string;
150
+ totp?: string | null;
148
151
  } | undefined;
149
152
  identity?: {
150
153
  address1: string | null;
@@ -323,8 +323,12 @@ export class Client {
323
323
  "bitwarden-client-version": "2025.9.0",
324
324
  },
325
325
  }).then((r) => r.json());
326
+ this.decryptOrgKeys();
327
+ return this.syncCache;
328
+ }
329
+ decryptOrgKeys() {
326
330
  if (!this.keys.privateKey)
327
- return this.syncCache;
331
+ return;
328
332
  for (const org of this.syncCache?.profile?.organizations || []) {
329
333
  if (org.id in this.orgKeys)
330
334
  continue;
@@ -333,7 +337,6 @@ export class Client {
333
337
  mac: new Uint8Array(),
334
338
  };
335
339
  }
336
- return this.syncCache;
337
340
  }
338
341
  getDecryptionKey(cipher) {
339
342
  let key = this.keys.userKey;
@@ -366,6 +369,8 @@ export class Client {
366
369
  ret.data.username = ret.login.username;
367
370
  ret.login.password = this.decrypt(cipher.login.password, key);
368
371
  ret.data.password = ret.login.password;
372
+ ret.login.totp = this.decrypt(cipher.login.totp, key);
373
+ ret.data.totp = ret.login.totp;
369
374
  ret.login.uri = this.decrypt(cipher.login.uri, key);
370
375
  ret.data.uri = ret.login.uri;
371
376
  if (cipher.login.uris?.length) {
@@ -0,0 +1,9 @@
1
+ import { Box } from "ink";
2
+ type Props = {
3
+ isActive?: boolean;
4
+ label?: string;
5
+ value: boolean;
6
+ onToggle: (value: boolean) => void;
7
+ } & React.ComponentProps<typeof Box>;
8
+ export declare const Checkbox: ({ isActive, value, label, onToggle, ...props }: Props) => import("react/jsx-runtime").JSX.Element;
9
+ export {};
@@ -0,0 +1,12 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Text, Box, useFocus, useInput } from "ink";
3
+ import { primary } from "../theme/style.js";
4
+ export const Checkbox = ({ isActive = true, value, label, onToggle, ...props }) => {
5
+ const { isFocused } = useFocus();
6
+ useInput((input, key) => {
7
+ if (input === " ") {
8
+ onToggle(!value);
9
+ }
10
+ }, { isActive: isFocused && isActive });
11
+ return (_jsxs(Box, { ...props, children: [_jsx(Box, { width: 5, height: 3, flexShrink: 0, borderStyle: "round", borderColor: isFocused && isActive ? primary : "gray", children: value && (_jsx(Box, { width: 1, height: 1, marginLeft: 1, children: _jsx(Text, { color: isFocused && isActive ? primary : "gray", children: "X" }) })) }), _jsx(Box, { marginTop: 1, marginLeft: 1, children: _jsx(Text, { children: label }) })] }));
12
+ };
@@ -9,7 +9,7 @@ import { primary } from "../theme/style.js";
9
9
  import { bwClient, clearConfig, emptyCipher, useBwSync } from "../hooks/bw.js";
10
10
  import { useStatusMessage } from "../hooks/status-message.js";
11
11
  export function DashboardView({ onLogout }) {
12
- const { sync, fetchSync } = useBwSync();
12
+ const { sync, error, fetchSync } = useBwSync();
13
13
  const [syncState, setSyncState] = useState(sync);
14
14
  const [searchQuery, setSearchQuery] = useState("");
15
15
  const [listIndex, setListIndex] = useState(0);
@@ -35,6 +35,10 @@ export function DashboardView({ onLogout }) {
35
35
  }) ?? []).sort((a, b) => a.name.localeCompare(b.name));
36
36
  }, [syncState, searchQuery]);
37
37
  const selectedCipher = detailMode === "new" ? editedCipher : filteredCiphers[listIndex];
38
+ const logout = async () => {
39
+ await clearConfig();
40
+ onLogout();
41
+ };
38
42
  useEffect(() => {
39
43
  setListIndex(0);
40
44
  }, [searchQuery]);
@@ -45,10 +49,13 @@ export function DashboardView({ onLogout }) {
45
49
  if (focusedComponent === "detail")
46
50
  focusNext();
47
51
  }, [focusedComponent]);
52
+ useEffect(() => {
53
+ if (error)
54
+ showStatusMessage(error, "error");
55
+ }, [error]);
48
56
  useInput(async (input, key) => {
49
57
  if (key.ctrl && input === "w") {
50
- await clearConfig();
51
- onLogout();
58
+ await logout();
52
59
  return;
53
60
  }
54
61
  if (input === "/" && focusedComponent !== "search") {
@@ -3,14 +3,39 @@ import { Box, Text } from "ink";
3
3
  import { CipherType } from "../../clients/bw.js";
4
4
  import { primaryLight } from "../../theme/style.js";
5
5
  import { TextInput } from "../../components/TextInput.js";
6
+ import { useEffect, useState } from "react";
7
+ import { authenticator } from "otplib";
6
8
  export function MainTab({ isFocused, selectedCipher, onChange, }) {
9
+ const [otpCode, setOtpCode] = useState("");
10
+ const [otpTimeout, setOtpTimeout] = useState(0);
11
+ useEffect(() => {
12
+ let interval = null;
13
+ if (selectedCipher?.login?.totp) {
14
+ interval = setInterval(() => {
15
+ setOtpTimeout((t) => {
16
+ if (t <= 1) {
17
+ setOtpCode(authenticator.generate(selectedCipher.login.totp));
18
+ return 30;
19
+ }
20
+ return t - 1;
21
+ });
22
+ }, 1000);
23
+ }
24
+ else {
25
+ setOtpCode("");
26
+ }
27
+ return () => {
28
+ if (interval)
29
+ clearInterval(interval);
30
+ };
31
+ }, [selectedCipher?.login?.totp]);
7
32
  return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 12, marginRight: 2, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Name:" }) }), _jsx(Box, { flexGrow: 1, paddingLeft: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: selectedCipher.name, onChange: (value) => onChange({ ...selectedCipher, name: value }) }) })] }), selectedCipher.type === CipherType.Login && (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 12, marginRight: 2, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Username:" }) }), _jsx(Box, { flexGrow: 1, paddingLeft: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: selectedCipher.login?.username ?? "", onChange: (value) => onChange({
8
33
  ...selectedCipher,
9
34
  login: { ...selectedCipher.login, username: value },
10
- }) }) })] })), selectedCipher.type === CipherType.Login && (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 12, marginRight: 2, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Password:" }) }), _jsx(Box, { flexGrow: 1, paddingLeft: 1, children: _jsx(TextInput, { inline: true, isPassword: true, showPasswordOnFocus: true, isActive: isFocused, value: selectedCipher.login?.password ?? "", onChange: (value) => onChange({
11
- ...selectedCipher,
12
- login: { ...selectedCipher.login, password: value },
13
- }) }) })] })), selectedCipher.type === CipherType.Login && (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 12, marginRight: 2, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "URL:" }) }), _jsx(Box, { flexGrow: 1, paddingLeft: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: selectedCipher.login?.uris?.[0]?.uri ?? "", onChange: (value) => onChange({
35
+ }) }) })] })), selectedCipher.type === CipherType.Login && (_jsxs(Box, { flexDirection: "row", children: [_jsxs(Box, { flexDirection: "row", flexGrow: 1, children: [_jsx(Box, { width: 12, marginRight: 2, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Password:" }) }), _jsx(Box, { flexGrow: 1, paddingLeft: 1, children: _jsx(TextInput, { inline: true, isPassword: true, showPasswordOnFocus: true, isActive: isFocused, value: selectedCipher.login?.password ?? "", onChange: (value) => onChange({
36
+ ...selectedCipher,
37
+ login: { ...selectedCipher.login, password: value },
38
+ }) }) })] }), selectedCipher.login?.totp && (_jsxs(Box, { flexDirection: "row", flexGrow: 1, children: [_jsx(Box, { marginRight: 2, flexShrink: 0, children: _jsxs(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: ["OTP (", otpTimeout.toString().padStart(2, "0"), "s):"] }) }), _jsx(Box, { flexGrow: 1, paddingLeft: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: otpCode }) })] }))] })), selectedCipher.type === CipherType.Login && (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 12, marginRight: 2, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "URL:" }) }), _jsx(Box, { flexGrow: 1, paddingLeft: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: selectedCipher.login?.uris?.[0]?.uri ?? "", onChange: (value) => onChange({
14
39
  ...selectedCipher,
15
40
  login: {
16
41
  ...selectedCipher.login,
@@ -10,6 +10,7 @@ export declare function saveConfig(config: BwConfig): Promise<void>;
10
10
  export declare function clearConfig(): Promise<void>;
11
11
  export declare const useBwSync: () => {
12
12
  sync: SyncResponse | null;
13
+ error: string | null;
13
14
  fetchSync: (forceRefresh?: boolean) => Promise<void>;
14
15
  };
15
16
  export declare const emptyCipher: any;
package/dist/hooks/bw.js CHANGED
@@ -4,7 +4,7 @@ import path from "path";
4
4
  import { CipherType, Client } from "../clients/bw.js";
5
5
  import { useCallback, useEffect, useState } from "react";
6
6
  export const bwClient = new Client();
7
- const configPath = path.join(os.homedir(), ".config", "bwtui", "config.json");
7
+ const configPath = path.join(os.homedir(), ".config", "bitty", "config.json");
8
8
  export async function loadConfig() {
9
9
  try {
10
10
  if (fs.existsSync(configPath)) {
@@ -83,14 +83,21 @@ export async function clearConfig() {
83
83
  }
84
84
  export const useBwSync = () => {
85
85
  const [sync, setSync] = useState(null);
86
+ const [error, setError] = useState(null);
86
87
  const fetchSync = useCallback(async (forceRefresh = true) => {
87
- const sync = await bwClient.getDecryptedSync({ forceRefresh });
88
- setSync(sync);
88
+ try {
89
+ setError(null);
90
+ const sync = await bwClient.getDecryptedSync({ forceRefresh });
91
+ setSync(sync);
92
+ }
93
+ catch (e) {
94
+ setError("Error fetching sync data");
95
+ }
89
96
  }, []);
90
97
  useEffect(() => {
91
98
  fetchSync();
92
99
  }, [fetchSync]);
93
- return { sync, fetchSync };
100
+ return { sync, error, fetchSync };
94
101
  };
95
102
  export const emptyCipher = {
96
103
  name: "",
@@ -6,6 +6,7 @@ import { Button } from "../components/Button.js";
6
6
  import { primary } from "../theme/style.js";
7
7
  import { bwClient, loadConfig, saveConfig } from "../hooks/bw.js";
8
8
  import { useStatusMessage } from "../hooks/status-message.js";
9
+ import { Checkbox } from "../components/Checkbox.js";
9
10
  const art = `
10
11
  ███████████ ███ ███████████ ███████████ █████ █████
11
12
  ░░███░░░░░███ ░░░ ░█░░░███░░░█░█░░░███░░░█░░███ ░░███
@@ -21,6 +22,7 @@ export function LoginView({ onLogin }) {
21
22
  const [url, setUrl] = useState("https://vault.bitwarden.eu");
22
23
  const [email, setEmail] = useState("");
23
24
  const [password, setPassword] = useState("");
25
+ const [rememberMe, setRememberMe] = useState(false);
24
26
  const { stdout } = useStdout();
25
27
  const { focusNext } = useFocusManager();
26
28
  const { statusMessage, statusMessageColor, showStatusMessage } = useStatusMessage();
@@ -38,11 +40,13 @@ export function LoginView({ onLogin }) {
38
40
  if (!bwClient.refreshToken || !bwClient.keys)
39
41
  throw new Error("Missing URL or keys after login");
40
42
  onLogin();
41
- saveConfig({
42
- baseUrl: url?.trim().length ? url.trim() : undefined,
43
- keys: bwClient.keys,
44
- refreshToken: bwClient.refreshToken,
45
- });
43
+ if (rememberMe) {
44
+ saveConfig({
45
+ baseUrl: url?.trim().length ? url.trim() : undefined,
46
+ keys: bwClient.keys,
47
+ refreshToken: bwClient.refreshToken,
48
+ });
49
+ }
46
50
  }
47
51
  catch (e) {
48
52
  showStatusMessage("Login failed, please check your credentials.", "error");
@@ -71,5 +75,5 @@ export function LoginView({ onLogin }) {
71
75
  else {
72
76
  focusNext();
73
77
  }
74
- }, isPassword: true }), _jsx(Button, { onClick: handleLogin, children: "Log In" }), statusMessage && (_jsx(Box, { marginTop: 1, width: "100%", justifyContent: "center", children: _jsx(Text, { color: statusMessageColor, children: statusMessage }) }))] }))] }));
78
+ }, isPassword: true }), _jsxs(Box, { children: [_jsx(Checkbox, { label: "Remember me (less secure)", value: rememberMe, width: "50%", onToggle: setRememberMe }), _jsx(Button, { width: "50%", onClick: handleLogin, children: "Log In" })] }), statusMessage && (_jsx(Box, { marginTop: 1, width: "100%", justifyContent: "center", children: _jsx(Text, { color: statusMessageColor, children: statusMessage }) }))] }))] }));
75
79
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bitty-tui",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "license": "MIT",
5
5
  "repository": "https://github.com/mceck/bitty",
6
6
  "keywords": [
@@ -32,6 +32,7 @@
32
32
  "chalk": "^5.6.2",
33
33
  "clipboardy": "^4.0.0",
34
34
  "ink": "^6.3.0",
35
+ "otplib": "^12.0.1",
35
36
  "react": "^19.1.0"
36
37
  },
37
38
  "devDependencies": {
package/readme.md CHANGED
@@ -15,6 +15,8 @@ Bitwarden compatible TUI for your terminal.
15
15
 
16
16
  Works also with Vaultwarden.
17
17
 
18
+ If you check "Remember me" during login, your vault encryption keys will be stored in plain text in your home folder (`$HOME/.config/bitty/config.json`). Use this option only if you are the only user of your machine.
19
+
18
20
  ## Acknowledgments
19
21
 
20
22
  - [Bitwarden](https://github.com/bitwarden)