diff --git a/.env.example b/.env.example index 1be8381..a808ac3 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,12 @@ ENABLE_RELOAD=false # Set to true for live code reload (causes log spam in Dock SECRET_KEY=change-this-in-production-use-random-string CORS_ORIGINS=http://localhost:8000,http://localhost:3000 +# Telefoni (Yealink) callbacks security (MUST set at least one) +# Option A: Shared secret token (recommended) +TELEFONI_SHARED_SECRET= +# Option B: IP whitelist (LAN only) - supports IPs and CIDRs +TELEFONI_IP_WHITELIST=127.0.0.1 + # Shadow Admin (Emergency Access) SHADOW_ADMIN_ENABLED=false SHADOW_ADMIN_USERNAME=shadowadmin diff --git a/.env.prod.example b/.env.prod.example index 4de5d29..2e23242 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -49,6 +49,10 @@ API_RELOAD=false # Brug: python -c "import secrets; print(secrets.token_urlsafe(32))" SECRET_KEY=CHANGEME_GENERATE_RANDOM_SECRET_KEY +# Telefoni (Yealink) callbacks security (MUST set at least one) +TELEFONI_SHARED_SECRET= +TELEFONI_IP_WHITELIST= + # CORS origins - IP adresse med port CORS_ORIGINS=http://172.16.31.183:8001 diff --git a/app/auth/backend/admin.py b/app/auth/backend/admin.py index e44de9b..1d0b8b1 100644 --- a/app/auth/backend/admin.py +++ b/app/auth/backend/admin.py @@ -5,7 +5,7 @@ from fastapi import APIRouter, HTTPException, status, Depends from app.core.auth_dependencies import require_permission from app.core.auth_service import AuthService from app.core.database import execute_query, execute_query_single, execute_insert, execute_update -from app.models.schemas import UserAdminCreate, UserGroupsUpdate, GroupCreate, GroupPermissionsUpdate +from app.models.schemas import UserAdminCreate, UserGroupsUpdate, GroupCreate, GroupPermissionsUpdate, UserTwoFactorResetRequest import logging logger = logging.getLogger(__name__) @@ -19,6 +19,7 @@ async def list_users(): """ SELECT u.user_id, u.username, u.email, u.full_name, u.is_active, u.is_superadmin, u.is_2fa_enabled, + u.telefoni_extension, u.telefoni_aktiv, u.telefoni_phone_ip, u.telefoni_phone_username, u.created_at, u.last_login_at, COALESCE(array_remove(array_agg(g.name), NULL), ARRAY[]::varchar[]) AS groups FROM users u @@ -93,6 +94,33 @@ async def update_user_groups(user_id: int, payload: UserGroupsUpdate): return {"message": "Groups updated"} +@router.post("/admin/users/{user_id}/2fa/reset") +async def reset_user_2fa( + user_id: int, + payload: UserTwoFactorResetRequest, + current_user: dict = Depends(require_permission("users.manage")) +): + ok = AuthService.admin_reset_user_2fa(user_id) + if not ok: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + + reason = (payload.reason or "").strip() + if reason: + logger.info( + "✅ Admin reset 2FA for user_id=%s by %s (reason: %s)", + user_id, + current_user.get("username"), + reason + ) + else: + logger.info( + "✅ Admin reset 2FA for user_id=%s by %s", + user_id, + current_user.get("username") + ) + return {"message": "2FA reset"} + + @router.get("/admin/groups", dependencies=[Depends(require_permission("users.manage"))]) async def list_groups(): groups = execute_query( diff --git a/app/auth/backend/router.py b/app/auth/backend/router.py index 3a0041e..ebede4c 100644 --- a/app/auth/backend/router.py +++ b/app/auth/backend/router.py @@ -24,6 +24,7 @@ class LoginResponse(BaseModel): access_token: str token_type: str = "bearer" user: dict + requires_2fa_setup: bool = False class LogoutRequest(BaseModel): @@ -70,6 +71,11 @@ async def login(request: Request, credentials: LoginRequest, response: Response) is_superadmin=user['is_superadmin'], is_shadow_admin=user.get('is_shadow_admin', False) ) + + requires_2fa_setup = ( + not user.get("is_shadow_admin", False) + and not user.get("is_2fa_enabled", False) + ) response.set_cookie( key="access_token", @@ -81,7 +87,8 @@ async def login(request: Request, credentials: LoginRequest, response: Response) return LoginResponse( access_token=access_token, - user=user + user=user, + requires_2fa_setup=requires_2fa_setup ) diff --git a/app/auth/backend/views.py b/app/auth/backend/views.py index c38d17f..baf580b 100644 --- a/app/auth/backend/views.py +++ b/app/auth/backend/views.py @@ -18,3 +18,14 @@ async def login_page(request: Request): "auth/frontend/login.html", {"request": request} ) + + +@router.get("/2fa/setup", response_class=HTMLResponse) +async def two_factor_setup_page(request: Request): + """ + Render 2FA setup page + """ + return templates.TemplateResponse( + "auth/frontend/2fa_setup.html", + {"request": request} + ) diff --git a/app/auth/frontend/2fa_setup.html b/app/auth/frontend/2fa_setup.html new file mode 100644 index 0000000..97211bd --- /dev/null +++ b/app/auth/frontend/2fa_setup.html @@ -0,0 +1,145 @@ +{% extends "shared/frontend/base.html" %} + +{% block title %}2FA Setup - BMC Hub{% endblock %} + +{% block content %} +
+
+
+
+
+
+

2FA Setup

+

Opsaet tofaktor for din konto

+
+ + + +
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+
+
+
+ + +{% endblock %} diff --git a/app/auth/frontend/login.html b/app/auth/frontend/login.html index 61ed97e..3462e35 100644 --- a/app/auth/frontend/login.html +++ b/app/auth/frontend/login.html @@ -124,7 +124,13 @@ document.getElementById('loginForm').addEventListener('submit', async (e) => { const d = new Date(); d.setTime(d.getTime() + (24*60*60*1000)); document.cookie = `access_token=${data.access_token};expires=${d.toUTCString()};path=/;SameSite=Lax`; - + + if (data.requires_2fa_setup) { + const goSetup = confirm('2FA er ikke opsat. Vil du opsaette 2FA nu?'); + window.location.href = goSetup ? '/2fa/setup' : '/'; + return; + } + // Redirect to dashboard window.location.href = '/'; } else { diff --git a/app/contacts/backend/router_simple.py b/app/contacts/backend/router_simple.py index 21e32b7..0bcc023 100644 --- a/app/contacts/backend/router_simple.py +++ b/app/contacts/backend/router_simple.py @@ -406,3 +406,55 @@ async def get_contact_subscription_billing_matrix( if not customer_id: raise HTTPException(status_code=404, detail="Kontakt har ingen tilknyttet kunde") return await get_subscription_billing_matrix(customer_id, months) + + +@router.get("/contacts/{contact_id}/kontakt") +async def get_contact_kontakt_history(contact_id: int, limit: int = Query(default=200, ge=1, le=1000)): + try: + exists = execute_query("SELECT id FROM contacts WHERE id = %s", (contact_id,)) + if not exists: + raise HTTPException(status_code=404, detail="Contact not found") + + query = """ + SELECT * FROM ( + SELECT + 'call' AS type, + t.id::text AS event_id, + t.started_at AS happened_at, + t.direction, + t.ekstern_nummer AS number, + NULL::text AS message, + t.duration_sec, + COALESCE(u.full_name, u.username) AS user_name, + NULL::text AS sms_status + FROM telefoni_opkald t + LEFT JOIN users u ON u.user_id = t.bruger_id + WHERE t.kontakt_id = %s + + UNION ALL + + SELECT + 'sms' AS type, + s.id::text AS event_id, + s.created_at AS happened_at, + NULL::text AS direction, + s.recipient AS number, + s.message, + NULL::int AS duration_sec, + COALESCE(u.full_name, u.username) AS user_name, + s.status AS sms_status + FROM sms_messages s + LEFT JOIN users u ON u.user_id = s.bruger_id + WHERE s.kontakt_id = %s + ) z + ORDER BY z.happened_at DESC NULLS LAST + LIMIT %s + """ + + rows = execute_query(query, (contact_id, contact_id, limit)) or [] + return {"items": rows} + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to fetch kontakt history for contact {contact_id}: {e}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/app/contacts/frontend/contact_detail.html b/app/contacts/frontend/contact_detail.html index 1348764..223cecf 100644 --- a/app/contacts/frontend/contact_detail.html +++ b/app/contacts/frontend/contact_detail.html @@ -205,8 +205,8 @@ + +
  • Arkiverede Tickets
  • Hardware Assets
  • ESET Oversigt
  • +
  • Telefoni
  • Lokaliteter
  • Prepaid Cards
  • @@ -517,6 +523,8 @@ + + + + +