basic-password-manager 0.1.0__tar.gz → 0.2.0__tar.gz
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.
- {basic_password_manager-0.1.0 → basic_password_manager-0.2.0}/LICENSE +1 -1
- {basic_password_manager-0.1.0 → basic_password_manager-0.2.0}/PKG-INFO +18 -2
- {basic_password_manager-0.1.0 → basic_password_manager-0.2.0}/README.md +16 -1
- {basic_password_manager-0.1.0 → basic_password_manager-0.2.0}/basic_password_manager.egg-info/PKG-INFO +18 -2
- {basic_password_manager-0.1.0 → basic_password_manager-0.2.0}/basic_password_manager.egg-info/SOURCES.txt +1 -0
- {basic_password_manager-0.1.0 → basic_password_manager-0.2.0}/basic_password_manager.egg-info/requires.txt +1 -0
- {basic_password_manager-0.1.0 → basic_password_manager-0.2.0}/pw_manager/cli.py +40 -2
- basic_password_manager-0.2.0/pw_manager/tui.py +349 -0
- {basic_password_manager-0.1.0 → basic_password_manager-0.2.0}/pyproject.toml +2 -1
- {basic_password_manager-0.1.0 → basic_password_manager-0.2.0}/basic_password_manager.egg-info/dependency_links.txt +0 -0
- {basic_password_manager-0.1.0 → basic_password_manager-0.2.0}/basic_password_manager.egg-info/entry_points.txt +0 -0
- {basic_password_manager-0.1.0 → basic_password_manager-0.2.0}/basic_password_manager.egg-info/top_level.txt +0 -0
- {basic_password_manager-0.1.0 → basic_password_manager-0.2.0}/pw_manager/__init__.py +0 -0
- {basic_password_manager-0.1.0 → basic_password_manager-0.2.0}/pw_manager/crypto.py +0 -0
- {basic_password_manager-0.1.0 → basic_password_manager-0.2.0}/pw_manager/vault.py +0 -0
- {basic_password_manager-0.1.0 → basic_password_manager-0.2.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: basic-password-manager
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Local encrypted password manager that lives in your terminal
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Repository, https://github.com/Daisentaur/password-manager
|
|
@@ -9,6 +9,7 @@ Description-Content-Type: text/markdown
|
|
|
9
9
|
License-File: LICENSE
|
|
10
10
|
Requires-Dist: argon2-cffi
|
|
11
11
|
Requires-Dist: cryptography
|
|
12
|
+
Requires-Dist: textual
|
|
12
13
|
Dynamic: license-file
|
|
13
14
|
|
|
14
15
|
# pw-manager
|
|
@@ -44,12 +45,23 @@ For development: `python3 -m venv venv && ./venv/bin/pip install -e .`
|
|
|
44
45
|
|
|
45
46
|
## Usage
|
|
46
47
|
|
|
48
|
+
Run `pw` with no arguments for the interactive TUI: unlock, then `/` to
|
|
49
|
+
filter, Enter to copy the selected password, `n` to add, `e` to edit,
|
|
50
|
+
`d` to delete, `t` to switch themes (persisted across runs), `q` to quit.
|
|
51
|
+
The bundled themes — muted-slate (default), dawn, matrix — are borrowed with
|
|
52
|
+
admiration from [tuxedo](https://github.com/webstonehq/tuxedo); Textual's
|
|
53
|
+
built-in themes are in the picker too.
|
|
54
|
+
|
|
55
|
+
Or use the subcommands:
|
|
56
|
+
|
|
47
57
|
| Command | What it does |
|
|
48
58
|
|---|---|
|
|
49
59
|
| `pw init` | create a new empty vault |
|
|
50
60
|
| `pw add github` | store an entry (prompts for username/password/notes) |
|
|
51
61
|
| `pw add github --gen` | same, but generates a strong random password |
|
|
52
62
|
| `pw get github` | copy the password to the clipboard, show the username |
|
|
63
|
+
| `pw edit github` | update an entry field by field (Enter keeps current) |
|
|
64
|
+
| `pw passwd` | change the master password |
|
|
53
65
|
| `pw ls` | list entry names |
|
|
54
66
|
| `pw rm github` | delete an entry |
|
|
55
67
|
| `pw find bank` | search entry names, usernames, and notes |
|
|
@@ -109,7 +121,11 @@ can't destroy the vault.
|
|
|
109
121
|
Your master password is never stored anywhere, in any form. "Wrong password"
|
|
110
122
|
is detected purely by GCM authentication failing.
|
|
111
123
|
|
|
112
|
-
### 3. `cli.py` — the
|
|
124
|
+
### 3. `cli.py` and `tui.py` — the interfaces
|
|
125
|
+
|
|
126
|
+
Both are thin skins over the same two modules — `tui.py` (built with
|
|
127
|
+
[Textual](https://textual.textualize.io)) holds the decrypted entries in
|
|
128
|
+
memory for the session; the CLI re-derives the key per command.
|
|
113
129
|
|
|
114
130
|
argparse subcommands over the two modules above. Passwords are copied to the
|
|
115
131
|
clipboard (`wl-copy`/`xclip`/`xsel`, whichever exists) rather than printed,
|
|
@@ -31,12 +31,23 @@ For development: `python3 -m venv venv && ./venv/bin/pip install -e .`
|
|
|
31
31
|
|
|
32
32
|
## Usage
|
|
33
33
|
|
|
34
|
+
Run `pw` with no arguments for the interactive TUI: unlock, then `/` to
|
|
35
|
+
filter, Enter to copy the selected password, `n` to add, `e` to edit,
|
|
36
|
+
`d` to delete, `t` to switch themes (persisted across runs), `q` to quit.
|
|
37
|
+
The bundled themes — muted-slate (default), dawn, matrix — are borrowed with
|
|
38
|
+
admiration from [tuxedo](https://github.com/webstonehq/tuxedo); Textual's
|
|
39
|
+
built-in themes are in the picker too.
|
|
40
|
+
|
|
41
|
+
Or use the subcommands:
|
|
42
|
+
|
|
34
43
|
| Command | What it does |
|
|
35
44
|
|---|---|
|
|
36
45
|
| `pw init` | create a new empty vault |
|
|
37
46
|
| `pw add github` | store an entry (prompts for username/password/notes) |
|
|
38
47
|
| `pw add github --gen` | same, but generates a strong random password |
|
|
39
48
|
| `pw get github` | copy the password to the clipboard, show the username |
|
|
49
|
+
| `pw edit github` | update an entry field by field (Enter keeps current) |
|
|
50
|
+
| `pw passwd` | change the master password |
|
|
40
51
|
| `pw ls` | list entry names |
|
|
41
52
|
| `pw rm github` | delete an entry |
|
|
42
53
|
| `pw find bank` | search entry names, usernames, and notes |
|
|
@@ -96,7 +107,11 @@ can't destroy the vault.
|
|
|
96
107
|
Your master password is never stored anywhere, in any form. "Wrong password"
|
|
97
108
|
is detected purely by GCM authentication failing.
|
|
98
109
|
|
|
99
|
-
### 3. `cli.py` — the
|
|
110
|
+
### 3. `cli.py` and `tui.py` — the interfaces
|
|
111
|
+
|
|
112
|
+
Both are thin skins over the same two modules — `tui.py` (built with
|
|
113
|
+
[Textual](https://textual.textualize.io)) holds the decrypted entries in
|
|
114
|
+
memory for the session; the CLI re-derives the key per command.
|
|
100
115
|
|
|
101
116
|
argparse subcommands over the two modules above. Passwords are copied to the
|
|
102
117
|
clipboard (`wl-copy`/`xclip`/`xsel`, whichever exists) rather than printed,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: basic-password-manager
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Local encrypted password manager that lives in your terminal
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Repository, https://github.com/Daisentaur/password-manager
|
|
@@ -9,6 +9,7 @@ Description-Content-Type: text/markdown
|
|
|
9
9
|
License-File: LICENSE
|
|
10
10
|
Requires-Dist: argon2-cffi
|
|
11
11
|
Requires-Dist: cryptography
|
|
12
|
+
Requires-Dist: textual
|
|
12
13
|
Dynamic: license-file
|
|
13
14
|
|
|
14
15
|
# pw-manager
|
|
@@ -44,12 +45,23 @@ For development: `python3 -m venv venv && ./venv/bin/pip install -e .`
|
|
|
44
45
|
|
|
45
46
|
## Usage
|
|
46
47
|
|
|
48
|
+
Run `pw` with no arguments for the interactive TUI: unlock, then `/` to
|
|
49
|
+
filter, Enter to copy the selected password, `n` to add, `e` to edit,
|
|
50
|
+
`d` to delete, `t` to switch themes (persisted across runs), `q` to quit.
|
|
51
|
+
The bundled themes — muted-slate (default), dawn, matrix — are borrowed with
|
|
52
|
+
admiration from [tuxedo](https://github.com/webstonehq/tuxedo); Textual's
|
|
53
|
+
built-in themes are in the picker too.
|
|
54
|
+
|
|
55
|
+
Or use the subcommands:
|
|
56
|
+
|
|
47
57
|
| Command | What it does |
|
|
48
58
|
|---|---|
|
|
49
59
|
| `pw init` | create a new empty vault |
|
|
50
60
|
| `pw add github` | store an entry (prompts for username/password/notes) |
|
|
51
61
|
| `pw add github --gen` | same, but generates a strong random password |
|
|
52
62
|
| `pw get github` | copy the password to the clipboard, show the username |
|
|
63
|
+
| `pw edit github` | update an entry field by field (Enter keeps current) |
|
|
64
|
+
| `pw passwd` | change the master password |
|
|
53
65
|
| `pw ls` | list entry names |
|
|
54
66
|
| `pw rm github` | delete an entry |
|
|
55
67
|
| `pw find bank` | search entry names, usernames, and notes |
|
|
@@ -109,7 +121,11 @@ can't destroy the vault.
|
|
|
109
121
|
Your master password is never stored anywhere, in any form. "Wrong password"
|
|
110
122
|
is detected purely by GCM authentication failing.
|
|
111
123
|
|
|
112
|
-
### 3. `cli.py` — the
|
|
124
|
+
### 3. `cli.py` and `tui.py` — the interfaces
|
|
125
|
+
|
|
126
|
+
Both are thin skins over the same two modules — `tui.py` (built with
|
|
127
|
+
[Textual](https://textual.textualize.io)) holds the decrypted entries in
|
|
128
|
+
memory for the session; the CLI re-derives the key per command.
|
|
113
129
|
|
|
114
130
|
argparse subcommands over the two modules above. Passwords are copied to the
|
|
115
131
|
clipboard (`wl-copy`/`xclip`/`xsel`, whichever exists) rather than printed,
|
|
@@ -84,6 +84,34 @@ def cmd_get(args):
|
|
|
84
84
|
print(f"password: {e['pw']}")
|
|
85
85
|
|
|
86
86
|
|
|
87
|
+
def cmd_edit(args):
|
|
88
|
+
"""Per-field update; Enter keeps the shown current value."""
|
|
89
|
+
master = ask_master()
|
|
90
|
+
entries = vault.load(VAULT_PATH, master)
|
|
91
|
+
if args.name not in entries:
|
|
92
|
+
sys.exit(f"no entry '{args.name}' — try 'ls'")
|
|
93
|
+
e = entries[args.name]
|
|
94
|
+
user = input(f"username [{e['user']}]: ") or e["user"]
|
|
95
|
+
pw = getpass.getpass("password (empty = keep current): ") or e["pw"]
|
|
96
|
+
notes = input(f"notes [{e.get('notes', '')}]: ") or e.get("notes", "")
|
|
97
|
+
entries[args.name] = {"user": user, "pw": pw, "notes": notes}
|
|
98
|
+
vault.save(VAULT_PATH, master, entries)
|
|
99
|
+
print(f"updated '{args.name}'")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def cmd_passwd(args):
|
|
103
|
+
"""Change the master password: decrypt with old, re-encrypt with new."""
|
|
104
|
+
entries = vault.load(VAULT_PATH, ask_master())
|
|
105
|
+
print("choose a new master password")
|
|
106
|
+
new = getpass.getpass("new master password: ")
|
|
107
|
+
if getpass.getpass("confirm new master password: ") != new:
|
|
108
|
+
sys.exit("passwords do not match")
|
|
109
|
+
if len(new) < 8:
|
|
110
|
+
sys.exit("master password must be at least 8 characters")
|
|
111
|
+
vault.save(VAULT_PATH, new, entries)
|
|
112
|
+
print("master password changed (old one is now useless)")
|
|
113
|
+
|
|
114
|
+
|
|
87
115
|
def cmd_ls(args):
|
|
88
116
|
entries = vault.load(VAULT_PATH, ask_master())
|
|
89
117
|
for name in sorted(entries):
|
|
@@ -142,8 +170,10 @@ def cmd_import(args):
|
|
|
142
170
|
|
|
143
171
|
|
|
144
172
|
def main():
|
|
145
|
-
p = argparse.ArgumentParser(
|
|
146
|
-
|
|
173
|
+
p = argparse.ArgumentParser(
|
|
174
|
+
description="local encrypted password manager (run bare for the TUI)"
|
|
175
|
+
)
|
|
176
|
+
sub = p.add_subparsers(dest="command")
|
|
147
177
|
|
|
148
178
|
sub.add_parser("init", help="create a new empty vault")
|
|
149
179
|
a = sub.add_parser("add", help="add or update an entry")
|
|
@@ -151,6 +181,9 @@ def main():
|
|
|
151
181
|
a.add_argument("--gen", action="store_true", help="generate the password")
|
|
152
182
|
g = sub.add_parser("get", help="copy an entry's password to clipboard")
|
|
153
183
|
g.add_argument("name")
|
|
184
|
+
e = sub.add_parser("edit", help="update an entry field by field")
|
|
185
|
+
e.add_argument("name")
|
|
186
|
+
sub.add_parser("passwd", help="change the master password")
|
|
154
187
|
sub.add_parser("ls", help="list entry names")
|
|
155
188
|
r = sub.add_parser("rm", help="delete an entry")
|
|
156
189
|
r.add_argument("name")
|
|
@@ -162,6 +195,11 @@ def main():
|
|
|
162
195
|
i.add_argument("csv_file")
|
|
163
196
|
|
|
164
197
|
args = p.parse_args()
|
|
198
|
+
if args.command is None:
|
|
199
|
+
from . import tui # imported lazily: textual is heavy, subcommands skip it
|
|
200
|
+
|
|
201
|
+
tui.main()
|
|
202
|
+
return
|
|
165
203
|
try:
|
|
166
204
|
globals()[f"cmd_{args.command}"](args)
|
|
167
205
|
except vault.VaultError as e:
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""Interactive TUI, launched by running `pw` with no arguments.
|
|
2
|
+
|
|
3
|
+
Theme palettes copied from tuxedo (github.com/webstonehq/tuxedo,
|
|
4
|
+
src/theme.rs); "muted-slate" is the default. `t` opens the theme picker,
|
|
5
|
+
and Textual's own built-in themes are available there too.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
from textual.app import App, ComposeResult
|
|
11
|
+
from textual.binding import Binding
|
|
12
|
+
from textual.containers import Vertical
|
|
13
|
+
from textual.screen import ModalScreen, Screen
|
|
14
|
+
from textual.theme import Theme
|
|
15
|
+
from textual.widgets import DataTable, Footer, Input, Label, Static
|
|
16
|
+
|
|
17
|
+
from . import vault
|
|
18
|
+
from .cli import VAULT_PATH, generate, to_clipboard
|
|
19
|
+
|
|
20
|
+
THEME_FILE = os.path.expanduser("~/.local/share/pw-manager/theme")
|
|
21
|
+
|
|
22
|
+
TUXEDO_THEMES = [
|
|
23
|
+
Theme(
|
|
24
|
+
name="muted-slate",
|
|
25
|
+
primary="#8aa9c9",
|
|
26
|
+
secondary="#7fb3a8",
|
|
27
|
+
accent="#9a8fc4",
|
|
28
|
+
foreground="#c8ccd4",
|
|
29
|
+
background="#1a1d23",
|
|
30
|
+
surface="#1f232b",
|
|
31
|
+
panel="#252a33",
|
|
32
|
+
success="#7aa67a",
|
|
33
|
+
warning="#d4b06a",
|
|
34
|
+
error="#e07a7a",
|
|
35
|
+
dark=True,
|
|
36
|
+
),
|
|
37
|
+
Theme(
|
|
38
|
+
name="dawn",
|
|
39
|
+
primary="#a35d3a",
|
|
40
|
+
secondary="#3a7a6a",
|
|
41
|
+
accent="#7a4a8a",
|
|
42
|
+
foreground="#3d3528",
|
|
43
|
+
background="#faf6f0",
|
|
44
|
+
surface="#f3ede2",
|
|
45
|
+
panel="#ede2cc",
|
|
46
|
+
success="#5a7a3a",
|
|
47
|
+
warning="#a3722a",
|
|
48
|
+
error="#b8483a",
|
|
49
|
+
dark=False,
|
|
50
|
+
),
|
|
51
|
+
Theme(
|
|
52
|
+
name="matrix",
|
|
53
|
+
primary="#9fff9f",
|
|
54
|
+
secondary="#7fcc7f",
|
|
55
|
+
accent="#cf9fff",
|
|
56
|
+
foreground="#7fcc7f",
|
|
57
|
+
background="#0a120a",
|
|
58
|
+
surface="#0f1a0f",
|
|
59
|
+
panel="#101c10",
|
|
60
|
+
success="#9fff9f",
|
|
61
|
+
warning="#ffd66e",
|
|
62
|
+
error="#ff8c8c",
|
|
63
|
+
dark=True,
|
|
64
|
+
),
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class UnlockScreen(Screen):
|
|
69
|
+
"""Master password prompt shown before anything else."""
|
|
70
|
+
|
|
71
|
+
def compose(self) -> ComposeResult:
|
|
72
|
+
with Vertical(id="unlock-box"):
|
|
73
|
+
yield Label("pw-manager", id="unlock-title")
|
|
74
|
+
yield Input(password=True, placeholder="master password", id="master")
|
|
75
|
+
yield Static("", id="unlock-error")
|
|
76
|
+
|
|
77
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
78
|
+
self.query_one("#unlock-error", Static).update("unlocking…")
|
|
79
|
+
try:
|
|
80
|
+
self.app.entries = vault.load(VAULT_PATH, event.value)
|
|
81
|
+
except vault.VaultError as e:
|
|
82
|
+
self.query_one("#unlock-error", Static).update(str(e))
|
|
83
|
+
self.query_one("#master", Input).clear()
|
|
84
|
+
return
|
|
85
|
+
self.app.master = event.value
|
|
86
|
+
self.app.push_screen(MainScreen())
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class EntryModal(ModalScreen):
|
|
90
|
+
"""Add or edit an entry. Empty password = generate (add) / keep current (edit)."""
|
|
91
|
+
|
|
92
|
+
BINDINGS = [Binding("escape", "dismiss", "cancel")]
|
|
93
|
+
|
|
94
|
+
def __init__(self, name: str = "", entry: dict | None = None) -> None:
|
|
95
|
+
super().__init__()
|
|
96
|
+
self.initial_name = name
|
|
97
|
+
self.entry = entry or {}
|
|
98
|
+
|
|
99
|
+
def compose(self) -> ComposeResult:
|
|
100
|
+
editing = bool(self.entry)
|
|
101
|
+
with Vertical(classes="modal-box"):
|
|
102
|
+
yield Label("edit entry" if editing else "new entry", classes="modal-title")
|
|
103
|
+
yield Input(value=self.initial_name, placeholder="name (e.g. github)", id="e-name")
|
|
104
|
+
yield Input(value=self.entry.get("user", ""), placeholder="username", id="e-user")
|
|
105
|
+
yield Input(
|
|
106
|
+
password=True,
|
|
107
|
+
placeholder="password (empty = keep current)" if editing else "password (empty = generate)",
|
|
108
|
+
id="e-pw",
|
|
109
|
+
)
|
|
110
|
+
yield Input(value=self.entry.get("notes", ""), placeholder="notes (optional)", id="e-notes")
|
|
111
|
+
yield Static("enter saves · esc cancels", classes="modal-hint")
|
|
112
|
+
|
|
113
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
114
|
+
name = self.query_one("#e-name", Input).value.strip()
|
|
115
|
+
if not name:
|
|
116
|
+
self.query_one("#e-name", Input).focus()
|
|
117
|
+
return
|
|
118
|
+
self.dismiss(
|
|
119
|
+
{
|
|
120
|
+
"name": name,
|
|
121
|
+
"user": self.query_one("#e-user", Input).value,
|
|
122
|
+
"pw": self.query_one("#e-pw", Input).value or self.entry.get("pw") or generate(),
|
|
123
|
+
"notes": self.query_one("#e-notes", Input).value,
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class ConfirmModal(ModalScreen):
|
|
129
|
+
"""y/n confirmation."""
|
|
130
|
+
|
|
131
|
+
BINDINGS = [
|
|
132
|
+
Binding("y", "yes", "yes"),
|
|
133
|
+
Binding("n,escape", "no", "no"),
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
def __init__(self, question: str) -> None:
|
|
137
|
+
super().__init__()
|
|
138
|
+
self.question = question
|
|
139
|
+
|
|
140
|
+
def compose(self) -> ComposeResult:
|
|
141
|
+
with Vertical(classes="modal-box"):
|
|
142
|
+
yield Label(self.question, classes="modal-title")
|
|
143
|
+
yield Static("y confirms · n cancels", classes="modal-hint")
|
|
144
|
+
|
|
145
|
+
def action_yes(self) -> None:
|
|
146
|
+
self.dismiss(True)
|
|
147
|
+
|
|
148
|
+
def action_no(self) -> None:
|
|
149
|
+
self.dismiss(False)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class MainScreen(Screen):
|
|
153
|
+
BINDINGS = [
|
|
154
|
+
Binding("enter", "copy", "copy password", priority=True),
|
|
155
|
+
Binding("n", "new", "new entry"),
|
|
156
|
+
Binding("e", "edit", "edit"),
|
|
157
|
+
Binding("d", "delete", "delete"),
|
|
158
|
+
Binding("slash", "search", "search"),
|
|
159
|
+
Binding("t", "app.change_theme", "theme"),
|
|
160
|
+
Binding("escape", "clear_search", show=False),
|
|
161
|
+
Binding("q", "quit", "quit"),
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
def compose(self) -> ComposeResult:
|
|
165
|
+
yield Input(placeholder="/ to search", id="search")
|
|
166
|
+
yield DataTable(id="table", cursor_type="row")
|
|
167
|
+
yield Footer()
|
|
168
|
+
|
|
169
|
+
def on_mount(self) -> None:
|
|
170
|
+
table = self.query_one(DataTable)
|
|
171
|
+
table.add_columns("name", "username", "notes")
|
|
172
|
+
self.refresh_table()
|
|
173
|
+
table.focus()
|
|
174
|
+
|
|
175
|
+
def refresh_table(self) -> None:
|
|
176
|
+
table = self.query_one(DataTable)
|
|
177
|
+
table.clear()
|
|
178
|
+
term = self.query_one("#search", Input).value.lower()
|
|
179
|
+
for name, e in sorted(self.app.entries.items()):
|
|
180
|
+
if (
|
|
181
|
+
term in name.lower()
|
|
182
|
+
or term in e["user"].lower()
|
|
183
|
+
or term in e.get("notes", "").lower()
|
|
184
|
+
):
|
|
185
|
+
table.add_row(name, e["user"], e.get("notes", ""), key=name)
|
|
186
|
+
|
|
187
|
+
def save(self) -> None:
|
|
188
|
+
vault.save(VAULT_PATH, self.app.master, self.app.entries)
|
|
189
|
+
|
|
190
|
+
def selected_name(self) -> str | None:
|
|
191
|
+
table = self.query_one(DataTable)
|
|
192
|
+
if table.row_count == 0:
|
|
193
|
+
return None
|
|
194
|
+
return table.coordinate_to_cell_key(table.cursor_coordinate).row_key.value
|
|
195
|
+
|
|
196
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
197
|
+
self.refresh_table()
|
|
198
|
+
|
|
199
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
200
|
+
self.query_one(DataTable).focus()
|
|
201
|
+
|
|
202
|
+
def action_copy(self) -> None:
|
|
203
|
+
name = self.selected_name()
|
|
204
|
+
if name is None:
|
|
205
|
+
return
|
|
206
|
+
if to_clipboard(self.app.entries[name]["pw"]):
|
|
207
|
+
self.notify(f"password for '{name}' copied", severity="information")
|
|
208
|
+
else:
|
|
209
|
+
self.notify("no clipboard tool (install wl-clipboard)", severity="error")
|
|
210
|
+
|
|
211
|
+
def action_new(self) -> None:
|
|
212
|
+
def done(entry: dict | None) -> None:
|
|
213
|
+
if entry:
|
|
214
|
+
name = entry.pop("name")
|
|
215
|
+
self.app.entries[name] = entry
|
|
216
|
+
self.save()
|
|
217
|
+
self.refresh_table()
|
|
218
|
+
self.notify(f"stored '{name}'")
|
|
219
|
+
|
|
220
|
+
self.app.push_screen(EntryModal(), done)
|
|
221
|
+
|
|
222
|
+
def action_edit(self) -> None:
|
|
223
|
+
name = self.selected_name()
|
|
224
|
+
if name is None:
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
def done(entry: dict | None) -> None:
|
|
228
|
+
if not entry:
|
|
229
|
+
return
|
|
230
|
+
new_name = entry.pop("name")
|
|
231
|
+
if new_name != name:
|
|
232
|
+
if new_name in self.app.entries:
|
|
233
|
+
self.notify(f"'{new_name}' already exists", severity="error")
|
|
234
|
+
return
|
|
235
|
+
del self.app.entries[name]
|
|
236
|
+
self.app.entries[new_name] = entry
|
|
237
|
+
self.save()
|
|
238
|
+
self.refresh_table()
|
|
239
|
+
self.notify(f"updated '{new_name}'")
|
|
240
|
+
|
|
241
|
+
self.app.push_screen(EntryModal(name, dict(self.app.entries[name])), done)
|
|
242
|
+
|
|
243
|
+
def action_delete(self) -> None:
|
|
244
|
+
name = self.selected_name()
|
|
245
|
+
if name is None:
|
|
246
|
+
return
|
|
247
|
+
|
|
248
|
+
def done(confirmed: bool | None) -> None:
|
|
249
|
+
if confirmed:
|
|
250
|
+
del self.app.entries[name]
|
|
251
|
+
self.save()
|
|
252
|
+
self.refresh_table()
|
|
253
|
+
self.notify(f"removed '{name}'")
|
|
254
|
+
|
|
255
|
+
self.app.push_screen(ConfirmModal(f"delete '{name}'?"), done)
|
|
256
|
+
|
|
257
|
+
def action_search(self) -> None:
|
|
258
|
+
self.query_one("#search", Input).focus()
|
|
259
|
+
|
|
260
|
+
def action_clear_search(self) -> None:
|
|
261
|
+
search = self.query_one("#search", Input)
|
|
262
|
+
search.clear()
|
|
263
|
+
self.query_one(DataTable).focus()
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class PwApp(App):
|
|
267
|
+
TITLE = "pw-manager"
|
|
268
|
+
|
|
269
|
+
# Colors come from $theme-variables so every theme applies everywhere;
|
|
270
|
+
# widgets without rules here (table, footer, toasts) use Textual's own
|
|
271
|
+
# theme-aware defaults.
|
|
272
|
+
CSS = """
|
|
273
|
+
#unlock-box {
|
|
274
|
+
align: center middle;
|
|
275
|
+
width: 44;
|
|
276
|
+
height: auto;
|
|
277
|
+
padding: 1 2;
|
|
278
|
+
background: $surface;
|
|
279
|
+
border: round $panel;
|
|
280
|
+
}
|
|
281
|
+
#unlock-title {
|
|
282
|
+
color: $primary;
|
|
283
|
+
text-style: bold;
|
|
284
|
+
margin-bottom: 1;
|
|
285
|
+
}
|
|
286
|
+
#unlock-error {
|
|
287
|
+
color: $error;
|
|
288
|
+
margin-top: 1;
|
|
289
|
+
}
|
|
290
|
+
UnlockScreen {
|
|
291
|
+
align: center middle;
|
|
292
|
+
}
|
|
293
|
+
Input {
|
|
294
|
+
background: $surface;
|
|
295
|
+
border: round $panel;
|
|
296
|
+
}
|
|
297
|
+
Input:focus {
|
|
298
|
+
border: round $primary;
|
|
299
|
+
}
|
|
300
|
+
ModalScreen {
|
|
301
|
+
align: center middle;
|
|
302
|
+
background: $background 60%;
|
|
303
|
+
}
|
|
304
|
+
.modal-box {
|
|
305
|
+
width: 52;
|
|
306
|
+
height: auto;
|
|
307
|
+
padding: 1 2;
|
|
308
|
+
background: $surface;
|
|
309
|
+
border: round $primary;
|
|
310
|
+
}
|
|
311
|
+
.modal-title {
|
|
312
|
+
color: $primary;
|
|
313
|
+
text-style: bold;
|
|
314
|
+
margin-bottom: 1;
|
|
315
|
+
}
|
|
316
|
+
.modal-hint {
|
|
317
|
+
color: $text-muted;
|
|
318
|
+
margin-top: 1;
|
|
319
|
+
}
|
|
320
|
+
"""
|
|
321
|
+
|
|
322
|
+
def __init__(self) -> None:
|
|
323
|
+
super().__init__()
|
|
324
|
+
self.entries: dict = {}
|
|
325
|
+
self.master: str = ""
|
|
326
|
+
|
|
327
|
+
def on_mount(self) -> None:
|
|
328
|
+
for theme in TUXEDO_THEMES:
|
|
329
|
+
self.register_theme(theme)
|
|
330
|
+
try:
|
|
331
|
+
saved = open(THEME_FILE).read().strip()
|
|
332
|
+
except OSError:
|
|
333
|
+
saved = ""
|
|
334
|
+
self.theme = saved if saved in self.available_themes else "muted-slate"
|
|
335
|
+
self.theme_changed_signal.subscribe(self, self._remember_theme)
|
|
336
|
+
self.push_screen(UnlockScreen())
|
|
337
|
+
|
|
338
|
+
def _remember_theme(self, theme: Theme) -> None:
|
|
339
|
+
os.makedirs(os.path.dirname(THEME_FILE), exist_ok=True)
|
|
340
|
+
with open(THEME_FILE, "w") as f:
|
|
341
|
+
f.write(theme.name)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def main() -> None:
|
|
345
|
+
PwApp().run()
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
if __name__ == "__main__":
|
|
349
|
+
main()
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "basic-password-manager"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Local encrypted password manager that lives in your terminal"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -12,6 +12,7 @@ requires-python = ">=3.10"
|
|
|
12
12
|
dependencies = [
|
|
13
13
|
"argon2-cffi",
|
|
14
14
|
"cryptography",
|
|
15
|
+
"textual",
|
|
15
16
|
]
|
|
16
17
|
|
|
17
18
|
[project.scripts]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|