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,20 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """PySide6 admin console for MessageFoundry.
4
+
5
+ A thin desktop client over the localhost API (:mod:`messagefoundry.api`): a channel
6
+ dashboard (start/stop), a message browser with an HL7 parse-tree viewer and delivery/
7
+ audit trail, and replay. The console never touches the store or engine directly — it
8
+ speaks only HTTP, so the same UI drives in-process, local-daemon, and (later) remote
9
+ engines.
10
+
11
+ The API client (:mod:`.client`) is independent of Qt and unit-testable on its own; the
12
+ widgets (:mod:`.widgets`) are imported lazily so importing this package doesn't require
13
+ PySide6 unless you actually open the GUI.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from messagefoundry.console.client import ApiError, EngineClient
19
+
20
+ __all__ = ["EngineClient", "ApiError"]
@@ -0,0 +1,274 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Console entrypoint: python -m messagefoundry.console [--url URL]
4
+
5
+ Connects to a running engine API (default http://127.0.0.1:8765 — start one with
6
+ ``python -m messagefoundry serve``), opens the admin window, and auto-refreshes channel and
7
+ message state on a timer. The interval is user-selectable from the window (link → dialog) and
8
+ remembered across runs (QSettings); ``--poll`` is just the default for a first run.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import logging
15
+ import sys
16
+
17
+ from PySide6.QtCore import QSettings
18
+ from PySide6.QtWidgets import QApplication, QDialog
19
+
20
+ from messagefoundry.console.change_password import ChangePasswordDialog
21
+ from messagefoundry.console.client import ApiError, EngineClient
22
+ from messagefoundry.console.login import LoginDialog
23
+ from messagefoundry.console.mfa import MfaVerifyDialog, make_mfa_handler
24
+ from messagefoundry.console.reauth import make_step_up_handler
25
+ from messagefoundry.console.shell import AppWindow
26
+
27
+ log = logging.getLogger(__name__)
28
+
29
+ _SETTINGS_KEY = "autorefresh/seconds"
30
+ _DEFAULT_SIZE = (2560, 1440)
31
+ # The cached token is a PHI-scoped bearer credential (the user's full RBAC, incl. messages:view_raw)
32
+ # valid for the server-side session lifetime. It lives in the OS keyring (Windows Credential
33
+ # Manager) and is re-validated against /auth/me on startup, so a stale/revoked one is discarded.
34
+ _KEYRING_SERVICE = "MessageFoundry"
35
+
36
+
37
+ def _load_token(base_url: str) -> str | None:
38
+ try:
39
+ import keyring
40
+ from keyring.errors import KeyringError
41
+ except ImportError:
42
+ return None
43
+ try:
44
+ return keyring.get_password(_KEYRING_SERVICE, base_url)
45
+ except KeyringError as exc: # keyring present but locked/unavailable — fall back to sign-in
46
+ log.warning("could not read stored credential: %s", exc)
47
+ return None
48
+
49
+
50
+ def _save_token(base_url: str, token: str) -> None:
51
+ try:
52
+ import keyring
53
+ from keyring.errors import KeyringError
54
+ except ImportError:
55
+ return # no keyring backend — the session simply isn't remembered across launches
56
+ try:
57
+ keyring.set_password(_KEYRING_SERVICE, base_url, token)
58
+ except KeyringError as exc:
59
+ log.warning("could not store credential (will re-prompt next launch): %s", exc)
60
+
61
+
62
+ def _delete_token(base_url: str) -> bool:
63
+ """Clear the stored token. Returns False if it may still be present (CONSOLE-2)."""
64
+ try:
65
+ import keyring
66
+ from keyring.errors import KeyringError, PasswordDeleteError
67
+ except ImportError:
68
+ return True # nothing was persisted to begin with
69
+ try:
70
+ keyring.delete_password(_KEYRING_SERVICE, base_url)
71
+ return True
72
+ except PasswordDeleteError:
73
+ return True # no such entry — already absent, effectively cleared
74
+ except KeyringError as exc:
75
+ log.warning("could not clear stored credential — it may still be present: %s", exc)
76
+ return False
77
+
78
+
79
+ def _authenticate(client: EngineClient) -> bool:
80
+ """Ensure the client is authenticated when the engine requires it.
81
+
82
+ Returns True to proceed (authenticated, or auth disabled, or unreachable so the window can show
83
+ the connection error), or False if the user cancelled the sign-in dialog.
84
+ """
85
+ try:
86
+ client.providers() # 200 when auth is on; 503 when disabled
87
+ except ApiError as exc:
88
+ if exc.status == 503 or exc.status is None:
89
+ return True # auth disabled, or unreachable (the window surfaces the error)
90
+ stored = _load_token(client.base_url)
91
+ if stored:
92
+ try:
93
+ client.set_token(stored)
94
+ return True
95
+ except ApiError:
96
+ client.clear_auth()
97
+ _delete_token(client.base_url)
98
+ while True:
99
+ login = LoginDialog(client)
100
+ if login.exec() != QDialog.DialogCode.Accepted:
101
+ return False
102
+ if not login.must_change_password:
103
+ # A second factor is required (WP-14): prompt for the TOTP / recovery code now. If the
104
+ # user cancels, REVOKE the un-MFA'd session server-side (not just locally) — the login
105
+ # already minted a durable session row, so a bare clear_auth() would leave it alive until
106
+ # expiry. logout() hits /auth/logout to revoke it (mirrors _sign_out); no keyring entry was
107
+ # saved yet on this branch, so a local clear is the only fallback if the revoke call fails.
108
+ if login.mfa_required and not _verify_mfa(client):
109
+ try:
110
+ client.logout()
111
+ except ApiError as exc:
112
+ log.warning("server-side logout after MFA cancel failed: %s", exc)
113
+ client.clear_auth()
114
+ continue
115
+ break
116
+ # Forced change: the session is must-change-restricted (403 on protected routes). Let the
117
+ # user set a new password, prefilling the current one they just typed and the server
118
+ # accepted.
119
+ change = ChangePasswordDialog(client, current_password=login.entered_password)
120
+ login.entered_password = "" # nosec B105 (clears the plaintext seam, not a credential; M4)
121
+ if change.exec() != QDialog.DialogCode.Accepted:
122
+ client.clear_auth() # drop the restricted token; the user bailed out
123
+ continue # back to sign-in (still blocked until they change it)
124
+ # change_password() already revoked + cleared the session server-side, so the loop falls
125
+ # through to a fresh sign-in with the new password rather than admitting the dead token.
126
+ if client.token is not None:
127
+ _save_token(client.base_url, client.token)
128
+ return True
129
+
130
+
131
+ def _verify_mfa(client: EngineClient) -> bool:
132
+ """Prompt for a second factor after a login that reported ``mfa_required`` (WP-14). Returns True
133
+ iff the TOTP / recovery code verified."""
134
+ return MfaVerifyDialog(client).exec() == QDialog.DialogCode.Accepted
135
+
136
+
137
+ def _sign_out(client: EngineClient, app: QApplication) -> None:
138
+ base_url = client.base_url
139
+ try:
140
+ client.logout() # revoke the session server-side
141
+ except ApiError as exc:
142
+ log.warning("server-side logout failed: %s", exc)
143
+ if not _delete_token(base_url):
144
+ log.warning("local credential may not have been cleared; remove it from the OS keyring")
145
+ app.quit() # re-launch the console to sign in again
146
+
147
+
148
+ def _password_changed(client: EngineClient, app: QApplication) -> None:
149
+ """Route back to sign-in after an in-app password change.
150
+
151
+ The dialog already called ``change_password`` (server revoked the session, client cleared its
152
+ token), so this only clears the cached keyring credential and quits — relaunch lands on the
153
+ sign-in dialog where the user authenticates with the new password.
154
+ """
155
+ if not _delete_token(client.base_url):
156
+ log.warning("local credential may not have been cleared; remove it from the OS keyring")
157
+ app.quit() # re-launch the console to sign in with the new password
158
+
159
+
160
+ def _session_expired(client: EngineClient, app: QApplication) -> None:
161
+ """The session expired/was revoked mid-session (a 401 on the health poll). The token is already
162
+ dead, so just clear the cached credential and quit — re-launch lands on the sign-in dialog (M-26)."""
163
+ if not _delete_token(client.base_url):
164
+ log.warning("local credential may not have been cleared; remove it from the OS keyring")
165
+ app.quit()
166
+
167
+
168
+ def _open_window(window: AppWindow, app: QApplication) -> None:
169
+ """Open at the default size, or maximized if the screen can't fit it.
170
+
171
+ ``_DEFAULT_SIZE`` is in physical pixels (what the user sees on a monitor), but Qt's
172
+ geometry and ``resize()`` are in device-independent (logical) pixels. On a HiDPI display
173
+ (e.g. a 5K2K monitor at 200% scaling) the two differ by ``devicePixelRatio``, so we compare
174
+ against the *physical* work area and convert the target back to logical pixels for resize().
175
+ """
176
+ target_w, target_h = _DEFAULT_SIZE
177
+ screen = app.primaryScreen()
178
+ if screen is None:
179
+ window.resize(target_w, target_h)
180
+ window.show()
181
+ return
182
+ dpr = screen.devicePixelRatio() or 1.0
183
+ available = screen.availableGeometry() # logical pixels
184
+ if available.width() * dpr < target_w or available.height() * dpr < target_h:
185
+ window.showMaximized() # default size is too big for this display
186
+ else:
187
+ window.resize(round(target_w / dpr), round(target_h / dpr))
188
+ window.show()
189
+
190
+
191
+ def main(argv: list[str] | None = None) -> int:
192
+ parser = argparse.ArgumentParser(prog="messagefoundry.console")
193
+ parser.add_argument("--url", default="http://127.0.0.1:8765", help="engine API base URL")
194
+ parser.add_argument("--poll", type=float, default=2.0, help="default auto-refresh seconds")
195
+ parser.add_argument(
196
+ "--service-name", default="MessageFoundry", help="Windows service name for the Status page"
197
+ )
198
+ parser.add_argument(
199
+ "--insecure",
200
+ action="store_true",
201
+ help="allow plaintext http to a non-loopback engine (trusted-network dev only; no TLS yet)",
202
+ )
203
+ parser.add_argument(
204
+ "--client-cert",
205
+ default=None,
206
+ help="PEM client certificate to present for mutual TLS (ASVS 12.3.5; opt-in, https only)",
207
+ )
208
+ parser.add_argument(
209
+ "--client-key",
210
+ default=None,
211
+ help="private key for --client-cert, when it is not bundled in the cert PEM",
212
+ )
213
+ args = parser.parse_args(argv)
214
+
215
+ app = QApplication(sys.argv[:1])
216
+ app.setOrganizationName("MessageFoundry")
217
+ app.setApplicationName("Console")
218
+
219
+ try:
220
+ client = EngineClient(
221
+ args.url,
222
+ allow_insecure=args.insecure,
223
+ tls_client_cert=args.client_cert,
224
+ tls_client_key=args.client_key,
225
+ )
226
+ except ApiError as exc:
227
+ print(f"error: {exc}", file=sys.stderr) # e.g. refusing plaintext http to a remote host
228
+ return 2
229
+
230
+ # When the engine demands step-up re-verification on a sensitive action (ASVS 7.5.3), prompt the
231
+ # operator and retry — instead of surfacing a raw 403 (WP-L3-16, console side).
232
+ client.set_step_up_handler(make_step_up_handler(client))
233
+ # And prompt for a second factor when a sensitive op needs one (403 + X-MFA-Required, WP-14).
234
+ client.set_mfa_handler(make_mfa_handler(client))
235
+
236
+ if not _authenticate(client):
237
+ client.close()
238
+ return 0
239
+
240
+ # A second, read-only client dedicated to background (off-thread) reads — the health poll, the
241
+ # Engine Status refresh, and the per-page auto-refresh. Keeping those off the primary client
242
+ # means the handler-bearing, token-mutating primary client is only ever used on the Qt main
243
+ # thread (sign-in / step-up / MFA / user actions), so no single client is shared across threads.
244
+ poll_client = client.for_polling()
245
+
246
+ # Remembered interval wins; fall back to --poll on first run. QSettings returns the value
247
+ # as a str (registry) or float (in-memory), so normalise via str() before parsing.
248
+ settings = QSettings()
249
+ poll_seconds = float(str(settings.value(_SETTINGS_KEY, args.poll)))
250
+ window = AppWindow(
251
+ client, poll_client=poll_client, poll_seconds=poll_seconds, service_name=args.service_name
252
+ )
253
+ window.interval_changed.connect(lambda seconds: settings.setValue(_SETTINGS_KEY, seconds))
254
+ window.logout_requested.connect(lambda: _sign_out(client, app))
255
+ window.change_password_requested.connect(lambda: _password_changed(client, app))
256
+ window.session_expired.connect(lambda: _session_expired(client, app))
257
+
258
+ # Confirm the engine is reachable before showing a blank window.
259
+ try:
260
+ client.health()
261
+ except ApiError as exc:
262
+ window._show_error(f"Cannot reach engine: {exc}") # noqa: SLF001 (entrypoint glue)
263
+
264
+ window.refresh_all()
265
+ _open_window(window, app)
266
+
267
+ exit_code = app.exec()
268
+ client.close()
269
+ poll_client.close()
270
+ return int(exit_code)
271
+
272
+
273
+ if __name__ == "__main__":
274
+ raise SystemExit(main())
@@ -0,0 +1,107 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Off-thread API calls for the console.
4
+
5
+ The :class:`~messagefoundry.console.client.EngineClient` is synchronous, and calling it on the Qt main
6
+ thread freezes the GUI for the duration of the call. That's fine for sub-millisecond loopback reads, but
7
+ a **DB-backed read during a failover** (``/cluster/nodes``, ``/status``) can stall for seconds while the
8
+ new primary recovers — long enough to lock up the window. :class:`AsyncRunner` runs a blocking callable on
9
+ a worker thread and delivers its result (or exception) back to a **main-thread** slot via a queued signal,
10
+ so the handler can safely touch widgets. Background work off the main thread + ``Signal``/``Slot`` back is
11
+ the PySide6 rule (CLAUDE.md §10).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Callable
17
+ from typing import Any
18
+
19
+ from PySide6.QtCore import QObject, QRunnable, QThreadPool, Signal
20
+
21
+
22
+ class _CallSignals(QObject):
23
+ """Per-call signals — emitted from the worker thread, delivered (queued) on the main thread."""
24
+
25
+ done = Signal(object)
26
+ failed = Signal(object)
27
+
28
+
29
+ class _Call(QRunnable):
30
+ def __init__(self, fn: Callable[[], Any], signals: _CallSignals) -> None:
31
+ super().__init__()
32
+ self._fn = fn
33
+ self._signals = signals
34
+
35
+ def run(self) -> None:
36
+ try:
37
+ result = self._fn()
38
+ except Exception as exc: # noqa: BLE001 — deliver ANY failure to the main thread, never crash the pool worker
39
+ self._signals.failed.emit(exc)
40
+ else:
41
+ self._signals.done.emit(result)
42
+
43
+
44
+ class AsyncRunner(QObject):
45
+ """Run blocking callables off the Qt main thread; deliver the result/error to main-thread slots.
46
+
47
+ ``submit(fn, on_done=…, on_error=…)`` runs ``fn()`` on a :class:`QThreadPool` worker; when it finishes,
48
+ ``on_done(result)`` (or ``on_error(exc)``) fires **on the main thread** so the handler can touch widgets.
49
+ Call :meth:`stop` on window close: after it, late results are dropped (a slow in-flight call can't update
50
+ a torn-down widget) and it waits, bounded, for workers to finish.
51
+
52
+ Delivery is via the per-call signals connected to **this** runner's bound-method slots. Because the
53
+ runner is a QObject with main-thread affinity, the cross-thread emit is an AutoConnection that resolves
54
+ to a *queued* connection — so the callbacks run on the main thread. (Connecting to a bare lambda has no
55
+ receiver context and would run on the worker thread, so don't.)
56
+ """
57
+
58
+ def __init__(self, parent: QObject | None = None) -> None:
59
+ super().__init__(parent)
60
+ self._pool = QThreadPool(self)
61
+ self._stopped = False
62
+ # Hold each call's signals object (and its callbacks) alive until its slot has run: a queued signal
63
+ # needs its sender to outlive the emit→deliver gap, and the QRunnable is dropped when run() returns.
64
+ self._calls: dict[
65
+ _CallSignals, tuple[Callable[[Any], None], Callable[[BaseException], None] | None]
66
+ ] = {}
67
+
68
+ def submit(
69
+ self,
70
+ fn: Callable[[], Any],
71
+ *,
72
+ on_done: Callable[[Any], None],
73
+ on_error: Callable[[BaseException], None] | None = None,
74
+ ) -> None:
75
+ if self._stopped:
76
+ return
77
+ signals = _CallSignals()
78
+ self._calls[signals] = (on_done, on_error)
79
+ signals.done.connect(self._handle_done)
80
+ signals.failed.connect(self._handle_failed)
81
+ self._pool.start(_Call(fn, signals))
82
+
83
+ def _take(self) -> tuple[Callable[[Any], None], Callable[[BaseException], None] | None] | None:
84
+ sender = self.sender()
85
+ if not isinstance(sender, _CallSignals):
86
+ return None
87
+ return self._calls.pop(sender, None)
88
+
89
+ def _handle_done(self, result: Any) -> None:
90
+ callbacks = self._take()
91
+ if callbacks is None or self._stopped:
92
+ return # dropped after stop(), or an unknown sender — don't touch (torn-down) widgets
93
+ callbacks[0](result)
94
+
95
+ def _handle_failed(self, error: BaseException) -> None:
96
+ callbacks = self._take()
97
+ if callbacks is None or self._stopped:
98
+ return
99
+ on_error = callbacks[1]
100
+ if on_error is not None:
101
+ on_error(error)
102
+
103
+ def stop(self) -> None:
104
+ """Drop late results and wait (bounded) for in-flight workers — call on window/page close."""
105
+ self._stopped = True
106
+ self._pool.waitForDone(2000)
107
+ self._calls.clear()
@@ -0,0 +1,111 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Password-change dialog used both for the forced-change-at-login flow and the in-app
4
+ "Change password…" affordance.
5
+
6
+ The server (``POST /me/password``) revokes all of the user's sessions on a successful change, so
7
+ :meth:`EngineClient.change_password` clears the client's token afterwards. Callers must therefore
8
+ re-prompt sign-in once this dialog is accepted.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from PySide6.QtWidgets import (
14
+ QDialog,
15
+ QFormLayout,
16
+ QLabel,
17
+ QLineEdit,
18
+ QPushButton,
19
+ QVBoxLayout,
20
+ QWidget,
21
+ )
22
+
23
+ from messagefoundry.console.client import ApiError, EngineClient
24
+ from messagefoundry.console.widgets import ERROR_COLOR
25
+
26
+
27
+ class ChangePasswordDialog(QDialog):
28
+ """Collects current/new/confirm passwords and calls :meth:`EngineClient.change_password`.
29
+
30
+ ``current_password`` pre-fills (and hides) the current-password field for the forced
31
+ change-at-login case, where the just-entered plaintext is already known and the server has
32
+ accepted it. When empty (the in-app affordance), the field is shown so the user types it.
33
+ Errors are surfaced inline (matching :class:`~messagefoundry.console.login.LoginDialog`):
34
+ 400 carries the server's policy detail, 403 means the current password was wrong.
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ client: EngineClient,
40
+ *,
41
+ current_password: str = "",
42
+ parent: QWidget | None = None,
43
+ ) -> None:
44
+ super().__init__(parent)
45
+ self.setWindowTitle("Change password — MessageFoundry")
46
+ self._client = client
47
+ self._prefilled = bool(current_password)
48
+
49
+ self._current = QLineEdit(current_password)
50
+ self._current.setEchoMode(QLineEdit.EchoMode.Password)
51
+ self._new = QLineEdit()
52
+ self._new.setEchoMode(QLineEdit.EchoMode.Password)
53
+ self._confirm = QLineEdit()
54
+ self._confirm.setEchoMode(QLineEdit.EchoMode.Password)
55
+
56
+ form = QFormLayout()
57
+ # In the forced-change case the current password is already known/accepted — keep it out of
58
+ # the form so the user only chooses a new one (it is still sent on submit).
59
+ if not self._prefilled:
60
+ form.addRow("Current password", self._current)
61
+ form.addRow("New password", self._new)
62
+ form.addRow("Confirm new password", self._confirm)
63
+
64
+ self._error = QLabel("")
65
+ self._error.setStyleSheet(f"color: {ERROR_COLOR};")
66
+ self._error.setWordWrap(True)
67
+
68
+ change = QPushButton("Change password")
69
+ change.setDefault(True)
70
+ change.clicked.connect(self._attempt)
71
+
72
+ layout = QVBoxLayout(self)
73
+ layout.addLayout(form)
74
+ layout.addWidget(self._error)
75
+ layout.addWidget(change)
76
+
77
+ # Enter-key chaining mirrors LoginDialog: walk the visible fields then submit.
78
+ if not self._prefilled:
79
+ self._current.returnPressed.connect(self._new.setFocus)
80
+ self._new.returnPressed.connect(self._confirm.setFocus)
81
+ self._confirm.returnPressed.connect(self._attempt)
82
+
83
+ def _attempt(self) -> None:
84
+ current = self._current.text()
85
+ new = self._new.text()
86
+ confirm = self._confirm.text()
87
+ if not current or not new or not confirm:
88
+ self._error.setText("Enter your current password and a new password (twice).")
89
+ return
90
+ if new != confirm:
91
+ self._error.setText("The new passwords do not match.")
92
+ return
93
+ try:
94
+ self._client.change_password(current, new)
95
+ except ApiError as exc:
96
+ if exc.status == 403:
97
+ self._error.setText("Current password is incorrect.")
98
+ elif exc.status == 400:
99
+ # The server detail already reads e.g. "password must contain a symbol"; strip the
100
+ # client's "400: " status prefix so the policy text shows cleanly.
101
+ self._error.setText(str(exc).removeprefix("400: "))
102
+ else:
103
+ self._error.setText(str(exc))
104
+ return
105
+ # Clear the plaintext from the fields now the change succeeded — defensive credential
106
+ # hygiene, since Qt doesn't zero QLineEdit buffers on destruction (L1).
107
+ for field in (self._current, self._new, self._confirm):
108
+ field.clear()
109
+ # change_password() has already cleared the client's token (the server revoked the
110
+ # session); the caller must re-prompt sign-in.
111
+ self.accept()