bitwarden-wrapper 0.1.0__py3-none-any.whl
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.
- bitwarden_wrapper/__init__.py +422 -0
- bitwarden_wrapper/tui/bitwarden.css +81 -0
- bitwarden_wrapper/tui/bitwarden_app.py +78 -0
- bitwarden_wrapper/tui/bitwarden_credentials_modal_screen.py +119 -0
- bitwarden_wrapper/tui/bitwarden_existing_item.tcss +109 -0
- bitwarden_wrapper/tui/bitwarden_existing_item_app.py +169 -0
- bitwarden_wrapper-0.1.0.dist-info/METADATA +61 -0
- bitwarden_wrapper-0.1.0.dist-info/RECORD +10 -0
- bitwarden_wrapper-0.1.0.dist-info/WHEEL +4 -0
- bitwarden_wrapper-0.1.0.dist-info/licenses/LICENSE +660 -0
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
# extremely simple bitwarden cli wrapper
|
|
2
|
+
# Author: @jessebot jessebot@linux.com
|
|
3
|
+
"""
|
|
4
|
+
Example:
|
|
5
|
+
bw = BwCLI(my_passsword, my_client_id, my_client_secret)
|
|
6
|
+
bw.unlock()
|
|
7
|
+
random_password = bw.generate()
|
|
8
|
+
bw.create_login(name="test mctest",
|
|
9
|
+
item_url="coolhosting4dogs.tech",
|
|
10
|
+
user="admin",
|
|
11
|
+
password=random_password)
|
|
12
|
+
bw.lock()
|
|
13
|
+
"""
|
|
14
|
+
import base64
|
|
15
|
+
import json
|
|
16
|
+
import logging as log
|
|
17
|
+
from rich.prompt import Prompt, Confirm
|
|
18
|
+
from rich_subproc import subproc
|
|
19
|
+
from shutil import which
|
|
20
|
+
from sys import exit
|
|
21
|
+
from os import environ as env
|
|
22
|
+
|
|
23
|
+
# internal libraries
|
|
24
|
+
from .tui.bitwarden_existing_item_app import AskUserForDuplicateStrategy
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BwCLI():
|
|
28
|
+
"""
|
|
29
|
+
Python Wrapper for the Bitwarden cli
|
|
30
|
+
"""
|
|
31
|
+
def __init__(self,
|
|
32
|
+
password: str,
|
|
33
|
+
client_id: str,
|
|
34
|
+
client_secret: str,
|
|
35
|
+
duplicate_strategy: str = "ask",
|
|
36
|
+
tui: bool = False):
|
|
37
|
+
"""
|
|
38
|
+
for storing the session token, credentials, and duplicate_strategy
|
|
39
|
+
|
|
40
|
+
duplicate_strategy: str, must be one of: edit, ask, duplicate, no_action
|
|
41
|
+
"""
|
|
42
|
+
self.bw_path = str(which("bw"))
|
|
43
|
+
log.debug(f"self.bw_path is {self.bw_path}")
|
|
44
|
+
|
|
45
|
+
if not self.bw_path:
|
|
46
|
+
log.error("whoops, looks like bw isn't installed. "
|
|
47
|
+
"Try [green]brew install bw")
|
|
48
|
+
exit()
|
|
49
|
+
|
|
50
|
+
# if we clean up the session when we're done or not
|
|
51
|
+
self.delete_session = True
|
|
52
|
+
|
|
53
|
+
# this is used for bweso and logins
|
|
54
|
+
self.host = env.get("BW_HOST", "https://bitwarden.com")
|
|
55
|
+
log.debug(f"Using {self.host} as $BW_HOST")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# make sure there's not a session token in the env vars already
|
|
59
|
+
self.env = {"BW_SESSION": env.get("BW_SESSION", ""),
|
|
60
|
+
"PATH": env.get("PATH"),
|
|
61
|
+
"HOME": env.get("HOME"),
|
|
62
|
+
"NODE_OPTIONS": "--no-deprecation"}
|
|
63
|
+
|
|
64
|
+
# credentials
|
|
65
|
+
self.password = password
|
|
66
|
+
self.client_id = client_id
|
|
67
|
+
self.client_secret = client_secret
|
|
68
|
+
self.duplicate_strategy = duplicate_strategy
|
|
69
|
+
|
|
70
|
+
self.tui = tui
|
|
71
|
+
|
|
72
|
+
def sync(self) -> None:
|
|
73
|
+
"""
|
|
74
|
+
syncs your bitwaren vault on initialize of this class
|
|
75
|
+
"""
|
|
76
|
+
res = subproc([f"{self.bw_path} sync"], env=self.env)
|
|
77
|
+
log.info(res)
|
|
78
|
+
|
|
79
|
+
def __get_credential__(self, credential: str) -> str:
|
|
80
|
+
"""
|
|
81
|
+
prompts a user for a specific credential
|
|
82
|
+
"""
|
|
83
|
+
cred_prompt = f"[cyan]🤫 Enter your Bitwarden vault {credential}"
|
|
84
|
+
return Prompt.ask(cred_prompt, password=True)
|
|
85
|
+
|
|
86
|
+
def status(self) -> str:
|
|
87
|
+
"""
|
|
88
|
+
generate a new password. Takes special_characters bool.
|
|
89
|
+
"""
|
|
90
|
+
log.info('Checking if you are logged in...')
|
|
91
|
+
return json.loads(subproc(["bw status"], env=self.env))['status']
|
|
92
|
+
|
|
93
|
+
def unlock(self) -> None:
|
|
94
|
+
"""
|
|
95
|
+
unlocks the local bitwarden vault, and returns session token
|
|
96
|
+
"""
|
|
97
|
+
if self.env['BW_SESSION']:
|
|
98
|
+
log.info('Using session token from $BW_SESSION env variable')
|
|
99
|
+
self.delete_session = False
|
|
100
|
+
|
|
101
|
+
status = self.status()
|
|
102
|
+
if status == "locked" or status == "unauthenticated":
|
|
103
|
+
env = {"BW_PASSWORD": self.password,
|
|
104
|
+
"BW_CLIENTID": self.client_id,
|
|
105
|
+
"BW_CLIENTSECRET": self.client_secret,
|
|
106
|
+
"BW_HOST": self.host,
|
|
107
|
+
"PATH": self.env['PATH'],
|
|
108
|
+
"HOME": self.env['HOME'],
|
|
109
|
+
"NODE_OPTIONS": "--no-deprecation"}
|
|
110
|
+
|
|
111
|
+
# login if we need to
|
|
112
|
+
if status == "unauthenticated":
|
|
113
|
+
log.info('Logging into the Bitwarden vault...')
|
|
114
|
+
# set command to login if we're unauthenticated
|
|
115
|
+
cmd = f"{self.bw_path} login --passwordenv BW_PASSWORD --apikey --raw"
|
|
116
|
+
subproc([cmd], quiet=True, env=env)
|
|
117
|
+
|
|
118
|
+
# we still need to unlock, even if we logged in already
|
|
119
|
+
if status == "locked" or status == "unauthenticated":
|
|
120
|
+
log.info('Unlocking the Bitwarden vault...')
|
|
121
|
+
# set command to unlock if status is locked
|
|
122
|
+
cmd = f"{self.bw_path} unlock --passwordenv BW_PASSWORD --raw"
|
|
123
|
+
|
|
124
|
+
# run either bw login or bw unlock depending on bw status
|
|
125
|
+
self.env['BW_SESSION'] = subproc([cmd], quiet=True, env=env)
|
|
126
|
+
log.debug(f"bw unlock session is {self.env['BW_SESSION']}")
|
|
127
|
+
log.info('Unlocked the Bitwarden vault.')
|
|
128
|
+
else:
|
|
129
|
+
log.info(f"[green]bw status[/] returned '{status}', so we won't "
|
|
130
|
+
"unlock the Bitwarden vault before starting.")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def create_custom_field(custom_field_name: str, value: str) -> dict:
|
|
134
|
+
"""
|
|
135
|
+
creates a custom field dict for a bitwarden login item
|
|
136
|
+
"""
|
|
137
|
+
custom_field = {"name": custom_field_name,
|
|
138
|
+
"value": value,
|
|
139
|
+
"type": 1,
|
|
140
|
+
"linkedId": None}
|
|
141
|
+
|
|
142
|
+
return custom_field
|
|
143
|
+
|
|
144
|
+
def lock(self) -> None:
|
|
145
|
+
"""
|
|
146
|
+
lock bitwarden vault, only if the user didn't have a session env var,
|
|
147
|
+
so that we don't clean up a session the user didn't intend for
|
|
148
|
+
"""
|
|
149
|
+
if self.delete_session:
|
|
150
|
+
log.info('Locking the Bitwarden vault...')
|
|
151
|
+
subproc([f"{self.bw_path} lock"], env=self.env)
|
|
152
|
+
log.info('Bitwarden vault locked.')
|
|
153
|
+
else:
|
|
154
|
+
log.debug("We didn't lock the Bitwarden vault when we were done, "
|
|
155
|
+
"because we didn't set the BW_SESSION env var, so we"
|
|
156
|
+
" don't want to be rude.")
|
|
157
|
+
|
|
158
|
+
def generate(self, special_characters: bool = False) -> str:
|
|
159
|
+
"""
|
|
160
|
+
generate a new password. Takes special_characters bool.
|
|
161
|
+
"""
|
|
162
|
+
log.debug('Generating a new password...')
|
|
163
|
+
|
|
164
|
+
command = "bw generate --length 32 --uppercase --lowercase --number"
|
|
165
|
+
if special_characters:
|
|
166
|
+
command += " --special"
|
|
167
|
+
|
|
168
|
+
password = subproc([command], quiet=True)
|
|
169
|
+
log.debug('New password generated.')
|
|
170
|
+
return password
|
|
171
|
+
|
|
172
|
+
def get_item(self, item_name: str, sync_first: bool = True) -> list:
|
|
173
|
+
"""
|
|
174
|
+
Get Item and return False if it does not exist. If item exists:
|
|
175
|
+
return data, unless there's more than one, then return multiple IDs
|
|
176
|
+
|
|
177
|
+
Required Args:
|
|
178
|
+
- item_name: str of name of item
|
|
179
|
+
"""
|
|
180
|
+
# always sync vault before checking anything, unless otherwise stated
|
|
181
|
+
if sync_first:
|
|
182
|
+
self.sync()
|
|
183
|
+
|
|
184
|
+
# go get the actual item
|
|
185
|
+
response = json.loads(
|
|
186
|
+
subproc([f'{self.bw_path} get item {item_name} --response'],
|
|
187
|
+
error_ok=True,
|
|
188
|
+
quiet=True,
|
|
189
|
+
env=self.env)
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
message = response.get("message", "")
|
|
193
|
+
|
|
194
|
+
# if there is no item, just return False
|
|
195
|
+
if message == "Not found.":
|
|
196
|
+
log.debug(response)
|
|
197
|
+
return False, None
|
|
198
|
+
|
|
199
|
+
elif 'More than one result was found' in message:
|
|
200
|
+
item_list = response['data']
|
|
201
|
+
log.warn(f"found more than 1 entry for {item_name}: {item_list}")
|
|
202
|
+
# make a list of each full item
|
|
203
|
+
list_for_dialog = []
|
|
204
|
+
|
|
205
|
+
# ask the user what to do
|
|
206
|
+
if self.tui:
|
|
207
|
+
for id in item_list:
|
|
208
|
+
list_for_dialog.append(self.get_item(id)[0])
|
|
209
|
+
|
|
210
|
+
user_response = AskUserForDuplicateStrategy(list_for_dialog,
|
|
211
|
+
item_name).run()
|
|
212
|
+
action = user_response[0]
|
|
213
|
+
always_do_action = user_response[1]
|
|
214
|
+
item = user_response[2]
|
|
215
|
+
|
|
216
|
+
else:
|
|
217
|
+
action = Prompt.ask(
|
|
218
|
+
f"Found more than 1 entry for {item_name}. "
|
|
219
|
+
"What would you like to do?",
|
|
220
|
+
choices=["duplicate", "edit", "no_action"])
|
|
221
|
+
if action == "edit":
|
|
222
|
+
print("Here are the items:")
|
|
223
|
+
for id in item_list:
|
|
224
|
+
item_data = self.get_item(id)[0]
|
|
225
|
+
print(f"ID: {item_data['id']}, Name: {item_data['name']}")
|
|
226
|
+
list_for_dialog.append(item_data['id'])
|
|
227
|
+
|
|
228
|
+
item = Prompt.ask("Which item ID would you like to edit?",
|
|
229
|
+
choices=(list_for_dialog))
|
|
230
|
+
elif action == "duplicate" or action == "no_action":
|
|
231
|
+
always_do_action = Confirm.ask(
|
|
232
|
+
f"Would you like to '{action}' every time?")
|
|
233
|
+
|
|
234
|
+
# if they always want to do this, then set self.duplicate_strategy
|
|
235
|
+
if always_do_action:
|
|
236
|
+
# NOTE: we still always ask if there's more than 1 entry returned
|
|
237
|
+
self.duplicate_strategy = action
|
|
238
|
+
|
|
239
|
+
return item, action
|
|
240
|
+
else:
|
|
241
|
+
data = response.get('data', "")
|
|
242
|
+
log.debug(data)
|
|
243
|
+
if not data:
|
|
244
|
+
log.debug(response)
|
|
245
|
+
return data, self.duplicate_strategy
|
|
246
|
+
|
|
247
|
+
def list_items(self, url: str = "", sync_first: bool = True) -> dict:
|
|
248
|
+
"""
|
|
249
|
+
get multiplte times associated with a given url.
|
|
250
|
+
|
|
251
|
+
Optionally specify:
|
|
252
|
+
sync_first: bool - sync the vault before checking for items
|
|
253
|
+
|
|
254
|
+
Returns a dict of name: id
|
|
255
|
+
"""
|
|
256
|
+
# always sync vault before checking anything, unless otherwise stated
|
|
257
|
+
if sync_first:
|
|
258
|
+
self.sync()
|
|
259
|
+
|
|
260
|
+
# go get the actual item
|
|
261
|
+
response = json.loads(
|
|
262
|
+
subproc([f'{self.bw_path} list items --search {url}'],
|
|
263
|
+
error_ok=True,
|
|
264
|
+
quiet=True,
|
|
265
|
+
env=self.env
|
|
266
|
+
)
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
# if there is no item, just return False
|
|
270
|
+
if not response:
|
|
271
|
+
log.debug("We didn't get anything back from bitwarden... 🤔")
|
|
272
|
+
return None
|
|
273
|
+
else:
|
|
274
|
+
# make a convenient dictionary to use downstream
|
|
275
|
+
item_dict = {}
|
|
276
|
+
for item in response:
|
|
277
|
+
name = item['name']
|
|
278
|
+
item_dict[name] = item['id']
|
|
279
|
+
return item_dict
|
|
280
|
+
|
|
281
|
+
def create_login(self,
|
|
282
|
+
name: str = "",
|
|
283
|
+
item_url: str = "",
|
|
284
|
+
user: str = "",
|
|
285
|
+
password: str = "",
|
|
286
|
+
fields: list = [],
|
|
287
|
+
org: str = None,
|
|
288
|
+
collection: str = None,
|
|
289
|
+
strategy: str = None,
|
|
290
|
+
sync: bool = None) -> str:
|
|
291
|
+
"""
|
|
292
|
+
Create login item to store a set of credentials for one site.
|
|
293
|
+
Required Args:
|
|
294
|
+
- name: str of the name of the item to create in the vault
|
|
295
|
+
Optional Args:
|
|
296
|
+
- user: str of username to use for login item
|
|
297
|
+
- password: str of password you want to use for login item
|
|
298
|
+
- item_url: str of URL you want to use the credentials for
|
|
299
|
+
- org: str of organization to use for collection
|
|
300
|
+
- collection: str collection inside organization to user
|
|
301
|
+
- fields: list of {key: value} dicts for custom fields
|
|
302
|
+
- strategy: str that defaults to self.duplicate_strategy
|
|
303
|
+
|
|
304
|
+
Returns string of the item id created or updated
|
|
305
|
+
"""
|
|
306
|
+
# don't edit anything by default
|
|
307
|
+
edit = False
|
|
308
|
+
|
|
309
|
+
# fix naming for bitwarden items to inlude the url AND name
|
|
310
|
+
if name:
|
|
311
|
+
item_name = name
|
|
312
|
+
if item_url:
|
|
313
|
+
item_name += f"-{item_url}"
|
|
314
|
+
else:
|
|
315
|
+
item_name = item_url
|
|
316
|
+
|
|
317
|
+
# make sure sync is set to False by default
|
|
318
|
+
if not sync:
|
|
319
|
+
sync = False
|
|
320
|
+
|
|
321
|
+
# go check for existing items
|
|
322
|
+
item_res = self.get_item(item_name, sync)
|
|
323
|
+
item = item_res[0]
|
|
324
|
+
|
|
325
|
+
if not strategy:
|
|
326
|
+
strategy = item_res[1]
|
|
327
|
+
|
|
328
|
+
if item:
|
|
329
|
+
if strategy == "ask":
|
|
330
|
+
if self.tui:
|
|
331
|
+
user_response = AskUserForDuplicateStrategy(item,
|
|
332
|
+
item_name).run()
|
|
333
|
+
strategy = user_response[0]
|
|
334
|
+
always_do_action = user_response[1]
|
|
335
|
+
else:
|
|
336
|
+
item_desc = f'"{item['name']}" (id: {item['id']})'
|
|
337
|
+
strategy = Prompt.ask(
|
|
338
|
+
f"A similar entry already exists for {item_name}. "
|
|
339
|
+
f"It's called {item_desc}. What would you like to do?",
|
|
340
|
+
choices=["duplicate", "edit", "no_action"])
|
|
341
|
+
always_do_action = Confirm.ask(
|
|
342
|
+
f"Would you like to '{strategy}' every time?")
|
|
343
|
+
|
|
344
|
+
# if they always want to do this, then set self.duplicate_strategy
|
|
345
|
+
if always_do_action:
|
|
346
|
+
# NOTE: we still always ask if there's more than 1 entry returned
|
|
347
|
+
self.duplicate_strategy = strategy
|
|
348
|
+
|
|
349
|
+
if strategy == 'edit':
|
|
350
|
+
log.info("duplicate_strategy set to edit, so we will edit the "
|
|
351
|
+
f"existing item: {name}")
|
|
352
|
+
edit = True
|
|
353
|
+
|
|
354
|
+
elif strategy == 'duplicate':
|
|
355
|
+
msg = (f"😵 Item named {name} already exists in your Bitwarden"
|
|
356
|
+
" vault and duplicate_strategy is set to 'duplicate'."
|
|
357
|
+
" We will create the item anyway, but the Bitwarden ESO "
|
|
358
|
+
"Provider may have trouble finding it :(")
|
|
359
|
+
log.warn(msg)
|
|
360
|
+
|
|
361
|
+
elif strategy == "no_action":
|
|
362
|
+
log.info(
|
|
363
|
+
"We've encounted an existing entry for the item we were going "
|
|
364
|
+
" to create. duplicate_strategy is set to 'no_action', so we "
|
|
365
|
+
"will not replace or edit it nor will we create a new item."
|
|
366
|
+
f"item: {item}"
|
|
367
|
+
)
|
|
368
|
+
return
|
|
369
|
+
|
|
370
|
+
# create new item
|
|
371
|
+
if not edit:
|
|
372
|
+
if item:
|
|
373
|
+
log.info(f'Not editing Bitwarden item "{item_name}", because we '
|
|
374
|
+
'were instructed to create a duplicate.')
|
|
375
|
+
else:
|
|
376
|
+
log.info(f'Creating Bitwarden login item "{item_name}"')
|
|
377
|
+
|
|
378
|
+
login_obj = json.dumps({
|
|
379
|
+
"organizationId": org,
|
|
380
|
+
"collectionIds": collection,
|
|
381
|
+
"folderId": None,
|
|
382
|
+
"type": 1,
|
|
383
|
+
"name": item_name,
|
|
384
|
+
"notes": None,
|
|
385
|
+
"favorite": False,
|
|
386
|
+
"fields": fields,
|
|
387
|
+
"login": {"uris": [{"match": 0,
|
|
388
|
+
"uri": item_url}],
|
|
389
|
+
"username": user,
|
|
390
|
+
"password": password,
|
|
391
|
+
"totp": None},
|
|
392
|
+
"secureNote": None,
|
|
393
|
+
"card": None,
|
|
394
|
+
"identity": None,
|
|
395
|
+
"reprompt": 0})
|
|
396
|
+
|
|
397
|
+
encodedBytes = base64.b64encode(login_obj.encode("utf-8"))
|
|
398
|
+
encodedStr = str(encodedBytes, "utf-8")
|
|
399
|
+
|
|
400
|
+
cmd = f"{self.bw_path} create item {encodedStr}"
|
|
401
|
+
|
|
402
|
+
# edit existing item
|
|
403
|
+
else:
|
|
404
|
+
log.info(f'Updating existing Bitwarden login item "{item_name}"...')
|
|
405
|
+
item['login']['password'] = password
|
|
406
|
+
item['login']['username'] = user
|
|
407
|
+
item['fields'] = fields
|
|
408
|
+
|
|
409
|
+
encodedBytes = base64.b64encode(json.dumps(item).encode("utf-8"))
|
|
410
|
+
encodedStr = str(encodedBytes, "utf-8")
|
|
411
|
+
|
|
412
|
+
cmd = f"{self.bw_path} edit item {item['id']} {encodedStr}"
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
# edit OR create the item
|
|
416
|
+
bitwarden_return_item = subproc([cmd + " --response"], quiet=True, env=self.env)
|
|
417
|
+
log.debug(bitwarden_return_item)
|
|
418
|
+
|
|
419
|
+
if edit:
|
|
420
|
+
return item['id']
|
|
421
|
+
else:
|
|
422
|
+
return json.loads(bitwarden_return_item)['data']['id']
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
$bluish_white: rgb(189,216,255);
|
|
2
|
+
$lavender: rgb(174,168,248);
|
|
3
|
+
$spacechalk_red: rgb(255,141,135);
|
|
4
|
+
$neon_magenta: rgb(242,137,249);
|
|
5
|
+
$light_pink: rgb(255,175,249);
|
|
6
|
+
$orange: rgb(253,205,54);
|
|
7
|
+
$soft_yellow: rgb(249,249,134);
|
|
8
|
+
$soft_green: rgb(193,255,135);
|
|
9
|
+
$spacechalk_lime: rgb(168,253,87);
|
|
10
|
+
$sky_blue: rgb(92,201,253);
|
|
11
|
+
$cornflower: rgb(95,135,255);
|
|
12
|
+
$light_cornflower: rgb(122,162,247);
|
|
13
|
+
$blue_gray: rgb(86,95,137);
|
|
14
|
+
$dark_gray: rgb(58,58,58);
|
|
15
|
+
$navy: rgb(35,35,54);
|
|
16
|
+
|
|
17
|
+
/* base container for the whole screen */
|
|
18
|
+
#bitwarden-modal-container {
|
|
19
|
+
width: 90%;
|
|
20
|
+
height: 60%;
|
|
21
|
+
align: center middle;
|
|
22
|
+
background: $navy 50%;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/* box with border and inputs and button */
|
|
26
|
+
#credentials-box {
|
|
27
|
+
align: center middle;
|
|
28
|
+
content-align: center middle;
|
|
29
|
+
padding: 1;
|
|
30
|
+
border: round $cornflower;
|
|
31
|
+
border-title-color: $soft_green;
|
|
32
|
+
grid-gutter: 1;
|
|
33
|
+
grid-rows: 0.25fr 1fr 0.25fr;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* help blurb */
|
|
37
|
+
#bitwarden-help-text {
|
|
38
|
+
color: $blue_gray;
|
|
39
|
+
width: 100%;
|
|
40
|
+
margin-left: 1;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
#all-bitwarden-inputs {
|
|
44
|
+
align: center middle;
|
|
45
|
+
content-align: center middle;
|
|
46
|
+
grid-gutter: 1;
|
|
47
|
+
padding-left: 1;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* bitwarden label and input row */
|
|
51
|
+
.bitwarden-input-row {
|
|
52
|
+
grid-size: 2;
|
|
53
|
+
grid-columns: 0.42fr 1fr;
|
|
54
|
+
height: auto;
|
|
55
|
+
align: center middle;
|
|
56
|
+
content-align: center middle;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* bitwarden input label */
|
|
60
|
+
.bitwarden-label {
|
|
61
|
+
content-align: left middle;
|
|
62
|
+
color: $light_cornflower 80%;
|
|
63
|
+
width: 100%;
|
|
64
|
+
height: 3;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* bitwarden input */
|
|
68
|
+
.bitwarden-inputs {
|
|
69
|
+
height: 100%;
|
|
70
|
+
content-align: left middle;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/* submit button */
|
|
74
|
+
#bitwarden-button-box {
|
|
75
|
+
grid-size: 2;
|
|
76
|
+
align: center middle;
|
|
77
|
+
margin-bottom: 1;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
#bitwarden-submit {
|
|
81
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from smol_k8s_lab.tui.bitwarden.bitwarden_credentials_modal_screen import (
|
|
2
|
+
BitwardenCredentialsScreen)
|
|
3
|
+
from textual.app import App, ComposeResult
|
|
4
|
+
from textual.widgets import Label
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class BitwardenCredentialsApp(App[None]):
|
|
8
|
+
"""
|
|
9
|
+
small app to launch a modal screen for a bitwarden credentials prompt
|
|
10
|
+
"""
|
|
11
|
+
CSS = """
|
|
12
|
+
$bluish_white: rgb(189,216,255);
|
|
13
|
+
$lavender: rgb(174,168,248);
|
|
14
|
+
$neon_magenta: rgb(242,137,249);
|
|
15
|
+
$orange: rgb(253,205,54);
|
|
16
|
+
$spacechalk_lime: rgb(168,253,87);
|
|
17
|
+
$light_cornflower: rgb(122,162,247);
|
|
18
|
+
$blue_gray: rgb(86,95,137);
|
|
19
|
+
$dark_gray: rgb(58,58,58);
|
|
20
|
+
$navy: rgb(35,35,54);
|
|
21
|
+
|
|
22
|
+
Screen {
|
|
23
|
+
align: center middle;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
Tooltip {
|
|
27
|
+
background: $navy 50%;
|
|
28
|
+
color: $bluish_white 75%;
|
|
29
|
+
border: dashed gray 80%;
|
|
30
|
+
padding-bottom: 0;
|
|
31
|
+
padding-left: 1;
|
|
32
|
+
padding-right: 1;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
Button {
|
|
36
|
+
color: $light_cornflower;
|
|
37
|
+
background: $lavender 10%;
|
|
38
|
+
border-top: tall $blue_gray 60%;
|
|
39
|
+
border-bottom: tall $dark_gray 50%;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
Input {
|
|
43
|
+
background: $bluish_white 8%;
|
|
44
|
+
color: white 75%;
|
|
45
|
+
border: tall black 18%;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
Input.-invalid {
|
|
49
|
+
border: tall $neon_magenta 90%;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
Input.-valid:focus {
|
|
53
|
+
border: tall $spacechalk_lime 70%;
|
|
54
|
+
}
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def compose(self) -> ComposeResult:
|
|
58
|
+
"""
|
|
59
|
+
check if we need to grab the password or not
|
|
60
|
+
"""
|
|
61
|
+
yield Label("")
|
|
62
|
+
self.get_credentials()
|
|
63
|
+
|
|
64
|
+
def get_credentials(self) -> None:
|
|
65
|
+
"""
|
|
66
|
+
launch a modal screen wtih inputs for bitwarden credentials
|
|
67
|
+
"""
|
|
68
|
+
def check_modal_output(credentials: dict):
|
|
69
|
+
if credentials:
|
|
70
|
+
self.app.exit(credentials)
|
|
71
|
+
else:
|
|
72
|
+
self.app.exit(None)
|
|
73
|
+
|
|
74
|
+
self.app.push_screen(BitwardenCredentialsScreen(), check_modal_output)
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
app = BitwardenCredentialsApp()
|
|
78
|
+
app.run()
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from rich.text import Text
|
|
2
|
+
from textual import on
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual.binding import Binding
|
|
5
|
+
from textual.containers import Grid
|
|
6
|
+
from textual.screen import ModalScreen
|
|
7
|
+
from textual.validation import Length
|
|
8
|
+
from textual.widgets import Label, Input, Button
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
HELP_TXT = ("Requires [u][link=https://bitwarden.com/help/personal-api-key/]"
|
|
12
|
+
"personal API credentials[/][/]. To avoid this prompt, export the "
|
|
13
|
+
"following env vars before running smol-k8s-lab: "
|
|
14
|
+
"BW_PASSWORD, BW_CLIENTID, BW_CLIENTSECRET")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BitwardenCredentialsScreen(ModalScreen):
|
|
18
|
+
"""
|
|
19
|
+
modal screen to ask for bitwarden credentials
|
|
20
|
+
"""
|
|
21
|
+
CSS_PATH = "./bitwarden.css"
|
|
22
|
+
BINDINGS = [Binding(key="b,escape,q",
|
|
23
|
+
key_display="b",
|
|
24
|
+
action="press_cancel",
|
|
25
|
+
description=" Back")]
|
|
26
|
+
|
|
27
|
+
def compose(self) -> ComposeResult:
|
|
28
|
+
|
|
29
|
+
# base container for the whole screen
|
|
30
|
+
with Grid(id="bitwarden-modal-container"):
|
|
31
|
+
|
|
32
|
+
# container for the actual help, inputs, and confirm button
|
|
33
|
+
with Grid(id="credentials-box"):
|
|
34
|
+
|
|
35
|
+
# base help text to start us off
|
|
36
|
+
yield Label(Text.from_markup(HELP_TXT), id="bitwarden-help-text")
|
|
37
|
+
|
|
38
|
+
with Grid(id="all-bitwarden-inputs"):
|
|
39
|
+
for variable in ['password', 'client_id', 'client_secret']:
|
|
40
|
+
yield self.generate_credential_row(variable)
|
|
41
|
+
|
|
42
|
+
with Grid(id="bitwarden-button-box"):
|
|
43
|
+
# submit button, to trigger returning the credentials/exiting
|
|
44
|
+
confirm_button = Button("submit", id="bitwarden-submit")
|
|
45
|
+
confirm_button.disabled = True
|
|
46
|
+
confirm_button.tooltip = ("submit your credentials, disabled"
|
|
47
|
+
" until all fields are filled out.")
|
|
48
|
+
yield confirm_button
|
|
49
|
+
|
|
50
|
+
# cancel button, to go back to the script or previous tui screen
|
|
51
|
+
back_button = Button("cancel", id="bitwarden-cancel")
|
|
52
|
+
back_button.tooltip = ("Exit Bitwarden credentials input screen "
|
|
53
|
+
"without saving")
|
|
54
|
+
yield back_button
|
|
55
|
+
|
|
56
|
+
def on_mount(self,) -> None:
|
|
57
|
+
credentials_box = self.get_widget_by_id("credentials-box")
|
|
58
|
+
credentials_box.border_title = "🛡️ Enter Bitwarden Vault Credentials"
|
|
59
|
+
|
|
60
|
+
# we set this at the beginning, so we can still return empty if they cancel
|
|
61
|
+
self.credentials = {}
|
|
62
|
+
|
|
63
|
+
def action_press_cancel(self) -> None:
|
|
64
|
+
"""
|
|
65
|
+
presses the cancel button
|
|
66
|
+
"""
|
|
67
|
+
self.get_widget_by_id("bitwarden-cancel").action_press()
|
|
68
|
+
|
|
69
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
70
|
+
"""
|
|
71
|
+
return bitwarden password, client id, and client secret as a dict
|
|
72
|
+
"""
|
|
73
|
+
if event.button.id == "bitwarden-cancel":
|
|
74
|
+
self.dismiss(None)
|
|
75
|
+
else:
|
|
76
|
+
self.dismiss(self.credentials)
|
|
77
|
+
|
|
78
|
+
@on(Input.Changed)
|
|
79
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
80
|
+
"""
|
|
81
|
+
on any input field change, check all input fields and enable or disable
|
|
82
|
+
the confirm button
|
|
83
|
+
"""
|
|
84
|
+
all_valid = True
|
|
85
|
+
|
|
86
|
+
all_inputs = self.query(".bitwarden-inputs")
|
|
87
|
+
|
|
88
|
+
# check all the input validations
|
|
89
|
+
for input in all_inputs:
|
|
90
|
+
is_not_valid = input.validate(input.value).failures
|
|
91
|
+
# returns None if input is valid
|
|
92
|
+
if is_not_valid:
|
|
93
|
+
all_valid = False
|
|
94
|
+
# if valid, make sure it's in our final return dict
|
|
95
|
+
else:
|
|
96
|
+
self.credentials[input.name] = input.value
|
|
97
|
+
|
|
98
|
+
confirm_button = self.get_widget_by_id("bitwarden-submit")
|
|
99
|
+
|
|
100
|
+
# disable button if any inputs are invalid
|
|
101
|
+
if all_valid:
|
|
102
|
+
confirm_button.disabled = False
|
|
103
|
+
else:
|
|
104
|
+
confirm_button.disabled = True
|
|
105
|
+
|
|
106
|
+
def generate_credential_row(self, label: str) -> None:
|
|
107
|
+
"""
|
|
108
|
+
return a row of a Label and Input in a Grid container
|
|
109
|
+
"""
|
|
110
|
+
pretty_label = label.replace("_", " ").replace("id", "ID") + ":"
|
|
111
|
+
input_label = Label(pretty_label, classes="bitwarden-label")
|
|
112
|
+
|
|
113
|
+
input = Input(placeholder=label,
|
|
114
|
+
password=True,
|
|
115
|
+
validators=Length(minimum=2),
|
|
116
|
+
classes="bitwarden-inputs",
|
|
117
|
+
name=label)
|
|
118
|
+
|
|
119
|
+
return Grid(input_label, input, classes="bitwarden-input-row")
|