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 +0 -0
- package/dist/clients/bw.d.ts +3 -0
- package/dist/clients/bw.js +7 -2
- package/dist/components/Checkbox.d.ts +9 -0
- package/dist/components/Checkbox.js +12 -0
- package/dist/dashboard/DashboardView.js +10 -3
- package/dist/dashboard/components/MainInfoTab.js +29 -4
- package/dist/hooks/bw.d.ts +1 -0
- package/dist/hooks/bw.js +11 -4
- package/dist/login/LoginView.js +10 -6
- package/package.json +2 -1
- package/readme.md +2 -0
package/dist/cli.js
CHANGED
|
File without changes
|
package/dist/clients/bw.d.ts
CHANGED
|
@@ -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;
|
package/dist/clients/bw.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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,
|
package/dist/hooks/bw.d.ts
CHANGED
|
@@ -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", "
|
|
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
|
-
|
|
88
|
-
|
|
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: "",
|
package/dist/login/LoginView.js
CHANGED
|
@@ -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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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.
|
|
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)
|