feat: add Blazor WASM shell with BFF proxy, login and PWA scaffold

Web host serves the WebAssembly app without prerendering and proxies /api, /hubs and /ics to the Api via YARP. MudBlazor shell with German UI, dark mode, drawer and bottom navigation, login and forced password change, manifest, icons and service worker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
JannisandClaude Opus 5.5 committed 2026-10-09 18:25:10 +02:00
1 parent 80e6ad26a4
commit 6c845615e3
62 files changed
+1724

No files matched your search

@@ -0,0 +1,59 @@
using System.Security.Claims;
using FamilyPlanner.Shared;
using FamilyPlanner.Shared.Auth;
using FamilyPlanner.Web.Client.Services;
using Microsoft.AspNetCore.Components.Authorization;
namespace FamilyPlanner.Web.Client.Auth;
/// <summary>
/// Ermittelt den Anmeldezustand über <c>GET /api/auth/me</c>. Das Auth-Cookie selbst ist HttpOnly und
/// für die App unsichtbar (ADR-0002).
/// </summary>
public sealed class ApiAuthenticationStateProvider(IAuthService authService) : AuthenticationStateProvider
{
public const string AuthenticationType = "FamilyPlanner";
private Task<AuthenticationState>? _state;
public CurrentUserDto? CurrentUser { get; private set; }
public override Task<AuthenticationState> GetAuthenticationStateAsync() => _state ??= LoadAsync();
/// <summary>Nach Login, Logout oder Passwortwechsel aufrufen.</summary>
public void SetUser(CurrentUserDto? user)
{
CurrentUser = user;
_state = Task.FromResult(new AuthenticationState(BuildPrincipal(user)));
NotifyAuthenticationStateChanged(_state);
}
public static ClaimsPrincipal BuildPrincipal(CurrentUserDto? user)
{
if (user is null)
{
return new ClaimsPrincipal(new ClaimsIdentity());
}
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
new(ClaimTypes.Name, user.UserName),
new(ApiConventions.DisplayNameClaim, user.DisplayName),
new(ApiConventions.ColorClaim, user.Color),
};
claims.AddRange(user.Roles.Select(role => new Claim(ClaimTypes.Role, role)));
if (user.MustChangePassword)
{
claims.Add(new Claim(ApiConventions.MustChangePasswordClaim, "true"));
}
return new ClaimsPrincipal(new ClaimsIdentity(claims, AuthenticationType));
}
private async Task<AuthenticationState> LoadAsync()
{
CurrentUser = await authService.GetCurrentUserAsync();
return new AuthenticationState(BuildPrincipal(CurrentUser));
}
}
@@ -0,0 +1,32 @@
using FamilyPlanner.Domain.Authorization;
using FamilyPlanner.Shared;
using Microsoft.AspNetCore.Authorization;
namespace FamilyPlanner.Web.Client.Auth;
/// <summary>
/// Dieselben Policies wie in der Api – im Client nur zum Ein-/Ausblenden von UI-Elementen.
/// Erzwungen werden sie serverseitig.
/// </summary>
public static class AuthorizationSetup
{
public static IServiceCollection AddFamilyPlannerAuthorization(this IServiceCollection services)
{
services.AddAuthorizationCore(options =>
{
var passwordChanged = PasswordChangedPolicy();
options.AddPolicy(Policies.PasswordChanged, passwordChanged);
foreach (var (policy, roles) in PolicyCatalog.RoleBasedPolicies)
{
options.AddPolicy(policy, p => p.Combine(passwordChanged).RequireRole(roles));
}
});
return services;
}
private static AuthorizationPolicy PasswordChangedPolicy() =>
new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.RequireAssertion(ctx => !ctx.User.HasClaim(c => c.Type == ApiConventions.MustChangePasswordClaim))
.Build();
}
@@ -0,0 +1,20 @@
<MudPaper Class="pa-6 d-flex flex-column align-center text-center" Elevation="0" Outlined="true">
<MudIcon Icon="@Icon" Color="Color.Primary" Style="font-size:4rem" />
<MudText Typo="Typo.h6" Class="mt-2">@Title</MudText>
<MudText Typo="Typo.body2" Class="mt-1">@Description</MudText>
<MudChip T="string" Size="Size.Small" Color="Color.Default" Class="mt-3">Kommt in Phase @Phase</MudChip>
</MudPaper>
@code {
[Parameter, EditorRequired]
public string Title { get; set; } = string.Empty;
[Parameter]
public string Description { get; set; } = string.Empty;
[Parameter]
public string Icon { get; set; } = Icons.Material.Filled.Construction;
[Parameter]
public int Phase { get; set; }
}
@@ -0,0 +1,9 @@
<div class="d-flex justify-center align-center pa-8" role="status" aria-live="polite">
<MudProgressCircular Color="Color.Primary" Indeterminate="true" />
<span class="ml-3">@Text</span>
</div>
@code {
[Parameter]
public string Text { get; set; } = "Wird geladen …";
}
@@ -0,0 +1,36 @@
@inject NavigationManager Navigation
@if (_showForbidden)
{
<MudAlert Severity="Severity.Info" Class="ma-4" role="alert">
Für diesen Bereich fehlt dir die Berechtigung.
<MudLink Href="" Class="ml-2">Zur Übersicht</MudLink>
</MudAlert>
}
@code {
[CascadingParameter]
private Task<AuthenticationState>? AuthenticationState { get; set; }
private bool _showForbidden;
protected override async Task OnParametersSetAsync()
{
var user = AuthenticationState is null ? null : (await AuthenticationState).User;
if (user?.Identity?.IsAuthenticated != true)
{
var returnUrl = "/" + Navigation.ToBaseRelativePath(Navigation.Uri);
Navigation.NavigateTo($"login?returnUrl={Uri.EscapeDataString(returnUrl)}", replace: true);
return;
}
if (user.HasClaim(c => c.Type == ApiConventions.MustChangePasswordClaim))
{
Navigation.NavigateTo("konto/passwort", replace: true);
return;
}
_showForbidden = true;
}
}
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<RootNamespace>FamilyPlanner.Web.Client</RootNamespace>
<NoDefaultLaunchSettingsFile>true</NoDefaultLaunchSettingsFile>
<StaticWebAssetProjectMode>Default</StaticWebAssetProjectMode>
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
<BlazorWebAssemblyLoadAllGlobalizationData>true</BlazorWebAssemblyLoadAllGlobalizationData>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" />
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Localization" />
<PackageReference Include="MudBlazor" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\FamilyPlanner.Domain\FamilyPlanner.Domain.csproj" />
<ProjectReference Include="..\FamilyPlanner.Shared\FamilyPlanner.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="FamilyPlanner.Web.Tests" />
</ItemGroup>
</Project>
@@ -0,0 +1,30 @@
@implements IDisposable
@inject ThemeState ThemeState
<MudThemeProvider @ref="_themeProvider" Theme="FamilyTheme.Theme" IsDarkMode="ThemeState.IsDarkMode" />
<MudPopoverProvider />
<MudDialogProvider />
<MudSnackbarProvider />
@code {
private MudThemeProvider? _themeProvider;
protected override void OnInitialized() => ThemeState.Changed += OnThemeChanged;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && _themeProvider is not null)
{
await ThemeState.InitializeAsync(await _themeProvider.GetSystemDarkModeAsync());
await _themeProvider.WatchSystemDarkModeAsync(isDark =>
{
ThemeState.SetSystemDarkMode(isDark);
return Task.CompletedTask;
});
}
}
private void OnThemeChanged() => InvokeAsync(StateHasChanged);
public void Dispose() => ThemeState.Changed -= OnThemeChanged;
}
@@ -0,0 +1,13 @@
@* Nur auf kleinen Bildschirmen: große Touch-Ziele für die wichtigsten Bereiche. *@
<MudHidden Breakpoint="Breakpoint.SmAndDown" Invert="true">
<nav class="fp-bottom-nav mud-elevation-4" aria-label="Schnellnavigation">
@foreach (var item in NavItems.All.Where(i => i.InBottomNav))
{
<NavLink href="@item.Href" class="fp-bottom-nav-item"
Match="@(item.Href.Length == 0 ? NavLinkMatch.All : NavLinkMatch.Prefix)">
<MudIcon Icon="@item.Icon" />
<span>@item.ShortTitle</span>
</NavLink>
}
</nav>
</MudHidden>
@@ -0,0 +1,36 @@
using MudBlazor;
namespace FamilyPlanner.Web.Client.Layout;
public static class FamilyTheme
{
private static readonly string[] FontStack =
["system-ui", "-apple-system", "Segoe UI", "Roboto", "Helvetica Neue", "Arial", "sans-serif"];
public static MudTheme Theme { get; } = new()
{
PaletteLight = new PaletteLight
{
Primary = "#1565C0",
Secondary = "#FF7043",
Tertiary = "#43A047",
AppbarBackground = "#1565C0",
Background = "#F7F9FC",
},
PaletteDark = new PaletteDark
{
Primary = "#90CAF9",
Secondary = "#FF8A65",
Tertiary = "#81C784",
AppbarBackground = "#1E1E2D",
},
Typography = new Typography
{
Default = new DefaultTypography { FontFamily = FontStack },
},
LayoutProperties = new LayoutProperties
{
DefaultBorderRadius = "12px",
},
};
}
@@ -0,0 +1,8 @@
@inherits LayoutComponentBase
<MudLayout Class="fp-app">
<MudMainContent Class="fp-login-main">
<OfflineBanner />
@Body
</MudMainContent>
</MudLayout>
@@ -0,0 +1,43 @@
@inherits LayoutComponentBase
@implements IDisposable
@inject ThemeState ThemeState
<MudLayout Class="fp-app">
<MudAppBar Elevation="1">
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start"
OnClick="ToggleDrawer" aria-label="Menü öffnen" />
<MudText Typo="Typo.h6" Class="ml-2 fp-app-title">Familienplaner</MudText>
<MudSpacer />
<MudTooltip Text="@(ThemeState.IsDarkMode ? "Helles Design" : "Dunkles Design")">
<MudIconButton Icon="@(ThemeState.IsDarkMode ? Icons.Material.Filled.LightMode : Icons.Material.Filled.DarkMode)"
Color="Color.Inherit" OnClick="ThemeState.ToggleAsync"
aria-label="@(ThemeState.IsDarkMode ? "Helles Design einschalten" : "Dunkles Design einschalten")" />
</MudTooltip>
<UserMenu />
</MudAppBar>
<MudDrawer @bind-Open="_drawerOpen" ClipMode="DrawerClipMode.Always" Variant="DrawerVariant.Responsive" Elevation="1">
<NavMenu />
</MudDrawer>
<MudMainContent Class="fp-main">
<OfflineBanner />
<MudContainer MaxWidth="MaxWidth.Large" Class="py-4">
@Body
</MudContainer>
</MudMainContent>
<BottomNav />
</MudLayout>
@code {
private bool _drawerOpen = true;
protected override void OnInitialized() => ThemeState.Changed += OnThemeChanged;
private void ToggleDrawer() => _drawerOpen = !_drawerOpen;
private void OnThemeChanged() => InvokeAsync(StateHasChanged);
public void Dispose() => ThemeState.Changed -= OnThemeChanged;
}
@@ -0,0 +1,21 @@
using FamilyPlanner.Domain.Authorization;
using MudBlazor;
namespace FamilyPlanner.Web.Client.Layout;
public sealed record NavItem(string Href, string Title, string ShortTitle, string Icon, bool InBottomNav, string? Policy = null);
/// <summary>Zentrale Navigationseinträge für Drawer (Desktop) und Bottom-Navigation (Smartphone).</summary>
public static class NavItems
{
public static IReadOnlyList<NavItem> All { get; } =
[
new("", "Übersicht", "Start", Icons.Material.Filled.Dashboard, InBottomNav: true),
new("einkauf", "Einkaufsliste", "Einkauf", Icons.Material.Filled.ShoppingCart, InBottomNav: true),
new("essen", "Essenplan", "Essen", Icons.Material.Filled.Restaurant, InBottomNav: false),
new("aufgaben", "Aufgaben", "Aufgaben", Icons.Material.Filled.TaskAlt, InBottomNav: true),
new("kalender", "Kalender", "Kalender", Icons.Material.Filled.CalendarMonth, InBottomNav: true),
new("chat", "Chat", "Chat", Icons.Material.Filled.Chat, InBottomNav: true),
new("admin", "Verwaltung", "Admin", Icons.Material.Filled.AdminPanelSettings, InBottomNav: false, Policies.Admin),
];
}
@@ -0,0 +1,17 @@
<MudNavMenu Class="pt-2" aria-label="Hauptnavigation">
@foreach (var item in NavItems.All)
{
if (item.Policy is null)
{
<MudNavLink Href="@item.Href" Icon="@item.Icon"
Match="@(item.Href.Length == 0 ? NavLinkMatch.All : NavLinkMatch.Prefix)">@item.Title</MudNavLink>
}
else
{
<AuthorizeView Policy="@item.Policy">
<MudDivider Class="my-2" />
<MudNavLink Href="@item.Href" Icon="@item.Icon" Match="NavLinkMatch.Prefix">@item.Title</MudNavLink>
</AuthorizeView>
}
}
</MudNavMenu>
@@ -0,0 +1,60 @@
@implements IAsyncDisposable
@inject IJSRuntime JS
@if (!_online)
{
<MudAlert Severity="Severity.Warning" Dense="true" Square="true" Icon="@Icons.Material.Filled.CloudOff" role="status">
Du bist offline. Angezeigte Daten sind eventuell nicht aktuell.
</MudAlert>
}
@code {
private bool _online = true;
private DotNetObjectReference<OfflineBanner>? _selfReference;
private IJSObjectReference? _subscription;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender)
{
return;
}
try
{
_selfReference = DotNetObjectReference.Create(this);
_subscription = await JS.InvokeAsync<IJSObjectReference>("familyPlanner.network.subscribe", _selfReference);
_online = await JS.InvokeAsync<bool>("familyPlanner.network.isOnline");
StateHasChanged();
}
catch (JSException)
{
// pwa.js nicht geladen (z. B. in Tests) – Banner bleibt aus.
}
}
[JSInvokable]
public void SetOnline(bool online)
{
_online = online;
StateHasChanged();
}
public async ValueTask DisposeAsync()
{
if (_subscription is not null)
{
try
{
await _subscription.InvokeVoidAsync("dispose");
await _subscription.DisposeAsync();
}
catch (JSDisconnectedException)
{
// Seite wird entladen.
}
}
_selfReference?.Dispose();
}
}
@@ -0,0 +1,56 @@
@inject IAuthService AuthService
@inject ApiAuthenticationStateProvider AuthState
@inject NavigationManager Navigation
<AuthorizeView>
<Authorized Context="auth">
<MudMenu AnchorOrigin="Origin.BottomRight" TransformOrigin="Origin.TopRight" aria-label="Benutzermenü">
<ActivatorContent>
<MudAvatar Size="Size.Medium" Class="fp-user-avatar"
Style="@($"background-color:{ColorOf(auth.User)};color:#fff;cursor:pointer")">
@Initials(DisplayNameOf(auth.User))
</MudAvatar>
</ActivatorContent>
<ChildContent>
<div class="px-4 py-2">
<MudText Typo="Typo.subtitle2">@DisplayNameOf(auth.User)</MudText>
<MudText Typo="Typo.caption">@string.Join(", ", RolesOf(auth.User))</MudText>
</div>
<MudDivider />
<MudMenuItem Icon="@Icons.Material.Filled.Password" OnClick="@(() => Navigation.NavigateTo("konto/passwort"))">
Passwort ändern
</MudMenuItem>
<MudMenuItem Icon="@Icons.Material.Filled.Logout" OnClick="LogoutAsync">Abmelden</MudMenuItem>
</ChildContent>
</MudMenu>
</Authorized>
</AuthorizeView>
@code {
private static string DisplayNameOf(System.Security.Claims.ClaimsPrincipal user) =>
user.FindFirst(ApiConventions.DisplayNameClaim)?.Value ?? user.Identity?.Name ?? "?";
private static string ColorOf(System.Security.Claims.ClaimsPrincipal user) =>
user.FindFirst(ApiConventions.ColorClaim)?.Value ?? "#1E88E5";
private static IEnumerable<string> RolesOf(System.Security.Claims.ClaimsPrincipal user) =>
user.FindAll(System.Security.Claims.ClaimTypes.Role).Select(c => Roles.DisplayName(c.Value));
internal static string Initials(string name)
{
var parts = name.Split(' ', StringSplitOptions.RemoveEmptyEntries);
return parts.Length switch
{
0 => "?",
1 => parts[0][..1].ToUpperInvariant(),
_ => string.Concat(parts[0][..1], parts[^1][..1]).ToUpperInvariant(),
};
}
private async Task LogoutAsync()
{
await AuthService.LogoutAsync();
AuthState.SetUser(null);
Navigation.NavigateTo("login", replace: true);
}
}
@@ -0,0 +1,21 @@
@page "/admin"
@attribute [Authorize(Policy = Policies.Admin)]
<PageTitle>Verwaltung – Familienplaner</PageTitle>
<MudText Typo="Typo.h4" HtmlTag="h1" Class="mb-4">Verwaltung</MudText>
<MudGrid Spacing="3">
<MudItem xs="12" md="4">
<ComingSoon Title="Benutzer" Phase="2" Icon="@Icons.Material.Filled.People"
Description="Familienmitglieder und Gäste anlegen, Rollen und Farben vergeben, Passwörter zurücksetzen." />
</MudItem>
<MudItem xs="12" md="4">
<ComingSoon Title="Einstellungen" Phase="2" Icon="@Icons.Material.Filled.Settings"
Description="Familienname, Essenplan, Aufgaben-Kategorien, Punktesystem und Gast-Rechte." />
</MudItem>
<MudItem xs="12" md="4">
<ComingSoon Title="Kalender" Phase="6" Icon="@Icons.Material.Filled.EditCalendar"
Description="Kalender anlegen, Berechtigungen festlegen, ICS-Abos verwalten und importieren." />
</MudItem>
</MudGrid>
@@ -0,0 +1,7 @@
@page "/kalender"
<PageTitle>Kalender – Familienplaner</PageTitle>
<MudText Typo="Typo.h4" HtmlTag="h1" Class="mb-4">Kalender</MudText>
<ComingSoon Title="Familienkalender" Phase="6" Icon="@Icons.Material.Filled.CalendarMonth"
Description="Termine für alle, Farben je Person, Wiederholungen und Abo für iPhone und Outlook." />
@@ -0,0 +1,7 @@
@page "/chat"
<PageTitle>Chat – Familienplaner</PageTitle>
<MudText Typo="Typo.h4" HtmlTag="h1" Class="mb-4">Chat</MudText>
<ComingSoon Title="Familienchat" Phase="8" Icon="@Icons.Material.Filled.Chat"
Description="Gruppenchat für die Familie und Direktnachrichten – in Echtzeit." />
@@ -0,0 +1,56 @@
@page "/"
@inject ApiAuthenticationStateProvider AuthState
@inject TimeProvider TimeProvider
<PageTitle>Übersicht – Familienplaner</PageTitle>
<MudText Typo="Typo.h4" HtmlTag="h1">@Greeting, @(AuthState.CurrentUser?.DisplayName ?? "")!</MudText>
<MudText Typo="Typo.subtitle1" Class="mb-4">@Today.ToString("dddd, d. MMMM yyyy")</MudText>
<MudGrid Spacing="3">
@foreach (var tile in Tiles)
{
<MudItem xs="12" sm="6" lg="4">
<MudCard Outlined="true" Class="fp-tile">
<MudCardHeader>
<CardHeaderAvatar>
<MudIcon Icon="@tile.Icon" Color="Color.Primary" />
</CardHeaderAvatar>
<CardHeaderContent>
<MudText Typo="Typo.h6" HtmlTag="h2">@tile.Title</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudText Typo="Typo.body2">@tile.Text</MudText>
</MudCardContent>
<MudCardActions>
<MudButton Href="@tile.Href" Variant="Variant.Text" Color="Color.Primary">Öffnen</MudButton>
</MudCardActions>
</MudCard>
</MudItem>
}
</MudGrid>
@code {
private sealed record Tile(string Title, string Text, string Icon, string Href);
private static readonly Tile[] Tiles =
[
new("Heute zu essen", "Der Essenplan folgt in Phase 4.", Icons.Material.Filled.Restaurant, "essen"),
new("Termine heute", "Der Familienkalender folgt in Phase 6.", Icons.Material.Filled.Event, "kalender"),
new("Einkaufsliste", "Die Einkaufsliste folgt in Phase 3.", Icons.Material.Filled.ShoppingCart, "einkauf"),
new("Meine Aufgaben", "Aufgaben und Ämtchen folgen in Phase 5.", Icons.Material.Filled.TaskAlt, "aufgaben"),
new("Nachrichten", "Der Familienchat folgt in Phase 8.", Icons.Material.Filled.Chat, "chat"),
];
private DateTime Now => TimeZoneInfo.ConvertTime(TimeProvider.GetUtcNow(), FamilyPlanner.Domain.Common.FamilyTime.Berlin).DateTime;
private DateTime Today => Now.Date;
private string Greeting => Now.Hour switch
{
< 11 => "Guten Morgen",
< 18 => "Hallo",
_ => "Guten Abend",
};
}
@@ -0,0 +1,7 @@
@page "/essen"
<PageTitle>Essenplan – Familienplaner</PageTitle>
<MudText Typo="Typo.h4" HtmlTag="h1" Class="mb-4">Essenplan</MudText>
<ComingSoon Title="Essenplan" Phase="4" Icon="@Icons.Material.Filled.Restaurant"
Description="Gerichte für die nächsten Tage planen, Wünsche der Kinder übernehmen und Zutaten auf die Einkaufsliste setzen." />
@@ -0,0 +1,7 @@
@page "/einkauf"
<PageTitle>Einkaufsliste – Familienplaner</PageTitle>
<MudText Typo="Typo.h4" HtmlTag="h1" Class="mb-4">Einkaufsliste</MudText>
<ComingSoon Title="Einkaufsliste" Phase="3" Icon="@Icons.Material.Filled.ShoppingCart"
Description="Artikel mit einem Feld erfassen, abhaken, in Echtzeit mit der Familie teilen – auch offline." />
@@ -0,0 +1,7 @@
@page "/aufgaben"
<PageTitle>Aufgaben – Familienplaner</PageTitle>
<MudText Typo="Typo.h4" HtmlTag="h1" Class="mb-4">Aufgaben</MudText>
<ComingSoon Title="Aufgaben &amp; Ämtchen" Phase="5" Icon="@Icons.Material.Filled.TaskAlt"
Description="Aufgaben zuweisen, wiederkehrend planen und abhaken – mit kindgerechter Ämtchen-Ansicht und Punkten." />
@@ -0,0 +1,2 @@
@* Alle Seiten in diesem Ordner erfordern eine Anmeldung mit erledigtem Passwortwechsel. *@
@attribute [Authorize(Policy = Policies.PasswordChanged)]
+37
View File
@@ -0,0 +1,37 @@
using System.Globalization;
using FamilyPlanner.Web.Client.Auth;
using FamilyPlanner.Web.Client.Services;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using MudBlazor;
using MudBlazor.Services;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
// UI-Sprache Deutsch; Texte sind für spätere Lokalisierung über IStringLocalizer vorbereitet.
var culture = new CultureInfo("de-DE");
CultureInfo.DefaultThreadCurrentCulture = culture;
CultureInfo.DefaultThreadCurrentUICulture = culture;
builder.Services.AddLocalization();
builder.Services.AddMudServices(config =>
{
config.SnackbarConfiguration.PositionClass = Defaults.Classes.Position.BottomCenter;
config.SnackbarConfiguration.VisibleStateDuration = 4000;
});
builder.Services.AddTransient<CsrfHeaderHandler>();
builder.Services.AddHttpClient(ApiClient.Name, client => client.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress))
.AddHttpMessageHandler<CsrfHeaderHandler>();
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient(ApiClient.Name));
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<ApiAuthenticationStateProvider>();
builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<ApiAuthenticationStateProvider>());
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddFamilyPlannerAuthorization();
builder.Services.AddScoped<ThemeState>();
builder.Services.AddSingleton(TimeProvider.System);
await builder.Build().RunAsync();
@@ -0,0 +1,123 @@
@page "/konto/passwort"
@attribute [Authorize]
@layout LoginLayout
@inject IAuthService AuthService
@inject ApiAuthenticationStateProvider AuthState
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
<PageTitle>Passwort ändern – Familienplaner</PageTitle>
<MudCard Elevation="3" Class="fp-login-card">
<MudCardContent>
<MudText Typo="Typo.h5" HtmlTag="h1" Class="mb-2">Passwort ändern</MudText>
@if (IsForced)
{
<MudAlert Severity="Severity.Info" Class="mb-4">
Bitte vergib zuerst ein eigenes Passwort. Danach geht es direkt los.
</MudAlert>
}
<EditForm Model="_model" OnSubmit="SaveAsync" FormName="change-password">
<MudStack Spacing="3">
@if (_error is not null)
{
<MudAlert Severity="Severity.Error" role="alert">@_error</MudAlert>
}
<MudTextField @bind-Value="_model.CurrentPassword" Label="Aktuelles Passwort" InputId="current-password"
InputType="InputType.Password" Variant="Variant.Outlined" Disabled="_busy" AutoFocus="true" />
<MudTextField @bind-Value="_model.NewPassword" Label="Neues Passwort" InputId="new-password"
InputType="InputType.Password" Variant="Variant.Outlined" Disabled="_busy"
HelperText="Mindestens 8 Zeichen. Ein Satz ist leichter zu merken als kryptische Zeichen." />
<MudTextField @bind-Value="_model.ConfirmPassword" Label="Neues Passwort wiederholen" InputId="confirm-password"
InputType="InputType.Password" Variant="Variant.Outlined" Disabled="_busy" />
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary"
Size="Size.Large" FullWidth="true" Disabled="_busy">
@(_busy ? "Speichern …" : "Passwort speichern")
</MudButton>
@if (IsForced)
{
<MudButton Variant="Variant.Text" OnClick="LogoutAsync" FullWidth="true">Abmelden</MudButton>
}
else
{
<MudButton Variant="Variant.Text" Href="" FullWidth="true">Abbrechen</MudButton>
}
</MudStack>
</EditForm>
</MudCardContent>
</MudCard>
@code {
private const int MinimumLength = 8;
private readonly ChangePasswordModel _model = new();
private string? _error;
private bool _busy;
private bool IsForced => AuthState.CurrentUser?.MustChangePassword == true;
private async Task SaveAsync()
{
_error = Validate();
if (_error is not null)
{
return;
}
_busy = true;
var result = await AuthService.ChangePasswordAsync(new ChangePasswordRequest(_model.CurrentPassword, _model.NewPassword));
_busy = false;
if (!result.Succeeded || result.User is null)
{
_error = result.Error ?? "Das Passwort konnte nicht geändert werden.";
return;
}
AuthState.SetUser(result.User);
Snackbar.Add("Passwort geändert.", Severity.Success);
Navigation.NavigateTo("", replace: true);
}
private string? Validate()
{
if (string.IsNullOrEmpty(_model.CurrentPassword))
{
return "Bitte das aktuelle Passwort eingeben.";
}
if (_model.NewPassword.Length < MinimumLength)
{
return $"Das neue Passwort muss mindestens {MinimumLength} Zeichen lang sein.";
}
if (_model.NewPassword != _model.ConfirmPassword)
{
return "Die neuen Passwörter stimmen nicht überein.";
}
return _model.NewPassword == _model.CurrentPassword
? "Das neue Passwort muss sich vom bisherigen unterscheiden."
: null;
}
private async Task LogoutAsync()
{
await AuthService.LogoutAsync();
AuthState.SetUser(null);
Navigation.NavigateTo("login", replace: true);
}
private sealed class ChangePasswordModel
{
public string CurrentPassword { get; set; } = string.Empty;
public string NewPassword { get; set; } = string.Empty;
public string ConfirmPassword { get; set; } = string.Empty;
}
}
@@ -0,0 +1,113 @@
@page "/login"
@attribute [AllowAnonymous]
@layout LoginLayout
@inject IAuthService AuthService
@inject ApiAuthenticationStateProvider AuthState
@inject NavigationManager Navigation
<PageTitle>Anmelden – Familienplaner</PageTitle>
<MudCard Elevation="3" Class="fp-login-card">
<MudCardContent>
<div class="d-flex flex-column align-center mb-4">
<img src="icons/icon.svg" alt="" width="72" height="72" />
<MudText Typo="Typo.h5" HtmlTag="h1" Class="mt-2">Familienplaner</MudText>
<MudText Typo="Typo.body2">Schön, dass du da bist! Bitte melde dich an.</MudText>
</div>
<EditForm Model="_model" OnSubmit="LoginAsync" FormName="login">
<MudStack Spacing="3">
@if (_error is not null)
{
<MudAlert Severity="Severity.Error" Class="fp-login-error" role="alert">@_error</MudAlert>
}
<MudTextField @bind-Value="_model.UserName" Label="Benutzername oder E-Mail" InputId="username"
Variant="Variant.Outlined" AutoFocus="true" Disabled="_busy" />
<MudTextField @bind-Value="_model.Password" Label="Passwort" InputId="password"
InputType="@(_showPassword ? InputType.Text : InputType.Password)"
Variant="Variant.Outlined" Disabled="_busy"
Adornment="Adornment.End"
AdornmentIcon="@(_showPassword ? Icons.Material.Filled.VisibilityOff : Icons.Material.Filled.Visibility)"
AdornmentAriaLabel="@(_showPassword ? "Passwort verbergen" : "Passwort anzeigen")"
OnAdornmentClick="() => _showPassword = !_showPassword" />
<MudCheckBox @bind-Value="_model.RememberMe" Label="Angemeldet bleiben" Color="Color.Primary" Disabled="_busy" />
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary"
Size="Size.Large" FullWidth="true" Disabled="_busy" Class="fp-login-submit">
@(_busy ? "Anmelden …" : "Anmelden")
</MudButton>
</MudStack>
</EditForm>
<MudText Typo="Typo.caption" Class="d-block mt-4 text-center">
Noch keinen Zugang? Frag den Administrator deiner Familie.
</MudText>
</MudCardContent>
</MudCard>
@code {
private readonly LoginModel _model = new();
private string? _error;
private bool _busy;
private bool _showPassword;
[SupplyParameterFromQuery]
public string? ReturnUrl { get; set; }
[CascadingParameter]
private Task<AuthenticationState>? AuthenticationState { get; set; }
protected override async Task OnInitializedAsync()
{
if (AuthenticationState is not null && (await AuthenticationState).User.Identity?.IsAuthenticated == true)
{
Navigation.NavigateTo(SafeReturnUrl(ReturnUrl), replace: true);
}
}
private async Task LoginAsync()
{
if (string.IsNullOrWhiteSpace(_model.UserName) || string.IsNullOrEmpty(_model.Password))
{
_error = "Bitte Benutzername/E-Mail und Passwort eingeben.";
return;
}
_busy = true;
_error = null;
var result = await AuthService.LoginAsync(new LoginRequest(_model.UserName.Trim(), _model.Password, _model.RememberMe));
_busy = false;
if (!result.Succeeded || result.User is null)
{
_error = result.Error ?? "Anmeldung fehlgeschlagen.";
_model.Password = string.Empty;
return;
}
AuthState.SetUser(result.User);
Navigation.NavigateTo(result.User.MustChangePassword ? "konto/passwort" : SafeReturnUrl(ReturnUrl), replace: true);
}
/// <summary>Nur lokale Pfade zulassen (kein Open Redirect).</summary>
internal static string SafeReturnUrl(string? returnUrl) =>
!string.IsNullOrWhiteSpace(returnUrl)
&& returnUrl.StartsWith('/')
&& !returnUrl.StartsWith("//", StringComparison.Ordinal)
&& !returnUrl.StartsWith("/\\", StringComparison.Ordinal)
&& !returnUrl.StartsWith("/login", StringComparison.OrdinalIgnoreCase)
? returnUrl
: "/";
private sealed class LoginModel
{
public string UserName { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public bool RememberMe { get; set; }
}
}
@@ -0,0 +1,12 @@
@page "/not-found"
@attribute [AllowAnonymous]
@layout LoginLayout
<PageTitle>Nicht gefunden – Familienplaner</PageTitle>
<MudPaper Class="fp-login-card pa-6 text-center" Elevation="2">
<MudIcon Icon="@Icons.Material.Filled.SearchOff" Color="Color.Primary" Style="font-size:4rem" />
<MudText Typo="Typo.h5" HtmlTag="h1" Class="mt-2">Seite nicht gefunden</MudText>
<MudText Typo="Typo.body2" Class="mt-2">Diese Seite gibt es leider nicht.</MudText>
<MudButton Href="" Variant="Variant.Filled" Color="Color.Primary" Class="mt-4">Zur Übersicht</MudButton>
</MudPaper>
+15
View File
@@ -0,0 +1,15 @@
<AppProviders />
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Public.NotFound)">
<Found Context="routeData">
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(MainLayout)">
<Authorizing>
<LoadingIndicator />
</Authorizing>
<NotAuthorized>
<NotAuthorizedHandler />
</NotAuthorized>
</AuthorizeRouteView>
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>
@@ -0,0 +1,56 @@
using System.Net.Http.Json;
using FamilyPlanner.Shared;
namespace FamilyPlanner.Web.Client.Services;
public static class ApiClient
{
public const string Name = "api";
/// <summary>Liest eine RFC-7807-Fehlermeldung und liefert einen anzeigbaren deutschen Text.</summary>
public static async Task<ApiProblem> ReadProblemAsync(HttpResponseMessage response, CancellationToken cancellationToken = default)
{
ApiProblem? problem = null;
try
{
problem = await response.Content.ReadFromJsonAsync<ApiProblem>(cancellationToken);
}
catch (Exception ex) when (ex is System.Text.Json.JsonException or NotSupportedException)
{
// Kein JSON (z. B. Proxy-Fehlerseite).
}
return problem ?? new ApiProblem { Status = (int)response.StatusCode };
}
}
public sealed class ApiProblem
{
public int? Status { get; set; }
public string? Title { get; set; }
public string? Detail { get; set; }
public Dictionary<string, string[]>? Errors { get; set; }
public string Message =>
Errors is { Count: > 0 }
? string.Join(" ", Errors.Values.SelectMany(v => v))
: Detail ?? Title ?? Status switch
{
429 => "Zu viele Anfragen. Bitte kurz warten.",
>= 500 => "Der Server ist gerade nicht erreichbar. Bitte später erneut versuchen.",
_ => "Die Anfrage ist fehlgeschlagen.",
};
}
/// <summary>Setzt den CSRF-Header für alle Requests an die eigene Api (ADR-0006).</summary>
public sealed class CsrfHeaderHandler : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
request.Headers.TryAddWithoutValidation(ApiConventions.CsrfHeaderName, ApiConventions.CsrfHeaderValue);
return base.SendAsync(request, cancellationToken);
}
}
@@ -0,0 +1,81 @@
using System.Net;
using System.Net.Http.Json;
using FamilyPlanner.Shared.Auth;
namespace FamilyPlanner.Web.Client.Services;
public sealed record AuthResult(bool Succeeded, CurrentUserDto? User, string? Error)
{
public static AuthResult Success(CurrentUserDto user) => new(true, user, null);
public static AuthResult Failure(string error) => new(false, null, error);
}
public interface IAuthService
{
Task<CurrentUserDto?> GetCurrentUserAsync(CancellationToken cancellationToken = default);
Task<AuthResult> LoginAsync(LoginRequest request, CancellationToken cancellationToken = default);
Task LogoutAsync(CancellationToken cancellationToken = default);
Task<AuthResult> ChangePasswordAsync(ChangePasswordRequest request, CancellationToken cancellationToken = default);
}
public sealed class AuthService(HttpClient http) : IAuthService
{
private const string OfflineMessage = "Keine Verbindung zum Server. Bitte Internetverbindung prüfen.";
public async Task<CurrentUserDto?> GetCurrentUserAsync(CancellationToken cancellationToken = default)
{
try
{
using var response = await http.GetAsync("api/auth/me", cancellationToken);
return response.StatusCode == HttpStatusCode.OK
? await response.Content.ReadFromJsonAsync<CurrentUserDto>(cancellationToken)
: null;
}
catch (HttpRequestException)
{
return null;
}
}
public Task<AuthResult> LoginAsync(LoginRequest request, CancellationToken cancellationToken = default) =>
PostForUserAsync("api/auth/login", request, cancellationToken);
public async Task LogoutAsync(CancellationToken cancellationToken = default)
{
try
{
using var response = await http.PostAsync("api/auth/logout", content: null, cancellationToken);
}
catch (HttpRequestException)
{
// Offline: lokaler Zustand wird trotzdem zurückgesetzt.
}
}
public Task<AuthResult> ChangePasswordAsync(ChangePasswordRequest request, CancellationToken cancellationToken = default) =>
PostForUserAsync("api/auth/change-password", request, cancellationToken);
private async Task<AuthResult> PostForUserAsync<TRequest>(string uri, TRequest request, CancellationToken cancellationToken)
{
try
{
using var response = await http.PostAsJsonAsync(uri, request, cancellationToken);
if (response.IsSuccessStatusCode)
{
var user = await response.Content.ReadFromJsonAsync<CurrentUserDto>(cancellationToken);
return user is null ? AuthResult.Failure("Unerwartete Antwort vom Server.") : AuthResult.Success(user);
}
var problem = await ApiClient.ReadProblemAsync(response, cancellationToken);
return AuthResult.Failure(problem.Message);
}
catch (HttpRequestException)
{
return AuthResult.Failure(OfflineMessage);
}
}
}
@@ -0,0 +1,48 @@
using Microsoft.JSInterop;
namespace FamilyPlanner.Web.Client.Services;
public enum ThemePreference
{
System,
Light,
Dark,
}
/// <summary>Dark-Mode-Zustand der App. Standard folgt dem System, die Wahl wird lokal gespeichert.</summary>
public sealed class ThemeState(IJSRuntime js)
{
private bool _systemIsDark;
public event Action? Changed;
public ThemePreference Preference { get; private set; } = ThemePreference.System;
public bool IsDarkMode => Preference switch
{
ThemePreference.Dark => true,
ThemePreference.Light => false,
_ => _systemIsDark,
};
public async Task InitializeAsync(bool systemIsDark)
{
_systemIsDark = systemIsDark;
var stored = await js.InvokeAsync<string?>("familyPlanner.theme.load");
Preference = Enum.TryParse<ThemePreference>(stored, ignoreCase: true, out var preference) ? preference : ThemePreference.System;
Changed?.Invoke();
}
public void SetSystemDarkMode(bool isDark)
{
_systemIsDark = isDark;
Changed?.Invoke();
}
public async Task ToggleAsync()
{
Preference = IsDarkMode ? ThemePreference.Light : ThemePreference.Dark;
await js.InvokeVoidAsync("familyPlanner.theme.save", Preference.ToString());
Changed?.Invoke();
}
}
@@ -0,0 +1,20 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using MudBlazor
@using FamilyPlanner.Domain
@using FamilyPlanner.Domain.Authorization
@using FamilyPlanner.Shared
@using FamilyPlanner.Shared.Auth
@using FamilyPlanner.Web.Client
@using FamilyPlanner.Web.Client.Auth
@using FamilyPlanner.Web.Client.Components
@using FamilyPlanner.Web.Client.Layout
@using FamilyPlanner.Web.Client.Services
+62
View File
@@ -0,0 +1,62 @@
using Microsoft.AspNetCore.HttpOverrides;
using Yarp.ReverseProxy.Configuration;
namespace FamilyPlanner.Web;
/// <summary>
/// Backend-for-Frontend: leitet <c>/api</c>, <c>/hubs</c> und <c>/ics</c> same-origin an die Api weiter (ADR-0002).
/// Die Adresse der Api kommt über Aspire Service Discovery (<c>services__api__…</c>).
/// </summary>
internal static class BffSetup
{
private const string ApiCluster = "api";
public static IServiceCollection AddFamilyPlannerProxy(this IServiceCollection services)
{
RouteConfig[] routes =
[
Route("api", "/api/{**catch-all}"),
Route("hubs", "/hubs/{**catch-all}"),
Route("ics", "/ics/{**catch-all}"),
];
ClusterConfig[] clusters =
[
new()
{
ClusterId = ApiCluster,
Destinations = new Dictionary<string, DestinationConfig>
{
["api"] = new() { Address = "https+http://api" },
},
},
];
services.AddReverseProxy()
.LoadFromMemory(routes, clusters)
.AddServiceDiscoveryDestinationResolver();
services.Configure<ForwardedHeadersOptions>(options =>
{
// Optionaler Reverse Proxy (Caddy/Traefik) vor dem Web-Host im Docker-Netz.
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost;
options.KnownIPNetworks.Clear();
options.KnownProxies.Clear();
});
return services;
}
public static IEndpointRouteBuilder MapFamilyPlannerProxy(this IEndpointRouteBuilder app)
{
app.MapReverseProxy();
return app;
}
private static RouteConfig Route(string id, string path) => new()
{
RouteId = id,
ClusterId = ApiCluster,
Match = new RouteMatch { Path = path },
};
}
@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="description" content="Familienplaner – Einkaufsliste, Essenplan, Aufgaben, Kalender und Chat für die ganze Familie." />
<meta name="theme-color" content="#1565C0" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Familienplaner" />
<base href="/" />
<title>Familienplaner</title>
<link rel="manifest" href="manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="@Assets["icons/icon.svg"]" />
<link rel="icon" type="image/png" sizes="32x32" href="@Assets["icons/favicon-32.png"]" />
<link rel="apple-touch-icon" href="@Assets["icons/apple-touch-icon.png"]" />
<ResourcePreloader />
<link rel="stylesheet" href="@Assets["_content/MudBlazor/MudBlazor.min.css"]" />
<link rel="stylesheet" href="@Assets["app.css"]" />
<ImportMap />
<HeadOutlet @rendermode="RenderModeWasm" />
</head>
<body>
<div id="app-loading" class="app-loading" role="status" aria-live="polite">
<img src="@Assets["icons/icon.svg"]" alt="" width="96" height="96" />
<p>Familienplaner wird geladen …</p>
</div>
<Routes @rendermode="RenderModeWasm" />
<div id="blazor-error-ui" data-nosnippet>
Es ist ein unerwarteter Fehler aufgetreten.
<a href="." class="reload">Neu laden</a>
<span class="dismiss" role="button" aria-label="Schließen">✕</span>
</div>
<script src="@Assets["_content/MudBlazor/MudBlazor.min.js"]"></script>
<script src="@Assets["js/pwa.js"]"></script>
<script src="@Assets["_framework/blazor.web.js"]"></script>
</body>
</html>
@code {
// Kein Prerendering: der Host kennt keinen Auth-Zustand (ADR-0002).
private static readonly IComponentRenderMode RenderModeWasm = new InteractiveWebAssemblyRenderMode(prerender: false);
}
@@ -0,0 +1,26 @@
@page "/Error"
@using System.Diagnostics
<PageTitle>Fehler – Familienplaner</PageTitle>
<h1>Fehler</h1>
<p>Bei der Verarbeitung der Anfrage ist ein Fehler aufgetreten.</p>
@if (ShowRequestId)
{
<p>
<strong>Request-ID:</strong> <code>@RequestId</code>
</p>
}
@code {
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
private string? RequestId { get; set; }
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
protected override void OnInitialized() =>
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
}
@@ -0,0 +1,8 @@
@using System.Net.Http
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.JSInterop
@using FamilyPlanner.Web
@using FamilyPlanner.Web.Client
@using FamilyPlanner.Web.Components
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<RootNamespace>FamilyPlanner.Web</RootNamespace>
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" />
<PackageReference Include="Yarp.ReverseProxy" />
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery.Yarp" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\FamilyPlanner.ServiceDefaults\FamilyPlanner.ServiceDefaults.csproj" />
<ProjectReference Include="..\FamilyPlanner.Web.Client\FamilyPlanner.Web.Client.csproj" />
</ItemGroup>
</Project>
+44
View File
@@ -0,0 +1,44 @@
using FamilyPlanner.Web;
using FamilyPlanner.Web.Client.Auth;
using FamilyPlanner.Web.Components;
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.Services.AddRazorComponents()
.AddInteractiveWebAssemblyComponents();
builder.Services.AddFamilyPlannerProxy();
// Die Seiten tragen [Authorize]-Metadaten für den WASM-Router. Der Host rendert nur die Hülle (kein Prerendering)
// und authentifiziert nicht selbst: Policies bekannt machen, Endpunkte aber anonym ausliefern (ADR-0002).
builder.Services.AddFamilyPlannerAuthorization();
var app = builder.Build();
app.UseForwardedHeaders();
if (app.Environment.IsDevelopment())
{
app.UseWebAssemblyDebugging();
}
else
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
app.UseHsts();
}
app.UseSecurityHeaders();
app.UseHttpsRedirection();
app.UseAntiforgery();
app.MapDefaultEndpoints();
app.MapStaticAssets();
app.MapFamilyPlannerProxy();
app.MapRazorComponents<App>()
.AddInteractiveWebAssemblyRenderMode()
.AddAdditionalAssemblies(typeof(FamilyPlanner.Web.Client._Imports).Assembly)
.AllowAnonymous();
app.Run();
@@ -0,0 +1,25 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "http://localhost:5049",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "https://localhost:7264;http://localhost:5049",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
+24
View File
@@ -0,0 +1,24 @@
namespace FamilyPlanner.Web;
internal static class SecurityHeaders
{
/// <summary>
/// Grundlegende Security-Header. Eine vollständige Content-Security-Policy folgt in Phase 9 (Härtung),
/// sobald alle Inline-Skripte des Blazor-Hosts per Hash/Nonce erfasst sind.
/// </summary>
public static IApplicationBuilder UseSecurityHeaders(this IApplicationBuilder app) =>
app.Use(async (context, next) =>
{
context.Response.OnStarting(() =>
{
var headers = context.Response.Headers;
headers.XContentTypeOptions = "nosniff";
headers.XFrameOptions = "DENY";
headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(), payment=()";
headers["Cross-Origin-Opener-Policy"] = "same-origin";
return Task.CompletedTask;
});
await next(context);
});
}
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Yarp": "Warning"
}
},
"AllowedHosts": "*"
}
+130
View File
@@ -0,0 +1,130 @@
/* Globale Styles. Komponenten-Styles liegen als *.razor.css im Client-Projekt. */
html, body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
-webkit-tap-highlight-color: transparent;
}
/* Ladeanzeige, bis die WebAssembly-App gerendert hat. */
.app-loading {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
background: #fff;
color: #1565C0;
font-size: 1.1rem;
}
.app-loading img {
animation: fp-pulse 1.4s ease-in-out infinite;
}
body:has(.fp-app) .app-loading {
display: none;
}
@media (prefers-color-scheme: dark) {
.app-loading {
background: #1a1a27;
color: #90CAF9;
}
}
@keyframes fp-pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(.92); opacity: .7; }
}
@media (prefers-reduced-motion: reduce) {
.app-loading img { animation: none; }
}
#blazor-error-ui {
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
color: #000;
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 2000;
box-sizing: border-box;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}
/* Login / Passwortwechsel */
.fp-login-main {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 16px;
box-sizing: border-box;
}
.fp-login-card {
width: 100%;
max-width: 420px;
}
/* Bottom-Navigation (nur Smartphone, siehe BottomNav.razor) */
.fp-bottom-nav {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 1200;
display: flex;
justify-content: space-around;
background: var(--mud-palette-surface);
border-top: 1px solid var(--mud-palette-lines-default);
/* Sicherer Bereich für Geräte mit Home-Indikator. */
padding-bottom: env(safe-area-inset-bottom);
}
.fp-bottom-nav-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60px;
gap: 2px;
font-size: .75rem;
color: var(--mud-palette-text-secondary);
text-decoration: none;
}
.fp-bottom-nav-item.active {
color: var(--mud-palette-primary);
font-weight: 600;
}
.fp-bottom-nav-item:focus-visible {
outline: 2px solid var(--mud-palette-primary);
outline-offset: -4px;
}
@media (max-width: 959.98px) {
.fp-main {
padding-bottom: calc(72px + env(safe-area-inset-bottom));
}
}
.fp-tile {
height: 100%;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Familienplaner">
<rect width="512" height="512" rx="112" fill="#1565C0"/>
<polygon points="256,92 80,244 432,244" fill="#FFFFFF"/>
<rect x="132" y="222" width="248" height="202" rx="15" fill="#FFFFFF"/>
<polygon points="256.0,292.0 256.0,291.7 256.1,290.8 256.3,289.4 256.7,287.5 257.4,285.2 258.5,282.5 259.8,279.5 261.6,276.3 263.8,273.1 266.4,269.9 269.4,266.8 272.9,263.9 276.7,261.4 280.9,259.3 285.4,257.6 290.1,256.5 295.1,256.0 300.1,256.1 305.1,256.9 310.0,258.2 314.9,260.1 319.4,262.7 323.7,265.7 327.6,269.3 331.0,273.2 333.9,277.6 336.2,282.2 337.8,287.0 338.9,292.1 339.2,297.2 338.9,302.4 337.8,307.6 336.2,312.8 333.9,317.9 331.0,322.9 327.6,327.9 323.7,332.7 319.4,337.4 314.9,342.1 310.0,346.6 305.1,351.0 300.1,355.4 295.1,359.7 290.1,363.8 285.4,368.0 280.9,372.0 276.7,375.9 272.9,379.7 269.4,383.4 266.4,386.9 263.8,390.3 261.6,393.4 259.8,396.3 258.5,398.8 257.4,401.1 256.7,402.9 256.3,404.4 256.1,405.5 256.0,406.2 256.0,406.4 256.0,406.2 255.9,405.5 255.7,404.4 255.3,402.9 254.6,401.1 253.5,398.8 252.2,396.3 250.4,393.4 248.2,390.3 245.6,386.9 242.6,383.4 239.1,379.7 235.3,375.9 231.1,372.0 226.6,368.0 221.9,363.8 216.9,359.7 211.9,355.4 206.9,351.0 202.0,346.6 197.1,342.1 192.6,337.4 188.3,332.7 184.4,327.9 181.0,322.9 178.1,317.9 175.8,312.8 174.2,307.6 173.1,302.4 172.8,297.2 173.1,292.1 174.2,287.0 175.8,282.2 178.1,277.6 181.0,273.2 184.4,269.3 188.3,265.7 192.6,262.7 197.1,260.1 202.0,258.2 206.9,256.9 211.9,256.1 216.9,256.0 221.9,256.5 226.6,257.6 231.1,259.3 235.3,261.4 239.1,263.9 242.6,266.8 245.6,269.9 248.2,273.1 250.4,276.3 252.2,279.5 253.5,282.5 254.6,285.2 255.3,287.5 255.7,289.4 255.9,290.8 256.0,291.7" fill="#FF7043"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+35
View File
@@ -0,0 +1,35 @@
// PWA-Hilfen: Service-Worker-Registrierung und Online/Offline-Status für Blazor.
(() => {
// Service Worker nur in sicheren Kontexten (HTTPS oder localhost).
if ('serviceWorker' in navigator && window.isSecureContext) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js', { scope: '/' })
.catch(error => console.warn('Service Worker konnte nicht registriert werden.', error));
});
}
window.familyPlanner = {
network: {
isOnline: () => navigator.onLine,
subscribe: dotNetRef => {
const notify = () => dotNetRef.invokeMethodAsync('SetOnline', navigator.onLine);
window.addEventListener('online', notify);
window.addEventListener('offline', notify);
return {
dispose: () => {
window.removeEventListener('online', notify);
window.removeEventListener('offline', notify);
}
};
}
},
theme: {
load: () => {
try { return localStorage.getItem('fp-theme'); } catch { return null; }
},
save: value => {
try { localStorage.setItem('fp-theme', value); } catch { /* privater Modus */ }
}
}
};
})();
@@ -0,0 +1,35 @@
{
"id": "/",
"name": "Familienplaner",
"short_name": "Familie",
"description": "Einkaufsliste, Essenplan, Aufgaben, Kalender und Chat für die ganze Familie.",
"lang": "de",
"dir": "ltr",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#FFFFFF",
"theme_color": "#1565C0",
"categories": ["lifestyle", "productivity"],
"icons": [
{ "src": "icons/icon-48.png", "sizes": "48x48", "type": "image/png" },
{ "src": "icons/icon-72.png", "sizes": "72x72", "type": "image/png" },
{ "src": "icons/icon-96.png", "sizes": "96x96", "type": "image/png" },
{ "src": "icons/icon-128.png", "sizes": "128x128", "type": "image/png" },
{ "src": "icons/icon-144.png", "sizes": "144x144", "type": "image/png" },
{ "src": "icons/icon-152.png", "sizes": "152x152", "type": "image/png" },
{ "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "icons/icon-256.png", "sizes": "256x256", "type": "image/png" },
{ "src": "icons/icon-384.png", "sizes": "384x384", "type": "image/png" },
{ "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "icons/icon-maskable-192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" },
{ "src": "icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" },
{ "src": "icons/icon.svg", "sizes": "any", "type": "image/svg+xml" }
],
"shortcuts": [
{ "name": "Einkaufsliste", "url": "/einkauf", "icons": [{ "src": "icons/icon-96.png", "sizes": "96x96" }] },
{ "name": "Aufgaben", "url": "/aufgaben", "icons": [{ "src": "icons/icon-96.png", "sizes": "96x96" }] },
{ "name": "Kalender", "url": "/kalender", "icons": [{ "src": "icons/icon-96.png", "sizes": "96x96" }] }
]
}
@@ -0,0 +1,76 @@
// Service Worker des Familienplaners (ADR-0007).
// - App-Shell: Navigationen Network-first, offline Fallback auf die gecachte Shell ("/").
// Da nicht vorgerendert wird, ist die Shell für alle Routen identisch.
// - Statische Assets (_framework, _content, Icons, CSS/JS): Cache-first (Dateinamen sind fingerprinted).
// - /api, /hubs, /ics und Health-Endpunkte werden nie gecacht.
// Bei Änderungen an dieser Datei CACHE_VERSION erhöhen.
const CACHE_VERSION = 'v1';
const SHELL_CACHE = `fp-shell-${CACHE_VERSION}`;
const ASSET_CACHE = `fp-assets-${CACHE_VERSION}`;
const SHELL_URL = '/';
const PRECACHE = [SHELL_URL, '/manifest.webmanifest', '/icons/icon.svg', '/icons/icon-192.png', '/icons/icon-512.png'];
const BYPASS = ['/api/', '/hubs/', '/ics/', '/health', '/alive', '/_framework/debug'];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(SHELL_CACHE)
.then(cache => cache.addAll(PRECACHE))
.then(() => self.skipWaiting()));
});
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys()
.then(keys => Promise.all(keys
.filter(key => key.startsWith('fp-') && key !== SHELL_CACHE && key !== ASSET_CACHE)
.map(key => caches.delete(key))))
.then(() => self.clients.claim()));
});
self.addEventListener('fetch', event => {
const request = event.request;
if (request.method !== 'GET') {
return;
}
const url = new URL(request.url);
if (url.origin !== self.location.origin || BYPASS.some(prefix => url.pathname.startsWith(prefix))) {
return;
}
if (request.mode === 'navigate') {
event.respondWith(networkFirstShell(request));
return;
}
event.respondWith(cacheFirst(request));
});
async function networkFirstShell(request) {
const cache = await caches.open(SHELL_CACHE);
try {
const response = await fetch(request);
if (response.ok) {
await cache.put(SHELL_URL, response.clone());
}
return response;
} catch {
const cached = await cache.match(SHELL_URL);
return cached ?? Response.error();
}
}
async function cacheFirst(request) {
const cache = await caches.open(ASSET_CACHE);
const cached = await cache.match(request);
if (cached) {
return cached;
}
const response = await fetch(request);
if (response.ok && response.type === 'basic') {
await cache.put(request, response.clone());
}
return response;
}
+79
View File
@@ -0,0 +1,79 @@
"""Erzeugt die PWA-Icons (PNG + SVG) für FamilyPlanner. Aufruf: python make_icons.py <zielordner>"""
import math
import os
import sys
from PIL import Image, ImageDraw
BG = "#1565C0"
FG = "#FFFFFF"
HEART = "#FF7043"
def heart_points(cx, cy, scale):
pts = []
for i in range(120):
t = 2 * math.pi * i / 120
x = 16 * math.sin(t) ** 3
y = 13 * math.cos(t) - 5 * math.cos(2 * t) - 2 * math.cos(3 * t) - math.cos(4 * t)
pts.append((cx + x * scale, cy - y * scale))
return pts
def glyph(s, inset):
"""Koordinaten im 512er-Raster, skaliert auf das Inset (für maskable Icons kleiner)."""
f = (512 - 2 * inset) / 512
def p(x, y):
return (inset + x * f) * s / 512, (inset + y * f) * s / 512
roof = [p(256, 92), p(80, 244), p(432, 244)]
body = [p(132, 222), p(380, 424)]
heart = [p(x, y) for x, y in heart_points(256, 318, 5.2)]
return roof, body, heart, f
def draw_icon(size, maskable):
s = size * 4 # Supersampling
img = Image.new("RGBA", (s, s), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
if maskable:
d.rectangle([0, 0, s, s], fill=BG)
inset = 80
else:
d.rounded_rectangle([0, 0, s - 1, s - 1], radius=int(s * 0.22), fill=BG)
inset = 0
roof, body, heart, f = glyph(s, inset)
d.polygon(roof, fill=FG)
d.rounded_rectangle([body[0], body[1]], radius=int(s * 0.03 * f), fill=FG)
d.polygon(heart, fill=HEART)
return img.resize((size, size), Image.LANCZOS)
def write_svg(path):
heart = " ".join(f"{x:.1f},{y:.1f}" for x, y in heart_points(256, 318, 5.2))
svg = f"""<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Familienplaner">
<rect width="512" height="512" rx="112" fill="{BG}"/>
<polygon points="256,92 80,244 432,244" fill="{FG}"/>
<rect x="132" y="222" width="248" height="202" rx="15" fill="{FG}"/>
<polygon points="{heart}" fill="{HEART}"/>
</svg>
"""
with open(path, "w", encoding="utf-8", newline="\n") as fh:
fh.write(svg)
def main(target):
os.makedirs(target, exist_ok=True)
for size in (48, 72, 96, 128, 144, 152, 192, 256, 384, 512):
draw_icon(size, False).save(os.path.join(target, f"icon-{size}.png"), optimize=True)
for size in (192, 512):
draw_icon(size, True).save(os.path.join(target, f"icon-maskable-{size}.png"), optimize=True)
draw_icon(32, False).save(os.path.join(target, "favicon-32.png"), optimize=True)
# Apple ignoriert Transparenz -> vollflächig
draw_icon(180, True).save(os.path.join(target, "apple-touch-icon.png"), optimize=True)
write_svg(os.path.join(target, "icon.svg"))
if __name__ == "__main__":
main(sys.argv[1])