Skip to content

Commit

Permalink
Merge pull request #313 from freedomofpress/issue-286
Browse files Browse the repository at this point in the history
LoginDialog going solo
  • Loading branch information
heartsucker authored Apr 22, 2019
2 parents e1cdfe6 + cd11055 commit 36b82d3
Show file tree
Hide file tree
Showing 6 changed files with 186 additions and 57 deletions.
65 changes: 34 additions & 31 deletions securedrop_client/gui/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
import logging
from typing import List

from PyQt5.QtWidgets import QMainWindow, QWidget, QHBoxLayout, QVBoxLayout, QDesktopWidget
from PyQt5.QtWidgets import QMainWindow, QWidget, QHBoxLayout, QVBoxLayout, QDesktopWidget, \
QApplication

from securedrop_client import __version__
from securedrop_client.db import Source
Expand Down Expand Up @@ -54,55 +55,55 @@ def __init__(self, sdc_home: str):
"""
super().__init__()
self.sdc_home = sdc_home
self.controller = None
# Cache a dict of source.uuid -> SourceConversationWrapper
# We do this to not create/destroy widgets constantly (because it causes UI "flicker")
self.conversations = {}

self.setWindowTitle(_("SecureDrop Client {}").format(__version__))
self.setWindowIcon(load_icon(self.icon))

# Top Pane to display activity and error messages
self.top_pane = TopPane()

# Main Pane to display everything else
self.main_pane = QWidget()
layout = QHBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
self.main_pane.setLayout(layout)
self.left_pane = LeftPane()
self.main_view = MainView(self.main_pane)
self.main_view.source_list.itemSelectionChanged.connect(self.on_source_changed)
layout.addWidget(self.left_pane, 1)
layout.addWidget(self.main_view, 8)

# Set the main window's central widget to show Top Pane and Main Pane
self.central_widget = QWidget()
central_widget_layout = QVBoxLayout()
central_widget_layout.setContentsMargins(0, 0, 0, 0)
central_widget_layout.setSpacing(0)
self.central_widget.setLayout(central_widget_layout)
self.setCentralWidget(self.central_widget)

self.top_pane = TopPane()
central_widget_layout.addWidget(self.top_pane)

self.widget = QWidget()
widget_layout = QHBoxLayout()
widget_layout.setContentsMargins(0, 0, 0, 0)
widget_layout.setSpacing(0)
self.widget.setLayout(widget_layout)

self.left_pane = LeftPane()
self.main_view = MainView(self.widget)
self.main_view.source_list.itemSelectionChanged.connect(self.on_source_changed)

widget_layout.addWidget(self.left_pane, 1)
widget_layout.addWidget(self.main_view, 8)

central_widget_layout.addWidget(self.widget)

# Cache a dict of source.uuid -> SourceConversationWrapper
# We do this to not create/destroy widgets constantly (because it causes UI "flicker")
self.conversations = {}

self.autosize_window()
self.show()
central_widget_layout.addWidget(self.main_pane)

def setup(self, controller):
"""
Create references to the controller logic and instantiate the various
views used in the UI.
"""
self.controller = controller # Reference the Client logic instance.
self.left_pane.setup(self, controller)
self.top_pane.setup(controller)
self.update_activity_status(_('Started SecureDrop Client. Please sign in.'), 20000)
self.top_pane.setup(self.controller)
self.left_pane.setup(self, self.controller)
self.main_view.source_list.setup(self.controller)
self.show_login()

self.login_dialog = LoginDialog(self)
self.main_view.setup(self.controller)
def show_main_window(self, username: str = None) -> None:
self.autosize_window()
self.show()

if username:
self.set_logged_in_as(username)

def autosize_window(self):
"""
Expand All @@ -117,6 +118,8 @@ def show_login(self):
Show the login form.
"""
self.login_dialog = LoginDialog(self)
self.login_dialog.move(
QApplication.desktop().screen().rect().center() - self.rect().center())
self.login_dialog.setup(self.controller)
self.login_dialog.reset()
self.login_dialog.exec()
Expand Down
20 changes: 17 additions & 3 deletions securedrop_client/gui/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,15 @@
import logging
import arrow
import html
import sys
from typing import List
from uuid import uuid4

from PyQt5.QtCore import Qt, pyqtSlot, QTimer, QSize
from PyQt5.QtGui import QIcon, QPalette, QBrush, QColor, QFont, QLinearGradient
from PyQt5.QtWidgets import QListWidget, QLabel, QWidget, QListWidgetItem, QHBoxLayout, \
QPushButton, QVBoxLayout, QLineEdit, QScrollArea, QDialog, QAction, QMenu, QMessageBox, \
QToolButton, QSizePolicy, QTextEdit, QStatusBar, QGraphicsDropShadowEffect
from typing import List
from uuid import uuid4

from securedrop_client.db import Source, Message, File, Reply
from securedrop_client.gui import SvgLabel, SvgPushButton
Expand Down Expand Up @@ -856,7 +858,15 @@ class LoginDialog(QDialog):
MIN_JOURNALIST_USERNAME = 3 # Journalist.MIN_USERNAME_LEN on server

def __init__(self, parent):
super().__init__(parent)
self.parent = parent
super().__init__(self.parent)

def closeEvent(self, event):
"""
Only exit the application when the main window is not visible.
"""
if not self.parent.isVisible():
sys.exit(0)

def setup(self, controller):
self.controller = controller
Expand Down Expand Up @@ -899,6 +909,9 @@ def setup(self, controller):
self.submit = QPushButton(_('Sign in'))
self.submit.clicked.connect(self.validate)

self.offline_mode = QPushButton(_('Offline mode'))
self.offline_mode.clicked.connect(self.controller.login_offline_mode)

self.error_label = QLabel('')
self.error_label.setObjectName('error_label') # Set css id
self.error_label.setStyleSheet(self.CSS) # Set styles
Expand All @@ -913,6 +926,7 @@ def setup(self, controller):
layout.addWidget(self.tfa_label)
layout.addWidget(self.tfa_field)
layout.addWidget(self.submit)
layout.addWidget(self.offline_mode)
layout.addWidget(self.error_label)
layout.addStretch()

Expand Down
19 changes: 12 additions & 7 deletions securedrop_client/logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,12 +192,6 @@ def setup(self):
# triggered by UI events.
self.gui.setup(self)

# If possible, update the UI with available sources.
self.update_sources()

# Show the login dialog.
self.gui.show_login()

# Create a timer to check for sync status every 30 seconds.
self.sync_timer = QTimer()
self.sync_timer.timeout.connect(self.update_sync)
Expand Down Expand Up @@ -351,7 +345,7 @@ def on_authenticate(self, result):
# It worked! Sync with the API and update the UI.
self.gui.hide_login()
self.sync_api()
self.gui.set_logged_in_as(self.api.username)
self.gui.show_main_window(self.api.username)
self.start_message_thread()
self.start_reply_thread()

Expand All @@ -367,6 +361,17 @@ def on_authenticate(self, result):
'Please verify your credentials and try again.')
self.gui.show_login_error(error=error)

def login_offline_mode(self):
"""
Allow user to view in offline mode without authentication.
"""
self.gui.hide_login()
self.gui.show_main_window()
self.start_message_thread()
self.start_reply_thread()
self.is_authenticated = False
self.update_sources()

def on_login_timeout(self):
"""
Reset the form and indicate the error.
Expand Down
59 changes: 51 additions & 8 deletions tests/gui/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,19 @@ def test_init(mocker):
mock_li = mocker.MagicMock(return_value=load_icon('icon.png'))
mock_lo = mocker.MagicMock(return_value=QHBoxLayout())
mock_lo().addWidget = mocker.MagicMock()

