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>
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 & Ä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)]
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Yarp": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
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 |
@@ -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;
|
||||
}
|
||||
@@ -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])
|
||||