From 56d6d45aa25f35d235b56b8470099fca6076cf41 Mon Sep 17 00:00:00 2001 From: Christian Date: Mon, 2 Feb 2026 20:23:56 +0100 Subject: [PATCH] =?UTF-8?q?feat(sag):=20Add=20Varek=C3=B8b=20&=20Salg=20mo?= =?UTF-8?q?dule=20with=20database=20migration=20and=20frontend=20template?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items. - Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases. - Added JavaScript functions for loading and rendering order data dynamically. - Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality. - Developed an email templates API for managing system and customer-specific email templates. - Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods. - Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication. --- .env.example | 18 + .../agents/Planning with subagents.agent.md | 31 +- NEXTCLOUD_MODULE_PLAN.md | 241 ++ app/apply_migration_084.py | 50 + app/apply_migration_085.py | 69 + app/auth/backend/admin.py | 150 ++ app/auth/backend/router.py | 119 +- app/auth/frontend/login.html | 15 +- app/contacts/backend/router.py | 8 +- app/core/auth_dependencies.py | 35 +- app/core/auth_service.py | 264 ++- app/core/config.py | 15 + app/core/crypto.py | 31 + app/core/database.py | 15 +- app/customers/frontend/customer_detail.html | 329 +++ app/models/schemas.py | 55 +- app/modules/nextcloud/backend/__init__.py | 1 + app/modules/nextcloud/backend/router.py | 272 +++ app/modules/nextcloud/backend/service.py | 240 ++ app/modules/nextcloud/frontend/__init__.py | 1 + app/modules/nextcloud/models/__init__.py | 1 + app/modules/nextcloud/models/schemas.py | 63 + app/modules/nextcloud/module.json | 19 + app/modules/nextcloud/templates/tab.html | 38 + app/modules/sag/backend/router.py | 1082 ++++++++- app/modules/sag/backend/solutions.py | 108 + app/modules/sag/frontend/views.py | 207 +- .../sag/migrations/002_varekob_salg.sql | 36 + app/modules/sag/templates/create.html | 718 +++--- app/modules/sag/templates/detail.html | 1965 ++++++++++++++++- app/modules/sag/templates/edit.html | 43 +- app/modules/sag/templates/index.html | 73 +- app/modules/sag/templates/varekob_salg.html | 282 +++ app/modules/search/backend/__init__.py | 0 app/modules/search/backend/router.py | 84 + app/settings/backend/email_templates.py | 225 ++ app/settings/frontend/settings.html | 859 +++++++ app/tags/backend/router.py | 15 + app/ticket/frontend/views.py | 61 +- app/timetracking/backend/models.py | 11 +- app/timetracking/backend/order_service.py | 53 +- app/timetracking/backend/router.py | 140 ++ apply_migration_085.py | 36 + main.py | 76 +- migrations/076_nextcloud_instances.sql | 22 + migrations/077_nextcloud_cache.sql | 12 + migrations/078_nextcloud_audit_log.sql | 22 + migrations/079_email_templates.sql | 38 + .../080_nextcloud_user_email_template.sql | 22 + migrations/081_better_email_templates.sql | 96 + migrations/082_sag_comments.sql | 14 + migrations/083_sag_hardware_locations.sql | 27 + migrations/084_sag_files_and_emails.sql | 27 + migrations/085_sag_solutions.sql | 28 + migrations/086_case_types_settings.sql | 11 + migrations/086_sag_prepaid_integration.sql | 8 + migrations/087_sag_billing_method.sql | 10 + migrations/088_sag_order_lines.sql | 8 + migrations/089_sag_work_type.sql | 8 + migrations/090_auth_2fa.sql | 18 + requirements.txt | 3 + static/js/tag-picker.js | 12 +- 62 files changed, 7987 insertions(+), 553 deletions(-) create mode 100644 NEXTCLOUD_MODULE_PLAN.md create mode 100644 app/apply_migration_084.py create mode 100644 app/apply_migration_085.py create mode 100644 app/auth/backend/admin.py create mode 100644 app/core/crypto.py create mode 100644 app/modules/nextcloud/backend/__init__.py create mode 100644 app/modules/nextcloud/backend/router.py create mode 100644 app/modules/nextcloud/backend/service.py create mode 100644 app/modules/nextcloud/frontend/__init__.py create mode 100644 app/modules/nextcloud/models/__init__.py create mode 100644 app/modules/nextcloud/models/schemas.py create mode 100644 app/modules/nextcloud/module.json create mode 100644 app/modules/nextcloud/templates/tab.html create mode 100644 app/modules/sag/backend/solutions.py create mode 100644 app/modules/sag/migrations/002_varekob_salg.sql create mode 100644 app/modules/sag/templates/varekob_salg.html create mode 100644 app/modules/search/backend/__init__.py create mode 100644 app/modules/search/backend/router.py create mode 100644 app/settings/backend/email_templates.py create mode 100644 apply_migration_085.py create mode 100644 migrations/076_nextcloud_instances.sql create mode 100644 migrations/077_nextcloud_cache.sql create mode 100644 migrations/078_nextcloud_audit_log.sql create mode 100644 migrations/079_email_templates.sql create mode 100644 migrations/080_nextcloud_user_email_template.sql create mode 100644 migrations/081_better_email_templates.sql create mode 100644 migrations/082_sag_comments.sql create mode 100644 migrations/083_sag_hardware_locations.sql create mode 100644 migrations/084_sag_files_and_emails.sql create mode 100644 migrations/085_sag_solutions.sql create mode 100644 migrations/086_case_types_settings.sql create mode 100644 migrations/086_sag_prepaid_integration.sql create mode 100644 migrations/087_sag_billing_method.sql create mode 100644 migrations/088_sag_order_lines.sql create mode 100644 migrations/089_sag_work_type.sql create mode 100644 migrations/090_auth_2fa.sql diff --git a/.env.example b/.env.example index 51fa1d6..1be8381 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,14 @@ 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 +# Shadow Admin (Emergency Access) +SHADOW_ADMIN_ENABLED=false +SHADOW_ADMIN_USERNAME=shadowadmin +SHADOW_ADMIN_PASSWORD= +SHADOW_ADMIN_TOTP_SECRET= +SHADOW_ADMIN_EMAIL=shadowadmin@bmcnetworks.dk +SHADOW_ADMIN_FULL_NAME=Shadow Administrator + # ===================================================== # LOGGING # ===================================================== @@ -45,6 +53,16 @@ ECONOMIC_AGREEMENT_GRANT_TOKEN=your_agreement_grant_token_here # 🚨 SAFETY SWITCHES - Beskytter mod utilsigtede ændringer ECONOMIC_READ_ONLY=true # Set to false ONLY after testing ECONOMIC_DRY_RUN=true # Set to false ONLY when ready for production writes +# ===================================================== +# Nextcloud Integration (Optional) +# ===================================================== +NEXTCLOUD_READ_ONLY=true +NEXTCLOUD_DRY_RUN=true +NEXTCLOUD_TIMEOUT_SECONDS=15 +NEXTCLOUD_CACHE_TTL_SECONDS=300 +# Generate a Fernet key: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +NEXTCLOUD_ENCRYPTION_KEY= + # ===================================================== # vTiger Cloud Integration (Required for Subscriptions) # ===================================================== diff --git a/.github/agents/Planning with subagents.agent.md b/.github/agents/Planning with subagents.agent.md index 82ea150..ae673ed 100644 --- a/.github/agents/Planning with subagents.agent.md +++ b/.github/agents/Planning with subagents.agent.md @@ -1,5 +1,28 @@ --- -description: 'Describe what this custom agent does and when to use it.' -tools: [] ---- -Define what this custom agent accomplishes for the user, when to use it, and the edges it won't cross. Specify its ideal inputs/outputs, the tools it may call, and how it reports progress or asks for help. \ No newline at end of file +name: hub-sales-and-aggregation-agent + +description: "Planlægger og specificerer varekøb og salg i BMC Hub som en simpel sag-baseret funktion, inklusiv aggregering af varer og tid op gennem sagstræet." + +scope: + - Sag-modul + - Vare- og ydelsessalg + - Aggregering i sagstræ + +constraints: + - Ingen ERP-kompleksitet + - Ingen lagerstyring + - Ingen selvstændig ordre-entitet i v1 + - Alt salg er knyttet til en Sag + - Aggregering er læsevisning, ikke datakopiering + +inputs: + - Eksisterende Sag-model med parent/child-relationer + - Eksisterende Tidsmodul + - Varekatalog (internt og leverandørvarer) + +outputs: + - Datamodelforslag + - UI-struktur for Varer-fanen + - Aggregeringslogik + - Faktureringsforberedelse +--- \ No newline at end of file diff --git a/NEXTCLOUD_MODULE_PLAN.md b/NEXTCLOUD_MODULE_PLAN.md new file mode 100644 index 0000000..3bb7170 --- /dev/null +++ b/NEXTCLOUD_MODULE_PLAN.md @@ -0,0 +1,241 @@ +# Nextcloud-modul – BMC Hub + +## 1. Formål og rolle i Hubben +Nextcloud-modulet gør det muligt at sælge, administrere og supportere kunders Nextcloud‑løsninger direkte i Hubben. + +Hubben er styrende system. Nextcloud er et eksternt drifts‑ og brugersystem, som Hubben taler med direkte (ingen gateway). + +## 2. Aktivering af modulet +Modulet er kontekstbaseret og aktiveres via tag: + +- Når Firma, Kontakt eller Sag har tagget `nextcloud`, vises en Nextcloud‑fane i UI. +- Uden tag vises ingen Nextcloud‑funktioner. + +## 3. Kunde → Nextcloud‑fane (overblik) +Fanen indeholder: +1. Drifts‑ og systeminformation (read‑only) +2. Handlinger relateret til brugere +3. Historik (hvad Hubben har gjort mod instansen) + +Fanen må aldrig blokere kundevisningen, selv hvis Nextcloud er utilgængelig. + +## 4. Systemstatus og driftsinformation +**Datakilde**: Nextcloud Serverinfo API + +- `GET /ocs/v2.php/apps/serverinfo/api/v1/info` +- Direkte kald til Nextcloud +- Autentificeret +- Read‑only +- Cached i DB med global TTL = 5 min + +### 4.1 Overblik +Vises øverst i fanen: +- Instans‑status (Online / Offline / Ukendt) +- Sidst opdateret +- Nextcloud‑version +- PHP‑version +- Database‑type og ‑version + +### 4.2 Ressourceforbrug +Vises som simple værdier/badges: +- CPU +- Load average (1 / 5 / 15 min) +- Antal kerner +- RAM (total + brug i %) +- Disk (total + brug i % + fri plads) + +Ved kritiske værdier vises advarsel. + +### 4.3 Nextcloud‑nøgletal +Hvor API tillader det: +- Antal brugere +- Aktive brugere +- Antal filer +- Samlet datamængde +- Status på: database, cache/Redis, cron/background jobs + +## 5. Handlinger i Nextcloud‑fanen +Knapper: +- Tilføj ny bruger +- Reset password +- Luk bruger +- Gensend guide + +Alle handlinger: +- udføres direkte mod Nextcloud +- logges i Hub +- kan spores i historik +- kan knyttes til sag + +## 6. Tilføj ny bruger (primær funktion) + +### 6.1 Start af flow +- Ved “Tilføj ny bruger” oprettes automatisk en ny Sag +- Sagstype: **Nextcloud – Brugeroprettelse** +- Ingen Nextcloud‑handling udføres uden en sag + +### 6.2 Sag – felter og logik +**Firma** +- Vælg eksisterende firma +- Hub slår tilknyttet Nextcloud‑instans op i DB og vælger automatisk +- Instans kan ikke ændres manuelt + +**Kontaktperson** +- Vælg eksisterende kontakt eller opret ny +- Bruges til kommunikation, velkomstmail og ejerskab af sag + +**Grupper** +- Multiselect +- Hentes live fra Nextcloud (OCS groups API) +- Kun gyldige grupper kan vælges + +**Velkomstbrev** +- Checkbox: skal velkomstbrev sendes? + - Hvis ja: bruger oprettes, password genereres, guide + logininfo sendes + - Hvis nej: bruger oprettes uden mail, sag forbliver åben til manuel opfølgning + +## 7. Øvrige handlinger + +**Reset password** +- Vælg eksisterende Nextcloud‑bruger +- Nyt password genereres +- Valg: send mail til kontakt eller kun log i sag + +**Luk bruger** +- Bruger deaktiveres i Nextcloud +- Data bevares +- Kræver eksplicit bekræftelse +- Logges i sag og historik + +**Gensend guide** +- Gensender velkomstmail og guide +- Password ændres ikke +- Kan udføres uden ny sag, men logges + +## 8. Arkitekturprincipper +- Hub ejer: firma, kontakt, sag, historik +- Nextcloud ejer: brugere, filer, rettigheder +- Integration er direkte (ingen gateway) +- Per‑instans auth ligger krypteret i DB +- Global DB‑cache (5 min) for read‑only statusdata + +## 9. Logning og sporbarhed +For hver handling gemmes: +- tidspunkt +- handlingstype +- udførende bruger +- mål (bruger/instans) +- teknisk resultat (success/fejl) + +Audit‑log er **separat pr. kunde**, med **manuel retention** og **tidsbaseret partitionering**. + +## 10. Afgrænsninger (v1) +Modulet indeholder ikke: +- ændring af server‑konfiguration +- håndtering af apps +- ændring af kvoter +- direkte admin‑login + +## 11. Klar til udvidelse +Modulet er designet til senere udvidelser: +- overvågning → automatisk sag +- historiske grafer +- offboarding‑flows +- kvote‑styring +- SLA‑rapportering + +## 12. Sikkerhed og drift +- Credentials krypteres med `settings.NEXTCLOUD_ENCRYPTION_KEY` +- Safety switches: `NEXTCLOUD_READ_ONLY` og `NEXTCLOUD_DRY_RUN` (default true) +- Ingen credentials i UI eller logs +- TLS‑only base URLs + +## 13. Backend‑struktur (plan) +Placering: `app/modules/nextcloud/` +- `backend/router.py` +- `backend/service.py` +- `backend/models.py` + +Alle eksterne kald går via service‑laget, som: +- loader instans fra DB +- dekrypterer credentials +- bruger global DB‑cache (5 min) +- skriver audit‑log pr. kunde + +## 14. Database‑model (plan) + +### `nextcloud_instances` +- `customer_id` FK +- `base_url` +- `auth_type` +- `username` +- `password_encrypted` +- `is_enabled`, `disabled_at` +- `created_at`, `updated_at`, `deleted_at` + +### `nextcloud_cache` +- `cache_key` (PK) +- `payload` (JSONB) +- `expires_at` +- `created_at` + +### `nextcloud_audit_log` +- `customer_id`, `instance_id` +- `event_type` +- `request_meta`, `response_meta` +- `actor_user_id` +- `created_at` + +Partitionering: månedlig range på `created_at`. Retention er manuel via admin‑UI. + +## 15. API‑endpoints (v1) + +### Instanser (admin) +- `GET /api/v1/nextcloud/instances` +- `POST /api/v1/nextcloud/instances` +- `PATCH /api/v1/nextcloud/instances/{id}` +- `POST /api/v1/nextcloud/instances/{id}/disable` +- `POST /api/v1/nextcloud/instances/{id}/enable` +- `POST /api/v1/nextcloud/instances/{id}/rotate-credentials` + +### Status + grupper +- `GET /api/v1/nextcloud/instances/{id}/status` +- `GET /api/v1/nextcloud/instances/{id}/groups` + +### Brugere (handlinger) +- `POST /api/v1/nextcloud/instances/{id}/users` (opret) +- `POST /api/v1/nextcloud/instances/{id}/users/{uid}/reset-password` +- `POST /api/v1/nextcloud/instances/{id}/users/{uid}/disable` +- `POST /api/v1/nextcloud/instances/{id}/users/{uid}/resend-guide` + +Alle endpoints skal: +- validere `is_enabled = true` +- håndhæve kundeejerskab +- skrive audit‑log +- respektere `READ_ONLY`/`DRY_RUN` + +## 16. UI‑krav (plan) +Nextcloud‑fanen i kundevisning skal vise: +- Systemstatus +- Nøgletal +- Handlinger +- Historik + +Admin‑UI (Settings) skal give: +- Liste over instanser +- Enable/disable +- Rotation af credentials +- Retentionstyring af audit‑log pr. kunde + +## 17. Migrations (plan) +1. `migrations/0XX_nextcloud_instances.sql` +2. `migrations/0XX_nextcloud_cache.sql` +3. `migrations/0XX_nextcloud_audit_log.sql` (partitioneret) + +## 18. Næste skridt +1. Opret migrationsfiler +2. Implementer kryptering helper +3. Implementer service‑lag +4. Implementer routere og schemas +5. Implementer UI‑fanen + admin‑UI +6. Implementer audit‑log viewer/export \ No newline at end of file diff --git a/app/apply_migration_084.py b/app/apply_migration_084.py new file mode 100644 index 0000000..8251528 --- /dev/null +++ b/app/apply_migration_084.py @@ -0,0 +1,50 @@ +from app.core.database import get_db_connection, release_db_connection, init_db +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +def run_migration(): + init_db() # Initialize the pool + conn = get_db_connection() + try: + with conn.cursor() as cursor: + # Files linked to a Case + cursor.execute(""" + CREATE TABLE IF NOT EXISTS sag_files ( + id SERIAL PRIMARY KEY, + sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE, + filename VARCHAR(255) NOT NULL, + content_type VARCHAR(100), + size_bytes INTEGER, + stored_name TEXT NOT NULL, + uploaded_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """) + + cursor.execute("CREATE INDEX IF NOT EXISTS idx_sag_files_sag_id ON sag_files(sag_id);") + cursor.execute("COMMENT ON TABLE sag_files IS 'Files uploaded directly to the Case.';") + + # Emails linked to a Case (Many-to-Many) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS sag_emails ( + sag_id INTEGER REFERENCES sag_sager(id) ON DELETE CASCADE, + email_id INTEGER REFERENCES email_messages(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (sag_id, email_id) + ); + """) + + cursor.execute("COMMENT ON TABLE sag_emails IS 'Emails linked to the Case.';") + + conn.commit() + logger.info("Migration 084 applied successfully.") + except Exception as e: + conn.rollback() + logger.error(f"Migration failed: {e}") + finally: + release_db_connection(conn) + +if __name__ == "__main__": + run_migration() diff --git a/app/apply_migration_085.py b/app/apply_migration_085.py new file mode 100644 index 0000000..d0fe79f --- /dev/null +++ b/app/apply_migration_085.py @@ -0,0 +1,69 @@ +import logging +import os +import sys + +# Ensure we can import app modules +sys.path.append("/app") + +from app.core.database import execute_query, init_db + +# Setup logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +SQL_MIGRATION = """ +CREATE TABLE IF NOT EXISTS sag_solutions ( + id SERIAL PRIMARY KEY, + sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + description TEXT, + solution_type VARCHAR(50), + result VARCHAR(50), + created_by_user_id INTEGER, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_sag_solutions_sag_id UNIQUE (sag_id) +); + +ALTER TABLE tmodule_times ADD COLUMN IF NOT EXISTS solution_id INTEGER REFERENCES sag_solutions(id) ON DELETE SET NULL; + +ALTER TABLE tmodule_times ADD COLUMN IF NOT EXISTS sag_id INTEGER REFERENCES sag_sager(id) ON DELETE SET NULL; + +ALTER TABLE tmodule_times ALTER COLUMN vtiger_id DROP NOT NULL; + +ALTER TABLE tmodule_times ALTER COLUMN case_id DROP NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_sag_solutions_sag_id ON sag_solutions(sag_id); + +CREATE INDEX IF NOT EXISTS idx_tmodule_times_solution_id ON tmodule_times(solution_id); + +CREATE INDEX IF NOT EXISTS idx_tmodule_times_sag_id ON tmodule_times(sag_id); +""" + +def run_migration(): + logger.info("Initializing DB connection...") + try: + init_db() + except Exception as e: + logger.error(f"Failed to init db: {e}") + return + + logger.info("Applying migration 085...") + + commands = [cmd.strip() for cmd in SQL_MIGRATION.split(";") if cmd.strip()] + + for cmd in commands: + # Skip empty lines or pure comments + if not cmd or cmd.startswith("--"): + continue + + logger.info(f"Executing: {cmd[:50]}...") + try: + execute_query(cmd, ()) + except Exception as e: + logger.warning(f"Error executing command: {e}") + + logger.info("✅ Migration applied successfully") + +if __name__ == "__main__": + run_migration() diff --git a/app/auth/backend/admin.py b/app/auth/backend/admin.py new file mode 100644 index 0000000..77c65af --- /dev/null +++ b/app/auth/backend/admin.py @@ -0,0 +1,150 @@ +""" +Auth Admin API - Users, Groups, Permissions management +""" +from fastapi import APIRouter, HTTPException, status, Depends +from app.core.auth_dependencies import require_superadmin +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 +import logging + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +@router.get("/admin/users", dependencies=[Depends(require_superadmin)]) +async def list_users(): + users = execute_query( + """ + SELECT u.user_id, u.username, u.email, u.full_name, + u.is_active, u.is_superadmin, u.is_2fa_enabled, + COALESCE(array_remove(array_agg(g.name), NULL), ARRAY[]::varchar[]) AS groups + FROM users u + LEFT JOIN user_groups ug ON u.user_id = ug.user_id + LEFT JOIN groups g ON ug.group_id = g.id + GROUP BY u.user_id + ORDER BY u.user_id + """ + ) + return users + + +@router.post("/admin/users", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_superadmin)]) +async def create_user(payload: UserAdminCreate): + existing = execute_query_single( + "SELECT user_id FROM users WHERE username = %s OR email = %s", + (payload.username, payload.email) + ) + if existing: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Username or email already exists" + ) + + password_hash = AuthService.hash_password(payload.password) + user_id = execute_insert( + """ + INSERT INTO users (username, email, password_hash, full_name, is_superadmin, is_active) + VALUES (%s, %s, %s, %s, %s, %s) RETURNING user_id + """, + (payload.username, payload.email, password_hash, payload.full_name, payload.is_superadmin, payload.is_active) + ) + + if payload.group_ids: + for group_id in payload.group_ids: + execute_insert( + """ + INSERT INTO user_groups (user_id, group_id) + VALUES (%s, %s) ON CONFLICT DO NOTHING + """, + (user_id, group_id) + ) + + logger.info("✅ User created via admin: %s (ID: %s)", payload.username, user_id) + return {"user_id": user_id} + + +@router.put("/admin/users/{user_id}/groups", dependencies=[Depends(require_superadmin)]) +async def update_user_groups(user_id: int, payload: UserGroupsUpdate): + user = execute_query_single("SELECT user_id FROM users WHERE user_id = %s", (user_id,)) + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + + execute_update("DELETE FROM user_groups WHERE user_id = %s", (user_id,)) + + for group_id in payload.group_ids: + execute_insert( + """ + INSERT INTO user_groups (user_id, group_id) + VALUES (%s, %s) ON CONFLICT DO NOTHING + """, + (user_id, group_id) + ) + + return {"message": "Groups updated"} + + +@router.get("/admin/groups", dependencies=[Depends(require_superadmin)]) +async def list_groups(): + groups = execute_query( + """ + SELECT g.id, g.name, g.description, + COALESCE(array_remove(array_agg(p.code), NULL), ARRAY[]::varchar[]) AS permissions + FROM groups g + LEFT JOIN group_permissions gp ON g.id = gp.group_id + LEFT JOIN permissions p ON gp.permission_id = p.id + GROUP BY g.id + ORDER BY g.id + """ + ) + return groups + + +@router.post("/admin/groups", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_superadmin)]) +async def create_group(payload: GroupCreate): + existing = execute_query_single("SELECT id FROM groups WHERE name = %s", (payload.name,)) + if existing: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Group already exists") + + group_id = execute_insert( + """ + INSERT INTO groups (name, description) + VALUES (%s, %s) RETURNING id + """, + (payload.name, payload.description) + ) + + return {"group_id": group_id} + + +@router.put("/admin/groups/{group_id}/permissions", dependencies=[Depends(require_superadmin)]) +async def update_group_permissions(group_id: int, payload: GroupPermissionsUpdate): + group = execute_query_single("SELECT id FROM groups WHERE id = %s", (group_id,)) + if not group: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Group not found") + + execute_update("DELETE FROM group_permissions WHERE group_id = %s", (group_id,)) + + for permission_id in payload.permission_ids: + execute_insert( + """ + INSERT INTO group_permissions (group_id, permission_id) + VALUES (%s, %s) ON CONFLICT DO NOTHING + """, + (group_id, permission_id) + ) + + return {"message": "Permissions updated"} + + +@router.get("/admin/permissions", dependencies=[Depends(require_superadmin)]) +async def list_permissions(): + permissions = execute_query( + """ + SELECT id, code, description, category + FROM permissions + ORDER BY category, code + """ + ) + return permissions diff --git a/app/auth/backend/router.py b/app/auth/backend/router.py index a8ed06d..8e8aa74 100644 --- a/app/auth/backend/router.py +++ b/app/auth/backend/router.py @@ -1,8 +1,9 @@ """ Auth API Router - Login, Logout, Me endpoints """ -from fastapi import APIRouter, HTTPException, status, Request, Depends +from fastapi import APIRouter, HTTPException, status, Request, Depends, Response from pydantic import BaseModel +from typing import Optional from app.core.auth_service import AuthService from app.core.auth_dependencies import get_current_user import logging @@ -15,6 +16,7 @@ router = APIRouter() class LoginRequest(BaseModel): username: str password: str + otp_code: Optional[str] = None class LoginResponse(BaseModel): @@ -27,20 +29,32 @@ class LogoutRequest(BaseModel): token_jti: str +class TwoFactorCodeRequest(BaseModel): + otp_code: str + + @router.post("/login", response_model=LoginResponse) -async def login(request: Request, credentials: LoginRequest): +async def login(request: Request, credentials: LoginRequest, response: Response): """ Authenticate user and return JWT token """ ip_address = request.client.host if request.client else None # Authenticate user - user = AuthService.authenticate_user( + user, error_detail = AuthService.authenticate_user( username=credentials.username, password=credentials.password, - ip_address=ip_address + ip_address=ip_address, + otp_code=credentials.otp_code ) - + + if error_detail: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=error_detail, + headers={"WWW-Authenticate": "Bearer"}, + ) + if not user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -52,9 +66,18 @@ async def login(request: Request, credentials: LoginRequest): access_token = AuthService.create_access_token( user_id=user['user_id'], username=user['username'], - is_superadmin=user['is_superadmin'] + is_superadmin=user['is_superadmin'], + is_shadow_admin=user.get('is_shadow_admin', False) ) + response.set_cookie( + key="access_token", + value=access_token, + httponly=True, + samesite="lax", + secure=False + ) + return LoginResponse( access_token=access_token, user=user @@ -62,12 +85,22 @@ async def login(request: Request, credentials: LoginRequest): @router.post("/logout") -async def logout(request: LogoutRequest, current_user: dict = Depends(get_current_user)): +async def logout( + request: LogoutRequest, + response: Response, + current_user: dict = Depends(get_current_user) +): """ Revoke JWT token (logout) """ - AuthService.revoke_token(request.token_jti, current_user['id']) + AuthService.revoke_token( + request.token_jti, + current_user['id'], + current_user.get('is_shadow_admin', False) + ) + response.delete_cookie("access_token") + return {"message": "Successfully logged out"} @@ -82,5 +115,75 @@ async def get_me(current_user: dict = Depends(get_current_user)): "email": current_user['email'], "full_name": current_user['full_name'], "is_superadmin": current_user['is_superadmin'], + "is_2fa_enabled": current_user.get('is_2fa_enabled', False), "permissions": current_user['permissions'] } + + +@router.post("/2fa/setup") +async def setup_2fa(current_user: dict = Depends(get_current_user)): + """Generate and store TOTP secret (requires verification to enable)""" + if current_user.get("is_shadow_admin"): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Shadow admin cannot configure 2FA", + ) + + result = AuthService.setup_user_2fa( + user_id=current_user["id"], + username=current_user["username"] + ) + + return result + + +@router.post("/2fa/enable") +async def enable_2fa( + request: TwoFactorCodeRequest, + current_user: dict = Depends(get_current_user) +): + """Enable 2FA after verifying the provided code""" + if current_user.get("is_shadow_admin"): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Shadow admin cannot configure 2FA", + ) + + ok = AuthService.enable_user_2fa( + user_id=current_user["id"], + otp_code=request.otp_code + ) + + if not ok: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid 2FA code or missing setup", + ) + + return {"message": "2FA enabled"} + + +@router.post("/2fa/disable") +async def disable_2fa( + request: TwoFactorCodeRequest, + current_user: dict = Depends(get_current_user) +): + """Disable 2FA after verifying the provided code""" + if current_user.get("is_shadow_admin"): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Shadow admin cannot configure 2FA", + ) + + ok = AuthService.disable_user_2fa( + user_id=current_user["id"], + otp_code=request.otp_code + ) + + if not ok: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid 2FA code or missing setup", + ) + + return {"message": "2FA disabled"} diff --git a/app/auth/frontend/login.html b/app/auth/frontend/login.html index 226a85d..b1865f1 100644 --- a/app/auth/frontend/login.html +++ b/app/auth/frontend/login.html @@ -38,6 +38,18 @@ required > + +
+ + +
@@ -80,6 +92,7 @@ document.getElementById('loginForm').addEventListener('submit', async (e) => { const username = document.getElementById('username').value; const password = document.getElementById('password').value; + const otp_code = document.getElementById('otp_code').value; const errorMessage = document.getElementById('errorMessage'); const errorText = document.getElementById('errorText'); const submitBtn = e.target.querySelector('button[type="submit"]'); @@ -97,7 +110,7 @@ document.getElementById('loginForm').addEventListener('submit', async (e) => { headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username, password }) + body: JSON.stringify({ username, password, otp_code }) }); const data = await response.json(); diff --git a/app/contacts/backend/router.py b/app/contacts/backend/router.py index 96ddb52..a6fd305 100644 --- a/app/contacts/backend/router.py +++ b/app/contacts/backend/router.py @@ -148,11 +148,13 @@ async def get_contact(contact_id: int): FROM contacts WHERE id = %s """ - contact = execute_query(contact_query, (contact_id,)) + contact_result = execute_query(contact_query, (contact_id,)) - if not contact: + if not contact_result: raise HTTPException(status_code=404, detail="Contact not found") + contact = contact_result[0] + # Get linked companies companies_query = """ SELECT @@ -163,7 +165,7 @@ async def get_contact(contact_id: int): WHERE cc.contact_id = %s ORDER BY cc.is_primary DESC, cu.name """ - companies = execute_query_single(companies_query, (contact_id,)) # Default is fetchall + companies = execute_query(companies_query, (contact_id,)) contact['companies'] = companies or [] return contact diff --git a/app/core/auth_dependencies.py b/app/core/auth_dependencies.py index 84f4bea..53f57ff 100644 --- a/app/core/auth_dependencies.py +++ b/app/core/auth_dependencies.py @@ -6,16 +6,18 @@ from fastapi import Depends, HTTPException, status, Request from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from typing import Optional from app.core.auth_service import AuthService +from app.core.config import settings +from app.core.database import execute_query_single import logging logger = logging.getLogger(__name__) -security = HTTPBearer() +security = HTTPBearer(auto_error=False) async def get_current_user( request: Request, - credentials: HTTPAuthorizationCredentials = Depends(security) + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security) ) -> dict: """ Dependency to get current authenticated user from JWT token @@ -25,7 +27,13 @@ async def get_current_user( async def my_endpoint(current_user: dict = Depends(get_current_user)): ... """ - token = credentials.credentials + token = credentials.credentials if credentials else request.cookies.get("access_token") + if not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) # Verify token payload = AuthService.verify_token(token) @@ -41,14 +49,27 @@ async def get_current_user( user_id = int(payload.get("sub")) username = payload.get("username") is_superadmin = payload.get("is_superadmin", False) + is_shadow_admin = payload.get("shadow_admin", False) # Add IP address to user info ip_address = request.client.host if request.client else None + if is_shadow_admin: + return { + "id": user_id, + "username": username, + "email": settings.SHADOW_ADMIN_EMAIL, + "full_name": settings.SHADOW_ADMIN_FULL_NAME, + "is_superadmin": True, + "is_shadow_admin": True, + "is_2fa_enabled": True, + "ip_address": ip_address, + "permissions": AuthService.get_all_permissions() + } + # Get additional user details from database - from app.core.database import execute_query user_details = execute_query_single( - "SELECT email, full_name FROM users WHERE id = %s", + "SELECT email, full_name, is_2fa_enabled FROM users WHERE user_id = %s", (user_id,)) return { @@ -57,6 +78,8 @@ async def get_current_user( "email": user_details.get('email') if user_details else None, "full_name": user_details.get('full_name') if user_details else None, "is_superadmin": is_superadmin, + "is_shadow_admin": False, + "is_2fa_enabled": user_details.get('is_2fa_enabled') if user_details else False, "ip_address": ip_address, "permissions": AuthService.get_user_permissions(user_id) } @@ -70,7 +93,7 @@ async def get_optional_user( Dependency to get current user if authenticated, None otherwise Allows endpoints that work both with and without authentication """ - if not credentials: + if not credentials and not request.cookies.get("access_token"): return None try: diff --git a/app/core/auth_service.py b/app/core/auth_service.py index bc4e399..8ccb52e 100644 --- a/app/core/auth_service.py +++ b/app/core/auth_service.py @@ -2,12 +2,14 @@ Authentication Service - Håndterer login, JWT tokens, password hashing Adapted from OmniSync for BMC Hub """ -from typing import Optional, Dict, List +from typing import Optional, Dict, List, Tuple from datetime import datetime, timedelta import hashlib import secrets import jwt -from app.core.database import execute_query, execute_insert, execute_update +import pyotp +from passlib.context import CryptContext +from app.core.database import execute_query, execute_query_single, execute_insert, execute_update from app.core.config import settings import logging @@ -18,6 +20,8 @@ SECRET_KEY = getattr(settings, 'JWT_SECRET_KEY', 'your-secret-key-change-in-prod ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 8 # 8 timer +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + class AuthService: """Service for authentication and authorization""" @@ -25,18 +29,124 @@ class AuthService: @staticmethod def hash_password(password: str) -> str: """ - Hash password using SHA256 - I produktion: Brug bcrypt eller argon2! + Hash password using bcrypt """ - return hashlib.sha256(password.encode()).hexdigest() + return pwd_context.hash(password) @staticmethod def verify_password(plain_password: str, hashed_password: str) -> bool: """Verify password against hash""" - return AuthService.hash_password(plain_password) == hashed_password + if not hashed_password: + return False + if not hashed_password.startswith("$2"): + return False + try: + return pwd_context.verify(plain_password, hashed_password) + except Exception: + return False + + @staticmethod + def verify_legacy_sha256(plain_password: str, hashed_password: str) -> bool: + """Verify legacy SHA256 hash and upgrade when used""" + if not hashed_password or len(hashed_password) != 64: + return False + try: + return hashlib.sha256(plain_password.encode()).hexdigest() == hashed_password + except Exception: + return False + + @staticmethod + def upgrade_password_hash(user_id: int, plain_password: str): + """Upgrade legacy password hash to bcrypt""" + new_hash = AuthService.hash_password(plain_password) + execute_update( + "UPDATE users SET password_hash = %s, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s", + (new_hash, user_id) + ) + + @staticmethod + def verify_totp_code(secret: str, code: str) -> bool: + """Verify TOTP code""" + if not secret or not code: + return False + try: + totp = pyotp.TOTP(secret) + return totp.verify(code, valid_window=1) + except Exception: + return False + + @staticmethod + def generate_2fa_secret() -> str: + """Generate a new TOTP secret""" + return pyotp.random_base32() + + @staticmethod + def get_2fa_provisioning_uri(username: str, secret: str) -> str: + """Generate provisioning URI for authenticator apps""" + totp = pyotp.TOTP(secret) + return totp.provisioning_uri(name=username, issuer_name="BMC Hub") + + @staticmethod + def setup_user_2fa(user_id: int, username: str) -> Dict: + """Create and store a new TOTP secret (not enabled until verified)""" + secret = AuthService.generate_2fa_secret() + execute_update( + "UPDATE users SET totp_secret = %s, is_2fa_enabled = FALSE, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s", + (secret, user_id) + ) + + return { + "secret": secret, + "provisioning_uri": AuthService.get_2fa_provisioning_uri(username, secret) + } + + @staticmethod + def enable_user_2fa(user_id: int, otp_code: str) -> bool: + """Enable 2FA after verifying TOTP code""" + user = execute_query_single( + "SELECT totp_secret FROM users WHERE user_id = %s", + (user_id,) + ) + + if not user or not user.get("totp_secret"): + return False + + if not AuthService.verify_totp_code(user["totp_secret"], otp_code): + return False + + execute_update( + "UPDATE users SET is_2fa_enabled = TRUE, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s", + (user_id,) + ) + return True + + @staticmethod + def disable_user_2fa(user_id: int, otp_code: str) -> bool: + """Disable 2FA after verifying TOTP code""" + user = execute_query_single( + "SELECT totp_secret FROM users WHERE user_id = %s", + (user_id,) + ) + + if not user or not user.get("totp_secret"): + return False + + if not AuthService.verify_totp_code(user["totp_secret"], otp_code): + return False + + execute_update( + "UPDATE users SET is_2fa_enabled = FALSE, totp_secret = NULL, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s", + (user_id,) + ) + return True @staticmethod - def create_access_token(user_id: int, username: str, is_superadmin: bool = False) -> str: + def create_access_token( + user_id: int, + username: str, + is_superadmin: bool = False, + is_shadow_admin: bool = False + ) -> str: """ Create JWT access token @@ -55,6 +165,7 @@ class AuthService: "sub": str(user_id), "username": username, "is_superadmin": is_superadmin, + "shadow_admin": is_shadow_admin, "exp": expire, "iat": datetime.utcnow(), "jti": jti @@ -62,12 +173,13 @@ class AuthService: token = jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM) - # Store session for token revocation - execute_insert( - """INSERT INTO sessions (user_id, token_jti, expires_at) - VALUES (%s, %s, %s)""", - (user_id, jti, expire) - ) + # Store session for token revocation (skip for shadow admin) + if not is_shadow_admin: + execute_insert( + """INSERT INTO sessions (user_id, token_jti, expires_at) + VALUES (%s, %s, %s)""", + (user_id, jti, expire) + ) return token @@ -81,6 +193,9 @@ class AuthService: """ try: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + + if payload.get("shadow_admin"): + return payload # Check if token is revoked jti = payload.get('jti') @@ -102,7 +217,12 @@ class AuthService: return None @staticmethod - def authenticate_user(username: str, password: str, ip_address: Optional[str] = None) -> Optional[Dict]: + def authenticate_user( + username: str, + password: str, + ip_address: Optional[str] = None, + otp_code: Optional[str] = None + ) -> Tuple[Optional[Dict], Optional[str]]: """ Authenticate user with username/password @@ -114,38 +234,70 @@ class AuthService: Returns: User dict if successful, None otherwise """ + # Shadow Admin shortcut + if settings.SHADOW_ADMIN_ENABLED and username == settings.SHADOW_ADMIN_USERNAME: + if not settings.SHADOW_ADMIN_PASSWORD or not settings.SHADOW_ADMIN_TOTP_SECRET: + logger.error("❌ Shadow admin enabled but not configured") + return None, "Shadow admin not configured" + + if not secrets.compare_digest(password, settings.SHADOW_ADMIN_PASSWORD): + logger.warning(f"❌ Shadow admin login failed from IP: {ip_address}") + return None, "Invalid username or password" + + if not otp_code: + return None, "2FA code required" + + if not AuthService.verify_totp_code(settings.SHADOW_ADMIN_TOTP_SECRET, otp_code): + logger.warning(f"❌ Shadow admin 2FA failed from IP: {ip_address}") + return None, "Invalid 2FA code" + + logger.warning(f"⚠️ Shadow admin login used from IP: {ip_address}") + return { + "user_id": 0, + "username": settings.SHADOW_ADMIN_USERNAME, + "email": settings.SHADOW_ADMIN_EMAIL, + "full_name": settings.SHADOW_ADMIN_FULL_NAME, + "is_superadmin": True, + "is_shadow_admin": True + }, None + # Get user user = execute_query_single( - """SELECT id, username, email, password_hash, full_name, - is_active, is_superadmin, failed_login_attempts, locked_until - FROM users + """SELECT user_id, username, email, password_hash, full_name, + is_active, is_superadmin, failed_login_attempts, locked_until, + is_2fa_enabled, totp_secret + FROM users WHERE username = %s OR email = %s""", (username, username)) if not user: logger.warning(f"❌ Login failed: User not found - {username}") - return None + return None, "Invalid username or password" # Check if account is active if not user['is_active']: logger.warning(f"❌ Login failed: Account disabled - {username}") - return None + return None, "Account disabled" # Check if account is locked if user['locked_until']: locked_until = user['locked_until'] if datetime.now() < locked_until: logger.warning(f"❌ Login failed: Account locked - {username}") - return None + return None, "Account locked" else: # Unlock account execute_update( - "UPDATE users SET locked_until = NULL, failed_login_attempts = 0 WHERE id = %s", - (user['id'],) + "UPDATE users SET locked_until = NULL, failed_login_attempts = 0 WHERE user_id = %s", + (user['user_id'],) ) # Verify password - if not AuthService.verify_password(password, user['password_hash']): + if AuthService.verify_password(password, user['password_hash']): + pass + elif AuthService.verify_legacy_sha256(password, user['password_hash']): + AuthService.upgrade_password_hash(user['user_id'], password) + else: # Increment failed attempts failed_attempts = user['failed_login_attempts'] + 1 @@ -155,18 +307,30 @@ class AuthService: execute_update( """UPDATE users SET failed_login_attempts = %s, locked_until = %s - WHERE id = %s""", - (failed_attempts, locked_until, user['id']) + WHERE user_id = %s""", + (failed_attempts, locked_until, user['user_id']) ) logger.warning(f"🔒 Account locked due to failed attempts: {username}") else: execute_update( - "UPDATE users SET failed_login_attempts = %s WHERE id = %s", - (failed_attempts, user['id']) + "UPDATE users SET failed_login_attempts = %s WHERE user_id = %s", + (failed_attempts, user['user_id']) ) logger.warning(f"❌ Login failed: Invalid password - {username} (attempt {failed_attempts})") - return None + return None, "Invalid username or password" + + # 2FA check + if user.get('is_2fa_enabled'): + if not user.get('totp_secret'): + return None, "2FA not configured" + + if not otp_code: + return None, "2FA code required" + + if not AuthService.verify_totp_code(user['totp_secret'], otp_code): + logger.warning(f"❌ Login failed: Invalid 2FA - {username}") + return None, "Invalid 2FA code" # Success! Reset failed attempts and update last login execute_update( @@ -174,28 +338,47 @@ class AuthService: SET failed_login_attempts = 0, locked_until = NULL, last_login_at = CURRENT_TIMESTAMP - WHERE id = %s""", - (user['id'],) + WHERE user_id = %s""", + (user['user_id'],) ) logger.info(f"✅ User logged in: {username} from IP: {ip_address}") return { - 'user_id': user['id'], + 'user_id': user['user_id'], 'username': user['username'], 'email': user['email'], 'full_name': user['full_name'], - 'is_superadmin': bool(user['is_superadmin']) - } + 'is_superadmin': bool(user['is_superadmin']), + 'is_shadow_admin': False + }, None @staticmethod - def revoke_token(jti: str, user_id: int): + def revoke_token(jti: str, user_id: int, is_shadow_admin: bool = False): """Revoke a JWT token""" + if is_shadow_admin: + logger.info("🔒 Shadow admin logout - no session to revoke") + return execute_update( "UPDATE sessions SET revoked = TRUE WHERE token_jti = %s AND user_id = %s", (jti, user_id) ) logger.info(f"🔒 Token revoked for user {user_id}") + + @staticmethod + def get_all_permissions() -> List[str]: + """Get all permission codes""" + perms = execute_query("SELECT code FROM permissions") + return [p['code'] for p in perms] if perms else [] + + @staticmethod + def is_user_2fa_enabled(user_id: int) -> bool: + """Check if user has 2FA enabled""" + user = execute_query_single( + "SELECT is_2fa_enabled FROM users WHERE user_id = %s", + (user_id,) + ) + return bool(user and user.get("is_2fa_enabled")) @staticmethod def get_user_permissions(user_id: int) -> List[str]: @@ -210,13 +393,12 @@ class AuthService: """ # Check if user is superadmin first user = execute_query_single( - "SELECT is_superadmin FROM users WHERE id = %s", + "SELECT is_superadmin FROM users WHERE user_id = %s", (user_id,)) # Superadmins have all permissions if user and user['is_superadmin']: - all_perms = execute_query_single("SELECT code FROM permissions") - return [p['code'] for p in all_perms] if all_perms else [] + return AuthService.get_all_permissions() # Get permissions through groups perms = execute_query(""" @@ -242,8 +424,8 @@ class AuthService: True if user has permission """ # Superadmins have all permissions - user = execute_query( - "SELECT is_superadmin FROM users WHERE id = %s", + user = execute_query_single( + "SELECT is_superadmin FROM users WHERE user_id = %s", (user_id,)) if user and user['is_superadmin']: @@ -279,7 +461,7 @@ class AuthService: user_id = execute_insert( """INSERT INTO users (username, email, password_hash, full_name, is_superadmin) - VALUES (%s, %s, %s, %s, %s) RETURNING id""", + VALUES (%s, %s, %s, %s, %s) RETURNING user_id""", (username, email, password_hash, full_name, is_superadmin) ) @@ -292,7 +474,7 @@ class AuthService: password_hash = AuthService.hash_password(new_password) execute_update( - "UPDATE users SET password_hash = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s", + "UPDATE users SET password_hash = %s, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s", (password_hash, user_id) ) diff --git a/app/core/config.py b/app/core/config.py index 69727ad..1a67ddf 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -29,6 +29,14 @@ class Settings(BaseSettings): SECRET_KEY: str = "dev-secret-key-change-in-production" ALLOWED_ORIGINS: List[str] = ["http://localhost:8000", "http://localhost:3000"] CORS_ORIGINS: str = "http://localhost:8000,http://localhost:3000" + + # Shadow Admin (emergency access) + SHADOW_ADMIN_ENABLED: bool = False + SHADOW_ADMIN_USERNAME: str = "shadowadmin" + SHADOW_ADMIN_PASSWORD: str = "" + SHADOW_ADMIN_TOTP_SECRET: str = "" + SHADOW_ADMIN_EMAIL: str = "shadowadmin@bmcnetworks.dk" + SHADOW_ADMIN_FULL_NAME: str = "Shadow Administrator" # Logging LOG_LEVEL: str = "INFO" @@ -41,6 +49,13 @@ class Settings(BaseSettings): ECONOMIC_AGREEMENT_GRANT_TOKEN: str = "" ECONOMIC_READ_ONLY: bool = True ECONOMIC_DRY_RUN: bool = True + + # Nextcloud Integration + NEXTCLOUD_READ_ONLY: bool = True + NEXTCLOUD_DRY_RUN: bool = True + NEXTCLOUD_TIMEOUT_SECONDS: int = 15 + NEXTCLOUD_CACHE_TTL_SECONDS: int = 300 + NEXTCLOUD_ENCRYPTION_KEY: str = "" # Ollama LLM OLLAMA_ENDPOINT: str = "http://localhost:11434" diff --git a/app/core/crypto.py b/app/core/crypto.py new file mode 100644 index 0000000..2f39efc --- /dev/null +++ b/app/core/crypto.py @@ -0,0 +1,31 @@ +""" +Crypto helpers for encrypting/decrypting secrets at rest. +""" + +import logging +from typing import Optional +from cryptography.fernet import Fernet, InvalidToken + +from app.core.config import settings + +logger = logging.getLogger(__name__) + + +def _get_fernet() -> Fernet: + if not settings.NEXTCLOUD_ENCRYPTION_KEY: + raise ValueError("NEXTCLOUD_ENCRYPTION_KEY not configured") + return Fernet(settings.NEXTCLOUD_ENCRYPTION_KEY.encode()) + + +def encrypt_secret(value: str) -> str: + fernet = _get_fernet() + return fernet.encrypt(value.encode()).decode() + + +def decrypt_secret(value: str) -> Optional[str]: + try: + fernet = _get_fernet() + return fernet.decrypt(value.encode()).decode() + except (InvalidToken, ValueError) as exc: + logger.error("❌ Nextcloud credential decryption failed: %s", exc) + return None diff --git a/app/core/database.py b/app/core/database.py index 9087723..03f715e 100644 --- a/app/core/database.py +++ b/app/core/database.py @@ -68,19 +68,18 @@ def execute_query(query: str, params: tuple = None, fetch: bool = True): cursor.execute(query, params) # Auto-detect write operations and commit - query_upper = query.strip().upper() - is_write = query_upper.startswith(('INSERT', 'UPDATE', 'DELETE')) + # Robust detection handling comments and whitespace + clean_query = "\n".join([line for line in query.split("\n") if not line.strip().startswith("--")]).strip().upper() + is_write = clean_query.startswith(('INSERT', 'UPDATE', 'DELETE', 'CREATE', 'ALTER', 'DROP', 'TRUNCATE', 'COMMENT')) if is_write: conn.commit() - # Only fetch if there are results to fetch - # (SELECT queries or INSERT/UPDATE/DELETE with RETURNING clause) - if fetch and (not is_write or 'RETURNING' in query_upper): + # Only fetch if there are results to fetch (cursor.description is not None) + if cursor.description: return cursor.fetchall() - elif is_write: - return cursor.rowcount - return [] + + return cursor.rowcount except Exception as e: conn.rollback() logger.error(f"Query error: {e}") diff --git a/app/customers/frontend/customer_detail.html b/app/customers/frontend/customer_detail.html index 27557ea..3c84165 100644 --- a/app/customers/frontend/customer_detail.html +++ b/app/customers/frontend/customer_detail.html @@ -316,6 +316,11 @@ Hardware +
+ +
+
+
+
Tags
+ +
+
+
Ingen tags tilføjet endnu.
+
+
@@ -600,6 +618,11 @@ + +
+ {% include "modules/nextcloud/templates/tab.html" %} +
+
Aktivitet
@@ -749,6 +772,28 @@
+ + +