• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

IJHack / QtPass / 24940894757

25 Apr 2026 09:18PM UTC coverage: 28.024% (+0.4%) from 27.609%
24940894757

push

github

web-flow
feat: add Import key dialog to UsersDialog for issue #1167 (#1170)

* feat: add Import key dialog to UsersDialog for issue #1167

- Add ImportKeyDialog class for importing GPG keys from file or clipboard
- Add Import key button to UsersDialog next to search
- Wire up gpg --import execution
- On success, refresh key list and select newly imported key
- Use --status-fd for machine-readable output
- Parse multiline gpg output correctly (line-by-line)
- Fix QDialogButtonBox to only have Cancel button
- Move on_importKeyButton_clicked to private slots
- Clear filter before showing new key (leave cleared after import)
- Add gpgExe fallback to gpg
- Fix clang-format issues

* fix: address review feedback on Import key dialog

Locale-aware parsing, naming, UX, and tests:

- Rename ImportKeyDialog::importedKeyFingerprint() to importedKeyId()
  and update the lone caller in UsersDialog. The parser returns whatever
  gpg gave us (fingerprint when --status-fd's IMPORT_OK is present,
  long key id when only IMPORTED is, the human-readable token
  otherwise); the new name matches m_importedKeyId and the surrounding
  documentation.

- parseGpgImportOutput now prefers locale-independent status lines:
  [GNUPG:] IMPORT_OK <reason> <fingerprint> first, [GNUPG:] IMPORTED
  <keyid> next, falling back to the English-locale "gpg: key X:
  imported" line. Drops the redundant KEY_IMPORTED_EXACT regex (the
  generalised one matched the same inputs).

- Stop wiping inputTextEdit when the clipboard is empty in
  on_pasteButton_clicked.

- Reject non-armored files in on_fileButton_clicked instead of
  silently corrupting binary keyrings through QString::fromUtf8.
  File picker now advertises ASCII-armored only (*.asc); users with
  binary keys are pointed at gpg --armor --export or the From
  Clipboard path.

- Drop the redundant ui->inputTextEdit->setPlaceholderText() runtime
  set; the .ui already declares it, so translators only see the
  string once.

- UsersDialog::on_impo... (continued)

25 of 113 new or added lines in 3 files covered. (22.12%)

36 existing lines in 2 files now uncovered.

1805 of 6441 relevant lines covered (28.02%)

27.56 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/src/usersdialog.cpp
1
// SPDX-FileCopyrightText: 2015 Anne Jan Brouwer
2
// SPDX-License-Identifier: GPL-3.0-or-later
3
#include "usersdialog.h"
4
#include "importkeydialog.h"
5
#include "qtpasssettings.h"
6
#include "ui_usersdialog.h"
7
#include <QApplication>
8
#include <QCloseEvent>
9
#include <QDateTime>
10
#include <QKeyEvent>
11
#include <QLineEdit>
12
#include <QListWidget>
13
#include <QMessageBox>
14
#include <QRegularExpression>
15
#include <QSet>
16
#include <QSignalBlocker>
17
#include <QWidget>
18

19
#ifdef QT_DEBUG
20
#include "debughelper.h"
21
#endif
22
/**
23
 * @brief UsersDialog::UsersDialog basic constructor
24
 * @param dir Password directory
25
 * @param parent
26
 */
27
UsersDialog::UsersDialog(const QString &dir, QWidget *parent)
×
28
    : QDialog(parent), ui(new Ui::UsersDialog), m_dir(dir) {
×
29

30
  ui->setupUi(this);
×
31

32
  restoreDialogState();
×
33
  if (!loadGpgKeys()) {
×
34
    connect(ui->buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
×
35
    return;
×
36
  }
37

38
  loadRecipients();
×
39
  populateList();
×
40

41
  connectSignals();
×
42
}
×
43

44
void UsersDialog::connectSignals() {
×
45
  connect(ui->buttonBox, &QDialogButtonBox::accepted, this,
×
46
          &UsersDialog::accept);
×
47
  connect(ui->buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
×
48
  connect(ui->listWidget, &QListWidget::itemChanged, this,
×
49
          &UsersDialog::itemChange);
×
50
  // importKeyButton is wired via Qt's automatic on_<name>_<signal> mechanism
51
  // already triggered by setupUi().
52

53
  ui->lineEdit->setClearButtonEnabled(true);
×
54
}
×
55

56
/**
57
 * @brief Restore dialog geometry from settings.
58
 */
59
void UsersDialog::restoreDialogState() {
×
60
  QByteArray savedGeometry = QtPassSettings::getDialogGeometry("usersDialog");
×
61
  bool hasSavedGeometry = !savedGeometry.isEmpty();
62
  if (hasSavedGeometry) {
×
63
    restoreGeometry(savedGeometry);
×
64
  }
65
  if (QtPassSettings::isDialogMaximized("usersDialog")) {
×
66
    showMaximized();
×
67
  } else if (hasSavedGeometry) {
×
68
    move(QtPassSettings::getDialogPos("usersDialog"));
×
69
    resize(QtPassSettings::getDialogSize("usersDialog"));
×
70
  }
71
}
×
72

73
auto UsersDialog::loadGpgKeys() -> bool {
×
74
  QList<UserInfo> users = QtPassSettings::getPass()->listKeys();
×
75
  if (users.isEmpty()) {
×
76
    QMessageBox::critical(parentWidget(), tr("Keylist missing"),
×
77
                          tr("Could not fetch list of available GPG keys"));
×
78
    reject();
×
79
    return false;
80
  }
81

82
  markSecretKeys(users);
×
83

84
  m_userList = users;
85
  return true;
×
86
}
87

88
void UsersDialog::markSecretKeys(QList<UserInfo> &users) {
×
89
  QList<UserInfo> secret_keys = QtPassSettings::getPass()->listKeys("", true);
×
90
  QSet<QString> secretKeyIds;
91
  for (const UserInfo &sec : secret_keys) {
×
92
    secretKeyIds.insert(sec.key_id);
×
93
  }
94
  for (auto &user : users) {
×
95
    if (secretKeyIds.contains(user.key_id)) {
×
96
      user.have_secret = true;
×
97
    }
98
  }
99
}
×
100

101
void UsersDialog::loadRecipients() {
×
102
  int count = 0;
×
103
  QStringList recipients = QtPassSettings::getPass()->getRecipientString(
×
104
      m_dir.isEmpty() ? "" : m_dir, " ", &count);
×
105

106
  QList<UserInfo> selectedUsers =
107
      QtPassSettings::getPass()->listKeys(recipients);
×
108
  QSet<QString> selectedKeyIds;
109
  for (const UserInfo &sel : selectedUsers) {
×
110
    selectedKeyIds.insert(sel.key_id);
×
111
  }
112
  for (auto &user : m_userList) {
×
113
    if (selectedKeyIds.contains(user.key_id)) {
×
114
      user.enabled = true;
×
115
    }
116
  }
117

118
  if (count > selectedUsers.size()) {
×
119
    QStringList allRecipients = QtPassSettings::getPass()->getRecipientList(
×
120
        m_dir.isEmpty() ? "" : m_dir);
×
121

122
    // Use bulk lookup to resolve all recipients at once (single gpg call)
123
    // This preserves the original email/UID resolution behavior
124
    QList<UserInfo> resolvedKeys =
125
        QtPassSettings::getPass()->listKeys(allRecipients);
×
126
    // Track resolved recipients by their resolved key_id
127
    QSet<QString> resolvedKeyIds;
128
    for (const UserInfo &key : resolvedKeys) {
×
129
      resolvedKeyIds.insert(key.key_id);
×
130
    }
131

132
    // Accept a recipient as resolved if GPG returned a key for it
133
    // (either its exact key_id, or GPG resolved email/UID/fingerprint to it)
134
    QSet<QString> resolvedRecipients;
135
    for (const UserInfo &key : resolvedKeys) {
×
136
      resolvedRecipients.insert(key.key_id);
×
137
      // Also add the name (email/UID) of resolved keys as valid resolved tokens
138
      // since GPG matched them to this key
139
      if (!key.name.isEmpty()) {
×
140
        resolvedRecipients.insert(
×
141
            key.name.section('@', 0, 0));    // email local part
×
142
        resolvedRecipients.insert(key.name); // full email/UID
×
143
      }
144
    }
145

146
    for (const QString &recipient : allRecipients) {
×
147
      if (!resolvedKeyIds.contains(recipient) &&
×
148
          !resolvedRecipients.contains(recipient) &&
×
149
          !selectedKeyIds.contains(recipient)) {
150
        UserInfo i;
×
151
        i.enabled = true;
×
152
        i.key_id = recipient;
×
153
        i.name = " ?? " + tr("Key not found in keyring");
×
154
        m_userList.append(i);
155
      }
×
156
    }
157
  }
158
}
×
159

160
/**
161
 * @brief UsersDialog::~UsersDialog basic destructor.
162
 */
163
UsersDialog::~UsersDialog() { delete ui; }
×
164

165
/**
166
 * @brief UsersDialog::accept
167
 */
168
void UsersDialog::accept() {
×
169
  QtPassSettings::getPass()->Init(m_dir, m_userList);
×
170

171
  QDialog::accept();
×
172
}
×
173

174
/**
175
 * @brief UsersDialog::closeEvent save window state on close.
176
 * @param event
177
 */
178
void UsersDialog::closeEvent(QCloseEvent *event) {
×
179
  QtPassSettings::setDialogGeometry("usersDialog", saveGeometry());
×
180
  if (!isMaximized()) {
×
181
    QtPassSettings::setDialogPos("usersDialog", pos());
×
182
    QtPassSettings::setDialogSize("usersDialog", size());
×
183
  }
184
  QtPassSettings::setDialogMaximized("usersDialog", isMaximized());
×
185
  event->accept();
186
}
×
187

188
/**
189
 * @brief UsersDialog::keyPressEvent clear the lineEdit when escape is pressed.
190
 * No action for Enter currently.
191
 * @param event
192
 */
193
void UsersDialog::keyPressEvent(QKeyEvent *event) {
×
194
  switch (event->key()) {
×
195
  case Qt::Key_Escape:
×
196
    ui->lineEdit->clear();
×
197
    break;
×
198
  default:
199
    break;
200
  }
201
}
×
202

203
/**
204
 * @brief UsersDialog::itemChange update the item information.
205
 * @param item
206
 */
207
void UsersDialog::itemChange(QListWidgetItem *item) {
×
208
  if (!item) {
×
209
    return;
×
210
  }
211
  bool ok = false;
×
212
  const int index = item->data(Qt::UserRole).toInt(&ok);
×
213
  if (!ok) {
×
214
#ifdef QT_DEBUG
215
    qWarning() << "UsersDialog::itemChange: invalid user index data for item";
216
#endif
217
    return;
218
  }
219
  if (index < 0 || index >= m_userList.size()) {
×
220
#ifdef QT_DEBUG
221
    qWarning() << "UsersDialog::itemChange: user index out of range:" << index
222
               << "valid range is [0," << (m_userList.size() - 1) << "]";
223
#endif
224
    return;
225
  }
226
  m_userList[index].enabled = item->checkState() == Qt::Checked;
×
227
}
228

229
/**
230
 * @brief UsersDialog::populateList update the view based on filter options
231
 * (such as searching).
232
 * @param filter
233
 */
234
void UsersDialog::populateList(const QString &filter) {
×
235
  // Invalidate cached datetime so expiry checks use fresh current time
236
  m_cachedDateTimeValid = false;
×
237

238
  QString patternString = "*" + filter + "*";
×
239
  if (m_cachedPatternString != patternString) {
×
240
    QRegularExpression re(
241
        QRegularExpression::wildcardToRegularExpression(patternString),
×
242
        QRegularExpression::CaseInsensitiveOption);
×
243
    if (re.isValid()) {
×
244
      m_cachedNameFilter = re;
×
245
      m_cachedPatternString = patternString;
×
246
    }
247
  }
×
248
  const QRegularExpression &nameFilter = m_cachedNameFilter;
×
249
  ui->listWidget->clear();
×
250

251
  for (int i = 0; i < m_userList.size(); ++i) {
×
252
    const auto &user = m_userList.at(i);
253
    if (!passesFilter(user, filter, nameFilter)) {
×
254
      continue;
×
255
    }
256

257
    auto *item = new QListWidgetItem(buildUserText(user), ui->listWidget);
×
258
    applyUserStyling(item, user);
×
259
    item->setCheckState(user.enabled ? Qt::Checked : Qt::Unchecked);
×
260
    item->setData(Qt::UserRole, QVariant::fromValue(i));
×
261
    ui->listWidget->addItem(item);
×
262
  }
263
}
×
264

265
/**
266
 * @brief Checks if a user passes the filter criteria.
267
 * @param user User to check
268
 * @param filter Filter string
269
 * @param nameFilter Compiled name filter regex
270
 * @return true if user passes filter
271
 */
272
bool UsersDialog::passesFilter(const UserInfo &user, const QString &filter,
×
273
                               const QRegularExpression &nameFilter) const {
274
  if (!filter.isEmpty() && !nameFilter.match(user.name).hasMatch()) {
×
275
    return false;
276
  }
277
  if (!user.isValid() && !ui->checkBox->isChecked()) {
×
278
    return false;
279
  }
280
  const bool expired = isUserExpired(user);
×
281
  return !(expired && !ui->checkBox->isChecked());
×
282
}
283

284
/**
285
 * @brief Checks if a user's key has expired.
286
 * @param user User to check
287
 * @return true if user's key is expired
288
 */
289
auto UsersDialog::isUserExpired(const UserInfo &user) const -> bool {
×
290
  if (!m_cachedDateTimeValid) {
×
291
    m_cachedCurrentDateTime = QDateTime::currentDateTime();
×
292
    m_cachedDateTimeValid = true;
×
293
  }
294
  return user.expiry.toSecsSinceEpoch() > 0 &&
×
295
         m_cachedCurrentDateTime > user.expiry;
×
296
}
297

298
/**
299
 * @brief Builds display text for a user.
300
 * @param user User to format
301
 * @return Formatted user text
302
 */
303
QString UsersDialog::buildUserText(const UserInfo &user) const {
×
304
  QString text = user.name + "\n" + user.key_id;
×
305
  if (user.created.toSecsSinceEpoch() > 0) {
×
306
    text += " " + tr("created") + " " +
×
307
            QLocale::system().toString(user.created, QLocale::ShortFormat);
×
308
  }
309
  if (user.expiry.toSecsSinceEpoch() > 0) {
×
310
    text += " " + tr("expires") + " " +
×
311
            QLocale::system().toString(user.expiry, QLocale::ShortFormat);
×
312
  }
313
  return text;
×
314
}
315

316
/**
317
 * @brief Applies visual styling to a user list item based on key status.
318
 * @param item List widget item to style
319
 * @param user User whose status determines styling
320
 */
321
void UsersDialog::applyUserStyling(QListWidgetItem *item,
×
322
                                   const UserInfo &user) const {
323
  const QString originalText = item->text();
×
324
  if (user.have_secret) {
×
325
    const QPalette palette = QApplication::palette();
×
326
    item->setForeground(palette.color(QPalette::Link));
×
327
    QFont font = item->font();
×
328
    font.setBold(true);
329
    item->setFont(font);
×
330
  } else if (!user.isValid()) {
×
331
    item->setBackground(Qt::darkRed);
×
332
    item->setForeground(Qt::white);
×
333
    item->setText(tr("[INVALID] ") + originalText);
×
334
  } else if (isUserExpired(user)) {
×
335
    item->setForeground(Qt::darkRed);
×
336
    item->setText(tr("[EXPIRED] ") + originalText);
×
337
  } else if (!user.fullyValid()) {
×
338
    item->setBackground(Qt::darkYellow);
×
339
    item->setForeground(Qt::white);
×
340
    item->setText(tr("[PARTIAL] ") + originalText);
×
341
  } else {
342
    item->setText(originalText);
×
343
  }
344
}
×
345

346
/**
347
 * @brief UsersDialog::on_lineEdit_textChanged typing in the searchbox.
348
 * @param filter
349
 */
350
void UsersDialog::on_lineEdit_textChanged(const QString &filter) {
×
351
  populateList(filter);
×
352
}
×
353

354
/**
355
 * @brief UsersDialog::on_checkBox_clicked filtering.
356
 */
357
void UsersDialog::on_checkBox_clicked() { populateList(ui->lineEdit->text()); }
×
358

NEW
359
void UsersDialog::on_importKeyButton_clicked() {
×
NEW
360
  ImportKeyDialog dialog(this);
×
NEW
361
  if (dialog.exec() != QDialog::Accepted) {
×
362
    return;
363
  }
364

365
  // dialog.exec() == Accepted is only reachable after a successful import
366
  // (see ImportKeyDialog::importFromString), so importedKeyId() is non-empty
367
  // by construction. Guard anyway: an empty value would make the
368
  // bidirectional endsWith() below match the first listed key.
NEW
369
  const QString importedKey = dialog.importedKeyId();
×
NEW
370
  if (importedKey.isEmpty()) {
×
371
    return;
372
  }
373

NEW
374
  if (!loadGpgKeys()) {
×
375
    return;
376
  }
377

378
  // Clear the filter so the just-imported key is visible. setText("") still
379
  // emits textChanged once, so don't double-populate.
380
  {
NEW
381
    const QSignalBlocker blocker(ui->lineEdit);
×
NEW
382
    ui->lineEdit->clear();
×
NEW
383
  }
×
NEW
384
  populateList(QString());
×
385

386
  // Match against the user's stored key_id, not the visible item text.
387
  // gpg can return a 16-char long key id (IMPORTED status line) or a
388
  // 40-char fingerprint (IMPORT_OK status line); compare both directions
389
  // so a long-id match works against a fingerprint hit and vice versa.
NEW
390
  for (int i = 0; i < ui->listWidget->count(); ++i) {
×
NEW
391
    QListWidgetItem *item = ui->listWidget->item(i);
×
NEW
392
    if (item == nullptr) {
×
NEW
393
      continue;
×
394
    }
NEW
395
    bool ok = false;
×
NEW
396
    const int idx = item->data(Qt::UserRole).toInt(&ok);
×
NEW
397
    if (!ok || idx < 0 || idx >= m_userList.size()) {
×
NEW
398
      continue;
×
399
    }
NEW
400
    const QString &keyId = m_userList[idx].key_id;
×
NEW
401
    if (keyId.isEmpty()) {
×
NEW
402
      continue;
×
403
    }
404
    // Only perform endsWith checks when the suffix being matched is at least
405
    // 16 chars to avoid false positives with short IDs.
NEW
406
    if ((keyId.length() >= 16 &&
×
NEW
407
         importedKey.endsWith(keyId, Qt::CaseInsensitive)) ||
×
NEW
408
        (importedKey.length() >= 16 &&
×
NEW
409
         keyId.endsWith(importedKey, Qt::CaseInsensitive))) {
×
NEW
410
      ui->listWidget->setCurrentItem(item);
×
NEW
411
      break;
×
412
    }
413
  }
NEW
414
}
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc