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 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, {}) }) }));
@@ -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;
@@ -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;
@@ -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,
@@ -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
- export const Button = ({ isActive = true, doubleConfirm, onClick, children, autoFocus = false, ...props }) => {
6
- const { isFocused } = useFocus({ autoFocus: autoFocus });
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
- useInput((input, key) => {
10
- if (key.return) {
11
- if (timeoutRef.current)
12
- clearTimeout(timeoutRef.current);
13
- if (doubleConfirm && !askConfirm) {
14
- setAskConfirm(true);
15
- timeoutRef.current = setTimeout(() => setAskConfirm(false), 1000);
16
- return;
17
- }
18
- if (askConfirm)
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
- onClick();
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 ? (askConfirm ? "yellow" : "white") : "gray", children: askConfirm ? "Confirm?" : children }) }));
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 { isFocused } = useFocus();
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 { isFocused } = useFocus({ id, isActive, autoFocus });
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
- } }) }), 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) => {
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
- if (!updated) {
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
- 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, 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
- 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, 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;