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,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()
|