bitty-tui 0.0.18 → 0.0.20
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 +28 -2
- package/dist/clients/bw.js +108 -3
- package/dist/components/Button.d.ts +3 -1
- package/dist/components/Button.js +20 -3
- package/dist/components/Checkbox.js +1 -1
- package/dist/components/TabButton.d.ts +9 -0
- package/dist/components/TabButton.js +11 -0
- package/dist/components/TextInput.js +1 -1
- package/dist/dashboard/DashboardView.js +88 -5
- package/dist/dashboard/components/CipherDetail.d.ts +10 -2
- package/dist/dashboard/components/CipherDetail.js +4 -6
- package/dist/dashboard/components/CollectionsTab.d.ts +7 -0
- package/dist/dashboard/components/CollectionsTab.js +48 -0
- package/dist/dashboard/components/HelpBar.js +8 -8
- 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 +101 -4
- package/dist/dashboard/components/VaultList.js +1 -1
- package/dist/hooks/bw.d.ts +2 -1
- package/dist/hooks/bw.js +56 -11
- package/dist/hooks/status-message.d.ts +3 -1
- package/dist/hooks/status-message.js +4 -2
- package/dist/hooks/use-mouse.js +1 -1
- package/dist/login/LoginView.js +9 -1
- package/package.json +3 -3
- package/readme.md +0 -6
package/dist/cli.js
CHANGED
|
File without changes
|
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;
|
|
@@ -156,7 +171,7 @@ export declare class Client {
|
|
|
156
171
|
* - Derived encryption keys (master key, user key, private key)
|
|
157
172
|
*/
|
|
158
173
|
login(email: string, password: string, skipPrelogin?: boolean, opts?: Record<string, any>): Promise<void>;
|
|
159
|
-
sendEmailMfaCode(email: string): Promise<
|
|
174
|
+
sendEmailMfaCode(email: string): Promise<Response>;
|
|
160
175
|
checkToken(): Promise<void>;
|
|
161
176
|
/**
|
|
162
177
|
* Fetches the latest sync data from the Bitwarden server and decrypts organization keys if available.
|
|
@@ -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
|
@@ -416,7 +416,7 @@ export class Client {
|
|
|
416
416
|
this.orgKeys = {};
|
|
417
417
|
}
|
|
418
418
|
async sendEmailMfaCode(email) {
|
|
419
|
-
fetchApi(`${this.apiUrl}/two-factor/send-email-login`, {
|
|
419
|
+
return fetchApi(`${this.apiUrl}/two-factor/send-email-login`, {
|
|
420
420
|
method: "POST",
|
|
421
421
|
headers: {
|
|
422
422
|
"Content-Type": "application/json",
|
|
@@ -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,
|
|
@@ -2,10 +2,12 @@ import { Box } from "ink";
|
|
|
2
2
|
import { ReactNode } from "react";
|
|
3
3
|
type Props = {
|
|
4
4
|
isActive?: boolean;
|
|
5
|
+
activeBorderColor?: string;
|
|
5
6
|
doubleConfirm?: boolean;
|
|
7
|
+
tripleConfirm?: boolean;
|
|
6
8
|
autoFocus?: boolean;
|
|
7
9
|
onClick: () => void;
|
|
8
10
|
children: ReactNode;
|
|
9
11
|
} & React.ComponentProps<typeof Box>;
|
|
10
|
-
export declare const Button: ({ isActive, doubleConfirm, onClick, children, autoFocus, ...props }: Props) => import("react/jsx-runtime").JSX.Element;
|
|
12
|
+
export declare const Button: ({ isActive, activeBorderColor, doubleConfirm, tripleConfirm, onClick, children, autoFocus, ...props }: Props) => import("react/jsx-runtime").JSX.Element;
|
|
11
13
|
export {};
|
|
@@ -3,22 +3,33 @@ import { Text, Box, useFocus, useInput } from "ink";
|
|
|
3
3
|
import { useId, useRef, useState } from "react";
|
|
4
4
|
import { primary } from "../theme/style.js";
|
|
5
5
|
import { useMouseTarget } from "../hooks/use-mouse.js";
|
|
6
|
-
export const Button = ({ isActive = true, doubleConfirm, onClick, children, autoFocus = false, ...props }) => {
|
|
6
|
+
export const Button = ({ isActive = true, activeBorderColor, doubleConfirm, tripleConfirm, onClick, children, autoFocus = false, ...props }) => {
|
|
7
7
|
const generatedId = useId();
|
|
8
8
|
const { isFocused } = useFocus({ id: generatedId, autoFocus: autoFocus });
|
|
9
9
|
const [askConfirm, setAskConfirm] = useState(false);
|
|
10
|
+
const [ask2Confirm, setAsk2Confirm] = useState(false);
|
|
10
11
|
const timeoutRef = useRef(null);
|
|
11
12
|
const boxRef = useRef(null);
|
|
12
13
|
const handlePress = () => {
|
|
13
14
|
if (timeoutRef.current)
|
|
14
15
|
clearTimeout(timeoutRef.current);
|
|
15
|
-
if (doubleConfirm && !askConfirm) {
|
|
16
|
+
if ((doubleConfirm || tripleConfirm) && !askConfirm) {
|
|
16
17
|
setAskConfirm(true);
|
|
17
18
|
timeoutRef.current = setTimeout(() => setAskConfirm(false), 1000);
|
|
18
19
|
return;
|
|
19
20
|
}
|
|
21
|
+
if (tripleConfirm && !ask2Confirm) {
|
|
22
|
+
setAsk2Confirm(true);
|
|
23
|
+
timeoutRef.current = setTimeout(() => {
|
|
24
|
+
setAskConfirm(false);
|
|
25
|
+
setAsk2Confirm(false);
|
|
26
|
+
}, 1000);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
20
29
|
if (askConfirm)
|
|
21
30
|
setAskConfirm(false);
|
|
31
|
+
if (ask2Confirm)
|
|
32
|
+
setAsk2Confirm(false);
|
|
22
33
|
onClick();
|
|
23
34
|
};
|
|
24
35
|
useMouseTarget(generatedId, boxRef, { onClick: handlePress });
|
|
@@ -26,5 +37,11 @@ export const Button = ({ isActive = true, doubleConfirm, onClick, children, auto
|
|
|
26
37
|
if (key.return)
|
|
27
38
|
handlePress();
|
|
28
39
|
}, { isActive: isFocused && isActive });
|
|
29
|
-
return (_jsx(Box, { ref: boxRef, borderStyle: "round", borderColor: isFocused && isActive ? primary : "
|
|
40
|
+
return (_jsx(Box, { ref: boxRef, borderStyle: "round", borderColor: isFocused && isActive ? activeBorderColor ?? primary : "#9f9f9f", alignItems: "center", justifyContent: "center", ...props, children: _jsx(Text, { color: isFocused && isActive
|
|
41
|
+
? ask2Confirm
|
|
42
|
+
? "red"
|
|
43
|
+
: askConfirm
|
|
44
|
+
? "yellow"
|
|
45
|
+
: "white"
|
|
46
|
+
: "#9f9f9f", children: ask2Confirm ? "Are you sure?" : askConfirm ? "Confirm?" : children }) }));
|
|
30
47
|
};
|
|
@@ -15,5 +15,5 @@ export const Checkbox = ({ isActive = true, value, label, onToggle, ...props })
|
|
|
15
15
|
onToggle(!value);
|
|
16
16
|
}
|
|
17
17
|
}, { isActive: isFocused && isActive });
|
|
18
|
-
return (_jsxs(Box, { ref: boxRef, ...props, children: [_jsx(Box, { width: 5, height: 3, flexShrink: 0, borderStyle: "round", borderColor: isFocused && isActive ? primary : "
|
|
18
|
+
return (_jsxs(Box, { ref: boxRef, ...props, children: [_jsx(Box, { width: 5, height: 3, flexShrink: 0, borderStyle: "round", borderColor: isFocused && isActive ? primary : "#9f9f9f", children: value && (_jsx(Box, { width: 1, height: 1, marginLeft: 1, children: _jsx(Text, { color: isFocused && isActive ? primary : "#9f9f9f", children: "X" }) })) }), _jsx(Box, { marginTop: 1, marginLeft: 1, children: _jsx(Text, { children: label }) })] }));
|
|
19
19
|
};
|
|
@@ -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 : "#9f9f9f", alignItems: "center", justifyContent: "center", paddingX: 1, children: _jsx(Text, { color: active ? "white" : "#9f9f9f", children: children }) }));
|
|
11
|
+
};
|
|
@@ -194,5 +194,5 @@ export const TextInput = ({ id, placeholder, value, isPassword, showPasswordOnFo
|
|
|
194
194
|
}
|
|
195
195
|
}
|
|
196
196
|
}, { isActive: isFocused });
|
|
197
|
-
return (_jsx(Box, { ref: boxRef, borderStyle: "round", borderColor: isFocused ? primary : "
|
|
197
|
+
return (_jsx(Box, { ref: boxRef, borderStyle: "round", borderColor: isFocused ? primary : "#9f9f9f", 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 ? undefined : "#9f9f9f", children: displayValue }) }));
|
|
198
198
|
};
|
|
@@ -6,9 +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
11
|
import { useMouseSubscribe } from "../hooks/use-mouse.js";
|
|
12
|
+
import { TabButton } from "../components/TabButton.js";
|
|
12
13
|
export function DashboardView({ onLogout }) {
|
|
13
14
|
const { sync, error, fetchSync } = useBwSync();
|
|
14
15
|
const [syncState, setSyncState] = useState(sync);
|
|
@@ -18,6 +19,7 @@ export function DashboardView({ onLogout }) {
|
|
|
18
19
|
const [focusedComponent, setFocusedComponent] = useState("list");
|
|
19
20
|
const [detailMode, setDetailMode] = useState("view");
|
|
20
21
|
const [editedCipher, setEditedCipher] = useState(null);
|
|
22
|
+
const [activeTab, setActiveTab] = useState("main");
|
|
21
23
|
const { focus, focusNext } = useFocusManager();
|
|
22
24
|
const { stdout } = useStdout();
|
|
23
25
|
const { statusMessage, statusMessageColor, showStatusMessage } = useStatusMessage();
|
|
@@ -42,6 +44,8 @@ export function DashboardView({ onLogout }) {
|
|
|
42
44
|
return i < 0 ? 0 : i;
|
|
43
45
|
}, [listSelected, filteredCiphers]);
|
|
44
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]);
|
|
45
49
|
const logout = async () => {
|
|
46
50
|
bwClient.logout();
|
|
47
51
|
await clearConfig();
|
|
@@ -56,6 +60,9 @@ export function DashboardView({ onLogout }) {
|
|
|
56
60
|
}
|
|
57
61
|
else {
|
|
58
62
|
setFocusedComponent("detail");
|
|
63
|
+
if (focusedComponent !== "detail") {
|
|
64
|
+
setShowDetails(false);
|
|
65
|
+
}
|
|
59
66
|
}
|
|
60
67
|
});
|
|
61
68
|
useEffect(() => {
|
|
@@ -70,7 +77,27 @@ export function DashboardView({ onLogout }) {
|
|
|
70
77
|
await logout();
|
|
71
78
|
return;
|
|
72
79
|
}
|
|
73
|
-
if (
|
|
80
|
+
if (key.shift && key.rightArrow) {
|
|
81
|
+
setActiveTab((prev) => {
|
|
82
|
+
if (prev === "main")
|
|
83
|
+
return "more";
|
|
84
|
+
if (prev === "more")
|
|
85
|
+
return syncState?.collections?.length ? "collections" : "main";
|
|
86
|
+
return "main";
|
|
87
|
+
});
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (key.shift && key.leftArrow) {
|
|
91
|
+
setActiveTab((prev) => {
|
|
92
|
+
if (prev === "main")
|
|
93
|
+
return syncState?.collections?.length ? "collections" : "more";
|
|
94
|
+
if (prev === "more")
|
|
95
|
+
return "main";
|
|
96
|
+
return "more";
|
|
97
|
+
});
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (input === "/" && focusedComponent === "list") {
|
|
74
101
|
setFocusedComponent("search");
|
|
75
102
|
focus("search");
|
|
76
103
|
return;
|
|
@@ -79,12 +106,14 @@ export function DashboardView({ onLogout }) {
|
|
|
79
106
|
setDetailMode("new");
|
|
80
107
|
setEditedCipher(emptyCipher);
|
|
81
108
|
setFocusedComponent("detail");
|
|
109
|
+
setActiveTab("main");
|
|
82
110
|
setShowDetails(false);
|
|
83
111
|
return;
|
|
84
112
|
}
|
|
85
113
|
if (key.escape) {
|
|
86
114
|
setFocusedComponent("list");
|
|
87
115
|
setDetailMode("view");
|
|
116
|
+
setActiveTab("main");
|
|
88
117
|
}
|
|
89
118
|
if (focusedComponent === "search") {
|
|
90
119
|
if (key.escape && searchQuery?.length) {
|
|
@@ -109,25 +138,56 @@ export function DashboardView({ onLogout }) {
|
|
|
109
138
|
setShowDetails(true);
|
|
110
139
|
setTimeout(focusNext, 50);
|
|
111
140
|
}, [showDetails]);
|
|
112
|
-
return (_jsxs(Box, { flexDirection: "column", width: "100%", height: stdout.rows -
|
|
141
|
+
return (_jsxs(Box, { flexDirection: "column", width: "100%", height: stdout.rows - 2, 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: () => {
|
|
113
142
|
setFocusedComponent("list");
|
|
114
143
|
focusNext();
|
|
115
|
-
} }) }),
|
|
144
|
+
} }) }), _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: false, onClick: async () => {
|
|
145
|
+
await fetchSync();
|
|
146
|
+
showStatusMessage("Refreshed!", "success");
|
|
147
|
+
}, children: "\uD83D\uDD04" }), _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) => {
|
|
148
|
+
const fresh = createEmptyCipher(type);
|
|
149
|
+
fresh.name = editedCipher?.name ?? "";
|
|
150
|
+
fresh.notes = editedCipher?.notes ?? null;
|
|
151
|
+
fresh.collectionIds = editedCipher?.collectionIds ?? [];
|
|
152
|
+
fresh.organizationId = editedCipher?.organizationId ?? null;
|
|
153
|
+
setEditedCipher(fresh);
|
|
154
|
+
}, onReset: async () => {
|
|
155
|
+
bwClient.decryptedSyncCache = null;
|
|
156
|
+
await fetchSync(false);
|
|
157
|
+
showStatusMessage("Resetted!", "success");
|
|
158
|
+
}, onChange: (cipher) => {
|
|
116
159
|
if (detailMode === "new") {
|
|
117
160
|
setEditedCipher(cipher);
|
|
118
161
|
return;
|
|
119
162
|
}
|
|
120
163
|
const updatedCiphers = syncState?.ciphers.map((c) => c.id === cipher.id ? cipher : c);
|
|
121
164
|
setSyncState((prev) => ({ ...prev, ciphers: updatedCiphers }));
|
|
165
|
+
}, onDelete: async (cipher) => {
|
|
166
|
+
showStatusMessage("Deleting...");
|
|
167
|
+
try {
|
|
168
|
+
await bwClient.deleteSecret(cipher.id);
|
|
169
|
+
fetchSync();
|
|
170
|
+
showStatusMessage("Deleted!", "success");
|
|
171
|
+
setFocusedComponent("list");
|
|
172
|
+
setActiveTab("main");
|
|
173
|
+
}
|
|
174
|
+
catch (e) {
|
|
175
|
+
showStatusMessage("Delete error", "error");
|
|
176
|
+
}
|
|
122
177
|
}, onSave: async (cipher) => {
|
|
123
178
|
showStatusMessage("Saving...");
|
|
124
179
|
if (detailMode === "new") {
|
|
125
180
|
try {
|
|
181
|
+
if (cipher.organizationId && !cipher.collectionIds?.length) {
|
|
182
|
+
showStatusMessage("Select at least one collection", "error");
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
126
185
|
await bwClient.createSecret(cipher);
|
|
127
186
|
fetchSync();
|
|
128
187
|
showStatusMessage("Saved!", "success");
|
|
129
188
|
setDetailMode("view");
|
|
130
189
|
setFocusedComponent("list");
|
|
190
|
+
setActiveTab("main");
|
|
131
191
|
}
|
|
132
192
|
catch (e) {
|
|
133
193
|
showStatusMessage("Synchronization error", "error");
|
|
@@ -135,14 +195,37 @@ export function DashboardView({ onLogout }) {
|
|
|
135
195
|
}
|
|
136
196
|
else {
|
|
137
197
|
try {
|
|
198
|
+
const original = sync?.ciphers.find((c) => c.id === cipher.id);
|
|
199
|
+
const originalOrgId = original?.organizationId ?? null;
|
|
200
|
+
const originalCollections = original?.collectionIds ?? [];
|
|
201
|
+
const newCollections = [...cipher.collectionIds ?? []];
|
|
202
|
+
// Sharing: personal cipher being assigned to an org
|
|
203
|
+
if (!originalOrgId && cipher.organizationId) {
|
|
204
|
+
if (!newCollections.length) {
|
|
205
|
+
showStatusMessage("Select at least one collection to share", "error");
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
await bwClient.shareCipher(cipher.id, cipher, newCollections);
|
|
209
|
+
fetchSync();
|
|
210
|
+
showStatusMessage("Shared!", "success");
|
|
211
|
+
setFocusedComponent("list");
|
|
212
|
+
setActiveTab("main");
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
138
215
|
const updated = await bwClient.updateSecret(cipher.id, cipher);
|
|
139
|
-
|
|
216
|
+
const collectionsChanged = originalCollections.length !== newCollections.length ||
|
|
217
|
+
originalCollections.some((id) => !newCollections.includes(id));
|
|
218
|
+
if (collectionsChanged) {
|
|
219
|
+
await bwClient.updateCollections(cipher.id, newCollections);
|
|
220
|
+
}
|
|
221
|
+
if (!updated && !collectionsChanged) {
|
|
140
222
|
showStatusMessage("Nothing to save");
|
|
141
223
|
return;
|
|
142
224
|
}
|
|
143
225
|
fetchSync();
|
|
144
226
|
showStatusMessage("Saved!", "success");
|
|
145
227
|
setFocusedComponent("list");
|
|
228
|
+
setActiveTab("main");
|
|
146
229
|
}
|
|
147
230
|
catch (e) {
|
|
148
231
|
showStatusMessage("Synchronization error", "error");
|
|
@@ -1,8 +1,16 @@
|
|
|
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, onReset, 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
|
+
onReset: () => void;
|
|
15
|
+
onTypeChange?: (type: CipherType) => void;
|
|
8
16
|
}): import("react/jsx-runtime").JSX.Element;
|
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, 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 : "
|
|
7
|
+
import { CollectionsTab } from "./CollectionsTab.js";
|
|
8
|
+
export function CipherDetail({ selectedCipher, isFocused, mode, activeTab, collections, organizations, onChange, onSave, onDelete, onReset, onTypeChange, }) {
|
|
9
|
+
return (_jsx(Box, { flexDirection: "column", width: "60%", flexGrow: 1, paddingX: 1, borderStyle: "round", borderColor: isFocused ? primaryLight : "#9f9f9f", borderLeftColor: "#9f9f9f", 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: "49%", isActive: isFocused, onClick: () => onSave(selectedCipher), children: "Save" }), mode !== "new" && (_jsxs(_Fragment, { children: [_jsx(Button, { doubleConfirm: true, width: "25%", activeBorderColor: "yellow", isActive: isFocused, onClick: () => onReset(), children: "Reset" }), _jsx(Button, { tripleConfirm: true, width: "25%", activeBorderColor: "red", 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;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useInput, useStdout } from "ink";
|
|
3
|
+
import { useId, useRef, useState } from "react";
|
|
4
|
+
import { useMouseTarget } from "../../hooks/use-mouse.js";
|
|
5
|
+
export function CollectionsTab({ isFocused, selectedCipher, collections, onChange, }) {
|
|
6
|
+
const { stdout } = useStdout();
|
|
7
|
+
const [cursor, setCursor] = useState(0);
|
|
8
|
+
const selected = selectedCipher.collectionIds ?? [];
|
|
9
|
+
useInput((_input, key) => {
|
|
10
|
+
if (key.upArrow) {
|
|
11
|
+
setCursor((c) => Math.max(0, c - 1));
|
|
12
|
+
}
|
|
13
|
+
else if (key.downArrow) {
|
|
14
|
+
setCursor((c) => Math.min(collections.length - 1, c + 1));
|
|
15
|
+
}
|
|
16
|
+
}, { isActive: isFocused });
|
|
17
|
+
if (!collections.length) {
|
|
18
|
+
return (_jsx(Box, { flexDirection: "column", height: stdout.rows - 18, children: _jsx(Text, { color: "#9f9f9f", children: "No writable collections available." }) }));
|
|
19
|
+
}
|
|
20
|
+
return (_jsx(Box, { flexDirection: "column", gap: 0, height: stdout.rows - 18, children: collections.map((col, idx) => {
|
|
21
|
+
const checked = selected.includes(col.id);
|
|
22
|
+
const isCursor = cursor === idx && isFocused;
|
|
23
|
+
return (_jsx(CollectionCheckbox, { col: col, isCursor: isCursor, checked: checked, onFocus: () => setCursor(idx), onChange: (checked) => onChange({
|
|
24
|
+
...selectedCipher,
|
|
25
|
+
collectionIds: checked
|
|
26
|
+
? [...selected, col.id]
|
|
27
|
+
: selected.filter((id) => id !== col.id),
|
|
28
|
+
}) }, col.id));
|
|
29
|
+
}) }));
|
|
30
|
+
function CollectionCheckbox({ col, isCursor, checked, onChange, onFocus, }) {
|
|
31
|
+
const checkRef = useRef(null);
|
|
32
|
+
const labelRef = useRef(null);
|
|
33
|
+
const checkId = useId();
|
|
34
|
+
const labelId = useId();
|
|
35
|
+
useMouseTarget(checkId, checkRef, {
|
|
36
|
+
onClick: () => onChange?.(!checked),
|
|
37
|
+
});
|
|
38
|
+
useMouseTarget(labelId, labelRef, {
|
|
39
|
+
onClick: () => onFocus?.(),
|
|
40
|
+
});
|
|
41
|
+
useInput((input) => {
|
|
42
|
+
if (input === " ") {
|
|
43
|
+
onChange?.(!checked);
|
|
44
|
+
}
|
|
45
|
+
}, { isActive: isCursor });
|
|
46
|
+
return (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { ref: checkRef, children: _jsx(Text, { color: isCursor ? "white" : "#9f9f9f", bold: isCursor, children: checked ? "[x] " : "[ ] " }) }), _jsx(Box, { ref: labelRef, children: _jsx(Text, { color: isCursor ? "white" : "#9f9f9f", children: col.name }) })] }, col.id));
|
|
47
|
+
}
|
|
48
|
+
}
|