1142 lines
50 KiB
Python
1142 lines
50 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",
|
|
"/monitoring/analytics",
|
|
"/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 == "/monitoring/analytics":
|
|
return "miners.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 (
|
|
len(parts) >= 5
|
|
and parts[:3] == ["", "api", "miners"]
|
|
and parts[4] == "controls"
|
|
):
|
|
if method == "GET" and len(parts) == 5:
|
|
return "miners.view"
|
|
if method == "POST" and len(parts) == 6 and parts[5] in {
|
|
"suspend", "resume", "performance-mode"
|
|
}:
|
|
return "miners.control"
|
|
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 ({"&":"&","<":"<",">":">",'"':""","'":"'"})[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
|