Add admin access and landing dashboard

This commit is contained in:
2026-05-21 13:43:47 +02:00
parent 6b3dc2de60
commit 9471c5c310
19 changed files with 1442 additions and 456 deletions
@@ -0,0 +1,91 @@
@using TrafagSalesExporter.Services
@inject IAdminAccessService AdminAccess
@inject ISnackbar Snackbar
@inject IUiTextService UiText
<MudPaper Class="pa-4 mb-4" Elevation="1" Style="max-width:520px;">
<MudStack Spacing="3">
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined">
@T("Adminbereich ist geschützt. Bitte anmelden.", "Admin area is protected. Please sign in.")
</MudAlert>
@if (!AdminAccess.IsConfigured)
{
<MudAlert Severity="Severity.Error" Variant="Variant.Filled">
@T("Admin-Zugang ist noch nicht konfiguriert.", "Admin access is not configured yet.")
</MudAlert>
}
<MudTextField @bind-Value="_username" Label="@T("Name", "Name")" Disabled="@(!AdminAccess.IsConfigured)" />
<MudTextField @bind-Value="_password" Label="@T("Passwort", "Password")" InputType="InputType.Password" Disabled="@(!AdminAccess.IsConfigured)" />
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="Unlock"
StartIcon="@Icons.Material.Filled.LockOpen" Disabled="@(!AdminAccess.IsConfigured)">
@T("Admin entsperren", "Unlock admin")
</MudButton>
<MudDivider />
<MudExpansionPanels Elevation="0">
<MudExpansionPanel Text="@T("Passwort ändern", "Change password")" Icon="@Icons.Material.Filled.Password">
<MudStack Spacing="3" Class="pt-2">
<MudTextField @bind-Value="_changeUsername" Label="@T("Name", "Name")" Disabled="@(!AdminAccess.IsConfigured)" />
<MudTextField @bind-Value="_currentPassword" Label="@T("Aktuelles Passwort", "Current password")" InputType="InputType.Password" Disabled="@(!AdminAccess.IsConfigured)" />
<MudTextField @bind-Value="_newPassword" Label="@T("Neues Passwort", "New password")" InputType="InputType.Password" HelperText="@T("Mindestens 8 Zeichen.", "At least 8 characters.")" Disabled="@(!AdminAccess.IsConfigured)" />
<MudTextField @bind-Value="_newPasswordRepeat" Label="@T("Neues Passwort wiederholen", "Repeat new password")" InputType="InputType.Password" Disabled="@(!AdminAccess.IsConfigured)" />
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="ChangePassword"
StartIcon="@Icons.Material.Filled.Save" Disabled="@(!AdminAccess.IsConfigured)">
@T("Passwort speichern", "Save password")
</MudButton>
</MudStack>
</MudExpansionPanel>
</MudExpansionPanels>
</MudStack>
</MudPaper>
@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; }
}
@@ -23,12 +23,31 @@
StartIcon="@Icons.Material.Filled.LockOpen" Disabled="@(!FinanceAccess.IsConfigured)"> StartIcon="@Icons.Material.Filled.LockOpen" Disabled="@(!FinanceAccess.IsConfigured)">
@T("Finance Cockpit entsperren", "Unlock Finance Cockpit") @T("Finance Cockpit entsperren", "Unlock Finance Cockpit")
</MudButton> </MudButton>
<MudDivider />
<MudExpansionPanels Elevation="0">
<MudExpansionPanel Text="@T("Passwort ändern", "Change password")" Icon="@Icons.Material.Filled.Password">
<MudStack Spacing="3" Class="pt-2">
<MudTextField @bind-Value="_changeUsername" Label="@T("Name", "Name")" Disabled="@(!FinanceAccess.IsConfigured)" />
<MudTextField @bind-Value="_currentPassword" Label="@T("Aktuelles Passwort", "Current password")" InputType="InputType.Password" Disabled="@(!FinanceAccess.IsConfigured)" />
<MudTextField @bind-Value="_newPassword" Label="@T("Neues Passwort", "New password")" InputType="InputType.Password" HelperText="@T("Mindestens 8 Zeichen.", "At least 8 characters.")" Disabled="@(!FinanceAccess.IsConfigured)" />
<MudTextField @bind-Value="_newPasswordRepeat" Label="@T("Neues Passwort wiederholen", "Repeat new password")" InputType="InputType.Password" Disabled="@(!FinanceAccess.IsConfigured)" />
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="ChangePasswordAsync"
StartIcon="@Icons.Material.Filled.Save" Disabled="@(!FinanceAccess.IsConfigured)">
@T("Passwort speichern", "Save password")
</MudButton>
</MudStack>
</MudExpansionPanel>
</MudExpansionPanels>
</MudStack> </MudStack>
</MudPaper> </MudPaper>
@code { @code {
private string? _username; private string? _username;
private string? _password; private string? _password;
private string? _changeUsername;
private string? _currentPassword;
private string? _newPassword;
private string? _newPasswordRepeat;
private Task UnlockAsync() private Task UnlockAsync()
{ {
@@ -43,5 +62,32 @@
return Task.CompletedTask; 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); private string T(string german, string english) => UiText.Text(german, english);
} }
@@ -7,7 +7,7 @@
<MudNavMenu> <MudNavMenu>
<MudNavGroup Title="@T("Finance Cockpit", "Finance Cockpit")" Icon="@Icons.Material.Filled.Analytics" Expanded="true"> <MudNavGroup Title="@T("Finance Cockpit", "Finance Cockpit")" Icon="@Icons.Material.Filled.Analytics" Expanded="true">
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard"> <MudNavLink Href="/export-dashboard" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Dashboard">
@T("Export Dashboard", "Export dashboard") @T("Export Dashboard", "Export dashboard")
</MudNavLink> </MudNavLink>
<MudNavLink Href="/management-cockpit" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.QueryStats"> <MudNavLink Href="/management-cockpit" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.QueryStats">
@@ -62,6 +62,9 @@
@T("HR KPI Schulung", "HR KPI training") @T("HR KPI Schulung", "HR KPI training")
</MudNavLink> </MudNavLink>
</MudNavGroup> </MudNavGroup>
<MudNavLink Href="/admin/sessions" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.PeopleAlt">
@T("Admin Bereich", "Admin area")
</MudNavLink>
</MudNavMenu> </MudNavMenu>
@code { @code {
@@ -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
<PageTitle>@T("Aktive Logins", "Active logins")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">@T("Aktive Logins", "Active logins")</MudText>
@if (!AdminAccess.IsUnlocked)
{
<AdminAccessPanel OnUnlocked="Refresh" />
}
else
{
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudStack Row AlignItems="AlignItems.Center" Class="mb-3">
<div>
<MudText Typo="Typo.h6">@T("HR-/Finance-Cockpit Sessions", "HR/Finance cockpit sessions")</MudText>
<MudText Typo="Typo.caption">
@T("Gezählt werden App-interne Entsperrungen seit dem letzten App-Start.", "Counts app-internal unlocks since the last app start.")
</MudText>
</div>
<MudSpacer />
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.Lock" OnClick="LockAdmin">
@T("Admin sperren", "Lock admin")
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Refresh" OnClick="Refresh">
@T("Aktualisieren", "Refresh")
</MudButton>
</MudStack>
<MudTable Items="_sessions" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Bereich", "Area")</MudTh>
<MudTh>@T("Name", "Name")</MudTh>
<MudTh>@T("IP-Adresse", "IP address")</MudTh>
<MudTh>@T("Entsperrt seit", "Unlocked since")</MudTh>
<MudTh>@T("Zuletzt gesehen", "Last seen")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Area</MudTd>
<MudTd>@context.Username</MudTd>
<MudTd>@context.RemoteAddress</MudTd>
<MudTd>@FormatDate(context.StartedAt)</MudTd>
<MudTd>@FormatDate(context.LastSeenAt)</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText Typo="Typo.caption">@T("Keine aktiven HR-/Finance-Logins erfasst.", "No active HR/Finance logins recorded.")</MudText>
</NoRecordsContent>
</MudTable>
</MudPaper>
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined">
@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.")
</MudAlert>
}
@code {
private IReadOnlyList<AccessSessionSnapshot> _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);
}
@@ -1,481 +1,126 @@
@page "/" @page "/"
@using System.Diagnostics
@using TrafagSalesExporter.Services @using TrafagSalesExporter.Services
@inject IDashboardPageService DashboardPageActions
@inject ExportOrchestrationService Orchestrator
@inject TimerBackgroundService TimerService
@inject ISnackbar Snackbar
@inject IUiTextService UiText @inject IUiTextService UiText
@implements IDisposable
<PageTitle>@T("Export Dashboard", "Export dashboard")</PageTitle> <PageTitle>@T("Trafag Cockpit", "Trafag Cockpit")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">@T("Export Dashboard", "Export dashboard")</MudText> <div class="home-shell">
<div class="home-content">
<svg class="home-manometer" viewBox="0 0 600 340" role="img" aria-label="Trafag cockpit manometer">
<rect x="0" y="0" width="600" height="340" fill="#fff" />
<path d="M70 260 A230 230 0 0 1 530 260" class="gauge-outer" />
<path d="M115 260 A185 185 0 0 1 485 260" class="gauge-inner" />
<MudPaper Class="pa-4 mb-4" Elevation="1"> <line x1="126" y1="260" x2="95" y2="260" class="gauge-tick" />
<MudStack Row AlignItems="AlignItems.Center" Spacing="4"> <line x1="177" y1="137" x2="155" y2="115" class="gauge-tick" />
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.PlayArrow" <line x1="300" y1="86" x2="300" y2="55" class="gauge-tick" />
OnClick="ExportAll" Disabled="_anyRunning"> <line x1="423" y1="137" x2="445" y2="115" class="gauge-tick" />
@T("Alle exportieren", "Export all") <line x1="474" y1="260" x2="505" y2="260" class="gauge-tick" />
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.TableView"
OnClick="ExportConsolidatedOnly" Disabled="_anyRunning">
@T("Zentrale Datei neu erzeugen", "Rebuild consolidated file")
</MudButton>
<MudText Typo="Typo.body1">
@if (TimerService.NextRun < DateTime.MaxValue)
{
<MudIcon Icon="@Icons.Material.Filled.Schedule" Size="Size.Small" Class="mr-1" />
@(string.Format(T("Naechster automatischer Lauf: {0}", "Next automatic run: {0}"), TimerService.NextRun.ToString("dd.MM.yyyy HH:mm")))
}
else
{
<MudIcon Icon="@Icons.Material.Filled.TimerOff" Size="Size.Small" Class="mr-1" />
@T("Timer deaktiviert", "Timer disabled")
}
</MudText>
</MudStack>
</MudPaper>
@if (_readinessWarnings.Count > 0) <text x="150" y="230" class="gauge-label">0</text>
{ <text x="205" y="154" class="gauge-label">25</text>
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Dense Class="mb-4"> <text x="300" y="126" class="gauge-label">50</text>
<MudText Typo="Typo.body2">@T("Aktive Standorte sind noch nicht vollstaendig bereit:", "Active sites are not fully ready:")</MudText> <text x="395" y="154" class="gauge-label">75</text>
@foreach (var warning in _readinessWarnings) <text x="450" y="230" class="gauge-label">100</text>
{ <text x="300" y="222" class="gauge-brand">TRAFAG</text>
<MudText Typo="Typo.caption">@warning</MudText>
} <g class="gauge-needle">
</MudAlert> <line x1="300" y1="260" x2="300" y2="96" class="needle-line" />
</g>
<circle cx="300" cy="260" r="28" fill="#050505" />
</svg>
<div class="home-welcome">@T("Willkommen im Trafag Analyse Dashboard", "Welcome to the Trafag Analytical Dashboard")</div>
</div>
</div>
<style>
.home-shell {
min-height: calc(100vh - 112px);
display: flex;
align-items: center;
justify-content: center;
background: #fff;
} }
@if (_consolidatedStale) .home-content {
{ display: flex;
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense Class="mb-4"> flex-direction: column;
@T("Seit der letzten zentralen Excel wurde mindestens ein Standort neu exportiert. Bitte `Zentrale Datei neu erzeugen` ausfuehren, damit das Endexcel aktuell ist.", align-items: center;
"At least one site was exported after the last consolidated Excel. Please rebuild the consolidated file so the final Excel is current.") gap: 18px;
</MudAlert>
} }
<MudTable Items="_dashboardRows" Dense Hover Striped Loading="_loading"> .home-manometer {
<HeaderContent> width: min(336px, 58vw);
<MudTh>@T("Land", "Country")</MudTh> height: auto;
<MudTh>@T("Basis", "Basis")</MudTh> display: block;
<MudTh>TSC</MudTh>
<MudTh>@T("Schema", "Schema")</MudTh>
<MudTh>@T("Server", "Server")</MudTh>
<MudTh>@T("Status", "Status")</MudTh>
<MudTh>@T("Live-Status", "Live status")</MudTh>
<MudTh>@T("Zeilen", "Rows")</MudTh>
<MudTh>@T("Letzter Lauf", "Last run")</MudTh>
<MudTh>@T("Dauer", "Duration")</MudTh>
<MudTh>@T("Aktion", "Action")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Land</MudTd>
<MudTd>
<MudTooltip Text="@context.DataBasis">
<MudStack Row AlignItems="AlignItems.Center" Spacing="1">
<MudIcon Icon="@GetDataBasisIcon(context.DataBasis)" Color="@GetDataBasisColor(context.DataBasis)" Size="Size.Small" />
<MudText Typo="Typo.caption">@context.DataBasis</MudText>
</MudStack>
</MudTooltip>
</MudTd>
<MudTd>@context.TSC</MudTd>
<MudTd>@context.Schema</MudTd>
<MudTd>@context.ServerName</MudTd>
<MudTd>
@if (Orchestrator.IsExporting(context.SiteId))
{
<MudProgressCircular Size="Size.Small" Indeterminate Color="Color.Primary" Class="mr-1" />
<MudText Typo="Typo.caption">@Orchestrator.GetExportStatus(context.SiteId)</MudText>
}
else if (context.LastStatus == "OK")
{
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
}
else if (context.LastStatus == "Error")
{
<MudTooltip Text="@context.ErrorMessage">
<MudIcon Icon="@Icons.Material.Filled.Error" Color="Color.Error" Size="Size.Small" />
</MudTooltip>
}
else
{
<MudText Typo="Typo.caption" Color="Color.Default">-</MudText>
}
</MudTd>
<MudTd>
@if (!string.IsNullOrWhiteSpace(context.LiveMessage))
{
<MudTooltip Text="@context.LiveDetails">
<MudText Typo="Typo.caption" Style="max-width:360px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block;">
@context.LiveMessage
</MudText>
</MudTooltip>
}
else
{
<MudText Typo="Typo.caption" Color="Color.Default">-</MudText>
}
</MudTd>
<MudTd>@(context.RowCount > 0 ? context.RowCount.ToString("N0") : "-")</MudTd>
<MudTd>@(context.LastRun.HasValue ? context.LastRun.Value.ToString("dd.MM.yyyy HH:mm:ss") : "-")</MudTd>
<MudTd>@(context.DurationSeconds > 0 ? $"{context.DurationSeconds:F1}s" : "-")</MudTd>
<MudTd>
<MudStack Row Spacing="1">
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.FileDownload"
OnClick="() => ExportSingle(context.SiteId)"
Disabled="Orchestrator.IsExporting(context.SiteId)">
Export
</MudButton>
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Info"
StartIcon="@Icons.Material.Filled.OpenInNew"
OnClick="() => OpenExportFile(context)"
Disabled="@(!context.HasOpenableFile || Orchestrator.IsExporting(context.SiteId))">
@T("Excel oeffnen", "Open Excel")
</MudButton>
</MudStack>
</MudTd>
</RowTemplate>
</MudTable>
<MudPaper Class="pa-4 mt-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-3">@T("Zentrale Datei", "Consolidated file")</MudText>
<MudTable Items="_consolidatedRows" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Datei", "File")</MudTh>
<MudTh>Pfad</MudTh>
<MudTh>Letzte Änderung</MudTh>
<MudTh>@T("Status", "Status")</MudTh>
<MudTh>@T("Aktion", "Action")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Label</MudTd>
<MudTd>@context.DisplayPath</MudTd>
<MudTd>@(context.LastModified.HasValue ? context.LastModified.Value.ToString("dd.MM.yyyy HH:mm:ss") : "-")</MudTd>
<MudTd>
@if (Orchestrator.IsConsolidatedExporting())
{
<MudProgressCircular Size="Size.Small" Indeterminate Color="Color.Primary" Class="mr-1" />
<MudText Typo="Typo.caption">@Orchestrator.GetConsolidatedExportStatus()</MudText>
}
else
{
<MudText Typo="Typo.caption" Color="Color.Default">-</MudText>
}
</MudTd>
<MudTd>
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Info"
StartIcon="@Icons.Material.Filled.OpenInNew"
OnClick="() => OpenFile(context.FilePath)"
Disabled="@(!context.HasOpenableFile)">
@T("Excel oeffnen", "Open Excel")
</MudButton>
</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText Typo="Typo.caption">@T("Keine zentrale Excel-Datei gefunden.", "No consolidated Excel file found.")</MudText>
</NoRecordsContent>
</MudTable>
</MudPaper>
@code {
private List<DashboardRow> _dashboardRows = new();
private List<ConsolidatedDashboardRow> _consolidatedRows = new();
private List<string> _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() .home-welcome {
{ color: #050505;
_loading = true; font-size: 24px;
var state = await DashboardPageActions.LoadAsync(); font-weight: 700;
_dashboardRows = state.DashboardRows; text-align: center;
_consolidatedRows = state.ConsolidatedRows; letter-spacing: 0;
_readinessWarnings = state.ReadinessWarnings;
_consolidatedStale = state.IsConsolidatedStale;
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting();
_loading = false;
} }
private async Task ExportAll() .gauge-outer,
{ .gauge-inner,
if (_readinessWarnings.Count > 0) .gauge-tick,
{ .needle-line {
Snackbar.Add(T("Es gibt aktive Standorte mit fehlender manueller Datei. Bitte Warnung im Dashboard pruefen.", fill: none;
"There are active sites with missing manual files. Please check the dashboard warning."), Severity.Warning); stroke: #050505;
stroke-linecap: round;
} }
_anyRunning = true; .gauge-outer {
await LoadDataAsync(); stroke-width: 16;
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() .gauge-inner {
{ stroke-width: 4;
_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) .gauge-tick {
{ stroke-width: 7;
_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() .gauge-label {
{ fill: #050505;
await InvokeAsync(async () => font-size: 24px;
{ font-weight: 800;
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting() || _dashboardRows.Count == 0; text-anchor: middle;
if (_anyRunning) dominant-baseline: middle;
{
StartPolling();
await RefreshLiveDataAsync();
StateHasChanged();
return;
} }
StopPolling(); .gauge-brand {
await LoadDataAsync(); fill: #050505;
StateHasChanged(); font-size: 28px;
}); font-weight: 900;
letter-spacing: 4px;
text-anchor: middle;
} }
public void Dispose() .needle-line {
{ stroke-width: 9;
StopPolling();
Orchestrator.OnExportStatusChanged -= HandleStatusChanged;
} }
private void OpenExportFile(DashboardRow row) .gauge-needle {
{ transform-origin: 300px 260px;
OpenFile(row.FilePath); animation: home-gauge-sweep 6.2s infinite cubic-bezier(.42, 0, .2, 1);
}
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;
} }
@@keyframes home-gauge-sweep {
0% { transform: rotate(-58deg); }
9% { transform: rotate(-12deg); }
18% { transform: rotate(43deg); }
31% { transform: rotate(8deg); }
44% { transform: rotate(68deg); }
58% { transform: rotate(-35deg); }
72% { transform: rotate(24deg); }
86% { transform: rotate(56deg); }
100% { transform: rotate(-58deg); }
} }
</style>
@code { @code {
private string T(string german, string english) => UiText.Text(german, english); private string T(string german, string english) => UiText.Text(german, english);
@@ -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
<PageTitle>@T("Export Dashboard", "Export dashboard")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">@T("Export Dashboard", "Export dashboard")</MudText>
<MudPaper Class="pa-4 mb-4" Elevation="1">
<div class="dashboard-header">
<MudStack Row AlignItems="AlignItems.Center" Spacing="4" Class="dashboard-actions">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.PlayArrow"
OnClick="ExportAll" Disabled="_anyRunning">
@T("Alle exportieren", "Export all")
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.TableView"
OnClick="ExportConsolidatedOnly" Disabled="_anyRunning">
@T("Zentrale Datei neu erzeugen", "Rebuild consolidated file")
</MudButton>
<MudText Typo="Typo.body1">
@if (TimerService.NextRun < DateTime.MaxValue)
{
<MudIcon Icon="@Icons.Material.Filled.Schedule" Size="Size.Small" Class="mr-1" />
@(string.Format(T("Naechster automatischer Lauf: {0}", "Next automatic run: {0}"), TimerService.NextRun.ToString("dd.MM.yyyy HH:mm")))
}
else
{
<MudIcon Icon="@Icons.Material.Filled.TimerOff" Size="Size.Small" Class="mr-1" />
@T("Timer deaktiviert", "Timer disabled")
}
</MudText>
</MudStack>
<div class="dashboard-manometer" aria-label="Export activity manometer">
<div class="manometer-arc">
<span class="tick tick-0"></span>
<span class="tick tick-1"></span>
<span class="tick tick-2"></span>
<span class="tick tick-3"></span>
<span class="tick tick-4"></span>
<span class="needle"></span>
<span class="hub"></span>
</div>
</div>
</div>
</MudPaper>
@if (_readinessWarnings.Count > 0)
{
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Dense Class="mb-4">
<MudText Typo="Typo.body2">@T("Aktive Standorte sind noch nicht vollstaendig bereit:", "Active sites are not fully ready:")</MudText>
@foreach (var warning in _readinessWarnings)
{
<MudText Typo="Typo.caption">@warning</MudText>
}
</MudAlert>
}
@if (_consolidatedStale)
{
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense Class="mb-4">
@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.")
</MudAlert>
}
<MudTable Items="_dashboardRows" Dense Hover Striped Loading="_loading">
<HeaderContent>
<MudTh>@T("Land", "Country")</MudTh>
<MudTh>@T("Basis", "Basis")</MudTh>
<MudTh>TSC</MudTh>
<MudTh>@T("Schema", "Schema")</MudTh>
<MudTh>@T("Server", "Server")</MudTh>
<MudTh>@T("Status", "Status")</MudTh>
<MudTh>@T("Live-Status", "Live status")</MudTh>
<MudTh>@T("Zeilen", "Rows")</MudTh>
<MudTh>@T("Letzter Lauf", "Last run")</MudTh>
<MudTh>@T("Dauer", "Duration")</MudTh>
<MudTh>@T("Aktion", "Action")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Land</MudTd>
<MudTd>
<MudTooltip Text="@context.DataBasis">
<MudStack Row AlignItems="AlignItems.Center" Spacing="1">
<MudIcon Icon="@GetDataBasisIcon(context.DataBasis)" Color="@GetDataBasisColor(context.DataBasis)" Size="Size.Small" />
<MudText Typo="Typo.caption">@context.DataBasis</MudText>
</MudStack>
</MudTooltip>
</MudTd>
<MudTd>@context.TSC</MudTd>
<MudTd>@context.Schema</MudTd>
<MudTd>@context.ServerName</MudTd>
<MudTd>
@if (Orchestrator.IsExporting(context.SiteId))
{
<MudProgressCircular Size="Size.Small" Indeterminate Color="Color.Primary" Class="mr-1" />
<MudText Typo="Typo.caption">@Orchestrator.GetExportStatus(context.SiteId)</MudText>
}
else if (context.LastStatus == "OK")
{
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
}
else if (context.LastStatus == "Error")
{
<MudTooltip Text="@context.ErrorMessage">
<MudIcon Icon="@Icons.Material.Filled.Error" Color="Color.Error" Size="Size.Small" />
</MudTooltip>
}
else
{
<MudText Typo="Typo.caption" Color="Color.Default">-</MudText>
}
</MudTd>
<MudTd>
@if (!string.IsNullOrWhiteSpace(context.LiveMessage))
{
<MudTooltip Text="@context.LiveDetails">
<MudText Typo="Typo.caption" Style="max-width:360px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block;">
@context.LiveMessage
</MudText>
</MudTooltip>
}
else
{
<MudText Typo="Typo.caption" Color="Color.Default">-</MudText>
}
</MudTd>
<MudTd>@(context.RowCount > 0 ? context.RowCount.ToString("N0") : "-")</MudTd>
<MudTd>@(context.LastRun.HasValue ? context.LastRun.Value.ToString("dd.MM.yyyy HH:mm:ss") : "-")</MudTd>
<MudTd>@(context.DurationSeconds > 0 ? $"{context.DurationSeconds:F1}s" : "-")</MudTd>
<MudTd>
<MudStack Row Spacing="1">
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.FileDownload"
OnClick="() => ExportSingle(context.SiteId)"
Disabled="Orchestrator.IsExporting(context.SiteId)">
Export
</MudButton>
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Info"
StartIcon="@Icons.Material.Filled.OpenInNew"
OnClick="() => OpenExportFile(context)"
Disabled="@(!context.HasOpenableFile || Orchestrator.IsExporting(context.SiteId))">
@T("Excel oeffnen", "Open Excel")
</MudButton>
</MudStack>
</MudTd>
</RowTemplate>
</MudTable>
<MudPaper Class="pa-4 mt-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-3">@T("Zentrale Datei", "Consolidated file")</MudText>
<MudTable Items="_consolidatedRows" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Datei", "File")</MudTh>
<MudTh>Pfad</MudTh>
<MudTh>Letzte Änderung</MudTh>
<MudTh>@T("Status", "Status")</MudTh>
<MudTh>@T("Aktion", "Action")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Label</MudTd>
<MudTd>@context.DisplayPath</MudTd>
<MudTd>@(context.LastModified.HasValue ? context.LastModified.Value.ToString("dd.MM.yyyy HH:mm:ss") : "-")</MudTd>
<MudTd>
@if (Orchestrator.IsConsolidatedExporting())
{
<MudProgressCircular Size="Size.Small" Indeterminate Color="Color.Primary" Class="mr-1" />
<MudText Typo="Typo.caption">@Orchestrator.GetConsolidatedExportStatus()</MudText>
}
else
{
<MudText Typo="Typo.caption" Color="Color.Default">-</MudText>
}
</MudTd>
<MudTd>
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Info"
StartIcon="@Icons.Material.Filled.OpenInNew"
OnClick="() => OpenFile(context.FilePath)"
Disabled="@(!context.HasOpenableFile)">
@T("Excel oeffnen", "Open Excel")
</MudButton>
</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText Typo="Typo.caption">@T("Keine zentrale Excel-Datei gefunden.", "No consolidated Excel file found.")</MudText>
</NoRecordsContent>
</MudTable>
</MudPaper>
<style>
.dashboard-header {
display: grid;
grid-template-columns: minmax(0, 1fr) 220px;
gap: 24px;
align-items: center;
}
.dashboard-actions {
min-width: 0;
flex-wrap: wrap;
}
.dashboard-manometer {
justify-self: end;
width: 210px;
height: 118px;
display: flex;
align-items: flex-end;
justify-content: center;
background: #fff;
border: 1px solid #111;
border-radius: 6px;
padding: 12px 14px 10px;
}
.manometer-arc {
position: relative;
width: 170px;
height: 86px;
border: 8px solid #111;
border-bottom: 0;
border-radius: 170px 170px 0 0;
background: #fff;
overflow: visible;
}
.manometer-arc::before {
content: "";
position: absolute;
left: 14px;
right: 14px;
bottom: -1px;
height: 70px;
border: 2px solid #111;
border-bottom: 0;
border-radius: 140px 140px 0 0;
}
.tick {
position: absolute;
left: 50%;
bottom: 0;
width: 3px;
height: 16px;
background: #111;
transform-origin: 50% 78px;
}
.tick-0 { transform: translateX(-50%) rotate(-70deg); }
.tick-1 { transform: translateX(-50%) rotate(-35deg); }
.tick-2 { transform: translateX(-50%) rotate(0deg); }
.tick-3 { transform: translateX(-50%) rotate(35deg); }
.tick-4 { transform: translateX(-50%) rotate(70deg); }
.needle {
position: absolute;
left: 50%;
bottom: 0;
width: 4px;
height: 72px;
background: #111;
border-radius: 4px;
transform-origin: 50% 100%;
animation: manometer-sweep 5.8s infinite cubic-bezier(.45, 0, .25, 1);
}
.hub {
position: absolute;
left: 50%;
bottom: -8px;
width: 22px;
height: 22px;
border-radius: 50%;
background: #111;
transform: translateX(-50%);
}
@@keyframes manometer-sweep {
0% { transform: translateX(-50%) rotate(-52deg); }
11% { transform: translateX(-50%) rotate(18deg); }
19% { transform: translateX(-50%) rotate(-8deg); }
33% { transform: translateX(-50%) rotate(63deg); }
48% { transform: translateX(-50%) rotate(4deg); }
61% { transform: translateX(-50%) rotate(38deg); }
74% { transform: translateX(-50%) rotate(-41deg); }
88% { transform: translateX(-50%) rotate(55deg); }
100% { transform: translateX(-50%) rotate(-52deg); }
}
@@media (max-width: 900px) {
.dashboard-header {
grid-template-columns: 1fr;
}
.dashboard-manometer {
justify-self: start;
}
}
</style>
@code {
private List<DashboardRow> _dashboardRows = new();
private List<ConsolidatedDashboardRow> _consolidatedRows = new();
private List<string> _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);
}
@@ -32,6 +32,21 @@
StartIcon="@Icons.Material.Filled.LockOpen" Disabled="@(!HrKpiAccess.IsConfigured)"> StartIcon="@Icons.Material.Filled.LockOpen" Disabled="@(!HrKpiAccess.IsConfigured)">
@T("HR KPI entsperren", "Unlock HR KPI") @T("HR KPI entsperren", "Unlock HR KPI")
</MudButton> </MudButton>
<MudDivider />
<MudExpansionPanels Elevation="0">
<MudExpansionPanel Text="@T("Passwort ändern", "Change password")" Icon="@Icons.Material.Filled.Password">
<MudStack Spacing="3" Class="pt-2">
<MudTextField @bind-Value="_changeUsername" Label="@T("Name", "Name")" Disabled="@(!HrKpiAccess.IsConfigured)" />
<MudTextField @bind-Value="_currentPassword" Label="@T("Aktuelles Passwort", "Current password")" InputType="InputType.Password" Disabled="@(!HrKpiAccess.IsConfigured)" />
<MudTextField @bind-Value="_newPassword" Label="@T("Neues Passwort", "New password")" InputType="InputType.Password" HelperText="@T("Mindestens 8 Zeichen.", "At least 8 characters.")" Disabled="@(!HrKpiAccess.IsConfigured)" />
<MudTextField @bind-Value="_newPasswordRepeat" Label="@T("Neues Passwort wiederholen", "Repeat new password")" InputType="InputType.Password" Disabled="@(!HrKpiAccess.IsConfigured)" />
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="ChangeHrPasswordAsync"
StartIcon="@Icons.Material.Filled.Save" Disabled="@(!HrKpiAccess.IsConfigured)">
@T("Passwort speichern", "Save password")
</MudButton>
</MudStack>
</MudExpansionPanel>
</MudExpansionPanels>
</MudStack> </MudStack>
</MudPaper> </MudPaper>
} }
@@ -175,6 +190,10 @@ else
private bool _managementView; private bool _managementView;
private string? _hrUsername; private string? _hrUsername;
private string? _hrPassword; private string? _hrPassword;
private string? _changeUsername;
private string? _currentPassword;
private string? _newPassword;
private string? _newPasswordRepeat;
private bool _loading; private bool _loading;
private HrKpiResult? _result; private HrKpiResult? _result;
private readonly List<(string Key, string Label)> _fluktuationOptions = private readonly List<(string Key, string Label)> _fluktuationOptions =
@@ -245,6 +264,33 @@ else
await LoadAsync(); 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() private void LockHrKpi()
{ {
HrKpiAccess.Lock(); HrKpiAccess.Lock();
@@ -85,8 +85,22 @@
<MudTd>@context.Land</MudTd> <MudTd>@context.Land</MudTd>
<MudTd>@context.TSC</MudTd> <MudTd>@context.TSC</MudTd>
<MudTd>@context.Schema</MudTd> <MudTd>@context.Schema</MudTd>
<MudTd>@context.SourceSystem</MudTd> <MudTd>
<MudTd>@GetConnectionTarget(context)</MudTd> <MudTooltip Text="@GetConnectionKindTooltip(context)">
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Color="@GetConnectionKindColor(context)"
Icon="@GetConnectionKindIcon(context)">
@context.SourceSystem
</MudChip>
</MudTooltip>
</MudTd>
<MudTd>
<MudStack Row AlignItems="AlignItems.Center" Spacing="1">
<MudTooltip Text="@GetConnectionKindTooltip(context)">
<MudIcon Icon="@GetConnectionKindIcon(context)" Color="@GetConnectionKindColor(context)" Size="Size.Small" />
</MudTooltip>
<span>@GetConnectionTarget(context)</span>
</MudStack>
</MudTd>
<MudTd> <MudTd>
@if (context.IsActive) @if (context.IsActive)
{ {
@@ -791,6 +805,39 @@
return GetServerNode(site.HanaServer); 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) private string GetEffectiveSapServiceUrl(Site site)
{ {
if (!string.IsNullOrWhiteSpace(site.SapServiceUrl)) if (!string.IsNullOrWhiteSpace(site.SapServiceUrl))
+3 -1
View File
@@ -41,7 +41,8 @@
.Trim('/') .Trim('/')
.ToLowerInvariant(); .ToLowerInvariant();
return path is "" or return path is
"export-dashboard" or
"management-cockpit" or "management-cockpit" or
"finance-cockpit/vergleich" or "finance-cockpit/vergleich" or
"finance-cockpit/schulung" or "finance-cockpit/schulung" or
@@ -49,6 +50,7 @@
"transformations" or "transformations" or
"finance-rules" or "finance-rules" or
"settings" or "settings" or
"admin/sessions" or
"logs" or "logs" or
"source-viewer"; "source-viewer";
} }
+4
View File
@@ -19,6 +19,7 @@ builder.Logging.AddFilter("Microsoft.EntityFrameworkCore.Database.Command", LogL
builder.Services.AddRazorComponents() builder.Services.AddRazorComponents()
.AddInteractiveServerComponents(); .AddInteractiveServerComponents();
builder.Services.AddCascadingAuthenticationState(); builder.Services.AddCascadingAuthenticationState();
builder.Services.AddHttpContextAccessor();
var securitySettings = builder.Configuration.GetSection(SecurityOptions.SectionName).Get<SecurityOptions>() ?? new SecurityOptions(); var securitySettings = builder.Configuration.GetSection(SecurityOptions.SectionName).Get<SecurityOptions>() ?? new SecurityOptions();
var useDevelopmentAuthentication = builder.Environment.IsDevelopment() && securitySettings.DevelopmentBypass; var useDevelopmentAuthentication = builder.Environment.IsDevelopment() && securitySettings.DevelopmentBypass;
@@ -47,6 +48,7 @@ builder.Services.AddHttpClient(nameof(ExchangeRateImportService));
builder.Services.Configure<HrKpiDataSourceOptions>(builder.Configuration.GetSection(HrKpiDataSourceOptions.SectionName)); builder.Services.Configure<HrKpiDataSourceOptions>(builder.Configuration.GetSection(HrKpiDataSourceOptions.SectionName));
builder.Services.Configure<HrKpiAccessOptions>(builder.Configuration.GetSection(HrKpiAccessOptions.SectionName)); builder.Services.Configure<HrKpiAccessOptions>(builder.Configuration.GetSection(HrKpiAccessOptions.SectionName));
builder.Services.Configure<FinanceCockpitAccessOptions>(builder.Configuration.GetSection(FinanceCockpitAccessOptions.SectionName)); builder.Services.Configure<FinanceCockpitAccessOptions>(builder.Configuration.GetSection(FinanceCockpitAccessOptions.SectionName));
builder.Services.Configure<AdminAccessOptions>(builder.Configuration.GetSection(AdminAccessOptions.SectionName));
builder.Services.AddDbContextFactory<AppDbContext>(options => builder.Services.AddDbContextFactory<AppDbContext>(options =>
options.UseSqlite("Data Source=trafag_exporter.db;Default Timeout=60")); options.UseSqlite("Data Source=trafag_exporter.db;Default Timeout=60"));
@@ -85,6 +87,7 @@ builder.Services.AddSingleton<IDatabaseSchemaMaintenanceService, DatabaseSchemaM
builder.Services.AddSingleton<IDatabaseSeedService, DatabaseSeedService>(); builder.Services.AddSingleton<IDatabaseSeedService, DatabaseSeedService>();
builder.Services.AddSingleton<IDatabaseInitializationService, DatabaseInitializationService>(); builder.Services.AddSingleton<IDatabaseInitializationService, DatabaseInitializationService>();
builder.Services.AddSingleton<IUiTextService, UiTextService>(); builder.Services.AddSingleton<IUiTextService, UiTextService>();
builder.Services.AddSingleton<IAccessSessionTracker, AccessSessionTracker>();
// Datenquellen-Adapter (Strategy per ConnectionKind). // Datenquellen-Adapter (Strategy per ConnectionKind).
builder.Services.AddSingleton<IDataSourceAdapter, HanaDataSourceAdapter>(); builder.Services.AddSingleton<IDataSourceAdapter, HanaDataSourceAdapter>();
@@ -109,6 +112,7 @@ builder.Services.AddScoped<ITransformationsPageService, TransformationsPageServi
builder.Services.AddScoped<IFinanceRulesPageService, FinanceRulesPageService>(); builder.Services.AddScoped<IFinanceRulesPageService, FinanceRulesPageService>();
builder.Services.AddScoped<IHrKpiAccessService, HrKpiAccessService>(); builder.Services.AddScoped<IHrKpiAccessService, HrKpiAccessService>();
builder.Services.AddScoped<IFinanceCockpitAccessService, FinanceCockpitAccessService>(); builder.Services.AddScoped<IFinanceCockpitAccessService, FinanceCockpitAccessService>();
builder.Services.AddScoped<IAdminAccessService, AdminAccessService>();
var app = builder.Build(); var app = builder.Build();
var pathBase = app.Configuration["ASPNETCORE_PATHBASE"]; var pathBase = app.Configuration["ASPNETCORE_PATHBASE"];
@@ -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;
}
@@ -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));
}
}
}
@@ -0,0 +1,54 @@
using System.Collections.Concurrent;
namespace TrafagSalesExporter.Services;
public interface IAccessSessionTracker
{
IReadOnlyList<AccessSessionSnapshot> 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<string, AccessSessionSnapshot> _sessions = new(StringComparer.OrdinalIgnoreCase);
public IReadOnlyList<AccessSessionSnapshot> 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);
@@ -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<AdminAccessOptions> 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);
}
}
@@ -11,16 +11,28 @@ public interface IFinanceCockpitAccessService
bool IsConfigured { get; } bool IsConfigured { get; }
bool IsUnlocked { get; } bool IsUnlocked { get; }
bool TryUnlock(string username, string password); bool TryUnlock(string username, string password);
bool TryChangePassword(string username, string currentPassword, string newPassword);
void Lock(); void Lock();
} }
public sealed class FinanceCockpitAccessService : IFinanceCockpitAccessService public sealed class FinanceCockpitAccessService : IFinanceCockpitAccessService, IDisposable
{ {
private readonly FinanceCockpitAccessOptions _options; 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<FinanceCockpitAccessOptions> options) public FinanceCockpitAccessService(
IOptions<FinanceCockpitAccessOptions> options,
IHostEnvironment environment,
IHttpContextAccessor httpContextAccessor,
IAccessSessionTracker sessionTracker)
{ {
_options = options.Value; _options = options.Value;
_environment = environment;
_httpContextAccessor = httpContextAccessor;
_sessionTracker = sessionTracker;
} }
public bool IsEnabled => _options.Enabled; public bool IsEnabled => _options.Enabled;
@@ -53,14 +65,48 @@ public sealed class FinanceCockpitAccessService : IFinanceCockpitAccessService
: FixedEquals(password, _options.Password); : FixedEquals(password, _options.Password);
IsUnlocked = valid; IsUnlocked = valid;
if (valid)
_sessionTracker.Register(_sessionId, "Finance Cockpit", username.Trim(), GetRemoteAddress());
return valid; 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) 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()); return FixedEquals(passwordHash, configuredHash.Trim());
} }
@@ -11,16 +11,28 @@ public interface IHrKpiAccessService
bool IsConfigured { get; } bool IsConfigured { get; }
bool IsUnlocked { get; } bool IsUnlocked { get; }
bool TryUnlock(string username, string password); bool TryUnlock(string username, string password);
bool TryChangePassword(string username, string currentPassword, string newPassword);
void Lock(); void Lock();
} }
public sealed class HrKpiAccessService : IHrKpiAccessService public sealed class HrKpiAccessService : IHrKpiAccessService, IDisposable
{ {
private readonly HrKpiAccessOptions _options; 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<HrKpiAccessOptions> options) public HrKpiAccessService(
IOptions<HrKpiAccessOptions> options,
IHostEnvironment environment,
IHttpContextAccessor httpContextAccessor,
IAccessSessionTracker sessionTracker)
{ {
_options = options.Value; _options = options.Value;
_environment = environment;
_httpContextAccessor = httpContextAccessor;
_sessionTracker = sessionTracker;
} }
public bool IsEnabled => _options.Enabled; public bool IsEnabled => _options.Enabled;
@@ -53,14 +65,48 @@ public sealed class HrKpiAccessService : IHrKpiAccessService
: FixedEquals(password, _options.Password); : FixedEquals(password, _options.Password);
IsUnlocked = valid; IsUnlocked = valid;
if (valid)
_sessionTracker.Register(_sessionId, "HR KPI", username.Trim(), GetRemoteAddress());
return valid; 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) 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()); return FixedEquals(passwordHash, configuredHash.Trim());
} }
@@ -26,6 +26,7 @@ public sealed class UiTextService : IUiTextService
["es"] = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) ["es"] = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{ {
["Trafag Finance/Sales Management Cockpit"] = "Trafag Cockpit de finanzas y ventas", ["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"] = "Cockpit financiero",
["Finance Cockpit ist geschuetzt. Bitte separat anmelden."] = "El cockpit financiero está protegido. Inicie sesión por separado.", ["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.", ["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", ["Finance Regeln"] = "Reglas financieras",
["Settings"] = "Configuración", ["Settings"] = "Configuración",
["Logs"] = "Registros", ["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", ["Finance sperren"] = "Bloquear finanzas",
["HR KPI (Login)"] = "KPI RR. HH. (login)", ["HR KPI (Login)"] = "KPI RR. HH. (login)",
["HR Dashboard"] = "Panel HR", ["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.", ["HR-KPI-Anmeldung fehlgeschlagen."] = "Error al iniciar sesión en HR KPI.",
["Name"] = "Nombre", ["Name"] = "Nombre",
["Passwort"] = "Contraseña", ["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 Cockpit entsperren"] = "Desbloquear cockpit financiero",
["Finance-Jahr"] = "Año financiero", ["Finance-Jahr"] = "Año financiero",
["Finance Summary laden"] = "Cargar resumen financiero", ["Finance Summary laden"] = "Cargar resumen financiero",
@@ -230,6 +256,7 @@ public sealed class UiTextService : IUiTextService
["it"] = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) ["it"] = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{ {
["Trafag Finance/Sales Management Cockpit"] = "Cockpit Trafag finanza e vendite", ["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"] = "Cockpit finance",
["Finance Cockpit ist geschuetzt. Bitte separat anmelden."] = "Il cockpit finance è protetto. Effettuare un accesso separato.", ["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.", ["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", ["Finance Regeln"] = "Regole finance",
["Settings"] = "Impostazioni", ["Settings"] = "Impostazioni",
["Logs"] = "Log", ["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", ["Finance sperren"] = "Blocca finance",
["HR KPI (Login)"] = "KPI HR (login)", ["HR KPI (Login)"] = "KPI HR (login)",
["HR Dashboard"] = "Dashboard HR", ["HR Dashboard"] = "Dashboard HR",
@@ -254,6 +288,24 @@ public sealed class UiTextService : IUiTextService
["HR-KPI-Anmeldung fehlgeschlagen."] = "Accesso a HR KPI non riuscito.", ["HR-KPI-Anmeldung fehlgeschlagen."] = "Accesso a HR KPI non riuscito.",
["Name"] = "Nome", ["Name"] = "Nome",
["Passwort"] = "Password", ["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 Cockpit entsperren"] = "Sblocca cockpit finance",
["Finance-Jahr"] = "Anno finance", ["Finance-Jahr"] = "Anno finance",
["Finance Summary laden"] = "Carica riepilogo finance", ["Finance Summary laden"] = "Carica riepilogo finance",
@@ -434,6 +486,7 @@ public sealed class UiTextService : IUiTextService
["hi"] = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) ["hi"] = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{ {
["Trafag Finance/Sales Management Cockpit"] = "Trafag वित्त और बिक्री प्रबंधन कॉकपिट", ["Trafag Finance/Sales Management Cockpit"] = "Trafag वित्त और बिक्री प्रबंधन कॉकपिट",
["Willkommen im Trafag Analyse Dashboard"] = "Trafag विश्लेषण डैशबोर्ड में आपका स्वागत है",
["Finance Cockpit"] = "वित्त कॉकपिट", ["Finance Cockpit"] = "वित्त कॉकपिट",
["Finance Cockpit ist geschuetzt. Bitte separat anmelden."] = "वित्त कॉकपिट सुरक्षित है. कृपया अलग से साइन इन करें.", ["Finance Cockpit ist geschuetzt. Bitte separat anmelden."] = "वित्त कॉकपिट सुरक्षित है. कृपया अलग से साइन इन करें.",
["Finance-Cockpit-Zugang ist noch nicht konfiguriert. Bitte Username und PasswordHash in FinanceCockpitAccess konfigurieren."] = "वित्त कॉकपिट एक्सेस अभी कॉन्फ़िगर नहीं है. कृपया FinanceCockpitAccess में Username और PasswordHash सेट करें.", ["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"] = "वित्त नियम", ["Finance Regeln"] = "वित्त नियम",
["Settings"] = "सेटिंग्स", ["Settings"] = "सेटिंग्स",
["Logs"] = "लॉग", ["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"] = "वित्त लॉक करें", ["Finance sperren"] = "वित्त लॉक करें",
["HR KPI (Login)"] = "HR KPI (लॉगिन)", ["HR KPI (Login)"] = "HR KPI (लॉगिन)",
["HR Dashboard"] = "HR डैशबोर्ड", ["HR Dashboard"] = "HR डैशबोर्ड",
@@ -458,6 +518,24 @@ public sealed class UiTextService : IUiTextService
["HR-KPI-Anmeldung fehlgeschlagen."] = "HR KPI साइन-इन विफल.", ["HR-KPI-Anmeldung fehlgeschlagen."] = "HR KPI साइन-इन विफल.",
["Name"] = "नाम", ["Name"] = "नाम",
["Passwort"] = "पासवर्ड", ["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 Cockpit entsperren"] = "वित्त कॉकपिट अनलॉक करें",
["Finance-Jahr"] = "वित्त वर्ष", ["Finance-Jahr"] = "वित्त वर्ष",
["Finance Summary laden"] = "वित्त सारांश लोड करें", ["Finance Summary laden"] = "वित्त सारांश लोड करें",
+5
View File
@@ -38,5 +38,10 @@
"Enabled": true, "Enabled": true,
"Username": "finance", "Username": "finance",
"PasswordHash": "1446F41A1BF8ABCF5DED217400CDC5D671F9E1B58753162A228F23FB7C844575" "PasswordHash": "1446F41A1BF8ABCF5DED217400CDC5D671F9E1B58753162A228F23FB7C844575"
},
"AdminAccess": {
"Enabled": true,
"Username": "admin",
"PasswordHash": "F0101E12FBCCDD6D2645B214B8732F5AEDFFB2DABBE7EE98043E68DB3BD9ADA4"
} }
} }
+36
View File
@@ -112,6 +112,42 @@ Serverbefund:
- Dadurch erreichen Requests weder `diag.txt` noch `BiDashboard.dll`. - 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`. - 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 ## Markdown-Doku und Anwenderdokus nachgezogen 2026-05-20
Geaendert: Geaendert: