From 0831715d3a0fc9a92ff4a8284089f6757708c659 Mon Sep 17 00:00:00 2001 From: Christian Date: Sat, 14 Feb 2026 02:26:29 +0100 Subject: [PATCH] feat: add SMS service and frontend integration - Implement SmsService class for sending SMS via CPSMS API. - Add SMS sending functionality in the frontend with validation and user feedback. - Create database migrations for SMS message storage and telephony features. - Introduce telephony settings and user-specific configurations for click-to-call functionality. - Enhance user experience with toast notifications for incoming calls and actions. --- .env.example | 6 + .env.prod.example | 4 + app/auth/backend/admin.py | 30 +- app/auth/backend/router.py | 9 +- app/auth/backend/views.py | 11 + app/auth/frontend/2fa_setup.html | 145 +++ app/auth/frontend/login.html | 8 +- app/contacts/backend/router_simple.py | 52 + app/contacts/frontend/contact_detail.html | 299 +++++- app/contacts/frontend/contacts.html | 57 +- app/core/auth_service.py | 84 +- app/core/config.py | 5 + app/customers/backend/router.py | 87 ++ app/customers/frontend/customer_detail.html | 190 +++- app/models/schemas.py | 38 +- app/modules/calendar/backend/router.py | 341 +++++++ app/modules/calendar/frontend/views.py | 26 + app/modules/calendar/templates/index.html | 888 ++++++++++++++++++ app/modules/hardware/backend/router.py | 55 ++ app/modules/hardware/frontend/views.py | 122 ++- app/modules/hardware/templates/detail.html | 35 +- .../hardware/templates/eset_overview.html | 21 +- app/modules/hardware/templates/index.html | 197 ++-- app/modules/sag/backend/reminders.py | 25 +- app/modules/sag/backend/router.py | 297 +++++- app/modules/sag/frontend/views.py | 23 + app/modules/sag/templates/create.html | 82 ++ app/modules/sag/templates/detail.html | 535 ++++++++++- app/modules/telefoni/__init__.py | 1 + app/modules/telefoni/backend/__init__.py | 1 + app/modules/telefoni/backend/router.py | 667 +++++++++++++ app/modules/telefoni/backend/schemas.py | 28 + app/modules/telefoni/backend/service.py | 111 +++ app/modules/telefoni/backend/utils.py | 102 ++ app/modules/telefoni/backend/websocket.py | 67 ++ app/modules/telefoni/frontend/__init__.py | 1 + app/modules/telefoni/frontend/views.py | 14 + app/modules/telefoni/templates/log.html | 512 ++++++++++ app/services/eset_service.py | 99 +- app/services/sms_service.py | 96 ++ app/settings/frontend/settings.html | 513 +++++++++- app/shared/frontend/base.html | 41 + ...ESET_SOFTWARE_INVENTORY_SUPPORT_REQUEST.md | 57 ++ main.py | 12 + migrations/120_telefoni_module.sql | 34 + migrations/121_telefoni_settings.sql | 8 + migrations/122_telefoni_user_phone_ip.sql | 6 + .../123_telefoni_user_phone_credentials.sql | 5 + .../124_telefoni_shared_secret_setting.sql | 6 + migrations/125_sms_messages.sql | 16 + migrations/126_sag_reminders_event_type.sql | 10 + migrations/127_sag_todo_steps.sql | 18 + static/js/sms.js | 150 +++ static/js/telefoni.js | 169 ++++ 54 files changed, 6244 insertions(+), 172 deletions(-) create mode 100644 app/auth/frontend/2fa_setup.html create mode 100644 app/modules/calendar/backend/router.py create mode 100644 app/modules/calendar/frontend/views.py create mode 100644 app/modules/calendar/templates/index.html create mode 100644 app/modules/telefoni/__init__.py create mode 100644 app/modules/telefoni/backend/__init__.py create mode 100644 app/modules/telefoni/backend/router.py create mode 100644 app/modules/telefoni/backend/schemas.py create mode 100644 app/modules/telefoni/backend/service.py create mode 100644 app/modules/telefoni/backend/utils.py create mode 100644 app/modules/telefoni/backend/websocket.py create mode 100644 app/modules/telefoni/frontend/__init__.py create mode 100644 app/modules/telefoni/frontend/views.py create mode 100644 app/modules/telefoni/templates/log.html create mode 100644 app/services/sms_service.py create mode 100644 docs/ESET_SOFTWARE_INVENTORY_SUPPORT_REQUEST.md create mode 100644 migrations/120_telefoni_module.sql create mode 100644 migrations/121_telefoni_settings.sql create mode 100644 migrations/122_telefoni_user_phone_ip.sql create mode 100644 migrations/123_telefoni_user_phone_credentials.sql create mode 100644 migrations/124_telefoni_shared_secret_setting.sql create mode 100644 migrations/125_sms_messages.sql create mode 100644 migrations/126_sag_reminders_event_type.sql create mode 100644 migrations/127_sag_todo_steps.sql create mode 100644 static/js/sms.js create mode 100644 static/js/telefoni.js 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 @@ + + + + +