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.
Files changed (142) hide show
  1. messagefoundry/__init__.py +108 -0
  2. messagefoundry/__main__.py +1155 -0
  3. messagefoundry/api/__init__.py +27 -0
  4. messagefoundry/api/app.py +1581 -0
  5. messagefoundry/api/approvals.py +184 -0
  6. messagefoundry/api/auth_models.py +211 -0
  7. messagefoundry/api/auth_routes.py +655 -0
  8. messagefoundry/api/field_authz.py +96 -0
  9. messagefoundry/api/models.py +374 -0
  10. messagefoundry/api/security.py +247 -0
  11. messagefoundry/api/tls.py +47 -0
  12. messagefoundry/auth/__init__.py +39 -0
  13. messagefoundry/auth/data/common_passwords.NOTICE +13 -0
  14. messagefoundry/auth/data/common_passwords.txt +10000 -0
  15. messagefoundry/auth/identity.py +71 -0
  16. messagefoundry/auth/ldap.py +264 -0
  17. messagefoundry/auth/notifications.py +68 -0
  18. messagefoundry/auth/passwords.py +53 -0
  19. messagefoundry/auth/permissions.py +120 -0
  20. messagefoundry/auth/policy.py +153 -0
  21. messagefoundry/auth/ratelimit.py +55 -0
  22. messagefoundry/auth/service.py +1323 -0
  23. messagefoundry/auth/tokens.py +26 -0
  24. messagefoundry/auth/totp.py +174 -0
  25. messagefoundry/checks.py +174 -0
  26. messagefoundry/config/__init__.py +30 -0
  27. messagefoundry/config/active_environment.py +80 -0
  28. messagefoundry/config/ai_policy.py +140 -0
  29. messagefoundry/config/code_sets.py +260 -0
  30. messagefoundry/config/connections_edit.py +200 -0
  31. messagefoundry/config/connections_file.py +287 -0
  32. messagefoundry/config/db_lookup.py +117 -0
  33. messagefoundry/config/environments.py +116 -0
  34. messagefoundry/config/ingest_time.py +83 -0
  35. messagefoundry/config/models.py +240 -0
  36. messagefoundry/config/reference.py +158 -0
  37. messagefoundry/config/response.py +83 -0
  38. messagefoundry/config/run_context.py +153 -0
  39. messagefoundry/config/settings.py +1311 -0
  40. messagefoundry/config/state.py +99 -0
  41. messagefoundry/config/tls_policy.py +110 -0
  42. messagefoundry/config/wiring.py +1918 -0
  43. messagefoundry/console/__init__.py +20 -0
  44. messagefoundry/console/__main__.py +274 -0
  45. messagefoundry/console/_async.py +107 -0
  46. messagefoundry/console/change_password.py +111 -0
  47. messagefoundry/console/client.py +552 -0
  48. messagefoundry/console/connections.py +324 -0
  49. messagefoundry/console/login.py +107 -0
  50. messagefoundry/console/mfa.py +205 -0
  51. messagefoundry/console/reauth.py +94 -0
  52. messagefoundry/console/search.py +57 -0
  53. messagefoundry/console/service_control.py +137 -0
  54. messagefoundry/console/sessions.py +122 -0
  55. messagefoundry/console/shell.py +410 -0
  56. messagefoundry/console/status.py +377 -0
  57. messagefoundry/console/users_page.py +282 -0
  58. messagefoundry/console/widgets.py +553 -0
  59. messagefoundry/generators/README.md +27 -0
  60. messagefoundry/generators/__init__.py +15 -0
  61. messagefoundry/generators/_core.py +589 -0
  62. messagefoundry/generators/_hl7data.py +428 -0
  63. messagefoundry/generators/adt.py +286 -0
  64. messagefoundry/generators/all_types.py +24 -0
  65. messagefoundry/generators/bar.py +28 -0
  66. messagefoundry/generators/dft.py +20 -0
  67. messagefoundry/generators/mdm.py +39 -0
  68. messagefoundry/generators/mfn.py +46 -0
  69. messagefoundry/generators/oml.py +32 -0
  70. messagefoundry/generators/orl.py +30 -0
  71. messagefoundry/generators/orm.py +23 -0
  72. messagefoundry/generators/oru.py +21 -0
  73. messagefoundry/generators/ras.py +20 -0
  74. messagefoundry/generators/rde.py +54 -0
  75. messagefoundry/generators/siu.py +64 -0
  76. messagefoundry/generators/vxu.py +20 -0
  77. messagefoundry/hl7schema.py +75 -0
  78. messagefoundry/last_resort.py +55 -0
  79. messagefoundry/logging_setup.py +332 -0
  80. messagefoundry/parsing/__init__.py +64 -0
  81. messagefoundry/parsing/consistency.py +166 -0
  82. messagefoundry/parsing/groups.py +228 -0
  83. messagefoundry/parsing/message.py +453 -0
  84. messagefoundry/parsing/peek.py +237 -0
  85. messagefoundry/parsing/split.py +120 -0
  86. messagefoundry/parsing/summary.py +46 -0
  87. messagefoundry/parsing/tree.py +128 -0
  88. messagefoundry/parsing/validate.py +95 -0
  89. messagefoundry/parsing/x12/__init__.py +46 -0
  90. messagefoundry/parsing/x12/delimiters.py +140 -0
  91. messagefoundry/parsing/x12/errors.py +30 -0
  92. messagefoundry/parsing/x12/interchange.py +232 -0
  93. messagefoundry/parsing/x12/message.py +200 -0
  94. messagefoundry/parsing/x12/peek.py +207 -0
  95. messagefoundry/pipeline/__init__.py +21 -0
  96. messagefoundry/pipeline/alert_sinks.py +486 -0
  97. messagefoundry/pipeline/alerts.py +100 -0
  98. messagefoundry/pipeline/cert_expiry.py +219 -0
  99. messagefoundry/pipeline/cluster.py +955 -0
  100. messagefoundry/pipeline/cluster_sqlserver.py +444 -0
  101. messagefoundry/pipeline/config_convergence.py +137 -0
  102. messagefoundry/pipeline/dryrun.py +450 -0
  103. messagefoundry/pipeline/engine.py +756 -0
  104. messagefoundry/pipeline/leader_tasks.py +158 -0
  105. messagefoundry/pipeline/reference_sync.py +369 -0
  106. messagefoundry/pipeline/retention.py +289 -0
  107. messagefoundry/pipeline/security_notify.py +168 -0
  108. messagefoundry/pipeline/state_convergence.py +143 -0
  109. messagefoundry/pipeline/wiring_runner.py +1722 -0
  110. messagefoundry/py.typed +0 -0
  111. messagefoundry/redaction.py +71 -0
  112. messagefoundry/scaffold.py +321 -0
  113. messagefoundry/secrets_dpapi.py +129 -0
  114. messagefoundry/store/__init__.py +46 -0
  115. messagefoundry/store/audit_tee.py +67 -0
  116. messagefoundry/store/base.py +758 -0
  117. messagefoundry/store/crypto.py +166 -0
  118. messagefoundry/store/keyprovider.py +192 -0
  119. messagefoundry/store/postgres.py +3447 -0
  120. messagefoundry/store/sqlserver.py +3014 -0
  121. messagefoundry/store/store.py +3790 -0
  122. messagefoundry/timezone.py +207 -0
  123. messagefoundry/transports/__init__.py +50 -0
  124. messagefoundry/transports/base.py +269 -0
  125. messagefoundry/transports/database.py +693 -0
  126. messagefoundry/transports/file.py +551 -0
  127. messagefoundry/transports/framing.py +164 -0
  128. messagefoundry/transports/loopback.py +53 -0
  129. messagefoundry/transports/mllp.py +644 -0
  130. messagefoundry/transports/remotefile.py +664 -0
  131. messagefoundry/transports/rest.py +281 -0
  132. messagefoundry/transports/signing.py +321 -0
  133. messagefoundry/transports/soap.py +507 -0
  134. messagefoundry/transports/tcp.py +307 -0
  135. messagefoundry/transports/timer.py +146 -0
  136. messagefoundry/transports/x12.py +323 -0
  137. messagefoundry-0.1.0.dist-info/METADATA +212 -0
  138. messagefoundry-0.1.0.dist-info/RECORD +142 -0
  139. messagefoundry-0.1.0.dist-info/WHEEL +4 -0
  140. messagefoundry-0.1.0.dist-info/entry_points.txt +2 -0
  141. messagefoundry-0.1.0.dist-info/licenses/LICENSE +662 -0
  142. 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
+ )