stkit 17.0.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 (128) hide show
  1. stk/__init__.py +19 -0
  2. stk/__main__.py +6 -0
  3. stk/agent_login.py +78 -0
  4. stk/app.py +323 -0
  5. stk/checks.py +634 -0
  6. stk/cli/__init__.py +49 -0
  7. stk/cli/agent.py +267 -0
  8. stk/cli/base.py +79 -0
  9. stk/cli/database.py +199 -0
  10. stk/cli/develop.py +63 -0
  11. stk/cli/doctor.py +262 -0
  12. stk/cli/main.py +101 -0
  13. stk/cli/reports.py +296 -0
  14. stk/cli/smoke.py +399 -0
  15. stk/cli/users.py +259 -0
  16. stk/cli/watch.py +68 -0
  17. stk/extensions.py +29 -0
  18. stk/migrations/__init__.py +130 -0
  19. stk/migrations/env.py +58 -0
  20. stk/migrations/script.py.mako +17 -0
  21. stk/migrations/versions/stk_0001_initial_schema.py +213 -0
  22. stk/portal/views.py +34 -0
  23. stk/public/__init__.py +0 -0
  24. stk/public/models.py +0 -0
  25. stk/public/views.py +344 -0
  26. stk/scaffold/__init__.py +1 -0
  27. stk/scaffold/app/CLAUDE.md +65 -0
  28. stk/scaffold/app/Dockerfile +41 -0
  29. stk/scaffold/app/README.md +16 -0
  30. stk/scaffold/app/__app__/__init__.py +1 -0
  31. stk/scaffold/app/__app__/app.py +8 -0
  32. stk/scaffold/app/__app__/settings.py +7 -0
  33. stk/scaffold/app/claude/skills/stk-api/SKILL.md +52 -0
  34. stk/scaffold/app/claude/skills/stk-blueprint/SKILL.md +48 -0
  35. stk/scaffold/app/claude/skills/stk-deploy/SKILL.md +61 -0
  36. stk/scaffold/app/claude/skills/stk-lessons/SKILL.md +58 -0
  37. stk/scaffold/app/claude/skills/stk-migrate/SKILL.md +96 -0
  38. stk/scaffold/app/docker-compose.yml +88 -0
  39. stk/scaffold/app/dockerignore +13 -0
  40. stk/scaffold/app/env-sample +78 -0
  41. stk/scaffold/app/gitignore +8 -0
  42. stk/scaffold/app/nginx/nginx.conf +46 -0
  43. stk/scaffold/app/nginx/stk.conf +36 -0
  44. stk/scaffold/app/postgres/init-db.sh +8 -0
  45. stk/scaffold/app/pyproject.toml +23 -0
  46. stk/scaffold/app/tests/__init__.py +0 -0
  47. stk/scaffold/app/tests/test_app.py +23 -0
  48. stk/scaffold/generator.py +209 -0
  49. stk/scaffold/templates.py +437 -0
  50. stk/security.py +145 -0
  51. stk/settings.py +147 -0
  52. stk/shell.py +284 -0
  53. stk/static/VERSIONS.txt +4 -0
  54. stk/static/css/app.css +229 -0
  55. stk/static/css/layout.css +330 -0
  56. stk/static/css/vuetify.min.css +6 -0
  57. stk/static/icons/fonts/tabler-icons.woff2 +0 -0
  58. stk/static/icons/tabler-icons.min.css +4 -0
  59. stk/static/img/auth-bg.webp +0 -0
  60. stk/static/img/favicon.ico +0 -0
  61. stk/static/img/stk.svg +1664 -0
  62. stk/static/js/axios.min.js +5 -0
  63. stk/static/js/components/NotificationDropdown.js +156 -0
  64. stk/static/js/components/ThemeSwitcher.js +68 -0
  65. stk/static/js/components/TransitionExpand.js +64 -0
  66. stk/static/js/components/VerticalNavGroup.js +119 -0
  67. stk/static/js/components/VerticalNavLink.js +36 -0
  68. stk/static/js/components/VerticalNavSectionTitle.js +21 -0
  69. stk/static/js/components/index.js +47 -0
  70. stk/static/js/config.js +150 -0
  71. stk/static/js/vue.min.js +13 -0
  72. stk/static/js/vuetify.min.js +3148 -0
  73. stk/static/robots.txt +1 -0
  74. stk/tasks/__init__.py +78 -0
  75. stk/templates/401.html +14 -0
  76. stk/templates/404.html +12 -0
  77. stk/templates/500.html +13 -0
  78. stk/templates/_brand.html +1 -0
  79. stk/templates/auth_layout.html +26 -0
  80. stk/templates/cms/activities.html +135 -0
  81. stk/templates/cms/roles.html +215 -0
  82. stk/templates/cms/users.html +312 -0
  83. stk/templates/dashboard.html +132 -0
  84. stk/templates/index.html +52 -0
  85. stk/templates/layout.html +328 -0
  86. stk/templates/login-layout.html +54 -0
  87. stk/templates/security/_macros.html +39 -0
  88. stk/templates/security/_messages.html +12 -0
  89. stk/templates/security/change_password.html +100 -0
  90. stk/templates/security/confirm_email.html +44 -0
  91. stk/templates/security/email/change_notice.html +4 -0
  92. stk/templates/security/email/change_notice.txt +5 -0
  93. stk/templates/security/email/confirmation_instructions.html +3 -0
  94. stk/templates/security/email/confirmation_instructions.txt +3 -0
  95. stk/templates/security/email/login_instructions.html +5 -0
  96. stk/templates/security/email/login_instructions.txt +5 -0
  97. stk/templates/security/email/reset_instructions.html +1 -0
  98. stk/templates/security/email/reset_instructions.txt +3 -0
  99. stk/templates/security/email/reset_notice.html +1 -0
  100. stk/templates/security/email/reset_notice.txt +1 -0
  101. stk/templates/security/email/welcome.html +7 -0
  102. stk/templates/security/email/welcome.txt +7 -0
  103. stk/templates/security/forgot_password.html +63 -0
  104. stk/templates/security/login_user.html +206 -0
  105. stk/templates/security/mf_recovery.html +67 -0
  106. stk/templates/security/mf_recovery_codes.html +119 -0
  107. stk/templates/security/register_user.html +263 -0
  108. stk/templates/security/reset_password.html +53 -0
  109. stk/templates/security/send_confirmation.html +48 -0
  110. stk/templates/security/send_login.html +39 -0
  111. stk/templates/security/two_factor_select.html +78 -0
  112. stk/templates/security/two_factor_setup.html +166 -0
  113. stk/templates/security/two_factor_verify_code.html +71 -0
  114. stk/templates/security/wan_register.html +179 -0
  115. stk/templates/security/wan_signin.html +123 -0
  116. stk/templates/security/wan_verify.html +109 -0
  117. stk/user/__init__.py +0 -0
  118. stk/user/forms.py +10 -0
  119. stk/user/models.py +308 -0
  120. stk/user/views.py +339 -0
  121. stk/utils/email.py +32 -0
  122. stk/utils/ratelimit.py +67 -0
  123. stk/websocket.py +133 -0
  124. stkit-17.0.0.dist-info/METADATA +135 -0
  125. stkit-17.0.0.dist-info/RECORD +128 -0
  126. stkit-17.0.0.dist-info/WHEEL +4 -0
  127. stkit-17.0.0.dist-info/entry_points.txt +2 -0
  128. stkit-17.0.0.dist-info/licenses/LICENSE +21 -0
stk/cli/smoke.py ADDED
@@ -0,0 +1,399 @@
1
+ """Real-browser smoke check: boot a throwaway app, drive it with Playwright."""
2
+
3
+ import asyncio
4
+ import os
5
+ import secrets
6
+ import socket
7
+ import subprocess
8
+ import sys
9
+ import time
10
+ from pathlib import Path
11
+
12
+ import click
13
+ import httpx
14
+
15
+ from stk.agent_login import create_agent_login_token
16
+ from stk.cli.base import cli_app, run_async
17
+
18
+ SMOKE_EMAIL = "smoke@example.com"
19
+ SMOKE_PASSWORD = "SmokePassword123!"
20
+ SMOKE_SCREENSHOT = Path(".stk/smoke/dashboard.png")
21
+
22
+ # Text the same colour as what it sits on renders invisible without raising a
23
+ # console error, so the browser has to measure it. 3:1 is the WCAG floor for
24
+ # large/bold text; below ~1.5 the element is effectively gone.
25
+ MIN_CONTRAST_RATIO = 3.0
26
+ _CONTRAST_TEMPLATE = """() => {
27
+ const parse = (value) => {
28
+ const parts = value.match(/[\\d.]+/g);
29
+ if (!parts) return null;
30
+ const nums = parts.map(Number);
31
+ const scale = value.startsWith('color(') ? 255 : 1;
32
+ return {r: nums[0] * scale, g: nums[1] * scale, b: nums[2] * scale,
33
+ a: nums.length > 3 ? nums[3] : 1};
34
+ };
35
+ const luminance = ({r, g, b}) => {
36
+ const channel = (v) => {
37
+ v /= 255;
38
+ return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
39
+ };
40
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
41
+ };
42
+ const flatten = (fg, bg) => ({
43
+ r: fg.r * fg.a + bg.r * (1 - fg.a),
44
+ g: fg.g * fg.a + bg.g * (1 - fg.a),
45
+ b: fg.b * fg.a + bg.b * (1 - fg.a),
46
+ a: 1,
47
+ });
48
+ const backdrop = (element) => {
49
+ for (let node = element; node; node = node.parentElement) {
50
+ const background = parse(getComputedStyle(node).backgroundColor);
51
+ if (background && background.a > 0.5) return background;
52
+ }
53
+ return {r: 255, g: 255, b: 255, a: 1};
54
+ };
55
+ const findings = [];
56
+ for (const element of document.querySelectorAll('.v-btn, .v-chip, .v-alert')) {
57
+ if (!element.offsetParent || !element.innerText.trim()) continue;
58
+ const foreground = parse(getComputedStyle(element).color);
59
+ if (!foreground) continue;
60
+ const background = backdrop(element);
61
+ const text = flatten(foreground, background);
62
+ const [a, b] = [luminance(text), luminance(background)];
63
+ const ratio = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
64
+ if (ratio < __MIN_RATIO__) {
65
+ findings.push({label: element.innerText.trim().slice(0, 32),
66
+ ratio: Math.round(ratio * 100) / 100});
67
+ }
68
+ }
69
+ return findings;
70
+ }"""
71
+ CONTRAST_JS = _CONTRAST_TEMPLATE.replace("__MIN_RATIO__", str(MIN_CONTRAST_RATIO))
72
+
73
+
74
+ FIXED_SMOKE_PATHS = ("/", "/login", "/dashboard/", "/users/")
75
+ # Prefixes that are not pages, or whose GET starts an auth flow or redirect
76
+ # (OAuth logins, agent login).
77
+ _SKIP_PREFIXES = ("/api/", "/static", "/_test", "/login", "/logout")
78
+
79
+
80
+ def machine_paths(app):
81
+ """Paths a browser never opens as a page: WebSockets and @csrf_exempt webhooks."""
82
+ return {
83
+ rule.rule
84
+ for rule in app.url_map.iter_rules()
85
+ if rule.websocket
86
+ or getattr(app.view_functions.get(rule.endpoint), "_stk_csrf_exempt", False)
87
+ }
88
+
89
+
90
+ def discover_smoke_paths(routes, skip=frozenset()):
91
+ """Pick parameterless GET pages from build_routes_report that smoke should visit."""
92
+ return sorted(
93
+ route["rule"]
94
+ for route in routes
95
+ if "GET" in route["methods"]
96
+ and not route["arguments"]
97
+ and "<" not in route["rule"]
98
+ and route["blueprint"] != "security"
99
+ and not route["rule"].startswith(_SKIP_PREFIXES)
100
+ and route["rule"] not in FIXED_SMOKE_PATHS
101
+ and route["rule"] not in skip
102
+ )
103
+
104
+
105
+ def build_smoke_report(pages, dashboard_screenshot):
106
+ """Return a browser smoke report with per-page failure reasons."""
107
+ page_reports = []
108
+ for page in pages:
109
+ problems = []
110
+ status_code = page.get("status")
111
+ if status_code is None:
112
+ problems.append("no document response")
113
+ elif status_code >= 400:
114
+ problems.append(f"HTTP {status_code}")
115
+
116
+ for entry in page.get("console", []):
117
+ if entry["type"] == "error":
118
+ problems.append(f"console error: {entry['text']}")
119
+ elif entry["type"] == "pageerror":
120
+ problems.append(f"uncaught error: {entry['text']}")
121
+
122
+ for request in page.get("failed_requests", []):
123
+ failure = request.get("failure") or "unknown"
124
+ problems.append(f"request failed: {request['url']} {failure}")
125
+
126
+ for finding in page.get("low_contrast", []):
127
+ problems.append(
128
+ f'invisible text: "{finding["label"]}" contrast {finding["ratio"]}:1'
129
+ )
130
+
131
+ page_reports.append(
132
+ {
133
+ "name": page["name"],
134
+ "path": page["path"],
135
+ "status_code": status_code,
136
+ "status": "failed" if problems else "passed",
137
+ "console": page.get("console", []),
138
+ "failed_requests": page.get("failed_requests", []),
139
+ "low_contrast": page.get("low_contrast", []),
140
+ "problems": problems,
141
+ }
142
+ )
143
+
144
+ status = (
145
+ "passed"
146
+ if all(page["status"] == "passed" for page in page_reports)
147
+ else "failed"
148
+ )
149
+ return {
150
+ "status": status,
151
+ "dashboard_screenshot": str(dashboard_screenshot),
152
+ "pages": page_reports,
153
+ }
154
+
155
+
156
+ def smoke_exit_code(report):
157
+ return 0 if report["status"] == "passed" else 1
158
+
159
+
160
+ def _free_localhost_port():
161
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
162
+ sock.bind(("127.0.0.1", 0))
163
+ return sock.getsockname()[1]
164
+
165
+
166
+ def _smoke_env(database_uri):
167
+ env = os.environ.copy()
168
+ env.update(
169
+ {
170
+ "SECRET_KEY": secrets.token_urlsafe(32),
171
+ "SECURITY_PASSWORD_SALT": secrets.token_urlsafe(32),
172
+ "SQLALCHEMY_DATABASE_URI": database_uri,
173
+ "STK_ENV": "development",
174
+ "STK_ENABLE_AGENT_LOGIN": "true",
175
+ "SESSION_COOKIE_SECURE": "False",
176
+ }
177
+ )
178
+ env.pop("REDIS_URL", None)
179
+ env.pop("REDIS_SESSION", None)
180
+ return env
181
+
182
+
183
+ def _run_smoke_setup(env):
184
+ commands = [
185
+ [sys.executable, "-m", "stk", "db", "upgrade"],
186
+ [
187
+ sys.executable,
188
+ "-m",
189
+ "stk",
190
+ "install",
191
+ "--email",
192
+ SMOKE_EMAIL,
193
+ "--password",
194
+ SMOKE_PASSWORD,
195
+ ],
196
+ ]
197
+ for command_args in commands:
198
+ result = subprocess.run(
199
+ command_args,
200
+ capture_output=True,
201
+ text=True,
202
+ check=False,
203
+ env=env,
204
+ )
205
+ if result.returncode != 0:
206
+ raise click.ClickException(
207
+ "smoke setup failed for "
208
+ f"{' '.join(command_args)}\n{result.stderr.strip()}"
209
+ )
210
+
211
+
212
+ def _prepare_smoke(app, secret_key):
213
+ """Return an agent login token and the extra page paths to visit."""
214
+ from stk.cli.reports import build_routes_report
215
+
216
+ async def _create_token():
217
+ async with app.app_context():
218
+ # Signed with the key the smoke server runs with, not the app's own.
219
+ app.config["SECRET_KEY"] = secret_key
220
+ token = create_agent_login_token(SMOKE_EMAIL, "/dashboard/")
221
+ skip = machine_paths(app) | set(app.config.get("STK_SMOKE_SKIP", ()))
222
+ paths = discover_smoke_paths(build_routes_report(app), skip)
223
+ return token, paths
224
+
225
+ return run_async(_create_token())
226
+
227
+
228
+ def _start_smoke_server(port, env):
229
+ return subprocess.Popen(
230
+ [
231
+ sys.executable,
232
+ "-m",
233
+ "stk",
234
+ "run",
235
+ "--host",
236
+ "127.0.0.1",
237
+ "--port",
238
+ str(port),
239
+ "--no-reload",
240
+ ],
241
+ env=env,
242
+ stdout=subprocess.PIPE,
243
+ stderr=subprocess.PIPE,
244
+ text=True,
245
+ )
246
+
247
+
248
+ def _wait_for_smoke_server(base_url, process):
249
+ deadline = time.monotonic() + 20
250
+ while time.monotonic() < deadline:
251
+ if process.poll() is not None:
252
+ stdout, stderr = process.communicate(timeout=1)
253
+ raise click.ClickException(
254
+ "smoke server exited before accepting requests\n"
255
+ f"{stdout.strip()}\n{stderr.strip()}"
256
+ )
257
+ try:
258
+ response = httpx.get(f"{base_url}/login", timeout=1)
259
+ if response.status_code < 500:
260
+ return
261
+ except httpx.HTTPError:
262
+ pass
263
+ time.sleep(0.2)
264
+ raise click.ClickException("smoke server did not start within 20 seconds")
265
+
266
+
267
+ def _stop_smoke_server(process):
268
+ if process.poll() is not None:
269
+ return
270
+ process.terminate()
271
+ try:
272
+ process.communicate(timeout=5)
273
+ except subprocess.TimeoutExpired:
274
+ process.kill()
275
+ process.communicate(timeout=5)
276
+
277
+
278
+ async def _visit_smoke_page(context, base_url, name, path, screenshot_path=None):
279
+ page = await context.new_page()
280
+ console_entries = []
281
+ failed_requests = []
282
+
283
+ def _record_console(message):
284
+ if message.type in {"error", "warning"}:
285
+ console_entries.append({"type": message.type, "text": message.text})
286
+
287
+ def _record_failed_request(request):
288
+ failure = request.failure
289
+ if callable(failure):
290
+ failure = failure()
291
+ failed_requests.append({"url": request.url, "failure": failure or ""})
292
+
293
+ def _record_page_error(error):
294
+ console_entries.append({"type": "pageerror", "text": str(error)})
295
+
296
+ page.on("console", _record_console)
297
+ page.on("pageerror", _record_page_error)
298
+ page.on("requestfailed", _record_failed_request)
299
+ response = await page.goto(f"{base_url}{path}", wait_until="networkidle")
300
+ low_contrast = await page.evaluate(CONTRAST_JS)
301
+ if screenshot_path is not None:
302
+ screenshot_path.parent.mkdir(parents=True, exist_ok=True)
303
+ await page.screenshot(path=str(screenshot_path), full_page=True)
304
+ await page.close()
305
+ return {
306
+ "name": name,
307
+ "path": path,
308
+ "status": response.status if response else None,
309
+ "console": console_entries,
310
+ "failed_requests": failed_requests,
311
+ "low_contrast": low_contrast,
312
+ }
313
+
314
+
315
+ async def _run_playwright_smoke(base_url, token, extra_paths):
316
+ try:
317
+ from playwright.async_api import Error as PlaywrightError
318
+ from playwright.async_api import async_playwright
319
+ except ImportError as exc:
320
+ raise click.ClickException(
321
+ "Playwright is not installed. Run `uv sync --extra dev`."
322
+ ) from exc
323
+
324
+ try:
325
+ async with async_playwright() as playwright:
326
+ browser = await playwright.chromium.launch()
327
+ context = await browser.new_context()
328
+ pages = [
329
+ await _visit_smoke_page(context, base_url, "home", "/"),
330
+ await _visit_smoke_page(context, base_url, "login", "/login"),
331
+ await _visit_smoke_page(
332
+ context,
333
+ base_url,
334
+ "agent-login",
335
+ f"/_test/login?token={token}",
336
+ ),
337
+ await _visit_smoke_page(
338
+ context,
339
+ base_url,
340
+ "dashboard",
341
+ "/dashboard/",
342
+ SMOKE_SCREENSHOT,
343
+ ),
344
+ await _visit_smoke_page(context, base_url, "admin-users", "/users/"),
345
+ ]
346
+ for path in extra_paths:
347
+ pages.append(await _visit_smoke_page(context, base_url, path, path))
348
+ await context.close()
349
+ await browser.close()
350
+ except PlaywrightError as exc:
351
+ message = str(exc)
352
+ if "Executable doesn't exist" in message or "playwright install" in message:
353
+ raise click.ClickException(
354
+ "Chromium is not installed. Run `uv run playwright install chromium`."
355
+ ) from exc
356
+ raise
357
+
358
+ for page in pages:
359
+ if page["name"] == "agent-login":
360
+ page["path"] = "/_test/login?token=<redacted>"
361
+ return build_smoke_report(pages, SMOKE_SCREENSHOT)
362
+
363
+
364
+ def run_smoke():
365
+ """Boot a throwaway app on a free port and drive it with a real browser."""
366
+ from stk.migrations import throwaway_database_url
367
+
368
+ app = cli_app()
369
+ with throwaway_database_url(app.config["SQLALCHEMY_DATABASE_URI"]) as database_uri:
370
+ env = _smoke_env(database_uri)
371
+ _run_smoke_setup(env)
372
+
373
+ token, extra_paths = _prepare_smoke(app, env["SECRET_KEY"])
374
+ port = _free_localhost_port()
375
+ base_url = f"http://127.0.0.1:{port}"
376
+ server = _start_smoke_server(port, env)
377
+ try:
378
+ _wait_for_smoke_server(base_url, server)
379
+ return asyncio.run(_run_playwright_smoke(base_url, token, extra_paths))
380
+ finally:
381
+ _stop_smoke_server(server)
382
+
383
+
384
+ def print_smoke_report(report):
385
+ click.echo(f"Smoke status: {report['status']}")
386
+ click.echo(f"Dashboard screenshot: {report['dashboard_screenshot']}")
387
+ for page in report["pages"]:
388
+ click.echo(f"{page['status'].upper()} {page['name']} {page['path']}")
389
+ click.echo(f" HTTP: {page['status_code']}")
390
+ for entry in page["console"]:
391
+ click.echo(f" console {entry['type']}: {entry['text']}")
392
+ for finding in page.get("low_contrast", []):
393
+ click.echo(f" invisible text: {finding['label']!r} {finding['ratio']}:1")
394
+ for request in page["failed_requests"]:
395
+ click.echo(
396
+ f" request failed: {request['url']} {request.get('failure') or ''}"
397
+ )
398
+ for problem in page["problems"]:
399
+ click.echo(f" problem: {problem}")
stk/cli/users.py ADDED
@@ -0,0 +1,259 @@
1
+ """User, role, and session commands."""
2
+
3
+ import secrets
4
+ import string
5
+ from datetime import datetime
6
+ from uuid import uuid4
7
+
8
+ import click
9
+ from email_validator import EmailNotValidError, validate_email
10
+ from quart_security import hash_password
11
+ from sqlalchemy import select
12
+
13
+ import stk.extensions as ext
14
+ from stk.agent_login import create_agent_login_token
15
+ from stk.cli.base import cli_app, console, run_async
16
+ from stk.user.models import User
17
+
18
+
19
+ def _check_email(email):
20
+ """Reject addresses the login form would refuse, such as reserved domains."""
21
+ try:
22
+ validate_email(email, check_deliverability=False)
23
+ except EmailNotValidError as exc:
24
+ raise click.BadParameter(str(exc), param_hint="email") from exc
25
+
26
+
27
+ @click.group("browser-token")
28
+ def browser_token():
29
+ """Create test-only browser login URLs."""
30
+
31
+
32
+ @browser_token.command("create")
33
+ @click.option("--user", "email", required=True, help="Test user email.")
34
+ @click.option("--ttl", default=60, type=int, help="Token TTL in seconds.")
35
+ @click.option("--next", "next_path", default="/dashboard/", help="Local redirect path.")
36
+ def browser_token_create(email, ttl, next_path):
37
+ """Create a signed test-only browser login URL."""
38
+ app = cli_app()
39
+ if ttl > app.config["STK_AGENT_LOGIN_MAX_TTL_SECONDS"]:
40
+ raise click.ClickException("ttl exceeds STK_AGENT_LOGIN_MAX_TTL_SECONDS")
41
+ if not app.config["STK_ENABLE_AGENT_LOGIN"]:
42
+ raise click.ClickException("agent login is disabled")
43
+
44
+ async def _create_token():
45
+ async with app.app_context():
46
+ return create_agent_login_token(email, next_path)
47
+
48
+ token = run_async(_create_token())
49
+ click.echo(f"/_test/login?token={token}")
50
+
51
+
52
+ @click.command()
53
+ @click.option("-e", "--email", default=None, help="Admin email")
54
+ @click.option("-p", "--password", default=None, help="Admin password")
55
+ def install(email, password):
56
+ """Install a default admin user and add an admin role to it."""
57
+ app = cli_app()
58
+
59
+ async def _run():
60
+ from stk.user.models import Role
61
+
62
+ async with ext.async_session_factory() as session:
63
+ admin_role = (
64
+ await session.execute(select(Role).where(Role.name == "admin"))
65
+ ).scalar_one_or_none()
66
+ if not admin_role:
67
+ admin_role = Role(name="admin")
68
+ session.add(admin_role)
69
+ await session.commit()
70
+
71
+ admin_user = (
72
+ await session.execute(
73
+ select(User).where(User.roles.any(Role.name == "admin"))
74
+ )
75
+ ).scalar_one_or_none()
76
+ if admin_user:
77
+ console.print(
78
+ f"[yellow]An admin user already exists:[/] [blue]{admin_user.email}[/]"
79
+ )
80
+ return
81
+
82
+ nonlocal email, password
83
+ if not email:
84
+ email = click.prompt("Admin email", default="admin@example.com")
85
+ _check_email(email)
86
+
87
+ generated = False
88
+ if not password:
89
+ password = "".join(
90
+ secrets.choice(string.ascii_letters + string.digits + "@#$%^&*")
91
+ for _ in range(32)
92
+ )
93
+ generated = True
94
+
95
+ user = User(
96
+ email=email,
97
+ name="Super Admin",
98
+ password=hash_password(password, app=app),
99
+ active=True,
100
+ confirmed_at=datetime.now(),
101
+ )
102
+ user.roles.append(admin_role)
103
+ session.add(user)
104
+ await session.commit()
105
+
106
+ console.print("\n[green]✓[/] Admin user created successfully!")
107
+ console.print(f"[blue]Email:[/] {email}")
108
+ if generated:
109
+ console.print(f"[blue]Password:[/] [red]{password}[/]")
110
+ console.print(
111
+ "\n[yellow]⚠️ Please save this password securely - you will not see it again![/]"
112
+ )
113
+
114
+ run_async(_run())
115
+
116
+
117
+ @click.command()
118
+ @click.option("-e", "--email", prompt=True, default=None)
119
+ @click.option("-p", "--password", prompt=True, default=None)
120
+ def create(email, password):
121
+ """Creates a user using an email."""
122
+ _check_email(email)
123
+ app = cli_app()
124
+
125
+ async def _run():
126
+ async with ext.async_session_factory() as session:
127
+ existing = (
128
+ await session.execute(select(User).where(User.email == email))
129
+ ).scalar_one_or_none()
130
+ if existing is not None:
131
+ console.print("[yellow]User already exists![/]")
132
+ else:
133
+ user = User(
134
+ email=email,
135
+ password=hash_password(password, app=app),
136
+ active=True,
137
+ confirmed_at=datetime.now(),
138
+ )
139
+ session.add(user)
140
+ await session.commit()
141
+
142
+ run_async(_run())
143
+
144
+
145
+ @click.command()
146
+ @click.option("-e", "--email", prompt=True, default=None)
147
+ @click.option("-r", "--role", prompt=True, default="admin")
148
+ def add_role(email, role):
149
+ """Adds a role to the specified user."""
150
+
151
+ async def _run():
152
+ from stk.user.models import Role
153
+
154
+ async with ext.async_session_factory() as session:
155
+ u = (
156
+ await session.execute(select(User).where(User.email == email))
157
+ ).scalar_one_or_none()
158
+
159
+ if u is None:
160
+ console.print("[red]Sorry, this user does not exist![/]")
161
+ else:
162
+ r = (
163
+ await session.execute(select(Role).where(Role.name == role))
164
+ ).scalar_one_or_none()
165
+ if r is None:
166
+ console.print("[yellow]Sorry, this role does not exist![/]")
167
+ answer = click.prompt(
168
+ "Would you like to create one? Y/N", default="N"
169
+ )
170
+ if answer.lower() == "y":
171
+ r = Role(name=role)
172
+ try:
173
+ session.add(r)
174
+ await session.commit()
175
+ console.print(
176
+ "[green]Role created successfully, you may add it now to the user[/]"
177
+ )
178
+ except Exception:
179
+ await session.rollback()
180
+ if r:
181
+ u.roles.append(r)
182
+ await session.commit()
183
+
184
+ run_async(_run())
185
+
186
+
187
+ @click.command()
188
+ @click.option("-e", "--email", prompt="Email", default=None)
189
+ @click.option("-p", "--password", hide_input=True, prompt=True, default=None)
190
+ def reset(email, password):
191
+ """Reset a user password using email"""
192
+ app = cli_app()
193
+ try:
194
+ pwd = hash_password(password, app=app)
195
+
196
+ async def _run():
197
+ async with ext.async_session_factory() as session:
198
+ u = (
199
+ await session.execute(select(User).where(User.email == email))
200
+ ).scalar_one_or_none()
201
+ if not u:
202
+ console.print(f'[red]User with email "{email}" not found.[/]')
203
+ return
204
+
205
+ u.password = pwd
206
+ u.password_set = True
207
+ u.fs_uniquifier = uuid4().hex
208
+ try:
209
+ await session.commit()
210
+ console.print(
211
+ "[green]User password has been reset successfully.[/]"
212
+ )
213
+ except Exception:
214
+ await session.rollback()
215
+ console.print("[red]Error committing to database.[/]")
216
+
217
+ run_async(_run())
218
+ except Exception as e:
219
+ console.print(f"[red]Error resetting user password: {e}[/]")
220
+
221
+
222
+ @click.command("cleanup-sessions")
223
+ def cleanup_sessions():
224
+ """Deactivate expired sessions and delete old rows."""
225
+ from stk.tasks import cleanup_expired_sessions
226
+
227
+ run_async(cleanup_expired_sessions())
228
+ console.print("[green]Session cleanup complete[/]")
229
+
230
+
231
+ @click.command("protect-mfa")
232
+ def protect_mfa():
233
+ """Encrypt legacy MFA seeds or rotate them to the current key."""
234
+ from quart_security.totp import encrypt_totp_secret
235
+ from sqlalchemy import update
236
+
237
+ app = cli_app()
238
+
239
+ async def _run():
240
+ count = 0
241
+ async with ext.async_session_factory() as database:
242
+ rows = await database.stream(
243
+ select(User.id, User.tf_totp_secret)
244
+ .where(User.tf_totp_secret.is_not(None))
245
+ .execution_options(yield_per=100)
246
+ )
247
+ async for user_id, secret in rows:
248
+ encrypted = encrypt_totp_secret(secret, app=app)
249
+ await database.execute(
250
+ update(User)
251
+ .where(User.id == user_id)
252
+ .values(tf_totp_secret=encrypted)
253
+ )
254
+ count += 1
255
+ await database.commit()
256
+ return count
257
+
258
+ count = run_async(_run())
259
+ console.print(f"[green]Protected {count} MFA seeds[/]")