Eerste commit
This commit is contained in:
@@ -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>
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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">🤔</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
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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. */ }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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">
|
||||
‹
|
||||
</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">⚠</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">⚙</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">☉</span> Overzicht</NavLink>
|
||||
<NavLink href="/familie" ActiveClass="actief"><span class="teken">👥</span> Mensen</NavLink>
|
||||
<NavLink href="/kluis" ActiveClass="actief"><span class="teken">🔒</span> Kluis</NavLink>
|
||||
<NavLink href="/momenten" ActiveClass="actief"><span class="teken">🎬</span> Momenten</NavLink>
|
||||
<NavLink href="/nalatenschap" ActiveClass="actief"><span class="teken">🌿</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();
|
||||
}
|
||||
}
|
||||
@@ -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> · profielfoto</text> }
|
||||
@if (Bijlagewachtrij.Wacht(bijlage)) { <text> · 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)">★</button>
|
||||
}
|
||||
|
||||
<button type="button" class="knop rustig klein" disabled="@Bezig"
|
||||
title="Verwijderen"
|
||||
@onclick="() => Weghalen = bijlage">🗑</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">📷</button>
|
||||
}
|
||||
<button type="button" class="knop rustig klein" disabled="@Bezig" title="Foto kiezen"
|
||||
aria-label="Foto kiezen" @onclick="OpFoto">🖼️</button>
|
||||
|
||||
@if (KanVideo)
|
||||
{
|
||||
@if (KanCamera)
|
||||
{
|
||||
<button type="button" class="knop rustig klein" disabled="@Bezig" title="Video opnemen"
|
||||
aria-label="Video opnemen" @onclick="OpVideo">🎥</button>
|
||||
}
|
||||
<button type="button" class="knop rustig klein" disabled="@Bezig" title="Video kiezen"
|
||||
aria-label="Video kiezen" @onclick="OpVideoKiezen">🏞️</button>
|
||||
}
|
||||
|
||||
<button type="button" class="knop rustig klein" disabled="@Bezig" title="Document toevoegen"
|
||||
aria-label="Document toevoegen" @onclick="OpDocument">📄</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…</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">—</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 — 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 — nog gewoon terwijl je er bent — 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 — 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 — waaronder iemand die jij zelf hebt
|
||||
aangewezen — 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>
|
||||
@@ -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")">✉️</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">🗑️</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (Leden.Count <= 1)
|
||||
{
|
||||
<div class="leeg">
|
||||
<span class="teken">👥</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;
|
||||
}
|
||||
}
|
||||
@@ -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…</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")'>
|
||||
📷 Voorkant maken
|
||||
</button>
|
||||
<button type="button" class="knop rustig groei" disabled="@Bezig"
|
||||
@onclick='() => MaakGerichteFotoAsync("Rijbewijs achterkant")'>
|
||||
📷 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")'>
|
||||
📷 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">🔒</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">🗑️</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">🎬</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 · </text> } else { <text>Bezorgd </text> }
|
||||
@Tonen.Kort(bericht.DeliveredAt)
|
||||
@if (bericht.HeeftBestand) { <text> · 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">🗑️</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) · 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">💬</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">🗑️</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">🗑️</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) · @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 · @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…</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> · 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">🗑️</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…</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 — 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">
|
||||
🔔 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">
|
||||
✉ 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">
|
||||
🗑️ 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'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; }
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
↓@(Gegevens?.AantalVoorMij ?? 0) · ↑@(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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 |
@@ -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$<iteraties>$<salt-base64>$<hash-base64></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$<iteraties>$<salt-base64>$<hash-base64></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("&", "&").Replace("<", "<").Replace(">", ">");
|
||||
}
|
||||
@@ -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("&", "&").Replace("<", "<").Replace(">", ">")
|
||||
.Replace("\"", """);
|
||||
}
|
||||
@@ -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 — 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>
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user