gaard-api 0.2.8__tar.gz → 0.2.9__tar.gz

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 (73) hide show
  1. {gaard_api-0.2.8/src/gaard_api.egg-info → gaard_api-0.2.9}/PKG-INFO +5 -5
  2. {gaard_api-0.2.8 → gaard_api-0.2.9}/pyproject.toml +5 -5
  3. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/admin/database.py +58 -2
  4. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/admin/models.py +10 -0
  5. gaard_api-0.2.9/src/gaard_api/admin-web/assets/identity.js +211 -0
  6. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/admin-web/assets/main.js +166 -19
  7. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/admin-web/assets/styles.css +88 -5
  8. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/api/v1/admin.py +426 -90
  9. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/api/v1/analysis.py +12 -5
  10. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/api/v1/prompts.py +2 -2
  11. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/api/v1/query.py +32 -8
  12. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/api/v1/schema.py +3 -3
  13. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/auth_dependencies.py +50 -3
  14. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/auth_hooks.py +0 -1
  15. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/extensions.py +2 -2
  16. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/license.py +69 -15
  17. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/main.py +5 -1
  18. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/package_updates.py +2 -48
  19. {gaard_api-0.2.8 → gaard_api-0.2.9/src/gaard_api.egg-info}/PKG-INFO +5 -5
  20. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api.egg-info/requires.txt +4 -4
  21. {gaard_api-0.2.8 → gaard_api-0.2.9}/tests/test_admin_api.py +734 -27
  22. {gaard_api-0.2.8 → gaard_api-0.2.9}/tests/test_analysis_api.py +2 -2
  23. {gaard_api-0.2.8 → gaard_api-0.2.9}/tests/test_license_api.py +64 -5
  24. {gaard_api-0.2.8 → gaard_api-0.2.9}/tests/test_license_service.py +79 -1
  25. gaard_api-0.2.8/src/gaard_api/admin-web/assets/identity.js +0 -109
  26. {gaard_api-0.2.8 → gaard_api-0.2.9}/MANIFEST.in +0 -0
  27. {gaard_api-0.2.8 → gaard_api-0.2.9}/README.md +0 -0
  28. {gaard_api-0.2.8 → gaard_api-0.2.9}/setup.cfg +0 -0
  29. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/__init__.py +0 -0
  30. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/admin/__init__.py +0 -0
  31. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/admin/defaults.py +0 -0
  32. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/admin/prompt_runtime.py +0 -0
  33. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/admin/security.py +0 -0
  34. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/admin/services.py +0 -0
  35. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/admin-web/assets/getgaard.svg +0 -0
  36. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/admin-web/assets/vendor/gridstack/gridstack-all.js +0 -0
  37. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/admin-web/assets/vendor/gridstack/gridstack.min.css +0 -0
  38. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/admin-web/index.html +0 -0
  39. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/admin-web/package.json +0 -0
  40. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/api/__init__.py +0 -0
  41. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/api/v1/__init__.py +0 -0
  42. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/api/v1/dashboards.py +0 -0
  43. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/api_registry.py +0 -0
  44. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/cli.py +0 -0
  45. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/cli_commands.py +0 -0
  46. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/conversations.py +0 -0
  47. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/core/__init__.py +0 -0
  48. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/core/error_handlers.py +0 -0
  49. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/core/schema_cache.py +0 -0
  50. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/core/settings.py +0 -0
  51. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/example_data/__init__.py +0 -0
  52. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/example_data/medical_poc/__init__.py +0 -0
  53. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/example_data/medical_poc/schema.sql +0 -0
  54. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/example_data/medical_poc/seed.sql +0 -0
  55. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/example_database.py +0 -0
  56. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/extension_services.py +0 -0
  57. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/py.typed +0 -0
  58. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/query_hooks.py +0 -0
  59. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/server_cli.py +0 -0
  60. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/siem.py +0 -0
  61. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/tls_http.py +0 -0
  62. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api.egg-info/SOURCES.txt +0 -0
  63. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api.egg-info/dependency_links.txt +0 -0
  64. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api.egg-info/entry_points.txt +0 -0
  65. {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api.egg-info/top_level.txt +0 -0
  66. {gaard_api-0.2.8 → gaard_api-0.2.9}/tests/test_api_registry.py +0 -0
  67. {gaard_api-0.2.8 → gaard_api-0.2.9}/tests/test_conversation_api.py +0 -0
  68. {gaard_api-0.2.8 → gaard_api-0.2.9}/tests/test_error_handlers.py +0 -0
  69. {gaard_api-0.2.8 → gaard_api-0.2.9}/tests/test_prompt_runtime.py +0 -0
  70. {gaard_api-0.2.8 → gaard_api-0.2.9}/tests/test_query_error_messages.py +0 -0
  71. {gaard_api-0.2.8 → gaard_api-0.2.9}/tests/test_server_cli.py +0 -0
  72. {gaard_api-0.2.8 → gaard_api-0.2.9}/tests/test_settings.py +0 -0
  73. {gaard_api-0.2.8 → gaard_api-0.2.9}/tests/test_tls_http.py +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gaard-api
3
- Version: 0.2.8
3
+ Version: 0.2.9
4
4
  Summary: GAARD backend web services providing admin interface
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
7
- Requires-Dist: gaard-core==0.2.8
8
- Requires-Dist: gaard-connectors==0.2.8
9
- Requires-Dist: gaard-llm==0.2.8
10
- Requires-Dist: gaard-plugin-api<0.3.0,>=0.2.8
7
+ Requires-Dist: gaard-core==0.2.9
8
+ Requires-Dist: gaard-connectors==0.2.9
9
+ Requires-Dist: gaard-llm==0.2.9
10
+ Requires-Dist: gaard-plugin-api<0.3.0,>=0.2.9
11
11
  Requires-Dist: fastapi>=0.111.0
12
12
  Requires-Dist: uvicorn[standard]>=0.30.0
13
13
  Requires-Dist: pydantic>=2.7.0
@@ -4,16 +4,16 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gaard-api"
7
- version = "0.2.8"
7
+ version = "0.2.9"
8
8
  description = "GAARD backend web services providing admin interface"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
11
11
 
12
12
  dependencies = [
13
- "gaard-core==0.2.8",
14
- "gaard-connectors==0.2.8",
15
- "gaard-llm==0.2.8",
16
- "gaard-plugin-api>=0.2.8,<0.3.0",
13
+ "gaard-core==0.2.9",
14
+ "gaard-connectors==0.2.9",
15
+ "gaard-llm==0.2.9",
16
+ "gaard-plugin-api>=0.2.9,<0.3.0",
17
17
  "fastapi>=0.111.0",
18
18
  "uvicorn[standard]>=0.30.0",
19
19
  "pydantic>=2.7.0",
@@ -1,6 +1,7 @@
1
1
  from collections.abc import Iterator
2
2
  import json
3
3
  import threading
4
+ from datetime import UTC, datetime, timedelta
4
5
 
5
6
  from sqlalchemy import create_engine, delete, select
6
7
  from sqlalchemy.engine import Engine
@@ -97,8 +98,24 @@ def init_metadata_store() -> None:
97
98
  session.commit()
98
99
 
99
100
 
101
+ def clear_expired_admin_sessions(session: Session) -> int:
102
+ """Remove sessions inactive for 30 days; called once during application startup."""
103
+ cutoff = datetime.now(UTC) - timedelta(days=30)
104
+ result = session.execute(
105
+ delete(AdminSession)
106
+ .where(AdminSession.last_seen < cutoff)
107
+ .execution_options(synchronize_session=False)
108
+ )
109
+ return int(result.rowcount or 0)
110
+
111
+
100
112
  def seed_admin_user(session: Session) -> None:
101
- user = session.scalar(select(AdminUser).where(AdminUser.username == "admin"))
113
+ user = session.scalar(
114
+ select(AdminUser).where(
115
+ AdminUser.username == "admin",
116
+ AdminUser.auth_provider == "local",
117
+ )
118
+ )
102
119
 
103
120
  if user is not None:
104
121
  return
@@ -108,6 +125,8 @@ def seed_admin_user(session: Session) -> None:
108
125
  username="admin",
109
126
  password_hash=hash_password("admin"),
110
127
  must_change_password=True,
128
+ is_system_admin=True,
129
+ enterprise_access=True,
111
130
  )
112
131
  )
113
132
 
@@ -117,12 +136,35 @@ def ensure_admin_user_schema(engine: Engine) -> None:
117
136
  additions = {
118
137
  "display_name": "ALTER TABLE admin_users ADD COLUMN display_name VARCHAR(255) NOT NULL DEFAULT ''",
119
138
  "auth_provider": "ALTER TABLE admin_users ADD COLUMN auth_provider VARCHAR(255) NOT NULL DEFAULT 'local'",
139
+ "role": "ALTER TABLE admin_users ADD COLUMN role VARCHAR(50) NOT NULL DEFAULT 'admin'",
140
+ "is_system_admin": "ALTER TABLE admin_users ADD COLUMN is_system_admin BOOLEAN NOT NULL DEFAULT 0",
141
+ "enterprise_access": "ALTER TABLE admin_users ADD COLUMN enterprise_access BOOLEAN NOT NULL DEFAULT 0",
120
142
  "is_provisioned": "ALTER TABLE admin_users ADD COLUMN is_provisioned BOOLEAN NOT NULL DEFAULT 0",
121
143
  }
122
144
  with engine.begin() as connection:
123
145
  for name, sql in additions.items():
124
146
  if name not in columns:
125
147
  connection.execute(text(sql))
148
+ # Existing installations predate the explicit system-admin marker. The
149
+ # first local administrator is the account created during initialization.
150
+ connection.execute(text("""
151
+ UPDATE admin_users
152
+ SET is_system_admin = 1
153
+ WHERE id = (
154
+ SELECT id FROM admin_users
155
+ WHERE auth_provider = 'local' AND role = 'admin'
156
+ ORDER BY id
157
+ LIMIT 1
158
+ )
159
+ AND NOT EXISTS (
160
+ SELECT 1 FROM admin_users WHERE is_system_admin = 1
161
+ )
162
+ """))
163
+ # The system administrator is the sole account with permanent Enterprise
164
+ # access. Other administrators receive a seat explicitly, like users.
165
+ connection.execute(text(
166
+ "UPDATE admin_users SET enterprise_access = 1 WHERE is_system_admin = 1"
167
+ ))
126
168
  if engine.dialect.name == "sqlite" and (
127
169
  admin_user_has_global_username_constraint(engine)
128
170
  or not admin_user_has_provider_username_constraint(engine)
@@ -175,6 +217,9 @@ def rebuild_sqlite_admin_users(connection) -> None:
175
217
  username VARCHAR(255) NOT NULL,
176
218
  display_name VARCHAR(255) NOT NULL DEFAULT '',
177
219
  auth_provider VARCHAR(255) NOT NULL DEFAULT 'local',
220
+ role VARCHAR(50) NOT NULL DEFAULT 'admin',
221
+ is_system_admin BOOLEAN NOT NULL DEFAULT 0,
222
+ enterprise_access BOOLEAN NOT NULL DEFAULT 0,
178
223
  password_hash TEXT NOT NULL,
179
224
  must_change_password BOOLEAN NOT NULL,
180
225
  is_provisioned BOOLEAN NOT NULL DEFAULT 0,
@@ -185,7 +230,7 @@ def rebuild_sqlite_admin_users(connection) -> None:
185
230
  """))
186
231
  connection.execute(text("""
187
232
  INSERT INTO admin_users__migrated
188
- (id, username, display_name, auth_provider, password_hash, must_change_password, is_provisioned, created_at, updated_at)
233
+ (id, username, display_name, auth_provider, role, is_system_admin, enterprise_access, password_hash, must_change_password, is_provisioned, created_at, updated_at)
189
234
  SELECT
190
235
  id,
191
236
  CASE WHEN password_hash = 'external$disabled' AND instr(username, ':') > 0
@@ -194,6 +239,9 @@ def rebuild_sqlite_admin_users(connection) -> None:
194
239
  display_name,
195
240
  CASE WHEN auth_provider = 'local' AND password_hash = 'external$disabled' AND instr(username, ':') > 0
196
241
  THEN substr(username, 1, instr(username, ':') - 1) ELSE auth_provider END,
242
+ role,
243
+ is_system_admin,
244
+ enterprise_access,
197
245
  password_hash, must_change_password, 0, created_at, updated_at
198
246
  FROM admin_users
199
247
  """))
@@ -593,12 +641,20 @@ def ensure_admin_session_schema(engine: Engine) -> None:
593
641
  "auth_provider": (
594
642
  "ALTER TABLE admin_sessions ADD COLUMN auth_provider VARCHAR(255) DEFAULT 'local'"
595
643
  ),
644
+ # SQLite cannot add a column with CURRENT_TIMESTAMP as its default. Backfill
645
+ # legacy rows below, while newly created sessions use the ORM default.
646
+ "last_seen": "ALTER TABLE admin_sessions ADD COLUMN last_seen DATETIME",
596
647
  }
597
648
  with engine.begin() as connection:
598
649
  for column_name, sql in additions.items():
599
650
  if column_name not in columns:
600
651
  connection.execute(text(sql))
601
652
 
653
+ if "last_seen" not in columns:
654
+ connection.execute(
655
+ text("UPDATE admin_sessions SET last_seen = created_at WHERE last_seen IS NULL")
656
+ )
657
+
602
658
  session_table = cast(Table, AdminSession.__table__)
603
659
  for index in session_table.indexes:
604
660
  index.create(bind=connection, checkfirst=True)
@@ -35,6 +35,13 @@ class AdminUser(Base):
35
35
  username: Mapped[str] = mapped_column(String(255), index=True)
36
36
  display_name: Mapped[str] = mapped_column(String(255), default="")
37
37
  auth_provider: Mapped[str] = mapped_column(String(255), index=True, default="local")
38
+ role: Mapped[str] = mapped_column(String(50), index=True, default="admin")
39
+ # The administrator created while initializing a GAARD installation.
40
+ # This flag is immutable through identity management.
41
+ is_system_admin: Mapped[bool] = mapped_column(Boolean, default=False)
42
+ # Enterprise access is assigned explicitly to every human identity except
43
+ # the system administrator, which always retains it.
44
+ enterprise_access: Mapped[bool] = mapped_column(Boolean, default=False)
38
45
  password_hash: Mapped[str] = mapped_column(Text)
39
46
  must_change_password: Mapped[bool] = mapped_column(Boolean, default=True)
40
47
  # Provider refreshes create provisional records so they can be assigned before login.
@@ -58,6 +65,9 @@ class AdminSession(Base):
58
65
  role: Mapped[str] = mapped_column(String(50), index=True, default="admin")
59
66
  auth_provider: Mapped[str] = mapped_column(String(255), index=True, default="local")
60
67
  created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
68
+ last_seen: Mapped[datetime] = mapped_column(
69
+ DateTime(timezone=True), index=True, default=utc_now
70
+ )
61
71
 
62
72
 
63
73
  class AdminAuditLog(Base):
@@ -0,0 +1,211 @@
1
+ export function createIdentityModule({ api, escapeHtml, state, render, setMessage }) {
2
+ let selected = null;
3
+ let paneWidth = 420;
4
+ let loaded = false;
5
+ let editAction = null;
6
+ let createError = "";
7
+ let temporaryPassword = "";
8
+ let refreshOnAttach = false;
9
+ let pageResizeObserver = null;
10
+ let enterpriseAccessEventsAttached = false;
11
+
12
+ function providerSpecific(user) {
13
+ const entries = Object.entries(user.attributes || {});
14
+ if (!entries.length) return "";
15
+ return `<h3>Provider specific</h3><table class="identity-provider-table"><tbody>${entries.map(([key, value]) => `<tr><th>${escapeHtml(key)}</th><td>${escapeHtml(Array.isArray(value) ? value.join(", ") : value)}</td></tr>`).join("")}</tbody></table>`;
16
+ }
17
+
18
+ function actionButton(action, label, enabled) {
19
+ return `<button type="button" data-identity-action="${action}" ${enabled ? "" : "disabled"}>${label}</button>`;
20
+ }
21
+
22
+ function builtinUserExtension() {
23
+ return (state.identityExtensions || []).find((extension) => extension.builtinUserManagement);
24
+ }
25
+
26
+ function canManageIdentities() {
27
+ return state.canManageIdentities === true;
28
+ }
29
+
30
+ function builtinUserId(user) {
31
+ return String(user.id || "").replace(/^local:/, "");
32
+ }
33
+
34
+ function canManageBuiltinAccount(user) {
35
+ return Boolean(
36
+ builtinUserExtension()
37
+ && user.provider_id === "local"
38
+ && !user.is_system_admin
39
+ );
40
+ }
41
+
42
+ function canChangeOwnAdminPassword(user) {
43
+ return Boolean(
44
+ user.provider_id === "local"
45
+ && user.role === "admin"
46
+ && user.username === state.username
47
+ );
48
+ }
49
+
50
+ function identityManagementPath(user) {
51
+ const extension = builtinUserExtension();
52
+ if (extension && canManageBuiltinAccount(user)) {
53
+ return `${extension.usersPath}/${encodeURIComponent(builtinUserId(user))}`;
54
+ }
55
+ return `/api/v1/admin/identities/${encodeURIComponent(user.id)}`;
56
+ }
57
+
58
+ function userDashboards(user) {
59
+ const dashboards = user.dashboards || [];
60
+ if (!dashboards.length) return `<h3>Dashboards</h3><p class="identity-empty">No dashboards created.</p>`;
61
+ return `<h3>Dashboards</h3><table class="identity-provider-table"><thead><tr><th>Name</th><th>Last updated</th></tr></thead><tbody>${dashboards.map((dashboard) => `<tr><td><strong>${escapeHtml(dashboard.name || "Untitled dashboard")}</strong>${dashboard.description ? `<br><span class="identity-dashboard-description">${escapeHtml(dashboard.description)}</span>` : ""}</td><td>${escapeHtml(dashboard.updated_at || "-")}</td></tr>`).join("")}</tbody></table>`;
62
+ }
63
+
64
+ function enterpriseLicenseControl(user, compact = false) {
65
+ const editable = canManageIdentities() && !user.is_system_admin;
66
+ return `<label class="identity-license-toggle${compact ? " identity-license-toggle-compact" : ""}" data-identity-enterprise-toggle><input type="checkbox" data-identity-enterprise-access data-identity-id="${escapeHtml(user.id)}" ${user.enterprise_access ? "checked" : ""} ${editable ? "" : "disabled"} /><span>${compact ? "" : "Enterprise license"}</span></label>`;
67
+ }
68
+
69
+ function renderPane(user) {
70
+ const managedBuiltinAccount = canManageBuiltinAccount(user);
71
+ const ownAdminPassword = canChangeOwnAdminPassword(user);
72
+ const editable = canManageIdentities() && Boolean(
73
+ managedBuiltinAccount || (!user.is_system_admin && (user.editable_name || user.editable_password))
74
+ );
75
+ const deletable = canManageIdentities() && managedBuiltinAccount;
76
+ const canClearSessions = canManageIdentities() && (!user.is_system_admin || user.username === state.username);
77
+ const enterpriseLicense = enterpriseLicenseControl(user);
78
+ return `<aside class="identity-pane" style="width:${paneWidth}px">
79
+ <div class="identity-resizer" id="identity-resizer" title="Drag to resize"></div>
80
+ <header><div><h2>${escapeHtml(user.name || user.username)}</h2><p>${escapeHtml(user.provider)}</p></div><button id="identity-close" type="button" aria-label="Close">×</button></header>
81
+ <div class="identity-pane-content">
82
+ <dl class="identity-basics"><dt>Name</dt><dd>${escapeHtml(user.name || user.username)}</dd><dt>Username</dt><dd>${escapeHtml(user.username)}${user.overshadowed ? ` <span class="identity-warning" title="Overshadowed by ${escapeHtml(user.overshadowed_by?.username || "another user")} from ${escapeHtml(user.overshadowed_by?.provider || "another provider")}">⚠️</span>` : ""}</dd></dl>
83
+ ${enterpriseLicense}
84
+ <div class="identity-sessions"><div class="identity-sessions-summary"><span>Active sessions</span><strong>${escapeHtml(user.sessions_count ?? 0)}</strong></div>${actionButton("sessions", "Clear sessions", canClearSessions && Number(user.sessions_count || 0) > 0)}</div>
85
+ <div class="identity-actions">${actionButton("username", "Change username", editable)}${actionButton("password", "Change password", canManageIdentities() && Boolean(user.editable_password || managedBuiltinAccount || ownAdminPassword))}${canManageIdentities() && managedBuiltinAccount ? actionButton("role", "Change role", true) : ""}${deletable ? actionButton("delete", "Delete user", true) : ""}</div>
86
+ ${userDashboards(user)}
87
+ ${providerSpecific(user)}
88
+ </div>
89
+ </aside>`;
90
+ }
91
+
92
+ function renderEditModal() {
93
+ if (!editAction || !selected) return "";
94
+ if (editAction === "delete") return `<div class="modal-backdrop" data-identity-modal-backdrop><section class="modal-panel modal-panel-small" role="dialog" aria-modal="true" aria-labelledby="identity-modal-title"><div class="modal-header"><div><h2 id="identity-modal-title">Delete user</h2><p>Delete ${escapeHtml(selected.name || selected.username)} and their saved application data?</p></div></div><div class="form-actions modal-actions"><button type="button" data-identity-modal-cancel>Cancel</button><button class="danger" type="button" id="identity-delete-confirm">Delete</button></div></section></div>`;
95
+ if (editAction === "sessions") return `<div class="modal-backdrop" data-identity-modal-backdrop><section class="modal-panel modal-panel-small" role="dialog" aria-modal="true" aria-labelledby="identity-modal-title"><div class="modal-header"><div><h2 id="identity-modal-title">Clear sessions</h2><p>Sign ${escapeHtml(selected.name || selected.username)} out of every other session. The session making this request is kept.</p></div></div><div class="form-actions modal-actions"><button type="button" data-identity-modal-cancel>Cancel</button><button class="danger" type="button" id="identity-sessions-clear-confirm">Clear sessions</button></div></section></div>`;
96
+ const isPassword = editAction === "password";
97
+ if (editAction === "role") {
98
+ return `<div class="modal-backdrop" data-identity-modal-backdrop><section class="modal-panel modal-panel-small" role="dialog" aria-modal="true" aria-labelledby="identity-modal-title"><div class="modal-header"><div><h2 id="identity-modal-title">Change role</h2><p>${escapeHtml(selected.name || selected.username)}</p></div></div><form id="identity-edit-modal-form" class="form-grid"><label>Role<select name="value"><option value="user" ${selected.role === "user" ? "selected" : ""}>User</option><option value="admin" ${selected.role === "admin" ? "selected" : ""}>Administrator</option></select></label><div class="form-actions"><button type="button" data-identity-modal-cancel>Cancel</button><button class="primary" type="submit">Apply now</button></div></form></section></div>`;
99
+ }
100
+ if (isPassword && canChangeOwnAdminPassword(selected)) {
101
+ return `<div class="modal-backdrop" data-identity-modal-backdrop><section class="modal-panel modal-panel-small" role="dialog" aria-modal="true" aria-labelledby="identity-modal-title"><div class="modal-header"><div><h2 id="identity-modal-title">Change password</h2><p>${escapeHtml(selected.name || selected.username)}</p></div></div><form id="identity-edit-modal-form" class="form-grid"><label>Current password<input name="current_password" type="password" autocomplete="current-password" required /></label><label>New password<input name="new_password" type="password" minlength="8" autocomplete="new-password" required /></label><div class="form-actions"><button type="button" data-identity-modal-cancel>Cancel</button><button class="primary" type="submit">Apply now</button></div></form></section></div>`;
102
+ }
103
+ const label = isPassword ? "New password" : "New username";
104
+ const value = isPassword ? "" : escapeHtml(selected.username);
105
+ return `<div class="modal-backdrop" data-identity-modal-backdrop><section class="modal-panel modal-panel-small" role="dialog" aria-modal="true" aria-labelledby="identity-modal-title"><div class="modal-header"><div><h2 id="identity-modal-title">${isPassword ? "Change password" : "Change username"}</h2><p>${escapeHtml(selected.name || selected.username)}</p></div></div><form id="identity-edit-modal-form" class="form-grid"><label>${label}<input name="value" ${isPassword ? "type=\"password\" minlength=\"8\" autocomplete=\"new-password\"" : "autocomplete=\"username\""} value="${value}" required /></label><div class="form-actions"><button type="button" data-identity-modal-cancel>Cancel</button><button class="primary" type="submit">Apply now</button></div></form></section></div>`;
106
+ }
107
+
108
+ function renderCreateModal() {
109
+ if (temporaryPassword) return `<div class="modal-backdrop" data-identity-modal-backdrop><section class="modal-panel modal-panel-small" role="dialog" aria-modal="true" aria-labelledby="identity-modal-title"><div class="modal-header"><div><h2 id="identity-modal-title">User created</h2><p>Share this temporary password securely. The user must change it after signing in.</p></div></div><div class="form-grid modal-form-content"><label>Temporary password<input value="${escapeHtml(temporaryPassword)}" readonly /></label></div><div class="form-actions modal-actions"><button class="primary" type="button" data-identity-modal-cancel>Done</button></div></section></div>`;
110
+ if (editAction !== "create") return "";
111
+ return `<div class="modal-backdrop" data-identity-modal-backdrop><section class="modal-panel modal-panel-small" role="dialog" aria-modal="true" aria-labelledby="identity-modal-title"><div class="modal-header"><div><h2 id="identity-modal-title">Create user</h2></div></div><form id="identity-create-modal-form" class="form-grid"><label>Name<input name="display_name" maxlength="255" autocomplete="name" /></label><label>Username<input name="username" maxlength="255" autocomplete="username" required /></label><label>Role<select name="role"><option value="user">User</option><option value="admin">Administrator</option></select></label>${createError ? `<div class="error" role="alert">${escapeHtml(createError)}</div>` : ""}<div class="form-actions"><button type="button" data-identity-modal-cancel>Cancel</button><button class="primary" type="submit">Create user</button></div></form></section></div>`;
112
+ }
113
+
114
+ function renderIdentity() {
115
+ const rows = (state.identities || []).map((user) => `<tr class="identity-table-row${selected?.id === user.id ? " selected" : ""}" data-identity-id="${escapeHtml(user.id)}"><td>${escapeHtml(user.name || user.username)}</td><td>${escapeHtml(user.username)}${user.overshadowed ? ` <span class="identity-warning" title="Overshadowed by ${escapeHtml(user.overshadowed_by?.username || "another user")} from ${escapeHtml(user.overshadowed_by?.provider || "another provider")}">⚠️</span>` : ""}</td><td>${escapeHtml(user.role)}</td><td>${escapeHtml(user.provider)}</td><td>${enterpriseLicenseControl(user, true)}</td></tr>`).join("");
116
+ const createButton = builtinUserExtension() && canManageIdentities()
117
+ ? `<button id="identity-create" class="primary" type="button">Create user</button>`
118
+ : "";
119
+ return `<section class="panel identities-page"><div class="panel-header"><h2>Identities</h2><div class="form-actions">${createButton}<button id="identity-refresh" type="button">Refresh</button></div></div><div class="table-wrap"><table><thead><tr><th>Name</th><th>Username</th><th>Role</th><th>Provider</th><th>Enterprise</th></tr></thead><tbody>${rows || `<tr><td colspan="5">No users found.</td></tr>`}</tbody></table></div>${selected ? renderPane(selected) : ""}</section>${renderEditModal()}${renderCreateModal()}`;
120
+ }
121
+
122
+ function clampPaneWidth() {
123
+ const page = document.querySelector(".identities-page");
124
+ const pane = document.querySelector(".identity-pane");
125
+ if (!page || !pane) return;
126
+ const maximum = Math.max(320, Math.floor(page.getBoundingClientRect().width));
127
+ paneWidth = Math.min(paneWidth, maximum);
128
+ pane.style.width = `${paneWidth}px`;
129
+ }
130
+
131
+ function observePageWidth() {
132
+ const page = document.querySelector(".identities-page");
133
+ if (!page || !window.ResizeObserver) return;
134
+ pageResizeObserver?.disconnect();
135
+ pageResizeObserver = new ResizeObserver(clampPaneWidth);
136
+ pageResizeObserver.observe(page);
137
+ }
138
+
139
+ async function load(refresh = false, refreshAfterLoad = false) {
140
+ try {
141
+ const result = await api(`/api/v1/admin/identities${refresh ? "?refresh=true" : ""}`);
142
+ state.identities = result.items || [];
143
+ state.canManageIdentities = result.can_manage_identities === true;
144
+ if (selected) selected = state.identities.find((item) => item.id === selected.id) || null;
145
+ loaded = true;
146
+ render();
147
+ if (refreshAfterLoad) void load(true);
148
+ } catch (error) { setMessage("error", error.message); }
149
+ }
150
+
151
+ function attachResize() {
152
+ clampPaneWidth(); observePageWidth();
153
+ document.querySelector("#identity-resizer")?.addEventListener("pointerdown", (event) => {
154
+ event.preventDefault(); const startX = event.clientX; const startWidth = paneWidth;
155
+ const move = (moveEvent) => { const page = document.querySelector(".identities-page"); const maximum = Math.max(320, Math.floor(page?.getBoundingClientRect().width || 320)); paneWidth = Math.min(maximum, Math.max(320, startWidth + startX - moveEvent.clientX)); clampPaneWidth(); };
156
+ const stop = () => { window.removeEventListener("pointermove", move); };
157
+ window.addEventListener("pointermove", move); window.addEventListener("pointerup", stop, { once: true });
158
+ });
159
+ }
160
+
161
+ function attachModal() {
162
+ document.querySelector("[data-identity-modal-cancel]")?.addEventListener("click", () => { editAction = null; createError = ""; temporaryPassword = ""; render(); });
163
+ document.querySelector("[data-identity-modal-backdrop]")?.addEventListener("click", (event) => { if (event.target === event.currentTarget) { editAction = null; createError = ""; temporaryPassword = ""; render(); } });
164
+ document.querySelector("#identity-edit-modal-form")?.addEventListener("submit", async (event) => { event.preventDefault(); const form = new FormData(event.currentTarget); try { if (editAction === "password" && canChangeOwnAdminPassword(selected)) { const result = await api("/api/v1/admin/auth/change-password", { method: "POST", body: JSON.stringify({ current_password: form.get("current_password"), new_password: form.get("new_password") }) }); state.mustChangePassword = result.must_change_password; localStorage.setItem("gaard_admin_must_change", String(result.must_change_password)); editAction = null; setMessage("success", "Password changed."); await load(); return; } const value = String(form.get("value") || ""); const body = editAction === "password" ? { new_password: value } : editAction === "role" ? { role: value } : { username: value }; await api(identityManagementPath(selected), { method: "PATCH", body: JSON.stringify(body) }); editAction = null; setMessage("success", "Identity updated. The affected user has been signed out."); await load(); } catch (error) { setMessage("error", error.message); } });
165
+ document.querySelector("#identity-create-modal-form")?.addEventListener("submit", async (event) => { event.preventDefault(); const form = new FormData(event.currentTarget); createError = ""; try { const result = await api(builtinUserExtension().usersPath, { method: "POST", body: JSON.stringify({ display_name: form.get("display_name"), username: form.get("username"), role: form.get("role") }) }); temporaryPassword = result.temporary_password || ""; await load(); } catch (error) { createError = error.message; render(); } });
166
+ document.querySelector("#identity-delete-confirm")?.addEventListener("click", async () => { try { await api(identityManagementPath(selected), { method: "DELETE" }); selected = null; editAction = null; setMessage("success", "Built-in user deleted."); await load(); } catch (error) { setMessage("error", error.message); } });
167
+ document.querySelector("#identity-sessions-clear-confirm")?.addEventListener("click", async () => { try { const result = await api(`/api/v1/admin/identities/${encodeURIComponent(selected.id)}/sessions`, { method: "DELETE" }); editAction = null; setMessage("success", `${result.cleared_sessions || 0} session(s) cleared.`); await load(); } catch (error) { setMessage("error", error.message); } });
168
+ document.querySelectorAll("[data-identity-enterprise-toggle]").forEach((toggle) => {
169
+ toggle.addEventListener("click", (event) => event.stopPropagation());
170
+ });
171
+ attachEnterpriseAccessEvents();
172
+ }
173
+
174
+ function attachEnterpriseAccessEvents() {
175
+ if (enterpriseAccessEventsAttached) return;
176
+ enterpriseAccessEventsAttached = true;
177
+ document.addEventListener("change", async (event) => {
178
+ const checkbox = event.target.closest("[data-identity-enterprise-access]");
179
+ if (!checkbox) return;
180
+ const identity = state.identities.find((item) => item.id === checkbox.dataset.identityId);
181
+ if (!identity) return;
182
+ try {
183
+ await api(`/api/v1/admin/identities/${encodeURIComponent(identity.id)}/enterprise-access`, {
184
+ method: "PATCH",
185
+ body: JSON.stringify({ enterprise_access: checkbox.checked })
186
+ });
187
+ setMessage("success", "Enterprise license assignment updated.");
188
+ await load();
189
+ } catch (error) {
190
+ setMessage("error", error.message);
191
+ await load();
192
+ }
193
+ });
194
+ }
195
+
196
+ function attach() {
197
+ document.querySelector("#identity-create")?.addEventListener("click", () => { editAction = "create"; createError = ""; temporaryPassword = ""; render(); });
198
+ document.querySelector("#identity-refresh")?.addEventListener("click", () => void load(true));
199
+ document.querySelectorAll(".identity-table-row").forEach((row) => row.addEventListener("click", () => { selected = state.identities.find((item) => item.id === row.dataset.identityId) || null; render(); }));
200
+ document.querySelector("#identity-close")?.addEventListener("click", () => { selected = null; render(); });
201
+ document.querySelectorAll("[data-identity-action]").forEach((button) => button.addEventListener("click", () => { editAction = button.dataset.identityAction; render(); }));
202
+ attachResize(); attachModal();
203
+ if (!loaded || refreshOnAttach) {
204
+ const loadFromCacheFirst = (state.identities || []).length === 0;
205
+ refreshOnAttach = false;
206
+ void load(!loadFromCacheFirst, loadFromCacheFirst);
207
+ }
208
+ }
209
+ function activate() { refreshOnAttach = true; }
210
+ return { render: renderIdentity, attach, activate };
211
+ }