ntermqt 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.
Files changed (52) hide show
  1. nterm/__init__.py +54 -0
  2. nterm/__main__.py +619 -0
  3. nterm/askpass/__init__.py +22 -0
  4. nterm/askpass/server.py +393 -0
  5. nterm/config.py +158 -0
  6. nterm/connection/__init__.py +17 -0
  7. nterm/connection/profile.py +296 -0
  8. nterm/manager/__init__.py +29 -0
  9. nterm/manager/connect_dialog.py +322 -0
  10. nterm/manager/editor.py +262 -0
  11. nterm/manager/io.py +678 -0
  12. nterm/manager/models.py +346 -0
  13. nterm/manager/settings.py +264 -0
  14. nterm/manager/tree.py +493 -0
  15. nterm/resources.py +48 -0
  16. nterm/session/__init__.py +60 -0
  17. nterm/session/askpass_ssh.py +399 -0
  18. nterm/session/base.py +110 -0
  19. nterm/session/interactive_ssh.py +522 -0
  20. nterm/session/pty_transport.py +571 -0
  21. nterm/session/ssh.py +610 -0
  22. nterm/terminal/__init__.py +11 -0
  23. nterm/terminal/bridge.py +83 -0
  24. nterm/terminal/resources/terminal.html +253 -0
  25. nterm/terminal/resources/terminal.js +414 -0
  26. nterm/terminal/resources/xterm-addon-fit.min.js +8 -0
  27. nterm/terminal/resources/xterm-addon-unicode11.min.js +8 -0
  28. nterm/terminal/resources/xterm-addon-web-links.min.js +8 -0
  29. nterm/terminal/resources/xterm.css +209 -0
  30. nterm/terminal/resources/xterm.min.js +8 -0
  31. nterm/terminal/widget.py +380 -0
  32. nterm/theme/__init__.py +10 -0
  33. nterm/theme/engine.py +456 -0
  34. nterm/theme/stylesheet.py +377 -0
  35. nterm/theme/themes/clean.yaml +0 -0
  36. nterm/theme/themes/default.yaml +36 -0
  37. nterm/theme/themes/dracula.yaml +36 -0
  38. nterm/theme/themes/gruvbox_dark.yaml +36 -0
  39. nterm/theme/themes/gruvbox_hybrid.yaml +38 -0
  40. nterm/theme/themes/gruvbox_light.yaml +36 -0
  41. nterm/vault/__init__.py +32 -0
  42. nterm/vault/credential_manager.py +163 -0
  43. nterm/vault/keychain.py +135 -0
  44. nterm/vault/manager_ui.py +962 -0
  45. nterm/vault/profile.py +219 -0
  46. nterm/vault/resolver.py +250 -0
  47. nterm/vault/store.py +642 -0
  48. ntermqt-0.1.0.dist-info/METADATA +327 -0
  49. ntermqt-0.1.0.dist-info/RECORD +52 -0
  50. ntermqt-0.1.0.dist-info/WHEEL +5 -0
  51. ntermqt-0.1.0.dist-info/entry_points.txt +5 -0
  52. ntermqt-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,962 @@
1
+ """
2
+ PyQt6 Credential Manager UI.
3
+
4
+ Provides a complete interface for managing vault credentials.
5
+ """
6
+
7
+ from __future__ import annotations
8
+ import logging
9
+ from typing import Optional, Callable
10
+ from dataclasses import dataclass
11
+
12
+ from PyQt6.QtWidgets import (
13
+ QWidget, QVBoxLayout, QHBoxLayout, QGridLayout,
14
+ QLabel, QLineEdit, QTextEdit, QPushButton, QCheckBox,
15
+ QComboBox, QTableWidget, QTableWidgetItem, QHeaderView,
16
+ QDialog, QDialogButtonBox, QMessageBox, QFrame,
17
+ QStackedWidget, QFileDialog, QGroupBox, QSplitter,
18
+ QAbstractItemView, QStyle, QApplication,
19
+ )
20
+ from PyQt6.QtCore import Qt, pyqtSignal, QSize
21
+ from PyQt6.QtGui import QFont, QIcon
22
+
23
+ from .store import CredentialStore, StoredCredential
24
+ from .keychain import KeychainIntegration
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ @dataclass
30
+ class ManagerTheme:
31
+ """Theme configuration for the credential manager."""
32
+ background_color: str = "#1e1e2e"
33
+ foreground_color: str = "#cdd6f4"
34
+ border_color: str = "#313244"
35
+ accent_color: str = "#89b4fa"
36
+ input_background: str = "#313244"
37
+ button_background: str = "#45475a"
38
+ button_hover: str = "#585b70"
39
+ error_color: str = "#f38ba8"
40
+ success_color: str = "#a6e3a1"
41
+ warning_color: str = "#f9e2af"
42
+ font_family: str = "JetBrains Mono, Cascadia Code, Consolas, monospace"
43
+ font_size: int = 12
44
+
45
+ @classmethod
46
+ def from_terminal_theme(cls, theme) -> ManagerTheme:
47
+ """Create manager theme from terminal Theme object."""
48
+ # Detect if this is a light or dark theme
49
+ bg = theme.background_color.lstrip('#')
50
+ bg_brightness = sum(int(bg[i:i+2], 16) for i in (0, 2, 4)) / 3
51
+ is_light_theme = bg_brightness > 128
52
+
53
+ if is_light_theme:
54
+ # Light theme: darken background slightly for inputs
55
+ input_bg = cls._adjust_brightness(theme.background_color, -15)
56
+ button_bg = cls._adjust_brightness(theme.background_color, -25)
57
+ button_hover = cls._adjust_brightness(theme.background_color, -35)
58
+ else:
59
+ # Dark theme: use terminal black or lighten background
60
+ input_bg = theme.terminal_colors.get("black", "#313244")
61
+ button_bg = "#45475a"
62
+ button_hover = "#585b70"
63
+
64
+ return cls(
65
+ background_color=theme.background_color,
66
+ foreground_color=theme.foreground_color,
67
+ border_color=theme.border_color,
68
+ accent_color=theme.accent_color,
69
+ input_background=input_bg,
70
+ button_background=button_bg,
71
+ button_hover=button_hover,
72
+ font_family=theme.font_family,
73
+ font_size=theme.font_size - 2, # Slightly smaller for UI
74
+ )
75
+
76
+ @staticmethod
77
+ def _adjust_brightness(hex_color: str, amount: int) -> str:
78
+ """Adjust color brightness. Positive = lighter, negative = darker."""
79
+ hex_color = hex_color.lstrip('#')
80
+ r = max(0, min(255, int(hex_color[0:2], 16) + amount))
81
+ g = max(0, min(255, int(hex_color[2:4], 16) + amount))
82
+ b = max(0, min(255, int(hex_color[4:6], 16) + amount))
83
+ return f"#{r:02x}{g:02x}{b:02x}"
84
+
85
+ def to_stylesheet(self) -> str:
86
+ """Generate Qt stylesheet from theme."""
87
+ return f"""
88
+ QWidget {{
89
+ background-color: {self.background_color};
90
+ color: {self.foreground_color};
91
+ font-family: {self.font_family};
92
+ font-size: {self.font_size}px;
93
+ }}
94
+
95
+ QLineEdit, QTextEdit, QComboBox {{
96
+ background-color: {self.input_background};
97
+ border: 1px solid {self.border_color};
98
+ border-radius: 4px;
99
+ padding: 6px 10px;
100
+ color: {self.foreground_color};
101
+ selection-background-color: {self.accent_color};
102
+ }}
103
+
104
+ QLineEdit:focus, QTextEdit:focus, QComboBox:focus {{
105
+ border-color: {self.accent_color};
106
+ }}
107
+
108
+ QPushButton {{
109
+ background-color: {self.button_background};
110
+ border: 1px solid {self.border_color};
111
+ border-radius: 4px;
112
+ padding: 8px 16px;
113
+ color: {self.foreground_color};
114
+ min-width: 80px;
115
+ }}
116
+
117
+ QPushButton:hover {{
118
+ background-color: {self.button_hover};
119
+ border-color: {self.accent_color};
120
+ }}
121
+
122
+ QPushButton:pressed {{
123
+ background-color: {self.accent_color};
124
+ }}
125
+
126
+ QPushButton[primary="true"] {{
127
+ background-color: {self.accent_color};
128
+ color: {self.background_color};
129
+ font-weight: bold;
130
+ }}
131
+
132
+ QPushButton[primary="true"]:hover {{
133
+ background-color: {self.foreground_color};
134
+ }}
135
+
136
+ QPushButton[danger="true"] {{
137
+ background-color: {self.error_color};
138
+ color: {self.background_color};
139
+ }}
140
+
141
+ QTableWidget {{
142
+ background-color: {self.background_color};
143
+ border: 1px solid {self.border_color};
144
+ border-radius: 4px;
145
+ gridline-color: {self.border_color};
146
+ }}
147
+
148
+ QTableWidget::item {{
149
+ padding: 8px;
150
+ border-bottom: 1px solid {self.border_color};
151
+ }}
152
+
153
+ QTableWidget::item:selected {{
154
+ background-color: {self.accent_color};
155
+ color: {self.background_color};
156
+ }}
157
+
158
+ QHeaderView::section {{
159
+ background-color: {self.input_background};
160
+ color: {self.foreground_color};
161
+ padding: 8px;
162
+ border: none;
163
+ border-bottom: 2px solid {self.accent_color};
164
+ font-weight: bold;
165
+ }}
166
+
167
+ QGroupBox {{
168
+ border: 1px solid {self.border_color};
169
+ border-radius: 4px;
170
+ margin-top: 12px;
171
+ padding-top: 8px;
172
+ font-weight: bold;
173
+ }}
174
+
175
+ QGroupBox::title {{
176
+ subcontrol-origin: margin;
177
+ left: 10px;
178
+ padding: 0 5px;
179
+ color: {self.accent_color};
180
+ }}
181
+
182
+ QCheckBox {{
183
+ spacing: 8px;
184
+ }}
185
+
186
+ QCheckBox::indicator {{
187
+ width: 18px;
188
+ height: 18px;
189
+ border: 1px solid {self.border_color};
190
+ border-radius: 3px;
191
+ background-color: {self.input_background};
192
+ }}
193
+
194
+ QCheckBox::indicator:checked {{
195
+ background-color: {self.accent_color};
196
+ border-color: {self.accent_color};
197
+ }}
198
+
199
+ QLabel[heading="true"] {{
200
+ font-size: {self.font_size + 4}px;
201
+ font-weight: bold;
202
+ color: {self.accent_color};
203
+ }}
204
+
205
+ QLabel[subheading="true"] {{
206
+ color: {self.button_hover};
207
+ font-size: {self.font_size - 1}px;
208
+ }}
209
+
210
+ QFrame[separator="true"] {{
211
+ background-color: {self.border_color};
212
+ max-height: 1px;
213
+ }}
214
+
215
+ QDialog {{
216
+ background-color: {self.background_color};
217
+ }}
218
+
219
+ QMessageBox {{
220
+ background-color: {self.background_color};
221
+ }}
222
+
223
+ QScrollBar:vertical {{
224
+ background-color: {self.background_color};
225
+ width: 12px;
226
+ border-radius: 6px;
227
+ }}
228
+
229
+ QScrollBar::handle:vertical {{
230
+ background-color: {self.button_background};
231
+ border-radius: 6px;
232
+ min-height: 30px;
233
+ }}
234
+
235
+ QScrollBar::handle:vertical:hover {{
236
+ background-color: {self.button_hover};
237
+ }}
238
+
239
+ QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{
240
+ height: 0px;
241
+ }}
242
+ """
243
+
244
+
245
+ class UnlockDialog(QDialog):
246
+ """Dialog for unlocking the vault."""
247
+
248
+ def __init__(
249
+ self,
250
+ parent: QWidget = None,
251
+ theme: ManagerTheme = None,
252
+ is_init: bool = False,
253
+ ):
254
+ super().__init__(parent)
255
+ self.theme = theme or ManagerTheme()
256
+ self.is_init = is_init
257
+ self._setup_ui()
258
+
259
+ def _setup_ui(self):
260
+ self.setWindowTitle("Initialize Vault" if self.is_init else "Unlock Vault")
261
+ self.setMinimumWidth(400)
262
+ self.setStyleSheet(self.theme.to_stylesheet())
263
+
264
+ layout = QVBoxLayout(self)
265
+ layout.setSpacing(16)
266
+ layout.setContentsMargins(24, 24, 24, 24)
267
+
268
+ # Header
269
+ title = QLabel("🔐 " + ("Create Master Password" if self.is_init else "Enter Master Password"))
270
+ title.setProperty("heading", True)
271
+ layout.addWidget(title)
272
+
273
+ if self.is_init:
274
+ hint = QLabel("This password encrypts all stored credentials.\nChoose a strong password you'll remember.")
275
+ hint.setProperty("subheading", True)
276
+ hint.setWordWrap(True)
277
+ layout.addWidget(hint)
278
+
279
+ # Password field
280
+ self.password_input = QLineEdit()
281
+ self.password_input.setEchoMode(QLineEdit.EchoMode.Password)
282
+ self.password_input.setPlaceholderText("Master password")
283
+ layout.addWidget(self.password_input)
284
+
285
+ # Confirm field (for init)
286
+ if self.is_init:
287
+ self.confirm_input = QLineEdit()
288
+ self.confirm_input.setEchoMode(QLineEdit.EchoMode.Password)
289
+ self.confirm_input.setPlaceholderText("Confirm password")
290
+ layout.addWidget(self.confirm_input)
291
+
292
+ # Remember checkbox (if keychain available)
293
+ if KeychainIntegration.is_available():
294
+ self.remember_check = QCheckBox("Remember password in system keychain")
295
+ self.remember_check.setChecked(True)
296
+ layout.addWidget(self.remember_check)
297
+ else:
298
+ self.remember_check = None
299
+
300
+ # Error label
301
+ self.error_label = QLabel()
302
+ self.error_label.setStyleSheet(f"color: {self.theme.error_color};")
303
+ self.error_label.hide()
304
+ layout.addWidget(self.error_label)
305
+
306
+ # Buttons
307
+ btn_layout = QHBoxLayout()
308
+ btn_layout.addStretch()
309
+
310
+ cancel_btn = QPushButton("Cancel")
311
+ cancel_btn.clicked.connect(self.reject)
312
+ btn_layout.addWidget(cancel_btn)
313
+
314
+ self.ok_btn = QPushButton("Create Vault" if self.is_init else "Unlock")
315
+ self.ok_btn.setProperty("primary", True)
316
+ self.ok_btn.clicked.connect(self._validate_and_accept)
317
+ btn_layout.addWidget(self.ok_btn)
318
+
319
+ layout.addLayout(btn_layout)
320
+
321
+ # Enter key triggers OK
322
+ self.password_input.returnPressed.connect(self._validate_and_accept)
323
+ if self.is_init:
324
+ self.confirm_input.returnPressed.connect(self._validate_and_accept)
325
+
326
+ def _validate_and_accept(self):
327
+ password = self.password_input.text()
328
+
329
+ if not password:
330
+ self._show_error("Password is required")
331
+ return
332
+
333
+ if self.is_init:
334
+ if len(password) < 8:
335
+ self._show_error("Password must be at least 8 characters")
336
+ return
337
+ if password != self.confirm_input.text():
338
+ self._show_error("Passwords don't match")
339
+ return
340
+
341
+ self.accept()
342
+
343
+ def _show_error(self, message: str):
344
+ self.error_label.setText(message)
345
+ self.error_label.show()
346
+
347
+ def get_password(self) -> str:
348
+ return self.password_input.text()
349
+
350
+ def should_remember(self) -> bool:
351
+ return self.remember_check.isChecked() if self.remember_check else False
352
+
353
+
354
+ class CredentialDialog(QDialog):
355
+ """Dialog for adding/editing a credential."""
356
+
357
+ def __init__(
358
+ self,
359
+ parent: QWidget = None,
360
+ theme: ManagerTheme = None,
361
+ credential: StoredCredential = None,
362
+ ):
363
+ super().__init__(parent)
364
+ self.theme = theme or ManagerTheme()
365
+ self.credential = credential
366
+ self.is_edit = credential is not None
367
+ self._setup_ui()
368
+
369
+ if credential:
370
+ self._populate_from_credential(credential)
371
+
372
+ def _setup_ui(self):
373
+ self.setWindowTitle("Edit Credential" if self.is_edit else "Add Credential")
374
+ self.setMinimumWidth(500)
375
+ self.setMinimumHeight(600)
376
+ self.setStyleSheet(self.theme.to_stylesheet())
377
+
378
+ layout = QVBoxLayout(self)
379
+ layout.setSpacing(16)
380
+ layout.setContentsMargins(24, 24, 24, 24)
381
+
382
+ # Basic info group
383
+ basic_group = QGroupBox("Basic Information")
384
+ basic_layout = QGridLayout(basic_group)
385
+ basic_layout.setSpacing(12)
386
+
387
+ basic_layout.addWidget(QLabel("Name:"), 0, 0)
388
+ self.name_input = QLineEdit()
389
+ self.name_input.setPlaceholderText("e.g., production-servers")
390
+ basic_layout.addWidget(self.name_input, 0, 1)
391
+
392
+ basic_layout.addWidget(QLabel("Username:"), 1, 0)
393
+ self.username_input = QLineEdit()
394
+ self.username_input.setPlaceholderText("SSH username")
395
+ basic_layout.addWidget(self.username_input, 1, 1)
396
+
397
+ layout.addWidget(basic_group)
398
+
399
+ # Authentication group
400
+ auth_group = QGroupBox("Authentication")
401
+ auth_layout = QGridLayout(auth_group)
402
+ auth_layout.setSpacing(12)
403
+
404
+ auth_layout.addWidget(QLabel("Password:"), 0, 0)
405
+ self.password_input = QLineEdit()
406
+ self.password_input.setEchoMode(QLineEdit.EchoMode.Password)
407
+ self.password_input.setPlaceholderText("Optional - leave blank for key-only auth")
408
+ auth_layout.addWidget(self.password_input, 0, 1)
409
+
410
+ auth_layout.addWidget(QLabel("SSH Key:"), 1, 0)
411
+ key_layout = QHBoxLayout()
412
+ self.ssh_key_input = QTextEdit()
413
+ self.ssh_key_input.setPlaceholderText("Paste private key or use Browse...")
414
+ self.ssh_key_input.setMaximumHeight(100)
415
+ key_layout.addWidget(self.ssh_key_input)
416
+
417
+ browse_btn = QPushButton("Browse...")
418
+ browse_btn.clicked.connect(self._browse_key)
419
+ key_layout.addWidget(browse_btn)
420
+ auth_layout.addLayout(key_layout, 1, 1)
421
+
422
+ auth_layout.addWidget(QLabel("Key Passphrase:"), 2, 0)
423
+ self.key_passphrase_input = QLineEdit()
424
+ self.key_passphrase_input.setEchoMode(QLineEdit.EchoMode.Password)
425
+ self.key_passphrase_input.setPlaceholderText("If key is encrypted")
426
+ auth_layout.addWidget(self.key_passphrase_input, 2, 1)
427
+
428
+ layout.addWidget(auth_group)
429
+
430
+ # Jump host group
431
+ jump_group = QGroupBox("Jump Host (Optional)")
432
+ jump_layout = QGridLayout(jump_group)
433
+ jump_layout.setSpacing(12)
434
+
435
+ jump_layout.addWidget(QLabel("Jump Host:"), 0, 0)
436
+ self.jump_host_input = QLineEdit()
437
+ self.jump_host_input.setPlaceholderText("e.g., bastion.example.com")
438
+ jump_layout.addWidget(self.jump_host_input, 0, 1)
439
+
440
+ jump_layout.addWidget(QLabel("Jump Username:"), 1, 0)
441
+ self.jump_username_input = QLineEdit()
442
+ self.jump_username_input.setPlaceholderText("Leave blank to use main username")
443
+ jump_layout.addWidget(self.jump_username_input, 1, 1)
444
+
445
+ jump_layout.addWidget(QLabel("Jump Auth:"), 2, 0)
446
+ self.jump_auth_combo = QComboBox()
447
+ self.jump_auth_combo.addItems(["SSH Agent", "Password", "Key"])
448
+ jump_layout.addWidget(self.jump_auth_combo, 2, 1)
449
+
450
+ self.jump_touch_check = QCheckBox("Requires YubiKey touch")
451
+ jump_layout.addWidget(self.jump_touch_check, 3, 1)
452
+
453
+ layout.addWidget(jump_group)
454
+
455
+ # Matching group
456
+ match_group = QGroupBox("Matching Rules")
457
+ match_layout = QGridLayout(match_group)
458
+ match_layout.setSpacing(12)
459
+
460
+ match_layout.addWidget(QLabel("Host Patterns:"), 0, 0)
461
+ self.match_hosts_input = QLineEdit()
462
+ self.match_hosts_input.setPlaceholderText("Comma-separated: *.prod.example.com, 10.0.*")
463
+ match_layout.addWidget(self.match_hosts_input, 0, 1)
464
+
465
+ match_layout.addWidget(QLabel("Tags:"), 1, 0)
466
+ self.match_tags_input = QLineEdit()
467
+ self.match_tags_input.setPlaceholderText("Comma-separated: production, linux, cisco")
468
+ match_layout.addWidget(self.match_tags_input, 1, 1)
469
+
470
+ self.default_check = QCheckBox("Use as default credential")
471
+ match_layout.addWidget(self.default_check, 2, 1)
472
+
473
+ layout.addWidget(match_group)
474
+
475
+ layout.addStretch()
476
+
477
+ # Error label
478
+ self.error_label = QLabel()
479
+ self.error_label.setStyleSheet(f"color: {self.theme.error_color};")
480
+ self.error_label.hide()
481
+ layout.addWidget(self.error_label)
482
+
483
+ # Buttons
484
+ btn_layout = QHBoxLayout()
485
+ btn_layout.addStretch()
486
+
487
+ cancel_btn = QPushButton("Cancel")
488
+ cancel_btn.clicked.connect(self.reject)
489
+ btn_layout.addWidget(cancel_btn)
490
+
491
+ save_btn = QPushButton("Save" if self.is_edit else "Add")
492
+ save_btn.setProperty("primary", True)
493
+ save_btn.clicked.connect(self._validate_and_accept)
494
+ btn_layout.addWidget(save_btn)
495
+
496
+ layout.addLayout(btn_layout)
497
+
498
+ def _browse_key(self):
499
+ path, _ = QFileDialog.getOpenFileName(
500
+ self,
501
+ "Select SSH Key",
502
+ str(Path.home() / ".ssh"),
503
+ "All Files (*)"
504
+ )
505
+ if path:
506
+ try:
507
+ with open(path) as f:
508
+ self.ssh_key_input.setPlainText(f.read())
509
+ except Exception as e:
510
+ QMessageBox.warning(self, "Error", f"Failed to read key: {e}")
511
+
512
+ def _populate_from_credential(self, cred: StoredCredential):
513
+ self.name_input.setText(cred.name)
514
+ self.name_input.setEnabled(False) # Can't change name on edit
515
+ self.username_input.setText(cred.username)
516
+
517
+ # Don't show actual secrets - user must re-enter to change
518
+ if cred.has_password:
519
+ self.password_input.setPlaceholderText("(unchanged - enter new to replace)")
520
+ if cred.has_ssh_key:
521
+ self.ssh_key_input.setPlaceholderText("(unchanged - paste new to replace)")
522
+
523
+ if cred.jump_host:
524
+ self.jump_host_input.setText(cred.jump_host)
525
+ if cred.jump_username:
526
+ self.jump_username_input.setText(cred.jump_username)
527
+
528
+ auth_map = {"agent": 0, "password": 1, "key": 2}
529
+ self.jump_auth_combo.setCurrentIndex(auth_map.get(cred.jump_auth_method, 0))
530
+ self.jump_touch_check.setChecked(cred.jump_requires_touch)
531
+
532
+ if cred.match_hosts:
533
+ self.match_hosts_input.setText(", ".join(cred.match_hosts))
534
+ if cred.match_tags:
535
+ self.match_tags_input.setText(", ".join(cred.match_tags))
536
+
537
+ self.default_check.setChecked(cred.is_default)
538
+
539
+ def _validate_and_accept(self):
540
+ if not self.name_input.text().strip():
541
+ self._show_error("Name is required")
542
+ return
543
+ if not self.username_input.text().strip():
544
+ self._show_error("Username is required")
545
+ return
546
+
547
+ # Must have at least one auth method (for new creds)
548
+ if not self.is_edit:
549
+ has_password = bool(self.password_input.text())
550
+ has_key = bool(self.ssh_key_input.toPlainText().strip())
551
+ if not has_password and not has_key:
552
+ self._show_error("Provide password or SSH key (or both)")
553
+ return
554
+
555
+ self.accept()
556
+
557
+ def _show_error(self, message: str):
558
+ self.error_label.setText(message)
559
+ self.error_label.show()
560
+
561
+ def get_credential_data(self) -> dict:
562
+ """Get credential data as dict for store.add_credential()."""
563
+ data = {
564
+ "name": self.name_input.text().strip(),
565
+ "username": self.username_input.text().strip(),
566
+ }
567
+
568
+ # Only include secrets if provided
569
+ if self.password_input.text():
570
+ data["password"] = self.password_input.text()
571
+ if self.ssh_key_input.toPlainText().strip():
572
+ data["ssh_key"] = self.ssh_key_input.toPlainText().strip()
573
+ if self.key_passphrase_input.text():
574
+ data["ssh_key_passphrase"] = self.key_passphrase_input.text()
575
+
576
+ # Jump host
577
+ if self.jump_host_input.text().strip():
578
+ data["jump_host"] = self.jump_host_input.text().strip()
579
+ if self.jump_username_input.text().strip():
580
+ data["jump_username"] = self.jump_username_input.text().strip()
581
+ auth_map = {0: "agent", 1: "password", 2: "key"}
582
+ data["jump_auth_method"] = auth_map[self.jump_auth_combo.currentIndex()]
583
+ data["jump_requires_touch"] = self.jump_touch_check.isChecked()
584
+
585
+ # Matching
586
+ if self.match_hosts_input.text().strip():
587
+ data["match_hosts"] = [
588
+ h.strip() for h in self.match_hosts_input.text().split(",") if h.strip()
589
+ ]
590
+ if self.match_tags_input.text().strip():
591
+ data["match_tags"] = [
592
+ t.strip() for t in self.match_tags_input.text().split(",") if t.strip()
593
+ ]
594
+
595
+ data["is_default"] = self.default_check.isChecked()
596
+
597
+ return data
598
+
599
+
600
+ class CredentialManagerWidget(QWidget):
601
+ """
602
+ Main credential manager widget.
603
+
604
+ Provides complete CRUD interface for vault credentials.
605
+
606
+ Signals:
607
+ credential_selected: Emitted when user selects a credential
608
+ vault_locked: Emitted when vault is locked
609
+ vault_unlocked: Emitted when vault is unlocked
610
+ """
611
+
612
+ credential_selected = pyqtSignal(str) # credential name
613
+ vault_locked = pyqtSignal()
614
+ vault_unlocked = pyqtSignal()
615
+
616
+ def __init__(
617
+ self,
618
+ store: CredentialStore = None,
619
+ theme: ManagerTheme = None,
620
+ parent: QWidget = None,
621
+ use_own_stylesheet: bool = False,
622
+ ):
623
+ super().__init__(parent)
624
+ self.store = store or CredentialStore()
625
+ self.theme = theme or ManagerTheme()
626
+ self._use_own_stylesheet = use_own_stylesheet
627
+ self._setup_ui()
628
+ self._refresh_state()
629
+
630
+ def set_theme(self, theme) -> None:
631
+ """
632
+ Set theme from terminal Theme object.
633
+
634
+ Args:
635
+ theme: Theme object from nterm.theme.engine
636
+ """
637
+ self.theme = ManagerTheme.from_terminal_theme(theme)
638
+ if self._use_own_stylesheet:
639
+ self.setStyleSheet(self.theme.to_stylesheet())
640
+ # Otherwise, parent window applies stylesheet via generate_stylesheet()
641
+
642
+ def _setup_ui(self):
643
+ if self._use_own_stylesheet:
644
+ self.setStyleSheet(self.theme.to_stylesheet())
645
+
646
+ layout = QVBoxLayout(self)
647
+ layout.setSpacing(16)
648
+ layout.setContentsMargins(16, 16, 16, 16)
649
+
650
+ # Header
651
+ header_layout = QHBoxLayout()
652
+
653
+ title = QLabel("🔐 Credential Vault")
654
+ title.setProperty("heading", True)
655
+ header_layout.addWidget(title)
656
+
657
+ header_layout.addStretch()
658
+
659
+ # Lock/unlock button
660
+ self.lock_btn = QPushButton("Lock")
661
+ self.lock_btn.clicked.connect(self._toggle_lock)
662
+ header_layout.addWidget(self.lock_btn)
663
+
664
+ layout.addLayout(header_layout)
665
+
666
+ # Status line
667
+ self.status_label = QLabel()
668
+ self.status_label.setProperty("subheading", True)
669
+ layout.addWidget(self.status_label)
670
+
671
+ # Stacked widget for locked/unlocked states
672
+ self.stack = QStackedWidget()
673
+
674
+ # Locked view
675
+ locked_widget = QWidget()
676
+ locked_layout = QVBoxLayout(locked_widget)
677
+ locked_layout.addStretch()
678
+
679
+ lock_icon = QLabel("🔒")
680
+ lock_icon.setStyleSheet("font-size: 48px;")
681
+ lock_icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
682
+ locked_layout.addWidget(lock_icon)
683
+
684
+ locked_msg = QLabel("Vault is locked")
685
+ locked_msg.setAlignment(Qt.AlignmentFlag.AlignCenter)
686
+ locked_msg.setProperty("heading", True)
687
+ locked_layout.addWidget(locked_msg)
688
+
689
+ self.unlock_btn = QPushButton("Unlock Vault")
690
+ self.unlock_btn.setProperty("primary", True)
691
+ self.unlock_btn.setMaximumWidth(200)
692
+ self.unlock_btn.clicked.connect(self._show_unlock_dialog)
693
+ locked_layout.addWidget(self.unlock_btn, alignment=Qt.AlignmentFlag.AlignCenter)
694
+
695
+ locked_layout.addStretch()
696
+ self.stack.addWidget(locked_widget)
697
+
698
+ # Unlocked view
699
+ unlocked_widget = QWidget()
700
+ unlocked_layout = QVBoxLayout(unlocked_widget)
701
+ unlocked_layout.setSpacing(12)
702
+
703
+ # Toolbar
704
+ toolbar_layout = QHBoxLayout()
705
+
706
+ self.add_btn = QPushButton("➕ Add")
707
+ self.add_btn.clicked.connect(self._add_credential)
708
+ toolbar_layout.addWidget(self.add_btn)
709
+
710
+ self.edit_btn = QPushButton("✏️ Edit")
711
+ self.edit_btn.clicked.connect(self._edit_credential)
712
+ self.edit_btn.setEnabled(False)
713
+ toolbar_layout.addWidget(self.edit_btn)
714
+
715
+ self.delete_btn = QPushButton("🗑️ Delete")
716
+ self.delete_btn.setProperty("danger", True)
717
+ self.delete_btn.clicked.connect(self._delete_credential)
718
+ self.delete_btn.setEnabled(False)
719
+ toolbar_layout.addWidget(self.delete_btn)
720
+
721
+ toolbar_layout.addStretch()
722
+
723
+ self.refresh_btn = QPushButton("🔄 Refresh")
724
+ self.refresh_btn.clicked.connect(self._refresh_credentials)
725
+ toolbar_layout.addWidget(self.refresh_btn)
726
+
727
+ unlocked_layout.addLayout(toolbar_layout)
728
+
729
+ # Credentials table
730
+ self.table = QTableWidget()
731
+ self.table.setColumnCount(6)
732
+ self.table.setHorizontalHeaderLabels([
733
+ "Name", "Username", "Auth", "Jump Host", "Default", "Last Used"
734
+ ])
735
+ self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
736
+ self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
737
+ self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
738
+ self.table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
739
+ self.table.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents)
740
+ self.table.horizontalHeader().setSectionResizeMode(5, QHeaderView.ResizeMode.ResizeToContents)
741
+ self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
742
+ self.table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
743
+ self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
744
+ self.table.itemSelectionChanged.connect(self._on_selection_changed)
745
+ self.table.doubleClicked.connect(self._edit_credential)
746
+
747
+ unlocked_layout.addWidget(self.table)
748
+
749
+ self.stack.addWidget(unlocked_widget)
750
+
751
+ layout.addWidget(self.stack)
752
+
753
+ # Keychain info
754
+ if KeychainIntegration.is_available():
755
+ keychain_label = QLabel(
756
+ f"✓ System keychain available ({KeychainIntegration.get_backend_name()})"
757
+ )
758
+ keychain_label.setProperty("subheading", True)
759
+ layout.addWidget(keychain_label)
760
+
761
+ def _refresh_state(self):
762
+ """Refresh UI state based on vault status."""
763
+ is_initialized = self.store.is_initialized()
764
+ is_unlocked = self.store.is_unlocked
765
+
766
+ if not is_initialized:
767
+ self.status_label.setText("Vault not initialized - click Unlock to create")
768
+ self.stack.setCurrentIndex(0)
769
+ self.unlock_btn.setText("Create Vault")
770
+ self.lock_btn.hide()
771
+ elif is_unlocked:
772
+ self.status_label.setText(f"Vault unlocked - {self.store.db_path}")
773
+ self.stack.setCurrentIndex(1)
774
+ self.lock_btn.show()
775
+ self.lock_btn.setText("🔒 Lock")
776
+ self._refresh_credentials()
777
+ else:
778
+ self.status_label.setText(f"Vault locked - {self.store.db_path}")
779
+ self.stack.setCurrentIndex(0)
780
+ self.unlock_btn.setText("Unlock Vault")
781
+ self.lock_btn.hide()
782
+
783
+ def _toggle_lock(self):
784
+ if self.store.is_unlocked:
785
+ self.store.lock()
786
+ self.vault_locked.emit()
787
+ self._refresh_state()
788
+
789
+ def _show_unlock_dialog(self):
790
+ is_init = not self.store.is_initialized()
791
+ dialog = UnlockDialog(self, self.theme, is_init=is_init)
792
+
793
+ if dialog.exec() == QDialog.DialogCode.Accepted:
794
+ password = dialog.get_password()
795
+ remember = dialog.should_remember()
796
+
797
+ try:
798
+ if is_init:
799
+ self.store.init_vault(password)
800
+ success = self.store.unlock(password)
801
+ else:
802
+ success = self.store.unlock(password)
803
+
804
+ if success:
805
+ if remember:
806
+ KeychainIntegration.store_master_password(password)
807
+ self.vault_unlocked.emit()
808
+ self._refresh_state()
809
+ else:
810
+ QMessageBox.warning(self, "Error", "Invalid password")
811
+ except Exception as e:
812
+ QMessageBox.critical(self, "Error", str(e))
813
+
814
+ def try_auto_unlock(self) -> bool:
815
+ """
816
+ Try to auto-unlock using keychain.
817
+
818
+ Returns:
819
+ True if unlocked successfully
820
+ """
821
+ if not self.store.is_initialized():
822
+ return False
823
+
824
+ password = KeychainIntegration.get_master_password()
825
+ if password and self.store.unlock(password):
826
+ self.vault_unlocked.emit()
827
+ self._refresh_state()
828
+ return True
829
+ return False
830
+
831
+ def _refresh_credentials(self):
832
+ """Refresh the credentials table."""
833
+ self.table.setRowCount(0)
834
+
835
+ try:
836
+ credentials = self.store.list_credentials()
837
+ except Exception as e:
838
+ logger.error(f"Failed to list credentials: {e}")
839
+ return
840
+
841
+ for cred in credentials:
842
+ row = self.table.rowCount()
843
+ self.table.insertRow(row)
844
+
845
+ self.table.setItem(row, 0, QTableWidgetItem(cred.name))
846
+ self.table.setItem(row, 1, QTableWidgetItem(cred.username))
847
+
848
+ # Auth methods
849
+ auth_parts = []
850
+ if cred.has_password:
851
+ auth_parts.append("🔑")
852
+ if cred.has_ssh_key:
853
+ auth_parts.append("🗝️")
854
+ self.table.setItem(row, 2, QTableWidgetItem(" ".join(auth_parts) or "Agent"))
855
+
856
+ self.table.setItem(row, 3, QTableWidgetItem(cred.jump_host or "—"))
857
+ self.table.setItem(row, 4, QTableWidgetItem("✓" if cred.is_default else ""))
858
+
859
+ last_used = cred.last_used.strftime("%Y-%m-%d %H:%M") if cred.last_used else "Never"
860
+ self.table.setItem(row, 5, QTableWidgetItem(last_used))
861
+
862
+ def _on_selection_changed(self):
863
+ has_selection = len(self.table.selectedItems()) > 0
864
+ self.edit_btn.setEnabled(has_selection)
865
+ self.delete_btn.setEnabled(has_selection)
866
+
867
+ if has_selection:
868
+ row = self.table.currentRow()
869
+ name = self.table.item(row, 0).text()
870
+ self.credential_selected.emit(name)
871
+
872
+ def _add_credential(self):
873
+ dialog = CredentialDialog(self, self.theme)
874
+ if dialog.exec() == QDialog.DialogCode.Accepted:
875
+ data = dialog.get_credential_data()
876
+ try:
877
+ self.store.add_credential(**data)
878
+ self._refresh_credentials()
879
+ except Exception as e:
880
+ QMessageBox.critical(self, "Error", f"Failed to add credential: {e}")
881
+
882
+ def _edit_credential(self):
883
+ row = self.table.currentRow()
884
+ if row < 0:
885
+ return
886
+
887
+ name = self.table.item(row, 0).text()
888
+ cred = self.store.get_credential(name)
889
+ if not cred:
890
+ QMessageBox.warning(self, "Error", "Credential not found")
891
+ return
892
+
893
+ dialog = CredentialDialog(self, self.theme, credential=cred)
894
+ if dialog.exec() == QDialog.DialogCode.Accepted:
895
+ data = dialog.get_credential_data()
896
+ try:
897
+ # Remove old and add updated
898
+ self.store.remove_credential(name)
899
+ self.store.add_credential(**data)
900
+ self._refresh_credentials()
901
+ except Exception as e:
902
+ QMessageBox.critical(self, "Error", f"Failed to update credential: {e}")
903
+
904
+ def _delete_credential(self):
905
+ row = self.table.currentRow()
906
+ if row < 0:
907
+ return
908
+
909
+ name = self.table.item(row, 0).text()
910
+
911
+ reply = QMessageBox.question(
912
+ self,
913
+ "Confirm Delete",
914
+ f"Delete credential '{name}'?\n\nThis cannot be undone.",
915
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
916
+ QMessageBox.StandardButton.No
917
+ )
918
+
919
+ if reply == QMessageBox.StandardButton.Yes:
920
+ try:
921
+ self.store.remove_credential(name)
922
+ self._refresh_credentials()
923
+ except Exception as e:
924
+ QMessageBox.critical(self, "Error", f"Failed to delete: {e}")
925
+
926
+ def get_selected_credential(self) -> Optional[str]:
927
+ """Get currently selected credential name."""
928
+ row = self.table.currentRow()
929
+ if row >= 0:
930
+ return self.table.item(row, 0).text()
931
+ return None
932
+
933
+
934
+ # Import Path for browse dialog
935
+ from pathlib import Path
936
+
937
+
938
+ def run_standalone():
939
+ """Run credential manager as standalone app."""
940
+ import sys
941
+
942
+ app = QApplication(sys.argv)
943
+ app.setStyle("Fusion")
944
+
945
+ window = QWidget()
946
+ window.setWindowTitle("nTerm Credential Manager")
947
+ window.setMinimumSize(800, 600)
948
+
949
+ layout = QVBoxLayout(window)
950
+ layout.setContentsMargins(0, 0, 0, 0)
951
+
952
+ # Standalone mode - manage own stylesheet
953
+ manager = CredentialManagerWidget(use_own_stylesheet=True)
954
+ manager.try_auto_unlock()
955
+ layout.addWidget(manager)
956
+
957
+ window.show()
958
+ sys.exit(app.exec())
959
+
960
+
961
+ if __name__ == "__main__":
962
+ run_standalone()