Add backend-authoritative invoice draft editing (ADR 0006/0007) #1

Merged
Stefan merged 12 commits from feature/backend-authoritative-draft-editing into main 2026-07-16 19:46:55 +02:00
34 changed files with 2765 additions and 16 deletions
Showing only changes of commit 8a0ebeeb1e - Show all commits
+2 -2
View File
@@ -47,8 +47,8 @@
## Services & Dependency Injection
- Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces; inject them into `IntranetController` (constructor injection). Do **not** reintroduce static God-classes or pass the whole controller into helpers.
- `IComService` (email/SMS via ProcessWeb Mailer API, attachments sent inline as base64; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService` (MigraDoc render), `IInvoiceService`, `IReminderService`, `IReportService` (SQL report engine via `FuchsVisualization`), `IWidgetService`, `IBankingService`, `IMfrClientFactory`.
- Lifetimes: stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are singletons; request-scoped DB services (`IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IComService`) are scoped. Register in `Program.cs`.
- `IComService` (email/SMS via ProcessWeb Mailer API, attachments sent inline as base64; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService` (MigraDoc render), `IInvoiceService`, `IReminderService`, `IReportService` (SQL report engine via `FuchsVisualization`), `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4).
- Lifetimes: stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are singletons; request-scoped DB services (`IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IComService`, `ISystemStatusService`) are scoped. Register in `Program.cs`.
- `FdsInvoiceData` / `FdsReminderData` are **pure data holders** (parse + properties). Loading, persistence and PDF generation belong in the services — never `Task.Run(...).Wait()` sync-over-async.
- Data access stays SQL-first via OCORE helpers (`getSQLDataSet_async`, `setSQLValue_async`) + stored procedures; no EF Core.
+1 -1
View File
@@ -37,7 +37,7 @@
## Services & Dependency Injection
- Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces, injected into `IntranetController`. Do not reintroduce static God-classes or pass the controller into helpers.
- Services: `IComService` (ProcessWeb Mailer API; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`. Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`).
- Services: `IComService` (ProcessWeb Mailer API; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4). Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`).
- `FdsInvoiceData` / `FdsReminderData` are **pure data holders**; load/persist/render belongs in services. No `Task.Run(...).Wait()` sync-over-async.
- Data access is SQL-first via OCORE helpers + stored procedures (no EF Core).
+1 -1
View File
@@ -37,7 +37,7 @@
## Services & Dependency Injection
- Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces, injected into `IntranetController`. Do not reintroduce static God-classes or pass the controller into helpers.
- Services: `IComService` (ProcessWeb Mailer API; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`. Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`).
- Services: `IComService` (ProcessWeb Mailer API; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4). Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`).
- `FdsInvoiceData` / `FdsReminderData` are **pure data holders**; load/persist/render belongs in services. No `Task.Run(...).Wait()` sync-over-async.
- Data access is SQL-first via OCORE helpers + stored procedures (no EF Core).
+51 -2
View File
@@ -205,6 +205,54 @@ public class StartupSelfTestServiceTests
Assert.Equal(1, service.MailerCalls);
}
[Fact]
public async Task RunOnce_Disabled_PublishesNotRunReport()
{
var reporter = new StartupCheckReporter();
using var service = new TestableStartupSelfTestService(
new ServiceCollection().BuildServiceProvider(),
CreateConfiguration(),
Options.Create(CreateSettings(enabled: false)),
NullLogger<StartupSelfTestService>.Instance,
reporter);
await service.RunForTestAsync(CancellationToken.None);
Assert.NotNull(reporter.Latest);
Assert.False(reporter.Latest!.Ran);
}
[Fact]
public async Task RunOnce_Enabled_PublishesReportWithPerCheckResults()
{
var reporter = new StartupCheckReporter();
using var service = new TestableStartupSelfTestService(
new ServiceCollection().BuildServiceProvider(),
CreateConfiguration(),
Options.Create(CreateSettings(enabled: true, checkKeyVault: true, checkDatabase: true, checkMfr: false, sendStartupEmail: true, startupRecipient: "ops@example.test", checkPdfLicense: false)),
NullLogger<StartupSelfTestService>.Instance,
reporter)
{
KeyVaultResult = true,
DatabaseResult = false,
MailerResult = true,
};
await service.RunForTestAsync(CancellationToken.None);
var report = reporter.Latest;
Assert.NotNull(report);
Assert.True(report!.Ran);
Assert.NotNull(report.CompletedUtc);
var kv = Assert.Single(report.Items, i => i.Name == "KeyVault");
Assert.True(kv.Enabled && kv.Ok);
var db = Assert.Single(report.Items, i => i.Name == "Database");
Assert.True(db.Enabled);
Assert.False(db.Ok); // intentionally-failing path
var mfr = Assert.Single(report.Items, i => i.Name == "MFR");
Assert.False(mfr.Enabled); // disabled check reported as not-enabled
}
[Fact]
public async Task ExecuteAsync_ProbeThrows_ServiceDoesNotThrowAndContinuesRemainingChecks()
{
@@ -250,8 +298,9 @@ public class StartupSelfTestServiceTests
IServiceProvider serviceProvider,
IConfiguration configuration,
IOptions<StartupSelfTestSettings> settings,
Microsoft.Extensions.Logging.ILogger<StartupSelfTestService> logger)
: base(serviceProvider, configuration, settings, logger)
Microsoft.Extensions.Logging.ILogger<StartupSelfTestService> logger,
StartupCheckReporter? reporter = null)
: base(serviceProvider, configuration, settings, logger, reporter)
{
}
+465
View File
@@ -0,0 +1,465 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Metrics;
using System.Threading;
using System.Threading.Tasks;
using Fuchs.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Tests for the Admin module's read-only diagnostics service. Covers the passive config
/// snapshot (SQL server parsing, email override reflection, presence flags), each probe's
/// disabled/unconfigured/ok/error outcomes where deterministically reachable without live
/// infrastructure, the test-email pipeline (success/failure/validation + HTML-encoding +
/// override reporting), and the emitted probe telemetry.
/// </summary>
public class SystemStatusServiceTests
{
private const string FuchsMeterName = "Fuchs.Intranet";
private static IConfiguration Config(Dictionary<string, string?>? overrides = null)
{
var dict = new Dictionary<string, string?>
{
["ConnectionStrings:fuchs_fds_ConnectionString"] =
"Data Source=SQLHOST,1433;Initial Catalog=site_fuchs_test;User ID=fuchs_app;password='secret';",
["ConnectionStrings:AzureBlobStorage_ConnectionString"] = "DefaultEndpointsProtocol=https;AccountName=acct;AccountKey=key==;",
["Fuchs:IsTestDeployment"] = "true",
["Fds:MFR_host"] = "portal.mobilefieldreport.com",
["Fds:MFR_UserName"] = "mfruser",
["Fds:SyncEnabled"] = "true",
["SecretManagement:VaultUri"] = "https://vault.example/",
["SecretManagement:AppName"] = "fuchs",
["SecretManagement:ManagedSecretKeys:0"] = "Fuchs--Mailer--Token",
["SecretManagement:ManagedSecretKeys:1"] = "ConnectionStrings--fuchs-fds-password",
};
if (overrides != null)
foreach (var kv in overrides) dict[kv.Key] = kv.Value;
return new ConfigurationBuilder().AddInMemoryCollection(dict).Build();
}
private static SystemStatusService Build(
IConfiguration? config = null,
Mock<IBlobStorageService>? blob = null,
Mock<IMfrClientFactory>? mfr = null,
Mock<IComService>? com = null,
ProcessWebComSettings? mailer = null,
FuchsEmailSettings? email = null,
AzureBlobStorageSettings? blobSettings = null,
IServiceProvider? provider = null,
StartupCheckReporter? startupChecks = null)
{
var env = new Mock<IHostEnvironment>();
env.SetupGet(e => e.EnvironmentName).Returns("Development");
return new SystemStatusService(
config ?? Config(),
env.Object,
provider ?? new ServiceCollection().BuildServiceProvider(),
(blob ?? new Mock<IBlobStorageService>()).Object,
(mfr ?? new Mock<IMfrClientFactory>()).Object,
(com ?? new Mock<IComService>()).Object,
Options.Create(mailer ?? new ProcessWebComSettings { Enabled = true, Token = "tok", BaseUrl = "https://api.example", AccountId = "acc", ServerId = "srv" }),
Options.Create(email ?? new FuchsEmailSettings()),
Options.Create(blobSettings ?? new AzureBlobStorageSettings { Enabled = true }),
startupChecks ?? new StartupCheckReporter(),
NullLogger<SystemStatusService>.Instance);
}
// ── GetInfo (passive snapshot) ─────────────────────────────────────────────
[Fact]
public void GetInfo_ParsesConnectionStringAndReflectsConfiguration()
{
var svc = Build();
SystemInfoSnapshot info = svc.GetInfo();
Assert.Equal("Development", info.Environment);
Assert.True(info.IsTestDeployment);
Assert.True(info.Database.Configured);
Assert.Equal("SQLHOST,1433", info.Database.Server);
Assert.Equal("site_fuchs_test", info.Database.Catalog);
Assert.Equal("fuchs_app", info.Database.UserId); // login only, never the password
Assert.True(info.Mfr.CredentialsConfigured);
Assert.Equal("portal.mobilefieldreport.com", info.Mfr.Host);
Assert.True(info.Mfr.SyncEnabled);
Assert.Equal("fuchs", info.KeyVault.AppName);
Assert.Equal(2, info.KeyVault.ManagedSecretCount);
Assert.False(info.KeyVault.ClientRegistered); // empty provider
}
[Fact]
public void GetInfo_NeverLeaksDatabasePassword()
{
var svc = Build();
SystemInfoSnapshot info = svc.GetInfo();
Assert.DoesNotContain("secret", info.Database.UserId ?? "");
Assert.DoesNotContain("secret", info.Database.Server ?? "");
Assert.DoesNotContain("secret", info.Database.Catalog ?? "");
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void GetInfo_EmailOverride_ReflectedOnlyWhenConfigured(bool overrideSet)
{
var email = new FuchsEmailSettings { OverrideRecipient = overrideSet ? "safety@example.test" : "" };
var svc = Build(email: email);
SystemInfoSnapshot info = svc.GetInfo();
Assert.Equal(overrideSet, info.Email.OverrideActive);
Assert.Equal(overrideSet ? "safety@example.test" : null, info.Email.OverrideRecipient);
Assert.True(info.Email.MailerEnabled); // Build() default enables the mailer
Assert.True(info.Email.TokenConfigured);
}
[Fact]
public void GetInfo_StartupChecks_NullWhenServiceHasNotRun()
{
var svc = Build(startupChecks: new StartupCheckReporter()); // nothing set yet
Assert.Null(svc.GetInfo().StartupChecks);
}
[Fact]
public void GetInfo_StartupChecks_ReflectsReporterContents()
{
var reporter = new StartupCheckReporter();
reporter.Set(new StartupCheckReport
{
Ran = true,
CompletedUtc = DateTimeOffset.UtcNow,
MachineName = "BUILD-HOST",
Items =
[
new StartupCheckItem { Name = "Database", Enabled = true, Ok = true },
new StartupCheckItem { Name = "Mailer", Enabled = false, Ok = false },
],
});
var svc = Build(startupChecks: reporter);
var report = svc.GetInfo().StartupChecks;
Assert.NotNull(report);
Assert.True(report!.Ran);
Assert.Equal("BUILD-HOST", report.MachineName);
Assert.Equal(2, report.Items.Count);
Assert.Contains(report.Items, i => i.Name == "Database" && i.Enabled && i.Ok);
}
[Fact]
public void GetInfo_DatabaseUnconfigured_WhenConnectionStringMissing()
{
var config = Config(new() { ["ConnectionStrings:fuchs_fds_ConnectionString"] = "" });
var svc = Build(config);
Assert.False(svc.GetInfo().Database.Configured);
}
// ── Blob probe ─────────────────────────────────────────────────────────────
[Fact]
public async Task ProbeBlob_Disabled_ReturnsDisabled()
{
var blob = new Mock<IBlobStorageService>();
blob.Setup(b => b.CheckConnectivityAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new BlobConnectivity { Enabled = false });
var svc = Build(blob: blob);
var r = await svc.ProbeAsync("blob");
Assert.Equal("blob", r.Component);
Assert.Equal("disabled", r.Status);
Assert.False(r.Ok);
}
[Fact]
public async Task ProbeBlob_EnabledButUnconfigured_ReturnsUnconfigured()
{
var blob = new Mock<IBlobStorageService>();
blob.Setup(b => b.CheckConnectivityAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new BlobConnectivity { Enabled = true, Configured = false, Detail = "keine Verbindungszeichenfolge" });
var svc = Build(blob: blob);
var r = await svc.ProbeAsync("blob");
Assert.Equal("unconfigured", r.Status);
Assert.False(r.Ok);
}
[Fact]
public async Task ProbeBlob_Reachable_ReturnsOkWithAccountDetail()
{
var blob = new Mock<IBlobStorageService>();
blob.Setup(b => b.CheckConnectivityAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new BlobConnectivity { Enabled = true, Configured = true, Reachable = true, AccountName = "acct", Detail = "StorageV2 / Standard_LRS" });
var svc = Build(blob: blob);
var r = await svc.ProbeAsync("blob");
Assert.Equal("ok", r.Status);
Assert.True(r.Ok);
Assert.Contains("acct", r.Detail);
}
[Fact]
public async Task ProbeBlob_Reachable_ReportsFileCountsPerContainerAsMetrics()
{
var blob = new Mock<IBlobStorageService>();
blob.Setup(b => b.CheckConnectivityAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new BlobConnectivity
{
Enabled = true, Configured = true, Reachable = true, AccountName = "acct",
Detail = "StorageV2 / Standard_LRS",
Containers = new List<BlobContainerCount>
{
new() { Name = "dev-fuchs-invoices", Exists = true, FileCount = 42 },
new() { Name = "dev-fuchs-reminders", Exists = false, FileCount = 0 },
},
});
var svc = Build(blob: blob);
var r = await svc.ProbeAsync("blob");
Assert.Equal("ok", r.Status);
Assert.NotNull(r.Metrics);
Assert.Equal(2, r.Metrics!.Count);
var inv = Assert.Single(r.Metrics, m => m.Label == "dev-fuchs-invoices");
Assert.Equal("42 Dateien", inv.Value);
var rem = Assert.Single(r.Metrics, m => m.Label == "dev-fuchs-reminders");
Assert.Equal("nicht vorhanden", rem.Value);
Assert.Contains("42 Dateien gesamt", r.Message); // total across containers
}
[Fact]
public async Task ProbeBlob_FileCountUnavailable_ReportsMetricWithoutFailing()
{
var blob = new Mock<IBlobStorageService>();
blob.Setup(b => b.CheckConnectivityAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new BlobConnectivity
{
Enabled = true, Configured = true, Reachable = true, AccountName = "acct",
Containers = new List<BlobContainerCount>
{
new() { Name = "dev-fuchs-invoices", Exists = true, FileCount = -1 },
},
});
var svc = Build(blob: blob);
var r = await svc.ProbeAsync("blob");
Assert.Equal("ok", r.Status);
var inv = Assert.Single(r.Metrics!);
Assert.Equal("Anzahl nicht ermittelbar", inv.Value);
}
[Fact]
public async Task ProbeBlob_ConfiguredButUnreachable_ReturnsError()
{
var blob = new Mock<IBlobStorageService>();
blob.Setup(b => b.CheckConnectivityAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new BlobConnectivity { Enabled = true, Configured = true, Reachable = false, AccountName = "acct", Detail = "403 Forbidden" });
var svc = Build(blob: blob);
var r = await svc.ProbeAsync("blob");
Assert.Equal("error", r.Status);
Assert.False(r.Ok);
}
// ── Key Vault probe ────────────────────────────────────────────────────────
[Fact]
public async Task ProbeKeyVault_ClientNotRegistered_ReturnsUnconfigured()
{
var svc = Build(); // empty service provider → no SecretClient
var r = await svc.ProbeAsync("keyvault");
Assert.Equal("keyvault", r.Component);
Assert.Equal("unconfigured", r.Status);
Assert.False(r.Ok);
}
[Fact]
public async Task ProbeKeyVault_NoManagedKeys_ReturnsUnconfigured()
{
var config = Config(new()
{
["SecretManagement:ManagedSecretKeys:0"] = null,
["SecretManagement:ManagedSecretKeys:1"] = null,
});
var svc = Build(config);
var r = await svc.ProbeAsync("keyvault");
Assert.Equal("unconfigured", r.Status);
}
// ── MFR probe ──────────────────────────────────────────────────────────────
[Fact]
public async Task ProbeMfr_FactoryThrows_ReturnsError()
{
var mfr = new Mock<IMfrClientFactory>();
mfr.Setup(f => f.Create()).Throws(new InvalidOperationException("no credentials"));
var svc = Build(mfr: mfr);
var r = await svc.ProbeAsync("mfr");
Assert.Equal("mfr", r.Component);
Assert.Equal("error", r.Status);
Assert.False(r.Ok);
Assert.Equal("no credentials", r.Detail);
}
// ── Database probe (unconfigured is deterministic without a live server) ────
[Fact]
public async Task ProbeDatabase_NoConnectionString_ReturnsUnconfigured()
{
var config = Config(new() { ["ConnectionStrings:fuchs_fds_ConnectionString"] = "" });
var svc = Build(config);
var r = await svc.ProbeAsync("database");
Assert.Equal("database", r.Component);
Assert.Equal("unconfigured", r.Status);
Assert.False(r.Ok);
}
// ── Unknown component ──────────────────────────────────────────────────────
[Fact]
public async Task ProbeAsync_UnknownComponent_ReturnsError()
{
var svc = Build();
var r = await svc.ProbeAsync("does-not-exist");
Assert.Equal("error", r.Status);
Assert.False(r.Ok);
}
[Fact]
public async Task ProbeAllAsync_ReturnsOneResultPerComponent()
{
var blob = new Mock<IBlobStorageService>();
blob.Setup(b => b.CheckConnectivityAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new BlobConnectivity { Enabled = false });
var mfr = new Mock<IMfrClientFactory>();
mfr.Setup(f => f.Create()).Throws(new InvalidOperationException("x"));
var config = Config(new() { ["ConnectionStrings:fuchs_fds_ConnectionString"] = "" });
var svc = Build(config, blob: blob, mfr: mfr);
var results = await svc.ProbeAllAsync();
Assert.Equal(svc.ProbeComponents.Count, results.Count);
foreach (var comp in svc.ProbeComponents)
Assert.Contains(results, r => r.Component == comp);
Assert.All(results, r => Assert.True(r.DurationMs >= 0));
}
[Fact]
public async Task ProbeAsync_EmitsProbeTelemetry()
{
var blob = new Mock<IBlobStorageService>();
blob.Setup(b => b.CheckConnectivityAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new BlobConnectivity { Enabled = false });
var svc = Build(blob: blob);
long delta = 0;
using var listener = new MeterListener
{
InstrumentPublished = (inst, l) =>
{
if (inst.Meter.Name == FuchsMeterName && inst.Name == "fuchs.systemstatus.probes")
l.EnableMeasurementEvents(inst);
}
};
listener.SetMeasurementEventCallback<long>((_, value, _, _) => Interlocked.Add(ref delta, value));
listener.Start();
await svc.ProbeAsync("blob");
Assert.True(delta >= 1, "fuchs.systemstatus.probes counter should increment per probe.");
}
// ── Test email ─────────────────────────────────────────────────────────────
[Theory]
[InlineData("", "subj", "body")]
[InlineData("to@example.test", "", "body")]
public async Task SendTestEmailAsync_MissingRequiredFields_DoesNotSend(string to, string subject, string body)
{
var com = new Mock<IComService>();
var svc = Build(com: com);
var r = await svc.SendTestEmailAsync(to, subject, body);
Assert.False(r.Sent);
com.Verify(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>>()), Times.Never);
}
[Fact]
public async Task SendTestEmailAsync_Success_SendsHtmlEncodedBodyToRecipient()
{
string? capturedHtml = null, capturedTo = null, capturedSubject = null;
var com = new Mock<IComService>();
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>()))
.Callback<string, string, string, string, string, Dictionary<string, byte[]>?>(
(_, subj, html, to, _, _) => { capturedSubject = subj; capturedHtml = html; capturedTo = to; })
.ReturnsAsync(true);
var svc = Build(com: com);
var r = await svc.SendTestEmailAsync("dest@example.test", "Betreff", "Zeile1<script>\nZeile2");
Assert.True(r.Sent);
Assert.Equal("dest@example.test", capturedTo);
Assert.Equal("Betreff", capturedSubject);
Assert.NotNull(capturedHtml);
Assert.Contains("&lt;script&gt;", capturedHtml); // HTML-encoded, not injected
Assert.DoesNotContain("<script>", capturedHtml);
Assert.Contains("<br/>", capturedHtml); // newline → <br/>
Assert.Null(r.OverrideRecipient);
}
[Fact]
public async Task SendTestEmailAsync_OverrideActive_ReportsOverrideRecipient()
{
var com = new Mock<IComService>();
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>()))
.ReturnsAsync(true);
var svc = Build(com: com, email: new FuchsEmailSettings { OverrideRecipient = "safety@example.test" });
var r = await svc.SendTestEmailAsync("dest@example.test", "Betreff", "Body");
Assert.True(r.Sent);
Assert.Equal("dest@example.test", r.RequestedRecipient);
Assert.Equal("safety@example.test", r.OverrideRecipient);
Assert.Contains("safety@example.test", r.Message);
}
[Fact]
public async Task SendTestEmailAsync_MailerRejects_ReturnsNotSent()
{
var com = new Mock<IComService>();
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>()))
.ReturnsAsync(false);
var svc = Build(com: com);
var r = await svc.SendTestEmailAsync("dest@example.test", "Betreff", "Body");
Assert.False(r.Sent);
Assert.Equal("dest@example.test", r.RequestedRecipient);
}
}
@@ -0,0 +1,105 @@
using Fuchs.Services;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
using OCORE.SQL;
using static OCORE.SQL.sql;
using static OCORE.web.mvc_helper_async;
namespace Fuchs.Controllers;
// Partial class: Admin / system-status module.
//
// Access is restricted to users whose "fds_sys" module authorization is greater than 4.
// The menu button is only shown, and the module script only loaded, for such users (frontend),
// but every data endpoint here ALSO enforces the level server-side (defense in depth) — the
// passive/probe data is diagnostic and must never be reachable by a lower-privileged session.
public partial class IntranetController
{
// fds_sys authorization must exceed this to use the Admin module.
private const int AdminMinAuthExclusive = 4;
private async Task<IActionResult> Do_Process_Admin(string fn, string id, string code)
{
_logger.LogDebug("Do_Process_Admin action={Action} code={Code} user={User}", id, code, UserAccountID);
// The auth probe is the one endpoint that answers for BOTH authorized and unauthorized
// users (the frontend uses manage>0 to decide whether to render the module at all).
int authLevel = await GetSystemAdminAuthAsync(fn, id, code);
bool authorized = authLevel > AdminMinAuthExclusive;
if (id.Equals("auth", StringComparison.OrdinalIgnoreCase))
return await JSONAsync(new { manage = authorized ? 1 : 0, level = authLevel });
if (!authorized)
{
_logger.LogWarning("Admin access denied for user={User} (fds_sys={Level}) action={Action}",
UserAccountID, authLevel, id);
return Unauthorized401();
}
var status = _systemStatus;
switch (id.ToLowerInvariant())
{
case "status":
{
var info = status.GetInfo();
var probes = await status.ProbeAllAsync(HttpContext.RequestAborted);
return AdminJson(new { info, probes });
}
case "info":
return AdminJson(new { info = status.GetInfo() });
case "probe":
{
// code carries the component id, e.g. /do/admin/probe/database
string component = string.IsNullOrWhiteSpace(code) ? Form("component") : code;
if (string.IsNullOrWhiteSpace(component))
return BadRequest400();
var probe = await status.ProbeAsync(component, HttpContext.RequestAborted);
return AdminJson(new { probe });
}
case "testmail":
{
if (!HasForm("to", "subject"))
return BadRequest400();
var result = await status.SendTestEmailAsync(
Form("to"), Form("subject"), Form("body"), HttpContext.RequestAborted);
_logger.LogInformation("Admin test email requested by user={User} to={To} sent={Sent}",
UserAccountID, result.RequestedRecipient, result.Sent);
return AdminJson(new { result });
}
default:
_logger.LogWarning("Admin: no handler for action={Action}, user={User}", id, UserAccountID);
return BadRequest400();
}
}
// The status DTOs (SystemInfoSnapshot / SystemProbeResult) are PascalCase; serialize them
// camelCase so the Admin frontend contract matches the lowercase convention used elsewhere.
private static readonly JsonSerializerSettings CamelCaseJson = new()
{
ContractResolver = new CamelCasePropertyNamesContractResolver(),
};
private ContentResult AdminJson(object payload) =>
Content(JsonConvert.SerializeObject(payload, CamelCaseJson), "application/json");
/// <summary>
/// Resolves the calling user's <c>fds_sys</c> module authorization level via the
/// <c>fis_getModuleAuth</c> SQL function (same mechanism as <see cref="HandleAuth"/>).
/// Returns -3 when it cannot be determined (fail-closed).
/// </summary>
private async Task<int> GetSystemAdminAuthAsync(string fn, string id, string code)
{
var val = await getSQLValue_async<int>(
"SELECT [dbo].[fis_getModuleAuth](@module, @authuser);",
_intranet.Intranet__SQLConnectionString, -3,
StdParamlist(SQL_VarChar("@module", "fds_sys")),
Security: DbSec, options: SqlOpt(fn, id, code));
return val.Result;
}
}
+5 -1
View File
@@ -38,6 +38,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
private readonly IInvoiceDraftService _invoiceDrafts;
private readonly IReminderDraftService _reminderDrafts;
private readonly IDraftNotifier _draftNotifier;
private readonly ISystemStatusService _systemStatus;
private readonly List<string> _allowedNonAuth = new() { "spwc", "spw" };
private readonly List<string> _allowedGet = new()
{
@@ -68,7 +69,8 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
IEventService events,
IInvoiceDraftService invoiceDrafts,
IReminderDraftService reminderDrafts,
IDraftNotifier draftNotifier)
IDraftNotifier draftNotifier,
ISystemStatusService systemStatus)
{
_intranet = intranet;
_mfr = mfr;
@@ -85,6 +87,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
_invoiceDrafts = invoiceDrafts;
_reminderDrafts = reminderDrafts;
_draftNotifier = draftNotifier;
_systemStatus = systemStatus;
}
/// <summary>Merged query-string + form parameters (form wins) for report processing.</summary>
@@ -163,6 +166,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
"rem" => await Do_Process_Reminder(fn, id, code),
"rep" => await Do_Process_Reports(fn, id, code),
"bam" => await Do_Process_Bankings(fn, id, code),
"admin" => await Do_Process_Admin(fn, id, code),
"auth" => await HandleAuth(fn, id, code),
"spwc" => await HandleSendPasswordCode(fn, id, code),
"spw" => await HandleSendPassword(fn, id, code),
+2 -1
View File
@@ -168,8 +168,9 @@ OCORE_Charting (standalone — referenced by solution but no direct project ref
### 4.3 Service Layer (Dependency Injection)
Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces, injected into `IntranetController`:
`IComService`, `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`.
`IComService`, `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService`.
Stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are singletons; DB/request-scoped services are scoped (see `Program.cs`).
The **Admin** module (`Do_Process_Admin`, `ISystemStatusService`) surfaces a live system-status/diagnostics page (host, SQL/Key Vault/blob/MFR connectivity, email config, test-email) restricted to `fds_sys` > 4 — see ADR [0011](Decisions/0011-admin-module-system-status.md) and the [concept doc](Concepts/admin-system-status.md).
`FdsInvoiceData` / `FdsReminderData` are now **pure data holders** (parse + properties); loading, persistence and PDF generation live in the services (fully async — no `Task.Run(...).Wait()`).
`FuchsPdf` / `FuchsVisualization` remain as static rendering libraries used *by* the services. The earlier static, controller-coupled helpers (`FuchsWidgets`, `FuchsReports`, `Banking`, `FuchsFdsEmail`) have been removed.
@@ -0,0 +1,97 @@
---
status: Active
lastUpdated: 2026-07-16
applyTo:
- "Fuchs/Controllers/IntranetController.Admin.cs"
- "Fuchs/Services/SystemStatusService.cs"
- "Fuchs/Services/ISystemStatusService.cs"
- "Fuchs/Services/SystemStatusModels.cs"
- "Fuchs/js/intranet/modules/fis.admin*.js"
- "Fuchs/js/intranet/modules/fis.admin.scss"
- "Fuchs/js/intranet/fis_main_menu.js"
relatedDecisions:
- "0011-admin-module-system-status.md"
---
# Admin / System-Status module
## Summary
The **Admin** module gives a privileged operator a live, in-app view of the running
deployment's configuration and health, plus a test-email tool. It answers "which host am I
on, can this instance reach SQL Server / Key Vault / Blob Storage / the MFR ERP, how is
email wired up (including the OverrideRecipient redirect), and does sending actually work".
Access is restricted to users whose `fds_sys` module authorization is greater than 4.
## How it works
### Authorization (two layers)
1. **Menu visibility** — at page load `fis_main_menu.js#addAdminMenuIfAuthorized` calls
`$fis.getAuth('fds_sys')`; only when the level is > 4 does it push the `init:admin`
button into `$ocms.ocmsmenu` and re-render `#mainmenu`. Users below the threshold never
see the button and never fetch the module script.
2. **Server-side gate** — every `/do/admin/*` endpoint resolves
`fis_getModuleAuth('fds_sys', authuser)` and returns 401 unless it is > 4. The only
exception is `admin/auth`, which returns `{ manage: 0 }` for unauthorized users so the
frontend can cleanly decline to render. This is defense in depth: hiding the button is
not a security control on its own.
### Request flow
```
click "Administration" → $ocms.init('admin')
POST /do/admin/auth → { manage, level } (manage>0 ⇒ authorized)
load /web/fis.admin.de.js + /web/fis.admin.css
$ocms.admin.init2() → init3() renders the page
POST /do/admin/status → { info, probes } full snapshot + all probes
per-card "Aktualisieren" → POST /do/admin/probe/<component> → { probe }
"Test-E-Mail senden" → POST /do/admin/testmail (to/subject/body) → { result }
```
### Backend (`ISystemStatusService` / `SystemStatusService`, scoped)
- `GetInfo()` — passive snapshot, no network calls: host (machine/OS/framework/environment/
test-deployment/uptime), database (server/catalog/login parsed from the connection string —
**never the password**), email (mailer enabled/base-url/account/server-id/token-present +
OverrideRecipient), blob (enabled/configured/containers), MFR (host/creds-present/sync),
Key Vault (vault-uri/app-prefix/managed-secret-count/client-registered).
- Probes (`database`, `keyvault`, `blob`, `mfr`) each return a `SystemProbeResult`
(`status` = `ok`/`error`/`disabled`/`unconfigured`, `ok`, `message`, `detail`, `durationMs`,
optional `metrics`). They never throw — failures are captured in the result. `database` runs
`SELECT @@SERVERNAME, DB_NAME(), SUSER_SNAME()`; `keyvault` reads the first managed secret via
the DI `SecretClient`; `blob` calls `IBlobStorageService.CheckConnectivityAsync` (account-info
request **plus a per-container blob count** for the invoice/reminder containers, surfaced as
`metrics` and totalled in the message); `mfr` calls `IMfrClientFactory.Create().GetEntities()`.
The generic `metrics` (label/value pairs) is how a probe reports extra facts for display — the
blob probe uses it for the file counts.
- `SendTestEmailAsync(to, subject, body)` HTML-encodes the body and sends via `IComService`, so
the `Fuchs:Email:OverrideRecipient` redirect applies exactly as for any other mail; the result
reports the requested recipient and, when active, the override target.
- Every probe emits the `fuchs.systemstatus.probes` counter tagged by component + status and an
`systemstatus.probe` activity span.
### Startup-checks widget (non-refreshable)
`StartupSelfTestService` runs the boot self-test once (Key Vault / Database / MFR / PDF-license /
mailer, gated by `Fuchs:StartupChecks:*`). It now also writes its outcome to the singleton
`StartupCheckReporter`, which `GetInfo()` returns as `StartupChecks`. The Admin page renders it as a
single **non-refreshable** card (there is no per-check retry — the live connectivity probes above
cover on-demand re-testing; the startup card is a historical record of the boot run). When the
self-test is disabled (`Enabled=false`, the appsettings default) or has not completed, the card shows
a "nicht ausgeführt" note. Each item shows OK / Fehler / übersprungen (a disabled check reports
`enabled=false`).
### Frontend (`fis.admin.js` + `fis.admin_txt_de.js` + `fis.admin.scss`)
Standard lazy-loaded module (same contract as `inv`/`rep`/`bam`). Renders a system card plus a
responsive grid of status cards; connectivity cards carry a colored status pill and a per-card
refresh button, the email card carries the test-email dialog. The topbar has an "Alle prüfen"
button that reloads the whole snapshot. Admin responses are serialized **camelCase** (the DTOs are
PascalCase) so the JS reads `probe.status`, `info.database.server`, etc.
## Key files
- `Fuchs/Controllers/IntranetController.Admin.cs``Do_Process_Admin` dispatch + auth gate + camelCase JSON helper.
- `Fuchs/Services/ISystemStatusService.cs`, `SystemStatusService.cs`, `SystemStatusModels.cs` — diagnostics service + DTOs.
- `Fuchs/Services/StartupCheckReport.cs` (`StartupCheckReporter` singleton) + `StartupSelfTestService.cs` — boot self-test result captured for the non-refreshable widget.
- `Fuchs/Services/IBlobStorageService.cs` / `AzureBlobStorageService.cs``CheckConnectivityAsync` + `BlobConnectivity`.
- `Fuchs/js/intranet/modules/fis.admin*.js`, `fis.admin.scss` — the module, texts, styles (bundled via `bdlconfig.json`).
- `Fuchs/js/intranet/fis_main_menu.js` (`addAdminMenuIfAuthorized`) + `fis_main_go.js` — conditional menu button.
- `Fuchs.Tests/SystemStatusServiceTests.cs` — service tests.
## Related decisions
- [0011 — Admin module gated on `fds_sys` > 4](../Decisions/0011-admin-module-system-status.md)
@@ -0,0 +1,67 @@
---
status: Accepted
date: 2026-07-16
applyTo:
- "Fuchs/Controllers/IntranetController.Admin.cs"
- "Fuchs/Services/SystemStatusService.cs"
- "Fuchs/Services/ISystemStatusService.cs"
- "Fuchs/Services/SystemStatusModels.cs"
- "Fuchs/js/intranet/modules/fis.admin*.js"
- "Fuchs/js/intranet/modules/fis.admin.scss"
supersededBy: ""
---
# 0011 — Admin module gated on `fds_sys` > 4
## Context
Operators needed an in-app view of the running deployment's health: which host it
runs on, whether SQL Server / Azure Key Vault / Azure Blob Storage / the MFR ERP are
reachable, how the email service is configured (including the dev/test
`Fuchs:Email:OverrideRecipient` redirect), and a way to send a test email. The
`StartupSelfTestService` already probes most of these once at startup and writes the
result to the log — but that is invisible to a logged-in operator and cannot be re-run
on demand. This is privileged, infrastructure-revealing information (server names,
account names, connectivity state) that must not be exposed to ordinary users.
## Decision
- Add an **Admin** module (`admin`) alongside the existing invoice/reminder/report/banking
modules, following the same frontend module contract (`init:admin``admin/auth`
returns `{ manage }` → lazy-load `/web/fis.admin.de.js` + `/web/fis.admin.css`
`init2()`).
- Access is gated on the caller's **`fds_sys` module authorization being strictly greater
than 4** (`fis_getModuleAuth('fds_sys', authuser) > 4`). The menu button is only rendered,
and the module script only fetched, for such users; **every** Admin data endpoint
additionally re-checks the level server-side (defense in depth) and returns 401 otherwise.
`admin/auth` is the sole endpoint that answers for unauthorized users too (it returns
`manage: 0`), so the frontend can decide whether to render the module at all.
- Backend diagnostics live in a new DI-registered `ISystemStatusService` (scoped). It exposes
a passive `GetInfo()` snapshot and live, never-throwing connectivity probes
(`database`, `keyvault`, `blob`, `mfr`) plus `SendTestEmailAsync`. The test email goes
through the normal `IComService` pipeline, so the `OverrideRecipient` safety net applies
exactly as for any other outbound mail.
- The service never returns secrets — only presence flags and non-sensitive values
(server/catalog/login name, storage account name, error text). Responses are serialized
camelCase to match the frontend's lowercase convention.
## Consequences
- `fds_sys` is now a security-relevant authorization key: granting it a value > 4 exposes
infrastructure status and the ability to send test emails. Provision it deliberately.
- `SystemStatusService` is intentionally **separate** from `StartupSelfTestService` rather
than a shared refactor: the startup service's probe methods are `protected virtual` and its
tests override them, so folding both onto one probe surface would have broken that contract.
The two therefore duplicate a little probe logic (Key Vault secret read, MFR `GetEntities`,
SQL `SELECT`); keep them behaviourally aligned when either changes.
- New probes (or new status facts) belong in `SystemStatusService` behind the same
`SystemProbeResult` / `SystemInfoSnapshot` shapes; add the component id to
`SystemStatusService.Components` and a card in `fis.admin.js`.
- Blob connectivity is checked via a new `IBlobStorageService.CheckConnectivityAsync`
(account-info request); it too never throws.
## Alternatives considered
- **Reuse `StartupSelfTestService` directly.** Rejected — see Consequences; its virtual/overridden
probe surface is owned by its tests, and it is a one-shot `BackgroundService`, not a
request-scoped query service.
- **Gate only in the frontend (hide the menu button).** Rejected — the endpoints would still be
reachable by crafting the POST. Server-side enforcement on every endpoint is required.
- **A separate `fds_admin`/new module-auth key.** Rejected — `fds_sys` already models
system-level privilege; reusing it avoids a parallel permission to provision.
+3
View File
@@ -51,6 +51,9 @@ public static class FuchsTelemetry
Meter.CreateCounter<long>("fuchs.blobstorage.uploads", "{upload}", "Number of documents successfully archived to Azure Blob Storage.");
public static readonly Counter<long> BlobUploadsFailed =
Meter.CreateCounter<long>("fuchs.blobstorage.uploads.failed", "{upload}", "Number of documents that failed to archive to Azure Blob Storage.");
public static readonly Counter<long> SystemProbes =
Meter.CreateCounter<long>("fuchs.systemstatus.probes", "{probe}",
"Admin system-status connectivity probes executed, tagged by component and outcome.");
// ── Performance histograms (durations in milliseconds) ───────────────────
public static readonly Histogram<double> PdfRenderDuration =
+7
View File
@@ -123,6 +123,9 @@ public class Program
builder.Services.Configure<StartupSelfTestSettings>(builder.Configuration.GetSection("Fuchs:StartupChecks"));
builder.Services.AddHttpClient("ProcessWebMailer");
builder.Services.AddScoped<IComService, ProcessWebComService>();
// Holds the one-shot startup self-test result for the lifetime of the process so the Admin
// module can display it as a non-refreshable widget (must be registered before the service).
builder.Services.AddSingleton<StartupCheckReporter>();
builder.Services.AddHostedService<StartupSelfTestService>();
// Business services (DI migration — replaces the static helper / Active-Record pattern)
@@ -135,6 +138,10 @@ public class Program
builder.Services.AddScoped<IReminderService, ReminderService>();
builder.Services.AddScoped<IEventService, EventService>();
// Read-only system diagnostics for the Admin module (config snapshot + connectivity probes
// + test-email). Restricted to fds_sys > 4 in IntranetController.Admin; see the concept doc.
builder.Services.AddScoped<ISystemStatusService, SystemStatusService>();
// Live, backend-authoritative invoice draft editing (ADR 0006): an in-memory
// draft cache (singleton), the scoped edit orchestrator, a targeted SignalR
// notifier over the dedicated DraftPreviewHub, and the idle-expiry monitor.
+81
View File
@@ -89,6 +89,87 @@ public class AzureBlobStorageService : IBlobStorageService
}
}
public async Task<BlobConnectivity> CheckConnectivityAsync(CancellationToken cancellationToken = default)
{
if (!_settings.Enabled)
return new BlobConnectivity { Enabled = false, Configured = _client != null };
if (_client == null)
return new BlobConnectivity
{
Enabled = true,
Configured = false,
Detail = "ConnectionStrings:AzureBlobStorage_ConnectionString ist nicht konfiguriert.",
};
using var act = FuchsTelemetry.StartActivity("blobstorage.connectivity");
try
{
AccountInfo info = await _client.GetAccountInfoAsync(cancellationToken);
var containers = await CountContainersAsync(cancellationToken);
return new BlobConnectivity
{
Enabled = true,
Configured = true,
Reachable = true,
AccountName = _client.AccountName,
Detail = $"{info.AccountKind} / {info.SkuName}",
Containers = containers,
};
}
catch (Exception ex)
{
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
_logger.LogWarning(ex, "Blob connectivity check failed for account {Account}.", _client.AccountName);
return new BlobConnectivity
{
Enabled = true,
Configured = true,
Reachable = false,
AccountName = _client.AccountName,
Detail = ex.Message,
};
}
}
/// <summary>
/// Counts the blobs in each distinct configured container (invoice + reminder). A container
/// that does not exist yet reports <see cref="BlobContainerCount.Exists"/> = false / count 0;
/// a container whose listing throws reports count -1. Never throws.
/// </summary>
private async Task<List<BlobContainerCount>> CountContainersAsync(CancellationToken cancellationToken)
{
var result = new List<BlobContainerCount>();
if (_client == null) return result;
var names = new[] { _settings.InvoiceContainer, _settings.ReminderContainer }
.Where(n => !string.IsNullOrWhiteSpace(n))
.Distinct(StringComparer.OrdinalIgnoreCase);
foreach (var name in names)
{
try
{
var container = _client.GetBlobContainerClient(name);
bool exists = await container.ExistsAsync(cancellationToken);
int count = 0;
if (exists)
{
await foreach (var _ in container.GetBlobsAsync(cancellationToken: cancellationToken))
count++;
}
result.Add(new BlobContainerCount { Name = name, Exists = exists, FileCount = count });
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Blob file count failed for container {Container}.", name);
result.Add(new BlobContainerCount { Name = name, Exists = false, FileCount = -1 });
}
}
return result;
}
private static string BuildBlobName(string documentId, string fileName) =>
$"{documentId}/{(string.IsNullOrWhiteSpace(fileName) ? $"{documentId}.pdf" : fileName)}";
+45
View File
@@ -44,4 +44,49 @@ public interface IBlobStorageService
/// </summary>
Task<bool> ExistsAsync(string containerName, string documentId, string fileName,
CancellationToken cancellationToken = default);
/// <summary>
/// Verifies connectivity and access to the configured storage account by requesting its
/// account information. Read-only, never mutates, and never throws — any failure is reported
/// via <see cref="BlobConnectivity.Reachable"/>/<see cref="BlobConnectivity.Detail"/>. Used by
/// the Admin module's system-status view.
/// </summary>
Task<BlobConnectivity> CheckConnectivityAsync(CancellationToken cancellationToken = default);
}
/// <summary>Result of <see cref="IBlobStorageService.CheckConnectivityAsync"/> — no secrets.</summary>
public sealed class BlobConnectivity
{
/// <summary><c>Fuchs:AzureStorage:Enabled</c>.</summary>
public bool Enabled { get; init; }
/// <summary>A storage client could be constructed (connection string present + valid form).</summary>
public bool Configured { get; init; }
/// <summary>The account responded to an authenticated request.</summary>
public bool Reachable { get; init; }
/// <summary>Storage account name (never the key/connection string).</summary>
public string? AccountName { get; init; }
/// <summary>Non-sensitive detail: account kind/SKU on success, error summary on failure.</summary>
public string? Detail { get; init; }
/// <summary>
/// Per-configured-container blob (file) counts, populated when the account was reachable.
/// <c>null</c> when connectivity failed.
/// </summary>
public IReadOnlyList<BlobContainerCount>? Containers { get; init; }
}
/// <summary>Blob (file) count for one configured container.</summary>
public sealed class BlobContainerCount
{
public string Name { get; init; } = "";
/// <summary><c>false</c> when the container does not exist yet (created lazily on first upload).</summary>
public bool Exists { get; init; }
/// <summary>Number of blobs in the container; <c>-1</c> when the count could not be determined.</summary>
public int FileCount { get; init; }
}
+28
View File
@@ -0,0 +1,28 @@
namespace Fuchs.Services;
/// <summary>
/// Gathers system configuration/environment facts and runs live connectivity/access probes
/// for the Admin module. Read-only diagnostics: probes never mutate state and never throw
/// (failures are captured in the returned <see cref="SystemProbeResult"/>).
/// </summary>
public interface ISystemStatusService
{
/// <summary>Passive snapshot of hosting, database, email, blob, MFR and Key Vault configuration.</summary>
SystemInfoSnapshot GetInfo();
/// <summary>The set of probe component ids this service knows how to run.</summary>
IReadOnlyList<string> ProbeComponents { get; }
/// <summary>Runs a single probe by component id (<c>database</c>/<c>keyvault</c>/<c>blob</c>/<c>mfr</c>).</summary>
Task<SystemProbeResult> ProbeAsync(string component, CancellationToken cancellationToken = default);
/// <summary>Runs every known probe (in parallel) and returns the results.</summary>
Task<IReadOnlyList<SystemProbeResult>> ProbeAllAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Sends a test email through the normal <see cref="IComService"/> pipeline, so the
/// dev/test recipient override (<see cref="FuchsEmailSettings.OverrideRecipient"/>) applies
/// exactly as for any other outbound email.
/// </summary>
Task<EmailTestResult> SendTestEmailAsync(string to, string subject, string body, CancellationToken cancellationToken = default);
}
+46
View File
@@ -0,0 +1,46 @@
namespace Fuchs.Services;
/// <summary>
/// Process-lifetime holder for the result of the one-shot <see cref="StartupSelfTestService"/> run.
/// Registered as a singleton: the startup service writes the report once at boot, and the Admin
/// module reads it back (via <see cref="ISystemStatusService.GetInfo"/>) as a non-refreshable
/// widget — the checks are inherently a startup event and are never re-run per request.
/// </summary>
public sealed class StartupCheckReporter
{
private volatile StartupCheckReport? _report;
/// <summary>The latest (and only) startup report, or <c>null</c> if the service has not run yet.</summary>
public StartupCheckReport? Latest => _report;
public void Set(StartupCheckReport report) => _report = report;
}
/// <summary>Snapshot of a completed startup self-test run. Serialized to the Admin frontend.</summary>
public sealed class StartupCheckReport
{
/// <summary><c>false</c> when <c>Fuchs:StartupChecks:Enabled</c> was off, so nothing was probed.</summary>
public bool Ran { get; init; }
/// <summary>When the run finished (UTC); <c>null</c> when it did not run.</summary>
public DateTimeOffset? CompletedUtc { get; init; }
/// <summary>The machine the startup run executed on.</summary>
public string MachineName { get; init; } = "";
/// <summary>Per-check outcomes (only meaningful for enabled checks).</summary>
public IReadOnlyList<StartupCheckItem> Items { get; init; } = [];
}
/// <summary>One startup check's configured/enabled state and its boot-time result.</summary>
public sealed class StartupCheckItem
{
/// <summary>Stable key: <c>KeyVault</c> | <c>Database</c> | <c>MFR</c> | <c>PdfLicense</c> | <c>Mailer</c>.</summary>
public string Name { get; init; } = "";
/// <summary>Whether this check was enabled (and therefore actually ran).</summary>
public bool Enabled { get; init; }
/// <summary>Result of the check; only meaningful when <see cref="Enabled"/> is true.</summary>
public bool Ok { get; init; }
}
+20 -1
View File
@@ -20,17 +20,20 @@ public class StartupSelfTestService : BackgroundService
private readonly IConfiguration _configuration;
private readonly StartupSelfTestSettings _settings;
private readonly ILogger<StartupSelfTestService> _logger;
private readonly StartupCheckReporter? _reporter;
public StartupSelfTestService(
IServiceProvider serviceProvider,
IConfiguration configuration,
IOptions<StartupSelfTestSettings> settings,
ILogger<StartupSelfTestService> logger)
ILogger<StartupSelfTestService> logger,
StartupCheckReporter? reporter = null)
{
_serviceProvider = serviceProvider;
_configuration = configuration;
_settings = settings.Value;
_logger = logger;
_reporter = reporter;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -41,6 +44,7 @@ public class StartupSelfTestService : BackgroundService
if (!_settings.Enabled)
{
_logger.LogDebug("StartupSelfTestService skipped - Fuchs:StartupChecks:Enabled is false.");
_reporter?.Set(new StartupCheckReport { Ran = false, MachineName = Environment.MachineName });
return;
}
@@ -124,6 +128,21 @@ public class StartupSelfTestService : BackgroundService
mfrOk,
mailerOk,
pdfLicenseOk);
_reporter?.Set(new StartupCheckReport
{
Ran = true,
CompletedUtc = DateTimeOffset.UtcNow,
MachineName = Environment.MachineName,
Items =
[
new StartupCheckItem { Name = "KeyVault", Enabled = _settings.CheckKeyVault, Ok = keyVaultOk },
new StartupCheckItem { Name = "Database", Enabled = _settings.CheckDatabase, Ok = databaseOk },
new StartupCheckItem { Name = "MFR", Enabled = _settings.CheckMfr, Ok = mfrOk },
new StartupCheckItem { Name = "PdfLicense", Enabled = _settings.CheckPdfLicense, Ok = pdfLicenseOk },
new StartupCheckItem { Name = "Mailer", Enabled = _settings.SendStartupEmail, Ok = mailerOk },
],
});
}
catch (OperationCanceledException)
{
+132
View File
@@ -0,0 +1,132 @@
namespace Fuchs.Services;
/// <summary>
/// Result of a single connectivity/access probe run by <see cref="ISystemStatusService"/>.
/// Serialized directly to the Admin module frontend, so property names are the JSON contract.
/// </summary>
public sealed class SystemProbeResult
{
/// <summary>Probe identifier: <c>database</c>, <c>keyvault</c>, <c>blob</c> or <c>mfr</c>.</summary>
public string Component { get; init; } = "";
/// <summary><c>ok</c> | <c>error</c> | <c>disabled</c> | <c>unconfigured</c>.</summary>
public string Status { get; init; } = "unconfigured";
/// <summary><c>true</c> only when the component was reachable and access succeeded.</summary>
public bool Ok { get; init; }
/// <summary>Short human-readable summary (German), safe to show in the UI.</summary>
public string Message { get; init; } = "";
/// <summary>Optional extra detail (server name, account name, error text — never secrets).</summary>
public string? Detail { get; init; }
/// <summary>Round-trip duration of the probe in milliseconds.</summary>
public long DurationMs { get; init; }
/// <summary>When the probe ran (UTC).</summary>
public DateTimeOffset CheckedUtc { get; init; } = DateTimeOffset.UtcNow;
/// <summary>
/// Optional extra label/value pairs a probe wants shown alongside its result — e.g. the blob
/// probe reports the file count per container here. <c>null</c> for probes that have none.
/// </summary>
public IReadOnlyList<ProbeMetric>? Metrics { get; init; }
public static SystemProbeResult Disabled(string component, string message) =>
new() { Component = component, Status = "disabled", Ok = false, Message = message };
public static SystemProbeResult Unconfigured(string component, string message) =>
new() { Component = component, Status = "unconfigured", Ok = false, Message = message };
}
/// <summary>A named measurement a probe surfaces for display (e.g. "dev-fuchs-invoices" → "42 Dateien").</summary>
public sealed class ProbeMetric
{
public string Label { get; init; } = "";
public string Value { get; init; } = "";
}
/// <summary>
/// Passive configuration/environment snapshot (no network calls). Complements the
/// active <see cref="SystemProbeResult"/> probes with "how is this configured" facts.
/// Secrets are never included — only presence flags and non-sensitive settings.
/// </summary>
public sealed class SystemInfoSnapshot
{
public string MachineName { get; init; } = "";
public string OsDescription { get; init; } = "";
public string FrameworkDescription { get; init; } = "";
public string Environment { get; init; } = "";
public bool IsTestDeployment { get; init; }
public int ProcessId { get; init; }
public DateTimeOffset ProcessStartUtc { get; init; }
public double UptimeHours { get; init; }
public DatabaseInfo Database { get; init; } = new();
public EmailInfo Email { get; init; } = new();
public BlobInfo Blob { get; init; } = new();
public MfrInfo Mfr { get; init; } = new();
public KeyVaultInfo KeyVault { get; init; } = new();
/// <summary>Result of the one-shot startup self-test, or <c>null</c> if it has not run.</summary>
public StartupCheckReport? StartupChecks { get; init; }
public sealed class DatabaseInfo
{
public bool Configured { get; init; }
public string? Server { get; init; }
public string? Catalog { get; init; }
public string? UserId { get; init; }
}
public sealed class EmailInfo
{
public bool MailerEnabled { get; init; }
public string? BaseUrl { get; init; }
public string? AccountId { get; init; }
public string? ServerId { get; init; }
public bool TokenConfigured { get; init; }
public bool OverrideActive { get; init; }
public string? OverrideRecipient { get; init; }
}
public sealed class BlobInfo
{
public bool Enabled { get; init; }
public bool Configured { get; init; }
public string? InvoiceContainer { get; init; }
public string? ReminderContainer { get; init; }
}
public sealed class MfrInfo
{
public string? Host { get; init; }
public bool CredentialsConfigured { get; init; }
public bool SyncEnabled { get; init; }
}
public sealed class KeyVaultInfo
{
public string? VaultUri { get; init; }
public string? AppName { get; init; }
public int ManagedSecretCount { get; init; }
public bool ClientRegistered { get; init; }
}
}
/// <summary>Outcome of an Admin test-email send.</summary>
public sealed class EmailTestResult
{
public bool Sent { get; init; }
public string Message { get; init; } = "";
/// <summary>Address the caller requested.</summary>
public string RequestedRecipient { get; init; } = "";
/// <summary>
/// When the dev/test override is active, the address the mail was actually redirected to
/// (see <see cref="FuchsEmailSettings.OverrideRecipient"/>); otherwise <c>null</c>.
/// </summary>
public string? OverrideRecipient { get; init; }
}
+415
View File
@@ -0,0 +1,415 @@
using System.Diagnostics;
using Azure;
using Azure.Security.KeyVault.Secrets;
using Fuchs.Observability;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Fuchs.Services;
/// <summary>
/// Read-only diagnostics for the Admin module: a passive configuration snapshot plus live
/// connectivity/access probes (database, Key Vault, blob storage, MFR ERP) and a test-email
/// sender. Every probe is wrapped so it reports failure via <see cref="SystemProbeResult"/>
/// instead of throwing. No secrets are ever returned — only presence flags and non-sensitive
/// values (server/account names, error messages).
/// </summary>
public sealed class SystemStatusService : ISystemStatusService
{
private static readonly string[] Components = { "database", "keyvault", "blob", "mfr" };
private readonly IConfiguration _configuration;
private readonly IHostEnvironment _environment;
private readonly IServiceProvider _serviceProvider;
private readonly IBlobStorageService _blob;
private readonly IMfrClientFactory _mfrFactory;
private readonly IComService _comService;
private readonly ProcessWebComSettings _mailer;
private readonly FuchsEmailSettings _email;
private readonly AzureBlobStorageSettings _blobSettings;
private readonly StartupCheckReporter _startupChecks;
private readonly ILogger<SystemStatusService> _logger;
public SystemStatusService(
IConfiguration configuration,
IHostEnvironment environment,
IServiceProvider serviceProvider,
IBlobStorageService blob,
IMfrClientFactory mfrFactory,
IComService comService,
IOptions<ProcessWebComSettings> mailer,
IOptions<FuchsEmailSettings> email,
IOptions<AzureBlobStorageSettings> blobSettings,
StartupCheckReporter startupChecks,
ILogger<SystemStatusService> logger)
{
_configuration = configuration;
_environment = environment;
_serviceProvider = serviceProvider;
_blob = blob;
_mfrFactory = mfrFactory;
_comService = comService;
_mailer = mailer.Value;
_email = email.Value;
_blobSettings = blobSettings.Value;
_startupChecks = startupChecks;
_logger = logger;
}
public IReadOnlyList<string> ProbeComponents => Components;
public SystemInfoSnapshot GetInfo()
{
using var process = Process.GetCurrentProcess();
DateTimeOffset startUtc;
try { startUtc = process.StartTime.ToUniversalTime(); }
catch { startUtc = DateTimeOffset.UtcNow; }
return new SystemInfoSnapshot
{
MachineName = Environment.MachineName,
OsDescription = System.Runtime.InteropServices.RuntimeInformation.OSDescription,
FrameworkDescription = System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription,
Environment = _environment.EnvironmentName,
IsTestDeployment = _configuration.GetValue("Fuchs:IsTestDeployment", false),
ProcessId = process.Id,
ProcessStartUtc = startUtc,
UptimeHours = Math.Round((DateTimeOffset.UtcNow - startUtc).TotalHours, 2),
Database = BuildDatabaseInfo(),
Email = BuildEmailInfo(),
Blob = BuildBlobInfo(),
Mfr = BuildMfrInfo(),
KeyVault = BuildKeyVaultInfo(),
StartupChecks = _startupChecks.Latest,
};
}
private SystemInfoSnapshot.DatabaseInfo BuildDatabaseInfo()
{
string? cs = _configuration.GetConnectionString("fuchs_fds_ConnectionString");
if (string.IsNullOrWhiteSpace(cs))
return new SystemInfoSnapshot.DatabaseInfo { Configured = false };
try
{
var b = new SqlConnectionStringBuilder(cs);
return new SystemInfoSnapshot.DatabaseInfo
{
Configured = true,
Server = b.DataSource,
Catalog = b.InitialCatalog,
UserId = b.UserID, // login name only; the password is never surfaced
};
}
catch
{
return new SystemInfoSnapshot.DatabaseInfo { Configured = true };
}
}
private SystemInfoSnapshot.EmailInfo BuildEmailInfo() => new()
{
MailerEnabled = _mailer.Enabled,
BaseUrl = _mailer.BaseUrl,
AccountId = _mailer.AccountId,
ServerId = _mailer.ServerId,
TokenConfigured = !string.IsNullOrWhiteSpace(_mailer.Token) && _mailer.Token != "MANAGED_BY_KEYVAULT",
OverrideActive = !string.IsNullOrWhiteSpace(_email.OverrideRecipient),
OverrideRecipient = string.IsNullOrWhiteSpace(_email.OverrideRecipient) ? null : _email.OverrideRecipient,
};
private SystemInfoSnapshot.BlobInfo BuildBlobInfo()
{
string? cs = _configuration.GetConnectionString("AzureBlobStorage_ConnectionString");
return new SystemInfoSnapshot.BlobInfo
{
Enabled = _blobSettings.Enabled,
Configured = !string.IsNullOrWhiteSpace(cs) && cs != "MANAGED_BY_KEYVAULT",
InvoiceContainer = _blobSettings.InvoiceContainer,
ReminderContainer = _blobSettings.ReminderContainer,
};
}
private SystemInfoSnapshot.MfrInfo BuildMfrInfo()
{
string? host = _configuration["Fds:MFR_host"];
string? user = _configuration["Fds:MFR_UserName"];
return new SystemInfoSnapshot.MfrInfo
{
Host = host,
CredentialsConfigured = !string.IsNullOrWhiteSpace(user) && user != "MANAGED_BY_KEYVAULT",
SyncEnabled = _configuration.GetValue("Fds:SyncEnabled", false),
};
}
private SystemInfoSnapshot.KeyVaultInfo BuildKeyVaultInfo()
{
string[] keys = _configuration.GetSection("SecretManagement:ManagedSecretKeys").Get<string[]>() ?? [];
return new SystemInfoSnapshot.KeyVaultInfo
{
VaultUri = _configuration["SecretManagement:VaultUri"],
AppName = _configuration["SecretManagement:AppName"],
ManagedSecretCount = keys.Length,
ClientRegistered = _serviceProvider.GetService<SecretClient>() != null,
};
}
public async Task<IReadOnlyList<SystemProbeResult>> ProbeAllAsync(CancellationToken cancellationToken = default)
{
var tasks = Components.Select(c => ProbeAsync(c, cancellationToken));
return await Task.WhenAll(tasks);
}
public async Task<SystemProbeResult> ProbeAsync(string component, CancellationToken cancellationToken = default)
{
component = (component ?? "").Trim().ToLowerInvariant();
using var act = FuchsTelemetry.StartActivity("systemstatus.probe");
act?.SetTag("fuchs.systemstatus.component", component);
var sw = Stopwatch.StartNew();
SystemProbeResult result;
try
{
result = component switch
{
"database" => await ProbeDatabaseAsync(cancellationToken),
"keyvault" => await ProbeKeyVaultAsync(cancellationToken),
"blob" => await ProbeBlobAsync(cancellationToken),
"mfr" => await ProbeMfrAsync(cancellationToken),
_ => new SystemProbeResult
{
Component = component,
Status = "error",
Ok = false,
Message = $"Unbekannte Komponente '{component}'.",
},
};
}
catch (Exception ex)
{
// Defense in depth: individual probes already catch, but never let a probe throw.
_logger.LogWarning(ex, "System status probe {Component} threw unexpectedly.", component);
result = new SystemProbeResult
{
Component = component,
Status = "error",
Ok = false,
Message = "Unerwarteter Fehler bei der Prüfung.",
Detail = ex.Message,
};
}
sw.Stop();
result = new SystemProbeResult
{
Component = result.Component,
Status = result.Status,
Ok = result.Ok,
Message = result.Message,
Detail = result.Detail,
Metrics = result.Metrics,
DurationMs = sw.ElapsedMilliseconds,
CheckedUtc = DateTimeOffset.UtcNow,
};
act?.SetTag("fuchs.systemstatus.status", result.Status);
FuchsTelemetry.SystemProbes.Add(1,
new KeyValuePair<string, object?>("component", component),
new KeyValuePair<string, object?>("status", result.Status));
_logger.LogInformation("System status probe {Component} => {Status} in {Ms} ms.",
component, result.Status, sw.ElapsedMilliseconds);
return result;
}
private async Task<SystemProbeResult> ProbeDatabaseAsync(CancellationToken ct)
{
string? cs = _configuration.GetConnectionString("fuchs_fds_ConnectionString");
if (string.IsNullOrWhiteSpace(cs))
return SystemProbeResult.Unconfigured("database", "Keine Verbindungszeichenfolge konfiguriert.");
try
{
await using var conn = new SqlConnection(cs);
await conn.OpenAsync(ct);
await using var cmd = new SqlCommand(
"SELECT CONVERT(nvarchar(256), @@SERVERNAME), DB_NAME(), SUSER_SNAME();", conn);
await using var reader = await cmd.ExecuteReaderAsync(ct);
string server = "", db = "", login = "";
if (await reader.ReadAsync(ct))
{
server = reader.IsDBNull(0) ? "" : reader.GetString(0);
db = reader.IsDBNull(1) ? "" : reader.GetString(1);
login = reader.IsDBNull(2) ? "" : reader.GetString(2);
}
return new SystemProbeResult
{
Component = "database",
Status = "ok",
Ok = true,
Message = "Verbindung und Anmeldung erfolgreich.",
Detail = $"{server} / {db} (Login: {login})",
};
}
catch (Exception ex)
{
return new SystemProbeResult
{
Component = "database",
Status = "error",
Ok = false,
Message = "Verbindung oder Zugriff fehlgeschlagen.",
Detail = ex.Message,
};
}
}
private async Task<SystemProbeResult> ProbeKeyVaultAsync(CancellationToken ct)
{
string appName = _configuration["SecretManagement:AppName"] ?? "";
string[] managedKeys = _configuration.GetSection("SecretManagement:ManagedSecretKeys").Get<string[]>() ?? [];
if (string.IsNullOrWhiteSpace(appName) || managedKeys.Length == 0)
return SystemProbeResult.Unconfigured("keyvault", "SecretManagement-Einstellungen unvollständig.");
var secretClient = _serviceProvider.GetService<SecretClient>();
if (secretClient is null)
return SystemProbeResult.Unconfigured("keyvault", "SecretClient ist nicht registriert.");
string probeName = $"{appName}--{managedKeys[0]}";
try
{
KeyVaultSecret secret = await secretClient.GetSecretAsync(probeName, version: null, ct);
bool hasValue = !string.IsNullOrWhiteSpace(secret.Value);
return new SystemProbeResult
{
Component = "keyvault",
Status = hasValue ? "ok" : "error",
Ok = hasValue,
Message = hasValue ? "Zugriff auf Key Vault erfolgreich." : "Secret ist leer.",
Detail = $"Geprüft: {probeName}",
};
}
catch (RequestFailedException ex)
{
return new SystemProbeResult
{
Component = "keyvault",
Status = "error",
Ok = false,
Message = "Zugriff auf Key Vault fehlgeschlagen.",
Detail = $"Status {ex.Status}: {ex.Message}",
};
}
catch (Exception ex)
{
return new SystemProbeResult
{
Component = "keyvault",
Status = "error",
Ok = false,
Message = "Zugriff auf Key Vault fehlgeschlagen.",
Detail = ex.Message,
};
}
}
private async Task<SystemProbeResult> ProbeBlobAsync(CancellationToken ct)
{
BlobConnectivity c = await _blob.CheckConnectivityAsync(ct);
if (!c.Enabled)
return SystemProbeResult.Disabled("blob", "Azure Blob Storage ist deaktiviert (Fuchs:AzureStorage:Enabled=false).");
if (!c.Configured)
return SystemProbeResult.Unconfigured("blob", c.Detail ?? "Keine Verbindungszeichenfolge konfiguriert.");
List<ProbeMetric>? metrics = c.Containers?
.Select(x => new ProbeMetric
{
Label = x.Name,
Value = !x.Exists ? "nicht vorhanden"
: x.FileCount < 0 ? "Anzahl nicht ermittelbar"
: $"{x.FileCount} Datei{(x.FileCount == 1 ? "" : "en")}",
})
.ToList();
string message = c.Reachable ? "Verbindung und Zugriff erfolgreich." : "Zugriff fehlgeschlagen.";
if (c.Reachable && c.Containers is { Count: > 0 })
{
int total = c.Containers.Where(x => x.FileCount > 0).Sum(x => x.FileCount);
message += $" {total} Datei{(total == 1 ? "" : "en")} gesamt.";
}
return new SystemProbeResult
{
Component = "blob",
Status = c.Reachable ? "ok" : "error",
Ok = c.Reachable,
Message = message,
Detail = string.IsNullOrWhiteSpace(c.AccountName) ? c.Detail : $"{c.AccountName}: {c.Detail}",
Metrics = metrics,
};
}
private async Task<SystemProbeResult> ProbeMfrAsync(CancellationToken ct)
{
try
{
using var client = _mfrFactory.Create();
string entities = await client.GetEntities(throwErrorIfNotOk: true);
bool ok = !string.IsNullOrWhiteSpace(entities);
return new SystemProbeResult
{
Component = "mfr",
Status = ok ? "ok" : "error",
Ok = ok,
Message = ok ? "Verbindung und Zugriff erfolgreich." : "Leere Antwort erhalten.",
Detail = _configuration["Fds:MFR_host"],
};
}
catch (Exception ex)
{
return new SystemProbeResult
{
Component = "mfr",
Status = "error",
Ok = false,
Message = "Verbindung oder Zugriff fehlgeschlagen.",
Detail = ex.Message,
};
}
}
public async Task<EmailTestResult> SendTestEmailAsync(string to, string subject, string body,
CancellationToken cancellationToken = default)
{
to = (to ?? "").Trim();
subject = (subject ?? "").Trim();
body = body ?? "";
if (to.Length == 0 || subject.Length == 0)
return new EmailTestResult { Sent = false, Message = "Empfänger und Betreff sind erforderlich.", RequestedRecipient = to };
// Body arrives as plain text from the Admin form; wrap the (HTML-encoded) lines in <p>.
string html = "<p>" + System.Net.WebUtility.HtmlEncode(body).Replace("\r\n", "\n").Replace("\n", "<br/>") + "</p>";
bool sent = await _comService.SendEmailAsync("admin_test", subject, html, to, "Admin Test", attachments: null);
string? overrideTo = string.IsNullOrWhiteSpace(_email.OverrideRecipient) ? null : _email.OverrideRecipient;
string message = sent
? (overrideTo is null
? "Test-E-Mail wurde an den Mailer übergeben."
: $"Test-E-Mail wurde an den Mailer übergeben (durch OverrideRecipient umgeleitet an {overrideTo}).")
: "Test-E-Mail wurde vom Mailer nicht akzeptiert (ggf. ist der Mailer deaktiviert).";
return new EmailTestResult
{
Sent = sent,
Message = message,
RequestedRecipient = to,
OverrideRecipient = overrideTo,
};
}
}
+2 -2
View File
@@ -30,10 +30,10 @@
"Enabled": true
},
"Email": {
"OverrideRecipient": "service@emails.processweb.de"
"OverrideRecipient": "info@processweb.de"
},
"AzureStorage": {
"Enabled": false,
"Enabled": true,
"InvoiceContainer": "dev-fuchs-invoices",
"ReminderContainer": "dev-fuchs-reminders"
}
+19
View File
@@ -151,5 +151,24 @@
"js/intranet/modules/fis.bam.scss"
],
"minify": { "enabled": true }
},
{
"context": "intranet:admin",
"outputFileName": "wwwroot/web/fis.admin.de.js",
"inputFiles": [
"js/intranet/modules/fis.admin_txt_de.js",
"js/intranet/modules/fis.admin.js"
],
"minify": { "enabled": true }
},
{
"context": "intranet:admin",
"outputFileName": "wwwroot/web/fis.admin.min.css",
"inputFiles": [
"css/intranet/oci_variables.scss",
"css/intranet/fis_variables.scss",
"js/intranet/modules/fis.admin.scss"
],
"minify": { "enabled": true }
}
]
+1
View File
@@ -2,4 +2,5 @@
$fis.notifications.init();
$fis.draft.init();
$fis.ov();
$fis.addAdminMenuIfAuthorized();
});
+17
View File
@@ -10,3 +10,20 @@
//, { lbl: $t.m_efa, id: 'm_efa', fnc: 'init:efa' }
]);
})();
/* The Administration module is restricted to users whose fds_sys authorization exceeds 4.
The button is added — and only then does clicking it load the module script — after the
asynchronous auth check resolves, so lower-privileged users never see it or fetch the code.
Called from fis_main_go.js once the DOM (and the initial main menu) is ready. */
$fis.addAdminMenuIfAuthorized = function () {
if ($('#m_admin').length > 0) { return; } // idempotent
$fis.getAuth('fds_sys').then(function (auth) {
if ((auth || -1) > 4 && $('#m_admin').length === 0) {
$ocms.ocmsmenu.push({ lbl: $t.m_admin, id: 'm_admin', fnc: 'init:admin', ico: 'glyphicon glyphicon-cog' });
/* Re-render the main menu cleanly: drop the previously generated menu list
(never the .nav-right settings dropdown) and rebuild it from the array. */
$('#mainmenu').children('ul:not(.nav-right)').remove();
$('#mainmenu').ocmsmenu($ocms.ocmsmenu);
}
}).catch(function () { /* auth lookup failed → leave the menu unchanged */ });
};
+1
View File
@@ -4,6 +4,7 @@
, m_rep: 'Berichte'
, m_todo: 'ToDos'
, m_bcd: 'BankBuchungen'
, m_admin: 'Administration'
, rsp: 'Passwort ändern'
, pnm: 'Die Passwörter stimmen nicht überein'
, cps: 'Das neue Passwort wurde gespeichert.'
+250
View File
@@ -0,0 +1,250 @@
let $adm = {
init2: function () {
// No external libraries needed — go straight to render.
$ocms.getScript([], function () { $adm.init3(); });
},
init3: function () {
$fis.cf(true);
$fis.lf(true);
$('#activemodule').text($adt.mdl);
$adm.topbar();
let frm = $$.dc('admfrm', $('#contentframe')).ldng(1);
$adm.load(frm);
},
topbar: function () {
return $('#topbar').ocmsmenu([
{ lbl: $adt.refreshAll, id: 'adm_refresh_all', ico: 'glyphicon glyphicon-refresh', fnc: function () { $adm.init3(); } }
]);
},
load: function (frm) {
$ocms.postXT({
url: $ocms.url('admin/status'), success: function (r) {
frm.empty();
$adm.render(frm, r.info || {}, r.probes || []);
}, error: function () {
frm.empty();
$$.dc('adm_err', frm).text($adt.loadfail);
}, complete: function () { frm.ldng(0); }
});
},
render: function (frm, info, probes) {
let pmap = {};
$.each(probes, function (i, p) { pmap[p.component] = p; });
$$.dc('adm_sec', frm).text($adt.sub_info);
$adm.sysCard(frm, info);
$adm.startupCard(frm, info.startupChecks);
$$.dc('adm_sec', frm).text($adt.sub_status);
let grid = $$.dc('adm_grid', frm);
$adm.dbCard(grid, info.database || {}, pmap.database);
$adm.mailCard(grid, info.email || {});
$adm.blobCard(grid, info.blob || {}, pmap.blob);
$adm.mfrCard(grid, info.mfr || {}, pmap.mfr);
$adm.kvCard(grid, info.keyVault || {}, pmap.keyvault);
},
/* ── generic building blocks ─────────────────────────────────────────── */
kvTable: function (parent, rows) {
let t = $$.tbl().addClass('adm_kv');
$.each(rows || [], function (i, r) {
if (!r) { return true; }
let tr = $$.tr(t);
$$.td(tr).addClass('k').text(r[0] == null ? '' : String(r[0]));
let v = $$.td(tr).addClass('v');
if (r[1] instanceof jQuery) { v.append(r[1]); } else { v.text(r[1] == null ? '' : String(r[1])); }
});
if (parent instanceof jQuery) { parent.append(t); }
return t;
},
yesNo: function (b) {
return $$.sc('adm_bool adm_bool_' + (b ? 'y' : 'n'), b ? $adt.yes : $adt.no);
},
setPill: function (pill, probe) {
probe = probe || {};
let st = probe.status || 'unconfigured';
let lbl = {
ok: $adt.st_ok, error: $adt.st_error, disabled: $adt.st_disabled,
unconfigured: $adt.st_unconfigured, checking: $adt.st_checking
}[st] || st;
pill.attr('class', 'adm_pill adm_pill_' + st).text(lbl);
return pill;
},
/* Connectivity card with a live status pill + per-card refresh button. */
probeCard: function (grid, title, component, cfgRows, probe) {
let card = $$.dc('adm_card', grid);
let hd = $$.dc('adm_card_hd', card);
$$.s(title).appendTo(hd);
let pill = $$.sc('adm_pill').appendTo(hd);
let rbtn = $$.bbtn($adt.refresh, 'adm_btn').appendTo(hd);
let bd = $$.dc('adm_card_bd', card);
$adm.kvTable(bd, cfgRows);
let pf = $$.dc('adm_probe', bd);
let msg = $$.dc('adm_probe_msg', pf);
let met = $$.dc('adm_probe_metrics', pf);
let det = $$.dc('adm_probe_det', pf);
let dur = $$.dc('adm_probe_dur', pf);
let apply = function (p) {
p = p || {};
$adm.setPill(pill, p);
msg.text(p.message || '');
met.empty();
$.each(p.metrics || [], function (i, m) {
let row = $$.dc('adm_metric', met);
$$.sc('adm_metric_k').text(m.label || '').appendTo(row);
$$.sc('adm_metric_v').text(m.value == null ? '' : String(m.value)).appendTo(row);
});
det.text(p.detail || '');
dur.text(p.durationMs != null ? (p.durationMs + ' ms') : '');
};
apply(probe);
rbtn.click(function () {
$adm.setPill(pill, { status: 'checking' });
msg.text(''); met.empty(); det.text(''); dur.text('');
$ocms.postXT({
url: $ocms.url('admin/probe/' + component), success: function (r) {
apply(r.probe);
}, error: function () {
$adm.setPill(pill, { status: 'error' });
msg.text($adt.st_error);
}
});
});
return card;
},
/* ── individual cards ────────────────────────────────────────────────── */
sysCard: function (frm, info) {
let card = $$.dc('adm_card adm_card_wide', frm);
let hd = $$.dc('adm_card_hd', card);
$$.s($adt.host).appendTo(hd);
let env = $$.sc('adm_env').text(info.environment || '');
if (info.isTestDeployment === true) { env.aC('adm_env_test'); }
env.appendTo(hd);
let bd = $$.dc('adm_card_bd', card);
$adm.kvTable(bd, [
[$adt.machine, info.machineName],
[$adt.os, info.osDescription],
[$adt.framework, info.frameworkDescription],
[$adt.env, info.environment],
[$adt.testdep, $adm.yesNo(info.isTestDeployment === true)],
[$adt.pid, info.processId],
[$adt.since, info.processStartUtc ? fdt(info.processStartUtc) : ''],
[$adt.uptime, (info.uptimeHours != null ? (info.uptimeHours + ' ' + $adt.hours) : '')]
]);
},
/* Non-refreshable widget: the one-shot startup self-test result captured at boot. */
startupCard: function (frm, sc) {
let card = $$.dc('adm_card adm_card_wide', frm);
let hd = $$.dc('adm_card_hd', card);
$$.s($adt.startup).appendTo(hd);
let bd = $$.dc('adm_card_bd', card);
if (!sc || sc.ran !== true) {
$adm.setPill($$.sc('adm_pill').appendTo(hd), { status: 'disabled' });
$$.dc('adm_note', bd).text($adt.startup_notrun);
return;
}
let items = sc.items || [];
let anyFail = false;
$.each(items, function (i, it) { if (it.enabled === true && it.ok !== true) { anyFail = true; } });
$adm.setPill($$.sc('adm_pill').appendTo(hd), { status: anyFail ? 'error' : 'ok' });
$adm.kvTable(bd, [
[$adt.startup_on, sc.machineName],
[$adt.startup_at, sc.completedUtc ? fdt(sc.completedUtc) : '']
]);
let list = $$.dc('adm_checks', bd);
$.each(items, function (i, it) {
let row = $$.dc('adm_check', list);
$$.sc('adm_check_k').text($adt['chk_' + it.name] || it.name).appendTo(row);
let stt = it.enabled !== true ? 'skipped' : (it.ok === true ? 'ok' : 'fail');
let lbl = { ok: $adt.chk_ok, fail: $adt.chk_fail, skipped: $adt.chk_skipped }[stt];
$$.sc('adm_check_v adm_check_' + stt).text(lbl).appendTo(row);
});
},
dbCard: function (grid, d, probe) {
$adm.probeCard(grid, $adt.db, 'database', [
[$adt.configured, $adm.yesNo(d.configured === true)],
[$adt.server, d.server],
[$adt.catalog, d.catalog],
[$adt.login, d.userId]
], probe);
},
mailCard: function (grid, e) {
let card = $$.dc('adm_card', grid);
let hd = $$.dc('adm_card_hd', card);
$$.s($adt.mail).appendTo(hd);
$adm.setPill($$.sc('adm_pill').appendTo(hd), { status: e.mailerEnabled === true ? 'ok' : 'disabled' });
let bd = $$.dc('adm_card_bd', card);
let rows = [
[$adt.mailer, $adm.yesNo(e.mailerEnabled === true)],
[$adt.baseurl, e.baseUrl],
[$adt.account, e.accountId],
[$adt.serverid, e.serverId],
[$adt.token, $adm.yesNo(e.tokenConfigured === true)],
[$adt.overrideActive, $adm.yesNo(e.overrideActive === true)]
];
if (e.overrideActive === true) { rows.push([$adt.override, e.overrideRecipient]); }
$adm.kvTable(bd, rows);
let ft = $$.dc('adm_card_ft', card);
$$.bbtn($adt.testmail_btn, 'adm_btn adm_btn_primary').appendTo(ft).click($adm.testMailDlg);
},
blobCard: function (grid, b, probe) {
$adm.probeCard(grid, $adt.blob, 'blob', [
[$adt.enabled, $adm.yesNo(b.enabled === true)],
[$adt.configured, $adm.yesNo(b.configured === true)],
[$adt.invContainer, b.invoiceContainer],
[$adt.remContainer, b.reminderContainer]
], probe);
},
mfrCard: function (grid, m, probe) {
$adm.probeCard(grid, $adt.mfr, 'mfr', [
[$adt.host_l, m.host],
[$adt.creds, $adm.yesNo(m.credentialsConfigured === true)],
[$adt.sync, $adm.yesNo(m.syncEnabled === true)]
], probe);
},
kvCard: function (grid, k, probe) {
$adm.probeCard(grid, $adt.kv, 'keyvault', [
[$adt.vault, k.vaultUri],
[$adt.appname, k.appName],
[$adt.seccount, k.managedSecretCount],
[$adt.clientreg, $adm.yesNo(k.clientRegistered === true)]
], probe);
},
/* ── test email ──────────────────────────────────────────────────────── */
testMailDlg: function () {
$ocms.dlgform([
{ name: 'to', label: $adt.tm_to, type: 'email', required: true, value: ($ocms.auth.email || '') },
{ name: 'subject', label: $adt.tm_subject, type: 'string', required: true, value: $adt.tm_default_subject },
{ name: 'body', label: $adt.tm_body, type: 'text', required: true, value: $adt.tm_default_body }
], {
title: $adt.testmail,
button: $adt.testmail_btn,
size: [420, 560],
addcontent: $$.dc('adm_note').text($adt.tm_hint),
submit: function (e) {
let c = $(this).ldng(1);
let qs = c.serializeObject(true, { typedvalues: true });
$ocms.postXT({
url: $ocms.url('admin/testmail'), data: qs, timeout: 60000, success: function (r) {
let res = r.result || {};
alert(res.message || (res.sent ? $adt.tm_sent : $adt.tm_failed));
c.trigger('modal_close');
}, error: function () {
alert($adt.tm_failed);
}, complete: function () { c.ldng(0); }
});
}
});
}
};
let $$adm = { init2: $adm.init2, auth: {} };
export default $$adm;
+200
View File
@@ -0,0 +1,200 @@
.admfrm {
padding: 1.5rem;
.adm_sec {
font-size: 1.2rem;
font-weight: bold;
color: $fuchs_blau;
margin: 1.5rem 0 0.75rem;
border-bottom: 2px solid $fuchs_blau;
padding-bottom: 0.25rem;
&:first-child { margin-top: 0; }
}
.adm_err {
padding: 1rem;
color: #b00020;
font-weight: bold;
}
.adm_grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(22rem, 1fr));
gap: 1rem;
align-items: start;
}
.adm_card {
border: 1px solid #d5d5d5;
border-radius: 0.4rem;
background: #fff;
box-shadow: 0 1px 3px rgba(50, 50, 50, 0.12);
overflow: hidden;
&.adm_card_wide { grid-column: 1 / -1; }
}
.adm_card_hd {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.85rem;
background: $fuchs_blau;
color: #fff;
font-weight: bold;
> span:first-child { flex: 1 1 auto; }
}
.adm_card_bd {
padding: 0.6rem 0.85rem;
}
.adm_card_ft {
padding: 0.5rem 0.85rem 0.75rem;
text-align: right;
}
table.adm_kv {
border-collapse: collapse;
width: 100%;
td {
padding: 0.25rem 0.35rem;
border-bottom: 1px solid #eee;
vertical-align: top;
font-size: 0.92rem;
&.k { color: #666; white-space: nowrap; width: 40%; }
&.v { word-break: break-word; }
}
tr:last-child td { border-bottom: none; }
}
.adm_probe {
margin-top: 0.6rem;
padding-top: 0.5rem;
border-top: 1px dashed #ddd;
font-size: 0.88rem;
.adm_probe_msg { font-weight: 600; }
.adm_probe_det { color: #555; word-break: break-word; margin-top: 0.15rem; }
.adm_probe_dur { color: #999; margin-top: 0.15rem; font-size: 0.8rem; }
.adm_probe_metrics {
margin-top: 0.35rem;
.adm_metric {
display: flex;
justify-content: space-between;
gap: 0.5rem;
padding: 0.1rem 0;
border-bottom: 1px dotted #eee;
&:last-child { border-bottom: none; }
.adm_metric_k { color: #555; word-break: break-word; }
.adm_metric_v { font-weight: 600; white-space: nowrap; }
}
}
}
/* startup checks list */
.adm_checks {
margin-top: 0.5rem;
.adm_check {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0;
border-bottom: 1px solid #eee;
&:last-child { border-bottom: none; }
.adm_check_k { color: #333; }
.adm_check_v {
font-weight: bold;
font-size: 0.8rem;
padding: 0.05rem 0.5rem;
border-radius: 0.25rem;
white-space: nowrap;
}
.adm_check_ok { background: #e2f3da; color: #2f6d18; }
.adm_check_fail { background: #f3ded9; color: #c0341d; }
.adm_check_skipped { background: #ececec; color: #777; }
}
}
/* status pill */
.adm_pill {
display: inline-block;
padding: 0.1rem 0.55rem;
border-radius: 1rem;
font-size: 0.78rem;
font-weight: bold;
color: #fff;
background: #999;
white-space: nowrap;
}
.adm_pill_ok { background: $fuchs_akzent; }
.adm_pill_error { background: #c0341d; }
.adm_pill_disabled { background: #888; }
.adm_pill_unconfigured { background: #d69100; }
.adm_pill_checking { background: #2a72c4; }
/* boolean chips */
.adm_bool {
display: inline-block;
padding: 0 0.4rem;
border-radius: 0.25rem;
font-size: 0.8rem;
font-weight: bold;
}
.adm_bool_y { background: #e2f3da; color: #2f6d18; }
.adm_bool_n { background: #f3e0dd; color: #9c2a17; }
.adm_env {
font-size: 0.8rem;
font-weight: normal;
padding: 0.1rem 0.5rem;
border-radius: 0.25rem;
background: rgba(255, 255, 255, 0.25);
&.adm_env_test { background: #13a143; }
}
.adm_btn {
cursor: pointer;
border-radius: 0.28rem;
padding: 0.2rem 0.6rem;
font-size: 0.85rem;
border: 1px solid #ababab;
background-color: #fff;
color: #333;
&.adm_btn_primary {
background-color: $fuchs_akzent;
border-color: darken($fuchs_akzent, 8%);
color: #fff;
}
}
.adm_card_hd .adm_btn {
background-color: rgba(255, 255, 255, 0.15);
color: #fff;
border-color: rgba(255, 255, 255, 0.4);
}
}
.adm_note {
margin-top: 0.75rem;
padding: 0.5rem 0.6rem;
background: #f3f6fb;
border-left: 3px solid $fuchs_blau;
font-size: 0.85rem;
color: #444;
}
@@ -0,0 +1,84 @@
let $adt = {
mdl: 'Administration'
, sub_info: 'System'
, sub_status: 'Status & Konnektivität'
, sub_tests: 'Tests'
// startup checks widget (non-refreshable)
, startup: 'Startup-Prüfungen'
, startup_notrun: 'Die Startup-Prüfungen sind deaktiviert oder wurden noch nicht ausgeführt (Fuchs:StartupChecks:Enabled).'
, startup_on: 'Host'
, startup_at: 'Ausgeführt (UTC)'
, chk_ok: 'OK'
, chk_fail: 'Fehler'
, chk_skipped: 'übersprungen'
, chk_KeyVault: 'Azure Key Vault'
, chk_Database: 'SQL-Datenbank'
, chk_MFR: 'MFR (ERP)'
, chk_PdfLicense: 'Spire.PDF-Lizenz'
, chk_Mailer: 'E-Mail (Startup-Probe)'
// system info
, host: 'Host / Server'
, machine: 'Hostname'
, os: 'Betriebssystem'
, framework: 'Framework'
, env: 'Umgebung'
, testdep: 'Test-Deployment'
, pid: 'Prozess-ID'
, uptime: 'Laufzeit'
, since: 'Start (UTC)'
, hours: 'Std.'
// status cards
, db: 'SQL-Datenbank'
, mail: 'E-Mail-Dienst'
, blob: 'Azure Blob Storage'
, mfr: 'MFR (ERP)'
, kv: 'Azure Key Vault'
// labels
, server: 'Server'
, catalog: 'Datenbank'
, login: 'Login'
, mailer: 'Mailer aktiv'
, baseurl: 'Base-URL'
, account: 'Account'
, serverid: 'Server-ID'
, token: 'Token hinterlegt'
, override: 'OverrideRecipient'
, overrideActive: 'Umleitung aktiv'
, enabled: 'Aktiviert'
, configured: 'Konfiguriert'
, invContainer: 'Rechnungs-Container'
, remContainer: 'Mahnungs-Container'
, host_l: 'Host'
, creds: 'Zugangsdaten hinterlegt'
, sync: 'Sync aktiv'
, vault: 'Vault-URI'
, appname: 'App-Präfix'
, seccount: 'Verwaltete Secrets'
, clientreg: 'Client registriert'
, duration: 'Dauer'
, checked: 'Geprüft'
, detail: 'Detail'
// status values
, st_ok: 'OK'
, st_error: 'Fehler'
, st_disabled: 'Deaktiviert'
, st_unconfigured: 'Nicht konfiguriert'
, st_checking: 'Prüfe…'
// actions
, refresh: 'Aktualisieren'
, refreshAll: 'Alle prüfen'
, yes: 'Ja'
, no: 'Nein'
// test email
, testmail: 'Test-E-Mail'
, testmail_btn: 'Test-E-Mail senden'
, tm_to: 'Empfänger'
, tm_subject: 'Betreff'
, tm_body: 'Nachricht'
, tm_hint: 'Der Versand nutzt die normale E-Mail-Pipeline; bei gesetztem OverrideRecipient wird die Nachricht dorthin umgeleitet.'
, tm_sent: 'Test-E-Mail übergeben.'
, tm_failed: 'Test-E-Mail konnte nicht gesendet werden.'
, tm_default_subject: 'Fuchs Intranet Test-E-Mail'
, tm_default_body: 'Dies ist eine Test-E-Mail aus dem Administrationsbereich des Fuchs Intranet.'
, loadfail: 'Der Systemstatus konnte nicht geladen werden.'
};
+256
View File
@@ -0,0 +1,256 @@
/* basics */
/* Color SCHEME */
/* #8396bd */
/* #e3e6e6 */
/* #ffffff */
/* #262626 */
/* #262626 */
/* #e3e6e6 */
/* #FFC801 */
/* #ffff4a */
/* media breaks */
/* other */
/* mixin */
/* basics */
/* Color SCHEME */
/* #1b4379 #0033b3 */
/*neu: #56a532 ; alt: rgb(32,144,119);*/
/* 10% sw */
.admfrm {
padding: 1.5rem;
}
.admfrm .adm_sec {
font-size: 1.2rem;
font-weight: bold;
color: rgb(27, 67, 121);
margin: 1.5rem 0 0.75rem;
border-bottom: 2px solid rgb(27, 67, 121);
padding-bottom: 0.25rem;
}
.admfrm .adm_sec:first-child {
margin-top: 0;
}
.admfrm .adm_err {
padding: 1rem;
color: #b00020;
font-weight: bold;
}
.admfrm .adm_grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(22rem, 1fr));
gap: 1rem;
align-items: start;
}
.admfrm .adm_card {
border: 1px solid #d5d5d5;
border-radius: 0.4rem;
background: #fff;
box-shadow: 0 1px 3px rgba(50, 50, 50, 0.12);
overflow: hidden;
}
.admfrm .adm_card.adm_card_wide {
grid-column: 1/-1;
}
.admfrm .adm_card_hd {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.85rem;
background: rgb(27, 67, 121);
color: #fff;
font-weight: bold;
}
.admfrm .adm_card_hd > span:first-child {
flex: 1 1 auto;
}
.admfrm .adm_card_bd {
padding: 0.6rem 0.85rem;
}
.admfrm .adm_card_ft {
padding: 0.5rem 0.85rem 0.75rem;
text-align: right;
}
.admfrm table.adm_kv {
border-collapse: collapse;
width: 100%;
}
.admfrm table.adm_kv td {
padding: 0.25rem 0.35rem;
border-bottom: 1px solid #eee;
vertical-align: top;
font-size: 0.92rem;
}
.admfrm table.adm_kv td.k {
color: #666;
white-space: nowrap;
width: 40%;
}
.admfrm table.adm_kv td.v {
word-break: break-word;
}
.admfrm table.adm_kv tr:last-child td {
border-bottom: none;
}
.admfrm .adm_probe {
margin-top: 0.6rem;
padding-top: 0.5rem;
border-top: 1px dashed #ddd;
font-size: 0.88rem;
}
.admfrm .adm_probe .adm_probe_msg {
font-weight: 600;
}
.admfrm .adm_probe .adm_probe_det {
color: #555;
word-break: break-word;
margin-top: 0.15rem;
}
.admfrm .adm_probe .adm_probe_dur {
color: #999;
margin-top: 0.15rem;
font-size: 0.8rem;
}
.admfrm .adm_probe .adm_probe_metrics {
margin-top: 0.35rem;
}
.admfrm .adm_probe .adm_probe_metrics .adm_metric {
display: flex;
justify-content: space-between;
gap: 0.5rem;
padding: 0.1rem 0;
border-bottom: 1px dotted #eee;
}
.admfrm .adm_probe .adm_probe_metrics .adm_metric:last-child {
border-bottom: none;
}
.admfrm .adm_probe .adm_probe_metrics .adm_metric .adm_metric_k {
color: #555;
word-break: break-word;
}
.admfrm .adm_probe .adm_probe_metrics .adm_metric .adm_metric_v {
font-weight: 600;
white-space: nowrap;
}
.admfrm {
/* startup checks list */
}
.admfrm .adm_checks {
margin-top: 0.5rem;
}
.admfrm .adm_checks .adm_check {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0;
border-bottom: 1px solid #eee;
}
.admfrm .adm_checks .adm_check:last-child {
border-bottom: none;
}
.admfrm .adm_checks .adm_check .adm_check_k {
color: #333;
}
.admfrm .adm_checks .adm_check .adm_check_v {
font-weight: bold;
font-size: 0.8rem;
padding: 0.05rem 0.5rem;
border-radius: 0.25rem;
white-space: nowrap;
}
.admfrm .adm_checks .adm_check .adm_check_ok {
background: #e2f3da;
color: #2f6d18;
}
.admfrm .adm_checks .adm_check .adm_check_fail {
background: #f3ded9;
color: #c0341d;
}
.admfrm .adm_checks .adm_check .adm_check_skipped {
background: #ececec;
color: #777;
}
.admfrm {
/* status pill */
}
.admfrm .adm_pill {
display: inline-block;
padding: 0.1rem 0.55rem;
border-radius: 1rem;
font-size: 0.78rem;
font-weight: bold;
color: #fff;
background: #999;
white-space: nowrap;
}
.admfrm .adm_pill_ok {
background: rgb(86, 165, 50);
}
.admfrm .adm_pill_error {
background: #c0341d;
}
.admfrm .adm_pill_disabled {
background: #888;
}
.admfrm .adm_pill_unconfigured {
background: #d69100;
}
.admfrm .adm_pill_checking {
background: #2a72c4;
}
.admfrm {
/* boolean chips */
}
.admfrm .adm_bool {
display: inline-block;
padding: 0 0.4rem;
border-radius: 0.25rem;
font-size: 0.8rem;
font-weight: bold;
}
.admfrm .adm_bool_y {
background: #e2f3da;
color: #2f6d18;
}
.admfrm .adm_bool_n {
background: #f3e0dd;
color: #9c2a17;
}
.admfrm .adm_env {
font-size: 0.8rem;
font-weight: normal;
padding: 0.1rem 0.5rem;
border-radius: 0.25rem;
background: rgba(255, 255, 255, 0.25);
}
.admfrm .adm_env.adm_env_test {
background: #13a143;
}
.admfrm .adm_btn {
cursor: pointer;
border-radius: 0.28rem;
padding: 0.2rem 0.6rem;
font-size: 0.85rem;
border: 1px solid #ababab;
background-color: #fff;
color: #333;
}
.admfrm .adm_btn.adm_btn_primary {
background-color: rgb(86, 165, 50);
border-color: rgb(69.68, 133.688372093, 40.511627907);
color: #fff;
}
.admfrm .adm_card_hd .adm_btn {
background-color: rgba(255, 255, 255, 0.15);
color: #fff;
border-color: rgba(255, 255, 255, 0.4);
}
.adm_note {
margin-top: 0.75rem;
padding: 0.5rem 0.6rem;
background: #f3f6fb;
border-left: 3px solid rgb(27, 67, 121);
font-size: 0.85rem;
color: #444;
}
+335
View File
@@ -0,0 +1,335 @@
let $adt = {
mdl: 'Administration'
, sub_info: 'System'
, sub_status: 'Status & Konnektivität'
, sub_tests: 'Tests'
// startup checks widget (non-refreshable)
, startup: 'Startup-Prüfungen'
, startup_notrun: 'Die Startup-Prüfungen sind deaktiviert oder wurden noch nicht ausgeführt (Fuchs:StartupChecks:Enabled).'
, startup_on: 'Host'
, startup_at: 'Ausgeführt (UTC)'
, chk_ok: 'OK'
, chk_fail: 'Fehler'
, chk_skipped: 'übersprungen'
, chk_KeyVault: 'Azure Key Vault'
, chk_Database: 'SQL-Datenbank'
, chk_MFR: 'MFR (ERP)'
, chk_PdfLicense: 'Spire.PDF-Lizenz'
, chk_Mailer: 'E-Mail (Startup-Probe)'
// system info
, host: 'Host / Server'
, machine: 'Hostname'
, os: 'Betriebssystem'
, framework: 'Framework'
, env: 'Umgebung'
, testdep: 'Test-Deployment'
, pid: 'Prozess-ID'
, uptime: 'Laufzeit'
, since: 'Start (UTC)'
, hours: 'Std.'
// status cards
, db: 'SQL-Datenbank'
, mail: 'E-Mail-Dienst'
, blob: 'Azure Blob Storage'
, mfr: 'MFR (ERP)'
, kv: 'Azure Key Vault'
// labels
, server: 'Server'
, catalog: 'Datenbank'
, login: 'Login'
, mailer: 'Mailer aktiv'
, baseurl: 'Base-URL'
, account: 'Account'
, serverid: 'Server-ID'
, token: 'Token hinterlegt'
, override: 'OverrideRecipient'
, overrideActive: 'Umleitung aktiv'
, enabled: 'Aktiviert'
, configured: 'Konfiguriert'
, invContainer: 'Rechnungs-Container'
, remContainer: 'Mahnungs-Container'
, host_l: 'Host'
, creds: 'Zugangsdaten hinterlegt'
, sync: 'Sync aktiv'
, vault: 'Vault-URI'
, appname: 'App-Präfix'
, seccount: 'Verwaltete Secrets'
, clientreg: 'Client registriert'
, duration: 'Dauer'
, checked: 'Geprüft'
, detail: 'Detail'
// status values
, st_ok: 'OK'
, st_error: 'Fehler'
, st_disabled: 'Deaktiviert'
, st_unconfigured: 'Nicht konfiguriert'
, st_checking: 'Prüfe…'
// actions
, refresh: 'Aktualisieren'
, refreshAll: 'Alle prüfen'
, yes: 'Ja'
, no: 'Nein'
// test email
, testmail: 'Test-E-Mail'
, testmail_btn: 'Test-E-Mail senden'
, tm_to: 'Empfänger'
, tm_subject: 'Betreff'
, tm_body: 'Nachricht'
, tm_hint: 'Der Versand nutzt die normale E-Mail-Pipeline; bei gesetztem OverrideRecipient wird die Nachricht dorthin umgeleitet.'
, tm_sent: 'Test-E-Mail übergeben.'
, tm_failed: 'Test-E-Mail konnte nicht gesendet werden.'
, tm_default_subject: 'Fuchs Intranet Test-E-Mail'
, tm_default_body: 'Dies ist eine Test-E-Mail aus dem Administrationsbereich des Fuchs Intranet.'
, loadfail: 'Der Systemstatus konnte nicht geladen werden.'
};
let $adm = {
init2: function () {
// No external libraries needed — go straight to render.
$ocms.getScript([], function () { $adm.init3(); });
},
init3: function () {
$fis.cf(true);
$fis.lf(true);
$('#activemodule').text($adt.mdl);
$adm.topbar();
let frm = $$.dc('admfrm', $('#contentframe')).ldng(1);
$adm.load(frm);
},
topbar: function () {
return $('#topbar').ocmsmenu([
{ lbl: $adt.refreshAll, id: 'adm_refresh_all', ico: 'glyphicon glyphicon-refresh', fnc: function () { $adm.init3(); } }
]);
},
load: function (frm) {
$ocms.postXT({
url: $ocms.url('admin/status'), success: function (r) {
frm.empty();
$adm.render(frm, r.info || {}, r.probes || []);
}, error: function () {
frm.empty();
$$.dc('adm_err', frm).text($adt.loadfail);
}, complete: function () { frm.ldng(0); }
});
},
render: function (frm, info, probes) {
let pmap = {};
$.each(probes, function (i, p) { pmap[p.component] = p; });
$$.dc('adm_sec', frm).text($adt.sub_info);
$adm.sysCard(frm, info);
$adm.startupCard(frm, info.startupChecks);
$$.dc('adm_sec', frm).text($adt.sub_status);
let grid = $$.dc('adm_grid', frm);
$adm.dbCard(grid, info.database || {}, pmap.database);
$adm.mailCard(grid, info.email || {});
$adm.blobCard(grid, info.blob || {}, pmap.blob);
$adm.mfrCard(grid, info.mfr || {}, pmap.mfr);
$adm.kvCard(grid, info.keyVault || {}, pmap.keyvault);
},
/* ── generic building blocks ─────────────────────────────────────────── */
kvTable: function (parent, rows) {
let t = $$.tbl().addClass('adm_kv');
$.each(rows || [], function (i, r) {
if (!r) { return true; }
let tr = $$.tr(t);
$$.td(tr).addClass('k').text(r[0] == null ? '' : String(r[0]));
let v = $$.td(tr).addClass('v');
if (r[1] instanceof jQuery) { v.append(r[1]); } else { v.text(r[1] == null ? '' : String(r[1])); }
});
if (parent instanceof jQuery) { parent.append(t); }
return t;
},
yesNo: function (b) {
return $$.sc('adm_bool adm_bool_' + (b ? 'y' : 'n'), b ? $adt.yes : $adt.no);
},
setPill: function (pill, probe) {
probe = probe || {};
let st = probe.status || 'unconfigured';
let lbl = {
ok: $adt.st_ok, error: $adt.st_error, disabled: $adt.st_disabled,
unconfigured: $adt.st_unconfigured, checking: $adt.st_checking
}[st] || st;
pill.attr('class', 'adm_pill adm_pill_' + st).text(lbl);
return pill;
},
/* Connectivity card with a live status pill + per-card refresh button. */
probeCard: function (grid, title, component, cfgRows, probe) {
let card = $$.dc('adm_card', grid);
let hd = $$.dc('adm_card_hd', card);
$$.s(title).appendTo(hd);
let pill = $$.sc('adm_pill').appendTo(hd);
let rbtn = $$.bbtn($adt.refresh, 'adm_btn').appendTo(hd);
let bd = $$.dc('adm_card_bd', card);
$adm.kvTable(bd, cfgRows);
let pf = $$.dc('adm_probe', bd);
let msg = $$.dc('adm_probe_msg', pf);
let met = $$.dc('adm_probe_metrics', pf);
let det = $$.dc('adm_probe_det', pf);
let dur = $$.dc('adm_probe_dur', pf);
let apply = function (p) {
p = p || {};
$adm.setPill(pill, p);
msg.text(p.message || '');
met.empty();
$.each(p.metrics || [], function (i, m) {
let row = $$.dc('adm_metric', met);
$$.sc('adm_metric_k').text(m.label || '').appendTo(row);
$$.sc('adm_metric_v').text(m.value == null ? '' : String(m.value)).appendTo(row);
});
det.text(p.detail || '');
dur.text(p.durationMs != null ? (p.durationMs + ' ms') : '');
};
apply(probe);
rbtn.click(function () {
$adm.setPill(pill, { status: 'checking' });
msg.text(''); met.empty(); det.text(''); dur.text('');
$ocms.postXT({
url: $ocms.url('admin/probe/' + component), success: function (r) {
apply(r.probe);
}, error: function () {
$adm.setPill(pill, { status: 'error' });
msg.text($adt.st_error);
}
});
});
return card;
},
/* ── individual cards ────────────────────────────────────────────────── */
sysCard: function (frm, info) {
let card = $$.dc('adm_card adm_card_wide', frm);
let hd = $$.dc('adm_card_hd', card);
$$.s($adt.host).appendTo(hd);
let env = $$.sc('adm_env').text(info.environment || '');
if (info.isTestDeployment === true) { env.aC('adm_env_test'); }
env.appendTo(hd);
let bd = $$.dc('adm_card_bd', card);
$adm.kvTable(bd, [
[$adt.machine, info.machineName],
[$adt.os, info.osDescription],
[$adt.framework, info.frameworkDescription],
[$adt.env, info.environment],
[$adt.testdep, $adm.yesNo(info.isTestDeployment === true)],
[$adt.pid, info.processId],
[$adt.since, info.processStartUtc ? fdt(info.processStartUtc) : ''],
[$adt.uptime, (info.uptimeHours != null ? (info.uptimeHours + ' ' + $adt.hours) : '')]
]);
},
/* Non-refreshable widget: the one-shot startup self-test result captured at boot. */
startupCard: function (frm, sc) {
let card = $$.dc('adm_card adm_card_wide', frm);
let hd = $$.dc('adm_card_hd', card);
$$.s($adt.startup).appendTo(hd);
let bd = $$.dc('adm_card_bd', card);
if (!sc || sc.ran !== true) {
$adm.setPill($$.sc('adm_pill').appendTo(hd), { status: 'disabled' });
$$.dc('adm_note', bd).text($adt.startup_notrun);
return;
}
let items = sc.items || [];
let anyFail = false;
$.each(items, function (i, it) { if (it.enabled === true && it.ok !== true) { anyFail = true; } });
$adm.setPill($$.sc('adm_pill').appendTo(hd), { status: anyFail ? 'error' : 'ok' });
$adm.kvTable(bd, [
[$adt.startup_on, sc.machineName],
[$adt.startup_at, sc.completedUtc ? fdt(sc.completedUtc) : '']
]);
let list = $$.dc('adm_checks', bd);
$.each(items, function (i, it) {
let row = $$.dc('adm_check', list);
$$.sc('adm_check_k').text($adt['chk_' + it.name] || it.name).appendTo(row);
let stt = it.enabled !== true ? 'skipped' : (it.ok === true ? 'ok' : 'fail');
let lbl = { ok: $adt.chk_ok, fail: $adt.chk_fail, skipped: $adt.chk_skipped }[stt];
$$.sc('adm_check_v adm_check_' + stt).text(lbl).appendTo(row);
});
},
dbCard: function (grid, d, probe) {
$adm.probeCard(grid, $adt.db, 'database', [
[$adt.configured, $adm.yesNo(d.configured === true)],
[$adt.server, d.server],
[$adt.catalog, d.catalog],
[$adt.login, d.userId]
], probe);
},
mailCard: function (grid, e) {
let card = $$.dc('adm_card', grid);
let hd = $$.dc('adm_card_hd', card);
$$.s($adt.mail).appendTo(hd);
$adm.setPill($$.sc('adm_pill').appendTo(hd), { status: e.mailerEnabled === true ? 'ok' : 'disabled' });
let bd = $$.dc('adm_card_bd', card);
let rows = [
[$adt.mailer, $adm.yesNo(e.mailerEnabled === true)],
[$adt.baseurl, e.baseUrl],
[$adt.account, e.accountId],
[$adt.serverid, e.serverId],
[$adt.token, $adm.yesNo(e.tokenConfigured === true)],
[$adt.overrideActive, $adm.yesNo(e.overrideActive === true)]
];
if (e.overrideActive === true) { rows.push([$adt.override, e.overrideRecipient]); }
$adm.kvTable(bd, rows);
let ft = $$.dc('adm_card_ft', card);
$$.bbtn($adt.testmail_btn, 'adm_btn adm_btn_primary').appendTo(ft).click($adm.testMailDlg);
},
blobCard: function (grid, b, probe) {
$adm.probeCard(grid, $adt.blob, 'blob', [
[$adt.enabled, $adm.yesNo(b.enabled === true)],
[$adt.configured, $adm.yesNo(b.configured === true)],
[$adt.invContainer, b.invoiceContainer],
[$adt.remContainer, b.reminderContainer]
], probe);
},
mfrCard: function (grid, m, probe) {
$adm.probeCard(grid, $adt.mfr, 'mfr', [
[$adt.host_l, m.host],
[$adt.creds, $adm.yesNo(m.credentialsConfigured === true)],
[$adt.sync, $adm.yesNo(m.syncEnabled === true)]
], probe);
},
kvCard: function (grid, k, probe) {
$adm.probeCard(grid, $adt.kv, 'keyvault', [
[$adt.vault, k.vaultUri],
[$adt.appname, k.appName],
[$adt.seccount, k.managedSecretCount],
[$adt.clientreg, $adm.yesNo(k.clientRegistered === true)]
], probe);
},
/* ── test email ──────────────────────────────────────────────────────── */
testMailDlg: function () {
$ocms.dlgform([
{ name: 'to', label: $adt.tm_to, type: 'email', required: true, value: ($ocms.auth.email || '') },
{ name: 'subject', label: $adt.tm_subject, type: 'string', required: true, value: $adt.tm_default_subject },
{ name: 'body', label: $adt.tm_body, type: 'text', required: true, value: $adt.tm_default_body }
], {
title: $adt.testmail,
button: $adt.testmail_btn,
size: [420, 560],
addcontent: $$.dc('adm_note').text($adt.tm_hint),
submit: function (e) {
let c = $(this).ldng(1);
let qs = c.serializeObject(true, { typedvalues: true });
$ocms.postXT({
url: $ocms.url('admin/testmail'), data: qs, timeout: 60000, success: function (r) {
let res = r.result || {};
alert(res.message || (res.sent ? $adt.tm_sent : $adt.tm_failed));
c.trigger('modal_close');
}, error: function () {
alert($adt.tm_failed);
}, complete: function () { c.ldng(0); }
});
}
});
}
};
let $$adm = { init2: $adm.init2, auth: {} };
export default $$adm;
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
.admfrm{padding:1.5rem}.admfrm .adm_sec{font-size:1.2rem;font-weight:700;color:#1b4379;margin:1.5rem 0 .75rem;border-bottom:2px solid #1b4379;padding-bottom:.25rem}.admfrm .adm_sec:first-child{margin-top:0}.admfrm .adm_err{padding:1rem;color:#b00020;font-weight:700}.admfrm .adm_grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(22rem,1fr));gap:1rem;align-items:start}.admfrm .adm_card{border:1px solid #d5d5d5;border-radius:.4rem;background:#fff;box-shadow:0 1px 3px rgba(50,50,50,.12);overflow:hidden}.admfrm .adm_card.adm_card_wide{grid-column:1/-1}.admfrm .adm_card_hd{display:flex;align-items:center;gap:.5rem;padding:.6rem .85rem;background:#1b4379;color:#fff;font-weight:700}.admfrm .adm_card_hd>span:first-child{flex:1 1 auto}.admfrm .adm_card_bd{padding:.6rem .85rem}.admfrm .adm_card_ft{padding:.5rem .85rem .75rem;text-align:right}.admfrm table.adm_kv{border-collapse:collapse;width:100%}.admfrm table.adm_kv td{padding:.25rem .35rem;border-bottom:1px solid #eee;vertical-align:top;font-size:.92rem}.admfrm table.adm_kv td.k{color:#666;white-space:nowrap;width:40%}.admfrm table.adm_kv td.v{word-break:break-word}.admfrm table.adm_kv tr:last-child td{border-bottom:none}.admfrm .adm_probe{margin-top:.6rem;padding-top:.5rem;border-top:1px dashed #ddd;font-size:.88rem}.admfrm .adm_probe .adm_probe_msg{font-weight:600}.admfrm .adm_probe .adm_probe_det{color:#555;word-break:break-word;margin-top:.15rem}.admfrm .adm_probe .adm_probe_dur{color:#999;margin-top:.15rem;font-size:.8rem}.admfrm .adm_probe .adm_probe_metrics{margin-top:.35rem}.admfrm .adm_probe .adm_probe_metrics .adm_metric{display:flex;justify-content:space-between;gap:.5rem;padding:.1rem 0;border-bottom:1px dotted #eee}.admfrm .adm_probe .adm_probe_metrics .adm_metric:last-child{border-bottom:none}.admfrm .adm_probe .adm_probe_metrics .adm_metric .adm_metric_k{color:#555;word-break:break-word}.admfrm .adm_probe .adm_probe_metrics .adm_metric .adm_metric_v{font-weight:600;white-space:nowrap}.admfrm .adm_checks{margin-top:.5rem}.admfrm .adm_checks .adm_check{display:flex;justify-content:space-between;align-items:center;gap:.5rem;padding:.25rem 0;border-bottom:1px solid #eee}.admfrm .adm_checks .adm_check:last-child{border-bottom:none}.admfrm .adm_checks .adm_check .adm_check_k{color:#333}.admfrm .adm_checks .adm_check .adm_check_v{font-weight:700;font-size:.8rem;padding:.05rem .5rem;border-radius:.25rem;white-space:nowrap}.admfrm .adm_checks .adm_check .adm_check_ok{background:#e2f3da;color:#2f6d18}.admfrm .adm_checks .adm_check .adm_check_fail{background:#f3ded9;color:#c0341d}.admfrm .adm_checks .adm_check .adm_check_skipped{background:#ececec;color:#777}.admfrm .adm_pill{display:inline-block;padding:.1rem .55rem;border-radius:1rem;font-size:.78rem;font-weight:700;color:#fff;background:#999;white-space:nowrap}.admfrm .adm_pill_ok{background:#56a532}.admfrm .adm_pill_error{background:#c0341d}.admfrm .adm_pill_disabled{background:#888}.admfrm .adm_pill_unconfigured{background:#d69100}.admfrm .adm_pill_checking{background:#2a72c4}.admfrm .adm_bool{display:inline-block;padding:0 .4rem;border-radius:.25rem;font-size:.8rem;font-weight:700}.admfrm .adm_bool_y{background:#e2f3da;color:#2f6d18}.admfrm .adm_bool_n{background:#f3e0dd;color:#9c2a17}.admfrm .adm_env{font-size:.8rem;font-weight:400;padding:.1rem .5rem;border-radius:.25rem;background:rgba(255,255,255,.25)}.admfrm .adm_env.adm_env_test{background:#13a143}.admfrm .adm_btn{cursor:pointer;border-radius:.28rem;padding:.2rem .6rem;font-size:.85rem;border:1px solid #ababab;background-color:#fff;color:#333}.admfrm .adm_btn.adm_btn_primary{background-color:#56a532;border-color:rgb(69.68,133.688372093,40.511627907);color:#fff}.admfrm .adm_card_hd .adm_btn{background-color:rgba(255,255,255,.15);color:#fff;border-color:rgba(255,255,255,.4)}.adm_note{margin-top:.75rem;padding:.5rem .6rem;background:#f3f6fb;border-left:3px solid #1b4379;font-size:.85rem;color:#444}
+19
View File
@@ -2842,6 +2842,7 @@ $.extend($t, {
, m_rep: 'Berichte'
, m_todo: 'ToDos'
, m_bcd: 'BankBuchungen'
, m_admin: 'Administration'
, rsp: 'Passwort ändern'
, pnm: 'Die Passwörter stimmen nicht überein'
, cps: 'Das neue Passwort wurde gespeichert.'
@@ -3221,8 +3222,26 @@ $fis.draft = {
//, { lbl: $t.m_efa, id: 'm_efa', fnc: 'init:efa' }
]);
})();
/* The Administration module is restricted to users whose fds_sys authorization exceeds 4.
The button is added — and only then does clicking it load the module script — after the
asynchronous auth check resolves, so lower-privileged users never see it or fetch the code.
Called from fis_main_go.js once the DOM (and the initial main menu) is ready. */
$fis.addAdminMenuIfAuthorized = function () {
if ($('#m_admin').length > 0) { return; } // idempotent
$fis.getAuth('fds_sys').then(function (auth) {
if ((auth || -1) > 4 && $('#m_admin').length === 0) {
$ocms.ocmsmenu.push({ lbl: $t.m_admin, id: 'm_admin', fnc: 'init:admin', ico: 'glyphicon glyphicon-cog' });
/* Re-render the main menu cleanly: drop the previously generated menu list
(never the .nav-right settings dropdown) and rebuild it from the array. */
$('#mainmenu').children('ul:not(.nav-right)').remove();
$('#mainmenu').ocmsmenu($ocms.ocmsmenu);
}
}).catch(function () { /* auth lookup failed → leave the menu unchanged */ });
};
$(document).ready(function () {
$fis.notifications.init();
$fis.draft.init();
$fis.ov();
$fis.addAdminMenuIfAuthorized();
});
+2 -2
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long