mocker.patch('securedrop_client.gui.main.load_icon', mock_li)
mock_lp = mocker.patch('securedrop_client.gui.main.LeftPane')
mock_mv = mocker.patch('securedrop_client.gui.main.MainView')
mocker.patch('securedrop_client.gui.main.QHBoxLayout', mock_lo)
mocker.patch('securedrop_client.gui.main.QMainWindow')

w = Window('mock')

assert w.conversations == {}
assert w.sdc_home == 'mock'
mock_li.assert_called_once_with(w.icon)
mock_lp.assert_called_once_with()
mock_mv.assert_called_once_with(w.widget)
mock_mv.assert_called_once_with(w.main_pane)
assert mock_lo().addWidget.call_count == 2


Expand All @@ -41,8 +43,45 @@ def test_setup(mocker):
"""
w = Window('mock')
mock_controller = mocker.MagicMock()
w.show_login = mocker.MagicMock()
w.top_pane = mocker.MagicMock()
w.left_pane = mocker.MagicMock()
w.main_view = mocker.MagicMock()
w.main_view.source_list = mocker.MagicMock()

w.setup(mock_controller)

assert w.controller == mock_controller
w.top_pane.setup.assert_called_once_with(mock_controller)
w.left_pane.setup.assert_called_once_with(w, mock_controller)
w.main_view.source_list.setup.assert_called_once_with(mock_controller)
w.show_login.assert_called_once_with()


def test_show_main_window(mocker):
w = Window('mock')
w.autosize_window = mocker.MagicMock()
w.show = mocker.MagicMock()
w.set_logged_in_as = mocker.MagicMock()

w.show_main_window(username='test_username')

w.autosize_window.assert_called_once_with()
w.show.assert_called_once_with()
w.set_logged_in_as.assert_called_once_with('test_username')


def test_show_main_window_without_username(mocker):
w = Window('mock')
w.autosize_window = mocker.MagicMock()
w.show = mocker.MagicMock()
w.set_logged_in_as = mocker.MagicMock()

w.show_main_window()

w.autosize_window.assert_called_once_with()
w.show.assert_called_once_with()
w.set_logged_in_as.called is False


def test_autosize_window(mocker):
Expand All @@ -66,9 +105,8 @@ def test_show_login(mocker):
"""
The login dialog is displayed with a clean state.
"""
mock_controller = mocker.MagicMock()
w = Window('mock')
w.setup(mock_controller)
w.controller = mocker.MagicMock()
mock_ld = mocker.patch('securedrop_client.gui.main.LoginDialog')

w.show_login()
Expand All @@ -82,24 +120,27 @@ def test_show_login_error(mocker):
"""
Ensures that an error message is displayed in the login dialog.
"""
mock_controller = mocker.MagicMock()
w = Window('mock')
w.setup(mock_controller)
w.show_login = mocker.MagicMock()
w.setup(mocker.MagicMock())
w.login_dialog = mocker.MagicMock()

w.show_login_error('boom')

w.login_dialog.error.assert_called_once_with('boom')


def test_hide_login(mocker):
"""
Ensure the login dialog is closed and hidden.
"""
mock_controller = mocker.MagicMock()
w = Window('mock')
w.setup(mock_controller)
w.show_login = mocker.MagicMock()
ld = mocker.MagicMock()
w.login_dialog = ld

w.hide_login()

ld.accept.assert_called_once_with()
assert w.login_dialog is None

Expand Down Expand Up @@ -198,7 +239,9 @@ def test_set_logged_in_as(mocker):
"""
w = Window('mock')
w.left_pane = mocker.MagicMock()

