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,94 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Step-up re-verification dialog (ASVS 7.5.3, WP-L3-16 — console side).
4
+
5
+ The engine refuses a highly sensitive operation (user admin, replay, purge, config reload) with
6
+ **403 + ``X-Step-Up-Required``** when the session hasn't re-proved its credential within
7
+ ``[auth].step_up_max_age_seconds``. :class:`ReauthDialog` collects the password and calls
8
+ ``POST /me/reauth`` (local password re-verify, or an AD re-bind); :func:`make_step_up_handler` wires it
9
+ into :class:`~messagefoundry.console.client.EngineClient` so any sensitive action transparently
10
+ prompts-and-retries instead of surfacing a raw 403.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Callable
16
+
17
+ from PySide6.QtWidgets import (
18
+ QApplication,
19
+ QDialog,
20
+ QFormLayout,
21
+ QLabel,
22
+ QLineEdit,
23
+ QPushButton,
24
+ QVBoxLayout,
25
+ QWidget,
26
+ )
27
+
28
+ from messagefoundry.console.client import ApiError, EngineClient
29
+ from messagefoundry.console.widgets import ERROR_COLOR
30
+
31
+
32
+ class ReauthDialog(QDialog):
33
+ """Collect the caller's current password and call :meth:`EngineClient.reauth` to refresh the
34
+ step-up window. Accepts on success; surfaces a wrong password (403) inline."""
35
+
36
+ def __init__(self, client: EngineClient, *, parent: QWidget | None = None) -> None:
37
+ super().__init__(parent)
38
+ self.setWindowTitle("Re-verify — MessageFoundry")
39
+ self._client = client
40
+
41
+ user = client.current_user
42
+ is_ad = user is not None and user.auth_provider == "ad"
43
+ prompt = QLabel(
44
+ "This action needs you to re-verify. Re-enter your "
45
+ + ("Active Directory password" if is_ad else "password")
46
+ + " to continue."
47
+ )
48
+ prompt.setWordWrap(True)
49
+
50
+ self._password = QLineEdit()
51
+ self._password.setEchoMode(QLineEdit.EchoMode.Password)
52
+ form = QFormLayout()
53
+ form.addRow("Password", self._password)
54
+
55
+ self._error = QLabel("")
56
+ self._error.setStyleSheet(f"color: {ERROR_COLOR};")
57
+ self._error.setWordWrap(True)
58
+
59
+ confirm = QPushButton("Re-verify")
60
+ confirm.setDefault(True)
61
+ confirm.clicked.connect(self._attempt)
62
+
63
+ layout = QVBoxLayout(self)
64
+ layout.addWidget(prompt)
65
+ layout.addLayout(form)
66
+ layout.addWidget(self._error)
67
+ layout.addWidget(confirm)
68
+
69
+ self._password.returnPressed.connect(self._attempt)
70
+
71
+ def _attempt(self) -> None:
72
+ password = self._password.text()
73
+ if not password:
74
+ self._error.setText("Enter your password.")
75
+ return
76
+ try:
77
+ self._client.reauth(password)
78
+ except ApiError as exc:
79
+ self._error.setText("Password is incorrect." if exc.status == 403 else str(exc))
80
+ return
81
+ self._password.clear() # don't leave plaintext in the field (Qt doesn't zero buffers)
82
+ self.accept()
83
+
84
+
85
+ def make_step_up_handler(client: EngineClient) -> Callable[[], bool]:
86
+ """A handler for :meth:`EngineClient.set_step_up_handler`: prompt for re-verification on the active
87
+ window and return ``True`` iff the user re-verified. Runs on the calling thread — the console's
88
+ sensitive actions run on the Qt main thread, so the modal dialog shows directly."""
89
+
90
+ def handler() -> bool:
91
+ dialog = ReauthDialog(client, parent=QApplication.activeWindow())
92
+ return dialog.exec() == QDialog.DialogCode.Accepted
93
+
94
+ return handler
@@ -0,0 +1,57 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Log Search page: search options on top, the message list below, and the single-message
4
+ detail (parse tree / raw / deliveries / audit) at the bottom. Composes the existing
5
+ ``MessagesPanel`` and ``MessageDetailPanel``, stacked vertically.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from PySide6.QtCore import Qt, Signal
11
+ from PySide6.QtWidgets import QSplitter, QVBoxLayout, QWidget
12
+
13
+ from messagefoundry.console.client import EngineClient
14
+ from messagefoundry.console.widgets import MessageDetailPanel, MessagesPanel
15
+
16
+
17
+ class LogSearchPage(QWidget):
18
+ """Message browser + detail, the relocated home of the old console's lower half."""
19
+
20
+ error = Signal(str)
21
+
22
+ def __init__(self, client: EngineClient, *, poll_client: EngineClient | None = None) -> None:
23
+ super().__init__()
24
+ self.messages = MessagesPanel(client, poll_client=poll_client)
25
+ self.detail = MessageDetailPanel(client, poll_client=poll_client)
26
+
27
+ self.messages.message_selected.connect(self.detail.load)
28
+ self.messages.selection_cleared.connect(self.detail.clear)
29
+ self.detail.changed.connect(self.messages.refresh)
30
+ self.messages.error.connect(self.error.emit)
31
+ self.detail.error.connect(self.error.emit)
32
+
33
+ split = QSplitter(Qt.Orientation.Vertical)
34
+ split.addWidget(self.messages)
35
+ split.addWidget(self.detail)
36
+ split.setStretchFactor(0, 1)
37
+ split.setStretchFactor(1, 2)
38
+
39
+ layout = QVBoxLayout(self)
40
+ layout.addWidget(split)
41
+
42
+ def refresh(self) -> None:
43
+ """Silent refresh — used by the auto-refresh timer (no PHI audit)."""
44
+ self.messages.refresh()
45
+
46
+ def reload(self) -> None:
47
+ """User-initiated load (opening the page) — audits summary display."""
48
+ self.messages.refresh(audit=True)
49
+
50
+ def set_channel(self, channel_id: str) -> None:
51
+ """Filter the list to one channel (used by the Connections 'Logs' link)."""
52
+ self.messages.set_channel_filter(channel_id)
53
+
54
+ def stop(self) -> None:
55
+ """Stop the message-list + detail background runners (call on window close)."""
56
+ self.messages.stop()
57
+ self.detail.stop()
@@ -0,0 +1,137 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Windows service control for the Engine Status page.
4
+
5
+ The engine can't control its *own* hosting service through the API (stopping it kills the API;
6
+ once stopped there's no API to start it), and service control needs admin rights — so this is
7
+ done locally via the Windows SCM: ``sc query`` for state (no elevation) and an elevated
8
+ ``net start/stop`` for actions (one UAC prompt each). Same-machine, Windows-only; on other
9
+ platforms / when ``sc`` is absent, state is ``"unavailable"`` and actions are no-ops.
10
+
11
+ Elevated actions are fire-and-forget (the elevated process is detached, so output isn't
12
+ captured) — poll :func:`service_state` to observe the result.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import ctypes
18
+ import re
19
+ import subprocess
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ import messagefoundry
24
+
25
+ __all__ = [
26
+ "service_state",
27
+ "control_service",
28
+ "parse_service_state",
29
+ "install_script_path",
30
+ "install_service",
31
+ "is_safe_environment",
32
+ ]
33
+
34
+ _ACTIONS = {
35
+ "start": '{0} start "{1}"',
36
+ "stop": '{0} stop "{1}"',
37
+ "restart": '{0} stop "{1}" & {0} start "{1}"',
38
+ }
39
+
40
+ # The restart action chains two `net` calls with `&`, so control_service can't avoid cmd.exe — and
41
+ # that line runs ELEVATED. A service name with a quote/`&`/`|` could break out of its quoted argument
42
+ # and run arbitrary commands as admin. Windows service names need none of those, so allow only a
43
+ # conservative set and reject the rest (review low-16).
44
+ _SAFE_SERVICE_NAME = re.compile(r"^[A-Za-z0-9 ._-]+$")
45
+
46
+
47
+ def _is_safe_service_name(name: str) -> bool:
48
+ return bool(_SAFE_SERVICE_NAME.match(name))
49
+
50
+
51
+ # The active-environment name is passed to install-service.ps1 as `-Environment <name>`, interpolated
52
+ # into an ELEVATED PowerShell command line (ShellExecuteW "runas"). It also becomes a filename segment
53
+ # (environments/<name>.toml), so constrain it to the same token charset the engine validates
54
+ # ([ai].environment) and reject anything else — an unsafe value could break out of the quoted arg and
55
+ # run as admin (cf. the service-name guard above).
56
+ _SAFE_ENV_NAME = re.compile(r"^[A-Za-z0-9._-]+$")
57
+
58
+
59
+ def is_safe_environment(name: str) -> bool:
60
+ """True if ``name`` is a valid active-environment name (letters, digits, ``.``, ``_``, ``-``)."""
61
+ return bool(_SAFE_ENV_NAME.match(name))
62
+
63
+
64
+ def parse_service_state(sc_output: str) -> str:
65
+ """Map ``sc query`` output to ``running`` / ``stopped`` / ``unknown``."""
66
+ text = sc_output.upper()
67
+ if "RUNNING" in text:
68
+ return "running"
69
+ if "STOP" in text: # STOPPED or STOP_PENDING
70
+ return "stopped"
71
+ return "unknown"
72
+
73
+
74
+ def service_state(name: str) -> str:
75
+ """``running`` | ``stopped`` | ``not installed`` | ``unavailable`` (non-Windows / no ``sc``)."""
76
+ if sys.platform != "win32":
77
+ return "unavailable"
78
+ if not _is_safe_service_name(name):
79
+ return "unavailable" # never let an unsafe name enable the (elevated) control buttons
80
+ try:
81
+ # nosec: fixed system tool (sc), no shell; `name` is validated above (low-16).
82
+ proc = subprocess.run(["sc", "query", name], capture_output=True, text=True, timeout=5) # nosec B603 B607
83
+ except (OSError, subprocess.SubprocessError):
84
+ return "unavailable"
85
+ if proc.returncode != 0:
86
+ return "not installed" # e.g. error 1060: service does not exist
87
+ return parse_service_state(proc.stdout)
88
+
89
+
90
+ def control_service(action: str, name: str) -> bool:
91
+ """Start/stop/restart ``name`` with a one-time UAC elevation. Returns False off Windows.
92
+
93
+ Uses ``net`` (synchronous) under an elevated, hidden ``cmd``. Output isn't captured;
94
+ call :func:`service_state` afterwards to see the new state.
95
+
96
+ Raises :class:`ValueError` for a service name with shell metacharacters — it would be
97
+ interpolated into an elevated cmd.exe line (review low-16)."""
98
+ if not _is_safe_service_name(name):
99
+ raise ValueError(f"unsafe service name {name!r}")
100
+ if sys.platform != "win32":
101
+ return False
102
+ command = _ACTIONS[action].format("net", name)
103
+ # ShellExecuteW with the "runas" verb raises the UAC prompt; SW_HIDE (0) hides the console.
104
+ ctypes.windll.shell32.ShellExecuteW(None, "runas", "cmd.exe", f"/c {command}", None, 0)
105
+ return True
106
+
107
+
108
+ def install_script_path() -> Path | None:
109
+ """Locate ``scripts/service/install-service.ps1`` in the (editable-installed) repo."""
110
+ pkg = messagefoundry.__file__
111
+ if pkg is None:
112
+ return None
113
+ script = Path(pkg).resolve().parents[1] / "scripts" / "service" / "install-service.ps1"
114
+ return script if script.exists() else None
115
+
116
+
117
+ def _install_params(script_path: str, environment: str) -> str:
118
+ """Build the argument string for the elevated installer launch.
119
+
120
+ ``environment`` is the active environment the service will run as (``serve --env``); it is
121
+ interpolated into an elevated command line, so reject anything that isn't a plain environment
122
+ name (raises :class:`ValueError`). Validation runs on every platform so it is unit-testable."""
123
+ if not is_safe_environment(environment):
124
+ raise ValueError(f"unsafe environment name {environment!r}")
125
+ return f'-NoExit -ExecutionPolicy Bypass -File "{script_path}" -Environment "{environment}"'
126
+
127
+
128
+ def install_service(script_path: str, environment: str) -> bool:
129
+ """Run the install script elevated in a *visible* PowerShell window (one-time setup, so the
130
+ operator can read the output / 'next steps' and any errors). ``environment`` is the active
131
+ environment the service runs as (ADR 0017 — install-service.ps1 requires it). Returns False off
132
+ Windows. Raises :class:`ValueError` for an unsafe environment name (it runs elevated)."""
133
+ params = _install_params(script_path, environment) # validates env on every platform
134
+ if sys.platform != "win32":
135
+ return False
136
+ ctypes.windll.shell32.ShellExecuteW(None, "runas", "powershell.exe", params, None, 1)
137
+ return True
@@ -0,0 +1,122 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Self-service active-session inventory dialog (the WP-10 console view).
4
+
5
+ Lists the signed-in user's active sessions (``GET /me/sessions``) and lets them revoke an individual
6
+ session or sign out everywhere else. The **current** session is flagged and cannot be revoked here —
7
+ that is what "Sign out" does — so this dialog never logs the user out. Calls are synchronous like the
8
+ other console dialogs (localhost, modal; see :mod:`messagefoundry.console.client`).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from PySide6.QtWidgets import (
14
+ QDialog,
15
+ QHBoxLayout,
16
+ QHeaderView,
17
+ QLabel,
18
+ QMessageBox,
19
+ QPushButton,
20
+ QTableWidget,
21
+ QTableWidgetItem,
22
+ QVBoxLayout,
23
+ QWidget,
24
+ )
25
+
26
+ from messagefoundry.api.auth_models import SessionInfo
27
+ from messagefoundry.console.client import ApiError, EngineClient
28
+ from messagefoundry.console.widgets import ERROR_COLOR, fmt_ts
29
+
30
+
31
+ class SessionsDialog(QDialog):
32
+ """The user's active sessions with per-row revoke + "sign out everywhere else"."""
33
+
34
+ def __init__(self, client: EngineClient, *, parent: QWidget | None = None) -> None:
35
+ super().__init__(parent)
36
+ self.setWindowTitle("Active sessions — MessageFoundry")
37
+ self._client = client
38
+ self._sessions: list[SessionInfo] = []
39
+
40
+ self._table = QTableWidget(0, 5)
41
+ self._table.setHorizontalHeaderLabels(["Client", "Created", "Last used", "Expires", ""])
42
+ header = self._table.horizontalHeader()
43
+ if header is not None:
44
+ header.setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
45
+ self._table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
46
+ self._table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
47
+
48
+ self._error = QLabel("")
49
+ self._error.setStyleSheet(f"color: {ERROR_COLOR};")
50
+ self._error.setWordWrap(True)
51
+
52
+ self._others_btn = QPushButton("Sign out everywhere else")
53
+ self._others_btn.clicked.connect(self._sign_out_others)
54
+ refresh_btn = QPushButton("Refresh")
55
+ refresh_btn.clicked.connect(self._reload)
56
+ close_btn = QPushButton("Close")
57
+ close_btn.clicked.connect(self.accept)
58
+
59
+ buttons = QHBoxLayout()
60
+ buttons.addWidget(self._others_btn)
61
+ buttons.addStretch(1)
62
+ buttons.addWidget(refresh_btn)
63
+ buttons.addWidget(close_btn)
64
+
65
+ layout = QVBoxLayout(self)
66
+ layout.addWidget(self._table)
67
+ layout.addWidget(self._error)
68
+ layout.addLayout(buttons)
69
+
70
+ self._reload()
71
+
72
+ def _reload(self) -> None:
73
+ self._error.clear()
74
+ try:
75
+ self._sessions = self._client.list_sessions()
76
+ except ApiError as exc:
77
+ self._error.setText(str(exc))
78
+ return
79
+ self._table.setRowCount(len(self._sessions))
80
+ for row, session in enumerate(self._sessions):
81
+ label = (session.client or "unknown") + (" (this device)" if session.current else "")
82
+ cells = [
83
+ label,
84
+ fmt_ts(session.created_at),
85
+ fmt_ts(session.last_used_at),
86
+ fmt_ts(session.expires_at),
87
+ ]
88
+ for col, text in enumerate(cells):
89
+ self._table.setItem(row, col, QTableWidgetItem(text))
90
+ # The current session is revoked via "Sign out", not here, so it gets a label not a button.
91
+ if session.current:
92
+ self._table.setCellWidget(row, 4, QLabel("current"))
93
+ else:
94
+ revoke = QPushButton("Revoke")
95
+ revoke.clicked.connect(lambda _=False, sid=session.id: self._revoke(sid))
96
+ self._table.setCellWidget(row, 4, revoke)
97
+ # Nothing else to sign out when only this session exists.
98
+ self._others_btn.setEnabled(any(not s.current for s in self._sessions))
99
+
100
+ def _revoke(self, session_id: str) -> None:
101
+ try:
102
+ self._client.revoke_session(session_id)
103
+ except ApiError as exc:
104
+ self._error.setText(str(exc))
105
+ return
106
+ self._reload()
107
+
108
+ def _sign_out_others(self) -> None:
109
+ confirm = QMessageBox.question(
110
+ self,
111
+ "Sign out other sessions",
112
+ "Revoke every other active session (all devices except this one)?",
113
+ )
114
+ if confirm != QMessageBox.StandardButton.Yes:
115
+ return
116
+ try:
117
+ detail = self._client.revoke_other_sessions()
118
+ except ApiError as exc:
119
+ self._error.setText(str(exc))
120
+ return
121
+ self._reload()
122
+ QMessageBox.information(self, "Sessions revoked", detail)