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.
- {gaard_api-0.2.8/src/gaard_api.egg-info → gaard_api-0.2.9}/PKG-INFO +5 -5
- {gaard_api-0.2.8 → gaard_api-0.2.9}/pyproject.toml +5 -5
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/admin/database.py +58 -2
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/admin/models.py +10 -0
- gaard_api-0.2.9/src/gaard_api/admin-web/assets/identity.js +211 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/admin-web/assets/main.js +166 -19
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/admin-web/assets/styles.css +88 -5
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/api/v1/admin.py +426 -90
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/api/v1/analysis.py +12 -5
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/api/v1/prompts.py +2 -2
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/api/v1/query.py +32 -8
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/api/v1/schema.py +3 -3
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/auth_dependencies.py +50 -3
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/auth_hooks.py +0 -1
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/extensions.py +2 -2
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/license.py +69 -15
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/main.py +5 -1
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/package_updates.py +2 -48
- {gaard_api-0.2.8 → gaard_api-0.2.9/src/gaard_api.egg-info}/PKG-INFO +5 -5
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api.egg-info/requires.txt +4 -4
- {gaard_api-0.2.8 → gaard_api-0.2.9}/tests/test_admin_api.py +734 -27
- {gaard_api-0.2.8 → gaard_api-0.2.9}/tests/test_analysis_api.py +2 -2
- {gaard_api-0.2.8 → gaard_api-0.2.9}/tests/test_license_api.py +64 -5
- {gaard_api-0.2.8 → gaard_api-0.2.9}/tests/test_license_service.py +79 -1
- gaard_api-0.2.8/src/gaard_api/admin-web/assets/identity.js +0 -109
- {gaard_api-0.2.8 → gaard_api-0.2.9}/MANIFEST.in +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/README.md +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/setup.cfg +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/__init__.py +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/admin/__init__.py +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/admin/defaults.py +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/admin/prompt_runtime.py +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/admin/security.py +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/admin/services.py +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/admin-web/assets/getgaard.svg +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/admin-web/assets/vendor/gridstack/gridstack-all.js +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/admin-web/assets/vendor/gridstack/gridstack.min.css +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/admin-web/index.html +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/admin-web/package.json +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/api/__init__.py +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/api/v1/__init__.py +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/api/v1/dashboards.py +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/api_registry.py +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/cli.py +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/cli_commands.py +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/conversations.py +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/core/__init__.py +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/core/error_handlers.py +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/core/schema_cache.py +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/core/settings.py +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/example_data/__init__.py +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/example_data/medical_poc/__init__.py +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/example_data/medical_poc/schema.sql +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/example_data/medical_poc/seed.sql +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/example_database.py +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/extension_services.py +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/py.typed +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/query_hooks.py +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/server_cli.py +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/siem.py +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api/tls_http.py +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api.egg-info/SOURCES.txt +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api.egg-info/dependency_links.txt +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api.egg-info/entry_points.txt +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/src/gaard_api.egg-info/top_level.txt +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/tests/test_api_registry.py +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/tests/test_conversation_api.py +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/tests/test_error_handlers.py +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/tests/test_prompt_runtime.py +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/tests/test_query_error_messages.py +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/tests/test_server_cli.py +0 -0
- {gaard_api-0.2.8 → gaard_api-0.2.9}/tests/test_settings.py +0 -0
- {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.
|
|
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
|
-
Requires-Dist: gaard-connectors==0.2.
|
|
9
|
-
Requires-Dist: gaard-llm==0.2.
|
|
10
|
-
Requires-Dist: gaard-plugin-api<0.3.0,>=0.2.
|
|
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.
|
|
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.
|
|
14
|
-
"gaard-connectors==0.2.
|
|
15
|
-
"gaard-llm==0.2.
|
|
16
|
-
"gaard-plugin-api>=0.2.
|
|
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(
|
|
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
|
+
}
|