w.set_logged_in_as('test')

w.left_pane.set_logged_in_as.assert_called_once_with('test')


Expand Down
43 changes: 42 additions & 1 deletion tests/gui/test_widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
Make sure the UI widgets are configured correctly and work as expected.
"""
from PyQt5.QtWidgets import QWidget, QApplication, QWidgetItem, QSpacerItem, QVBoxLayout, \
QMessageBox, QLabel
QMessageBox, QLabel, QMainWindow
from tests import factory
from securedrop_client import db, logic
from securedrop_client.gui.widgets import MainView, SourceList, SourceWidget, LoginDialog, \
Expand Down Expand Up @@ -379,6 +379,17 @@ def test_MainView_init():
assert isinstance(mv.view_holder, QWidget)


def test_MainView_setup(mocker):
mv = MainView(None)
mv.source_list = mocker.MagicMock()
controller = mocker.MagicMock()

mv.setup(controller)

assert mv.controller == controller
mv.source_list.setup.assert_called_once_with(controller)


def test_MainView_show_conversation(mocker):
"""
Ensure the passed-in widget is added to the layout of the main view holder
Expand Down Expand Up @@ -779,6 +790,36 @@ def test_LoginDialog_validate_input_ok(mocker):
mock_controller.login.assert_called_once_with('foo', 'nicelongpassword', '123456')


def test_LoginDialog_closeEvent_exits(mocker):
"""
If the main window is not visible, then exit the application when the LoginDialog receives a
close event.
"""
mw = QMainWindow()
ld = LoginDialog(mw)
sys_exit_fn = mocker.patch('securedrop_client.gui.widgets.sys.exit')
mw.hide()

ld.closeEvent(event='mock')

sys_exit_fn.assert_called_once_with(0)


def test_LoginDialog_closeEvent_does_not_exit_when_main_window_is_visible(mocker):
"""
If the main window is visible, then to not exit the application when the LoginDialog receives a
close event.
"""
mw = QMainWindow()
ld = LoginDialog(mw)
sys_exit_fn = mocker.patch('securedrop_client.gui.widgets.sys.exit')
mw.show()

ld.closeEvent(event='mock')

assert sys_exit_fn.called is False


def test_SpeechBubble_init(mocker):
"""
Check the speech bubble is configured correctly (there's a label containing
Expand Down
Loading

0 comments on commit 36b82d3

Please sign in to comment.