bitty-tui 0.0.17 → 0.0.19
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 +2 -1
- package/dist/clients/bw.d.ts +27 -1
- package/dist/clients/bw.js +107 -2
- package/dist/components/Button.d.ts +2 -1
- package/dist/components/Button.js +38 -15
- package/dist/components/Checkbox.js +9 -2
- package/dist/components/ScrollView.d.ts +2 -1
- package/dist/components/ScrollView.js +3 -1
- package/dist/components/TabButton.d.ts +9 -0
- package/dist/components/TabButton.js +11 -0
- package/dist/components/TextInput.js +9 -4
- package/dist/dashboard/DashboardView.js +68 -7
- package/dist/dashboard/components/CipherDetail.d.ts +9 -2
- package/dist/dashboard/components/CipherDetail.js +3 -5
- package/dist/dashboard/components/CollectionsTab.d.ts +7 -0
- package/dist/dashboard/components/CollectionsTab.js +48 -0
- package/dist/dashboard/components/MainInfoTab.js +4 -29
- package/dist/dashboard/components/MainTab.d.ts +4 -2
- package/dist/dashboard/components/MainTab.js +97 -58
- package/dist/dashboard/components/MoreInfoTab.d.ts +6 -1
- package/dist/dashboard/components/MoreInfoTab.js +79 -4
- package/dist/dashboard/components/VaultList.js +15 -1
- package/dist/hooks/bw.d.ts +2 -1
- package/dist/hooks/bw.js +56 -11
- package/dist/hooks/use-mouse.d.ts +11 -0
- package/dist/hooks/use-mouse.js +124 -0
- package/package.json +3 -3
- package/readme.md +0 -3
package/dist/cli.js
CHANGED
|
@@ -3,6 +3,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
3
3
|
import { render } from "ink";
|
|
4
4
|
import App from "./app.js";
|
|
5
5
|
import { StatusMessageProvider } from "./hooks/status-message.js";
|
|
6
|
+
import { MouseProvider } from "./hooks/use-mouse.js";
|
|
6
7
|
import { readPackageUpSync } from "read-package-up";
|
|
7
8
|
import { art } from "./theme/art.js";
|
|
8
9
|
import path from "node:path";
|
|
@@ -26,4 +27,4 @@ if (args.includes("--help") || args.includes("-h")) {
|
|
|
26
27
|
`);
|
|
27
28
|
process.exit(0);
|
|
28
29
|
}
|
|
29
|
-
render(_jsx(StatusMessageProvider, { children: _jsx(App, {}) }));
|
|
30
|
+
render(_jsx(StatusMessageProvider, { children: _jsx(MouseProvider, { children: _jsx(App, {}) }) }));
|
package/dist/clients/bw.d.ts
CHANGED
|
@@ -45,7 +45,7 @@ export interface Cipher {
|
|
|
45
45
|
type: CipherType;
|
|
46
46
|
key?: string | null;
|
|
47
47
|
folderId?: string | null;
|
|
48
|
-
organizationId
|
|
48
|
+
organizationId?: string | null;
|
|
49
49
|
collectionIds?: string[] | null;
|
|
50
50
|
deletedDate: string | null;
|
|
51
51
|
name: string;
|
|
@@ -63,6 +63,14 @@ export interface Cipher {
|
|
|
63
63
|
totp?: string | null;
|
|
64
64
|
currentTotp?: string | null;
|
|
65
65
|
};
|
|
66
|
+
card?: {
|
|
67
|
+
cardholderName: string | null;
|
|
68
|
+
brand: string | null;
|
|
69
|
+
number: string | null;
|
|
70
|
+
expMonth: string | null;
|
|
71
|
+
expYear: string | null;
|
|
72
|
+
code: string | null;
|
|
73
|
+
};
|
|
66
74
|
identity?: {
|
|
67
75
|
address1: string | null;
|
|
68
76
|
address2: string | null;
|
|
@@ -94,6 +102,12 @@ export interface Cipher {
|
|
|
94
102
|
type: number;
|
|
95
103
|
}[];
|
|
96
104
|
}
|
|
105
|
+
export interface Collection {
|
|
106
|
+
id: string;
|
|
107
|
+
organizationId: string;
|
|
108
|
+
name: string;
|
|
109
|
+
readOnly: boolean;
|
|
110
|
+
}
|
|
97
111
|
export type CipherDto = Omit<Cipher, "id" | "data">;
|
|
98
112
|
export declare enum KdfType {
|
|
99
113
|
PBKDF2 = 0,
|
|
@@ -101,6 +115,7 @@ export declare enum KdfType {
|
|
|
101
115
|
}
|
|
102
116
|
export interface SyncResponse {
|
|
103
117
|
ciphers: Cipher[];
|
|
118
|
+
collections?: Collection[];
|
|
104
119
|
profile?: {
|
|
105
120
|
organizations?: {
|
|
106
121
|
id: string;
|
|
@@ -184,6 +199,9 @@ export declare class Client {
|
|
|
184
199
|
createSecret(obj: CipherDto): Promise<any>;
|
|
185
200
|
objectDiff(obj1: any, obj2: any): any;
|
|
186
201
|
updateSecret(id: string, patch: Partial<CipherDto>): Promise<any>;
|
|
202
|
+
deleteSecret(id: string): Promise<void>;
|
|
203
|
+
shareCipher(id: string, cipher: Partial<CipherDto>, collectionIds: string[]): Promise<any>;
|
|
204
|
+
updateCollections(id: string, collectionIds: string[]): Promise<any>;
|
|
187
205
|
encrypt(value: string | null, key?: any): string;
|
|
188
206
|
decrypt(value: string | null | undefined, key?: any): string | null | undefined;
|
|
189
207
|
encryptCipher(obj: Partial<CipherDto>, key?: any): {
|
|
@@ -208,6 +226,14 @@ export declare class Client {
|
|
|
208
226
|
totp?: string | null;
|
|
209
227
|
currentTotp?: string | null;
|
|
210
228
|
} | undefined;
|
|
229
|
+
card?: {
|
|
230
|
+
cardholderName: string | null;
|
|
231
|
+
brand: string | null;
|
|
232
|
+
number: string | null;
|
|
233
|
+
expMonth: string | null;
|
|
234
|
+
expYear: string | null;
|
|
235
|
+
code: string | null;
|
|
236
|
+
} | undefined;
|
|
211
237
|
identity?: {
|
|
212
238
|
address1: string | null;
|
|
213
239
|
address2: string | null;
|
package/dist/clients/bw.js
CHANGED
|
@@ -504,6 +504,13 @@ export class Client {
|
|
|
504
504
|
}
|
|
505
505
|
this.decryptedSyncCache = {
|
|
506
506
|
...this.syncCache,
|
|
507
|
+
collections: this.syncCache.collections?.map((col) => {
|
|
508
|
+
const orgKey = this.orgKeys[col.organizationId];
|
|
509
|
+
return {
|
|
510
|
+
...col,
|
|
511
|
+
name: orgKey ? this.decrypt(col.name, orgKey) ?? col.name : col.name,
|
|
512
|
+
};
|
|
513
|
+
}),
|
|
507
514
|
ciphers: this.syncCache.ciphers.map((cipher) => {
|
|
508
515
|
const key = this.getDecryptionKey(cipher);
|
|
509
516
|
const ret = JSON.parse(JSON.stringify(cipher));
|
|
@@ -555,6 +562,17 @@ export class Client {
|
|
|
555
562
|
this.decrypt(cipher.identity.username, key),
|
|
556
563
|
};
|
|
557
564
|
}
|
|
565
|
+
if (cipher.card) {
|
|
566
|
+
ret.card = {
|
|
567
|
+
cardholderName: cipher.card.cardholderName &&
|
|
568
|
+
this.decrypt(cipher.card.cardholderName, key),
|
|
569
|
+
brand: cipher.card.brand && this.decrypt(cipher.card.brand, key),
|
|
570
|
+
number: cipher.card.number && this.decrypt(cipher.card.number, key),
|
|
571
|
+
expMonth: cipher.card.expMonth && this.decrypt(cipher.card.expMonth, key),
|
|
572
|
+
expYear: cipher.card.expYear && this.decrypt(cipher.card.expYear, key),
|
|
573
|
+
code: cipher.card.code && this.decrypt(cipher.card.code, key),
|
|
574
|
+
};
|
|
575
|
+
}
|
|
558
576
|
if (cipher.sshKey) {
|
|
559
577
|
ret.sshKey = {
|
|
560
578
|
keyFingerprint: cipher.sshKey.keyFingerprint &&
|
|
@@ -602,13 +620,21 @@ export class Client {
|
|
|
602
620
|
}
|
|
603
621
|
async createSecret(obj) {
|
|
604
622
|
const key = this.getDecryptionKey(obj);
|
|
605
|
-
const
|
|
623
|
+
const encrypted = this.encryptCipher(obj, key);
|
|
624
|
+
const hasCollections = obj.collectionIds?.length;
|
|
625
|
+
const url = hasCollections
|
|
626
|
+
? `${this.apiUrl}/ciphers/create`
|
|
627
|
+
: `${this.apiUrl}/ciphers`;
|
|
628
|
+
const body = hasCollections
|
|
629
|
+
? { cipher: encrypted, collectionIds: obj.collectionIds }
|
|
630
|
+
: encrypted;
|
|
631
|
+
const s = await fetchApi(url, {
|
|
606
632
|
method: "POST",
|
|
607
633
|
headers: {
|
|
608
634
|
Authorization: `Bearer ${this.token}`,
|
|
609
635
|
"Content-Type": "application/json",
|
|
610
636
|
},
|
|
611
|
-
body: JSON.stringify(
|
|
637
|
+
body: JSON.stringify(body),
|
|
612
638
|
});
|
|
613
639
|
return s.json();
|
|
614
640
|
}
|
|
@@ -661,6 +687,60 @@ export class Client {
|
|
|
661
687
|
this.syncCache = null;
|
|
662
688
|
return s.json();
|
|
663
689
|
}
|
|
690
|
+
async deleteSecret(id) {
|
|
691
|
+
await this.checkToken();
|
|
692
|
+
await fetchApi(`${this.apiUrl}/ciphers/${id}`, {
|
|
693
|
+
method: "DELETE",
|
|
694
|
+
headers: {
|
|
695
|
+
Authorization: `Bearer ${this.token}`,
|
|
696
|
+
},
|
|
697
|
+
});
|
|
698
|
+
this.decryptedSyncCache = null;
|
|
699
|
+
this.syncCache = null;
|
|
700
|
+
}
|
|
701
|
+
async shareCipher(id, cipher, collectionIds) {
|
|
702
|
+
if (!collectionIds.length) {
|
|
703
|
+
throw new Error("At least one collection is required to share a cipher");
|
|
704
|
+
}
|
|
705
|
+
await this.getDecryptedSync();
|
|
706
|
+
const original = this.syncCache?.ciphers.find((c) => c.id === id);
|
|
707
|
+
if (!original) {
|
|
708
|
+
throw new Error("Secret not found in cache. Please sync first.");
|
|
709
|
+
}
|
|
710
|
+
const key = this.getDecryptionKey(cipher);
|
|
711
|
+
const encrypted = this.encryptCipher(cipher, key);
|
|
712
|
+
const data = this.patchObject(original, encrypted);
|
|
713
|
+
data.data = undefined;
|
|
714
|
+
await this.checkToken();
|
|
715
|
+
const s = await fetchApi(`${this.apiUrl}/ciphers/${id}/share`, {
|
|
716
|
+
method: "PUT",
|
|
717
|
+
headers: {
|
|
718
|
+
Authorization: `Bearer ${this.token}`,
|
|
719
|
+
"Content-Type": "application/json",
|
|
720
|
+
},
|
|
721
|
+
body: JSON.stringify({ cipher: data, collectionIds }),
|
|
722
|
+
});
|
|
723
|
+
this.decryptedSyncCache = null;
|
|
724
|
+
this.syncCache = null;
|
|
725
|
+
return s.json();
|
|
726
|
+
}
|
|
727
|
+
async updateCollections(id, collectionIds) {
|
|
728
|
+
if (!collectionIds.length) {
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
await this.checkToken();
|
|
732
|
+
const s = await fetchApi(`${this.apiUrl}/ciphers/${id}/collections_v2`, {
|
|
733
|
+
method: "PUT",
|
|
734
|
+
headers: {
|
|
735
|
+
Authorization: `Bearer ${this.token}`,
|
|
736
|
+
"Content-Type": "application/json",
|
|
737
|
+
},
|
|
738
|
+
body: JSON.stringify({ collectionIds }),
|
|
739
|
+
});
|
|
740
|
+
this.decryptedSyncCache = null;
|
|
741
|
+
this.syncCache = null;
|
|
742
|
+
return s.json();
|
|
743
|
+
}
|
|
664
744
|
encrypt(value, key) {
|
|
665
745
|
if (!value)
|
|
666
746
|
return value;
|
|
@@ -752,6 +832,28 @@ export class Client {
|
|
|
752
832
|
: ret.identity.username,
|
|
753
833
|
};
|
|
754
834
|
}
|
|
835
|
+
if (ret.card) {
|
|
836
|
+
ret.card = {
|
|
837
|
+
cardholderName: ret.card.cardholderName
|
|
838
|
+
? this.encrypt(ret.card.cardholderName, key)
|
|
839
|
+
: ret.card.cardholderName,
|
|
840
|
+
brand: ret.card.brand
|
|
841
|
+
? this.encrypt(ret.card.brand, key)
|
|
842
|
+
: ret.card.brand,
|
|
843
|
+
number: ret.card.number
|
|
844
|
+
? this.encrypt(ret.card.number, key)
|
|
845
|
+
: ret.card.number,
|
|
846
|
+
expMonth: ret.card.expMonth
|
|
847
|
+
? this.encrypt(ret.card.expMonth, key)
|
|
848
|
+
: ret.card.expMonth,
|
|
849
|
+
expYear: ret.card.expYear
|
|
850
|
+
? this.encrypt(ret.card.expYear, key)
|
|
851
|
+
: ret.card.expYear,
|
|
852
|
+
code: ret.card.code
|
|
853
|
+
? this.encrypt(ret.card.code, key)
|
|
854
|
+
: ret.card.code,
|
|
855
|
+
};
|
|
856
|
+
}
|
|
755
857
|
if (ret.login) {
|
|
756
858
|
ret.login = {
|
|
757
859
|
...ret.login,
|
|
@@ -761,6 +863,9 @@ export class Client {
|
|
|
761
863
|
password: ret.login.password
|
|
762
864
|
? this.encrypt(ret.login.password, key)
|
|
763
865
|
: ret.login.password,
|
|
866
|
+
totp: ret.login.totp
|
|
867
|
+
? this.encrypt(ret.login.totp, key)
|
|
868
|
+
: ret.login.totp,
|
|
764
869
|
uri: ret.login.uri ? this.encrypt(ret.login.uri, key) : ret.login.uri,
|
|
765
870
|
uris: ret.login.uris?.map((uri) => ({
|
|
766
871
|
uri: uri.uri ? this.encrypt(uri.uri, key) : uri.uri,
|
|
@@ -3,9 +3,10 @@ import { ReactNode } from "react";
|
|
|
3
3
|
type Props = {
|
|
4
4
|
isActive?: boolean;
|
|
5
5
|
doubleConfirm?: boolean;
|
|
6
|
+
tripleConfirm?: boolean;
|
|
6
7
|
autoFocus?: boolean;
|
|
7
8
|
onClick: () => void;
|
|
8
9
|
children: ReactNode;
|
|
9
10
|
} & React.ComponentProps<typeof Box>;
|
|
10
|
-
export declare const Button: ({ isActive, doubleConfirm, onClick, children, autoFocus, ...props }: Props) => import("react/jsx-runtime").JSX.Element;
|
|
11
|
+
export declare const Button: ({ isActive, doubleConfirm, tripleConfirm, onClick, children, autoFocus, ...props }: Props) => import("react/jsx-runtime").JSX.Element;
|
|
11
12
|
export {};
|
|
@@ -1,24 +1,47 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { Text, Box, useFocus, useInput } from "ink";
|
|
3
|
-
import { useRef, useState } from "react";
|
|
3
|
+
import { useId, useRef, useState } from "react";
|
|
4
4
|
import { primary } from "../theme/style.js";
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
import { useMouseTarget } from "../hooks/use-mouse.js";
|
|
6
|
+
export const Button = ({ isActive = true, doubleConfirm, tripleConfirm, onClick, children, autoFocus = false, ...props }) => {
|
|
7
|
+
const generatedId = useId();
|
|
8
|
+
const { isFocused } = useFocus({ id: generatedId, autoFocus: autoFocus });
|
|
7
9
|
const [askConfirm, setAskConfirm] = useState(false);
|
|
10
|
+
const [ask2Confirm, setAsk2Confirm] = useState(false);
|
|
8
11
|
const timeoutRef = useRef(null);
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
12
|
+
const boxRef = useRef(null);
|
|
13
|
+
const handlePress = () => {
|
|
14
|
+
if (timeoutRef.current)
|
|
15
|
+
clearTimeout(timeoutRef.current);
|
|
16
|
+
if ((doubleConfirm || tripleConfirm) && !askConfirm) {
|
|
17
|
+
setAskConfirm(true);
|
|
18
|
+
timeoutRef.current = setTimeout(() => setAskConfirm(false), 1000);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (tripleConfirm && !ask2Confirm) {
|
|
22
|
+
setAsk2Confirm(true);
|
|
23
|
+
timeoutRef.current = setTimeout(() => {
|
|
19
24
|
setAskConfirm(false);
|
|
20
|
-
|
|
25
|
+
setAsk2Confirm(false);
|
|
26
|
+
}, 1000);
|
|
27
|
+
return;
|
|
21
28
|
}
|
|
29
|
+
if (askConfirm)
|
|
30
|
+
setAskConfirm(false);
|
|
31
|
+
if (ask2Confirm)
|
|
32
|
+
setAsk2Confirm(false);
|
|
33
|
+
onClick();
|
|
34
|
+
};
|
|
35
|
+
useMouseTarget(generatedId, boxRef, { onClick: handlePress });
|
|
36
|
+
useInput((input, key) => {
|
|
37
|
+
if (key.return)
|
|
38
|
+
handlePress();
|
|
22
39
|
}, { isActive: isFocused && isActive });
|
|
23
|
-
return (_jsx(Box, { borderStyle: "round", borderColor: isFocused && isActive ? primary : "gray", alignItems: "center", justifyContent: "center", ...props, children: _jsx(Text, { color: isFocused && isActive
|
|
40
|
+
return (_jsx(Box, { ref: boxRef, borderStyle: "round", borderColor: isFocused && isActive ? primary : "gray", alignItems: "center", justifyContent: "center", ...props, children: _jsx(Text, { color: isFocused && isActive
|
|
41
|
+
? ask2Confirm
|
|
42
|
+
? "red"
|
|
43
|
+
: askConfirm
|
|
44
|
+
? "yellow"
|
|
45
|
+
: "white"
|
|
46
|
+
: "gray", children: ask2Confirm ? "Are you sure?" : askConfirm ? "Confirm?" : children }) }));
|
|
24
47
|
};
|
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Text, Box, useFocus, useInput } from "ink";
|
|
3
|
+
import { useId, useRef } from "react";
|
|
3
4
|
import { primary } from "../theme/style.js";
|
|
5
|
+
import { useMouseTarget } from "../hooks/use-mouse.js";
|
|
4
6
|
export const Checkbox = ({ isActive = true, value, label, onToggle, ...props }) => {
|
|
5
|
-
const
|
|
7
|
+
const generatedId = useId();
|
|
8
|
+
const { isFocused } = useFocus({ id: generatedId });
|
|
9
|
+
const boxRef = useRef(null);
|
|
10
|
+
useMouseTarget(generatedId, boxRef, {
|
|
11
|
+
onClick: () => onToggle(!value),
|
|
12
|
+
});
|
|
6
13
|
useInput((input, key) => {
|
|
7
14
|
if (input === " ") {
|
|
8
15
|
onToggle(!value);
|
|
9
16
|
}
|
|
10
17
|
}, { 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 }) })] }));
|
|
18
|
+
return (_jsxs(Box, { ref: boxRef, ...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
19
|
};
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { ReactNode } from "react";
|
|
2
|
-
export declare const ScrollView: <T>({ count, list, isActive, selectedIndex, onSelect, onSubmit, children, }: {
|
|
2
|
+
export declare const ScrollView: <T>({ count, list, isActive, selectedIndex, onSelect, onSubmit, offsetRef, children, }: {
|
|
3
3
|
count: number;
|
|
4
4
|
list: T[];
|
|
5
5
|
isActive: boolean;
|
|
6
6
|
selectedIndex: number;
|
|
7
7
|
onSelect?: (position: number) => void;
|
|
8
8
|
onSubmit?: (position: number) => void;
|
|
9
|
+
offsetRef?: React.MutableRefObject<number>;
|
|
9
10
|
children: (arg: {
|
|
10
11
|
el: T;
|
|
11
12
|
index: number;
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { Box, useInput } from "ink";
|
|
3
3
|
import { useEffect, useState } from "react";
|
|
4
|
-
export const ScrollView = ({ count, list, isActive, selectedIndex, onSelect, onSubmit, children, }) => {
|
|
4
|
+
export const ScrollView = ({ count, list, isActive, selectedIndex, onSelect, onSubmit, offsetRef, children, }) => {
|
|
5
5
|
const [offset, setOffset] = useState(0);
|
|
6
|
+
if (offsetRef)
|
|
7
|
+
offsetRef.current = offset;
|
|
6
8
|
useInput((input, key) => {
|
|
7
9
|
if (key.upArrow) {
|
|
8
10
|
if (selectedIndex === offset && offset > 0) {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { ReactNode } from "react";
|
|
2
|
+
type Props = {
|
|
3
|
+
active?: boolean;
|
|
4
|
+
onClick: () => void;
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
borderLess?: boolean;
|
|
7
|
+
};
|
|
8
|
+
export declare const TabButton: ({ active, onClick, children, borderLess }: Props) => import("react/jsx-runtime").JSX.Element;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Text, Box } from "ink";
|
|
3
|
+
import { useId, useRef } from "react";
|
|
4
|
+
import { primary } from "../theme/style.js";
|
|
5
|
+
import { useMouseTarget } from "../hooks/use-mouse.js";
|
|
6
|
+
export const TabButton = ({ active, onClick, children, borderLess }) => {
|
|
7
|
+
const id = useId();
|
|
8
|
+
const boxRef = useRef(null);
|
|
9
|
+
useMouseTarget(id, boxRef, { onClick });
|
|
10
|
+
return (_jsx(Box, { ref: boxRef, borderStyle: borderLess ? undefined : "round", borderColor: active ? primary : "gray", alignItems: "center", justifyContent: "center", paddingX: 1, children: _jsx(Text, { color: active ? "white" : "gray", children: children }) }));
|
|
11
|
+
};
|
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { Text, Box, useFocus, useInput, useFocusManager } from "ink";
|
|
2
|
+
import { Text, Box, useFocus, useInput, useFocusManager, } from "ink";
|
|
3
3
|
import { primary } from "../theme/style.js";
|
|
4
|
-
import { useEffect, useMemo, useState } from "react";
|
|
4
|
+
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
|
5
5
|
import clipboard from "clipboardy";
|
|
6
6
|
import chalk from "chalk";
|
|
7
7
|
import { useStatusMessage } from "../hooks/status-message.js";
|
|
8
|
+
import { useMouseTarget } from "../hooks/use-mouse.js";
|
|
8
9
|
export const TextInput = ({ id, placeholder, value, isPassword, showPasswordOnFocus, isActive, autoFocus, inline, multiline, maxLines = 1, onChange, onSubmit, onCopy, ...props }) => {
|
|
9
10
|
const [cursor, setCursor] = useState(onChange ? value.length : 0);
|
|
10
11
|
const [scrollOffset, setScrollOffset] = useState(0);
|
|
11
|
-
const
|
|
12
|
+
const generatedId = useId();
|
|
13
|
+
const effectiveId = id ?? generatedId;
|
|
14
|
+
const { isFocused } = useFocus({ id: effectiveId, isActive, autoFocus });
|
|
12
15
|
const { showStatusMessage } = useStatusMessage();
|
|
13
16
|
const { focusNext } = useFocusManager();
|
|
17
|
+
const boxRef = useRef(null);
|
|
18
|
+
useMouseTarget(effectiveId, boxRef);
|
|
14
19
|
const displayValue = useMemo(() => {
|
|
15
20
|
let displayValue = value;
|
|
16
21
|
if (isPassword && (showPasswordOnFocus ? !isFocused : true)) {
|
|
@@ -189,5 +194,5 @@ export const TextInput = ({ id, placeholder, value, isPassword, showPasswordOnFo
|
|
|
189
194
|
}
|
|
190
195
|
}
|
|
191
196
|
}, { isActive: isFocused });
|
|
192
|
-
return (_jsx(Box, { borderStyle: "round", borderColor: isFocused ? primary : "gray", borderBottom: !inline, borderTop: !inline, borderLeft: !inline, borderRight: !inline, flexGrow: 1, flexShrink: 0, paddingX: inline ? 0 : 1, overflow: "hidden", minHeight: inline ? 1 : 3, ...props, children: _jsx(Text, { color: value ? "white" : "gray", children: displayValue }) }));
|
|
197
|
+
return (_jsx(Box, { ref: boxRef, borderStyle: "round", borderColor: isFocused ? primary : "gray", borderBottom: !inline, borderTop: !inline, borderLeft: !inline, borderRight: !inline, flexGrow: 1, flexShrink: 0, paddingX: inline ? 0 : 1, overflow: "hidden", minHeight: inline ? 1 : 3, ...props, children: _jsx(Text, { color: value ? "white" : "gray", children: displayValue }) }));
|
|
193
198
|
};
|
|
@@ -6,8 +6,10 @@ import { VaultList } from "./components/VaultList.js";
|
|
|
6
6
|
import { CipherDetail } from "./components/CipherDetail.js";
|
|
7
7
|
import { HelpBar } from "./components/HelpBar.js";
|
|
8
8
|
import { primary } from "../theme/style.js";
|
|
9
|
-
import { bwClient, clearConfig, emptyCipher, useBwSync } from "../hooks/bw.js";
|
|
9
|
+
import { bwClient, clearConfig, createEmptyCipher, emptyCipher, useBwSync } from "../hooks/bw.js";
|
|
10
10
|
import { useStatusMessage } from "../hooks/status-message.js";
|
|
11
|
+
import { useMouseSubscribe } from "../hooks/use-mouse.js";
|
|
12
|
+
import { TabButton } from "../components/TabButton.js";
|
|
11
13
|
export function DashboardView({ onLogout }) {
|
|
12
14
|
const { sync, error, fetchSync } = useBwSync();
|
|
13
15
|
const [syncState, setSyncState] = useState(sync);
|
|
@@ -17,6 +19,7 @@ export function DashboardView({ onLogout }) {
|
|
|
17
19
|
const [focusedComponent, setFocusedComponent] = useState("list");
|
|
18
20
|
const [detailMode, setDetailMode] = useState("view");
|
|
19
21
|
const [editedCipher, setEditedCipher] = useState(null);
|
|
22
|
+
const [activeTab, setActiveTab] = useState("main");
|
|
20
23
|
const { focus, focusNext } = useFocusManager();
|
|
21
24
|
const { stdout } = useStdout();
|
|
22
25
|
const { statusMessage, statusMessageColor, showStatusMessage } = useStatusMessage();
|
|
@@ -41,18 +44,27 @@ export function DashboardView({ onLogout }) {
|
|
|
41
44
|
return i < 0 ? 0 : i;
|
|
42
45
|
}, [listSelected, filteredCiphers]);
|
|
43
46
|
const selectedCipher = detailMode === "new" ? editedCipher : filteredCiphers[listIndex];
|
|
47
|
+
const writableCollections = useMemo(() => (syncState?.collections ?? []).filter((c) => !c.readOnly && c.organizationId === selectedCipher?.organizationId), [syncState, editedCipher, filteredCiphers[listIndex]]);
|
|
48
|
+
const organizations = useMemo(() => (syncState?.profile?.organizations ?? []).map(({ id, name }) => ({ id, name })), [syncState]);
|
|
44
49
|
const logout = async () => {
|
|
45
50
|
bwClient.logout();
|
|
46
51
|
await clearConfig();
|
|
47
52
|
onLogout();
|
|
48
53
|
};
|
|
54
|
+
useMouseSubscribe((targetId) => {
|
|
55
|
+
if (targetId === "search") {
|
|
56
|
+
setFocusedComponent("search");
|
|
57
|
+
}
|
|
58
|
+
else if (targetId === "list") {
|
|
59
|
+
setFocusedComponent("list");
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
setFocusedComponent("detail");
|
|
63
|
+
}
|
|
64
|
+
});
|
|
49
65
|
useEffect(() => {
|
|
50
66
|
setSyncState(sync);
|
|
51
67
|
}, [sync]);
|
|
52
|
-
useEffect(() => {
|
|
53
|
-
if (focusedComponent === "detail")
|
|
54
|
-
focusNext();
|
|
55
|
-
}, [focusedComponent]);
|
|
56
68
|
useEffect(() => {
|
|
57
69
|
if (error)
|
|
58
70
|
showStatusMessage(error, "error");
|
|
@@ -71,12 +83,14 @@ export function DashboardView({ onLogout }) {
|
|
|
71
83
|
setDetailMode("new");
|
|
72
84
|
setEditedCipher(emptyCipher);
|
|
73
85
|
setFocusedComponent("detail");
|
|
86
|
+
setActiveTab("main");
|
|
74
87
|
setShowDetails(false);
|
|
75
88
|
return;
|
|
76
89
|
}
|
|
77
90
|
if (key.escape) {
|
|
78
91
|
setFocusedComponent("list");
|
|
79
92
|
setDetailMode("view");
|
|
93
|
+
setActiveTab("main");
|
|
80
94
|
}
|
|
81
95
|
if (focusedComponent === "search") {
|
|
82
96
|
if (key.escape && searchQuery?.length) {
|
|
@@ -104,22 +118,46 @@ export function DashboardView({ onLogout }) {
|
|
|
104
118
|
return (_jsxs(Box, { flexDirection: "column", width: "100%", height: stdout.rows - 1, children: [_jsx(Box, { borderStyle: "double", borderColor: primary, paddingX: 2, justifyContent: "center", flexShrink: 0, children: _jsx(Text, { bold: true, color: primary, children: "BiTTY" }) }), _jsxs(Box, { children: [_jsx(Box, { width: "40%", children: _jsx(TextInput, { id: "search", placeholder: focusedComponent === "search" ? "" : "[/] Search in vault", value: searchQuery, isActive: false, onChange: setSearchQuery, onSubmit: () => {
|
|
105
119
|
setFocusedComponent("list");
|
|
106
120
|
focusNext();
|
|
107
|
-
} }) }),
|
|
121
|
+
} }) }), _jsxs(Box, { width: "60%", paddingX: 1, justifyContent: "space-between", children: [statusMessage ? (_jsx(Box, { padding: 1, flexShrink: 1, children: _jsx(Text, { color: statusMessageColor, children: statusMessage }) })) : (_jsx(Box, {})), selectedCipher && (_jsxs(Box, { gap: 1, flexShrink: 0, children: [_jsx(TabButton, { active: activeTab === "main", onClick: () => setActiveTab("main"), children: "Main" }), _jsx(TabButton, { active: activeTab === "more", onClick: () => setActiveTab("more"), children: "More" }), !!syncState?.collections?.length && (_jsx(TabButton, { active: activeTab === "collections", onClick: () => setActiveTab("collections"), children: "Collections" }))] }))] })] }), _jsxs(Box, { minHeight: 20, flexGrow: 1, children: [_jsx(VaultList, { filteredCiphers: filteredCiphers, isFocused: ["list", "search"].includes(focusedComponent), selected: listIndex, onSelect: (index) => setListSelected(filteredCiphers[index] || null) }), _jsx(CipherDetail, { selectedCipher: showDetails ? selectedCipher : null, mode: detailMode, activeTab: activeTab, collections: writableCollections, organizations: organizations, isFocused: focusedComponent === "detail", onTypeChange: (type) => {
|
|
122
|
+
const fresh = createEmptyCipher(type);
|
|
123
|
+
fresh.name = editedCipher?.name ?? "";
|
|
124
|
+
fresh.notes = editedCipher?.notes ?? null;
|
|
125
|
+
fresh.collectionIds = editedCipher?.collectionIds ?? [];
|
|
126
|
+
fresh.organizationId = editedCipher?.organizationId ?? null;
|
|
127
|
+
setEditedCipher(fresh);
|
|
128
|
+
}, onChange: (cipher) => {
|
|
108
129
|
if (detailMode === "new") {
|
|
109
130
|
setEditedCipher(cipher);
|
|
110
131
|
return;
|
|
111
132
|
}
|
|
112
133
|
const updatedCiphers = syncState?.ciphers.map((c) => c.id === cipher.id ? cipher : c);
|
|
113
134
|
setSyncState((prev) => ({ ...prev, ciphers: updatedCiphers }));
|
|
135
|
+
}, onDelete: async (cipher) => {
|
|
136
|
+
showStatusMessage("Deleting...");
|
|
137
|
+
try {
|
|
138
|
+
await bwClient.deleteSecret(cipher.id);
|
|
139
|
+
fetchSync();
|
|
140
|
+
showStatusMessage("Deleted!", "success");
|
|
141
|
+
setFocusedComponent("list");
|
|
142
|
+
setActiveTab("main");
|
|
143
|
+
}
|
|
144
|
+
catch (e) {
|
|
145
|
+
showStatusMessage("Delete error", "error");
|
|
146
|
+
}
|
|
114
147
|
}, onSave: async (cipher) => {
|
|
115
148
|
showStatusMessage("Saving...");
|
|
116
149
|
if (detailMode === "new") {
|
|
117
150
|
try {
|
|
151
|
+
if (cipher.organizationId && !cipher.collectionIds?.length) {
|
|
152
|
+
showStatusMessage("Select at least one collection", "error");
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
118
155
|
await bwClient.createSecret(cipher);
|
|
119
156
|
fetchSync();
|
|
120
157
|
showStatusMessage("Saved!", "success");
|
|
121
158
|
setDetailMode("view");
|
|
122
159
|
setFocusedComponent("list");
|
|
160
|
+
setActiveTab("main");
|
|
123
161
|
}
|
|
124
162
|
catch (e) {
|
|
125
163
|
showStatusMessage("Synchronization error", "error");
|
|
@@ -127,14 +165,37 @@ export function DashboardView({ onLogout }) {
|
|
|
127
165
|
}
|
|
128
166
|
else {
|
|
129
167
|
try {
|
|
168
|
+
const original = sync?.ciphers.find((c) => c.id === cipher.id);
|
|
169
|
+
const originalOrgId = original?.organizationId ?? null;
|
|
170
|
+
const originalCollections = original?.collectionIds ?? [];
|
|
171
|
+
const newCollections = [...cipher.collectionIds ?? []];
|
|
172
|
+
// Sharing: personal cipher being assigned to an org
|
|
173
|
+
if (!originalOrgId && cipher.organizationId) {
|
|
174
|
+
if (!newCollections.length) {
|
|
175
|
+
showStatusMessage("Select at least one collection to share", "error");
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
await bwClient.shareCipher(cipher.id, cipher, newCollections);
|
|
179
|
+
fetchSync();
|
|
180
|
+
showStatusMessage("Shared!", "success");
|
|
181
|
+
setFocusedComponent("list");
|
|
182
|
+
setActiveTab("main");
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
130
185
|
const updated = await bwClient.updateSecret(cipher.id, cipher);
|
|
131
|
-
|
|
186
|
+
const collectionsChanged = originalCollections.length !== newCollections.length ||
|
|
187
|
+
originalCollections.some((id) => !newCollections.includes(id));
|
|
188
|
+
if (collectionsChanged) {
|
|
189
|
+
await bwClient.updateCollections(cipher.id, newCollections);
|
|
190
|
+
}
|
|
191
|
+
if (!updated && !collectionsChanged) {
|
|
132
192
|
showStatusMessage("Nothing to save");
|
|
133
193
|
return;
|
|
134
194
|
}
|
|
135
195
|
fetchSync();
|
|
136
196
|
showStatusMessage("Saved!", "success");
|
|
137
197
|
setFocusedComponent("list");
|
|
198
|
+
setActiveTab("main");
|
|
138
199
|
}
|
|
139
200
|
catch (e) {
|
|
140
201
|
showStatusMessage("Synchronization error", "error");
|
|
@@ -1,8 +1,15 @@
|
|
|
1
|
-
import { Cipher } from "../../clients/bw.js";
|
|
2
|
-
|
|
1
|
+
import { Cipher, CipherType, Collection } from "../../clients/bw.js";
|
|
2
|
+
import { Organization } from "./MoreInfoTab.js";
|
|
3
|
+
export type DetailTab = "main" | "more" | "collections";
|
|
4
|
+
export declare function CipherDetail({ selectedCipher, isFocused, mode, activeTab, collections, organizations, onChange, onSave, onDelete, onTypeChange, }: {
|
|
3
5
|
selectedCipher: Cipher | null | undefined;
|
|
4
6
|
isFocused: boolean;
|
|
5
7
|
mode: "view" | "new";
|
|
8
|
+
activeTab: DetailTab;
|
|
9
|
+
collections: Collection[];
|
|
10
|
+
organizations: Organization[];
|
|
6
11
|
onChange: (cipher: Cipher) => void;
|
|
7
12
|
onSave: (cipher: Cipher) => void;
|
|
13
|
+
onDelete: (cipher: Cipher) => void;
|
|
14
|
+
onTypeChange?: (type: CipherType) => void;
|
|
8
15
|
}): import("react/jsx-runtime").JSX.Element;
|
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box } from "ink";
|
|
3
3
|
import { primaryLight } from "../../theme/style.js";
|
|
4
|
-
import { CipherType } from "../../clients/bw.js";
|
|
5
4
|
import { Button } from "../../components/Button.js";
|
|
6
|
-
import { useState } from "react";
|
|
7
5
|
import { MoreInfoTab } from "./MoreInfoTab.js";
|
|
8
6
|
import { MainTab } from "./MainTab.js";
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
return (_jsx(Box, { flexDirection: "column", width: "60%", flexGrow: 1, paddingX: 1, borderStyle: "round", borderColor: isFocused ? primaryLight : "gray", borderLeftColor: "gray", children: selectedCipher && (_jsxs(Box, { flexDirection: "column", justifyContent: "space-between", flexGrow: 1, children: [
|
|
7
|
+
import { CollectionsTab } from "./CollectionsTab.js";
|
|
8
|
+
export function CipherDetail({ selectedCipher, isFocused, mode, activeTab, collections, organizations, onChange, onSave, onDelete, onTypeChange, }) {
|
|
9
|
+
return (_jsx(Box, { flexDirection: "column", width: "60%", flexGrow: 1, paddingX: 1, borderStyle: "round", borderColor: isFocused ? primaryLight : "gray", borderLeftColor: "gray", children: selectedCipher && (_jsxs(Box, { flexDirection: "column", justifyContent: "space-between", flexGrow: 1, children: [activeTab === "more" ? (_jsx(MoreInfoTab, { isFocused: isFocused, selectedCipher: selectedCipher, organizations: organizations, onChange: onChange })) : activeTab === "collections" ? (_jsx(CollectionsTab, { isFocused: isFocused, selectedCipher: selectedCipher, collections: collections, onChange: onChange })) : (_jsx(MainTab, { isFocused: isFocused, selectedCipher: selectedCipher, mode: mode, onChange: onChange, onTypeChange: onTypeChange })), _jsxs(Box, { marginTop: 1, flexShrink: 0, gap: 1, children: [_jsx(Button, { doubleConfirm: true, width: "50%", isActive: isFocused, onClick: () => onSave(selectedCipher), children: "Save" }), mode !== "new" && (_jsx(Button, { tripleConfirm: true, width: "50%", isActive: isFocused, onClick: () => onDelete(selectedCipher), children: "Delete" }))] })] })) }));
|
|
12
10
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Cipher, Collection } from "../../clients/bw.js";
|
|
2
|
+
export declare function CollectionsTab({ isFocused, selectedCipher, collections, onChange, }: {
|
|
3
|
+
isFocused: boolean;
|
|
4
|
+
selectedCipher: Cipher;
|
|
5
|
+
collections: Collection[];
|
|
6
|
+
onChange: (cipher: Cipher) => void;
|
|
7
|
+
}): import("react/jsx-runtime").JSX.Element;
|