Eerste commit

This commit is contained in:
jf
2026-09-27 14:21:31 +00:00
commit de4ff128cb
295 changed files with 47374 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
<Application x:Class="MijnBesluit.Agentvenster.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
StartupUri="Hoofdvenster.xaml">
</Application>
+7
View File
@@ -0,0 +1,7 @@
using System.Windows;
namespace MijnBesluit.Agentvenster;
public partial class App : Application
{
}
@@ -0,0 +1,48 @@
<Window x:Class="MijnBesluit.Agentvenster.Hoofdvenster"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="MijnBesluit uitrolagent" Height="620" Width="860"
Icon="agentvenster.ico"
WindowStartupLocation="CenterScreen" Background="#FAF7F2"
Closing="Venster_Closing">
<Grid Margin="16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<StackPanel Grid.Row="0" Margin="0,0,0,12">
<TextBlock Text="MijnBesluit uitrolagent" FontSize="22" FontWeight="Bold" Foreground="#2A2724" />
<TextBlock x:Name="Doelregel" Foreground="#6B645C" Margin="0,4,0,0" TextWrapping="Wrap" />
</StackPanel>
<!-- Eén regel die van een afstand leesbaar is. Dit venster staat op een server waar je
niet naar zit te kijken; als je er langsloopt wil je in één oogopslag weten of hij
staat te wachten of bezig is. -->
<Border Grid.Row="1" x:Name="Standvak" Background="#EEF2DA" BorderBrush="#C1D13F"
BorderThickness="1" CornerRadius="6" Padding="12,10" Margin="0,0,0,12">
<TextBlock x:Name="Standregel" FontSize="15" FontWeight="Bold" Foreground="#3A3E1E"
Text="Bezig met opstarten…" TextWrapping="Wrap" />
</Border>
<StackPanel Grid.Row="2" Orientation="Horizontal" Margin="0,0,0,12">
<Button x:Name="KnopWacht" Content="Stoppen met wachten" Padding="14,7"
Click="KnopWacht_Click" />
<Button x:Name="KnopLeeg" Content="Logboek leegmaken" Padding="14,7" Margin="10,0,0,0"
Click="KnopLeeg_Click" />
</StackPanel>
<Border Grid.Row="3" BorderBrush="#E6E0D7" BorderThickness="1" Background="White" CornerRadius="6">
<TextBox x:Name="Logvenster" IsReadOnly="True" BorderThickness="0" Padding="10"
FontFamily="Consolas" FontSize="12"
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled"
TextWrapping="Wrap" AcceptsReturn="True" />
</Border>
<ProgressBar Grid.Row="4" x:Name="Balk" Height="10" Margin="0,12,0,0" />
</Grid>
</Window>
@@ -0,0 +1,230 @@
using System.IO;
using System.Windows;
using System.Windows.Media;
using MijnBesluit.Uitrolagent;
namespace MijnBesluit.Agentvenster;
/// <summary>
/// Het venster op de SERVER. Eén ding: staan wachten tot er vanaf de ontwikkelmachine een
/// opdracht binnenkomt, en laten zien wat er dan gebeurt.
///
/// Waarom dit er is: het wachten kon al met MijnBesluit.Uitrolagent.exe --wacht, maar dan zit je
/// met een consolevenster dat je eerst moet openen en dat blijft staan tot je het wegklikt. Op
/// een server die je via een externe-bureaubladverbinding bedient is dat net het soort venster
/// dat je per ongeluk sluit of niet terugvindt. Dit is hetzelfde, maar zichtbaar: je
/// dubbelklikt, hij staat te wachten, en je ziet de uitrol regel voor regel voorbijkomen -
/// precies zoals het uitrolvenster dat op je eigen pc doet.
///
/// Er wordt hier niets nagebouwd. De lus is dezelfde <see cref="Wachtstand"/> als in de console;
/// dit venster geeft hem alleen een andere plek om zijn regels naartoe te schrijven.
/// </summary>
public partial class Hoofdvenster : Window
{
private readonly Agentinstellingen? _instellingen;
private CancellationTokenSource? _stopper;
private Task? _lus;
private bool _bezig;
/// <summary>
/// Zoveel regels houden we vast. Dit venster kan dagen open blijven staan, en een tekstvak
/// dat almaar aangroeit wordt op een gegeven moment traag - juist op het moment dat je erin
/// wilt kijken.
/// </summary>
private const int MaximaalRegels = 4000;
public Hoofdvenster()
{
InitializeComponent();
var pad = Path.Combine(AppContext.BaseDirectory, "uitrolagent.json");
Agentinstellingen gelezen;
try
{
gelezen = Agentinstellingen.Lees(pad, Meld);
}
catch (Exception ex)
{
Doelregel.Text = pad;
Stand("Het instellingenbestand is niet te lezen.", true);
Meld($"{pad} kon niet worden gelezen: {ex.Message}", true);
Meld("Herstel het bestand en start dit venster opnieuw.", false);
KnopWacht.IsEnabled = false;
return;
}
if (!File.Exists(pad))
{
// Niet blokkeren: zonder bestand gelden de standaardwaarden, en die kloppen op een
// gewone installatie vaak gewoon. Maar wél zeggen, want als de paden er straks naast
// zitten is dít de reden.
Meld($"Let op: {pad} staat er niet. Ik gebruik de standaardinstellingen.", true);
}
_instellingen = gelezen;
// Hardop zeggen waar hij zijn instellingen vandaan haalt. Publiceren zet uitrolagent.json
// terug op de versie uit de projectmap; wat je niet kwijt wilt, hoort in het geheimenbestand
// dat daar per sleutel overheen gaat. Die twee regels hieronder zijn het verschil tussen
// "hij pakt mijn wachtwoord niet" en zien waaróm.
Meld($"Instellingen: {pad}", false);
if (!string.IsNullOrWhiteSpace(gelezen.ExtraConfigFile))
Meld(File.Exists(gelezen.ExtraConfigFile)
? $"Aangevuld uit {gelezen.ExtraConfigFile}."
: $"Er is geen {gelezen.ExtraConfigFile}; alles komt uit het bestand hierboven.",
false);
Doelregel.Text = $"Kijkt in: {gelezen.Opdrachtmap} • Scripts: {gelezen.Scriptmap}";
if (!Directory.Exists(gelezen.Scriptmap))
Meld($"Let op: de scriptmap {gelezen.Scriptmap} bestaat niet. " +
"Een uitrol zal daarop stuklopen.", true);
Loaded += (_, _) => Beginnen();
}
// ------------------------------------------------------------------ de lus
/// <summary>
/// Begint met wachten. Gebeurt vanzelf bij het openen: je start dit venster omdat je gaat
/// uitrollen, dus nog een knop indrukken voegt niets toe.
/// </summary>
private void Beginnen()
{
if (_instellingen is null || _lus is not null) return;
_stopper = new CancellationTokenSource();
var teken = _stopper.Token;
KnopWacht.Content = "Stoppen met wachten";
Stand("Wacht op een opdracht van je pc.", false);
var wachtstand = new Wachtstand(_instellingen, Meld, Bezig);
// Op een eigen draad, zodat het venster blijft reageren terwijl de lus draait en een
// uitrol een half uur kan duren.
_lus = Task.Run(async () =>
{
try { await wachtstand.LoopAsync(teken); }
catch (OperationCanceledException) { }
catch (Exception ex) { Meld($"De agent is gestopt: {ex.Message}", true); }
}, teken);
_ = _lus.ContinueWith(_ => Dispatcher.Invoke(() =>
{
_lus = null;
Bezig(false);
KnopWacht.Content = "Wachten";
Stand("Staat stil. Er wordt niets opgepakt.", true);
}), TaskScheduler.Default);
}
private void Stoppen()
{
if (_lus is null) return;
Meld("Stoppen gevraagd.", false);
_stopper?.Cancel();
}
// ------------------------------------------------------------------ knoppen
private void KnopWacht_Click(object sender, RoutedEventArgs e)
{
if (_lus is null) Beginnen();
else Stoppen();
}
private void KnopLeeg_Click(object sender, RoutedEventArgs e)
{
Logvenster.Clear();
}
/// <summary>
/// Sluiten terwijl er een uitrol loopt is het ene geval waarin dit venster tegenspreekt. Een
/// halve uitrol laat een site achter die niet meer bij zijn database past, en dat merk je
/// pas als er iemand op de website zit.
/// </summary>
private void Venster_Closing(object sender, System.ComponentModel.CancelEventArgs e)
{
if (_bezig)
{
var antwoord = MessageBox.Show(
"Er loopt op dit moment een uitrol.\n\n" +
"Dit venster sluiten breekt hem af, en dan staat er straks misschien een halve " +
"versie op deze server.\n\nToch sluiten?",
"Er is nog iets bezig", MessageBoxButton.YesNo, MessageBoxImage.Warning,
MessageBoxResult.No);
if (antwoord != MessageBoxResult.Yes)
{
e.Cancel = true;
return;
}
}
_stopper?.Cancel();
}
// ------------------------------------------------------------------ meekijken
/// <summary>
/// Waar elke regel van de wachtstand binnenkomt. Komt van een andere draad, dus eerst terug
/// naar die van het venster.
/// </summary>
private void Meld(string tekst, bool isFout)
{
if (!Dispatcher.CheckAccess()) { Dispatcher.Invoke(() => Meld(tekst, isFout)); return; }
// Een lege regel krijgt geen tijdstempel. De PowerShell-scripts gebruiken witregels om hun
// uitvoer leesbaar te houden; met een klokje ervoor worden dat juist losse regels ruis.
Logvenster.AppendText(string.IsNullOrWhiteSpace(tekst)
? Environment.NewLine
: $"{DateTime.Now:HH:mm:ss} {(isFout ? "! " : " ")}{tekst}{Environment.NewLine}");
Inkorten();
Logvenster.ScrollToEnd();
}
/// <summary>Gooit de oudste regels weg als het er te veel worden. Zie MaximaalRegels.</summary>
private void Inkorten()
{
if (Logvenster.LineCount <= MaximaalRegels) return;
var vanaf = Logvenster.GetCharacterIndexFromLineIndex(Logvenster.LineCount - MaximaalRegels / 2);
if (vanaf > 0) Logvenster.Text = Logvenster.Text[vanaf..];
}
/// <summary>
/// Aan zodra er een opdracht wordt uitgevoerd, uit als hij klaar is. De balk loopt zolang er
/// iets gebeurt: uitrollen duurt minuten, en zonder teken van leven ga je je afvragen of hij
/// nog wel iets doet.
/// </summary>
private void Bezig(bool bezig)
{
if (!Dispatcher.CheckAccess()) { Dispatcher.Invoke(() => Bezig(bezig)); return; }
_bezig = bezig;
Balk.IsIndeterminate = bezig;
if (bezig) Stand("Bezig met uitrollen. Laat dit venster staan.", false);
else if (_lus is not null) Stand($"Wacht op een opdracht van je pc. " +
$"Laatste uitrol: {DateTime.Now:HH:mm:ss}.", false);
}
private void Stand(string tekst, bool isWaarschuwing)
{
if (!Dispatcher.CheckAccess()) { Dispatcher.Invoke(() => Stand(tekst, isWaarschuwing)); return; }
Standregel.Text = tekst;
Standvak.Background = isWaarschuwing ? new SolidColorBrush(Color.FromRgb(0xFA, 0xEF, 0xE2))
: new SolidColorBrush(Color.FromRgb(0xEE, 0xF2, 0xDA));
Standvak.BorderBrush = isWaarschuwing ? new SolidColorBrush(Color.FromRgb(0xD9, 0xA3, 0x4A))
: new SolidColorBrush(Color.FromRgb(0xC1, 0xD1, 0x3F));
Standregel.Foreground = isWaarschuwing ? new SolidColorBrush(Color.FromRgb(0x6B, 0x45, 0x12))
: new SolidColorBrush(Color.FromRgb(0x3A, 0x3E, 0x1E));
}
}
@@ -0,0 +1,47 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- Het agentvenster: hetzelfde wachten als de wachtstand van MijnBesluit.Uitrolagent.exe,
maar met een gezicht. Bedoeld voor de SERVER; het uitrolvenster staat op de
ontwikkelmachine.
Waarom een apart project en niet een venster in de agent zelf: die agent is een
consoleprogramma en moet dat blijven. Zet je daar WPF in, dan wordt het een WinExe zonder
console, en dan werken zijn schakelaars voor zip, luisteren en een nieuwe sleutel alleen
nog met kunstgrepen (AttachConsole en aanverwante ellende - zie de SocketServer). Zo staat
het los: de agent doet het werk, dit project kijkt mee.
Let op bij het aanpassen van deze uitleg: twee streepjes achter elkaar mogen niet in een
XML-commentaar. Schrijf schakelaars dus uit in woorden. Zelfcontrole "Geen projectbestand
met een onmogelijk commentaar" bewaakt dat. -->
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>MijnBesluit.Agentvenster</AssemblyName>
<RootNamespace>MijnBesluit.Agentvenster</RootNamespace>
<PlatformTarget>x64</PlatformTarget>
<ApplicationIcon>agentvenster.ico</ApplicationIcon>
<!-- Directory.Build.props zet InvariantGlobalization voor de hele solution aan. WPF kan daar
niet tegen: zonder ICU valt het venster om bij de eerste letter die het wil tekenen, en
omdat het een WinExe is zie je daar niets van - je dubbelklikt en er gebeurt niets.
Zie de gelijkluidende regel in MijnBesluit.Uitroller.csproj. -->
<InvariantGlobalization>false</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<!-- De agent zelf. Een verwijzing naar een Exe-project is gewoon toegestaan: .NET levert
daarvan ook een dll op. Zo draait hier dezelfde Wachtstand als in de console - niet een
tweede uitvoering die na een half jaar net iets anders doet. -->
<ProjectReference Include="..\MijnBesluit.Uitrolagent\MijnBesluit.Uitrolagent.csproj" />
</ItemGroup>
<ItemGroup>
<!-- Ook als Resource, zodat het venster hem in zijn eigen titelbalk kan zetten. -->
<Resource Include="agentvenster.ico" />
</ItemGroup>
</Project>
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" ?>
<Application xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="MijnBesluit.App.App">
</Application>
+39
View File
@@ -0,0 +1,39 @@
using Microsoft.Extensions.DependencyInjection;
using MijnBesluit.AppUi;
namespace MijnBesluit.App;
public partial class App : Application
{
private readonly Terugknop _terugknop;
public App(Terugknop terugknop)
{
InitializeComponent();
_terugknop = terugknop;
}
protected override Window CreateWindow(IActivationState? activationState)
{
var venster = new Window(new HoofdPagina(_terugknop)) { Title = "MijnBesluit" };
// Android verbreekt de socketverbinding zodra de app een tijdje op de achtergrond staat -
// bijvoorbeeld terwijl iemand zijn e-mail leest om de inlogcode op te halen. Bij het
// terugkomen halen we de gegevens opnieuw op, zodat de melding "geen verbinding"
// verdwijnt zonder dat de gebruiker ergens op hoeft te drukken.
venster.Resumed += async (_, _) =>
{
var diensten = IPlatformApplication.Current?.Services;
// Eerst kijken of de app zichzelf had moeten uitloggen terwijl hij in de achtergrond
// stond. Dat is het geval waar automatisch uitloggen voor bedoeld is - een telefoon
// die een uur in een tas zit - en het hoort te gebeuren vóór we gegevens ophalen.
diensten?.GetService<Uitlogklok>()?.Hervatten();
var toestand = diensten?.GetService<AppToestand>();
if (toestand is { IsAangemeld: true }) await toestand.VerversAsync();
};
return venster;
}
}
@@ -0,0 +1,20 @@
@namespace MijnBesluit.App
@* De ingang van de app. De schermen zelf staan in MijnBesluit.AppUi, zodat website en app
dezelfde blijven; hier wordt alleen verteld waar ze te vinden zijn. *@
<Router AppAssembly="@typeof(Hoofdscherm).Assembly"
AdditionalAssemblies="new[] { typeof(MijnBesluit.AppUi.Schermen.Start).Assembly }">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(AppSchil)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound>
<LayoutView Layout="@typeof(AppSchil)">
<div class="leeg">
<span class="teken">&#129300;</span>
Dit scherm bestaat niet. Ga terug naar het <a href="/">overzicht</a>.
</div>
</LayoutView>
</NotFound>
</Router>
@@ -0,0 +1,7 @@
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using MijnBesluit.App
@using MijnBesluit.AppUi
@using MijnBesluit.AppUi.Layout
@using MijnBesluit.AppUi.Schermen
@using MijnBesluit.Client
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:b="clr-namespace:Microsoft.AspNetCore.Components.WebView.Maui;assembly=Microsoft.AspNetCore.Components.WebView.Maui"
xmlns:local="clr-namespace:MijnBesluit.App"
x:Class="MijnBesluit.App.HoofdPagina"
BackgroundColor="#faf7f2">
<!-- De hele app is één webview met de Blazor-schermen erin. Dezelfde schermen als op
de website, zodat een wijziging maar op één plek hoeft. -->
<b:BlazorWebView x:Name="webview" HostPage="wwwroot/index.html">
<b:BlazorWebView.RootComponents>
<b:RootComponent Selector="#app" ComponentType="{x:Type local:Hoofdscherm}" />
</b:BlazorWebView.RootComponents>
</b:BlazorWebView>
</ContentPage>
+24
View File
@@ -0,0 +1,24 @@
using MijnBesluit.AppUi;
namespace MijnBesluit.App;
public partial class HoofdPagina : ContentPage
{
private readonly Terugknop _terugknop;
public HoofdPagina(Terugknop terugknop)
{
InitializeComponent();
_terugknop = terugknop;
}
/// <summary>
/// De terugknop van het toestel. Android stuurt hem hierheen; geven we false terug, dan doet
/// het toestel wat het altijd deed en gaat de app dicht.
///
/// De schermen bepalen zelf wat terug betekent - een popup dicht, terug naar het overzicht -
/// via <see cref="Terugknop"/>. Hier staat alleen de doorgifte, want de webview zelf weet
/// niets van de knoppen van het toestel.
/// </summary>
protected override bool OnBackButtonPressed() => _terugknop.Ingedrukt();
}
+71
View File
@@ -0,0 +1,71 @@
using MijnBesluit.AppUi;
using MijnBesluit.Client;
namespace MijnBesluit.App;
public static class MauiProgram
{
/// <summary>
/// Waar de SocketServer draait. Voor het testen op een emulator wijst 10.0.2.2 naar je
/// eigen pc; op een echt toestel vul je hier de servernaam in.
/// </summary>
public const string Server = "mijnbesluit.nl";
public const int Poort = 5200;
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder.UseMauiApp<App>();
builder.Services.AddMauiBlazorWebView();
// Eén verbinding en één sessie voor de hele app: alle schermen delen hem.
builder.Services.AddSingleton<ITokenopslag, ToestelTokenopslag>();
builder.Services.AddSingleton(sp => new MijnBesluitClient(
Server, Poort, tls: true, sp.GetRequiredService<ITokenopslag>()));
builder.Services.AddSingleton(sp => new AppToestand(
sp.GetRequiredService<MijnBesluitClient>(), sp.GetRequiredService<IMeldingen>()));
// Het kiezen van bestanden en het opnemen van video: dat kan de webview niet zelf.
builder.Services.AddSingleton<IBestandskiezer, Toestelbestanden>();
// Een advertentie opent bij de adverteerder, in de browser van de telefoon en niet in de
// app. Ook dat kan de webview niet zelf.
builder.Services.AddSingleton<IBuitenlink, Toestelbuitenlink>();
// De terugknop van het toestel. Eén exemplaar voor de hele app: de pagina geeft de druk
// erin, de schermen in de webview melden zich erop aan.
builder.Services.AddSingleton<Terugknop>();
// Het "trek naar beneden om te verversen"-gebaar: de schil meldt hier dat er is
// getrokken, het scherm dat op dat moment openstaat luistert en haalt zijn gegevens
// opnieuw op. Zie VernieuwSignaal.
builder.Services.AddSingleton<VernieuwSignaal>();
// Het slot van het toestel: vingerafdruk, gezicht of de code van de telefoon zelf.
builder.Services.AddSingleton<IToestelslot, Toestelslot>();
// Instellingen die alleen op dit toestel gelden, en de klok die de app zichzelf laat
// vergrendelen. Eén exemplaar, want de schil bewaakt hem en het instellingenscherm zet hem.
//
// De standaardtermijn hangt af van het toestel: kan de telefoon om een vingerafdruk of
// code vragen, dan gaat de klok standaard aan - dan kost vergeten je één aanraking.
// Kan dat niet, dan kost vergeten je een e-mail met een code, en dat leggen we niemand
// ongevraagd op.
builder.Services.AddSingleton<IAppinstellingen, Toestelinstellingen>();
builder.Services.AddSingleton(sp => new Uitlogklok(
sp.GetRequiredService<IAppinstellingen>(),
sp.GetRequiredService<IToestelslot>().Beschikbaar ? Uitlogklok.StandaardMetSlot : 0,
sp.GetRequiredService<IToestelslot>().Beschikbaar));
// Meldingen op het toestel. Zonder Firebase levert dit niets op en werkt de app
// gewoon door; zie docs/Meldingen.md.
builder.Services.AddSingleton<IMeldingen, Toestelmeldingen>();
#if DEBUG
builder.Services.AddBlazorWebViewDeveloperTools();
#endif
return builder.Build();
}
}
+106
View File
@@ -0,0 +1,106 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<!-- De schermen zijn voor allebei dezelfde; alleen wat het toestel zelf doet verschilt.
Zie Platforms\ en de #if-blokken in Toestelslot.cs en Toestelmeldingen.cs.
iOS staat standaard UIT, en dat is geen halfheid maar de enige werkbare stand. Zodra
net10.0-ios in deze lijst staat, wil elke build - ook een build van alleen de APK - de
iOS-werkbelasting en een Mac hebben. Op een machine zonder die twee struikelt dan het
maken van de Android-app over iets wat er niets mee te maken heeft.
Aanzetten doe je per keer:
dotnet build -p:MetIos=true
tools\Apk-maken.ps1 (blijft gewoon Android, hier verandert niets)
of blijvend, door MetIos in een Directory.Build.props of in de omgeving te zetten op de
machine die wél aan een Mac hangt. Zie docs/iOS-app.md. -->
<TargetFrameworks>net10.0-android</TargetFrameworks>
<TargetFrameworks Condition="'$(MetIos)' == 'true'">$(TargetFrameworks);net10.0-ios</TargetFrameworks>
<OutputType>Exe</OutputType>
<RootNamespace>MijnBesluit.App</RootNamespace>
<UseMaui>true</UseMaui>
<SingleProject>true</SingleProject>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<ApplicationTitle>MijnBesluit</ApplicationTitle>
<ApplicationId>nl.mijnbesluit.app</ApplicationId>
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
<ApplicationVersion>1</ApplicationVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">24.0</SupportedOSPlatformVersion>
<!-- Op papier kan MAUI vanaf iOS 12.2, maar onder 13 zijn er meldingen van apps die op een
echt toestel omvallen, en 15.0 is wat het sjabloon zelf gebruikt. Dat draait op een
iPhone 6s en nieuwer - toestellen uit 2015. Lager gaan levert je een handvol toestellen
op en een soort fouten dat je niet kunt nabootsen. -->
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">15.0</SupportedOSPlatformVersion>
</PropertyGroup>
<ItemGroup>
<!-- Het echte logo. Het pictogram is alleen het witte woordmerk op een doorzichtige
achtergrond, met het logogroen eronder: zo maakt Android er zelf een rond, vierkant of
druppelvormig pictogram van zonder dat er letters afvallen. Het woordmerk staat daarom
op zeventig procent - alles buiten die cirkel kan een toestel wegsnijden.
Het startscherm laat het hele logo zien op de rustige achtergrondkleur. -->
<MauiIcon Include="Resources\AppIcon\appicon.png" Color="#C1D13F" />
<MauiSplashScreen Include="Resources\Splash\splash.png" Color="#faf7f2" BaseSize="128,128" />
<MauiFont Include="Resources\Fonts\*" />
</ItemGroup>
<ItemGroup>
<!-- Sinds .NET 8 brengt <UseMaui>true</UseMaui> deze packages niet meer vanzelf mee.
$(MauiVersion) volgt de werkbelasting die op deze machine staat, dus hier hoeft geen
versienummer met de hand bijgehouden te worden. -->
<PackageReference Include="Microsoft.Maui.Controls" Version="$(MauiVersion)" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebView.Maui" Version="$(MauiVersion)" />
<ProjectReference Include="..\MijnBesluit.AppUi\MijnBesluit.AppUi.csproj" />
</ItemGroup>
<!-- Meldingen op de telefoon, via Firebase.
Dit blok doet niets zolang google-services.json er niet staat, en gaat vanzelf aan zodra
je hem uit de Firebase-console in Platforms\Android zet. Zo is er geen stap "en vergeet
het csproj niet aan te passen" die je een keer overslaat, en blijft het project bouwen op
een machine waar dat bestand niet staat.
De schakelaar FIREBASE hoort bij de #if in Toestelmeldingen.cs: zonder het package is die
code niet te compileren, dus die staat uit tot het package er is.
Zie docs/Meldingen.md. -->
<PropertyGroup Condition="Exists('$(MSBuildProjectDirectory)\Platforms\Android\google-services.json')">
<DefineConstants>$(DefineConstants);FIREBASE</DefineConstants>
</PropertyGroup>
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android' AND Exists('$(MSBuildProjectDirectory)\Platforms\Android\google-services.json')">
<!-- Binding op com.google.firebase:firebase-messaging. Het versienummer moet passen bij de
werkbelasting op deze machine; 125.1.1.1 hoort bij net10.0-android. Klaagt de build over
versies, kijk dan in NuGet welke bij jouw .NET-versie hoort. -->
<PackageReference Include="Xamarin.Firebase.Messaging" Version="125.1.1.1" />
<!-- Firebase's afhankelijkheden pinnen oudere AndroidX .Ktx-packages, terwijl MAUI de
bijbehorende basispackages al nieuwer binnenhaalt. Dat gaf niet alleen de NU1608-
waarschuwingen, maar ook een harde fout bij het dexen: sinds AndroidX Fragment 1.9.0
zitten de Kotlin-uitbreidingen in het hoofdpackage zelf, dus kwam bijvoorbeeld
androidx.fragment.app.FragmentManagerKt twee keer binnen: een keer uit fragment.aar
en een keer uit het oude fragment-ktx.aar.
Door deze vijf expliciet op de versie te zetten die bij het al opgeloste basispackage
hoort, zijn de .Ktx-packages leeg (ze verwijzen alleen nog naar het hoofdpackage) en
is de dubbele klasse weg. Verhoogt een nieuwere MAUI de basispackages, dan komen de
NU1608-waarschuwingen terug; dat is het signaal om deze nummers mee te verhogen, niet
om het blok weg te halen. De nummers komen uit NuGet: Fragment.Ktx 1.9.0 bestaat,
Collection.Ktx 1.6.0.1, en de drie Lifecycle-packages op 2.11.0.1. -->
<PackageReference Include="Xamarin.AndroidX.Fragment.Ktx" Version="1.9.0" />
<PackageReference Include="Xamarin.AndroidX.Collection.Ktx" Version="1.6.0.1" />
<PackageReference Include="Xamarin.AndroidX.Lifecycle.LiveData" Version="2.11.0.1" />
<PackageReference Include="Xamarin.AndroidX.Lifecycle.LiveData.Core.Ktx" Version="2.11.0.1" />
<PackageReference Include="Xamarin.AndroidX.Lifecycle.Process" Version="2.11.0.1" />
<GoogleServicesJson Include="Platforms\Android\google-services.json" />
</ItemGroup>
</Project>
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:allowBackup="true"
android:icon="@mipmap/appicon"
android:roundIcon="@mipmap/appicon_round"
android:supportsRtl="true">
<!-- Op welk kanaal een melding terechtkomt die binnenkomt terwijl de app dicht is. Zonder
deze regel maakt Firebase daar zelf een kanaal "Overig" voor aan, en staat dezelfde
soort melding dus op twee plekken in de instellingen van de telefoon. De naam hoort
gelijk te zijn aan Meldingsdienst.Kanaal. -->
<meta-data android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="mijnbesluit-berichten" />
</application>
<!-- Om de app weer te openen nadat hij zichzelf heeft vergrendeld. Dit geeft geen toegang
tot vingerafdrukken: de app krijgt alleen ja of nee terug van het toestel. -->
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<!-- De app praat met de SocketServer. -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Camera en galerij, voor foto's bij een kluisitem en video's bij een moment.
Hier stond eerder bewust niets: hoe minder rechten, hoe minder er mis kan gaan. Maar
zonder CAMERA meldt Android dat het toestel niet kan opnemen, en verdwijnt de knop
"Video opnemen" uit de app zonder dat er iets misgaat of iets over te zien is.
Wat er NIET bij staat en ook niet bij hoort: locatie, contacten, en het schrijven naar
de opslag van de telefoon. We lezen alleen wat de gebruiker zelf aanwijst. -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<!-- Voor Android 12 en ouder; vanaf 13 doen de twee regels hierboven het werk. -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<!-- Niet verplicht: een toestel zonder camera mag de app gewoon installeren, dan valt
alleen de opnameknop weg. -->
<uses-feature android:name="android.hardware.camera" android:required="false" />
</manifest>
@@ -0,0 +1,14 @@
using Android.App;
using Android.Content.PM;
using Android.OS;
namespace MijnBesluit.App;
[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true,
LaunchMode = LaunchMode.SingleTop,
ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation |
ConfigChanges.UiMode | ConfigChanges.ScreenLayout |
ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
public class MainActivity : MauiAppCompatActivity
{
}
@@ -0,0 +1,24 @@
using Android.App;
using Android.Runtime;
namespace MijnBesluit.App;
[Application]
public class MainApplication : MauiApplication
{
public MainApplication(IntPtr handle, JniHandleOwnership ownership) : base(handle, ownership) { }
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
public override void OnCreate()
{
base.OnCreate();
#if FIREBASE
// Het meldingenkanaal moet er zijn voordat de eerste melding binnenkomt, ook als die
// binnenkomt terwijl de app dicht is. Hier en niet in de dienst zelf, want dit is het
// eerste wat draait.
Meldingsdienst.MaakKanaal(this);
#endif
}
}
@@ -0,0 +1,104 @@
#if FIREBASE
using Android.App;
using Android.Content;
using AndroidX.Core.App;
using Firebase.Messaging;
namespace MijnBesluit.App;
/// <summary>
/// Vangt meldingen op die binnenkomen terwijl de app openstaat.
///
/// Android zet een melding alleen zelf in de balk als de app op de achtergrond staat. Staat hij
/// open, dan geeft Android hem door aan deze dienst en gebeurt er verder niets - tenzij we hem
/// hier zelf tonen. Dat was precies het geval waarin de proefmelding "verstuurd" heette en er
/// op de telefoon niets te zien was.
///
/// De tekst komt uit het data-deel van het bericht en niet uit het notification-deel. Beide
/// staan erin (zie FirebasePush), maar het data-deel is het stuk waar de naamgeving in de
/// Android-binding niet per versie verschilt.
/// </summary>
[Service(Exported = false)]
[IntentFilter(new[] { "com.google.firebase.MESSAGING_EVENT" })]
public class Meldingsdienst : FirebaseMessagingService
{
/// <summary>
/// Het kanaal waarop deze meldingen binnenkomen. Vanaf Android 8 hoort elke melding bij een
/// kanaal, en kan de gebruiker per kanaal zeggen hoe opdringerig het mag zijn. De naam staat
/// ook in AndroidManifest.xml, zodat meldingen die binnenkomen terwijl de app dicht is op
/// hetzelfde kanaal terechtkomen en niet onder "Overig".
/// </summary>
public const string Kanaal = "mijnbesluit-berichten";
/// <summary>
/// Maakt het kanaal aan als het er nog niet is. Mag zo vaak aangeroepen worden als je wilt;
/// Android werkt een bestaand kanaal dan alleen bij. Gebeurt bij het starten van de app, want
/// een melding die binnenkomt terwijl de app dicht is heeft het kanaal al nodig.
/// </summary>
public static void MaakKanaal(Context context)
{
if (!OperatingSystem.IsAndroidVersionAtLeast(26)) return;
var kanaal = new NotificationChannel(Kanaal, "Berichten", NotificationImportance.High)
{
Description = "Laat weten dat er iets voor je klaarstaat. Wat erin staat komt er nooit bij te staan."
};
var beheer = (NotificationManager?)context.GetSystemService(Context.NotificationService);
beheer?.CreateNotificationChannel(kanaal);
}
public override void OnMessageReceived(RemoteMessage bericht)
{
var gegevens = bericht.Data;
gegevens.TryGetValue("titel", out var titel);
gegevens.TryGetValue("tekst", out var tekst);
if (string.IsNullOrWhiteSpace(titel)) titel = "MijnBesluit";
if (string.IsNullOrWhiteSpace(tekst)) tekst = "Er staat iets voor je klaar.";
Toon(this, titel, tekst);
}
/// <summary>
/// Firebase geeft een toestel af en toe een nieuw kenmerk. Het oude werkt dan niet meer.
/// De app meldt zich bij elke aanmelding opnieuw aan met het kenmerk van dat moment, dus dat
/// herstelt zichzelf zodra je weer inlogt. Zolang je ingelogd blijft kan er een melding
/// tussendoor vallen; dat is bewust niet hier opgelost, want hier is geen verbinding met de
/// server en geen sleutel om mee te praten.
/// </summary>
public override void OnNewToken(string kenmerk) => base.OnNewToken(kenmerk);
private static void Toon(Context context, string titel, string tekst)
{
MaakKanaal(context);
// Aantikken opent de app. SingleTop, zodat je niet een tweede keer hetzelfde scherm
// opent bovenop het scherm waar je al was.
var openen = new Intent(context, typeof(MainActivity));
openen.AddFlags(ActivityFlags.ClearTop | ActivityFlags.SingleTop);
var tik = PendingIntent.GetActivity(context, 0, openen,
PendingIntentFlags.UpdateCurrent | PendingIntentFlags.Immutable);
// ApplicationInfo.Icon en niet een eigen tekening: dat is een nummer dat Android zelf
// bijhoudt, dus hier hoeft geen tweede plaatje bijgehouden te worden dat kan ontbreken.
var melding = new NotificationCompat.Builder(context, Kanaal)
.SetContentTitle(titel)
.SetContentText(tekst)
.SetStyle(new NotificationCompat.BigTextStyle().BigText(tekst))
.SetSmallIcon(context.ApplicationInfo!.Icon)
.SetAutoCancel(true)
.SetPriority((int)NotificationPriority.High)
.SetContentIntent(tik)
.Build();
// Elk bericht een eigen nummer, anders vervangt de tweede melding de eerste.
NotificationManagerCompat.From(context).Notify(Volgnummer(), melding);
}
private static int _laatste;
private static int Volgnummer() => Interlocked.Increment(ref _laatste);
}
#endif
@@ -0,0 +1,29 @@
{
"project_info": {
"project_number": "1047194010071",
"project_id": "mijnbesluit-f04b5",
"storage_bucket": "mijnbesluit-f04b5.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:1047194010071:android:815622efe5c7b59a084b92",
"android_client_info": {
"package_name": "nl.mijnbesluit.app"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyDPDKCc6tQm8Juh9LwvvWlxmUFTrMCU4So"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}
@@ -0,0 +1,13 @@
using Foundation;
namespace MijnBesluit.App;
/// <summary>
/// De iOS-kant van het opstarten. Tegenhanger van MainApplication op Android: hij doet niets
/// meer dan dezelfde MauiProgram aanroepen, want alles wat de app ís staat in gedeelde code.
/// </summary>
[Register("AppDelegate")]
public class AppDelegate : MauiUIApplicationDelegate
{
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}
@@ -0,0 +1,86 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIDeviceFamily</key>
<array>
<!-- 1 = iPhone, 2 = iPad. De schermen zijn gemaakt voor een telefoon, maar een iPad
weigeren levert alleen een gebruiker op die het niet snapt. -->
<integer>1</integer>
<integer>2</integer>
</array>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>XSAppIconAssets</key>
<string>Assets.xcassets/appicon.appiconset</string>
<!-- ==========================================================================
Waarvoor de app iets van het toestel vraagt.
Deze teksten zijn geen formaliteit. iOS laat ze letterlijk zien op het moment
dat de app het vraagt, en als de tekst ontbreekt sluit iOS de app ter plekke
af - geen foutmelding, hij is gewoon weg. Dat is de klassieke "hij doet het
op Android wel"-val.
Ze staan in het Nederlands omdat de app Nederlands is; iemand die hier "ja"
op moet zeggen, hoort te kunnen lezen waarvoor.
========================================================================== -->
<key>NSFaceIDUsageDescription</key>
<string>Om MijnBesluit te openen zonder telkens een code uit je mail te halen. Je gezicht wordt door je telefoon gecontroleerd; MijnBesluit krijgt het nooit te zien.</string>
<key>NSCameraUsageDescription</key>
<string>Om een foto of video te maken die je in je kluis bewaart of als bericht meestuurt.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Om het geluid op te nemen bij een videobericht.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Om een foto of document uit je fotobibliotheek toe te voegen aan je kluis of aan een bericht.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Om een foto uit MijnBesluit op te slaan in je fotobibliotheek.</string>
<!-- ==========================================================================
BEWUST NOG NIET INGEVULD: ITSAppUsesNonExemptEncryption.
Apple vraagt bij elke inzending of de app encryptie gebruikt die buiten de
vrijstellingen valt. Zet je hier `false`, dan verklaar je dat dat niet zo is -
en dat is een verklaring met juridisch gewicht, geen vinkje. Deze app
versleutelt een kluis met gegevens, dus dat antwoord is niet vanzelfsprekend
"nee" zoals bij een app die alleen HTTPS gebruikt.
Laat dit staan zoals het is: zonder deze sleutel vraagt App Store Connect het
bij elke inzending, en dan beantwoord je het bewust. Wil je er vanaf, laat het
dan eerst nakijken door iemand die deze regels kent, en zet het antwoord er
daarna bij met de datum en de reden erbij in dit bestand.
Wat hier NIET hoort: een uitzondering op App Transport Security. De verbinding
met de SocketServer is een eigen TLS-socket en geen URL-verkeer, dus ATS gaat
er niet over. Een uitzondering toevoegen die je niet nodig hebt, levert alleen
vragen op bij de beoordeling.
========================================================================== -->
</dict>
</plist>
@@ -0,0 +1,14 @@
using ObjCRuntime;
using UIKit;
namespace MijnBesluit.App;
/// <summary>Het startpunt op iOS. Android heeft dit niet nodig; daar start Android de Activity.</summary>
public class Program
{
private static void Main(string[] args)
{
// Vanaf hier neemt UIKit het over en roept het AppDelegate aan.
UIApplication.Main(args, null, typeof(AppDelegate));
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+36
View File
@@ -0,0 +1,36 @@
using MijnBesluit.Client;
namespace MijnBesluit.App;
/// <summary>
/// Bewaart het sessietoken in de beveiligde opslag van het toestel: op Android is dat de
/// Keystore, op iOS de Keychain. Dus niet in een gewoon bestand, want daar kan een andere
/// app of iemand met het toestel in handen bij.
/// </summary>
public class ToestelTokenopslag : ITokenopslag
{
private const string Sleutel = "mijnbesluit-sessie";
public async Task<string?> LeesAsync()
{
try { return await SecureStorage.Default.GetAsync(Sleutel); }
catch
{
// Op sommige toestellen is de beveiligde opslag stuk na een herstel uit back-up.
// Dan is er gewoon geen sessie en logt de gebruiker opnieuw in.
return null;
}
}
public async Task SchrijfAsync(string token)
{
try { await SecureStorage.Default.SetAsync(Sleutel, token); }
catch { /* Niet kunnen onthouden is vervelend, maar geen reden om de app te laten vallen. */ }
}
public Task WisAsync()
{
SecureStorage.Default.Remove(Sleutel);
return Task.CompletedTask;
}
}
+159
View File
@@ -0,0 +1,159 @@
using Microsoft.Maui.Graphics;
using Microsoft.Maui.Graphics.Platform;
using MijnBesluit.AppUi;
namespace MijnBesluit.App;
/// <summary>
/// Het kiezen en maken van bestanden op een Android- of iOS-toestel, met de kiezers die MAUI
/// daarvoor meelevert. De schermen zien hier niets van; die kennen alleen
/// <see cref="IBestandskiezer"/>.
///
/// Foto's worden hier verkleind voordat ze de deur uit gaan. Een toestelcamera maakt tegenwoordig
/// bestanden van tien megabyte of meer, en die gaan in stukken van een halve megabyte over de
/// lijn - op een mobiele verbinding duurt dat te lang voor iets waar je alleen maar naar kijkt.
/// Naar 1600 pixels terugbrengen scheelt een factor tien tot twintig en is op een telefoon niet
/// te zien. Video's blijven zoals ze zijn: opnieuw coderen kost meer tijd dan het oplevert.
/// </summary>
public class Toestelbestanden : IBestandskiezer
{
/// <summary>De langste zijde van een foto die wordt verstuurd.</summary>
public const int MaxZijde = 1600;
/// <summary>Hoe hard er op de foto mag worden ingeleverd. 0.8 is ruim voldoende om te tonen.</summary>
private const float Kwaliteit = 0.8f;
/// <summary>Onder deze grootte is verkleinen de moeite niet waard.</summary>
private const int VerkleinVanaf = 400 * 1024;
public bool KanCamera => MediaPicker.Default.IsCaptureSupported;
public async Task<GekozenBestand?> KiesDocumentAsync()
{
var gekozen = await FilePicker.Default.PickAsync(new PickOptions
{
PickerTitle = "Kies een document"
});
return await LeesAsync(gekozen, "application/octet-stream");
}
public async Task<GekozenBestand?> MaakFotoAsync()
{
if (!MediaPicker.Default.IsCaptureSupported) return null;
// De camera geeft een naam van tweeëndertig tekens zonder betekenis. Die staat straks in
// je kluis en zegt je niets; het moment waarop je de foto maakte wel.
return await LeesAsync(await MediaPicker.Default.CapturePhotoAsync(), "image/jpeg",
Eigennaam("Foto", ".jpg"));
}
public async Task<GekozenBestand?> KiesFotoAsync()
{
// PickPhotosAsync in plaats van PickPhotoAsync: die laatste is vervallen. We nemen de
// eerste foto; meerdere tegelijk kiezen komt later als het nodig blijkt.
var gekozen = await MediaPicker.Default.PickPhotosAsync();
return await LeesAsync(gekozen?.FirstOrDefault(), "image/jpeg");
}
public async Task<GekozenBestand?> NeemVideoOpAsync()
{
if (!MediaPicker.Default.IsCaptureSupported) return null;
return await LeesAsync(await MediaPicker.Default.CaptureVideoAsync(), "video/mp4",
Eigennaam("Video", ".mp4"));
}
public async Task<GekozenBestand?> KiesVideoAsync()
{
// PickVideosAsync in plaats van PickVideoAsync, om dezelfde reden als bij de foto's: die
// laatste is vervallen. We nemen de eerste video; meerdere tegelijk kiezen komt later als
// het nodig blijkt.
var gekozen = await MediaPicker.Default.PickVideosAsync();
return await LeesAsync(gekozen?.FirstOrDefault(), "video/mp4");
}
/// <summary>
/// Leest het gekozen bestand helemaal in. Dat kan omdat er een grens op de grootte staat;
/// zonder die grens zou een lange video-opname het geheugen van de app opmaken.
/// </summary>
private static async Task<GekozenBestand?> LeesAsync(FileResult? gekozen, string standaardSoort,
string? naam = null)
{
if (gekozen is null) return null;
using var stroom = await gekozen.OpenReadAsync();
using var buffer = new MemoryStream();
await stroom.CopyToAsync(buffer);
var soort = string.IsNullOrWhiteSpace(gekozen.ContentType) ? standaardSoort : gekozen.ContentType;
var inhoud = buffer.ToArray();
if (soort.StartsWith("image/", StringComparison.OrdinalIgnoreCase) && inhoud.Length > VerkleinVanaf)
inhoud = Verklein(inhoud) ?? inhoud;
return new GekozenBestand(naam ?? Bruikbaar(gekozen.FileName), soort, inhoud);
}
/// <summary>
/// Een naam waar je later nog iets aan hebt: "Foto 15-09-2026 14.10". De tijd erbij, want op
/// één dag maak je er zo drie en dan wil je ze uit elkaar kunnen houden.
/// </summary>
private static string Eigennaam(string soort, string extensie) =>
// De punt tussen uren en minuten staat tussen enkele aanhalingstekens. Dat is hoe een
// opmaakpatroon in .NET zegt "letterlijk dit teken"; een backslash zou hier een
// ongeldige ontsnappingsreeks in C# zijn, en een kale punt hoort bij .fff.
$"{soort} {DateTime.Now:dd-MM-yyyy HH'.'mm}{extensie}";
/// <summary>
/// Een naam die het toestel zelf aandraagt, tenzij die nergens op slaat. Een galerij geeft
/// soms alleen een lange reeks tekens terug; daar is een datum beter dan.
/// </summary>
private static string Bruikbaar(string? naam)
{
if (string.IsNullOrWhiteSpace(naam)) return Eigennaam("Bestand", "");
var kaal = Path.GetFileNameWithoutExtension(naam);
var extensie = Path.GetExtension(naam);
// Twintig tekens of meer, en geen enkele klinker of streepje: dat is geen naam maar een
// kenmerk. Denk aan 824c9d46a1b749ab9f92986b.
var onleesbaar = kaal.Length >= 20 && kaal.All(char.IsLetterOrDigit)
&& !kaal.Any(c => "aeiouAEIOU".Contains(c));
return onleesbaar ? Eigennaam("Bestand", extensie) : naam;
}
/// <summary>
/// Brengt een foto terug tot <see cref="MaxZijde"/> pixels op de langste zijde.
///
/// Gaat er iets mis - een bestand dat geen afbeelding blijkt, een formaat dat het toestel
/// niet kent - dan geven we null terug en gaat het origineel gewoon de deur uit. Een foto
/// die traag verstuurt is vervelend; een foto die helemaal niet aankomt is erger.
/// </summary>
private static byte[]? Verklein(byte[] inhoud)
{
try
{
using var bron = new MemoryStream(inhoud);
using var afbeelding = PlatformImage.FromStream(bron);
// Downsize doet niets als de foto al kleiner is; dan zijn we alsnog klaar, want het
// opnieuw opslaan levert bij die grootte weinig op.
if (afbeelding.Width <= MaxZijde && afbeelding.Height <= MaxZijde) return null;
using var kleiner = afbeelding.Downsize(MaxZijde, disposeOriginal: false);
using var uit = new MemoryStream();
kleiner.Save(uit, ImageFormat.Jpeg, Kwaliteit);
var verkleind = uit.ToArray();
// Alleen als het echt scheelt. Een enkele keer is het resultaat groter dan het
// origineel; dan is het origineel de betere keus.
return verkleind.Length > 0 && verkleind.Length < inhoud.Length ? verkleind : null;
}
catch (Exception)
{
return null;
}
}
}
+30
View File
@@ -0,0 +1,30 @@
using MijnBesluit.AppUi;
namespace MijnBesluit.App;
/// <summary>
/// Opent een adres in de browser van de telefoon. Bewust buiten de app: een advertentielink die
/// binnen de webview opent maakt van MijnBesluit een browser, en dan staat iemand op de website
/// van een adverteerder zonder weg terug naar zijn eigen kluis.
/// </summary>
public class Toestelbuitenlink : IBuitenlink
{
public async Task OpenAsync(string adres)
{
if (!Uri.TryCreate(adres, UriKind.Absolute, out var adresje)) return;
// Alleen http en https. Een ander schema kan een andere app openen, en dat is niet wat
// hier hoort te gebeuren.
if (adresje.Scheme != Uri.UriSchemeHttp && adresje.Scheme != Uri.UriSchemeHttps) return;
try
{
await Browser.Default.OpenAsync(adresje, BrowserLaunchMode.SystemPreferred);
}
catch (Exception)
{
// Geen browser op dit toestel, of de gebruiker brak het af. Dat is geen reden om de
// app te laten vallen.
}
}
}
@@ -0,0 +1,25 @@
using MijnBesluit.AppUi;
namespace MijnBesluit.App;
/// <summary>
/// Instellingen die alleen op dit toestel gelden, in de gewone voorkeursopslag van Android.
///
/// Bewust niet in de Keystore: daar hoort alleen in wat geheim is. Hoe lang het duurt voordat
/// de app zichzelf uitlogt is geen geheim, en de Keystore is traag en kan stuk na een herstel
/// uit back-up.
/// </summary>
public class Toestelinstellingen : IAppinstellingen
{
public int Lees(string sleutel, int standaard)
{
try { return Preferences.Default.Get(sleutel, standaard); }
catch { return standaard; }
}
public void Schrijf(string sleutel, int waarde)
{
try { Preferences.Default.Set(sleutel, waarde); }
catch { /* Niet kunnen onthouden is vervelend, maar geen reden om de app te laten vallen. */ }
}
}
+79
View File
@@ -0,0 +1,79 @@
using MijnBesluit.AppUi;
#if ANDROID && FIREBASE
// AsAsync() maakt van een Task van Google Play Services een gewone await-bare Task. Het is een
// uitbreidingsmethode, dus zonder deze using bestaat hij niet en zegt de compiler alleen dat
// "Task" hem niet kent. Staat achter dezelfde schakelaar als de code die hem gebruikt: zonder
// het Firebase-package bestaat deze naamruimte niet.
using Android.Gms.Extensions;
#endif
namespace MijnBesluit.App;
/// <summary>
/// Haalt het Firebase-kenmerk van dit toestel op.
///
/// Let op: de regels hieronder met <c>#if ANDROID</c> werken pas als het package
/// <c>Xamarin.Firebase.Messaging</c> in MijnBesluit.App zit én <c>google-services.json</c>
/// in Platforms\Android staat. Zolang dat er niet is, geeft deze klasse null terug en werkt
/// de app gewoon - alleen zonder meldingen. Zie docs/Meldingen.md.
///
/// Op iOS is dat nu ook zo: er komt geen kenmerk en dus geen melding, en de rest van de app
/// werkt gewoon. Dat is met opzet geen half werkende poging. Meldingen op iOS lopen via APNs,
/// en daarvoor is meer nodig dan code: een APNs-sleutel uit het Apple Developer-account, de
/// Push Notifications-capability, een provisioning profile en een Entitlements.plist. Dat hangt
/// aan het account en niet aan dit bestand, dus het komt erbij zodra dat account er is.
/// Zie docs/iOS-app.md.
/// </summary>
public class Toestelmeldingen : IMeldingen
{
public string Platform =>
#if IOS
"ios";
#else
"android";
#endif
public string? Toestelnaam => DeviceInfo.Current.Name;
public async Task<string?> KenmerkAsync()
{
// Android 13 en hoger vraagt apart toestemming voor meldingen. Zonder die toestemming
// komt er wel een kenmerk, maar ziet de gebruiker nooit iets.
if (!await ToestemmingAsync()) return null;
#if ANDROID && FIREBASE
try
{
// GetToken staat in deze versie van de binding als verouderd gemarkeerd, maar het is
// wel de methode die werkt en waar Google zelf naar verwijst. Zodra er een opvolger
// is die het kenmerk teruggeeft, hoort die hier te komen.
var taak = Firebase.Messaging.FirebaseMessaging.Instance.GetToken();
var kenmerk = await taak.AsAsync<Java.Lang.String>();
return kenmerk?.ToString();
}
catch (Exception)
{
// Geen Google Play-diensten op dit toestel, of Firebase niet ingericht.
return null;
}
#else
return null;
#endif
}
private static async Task<bool> ToestemmingAsync()
{
#if ANDROID
if (!OperatingSystem.IsAndroidVersionAtLeast(33)) return true;
var stand = await Permissions.CheckStatusAsync<Permissions.PostNotifications>();
if (stand != PermissionStatus.Granted)
stand = await Permissions.RequestAsync<Permissions.PostNotifications>();
return stand == PermissionStatus.Granted;
#else
return await Task.FromResult(true);
#endif
}
}
+141
View File
@@ -0,0 +1,141 @@
using MijnBesluit.AppUi;
#if ANDROID
using Android.App;
using Android.Content;
using Android.Hardware.Biometrics;
using Android.OS;
#elif IOS
using LocalAuthentication;
#endif
namespace MijnBesluit.App;
/// <summary>
/// Vraagt het toestel om iemand te controleren: vingerafdruk, gezicht, of de pincode of het
/// patroon van de telefoon zelf.
///
/// Wat hier NIET gebeurt: de app vraagt geen eigen pincode en bewaart er ook geen. Er is dus
/// niets bij te houden, niets te vergeten en niets te lekken. De telefoon doet de controle in
/// een apart stuk hardware waar geen enkele app bij kan, en geeft alleen ja of nee terug.
///
/// Vanaf Android 10. Daarvoor bestaat dit venster nog niet in de vorm waarin het ook de code
/// van de telefoon accepteert, en dan zou een vingerafdrukloze telefoon er niet meer in komen.
/// Op oudere toestellen is <see cref="Beschikbaar"/> dus onwaar en logt de app uit zoals eerst.
///
/// Op iOS doet LocalAuthentication hetzelfde, met dezelfde afspraak: Face ID of Touch ID, en
/// de toestelcode als terugval. Dat laatste zit daar in de keuze van het beleid
/// (DeviceOwnerAuthentication) in plaats van in een aparte schakelaar.
/// </summary>
public class Toestelslot : IToestelslot
{
#if ANDROID
public bool Beschikbaar
{
get
{
// Zonder schermslot heeft vergrendelen geen zin: dan komt iedereen die de telefoon
// in handen heeft er met één veeg weer in, en is uitloggen de enige echte grendel.
if (!OperatingSystem.IsAndroidVersionAtLeast(29)) return false;
var bewaking = (KeyguardManager?)Platform.AppContext.GetSystemService(Context.KeyguardService);
return bewaking?.IsDeviceSecure == true;
}
}
public Task<bool> OntgrendelAsync(string titel, string uitleg)
{
if (!Beschikbaar) return Task.FromResult(false);
var activiteit = Platform.CurrentActivity;
if (activiteit is null) return Task.FromResult(false);
var klaar = new TaskCompletionSource<bool>();
try
{
// SetDeviceCredentialAllowed is vanaf Android 11 als verouderd gemarkeerd, maar het
// is de enige manier die vanaf Android 10 werkt én de code van de telefoon als
// terugval toelaat. De opvolger (SetAllowedAuthenticators) bestaat pas vanaf 11.
// Zolang de app vanaf 10 werkt, hoort dit hier te staan.
var venster = new BiometricPrompt.Builder(activiteit)
.SetTitle(titel)
.SetDescription(uitleg)
.SetDeviceCredentialAllowed(true)
.Build();
// Geen annuleersein dat we zelf afbreken: het venster heeft zijn eigen knop, en de
// uitkomst daarvan komt via OnAuthenticationError binnen.
venster.Authenticate(new CancellationSignal(), activiteit.MainExecutor!, new Antwoord(klaar));
}
catch (Exception)
{
// Een toestel dat hier struikelt hoort de app niet te laten vallen. Het slot blijft
// dan dicht en je kunt nog altijd helemaal uitloggen.
klaar.TrySetResult(false);
}
return klaar.Task;
}
/// <summary>Wat het venster terugmeldt.</summary>
private sealed class Antwoord : BiometricPrompt.AuthenticationCallback
{
private readonly TaskCompletionSource<bool> _klaar;
public Antwoord(TaskCompletionSource<bool> klaar) => _klaar = klaar;
public override void OnAuthenticationSucceeded(BiometricPrompt.AuthenticationResult? uitkomst) =>
_klaar.TrySetResult(true);
/// <summary>Afgebroken, te vaak mis, of het toestel kan het niet. Hoe dan ook: niet open.</summary>
public override void OnAuthenticationError(BiometricErrorCode code, Java.Lang.ICharSequence? tekst) =>
_klaar.TrySetResult(false);
/// <summary>
/// Eén poging die niet klopte - een vinger die verkeerd lag. Het venster blijft staan en
/// laat het opnieuw proberen, dus hier hoort niets te gebeuren. Pas als het toestel er
/// mee ophoudt komt OnAuthenticationError.
/// </summary>
public override void OnAuthenticationFailed() { }
}
#elif IOS
public bool Beschikbaar
{
get
{
// DeviceOwnerAuthentication en niet DeviceOwnerAuthenticationWithBiometrics: het
// eerste is "Face ID of Touch ID, en anders de code van de telefoon", het tweede
// eist een vingerafdruk of gezicht en niets anders. Dat laatste zou betekenen dat
// een toestel met alleen een cijfercode er niet meer in komt - precies de gebruiker
// die we op Android ook niet buitensluiten.
using var vraag = new LAContext();
return vraag.CanEvaluatePolicy(LAPolicy.DeviceOwnerAuthentication, out _);
}
}
public async Task<bool> OntgrendelAsync(string titel, string uitleg)
{
// iOS toont één regel tekst en geen kop, dus de titel valt hier weg. Wat er in beeld
// komt is de uitleg; die hoort dus op zichzelf te kunnen staan.
//
// Let op: staat NSFaceIDUsageDescription niet in Info.plist, dan sluit iOS de app hier
// ter plekke af - geen foutmelding, hij is weg. Zie Platforms/iOS/Info.plist; er is een
// zelfcontrole die daarop let.
using var vraag = new LAContext();
if (!vraag.CanEvaluatePolicy(LAPolicy.DeviceOwnerAuthentication, out _)) return false;
// Afgebroken, te vaak mis, of het toestel kan het niet: dan komt hier onwaar uit en
// blijft het slot dicht. De fout zelf laten we liggen - er valt voor de gebruiker niets
// mee te doen, en op het slotscherm staat al wat hij nog wél kan.
var (gelukt, _) = await vraag.EvaluatePolicyAsync(LAPolicy.DeviceOwnerAuthentication, uitleg);
return gelukt;
}
#else
public bool Beschikbaar => false;
public Task<bool> OntgrendelAsync(string titel, string uitleg) => Task.FromResult(false);
#endif
}
+36
View File
@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>MijnBesluit</title>
<!-- Dezelfde opmaak als de website; het bestand komt uit MijnBesluit.AppUi. -->
<link rel="stylesheet" href="_content/MijnBesluit.AppUi/app.css" />
<style>
/* Ruimte voor de statusbalk en de knoppen onderaan de telefoon. */
body { padding: env(safe-area-inset-top) env(safe-area-inset-right)
env(safe-area-inset-bottom) env(safe-area-inset-left); }
#blazor-error-ui {
display: none; position: fixed; bottom: 0; left: 0; right: 0; z-index: 1000;
background: #fbf1e3; color: #8a5a22; padding: .8rem 1rem;
font: 15px "Segoe UI", system-ui, sans-serif;
}
</style>
</head>
<body>
<div id="app">
<div style="padding:2rem;text-align:center;color:#6b645c;font:17px 'Segoe UI',system-ui,sans-serif">
Even geduld…
</div>
</div>
<div id="blazor-error-ui">
Er ging iets mis. <a href="" class="reload">Opnieuw laden</a>
</div>
<script src="_content/MijnBesluit.AppUi/validatie.js"></script>
<script src="_framework/blazor.webview.js" autostart="false"></script>
</body>
</html>
+152
View File
@@ -0,0 +1,152 @@
using MijnBesluit.Client;
namespace MijnBesluit.AppUi;
/// <summary>
/// Wat de app over de lopende sessie weet. Eén exemplaar per app (singleton), zodat elk scherm
/// dezelfde verbinding en dezelfde aanmelding gebruikt.
///
/// Hier zit bewust geen regelgeving: wat mag en niet mag bepaalt de server. Dit is alleen het
/// geheugen van de schermen.
/// </summary>
public class AppToestand
{
private string? _laatsteKenmerk;
/// <summary>Optioneel: zonder dit werkt de app gewoon, alleen zonder meldingen.</summary>
public IMeldingen? Meldingen { get; }
public AppToestand(MijnBesluitClient client, IMeldingen? meldingen = null)
{
Client = client;
Meldingen = meldingen;
Client.VerbindingVeranderd += nu => { Verbonden = nu; Veranderd?.Invoke(); };
Client.MeldingOntvangen += melding =>
{
Verbonden = true;
LaatsteMelding = melding;
Veranderd?.Invoke();
};
// De server kent ons token niet meer. Het is aan de kant van de client al opgeruimd;
// hier gooien we weg wat er nog van die sessie in het geheugen stond.
Client.SessieVerlopen += () =>
{
Familie = null;
LaatsteMelding = null;
SessieVerlopen?.Invoke();
Veranderd?.Invoke();
};
}
public MijnBesluitClient Client { get; }
/// <summary>Er is iets veranderd waar de schermen op moeten reageren.</summary>
public event Action? Veranderd;
/// <summary>
/// De sessie is aan de serverkant weg. De schil stuurt je dan naar het inlogscherm: blijven
/// staan met een knop "opnieuw proberen" heeft geen zin, want dat lukt nooit meer.
/// </summary>
public event Action? SessieVerlopen;
public bool IsAangemeld => Client.IsAangemeld;
public long PersoonId => Client.PersoonId;
/// <summary>
/// Staat er verbinding met de SocketServer? Puur om te tonen. Wegvallen is normaal: zodra
/// de app op de achtergrond gaat verbreekt Android de verbinding, en de eerstvolgende
/// aanroep zet hem weer op.
/// </summary>
public bool Verbonden { get; private set; } = true;
/// <summary>Het laatste bericht dat de server uit zichzelf stuurde.</summary>
public string? LaatsteMelding { get; private set; }
/// <summary>De familie waar de schermen mee werken; bij de meeste mensen is er maar één.</summary>
public FamilieOverzicht? Familie { get; private set; }
/// <summary>
/// Je eigen kleine profielfoto, klaar om in een img-element te zetten; null als je er geen
/// hebt. Schermen die jou als persoon tonen gebruiken deze, zodat het overal hetzelfde
/// plaatje is en niemand hem apart hoeft op te halen.
/// </summary>
public string? MijnFoto => Client.MijnProfiel?.Profielfotobron;
/// <summary>Je eigen naam, of een lege tekst zolang die niet bekend is.</summary>
public string MijnNaam => Client.MijnProfiel?.Naam ?? "";
/// <summary>
/// Haalt op wat elk scherm nodig heeft. Wordt aangeroepen na het aanmelden en bij het
/// hervatten van een sessie, zodat de schermen zelf niets hoeven te onthouden.
/// </summary>
public async Task<Uitkomst> VerversAsync(CancellationToken ct = default)
{
var families = await Client.FamiliesAsync(ct);
if (!families.Gelukt) return Uitkomst.Fout(families.Melding!);
// Bij het hervatten van een sessie is het eigen profiel al opgehaald; na het inloggen
// met een code nog niet. Dan halen we het hier alsnog op, zodat elk scherm je naam en
// je kleine profielfoto heeft zonder er zelf om te hoeven vragen. Gaat het mis, dan is
// dat geen reden om het scherm niet te tonen: het is een plaatje.
if (Client.MijnProfiel is null)
{
try { await Client.EigenProfielAsync(ct); }
catch (Exception) { /* dan maar zonder foto */ }
}
Familie = families.Waarde!.FirstOrDefault(f => f.Eigenaar) ?? families.Waarde!.FirstOrDefault();
Verbonden = true;
Veranderd?.Invoke();
return Uitkomst.Ok();
}
/// <summary>Probeert de vorige sessie op te pakken. Lukt dat niet, dan moet er ingelogd worden.</summary>
public async Task<bool> HervatAsync(CancellationToken ct = default)
{
if (!await Client.HervatSessieAsync(ct)) return false;
await VerversAsync(ct);
await MeldToestelAanAsync(ct);
return true;
}
/// <summary>
/// Meldt dit toestel aan voor meldingen. Gebeurt bij elk aanmelden en bij elke hervatte
/// sessie, want het kenmerk van Firebase verandert af en toe vanzelf - bijvoorbeeld na het
/// terugzetten van een back-up. Blijft het oude kenmerk dan staan, dan gaan de meldingen
/// het luchtledige in.
/// </summary>
public async Task MeldToestelAanAsync(CancellationToken ct = default)
{
if (Meldingen is null || !IsAangemeld) return;
try
{
var kenmerk = await Meldingen.KenmerkAsync();
if (string.IsNullOrWhiteSpace(kenmerk)) return;
_laatsteKenmerk = kenmerk;
await Client.ToestelAanmeldenAsync(kenmerk, Meldingen.Platform, Meldingen.Toestelnaam, ct);
}
catch (Exception)
{
// Geen meldingen is vervelend, maar mag het gebruik van de app niet in de weg zitten.
}
}
public async Task UitloggenAsync()
{
// Eerst het toestel afmelden: anders krijgt de volgende die op deze telefoon inlogt
// de meldingen van de vorige te zien.
if (_laatsteKenmerk is not null)
{
try { await Client.ToestelAfmeldenAsync(_laatsteKenmerk); } catch (Exception) { }
_laatsteKenmerk = null;
}
await Client.UitloggenAsync();
Familie = null;
LaatsteMelding = null;
Veranderd?.Invoke();
}
}
+56
View File
@@ -0,0 +1,56 @@
namespace MijnBesluit.AppUi;
/// <summary>Een bestand dat de gebruiker heeft gekozen of net heeft gemaakt.</summary>
public record GekozenBestand(string Naam, string Soort, byte[] Inhoud)
{
public bool IsAfbeelding => Soort.StartsWith("image/", StringComparison.OrdinalIgnoreCase);
public bool IsVideo => Soort.StartsWith("video/", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Het kiezen van een bestand op het toestel.
///
/// Dit staat bewust als afspraak in de schermen en niet als code: een webview kan zelf geen
/// bestandskiezer of camera openen, dat moet de app eromheen doen. De schermen hoeven daardoor
/// niets te weten van Android of iOS.
///
/// Maken en kiezen staan los van elkaar. Dat is geen dubbeling: wie een foto van zijn paspoort
/// wil toevoegen, maakt hem ter plekke; wie de foto van vorige zomer bedoelt, zoekt hem op.
/// </summary>
public interface IBestandskiezer
{
/// <summary>Een document kiezen: pdf, scan, tekstbestand.</summary>
Task<GekozenBestand?> KiesDocumentAsync();
/// <summary>Een foto maken met de camera.</summary>
Task<GekozenBestand?> MaakFotoAsync();
/// <summary>Een foto kiezen uit de galerij.</summary>
Task<GekozenBestand?> KiesFotoAsync();
/// <summary>Een video opnemen met de camera.</summary>
Task<GekozenBestand?> NeemVideoOpAsync();
/// <summary>Een video kiezen uit de galerij.</summary>
Task<GekozenBestand?> KiesVideoAsync();
/// <summary>
/// Heeft dit toestel een camera die we mogen gebruiken? Zo niet, dan tonen we de knoppen
/// voor maken en opnemen niet - een knop die niets doet is erger dan geen knop.
/// </summary>
bool KanCamera { get; }
}
/// <summary>
/// Wordt gebruikt als er geen echte kiezer is aangemeld, bijvoorbeeld wanneer de schermen in
/// een gewone browser draaien. Levert nooit een bestand op, maar laat ook niets omvallen.
/// </summary>
public sealed class GeenBestandskiezer : IBestandskiezer
{
public Task<GekozenBestand?> KiesDocumentAsync() => Task.FromResult<GekozenBestand?>(null);
public Task<GekozenBestand?> MaakFotoAsync() => Task.FromResult<GekozenBestand?>(null);
public Task<GekozenBestand?> KiesFotoAsync() => Task.FromResult<GekozenBestand?>(null);
public Task<GekozenBestand?> NeemVideoOpAsync() => Task.FromResult<GekozenBestand?>(null);
public Task<GekozenBestand?> KiesVideoAsync() => Task.FromResult<GekozenBestand?>(null);
public bool KanCamera => false;
}
+80
View File
@@ -0,0 +1,80 @@
using MijnBesluit.Client;
namespace MijnBesluit.AppUi;
/// <summary>
/// Bijlagen die iemand heeft gekozen terwijl het ding waar ze bij horen nog niet bestaat.
///
/// Bij een persoon kun je meteen een foto toevoegen: die persoon staat er al. Bij een nieuw
/// kluisitem of een nieuw bericht niet — daar is nog geen nummer om het bestand aan te hangen.
/// Daar stond daarom tot nu toe alleen een zinnetje "dat kan zodra dit is opgeslagen". Dat is
/// een uitleg van onze boekhouding, en daar heeft iemand die een foto van zijn polis wil
/// toevoegen niets aan.
///
/// Dus houden we ze hier vast tot het opslaan klaar is, en sturen ze daarna alsnog. De inhoud
/// zit toch al in het geheugen: de bestandskiezer geeft de bytes terug, ook als we ze meteen
/// zouden versturen. Ze even vasthouden kost dus niets extra's.
///
/// In het bijlagenblok krijgen ze een negatief nummer. Dat is het teken dat ze nog nergens
/// staan: het blok laat ze zien met "wordt opgeslagen", probeert ze niet op te halen, en
/// verwijderen haalt ze hier weg in plaats van bij de server.
/// </summary>
public class Bijlagewachtrij
{
private readonly List<GekozenBestand> _wachtenden = new();
public int Aantal => _wachtenden.Count;
public bool Leeg => _wachtenden.Count == 0;
public void VoegToe(GekozenBestand bestand) => _wachtenden.Add(bestand);
public void Leegmaken() => _wachtenden.Clear();
/// <summary>Of dit nummer bij een wachtende bijlage hoort en niet bij een opgeslagen.</summary>
public static bool Wacht(Bijlage bijlage) => bijlage.Id <= 0;
/// <summary>Hoe de wachtenden eruitzien in het bijlagenblok.</summary>
public IReadOnlyList<Bijlage> AlsBijlagen(DateTime nu) =>
_wachtenden.Select((b, i) => new Bijlage(-(i + 1), b.Naam, b.Soort, b.Inhoud.LongLength, nu))
.ToList();
public void Verwijder(Bijlage bijlage)
{
var plek = (int)(-bijlage.Id) - 1;
if (plek >= 0 && plek < _wachtenden.Count) _wachtenden.RemoveAt(plek);
}
/// <summary>
/// Verstuurt alles wat wachtte, nu het nummer bekend is, en maakt de wachtrij leeg.
///
/// Geeft de eerste melding terug die misging, of null als alles is gelukt. Bij een fout
/// stopt hij: gaat de verbinding eruit, dan gaan de volgende ook mis, en dan is één
/// melding duidelijker dan vijf dezelfde.
/// </summary>
public async Task<string?> VerstuurAsync(MijnBesluitClient client, long? kluisItemId = null,
long? berichtId = null, long? persoonId = null,
Action<string, int>? voortgang = null,
CancellationToken ct = default)
{
foreach (var bestand in _wachtenden.ToList())
{
voortgang?.Invoke(bestand.Naam, 0);
var uitkomst = await client.BestandVersturenAsync(
bestand.Naam, bestand.Soort, bestand.Inhoud, kluisItemId,
(gedaan, totaal) => voortgang?.Invoke(bestand.Naam,
totaal == 0 ? 100 : (int)(gedaan * 100 / totaal)),
persoonId, berichtId, ct);
if (!uitkomst.Gelukt)
{
// Wat al is verstuurd staat er; de rest blijft wachten, zodat iemand het opnieuw
// kan proberen zonder alles opnieuw te kiezen.
return uitkomst.Melding;
}
_wachtenden.Remove(bestand);
}
return null;
}
}
+20
View File
@@ -0,0 +1,20 @@
namespace MijnBesluit.AppUi;
/// <summary>
/// Een adres buiten de app openen, in de browser van de telefoon.
///
/// Net als de bestandskiezer staat dit als afspraak in de schermen en niet als code: een webview
/// kan dit niet zelf, dat moet de app eromheen doen. En het hóórt naar buiten te gaan - een
/// advertentielink die binnen de app opent maakt van MijnBesluit een browser, en dan staat iemand
/// op de website van een adverteerder zonder terugknop naar zijn eigen kluis.
/// </summary>
public interface IBuitenlink
{
Task OpenAsync(string adres);
}
/// <summary>Wordt gebruikt als er geen implementatie is. Doet niets, en klapt dus ook niet.</summary>
public sealed class Geenbuitenlink : IBuitenlink
{
public Task OpenAsync(string adres) => Task.CompletedTask;
}
+444
View File
@@ -0,0 +1,444 @@
@inherits LayoutComponentBase
@implements IDisposable
@using Microsoft.JSInterop
@inject AppToestand Toestand
@inject NavigationManager Navigatie
@inject Terugknop Terugknoppen
@inject Uitlogklok Klok
@inject IToestelslot Slot
@inject VernieuwSignaal Vernieuwen
@inject IJSRuntime JS
@* De schil van de app: een vaste balk boven, een vaste balk onder, en het scherm daartussen.
In de balk staat waar je bent - op het overzicht de naam van de app, elders de naam van het
scherm. Zo hoeft die naam niet nog eens als kop in het scherm zelf. *@
@* Elke aanraking en elke toets telt als "er gebeurt iets". Ze bubbelen hierheen, dus één
plek volstaat en de schermen hoeven er niets voor te doen. Zonder dit zou de app uitloggen
terwijl iemand zit te typen. *@
@if (Bezigmetstarten)
{
@* Nog niets: we weten op dit moment niet of er een sessie op dit toestel ligt, en dus ook
niet of de app open mag. Zolang dat zo is hoort er geen overzicht in beeld te komen dat
een tel later achter een slotscherm verdwijnt - en hoort het scherm erachter ook nog geen
gegevens op te halen. Dit duurt zo lang als het lezen van de beveiligde opslag: een wip. *@
<div class="schil app slotscherm">
<img class="merkgroot" src="_content/MijnBesluit.AppUi/logo.png" alt="" />
</div>
}
else if (Vergrendeld)
{
@* Niets van de app eronder: geen balk, geen navigatie, geen inhoud. Een grendel die je nog
door het raam laat kijken is geen grendel. *@
<div class="schil app slotscherm">
<img class="merkgroot" src="_content/MijnBesluit.AppUi/logo.png" alt="" />
<h1>De app staat op slot</h1>
@* Twee redenen, twee zinnen. "Er is een tijd niets gebeurd" klopt niet als je de app
net zelf hebt geopend, en een zin die niet klopt laat iemand zoeken naar wat er mis is. *@
<p class="zacht">
@if (DoorStilte)
{
<text>Er is een tijd niets gebeurd, dus is je kluis dichtgegaan. Je bent nog aangemeld:</text>
}
else
{
<text>Je kluis gaat dicht zodra je de app wegleg. Je bent nog aangemeld:</text>
}
ontgrendelen kan met je vingerafdruk of de code van je telefoon.
</p>
<button class="knop vol" disabled="@Ontgrendelen" @onclick="OntgrendelenAsync">
@(Ontgrendelen ? "Even wachten…" : "Ontgrendelen")
</button>
<p style="margin-top:1.2rem">
<button type="button" class="alsLink kaal" @onclick="UitloggenAsync">
Toch helemaal uitloggen
</button>
</p>
</div>
}
else
{
<div class="schil app" @onpointerdown="Aangeraakt" @onkeydown="Aangeraakt">
<header class="kop vast">
@if (OpOverzicht)
{
<span class="merk"><img class="stip" src="_content/MijnBesluit.AppUi/logo.png" alt="" /> MijnBesluit</span>
}
else
{
<button class="terug" @onclick='() => Navigatie.NavigateTo("/")' aria-label="Terug naar overzicht">
&#8249;
</button>
<span class="merk">@Schermnaam</span>
}
<div class="rechts">
@* Een tekentje en niet het woord "Geen verbinding". Op een telefoon van 360 pixels
duwde dat labeltje de schermnaam tot een paar pixels in elkaar, en verbinding
verliezen is hier normaal: Android verbreekt hem zodra de app even op de
achtergrond staat, en de eerstvolgende aanroep zet hem weer op. Iets wat komt en
gaat hoort de rest van de balk niet overhoop te gooien. *@
@if (!Toestand.Verbonden)
{
<span class="geenverbinding" title="Geen verbinding" aria-label="Geen verbinding">&#9888;</span>
}
@* Geen poppetje meer naar Mijn profiel. Je eigen kaart staat bovenaan Mijn mensen en
brengt je daar ook heen; twee ingangen naar hetzelfde scherm kostten hier de
breedte die de schermnaam nodig had - "Mijn mensen" viel over twee regels. *@
@if (Toestand.IsAangemeld)
{
<a href="/instellingen" title="Instellingen" aria-label="Instellingen">&#9881;</a>
<a href="/uitloggen">Uitloggen</a>
}
</div>
</header>
<main class="inhoud" id="app-inhoud">
@Body
</main>
@* Op het overzicht is er niets meer om naar terug te gaan. Eén druk sluit de app daar dan
ook niet meteen: dat gebeurde te vaak per ongeluk, midden in het invullen. *@
@if (Afsluiten)
{
<div class="afsluithint">Nog een keer terug om te sluiten</div>
}
@if (Toestand.IsAangemeld)
{
<nav class="nav vast">
<NavLink href="/" Match="NavLinkMatch.All" ActiveClass="actief"><span class="teken">&#9737;</span> Overzicht</NavLink>
<NavLink href="/familie" ActiveClass="actief"><span class="teken">&#128101;</span> Mensen</NavLink>
<NavLink href="/kluis" ActiveClass="actief"><span class="teken">&#128274;</span> Kluis</NavLink>
<NavLink href="/momenten" ActiveClass="actief"><span class="teken">&#127916;</span> Momenten</NavLink>
<NavLink href="/nalatenschap" ActiveClass="actief"><span class="teken">&#127807;</span> Nalatenschap</NavLink>
</nav>
}
</div>
}
@code {
private string Pad = "/";
/// <summary>Er is één keer op terug gedrukt op het overzicht; nog een keer sluit de app.</summary>
private bool Afsluiten;
/// <summary>Zolang de schil er staat vangt hij de terugknop van het toestel af.</summary>
private IDisposable? Terugmelding;
private bool OpOverzicht => Pad is "/" or "";
/// <summary>De app is dicht tot iemand zich bij het toestel meldt. De sessie blijft staan.</summary>
private bool Vergrendeld;
/// <summary>
/// De app is net geopend en kijkt nog of er een sessie op dit toestel ligt. Zolang dit waar
/// is komt er geen scherm in beeld; zie de uitleg boven in dit bestand.
/// </summary>
private bool Bezigmetstarten = true;
/// <summary>Er staat een venster van het toestel open; niet er nog een bovenop zetten.</summary>
private bool Ontgrendelen;
/// <summary>
/// Ging het slot erop omdat er een tijd niets gebeurde, of omdat de app net openging? Alleen
/// voor de zin op het slotscherm; het slot zelf is in beide gevallen hetzelfde.
/// </summary>
private bool DoorStilte;
/// <summary>De naam van het scherm, voor in de balk.</summary>
private string Schermnaam => Pad switch
{
"/familie" => "Mijn mensen",
"/kluis" => "Mijn kluis",
"/momenten" => "Momenten",
"/nalatenschap" => "Nalatenschap",
"/profiel" => "Mijn profiel",
"/instellingen" => "Instellingen",
"/inloggen" => "Inloggen",
_ => "MijnBesluit"
};
/// <summary>Loopt zolang de schil er staat en kijkt of de stilte lang genoeg heeft geduurd.</summary>
private readonly CancellationTokenSource _stoppen = new();
/// <summary>
/// Op welke schermen het trek-naar-beneden-gebaar iets doet. De schil staat om elk scherm
/// heen - ook het overzicht, de instellingen, Mijn profiel - maar alleen deze vier hebben
/// een lijst die het waard is om handmatig te verversen zonder eerst weg te navigeren.
/// </summary>
private static readonly string[] TeVerversenPaden = { "/kluis", "/familie", "/momenten", "/nalatenschap" };
private DotNetObjectReference<AppSchil>? _vernieuwverwijzing;
protected override async Task OnInitializedAsync()
{
Toestand.Veranderd += Herteken;
Toestand.SessieVerlopen += NaEenVerlopenSessie;
Navigatie.LocationChanged += Verplaatst;
Klok.Hervat += NaHetHervatten;
_ = WaaktAsync(_stoppen.Token);
// Deze meldt zich als eerste aan en ligt dus onderop: popups die later opengaan zijn
// eerder aan de beurt, en pas als er niets meer openstaat gaat het over navigeren.
Terugmelding = Terugknoppen.Aanmelden(Terug);
Bijwerken();
await BijHetOpenenAsync();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
// Niet alleen bij firstRender: die allereerste tekening is bijna altijd nog het
// opstart- of slotscherm (zie hierboven), en dáár staat geen #app-inhoud in de pagina -
// dus het koppelen zou dan stil mislukken en, omdat firstRender maar één keer waar is,
// nooit meer opnieuw worden geprobeerd. In plaats daarvan proberen we het na elke
// tekening; validatie.js zet zelf een vlaggetje zodra het is gelukt, dus zodra
// #app-inhoud er eenmaal wél staat, gebeurt het precies één keer en daarna niets meer.
_vernieuwverwijzing ??= DotNetObjectReference.Create(this);
await JS.InvokeVoidAsync("mijnbesluit.pullToVernieuwen", "app-inhoud", _vernieuwverwijzing, TeVerversenPaden);
}
/// <summary>
/// Aangeroepen vanuit validatie.js zodra er ver genoeg naar beneden is getrokken op een van
/// de schermen uit <see cref="TeVerversenPaden"/>. Het scherm dat op dat moment openstaat
/// luistert zelf mee via VernieuwSignaal; de schil hoeft niet te weten welk scherm dat is.
/// </summary>
[JSInvokable]
public Task OpVernieuwenAsync() => Vernieuwen.VraagAanAsync();
/// <summary>
/// Wat er gebeurt zodra de app opengaat, vóór er een scherm in beeld komt.
///
/// De afspraak is: na de eerste keer inloggen met een code kom je erin met je vingerafdruk
/// of de code van je telefoon, tot je zelf uitlogt. Daarom wordt hier maar één ding gevraagd,
/// en wel aan het toestel: ligt hier een sessie? De server komt er niet aan te pas.
///
/// Dat verschil is waar het eerder misging. Het slot ging pas op de app nadat de server het
/// token had goedgekeurd. Een telefoon die net wakker wordt heeft nog geen netwerk, en na
/// een herstart van de service kende de server de sessie niet meer - en dan stond je op het
/// inlogscherm met een vraag om je e-mailadres, terwijl je telefoon prima wist wie je was.
/// Nu is het andersom: eerst je vingerafdruk, daarna pas de vraag of de sessie nog loopt.
/// </summary>
private async Task BijHetOpenenAsync()
{
var opSlot = false;
try
{
// Zonder toestelslot heeft dit geen zin: daar is uitloggen de enige grendel, en dat
// doet de uitlogklok al. Zie Uitlogklok.
opSlot = Slot.Beschikbaar && await Toestand.Client.HeeftBewaardeSessieAsync();
}
catch (Exception)
{
// Kan de beveiligde opslag niet gelezen worden, dan is er geen sessie om te
// beschermen en komt iemand vanzelf op het inlogscherm uit.
}
Bezigmetstarten = false;
StateHasChanged();
if (opSlot) await OpSlotAsync(doorStilte: false);
}
/// <summary>
/// De terugknop van het toestel. Overal behalve op het overzicht brengt hij je daarheen -
/// hetzelfde als het pijltje linksboven. Op het overzicht zelf is er niets meer achter,
/// en pas een tweede druk binnen twee seconden laat de app sluiten.
/// </summary>
private bool Terug()
{
// Op slot komt de terugknop nergens: die zou je anders zo de app uit sturen, en dan
// staat er bij de volgende start weer een open kluis.
if (Vergrendeld) return true;
// Het inlogscherm telt hier als eindpunt. Daar teruggaan naar het overzicht stuurt je
// meteen weer naar het inlogscherm; dan kom je met de terugknop nergens meer.
if (!OpOverzicht && Pad != "/inloggen")
{
InvokeAsync(() => Navigatie.NavigateTo("/"));
return true;
}
if (Afsluiten) return false; // tweede keer: het toestel mag zijn gang gaan
Afsluiten = true;
InvokeAsync(StateHasChanged);
_ = VergeetAfsluitenAsync();
return true;
}
private async Task VergeetAfsluitenAsync()
{
await Task.Delay(TimeSpan.FromSeconds(2));
Afsluiten = false;
await InvokeAsync(StateHasChanged);
}
private void Verplaatst(object? afzender, LocationChangedEventArgs e)
{
Klok.Aangeraakt(); // van scherm wisselen is ook iets doen
Afsluiten = false; // je bent ergens anders; de waarschuwing hoort niet mee te reizen
Bijwerken();
InvokeAsync(StateHasChanged);
}
private void Bijwerken()
{
var relatief = Navigatie.ToBaseRelativePath(Navigatie.Uri);
var vraagteken = relatief.IndexOf('?');
if (vraagteken >= 0) relatief = relatief[..vraagteken];
Pad = "/" + relatief.TrimStart('/');
}
private void Aangeraakt() => Klok.Aangeraakt();
/// <summary>
/// De server kent ons token niet meer - bijvoorbeeld omdat de SocketServer opnieuw is
/// gestart; die bewaart zijn sessies in het geheugen. Dan hoort de app naar het inlogscherm
/// te gaan in plaats van te blijven staan met een knop die nooit meer kan lukken.
///
/// Ook het slot gaat eraf: er valt niets meer te beschermen, en een slotscherm dat je na het
/// ontgrendelen op een dode sessie zet is alleen maar verwarrend.
/// </summary>
private void NaEenVerlopenSessie() => InvokeAsync(() =>
{
Vergrendeld = false;
Navigatie.NavigateTo("/inloggen");
});
/// <summary>De app komt terug uit de achtergrond: meteen kijken, niet pas bij de volgende tik.</summary>
private void NaHetHervatten() => InvokeAsync(async () =>
{
// Stond hij al op slot, dan hoort het venster meteen weer in beeld te komen: daar is
// de gebruiker voor teruggekomen.
if (Vergrendeld) { await OntgrendelenAsync(); return; }
if (Toestand.IsAangemeld && Klok.IsVerlopen(DateTime.UtcNow)) await TijdIsOmAsync();
});
/// <summary>
/// De termijn is voorbij. Kan het toestel om een vingerafdruk of code vragen, dan gaat de
/// app op slot en blijft de sessie staan. Kan dat niet, dan is uitloggen de enige grendel
/// die er is.
/// </summary>
private async Task TijdIsOmAsync()
{
if (!Slot.Beschikbaar) { await UitloggenAsync(); return; }
await OpSlotAsync(doorStilte: true);
}
/// <summary>
/// Het slot erop, en de lijn naar de server dicht.
///
/// Dat laatste is geen zuinigheid maar netheid: wat dicht is hoeft niet bewaakt te worden, en
/// een app op slot heeft de server niets te vragen. Het token blijft staan en de sessie aan de
/// serverkant ook - die hangt aan het token, niet aan de verbinding - dus na het ontgrendelen
/// ben je gewoon weer waar je was.
///
/// In de praktijk is de server ons meestal voor: die verbreekt een verbinding waarover tien
/// minuten niets gebeurt, en de app gaat pas na een kwartier op slot. Dit is dus vooral het
/// geval waarin iemand zelf het slot erop zet, of de termijn korter heeft gezet.
/// </summary>
/// <param name="doorStilte">
/// Waar als de klok afliep, onwaar als de app net openging. Alleen voor de tekst op het
/// slotscherm.
/// </param>
private async Task OpSlotAsync(bool doorStilte)
{
DoorStilte = doorStilte;
Vergrendeld = true;
StateHasChanged();
await Toestand.Client.VerbindingSluitenAsync();
await OntgrendelenAsync();
}
private async Task OntgrendelenAsync()
{
if (Ontgrendelen) return;
Ontgrendelen = true;
StateHasChanged();
try
{
var open = await Slot.OntgrendelAsync(
"MijnBesluit openen",
"De app is vergrendeld omdat er een tijd niets gebeurde.");
if (!open) return; // afgebroken of mislukt: het slot blijft dicht
Vergrendeld = false;
Klok.Aangeraakt();
// De lijn alvast weer opzetten. Hoeft niet - het eerste verzoek doet het ook - maar
// zo staat hij er al terwijl iemand nog naar zijn scherm kijkt, in plaats van dat
// zijn eerste handeling op een TLS-handdruk wacht.
await Toestand.Client.VerbindingOpenenAsync();
}
finally
{
Ontgrendelen = false;
StateHasChanged();
}
}
private async Task UitloggenAsync()
{
Vergrendeld = false;
await Toestand.UitloggenAsync();
Navigatie.NavigateTo("/inloggen");
}
/// <summary>
/// Kijkt elke halve minuut of de app zichzelf moet uitloggen.
///
/// Elke halve minuut en niet vaker: de termijn staat in minuten, dus nauwkeuriger hoeft
/// niet, en op een telefoon is elke wakkere seconde accu. Er wordt op de wandklok gerekend,
/// dus een uur in een broekzak telt net zo goed mee als een uur kijken - ook al heeft deze
/// lus dan niet gedraaid.
/// </summary>
private async Task WaaktAsync(CancellationToken ct)
{
using var tik = new PeriodicTimer(TimeSpan.FromSeconds(30));
try
{
while (await tik.WaitForNextTickAsync(ct))
{
// Staat hij al op slot, dan is er niets meer te bewaken tot hij weer opengaat.
if (!Toestand.IsAangemeld || Vergrendeld || !Klok.IsVerlopen(DateTime.UtcNow)) continue;
await InvokeAsync(TijdIsOmAsync);
if (!Toestand.IsAangemeld) return; // uitgelogd; er valt niets meer te bewaken
}
}
catch (OperationCanceledException)
{
// De schil is weg. Niets aan de hand.
}
}
private void Herteken() => InvokeAsync(StateHasChanged);
public void Dispose()
{
Toestand.Veranderd -= Herteken;
Toestand.SessieVerlopen -= NaEenVerlopenSessie;
Navigatie.LocationChanged -= Verplaatst;
Terugmelding?.Dispose();
Klok.Hervat -= NaHetHervatten;
_stoppen.Cancel();
_stoppen.Dispose();
_vernieuwverwijzing?.Dispose();
}
}
+32
View File
@@ -0,0 +1,32 @@
namespace MijnBesluit.AppUi;
/// <summary>
/// Meldingen op het toestel zelf.
///
/// Net als bij het kiezen van bestanden: de schermen weten hier niets van Android of iOS, ze
/// vragen alleen om het kenmerk waarmee de server een melding kan sturen. Firebase levert dat
/// kenmerk, en het verandert af en toe vanzelf - daarom wordt het bij elke start opnieuw
/// opgehaald en doorgegeven.
/// </summary>
public interface IMeldingen
{
/// <summary>
/// Vraagt toestemming (Android 13 en hoger vraagt dat apart) en haalt het kenmerk op.
/// Geeft null als de gebruiker geen meldingen wil of als Firebase niet is ingesteld.
/// </summary>
Task<string?> KenmerkAsync();
/// <summary>Waar dit op draait: "android" of "ios".</summary>
string Platform { get; }
/// <summary>De naam van het toestel, om in het overzicht te kunnen tonen.</summary>
string? Toestelnaam { get; }
}
/// <summary>Voor als er geen meldingen zijn ingericht; alles werkt dan gewoon, zonder push.</summary>
public sealed class GeenMeldingen : IMeldingen
{
public Task<string?> KenmerkAsync() => Task.FromResult<string?>(null);
public string Platform => "onbekend";
public string? Toestelnaam => null;
}
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AddRazorSupportForMvc>false</AddRazorSupportForMvc>
</PropertyGroup>
<ItemGroup>
<!-- Bewust een package en geen FrameworkReference naar Microsoft.AspNetCore.App: een
Android-app draait niet op de gedeelde ASP.NET Core-runtime, dus daar is die
verwijzing niet op te lossen (NETSDK1082). Met het package erbij werkt dit project
zowel in de website als in de app. -->
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="8.0.13" />
<ProjectReference Include="..\MijnBesluit.Client\MijnBesluit.Client.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,236 @@
@* Foto's, documenten en opnamen bij een kluisitem, een persoon of een bericht.
Eén component voor alle drie, zodat ze er hetzelfde uitzien en zich hetzelfde gedragen.
Wat er per plek verschilt zijn de knoppen die je meegeeft: bij een persoon kun je een foto
aanwijzen als profielfoto, bij een kluisitem niet.
Het bekijken zit hier ingebouwd en gaat niet via de schermen eromheen. Dat scheelt vier keer
dezelfde code, en belangrijker: het werkt daardoor overal hetzelfde. *@
@inject AppToestand Toestand
<div class="veld">
<label>@Titel</label>
@if (Bijlagen.Count > 0)
{
<div class="stapel" style="margin-bottom:.6rem">
@foreach (var bijlage in Bijlagen)
{
<div class="rij bijlage">
@* De naam en het tekentje samen zijn de knop om hem te bekijken. Een aparte
oogknop erbij zou de rij op een telefoon te vol maken. *@
<button type="button" class="bijlage-openen groei" disabled="@Bezig"
title="Bekijken" @onclick="() => BekijkAsync(bijlage)">
<span class="teken">@Teken(bijlage)</span>
<span class="groei">
<span class="naam">@bijlage.FileName</span>
<span class="zacht">
@bijlage.Grootte
@if (bijlage.IsProfilePhoto) { <text> &middot; profielfoto</text> }
@if (Bijlagewachtrij.Wacht(bijlage)) { <text> &middot; nog niet opgeslagen</text> }
</span>
</span>
</button>
@if (KanProfielfoto && bijlage.IsAfbeelding && !bijlage.IsProfilePhoto
&& !Bijlagewachtrij.Wacht(bijlage))
{
<button type="button" class="knop rustig klein" disabled="@Bezig"
title="Als profielfoto gebruiken"
@onclick="() => OpProfielfoto.InvokeAsync(bijlage)">&#9733;</button>
}
<button type="button" class="knop rustig klein" disabled="@Bezig"
title="Verwijderen"
@onclick="() => Weghalen = bijlage">&#128465;</button>
</div>
@* Verwijderen is onomkeerbaar - de bijlage is daarna echt weg - dus eerst
bevestigen, net als overal elders waar iets wordt weggehaald. *@
@if (Weghalen == bijlage)
{
<div class="melding let-op" style="margin:0 0 .6rem">
<strong>@bijlage.FileName</strong> verwijderen?
<div class="rij" style="margin-top:.5rem">
<button type="button" class="knop gevaar" disabled="@Bezig"
@onclick="() => VerwijderBevestigdAsync(bijlage)">Ja, verwijderen</button>
<button type="button" class="knop rustig" disabled="@Bezig"
@onclick="() => Weghalen = null">Nee, laat staan</button>
</div>
</div>
}
}
</div>
}
@if (Bezig && Overzetten is not null)
{
<div class="zacht">@Overzetten</div>
<div class="voortgang" style="margin:.3rem 0 .6rem"><span style="width:@(Percentage)%"></span></div>
}
@* Maken en kiezen staan los van elkaar, want het zijn twee verschillende handelingen: een
foto van je paspoort maak je ter plekke, die van vorige zomer zoek je op. Zonder camera
blijven alleen de kiesknoppen over. *@
<div class="rij bijlage-acties">
@if (KanCamera)
{
<button type="button" class="knop rustig klein" disabled="@Bezig" title="Foto maken"
aria-label="Foto maken" @onclick="OpFotoMaken">&#128247;</button>
}
<button type="button" class="knop rustig klein" disabled="@Bezig" title="Foto kiezen"
aria-label="Foto kiezen" @onclick="OpFoto">&#128444;&#65039;</button>
@if (KanVideo)
{
@if (KanCamera)
{
<button type="button" class="knop rustig klein" disabled="@Bezig" title="Video opnemen"
aria-label="Video opnemen" @onclick="OpVideo">&#127909;</button>
}
<button type="button" class="knop rustig klein" disabled="@Bezig" title="Video kiezen"
aria-label="Video kiezen" @onclick="OpVideoKiezen">&#127966;&#65039;</button>
}
<button type="button" class="knop rustig klein" disabled="@Bezig" title="Document toevoegen"
aria-label="Document toevoegen" @onclick="OpDocument">&#128196;</button>
</div>
@if (Uitleg is not null)
{
<div class="uitleg">@Uitleg</div>
}
</div>
@* ---------------------------------------------------------------- bekijken *@
@if (Bekeken is not null)
{
<Popup Titel="@Bekeken.FileName" Bevestigtekst="Sluiten" AlleenSluiten="true"
OpBevestigen="Dichtdoen" OpSluiten="Dichtdoen">
@if (Ophalen)
{
<p class="zacht">Bezig met ophalen&hellip;</p>
<div class="voortgang"><span style="width:@(Vordering)%"></span></div>
}
else if (Kijkfout is not null)
{
<div class="melding @(Bijlagewachtrij.Wacht(Bekeken) ? "let-op" : "fout")">@Kijkfout</div>
}
else if (Bron is not null && Bekeken.IsAfbeelding)
{
<img src="@Bron" alt="@Bekeken.FileName" class="bijlage-groot" />
}
else if (Bron is not null && Bekeken.IsVideo)
{
<video src="@Bron" controls class="bijlage-groot"></video>
}
else
{
<p class="zacht">
Dit soort bestand is hier niet te tonen. Op de website kun je het downloaden.
</p>
}
</Popup>
}
@code {
[Parameter] public string Titel { get; set; } = "Foto's en documenten";
[Parameter] public string? Uitleg { get; set; }
[Parameter] public IReadOnlyList<Bijlage> Bijlagen { get; set; } = Array.Empty<Bijlage>();
[Parameter] public bool Bezig { get; set; }
/// <summary>Mogen hier video's bij? Bij een persoon niet, bij een bericht en een kluisitem wel.</summary>
[Parameter] public bool KanVideo { get; set; }
/// <summary>
/// Heeft dit toestel een camera die we mogen gebruiken? Zo niet, dan blijven alleen de
/// knoppen over waarmee je iets opzoekt dat er al staat.
/// </summary>
[Parameter] public bool KanCamera { get; set; }
/// <summary>Mag hier een foto worden aangewezen als profielfoto? Alleen bij een persoon.</summary>
[Parameter] public bool KanProfielfoto { get; set; }
/// <summary>Tekst en percentage van een lopende overdracht; null als er niets loopt.</summary>
[Parameter] public string? Overzetten { get; set; }
[Parameter] public int Percentage { get; set; }
[Parameter] public EventCallback OpFotoMaken { get; set; }
[Parameter] public EventCallback OpFoto { get; set; }
[Parameter] public EventCallback OpDocument { get; set; }
[Parameter] public EventCallback OpVideo { get; set; }
[Parameter] public EventCallback OpVideoKiezen { get; set; }
[Parameter] public EventCallback<Bijlage> OpVerwijderen { get; set; }
[Parameter] public EventCallback<Bijlage> OpProfielfoto { get; set; }
/// <summary>De bijlage die op een "weet je het zeker?" staat te wachten om verwijderd te worden.</summary>
private Bijlage? Weghalen;
/// <summary>De bijlage die openstaat om te bekijken; null als er niets openstaat.</summary>
private Bijlage? Bekeken;
/// <summary>De inhoud als verwijzing die een img- of video-element rechtstreeks aankan.</summary>
private string? Bron;
private bool Ophalen;
private int Vordering;
private string? Kijkfout;
private async Task BekijkAsync(Bijlage bijlage)
{
Bekeken = bijlage;
Bron = null;
Kijkfout = null;
// Een bijlage die nog wacht staat nergens, dus valt er ook niets op te halen. Dat is geen
// fout; de popup zegt gewoon wat er aan de hand is.
if (Bijlagewachtrij.Wacht(bijlage))
{
Kijkfout = "Deze staat er nog niet. Sla eerst op, dan kun je hem bekijken.";
return;
}
// Een document halen we niet op: we kunnen het toch niet tonen, en het kan tientallen
// megabytes zijn. De popup zegt dan meteen waar je het wél kunt openen.
if (!bijlage.IsAfbeelding && !bijlage.IsVideo) return;
Ophalen = true;
Vordering = 0;
StateHasChanged();
var uitkomst = await Toestand.Client.BestandOphalenAsync(bijlage.Id,
(gedaan, totaal) =>
{
Vordering = totaal == 0 ? 100 : (int)(gedaan * 100 / totaal);
InvokeAsync(StateHasChanged);
});
Ophalen = false;
if (!uitkomst.Gelukt) { Kijkfout = uitkomst.Melding; return; }
// De webview haalt zijn plaatjes niet van een server, dus geven we de inhoud mee in de
// verwijzing zelf. Dat kan omdat er een grens op de bestandsgrootte staat.
Bron = $"data:{uitkomst.Waarde!.Soort};base64,{Convert.ToBase64String(uitkomst.Waarde.Inhoud)}";
}
private void Dichtdoen()
{
Bekeken = null;
Bron = null;
Kijkfout = null;
Ophalen = false;
}
private async Task VerwijderBevestigdAsync(Bijlage bijlage)
{
Weghalen = null;
await OpVerwijderen.InvokeAsync(bijlage);
}
private static string Teken(Bijlage bijlage) =>
bijlage.IsAfbeelding ? "\U0001F4F7" : bijlage.IsVideo ? "\U0001F3AC" : "\U0001F4C4";
}
@@ -0,0 +1,130 @@
@* Een datum kiezen met drie keuzelijsten in plaats van <input type="date">.
Reden: in de webview waar de app in draait opent de agendaknop van een datumveld niet - je
ziet dan een veld waar niets mee gebeurt. Bovendien is kiezen op een telefoon makkelijker
dan typen, zeker voor wie niet zo handig is met een toetsenbord.
Werkt zowel vooruit (een bezorgmoment) als achteruit (een geboortedatum); zet daarvoor
VanJaar, TotJaar en Aflopend. Met Optioneel mag het veld leeg blijven. *@
<div class="datumkiezer">
<select class="dag" aria-label="Dag" @bind="Dag">
@for (var d = 1; d <= 31; d++)
{
<option value="@d">@d</option>
}
</select>
<select class="maand" aria-label="Maand" @bind="Maand">
@for (var m = 1; m <= 12; m++)
{
<option value="@m">@Maanden[m - 1]</option>
}
</select>
<select class="jaar" aria-label="Jaar" @bind="Jaar">
@if (Optioneel)
{
<option value="0">&mdash;</option>
}
@foreach (var j in Jaren)
{
<option value="@j">@j</option>
}
</select>
</div>
@code {
[Parameter] public DateTime? Waarde { get; set; }
[Parameter] public EventCallback<DateTime?> WaardeChanged { get; set; }
/// <summary>Hoeveel jaren er te kiezen zijn vanaf dit jaar. Genegeerd als TotJaar is gezet.</summary>
[Parameter] public int JarenVooruit { get; set; } = 60;
[Parameter] public int? VanJaar { get; set; }
[Parameter] public int? TotJaar { get; set; }
/// <summary>Nieuwste jaar bovenaan. Voor een geboortedatum scheelt dat veel scrollen.</summary>
[Parameter] public bool Aflopend { get; set; }
/// <summary>Mag het veld leeg blijven? Dan komt er een streepje bij de jaren.</summary>
[Parameter] public bool Optioneel { get; set; }
// Afgekort ("Feb.", "Mrt.") en niet voluit: in de drie keuzelijsten naast elkaar duwde
// "september" op een smalle telefoon de jaarkolom uit beeld. De lijst staat in
// MijnBesluit.Client, samen met de website-versie in het domein; een zelfcontrole houdt
// die twee gelijk.
private static readonly string[] Maanden = MijnBesluit.Client.Datums.KorteMaanden;
private int Eerste => VanJaar ?? DateTime.Today.Year;
private int Laatste => TotJaar ?? (DateTime.Today.Year + JarenVooruit);
private IEnumerable<int> Jaren
{
get
{
var reeks = Enumerable.Range(Eerste, Math.Max(1, Laatste - Eerste + 1));
return Aflopend ? reeks.Reverse() : reeks;
}
}
/// <summary>Jaar 0 betekent: geen datum gekozen. Alleen mogelijk als Optioneel aan staat.</summary>
private bool Leeg => _jaar == 0;
/// <summary>Het jaar dat bovenaan de lijst staat: bij Aflopend het nieuwste, anders het oudste.</summary>
private int StandaardJaar => Aflopend ? Laatste : Eerste;
private int _dag = DateTime.Today.Day;
private int _maand = DateTime.Today.Month;
private int _jaar = DateTime.Today.Year;
// Dag en maand staan nooit op slot, ook niet als het jaar nog op "-" staat: je moet een
// datum in elke volgorde kunnen invullen, niet per se bij het jaar beginnen. Kies je dag of
// maand terwijl er nog geen jaar gekozen is, dan springt het jaar vanzelf naar wat er
// bovenaan de lijst staat - daarmee telt de datum meteen als gekozen, zonder dat je apart
// nog het jaar hoeft aan te wijzen als de standaardwaarde al klopt.
private int Dag
{
get => _dag;
set { _dag = value; ActiveerAlsLeeg(); _ = Doorgeven(); }
}
private int Maand
{
get => _maand;
set { _maand = value; ActiveerAlsLeeg(); _ = Doorgeven(); }
}
private int Jaar { get => _jaar; set { _jaar = value; _ = Doorgeven(); } }
private void ActiveerAlsLeeg()
{
if (Optioneel && _jaar == 0) _jaar = StandaardJaar;
}
protected override void OnParametersSet()
{
if (Waarde is { } d)
{
_dag = d.Day;
_maand = d.Month;
_jaar = d.Year;
}
else if (Optioneel)
{
_jaar = 0;
}
}
private Task Doorgeven()
{
if (Leeg) return WaardeChanged.InvokeAsync(null);
// 31 februari bestaat niet; dan pakken we de laatste dag van die maand. Zo kan de
// gebruiker de drie lijstjes in elke volgorde instellen zonder foutmelding.
var dagen = DateTime.DaysInMonth(_jaar, _maand);
var dag = Math.Min(_dag, dagen);
return WaardeChanged.InvokeAsync(new DateTime(_jaar, _maand, dag, 12, 0, 0, DateTimeKind.Local));
}
}
@@ -0,0 +1,42 @@
@* Het ronde penningetje voor een persoon: zijn profielfoto, en anders zijn beginletters.
Eén component voor alle lijsten waar mensen in staan - Mijn mensen, je beheerders, de
nalatenschappen die aan jou zijn toevertrouwd. Ze horen er hetzelfde uit te zien, en dat
blijft alleen zo als het op één plek staat.
Meegeven doe je alleen een naam en waar het plaatje vandaan komt; hoe dat plaatje eruit
ziet is aan het stijlblad. Zonder bron valt hij terug op de letters, even groot, zodat de
lijst niet verspringt zodra er ergens een foto bij komt. *@
@if (!string.IsNullOrEmpty(Bron))
{
<img class="penning" src="@Bron" alt="@Naam" title="@Naam" />
}
else
{
<span class="penning" title="@Naam">@Beginletters(Naam)</span>
}
@code {
/// <summary>De naam van de persoon; alleen gebruikt voor de letters en de tekstballon.</summary>
[Parameter] public string Naam { get; set; } = "";
/// <summary>
/// Waar het plaatje vandaan komt. Op de website een adres van de server, in de app de
/// inhoud zelf. Leeg of null betekent: geen foto, dus de letters.
/// </summary>
[Parameter] public string? Bron { get; set; }
/// <summary>
/// De eerste letter van de voornaam en van de achternaam. Bij één woord alleen de eerste,
/// bij een lege naam een vraagteken - er moet altijd iets in de penning staan.
/// </summary>
public static string Beginletters(string? naam)
{
var delen = (naam ?? "").Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (delen.Length == 0) return "?";
return delen.Length == 1
? delen[0][..1].ToUpperInvariant()
: (delen[0][..1] + delen[^1][..1]).ToUpperInvariant();
}
}
@@ -0,0 +1,75 @@
@* Een venster over het scherm heen, voor wijzigen en bevestigen.
Bewust geen JavaScript-dialoog: die ziet er op elk toestel anders uit en is niet te
vormgeven. Dit is gewoon een laag over het scherm, met de knoppen onderaan waar een duim
ze kan bereiken. *@
@implements IDisposable
@inject Terugknop Terugknoppen
<div class="popup-achtergrond" @onclick="Sluiten">
<div class="popup" @onclick:stopPropagation="true">
<h2>@Titel</h2>
@ChildContent
@if (Foutmelding is not null)
{
<div class="melding fout" style="margin-top:.8rem">@Foutmelding</div>
}
<div class="rij popup-knoppen">
<button class="knop @(Gevaarlijk ? "gevaar" : "")" disabled="@Bezig" @onclick="Bevestigen">
@Bevestigtekst
</button>
@if (!AlleenSluiten)
{
<button class="knop rustig" disabled="@Bezig" @onclick="Sluiten">Annuleren</button>
}
</div>
</div>
</div>
@code {
[Parameter] public string Titel { get; set; } = "";
[Parameter] public string Bevestigtekst { get; set; } = "Opslaan";
/// <summary>Kleurt de knop als waarschuwing; voor verwijderen.</summary>
[Parameter] public bool Gevaarlijk { get; set; }
[Parameter] public bool Bezig { get; set; }
/// <summary>
/// Er valt niets te bevestigen of af te breken, alleen dicht te doen. Dan is "Annuleren"
/// naast "Sluiten" twee knoppen voor hetzelfde. Voor een popup die alleen iets laat zien.
/// </summary>
[Parameter] public bool AlleenSluiten { get; set; }
/// <summary>
/// Waarom Bevestigen niet is gelukt (bijvoorbeeld een verplicht veld dat leeg is). Staat
/// IN de popup, niet op de pagina erachter - die is aan het oog onttrokken zolang de popup
/// openstaat, dus een melding die daar verschijnt ziet niemand.
/// </summary>
[Parameter] public string? Foutmelding { get; set; }
[Parameter] public RenderFragment? ChildContent { get; set; }
[Parameter] public EventCallback OpBevestigen { get; set; }
[Parameter] public EventCallback OpSluiten { get; set; }
/// <summary>Zolang dit venster openstaat vangt het de terugknop van het toestel af.</summary>
private IDisposable? Terugmelding;
protected override void OnInitialized() =>
Terugmelding = Terugknoppen.Aanmelden(() =>
{
// Terug betekent hier hetzelfde als Annuleren: dicht, zonder op te slaan. Dat is wat
// iemand verwacht, en het is de veilige kant - per ongeluk opslaan kun je niet
// terugdraaien, per ongeluk annuleren wel.
InvokeAsync(Sluiten);
return true;
});
public void Dispose() => Terugmelding?.Dispose();
private Task Bevestigen() => OpBevestigen.InvokeAsync();
private Task Sluiten() => OpSluiten.InvokeAsync();
}
@@ -0,0 +1,146 @@
@* De privacyverklaring zelf. Staat hier als onderdeel en niet in een pagina, omdat de app hem
ook laat zien - daar in een popup vanaf het overzicht, hier als pagina op /privacy.
Dit bestand staat twee keer op schijf: de app en de site delen geen project. Een zelfcontrole
vergelijkt ze letterlijk, want twee versies van je privacyverklaring is erger dan twee versies
van een knop: het is het document waar iemand zich op beroept.
Verandert er iets aan wat er wordt opgeslagen, wie het kan zien of wat er naar buiten gaat, dan
verandert deze tekst mee - en dan hoort de datum hieronder mee te veranderen. *@
<p class="zacht">Laatst bijgewerkt op 19 september 2026.</p>
<p>
MijnBesluit is een digitale kluis. Je legt er vast wat er geregeld moet worden, en je zet
berichten klaar voor de mensen om je heen. Dat zijn je meest persoonlijke gegevens, en
daarom staat hier zo precies mogelijk wat ermee gebeurt.
</p>
<h2>Wie is verantwoordelijk</h2>
<p>
MijnBesluit is een dienst van <strong>Kwik-Bit Automatisering B.V.</strong><br />
Boulevard 1945 nr. 216, 7511 AG Enschede<br />
KvK-nummer 97571792
</p>
<p>
Vragen over je gegevens kun je stellen via
<a href="mailto:privacy@@mijnbesluit.nl">privacy@@mijnbesluit.nl</a>.
</p>
<h2>Wat we vastleggen</h2>
<ul>
<li><strong>Je e-mailadres.</strong> Daarmee log je in; we sturen er een code naartoe.</li>
<li><strong>Je naam en geboortedatum</strong>, als je die invult.</li>
<li><strong>Wat je in je kluis zet</strong>: titels, notities, wachtwoorden, polissen,
wensen, en de bestanden die je toevoegt.</li>
<li><strong>De mensen die je toevoegt</strong>: hun naam, en het e-mailadres dat je
invult om ze uit te nodigen.</li>
<li><strong>De berichten</strong> die je voor iemand klaarzet, en wanneer ze bezorgd zijn.</li>
<li><strong>Een logboek</strong> van handelingen: wie wanneer inlogde, wat er werd
gewijzigd, wanneer een bericht is bezorgd. Daar staat geen inhoud in.</li>
</ul>
<h2>Wat er versleuteld is</h2>
<p>
Alles wat in je kluis staat en alles wat in je berichten staat, is versleuteld met een
sleutel die bij jouw account hoort. Ook de bestanden die je toevoegt: die staan
versleuteld op schijf. Wie de database in handen krijgt zonder de sleutel, ziet er niets
van.
</p>
<p>
<strong>Eén uitzondering, en die noemen we liever zelf:</strong> de kleine versie van een
profielfoto - het rondje van veertig pixels in een lijst - is niet versleuteld. Dat is een
afweging geweest: een profielfoto is juist bedoeld om aan anderen te laten zien, en hem
als kluismateriaal behandelen zou betekenen dat we voor elke kijker sleutels moeten
uitdelen en weer intrekken. De foto op ware grootte is wél versleuteld.
</p>
<p>
Het verkeer tussen de app en onze server gaat over een versleutelde verbinding, net als
de website.
</p>
<h2>Wie kan wat zien</h2>
<ul>
<li>
<strong>Jij</strong> ziet je eigen kluis. Niemand anders, ook wij niet: wij kunnen je
kluisitems niet lezen zonder jouw account.
</li>
<li>
<strong>Mensen met wie je in een familie zit</strong> zien elkaars naam en
profielfoto. Niet elkaars kluis.
</li>
<li>
<strong>Iemand die je hebt aangewezen als nalatenschapsbeheerder</strong> krijgt
toegang tot wat je daarvoor hebt bestemd, en pas nadat je overlijden is bevestigd
volgens de procedure die in de app staat.
</li>
<li>
<strong>Iemand met wie je een kluisitem alvast "bespreekt".</strong> Dat kies je zelf,
per item: die persoon ziet het dan meteen, inclusief een eventuele foto of bijlage,
zonder dat daar een overlijden voor nodig is. Zonder die keuze ziet niemand het.
</li>
<li>
<strong>Een beheerder van MijnBesluit</strong> kan een profiel opzoeken op volledig
e-mailadres en een ten onrechte bevestigd overlijden terugdraaien. Hij kan géén kluis
openen. Elke handeling van een beheerder komt in het logboek.
</li>
</ul>
<h2>Wat er naar buiten gaat</h2>
<p>
Wij verkopen geen gegevens en er staan geen meetscripts of volgcookies op deze site.
Er zijn twee partijen die noodzakelijkerwijs iets te zien krijgen:
</p>
<ul>
<li>
<strong>Google (Firebase Cloud Messaging)</strong> bezorgt de meldingen op je
telefoon. Daarvoor gaat een kenmerk van je toestel naar Google, met de titel en de
korte tekst van de melding. De inhoud van een bericht of een kluisitem gaat daar
nooit in mee.
</li>
<li>
<strong>De verzender van onze e-mail</strong>, voor de inlogcodes en uitnodigingen.
</li>
</ul>
<p>
Adverteerders krijgen niets over jou te zien. Er wordt per advertentie geteld hoe vaak
hij is getoond en aangeklikt, per uur - meer niet. Geen profielen, geen kenmerken, geen
bezoeker die door de applicatie heen te volgen is.
</p>
<h2>Hoe lang we het bewaren</h2>
<p>
Zolang je account bestaat. Hef je het op, dan wordt het verwijderd - zie hieronder. Het
logboek blijft staan, omdat je achteraf moet kunnen nagaan wat er is gebeurd; daar staat
geen inhoud in. Van gegevens die bij onze administratie horen, zoals facturen voor
adverteerders, houden we de wettelijke bewaartermijn aan.
</p>
<h2>Je account opheffen</h2>
<p>
Dat kan zelf, op elk moment, en het hoeft niet via ons te lopen:
<a href="/account-verwijderen">mijnbesluit.nl/account-verwijderen</a>. Dat adres werkt ook
als je de app niet meer hebt. In de app staat dezelfde knop bij Mijn profiel.
</p>
<p>
Wat er dan weggaat: je kluis, je bestanden, de berichten die nog klaarstonden, je lijst
met mensen, en je account zelf. Wat blijft: berichten die al bezorgd zijn - die zijn van
degene die ze heeft gekregen - en je naam in de lijst van mensen die jou hebben
toegevoegd. Dat laatste is hun aantekening, niet jouw gegeven; alles wat er aan jou
vastzat, gaat eraf.
</p>
<h2>Je rechten</h2>
<p>
Je mag inzien wat we van je hebben, het laten corrigeren, en het laten verwijderen. Het
meeste doe je zelf in de app of op de website. Lukt iets niet, mail dan
<a href="mailto:privacy@@mijnbesluit.nl">privacy@@mijnbesluit.nl</a>. Ben je het oneens
met hoe wij met je gegevens omgaan, dan kun je een klacht indienen bij de Autoriteit
Persoonsgegevens.
</p>
<h2>Als er iets misgaat</h2>
<p>
Bij een datalek melden wij dat bij de Autoriteit Persoonsgegevens, en bij jou als het
jouw gegevens raakt.
</p>
@@ -0,0 +1,95 @@
@* De uitleg over MijnBesluit, zonder kop en zonder knoppen eromheen.
Dit staat als los onderdeel zodat de website en de app dezelfde tekst tonen: op de website als
pagina met knoppen om aan te melden, in de app als popup vanaf het overzicht. De app en de site
delen geen project, dus dit bestand staat twee keer op schijf; een zelfcontrole vergelijkt ze
letterlijk, zodat ze niet uit elkaar gaan lopen.
Kop en knoppen staan er bewust niet in: op de website hoort er "Een account aanmaken" onder, en
in de app ben je al aangemeld. *@
<p>
Er zit van alles in je hoofd waar niemand anders bij kan. Het wachtwoord van je e-mail,
waar de polissen liggen, hoe je je uitvaart voor je ziet, en de dingen die je nog tegen
iemand had willen zeggen. Zolang alles goed gaat maakt dat niet uit. Gaat het niet goed,
dan staan de mensen die van je houden met lege handen &mdash; op het slechtst denkbare moment.
</p>
<p>
MijnBesluit is de plek waar dat wél ergens staat. Veilig opgeborgen zolang het van jou
alleen is, en beschikbaar op het moment dat jij hebt bepaald.
</p>
<h2>Vier dingen</h2>
<ul style="padding-left:1.2rem;margin:0 0 1.5rem">
<li style="margin-bottom:.7rem">
<strong>Je kluis.</strong> Wachtwoorden voor websites en apparaten, de pincode van
je telefoon, je sociale media, polissen, documenten, je uitvaartwensen. Alles gaat
versleuteld de opslag in. Per onderdeel kies je zelf of het alleen voor jou is,
pas na je overlijden vrijkomt, of op een datum die jij bepaalt. Je kunt een item ook
meteen &mdash; nog gewoon terwijl je er bent &mdash; met je nalatenschapsbeheerder
bespreken; die ziet het dan al, inclusief een eventuele foto of bijlage, zonder dat
daar een overlijden voor nodig is.
</li>
<li style="margin-bottom:.7rem">
<strong>Je mensen.</strong> De familie en vrienden die erbij horen. Je maakt hun
profiel aan en nodigt ze uit. Tot ze die uitnodiging aannemen beheer jij dat
profiel; daarna is het van hen en vullen ze hun eigen MijnBesluit in. Zo groeit
het vanzelf verder.
</li>
<li style="margin-bottom:.7rem">
<strong>Momenten.</strong> Een bericht of een video die je klaarzet voor iemand,
te bezorgen op een datum die jij kiest &mdash; of pas als jij er niet meer bent.
Een achttiende verjaardag, een huwelijk, of gewoon iets wat je nog wilde zeggen.
En je kunt zelf ook berichten ontvangen die een ander voor jou heeft klaargezet.
</li>
<li>
<strong>Je nalatenschap.</strong> Wie er straks mag regelen wat er geregeld moet
worden. Die persoon krijgt niet zomaar toegang: er moeten meerdere mensen
onafhankelijk melden dat je bent overleden &mdash; waaronder iemand die jij zelf hebt
aangewezen &mdash; en daarna heb jij nog een termijn om te laten weten dat je er gewoon
nog bent. Pas als die voorbij is, gaat het open.
</li>
</ul>
<h2>Hoe veilig is het?</h2>
<p>
Je legt hier je wachtwoorden en je laatste wensen vast. Dan moet je weten wat daarmee
gebeurt.
</p>
<ul style="padding-left:1.2rem">
<li style="margin-bottom:.5rem">
<strong>Alles gaat versleuteld de opslag in.</strong> Elk onderdeel krijgt zijn eigen
sleutel, en die sleutel wordt zelf ook weer versleuteld. Wie de database in handen
zou krijgen, heeft daar niets aan: er staat geen leesbare regel in.
</li>
<li style="margin-bottom:.5rem">
<strong>Bijlagen net zo.</strong> Een foto van je polis of een scan van je testament
wordt versleuteld opgeslagen en pas ontsleuteld op het moment dat jij hem opent.
</li>
<li style="margin-bottom:.5rem">
<strong>Er is geen wachtwoord.</strong> Je vult je e-mailadres in en krijgt een code
die tien minuten geldig is en één keer werkt. Geen wachtwoord betekent: niets om te
stelen, te raden of te hergebruiken. De code zelf staat ook versleuteld opgeslagen,
dus zelfs wij kunnen hem niet teruglezen.
</li>
<li style="margin-bottom:.5rem">
<strong>Niemand komt bij je kluis, ook wij niet.</strong> Wat je op "alleen voor
mijzelf" zet, blijft dat. Je nalatenschapsbeheerder krijgt alleen wat jij hebt
vrijgegeven, en pas op het moment dat jij hebt bepaald.
</li>
<li>
<strong>Er wordt bijgehouden wie wat doet.</strong> Elke keer dat er iets wordt
geopend, gewijzigd of vrijgegeven, wordt dat vastgelegd. Zo is achteraf na te gaan
wat er is gebeurd.
</li>
</ul>
<p>
Volledige zekerheid bestaat niet, en dat beweren we ook niet. Wat we wel doen is elke
keuze zo maken dat een fout of een inbraak zo min mogelijk oplevert.
</p>
<h2>Wat kost het?</h2>
<p style="margin-bottom:0">
Een eigen MijnBesluit is gratis. Wat je vastlegt blijft van jou, en je kunt alles wat je
hebt ingevoerd altijd zelf wijzigen of verwijderen.
</p>
@@ -0,0 +1,52 @@
@* De versiehistorie: nieuwe of wezenlijk gewijzigde functies, nieuwste bovenaan.
Zelfde opzet als Productuitleg.razor en Privacytekst.razor: op de website een popup vanaf
het overzicht, in de app hetzelfde. Dit bestand staat twee keer op schijf - de app en de site
delen geen project - en een zelfcontrole vergelijkt ze letterlijk.
Wat hier NIET in hoort: bugfixes, tekstuele correcties, opmaak die is rechtgezet. Dit is voor
"wat kan ik nu wat ik eerst niet kon", niet een logboek van elke wijziging in de code. *@
<p class="zacht" style="margin-top:0">
Nieuwe of wezenlijk gewijzigde functies, nieuwste bovenaan.
</p>
<h2 style="margin-top:0">20 september 2026</h2>
<ul style="padding-left:1.2rem">
<li style="margin-bottom:.5rem">
<strong>Rijbewijs en paspoort in de kluis.</strong> Twee nieuwe categorieën, met een
knop om meteen de voor- en achterkant (rijbewijs) of de foto-pagina (paspoort) te maken
of te kiezen.
</li>
<li style="margin-bottom:.5rem">
<strong>Mijn profiel als pop-up.</strong> Je eigen profiel bewerk je nu in dezelfde
pop-up met vaste "Opslaan"-knop onderin als bij de andere mensen in je familie, in de
app en op de website.
</li>
<li style="margin-bottom:.5rem">
<strong>Duidelijker voor de nalatenschapsbeheerder.</strong> Bij "Aan mij toevertrouwd"
zie je nu meteen hoeveel meldingen er nodig zijn en hoe lang de bezwaartermijn is.
</li>
<li>
<strong>Muziek en verdere wensen groeien mee.</strong> Die velden bij een kluisitem
passen zich nu vanzelf aan de lengte van de tekst aan, ook op de website.
</li>
</ul>
<h2>19 september 2026</h2>
<ul style="padding-left:1.2rem">
<li style="margin-bottom:.5rem">
<strong>Met mij gedeeld, ook in de app.</strong> Heeft iemand een kluisitem rechtstreeks
met jou gedeeld om het vast te bespreken - bijvoorbeeld een uitvaartwens - dan zie je
dat voortaan ook op je telefoon, niet meer alleen op de website.
</li>
<li style="margin-bottom:.5rem">
<strong>Een gedeeld of nagelaten kluisitem bekijken.</strong> Dat opent nu als pop-up in
dezelfde opmaak als het invulformulier, inclusief de foto's en documenten die erbij
horen.
</li>
<li>
<strong>Met de vinger naar beneden trekken ververst het scherm.</strong> Werkt op Kluis,
Mijn mensen, Momenten en Nalatenschap.
</li>
</ul>
+184
View File
@@ -0,0 +1,184 @@
using MijnBesluit.Client;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
namespace MijnBesluit.AppUi;
/// <summary>
/// Gedeelde basis voor de schermen achter het inloggen: haalt de gegevens op, houdt één
/// foutmelding bij en stuurt wie niet is aangemeld naar het inlogscherm.
/// </summary>
public abstract class SchermBasis : ComponentBase, IDisposable
{
[Inject] protected AppToestand Toestand { get; set; } = default!;
[Inject] protected NavigationManager Navigatie { get; set; } = default!;
[Inject] private VernieuwSignaal Vernieuwen { get; set; } = default!;
/// <summary>Wordt getoond in een rode balk bovenaan het scherm.</summary>
protected string? Fout { get; set; }
/// <summary>Er loopt iets; knoppen zijn dan uit zodat er niet twee keer wordt gedrukt.</summary>
protected bool Bezig { get; set; }
protected override async Task OnInitializedAsync()
{
Toestand.Veranderd += OpVerandering;
// Op PersoonId en niet op IsAangemeld: een bewaard token zegt alleen dat we ooit zijn
// ingelogd, niet dat de app al weet wie hij is. Lukte het oppakken van de sessie de
// vorige keer niet omdat er even geen verbinding was, dan staat het token er nog en
// proberen we het hier gewoon opnieuw.
//
// En naar het inlogscherm alleen als er daarna echt geen token meer is. Dat betekent
// dat de server de sessie niet meer kent - de enige goede reden om iemand opnieuw te
// laten inloggen. Een haperende verbinding hoort dat niet te doen: dan komt er een rode
// balk "geen verbinding" en blijft de sessie staan.
if (Toestand.PersoonId == 0 && !await Toestand.HervatAsync() && !Toestand.IsAangemeld)
{
Navigatie.NavigateTo("/inloggen");
return;
}
await LaadAsync();
// Alleen het scherm dat nu open staat luistert naar het trek-naar-beneden-gebaar; zie
// VernieuwSignaal. Er wordt niet naar Bezig gekeken - dat gebruiken de schermen zelf al
// voor hun eigen knoppen en zou hier de verversing kunnen blokkeren zonder reden.
Vernieuwen.Gevraagd += OpVernieuwenGevraagdAsync;
}
/// <summary>Hier haalt het scherm zijn eigen gegevens op.</summary>
protected virtual Task LaadAsync() => Task.CompletedTask;
private async Task OpVernieuwenGevraagdAsync()
{
await LaadAsync();
await InvokeAsync(StateHasChanged);
}
/// <summary>
/// Voert iets uit dat kan mislukken en zet de melding klaar. Zo staat in elk scherm maar
/// één keer de afhandeling van fouten, en blijft de knop niet hangen als het misgaat.
/// </summary>
protected async Task<bool> ProbeerAsync(Func<Task<Uitkomst>> werk)
{
Bezig = true;
Fout = null;
StateHasChanged();
try
{
var uitkomst = await werk();
if (!uitkomst.Gelukt) Fout = uitkomst.Melding;
return uitkomst.Gelukt;
}
finally
{
Bezig = false;
StateHasChanged();
}
}
/// <summary>
/// Enter of spatie op een tegel die je normaal aantikt. Zo'n tegel is geen knop voor de
/// browser, dus zonder dit is hij alleen met een vinger of muis te bedienen - en niet met
/// een toetsenbord of een schermlezer.
/// </summary>
protected static Task ToetsAsync(KeyboardEventArgs toets, Func<Task> doen) =>
toets.Key is "Enter" or " " ? doen() : Task.CompletedTask;
private void OpVerandering() => InvokeAsync(StateHasChanged);
public virtual void Dispose()
{
Toestand.Veranderd -= OpVerandering;
Vernieuwen.Gevraagd -= OpVernieuwenGevraagdAsync;
}
}
/// <summary>Kleine hulpmiddelen om waarden leesbaar op het scherm te zetten.</summary>
public static class Tonen
{
// De maandnamen komen niet uit de taalinstelling van het toestel. Die bepaalde ze eerst, en
// op een telefoon die op Engels staat las de lijst "9 June 2031" terwijl de rest van het
// scherm Nederlands is. Een app die maar in één taal bestaat hoort ook maar in één taal te
// rekenen.
//
// De lijst zelf staat in MijnBesluit.Client, naast de website-versie in het domein; een
// zelfcontrole houdt die twee gelijk. De website liep tegen precies hetzelfde aan.
private static string[] Maanden => MijnBesluit.Client.Datums.Maanden;
private static string[] KorteMaanden => MijnBesluit.Client.Datums.KorteMaanden;
public static string Datum(DateTime? moment)
{
if (moment is null) return "";
var d = moment.Value.ToLocalTime();
return $"{d.Day} {Maanden[d.Month - 1]} {d.Year}";
}
public static string DatumTijd(DateTime? moment)
{
if (moment is null) return "";
var d = moment.Value.ToLocalTime();
return $"{Datum(d)} om {d:HH:mm}";
}
/// <summary>
/// De datum zoals hij op een tegel staat: "9 Jun. 2031". Daar telt elke pixel, en de maand
/// voluit duwde het jaartal van de regel af - "22 Septem...".
/// </summary>
public static string Kort(DateTime? moment)
{
if (moment is null) return "";
var d = moment.Value.ToLocalTime();
return $"{d.Day} {KorteMaanden[d.Month - 1]} {d.Year}";
}
/// <summary>
/// Hetzelfde voor een kale datum zonder tijd - een geboortedag, een sterfdag. Die hoort
/// niet omgerekend te worden naar een tijdzone: 9 juni is 9 juni, waar je ook bent.
/// </summary>
public static string Kort(DateOnly? dag) =>
dag is null ? "" : $"{dag.Value.Day} {KorteMaanden[dag.Value.Month - 1]} {dag.Value.Year}";
/// <summary>De nummers komen overeen met VaultCategory in het domein.</summary>
public static string Categorie(int categorie) => categorie switch
{
1 => "Uitvaartwens",
2 => "Polis",
3 => "Wachtwoord",
4 => "Apparaat",
5 => "Pincode",
6 => "Document",
7 => "Notitie",
8 => "Sociale media",
_ => "Overig"
};
/// <summary>
/// Hoe een relatie op het scherm heet. De lijst staat in MijnBesluit.Client, zodat de app en
/// de website er niet ieder een eigen versie van hebben.
/// </summary>
public static string Relatie(int relatie) => MijnBesluit.Client.Relaties.Naam(relatie);
public static string Vrijgave(int beleid) => beleid switch
{
0 => "Alleen voor mijzelf",
1 => "Na mijn overlijden",
2 => "Op een datum",
_ => ""
};
/// <summary>
/// Het tekentje bij de vrijgave. Het takje is hetzelfde als dat van Nalatenschap onderin,
/// en de kalender staat voor een datum - zo hoeft niemand een nieuwe taal te leren.
///
/// Voor "Alleen voor mijzelf" staat hier niets: dat ben jij, en daar hoort je eigen
/// profielfoto, geen plaatje. Het scherm zet daar een penning neer.
/// </summary>
public static string Vrijgaveteken(int beleid) => beleid switch
{
1 => "\U0001F33F", // takje, net als bij Nalatenschap
2 => "\U0001F4C5", // kalender
_ => ""
};
}
@@ -0,0 +1,568 @@
@page "/familie"
@inherits SchermBasis
@using Microsoft.JSInterop
@inject IBestandskiezer Bestandskiezer
@inject IJSRuntime JS
@* Familie samenstellen en uitnodigen. Dit is het hart van MijnBesluit: zodra iemand de
uitnodiging aanneemt en zelf zijn familie invult, wordt het profiel van hem. Jij ziet hem
dan nog wel staan, maar kunt er niets meer in wijzigen. Tot dat moment mag je alles
aanpassen - ook een e-mailadres waar een tikfout in zit.
De kop staat in de balk erboven, dus hier niet nog een keer. *@
@if (Fout is not null)
{
<div class="melding fout">@Fout</div>
}
@if (Gelukt is not null)
{
<div class="melding goed">@Gelukt</div>
}
<button class="knop vol" style="margin-bottom:1rem" disabled="@Bezig" @onclick="Nieuw">
Toevoegen persoon
</button>
@* Jezelf bovenaan. Je staat verderop niet tussen de anderen - je bent geen familielid van
jezelf - maar dit is wel de plek waar mensen je zoeken als ze hun eigen gegevens willen
wijzigen. De kaart doet zelf niets; hij brengt je naar Mijn profiel. *@
<a class="kaart compact klikbaar zelf" href="/profiel" title="Mijn profiel">
<div class="rij persoon">
<Penning Naam="@Mijnnaam" Bron="@Ikzelf?.Profielfotobron" />
<div class="groei">
<span class="groot" title="@Mijnnaam">@Mijnnaam</span>
<div class="onderregel">
<span class="zacht">Je eigen gegevens en foto</span>
<span class="label geclaimd">Jijzelf</span>
</div>
</div>
</div>
</a>
<div class="stapel">
@foreach (var lid in Leden.Where(l => l.PersonId != Toestand.PersoonId))
{
@* Dezelfde tegel als bij Momenten: foto voorop, knoppen als tekentjes rechtsonder.
Aantikken opent het wijzigscherm. Een profiel dat iemand zelf beheert mag je niet
wijzigen en heeft geen knoppen: dat is dus geen tegel en ook niet aan te tikken. *@
<div class="kaart compact @Tegelklasse(lid)"
role="@(lid.IsGeclaimd ? null : "button")" tabindex="@(lid.IsGeclaimd ? null : "0")"
title="@(lid.IsGeclaimd ? null : "Gegevens wijzigen")"
@onclick="() => TegelWijzigenAsync(lid)"
@onkeydown="e => ToetsAsync(e, () => TegelWijzigenAsync(lid))">
<div class="rij persoon">
@* De profielfoto als die er is, anders de beginletters. Even groot, zodat de
lijst niet verspringt zodra er ergens een foto bij komt. *@
<Penning Naam="@lid.DisplayName" Bron="@lid.Profielfotobron" />
@* Drie regels, net als bij Momenten en Mijn kluis: naam, relatie met de status
rechts, en daaronder de regel met de knoppen. Op Mijn mensen staat op die
onderste regel verder niets - dat mag. Zo staat de status op elke tegel
pal boven de knoppen, en op elke tegel op dezelfde plek. *@
<div class="groei">
<span class="groot" title="@lid.DisplayName">@lid.DisplayName</span>
<div class="onderregel">
<span class="zacht">@Omschrijving(lid)</span>
@switch (lid.State)
{
case ProfielStand.Geclaimd:
<span class="label geclaimd" title="@lid.DisplayName beheert dit profiel zelf">Geclaimd</span>
break;
case ProfielStand.Uitgenodigd:
<span class="label uitgenodigd" title="De uitnodiging staat nog open">Uitgenodigd</span>
break;
default:
<span class="label" title="Jij beheert dit profiel">Door jou</span>
break;
}
</div>
</div>
</div>
@* Een eigen regel, buiten .groei en dus uitgelijnd met de linkerkant van de foto -
niet met de naam ernaast. Wijzigen staat er niet bij: daarvoor tik je de tegel
zelf aan. stopPropagation, want anders opent een druk op de prullenbak ook meteen
het wijzigscherm. Wat elke knop doet staat in title en aria-label, dus een
schermlezer hoort het gewoon. *@
<div class="onderregel" style="margin-top:.5rem">
@* Bij een geclaimd profiel staat hier wanneer dat gebeurde, op dezelfde regel
als de prullenbak. Kort: "Geclaimd op 15 Sep. 2026" past ernaast, zonder dat
de tegel hoger wordt. *@
<span class="zacht">
@if (lid.IsGeclaimd)
{
@:Geclaimd@(lid.ClaimedAt is { } sinds ? $" op {Tonen.Kort(sinds)}" : "").
}
</span>
@* De prullenbak staat op ELKE tegel, ook bij een profiel dat iemand zelf
beheert. Dat je Karin uit je lijst haalt zegt niets over haar eigen
MijnBesluit: haar profiel blijft van haar, het verdwijnt alleen hier.
Dat dit eerder niet kon was geen regel maar een gevolg - de knoppen stonden
samen in één "alleen als je mag wijzigen"-blok, en weghalen is iets anders
dan wijzigen.
Jezelf staat hier niet tussen; daarvoor hef je je account op. *@
<span class="tegelknoppen">
@if (!lid.IsGeclaimd && !string.IsNullOrWhiteSpace(lid.Email))
{
<button class="knop rustig klein" disabled="@Bezig" @onclick="() => UitnodigenAsync(lid)"
@onclick:stopPropagation="true"
title="@(lid.State == ProfielStand.Uitgenodigd ? "Opnieuw uitnodigen" : "Uitnodigen")"
aria-label="@(lid.State == ProfielStand.Uitgenodigd ? "Opnieuw uitnodigen" : "Uitnodigen")">&#9993;&#65039;</button>
}
<button class="knop rustig klein" disabled="@Bezig" @onclick="() => Weghalen = lid"
@onclick:stopPropagation="true"
title="Bij je mensen weghalen" aria-label="Bij je mensen weghalen">&#128465;&#65039;</button>
</span>
</div>
</div>
}
</div>
@if (Leden.Count <= 1)
{
<div class="leeg">
<span class="teken">&#128101;</span>
Er staat hier nog niemand. Voeg je partner of je kinderen toe;
zij krijgen een uitnodiging en kunnen daarna hun eigen MijnBesluit invullen.
</div>
}
@* ---------------------------------------------------------------- toevoegen en wijzigen *@
@if (Bewerken is not null)
{
<Popup Titel="@(Bewerken.PersoonId == 0 ? "Iemand toevoegen" : "Gegevens wijzigen")"
Bevestigtekst="Opslaan" Bezig="@Bezig" Foutmelding="@Fout"
OpBevestigen="OpslaanAsync" OpSluiten="Sluiten">
<div class="raster">
<div class="veld"><label for="vn">Voornaam</label><input id="vn" @bind="Bewerken.Voornaam" /></div>
<div class="veld"><label for="an">Achternaam</label><input id="an" @bind="Bewerken.Achternaam" /></div>
</div>
<div class="veld">
<label for="rel">Wat is het van je?</label>
<select id="rel" @bind="Bewerken.Relatie">
@foreach (var (waarde, naam) in Relaties)
{
<option value="@waarde">@naam</option>
}
</select>
</div>
<div class="veld">
<label>Geboortedatum</label>
<Datumkiezer Waarde="Bewerken.Geboortedatum"
WaardeChanged="d => Bewerken.Geboortedatum = d"
Optioneel="true" Aflopend="true"
VanJaar="1900" TotJaar="@DateTime.Today.Year" />
<div class="uitleg">Niet verplicht. Met een datum staat je familie bovenaan op leeftijd.</div>
</div>
<div class="veld">
<label for="em">E-mailadres</label>
<input id="em" type="email" inputmode="email" @bind="Bewerken.Email" />
<div class="uitleg">Met een adres kun je hem uitnodigen. Zonder adres beheer je het profiel zelf.</div>
</div>
@if (Bewerken.PersoonId == 0)
{
<div class="veld">
<label>Foto's en documenten</label>
<div class="uitleg">
Die kun je toevoegen zodra deze persoon is opgeslagen; ze horen bij hem en
hij moet er eerst zijn.
</div>
</div>
}
else
{
<Bijlagenblok Bijlagen="Bijlagen" Bezig="@Bezig"
KanVideo="true" KanCamera="@Bestandskiezer.KanCamera" KanProfielfoto="true"
Overzetten="@Overzetten" Percentage="@Percentage"
Uitleg="Een foto van hem samen met jou, een kopie van zijn paspoort, een brief. Met het sterretje maak je er zijn profielfoto van."
OpFotoMaken="() => ToevoegenAsync(Soort.FotoMaken)"
OpFoto="() => ToevoegenAsync(Soort.Foto)"
OpDocument="() => ToevoegenAsync(Soort.Document)"
OpVideo="() => ToevoegenAsync(Soort.VideoOpnemen)"
OpVideoKiezen="() => ToevoegenAsync(Soort.Video)"
OpVerwijderen="BijlageWegAsync"
OpProfielfoto="ProfielfotoAsync" />
}
@if (BewerkLid is { } lid)
{
<div class="zacht geschiedenis">
<div>Profiel aangemaakt op @Tonen.Datum(lid.CreatedAt)</div>
@if (lid.InvitedAt is { } verstuurd)
{
<div>
Uitgenodigd op @Tonen.Datum(verstuurd)@(lid.InvitationResends > 0
? $" ({lid.InvitationResends + 1}× verstuurd)" : "")
</div>
@if (lid.InvitationExpiresAt is { } tot)
{
<div>
@(tot > DateTime.UtcNow
? $"Uitnodiging geldig tot {Tonen.Datum(tot)}"
: $"Uitnodiging verlopen op {Tonen.Datum(tot)}")
</div>
}
}
else
{
<div>Nog niet uitgenodigd.</div>
}
</div>
}
</Popup>
}
@* ---------------------------------------------------------------- verwijderen *@
@if (Weghalen is not null)
{
@* Drie gevallen, en ze verdienen alle drie een ander scherm.
1. Hij beheert je nalatenschap. Dan kan het niet, en zegt dit scherm waar je dat weghaalt.
De server weigert het ook; dit is er zodat je niet eerst op "verwijderen" hoeft te
drukken om erachter te komen.
2. Er staan berichten voor hem klaar. Die verdwijnen mee, en dat is het soort ding dat je
wilt weten vóór je ja zegt - niet erna.
3. Gewoon weghalen. *@
@if (Weghalen.IsBeheerder)
{
<Popup Titel="@($"{Weghalen.DisplayName} kan niet weg")"
Bevestigtekst="Begrepen" AlleenSluiten="true" Bezig="@Bezig"
OpBevestigen="() => Weghalen = null" OpSluiten="() => Weghalen = null">
<p>
@Weghalen.DisplayName is aangewezen om je nalatenschap te beheren. Zolang dat zo
is, hoort hij bij je mensen te staan.
</p>
<p class="zacht">
Haal hem eerst weg bij <strong>Nalatenschap</strong>. Dat is met opzet een aparte
handeling: wie je nalatenschap beheert, is een besluit dat je niet per ongeluk
terugdraait door iemand uit een lijst te vegen.
</p>
</Popup>
}
else
{
<Popup Titel="@($"{Weghalen.DisplayName} verwijderen?")"
Bevestigtekst="Verwijderen" Gevaarlijk="true" Bezig="@Bezig"
OpBevestigen="VerwijderenAsync" OpSluiten="() => Weghalen = null">
<p>
@Weghalen.DisplayName verdwijnt uit je lijst. Een uitnodiging die nog openstaat
vervalt daarmee.
</p>
@if (Weghalen.MomentenKlaar > 0)
{
<div class="melding let-op">
Er @(Weghalen.MomentenKlaar == 1 ? "staat nog 1 moment" : $"staan nog {Weghalen.MomentenKlaar} momenten")
voor @Weghalen.DisplayName klaar. @(Weghalen.MomentenKlaar == 1 ? "Dat verdwijnt" : "Die verdwijnen") mee
en @(Weghalen.MomentenKlaar == 1 ? "wordt" : "worden") dus nooit bezorgd.
Wat al bezorgd is blijft staan.
</div>
}
<p class="zacht">
@if (Weghalen.IsGeclaimd)
{
<text>
@Weghalen.DisplayName beheert dit profiel zelf. Het verdwijnt alleen bij
jou; zijn eigen MijnBesluit blijft zoals het is.
</text>
}
else
{
<text>
Het profiel zelf blijft bestaan; hij kan in andere families staan of zijn
eigen MijnBesluit hebben.
</text>
}
</p>
</Popup>
}
}
@code {
private IReadOnlyList<Familielid> Leden = Array.Empty<Familielid>();
/// <summary>Jouw eigen regel uit de ledenlijst; daar staat je profielfoto in.</summary>
private Familielid? Ikzelf => Leden.FirstOrDefault(l => l.PersonId == Toestand.PersoonId);
private string Mijnnaam => Ikzelf?.DisplayName is { Length: > 0 } naam ? naam : "Mijn profiel";
private Formulier? Bewerken;
/// <summary>Het lid dat bij <see cref="Bewerken"/> hoort; null bij toevoegen.
/// Nodig omdat het formulier alleen de te wijzigen velden bevat, niet de geschiedenis.</summary>
private Familielid? BewerkLid;
private string? Gelukt;
/// <summary>Wie er op verwijderen staat te wachten; null als er niemand weg hoeft.</summary>
private Familielid? Weghalen;
/// <summary>De foto's en documenten bij de persoon die in het wijzigscherm staat.</summary>
private IReadOnlyList<Bijlage> Bijlagen = Array.Empty<Bijlage>();
/// <summary>Tekst bij de voortgangsbalk tijdens een overdracht; null als er niets loopt.</summary>
private string? Overzetten;
private int Percentage;
private enum Soort { Document, Foto, FotoMaken, Video, VideoOpnemen }
/// <summary>
/// Wat je kunt kiezen. De lijst zelf staat in MijnBesluit.Client, naast de website-versie in
/// het domein; een zelfcontrole houdt die twee gelijk.
/// </summary>
private static (int Waarde, string Naam)[] Relaties => MijnBesluit.Client.Relaties.Keuzes;
protected override async Task LaadAsync()
{
if (Toestand.Familie is null)
{
var ververst = await Toestand.VerversAsync();
if (!ververst.Gelukt) { Fout = ververst.Melding; return; }
}
if (Toestand.Familie is null) return;
var leden = await Toestand.Client.FamilieledenAsync(Toestand.Familie.Id);
if (leden.Gelukt) Leden = leden.Waarde!;
else Fout = leden.Melding;
}
private void Nieuw()
{
Gelukt = null;
Fout = null;
Weghalen = null;
BewerkLid = null;
Bijlagen = Array.Empty<Bijlage>();
Bewerken = new Formulier();
}
private async Task WijzigenAsync(Familielid lid)
{
Gelukt = null;
Fout = null;
Weghalen = null;
BewerkLid = lid;
Bewerken = new Formulier
{
PersoonId = lid.PersonId,
Voornaam = lid.FirstName,
Achternaam = lid.LastName,
Email = lid.Email ?? "",
Relatie = lid.Relation,
Geboortedatum = lid.BirthDate?.ToDateTime(TimeOnly.MinValue)
};
await BijlagenLadenAsync();
}
private async Task OpslaanAsync()
{
var formulier = Bewerken!;
if (string.IsNullOrWhiteSpace(formulier.Voornaam) && string.IsNullOrWhiteSpace(formulier.Achternaam))
{
Fout = "Vul minstens een voor- of achternaam in.";
await JS.InvokeVoidAsync("mijnbesluit.springNaarVeld", "vn");
return;
}
var email = string.IsNullOrWhiteSpace(formulier.Email) ? null : formulier.Email.Trim();
if (await ProbeerAsync(async () =>
{
var geboren = formulier.Geboortedatum is { } g ? DateOnly.FromDateTime(g) : (DateOnly?)null;
var r = formulier.PersoonId == 0
? await Toestand.Client.VoegFamilielidToeAsync(
Toestand.Familie!.Id, formulier.Voornaam.Trim(), formulier.Achternaam.Trim(),
email, formulier.Relatie, geboren)
: await Toestand.Client.WijzigFamilielidAsync(
Toestand.Familie!.Id, formulier.PersoonId, formulier.Voornaam.Trim(),
formulier.Achternaam.Trim(), email, formulier.Relatie, null, geboren);
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
}))
{
Gelukt = formulier.PersoonId == 0 ? "Toegevoegd." : "De wijziging is opgeslagen.";
Sluiten();
await LaadAsync();
}
}
private async Task VerwijderenAsync()
{
var lid = Weghalen!;
Gelukt = null;
if (await ProbeerAsync(async () =>
{
var r = await Toestand.Client.VerwijderFamilielidAsync(Toestand.Familie!.Id, lid.PersonId);
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
}))
{
Gelukt = $"{lid.DisplayName} staat hier niet meer bij.";
Weghalen = null;
await LaadAsync();
}
}
private async Task UitnodigenAsync(Familielid lid)
{
Gelukt = null;
if (await ProbeerAsync(async () =>
{
var r = await Toestand.Client.NodigUitAsync(Toestand.Familie!.Id, lid.PersonId);
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
}))
{
Gelukt = $"De uitnodiging is naar {lid.Email} gestuurd.";
await LaadAsync();
}
}
// ------------------------------------------------------------------ foto's en documenten
private async Task BijlagenLadenAsync()
{
if (Bewerken is not { PersoonId: > 0 } formulier) { Bijlagen = Array.Empty<Bijlage>(); return; }
var lijst = await Toestand.Client.PersoonsfotosAsync(formulier.PersoonId);
Bijlagen = lijst.Gelukt ? lijst.Waarde! : Array.Empty<Bijlage>();
if (!lijst.Gelukt) Fout = lijst.Melding;
}
private async Task ToevoegenAsync(Soort soort)
{
if (Bewerken is not { PersoonId: > 0 } formulier) return;
Fout = null;
var gekozen = soort switch
{
Soort.Document => await Bestandskiezer.KiesDocumentAsync(),
Soort.Foto => await Bestandskiezer.KiesFotoAsync(),
Soort.FotoMaken => await Bestandskiezer.MaakFotoAsync(),
Soort.Video => await Bestandskiezer.KiesVideoAsync(),
_ => await Bestandskiezer.NeemVideoOpAsync()
};
// Niets gekozen of afgebroken: gewoon niets doen, geen foutmelding.
if (gekozen is null) return;
Overzetten = $"{gekozen.Naam} wordt opgeslagen…";
Percentage = 0;
StateHasChanged();
var uitkomst = await Toestand.Client.BestandVersturenAsync(
gekozen.Naam, gekozen.Soort, gekozen.Inhoud, null,
(gedaan, totaal) =>
{
Percentage = totaal == 0 ? 100 : (int)(gedaan * 100 / totaal);
InvokeAsync(StateHasChanged);
},
persoonId: formulier.PersoonId);
Overzetten = null;
if (!uitkomst.Gelukt) Fout = uitkomst.Melding;
else await BijlagenLadenAsync();
}
private async Task BijlageWegAsync(Bijlage bijlage)
{
if (await ProbeerAsync(async () =>
{
var r = await Toestand.Client.BijlageVerwijderenAsync(bijlage.Id);
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
}))
{
await BijlagenLadenAsync();
}
}
/// <summary>Wijst deze foto aan als profielfoto; een eerdere profielfoto blijft gewoon staan.</summary>
private async Task ProfielfotoAsync(Bijlage bijlage)
{
if (await ProbeerAsync(async () =>
{
var r = await Toestand.Client.ProfielfotoAsync(bijlage.Id);
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
}))
{
await BijlagenLadenAsync();
}
}
private void Sluiten()
{
Bewerken = null;
BewerkLid = null;
Bijlagen = Array.Empty<Bijlage>();
Overzetten = null;
}
/// <summary>Eén formulier voor toevoegen en wijzigen; PersoonId 0 betekent nieuw.</summary>
private class Formulier
{
public long PersoonId { get; set; }
public string Voornaam { get; set; } = "";
public string Achternaam { get; set; } = "";
public string Email { get; set; } = "";
public int Relatie { get; set; } = 1;
/// <summary>Null als er geen geboortedatum is ingevuld; dat mag.</summary>
public DateTime? Geboortedatum { get; set; }
}
/// <summary>
/// De regel onder de naam: "Partner (59)". Kort gehouden, want de status staat ernaast en
/// die heeft ook ruimte nodig. Is iemand overleden, dan is dat belangrijker dan zijn leeftijd.
/// </summary>
/// <summary>
/// Welke tegelklasse deze kaart krijgt. Bij een profiel dat iemand zelf beheert staan er
/// geen knoppen en valt er niets te wijzigen: dan is het geen tegel en ook niet aan te
/// tikken.
/// </summary>
private static string Tegelklasse(Familielid lid) =>
lid.IsGeclaimd ? "" : "tegel klikbaar";
/// <summary>
/// De tegel is aangetikt. Een profiel dat iemand zelf beheert mag je niet wijzigen, dus
/// dan gebeurt er niets.
/// </summary>
private Task TegelWijzigenAsync(Familielid lid) =>
lid.IsGeclaimd ? Task.CompletedTask : WijzigenAsync(lid);
private static string Omschrijving(Familielid lid)
{
var relatie = Tonen.Relatie(lid.Relation);
if (lid.DateOfDeath is { } overleden)
return $"{relatie} · overleden {Tonen.Kort(overleden)}";
return lid.BirthDate is { } geboren ? $"{relatie} ({Leeftijd(geboren)})" : relatie;
}
/// <summary>Leeftijd in hele jaren, zoals je hem zelf zou uitrekenen.</summary>
private static int Leeftijd(DateOnly geboren)
{
var vandaag = DateOnly.FromDateTime(DateTime.Today);
var jaren = vandaag.Year - geboren.Year;
if (geboren.AddYears(jaren) > vandaag) jaren--;
return jaren;
}
}
@@ -0,0 +1,157 @@
@page "/inloggen"
@inject AppToestand Toestand
@inject NavigationManager Navigatie
@* Aanmelden gaat in twee stappen: e-mailadres, dan de code uit de mail. Geen wachtwoorden;
er valt dus ook niets te vergeten of te laten uitlekken. *@
<div class="middenblok">
@* Hier stond het logo groot. Weggehaald: de balk erboven zegt al "Inloggen", en het logo
duwde "Welkom terug" en het invulveld zo ver naar beneden dat je op een telefoon eerst
naar een leeg scherm keek. Op de website staat hij wel: daar is de pagina hoog genoeg en
is het het eerste wat iemand van MijnBesluit ziet. *@
<h1>@(NieuwAccount ? "Begin je eigen MijnBesluit" : "Welkom terug")</h1>
<p class="zacht">
@(NieuwAccount
? "Je legt vast wat je wilt regelen, en zet berichten klaar voor de mensen om je heen."
: "Vul je e-mailadres in; je krijgt een code van zes cijfers om mee binnen te komen.")
</p>
@if (Fout is not null)
{
<div class="melding fout">@Fout</div>
}
<div class="kaart">
@if (!CodeVerstuurd)
{
@if (NieuwAccount)
{
<div class="raster">
<div class="veld">
<label for="voornaam">Voornaam</label>
<input id="voornaam" @bind="Voornaam" autocomplete="given-name" />
</div>
<div class="veld">
<label for="achternaam">Achternaam</label>
<input id="achternaam" @bind="Achternaam" autocomplete="family-name" />
</div>
</div>
}
<div class="veld">
<label for="email">E-mailadres</label>
<input id="email" type="email" inputmode="email" autocomplete="email"
@bind="Email" @bind:event="oninput" />
</div>
<button class="knop vol" disabled="@Bezig" @onclick="CodeAanvragenAsync">
@(Bezig ? "Even geduld…" : "Code aanvragen")
</button>
}
else
{
<p>We hebben een code gestuurd naar <span class="naar-adres">@Afgeschermd</span>.</p>
<div class="veld">
<label for="code">Code uit de e-mail</label>
<input id="code" class="codeveld" inputmode="numeric" autocomplete="one-time-code"
maxlength="7" @bind="Code" @bind:event="oninput" />
<div class="uitleg">De code blijft een kwartier geldig.</div>
</div>
<button class="knop vol" disabled="@(Bezig || Code.Length < 6)" @onclick="AanmeldenAsync">
@(Bezig ? "Even geduld…" : "Verder")
</button>
<button class="knop rustig vol" style="margin-top:.6rem" disabled="@Bezig" @onclick="OpnieuwAsync">
Ik heb geen code ontvangen
</button>
}
</div>
<p class="voet">
@if (NieuwAccount)
{
<span>Heb je al een account? <a href="" @onclick="NaarInloggen" @onclick:preventDefault>Inloggen</a>.</span>
}
else
{
<span>Nog geen account? <a href="" @onclick="NaarRegistreren" @onclick:preventDefault>Begin hier</a>.</span>
}
</p>
</div>
@code {
[Parameter, SupplyParameterFromQuery(Name = "nieuw")]
public bool NieuwAccount { get; set; }
private string Email = "";
private string Voornaam = "";
private string Achternaam = "";
private string Code = "";
private string? Afgeschermd;
private bool CodeVerstuurd;
private bool Bezig;
private string? Fout;
protected override async Task OnInitializedAsync()
{
// Wie nog een geldige sessie op het toestel heeft, hoeft hier niet te zijn.
if (await Toestand.HervatAsync()) Navigatie.NavigateTo("/");
}
private async Task CodeAanvragenAsync()
{
Bezig = true;
Fout = null;
try
{
var uitkomst = NieuwAccount
? await Toestand.Client.VraagRegistratiecodeAanAsync(Email.Trim(), Voornaam.Trim(), Achternaam.Trim())
: await Toestand.Client.VraagInlogcodeAanAsync(Email.Trim());
if (!uitkomst.Gelukt) { Fout = uitkomst.Melding; return; }
Afgeschermd = uitkomst.Waarde!.VerstuurdNaar;
CodeVerstuurd = true;
Code = "";
}
finally { Bezig = false; }
}
private async Task AanmeldenAsync()
{
Bezig = true;
Fout = null;
try
{
var uitkomst = NieuwAccount
? await Toestand.Client.RegistreerAsync(Email.Trim(), Code)
: await Toestand.Client.LogInAsync(Email.Trim(), Code);
if (!uitkomst.Gelukt) { Fout = uitkomst.Melding; Code = ""; return; }
await Toestand.VerversAsync();
await Toestand.MeldToestelAanAsync();
Navigatie.NavigateTo("/");
}
finally { Bezig = false; }
}
private async Task OpnieuwAsync()
{
CodeVerstuurd = false;
await CodeAanvragenAsync();
}
private void NaarInloggen() { NieuwAccount = false; Opnieuw(); }
private void NaarRegistreren() { NieuwAccount = true; Opnieuw(); }
private void Opnieuw()
{
CodeVerstuurd = false;
Code = "";
Fout = null;
}
}
@@ -0,0 +1,117 @@
@page "/instellingen"
@inject AppToestand Toestand
@inject Uitlogklok Klok
@inject IToestelslot Slot
@inject NavigationManager Navigatie
@* Wat je op dít toestel kunt instellen. Alles hier blijft op de telefoon: de server weet er
niets van, en op een andere telefoon staat het dus apart.
De kop staat in de balk erboven, dus hier niet nog een keer. *@
@if (Gelukt is not null)
{
<div class="melding goed">@Gelukt</div>
}
<div class="kaart">
<h2 style="margin-top:0">@(Slot.Beschikbaar ? "Automatisch op slot" : "Automatisch uitloggen")</h2>
<p class="zacht" style="margin-top:0">
De app blijft normaal aangemeld, zodat je niet elke keer een code uit je mail hoeft te
halen. Maar op een telefoon die op tafel ligt of kwijtraakt, staat je kluis dan open.
Hier zet je een termijn: gebeurt er zo lang niets, dan
@(Slot.Beschikbaar ? "gaat de app op slot" : "logt de app zichzelf uit").
</p>
<div class="veld">
<label for="minuten">@(Slot.Beschikbaar ? "Op slot na" : "Uitloggen na")</label>
@* Met een toestelslot staat "uit" er niet bij. Het slot kost je één aanraking; die keuze
levert dus bijna niets op en betekent wel dat een gevonden telefoon een open kluis is.
Zonder toestelslot mag hij er wél staan - daar is de prijs van de termijn een nieuwe
code per e-mail, en dat is te zwaar om iemand ongevraagd op te leggen. *@
<select id="minuten" @bind="Minuten" @bind:after="OpslaanAsync">
@foreach (var keuze in (Slot.Beschikbaar ? Uitlogklok.KeuzesMetSlot : Uitlogklok.Keuzes))
{
<option value="@keuze">@Uitlogklok.Naam(keuze)</option>
}
</select>
@* Dezelfde instelling, maar wat hij kost verschilt per toestel. Dat hoort erbij te
staan: het is het verschil tussen één aanraking en een e-mail afwachten. *@
<div class="uitleg">
@if (Slot.Beschikbaar)
{
<text>
Je blijft aangemeld. Openen gaat met je vingerafdruk of de code van je
telefoon - dezelfde waarmee je het toestel zelf ontgrendelt. MijnBesluit
krijgt die nooit te zien; de telefoon zegt alleen ja of nee.
</text>
}
else
{
<text>
Let op: op dit toestel is dit echt uitloggen. Je moet daarna een nieuwe code
per e-mail aanvragen, net als de eerste keer. Dat komt doordat deze telefoon
geen schermslot heeft, of te oud is om erom te kunnen vragen. Daarom staat
het hier standaard uit.
</text>
}
</div>
</div>
</div>
<div class="kaart" style="margin-top:1rem">
<h2 style="margin-top:0">Meldingen</h2>
<p class="zacht" style="margin:0">
Of je telefoon iets laat weten als er een bericht klaarstaat, regel je bij
<a href="/profiel">Mijn profiel</a>. Daar staat ook de knop om een proefmelding te sturen.
</p>
</div>
@* Dezelfde tekst als op mijnbesluit.nl/privacy, uit hetzelfde bestand - zie Privacytekst.razor.
In een popup en niet in de browser: een verklaring die de app uit stuurt, is er een die
niemand leest. *@
<div class="kaart" style="margin-top:1rem">
<h2 style="margin-top:0">Privacy</h2>
<p class="zacht" style="margin-top:0">
Wat we vastleggen, wat versleuteld is, wie wat kan zien, en hoe je alles laat verwijderen.
</p>
<button type="button" class="knop rustig" @onclick="() => Privacy = true">
Privacyverklaring lezen
</button>
</div>
@if (Privacy)
{
<Popup Titel="Privacy" Bevestigtekst="Sluiten" AlleenSluiten="true"
OpBevestigen="() => Privacy = false" OpSluiten="() => Privacy = false">
<Privacytekst />
</Popup>
}
@code {
/// <summary>Staat de privacyverklaring open?</summary>
private bool Privacy;
private int Minuten;
private string? Gelukt;
// De klok tilt een nul zelf op naar de standaard zodra dit toestel om een vingerafdruk kan
// vragen, dus wat hier binnenkomt staat altijd in de lijst.
protected override void OnInitialized() => Minuten = Klok.Minuten;
private Task OpslaanAsync()
{
Klok.Minuten = Minuten;
var termijn = Uitlogklok.Naam(Minuten).ToLowerInvariant().Replace("na ", "");
Gelukt = Minuten == 0
? "De app blijft open staan."
: Slot.Beschikbaar
? $"De app gaat op slot als er {termijn} niets gebeurt."
: $"De app logt zichzelf uit als er {termijn} niets gebeurt.";
return Task.CompletedTask;
}
}
+609
View File
@@ -0,0 +1,609 @@
@page "/kluis"
@inherits SchermBasis
@using Microsoft.JSInterop
@inject IBestandskiezer Bestandskiezer
@inject IJSRuntime JS
@* De kluis: wachtwoorden, pincodes, polissen en uitvaartwensen. Alles wat hier binnenkomt
wordt op de server versleuteld; de app ziet de inhoud alleen als hij erom vraagt. *@
@* De kop staat in de balk erboven, dus hier niet nog een keer.
Bovenaan filteren en toevoegen op één regel, net als op de website: het soort links, de knop
ernaast. Het filteren gebeurt bij de server en niet hier in de lijst - dan hoeft de telefoon
niet alles op te halen om er drie dingen uit te laten zien. *@
@* Geen label boven de keuzelijst: "Alles" of "Wachtwoord" zegt zelf al genoeg, en het scheelt
een regel bovenaan een scherm waar de lijst de hoofdzaak is. Voor een schermlezer staat het er
nog wel, in aria-label.
De keuzelijst en de knop zijn even hoog doordat de rij op stretch staat: dan worden ze allebei
zo hoog als de rij. Een vaste hoogte meegeven zou ook kunnen, maar dan loopt het uit elkaar
zodra iemand zijn lettergrootte verandert. *@
<div class="rij kluiskop">
<select class="groei" aria-label="Filter op soort" disabled="@Bezig" @bind="Filter" @bind:after="LaadAsync">
<option value="">Alles</option>
<option value="3">Wachtwoord</option>
<option value="8">Sociale media</option>
<option value="4">Apparaat</option>
<option value="5">Pincode</option>
<option value="2">Polis</option>
<option value="1">Uitvaartwens</option>
<option value="9">Donorcodicil</option>
<option value="10">Rijbewijs</option>
<option value="11">Paspoort</option>
<option value="6">Document</option>
<option value="7">Notitie</option>
<option value="99">Overig</option>
</select>
<button class="knop" disabled="@Bezig" @onclick="() => Nieuw()">Toevoegen</button>
</div>
@if (Fout is not null)
{
<div class="melding fout">@Fout</div>
}
@if (Bewerken is not null)
{
<Popup Titel="@(ItemId == 0 ? "Nieuw item" : "Item wijzigen")"
Bevestigtekst="Opslaan" Bezig="@Bezig"
OpBevestigen="OpslaanAsync" OpSluiten="Afbreken">
<div class="veld">
<label for="soort">Wat leg je vast?</label>
@* De nummers komen overeen met VaultCategory in het domein. Overig is 99, niet 9:
dat stond hier eerder verkeerd en leverde een categorie op die nergens bij hoort. *@
<select id="soort" @bind="Categorie">
<option value="3">Wachtwoord</option>
<option value="8">Sociale media</option>
<option value="4">Apparaat</option>
<option value="5">Pincode</option>
<option value="2">Polis</option>
<option value="1">Uitvaartwens</option>
<option value="9">Donorcodicil</option>
<option value="10">Rijbewijs</option>
<option value="11">Paspoort</option>
<option value="6">Document</option>
<option value="7">Notitie</option>
<option value="99">Overig</option>
</select>
</div>
<div class="veld">
<label for="titel">Naam</label>
<input id="titel" @bind="Titel" placeholder="Waar herken je het aan?" />
</div>
@switch (Categorie)
{
case 3:
<div class="veld"><label for="site">Website</label><input id="site" @bind="Bewerken.Website" /></div>
<div class="veld"><label for="gebruiker">Gebruikersnaam</label><input id="gebruiker" @bind="Bewerken.Username" /></div>
<div class="veld"><label for="geheim">Wachtwoord</label><input id="geheim" @bind="Bewerken.Secret" /></div>
break;
case 8:
<div class="veld">
<label for="medium">Welk medium?</label>
<select id="medium" @bind="Bewerken.Platform">
<option value="">Kies&hellip;</option>
@foreach (var naam in SocialeMedia.Namen)
{
<option value="@naam">@naam</option>
}
</select>
</div>
@if (Bewerken.Platform == SocialeMedia.Anders)
{
<div class="veld">
<label for="medium-anders">Welke dan?</label>
<input id="medium-anders" @bind="Bewerken.PlatformAnders" />
</div>
}
<div class="veld"><label for="sm-gebruiker">Gebruikersnaam</label><input id="sm-gebruiker" @bind="Bewerken.Username" /></div>
<div class="veld"><label for="sm-geheim">Wachtwoord</label><input id="sm-geheim" @bind="Bewerken.Secret" /></div>
<div class="veld">
<label for="sm-herstel">Herstelcodes</label>
<textarea id="sm-herstel" rows="2" @bind="Bewerken.RecoveryCodes"></textarea>
<div class="uitleg">De codes die je krijgt bij tweestapsverificatie. Zonder die codes komt niemand er nog in.</div>
</div>
break;
case 4:
<div class="veld"><label for="apparaat">Apparaat</label><input id="apparaat" @bind="Bewerken.DeviceName" /></div>
<div class="veld"><label for="app-gebruiker">Gebruikersnaam</label><input id="app-gebruiker" @bind="Bewerken.Username" /></div>
<div class="veld"><label for="app-geheim">Wachtwoord of code</label><input id="app-geheim" @bind="Bewerken.Secret" /></div>
break;
case 5:
<div class="veld"><label for="waarvan">Waarvan</label><input id="waarvan" @bind="Bewerken.DeviceName" placeholder="Mijn telefoon" /></div>
<div class="veld"><label for="pin">Pincode</label><input id="pin" inputmode="numeric" @bind="Bewerken.Pincode" /></div>
break;
case 2:
<div class="veld"><label for="maats">Verzekeraar</label><input id="maats" @bind="Bewerken.Insurer" /></div>
<div class="veld"><label for="polis">Polisnummer</label><input id="polis" @bind="Bewerken.PolicyNumber" /></div>
<div class="veld"><label for="tel">Telefoonnummer</label><input id="tel" inputmode="tel" @bind="Bewerken.ContactPhone" /></div>
break;
case 1:
<div class="veld">
<label for="wijze">Begraven of cremeren</label>
<select id="wijze" @bind="Bewerken.BurialOrCremation">
<option value="">Nog niet gekozen</option>
<option value="Begraven">Begraven</option>
<option value="Cremeren">Cremeren</option>
<option value="Aan de wetenschap">Aan de wetenschap</option>
<option value="Nabestaanden beslissen">Nabestaanden beslissen</option>
</select>
</div>
<div class="veld"><label for="plek">Plek</label><input id="plek" @bind="Bewerken.Location" /></div>
<div class="veld"><label for="muziek">Muziek</label><textarea id="muziek" rows="1" @bind="Bewerken.Music"></textarea></div>
<div class="veld"><label for="wensen">Wensen</label><textarea id="wensen" rows="4" @bind="Bewerken.Wishes"></textarea></div>
break;
case 9:
break;
case 10:
case 11:
// Geen eigen velden: alleen de titel, de foto's hieronder en de gewone
// toelichting. Zie de gerichte maak-knoppen vlak boven de bijlagenlijst.
break;
}
@if (Categorie is 1 or 9)
{
<div class="veld">
<label for="bespreken">Bespreken met</label>
<select id="bespreken" @bind="BesprekenMetTekst">
<option value="">Niemand (alleen voor mijzelf)</option>
@foreach (var b in Beheerders)
{
<option value="@b.PersonId">@b.DisplayName</option>
}
</select>
<div class="uitleg">
Kies je hier iemand, dan kan diegene dit item meteen lezen en met je bespreken,
nog vóór je overlijden. Kies je niemand, dan blijft dit item zoals altijd alleen
voor jezelf.
</div>
</div>
}
<div class="veld">
<label for="notitie">Toelichting</label>
<textarea id="notitie" rows="3" @bind="Bewerken.Notes"></textarea>
</div>
@* Rijbewijs en paspoort vragen om specifieke kanten, niet om "een foto, welke dan
ook" - dus staan hier twee (of één) gerichte knoppen boven de gewone bijlagenlijst.
De naam waarmee de foto wordt opgeslagen zegt meteen welke kant het is; dat scheelt
een apart veld erbij, en de bijlagenlijst hieronder laat ze meteen zien. *@
@if (Bestandskiezer.KanCamera && Categorie == 10)
{
<div class="rij" style="gap:.5rem;margin-bottom:.6rem">
<button type="button" class="knop rustig groei" disabled="@Bezig"
@onclick='() => MaakGerichteFotoAsync("Rijbewijs voorkant")'>
&#128247; Voorkant maken
</button>
<button type="button" class="knop rustig groei" disabled="@Bezig"
@onclick='() => MaakGerichteFotoAsync("Rijbewijs achterkant")'>
&#128247; Achterkant maken
</button>
</div>
}
else if (Bestandskiezer.KanCamera && Categorie == 11)
{
<div class="rij" style="gap:.5rem;margin-bottom:.6rem">
<button type="button" class="knop rustig groei" disabled="@Bezig"
@onclick='() => MaakGerichteFotoAsync("Paspoort")'>
&#128247; Foto maken
</button>
</div>
}
@* Ook bij een nieuw item. Hier was geen nummer om een bestand aan te hangen, dus stond
er een zinnetje "dat kan zodra dit is opgeslagen" - een uitleg van onze boekhouding
waar iemand die een foto van zijn polis wil toevoegen niets aan heeft. Nu wachten ze
in Wachtrij en gaan ze mee zodra het opslaan klaar is. *@
<Bijlagenblok Bijlagen="@AlleBijlagen" Bezig="@Bezig"
KanVideo="true" KanCamera="@Bestandskiezer.KanCamera"
Overzetten="@Overzetten" Percentage="@Percentage"
Uitleg="@(ItemId == 0
? "Een foto van je polis, een scan van je testament. Ze worden meegestuurd zodra je opslaat."
: "Een foto van je polis, een scan van je testament, de handleiding van de kluis zelf.")"
OpFotoMaken="() => ToevoegenAsync(Soort.FotoMaken)"
OpFoto="() => ToevoegenAsync(Soort.Foto)"
OpDocument="() => ToevoegenAsync(Soort.Document)"
OpVideo="() => ToevoegenAsync(Soort.VideoOpnemen)"
OpVideoKiezen="() => ToevoegenAsync(Soort.Video)"
OpVerwijderen="BijlageWegAsync" />
<div class="veld">
<label for="vrijgave">Wanneer mag dit gezien worden?</label>
<select id="vrijgave" @bind="Vrijgave">
<option value="0">Alleen voor mijzelf</option>
<option value="1">Na mijn overlijden</option>
<option value="2">Op een datum</option>
</select>
</div>
@if (Vrijgave == 2)
{
<div class="veld">
<label>Vrijgeven op</label>
<Datumkiezer @bind-Waarde="VrijgaveDatum" />
</div>
}
</Popup>
}
@if (Items.Count == 0 && Bewerken is null)
{
@* Met een filter aan betekent "niets te zien" iets anders dan met een lege kluis: dan is er
wél iets, alleen niet van deze soort. *@
<div class="leeg">
<span class="teken">&#128274;</span>
@if (Filter.Length > 0)
{
<text>Van deze soort staat er nog niets in je kluis.</text>
}
else
{
<text>
Je kluis is nog leeg. Begin met wat je nu al kwijt zou raken:
het wachtwoord van je e-mail, of de pincode van je telefoon.
</text>
}
</div>
}
<div class="stapel">
@foreach (var item in Items)
{
@* Dezelfde tegel als bij Momenten en Mijn mensen: drie regels over de volle breedte,
de knoppen als tekentjes rechtsonder. Een kluisitem heeft geen foto, dus begint de
tegel meteen met de titel.
Aantikken opent het scherm met de inhoud. Dat is hetzelfde scherm als wijzigen: het
liet toch al alles zien wat er in het item staat, inclusief de bijlagen. Er stond hier
eerder een uitklap ónder de tegel die precies hetzelfde toonde - twee plekken om te
onderhouden, en twee manieren om hetzelfde te doen. *@
<div class="kaart compact tegel klikbaar"
role="button" tabindex="0" title="Bekijken en wijzigen"
@onclick="() => TegelOpenenAsync(item)"
@onkeydown="e => ToetsAsync(e, () => TegelOpenenAsync(item))">
<div class="rij">
<div class="groei">
<span class="groot" title="@item.Title">@item.Title</span>
<div class="zacht">@Tonen.Categorie(item.Category)</div>
@* stopPropagation, want anders opent een druk op de prullenbak ook meteen
het scherm met de inhoud. *@
<div class="onderregel">
@* Een tekentje voor de regel, even hoog als de tekst ernaast. Het zegt
in een oogopslag wat er met dit item gebeurt; de tekst blijft staan,
want een takje raadt niemand. Bij "Alleen voor mijzelf" is dat jij,
dus staat daar je eigen profielfoto - en heb je die niet, dan je
initialen, net als overal waar mensen in een lijst staan. *@
<span class="zacht regelicoon">
@if (item.ReleasePolicy == 0)
{
<span class="penningetje"><Penning Naam="@Toestand.MijnNaam" Bron="@Toestand.MijnFoto" /></span>
}
else
{
<span class="teken" aria-hidden="true">@Tonen.Vrijgaveteken(item.ReleasePolicy)</span>
}
@Tonen.Vrijgave(item.ReleasePolicy)
</span>
@if (Weghalen != item.Id)
{
<span class="tegelknoppen">
<button class="knop rustig klein" disabled="@Bezig"
title="Verwijderen" aria-label="Verwijderen"
@onclick="() => Weghalen = item.Id"
@onclick:stopPropagation="true">&#128465;&#65039;</button>
</span>
}
</div>
</div>
</div>
@if (Weghalen == item.Id)
{
@* Alles wat hieronder uitklapt zit ín de tegel, en een tik op de tegel opent of sluit hem.
Zonder stopPropagation klapt hij dus weer dicht zodra je de video aanraakt, een
bijlage aantikt of op "nee" drukt. *@
<div class="melding let-op" style="margin:.8rem 0 0"
@onclick="() => { }" @onclick:stopPropagation="true">
<strong>@item.Title</strong> verwijderen? Wat erin staat is daarna weg.
<div class="rij" style="margin-top:.7rem">
<button class="knop gevaar" disabled="@Bezig" @onclick="() => VerwijderenAsync(item.Id)">
Ja, verwijderen
</button>
<button class="knop rustig" disabled="@Bezig" @onclick="() => Weghalen = null">Nee, bewaren</button>
</div>
</div>
}
</div>
}
</div>
@code {
private IReadOnlyList<KluisItem> Items = Array.Empty<KluisItem>();
private KluisInhoud? Bewerken;
/// <summary>Op welk soort de lijst gefilterd staat; leeg is alles. Zelfde opzet als op de website.</summary>
private string Filter = "";
private int Categorie = 3;
private IReadOnlyList<Beheerder> Beheerders = Array.Empty<Beheerder>();
private string BesprekenMetTekst = "";
private long? BesprekenMetPersonId => long.TryParse(BesprekenMetTekst, out var p) ? p : null;
private int Vrijgave = 1;
private DateTime? VrijgaveDatum = DateTime.Today.AddYears(1);
private string Titel = "";
/// <summary>0 als het om een nieuw item gaat.</summary>
private long ItemId;
/// <summary>Welk item er op verwijderen staat te wachten; null als er niets weg hoeft.</summary>
private long? Weghalen;
/// <summary>De bijlagen bij het item dat openstaat.</summary>
private IReadOnlyList<Bijlage> Bijlagen = Array.Empty<Bijlage>();
/// <summary>Tekst bij de voortgangsbalk tijdens het versturen of ophalen; null als er niets loopt.</summary>
private string? Overzetten;
private int Percentage;
private enum Soort { Document, Foto, FotoMaken, Video, VideoOpnemen }
/// <summary>Bijlagen die zijn gekozen voor een item dat nog niet bestaat.</summary>
private readonly Bijlagewachtrij Wachtrij = new();
/// <summary>Wat er opgeslagen staat plus wat er nog wacht; samen wat het scherm laat zien.</summary>
private IReadOnlyList<Bijlage> AlleBijlagen =>
Wachtrij.Leeg ? Bijlagen
: Bijlagen.Concat(Wachtrij.AlsBijlagen(DateTime.UtcNow)).ToList();
protected override async Task LaadAsync()
{
// Leeg is "alles". Een nummer dat niet te lezen valt telt ook als alles: liever de hele
// lijst dan een leeg scherm zonder uitleg.
int? categorie = int.TryParse(Filter, out var nummer) ? nummer : null;
var lijst = await Toestand.Client.KluisAsync(categorie);
if (lijst.Gelukt) Items = lijst.Waarde!;
else Fout = lijst.Melding;
var beheerders = await Toestand.Client.BeheerdersAsync();
if (beheerders.Gelukt) Beheerders = beheerders.Waarde!;
// De vraag "verwijderen?" hoorde bij een item dat er na het filteren misschien niet
// meer staat.
Weghalen = null;
}
/// <summary>Het wijzigscherm dichtdoen zonder op te slaan; wat wachtte gaat dan ook weg.</summary>
private void Afbreken()
{
Wachtrij.Leegmaken();
Bewerken = null;
}
private void Nieuw()
{
Wachtrij.Leegmaken(); // wat er van een vorige keer nog wachtte hoort hier niet bij
Bijlagen = Array.Empty<Bijlage>();
Bewerken = new KluisInhoud();
ItemId = 0;
Titel = "";
Categorie = 3;
Vrijgave = 1;
VrijgaveDatum = DateTime.Today.AddYears(1);
BesprekenMetTekst = "";
Weghalen = null;
Fout = null;
}
private async Task WijzigenAsync(KluisItem item)
{
Fout = null;
Weghalen = null;
Wachtrij.Leegmaken();
// De inhoud staat versleuteld op de server; die halen we op om het formulier te vullen.
var inhoud = await Toestand.Client.KluisOpenenAsync(item.Id);
if (!inhoud.Gelukt) { Fout = inhoud.Melding; return; }
Bewerken = inhoud.Waarde;
ItemId = item.Id;
Titel = item.Title;
Categorie = item.Category;
Vrijgave = item.ReleasePolicy;
VrijgaveDatum = item.ReleaseAt?.ToLocalTime() ?? DateTime.Today.AddYears(1);
BesprekenMetTekst = item.SharedManagerPersonId?.ToString() ?? "";
// De bijlagen horen bij het item, dus die halen we er meteen bij.
await BijlagenLadenAsync();
}
private async Task VerwijderenAsync(long itemId)
{
if (await ProbeerAsync(async () =>
{
var r = await Toestand.Client.KluisVerwijderenAsync(itemId);
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
}))
{
Weghalen = null;
Bijlagen = Array.Empty<Bijlage>();
await LaadAsync();
}
}
private async Task OpslaanAsync()
{
if (string.IsNullOrWhiteSpace(Titel)) { Fout = "Geef het item een naam, anders weet niemand later waar het over gaat."; return; }
var datum = Vrijgave == 2 ? VrijgaveDatum : null;
if (Vrijgave == 2 && datum is null) { Fout = "Kies een datum waarop dit vrijkomt."; return; }
long nieuwItemId = 0;
if (await ProbeerAsync(async () =>
{
var r = ItemId == 0
? await Toestand.Client.KluisOpslaanAsync(Categorie, Titel.Trim(), Bewerken!, Vrijgave, datum, BesprekenMetPersonId)
: await Toestand.Client.KluisWijzigenAsync(ItemId, Categorie, Titel.Trim(), Bewerken!, Vrijgave, datum, BesprekenMetPersonId);
if (!r.Gelukt) return Uitkomst.Fout(r.Melding!);
if (ItemId == 0 && r.Waarde.TryGetProperty("itemId", out var nummer))
nieuwItemId = nummer.GetInt64();
return Uitkomst.Ok();
}))
{
// Nu pas kunnen de bijlagen mee: het item bestaat en heeft een nummer.
var doel = ItemId == 0 ? nieuwItemId : ItemId;
if (doel != 0 && !Wachtrij.Leeg)
{
var misging = await Wachtrij.VerstuurAsync(Toestand.Client, kluisItemId: doel,
voortgang: (naam, procent) =>
{
Overzetten = $"{naam} wordt opgeslagen…";
Percentage = procent;
InvokeAsync(StateHasChanged);
});
Overzetten = null;
if (misging is not null) Fout = misging;
}
Bewerken = null;
await LaadAsync();
}
}
/// <summary>
/// De tegel is aangetikt: dat opent het scherm met de inhoud. Dat is hetzelfde scherm als
/// wijzigen - het toont alles wat er in het item staat en je kunt het meteen aanpassen.
/// Staat de vraag "verwijderen?" open, dan gebeurt er niets: dan hoort de aandacht bij die
/// vraag te blijven.
/// </summary>
private Task TegelOpenenAsync(KluisItem item) =>
Weghalen == item.Id ? Task.CompletedTask : WijzigenAsync(item);
private async Task BijlagenLadenAsync()
{
if (BijlageDoel is not { } id) { Bijlagen = Array.Empty<Bijlage>(); return; }
var lijst = await Toestand.Client.BijlagenAsync(id);
Bijlagen = lijst.Gelukt ? lijst.Waarde! : Array.Empty<Bijlage>();
if (!lijst.Gelukt) Fout = lijst.Melding;
}
/// <summary>
/// Bij welk item een bijlage hoort: het item dat in het scherm staat. Bij een nieuw item is
/// er nog geen nummer; dan wachten de bijlagen in de wachtrij tot het opslaan klaar is.
/// </summary>
private long? BijlageDoel => Bewerken is not null && ItemId != 0 ? ItemId : null;
private async Task ToevoegenAsync(Soort soort)
{
Fout = null;
var gekozen = soort switch
{
Soort.Document => await Bestandskiezer.KiesDocumentAsync(),
Soort.Foto => await Bestandskiezer.KiesFotoAsync(),
Soort.FotoMaken => await Bestandskiezer.MaakFotoAsync(),
Soort.Video => await Bestandskiezer.KiesVideoAsync(),
_ => await Bestandskiezer.NeemVideoOpAsync()
};
// Niets gekozen of afgebroken: gewoon niets doen, geen foutmelding.
if (gekozen is null) return;
await VerwerkGekozenAsync(gekozen);
}
/// <summary>
/// Voor Rijbewijs en Paspoort: meteen de camera openen voor één specifieke kant, met een
/// vaste, herkenbare naam - zo zie je in de bijlagenlijst meteen welke kant het is, zonder
/// dat daar een apart veld voor nodig is.
/// </summary>
private async Task MaakGerichteFotoAsync(string label)
{
Fout = null;
var gekozen = await Bestandskiezer.MaakFotoAsync();
if (gekozen is null) return;
var punt = gekozen.Naam.LastIndexOf('.');
var extensie = punt >= 0 ? gekozen.Naam[punt..] : ".jpg";
await VerwerkGekozenAsync(gekozen with { Naam = $"{label}{extensie}" });
}
private async Task VerwerkGekozenAsync(GekozenBestand gekozen)
{
// Nog geen nummer om het aan te hangen: dan wacht het tot na het opslaan.
if (BijlageDoel is not { } itemId)
{
Wachtrij.VoegToe(gekozen);
return;
}
Overzetten = $"{gekozen.Naam} wordt opgeslagen…";
Percentage = 0;
StateHasChanged();
var uitkomst = await Toestand.Client.BestandVersturenAsync(
gekozen.Naam, gekozen.Soort, gekozen.Inhoud, itemId,
(gedaan, totaal) =>
{
Percentage = totaal == 0 ? 100 : (int)(gedaan * 100 / totaal);
InvokeAsync(StateHasChanged);
});
Overzetten = null;
if (!uitkomst.Gelukt) Fout = uitkomst.Melding;
else await BijlagenLadenAsync();
}
private async Task BijlageWegAsync(Bijlage bijlage)
{
// Wat nog wacht staat nergens; dat halen we hier weg en niet bij de server.
if (Bijlagewachtrij.Wacht(bijlage)) { Wachtrij.Verwijder(bijlage); return; }
if (await ProbeerAsync(async () =>
{
var r = await Toestand.Client.BijlageVerwijderenAsync(bijlage.Id);
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
}))
{
await BijlagenLadenAsync();
}
}
/// <summary>
/// Muziek en Wensen mogen meerdere regels worden; dan is een vast aantal regels niet
/// genoeg en een schuifbalk niet fijn op een telefoon. Deze twee velden groeien daarom
/// mee met wat erin staat - alleen hier in de app, op de website hebben ze de ruimte
/// al staan.
/// </summary>
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (Bewerken is not null)
{
await JS.InvokeVoidAsync("mijnbesluit.laatMeegroeien", "muziek");
await JS.InvokeVoidAsync("mijnbesluit.laatMeegroeien", "wensen");
}
}
}
@@ -0,0 +1,854 @@
@page "/momenten"
@inherits SchermBasis
@inject IBestandskiezer Bestandskiezer
@* Berichten die je klaarzet voor later: op een datum, of pas na je overlijden.
Zolang een bericht niet bezorgd is, mag je het wijzigen of terugtrekken. *@
@* De kop staat in de balk erboven, dus hier niet nog een keer. *@
<button class="knop vol" style="margin-bottom:1rem" disabled="@Bezig" @onclick="Nieuw">
Bericht klaarzetten
</button>
@if (Fout is not null)
{
<div class="melding fout">@Fout</div>
}
@if (Gelukt is not null)
{
<div class="melding goed">@Gelukt</div>
}
@* Twee tegels die zeggen wat er is, en waarmee je kiest wat je ziet.
Waarom niet allebei de lijsten onder elkaar, zoals het was: "Voor mij" is bij bijna iedereen
leeg - berichten komen pas na een datum of na een overlijden - en dat betekende dat je elke
keer langs een lege doos moest scrollen om bij je eigen berichten te komen. Het getal zegt
nu wat die lijst bevat, zonder dat je ernaartoe hoeft.
Welke tegel openstaat bij binnenkomst wordt niet vastgezet maar afgeleid: staat er iets
nieuws voor je klaar, dan is dát wat je wilt zien. Zie Standaardkant. *@
<div class="rij keuzetegels" role="tablist" style="margin-bottom:1rem">
<button class="kaart compact keuzetegel @(Kant == "van" ? "aan" : "")"
role="tab" aria-selected="@(Kant == "van")" @onclick='() => Kant = "van"'>
<span class="groot">Van mij</span>
<span class="zacht">@Uitgaand.Count</span>
</button>
<button class="kaart compact keuzetegel @(Kant == "voor" ? "aan" : "") @(AantalNieuw > 0 ? "nieuw" : "")"
role="tab" aria-selected="@(Kant == "voor")" @onclick='() => Kant = "voor"'>
<span class="groot">Voor mij</span>
<span class="zacht">@Inkomend.Count@(AantalNieuw > 0 ? $" · {AantalNieuw} nieuw" : "")</span>
</button>
</div>
@if (Schrijven is not null)
{
<Popup Titel="@(Schrijven.BerichtId == 0 ? "Een bericht klaarzetten" : "Bericht wijzigen")"
Bevestigtekst="@(Schrijven.BerichtId == 0 ? "Klaarzetten" : "Opslaan")" Bezig="@Bezig"
OpBevestigen="OpslaanAsync" OpSluiten="Sluiten">
@if (Schrijven.BerichtId == 0)
{
<div class="veld">
<label for="aan">Voor wie is het?</label>
<select id="aan" @bind="Schrijven.AanPersoonId">
<option value="0">Kies iemand</option>
@foreach (var lid in Leden.Where(l => l.PersonId != Toestand.PersoonId))
{
<option value="@lid.PersonId">@lid.DisplayName</option>
}
</select>
</div>
}
else
{
<p class="zacht">Voor @Naam(Schrijven.AanPersoonId)</p>
}
<div class="veld"><label for="ond">Onderwerp</label><input id="ond" @bind="Schrijven.Onderwerp" /></div>
<div class="veld"><label for="tekst">Je bericht</label><textarea id="tekst" rows="7" @bind="Schrijven.Tekst"></textarea></div>
@* "Een opname erbij" is iets anders dan een bijlage: daarmee ís het bericht een video,
in plaats van een tekst met iets eronder. Die opname gaat als los bestand mee en wordt
bij het opslaan aan het bericht geknoopt; bijlagen staan daaronder. *@
@if (Schrijven.BerichtId == 0)
{
@if (Bestandskiezer.KanCamera)
{
<div class="veld">
<label>Een opname erbij</label>
@if (Schrijven.BestandId is not null)
{
<div class="rij">
<span class="groei">@Schrijven.Bestandsnaam</span>
<button class="knop rustig" disabled="@Bezig" @onclick="OpnameWeghalen">Weghalen</button>
</div>
}
else if (Overzetten is null)
{
<button class="knop rustig" disabled="@Bezig" @onclick="VideoOpnemenAsync">Video opnemen</button>
<div class="uitleg">
Een kort filmpje zegt meer dan een brief. Maximaal
@(MaxMegabyte) MB, dus houd het bij een minuut of wat.
</div>
}
</div>
}
@if (Overzetten is not null)
{
<div class="veld">
<div class="zacht">@Overzetten</div>
<div class="voortgang"><span style="width:@(Percentage)%"></span></div>
</div>
}
}
@* Ook bij een nieuw bericht. Hier was geen nummer om een bestand aan te hangen, dus
stond er een zinnetje "dat kan zodra het bericht klaarstaat" - een uitleg van onze
boekhouding waar iemand die een foto wil meesturen niets aan heeft. Nu wachten ze in
Wachtrij en gaan ze mee zodra het bericht is klaargezet. *@
<Bijlagenblok Bijlagen="@AlleBijlagen" Bezig="@Bezig"
KanVideo="true" KanCamera="@Bestandskiezer.KanCamera"
Overzetten="@Overzetten" Percentage="@Percentage"
Uitleg="@(Schrijven.BerichtId == 0
? "Een foto, een ingescande brief, een filmpje. Ze gaan mee zodra je het bericht klaarzet."
: "Een foto, een brief die je hebt ingescand, of een filmpje. Het gaat mee met het bericht.")"
OpFotoMaken="() => ToevoegenAsync(Soort.FotoMaken)"
OpFoto="() => ToevoegenAsync(Soort.Foto)"
OpDocument="() => ToevoegenAsync(Soort.Document)"
OpVideo="() => ToevoegenAsync(Soort.VideoOpnemen)"
OpVideoKiezen="() => ToevoegenAsync(Soort.Video)"
OpVerwijderen="BijlageWegAsync" />
<div class="veld">
<label for="wanneer">Wanneer mag het bezorgd worden?</label>
<select id="wanneer" @bind="Schrijven.Wanneer">
<option value="overlijden">Pas na mijn overlijden</option>
<option value="datum">Op een datum</option>
<option value="nu">Meteen</option>
</select>
</div>
@if (Schrijven.Wanneer == "datum")
{
<div class="veld">
<label>Bezorgen op</label>
<Datumkiezer @bind-Waarde="Schrijven.BezorgOp" />
</div>
}
</Popup>
}
@if (Kant == "voor")
{
@if (Inkomend.Count == 0)
{
<div class="leeg"><span class="teken">&#127916;</span> Er is nog niets voor je bezorgd.</div>
}
<div class="stapel">
@foreach (var bericht in Inkomend)
{
@* Drie regels naast de foto: onderwerp, van wie, wanneer. In die volgorde, want zo lees
je een lijst: eerst waar het over gaat, dan van wie. Wat niet past wordt afgekapt in
plaats van afgebroken - een tegel van vijf regels hoog zet de rest buiten beeld. *@
<div class="kaart compact tegel klikbaar @(bericht.IsNieuw ? "nieuw" : "")"
role="button" tabindex="0"
title="Lezen"
@onclick="() => LezenAsync(bericht.Id)"
@onkeydown="e => ToetsAsync(e, () => LezenAsync(bericht.Id))">
<div class="rij persoon">
<Penning Naam="@Naam(bericht.FromPersonId)" Bron="@Foto(bericht.FromPersonId)" />
<div class="groei">
<span class="groot" title="@bericht.Subject">@bericht.Subject</span>
<div class="zacht">@Naam(bericht.FromPersonId)</div>
<div class="onderregel">
<span class="zacht">
@* Bij een nieuw bericht valt "bezorgd" weg. Anders duwde die regel de
datum van het scherm af - en juist de datum wil je hier lezen. *@
@if (bericht.IsNieuw) { <text>Nieuw &middot; </text> } else { <text>Bezorgd </text> }
@Tonen.Kort(bericht.DeliveredAt)
@if (bericht.HeeftBestand) { <text> &middot; met opname</text> }
</span>
@if (Verwijderen != bericht.Id)
{
<span class="tegelknoppen">
<button class="knop rustig klein" disabled="@Bezig"
title="Verwijderen" aria-label="Verwijderen"
@onclick="() => Verwijderen = bericht.Id"
@onclick:stopPropagation="true">&#128465;&#65039;</button>
</span>
}
</div>
</div>
</div>
@if (Verwijderen == bericht.Id)
{
@* Zie de toelichting bij dezelfde constructie onder "Van mij": stopPropagation
overal hieronder, anders klapt dit weer dicht - of erger, opent het de
leespopup - zodra je "ja" of "nee" aantikt. *@
<div class="melding let-op" style="margin:.8rem 0 0"
@onclick="() => { }" @onclick:stopPropagation="true">
Dit bericht verwijderen? Het verdwijnt dan alleen bij jou; @Naam(bericht.FromPersonId)
houdt het eigen exemplaar gewoon.
<div class="rij" style="margin-top:.7rem">
<button class="knop gevaar" disabled="@Bezig"
@onclick="() => VerwijderenAsync(bericht.Id)"
@onclick:stopPropagation="true">
Ja, verwijderen
</button>
<button class="knop rustig" disabled="@Bezig"
@onclick="() => Verwijderen = null"
@onclick:stopPropagation="true">Nee, laat staan</button>
</div>
</div>
}
</div>
}
</div>
@* Het bericht dat openstaat, in een popup - net als overal elders in de app. De tegel zelf
klapt niet meer uit: dat gaf op een tegel met veel tekst een rommelig, wisselend lange
lijst, en overal elders in de app hoort lezen in een popup te zitten. *@
@if (Gelezen is { } gelezenId && Inkomend.FirstOrDefault(b => b.Id == gelezenId) is { } open)
{
<Popup Titel="@open.Subject" Bevestigtekst="Sluiten" AlleenSluiten="true"
OpBevestigen="Dichtdoen" OpSluiten="Dichtdoen">
<p class="zacht" style="margin-top:0">@Naam(open.FromPersonId) &middot; bezorgd @Tonen.Kort(open.DeliveredAt)</p>
<p style="margin:.8rem 0 0; white-space:pre-wrap">@Berichttekst</p>
@if (Opname is not null)
{
<video src="@Opname" controls style="max-width:100%;border-radius:10px;margin-top:.6rem"></video>
}
else if (open.HeeftBestand && Overzetten is not null)
{
<div style="margin-top:.6rem">
<div class="zacht">@Overzetten</div>
<div class="voortgang"><span style="width:@(Percentage)%"></span></div>
</div>
}
</Popup>
}
}
@if (Kant == "van")
{
@if (Uitgaand.Count == 0)
{
<div class="leeg">
<span class="teken">&#128172;</span>
Zet iets klaar voor iemand. Een brief voor later komt vaak harder aan dan je denkt.
</div>
}
<div class="stapel">
@foreach (var bericht in Uitgaand)
{
@* Aantikken is wijzigen; zolang het bericht nog niet bezorgd is tenminste. Daarna staat
het vast en doet de tegel niets meer. *@
<div class="kaart compact tegel klikbaar"
role="button" tabindex="0"
title="@(Bezorgd(bericht) ? "Bekijken" : "Bericht wijzigen")"
@onclick="() => TegelWijzigenAsync(bericht)"
@onkeydown="e => ToetsAsync(e, () => TegelWijzigenAsync(bericht))">
<div class="rij persoon">
<Penning Naam="@Naam(bericht.ToPersonId)" Bron="@Foto(bericht.ToPersonId)" />
<div class="groei">
<span class="groot" title="@bericht.Subject">@bericht.Subject</span>
<div class="zacht">@Naam(bericht.ToPersonId)</div>
@* Alleen terugtrekken staat er nog als knop; wijzigen doe je door de tegel
aan te tikken. stopPropagation, want anders opent een druk op de
prullenbak ook meteen het wijzigscherm. *@
<div class="onderregel">
@* Hetzelfde tekentje als in de kluis, even hoog als de tekst: het takje
voor na je overlijden, de kalender voor een datum. Het staat vóór de
tekst en dus links uitgelijnd met de naam erboven. De tekst blijft
staan; een takje raadt niemand. *@
<span class="zacht regelicoon">
<span class="teken" aria-hidden="true">@Bezorgteken(bericht)</span>
@Bezorging(bericht)
</span>
@if (!Bezorgd(bericht) && Weghalen != bericht.Id)
{
<span class="tegelknoppen">
<button class="knop rustig klein" disabled="@Bezig"
title="Terugtrekken" aria-label="Terugtrekken"
@onclick="() => Weghalen = bericht.Id"
@onclick:stopPropagation="true">&#128465;&#65039;</button>
</span>
}
else if (Bezorgd(bericht) && Verwijderen != bericht.Id)
{
<span class="tegelknoppen">
<button class="knop rustig klein" disabled="@Bezig"
title="Verwijderen" aria-label="Verwijderen"
@onclick="() => Verwijderen = bericht.Id"
@onclick:stopPropagation="true">&#128465;&#65039;</button>
</span>
}
</div>
</div>
</div>
@if (Weghalen == bericht.Id)
{
@* Alles wat hieronder uitklapt zit ín de tegel, en een tik op de tegel opent of sluit hem.
Zonder stopPropagation klapt hij dus weer dicht zodra je de video aanraakt, een
bijlage aantikt of op "nee" drukt. *@
<div class="melding let-op" style="margin:.8rem 0 0"
@onclick="() => { }" @onclick:stopPropagation="true">
Dit bericht terugtrekken? Het wordt dan nooit bezorgd.
<div class="rij" style="margin-top:.7rem">
<button class="knop gevaar" disabled="@Bezig" @onclick="() => IntrekkenAsync(bericht.Id)">
Ja, terugtrekken
</button>
<button class="knop rustig" disabled="@Bezig" @onclick="() => Weghalen = null">Nee, laat staan</button>
</div>
</div>
}
else if (Verwijderen == bericht.Id)
{
<div class="melding let-op" style="margin:.8rem 0 0"
@onclick="() => { }" @onclick:stopPropagation="true">
Dit bericht verwijderen? Het verdwijnt dan alleen bij jou; @Naam(bericht.ToPersonId)
houdt het eigen exemplaar gewoon.
<div class="rij" style="margin-top:.7rem">
<button class="knop gevaar" disabled="@Bezig"
@onclick="() => VerwijderenAsync(bericht.Id)">
Ja, verwijderen
</button>
<button class="knop rustig" disabled="@Bezig" @onclick="() => Verwijderen = null">Nee, laat staan</button>
</div>
</div>
}
</div>
}
</div>
@* Je eigen verzonden bericht ter inzage, in dezelfde opmaak als "Voor mij" hierboven. *@
@if (GelezenUit is { } gelezenUitId && Uitgaand.FirstOrDefault(b => b.Id == gelezenUitId) is { } openUit)
{
<Popup Titel="@openUit.Subject" Bevestigtekst="Sluiten" AlleenSluiten="true"
OpBevestigen="DichtdoenUit" OpSluiten="DichtdoenUit">
<p class="zacht" style="margin-top:0">Voor @Naam(openUit.ToPersonId) &middot; @Bezorging(openUit)</p>
@* Alleen zolang het bericht al bezorgd is: daarvoor staat er nog niets om te tonen. *@
@if (openUit.DeliveredAt is not null)
{
<p class="zacht" style="margin:.3rem 0 0">
Verzonden: @Tonen.Kort(openUit.DeliveredAt)<br />
Gelezen: @(openUit.ReadAt is { } gelezenOp ? Tonen.Kort(gelezenOp) : "nog niet")
</p>
}
<p style="margin:.8rem 0 0; white-space:pre-wrap">@BerichttekstUit</p>
@if (Opname is not null)
{
<video src="@Opname" controls style="max-width:100%;border-radius:10px;margin-top:.6rem"></video>
}
else if (openUit.HeeftBestand && Overzetten is not null)
{
<div style="margin-top:.6rem">
<div class="zacht">@Overzetten</div>
<div class="voortgang"><span style="width:@(Percentage)%"></span></div>
</div>
}
</Popup>
}
}
@code {
private IReadOnlyList<Bericht> Inkomend = Array.Empty<Bericht>();
private IReadOnlyList<Bericht> Uitgaand = Array.Empty<Bericht>();
private IReadOnlyList<Familielid> Leden = Array.Empty<Familielid>();
/// <summary>Welke tegel openstaat: "van" of "voor". Zie Standaardkant.</summary>
private string Kant = "van";
/// <summary>Of de kant al een keer door de gebruiker of door het laden is gekozen.</summary>
private bool KantGekozen;
/// <summary>Hoeveel binnengekomen berichten nog niet gelezen zijn.</summary>
private int AantalNieuw => Inkomend.Count(b => b.IsNieuw);
private Formulier? Schrijven;
private long? Gelezen;
private long? Weghalen;
/// <summary>Welke tegel om bevestiging vraagt om te verwijderen - bij "Voor mij" én bij
/// "Van mij" (ook als het bericht al bezorgd is, in tegenstelling tot Weghalen hierboven,
/// dat alleen voor terugtrekken vóór bezorging is).</summary>
private long? Verwijderen;
private string? Berichttekst;
/// <summary>Welk eigen verzonden bericht ter inzage openstaat; los van Gelezen hierboven,
/// dat is voor "Voor mij".</summary>
private long? GelezenUit;
private string? BerichttekstUit;
private string? Gelukt;
/// <summary>De opname bij het bericht dat openstaat, als data-URL.</summary>
private string? Opname;
/// <summary>Tekst bij de voortgangsbalk; null als er niets loopt.</summary>
private string? Overzetten;
private int Percentage;
/// <summary>De bijlagen bij het bericht dat in het wijzigscherm staat.</summary>
private IReadOnlyList<Bijlage> Bijlagen = Array.Empty<Bijlage>();
private enum Soort { Document, Foto, FotoMaken, Video, VideoOpnemen }
private static int MaxMegabyte => 50;
/// <summary>
/// Kiest één keer, bij het openen, welke tegel openstaat.
///
/// "Van mij" is de gewone stand: dat is waar je komt om iets klaar te zetten of na te kijken.
/// Maar staat er iets nieuws vóór jou, dan is dat het enige wat op dat moment telt - een
/// bericht van iemand die er niet meer is, laat je niet achter een tegel staan.
///
/// Eén keer, want daarna heeft de gebruiker zelf gekozen. Bij elk herladen opnieuw beslissen
/// zou betekenen dat het scherm onder je handen omspringt zodra je een bericht wegklikt.
/// </summary>
private void Standaardkant()
{
if (KantGekozen) return;
Kant = AantalNieuw > 0 ? "voor" : "van";
KantGekozen = true;
}
protected override async Task LaadAsync()
{
var inkomend = await Toestand.Client.InkomendAsync();
if (inkomend.Gelukt) Inkomend = inkomend.Waarde!; else Fout = inkomend.Melding;
var uitgaand = await Toestand.Client.UitgaandAsync();
if (uitgaand.Gelukt) Uitgaand = uitgaand.Waarde!; else Fout ??= uitgaand.Melding;
Standaardkant();
if (Toestand.Familie is not null)
{
var leden = await Toestand.Client.FamilieledenAsync(Toestand.Familie.Id);
if (leden.Gelukt) Leden = leden.Waarde!;
}
}
private void Nieuw()
{
Gelukt = null;
Fout = null;
Weghalen = null;
Bijlagen = Array.Empty<Bijlage>();
Schrijven = new Formulier { BezorgOp = DateTime.Today.AddYears(1) };
}
private async Task WijzigenAsync(Bericht bericht)
{
Gelukt = null;
Fout = null;
Weghalen = null;
// De tekst staat versleuteld op de server; die halen we op om het scherm te vullen.
var tekst = await Toestand.Client.BerichtConceptAsync(bericht.Id);
if (!tekst.Gelukt) { Fout = tekst.Melding; return; }
Schrijven = new Formulier
{
BerichtId = bericht.Id,
AanPersoonId = bericht.ToPersonId,
Onderwerp = bericht.Subject,
Tekst = tekst.Waarde!,
Wanneer = bericht.DeliverAfterDeath ? "overlijden" : bericht.DeliverAt is null ? "nu" : "datum",
BezorgOp = bericht.DeliverAt?.ToLocalTime() ?? DateTime.Today.AddYears(1)
};
await BijlagenLadenAsync();
}
// ------------------------------------------------------------------ foto's en documenten
private async Task BijlagenLadenAsync()
{
if (Schrijven is not { BerichtId: > 0 } formulier) { Bijlagen = Array.Empty<Bijlage>(); return; }
var lijst = await Toestand.Client.BerichtbijlagenAsync(formulier.BerichtId);
Bijlagen = lijst.Gelukt ? lijst.Waarde! : Array.Empty<Bijlage>();
if (!lijst.Gelukt) Fout = lijst.Melding;
}
private async Task ToevoegenAsync(Soort soort)
{
if (Schrijven is null) return;
Fout = null;
var gekozen = soort switch
{
Soort.Document => await Bestandskiezer.KiesDocumentAsync(),
Soort.Foto => await Bestandskiezer.KiesFotoAsync(),
Soort.FotoMaken => await Bestandskiezer.MaakFotoAsync(),
Soort.Video => await Bestandskiezer.KiesVideoAsync(),
_ => await Bestandskiezer.NeemVideoOpAsync()
};
if (gekozen is null) return;
// Nog geen nummer om het aan te hangen: dan wacht het tot het bericht is klaargezet.
if (Schrijven is not { BerichtId: > 0 } formulier)
{
Wachtrij.VoegToe(gekozen);
return;
}
Overzetten = $"{gekozen.Naam} wordt opgeslagen…";
Percentage = 0;
StateHasChanged();
var uitkomst = await Toestand.Client.BestandVersturenAsync(
gekozen.Naam, gekozen.Soort, gekozen.Inhoud, null,
(gedaan, totaal) =>
{
Percentage = totaal == 0 ? 100 : (int)(gedaan * 100 / totaal);
InvokeAsync(StateHasChanged);
},
berichtId: formulier.BerichtId);
Overzetten = null;
if (!uitkomst.Gelukt) Fout = uitkomst.Melding;
else await BijlagenLadenAsync();
}
private async Task BijlageWegAsync(Bijlage bijlage)
{
// Wat nog wacht staat nergens; dat halen we hier weg en niet bij de server.
if (Bijlagewachtrij.Wacht(bijlage)) { Wachtrij.Verwijder(bijlage); return; }
if (await ProbeerAsync(async () =>
{
var r = await Toestand.Client.BijlageVerwijderenAsync(bijlage.Id);
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
}))
{
await BijlagenLadenAsync();
}
}
private async Task VideoOpnemenAsync()
{
Fout = null;
var opname = await Bestandskiezer.NeemVideoOpAsync();
if (opname is null) return;
Overzetten = "De opname wordt opgeslagen…";
Percentage = 0;
StateHasChanged();
// De opname gaat als los bestand naar de kluis; het bericht verwijst er straks naar.
var uitkomst = await Toestand.Client.BestandVersturenAsync(
opname.Naam, opname.Soort, opname.Inhoud, null,
(gedaan, totaal) =>
{
Percentage = totaal == 0 ? 100 : (int)(gedaan * 100 / totaal);
InvokeAsync(StateHasChanged);
});
Overzetten = null;
if (!uitkomst.Gelukt) { Fout = uitkomst.Melding; return; }
Schrijven!.BestandId = uitkomst.Waarde;
Schrijven.Bestandsnaam = opname.Naam;
}
private void Sluiten()
{
Wachtrij.Leegmaken();
Schrijven = null;
Bijlagen = Array.Empty<Bijlage>();
Overzetten = null;
}
/// <summary>Bijlagen die zijn gekozen voor een bericht dat nog niet bestaat.</summary>
private readonly Bijlagewachtrij Wachtrij = new();
/// <summary>Wat er opgeslagen staat plus wat er nog wacht; samen wat het scherm laat zien.</summary>
private IReadOnlyList<Bijlage> AlleBijlagen =>
Wachtrij.Leeg ? Bijlagen
: Bijlagen.Concat(Wachtrij.AlsBijlagen(DateTime.UtcNow)).ToList();
private void OpnameWeghalen()
{
if (Schrijven is null) return;
Schrijven.BestandId = null;
Schrijven.Bestandsnaam = null;
}
private async Task OpslaanAsync()
{
var f = Schrijven!;
if (f.BerichtId == 0 && f.AanPersoonId == 0) { Fout = "Kies eerst voor wie het bericht is."; return; }
if (string.IsNullOrWhiteSpace(f.Onderwerp)) { Fout = "Geef het bericht een onderwerp."; return; }
if (string.IsNullOrWhiteSpace(f.Tekst) && f.BestandId is null)
{
Fout = "Er staat nog niets in je bericht. Schrijf iets, of neem een video op.";
return;
}
var naOverlijden = f.Wanneer == "overlijden";
var bezorgOp = f.Wanneer == "datum" ? f.BezorgOp : null;
if (f.Wanneer == "datum" && bezorgOp is null) { Fout = "Kies een datum."; return; }
long nieuwBerichtId = 0;
if (await ProbeerAsync(async () =>
{
var r = f.BerichtId == 0
? await Toestand.Client.BerichtKlaarzettenAsync(
f.AanPersoonId, f.Onderwerp.Trim(), f.Tekst, bezorgOp, naOverlijden,
soort: f.BestandId is null ? 0 : 1, bestandId: f.BestandId)
: await Toestand.Client.BerichtWijzigenAsync(
f.BerichtId, f.Onderwerp.Trim(), f.Tekst, bezorgOp, naOverlijden);
if (!r.Gelukt) return Uitkomst.Fout(r.Melding!);
if (f.BerichtId == 0 && r.Waarde.TryGetProperty("berichtId", out var nummer))
nieuwBerichtId = nummer.GetInt64();
return Uitkomst.Ok();
}))
{
// Nu pas kunnen de bijlagen mee: het bericht bestaat en heeft een nummer.
var doel = f.BerichtId == 0 ? nieuwBerichtId : f.BerichtId;
if (doel != 0 && !Wachtrij.Leeg)
{
var misging = await Wachtrij.VerstuurAsync(Toestand.Client, berichtId: doel,
voortgang: (naam, procent) =>
{
Overzetten = $"{naam} wordt opgeslagen…";
Percentage = procent;
InvokeAsync(StateHasChanged);
});
Overzetten = null;
if (misging is not null) Fout = misging;
}
Gelukt = f.BerichtId == 0 ? "Het bericht staat klaar." : "De wijziging is opgeslagen.";
Schrijven = null;
Bijlagen = Array.Empty<Bijlage>();
await LaadAsync();
}
}
private async Task IntrekkenAsync(long berichtId)
{
if (await ProbeerAsync(async () =>
{
var r = await Toestand.Client.BerichtIntrekkenAsync(berichtId);
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
}))
{
Gelukt = "Het bericht is teruggetrokken.";
Weghalen = null;
await LaadAsync();
}
}
/// <summary>
/// Een bericht uit je eigen lijst halen - bij "Voor mij" of bij "Van mij", bezorgd of niet.
/// Dit is iets anders dan IntrekkenAsync hierboven: dat trekt een bericht terug vóór het is
/// bezorgd (de ander krijgt het dan nooit), dit ruimt alleen je eigen exemplaar op nadat het
/// er al was - de ander houdt het zijne gewoon.
/// </summary>
private async Task VerwijderenAsync(long berichtId)
{
if (await ProbeerAsync(async () =>
{
var r = await Toestand.Client.BerichtVerwijderenAsync(berichtId);
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
}))
{
Gelukt = "Het bericht is verwijderd.";
Verwijderen = null;
await LaadAsync();
}
}
/// <summary>Doet de popup met het gelezen bericht weer dicht.</summary>
private void Dichtdoen()
{
Gelezen = null;
Berichttekst = null;
Opname = null;
}
/// <summary>Doet de popup met het eigen verzonden bericht weer dicht.</summary>
private void DichtdoenUit()
{
GelezenUit = null;
BerichttekstUit = null;
Opname = null;
}
/// <summary>
/// Je eigen verzonden bericht nog eens bekijken, ook als het al bezorgd is. Anders dan
/// LezenAsync hierboven (voor "Voor mij", dat meteen ook als gelezen markeert en de lijst
/// herlaadt): hier verandert er niets aan de status, dus is er ook niets om te herladen.
/// </summary>
private async Task BekijkenUitAsync(long id)
{
Opname = null;
var gelukt = await ProbeerAsync(async () =>
{
var r = await Toestand.Client.BerichtVerzondenLezenAsync(id);
if (!r.Gelukt) return Uitkomst.Fout(r.Melding!);
BerichttekstUit = r.Waarde;
GelezenUit = id;
return Uitkomst.Ok();
});
if (gelukt && Uitgaand.FirstOrDefault(b => b.Id == id) is { VaultFileId: { } bestandId })
{
Overzetten = "De opname wordt opgehaald…";
Percentage = 0;
StateHasChanged();
var bestand = await Toestand.Client.BestandOphalenAsync(bestandId,
(gedaan, totaal) =>
{
Percentage = totaal == 0 ? 100 : (int)(gedaan * 100 / totaal);
InvokeAsync(StateHasChanged);
});
Overzetten = null;
if (bestand.Gelukt)
{
Opname = $"data:{bestand.Waarde!.Soort};base64,{Convert.ToBase64String(bestand.Waarde.Inhoud)}";
}
else
{
Fout = bestand.Melding;
}
}
}
private async Task LezenAsync(long id)
{
Opname = null;
var gelukt = await ProbeerAsync(async () =>
{
var r = await Toestand.Client.BerichtLezenAsync(id);
if (!r.Gelukt) return Uitkomst.Fout(r.Melding!);
Berichttekst = r.Waarde;
Gelezen = id;
return Uitkomst.Ok();
});
if (gelukt && Inkomend.FirstOrDefault(b => b.Id == id) is { VaultFileId: { } bestandId })
{
Overzetten = "De opname wordt opgehaald…";
Percentage = 0;
StateHasChanged();
var bestand = await Toestand.Client.BestandOphalenAsync(bestandId,
(gedaan, totaal) =>
{
Percentage = totaal == 0 ? 100 : (int)(gedaan * 100 / totaal);
InvokeAsync(StateHasChanged);
});
Overzetten = null;
if (bestand.Gelukt)
{
Opname = $"data:{bestand.Waarde!.Soort};base64,{Convert.ToBase64String(bestand.Waarde.Inhoud)}";
}
else
{
Fout = bestand.Melding;
}
}
await LaadAsync();
}
/// <summary>
/// De tegel is aangetikt. Staat er al een bevestiging open (terugtrekken of verwijderen),
/// dan gebeurt er niets - de aandacht hoort daarbij. Is het bericht al bezorgd, dan opent
/// het ter inzage (BekijkenUitAsync); anders open je het om te wijzigen.
/// </summary>
private Task TegelWijzigenAsync(Bericht bericht) =>
Weghalen == bericht.Id || Verwijderen == bericht.Id ? Task.CompletedTask
: Bezorgd(bericht) ? BekijkenUitAsync(bericht.Id)
: WijzigenAsync(bericht);
/// <summary>2 = bezorgd, 3 = gelezen.</summary>
private static bool Bezorgd(Bericht b) => b.Status >= 2;
/// <summary>
/// Zolang het bericht nog klaarstaat, hoort hier te staan wannéér het weggaat. Is het
/// eenmaal onderweg, dan is dat niet meer interessant - dan wil je weten of het is
/// aangekomen, en of het al gelezen is. Zonder deze voorrang bleef hier voor altijd "zo
/// snel mogelijk" staan, ook nadat het allang bezorgd en gelezen was.
/// </summary>
private static string Bezorging(Bericht b) =>
b.Status == 3 ? "gelezen"
: b.Status == 2 ? "verzonden"
: b.DeliverAfterDeath ? "na mijn overlijden"
: b.DeliverAt is { } op ? Tonen.Kort(op)
: "zo snel mogelijk";
/// <summary>
/// Het tekentje bij die tekst. Staat hier vlak naast <see cref="Bezorging"/>, zodat de twee
/// niet uit elkaar kunnen lopen: elk geval daar hoort er hier ook een te hebben.
///
/// Takje en kalender zijn dezelfde als in de kluis. De envelop is voor een bericht dat nog
/// klaarstaat om meteen weg te gaan; het vinkje voor verzonden, het oog voor gelezen.
/// </summary>
private static string Bezorgteken(Bericht b) =>
b.Status == 3 ? "\U0001F441\uFE0F"
: b.Status == 2 ? "\u2705"
: b.DeliverAfterDeath ? "\U0001F33F"
: b.DeliverAt is not null ? "\U0001F4C5"
: "\u2709";
private string Naam(long persoonId) =>
Leden.FirstOrDefault(l => l.PersonId == persoonId)?.DisplayName ?? "iemand";
/// <summary>De profielfoto van deze persoon; null als hij er geen heeft of niet in je lijst staat.</summary>
private string? Foto(long persoonId) =>
Leden.FirstOrDefault(l => l.PersonId == persoonId)?.Profielfotobron;
private class Formulier
{
public long BerichtId { get; set; }
public long AanPersoonId { get; set; }
public string Onderwerp { get; set; } = "";
public string Tekst { get; set; } = "";
public string Wanneer { get; set; } = "overlijden";
public DateTime? BezorgOp { get; set; }
/// <summary>De opname die bij dit bericht hoort, als die er is.</summary>
public long? BestandId { get; set; }
public string? Bestandsnaam { get; set; }
}
}
@@ -0,0 +1,745 @@
@page "/nalatenschap"
@inherits SchermBasis
@* Wie mag er straks bij, hoe wordt vastgesteld dat iemand is overleden, en hoe geef je door
dat iemand anders is overleden. Dezelfde onderdelen als op de website; de kop staat in de
balk erboven, dus hier niet nog een keer. *@
@if (Fout is not null)
{
<div class="melding fout">@Fout</div>
}
@if (Gelukt is not null)
{
<div class="melding goed">@Gelukt</div>
}
@* ---------------------------------------------------------------- loopt er iets *@
@if (Stand is { LooptEenProcedure: true })
{
<div class="melding let-op">
@if (Stand.WachtOpBeheerder)
{
<text>
Er @(Stand.ActiveDeclarations == 1 ? "is één melding" : $"zijn {Stand.ActiveDeclarations} meldingen")
binnengekomen over jouw profiel — genoeg in aantal, maar er zit nog geen melding bij
van iemand die jij als beheerder hebt aangewezen. Zolang die er niet is gebeurt er niets.
</text>
}
else if (Stand.Missing == 0)
{
<strong>Er is gemeld dat je zou zijn overleden.</strong>
<text> Klopt het niet, laat het dan weten</text>
@if (Stand.ObjectionDeadline is { } tot) { <text> vóór @Tonen.Datum(tot)</text> }
<text>.</text>
}
else
{
<text>
Er @(Stand.ActiveDeclarations == 1 ? "is één melding" : $"zijn {Stand.ActiveDeclarations} meldingen")
binnengekomen over jouw profiel. Er
@(Stand.Missing == 1 ? "is er nog één" : $"zijn er nog {Stand.Missing}") nodig
voordat de procedure begint.
</text>
}
<div class="voortgang" style="margin-top:.7rem"><span style="width:@(Breedte)%"></span></div>
<button class="knop" style="margin-top:.8rem" disabled="@Bezig" @onclick="IkBenErNogAsync">
Ik ben er nog
</button>
</div>
}
else if (Stand is { IsBevestigd: true })
{
<div class="melding let-op">Het overlijden is bevestigd. De beheerder heeft toegang gekregen.</div>
}
@* ---------------------------------------------------------------- aan mij toevertrouwd *@
@if (Toevertrouwd.Count > 0)
{
<div class="kaart">
<h2 style="margin-top:0">Aan mij toevertrouwd</h2>
<p class="zacht" style="margin-top:0">
Deze mensen hebben jou aangewezen om hun nalatenschap te regelen. Zolang zij er zijn
valt er niets in te zien; dat verandert pas als de procedure is afgerond.
</p>
<div class="stapel">
@foreach (var nalatenschap in Toevertrouwd)
{
@* Dezelfde tegel als bij Momenten en Mijn mensen: foto voorop, de tekst over de
volle breedte, de knop als tekentje rechtsonder in de hoek van de rij. Naast
de naam bleef er op een telefoon zo weinig over dat "Overleden 3 Mrt. 2026"
over vier regels ging en de naam werd afgekapt. *@
<div class="kaart compact @(nalatenschap.Afgerond ? "tegel klikbaar" : "")"
role="@(nalatenschap.Afgerond ? "button" : null)"
tabindex="@(nalatenschap.Afgerond ? "0" : null)"
title="@(!nalatenschap.Afgerond ? null
: Bekeken == nalatenschap.PersonId ? "Sluiten" : "Bekijken")"
@onclick="() => TegelOpenenAsync(nalatenschap)"
@onkeydown="e => ToetsAsync(e, () => TegelOpenenAsync(nalatenschap))">
<div class="rij persoon">
<Penning Naam="@nalatenschap.DisplayName" Bron="@nalatenschap.Profielfotobron" />
@* Drie regels, net als bij Momenten. Twee regels kon ook, maar dan botst
het driehoekje rechtsonder tegen de naam aan zodra die lang is. *@
<div class="groei">
<span class="groot" title="@nalatenschap.DisplayName">@nalatenschap.DisplayName</span>
<div class="zacht">
@if (!nalatenschap.Afgerond)
{
<text>Je bent aangewezen</text>
}
else if (nalatenschap.DateOfDeath is { } overleden)
{
<text>Overleden @Tonen.Kort(overleden)</text>
}
</div>
@* Wat @nalatenschap.DisplayName zelf heeft ingesteld: zo weet je bij
leven al waar je op moet rekenen, en kun je het er nog met diegene
over hebben in plaats van er pas achter te komen zodra het zover
is. Alleen zolang de procedure nog niet is afgerond - daarna is
het geen vooruitzicht meer maar geschiedenis. *@
@if (!nalatenschap.Afgerond)
{
<div class="zacht" style="margin-top:.1rem">
@nalatenschap.RequiredDeclarations
meldingen nodig &middot; @nalatenschap.ObjectionDays dagen bezwaartermijn
</div>
}
@* De onderste regel draagt rechts het wijzertje. Geen knop: je klapt
de kluis open door de tegel aan te tikken. *@
<div class="onderregel">
<span class="zacht">
@if (!nalatenschap.Afgerond)
{
<text>Volgorde @nalatenschap.Volgorde</text>
}
else
{
<text>@nalatenschap.AantalItems item@(nalatenschap.AantalItems == 1 ? "" : "s")</text>
}
</span>
@if (nalatenschap.Afgerond)
{
<span class="tegelknoppen">
<span class="tegelteken" aria-hidden="true">@(Bekeken == nalatenschap.PersonId ? "▲" : "▼")</span>
</span>
}
</div>
</div>
</div>
@if (nalatenschap.Afgerond && Bekeken == nalatenschap.PersonId)
{
@* Alles hieronder zit ín de tegel, en een tik op de tegel klapt hem open
of dicht. Zonder stopPropagation klapt hij dus weer dicht zodra je een
item eronder aantikt. *@
<div @onclick="() => { }" @onclick:stopPropagation="true">
@if (Nagelaten.Count == 0)
{
<p class="zacht" style="margin:.8rem 0 0">
Er is niets vrijgegeven. @nalatenschap.DisplayName heeft alles op
"alleen voor mijzelf" laten staan; dat blijft ook nu dicht.
</p>
}
<div class="stapel" style="margin-top:.8rem">
@foreach (var item in Nagelaten)
{
<div class="rij">
<div class="groei">
<span>@item.Title</span>
<div class="zacht">@Tonen.Categorie(item.Category)</div>
</div>
<button class="knop rustig klein" disabled="@Bezig" @onclick:stopPropagation="true"
@onclick="() => ItemOpenenAsync(item)">Openen</button>
</div>
}
</div>
</div>
}
</div>
}
</div>
</div>
}
@* ---------------------------------------------------------------- met mij gedeeld *@
@if (GedeeldMetMij.Count > 0)
{
<div class="kaart">
<h2 style="margin-top:0">Met mij gedeeld</h2>
<p class="zacht" style="margin-top:0">
Dit is met jou gedeeld om alvast te bespreken - je hoeft niet te wachten op een
overlijden om het te lezen.
</p>
<div class="stapel">
@foreach (var item in GedeeldMetMij)
{
<div class="rij">
<div class="groei">
<span>@item.Title</span>
<div class="zacht">@Tonen.Categorie(item.Category)</div>
</div>
<button class="knop rustig klein" disabled="@Bezig" @onclick="() => ItemOpenenAsync(item)">Openen</button>
</div>
}
</div>
</div>
}
@* ---------------------------------------------------------------- item bekijken *@
@* Dezelfde opmaak als het invulformulier in de kluis (velden onder elkaar), maar zonder
invoervelden: dit is een ander item dan het jouwe, dus er valt hier niets te wijzigen -
alleen te lezen en, als er iets bij hangt, te bekijken. *@
@if (GeopendItem is not null && Inhoud is not null)
{
<Popup Titel="@GeopendTitel" Bevestigtekst="Sluiten" AlleenSluiten="true"
OpBevestigen="ItemDichtdoen" OpSluiten="ItemDichtdoen">
<div class="stapel">
@foreach (var regel in Regels(Inhoud))
{
<div class="veld">
<label>@regel.Naam</label>
<p style="margin:.2rem 0 0">@regel.Waarde</p>
</div>
}
</div>
@if (GeopendBijlagen.Count > 0)
{
<hr />
<h3>Bijlagen</h3>
<div class="stapel">
@foreach (var bijlage in GeopendBijlagen)
{
<div class="rij bijlage">
<button type="button" class="bijlage-openen groei" disabled="@Bezig"
title="Bekijken" @onclick="() => BijlageBekijkenAsync(bijlage)">
<span class="teken">@(bijlage.IsAfbeelding ? "\U0001F4F7" : bijlage.IsVideo ? "\U0001F3AC" : "\U0001F4C4")</span>
<span class="groei">
<span class="naam">@bijlage.FileName</span>
<span class="zacht">@bijlage.Grootte</span>
</span>
</button>
</div>
}
</div>
}
</Popup>
}
@if (BekekenBijlage is not null)
{
<Popup Titel="@BekekenBijlage.FileName" Bevestigtekst="Sluiten" AlleenSluiten="true"
OpBevestigen="BijlageDichtdoen" OpSluiten="BijlageDichtdoen">
@if (BijlageOphalen)
{
<p class="zacht">Bezig met ophalen&hellip;</p>
<div class="voortgang"><span style="width:@(BijlageVordering)%"></span></div>
}
else if (BijlageKijkfout is not null)
{
<div class="melding fout">@BijlageKijkfout</div>
}
else if (BijlageBron is not null && BekekenBijlage.IsAfbeelding)
{
<img src="@BijlageBron" alt="@BekekenBijlage.FileName" class="bijlage-groot" />
}
else if (BijlageBron is not null && BekekenBijlage.IsVideo)
{
<video src="@BijlageBron" controls class="bijlage-groot"></video>
}
else
{
<p class="zacht">
Dit soort bestand is hier niet te tonen. Op de website kun je het downloaden.
</p>
}
</Popup>
}
@* ---------------------------------------------------------------- mijn beheerders *@
<div class="kaart">
<h2>Mijn beheerders</h2>
@if (Beheerders.Count == 0)
{
<p class="zacht">Je hebt nog niemand aangewezen. Zonder beheerder blijft je kluis dicht.</p>
}
else
{
<div class="stapel">
@foreach (var beheerder in Beheerders)
{
<div class="rij persoon">
<Penning Naam="@beheerder.DisplayName" Bron="@beheerder.Profielfotobron" />
<div class="groei">
<span class="groot" title="@beheerder.DisplayName">@beheerder.DisplayName</span>
<div class="zacht">
Volgorde @beheerder.Priority
@if (beheerder.HeeftToegang) { <text> &middot; heeft toegang gekregen</text> }
</div>
</div>
@* Wie je aanwijst moet je ook weer kunnen weghalen - als hij zelf overlijdt,
of als je het anders wilt regelen. Behalve als hij je kluis al heeft
gekregen: dan valt er niets meer terug te nemen, en dat zegt de server
ook zo als je het toch probeert. *@
@if (!beheerder.HeeftToegang)
{
<span class="tegelknoppen">
<button class="knop rustig klein" disabled="@Bezig"
title="Niet meer laten beheren" aria-label="Niet meer laten beheren"
@onclick="() => Weghalen = beheerder">&#128465;&#65039;</button>
</span>
}
</div>
@if (Weghalen?.PersonId == beheerder.PersonId)
{
<div class="melding let-op">
<strong>@beheerder.DisplayName</strong> niet meer laten beheren? Je kluis
gaat dan niet meer naar hem of haar. Wijs wel iemand anders aan: zonder
beheerder blijft je kluis ook na je overlijden dicht.
<div class="rij" style="margin-top:.7rem">
<button class="knop gevaar" disabled="@Bezig"
@onclick="() => BeheerderWegAsync(beheerder)">
Ja, weghalen
</button>
<button class="knop rustig" disabled="@Bezig" @onclick="() => Weghalen = null">
Nee, laat staan
</button>
</div>
</div>
}
}
</div>
}
<button class="knop vol" style="margin-top:1rem" disabled="@Bezig" @onclick="() => Aanwijzen = true">
Iemand aanwijzen
</button>
</div>
@* ---------------------------------------------------------------- de procedure *@
<div class="kaart">
<h2>Hoeveel meldingen zijn er nodig?</h2>
<p class="zacht">
Er moeten meerdere verschillende mensen melden dat je bent overleden. Daarna krijg je
zelf nog een termijn om dat tegen te spreken. Pas als die verstrijkt, krijgt je beheerder
toegang.
</p>
<div class="veld">
<label for="aantal">Aantal meldingen</label>
<select id="aantal" @bind="AantalMeldingen">
@for (var n = 1; n <= 10; n++)
{
<option value="@n">@n</option>
}
</select>
</div>
<div class="veld">
<label for="dagen">Bezwaartermijn in dagen</label>
@* Een vrij getal, net als op de website. Hier stond een keuzelijstje met zes vaste
waarden; dat is een beperking die nergens op sloeg - de server heeft altijd alles
tussen 1 en 90 toegestaan. *@
<input id="dagen" type="number" inputmode="numeric" min="1" max="90" @bind="Bezwaardagen" />
<div class="uitleg">Tussen 1 en 90 dagen. Zoveel tijd krijg je om een melding tegen te spreken.</div>
</div>
@* Zonder beheerder komt de procedure niet op gang, hoeveel mensen er ook melden. Dat hoort
hier te staan en niet pas als het zover is. *@
@if (Beheerders.Count == 0)
{
<div class="melding let-op" style="margin-top:.8rem">
Je hebt nog niemand aangewezen. Melden mag iedereen uit Mijn mensen, maar de procedure
begint pas te lopen als er een melding van een van jouw beheerders bij zit. Zonder
beheerder gebeurt er dus niets.
</div>
}
<button class="knop rustig" disabled="@Bezig" @onclick="InstellingenOpslaanAsync">Opslaan</button>
</div>
@* ---------------------------------------------------------------- iemand melden *@
@* Melden mag over iedereen uit je eigen mensen. Staat daar niemand in, dan staat dit blok er
niet: een knop met een lege lijst is geen knop. *@
@if (Meldbaar.Count > 0)
{
<div class="kaart">
<h2>Iemand als overleden melden</h2>
<p class="zacht">
Is iemand uit Mijn mensen overleden? Geef het hier door. Er zijn meerdere
onafhankelijke meldingen nodig, waaronder één van iemand die hij zelf als beheerder
heeft aangewezen. Daarna krijgt de betrokkene eerst bericht.
</p>
<button class="knop gevaar" disabled="@Bezig" @onclick="() => Melden = true">Melding doen</button>
</div>
}
@* ---------------------------------------------------------------- popups *@
@if (Aanwijzen)
{
<Popup Titel="Iemand aanwijzen" Bevestigtekst="Aanwijzen" Bezig="@Bezig"
OpBevestigen="AanwijzenAsync" OpSluiten="() => Aanwijzen = false">
<div class="veld">
<label for="beheerder">Wie wijs je aan?</label>
<select id="beheerder" @bind="BeheerderId">
<option value="0">Kies iemand</option>
@foreach (var lid in Kandidaten)
{
<option value="@lid.PersonId">@lid.DisplayName</option>
}
</select>
</div>
<div class="veld">
<label for="volgorde">Volgorde</label>
<select id="volgorde" @bind="Volgorde">
@for (var n = 1; n <= 5; n++)
{
<option value="@n">@n</option>
}
</select>
<div class="uitleg">1 is de eerste keus. Wijs je er meer aan, dan is dit de volgorde.</div>
</div>
</Popup>
}
@if (Melden)
{
<Popup Titel="Iemand als overleden melden" Bevestigtekst="Melding doen" Gevaarlijk="true"
Bezig="@Bezig" OpBevestigen="MeldenAsync" OpSluiten="() => Melden = false">
<div class="veld">
<label for="overledene">Wie?</label>
<select id="overledene" @bind="OverledeneId">
<option value="0">Kies iemand</option>
@foreach (var wie in Meldbaar)
{
<option value="@wie.PersonId">@wie.DisplayName</option>
}
</select>
</div>
<div class="veld">
<label>Datum van overlijden</label>
<Datumkiezer Waarde="Overlijdensdatum" WaardeChanged="d => Overlijdensdatum = d"
Optioneel="true" Aflopend="true"
VanJaar="@(DateTime.Today.Year - 5)" TotJaar="@DateTime.Today.Year" />
<div class="uitleg">Niet verplicht als je het niet precies weet.</div>
</div>
<p class="zacht">
Deze melding is niet zomaar iets: de betrokkene krijgt er bericht van. Doe hem alleen
als je het zeker weet.
</p>
</Popup>
}
@code {
private IReadOnlyList<Familielid> Leden = Array.Empty<Familielid>();
private IReadOnlyList<Beheerder> Beheerders = Array.Empty<Beheerder>();
private NalatenschapStand? Stand;
private string? Gelukt;
private bool Aanwijzen, Melden;
private long BeheerderId, OverledeneId;
private int Volgorde = 1;
private DateTime? Overlijdensdatum;
/// <summary>
/// Over wie jij een overlijdensmelding mag doen: iedereen uit je eigen mensen die nog niet
/// als overleden staat. Dezelfde kring die de server aanhoudt, zodat het scherm en de regel
/// het over dezelfde mensen hebben.
/// </summary>
private IReadOnlyList<Familielid> Meldbaar =>
Leden.Where(l => l.PersonId != Toestand.PersoonId && l.DateOfDeath is null).ToList();
private int AantalMeldingen = 3;
private int Bezwaardagen = 14;
/// <summary>De nalatenschappen die aan mij zijn toevertrouwd en waarvan de procedure rond is.</summary>
private IReadOnlyList<Toevertrouwd> Toevertrouwd = Array.Empty<Toevertrouwd>();
/// <summary>Kluisitems die iemand rechtstreeks met mij heeft gedeeld (nog geen overlijden nodig).</summary>
private IReadOnlyList<KluisItem> GedeeldMetMij = Array.Empty<KluisItem>();
/// <summary>Welke nalatenschap openstaat, en welk item daarbinnen.</summary>
private long? Bekeken, GeopendItem;
private IReadOnlyList<KluisItem> Nagelaten = Array.Empty<KluisItem>();
private KluisInhoud? Inhoud;
private string? GeopendTitel;
private IReadOnlyList<Bijlage> GeopendBijlagen = Array.Empty<Bijlage>();
/// <summary>De bijlage die openstaat om te bekijken; null als er niets openstaat.</summary>
private Bijlage? BekekenBijlage;
private string? BijlageBron;
private bool BijlageOphalen;
private int BijlageVordering;
private string? BijlageKijkfout;
/// <summary>Wie er nog aangewezen kan worden: iedereen behalve jezelf en wie er al staat.</summary>
private IEnumerable<Familielid> Kandidaten =>
Leden.Where(l => l.PersonId != Toestand.PersoonId
&& Beheerders.All(b => b.PersonId != l.PersonId));
private int Breedte => Stand is null || Stand.RequiredDeclarations == 0
? 0
: Math.Min(100, Stand.ActiveDeclarations * 100 / Stand.RequiredDeclarations);
protected override async Task LaadAsync()
{
if (Toestand.Familie is null) await Toestand.VerversAsync();
if (Toestand.Familie is not null)
{
var leden = await Toestand.Client.FamilieledenAsync(Toestand.Familie.Id);
if (leden.Gelukt) Leden = leden.Waarde!;
}
var beheerders = await Toestand.Client.BeheerdersAsync();
if (beheerders.Gelukt) Beheerders = beheerders.Waarde!;
var toevertrouwd = await Toestand.Client.ToevertrouwdAsync();
if (toevertrouwd.Gelukt) Toevertrouwd = toevertrouwd.Waarde!;
var gedeeld = await Toestand.Client.GedeeldMetMijAsync();
if (gedeeld.Gelukt) GedeeldMetMij = gedeeld.Waarde!;
var stand = await Toestand.Client.NalatenschapStandAsync();
if (stand.Gelukt)
{
Stand = stand.Waarde;
// Allebei de velden terugzetten op wat er is opgeslagen. Het aantal dagen ontbrak
// hier: dat liet zich wijzigen en opslaan, maar sprong bij het herladen terug naar
// veertien - waardoor het leek alsof het niet werd bewaard.
AantalMeldingen = Stand!.RequiredDeclarations;
Bezwaardagen = Stand.ObjectionDays;
}
else
{
Fout = stand.Melding;
}
}
// ------------------------------------------------------------------ aan mij toevertrouwd
/// <summary>De beheerder waarvan de vraag "weghalen?" openstaat.</summary>
private Beheerder? Weghalen;
private async Task BeheerderWegAsync(Beheerder beheerder)
{
if (await ProbeerAsync(async () =>
{
var r = await Toestand.Client.VerwijderBeheerderAsync(beheerder.PersonId);
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
}))
{
Gelukt = $"{beheerder.DisplayName} beheert je nalatenschap niet meer.";
Weghalen = null;
await LaadAsync();
}
}
/// <summary>De tegel is aangetikt. Zolang de procedure loopt valt er niets in te zien.</summary>
private Task TegelOpenenAsync(Toevertrouwd nalatenschap) =>
nalatenschap.Afgerond ? OpenenAsync(nalatenschap.PersonId) : Task.CompletedTask;
private async Task OpenenAsync(long persoonId)
{
if (Bekeken == persoonId) { Dichtdoen(); return; }
if (await ProbeerAsync(async () =>
{
var r = await Toestand.Client.NalatenschapItemsAsync(persoonId);
if (!r.Gelukt) return Uitkomst.Fout(r.Melding!);
Bekeken = persoonId;
Nagelaten = r.Waarde!;
ItemDichtdoen();
return Uitkomst.Ok();
}))
{
Gelukt = null;
}
}
private void Dichtdoen()
{
Bekeken = null;
Nagelaten = Array.Empty<KluisItem>();
ItemDichtdoen();
}
/// <summary>
/// Opent één nagelaten of gedeeld item. De sleutel daarvoor is bij het bevestigen van het
/// overlijden, of bij het delen zelf, voor jou klaargezet; de server gebruikt die vanzelf
/// als het item niet van jou is.
/// </summary>
private async Task ItemOpenenAsync(KluisItem item)
{
if (GeopendItem == item.Id) { ItemDichtdoen(); return; }
await ProbeerAsync(async () =>
{
var r = await Toestand.Client.KluisOpenenAsync(item.Id);
if (!r.Gelukt) return Uitkomst.Fout(r.Melding!);
GeopendItem = item.Id;
Inhoud = r.Waarde;
GeopendTitel = item.Title;
// Bijlagen zijn hier optioneel: als ophalen mislukt tonen we het item gewoon
// zonder bijlagenlijst, in plaats van de hele popup te blokkeren.
var bijlagen = await Toestand.Client.BijlagenAsync(item.Id);
GeopendBijlagen = bijlagen.Gelukt ? bijlagen.Waarde! : Array.Empty<Bijlage>();
return Uitkomst.Ok();
});
}
private void ItemDichtdoen()
{
GeopendItem = null;
Inhoud = null;
GeopendTitel = null;
GeopendBijlagen = Array.Empty<Bijlage>();
BijlageDichtdoen();
}
private async Task BijlageBekijkenAsync(Bijlage bijlage)
{
BekekenBijlage = bijlage;
BijlageBron = null;
BijlageKijkfout = null;
// Een document halen we niet op: we kunnen het toch niet tonen, en het kan tientallen
// megabytes zijn. De popup zegt dan meteen waar je het wél kunt openen.
if (!bijlage.IsAfbeelding && !bijlage.IsVideo) return;
BijlageOphalen = true;
BijlageVordering = 0;
StateHasChanged();
var uitkomst = await Toestand.Client.BestandOphalenAsync(bijlage.Id,
(gedaan, totaal) =>
{
BijlageVordering = totaal == 0 ? 100 : (int)(gedaan * 100 / totaal);
InvokeAsync(StateHasChanged);
});
BijlageOphalen = false;
if (!uitkomst.Gelukt) { BijlageKijkfout = uitkomst.Melding; return; }
// De webview haalt zijn plaatjes niet van een server, dus geven we de inhoud mee in de
// verwijzing zelf. Dat kan omdat er een grens op de bestandsgrootte staat.
BijlageBron = $"data:{uitkomst.Waarde!.Soort};base64,{Convert.ToBase64String(uitkomst.Waarde.Inhoud)}";
}
private void BijlageDichtdoen()
{
BekekenBijlage = null;
BijlageBron = null;
BijlageKijkfout = null;
BijlageOphalen = false;
}
/// <summary>Dezelfde velden als in Mijn kluis; wat de eigenaar zag, ziet de beheerder ook.</summary>
private static IEnumerable<(string Naam, string Waarde)> Regels(KluisInhoud i)
{
(string, string?)[] alles =
{
("Website", i.Website), ("Gebruikersnaam", i.Username), ("Wachtwoord", i.Secret),
("Apparaat", i.DeviceName), ("Pincode", i.Pincode), ("Herstelcodes", i.RecoveryCodes),
("Medium", i.Platform), ("Welk medium", i.PlatformAnders),
("Verzekeraar", i.Insurer), ("Polisnummer", i.PolicyNumber), ("Telefoon", i.ContactPhone),
("Begraven of cremeren", i.BurialOrCremation), ("Plek", i.Location), ("Muziek", i.Music),
("Sprekers", i.Speakers), ("Wensen", i.Wishes), ("Toelichting", i.Notes)
};
return alles.Where(r => !string.IsNullOrWhiteSpace(r.Item2)).Select(r => (r.Item1, r.Item2!));
}
private async Task AanwijzenAsync()
{
Gelukt = null;
if (BeheerderId == 0) { Fout = "Kies eerst iemand."; return; }
if (await ProbeerAsync(async () =>
{
var r = await Toestand.Client.WijsBeheerderAanAsync(BeheerderId, Volgorde);
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
}))
{
Gelukt = "De beheerder is vastgelegd.";
Aanwijzen = false;
BeheerderId = 0;
Volgorde = 1;
await LaadAsync();
}
}
private async Task InstellingenOpslaanAsync()
{
Gelukt = null;
if (await ProbeerAsync(async () =>
{
var r = await Toestand.Client.NalatenschapInstellingenAsync(AantalMeldingen, Bezwaardagen);
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
}))
{
Gelukt = "Je instellingen zijn opgeslagen.";
await LaadAsync();
}
}
private async Task MeldenAsync()
{
Gelukt = null;
if (OverledeneId == 0) { Fout = "Kies eerst iemand."; return; }
var datum = Overlijdensdatum is { } d ? DateOnly.FromDateTime(d) : (DateOnly?)null;
if (await ProbeerAsync(async () =>
{
var r = await Toestand.Client.MeldOverlijdenAsync(OverledeneId, datum);
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
}))
{
Gelukt = "Je melding is genoteerd.";
Melden = false;
OverledeneId = 0;
Overlijdensdatum = null;
await LaadAsync();
}
}
private async Task IkBenErNogAsync()
{
Gelukt = null;
if (await ProbeerAsync(async () =>
{
var r = await Toestand.Client.IkBenErNogAsync();
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
}))
{
Gelukt = "We hebben doorgegeven dat je er nog bent.";
await LaadAsync();
}
}
}
@@ -0,0 +1,472 @@
@page "/profiel"
@inherits SchermBasis
@inject IBestandskiezer Bestandskiezer
@* Je eigen gegevens. Dit is het enige profiel dat niet bij Mijn mensen staat - daar staat
iedereen behalve jij - dus zonder dit scherm kon je je eigen naam of adres nergens kwijt.
Het e-mailadres staat bewust apart van de rest. Dat is je inlog: wie dat zomaar kan
verzetten, kan zichzelf buitensluiten of een ander account overnemen. Daarom gaat er eerst
een code naar het nieuwe adres. De kop staat in de balk erboven, dus hier niet nog een keer. *@
@* Net als overal elders: aantikken van je eigen kaart bij Mijn mensen opent dit als popup,
niet als eigen pagina met een knop onderaan. Die knop stond hier ooit vast onderin gezet
(.knop-vast-onder), maar "vast onderin" is precies wat een Popup toch al gratis geeft - zie
.popup-knoppen. De route /profiel blijft bestaan (de kop, Instellingen en het menu linken er
nog naar), maar toont nu meteen deze popup in plaats van een gewone pagina. *@
@if (Mijn is null)
{
@if (Fout is not null)
{
<div class="melding fout">@Fout</div>
}
<p class="zacht">Bezig met laden&hellip;</p>
}
else
{
<Popup Titel="Mijn profiel" Bevestigtekst="Opslaan" Bezig="@Bezig"
OpBevestigen="OpslaanAsync" OpSluiten="Sluiten">
@if (Fout is not null)
{
<div class="melding fout">@Fout</div>
}
@if (Gelukt is not null)
{
<div class="melding goed">@Gelukt</div>
}
<div class="raster">
<div class="veld"><label for="vn">Voornaam</label><input id="vn" @bind="Voornaam" /></div>
<div class="veld"><label for="an">Achternaam</label><input id="an" @bind="Achternaam" /></div>
</div>
<div class="veld">
<label>Geboortedatum</label>
<Datumkiezer Waarde="Geboortedatum" WaardeChanged="d => Geboortedatum = d"
Optioneel="true" Aflopend="true"
VanJaar="1900" TotJaar="@DateTime.Today.Year" />
<div class="uitleg">Niet verplicht.</div>
</div>
<p class="zacht" style="margin:.8rem 0 0">
Je bent hier sinds @Tonen.Datum(Mijn.LidSinds).
</p>
@* ---------------------------------------------------------------- meldingen *@
<div class="kaart" style="margin-top:1rem">
<h2 style="margin-top:0">Meldingen</h2>
<p class="zacht" style="margin-top:0">
Als er een bericht voor je klaarstaat, laat je telefoon dat weten. Wat erin staat
komt er nooit bij te staan &mdash; alleen dát er iets is, en van wie.
</p>
@if (Proef is not null)
{
<div class="melding @(Proef.Gelukt ? "goed" : "let-op")">@Proef.Uitleg</div>
}
<button class="knop rustig" disabled="@Bezig" @onclick="ProefmeldingAsync">
&#128276; Stuur me een proefmelding
</button>
<div class="uitleg">
Komt er niets, dan zegt deze knop waar het op vastloopt.
</div>
</div>
<div class="kaart" style="margin-top:1rem">
<h2 style="margin-top:0">E-mailadres</h2>
<p class="zacht" style="margin-top:0">
Hiermee log je in. Je huidige adres is <strong>@Mijn.Email</strong>.
</p>
<button class="knop rustig" disabled="@Bezig" @onclick="AdresWijzigen">
&#9993; Ander e-mailadres
</button>
</div>
<div class="kaart" style="margin-top:1rem">
<Bijlagenblok Titel="Mijn foto's en documenten" Bijlagen="Bijlagen" Bezig="@Bezig"
KanVideo="true" KanCamera="@Bestandskiezer.KanCamera" KanProfielfoto="true"
Overzetten="@Overzetten" Percentage="@Percentage"
Uitleg="Een foto van jezelf, je paspoort, je donorcodicil. Met het sterretje maak je er je profielfoto van."
OpFotoMaken="() => ToevoegenAsync(Soort.FotoMaken)"
OpFoto="() => ToevoegenAsync(Soort.Foto)"
OpDocument="() => ToevoegenAsync(Soort.Document)"
OpVideo="() => ToevoegenAsync(Soort.VideoOpnemen)"
OpVideoKiezen="() => ToevoegenAsync(Soort.Video)"
OpVerwijderen="BijlageWegAsync"
OpProfielfoto="ProfielfotoAsync" />
</div>
@* Onderaan, en met een rustige knop: dit is niets om per ongeluk aan te tikken. Dat het er
moet zijn is een eis van Google Play, maar het is ook gewoon eerlijk - wie een kluis
sluit, hoort erop te kunnen rekenen dat hij leeg is. *@
<div class="kaart" style="margin-top:1rem">
<h2 style="margin-top:0">Je MijnBesluit opheffen</h2>
<p class="zacht" style="margin-top:0">
Je kluis, je bestanden en de berichten die nog klaarstaan worden verwijderd. Dat is
niet terug te draaien.
</p>
<button class="knop rustig" disabled="@Bezig" @onclick="OpheffenBeginnenAsync">
&#128465;&#65039; Mijn account opheffen
</button>
</div>
</Popup>
}
@* ---------------------------------------------------------------- account opheffen *@
@if (Opheffen is not null)
{
<Popup Titel="Je MijnBesluit opheffen"
Bevestigtekst="@(Opheffen.CodeGevraagd ? "Definitief opheffen" : "Stuur me een code")"
Bezig="@Bezig" OpBevestigen="OpheffenStapAsync" OpSluiten="() => Opheffen = null">
@if (!Opheffen.CodeGevraagd)
{
<p>Dit verdwijnt:</p>
<ul>
<li>@Opheffen.Telling.Kluisitems in je kluis</li>
<li>@Opheffen.Telling.Bestanden foto&#39;s en documenten</li>
<li>@Opheffen.Telling.BerichtenKlaar berichten die nog klaarstonden</li>
<li>je lijst met @Opheffen.Telling.Mensen mensen</li>
</ul>
<p>Dit blijft:</p>
<ul>
<li>
@Opheffen.Telling.BerichtenBezorgd berichten die al bezorgd zijn - die zijn
van degene die ze heeft gekregen
</li>
<li>je naam in de lijst van mensen die jou hebben toegevoegd</li>
</ul>
@if (Opheffen.Telling.Nalatenschappen > 0)
{
<div class="melding let-op">
Je bent aangewezen als nalatenschapsbeheerder bij
@Opheffen.Telling.Nalatenschappen @(Opheffen.Telling.Nalatenschappen == 1 ? "persoon" : "mensen").
Die aanwijzing vervalt. Zeg het ze even, anders rekenen ze op iemand die er niet meer is.
</div>
}
<p class="zacht">
We sturen eerst een code naar je e-mailadres. Zo kan niemand die je telefoon in
handen krijgt je kluis leeggooien.
</p>
}
else
{
<p>Er is een code gestuurd naar <strong>@Mijn?.Email</strong>.</p>
<div class="veld">
<label for="opheffencode">De code uit die e-mail</label>
<input id="opheffencode" inputmode="numeric" maxlength="6" @bind="Opheffen.Code" />
</div>
<div class="melding let-op">
Hierna is het weg. Er is geen prullenbak en wij kunnen het niet terughalen.
</div>
}
</Popup>
}
@* ---------------------------------------------------------------- e-mailadres wijzigen *@
@if (Adres is not null)
{
<Popup Titel="Ander e-mailadres"
Bevestigtekst="@(Adres.CodeGevraagd ? "Bevestigen" : "Stuur me een code")"
Bezig="@Bezig" OpBevestigen="AdresStapAsync" OpSluiten="() => Adres = null">
@if (!Adres.CodeGevraagd)
{
<div class="veld">
<label for="nieuw">Je nieuwe e-mailadres</label>
<input id="nieuw" type="email" inputmode="email" @bind="Adres.Nieuw" />
<div class="uitleg">
We sturen er een code naartoe. Pas als je die invult verandert je adres,
dus een tikfout kan je hier niet buitensluiten.
</div>
</div>
}
else
{
<p>Er is een code gestuurd naar <strong>@Adres.Nieuw</strong>.</p>
<div class="veld">
<label for="code">De code uit die e-mail</label>
<input id="code" inputmode="numeric" maxlength="6" @bind="Adres.Code" />
</div>
<p class="zacht">
Zodra dit klaar is word je uitgelogd. Log daarna weer in met je nieuwe adres.
</p>
}
</Popup>
}
@code {
private EigenProfiel? Mijn;
private string Voornaam = "";
private string Achternaam = "";
private DateTime? Geboortedatum;
private string? Gelukt;
/// <summary>Het adreswijzigingsformulier; null als het niet openstaat.</summary>
private Adreswissel? Adres;
/// <summary>Het opheffen van het account; null als het niet openstaat.</summary>
private Opheffing? Opheffen;
private IReadOnlyList<Bijlage> Bijlagen = Array.Empty<Bijlage>();
/// <summary>Tekst bij de voortgangsbalk tijdens een overdracht; null als er niets loopt.</summary>
private string? Overzetten;
private int Percentage;
private enum Soort { Document, Foto, FotoMaken, Video, VideoOpnemen }
/// <summary>De uitkomst van de laatste proefmelding; null als er nog niet is geprobeerd.</summary>
private Proefmelding? Proef;
/// <summary>
/// Vraagt de server een proefmelding naar dit toestel te sturen.
///
/// Dit is de knop waarmee je staand naast je telefoon kunt zien of de hele keten werkt: de
/// app heeft een kenmerk, de server kent het, en Google levert af. Blijft er een melding uit,
/// dan zegt het antwoord welke van die drie het is.
/// </summary>
private async Task ProefmeldingAsync()
{
Gelukt = null;
Proef = null;
await ProbeerAsync(async () =>
{
var r = await Toestand.Client.ProefmeldingAsync();
if (!r.Gelukt) return Uitkomst.Fout(r.Melding!);
Proef = r.Waarde;
return Uitkomst.Ok();
});
}
protected override async Task LaadAsync()
{
var profiel = await Toestand.Client.EigenProfielAsync();
if (!profiel.Gelukt) { Fout = profiel.Melding; return; }
Mijn = profiel.Waarde;
Voornaam = Mijn!.Voornaam;
Achternaam = Mijn.Achternaam;
Geboortedatum = Mijn.Geboortedatum?.ToDateTime(TimeOnly.MinValue);
await BijlagenLadenAsync();
}
private async Task OpslaanAsync()
{
Gelukt = null;
if (await ProbeerAsync(async () =>
{
var geboren = Geboortedatum is { } g ? DateOnly.FromDateTime(g) : (DateOnly?)null;
var r = await Toestand.Client.EigenProfielOpslaanAsync(
Voornaam.Trim(), Achternaam.Trim(), geboren);
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
}))
{
// Dit scherm is bereikt via de eigen tegel bovenaan Mijn mensen; wie op Opslaan
// drukt verwacht daar weer terug te komen, niet op ditzelfde formulier te blijven
// staan met alleen een tekstje erboven.
Navigatie.NavigateTo("/familie");
}
}
/// <summary>Annuleren, of de popup gewoon dichttikken: terug naar waar de tegel vandaan kwam.</summary>
private void Sluiten() => Navigatie.NavigateTo("/familie");
// ------------------------------------------------------------------ e-mailadres
private void AdresWijzigen()
{
Gelukt = null;
Fout = null;
Adres = new Adreswissel();
}
/// <summary>Eén knop, twee stappen: eerst de code aanvragen, daarna hem bevestigen.</summary>
private async Task AdresStapAsync()
{
var wissel = Adres!;
if (!wissel.CodeGevraagd)
{
if (await ProbeerAsync(async () =>
{
var r = await Toestand.Client.AdreswijzigingAanvragenAsync(wissel.Nieuw.Trim());
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
}))
{
wissel.Nieuw = wissel.Nieuw.Trim();
wissel.CodeGevraagd = true;
}
return;
}
if (await ProbeerAsync(async () =>
{
var r = await Toestand.Client.AdreswijzigingBevestigenAsync(wissel.Nieuw, wissel.Code.Trim());
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
}))
{
// De sessie is aan de serverkant ongeldig gemaakt, dus hier ook opruimen: anders
// loopt het volgende scherm tegen "je sessie is verlopen" aan zonder uitleg.
Adres = null;
await Toestand.UitloggenAsync();
Navigatie.NavigateTo("/inloggen");
}
}
// ------------------------------------------------------------------ foto's en documenten
private async Task BijlagenLadenAsync()
{
if (Mijn is null) { Bijlagen = Array.Empty<Bijlage>(); return; }
var lijst = await Toestand.Client.PersoonsfotosAsync(Mijn.PersoonId);
Bijlagen = lijst.Gelukt ? lijst.Waarde! : Array.Empty<Bijlage>();
if (!lijst.Gelukt) Fout = lijst.Melding;
}
private async Task ToevoegenAsync(Soort soort)
{
if (Mijn is null) return;
Fout = null;
var gekozen = soort switch
{
Soort.Document => await Bestandskiezer.KiesDocumentAsync(),
Soort.Foto => await Bestandskiezer.KiesFotoAsync(),
Soort.FotoMaken => await Bestandskiezer.MaakFotoAsync(),
Soort.Video => await Bestandskiezer.KiesVideoAsync(),
_ => await Bestandskiezer.NeemVideoOpAsync()
};
if (gekozen is null) return;
Overzetten = $"{gekozen.Naam} wordt opgeslagen…";
Percentage = 0;
StateHasChanged();
var uitkomst = await Toestand.Client.BestandVersturenAsync(
gekozen.Naam, gekozen.Soort, gekozen.Inhoud, null,
(gedaan, totaal) =>
{
Percentage = totaal == 0 ? 100 : (int)(gedaan * 100 / totaal);
InvokeAsync(StateHasChanged);
},
persoonId: Mijn.PersoonId);
Overzetten = null;
if (!uitkomst.Gelukt) Fout = uitkomst.Melding;
else await BijlagenLadenAsync();
}
private async Task BijlageWegAsync(Bijlage bijlage)
{
if (await ProbeerAsync(async () =>
{
var r = await Toestand.Client.BijlageVerwijderenAsync(bijlage.Id);
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
}))
{
await BijlagenLadenAsync();
}
}
private async Task ProfielfotoAsync(Bijlage bijlage)
{
if (await ProbeerAsync(async () =>
{
var r = await Toestand.Client.ProfielfotoAsync(bijlage.Id);
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
}))
{
await BijlagenLadenAsync();
// Het profiel nog een keer ophalen, want daar zit je kleine foto in - en die staat
// ook op andere schermen. Zonder dit zou je in de kluis je oude foto blijven zien
// tot de app opnieuw was gestart.
await Toestand.Client.EigenProfielAsync();
}
}
// ------------------------------------------------------------------ account opheffen
/// <summary>
/// Haalt eerst op wát er weggaat. "Weet je het zeker?" is een lege vraag; "je kluis met 23
/// items en 4 berichten die nog klaarstonden" is er een waar iemand over nadenkt.
/// </summary>
private async Task OpheffenBeginnenAsync()
{
await ProbeerAsync(async () =>
{
var r = await Toestand.Client.OpheffenTellingAsync();
if (!r.Gelukt) return Uitkomst.Fout(r.Melding!);
Opheffen = new Opheffing { Telling = r.Waarde! };
return Uitkomst.Ok();
});
}
private async Task OpheffenStapAsync()
{
if (Opheffen is null) return;
if (!Opheffen.CodeGevraagd)
{
await ProbeerAsync(async () =>
{
var r = await Toestand.Client.OpheffenCodeAanvragenAsync();
if (!r.Gelukt) return Uitkomst.Fout(r.Melding!);
Opheffen.CodeGevraagd = true;
return Uitkomst.Ok();
});
return;
}
await ProbeerAsync(async () =>
{
var r = await Toestand.Client.OpheffenBevestigenAsync(Opheffen.Code.Trim());
if (!r.Gelukt) return Uitkomst.Fout(r.Melding!);
// Het account bestaat niet meer, dus er valt niets meer te tonen. De client heeft
// zijn bewaarde sessie al opgeruimd; wij sturen alleen het scherm nog naar de deur.
Opheffen = null;
Navigatie.NavigateTo("/inloggen");
return Uitkomst.Ok();
});
}
/// <summary>Het opheffen: eerst zien wat er weggaat, daarna de code die dat bevestigt.</summary>
private class Opheffing
{
public MijnBesluit.Client.Opheffing Telling { get; set; } = new(0, 0, 0, 0, 0, 0);
public bool CodeGevraagd { get; set; }
public string Code { get; set; } = "";
}
/// <summary>Het adreswijzigingsformulier: eerst een adres, daarna de code die daarheen ging.</summary>
private class Adreswissel
{
public string Nieuw { get; set; } = "";
public string Code { get; set; } = "";
public bool CodeGevraagd { get; set; }
}
}
+300
View File
@@ -0,0 +1,300 @@
@page "/"
@inherits SchermBasis
@inject IBuitenlink Buitenlink
@* Het beginscherm. Geen kop: die staat in de balk erboven.
Bovenaan wat er speelt - wie jouw nalatenschap beheert, wat er aankomt - en daaronder de
tegels. Staat er niets, dan beginnen de tegels bovenaan. *@
@if (Fout is not null)
{
<div class="melding fout">
@Fout
<div style="margin-top:.7rem">
<button class="knop rustig" disabled="@Bezig" @onclick="LaadAsync">Opnieuw proberen</button>
</div>
</div>
}
@if (Toestand.LaatsteMelding is not null)
{
<div class="melding goed">@Toestand.LaatsteMelding</div>
}
@* Heeft iemand een profiel voor je aangemaakt? Dan staat die vraag hier, bovenaan, tot je hem
beantwoordt. Bewust hier en niet als popup bij het aanmelden: iemand kan je ook toevoegen
nadat jij je al had aangemeld, en een popup die je wegklikt is een vraag die je nooit meer
krijgt. Zie docs/Identiteit.md. *@
@foreach (var aantekening in Aantekeningen)
{
<div class="kaart" style="margin-bottom:1rem">
<h2 style="margin-top:0">Ben jij dit?</h2>
<p style="margin-top:0">
<strong>@aantekening.Van</strong> heeft een profiel voor je aangemaakt op jouw
e-mailadres@(aantekening.Relatie is { Length: > 0 } r ? $", als {r.ToLowerInvariant()}" : "").
</p>
<p class="zacht" style="margin-top:0">
Ben jij dat, dan voegen we het bij je eigen profiel: wat hij over je heeft ingevuld
komt bij jou te staan, en vanaf dat moment bepaal jij wat er staat. Ben jij het niet,
dan blijft alles zoals het is en vragen we het niet nog eens.
</p>
<div class="knoppen">
<button class="knop vol" disabled="@Bezig"
@onclick="() => OvernemenAsync(aantekening.PersoonId)">Ja, dat ben ik</button>
<button class="knop rustig" disabled="@Bezig"
@onclick="() => AfwijzenAsync(aantekening.PersoonId)">Nee, dat ben ik niet</button>
</div>
</div>
}
@if (Gegevens is { Regels.Count: > 0 })
{
<div class="stapel speelt">
@foreach (var regel in Gegevens.Regels)
{
<div class="regel @(regel.IsWaarschuwing ? "let-op" : "")">
<span class="teken">@Teken(regel.Soort)</span>
<div class="groei">
<div>@regel.Tekst</div>
@if (regel.Datum is { } datum)
{
<div class="zacht">@Tonen.Datum(datum)</div>
}
</div>
</div>
}
</div>
@if (Gegevens.Regels.Any(r => r.IsWaarschuwing))
{
<button class="knop" style="margin-bottom:1rem" disabled="@Bezig" @onclick="IkBenErNogAsync">
Ik ben er nog
</button>
}
}
@* De advertentie staat hier en niet bovenaan: "wat er speelt" gaat voor. Iemand die net leest
dat er een overlijden op zijn naam is gemeld, hoort daar geen reclame boven te zien. Zie
docs/Adverteren.md.
Een knop en geen link: hij hoort in de browser van de telefoon te openen en niet in de app.
Zonder dat zou MijnBesluit zelf een browser worden, en sta je op de site van een adverteerder
zonder weg terug naar je eigen kluis. *@
@if (Reclame is not null)
{
<button type="button" class="advertentie" style="width:100%;text-align:left"
@onclick="() => Buitenlink.OpenAsync(Reclame.Link)">
@if (Reclame.Afbeelding is { Length: > 0 } plaatje)
{
<img src="@plaatje" alt="" />
}
<span class="groei">
<span class="merkje">Advertentie</span>
<span class="tekst">@Reclame.Tekst</span>
</span>
</button>
}
<div class="raster">
<a class="kaart klikbaar" href="/kluis">
<span class="groot">Mijn kluis <span class="aantal">@(Gegevens?.AantalKluis ?? 0)</span></span>
</a>
<a class="kaart klikbaar" href="/familie">
<span class="groot">Mijn mensen <span class="aantal">@(Gegevens?.AantalFamilie ?? 0)</span></span>
</a>
@* Twee richtingen, dus twee aantallen; die staan achter de titel in hetzelfde rondje als
de andere tegels, met hun naam erbij zodat je niet hoeft te raden welke welke is. Zo
past de tegel op één regel, even hoog als de andere drie. *@
<a class="kaart klikbaar" href="/momenten">
<span class="groot">
Momenten
<span class="aantal" title="@(Gegevens?.AantalVoorMij ?? 0) voor mij, @(Gegevens?.AantalVanMij ?? 0) van mij">
&darr;@(Gegevens?.AantalVoorMij ?? 0) &middot; &uarr;@(Gegevens?.AantalVanMij ?? 0)
</span>
@if (Gegevens is { AantalNieuweBerichten: > 0 })
{
<span class="aantal nieuw">@Gegevens.AantalNieuweBerichten</span>
}
</span>
</a>
<a class="kaart klikbaar" href="/nalatenschap">
<span class="groot">Nalatenschap <span class="aantal">@(Gegevens?.AantalBeheerders ?? 0)</span></span>
</a>
</div>
@* Drie links onderaan. De eerste twee openen een popup: uitklappen ín het scherm kon ook, maar
dan schuift de rest weg terwijl je iets aan het lezen of instellen bent; in een popup blijf je
waar je was.
De derde ook. De tekst staat als onderdeel op schijf (Privacytekst.razor) en niet alleen op de
website: een privacyverklaring die de app uit stuurt naar een browser is er een die niemand
leest. Dat onderdeel staat twee keer op schijf - de app en de site delen geen project - en een
zelfcontrole vergelijkt ze letterlijk. *@
<p style="margin-top:1.5rem">
@if (Gegevens is not null)
{
<button type="button" class="alsLink" @onclick="() => Vooruitblik = true">Hoe ver vooruit wil je kijken?</button>
<br />
}
<button type="button" class="alsLink" @onclick="() => Uitleg = true">Wat is MijnBesluit?</button>
<br />
<button type="button" class="alsLink" @onclick="() => Privacy = true">Privacyverklaring</button>
<br />
<button type="button" class="alsLink" @onclick="() => VersiehistorieOpen = true">Versiehistorie</button>
</p>
@if (Vooruitblik)
{
<Popup Titel="Hoe ver vooruit wil je kijken?" Bevestigtekst="Sluiten" AlleenSluiten="true"
OpBevestigen="() => Vooruitblik = false" OpSluiten="() => Vooruitblik = false">
<p style="margin-top:0">
Op dit scherm zie je wat er de komende tijd voor je klaarstaat. Zet het op 0 als je
liever verrast wordt.
</p>
<div class="veld">
<label for="vooruit">Vooruit kijken</label>
<select id="vooruit" @bind="Dagen" @bind:after="VooruitblikOpslaanAsync">
<option value="0">Niets vooruit tonen</option>
<option value="7">7 dagen</option>
<option value="14">14 dagen</option>
<option value="30">30 dagen</option>
<option value="60">60 dagen</option>
</select>
</div>
</Popup>
}
@* Dezelfde tekst als op mijnbesluit.nl/privacy - letterlijk hetzelfde bestand. *@
@if (Privacy)
{
<Popup Titel="Privacy" Bevestigtekst="Sluiten" AlleenSluiten="true"
OpBevestigen="() => Privacy = false" OpSluiten="() => Privacy = false">
<Privacytekst />
</Popup>
}
@* Zelfde tekst als op de website - Versiehistorie.razor staat twee keer op schijf en een
zelfcontrole vergelijkt ze letterlijk. *@
@if (VersiehistorieOpen)
{
<Popup Titel="Versiehistorie" Bevestigtekst="Sluiten" AlleenSluiten="true"
OpBevestigen="() => VersiehistorieOpen = false" OpSluiten="() => VersiehistorieOpen = false">
<Versiehistorie />
</Popup>
}
@* Dezelfde uitleg als op de website. *@
@if (Uitleg)
{
<Popup Titel="Wat is MijnBesluit?" Bevestigtekst="Sluiten" AlleenSluiten="true"
OpBevestigen="() => Uitleg = false" OpSluiten="() => Uitleg = false">
<Productuitleg />
</Popup>
}
@code {
/// <summary>Staat de uitleg open?</summary>
private bool Uitleg;
/// <summary>Staat de privacyverklaring open?</summary>
private bool Privacy;
/// <summary>Staat de versiehistorie open?</summary>
private bool VersiehistorieOpen;
/// <summary>Staat het schermpje voor de vooruitblik open?</summary>
private bool Vooruitblik;
/// <summary>Profielen die anderen op mijn adres hebben aangemaakt; alleen ik kan zeggen of ik dat ben.</summary>
private IReadOnlyList<Aantekening> Aantekeningen = Array.Empty<Aantekening>();
private Overzicht? Gegevens;
private int Dagen = 30;
/// <summary>De advertentie voor deze keer, of null als er geen is.</summary>
private Advertentie? Reclame;
protected override async Task LaadAsync()
{
Fout = null;
var overzicht = await Toestand.Client.OverzichtAsync();
if (!overzicht.Gelukt) { Fout = overzicht.Melding; return; }
Gegevens = overzicht.Waarde;
Dagen = Gegevens!.VooruitblikDagen;
// Apart opgevraagd en niet meegestuurd met het overzicht: het opvragen telt de vertoning
// mee, en dat hoort alleen te gebeuren als de tegel ook echt in beeld komt. Gaat het mis,
// dan staat het overzicht er gewoon - reclame is het minst belangrijke op dit scherm.
var reclame = await Toestand.Client.AdvertentieAsync();
Reclame = reclame.Gelukt ? reclame.Waarde : null;
// Ook apart, en ook zonder de rest te laten vallen als het misgaat: dit is een vraag die
// best een keer later mag komen, en het overzicht hoort er hoe dan ook te staan.
var aantekeningen = await Toestand.Client.AantekeningenOverMijAsync();
Aantekeningen = aantekeningen.Gelukt ? aantekeningen.Waarde! : Array.Empty<Aantekening>();
}
/// <summary>"Ja, dat ben ik." Daarna staat alles wat die ander over je had bij jou.</summary>
private async Task OvernemenAsync(long persoonId)
{
if (await ProbeerAsync(async () =>
{
var r = await Toestand.Client.AantekeningOvernemenAsync(persoonId);
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
}))
{
// De familie is veranderd; die staat in het geheugen van de app.
await Toestand.VerversAsync();
await LaadAsync();
}
}
/// <summary>"Nee, dat ben ik niet." De vraag komt niet terug.</summary>
private async Task AfwijzenAsync(long persoonId)
{
if (await ProbeerAsync(async () =>
{
var r = await Toestand.Client.AantekeningAfwijzenAsync(persoonId);
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
}))
{
Aantekeningen = Aantekeningen.Where(a => a.PersoonId != persoonId).ToList();
}
}
private async Task VooruitblikOpslaanAsync()
{
if (await ProbeerAsync(async () =>
{
var r = await Toestand.Client.VooruitblikAsync(Dagen);
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
}))
{
await LaadAsync();
}
}
private async Task IkBenErNogAsync()
{
if (await ProbeerAsync(async () =>
{
var r = await Toestand.Client.IkBenErNogAsync();
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
}))
{
await LaadAsync();
}
}
private static string Teken(string soort) => soort switch
{
"beheerder" => "\U0001F33F", // blad: iemand beheert jouw nalatenschap
"beheer-voor" => "\U0001F91D", // handdruk: jij beheert die van een ander
"moment" => "✉", // envelop
"overlijden" => "⚠", // waarschuwing
_ => "•"
};
}
@@ -0,0 +1,15 @@
@page "/uitloggen"
@inject AppToestand Toestand
@inject NavigationManager Navigatie
<div class="middenblok">
<p class="zacht">Bezig met uitloggen…</p>
</div>
@code {
protected override async Task OnInitializedAsync()
{
await Toestand.UitloggenAsync();
Navigatie.NavigateTo("/inloggen");
}
}
+70
View File
@@ -0,0 +1,70 @@
namespace MijnBesluit.AppUi;
/// <summary>
/// De terugknop van het toestel. Android heeft er een, en zonder hulp sluit die de hele app —
/// ook als er net een popup openstaat of je drie schermen diep zit. Dat is precies wat je niet
/// wilt bij een app waar je gegevens in zit te typen.
///
/// Wie iets open heeft staan meldt zich hier aan, en meldt zich weer af zodra het dicht is. De
/// laatste aanmelding is als eerste aan de beurt: dat is dezelfde volgorde waarin de dingen over
/// elkaar heen liggen — een foto in een popup, in een wijzigscherm, in een scherm.
///
/// Zegt een afhandelaar nee, dan gaat de knop door naar wie eronder ligt. Zegt niemand ja, dan
/// mag het toestel zijn gang gaan en gaat de app alsnog dicht.
///
/// De schermen zelf hoeven hier niets van te weten: <c>AppSchil</c> regelt "terug naar het
/// overzicht" en <c>Popup</c> regelt "dit venster dicht". Zo werkt de terugknop overal, ook in
/// schermen die later pas worden gemaakt.
/// </summary>
public class Terugknop
{
private readonly List<Func<bool>> _stapel = new();
/// <summary>
/// Meldt een afhandelaar aan. Geef het teruggegeven voorwerp weer vrij zodra het scherm of
/// het venster dicht gaat; dat haalt hem van de stapel af.
/// </summary>
/// <param name="afhandelaar">
/// Geeft true als hij de druk op de knop heeft afgehandeld, en false om hem door te geven.
/// Wordt aangeroepen vanaf de draad van het toestel, dus werk aan het scherm hoort daarbinnen
/// via <c>InvokeAsync</c> te lopen.
/// </param>
public IDisposable Aanmelden(Func<bool> afhandelaar)
{
lock (_stapel) _stapel.Add(afhandelaar);
return new Afmelding(this, afhandelaar);
}
/// <summary>
/// Er is op de terugknop gedrukt. True betekent: afgehandeld, de app blijft open.
/// </summary>
public bool Ingedrukt()
{
Func<bool>[] stapel;
lock (_stapel) stapel = _stapel.ToArray();
for (var i = stapel.Length - 1; i >= 0; i--)
{
if (stapel[i]()) return true;
}
return false;
}
private sealed class Afmelding : IDisposable
{
private readonly Terugknop _knop;
private readonly Func<bool> _afhandelaar;
public Afmelding(Terugknop knop, Func<bool> afhandelaar)
{
_knop = knop;
_afhandelaar = afhandelaar;
}
public void Dispose()
{
lock (_knop._stapel) _knop._stapel.Remove(_afhandelaar);
}
}
}
+39
View File
@@ -0,0 +1,39 @@
namespace MijnBesluit.AppUi;
/// <summary>
/// Het slot van het toestel zelf: vingerafdruk, gezicht, of de pincode of het patroon waarmee
/// je je telefoon ontgrendelt.
///
/// De app ziet die vingerafdruk of code nooit. Hij vraagt het toestel om iemand te controleren
/// en krijgt alleen ja of nee terug; het echte werk gebeurt in een deel van de telefoon waar
/// geen enkele app bij kan. Er valt hier dus ook niets te bewaren of te lekken.
///
/// Net als de bestandskiezer staat dit als afspraak in de schermen en niet als code: een
/// webview kan hier niet bij, dat moet de app eromheen doen. Zonder implementatie is
/// <see cref="Beschikbaar"/> onwaar en gedraagt de app zich als vroeger - dan logt hij uit in
/// plaats van op slot te gaan.
/// </summary>
public interface IToestelslot
{
/// <summary>
/// Kan dit toestel iemand controleren? Onwaar als de telefoon geen schermslot heeft, of
/// als de Android-versie te oud is. Dan heeft vergrendelen geen zin: iedereen die de
/// telefoon in handen heeft komt er toch in.
/// </summary>
bool Beschikbaar { get; }
/// <summary>
/// Laat het toestel om een vingerafdruk of code vragen. Waar als het gelukt is, onwaar bij
/// afbreken of mislukken. Gooit niets: een slot dat stukgaat hoort de app niet te laten
/// vallen, het hoort dicht te blijven.
/// </summary>
Task<bool> OntgrendelAsync(string titel, string uitleg);
}
/// <summary>Wordt gebruikt als er geen toestelslot is. Zegt netjes nee.</summary>
public sealed class Geenslot : IToestelslot
{
public bool Beschikbaar => false;
public Task<bool> OntgrendelAsync(string titel, string uitleg) => Task.FromResult(false);
}
+147
View File
@@ -0,0 +1,147 @@
namespace MijnBesluit.AppUi;
/// <summary>
/// Waar de app instellingen bewaart die alleen op dit toestel gelden.
///
/// Dit staat als afspraak in de schermen en niet als code, net als de bestandskiezer: een
/// webview kan niet bij de opslag van het toestel, dat moet de app eromheen doen. Zonder
/// implementatie werkt alles gewoon, alleen onthoudt het toestel niets tussen twee starts.
/// </summary>
public interface IAppinstellingen
{
int Lees(string sleutel, int standaard);
void Schrijf(string sleutel, int waarde);
}
/// <summary>Wordt gebruikt als er geen toestelopslag is; onthoudt alleen binnen deze start.</summary>
public sealed class Losseinstellingen : IAppinstellingen
{
private readonly Dictionary<string, int> _waarden = new();
public int Lees(string sleutel, int standaard) =>
_waarden.TryGetValue(sleutel, out var waarde) ? waarde : standaard;
public void Schrijf(string sleutel, int waarde) => _waarden[sleutel] = waarde;
}
/// <summary>
/// Logt de app uit als er een tijd niets is gebeurd.
///
/// Waarom dit er is: de app blijft aangemeld, en dat is prettig — je hoeft niet elke keer een
/// code uit je mail te halen. Maar op een telefoon die op tafel ligt of kwijtraakt, staat de
/// kluis dan open. Wie dat niet wil, zet hier een termijn.
///
/// Wat er gebeurt als de tijd om is, hangt af van het toestel. Kan de telefoon om een
/// vingerafdruk of zijn eigen code vragen, dan gaat de app op slot: je kluis is dicht, maar je
/// blijft aangemeld en bent er met één aanraking weer in. Kan dat niet, dan logt de app écht
/// uit - het sessietoken gaat van het toestel af en je moet een nieuwe code per e-mail
/// aanvragen.
///
/// Daarom hangt ook de standaardwaarde van het toestel af, en staat die niet hier vast. Op een
/// telefoon met een schermslot staat de klok standaard aan: dat kost je bijna niets en zet je
/// kluis dicht zodra je de telefoon wegleg. Zonder schermslot staat hij standaard uit, want
/// dan is de prijs van vergeten een e-mail met een code, en dat is te zwaar om iemand
/// ongevraagd op te leggen.
///
/// De tijd loopt op de wandklok en niet op een teller, zodat een telefoon die een uur in een
/// broekzak zit net zo goed meetelt als een uur waarin je ernaar kijkt.
/// </summary>
public class Uitlogklok
{
/// <summary>Onder deze naam staat de termijn in de opslag van het toestel.</summary>
public const string Sleutel = "uitlogminuten";
/// <summary>Wat je kunt kiezen. Nul is uit.</summary>
public static readonly int[] Keuzes = { 0, 1, 2, 5, 10, 15, 30, 60 };
/// <summary>
/// Wat je kunt kiezen op een toestel dat om een vingerafdruk of code kan vragen: hetzelfde,
/// maar zonder "uit".
///
/// Waarom die keuze daar verdwijnt: op zo'n toestel kost het slot je één aanraking. "Uit"
/// zetten levert dus bijna niets op en betekent wel dat een gevonden telefoon een open kluis
/// is. Zonder toestelslot ligt dat anders - daar betekent de termijn écht uitloggen, en dan
/// is de prijs van vergeten een e-mail met een code. Die keuze mag iemand daar wél zelf
/// maken.
/// </summary>
public static readonly int[] KeuzesMetSlot = { 1, 2, 5, 10, 15, 30, 60 };
private readonly IAppinstellingen _opslag;
private readonly int _standaard;
private readonly bool _slotBeschikbaar;
private DateTime _laatsteActie = DateTime.UtcNow;
/// <param name="standaard">
/// Wat er geldt zolang niemand iets heeft gekozen. Wie zelf "uit" kiest, houdt uit: die nul
/// staat dan in de opslag en is iets anders dan niets.
/// </param>
/// <param name="slotBeschikbaar">
/// Kan dit toestel om een vingerafdruk of zijn eigen code vragen? Dan geldt er een
/// ondergrens: nul is hier geen geldige stand. Zie <see cref="Minuten"/>.
/// </param>
public Uitlogklok(IAppinstellingen? opslag = null, int standaard = 0, bool slotBeschikbaar = false)
{
_opslag = opslag ?? new Losseinstellingen();
_standaard = standaard;
_slotBeschikbaar = slotBeschikbaar;
}
/// <summary>
/// Na hoeveel minuten stilte de app op slot gaat of uitlogt. Nul betekent: niet.
///
/// Op een toestel dat om een vingerafdruk kan vragen kán hier geen nul uitkomen. Het slot
/// kost daar één aanraking; uit zetten levert dus bijna niets op en betekent wel dat een
/// gevonden telefoon een open kluis is. Er kan toch een nul in de opslag staan - van vroeger,
/// of van een toestel waar het schermslot pas later is aangezet - en die tillen we hier op
/// in plaats van in het instellingenscherm, zodat ook de klok zelf en de schil ervan uitgaan.
/// </summary>
public int Minuten
{
get
{
var opgeslagen = _opslag.Lees(Sleutel, _standaard);
return _slotBeschikbaar && opgeslagen <= 0 ? StandaardMetSlot : opgeslagen;
}
set
{
_opslag.Schrijf(Sleutel, value);
Aangeraakt(); // een net gewijzigde termijn hoort niet meteen af te lopen
}
}
public bool StaatAan => Minuten > 0;
/// <summary>
/// De app is teruggekomen uit de achtergrond en er hoort nu gekeken te worden.
///
/// Dit is het geval waar het echt om gaat: een telefoon die een uur in een tas zit. De lus
/// in de schil draait dan niet, en zonder dit sein zou iemand na het openen nog een halve
/// minuut in zijn kluis kunnen kijken voordat de app doorheeft dat de tijd om is.
/// </summary>
public event Action? Hervat;
public void Hervatten() => Hervat?.Invoke();
/// <summary>Er is iets gebeurd: de teller begint opnieuw.</summary>
public void Aangeraakt() => _laatsteActie = DateTime.UtcNow;
/// <summary>Hoe lang het al stil is.</summary>
public TimeSpan Stil(DateTime nu) => nu - _laatsteActie;
/// <summary>Is de termijn voorbij? Staat de klok uit, dan nooit.</summary>
public bool IsVerlopen(DateTime nu) =>
StaatAan && Stil(nu) >= TimeSpan.FromMinutes(Minuten);
/// <summary>Wat er standaard geldt op een toestel dat om een vingerafdruk of code kan vragen.</summary>
public const int StandaardMetSlot = 15;
/// <summary>Hoe de termijn op het scherm heet.</summary>
public static string Naam(int minuten) => minuten switch
{
0 => "Uit — blijf open staan",
1 => "Na 1 minuut",
60 => "Na een uur",
_ => $"Na {minuten} minuten"
};
}
+19
View File
@@ -0,0 +1,19 @@
namespace MijnBesluit.AppUi;
/// <summary>
/// Het "trek naar beneden om te verversen"-gebaar zit in de schil (<c>AppSchil</c>), die om het
/// hele scherm heen staat en niets weet van wat het actieve scherm daaronder eigenlijk is of hoe
/// het zijn gegevens ophaalt. Dit is de verbinding daartussen: de schil meldt hier dat er is
/// getrokken, en het scherm dat op dat moment open staat - en dus als enige luistert - haalt
/// zijn eigen gegevens opnieuw op. Zie <see cref="SchermBasis"/>.
/// </summary>
public class VernieuwSignaal
{
public event Func<Task>? Gevraagd;
/// <summary>Wordt aangeroepen door de schil zodra er ver genoeg naar beneden is getrokken.</summary>
public async Task VraagAanAsync()
{
if (Gevraagd is { } luisteraar) await luisteraar();
}
}
+9
View File
@@ -0,0 +1,9 @@
@using System.Net.Http
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using MijnBesluit.AppUi
@using MijnBesluit.AppUi.Layout
@using MijnBesluit.AppUi.Onderdelen
@using MijnBesluit.AppUi.Schermen
@using MijnBesluit.Client
+774
View File
@@ -0,0 +1,774 @@
/* MijnBesluit - rustig, warm en groot genoeg om makkelijk te bedienen.
Het onderwerp is zwaar; het scherm hoeft dat niet ook te zijn. */
:root {
--achtergrond: #faf7f2;
--vlak: #ffffff;
--vlak-zacht: #f3efe8;
--inkt: #2a2724;
--inkt-zacht: #6b645c;
/* Huisstijl: het groen uit het logo van mijnbesluit.nl. */
--merk: #C1D13F;
--merk-donker: #AFC02F;
/* Op dat groen hoort donkere tekst; wit is er nauwelijks op te lezen. */
--merk-tekst: #2a2724;
/* Donkerder groen voor tekst en randen, want het logogroen is te licht om op te lezen. */
--accent: #6B7A1C;
--accent-donker: #556115;
--accent-zacht: #F1F5DC;
--rand: #e6e0d7;
--waarschuwing: #8a5a22;
--waarschuwing-zacht: #fbf1e3;
--fout: #9b3b32;
--straal: 14px;
--schaduw: 0 1px 2px rgba(42, 39, 36, .05), 0 6px 20px rgba(42, 39, 36, .06);
/* De hoogte van de bovenbalk op de site. Staat hier omdat de navigatie er precies
onder moet blijven plakken; twee losse getallen lopen vroeg of laat uit elkaar.
Hoog genoeg voor de uitlogknop, die zelf 36px is. */
--kop-hoogte: 66px;
}
@media (prefers-color-scheme: dark) {
:root {
--achtergrond: #1b1917;
--vlak: #242120;
--vlak-zacht: #2c2927;
--inkt: #f0ece6;
--inkt-zacht: #a8a099;
--merk: #C1D13F;
--merk-donker: #CEDD5B;
--merk-tekst: #1f1d1a;
--accent: #C1D13F;
--accent-donker: #D2E062;
--accent-zacht: #2b3017;
--rand: #38342f;
--waarschuwing: #d9a55f;
--waarschuwing-zacht: #33291a;
--fout: #e08e84;
--schaduw: 0 1px 2px rgba(0, 0, 0, .3), 0 6px 20px rgba(0, 0, 0, .25);
}
}
* { box-sizing: border-box; }
html { font-size: 17px; }
body {
margin: 0;
background: var(--achtergrond);
color: var(--inkt);
font-family: "Segoe UI", system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
h1, h2, h3 { line-height: 1.25; font-weight: 600; letter-spacing: -.01em; margin: 0 0 .4em; }
h1 { font-size: 1.9rem; }
h2 { font-size: 1.35rem; }
h3 { font-size: 1.1rem; }
p { margin: 0 0 1em; }
a { color: var(--accent-donker); }
/* ------------------------------------------------------------------ opbouw */
.schil { display: flex; flex-direction: column; min-height: 100vh; }
.kop {
display: flex; align-items: center; gap: 1rem;
padding: .9rem 1.25rem;
background: var(--vlak);
border-bottom: 1px solid var(--rand);
position: sticky; top: 0; z-index: 20;
}
/* Op de site een vaste hoogte, zodat de navigatie er precies onder kan blijven staan.
In de app niet: daar zit een terugknop in de balk die zijn eigen ruimte nodig heeft. */
.schil:not(.app) .kop { box-sizing: border-box; height: var(--kop-hoogte); }
/* In de app staan de balken echt vast: de inhoud schuift eronderdoor. Sticky is niet genoeg
zodra er binnen een scherm wordt gescrold. */
.schil.app { min-height: 100dvh; }
/* De bovenbalk staat vast op top:0, en dat is onder de statusbalk van de telefoon - de klok,
het bereik en het accupercentage. Door viewport-fit=cover loopt de webview daar tot onderaan
doorheen, dus moet de balk zelf die ruimte vrijhouden. De inhoud deed dat al wel; de balk
niet, en daardoor liep "Mijn mensen" en "Uitloggen" door de tijd en het batterijsymbool heen. */
.schil.app .kop.vast {
position: fixed; top: 0; left: 0; right: 0;
padding-top: calc(.9rem + env(safe-area-inset-top));
}
.schil.app .nav.vast { position: fixed; bottom: 0; left: 0; right: 0; }
.schil.app .inhoud {
/* Ruimte voor de twee balken, plus wat de telefoon zelf bovenaan en onderaan inneemt.
De balk is gemeten 60 pixels hoog (een merkteken van 26 met 0,9rem eromheen, plus een
randje). Met 4,2rem = 71 blijft er elf pixels lucht over: genoeg om niet tegen de balk te
plakken, en niet de gapende ruimte die er eerst was - dat waren er zeventien. Gaat de balk
ooit hoger worden, dan hoort dit getal mee te verhuizen. */
padding-top: calc(4.2rem + env(safe-area-inset-top));
padding-bottom: calc(5.5rem + env(safe-area-inset-bottom));
}
/* Het "trek naar beneden om te verversen"-vaantje (zie validatie.js). Hoogte en doorzichtigheid
komen uit JS mee met de vinger; hier staat alleen hoe het er dan uitziet. De transition is
voor het terugveren als iemand loslaat vóór de drempel, of nadat het verversen klaar is - bij
het trekken zelf zou een transition juist achter de vinger aan hobbelen.
validatie.js zet hem als buur vlak vóór #app-inhoud, niet erbinnen - anders zou Blazor hem bij
de eerstvolgende tekening weer weghalen. Die plek ligt in de kolom "schil.app", naast de vaste
kopbalk (position: fixed neemt geen ruimte in de kolom in) - zonder iets erbij stond hij dus
altijd achter die balk verstopt. Een marge om hem eronder te krijgen loste dat wel op, maar
telde in de flex-kolom gewoon mee als ruimte (marges van flex-items vouwen niet samen), en dat
bovenop de ruimte die .schil.app .inhoud zelf al voor de balk vrijhoudt gaf een dubbele,
veel te grote lege strook boven elk scherm. In plaats daarvan staat hij hier los van de
kolom-layout: absoluut gepositioneerd, op dezelfde hoogte als waar .inhoud begint, zodat hij
groeiend over die (toch al lege) ruimte heen verschijnt zonder er zelf plek voor te claimen. */
.schil.app { position: relative; }
.schil.app .ptr-indicator {
/* De top komt uit validatie.js: de kopbalk is niet overal even hoog (een scherm met
terugknop heeft een extra rij), dus die hoogte wordt bij elke aanraking gemeten in plaats
van hier als vaste waarde te staan - anders liep de tekst op zo'n scherm door de balk heen. */
position: absolute; left: 0; right: 0; z-index: 4;
}
.ptr-indicator {
/* Ondoorzichtige achtergrond, en geen opacity-vervaging: het vaantje groeit bovenop de inhoud
(zie de uitleg bij .schil.app .ptr-indicator hierboven) in plaats van eronder te schuiven,
dus zonder een volledig dekkende achtergrond op elk moment - ook halverwege het trekken -
schemerde wat eronder stond, zoals de knop "Toevoegen persoon" bij Mijn mensen, dwars door
de tekst heen. De hoogte alleen al onthult het vaantje netjes, een opacity-overgang erbij
hoefde niet. */
height: 0; overflow: hidden;
display: flex; align-items: center; justify-content: center; gap: .5rem;
font-size: 1.05rem; color: var(--inkt-zacht); text-align: center;
background: var(--achtergrond);
transition: height .2s ease;
}
.ptr-icoon {
display: inline-block; font-size: 1.3rem; line-height: 1;
transition: transform .2s ease;
}
.ptr-icoon.omgekeerd { transform: rotate(180deg); }
.ptr-icoon.bezig { animation: ptr-draaien .8s linear infinite; }
@keyframes ptr-draaien { to { transform: rotate(360deg); } }
/* De waarschuwing bij het afsluiten: kort zichtbaar, net boven de onderbalk, en niet aan te
raken - hij is een mededeling, geen knop. */
.afsluithint {
position: fixed;
left: 50%; transform: translateX(-50%);
bottom: calc(5.2rem + env(safe-area-inset-bottom));
z-index: 40;
/* Zonder breedte kroop hij samen tot een smalle koker van vier regels hoog. max-content laat
hem zo breed worden als de zin is, en pas daarna afbreken als het scherm te smal is. */
width: max-content;
max-width: calc(100% - 2rem);
padding: .55rem 1rem;
border-radius: 1rem;
background: var(--inkt);
color: var(--vlak);
font-size: .9rem;
text-align: center;
box-shadow: var(--schaduw);
pointer-events: none;
}
/* De terugknop in de bovenbalk. */
.terug {
border: none; background: transparent; cursor: pointer;
font-size: 1.8rem; line-height: 1; color: var(--inkt-zacht);
padding: 0 .2rem 0 0; margin: 0;
}
.kop .merk {
font-weight: 600; font-size: 1.05rem; color: var(--inkt);
text-decoration: none; display: flex; align-items: center; gap: .55rem;
/* De schermnaam hoort op één regel te staan. Valt hij over twee, dan wordt de balk hoger dan
op de andere schermen en springt de hele inhoud een stukje omlaag. Past hij echt niet, dan
liever afgekapt dan een balk die per scherm van hoogte verschilt. */
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0;
}
/* Het merkteken in de balk. Klein, want de balk hoort op elk scherm even hoog te zijn en op de
andere schermen staat hier de schermnaam. Op deze maat is het woordmerk niet te lezen; dat
hoeft ook niet, want de naam staat er in letters naast. Groot en leesbaar staat het logo op
het inlogscherm. */
.kop .merk .stip { width: 26px; height: 26px; flex: none; display: block; }
.kop .rechts { margin-left: auto; display: flex; align-items: center; gap: .9rem; font-size: .92rem; }
/* Het tekentje voor "geen verbinding". Het woord stond hier eerst voluit, en dat duwde de
schermnaam op een telefoon van 360 pixels tot zeven pixels in elkaar - of, zonder nowrap, viel
het over twee regels en werd de balk 81 pixels in plaats van 62. Iets wat komt en gaat hoort de
balk niet overhoop te gooien. De uitleg staat in title en aria-label. */
.kop .geenverbinding { color: var(--waarschuwing); font-size: 1.05rem; flex: none; line-height: 1; }
.inhoud { flex: 1; width: 100%; max-width: 900px; margin: 0 auto; padding: 1.6rem 1.25rem 5.5rem; }
/* Navigatie: onderaan op de telefoon, bovenaan op een groot scherm. */
.nav {
position: fixed; bottom: 0; left: 0; right: 0; z-index: 20;
display: flex; background: var(--vlak);
border-top: 1px solid var(--rand);
padding: .35rem .25rem calc(.35rem + env(safe-area-inset-bottom));
}
.nav a {
flex: 1; text-align: center; text-decoration: none;
color: var(--inkt-zacht); font-size: .72rem;
padding: .45rem .2rem; border-radius: 10px;
display: flex; flex-direction: column; align-items: center; gap: .2rem;
}
.nav a .teken { font-size: 1.2rem; line-height: 1; }
.nav a.actief { color: var(--accent-donker); background: var(--accent-zacht); }
@media (min-width: 720px) {
html { font-size: 16px; }
.nav {
/* Vastzetten net onder de bovenbalk: samen vormen ze een balk die blijft staan. */
position: sticky; top: var(--kop-hoogte); z-index: 19;
border-top: none; border-bottom: 1px solid var(--rand);
justify-content: center; gap: .3rem; padding: .3rem 1rem;
}
.nav a { flex: none; flex-direction: row; font-size: .92rem; padding: .5rem .9rem; }
.nav a .teken { font-size: 1rem; }
.inhoud { padding-bottom: 3rem; }
}
/* ------------------------------------------------------------------ blokken */
.kaart {
background: var(--vlak);
border: 1px solid var(--rand);
border-radius: var(--straal);
padding: 1.25rem;
box-shadow: var(--schaduw);
margin-bottom: 1rem;
}
/* Een compacte kaart: voor lijsten waar je er veel van onder elkaar ziet. */
.kaart.compact { padding: .9rem 1rem; }
.kaart.klikbaar { display: block; text-decoration: none; color: inherit; }
.kaart.klikbaar:hover { border-color: var(--accent); }
/* Je eigen kaart bovenaan Mijn mensen. Hij hoort er wel bij te horen en er toch net uit te
springen: een randje in de accentkleur, want dit is de enige kaart die ergens heen gaat. */
.kaart.zelf { border-color: var(--accent); }
/* De knoppen rechtsonder op een tegel: kleine knoppen naast elkaar, met minder ruimte ertussen
dan een gewone rij. Hier gemeten: met "Wijzigen" en "Terugtrekken" voluit hield het onderwerp
op een telefoon van 360 pixels nog 54 pixels over - "Ee...". Als tekentjes is dat 231, de
volle breedte. Daarom staan er hier iconen en geen woorden. */
.tegelknoppen { display: flex; gap: .35rem; flex: none; }
/* Een vaste breedte in plaats van "zo breed als het tekentje". Dat laatste hing af van het
lettertype waarmee het toestel emoji tekent, en dat verschilt per telefoon: dan staan de
knoppen op de ene telefoon net iets anders dan op de andere. Nu is een knop altijd even
breed, wat er ook in staat. */
.tegelknoppen .knop { width: 2.6rem; padding-left: 0; padding-right: 0; }
/* De knoppen onder "Foto's en documenten": Foto maken, Foto kiezen, Video opnemen, Video
kiezen, Document - vijf stuks op zijn breedst. Voluit met tekst erbij liepen ze op een
telefoon van 360 pixels op twee, soms drie regels uit; dat kostte veel ruimte voor iets wat
je maar af en toe gebruikt. Nu alleen het tekentje, net als bij .tegelknoppen, en altijd op
één regel - flex-wrap staat hier bewust uit, in tegenstelling tot een gewone .rij die wél
omslaat. Wat elk tekentje betekent staat in title (bij een muis) en aria-label (voor een
schermlezer), want zonder tekst valt er verder niets te lezen. */
.bijlage-acties { display: flex; gap: .35rem; flex-wrap: nowrap; overflow-x: auto; }
.bijlage-acties .knop {
flex: 1 1 0; min-width: 0; width: auto;
padding-left: 0; padding-right: 0; font-size: 1.15rem;
}
/* Geen knop maar een wijzertje: het driehoekje dat laat zien dat de tegel uitklapt. Openen doe
je door de tegel zelf aan te tikken, dus hier hoort geen tweede knop te staan die hetzelfde
doet. De regelhoogte is die van een knop, zodat de onderste regel even hoog blijft als op een
tegel waar wel een knop staat. */
.tegelteken {
width: 1.8rem; flex: none; text-align: center;
color: var(--inkt-zacht); font-size: .9rem; line-height: 40px;
}
/* Een tegel die je aantikt. Bij een <a> regelt de browser dit zelf; dit is voor de tegels die
geen andere pagina openen maar iets uitklappen of een scherm oproepen. */
.kaart.klikbaar { cursor: pointer; }
/* Twee tegels naast elkaar waarmee je kiest wat je eronder ziet: Van mij / Voor mij bij
Momenten. Geen tabbladen met een streepje eronder, maar dezelfde kaart als in de lijst
eronder - zodat je ziet dat het bij elkaar hoort.
Ze delen de breedte gelijk (flex: 1 1 0 met een basis van nul), want anders wordt de tegel
met "12 · 3 nieuw" erin breder dan die met "0", en dan springt de rij van plek zodra er een
bericht bij komt.
margin-bottom: 0 omdat .kaart die van zichzelf heeft; in een rij levert dat een gat op
waar niemand om vroeg. */
.keuzetegels { gap: .7rem; flex-wrap: nowrap; }
.keuzetegel {
flex: 1 1 0;
min-width: 0;
margin-bottom: 0;
display: flex; flex-direction: column; align-items: flex-start; gap: .15rem;
font: inherit; text-align: left; cursor: pointer;
}
.keuzetegel > .groot { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* De tegel die openstaat. Vlak en randje in de accentkleur: hetzelfde gebaar als de gekozen
knop onderin de navigatiebalk, zodat "dit kijk je nu naar" er overal eender uitziet. */
.keuzetegel.aan { border-color: var(--accent); background: var(--accent-zacht); }
.keuzetegel:hover { border-color: var(--accent); }
/* Een bericht dat nog niet gelezen is. Het randje in de accentkleur vervangt het labeltje
"Nieuw" dat eerst naast de knop stond: dat kostte breedte die het onderwerp harder nodig had. */
.kaart.nieuw { border-color: var(--accent); }
/* Een tegel in een lijst: links de foto als die er is, en ernaast drie regels tekst. Op de
onderste regel staan rechts de knoppen, en op de regel daarboven kan rechts een labeltje
staan. Zo staan die twee op elke tegel op dezelfde plek, ook als de onderste regel leeg is.
De knoppen lagen hier eerder overheen geschoven, zodat de tegel niet hoger werd dan zijn
tekst. Dat ging goed tot er ook rechts op de tweede regel iets moest staan: een knop is 40
pixels hoog en dat is anderhalve tekstregel, dus hij kwam altijd over dat labeltje heen. Nu
staan de knoppen gewoon naast de tekst op de onderste regel. De tegel wordt daar een pixel
of vijftien hoger van, en er kan niets meer over elkaar heen lopen.
De bovenste regel - het onderwerp, de naam, de titel - loopt door tot de rand. Wat niet past
wordt afgekapt in plaats van afgebroken; een tegel van vijf regels hoog zet de rest van de
lijst buiten beeld. */
.kaart.tegel > .rij { flex-wrap: nowrap; }
.kaart.tegel > .rij > .groei > .groot,
.kaart.tegel > .rij > .groei > .zacht {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Breekt af naar een volgende regel zodra het niet meer past. Zonder flex-wrap lopen drie
knoppen naast elkaar het scherm van een telefoon uit, en is de laatste niet te bereiken. */
.rij { display: flex; align-items: center; gap: .9rem; flex-wrap: wrap; }
.rij .groei { flex: 1; min-width: 0; }
/* Een kop in zo'n rij mag krimpen, maar zijn tekst hoort dan af te breken in plaats van over
de knop ernaast heen te lopen. Dat gebeurde op de telefoon bij Momenten en Mijn kluis. */
.rij > h1, .rij > h2, .rij > h3 { overflow-wrap: anywhere; }
/* De rij met een persoon erin: penning, naam, en rechts de status.
Deze rij breekt bewust NIET af. De status hoort altijd op dezelfde plek te staan - rechtsboven
op de tegel - zodat je hem in een lijst met één blik afleest. Zakt hij bij de een wel en bij
de ander niet naar een eigen regel, dan moet je elke kaart apart bekijken.
De naam krijgt de ruimte die overblijft en wordt afgekapt als het niet past. Dat is de goede
afweging: je herkent iemand aan het begin van zijn naam, en de status is een vaste ankerplek. */
.rij.persoon { flex-wrap: nowrap; }
.rij.persoon > .groei { min-width: 0; }
/* De regels onder de naam op een persoonsrij: naam van de ander, datum. Net als de naam zelf
worden ze afgekapt in plaats van afgebroken. Zonder dit groeide een tegel bij Momenten naar
vijf regels zodra er een lange bezorgtekst onder stond, en dan staat de rest van de lijst
buiten beeld. */
.rij.persoon > .groei > .zacht {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Een regel met tekst links en iets vasts rechts: "Partner (59)" met de status ernaast, of de
onderste regel met de knoppen erachter. De tekst krimpt en wordt afgekapt, wat rechts staat
houdt zijn breedte.
Dit is wat de tegels op elkaar laat lijken: het labeltje staat op elke tegel op dezelfde
plek, en de knoppen eronder ook - ook als er op die onderste regel verder niets staat. */
.onderregel { display: flex; align-items: center; gap: .6rem; }
/* Twee van die regels onder elkaar - het labeltje boven, de knoppen eronder - plakten tegen
elkaar aan: de knop begint precies waar het labeltje ophoudt. Een beetje lucht ertussen. */
.onderregel + .onderregel { margin-top: .35rem; }
.onderregel > .zacht,
.onderregel > .groot { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.onderregel > .label { flex: none; }
/* Een tekentje voor de tekst van een regel, even hoog als die tekst - geen plaatje dat de
regel hoger maakt. Inline en niet flex: de regel zelf kapt zijn tekst af met puntjes, en dat
werkt alleen zolang de tekst gewone tekst in een gewone regel blijft. */
.regelicoon > .teken { font-size: 1em; margin-right: .35em; }
/* Bij "Alleen voor mijzelf" staat er geen tekentje maar de eigen profielfoto. De penning is
elders 44 bij 44; hier krimpt hij mee met de tekst. De letters erin (voor wie geen foto
heeft) mogen kleiner, anders passen twee initialen niet in een rondje van een regel hoog.
De -.2em zet hem op de tekstregel in plaats van op de onderkant van de letters. */
.regelicoon .penningetje {
display: inline-block; width: 1.15em; height: 1.15em;
vertical-align: -.2em; margin-right: .35em;
}
/* De maat staat op het omhulsel hierboven en niet op de penning zelf. Een em rekent namelijk
met de lettergrootte van het element waar hij op staat: zet je font-size en width samen op de
penning, dan krimpt de em mee met de letters en werd het rondje 9 pixels in plaats van 18. */
.regelicoon .penningetje .penning {
width: 100%; height: 100%; font-size: .55em; font-weight: 700;
display: inline-flex;
}
/* Een naam die niet past wordt afgekapt met puntjes, en breekt niet over drie regels. Een
kaart van drie regels hoog zet de rest van de lijst buiten beeld, en je herkent iemand
toch aan het begin van zijn naam. De volledige naam staat in het wijzigscherm, en op een
muis verschijnt hij als zwevende tekst. */
.rij.persoon .groot {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Knoppen mogen krimpen tot hun tekst, maar nooit onleesbaar smal worden. */
.rij > .knop { flex: 0 1 auto; }
.stapel { display: flex; flex-direction: column; gap: .75rem; }
.zacht { color: var(--inkt-zacht); font-size: .9rem; }
.groot { font-size: 1.05rem; font-weight: 600; }
/* Het aantal achter de titel van een tegel, in plaats van op een regel eronder. Zo blijven
de tegels laag en passen er meer op een scherm. */
.aantal {
display: inline-block; min-width: 1.9rem; text-align: center;
margin-left: .4rem; padding: .05rem .5rem;
border-radius: 999px; background: var(--vlak-zacht); color: var(--inkt-zacht);
font-size: .85rem; font-weight: 600; vertical-align: middle;
}
.aantal.nieuw { background: var(--merk); color: var(--merk-tekst); }
/* Wat er speelt, boven de tegels. */
.speelt { margin-bottom: 1.2rem; }
.speelt .regel {
display: flex; align-items: flex-start; gap: .8rem;
padding: .8rem 1rem;
background: var(--vlak); border: 1px solid var(--rand); border-radius: var(--straal);
}
.speelt .regel.let-op { border-color: var(--waarschuwing); background: var(--waarschuwing-zacht); }
.speelt .regel .teken { font-size: 1.2rem; line-height: 1.4; }
.raster { display: grid; gap: 1rem; grid-template-columns: 1fr; }
@media (min-width: 620px) { .raster.twee { grid-template-columns: 1fr 1fr; } }
/* ------------------------------------------------------------------ knoppen */
.knop {
display: inline-flex; align-items: center; justify-content: center; gap: .5rem;
min-height: 48px; padding: .7rem 1.3rem;
border: 1px solid transparent; border-radius: 10px;
background: var(--merk); color: var(--merk-tekst);
font: inherit; font-weight: 600; cursor: pointer; text-decoration: none;
}
/* Een knop mag nooit breder worden dan zijn omgeving; de tekst breekt dan maar af. */
.knop { max-width: 100%; }
@media (max-width: 420px) {
.knop { padding-left: .9rem; padding-right: .9rem; }
}
.knop:hover { background: var(--merk-donker); }
.knop:disabled { opacity: .55; cursor: default; }
.knop.vol { width: 100%; }
/* Kleine knop voor in een rij, zoals bij een familielid. Nog steeds groot genoeg om met een
duim te raken: 40 pixels is de ondergrens die je wilt aanhouden. */
.knop.klein { min-height: 40px; padding: .35rem .8rem; font-size: .9rem; font-weight: 500; }
.knop.rustig { background: transparent; color: var(--accent-donker); border-color: var(--rand); }
.knop.rustig:hover { border-color: var(--accent); background: var(--accent-zacht); }
.knop.gevaar { background: transparent; color: var(--fout); border-color: var(--rand); }
/* ------------------------------------------------------------------ formulieren */
label { display: block; font-weight: 500; margin-bottom: .3rem; font-size: .93rem; }
/* Alle invoervelden, ook die zonder expliciete type: een <input> zonder type is een
tekstveld, maar matcht input[type=text] niet. Zonder deze regel valt zo'n veld terug op
zijn standaardbreedte en loopt het op een telefoon buiten het scherm. */
input:not([type=checkbox]):not([type=radio]):not([type=file]):not([type=submit]),
select, textarea {
width: 100%; max-width: 100%; min-height: 48px;
padding: .65rem .8rem;
border: 1px solid var(--rand); border-radius: 10px;
background: var(--vlak); color: var(--inkt);
font: inherit;
}
textarea { min-height: 130px; resize: vertical; line-height: 1.55; }
/* Meegroeiend tekstvak (zie validatie.js): de hoogte komt uit de inhoud, niet uit min-height. */
textarea[data-meegroeit] { min-height: unset; overflow: hidden; resize: none; }
input:focus, select:focus, textarea:focus {
outline: none; border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-zacht);
}
.veld { margin-bottom: 1rem; }
/* Een veld dat niet goed is ingevuld: springNaarVeld() in validatie.js zet deze klasse erop,
scrollt het in beeld en geeft het de focus. Verdwijnt vanzelf zodra iemand er iets aan
verandert - zie validatie.js. */
.veld-fout { border-color: var(--fout) !important; box-shadow: 0 0 0 3px rgba(155, 59, 50, .15); }
/* Drie keuzelijsten naast elkaar voor een datum. Dag en jaar krijgen een vaste breedte, want
met 'flex' erop kromp het jaar op een telefoon tot "196" - het laatste cijfer viel weg.
De maand neemt wat er overblijft. */
.datumkiezer { display: flex; gap: .4rem; align-items: center; }
.datumkiezer select { min-width: 0; padding-left: .5rem; padding-right: .3rem; }
.datumkiezer .dag { flex: 0 0 4.2rem; }
.datumkiezer .maand { flex: 1 1 5rem; }
.datumkiezer .jaar { flex: 0 0 5.4rem; }
.veld .uitleg { font-size: .85rem; color: var(--inkt-zacht); margin-top: .3rem; }
.keuze { display: flex; align-items: flex-start; gap: .6rem; margin-bottom: .6rem; }
.keuze input { width: 20px; height: 20px; min-height: 0; margin-top: .25rem; flex: none; }
.keuze label { margin: 0; font-weight: 400; }
/* De geschiedenis van een profiel, onderaan het wijzigscherm: wanneer aangemaakt,
wanneer uitgenodigd, wanneer overgenomen. */
.geschiedenis { border-top: 1px solid var(--rand); margin-top: 1rem; padding-top: .8rem; }
.geschiedenis > div { margin-bottom: .2rem; }
/* Eén bijlage in het rijtje onder een kluisitem, persoon of bericht. */
.bijlage { border: 1px solid var(--rand); border-radius: 10px; padding: .5rem .6rem; }
.bijlage .teken { font-size: 1.2rem; line-height: 1; }
/* De naam en het tekentje samen zijn de knop om een bijlage te bekijken. Ziet eruit als tekst,
gedraagt zich als een knop - dat laatste is wat een schermlezer nodig heeft. */
.bijlage-openen {
display: flex; align-items: center; gap: .6rem;
border: none; background: none; padding: 0; margin: 0;
font: inherit; color: inherit; text-align: left; cursor: pointer;
min-width: 0;
}
.bijlage-openen:disabled { cursor: default; opacity: .6; }
.bijlage-openen > .groei { display: flex; flex-direction: column; min-width: 0; }
/* Een naam die niet past hoort af te breken, niet de knoppen ernaast weg te duwen. Een foto van
de camera heette eerder 824c9d46a1b749ab9f92986b - die past nergens in. */
.bijlage-openen .naam { overflow-wrap: anywhere; }
/* Het plaatje of filmpje in de kijkpopup. Hoogte begrensd, anders moet je op een telefoon
scrollen om de sluitknop terug te vinden. */
.bijlage-groot {
display: block; max-width: 100%; max-height: 60vh;
margin: 0 auto; border-radius: 10px;
}
/* Het voorbeeldje in de rij zelf, even groot als het tekentje ernaast zou zijn. Alleen de
website gebruikt dit; in de app zit het plaatje in de kijkpopup. */
.bijlage-klein {
width: 40px; height: 40px; flex: none; display: block;
border-radius: 8px; object-fit: cover;
}
.bijlage a { overflow-wrap: anywhere; }
/* ------------------------------------------------------------------ popup */
.popup-achtergrond {
position: fixed; inset: 0; z-index: 50;
background: rgba(20, 18, 16, .45);
display: flex; align-items: flex-end; justify-content: center;
padding: 1rem;
}
.popup {
width: 100%; max-width: 520px; max-height: 85dvh; overflow-y: auto;
background: var(--vlak); border-radius: var(--straal);
padding: 1.2rem 1.25rem 0;
box-shadow: var(--schaduw);
}
.popup h2 { margin-top: 0; }
/* Blijft onderin beeld staan terwijl de inhoud erboven scrolt - bij een lang formulier
(bijvoorbeeld een uitvaartwens met veel velden) zag je Opslaan en Annuleren anders pas na
helemaal naar beneden scrollen. sticky werkt hier omdat .popup zelf de scrollende ouder is
(overflow-y: auto hierboven); de negatieve margin trekt de balk weer tot de randen van de
popup, en de eigen achtergrond zorgt dat tekst er niet doorheen scrolt. */
.popup-knoppen {
position: sticky; bottom: 0; z-index: 1;
margin: 1rem -1.25rem 0; padding: .8rem 1.25rem calc(.8rem + env(safe-area-inset-bottom));
background: var(--vlak);
box-shadow: 0 -6px 10px -8px rgba(20, 18, 16, .35);
}
.popup-knoppen .knop { flex: 1 1 auto; }
/* De vaste Opslaan-knop op een volledig scherm (zoals Mijn profiel), niet in een popup. Hij
hoort net als bij een popup altijd in beeld te blijven, ook als het formulier eronder lang
is - anders scrol je een heel scherm omhoog om op te slaan. Op de telefoon zweeft hij boven
de onderbalk; op een groot scherm is er geen onderbalk meer, dus dan zakt hij naar onderen. */
.knop-vast-onder {
position: sticky; bottom: calc(4.7rem + env(safe-area-inset-bottom)); z-index: 5;
margin: 1rem -1.25rem 0; padding: .8rem 1.25rem;
background: var(--vlak);
box-shadow: 0 -6px 10px -8px rgba(20, 18, 16, .35);
}
@media (min-width: 720px) {
.knop-vast-onder { bottom: 0; }
}
/* Op een groter scherm hoort hij midden in beeld, niet onderaan. */
@media (min-width: 620px) {
.popup-achtergrond { align-items: center; }
}
/* ------------------------------------------------------------------ meldingen */
.melding {
border-radius: 10px; padding: .8rem 1rem; margin-bottom: 1rem;
border: 1px solid var(--rand); background: var(--vlak-zacht); font-size: .95rem;
}
.melding.fout { border-color: var(--fout); color: var(--fout); background: transparent; }
.melding.let-op { border-color: var(--waarschuwing); background: var(--waarschuwing-zacht); color: var(--waarschuwing); }
.melding.goed { border-color: var(--accent); background: var(--accent-zacht); color: var(--accent-donker); }
.label {
display: inline-block; font-size: .76rem; font-weight: 600;
padding: .18rem .6rem; border-radius: 999px;
background: var(--vlak-zacht); color: var(--inkt-zacht); border: 1px solid var(--rand);
}
.label.geclaimd { background: var(--accent-zacht); color: var(--accent-donker); border-color: var(--accent); }
.label.uitgenodigd { background: var(--waarschuwing-zacht); color: var(--waarschuwing); border-color: var(--waarschuwing); }
/* De ronde penning bij een persoon: zijn profielfoto, of anders zijn beginletters. Beide
precies even groot, zodat een lijst niet verspringt zodra er ergens een foto bij komt. */
.penning {
width: 44px; height: 44px; flex: none; border-radius: 50%;
background: var(--accent-zacht); color: var(--accent-donker);
display: flex; align-items: center; justify-content: center;
font-weight: 600; font-size: .95rem;
overflow: hidden;
}
/* object-fit: cover snijdt bij in plaats van uit te rekken - een portret dat staand is
gemaakt hoort niet breed getrokken te worden.
Let op de twee gevallen apart. Een img DIE de penning is houdt de 44 bij 44 hierboven; hem
hier op 100% zetten ging mis, want "img.penning" weegt zwaarder dan ".penning" en dan werd
de foto zo groot als de kaart. Een img BINNEN een penning vult hem juist wel. */
img.penning { object-fit: cover; display: block; }
.penning img { width: 100%; height: 100%; object-fit: cover; display: block; }
.leeg { text-align: center; padding: 2.5rem 1rem; color: var(--inkt-zacht); }
.leeg .teken { font-size: 2.2rem; display: block; margin-bottom: .5rem; opacity: .55; }
.voortgang { height: 8px; border-radius: 999px; background: var(--vlak-zacht); overflow: hidden; }
.voortgang > span { display: block; height: 100%; background: var(--merk); }
hr { border: none; border-top: 1px solid var(--rand); margin: 1.25rem 0; }
.middenblok { max-width: 420px; margin: 3rem auto; }
/* In de app hoort de kop bovenaan te beginnen. Die 3rem is voor een webpagina die zelf de hele
hoogte heeft; in de app zit er al een vaste balk boven, en dan keek je op een telefoon eerst
naar een leeg scherm voordat "Welkom terug" in beeld kwam. */
.schil.app .middenblok { margin-top: .5rem; }
#blazor-error-ui {
position: fixed; bottom: 0; left: 0; right: 0; z-index: 1000;
background: var(--waarschuwing-zacht); color: var(--waarschuwing);
padding: .8rem 1.2rem; display: none; box-shadow: var(--schaduw);
}
#blazor-error-ui .dismiss { cursor: pointer; float: right; }
/* --------------------------------------------------- inloggen met een code */
.codeveld {
font-size: 1.7rem; letter-spacing: .5rem; text-align: center;
font-family: "Segoe UI", system-ui, monospace; font-variant-numeric: tabular-nums;
width: 100%;
}
/* Op een smalle telefoon past zes cijfers met een halve rem ertussen net niet. */
@media (max-width: 420px) {
.codeveld { font-size: 1.45rem; letter-spacing: .3rem; padding-left: .4rem; padding-right: .4rem; }
}
/* De uitleg achter het vraagteken; werkt zonder javascript. */
.uitklap { margin: .4rem 0 1rem; }
.uitklap summary {
cursor: pointer; color: var(--accent-donker); font-size: .9rem;
list-style: none; display: inline-flex; align-items: center; gap: .4rem;
}
.uitklap summary::-webkit-details-marker { display: none; }
.uitklap summary::before {
content: "?"; width: 20px; height: 20px; border-radius: 50%;
background: var(--accent-zacht); color: var(--accent-donker);
display: inline-flex; align-items: center; justify-content: center;
font-weight: 700; font-size: .78rem; flex: none;
}
.uitklap p { margin: .6rem 0 0; font-size: .9rem; color: var(--inkt-zacht); }
/* Een knop die eruitziet als de uitklapper erboven. "Wat is MijnBesluit?" hoort daar visueel
bij - het zijn allebei vragen onderaan het overzicht - maar het is een knop en geen link,
want hij opent een popup en gaat nergens heen. */
.alsLink {
border: none; background: none; padding: 0; margin: 0; cursor: pointer;
font: inherit; font-size: .9rem; color: var(--accent-donker);
display: inline-flex; align-items: center; gap: .4rem;
}
.alsLink::before {
content: "?"; width: 20px; height: 20px; border-radius: 50%;
background: var(--accent-zacht); color: var(--accent-donker);
display: inline-flex; align-items: center; justify-content: center;
font-weight: 700; font-size: .78rem; flex: none;
}
/* Zonder het vraagtekentje, voor een knop die geen vraag is maar een uitweg - "toch helemaal
uitloggen" op het slotscherm. Onderstreept, zodat hij nog wel als iets aanwijsbaars leest. */
.alsLink.kaal::before { content: none; }
.alsLink.kaal { text-decoration: underline; }
.naar-adres { font-weight: 600; }
.voet { text-align: center; margin-top: 2rem; font-size: .9rem; }
.voet a { color: var(--inkt-zacht); }
/* Het logo boven het inloggen. Groot genoeg om het woordmerk te lezen, en gecentreerd omdat
het middenblok dat ook is. */
.merkgroot {
display: block; margin: 0 auto 1.2rem; width: 132px; height: auto;
}
/* Het scherm dat er staat als de app zichzelf heeft vergrendeld. Geen balk, geen navigatie,
niets van de app eronder: een grendel die je nog door het raam laat kijken is geen grendel. */
.slotscherm {
display: flex; flex-direction: column; justify-content: center;
min-height: 100dvh; max-width: 340px; margin: 0 auto;
padding: 2rem 1.25rem; text-align: center;
}
.slotscherm h1 { margin: 0 0 .4rem; }
.slotscherm p { margin: 0; }
/* De regel bovenaan Mijn kluis: de keuzelijst met daarnaast de knop Toevoegen.
align-items op stretch, zodat de twee even hoog worden zonder dat er ergens een vaste hoogte
staat. Met een vast getal loopt het uit elkaar zodra iemand zijn lettergrootte verandert; zo
volgen ze elkaar altijd. */
.rij.kluiskop { align-items: stretch; margin-bottom: 1rem; }
.rij.kluiskop > select { min-height: 0; }
.rij.kluiskop > .knop { min-height: 0; }
/* De advertentietegel op het overzicht.
Laag, met een lichte achtergrond, en zichtbaar het woord "Advertentie" erop. Dat laatste is
geen wettelijke formaliteit maar het punt zelf: een advertentie die zich voordoet als onderdeel
van de applicatie kost je meer vertrouwen dan hij oplevert. Zie docs/Adverteren.md.
Hij staat boven de tegels maar onder "wat er speelt". Een waarschuwing dat er een overlijden op
jouw naam is gemeld gaat altijd voor. */
.advertentie {
display: flex; align-items: center; gap: .9rem;
padding: .7rem .9rem; margin-bottom: 1rem;
background: var(--vlak-zacht); border: 1px solid var(--rand); border-radius: var(--straal);
text-decoration: none; color: inherit;
}
.advertentie:hover { border-color: var(--accent); }
.advertentie img {
width: 56px; height: 56px; flex: none; object-fit: cover; border-radius: 10px; display: block;
}
.advertentie .groei { min-width: 0; }
/* Twee regels en dan afgekapt: een advertentie mag het overzicht niet overnemen. */
.advertentie .tekst {
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
overflow: hidden; line-height: 1.35;
}
.advertentie .merkje {
font-size: .7rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase;
color: var(--inkt-zacht); display: block; margin-bottom: .15rem;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+127
View File
@@ -0,0 +1,127 @@
// Springt naar een veld dat niet goed is ingevuld: scrollt het in beeld, geeft het de focus en
// zet er een rode rand omheen tot iemand er iets aan verandert. Eén plek voor dit gedrag in
// plaats van dat elk scherm het zelf opnieuw uitvindt - zie .veld-fout in app.css.
window.mijnbesluit = window.mijnbesluit || {};
window.mijnbesluit.springNaarVeld = function (id) {
const veld = document.getElementById(id);
if (!veld) return;
veld.scrollIntoView({ behavior: "smooth", block: "center" });
veld.classList.add("veld-fout");
veld.focus({ preventScroll: true });
const weghalen = () => veld.classList.remove("veld-fout");
veld.addEventListener("input", weghalen, { once: true });
veld.addEventListener("change", weghalen, { once: true });
};
// Laat een tekstvak meegroeien met de inhoud - alleen nodig in de app: op de website is er
// al genoeg ruimte. Wordt telkens bij het renderen aangeroepen; het dataset-vlaggetje zorgt
// dat de listener maar één keer wordt gekoppeld.
window.mijnbesluit.laatMeegroeien = function (id) {
const veld = document.getElementById(id);
if (!veld || veld.dataset.meegroeit) return;
veld.dataset.meegroeit = "1";
const passenOpInhoud = () => {
veld.style.height = "auto";
veld.style.height = veld.scrollHeight + "px";
};
veld.addEventListener("input", passenOpInhoud);
passenOpInhoud();
};
// Trek naar beneden om te verversen - alleen op de schermen die de schil doorgeeft. Wordt één
// keer aangeroepen door AppSchil, voor de hele levensduur van de app; welk scherm er op dat
// moment binnen staat wordt bij elke aanraking opnieuw bekeken via de URL, niet onthouden.
window.mijnbesluit.pullToVernieuwen = function (containerId, dotNetRef, paden) {
const vak = document.getElementById(containerId);
if (!vak || vak.dataset.pullInit) return;
vak.dataset.pullInit = "1";
const drempel = 70;
let start = null, actief = false, bezig = false;
const indicator = document.createElement("div");
indicator.className = "ptr-indicator";
const icoon = document.createElement("span");
icoon.className = "ptr-icoon";
icoon.textContent = "↓"; // pijl naar beneden
indicator.appendChild(icoon);
const tekst = document.createElement("span");
tekst.textContent = "Trek naar beneden om te verversen";
indicator.appendChild(tekst);
vak.parentNode.insertBefore(indicator, vak);
const hierActief = () => paden.some(p => window.location.pathname.indexOf(p) >= 0);
// Niet vak.scrollTop: de schermen zelf scrollen niet, de pagina als geheel doet dat (de
// kop- en navigatiebalk staan vast met position: fixed erbovenop). "Bovenaan" betekent dus
// dat de pagina, niet dit ene element, niets heeft weggescrold.
const bovenaan = () => (document.scrollingElement || document.documentElement).scrollTop === 0;
// De kopbalk is niet overal even hoog - een scherm met een terugknop heeft een extra rij.
// Het vaantje hoort er altijd net onder te beginnen, dus die hoogte wordt bij elke aanraking
// opnieuw gemeten in plaats van uit één vaste waarde in de CSS te komen. Zonder dit stond het
// vaantje op een scherm met een hogere balk er middenin verstopt: de tekst liep dan door de
// balk heen in plaats van eronder te verschijnen.
const zetBovenkant = () => {
const kop = document.querySelector(".schil.app .kop.vast");
if (kop) indicator.style.top = kop.offsetHeight + "px";
};
const terug = () => {
indicator.style.height = "0px";
icoon.classList.remove("omgekeerd", "bezig");
start = null;
actief = false;
};
vak.addEventListener("touchstart", e => {
if (bezig || !bovenaan() || !hierActief()) { start = null; return; }
zetBovenkant();
start = e.touches[0].clientY;
}, { passive: true });
vak.addEventListener("touchmove", e => {
if (start === null || bezig) return;
const afstand = e.touches[0].clientY - start;
if (afstand > 0 && bovenaan()) {
actief = true;
const hoogte = Math.min(afstand, drempel * 1.5);
indicator.style.height = hoogte + "px";
const overDrempel = hoogte >= drempel;
icoon.classList.toggle("omgekeerd", overDrempel);
tekst.textContent = overDrempel ? "Laat los om te verversen"
: "Trek naar beneden om te verversen";
}
}, { passive: true });
vak.addEventListener("touchend", () => {
if (!actief) { terug(); return; }
const hoogte = parseInt(indicator.style.height || "0", 10);
if (hoogte >= drempel)
{
bezig = true;
indicator.style.height = drempel + "px";
icoon.classList.remove("omgekeerd");
icoon.classList.add("bezig");
tekst.textContent = "Bezig met verversen…";
dotNetRef.invokeMethodAsync("OpVernieuwenAsync").finally(() =>
{
bezig = false;
terug();
});
}
else
{
terug();
}
});
};
@@ -0,0 +1,31 @@
namespace MijnBesluit.Application.Abstractions;
/// <summary>
/// Maakt een kleine versie van een foto, voor in lijstjes.
///
/// Dit staat hier als afspraak en niet als code, om twee redenen. De eerste is de gewone: de
/// Application-laag hoort niet te weten met welke bibliotheek er geschaald wordt. De tweede is
/// praktischer - een afbeeldingsbibliotheek brengt platformafhankelijke onderdelen mee, en die
/// horen in de buitenste laag thuis.
///
/// Lukt het verkleinen niet, dan komt er null terug en gaat alles gewoon door: het scherm valt
/// dan terug op de beginletters. Een ontbrekend plaatje is nooit een reden om een foto niet op
/// te slaan.
/// </summary>
public interface IAfbeeldingVerkleiner
{
/// <summary>
/// Verkleint een afbeelding tot hoogstens <paramref name="maxZijde"/> pixels op de langste
/// zijde en geeft hem terug als JPEG. Null als het geen afbeelding is of als het niet lukt.
/// </summary>
byte[]? Verklein(byte[] inhoud, string soort, int maxZijde);
}
/// <summary>
/// Doet niets. Wordt gebruikt als er geen verkleiner is aangemeld - in de zelfcontroles
/// bijvoorbeeld, waar geen echte afbeeldingen langskomen.
/// </summary>
public sealed class GeenAfbeeldingVerkleiner : IAfbeeldingVerkleiner
{
public byte[]? Verklein(byte[] inhoud, string soort, int maxZijde) => null;
}
@@ -0,0 +1,12 @@
namespace MijnBesluit.Application.Abstractions;
/// <summary>Tijd als afhankelijkheid, zodat termijnen en bezorgmomenten te testen zijn.</summary>
public interface IClock
{
DateTime UtcNow { get; }
}
public sealed class SystemClock : IClock
{
public DateTime UtcNow => DateTime.UtcNow;
}
@@ -0,0 +1,17 @@
namespace MijnBesluit.Application.Abstractions;
/// <summary>
/// Beschermt de datasleutels van gebruikers met de mastersleutel van de server.
/// De implementatie bepaalt waar die mastersleutel staat: een sleutelbestand met
/// DPAPI-bescherming, een HSM of een sleutelkluis. Rotatie gebeurt via <see cref="CurrentKeyVersion"/>.
/// </summary>
public interface IMasterKeyProtector
{
int CurrentKeyVersion { get; }
/// <summary>Versleutelt een datasleutel met de huidige mastersleutel.</summary>
byte[] Protect(byte[] dataKey);
/// <summary>Ontsleutelt een datasleutel; herkent zelf met welke masterversie hij is ingepakt.</summary>
byte[] Unprotect(byte[] protectedDataKey);
}
@@ -0,0 +1,371 @@
using MijnBesluit.Domain.Entities;
using MijnBesluit.Domain.Enums;
namespace MijnBesluit.Application.Abstractions;
/// <summary>Slaat alle wijzigingen van deze bewerking in één transactie op.</summary>
public interface IUnitOfWork
{
Task<int> SaveChangesAsync(CancellationToken ct = default);
}
public interface IUserRepository
{
Task<User?> FindByIdAsync(long id, CancellationToken ct = default);
Task<User?> FindByEmailAsync(string email, CancellationToken ct = default);
Task<User?> FindByPersonIdAsync(long personId, CancellationToken ct = default);
Task<bool> EmailExistsAsync(string email, CancellationToken ct = default);
/// <summary>De beheerders. Nodig om ze te laten weten dat er iets op hen ligt te wachten.</summary>
Task<IReadOnlyList<User>> ListBeheerdersAsync(CancellationToken ct = default);
void Add(User user);
/// <summary>Verwijdert een account. Alleen bij het opheffen ervan; zie AccountOpheffenService.</summary>
void Remove(User user);
}
public interface ILoginCodeRepository
{
/// <summary>De nieuwste code die nog open staat voor dit adres en dit doel.</summary>
Task<LoginCode?> FindOpenAsync(string email, LoginCodePurpose purpose, DateTime nowUtc, CancellationToken ct = default);
/// <summary>Alle codes die nog open staan; gebruikt om oude ongeldig te maken bij een nieuwe aanvraag.</summary>
Task<IReadOnlyList<LoginCode>> ListOpenAsync(string email, LoginCodePurpose purpose, DateTime nowUtc, CancellationToken ct = default);
/// <summary>
/// Hoeveel codes er sinds een moment voor dit adres zijn aangevraagd waarmee NIET is
/// ingelogd; voor de rem op aanvragen. Een code waarmee iemand is binnengekomen telt niet
/// mee: die rem is er om te voorkomen dat iemand het postvak van een ander volgooit, en wie
/// de code invult zit zelf in dat postvak. Een code die is verdrongen door een nieuwe
/// aanvraag of die is stukgeraden telt wél mee - anders is de rem in twee stappen leeg te
/// trekken door een vreemde.
/// </summary>
Task<int> CountSinceAsync(string email, DateTime sinceUtc, CancellationToken ct = default);
/// <summary>Ruimt codes op die allang verlopen zijn.</summary>
Task<int> RemoveExpiredAsync(DateTime olderThanUtc, CancellationToken ct = default);
/// <summary>Ruimt alle codes van dit adres op. Voor het opheffen van een account.</summary>
Task<int> RemoveForEmailAsync(string email, CancellationToken ct = default);
void Add(LoginCode code);
}
public interface IPersonRepository
{
Task<Person?> FindByIdAsync(long id, CancellationToken ct = default);
/// <summary>
/// Eén profiel met dit adres. Er kunnen er meer zijn - twee mensen mogen allebei hun eigen
/// aantekening over dezelfde persoon hebben - dus dit geeft er één, bij voorkeur het profiel
/// dat de persoon zelf heeft geclaimd. Weet je wiens profiel je zoekt, gebruik dan
/// <see cref="ListByEmailAsync"/> en kies zelf.
/// </summary>
Task<Person?> FindByEmailAsync(string email, CancellationToken ct = default);
/// <summary>Alle profielen met dit adres, geclaimde eerst.</summary>
Task<IReadOnlyList<Person>> ListByEmailAsync(string email, CancellationToken ct = default);
/// <summary>
/// Aantekeningen die ánderen over dit adres hebben staan en die nog van niemand zijn.
///
/// Dit is de vraag "heeft iemand een profiel voor mij aangemaakt?" - gesteld door degene die
/// net bewezen heeft bij dat postvak te kunnen. Niet meegenomen: wat al geclaimd is, wat al
/// is opgegaan in een ander, wat deze gebruiker zelf beheert, en wat hij eerder heeft
/// weggewuifd. Zie docs/Identiteit.md.
/// </summary>
Task<IReadOnlyList<Person>> ListAantekeningenOverAdresAsync(string email, long gebruikerId,
CancellationToken ct = default);
Task<IReadOnlyList<Person>> ListByIdsAsync(IEnumerable<long> ids, CancellationToken ct = default);
/// <summary>Zoekt het profiel dat bij een bezwaarlink hoort, op de versleutelde vorm ervan.</summary>
Task<Person?> FindByObjectionTokenAsync(byte[] tokenHash, CancellationToken ct = default);
void Add(Person person);
void Remove(Person person);
}
public interface IFamilyRepository
{
Task<Family?> FindByIdAsync(long id, CancellationToken ct = default);
Task<IReadOnlyList<Family>> ListOwnedByPersonAsync(long personId, CancellationToken ct = default);
Task<IReadOnlyList<FamilyMember>> ListMembersAsync(long familyId, CancellationToken ct = default);
Task<FamilyMember?> FindMemberAsync(long familyId, long personId, CancellationToken ct = default);
/// <summary>Alle families waar deze persoon lid van is; zo verspreidt het netwerk zich.</summary>
Task<IReadOnlyList<Family>> ListFamiliesContainingAsync(long personId, CancellationToken ct = default);
/// <summary>
/// Iedereen met wie deze persoon in een familie zit, inclusief hijzelf. Dat is de kring
/// waarbinnen mensen elkaar kennen: wie hier niet in staat, heeft niets met deze persoon te
/// maken. In één vraag, want dit wordt op meerdere plekken als toegangsregel gebruikt.
/// </summary>
Task<IReadOnlyList<long>> ListCircleAsync(long personId, CancellationToken ct = default);
/// <summary>Alle lidmaatschappen van deze persoon, over alle families heen. Voor samenvoegen.</summary>
Task<IReadOnlyList<FamilyMember>> ListMembershipsOfPersonAsync(long personId, CancellationToken ct = default);
/// <summary>Alle families waarvan deze persoon de eigenaar is. Voor samenvoegen.</summary>
Task<IReadOnlyList<Family>> ListOwnedAsync(long personId, CancellationToken ct = default);
void Add(Family family);
void Add(FamilyMember member);
void Remove(FamilyMember member);
void Remove(Family family);
}
public interface IInvitationRepository
{
Task<Invitation?> FindByIdAsync(long id, CancellationToken ct = default);
Task<Invitation?> FindByTokenHashAsync(byte[] tokenHash, CancellationToken ct = default);
Task<IReadOnlyList<Invitation>> ListOpenForPersonAsync(long personId, CancellationToken ct = default);
Task<bool> HasOpenInvitationAsync(long personId, DateTime nowUtc, CancellationToken ct = default);
/// <summary>
/// De laatste uitnodiging per persoon, ongeacht de status. Bedoeld om te kunnen tonen
/// wanneer iemand is uitgenodigd - ook als die uitnodiging inmiddels verlopen of
/// aangenomen is. In één keer voor een hele familie, zodat een lijst met twintig mensen
/// niet twintig vragen aan de database kost.
/// </summary>
Task<IReadOnlyList<Invitation>> ListLatestForPeopleAsync(IEnumerable<long> personIds,
CancellationToken ct = default);
/// <summary>Alle uitnodigingen voor deze persoon, ongeacht de status. Voor samenvoegen.</summary>
Task<IReadOnlyList<Invitation>> ListAllForPersonAsync(long personId, CancellationToken ct = default);
/// <summary>Alle uitnodigingen die deze gebruiker heeft aangemaakt. Voor het opheffen van zijn account.</summary>
Task<IReadOnlyList<Invitation>> ListSentByUserAsync(long userId, CancellationToken ct = default);
void Add(Invitation invitation);
void Remove(Invitation invitation);
}
public interface IVaultRepository
{
Task<VaultItem?> FindByIdAsync(long id, CancellationToken ct = default);
Task<IReadOnlyList<VaultItem>> ListForPersonAsync(long personId, VaultCategory? category = null, CancellationToken ct = default);
Task<VaultFile?> FindFileAsync(long id, CancellationToken ct = default);
/// <summary>De bijlagen bij een kluisitem, nieuwste laatst.</summary>
Task<IReadOnlyList<VaultFile>> ListFilesForItemAsync(long vaultItemId, CancellationToken ct = default);
/// <summary>De foto's die bij een persoon horen, nieuwste laatst.</summary>
Task<IReadOnlyList<VaultFile>> ListFilesForPersonAsync(long subjectPersonId, CancellationToken ct = default);
/// <summary>Elk bestand dat deze persoon bewaart. Voor samenvoegen.</summary>
Task<IReadOnlyList<VaultFile>> ListFilesOwnedByAsync(long ownerPersonId, CancellationToken ct = default);
/// <summary>De bijlagen bij een bericht, nieuwste laatst.</summary>
Task<IReadOnlyList<VaultFile>> ListFilesForMessageAsync(long messageId, CancellationToken ct = default);
/// <summary>
/// De aangewezen profielfoto van elk van deze personen, voor zover die er is. In één vraag,
/// want dit wordt gebruikt om een lijst met personen te tonen.
/// </summary>
Task<IReadOnlyList<VaultFile>> ListProfilePhotosAsync(IEnumerable<long> personIds, CancellationToken ct = default);
Task<ContentGrant?> FindGrantAsync(string resourceType, long resourceId, long userId, CancellationToken ct = default);
/// <summary>Kluisitems die actief (niet ingetrokken) met deze gebruiker zijn gedeeld, ongeacht reden.</summary>
Task<IReadOnlyList<VaultItem>> ListSharedWithAsync(long userId, CancellationToken ct = default);
void Add(VaultItem item);
void Add(VaultFile file);
void Add(ContentGrant grant);
void Remove(VaultItem item);
void Remove(VaultFile file);
/// <summary>Haalt alle toegang weg die aan deze gebruiker is verleend. Voor het opheffen van zijn account.</summary>
Task RemoveGrantsForUserAsync(long userId, CancellationToken ct = default);
/// <summary>Haalt alle verleende toegang tot dit item weg. Nodig zodra de inhoud verandert of
/// verdwijnt: de sleutel die eerder is uitgedeeld hoort dan nergens meer bij.</summary>
Task RemoveGrantsAsync(long itemId, CancellationToken ct = default);
/// <summary>
/// Trekt de toegang in die om één bepaalde reden is verleend tot de items van deze eigenaar.
/// Gebruikt als een overlijden alsnog wordt tegengesproken: de beheerder die toegang kreeg,
/// hoort die dan meteen weer kwijt te zijn.
/// </summary>
Task<int> RevokeGrantsForOwnerAsync(long ownerPersonId, string reason, DateTime nowUtc,
CancellationToken ct = default);
}
public interface IMessageRepository
{
Task<Message?> FindByIdAsync(long id, CancellationToken ct = default);
Task<IReadOnlyList<Message>> ListOutboxAsync(long fromPersonId, CancellationToken ct = default);
Task<IReadOnlyList<Message>> ListInboxAsync(long toPersonId, CancellationToken ct = default);
/// <summary>Berichten die op of vóór <paramref name="nowUtc"/> bezorgd moeten worden.</summary>
Task<IReadOnlyList<Message>> ListDueAsync(DateTime nowUtc, int max, CancellationToken ct = default);
/// <summary>Berichten die wachten op het bevestigde overlijden van deze afzender.</summary>
Task<IReadOnlyList<Message>> ListAwaitingDeathOfAsync(long fromPersonId, CancellationToken ct = default);
/// <summary>Berichten die nog klaarstaan voor deze ontvanger, dus nog niet bezorgd zijn.</summary>
Task<IReadOnlyList<Message>> ListPlannedForAsync(long toPersonId, CancellationToken ct = default);
/// <summary>Elk bericht van of aan deze persoon, ongeacht de status. Voor samenvoegen.</summary>
Task<IReadOnlyList<Message>> ListAllTouchingAsync(long personId, CancellationToken ct = default);
/// <summary>Het bericht bij deze kijklink, voor de landingspagina van iemand zonder account.</summary>
Task<Message?> FindByViewTokenHashAsync(byte[] tokenHash, CancellationToken ct = default);
void Add(Message message);
void Remove(Message message);
}
public interface ILegacyRepository
{
Task<IReadOnlyList<DeathDeclaration>> ListDeclarationsAsync(long personId, CancellationToken ct = default);
Task<DeathDeclaration?> FindDeclarationByUserAsync(long personId, long userId, CancellationToken ct = default);
Task<IReadOnlyList<LegacyManager>> ListManagersForAsync(long personId, CancellationToken ct = default);
Task<IReadOnlyList<LegacyManager>> ListAssignmentsOfAsync(long managerPersonId, CancellationToken ct = default);
/// <summary>Elke overlijdensmelding over deze persoon, ook ingetrokken. Voor samenvoegen.</summary>
Task<IReadOnlyList<DeathDeclaration>> ListAllDeclarationsAsync(long personId, CancellationToken ct = default);
/// <summary>Alle overlijdensmeldingen die deze gebruiker heeft gedaan. Voor het opheffen van zijn account.</summary>
Task<IReadOnlyList<DeathDeclaration>> ListDeclarationsByUserAsync(long userId, CancellationToken ct = default);
void Remove(LegacyManager manager);
void Remove(DeathDeclaration declaration);
void Add(DeathDeclaration declaration);
void Add(LegacyManager manager);
}
/// <summary>
/// De bonnetjes van samenvoegingen. Alleen nodig om er een terug te kunnen draaien; nergens in
/// de schermen te zien behalve op de beheerpagina.
/// </summary>
public interface ISamenvoegRepository
{
Task<Samenvoeging?> FindByIdAsync(long id, CancellationToken ct = default);
/// <summary>Alles wat er ooit met dit profiel is gedaan, nieuwste eerst.</summary>
Task<IReadOnlyList<Samenvoeging>> ListVoorDuplicaatAsync(long duplicaatPersonId, CancellationToken ct = default);
/// <summary>De samenvoegingen waarbij dit profiel het doel was, nieuwste eerst.</summary>
Task<IReadOnlyList<Samenvoeging>> ListVoorDoelAsync(long doelPersonId, CancellationToken ct = default);
void Add(Samenvoeging samenvoeging);
}
/// <summary>
/// Tellingen voor de statistiektegel op de beheerpagina. Alleen aantallen; wat er in een
/// kluisitem of bericht staat komt hier nooit in voor.
/// </summary>
public interface IStatistiekRepository
{
Task<MijnBesluit.Application.Models.Statistieken> OphalenAsync(int groeidagen, DateTime nowUtc,
CancellationToken ct = default);
}
public interface IAuditLog
{
Task WriteAsync(AuditAction action, long? userId, string? entityType = null, long? entityId = null,
string? details = null, CancellationToken ct = default);
}
/// <summary>Versturen van uitnodigingen en meldingen.</summary>
/// <summary>
/// Adverteerders, hun campagnes en de tellers per uur. Zie docs/Adverteren.md.
/// </summary>
public interface IAdvertentieRepository
{
Task<Adverteerder?> FindAdverteerderAsync(long id, CancellationToken ct = default);
Task<Adverteerder?> FindAdverteerderByEmailAsync(string email, CancellationToken ct = default);
Task<IReadOnlyList<Adverteerder>> ListAdverteerdersAsync(CancellationToken ct = default);
void Add(Adverteerder adverteerder);
Task<Campagne?> FindCampagneAsync(long id, CancellationToken ct = default);
Task<IReadOnlyList<Campagne>> ListCampagnesAsync(long adverteerderId, CancellationToken ct = default);
/// <summary>Campagnes die op deze dag goedgekeurd en binnen hun periode zijn.</summary>
Task<IReadOnlyList<Campagne>> ListLopendAsync(DateOnly dag, CancellationToken ct = default);
/// <summary>Alles wat op de beheerder wacht: betaling of goedkeuring.</summary>
Task<IReadOnlyList<Campagne>> ListWachtendAsync(CancellationToken ct = default);
void Add(Campagne campagne);
Task<CampagneUur?> FindUurAsync(long campagneId, DateTime uurUtc, CancellationToken ct = default);
Task<IReadOnlyList<CampagneUur>> ListUrenAsync(long campagneId, CancellationToken ct = default);
/// <summary>
/// Hoe vaak elke campagne dit klokuur al is getoond. In één vraag, want dit gebeurt elke keer
/// dat iemand het overzicht opent - één vraag per campagne loopt daar uit de hand.
/// </summary>
Task<IReadOnlyDictionary<long, int>> VertoningenInUurAsync(DateTime uurUtc, IEnumerable<long> campagneIds,
CancellationToken ct = default);
/// <summary>
/// Telt er iets bij en maakt de regel aan als hij er nog niet is.
///
/// Dit hoort hier en niet in de dienst, omdat het in één opdracht naar de database moet: twee
/// bezoekers tegelijk zouden anders allebei een nieuwe regel aanmaken en op de unieke sleutel
/// stuklopen. De opslag lost dat op met INSERT ... ON DUPLICATE KEY UPDATE.
/// </summary>
Task TelAsync(long campagneId, DateTime uurUtc, int vertoningen, int kliks,
CancellationToken ct = default);
/// <summary>Wat er over de hele looptijd is getoond en aangeklikt.</summary>
Task<(int Vertoningen, int Kliks)> TotalenAsync(long campagneId, CancellationToken ct = default);
void Add(CampagneUur uur);
}
public interface IEmailSender
{
Task SendAsync(string toEmail, string subject, string htmlBody, CancellationToken ct = default);
}
/// <summary>Opslag van versleutelde bijlagen buiten de database.</summary>
/// <summary>Toestellen waarop iemand meldingen wil ontvangen.</summary>
/// <summary>
/// Aanmeldingen van de app, zodat ze een herstart van de SocketServer overleven.
///
/// Er gaat hier nooit een token doorheen, alleen de hash ervan: deze laag hoeft het token niet
/// te kennen en hoort het dus ook niet te krijgen.
/// </summary>
public interface ISessieRepository
{
Task<Sessie?> ZoekAsync(byte[] tokenHash, CancellationToken ct = default);
void Add(Sessie sessie);
/// <summary>Eén sessie eraf: uitloggen op dit toestel.</summary>
Task VerwijderAsync(byte[] tokenHash, CancellationToken ct = default);
/// <summary>Alle sessies van één gebruiker eraf, ook die op andere toestellen.</summary>
Task VerwijderAllesVanAsync(long userId, CancellationToken ct = default);
/// <summary>Ruimt verlopen sessies op; draait als achtergrondtaak.</summary>
Task<int> RuimVerlopenOpAsync(DateTime nu, CancellationToken ct = default);
}
public interface IToestelRepository
{
Task<Toestel?> FindByKenmerkAsync(string kenmerk, CancellationToken ct = default);
Task<IReadOnlyList<Toestel>> ListForUserAsync(long userId, CancellationToken ct = default);
void Add(Toestel toestel);
void Remove(Toestel toestel);
}
/// <summary>
/// Stuurt een melding naar een toestel. De echte uitvoering praat met Firebase; in de tests
/// wordt hij vervangen door iets dat alleen onthoudt wat er verstuurd zou zijn.
/// </summary>
public interface IPushSender
{
/// <summary>Is er iets ingesteld om mee te versturen? Zo niet, dan slaan we het over.</summary>
bool IsIngesteld { get; }
/// <summary>
/// Verstuurt één melding. Geeft terug of het kenmerk nog bruikbaar is: bij <c>false</c>
/// kent Google het toestel niet meer en hoort de regel afgemeld te worden.
/// </summary>
Task<bool> StuurAsync(string kenmerk, string titel, string tekst,
IReadOnlyDictionary<string, string>? gegevens = null,
CancellationToken ct = default);
}
public interface IFileStore
{
Task<string> WriteAsync(byte[] encryptedBytes, string suggestedName, CancellationToken ct = default);
Task<byte[]> ReadAsync(string storageKey, CancellationToken ct = default);
Task DeleteAsync(string storageKey, CancellationToken ct = default);
}
@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\MijnBesluit.Domain\MijnBesluit.Domain.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,33 @@
namespace MijnBesluit.Application.Models;
/// <summary>
/// Maakt het <c>Message-ID</c> van een uitgaande e-mail.
///
/// Waarom dit er is: .NET zet zelf geen Message-ID op een bericht. Veel mailservers vullen die
/// dan aan, maar niet allemaal. Gmail weigert sinds kort alles zonder geldige Message-ID met
/// <c>550-5.7.1 Messages missing a valid Message-ID header are not accepted</c> - en dan komt er
/// dus geen enkele inlogcode aan bij iemand met een gmail-adres.
///
/// De vorm is die uit RFC 5322: iets unieks, een apenstaartje, en een domein dat van jou is.
/// </summary>
public static class Berichtkenmerk
{
/// <summary>Maakt een nieuw Message-ID op basis van het afzenderadres.</summary>
public static string Nieuw(string? afzenderAdres)
{
var domein = Domein(afzenderAdres);
return $"<{Guid.NewGuid():N}.{DateTime.UtcNow:yyyyMMddHHmmss}@{domein}>";
}
/// <summary>Het deel achter het apenstaartje, of een vervanger als het adres niet deugt.</summary>
public static string Domein(string? adres)
{
if (string.IsNullOrWhiteSpace(adres)) return "mijnbesluit.nl";
var apenstaartje = adres.LastIndexOf('@');
if (apenstaartje < 0 || apenstaartje == adres.Length - 1) return "mijnbesluit.nl";
var domein = adres[(apenstaartje + 1)..].Trim().Trim('>').ToLowerInvariant();
return string.IsNullOrWhiteSpace(domein) ? "mijnbesluit.nl" : domein;
}
}
@@ -0,0 +1,26 @@
namespace MijnBesluit.Application.Models;
/// <summary>Uitkomst van een bewerking, met een foutmelding die direct aan de gebruiker getoond mag worden.</summary>
public readonly struct Result
{
public bool Succeeded { get; }
public string? Error { get; }
private Result(bool ok, string? error) { Succeeded = ok; Error = error; }
public static Result Ok() => new(true, null);
public static Result Fail(string error) => new(false, error);
}
/// <summary>Uitkomst met een waarde erbij.</summary>
public readonly struct Result<T>
{
public bool Succeeded { get; }
public string? Error { get; }
public T? Value { get; }
private Result(bool ok, T? value, string? error) { Succeeded = ok; Value = value; Error = error; }
public static Result<T> Ok(T value) => new(true, value, null);
public static Result<T> Fail(string error) => new(false, default, error);
}
@@ -0,0 +1,23 @@
using MijnBesluit.Domain.Enums;
namespace MijnBesluit.Application.Models;
/// <summary>Eén dagtotaal in de groeigrafiek van de beheerpagina.</summary>
public record GroeiPunt(DateOnly Datum, int NieuweProfielen, int NieuweKluisItems);
/// <summary>
/// Tellingen voor de statistiektegel op de beheerpagina. Puur aantallen, geen inhoud - dit is
/// bedoeld om de belasting te kunnen inschatten, niet om in iemands kluis te kijken. Een
/// beheerder mag dat toch al niet, zie BeheerService.
/// </summary>
public record Statistieken(
int Profielen,
int GeclaimdeProfielen,
int Families,
int KluisItems,
int KluisBestanden,
int Momenten,
int BezorgdeMomenten,
int Nalatenschapbeheerders,
IReadOnlyDictionary<VaultCategory, int> ItemsPerCategorie,
IReadOnlyList<GroeiPunt> GroeiPerDag);
@@ -0,0 +1,38 @@
namespace MijnBesluit.Application.Models;
/// <summary>
/// De versleutelde inhoud van een kluisitem. Per categorie wordt alleen ingevuld wat van toepassing is;
/// het geheel gaat als JSON versleuteld de database in, zodat er later velden bij kunnen zonder schemawijziging.
/// </summary>
public class VaultPayload
{
/// <summary>Vrije toelichting, voor alle categorieën.</summary>
public string? Notes { get; set; }
// Wachtwoord / apparaat / pincode
public string? Website { get; set; }
public string? Username { get; set; }
public string? Secret { get; set; }
public string? Pincode { get; set; }
public string? DeviceName { get; set; }
public string? RecoveryCodes { get; set; }
// Sociale media
/// <summary>Welk medium; een van SocialeMedia.Namen, of "Anders".</summary>
public string? Platform { get; set; }
/// <summary>Alleen gevuld als Platform "Anders" is: de naam die de gebruiker zelf invult.</summary>
public string? PlatformAnders { get; set; }
// Polis
public string? Insurer { get; set; }
public string? PolicyNumber { get; set; }
public string? ContactPhone { get; set; }
public decimal? Amount { get; set; }
// Uitvaartwens
public string? BurialOrCremation { get; set; }
public string? Location { get; set; }
public string? Music { get; set; }
public string? Speakers { get; set; }
public string? Wishes { get; set; }
}
@@ -0,0 +1,101 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace MijnBesluit.Application.Security;
/// <summary>Versleutelde inhoud: cijfertekst, nonce en authenticatietag horen altijd bij elkaar.</summary>
public readonly record struct SealedData(byte[] Ciphertext, byte[] Nonce, byte[] Tag);
/// <summary>
/// Envelope-encryptie met AES-256-GCM in drie lagen:
/// <list type="number">
/// <item>Elk item krijgt een eigen willekeurige contentsleutel (CEK).</item>
/// <item>De CEK wordt versleuteld met de datasleutel van de gebruiker (DEK).</item>
/// <item>De DEK wordt versleuteld met de mastersleutel van de server (zie <see cref="Abstractions.IMasterKeyProtector"/>).</item>
/// </list>
/// Zo hoeft er nooit een sleutel in leesbare vorm in de database te staan, en kan toegang
/// voor een ander (ontvanger van een bericht, nalatenschapsbeheerder) worden gegeven door
/// alleen de CEK opnieuw in te pakken.
/// </summary>
public static class EnvelopeCrypto
{
public const int KeySize = 32; // 256 bit
public const int NonceSize = 12; // 96 bit, de aanbevolen maat voor GCM
public const int TagSize = 16; // 128 bit
public static byte[] NewKey() => RandomNumberGenerator.GetBytes(KeySize);
/// <summary>Versleutelt bytes met een sleutel. <paramref name="associatedData"/> wordt meegeauthenticeerd maar niet versleuteld.</summary>
public static SealedData Seal(byte[] plaintext, byte[] key, byte[]? associatedData = null)
{
ValidateKey(key);
ArgumentNullException.ThrowIfNull(plaintext);
var nonce = RandomNumberGenerator.GetBytes(NonceSize);
var ciphertext = new byte[plaintext.Length];
var tag = new byte[TagSize];
using var aes = new AesGcm(key, TagSize);
aes.Encrypt(nonce, plaintext, ciphertext, tag, associatedData);
return new SealedData(ciphertext, nonce, tag);
}
/// <summary>Ontsleutelt en controleert de authenticatietag. Gooit bij geknoei met de data.</summary>
public static byte[] Open(SealedData sealedData, byte[] key, byte[]? associatedData = null)
{
ValidateKey(key);
var plaintext = new byte[sealedData.Ciphertext.Length];
using var aes = new AesGcm(key, TagSize);
aes.Decrypt(sealedData.Nonce, sealedData.Ciphertext, sealedData.Tag, plaintext, associatedData);
return plaintext;
}
/// <summary>Pakt een sleutel in met een andere sleutel; het resultaat is één blob van nonce + tag + cijfertekst.</summary>
public static byte[] WrapKey(byte[] key, byte[] wrappingKey)
{
var s = Seal(key, wrappingKey);
var blob = new byte[NonceSize + TagSize + s.Ciphertext.Length];
Buffer.BlockCopy(s.Nonce, 0, blob, 0, NonceSize);
Buffer.BlockCopy(s.Tag, 0, blob, NonceSize, TagSize);
Buffer.BlockCopy(s.Ciphertext, 0, blob, NonceSize + TagSize, s.Ciphertext.Length);
return blob;
}
/// <summary>Pakt een met <see cref="WrapKey"/> ingepakte sleutel weer uit.</summary>
public static byte[] UnwrapKey(byte[] blob, byte[] wrappingKey)
{
if (blob.Length <= NonceSize + TagSize)
throw new CryptographicException("Ingepakte sleutel is te kort of beschadigd.");
var nonce = new byte[NonceSize];
var tag = new byte[TagSize];
var ciphertext = new byte[blob.Length - NonceSize - TagSize];
Buffer.BlockCopy(blob, 0, nonce, 0, NonceSize);
Buffer.BlockCopy(blob, NonceSize, tag, 0, TagSize);
Buffer.BlockCopy(blob, NonceSize + TagSize, ciphertext, 0, ciphertext.Length);
return Open(new SealedData(ciphertext, nonce, tag), wrappingKey);
}
/// <summary>Versleutelt een object als JSON.</summary>
public static SealedData SealJson<T>(T value, byte[] key) =>
Seal(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(value, JsonOptions)), key);
/// <summary>Ontsleutelt JSON terug naar een object.</summary>
public static T? OpenJson<T>(SealedData sealedData, byte[] key) =>
JsonSerializer.Deserialize<T>(Encoding.UTF8.GetString(Open(sealedData, key)), JsonOptions);
internal static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
private static void ValidateKey(byte[] key)
{
if (key is null || key.Length != KeySize)
throw new CryptographicException($"Sleutel moet {KeySize} bytes zijn.");
}
}
@@ -0,0 +1,29 @@
using System.Security.Cryptography;
namespace MijnBesluit.Application.Security;
/// <summary>
/// Maakt de cijfercode die per e-mail wordt verstuurd.
///
/// Zes cijfers, niet vier. In de WinDev-versie was het <c>Random(1111,9999)</c>: ruim achtduizend
/// mogelijkheden. Met zes cijfers is dat een miljoen, wat raden zinloos maakt ook als er ooit iets
/// misgaat met de pogingenteller. Wil je terug naar vier, dan is dat hier één cijfer aanpassen.
/// </summary>
public static class InlogcodeGenerator
{
public const int Lengte = 6;
/// <summary>
/// Een code van zes cijfers, gelijkmatig verdeeld. Bewust niet <c>Random</c> uit de
/// standaardbibliotheek: die is voorspelbaar als je een paar uitkomsten kent.
/// </summary>
public static string Nieuw()
{
var bovengrens = (int)Math.Pow(10, Lengte);
return RandomNumberGenerator.GetInt32(0, bovengrens).ToString(new string('0', Lengte));
}
/// <summary>Haalt spaties en streepjes weg die mensen bij het overtypen invoegen.</summary>
public static string Normaliseer(string? ingevoerd) =>
new((ingevoerd ?? string.Empty).Where(char.IsDigit).ToArray());
}
@@ -0,0 +1,62 @@
using System.Security.Cryptography;
using System.Text;
namespace MijnBesluit.Application.Security;
/// <summary>
/// Wachtwoordhashing met PBKDF2-HMAC-SHA512. Formaat van de opgeslagen waarde:
/// <c>pbkdf2-sha512$&lt;iteraties&gt;$&lt;salt-base64&gt;$&lt;hash-base64&gt;</c>.
/// Het algoritme staat in de waarde zelf, zodat later naar Argon2id kan worden overgestapt
/// zonder bestaande wachtwoorden ongeldig te maken.
/// </summary>
public static class PasswordHasher
{
public const string Prefix = "pbkdf2-sha512";
/// <summary>OWASP-richtlijn voor PBKDF2-HMAC-SHA512.</summary>
public const int DefaultIterations = 210_000;
private const int SaltBytes = 16;
private const int HashBytes = 64;
public static string Hash(string password, int iterations = DefaultIterations)
{
if (string.IsNullOrEmpty(password)) throw new ArgumentException("Wachtwoord is leeg.", nameof(password));
var salt = RandomNumberGenerator.GetBytes(SaltBytes);
var hash = Derive(password, salt, iterations);
return $"{Prefix}${iterations}${Convert.ToBase64String(salt)}${Convert.ToBase64String(hash)}";
}
/// <summary>Controleert een wachtwoord in constante tijd.</summary>
public static bool Verify(string password, string encoded)
{
if (string.IsNullOrEmpty(password) || string.IsNullOrEmpty(encoded)) return false;
var parts = encoded.Split('$');
if (parts.Length != 4 || parts[0] != Prefix) return false;
if (!int.TryParse(parts[1], out var iterations) || iterations < 1000) return false;
byte[] salt, expected;
try
{
salt = Convert.FromBase64String(parts[2]);
expected = Convert.FromBase64String(parts[3]);
}
catch (FormatException) { return false; }
var actual = Derive(password, salt, iterations, expected.Length);
return CryptographicOperations.FixedTimeEquals(actual, expected);
}
/// <summary>Geeft true als de hash met verouderde instellingen is gemaakt en bij de volgende login vervangen moet worden.</summary>
public static bool NeedsUpgrade(string encoded)
{
var parts = encoded.Split('$');
if (parts.Length != 4 || parts[0] != Prefix) return true;
return !int.TryParse(parts[1], out var iterations) || iterations < DefaultIterations;
}
private static byte[] Derive(string password, byte[] salt, int iterations, int length = HashBytes) =>
Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(password), salt, iterations, HashAlgorithmName.SHA512, length);
}
@@ -0,0 +1,63 @@
using System.Security.Cryptography;
using System.Text;
namespace MijnBesluit.Application.Security;
/// <summary>
/// Hasht korte geheimen zoals inlogcodes met PBKDF2-HMAC-SHA512. Formaat van de opgeslagen waarde:
/// <c>pbkdf2-sha512$&lt;iteraties&gt;$&lt;salt-base64&gt;$&lt;hash-base64&gt;</c>.
///
/// Het algoritme staat in de waarde zelf, zodat er later op iets anders overgestapt kan worden
/// zonder wat er al ligt ongeldig te maken.
///
/// Waarom hashen we een code die maar een kwartier geldig is? Omdat een gestolen databasedump
/// anders elke openstaande code leesbaar maakt. Met een hash kan dat niet, ook al leeft de code
/// maar kort.
/// </summary>
public static class SecretHasher
{
public const string Prefix = "pbkdf2-sha512";
/// <summary>
/// Minder rondes dan bij een wachtwoord: een code leeft een kwartier, is eenmalig en er
/// mag maar een paar keer naar geraden worden. De rem zit dus in het aantal pogingen,
/// niet in de rekentijd.
/// </summary>
public const int DefaultIterations = 100_000;
private const int SaltBytes = 16;
private const int HashBytes = 64;
public static string Hash(string geheim, int iterations = DefaultIterations)
{
if (string.IsNullOrEmpty(geheim)) throw new ArgumentException("Geheim is leeg.", nameof(geheim));
var salt = RandomNumberGenerator.GetBytes(SaltBytes);
var hash = Derive(geheim, salt, iterations);
return $"{Prefix}${iterations}${Convert.ToBase64String(salt)}${Convert.ToBase64String(hash)}";
}
/// <summary>Controleert een geheim in constante tijd.</summary>
public static bool Verify(string geheim, string encoded)
{
if (string.IsNullOrEmpty(geheim) || string.IsNullOrEmpty(encoded)) return false;
var delen = encoded.Split('$');
if (delen.Length != 4 || delen[0] != Prefix) return false;
if (!int.TryParse(delen[1], out var iterations) || iterations < 1000) return false;
byte[] salt, verwacht;
try
{
salt = Convert.FromBase64String(delen[2]);
verwacht = Convert.FromBase64String(delen[3]);
}
catch (FormatException) { return false; }
var werkelijk = Derive(geheim, salt, iterations, verwacht.Length);
return CryptographicOperations.FixedTimeEquals(werkelijk, verwacht);
}
private static byte[] Derive(string geheim, byte[] salt, int iterations, int lengte = HashBytes) =>
Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(geheim), salt, iterations, HashAlgorithmName.SHA512, lengte);
}
@@ -0,0 +1,37 @@
using System.Security.Cryptography;
using System.Text;
namespace MijnBesluit.Application.Security;
/// <summary>
/// Genereert uitnodigings- en sessietokens. Alleen de hash gaat de database in;
/// het token zelf bestaat maar één keer, in de e-mail of bij de client.
/// </summary>
public static class TokenGenerator
{
/// <summary>Nieuw token als URL-veilige base64-tekst.</summary>
public static string Create(int byteLength = 32) =>
Convert.ToBase64String(RandomNumberGenerator.GetBytes(byteLength))
.Replace('+', '-').Replace('/', '_').TrimEnd('=');
/// <summary>SHA-256 van het token, zoals het in de database wordt bewaard.</summary>
public static byte[] HashToken(string token) =>
SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(token));
/// <summary>Vergelijking in constante tijd van een aangeboden token met een opgeslagen hash.</summary>
public static bool Matches(string token, byte[] storedHash) =>
storedHash.Length == 32 && CryptographicOperations.FixedTimeEquals(HashToken(token), storedHash);
/// <summary>
/// Leidt uit het token zelf een 256-bit sleutel af, los van <see cref="HashToken"/>.
///
/// Dat "los van" is de reden dat dit een aparte methode is en geen hergebruik van de hash:
/// die hash staat in de database om het token te herkennen. Was de afgeleide sleutel
/// daaraan gelijk, dan lag de sleutel waarmee een bericht is versleuteld gewoon naast de
/// hash in diezelfde database - dat maakt de versleuteling zinloos. <paramref name="doel"/>
/// zorgt er bovendien voor dat twee sleutels voor hetzelfde token, voor iets anders
/// gebruikt, elkaar niet gelijk zijn.
/// </summary>
public static byte[] DeriveKey(string token, string doel) =>
HMACSHA256.HashData(Encoding.UTF8.GetBytes(token), Encoding.UTF8.GetBytes(doel));
}
@@ -0,0 +1,317 @@
using MijnBesluit.Application.Abstractions;
using MijnBesluit.Application.Models;
using MijnBesluit.Domain.Entities;
using MijnBesluit.Domain.Enums;
namespace MijnBesluit.Application.Services;
/// <summary>
/// Een account opheffen: alles weghalen wat van deze gebruiker is.
///
/// Google Play eist dit van elke app waarin je een account kunt maken - in de app zelf én op een
/// webadres, voor wie de app niet meer heeft. Maar het is hier meer dan een eis van een winkel:
/// dit is een kluis, en wie hem sluit hoort erop te kunnen rekenen dat hij leeg is.
///
/// De regel waar alles op teruggaat: <b>weg is wat van jou is; blijven doet wat van een ander
/// is.</b> Dat klinkt vanzelfsprekend en is het niet, want in deze applicatie zitten die twee
/// dicht tegen elkaar aan. Per soort:
///
/// <list type="bullet">
/// <item><b>Je kluis</b> - items, bijlagen, de bestanden op schijf, en je sleutel. Weg. Zonder
/// die sleutel is er sowieso niets meer te openen, maar we laten de rijen niet staan: een lege
/// kluis is iets anders dan een onleesbare.</item>
///
/// <item><b>Berichten die nog niet bezorgd zijn</b> - weg. Ze stonden klaar en komen er nooit;
/// iemand die zijn account opheft heeft ze niet verstuurd.</item>
///
/// <item><b>Berichten die al bezorgd zijn</b> - blijven staan bij de ontvanger. Die heeft ze
/// gekregen, gelezen misschien, en ze zijn vanaf dat moment van hem. Ze blijven leesbaar ook:
/// bij het bezorgen krijgt de ontvanger zijn eigen kopie van de sleutel, dus jouw sleutel
/// weggooien maakt ze niet stuk. Dat dit kan is geen toeval - zie MessageService.</item>
///
/// <item><b>Berichten die anderen aan jou stuurden</b> - blijven van hen, maar jouw toegang
/// gaat eraf. Je staat in hun uitgaande lijst; dat weghalen zou hun aantekening wissen.</item>
///
/// <item><b>Je eigen families en de mensen die je daarin hebt gezet</b> - weg, voor zover die
/// mensen nergens anders aan hangen. Heeft iemand zelf een account genomen, of staat hij ook in
/// de lijst van een ander, dan blijft hij staan: dan is hij niet meer alleen jouw aantekening.</item>
///
/// <item><b>Jouw naam in de lijst van een ander</b> - blijft staan. Dat is zijn aantekening over
/// jou, niet jouw gegeven. Wat eraf gaat is alles wat er aan jou vastzat: je e-mailadres, je
/// geboortedatum, je foto, en het feit dat het profiel van jou wás. Wat overblijft is een naam
/// in andermans lijst, zoals iedereen die nog nooit van MijnBesluit heeft gehoord.</item>
///
/// <item><b>Nalatenschap</b> - je aanwijzingen vervallen, in beide richtingen: wie jij had
/// aangewezen en bij wie jij was aangewezen. Een beheerder zonder account kan niets, en dat
/// stilletjes laten staan zou iemand een zekerheid geven die er niet is. Je overlijdensmeldingen
/// over anderen vervallen ook - een opgeheven account kan nergens meer voor instaan.</item>
///
/// <item><b>Het logboek</b> - blijft. Dat is de enige tabel die nooit gewijzigd of gewist wordt,
/// en die is er juist voor de vraag achteraf wie wat wanneer deed. Er staat geen inhoud in.</item>
/// </list>
///
/// Er is geen weg terug en er is geen prullenbak. Daarom vraagt dit een verse code per e-mail:
/// een openstaande sessie op een telefoon die iemand anders in handen heeft, hoort een kluis
/// niet leeg te kunnen gooien.
/// </summary>
public class AccountOpheffenService
{
private readonly IUserRepository _users;
private readonly IPersonRepository _people;
private readonly IFamilyRepository _families;
private readonly IInvitationRepository _invitations;
private readonly IVaultRepository _vault;
private readonly IMessageRepository _messages;
private readonly ILegacyRepository _legacy;
private readonly ILoginCodeRepository _codes;
private readonly IToestelRepository _toestellen;
private readonly IFileStore _files;
private readonly IEmailSender _email;
private readonly IAuditLog _audit;
private readonly IUnitOfWork _uow;
private readonly IClock _clock;
public AccountOpheffenService(IUserRepository users, IPersonRepository people, IFamilyRepository families,
IInvitationRepository invitations, IVaultRepository vault,
IMessageRepository messages, ILegacyRepository legacy,
ILoginCodeRepository codes, IToestelRepository toestellen,
IFileStore files, IEmailSender email, IAuditLog audit,
IUnitOfWork uow, IClock clock)
{
_users = users;
_people = people;
_families = families;
_invitations = invitations;
_vault = vault;
_messages = messages;
_legacy = legacy;
_codes = codes;
_toestellen = toestellen;
_files = files;
_email = email;
_audit = audit;
_uow = uow;
_clock = clock;
}
/// <summary>Wat er weggaat, zodat het scherm het kan laten zien vóórdat iemand ja zegt.</summary>
public record Telling(int Kluisitems, int Bestanden, int BerichtenKlaar, int BerichtenBezorgd,
int Mensen, int Nalatenschappen);
/// <summary>
/// Telt op wat er verdwijnt. Leest alleen; er verandert hier niets.
///
/// Dit staat er omdat "weet je het zeker?" een lege vraag is. "Je kluis met 23 items, 4
/// berichten die nog klaarstonden" is een vraag waar iemand echt over nadenkt.
/// </summary>
public async Task<Result<Telling>> TelOpAsync(long gebruikerId, CancellationToken ct = default)
{
var gebruiker = await _users.FindByIdAsync(gebruikerId, ct);
if (gebruiker is null) return Result<Telling>.Fail("Account niet gevonden.");
var persoonId = gebruiker.PersonId;
var items = await _vault.ListForPersonAsync(persoonId, null, ct);
var bestanden = await _vault.ListFilesOwnedByAsync(persoonId, ct);
var berichten = await _messages.ListAllTouchingAsync(persoonId, ct);
var vanMij = berichten.Where(m => m.FromPersonId == persoonId).ToList();
var mensen = 0;
foreach (var familie in await _families.ListOwnedAsync(persoonId, ct))
mensen += (await _families.ListMembersAsync(familie.Id, ct)).Count(m => m.PersonId != persoonId);
var nalatenschappen = (await _legacy.ListAssignmentsOfAsync(persoonId, ct)).Count;
return Result<Telling>.Ok(new Telling(
items.Count,
bestanden.Count,
vanMij.Count(m => m.DeliveredAt is null),
vanMij.Count(m => m.DeliveredAt is not null),
mensen,
nalatenschappen));
}
/// <summary>
/// Heft het account op. Onomkeerbaar.
///
/// De aanroeper hoort eerst te hebben vastgesteld dat het echt deze persoon is - met een
/// verse code per e-mail. Deze dienst controleert dat niet zelf: hij weet niet hoe iemand
/// binnenkwam.
/// </summary>
public async Task<Result> VerwijderAsync(long gebruikerId, CancellationToken ct = default)
{
var gebruiker = await _users.FindByIdAsync(gebruikerId, ct);
if (gebruiker is null) return Result.Fail("Account niet gevonden.");
var persoon = await _people.FindByIdAsync(gebruiker.PersonId, ct);
if (persoon is null) return Result.Fail("Je profiel is niet gevonden.");
var adres = gebruiker.Email;
var naam = persoon.DisplayName;
var persoonId = persoon.Id;
var nu = _clock.UtcNow;
// Eerst het logboek, en pas daarna weghalen: daarna is er geen gebruiker meer om het op
// te schrijven. Dit is de enige rij die deze handeling overleeft.
await _audit.WriteAsync(AuditAction.AccountOpgeheven, gebruikerId, nameof(User), gebruikerId,
"account opgeheven op verzoek van de gebruiker", ct);
// ---------------------------------------------------------------- de kluis
foreach (var item in await _vault.ListForPersonAsync(persoonId, null, ct))
{
await _vault.RemoveGrantsAsync(item.Id, ct);
_vault.Remove(item);
}
// Alle bestanden van deze persoon, ook de losse: profielfoto's, bijlagen bij berichten.
// Eerst van schijf, dan de rij. Andersom zou bij een fout halverwege een bestand
// achterlaten waar niets meer naar wijst.
foreach (var bestand in await _vault.ListFilesOwnedByAsync(persoonId, ct))
{
if (!string.IsNullOrWhiteSpace(bestand.StorageKey))
{
// Een bestand dat al weg is hoort het opheffen niet tegen te houden.
try { await _files.DeleteAsync(bestand.StorageKey, ct); }
catch (Exception) { /* dan blijft er een verweesd bestand staan; de rij gaat wel weg */ }
}
_vault.Remove(bestand);
}
// ---------------------------------------------------------------- berichten
foreach (var bericht in await _messages.ListAllTouchingAsync(persoonId, ct))
{
// Van jou en nog niet bezorgd: weg. Van jou en al bezorgd: blijft bij de ontvanger,
// die heeft hem gekregen. Aan jou: blijft van de afzender; jouw toegang gaat
// hieronder in één keer eraf.
if (bericht.FromPersonId == persoonId && bericht.DeliveredAt is null)
_messages.Remove(bericht);
}
await _vault.RemoveGrantsForUserAsync(gebruikerId, ct);
// ---------------------------------------------------------------- nalatenschap
foreach (var rol in await _legacy.ListAssignmentsOfAsync(persoonId, ct)) _legacy.Remove(rol);
foreach (var rol in await _legacy.ListManagersForAsync(persoonId, ct)) _legacy.Remove(rol);
foreach (var melding in await _legacy.ListDeclarationsByUserAsync(gebruikerId, ct))
_legacy.Remove(melding);
foreach (var melding in await _legacy.ListAllDeclarationsAsync(persoonId, ct))
_legacy.Remove(melding);
// ---------------------------------------------------------------- uitnodigingen
foreach (var uitnodiging in await _invitations.ListSentByUserAsync(gebruikerId, ct))
_invitations.Remove(uitnodiging);
// Ook de uitnodigingen die aan jou gericht waren. Een openstaande uitnodiging is een
// sleutel tot een profiel; die hoort niet te blijven liggen bij een profiel dat van
// niemand meer is.
foreach (var uitnodiging in await _invitations.ListAllForPersonAsync(persoonId, ct))
_invitations.Remove(uitnodiging);
// ---------------------------------------------------------------- toestellen en codes
foreach (var toestel in await _toestellen.ListForUserAsync(gebruikerId, ct)) _toestellen.Remove(toestel);
await _codes.RemoveForEmailAsync(adres, ct);
// ---------------------------------------------------------------- families
await _uow.SaveChangesAsync(ct);
foreach (var familie in await _families.ListOwnedAsync(persoonId, ct))
{
foreach (var lid in await _families.ListMembersAsync(familie.Id, ct))
{
var vanJou = lid.PersonId != persoonId
? await _people.FindByIdAsync(lid.PersonId, ct)
: null;
_families.Remove(lid);
// Een aantekening die alleen aan deze familie hing, gaat mee weg. Hangt er meer
// aan - de persoon heeft zelf een account genomen, staat ook in de lijst van een
// ander, of er liggen berichten - dan blijft hij staan. Dan is het niet meer
// alleen jouw aantekening.
if (vanJou is not null && await IsAlleenVanJouAsync(vanJou, persoonId, familie.Id, ct))
_people.Remove(vanJou);
}
_families.Remove(familie);
}
await _uow.SaveChangesAsync(ct);
// ---------------------------------------------------------------- jij zelf
// Staat er nergens meer iets naar dit profiel te wijzen, dan gaat het profiel zelf ook
// weg. Is dat niet zo - je staat in de lijst van iemand anders, of er ligt een bezorgd
// bericht van je - dan blijft de rij staan met alleen nog de naam erin. Die naam is de
// aantekening van een ander over jou.
var nogInGebruik = (await _families.ListMembershipsOfPersonAsync(persoonId, ct)).Count > 0 ||
(await _messages.ListAllTouchingAsync(persoonId, ct)).Count > 0;
if (nogInGebruik)
{
persoon.Email = null;
persoon.MobileNumber = null;
persoon.BirthDate = null;
persoon.Nickname = null;
persoon.ClaimedByUserId = null;
persoon.ClaimedAt = null;
persoon.ManagedByUserId = null;
persoon.CreatedByUserId = null;
persoon.ObjectionTokenHash = null;
persoon.ObjectionTokenExpiresAt = null;
persoon.UpdatedAt = nu;
}
else
{
_people.Remove(persoon);
}
_users.Remove(gebruiker);
await _uow.SaveChangesAsync(ct);
// Pas melden als het echt weg is. Een bevestiging van iets dat halverwege strandde is
// erger dan geen bevestiging.
try
{
await _email.SendAsync(adres, "Je MijnBesluit is opgeheven",
$"<p>Dag {Veilig(naam)},</p>" +
"<p>Je account bij MijnBesluit is opgeheven. Je kluis, je bestanden en de berichten " +
"die nog klaarstonden zijn verwijderd. Wat al bezorgd was, blijft bij degene die het " +
"heeft gekregen.</p>" +
"<p>Je kunt op elk moment opnieuw beginnen met hetzelfde e-mailadres; er staat niets " +
"meer van vroeger klaar.</p>" +
"<p>Met vriendelijke groet,<br>het MijnBesluit-team</p>", ct);
}
catch (Exception) { /* Het account is weg; een bericht dat niet aankomt verandert dat niet. */ }
return Result.Ok();
}
/// <summary>
/// Hangt deze persoon alleen aan de familie die nu wordt opgeheven? Zo ja, dan was het puur
/// jouw aantekening en gaat hij mee. Zo nee, dan is hij van meer mensen dan van jou alleen.
/// </summary>
private async Task<bool> IsAlleenVanJouAsync(Person persoon, long mijnPersoonId, long familieId,
CancellationToken ct)
{
if (persoon.Id == mijnPersoonId) return false;
if (persoon.ClaimedByUserId is not null) return false; // hij heeft zelf een account
var lidmaatschappen = await _families.ListMembershipsOfPersonAsync(persoon.Id, ct);
if (lidmaatschappen.Any(l => l.FamilyId != familieId)) return false;
if ((await _messages.ListAllTouchingAsync(persoon.Id, ct)).Count > 0) return false;
if ((await _legacy.ListAssignmentsOfAsync(persoon.Id, ct)).Count > 0) return false;
if ((await _legacy.ListManagersForAsync(persoon.Id, ct)).Count > 0) return false;
// Zijn eigen kluis en bestanden horen bij hém, niet bij jou. Heeft hij die, dan blijft
// hij staan - ook al is hij nooit toegekomen aan het aannemen van je uitnodiging.
if ((await _vault.ListForPersonAsync(persoon.Id, null, ct)).Count > 0) return false;
if ((await _vault.ListFilesOwnedByAsync(persoon.Id, ct)).Count > 0) return false;
return true;
}
private static string Veilig(string tekst) =>
tekst.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
}
@@ -0,0 +1,809 @@
using System.Net.Mail;
using MijnBesluit.Application.Abstractions;
using MijnBesluit.Application.Models;
using MijnBesluit.Application.Security;
using MijnBesluit.Domain.Entities;
using MijnBesluit.Domain.Enums;
namespace MijnBesluit.Application.Services;
/// <summary>Wat het scherm moet weten nadat er een code is verstuurd.</summary>
public record CodeVerstuurd(string Email, DateTime GeldigTot)
{
/// <summary>Het adres deels afgeschermd, om op het scherm te tonen: <c>j***n@kwik-bit.nl</c>.</summary>
public string AfgeschermdAdres
{
get
{
var apenstaartje = Email.IndexOf('@');
if (apenstaartje <= 1) return Email;
var naam = Email[..apenstaartje];
var zichtbaar = naam.Length <= 2 ? naam[..1] : naam[..1] + new string('*', naam.Length - 2) + naam[^1..];
return zichtbaar + Email[apenstaartje..];
}
}
}
/// <summary>Wat er bij een adverteerderscode is meegegeven voor een nieuw account.</summary>
public record Aanmeldgegevens(string Bedrijfsnaam, string Contactpersoon);
/// <summary>Je eigen gegevens, zoals het profielscherm ze toont.</summary>
public record EigenProfiel(long PersoonId, string Voornaam, string Achternaam, string Email,
DateOnly? Geboortedatum, DateTime LidSinds);
/// <summary>
/// Inloggen en registreren met een code per e-mail. Er zijn geen wachtwoorden: wie bij het
/// e-mailadres kan, kan inloggen.
///
/// De gang van zaken is dezelfde als in de WinDev-versie — e-mailadres invullen, code aanvragen,
/// code invullen — met twee verschillen die bewust zijn gemaakt:
///
/// <list type="bullet">
/// <item>De code staat niet leesbaar in de database en wordt nooit naar de browser gestuurd.
/// In de WinDev-versie stond hij in <c>mlPeople.ActivationCode</c> en werd hij in een
/// paginavariabele meegegeven aan de browser; wie de paginabron bekeek, kon zo inloggen.</item>
/// <item>Zes cijfers in plaats van vier, met een rem op het aantal pogingen en op het aantal
/// aanvragen per uur.</item>
/// </list>
/// </summary>
public class AccountService
{
/// <summary>Hoe lang een code bruikbaar blijft.</summary>
public static readonly TimeSpan CodeGeldigheid = TimeSpan.FromMinutes(15);
/// <summary>Zoveel keer mag er per code fout geraden worden; daarna moet er een nieuwe komen.</summary>
public const int MaxPogingen = 5;
/// <summary>Zoveel codes per e-mailadres per uur, zodat niemands postvak volloopt.</summary>
public const int MaxAanvragenPerUur = 5;
/// <summary>Zo lang moet er tussen twee aanvragen zitten.</summary>
public static readonly TimeSpan MinimaleWachttijd = TimeSpan.FromSeconds(30);
private readonly IUserRepository _users;
private readonly IPersonRepository _people;
private readonly IFamilyRepository _families;
private readonly ILoginCodeRepository _codes;
private readonly IMasterKeyProtector _keys;
private readonly IEmailSender _email;
private readonly IAuditLog _audit;
private readonly IUnitOfWork _uow;
private readonly IClock _clock;
private readonly IVaultRepository _vault;
public AccountService(IUserRepository users, IPersonRepository people, IFamilyRepository families,
ILoginCodeRepository codes, IMasterKeyProtector keys, IEmailSender email,
IAuditLog audit, IUnitOfWork uow, IClock clock, IVaultRepository vault)
{
_users = users; _people = people; _families = families; _codes = codes;
_keys = keys; _email = email; _audit = audit; _uow = uow; _clock = clock; _vault = vault;
}
// ------------------------------------------------------------------ code aanvragen
/// <summary>
/// Stuurt een inlogcode naar een bestaand account.
/// </summary>
public async Task<Result<CodeVerstuurd>> VraagInlogcodeAanAsync(string email, string? vanafIp = null,
CancellationToken ct = default)
{
email = Normaliseer(email);
if (!IsGeldigAdres(email))
return Result<CodeVerstuurd>.Fail("Vul een geldig e-mailadres in a.u.b.");
var gebruiker = await _users.FindByEmailAsync(email, ct);
if (gebruiker is null)
return Result<CodeVerstuurd>.Fail(
"We kennen dit e-mailadres niet. Ben je nieuw? Maak dan eerst een account aan.");
if (gebruiker.Status != UserStatus.Actief)
return Result<CodeVerstuurd>.Fail("Dit is geen actief gebruikersaccount.");
return await StuurCodeAsync(email, LoginCodePurpose.Inloggen, null, null, vanafIp, ct);
}
/// <summary>
/// Stuurt een code om een nieuw account aan te maken. De naam wordt bij de code bewaard en
/// pas gebruikt zodra de code klopt.
/// </summary>
public async Task<Result<CodeVerstuurd>> VraagRegistratiecodeAanAsync(string email, string voornaam,
string achternaam, string? vanafIp = null,
CancellationToken ct = default)
{
email = Normaliseer(email);
if (!IsGeldigAdres(email))
return Result<CodeVerstuurd>.Fail("Vul een geldig e-mailadres in a.u.b.");
if (string.IsNullOrWhiteSpace(voornaam))
return Result<CodeVerstuurd>.Fail("Vul je voornaam in.");
if (await _users.EmailExistsAsync(email, ct))
return Result<CodeVerstuurd>.Fail(
"Er bestaat al een account met dit e-mailadres. Log in met een code.");
return await StuurCodeAsync(email, LoginCodePurpose.Registreren, voornaam.Trim(), achternaam.Trim(), vanafIp, ct);
}
/// <summary>
/// Stuurt een code naar iemand die wil adverteren. Bewust een eigen doel: een code voor het
/// adverteerdersgedeelte hoort nooit ergens anders te werken, en andersom net zo min.
///
/// Er hoeft hier géén account te bestaan. Wie voor het eerst komt vult daarna zijn
/// bedrijfsgegevens in; wie terugkomt ziet zijn campagnes. Dat betekent ook dat iemand een
/// code kan laten sturen naar een adres dat niet van hem is - daar zijn dezelfde remmen voor
/// als bij het gewone inloggen: één per 30 seconden, vijf per uur.
/// </summary>
/// <param name="bedrijfsnaam">
/// Alleen bij een nieuw account. Wordt bij de code bewaard en pas gebruikt zodra die klopt -
/// net als de naam bij het registreren van een gebruiker. Zo hoeft het scherm niets te
/// onthouden tussen twee formulieren door, en kan niemand zich een bedrijfsnaam aanmeten
/// zonder eerst bij het e-mailadres te kunnen.
/// </param>
public Task<Result<CodeVerstuurd>> VraagAdverteerderscodeAanAsync(string email, string? bedrijfsnaam = null,
string? contactpersoon = null,
string? vanafIp = null,
CancellationToken ct = default)
{
email = Normaliseer(email);
if (!IsGeldigAdres(email))
return Task.FromResult(Result<CodeVerstuurd>.Fail("Vul een geldig e-mailadres in a.u.b."));
return StuurCodeAsync(email, LoginCodePurpose.Adverteerder, bedrijfsnaam?.Trim(),
contactpersoon?.Trim(), vanafIp, ct);
}
/// <summary>
/// Kijkt of de code van een adverteerder klopt en stempelt hem af. Geeft de bedrijfsnaam en
/// contactpersoon terug die bij de aanvraag zijn meegegeven; die zijn leeg als iemand gewoon
/// opnieuw inlogt.
/// </summary>
public async Task<Result<Aanmeldgegevens>> ControleerAdverteerderscodeAsync(
string email, string code, CancellationToken ct = default)
{
email = Normaliseer(email);
// Ophalen vóór het afstempelen: daarna is de regel niet meer open en zijn de gegevens
// niet meer te vinden.
var openstaand = await _codes.FindOpenAsync(email, LoginCodePurpose.Adverteerder, _clock.UtcNow, ct);
var gegevens = new Aanmeldgegevens(openstaand?.FirstName ?? "", openstaand?.LastName ?? "");
var controle = await ControleerCodeAsync(email, code, LoginCodePurpose.Adverteerder, ct);
return controle.Succeeded ? Result<Aanmeldgegevens>.Ok(gegevens)
: Result<Aanmeldgegevens>.Fail(controle.Error!);
}
/// <summary>
/// Stuurt een code waarmee iemand zijn account kan opheffen. Een eigen doel, zodat deze code
/// nergens anders voor werkt en een gewone inlogcode hier niet volstaat.
///
/// Waarom er überhaupt een code aan te pas komt: opheffen is onomkeerbaar, en een openstaande
/// sessie op een telefoon die iemand anders in handen heeft hoort een kluis niet leeg te
/// kunnen gooien. Je moet dus op dat moment bij het e-mailadres kunnen.
/// </summary>
public async Task<Result<CodeVerstuurd>> VraagOpheffencodeAanAsync(string email, string? vanafIp = null,
CancellationToken ct = default)
{
email = Normaliseer(email);
if (!IsGeldigAdres(email))
return Result<CodeVerstuurd>.Fail("Vul een geldig e-mailadres in a.u.b.");
// Bestaat het account niet, dan zeggen we dat gewoon: dit scherm is alleen te bereiken
// door wie al is ingelogd, of door wie zelf zijn adres intypt op de openbare pagina. In
// dat tweede geval helpt "we kennen dit adres niet" iemand die een typefout maakt, en
// levert het niemand iets op wat hij niet al kon weten door een code aan te vragen.
var gebruiker = await _users.FindByEmailAsync(email, ct);
if (gebruiker is null)
return Result<CodeVerstuurd>.Fail("We kennen dit e-mailadres niet.");
return await StuurCodeAsync(email, LoginCodePurpose.Opheffen, null, null, vanafIp, ct);
}
/// <summary>Kijkt of de opheffingscode klopt en stempelt hem af. Geeft het account terug.</summary>
public async Task<Result<User>> ControleerOpheffencodeAsync(string email, string code,
CancellationToken ct = default)
{
email = Normaliseer(email);
var controle = await ControleerCodeAsync(email, code, LoginCodePurpose.Opheffen, ct);
if (!controle.Succeeded) return Result<User>.Fail(controle.Error!);
var gebruiker = await _users.FindByEmailAsync(email, ct);
return gebruiker is null ? Result<User>.Fail("Account niet gevonden.") : Result<User>.Ok(gebruiker);
}
private async Task<Result<CodeVerstuurd>> StuurCodeAsync(string email, LoginCodePurpose doel, string? voornaam,
string? achternaam, string? vanafIp, CancellationToken ct)
{
var nu = _clock.UtcNow;
// Rem op het aantal aanvragen: anders is dit een manier om iemands postvak vol te gooien.
var recent = await _codes.ListOpenAsync(email, doel, nu, ct);
var laatste = recent.OrderByDescending(c => c.CreatedAt).FirstOrDefault();
if (laatste is not null && nu - laatste.CreatedAt < MinimaleWachttijd)
{
var seconden = (int)Math.Ceiling((MinimaleWachttijd - (nu - laatste.CreatedAt)).TotalSeconds);
return Result<CodeVerstuurd>.Fail($"Er is net een code verstuurd. Wacht nog {seconden} seconden.");
}
// Alleen codes die NIET zijn gebruikt tellen mee. Deze rem is er om te voorkomen dat
// iemand het postvak van een ander volgooit, en wie de code invult zit zelf in dat
// postvak - dat is geen misbruik maar een geslaagde aanmelding. Telden gebruikte codes
// wel mee, dan sloot de app zichzelf buiten na vijf keer inloggen in een uur, precies
// wanneer er iets anders aan de hand was en je het een paar keer opnieuw probeerde.
if (await _codes.CountSinceAsync(email, nu.AddHours(-1), ct) >= MaxAanvragenPerUur)
return Result<CodeVerstuurd>.Fail(
"Er zijn te veel codes aangevraagd voor dit e-mailadres. Probeer het over een uur opnieuw.");
var code = InlogcodeGenerator.Nieuw();
var geldigTot = nu.Add(CodeGeldigheid);
// Eerst versturen, dan pas vastleggen. Lukt het versturen niet, dan verandert er niets:
// geen code die klaarstaat maar nooit aankomt, en de vorige code blijft gewoon geldig.
// De oorzaak van de fout staat in het logboek van de server.
try
{
await _email.SendAsync(email, BouwCodeOnderwerp(code, doel), BouwCodeMail(code, doel), ct);
}
catch (Exception)
{
return Result<CodeVerstuurd>.Fail(
"We konden de e-mail met je code niet versturen. Probeer het zo nog eens; " +
"blijft het misgaan, dan klopt er iets niet aan de e-mailinstellingen.");
}
// Een nieuwe code maakt de vorige ongeldig; anders zouden er meerdere tegelijk werken.
foreach (var oud in recent) oud.ConsumedAt = nu;
_codes.Add(new LoginCode
{
Email = email,
Purpose = doel,
CodeHash = SecretHasher.Hash(code),
FirstName = voornaam,
LastName = achternaam,
CreatedAt = nu,
ExpiresAt = geldigTot,
RequestedFromIp = vanafIp
});
await _audit.WriteAsync(AuditAction.InlogcodeAangevraagd, null, nameof(LoginCode), null, email, ct);
await _uow.SaveChangesAsync(ct);
return Result<CodeVerstuurd>.Ok(new CodeVerstuurd(email, geldigTot));
}
// ------------------------------------------------------------------ code gebruiken
/// <summary>Logt in met een code. De code werkt één keer.</summary>
public async Task<Result<User>> LogInMetCodeAsync(string email, string code, CancellationToken ct = default)
{
email = Normaliseer(email);
var controle = await ControleerCodeAsync(email, code, LoginCodePurpose.Inloggen, ct);
if (!controle.Succeeded) return Result<User>.Fail(controle.Error!);
var nu = _clock.UtcNow;
var gebruiker = await _users.FindByEmailAsync(email, ct);
if (gebruiker is null) return Result<User>.Fail("Dit account bestaat niet meer.");
if (gebruiker.Status != UserStatus.Actief) return Result<User>.Fail("Dit is geen actief gebruikersaccount.");
gebruiker.FailedLoginCount = 0;
gebruiker.LockedUntil = null;
gebruiker.LastLoginAt = nu;
gebruiker.UpdatedAt = nu;
gebruiker.EmailConfirmedAt ??= nu;
await _audit.WriteAsync(AuditAction.Inloggen, gebruiker.Id, nameof(User), gebruiker.Id, null, ct);
await _uow.SaveChangesAsync(ct);
return Result<User>.Ok(gebruiker);
}
/// <summary>Maakt het account aan zodra de registratiecode klopt.</summary>
public async Task<Result<User>> RegistreerMetCodeAsync(string email, string code, CancellationToken ct = default)
{
email = Normaliseer(email);
// De naam is bij de code bewaard, dus die halen we op vóórdat hij wordt afgestempeld.
var openstaand = await _codes.FindOpenAsync(email, LoginCodePurpose.Registreren, _clock.UtcNow, ct);
var voornaam = openstaand?.FirstName ?? string.Empty;
var achternaam = openstaand?.LastName ?? string.Empty;
var controle = await ControleerCodeAsync(email, code, LoginCodePurpose.Registreren, ct);
if (!controle.Succeeded) return Result<User>.Fail(controle.Error!);
return await MaakAccountAsync(email, voornaam, achternaam, ct);
}
/// <summary>
/// Kijkt of de ingevulde code klopt en stempelt hem af. Telt mislukte pogingen, zodat er niet
/// eindeloos geraden kan worden.
/// </summary>
private async Task<Result> ControleerCodeAsync(string email, string ingevoerd, LoginCodePurpose doel,
CancellationToken ct)
{
var nu = _clock.UtcNow;
var code = InlogcodeGenerator.Normaliseer(ingevoerd);
if (string.IsNullOrEmpty(code)) return Result.Fail("Je moet een code invullen!");
var openstaand = await _codes.FindOpenAsync(email, doel, nu, ct);
if (openstaand is null)
{
await _audit.WriteAsync(AuditAction.InloggenMislukt, null, nameof(LoginCode), null, email, ct);
await _uow.SaveChangesAsync(ct);
return Result.Fail("Deze code is verlopen of al gebruikt. Vraag een nieuwe code aan.");
}
if (!SecretHasher.Verify(code, openstaand.CodeHash))
{
openstaand.Attempts++;
var resterend = MaxPogingen - openstaand.Attempts;
if (resterend <= 0)
{
openstaand.ConsumedAt = nu;
await _audit.WriteAsync(AuditAction.InloggenMislukt, null, nameof(LoginCode), openstaand.Id, email, ct);
await _uow.SaveChangesAsync(ct);
return Result.Fail("De code is te vaak fout ingevuld. Vraag een nieuwe code aan.");
}
await _uow.SaveChangesAsync(ct);
return Result.Fail(resterend == 1
? "Deze code klopt niet. Je hebt nog één poging."
: $"Deze code klopt niet. Je hebt nog {resterend} pogingen.");
}
// Twee velden, en dat is met opzet. ConsumedAt zegt "werkt niet meer" en staat ook
// gevuld bij een code die is verdrongen of stukgeraden; SignedInAt zegt "hier is iemand
// mee binnengekomen". Alleen dat laatste is het bewijs dat de aanvrager zelf in dat
// postvak zit, en alleen dat telt niet mee voor de rem op aanvragen.
openstaand.ConsumedAt = nu;
openstaand.SignedInAt = nu;
await _uow.SaveChangesAsync(ct);
return Result.Ok();
}
// ------------------------------------------------------------------ account aanmaken
/// <summary>
/// Maakt een account met een eigen profiel en een eigen familieproject. Bestaat er al een
/// profiel met dit e-mailadres omdat iemand anders het heeft aangemaakt, dan wordt dat
/// geclaimd in plaats van gedupliceerd.
/// </summary>
/// <param name="profiel">
/// Het profiel dat aan dit account gekoppeld moet worden. Geef dit mee als je het al weet -
/// bij een uitnodiging bijvoorbeeld, waar de uitnodiging zelf zegt om welk profiel het gaat.
/// Laat het leeg en er wordt op e-mailadres gezocht.
///
/// Zoeken op adres is niet genoeg: wie een uitnodiging krijgt en daarna van adres verandert
/// (of van wie het adres wordt gecorrigeerd), zou dan een tweede profiel krijgen terwijl het
/// eerste onbeheerd achterblijft.
/// </param>
public async Task<Result<User>> MaakAccountAsync(string email, string voornaam, string achternaam,
CancellationToken ct = default, Person? profiel = null)
{
email = Normaliseer(email);
if (await _users.EmailExistsAsync(email, ct))
return Result<User>.Fail("Er bestaat al een account met dit e-mailadres.");
var nu = _clock.UtcNow;
// Alléén het profiel uit de uitnodiging, en niet ook nog zoeken op e-mailadres. Wie
// zonder uitnodiging een account maakt, krijgt zijn eigen profiel - ook als een ander al
// een aantekening met dat adres had staan. Dat is de regel uit docs/Identiteit.md: het
// adres speelt geen rol bij het koppelen, alleen de link die iemand zelf aanklikt. Zoeken
// op adres betekende andermans aantekening adopteren zonder dat die ander daar iets over
// te zeggen had, en sinds er meerdere aantekeningen met hetzelfde adres kunnen bestaan
// was het bovendien de vraag welke je dan kreeg.
//
// De aantekening die een ander over je heeft, komt alsnog bij je terecht op het moment
// dat je zijn uitnodiging aanneemt; dan gaat hij op in het profiel dat je dan al hebt.
var persoon = profiel;
if (persoon is not null)
{
// Het account bepaalt vanaf nu het adres van dit profiel.
persoon.Email = email;
persoon.UpdatedAt = nu;
}
if (persoon is null)
{
persoon = new Person
{
FirstName = (voornaam ?? string.Empty).Trim(),
LastName = (achternaam ?? string.Empty).Trim(),
Email = email,
CreatedAt = nu,
UpdatedAt = nu
};
_people.Add(persoon);
}
var dataSleutel = EnvelopeCrypto.NewKey();
var gebruiker = new User
{
Email = email,
WrappedDataKey = _keys.Protect(dataSleutel),
Person = persoon,
EmailConfirmedAt = nu, // de code is per e-mail aangekomen, dus het adres klopt
LastLoginAt = nu,
CreatedAt = nu,
UpdatedAt = nu
};
_users.Add(gebruiker);
await _uow.SaveChangesAsync(ct);
Domain.Rules.ProfileOwnership.Claim(persoon, gebruiker.Id, nu);
// Foto's en documenten die een beheerder voor dit profiel heeft toegevoegd terwijl het
// nog niet geclaimd was, staan op naam van die beheerder - hij was op dat moment
// degene die was ingelogd. Zonder dit blijft zo'n bestand voor altijd van de beheerder:
// de persoon zelf kan zijn eigen profielfoto dan niet eens openen, laat staan wijzigen
// of verwijderen. We zetten ze daarom hier over, en wikkelen de sleutel van elk bestand
// opnieuw in - met de kluissleutel van de beheerder open, met die van deze nieuwe
// gebruiker weer dicht - anders zou de nieuwe eigenaar zijn eigen bestand niet kunnen
// ontsleutelen.
await BestandenOverzettenAsync(persoon.Id, dataSleutel, ct);
var familie = new Family
{
Name = string.IsNullOrWhiteSpace(achternaam)
? $"Familie van {persoon.DisplayName}"
: $"Familie {achternaam.Trim()}",
OwnerPersonId = persoon.Id,
CreatedAt = nu,
UpdatedAt = nu
};
_families.Add(familie);
await _uow.SaveChangesAsync(ct);
_families.Add(new FamilyMember
{
FamilyId = familie.Id,
PersonId = persoon.Id,
Relation = RelationType.Overig,
RelationLabel = "Ikzelf",
AddedByUserId = gebruiker.Id,
CreatedAt = nu
});
await _audit.WriteAsync(AuditAction.ProfielGeclaimd, gebruiker.Id, nameof(Person), persoon.Id, "registratie", ct);
await _uow.SaveChangesAsync(ct);
Array.Clear(dataSleutel);
return Result<User>.Ok(gebruiker);
}
/// <summary>
/// Zet de bestanden van deze persoon die nog op naam van een beheerder staan over naar de
/// persoon zelf, met een verse sleutel. Onbereikbare beheerderaccounts of bestanden die om
/// wat voor reden dan ook niet meer te ontsleutelen zijn, slaan we gewoon over: dit is
/// opruimwerk bij het claimen van een profiel en mag nooit de registratie zelf laten
/// mislukken.
/// </summary>
private async Task BestandenOverzettenAsync(long personId, byte[] nieuweDataSleutel, CancellationToken ct)
{
var bestanden = await _vault.ListFilesForPersonAsync(personId, ct);
var sleutelsPerBeheerder = new Dictionary<long, byte[]>();
try
{
foreach (var bestand in bestanden)
{
if (bestand.OwnerPersonId == personId) continue; // al van hemzelf
try
{
if (!sleutelsPerBeheerder.TryGetValue(bestand.OwnerPersonId, out var beheerderSleutel))
{
var beheerder = await _users.FindByPersonIdAsync(bestand.OwnerPersonId, ct);
if (beheerder is null) continue; // niets meer aan te ontsleutelen
beheerderSleutel = _keys.Unprotect(beheerder.WrappedDataKey);
sleutelsPerBeheerder[bestand.OwnerPersonId] = beheerderSleutel;
}
var contentKey = EnvelopeCrypto.UnwrapKey(bestand.WrappedContentKey, beheerderSleutel);
try
{
bestand.WrappedContentKey = EnvelopeCrypto.WrapKey(contentKey, nieuweDataSleutel);
bestand.OwnerPersonId = personId;
}
finally { Array.Clear(contentKey); }
}
catch (Exception)
{
// Dit ene bestand blijft dan van de beheerder, maar de rest van het profiel
// mag daar niet op vastlopen.
}
}
if (sleutelsPerBeheerder.Count > 0) await _uow.SaveChangesAsync(ct);
}
finally
{
foreach (var sleutel in sleutelsPerBeheerder.Values) Array.Clear(sleutel);
}
}
// ------------------------------------------------------- aantekeningen over mij
/// <summary>Een aantekening die iemand anders over dit adres heeft staan.</summary>
/// <param name="Van">Hoe degene die hem bijhoudt heet. Zonder naam is de vraag niet te beantwoorden.</param>
/// <param name="Relatie">Wat hij van je zou zijn, zoals die ander het invulde. Null als hij je nergens bij heeft staan.</param>
public record Aantekeningaanbod(long PersoonId, string Van, string? Relatie, DateTime AangemaaktOp);
/// <summary>
/// Heeft iemand een profiel voor mij aangemaakt?
///
/// Dit is de middenweg tussen twee dingen die allebei niet deugen. Automatisch koppelen op
/// e-mailadres niet, want een adres is geen identiteit: twee mensen kunnen er één delen en
/// één mens heeft er vaak meerdere, en dan maak je van twee mensen stilletjes één. En niets
/// doen ook niet, want dan moet iedereen die zich aanmeldt eerst een uitnodiging afwachten
/// terwijl er al een aantekening over hem klaarligt.
///
/// Dus vragen we het aan de enige die het antwoord weet: de persoon zelf, die net met een
/// code in dat postvak heeft bewezen dat het adres van hem is. Zolang hij niets zegt gebeurt
/// er niets, en degene die de aantekening bijhoudt merkt van deze hele vraag niets.
///
/// Zie docs/Identiteit.md.
/// </summary>
public async Task<IReadOnlyList<Aantekeningaanbod>> AantekeningenOverMijAsync(
long gebruikerId, CancellationToken ct = default)
{
var ik = await _users.FindByIdAsync(gebruikerId, ct);
if (ik is null || string.IsNullOrWhiteSpace(ik.Email)) return Array.Empty<Aantekeningaanbod>();
var aantekeningen = await _people.ListAantekeningenOverAdresAsync(Normaliseer(ik.Email), gebruikerId, ct);
if (aantekeningen.Count == 0) return Array.Empty<Aantekeningaanbod>();
var uitkomst = new List<Aantekeningaanbod>(aantekeningen.Count);
foreach (var aantekening in aantekeningen)
{
if (aantekening.ManagedByUserId is not { } beheerderId) continue;
var beheerder = await _users.FindByIdAsync(beheerderId, ct);
if (beheerder is null) continue;
var beheerderProfiel = await _people.FindByIdAsync(beheerder.PersonId, ct);
if (beheerderProfiel is null) continue;
// Wat hij van je zou zijn. Dat hoort erbij: "Jan de Vries heeft een profiel voor je
// aangemaakt" is een halve vraag, "als zijn schoonzoon" maakt hem beantwoordbaar.
var lidmaatschappen = await _families.ListMembershipsOfPersonAsync(aantekening.Id, ct);
var relatie = lidmaatschappen.Count > 0
? (lidmaatschappen[0].RelationLabel is { Length: > 0 } eigen
? eigen
: Domain.Rules.Relaties.Naam(lidmaatschappen[0].Relation))
: null;
uitkomst.Add(new Aantekeningaanbod(aantekening.Id, beheerderProfiel.DisplayName,
relatie, aantekening.CreatedAt));
}
return uitkomst;
}
/// <summary>
/// "Ja, dat ben ik." De aantekening gaat op in het profiel dat deze gebruiker al heeft.
///
/// Precies hetzelfde als het aannemen van een uitnodiging terwijl je bent ingelogd - het is
/// dezelfde verklaring, alleen zonder de link. Wat er verhuist staat in SamenvoegService.
/// </summary>
public async Task<Result> NeemAantekeningOverAsync(long persoonId, long gebruikerId,
SamenvoegService samenvoegen,
CancellationToken ct = default)
{
var ik = await _users.FindByIdAsync(gebruikerId, ct);
if (ik is null) return Result.Fail("Account niet gevonden.");
// Opnieuw ophalen en opnieuw toetsen: dit komt van een scherm, en een scherm is een
// wens en geen bewijs. Zonder deze regel kon iemand met een willekeurig persoonsnummer
// andermans aantekening naar zich toe trekken.
var aanbod = await _people.ListAantekeningenOverAdresAsync(Normaliseer(ik.Email), gebruikerId, ct);
if (aanbod.All(p => p.Id != persoonId))
return Result.Fail("Dit profiel hoort niet (meer) bij jouw e-mailadres.");
var gelukt = await samenvoegen.SamenvoegAsync(persoonId, ik.PersonId, gebruikerId, ct);
if (!gelukt.Succeeded) return Result.Fail(gelukt.Error!);
await _audit.WriteAsync(AuditAction.ProfielGeclaimd, gebruikerId, nameof(Person), persoonId,
"aantekening overgenomen", ct);
await _uow.SaveChangesAsync(ct);
return Result.Ok();
}
/// <summary>
/// "Nee, dat ben ik niet." Dan blijft alles zoals het is en wordt de vraag niet nog eens
/// gesteld. Degene die de aantekening bijhoudt krijgt hier geen bericht van: het weigeren
/// zou anders zelf een mededeling worden over wie er wel of niet een account heeft.
/// </summary>
public async Task<Result> WijsAantekeningAfAsync(long persoonId, long gebruikerId,
CancellationToken ct = default)
{
var ik = await _users.FindByIdAsync(gebruikerId, ct);
if (ik is null) return Result.Fail("Account niet gevonden.");
var aanbod = await _people.ListAantekeningenOverAdresAsync(Normaliseer(ik.Email), gebruikerId, ct);
var aantekening = aanbod.FirstOrDefault(p => p.Id == persoonId);
if (aantekening is null) return Result.Fail("Dit profiel hoort niet (meer) bij jouw e-mailadres.");
aantekening.LinkDeclinedByUserId = gebruikerId;
aantekening.LinkDeclinedAt = _clock.UtcNow;
await _uow.SaveChangesAsync(ct);
return Result.Ok();
}
// ------------------------------------------------------------------ eigen profiel
/// <summary>Je eigen gegevens ophalen voor het profielscherm.</summary>
public async Task<Result<EigenProfiel>> EigenProfielAsync(long gebruikerId, CancellationToken ct = default)
{
var gebruiker = await _users.FindByIdAsync(gebruikerId, ct);
if (gebruiker is null) return Result<EigenProfiel>.Fail("Account niet gevonden.");
var persoon = await _people.FindByIdAsync(gebruiker.PersonId, ct);
if (persoon is null) return Result<EigenProfiel>.Fail("Je profiel is niet gevonden.");
return Result<EigenProfiel>.Ok(new EigenProfiel(
persoon.Id, persoon.FirstName, persoon.LastName, gebruiker.Email,
persoon.BirthDate, gebruiker.CreatedAt));
}
/// <summary>
/// Wijzigt je eigen naam en geboortedatum. Het e-mailadres staat hier bewust niet bij: dat
/// is je inlog en gaat via een code naar het nieuwe adres.
/// </summary>
public async Task<Result> WijzigEigenProfielAsync(long gebruikerId, string voornaam, string achternaam,
DateOnly? geboortedatum, CancellationToken ct = default)
{
var gebruiker = await _users.FindByIdAsync(gebruikerId, ct);
if (gebruiker is null) return Result.Fail("Account niet gevonden.");
var persoon = await _people.FindByIdAsync(gebruiker.PersonId, ct);
if (persoon is null) return Result.Fail("Je profiel is niet gevonden.");
voornaam = (voornaam ?? string.Empty).Trim();
achternaam = (achternaam ?? string.Empty).Trim();
if (voornaam.Length == 0 && achternaam.Length == 0)
return Result.Fail("Vul minstens een voor- of achternaam in.");
// Een geboortedatum in de toekomst is geen tikfout die je stil wilt overnemen: daar gaat
// straks de sortering op leeftijd mee aan de haal.
if (geboortedatum is { } geboren && geboren > DateOnly.FromDateTime(_clock.UtcNow.Date))
return Result.Fail("Een geboortedatum in de toekomst kan niet.");
var nu = _clock.UtcNow;
persoon.FirstName = voornaam;
persoon.LastName = achternaam;
persoon.BirthDate = geboortedatum;
persoon.UpdatedAt = nu;
await _audit.WriteAsync(AuditAction.ProfielGewijzigd, gebruikerId, nameof(Person), persoon.Id, "eigen", ct);
await _uow.SaveChangesAsync(ct);
return Result.Ok();
}
/// <summary>
/// Begint het wijzigen van je inlogadres: er gaat een code naar het NIEUWE adres. Pas als die
/// code wordt ingevuld verandert er iets. Zo kan een openstaande sessie - of iemand die even
/// achter je scherm zit - je niet buitensluiten uit je eigen kluis.
/// </summary>
public async Task<Result<CodeVerstuurd>> VraagAdreswijzigingAanAsync(long gebruikerId, string nieuwAdres,
string? vanafIp = null,
CancellationToken ct = default)
{
var gebruiker = await _users.FindByIdAsync(gebruikerId, ct);
if (gebruiker is null) return Result<CodeVerstuurd>.Fail("Account niet gevonden.");
nieuwAdres = Normaliseer(nieuwAdres);
if (!IsGeldigAdres(nieuwAdres))
return Result<CodeVerstuurd>.Fail("Vul een geldig e-mailadres in a.u.b.");
if (nieuwAdres == gebruiker.Email)
return Result<CodeVerstuurd>.Fail("Dit is al je e-mailadres.");
if (await _users.EmailExistsAsync(nieuwAdres, ct))
return Result<CodeVerstuurd>.Fail("Er bestaat al een account met dit e-mailadres.");
return await StuurCodeAsync(nieuwAdres, LoginCodePurpose.AdresWijzigen, null, null, vanafIp, ct);
}
/// <summary>
/// Maakt de adreswijziging definitief. Het adres van je profiel gaat mee, en je wordt overal
/// uitgelogd: een sessie die met het oude adres is begonnen hoort niet door te lopen.
/// </summary>
public async Task<Result> BevestigAdreswijzigingAsync(long gebruikerId, string nieuwAdres, string code,
CancellationToken ct = default)
{
var gebruiker = await _users.FindByIdAsync(gebruikerId, ct);
if (gebruiker is null) return Result.Fail("Account niet gevonden.");
nieuwAdres = Normaliseer(nieuwAdres);
// Nog een keer kijken. Tussen aanvragen en bevestigen kan iemand anders dit adres hebben
// geregistreerd; dan is de code geldig maar het adres niet meer vrij.
if (await _users.EmailExistsAsync(nieuwAdres, ct))
return Result.Fail("Er bestaat inmiddels al een account met dit e-mailadres.");
var controle = await ControleerCodeAsync(nieuwAdres, code, LoginCodePurpose.AdresWijzigen, ct);
if (!controle.Succeeded) return Result.Fail(controle.Error!);
var nu = _clock.UtcNow;
var oud = gebruiker.Email;
gebruiker.Email = nieuwAdres;
gebruiker.EmailConfirmedAt = nu;
gebruiker.SecurityStamp = Guid.NewGuid(); // alles wat nog openstaat is hiermee verlopen
gebruiker.UpdatedAt = nu;
var persoon = await _people.FindByIdAsync(gebruiker.PersonId, ct);
if (persoon is not null)
{
persoon.Email = nieuwAdres;
persoon.UpdatedAt = nu;
}
await _audit.WriteAsync(AuditAction.AdresGewijzigd, gebruikerId, nameof(User), gebruikerId,
$"{oud} -> {nieuwAdres}", ct);
await _uow.SaveChangesAsync(ct);
return Result.Ok();
}
/// <summary>Geeft de onversleutelde datasleutel van een gebruiker. Alleen binnen een geverifieerde sessie gebruiken.</summary>
public byte[] OpenDataKey(User user) => _keys.Unprotect(user.WrappedDataKey);
/// <summary>Ruimt verlopen codes op; draaien als achtergrondtaak.</summary>
public Task<int> RuimVerlopenCodesOpAsync(CancellationToken ct = default) =>
_codes.RemoveExpiredAsync(_clock.UtcNow.AddDays(-1), ct);
internal static string Normaliseer(string? email) => (email ?? string.Empty).Trim().ToLowerInvariant();
/// <summary>Ziet dit eruit als een e-mailadres? Ook gebruikt bij het toevoegen van familie.</summary>
public static bool IsGeldigAdres(string email)
{
if (string.IsNullOrWhiteSpace(email) || email.Length > 255) return false;
try { return new MailAddress(email).Address == email; }
catch (FormatException) { return false; }
}
/// <summary>
/// Het onderwerp van de codemail, met de code vooraan.
///
/// Zo lees je hem van je vergrendelscherm zonder de mail te openen - dat scheelt precies de
/// handeling waar mensen op vastlopen. De keerzijde is dat de code dan ook zichtbaar is voor
/// wie over je schouder meekijkt; hij is tien minuten geldig en werkt één keer, en dat vinden
/// we het waard. Kort houden, anders knipt de telefoon hem af vóór de code.
/// </summary>
private static string BouwCodeOnderwerp(string code, LoginCodePurpose doel) => doel switch
{
LoginCodePurpose.Registreren => $"{code} is je code voor MijnBesluit",
LoginCodePurpose.AdresWijzigen => $"{code} is je code om je e-mailadres te wijzigen",
LoginCodePurpose.Adverteerder => $"{code} is je code voor adverteren op MijnBesluit",
LoginCodePurpose.Opheffen => $"{code} is je code om je MijnBesluit op te heffen",
_ => $"{code} is je inlogcode voor MijnBesluit"
};
private static string BouwCodeMail(string code, LoginCodePurpose doel)
{
var waarvoor = doel switch
{
LoginCodePurpose.Registreren => "om je account bij MijnBesluit aan te maken",
LoginCodePurpose.AdresWijzigen => "om dit adres aan je MijnBesluit-account te koppelen",
LoginCodePurpose.Adverteerder => "om in te loggen bij het adverteerdersgedeelte van MijnBesluit",
LoginCodePurpose.Opheffen => "om je account bij MijnBesluit op te heffen. Let op: daarmee "
+ "verdwijnt je kluis, en dat is niet terug te draaien",
_ => "om in te loggen bij MijnBesluit"
};
return $"""
<p>Hallo,</p>
<p>Hierbij je code {waarvoor}:</p>
<p style="font-size:30px;font-weight:bold;letter-spacing:5px;margin:20px 0">{code}</p>
<p>De code is {(int)CodeGeldigheid.TotalMinutes} minuten geldig en kan één keer gebruikt worden.</p>
<p>Heb je zelf geen code aangevraagd? Dan hoef je niets te doen; zonder deze code
verandert er niets en kan niemand met je e-mailadres inloggen.</p>
<p>Met vriendelijke groet,<br>het MijnBesluit-team</p>
""";
}
}
@@ -0,0 +1,579 @@
using System.Globalization;
using MijnBesluit.Application.Abstractions;
using MijnBesluit.Application.Models;
using MijnBesluit.Domain.Entities;
using MijnBesluit.Domain.Enums;
namespace MijnBesluit.Application.Services;
/// <summary>Wat adverteren kost. Staat in de instellingen, zodat het tarief kan wijzigen zonder uitrol.</summary>
public class AdvertentieOptions
{
/// <summary>
/// Wat één vertoning per uur per dag kost, in centen.
///
/// De rekensom is: dagen × vertoningen per uur × dit bedrag. Dertig dagen, vijf keer per uur
/// en een tarief van 20 cent is dus 30 × 5 × 20 = 3000 cent, oftewel 30 euro.
///
/// Hoe kom je aan een getal? Reken terug naar wat de adverteerder hoogstens kan krijgen. Vijf
/// keer per uur, 24 uur, 30 dagen is 3600 vertoningen. Voor 30 euro is dat ruim 8 euro per
/// duizend vertoningen - aan de lage kant voor een klein, goed omschreven publiek, en dat
/// hoort ook: in de praktijk haalt hij dat maximum nooit, want 's nachts opent niemand het
/// overzicht. Dat het geboekte aantal een máximum is en geen belofte hoort in je voorwaarden
/// te staan, en de adverteerder moet in zijn eigen scherm kunnen zien wat er werkelijk is
/// getoond.
///
/// Dit getal staat in de instellingen (Adverteren:TariefCenten) en niet vast in de code: het
/// is het soort getal dat je na drie maanden wilt bijstellen.
/// </summary>
public int TariefCenten { get; set; } = 20;
/// <summary>
/// Waar het bedrag niet onder komt. Een boeking van twee euro kost aan kijken, afvinken en
/// terugmailen meer dan hij opbrengt.
/// </summary>
public int MinimumCenten { get; set; } = 1000;
/// <summary>Het meeste dat iemand per uur kan boeken. Anders koopt één campagne alle ruimte op.</summary>
public int MaxPerUur { get; set; } = 20;
/// <summary>Hoe lang een campagne hoogstens mag lopen, in dagen.</summary>
public int MaxDagen { get; set; } = 366;
/// <summary>De langste zijde van de afbeelding na verkleinen.</summary>
public int MaxAfbeeldingZijde { get; set; } = 800;
/// <summary>Wat er hoogstens binnen mag komen voordat er verkleind wordt.</summary>
public int MaxAfbeeldingBytes { get; set; } = 4 * 1024 * 1024;
}
/// <summary>
/// Adverteren: bedrijven, hun campagnes, en de weg van indienen naar draaien.
///
/// Lees docs/Adverteren.md voordat je hier iets verandert. Daar staat waarom er goedkeuring
/// vooraf is, hoe er wordt gekozen wie er getoond wordt, en waarom het betaalpad met de hand
/// gaat: bunq.me is een betaalverzoek en geen kassa, er komt geen bericht terug.
///
/// Wat hier bewust NIET in zit: iets wat een advertentie koppelt aan wie hem ziet. Er wordt per
/// campagne per uur geteld, meer niet. Geen profielen, geen kenmerken, geen bezoeker die door de
/// applicatie heen te volgen is. Dit is een applicatie waarin mensen hun wachtwoorden en hun
/// laatste woorden bewaren; daar hoort geen advertentienetwerk in te zitten dat meekijkt.
/// </summary>
public class AdvertentieService
{
private readonly IAdvertentieRepository _adverteren;
private readonly IUserRepository _users;
private readonly IEmailSender _email;
private readonly IAuditLog _audit;
private readonly IUnitOfWork _uow;
private readonly IClock _clock;
private readonly IAfbeeldingVerkleiner _verkleiner;
private readonly AdvertentieOptions _opties;
public AdvertentieService(IAdvertentieRepository adverteren, IUserRepository users,
IEmailSender email, IAuditLog audit, IUnitOfWork uow, IClock clock,
IAfbeeldingVerkleiner verkleiner, AdvertentieOptions? opties = null)
{
_adverteren = adverteren;
_users = users;
_email = email;
_audit = audit;
_uow = uow;
_clock = clock;
_verkleiner = verkleiner;
_opties = opties ?? new AdvertentieOptions();
}
/// <summary>Waar de site staat; gebruikt in de e-mail aan de beheerder.</summary>
public string SiteUrl { get; set; } = "https://mijnbesluit.nl";
/// <summary>
/// Het bunq.me-adres waar de betaalverzoeken heen gaan. Staat in de instellingen
/// (Adverteren:BunqMe): een rekeningnummer wisselt eerder dan de code.
///
/// Leeg maken kan; dan komt er geen knop maar staan het bedrag en het kenmerk er gewoon,
/// zodat iemand het met de hand kan overmaken. Een betaalknop die naar een verkeerde
/// rekening wijst is erger dan geen knop.
/// </summary>
public string BunqMe { get; set; } = "https://bunq.me/KwikBit";
public AdvertentieOptions Tarieven => _opties;
// ------------------------------------------------------------------ rekenen
/// <summary>
/// Wat een campagne kost, in centen: dagen × vertoningen per uur × tarief, met een bodem.
///
/// Let op wat de adverteerder hiervoor krijgt: een *maximum* aantal vertoningen, geen
/// belofte. Zijn er in een uur minder bezoekers dan er vertoningen zijn ingekocht, dan haalt
/// niemand zijn aantal. Dat hoort in de voorwaarden te staan en op het boekscherm, en daarom
/// kan hij in zijn eigen scherm zien wat er werkelijk is getoond.
/// </summary>
public int BerekenBedrag(int dagen, int perUur)
{
if (dagen < 1 || perUur < 1) return 0;
var bedrag = dagen * perUur * _opties.TariefCenten;
return Math.Max(bedrag, _opties.MinimumCenten);
}
public int BerekenBedrag(DateOnly vanaf, DateOnly totEnMet, int perUur) =>
BerekenBedrag(totEnMet.DayNumber - vanaf.DayNumber + 1, perUur);
/// <summary>
/// Een bedrag in centen als tekst, met een komma: "30,00". Nederlands, want de site is dat
/// ook - er stond "€ 10.00" op het scherm.
///
/// Niet met de taalinstelling van de server, maar met de hand: die instelling is op de ene
/// machine Nederlands en op de andere niet, en dan hangt af van waar de applicatie draait hoe
/// er "tien euro" staat. Let op het verschil met <see cref="Betaallink"/>, waar juist een
/// punt hoort - bunq leest de link, geen mens.
/// </summary>
public static string Euro(int centen) =>
"\u20ac " + (centen / 100m).ToString("0.00", CultureInfo.InvariantCulture).Replace('.', ',');
/// <summary>
/// Het betaalverzoek bij een campagne: het bunq.me-adres met het bedrag en het kenmerk er al
/// in gevuld. Null als er geen adres is ingesteld of er niets te betalen valt.
///
/// bunq.me is een betaalverzoek en geen kassa: er komt geen bericht terug dat er betaald is.
/// Deze link bespaart de adverteerder dus het overtypen, meer niet - het afvinken blijft
/// handwerk van de beheerder. Zie docs/Adverteren.md.
/// </summary>
public string? Betaallink(Campagne campagne) => Betaallink(campagne.BedragCenten, campagne.Betaalkenmerk);
public string? Betaallink(int bedragCenten, string? kenmerk)
{
if (string.IsNullOrWhiteSpace(BunqMe) || bedragCenten <= 0) return null;
// Een punt als decimaalteken, wat de taalinstelling van de server ook is. Zonder
// InvariantCulture maakt een Nederlandse Windows er "30,00" van, en dan staat er een
// komma in de link - bunq leest daar een ander bedrag in, of helemaal geen.
var bedrag = (bedragCenten / 100m).ToString("0.00", CultureInfo.InvariantCulture);
return $"{BunqMe.TrimEnd('/')}?amount={bedrag}&description={Uri.EscapeDataString(kenmerk ?? "")}";
}
// ------------------------------------------------------------------ adverteerders
public Task<IReadOnlyList<Adverteerder>> AdverteerdersAsync(CancellationToken ct = default) =>
_adverteren.ListAdverteerdersAsync(ct);
public Task<Adverteerder?> AdverteerderAsync(long id, CancellationToken ct = default) =>
_adverteren.FindAdverteerderAsync(id, ct);
public Task<Adverteerder?> AdverteerderByEmailAsync(string email, CancellationToken ct = default) =>
_adverteren.FindAdverteerderByEmailAsync(Normaliseer(email), ct);
/// <summary>
/// Meldt een bedrijf aan. Het e-mailadres is de sleutel: daarmee logt hij in, en daarom kan
/// hetzelfde adres niet twee keer.
/// </summary>
public async Task<Result<Adverteerder>> MeldAanAsync(string bedrijfsnaam, string contactpersoon,
string email, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(bedrijfsnaam))
return Result<Adverteerder>.Fail("Vul de bedrijfsnaam in.");
email = Normaliseer(email);
if (!email.Contains('@'))
return Result<Adverteerder>.Fail("Dat is geen e-mailadres.");
if (await _adverteren.FindAdverteerderByEmailAsync(email, ct) is not null)
return Result<Adverteerder>.Fail("Er is al een account met dit e-mailadres.");
var adverteerder = new Adverteerder
{
Bedrijfsnaam = bedrijfsnaam.Trim(),
Contactpersoon = contactpersoon.Trim(),
Email = email,
AangemaaktOp = _clock.UtcNow
};
_adverteren.Add(adverteerder);
await _uow.SaveChangesAsync(ct);
return Result<Adverteerder>.Ok(adverteerder);
}
// ------------------------------------------------------------------ campagnes
public Task<IReadOnlyList<Campagne>> CampagnesAsync(long adverteerderId, CancellationToken ct = default) =>
_adverteren.ListCampagnesAsync(adverteerderId, ct);
public Task<Campagne?> CampagneAsync(long id, CancellationToken ct = default) =>
_adverteren.FindCampagneAsync(id, ct);
/// <summary>Alles wat op de beheerder ligt te wachten: betaling of goedkeuring.</summary>
public Task<IReadOnlyList<Campagne>> WachtOpBeheerderAsync(CancellationToken ct = default) =>
_adverteren.ListWachtendAsync(ct);
/// <summary>
/// Dient een campagne in. Hij komt op "wacht op betaling" te staan, met een betaalkenmerk dat
/// de adverteerder bij zijn bunq-betaling zet; daarmee vindt de beheerder hem terug.
///
/// De beheerder krijgt hier bericht van, want vanaf nu ligt het bij hem.
/// </summary>
public async Task<Result<Campagne>> DienInAsync(long adverteerderId, string tekst, string link,
DateOnly vanaf, DateOnly totEnMet, int perUur,
byte[]? afbeelding = null, string? afbeeldingSoort = null,
CancellationToken ct = default)
{
var adverteerder = await _adverteren.FindAdverteerderAsync(adverteerderId, ct);
if (adverteerder is null) return Result<Campagne>.Fail("Dit account bestaat niet.");
if (adverteerder.IsGeblokkeerd) return Result<Campagne>.Fail("Dit account kan geen campagnes meer indienen.");
var controle = Controleer(tekst, link, vanaf, totEnMet, perUur);
if (!controle.Succeeded) return Result<Campagne>.Fail(controle.Error!);
var nu = _clock.UtcNow;
var campagne = new Campagne
{
AdverteerderId = adverteerderId,
Tekst = tekst.Trim(),
Link = link.Trim(),
VanafDatum = vanaf,
TotEnMetDatum = totEnMet,
PerUur = perUur,
TariefCenten = _opties.TariefCenten,
BedragCenten = BerekenBedrag(vanaf, totEnMet, perUur),
Stand = CampagneStand.WachtOpBetaling,
AangemaaktOp = nu
};
var beeld = ZetAfbeelding(campagne, afbeelding, afbeeldingSoort);
if (!beeld.Succeeded) return Result<Campagne>.Fail(beeld.Error!);
_adverteren.Add(campagne);
await _uow.SaveChangesAsync(ct);
await _audit.WriteAsync(AuditAction.AdvertentieIngediend, null, nameof(Campagne), campagne.Id,
$"{adverteerder.Bedrijfsnaam}, {campagne.BedragCenten} cent", ct);
// Het kenmerk kan pas als het nummer bekend is. Het staat straks in de omschrijving van
// de betaling, dus het moet kort en overtypbaar zijn.
campagne.Betaalkenmerk = $"MB-{campagne.Id:D5}";
await _uow.SaveChangesAsync(ct);
await MeldBijBeheerderAsync(adverteerder, campagne, ct);
await BevestigIndienenAsync(campagne, ct);
return Result<Campagne>.Ok(campagne);
}
/// <summary>Wat er aan een campagne mankeert, of niets.</summary>
public Result Controleer(string tekst, string link, DateOnly vanaf, DateOnly totEnMet, int perUur)
{
if (string.IsNullOrWhiteSpace(tekst)) return Result.Fail("Vul de tekst van de advertentie in.");
if (tekst.Trim().Length > 300) return Result.Fail("De tekst mag hoogstens 300 tekens lang zijn.");
if (string.IsNullOrWhiteSpace(link)) return Result.Fail("Vul de link in.");
// Alleen http en https, en het moet een echt adres zijn. Zonder dit kan er een
// javascript: of data: link in, en dat is een advertentie die code uitvoert bij de lezer.
if (!Uri.TryCreate(link.Trim(), UriKind.Absolute, out var adres) ||
(adres.Scheme != Uri.UriSchemeHttp && adres.Scheme != Uri.UriSchemeHttps))
return Result.Fail("De link moet met http:// of https:// beginnen.");
if (totEnMet < vanaf) return Result.Fail("De einddatum ligt voor de begindatum.");
var dagen = totEnMet.DayNumber - vanaf.DayNumber + 1;
if (dagen > _opties.MaxDagen) return Result.Fail($"Een campagne loopt hoogstens {_opties.MaxDagen} dagen.");
if (perUur < 1) return Result.Fail("Kies hoe vaak de advertentie per uur getoond mag worden.");
if (perUur > _opties.MaxPerUur) return Result.Fail($"Hoogstens {_opties.MaxPerUur} keer per uur.");
return Result.Ok();
}
private Result ZetAfbeelding(Campagne campagne, byte[]? inhoud, string? soort)
{
if (inhoud is null || inhoud.Length == 0) return Result.Ok();
if (inhoud.Length > _opties.MaxAfbeeldingBytes)
return Result.Fail($"De afbeelding is te groot; hoogstens {_opties.MaxAfbeeldingBytes / (1024 * 1024)} MB.");
// Verkleinen en niet alleen weigeren: iemand die een foto van zijn telefoon kiest levert
// vier megapixel aan, en dat hoeft niet in een tegel van een paar centimeter.
var kleiner = _verkleiner.Verklein(inhoud, soort ?? "image/jpeg", _opties.MaxAfbeeldingZijde);
campagne.Afbeelding = kleiner ?? inhoud;
campagne.AfbeeldingSoort = kleiner is null ? soort : "image/jpeg";
return Result.Ok();
}
// ------------------------------------------------------------------ wie er getoond wordt
/// <summary>
/// Welke advertentie er nu getoond mag worden, of geen.
///
/// Per keer dat iemand het overzicht opent is er ruimte voor één. De keuze gaat zo:
///
/// 1. Neem alle campagnes die nu mogen draaien: goedgekeurd en binnen hun periode. Betaald
/// zit daar automatisch in, want goedkeuren kan niet zonder betaling.
/// 2. Kijk per campagne hoe vaak hij dit klokuur al is getoond.
/// 3. Laat de campagne zien die het verst achterloopt op zijn eigen aantal - de laagste
/// verhouding getoond gedeeld door geboekt.
/// 4. Zijn ze allemaal op hun aantal, dan verschijnt er niets. Geen opvulling, geen herhaling.
///
/// Zo krijgt wie meer boekt ook meer, zonder dat de een de ander verdringt en zonder dat
/// iemand over zijn ingekochte aantal heen gaat. Bij gelijke stand wint de oudste campagne;
/// die is dan als eerste aan de beurt en zakt daarna vanzelf onder de ander.
///
/// Let op: dit telt de vertoning NIET mee. Dat doet <see cref="TelVertoningAsync"/>, en wel
/// pas als de tegel ook echt is meegestuurd. Hier tellen zou betekenen dat een afgebroken
/// verzoek toch van iemands tegoed afgaat.
/// </summary>
public async Task<Campagne?> KiesAsync(DateTime nuUtc, CancellationToken ct = default)
{
var lopend = await _adverteren.ListLopendAsync(DateOnly.FromDateTime(nuUtc), ct);
if (lopend.Count == 0) return null;
var uur = CampagneUur.Uur(nuUtc);
var getoond = await _adverteren.VertoningenInUurAsync(uur, lopend.Select(c => c.Id), ct);
Campagne? beste = null;
var besteVerhouding = double.MaxValue;
foreach (var campagne in lopend.OrderBy(c => c.Id))
{
var alGetoond = getoond.TryGetValue(campagne.Id, out var aantal) ? aantal : 0;
// Op zijn aantal: dan is hij dit uur klaar. Niet "bijna klaar, nog eentje dan" -
// iemand heeft een aantal ingekocht en daar gaan we niet overheen.
if (alGetoond >= campagne.PerUur) continue;
var verhouding = (double)alGetoond / campagne.PerUur;
if (verhouding >= besteVerhouding) continue;
besteVerhouding = verhouding;
beste = campagne;
}
return beste;
}
/// <summary>Zoals de aanroep met een tijdstip, maar met de klok van de dienst zelf.</summary>
public Task<Campagne?> KiesAsync(CancellationToken ct = default) => KiesAsync(_clock.UtcNow, ct);
/// <summary>Telt één vertoning mee, nu.</summary>
public Task TelVertoningAsync(long campagneId, CancellationToken ct = default) =>
TelVertoningAsync(campagneId, _clock.UtcNow, ct);
/// <summary>Telt één klik mee, nu.</summary>
public Task TelKlikAsync(long campagneId, CancellationToken ct = default) =>
TelKlikAsync(campagneId, _clock.UtcNow, ct);
/// <summary>Telt één vertoning mee, in het klokuur waar dit moment in valt.</summary>
public Task TelVertoningAsync(long campagneId, DateTime nuUtc, CancellationToken ct = default) =>
_adverteren.TelAsync(campagneId, CampagneUur.Uur(nuUtc), 1, 0, ct);
/// <summary>
/// Telt één klik mee. Er wordt niet gecontroleerd of die campagne nog loopt: een klik op een
/// tegel die net is afgelopen hoort nog steeds bij die campagne.
/// </summary>
public Task TelKlikAsync(long campagneId, DateTime nuUtc, CancellationToken ct = default) =>
_adverteren.TelAsync(campagneId, CampagneUur.Uur(nuUtc), 0, 1, ct);
/// <summary>
/// Wat er werkelijk is getoond en aangeklikt. Dit is wat de adverteerder in zijn eigen scherm
/// hoort te zien: hij heeft een maximum gekocht, geen belofte, en dan mag hij zien wat het
/// geworden is.
/// </summary>
public Task<(int Vertoningen, int Kliks)> TotalenAsync(long campagneId, CancellationToken ct = default) =>
_adverteren.TotalenAsync(campagneId, ct);
/// <summary>De tellers per uur, voor een grafiekje of een verantwoording.</summary>
public Task<IReadOnlyList<CampagneUur>> UrenAsync(long campagneId, CancellationToken ct = default) =>
_adverteren.ListUrenAsync(campagneId, ct);
// ------------------------------------------------------------------ de beheerder
/// <summary>De beheerder heeft het geld gezien en vinkt de betaling af.</summary>
public async Task<Result> BetaaldAsync(long campagneId, long beheerderUserId, CancellationToken ct = default)
{
var toegang = await AlsBeheerderAsync(beheerderUserId, ct);
if (!toegang.Succeeded) return toegang;
var campagne = await _adverteren.FindCampagneAsync(campagneId, ct);
if (campagne is null) return Result.Fail("Deze campagne bestaat niet.");
if (campagne.IsBetaald) return Result.Fail("Deze campagne staat al als betaald.");
campagne.BetaaldOp = _clock.UtcNow;
campagne.BetaaldAfgevinktDoorUserId = beheerderUserId;
campagne.GewijzigdOp = _clock.UtcNow;
// Betaald is nog niet goedgekeurd. Dat zijn twee vinkjes in hetzelfde scherm, maar twee
// verschillende vragen: is het geld binnen, en mag dit naast onze tekst over uitvaarten.
if (campagne.Stand == CampagneStand.WachtOpBetaling)
campagne.Stand = CampagneStand.WachtOpGoedkeuring;
await _audit.WriteAsync(AuditAction.AdvertentieBetaald, beheerderUserId, nameof(Campagne), campagne.Id,
$"betaling afgevinkt ({campagne.BedragCenten} cent)", ct);
await _uow.SaveChangesAsync(ct);
return Result.Ok();
}
/// <summary>De beheerder heeft de inhoud bekeken en keurt hem goed. Vanaf de begindatum draait hij.</summary>
public async Task<Result> GoedkeurenAsync(long campagneId, long beheerderUserId, CancellationToken ct = default)
{
var toegang = await AlsBeheerderAsync(beheerderUserId, ct);
if (!toegang.Succeeded) return toegang;
var campagne = await _adverteren.FindCampagneAsync(campagneId, ct);
if (campagne is null) return Result.Fail("Deze campagne bestaat niet.");
// Eerst het geld, dan de goedkeuring. Andersom draait er iets waarvoor niet betaald is,
// en dat merk je pas als het voorbij is.
if (!campagne.IsBetaald) return Result.Fail("Er is nog niet betaald. Vink eerst de betaling af.");
if (campagne.Stand == CampagneStand.Loopt) return Result.Fail("Deze campagne is al goedgekeurd.");
campagne.Stand = CampagneStand.Loopt;
campagne.GoedgekeurdOp = _clock.UtcNow;
campagne.GoedgekeurdDoorUserId = beheerderUserId;
campagne.AfwijzingReden = null;
campagne.GewijzigdOp = _clock.UtcNow;
await _audit.WriteAsync(AuditAction.AdvertentieGoedgekeurd, beheerderUserId, nameof(Campagne), campagne.Id,
"goedgekeurd", ct);
await _uow.SaveChangesAsync(ct);
await BerichtAanAdverteerderAsync(campagne, "Je advertentie is goedgekeurd",
$"<p>Je advertentie <strong>{Veilig(campagne.Tekst)}</strong> is goedgekeurd en draait " +
$"van {campagne.VanafDatum:dd-MM-yyyy} tot en met {campagne.TotEnMetDatum:dd-MM-yyyy}.</p>", ct);
return Result.Ok();
}
/// <summary>
/// Afwijzen, met een reden. Die reden is verplicht: een adverteerder die niet weet waarom hij
/// is afgewezen dient hetzelfde nog een keer in, en dan zit je er allebei weer naar te kijken.
/// </summary>
public async Task<Result> AfwijzenAsync(long campagneId, long beheerderUserId, string reden,
CancellationToken ct = default)
{
var toegang = await AlsBeheerderAsync(beheerderUserId, ct);
if (!toegang.Succeeded) return toegang;
if (string.IsNullOrWhiteSpace(reden)) return Result.Fail("Geef een reden op.");
var campagne = await _adverteren.FindCampagneAsync(campagneId, ct);
if (campagne is null) return Result.Fail("Deze campagne bestaat niet.");
campagne.Stand = CampagneStand.Afgewezen;
campagne.AfwijzingReden = reden.Trim();
campagne.GewijzigdOp = _clock.UtcNow;
await _audit.WriteAsync(AuditAction.AdvertentieAfgewezen, beheerderUserId, nameof(Campagne), campagne.Id,
$"afgewezen: {reden.Trim()}", ct);
await _uow.SaveChangesAsync(ct);
await BerichtAanAdverteerderAsync(campagne, "Je advertentie is afgewezen",
$"<p>Je advertentie <strong>{Veilig(campagne.Tekst)}</strong> is niet geplaatst.</p>" +
$"<p>Reden: {Veilig(reden.Trim())}</p>" +
"<p>Is er betaald, neem dan contact op; dan regelen we de terugbetaling.</p>", ct);
return Result.Ok();
}
/// <summary>Voortijdig stoppen. Hij verdwijnt dan meteen, ook als de einddatum nog niet is bereikt.</summary>
public async Task<Result> StopzettenAsync(long campagneId, long beheerderUserId, CancellationToken ct = default)
{
var toegang = await AlsBeheerderAsync(beheerderUserId, ct);
if (!toegang.Succeeded) return toegang;
var campagne = await _adverteren.FindCampagneAsync(campagneId, ct);
if (campagne is null) return Result.Fail("Deze campagne bestaat niet.");
campagne.Stand = CampagneStand.Gestopt;
campagne.GewijzigdOp = _clock.UtcNow;
await _audit.WriteAsync(AuditAction.AdvertentieGestopt, beheerderUserId, nameof(Campagne), campagne.Id,
"stopgezet", ct);
await _uow.SaveChangesAsync(ct);
return Result.Ok();
}
private async Task<Result> AlsBeheerderAsync(long userId, CancellationToken ct)
{
var gebruiker = await _users.FindByIdAsync(userId, ct);
// Dezelfde melding als "bestaat niet". Wie geen beheerder is, hoort niet te weten dat
// hier iets te beheren valt.
return gebruiker?.IsBeheerder == true ? Result.Ok() : Result.Fail("Niet gevonden.");
}
// ------------------------------------------------------------------ berichten
private async Task MeldBijBeheerderAsync(Adverteerder adverteerder, Campagne campagne, CancellationToken ct)
{
var beheerders = await _users.ListBeheerdersAsync(ct);
if (beheerders.Count == 0) return;
var bedrag = Euro(campagne.BedragCenten);
var body =
$"<p><strong>{Veilig(adverteerder.Bedrijfsnaam)}</strong> heeft een advertentie ingediend.</p>" +
$"<p>{Veilig(campagne.Tekst)}<br />" +
$"Van {campagne.VanafDatum:dd-MM-yyyy} tot en met {campagne.TotEnMetDatum:dd-MM-yyyy}, " +
$"{campagne.PerUur} keer per uur.<br />" +
$"Bedrag: {bedrag}, kenmerk {campagne.Betaalkenmerk}.</p>" +
$"<p>Zodra de betaling binnen is, vink je hem af en kijk je naar de inhoud: " +
$"<a href=\"{SiteUrl}/beheer/adverteren\">{SiteUrl}/beheer/adverteren</a></p>";
foreach (var beheerder in beheerders)
{
// Eén beheerder die niet bereikbaar is hoort de rest niet tegen te houden, en al
// helemaal niet het indienen zelf - dat is dan al opgeslagen.
try { await _email.SendAsync(beheerder.Email, "Nieuwe advertentie ingediend", body, ct); }
catch (Exception) { /* Bericht kwijt is vervelend; de campagne staat er wel. */ }
}
}
/// <summary>
/// De bevestiging aan de adverteerder, met het bedrag, het kenmerk en de betaallink erin.
///
/// Die link staat ook in zijn eigen scherm, maar niet iedereen komt daar terug: dit bericht
/// blijft in zijn postvak staan, en het kenmerk moet hij toch bij de hand hebben als hij met
/// de hand overmaakt.
/// </summary>
private Task BevestigIndienenAsync(Campagne campagne, CancellationToken ct)
{
var bedrag = Euro(campagne.BedragCenten);
var link = Betaallink(campagne);
var body =
$"<p>We hebben je advertentie ontvangen:</p>" +
$"<p><strong>{Veilig(campagne.Tekst)}</strong><br />" +
$"Van {campagne.VanafDatum:dd-MM-yyyy} tot en met {campagne.TotEnMetDatum:dd-MM-yyyy}, " +
$"{campagne.PerUur} keer per uur.</p>" +
$"<p>Te betalen: <strong>{bedrag}</strong>, onder vermelding van " +
$"<strong>{Veilig(campagne.Betaalkenmerk ?? "")}</strong>.</p>" +
(link is null
? ""
: $"<p><a href=\"{link}\">Betaal {bedrag} voor je advertentie</a></p>") +
"<p>Zodra de betaling binnen is kijken we naar de inhoud. Pas na goedkeuring gaat de " +
"advertentie draaien; je krijgt daar bericht van.</p>" +
$"<p>Wat er werkelijk is getoond kun je volgen op " +
$"<a href=\"{SiteUrl}/adverteren\">{SiteUrl}/adverteren</a>.</p>";
return BerichtAanAdverteerderAsync(campagne, "We hebben je advertentie ontvangen", body, ct);
}
private async Task BerichtAanAdverteerderAsync(Campagne campagne, string onderwerp, string body,
CancellationToken ct)
{
var adverteerder = campagne.Adverteerder ?? await _adverteren.FindAdverteerderAsync(campagne.AdverteerderId, ct);
if (adverteerder is null) return;
try { await _email.SendAsync(adverteerder.Email, onderwerp, body, ct); }
catch (Exception) { /* Zie boven: het besluit staat, het bericht is bijzaak. */ }
}
private static string Normaliseer(string email) => (email ?? string.Empty).Trim().ToLowerInvariant();
/// <summary>
/// Tekst van een adverteerder gaat in een e-mail die de beheerder leest. Die tekst is niet van
/// ons, dus mag hij geen html worden.
/// </summary>
private static string Veilig(string tekst) =>
(tekst ?? string.Empty).Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;")
.Replace("\"", "&quot;");
}
@@ -0,0 +1,164 @@
using MijnBesluit.Application.Abstractions;
using MijnBesluit.Application.Models;
using MijnBesluit.Domain.Entities;
using MijnBesluit.Domain.Enums;
using MijnBesluit.Domain.Rules;
namespace MijnBesluit.Application.Services;
/// <summary>
/// De beheerpagina. Bewust zo klein mogelijk.
///
/// Er is precies één situatie waar niemand anders uit komt: een overlijden dat ten onrechte is
/// bevestigd, terwijl de bezwaarlink uit de waarschuwingsmail is verlopen. Het account staat dan
/// op "overleden", inloggen kan niet meer, en er is geen weg terug. Daarvoor bestaat dit.
///
/// Wat een beheerder NIET kan, en ook nooit moet kunnen:
///
/// <list type="bullet">
/// <item>Een kluis openen. De enige handeling hier neemt toegang wég; hij geeft hem nooit. Wie
/// dat verandert, haalt de bodem onder het hele product vandaan.</item>
/// <item>Zoeken op naam. Dat zou de zoekmachine op mensen zijn die we in docs/Identiteit.md
/// uitdrukkelijk niet wilden. Zoeken kan alleen op het volledige e-mailadres: je moet dus al
/// weten wie je zoekt, omdat diegene contact met je heeft opgenomen.</item>
/// <item>Iemand beheerder maken. Dat gaat met de hand in de database. Wat niet via de
/// applicatie kan, kan ook niet via een lek in de applicatie.</item>
/// </list>
///
/// Er is er sinds kort een tweede: een samenvoeging terugdraaien. Samenvoegen doet de persoon
/// zelf - alleen hij mag zeggen dat twee profielen hem zijn - maar het raakt ook de gegevens van
/// degene die het duplicaat ooit aanmaakte. Gaat het mis, dan hoort er een mens naar te kijken.
/// Ook die handeling geeft geen toegang; hij zet alleen terug wat er stond.
/// </summary>
public class BeheerService
{
private readonly IUserRepository _users;
private readonly IPersonRepository _people;
private readonly ILegacyRepository _legacy;
private readonly IAuditLog _audit;
private readonly IClock _clock;
private readonly LegacyService _nalatenschap;
private readonly SamenvoegService? _samenvoegen;
private readonly IStatistiekRepository? _statistieken;
public BeheerService(IUserRepository users, IPersonRepository people, ILegacyRepository legacy,
IAuditLog audit, IClock clock, LegacyService nalatenschap,
SamenvoegService? samenvoegen = null, IStatistiekRepository? statistieken = null)
{
_users = users; _people = people; _legacy = legacy;
_audit = audit; _clock = clock; _nalatenschap = nalatenschap; _samenvoegen = samenvoegen;
_statistieken = statistieken;
}
/// <summary>Wat de beheerder van een profiel te zien krijgt. Geen kluis, geen berichten.</summary>
public record Profielbeeld(long PersonId, string DisplayName, string? Email, bool IsGeclaimd,
bool AccountGeblokkeerd, DateTime? OverlijdenBevestigdOp,
DateOnly? Overlijdensdatum, DeathProcedureState Stand,
int Meldingen, int MeldingenNodig,
long? OpgegaanIn,
IReadOnlyList<SamenvoegService.Samenvoegbeeld> Samenvoegingen);
/// <summary>Is deze gebruiker beheerder? Elke handeling hier begint hiermee.</summary>
public async Task<bool> IsBeheerderAsync(long userId, CancellationToken ct = default) =>
(await _users.FindByIdAsync(userId, ct))?.IsBeheerder == true;
/// <summary>
/// Aantallen voor de statistiektegel: hoeveel profielen, kluisitems, momenten enzovoort er
/// zijn, plus de groei van de laatste dertig dagen. Puur tellingen - dit is om de belasting
/// te kunnen inschatten, niet om in iemands kluis te kijken.
/// </summary>
public async Task<Result<Statistieken>> StatistiekenAsync(long currentUserId, CancellationToken ct = default)
{
if (!await IsBeheerderAsync(currentUserId, ct))
return Result<Statistieken>.Fail("Je bent geen beheerder.");
if (_statistieken is null) return Result<Statistieken>.Fail("Statistieken zijn hier niet beschikbaar.");
var statistieken = await _statistieken.OphalenAsync(30, _clock.UtcNow, ct);
return Result<Statistieken>.Ok(statistieken);
}
/// <summary>
/// Zoekt een profiel op het volledige e-mailadres. Niet op een deel ervan en niet op naam:
/// je hoort al te weten wie je zoekt.
/// </summary>
public async Task<Result<Profielbeeld>> ZoekOpAdresAsync(string email, long currentUserId,
CancellationToken ct = default)
{
if (!await IsBeheerderAsync(currentUserId, ct))
return Result<Profielbeeld>.Fail("Je bent geen beheerder.");
if (string.IsNullOrWhiteSpace(email)) return Result<Profielbeeld>.Fail("Vul een e-mailadres in.");
var adres = email.Trim().ToLowerInvariant();
var gebruiker = await _users.FindByEmailAsync(adres, ct);
var persoon = gebruiker is not null
? await _people.FindByIdAsync(gebruiker.PersonId, ct)
: await _people.FindByEmailAsync(adres, ct);
if (persoon is null) return Result<Profielbeeld>.Fail("Geen profiel gevonden met dit e-mailadres.");
// Opzoeken is zelf ook een handeling die je terug wilt kunnen vinden.
await _audit.WriteAsync(AuditAction.ProfielGewijzigd, currentUserId, nameof(Person), persoon.Id,
"beheer: opgezocht", ct);
var stand = DeathProcedure.Evaluate(persoon, await _legacy.ListDeclarationsAsync(persoon.Id, ct),
await Beheerderskring.GebruikersAsync(_legacy, _users, persoon.Id, ct),
_clock.UtcNow);
var samenvoegingen = _samenvoegen is null
? Array.Empty<SamenvoegService.Samenvoegbeeld>()
: await _samenvoegen.GeschiedenisAsync(persoon.Id, ct);
return Result<Profielbeeld>.Ok(new Profielbeeld(
persoon.Id, persoon.DisplayName, persoon.Email, persoon.IsClaimed,
gebruiker?.Status == UserStatus.Overleden, persoon.DeathConfirmedAt, persoon.DateOfDeath,
stand.State, stand.ActiveDeclarations, stand.RequiredDeclarations,
persoon.MergedIntoPersonId, samenvoegingen));
}
/// <summary>
/// Draait een samenvoeging terug. Alles wat toen is verhuisd gaat terug naar het profiel dat
/// is opgegaan; wat er ná die tijd bij het doel is bijgekomen, blijft daar staan.
///
/// Net als bij een overlijden is de reden verplicht en komt hij in het logboek.
/// </summary>
public async Task<Result> TerugdraaienAsync(long samenvoegingId, long currentUserId, string reden,
CancellationToken ct = default)
{
if (!await IsBeheerderAsync(currentUserId, ct))
return Result.Fail("Je bent geen beheerder.");
if (_samenvoegen is null)
return Result.Fail("Samenvoegen is hier niet beschikbaar.");
if (string.IsNullOrWhiteSpace(reden) || reden.Trim().Length < 5)
return Result.Fail("Geef een reden op; die wordt vastgelegd.");
return await _samenvoegen.TerugdraaienAsync(samenvoegingId, currentUserId, reden.Trim(), ct);
}
/// <summary>
/// Draait een bevestigd overlijden terug. Alle meldingen vervallen, het account wordt weer
/// actief, en de toegang die de nalatenschapsbeheerder had gekregen wordt ingetrokken.
///
/// De reden is verplicht en komt in het logboek. Dit is de zwaarste knop in de applicatie;
/// dan hoort er ook te staan waarom hij is ingedrukt.
/// </summary>
public async Task<Result> HerstelOverlijdenAsync(long personId, long currentUserId, string reden,
CancellationToken ct = default)
{
if (!await IsBeheerderAsync(currentUserId, ct))
return Result.Fail("Je bent geen beheerder.");
if (string.IsNullOrWhiteSpace(reden) || reden.Trim().Length < 5)
return Result.Fail("Geef een reden op; die wordt vastgelegd.");
var persoon = await _people.FindByIdAsync(personId, ct);
if (persoon is null) return Result.Fail("Dit profiel bestaat niet meer.");
if (persoon.DeathConfirmedAt is null)
return Result.Fail("Voor dit profiel is geen overlijden bevestigd.");
return await _nalatenschap.HerstelDoorBeheerderAsync(persoon, currentUserId, reden.Trim(), ct);
}
}
@@ -0,0 +1,31 @@
using MijnBesluit.Application.Abstractions;
namespace MijnBesluit.Application.Services;
/// <summary>
/// De accounts van de mensen die een profiel heeft aangewezen als nalatenschapsbeheerder.
///
/// <see cref="MijnBesluit.Domain.Rules.DeathProcedure.Evaluate"/> heeft die verzameling nodig:
/// de procedure begint pas te lopen als er een melding van een van hen bij zit. De aanwijzing
/// staat op persoonsnummer en een melding op accountnummer, dus daar zit één vertaalslag tussen.
///
/// Die staat hier, op één plek, omdat drie diensten hem nodig hebben. Drie keer dezelfde lus is
/// drie keer een kans dat er eentje anders gaat rekenen dan de andere.
/// </summary>
public static class Beheerderskring
{
public static async Task<IReadOnlySet<long>> GebruikersAsync(
ILegacyRepository legacy, IUserRepository users, long personId, CancellationToken ct = default)
{
var uitkomst = new HashSet<long>();
foreach (var beheerder in await legacy.ListManagersForAsync(personId, ct))
{
// Een aangewezen persoon zonder account kan niet melden; die telt hier dus niet mee.
if (await users.FindByPersonIdAsync(beheerder.ManagerPersonId, ct) is { } gebruiker)
uitkomst.Add(gebruiker.Id);
}
return uitkomst;
}
}
@@ -0,0 +1,561 @@
using MijnBesluit.Application.Abstractions;
using MijnBesluit.Application.Models;
using MijnBesluit.Application.Security;
using MijnBesluit.Domain.Entities;
using MijnBesluit.Domain.Enums;
using MijnBesluit.Domain.Rules;
namespace MijnBesluit.Application.Services;
/// <summary>Eén familielid zoals het in beeld komt, inclusief of het profiel al geclaimd is.</summary>
public record FamilyMemberView(
long PersonId,
string DisplayName,
// Los van DisplayName, want dat kan een bijnaam zijn; een bewerkscherm heeft de echte
// voor- en achternaam nodig om in te vullen.
string FirstName,
string LastName,
string? Email,
RelationType Relation,
string? RelationLabel,
ProfileState State,
bool CanEdit,
DateOnly? BirthDate,
DateOnly? DateOfDeath,
// De geschiedenis van het profiel: wanneer het is aangemaakt, wanneer er voor het laatst
// een uitnodiging uitging en wanneer de persoon het heeft overgenomen. Alles in UTC;
// het scherm rekent om naar lokale tijd.
DateTime CreatedAt,
DateTime? InvitedAt,
DateTime? InvitationExpiresAt,
int InvitationResends,
DateTime? ClaimedAt,
// Twee dingen die alleen bij het weghalen van iemand ter sprake komen, maar die hier
// meekomen omdat het scherm ze dan meteen heeft. Vraag je ze pas op het moment van
// verwijderen op, dan staat er een seconde niets terwijl iemand al op "ja" wil drukken.
//
// IsBeheerder: deze persoon is aangewezen om jouw nalatenschap te beheren. Dan mag hij niet
// zomaar weg; zie VerwijderLidAsync.
// MomentenKlaar: hoeveel berichten van jou aan deze persoon nog klaarstaan. Die verdwijnen
// mee, en dat hoort iemand te weten vóórdat hij ja zegt.
bool IsBeheerder = false,
int MomentenKlaar = 0);
/// <summary>
/// Familie samenstellen, mensen uitnodigen en profielen laten overgaan naar de persoon zelf.
/// Dit is het hart van de groei van MijnBesluit: wie wordt uitgenodigd en zelf familie invult,
/// wordt eigenaar van zijn profiel en nodigt op zijn beurt weer anderen uit.
/// </summary>
public class FamilyService
{
private static readonly TimeSpan InvitationLifetime = TimeSpan.FromDays(30);
private readonly IFamilyRepository _families;
private readonly IPersonRepository _people;
private readonly IUserRepository _users;
private readonly IInvitationRepository _invitations;
private readonly IMessageRepository _messages;
private readonly ILegacyRepository _legacy;
private readonly IEmailSender _email;
private readonly IAuditLog _audit;
private readonly IUnitOfWork _uow;
private readonly IClock _clock;
/// <summary>Basis-URL van de website, voor de link in de uitnodigingsmail.</summary>
public string SiteUrl { get; set; } = "https://mijnbesluit.nl";
public FamilyService(IFamilyRepository families, IPersonRepository people, IUserRepository users,
IInvitationRepository invitations, IMessageRepository messages,
ILegacyRepository legacy, IEmailSender email, IAuditLog audit,
IUnitOfWork uow, IClock clock)
{
_families = families; _people = people; _users = users; _invitations = invitations;
_messages = messages; _legacy = legacy;
_email = email; _audit = audit; _uow = uow; _clock = clock;
}
/// <summary>De familieprojecten waarvan deze persoon de eigenaar is.</summary>
public Task<IReadOnlyList<Family>> ListOwnedAsync(long personId, CancellationToken ct = default) =>
_families.ListOwnedByPersonAsync(personId, ct);
/// <summary>De familieprojecten waar deze persoon lid van is, van zichzelf of van een ander.</summary>
public Task<IReadOnlyList<Family>> ListMemberOfAsync(long personId, CancellationToken ct = default) =>
_families.ListFamiliesContainingAsync(personId, ct);
/// <summary>De familie van een gebruiker, met per lid de eigendomsstatus.</summary>
public async Task<IReadOnlyList<FamilyMemberView>> ListMembersAsync(long familyId, long currentUserId,
CancellationToken ct = default)
{
var now = _clock.UtcNow;
var family = await _families.FindByIdAsync(familyId, ct);
if (family is null) return Array.Empty<FamilyMemberView>();
var members = await _families.ListMembersAsync(familyId, ct);
var people = (await _people.ListByIdsAsync(members.Select(m => m.PersonId), ct)).ToDictionary(p => p.Id);
// De laatste uitnodiging per persoon, in één vraag. Die gebruiken we voor twee dingen:
// om te bepalen of er nog een uitnodiging openstaat, en om te tonen wanneer hij uitging.
var uitnodigingen = (await _invitations.ListLatestForPeopleAsync(members.Select(m => m.PersonId), ct))
.ToDictionary(i => i.PersonId);
// Wie er is aangewezen om jouw nalatenschap te beheren, en hoeveel berichten er van jou
// voor elk van deze mensen klaarstaan. Allebei in één vraag voor de hele lijst: per lid
// vragen zou bij twintig mensen veertig rondjes langs de database betekenen.
//
// Dit is er voor het weghalen van iemand. Zie VerwijderLidAsync voor waarom het uitmaakt.
var beheerders = (await _legacy.ListManagersForAsync(family.OwnerPersonId, ct))
.Select(b => b.ManagerPersonId).ToHashSet();
var klaar = (await _messages.ListOutboxAsync(family.OwnerPersonId, ct))
.Where(b => b.DeliveredAt is null)
.GroupBy(b => b.ToPersonId)
.ToDictionary(g => g.Key, g => g.Count());
var views = new List<FamilyMemberView>(members.Count);
foreach (var m in members)
{
if (!people.TryGetValue(m.PersonId, out var p)) continue;
uitnodigingen.TryGetValue(p.Id, out var uitnodiging);
var open = !p.IsClaimed && uitnodiging is not null && uitnodiging.IsOpen(now);
views.Add(new FamilyMemberView(
p.Id, p.DisplayName, p.FirstName, p.LastName, p.Email, m.Relation, m.RelationLabel,
p.State(open), ProfileOwnership.CanEdit(p, currentUserId), p.BirthDate, p.DateOfDeath,
p.CreatedAt, uitnodiging?.SentAt, uitnodiging?.ExpiresAt,
uitnodiging?.ResendCount ?? 0, p.ClaimedAt,
beheerders.Contains(p.Id), klaar.GetValueOrDefault(p.Id)));
}
// De volgorde op het scherm: eerst de familie met de oudste bovenaan, daaronder de
// vrienden en overige personen op alfabet. Wie geen geboortedatum heeft kan niet op
// leeftijd worden gezet en komt achter de mensen die er wel een hebben.
var familie = views.Where(v => IsFamilie(v.Relation))
.OrderBy(v => v.BirthDate is null)
.ThenBy(v => v.BirthDate ?? DateOnly.MaxValue)
.ThenBy(v => v.DisplayName, StringComparer.InvariantCultureIgnoreCase);
var overigen = views.Where(v => !IsFamilie(v.Relation))
.OrderBy(v => v.DisplayName, StringComparer.InvariantCultureIgnoreCase);
return familie.Concat(overigen).ToList();
}
/// <summary>
/// Vrienden en "overig" zijn wel lid van je familie in MijnBesluit, maar het zijn geen
/// familieleden. Op het scherm staan ze daarom apart, onder de echte familie.
///
/// Welke dat zijn staat bij de lijst zelf (Domain.Rules.Relaties) en niet hier: komt er een
/// soort vriend bij, dan hoort dat op één plek geregeld te zijn en niet pas nadat iemand
/// haar tussen de kleinkinderen ziet staan.
/// </summary>
private static bool IsFamilie(RelationType relatie) => Relaties.IsFamilie(relatie);
/// <summary>
/// Voegt iemand toe aan een familie. Bestaat er al een profiel met dit e-mailadres, dan wordt dat
/// gekoppeld in plaats van gedupliceerd — zo blijft één persoon ook echt één profiel.
/// </summary>
public async Task<Result<Person>> AddMemberAsync(long familyId, long currentUserId, string firstName,
string lastName, string? email, RelationType relation,
string? relationLabel = null, DateOnly? birthDate = null,
CancellationToken ct = default)
{
var family = await _families.FindByIdAsync(familyId, ct);
if (family is null) return Result<Person>.Fail("Deze familie bestaat niet.");
var owner = await _people.FindByIdAsync(family.OwnerPersonId, ct);
if (owner is null || !ProfileOwnership.CanEdit(owner, currentUserId))
return Result<Person>.Fail("Je mag deze familie niet wijzigen.");
if (string.IsNullOrWhiteSpace(firstName) && string.IsNullOrWhiteSpace(lastName))
return Result<Person>.Fail("Vul minstens een voor- of achternaam in.");
var now = _clock.UtcNow;
var normalized = string.IsNullOrWhiteSpace(email) ? null : AccountService.Normaliseer(email);
// Een adres met een tikfout valt anders pas op bij het uitnodigen, als de mail nergens
// aankomt. Beter meteen zeggen.
if (normalized is not null && !AccountService.IsGeldigAdres(normalized))
return Result<Person>.Fail($"'{email}' ziet er niet uit als een e-mailadres.");
// Alleen je eigen aantekeningen hergebruiken, nooit die van een ander.
//
// Hier stond eerder een zoekopdracht op e-mailadres zonder meer. Dat betekende: wie het
// adres van iemand kent, hangt die persoon aan zijn eigen familie en ziet meteen wat een
// ander over hem heeft ingevuld - naam, geboortedatum, profielfoto. Dat is een
// zoekmachine op mensen, en dat is precies wat dit niet moet zijn.
//
// Dus krijgt iedereen zijn eigen profiel. Twee ouders die allebei hun dochter toevoegen
// hebben twee aantekeningen, en dat is eerlijk: het zijn ook twee aantekeningen. Ze
// worden pas één zodra de dochter zelf beide uitnodigingen aanneemt - zij is de enige
// die mag zeggen dat het om haar gaat. Zie SamenvoegService.
//
// Voeg je iemand toe aan een tweede familie van jezelf, dan hoort dat wél hetzelfde
// profiel te zijn; anders zie je je eigen dochter twee keer.
Person? person = null;
if (normalized is not null)
{
// Álle profielen met dit adres opvragen en er het jouwe uit kiezen. Met alleen de
// eerste de beste ging dit mis zodra er meer dan één was: dan kreeg je die van een
// ander te pakken, viel de controle hieronder uit, en maakte je een tweede profiel
// aan voor iemand die je zelf al had staan.
var gevonden = await _people.ListByEmailAsync(normalized, ct);
person = gevonden.FirstOrDefault(
p => !p.IsSamengevoegd &&
(p.ManagedByUserId == currentUserId || p.ClaimedByUserId == currentUserId));
}
if (person is null)
{
person = new Person
{
FirstName = firstName.Trim(),
LastName = lastName.Trim(),
Email = normalized,
BirthDate = birthDate,
CreatedByUserId = currentUserId,
ManagedByUserId = currentUserId,
CreatedAt = now,
UpdatedAt = now
};
_people.Add(person);
await _uow.SaveChangesAsync(ct);
}
if (await _families.FindMemberAsync(familyId, person.Id, ct) is not null)
return Result<Person>.Fail($"{person.DisplayName} staat al in deze familie.");
_families.Add(new FamilyMember
{
FamilyId = familyId,
PersonId = person.Id,
Relation = relation,
RelationLabel = relationLabel,
AddedByUserId = currentUserId,
CreatedAt = now
});
await _uow.SaveChangesAsync(ct);
return Result<Person>.Ok(person);
}
/// <summary>Wijzigt de gegevens van een profiel. Lukt alleen zolang de persoon het profiel niet zelf beheert.</summary>
public async Task<Result> UpdatePersonAsync(long personId, long currentUserId, Action<Person> change,
CancellationToken ct = default)
{
var person = await _people.FindByIdAsync(personId, ct);
if (person is null) return Result.Fail("Profiel niet gevonden.");
var denial = ProfileOwnership.ExplainDenial(person, currentUserId);
if (denial is not null) return Result.Fail(denial);
change(person);
person.UpdatedAt = _clock.UtcNow;
await _audit.WriteAsync(AuditAction.ProfielGewijzigd, currentUserId, nameof(Person), person.Id, null, ct);
await _uow.SaveChangesAsync(ct);
return Result.Ok();
}
/// <summary>
/// Wijzigt naam, e-mailadres en relatie van iemand in je familie. Kan alleen zolang de
/// persoon het profiel niet zelf beheert: daarna is het van hem, en dan mag zelfs de
/// aanmaker er niets meer aan veranderen.
///
/// Het e-mailadres mag wél nog gewijzigd worden nadat er een uitnodiging uit is gegaan -
/// juist dan, want een tikfout in het adres merk je pas als de uitnodiging nergens aankomt.
/// Stuur daarna een nieuwe uitnodiging.
/// </summary>
public async Task<Result> WijzigLidAsync(long familyId, long personId, long currentUserId,
string firstName, string lastName, string? email,
RelationType relation, string? relationLabel = null,
DateOnly? birthDate = null, CancellationToken ct = default)
{
var person = await _people.FindByIdAsync(personId, ct);
if (person is null) return Result.Fail("Profiel niet gevonden.");
var denial = ProfileOwnership.ExplainDenial(person, currentUserId);
if (denial is not null) return Result.Fail(denial);
if (string.IsNullOrWhiteSpace(firstName) && string.IsNullOrWhiteSpace(lastName))
return Result.Fail("Vul minstens een voor- of achternaam in.");
var genormaliseerd = string.IsNullOrWhiteSpace(email) ? null : AccountService.Normaliseer(email);
if (genormaliseerd is not null)
{
if (!AccountService.IsGeldigAdres(genormaliseerd))
return Result.Fail($"'{email}' ziet er niet uit als een e-mailadres.");
// Alleen bij JOUW eigen profielen is dit een botsing. Dat een ander ook een
// aantekening met dit adres heeft, is geen bezwaar en hoort het ook niet te zijn: dan
// zou wie het adres van iemand het eerst invulde bepalen dat niemand anders die
// persoon nog kan toevoegen. Zie docs/Identiteit.md - ze worden pas één zodra de
// persoon zelf beide uitnodigingen aanneemt.
var vanMij = (await _people.ListByEmailAsync(genormaliseerd, ct))
.FirstOrDefault(p => p.Id != person.Id && !p.IsSamengevoegd &&
(p.ManagedByUserId == currentUserId || p.ClaimedByUserId == currentUserId));
if (vanMij is not null)
return Result.Fail($"Je hebt {genormaliseerd} al staan bij {vanMij.DisplayName}.");
}
var nu = _clock.UtcNow;
var adresGewijzigd = !string.Equals(person.Email, genormaliseerd, StringComparison.Ordinal);
person.FirstName = firstName.Trim();
person.LastName = lastName.Trim();
person.Email = genormaliseerd;
person.BirthDate = birthDate;
person.UpdatedAt = nu;
if (adresGewijzigd)
{
// Een verstuurde uitnodiging is een sleutel tot dit profiel. Ging hij naar een adres
// met een tikfout erin, dan ligt die sleutel in het postvak van een vreemde. Bij het
// corrigeren van het adres trekken we hem daarom in; er moet dan een nieuwe uitnodiging
// naar het juiste adres.
foreach (var open in await _invitations.ListOpenForPersonAsync(personId, ct))
{
open.Status = InvitationStatus.Ingetrokken;
}
}
var lid = await _families.FindMemberAsync(familyId, personId, ct);
if (lid is not null)
{
lid.Relation = relation;
lid.RelationLabel = string.IsNullOrWhiteSpace(relationLabel) ? null : relationLabel.Trim();
}
await _audit.WriteAsync(AuditAction.ProfielGewijzigd, currentUserId, nameof(Person), person.Id, null, ct);
await _uow.SaveChangesAsync(ct);
return Result.Ok();
}
/// <summary>
/// Haalt iemand uit je familie. Het profiel zelf blijft bestaan - die persoon kan in
/// andere families staan, of zijn eigen MijnBesluit hebben. Alleen de koppeling met
/// jouw familie verdwijnt, samen met een uitnodiging die nog openstond.
///
/// Iedereen mag hier weg, ook iemand die zijn profiel zelf beheert: dat je Karin uit je
/// lijst haalt, zegt niets over haar eigen MijnBesluit. Op twee dingen na.
///
/// 1. NIET als deze persoon jouw nalatenschap mag beheren. Dat is geen regel om iemand mee
/// te plagen: wie weggaat uit je lijst en beheerder blijft, laat een aanwijzing achter
/// die pas na je overlijden wordt gebruikt - en dan is er niemand meer die kan zeggen dat
/// het niet de bedoeling was. Wie dat wil, haalt hem eerst bij Nalatenschap weg. Twee
/// handelingen voor iets dat twee besluiten zijn.
///
/// 2. Berichten die nog voor deze persoon klaarstaan gaan mee. Ze hebben geen ontvanger
/// meer, en een bericht dat blijft wachten op iemand die je uit je lijst hebt gehaald zou
/// jaren later alsnog bezorgd worden. Wat al bezorgd is blijft staan: dat is uit jouw
/// handen, precies zoals het scherm bij een bezorgd bericht al zegt.
/// </summary>
public async Task<Result> VerwijderLidAsync(long familyId, long personId, long currentUserId,
CancellationToken ct = default)
{
var family = await _families.FindByIdAsync(familyId, ct);
if (family is null) return Result.Fail("Deze familie bestaat niet.");
var owner = await _people.FindByIdAsync(family.OwnerPersonId, ct);
if (owner is null || !ProfileOwnership.CanEdit(owner, currentUserId))
return Result.Fail("Je mag deze familie niet wijzigen.");
if (personId == family.OwnerPersonId)
return Result.Fail("Jezelf weghalen kan niet; jij bent de eigenaar.");
var lid = await _families.FindMemberAsync(familyId, personId, ct);
if (lid is null) return Result.Fail("Deze persoon staat niet bij jouw mensen.");
var persoon = await _people.FindByIdAsync(personId, ct);
var naam = persoon?.DisplayName ?? "Deze persoon";
var beheerders = await _legacy.ListManagersForAsync(family.OwnerPersonId, ct);
if (beheerders.Any(b => b.ManagerPersonId == personId))
return Result.Fail($"{naam} beheert je nalatenschap. Haal dat eerst weg bij " +
"Nalatenschap; daarna kun je hem hier weghalen.");
// Een openstaande uitnodiging is een sleutel tot dit profiel; die hoort mee te verdwijnen.
foreach (var open in await _invitations.ListOpenForPersonAsync(personId, ct))
open.Status = InvitationStatus.Ingetrokken;
// De berichten die nog voor hem klaarstonden. Bezorgde berichten blijven.
var klaar = (await _messages.ListOutboxAsync(family.OwnerPersonId, ct))
.Where(b => b.ToPersonId == personId && b.DeliveredAt is null)
.ToList();
foreach (var bericht in klaar) _messages.Remove(bericht);
_families.Remove(lid);
await _audit.WriteAsync(AuditAction.ProfielGewijzigd, currentUserId, nameof(Family), familyId,
$"lid {personId} verwijderd" +
(klaar.Count > 0 ? $", met {klaar.Count} klaarstaand(e) bericht(en)" : ""), ct);
await _uow.SaveChangesAsync(ct);
return Result.Ok();
}
/// <summary>
/// Verstuurt een uitnodiging per e-mail. Het token staat alleen in de mail;
/// in de database blijft uitsluitend de hash achter.
/// </summary>
public async Task<Result<string>> InviteAsync(long familyId, long personId, long currentUserId,
CancellationToken ct = default)
{
var person = await _people.FindByIdAsync(personId, ct);
if (person is null) return Result<string>.Fail("Profiel niet gevonden.");
if (person.IsClaimed) return Result<string>.Fail($"{person.DisplayName} heeft al een eigen account.");
if (string.IsNullOrWhiteSpace(person.Email))
return Result<string>.Fail($"Vul eerst het e-mailadres van {person.DisplayName} in.");
var member = await _families.FindMemberAsync(familyId, personId, ct);
if (member is null) return Result<string>.Fail("Deze persoon staat niet in deze familie.");
var now = _clock.UtcNow;
var token = TokenGenerator.Create();
// Hoe vaak we het al eerder hebben geprobeerd. Dat tonen we op het scherm: zie je dat
// er al drie uitnodigingen uit zijn, dan ligt het waarschijnlijk niet aan de ontvanger
// maar aan het adres.
var eerder = await _invitations.ListLatestForPeopleAsync(new[] { personId }, ct);
var aantalEerder = eerder.Count == 0 ? 0 : eerder[0].ResendCount + 1;
// Een uitnodiging die nog openstaat is een sleutel tot dit profiel. Sturen we een
// nieuwe, dan hoort de oude niet naast de nieuwe te blijven werken.
foreach (var open in await _invitations.ListOpenForPersonAsync(personId, ct))
open.Status = InvitationStatus.Ingetrokken;
var invitation = new Invitation
{
PersonId = personId,
FamilyId = familyId,
Email = person.Email!,
TokenHash = TokenGenerator.HashToken(token),
Status = InvitationStatus.Verzonden,
CreatedByUserId = currentUserId,
CreatedAt = now,
SentAt = now,
ExpiresAt = now.Add(InvitationLifetime),
ResendCount = aantalEerder
};
_invitations.Add(invitation);
var inviterUser = await _users.FindByIdAsync(currentUserId, ct);
var inviter = inviterUser?.PersonId is { } pid ? await _people.FindByIdAsync(pid, ct) : null;
var link = $"{SiteUrl.TrimEnd('/')}/uitnodiging/{token}";
await _email.SendAsync(person.Email!, "Je bent uitgenodigd voor MijnBesluit",
BuildInvitationMail(person.DisplayName, inviter?.DisplayName ?? "Iemand", link), ct);
await _audit.WriteAsync(AuditAction.UitnodigingVerstuurd, currentUserId, nameof(Invitation), invitation.Id, person.Email, ct);
await _uow.SaveChangesAsync(ct);
return Result<string>.Ok(token);
}
/// <summary>
/// Accepteert een uitnodiging: er komt een account bij het bestaande profiel en het profiel
/// gaat over naar de persoon zelf. De uitnodiger ziet het profiel daarna nog wel, maar kan het
/// niet meer wijzigen. Meteen wordt een eigen familieproject aangemaakt, zodat de nieuwe
/// deelnemer op zijn beurt kan uitnodigen.
///
/// Er is hier geen code per e-mail nodig: de uitnodigingslink is zelf al het bewijs dat
/// iemand bij dat e-mailadres kan, want daar is hij naartoe gestuurd.
/// </summary>
/// <param name="currentUserId">
/// Wie er al is ingelogd, als er iemand is ingelogd. Dat maakt het verschil tussen een
/// nieuw account en samenvoegen: is dit al jouw account, dan gaat het profiel uit deze
/// uitnodiging op in het profiel dat je al hebt.
///
/// Dat is de hele oplossing voor dubbele mensen. Jij voegt je dochter toe, je ex-partner
/// doet dat ook, en zij neemt beide uitnodigingen aan. Door die tweede aan te nemen terwijl
/// ze is ingelogd, zegt zij zelf dat het om haar gaat - en dat is de enige die dat mag
/// zeggen. Er wordt nergens op naam of geboortedatum gezocht.
/// </param>
/// <param name="samenvoegen">
/// Alleen nodig voor dat samenvoegen. Zonder deze dienst werkt het aannemen van een eerste
/// uitnodiging gewoon; een tweede wordt dan netjes geweigerd in plaats van stilzwijgend een
/// tweede profiel te maken.
/// </param>
public async Task<Result<User>> AcceptInvitationAsync(string token, AccountService accounts,
CancellationToken ct = default,
long? currentUserId = null,
SamenvoegService? samenvoegen = null)
{
var now = _clock.UtcNow;
var invitation = await _invitations.FindByTokenHashAsync(TokenGenerator.HashToken(token), ct);
if (invitation is null) return Result<User>.Fail("Deze uitnodiging is niet (meer) geldig.");
if (invitation.Status == InvitationStatus.Geaccepteerd) return Result<User>.Fail("Deze uitnodiging is al gebruikt.");
if (!invitation.IsOpen(now))
{
invitation.Status = InvitationStatus.Verlopen;
await _uow.SaveChangesAsync(ct);
return Result<User>.Fail("Deze uitnodiging is verlopen. Vraag om een nieuwe.");
}
var person = await _people.FindByIdAsync(invitation.PersonId, ct);
if (person is null) return Result<User>.Fail("Het profiel bij deze uitnodiging bestaat niet meer.");
if (person.IsClaimed) return Result<User>.Fail("Dit profiel heeft al een eigen account.");
// ---------------------------------------------------------------- al ingelogd
if (currentUserId is { } gebruikerId)
{
var ikzelf = await _users.FindByIdAsync(gebruikerId, ct);
if (ikzelf is null) return Result<User>.Fail("Account niet gevonden.");
if (samenvoegen is null)
return Result<User>.Fail("Samenvoegen is hier niet beschikbaar. Log uit en probeer het opnieuw.");
var gelukt = await samenvoegen.SamenvoegAsync(person.Id, ikzelf.PersonId, gebruikerId, ct);
if (!gelukt.Succeeded) return Result<User>.Fail(gelukt.Error!);
invitation.Status = InvitationStatus.Geaccepteerd;
invitation.AcceptedAt = now;
await _audit.WriteAsync(AuditAction.UitnodigingGeaccepteerd, gebruikerId,
nameof(Invitation), invitation.Id, "samengevoegd", ct);
await _uow.SaveChangesAsync(ct);
return Result<User>.Ok(ikzelf);
}
// ---------------------------------------------------------------- nog niet ingelogd
// Bestaat er al een account op dit adres, dan is dit vrijwel zeker een tweede
// uitnodiging voor iemand die hier al is. Een tweede account maken zou precies het
// probleem veroorzaken dat we willen voorkomen, dus vragen we haar eerst in te loggen.
if (await _users.EmailExistsAsync(invitation.Email, ct))
return Result<User>.Fail(
"Je hebt al een account met dit e-mailadres. Log eerst in; daarna kun je deze " +
"uitnodiging aannemen en wordt dit profiel aan het jouwe toegevoegd.");
// Uitdrukkelijk het profiel uit de uitnodiging, niet een profiel dat toevallig hetzelfde
// adres heeft. Anders ontstaat er een tweede profiel zodra het adres inmiddels gewijzigd is.
var registratie = await accounts.MaakAccountAsync(invitation.Email, person.FirstName,
person.LastName, ct, person);
if (!registratie.Succeeded) return registratie;
invitation.Status = InvitationStatus.Geaccepteerd;
invitation.AcceptedAt = now;
await _audit.WriteAsync(AuditAction.UitnodigingGeaccepteerd, registratie.Value!.Id,
nameof(Invitation), invitation.Id, null, ct);
await _uow.SaveChangesAsync(ct);
return registratie;
}
private static string BuildInvitationMail(string name, string inviter, string link) => $"""
<p>Hallo {System.Net.WebUtility.HtmlEncode(name)},</p>
<p>{System.Net.WebUtility.HtmlEncode(inviter)} heeft in MijnBesluit een profiel voor je aangemaakt.
MijnBesluit is een digitale kluis waarin je vastlegt wat je wilt regelen, en waarmee je berichten
kunt klaarzetten voor familie en vrienden &mdash; nu of later.</p>
<p>Zolang je de uitnodiging niet aanneemt, beheert {System.Net.WebUtility.HtmlEncode(inviter)} jouw profiel.
Neem je hem aan, dan is het profiel van jou en kan niemand anders er nog iets in wijzigen.</p>
<p>Je hoeft geen wachtwoord te bedenken: bij MijnBesluit log je in met een code die we je
per e-mail sturen.</p>
<p><a href="{link}">Uitnodiging aannemen</a></p>
<p>Deze link is 30 dagen geldig.</p>
<p>Met vriendelijke groet,<br>het MijnBesluit-team</p>
""";
}
@@ -0,0 +1,511 @@
using MijnBesluit.Application.Abstractions;
using MijnBesluit.Application.Models;
using MijnBesluit.Application.Security;
using MijnBesluit.Domain.Entities;
using MijnBesluit.Domain.Enums;
using MijnBesluit.Domain.Rules;
namespace MijnBesluit.Application.Services;
/// <summary>
/// Overlijden melden, bezwaar maken en de nalatenschap vrijgeven.
/// Er zijn meerdere onafhankelijke meldingen nodig; daarna krijgt de persoon zelf nog een
/// bezwaartermijn. Reageert hij niet, dan krijgt de aangewezen beheerder toegang tot alles
/// wat is gemarkeerd als "vrijgeven na overlijden".
/// </summary>
public class LegacyService
{
/// <summary>Basis-URL van de website, voor de bezwaarlink in de waarschuwingsmail.</summary>
public string SiteUrl { get; set; } = "https://mijnbesluit.nl";
/// <summary>
/// Hoe lang de bezwaarlink blijft werken. Ruim, en bewust langer dan de bezwaartermijn:
/// juist wie de mail pas laat opmerkt heeft hem nodig, want dan is het overlijden al
/// bevestigd en kan hij niet meer inloggen.
/// </summary>
public static readonly TimeSpan BezwaarlinkGeldigheid = TimeSpan.FromDays(365);
private readonly ILegacyRepository _legacy;
private readonly IPersonRepository _people;
private readonly IUserRepository _users;
private readonly IVaultRepository _vault;
private readonly IMessageRepository _messages;
private readonly IFamilyRepository? _families;
private readonly IEmailSender _email;
private readonly IAuditLog _audit;
private readonly IUnitOfWork _uow;
private readonly IClock _clock;
private readonly VaultService _vaultService;
public LegacyService(ILegacyRepository legacy, IPersonRepository people, IUserRepository users,
IVaultRepository vault, IMessageRepository messages, IEmailSender email,
IAuditLog audit, IUnitOfWork uow, IClock clock, VaultService vaultService,
IFamilyRepository? families = null)
{
_legacy = legacy; _people = people; _users = users; _vault = vault; _messages = messages;
_email = email; _audit = audit; _uow = uow; _clock = clock; _vaultService = vaultService;
_families = families;
}
/// <summary>Wijst iemand aan als nalatenschapsbeheerder.</summary>
/// <summary>Wie er zijn aangewezen om jouw nalatenschap te beheren, op volgorde.</summary>
public record BeheerderView(long PersonId, string DisplayName, int Priority, DateTime? AccessGrantedAt);
public async Task<IReadOnlyList<BeheerderView>> ListManagersAsync(long personId, CancellationToken ct = default)
{
var lijst = new List<BeheerderView>();
foreach (var m in await _legacy.ListManagersForAsync(personId, ct))
{
var persoon = await _people.FindByIdAsync(m.ManagerPersonId, ct);
lijst.Add(new BeheerderView(m.ManagerPersonId, persoon?.DisplayName ?? "onbekend",
m.Priority, m.AccessGrantedAt));
}
return lijst.OrderBy(b => b.Priority).ToList();
}
/// <summary>
/// Stelt in hoeveel onafhankelijke meldingen er nodig zijn en hoeveel dagen je daarna nog
/// hebt om te laten weten dat je er nog bent. Grenzen: minstens één melding, hoogstens
/// tien, en een termijn van nul tot negentig dagen.
/// </summary>
public async Task<Result> WijzigProcedureAsync(long currentUserId, int aantalMeldingen, int bezwaardagen,
CancellationToken ct = default)
{
var user = await _users.FindByIdAsync(currentUserId, ct);
if (user is null) return Result.Fail("Account niet gevonden.");
if (aantalMeldingen is < 1 or > 10)
return Result.Fail("Het aantal meldingen moet tussen 1 en 10 liggen.");
// Ondergrens één dag, geen nul. Met nul is het overlijden bevestigd op het moment dat de
// laatste melding binnenkomt, en dan is de bezwaartermijn geen termijn meer - precies de
// enige bescherming die de betrokkene heeft, weg met één instelling.
if (bezwaardagen is < 1 or > 90)
return Result.Fail("De bezwaartermijn moet tussen 1 en 90 dagen liggen.");
var persoon = await _people.FindByIdAsync(user.PersonId, ct);
if (persoon is null) return Result.Fail("Profiel niet gevonden.");
persoon.RequiredDeathDeclarations = aantalMeldingen;
persoon.DeathObjectionDays = bezwaardagen;
persoon.UpdatedAt = _clock.UtcNow;
await _audit.WriteAsync(AuditAction.ProfielGewijzigd, currentUserId, nameof(Person), persoon.Id,
$"procedure: {aantalMeldingen} meldingen, {bezwaardagen} dagen", ct);
await _uow.SaveChangesAsync(ct);
return Result.Ok();
}
public async Task<Result> AppointManagerAsync(long currentUserId, long managerPersonId, int priority = 1,
CancellationToken ct = default)
{
var user = await _users.FindByIdAsync(currentUserId, ct);
if (user is null) return Result.Fail("Account niet gevonden.");
if (managerPersonId == user.PersonId) return Result.Fail("Je kunt jezelf niet als beheerder aanwijzen.");
var manager = await _people.FindByIdAsync(managerPersonId, ct);
if (manager is null) return Result.Fail("Deze persoon bestaat niet.");
var existing = await _legacy.ListManagersForAsync(user.PersonId, ct);
if (existing.Any(m => m.ManagerPersonId == managerPersonId))
return Result.Fail($"{manager.DisplayName} is al aangewezen.");
_legacy.Add(new LegacyManager
{
PersonId = user.PersonId,
ManagerPersonId = managerPersonId,
Priority = priority,
CreatedAt = _clock.UtcNow
});
await _uow.SaveChangesAsync(ct);
return Result.Ok();
}
/// <summary>
/// Haalt iemand weer weg als beheerder. Nodig zodra een beheerder zelf overlijdt, of als je
/// het gewoon anders wilt regelen: aanwijzen zonder kunnen terugnemen is geen keuze.
///
/// Eén geval kan niet: iemand die je kluis al gekregen heeft. Dan is de procedure afgerond
/// en heeft hij gezien wat erin stond; die regel weghalen zou alleen de administratie
/// opschonen en niet wat er gebeurd is. Dat zeggen we dan ook zo.
/// </summary>
public async Task<Result> RemoveManagerAsync(long currentUserId, long managerPersonId,
CancellationToken ct = default)
{
var user = await _users.FindByIdAsync(currentUserId, ct);
if (user is null) return Result.Fail("Account niet gevonden.");
var beheer = (await _legacy.ListManagersForAsync(user.PersonId, ct))
.FirstOrDefault(m => m.ManagerPersonId == managerPersonId);
if (beheer is null) return Result.Fail("Deze persoon beheert je nalatenschap niet.");
if (beheer.AccessGrantedAt is not null)
{
var wie = await _people.FindByIdAsync(managerPersonId, ct);
return Result.Fail($"{wie?.DisplayName ?? "Deze persoon"} heeft je kluis al gekregen. " +
"Dat is niet meer terug te nemen.");
}
_legacy.Remove(beheer);
await _uow.SaveChangesAsync(ct);
return Result.Ok();
}
/// <summary>
/// Meldt dat iemand is overleden. Eén melding per gebruiker telt.
/// Zodra het vereiste aantal is bereikt, gaat de bezwaartermijn lopen en krijgt de betrokkene bericht.
/// </summary>
public async Task<Result<DeathProcedureResult>> DeclareDeathAsync(long personId, long currentUserId,
DateOnly? dateOfDeath = null, string? note = null,
CancellationToken ct = default)
{
var person = await _people.FindByIdAsync(personId, ct);
if (person is null) return Result<DeathProcedureResult>.Fail("Profiel niet gevonden.");
if (!DeathProcedure.MayDeclare(person, currentUserId))
return Result<DeathProcedureResult>.Fail("Voor dit profiel kun je geen melding (meer) doen.");
// Melden mag iedereen uit de kring van de betrokkene: de mensen die hij heeft toegevoegd
// en die de uitnodiging hebben aangenomen. Zij merken een overlijden als eerste, en het
// zijn er genoeg om aan het vereiste aantal te komen.
//
// Dat het daarmee niet zomaar iemand is, komt van de tweede regel: de procedure begint
// pas te lopen als er een melding van een aangewezen beheerder bij zit. Zie
// DeathProcedure voor waarom het zo en niet andersom is.
//
// Deze regel staat hier en niet in het scherm: de schermen bieden alleen je eigen mensen
// aan, maar een regel die alleen in het scherm staat is geen regel.
if (!await KentAsync(currentUserId, person.Id, ct))
return Result<DeathProcedureResult>.Fail(
"Je kunt alleen een melding doen over iemand die bij jou in de familie staat.");
var now = _clock.UtcNow;
if (await _legacy.FindDeclarationByUserAsync(personId, currentUserId, ct) is { IsActive: true })
return Result<DeathProcedureResult>.Fail("Je hebt deze melding al gedaan.");
_legacy.Add(new DeathDeclaration
{
PersonId = personId,
DeclaredByUserId = currentUserId,
DateOfDeath = dateOfDeath,
Note = note,
DeclaredAt = now
});
await _audit.WriteAsync(AuditAction.OverlijdenGemeld, currentUserId, nameof(Person), personId, note, ct);
await _uow.SaveChangesAsync(ct);
var declarations = await _legacy.ListDeclarationsAsync(personId, ct);
var beheerders = await Beheerderskring.GebruikersAsync(_legacy, _users, personId, ct);
var state = DeathProcedure.Evaluate(person, declarations, beheerders, now);
// Zodra de bezwaartermijn echt gaat lopen: de betrokkene krijgt bericht en kan bezwaar
// maken. Dat is precies wanneer er een einddatum is - dus niet als het aantal wel is
// gehaald maar er nog geen beheerder heeft gemeld. Anders zou iemand een schrikmail
// krijgen over een procedure die stilstaat.
if (state.ObjectionDeadline is not null && state.State == DeathProcedureState.Lopend
&& !string.IsNullOrWhiteSpace(person.Email))
{
// Een link die zonder inloggen werkt. Dat is geen gemak maar noodzaak: zodra het
// overlijden bevestigd is staat het account op "overleden" en kan de betrokkene er
// niet meer in. Zonder deze link zou hij dan vastzitten.
var bezwaartoken = TokenGenerator.Create();
person.ObjectionTokenHash = TokenGenerator.HashToken(bezwaartoken);
person.ObjectionTokenExpiresAt = now.Add(BezwaarlinkGeldigheid);
var link = $"{SiteUrl.TrimEnd('/')}/leefnog/{bezwaartoken}";
await _email.SendAsync(person.Email!, "Belangrijk: melding over jouw MijnBesluit-profiel", $"""
<p>Hallo {System.Net.WebUtility.HtmlEncode(person.DisplayName)},</p>
<p>Er is bij MijnBesluit gemeld dat je bent overleden. Klopt dat niet, laat het
ons dan weten vóór {state.ObjectionDeadline:dd-MM-yyyy}:</p>
<p><a href="{link}">Ik ben er nog</a></p>
<p>Daar hoef je niet voor in te loggen. Deze link blijft ook daarna werken, dus
ook als je deze e-mail pas later opmerkt.</p>
<p>Reageer je niet, dan krijgt je nalatenschapsbeheerder toegang tot wat je hebt
vrijgegeven voor na je overlijden.</p>
""", ct);
}
if (state.State == DeathProcedureState.Bevestigd) await ConfirmAsync(person, ct);
if (dateOfDeath is not null && person.DateOfDeath is null) person.DateOfDeath = dateOfDeath;
await _uow.SaveChangesAsync(ct);
return Result<DeathProcedureResult>.Ok(state);
}
/// <summary>De betrokkene laat weten dat hij gewoon leeft; alle meldingen vervallen.</summary>
public async Task<Result> ObjectAsync(long personId, long currentUserId, CancellationToken ct = default)
{
var person = await _people.FindByIdAsync(personId, ct);
if (person is null) return Result.Fail("Profiel niet gevonden.");
if (person.ClaimedByUserId != currentUserId)
return Result.Fail("Alleen de persoon zelf kan een overlijdensmelding tegenspreken.");
await BezwaarAsync(person, currentUserId, ct);
await _uow.SaveChangesAsync(ct);
return Result.Ok();
}
/// <summary>
/// Bezwaar maken met de link uit de waarschuwingsmail, zonder in te loggen.
///
/// Dit is de enige weg terug als het overlijden al is bevestigd: het account staat dan op
/// "overleden" en inloggen kan niet meer. De link is het bewijs - net als bij een
/// uitnodiging - en hij komt alleen aan op het adres van de betrokkene zelf.
/// </summary>
public async Task<Result<string>> BezwaarMetTokenAsync(string token, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(token)) return Result<string>.Fail("Deze link is niet geldig.");
var person = await _people.FindByObjectionTokenAsync(TokenGenerator.HashToken(token), ct);
if (person is null) return Result<string>.Fail("Deze link is niet (meer) geldig.");
var now = _clock.UtcNow;
if (person.ObjectionTokenExpiresAt is not { } tot || now > tot)
return Result<string>.Fail("Deze link is verlopen. Neem contact op met de beheerder.");
await BezwaarAsync(person, person.ClaimedByUserId, ct);
await _uow.SaveChangesAsync(ct);
return Result<string>.Ok(person.DisplayName);
}
/// <summary>
/// Draait een bevestigd overlijden terug op gezag van een beheerder.
///
/// Bewust <c>internal</c>: dit is de enige weg naar deze handeling die niet langs de
/// betrokkene zelf loopt, en die hoort alleen bereikbaar te zijn vanuit
/// <see cref="BeheerService"/>, die eerst nakijkt of de aanroeper écht beheerder is. De
/// website en de app kunnen er niet bij.
/// </summary>
internal async Task<Result> HerstelDoorBeheerderAsync(Person person, long beheerderUserId, string reden,
CancellationToken ct = default)
{
await BezwaarAsync(person, beheerderUserId, ct);
await _audit.WriteAsync(AuditAction.OverlijdenBetwist, beheerderUserId, nameof(Person), person.Id,
$"hersteld door beheerder: {reden}", ct);
await _uow.SaveChangesAsync(ct);
return Result.Ok();
}
/// <summary>
/// Alles wat er bij een bezwaar moet gebeuren, op één plek. Er zijn twee wegen naartoe -
/// ingelogd en via de link - en die horen precies hetzelfde te doen.
///
/// Let op de laatste stap: is het overlijden al bevestigd geweest, dan heeft de beheerder
/// sleutels gekregen. Die moeten er weer af, anders houdt hij toegang tot de kluis van
/// iemand die gewoon leeft.
/// </summary>
private async Task BezwaarAsync(Person person, long? doorUserId, CancellationToken ct)
{
var now = _clock.UtcNow;
var wasBevestigd = person.DeathConfirmedAt is not null;
person.DeathObjectedAt = now;
person.DeathConfirmedAt = null;
person.DateOfDeath = null;
person.UpdatedAt = now;
// De link is gebruikt; een volgende melding levert een nieuwe op.
person.ObjectionTokenHash = null;
person.ObjectionTokenExpiresAt = null;
foreach (var d in await _legacy.ListDeclarationsAsync(person.Id, ct))
if (d.IsActive) d.RevokedAt = now;
if (wasBevestigd)
{
var gebruiker = await _users.FindByPersonIdAsync(person.Id, ct);
if (gebruiker is { Status: UserStatus.Overleden }) gebruiker.Status = UserStatus.Actief;
await _vault.RevokeGrantsForOwnerAsync(person.Id, "nalatenschap", now, ct);
foreach (var beheer in await _legacy.ListManagersForAsync(person.Id, ct))
beheer.AccessGrantedAt = null;
}
await _audit.WriteAsync(AuditAction.OverlijdenBetwist, doorUserId, nameof(Person), person.Id,
wasBevestigd ? "na bevestiging" : null, ct);
}
/// <summary>
/// Loopt de lopende procedures na en bevestigt wat aan de bezwaartermijn voldoet.
/// Draait als achtergrondtaak, bijvoorbeeld één keer per dag.
/// </summary>
public async Task<int> ProcessPendingAsync(IEnumerable<long> personIds, CancellationToken ct = default)
{
var now = _clock.UtcNow;
var confirmed = 0;
foreach (var id in personIds)
{
var person = await _people.FindByIdAsync(id, ct);
if (person is null || person.DeathConfirmedAt is not null) continue;
var state = DeathProcedure.Evaluate(person, await _legacy.ListDeclarationsAsync(id, ct),
await Beheerderskring.GebruikersAsync(_legacy, _users, id, ct),
now);
if (state.State != DeathProcedureState.Bevestigd) continue;
await ConfirmAsync(person, ct);
confirmed++;
}
await _uow.SaveChangesAsync(ct);
return confirmed;
}
/// <summary>Bevestigt het overlijden en geeft de nalatenschap vrij aan de aangewezen beheerders.</summary>
private async Task ConfirmAsync(Person person, CancellationToken ct)
{
var now = _clock.UtcNow;
person.DeathConfirmedAt = now;
person.UpdatedAt = now;
person.DateOfDeath ??= DateOnly.FromDateTime(now);
var ownerUser = await _users.FindByPersonIdAsync(person.Id, ct);
if (ownerUser is not null) ownerUser.Status = UserStatus.Overleden;
await _audit.WriteAsync(AuditAction.OverlijdenBevestigd, null, nameof(Person), person.Id, null, ct);
var released = await _vaultService.ListReleasedAsync(person.Id, ct);
foreach (var manager in (await _legacy.ListManagersForAsync(person.Id, ct)).OrderBy(m => m.Priority))
{
var managerPerson = await _people.FindByIdAsync(manager.ManagerPersonId, ct);
if (managerPerson?.ClaimedByUserId is not { } managerUserId) continue;
if (ownerUser is null) continue;
foreach (var item in released)
await _vaultService.GrantAsync(item.Id, ownerUser.Id, managerUserId, "nalatenschap", ct);
manager.AccessGrantedAt = now;
await _audit.WriteAsync(AuditAction.NalatenschapToegangVerleend, managerUserId,
nameof(Person), person.Id, null, ct);
if (!string.IsNullOrWhiteSpace(managerPerson.Email))
{
await _email.SendAsync(managerPerson.Email!, "Toegang tot de nalatenschap in MijnBesluit", $"""
<p>Hallo {System.Net.WebUtility.HtmlEncode(managerPerson.DisplayName)},</p>
<p>{System.Net.WebUtility.HtmlEncode(person.DisplayName)} heeft jou aangewezen als
nalatenschapsbeheerder. De procedure is afgerond en je hebt nu toegang tot wat
{System.Net.WebUtility.HtmlEncode(person.DisplayName)} daarvoor heeft vrijgegeven.</p>
""", ct);
}
}
}
// ------------------------------------------------------------------ aan mij toevertrouwd
/// <summary>Een nalatenschap die jij beheert, en wat je ervan kunt inzien.</summary>
/// <param name="Volgorde">De hoeveelste beheerder je bent. Nummer 1 is als eerste aan zet.</param>
/// <param name="Afgerond">
/// Is de procedure rond? Zo niet, dan sta je alleen aangewezen en valt er niets in te zien.
/// </param>
/// <param name="RequiredDeclarations">
/// Hoeveel meldingen deze persoon heeft ingesteld voordat de procedure begint. Staat er ook
/// als de procedure nog niet loopt: zo weet je bij leven al waar je op moet rekenen, en kun
/// je het er nog met diegene over hebben.
/// </param>
/// <param name="ObjectionDays">De bezwaartermijn die deze persoon heeft ingesteld, in dagen.</param>
public record ToevertrouwdView(long PersonId, string DisplayName, DateOnly? DateOfDeath,
DateTime? AccessGrantedAt, int AantalItems,
bool Afgerond, int Volgorde,
int RequiredDeclarations, int ObjectionDays);
/// <summary>
/// De nalatenschappen die aan jou zijn toevertrouwd.
///
/// Dit is het sluitstuk van de hele overlijdensprocedure: zonder dit krijgt een beheerder
/// wel de sleutels en een e-mail, maar ziet hij nergens iets staan.
///
/// Er staan er twee soorten in. Aanwijzingen van mensen die er nog gewoon zijn - daarvan
/// hoor je te weten dat je bent gevraagd, want daar reken je op als het zover is. En de
/// afgeronde, waarvan je ook echt kunt inzien wat er is vrijgegeven.
///
/// Daartussen zit bewust niets. Een lopende procedure ziet er hier precies zo uit als
/// iemand die springlevend is: anders zou een beheerder aan dit scherm kunnen aflezen dat
/// er meldingen binnen zijn over iemand die dat nog kan tegenspreken.
/// </summary>
public async Task<IReadOnlyList<ToevertrouwdView>> ToevertrouwdAsync(long currentUserId,
CancellationToken ct = default)
{
var gebruiker = await _users.FindByIdAsync(currentUserId, ct);
if (gebruiker is null) return Array.Empty<ToevertrouwdView>();
var uitkomst = new List<ToevertrouwdView>();
foreach (var opdracht in await _legacy.ListAssignmentsOfAsync(gebruiker.PersonId, ct))
{
var persoon = await _people.FindByIdAsync(opdracht.PersonId, ct);
if (persoon is null) continue;
if (persoon.DeathConfirmedAt is null)
{
// Alleen de aanwijzing zelf. Geen datum, geen aantallen: er is niets vrijgegeven
// en er valt over deze persoon niets te melden.
uitkomst.Add(new ToevertrouwdView(persoon.Id, persoon.DisplayName, null, null, 0,
false, opdracht.Priority,
persoon.RequiredDeathDeclarations, persoon.DeathObjectionDays));
continue;
}
var items = await _vaultService.ListReleasedAsync(persoon.Id, ct);
uitkomst.Add(new ToevertrouwdView(persoon.Id, persoon.DisplayName, persoon.DateOfDeath,
opdracht.AccessGrantedAt, items.Count,
true, opdracht.Priority,
persoon.RequiredDeathDeclarations, persoon.DeathObjectionDays));
}
// De afgeronde bovenaan: daar moet iemand iets mee. De rest is om te weten dat het er is.
return uitkomst.OrderByDescending(t => t.Afgerond)
.ThenBy(t => t.DisplayName, StringComparer.InvariantCultureIgnoreCase)
.ToList();
}
/// <summary>
/// Wat er van deze nalatenschap voor jou is vrijgegeven. De inhoud blijft versleuteld; die
/// gaat pas open als je een item echt opent, met de sleutel die bij het bevestigen voor jou
/// is klaargezet.
/// </summary>
public async Task<Result<IReadOnlyList<VaultItem>>> NalatenschapItemsAsync(long personId, long currentUserId,
CancellationToken ct = default)
{
var gebruiker = await _users.FindByIdAsync(currentUserId, ct);
if (gebruiker is null) return Result<IReadOnlyList<VaultItem>>.Fail("Account niet gevonden.");
var overledene = await _people.FindByIdAsync(personId, ct);
if (overledene is null) return Result<IReadOnlyList<VaultItem>>.Fail("Dit profiel bestaat niet meer.");
if (overledene.DeathConfirmedAt is null)
return Result<IReadOnlyList<VaultItem>>.Fail("Deze procedure is nog niet afgerond.");
var beheerders = await _legacy.ListManagersForAsync(personId, ct);
if (beheerders.All(b => b.ManagerPersonId != gebruiker.PersonId))
return Result<IReadOnlyList<VaultItem>>.Fail("Je bent geen beheerder van deze nalatenschap.");
return Result<IReadOnlyList<VaultItem>>.Ok(await _vaultService.ListReleasedAsync(personId, ct));
}
/// <summary>
/// Zit deze gebruiker met dit profiel in een familie? Dat is de kring waarbinnen mensen
/// elkaar kennen, en daarmee de kring die iets over een overlijden kan zeggen.
/// </summary>
private async Task<bool> KentAsync(long userId, long personId, CancellationToken ct)
{
if (_families is null) return true; // zonder families valt er niets af te bakenen
var gebruiker = await _users.FindByIdAsync(userId, ct);
if (gebruiker is null) return false;
return (await _families.ListCircleAsync(gebruiker.PersonId, ct)).Contains(personId);
}
/// <summary>Stand van zaken van de overlijdensprocedure van één profiel.</summary>
public async Task<DeathProcedureResult> StatusAsync(long personId, CancellationToken ct = default)
{
var person = await _people.FindByIdAsync(personId, ct);
if (person is null) return new DeathProcedureResult(DeathProcedureState.Geen, 0, 3, null);
return DeathProcedure.Evaluate(person, await _legacy.ListDeclarationsAsync(personId, ct),
await Beheerderskring.GebruikersAsync(_legacy, _users, personId, ct),
_clock.UtcNow);
}
}
@@ -0,0 +1,183 @@
using MijnBesluit.Application.Abstractions;
using MijnBesluit.Application.Models;
using MijnBesluit.Domain.Entities;
namespace MijnBesluit.Application.Services;
/// <summary>
/// Meldingen op de telefoon: welke toestellen er zijn, en het versturen ernaartoe.
///
/// Een melding zegt bewust nooit wát er in een bericht staat. Hij komt op een scherm dat ook
/// zichtbaar is als de telefoon op tafel ligt, dus staat er alleen dat er iets klaarstaat.
/// </summary>
public class MeldingService
{
private readonly IToestelRepository _toestellen;
private readonly IUserRepository _users;
private readonly IPushSender _push;
private readonly IUnitOfWork _uow;
private readonly IClock _clock;
public MeldingService(IToestelRepository toestellen, IUserRepository users, IPushSender push,
IUnitOfWork uow, IClock clock)
{
_toestellen = toestellen; _users = users; _push = push; _uow = uow; _clock = clock;
}
/// <summary>
/// Meldt een toestel aan, of werkt het bij als het al bekend is.
///
/// Hoort het kenmerk bij een andere gebruiker, dan verhuist het. Dat gebeurt echt: iemand
/// logt uit en een ander logt in op dezelfde telefoon. Zonder deze regel krijgt die tweede
/// persoon de meldingen van de eerste.
/// </summary>
public async Task<Result> AanmeldenAsync(long currentUserId, string kenmerk, string platform,
string? naam = null, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(kenmerk)) return Result.Fail("Geen kenmerk meegegeven.");
if (kenmerk.Length > 512) return Result.Fail("Dit kenmerk is te lang.");
if (await _users.FindByIdAsync(currentUserId, ct) is null) return Result.Fail("Account niet gevonden.");
var nu = _clock.UtcNow;
var bestaand = await _toestellen.FindByKenmerkAsync(kenmerk.Trim(), ct);
if (bestaand is null)
{
_toestellen.Add(new Toestel
{
UserId = currentUserId,
Kenmerk = kenmerk.Trim(),
Platform = string.IsNullOrWhiteSpace(platform) ? "android" : platform.Trim().ToLowerInvariant(),
Naam = naam?.Trim(),
AangemeldOp = nu,
LaatstGezien = nu
});
}
else
{
bestaand.UserId = currentUserId;
bestaand.Platform = string.IsNullOrWhiteSpace(platform) ? bestaand.Platform : platform.Trim().ToLowerInvariant();
if (!string.IsNullOrWhiteSpace(naam)) bestaand.Naam = naam.Trim();
bestaand.LaatstGezien = nu;
bestaand.AfgemeldOp = null;
}
await _uow.SaveChangesAsync(ct);
return Result.Ok();
}
/// <summary>Meldt een toestel af, bijvoorbeeld bij uitloggen.</summary>
public async Task<Result> AfmeldenAsync(long currentUserId, string kenmerk, CancellationToken ct = default)
{
var toestel = await _toestellen.FindByKenmerkAsync((kenmerk ?? "").Trim(), ct);
// Van iemand anders? Dan bestaat het voor jou niet, en zeggen we dat ook niet.
if (toestel is null || toestel.UserId != currentUserId) return Result.Ok();
toestel.AfgemeldOp = _clock.UtcNow;
await _uow.SaveChangesAsync(ct);
return Result.Ok();
}
/// <summary>
/// Stuurt een melding naar alle toestellen van een gebruiker. Toestellen die Google niet
/// meer kent worden meteen afgemeld, zodat we het niet blijven proberen.
/// </summary>
public async Task<int> StuurAsync(long userId, string titel, string tekst,
IReadOnlyDictionary<string, string>? gegevens = null,
CancellationToken ct = default)
{
if (!_push.IsIngesteld) return 0;
var verstuurd = 0;
var aangepast = false;
foreach (var toestel in await _toestellen.ListForUserAsync(userId, ct))
{
if (!toestel.IsActief) continue;
bool bruikbaar;
try
{
bruikbaar = await _push.StuurAsync(toestel.Kenmerk, titel, tekst, gegevens, ct);
}
catch (Exception)
{
// Een storing bij Google mag het bezorgen van een bericht niet tegenhouden.
continue;
}
if (bruikbaar) verstuurd++;
else { toestel.AfgemeldOp = _clock.UtcNow; aangepast = true; }
}
if (aangepast) await _uow.SaveChangesAsync(ct);
return verstuurd;
}
/// <summary>De toestellen van een gebruiker, om te kunnen tonen waar meldingen heen gaan.</summary>
public Task<IReadOnlyList<Toestel>> ToestellenAsync(long userId, CancellationToken ct = default) =>
_toestellen.ListForUserAsync(userId, ct);
/// <summary>De uitkomst van een proefmelding, met een uitleg die je aan iemand kunt laten zien.</summary>
public record Proefuitslag(bool Ingesteld, int Toestellen, int Verstuurd, string Uitleg)
{
public bool Gelukt => Verstuurd > 0;
}
/// <summary>
/// Stuurt een proefmelding naar je eigen toestellen en zegt precies wat eraan mankeert als
/// er niets aankomt.
///
/// Waarom dit bestaat: als een melding uitblijft zijn er drie oorzaken, en ze zien er van
/// buiten hetzelfde uit — de server heeft geen servicesleutel, het toestel heeft zich nooit
/// aangemeld, of Google weigert het kenmerk. Zonder deze knop is de enige manier om dat uit
/// elkaar te houden: een bericht klaarzetten, een kwartier wachten op de achtergrondtaak, en
/// dan in het logboek kijken. Dat is te omslachtig om vaak te doen, en juist bij het
/// inrichten doe je het vaak.
///
/// Alleen naar je eigen toestellen. Iemand anders een melding kunnen sturen zou een manier
/// zijn om mensen lastig te vallen.
/// </summary>
public async Task<Proefuitslag> ProefAsync(long userId, CancellationToken ct = default)
{
if (!_push.IsIngesteld)
{
return new Proefuitslag(false, 0, 0,
"Op de server staat geen servicesleutel voor Firebase. Zie docs/Meldingen.md, stap 3.");
}
var toestellen = (await _toestellen.ListForUserAsync(userId, ct)).Count(t => t.IsActief);
if (toestellen == 0)
{
return new Proefuitslag(true, 0, 0,
"Er is geen toestel aangemeld. Log in de app opnieuw in en geef toestemming voor " +
"meldingen; pas daarna kent de server je telefoon.");
}
var verstuurd = await StuurAsync(userId, "MijnBesluit",
"Dit is een proefmelding. Zie je hem, dan werkt het.", new Dictionary<string, string>
{
["soort"] = "proef"
}, ct);
var uitleg = verstuurd > 0
? $"Verstuurd naar {(verstuurd == 1 ? "je toestel" : $"{verstuurd} toestellen")}. " +
"Komt hij niet aan, dan staan meldingen op de telefoon zelf uit."
: "Google nam de melding niet aan. Het kenmerk van dit toestel is verlopen; het is nu " +
"afgemeld. Log in de app opnieuw in, dan meldt hij zich met een nieuw kenmerk.";
return new Proefuitslag(true, toestellen, verstuurd, uitleg);
}
}
/// <summary>Wordt gebruikt zolang er niets is ingesteld: er gaat dan niets uit, en niets valt om.</summary>
public sealed class GeenPush : IPushSender
{
public bool IsIngesteld => false;
public Task<bool> StuurAsync(string kenmerk, string titel, string tekst,
IReadOnlyDictionary<string, string>? gegevens = null,
CancellationToken ct = default) => Task.FromResult(true);
}
@@ -0,0 +1,647 @@
using MijnBesluit.Application.Abstractions;
using MijnBesluit.Application.Models;
using MijnBesluit.Application.Security;
using MijnBesluit.Domain.Entities;
using MijnBesluit.Domain.Enums;
using MijnBesluit.Domain.Rules;
namespace MijnBesluit.Application.Services;
/// <summary>De inhoud van een bericht, versleuteld opgeslagen.</summary>
public class MessageBody
{
public string Text { get; set; } = string.Empty;
}
/// <summary>
/// Berichten ("momenten") klaarzetten en bezorgen. Een bericht kan direct gaan, op een datum,
/// of pas nadat het overlijden van de afzender bevestigd is. Bij bezorging krijgt de ontvanger
/// zijn eigen ingepakte kopie van de contentsleutel; pas dán kan hij het lezen.
/// </summary>
public class MessageService
{
private readonly IMessageRepository _messages;
private readonly IPersonRepository _people;
private readonly IUserRepository _users;
private readonly IVaultRepository _vault;
private readonly IMasterKeyProtector _keys;
private readonly IFileStore _files;
private readonly IEmailSender _email;
private readonly IAuditLog _audit;
private readonly IUnitOfWork _uow;
private readonly IClock _clock;
private readonly MeldingService? _meldingen;
/// <summary>Hoe lang een kijklink werkt, en na hoeveel tijd zonder openen een herinnering volgt.</summary>
public static readonly TimeSpan ViewLinkLifetime = TimeSpan.FromDays(30);
/// <summary>Waar de kijklink naartoe wijst. Wordt bij het opstarten gezet, net als bij FamilyService.</summary>
public string SiteUrl { get; set; } = "https://mijnbesluit.nl";
public MessageService(IMessageRepository messages, IPersonRepository people, IUserRepository users,
IVaultRepository vault, IMasterKeyProtector keys, IFileStore files, IEmailSender email,
IAuditLog audit, IUnitOfWork uow, IClock clock,
MeldingService? meldingen = null)
{
_messages = messages; _people = people; _users = users; _vault = vault;
_keys = keys; _files = files; _email = email; _audit = audit; _uow = uow; _clock = clock;
_meldingen = meldingen;
}
/// <summary>Zet een bericht klaar voor iemand, nu of later.</summary>
public async Task<Result<Message>> ComposeAsync(long currentUserId, long toPersonId, string subject, string text,
DateTime? deliverAt = null, bool afterDeath = false,
MessageKind kind = MessageKind.Tekst, long? vaultFileId = null,
CancellationToken ct = default)
{
var user = await _users.FindByIdAsync(currentUserId, ct);
if (user is null) return Result<Message>.Fail("Account niet gevonden.");
if (await _people.FindByIdAsync(toPersonId, ct) is null)
return Result<Message>.Fail("De ontvanger bestaat niet.");
if (string.IsNullOrWhiteSpace(subject)) return Result<Message>.Fail("Geef het bericht een onderwerp.");
if (deliverAt is { } at && at < _clock.UtcNow.AddMinutes(-1))
return Result<Message>.Fail("De bezorgdatum ligt in het verleden.");
var now = _clock.UtcNow;
var dataKey = _keys.Unprotect(user.WrappedDataKey);
var contentKey = EnvelopeCrypto.NewKey();
try
{
var sealedData = EnvelopeCrypto.SealJson(new MessageBody { Text = text }, contentKey);
var message = new Message
{
FromPersonId = user.PersonId,
ToPersonId = toPersonId,
Kind = kind,
Subject = subject.Trim(),
Ciphertext = sealedData.Ciphertext,
Nonce = sealedData.Nonce,
Tag = sealedData.Tag,
WrappedContentKey = EnvelopeCrypto.WrapKey(contentKey, dataKey),
VaultFileId = vaultFileId,
DeliverAt = deliverAt,
DeliverAfterDeath = afterDeath,
Status = MessageStatus.Gepland,
CreatedAt = now,
UpdatedAt = now
};
_messages.Add(message);
await _audit.WriteAsync(AuditAction.BerichtGepland, currentUserId, nameof(Message), null, subject, ct);
await _uow.SaveChangesAsync(ct);
return Result<Message>.Ok(message);
}
finally
{
Array.Clear(dataKey);
Array.Clear(contentKey);
}
}
/// <summary>
/// De tekst van een bericht dat jij hebt klaargezet en dat nog niet bezorgd is.
///
/// Dit is bewust iets anders dan <see cref="ReadAsync"/>: dat is voor de ontvanger van een
/// bezorgd bericht. Hier gaat het om je eigen concept, dat je nog wilt kunnen nalezen en
/// wijzigen. De inhoud wordt ontsleuteld met je eigen datasleutel - dezelfde waarmee hij bij
/// het klaarzetten is versleuteld.
/// </summary>
public async Task<Result<string>> ConceptAsync(long messageId, long currentUserId, CancellationToken ct = default)
{
var message = await _messages.FindByIdAsync(messageId, ct);
if (message is null) return Result<string>.Fail("Dit bericht bestaat niet meer.");
var user = await _users.FindByIdAsync(currentUserId, ct);
if (user is null) return Result<string>.Fail("Account niet gevonden.");
if (message.FromPersonId != user.PersonId) return Result<string>.Fail("Dit bericht is niet van jou.");
if (message.Status is MessageStatus.Bezorgd or MessageStatus.Gelezen)
return Result<string>.Fail("Dit bericht is al bezorgd.");
var dataKey = _keys.Unprotect(user.WrappedDataKey);
byte[]? contentKey = null;
try
{
contentKey = EnvelopeCrypto.UnwrapKey(message.WrappedContentKey, dataKey);
var body = EnvelopeCrypto.OpenJson<MessageBody>(
new SealedData(message.Ciphertext, message.Nonce, message.Tag), contentKey);
return Result<string>.Ok(body?.Text ?? string.Empty);
}
finally
{
Array.Clear(dataKey);
if (contentKey is not null) Array.Clear(contentKey);
}
}
/// <summary>
/// De tekst van een bericht dat jij hebt verstuurd, ook nadat het is bezorgd - alleen om na
/// te lezen, niet om te wijzigen (dat is <see cref="ConceptAsync"/>, en dat mag alleen nog
/// vóór bezorging). Dit kan sowieso: je eigen exemplaar is versleuteld met je eigen
/// datasleutel, dezelfde als bij het klaarzetten, en die verandert niet door bezorging - dat
/// is alleen voor de ontvanger een apart stukje sleutel (het "grant"), zie ReadAsync.
/// </summary>
public async Task<Result<string>> ReadSentAsync(long messageId, long currentUserId, CancellationToken ct = default)
{
var message = await _messages.FindByIdAsync(messageId, ct);
if (message is null) return Result<string>.Fail("Dit bericht bestaat niet meer.");
var user = await _users.FindByIdAsync(currentUserId, ct);
if (user is null) return Result<string>.Fail("Account niet gevonden.");
if (message.FromPersonId != user.PersonId) return Result<string>.Fail("Dit bericht is niet van jou.");
var dataKey = _keys.Unprotect(user.WrappedDataKey);
byte[]? contentKey = null;
try
{
contentKey = EnvelopeCrypto.UnwrapKey(message.WrappedContentKey, dataKey);
var body = EnvelopeCrypto.OpenJson<MessageBody>(
new SealedData(message.Ciphertext, message.Nonce, message.Tag), contentKey);
return Result<string>.Ok(body?.Text ?? string.Empty);
}
finally
{
Array.Clear(dataKey);
if (contentKey is not null) Array.Clear(contentKey);
}
}
/// <summary>
/// Wijzigt een bericht dat nog niet bezorgd is. Alleen de afzender kan dit.
///
/// Een bezorgd bericht blijft staan zoals het is: de ontvanger heeft het al gezien, en een
/// bericht dat achteraf verandert is geen bericht meer.
/// </summary>
public async Task<Result> UpdateAsync(long messageId, long currentUserId, string subject, string text,
DateTime? deliverAt, bool afterDeath, CancellationToken ct = default)
{
var message = await _messages.FindByIdAsync(messageId, ct);
if (message is null) return Result.Fail("Dit bericht bestaat niet meer.");
var user = await _users.FindByIdAsync(currentUserId, ct);
if (user is null) return Result.Fail("Account niet gevonden.");
if (message.FromPersonId != user.PersonId) return Result.Fail("Dit bericht is niet van jou.");
if (message.Status is MessageStatus.Bezorgd or MessageStatus.Gelezen)
return Result.Fail("Dit bericht is al bezorgd en kan niet meer gewijzigd worden.");
if (message.Status == MessageStatus.Ingetrokken)
return Result.Fail("Dit bericht is ingetrokken.");
if (string.IsNullOrWhiteSpace(subject)) return Result.Fail("Geef het bericht een onderwerp.");
if (deliverAt is { } at && at < _clock.UtcNow.AddMinutes(-1))
return Result.Fail("De bezorgdatum ligt in het verleden.");
var now = _clock.UtcNow;
var dataKey = _keys.Unprotect(user.WrappedDataKey);
var contentKey = EnvelopeCrypto.NewKey();
try
{
var sealedData = EnvelopeCrypto.SealJson(new MessageBody { Text = text }, contentKey);
message.Subject = subject.Trim();
message.Ciphertext = sealedData.Ciphertext;
message.Nonce = sealedData.Nonce;
message.Tag = sealedData.Tag;
message.WrappedContentKey = EnvelopeCrypto.WrapKey(contentKey, dataKey);
message.DeliverAt = deliverAt;
message.DeliverAfterDeath = afterDeath;
message.UpdatedAt = now;
await _audit.WriteAsync(AuditAction.BerichtGepland, currentUserId, nameof(Message), message.Id, "gewijzigd", ct);
await _uow.SaveChangesAsync(ct);
return Result.Ok();
}
finally
{
Array.Clear(dataKey);
Array.Clear(contentKey);
}
}
/// <summary>
/// Trekt een bericht in dat nog niet bezorgd is. Het verdwijnt uit je uitgaande post en
/// wordt nooit meer bezorgd.
/// </summary>
public async Task<Result> WithdrawAsync(long messageId, long currentUserId, CancellationToken ct = default)
{
var message = await _messages.FindByIdAsync(messageId, ct);
if (message is null) return Result.Fail("Dit bericht bestaat niet meer.");
var user = await _users.FindByIdAsync(currentUserId, ct);
if (user is null) return Result.Fail("Account niet gevonden.");
if (message.FromPersonId != user.PersonId) return Result.Fail("Dit bericht is niet van jou.");
if (message.Status is MessageStatus.Bezorgd or MessageStatus.Gelezen)
return Result.Fail("Dit bericht is al bezorgd; intrekken kan niet meer.");
message.Status = MessageStatus.Ingetrokken;
message.UpdatedAt = _clock.UtcNow;
await _audit.WriteAsync(AuditAction.BerichtGepland, currentUserId, nameof(Message), message.Id, "ingetrokken", ct);
await _uow.SaveChangesAsync(ct);
return Result.Ok();
}
/// <summary>
/// Een bericht uit je eigen lijst verwijderen - "Van mij" of "Voor mij", bezorgd of niet. Dit
/// haalt het alleen bij jou weg: de ander (afzender of ontvanger) houdt zijn eigen exemplaar
/// gewoon, tot die het op zijn beurt ook weghaalt. Zie Message.HiddenBySender/HiddenByRecipient.
/// </summary>
public async Task<Result> DeleteForMeAsync(long messageId, long currentUserId, CancellationToken ct = default)
{
var message = await _messages.FindByIdAsync(messageId, ct);
if (message is null) return Result.Fail("Dit bericht bestaat niet meer.");
var user = await _users.FindByIdAsync(currentUserId, ct);
if (user is null) return Result.Fail("Account niet gevonden.");
if (message.FromPersonId == user.PersonId) message.HiddenBySender = true;
else if (message.ToPersonId == user.PersonId) message.HiddenByRecipient = true;
else return Result.Fail("Dit bericht is niet van jou.");
message.UpdatedAt = _clock.UtcNow;
await _audit.WriteAsync(AuditAction.BerichtGepland, currentUserId, nameof(Message), message.Id,
message.FromPersonId == user.PersonId ? "verwijderd (afzender)" : "verwijderd (ontvanger)", ct);
await _uow.SaveChangesAsync(ct);
return Result.Ok();
}
/// <summary>
/// Bezorgt alle berichten waarvan de datum is bereikt of waarvan de afzender bevestigd overleden is.
/// Draait als achtergrondtaak, bijvoorbeeld elk kwartier.
/// </summary>
public async Task<int> DeliverDueAsync(int max = 200, CancellationToken ct = default)
{
var now = _clock.UtcNow;
var due = await _messages.ListDueAsync(now, max, ct);
var delivered = 0;
foreach (var message in due)
{
var sender = await _people.FindByIdAsync(message.FromPersonId, ct);
if (sender is null) continue;
if (!MessageRelease.IsDue(message, sender, now)) continue;
if (await DeliverAsync(message, sender, ct)) delivered++;
}
await _uow.SaveChangesAsync(ct);
return delivered;
}
/// <summary>Bezorgt één bericht: contentsleutel omzetten naar de ontvanger en een seintje per e-mail.</summary>
private async Task<bool> DeliverAsync(Message message, Person sender, CancellationToken ct)
{
var recipient = await _people.FindByIdAsync(message.ToPersonId, ct);
if (recipient is null) return false;
var senderUser = await _users.FindByPersonIdAsync(message.FromPersonId, ct);
if (senderUser is null) return false;
var recipientUser = recipient.ClaimedByUserId is { } uid ? await _users.FindByIdAsync(uid, ct) : null;
// Nog geen account? Dan sturen we in plaats daarvan een kijklink: een e-mail met een
// link naar een landingspagina waar dit ene bericht - inclusief bijlage - te zien is
// zonder in te loggen. Het bericht zelf blijft "Gepland" staan; claimt de ontvanger
// zijn profiel alsnog, dan loopt hij hier op de eerstvolgende ronde gewoon weer langs
// en wordt het bericht dan wél normaal bezorgd.
if (recipientUser is null)
{
await StuurKijklinkAsync(message, sender, recipient, ct);
return false;
}
var senderKey = _keys.Unprotect(senderUser.WrappedDataKey);
var recipientKey = _keys.Unprotect(recipientUser.WrappedDataKey);
byte[]? contentKey = null;
try
{
contentKey = EnvelopeCrypto.UnwrapKey(message.WrappedContentKey, senderKey);
_vault.Add(new ContentGrant
{
ResourceType = nameof(Message),
ResourceId = message.Id,
UserId = recipientUser.Id,
WrappedContentKey = EnvelopeCrypto.WrapKey(contentKey, recipientKey),
Reason = "ontvanger",
GrantedAt = _clock.UtcNow
});
// Zit er een opname of bijlage bij, dan moet de ontvanger daar ook bij kunnen. Die
// heeft een eigen sleutel, versleuteld met de datasleutel van de afzender; zonder
// deze regel krijgt de ontvanger straks wel de tekst te zien maar geen video.
if (message.VaultFileId is { } bestandId &&
await _vault.FindFileAsync(bestandId, ct) is { DeletedAt: null } bestand)
{
byte[]? bestandsleutel = null;
try
{
bestandsleutel = EnvelopeCrypto.UnwrapKey(bestand.WrappedContentKey, senderKey);
_vault.Add(new ContentGrant
{
ResourceType = nameof(VaultFile),
ResourceId = bestand.Id,
UserId = recipientUser.Id,
WrappedContentKey = EnvelopeCrypto.WrapKey(bestandsleutel, recipientKey),
Reason = "bijlage bij bericht",
GrantedAt = _clock.UtcNow
});
}
finally
{
if (bestandsleutel is not null) Array.Clear(bestandsleutel);
}
}
}
finally
{
Array.Clear(senderKey);
Array.Clear(recipientKey);
if (contentKey is not null) Array.Clear(contentKey);
}
message.Status = MessageStatus.Bezorgd;
message.DeliveredAt = _clock.UtcNow;
message.UpdatedAt = message.DeliveredAt.Value;
if (!string.IsNullOrWhiteSpace(recipient.Email))
{
await _email.SendAsync(recipient.Email!, "Er staat een bericht voor je klaar in MijnBesluit", $"""
<p>Hallo {System.Net.WebUtility.HtmlEncode(recipient.DisplayName)},</p>
<p>{System.Net.WebUtility.HtmlEncode(sender.DisplayName)} heeft een bericht voor je klaargezet:
<strong>{System.Net.WebUtility.HtmlEncode(message.Subject)}</strong>.</p>
<p>Log in op MijnBesluit om het te lezen.</p>
""", ct);
}
// Een melding op de telefoon, als de ontvanger de app heeft. Bewust zonder de inhoud:
// die melding komt op een scherm dat ook zichtbaar is als de telefoon op tafel ligt.
if (_meldingen is not null)
{
await _meldingen.StuurAsync(recipientUser.Id,
"Er staat een bericht voor je klaar",
$"{sender.DisplayName} heeft iets voor je klaargezet.",
new Dictionary<string, string>
{
["soort"] = "bericht",
["berichtId"] = message.Id.ToString()
}, ct);
}
await _audit.WriteAsync(AuditAction.BerichtBezorgd, null, nameof(Message), message.Id, null, ct);
return true;
}
/// <summary>
/// Stuurt een kijklink naar iemand zonder account: de eerste keer meteen, en daarna alleen
/// nog als de vorige link inmiddels verlopen is én nooit geopend - dat is dan de ene
/// herinnering. Buiten die twee momenten doet deze methode niets, ook al wordt ze elke
/// ronde van <see cref="DeliverDueAsync"/> weer aangeroepen zolang het bericht "Gepland"
/// blijft staan.
/// </summary>
private async Task StuurKijklinkAsync(Message message, Person sender, Person recipient, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(recipient.Email)) return;
var now = _clock.UtcNow;
var herinnering = false;
if (message.ViewTokenHash is not null)
{
// Al eerder een link gestuurd. Nog geldig? Dan is er niets te doen: de ontvanger
// heeft hem al, of moet hem nog openen - allebei geen reden voor een nieuwe mail.
if (message.ViewTokenExpiresAt is { } verloopt && verloopt > now) return;
// Verlopen, maar al geopend of al eens herinnerd: verder wachten heeft geen zin.
if (message.ViewedAt is not null || message.ViewReminderSentAt is not null) return;
herinnering = true;
}
var senderUser = await _users.FindByPersonIdAsync(message.FromPersonId, ct);
if (senderUser is null) return;
var token = TokenGenerator.Create();
var senderKey = _keys.Unprotect(senderUser.WrappedDataKey);
var tokenKey = TokenGenerator.DeriveKey(token, "bericht-inhoud");
byte[]? contentKey = null;
try
{
contentKey = EnvelopeCrypto.UnwrapKey(message.WrappedContentKey, senderKey);
message.WrappedContentKeyForToken = EnvelopeCrypto.WrapKey(contentKey, tokenKey);
// Zit er een bijlage bij, dan krijgt die een eigen, uit hetzelfde token afgeleide
// sleutel - een andere dan die van de tekst, zodat een van de twee lekken niet
// meteen de andere blootgeeft.
if (message.VaultFileId is { } bestandId &&
await _vault.FindFileAsync(bestandId, ct) is { DeletedAt: null } bestand)
{
var bestandTokenKey = TokenGenerator.DeriveKey(token, "bericht-bijlage");
byte[]? bestandsleutel = null;
try
{
bestandsleutel = EnvelopeCrypto.UnwrapKey(bestand.WrappedContentKey, senderKey);
message.WrappedVaultKeyForToken = EnvelopeCrypto.WrapKey(bestandsleutel, bestandTokenKey);
}
finally
{
if (bestandsleutel is not null) Array.Clear(bestandsleutel);
}
}
}
finally
{
Array.Clear(senderKey);
Array.Clear(tokenKey);
if (contentKey is not null) Array.Clear(contentKey);
}
message.ViewTokenHash = TokenGenerator.HashToken(token);
message.ViewTokenExpiresAt = now.Add(ViewLinkLifetime);
message.ViewTokenSentAt = now;
if (herinnering) message.ViewReminderSentAt = now;
message.UpdatedAt = now;
var link = $"{SiteUrl.TrimEnd('/')}/moment/{token}";
var onderwerp = herinnering
? "Je hebt nog een bericht openstaan in MijnBesluit"
: "Er staat een bericht voor je klaar in MijnBesluit";
var inleiding = herinnering
? "Je hebt dit bericht nog niet bekeken. De vorige link is verlopen; deze is weer 30 dagen geldig."
: $"{System.Net.WebUtility.HtmlEncode(sender.DisplayName)} heeft een bericht voor je klaargezet, " +
$"met de titel <strong>{System.Net.WebUtility.HtmlEncode(message.Subject)}</strong>.";
await _email.SendAsync(recipient.Email!, onderwerp, $"""
<p>Hallo {System.Net.WebUtility.HtmlEncode(recipient.DisplayName)},</p>
<p>{inleiding}</p>
<p>Je hoeft geen account aan te maken om het te bekijken - foto's, video's en
documenten die erbij zitten inbegrepen:</p>
<p><a href="{link}">{link}</a></p>
<p>Deze link is 30 dagen geldig. Wil je zelf ook een MijnBesluit beginnen, dan kan dat
vanaf dezelfde pagina.</p>
""", ct);
await _audit.WriteAsync(AuditAction.BerichtLinkVerstuurd, null, nameof(Message), message.Id,
herinnering ? "herinnering" : "eerste link", ct);
}
/// <summary>Wat de landingspagina van een kijklink te zien krijgt.</summary>
public class MomentWeergave
{
public string AfzenderNaam { get; set; } = string.Empty;
public string Subject { get; set; } = string.Empty;
public string Text { get; set; } = string.Empty;
public long? VaultFileId { get; set; }
public string? BestandsNaam { get; set; }
public string? BestandsType { get; set; }
/// <summary>Kan de ontvanger vanaf hier ook meteen zijn eigen profiel gaan claimen?</summary>
public bool KanNogClaimen { get; set; }
}
/// <summary>
/// De inhoud van een bericht bij een kijklink, voor iemand zonder account. Markeert het
/// bericht als geopend - dat is wat het verschil maakt tussen een link die na 30 dagen
/// een herinnering krijgt en een die dat niet doet.
/// </summary>
public async Task<Result<MomentWeergave>> ViewByTokenAsync(string token, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(token)) return Result<MomentWeergave>.Fail("Deze link is niet geldig.");
var message = await _messages.FindByViewTokenHashAsync(TokenGenerator.HashToken(token), ct);
if (message?.ViewTokenHash is null || !TokenGenerator.Matches(token, message.ViewTokenHash) ||
message.WrappedContentKeyForToken is null)
return Result<MomentWeergave>.Fail("Deze link is niet (meer) geldig.");
if (message.ViewTokenExpiresAt is not { } verloopt || verloopt <= _clock.UtcNow)
return Result<MomentWeergave>.Fail(
"Deze link is verlopen. Is er nog niets nieuws gestuurd, vraag de afzender dan " +
"gerust om het bericht opnieuw klaar te zetten.");
var sender = await _people.FindByIdAsync(message.FromPersonId, ct);
var recipient = await _people.FindByIdAsync(message.ToPersonId, ct);
var tokenKey = TokenGenerator.DeriveKey(token, "bericht-inhoud");
byte[]? contentKey = null;
string tekst;
try
{
contentKey = EnvelopeCrypto.UnwrapKey(message.WrappedContentKeyForToken, tokenKey);
var body = EnvelopeCrypto.OpenJson<MessageBody>(
new SealedData(message.Ciphertext, message.Nonce, message.Tag), contentKey);
tekst = body?.Text ?? string.Empty;
}
finally
{
Array.Clear(tokenKey);
if (contentKey is not null) Array.Clear(contentKey);
}
var bestand = message.VaultFileId is { } bestandId ? await _vault.FindFileAsync(bestandId, ct) : null;
if (bestand?.DeletedAt is not null) bestand = null;
if (message.ViewedAt is null)
{
message.ViewedAt = _clock.UtcNow;
await _audit.WriteAsync(AuditAction.BerichtLinkBekeken, null, nameof(Message), message.Id, null, ct);
await _uow.SaveChangesAsync(ct);
}
return Result<MomentWeergave>.Ok(new MomentWeergave
{
AfzenderNaam = sender?.DisplayName ?? "Iemand",
Subject = message.Subject,
Text = tekst,
VaultFileId = bestand?.Id,
BestandsNaam = bestand?.FileName,
BestandsType = bestand?.ContentType,
KanNogClaimen = recipient is not null && !recipient.IsClaimed
});
}
/// <summary>De bijlage bij een bericht dat via een kijklink wordt bekeken - zelfde token, zelfde geldigheid.</summary>
public async Task<Result<(VaultFile Bestand, byte[] Inhoud)>> OpenAttachmentByTokenAsync(
string token, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(token)) return Result<(VaultFile, byte[])>.Fail("Deze link is niet geldig.");
var message = await _messages.FindByViewTokenHashAsync(TokenGenerator.HashToken(token), ct);
if (message?.ViewTokenHash is null || !TokenGenerator.Matches(token, message.ViewTokenHash) ||
message.WrappedVaultKeyForToken is null)
return Result<(VaultFile, byte[])>.Fail("Deze link is niet (meer) geldig.");
if (message.ViewTokenExpiresAt is not { } verloopt || verloopt <= _clock.UtcNow)
return Result<(VaultFile, byte[])>.Fail("Deze link is verlopen.");
if (message.VaultFileId is not { } bestandId)
return Result<(VaultFile, byte[])>.Fail("Bij dit bericht zit geen bijlage.");
var bestand = await _vault.FindFileAsync(bestandId, ct);
if (bestand is null || bestand.DeletedAt is not null)
return Result<(VaultFile, byte[])>.Fail("Dit bestand bestaat niet meer.");
var tokenKey = TokenGenerator.DeriveKey(token, "bericht-bijlage");
byte[]? bestandsleutel = null;
try
{
bestandsleutel = EnvelopeCrypto.UnwrapKey(message.WrappedVaultKeyForToken, tokenKey);
var versleuteld = await _files.ReadAsync(bestand.StorageKey, ct);
var inhoud = EnvelopeCrypto.Open(new SealedData(versleuteld, bestand.Nonce, bestand.Tag), bestandsleutel);
return Result<(VaultFile, byte[])>.Ok((bestand, inhoud));
}
finally
{
Array.Clear(tokenKey);
if (bestandsleutel is not null) Array.Clear(bestandsleutel);
}
}
/// <summary>Leest een bezorgd bericht en markeert het als gelezen.</summary>
public async Task<Result<string>> ReadAsync(long messageId, long currentUserId, CancellationToken ct = default)
{
var message = await _messages.FindByIdAsync(messageId, ct);
if (message is null) return Result<string>.Fail("Dit bericht bestaat niet meer.");
var user = await _users.FindByIdAsync(currentUserId, ct);
if (user is null) return Result<string>.Fail("Account niet gevonden.");
if (message.ToPersonId != user.PersonId) return Result<string>.Fail("Dit bericht is niet voor jou.");
if (message.Status == MessageStatus.Gepland) return Result<string>.Fail("Dit bericht is nog niet vrijgegeven.");
var grant = await _vault.FindGrantAsync(nameof(Message), message.Id, currentUserId, ct);
if (grant is null || grant.RevokedAt is not null) return Result<string>.Fail("Je hebt geen toegang tot dit bericht.");
var dataKey = _keys.Unprotect(user.WrappedDataKey);
byte[]? contentKey = null;
try
{
contentKey = EnvelopeCrypto.UnwrapKey(grant.WrappedContentKey, dataKey);
var body = EnvelopeCrypto.OpenJson<MessageBody>(
new SealedData(message.Ciphertext, message.Nonce, message.Tag), contentKey);
if (message.Status != MessageStatus.Gelezen)
{
message.Status = MessageStatus.Gelezen;
message.ReadAt = _clock.UtcNow;
await _audit.WriteAsync(AuditAction.BerichtGelezen, currentUserId, nameof(Message), message.Id, null, ct);
await _uow.SaveChangesAsync(ct);
}
return Result<string>.Ok(body?.Text ?? string.Empty);
}
finally
{
Array.Clear(dataKey);
if (contentKey is not null) Array.Clear(contentKey);
}
}
public Task<IReadOnlyList<Message>> InboxAsync(long personId, CancellationToken ct = default) =>
_messages.ListInboxAsync(personId, ct);
/// <summary>Uitgaande post. Ingetrokken berichten laten we weg: die zijn weggehaald.</summary>
public async Task<IReadOnlyList<Message>> OutboxAsync(long personId, CancellationToken ct = default) =>
(await _messages.ListOutboxAsync(personId, ct))
.Where(m => m.Status != MessageStatus.Ingetrokken)
.ToList();
}
@@ -0,0 +1,157 @@
using MijnBesluit.Application.Abstractions;
using MijnBesluit.Application.Models;
using MijnBesluit.Domain.Enums;
using MijnBesluit.Domain.Rules;
namespace MijnBesluit.Application.Services;
/// <summary>Eén regel op het overzicht: iets wat speelt of eraan komt.</summary>
/// <param name="Soort">"beheerder", "beheer-voor", "moment", "overlijden".</param>
public record Overzichtsregel(string Soort, string Tekst, DateTime? Datum = null, long? Id = null);
/// <summary>Alles wat het beginscherm laat zien, in één antwoord.</summary>
public record Overzicht(
int AantalKluis,
int AantalFamilie,
// Momenten in twee richtingen: wat er voor jou klaarstaat en wat jij voor anderen
// hebt klaargezet. Die twee zijn het scherm Momenten zelf ook, als twee tabbladen.
int AantalVoorMij,
int AantalVanMij,
int AantalNieuweBerichten,
/// <summary>Hoeveel mensen jouw nalatenschap straks mogen beheren.</summary>
int AantalBeheerders,
int VooruitblikDagen,
IReadOnlyList<Overzichtsregel> Regels);
/// <summary>
/// Stelt het beginscherm samen: de aantallen voor de tegels, en daarboven wat er speelt.
///
/// Dat laatste is de bedoeling van het scherm: niet "hier zijn vier knoppen", maar "dit staat
/// er voor je klaar en dit heb je geregeld". Staat er niets, dan blijven alleen de tegels over.
/// </summary>
public class OverzichtService
{
private readonly IUserRepository _users;
private readonly IPersonRepository _people;
private readonly IFamilyRepository _families;
private readonly IVaultRepository _vault;
private readonly IMessageRepository _messages;
private readonly ILegacyRepository _legacy;
private readonly IUnitOfWork _uow;
private readonly IClock _clock;
public OverzichtService(IUserRepository users, IPersonRepository people, IFamilyRepository families,
IVaultRepository vault, IMessageRepository messages, ILegacyRepository legacy,
IUnitOfWork uow, IClock clock)
{
_users = users; _people = people; _families = families; _vault = vault;
_messages = messages; _legacy = legacy; _uow = uow; _clock = clock;
}
/// <summary>Het maximum dat iemand vooruit kan kijken. Verder is het geen vooruitblik meer.</summary>
public const int MaxVooruitblikDagen = 60;
public async Task<Result<Overzicht>> VoorAsync(long currentUserId, CancellationToken ct = default)
{
var user = await _users.FindByIdAsync(currentUserId, ct);
if (user is null) return Result<Overzicht>.Fail("Account niet gevonden.");
var nu = _clock.UtcNow;
var ik = user.PersonId;
var regels = new List<Overzichtsregel>();
// ---------------------------------------------------------------- aantallen
var kluis = await _vault.ListForPersonAsync(ik, null, ct);
var families = await _families.ListOwnedByPersonAsync(ik, ct);
var leden = families.Count == 0
? Array.Empty<Domain.Entities.FamilyMember>()
: (await _families.ListMembersAsync(families[0].Id, ct)).ToArray();
var inbox = await _messages.ListInboxAsync(ik, ct);
var outbox = await _messages.ListOutboxAsync(ik, ct);
var nieuw = inbox.Count(m => m.Status == MessageStatus.Bezorgd);
// ---------------------------------------------------------------- wie beheert wat
var beheerders = await _legacy.ListManagersForAsync(ik, ct);
foreach (var aanwijzing in beheerders)
{
var beheerder = await _people.FindByIdAsync(aanwijzing.ManagerPersonId, ct);
if (beheerder is null) continue;
regels.Add(new Overzichtsregel("beheerder",
$"{beheerder.DisplayName} beheert straks jouw nalatenschap.", null, beheerder.Id));
}
foreach (var opdracht in await _legacy.ListAssignmentsOfAsync(ik, ct))
{
var voor = await _people.FindByIdAsync(opdracht.PersonId, ct);
if (voor is null || voor.Id == ik) continue;
regels.Add(new Overzichtsregel("beheer-voor",
$"Jij beheert straks de nalatenschap van {voor.DisplayName}.", null, voor.Id));
}
// ---------------------------------------------------------------- loopt er iets
var mij = await _people.FindByIdAsync(ik, ct);
if (mij is not null)
{
var stand = DeathProcedure.Evaluate(mij, await _legacy.ListDeclarationsAsync(ik, ct),
await Beheerderskring.GebruikersAsync(_legacy, _users, ik, ct),
nu);
if (stand.State == DeathProcedureState.Lopend)
{
regels.Add(new Overzichtsregel("overlijden",
"Er is een overlijden op jouw naam gemeld. Laat weten dat je er nog bent.",
stand.ObjectionDeadline));
}
}
// ---------------------------------------------------------------- wat komt eraan
var dagen = Math.Clamp(user.VooruitblikDagen, 0, MaxVooruitblikDagen);
if (dagen > 0)
{
var tot = nu.AddDays(dagen);
foreach (var bericht in await _messages.ListPlannedForAsync(ik, ct))
{
// Berichten die pas na iemands overlijden komen, laten we hier weg. Anders staat
// er "op 12 maart komt er iets van Karin" terwijl dat alleen gebeurt als jij er
// niet meer bent. Dat hoort niet op een beginscherm.
if (bericht.DeliverAfterDeath || bericht.DeliverAt is not { } op) continue;
if (op < nu || op > tot) continue;
var van = await _people.FindByIdAsync(bericht.FromPersonId, ct);
regels.Add(new Overzichtsregel("moment",
$"Er komt een moment van {van?.DisplayName ?? "iemand"}.", op, bericht.Id));
}
}
// Wat een datum heeft eerst, op volgorde; de vaste regels daaronder.
var gesorteerd = regels
.OrderBy(r => r.Datum is null)
.ThenBy(r => r.Datum ?? DateTime.MaxValue)
.ToList();
return Result<Overzicht>.Ok(new Overzicht(
kluis.Count, leden.Length, inbox.Count, outbox.Count, nieuw,
beheerders.Count, dagen, gesorteerd));
}
/// <summary>Stelt in hoeveel dagen vooruit iemand wil kijken.</summary>
public async Task<Result> VooruitblikAsync(long currentUserId, int dagen, CancellationToken ct = default)
{
var user = await _users.FindByIdAsync(currentUserId, ct);
if (user is null) return Result.Fail("Account niet gevonden.");
if (dagen < 0 || dagen > MaxVooruitblikDagen)
return Result.Fail($"Kies een aantal dagen tussen 0 en {MaxVooruitblikDagen}.");
user.VooruitblikDagen = dagen;
user.UpdatedAt = _clock.UtcNow;
await _uow.SaveChangesAsync(ct);
return Result.Ok();
}
}
@@ -0,0 +1,555 @@
using System.Text.Json;
using MijnBesluit.Application.Abstractions;
using MijnBesluit.Application.Models;
using MijnBesluit.Domain.Entities;
using MijnBesluit.Domain.Enums;
namespace MijnBesluit.Application.Services;
/// <summary>
/// Voegt twee profielen van dezelfde persoon samen.
///
/// Waarom dit nodig is: twee mensen kunnen allebei een profiel aanmaken voor dezelfde persoon.
/// Jij voegt je dochter toe, je ex-partner doet dat ook. Dat zijn twee eerlijke aantekeningen
/// van degene die ze maakte, en zolang niemand het profiel heeft geclaimd is dat geen probleem.
///
/// Het wordt er pas een op het moment dat zij zelf zegt: die ben ik. Dat zegt ze door beide
/// uitnodigingen aan te nemen. Vanaf dat moment hoort er nog één Ysabeau te zijn, die jij en je
/// ex allebei zien - ieder met je eigen relatie erbij, want de relatie hangt aan het
/// lidmaatschap en niet aan de persoon.
///
/// Wat hier BEWUST niet gebeurt: op naam en geboortedatum zoeken naar mogelijke duplicaten.
/// Dat lijkt behulpzaam, maar wie zo'n vraag mag stellen heeft een zoekmachine op mensen. De
/// enige die mag zeggen dat twee profielen dezelfde persoon zijn, is die persoon zelf - en de
/// uitnodiging die zij aanneemt is precies dat bewijs.
/// </summary>
public class SamenvoegService
{
private readonly IPersonRepository _people;
private readonly IFamilyRepository _families;
private readonly IInvitationRepository _invitations;
private readonly IMessageRepository _messages;
private readonly IVaultRepository _vault;
private readonly ILegacyRepository _legacy;
private readonly IAuditLog _audit;
private readonly IUnitOfWork _uow;
private readonly IClock _clock;
private readonly ISamenvoegRepository? _boekhouding;
public SamenvoegService(IPersonRepository people, IFamilyRepository families,
IInvitationRepository invitations, IMessageRepository messages,
IVaultRepository vault, ILegacyRepository legacy, IAuditLog audit,
IUnitOfWork uow, IClock clock,
ISamenvoegRepository? boekhouding = null)
{
_people = people; _families = families; _invitations = invitations; _messages = messages;
_vault = vault; _legacy = legacy; _audit = audit; _uow = uow; _clock = clock;
_boekhouding = boekhouding;
}
/// <summary>
/// Eén handeling uit een samenvoeging, precies genoeg om hem terug te draaien.
///
/// <c>Soort</c> zegt wat er is gebeurd, <c>Id</c> om welke regel het gaat. Bij een regel die
/// is weggegooid is er geen Id meer om naar te wijzen; die staat als volledige kopie in
/// <c>Kopie</c>, zodat hij opnieuw kan worden aangemaakt.
/// </summary>
public record Samenvoegstap(string Soort, long Id = 0, string? Veld = null, string? Kopie = null);
private static readonly JsonSerializerOptions Jsonopzet = new(JsonSerializerDefaults.Web);
/// <summary>
/// Laat <paramref name="duplicaatId"/> opgaan in <paramref name="doelId"/>. Alles wat naar
/// het duplicaat verwees gaat mee; het duplicaat blijft staan met een verwijzing naar waar
/// hij in is opgegaan.
/// </summary>
/// <param name="currentUserId">
/// Wie dit doet. Dat hoort altijd de persoon zelf te zijn: alleen zij mag zeggen dat twee
/// profielen haar zijn.
/// </param>
public async Task<Result> SamenvoegAsync(long duplicaatId, long doelId, long currentUserId,
CancellationToken ct = default)
{
if (duplicaatId == doelId) return Result.Ok(); // niets te doen
var duplicaat = await _people.FindByIdAsync(duplicaatId, ct);
var doel = await _people.FindByIdAsync(doelId, ct);
if (duplicaat is null || doel is null) return Result.Fail("Een van beide profielen bestaat niet meer.");
if (duplicaat.IsSamengevoegd) return Result.Ok(); // al eerder gedaan
// Het doel moet van degene zijn die dit doet. Zonder die regel kan iemand andermans
// profielen aan elkaar knopen.
if (doel.ClaimedByUserId != currentUserId)
return Result.Fail("Je kunt alleen profielen samenvoegen met je eigen profiel.");
// Twee geclaimde profielen samenvoegen is nooit automatisch goed: dat zijn twee echte
// accounts, allebei met een eigen kluis. Daar hoort een mens naar te kijken.
if (duplicaat.IsClaimed && duplicaat.ClaimedByUserId != currentUserId)
return Result.Fail("Dit profiel heeft al een eigen account. Neem contact op met de beheerder.");
var nu = _clock.UtcNow;
// Alles wat we doen schrijven we op. Zonder die lijst is achteraf niet meer te zien wélke
// lidmaatschappen, berichten en bestanden van het duplicaat waren - ze staan dan gewoon
// bij het doel, tussen alles wat daar al stond - en is terugdraaien dus onmogelijk.
var stappen = new List<Samenvoegstap>();
await VerhuisLidmaatschappenAsync(duplicaatId, doelId, stappen, ct);
await VerhuisBerichtenAsync(duplicaatId, doelId, stappen, ct);
await VerhuisBestandenAsync(duplicaatId, doelId, stappen, ct);
await VerhuisUitnodigingenAsync(duplicaatId, doelId, stappen, ct);
await VerhuisNalatenschapAsync(duplicaatId, doelId, stappen, ct);
await VerhuisFamiliesAsync(duplicaatId, doelId, stappen, ct);
VulAanWatOntbreekt(duplicaat, doel, nu, stappen);
duplicaat.MergedIntoPersonId = doelId;
duplicaat.UpdatedAt = nu;
_boekhouding?.Add(new Samenvoeging
{
DuplicaatPersonId = duplicaatId,
DoelPersonId = doelId,
DoorUserId = currentUserId,
SamengevoegdOp = nu,
Stappen = JsonSerializer.Serialize(stappen, Jsonopzet)
});
await _audit.WriteAsync(AuditAction.ProfielGeclaimd, currentUserId,
nameof(Person), duplicaatId, $"samengevoegd met {doelId}", ct);
await _uow.SaveChangesAsync(ct);
return Result.Ok();
}
/// <summary>
/// De lidmaatschappen. Staat het doel al in die familie, dan verdwijnt het lidmaatschap van
/// het duplicaat: anders zou iemand twee keer in dezelfde lijst staan. De relatie die er al
/// stond blijft dan leidend - die is ingevuld door de eigenaar van die familie.
/// </summary>
private async Task VerhuisLidmaatschappenAsync(long duplicaatId, long doelId,
List<Samenvoegstap> stappen, CancellationToken ct)
{
foreach (var lidmaatschap in await _families.ListMembershipsOfPersonAsync(duplicaatId, ct))
{
var bestaand = await _families.FindMemberAsync(lidmaatschap.FamilyId, doelId, ct);
if (bestaand is null)
{
lidmaatschap.PersonId = doelId;
stappen.Add(new Samenvoegstap("lid-verplaatst", lidmaatschap.Id));
}
else
{
// Deze regel verdwijnt. Het Id verdwijnt daarmee ook, dus gaat er een volledige
// kopie mee: terugdraaien betekent hier opnieuw aanmaken.
stappen.Add(new Samenvoegstap("lid-weggehaald", Kopie: JsonSerializer.Serialize(new
{
lidmaatschap.FamilyId, lidmaatschap.PersonId, Relatie = (int)lidmaatschap.Relation,
lidmaatschap.RelationLabel, lidmaatschap.AddedByUserId, lidmaatschap.CreatedAt
}, Jsonopzet)));
_families.Remove(lidmaatschap);
}
}
}
private async Task VerhuisBerichtenAsync(long duplicaatId, long doelId,
List<Samenvoegstap> stappen, CancellationToken ct)
{
foreach (var bericht in await _messages.ListAllTouchingAsync(duplicaatId, ct))
{
// Afzender en ontvanger apart: een bericht van het duplicaat aan zichzelf zou anders
// maar half terugdraaien.
if (bericht.FromPersonId == duplicaatId)
{
bericht.FromPersonId = doelId;
stappen.Add(new Samenvoegstap("bericht-afzender", bericht.Id));
}
if (bericht.ToPersonId == duplicaatId)
{
bericht.ToPersonId = doelId;
stappen.Add(new Samenvoegstap("bericht-ontvanger", bericht.Id));
}
}
}
/// <summary>
/// Foto's waar de persoon op staat, en bestanden die hij bewaart. Hoogstens één foto mag de
/// profielfoto zijn; heeft het doel er al een, dan verliest die van het duplicaat die rol.
/// De foto zelf blijft gewoon staan.
/// </summary>
private async Task VerhuisBestandenAsync(long duplicaatId, long doelId,
List<Samenvoegstap> stappen, CancellationToken ct)
{
var alHier = await _vault.ListFilesForPersonAsync(doelId, ct);
var heeftAlProfielfoto = alHier.Any(f => f.IsProfilePhoto && f.DeletedAt is null);
foreach (var bestand in await _vault.ListFilesForPersonAsync(duplicaatId, ct))
{
bestand.SubjectPersonId = doelId;
stappen.Add(new Samenvoegstap("bestand-onderwerp", bestand.Id));
if (bestand.IsProfilePhoto && heeftAlProfielfoto)
{
bestand.IsProfilePhoto = false;
stappen.Add(new Samenvoegstap("bestand-geen-profielfoto-meer", bestand.Id));
}
else if (bestand.IsProfilePhoto) heeftAlProfielfoto = true;
}
foreach (var bestand in await _vault.ListFilesOwnedByAsync(duplicaatId, ct))
{
bestand.OwnerPersonId = doelId;
stappen.Add(new Samenvoegstap("bestand-eigenaar", bestand.Id));
}
foreach (var item in await _vault.ListForPersonAsync(duplicaatId, null, ct))
{
item.OwnerPersonId = doelId;
stappen.Add(new Samenvoegstap("kluisitem-eigenaar", item.Id));
}
}
private async Task VerhuisUitnodigingenAsync(long duplicaatId, long doelId,
List<Samenvoegstap> stappen, CancellationToken ct)
{
foreach (var uitnodiging in await _invitations.ListAllForPersonAsync(duplicaatId, ct))
{
uitnodiging.PersonId = doelId;
stappen.Add(new Samenvoegstap("uitnodiging", uitnodiging.Id));
}
}
/// <summary>
/// Rollen rond een nalatenschap. Een dubbele aanwijzing - dezelfde beheerder twee keer voor
/// hetzelfde profiel - halen we weg, net als een tweede overlijdensmelding van dezelfde
/// gebruiker: die telt maar één keer mee en zou het aantal anders vervalsen.
/// </summary>
private async Task VerhuisNalatenschapAsync(long duplicaatId, long doelId,
List<Samenvoegstap> stappen, CancellationToken ct)
{
var bijDoel = await _legacy.ListManagersForAsync(doelId, ct);
foreach (var beheer in await _legacy.ListManagersForAsync(duplicaatId, ct))
{
if (bijDoel.Any(b => b.ManagerPersonId == beheer.ManagerPersonId))
{
stappen.Add(new Samenvoegstap("beheerder-weggehaald", Kopie: Kopieer(beheer)));
_legacy.Remove(beheer);
}
else
{
beheer.PersonId = doelId;
stappen.Add(new Samenvoegstap("beheerder-verplaatst", beheer.Id));
}
}
var opdrachtenDoel = await _legacy.ListAssignmentsOfAsync(doelId, ct);
foreach (var opdracht in await _legacy.ListAssignmentsOfAsync(duplicaatId, ct))
{
if (opdrachtenDoel.Any(o => o.PersonId == opdracht.PersonId))
{
stappen.Add(new Samenvoegstap("opdracht-weggehaald", Kopie: Kopieer(opdracht)));
_legacy.Remove(opdracht);
}
else
{
opdracht.ManagerPersonId = doelId;
stappen.Add(new Samenvoegstap("opdracht-verplaatst", opdracht.Id));
}
}
var meldingenDoel = await _legacy.ListAllDeclarationsAsync(doelId, ct);
foreach (var melding in await _legacy.ListAllDeclarationsAsync(duplicaatId, ct))
{
// Een dubbele melding van dezelfde gebruiker blijft gewoon bij het duplicaat staan.
// Verhuizen zou het aantal vervalsen, weggooien zou geschiedenis wissen.
if (meldingenDoel.Any(m => m.DeclaredByUserId == melding.DeclaredByUserId)) continue;
melding.PersonId = doelId;
stappen.Add(new Samenvoegstap("melding-verplaatst", melding.Id));
}
}
private static string Kopieer(LegacyManager rol) => JsonSerializer.Serialize(new
{
rol.PersonId, rol.ManagerPersonId, rol.Priority, rol.AcceptedAt, rol.AccessGrantedAt, rol.CreatedAt
}, Jsonopzet);
private async Task VerhuisFamiliesAsync(long duplicaatId, long doelId,
List<Samenvoegstap> stappen, CancellationToken ct)
{
foreach (var familie in await _families.ListOwnedAsync(duplicaatId, ct))
{
familie.OwnerPersonId = doelId;
stappen.Add(new Samenvoegstap("familie-eigenaar", familie.Id));
}
}
/// <summary>
/// Wat het duplicaat wist en het doel niet, nemen we over. Alleen lege velden vullen, nooit
/// overschrijven: wat er bij het doel staat is van de persoon zelf, en dat weegt zwaarder
/// dan wat een ander ooit heeft ingevuld.
/// </summary>
private static void VulAanWatOntbreekt(Person duplicaat, Person doel, DateTime nu,
List<Samenvoegstap> stappen)
{
var gewijzigd = false;
if (doel.BirthDate is null && duplicaat.BirthDate is not null)
{
doel.BirthDate = duplicaat.BirthDate;
stappen.Add(new Samenvoegstap("doel-aangevuld", doel.Id, nameof(Person.BirthDate)));
gewijzigd = true;
}
if (doel.DateOfDeath is null && duplicaat.DateOfDeath is not null)
{
doel.DateOfDeath = duplicaat.DateOfDeath;
stappen.Add(new Samenvoegstap("doel-aangevuld", doel.Id, nameof(Person.DateOfDeath)));
gewijzigd = true;
}
if (string.IsNullOrWhiteSpace(doel.MobileNumber) && !string.IsNullOrWhiteSpace(duplicaat.MobileNumber))
{
doel.MobileNumber = duplicaat.MobileNumber;
stappen.Add(new Samenvoegstap("doel-aangevuld", doel.Id, nameof(Person.MobileNumber)));
gewijzigd = true;
}
if (gewijzigd) doel.UpdatedAt = nu;
}
// ------------------------------------------------------------------ terugdraaien
/// <summary>Wat er van een samenvoeging te zien is op de beheerpagina.</summary>
public record Samenvoegbeeld(long Id, long DuplicaatPersonId, string DuplicaatNaam,
long DoelPersonId, string DoelNaam, DateTime SamengevoegdOp,
int Stappen, bool IsTeruggedraaid, DateTime? TeruggedraaidOp);
/// <summary>
/// De samenvoegingen waar dit profiel bij betrokken was, als duplicaat of als doel.
/// Nieuwste eerst.
/// </summary>
public async Task<IReadOnlyList<Samenvoegbeeld>> GeschiedenisAsync(long personId,
CancellationToken ct = default)
{
if (_boekhouding is null) return Array.Empty<Samenvoegbeeld>();
var alles = (await _boekhouding.ListVoorDuplicaatAsync(personId, ct))
.Concat(await _boekhouding.ListVoorDoelAsync(personId, ct))
.DistinctBy(x => x.Id)
.OrderByDescending(x => x.SamengevoegdOp)
.ToList();
var uitkomst = new List<Samenvoegbeeld>(alles.Count);
foreach (var bon in alles)
{
var duplicaat = await _people.FindByIdAsync(bon.DuplicaatPersonId, ct);
var doel = await _people.FindByIdAsync(bon.DoelPersonId, ct);
uitkomst.Add(new Samenvoegbeeld(
bon.Id, bon.DuplicaatPersonId, duplicaat?.DisplayName ?? "onbekend",
bon.DoelPersonId, doel?.DisplayName ?? "onbekend", bon.SamengevoegdOp,
Lees(bon.Stappen).Count, bon.IsTeruggedraaid, bon.TeruggedraaidOp));
}
return uitkomst;
}
/// <summary>
/// Draait een samenvoeging terug: alles wat toen is verhuisd gaat terug naar het duplicaat,
/// en dat profiel staat daarna weer op zichzelf.
///
/// Dit is bewust <c>internal</c>: alleen <see cref="BeheerService"/> mag hierbij. Een
/// gebruiker kan zelf samenvoegen omdat alleen hij mag zeggen dat twee profielen hem zijn.
/// Terugdraaien is iets anders - dat raakt ook de gegevens van de ánder, die het duplicaat
/// ooit heeft aangemaakt - en hoort daarom langs een mens te gaan.
///
/// Wat er NIET gebeurt: wat er ná de samenvoeging is bijgekomen blijft bij het doel staan.
/// Dat is geen tekortkoming maar de bedoeling: die dingen zijn daar aangemaakt en horen daar.
/// Alleen wat in het bonnetje staat gaat terug.
/// </summary>
internal async Task<Result> TerugdraaienAsync(long samenvoegingId, long beheerderUserId, string reden,
CancellationToken ct = default)
{
if (_boekhouding is null) return Result.Fail("Samenvoegingen worden hier niet vastgelegd.");
var bon = await _boekhouding.FindByIdAsync(samenvoegingId, ct);
if (bon is null) return Result.Fail("Deze samenvoeging bestaat niet.");
if (bon.IsTeruggedraaid) return Result.Fail("Deze samenvoeging is al teruggedraaid.");
var duplicaat = await _people.FindByIdAsync(bon.DuplicaatPersonId, ct);
var doel = await _people.FindByIdAsync(bon.DoelPersonId, ct);
if (duplicaat is null || doel is null) return Result.Fail("Een van beide profielen bestaat niet meer.");
// Is het doel zelf later ergens in opgegaan, dan staan de gegevens inmiddels een stap
// verderop en wijst het bonnetje niet meer naar waar ze zijn. Eerst die terugdraaien.
if (doel.IsSamengevoegd)
return Result.Fail("Het doelprofiel is zelf later samengevoegd. Draai die samenvoeging eerst terug.");
var nu = _clock.UtcNow;
// In omgekeerde volgorde: het aanvullen van velden ging als laatste, dus komt het eerst
// weer ongedaan. Bij verplaatsingen maakt de volgorde niet uit, bij weggegooide regels
// wel - die worden opnieuw aangemaakt en mogen niet botsen met iets wat er nog staat.
foreach (var stap in Enumerable.Reverse(Lees(bon.Stappen)))
await DraaiStapTerugAsync(stap, duplicaat, doel, ct);
duplicaat.MergedIntoPersonId = null;
duplicaat.UpdatedAt = nu;
bon.TeruggedraaidOp = nu;
bon.TeruggedraaidDoorUserId = beheerderUserId;
bon.Reden = reden;
await _audit.WriteAsync(AuditAction.ProfielGewijzigd, beheerderUserId, nameof(Person),
duplicaat.Id, $"beheer: samenvoeging {bon.Id} teruggedraaid - {reden}", ct);
await _uow.SaveChangesAsync(ct);
return Result.Ok();
}
private async Task DraaiStapTerugAsync(Samenvoegstap stap, Person duplicaat, Person doel,
CancellationToken ct)
{
var terug = duplicaat.Id;
switch (stap.Soort)
{
case "lid-verplaatst":
foreach (var lid in await _families.ListMembershipsOfPersonAsync(doel.Id, ct))
if (lid.Id == stap.Id) lid.PersonId = terug;
break;
case "lid-weggehaald":
{
var oud = Ontleed<Lidkopie>(stap.Kopie);
if (oud is null) break;
// Alleen aanmaken als er niet inmiddels weer zo'n regel staat; anders zou dezelfde
// persoon twee keer in dezelfde familie komen.
if (await _families.FindMemberAsync(oud.FamilyId, oud.PersonId, ct) is null)
{
_families.Add(new FamilyMember
{
FamilyId = oud.FamilyId,
PersonId = oud.PersonId,
Relation = (RelationType)oud.Relatie,
RelationLabel = oud.RelationLabel,
AddedByUserId = oud.AddedByUserId,
CreatedAt = oud.CreatedAt
});
}
break;
}
case "bericht-afzender":
case "bericht-ontvanger":
foreach (var bericht in await _messages.ListAllTouchingAsync(doel.Id, ct))
{
if (bericht.Id != stap.Id) continue;
if (stap.Soort == "bericht-afzender") bericht.FromPersonId = terug;
else bericht.ToPersonId = terug;
}
break;
case "bestand-onderwerp":
foreach (var bestand in await _vault.ListFilesForPersonAsync(doel.Id, ct))
if (bestand.Id == stap.Id) bestand.SubjectPersonId = terug;
break;
case "bestand-geen-profielfoto-meer":
if (await _vault.FindFileAsync(stap.Id, ct) is { } foto) foto.IsProfilePhoto = true;
break;
case "bestand-eigenaar":
foreach (var bestand in await _vault.ListFilesOwnedByAsync(doel.Id, ct))
if (bestand.Id == stap.Id) bestand.OwnerPersonId = terug;
break;
case "kluisitem-eigenaar":
if (await _vault.FindByIdAsync(stap.Id, ct) is { } item) item.OwnerPersonId = terug;
break;
case "uitnodiging":
foreach (var uitnodiging in await _invitations.ListAllForPersonAsync(doel.Id, ct))
if (uitnodiging.Id == stap.Id) uitnodiging.PersonId = terug;
break;
case "beheerder-verplaatst":
foreach (var rol in await _legacy.ListManagersForAsync(doel.Id, ct))
if (rol.Id == stap.Id) rol.PersonId = terug;
break;
case "opdracht-verplaatst":
foreach (var rol in await _legacy.ListAssignmentsOfAsync(doel.Id, ct))
if (rol.Id == stap.Id) rol.ManagerPersonId = terug;
break;
case "beheerder-weggehaald":
case "opdracht-weggehaald":
{
var oud = Ontleed<Rolkopie>(stap.Kopie);
if (oud is null) break;
var bestaat = (await _legacy.ListManagersForAsync(oud.PersonId, ct))
.Any(r => r.ManagerPersonId == oud.ManagerPersonId);
if (!bestaat)
{
_legacy.Add(new LegacyManager
{
PersonId = oud.PersonId,
ManagerPersonId = oud.ManagerPersonId,
Priority = oud.Priority,
AcceptedAt = oud.AcceptedAt,
AccessGrantedAt = oud.AccessGrantedAt,
CreatedAt = oud.CreatedAt
});
}
break;
}
case "melding-verplaatst":
foreach (var melding in await _legacy.ListAllDeclarationsAsync(doel.Id, ct))
if (melding.Id == stap.Id) melding.PersonId = terug;
break;
case "familie-eigenaar":
foreach (var familie in await _families.ListOwnedAsync(doel.Id, ct))
if (familie.Id == stap.Id) familie.OwnerPersonId = terug;
break;
case "doel-aangevuld":
// Alleen leegmaken wat wij hebben ingevuld. Heeft de persoon het daarna zelf
// aangepast, dan is het van hem en blijft het staan.
if (stap.Veld == nameof(Person.BirthDate) && doel.BirthDate == duplicaat.BirthDate)
doel.BirthDate = null;
else if (stap.Veld == nameof(Person.DateOfDeath) && doel.DateOfDeath == duplicaat.DateOfDeath)
doel.DateOfDeath = null;
else if (stap.Veld == nameof(Person.MobileNumber) && doel.MobileNumber == duplicaat.MobileNumber)
doel.MobileNumber = null;
break;
}
}
private static List<Samenvoegstap> Lees(string json)
{
try { return JsonSerializer.Deserialize<List<Samenvoegstap>>(json, Jsonopzet) ?? new(); }
catch (JsonException) { return new(); }
}
private static T? Ontleed<T>(string? json) where T : class
{
if (string.IsNullOrWhiteSpace(json)) return null;
try { return JsonSerializer.Deserialize<T>(json, Jsonopzet); }
catch (JsonException) { return null; }
}
private sealed record Lidkopie(long FamilyId, long PersonId, int Relatie, string? RelationLabel,
long? AddedByUserId, DateTime CreatedAt);
private sealed record Rolkopie(long PersonId, long ManagerPersonId, int Priority,
DateTime? AcceptedAt, DateTime? AccessGrantedAt, DateTime CreatedAt);
}
@@ -0,0 +1,931 @@
using MijnBesluit.Application.Abstractions;
using MijnBesluit.Application.Models;
using MijnBesluit.Application.Security;
using MijnBesluit.Domain.Entities;
using MijnBesluit.Domain.Enums;
using MijnBesluit.Domain.Rules;
namespace MijnBesluit.Application.Services;
/// <summary>
/// De digitale kluis. Elk item wordt versleuteld met een eigen contentsleutel; die sleutel wordt
/// ingepakt met de datasleutel van de eigenaar. Toegang geven aan een ander is daardoor niets meer
/// dan diezelfde contentsleutel nóg een keer inpakken, nu met de datasleutel van die ander.
/// </summary>
public class VaultService
{
private readonly IVaultRepository _vault;
private readonly IPersonRepository _people;
private readonly IMessageRepository _messages;
private readonly IUserRepository _users;
private readonly IFamilyRepository? _families;
private readonly ILegacyRepository? _legacy;
private readonly IMasterKeyProtector _keys;
private readonly IFileStore _files;
private readonly IAuditLog _audit;
private readonly IUnitOfWork _uow;
private readonly IClock _clock;
private readonly IAfbeeldingVerkleiner _verkleiner;
/// <summary>De langste zijde van de kleine versie die bij een foto wordt bewaard.</summary>
public const int ThumbnailZijde = 160;
/// <summary>
/// De langste zijde waarop een foto wordt bewaard. Een toestelcamera maakt tegenwoordig
/// bestanden van tien megabyte; op een scherm zie je het verschil met 1600 pixels niet, in
/// je opslag en op een mobiele verbinding merk je het meteen.
///
/// De app verkleint zelf al vóór het versturen, dus daarvandaan komt hier meestal niets meer
/// binnen dat groter is. Voor de website is dit de enige plek waar het gebeurt.
/// </summary>
public const int FotoMaxZijde = 1600;
/// <summary>Onder deze grootte is verkleinen de moeite niet waard.</summary>
public const int VerkleinVanaf = 1024 * 1024;
public VaultService(IVaultRepository vault, IPersonRepository people, IMessageRepository messages,
IUserRepository users, IMasterKeyProtector keys, IFileStore files, IAuditLog audit,
IUnitOfWork uow, IClock clock, IAfbeeldingVerkleiner? verkleiner = null,
IFamilyRepository? families = null, ILegacyRepository? legacy = null)
{
_vault = vault; _people = people; _messages = messages; _users = users; _keys = keys;
_files = files; _audit = audit; _uow = uow; _clock = clock; _families = families;
_verkleiner = verkleiner ?? new GeenAfbeeldingVerkleiner();
_legacy = legacy;
}
/// <summary>Titels en categorieën van de kluis. De inhoud blijft versleuteld tot hij echt geopend wordt.</summary>
public Task<IReadOnlyList<VaultItem>> ListAsync(long personId, VaultCategory? category = null,
CancellationToken ct = default) =>
_vault.ListForPersonAsync(personId, category, ct);
/// <summary>Slaat een nieuw kluisitem versleuteld op.</summary>
public async Task<Result<VaultItem>> CreateAsync(long currentUserId, VaultCategory category, string title,
VaultPayload payload, ReleasePolicy release = ReleasePolicy.AlleenIkzelf,
DateTime? releaseAt = null, long? sharedManagerPersonId = null,
CancellationToken ct = default)
{
var user = await _users.FindByIdAsync(currentUserId, ct);
if (user is null) return Result<VaultItem>.Fail("Account niet gevonden.");
if (string.IsNullOrWhiteSpace(title)) return Result<VaultItem>.Fail("Geef het item een titel.");
if (release == ReleasePolicy.OpDatum && releaseAt is null)
return Result<VaultItem>.Fail("Kies een datum waarop dit item vrijkomt.");
var now = _clock.UtcNow;
var dataKey = _keys.Unprotect(user.WrappedDataKey);
var contentKey = EnvelopeCrypto.NewKey();
try
{
var sealedData = EnvelopeCrypto.SealJson(payload, contentKey);
var item = new VaultItem
{
OwnerPersonId = user.PersonId,
Category = category,
Title = title.Trim(),
Ciphertext = sealedData.Ciphertext,
Nonce = sealedData.Nonce,
Tag = sealedData.Tag,
WrappedContentKey = EnvelopeCrypto.WrapKey(contentKey, dataKey),
ReleasePolicy = release,
ReleaseAt = releaseAt,
SharedManagerPersonId = KanBesprekenMet(category) ? sharedManagerPersonId : null,
CreatedAt = now,
UpdatedAt = now
};
_vault.Add(item);
await _audit.WriteAsync(AuditAction.KluisItemGewijzigd, currentUserId, nameof(VaultItem), null, "nieuw", ct);
await _uow.SaveChangesAsync(ct);
if (item.SharedManagerPersonId is { } gekozenBeheerder1)
await DeelMetBeheerderAsync(item, user, gekozenBeheerder1, ct);
return Result<VaultItem>.Ok(item);
}
finally
{
Array.Clear(dataKey);
Array.Clear(contentKey);
}
}
/// <summary>
/// Wijzigt een kluisitem. Alleen de eigenaar kan dit: wie er toegang toe heeft gekregen
/// mag meelezen, niet aanpassen.
///
/// De inhoud wordt opnieuw versleuteld met een nieuwe contentsleutel. Dat is bewust: de oude
/// sleutel kan gedeeld zijn met iemand anders, en die zou anders de nieuwe inhoud kunnen lezen.
/// </summary>
public async Task<Result> UpdateAsync(long itemId, long currentUserId, VaultCategory category, string title,
VaultPayload payload, ReleasePolicy release, DateTime? releaseAt = null,
long? sharedManagerPersonId = null, CancellationToken ct = default)
{
var item = await _vault.FindByIdAsync(itemId, ct);
if (item is null || item.DeletedAt is not null) return Result.Fail("Dit item bestaat niet meer.");
var user = await _users.FindByIdAsync(currentUserId, ct);
if (user is null) return Result.Fail("Account niet gevonden.");
if (item.OwnerPersonId != user.PersonId) return Result.Fail("Dit item is niet van jou.");
if (string.IsNullOrWhiteSpace(title)) return Result.Fail("Geef het item een titel.");
if (release == ReleasePolicy.OpDatum && releaseAt is null)
return Result.Fail("Kies een datum waarop dit item vrijkomt.");
var now = _clock.UtcNow;
var dataKey = _keys.Unprotect(user.WrappedDataKey);
var contentKey = EnvelopeCrypto.NewKey();
try
{
var sealedData = EnvelopeCrypto.SealJson(payload, contentKey);
item.Category = category;
item.Title = title.Trim();
item.Ciphertext = sealedData.Ciphertext;
item.Nonce = sealedData.Nonce;
item.Tag = sealedData.Tag;
item.WrappedContentKey = EnvelopeCrypto.WrapKey(contentKey, dataKey);
item.ReleasePolicy = release;
item.ReleaseAt = releaseAt;
item.SharedManagerPersonId = KanBesprekenMet(category) ? sharedManagerPersonId : null;
item.UpdatedAt = now;
// Eerder verleende toegang was op de oude sleutel; die is nu waardeloos. Wie het item
// opnieuw mag zien (bijvoorbeeld dezelfde gekozen beheerder), krijgt dat hieronder
// opnieuw toegewezen. Kiest de eigenaar niemand meer, dan blijft de toegang gewoon weg.
await _vault.RemoveGrantsAsync(itemId, ct);
await _audit.WriteAsync(AuditAction.KluisItemGewijzigd, currentUserId, nameof(VaultItem), item.Id, "gewijzigd", ct);
await _uow.SaveChangesAsync(ct);
if (item.SharedManagerPersonId is { } gekozenBeheerder2)
await DeelMetBeheerderAsync(item, user, gekozenBeheerder2, ct);
return Result.Ok();
}
finally
{
Array.Clear(dataKey);
Array.Clear(contentKey);
}
}
/// <summary>
/// Verwijdert een kluisitem. Het item wordt gemarkeerd als verwijderd en is nergens meer
/// op te vragen; de versleutelde inhoud blijft nog even staan, zodat een vergissing te
/// herstellen is zolang de back-up van die dag er nog is.
/// </summary>
public async Task<Result> DeleteAsync(long itemId, long currentUserId, CancellationToken ct = default)
{
var item = await _vault.FindByIdAsync(itemId, ct);
if (item is null || item.DeletedAt is not null) return Result.Fail("Dit item bestaat niet meer.");
var user = await _users.FindByIdAsync(currentUserId, ct);
if (user is null) return Result.Fail("Account niet gevonden.");
if (item.OwnerPersonId != user.PersonId) return Result.Fail("Dit item is niet van jou.");
item.DeletedAt = _clock.UtcNow;
item.UpdatedAt = item.DeletedAt.Value;
// De bijlagen horen bij dit item; die gaan mee. Anders blijven er versleutelde bestanden
// op de schijf staan waar niemand meer bij kan en die niemand meer opruimt.
foreach (var bijlage in await _vault.ListFilesForItemAsync(itemId, ct))
{
bijlage.DeletedAt = item.DeletedAt;
try { await _files.DeleteAsync(bijlage.StorageKey, ct); }
catch (Exception) { /* Een opruimfout mag het verwijderen niet tegenhouden. */ }
}
await _vault.RemoveGrantsAsync(itemId, ct);
await _audit.WriteAsync(AuditAction.KluisItemVerwijderd, currentUserId, nameof(VaultItem), item.Id, item.Title, ct);
await _uow.SaveChangesAsync(ct);
return Result.Ok();
}
/// <summary>
/// Opent een kluisitem. Dat lukt als je de eigenaar bent, of als je er toegang toe hebt gekregen
/// (bijvoorbeeld als nalatenschapsbeheerder na een bevestigd overlijden).
/// </summary>
public async Task<Result<VaultPayload>> OpenAsync(long itemId, long currentUserId, CancellationToken ct = default)
{
var item = await _vault.FindByIdAsync(itemId, ct);
if (item is null || item.DeletedAt is not null) return Result<VaultPayload>.Fail("Dit item bestaat niet meer.");
var user = await _users.FindByIdAsync(currentUserId, ct);
if (user is null) return Result<VaultPayload>.Fail("Account niet gevonden.");
var dataKey = _keys.Unprotect(user.WrappedDataKey);
try
{
byte[]? contentKey = null;
if (item.OwnerPersonId == user.PersonId)
{
contentKey = EnvelopeCrypto.UnwrapKey(item.WrappedContentKey, dataKey);
}
else
{
var grant = await _vault.FindGrantAsync(nameof(VaultItem), item.Id, currentUserId, ct);
if (grant is not null && grant.RevokedAt is null)
contentKey = EnvelopeCrypto.UnwrapKey(grant.WrappedContentKey, dataKey);
}
if (contentKey is null)
return Result<VaultPayload>.Fail("Je hebt geen toegang tot dit item.");
try
{
var payload = EnvelopeCrypto.OpenJson<VaultPayload>(
new SealedData(item.Ciphertext, item.Nonce, item.Tag), contentKey) ?? new VaultPayload();
await _audit.WriteAsync(AuditAction.KluisItemGelezen, currentUserId, nameof(VaultItem), item.Id, null, ct);
await _uow.SaveChangesAsync(ct);
return Result<VaultPayload>.Ok(payload);
}
finally { Array.Clear(contentKey); }
}
finally { Array.Clear(dataKey); }
}
/// <summary>
/// Geeft een ander toegang tot een kluisitem door de contentsleutel opnieuw in te pakken
/// met diens datasleutel. Wordt gebruikt bij het vrijgeven van de nalatenschap, en bij het
/// delen van een uitvaartwens om vooraf te bespreken.
///
/// De bijlagen bij dit item (een foto, een document) krijgen in dezelfde beurt hun eigen
/// toegang: wie mag zien wat er staat, mag ook zien wat erbij hangt. Elke bijlage heeft zijn
/// eigen contentsleutel, dus dat is een aparte toewijzing per bijlage - vandaar de lus.
/// </summary>
public async Task<Result> GrantAsync(long itemId, long ownerUserId, long toUserId, string reason,
CancellationToken ct = default)
{
var item = await _vault.FindByIdAsync(itemId, ct);
if (item is null) return Result.Fail("Dit item bestaat niet meer.");
var owner = await _users.FindByIdAsync(ownerUserId, ct);
var receiver = await _users.FindByIdAsync(toUserId, ct);
if (owner is null || receiver is null) return Result.Fail("Account niet gevonden.");
if (item.OwnerPersonId != owner.PersonId) return Result.Fail("Alleen de eigenaar kan toegang geven.");
var ownerKey = _keys.Unprotect(owner.WrappedDataKey);
var receiverKey = _keys.Unprotect(receiver.WrappedDataKey);
try
{
await GrantOverAsync(nameof(VaultItem), item.Id, item.WrappedContentKey, toUserId,
ownerKey, receiverKey, reason, ct);
foreach (var bestand in await _vault.ListFilesForItemAsync(itemId, ct))
await GrantOverAsync(nameof(VaultFile), bestand.Id, bestand.WrappedContentKey, toUserId,
ownerKey, receiverKey, reason, ct);
await _uow.SaveChangesAsync(ct);
return Result.Ok();
}
finally
{
Array.Clear(ownerKey);
Array.Clear(receiverKey);
}
}
/// <summary>
/// Eén toewijzing: de contentsleutel van iets (een item, een bijlage) uitpakken met de
/// sleutel van de eigenaar en weer inpakken met die van de ontvanger. Bestond de toewijzing
/// al en is hij niet ingetrokken, dan gebeurt er niets - nog een keer dezelfde sleutel
/// inpakken voegt niets toe.
/// </summary>
private async Task GrantOverAsync(string resourceType, long resourceId, byte[] wrappedContentKeyVanEigenaar,
long toUserId, byte[] ownerKey, byte[] receiverKey, string reason,
CancellationToken ct)
{
if (await _vault.FindGrantAsync(resourceType, resourceId, toUserId, ct) is { RevokedAt: null }) return;
byte[]? contentKey = null;
try
{
contentKey = EnvelopeCrypto.UnwrapKey(wrappedContentKeyVanEigenaar, ownerKey);
_vault.Add(new ContentGrant
{
ResourceType = resourceType,
ResourceId = resourceId,
UserId = toUserId,
WrappedContentKey = EnvelopeCrypto.WrapKey(contentKey, receiverKey),
Reason = reason,
GrantedAt = _clock.UtcNow
});
}
finally
{
if (contentKey is not null) Array.Clear(contentKey);
}
}
/// <summary>
/// Geeft de gekozen nalatenschapsbeheerder van een Uitvaartwens meteen leestoegang tot dit
/// item, zodat het al vóór een overlijden te bespreken is. Alleen als de gekozen persoon
/// ook echt een aangewezen beheerder is én al een account heeft geclaimd (zonder account
/// is er geen datasleutel om de contentsleutel opnieuw mee in te pakken). Ontbreekt een van
/// beide, dan wordt de keuze stil overgeslagen; het item zelf blijft wel bewaard staan.
/// </summary>
/// <summary>
/// Bij welke soorten kluisitems een beheerder te kiezen is om vooraf mee te bespreken. Op dit
/// moment een uitvaartwens en een donorcodicil - allebei dingen waar je iemand graag vooraf
/// bij betrekt, in plaats van dat diegene het pas na een overlijden voor het eerst ziet.
/// </summary>
private static bool KanBesprekenMet(VaultCategory category) =>
category is VaultCategory.Uitvaartwens or VaultCategory.Donorcodicil;
private async Task DeelMetBeheerderAsync(VaultItem item, User owner, long beheerderPersonId, CancellationToken ct)
{
if (_legacy is not null)
{
var beheerders = await _legacy.ListManagersForAsync(owner.PersonId, ct);
if (!beheerders.Any(m => m.ManagerPersonId == beheerderPersonId)) return;
}
var beheerderUser = await _users.FindByPersonIdAsync(beheerderPersonId, ct);
if (beheerderUser is null) return; // nog geen account geclaimd; geen datasleutel om mee te delen
await GrantAsync(item.Id, owner.Id, beheerderUser.Id, "gedeeld", ct);
}
/// <summary>
/// Kluisitems die een ander direct met je heeft gedeeld (bijvoorbeeld een uitvaartwens om
/// vooraf te bespreken), los van de gewone nalatenschap-vrijgave na een overlijden.
/// </summary>
public async Task<Result<IReadOnlyList<VaultItem>>> ListSharedWithMeAsync(long currentUserId, CancellationToken ct = default)
{
var user = await _users.FindByIdAsync(currentUserId, ct);
if (user is null) return Result<IReadOnlyList<VaultItem>>.Fail("Account niet gevonden.");
var items = await _vault.ListSharedWithAsync(user.Id, ct);
return Result<IReadOnlyList<VaultItem>>.Ok(items);
}
/// <summary>Slaat een bijlage versleuteld op buiten de database.</summary>
/// <summary>
/// Hoe groot een bijlage mag zijn. De inhoud wordt in één keer versleuteld, dus hij moet
/// even in het geheugen passen - vandaar een grens. Voor documenten en foto's ruim genoeg;
/// voor lange video-opnamen niet, en dat hoort de gebruiker dan ook te horen.
/// </summary>
public const long MaxBestandsgrootte = 50L * 1024 * 1024;
/// <summary>
/// Slaat een bestand versleuteld op. Het hoort bij hoogstens één ding: een kluisitem, een
/// persoon (een foto van iemand) of een bericht. Alle drie leeg mag ook - dan staat het op
/// zichzelf, zoals de opname bij een videobericht die pas later aan het bericht wordt gehangen.
/// </summary>
public async Task<Result<VaultFile>> StoreFileAsync(long currentUserId, string fileName, string contentType,
byte[] content, long? vaultItemId = null,
long? subjectPersonId = null, long? messageId = null,
CancellationToken ct = default)
{
var user = await _users.FindByIdAsync(currentUserId, ct);
if (user is null) return Result<VaultFile>.Fail("Account niet gevonden.");
if (string.IsNullOrWhiteSpace(fileName)) return Result<VaultFile>.Fail("Het bestand heeft geen naam.");
if (content.LongLength == 0) return Result<VaultFile>.Fail("Het bestand is leeg.");
if (content.LongLength > MaxBestandsgrootte)
return Result<VaultFile>.Fail(
$"Dit bestand is {content.LongLength / 1024 / 1024} MB; groter dan {MaxBestandsgrootte / 1024 / 1024} MB gaat niet.");
if (new[] { vaultItemId, subjectPersonId, messageId }.Count(x => x is not null) > 1)
return Result<VaultFile>.Fail("Een bijlage hoort bij één ding tegelijk.");
// Hoort het bij een kluisitem, dan moet dat item wel van jou zijn.
if (vaultItemId is { } itemId)
{
var item = await _vault.FindByIdAsync(itemId, ct);
if (item is null || item.DeletedAt is not null) return Result<VaultFile>.Fail("Dit item bestaat niet meer.");
if (item.OwnerPersonId != user.PersonId) return Result<VaultFile>.Fail("Dit item is niet van jou.");
}
// Een foto bij een persoon mag alleen als jij dat profiel beheert - dezelfde regel als
// voor het wijzigen van zijn naam. Zodra hij het profiel zelf heeft geclaimd, is het aan
// hem welke foto's erbij staan.
if (subjectPersonId is { } persoonId)
{
var persoon = await _people.FindByIdAsync(persoonId, ct);
if (persoon is null) return Result<VaultFile>.Fail("Deze persoon bestaat niet.");
var mag = persoon.Id == user.PersonId || ProfileOwnership.CanEdit(persoon, currentUserId);
if (!mag) return Result<VaultFile>.Fail($"{persoon.DisplayName} beheert zijn profiel zelf.");
}
// Een bijlage bij een bericht kan zolang het bericht nog niet bezorgd is, en alleen door
// de afzender: daarna is het bij de ontvanger en verandert er niets meer aan.
if (messageId is { } berichtId)
{
var bericht = await _messages.FindByIdAsync(berichtId, ct);
if (bericht is null) return Result<VaultFile>.Fail("Dit bericht bestaat niet meer.");
if (bericht.FromPersonId != user.PersonId) return Result<VaultFile>.Fail("Dit bericht is niet van jou.");
if (bericht.Status == MessageStatus.Bezorgd)
return Result<VaultFile>.Fail("Dit bericht is al bezorgd; daar kan niets meer bij.");
}
// Een grote foto gaat verkleind de kluis in. Lukt dat niet, of wordt hij er niet kleiner
// van, dan blijft het origineel staan: een foto die niet wordt opgeslagen is erger dan
// een foto die veel ruimte inneemt.
if (contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)
&& content.LongLength > VerkleinVanaf)
{
var kleiner = _verkleiner.Verklein(content, contentType, FotoMaxZijde);
if (kleiner is { Length: > 0 } && kleiner.Length < content.Length)
{
content = kleiner;
contentType = "image/jpeg";
fileName = AlsJpeg(fileName);
}
}
var dataKey = _keys.Unprotect(user.WrappedDataKey);
var contentKey = EnvelopeCrypto.NewKey();
try
{
var sealedData = EnvelopeCrypto.Seal(content, contentKey);
var storageKey = await _files.WriteAsync(sealedData.Ciphertext, fileName, ct);
var file = new VaultFile
{
OwnerPersonId = user.PersonId,
VaultItemId = vaultItemId,
SubjectPersonId = subjectPersonId,
MessageId = messageId,
FileName = SchoneNaam(fileName),
ContentType = contentType,
SizeBytes = content.LongLength,
StorageKey = storageKey,
Nonce = sealedData.Nonce,
Tag = sealedData.Tag,
WrappedContentKey = EnvelopeCrypto.WrapKey(contentKey, dataKey),
Sha256 = System.Security.Cryptography.SHA256.HashData(content),
CreatedAt = _clock.UtcNow
};
// Een kleine versie erbij, met dezelfde sleutel. Lukt dat niet - geen afbeelding, een
// formaat dat we niet kennen - dan blijft hij leeg en valt het scherm terug op de
// beginletters. Dat is nooit een reden om de foto zelf niet op te slaan.
var klein = _verkleiner.Verklein(content, contentType, ThumbnailZijde);
if (klein is { Length: > 0 }) file.Thumbnail = klein;
_vault.Add(file);
await _uow.SaveChangesAsync(ct);
return Result<VaultFile>.Ok(file);
}
finally
{
Array.Clear(dataKey);
Array.Clear(contentKey);
}
}
/// <summary>Welke kluisitems van een overledene zijn inmiddels vrijgegeven?</summary>
/// <summary>De bijlagen bij een kluisitem. Alleen de eigenaar ziet ze.</summary>
public async Task<Result<IReadOnlyList<VaultFile>>> ListFilesAsync(long vaultItemId, long currentUserId,
CancellationToken ct = default)
{
var item = await _vault.FindByIdAsync(vaultItemId, ct);
if (item is null || item.DeletedAt is not null)
return Result<IReadOnlyList<VaultFile>>.Fail("Dit item bestaat niet meer.");
var user = await _users.FindByIdAsync(currentUserId, ct);
if (user is null) return Result<IReadOnlyList<VaultFile>>.Fail("Account niet gevonden.");
// Net als bij het item zelf (zie OpenAsync): de eigenaar altijd, en verder wie er
// toegang toe heeft gekregen - bijvoorbeeld een beheerder met wie een uitvaartwens
// vooraf wordt besproken. Die ziet dan ook de foto's en documenten die erbij horen.
if (item.OwnerPersonId != user.PersonId)
{
var grant = await _vault.FindGrantAsync(nameof(VaultItem), item.Id, currentUserId, ct);
if (grant is null || grant.RevokedAt is not null)
return Result<IReadOnlyList<VaultFile>>.Fail("Dit item is niet van jou.");
}
return Result<IReadOnlyList<VaultFile>>.Ok(await _vault.ListFilesForItemAsync(vaultItemId, ct));
}
/// <summary>
/// De foto's die bij een persoon horen. Je ziet ze als het jouw eigen profiel is of als jij
/// dat profiel beheert.
/// </summary>
public async Task<Result<IReadOnlyList<VaultFile>>> ListPersonFilesAsync(long personId, long currentUserId,
CancellationToken ct = default)
{
var user = await _users.FindByIdAsync(currentUserId, ct);
if (user is null) return Result<IReadOnlyList<VaultFile>>.Fail("Account niet gevonden.");
var persoon = await _people.FindByIdAsync(personId, ct);
if (persoon is null) return Result<IReadOnlyList<VaultFile>>.Fail("Deze persoon bestaat niet.");
var mag = persoon.Id == user.PersonId || ProfileOwnership.CanEdit(persoon, currentUserId);
if (!mag) return Result<IReadOnlyList<VaultFile>>.Fail($"{persoon.DisplayName} beheert zijn profiel zelf.");
return Result<IReadOnlyList<VaultFile>>.Ok(await _vault.ListFilesForPersonAsync(personId, ct));
}
/// <summary>De bijlagen bij een bericht. Alleen de afzender ziet ze hier.</summary>
public async Task<Result<IReadOnlyList<VaultFile>>> ListMessageFilesAsync(long messageId, long currentUserId,
CancellationToken ct = default)
{
var user = await _users.FindByIdAsync(currentUserId, ct);
if (user is null) return Result<IReadOnlyList<VaultFile>>.Fail("Account niet gevonden.");
var bericht = await _messages.FindByIdAsync(messageId, ct);
if (bericht is null) return Result<IReadOnlyList<VaultFile>>.Fail("Dit bericht bestaat niet meer.");
if (bericht.FromPersonId != user.PersonId)
return Result<IReadOnlyList<VaultFile>>.Fail("Dit bericht is niet van jou.");
return Result<IReadOnlyList<VaultFile>>.Ok(await _vault.ListFilesForMessageAsync(messageId, ct));
}
/// <summary>De profielfoto van een persoon: het nummer van het bestand en de kleine versie.</summary>
public record Profielfoto(long PersoonId, long BestandId, byte[]? Klein);
/// <summary>
/// De profielfoto's van een groep personen, met de kleine versie er ontsleuteld bij.
///
/// In één keer voor de hele lijst, en bewust alleen de kleine versie: de foto zelf ophalen
/// voor twintig mensen zou een paar megabyte zijn voor plaatjes van veertig pixels.
///
/// Personen zonder foto komen niet in het antwoord voor; het scherm toont dan de
/// beginletters. Hetzelfde geldt voor een foto van voor deze wijziging, die nog geen kleine
/// versie heeft: dan komt het bestandsnummer wel terug maar de kleine versie niet, zodat de
/// website hem alsnog gewoon kan opvragen.
/// </summary>
public async Task<IReadOnlyList<Profielfoto>> ProfielfotosAsync(IEnumerable<long> personIds, long currentUserId,
CancellationToken ct = default)
{
var user = await _users.FindByIdAsync(currentUserId, ct);
if (user is null) return Array.Empty<Profielfoto>();
var bestanden = await _vault.ListProfilePhotosAsync(personIds, ct);
if (bestanden.Count == 0) return Array.Empty<Profielfoto>();
// Wiens foto je mag zien: die van jezelf, en die van iedereen met wie je in een familie
// zit. Dat is precies de kring die de eigenaar zelf heeft samengesteld. Deze controle
// staat hier en niet bij de aanroeper: een toegangsregel hoort niet af te hangen van de
// vraag of elk scherm eraan denkt.
var zichtbaar = await KringAsync(user.PersonId, ct);
var uitkomst = new List<Profielfoto>(bestanden.Count);
foreach (var bestand in bestanden)
{
if (bestand.SubjectPersonId is not { } persoonId) continue;
if (persoonId != user.PersonId && !zichtbaar.Contains(persoonId)) continue;
byte[]? klein = null;
if (bestand.HeeftThumbnail)
{
klein = bestand.Thumbnail;
}
else if (bestand.Thumbnail is null || bestand.ThumbnailNonce is not null)
{
// Nog nooit geprobeerd, of nog een versleutelde uit de eerste opzet. Dan maken
// we hem alsnog - maar alleen als wij de foto kunnen openmaken, want daarvoor
// is de sleutel van de eigenaar nodig.
if (bestand.OwnerPersonId == user.PersonId)
{
var dataKey = _keys.Unprotect(user.WrappedDataKey);
try { klein = await MaakThumbnailAchterafAsync(bestand, dataKey, ct); }
finally { Array.Clear(dataKey); }
}
}
uitkomst.Add(new Profielfoto(persoonId, bestand.Id, klein));
}
return uitkomst;
}
/// <summary>
/// Iedereen met wie deze persoon in een familie zit. Dat is de kring waarbinnen een
/// profielfoto zichtbaar is - de kring die mensen zelf samenstellen door elkaar toe te
/// voegen en uit te nodigen.
/// </summary>
private async Task<HashSet<long>> KringAsync(long personId, CancellationToken ct)
{
if (_families is null) return new HashSet<long> { personId };
return (await _families.ListCircleAsync(personId, ct)).ToHashSet();
}
/// <summary>
/// Maakt alsnog een kleine versie bij een foto die er geen heeft, en bewaart hem.
///
/// Dit kost één keer het openmaken van de hele foto. Daarna staat hij erbij en gebeurt dit
/// niet meer. Lukt het niet - geen verkleiner beschikbaar, of een bestand waar hij geen raad
/// mee weet - dan leggen we dat vast met een lege waarde in plaats van null. Zonder dat
/// verschil zou elke keer dat je de lijst opent opnieuw elke foto worden opengemaakt.
/// </summary>
private async Task<byte[]?> MaakThumbnailAchterafAsync(VaultFile bestand, byte[] dataKey, CancellationToken ct)
{
if (!bestand.ContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) return null;
byte[]? contentKey = null;
try
{
contentKey = EnvelopeCrypto.UnwrapKey(bestand.WrappedContentKey, dataKey);
var versleuteld = await _files.ReadAsync(bestand.StorageKey, ct);
var inhoud = EnvelopeCrypto.Open(new SealedData(versleuteld, bestand.Nonce, bestand.Tag), contentKey);
var klein = _verkleiner.Verklein(inhoud, bestand.ContentType, ThumbnailZijde);
if (klein is not { Length: > 0 })
{
// Geprobeerd, niet gelukt. Dit onthouden we, anders doen we het bij elke lijst
// opnieuw voor een foto waar het toch nooit van gaat komen.
bestand.Thumbnail = Array.Empty<byte>();
await _uow.SaveChangesAsync(ct);
return null;
}
bestand.Thumbnail = klein;
bestand.ThumbnailNonce = null;
bestand.ThumbnailTag = null;
await _uow.SaveChangesAsync(ct);
return klein;
}
catch (Exception)
{
// Een lijst met mensen hoort niet om te vallen omdat er één foto niet openging.
return null;
}
finally
{
if (contentKey is not null) Array.Clear(contentKey);
}
}
/// <summary>
/// De kleine versie van één bestand, ontsleuteld. Null als er geen is; de aanroeper valt dan
/// terug op het bestand zelf.
/// </summary>
public async Task<Result<byte[]>> OpenThumbnailAsync(long fileId, long currentUserId,
CancellationToken ct = default)
{
var user = await _users.FindByIdAsync(currentUserId, ct);
if (user is null) return Result<byte[]>.Fail("Account niet gevonden.");
var bestand = await _vault.FindFileAsync(fileId, ct);
if (bestand is null || bestand.DeletedAt is not null) return Result<byte[]>.Fail("Dit bestand bestaat niet meer.");
// Een kleine versie is niet versleuteld, maar hij hoort wel bij iemand: je mag hem zien
// als het bestand van jou is, of als je met die persoon in een familie zit.
var vanMij = bestand.OwnerPersonId == user.PersonId;
var inMijnKring = bestand.SubjectPersonId is { } sp && (await KringAsync(user.PersonId, ct)).Contains(sp);
if (!vanMij && !inMijnKring) return Result<byte[]>.Fail("Dit bestand is niet van jou.");
if (!bestand.HeeftThumbnail) return Result<byte[]>.Fail("Van dit bestand is geen kleine versie.");
return Result<byte[]>.Ok(bestand.Thumbnail!);
}
/// <summary>
/// Wijst een foto aan als de profielfoto van de persoon waar hij bij hoort. Er kan er maar
/// één zijn, dus de vorige verliest die rol - dat gebeurt hier en niet in de database, want
/// een database kan dat niet afdwingen zonder er een puinhoop van te maken.
/// </summary>
public async Task<Result> SetProfilePhotoAsync(long fileId, long currentUserId, CancellationToken ct = default)
{
var user = await _users.FindByIdAsync(currentUserId, ct);
if (user is null) return Result.Fail("Account niet gevonden.");
var bestand = await _vault.FindFileAsync(fileId, ct);
if (bestand is null || bestand.DeletedAt is not null) return Result.Fail("Deze foto bestaat niet meer.");
if (bestand.SubjectPersonId is not { } persoonId)
return Result.Fail("Dit bestand hoort niet bij een persoon.");
var persoon = await _people.FindByIdAsync(persoonId, ct);
if (persoon is null) return Result.Fail("Deze persoon bestaat niet.");
var mag = persoon.Id == user.PersonId || ProfileOwnership.CanEdit(persoon, currentUserId);
if (!mag) return Result.Fail($"{persoon.DisplayName} beheert zijn profiel zelf.");
foreach (var andere in await _vault.ListFilesForPersonAsync(persoonId, ct))
andere.IsProfilePhoto = andere.Id == fileId;
await _audit.WriteAsync(AuditAction.ProfielGewijzigd, currentUserId, nameof(VaultFile), fileId,
"profielfoto", ct);
await _uow.SaveChangesAsync(ct);
return Result.Ok();
}
/// <summary>
/// Zelfherstellend gat-dichten: dit bestand hoort bij een kluisitem, het item zelf is actief
/// met deze gebruiker gedeeld, maar het bestand kreeg daar (nog) geen eigen toegang bij. Is
/// dat zo, dan maken we die toegang hier alsnog aan en geven de ingepakte sleutel terug;
/// anders null.
/// </summary>
private async Task<byte[]?> HerstelBestandstoegangAsync(VaultFile file, long vaultItemId, User user,
long currentUserId, CancellationToken ct)
{
var itemGrant = await _vault.FindGrantAsync(nameof(VaultItem), vaultItemId, currentUserId, ct);
if (itemGrant is null || itemGrant.RevokedAt is not null) return null;
var eigenaar = await _users.FindByPersonIdAsync(file.OwnerPersonId, ct);
if (eigenaar is null) return null;
var eigenaarSleutel = _keys.Unprotect(eigenaar.WrappedDataKey);
var ontvangerSleutel = _keys.Unprotect(user.WrappedDataKey);
try
{
await GrantOverAsync(nameof(VaultFile), file.Id, file.WrappedContentKey, currentUserId,
eigenaarSleutel, ontvangerSleutel, "gedeeld (hersteld)", ct);
await _uow.SaveChangesAsync(ct);
}
finally
{
Array.Clear(eigenaarSleutel);
Array.Clear(ontvangerSleutel);
}
var grant = await _vault.FindGrantAsync(nameof(VaultFile), file.Id, currentUserId, ct);
return grant?.WrappedContentKey;
}
/// <summary>
/// Zet een bestand dat over deze gebruiker gaat definitief op zijn naam, met een verse
/// sleutel. Anders dan <see cref="HerstelBestandstoegangAsync"/> - dat geeft er een kopie
/// van de toegang bij, de oude eigenaar blijft eigenaar - hoort een profielfoto van jezelf
/// gewoon van jou te worden, niet gedeeld met jou te blijven.
/// </summary>
private async Task<byte[]?> ZetBestandOverAsync(VaultFile file, User nieuweEigenaar, CancellationToken ct)
{
var oudeEigenaar = await _users.FindByPersonIdAsync(file.OwnerPersonId, ct);
if (oudeEigenaar is null) return null;
var oudeSleutel = _keys.Unprotect(oudeEigenaar.WrappedDataKey);
var nieuweSleutel = _keys.Unprotect(nieuweEigenaar.WrappedDataKey);
byte[]? contentKey = null;
try
{
contentKey = EnvelopeCrypto.UnwrapKey(file.WrappedContentKey, oudeSleutel);
file.WrappedContentKey = EnvelopeCrypto.WrapKey(contentKey, nieuweSleutel);
file.OwnerPersonId = nieuweEigenaar.PersonId;
await _uow.SaveChangesAsync(ct);
return file.WrappedContentKey;
}
catch (Exception)
{
return null;
}
finally
{
Array.Clear(oudeSleutel);
Array.Clear(nieuweSleutel);
if (contentKey is not null) Array.Clear(contentKey);
}
}
/// <summary>
/// Haalt een bestand op en ontsleutelt het. Dat lukt als je de eigenaar bent, of als je er
/// toegang toe hebt gekregen - net als bij een kluisitem.
/// </summary>
public async Task<Result<(VaultFile Bestand, byte[] Inhoud)>> OpenFileAsync(long fileId, long currentUserId,
CancellationToken ct = default)
{
var file = await _vault.FindFileAsync(fileId, ct);
if (file is null || file.DeletedAt is not null)
return Result<(VaultFile, byte[])>.Fail("Dit bestand bestaat niet meer.");
var user = await _users.FindByIdAsync(currentUserId, ct);
if (user is null) return Result<(VaultFile, byte[])>.Fail("Account niet gevonden.");
byte[] wrappedKey;
if (file.OwnerPersonId == user.PersonId)
{
wrappedKey = file.WrappedContentKey;
}
else if (file.SubjectPersonId == user.PersonId)
{
// Dit bestand gaat over jou - een foto of document dat een beheerder toevoegde
// terwijl hij jouw nog niet-geclaimde profiel beheerde - maar staat nog op zijn
// naam. Dat hoort niet zo te blijven: zet het definitief op jouw naam, in plaats
// van dat je voor altijd buiten je eigen foto blijft staan.
var overgezet = await ZetBestandOverAsync(file, user, ct);
if (overgezet is null) return Result<(VaultFile, byte[])>.Fail("Je hebt geen toegang tot dit bestand.");
wrappedKey = overgezet;
}
else
{
var grant = await _vault.FindGrantAsync(nameof(VaultFile), file.Id, currentUserId, ct);
if (grant is null || grant.RevokedAt is not null)
{
// Geen rechtstreekse toegang tot dit bestand. Hoort het bij een kluisitem dat wél
// actief met deze gebruiker is gedeeld, dan is dit een gat dat we alsnog dichten
// in plaats van iemand met een foutmelding te laten zitten: het bestand kan later
// zijn toegevoegd dan het delen zelf, of gedeeld zijn vóór bestanden meededen in
// het delen van een item.
var hersteld = file.VaultItemId is { } itemId
? await HerstelBestandstoegangAsync(file, itemId, user, currentUserId, ct)
: null;
if (hersteld is null) return Result<(VaultFile, byte[])>.Fail("Je hebt geen toegang tot dit bestand.");
wrappedKey = hersteld;
}
else
{
wrappedKey = grant.WrappedContentKey;
}
}
var dataKey = _keys.Unprotect(user.WrappedDataKey);
byte[]? contentKey = null;
try
{
contentKey = EnvelopeCrypto.UnwrapKey(wrappedKey, dataKey);
var versleuteld = await _files.ReadAsync(file.StorageKey, ct);
var inhoud = EnvelopeCrypto.Open(new SealedData(versleuteld, file.Nonce, file.Tag), contentKey);
// Controle op beschadiging: de hash is bij het opslaan vastgelegd.
if (file.Sha256.Length == 32 &&
!System.Security.Cryptography.SHA256.HashData(inhoud).SequenceEqual(file.Sha256))
{
return Result<(VaultFile, byte[])>.Fail("Dit bestand is beschadigd en kan niet worden geopend.");
}
await _audit.WriteAsync(AuditAction.KluisItemGelezen, currentUserId, nameof(VaultFile), file.Id, file.FileName, ct);
await _uow.SaveChangesAsync(ct);
return Result<(VaultFile, byte[])>.Ok((file, inhoud));
}
finally
{
Array.Clear(dataKey);
if (contentKey is not null) Array.Clear(contentKey);
}
}
/// <summary>Verwijdert een bijlage; de versleutelde inhoud gaat meteen uit de opslag.</summary>
public async Task<Result> DeleteFileAsync(long fileId, long currentUserId, CancellationToken ct = default)
{
var file = await _vault.FindFileAsync(fileId, ct);
if (file is null || file.DeletedAt is not null) return Result.Fail("Dit bestand bestaat niet meer.");
var user = await _users.FindByIdAsync(currentUserId, ct);
if (user is null) return Result.Fail("Account niet gevonden.");
if (file.OwnerPersonId != user.PersonId)
{
// Gaat dit bestand over jou, dan hoort het van jou te zijn - ook al staat het nog op
// naam van wie het ooit toevoegde (zie ZetBestandOverAsync). Anders dan bij een
// gedeeld kluisitem is hier geen ruimte voor een "van een ander": je mag je eigen
// foto's altijd weggooien.
if (file.SubjectPersonId != user.PersonId || await ZetBestandOverAsync(file, user, ct) is null)
return Result.Fail("Dit bestand is niet van jou.");
}
file.DeletedAt = _clock.UtcNow;
try { await _files.DeleteAsync(file.StorageKey, ct); }
catch (Exception)
{
// Het bestand is hoe dan ook onbereikbaar geworden; een opruimfout mag dat niet tegenhouden.
}
await _audit.WriteAsync(AuditAction.KluisItemVerwijderd, currentUserId, nameof(VaultFile), file.Id, file.FileName, ct);
await _uow.SaveChangesAsync(ct);
return Result.Ok();
}
/// <summary>
/// Zet de extensie op .jpg. Nodig zodra we een png of heic hebben verkleind: het bestand is
/// dan een jpeg, en een naam die iets anders belooft levert later een plaatje op dat nergens
/// opengaat.
/// </summary>
private static string AlsJpeg(string naam)
{
var punt = naam.LastIndexOf('.');
var kaal = punt > 0 ? naam[..punt] : naam;
return kaal + ".jpg";
}
/// <summary>
/// Haalt padaanduidingen uit een bestandsnaam. Een naam als <c>..\..\windows\system32\iets</c>
/// mag nooit in de opslag terechtkomen, ook al bepaalt de opslag zelf de uiteindelijke naam.
/// </summary>
private static string SchoneNaam(string naam)
{
// Bewust niet Path.GetFileName: die kijkt naar de scheidingstekens van het besturings-
// systeem waarop dit draait. Een naam met backslashes erin zou op Linux dan blijven
// staan, terwijl hij van een Windows-toestel kan komen (of van iemand met slechte
// bedoelingen). Dus knippen we op allebei.
var kaal = naam.Trim();
var streep = kaal.LastIndexOfAny(new[] { '/', '\\', ':' });
if (streep >= 0) kaal = kaal[(streep + 1)..];
foreach (var teken in Path.GetInvalidFileNameChars()) kaal = kaal.Replace(teken, '_');
foreach (var teken in new[] { '\\', '/', ':', '*', '?', '"', '<', '>', '|' })
kaal = kaal.Replace(teken, '_');
kaal = kaal.Trim(' ', '.');
return string.IsNullOrWhiteSpace(kaal) ? "bestand" : kaal[..Math.Min(kaal.Length, 255)];
}
public async Task<IReadOnlyList<VaultItem>> ListReleasedAsync(long ownerPersonId, CancellationToken ct = default)
{
var owner = await _people.FindByIdAsync(ownerPersonId, ct);
if (owner is null) return Array.Empty<VaultItem>();
var now = _clock.UtcNow;
var all = await _vault.ListForPersonAsync(ownerPersonId, null, ct);
return all.Where(i => MessageRelease.IsReleased(i, owner, now)).ToList();
}
}
@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\MijnBesluit.Protocol\MijnBesluit.Protocol.csproj" />
</ItemGroup>
</Project>
+704
View File
@@ -0,0 +1,704 @@
using System.Text.Json;
using MijnBesluit.Protocol;
namespace MijnBesluit.Client;
/// <summary>
/// De kant van de app: benoemde methodes in plaats van losse socketacties.
///
/// Hier zit geen regelgeving in — alles wat mag en niet mag wordt door de server bepaald.
/// Deze laag doet twee dingen: de juiste actie versturen, en het antwoord omzetten naar iets
/// waar een scherm mee kan werken.
/// </summary>
public class MijnBesluitClient : IAsyncDisposable
{
private readonly MijnBesluitSocketClient _socket;
private readonly ITokenopslag _opslag;
public MijnBesluitClient(string host, int poort = 5200, bool tls = true, ITokenopslag? opslag = null)
{
_socket = new MijnBesluitSocketClient(host, poort, tls);
_opslag = opslag ?? new GeheugenTokenopslag();
_socket.PushReceived += bericht => MeldingOntvangen?.Invoke(bericht.Message ?? string.Empty);
_socket.Disconnected += _ => Verbindingsstand(false);
}
/// <summary>De server stuurt uit zichzelf een melding, bijvoorbeeld bij een nieuw bericht.</summary>
public event Action<string>? MeldingOntvangen;
/// <summary>
/// De server kent het token niet meer. Het bewaarde token is dan al weggegooid; de app hoort
/// hierop naar het inlogscherm te gaan.
///
/// Dit gebeurt vaker dan je zou denken: de sessies van de SocketServer staan in het geheugen,
/// dus na elke herstart van de service zijn ze weg. Zonder dit bleef de app dan hangen op een
/// rode balk met een knop "opnieuw proberen" die nooit kan lukken.
/// </summary>
public event Action? SessieVerlopen;
/// <summary>
/// De verbinding is weggevallen of weer terug. Let op: wegvallen is normaal — Android
/// verbreekt de verbinding zodra de app een tijdje op de achtergrond staat, bijvoorbeeld
/// terwijl iemand zijn e-mail leest om de inlogcode op te halen. De volgende aanroep zet
/// hem vanzelf weer op; daarom hoort deze melding weer te verdwijnen zodra dat lukt.
/// </summary>
public event Action<bool>? VerbindingVeranderd;
/// <summary>Staat er nu een verbinding? Alleen om te tonen; verzoeken verbinden zelf opnieuw.</summary>
public bool IsVerbonden { get; private set; } = true;
private void Verbindingsstand(bool nu)
{
if (nu == IsVerbonden) return;
IsVerbonden = nu;
VerbindingVeranderd?.Invoke(nu);
}
/// <summary>De persoon die op dit moment is aangemeld.</summary>
public long PersoonId { get; private set; }
public bool IsAangemeld => !string.IsNullOrEmpty(_socket.Token);
/// <summary>Alleen voor ontwikkelen: accepteert een zelfondertekend servercertificaat.</summary>
public bool AccepteerElkCertificaat
{
get => _socket.AcceptAnyCertificate;
set => _socket.AcceptAnyCertificate = value;
}
/// <summary>
/// Ligt er een sessie op dit toestel? Leest alleen wat er bewaard staat en vraagt de server
/// niets.
///
/// Daar zit het hele punt: de app hoort bij het openen te weten dat jij het bent, ook als er
/// nog geen netwerk is of de server even niet reageert. Op die wetenschap gaat het slot erop
/// en vraagt de app om je vingerafdruk. Pas daarna is het aan de server om te zeggen of de
/// sessie ook echt nog loopt.
/// </summary>
public async Task<bool> HeeftBewaardeSessieAsync()
{
if (!string.IsNullOrEmpty(_socket.Token)) return true;
var token = await _opslag.LeesAsync();
if (string.IsNullOrEmpty(token)) return false;
_socket.Token = token;
return true;
}
/// <summary>Haalt een eerder bewaard sessietoken op, zodat je niet elke keer opnieuw hoeft in te loggen.</summary>
public async Task<bool> HervatSessieAsync(CancellationToken ct = default)
{
if (!await HeeftBewaardeSessieAsync()) return false;
// Het eigen profiel opvragen doet twee dingen tegelijk: het bewijst dat het token nog
// geldig is, én het zegt wie we zijn.
//
// Dat tweede is waarom hier niet zomaar een willekeurige aanroep staat. PersoonId werd
// alleen gevuld bij het inloggen met een code, niet bij het hervatten van een sessie. Wie
// de app opende zonder opnieuw in te loggen had dus PersoonId 0, en dan weet de app niet
// welk familielid jijzelf bent: de kaart bovenaan Mijn mensen viel terug op "Mijn profiel"
// met initialen, én je stond een tweede keer gewoon in de lijst.
var profiel = await StuurAsync<EigenProfiel>(SocketActions.OwnProfile, null, ct);
Verbindingsstand(_socket.IsConnected);
if (profiel.Gelukt && profiel.Waarde is not null)
{
PersoonId = profiel.Waarde.PersoonId;
MijnProfiel = profiel.Waarde;
return true;
}
// Mislukken is niet hetzelfde als geweigerd worden, en dat onderscheid is hier het hele
// punt. Een telefoon die net wakker wordt heeft nog geen netwerk; de server kan net
// opnieuw opstarten; er is even geen bereik. Werd het token dan weggegooid, dan moest
// iemand opnieuw inloggen met een code per e-mail terwijl er niets mis was met zijn
// sessie - en dat overkwam je juist bij het terugkomen in een app die op slot stond,
// want dan is de app vaak door Android afgesloten en start hij koud op.
//
// Alleen de server mag een sessie ongeldig verklaren. Zegt hij dat, dan heeft StuurAsync
// het token al opgeruimd via SessieKwijtAsync en is het hier dus al leeg. Staat het er
// nog, dan blijft het staan en proberen we het de volgende keer gewoon opnieuw.
if (_socket.Token is null)
{
PersoonId = 0;
MijnProfiel = null;
}
return false;
}
// ------------------------------------------------------------------ inloggen
public async Task<Uitkomst<CodeGestuurd>> VraagInlogcodeAanAsync(string email, CancellationToken ct = default) =>
await StuurAsync<CodeGestuurd>(SocketActions.RequestLoginCode, new { email }, ct);
public async Task<Uitkomst> LogInAsync(string email, string code, CancellationToken ct = default)
{
var antwoord = await StuurAsync<SessieGegevens>(SocketActions.SignIn, new { email, code }, ct);
return await BewaarSessieAsync(antwoord);
}
public async Task<Uitkomst<CodeGestuurd>> VraagRegistratiecodeAanAsync(
string email, string voornaam, string achternaam, CancellationToken ct = default) =>
await StuurAsync<CodeGestuurd>(SocketActions.RequestRegisterCode,
new { email, firstName = voornaam, lastName = achternaam }, ct);
public async Task<Uitkomst> RegistreerAsync(string email, string code, CancellationToken ct = default)
{
var antwoord = await StuurAsync<SessieGegevens>(SocketActions.Register, new { email, code }, ct);
return await BewaarSessieAsync(antwoord);
}
public async Task<Uitkomst> NeemUitnodigingAanAsync(string token, CancellationToken ct = default)
{
var antwoord = await StuurAsync<SessieGegevens>(SocketActions.AcceptInvitation, new { token }, ct);
return await BewaarSessieAsync(antwoord);
}
/// <summary>
/// Doet de lijn naar de server dicht, maar blijft aangemeld. De app doet dit als hij op slot
/// gaat: wat dicht is, hoeft niet bewaakt te worden.
///
/// Let op het verschil met uitloggen: het token blijft staan, en de sessie aan de serverkant
/// blijft bestaan - die hangt aan het token en niet aan de verbinding. De eerste aanroep na
/// het ontgrendelen maakt vanzelf een nieuwe verbinding.
/// </summary>
public async Task VerbindingSluitenAsync()
{
await _socket.VerbreekAsync();
Verbindingsstand(false);
}
/// <summary>
/// Zet de verbinding alvast weer op, na het ontgrendelen. Hoeft niet - het eerste verzoek
/// doet het ook - maar zo staat de lijn er al terwijl iemand nog naar zijn scherm kijkt, in
/// plaats van dat zijn eerste handeling op een TLS-handdruk wacht.
/// </summary>
public async Task VerbindingOpenenAsync(CancellationToken ct = default)
{
if (!IsAangemeld) return;
try { Verbindingsstand(await _socket.ConnectAsync(ct)); }
catch (Exception) { Verbindingsstand(false); }
}
public async Task UitloggenAsync(CancellationToken ct = default)
{
// De server krijgt alleen bericht als er op dit moment verbinding is. Anders zou
// uitloggen tien seconden blijven hangen op een verbindingspoging die toch mislukt -
// en dan lijkt de knop het niet te doen. De sessie aan de serverkant verloopt vanzelf.
if (IsAangemeld && _socket.IsConnected)
{
try { await _socket.SendAsync(SocketActions.SignOut, null, TimeSpan.FromSeconds(3), ct); }
catch (Exception) { /* Uitloggen op dit toestel gaat hoe dan ook door. */ }
}
_socket.Token = null;
PersoonId = 0;
MijnProfiel = null;
await _opslag.WisAsync();
}
private async Task<Uitkomst> BewaarSessieAsync(Uitkomst<SessieGegevens> antwoord)
{
if (!antwoord.Gelukt) return Uitkomst.Fout(antwoord.Melding!);
_socket.Token = antwoord.Waarde!.Token;
PersoonId = antwoord.Waarde.PersoonId;
await _opslag.SchrijfAsync(antwoord.Waarde.Token);
return Uitkomst.Ok();
}
// ------------------------------------------------------------------ overzicht
/// <summary>Alles wat het beginscherm toont, in één aanroep.</summary>
public Task<Uitkomst<Overzicht>> OverzichtAsync(CancellationToken ct = default) =>
StuurAsync<Overzicht>(SocketActions.Overview, null, ct);
/// <summary>Stelt in hoeveel dagen vooruit het overzicht kijkt (0 tot 60).</summary>
public Task<Uitkomst<JsonElement>> VooruitblikAsync(int dagen, CancellationToken ct = default) =>
StuurAsync<JsonElement>(SocketActions.SetLookahead, new { days = dagen }, ct);
// ------------------------------------------------------------------ eigen profiel
/// <summary>
/// Je eigen profiel zoals het er de laatste keer uitzag, of null zolang het niet is
/// opgehaald. Staat hier en niet in elk scherm apart: je eigen naam en je eigen foto zijn
/// op meer plekken nodig dan alleen het profielscherm, en ze veranderen bijna nooit.
/// Elke ophaling ververst dit, dus wie zijn foto wisselt ziet dat overal terug.
/// </summary>
public EigenProfiel? MijnProfiel { get; private set; }
/// <summary>Je eigen naam, adres en geboortedatum.</summary>
public async Task<Uitkomst<EigenProfiel>> EigenProfielAsync(CancellationToken ct = default)
{
var uitkomst = await StuurAsync<EigenProfiel>(SocketActions.OwnProfile, null, ct);
if (uitkomst.Gelukt && uitkomst.Waarde is not null) MijnProfiel = uitkomst.Waarde;
return uitkomst;
}
/// <summary>Wijzigt je eigen naam en geboortedatum. Het e-mailadres gaat apart, met een code.</summary>
public Task<Uitkomst<JsonElement>> EigenProfielOpslaanAsync(
string voornaam, string achternaam, DateOnly? geboortedatum, CancellationToken ct = default) =>
StuurAsync<JsonElement>(SocketActions.SaveOwnProfile,
new { firstName = voornaam, lastName = achternaam, birthDate = geboortedatum }, ct);
// ------------------------------------------------------------------ account opheffen
/// <summary>Wat er weggaat als je je account opheft. Leest alleen.</summary>
public Task<Uitkomst<Opheffing>> OpheffenTellingAsync(CancellationToken ct = default) =>
StuurAsync<Opheffing>(SocketActions.DeleteAccountSummary, null, ct);
/// <summary>Vraagt de code aan waarmee het opheffen bevestigd wordt. Gaat naar je eigen adres.</summary>
public Task<Uitkomst<JsonElement>> OpheffenCodeAanvragenAsync(CancellationToken ct = default) =>
StuurAsync<JsonElement>(SocketActions.RequestDeleteAccount, null, ct);
/// <summary>
/// Heft het account op. Hierna is er niets meer om mee te werken, dus ruimt de app zijn
/// bewaarde sessie ook meteen op.
/// </summary>
public async Task<Uitkomst<JsonElement>> OpheffenBevestigenAsync(string code, CancellationToken ct = default)
{
var uitkomst = await StuurAsync<JsonElement>(SocketActions.ConfirmDeleteAccount, new { code }, ct);
if (uitkomst.Gelukt) await UitloggenAsync(ct);
return uitkomst;
}
/// <summary>
/// Stuurt een code naar het NIEUWE adres. Er verandert pas iets als die code klopt; zo kan
/// niemand je met een openstaande sessie uit je eigen kluis zetten.
/// </summary>
public Task<Uitkomst<CodeGestuurd>> AdreswijzigingAanvragenAsync(
string nieuwAdres, CancellationToken ct = default) =>
StuurAsync<CodeGestuurd>(SocketActions.RequestEmailChange, new { email = nieuwAdres }, ct);
/// <summary>Maakt de adreswijziging definitief. Je wordt daarna overal uitgelogd.</summary>
public Task<Uitkomst<JsonElement>> AdreswijzigingBevestigenAsync(
string nieuwAdres, string code, CancellationToken ct = default) =>
StuurAsync<JsonElement>(SocketActions.ConfirmEmailChange, new { email = nieuwAdres, code }, ct);
// ------------------------------------------------------------------ familie
public Task<Uitkomst<IReadOnlyList<FamilieOverzicht>>> FamiliesAsync(CancellationToken ct = default) =>
StuurAsync<IReadOnlyList<FamilieOverzicht>>(SocketActions.ListFamilies, null, ct);
public Task<Uitkomst<IReadOnlyList<Familielid>>> FamilieledenAsync(long familieId, CancellationToken ct = default) =>
StuurAsync<IReadOnlyList<Familielid>>(SocketActions.ListMembers, new { familyId = familieId }, ct);
public Task<Uitkomst<JsonElement>> VoegFamilielidToeAsync(
long familieId, string voornaam, string achternaam, string? email, int relatie,
DateOnly? geboortedatum = null, CancellationToken ct = default) =>
StuurAsync<JsonElement>(SocketActions.AddMember, new
{
familyId = familieId,
firstName = voornaam,
lastName = achternaam,
email,
relation = relatie,
relationLabel = (string?)null,
birthDate = geboortedatum
}, ct);
/// <summary>
/// Wijzigt naam, e-mailadres en relatie van iemand in je familie. De server weigert dit
/// zodra de persoon het profiel zelf heeft geclaimd.
/// </summary>
public Task<Uitkomst<JsonElement>> WijzigFamilielidAsync(
long familieId, long persoonId, string voornaam, string achternaam, string? email, int relatie,
string? relatieOmschrijving = null, DateOnly? geboortedatum = null,
CancellationToken ct = default) =>
StuurAsync<JsonElement>(SocketActions.UpdatePerson, new
{
familyId = familieId,
personId = persoonId,
firstName = voornaam,
lastName = achternaam,
email,
relation = relatie,
relationLabel = relatieOmschrijving,
birthDate = geboortedatum
}, ct);
public Task<Uitkomst<JsonElement>> NodigUitAsync(long familieId, long persoonId, CancellationToken ct = default) =>
StuurAsync<JsonElement>(SocketActions.Invite, new { familyId = familieId, personId = persoonId }, ct);
public Task<Uitkomst<JsonElement>> VerwijderFamilielidAsync(
long familieId, long persoonId, CancellationToken ct = default) =>
StuurAsync<JsonElement>(SocketActions.RemoveMember, new { familyId = familieId, personId = persoonId }, ct);
// ------------------------------------------------------------------ kluis
public Task<Uitkomst<IReadOnlyList<KluisItem>>> KluisAsync(int? categorie = null, CancellationToken ct = default) =>
StuurAsync<IReadOnlyList<KluisItem>>(SocketActions.VaultList, new { category = categorie }, ct);
public Task<Uitkomst<KluisInhoud>> KluisOpenenAsync(long itemId, CancellationToken ct = default) =>
StuurAsync<KluisInhoud>(SocketActions.VaultOpen, new { id = itemId }, ct);
public Task<Uitkomst<JsonElement>> KluisOpslaanAsync(
int categorie, string titel, KluisInhoud inhoud, int vrijgave, DateTime? vrijgaveDatum = null,
long? besprekenMetPersonId = null, CancellationToken ct = default) =>
StuurAsync<JsonElement>(SocketActions.VaultSave, new
{
category = categorie,
title = titel,
payload = inhoud,
releasePolicy = vrijgave,
releaseAt = vrijgaveDatum,
sharedManagerPersonId = besprekenMetPersonId
}, ct);
public Task<Uitkomst<JsonElement>> KluisWijzigenAsync(
long itemId, int categorie, string titel, KluisInhoud inhoud, int vrijgave,
DateTime? vrijgaveDatum = null, long? besprekenMetPersonId = null, CancellationToken ct = default) =>
StuurAsync<JsonElement>(SocketActions.VaultUpdate, new
{
id = itemId,
category = categorie,
title = titel,
payload = inhoud,
releasePolicy = vrijgave,
releaseAt = vrijgaveDatum,
sharedManagerPersonId = besprekenMetPersonId
}, ct);
public Task<Uitkomst<JsonElement>> KluisVerwijderenAsync(long itemId, CancellationToken ct = default) =>
StuurAsync<JsonElement>(SocketActions.VaultDelete, new { id = itemId }, ct);
// ------------------------------------------------------------------ bijlagen
/// <summary>Hoeveel bytes er per stuk over de lijn gaan; de server gebruikt dezelfde maat.</summary>
public const int StukGrootte = 512 * 1024;
/// <summary>
/// Zet een bestand in de kluis. Het gaat in stukken over de lijn, want één socketbericht is
/// begrensd. <paramref name="voortgang"/> krijgt na elk stuk het aantal verstuurde bytes,
/// zodat een scherm een balkje kan tonen.
/// </summary>
public async Task<Uitkomst<long>> BestandVersturenAsync(
string bestandsnaam, string soort, byte[] inhoud, long? kluisItemId = null,
Action<long, long>? voortgang = null, long? persoonId = null, long? berichtId = null,
CancellationToken ct = default)
{
if (inhoud.LongLength == 0) return Uitkomst<long>.Fout("Het bestand is leeg.");
var begin = await StuurAsync<JsonElement>(SocketActions.FileBegin, new
{
fileName = bestandsnaam,
contentType = soort,
vaultItemId = kluisItemId,
subjectPersonId = persoonId,
messageId = berichtId,
totalBytes = inhoud.LongLength
}, ct);
if (!begin.Gelukt) return Uitkomst<long>.Fout(begin.Melding!);
var uploadId = begin.Waarde.GetProperty("uploadId").GetString()!;
var stukGrootte = begin.Waarde.TryGetProperty("stukGrootte", out var sg) ? sg.GetInt32() : StukGrootte;
try
{
var index = 0;
for (long positie = 0; positie < inhoud.LongLength; positie += stukGrootte)
{
var lengte = (int)Math.Min(stukGrootte, inhoud.LongLength - positie);
var stuk = await StuurAsync<JsonElement>(SocketActions.FileChunk, new
{
uploadId,
index,
data = Convert.ToBase64String(inhoud, (int)positie, lengte)
}, ct);
if (!stuk.Gelukt) return Uitkomst<long>.Fout(stuk.Melding!);
index++;
voortgang?.Invoke(positie + lengte, inhoud.LongLength);
}
var klaar = await StuurAsync<JsonElement>(SocketActions.FileEnd, new { uploadId }, ct);
if (!klaar.Gelukt) return Uitkomst<long>.Fout(klaar.Melding!);
return Uitkomst<long>.Ok(klaar.Waarde.GetProperty("bestandId").GetInt64());
}
catch (Exception)
{
// Halverwege misgegaan: laat de server de resten opruimen in plaats van ze een half
// uur te laten staan.
await StuurAsync<JsonElement>(SocketActions.FileCancel, new { uploadId }, CancellationToken.None);
throw;
}
}
public Task<Uitkomst<IReadOnlyList<Bijlage>>> BijlagenAsync(long kluisItemId, CancellationToken ct = default) =>
StuurAsync<IReadOnlyList<Bijlage>>(SocketActions.FileList, new { id = kluisItemId }, ct);
/// <summary>Haalt een bestand op, stuk voor stuk, en zet het weer aan elkaar.</summary>
public async Task<Uitkomst<BestandInhoud>> BestandOphalenAsync(
long bestandId, Action<long, long>? voortgang = null, CancellationToken ct = default)
{
var eerste = await StuurAsync<JsonElement>(SocketActions.FileRead, new { id = bestandId, index = 0 }, ct);
if (!eerste.Gelukt) return Uitkomst<BestandInhoud>.Fout(eerste.Melding!);
var naam = eerste.Waarde.GetProperty("naam").GetString() ?? "bestand";
var soort = eerste.Waarde.GetProperty("soort").GetString() ?? "application/octet-stream";
var grootte = eerste.Waarde.GetProperty("grootte").GetInt64();
var stukken = eerste.Waarde.GetProperty("stukken").GetInt32();
using var buffer = new MemoryStream(capacity: (int)Math.Min(grootte, int.MaxValue));
var eersteStuk = Convert.FromBase64String(eerste.Waarde.GetProperty("data").GetString() ?? "");
buffer.Write(eersteStuk, 0, eersteStuk.Length);
voortgang?.Invoke(buffer.Length, grootte);
for (var index = 1; index < stukken; index++)
{
var volgende = await StuurAsync<JsonElement>(SocketActions.FileRead, new { id = bestandId, index }, ct);
if (!volgende.Gelukt) return Uitkomst<BestandInhoud>.Fout(volgende.Melding!);
var stuk = Convert.FromBase64String(volgende.Waarde.GetProperty("data").GetString() ?? "");
buffer.Write(stuk, 0, stuk.Length);
voortgang?.Invoke(buffer.Length, grootte);
}
return Uitkomst<BestandInhoud>.Ok(new BestandInhoud(naam, soort, buffer.ToArray()));
}
/// <summary>De foto's die bij een persoon horen.</summary>
public Task<Uitkomst<IReadOnlyList<Bijlage>>> PersoonsfotosAsync(long persoonId, CancellationToken ct = default) =>
StuurAsync<IReadOnlyList<Bijlage>>(SocketActions.FileListPerson, new { personId = persoonId }, ct);
/// <summary>De bijlagen bij een bericht dat jij hebt klaargezet.</summary>
public Task<Uitkomst<IReadOnlyList<Bijlage>>> BerichtbijlagenAsync(long berichtId, CancellationToken ct = default) =>
StuurAsync<IReadOnlyList<Bijlage>>(SocketActions.FileListMessage, new { id = berichtId }, ct);
/// <summary>Wijst een foto aan als profielfoto van de persoon waar hij bij hoort.</summary>
public Task<Uitkomst<JsonElement>> ProfielfotoAsync(long bestandId, CancellationToken ct = default) =>
StuurAsync<JsonElement>(SocketActions.FileProfilePhoto, new { id = bestandId }, ct);
public Task<Uitkomst<JsonElement>> BijlageVerwijderenAsync(long bestandId, CancellationToken ct = default) =>
StuurAsync<JsonElement>(SocketActions.FileDelete, new { id = bestandId }, ct);
// ------------------------------------------------------------------ berichten
public Task<Uitkomst<IReadOnlyList<Bericht>>> InkomendAsync(CancellationToken ct = default) =>
StuurAsync<IReadOnlyList<Bericht>>(SocketActions.Inbox, null, ct);
public Task<Uitkomst<IReadOnlyList<Bericht>>> UitgaandAsync(CancellationToken ct = default) =>
StuurAsync<IReadOnlyList<Bericht>>(SocketActions.Outbox, null, ct);
public async Task<Uitkomst<string>> BerichtLezenAsync(long berichtId, CancellationToken ct = default)
{
var antwoord = await StuurAsync<JsonElement>(SocketActions.MessageRead, new { id = berichtId }, ct);
if (!antwoord.Gelukt) return Uitkomst<string>.Fout(antwoord.Melding!);
return antwoord.Waarde.TryGetProperty("tekst", out var tekst)
? Uitkomst<string>.Ok(tekst.GetString() ?? string.Empty)
: Uitkomst<string>.Ok(string.Empty);
}
/// <summary>Je eigen verstuurde bericht nalezen, ook als het al bezorgd is. Zie MessageService.ReadSentAsync.</summary>
public async Task<Uitkomst<string>> BerichtVerzondenLezenAsync(long berichtId, CancellationToken ct = default)
{
var antwoord = await StuurAsync<JsonElement>(SocketActions.MessageReadSent, new { id = berichtId }, ct);
if (!antwoord.Gelukt) return Uitkomst<string>.Fout(antwoord.Melding!);
return antwoord.Waarde.TryGetProperty("tekst", out var tekst)
? Uitkomst<string>.Ok(tekst.GetString() ?? string.Empty)
: Uitkomst<string>.Ok(string.Empty);
}
/// <param name="soort">0 tekst, 1 video, 2 audio, 3 document.</param>
/// <param name="bestandId">Een eerder verstuurde opname of bijlage die bij dit bericht hoort.</param>
public Task<Uitkomst<JsonElement>> BerichtKlaarzettenAsync(
long aanPersoonId, string onderwerp, string tekst, DateTime? bezorgOp, bool naOverlijden,
int soort = 0, long? bestandId = null, CancellationToken ct = default) =>
StuurAsync<JsonElement>(SocketActions.MessageCompose, new
{
toPersonId = aanPersoonId,
subject = onderwerp,
text = tekst,
deliverAt = bezorgOp,
afterDeath = naOverlijden,
kind = soort,
vaultFileId = bestandId
}, ct);
/// <summary>De tekst van een bericht dat jij hebt klaargezet, om het te kunnen wijzigen.</summary>
public async Task<Uitkomst<string>> BerichtConceptAsync(long berichtId, CancellationToken ct = default)
{
var antwoord = await StuurAsync<JsonElement>(SocketActions.MessageDraft, new { id = berichtId }, ct);
if (!antwoord.Gelukt) return Uitkomst<string>.Fout(antwoord.Melding!);
return antwoord.Waarde.TryGetProperty("tekst", out var tekst)
? Uitkomst<string>.Ok(tekst.GetString() ?? string.Empty)
: Uitkomst<string>.Ok(string.Empty);
}
public Task<Uitkomst<JsonElement>> BerichtWijzigenAsync(
long berichtId, string onderwerp, string tekst, DateTime? bezorgOp, bool naOverlijden,
CancellationToken ct = default) =>
StuurAsync<JsonElement>(SocketActions.MessageUpdate, new
{
id = berichtId,
subject = onderwerp,
text = tekst,
deliverAt = bezorgOp,
afterDeath = naOverlijden
}, ct);
public Task<Uitkomst<JsonElement>> BerichtIntrekkenAsync(long berichtId, CancellationToken ct = default) =>
StuurAsync<JsonElement>(SocketActions.MessageWithdraw, new { id = berichtId }, ct);
public Task<Uitkomst<JsonElement>> BerichtVerwijderenAsync(long berichtId, CancellationToken ct = default) =>
StuurAsync<JsonElement>(SocketActions.MessageDelete, new { id = berichtId }, ct);
// ------------------------------------------------------------------ nalatenschap
public Task<Uitkomst<NalatenschapStand>> NalatenschapStandAsync(long? persoonId = null, CancellationToken ct = default) =>
StuurAsync<NalatenschapStand>(SocketActions.LegacyStatus,
persoonId is null ? null : new { personId = persoonId.Value }, ct);
public Task<Uitkomst<NalatenschapStand>> MeldOverlijdenAsync(
long persoonId, DateOnly? datum = null, string? notitie = null, CancellationToken ct = default) =>
StuurAsync<NalatenschapStand>(SocketActions.DeclareDeath,
new { personId = persoonId, dateOfDeath = datum, note = notitie }, ct);
public Task<Uitkomst<JsonElement>> IkBenErNogAsync(CancellationToken ct = default) =>
StuurAsync<JsonElement>(SocketActions.ObjectDeath, null, ct);
public Task<Uitkomst<JsonElement>> WijsBeheerderAanAsync(
long persoonId, int volgorde = 1, CancellationToken ct = default) =>
StuurAsync<JsonElement>(SocketActions.AppointManager,
new { managerPersonId = persoonId, priority = volgorde }, ct);
/// <summary>Haalt iemand weer weg als beheerder van jouw nalatenschap.</summary>
public Task<Uitkomst<JsonElement>> VerwijderBeheerderAsync(
long persoonId, CancellationToken ct = default) =>
StuurAsync<JsonElement>(SocketActions.RemoveManager, new { managerPersonId = persoonId }, ct);
/// <summary>Wie je hebt aangewezen om je nalatenschap te beheren, op volgorde.</summary>
/// <summary>De nalatenschappen die aan jou zijn toevertrouwd en waarvan de procedure rond is.</summary>
public Task<Uitkomst<IReadOnlyList<Toevertrouwd>>> ToevertrouwdAsync(CancellationToken ct = default) =>
StuurAsync<IReadOnlyList<Toevertrouwd>>(SocketActions.LegacyEntrusted, null, ct);
/// <summary>Wat er van die nalatenschap voor jou is vrijgegeven.</summary>
public Task<Uitkomst<IReadOnlyList<KluisItem>>> NalatenschapItemsAsync(long persoonId, CancellationToken ct = default) =>
StuurAsync<IReadOnlyList<KluisItem>>(SocketActions.LegacyEntrustedItems, new { personId = persoonId }, ct);
/// <summary>
/// Kluisitems die iemand rechtstreeks met jou heeft gedeeld om vooraf te bespreken (bijvoorbeeld
/// een uitvaartwens) - los van de gewone nalatenschap-vrijgave na een overlijden.
/// </summary>
public Task<Uitkomst<IReadOnlyList<KluisItem>>> GedeeldMetMijAsync(CancellationToken ct = default) =>
StuurAsync<IReadOnlyList<KluisItem>>(SocketActions.VaultSharedWithMe, null, ct);
/// <summary>
/// De advertentie voor het overzicht, of null als er nu geen is. Het opvragen telt de
/// vertoning mee: wat hier terugkomt wordt ook getoond.
/// </summary>
public Task<Uitkomst<Advertentie?>> AdvertentieAsync(CancellationToken ct = default) =>
StuurAsync<Advertentie?>(SocketActions.Advertentie, null, ct);
public Task<Uitkomst<IReadOnlyList<Beheerder>>> BeheerdersAsync(CancellationToken ct = default) =>
StuurAsync<IReadOnlyList<Beheerder>>(SocketActions.LegacyManagers, null, ct);
/// <summary>
/// Hoeveel onafhankelijke meldingen er nodig zijn voordat de procedure begint, en hoeveel
/// dagen je daarna nog hebt om te laten weten dat je er nog bent.
/// </summary>
public Task<Uitkomst<JsonElement>> NalatenschapInstellingenAsync(
int aantalMeldingen, int bezwaardagen, CancellationToken ct = default) =>
StuurAsync<JsonElement>(SocketActions.LegacySettings,
new { declarations = aantalMeldingen, objectionDays = bezwaardagen }, ct);
// ------------------------------------------------------------------ meldingen
/// <summary>
/// Meldt dit toestel aan voor meldingen. Het kenmerk komt van Firebase (Android) of APNs
/// (iOS) en verandert af en toe vanzelf, dus dit hoort bij elke start te gebeuren.
/// </summary>
public Task<Uitkomst<JsonElement>> ToestelAanmeldenAsync(
string kenmerk, string platform = "android", string? naam = null, CancellationToken ct = default) =>
StuurAsync<JsonElement>(SocketActions.RegisterDevice,
new { token = kenmerk, platform, name = naam }, ct);
public Task<Uitkomst<JsonElement>> ToestelAfmeldenAsync(string kenmerk, CancellationToken ct = default) =>
StuurAsync<JsonElement>(SocketActions.UnregisterDevice, new { token = kenmerk }, ct);
/// <summary>
/// Vraagt de server om een proefmelding naar de toestellen van deze gebruiker. Het antwoord
/// zegt of het is gelukt en anders waar het op vastloopt.
/// </summary>
public Task<Uitkomst<Proefmelding>> ProefmeldingAsync(CancellationToken ct = default) =>
StuurAsync<Proefmelding>(SocketActions.TestDevice, null, ct);
// ------------------------------------------------------- aantekeningen over mij
/// <summary>
/// Heeft iemand een profiel voor mij aangemaakt? Alleen ik kan zeggen of dat over mij gaat;
/// koppelen op e-mailadres doet MijnBesluit niet. Zie docs/Identiteit.md.
/// </summary>
public async Task<Uitkomst<IReadOnlyList<Aantekening>>> AantekeningenOverMijAsync(CancellationToken ct = default) =>
await StuurAsync<IReadOnlyList<Aantekening>>(SocketActions.NotesAboutMe, null, ct);
/// <summary>"Ja, dat ben ik." De aantekening gaat op in het profiel dat ik al heb.</summary>
public Task<Uitkomst<JsonElement>> AantekeningOvernemenAsync(long persoonId, CancellationToken ct = default) =>
StuurAsync<JsonElement>(SocketActions.AcceptNote, new { personId = persoonId }, ct);
/// <summary>"Nee, dat ben ik niet." Er verandert niets en de vraag komt niet terug.</summary>
public Task<Uitkomst<JsonElement>> AantekeningAfwijzenAsync(long persoonId, CancellationToken ct = default) =>
StuurAsync<JsonElement>(SocketActions.DeclineNote, new { personId = persoonId }, ct);
// ------------------------------------------------------------------ binnenwerk
/// <summary>Stuurt een actie en zet het antwoord om; fouten komen als melding terug.</summary>
private async Task<Uitkomst<T>> StuurAsync<T>(string actie, object? payload, CancellationToken ct)
{
var antwoord = await _socket.SendAsync(actie, payload, null, ct);
// Een geslaagd verzoek is het bewijs dat er verbinding is. Zonder dit blijft de melding
// "geen verbinding" staan na een onderbreking die allang weer voorbij is.
Verbindingsstand(_socket.IsConnected);
if (antwoord.Status != "Ok")
{
if (antwoord.Code == SocketFoutcodes.SessieVerlopen) await SessieKwijtAsync();
return Uitkomst<T>.Fout(antwoord.Message ?? "Er ging iets mis.");
}
if (antwoord.Payload is null)
return Uitkomst<T>.Ok(default!);
try
{
// De payload komt binnen als JsonElement; die zetten we om naar het gevraagde type.
var element = antwoord.Payload is JsonElement e
? e
: JsonSerializer.SerializeToElement(antwoord.Payload, SocketFraming.Json);
return Uitkomst<T>.Ok(element.Deserialize<T>(SocketFraming.Json)!);
}
catch (JsonException ex)
{
return Uitkomst<T>.Fout($"Onverwacht antwoord van de server: {ex.Message}");
}
}
/// <summary>
/// De server kent ons token niet meer. Opruimen en één keer melden; een tweede verzoek dat
/// hetzelfde antwoord krijgt hoort niet nog een keer af te gaan.
/// </summary>
private async Task SessieKwijtAsync()
{
if (!IsAangemeld) return;
_socket.Token = null;
PersoonId = 0;
MijnProfiel = null;
await _opslag.WisAsync();
SessieVerlopen?.Invoke();
}
public async ValueTask DisposeAsync() => await _socket.DisposeAsync();
}
+352
View File
@@ -0,0 +1,352 @@
using System.Text.Json.Serialization;
namespace MijnBesluit.Client;
// De vorm van wat de SocketServer terugstuurt. De namen komen overeen met wat de dispatcher
// verstuurt; het protocol gebruikt kleine beginletters, dat regelt de JSON-instelling.
public record SessieGegevens(
[property: JsonPropertyName("token")] string Token,
[property: JsonPropertyName("persoonId")] long PersoonId,
[property: JsonPropertyName("verlooptOp")] DateTime? VerlooptOp);
public record CodeGestuurd(
[property: JsonPropertyName("verstuurdNaar")] string VerstuurdNaar,
[property: JsonPropertyName("geldigTot")] DateTime GeldigTot);
public record FamilieOverzicht(long Id, string Name, bool Eigenaar);
/// <summary>Eén regel op het beginscherm: iets wat speelt of eraan komt.</summary>
public record Overzichtsregel(string Soort, string Tekst, DateTime? Datum, long? Id)
{
public bool IsWaarschuwing => Soort == "overlijden";
public bool IsMoment => Soort == "moment";
}
/// <summary>Alles wat het beginscherm laat zien.</summary>
public record Overzicht(
int AantalKluis,
int AantalFamilie,
int AantalVoorMij,
int AantalVanMij,
int AantalNieuweBerichten,
int AantalBeheerders,
int VooruitblikDagen,
IReadOnlyList<Overzichtsregel> Regels);
/// <summary>
/// De sociale media die je bij een kluisitem kunt kiezen.
///
/// Dit is een kopie van SocialeMedia in MijnBesluit.Domain: de app ziet het domein niet, want
/// die praat alleen over de socket. Een controle in de zelftest bewaakt dat de twee lijsten
/// gelijk blijven, zodat ze niet stilletjes uit elkaar lopen.
/// </summary>
public static class SocialeMedia
{
public const string Anders = "Anders";
public static readonly string[] Namen =
{
"Facebook", "Instagram", "WhatsApp", "LinkedIn", "X (voorheen Twitter)",
"TikTok", "YouTube", "Snapchat", "Pinterest", "Telegram", "Signal",
"Discord", "Reddit", "Strava", "Marktplaats", Anders
};
}
/// <summary>
/// Datums zoals ze op het scherm horen te staan, in het Nederlands.
///
/// Dit is een kopie van Datums in MijnBesluit.Domain: de app ziet het domein niet. Een controle
/// in de zelftest bewaakt dat de twee gelijk blijven.
///
/// Waarom dit met de hand gaat en niet met ToString("d MMMM yyyy"): dat volgt de taalinstelling
/// van het toestel, en dan leest een telefoon die op Engels staat "9 June 2031" terwijl de rest
/// van het scherm Nederlands is. Een app die maar in één taal bestaat, hoort ook maar in één taal
/// te rekenen.
/// </summary>
public static class Datums
{
public static readonly string[] Maanden =
{
"januari", "februari", "maart", "april", "mei", "juni",
"juli", "augustus", "september", "oktober", "november", "december"
};
/// <summary>Afkortingen voor waar het krap is. "Mei" heeft geen punt: dat is geen afkorting.</summary>
public static readonly string[] KorteMaanden =
{
"Jan.", "Feb.", "Mrt.", "Apr.", "Mei", "Jun.",
"Jul.", "Aug.", "Sep.", "Okt.", "Nov.", "Dec."
};
/// <summary>Zondag eerst, want zo telt DayOfWeek.</summary>
public static readonly string[] Dagen =
{
"zondag", "maandag", "dinsdag", "woensdag", "donderdag", "vrijdag", "zaterdag"
};
}
/// <summary>
/// Wat iemand van je is: de lijst die je te kiezen krijgt, en hoe elke relatie heet.
///
/// Dit is een kopie van Relaties in MijnBesluit.Domain: de app ziet het domein niet, want die
/// praat alleen over de socket. Daarom staan hier nummers en geen enum - dat is wat er over de
/// lijn gaat. Een controle in de zelftest bewaakt dat de twee lijsten gelijk blijven.
///
/// <see cref="Keuzes"/> is wat je kunt kiezen, <see cref="Naam"/> vertaalt élke waarde die ooit
/// heeft bestaan. Kind, Bonuskind, Ouder, Bonusouder, Kleinkind en Grootouder kun je niet meer
/// kiezen, maar staan wel bij bestaande mensen in de database; zonder vertaling zou daar ineens
/// niets meer staan.
/// </summary>
public static class Relaties
{
public static readonly (int Waarde, string Naam)[] Keuzes =
{
(1, "Partner"),
(11, "Zoon"),
(12, "Dochter"),
(28, "Bonuszoon"),
(29, "Bonusdochter"),
(16, "Schoonzoon"),
(15, "Schoondochter"),
(14, "Kleinzoon"),
(13, "Kleindochter"),
(33, "Achterkleinzoon"),
(32, "Achterkleindochter"),
(19, "Vader"),
(20, "Moeder"),
(22, "Bonusvader"),
(21, "Bonusmoeder"),
(24, "Schoonvader"),
(25, "Schoonmoeder"),
(17, "Opa"),
(18, "Oma"),
(6, "Broer"),
(7, "Zus"),
(30, "Bonusbroer"),
(31, "Bonuszus"),
(26, "Zwager"),
(27, "Schoonzus"),
(34, "Oom"),
(35, "Tante"),
(10, "Vriend"),
(23, "Vriendin"),
(99, "Anders")
};
/// <summary>Hoe een relatie op het scherm heet - ook de relaties die je niet meer kunt kiezen.</summary>
public static string Naam(int relatie) => relatie switch
{
2 => "Kind",
3 => "Bonuskind",
4 => "Ouder",
5 => "Bonusouder",
8 => "Kleinkind",
9 => "Grootouder",
0 => "Familie",
_ => Keuzes.FirstOrDefault(k => k.Waarde == relatie).Naam ?? "Familie"
};
}
/// <summary>
/// Een profiel dat iemand anders voor je heeft aangemaakt op jouw e-mailadres, en waarvan alleen
/// jij kunt zeggen of het over jou gaat. Zie docs/Identiteit.md.
/// </summary>
public record Aantekening(long PersoonId, string Van, string? Relatie, DateTime AangemaaktOp);
/// <summary>Eigendomsstatus van een profiel: 0 beheerd, 1 uitgenodigd, 2 geclaimd.</summary>
public enum ProfielStand { Beheerd = 0, Uitgenodigd = 1, Geclaimd = 2 }
public record Familielid(
long PersonId,
string DisplayName,
string FirstName,
string LastName,
string? Email,
int Relation,
string? RelationLabel,
ProfielStand State,
bool CanEdit,
DateOnly? BirthDate,
DateOnly? DateOfDeath,
// De geschiedenis van het profiel. Komt rechtstreeks van de server; alles in UTC.
DateTime CreatedAt,
DateTime? InvitedAt,
DateTime? InvitationExpiresAt,
int InvitationResends,
DateTime? ClaimedAt,
// Twee dingen die pas ter sprake komen als je iemand weghaalt, maar die meekomen met de
// lijst zodat het scherm ze meteen heeft. Zie FamilyService.VerwijderLidAsync.
bool IsBeheerder,
int MomentenKlaar,
// De profielfoto. Het nummer om hem groot op te halen, en de kleine versie als base64 om
// hem meteen te kunnen tonen zonder tweede ronde langs de server.
long? ProfielfotoId = null,
string? Profielfoto = null)
{
public bool IsGeclaimd => State == ProfielStand.Geclaimd;
/// <summary>De kleine profielfoto als verwijzing die een img-element rechtstreeks aankan.</summary>
public string? Profielfotobron =>
string.IsNullOrEmpty(Profielfoto) ? null : $"data:image/jpeg;base64,{Profielfoto}";
}
/// <summary>
/// Wat er verdwijnt als je je account opheft. Staat in het scherm vóórdat iemand ja zegt:
/// "weet je het zeker?" is een lege vraag, "je kluis met 23 items" is er een waar iemand echt
/// over nadenkt.
/// </summary>
public record Opheffing(int Kluisitems, int Bestanden, int BerichtenKlaar, int BerichtenBezorgd,
int Mensen, int Nalatenschappen);
/// <summary>Je eigen gegevens, zoals het profielscherm ze toont.</summary>
public record EigenProfiel(long PersoonId, string Voornaam, string Achternaam, string Email,
DateOnly? Geboortedatum, DateTime LidSinds,
// De kleine versie van je eigen profielfoto, als base64. Die gaat mee
// in dit antwoord omdat de app hem op meer schermen laat zien dan
// alleen je profiel - in de kluis staat hij bij "Alleen voor mijzelf".
// Een eigen ronde langs de server voor een plaatje van veertig pixels
// zou duurder zijn dan het plaatje zelf.
string? Profielfoto = null)
{
public string Naam => string.Join(" ",
new[] { Voornaam, Achternaam }.Where(d => !string.IsNullOrWhiteSpace(d)));
/// <summary>De kleine profielfoto als verwijzing die een img-element rechtstreeks aankan.</summary>
public string? Profielfotobron =>
string.IsNullOrEmpty(Profielfoto) ? null : $"data:image/jpeg;base64,{Profielfoto}";
}
/// <summary>
/// Een nalatenschap die jij beheert. <see cref="Afgerond"/> is false zolang de persoon er
/// gewoon is: dan weet je alleen dat je bent aangewezen en valt er niets in te zien.
/// </summary>
public record Toevertrouwd(long PersonId, string DisplayName, DateOnly? DateOfDeath,
DateTime? AccessGrantedAt, int AantalItems,
bool Afgerond = false, int Volgorde = 1, string? Profielfoto = null,
int RequiredDeclarations = 3, int ObjectionDays = 14)
{
/// <summary>De kleine profielfoto als verwijzing die een img-element rechtstreeks aankan.</summary>
public string? Profielfotobron =>
string.IsNullOrEmpty(Profielfoto) ? null : $"data:image/jpeg;base64,{Profielfoto}";
}
public record KluisItem(
long Id,
string Title,
int Category,
int ReleasePolicy,
DateTime? ReleaseAt,
DateTime UpdatedAt,
long? SharedManagerPersonId = null);
/// <summary>De ontsleutelde inhoud van een kluisitem. Per soort is maar een deel gevuld.</summary>
public class KluisInhoud
{
public string? Notes { get; set; }
public string? Website { get; set; }
public string? Username { get; set; }
public string? Secret { get; set; }
public string? Pincode { get; set; }
public string? DeviceName { get; set; }
public string? RecoveryCodes { get; set; }
/// <summary>Welk sociaal medium; een van SocialeMedia.Namen, of "Anders".</summary>
public string? Platform { get; set; }
/// <summary>Alleen gevuld als Platform "Anders" is.</summary>
public string? PlatformAnders { get; set; }
public string? Insurer { get; set; }
public string? PolicyNumber { get; set; }
public string? ContactPhone { get; set; }
public decimal? Amount { get; set; }
public string? BurialOrCremation { get; set; }
public string? Location { get; set; }
public string? Music { get; set; }
public string? Speakers { get; set; }
public string? Wishes { get; set; }
}
/// <summary>Een bijlage bij een kluisitem, zonder de inhoud zelf.</summary>
public record Bijlage(long Id, string FileName, string ContentType, long SizeBytes, DateTime CreatedAt,
bool IsProfilePhoto = false)
{
/// <summary>De grootte zoals je hem aan iemand laat zien: "1,4 MB".</summary>
public string Grootte => SizeBytes >= 1024 * 1024
? $"{SizeBytes / 1024d / 1024d:0.#} MB"
: $"{Math.Max(1, SizeBytes / 1024)} kB";
public bool IsAfbeelding => ContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase);
public bool IsVideo => ContentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase);
}
/// <summary>Een opgehaald bestand, met de inhoud erbij.</summary>
public record BestandInhoud(string Naam, string Soort, byte[] Inhoud);
public record Bericht(
long Id,
string Subject,
int Kind,
long FromPersonId,
long ToPersonId,
DateTime? DeliveredAt,
DateTime? ReadAt,
DateTime? DeliverAt,
bool DeliverAfterDeath,
int Status,
long? VaultFileId)
{
public bool IsNieuw => Status == 2; // 2 = bezorgd, 3 = gelezen
/// <summary>0 tekst, 1 video, 2 audio, 3 document.</summary>
public bool IsVideo => Kind == 1;
/// <summary>Zit er een opname of bijlage bij dit bericht?</summary>
public bool HeeftBestand => VaultFileId is > 0;
}
/// <summary>De uitkomst van een proefmelding: is hij verstuurd, en zo niet waarom niet.</summary>
public record Proefmelding(bool Gelukt, string Uitleg);
/// <summary>Iemand die jouw nalatenschap mag beheren. Volgorde 1 is de eerste keus.</summary>
/// <summary>
/// De advertentie die nu op het overzicht mag staan. De adressen zijn volledig: de app weet niet
/// waar de site staat en hoort dat ook niet zelf in elkaar te zetten.
/// </summary>
/// <param name="Link">
/// Gaat naar de site en niet rechtstreeks naar de adverteerder. Daar wordt de klik geteld en
/// vandaar wordt doorgestuurd; zo is er één plek waar dat gebeurt.
/// </param>
public record Advertentie(long Id, string Tekst, string? Afbeelding, string Link);
public record Beheerder(long PersonId, string DisplayName, int Priority, DateTime? AccessGrantedAt,
string? Profielfoto = null)
{
public bool HeeftToegang => AccessGrantedAt is not null;
/// <summary>De kleine profielfoto als verwijzing die een img-element rechtstreeks aankan.</summary>
public string? Profielfotobron =>
string.IsNullOrEmpty(Profielfoto) ? null : $"data:image/jpeg;base64,{Profielfoto}";
}
public record NalatenschapStand(
int State,
int ActiveDeclarations,
int RequiredDeclarations,
DateTime? ObjectionDeadline,
int Missing,
int ObjectionDays = 14,
bool BeheerderHeeftGemeld = false,
bool WachtOpBeheerder = false)
{
public bool LooptEenProcedure => State == 1;
public bool IsBevestigd => State == 3;
}
+21
View File
@@ -0,0 +1,21 @@
namespace MijnBesluit.Client;
/// <summary>
/// Waar het sessietoken tussen twee keer starten wordt bewaard. In de app is dat de beveiligde
/// opslag van het toestel; in een test gewoon het geheugen.
/// </summary>
public interface ITokenopslag
{
Task<string?> LeesAsync();
Task SchrijfAsync(string token);
Task WisAsync();
}
/// <summary>Bewaart niets. Handig om mee te beginnen en in tests.</summary>
public sealed class GeheugenTokenopslag : ITokenopslag
{
private string? _token;
public Task<string?> LeesAsync() => Task.FromResult(_token);
public Task SchrijfAsync(string token) { _token = token; return Task.CompletedTask; }
public Task WisAsync() { _token = null; return Task.CompletedTask; }
}
+31
View File
@@ -0,0 +1,31 @@
namespace MijnBesluit.Client;
/// <summary>
/// Wat een aanroep opleverde. Bewust geen uitzonderingen: een netwerkstoring of een afgewezen
/// code is bij een app geen uitzonderlijke situatie maar een normaal antwoord, en de melding
/// kan zo rechtstreeks op het scherm.
/// </summary>
public readonly struct Uitkomst
{
public bool Gelukt { get; }
public string? Melding { get; }
private Uitkomst(bool gelukt, string? melding) { Gelukt = gelukt; Melding = melding; }
public static Uitkomst Ok() => new(true, null);
public static Uitkomst Fout(string melding) => new(false, melding);
}
/// <summary>Zelfde, met een waarde erbij.</summary>
public readonly struct Uitkomst<T>
{
public bool Gelukt { get; }
public string? Melding { get; }
public T? Waarde { get; }
private Uitkomst(bool gelukt, T? waarde, string? melding)
{ Gelukt = gelukt; Waarde = waarde; Melding = melding; }
public static Uitkomst<T> Ok(T waarde) => new(true, waarde, null);
public static Uitkomst<T> Fout(string melding) => new(false, default, melding);
}
@@ -0,0 +1,137 @@
using MijnBesluit.Domain.Enums;
namespace MijnBesluit.Domain.Entities;
/// <summary>
/// Een bedrijf dat adverteert.
///
/// Bewust een eigen soort en geen <see cref="User"/>: een adverteerder is een bedrijf, geen
/// persoon met een kluis, familie of nalatenschap. Hem bij de gebruikers zetten zou betekenen
/// dat elke vraag over gebruikers hem moet uitzonderen, en één vergeten uitzondering is er één
/// te veel in een applicatie die over nalatenschappen gaat.
///
/// Inloggen gaat wel op dezelfde manier: een code van zes cijfers per e-mail, geen wachtwoord.
/// </summary>
public class Adverteerder
{
public long Id { get; set; }
public string Bedrijfsnaam { get; set; } = string.Empty;
public string Contactpersoon { get; set; } = string.Empty;
/// <summary>Waarmee hij inlogt, en waar de bevestigingen heen gaan.</summary>
public string Email { get; set; } = string.Empty;
public string? Telefoon { get; set; }
public string? Adres { get; set; }
public string? Postcode { get; set; }
public string? Plaats { get; set; }
public string? Land { get; set; }
/// <summary>KvK-nummer.</summary>
public string? Kvk { get; set; }
/// <summary>Btw-nummer.</summary>
public string? Btw { get; set; }
public DateTime AangemaaktOp { get; set; }
public DateTime? LaatstGezienOp { get; set; }
/// <summary>
/// Geblokkeerd: hij kan geen nieuwe campagnes meer indienen. Campagnes die al draaien
/// blijven staan - die zijn betaald, en stopzetten is een aparte beslissing per campagne.
/// </summary>
public DateTime? GeblokkeerdOp { get; set; }
public bool IsGeblokkeerd => GeblokkeerdOp is not null;
}
/// <summary>
/// Eén geboekte advertentie: wat er staat, wanneer hij mag draaien, hoe vaak per uur, wat hij
/// kost en hoe ver hij is.
///
/// Zie docs/Adverteren.md voor de keuzeregels en het betaalpad.
/// </summary>
public class Campagne
{
public long Id { get; set; }
public long AdverteerderId { get; set; }
public Adverteerder? Adverteerder { get; set; }
public string Tekst { get; set; } = string.Empty;
/// <summary>Waar de tegel heen gaat als iemand erop tikt.</summary>
public string Link { get; set; } = string.Empty;
/// <summary>
/// De afbeelding, verkleind bij het opslaan. Staat hier en niet bij de bestanden van
/// gebruikers: die zijn versleuteld met sleutels per gebruiker, en een advertentie is juist
/// openbaar.
/// </summary>
public byte[]? Afbeelding { get; set; }
public string? AfbeeldingSoort { get; set; }
public DateOnly VanafDatum { get; set; }
public DateOnly TotEnMetDatum { get; set; }
/// <summary>Hoe vaak hij per klokuur hoogstens wordt getoond. Een maximum, geen belofte.</summary>
public int PerUur { get; set; }
/// <summary>Wat het kost, in centen. Bij het boeken berekend en dan vastgelegd.</summary>
public int BedragCenten { get; set; }
/// <summary>
/// Het tarief waarmee gerekend is. Verandert het tarief later, dan blijft hier staan wat er
/// destijds is afgesproken - anders valt een oude factuur niet meer na te rekenen.
/// </summary>
public int TariefCenten { get; set; }
/// <summary>Wat de adverteerder bij zijn betaling zet, zodat de beheerder hem terugvindt.</summary>
public string? Betaalkenmerk { get; set; }
public DateTime? BetaaldOp { get; set; }
public long? BetaaldAfgevinktDoorUserId { get; set; }
public CampagneStand Stand { get; set; } = CampagneStand.Concept;
public DateTime? GoedgekeurdOp { get; set; }
public long? GoedgekeurdDoorUserId { get; set; }
/// <summary>Waarom hij is afgewezen. Verplicht bij afwijzen: de adverteerder hoort te weten waarom.</summary>
public string? AfwijzingReden { get; set; }
public DateTime AangemaaktOp { get; set; }
public DateTime? GewijzigdOp { get; set; }
public bool IsBetaald => BetaaldOp is not null;
public bool HeeftAfbeelding => Afbeelding is { Length: > 0 };
/// <summary>Hoeveel dagen hij loopt; begin- en einddag tellen allebei mee.</summary>
public int Dagen => TotEnMetDatum.DayNumber - VanafDatum.DayNumber + 1;
/// <summary>Mag hij op deze dag draaien? Alleen goedgekeurd én binnen de periode.</summary>
public bool DraaitOp(DateOnly dag) =>
Stand == CampagneStand.Loopt && dag >= VanafDatum && dag <= TotEnMetDatum;
}
/// <summary>
/// De tellers van één campagne in één klokuur.
///
/// Eén regel per uur en niet per vertoning: bij een paar honderd bezoekers per uur loopt zo'n
/// tabel eindeloos vol, en voor het kiezen én voor de verantwoording is een teller genoeg.
/// </summary>
public class CampagneUur
{
public long Id { get; set; }
public long CampagneId { get; set; }
/// <summary>Het hele uur in UTC waar deze tellers bij horen.</summary>
public DateTime UurUtc { get; set; }
public int Vertoningen { get; set; }
public int Kliks { get; set; }
/// <summary>Het hele uur waar dit moment in valt. Eén plek, zodat de afronding overal gelijk is.</summary>
public static DateTime Uur(DateTime moment) =>
new(moment.Year, moment.Month, moment.Day, moment.Hour, 0, 0, DateTimeKind.Utc);
}
@@ -0,0 +1,32 @@
using MijnBesluit.Domain.Enums;
namespace MijnBesluit.Domain.Entities;
/// <summary>Onveranderlijk logboek: wie deed wat, wanneer en waarvandaan.</summary>
public class AuditLogEntry
{
public long Id { get; set; }
public long? UserId { get; set; }
public AuditAction Action { get; set; }
public string? EntityType { get; set; }
public long? EntityId { get; set; }
public string? IpAddress { get; set; }
public string? UserAgent { get; set; }
public string? Details { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
/// <summary>Vernieuwtoken voor een ingelogde sessie (web of app).</summary>
public class RefreshToken
{
public long Id { get; set; }
public long UserId { get; set; }
public byte[] TokenHash { get; set; } = Array.Empty<byte>();
public string? Device { get; set; }
public string? CreatedByIp { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime ExpiresAt { get; set; }
public DateTime? RevokedAt { get; set; }
public bool IsActive(DateTime nowUtc) => RevokedAt is null && ExpiresAt > nowUtc;
}
+40
View File
@@ -0,0 +1,40 @@
using MijnBesluit.Domain.Enums;
namespace MijnBesluit.Domain.Entities;
/// <summary>Een familieproject. Iedere deelnemer kan zijn eigen familie starten.</summary>
public class Family
{
public long Id { get; set; }
public string Name { get; set; } = string.Empty;
/// <summary>De persoon om wie deze familie draait; de eigenaar van het project.</summary>
public long OwnerPersonId { get; set; }
public Person? OwnerPerson { get; set; }
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public ICollection<FamilyMember> Members { get; set; } = new List<FamilyMember>();
}
/// <summary>Koppeling van een persoon aan een familie, met de relatie erbij.</summary>
public class FamilyMember
{
public long Id { get; set; }
public long FamilyId { get; set; }
public Family? Family { get; set; }
public long PersonId { get; set; }
public Person? Person { get; set; }
public RelationType Relation { get; set; } = RelationType.Onbekend;
/// <summary>Vrije omschrijving als <see cref="Relation"/> niet volstaat.</summary>
public string? RelationLabel { get; set; }
public long? AddedByUserId { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
@@ -0,0 +1,32 @@
using MijnBesluit.Domain.Enums;
namespace MijnBesluit.Domain.Entities;
/// <summary>Een e-mailuitnodiging om een aangemaakt profiel te komen claimen.</summary>
public class Invitation
{
public long Id { get; set; }
public long PersonId { get; set; }
public Person? Person { get; set; }
public long FamilyId { get; set; }
public Family? Family { get; set; }
public string Email { get; set; } = string.Empty;
/// <summary>SHA-256 van het uitnodigingstoken. Het token zelf staat alleen in de e-mail.</summary>
public byte[] TokenHash { get; set; } = Array.Empty<byte>();
public InvitationStatus Status { get; set; } = InvitationStatus.Concept;
public long CreatedByUserId { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? SentAt { get; set; }
public DateTime ExpiresAt { get; set; }
public DateTime? AcceptedAt { get; set; }
public int ResendCount { get; set; }
public bool IsOpen(DateTime nowUtc) =>
(Status == InvitationStatus.Verzonden || Status == InvitationStatus.Concept) && ExpiresAt > nowUtc;
}
+44
View File
@@ -0,0 +1,44 @@
namespace MijnBesluit.Domain.Entities;
/// <summary>Een melding dat iemand is overleden. Er zijn er meerdere nodig voordat dit bevestigd wordt.</summary>
public class DeathDeclaration
{
public long Id { get; set; }
/// <summary>De persoon die volgens de melder is overleden.</summary>
public long PersonId { get; set; }
public Person? Person { get; set; }
/// <summary>De gebruiker die de melding doet. Eén melding per gebruiker per profiel.</summary>
public long DeclaredByUserId { get; set; }
public DateOnly? DateOfDeath { get; set; }
public string? Note { get; set; }
public DateTime DeclaredAt { get; set; } = DateTime.UtcNow;
public DateTime? RevokedAt { get; set; }
public bool IsActive => RevokedAt is null;
}
/// <summary>Wie de nalatenschap mag beheren zodra het overlijden bevestigd is.</summary>
public class LegacyManager
{
public long Id { get; set; }
/// <summary>Het profiel waarvan de nalatenschap beheerd wordt.</summary>
public long PersonId { get; set; }
public Person? Person { get; set; }
/// <summary>De aangewezen beheerder.</summary>
public long ManagerPersonId { get; set; }
public Person? ManagerPerson { get; set; }
/// <summary>1 = eerste keus. Bij gelijke rechten mogen meerdere beheerders naast elkaar bestaan.</summary>
public int Priority { get; set; } = 1;
public DateTime? AcceptedAt { get; set; }
public DateTime? AccessGrantedAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
@@ -0,0 +1,53 @@
using MijnBesluit.Domain.Enums;
namespace MijnBesluit.Domain.Entities;
/// <summary>
/// Een eenmalige code die per e-mail is verstuurd om in te loggen of een account aan te maken.
///
/// De code zelf staat hier niet in: alleen een hash ervan. Wie in de database kan kijken, kan
/// dus niet met andermans code inloggen. De code bestaat maar op twee plekken: in de e-mail en
/// in het hoofd van degene die hem invult.
/// </summary>
public class LoginCode
{
public long Id { get; set; }
/// <summary>Het e-mailadres waar de code naartoe is gestuurd, genormaliseerd opgeslagen.</summary>
public string Email { get; set; } = string.Empty;
public LoginCodePurpose Purpose { get; set; }
/// <summary>Hash van de code, inclusief algoritme en salt.</summary>
public string CodeHash { get; set; } = string.Empty;
/// <summary>Alleen gevuld bij <see cref="LoginCodePurpose.Registreren"/>: de naam die is ingevuld.</summary>
public string? FirstName { get; set; }
public string? LastName { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime ExpiresAt { get; set; }
/// <summary>Gevuld zodra de code is gebruikt of ongeldig gemaakt. Een code werkt maar één keer.</summary>
public DateTime? ConsumedAt { get; set; }
/// <summary>
/// Gevuld als er met deze code ook echt iemand is binnengekomen. Dat is iets anders dan
/// <see cref="ConsumedAt"/>: die staat ook gevuld als een nieuwe aanvraag deze code ongeldig
/// maakte, of als hij te vaak fout is ingevuld.
///
/// Het verschil telt bij de rem op het aantal aanvragen per uur. Die rem is er tegen iemand
/// die andermans postvak volgooit; wie de code invult zit zelf in dat postvak. Alleen codes
/// met dit veld gevuld tellen dus niet mee - en juist die twee andere gevallen wél, want
/// anders kan een vreemde de teller leegtrekken door codes aan te vragen of expres fout in
/// te vullen.
/// </summary>
public DateTime? SignedInAt { get; set; }
/// <summary>Aantal mislukte pogingen. Boven de grens wordt de code weggegooid.</summary>
public int Attempts { get; set; }
public string? RequestedFromIp { get; set; }
public bool IsOpen(DateTime nowUtc) => ConsumedAt is null && ExpiresAt > nowUtc;
}

Some files were not shown because too many files have changed in this diff Show More