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.
@@ -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")