bitty-tui 0.0.18 → 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
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;
@@ -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 {};
@@ -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, 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 ? 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 }) }));
30
47
  };
@@ -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
+ };
@@ -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();
@@ -79,12 +83,14 @@ export function DashboardView({ onLogout }) {
79
83
  setDetailMode("new");
80
84
  setEditedCipher(emptyCipher);
81
85
  setFocusedComponent("detail");
86
+ setActiveTab("main");
82
87
  setShowDetails(false);
83
88
  return;
84
89
  }
85
90
  if (key.escape) {
86
91
  setFocusedComponent("list");
87
92
  setDetailMode("view");
93
+ setActiveTab("main");
88
94
  }
89
95
  if (focusedComponent === "search") {
90
96
  if (key.escape && searchQuery?.length) {
@@ -112,22 +118,46 @@ export function DashboardView({ onLogout }) {
112
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: () => {
113
119
  setFocusedComponent("list");
114
120
  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) => {
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) => {
116
129
  if (detailMode === "new") {
117
130
  setEditedCipher(cipher);
118
131
  return;
119
132
  }
120
133
  const updatedCiphers = syncState?.ciphers.map((c) => c.id === cipher.id ? cipher : c);
121
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
+ }
122
147
  }, onSave: async (cipher) => {
123
148
  showStatusMessage("Saving...");
124
149
  if (detailMode === "new") {
125
150
  try {
151
+ if (cipher.organizationId && !cipher.collectionIds?.length) {
152
+ showStatusMessage("Select at least one collection", "error");
153
+ return;
154
+ }
126
155
  await bwClient.createSecret(cipher);
127
156
  fetchSync();
128
157
  showStatusMessage("Saved!", "success");
129
158
  setDetailMode("view");
130
159
  setFocusedComponent("list");
160
+ setActiveTab("main");
131
161
  }
132
162
  catch (e) {
133
163
  showStatusMessage("Synchronization error", "error");
@@ -135,14 +165,37 @@ export function DashboardView({ onLogout }) {
135
165
  }
136
166
  else {
137
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
+ }
138
185
  const updated = await bwClient.updateSecret(cipher.id, cipher);
139
- 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) {
140
192
  showStatusMessage("Nothing to save");
141
193
  return;
142
194
  }
143
195
  fetchSync();
144
196
  showStatusMessage("Saved!", "success");
145
197
  setFocusedComponent("list");
198
+ setActiveTab("main");
146
199
  }
147
200
  catch (e) {
148
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;
@@ -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: "gray", 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" : "gray", bold: isCursor, children: checked ? "[x] " : "[ ] " }) }), _jsx(Box, { ref: labelRef, children: _jsx(Text, { color: isCursor ? "white" : "gray", children: col.name }) })] }, col.id));
47
+ }
48
+ }
@@ -3,39 +3,14 @@ import { Box, Text } from "ink";
3
3
  import { CipherType } from "../../clients/bw.js";
4
4
  import { primaryLight } from "../../theme/style.js";
5
5
  import { TextInput } from "../../components/TextInput.js";
6
- import { useEffect, useState } from "react";
7
- import { authenticator } from "otplib";
8
6
  export function MainTab({ isFocused, selectedCipher, onChange, }) {
9
- const [otpCode, setOtpCode] = useState("");
10
- const [otpTimeout, setOtpTimeout] = useState(0);
11
- useEffect(() => {
12
- let interval = null;
13
- if (selectedCipher?.login?.totp) {
14
- interval = setInterval(() => {
15
- setOtpTimeout((t) => {
16
- if (t <= 1) {
17
- setOtpCode(authenticator.generate(selectedCipher.login.totp));
18
- return 30;
19
- }
20
- return t - 1;
21
- });
22
- }, 1000);
23
- }
24
- else {
25
- setOtpCode("");
26
- }
27
- return () => {
28
- if (interval)
29
- clearInterval(interval);
30
- };
31
- }, [selectedCipher?.login?.totp]);
32
7
  return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 12, marginRight: 2, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Name:" }) }), _jsx(Box, { flexGrow: 1, paddingLeft: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: selectedCipher.name, onChange: (value) => onChange({ ...selectedCipher, name: value }) }) })] }), selectedCipher.type === CipherType.Login && (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 12, marginRight: 2, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Username:" }) }), _jsx(Box, { flexGrow: 1, paddingLeft: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: selectedCipher.login?.username ?? "", onChange: (value) => onChange({
33
8
  ...selectedCipher,
34
9
  login: { ...selectedCipher.login, username: value },
35
- }) }) })] })), selectedCipher.type === CipherType.Login && (_jsxs(Box, { flexDirection: "row", children: [_jsxs(Box, { flexDirection: "row", flexGrow: 1, children: [_jsx(Box, { width: 12, marginRight: 2, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Password:" }) }), _jsx(Box, { flexGrow: 1, paddingLeft: 1, children: _jsx(TextInput, { inline: true, isPassword: true, showPasswordOnFocus: true, isActive: isFocused, value: selectedCipher.login?.password ?? "", onChange: (value) => onChange({
36
- ...selectedCipher,
37
- login: { ...selectedCipher.login, password: value },
38
- }) }) })] }), selectedCipher.login?.totp && (_jsxs(Box, { flexDirection: "row", flexGrow: 1, children: [_jsx(Box, { marginRight: 2, flexShrink: 0, children: _jsxs(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: ["OTP (", otpTimeout.toString().padStart(2, "0"), "s):"] }) }), _jsx(Box, { flexGrow: 1, paddingLeft: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: otpCode }) })] }))] })), selectedCipher.type === CipherType.Login && (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 12, marginRight: 2, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "URL:" }) }), _jsx(Box, { flexGrow: 1, paddingLeft: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: selectedCipher.login?.uris?.[0]?.uri ?? "", onChange: (value) => onChange({
10
+ }) }) })] })), selectedCipher.type === CipherType.Login && (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 12, marginRight: 2, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Password:" }) }), _jsx(Box, { flexGrow: 1, paddingLeft: 1, children: _jsx(TextInput, { inline: true, isPassword: true, showPasswordOnFocus: true, isActive: isFocused, value: selectedCipher.login?.password ?? "", onChange: (value) => onChange({
11
+ ...selectedCipher,
12
+ login: { ...selectedCipher.login, password: value },
13
+ }) }) })] })), selectedCipher.type === CipherType.Login && (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 12, marginRight: 2, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "URL:" }) }), _jsx(Box, { flexGrow: 1, paddingLeft: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: selectedCipher.login?.uris?.[0]?.uri ?? "", onChange: (value) => onChange({
39
14
  ...selectedCipher,
40
15
  login: {
41
16
  ...selectedCipher.login,
@@ -1,6 +1,8 @@
1
- import { Cipher } from "../../clients/bw.js";
2
- export declare function MainTab({ isFocused, selectedCipher, onChange, }: {
1
+ import { Cipher, CipherType } from "../../clients/bw.js";
2
+ export declare function MainTab({ isFocused, selectedCipher, mode, onChange, onTypeChange, }: {
3
3
  isFocused: boolean;
4
4
  selectedCipher: Cipher;
5
+ mode: "view" | "new";
5
6
  onChange: (cipher: Cipher) => void;
7
+ onTypeChange?: (type: CipherType) => void;
6
8
  }): import("react/jsx-runtime").JSX.Element;
@@ -1,31 +1,95 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import { CipherType } from "../../clients/bw.js";
4
4
  import { primaryLight } from "../../theme/style.js";
5
5
  import { TextInput } from "../../components/TextInput.js";
6
+ import { TabButton } from "../../components/TabButton.js";
6
7
  import { useEffect, useState } from "react";
7
- import { authenticator } from "otplib";
8
+ import { createGuardrails, generate, stringToBytes } from "otplib";
8
9
  const OTP_INTERVAL = 30;
9
- export function MainTab({ isFocused, selectedCipher, onChange, }) {
10
+ const OTP_GUARDRAILS = createGuardrails({
11
+ MIN_SECRET_BYTES: 1,
12
+ MAX_SECRET_BYTES: 1024,
13
+ });
14
+ function parseTotpConfig(value) {
15
+ if (!value.startsWith("otpauth://")) {
16
+ return { secret: value };
17
+ }
18
+ try {
19
+ const url = new URL(value);
20
+ const secret = url.searchParams.get("secret") ?? "";
21
+ const periodRaw = Number.parseInt(url.searchParams.get("period") ?? "", 10);
22
+ const digitsRaw = Number.parseInt(url.searchParams.get("digits") ?? "", 10);
23
+ const algorithmRaw = (url.searchParams.get("algorithm") ?? "").toLowerCase();
24
+ const period = Number.isFinite(periodRaw) && periodRaw > 0 ? periodRaw : undefined;
25
+ const digits = digitsRaw === 6 || digitsRaw === 7 || digitsRaw === 8
26
+ ? digitsRaw
27
+ : undefined;
28
+ const algorithm = algorithmRaw === "sha1" ||
29
+ algorithmRaw === "sha256" ||
30
+ algorithmRaw === "sha512"
31
+ ? algorithmRaw
32
+ : undefined;
33
+ return { secret, period, digits, algorithm };
34
+ }
35
+ catch {
36
+ return { secret: value };
37
+ }
38
+ }
39
+ function normalizeBase32Secret(value) {
40
+ return value.replace(/\s+/g, "").toUpperCase();
41
+ }
42
+ function Field({ label, value, isFocused, onChange, isPassword, labelWidth = 12, maxLines = 3, }) {
43
+ return (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: labelWidth, flexShrink: 0, children: _jsxs(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: [label, ":"] }) }), _jsx(Box, { flexGrow: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, isPassword: isPassword, showPasswordOnFocus: isPassword, value: value, multiline: maxLines > 1, maxLines: maxLines, onChange: onChange }) })] }));
44
+ }
45
+ export function MainTab({ isFocused, selectedCipher, mode, onChange, onTypeChange, }) {
10
46
  const [otpCode, setOtpCode] = useState("");
11
47
  const [otpTimeout, setOtpTimeout] = useState(0);
12
- const genOtp = () => {
13
- if (selectedCipher.login?.totp) {
14
- const totp = authenticator.generate(selectedCipher.login.totp);
15
- selectedCipher.login.currentTotp = totp;
16
- setOtpCode(totp);
48
+ const genOtp = async (config) => {
49
+ if (config.secret) {
50
+ const normalizedSecret = normalizeBase32Secret(config.secret);
51
+ const options = {
52
+ guardrails: OTP_GUARDRAILS,
53
+ ...(config.period ? { period: config.period } : {}),
54
+ ...(config.digits ? { digits: config.digits } : {}),
55
+ ...(config.algorithm ? { algorithm: config.algorithm } : {}),
56
+ };
57
+ try {
58
+ const totp = await generate({ ...options, secret: normalizedSecret });
59
+ if (selectedCipher.login) {
60
+ selectedCipher.login.currentTotp = totp;
61
+ }
62
+ setOtpCode(totp);
63
+ }
64
+ catch {
65
+ try {
66
+ const totp = await generate({
67
+ ...options,
68
+ secret: stringToBytes(config.secret),
69
+ });
70
+ if (selectedCipher.login) {
71
+ selectedCipher.login.currentTotp = totp;
72
+ }
73
+ setOtpCode(totp);
74
+ }
75
+ catch {
76
+ setOtpCode("");
77
+ }
78
+ }
17
79
  }
18
80
  };
19
81
  useEffect(() => {
20
82
  let interval = null;
21
83
  if (selectedCipher?.login?.totp) {
22
- genOtp();
23
- setOtpTimeout(OTP_INTERVAL);
84
+ const config = parseTotpConfig(selectedCipher.login.totp);
85
+ const intervalSeconds = config.period ?? OTP_INTERVAL;
86
+ void genOtp(config);
87
+ setOtpTimeout(intervalSeconds);
24
88
  interval = setInterval(() => {
25
89
  setOtpTimeout((t) => {
26
90
  if (t <= 1) {
27
- genOtp();
28
- return OTP_INTERVAL;
91
+ void genOtp(config);
92
+ return intervalSeconds;
29
93
  }
30
94
  return t - 1;
31
95
  });
@@ -39,54 +103,29 @@ export function MainTab({ isFocused, selectedCipher, onChange, }) {
39
103
  clearInterval(interval);
40
104
  };
41
105
  }, [selectedCipher?.login?.totp]);
42
- return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 12, marginRight: 2, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Name:" }) }), _jsx(Box, { flexGrow: 1, paddingLeft: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: selectedCipher.name, onChange: (value) => onChange({ ...selectedCipher, name: value }) }) })] }), selectedCipher.type === CipherType.Login && (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 12, marginRight: 2, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Username:" }) }), _jsx(Box, { flexGrow: 1, paddingLeft: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: selectedCipher.login?.username ?? "", onChange: (value) => onChange({
43
- ...selectedCipher,
44
- login: { ...selectedCipher.login, username: value },
45
- }) }) })] })), selectedCipher.type === CipherType.Login && (_jsxs(Box, { flexDirection: "row", children: [_jsxs(Box, { flexDirection: "row", flexGrow: 1, children: [_jsx(Box, { width: 12, marginRight: 2, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Password:" }) }), _jsx(Box, { flexGrow: 1, paddingLeft: 1, children: _jsx(TextInput, { inline: true, isPassword: true, showPasswordOnFocus: true, isActive: isFocused, value: selectedCipher.login?.password ?? "", onChange: (value) => onChange({
106
+ const updateIdentity = (patch) => onChange({ ...selectedCipher, identity: { ...selectedCipher.identity, ...patch } });
107
+ const updateCard = (patch) => onChange({ ...selectedCipher, card: { ...selectedCipher.card, ...patch } });
108
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [mode === "new" && (_jsxs(Box, { flexDirection: "row", gap: 1, children: [_jsx(Box, { width: 10, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Type:" }) }), [
109
+ [CipherType.Login, "Login"],
110
+ [CipherType.SecureNote, "Note"],
111
+ [CipherType.Card, "Card"],
112
+ [CipherType.Identity, "Identity"],
113
+ ].map(([t, label]) => (_jsx(TabButton, { borderLess: true, active: selectedCipher.type === t, onClick: () => onTypeChange?.(t), children: label }, t)))] })), _jsx(Field, { label: "Name", labelWidth: selectedCipher.type === CipherType.SSHKey ? 13 : 12, value: selectedCipher.name, isFocused: isFocused, onChange: (value) => onChange({ ...selectedCipher, name: value }) }), selectedCipher.type === CipherType.Login && (_jsx(Field, { label: "Username", value: selectedCipher.login?.username ?? "", isFocused: isFocused, onChange: (value) => onChange({
114
+ ...selectedCipher,
115
+ login: { ...selectedCipher.login, username: value },
116
+ }) })), selectedCipher.type === CipherType.Login && (_jsxs(Box, { flexDirection: "row", children: [_jsxs(Box, { flexDirection: "row", flexGrow: 1, children: [_jsx(Box, { width: 12, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Password:" }) }), _jsx(Box, { flexGrow: 1, children: _jsx(TextInput, { inline: true, isPassword: true, showPasswordOnFocus: true, isActive: isFocused, value: selectedCipher.login?.password ?? "", onChange: (value) => onChange({
46
117
  ...selectedCipher,
47
118
  login: { ...selectedCipher.login, password: value },
48
- }) }) })] }), selectedCipher.login?.totp && (_jsxs(Box, { flexDirection: "row", flexGrow: 1, children: [_jsx(Box, { marginRight: 2, flexShrink: 0, children: _jsxs(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: ["OTP (", otpTimeout.toString().padStart(2, "0"), "s):"] }) }), _jsx(Box, { flexGrow: 1, paddingLeft: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: otpCode }) })] }))] })), selectedCipher.type === CipherType.Login && (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 12, marginRight: 2, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "URL:" }) }), _jsx(Box, { flexGrow: 1, paddingLeft: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: selectedCipher.login?.uris?.[0]?.uri ?? "", onChange: (value) => onChange({
49
- ...selectedCipher,
50
- login: {
51
- ...selectedCipher.login,
52
- uris: [
53
- { uri: value },
54
- ...selectedCipher.login.uris.slice(1),
55
- ],
56
- },
57
- }) }) })] })), selectedCipher.type === CipherType.SSHKey && (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 12, marginRight: 2, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Private Key:" }) }), _jsx(Box, { flexGrow: 1, paddingLeft: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: selectedCipher.sshKey?.privateKey ?? "" }) })] })), selectedCipher.type === CipherType.SSHKey && (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 12, marginRight: 2, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Public Key:" }) }), _jsx(Box, { flexGrow: 1, paddingLeft: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: selectedCipher.sshKey?.publicKey ?? "" }) })] })), selectedCipher.type === CipherType.Identity &&
58
- selectedCipher.identity?.firstName && (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 12, marginRight: 2, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "First Name:" }) }), _jsx(Box, { flexGrow: 1, paddingLeft: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: selectedCipher.identity?.firstName ?? "", onChange: (value) => onChange({
59
- ...selectedCipher,
60
- identity: { ...selectedCipher.identity, firstName: value },
61
- }) }) })] })), selectedCipher.type === CipherType.Identity &&
62
- selectedCipher.identity?.lastName && (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 12, marginRight: 2, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Last Name:" }) }), _jsx(Box, { flexGrow: 1, paddingLeft: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: selectedCipher.identity?.lastName ?? "", onChange: (value) => onChange({
63
- ...selectedCipher,
64
- identity: { ...selectedCipher.identity, lastName: value },
65
- }) }) })] })), selectedCipher.type === CipherType.Identity &&
66
- selectedCipher.identity?.username && (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 12, marginRight: 2, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Username:" }) }), _jsx(Box, { flexGrow: 1, paddingLeft: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: selectedCipher.identity?.username ?? "", onChange: (value) => onChange({
67
- ...selectedCipher,
68
- identity: { ...selectedCipher.identity, username: value },
69
- }) }) })] })), selectedCipher.type === CipherType.Identity &&
70
- selectedCipher.identity?.city && (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 12, marginRight: 2, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "City:" }) }), _jsx(Box, { flexGrow: 1, paddingLeft: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: selectedCipher.identity?.city ?? "", onChange: (value) => onChange({
71
- ...selectedCipher,
72
- identity: { ...selectedCipher.identity, city: value },
73
- }) }) })] })), selectedCipher.type === CipherType.Identity &&
74
- selectedCipher.identity?.address1 && (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 12, marginRight: 2, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Address:" }) }), _jsx(Box, { flexGrow: 1, paddingLeft: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: selectedCipher.identity?.address1 ?? "", onChange: (value) => onChange({
75
- ...selectedCipher,
76
- identity: { ...selectedCipher.identity, address1: value },
77
- }) }) })] })), selectedCipher.type === CipherType.Identity &&
78
- selectedCipher.identity?.country && (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 12, marginRight: 2, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Country:" }) }), _jsx(Box, { flexGrow: 1, paddingLeft: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: selectedCipher.identity?.country ?? "", onChange: (value) => onChange({
79
- ...selectedCipher,
80
- identity: { ...selectedCipher.identity, country: value },
81
- }) }) })] })), selectedCipher.type === CipherType.Identity &&
82
- selectedCipher.identity?.email && (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 12, marginRight: 2, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Email:" }) }), _jsx(Box, { flexGrow: 1, paddingLeft: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: selectedCipher.identity?.email ?? "", onChange: (value) => onChange({
83
- ...selectedCipher,
84
- identity: { ...selectedCipher.identity, email: value },
85
- }) }) })] })), selectedCipher.type === CipherType.Identity &&
86
- selectedCipher.identity?.phone && (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 12, marginRight: 2, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Phone:" }) }), _jsx(Box, { flexGrow: 1, paddingLeft: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: selectedCipher.identity?.phone ?? "", onChange: (value) => onChange({
87
- ...selectedCipher,
88
- identity: { ...selectedCipher.identity, phone: value },
89
- }) }) })] })), _jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 12, flexShrink: 0, marginRight: 2, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Notes:" }) }), _jsx(Box, { flexGrow: 1, minHeight: 7, children: _jsx(TextInput, { multiline: true, maxLines: 5, isActive: isFocused, value: selectedCipher.notes ?? "", onChange: (value) => onChange({
119
+ }) }) })] }), selectedCipher.login?.totp && (_jsxs(Box, { flexDirection: "row", width: 20, flexShrink: 0, children: [_jsx(Box, { flexShrink: 0, width: 12, children: _jsxs(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: ["OTP (", otpTimeout.toString().padStart(2, "0"), "s):"] }) }), _jsx(Box, { flexGrow: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: otpCode }) })] }))] })), selectedCipher.type === CipherType.Login && (_jsx(Field, { label: "URL", value: selectedCipher.login?.uris?.[0]?.uri ?? "", isFocused: isFocused, onChange: (value) => onChange({
120
+ ...selectedCipher,
121
+ login: {
122
+ ...selectedCipher.login,
123
+ uris: [
124
+ { uri: value },
125
+ ...selectedCipher.login.uris.slice(1),
126
+ ],
127
+ },
128
+ }) })), selectedCipher.type === CipherType.Card && (_jsxs(_Fragment, { children: [_jsx(Field, { label: "Cardholder", value: selectedCipher.card?.cardholderName ?? "", isFocused: isFocused, onChange: (v) => updateCard({ cardholderName: v }) }), _jsx(Field, { label: "Number", value: selectedCipher.card?.number ?? "", isFocused: isFocused, isPassword: true, onChange: (v) => updateCard({ number: v }) }), _jsx(Field, { label: "Brand", value: selectedCipher.card?.brand ?? "", isFocused: isFocused, onChange: (v) => updateCard({ brand: v }) }), _jsxs(Box, { flexDirection: "row", gap: 2, children: [_jsxs(Box, { flexDirection: "row", flexGrow: 1, children: [_jsx(Box, { width: 12, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Exp Month:" }) }), _jsx(Box, { flexGrow: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: selectedCipher.card?.expMonth ?? "", onChange: (v) => updateCard({ expMonth: v }) }) })] }), _jsxs(Box, { flexDirection: "row", flexGrow: 1, children: [_jsx(Box, { width: 10, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Exp Year:" }) }), _jsx(Box, { flexGrow: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: selectedCipher.card?.expYear ?? "", onChange: (v) => updateCard({ expYear: v }) }) })] })] }), _jsx(Field, { label: "CVV", value: selectedCipher.card?.code ?? "", isFocused: isFocused, isPassword: true, onChange: (v) => updateCard({ code: v }) })] })), selectedCipher.type === CipherType.Identity && (_jsxs(_Fragment, { children: [_jsxs(Box, { flexDirection: "row", gap: 2, children: [_jsxs(Box, { flexDirection: "row", width: "50%", flexShrink: 0, children: [_jsx(Box, { width: 12, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Title:" }) }), _jsx(Box, { flexGrow: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: selectedCipher.identity?.title ?? "", onChange: (v) => updateIdentity({ title: v }) }) })] }), _jsxs(Box, { flexDirection: "row", width: "50%", flexShrink: 0, children: [_jsx(Box, { width: 12, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "First Name:" }) }), _jsx(Box, { flexGrow: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: selectedCipher.identity?.firstName ?? "", onChange: (v) => updateIdentity({ firstName: v }) }) })] })] }), _jsxs(Box, { flexDirection: "row", gap: 2, children: [_jsxs(Box, { flexDirection: "row", width: "50%", flexShrink: 0, children: [_jsx(Box, { width: 12, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Middle:" }) }), _jsx(Box, { flexGrow: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: selectedCipher.identity?.middleName ?? "", onChange: (v) => updateIdentity({ middleName: v }) }) })] }), _jsxs(Box, { flexDirection: "row", width: "50%", flexShrink: 0, children: [_jsx(Box, { width: 12, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Last Name:" }) }), _jsx(Box, { flexGrow: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: selectedCipher.identity?.lastName ?? "", onChange: (v) => updateIdentity({ lastName: v }) }) })] })] }), _jsxs(Box, { flexDirection: "row", gap: 2, children: [_jsxs(Box, { flexDirection: "row", width: "50%", flexShrink: 0, children: [_jsx(Box, { width: 12, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Username:" }) }), _jsx(Box, { flexGrow: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: selectedCipher.identity?.username ?? "", onChange: (v) => updateIdentity({ username: v }) }) })] }), _jsxs(Box, { flexDirection: "row", width: "50%", flexShrink: 0, children: [_jsx(Box, { width: 12, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Company:" }) }), _jsx(Box, { flexGrow: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: selectedCipher.identity?.company ?? "", onChange: (v) => updateIdentity({ company: v }) }) })] })] }), _jsxs(Box, { flexDirection: "row", gap: 2, children: [_jsxs(Box, { flexDirection: "row", width: "50%", flexShrink: 0, children: [_jsx(Box, { width: 12, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Email:" }) }), _jsx(Box, { flexGrow: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: selectedCipher.identity?.email ?? "", onChange: (v) => updateIdentity({ email: v }) }) })] }), _jsxs(Box, { flexDirection: "row", width: "50%", flexShrink: 0, children: [_jsx(Box, { width: 12, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Phone:" }) }), _jsx(Box, { flexGrow: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: selectedCipher.identity?.phone ?? "", onChange: (v) => updateIdentity({ phone: v }) }) })] })] })] })), selectedCipher.type === CipherType.SSHKey && (_jsxs(_Fragment, { children: [_jsx(Field, { label: "Private Key", labelWidth: 13, value: selectedCipher.sshKey?.privateKey ?? "", isFocused: isFocused }), _jsx(Field, { label: "Public Key", labelWidth: 13, value: selectedCipher.sshKey?.publicKey ?? "", isFocused: isFocused })] })), _jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: selectedCipher.type === CipherType.SSHKey ? 12 : 11, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Notes:" }) }), _jsx(Box, { flexGrow: 1, minHeight: 6, children: _jsx(TextInput, { multiline: true, maxLines: 5, isActive: isFocused, value: selectedCipher.notes ?? "", onChange: (value) => onChange({
90
129
  ...selectedCipher,
91
130
  notes: value,
92
131
  }) }) })] })] }));
@@ -1,6 +1,11 @@
1
1
  import { Cipher } from "../../clients/bw.js";
2
- export declare function MoreInfoTab({ isFocused, selectedCipher, onChange, }: {
2
+ export type Organization = {
3
+ id: string;
4
+ name: string;
5
+ };
6
+ export declare function MoreInfoTab({ isFocused, selectedCipher, organizations, onChange, }: {
3
7
  isFocused: boolean;
4
8
  selectedCipher: Cipher;
9
+ organizations: Organization[];
5
10
  onChange: (cipher: Cipher) => void;
6
11
  }): import("react/jsx-runtime").JSX.Element;
@@ -1,14 +1,71 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Box, Text, useStdout } from "ink";
2
+ import { Box, Text, useInput, useStdout } from "ink";
3
+ import { useRef, useId, useState } from "react";
3
4
  import { CipherType } from "../../clients/bw.js";
4
5
  import { primaryLight } from "../../theme/style.js";
5
6
  import { TextInput } from "../../components/TextInput.js";
6
- export function MoreInfoTab({ isFocused, selectedCipher, onChange, }) {
7
+ import { useMouseTarget } from "../../hooks/use-mouse.js";
8
+ export function MoreInfoTab({ isFocused, selectedCipher, organizations, onChange, }) {
7
9
  const { stdout } = useStdout();
8
- return (_jsxs(Box, { flexDirection: "column", gap: 1, height: stdout.rows - 18, children: [_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 18, marginRight: 2, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "ID:" }) }), _jsx(Box, { flexGrow: 1, paddingLeft: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: selectedCipher.id ?? "" }) })] }), !!selectedCipher.organizationId && (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 18, marginRight: 2, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Organization ID:" }) }), _jsx(Box, { flexGrow: 1, paddingLeft: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: selectedCipher.organizationId ?? "" }) })] })), !!selectedCipher.collectionIds?.length && (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 18, marginRight: 2, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Collection IDs:" }) }), _jsx(Box, { flexGrow: 1, paddingLeft: 1, children: _jsx(Box, { flexDirection: "column", children: selectedCipher.collectionIds?.map((id) => (_jsx(TextInput, { inline: true, isActive: isFocused, value: id }, id))) || _jsx(Text, { children: "-" }) }) })] })), !!selectedCipher.folderId && (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 18, marginRight: 2, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Folder ID:" }) }), _jsx(Box, { flexGrow: 1, paddingLeft: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: selectedCipher.folderId ?? "" }) })] })), selectedCipher.type === CipherType.SSHKey && (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 12, marginRight: 2, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Fingerprint:" }) }), _jsx(Box, { flexGrow: 1, paddingLeft: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: selectedCipher.sshKey?.keyFingerprint ?? "" }) })] })), !!selectedCipher.fields?.length && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Fields:" }), selectedCipher.fields?.map((field, idx) => (_jsxs(Box, { flexDirection: "row", paddingLeft: 2, children: [_jsx(Box, { width: 16, marginRight: 2, children: _jsxs(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: [field.name || idx, ":"] }) }), _jsx(Box, { flexGrow: 1, paddingLeft: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: field.value ?? "", onChange: (value) => {
10
+ const [orgCursor, setOrgCursor] = useState(0);
11
+ const canChangeOrg = !selectedCipher.organizationId;
12
+ const orgOptions = canChangeOrg ? [{ id: null, name: "None (Personal)" }, ...organizations.map((o) => ({ id: o.id, name: o.name }))] : [];
13
+ useInput((_input, key) => {
14
+ if (!canChangeOrg)
15
+ return;
16
+ if (key.upArrow)
17
+ setOrgCursor((c) => Math.max(0, c - 1));
18
+ else if (key.downArrow)
19
+ setOrgCursor((c) => Math.min(orgOptions.length - 1, c + 1));
20
+ else if (_input === " ") {
21
+ const selected = orgOptions[orgCursor];
22
+ onChange({ ...selectedCipher, organizationId: selected?.id, collectionIds: [] });
23
+ }
24
+ }, { isActive: isFocused && canChangeOrg });
25
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, height: stdout.rows - 18, children: [_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 9, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "ID:" }) }), _jsx(Box, { flexGrow: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: selectedCipher.id ?? "" }) })] }), selectedCipher.type === CipherType.Login && (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 9, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "TOTP:" }) }), _jsx(Box, { flexGrow: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, isPassword: true, showPasswordOnFocus: true, value: selectedCipher.login?.totp ?? "", onChange: (value) => onChange({
26
+ ...selectedCipher,
27
+ login: { ...selectedCipher.login, totp: value },
28
+ }) }) })] })), canChangeOrg && organizations.length > 0 && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Organization:" }), orgOptions.map((opt, idx) => {
29
+ const checked = selectedCipher.organizationId === opt.id;
30
+ const isCursor = orgCursor === idx && isFocused;
31
+ return (_jsx(OrgCheckbox, { label: opt.name, isCursor: isCursor, checked: checked, onFocus: () => setOrgCursor(idx), onChange: () => onChange({ ...selectedCipher, organizationId: opt.id, collectionIds: [] }) }, opt.id ?? "__none"));
32
+ })] })), !!selectedCipher.organizationId && (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 18, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Organization:" }) }), _jsx(Box, { flexGrow: 1, children: _jsx(Text, { color: "gray", children: organizations.find((o) => o.id === selectedCipher.organizationId)?.name ?? selectedCipher.organizationId }) })] })), !!selectedCipher.folderId && (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 18, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Folder ID:" }) }), _jsx(Box, { flexGrow: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: selectedCipher.folderId ?? "" }) })] })), selectedCipher.type === CipherType.Identity && (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 9, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Address:" }) }), _jsx(Box, { flexGrow: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: selectedCipher.identity?.address1 ?? "", onChange: (value) => onChange({
33
+ ...selectedCipher,
34
+ identity: { ...selectedCipher.identity, address1: value },
35
+ }) }) })] }), _jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 9, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "City:" }) }), _jsx(Box, { flexGrow: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: selectedCipher.identity?.city ?? "", onChange: (value) => onChange({
36
+ ...selectedCipher,
37
+ identity: { ...selectedCipher.identity, city: value },
38
+ }) }) })] }), _jsxs(Box, { flexDirection: "row", flexGrow: 1, gap: 1, children: [_jsxs(Box, { flexDirection: "row", width: "40%", flexShrink: 0, children: [_jsx(Box, { width: 9, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "State:" }) }), _jsx(Box, { flexGrow: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: selectedCipher.identity?.state ?? "", onChange: (value) => onChange({
39
+ ...selectedCipher,
40
+ identity: { ...selectedCipher.identity, state: value },
41
+ }) }) })] }), _jsxs(Box, { flexDirection: "row", width: "60%", flexShrink: 0, children: [_jsx(Box, { width: 13, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Postal Code:" }) }), _jsx(Box, { flexGrow: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: selectedCipher.identity?.postalCode ?? "", onChange: (value) => onChange({
42
+ ...selectedCipher,
43
+ identity: {
44
+ ...selectedCipher.identity,
45
+ postalCode: value,
46
+ },
47
+ }) }) })] })] }), _jsxs(Box, { flexDirection: "row", flexGrow: 1, gap: 1, children: [_jsxs(Box, { flexDirection: "row", width: "40%", flexShrink: 0, children: [_jsx(Box, { width: 9, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Country:" }) }), _jsx(Box, { flexGrow: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: selectedCipher.identity?.country ?? "", onChange: (value) => onChange({
48
+ ...selectedCipher,
49
+ identity: { ...selectedCipher.identity, country: value },
50
+ }) }) })] }), _jsxs(Box, { flexDirection: "row", width: "60%", flexShrink: 0, children: [_jsx(Box, { width: 13, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "License:" }) }), _jsx(Box, { flexGrow: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: selectedCipher.identity?.licenseNumber ?? "", onChange: (value) => onChange({
51
+ ...selectedCipher,
52
+ identity: {
53
+ ...selectedCipher.identity,
54
+ licenseNumber: value,
55
+ },
56
+ }) }) })] })] }), _jsxs(Box, { flexDirection: "row", flexGrow: 1, gap: 1, children: [_jsxs(Box, { flexDirection: "row", width: "40%", flexShrink: 0, children: [_jsx(Box, { width: 9, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "SSN:" }) }), _jsx(Box, { flexGrow: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, isPassword: true, showPasswordOnFocus: true, value: selectedCipher.identity?.ssn ?? "", onChange: (value) => onChange({
57
+ ...selectedCipher,
58
+ identity: { ...selectedCipher.identity, ssn: value },
59
+ }) }) })] }), _jsxs(Box, { flexDirection: "row", width: "60%", flexShrink: 0, children: [_jsx(Box, { width: 13, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Passport:" }) }), _jsx(Box, { flexGrow: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: selectedCipher.identity?.passportNumber ?? "", onChange: (value) => onChange({
60
+ ...selectedCipher,
61
+ identity: {
62
+ ...selectedCipher.identity,
63
+ passportNumber: value,
64
+ },
65
+ }) }) })] })] })] })), selectedCipher.type === CipherType.SSHKey && (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 13, flexShrink: 0, children: _jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Fingerprint:" }) }), _jsx(Box, { flexGrow: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: selectedCipher.sshKey?.keyFingerprint ?? "" }) })] })), !!selectedCipher.fields?.length && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Fields:" }), selectedCipher.fields?.map((field, idx) => (_jsxs(Box, { flexDirection: "row", paddingLeft: 2, children: [_jsx(Box, { width: 16, children: _jsxs(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: [field.name || idx, ":"] }) }), _jsx(Box, { flexGrow: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: field.value ?? "", onChange: (value) => {
9
66
  const newFields = selectedCipher.fields?.map((f, i) => i === idx ? { ...f, value } : f);
10
67
  onChange({ ...selectedCipher, fields: newFields });
11
- } }) })] }, idx)))] })), !!selectedCipher.login?.uris?.length && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Uris:" }), selectedCipher.login.uris.map((uri, idx) => (_jsx(Box, { flexDirection: "row", paddingLeft: 2, children: _jsx(Box, { flexGrow: 1, paddingLeft: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: uri.uri ?? "", onChange: (value) => {
68
+ } }) })] }, idx)))] })), !!selectedCipher.login?.uris?.length && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: isFocused ? primaryLight : "gray", children: "Uris:" }), selectedCipher.login.uris.map((uri, idx) => (_jsx(Box, { flexDirection: "row", paddingLeft: 2, children: _jsx(Box, { flexGrow: 1, children: _jsx(TextInput, { inline: true, isActive: isFocused, value: uri.uri ?? "", onChange: (value) => {
12
69
  const newUris = selectedCipher.login?.uris?.map((u, i) => i === idx ? { ...u, uri: value } : u);
13
70
  onChange({
14
71
  ...selectedCipher,
@@ -16,3 +73,21 @@ export function MoreInfoTab({ isFocused, selectedCipher, onChange, }) {
16
73
  });
17
74
  } }) }) }, idx)))] }))] }));
18
75
  }
76
+ function OrgCheckbox({ label, isCursor, checked, onChange, onFocus, }) {
77
+ const checkRef = useRef(null);
78
+ const labelRef = useRef(null);
79
+ const checkId = useId();
80
+ const labelId = useId();
81
+ useMouseTarget(checkId, checkRef, {
82
+ onClick: () => onChange(),
83
+ });
84
+ useMouseTarget(labelId, labelRef, {
85
+ onClick: () => onFocus(),
86
+ });
87
+ useInput((input) => {
88
+ if (input === " ") {
89
+ onChange();
90
+ }
91
+ }, { isActive: isCursor });
92
+ return (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { ref: checkRef, children: _jsx(Text, { color: isCursor ? "white" : "gray", bold: isCursor, children: checked ? "[x] " : "[ ] " }) }), _jsx(Box, { ref: labelRef, children: _jsx(Text, { color: isCursor ? "white" : "gray", children: label }) })] }));
93
+ }
@@ -1,4 +1,4 @@
1
- import { BwKeys, Client, SyncResponse } from "../clients/bw.js";
1
+ import { BwKeys, CipherType, Client, SyncResponse } from "../clients/bw.js";
2
2
  interface BwConfig {
3
3
  baseUrl?: string;
4
4
  keys: BwKeys;
@@ -19,5 +19,6 @@ export declare const useBwSync: () => {
19
19
  error: string | null;
20
20
  fetchSync: (forceRefresh?: boolean) => Promise<void>;
21
21
  };
22
+ export declare function createEmptyCipher(type?: CipherType): any;
22
23
  export declare const emptyCipher: any;
23
24
  export {};
package/dist/hooks/bw.js CHANGED
@@ -123,15 +123,60 @@ export const useBwSync = () => {
123
123
  }, [fetchSync]);
