Files
minerops/backend/app/identity.py
T

1128 lines
49 KiB
Python

from datetime import datetime, timedelta, timezone
from html import escape
from typing import Any, Callable
from urllib.parse import quote, urlsplit
import jwt
import psycopg
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from fastapi import APIRouter, Depends, Header, HTTPException, Request, Response, status
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from pydantic import BaseModel, EmailStr, Field
from app.config import settings
router = APIRouter()
password_hasher = PasswordHasher()
SESSION_COOKIE = "minerops_session"
COOKIE_TOKEN_HINT = "cookie-session"
DEFAULT_ROLES = ("Admin", "Manager", "Technician", "Read Only")
class SetupAdminRequest(BaseModel):
username: str = Field(min_length=3, max_length=64, pattern=r"^[A-Za-z0-9_.-]+$")
display_name: str = Field(min_length=1, max_length=120)
email: EmailStr | None = None
password: str = Field(min_length=12, max_length=256)
class LoginRequest(BaseModel):
username: str = Field(min_length=1, max_length=64)
password: str = Field(min_length=1, max_length=256)
class CreateUserRequest(BaseModel):
username: str = Field(min_length=3, max_length=64, pattern=r"^[A-Za-z0-9_.-]+$")
display_name: str = Field(min_length=1, max_length=120)
email: EmailStr | None = None
role: str
password: str = Field(min_length=12, max_length=256)
class UpdateUserRequest(BaseModel):
display_name: str | None = Field(default=None, min_length=1, max_length=120)
email: EmailStr | None = None
role: str | None = None
is_active: bool | None = None
class ResetPasswordRequest(BaseModel):
password: str = Field(min_length=12, max_length=256)
def api_response(data: Any = None, meta: dict | None = None, error: Any = None) -> dict:
return {"data": data, "meta": meta or {}, "error": error}
def get_db_connection():
return psycopg.connect(
host=settings.postgres_host,
port=settings.postgres_port,
dbname=settings.postgres_db,
user=settings.postgres_user,
password=settings.postgres_password,
connect_timeout=5,
)
def create_access_token(user_id: str, username: str, role: str) -> str:
now = datetime.now(timezone.utc)
payload = {
"sub": user_id,
"username": username,
"role": role,
"iat": int(now.timestamp()),
"exp": int((now + timedelta(minutes=settings.jwt_access_token_minutes)).timestamp()),
"type": "access",
}
return jwt.encode(payload, settings.jwt_secret_key, algorithm="HS256")
def create_session_token(user_id: str, username: str, role: str) -> str:
now = datetime.now(timezone.utc)
payload = {
"sub": user_id,
"username": username,
"role": role,
"iat": int(now.timestamp()),
"exp": int((now + timedelta(days=settings.jwt_refresh_token_days)).timestamp()),
"type": "session",
}
return jwt.encode(payload, settings.jwt_secret_key, algorithm="HS256")
def decode_auth_token(token: str) -> dict:
try:
payload = jwt.decode(token, settings.jwt_secret_key, algorithms=["HS256"])
if payload.get("type") not in {"access", "session"}:
raise HTTPException(status_code=401, detail="Invalid token type")
return payload
except jwt.ExpiredSignatureError as exc:
raise HTTPException(status_code=401, detail="Session expired") from exc
except jwt.InvalidTokenError as exc:
raise HTTPException(status_code=401, detail="Invalid session") from exc
def token_from_request(request: Request, authorization: str | None = None) -> str:
header = authorization or request.headers.get("authorization") or ""
if header.lower().startswith("bearer "):
token = header.split(" ", 1)[1].strip()
if token and token != COOKIE_TOKEN_HINT:
return token
cookie_token = request.cookies.get(SESSION_COOKIE, "").strip()
if cookie_token:
return cookie_token
raise HTTPException(status_code=401, detail="Login required")
def get_auth_token(
request: Request,
authorization: str | None = Header(default=None),
) -> str:
return token_from_request(request, authorization)
def load_user_for_token(token: str) -> dict:
payload = decode_auth_token(token)
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
users.id::text AS id,
users.username,
users.display_name,
users.email,
users.is_active,
roles.name AS role,
COALESCE(
jsonb_agg(DISTINCT permissions.key)
FILTER (WHERE permissions.key IS NOT NULL),
'[]'::jsonb
) AS permissions
FROM users
JOIN roles ON roles.id = users.role_id
LEFT JOIN role_permissions ON role_permissions.role_id = roles.id
LEFT JOIN permissions ON permissions.id = role_permissions.permission_id
WHERE users.id = %s
GROUP BY users.id, roles.name
""",
(payload["sub"],),
)
user = cur.fetchone()
if not user or not user["is_active"]:
raise HTTPException(status_code=401, detail="User not found or inactive")
result = dict(user)
result["permissions"] = list(result.get("permissions") or [])
return result
finally:
if conn is not None:
conn.close()
def get_current_user(token: str = Depends(get_auth_token)) -> dict:
return load_user_for_token(token)
def user_has_permission(user: dict, permission: str) -> bool:
return user.get("role") == "Admin" or permission in set(user.get("permissions") or [])
def require_permission(permission: str) -> Callable:
def dependency(current_user: dict = Depends(get_current_user)) -> dict:
if not user_has_permission(current_user, permission):
raise HTTPException(status_code=403, detail="You do not have permission for this action")
return current_user
return dependency
def request_uses_https(request: Request) -> bool:
forwarded = request.headers.get("x-forwarded-proto", "").split(",", 1)[0].strip()
return forwarded == "https" or request.url.scheme == "https"
def set_session_cookie(response: Response, request: Request, token: str) -> None:
response.set_cookie(
key=SESSION_COOKIE,
value=token,
max_age=settings.jwt_refresh_token_days * 24 * 60 * 60,
httponly=True,
secure=request_uses_https(request),
samesite="lax",
path="/",
)
def clear_session_cookie(response: Response, request: Request) -> None:
response.delete_cookie(
SESSION_COOKIE,
path="/",
secure=request_uses_https(request),
httponly=True,
samesite="lax",
)
def audit_event(
cur,
actor_user_id: str | None,
action: str,
entity_id: str | None,
details: dict,
) -> None:
cur.execute(
"""
INSERT INTO audit_logs (
actor_user_id, action, entity_type, entity_id, new_value_json
)
VALUES (%s, %s, 'user', %s, %s)
""",
(
actor_user_id,
action,
entity_id,
psycopg.types.json.Jsonb(details),
),
)
def serialize_user(row: dict) -> dict:
result = dict(row)
for key in ("created_at", "updated_at", "last_login_at"):
if result.get(key) is not None:
result[key] = result[key].isoformat()
return result
def validate_role(role: str) -> str:
cleaned = str(role or "").strip()
if cleaned not in DEFAULT_ROLES:
raise HTTPException(status_code=400, detail="Invalid account role")
return cleaned
@router.get("/api/auth/setup-status")
def setup_status():
conn = None
try:
conn = get_db_connection()
with conn.cursor() as cur:
cur.execute("SELECT COUNT(*) FROM users")
user_count = cur.fetchone()[0]
return api_response(data={"setup_required": user_count == 0, "user_count": user_count})
finally:
if conn is not None:
conn.close()
@router.post("/api/auth/setup-admin")
def setup_admin(payload: SetupAdminRequest, request: Request, response: Response):
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute("SELECT COUNT(*) AS user_count FROM users")
if cur.fetchone()["user_count"] > 0:
raise HTTPException(status_code=409, detail="Initial setup is already complete")
cur.execute("SELECT id FROM roles WHERE name = 'Admin'")
role = cur.fetchone()
if not role:
raise HTTPException(status_code=500, detail="Admin role is missing")
cur.execute(
"""
INSERT INTO users (username, display_name, email, password_hash, role_id)
VALUES (%s, %s, %s, %s, %s)
RETURNING id::text, username, display_name, email
""",
(
payload.username.strip(),
payload.display_name.strip(),
str(payload.email) if payload.email else None,
password_hasher.hash(payload.password),
role["id"],
),
)
user = dict(cur.fetchone())
audit_event(
cur,
user["id"],
"user.created",
user["id"],
{"username": user["username"], "role": "Admin", "source": "initial-setup"},
)
conn.commit()
session_token = create_session_token(user["id"], user["username"], "Admin")
set_session_cookie(response, request, session_token)
return api_response(data={"user": {**user, "role": "Admin"}})
except HTTPException:
if conn is not None:
conn.rollback()
raise
except psycopg.errors.UniqueViolation as exc:
if conn is not None:
conn.rollback()
raise HTTPException(status_code=409, detail="Username already exists") from exc
finally:
if conn is not None:
conn.close()
@router.post("/api/auth/login")
def login(payload: LoginRequest, request: Request, response: Response):
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT users.id::text AS id, users.username, users.display_name,
users.email, users.password_hash, users.is_active,
roles.name AS role
FROM users
JOIN roles ON roles.id = users.role_id
WHERE lower(users.username) = lower(%s)
""",
(payload.username.strip(),),
)
user = cur.fetchone()
if not user or not user["is_active"]:
raise HTTPException(status_code=401, detail="Invalid username or password")
try:
password_hasher.verify(user["password_hash"], payload.password)
except VerifyMismatchError as exc:
raise HTTPException(status_code=401, detail="Invalid username or password") from exc
access_token = create_access_token(user["id"], user["username"], user["role"])
session_token = create_session_token(user["id"], user["username"], user["role"])
cur.execute(
"UPDATE users SET last_login_at = now(), updated_at = now() WHERE id = %s",
(user["id"],),
)
audit_event(
cur,
user["id"],
"user.login",
user["id"],
{"username": user["username"]},
)
conn.commit()
set_session_cookie(response, request, session_token)
return api_response(
data={
"access_token": access_token,
"token_type": "bearer",
"expires_in": settings.jwt_access_token_minutes * 60,
"user": {
"id": user["id"],
"username": user["username"],
"display_name": user["display_name"],
"email": user["email"],
"role": user["role"],
},
}
)
except HTTPException:
if conn is not None:
conn.rollback()
raise
finally:
if conn is not None:
conn.close()
@router.post("/api/auth/logout")
def logout(request: Request, response: Response):
clear_session_cookie(response, request)
return api_response(data={"logged_out": True})
@router.get("/api/auth/me")
def me(current_user: dict = Depends(get_current_user)):
return api_response(data=current_user)
@router.get("/api/admin/roles")
def list_roles(current_user: dict = Depends(require_permission("users.manage"))):
del current_user
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT roles.name, roles.description,
COALESCE(jsonb_agg(permissions.key ORDER BY permissions.key)
FILTER (WHERE permissions.key IS NOT NULL), '[]'::jsonb) AS permissions
FROM roles
LEFT JOIN role_permissions ON role_permissions.role_id = roles.id
LEFT JOIN permissions ON permissions.id = role_permissions.permission_id
WHERE roles.name = ANY(%s)
GROUP BY roles.id
ORDER BY array_position(%s::text[], roles.name)
""",
(list(DEFAULT_ROLES), list(DEFAULT_ROLES)),
)
rows = [dict(row) for row in cur.fetchall()]
return api_response(data=rows)
finally:
if conn is not None:
conn.close()
@router.get("/api/admin/users")
def list_users(current_user: dict = Depends(require_permission("users.manage"))):
del current_user
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT users.id::text AS id, users.username, users.display_name,
users.email, users.is_active, users.last_login_at,
users.created_at, users.updated_at, roles.name AS role
FROM users
JOIN roles ON roles.id = users.role_id
ORDER BY users.is_active DESC, lower(users.display_name), lower(users.username)
"""
)
rows = [serialize_user(dict(row)) for row in cur.fetchall()]
return api_response(data=rows)
finally:
if conn is not None:
conn.close()
@router.post("/api/admin/users", status_code=201)
def create_user(
payload: CreateUserRequest,
current_user: dict = Depends(require_permission("users.manage")),
):
role_name = validate_role(payload.role)
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute("SELECT id FROM roles WHERE name = %s", (role_name,))
role = cur.fetchone()
if not role:
raise HTTPException(status_code=400, detail="Role is not available")
cur.execute(
"SELECT 1 FROM users WHERE lower(username) = lower(%s)",
(payload.username.strip(),),
)
if cur.fetchone():
raise HTTPException(status_code=409, detail="Username already exists")
cur.execute(
"""
INSERT INTO users (username, display_name, email, password_hash, role_id)
VALUES (%s, %s, %s, %s, %s)
RETURNING id::text, username, display_name, email, is_active,
created_at, updated_at
""",
(
payload.username.strip(),
payload.display_name.strip(),
str(payload.email) if payload.email else None,
password_hasher.hash(payload.password),
role["id"],
),
)
user = serialize_user(dict(cur.fetchone()))
user["role"] = role_name
audit_event(
cur,
current_user["id"],
"user.created",
user["id"],
{"username": user["username"], "role": role_name},
)
conn.commit()
return api_response(data=user)
except HTTPException:
if conn is not None:
conn.rollback()
raise
except psycopg.errors.UniqueViolation as exc:
if conn is not None:
conn.rollback()
raise HTTPException(status_code=409, detail="Username already exists") from exc
finally:
if conn is not None:
conn.close()
def active_admin_count(cur) -> int:
cur.execute(
"""
SELECT COUNT(*)
FROM users
JOIN roles ON roles.id = users.role_id
WHERE users.is_active = true AND roles.name = 'Admin'
"""
)
return cur.fetchone()[0]
@router.patch("/api/admin/users/{user_id}")
def update_user(
user_id: str,
payload: UpdateUserRequest,
current_user: dict = Depends(require_permission("users.manage")),
):
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT users.id::text AS id, users.username, users.display_name,
users.email, users.is_active, roles.name AS role
FROM users JOIN roles ON roles.id = users.role_id
WHERE users.id = %s
""",
(user_id,),
)
existing = cur.fetchone()
if not existing:
raise HTTPException(status_code=404, detail="User not found")
new_role = validate_role(payload.role) if payload.role is not None else existing["role"]
new_active = payload.is_active if payload.is_active is not None else existing["is_active"]
removing_admin = existing["role"] == "Admin" and (new_role != "Admin" or not new_active)
if removing_admin and active_admin_count(cur) <= 1:
raise HTTPException(status_code=409, detail="The last active administrator cannot be disabled or demoted")
if user_id == current_user["id"] and not new_active:
raise HTTPException(status_code=409, detail="You cannot disable your own account")
cur.execute("SELECT id FROM roles WHERE name = %s", (new_role,))
role = cur.fetchone()
cur.execute(
"""
UPDATE users
SET display_name = %s, email = %s, role_id = %s,
is_active = %s, updated_at = now()
WHERE id = %s
RETURNING id::text, username, display_name, email, is_active,
last_login_at, created_at, updated_at
""",
(
payload.display_name.strip() if payload.display_name is not None else existing["display_name"],
str(payload.email) if payload.email else None,
role["id"],
new_active,
user_id,
),
)
user = serialize_user(dict(cur.fetchone()))
user["role"] = new_role
audit_event(
cur,
current_user["id"],
"user.updated",
user_id,
{
"username": user["username"],
"role": new_role,
"is_active": new_active,
},
)
conn.commit()
return api_response(data=user)
except HTTPException:
if conn is not None:
conn.rollback()
raise
finally:
if conn is not None:
conn.close()
@router.post("/api/admin/users/{user_id}/reset-password")
def reset_password(
user_id: str,
payload: ResetPasswordRequest,
current_user: dict = Depends(require_permission("users.manage")),
):
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
UPDATE users SET password_hash = %s, updated_at = now()
WHERE id = %s
RETURNING id::text, username
""",
(password_hasher.hash(payload.password), user_id),
)
user = cur.fetchone()
if not user:
raise HTTPException(status_code=404, detail="User not found")
audit_event(
cur,
current_user["id"],
"user.password_reset",
user_id,
{"username": user["username"]},
)
conn.commit()
return api_response(data={"user_id": user_id, "password_reset": True})
except HTTPException:
if conn is not None:
conn.rollback()
raise
finally:
if conn is not None:
conn.close()
def protected_page(path: str) -> bool:
exact = {
"/dashboard",
"/repairs",
"/locations",
"/system-health",
"/accounts",
"/monitoring/map",
"/operations/repairs",
"/assets/miners",
}
prefixes = ("/miners/", "/agent/", "/tools/")
return path in exact or path.startswith(prefixes)
def safe_next_path(value: str | None) -> str:
"""Return a safe local application destination for login redirects."""
fallback = "/dashboard"
if not value or value != value.strip():
return fallback
if any(ord(character) < 32 or ord(character) == 127 for character in value):
return fallback
try:
target = urlsplit(value)
except ValueError:
return fallback
path = target.path
if (
target.scheme
or target.netloc
or target.fragment
or not path.startswith("/")
or path.startswith("//")
or "\\" in path
or "%" in path
or any(segment in {".", ".."} for segment in path.split("/"))
or not protected_page(path)
):
return fallback
return value
def required_page_permission(path: str) -> str:
if path == "/accounts":
return "users.manage"
if path == "/system-health":
return "system_health.view"
if path in {"/repairs", "/operations/repairs"}:
return "repairs.view"
if path in {"/locations", "/monitoring/map"}:
return "locations.view"
if path == "/assets/miners":
return "miners.view"
if path.startswith("/agent/"):
return "agents.view"
if path.startswith("/miners/"):
return "miners.view"
return "dashboard.view"
def machine_agent_request(path: str) -> bool:
exact = {
"/api/agent/checkin",
"/api/agent/config",
"/api/agent/miner-snapshots",
"/api/agent/pool-snapshots",
"/api/agent/commands/pending",
"/api/agent/commands/result",
"/api/agent/discoveries",
"/api/agent/miner-enrichment",
"/api/agent/miner-enrichment-v2",
"/api/agent/miner-enrichment-v3",
"/api/agent/api3-enrichment",
}
return path in exact
def repair_timeline_request(path: str) -> bool:
parts = path.split("/")
if len(parts) == 4 and parts[:3] == ["", "api", "repairs"]:
return parts[3] not in {"queue", "parts-summary"}
return (
len(parts) == 5
and parts[:3] == ["", "api", "repairs"]
and parts[4] == "events"
)
def repair_completion_request(path: str) -> bool:
parts = path.split("/")
return (
len(parts) == 5
and parts[:3] == ["", "api", "repairs"]
and parts[4] == "complete"
)
def repair_parts_request(path: str) -> bool:
parts = path.split("/")
return (
len(parts) == 5
and parts[:3] == ["", "api", "repairs"]
and parts[4] == "parts"
) or (
len(parts) == 7
and parts[:3] == ["", "api", "repairs"]
and parts[4] == "parts"
and parts[6] == "void"
)
def component_request(path: str) -> bool:
parts = path.split("/")
return path == "/api/components" or (
len(parts) == 4 and parts[:3] == ["", "api", "components"]
)
def required_api_permission(method: str, path: str) -> str | None:
parts = path.split("/")
if (
method == "GET"
and len(parts) == 5
and parts[:3] == ["", "api", "miners"]
and parts[4] == "maintenance-events"
):
return "repairs.read"
if component_request(path):
return "components.create" if method == "POST" else "components.read"
if repair_parts_request(path):
return "repairs.read" if method == "GET" else "repairs.parts.manage"
if path.startswith("/api/admin/"):
return "users.manage"
if method == "POST" and path.endswith("/change-mac") and path.startswith("/api/miners/"):
return "miners.replace_control_board"
if (
method == "POST"
and len(path.split("/")) == 5
and path.split("/")[:3] == ["", "api", "miners"]
and path.split("/")[4] == "replace-control-board"
):
return "miners.replace_control_board"
if method == "POST" and path == "/api/repairs":
return "repairs.create"
if method == "POST" and repair_completion_request(path):
return "repairs.complete"
if method == "GET" and repair_timeline_request(path):
return "repairs.read"
if method == "GET":
if path.startswith("/api/system/"):
return "system_health.view"
if path.startswith("/api/repairs") or path.startswith("/api/maintenance"):
return "repairs.view"
if path.startswith("/api/location") or path in {"/api/rooms", "/api/sites"}:
return "locations.view"
return None
if path.startswith("/api/repairs") or "/maintenance-events" in path:
return "repairs.manage"
if path.startswith("/api/location") or path.startswith("/api/rooms"):
return "locations.manage"
if "/commands/" in path or path.endswith("/agent-control"):
return "miners.control"
if path.endswith("/api3/change-password"):
return "credentials.manage"
if path.startswith("/api/agent/") or path.startswith("/api/dashboard/scheduled-bulk-actions"):
return "agents.manage"
if path.startswith("/api/scan-ranges"):
return "scan_ranges.manage" if method in {"POST", "PATCH", "DELETE"} else "scans.run"
if path.startswith("/api/imports") or path.startswith("/api/tools"):
return "miners.update"
if path.startswith("/api/miners"):
return "miners.update"
return None
def request_user_or_none(request: Request) -> dict | None:
try:
return load_user_for_token(token_from_request(request))
except HTTPException:
return None
def install_identity_middleware(app) -> None:
@app.middleware("http")
async def identity_gate(request: Request, call_next):
path = request.url.path
if path == "/":
destination = "/dashboard" if request_user_or_none(request) else "/login"
return RedirectResponse(destination, status_code=303)
if path == "/login":
user = request_user_or_none(request)
if user:
return RedirectResponse(
safe_next_path(request.query_params.get("next")),
status_code=303,
)
return await call_next(request)
if protected_page(path):
user = request_user_or_none(request)
if not user:
requested_path = path
if request.url.query:
requested_path += "?" + request.url.query
destination = "/login?next=" + quote(requested_path, safe="")
response = RedirectResponse(destination, status_code=303)
clear_session_cookie(response, request)
return response
permission = required_page_permission(path)
if not user_has_permission(user, permission):
return HTMLResponse(
"<h1>Access denied</h1><p>Your account does not have permission to view this page.</p><p><a href='/dashboard'>Return to dashboard</a></p>",
status_code=403,
)
request.state.current_user = user
if path.startswith("/api/") and not path.startswith("/api/auth/") and not machine_agent_request(path):
permission = required_api_permission(request.method.upper(), path)
if permission:
user = request_user_or_none(request)
if not user:
return JSONResponse({"detail": "Login required"}, status_code=401)
if not user_has_permission(user, permission):
return JSONResponse(
{"detail": "You do not have permission for this action"},
status_code=403,
)
request.state.current_user = user
return await call_next(request)
LOGIN_HTML = r"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in | MinerOps</title>
<style>
:root { color-scheme: light; font-family: Inter, "Segoe UI", Arial, sans-serif; }
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background: #eef1f4; color: #17202a; display: grid; grid-template-rows: auto 1fr; }
header { min-height: 72px; padding: 18px clamp(20px, 5vw, 72px); background: #17212b; color: #fff; display: flex; align-items: center; border-bottom: 4px solid #dfb52c; }
.brand { font-size: 25px; font-weight: 760; letter-spacing: 0; }
main { display: grid; place-items: center; padding: 28px 18px 56px; }
.login-panel { width: min(100%, 430px); background: #fff; border: 1px solid #cbd2d9; border-radius: 8px; box-shadow: 0 14px 35px rgba(23, 32, 42, .12); overflow: hidden; }
.panel-head { padding: 25px 26px 18px; border-bottom: 1px solid #e1e5e9; }
h1 { margin: 0 0 7px; font-size: 24px; letter-spacing: 0; }
.subtitle { margin: 0; color: #596673; font-size: 14px; line-height: 1.45; }
form { padding: 23px 26px 27px; display: grid; gap: 16px; }
.field { display: grid; gap: 7px; }
label { font-size: 13px; font-weight: 700; color: #34414d; }
input { width: 100%; min-height: 44px; border: 1px solid #aeb8c2; border-radius: 6px; padding: 10px 12px; font: inherit; background: #fff; }
input:focus { outline: 3px solid rgba(31, 111, 165, .18); border-color: #1f6fa5; }
button { min-height: 45px; border: 0; border-radius: 6px; padding: 10px 16px; background: #176b48; color: #fff; font: inherit; font-weight: 750; cursor: pointer; }
button:hover { background: #10563a; }
button:disabled { opacity: .6; cursor: wait; }
.message { min-height: 20px; padding: 0 26px 22px; color: #a12525; font-size: 14px; }
.setup-only { display: none; }
.setup-mode .setup-only { display: grid; }
.security-note { margin: 0; color: #687581; font-size: 12px; line-height: 1.45; }
@media (max-width: 520px) { header { min-height: 62px; padding: 14px 18px; } .brand { font-size: 22px; } main { align-items: start; padding: 18px 12px; } .panel-head, form { padding-left: 20px; padding-right: 20px; } .login-panel { box-shadow: none; } }
</style>
</head>
<body>
<header><div class="brand">MinerOps</div></header>
<main>
<section class="login-panel" id="panel"
data-next="__MINEROPS_SAFE_NEXT_PATH__">
<div class="panel-head"><h1 id="title">Sign in</h1><p class="subtitle" id="subtitle">Use your MinerOps account to continue.</p></div>
<form id="authForm">
<div class="field setup-only"><label for="displayName">Display name</label><input id="displayName" autocomplete="name" maxlength="120"></div>
<div class="field setup-only"><label for="email">Email <span aria-hidden="true">(optional)</span></label><input id="email" type="email" autocomplete="email" maxlength="254"></div>
<div class="field"><label for="username">Username</label><input id="username" autocomplete="username" required maxlength="64" autofocus></div>
<div class="field"><label for="password">Password</label><input id="password" type="password" autocomplete="current-password" required minlength="12"></div>
<button id="submitButton" type="submit">Sign in</button>
<p class="security-note">Your session is stored in a protected browser cookie and expires automatically.</p>
</form>
<div class="message" id="message" role="alert" aria-live="polite"></div>
</section>
</main>
<script>
(function () {
const panel = document.getElementById("panel");
const form = document.getElementById("authForm");
const message = document.getElementById("message");
const button = document.getElementById("submitButton");
let setupRequired = false;
function destination() {
return panel.dataset.next || "/dashboard";
}
function sessionHint() {
["minerops_token", "access_token", "token", "auth_token", "authToken"].forEach(function (key) {
localStorage.setItem(key, "cookie-session");
});
}
async function payload(response) {
const data = await response.json().catch(function () { return {}; });
if (!response.ok) throw new Error(data.detail || data.error || "Request failed");
return data;
}
async function initialize() {
try {
const response = await fetch("/api/auth/setup-status", { credentials: "same-origin" });
const data = await payload(response);
setupRequired = Boolean(data.data && data.data.setup_required);
if (setupRequired) {
panel.classList.add("setup-mode");
document.getElementById("title").textContent = "Create administrator";
document.getElementById("subtitle").textContent = "Set up the first MinerOps account.";
document.getElementById("password").autocomplete = "new-password";
button.textContent = "Create account";
}
} catch (error) {
message.textContent = error.message;
}
}
form.addEventListener("submit", async function (event) {
event.preventDefault();
button.disabled = true;
message.textContent = setupRequired ? "Creating administrator..." : "Signing in...";
const body = {
username: document.getElementById("username").value.trim(),
password: document.getElementById("password").value
};
try {
if (setupRequired) {
body.display_name = document.getElementById("displayName").value.trim();
body.email = document.getElementById("email").value.trim() || null;
await payload(await fetch("/api/auth/setup-admin", {
method: "POST", credentials: "same-origin",
headers: { "Content-Type": "application/json" }, body: JSON.stringify(body)
}));
} else {
await payload(await fetch("/api/auth/login", {
method: "POST", credentials: "same-origin",
headers: { "Content-Type": "application/json" }, body: JSON.stringify(body)
}));
}
sessionHint();
location.replace(destination());
} catch (error) {
message.textContent = error.message;
button.disabled = false;
}
});
initialize();
})();
</script>
</body>
</html>"""
ACCOUNTS_HTML = r"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Accounts | MinerOps</title>
<style>
:root { font-family: Inter, "Segoe UI", Arial, sans-serif; color: #1b252e; background: #f1f3f5; }
* { box-sizing: border-box; }
body { margin: 0; }
header { background: #17212b; color: #fff; min-height: 66px; padding: 13px clamp(16px, 4vw, 48px); display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 4px solid #dfb52c; }
.brand { font-size: 22px; font-weight: 760; }
nav { display: flex; gap: 8px; flex-wrap: wrap; }
a, button { font: inherit; }
nav a, nav button { color: #fff; background: transparent; border: 1px solid #64717d; border-radius: 5px; padding: 8px 11px; text-decoration: none; cursor: pointer; }
main { max-width: 1240px; margin: 0 auto; padding: 28px clamp(14px, 4vw, 42px) 60px; }
.page-head { display: flex; justify-content: space-between; align-items: end; gap: 18px; margin-bottom: 22px; }
h1 { margin: 0 0 5px; font-size: 28px; letter-spacing: 0; }
.page-head p { margin: 0; color: #5f6c77; }
.primary { border: 0; border-radius: 6px; padding: 10px 14px; min-height: 42px; color: #fff; background: #176b48; font-weight: 720; cursor: pointer; }
.role-strip { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border: 1px solid #c9d0d6; background: #fff; margin-bottom: 22px; }
.role { padding: 14px; border-right: 1px solid #dce1e5; }
.role:last-child { border-right: 0; }
.role strong { display: block; margin-bottom: 4px; }
.role span { font-size: 12px; color: #65727d; }
.table-wrap { background: #fff; border: 1px solid #c9d0d6; overflow-x: auto; }
table { width: 100%; border-collapse: collapse; min-width: 820px; }
th, td { padding: 12px 13px; text-align: left; border-bottom: 1px solid #e1e5e8; vertical-align: middle; }
th { background: #e9edf0; color: #3a4650; font-size: 12px; text-transform: uppercase; }
td { font-size: 14px; }
.status { display: inline-block; border-radius: 999px; padding: 4px 8px; font-size: 12px; font-weight: 700; background: #dcefe6; color: #15593d; }
.status.inactive { background: #eceff1; color: #67727b; }
.actions { display: flex; gap: 6px; flex-wrap: wrap; }
.actions button { border: 1px solid #aeb8c0; border-radius: 5px; background: #fff; padding: 6px 9px; cursor: pointer; }
.empty { padding: 30px; text-align: center; color: #64717c; }
.notice { min-height: 22px; margin: 12px 0; color: #a12525; }
dialog { width: min(94vw, 480px); border: 1px solid #9ea9b2; border-radius: 8px; padding: 0; box-shadow: 0 24px 60px rgba(18, 27, 35, .28); }
dialog::backdrop { background: rgba(18, 27, 35, .48); }
.dialog-head { padding: 18px 20px; border-bottom: 1px solid #dde2e6; }
.dialog-head h2 { margin: 0; font-size: 20px; }
.dialog-form { display: grid; gap: 14px; padding: 20px; }
.field { display: grid; gap: 6px; }
.field label { font-size: 13px; font-weight: 700; }
.field input, .field select { width: 100%; min-height: 42px; border: 1px solid #aeb8c2; border-radius: 5px; padding: 9px 10px; font: inherit; }
.check { display: flex; align-items: center; gap: 8px; }
.check input { width: 18px; height: 18px; }
.dialog-actions { display: flex; justify-content: flex-end; gap: 9px; padding-top: 5px; }
.secondary { border: 1px solid #aeb8c0; border-radius: 5px; background: #fff; padding: 9px 13px; cursor: pointer; }
@media (max-width: 800px) { .role-strip { grid-template-columns: 1fr 1fr; } .role:nth-child(2) { border-right: 0; } .role:nth-child(-n+2) { border-bottom: 1px solid #dce1e5; } .page-head { align-items: start; } }
@media (max-width: 620px) { header { align-items: start; flex-direction: column; } main { padding-top: 20px; } .page-head { flex-direction: column; } .page-head .primary { width: 100%; } .role-strip { grid-template-columns: 1fr; } .role { border-right: 0; border-bottom: 1px solid #dce1e5; } .role:last-child { border-bottom: 0; } .table-wrap { border: 0; background: transparent; overflow: visible; } table { min-width: 0; } thead { display: none; } tbody { display: grid; gap: 10px; } tr { display: grid; background: #fff; border: 1px solid #c9d0d6; } td { display: grid; grid-template-columns: 105px 1fr; gap: 8px; padding: 9px 12px; } td::before { content: attr(data-label); color: #64717c; font-size: 11px; font-weight: 750; text-transform: uppercase; } td.actions-cell { grid-template-columns: 1fr; } td.actions-cell::before { display: none; } }
</style>
</head>
<body>
<header><div class="brand">MinerOps</div><nav><a href="/dashboard">Dashboard</a><a href="/system-health">System Health</a><button id="logoutButton" type="button">Sign out</button></nav></header>
<main>
<div class="page-head"><div><h1>Accounts</h1><p>Manage access to MinerOps and assign operational roles.</p></div><button class="primary" id="createButton" type="button">Add account</button></div>
<section class="role-strip" aria-label="Account roles">
<div class="role"><strong>Admin</strong><span>Full system and account access</span></div>
<div class="role"><strong>Manager</strong><span>Fleet, workflow, and reporting control</span></div>
<div class="role"><strong>Technician</strong><span>Repair, location, and miner operations</span></div>
<div class="role"><strong>Read Only</strong><span>Operational visibility without changes</span></div>
</section>
<div class="notice" id="notice" role="status"></div>
<section class="table-wrap"><table><thead><tr><th>Account</th><th>Role</th><th>Status</th><th>Last login</th><th>Actions</th></tr></thead><tbody id="userRows"><tr><td colspan="5" class="empty">Loading accounts...</td></tr></tbody></table></section>
</main>
<dialog id="accountDialog"><div class="dialog-head"><h2 id="dialogTitle">Add account</h2></div><form class="dialog-form" id="accountForm">
<input id="userId" type="hidden">
<div class="field"><label for="accountUsername">Username</label><input id="accountUsername" required maxlength="64" pattern="[A-Za-z0-9_.-]+"></div>
<div class="field"><label for="accountDisplayName">Display name</label><input id="accountDisplayName" required maxlength="120"></div>
<div class="field"><label for="accountEmail">Email</label><input id="accountEmail" type="email" maxlength="254"></div>
<div class="field"><label for="accountRole">Role</label><select id="accountRole"></select></div>
<div class="field" id="passwordField"><label for="accountPassword">Temporary password</label><input id="accountPassword" type="password" minlength="12" autocomplete="new-password"></div>
<label class="check" id="activeField"><input id="accountActive" type="checkbox" checked> Account is active</label>
<div class="dialog-actions"><button class="secondary" id="cancelButton" type="button">Cancel</button><button class="primary" type="submit">Save account</button></div>
</form></dialog>
<dialog id="passwordDialog"><div class="dialog-head"><h2>Reset password</h2></div><form class="dialog-form" id="passwordForm"><input id="passwordUserId" type="hidden"><div class="field"><label for="newPassword">New temporary password</label><input id="newPassword" type="password" minlength="12" required autocomplete="new-password"></div><div class="dialog-actions"><button class="secondary" id="passwordCancel" type="button">Cancel</button><button class="primary" type="submit">Reset password</button></div></form></dialog>
<script>
(function () {
const roles = ["Admin", "Manager", "Technician", "Read Only"];
let users = [];
const rows = document.getElementById("userRows");
const notice = document.getElementById("notice");
const dialog = document.getElementById("accountDialog");
const passwordDialog = document.getElementById("passwordDialog");
const roleSelect = document.getElementById("accountRole");
roles.forEach(function (role) { const option = document.createElement("option"); option.value = role; option.textContent = role; roleSelect.appendChild(option); });
function text(value) { return String(value == null ? "" : value).replace(/[&<>"']/g, function (char) { return ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[char]; }); }
function date(value) { if (!value) return "Never"; return new Date(value).toLocaleString(); }
async function api(url, options) {
const response = await fetch(url, Object.assign({ credentials: "same-origin" }, options || {}));
const body = await response.json().catch(function () { return {}; });
if (response.status === 401) { location.replace("/login?next=/accounts"); throw new Error("Session expired"); }
if (!response.ok) throw new Error(body.detail || body.error || "Request failed");
return body.data;
}
function render() {
if (!users.length) { rows.innerHTML = '<tr><td colspan="5" class="empty">No accounts found.</td></tr>'; return; }
rows.innerHTML = users.map(function (user) { return '<tr><td data-label="Account"><strong>' + text(user.display_name) + '</strong><br><span>' + text(user.username) + (user.email ? ' · ' + text(user.email) : '') + '</span></td><td data-label="Role">' + text(user.role) + '</td><td data-label="Status"><span class="status ' + (user.is_active ? '' : 'inactive') + '">' + (user.is_active ? 'Active' : 'Disabled') + '</span></td><td data-label="Last login">' + text(date(user.last_login_at)) + '</td><td class="actions-cell"><div class="actions"><button type="button" data-action="edit" data-id="' + text(user.id) + '">Edit</button><button type="button" data-action="password" data-id="' + text(user.id) + '">Reset password</button></div></td></tr>'; }).join("");
}
async function load() { try { users = await api("/api/admin/users"); render(); } catch (error) { notice.textContent = error.message; } }
function openCreate() { document.getElementById("dialogTitle").textContent = "Add account"; document.getElementById("accountForm").reset(); document.getElementById("userId").value = ""; document.getElementById("accountUsername").disabled = false; document.getElementById("passwordField").style.display = "grid"; document.getElementById("activeField").style.display = "none"; roleSelect.value = "Technician"; dialog.showModal(); }
function openEdit(user) { document.getElementById("dialogTitle").textContent = "Edit account"; document.getElementById("userId").value = user.id; document.getElementById("accountUsername").value = user.username; document.getElementById("accountUsername").disabled = true; document.getElementById("accountDisplayName").value = user.display_name; document.getElementById("accountEmail").value = user.email || ""; roleSelect.value = user.role; document.getElementById("accountActive").checked = user.is_active; document.getElementById("passwordField").style.display = "none"; document.getElementById("activeField").style.display = "flex"; dialog.showModal(); }
document.getElementById("createButton").onclick = openCreate;
document.getElementById("cancelButton").onclick = function () { dialog.close(); };
document.getElementById("passwordCancel").onclick = function () { passwordDialog.close(); };
rows.addEventListener("click", function (event) { const button = event.target.closest("button[data-action]"); if (!button) return; const user = users.find(function (item) { return item.id === button.dataset.id; }); if (!user) return; if (button.dataset.action === "edit") openEdit(user); else { document.getElementById("passwordUserId").value = user.id; document.getElementById("newPassword").value = ""; passwordDialog.showModal(); } });
document.getElementById("accountForm").addEventListener("submit", async function (event) { event.preventDefault(); notice.textContent = "Saving account..."; const id = document.getElementById("userId").value; const body = { display_name: document.getElementById("accountDisplayName").value.trim(), email: document.getElementById("accountEmail").value.trim() || null, role: roleSelect.value }; if (id) body.is_active = document.getElementById("accountActive").checked; else { body.username = document.getElementById("accountUsername").value.trim(); body.password = document.getElementById("accountPassword").value; } try { await api(id ? "/api/admin/users/" + id : "/api/admin/users", { method: id ? "PATCH" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); dialog.close(); notice.textContent = "Account saved."; await load(); } catch (error) { notice.textContent = error.message; } });
document.getElementById("passwordForm").addEventListener("submit", async function (event) { event.preventDefault(); const id = document.getElementById("passwordUserId").value; try { await api("/api/admin/users/" + id + "/reset-password", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: document.getElementById("newPassword").value }) }); passwordDialog.close(); notice.textContent = "Password reset."; } catch (error) { notice.textContent = error.message; } });
document.getElementById("logoutButton").onclick = async function () { await fetch("/api/auth/logout", { method: "POST", credentials: "same-origin" }); localStorage.clear(); location.replace("/login"); };
load();
})();
</script>
</body>
</html>"""
@router.get("/login", response_class=HTMLResponse)
def login_page(request: Request):
next_path = escape(
safe_next_path(request.query_params.get("next")),
quote=True,
)
return LOGIN_HTML.replace("__MINEROPS_SAFE_NEXT_PATH__", next_path)
@router.get("/accounts", response_class=HTMLResponse)
def accounts_page():
return ACCOUNTS_HTML