diff --git a/PRODUCTIZATION_ROADMAP.md b/PRODUCTIZATION_ROADMAP.md index c05a29e..cb4480a 100644 --- a/PRODUCTIZATION_ROADMAP.md +++ b/PRODUCTIZATION_ROADMAP.md @@ -16,6 +16,7 @@ Current branch roles: 3. Start as single-tenant configurable, not full multi-tenant. 4. Make branding and document identity admin-managed, not code-managed. 5. Add new business apps only after the core platform layer is standardized. +6. Prefer inline editing for lightweight profile and configuration data, but keep explicit forms for sensitive or high-risk settings. ## Product Layers @@ -213,3 +214,35 @@ This is the first productization slice because it gives: - keep migrations backward-compatible - update both wiki and developer handbook for every architecture change - snapshot at the end of each major phase + +## Shared UI Pattern: Inline Editing + +Use inline editing as a platform pattern where it improves speed without weakening clarity or safety. + +Good candidates: + +- user profile and contact data +- company config sections +- branding text and non-sensitive metadata +- low-risk app-registry metadata + +Do not use it by default for: + +- credentials and secrets +- integrations with side effects +- destructive actions +- multi-step workflow forms +- settings that need heavy validation or confirmation + +Preferred implementation style: + +- section-level inline editing +- explicit `Bearbeiten`, `Speichern`, `Abbrechen` +- no noisy per-field autosave +- clear view mode and edit mode separation + +Reason: + +- keeps Workdock faster and more product-grade +- avoids large admin-style forms for simple edits +- still preserves reliable validation and safer change boundaries diff --git a/backend/locale/en/LC_MESSAGES/django.mo b/backend/locale/en/LC_MESSAGES/django.mo index b04c453..612fd69 100644 Binary files a/backend/locale/en/LC_MESSAGES/django.mo and b/backend/locale/en/LC_MESSAGES/django.mo differ diff --git a/backend/locale/en/LC_MESSAGES/django.po b/backend/locale/en/LC_MESSAGES/django.po index a94e56c..99412a3 100644 --- a/backend/locale/en/LC_MESSAGES/django.po +++ b/backend/locale/en/LC_MESSAGES/django.po @@ -2,14 +2,14 @@ msgid "" msgstr "" "Project-Id-Version: tubco-portal\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-03-27 01:04+0000\n" +"POT-Creation-Date: 2026-03-27 01:45+0000\n" "PO-Revision-Date: 2026-03-24 00:00+0000\n" "Language: en\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: workflows/app_registry.py:35 workflows/models.py:373 workflows/models.py:454 +#: workflows/app_registry.py:35 workflows/models.py:389 workflows/models.py:470 #: workflows/templates/workflows/onboarding_form.html:25 #: workflows/templates/workflows/requests_dashboard.html:68 #: workflows/templates/workflows/requests_dashboard.html:131 @@ -36,7 +36,7 @@ msgstr "Multi-step form" msgid "E-Mail Routing" msgstr "Email routing" -#: workflows/app_registry.py:46 workflows/models.py:374 workflows/models.py:455 +#: workflows/app_registry.py:46 workflows/models.py:390 workflows/models.py:471 #: workflows/templates/workflows/requests_dashboard.html:78 #: workflows/templates/workflows/requests_dashboard.html:132 msgid "Offboarding" @@ -85,12 +85,13 @@ msgid "Dashboard öffnen" msgstr "Open dashboard" #: workflows/app_registry.py:62 -#: workflows/templates/workflows/app_registry.html:27 +#: workflows/templates/workflows/app_registry.html:28 msgid "Suche" msgstr "Search" #: workflows/app_registry.py:62 -#: workflows/templates/workflows/app_registry.html:31 +#: workflows/templates/workflows/account_profile.html:218 +#: workflows/templates/workflows/app_registry.html:32 #: workflows/templates/workflows/backup_recovery.html:72 #: workflows/templates/workflows/job_monitor.html:29 #: workflows/templates/workflows/job_monitor.html:50 @@ -151,8 +152,8 @@ msgid "Logo, Portalname, Farben und PDF-Briefkopf verwalten." msgstr "Manage logo, portal name, colors, and PDF letterhead." #: workflows/app_registry.py:95 -#: workflows/templates/workflows/app_registry.html:5 -#: workflows/templates/workflows/app_registry.html:13 +#: workflows/templates/workflows/app_registry.html:6 +#: workflows/templates/workflows/app_registry.html:14 msgid "App Registry" msgstr "" @@ -258,13 +259,13 @@ msgid "Nur Platform" msgstr "" #: workflows/app_registry.py:311 -#: workflows/templates/workflows/app_registry.html:85 +#: workflows/templates/workflows/app_registry.html:107 msgid "Alle Firmenrollen" msgstr "" -#: workflows/app_registry.py:317 workflows/models.py:150 -#: workflows/templates/workflows/app_registry.html:43 -#: workflows/templates/workflows/app_registry.html:78 +#: workflows/app_registry.py:317 workflows/models.py:166 +#: workflows/templates/workflows/app_registry.html:44 +#: workflows/templates/workflows/app_registry.html:100 msgid "Apps" msgstr "Apps" @@ -272,9 +273,9 @@ msgstr "Apps" msgid "Wählen Sie den gewünschten Prozess." msgstr "Choose the desired process." -#: workflows/app_registry.py:323 workflows/models.py:151 -#: workflows/templates/workflows/app_registry.html:44 -#: workflows/templates/workflows/app_registry.html:74 +#: workflows/app_registry.py:323 workflows/models.py:167 +#: workflows/templates/workflows/app_registry.html:45 +#: workflows/templates/workflows/app_registry.html:96 msgid "Platform Apps" msgstr "" @@ -284,9 +285,9 @@ msgstr "" msgid "Produktweite Konfiguration und Produktsteuerung." msgstr "Configuration, tests, and controls." -#: workflows/app_registry.py:329 workflows/models.py:152 -#: workflows/templates/workflows/app_registry.html:45 -#: workflows/templates/workflows/app_registry.html:76 +#: workflows/app_registry.py:329 workflows/models.py:168 +#: workflows/templates/workflows/app_registry.html:46 +#: workflows/templates/workflows/app_registry.html:98 msgid "Admin Apps" msgstr "Admin Apps" @@ -383,76 +384,88 @@ msgstr "" msgid "Remote Backup in Nextcloud konnte nicht gelöscht werden." msgstr "" -#: workflows/forms.py:104 workflows/forms.py:227 +#: workflows/forms.py:106 workflows/forms.py:326 #: workflows/templates/workflows/account_profile.html:66 #: workflows/templates/workflows/user_management.html:72 #: workflows/templates/workflows/user_management.html:170 msgid "Benutzername" msgstr "" -#: workflows/forms.py:105 +#: workflows/forms.py:107 msgid "Passwort" msgstr "Password" -#: workflows/forms.py:109 workflows/forms.py:173 workflows/forms.py:228 +#: workflows/forms.py:109 workflows/forms.py:265 workflows/forms.py:297 +msgid "TOTP-Code" +msgstr "" + +#: workflows/forms.py:117 workflows/forms.py:286 workflows/forms.py:319 +msgid "Der TOTP-Code ist ungültig." +msgstr "" + +#: workflows/forms.py:118 +msgid "Bitte geben Sie Ihren TOTP-Code ein." +msgstr "" + +#: workflows/forms.py:143 workflows/forms.py:207 workflows/forms.py:327 #, fuzzy #| msgid "E-Mail" msgid "E-Mail-Adresse" msgstr "Email" -#: workflows/forms.py:114 workflows/forms.py:133 +#: workflows/forms.py:148 workflows/forms.py:167 #: workflows/templates/workflows/user_management.html:77 #: workflows/templates/workflows/user_management.html:108 msgid "Neues Passwort" msgstr "New password" -#: workflows/forms.py:120 workflows/forms.py:139 +#: workflows/forms.py:154 workflows/forms.py:173 msgid "Neues Passwort bestätigen" msgstr "Confirm new password" -#: workflows/forms.py:128 +#: workflows/forms.py:162 workflows/forms.py:260 workflows/forms.py:292 #, fuzzy #| msgid "Neues Passwort" msgid "Aktuelles Passwort" msgstr "New password" -#: workflows/forms.py:150 workflows/templates/workflows/account_profile.html:36 +#: workflows/forms.py:184 workflows/templates/workflows/account_profile.html:36 #: workflows/templates/workflows/includes/app_header.html:27 msgid "Profilbild" msgstr "" -#: workflows/forms.py:166 +#: workflows/forms.py:200 msgid "Das Profilbild darf maximal 5 MB groß sein." msgstr "" -#: workflows/forms.py:171 workflows/forms.py:225 +#: workflows/forms.py:205 workflows/forms.py:324 #: workflows/templates/workflows/account_profile.html:112 msgid "Vorname" msgstr "" -#: workflows/forms.py:172 workflows/forms.py:226 +#: workflows/forms.py:206 workflows/forms.py:325 #: workflows/templates/workflows/account_profile.html:116 msgid "Nachname" msgstr "" -#: workflows/forms.py:174 +#: workflows/forms.py:208 #: workflows/templates/workflows/account_profile.html:120 msgid "Telefon" msgstr "" -#: workflows/forms.py:175 +#: workflows/forms.py:209 #: workflows/templates/workflows/account_profile.html:124 msgid "Mobil" msgstr "" -#: workflows/forms.py:176 workflows/templates/workflows/account_profile.html:70 +#: workflows/forms.py:210 workflows/templates/workflows/account_profile.html:70 #: workflows/templates/workflows/account_profile.html:128 #, fuzzy #| msgid "Produktion" msgid "Position" msgstr "Production" -#: workflows/forms.py:177 workflows/models.py:334 +#: workflows/forms.py:211 workflows/models.py:350 #: workflows/templates/workflows/account_profile.html:74 #: workflows/templates/workflows/account_profile.html:132 #: workflows/templates/workflows/onboarding_intro_session.html:28 @@ -460,19 +473,27 @@ msgstr "Production" msgid "Abteilung" msgstr "Department" -#: workflows/forms.py:178 +#: workflows/forms.py:212 #: workflows/templates/workflows/account_profile.html:136 msgid "Standort" msgstr "" -#: workflows/forms.py:180 +#: workflows/forms.py:214 #: workflows/templates/workflows/account_profile.html:140 #, fuzzy #| msgid "Einweisung" msgid "Hinweise" msgstr "Introduction" -#: workflows/forms.py:229 workflows/templates/workflows/account_profile.html:62 +#: workflows/forms.py:278 workflows/forms.py:310 +msgid "Das aktuelle Passwort ist nicht korrekt." +msgstr "" + +#: workflows/forms.py:284 workflows/forms.py:316 +msgid "Bitte geben Sie einen gültigen TOTP-Code ein." +msgstr "" + +#: workflows/forms.py:328 workflows/templates/workflows/account_profile.html:62 #: workflows/templates/workflows/user_management.html:74 #: workflows/templates/workflows/user_management.html:93 #: workflows/templates/workflows/user_management.html:171 @@ -481,205 +502,207 @@ msgstr "Introduction" msgid "Rolle" msgstr "Role:" -#: workflows/forms.py:243 +#: workflows/forms.py:342 msgid "Dieser Benutzername ist bereits vergeben." msgstr "This username is already taken." -#: workflows/forms.py:252 workflows/views.py:807 +#: workflows/forms.py:351 workflows/views.py:987 msgid "Ungültige Rolle." msgstr "Invalid role." -#: workflows/forms.py:254 workflows/views.py:810 +#: workflows/forms.py:353 workflows/views.py:990 msgid "Nur Platform Owner dürfen diese Rolle vergeben." msgstr "" -#: workflows/forms.py:293 +#: workflows/forms.py:392 msgid "Portal-Titel" msgstr "Portal title" -#: workflows/forms.py:294 +#: workflows/forms.py:393 msgid "Firmenname" msgstr "Company name" -#: workflows/forms.py:295 +#: workflows/forms.py:394 #, fuzzy #| msgid "Firmenname" msgid "Firmen-Domain" msgstr "Company name" -#: workflows/forms.py:296 +#: workflows/forms.py:395 msgid "Support-E-Mail" msgstr "Support email" -#: workflows/forms.py:297 +#: workflows/forms.py:396 msgid "Absender-Anzeigename" msgstr "" -#: workflows/forms.py:298 +#: workflows/forms.py:397 msgid "Login-Untertitel" msgstr "" -#: workflows/forms.py:299 +#: workflows/forms.py:398 msgid "Footer-Text DE" msgstr "" -#: workflows/forms.py:300 +#: workflows/forms.py:399 msgid "Footer-Text EN" msgstr "" -#: workflows/forms.py:301 +#: workflows/forms.py:400 msgid "Rechtlicher Hinweis DE" msgstr "" -#: workflows/forms.py:302 +#: workflows/forms.py:401 msgid "Rechtlicher Hinweis EN" msgstr "" -#: workflows/forms.py:303 +#: workflows/forms.py:402 msgid "Standardsprache" msgstr "Default language" -#: workflows/forms.py:304 +#: workflows/forms.py:403 msgid "Logo" msgstr "Logo" -#: workflows/forms.py:305 +#: workflows/forms.py:404 msgid "PDF-Briefkopf" msgstr "PDF letterhead" -#: workflows/forms.py:306 +#: workflows/forms.py:405 msgid "Favicon" msgstr "" -#: workflows/forms.py:307 -#: workflows/templates/workflows/branding_settings.html:94 +#: workflows/forms.py:406 +#: workflows/templates/workflows/branding_settings.html:89 +#: workflows/templates/workflows/branding_settings.html:162 msgid "Primärfarbe" msgstr "Primary color" -#: workflows/forms.py:308 -#: workflows/templates/workflows/branding_settings.html:95 +#: workflows/forms.py:407 +#: workflows/templates/workflows/branding_settings.html:90 +#: workflows/templates/workflows/branding_settings.html:163 msgid "Sekundärfarbe" msgstr "Secondary color" -#: workflows/forms.py:325 +#: workflows/forms.py:424 msgid "Das Logo darf maximal 5 MB groß sein." msgstr "" -#: workflows/forms.py:333 +#: workflows/forms.py:432 msgid "Der PDF-Briefkopf darf maximal 10 MB groß sein." msgstr "" -#: workflows/forms.py:341 +#: workflows/forms.py:440 msgid "Das Favicon darf maximal 2 MB groß sein." msgstr "" -#: workflows/forms.py:365 +#: workflows/forms.py:464 #, fuzzy #| msgid "Firmenname" msgid "Rechtlicher Firmenname" msgstr "Company name" -#: workflows/forms.py:366 +#: workflows/forms.py:465 msgid "Straße und Hausnummer" msgstr "" -#: workflows/forms.py:367 +#: workflows/forms.py:466 msgid "Postleitzahl" msgstr "" -#: workflows/forms.py:368 +#: workflows/forms.py:467 msgid "Stadt" msgstr "" -#: workflows/forms.py:369 +#: workflows/forms.py:468 msgid "Land" msgstr "" -#: workflows/forms.py:370 workflows/templates/workflows/base_shell.html:64 +#: workflows/forms.py:469 workflows/templates/workflows/base_shell.html:64 msgid "Website" msgstr "" -#: workflows/forms.py:371 +#: workflows/forms.py:470 msgid "Impressum-URL" msgstr "" -#: workflows/forms.py:372 +#: workflows/forms.py:471 msgid "Datenschutz-URL" msgstr "" -#: workflows/forms.py:373 +#: workflows/forms.py:472 msgid "HR-Kontakt" msgstr "" -#: workflows/forms.py:374 +#: workflows/forms.py:473 msgid "IT-Kontakt" msgstr "" -#: workflows/forms.py:375 +#: workflows/forms.py:474 #, fuzzy #| msgid "Operations" msgid "Operations-Kontakt" msgstr "Operations" -#: workflows/forms.py:376 +#: workflows/forms.py:475 msgid "Zentrale Telefonnummer" msgstr "" -#: workflows/forms.py:377 +#: workflows/forms.py:476 msgid "USt-IdNr." msgstr "" -#: workflows/forms.py:378 +#: workflows/forms.py:477 msgid "Register- oder Handelsnummer" msgstr "" -#: workflows/forms.py:395 +#: workflows/forms.py:494 msgid "Trial-Modus aktiv" msgstr "" -#: workflows/forms.py:396 +#: workflows/forms.py:495 msgid "Trial-Beginn" msgstr "" -#: workflows/forms.py:397 +#: workflows/forms.py:496 msgid "Trial-Ende" msgstr "" -#: workflows/forms.py:398 +#: workflows/forms.py:497 msgid "Produktive Integrationen begrenzen" msgstr "" -#: workflows/forms.py:399 +#: workflows/forms.py:498 msgid "Cleanup nach Ablauf zulassen" msgstr "" -#: workflows/forms.py:400 +#: workflows/forms.py:499 msgid "Banner-Text DE" msgstr "" -#: workflows/forms.py:401 +#: workflows/forms.py:500 msgid "Banner-Text EN" msgstr "" -#: workflows/forms.py:421 +#: workflows/forms.py:520 msgid "Bitte ein Trial-Ende festlegen." msgstr "" -#: workflows/forms.py:423 +#: workflows/forms.py:522 msgid "Das Trial-Ende muss nach dem Trial-Beginn liegen." msgstr "" -#: workflows/forms.py:562 workflows/forms.py:747 +#: workflows/forms.py:661 workflows/forms.py:846 #, python-format msgid "Bitte nutzen Sie das Format name@%(domain)s." msgstr "" -#: workflows/forms.py:584 workflows/forms.py:761 +#: workflows/forms.py:683 workflows/forms.py:860 #, python-format msgid "Bitte verwenden Sie eine @%(domain)s E-Mail-Adresse." msgstr "" -#: workflows/forms.py:669 +#: workflows/forms.py:768 #, python-format msgid "" "Das Übergabedatum muss mindestens %(days)s Tage in der Zukunft liegen " @@ -731,251 +754,251 @@ msgid "" "ausführen." msgstr "" -#: workflows/models.py:199 workflows/views.py:420 +#: workflows/models.py:215 workflows/views.py:460 #, fuzzy #| msgid "Gesamtbestand" msgid "Gestartet" msgstr "Total records" -#: workflows/models.py:200 workflows/views.py:420 +#: workflows/models.py:216 workflows/views.py:460 #, fuzzy #| msgid "Eingereicht" msgid "Erfolgreich" msgstr "Submitted" -#: workflows/models.py:201 workflows/models.py:254 workflows/models.py:508 +#: workflows/models.py:217 workflows/models.py:270 workflows/models.py:524 #: workflows/templates/workflows/backup_recovery.html:102 #: workflows/templates/workflows/requests_dashboard.html:222 -#: workflows/templates/workflows/welcome_emails.html:108 workflows/views.py:246 -#: workflows/views.py:420 +#: workflows/templates/workflows/welcome_emails.html:108 workflows/views.py:286 +#: workflows/views.py:460 msgid "Fehlgeschlagen" msgstr "Failed" -#: workflows/models.py:251 workflows/views.py:243 +#: workflows/models.py:267 workflows/views.py:283 msgid "Eingereicht" msgstr "Submitted" -#: workflows/models.py:252 workflows/views.py:244 +#: workflows/models.py:268 workflows/views.py:284 msgid "In Bearbeitung" msgstr "Processing" -#: workflows/models.py:253 workflows/models.py:568 workflows/views.py:245 +#: workflows/models.py:269 workflows/models.py:584 workflows/views.py:285 msgid "Abgeschlossen" msgstr "Completed" -#: workflows/models.py:261 +#: workflows/models.py:277 msgid "Herr" msgstr "" -#: workflows/models.py:261 +#: workflows/models.py:277 msgid "Frau" msgstr "" -#: workflows/models.py:261 +#: workflows/models.py:277 msgid "Divers" msgstr "" -#: workflows/models.py:271 +#: workflows/models.py:287 msgid "befristet" msgstr "" -#: workflows/models.py:271 +#: workflows/models.py:287 msgid "unbefristet" msgstr "" -#: workflows/models.py:335 +#: workflows/models.py:351 msgid "Geräte" msgstr "" -#: workflows/models.py:336 +#: workflows/models.py:352 msgid "Software" msgstr "" -#: workflows/models.py:337 +#: workflows/models.py:353 #, fuzzy #| msgid "Vorgänge" msgid "Zugänge" msgstr "Requests" -#: workflows/models.py:338 +#: workflows/models.py:354 msgid "Workspace-Gruppen" msgstr "" -#: workflows/models.py:339 +#: workflows/models.py:355 msgid "Ressourcen" msgstr "" -#: workflows/models.py:340 +#: workflows/models.py:356 msgid "Telefonnummern" msgstr "" -#: workflows/models.py:366 +#: workflows/models.py:382 msgid "Automatisch" msgstr "" -#: workflows/models.py:367 workflows/views.py:101 +#: workflows/models.py:383 workflows/views.py:102 msgid "Stammdaten" msgstr "Master data" -#: workflows/models.py:368 workflows/views.py:102 +#: workflows/models.py:384 workflows/views.py:103 msgid "Vertrag" msgstr "Contract" -#: workflows/models.py:369 workflows/views.py:103 +#: workflows/models.py:385 workflows/views.py:104 msgid "IT-Setup" msgstr "IT setup" -#: workflows/models.py:370 workflows/views.py:104 +#: workflows/models.py:386 workflows/views.py:105 msgid "Abschluss" msgstr "Finish" -#: workflows/models.py:412 +#: workflows/models.py:428 #, fuzzy #| msgid "Onboarding" msgid "Onboarding: IT" msgstr "Onboarding" -#: workflows/models.py:413 +#: workflows/models.py:429 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Onboarding: Allgemeine Info" msgstr "Save offboarding request" -#: workflows/models.py:414 +#: workflows/models.py:430 #, fuzzy #| msgid "Onboarding starten" msgid "Onboarding: Visitenkarte" msgstr "Start onboarding" -#: workflows/models.py:415 +#: workflows/models.py:431 #, fuzzy #| msgid "Onboarding" msgid "Onboarding: HR Works" msgstr "Onboarding" -#: workflows/models.py:416 +#: workflows/models.py:432 #, fuzzy #| msgid "Onboarding starten" msgid "Onboarding: Schlüssel" msgstr "Start onboarding" -#: workflows/models.py:417 +#: workflows/models.py:433 msgid "Onboarding: Referenz Anfordernde Person" msgstr "" -#: workflows/models.py:418 +#: workflows/models.py:434 #, fuzzy #| msgid "Welcome E-Mails" msgid "Onboarding: Welcome E-Mail" msgstr "Welcome Emails" -#: workflows/models.py:419 +#: workflows/models.py:435 #, fuzzy #| msgid "Offboarding" msgid "Offboarding: IT" msgstr "Offboarding" -#: workflows/models.py:420 +#: workflows/models.py:436 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Offboarding: Allgemeine Info" msgstr "Save offboarding request" -#: workflows/models.py:421 +#: workflows/models.py:437 #, fuzzy #| msgid "Offboarding starten" msgid "Offboarding: HR Works Deaktivierung" msgstr "Start offboarding" -#: workflows/models.py:422 +#: workflows/models.py:438 msgid "Offboarding: Referenz Anfordernde Person" msgstr "" -#: workflows/models.py:458 +#: workflows/models.py:474 msgid "Immer" msgstr "" -#: workflows/models.py:459 workflows/models.py:537 +#: workflows/models.py:475 workflows/models.py:553 msgid "Enthält" msgstr "" -#: workflows/models.py:460 workflows/models.py:538 +#: workflows/models.py:476 workflows/models.py:554 msgid "Ist gleich" msgstr "" -#: workflows/models.py:461 +#: workflows/models.py:477 msgid "Ist aktiv/Ja" msgstr "" -#: workflows/models.py:462 +#: workflows/models.py:478 #, fuzzy #| msgid "inaktiv" msgid "Ist inaktiv/Nein" msgstr "inactive" -#: workflows/models.py:504 +#: workflows/models.py:520 #: workflows/templates/workflows/welcome_emails.html:100 msgid "Geplant" msgstr "Scheduled" -#: workflows/models.py:505 +#: workflows/models.py:521 #: workflows/templates/workflows/welcome_emails.html:102 msgid "Pausiert" msgstr "Paused" -#: workflows/models.py:506 +#: workflows/models.py:522 #: workflows/templates/workflows/welcome_emails.html:104 msgid "Abgebrochen" msgstr "Cancelled" -#: workflows/models.py:507 +#: workflows/models.py:523 #: workflows/templates/workflows/welcome_emails.html:106 msgid "Gesendet" msgstr "Sent" -#: workflows/models.py:530 workflows/tasks.py:600 +#: workflows/models.py:546 workflows/tasks.py:600 msgid "Geräte und Arbeitsplatz" msgstr "Devices and workplace" -#: workflows/models.py:531 workflows/tasks.py:601 +#: workflows/models.py:547 workflows/tasks.py:601 msgid "Konten und Berechtigungen" msgstr "Accounts and permissions" -#: workflows/models.py:532 workflows/tasks.py:602 +#: workflows/models.py:548 workflows/tasks.py:602 msgid "Software und Tools" msgstr "Software and tools" -#: workflows/models.py:533 workflows/tasks.py:603 +#: workflows/models.py:549 workflows/tasks.py:603 msgid "Prozesse und Hinweise" msgstr "Processes and notes" -#: workflows/models.py:536 +#: workflows/models.py:552 msgid "Immer anzeigen" msgstr "Always show" -#: workflows/models.py:539 +#: workflows/models.py:555 msgid "Ist Ja / aktiv" msgstr "Is yes / active" -#: workflows/models.py:540 +#: workflows/models.py:556 msgid "Ist Nein / inaktiv" msgstr "Is no / inactive" -#: workflows/models.py:567 +#: workflows/models.py:583 msgid "Entwurf" msgstr "Draft" -#: workflows/models.py:587 +#: workflows/models.py:603 #, fuzzy #| msgid "Nextcloud:" msgid "Nextcloud" msgstr "Nextcloud:" -#: workflows/models.py:588 +#: workflows/models.py:604 msgid "S3" msgstr "" -#: workflows/models.py:589 +#: workflows/models.py:605 msgid "NFS" msgstr "" @@ -983,18 +1006,18 @@ msgstr "" msgid "Platform Owner" msgstr "" -#: workflows/roles.py:27 workflows/templates/workflows/app_registry.html:88 -#: workflows/templates/workflows/app_registry.html:114 +#: workflows/roles.py:27 workflows/templates/workflows/app_registry.html:110 +#: workflows/templates/workflows/app_registry.html:136 msgid "Super Admin" msgstr "Super Admin" -#: workflows/roles.py:28 workflows/templates/workflows/app_registry.html:89 -#: workflows/templates/workflows/app_registry.html:118 +#: workflows/roles.py:28 workflows/templates/workflows/app_registry.html:111 +#: workflows/templates/workflows/app_registry.html:140 msgid "Admin" msgstr "Admin" -#: workflows/roles.py:29 workflows/templates/workflows/app_registry.html:90 -#: workflows/templates/workflows/app_registry.html:122 +#: workflows/roles.py:29 workflows/templates/workflows/app_registry.html:112 +#: workflows/templates/workflows/app_registry.html:144 msgid "IT Staff" msgstr "IT Staff" @@ -1105,13 +1128,12 @@ msgid "Anmeldung fehlgeschlagen" msgstr "Failed" #: workflows/templates/registration/login.html:31 -#: workflows/templates/workflows/auth/login.html:29 msgid "" "Benutzername oder Passwort sind nicht korrekt. Bitte versuchen Sie es erneut." msgstr "" #: workflows/templates/registration/login.html:37 -#: workflows/templates/workflows/auth/login.html:35 +#: workflows/templates/workflows/auth/login.html:39 msgid "Anmelden" msgstr "Sign in" @@ -1250,6 +1272,8 @@ msgid "Die wichtigsten Stammdaten Ihres aktuellen Kontos." msgstr "The most important master data of your current account." #: workflows/templates/workflows/account_profile.html:97 +#: workflows/templates/workflows/branding_settings.html:32 +#: workflows/templates/workflows/company_config.html:25 #, fuzzy #| msgid "In Bearbeitung" msgid "Bearbeiten" @@ -1271,12 +1295,16 @@ msgid "E-Mail" msgstr "Email" #: workflows/templates/workflows/account_profile.html:165 +#: workflows/templates/workflows/branding_settings.html:177 +#: workflows/templates/workflows/company_config.html:54 #: workflows/templates/workflows/user_management.html:115 msgid "Speichern" msgstr "Save" #: workflows/templates/workflows/account_profile.html:166 #: workflows/templates/workflows/base_shell.html:79 +#: workflows/templates/workflows/branding_settings.html:178 +#: workflows/templates/workflows/company_config.html:55 #: workflows/templates/workflows/welcome_emails.html:134 msgid "Abbrechen" msgstr "Cancel" @@ -1290,7 +1318,7 @@ msgid "Direkte Aktionen für Ihr Workdock-Konto." msgstr "Direct actions for your Workdock account." #: workflows/templates/workflows/account_profile.html:179 -#: workflows/templates/workflows/account_profile.html:190 +#: workflows/templates/workflows/account_profile.html:278 #: workflows/templates/workflows/auth/password_change_form.html:4 #: workflows/templates/workflows/auth/password_change_form.html:17 #: workflows/templates/workflows/includes/app_header.html:48 @@ -1302,14 +1330,93 @@ msgid "Aktualisieren Sie Ihr Passwort direkt im Konto." msgstr "Update your password directly in your account." #: workflows/templates/workflows/account_profile.html:184 +msgid "TOTP" +msgstr "" + +#: workflows/templates/workflows/account_profile.html:187 +msgid "Zweiter Faktor ist aktiv und wird bei der Anmeldung geprüft." +msgstr "" + +#: workflows/templates/workflows/account_profile.html:189 +msgid "Standardmäßig deaktiviert. Kann hier jederzeit aktiviert werden." +msgstr "" + +#: workflows/templates/workflows/account_profile.html:195 msgid "Sitzung" msgstr "Session" -#: workflows/templates/workflows/account_profile.html:185 +#: workflows/templates/workflows/account_profile.html:196 msgid "Sie können sich jederzeit sicher vom aktuellen Gerät abmelden." msgstr "" -#: workflows/templates/workflows/account_profile.html:193 +#: workflows/templates/workflows/account_profile.html:203 +msgid "Zwei-Faktor-Authentifizierung" +msgstr "" + +#: workflows/templates/workflows/account_profile.html:204 +msgid "" +"Aktivieren Sie TOTP mit einer Authenticator-App. Standardmäßig bleibt es " +"ausgeschaltet." +msgstr "" + +#: workflows/templates/workflows/account_profile.html:208 +#: workflows/templates/workflows/app_registry.html:35 +#: workflows/templates/workflows/app_registry.html:84 +#: workflows/templates/workflows/form_builder.html:91 +#: workflows/templates/workflows/integrations_setup.html:263 +#: workflows/templates/workflows/intro_builder.html:65 +#: workflows/templates/workflows/trial_management.html:28 +#: workflows/templates/workflows/user_management.html:75 +msgid "Aktiv" +msgstr "Active" + +#: workflows/templates/workflows/account_profile.html:210 +#, fuzzy +#| msgid "Auf" +msgid "Aus" +msgstr "To" + +#: workflows/templates/workflows/account_profile.html:219 +#, fuzzy +#| msgid "Deaktivieren" +msgid "TOTP ist aktiviert." +msgstr "Disabled" + +#: workflows/templates/workflows/account_profile.html:222 +msgid "Bestätigt am" +msgstr "" + +#: workflows/templates/workflows/account_profile.html:241 +#, fuzzy +#| msgid "Aktivieren" +msgid "TOTP deaktivieren" +msgstr "Enable" + +#: workflows/templates/workflows/account_profile.html:247 +#, fuzzy +#| msgid "Onboarding starten" +msgid "Manueller Schlüssel" +msgstr "Start onboarding" + +#: workflows/templates/workflows/account_profile.html:251 +#, fuzzy +#| msgid "Setup Mail" +msgid "Setup-Link" +msgstr "Setup Mail" + +#: workflows/templates/workflows/account_profile.html:255 +msgid "" +"Wenn Ihre App keinen QR-Code scannen kann, tragen Sie den Schlüssel oder den " +"otpauth-Link manuell ein." +msgstr "" + +#: workflows/templates/workflows/account_profile.html:271 +#, fuzzy +#| msgid "Aktivieren" +msgid "TOTP aktivieren" +msgstr "Enable" + +#: workflows/templates/workflows/account_profile.html:281 #: workflows/templates/workflows/includes/app_header.html:51 msgid "Abmelden" msgstr "Log out" @@ -1320,32 +1427,39 @@ msgstr "Log out" msgid "Ungespeicherte Änderungen" msgstr "Last updated" -#: workflows/templates/workflows/app_registry.html:14 +#: workflows/templates/workflows/app_registry.html:4 +#: workflows/templates/workflows/app_registry.html:103 +#: workflows/templates/workflows/form_builder.html:87 +#: workflows/templates/workflows/intro_builder.html:58 +msgid "Sortierung" +msgstr "Sort order" + +#: workflows/templates/workflows/app_registry.html:15 msgid "" "Apps zentral steuern, für Kunden vorbereiten und ohne Template-Eingriffe auf " "der Landing Page ausspielen." msgstr "" -#: workflows/templates/workflows/app_registry.html:20 +#: workflows/templates/workflows/app_registry.html:21 msgid "" "Sicherheit bleibt codebasiert: Sichtbarkeit und Reihenfolge sind hier " "steuerbar, Berechtigungen weiterhin über Rollen und Capabilities." msgstr "" -#: workflows/templates/workflows/app_registry.html:21 +#: workflows/templates/workflows/app_registry.html:22 #, fuzzy #| msgid "Produktion" msgid "Produktkern" msgstr "Production" -#: workflows/templates/workflows/app_registry.html:28 +#: workflows/templates/workflows/app_registry.html:29 #, fuzzy #| msgid "Nach Name oder E-Mail suchen" msgid "Nach App-Name oder Key filtern" msgstr "Search by name or email" -#: workflows/templates/workflows/app_registry.html:33 -#: workflows/templates/workflows/app_registry.html:42 +#: workflows/templates/workflows/app_registry.html:34 +#: workflows/templates/workflows/app_registry.html:43 #: workflows/templates/workflows/audit_log.html:25 #: workflows/templates/workflows/job_monitor.html:22 #: workflows/templates/workflows/job_monitor.html:31 @@ -1355,18 +1469,8 @@ msgstr "Search by name or email" msgid "Alle" msgstr "" -#: workflows/templates/workflows/app_registry.html:34 -#: workflows/templates/workflows/app_registry.html:62 -#: workflows/templates/workflows/form_builder.html:91 -#: workflows/templates/workflows/integrations_setup.html:263 -#: workflows/templates/workflows/intro_builder.html:65 -#: workflows/templates/workflows/trial_management.html:28 -#: workflows/templates/workflows/user_management.html:75 -msgid "Aktiv" -msgstr "Active" - -#: workflows/templates/workflows/app_registry.html:35 -#: workflows/templates/workflows/app_registry.html:64 +#: workflows/templates/workflows/app_registry.html:36 +#: workflows/templates/workflows/app_registry.html:86 #: workflows/templates/workflows/backup_recovery.html:106 #: workflows/templates/workflows/trial_management.html:30 #: workflows/templates/workflows/trial_management.html:43 @@ -1375,132 +1479,155 @@ msgstr "Active" msgid "Deaktiviert" msgstr "Disabled" -#: workflows/templates/workflows/app_registry.html:36 -#: workflows/templates/workflows/app_registry.html:83 +#: workflows/templates/workflows/app_registry.html:37 +#: workflows/templates/workflows/app_registry.html:105 msgid "Platform only" msgstr "" -#: workflows/templates/workflows/app_registry.html:40 -#: workflows/templates/workflows/app_registry.html:136 +#: workflows/templates/workflows/app_registry.html:41 +#: workflows/templates/workflows/app_registry.html:158 #, fuzzy #| msgid "Eingereicht" msgid "Bereich" msgstr "Submitted" -#: workflows/templates/workflows/app_registry.html:69 +#: workflows/templates/workflows/app_registry.html:50 +msgid "Für eine verlässliche Reihenfolge bitte ohne aktive Filter umsortieren." +msgstr "" + +#: workflows/templates/workflows/app_registry.html:59 +msgid "Produktweite Steuerung und nur für die Platform sichtbare Oberflächen." +msgstr "" + +#: workflows/templates/workflows/app_registry.html:61 +msgid "Administrative Apps für Kundenrollen mit erhöhter Verantwortung." +msgstr "" + +#: workflows/templates/workflows/app_registry.html:63 +msgid "" +"Operative Apps, die im täglichen Einsatz auf der Landing Page erscheinen." +msgstr "" + +#: workflows/templates/workflows/app_registry.html:79 +#, fuzzy +#| msgid "Ziehen zum Sortieren" +msgid "Ziehen zum Umordnen" +msgstr "Drag to reorder" + +#: workflows/templates/workflows/app_registry.html:91 msgid "Empfohlener Standardzugriff:" msgstr "" -#: workflows/templates/workflows/app_registry.html:81 -#: workflows/templates/workflows/form_builder.html:87 -#: workflows/templates/workflows/intro_builder.html:58 -msgid "Sortierung" -msgstr "Sort order" - -#: workflows/templates/workflows/app_registry.html:91 -#: workflows/templates/workflows/app_registry.html:126 +#: workflows/templates/workflows/app_registry.html:113 +#: workflows/templates/workflows/app_registry.html:148 #, fuzzy #| msgid "IT Staff" msgid "Staff" msgstr "IT Staff" -#: workflows/templates/workflows/app_registry.html:99 +#: workflows/templates/workflows/app_registry.html:121 #, fuzzy #| msgid "Noch nicht verfügbar" msgid "Verfügbarkeit" msgstr "Not available yet" -#: workflows/templates/workflows/app_registry.html:103 +#: workflows/templates/workflows/app_registry.html:125 #, fuzzy #| msgid "Deaktivieren" msgid "App aktiviert" msgstr "Disabled" -#: workflows/templates/workflows/app_registry.html:106 +#: workflows/templates/workflows/app_registry.html:128 msgid "" "Deaktivierte Apps erscheinen nicht auf der Landing Page, selbst wenn Rollen " "sie sehen dürften." msgstr "" -#: workflows/templates/workflows/app_registry.html:110 +#: workflows/templates/workflows/app_registry.html:132 msgid "Sichtbarkeit nach Rolle" msgstr "" -#: workflows/templates/workflows/app_registry.html:129 +#: workflows/templates/workflows/app_registry.html:151 msgid "" "Wenn keine Firmenrolle aktiv ist, bleibt die App nur für die Platform " "sichtbar." msgstr "" -#: workflows/templates/workflows/app_registry.html:133 +#: workflows/templates/workflows/app_registry.html:155 #, fuzzy #| msgid "Sortierung" msgid "Platzierung" msgstr "Sort order" -#: workflows/templates/workflows/app_registry.html:144 +#: workflows/templates/workflows/app_registry.html:166 #, fuzzy #| msgid "Reihenfolge speichern" msgid "Reihenfolge" msgstr "Save order" -#: workflows/templates/workflows/app_registry.html:151 +#: workflows/templates/workflows/app_registry.html:176 +msgid "Wird per Drag-and-drop und Bereichswechsel dynamisch neu nummeriert." +msgstr "" + +#: workflows/templates/workflows/app_registry.html:182 msgid "Bezeichnungen & Texte" msgstr "" -#: workflows/templates/workflows/app_registry.html:154 -#: workflows/templates/workflows/branding_settings.html:141 +#: workflows/templates/workflows/app_registry.html:185 +#: workflows/templates/workflows/branding_settings.html:40 +#: workflows/templates/workflows/branding_settings.html:111 #: workflows/templates/workflows/trial_management.html:105 msgid "Deutsch" msgstr "" -#: workflows/templates/workflows/app_registry.html:156 +#: workflows/templates/workflows/app_registry.html:187 msgid "Titel" msgstr "" -#: workflows/templates/workflows/app_registry.html:160 +#: workflows/templates/workflows/app_registry.html:191 msgid "Beschreibung" msgstr "" -#: workflows/templates/workflows/app_registry.html:164 +#: workflows/templates/workflows/app_registry.html:195 #, fuzzy #| msgid "Aktionen" msgid "Aktionslabel" msgstr "Actions" -#: workflows/templates/workflows/app_registry.html:169 -#: workflows/templates/workflows/branding_settings.html:152 +#: workflows/templates/workflows/app_registry.html:200 +#: workflows/templates/workflows/branding_settings.html:49 +#: workflows/templates/workflows/branding_settings.html:121 #: workflows/templates/workflows/trial_management.html:112 #, fuzzy #| msgid "English label" msgid "English" msgstr "English label" -#: workflows/templates/workflows/app_registry.html:171 +#: workflows/templates/workflows/app_registry.html:202 msgid "Title" msgstr "" -#: workflows/templates/workflows/app_registry.html:175 +#: workflows/templates/workflows/app_registry.html:206 msgid "Description" msgstr "" -#: workflows/templates/workflows/app_registry.html:179 +#: workflows/templates/workflows/app_registry.html:210 #, fuzzy #| msgid "Aktionen" msgid "Action label" msgstr "Actions" -#: workflows/templates/workflows/app_registry.html:191 +#: workflows/templates/workflows/app_registry.html:226 msgid "" "Empfehlung: Produktweite Apps sparsam halten, kundenbezogene Prozesse unter " "Apps oder Admin Apps einordnen." msgstr "" -#: workflows/templates/workflows/app_registry.html:192 +#: workflows/templates/workflows/app_registry.html:227 msgid "Keine ungespeicherten Änderungen" msgstr "" -#: workflows/templates/workflows/app_registry.html:194 +#: workflows/templates/workflows/app_registry.html:229 #, fuzzy #| msgid "Regeln speichern" msgid "App Registry speichern" @@ -1594,6 +1721,16 @@ msgstr "" msgid "Noch keine Audit-Einträge vorhanden." msgstr "No requests available yet." +#: workflows/templates/workflows/auth/login.html:29 +msgid "" +"Anmeldedaten oder TOTP-Code sind nicht korrekt. Bitte versuchen Sie es " +"erneut." +msgstr "" + +#: workflows/templates/workflows/auth/login.html:37 +msgid "Nur erforderlich, wenn TOTP für Ihr Konto aktiviert ist." +msgstr "" + #: workflows/templates/workflows/auth/password_change_done.html:4 #: workflows/templates/workflows/auth/password_change_done.html:17 #: workflows/templates/workflows/user_management.html:174 @@ -1831,83 +1968,26 @@ msgid "Portalname, Firmenauftritt, Logo und PDF-Briefkopf zentral verwalten." msgstr "" "Manage portal name, company branding, logo, and PDF letterhead centrally." -#: workflows/templates/workflows/branding_settings.html:23 -msgid "Identität" -msgstr "" - -#: workflows/templates/workflows/branding_settings.html:24 -msgid "Titel, Firmenname und zentrale Spracheinstellungen." -msgstr "" - -#: workflows/templates/workflows/branding_settings.html:38 -msgid "" -"Wird für E-Mail-Vorschläge und Domain-bezogene Standardtexte verwendet, z. " -"B. tub.co." -msgstr "" - -#: workflows/templates/workflows/branding_settings.html:53 -msgid "Farben & Erscheinungsbild" -msgstr "" - -#: workflows/templates/workflows/branding_settings.html:54 -msgid "Zentrale visuelle Markenwerte und Browser-Icon." -msgstr "" - -#: workflows/templates/workflows/branding_settings.html:68 -msgid "Erlaubte Formate: SVG, PNG, JPG, JPEG, WEBP. Maximal 5 MB." -msgstr "" - -#: workflows/templates/workflows/branding_settings.html:71 -msgid "Aktuelles Logo:" -msgstr "Current logo:" - -#: workflows/templates/workflows/branding_settings.html:71 -#: workflows/templates/workflows/branding_settings.html:80 -#: workflows/templates/workflows/branding_settings.html:128 +#: workflows/templates/workflows/branding_settings.html:66 +#: workflows/templates/workflows/branding_settings.html:144 msgid "öffnen" msgstr "open" -#: workflows/templates/workflows/branding_settings.html:77 -msgid "Erlaubte Formate: ICO, PNG, SVG, WEBP. Maximal 2 MB." -msgstr "" +#: workflows/templates/workflows/branding_settings.html:140 +msgid "Aktuelles Logo:" +msgstr "Current logo:" -#: workflows/templates/workflows/branding_settings.html:80 +#: workflows/templates/workflows/branding_settings.html:141 #, fuzzy #| msgid "Aktuelles Logo:" msgid "Aktuelles Favicon:" msgstr "Current logo:" -#: workflows/templates/workflows/branding_settings.html:109 -#, fuzzy -#| msgid "Produktion" -msgid "Kommunikation" -msgstr "Production" - -#: workflows/templates/workflows/branding_settings.html:110 -msgid "Absender, Support und PDF-Branding für ausgehende Kommunikation." -msgstr "" - -#: workflows/templates/workflows/branding_settings.html:120 -msgid "Wird für ausgehende System-E-Mails als Anzeigename verwendet." -msgstr "" - -#: workflows/templates/workflows/branding_settings.html:125 -msgid "Erlaubtes Format: PDF. Maximal 10 MB." -msgstr "" - -#: workflows/templates/workflows/branding_settings.html:128 +#: workflows/templates/workflows/branding_settings.html:142 msgid "Aktueller Briefkopf:" msgstr "Current letterhead:" -#: workflows/templates/workflows/branding_settings.html:136 -msgid "Footer & Rechtliches" -msgstr "" - -#: workflows/templates/workflows/branding_settings.html:137 -msgid "Gemeinsame Footer-Texte und rechtliche Hinweise für die Shell." -msgstr "" - -#: workflows/templates/workflows/branding_settings.html:166 +#: workflows/templates/workflows/branding_settings.html:185 #, fuzzy #| msgid "" #| "TUBCO bleibt als Standard erhalten, bis hier Werte geändert oder Dateien " @@ -1919,68 +1999,18 @@ msgstr "" "TUBCO remains the default until values are changed or files are uploaded " "here." -#: workflows/templates/workflows/branding_settings.html:167 -msgid "Branding speichern" -msgstr "Save branding" - #: workflows/templates/workflows/company_config.html:13 msgid "" "Strukturierte Firmendaten, Kontaktpunkte und öffentliche Unternehmenslinks " "zentral pflegen." msgstr "" -#: workflows/templates/workflows/company_config.html:23 -#, fuzzy -#| msgid "Firmenname" -msgid "Firmenprofil" -msgstr "Company name" - -#: workflows/templates/workflows/company_config.html:24 -msgid "Rechtlicher Name und zentrale Stammdaten der Firma." -msgstr "" - -#: workflows/templates/workflows/company_config.html:48 -msgid "Adresse & Register" -msgstr "" - -#: workflows/templates/workflows/company_config.html:49 -msgid "Anschrift sowie optionale Register- und Steuerangaben." -msgstr "" - -#: workflows/templates/workflows/company_config.html:77 -msgid "Kontaktpunkte" -msgstr "" - -#: workflows/templates/workflows/company_config.html:78 -msgid "Zentrale Ansprechpartner für HR, IT und Operations." -msgstr "" - -#: workflows/templates/workflows/company_config.html:98 -msgid "Recht & Öffentlichkeit" -msgstr "" - -#: workflows/templates/workflows/company_config.html:99 -msgid "Öffentliche Links für Website, Impressum und Datenschutz." -msgstr "" - -#: workflows/templates/workflows/company_config.html:111 -msgid "" -"Diese Links können später im Portal-Footer oder in öffentlichen Seiten " -"verwendet werden." -msgstr "" - -#: workflows/templates/workflows/company_config.html:115 +#: workflows/templates/workflows/company_config.html:62 msgid "" "Diese Ebene ist bewusst von Branding getrennt: Hier geht es um strukturierte " "Firmendaten, nicht um visuelle Gestaltung." msgstr "" -#: workflows/templates/workflows/company_config.html:116 -#, fuzzy -#| msgid "Optionen speichern" -msgid "Firmenkonfiguration speichern" -msgstr "Save options" - #: workflows/templates/workflows/form_builder.html:15 msgid "Felder per Drag-and-Drop sortieren und pro Schritt gruppieren." msgstr "Sort fields by drag and drop and group them by step." @@ -2847,7 +2877,7 @@ msgid "Dienstliche E-Mail" msgstr "Work email" #: workflows/templates/workflows/onboarding_intro_session.html:31 -#: workflows/views.py:1062 +#: workflows/views.py:1242 msgid "Vertragsbeginn" msgstr "Contract start" @@ -3651,280 +3681,302 @@ msgstr "Resume" msgid "Keine geplanten Welcome E-Mails vorhanden." msgstr "No scheduled welcome emails available." -#: workflows/views.py:101 +#: workflows/views.py:102 msgid "Person, Rolle, Abteilung" msgstr "Person, role, department" -#: workflows/views.py:102 +#: workflows/views.py:103 msgid "Beschäftigung und Termine" msgstr "Employment and dates" -#: workflows/views.py:103 +#: workflows/views.py:104 msgid "Geräte, Software und Zugänge" msgstr "Devices, software, and access" -#: workflows/views.py:104 +#: workflows/views.py:105 msgid "Notizen und Freigabe" msgstr "Notes and approval" -#: workflows/views.py:141 +#: workflows/views.py:154 #, fuzzy #| msgid "Lokal gespeichert" msgid "Profilbild gespeichert." msgstr "Stored locally" -#: workflows/views.py:143 +#: workflows/views.py:156 #, fuzzy #| msgid "Passwort konnte nicht gespeichert werden" msgid "Profilbild konnte nicht gespeichert werden." msgstr "Password could not be saved" -#: workflows/views.py:149 +#: workflows/views.py:162 #, fuzzy #| msgid "Lokal gespeichert" msgid "Profildaten gespeichert." msgstr "Stored locally" -#: workflows/views.py:151 +#: workflows/views.py:164 #, fuzzy #| msgid "Passwort konnte nicht gespeichert werden" msgid "Profildaten konnten nicht gespeichert werden." msgstr "Password could not be saved" -#: workflows/views.py:172 workflows/views.py:1148 workflows/views.py:1153 +#: workflows/views.py:171 +#, fuzzy +#| msgid "Deaktivieren" +msgid "TOTP wurde aktiviert." +msgstr "Disabled" + +#: workflows/views.py:173 +#, fuzzy +#| msgid "Passwort konnte nicht gespeichert werden" +msgid "TOTP konnte nicht aktiviert werden." +msgstr "Password could not be saved" + +#: workflows/views.py:180 +msgid "TOTP wurde deaktiviert." +msgstr "" + +#: workflows/views.py:182 +#, fuzzy +#| msgid "Passwort konnte nicht gespeichert werden" +msgid "TOTP konnte nicht deaktiviert werden." +msgstr "Password could not be saved" + +#: workflows/views.py:212 workflows/views.py:1328 workflows/views.py:1333 msgid "Sie haben keine Berechtigung für diese Aktion." msgstr "You do not have permission for this action." -#: workflows/views.py:253 +#: workflows/views.py:293 #, fuzzy #| msgid "Vorgänge" msgid "Vorgänge gelöscht" msgstr "Requests" -#: workflows/views.py:254 +#: workflows/views.py:294 msgid "Vorgang gelöscht" msgstr "" -#: workflows/views.py:255 +#: workflows/views.py:295 msgid "Vorgang erneut angestoßen" msgstr "" -#: workflows/views.py:256 +#: workflows/views.py:296 #, fuzzy #| msgid "Einweisung" msgid "Einweisungs-PDF erzeugt" msgstr "Introduction" -#: workflows/views.py:257 +#: workflows/views.py:297 #, fuzzy #| msgid "Live-Protokoll erzeugen" msgid "Live-Protokoll erzeugt" msgstr "Generate live protocol" -#: workflows/views.py:258 +#: workflows/views.py:298 #, fuzzy #| msgid "Einweisung wurde zurückgesetzt." msgid "Einweisung zurückgesetzt" msgstr "Introduction was reset." -#: workflows/views.py:259 +#: workflows/views.py:299 #, fuzzy #| msgid "Einweisung wurde als Entwurf gespeichert." msgid "Einweisung als Entwurf gespeichert" msgstr "Introduction was saved as draft." -#: workflows/views.py:260 +#: workflows/views.py:300 #, fuzzy #| msgid "Einweisung wurde als abgeschlossen gespeichert." msgid "Einweisung abgeschlossen" msgstr "Introduction was saved as completed." -#: workflows/views.py:261 +#: workflows/views.py:301 msgid "Formularoption gelöscht" msgstr "" -#: workflows/views.py:262 +#: workflows/views.py:302 #, fuzzy #| msgid "Optionen speichern" msgid "Formularoptionen gespeichert" msgstr "Save options" -#: workflows/views.py:263 +#: workflows/views.py:303 #, fuzzy #| msgid "Feldtexte speichern" msgid "Feldtexte gespeichert" msgstr "Save field text" -#: workflows/views.py:264 +#: workflows/views.py:304 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Formularlayout gespeichert" msgstr "Save offboarding request" -#: workflows/views.py:265 +#: workflows/views.py:305 msgid "Einweisungs-Checkpunkt gelöscht" msgstr "" -#: workflows/views.py:266 +#: workflows/views.py:306 msgid "Einweisungs-Checkpunkt hinzugefügt" msgstr "" -#: workflows/views.py:267 +#: workflows/views.py:307 #, fuzzy #| msgid "Checkliste speichern" msgid "Einweisungs-Checkliste gespeichert" msgstr "Save checklist" -#: workflows/views.py:268 +#: workflows/views.py:308 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail sofort ausgelöst" msgstr "Welcome Emails" -#: workflows/views.py:269 +#: workflows/views.py:309 #, fuzzy #| msgid "Welcome-Einstellungen speichern" msgid "Welcome E-Mail Einstellungen gespeichert" msgstr "Save welcome settings" -#: workflows/views.py:270 +#: workflows/views.py:310 msgid "Welcome E-Mail Sammelaktion ausgeführt" msgstr "" -#: workflows/views.py:271 +#: workflows/views.py:311 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail pausiert" msgstr "Welcome Emails" -#: workflows/views.py:272 +#: workflows/views.py:312 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail fortgesetzt" msgstr "Welcome Emails" -#: workflows/views.py:273 +#: workflows/views.py:313 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail abgebrochen" msgstr "Welcome Emails" -#: workflows/views.py:274 +#: workflows/views.py:314 #, fuzzy #| msgid "SMTP-Test" msgid "SMTP-Test gesendet" msgstr "SMTP test" -#: workflows/views.py:275 +#: workflows/views.py:315 #, fuzzy #| msgid "Nextcloud-Test" msgid "Nextcloud-Testupload ausgeführt" msgstr "Nextcloud test" -#: workflows/views.py:276 +#: workflows/views.py:316 #, fuzzy #| msgid "Nextcloud schalten" msgid "Nextcloud-Modus umgeschaltet" msgstr "Toggle Nextcloud" -#: workflows/views.py:277 +#: workflows/views.py:317 msgid "E-Mail-Modus umgeschaltet" msgstr "" -#: workflows/views.py:278 +#: workflows/views.py:318 #, fuzzy #| msgid "Integrationen Setup" msgid "Integrationen gespeichert" msgstr "Integrations Setup" -#: workflows/views.py:279 +#: workflows/views.py:319 #, fuzzy #| msgid "Welcome-Einstellungen speichern" msgid "Nextcloud-Einstellungen gespeichert" msgstr "Save welcome settings" -#: workflows/views.py:280 +#: workflows/views.py:320 #, fuzzy #| msgid "Welcome-Einstellungen speichern" msgid "Mail-Einstellungen gespeichert" msgstr "Save welcome settings" -#: workflows/views.py:281 +#: workflows/views.py:321 #, fuzzy #| msgid "E-Mail Routing & Vorlagen speichern" msgid "E-Mail-Routing gespeichert" msgstr "Save email routing & templates" -#: workflows/views.py:282 +#: workflows/views.py:322 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Benachrichtigungsregeln gespeichert" msgstr "Save offboarding request" -#: workflows/views.py:283 +#: workflows/views.py:323 #, fuzzy #| msgid "Anfrage gespeichert" msgid "Benutzer erstellt" msgstr "Request saved" -#: workflows/views.py:284 +#: workflows/views.py:324 msgid "Benutzer aktualisiert" msgstr "" -#: workflows/views.py:285 +#: workflows/views.py:325 msgid "Passwort-Reset-Link versendet" msgstr "" -#: workflows/views.py:286 +#: workflows/views.py:326 #, fuzzy #| msgid "Benutzerübersicht" msgid "Benutzer gelöscht" msgstr "User overview" -#: workflows/views.py:287 +#: workflows/views.py:327 #, fuzzy #| msgid "Anfrage gespeichert" msgid "Backup erstellt" msgstr "Request saved" -#: workflows/views.py:288 +#: workflows/views.py:328 msgid "Backup verifiziert" msgstr "" -#: workflows/views.py:289 +#: workflows/views.py:329 #, fuzzy #| msgid "Anfrage gespeichert" msgid "Backup gelöscht" msgstr "Request saved" -#: workflows/views.py:290 +#: workflows/views.py:330 #, fuzzy #| msgid "Welcome-Einstellungen speichern" msgid "Backup-Einstellungen gespeichert" msgstr "Save welcome settings" -#: workflows/views.py:291 +#: workflows/views.py:331 #, fuzzy #| msgid "Anfrage gespeichert" msgid "App-Registry gespeichert" msgstr "Request saved" -#: workflows/views.py:459 +#: workflows/views.py:503 #, fuzzy #| msgid "Anfrage gespeichert" msgid "App-Registry gespeichert." msgstr "Request saved" -#: workflows/views.py:558 +#: workflows/views.py:602 msgid "Für diesen Benutzer ist keine E-Mail-Adresse hinterlegt." msgstr "" -#: workflows/views.py:567 +#: workflows/views.py:611 #, python-format msgid "Zugangseinladung für %(username)s" msgstr "" -#: workflows/views.py:569 +#: workflows/views.py:613 #, python-format msgid "" "Hallo %(name)s,\n" @@ -3937,12 +3989,12 @@ msgid "" "Ihrem Administrator." msgstr "" -#: workflows/views.py:580 +#: workflows/views.py:624 #, python-format msgid "Passwort zurücksetzen für %(username)s" msgstr "" -#: workflows/views.py:582 +#: workflows/views.py:626 #, python-format msgid "" "Hallo %(name)s,\n" @@ -3955,7 +4007,7 @@ msgid "" "ignorieren." msgstr "" -#: workflows/views.py:620 +#: workflows/views.py:677 #, fuzzy #| msgid "" #| "Benutzer konnte nicht erstellt werden. Bitte prüfen Sie die Eingaben." @@ -3963,13 +4015,69 @@ msgid "" "Branding konnte nicht gespeichert werden. Bitte prüfen Sie die Eingaben." msgstr "User could not be created. Please check the input." -#: workflows/views.py:646 +#: workflows/views.py:705 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Portal-Branding wurde gespeichert." msgstr "Save offboarding request" -#: workflows/views.py:677 +#: workflows/views.py:722 +msgid "Identität" +msgstr "" + +#: workflows/views.py:723 +msgid "Titel, Firmenname und zentrale Spracheinstellungen." +msgstr "" + +#: workflows/views.py:727 +msgid "" +"Wird für E-Mail-Vorschläge und Domain-bezogene Standardtexte verwendet, z. " +"B. tub.co." +msgstr "" + +#: workflows/views.py:732 +msgid "Farben & Erscheinungsbild" +msgstr "" + +#: workflows/views.py:733 +msgid "Zentrale visuelle Markenwerte und Browser-Icon." +msgstr "" + +#: workflows/views.py:737 +msgid "Erlaubte Formate: SVG, PNG, JPG, JPEG, WEBP. Maximal 5 MB." +msgstr "" + +#: workflows/views.py:738 +msgid "Erlaubte Formate: ICO, PNG, SVG, WEBP. Maximal 2 MB." +msgstr "" + +#: workflows/views.py:743 +#, fuzzy +#| msgid "Produktion" +msgid "Kommunikation" +msgstr "Production" + +#: workflows/views.py:744 +msgid "Absender, Support und PDF-Branding für ausgehende Kommunikation." +msgstr "" + +#: workflows/views.py:748 +msgid "Wird für ausgehende System-E-Mails als Anzeigename verwendet." +msgstr "" + +#: workflows/views.py:749 +msgid "Erlaubtes Format: PDF. Maximal 10 MB." +msgstr "" + +#: workflows/views.py:754 +msgid "Footer & Rechtliches" +msgstr "" + +#: workflows/views.py:755 +msgid "Gemeinsame Footer-Texte und rechtliche Hinweise für die Shell." +msgstr "" + +#: workflows/views.py:809 #, fuzzy #| msgid "" #| "Benutzer konnte nicht erstellt werden. Bitte prüfen Sie die Eingaben." @@ -3978,13 +4086,53 @@ msgid "" "Eingaben." msgstr "User could not be created. Please check the input." -#: workflows/views.py:704 +#: workflows/views.py:838 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Firmenkonfiguration wurde gespeichert." msgstr "Save offboarding request" -#: workflows/views.py:736 +#: workflows/views.py:855 +#, fuzzy +#| msgid "Firmenname" +msgid "Firmenprofil" +msgstr "Company name" + +#: workflows/views.py:856 +msgid "Rechtlicher Name und zentrale Stammdaten der Firma." +msgstr "" + +#: workflows/views.py:861 +msgid "Adresse & Register" +msgstr "" + +#: workflows/views.py:862 +msgid "Anschrift sowie optionale Register- und Steuerangaben." +msgstr "" + +#: workflows/views.py:867 +msgid "Kontaktpunkte" +msgstr "" + +#: workflows/views.py:868 +msgid "Zentrale Ansprechpartner für HR, IT und Operations." +msgstr "" + +#: workflows/views.py:873 +msgid "Recht & Öffentlichkeit" +msgstr "" + +#: workflows/views.py:874 +msgid "Öffentliche Links für Website, Impressum und Datenschutz." +msgstr "" + +#: workflows/views.py:876 +msgid "" +"Diese Links können später im Portal-Footer oder in öffentlichen Seiten " +"verwendet werden." +msgstr "" + +#: workflows/views.py:916 #, fuzzy #| msgid "" #| "Benutzer konnte nicht erstellt werden. Bitte prüfen Sie die Eingaben." @@ -3993,21 +4141,21 @@ msgid "" "Eingaben." msgstr "Trial configuration could not be saved. Please check the input." -#: workflows/views.py:763 +#: workflows/views.py:943 msgid "Trial-Konfiguration wurde gespeichert." msgstr "Trial configuration was saved." -#: workflows/views.py:780 +#: workflows/views.py:960 msgid "Benutzer konnte nicht erstellt werden. Bitte prüfen Sie die Eingaben." msgstr "User could not be created. Please check the input." -#: workflows/views.py:793 +#: workflows/views.py:973 #, fuzzy, python-format #| msgid "Benutzer wurde erstellt: %(username)s" msgid "Benutzer wurde erstellt und eingeladen: %(username)s" msgstr "User created: %(username)s" -#: workflows/views.py:815 +#: workflows/views.py:995 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -4018,14 +4166,14 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:818 +#: workflows/views.py:998 msgid "" "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren oder " "herabstufen." msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:821 +#: workflows/views.py:1001 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -4036,7 +4184,7 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:824 +#: workflows/views.py:1004 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -4047,18 +4195,18 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:841 +#: workflows/views.py:1021 #, python-format msgid "Benutzer wurde aktualisiert: %(username)s" msgstr "User updated: %(username)s" -#: workflows/views.py:863 +#: workflows/views.py:1043 #, fuzzy, python-format #| msgid "Benutzer wurde erstellt: %(username)s" msgid "Passwort-Reset-Link wurde versendet: %(username)s" msgstr "User created: %(username)s" -#: workflows/views.py:875 +#: workflows/views.py:1055 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -4068,7 +4216,7 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:878 +#: workflows/views.py:1058 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -4078,7 +4226,7 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:881 +#: workflows/views.py:1061 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -4087,7 +4235,7 @@ msgid "Der letzte aktive Platform Owner kann nicht gelöscht werden." msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:884 +#: workflows/views.py:1064 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -4096,124 +4244,132 @@ msgid "Der letzte aktive Super Admin kann nicht gelöscht werden." msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:897 +#: workflows/views.py:1077 #, fuzzy, python-format #| msgid "Benutzer wurde erstellt: %(username)s" msgid "Benutzer wurde gelöscht: %(username)s" msgstr "User created: %(username)s" -#: workflows/views.py:986 +#: workflows/views.py:1166 #, python-format msgid "Backup wurde erstellt: %(name)s" msgstr "" -#: workflows/views.py:988 +#: workflows/views.py:1168 #, python-format msgid "Backup konnte nicht erstellt werden: %(error)s" msgstr "" -#: workflows/views.py:1004 +#: workflows/views.py:1184 #, python-format msgid "Backup wurde verifiziert: %(name)s" msgstr "" -#: workflows/views.py:1006 +#: workflows/views.py:1186 #, python-format msgid "Backup-Verifikation fehlgeschlagen: %(error)s" msgstr "" -#: workflows/views.py:1022 +#: workflows/views.py:1202 #, python-format msgid "Backup wurde gelöscht: %(name)s" msgstr "" -#: workflows/views.py:1024 +#: workflows/views.py:1204 #, python-format msgid "Backup konnte nicht gelöscht werden: %(error)s" msgstr "" -#: workflows/views.py:1050 +#: workflows/views.py:1230 #, fuzzy #| msgid "Anfrage gespeichert" msgid "Anfrage erstellt" msgstr "Request saved" -#: workflows/views.py:1052 +#: workflows/views.py:1232 #, fuzzy, python-format #| msgid "Sitzungsstatus" msgid "Status: %(status)s" msgstr "Session status" -#: workflows/views.py:1064 +#: workflows/views.py:1244 #, fuzzy #| msgid "Geplant für" msgid "Geplanter Start" msgstr "Scheduled for" -#: workflows/views.py:1074 +#: workflows/views.py:1254 msgid "Geräteübergabe / Hardware-Abholung" msgstr "" -#: workflows/views.py:1076 +#: workflows/views.py:1256 msgid "Geplanter Hardware-Termin" msgstr "" -#: workflows/views.py:1085 +#: workflows/views.py:1265 #, fuzzy #| msgid "Noch nicht verfügbar" msgid "PDF verfügbar" msgstr "Not available yet" -#: workflows/views.py:1111 +#: workflows/views.py:1291 #, fuzzy #| msgid "Einweisung" msgid "Einweisungssitzung" msgstr "Introduction" -#: workflows/views.py:1123 +#: workflows/views.py:1303 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail" msgstr "Welcome Emails" -#: workflows/views.py:1162 +#: workflows/views.py:1342 msgid "Keine Einträge ausgewählt." msgstr "No entries selected." -#: workflows/views.py:1205 +#: workflows/views.py:1385 #, python-format msgid "%(count)s Eintrag/Einträge gelöscht." msgstr "%(count)s entry/entries deleted." -#: workflows/views.py:1207 +#: workflows/views.py:1387 #, python-format msgid "%(count)s Auswahl(en) konnten nicht verarbeitet werden." msgstr "%(count)s selection(s) could not be processed." -#: workflows/views.py:1209 +#: workflows/views.py:1389 msgid "Keine passenden Einträge gefunden." msgstr "No matching entries found." -#: workflows/views.py:1437 +#: workflows/views.py:1617 msgid "Einweisungs- und Übergabeprotokoll wurde erzeugt." msgstr "Introduction and handover protocol was generated." -#: workflows/views.py:1454 +#: workflows/views.py:1634 msgid "Einweisungsprotokoll aus Live-Status wurde erzeugt." msgstr "Introduction protocol from live status was generated." -#: workflows/views.py:1483 +#: workflows/views.py:1663 msgid "Einweisung wurde zurückgesetzt." msgstr "Introduction was reset." -#: workflows/views.py:1497 +#: workflows/views.py:1677 msgid "Einweisung wurde als abgeschlossen gespeichert." msgstr "Introduction was saved as completed." -#: workflows/views.py:1510 +#: workflows/views.py:1690 msgid "Einweisung wurde als Entwurf gespeichert." msgstr "Introduction was saved as draft." +#~ msgid "Branding speichern" +#~ msgstr "Save branding" + +#, fuzzy +#~| msgid "Optionen speichern" +#~ msgid "Firmenkonfiguration speichern" +#~ msgstr "Save options" + #, fuzzy #~| msgid "Aktion" #~ msgid "Aktion DE" @@ -4250,15 +4406,9 @@ msgstr "Introduction was saved as draft." #~ msgid "Aktiv/Inaktiv direkt umschalten." #~ msgstr "Switch active/inactive directly." -#~ msgid "Aktivieren" -#~ msgstr "Enable" - #~ msgid "Zwischen Testmodus und Produktion wechseln." #~ msgstr "Switch between test mode and production." -#~ msgid "Auf" -#~ msgstr "To" - #~ msgid "SMTP Einstellungen" #~ msgstr "SMTP Settings" diff --git a/backend/workflows/app_registry.py b/backend/workflows/app_registry.py index bf46d01..3e9db5b 100644 --- a/backend/workflows/app_registry.py +++ b/backend/workflows/app_registry.py @@ -349,6 +349,16 @@ def ensure_portal_app_configs() -> None: 'visible_to_staff': visibility.get(ROLE_STAFF, False), }, ) + normalize_portal_app_sort_orders() + + +def normalize_portal_app_sort_orders() -> None: + for section_key, _label in PortalAppConfig.SECTION_CHOICES: + configs = list(PortalAppConfig.objects.filter(section=section_key).order_by('sort_order', 'key')) + for position, config in enumerate(configs): + if config.sort_order != position: + config.sort_order = position + config.save(update_fields=['sort_order']) def get_portal_app_registry_rows() -> list[dict[str, object]]: diff --git a/backend/workflows/forms.py b/backend/workflows/forms.py index 477a401..ccb0a3d 100644 --- a/backend/workflows/forms.py +++ b/backend/workflows/forms.py @@ -3,6 +3,7 @@ from pathlib import Path from datetime import timedelta from django.contrib.auth import get_user_model, password_validation from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm, PasswordResetForm, SetPasswordForm +from django.core.exceptions import ValidationError from django.utils import timezone from django.utils.translation import get_language, gettext as _, gettext_lazy @@ -10,6 +11,7 @@ from .branding import get_company_email_domain from .form_builder import apply_form_field_config from .models import EmployeeProfile, FormOption, OffboardingRequest, OnboardingRequest, PortalBranding, PortalCompanyConfig, PortalTrialConfig, UserProfile, WorkflowConfig from .roles import ROLE_ADMIN, ROLE_GROUP_NAMES, ROLE_IT_STAFF, ROLE_LABELS, ROLE_PLATFORM_OWNER, ROLE_STAFF, ROLE_SUPER_ADMIN, assign_user_role +from .totp import normalize_totp_token, verify_totp_token YES_NO_CHOICES = [('', '--'), ('ja', 'Ja'), ('nein', 'Nein')] @@ -103,6 +105,38 @@ SOFTWARE_EXTRA_CHOICES = [('Adobe Acrobat Pro (Abonnement: Zusätzliche Kosten)' class AppAuthenticationForm(AuthenticationForm): username = forms.CharField(label=gettext_lazy('Benutzername')) password = forms.CharField(label=gettext_lazy('Passwort'), strip=False, widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'})) + otp_code = forms.CharField( + label=gettext_lazy('TOTP-Code'), + required=False, + max_length=12, + widget=forms.TextInput(attrs={'autocomplete': 'one-time-code', 'inputmode': 'numeric'}), + ) + + error_messages = { + **AuthenticationForm.error_messages, + 'invalid_otp': gettext_lazy('Der TOTP-Code ist ungültig.'), + 'missing_otp': gettext_lazy('Bitte geben Sie Ihren TOTP-Code ein.'), + } + + def clean(self): + cleaned_data = super().clean() + user = self.get_user() + if not user: + return cleaned_data + profile, _ = UserProfile.objects.get_or_create(user=user) + if profile.totp_enabled: + otp_code = normalize_totp_token(cleaned_data.get('otp_code')) + if not otp_code: + raise ValidationError( + self.error_messages['missing_otp'], + code='missing_otp', + ) + if not profile.totp_secret or not verify_totp_token(profile.totp_secret, otp_code, for_time=int(timezone.now().timestamp())): + raise ValidationError( + self.error_messages['invalid_otp'], + code='invalid_otp', + ) + return cleaned_data class AppPasswordResetForm(PasswordResetForm): @@ -221,6 +255,71 @@ class AccountDetailsForm(forms.Form): return self.user, self.profile +class AccountTOTPEnableForm(forms.Form): + current_password = forms.CharField( + label=gettext_lazy('Aktuelles Passwort'), + strip=False, + widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'}), + ) + verification_code = forms.CharField( + label=gettext_lazy('TOTP-Code'), + max_length=12, + widget=forms.TextInput(attrs={'autocomplete': 'one-time-code', 'inputmode': 'numeric'}), + ) + + def __init__(self, *args, user=None, secret: str = '', **kwargs): + super().__init__(*args, **kwargs) + self.user = user + self.secret = secret + + def clean_current_password(self): + password = self.cleaned_data.get('current_password') or '' + if not self.user or not self.user.check_password(password): + raise ValidationError(_('Das aktuelle Passwort ist nicht korrekt.')) + return password + + def clean_verification_code(self): + code = normalize_totp_token(self.cleaned_data.get('verification_code')) + if not code: + raise ValidationError(_('Bitte geben Sie einen gültigen TOTP-Code ein.')) + if not self.secret or not verify_totp_token(self.secret, code, for_time=int(timezone.now().timestamp())): + raise ValidationError(_('Der TOTP-Code ist ungültig.')) + return code + + +class AccountTOTPDisableForm(forms.Form): + current_password = forms.CharField( + label=gettext_lazy('Aktuelles Passwort'), + strip=False, + widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'}), + ) + verification_code = forms.CharField( + label=gettext_lazy('TOTP-Code'), + max_length=12, + widget=forms.TextInput(attrs={'autocomplete': 'one-time-code', 'inputmode': 'numeric'}), + ) + + def __init__(self, *args, user=None, profile=None, **kwargs): + super().__init__(*args, **kwargs) + self.user = user + self.profile = profile + + def clean_current_password(self): + password = self.cleaned_data.get('current_password') or '' + if not self.user or not self.user.check_password(password): + raise ValidationError(_('Das aktuelle Passwort ist nicht korrekt.')) + return password + + def clean_verification_code(self): + code = normalize_totp_token(self.cleaned_data.get('verification_code')) + if not code: + raise ValidationError(_('Bitte geben Sie einen gültigen TOTP-Code ein.')) + secret = getattr(self.profile, 'totp_secret', '') or '' + if not secret or not verify_totp_token(secret, code, for_time=int(timezone.now().timestamp())): + raise ValidationError(_('Der TOTP-Code ist ungültig.')) + return code + + class UserManagementCreateForm(forms.Form): first_name = forms.CharField(label=_('Vorname'), max_length=150, required=False) last_name = forms.CharField(label=_('Nachname'), max_length=150, required=False) diff --git a/backend/workflows/migrations/0049_userprofile_totp_fields.py b/backend/workflows/migrations/0049_userprofile_totp_fields.py new file mode 100644 index 0000000..c9f13eb --- /dev/null +++ b/backend/workflows/migrations/0049_userprofile_totp_fields.py @@ -0,0 +1,26 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0048_userprofile'), + ] + + operations = [ + migrations.AddField( + model_name='userprofile', + name='totp_confirmed_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='userprofile', + name='totp_enabled', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='userprofile', + name='totp_secret', + field=models.CharField(blank=True, default='', max_length=64), + ), + ] diff --git a/backend/workflows/models.py b/backend/workflows/models.py index 1d2633c..b637743 100644 --- a/backend/workflows/models.py +++ b/backend/workflows/models.py @@ -2,6 +2,7 @@ from django.conf import settings from django.core.validators import FileExtensionValidator from django.db import models from django.utils.translation import get_language +from django.utils import timezone from django.utils.translation import gettext_lazy as _ @@ -39,6 +40,9 @@ class UserProfile(models.Model): department = models.CharField(max_length=255, blank=True, default='') location = models.CharField(max_length=255, blank=True, default='') contact_notes = models.CharField(max_length=255, blank=True, default='') + totp_secret = models.CharField(max_length=64, blank=True, default='') + totp_enabled = models.BooleanField(default=False) + totp_confirmed_at = models.DateTimeField(null=True, blank=True) updated_at = models.DateTimeField(auto_now=True) class Meta: @@ -48,6 +52,18 @@ class UserProfile(models.Model): def __str__(self) -> str: return getattr(self.user, 'username', '') or str(self.user_id) + def disable_totp(self) -> None: + self.totp_secret = '' + self.totp_enabled = False + self.totp_confirmed_at = None + self.save(update_fields=['totp_secret', 'totp_enabled', 'totp_confirmed_at', 'updated_at']) + + def enable_totp(self, secret: str) -> None: + self.totp_secret = secret + self.totp_enabled = True + self.totp_confirmed_at = timezone.now() + self.save(update_fields=['totp_secret', 'totp_enabled', 'totp_confirmed_at', 'updated_at']) + class PortalBranding(models.Model): name = models.CharField(max_length=80, default='Default', unique=True) diff --git a/backend/workflows/static/workflows/css/account.css b/backend/workflows/static/workflows/css/account.css index 4c5d8ba..839bd3f 100644 --- a/backend/workflows/static/workflows/css/account.css +++ b/backend/workflows/static/workflows/css/account.css @@ -298,6 +298,34 @@ body { margin-bottom: 18px; } +.account-totp-card { + margin-bottom: 18px; + padding: 18px; + border-radius: 18px; + border: 1px solid #dbe5f2; + background: + radial-gradient(circle at top right, rgba(30, 64, 175, 0.08), transparent 28%), + #f9fbff; +} + +.account-totp-card h3 { + margin: 0 0 6px; + color: #132238; + font-size: 18px; +} + +.account-secret { + display: block; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + line-height: 1.55; + word-break: break-all; +} + +.account-totp-form { + margin-top: 14px; +} + .account-action-card { display: grid; gap: 6px; diff --git a/backend/workflows/static/workflows/css/admin_tools.css b/backend/workflows/static/workflows/css/admin_tools.css index 619303a..cf2d4af 100644 --- a/backend/workflows/static/workflows/css/admin_tools.css +++ b/backend/workflows/static/workflows/css/admin_tools.css @@ -12,6 +12,19 @@ h1 { margin: 12px 0 6px; color: #000078; } .branding-block-head { margin-bottom: 12px; } .branding-block-head h2 { margin: 0; color: #17345e; font-size: 18px; } .branding-block-head p { margin: 4px 0 0; color: #60738d; font-size: 13px; } +.branding-inline-head, .company-inline-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 14px; } +.branding-inline-trigger, .company-inline-trigger { min-width: 112px; } +.branding-inline-view.is-hidden, .branding-inline-form.is-hidden, .company-inline-view.is-hidden, .company-inline-form.is-hidden { display: none; } +.branding-inline-value, .company-inline-value { min-height: 40px; padding: 10px 12px; border: 1px solid #d9e4f1; border-radius: 10px; background: rgba(248,251,255,0.92); color: #18335b; line-height: 1.45; word-break: break-word; } +.branding-inline-actions, .company-inline-actions { display: flex; gap: 10px; margin-top: 14px; } +.branding-inline-error, .company-inline-error { margin-top: 6px; color: #ab1e1e; font-size: 12px; line-height: 1.4; } +.company-inline-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 14px; } +.company-inline-trigger { min-width: 112px; } +.company-inline-view.is-hidden, .company-inline-form.is-hidden { display: none; } +.company-inline-value { min-height: 40px; padding: 10px 12px; border: 1px solid #d9e4f1; border-radius: 10px; background: rgba(248,251,255,0.92); color: #18335b; line-height: 1.45; word-break: break-word; } +.company-inline-actions { display: flex; gap: 10px; margin-top: 14px; } +.company-inline-error { margin-top: 6px; color: #ab1e1e; font-size: 12px; line-height: 1.4; } +.field.has-error input, .field.has-error select, .field.has-error textarea { border-color: #e3a3a3; background: #fffafa; box-shadow: 0 0 0 4px rgba(185, 28, 28, 0.06); } .lang-pairs { align-items: start; } .lang-block { border: 1px solid #d9e4f1; border-radius: 14px; background: rgba(255,255,255,0.82); padding: 12px; } .lang-block h3 { margin: 0 0 10px; color: #223b63; font-size: 15px; } @@ -146,14 +159,18 @@ th { background: #f6f9ff; color: #334155; } .app-registry-card { border: 1px solid #d9e4f1; border-radius: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(246,250,255,0.95)); padding: 16px; box-shadow: inset 0 1px 0 rgba(255,255,255,0.94); transition: border-color 180ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 220ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 180ms cubic-bezier(0.2, 0.8, 0.2, 1); } .app-registry-card:hover { transform: translateY(-1px); box-shadow: 0 12px 24px rgba(16, 32, 57, 0.06); border-color: #c9d8eb; } .app-registry-card.is-disabled { opacity: 0.84; } +.app-registry-card.is-dragging { opacity: 0.55; transform: rotate(0.4deg); box-shadow: 0 18px 28px rgba(16, 32, 57, 0.14); } .app-registry-card[hidden] { display: none !important; } .app-registry-card-head { display: flex; justify-content: space-between; align-items: start; gap: 14px; margin-bottom: 14px; } .app-registry-card-title-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 4px; } .app-registry-card-title-row h2 { margin: 0; color: #17345e; font-size: 19px; } .app-registry-card-copy { margin: 8px 0 0; color: #60738d; max-width: 760px; } -.app-registry-summary { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(260px, 0.9fr); gap: 16px; align-items: center; list-style: none; cursor: pointer; } +.app-registry-summary { display: grid; grid-template-columns: 28px minmax(0, 1.5fr) minmax(260px, 0.9fr); gap: 16px; align-items: center; list-style: none; cursor: pointer; } .app-registry-summary::-webkit-details-marker { display: none; } .app-registry-summary::marker { display: none; } +.app-registry-drag-handle { display: inline-flex; align-items: center; justify-content: center; width: 28px; min-height: 42px; border-radius: 10px; border: 1px dashed #cbd7e6; background: #f8fbff; color: #5f6f85; font-size: 15px; letter-spacing: 0.04em; cursor: grab; user-select: none; } +.app-registry-card.is-dragging .app-registry-drag-handle { cursor: grabbing; } +.app-registry-card.drag-disabled .app-registry-drag-handle { opacity: 0.4; cursor: not-allowed; border-style: solid; } .app-registry-summary-main { min-width: 0; } .app-registry-summary-meta { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; align-items: center; } .app-registry-card-grid { display: grid; grid-template-columns: repeat(2, minmax(260px, 1fr)); gap: 12px; align-items: start; } @@ -187,6 +204,8 @@ th { background: #f6f9ff; color: #334155; } .actions { white-space: nowrap; } @media (max-width: 760px) { .grid { grid-template-columns: 1fr; } + .branding-inline-head, .company-inline-head { flex-direction: column; } + .branding-inline-actions, .company-inline-actions { flex-direction: column; } .trial-summary-grid { grid-template-columns: 1fr 1fr; } .trial-expired-shell { padding: 20px 16px 28px; } .trial-expired-card { padding: 18px; } @@ -200,3 +219,9 @@ th { background: #f6f9ff; color: #334155; } .app-registry-copy-panel { grid-column: auto; } .app-registry-savebar { align-items: stretch; flex-direction: column; } } +.app-registry-groups { display: grid; gap: 18px; } +.app-registry-group { border: 1px solid #d7e3f0; border-radius: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(244,248,255,0.95)); padding: 14px; box-shadow: inset 0 1px 0 rgba(255,255,255,0.94); } +.app-registry-group-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 14px; } +.app-registry-group-head h2 { margin: 0; color: #17345e; font-size: 18px; } +.app-registry-group-body { display: grid; gap: 14px; } +.app-registry-group[hidden] { display: none !important; } diff --git a/backend/workflows/templates/workflows/account_profile.html b/backend/workflows/templates/workflows/account_profile.html index 10a5202..6124e0c 100644 --- a/backend/workflows/templates/workflows/account_profile.html +++ b/backend/workflows/templates/workflows/account_profile.html @@ -180,12 +180,100 @@ {% trans "Aktualisieren Sie Ihr Passwort direkt im Konto." %} +
+ {% trans "TOTP" %} + + {% if account_user_profile.totp_enabled %} + {% trans "Zweiter Faktor ist aktiv und wird bei der Anmeldung geprüft." %} + {% else %} + {% trans "Standardmäßig deaktiviert. Kann hier jederzeit aktiviert werden." %} + {% endif %} + +
+
{% trans "Sitzung" %} {% trans "Sie können sich jederzeit sicher vom aktuellen Gerät abmelden." %}
+
+
+
+

{% trans "Zwei-Faktor-Authentifizierung" %}

+

{% trans "Aktivieren Sie TOTP mit einer Authenticator-App. Standardmäßig bleibt es ausgeschaltet." %}

+
+ +
+ + {% if account_user_profile.totp_enabled %} +
+ + +
+
+ {% csrf_token %} + + + +
+ {% else %} +
+ + +
+

{% trans "Wenn Ihre App keinen QR-Code scannen kann, tragen Sie den Schlüssel oder den otpauth-Link manuell ein." %}

+
+ {% csrf_token %} + + + +
+ {% endif %} +
+
{% trans "Passwort ändern" %}
diff --git a/backend/workflows/templates/workflows/app_registry.html b/backend/workflows/templates/workflows/app_registry.html index 5c07e5a..d58d709 100644 --- a/backend/workflows/templates/workflows/app_registry.html +++ b/backend/workflows/templates/workflows/app_registry.html @@ -1,6 +1,7 @@ {% extends 'workflows/base_shell.html' %} {% load static i18n %} {% trans "Ungespeicherte Änderungen" as dirty_state_label %} +{% trans "Sortierung" as sort_label %} {% block title %}{% trans "App Registry" %}{% endblock %} @@ -40,150 +41,184 @@
-
- {% for row in rows %} -
- -
-
-

{{ row.definition.title }}

- {% if row.config.is_enabled %} - {% trans "Aktiv" %} +
{% trans "Für eine verlässliche Reihenfolge bitte ohne aktive Filter umsortieren." %}
+
+ {% for section_key, section_label in section_choices %} +
+
+
+

{{ section_label }}

+

+ {% if section_key == 'platform' %} + {% trans "Produktweite Steuerung und nur für die Platform sichtbare Oberflächen." %} + {% elif section_key == 'admin' %} + {% trans "Administrative Apps für Kundenrollen mit erhöhter Verantwortung." %} {% else %} - {% trans "Deaktiviert" %} + {% trans "Operative Apps, die im täglichen Einsatz auf der Landing Page erscheinen." %} {% endif %} -

-
{{ row.config.key }}
-

{{ row.definition.description }}

-

{% trans "Empfohlener Standardzugriff:" %} {{ row.default_visibility_summary }}

+

-
- - {% if row.config.section == 'platform_apps' %} - {% trans "Platform Apps" %} - {% elif row.config.section == 'admin_apps' %} - {% trans "Admin Apps" %} - {% else %} - {% trans "Apps" %} - {% endif %} - - {% trans "Sortierung" %}: {{ row.config.sort_order }} - {% if not row.config.visible_to_super_admin and not row.config.visible_to_admin and not row.config.visible_to_it_staff and not row.config.visible_to_staff %} - {% trans "Platform only" %} - {% elif row.config.visible_to_super_admin and row.config.visible_to_admin and row.config.visible_to_it_staff and row.config.visible_to_staff %} - {% trans "Alle Firmenrollen" %} - {% else %} - - {% if row.config.visible_to_super_admin %}{% trans "Super Admin" %}{% endif %} - {% if row.config.visible_to_admin %}{% if row.config.visible_to_super_admin %} + {% endif %}{% trans "Admin" %}{% endif %} - {% if row.config.visible_to_it_staff %}{% if row.config.visible_to_super_admin or row.config.visible_to_admin %} + {% endif %}{% trans "IT Staff" %}{% endif %} - {% if row.config.visible_to_staff %}{% if row.config.visible_to_super_admin or row.config.visible_to_admin or row.config.visible_to_it_staff %} + {% endif %}{% trans "Staff" %}{% endif %} - - {% endif %} -
-
- -
-
-

{% trans "Verfügbarkeit" %}

-
- -
-

{% trans "Deaktivierte Apps erscheinen nicht auf der Landing Page, selbst wenn Rollen sie sehen dürften." %}

-
- -
-

{% trans "Sichtbarkeit nach Rolle" %}

-
- - - - -
-

{% trans "Wenn keine Firmenrolle aktiv ist, bleibt die App nur für die Platform sichtbar." %}

-
- -
-

{% trans "Platzierung" %}

-
-
- - -
-
- - -
-
-
- -
-

{% trans "Bezeichnungen & Texte" %}

-
-
-

{% trans "Deutsch" %}

-
- - -
-
- - -
-
- - -
-
-
-

{% trans "English" %}

-
- - -
-
- - -
-
- - -
-
-
-
+ {{ section_label }}
-
+
+ {% for row in rows %} + {% if row.config.section == section_key %} +
+ + +
+
+

{{ row.definition.title }}

+ {% if row.config.is_enabled %} + {% trans "Aktiv" %} + {% else %} + {% trans "Deaktiviert" %} + {% endif %} +
+
{{ row.config.key }}
+

{{ row.definition.description }}

+

{% trans "Empfohlener Standardzugriff:" %} {{ row.default_visibility_summary }}

+
+
+ + {% if row.config.section == 'platform' %} + {% trans "Platform Apps" %} + {% elif row.config.section == 'admin' %} + {% trans "Admin Apps" %} + {% else %} + {% trans "Apps" %} + {% endif %} + + {% trans "Sortierung" %}: {{ row.config.sort_order }} + {% if not row.config.visible_to_super_admin and not row.config.visible_to_admin and not row.config.visible_to_it_staff and not row.config.visible_to_staff %} + {% trans "Platform only" %} + {% elif row.config.visible_to_super_admin and row.config.visible_to_admin and row.config.visible_to_it_staff and row.config.visible_to_staff %} + {% trans "Alle Firmenrollen" %} + {% else %} + + {% if row.config.visible_to_super_admin %}{% trans "Super Admin" %}{% endif %} + {% if row.config.visible_to_admin %}{% if row.config.visible_to_super_admin %} + {% endif %}{% trans "Admin" %}{% endif %} + {% if row.config.visible_to_it_staff %}{% if row.config.visible_to_super_admin or row.config.visible_to_admin %} + {% endif %}{% trans "IT Staff" %}{% endif %} + {% if row.config.visible_to_staff %}{% if row.config.visible_to_super_admin or row.config.visible_to_admin or row.config.visible_to_it_staff %} + {% endif %}{% trans "Staff" %}{% endif %} + + {% endif %} +
+
+ +
+
+

{% trans "Verfügbarkeit" %}

+
+ +
+

{% trans "Deaktivierte Apps erscheinen nicht auf der Landing Page, selbst wenn Rollen sie sehen dürften." %}

+
+ +
+

{% trans "Sichtbarkeit nach Rolle" %}

+
+ + + + +
+

{% trans "Wenn keine Firmenrolle aktiv ist, bleibt die App nur für die Platform sichtbar." %}

+
+ +
+

{% trans "Platzierung" %}

+
+
+ + +
+
+ + +
{% trans "Wird per Drag-and-drop und Bereichswechsel dynamisch neu nummeriert." %}
+
+
+
+ +
+

{% trans "Bezeichnungen & Texte" %}

+
+
+

{% trans "Deutsch" %}

+
+ + +
+
+ + +
+
+ + +
+
+
+

{% trans "English" %}

+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+ {% endif %} + {% endfor %} +
+ {% endfor %}
@@ -204,13 +239,46 @@ const stateSelect = document.getElementById('app-registry-state'); const sectionSelect = document.getElementById('app-registry-section'); const cards = Array.from(document.querySelectorAll('[data-app-card]')); + const groups = Array.from(document.querySelectorAll('[data-app-group]')); + const groupBodies = Array.from(document.querySelectorAll('[data-app-group-body]')); const form = document.querySelector('form[action$="app-registry/save/"], form[action*="save_portal_app_registry"]') || document.querySelector('.stack-form'); const dirtyState = document.getElementById('app-registry-dirty-state'); + const reorderHint = document.getElementById('app-registry-reorder-hint'); + const SECTION_ORDER = ['app', 'platform', 'admin']; + let draggedCard = null; + + function cardsInDomOrder() { + return groupBodies.flatMap((body) => Array.from(body.querySelectorAll('[data-app-card]'))); + } + + function syncGroupContainers() { + cards.forEach((card) => { + const sectionField = card.querySelector('select[name^="section__"]'); + const targetSection = sectionField ? sectionField.value : card.dataset.section; + const targetBody = document.querySelector(`[data-app-group-body="${targetSection}"]`); + if (targetBody && card.parentElement !== targetBody) { + targetBody.appendChild(card); + } + card.dataset.section = targetSection; + }); + } + + function updateGroupVisibility() { + groups.forEach((group) => { + const visibleCards = group.querySelectorAll('[data-app-card]:not([hidden])').length; + group.hidden = visibleCards === 0; + const countBadge = group.querySelector('[data-app-group-count]'); + if (countBadge) { + countBadge.textContent = String(visibleCards); + } + }); + } function applyFilters() { const query = (searchInput?.value || '').trim().toLowerCase(); const state = stateSelect?.value || 'all'; const section = sectionSelect?.value || 'all'; + const filterActive = Boolean(query) || state !== 'all' || section !== 'all'; cards.forEach((card) => { const matchesQuery = !query || card.dataset.key.includes(query) || card.dataset.title.includes(query); @@ -221,20 +289,116 @@ (state === 'platform_only' && card.dataset.platformOnly === '1'); const matchesSection = section === 'all' || card.dataset.section === section; card.hidden = !(matchesQuery && matchesState && matchesSection); + card.draggable = !filterActive; + card.classList.toggle('drag-disabled', filterActive); + }); + + if (reorderHint) { + reorderHint.hidden = !filterActive; + } + updateGroupVisibility(); + } + + function normalizeSortOrders() { + const grouped = new Map(); + const orderedCards = cardsInDomOrder(); + SECTION_ORDER.forEach((key) => grouped.set(key, [])); + + orderedCards.forEach((card) => { + const sectionField = card.querySelector('select[name^="section__"]'); + const sortInput = card.querySelector('[data-sort-order-input]'); + const sectionKey = sectionField ? sectionField.value : card.dataset.section; + const sortValue = sortInput ? parseInt(sortInput.value || '0', 10) : 0; + if (!grouped.has(sectionKey)) grouped.set(sectionKey, []); + grouped.get(sectionKey).push({ + card, + sortInput, + sortValue: Number.isNaN(sortValue) ? 0 : sortValue, + title: (card.dataset.title || '').toLowerCase(), + }); + }); + + grouped.forEach((items, sectionKey) => { + items + .sort((a, b) => { + const domOrder = orderedCards.indexOf(a.card) - orderedCards.indexOf(b.card); + return (a.sortValue - b.sortValue) || domOrder || a.title.localeCompare(b.title); + }) + .forEach((item, index) => { + if (item.sortInput) item.sortInput.value = index; + item.card.dataset.section = sectionKey; + const badge = item.card.querySelector('[data-sort-badge]'); + if (badge) badge.textContent = `{{ sort_label|escapejs }}: ${index}`; + }); }); } - function markDirty() { - if (!dirtyState) return; - dirtyState.textContent = "{{ dirty_state_label|escapejs }}"; + function closestCardAfterPointer(container, clientY) { + const siblingCards = Array.from(container.querySelectorAll('[data-app-card]:not(.is-dragging)')); + return siblingCards.find((card) => { + const rect = card.getBoundingClientRect(); + return clientY < rect.top + rect.height / 2; + }) || null; } - searchInput?.addEventListener('input', applyFilters); - stateSelect?.addEventListener('change', applyFilters); - sectionSelect?.addEventListener('change', applyFilters); - form?.addEventListener('input', markDirty); - form?.addEventListener('change', markDirty); + function markDirty() { + if (dirtyState) { + dirtyState.textContent = '{{ dirty_state_label|escapejs }}'; + } + } + if (form) { + form.addEventListener('change', (event) => { + if (event.target.matches('select[name^="section__"], [data-sort-order-input]')) { + syncGroupContainers(); + normalizeSortOrders(); + } + markDirty(); + applyFilters(); + }); + + form.addEventListener('input', () => { + markDirty(); + }); + } + + cards.forEach((card) => { + card.addEventListener('dragstart', () => { + if (card.classList.contains('drag-disabled')) return; + draggedCard = card; + card.classList.add('is-dragging'); + }); + + card.addEventListener('dragend', () => { + card.classList.remove('is-dragging'); + draggedCard = null; + normalizeSortOrders(); + markDirty(); + applyFilters(); + }); + }); + + groupBodies.forEach((body) => { + body.addEventListener('dragover', (event) => { + if (!draggedCard || draggedCard.classList.contains('drag-disabled')) return; + event.preventDefault(); + const nextCard = closestCardAfterPointer(body, event.clientY); + if (nextCard) { + body.insertBefore(draggedCard, nextCard); + } else { + body.appendChild(draggedCard); + } + }); + }); + + [searchInput, stateSelect, sectionSelect].forEach((control) => { + if (!control) return; + control.addEventListener('input', applyFilters); + control.addEventListener('change', applyFilters); + }); + + syncGroupContainers(); + normalizeSortOrders(); applyFilters(); })(); diff --git a/backend/workflows/templates/workflows/auth/login.html b/backend/workflows/templates/workflows/auth/login.html index ab0ff1d..25e80ee 100644 --- a/backend/workflows/templates/workflows/auth/login.html +++ b/backend/workflows/templates/workflows/auth/login.html @@ -26,12 +26,16 @@ {% endif %} -
{{ form.username.label_tag }}{{ form.username }}
-
{{ form.password.label_tag }}{{ form.password }}
+
{{ form.username.label_tag }}{{ form.username }}
+
{{ form.password.label_tag }}{{ form.password }}
+
+ {{ form.otp_code.label_tag }}{{ form.otp_code }} +
{% trans "Nur erforderlich, wenn TOTP für Ihr Konto aktiviert ist." %}
+
diff --git a/backend/workflows/templates/workflows/branding_settings.html b/backend/workflows/templates/workflows/branding_settings.html index f1a989f..45815cd 100644 --- a/backend/workflows/templates/workflows/branding_settings.html +++ b/backend/workflows/templates/workflows/branding_settings.html @@ -15,71 +15,66 @@ {% include 'workflows/includes/messages.html' %}
-
- {% csrf_token %} -
-
-
-

{% trans "Identität" %}

-

{% trans "Titel, Firmenname und zentrale Spracheinstellungen." %}

+
+ {% for section in branding_sections %} +
+
+
+

{{ section.title }}

+

{{ section.subtitle }}

-
-
- - {{ form.portal_title }} -
-
- - {{ form.company_name }} -
-
- - {{ form.company_domain }} -
{% trans "Wird für E-Mail-Vorschläge und Domain-bezogene Standardtexte verwendet, z. B. tub.co." %}
-
-
- - {{ form.default_language }} -
-
- - {{ form.login_subtitle }} -
-
-
+ +
-
-
-

{% trans "Farben & Erscheinungsbild" %}

-

{% trans "Zentrale visuelle Markenwerte und Browser-Icon." %}

+
+ {% if section.key == 'legal' %} +
+
+

{% trans "Deutsch" %}

+ {% for row in section.rows|slice:":2" %} +
+ +
{{ row.value|default:"-" }}
+
+ {% endfor %} +
+
+

{% trans "English" %}

+ {% for row in section.rows|slice:"2:" %} +
+ +
{{ row.value|default:"-" }}
+
+ {% endfor %} +
+ {% else %}
-
- - {{ form.primary_color }} -
-
- - {{ form.secondary_color }} -
-
- - {{ form.logo_image }} -
{% trans "Erlaubte Formate: SVG, PNG, JPG, JPEG, WEBP. Maximal 5 MB." %}
- {% for error in form.logo_image.errors %}
{{ error }}
{% endfor %} - {% if branding.logo_image %} -
{% trans "Aktuelles Logo:" %} {% trans "öffnen" %}
- {% endif %} -
-
- - {{ form.favicon_image }} -
{% trans "Erlaubte Formate: ICO, PNG, SVG, WEBP. Maximal 2 MB." %}
- {% for error in form.favicon_image.errors %}
{{ error }}
{% endfor %} - {% if branding.favicon_image %} -
{% trans "Aktuelles Favicon:" %} {% trans "öffnen" %}
- {% endif %} + {% for row in section.rows %} +
+ +
+ {% if row.is_file %} + {% if row.value %} + {% trans "öffnen" %} + {% else %} + - + {% endif %} + {% else %} + {{ row.value|default:"-" }} + {% endif %} +
+ {% if row.hint %}
{{ row.hint }}
{% endif %}
+ {% endfor %} + {% if section.key == 'appearance' %}
@@ -101,78 +96,121 @@
+ {% endif %}
-
+ {% endif %} +
-
-
-

{% trans "Kommunikation" %}

-

{% trans "Absender, Support und PDF-Branding für ausgehende Kommunikation." %}

-
-
-
- - {{ form.support_email }} -
-
- - {{ form.sender_display_name }} -
{% trans "Wird für ausgehende System-E-Mails als Anzeigename verwendet." %}
-
-
- - {{ form.pdf_letterhead }} -
{% trans "Erlaubtes Format: PDF. Maximal 10 MB." %}
- {% for error in form.pdf_letterhead.errors %}
{{ error }}
{% endfor %} - {% if branding.pdf_letterhead %} -
{% trans "Aktueller Briefkopf:" %} {% trans "öffnen" %}
- {% endif %} -
-
-
+ + {% csrf_token %} + -
-
-

{% trans "Footer & Rechtliches" %}

-

{% trans "Gemeinsame Footer-Texte und rechtliche Hinweise für die Shell." %}

-
+ {% if section.key == 'legal' %}

{% trans "Deutsch" %}

-
- - {{ form.footer_text }} -
-
- - {{ form.legal_notice }} + {% for row in section.rows|slice:":2" %} +
+ + {{ row.bound_field }} + {% if row.bound_field.errors %}
{{ row.bound_field.errors|join:", " }}
{% endif %}
+ {% endfor %}

{% trans "English" %}

-
- - {{ form.footer_text_en }} -
-
- - {{ form.legal_notice_en }} + {% for row in section.rows|slice:"2:" %} +
+ + {{ row.bound_field }} + {% if row.bound_field.errors %}
{{ row.bound_field.errors|join:", " }}
{% endif %}
+ {% endfor %}
-
-
+ {% else %} +
+ {% for row in section.rows %} +
+ + {{ row.bound_field }} + {% if row.hint %}
{{ row.hint }}
{% endif %} + {% if row.is_file and row.value %} +
+ {% if row.name == 'logo_image' %}{% trans "Aktuelles Logo:" %} + {% elif row.name == 'favicon_image' %}{% trans "Aktuelles Favicon:" %} + {% elif row.name == 'pdf_letterhead' %}{% trans "Aktueller Briefkopf:" %} + {% endif %} + {% trans "öffnen" %} +
+ {% endif %} + {% if row.bound_field.errors %}
{{ row.bound_field.errors|join:", " }}
{% endif %} +
+ {% endfor %} + {% if section.key == 'appearance' %} +
+
+
+
+ +
+ {{ branding.company_name }} + {{ branding.portal_title }} +
+
+
+ {% trans "Primärfarbe" %} + {% trans "Sekundärfarbe" %} +
+ +
+
+
+ {% endif %} +
+ {% endif %} + +
+ + +
+
+
+ {% endfor %} +
{% trans "Die aktuell gesetzte Deployment-Branding bleibt erhalten, bis hier Werte geändert oder Dateien hochgeladen werden." %}
-
- + {% endblock %} {% block extra_scripts %} +{% endblock %} diff --git a/backend/workflows/templates/workflows/home.html b/backend/workflows/templates/workflows/home.html index 54d2753..6329e7e 100644 --- a/backend/workflows/templates/workflows/home.html +++ b/backend/workflows/templates/workflows/home.html @@ -3,15 +3,15 @@ {% block title %}{{ portal_title }}{% endblock %} - +{% block shell_header %} +{% include 'workflows/includes/app_header.html' with header_show_lang=1 header_inside_shell=1 %} +{% endblock %} {% block extra_css %} {% endblock %} {% block shell_body %} -{% include 'workflows/includes/app_header.html' with header_show_lang=1 %} -
diff --git a/backend/workflows/tests/test_account_ui.py b/backend/workflows/tests/test_account_ui.py index 9716e2e..86b19fa 100644 --- a/backend/workflows/tests/test_account_ui.py +++ b/backend/workflows/tests/test_account_ui.py @@ -1,7 +1,9 @@ from django.contrib.auth import get_user_model from django.test import Client, TestCase +from django.utils import timezone from workflows.models import UserProfile +from workflows.totp import generate_totp_token class AccountUISmokeTests(TestCase): @@ -55,3 +57,59 @@ class AccountUISmokeTests(TestCase): self.assertEqual(self.user.email, 'updated@example.com') self.assertEqual(profile.phone_number, '030 123456') self.assertEqual(profile.job_title, 'IT Manager') + + def test_totp_can_be_enabled_from_account(self): + response = self.client.post( + '/account/', + { + 'account_form': 'totp_enable', + 'current_password': 'secret-12345', + 'verification_code': '000000', + }, + HTTP_HOST='localhost', + follow=True, + ) + self.assertEqual(response.status_code, 200) + self.user.refresh_from_db() + profile = self.user.profile + pending_secret = self.client.session.get('account_totp_pending_secret') + self.assertTrue(pending_secret) + valid_code = generate_totp_token(pending_secret, int(timezone.now().timestamp())) + + response = self.client.post( + '/account/', + { + 'account_form': 'totp_enable', + 'current_password': 'secret-12345', + 'verification_code': valid_code, + }, + HTTP_HOST='localhost', + follow=True, + ) + self.assertEqual(response.status_code, 200) + profile.refresh_from_db() + self.assertTrue(profile.totp_enabled) + self.assertTrue(profile.totp_secret) + + def test_login_requires_totp_when_enabled(self): + profile = self.user.profile + profile.totp_secret = 'JBSWY3DPEHPK3PXP' + profile.totp_enabled = True + profile.save(update_fields=['totp_secret', 'totp_enabled', 'updated_at']) + + client = Client() + response = client.post( + '/accounts/login/', + {'username': 'profile-user', 'password': 'secret-12345'}, + HTTP_HOST='localhost', + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'TOTP-Code') + + token = generate_totp_token(profile.totp_secret, int(timezone.now().timestamp())) + response = client.post( + '/accounts/login/', + {'username': 'profile-user', 'password': 'secret-12345', 'otp_code': token}, + HTTP_HOST='localhost', + ) + self.assertEqual(response.status_code, 302) diff --git a/backend/workflows/totp.py b/backend/workflows/totp.py new file mode 100644 index 0000000..a15063b --- /dev/null +++ b/backend/workflows/totp.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import base64 +import hashlib +import hmac +import secrets +import struct +from urllib.parse import quote + + +def generate_totp_secret(length: int = 20) -> str: + return base64.b32encode(secrets.token_bytes(length)).decode('ascii').rstrip('=') + + +def normalize_totp_token(value: str | None) -> str: + return ''.join(ch for ch in (value or '').strip() if ch.isdigit()) + + +def _secret_bytes(secret: str) -> bytes: + padded = secret.strip().replace(' ', '').upper() + padding = '=' * ((8 - len(padded) % 8) % 8) + return base64.b32decode(padded + padding, casefold=True) + + +def generate_totp_token(secret: str, for_time: int, *, digits: int = 6, period: int = 30) -> str: + counter = int(for_time // period) + key = _secret_bytes(secret) + msg = struct.pack('>Q', counter) + digest = hmac.new(key, msg, hashlib.sha1).digest() + offset = digest[-1] & 0x0F + code_int = struct.unpack('>I', digest[offset:offset + 4])[0] & 0x7FFFFFFF + return str(code_int % (10**digits)).zfill(digits) + + +def verify_totp_token(secret: str, token: str, *, for_time: int, digits: int = 6, period: int = 30, window: int = 1) -> bool: + normalized = normalize_totp_token(token) + if len(normalized) != digits: + return False + for offset in range(-window, window + 1): + candidate_time = for_time + (offset * period) + if generate_totp_token(secret, candidate_time, digits=digits, period=period) == normalized: + return True + return False + + +def build_otpauth_uri(secret: str, *, account_name: str, issuer: str) -> str: + label = quote(f'{issuer}:{account_name}') + issuer_q = quote(issuer) + return f'otpauth://totp/{label}?secret={secret}&issuer={issuer_q}&algorithm=SHA1&digits=6&period=30' diff --git a/backend/workflows/views.py b/backend/workflows/views.py index 6b9611a..695f488 100644 --- a/backend/workflows/views.py +++ b/backend/workflows/views.py @@ -24,7 +24,7 @@ from django.utils.translation import gettext as _, gettext_lazy from django.utils.translation import get_language, override from django.urls import reverse -from .app_registry import build_portal_app_sections, get_portal_app_registry_rows +from .app_registry import build_portal_app_sections, get_portal_app_registry_rows, normalize_portal_app_sort_orders from .backup_ops import ( create_backup_bundle, delete_backup_bundle, @@ -33,7 +33,7 @@ from .backup_ops import ( verify_backup_bundle, ) from .branding import get_branding_email_copy, get_company_email_domain, get_default_notification_templates, get_portal_trial_config, is_trial_expired -from .forms import AccountAvatarForm, AccountDetailsForm, OffboardingRequestForm, OnboardingRequestForm, PortalBrandingForm, PortalCompanyConfigForm, PortalTrialConfigForm, UserManagementCreateForm +from .forms import AccountAvatarForm, AccountDetailsForm, AccountTOTPDisableForm, AccountTOTPEnableForm, OffboardingRequestForm, OnboardingRequestForm, PortalBrandingForm, PortalCompanyConfigForm, PortalTrialConfigForm, UserManagementCreateForm from .form_builder import ( DEFAULT_FIELD_ORDER, LOCKED_FIELD_RULES, @@ -46,6 +46,7 @@ from .models import AdminAuditLog, AsyncTaskLog, EmployeeProfile, FormFieldConfi from .emailing import send_system_email from .roles import ROLE_GROUP_NAMES, ROLE_LABELS, ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, assign_user_role, get_user_role_key, get_user_role_label, user_has_capability from .services import get_email_test_redirect, is_email_test_mode, is_nextcloud_enabled, upload_to_nextcloud +from .totp import build_otpauth_uri, generate_totp_secret from .tasks import ( _generate_onboarding_intro_pdf, _generate_onboarding_intro_session_pdf, @@ -128,10 +129,22 @@ def healthz(request): @login_required def account_profile_page(request): + session_secret_key = 'account_totp_pending_secret' profile, created = UserProfile.objects.get_or_create(user=request.user) + pending_totp_secret = request.session.get(session_secret_key) or '' + if profile.totp_enabled: + pending_totp_secret = '' + request.session.pop(session_secret_key, None) + elif not pending_totp_secret: + pending_totp_secret = generate_totp_secret() + request.session[session_secret_key] = pending_totp_secret + avatar_form = AccountAvatarForm(instance=profile) details_form = AccountDetailsForm(user=request.user, profile=profile) + totp_enable_form = AccountTOTPEnableForm(user=request.user, secret=pending_totp_secret) + totp_disable_form = AccountTOTPDisableForm(user=request.user, profile=profile) account_edit_open = False + totp_edit_open = False if request.method == 'POST': form_kind = (request.POST.get('account_form') or '').strip() if form_kind == 'avatar': @@ -149,6 +162,28 @@ def account_profile_page(request): messages.success(request, _('Profildaten gespeichert.')) return redirect('account_profile_page') messages.error(request, _('Profildaten konnten nicht gespeichert werden.')) + elif form_kind == 'totp_enable': + totp_edit_open = True + totp_enable_form = AccountTOTPEnableForm(request.POST, user=request.user, secret=pending_totp_secret) + if totp_enable_form.is_valid(): + profile.enable_totp(pending_totp_secret) + request.session.pop(session_secret_key, None) + messages.success(request, _('TOTP wurde aktiviert.')) + return redirect('account_profile_page') + messages.error(request, _('TOTP konnte nicht aktiviert werden.')) + elif form_kind == 'totp_disable': + totp_edit_open = True + totp_disable_form = AccountTOTPDisableForm(request.POST, user=request.user, profile=profile) + if totp_disable_form.is_valid(): + profile.disable_totp() + request.session.pop(session_secret_key, None) + messages.success(request, _('TOTP wurde deaktiviert.')) + return redirect('account_profile_page') + messages.error(request, _('TOTP konnte nicht deaktiviert werden.')) + + branding_context = get_branding_email_copy() + totp_account_name = (request.user.email or request.user.username or '').strip() + totp_issuer = (branding_context.get('portal_title') or branding_context.get('company_name') or 'Workdock').strip() return render( request, 'workflows/account_profile.html', @@ -157,8 +192,13 @@ def account_profile_page(request): 'account_user_profile': profile, 'avatar_form': avatar_form, 'details_form': details_form, + 'totp_enable_form': totp_enable_form, + 'totp_disable_form': totp_disable_form, 'account_edit_open': account_edit_open, + 'totp_edit_open': totp_edit_open, 'role_label': get_user_role_label(request.user), + 'totp_pending_secret': pending_totp_secret, + 'totp_otpauth_uri': '' if profile.totp_enabled else build_otpauth_uri(pending_totp_secret, account_name=totp_account_name, issuer=totp_issuer), }, ) @@ -426,6 +466,7 @@ def job_monitor_page(request): @require_POST def save_portal_app_registry(request): rows = get_portal_app_registry_rows() + updated_configs = [] for row in rows: config = row['config'] key = config.key @@ -448,6 +489,9 @@ def save_portal_app_registry(request): config.action_label_override = (request.POST.get(f'action_label_override__{key}') or '').strip() config.action_label_override_en = (request.POST.get(f'action_label_override_en__{key}') or '').strip() config.save() + updated_configs.append(config) + + normalize_portal_app_sort_orders() _audit( request, @@ -607,6 +651,8 @@ def portal_branding_page(request): { 'form': form, 'branding': branding, + 'branding_sections': _build_branding_sections(form, branding), + 'editing_branding_section': '', }, ) @@ -615,7 +661,18 @@ def portal_branding_page(request): @require_POST def save_portal_branding(request): branding, created = PortalBranding.objects.get_or_create(name='Default') - form = PortalBrandingForm(request.POST, request.FILES, instance=branding) + section_key = (request.POST.get('section_key') or '').strip() + data = request.POST.copy() + for field_name in PortalBrandingForm.Meta.fields: + if field_name not in data: + field = PortalBranding._meta.get_field(field_name) + if getattr(field, 'many_to_many', False): + continue + if getattr(field, 'null', False) and getattr(branding, field_name, None) is None: + data[field_name] = '' + else: + data[field_name] = getattr(branding, field_name, '') or '' + form = PortalBrandingForm(data, request.FILES, instance=branding) if not form.is_valid(): messages.error(request, _('Branding konnte nicht gespeichert werden. Bitte prüfen Sie die Eingaben.')) return render( @@ -624,6 +681,8 @@ def save_portal_branding(request): { 'form': form, 'branding': branding, + 'branding_sections': _build_branding_sections(form, branding), + 'editing_branding_section': section_key, }, status=400, ) @@ -650,10 +709,76 @@ def save_portal_branding(request): { 'form': PortalBrandingForm(instance=branding), 'branding': branding, + 'branding_sections': _build_branding_sections(PortalBrandingForm(instance=branding), branding), + 'editing_branding_section': '', }, ) +def _build_branding_sections(form, branding): + sections = [ + { + 'key': 'identity', + 'title': _('Identität'), + 'subtitle': _('Titel, Firmenname und zentrale Spracheinstellungen.'), + 'fields': ['portal_title', 'company_name', 'company_domain', 'default_language', 'login_subtitle'], + 'field_full': {'login_subtitle'}, + 'hint_map': { + 'company_domain': _('Wird für E-Mail-Vorschläge und Domain-bezogene Standardtexte verwendet, z. B. tub.co.'), + }, + }, + { + 'key': 'appearance', + 'title': _('Farben & Erscheinungsbild'), + 'subtitle': _('Zentrale visuelle Markenwerte und Browser-Icon.'), + 'fields': ['primary_color', 'secondary_color', 'logo_image', 'favicon_image'], + 'field_full': set(), + 'hint_map': { + 'logo_image': _('Erlaubte Formate: SVG, PNG, JPG, JPEG, WEBP. Maximal 5 MB.'), + 'favicon_image': _('Erlaubte Formate: ICO, PNG, SVG, WEBP. Maximal 2 MB.'), + }, + }, + { + 'key': 'communication', + 'title': _('Kommunikation'), + 'subtitle': _('Absender, Support und PDF-Branding für ausgehende Kommunikation.'), + 'fields': ['support_email', 'sender_display_name', 'pdf_letterhead'], + 'field_full': {'pdf_letterhead'}, + 'hint_map': { + 'sender_display_name': _('Wird für ausgehende System-E-Mails als Anzeigename verwendet.'), + 'pdf_letterhead': _('Erlaubtes Format: PDF. Maximal 10 MB.'), + }, + }, + { + 'key': 'legal', + 'title': _('Footer & Rechtliches'), + 'subtitle': _('Gemeinsame Footer-Texte und rechtliche Hinweise für die Shell.'), + 'fields': ['footer_text', 'legal_notice', 'footer_text_en', 'legal_notice_en'], + 'field_full': {'legal_notice', 'legal_notice_en'}, + 'hint_map': {}, + }, + ] + for section in sections: + rows = [] + for field_name in section['fields']: + field = form[field_name] + value = getattr(branding, field_name, '') or '' + is_file = bool(getattr(field.field.widget, 'input_type', '') == 'file') + rows.append( + { + 'name': field_name, + 'bound_field': field, + 'label': field.label, + 'value': value, + 'is_file': is_file, + 'is_full': field_name in section.get('field_full', set()), + 'hint': section.get('hint_map', {}).get(field_name, ''), + } + ) + section['rows'] = rows + return sections + + @_require_capability('manage_company_config') def portal_company_config_page(request): company_config, created = PortalCompanyConfig.objects.get_or_create(name='Default') @@ -664,6 +789,8 @@ def portal_company_config_page(request): { 'form': form, 'company_config': company_config, + 'company_config_sections': _build_company_config_sections(form, company_config), + 'editing_company_section': '', }, ) @@ -672,7 +799,12 @@ def portal_company_config_page(request): @require_POST def save_portal_company_config(request): company_config, created = PortalCompanyConfig.objects.get_or_create(name='Default') - form = PortalCompanyConfigForm(request.POST, instance=company_config) + section_key = (request.POST.get('section_key') or '').strip() + data = request.POST.copy() + for field_name in PortalCompanyConfigForm.Meta.fields: + if field_name not in data: + data[field_name] = getattr(company_config, field_name, '') or '' + form = PortalCompanyConfigForm(data, instance=company_config) if not form.is_valid(): messages.error(request, _('Firmenkonfiguration konnte nicht gespeichert werden. Bitte prüfen Sie die Eingaben.')) return render( @@ -681,6 +813,8 @@ def save_portal_company_config(request): { 'form': form, 'company_config': company_config, + 'company_config_sections': _build_company_config_sections(form, company_config), + 'editing_company_section': section_key, }, status=400, ) @@ -708,10 +842,56 @@ def save_portal_company_config(request): { 'form': PortalCompanyConfigForm(instance=company_config), 'company_config': company_config, + 'company_config_sections': _build_company_config_sections(PortalCompanyConfigForm(instance=company_config), company_config), + 'editing_company_section': '', }, ) +def _build_company_config_sections(form, company_config): + sections = [ + { + 'key': 'profile', + 'title': _('Firmenprofil'), + 'subtitle': _('Rechtlicher Name und zentrale Stammdaten der Firma.'), + 'fields': ['legal_company_name', 'phone_number', 'website_url', 'country'], + }, + { + 'key': 'address', + 'title': _('Adresse & Register'), + 'subtitle': _('Anschrift sowie optionale Register- und Steuerangaben.'), + 'fields': ['street_address', 'postal_code', 'city', 'registration_number', 'vat_id'], + }, + { + 'key': 'contacts', + 'title': _('Kontaktpunkte'), + 'subtitle': _('Zentrale Ansprechpartner für HR, IT und Operations.'), + 'fields': ['hr_contact_email', 'it_contact_email', 'operations_contact_email'], + }, + { + 'key': 'public', + 'title': _('Recht & Öffentlichkeit'), + 'subtitle': _('Öffentliche Links für Website, Impressum und Datenschutz.'), + 'fields': ['imprint_url', 'privacy_url'], + 'hint': _('Diese Links können später im Portal-Footer oder in öffentlichen Seiten verwendet werden.'), + }, + ] + for section in sections: + rows = [] + for field_name in section['fields']: + field = form[field_name] + rows.append( + { + 'name': field_name, + 'bound_field': field, + 'label': field.label, + 'value': getattr(company_config, field_name, '') or '', + } + ) + section['rows'] = rows + return sections + + @_require_capability('manage_trial_lifecycle') def portal_trial_config_page(request): trial_config = get_portal_trial_config()