messagefoundry 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.
- messagefoundry/__init__.py +108 -0
- messagefoundry/__main__.py +1155 -0
- messagefoundry/api/__init__.py +27 -0
- messagefoundry/api/app.py +1581 -0
- messagefoundry/api/approvals.py +184 -0
- messagefoundry/api/auth_models.py +211 -0
- messagefoundry/api/auth_routes.py +655 -0
- messagefoundry/api/field_authz.py +96 -0
- messagefoundry/api/models.py +374 -0
- messagefoundry/api/security.py +247 -0
- messagefoundry/api/tls.py +47 -0
- messagefoundry/auth/__init__.py +39 -0
- messagefoundry/auth/data/common_passwords.NOTICE +13 -0
- messagefoundry/auth/data/common_passwords.txt +10000 -0
- messagefoundry/auth/identity.py +71 -0
- messagefoundry/auth/ldap.py +264 -0
- messagefoundry/auth/notifications.py +68 -0
- messagefoundry/auth/passwords.py +53 -0
- messagefoundry/auth/permissions.py +120 -0
- messagefoundry/auth/policy.py +153 -0
- messagefoundry/auth/ratelimit.py +55 -0
- messagefoundry/auth/service.py +1323 -0
- messagefoundry/auth/tokens.py +26 -0
- messagefoundry/auth/totp.py +174 -0
- messagefoundry/checks.py +174 -0
- messagefoundry/config/__init__.py +30 -0
- messagefoundry/config/active_environment.py +80 -0
- messagefoundry/config/ai_policy.py +140 -0
- messagefoundry/config/code_sets.py +260 -0
- messagefoundry/config/connections_edit.py +200 -0
- messagefoundry/config/connections_file.py +287 -0
- messagefoundry/config/db_lookup.py +117 -0
- messagefoundry/config/environments.py +116 -0
- messagefoundry/config/ingest_time.py +83 -0
- messagefoundry/config/models.py +240 -0
- messagefoundry/config/reference.py +158 -0
- messagefoundry/config/response.py +83 -0
- messagefoundry/config/run_context.py +153 -0
- messagefoundry/config/settings.py +1311 -0
- messagefoundry/config/state.py +99 -0
- messagefoundry/config/tls_policy.py +110 -0
- messagefoundry/config/wiring.py +1918 -0
- messagefoundry/console/__init__.py +20 -0
- messagefoundry/console/__main__.py +274 -0
- messagefoundry/console/_async.py +107 -0
- messagefoundry/console/change_password.py +111 -0
- messagefoundry/console/client.py +552 -0
- messagefoundry/console/connections.py +324 -0
- messagefoundry/console/login.py +107 -0
- messagefoundry/console/mfa.py +205 -0
- messagefoundry/console/reauth.py +94 -0
- messagefoundry/console/search.py +57 -0
- messagefoundry/console/service_control.py +137 -0
- messagefoundry/console/sessions.py +122 -0
- messagefoundry/console/shell.py +410 -0
- messagefoundry/console/status.py +377 -0
- messagefoundry/console/users_page.py +282 -0
- messagefoundry/console/widgets.py +553 -0
- messagefoundry/generators/README.md +27 -0
- messagefoundry/generators/__init__.py +15 -0
- messagefoundry/generators/_core.py +589 -0
- messagefoundry/generators/_hl7data.py +428 -0
- messagefoundry/generators/adt.py +286 -0
- messagefoundry/generators/all_types.py +24 -0
- messagefoundry/generators/bar.py +28 -0
- messagefoundry/generators/dft.py +20 -0
- messagefoundry/generators/mdm.py +39 -0
- messagefoundry/generators/mfn.py +46 -0
- messagefoundry/generators/oml.py +32 -0
- messagefoundry/generators/orl.py +30 -0
- messagefoundry/generators/orm.py +23 -0
- messagefoundry/generators/oru.py +21 -0
- messagefoundry/generators/ras.py +20 -0
- messagefoundry/generators/rde.py +54 -0
- messagefoundry/generators/siu.py +64 -0
- messagefoundry/generators/vxu.py +20 -0
- messagefoundry/hl7schema.py +75 -0
- messagefoundry/last_resort.py +55 -0
- messagefoundry/logging_setup.py +332 -0
- messagefoundry/parsing/__init__.py +64 -0
- messagefoundry/parsing/consistency.py +166 -0
- messagefoundry/parsing/groups.py +228 -0
- messagefoundry/parsing/message.py +453 -0
- messagefoundry/parsing/peek.py +237 -0
- messagefoundry/parsing/split.py +120 -0
- messagefoundry/parsing/summary.py +46 -0
- messagefoundry/parsing/tree.py +128 -0
- messagefoundry/parsing/validate.py +95 -0
- messagefoundry/parsing/x12/__init__.py +46 -0
- messagefoundry/parsing/x12/delimiters.py +140 -0
- messagefoundry/parsing/x12/errors.py +30 -0
- messagefoundry/parsing/x12/interchange.py +232 -0
- messagefoundry/parsing/x12/message.py +200 -0
- messagefoundry/parsing/x12/peek.py +207 -0
- messagefoundry/pipeline/__init__.py +21 -0
- messagefoundry/pipeline/alert_sinks.py +486 -0
- messagefoundry/pipeline/alerts.py +100 -0
- messagefoundry/pipeline/cert_expiry.py +219 -0
- messagefoundry/pipeline/cluster.py +955 -0
- messagefoundry/pipeline/cluster_sqlserver.py +444 -0
- messagefoundry/pipeline/config_convergence.py +137 -0
- messagefoundry/pipeline/dryrun.py +450 -0
- messagefoundry/pipeline/engine.py +756 -0
- messagefoundry/pipeline/leader_tasks.py +158 -0
- messagefoundry/pipeline/reference_sync.py +369 -0
- messagefoundry/pipeline/retention.py +289 -0
- messagefoundry/pipeline/security_notify.py +168 -0
- messagefoundry/pipeline/state_convergence.py +143 -0
- messagefoundry/pipeline/wiring_runner.py +1722 -0
- messagefoundry/py.typed +0 -0
- messagefoundry/redaction.py +71 -0
- messagefoundry/scaffold.py +321 -0
- messagefoundry/secrets_dpapi.py +129 -0
- messagefoundry/store/__init__.py +46 -0
- messagefoundry/store/audit_tee.py +67 -0
- messagefoundry/store/base.py +758 -0
- messagefoundry/store/crypto.py +166 -0
- messagefoundry/store/keyprovider.py +192 -0
- messagefoundry/store/postgres.py +3447 -0
- messagefoundry/store/sqlserver.py +3014 -0
- messagefoundry/store/store.py +3790 -0
- messagefoundry/timezone.py +207 -0
- messagefoundry/transports/__init__.py +50 -0
- messagefoundry/transports/base.py +269 -0
- messagefoundry/transports/database.py +693 -0
- messagefoundry/transports/file.py +551 -0
- messagefoundry/transports/framing.py +164 -0
- messagefoundry/transports/loopback.py +53 -0
- messagefoundry/transports/mllp.py +644 -0
- messagefoundry/transports/remotefile.py +664 -0
- messagefoundry/transports/rest.py +281 -0
- messagefoundry/transports/signing.py +321 -0
- messagefoundry/transports/soap.py +507 -0
- messagefoundry/transports/tcp.py +307 -0
- messagefoundry/transports/timer.py +146 -0
- messagefoundry/transports/x12.py +323 -0
- messagefoundry-0.1.0.dist-info/METADATA +212 -0
- messagefoundry-0.1.0.dist-info/RECORD +142 -0
- messagefoundry-0.1.0.dist-info/WHEEL +4 -0
- messagefoundry-0.1.0.dist-info/entry_points.txt +2 -0
- messagefoundry-0.1.0.dist-info/licenses/LICENSE +662 -0
- messagefoundry-0.1.0.dist-info/licenses/NOTICE +27 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Connections dashboard: one row per endpoint (each inbound + each outbound connection).
|
|
4
|
+
|
|
5
|
+
A toolbar acts on the selected rows — Start/Stop/Restart operate on the selected **inbound**
|
|
6
|
+
connections; Purge clears the queue of the selected **outbound** connections. The table is fed by
|
|
7
|
+
the server-computed ``GET /connections`` rows, so the page stays thin. Clicking a row's *Logs*
|
|
8
|
+
link asks the shell to open the Log Search page filtered to that connection.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Callable
|
|
15
|
+
|
|
16
|
+
from PySide6.QtCore import QItemSelectionModel, Qt, Signal
|
|
17
|
+
from PySide6.QtGui import QBrush, QColor
|
|
18
|
+
from PySide6.QtWidgets import (
|
|
19
|
+
QHBoxLayout,
|
|
20
|
+
QLabel,
|
|
21
|
+
QMenu,
|
|
22
|
+
QMessageBox,
|
|
23
|
+
QPushButton,
|
|
24
|
+
QTableWidgetItem,
|
|
25
|
+
QToolButton,
|
|
26
|
+
QVBoxLayout,
|
|
27
|
+
QWidget,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
from messagefoundry.api.models import ConnectionRow
|
|
31
|
+
from messagefoundry.console._async import AsyncRunner
|
|
32
|
+
from messagefoundry.console.client import ApiError, EngineClient
|
|
33
|
+
from messagefoundry.console.widgets import ConfigurableTable
|
|
34
|
+
|
|
35
|
+
_COLUMNS = [
|
|
36
|
+
"Name",
|
|
37
|
+
"Status",
|
|
38
|
+
"Direction",
|
|
39
|
+
"Method",
|
|
40
|
+
"Logs",
|
|
41
|
+
"Queue Depth",
|
|
42
|
+
"Idle",
|
|
43
|
+
"Alerts",
|
|
44
|
+
"# Errored",
|
|
45
|
+
"# Read",
|
|
46
|
+
"# Written",
|
|
47
|
+
"Peer",
|
|
48
|
+
"Port",
|
|
49
|
+
"Backlog",
|
|
50
|
+
"Delivered Age",
|
|
51
|
+
]
|
|
52
|
+
_LOGS_COL = 4
|
|
53
|
+
|
|
54
|
+
# (role, channel_id, destination) identifies a row across refreshes for selection + actions.
|
|
55
|
+
_RowKey = tuple[str, str, str]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _fmt_int(n: int | None) -> str:
|
|
59
|
+
return "—" if n is None else str(n)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _fmt_secs(s: float | None) -> str:
|
|
63
|
+
if s is None:
|
|
64
|
+
return "—"
|
|
65
|
+
if s < 1:
|
|
66
|
+
return "0s"
|
|
67
|
+
if s < 60:
|
|
68
|
+
return f"{s:.0f}s"
|
|
69
|
+
if s < 3600:
|
|
70
|
+
return f"{s / 60:.0f}m"
|
|
71
|
+
return f"{s / 3600:.1f}h"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _style_link(item: QTableWidgetItem) -> None:
|
|
75
|
+
"""Make a cell look like a clickable link (blue, underlined)."""
|
|
76
|
+
item.setForeground(QBrush(QColor("#1a73e8")))
|
|
77
|
+
font = item.font()
|
|
78
|
+
font.setUnderline(True)
|
|
79
|
+
item.setFont(font)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class _Snapshot:
|
|
84
|
+
"""One off-thread refresh result, applied on the main thread. ``error`` set ⇒ the read failed
|
|
85
|
+
(the table is left as-is); otherwise ``rows`` holds the endpoint list to render."""
|
|
86
|
+
|
|
87
|
+
rows: list[ConnectionRow] | None
|
|
88
|
+
error: str | None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class ConnectionsPage(QWidget):
|
|
92
|
+
"""Endpoint table + action toolbar."""
|
|
93
|
+
|
|
94
|
+
error = Signal(str)
|
|
95
|
+
open_logs = Signal(str) # channel_id — ask the shell to open Log Search filtered to it
|
|
96
|
+
|
|
97
|
+
def __init__(self, client: EngineClient, *, poll_client: EngineClient | None = None) -> None:
|
|
98
|
+
super().__init__()
|
|
99
|
+
self._client = client # actions (start/stop/purge) — main thread, may step-up/MFA
|
|
100
|
+
self._poll = poll_client or client # reads — run off the main thread (see _async)
|
|
101
|
+
self._runner = AsyncRunner(self)
|
|
102
|
+
self._loading = False # in-flight refresh guard (don't pile up during a slow call)
|
|
103
|
+
# A refresh requested while one is in flight is latched (not dropped) and re-fired on
|
|
104
|
+
# completion, so a post-action refresh (start/stop/purge) isn't lost when it lands during a
|
|
105
|
+
# periodic tick. None = none pending; bool = pending autosize (OR-merged).
|
|
106
|
+
self._pending: bool | None = None
|
|
107
|
+
|
|
108
|
+
self._start = QPushButton("Start")
|
|
109
|
+
self._stop = QPushButton("Stop")
|
|
110
|
+
self._actions = QToolButton()
|
|
111
|
+
self._actions.setText("Actions ▾")
|
|
112
|
+
self._actions.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
|
|
113
|
+
menu = QMenu(self._actions)
|
|
114
|
+
act_restart = menu.addAction("Restart")
|
|
115
|
+
menu.addSeparator()
|
|
116
|
+
act_purge_top = menu.addAction("Purge Top Message")
|
|
117
|
+
act_purge_all = menu.addAction("Purge All Queued Messages")
|
|
118
|
+
self._actions.setMenu(menu)
|
|
119
|
+
|
|
120
|
+
self._start.clicked.connect(lambda: self._inbound_action(self._client.start_connection))
|
|
121
|
+
self._stop.clicked.connect(lambda: self._inbound_action(self._client.stop_connection))
|
|
122
|
+
act_restart.triggered.connect(lambda: self._inbound_action(self._client.restart_connection))
|
|
123
|
+
act_purge_top.triggered.connect(lambda: self._purge("top"))
|
|
124
|
+
act_purge_all.triggered.connect(lambda: self._purge("all"))
|
|
125
|
+
|
|
126
|
+
toolbar = QHBoxLayout()
|
|
127
|
+
toolbar.addWidget(QLabel("Connections"))
|
|
128
|
+
toolbar.addStretch(1)
|
|
129
|
+
toolbar.addWidget(self._start)
|
|
130
|
+
toolbar.addWidget(self._stop)
|
|
131
|
+
toolbar.addWidget(self._actions)
|
|
132
|
+
|
|
133
|
+
self._table = ConfigurableTable(
|
|
134
|
+
_COLUMNS, settings_key="connections/header_state", multi=True
|
|
135
|
+
)
|
|
136
|
+
self._table.itemSelectionChanged.connect(self._sync_toolbar)
|
|
137
|
+
self._table.cellClicked.connect(self._on_cell_clicked)
|
|
138
|
+
self._loaded = False # autosize columns on the first (and user-initiated) loads
|
|
139
|
+
|
|
140
|
+
layout = QVBoxLayout(self)
|
|
141
|
+
layout.addLayout(toolbar)
|
|
142
|
+
layout.addWidget(self._table)
|
|
143
|
+
self._sync_toolbar()
|
|
144
|
+
|
|
145
|
+
def refresh(self, *, autosize: bool = False) -> None:
|
|
146
|
+
# Read the endpoint list OFF the main thread (a slow/wedged engine can stall /connections for
|
|
147
|
+
# up to the client timeout); the result applies on the main thread via _apply.
|
|
148
|
+
if self._loading:
|
|
149
|
+
self._pending = autosize if self._pending is None else (self._pending or autosize)
|
|
150
|
+
return # a fetch is already in flight — latch this one, don't pile up or drop it
|
|
151
|
+
self._pending = None
|
|
152
|
+
self._loading = True
|
|
153
|
+
self._runner.submit(
|
|
154
|
+
self._fetch,
|
|
155
|
+
on_done=lambda snap: self._apply(snap, autosize=autosize),
|
|
156
|
+
on_error=self._on_error,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def stop(self) -> None:
|
|
160
|
+
"""Stop the background runner (call on window close) so a late result can't touch dead widgets."""
|
|
161
|
+
self._runner.stop()
|
|
162
|
+
|
|
163
|
+
def _on_error(self, exc: BaseException) -> None:
|
|
164
|
+
# Belt-and-suspenders: connections() raises only ApiError (handled via the snapshot in _apply),
|
|
165
|
+
# but an unexpected error must still clear the in-flight guard or the page wedges forever.
|
|
166
|
+
self._loading = False
|
|
167
|
+
self.error.emit(str(exc))
|
|
168
|
+
self._drain_pending()
|
|
169
|
+
|
|
170
|
+
def _drain_pending(self) -> bool:
|
|
171
|
+
"""Re-fire a refresh that was latched while one was in flight. Returns True if it did."""
|
|
172
|
+
if self._pending is None:
|
|
173
|
+
return False
|
|
174
|
+
autosize = self._pending
|
|
175
|
+
self._pending = None
|
|
176
|
+
self.refresh(autosize=autosize)
|
|
177
|
+
return True
|
|
178
|
+
|
|
179
|
+
def _fetch(self) -> _Snapshot:
|
|
180
|
+
"""Runs on a worker thread — only blocking I/O, no widget access."""
|
|
181
|
+
try:
|
|
182
|
+
return _Snapshot(self._poll.connections(), None)
|
|
183
|
+
except ApiError as exc:
|
|
184
|
+
return _Snapshot(None, str(exc))
|
|
185
|
+
|
|
186
|
+
def _apply(self, snap: _Snapshot, *, autosize: bool = False) -> None:
|
|
187
|
+
"""Runs on the main thread (result slot) — safe to touch widgets."""
|
|
188
|
+
self._loading = False
|
|
189
|
+
if self._drain_pending():
|
|
190
|
+
return # a refresh was requested mid-flight — re-fire it, skip this superseded snapshot
|
|
191
|
+
if snap.error is not None:
|
|
192
|
+
self.error.emit(snap.error)
|
|
193
|
+
return
|
|
194
|
+
rows = snap.rows or []
|
|
195
|
+
selected = self._selected_keys()
|
|
196
|
+
self._table.begin_populate()
|
|
197
|
+
self._table.setRowCount(len(rows))
|
|
198
|
+
for r, row in enumerate(rows):
|
|
199
|
+
key: _RowKey = (row.role, row.channel_id, row.destination or "")
|
|
200
|
+
cells = [
|
|
201
|
+
row.name,
|
|
202
|
+
f"{row.status} [SIMULATED]" if row.simulated else row.status,
|
|
203
|
+
row.direction,
|
|
204
|
+
row.method,
|
|
205
|
+
"Logs", # clickable cell -> open_logs (see _on_cell_clicked)
|
|
206
|
+
_fmt_int(row.queue_depth),
|
|
207
|
+
_fmt_secs(row.idle_seconds),
|
|
208
|
+
_fmt_int(row.alerts_active),
|
|
209
|
+
_fmt_int(row.errored),
|
|
210
|
+
_fmt_int(row.read),
|
|
211
|
+
_fmt_int(row.written),
|
|
212
|
+
row.peer or "",
|
|
213
|
+
_fmt_int(row.port),
|
|
214
|
+
_fmt_secs(row.backlog_seconds),
|
|
215
|
+
_fmt_secs(row.delivered_age_seconds),
|
|
216
|
+
]
|
|
217
|
+
for c, text in enumerate(cells):
|
|
218
|
+
item = QTableWidgetItem(text)
|
|
219
|
+
if c == 0:
|
|
220
|
+
item.setData(Qt.ItemDataRole.UserRole, key)
|
|
221
|
+
elif c == _LOGS_COL:
|
|
222
|
+
_style_link(item)
|
|
223
|
+
self._table.setItem(r, c, item)
|
|
224
|
+
self._table.end_populate(autosize=autosize or not self._loaded)
|
|
225
|
+
self._loaded = True
|
|
226
|
+
self._reselect(selected)
|
|
227
|
+
|
|
228
|
+
def reload(self) -> None:
|
|
229
|
+
"""User-initiated load (nav/open) — autosizes columns to contents."""
|
|
230
|
+
self.refresh(autosize=True)
|
|
231
|
+
|
|
232
|
+
# --- selection -----------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
def _selected_keys(self) -> list[_RowKey]:
|
|
235
|
+
model = self._table.selectionModel()
|
|
236
|
+
if model is None:
|
|
237
|
+
return []
|
|
238
|
+
keys: list[_RowKey] = []
|
|
239
|
+
for index in model.selectedRows():
|
|
240
|
+
item = self._table.item(index.row(), 0)
|
|
241
|
+
data = item.data(Qt.ItemDataRole.UserRole) if item else None
|
|
242
|
+
if data:
|
|
243
|
+
keys.append((data[0], data[1], data[2]))
|
|
244
|
+
return keys
|
|
245
|
+
|
|
246
|
+
def _reselect(self, keys: list[_RowKey]) -> None:
|
|
247
|
+
model = self._table.selectionModel()
|
|
248
|
+
if model is None or not keys:
|
|
249
|
+
self._sync_toolbar()
|
|
250
|
+
return
|
|
251
|
+
wanted = set(keys)
|
|
252
|
+
self._table.blockSignals(True)
|
|
253
|
+
model.clearSelection()
|
|
254
|
+
flags = QItemSelectionModel.SelectionFlag.Select | QItemSelectionModel.SelectionFlag.Rows
|
|
255
|
+
for r in range(self._table.rowCount()):
|
|
256
|
+
item = self._table.item(r, 0)
|
|
257
|
+
data = item.data(Qt.ItemDataRole.UserRole) if item else None
|
|
258
|
+
if data and (data[0], data[1], data[2]) in wanted:
|
|
259
|
+
model.select(self._table.model().index(r, 0), flags)
|
|
260
|
+
self._table.blockSignals(False)
|
|
261
|
+
self._sync_toolbar()
|
|
262
|
+
|
|
263
|
+
def _sync_toolbar(self) -> None:
|
|
264
|
+
has = bool(self._selected_keys())
|
|
265
|
+
self._start.setEnabled(has)
|
|
266
|
+
self._stop.setEnabled(has)
|
|
267
|
+
self._actions.setEnabled(has)
|
|
268
|
+
|
|
269
|
+
# --- actions -------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
def _inbound_action(self, action: Callable[[str], None]) -> None:
|
|
272
|
+
"""Start/Stop/Restart the inbound connection(s) in the selected source rows."""
|
|
273
|
+
names: list[str] = []
|
|
274
|
+
for role, channel_id, _dest in self._selected_keys():
|
|
275
|
+
if role == "source" and channel_id not in names:
|
|
276
|
+
names.append(channel_id)
|
|
277
|
+
if not names:
|
|
278
|
+
self.error.emit("Select one or more inbound (source) rows.")
|
|
279
|
+
return
|
|
280
|
+
try:
|
|
281
|
+
for name in names:
|
|
282
|
+
action(name)
|
|
283
|
+
except ApiError as exc:
|
|
284
|
+
self.error.emit(str(exc))
|
|
285
|
+
return
|
|
286
|
+
self.refresh()
|
|
287
|
+
|
|
288
|
+
def _purge(self, scope: str) -> None:
|
|
289
|
+
"""Purge the queue of the outbound connection(s) in the selected destination rows."""
|
|
290
|
+
names: list[str] = []
|
|
291
|
+
for role, _channel_id, dest in self._selected_keys():
|
|
292
|
+
if role == "destination" and dest and dest not in names:
|
|
293
|
+
names.append(dest)
|
|
294
|
+
if not names:
|
|
295
|
+
self.error.emit("Select one or more outbound (destination) rows to purge.")
|
|
296
|
+
return
|
|
297
|
+
if scope == "all":
|
|
298
|
+
# Bulk + destructive: cancels every queued delivery (they won't retry). Confirm, default
|
|
299
|
+
# No, so an accidental click next to "Purge Top Message" can't wipe a queue (review M-28).
|
|
300
|
+
answer = QMessageBox.question(
|
|
301
|
+
self,
|
|
302
|
+
"Purge all queued messages",
|
|
303
|
+
f"Cancel ALL queued deliveries to {', '.join(names)}?\n\n"
|
|
304
|
+
"Queued messages won't be sent and won't retry. This can't be undone.",
|
|
305
|
+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
306
|
+
QMessageBox.StandardButton.No,
|
|
307
|
+
)
|
|
308
|
+
if answer != QMessageBox.StandardButton.Yes:
|
|
309
|
+
return
|
|
310
|
+
try:
|
|
311
|
+
for name in names:
|
|
312
|
+
self._client.purge_connection(name, scope)
|
|
313
|
+
except ApiError as exc:
|
|
314
|
+
self.error.emit(str(exc))
|
|
315
|
+
return
|
|
316
|
+
self.refresh()
|
|
317
|
+
|
|
318
|
+
def _on_cell_clicked(self, row: int, col: int) -> None:
|
|
319
|
+
if col != _LOGS_COL:
|
|
320
|
+
return
|
|
321
|
+
item = self._table.item(row, 0)
|
|
322
|
+
key = item.data(Qt.ItemDataRole.UserRole) if item else None
|
|
323
|
+
if key:
|
|
324
|
+
self.open_logs.emit(key[1]) # channel_id
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Sign-in dialog shown before the main window when the engine requires authentication."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from PySide6.QtWidgets import (
|
|
8
|
+
QComboBox,
|
|
9
|
+
QDialog,
|
|
10
|
+
QFormLayout,
|
|
11
|
+
QLabel,
|
|
12
|
+
QLineEdit,
|
|
13
|
+
QMessageBox,
|
|
14
|
+
QPushButton,
|
|
15
|
+
QVBoxLayout,
|
|
16
|
+
QWidget,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from messagefoundry.console.client import ApiError, EngineClient
|
|
20
|
+
from messagefoundry.console.widgets import ERROR_COLOR
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LoginDialog(QDialog):
|
|
24
|
+
"""Collects credentials and a provider, then calls :meth:`EngineClient.login`.
|
|
25
|
+
|
|
26
|
+
On success the client holds the session token; the caller persists it (OS keyring) and opens
|
|
27
|
+
the main window. ``providers()`` decides which provider options to offer.
|
|
28
|
+
|
|
29
|
+
When the engine reports ``must_change_password`` the dialog still ``accept()``s so the
|
|
30
|
+
entrypoint regains control, but exposes :attr:`must_change_password` and
|
|
31
|
+
:attr:`entered_password` so it can chain a :class:`ChangePasswordDialog` and re-prompt sign-in.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, client: EngineClient, parent: QWidget | None = None) -> None:
|
|
35
|
+
super().__init__(parent)
|
|
36
|
+
self.setWindowTitle("Sign in — MessageFoundry")
|
|
37
|
+
self._client = client
|
|
38
|
+
# Seams read by _authenticate() after the dialog is accepted.
|
|
39
|
+
self.must_change_password = False
|
|
40
|
+
self.mfa_required = False # engine wants a second factor before sensitive ops (WP-14)
|
|
41
|
+
self.entered_password = "" # nosec B105 (empty seam init, not a credential)
|
|
42
|
+
|
|
43
|
+
self._username = QLineEdit()
|
|
44
|
+
self._password = QLineEdit()
|
|
45
|
+
self._password.setEchoMode(QLineEdit.EchoMode.Password)
|
|
46
|
+
self._provider = QComboBox()
|
|
47
|
+
self._provider.addItem("Local", "local")
|
|
48
|
+
try:
|
|
49
|
+
providers = client.providers()
|
|
50
|
+
except ApiError:
|
|
51
|
+
providers = None
|
|
52
|
+
if providers is not None and providers.ad:
|
|
53
|
+
self._provider.addItem("Active Directory", "ad")
|
|
54
|
+
|
|
55
|
+
form = QFormLayout()
|
|
56
|
+
form.addRow("Username", self._username)
|
|
57
|
+
form.addRow("Password", self._password)
|
|
58
|
+
form.addRow("Provider", self._provider)
|
|
59
|
+
|
|
60
|
+
self._error = QLabel("")
|
|
61
|
+
self._error.setStyleSheet(f"color: {ERROR_COLOR};")
|
|
62
|
+
self._error.setWordWrap(True)
|
|
63
|
+
|
|
64
|
+
sign_in = QPushButton("Sign in")
|
|
65
|
+
sign_in.setDefault(True)
|
|
66
|
+
sign_in.clicked.connect(self._attempt)
|
|
67
|
+
|
|
68
|
+
layout = QVBoxLayout(self)
|
|
69
|
+
layout.addLayout(form)
|
|
70
|
+
layout.addWidget(self._error)
|
|
71
|
+
layout.addWidget(sign_in)
|
|
72
|
+
|
|
73
|
+
self._username.returnPressed.connect(self._password.setFocus)
|
|
74
|
+
self._password.returnPressed.connect(self._attempt)
|
|
75
|
+
|
|
76
|
+
def _attempt(self) -> None:
|
|
77
|
+
username = self._username.text().strip()
|
|
78
|
+
password = self._password.text()
|
|
79
|
+
provider = str(self._provider.currentData())
|
|
80
|
+
if not username or not password:
|
|
81
|
+
self._error.setText("Enter a username and password.")
|
|
82
|
+
return
|
|
83
|
+
try:
|
|
84
|
+
result = self._client.login(username, password, provider=provider)
|
|
85
|
+
except ApiError as exc:
|
|
86
|
+
self._error.setText("Sign-in failed." if exc.status == 401 else str(exc))
|
|
87
|
+
return
|
|
88
|
+
if result.must_change_password and result.user.auth_provider == "ad":
|
|
89
|
+
# The console can't rotate AD passwords (the server rejects /me/password for AD
|
|
90
|
+
# accounts) — advise and admit; the user changes it in Active Directory. (AD logins
|
|
91
|
+
# don't set must_change_password today, so this branch is a forward-guard.)
|
|
92
|
+
QMessageBox.information(
|
|
93
|
+
self,
|
|
94
|
+
"Password change required",
|
|
95
|
+
"This Active Directory account must change its password. Update it in Active "
|
|
96
|
+
"Directory (or ask an administrator) before your access is restricted.",
|
|
97
|
+
)
|
|
98
|
+
else:
|
|
99
|
+
# Hand the must-change flag and the just-entered plaintext back to _authenticate, which
|
|
100
|
+
# chains a ChangePasswordDialog (prefilling the current password) and re-prompts sign-in.
|
|
101
|
+
self.must_change_password = result.must_change_password
|
|
102
|
+
self.entered_password = password
|
|
103
|
+
# The engine accepted the password but wants a second factor (local MFA-enrolled / required
|
|
104
|
+
# admin); _authenticate prompts for the TOTP code before opening the window (always False for
|
|
105
|
+
# an MFA-delegated AD login).
|
|
106
|
+
self.mfa_required = result.mfa_required
|
|
107
|
+
self.accept()
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""MFA (TOTP) console dialogs (WP-14, ASVS 6.3.3).
|
|
4
|
+
|
|
5
|
+
Two flows over :class:`~messagefoundry.console.client.EngineClient`:
|
|
6
|
+
|
|
7
|
+
- :class:`MfaVerifyDialog` + :func:`make_mfa_handler` — collect a 6-digit TOTP code (or a recovery
|
|
8
|
+
code) and call ``verify_mfa``. Wired into the client as the **X-MFA-Required** handler so any
|
|
9
|
+
sensitive action transparently prompts-and-retries (mirrors the step-up handler in ``reauth.py``),
|
|
10
|
+
and shown at login when the engine reports ``mfa_required``.
|
|
11
|
+
- :class:`MfaEnrollDialog` / :func:`manage_mfa` — enroll a TOTP authenticator (show the setup key +
|
|
12
|
+
``otpauth://`` URI, confirm a live code, then reveal the one-time recovery codes) or turn MFA off.
|
|
13
|
+
|
|
14
|
+
Note: the setup key / URI are shown as text for manual entry — rendering an actual QR image would
|
|
15
|
+
need a new dependency (qrcode), which isn't pulled in; authenticator apps accept manual key entry.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
|
|
22
|
+
from PySide6.QtWidgets import (
|
|
23
|
+
QApplication,
|
|
24
|
+
QDialog,
|
|
25
|
+
QFormLayout,
|
|
26
|
+
QLabel,
|
|
27
|
+
QLineEdit,
|
|
28
|
+
QMessageBox,
|
|
29
|
+
QPushButton,
|
|
30
|
+
QVBoxLayout,
|
|
31
|
+
QWidget,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
from messagefoundry.console.client import ApiError, EngineClient
|
|
35
|
+
from messagefoundry.console.widgets import ERROR_COLOR
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class MfaVerifyDialog(QDialog):
|
|
39
|
+
"""Collect a TOTP code (or single-use recovery code) and call :meth:`EngineClient.verify_mfa`.
|
|
40
|
+
Accepts on success; surfaces a wrong code (401) inline."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, client: EngineClient, *, parent: QWidget | None = None) -> None:
|
|
43
|
+
super().__init__(parent)
|
|
44
|
+
self.setWindowTitle("Two-factor verification — MessageFoundry")
|
|
45
|
+
self._client = client
|
|
46
|
+
|
|
47
|
+
prompt = QLabel(
|
|
48
|
+
"Enter the 6-digit code from your authenticator app (or a recovery code) to continue."
|
|
49
|
+
)
|
|
50
|
+
prompt.setWordWrap(True)
|
|
51
|
+
|
|
52
|
+
self._code = QLineEdit()
|
|
53
|
+
self._code.setPlaceholderText("123456")
|
|
54
|
+
form = QFormLayout()
|
|
55
|
+
form.addRow("Code", self._code)
|
|
56
|
+
|
|
57
|
+
self._error = QLabel("")
|
|
58
|
+
self._error.setStyleSheet(f"color: {ERROR_COLOR};")
|
|
59
|
+
self._error.setWordWrap(True)
|
|
60
|
+
|
|
61
|
+
confirm = QPushButton("Verify")
|
|
62
|
+
confirm.setDefault(True)
|
|
63
|
+
confirm.clicked.connect(self._attempt)
|
|
64
|
+
|
|
65
|
+
layout = QVBoxLayout(self)
|
|
66
|
+
layout.addWidget(prompt)
|
|
67
|
+
layout.addLayout(form)
|
|
68
|
+
layout.addWidget(self._error)
|
|
69
|
+
layout.addWidget(confirm)
|
|
70
|
+
|
|
71
|
+
self._code.returnPressed.connect(self._attempt)
|
|
72
|
+
|
|
73
|
+
def _attempt(self) -> None:
|
|
74
|
+
code = self._code.text().strip()
|
|
75
|
+
if not code:
|
|
76
|
+
self._error.setText("Enter a code.")
|
|
77
|
+
return
|
|
78
|
+
try:
|
|
79
|
+
self._client.verify_mfa(code)
|
|
80
|
+
except ApiError as exc:
|
|
81
|
+
self._error.setText("That code is not valid." if exc.status == 401 else str(exc))
|
|
82
|
+
return
|
|
83
|
+
self._code.clear() # don't leave the code in the field
|
|
84
|
+
self.accept()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def make_mfa_handler(client: EngineClient) -> Callable[[], bool]:
|
|
88
|
+
"""Handler for :meth:`EngineClient.set_mfa_handler`: prompt for a second factor on the active
|
|
89
|
+
window and return ``True`` iff verified. Runs on the calling thread (the console's sensitive
|
|
90
|
+
actions run on the Qt main thread, so the modal dialog shows directly)."""
|
|
91
|
+
|
|
92
|
+
def handler() -> bool:
|
|
93
|
+
dialog = MfaVerifyDialog(client, parent=QApplication.activeWindow())
|
|
94
|
+
return dialog.exec() == QDialog.DialogCode.Accepted
|
|
95
|
+
|
|
96
|
+
return handler
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class MfaEnrollDialog(QDialog):
|
|
100
|
+
"""Two-step TOTP enrollment: show the setup key + ``otpauth://`` URI, collect a confirming code,
|
|
101
|
+
then reveal the one-time recovery codes. ``enroll_mfa()`` stages the secret (step-up gated, so the
|
|
102
|
+
client's step-up handler may prompt for the password first)."""
|
|
103
|
+
|
|
104
|
+
def __init__(self, client: EngineClient, *, parent: QWidget | None = None) -> None:
|
|
105
|
+
super().__init__(parent)
|
|
106
|
+
self.setWindowTitle("Enable two-factor authentication — MessageFoundry")
|
|
107
|
+
self._client = client
|
|
108
|
+
|
|
109
|
+
layout = QVBoxLayout(self)
|
|
110
|
+
self._error = QLabel("")
|
|
111
|
+
self._error.setStyleSheet(f"color: {ERROR_COLOR};")
|
|
112
|
+
self._error.setWordWrap(True)
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
enroll = client.enroll_mfa()
|
|
116
|
+
except ApiError as exc:
|
|
117
|
+
failed = QLabel(f"Could not start enrollment: {exc}")
|
|
118
|
+
failed.setWordWrap(True)
|
|
119
|
+
close = QPushButton("Close")
|
|
120
|
+
close.clicked.connect(self.reject)
|
|
121
|
+
layout.addWidget(failed)
|
|
122
|
+
layout.addWidget(close)
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
intro = QLabel(
|
|
126
|
+
"Add this account to an authenticator app (Google Authenticator, Microsoft "
|
|
127
|
+
"Authenticator, Authy, 1Password): enter the setup key manually, or paste the setup URL "
|
|
128
|
+
"if your app accepts it. Then enter the current 6-digit code to confirm."
|
|
129
|
+
)
|
|
130
|
+
intro.setWordWrap(True)
|
|
131
|
+
|
|
132
|
+
key = QLineEdit(enroll.secret)
|
|
133
|
+
key.setReadOnly(True)
|
|
134
|
+
uri = QLineEdit(enroll.otpauth_uri)
|
|
135
|
+
uri.setReadOnly(True)
|
|
136
|
+
self._code = QLineEdit()
|
|
137
|
+
self._code.setPlaceholderText("123456")
|
|
138
|
+
form = QFormLayout()
|
|
139
|
+
form.addRow("Setup key", key)
|
|
140
|
+
form.addRow("Setup URL", uri)
|
|
141
|
+
form.addRow("Code", self._code)
|
|
142
|
+
|
|
143
|
+
confirm = QPushButton("Confirm")
|
|
144
|
+
confirm.setDefault(True)
|
|
145
|
+
confirm.clicked.connect(self._confirm)
|
|
146
|
+
|
|
147
|
+
layout.addWidget(intro)
|
|
148
|
+
layout.addLayout(form)
|
|
149
|
+
layout.addWidget(self._error)
|
|
150
|
+
layout.addWidget(confirm)
|
|
151
|
+
self._code.returnPressed.connect(self._confirm)
|
|
152
|
+
|
|
153
|
+
def _confirm(self) -> None:
|
|
154
|
+
code = self._code.text().strip()
|
|
155
|
+
if not code:
|
|
156
|
+
self._error.setText("Enter the 6-digit code from your app.")
|
|
157
|
+
return
|
|
158
|
+
try:
|
|
159
|
+
codes = self._client.confirm_mfa(code)
|
|
160
|
+
except ApiError as exc:
|
|
161
|
+
self._error.setText("That code is not valid." if exc.status == 400 else str(exc))
|
|
162
|
+
return
|
|
163
|
+
self._show_recovery_codes(codes)
|
|
164
|
+
self.accept()
|
|
165
|
+
|
|
166
|
+
def _show_recovery_codes(self, codes: list[str]) -> None:
|
|
167
|
+
box = QMessageBox(self)
|
|
168
|
+
box.setWindowTitle("Save your recovery codes")
|
|
169
|
+
box.setIcon(QMessageBox.Icon.Information)
|
|
170
|
+
box.setText(
|
|
171
|
+
"Two-factor authentication is now on. Save these single-use recovery codes somewhere "
|
|
172
|
+
"safe — each works once if you lose your authenticator. They will not be shown again."
|
|
173
|
+
)
|
|
174
|
+
box.setInformativeText("\n".join(codes) if codes else "(no recovery codes configured)")
|
|
175
|
+
box.exec()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def manage_mfa(client: EngineClient, parent: QWidget | None = None) -> None:
|
|
179
|
+
"""Open the right MFA flow for the current state: enroll if off, or offer to turn it off if on."""
|
|
180
|
+
try:
|
|
181
|
+
status = client.mfa_status()
|
|
182
|
+
except ApiError as exc:
|
|
183
|
+
QMessageBox.warning(parent, "Two-factor authentication", str(exc))
|
|
184
|
+
return
|
|
185
|
+
if not status.enabled:
|
|
186
|
+
MfaEnrollDialog(client, parent=parent).exec()
|
|
187
|
+
return
|
|
188
|
+
ask = QMessageBox.question(
|
|
189
|
+
parent,
|
|
190
|
+
"Two-factor authentication",
|
|
191
|
+
f"Two-factor authentication is ON ({status.recovery_codes_remaining} recovery code(s) "
|
|
192
|
+
"left).\n\nTurn it off? You will then sign in with your password only.",
|
|
193
|
+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
194
|
+
QMessageBox.StandardButton.No,
|
|
195
|
+
)
|
|
196
|
+
if ask != QMessageBox.StandardButton.Yes:
|
|
197
|
+
return
|
|
198
|
+
try:
|
|
199
|
+
client.disable_mfa()
|
|
200
|
+
except ApiError as exc:
|
|
201
|
+
QMessageBox.warning(parent, "Two-factor authentication", str(exc))
|
|
202
|
+
return
|
|
203
|
+
QMessageBox.information(
|
|
204
|
+
parent, "Two-factor authentication", "Two-factor authentication is now off."
|
|
205
|
+
)
|