From 9471c5c310485adb0c6cf6a7bfb48ccb856c5954 Mon Sep 17 00:00:00 2001 From: metacube Date: Thu, 21 May 2026 13:43:47 +0200 Subject: [PATCH] Add admin access and landing dashboard --- .../Components/AdminAccessPanel.razor | 91 +++ .../FinanceCockpitUnlockPanel.razor | 46 ++ .../Components/Layout/NavMenu.razor | 5 +- .../Components/Pages/AdminSessions.razor | 84 +++ .../Components/Pages/Dashboard.razor | 533 +++------------ .../Components/Pages/ExportDashboard.razor | 606 ++++++++++++++++++ .../Components/Pages/HrKpi.razor | 46 ++ .../Components/Pages/Standorte.razor | 51 +- TrafagSalesExporter/Components/Routes.razor | 4 +- TrafagSalesExporter/Program.cs | 4 + .../Security/AdminAccessOptions.cs | 11 + .../Services/AccessPasswordSettingsWriter.cs | 40 ++ .../Services/AccessSessionTracker.cs | 54 ++ .../Services/AdminAccessService.cs | 96 +++ .../Services/FinanceCockpitAccessService.cs | 54 +- .../Services/HrKpiAccessService.cs | 54 +- TrafagSalesExporter/Services/UiTextService.cs | 78 +++ TrafagSalesExporter/appsettings.json | 5 + TrafagSalesExporter/lastchange.md | 36 ++ 19 files changed, 1442 insertions(+), 456 deletions(-) create mode 100644 TrafagSalesExporter/Components/AdminAccessPanel.razor create mode 100644 TrafagSalesExporter/Components/Pages/AdminSessions.razor create mode 100644 TrafagSalesExporter/Components/Pages/ExportDashboard.razor create mode 100644 TrafagSalesExporter/Security/AdminAccessOptions.cs create mode 100644 TrafagSalesExporter/Services/AccessPasswordSettingsWriter.cs create mode 100644 TrafagSalesExporter/Services/AccessSessionTracker.cs create mode 100644 TrafagSalesExporter/Services/AdminAccessService.cs diff --git a/TrafagSalesExporter/Components/AdminAccessPanel.razor b/TrafagSalesExporter/Components/AdminAccessPanel.razor new file mode 100644 index 0000000..43d4313 --- /dev/null +++ b/TrafagSalesExporter/Components/AdminAccessPanel.razor @@ -0,0 +1,91 @@ +@using TrafagSalesExporter.Services +@inject IAdminAccessService AdminAccess +@inject ISnackbar Snackbar +@inject IUiTextService UiText + + + + + @T("Adminbereich ist geschützt. Bitte anmelden.", "Admin area is protected. Please sign in.") + + @if (!AdminAccess.IsConfigured) + { + + @T("Admin-Zugang ist noch nicht konfiguriert.", "Admin access is not configured yet.") + + } + + + + @T("Admin entsperren", "Unlock admin") + + + + + + + + + + + @T("Passwort speichern", "Save password") + + + + + + + +@code { + private string? _username; + private string? _password; + private string? _changeUsername; + private string? _currentPassword; + private string? _newPassword; + private string? _newPasswordRepeat; + + private void Unlock() + { + if (!AdminAccess.TryUnlock(_username ?? string.Empty, _password ?? string.Empty)) + { + Snackbar.Add(T("Admin-Anmeldung fehlgeschlagen.", "Admin sign-in failed."), Severity.Error); + return; + } + + _password = string.Empty; + OnUnlocked.InvokeAsync(); + } + + private void ChangePassword() + { + if (string.IsNullOrWhiteSpace(_newPassword) || _newPassword.Length < 8) + { + Snackbar.Add(T("Das neue Passwort muss mindestens 8 Zeichen lang sein.", "The new password must be at least 8 characters long."), Severity.Warning); + return; + } + + if (_newPassword != _newPasswordRepeat) + { + Snackbar.Add(T("Die neuen Passwörter stimmen nicht überein.", "The new passwords do not match."), Severity.Warning); + return; + } + + if (!AdminAccess.TryChangePassword(_changeUsername ?? string.Empty, _currentPassword ?? string.Empty, _newPassword)) + { + Snackbar.Add(T("Passwort konnte nicht geändert werden. Name oder aktuelles Passwort prüfen.", "Password could not be changed. Check the name or current password."), Severity.Error); + return; + } + + _currentPassword = string.Empty; + _newPassword = string.Empty; + _newPasswordRepeat = string.Empty; + Snackbar.Add(T("Passwort wurde geändert.", "Password has been changed."), Severity.Success); + } + + private string T(string german, string english) => UiText.Text(german, english); + + [Parameter] + public EventCallback OnUnlocked { get; set; } +} diff --git a/TrafagSalesExporter/Components/FinanceCockpit/FinanceCockpitUnlockPanel.razor b/TrafagSalesExporter/Components/FinanceCockpit/FinanceCockpitUnlockPanel.razor index d960431..14ecab1 100644 --- a/TrafagSalesExporter/Components/FinanceCockpit/FinanceCockpitUnlockPanel.razor +++ b/TrafagSalesExporter/Components/FinanceCockpit/FinanceCockpitUnlockPanel.razor @@ -23,12 +23,31 @@ StartIcon="@Icons.Material.Filled.LockOpen" Disabled="@(!FinanceAccess.IsConfigured)"> @T("Finance Cockpit entsperren", "Unlock Finance Cockpit") + + + + + + + + + + @T("Passwort speichern", "Save password") + + + + @code { private string? _username; private string? _password; + private string? _changeUsername; + private string? _currentPassword; + private string? _newPassword; + private string? _newPasswordRepeat; private Task UnlockAsync() { @@ -43,5 +62,32 @@ return Task.CompletedTask; } + private Task ChangePasswordAsync() + { + if (string.IsNullOrWhiteSpace(_newPassword) || _newPassword.Length < 8) + { + Snackbar.Add(T("Das neue Passwort muss mindestens 8 Zeichen lang sein.", "The new password must be at least 8 characters long."), Severity.Warning); + return Task.CompletedTask; + } + + if (_newPassword != _newPasswordRepeat) + { + Snackbar.Add(T("Die neuen Passwörter stimmen nicht überein.", "The new passwords do not match."), Severity.Warning); + return Task.CompletedTask; + } + + if (!FinanceAccess.TryChangePassword(_changeUsername ?? string.Empty, _currentPassword ?? string.Empty, _newPassword)) + { + Snackbar.Add(T("Passwort konnte nicht geändert werden. Name oder aktuelles Passwort prüfen.", "Password could not be changed. Check the name or current password."), Severity.Error); + return Task.CompletedTask; + } + + _currentPassword = string.Empty; + _newPassword = string.Empty; + _newPasswordRepeat = string.Empty; + Snackbar.Add(T("Passwort wurde geändert.", "Password has been changed."), Severity.Success); + return Task.CompletedTask; + } + private string T(string german, string english) => UiText.Text(german, english); } diff --git a/TrafagSalesExporter/Components/Layout/NavMenu.razor b/TrafagSalesExporter/Components/Layout/NavMenu.razor index 428aea1..432d4f7 100644 --- a/TrafagSalesExporter/Components/Layout/NavMenu.razor +++ b/TrafagSalesExporter/Components/Layout/NavMenu.razor @@ -7,7 +7,7 @@ - + @T("Export Dashboard", "Export dashboard") @@ -62,6 +62,9 @@ @T("HR KPI Schulung", "HR KPI training") + + @T("Admin Bereich", "Admin area") + @code { diff --git a/TrafagSalesExporter/Components/Pages/AdminSessions.razor b/TrafagSalesExporter/Components/Pages/AdminSessions.razor new file mode 100644 index 0000000..089057d --- /dev/null +++ b/TrafagSalesExporter/Components/Pages/AdminSessions.razor @@ -0,0 +1,84 @@ +@page "/admin/sessions" +@attribute [Authorize(Policy = TrafagSalesExporter.Security.SecurityPolicies.AdminOnly)] +@using TrafagSalesExporter.Services +@inject IAccessSessionTracker SessionTracker +@inject IAdminAccessService AdminAccess +@inject IUiTextService UiText + +@T("Aktive Logins", "Active logins") + +@T("Aktive Logins", "Active logins") + +@if (!AdminAccess.IsUnlocked) +{ + +} +else +{ + + +
+ @T("HR-/Finance-Cockpit Sessions", "HR/Finance cockpit sessions") + + @T("Gezählt werden App-interne Entsperrungen seit dem letzten App-Start.", "Counts app-internal unlocks since the last app start.") + +
+ + + @T("Admin sperren", "Lock admin") + + + @T("Aktualisieren", "Refresh") + +
+ + + + @T("Bereich", "Area") + @T("Name", "Name") + @T("IP-Adresse", "IP address") + @T("Entsperrt seit", "Unlocked since") + @T("Zuletzt gesehen", "Last seen") + + + @context.Area + @context.Username + @context.RemoteAddress + @FormatDate(context.StartedAt) + @FormatDate(context.LastSeenAt) + + + @T("Keine aktiven HR-/Finance-Logins erfasst.", "No active HR/Finance logins recorded.") + + +
+ + + @T("Hinweis: HR und Finance verwenden gemeinsame App-Logins. Diese Seite zeigt daher den verwendeten Login-Namen und die Session, nicht zwingend die echte Person.", "Note: HR and Finance use shared app logins. This page therefore shows the used login name and session, not necessarily the real person.") + +} + +@code { + private IReadOnlyList _sessions = []; + + protected override void OnInitialized() + { + Refresh(); + } + + private void Refresh() + { + _sessions = SessionTracker.GetActiveSessions(); + } + + private void LockAdmin() + { + AdminAccess.Lock(); + _sessions = []; + } + + private static string FormatDate(DateTimeOffset value) + => value.ToString("dd.MM.yyyy HH:mm:ss"); + + private string T(string german, string english) => UiText.Text(german, english); +} diff --git a/TrafagSalesExporter/Components/Pages/Dashboard.razor b/TrafagSalesExporter/Components/Pages/Dashboard.razor index 6645e76..e7fd4c7 100644 --- a/TrafagSalesExporter/Components/Pages/Dashboard.razor +++ b/TrafagSalesExporter/Components/Pages/Dashboard.razor @@ -1,481 +1,126 @@ @page "/" -@using System.Diagnostics @using TrafagSalesExporter.Services -@inject IDashboardPageService DashboardPageActions -@inject ExportOrchestrationService Orchestrator -@inject TimerBackgroundService TimerService -@inject ISnackbar Snackbar @inject IUiTextService UiText -@implements IDisposable -@T("Export Dashboard", "Export dashboard") +@T("Trafag Cockpit", "Trafag Cockpit") -@T("Export Dashboard", "Export dashboard") +
+
+ + + + - - - - @T("Alle exportieren", "Export all") - - - @T("Zentrale Datei neu erzeugen", "Rebuild consolidated file") - - - @if (TimerService.NextRun < DateTime.MaxValue) - { - - @(string.Format(T("Naechster automatischer Lauf: {0}", "Next automatic run: {0}"), TimerService.NextRun.ToString("dd.MM.yyyy HH:mm"))) - } - else - { - - @T("Timer deaktiviert", "Timer disabled") - } - - - + + + + + -@if (_readinessWarnings.Count > 0) -{ - - @T("Aktive Standorte sind noch nicht vollstaendig bereit:", "Active sites are not fully ready:") - @foreach (var warning in _readinessWarnings) - { - @warning - } - -} + 0 + 25 + 50 + 75 + 100 + TRAFAG -@if (_consolidatedStale) -{ - - @T("Seit der letzten zentralen Excel wurde mindestens ein Standort neu exportiert. Bitte `Zentrale Datei neu erzeugen` ausfuehren, damit das Endexcel aktuell ist.", - "At least one site was exported after the last consolidated Excel. Please rebuild the consolidated file so the final Excel is current.") - -} + + + + + +
@T("Willkommen im Trafag Analyse Dashboard", "Welcome to the Trafag Analytical Dashboard")
+
+
- - - @T("Land", "Country") - @T("Basis", "Basis") - TSC - @T("Schema", "Schema") - @T("Server", "Server") - @T("Status", "Status") - @T("Live-Status", "Live status") - @T("Zeilen", "Rows") - @T("Letzter Lauf", "Last run") - @T("Dauer", "Duration") - @T("Aktion", "Action") - - - @context.Land - - - - - @context.DataBasis - - - - @context.TSC - @context.Schema - @context.ServerName - - @if (Orchestrator.IsExporting(context.SiteId)) - { - - @Orchestrator.GetExportStatus(context.SiteId) - } - else if (context.LastStatus == "OK") - { - - } - else if (context.LastStatus == "Error") - { - - - - } - else - { - - - } - - - @if (!string.IsNullOrWhiteSpace(context.LiveMessage)) - { - - - @context.LiveMessage - - - } - else - { - - - } - - @(context.RowCount > 0 ? context.RowCount.ToString("N0") : "-") - @(context.LastRun.HasValue ? context.LastRun.Value.ToString("dd.MM.yyyy HH:mm:ss") : "-") - @(context.DurationSeconds > 0 ? $"{context.DurationSeconds:F1}s" : "-") - - - - Export - - - @T("Excel oeffnen", "Open Excel") - - - - - - - - @T("Zentrale Datei", "Consolidated file") - - - @T("Datei", "File") - Pfad - Letzte Änderung - @T("Status", "Status") - @T("Aktion", "Action") - - - @context.Label - @context.DisplayPath - @(context.LastModified.HasValue ? context.LastModified.Value.ToString("dd.MM.yyyy HH:mm:ss") : "-") - - @if (Orchestrator.IsConsolidatedExporting()) - { - - @Orchestrator.GetConsolidatedExportStatus() - } - else - { - - - } - - - - @T("Excel oeffnen", "Open Excel") - - - - - @T("Keine zentrale Excel-Datei gefunden.", "No consolidated Excel file found.") - - - - -@code { - private List _dashboardRows = new(); - private List _consolidatedRows = new(); - private List _readinessWarnings = new(); - private bool _consolidatedStale; - private bool _loading = true; - private bool _anyRunning; - private CancellationTokenSource? _pollingCts; - - protected override async Task OnInitializedAsync() - { - Orchestrator.OnExportStatusChanged += HandleStatusChanged; - await LoadDataAsync(); + @code { private string T(string german, string english) => UiText.Text(german, english); diff --git a/TrafagSalesExporter/Components/Pages/ExportDashboard.razor b/TrafagSalesExporter/Components/Pages/ExportDashboard.razor new file mode 100644 index 0000000..f65da0d --- /dev/null +++ b/TrafagSalesExporter/Components/Pages/ExportDashboard.razor @@ -0,0 +1,606 @@ +@page "/export-dashboard" +@using System.Diagnostics +@using TrafagSalesExporter.Services +@inject IDashboardPageService DashboardPageActions +@inject ExportOrchestrationService Orchestrator +@inject TimerBackgroundService TimerService +@inject ISnackbar Snackbar +@inject IUiTextService UiText +@implements IDisposable + +@T("Export Dashboard", "Export dashboard") + +@T("Export Dashboard", "Export dashboard") + + +
+ + + @T("Alle exportieren", "Export all") + + + @T("Zentrale Datei neu erzeugen", "Rebuild consolidated file") + + + @if (TimerService.NextRun < DateTime.MaxValue) + { + + @(string.Format(T("Naechster automatischer Lauf: {0}", "Next automatic run: {0}"), TimerService.NextRun.ToString("dd.MM.yyyy HH:mm"))) + } + else + { + + @T("Timer deaktiviert", "Timer disabled") + } + + +
+
+ + + + + + + +
+
+
+
+ +@if (_readinessWarnings.Count > 0) +{ + + @T("Aktive Standorte sind noch nicht vollstaendig bereit:", "Active sites are not fully ready:") + @foreach (var warning in _readinessWarnings) + { + @warning + } + +} + +@if (_consolidatedStale) +{ + + @T("Seit der letzten zentralen Excel wurde mindestens ein Standort neu exportiert. Bitte `Zentrale Datei neu erzeugen` ausfuehren, damit das Endexcel aktuell ist.", + "At least one site was exported after the last consolidated Excel. Please rebuild the consolidated file so the final Excel is current.") + +} + + + + @T("Land", "Country") + @T("Basis", "Basis") + TSC + @T("Schema", "Schema") + @T("Server", "Server") + @T("Status", "Status") + @T("Live-Status", "Live status") + @T("Zeilen", "Rows") + @T("Letzter Lauf", "Last run") + @T("Dauer", "Duration") + @T("Aktion", "Action") + + + @context.Land + + + + + @context.DataBasis + + + + @context.TSC + @context.Schema + @context.ServerName + + @if (Orchestrator.IsExporting(context.SiteId)) + { + + @Orchestrator.GetExportStatus(context.SiteId) + } + else if (context.LastStatus == "OK") + { + + } + else if (context.LastStatus == "Error") + { + + + + } + else + { + - + } + + + @if (!string.IsNullOrWhiteSpace(context.LiveMessage)) + { + + + @context.LiveMessage + + + } + else + { + - + } + + @(context.RowCount > 0 ? context.RowCount.ToString("N0") : "-") + @(context.LastRun.HasValue ? context.LastRun.Value.ToString("dd.MM.yyyy HH:mm:ss") : "-") + @(context.DurationSeconds > 0 ? $"{context.DurationSeconds:F1}s" : "-") + + + + Export + + + @T("Excel oeffnen", "Open Excel") + + + + + + + + @T("Zentrale Datei", "Consolidated file") + + + @T("Datei", "File") + Pfad + Letzte Änderung + @T("Status", "Status") + @T("Aktion", "Action") + + + @context.Label + @context.DisplayPath + @(context.LastModified.HasValue ? context.LastModified.Value.ToString("dd.MM.yyyy HH:mm:ss") : "-") + + @if (Orchestrator.IsConsolidatedExporting()) + { + + @Orchestrator.GetConsolidatedExportStatus() + } + else + { + - + } + + + + @T("Excel oeffnen", "Open Excel") + + + + + @T("Keine zentrale Excel-Datei gefunden.", "No consolidated Excel file found.") + + + + + + +@code { + private List _dashboardRows = new(); + private List _consolidatedRows = new(); + private List _readinessWarnings = new(); + private bool _consolidatedStale; + private bool _loading = true; + private bool _anyRunning; + private CancellationTokenSource? _pollingCts; + + protected override async Task OnInitializedAsync() + { + Orchestrator.OnExportStatusChanged += HandleStatusChanged; + await LoadDataAsync(); + } + + private async Task LoadDataAsync() + { + _loading = true; + var state = await DashboardPageActions.LoadAsync(); + _dashboardRows = state.DashboardRows; + _consolidatedRows = state.ConsolidatedRows; + _readinessWarnings = state.ReadinessWarnings; + _consolidatedStale = state.IsConsolidatedStale; + + _anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting(); + _loading = false; + } + + private async Task ExportAll() + { + if (_readinessWarnings.Count > 0) + { + Snackbar.Add(T("Es gibt aktive Standorte mit fehlender manueller Datei. Bitte Warnung im Dashboard pruefen.", + "There are active sites with missing manual files. Please check the dashboard warning."), Severity.Warning); + } + + _anyRunning = true; + await LoadDataAsync(); + StartPolling(); + _ = Task.Run(async () => + { + try + { + await Orchestrator.ExportAllAsync(); + await InvokeAsync(() => + Snackbar.Add(T("Export fuer alle Standorte beendet", "Export completed for all sites"), Severity.Success)); + } + catch (Exception ex) + { + await InvokeAsync(() => + Snackbar.Add(string.Format(T("Export fuer alle Standorte fehlgeschlagen: {0}", "Export for all sites failed: {0}"), FormatException(ex)), Severity.Error)); + } + finally + { + await InvokeAsync(async () => + { + await LoadDataAsync(); + StateHasChanged(); + }); + } + }); + Snackbar.Add(T("Export fuer alle Standorte gestartet", "Export started for all sites"), Severity.Info); + } + + private async Task ExportConsolidatedOnly() + { + _anyRunning = true; + await LoadDataAsync(); + StartPolling(); + _ = Task.Run(async () => + { + try + { + var filePath = await Orchestrator.ExportConsolidatedOnlyAsync(); + + if (!string.IsNullOrWhiteSpace(filePath)) + { + await InvokeAsync(() => + Snackbar.Add(string.Format(T("Zentrale Datei erzeugt: {0}", "Consolidated file created: {0}"), filePath), Severity.Success)); + } + else + { + await InvokeAsync(() => + Snackbar.Add(T("Zentrale Datei konnte nicht erzeugt werden. Details stehen in den Logs.", "Consolidated file could not be created. Details are in the logs."), Severity.Warning)); + } + } + catch (Exception ex) + { + await InvokeAsync(() => + Snackbar.Add(string.Format(T("Zentrale Datei fehlgeschlagen: {0}", "Consolidated file failed: {0}"), FormatException(ex)), Severity.Error)); + } + finally + { + await InvokeAsync(async () => + { + await LoadDataAsync(); + StateHasChanged(); + }); + } + }); + Snackbar.Add(T("Zentrale Datei wird erzeugt", "Building consolidated file"), Severity.Info); + } + + private void ExportSingle(int siteId) + { + _anyRunning = true; + _ = InvokeAsync(async () => await LoadDataAsync()); + StartPolling(); + _ = Task.Run(async () => + { + try + { + var result = await Orchestrator.ExportSiteByIdAsync(siteId); + + if (result?.Log.Status == "OK" && !string.IsNullOrWhiteSpace(result.FilePath)) + { + await InvokeAsync(() => + Snackbar.Add(string.Format(T("Export gespeichert: {0}", "Export saved: {0}"), result.FilePath), Severity.Success)); + await InvokeAsync(() => + Snackbar.Add(T("Die zentrale Excel ist danach noch nicht automatisch aktualisiert. Bitte `Zentrale Datei neu erzeugen` starten.", + "The consolidated Excel is not automatically updated after this. Please rebuild the consolidated file."), Severity.Info)); + } + else if (result?.Log.Status == "Error" && !string.IsNullOrWhiteSpace(result.Log.ErrorMessage)) + { + await InvokeAsync(() => + Snackbar.Add(string.Format(T("Export fehlgeschlagen: {0}", "Export failed: {0}"), result.Log.ErrorMessage), Severity.Error)); + } + } + catch (Exception ex) + { + await InvokeAsync(() => + Snackbar.Add(string.Format(T("Export fehlgeschlagen: {0}", "Export failed: {0}"), FormatException(ex)), Severity.Error)); + } + finally + { + await InvokeAsync(async () => + { + await LoadDataAsync(); + StateHasChanged(); + }); + } + }); + Snackbar.Add(T("Export gestartet", "Export started"), Severity.Info); + } + + private async void HandleStatusChanged() + { + await InvokeAsync(async () => + { + _anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting() || _dashboardRows.Count == 0; + if (_anyRunning) + { + StartPolling(); + await RefreshLiveDataAsync(); + StateHasChanged(); + return; + } + + StopPolling(); + await LoadDataAsync(); + StateHasChanged(); + }); + } + + public void Dispose() + { + StopPolling(); + Orchestrator.OnExportStatusChanged -= HandleStatusChanged; + } + + private void OpenExportFile(DashboardRow row) + { + OpenFile(row.FilePath); + } + + private void OpenFile(string filePath) + { + if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath)) + { + Snackbar.Add(T("Exportdatei nicht gefunden.", "Export file not found."), Severity.Warning); + return; + } + + try + { + Process.Start(new ProcessStartInfo + { + FileName = filePath, + UseShellExecute = true + }); + } + catch (Exception ex) + { + Snackbar.Add(string.Format(T("Datei konnte nicht geoeffnet werden: {0}", "Could not open file: {0}"), ex.Message), Severity.Error); + } + } + + private void StartPolling() + { + if (_pollingCts is not null && !_pollingCts.IsCancellationRequested) + return; + + _pollingCts = new CancellationTokenSource(); + _ = PollDashboardAsync(_pollingCts.Token); + } + + private void StopPolling() + { + _pollingCts?.Cancel(); + _pollingCts?.Dispose(); + _pollingCts = null; + } + + private async Task PollDashboardAsync(CancellationToken cancellationToken) + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(3)); + + try + { + while (await timer.WaitForNextTickAsync(cancellationToken)) + { + var anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting(); + if (!anyRunning) + { + await InvokeAsync(async () => + { + _anyRunning = false; + await LoadDataAsync(); + StateHasChanged(); + }); + StopPolling(); + break; + } + + await InvokeAsync(async () => + { + _anyRunning = true; + await RefreshLiveDataAsync(); + StateHasChanged(); + }); + } + } + catch (OperationCanceledException) + { + } + } + + private Task RefreshLiveDataAsync() + { + foreach (var row in _dashboardRows) + { + if (!Orchestrator.IsExporting(row.SiteId)) + continue; + + row.LiveMessage = Orchestrator.GetExportStatus(row.SiteId); + row.LiveDetails = string.Empty; + } + + _anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting(); + return Task.CompletedTask; + } + + private static string FormatException(Exception ex) + => ex.InnerException is null ? ex.Message : $"{ex.Message} Inner: {ex.InnerException.Message}"; + + private static string GetDataBasisIcon(string dataBasis) + { + if (dataBasis.Contains("Excel", StringComparison.OrdinalIgnoreCase)) + return Icons.Material.Filled.TableView; + if (dataBasis.Contains("CSV", StringComparison.OrdinalIgnoreCase) || + dataBasis.Contains("Datei", StringComparison.OrdinalIgnoreCase)) + return Icons.Material.Filled.Description; + if (dataBasis.Contains("SAP", StringComparison.OrdinalIgnoreCase)) + return Icons.Material.Filled.CloudSync; + if (dataBasis.Contains("Server", StringComparison.OrdinalIgnoreCase)) + return Icons.Material.Filled.Storage; + + return Icons.Material.Filled.Source; + } + + private static Color GetDataBasisColor(string dataBasis) + { + if (dataBasis.Contains("Excel", StringComparison.OrdinalIgnoreCase)) + return Color.Success; + if (dataBasis.Contains("CSV", StringComparison.OrdinalIgnoreCase) || + dataBasis.Contains("Datei", StringComparison.OrdinalIgnoreCase)) + return Color.Info; + if (dataBasis.Contains("SAP", StringComparison.OrdinalIgnoreCase)) + return Color.Primary; + if (dataBasis.Contains("Server", StringComparison.OrdinalIgnoreCase)) + return Color.Secondary; + + return Color.Default; + } + +} + +@code { + private string T(string german, string english) => UiText.Text(german, english); +} diff --git a/TrafagSalesExporter/Components/Pages/HrKpi.razor b/TrafagSalesExporter/Components/Pages/HrKpi.razor index 500de21..6edbab7 100644 --- a/TrafagSalesExporter/Components/Pages/HrKpi.razor +++ b/TrafagSalesExporter/Components/Pages/HrKpi.razor @@ -32,6 +32,21 @@ StartIcon="@Icons.Material.Filled.LockOpen" Disabled="@(!HrKpiAccess.IsConfigured)"> @T("HR KPI entsperren", "Unlock HR KPI") + + + + + + + + + + @T("Passwort speichern", "Save password") + + + + } @@ -175,6 +190,10 @@ else private bool _managementView; private string? _hrUsername; private string? _hrPassword; + private string? _changeUsername; + private string? _currentPassword; + private string? _newPassword; + private string? _newPasswordRepeat; private bool _loading; private HrKpiResult? _result; private readonly List<(string Key, string Label)> _fluktuationOptions = @@ -245,6 +264,33 @@ else await LoadAsync(); } + private Task ChangeHrPasswordAsync() + { + if (string.IsNullOrWhiteSpace(_newPassword) || _newPassword.Length < 8) + { + Snackbar.Add(T("Das neue Passwort muss mindestens 8 Zeichen lang sein.", "The new password must be at least 8 characters long."), Severity.Warning); + return Task.CompletedTask; + } + + if (_newPassword != _newPasswordRepeat) + { + Snackbar.Add(T("Die neuen Passwörter stimmen nicht überein.", "The new passwords do not match."), Severity.Warning); + return Task.CompletedTask; + } + + if (!HrKpiAccess.TryChangePassword(_changeUsername ?? string.Empty, _currentPassword ?? string.Empty, _newPassword)) + { + Snackbar.Add(T("Passwort konnte nicht geändert werden. Name oder aktuelles Passwort prüfen.", "Password could not be changed. Check the name or current password."), Severity.Error); + return Task.CompletedTask; + } + + _currentPassword = string.Empty; + _newPassword = string.Empty; + _newPasswordRepeat = string.Empty; + Snackbar.Add(T("Passwort wurde geändert.", "Password has been changed."), Severity.Success); + return Task.CompletedTask; + } + private void LockHrKpi() { HrKpiAccess.Lock(); diff --git a/TrafagSalesExporter/Components/Pages/Standorte.razor b/TrafagSalesExporter/Components/Pages/Standorte.razor index bd1afc5..a09e5b8 100644 --- a/TrafagSalesExporter/Components/Pages/Standorte.razor +++ b/TrafagSalesExporter/Components/Pages/Standorte.razor @@ -85,8 +85,22 @@ @context.Land @context.TSC @context.Schema - @context.SourceSystem - @GetConnectionTarget(context) + + + + @context.SourceSystem + + + + + + + + + @GetConnectionTarget(context) + + @if (context.IsActive) { @@ -791,6 +805,39 @@ return GetServerNode(site.HanaServer); } + private string GetConnectionKindIcon(Site site) + { + var connectionKind = GetSourceSystemConnectionKind(site.SourceSystem); + if (string.Equals(connectionKind, SourceSystemConnectionKinds.ManualExcel, StringComparison.OrdinalIgnoreCase)) + return Icons.Material.Filled.UploadFile; + if (string.Equals(connectionKind, SourceSystemConnectionKinds.SapGateway, StringComparison.OrdinalIgnoreCase)) + return Icons.Material.Filled.CloudSync; + + return Icons.Material.Filled.Storage; + } + + private Color GetConnectionKindColor(Site site) + { + var connectionKind = GetSourceSystemConnectionKind(site.SourceSystem); + if (string.Equals(connectionKind, SourceSystemConnectionKinds.ManualExcel, StringComparison.OrdinalIgnoreCase)) + return Color.Warning; + if (string.Equals(connectionKind, SourceSystemConnectionKinds.SapGateway, StringComparison.OrdinalIgnoreCase)) + return Color.Info; + + return Color.Primary; + } + + private string GetConnectionKindTooltip(Site site) + { + var connectionKind = GetSourceSystemConnectionKind(site.SourceSystem); + if (string.Equals(connectionKind, SourceSystemConnectionKinds.ManualExcel, StringComparison.OrdinalIgnoreCase)) + return "Manual Excel / CSV"; + if (string.Equals(connectionKind, SourceSystemConnectionKinds.SapGateway, StringComparison.OrdinalIgnoreCase)) + return "SAP OData Server"; + + return "HANA / Server"; + } + private string GetEffectiveSapServiceUrl(Site site) { if (!string.IsNullOrWhiteSpace(site.SapServiceUrl)) diff --git a/TrafagSalesExporter/Components/Routes.razor b/TrafagSalesExporter/Components/Routes.razor index 643b727..4bec2d4 100644 --- a/TrafagSalesExporter/Components/Routes.razor +++ b/TrafagSalesExporter/Components/Routes.razor @@ -41,7 +41,8 @@ .Trim('/') .ToLowerInvariant(); - return path is "" or + return path is + "export-dashboard" or "management-cockpit" or "finance-cockpit/vergleich" or "finance-cockpit/schulung" or @@ -49,6 +50,7 @@ "transformations" or "finance-rules" or "settings" or + "admin/sessions" or "logs" or "source-viewer"; } diff --git a/TrafagSalesExporter/Program.cs b/TrafagSalesExporter/Program.cs index b3498d9..87e4bc0 100644 --- a/TrafagSalesExporter/Program.cs +++ b/TrafagSalesExporter/Program.cs @@ -19,6 +19,7 @@ builder.Logging.AddFilter("Microsoft.EntityFrameworkCore.Database.Command", LogL builder.Services.AddRazorComponents() .AddInteractiveServerComponents(); builder.Services.AddCascadingAuthenticationState(); +builder.Services.AddHttpContextAccessor(); var securitySettings = builder.Configuration.GetSection(SecurityOptions.SectionName).Get() ?? new SecurityOptions(); var useDevelopmentAuthentication = builder.Environment.IsDevelopment() && securitySettings.DevelopmentBypass; @@ -47,6 +48,7 @@ builder.Services.AddHttpClient(nameof(ExchangeRateImportService)); builder.Services.Configure(builder.Configuration.GetSection(HrKpiDataSourceOptions.SectionName)); builder.Services.Configure(builder.Configuration.GetSection(HrKpiAccessOptions.SectionName)); builder.Services.Configure(builder.Configuration.GetSection(FinanceCockpitAccessOptions.SectionName)); +builder.Services.Configure(builder.Configuration.GetSection(AdminAccessOptions.SectionName)); builder.Services.AddDbContextFactory(options => options.UseSqlite("Data Source=trafag_exporter.db;Default Timeout=60")); @@ -85,6 +87,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); // Datenquellen-Adapter (Strategy per ConnectionKind). builder.Services.AddSingleton(); @@ -109,6 +112,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); var app = builder.Build(); var pathBase = app.Configuration["ASPNETCORE_PATHBASE"]; diff --git a/TrafagSalesExporter/Security/AdminAccessOptions.cs b/TrafagSalesExporter/Security/AdminAccessOptions.cs new file mode 100644 index 0000000..590b121 --- /dev/null +++ b/TrafagSalesExporter/Security/AdminAccessOptions.cs @@ -0,0 +1,11 @@ +namespace TrafagSalesExporter.Security; + +public sealed class AdminAccessOptions +{ + public const string SectionName = "AdminAccess"; + + public bool Enabled { get; set; } = true; + public string Username { get; set; } = "admin"; + public string PasswordHash { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; +} diff --git a/TrafagSalesExporter/Services/AccessPasswordSettingsWriter.cs b/TrafagSalesExporter/Services/AccessPasswordSettingsWriter.cs new file mode 100644 index 0000000..436fb74 --- /dev/null +++ b/TrafagSalesExporter/Services/AccessPasswordSettingsWriter.cs @@ -0,0 +1,40 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace TrafagSalesExporter.Services; + +internal static class AccessPasswordSettingsWriter +{ + private static readonly object FileLock = new(); + + public static string HashPassword(string password) + => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(password))); + + public static void SavePasswordHash(string contentRootPath, string sectionName, string passwordHash) + { + var path = Path.Combine(contentRootPath, "appsettings.json"); + + lock (FileLock) + { + var json = File.Exists(path) + ? File.ReadAllText(path, Encoding.UTF8) + : "{}"; + + var root = JsonNode.Parse(json)?.AsObject() ?? new JsonObject(); + var section = root[sectionName] as JsonObject; + if (section is null) + { + section = new JsonObject(); + root[sectionName] = section; + } + + section["PasswordHash"] = passwordHash; + section["Password"] = string.Empty; + + var options = new JsonSerializerOptions { WriteIndented = true }; + File.WriteAllText(path, root.ToJsonString(options), new UTF8Encoding(false)); + } + } +} diff --git a/TrafagSalesExporter/Services/AccessSessionTracker.cs b/TrafagSalesExporter/Services/AccessSessionTracker.cs new file mode 100644 index 0000000..958bfd0 --- /dev/null +++ b/TrafagSalesExporter/Services/AccessSessionTracker.cs @@ -0,0 +1,54 @@ +using System.Collections.Concurrent; + +namespace TrafagSalesExporter.Services; + +public interface IAccessSessionTracker +{ + IReadOnlyList GetActiveSessions(); + void Register(string sessionId, string area, string username, string? remoteAddress); + void Touch(string sessionId); + void Unregister(string sessionId); +} + +public sealed class AccessSessionTracker : IAccessSessionTracker +{ + private readonly ConcurrentDictionary _sessions = new(StringComparer.OrdinalIgnoreCase); + + public IReadOnlyList GetActiveSessions() + => _sessions.Values + .OrderByDescending(session => session.LastSeenAt) + .ToList(); + + public void Register(string sessionId, string area, string username, string? remoteAddress) + { + var now = DateTimeOffset.Now; + _sessions[sessionId] = new AccessSessionSnapshot( + sessionId, + area, + username, + string.IsNullOrWhiteSpace(remoteAddress) ? "unbekannt" : remoteAddress, + now, + now); + } + + public void Touch(string sessionId) + { + if (!_sessions.TryGetValue(sessionId, out var session)) + return; + + _sessions[sessionId] = session with { LastSeenAt = DateTimeOffset.Now }; + } + + public void Unregister(string sessionId) + { + _sessions.TryRemove(sessionId, out _); + } +} + +public sealed record AccessSessionSnapshot( + string SessionId, + string Area, + string Username, + string RemoteAddress, + DateTimeOffset StartedAt, + DateTimeOffset LastSeenAt); diff --git a/TrafagSalesExporter/Services/AdminAccessService.cs b/TrafagSalesExporter/Services/AdminAccessService.cs new file mode 100644 index 0000000..727088e --- /dev/null +++ b/TrafagSalesExporter/Services/AdminAccessService.cs @@ -0,0 +1,96 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Options; +using TrafagSalesExporter.Security; + +namespace TrafagSalesExporter.Services; + +public interface IAdminAccessService +{ + bool IsEnabled { get; } + bool IsConfigured { get; } + bool IsUnlocked { get; } + bool TryUnlock(string username, string password); + bool TryChangePassword(string username, string currentPassword, string newPassword); + void Lock(); +} + +public sealed class AdminAccessService : IAdminAccessService +{ + private readonly AdminAccessOptions _options; + private readonly IHostEnvironment _environment; + + public AdminAccessService(IOptions options, IHostEnvironment environment) + { + _options = options.Value; + _environment = environment; + } + + public bool IsEnabled => _options.Enabled; + + public bool IsConfigured => + !IsEnabled || + !string.IsNullOrWhiteSpace(_options.Username) && + (!string.IsNullOrWhiteSpace(_options.PasswordHash) || !string.IsNullOrEmpty(_options.Password)); + + public bool IsUnlocked { get; private set; } + + public bool TryUnlock(string username, string password) + { + if (!IsEnabled) + { + IsUnlocked = true; + return true; + } + + if (!IsConfigured || + string.IsNullOrWhiteSpace(username) || + string.IsNullOrEmpty(password) || + !FixedEquals(username.Trim(), _options.Username.Trim())) + { + return false; + } + + var valid = !string.IsNullOrWhiteSpace(_options.PasswordHash) + ? VerifyPasswordHash(password, _options.PasswordHash) + : FixedEquals(password, _options.Password); + + IsUnlocked = valid; + return valid; + } + + public bool TryChangePassword(string username, string currentPassword, string newPassword) + { + if (!IsEnabled || + !IsConfigured || + string.IsNullOrWhiteSpace(newPassword) || + newPassword.Length < 8 || + !TryUnlock(username, currentPassword)) + { + return false; + } + + var passwordHash = AccessPasswordSettingsWriter.HashPassword(newPassword); + AccessPasswordSettingsWriter.SavePasswordHash(_environment.ContentRootPath, AdminAccessOptions.SectionName, passwordHash); + _options.PasswordHash = passwordHash; + _options.Password = string.Empty; + IsUnlocked = true; + return true; + } + + public void Lock() => IsUnlocked = false; + + private static bool VerifyPasswordHash(string password, string configuredHash) + { + var passwordHash = AccessPasswordSettingsWriter.HashPassword(password); + return FixedEquals(passwordHash, configuredHash.Trim()); + } + + private static bool FixedEquals(string left, string right) + { + var leftBytes = Encoding.UTF8.GetBytes(left); + var rightBytes = Encoding.UTF8.GetBytes(right); + return leftBytes.Length == rightBytes.Length && + CryptographicOperations.FixedTimeEquals(leftBytes, rightBytes); + } +} diff --git a/TrafagSalesExporter/Services/FinanceCockpitAccessService.cs b/TrafagSalesExporter/Services/FinanceCockpitAccessService.cs index 71db8c7..12dbfb9 100644 --- a/TrafagSalesExporter/Services/FinanceCockpitAccessService.cs +++ b/TrafagSalesExporter/Services/FinanceCockpitAccessService.cs @@ -11,16 +11,28 @@ public interface IFinanceCockpitAccessService bool IsConfigured { get; } bool IsUnlocked { get; } bool TryUnlock(string username, string password); + bool TryChangePassword(string username, string currentPassword, string newPassword); void Lock(); } -public sealed class FinanceCockpitAccessService : IFinanceCockpitAccessService +public sealed class FinanceCockpitAccessService : IFinanceCockpitAccessService, IDisposable { private readonly FinanceCockpitAccessOptions _options; + private readonly IHostEnvironment _environment; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IAccessSessionTracker _sessionTracker; + private readonly string _sessionId = Guid.NewGuid().ToString("N"); - public FinanceCockpitAccessService(IOptions options) + public FinanceCockpitAccessService( + IOptions options, + IHostEnvironment environment, + IHttpContextAccessor httpContextAccessor, + IAccessSessionTracker sessionTracker) { _options = options.Value; + _environment = environment; + _httpContextAccessor = httpContextAccessor; + _sessionTracker = sessionTracker; } public bool IsEnabled => _options.Enabled; @@ -53,14 +65,48 @@ public sealed class FinanceCockpitAccessService : IFinanceCockpitAccessService : FixedEquals(password, _options.Password); IsUnlocked = valid; + if (valid) + _sessionTracker.Register(_sessionId, "Finance Cockpit", username.Trim(), GetRemoteAddress()); return valid; } - public void Lock() => IsUnlocked = false; + public void Lock() + { + IsUnlocked = false; + _sessionTracker.Unregister(_sessionId); + } + + public bool TryChangePassword(string username, string currentPassword, string newPassword) + { + if (!IsEnabled || + !IsConfigured || + string.IsNullOrWhiteSpace(newPassword) || + newPassword.Length < 8 || + !TryUnlock(username, currentPassword)) + { + return false; + } + + var passwordHash = AccessPasswordSettingsWriter.HashPassword(newPassword); + AccessPasswordSettingsWriter.SavePasswordHash(_environment.ContentRootPath, FinanceCockpitAccessOptions.SectionName, passwordHash); + _options.PasswordHash = passwordHash; + _options.Password = string.Empty; + IsUnlocked = true; + _sessionTracker.Register(_sessionId, "Finance Cockpit", username.Trim(), GetRemoteAddress()); + return true; + } + + public void Dispose() + { + _sessionTracker.Unregister(_sessionId); + } + + private string? GetRemoteAddress() + => _httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString(); private static bool VerifyPasswordHash(string password, string configuredHash) { - var passwordHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(password))); + var passwordHash = AccessPasswordSettingsWriter.HashPassword(password); return FixedEquals(passwordHash, configuredHash.Trim()); } diff --git a/TrafagSalesExporter/Services/HrKpiAccessService.cs b/TrafagSalesExporter/Services/HrKpiAccessService.cs index 0d5691d..f66f825 100644 --- a/TrafagSalesExporter/Services/HrKpiAccessService.cs +++ b/TrafagSalesExporter/Services/HrKpiAccessService.cs @@ -11,16 +11,28 @@ public interface IHrKpiAccessService bool IsConfigured { get; } bool IsUnlocked { get; } bool TryUnlock(string username, string password); + bool TryChangePassword(string username, string currentPassword, string newPassword); void Lock(); } -public sealed class HrKpiAccessService : IHrKpiAccessService +public sealed class HrKpiAccessService : IHrKpiAccessService, IDisposable { private readonly HrKpiAccessOptions _options; + private readonly IHostEnvironment _environment; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IAccessSessionTracker _sessionTracker; + private readonly string _sessionId = Guid.NewGuid().ToString("N"); - public HrKpiAccessService(IOptions options) + public HrKpiAccessService( + IOptions options, + IHostEnvironment environment, + IHttpContextAccessor httpContextAccessor, + IAccessSessionTracker sessionTracker) { _options = options.Value; + _environment = environment; + _httpContextAccessor = httpContextAccessor; + _sessionTracker = sessionTracker; } public bool IsEnabled => _options.Enabled; @@ -53,14 +65,48 @@ public sealed class HrKpiAccessService : IHrKpiAccessService : FixedEquals(password, _options.Password); IsUnlocked = valid; + if (valid) + _sessionTracker.Register(_sessionId, "HR KPI", username.Trim(), GetRemoteAddress()); return valid; } - public void Lock() => IsUnlocked = false; + public void Lock() + { + IsUnlocked = false; + _sessionTracker.Unregister(_sessionId); + } + + public bool TryChangePassword(string username, string currentPassword, string newPassword) + { + if (!IsEnabled || + !IsConfigured || + string.IsNullOrWhiteSpace(newPassword) || + newPassword.Length < 8 || + !TryUnlock(username, currentPassword)) + { + return false; + } + + var passwordHash = AccessPasswordSettingsWriter.HashPassword(newPassword); + AccessPasswordSettingsWriter.SavePasswordHash(_environment.ContentRootPath, HrKpiAccessOptions.SectionName, passwordHash); + _options.PasswordHash = passwordHash; + _options.Password = string.Empty; + IsUnlocked = true; + _sessionTracker.Register(_sessionId, "HR KPI", username.Trim(), GetRemoteAddress()); + return true; + } + + public void Dispose() + { + _sessionTracker.Unregister(_sessionId); + } + + private string? GetRemoteAddress() + => _httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString(); private static bool VerifyPasswordHash(string password, string configuredHash) { - var passwordHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(password))); + var passwordHash = AccessPasswordSettingsWriter.HashPassword(password); return FixedEquals(passwordHash, configuredHash.Trim()); } diff --git a/TrafagSalesExporter/Services/UiTextService.cs b/TrafagSalesExporter/Services/UiTextService.cs index 54e1e63..50e06d8 100644 --- a/TrafagSalesExporter/Services/UiTextService.cs +++ b/TrafagSalesExporter/Services/UiTextService.cs @@ -26,6 +26,7 @@ public sealed class UiTextService : IUiTextService ["es"] = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Trafag Finance/Sales Management Cockpit"] = "Trafag Cockpit de finanzas y ventas", + ["Willkommen im Trafag Analyse Dashboard"] = "Bienvenido al panel analítico de Trafag", ["Finance Cockpit"] = "Cockpit financiero", ["Finance Cockpit ist geschuetzt. Bitte separat anmelden."] = "El cockpit financiero está protegido. Inicie sesión por separado.", ["Finance-Cockpit-Zugang ist noch nicht konfiguriert. Bitte Username und PasswordHash in FinanceCockpitAccess konfigurieren."] = "El acceso al cockpit financiero aún no está configurado. Configure Username y PasswordHash en FinanceCockpitAccess.", @@ -40,6 +41,13 @@ public sealed class UiTextService : IUiTextService ["Finance Regeln"] = "Reglas financieras", ["Settings"] = "Configuración", ["Logs"] = "Registros", + ["Aktive Logins"] = "Inicios de sesión activos", + ["Admin Bereich"] = "Área de administración", + ["Adminbereich ist geschützt. Bitte anmelden."] = "El área de administración está protegida. Inicie sesión.", + ["Admin-Zugang ist noch nicht konfiguriert."] = "El acceso de administrador aún no está configurado.", + ["Admin entsperren"] = "Desbloquear administración", + ["Admin-Anmeldung fehlgeschlagen."] = "Error al iniciar sesión como administrador.", + ["Admin sperren"] = "Bloquear administración", ["Finance sperren"] = "Bloquear finanzas", ["HR KPI (Login)"] = "KPI RR. HH. (login)", ["HR Dashboard"] = "Panel HR", @@ -50,6 +58,24 @@ public sealed class UiTextService : IUiTextService ["HR-KPI-Anmeldung fehlgeschlagen."] = "Error al iniciar sesión en HR KPI.", ["Name"] = "Nombre", ["Passwort"] = "Contraseña", + ["Passwort ändern"] = "Cambiar contraseña", + ["Aktuelles Passwort"] = "Contraseña actual", + ["Neues Passwort"] = "Nueva contraseña", + ["Mindestens 8 Zeichen."] = "Al menos 8 caracteres.", + ["Neues Passwort wiederholen"] = "Repetir nueva contraseña", + ["Passwort speichern"] = "Guardar contraseña", + ["Das neue Passwort muss mindestens 8 Zeichen lang sein."] = "La nueva contraseña debe tener al menos 8 caracteres.", + ["Die neuen Passwörter stimmen nicht überein."] = "Las nuevas contraseñas no coinciden.", + ["Passwort konnte nicht geändert werden. Name oder aktuelles Passwort prüfen."] = "No se pudo cambiar la contraseña. Compruebe el nombre o la contraseña actual.", + ["Passwort wurde geändert."] = "La contraseña se ha cambiado.", + ["HR-/Finance-Cockpit Sessions"] = "Sesiones de cockpit HR/Finance", + ["Gezählt werden App-interne Entsperrungen seit dem letzten App-Start."] = "Se cuentan los desbloqueos internos de la app desde el último inicio.", + ["Bereich"] = "Área", + ["IP-Adresse"] = "Dirección IP", + ["Entsperrt seit"] = "Desbloqueado desde", + ["Zuletzt gesehen"] = "Visto por última vez", + ["Keine aktiven HR-/Finance-Logins erfasst."] = "No hay inicios de sesión HR/Finance activos registrados.", + ["Hinweis: HR und Finance verwenden gemeinsame App-Logins. Diese Seite zeigt daher den verwendeten Login-Namen und die Session, nicht zwingend die echte Person."] = "Nota: HR y Finance usan logins compartidos de la app. Esta página muestra el nombre de login usado y la sesión, no necesariamente la persona real.", ["Finance Cockpit entsperren"] = "Desbloquear cockpit financiero", ["Finance-Jahr"] = "Año financiero", ["Finance Summary laden"] = "Cargar resumen financiero", @@ -230,6 +256,7 @@ public sealed class UiTextService : IUiTextService ["it"] = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Trafag Finance/Sales Management Cockpit"] = "Cockpit Trafag finanza e vendite", + ["Willkommen im Trafag Analyse Dashboard"] = "Benvenuto nel dashboard analitico Trafag", ["Finance Cockpit"] = "Cockpit finance", ["Finance Cockpit ist geschuetzt. Bitte separat anmelden."] = "Il cockpit finance è protetto. Effettuare un accesso separato.", ["Finance-Cockpit-Zugang ist noch nicht konfiguriert. Bitte Username und PasswordHash in FinanceCockpitAccess konfigurieren."] = "L'accesso al cockpit finance non è ancora configurato. Configurare Username e PasswordHash in FinanceCockpitAccess.", @@ -244,6 +271,13 @@ public sealed class UiTextService : IUiTextService ["Finance Regeln"] = "Regole finance", ["Settings"] = "Impostazioni", ["Logs"] = "Log", + ["Aktive Logins"] = "Login attivi", + ["Admin Bereich"] = "Area admin", + ["Adminbereich ist geschützt. Bitte anmelden."] = "L'area admin è protetta. Effettuare l'accesso.", + ["Admin-Zugang ist noch nicht konfiguriert."] = "L'accesso admin non è ancora configurato.", + ["Admin entsperren"] = "Sblocca admin", + ["Admin-Anmeldung fehlgeschlagen."] = "Accesso admin non riuscito.", + ["Admin sperren"] = "Blocca admin", ["Finance sperren"] = "Blocca finance", ["HR KPI (Login)"] = "KPI HR (login)", ["HR Dashboard"] = "Dashboard HR", @@ -254,6 +288,24 @@ public sealed class UiTextService : IUiTextService ["HR-KPI-Anmeldung fehlgeschlagen."] = "Accesso a HR KPI non riuscito.", ["Name"] = "Nome", ["Passwort"] = "Password", + ["Passwort ändern"] = "Cambia password", + ["Aktuelles Passwort"] = "Password attuale", + ["Neues Passwort"] = "Nuova password", + ["Mindestens 8 Zeichen."] = "Almeno 8 caratteri.", + ["Neues Passwort wiederholen"] = "Ripeti nuova password", + ["Passwort speichern"] = "Salva password", + ["Das neue Passwort muss mindestens 8 Zeichen lang sein."] = "La nuova password deve contenere almeno 8 caratteri.", + ["Die neuen Passwörter stimmen nicht überein."] = "Le nuove password non corrispondono.", + ["Passwort konnte nicht geändert werden. Name oder aktuelles Passwort prüfen."] = "Impossibile modificare la password. Controllare nome o password attuale.", + ["Passwort wurde geändert."] = "La password è stata modificata.", + ["HR-/Finance-Cockpit Sessions"] = "Sessioni cockpit HR/Finance", + ["Gezählt werden App-interne Entsperrungen seit dem letzten App-Start."] = "Vengono contati gli sblocchi interni dell'app dall'ultimo avvio.", + ["Bereich"] = "Area", + ["IP-Adresse"] = "Indirizzo IP", + ["Entsperrt seit"] = "Sbloccato da", + ["Zuletzt gesehen"] = "Ultima attività", + ["Keine aktiven HR-/Finance-Logins erfasst."] = "Nessun login HR/Finance attivo registrato.", + ["Hinweis: HR und Finance verwenden gemeinsame App-Logins. Diese Seite zeigt daher den verwendeten Login-Namen und die Session, nicht zwingend die echte Person."] = "Nota: HR e Finance usano login condivisi dell'app. Questa pagina mostra quindi il nome login usato e la sessione, non necessariamente la persona reale.", ["Finance Cockpit entsperren"] = "Sblocca cockpit finance", ["Finance-Jahr"] = "Anno finance", ["Finance Summary laden"] = "Carica riepilogo finance", @@ -434,6 +486,7 @@ public sealed class UiTextService : IUiTextService ["hi"] = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Trafag Finance/Sales Management Cockpit"] = "Trafag वित्त और बिक्री प्रबंधन कॉकपिट", + ["Willkommen im Trafag Analyse Dashboard"] = "Trafag विश्लेषण डैशबोर्ड में आपका स्वागत है", ["Finance Cockpit"] = "वित्त कॉकपिट", ["Finance Cockpit ist geschuetzt. Bitte separat anmelden."] = "वित्त कॉकपिट सुरक्षित है. कृपया अलग से साइन इन करें.", ["Finance-Cockpit-Zugang ist noch nicht konfiguriert. Bitte Username und PasswordHash in FinanceCockpitAccess konfigurieren."] = "वित्त कॉकपिट एक्सेस अभी कॉन्फ़िगर नहीं है. कृपया FinanceCockpitAccess में Username और PasswordHash सेट करें.", @@ -448,6 +501,13 @@ public sealed class UiTextService : IUiTextService ["Finance Regeln"] = "वित्त नियम", ["Settings"] = "सेटिंग्स", ["Logs"] = "लॉग", + ["Aktive Logins"] = "सक्रिय लॉगिन", + ["Admin Bereich"] = "Admin क्षेत्र", + ["Adminbereich ist geschützt. Bitte anmelden."] = "Admin क्षेत्र सुरक्षित है. कृपया साइन इन करें.", + ["Admin-Zugang ist noch nicht konfiguriert."] = "Admin एक्सेस अभी कॉन्फ़िगर नहीं है.", + ["Admin entsperren"] = "Admin अनलॉक करें", + ["Admin-Anmeldung fehlgeschlagen."] = "Admin साइन-इन विफल.", + ["Admin sperren"] = "Admin लॉक करें", ["Finance sperren"] = "वित्त लॉक करें", ["HR KPI (Login)"] = "HR KPI (लॉगिन)", ["HR Dashboard"] = "HR डैशबोर्ड", @@ -458,6 +518,24 @@ public sealed class UiTextService : IUiTextService ["HR-KPI-Anmeldung fehlgeschlagen."] = "HR KPI साइन-इन विफल.", ["Name"] = "नाम", ["Passwort"] = "पासवर्ड", + ["Passwort ändern"] = "पासवर्ड बदलें", + ["Aktuelles Passwort"] = "वर्तमान पासवर्ड", + ["Neues Passwort"] = "नया पासवर्ड", + ["Mindestens 8 Zeichen."] = "कम से कम 8 अक्षर.", + ["Neues Passwort wiederholen"] = "नया पासवर्ड दोहराएं", + ["Passwort speichern"] = "पासवर्ड सहेजें", + ["Das neue Passwort muss mindestens 8 Zeichen lang sein."] = "नया पासवर्ड कम से कम 8 अक्षरों का होना चाहिए.", + ["Die neuen Passwörter stimmen nicht überein."] = "नए पासवर्ड मेल नहीं खाते.", + ["Passwort konnte nicht geändert werden. Name oder aktuelles Passwort prüfen."] = "पासवर्ड बदला नहीं जा सका. नाम या वर्तमान पासवर्ड जांचें.", + ["Passwort wurde geändert."] = "पासवर्ड बदल दिया गया.", + ["HR-/Finance-Cockpit Sessions"] = "HR/Finance cockpit sessions", + ["Gezählt werden App-interne Entsperrungen seit dem letzten App-Start."] = "ऐप के पिछले प्रारंभ के बाद से आंतरिक अनलॉक गिने जाते हैं.", + ["Bereich"] = "क्षेत्र", + ["IP-Adresse"] = "IP पता", + ["Entsperrt seit"] = "अनलॉक समय", + ["Zuletzt gesehen"] = "अंतिम बार देखा गया", + ["Keine aktiven HR-/Finance-Logins erfasst."] = "कोई सक्रिय HR/Finance लॉगिन दर्ज नहीं है.", + ["Hinweis: HR und Finance verwenden gemeinsame App-Logins. Diese Seite zeigt daher den verwendeten Login-Namen und die Session, nicht zwingend die echte Person."] = "नोट: HR और Finance साझा ऐप लॉगिन का उपयोग करते हैं. इसलिए यह पेज उपयोग किया गया लॉगिन नाम और सत्र दिखाता है, जरूरी नहीं कि वास्तविक व्यक्ति.", ["Finance Cockpit entsperren"] = "वित्त कॉकपिट अनलॉक करें", ["Finance-Jahr"] = "वित्त वर्ष", ["Finance Summary laden"] = "वित्त सारांश लोड करें", diff --git a/TrafagSalesExporter/appsettings.json b/TrafagSalesExporter/appsettings.json index 032d57c..81261ef 100644 --- a/TrafagSalesExporter/appsettings.json +++ b/TrafagSalesExporter/appsettings.json @@ -38,5 +38,10 @@ "Enabled": true, "Username": "finance", "PasswordHash": "1446F41A1BF8ABCF5DED217400CDC5D671F9E1B58753162A228F23FB7C844575" + }, + "AdminAccess": { + "Enabled": true, + "Username": "admin", + "PasswordHash": "F0101E12FBCCDD6D2645B214B8732F5AEDFFB2DABBE7EE98043E68DB3BD9ADA4" } } diff --git a/TrafagSalesExporter/lastchange.md b/TrafagSalesExporter/lastchange.md index d6173fa..8a7f80d 100644 --- a/TrafagSalesExporter/lastchange.md +++ b/TrafagSalesExporter/lastchange.md @@ -112,6 +112,42 @@ Serverbefund: - Dadurch erreichen Requests weder `diag.txt` noch `BiDashboard.dll`. - Marco/IT muss in IIS die SSL Settings pruefen und Client Certificates auf `Ignore` oder hoechstens `Accept` setzen, nicht `Require`. +## Adminbereich und Passwortwechsel 2026-05-21 + +Geaendert: + +- Finance Cockpit und HR KPI Login-Masken haben einen Bereich `Passwort ändern`. +- Passwortaenderung verlangt Benutzername, aktuelles Passwort, neues Passwort und Wiederholung. +- Neue Passwoerter muessen mindestens 8 Zeichen haben. +- Gespeichert wird ein SHA-256-Hash in `appsettings.json`, kein Klartext. +- Neuer interner Adminbereich `/admin/sessions`. +- Der Adminbereich hat eine eigene App-interne Sperre `AdminAccess`. +- Adminseite `Aktive Logins` zeigt App-interne HR-/Finance-Entsperrungen seit dem letzten App-Start: + - Bereich + - Login-Name + - IP-Adresse, soweit aus dem Request verfuegbar + - Entsperrt seit + - Zuletzt gesehen +- Hinweis: Da HR und Finance gemeinsame App-Logins verwenden, zeigt die Seite nicht zwingend die echte Person, sondern die verwendete App-Session. +- Standorte-Tabelle zeigt jetzt Icons fuer den Quellentyp: + - Upload-Datei = Manual Excel / CSV + - Cloud Sync = SAP OData + - Storage = HANA / Server + +Initialer Adminzugang: + +```text +Username: admin +Initialpasswort: TrafagAdmin2026! +``` + +Nach erster Nutzung sollte das Adminpasswort ueber die Admin-Loginmaske geaendert werden. + +Verifiziert: + +- `dotnet build .\TrafagSalesExporter.csproj --no-restore --verbosity minimal -p:OutDir=C:\TMP\trafag_out\` +- Ergebnis: Build erfolgreich, nur bestehende MudBlazor-Analyzer-Warnungen zu `Dense` auf vorhandenen Controls. + ## Markdown-Doku und Anwenderdokus nachgezogen 2026-05-20 Geaendert: