Files
keepassxc/src/gui/reports/ReportsWidgetHealthcheck.cpp
Xavier Valls 6acd0b25ae Add a context menu entry to delete entries from health check reports (#6537)
* Closes #4986 - Allow deleting entries from the reports view
* Closes #4533 - Exclude & delete multiple entries in a report
* Also allow deleting selected entries using the delete key
* Introduce GuiTools namespace to collect shared GUI prompts and actions
* Add functionality to HIBP report to mirror health check report

Co-authored-by: Jonathan White <support@dmapps.us>
2021-05-31 10:40:20 -04:00

396 lines
13 KiB
C++

/*
* Copyright (C) 2019 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 or (at your option)
* version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include "ReportsWidgetHealthcheck.h"
#include "ui_ReportsWidgetHealthcheck.h"
#include "core/AsyncTask.h"
#include "core/Database.h"
#include "core/Global.h"
#include "core/Group.h"
#include "core/Metadata.h"
#include "core/PasswordHealth.h"
#include "gui/GuiTools.h"
#include "gui/Icons.h"
#include "gui/styles/StateColorPalette.h"
#include <QMenu>
#include <QSharedPointer>
#include <QShortcut>
#include <QSortFilterProxyModel>
#include <QStandardItemModel>
namespace
{
class Health
{
public:
struct Item
{
QPointer<Group> group;
QPointer<Entry> entry;
QSharedPointer<PasswordHealth> health;
bool exclude = false;
Item(Group* g, Entry* e, QSharedPointer<PasswordHealth> h)
: group(g)
, entry(e)
, health(h)
, exclude(e->excludeFromReports())
{
}
bool operator<(const Item& rhs) const
{
return health->score() < rhs.health->score();
}
};
explicit Health(QSharedPointer<Database>);
const QList<QSharedPointer<Item>>& items() const
{
return m_items;
}
bool anyKnownBad() const
{
return m_anyKnownBad;
}
private:
QSharedPointer<Database> m_db;
HealthChecker m_checker;
QList<QSharedPointer<Item>> m_items;
bool m_anyKnownBad = false;
};
class ReportSortProxyModel : public QSortFilterProxyModel
{
public:
ReportSortProxyModel(QObject* parent)
: QSortFilterProxyModel(parent){};
~ReportSortProxyModel() override = default;
protected:
bool lessThan(const QModelIndex& left, const QModelIndex& right) const override
{
// Check if the display data is a number, convert and compare if so
bool ok = false;
int leftInt = sourceModel()->data(left).toString().toInt(&ok);
if (ok) {
return leftInt < sourceModel()->data(right).toString().toInt();
}
// Otherwise use default sorting
return QSortFilterProxyModel::lessThan(left, right);
}
};
} // namespace
Health::Health(QSharedPointer<Database> db)
: m_db(db)
, m_checker(db)
{
for (auto group : db->rootGroup()->groupsRecursive(true)) {
// Skip recycle bin
if (group->isRecycled()) {
continue;
}
for (auto entry : group->entries()) {
if (entry->isRecycled()) {
continue;
}
// Skip entries with empty password
if (entry->password().isEmpty()) {
continue;
}
// Evaluate this entry
const auto item = QSharedPointer<Item>(new Item(group, entry, m_checker.evaluate(entry)));
if (item->exclude) {
m_anyKnownBad = true;
}
// Add entry if its password isn't at least "good"
if (item->health->quality() < PasswordHealth::Quality::Good) {
m_items.append(item);
}
}
}
// Sort the result so that the worst passwords (least score)
// are at the top
std::sort(m_items.begin(), m_items.end(), [](QSharedPointer<Item> x, QSharedPointer<Item> y) { return *x < *y; });
}
ReportsWidgetHealthcheck::ReportsWidgetHealthcheck(QWidget* parent)
: QWidget(parent)
, m_ui(new Ui::ReportsWidgetHealthcheck())
, m_referencesModel(new QStandardItemModel(this))
, m_modelProxy(new ReportSortProxyModel(this))
{
m_ui->setupUi(this);
m_modelProxy->setSourceModel(m_referencesModel.data());
m_modelProxy->setSortLocaleAware(true);
m_ui->healthcheckTableView->setModel(m_modelProxy.data());
m_ui->healthcheckTableView->horizontalHeader()->setSectionResizeMode(QHeaderView::Interactive);
m_ui->healthcheckTableView->verticalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents);
connect(m_ui->healthcheckTableView, SIGNAL(customContextMenuRequested(QPoint)), SLOT(customMenuRequested(QPoint)));
connect(m_ui->healthcheckTableView, SIGNAL(doubleClicked(QModelIndex)), SLOT(emitEntryActivated(QModelIndex)));
connect(m_ui->showKnownBadCheckBox, SIGNAL(stateChanged(int)), this, SLOT(calculateHealth()));
connect(m_ui->excludeExpired, SIGNAL(stateChanged(int)), this, SLOT(calculateHealth()));
new QShortcut(Qt::Key_Delete, this, SLOT(deleteSelectedEntries()));
}
ReportsWidgetHealthcheck::~ReportsWidgetHealthcheck()
{
}
void ReportsWidgetHealthcheck::addHealthRow(QSharedPointer<PasswordHealth> health,
Group* group,
Entry* entry,
bool knownBad)
{
QString descr, tip;
QColor qualityColor;
StateColorPalette statePalette;
const auto quality = health->quality();
switch (quality) {
case PasswordHealth::Quality::Bad:
descr = tr("Bad", "Password quality");
tip = tr("Bad — password must be changed");
qualityColor = statePalette.color(StateColorPalette::HealthCritical);
break;
case PasswordHealth::Quality::Poor:
descr = tr("Poor", "Password quality");
tip = tr("Poor — password should be changed");
qualityColor = statePalette.color(StateColorPalette::HealthBad);
break;
case PasswordHealth::Quality::Weak:
descr = tr("Weak", "Password quality");
tip = tr("Weak — consider changing the password");
qualityColor = statePalette.color(StateColorPalette::HealthWeak);
break;
case PasswordHealth::Quality::Good:
case PasswordHealth::Quality::Excellent:
qualityColor = statePalette.color(StateColorPalette::HealthOk);
break;
}
auto title = entry->title();
if (knownBad) {
title.append(tr(" (Excluded)"));
}
auto row = QList<QStandardItem*>();
row << new QStandardItem(descr);
row << new QStandardItem(entry->iconPixmap(), title);
row << new QStandardItem(group->iconPixmap(), group->hierarchy().join("/"));
row << new QStandardItem(QString::number(health->score()));
row << new QStandardItem(health->scoreReason());
// Set background color of first column according to password quality.
// Set the same as foreground color so the description is usually
// invisible, it's just for screen readers etc.
QBrush brush(qualityColor);
row[0]->setForeground(brush);
row[0]->setBackground(brush);
// Set tooltips
row[0]->setToolTip(tip);
if (knownBad) {
row[1]->setToolTip(tr("This entry is being excluded from reports"));
}
row[4]->setToolTip(health->scoreDetails());
// Store entry pointer per table row (used in double click handler)
m_referencesModel->appendRow(row);
m_rowToEntry.append({group, entry});
}
void ReportsWidgetHealthcheck::loadSettings(QSharedPointer<Database> db)
{
m_db = std::move(db);
m_healthCalculated = false;
m_referencesModel->clear();
m_rowToEntry.clear();
auto row = QList<QStandardItem*>();
row << new QStandardItem(tr("Please wait, health data is being calculated…"));
m_referencesModel->appendRow(row);
}
void ReportsWidgetHealthcheck::showEvent(QShowEvent* event)
{
QWidget::showEvent(event);
if (!m_healthCalculated) {
// Perform stats calculation on next event loop to allow widget to appear
m_healthCalculated = true;
QTimer::singleShot(0, this, SLOT(calculateHealth()));
}
}
void ReportsWidgetHealthcheck::calculateHealth()
{
m_referencesModel->clear();
// Perform the health check
const QScopedPointer<Health> health(AsyncTask::runAndWaitForFuture([this] { return new Health(m_db); }));
// Display entries that are marked as "known bad"?
const auto showExcluded = m_ui->showKnownBadCheckBox->isChecked();
// Display the entries
m_rowToEntry.clear();
for (const auto& item : health->items()) {
auto excluded = item->exclude || (item->entry->isExpired() && m_ui->excludeExpired->isChecked());
if (excluded && !showExcluded) {
// Exclude this entry from the report
continue;
}
// Show the entry in the report
addHealthRow(item->health, item->group, item->entry, item->exclude);
}
// Set the table header
if (m_referencesModel->rowCount() == 0) {
m_referencesModel->setHorizontalHeaderLabels(QStringList() << tr("Congratulations, everything is healthy!"));
} else {
m_referencesModel->setHorizontalHeaderLabels(QStringList() << tr("") << tr("Title") << tr("Path") << tr("Score")
<< tr("Reason"));
m_ui->healthcheckTableView->sortByColumn(0, Qt::AscendingOrder);
}
m_ui->healthcheckTableView->resizeColumnsToContents();
m_ui->healthcheckTableView->horizontalHeader()->setSectionResizeMode(0, QHeaderView::Fixed);
// Show the "show known bad entries" checkbox if there's any known
// bad entry in the database.
if (health->anyKnownBad()) {
m_ui->showKnownBadCheckBox->show();
} else {
m_ui->showKnownBadCheckBox->hide();
}
}
void ReportsWidgetHealthcheck::emitEntryActivated(const QModelIndex& index)
{
if (!index.isValid()) {
return;
}
auto mappedIndex = m_modelProxy->mapToSource(index);
const auto row = m_rowToEntry[mappedIndex.row()];
const auto group = row.first;
const auto entry = row.second;
if (group && entry) {
emit entryActivated(const_cast<Entry*>(entry));
}
}
void ReportsWidgetHealthcheck::customMenuRequested(QPoint pos)
{
auto selected = m_ui->healthcheckTableView->selectionModel()->selectedRows();
if (selected.isEmpty()) {
return;
}
// Create the context menu
const auto menu = new QMenu(this);
// Create the "edit entry" menu item (only if 1 row is selected)
if (selected.size() == 1) {
const auto edit = new QAction(icons()->icon("entry-edit"), tr("Edit Entry…"), this);
menu->addAction(edit);
connect(edit, &QAction::triggered, edit, [this, selected] {
auto row = m_modelProxy->mapToSource(selected[0]).row();
auto entry = m_rowToEntry[row].second;
emit entryActivated(entry);
});
}
// Create the "delete entry" menu item
const auto delEntry = new QAction(icons()->icon("entry-delete"), tr("Delete Entry(s)…", "", selected.size()), this);
menu->addAction(delEntry);
connect(delEntry, &QAction::triggered, this, &ReportsWidgetHealthcheck::deleteSelectedEntries);
// Create the "exclude from reports" menu item
const auto exclude = new QAction(icons()->icon("reports-exclude"), tr("Exclude from reports"), this);
bool isExcluded = false;
for (auto index : selected) {
auto row = m_modelProxy->mapToSource(index).row();
auto entry = m_rowToEntry[row].second;
if (entry && entry->excludeFromReports()) {
// If at least one entry is excluded switch to inclusion
isExcluded = true;
break;
}
}
exclude->setCheckable(true);
exclude->setChecked(isExcluded);
menu->addAction(exclude);
connect(exclude, &QAction::toggled, exclude, [this, selected](bool state) {
for (auto index : selected) {
auto row = m_modelProxy->mapToSource(index).row();
auto entry = m_rowToEntry[row].second;
if (entry) {
entry->setExcludeFromReports(state);
}
}
calculateHealth();
});
// Show the context menu
menu->popup(m_ui->healthcheckTableView->viewport()->mapToGlobal(pos));
}
void ReportsWidgetHealthcheck::saveSettings()
{
// nothing to do - the tab is passive
}
void ReportsWidgetHealthcheck::deleteSelectedEntries()
{
QList<Entry*> selectedEntries;
for (auto index : m_ui->healthcheckTableView->selectionModel()->selectedRows()) {
auto row = m_modelProxy->mapToSource(index).row();
auto entry = m_rowToEntry[row].second;
if (entry) {
selectedEntries << entry;
}
}
bool permanent = !m_db->metadata()->recycleBinEnabled();
if (GuiTools::confirmDeleteEntries(this, selectedEntries, permanent)) {
GuiTools::deleteEntriesResolveReferences(this, selectedEntries, permanent);
}
calculateHealth();
}