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.
- nterm/__init__.py +54 -0
- nterm/__main__.py +619 -0
- nterm/askpass/__init__.py +22 -0
- nterm/askpass/server.py +393 -0
- nterm/config.py +158 -0
- nterm/connection/__init__.py +17 -0
- nterm/connection/profile.py +296 -0
- nterm/manager/__init__.py +29 -0
- nterm/manager/connect_dialog.py +322 -0
- nterm/manager/editor.py +262 -0
- nterm/manager/io.py +678 -0
- nterm/manager/models.py +346 -0
- nterm/manager/settings.py +264 -0
- nterm/manager/tree.py +493 -0
- nterm/resources.py +48 -0
- nterm/session/__init__.py +60 -0
- nterm/session/askpass_ssh.py +399 -0
- nterm/session/base.py +110 -0
- nterm/session/interactive_ssh.py +522 -0
- nterm/session/pty_transport.py +571 -0
- nterm/session/ssh.py +610 -0
- nterm/terminal/__init__.py +11 -0
- nterm/terminal/bridge.py +83 -0
- nterm/terminal/resources/terminal.html +253 -0
- nterm/terminal/resources/terminal.js +414 -0
- nterm/terminal/resources/xterm-addon-fit.min.js +8 -0
- nterm/terminal/resources/xterm-addon-unicode11.min.js +8 -0
- nterm/terminal/resources/xterm-addon-web-links.min.js +8 -0
- nterm/terminal/resources/xterm.css +209 -0
- nterm/terminal/resources/xterm.min.js +8 -0
- nterm/terminal/widget.py +380 -0
- nterm/theme/__init__.py +10 -0
- nterm/theme/engine.py +456 -0
- nterm/theme/stylesheet.py +377 -0
- nterm/theme/themes/clean.yaml +0 -0
- nterm/theme/themes/default.yaml +36 -0
- nterm/theme/themes/dracula.yaml +36 -0
- nterm/theme/themes/gruvbox_dark.yaml +36 -0
- nterm/theme/themes/gruvbox_hybrid.yaml +38 -0
- nterm/theme/themes/gruvbox_light.yaml +36 -0
- nterm/vault/__init__.py +32 -0
- nterm/vault/credential_manager.py +163 -0
- nterm/vault/keychain.py +135 -0
- nterm/vault/manager_ui.py +962 -0
- nterm/vault/profile.py +219 -0
- nterm/vault/resolver.py +250 -0
- nterm/vault/store.py +642 -0
- ntermqt-0.1.0.dist-info/METADATA +327 -0
- ntermqt-0.1.0.dist-info/RECORD +52 -0
- ntermqt-0.1.0.dist-info/WHEEL +5 -0
- ntermqt-0.1.0.dist-info/entry_points.txt +5 -0
- 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()
|