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 CHANGED
File without changes
@@ -45,7 +45,7 @@ export interface Cipher {
45
45
  type: CipherType;
46
46
  key?: string | null;
47
47
  folderId?: string | null;
48
- organizationId: string | null;
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<void>;
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;
@@ -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 s = await fetchApi(`${this.apiUrl}/ciphers`, {
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(this.encryptCipher(obj, key)),
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 : "gray", alignItems: "center", justifyContent: "center", ...props, children: _jsx(Text, { color: isFocused && isActive ? (askConfirm ? "yellow" : "white") : "gray", children: askConfirm ? "Confirm?" : children }) }));
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 : "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 : "#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 : "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 : "#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 (input === "/" && focusedComponent !== "search") {
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 - 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: () => {
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
- } }) }), statusMessage && (_jsx(Box, { width: "60%", padding: 1, children: _jsx(Text, { color: statusMessageColor, children: statusMessage }) }))] }), _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, isFocused: focusedComponent === "detail", onChange: (cipher) => {
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
- if (!updated) {
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
- export declare function CipherDetail({ selectedCipher, isFocused, mode, onChange, onSave, }: {
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
- export function CipherDetail({ selectedCipher, isFocused, mode, onChange, onSave, }) {
10
- const [isMoreInfoTab, setIsMoreInfoTab] = useState(false);
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: [isMoreInfoTab ? (_jsx(MoreInfoTab, { isFocused: isFocused, selectedCipher: selectedCipher, onChange: onChange })) : (_jsx(MainTab, { isFocused: isFocused, selectedCipher: selectedCipher, onChange: onChange })), _jsxs(Box, { marginTop: 1, flexShrink: 0, gap: 1, children: [mode !== "new" && (_jsx(Button, { width: "50%", isActive: isFocused, onClick: () => setIsMoreInfoTab(!isMoreInfoTab), children: "More" })), selectedCipher.type !== CipherType.SSHKey && (_jsx(Button, { doubleConfirm: true, width: "50%", isActive: isFocused, onClick: () => onSave(selectedCipher), children: "Save" }))] })] })) }));
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
+ }