124
124
  return { sync, error, fetchSync };
125
125
  };
126
- export const emptyCipher = {
127
- name: "",
128
- type: CipherType.Login,
129
- notes: null,
130
- login: {
131
- username: null,
132
- password: null,
133
- uris: [],
134
- },
135
- fields: [],
136
- organizationId: null,
126
+ const emptyLogin = {
127
+ username: null,
128
+ password: null,
129
+ totp: null,
130
+ uris: [],
137
131
  };
132
+ const emptyCard = {
133
+ cardholderName: null,
134
+ brand: null,
135
+ number: null,
136
+ expMonth: null,
137
+ expYear: null,
138
+ code: null,
139
+ };
140
+ const emptyIdentity = {
141
+ title: null,
142
+ firstName: null,
143
+ middleName: null,
144
+ lastName: null,
145
+ username: null,
146
+ company: null,
147
+ email: null,
148
+ phone: null,
149
+ address1: null,
150
+ address2: null,
151
+ address3: null,
152
+ city: null,
153
+ state: null,
154
+ postalCode: null,
155
+ country: null,
156
+ ssn: null,
157
+ passportNumber: null,
158
+ licenseNumber: null,
159
+ };
160
+ export function createEmptyCipher(type = CipherType.Login) {
161
+ const base = {
162
+ name: "",
163
+ type,
164
+ notes: null,
165
+ fields: [],
166
+ organizationId: null,
167
+ collectionIds: [],
168
+ };
169
+ switch (type) {
170
+ case CipherType.Login:
171
+ return { ...base, login: { ...emptyLogin } };
172
+ case CipherType.SecureNote:
173
+ return { ...base, secureNote: { type: 0 } };
174
+ case CipherType.Card:
175
+ return { ...base, card: { ...emptyCard } };
176
+ case CipherType.Identity:
177
+ return { ...base, identity: { ...emptyIdentity } };
178
+ default:
179
+ return base;
180
+ }
181
+ }
182
+ export const emptyCipher = createEmptyCipher();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bitty-tui",
3
- "version": "0.0.18",
3
+ "version": "0.0.19",
4
4
  "license": "MIT",
5
5
  "repository": "https://github.com/mceck/bitty",
6
6
  "keywords": [
@@ -31,9 +31,9 @@
31
31
  "dependencies": {
32
32
  "argon2": "^0.44.0",
33
33
  "chalk": "^5.6.2",
34
- "clipboardy": "^4.0.0",
34
+ "clipboardy": "^5.3.1",
35
35
  "ink": "^6.3.0",
36
- "otplib": "^12.0.1",
36
+ "otplib": "^13.3.0",
37
37
  "react": "^19.1.0",
38
38
  "read-package-up": "^12.0.0"
39
39
  },
package/readme.md CHANGED
@@ -25,10 +25,7 @@ If you check "Remember me" during login, your vault encryption keys will be stor
25
25
 
26
26
  ## TODO
27
27
 
28
- - Collections support
29
28
  - Test Fido, Duo MFA support
30
- - Handle more fields editing
31
- - Handle creating different cipher types
32
29
 
33
30
  ## Acknowledgments
34
31
  - [Bitwarden whitepaper](https://bitwarden.com/help/bitwarden-security-white-paper)