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,377 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Engine Status page: engine-process, database, and active-passive cluster health.
4
+
5
+ All engine reads run **off the Qt main thread** (:class:`~messagefoundry.console._async.AsyncRunner`) and
6
+ apply on the main thread via the result slot, so a slow DB-backed read — e.g. ``/cluster/nodes`` while a
7
+ new primary is recovering during a failover — can't freeze the window. The database integrity check is
8
+ on-demand (``PRAGMA quick_check`` can run for many seconds on a large DB) and also off-thread.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import time
14
+ from dataclasses import dataclass
15
+
16
+ from PySide6.QtCore import Signal
17
+ from PySide6.QtWidgets import (
18
+ QFormLayout,
19
+ QGroupBox,
20
+ QHBoxLayout,
21
+ QInputDialog,
22
+ QLabel,
23
+ QMessageBox,
24
+ QPushButton,
25
+ QTreeWidget,
26
+ QTreeWidgetItem,
27
+ QVBoxLayout,
28
+ QWidget,
29
+ )
30
+
31
+ from messagefoundry.api.models import ClusterNodeList, ClusterStatus, IntegrityResult, SystemStatus
32
+ from messagefoundry.console import service_control
33
+ from messagefoundry.console._async import AsyncRunner
34
+ from messagefoundry.console.client import ApiError, EngineClient
35
+
36
+ _ENGINE_ROWS = ["Reachable", "Version", "Uptime", "PID", "Channels", "Queue"]
37
+ _DB_ROWS = ["Path", "Size", "Free disk", "Journal mode", "Messages", "Events", "Audit entries"]
38
+ _NODE_COLS = ["Node", "Host", "PID", "Status", "Last seen", "Leader"]
39
+
40
+
41
+ def _human_bytes(n: int) -> str:
42
+ size = float(n)
43
+ for unit in ("B", "KB", "MB", "GB"):
44
+ if size < 1024:
45
+ return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
46
+ size /= 1024
47
+ return f"{size:.1f} TB"
48
+
49
+
50
+ def _human_uptime(seconds: float) -> str:
51
+ s = int(seconds)
52
+ days, s = divmod(s, 86400)
53
+ hours, s = divmod(s, 3600)
54
+ minutes, _ = divmod(s, 60)
55
+ parts = []
56
+ if days:
57
+ parts.append(f"{days}d")
58
+ if hours or days:
59
+ parts.append(f"{hours}h")
60
+ parts.append(f"{minutes}m")
61
+ return " ".join(parts)
62
+
63
+
64
+ def _ago(last_seen: float | None, now: float) -> str:
65
+ if last_seen is None:
66
+ return "—"
67
+ delta = max(0, int(now - last_seen))
68
+ return "just now" if delta < 1 else f"{delta}s ago"
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class _Snapshot:
73
+ """One off-thread refresh result, applied on the main thread. ``error`` set ⇒ engine unreachable;
74
+ ``cluster`` None ⇒ the cluster endpoints aren't available (older engine / not permitted)."""
75
+
76
+ service_state: str
77
+ status: SystemStatus | None
78
+ cluster: tuple[ClusterStatus, ClusterNodeList] | None
79
+ error: str | None
80
+
81
+
82
+ class EngineStatusPage(QWidget):
83
+ """Read-only engine + database + cluster health, plus an on-demand integrity check."""
84
+
85
+ error = Signal(str)
86
+
87
+ def __init__(self, client: EngineClient, *, service_name: str = "MessageFoundry") -> None:
88
+ super().__init__()
89
+ self._client = client
90
+ self._service_name = service_name
91
+ self._runner = AsyncRunner(self)
92
+ self._loading = False # in-flight refresh guard (don't pile up during a slow call)
93
+ self._engine = {row: QLabel("—") for row in _ENGINE_ROWS}
94
+ self._db = {row: QLabel("—") for row in _DB_ROWS}
95
+
96
+ engine_box = QGroupBox("Engine")
97
+ engine_form = QFormLayout(engine_box)
98
+ for row, label in self._engine.items():
99
+ engine_form.addRow(f"{row}:", label)
100
+
101
+ db_box = QGroupBox("Database")
102
+ db_form = QFormLayout(db_box)
103
+ for row, label in self._db.items():
104
+ db_form.addRow(f"{row}:", label)
105
+
106
+ # Active-passive cluster roster (Workstream G). Hidden until a /cluster read succeeds — a
107
+ # single-node or older engine without the endpoints simply doesn't show it.
108
+ self._cluster_box = QGroupBox("Cluster")
109
+ self._cluster_box.setVisible(False)
110
+ self._cl_mode = QLabel("—")
111
+ self._cl_role = QLabel("—")
112
+ self._cl_leader = QLabel("—")
113
+ self._cl_lease = QLabel("—")
114
+ cluster_form = QFormLayout()
115
+ cluster_form.addRow("Mode:", self._cl_mode)
116
+ cluster_form.addRow("This node:", self._cl_role)
117
+ cluster_form.addRow("Leader:", self._cl_leader)
118
+ cluster_form.addRow("Lease owner:", self._cl_lease)
119
+ self._nodes = QTreeWidget()
120
+ self._nodes.setColumnCount(len(_NODE_COLS))
121
+ self._nodes.setHeaderLabels(_NODE_COLS)
122
+ self._nodes.setRootIsDecorated(False)
123
+ self._nodes.setUniformRowHeights(True)
124
+ cluster_layout = QVBoxLayout(self._cluster_box)
125
+ cluster_layout.addLayout(cluster_form)
126
+ cluster_layout.addWidget(self._nodes)
127
+
128
+ # Windows service control (sc/net via UAC) — same-machine only; disabled in dev/no-service.
129
+ service_box = QGroupBox(f"Service ({service_name})")
130
+ self._service_state = QLabel("—")
131
+ self._svc_start = QPushButton("Start")
132
+ self._svc_stop = QPushButton("Stop")
133
+ self._svc_restart = QPushButton("Restart")
134
+ self._svc_install = QPushButton("Install service…")
135
+ self._svc_install.setVisible(False) # only shown when the service is not installed
136
+ self._svc_start.clicked.connect(lambda: self._control("start"))
137
+ self._svc_stop.clicked.connect(lambda: self._control("stop"))
138
+ self._svc_restart.clicked.connect(lambda: self._control("restart"))
139
+ self._svc_install.clicked.connect(self._install_service)
140
+ self._svc_result = QLabel("")
141
+ service_controls = QHBoxLayout()
142
+ service_controls.addWidget(QLabel("State:"))
143
+ service_controls.addWidget(self._service_state)
144
+ service_controls.addStretch(1)
145
+ service_controls.addWidget(self._svc_install)
146
+ service_controls.addWidget(self._svc_start)
147
+ service_controls.addWidget(self._svc_stop)
148
+ service_controls.addWidget(self._svc_restart)
149
+ service_layout = QVBoxLayout(service_box)
150
+ service_layout.addLayout(service_controls)
151
+ service_layout.addWidget(self._svc_result)
152
+
153
+ self._integrity_btn = QPushButton("Run integrity check")
154
+ self._integrity_btn.clicked.connect(self._run_integrity)
155
+ self._integrity_result = QLabel("")
156
+ integrity = QHBoxLayout()
157
+ integrity.addWidget(self._integrity_btn)
158
+ integrity.addWidget(self._integrity_result, stretch=1)
159
+
160
+ layout = QVBoxLayout(self)
161
+ layout.addWidget(engine_box)
162
+ layout.addWidget(db_box)
163
+ layout.addWidget(self._cluster_box)
164
+ layout.addWidget(service_box)
165
+ layout.addLayout(integrity)
166
+ layout.addStretch(1)
167
+
168
+ # --- refresh (off the main thread) ---------------------------------------
169
+
170
+ def refresh(self) -> None:
171
+ if self._loading:
172
+ return # a fetch is already in flight — don't pile up (e.g. during a slow failover)
173
+ self._loading = True
174
+ self._runner.submit(self._fetch, on_done=self._apply, on_error=self._on_error)
175
+
176
+ def reload(self) -> None:
177
+ self.refresh()
178
+
179
+ def stop(self) -> None:
180
+ """Stop the background runner (call on window close) so a late result can't touch dead widgets."""
181
+ self._runner.stop()
182
+
183
+ def _on_error(self, exc: BaseException) -> None:
184
+ # Belt-and-suspenders: the reads in _fetch raise only ApiError (handled via the snapshot), but
185
+ # an unexpected error must still clear the in-flight guard or the page wedges forever.
186
+ self._loading = False
187
+ self.error.emit(str(exc))
188
+
189
+ def _fetch(self) -> _Snapshot:
190
+ """Runs on a worker thread — only blocking I/O, no widget access."""
191
+ svc = service_control.service_state(self._service_name)
192
+ try:
193
+ status = self._client.status()
194
+ except ApiError as exc:
195
+ return _Snapshot(svc, None, None, str(exc))
196
+ cluster: tuple[ClusterStatus, ClusterNodeList] | None
197
+ try:
198
+ cluster = (self._client.cluster_status(), self._client.cluster_nodes())
199
+ except ApiError:
200
+ cluster = None # endpoints not available / not permitted — keep the cluster box hidden
201
+ return _Snapshot(svc, status, cluster, None)
202
+
203
+ def _apply(self, snap: _Snapshot) -> None:
204
+ """Runs on the main thread (result slot) — safe to touch widgets."""
205
+ self._loading = False
206
+ self._apply_service(snap.service_state)
207
+ if snap.error is not None:
208
+ self._engine["Reachable"].setText("no")
209
+ self._cluster_box.setVisible(False)
210
+ self.error.emit(snap.error)
211
+ return
212
+ assert snap.status is not None
213
+ e = snap.status.engine
214
+ self._engine["Reachable"].setText("yes")
215
+ self._engine["Version"].setText(e.version)
216
+ self._engine["Uptime"].setText(_human_uptime(e.uptime_seconds))
217
+ self._engine["PID"].setText(str(e.pid))
218
+ self._engine["Channels"].setText(f"{e.channels_running} running / {e.channels_total} total")
219
+ queue = ", ".join(f"{k}={v}" for k, v in sorted(e.outbox_by_status.items()))
220
+ self._engine["Queue"].setText(queue or "empty")
221
+
222
+ d = snap.status.db
223
+ self._db["Path"].setText(d.path)
224
+ self._db["Size"].setText(_human_bytes(d.size_bytes))
225
+ self._db["Free disk"].setText(_human_bytes(d.disk_free_bytes))
226
+ self._db["Journal mode"].setText(d.journal_mode)
227
+ self._db["Messages"].setText(str(d.messages))
228
+ self._db["Events"].setText(str(d.events))
229
+ self._db["Audit entries"].setText(str(d.audit))
230
+
231
+ self._apply_cluster(snap.cluster)
232
+
233
+ def _apply_cluster(self, cluster: tuple[ClusterStatus, ClusterNodeList] | None) -> None:
234
+ if cluster is None:
235
+ self._cluster_box.setVisible(False)
236
+ return
237
+ status, nodes = cluster
238
+ self._cluster_box.setVisible(True)
239
+ self._cl_mode.setText("clustered" if status.clustered else "single-node")
240
+ self._cl_role.setText(f"{status.role} ({status.node_id})")
241
+ self._cl_leader.setText(nodes.leader_node_id or "— (no live leader)")
242
+ lease = nodes.lease_owner or "—"
243
+ if nodes.lease_owner and nodes.lease_expires_at is not None:
244
+ remaining = int(nodes.lease_expires_at - time.time())
245
+ lease += f" (lease {'expired' if remaining < 0 else f'~{remaining}s left'})"
246
+ self._cl_lease.setText(lease)
247
+
248
+ now = time.time()
249
+ self._nodes.clear()
250
+ for n in nodes.nodes:
251
+ item = QTreeWidgetItem(
252
+ [
253
+ n.node_id,
254
+ n.host or "—",
255
+ str(n.pid) if n.pid is not None else "—",
256
+ n.status,
257
+ _ago(n.last_seen, now),
258
+ "✓ leader" if n.is_leader else "",
259
+ ]
260
+ )
261
+ self._nodes.addTopLevelItem(item)
262
+ for col in range(self._nodes.columnCount()):
263
+ self._nodes.resizeColumnToContents(col)
264
+
265
+ def _apply_service(self, state: str) -> None:
266
+ self._service_state.setText(state)
267
+ # Only enable actions that make sense for the current state; all off in dev/no-service.
268
+ self._svc_start.setEnabled(state == "stopped")
269
+ self._svc_stop.setEnabled(state == "running")
270
+ self._svc_restart.setEnabled(state == "running")
271
+ self._svc_install.setVisible(state == "not installed") # offer install only then
272
+
273
+ # --- service control (unchanged; same-machine sc/net via UAC) ------------
274
+
275
+ def _install_service(self) -> None:
276
+ script = service_control.install_script_path()
277
+ if script is None:
278
+ self._svc_result.setText("Could not find install-service.ps1 — run it manually.")
279
+ return
280
+ env = self._prompt_environment()
281
+ if env is None:
282
+ return # cancelled or invalid — leave the page untouched
283
+ if not self._confirm_install(env):
284
+ return
285
+ if service_control.install_service(str(script), env):
286
+ self._svc_result.setText("Launching installer — approve the UAC prompt.")
287
+ else:
288
+ self._svc_result.setText("Service install is only available on Windows.")
289
+
290
+ def _prompt_environment(self) -> str | None:
291
+ """Ask which active environment the service should run as (ADR 0017: the operator chooses it
292
+ explicitly — `serve` has no silent default). Returns the validated name, or None if the
293
+ operator cancelled or entered an invalid name."""
294
+ env, ok = QInputDialog.getText(
295
+ self,
296
+ "Active environment",
297
+ "Which environment should the service run as?\n"
298
+ "Selects environments/<name>.toml — e.g. dev, staging, prod, or a custom name.",
299
+ text="prod",
300
+ )
301
+ if not ok:
302
+ return None
303
+ env = env.strip()
304
+ if not service_control.is_safe_environment(env):
305
+ QMessageBox.warning(
306
+ self,
307
+ "Invalid environment",
308
+ "Use a simple name of letters, digits, '.', '_' or '-' "
309
+ "(it selects environments/<name>.toml).",
310
+ )
311
+ return None
312
+ return env
313
+
314
+ def _confirm_install(self, env: str) -> bool:
315
+ reply = QMessageBox.question(
316
+ self,
317
+ "Install MessageFoundry service",
318
+ f"This will install MessageFoundry as a Windows service (environment: {env}):\n\n"
319
+ "• Downloads NSSM (the service wrapper) if it isn't already present\n"
320
+ "• Registers the service to start automatically at boot\n"
321
+ "• Requires administrator rights — Windows will show a UAC prompt\n"
322
+ "• Runs in a PowerShell window so you can read the result\n\n"
323
+ "Stop any console-mode engine first so they don't share ports.\n\nProceed?",
324
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
325
+ QMessageBox.StandardButton.No,
326
+ )
327
+ return reply == QMessageBox.StandardButton.Yes
328
+
329
+ def _control(self, action: str) -> None:
330
+ if not self._confirm_admin(action):
331
+ return
332
+ # The confirm dialog already told the user what's about to happen, and the State readout
333
+ # reflects the result — so don't leave a lingering "requested..." message here.
334
+ if service_control.control_service(action, self._service_name):
335
+ self._svc_result.setText("")
336
+ else:
337
+ self._svc_result.setText("Service control is only available on Windows.")
338
+
339
+ def _confirm_admin(self, action: str) -> bool:
340
+ text = (
341
+ f"This will {action} the '{self._service_name}' service, which requires administrator "
342
+ "rights — Windows will show a UAC prompt."
343
+ )
344
+ if action in ("stop", "restart"):
345
+ text += (
346
+ "\n\nThe engine API drops briefly, so the Engine/Database panels will read "
347
+ "'unreachable' until it's back."
348
+ )
349
+ text += "\n\nProceed?"
350
+ reply = QMessageBox.question(
351
+ self,
352
+ f"{action.capitalize()} service",
353
+ text,
354
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
355
+ QMessageBox.StandardButton.No,
356
+ )
357
+ return reply == QMessageBox.StandardButton.Yes
358
+
359
+ # --- integrity check (off the main thread — quick_check can run for minutes) ---
360
+
361
+ def _run_integrity(self) -> None:
362
+ self._integrity_btn.setEnabled(False)
363
+ self._integrity_result.setText("running…")
364
+ self._runner.submit(
365
+ self._client.integrity_check,
366
+ on_done=self._apply_integrity,
367
+ on_error=self._integrity_error,
368
+ )
369
+
370
+ def _apply_integrity(self, result: IntegrityResult) -> None:
371
+ self._integrity_btn.setEnabled(True)
372
+ self._integrity_result.setText("✓ ok" if result.ok else f"✗ {result.detail}")
373
+
374
+ def _integrity_error(self, exc: BaseException) -> None:
375
+ self._integrity_btn.setEnabled(True)
376
+ self._integrity_result.setText("")
377
+ self.error.emit(str(exc))
@@ -0,0 +1,282 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Users administration page — visible only to users holding ``users:manage``.
4
+
5
+ Lists users with their roles and supports create / set-roles / delete. All operations go through the
6
+ HTTP API (which enforces permissions), so the page is a thin view; the engine is the source of truth.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from PySide6.QtCore import Signal
12
+ from PySide6.QtWidgets import (
13
+ QHBoxLayout,
14
+ QHeaderView,
15
+ QInputDialog,
16
+ QLineEdit,
17
+ QMessageBox,
18
+ QPushButton,
19
+ QTableWidget,
20
+ QTableWidgetItem,
21
+ QVBoxLayout,
22
+ QWidget,
23
+ )
24
+
25
+ from messagefoundry.api.auth_models import UserSummary
26
+ from messagefoundry.console._async import AsyncRunner
27
+ from messagefoundry.console.client import ApiError, EngineClient
28
+
29
+
30
+ def _scope_label(channels: list[str] | None) -> str:
31
+ """Human-readable channel scope: all / none / the listed connections."""
32
+ if channels is None:
33
+ return "all"
34
+ return ", ".join(channels) if channels else "(none)"
35
+
36
+
37
+ class UsersPage(QWidget):
38
+ """Table of users + create/set-roles/delete actions (everything audited server-side)."""
39
+
40
+ error = Signal(str)
41
+
42
+ def __init__(self, client: EngineClient, *, poll_client: EngineClient | None = None) -> None:
43
+ super().__init__()
44
+ self._client = client # admin actions — main thread, may step-up/MFA
45
+ self._poll = poll_client or client # user-list read — runs off the main thread
46
+ self._runner = AsyncRunner(self)
47
+ self._loading = False # in-flight reload guard (don't pile up during a slow call)
48
+ # A reload requested while one is in flight is latched (not dropped) and re-fired on
49
+ # completion, so a post-action reload (create/delete/set-roles) isn't lost during a tick.
50
+ self._pending = False
51
+ self._row_ids: list[str] = [] # user_id per table row
52
+ self._row_scopes: list[list[str] | None] = [] # channel scope per row (None = all)
53
+ # current roles per row, to prefill the Set-roles dialog (H3)
54
+ self._row_roles: list[list[str]] = []
55
+
56
+ self._table = QTableWidget(0, 5)
57
+ self._table.setHorizontalHeaderLabels(
58
+ ["Username", "Provider", "Roles", "Channel scope", "Status"]
59
+ )
60
+ header = self._table.horizontalHeader()
61
+ if header is not None:
62
+ header.setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
63
+ self._table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
64
+ self._table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
65
+
66
+ add_btn = QPushButton("Add user…")
67
+ add_btn.clicked.connect(self._add_user)
68
+ roles_btn = QPushButton("Set roles…")
69
+ roles_btn.clicked.connect(self._set_roles)
70
+ scope_btn = QPushButton("Set scope…")
71
+ scope_btn.clicked.connect(self._set_scope)
72
+ delete_btn = QPushButton("Delete")
73
+ delete_btn.clicked.connect(self._delete)
74
+ sessions_btn = QPushButton("Revoke sessions")
75
+ sessions_btn.clicked.connect(self._revoke_sessions)
76
+ refresh_btn = QPushButton("Refresh")
77
+ refresh_btn.clicked.connect(self.reload)
78
+
79
+ buttons = QHBoxLayout()
80
+ for button in (add_btn, roles_btn, scope_btn, delete_btn, sessions_btn, refresh_btn):
81
+ buttons.addWidget(button)
82
+ buttons.addStretch(1)
83
+
84
+ layout = QVBoxLayout(self)
85
+ layout.addLayout(buttons)
86
+ layout.addWidget(self._table)
87
+
88
+ # --- page interface (auto-refresh timer + nav) ---------------------------
89
+
90
+ def refresh(self) -> None:
91
+ # This page has no separate silent path (no PHI-summary audit or column autosize to skip),
92
+ # so the auto-refresh tick and a user-initiated reload are the same full repopulate.
93
+ self.reload()
94
+
95
+ def reload(self) -> None:
96
+ # Read the user list OFF the main thread; apply on the main thread (a slow/wedged engine
97
+ # would otherwise freeze the GUI for the whole /users call).
98
+ if self._loading:
99
+ self._pending = True # latch — re-fire when the in-flight read completes (don't drop)
100
+ return
101
+ self._pending = False
102
+ self._loading = True
103
+ self._runner.submit(self._fetch, on_done=self._apply, on_error=self._on_error)
104
+
105
+ def stop(self) -> None:
106
+ """Stop the background runner (call on window close) so a late result can't touch dead widgets."""
107
+ self._runner.stop()
108
+
109
+ def _fetch(self) -> list[UserSummary]:
110
+ """Runs on a worker thread — only blocking I/O, no widget access."""
111
+ return self._poll.list_users()
112
+
113
+ def _on_error(self, exc: BaseException) -> None:
114
+ self._loading = False
115
+ self.error.emit(str(exc))
116
+ self._drain_pending()
117
+
118
+ def _drain_pending(self) -> bool:
119
+ """Re-fire a reload that was latched while one was in flight. Returns True if it did."""
120
+ if not self._pending:
121
+ return False
122
+ self._pending = False
123
+ self.reload()
124
+ return True
125
+
126
+ def _apply(self, users: list[UserSummary]) -> None:
127
+ """Runs on the main thread (result slot) — safe to touch widgets."""
128
+ self._loading = False
129
+ if self._drain_pending():
130
+ return # a reload was requested mid-flight — re-fire it, skip this superseded result
131
+ self._row_ids = [u.id for u in users]
132
+ self._row_scopes = [u.channel_scope for u in users]
133
+ self._row_roles = [list(u.roles) for u in users]
134
+ self._table.setRowCount(len(users))
135
+ for row, user in enumerate(users):
136
+ cells = [
137
+ user.username,
138
+ user.auth_provider,
139
+ ", ".join(user.roles),
140
+ _scope_label(user.channel_scope),
141
+ "disabled" if user.disabled else "active",
142
+ ]
143
+ for col, text in enumerate(cells):
144
+ self._table.setItem(row, col, QTableWidgetItem(text))
145
+
146
+ # --- actions -------------------------------------------------------------
147
+
148
+ def _selected_user_id(self) -> str | None:
149
+ row = self._table.currentRow()
150
+ return self._row_ids[row] if 0 <= row < len(self._row_ids) else None
151
+
152
+ def _available_roles(self) -> list[str]:
153
+ try:
154
+ return [r.id for r in self._client.list_roles()]
155
+ except ApiError:
156
+ return []
157
+
158
+ def _pick_roles(self, current: list[str]) -> list[str] | None:
159
+ available = ", ".join(self._available_roles())
160
+ text, ok = QInputDialog.getText(
161
+ self,
162
+ "Roles",
163
+ f"Comma-separated roles (available: {available}):",
164
+ QLineEdit.EchoMode.Normal,
165
+ ", ".join(current),
166
+ )
167
+ if not ok:
168
+ return None
169
+ return [r.strip() for r in text.split(",") if r.strip()]
170
+
171
+ def _add_user(self) -> None:
172
+ username, ok = QInputDialog.getText(self, "Add user", "Username:")
173
+ if not ok or not username.strip():
174
+ return
175
+ password, ok = QInputDialog.getText(
176
+ self, "Add user", "Password:", QLineEdit.EchoMode.Password
177
+ )
178
+ if not ok or not password:
179
+ return
180
+ roles = self._pick_roles([])
181
+ if roles is None:
182
+ return
183
+ try:
184
+ self._client.create_user(username.strip(), password, roles=roles)
185
+ except ApiError as exc:
186
+ self.error.emit(str(exc))
187
+ self.reload()
188
+
189
+ def _set_roles(self) -> None:
190
+ # Resolve the row (not just the user_id) so we can prefill the dialog with the user's
191
+ # current roles — otherwise it opens blank and submitting would clear/truncate them (H3).
192
+ row = self._table.currentRow()
193
+ if not (0 <= row < len(self._row_ids)):
194
+ return
195
+ user_id = self._row_ids[row]
196
+ # Prefill the user's current roles so the PUT (which replaces the whole set) doesn't strip
197
+ # them when the operator just wanted to tweak one — and confirm an empty submission (low-15).
198
+ roles = self._pick_roles(self._row_roles[row])
199
+ if roles is None:
200
+ return
201
+ if not roles and not self._confirm_strip_roles():
202
+ return
203
+ try:
204
+ self._client.set_user_roles(user_id, roles)
205
+ except ApiError as exc:
206
+ self.error.emit(str(exc))
207
+ self.reload()
208
+
209
+ def _confirm_strip_roles(self) -> bool:
210
+ reply = QMessageBox.question(
211
+ self,
212
+ "Remove all roles",
213
+ "Submitting no roles removes every role from this user, leaving them with no access. "
214
+ "Continue?",
215
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
216
+ QMessageBox.StandardButton.No,
217
+ )
218
+ return reply == QMessageBox.StandardButton.Yes
219
+
220
+ def _inbound_connections(self) -> list[str]:
221
+ try:
222
+ return sorted({c.channel_id for c in self._client.connections() if c.role == "source"})
223
+ except ApiError:
224
+ return []
225
+
226
+ def _set_scope(self) -> None:
227
+ row = self._table.currentRow()
228
+ if not (0 <= row < len(self._row_ids)):
229
+ return
230
+ user_id = self._row_ids[row]
231
+ current = self._row_scopes[row]
232
+ available = ", ".join(self._inbound_connections())
233
+ prefill = "*" if current is None else ", ".join(current)
234
+ text, ok = QInputDialog.getText(
235
+ self,
236
+ "Channel scope",
237
+ # '*' (or blank) = all channels; a comma-separated list restricts to those connections.
238
+ f"Connections ('*' = all; available: {available}):",
239
+ QLineEdit.EchoMode.Normal,
240
+ prefill,
241
+ )
242
+ if not ok:
243
+ return
244
+ entries = [c.strip() for c in text.split(",") if c.strip()]
245
+ channels = None if (not entries or entries == ["*"]) else entries
246
+ try:
247
+ self._client.set_channel_scope(user_id, channels)
248
+ except ApiError as exc:
249
+ self.error.emit(str(exc))
250
+ self.reload()
251
+
252
+ def _delete(self) -> None:
253
+ user_id = self._selected_user_id()
254
+ if user_id is None:
255
+ return
256
+ confirm = QMessageBox.question(self, "Delete user", "Delete the selected user?")
257
+ if confirm != QMessageBox.StandardButton.Yes:
258
+ return
259
+ try:
260
+ self._client.delete_user(user_id)
261
+ except ApiError as exc:
262
+ self.error.emit(str(exc))
263
+ self.reload()
264
+
265
+ def _revoke_sessions(self) -> None:
266
+ """Admin force-sign-out: revoke all of the selected user's sessions (offboarding/compromise)."""
267
+ user_id = self._selected_user_id()
268
+ if user_id is None:
269
+ return
270
+ confirm = QMessageBox.question(
271
+ self,
272
+ "Revoke sessions",
273
+ "Force-sign-out the selected user by revoking all of their active sessions?",
274
+ )
275
+ if confirm != QMessageBox.StandardButton.Yes:
276
+ return
277
+ try:
278
+ detail = self._client.revoke_user_sessions(user_id)
279
+ except ApiError as exc:
280
+ self.error.emit(str(exc))
281
+ return
282
+ QMessageBox.information(self, "Sessions revoked", detail)