Eerste commit

This commit is contained in:
jf
2026-09-27 14:21:31 +00:00
commit de4ff128cb
295 changed files with 47374 additions and 0 deletions
+63
View File
@@ -0,0 +1,63 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using MijnBesluit.Domain.Entities;
namespace MijnBesluit.Web;
/// <summary>
/// Wie er is ingelogd in het adverteerdersgedeelte. Een eigen schema met een eigen cookie, los
/// van de gebruikers van MijnBesluit.
///
/// Waarom niet één cookie met een claim erbij: dan hangt het verschil tussen een adverteerder en
/// iemand met een kluis af van de vraag of elk scherm aan die ene controle denkt. Met twee
/// schema's is het geen vergissing die je kúnt maken - een adverteerderscookie levert eenvoudig
/// geen geldige gebruiker op, en andersom ook niet.
/// </summary>
public static class Adverteerderslogin
{
public const string Schema = "Adverteerder";
/// <summary>
/// Wie er als adverteerder is ingelogd, uit zijn eigen cookie.
///
/// Let op waarom dit een aanroep is en geen blik op <c>context.User</c>: UseAuthentication
/// authenticeert alleen het STANDAARDschema, en dat is het cookie van de gewone gebruikers.
/// Het adverteerderscookie zit er dus niet in, hoe goed je ook bent ingelogd - context.User
/// is dan leeg en elk scherm zei "je bent niet meer ingelogd". Met AuthenticateAsync vraag je
/// dit schema expliciet uit te lezen.
/// </summary>
public static async Task<long?> AdverteerderIdAsync(HttpContext context)
{
var uitkomst = await context.AuthenticateAsync(Schema);
return uitkomst.Succeeded ? uitkomst.Principal.AdverteerderId() : null;
}
/// <summary>Het nummer van de adverteerder; alleen gevuld als hij met dít schema is ingelogd.</summary>
public static long? AdverteerderId(this ClaimsPrincipal? gebruiker)
{
// Let op het schema: een gewoon ingelogde gebruiker heeft ook een NameIdentifier, en die
// hoort hier nooit voor een adverteerdersnummer door te gaan.
var identiteit = gebruiker?.Identities.FirstOrDefault(i => i.AuthenticationType == Schema);
var waarde = identiteit?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
return long.TryParse(waarde, out var id) ? id : null;
}
/// <summary>De bedrijfsnaam om in de kop te zetten; lege tekst als er niets bekend is.</summary>
public static string Bedrijfsnaam(this ClaimsPrincipal? gebruiker) =>
gebruiker?.Identities.FirstOrDefault(i => i.AuthenticationType == Schema)?
.FindFirst(ClaimTypes.Name)?.Value ?? "";
/// <summary>Zet het cookie van het adverteerdersgedeelte.</summary>
public static Task MeldAanAsync(HttpContext context, Adverteerder adverteerder)
{
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, adverteerder.Id.ToString()),
new(ClaimTypes.Email, adverteerder.Email),
new(ClaimTypes.Name, adverteerder.Bedrijfsnaam)
};
return context.SignInAsync(Schema, new ClaimsPrincipal(new ClaimsIdentity(claims, Schema)));
}
}
+59
View File
@@ -0,0 +1,59 @@
// Deze usings staan er expliciet bij. In het webproject komen ze vanzelf mee, maar zo is het
// bestand ook los te compileren - en daar wordt het in de controle op nagekeken.
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using MijnBesluit.Application.Services;
namespace MijnBesluit.Web;
/// <summary>
/// De twee adressen die bij een advertentie horen: de afbeelding, en de doorverwijzing naar de
/// adverteerder waarbij de klik wordt geteld.
///
/// Allebei zonder inlog. Een advertentie is openbaar - hij staat op het overzicht van iedereen -
/// en een plaatje dat alleen met een sessie te laden is werkt niet in de app, die de tegel in een
/// webview toont.
///
/// Staat in een eigen bestand en niet in Program.cs, zodat het meekomt in het controleproject dat
/// de pagina's compileert. Zie Bestanden.cs voor dezelfde afweging.
/// </summary>
public static class Advertenties
{
public static void MapAdvertenties(this WebApplication app)
{
// De afbeelding bij een campagne. Alleen van een campagne die loopt of op de beheerder
// wacht; een afgewezen advertentie hoort niet als plaatje op te vragen te zijn.
app.MapGet("/advertentie/{id:long}/afbeelding", async (long id, HttpContext context,
AdvertentieService adverteren) =>
{
var campagne = await adverteren.CampagneAsync(id, context.RequestAborted);
if (campagne is null || !campagne.HeeftAfbeelding) return Results.NotFound();
// Een jaar in de browsercache. Een advertentie verandert niet meer nadat hij is
// goedgekeurd, en dit plaatje komt bij elke keer dat iemand het overzicht opent langs.
context.Response.Headers.CacheControl = "public, max-age=31536000, immutable";
return Results.File(campagne.Afbeelding!, campagne.AfbeeldingSoort ?? "image/jpeg");
});
// Aantikken van de tegel komt hier langs: tellen en dan doorsturen.
//
// Waarom niet rechtstreeks naar de adverteerder linken en de klik met javascript tellen:
// dan telt hij niet mee als er iets misgaat onderweg, en telt hij dubbel als iemand
// teruggaat en nog eens klikt. Dit is één plek en één telling.
app.MapGet("/advertentie/{id:long}/uit", async (long id, HttpContext context,
AdvertentieService adverteren) =>
{
var campagne = await adverteren.CampagneAsync(id, context.RequestAborted);
if (campagne is null) return Results.NotFound();
await adverteren.TelKlikAsync(id, context.RequestAborted);
// Geen permanente doorverwijzing: die blijft in de browser hangen, en dan telt een
// tweede klik niet meer mee. En de link is van de adverteerder, dus die opent in een
// nieuw venster zonder dat hij iets over deze pagina meekrijgt.
return Results.Redirect(campagne.Link, permanent: false);
});
}
}
@@ -0,0 +1,56 @@
using System.Text.Json;
using System.Text.Json.Nodes;
namespace MijnBesluit.Web;
/// <summary>
/// Het advertentietarief wijzigen vanuit het beheerscherm, zonder opnieuw uit te rollen.
///
/// Staat in dezelfde instellingen als de rest (Adverteren:TariefCenten): ASP.NET Core herleest
/// dat bestand vanzelf zodra het verandert (reloadOnChange), dus de eerstvolgende pagina pakt
/// het nieuwe tarief al op. Er wordt geschreven naar het bestand waar de instelling ook
/// werkelijk vandaan komt - bij een IIS-uitrol is dat het aparte productiebestand buiten de
/// publicatiemap (zie "ExtraConfigFile" in Program.cs), anders de gewone appsettings.json naast
/// het programma. Zie MijnBesluit.ServiceBeheer.ServiceInstellingen voor hetzelfde patroon bij
/// het poortnummer van de SocketServer: eerst naar een tijdelijk bestand, dan pas omzetten, zodat
/// een onderbreking nooit een half bestand achterlaat, en de rest van het bestand blijft
/// ongemoeid.
/// </summary>
public class AdvertentietariefInstellingen
{
private readonly string _pad;
public AdvertentietariefInstellingen(string appsettingsPad) => _pad = appsettingsPad;
/// <summary>Kijkt of een tarief bruikbaar is. Geeft null als het goed is, anders een zin die je zo kunt tonen.</summary>
public static string? Controleer(int centen) =>
centen < 1 ? "Het tarief moet minstens 1 cent zijn." : null;
public void SchrijfTarief(int centen)
{
var probleem = Controleer(centen);
if (probleem is not null) throw new ArgumentOutOfRangeException(nameof(centen), probleem);
var wortel = LeesBestand() as JsonObject ?? new JsonObject();
if (wortel["Adverteren"] is not JsonObject adverteren)
{
adverteren = new JsonObject();
wortel["Adverteren"] = adverteren;
}
adverteren["TariefCenten"] = centen;
var tijdelijk = _pad + ".tmp";
File.WriteAllText(tijdelijk, wortel.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
if (File.Exists(_pad)) File.Replace(tijdelijk, _pad, null);
else File.Move(tijdelijk, _pad);
}
private JsonNode? LeesBestand()
{
if (!File.Exists(_pad)) return null;
try { return JsonNode.Parse(File.ReadAllText(_pad), null, new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip }); }
catch (JsonException) { return null; }
}
}
+81
View File
@@ -0,0 +1,81 @@
// Deze usings staan er expliciet bij. In het webproject komen ze vanzelf mee, maar zo is het
// bestand ook los te compileren - en daar wordt het in de controle op nagekeken.
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using MijnBesluit.Application.Services;
namespace MijnBesluit.Web;
/// <summary>
/// Het ophalen van een bijlage.
///
/// Dit staat bewust in een eigen bestand en niet in Program.cs: zo valt het binnen het
/// controleproject dat de pagina's compileert, en wordt het dus meegenomen als er iets
/// verandert aan de Application-laag.
/// </summary>
public static class Bestanden
{
public static void MapBijlagen(this WebApplication app)
{
// Bewust een eigen adres en niet de Blazor-verbinding: een bestand van tientallen
// megabytes daardoorheen duwen is traag en houdt die verbinding bezet.
//
// Twee adressen naar dezelfde afhandeling. Een bijlage hangt tegenwoordig ook aan een
// persoon of aan een bericht, dus "kluis" klopt niet meer; /bestand is de naam die
// blijft. Het oude adres blijft staan zolang er nog schermen open kunnen zijn die het
// gebruiken - dat kost niets en voorkomt een kapot plaatje net na een uitrol.
app.MapGet("/bestand/{id:long}", Geef).RequireAuthorization();
app.MapGet("/kluis/bestand/{id:long}", Geef).RequireAuthorization();
// De kleine versie, voor in lijstjes. Is die er niet - een foto van voor deze wijziging,
// of een bestand dat geen afbeelding is - dan komt het bestand zelf terug. Zo hoeft het
// scherm niet te weten of er al een kleine versie bestaat.
app.MapGet("/bestand/{id:long}/klein", async (long id, HttpContext context, VaultService kluis) =>
{
var gebruikerId = context.User.UserId();
if (gebruikerId is null) return Results.Unauthorized();
var klein = await kluis.OpenThumbnailAsync(id, gebruikerId.Value, context.RequestAborted);
if (klein.Succeeded) return Results.File(klein.Value!, "image/jpeg");
return await Geef(id, context, kluis);
}).RequireAuthorization();
// De bijlage bij een moment dat via een kijklink wordt bekeken - dus zonder inloggen.
// Geen {id}: het token op zich bewijst al bij welk bericht je hoort, en dat bericht
// heeft hoogstens één bijlage. Bewust GEEN RequireAuthorization: dat is nou juist het
// hele punt van deze link.
app.MapGet("/moment/{token}/bestand", async (string token, MessageService berichten, HttpContext context) =>
{
var resultaat = await berichten.OpenAttachmentByTokenAsync(token, context.RequestAborted);
if (!resultaat.Succeeded) return Results.NotFound(resultaat.Error);
var bestand = resultaat.Value.Bestand;
var inScherm = bestand.ContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase) ||
bestand.ContentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase);
return Results.File(resultaat.Value.Inhoud, bestand.ContentType,
fileDownloadName: inScherm ? null : bestand.FileName);
});
}
private static async Task<IResult> Geef(long id, HttpContext context, VaultService kluis)
{
var gebruikerId = context.User.UserId();
if (gebruikerId is null) return Results.Unauthorized();
var resultaat = await kluis.OpenFileAsync(id, gebruikerId.Value, context.RequestAborted);
if (!resultaat.Succeeded) return Results.NotFound(resultaat.Error);
var bestand = resultaat.Value.Bestand;
var inhoud = resultaat.Value.Inhoud;
// Afbeeldingen en video's laten we in het scherm zien; de rest wordt gedownload.
var inScherm = bestand.ContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase) ||
bestand.ContentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase);
return Results.File(inhoud, bestand.ContentType,
fileDownloadName: inScherm ? null : bestand.FileName);
}
}
@@ -0,0 +1,11 @@
@inject NavigationManager Navigatie
@code {
/// <summary>Stuurt iemand zonder geldige sessie naar het inlogscherm, met de gevraagde pagina erbij.</summary>
protected override void OnInitialized()
{
var terug = Uri.EscapeDataString(
Navigatie.ToBaseRelativePath(Navigatie.Uri));
Navigatie.NavigateTo($"/inloggen?terug={terug}", forceLoad: true);
}
}
+61
View File
@@ -0,0 +1,61 @@
@* Expliciet, niet via de impliciete usings van de Web-SDK: zo compileert dit bestand ook in
het losse controleproject en merk je een fout hier en niet pas op de server. *@
@using Microsoft.AspNetCore.Hosting
@inject IWebHostEnvironment Omgeving
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<base href="/" />
<link rel="stylesheet" href="app.css?v=@Stempel" />
<link rel="icon" type="image/png" href="favicon.png" />
@* Als iemand de site op zijn telefoon bij de startknoppen zet. *@
<link rel="apple-touch-icon" href="logo.png" />
<HeadOutlet />
</head>
<body>
<Routes />
<div id="blazor-error-ui">
Er ging iets mis. Ververs de pagina.
<a href="" class="reload">Vernieuwen</a>
<span class="dismiss">&times;</span>
</div>
<script src="_framework/blazor.web.js"></script>
@* Kleine stukjes javascript die bij een pagina horen. Ze staan hier en niet in die pagina's
zelf: Blazor vervangt bij het navigeren alleen de HTML, en een <script> dat zo binnenkomt
wordt niet uitgevoerd. In de kop staan ze er altijd. *@
<script src="validatie.js?v=@Stempel"></script>
<script src="adverteren.js?v=@Stempel"></script>
</body>
</html>
@code {
// Een stempel achter app.css, afgeleid van wanneer dat bestand voor het laatst is
// gewijzigd. Zonder dit houdt de browser de oude stijlregels vast na een uitrol: je krijgt
// dan de nieuwe pagina met de oude opmaak, wat eruitziet als een fout die er niet is.
// De gebruiker merkt daar niets van - zodra het bestand verandert, verandert de naam mee
// en haalt de browser hem vanzelf opnieuw op.
private static string? _stempel;
private string Stempel => _stempel ??= Bepaal();
private string Bepaal()
{
try
{
var pad = Path.Combine(Omgeving.WebRootPath, "app.css");
return File.Exists(pad)
? File.GetLastWriteTimeUtc(pad).Ticks.ToString("x")
: "1";
}
catch
{
// Lukt het niet, dan liever een pagina zonder stempel dan geen pagina.
return "1";
}
}
}
@@ -0,0 +1,57 @@
@inherits LayoutComponentBase
<div class="schil">
<header class="kop">
@* Het logo is zelf het woordmerk, dus staat de naam er niet nog een keer naast. *@
<a class="merk" href="/"><img src="/logo.png" alt="MijnBesluit" /></a>
<div class="rechts">
<AuthorizeView>
<Authorized>
@* De naam is meteen de ingang naar je eigen profiel: dat is de enige
persoon die niet bij Mijn mensen staat. *@
<a href="/profiel" class="zacht" title="Mijn profiel">
@(context.User.Naam() is { Length: > 0 } naam ? naam : "Mijn profiel")
</a>
<form method="post" action="/uitloggen" style="margin:0">
<AntiforgeryToken />
<button type="submit" class="knop rustig" style="min-height:36px;padding:.3rem .8rem">Uitloggen</button>
</form>
</Authorized>
<NotAuthorized>
<a href="/inloggen">Inloggen</a>
</NotAuthorized>
</AuthorizeView>
</div>
</header>
<AuthorizeView Context="menu">
<Authorized>
<nav class="nav">
<NavLink href="/" Match="NavLinkMatch.All" ActiveClass="actief"><span class="teken">&#9737;</span> Overzicht</NavLink>
<NavLink href="/familie" ActiveClass="actief"><span class="teken">&#128101;</span> Mensen</NavLink>
<NavLink href="/kluis" ActiveClass="actief"><span class="teken">&#128274;</span> Kluis</NavLink>
<NavLink href="/momenten" ActiveClass="actief"><span class="teken">&#127916;</span> Momenten</NavLink>
<NavLink href="/nalatenschap" ActiveClass="actief"><span class="teken">&#127807;</span> Nalatenschap</NavLink>
@* Alleen zichtbaar voor een beheerder. Wie het niet is, ziet hier niets en krijgt
op /beheer "Niet gevonden" - een pagina waarvan je niet weet dat hij bestaat,
ga je ook niet proberen. Zie docs/Identiteit.md.
Dit komt uit het inlogcookie en NIET uit de database. De layout staat op elke
pagina en wordt tegelijk met die pagina opgebouwd; een databasevraag hier liep
daardoor gelijk op met de vraag van de pagina zelf, over dezelfde DbContext.
Die kan maar een ding tegelijk, en het gevolg was een foutpagina die er soms
wel en soms niet was ("A second operation was started on this context
instance"). Een layout hoort niets op te halen. *@
@if (menu.User.IsBeheerder())
{
<NavLink href="/beheer" ActiveClass="actief"><span class="teken">&#9881;</span> Beheer</NavLink>
}
</nav>
</Authorized>
</AuthorizeView>
<main class="inhoud">
@Body
</main>
</div>
@@ -0,0 +1 @@
@* De navigatie zit in MainLayout; dit bestand blijft leeg zodat de template-verwijzing niet breekt. *@
@@ -0,0 +1,126 @@
@* Foto's, documenten en opnamen bij een kluisitem, een persoon of een bericht.
Eén component voor alle drie, net als in de app, zodat ze er overal hetzelfde uitzien. 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 kiezen gaat via InputFile en niet via een eigen knop: dan doet de browser het
bestandsvenster, inclusief "meerdere tegelijk". *@
@using Microsoft.AspNetCore.Components.Forms
@using MijnBesluit.Domain.Entities
<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">
@* Een klein voorbeeld in de rij zelf. Eerder stond de foto op ware grootte
onder elke regel; in een wijzigpopup duwde dat de rest van het formulier
buiten beeld. Klikken opent hem groot, in een eigen tabblad. *@
@if (IsAfbeelding(bijlage))
{
<a href="/bestand/@bijlage.Id" target="_blank" title="Groot bekijken">
<img class="bijlage-klein" src="/bestand/@bijlage.Id/klein" alt="@bijlage.FileName" />
</a>
}
else
{
<span class="teken">@Teken(bijlage)</span>
}
<div class="groei">
<a href="/bestand/@bijlage.Id" target="_blank">@bijlage.FileName</a>
<div class="zacht">
@Grootte(bijlage.SizeBytes)
@if (bijlage.IsProfilePhoto) { <text> &middot; profielfoto</text> }
</div>
</div>
@if (KanProfielfoto && IsAfbeelding(bijlage) && !bijlage.IsProfilePhoto)
{
<button type="button" class="knop rustig klein" disabled="@Bezig"
title="Als profielfoto gebruiken"
@onclick="() => OpProfielfoto.InvokeAsync(bijlage.Id)">&#9733;</button>
}
<button type="button" class="knop rustig klein" disabled="@Bezig"
title="Verwijderen"
@onclick="() => Weghalen = bijlage.Id">&#128465;</button>
</div>
@* Verwijderen is onomkeerbaar - de bijlage is daarna echt weg - dus eerst
bevestigen, net als overal elders waar iets wordt weggehaald. *@
@if (Weghalen == bijlage.Id)
{
<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.Id)">Ja, verwijderen</button>
<button type="button" class="knop rustig" disabled="@Bezig"
@onclick="() => Weghalen = null">Nee, laat staan</button>
</div>
</div>
}
}
</div>
}
<InputFile OnChange="OpKiezen" disabled="@Bezig" multiple
accept="image/*,video/*,application/pdf,.doc,.docx,.txt" />
@if (Bezig)
{
<div class="zacht" style="margin-top:.4rem">Bezig met opslaan&hellip;</div>
}
@if (Uitleg is not null)
{
<div class="uitleg">@Uitleg</div>
}
</div>
@code {
[Parameter] public string Titel { get; set; } = "Foto's en documenten";
[Parameter] public string? Uitleg { get; set; }
[Parameter] public IReadOnlyList<VaultFile> Bijlagen { get; set; } = Array.Empty<VaultFile>();
/// <summary>Er loopt een overdracht; dan gaan de knoppen op slot.</summary>
[Parameter] public bool Bezig { get; set; }
/// <summary>Mag hier een foto worden aangewezen als profielfoto? Alleen bij een persoon.</summary>
[Parameter] public bool KanProfielfoto { get; set; }
[Parameter] public EventCallback<InputFileChangeEventArgs> OpKiezen { get; set; }
[Parameter] public EventCallback<long> OpVerwijderen { get; set; }
[Parameter] public EventCallback<long> OpProfielfoto { get; set; }
/// <summary>Het id van de bijlage die op een "weet je het zeker?" staat te wachten.</summary>
private long? Weghalen;
private async Task VerwijderBevestigdAsync(long bijlageId)
{
Weghalen = null;
await OpVerwijderen.InvokeAsync(bijlageId);
}
private static bool IsAfbeelding(VaultFile f) =>
f.ContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase);
private static bool IsVideo(VaultFile f) =>
f.ContentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase);
private static string Teken(VaultFile f) =>
IsAfbeelding(f) ? "\U0001F4F7" : IsVideo(f) ? "\U0001F3AC" : "\U0001F4C4";
/// <summary>De grootte zoals je hem aan iemand laat zien: "1,4 MB".</summary>
private static string Grootte(long bytes) => bytes >= 1024 * 1024
? $"{bytes / 1024d / 1024d:0.#} MB"
: $"{Math.Max(1, bytes / 1024)} kB";
}
@@ -0,0 +1,65 @@
@* Een kleine lijngrafiek van de groei per dag: nieuwe profielen en nieuwe kluisitems.
Puur SVG, geen library: het gaat om twee lijntjes over een dag of dertig, en dat is geen
reden om een grafiekbibliotheek naar de beheerpagina te trekken. Alleen op de website, en
alleen voor een beheerder - dit hoeft niet met de app mee. *@
<div class="grafiek">
@if (Punten.Count < 2)
{
<p class="zacht" style="margin:0">Nog niet genoeg dagen om een lijn te tekenen.</p>
}
else
{
<svg viewBox="0 0 @Breedte @Hoogte" preserveAspectRatio="none" class="grafieksvg"
role="img" aria-label="Groei van de laatste @Punten.Count dagen">
<line x1="0" y1="@(Hoogte - Marge)" x2="@Breedte" y2="@(Hoogte - Marge)" class="grafiek-as" />
<polyline points="@Lijn(p => p.NieuweKluisItems)" class="grafiek-lijn grafiek-lijn-kluis" />
<polyline points="@Lijn(p => p.NieuweProfielen)" class="grafiek-lijn grafiek-lijn-profielen" />
</svg>
<div class="grafiek-onderschrift">
<span class="zacht">@Punten[0].Datum.ToString("d MMM")</span>
<span class="zacht">@Punten[^1].Datum.ToString("d MMM")</span>
</div>
}
<div class="grafiek-legenda">
<span class="grafiek-legendapunt grafiek-lijn-profielen"></span>
<span class="zacht">Nieuwe profielen (totaal @Punten.Sum(p => p.NieuweProfielen))</span>
<span class="grafiek-legendapunt grafiek-lijn-kluis" style="margin-left:1.2rem"></span>
<span class="zacht">Nieuwe kluisitems (totaal @Punten.Sum(p => p.NieuweKluisItems))</span>
</div>
</div>
@code {
[Parameter] public IReadOnlyList<GroeiPunt> Punten { get; set; } = Array.Empty<GroeiPunt>();
private const int Breedte = 600;
private const int Hoogte = 160;
private const int Marge = 8;
/// <summary>De hoogste dagwaarde over beide reeksen, zodat ze op dezelfde schaal staan. Nooit
/// nul: bij een lege of vlakke grafiek blijft de lijn dan gewoon plat onderin staan.</summary>
private int Max => Punten.Count == 0
? 1
: Math.Max(1, Punten.Max(p => Math.Max(p.NieuweProfielen, p.NieuweKluisItems)));
private string Lijn(Func<GroeiPunt, int> waarde)
{
if (Punten.Count < 2) return "";
var stap = (double)Breedte / (Punten.Count - 1);
var bruikbareHoogte = Hoogte - Marge * 2;
var punten = Punten.Select((p, i) =>
{
var x = i * stap;
var y = Hoogte - Marge - (waarde(p) / (double)Max * bruikbareHoogte);
return FormattableString.Invariant($"{x:0.##},{y:0.##}");
});
return string.Join(" ", punten);
}
}
@@ -0,0 +1,54 @@
using MijnBesluit.Application.Models;
using MijnBesluit.Domain.Enums;
namespace MijnBesluit.Web.Components.Onderdelen;
/// <summary>
/// Hoe een kluisitem er op het scherm uitziet: de naam van de categorie en de ingevulde velden
/// op een rij.
///
/// Dit stond in Kluis.razor. Het staat hier omdat de nalatenschapspagina precies hetzelfde moet
/// tonen: een beheerder die na een overlijden een polis openslaat hoort dezelfde velden te zien
/// als de eigenaar zelf. Twee kopieën van deze lijst lopen vroeg of laat uit elkaar, en dan mist
/// er ergens stilletjes een veld.
/// </summary>
public static class Kluisweergave
{
/// <summary>De ingevulde velden van een item, in de volgorde waarin je ze wilt lezen.</summary>
public static IEnumerable<(string Label, string Waarde)> Velden(VaultPayload p)
{
if (p.Website is not null) yield return ("Website", p.Website);
if (p.Username is not null) yield return ("Gebruikersnaam", p.Username);
if (p.Secret is not null) yield return ("Wachtwoord", p.Secret);
if (p.Pincode is not null) yield return ("Pincode", p.Pincode);
if (p.DeviceName is not null) yield return ("Apparaat", p.DeviceName);
if (p.RecoveryCodes is not null) yield return ("Herstelcodes", p.RecoveryCodes);
if (p.Platform is not null) yield return ("Medium", p.Platform);
if (p.PlatformAnders is not null) yield return ("Welk medium", p.PlatformAnders);
if (p.Insurer is not null) yield return ("Verzekeraar", p.Insurer);
if (p.PolicyNumber is not null) yield return ("Polisnummer", p.PolicyNumber);
if (p.ContactPhone is not null) yield return ("Telefoon", p.ContactPhone);
if (p.BurialOrCremation is not null) yield return ("Begraven of cremeren", p.BurialOrCremation);
if (p.Location is not null) yield return ("Locatie", p.Location);
if (p.Music is not null) yield return ("Muziek", p.Music);
if (p.Speakers is not null) yield return ("Sprekers", p.Speakers);
if (p.Wishes is not null) yield return ("Wensen", p.Wishes);
if (p.Notes is not null) yield return ("Toelichting", p.Notes);
}
public static string Naam(VaultCategory c) => c switch
{
VaultCategory.Uitvaartwens => "Uitvaartwens",
VaultCategory.Donorcodicil => "Donorcodicil",
VaultCategory.Polis => "Polis",
VaultCategory.Wachtwoord => "Wachtwoord",
VaultCategory.Apparaat => "Apparaat",
VaultCategory.Pincode => "Pincode",
VaultCategory.Document => "Document",
VaultCategory.Notitie => "Notitie",
VaultCategory.SocialMedia => "Sociale media",
VaultCategory.Rijbewijs => "Rijbewijs",
VaultCategory.Paspoort => "Paspoort",
_ => "Overig"
};
}
@@ -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,59 @@
@* Een venster over het scherm heen, voor wijzigen en bevestigen.
Dezelfde opzet als in de app, zodat wijzigen en verwijderen op de site en op de telefoon
hetzelfde aanvoelen. Bewust geen JavaScript-dialoog: die is niet vorm te geven en ziet er
in elke browser anders uit. *@
<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 type="button" class="knop @(Gevaarlijk ? "gevaar" : "")" disabled="@Bezig" @onclick="Bevestigen">
@Bevestigtekst
</button>
@if (!AlleenSluiten)
{
<button type="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.
/// Staat net zo in de app-versie; die twee horen gelijk te blijven.
/// </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; }
private Task Bevestigen() => OpBevestigen.InvokeAsync();
private Task Sluiten() => OpSluiten.InvokeAsync();
}
@@ -0,0 +1,146 @@
@* De privacyverklaring zelf. Staat hier als onderdeel en niet in een pagina, omdat de app hem
ook laat zien - daar in een popup vanaf het overzicht, hier als pagina op /privacy.
Dit bestand staat twee keer op schijf: de app en de site delen geen project. Een zelfcontrole
vergelijkt ze letterlijk, want twee versies van je privacyverklaring is erger dan twee versies
van een knop: het is het document waar iemand zich op beroept.
Verandert er iets aan wat er wordt opgeslagen, wie het kan zien of wat er naar buiten gaat, dan
verandert deze tekst mee - en dan hoort de datum hieronder mee te veranderen. *@
<p class="zacht">Laatst bijgewerkt op 19 september 2026.</p>
<p>
MijnBesluit is een digitale kluis. Je legt er vast wat er geregeld moet worden, en je zet
berichten klaar voor de mensen om je heen. Dat zijn je meest persoonlijke gegevens, en
daarom staat hier zo precies mogelijk wat ermee gebeurt.
</p>
<h2>Wie is verantwoordelijk</h2>
<p>
MijnBesluit is een dienst van <strong>Kwik-Bit Automatisering B.V.</strong><br />
Boulevard 1945 nr. 216, 7511 AG Enschede<br />
KvK-nummer 97571792
</p>
<p>
Vragen over je gegevens kun je stellen via
<a href="mailto:privacy@@mijnbesluit.nl">privacy@@mijnbesluit.nl</a>.
</p>
<h2>Wat we vastleggen</h2>
<ul>
<li><strong>Je e-mailadres.</strong> Daarmee log je in; we sturen er een code naartoe.</li>
<li><strong>Je naam en geboortedatum</strong>, als je die invult.</li>
<li><strong>Wat je in je kluis zet</strong>: titels, notities, wachtwoorden, polissen,
wensen, en de bestanden die je toevoegt.</li>
<li><strong>De mensen die je toevoegt</strong>: hun naam, en het e-mailadres dat je
invult om ze uit te nodigen.</li>
<li><strong>De berichten</strong> die je voor iemand klaarzet, en wanneer ze bezorgd zijn.</li>
<li><strong>Een logboek</strong> van handelingen: wie wanneer inlogde, wat er werd
gewijzigd, wanneer een bericht is bezorgd. Daar staat geen inhoud in.</li>
</ul>
<h2>Wat er versleuteld is</h2>
<p>
Alles wat in je kluis staat en alles wat in je berichten staat, is versleuteld met een
sleutel die bij jouw account hoort. Ook de bestanden die je toevoegt: die staan
versleuteld op schijf. Wie de database in handen krijgt zonder de sleutel, ziet er niets
van.
</p>
<p>
<strong>Eén uitzondering, en die noemen we liever zelf:</strong> de kleine versie van een
profielfoto - het rondje van veertig pixels in een lijst - is niet versleuteld. Dat is een
afweging geweest: een profielfoto is juist bedoeld om aan anderen te laten zien, en hem
als kluismateriaal behandelen zou betekenen dat we voor elke kijker sleutels moeten
uitdelen en weer intrekken. De foto op ware grootte is wél versleuteld.
</p>
<p>
Het verkeer tussen de app en onze server gaat over een versleutelde verbinding, net als
de website.
</p>
<h2>Wie kan wat zien</h2>
<ul>
<li>
<strong>Jij</strong> ziet je eigen kluis. Niemand anders, ook wij niet: wij kunnen je
kluisitems niet lezen zonder jouw account.
</li>
<li>
<strong>Mensen met wie je in een familie zit</strong> zien elkaars naam en
profielfoto. Niet elkaars kluis.
</li>
<li>
<strong>Iemand die je hebt aangewezen als nalatenschapsbeheerder</strong> krijgt
toegang tot wat je daarvoor hebt bestemd, en pas nadat je overlijden is bevestigd
volgens de procedure die in de app staat.
</li>
<li>
<strong>Iemand met wie je een kluisitem alvast "bespreekt".</strong> Dat kies je zelf,
per item: die persoon ziet het dan meteen, inclusief een eventuele foto of bijlage,
zonder dat daar een overlijden voor nodig is. Zonder die keuze ziet niemand het.
</li>
<li>
<strong>Een beheerder van MijnBesluit</strong> kan een profiel opzoeken op volledig
e-mailadres en een ten onrechte bevestigd overlijden terugdraaien. Hij kan géén kluis
openen. Elke handeling van een beheerder komt in het logboek.
</li>
</ul>
<h2>Wat er naar buiten gaat</h2>
<p>
Wij verkopen geen gegevens en er staan geen meetscripts of volgcookies op deze site.
Er zijn twee partijen die noodzakelijkerwijs iets te zien krijgen:
</p>
<ul>
<li>
<strong>Google (Firebase Cloud Messaging)</strong> bezorgt de meldingen op je
telefoon. Daarvoor gaat een kenmerk van je toestel naar Google, met de titel en de
korte tekst van de melding. De inhoud van een bericht of een kluisitem gaat daar
nooit in mee.
</li>
<li>
<strong>De verzender van onze e-mail</strong>, voor de inlogcodes en uitnodigingen.
</li>
</ul>
<p>
Adverteerders krijgen niets over jou te zien. Er wordt per advertentie geteld hoe vaak
hij is getoond en aangeklikt, per uur - meer niet. Geen profielen, geen kenmerken, geen
bezoeker die door de applicatie heen te volgen is.
</p>
<h2>Hoe lang we het bewaren</h2>
<p>
Zolang je account bestaat. Hef je het op, dan wordt het verwijderd - zie hieronder. Het
logboek blijft staan, omdat je achteraf moet kunnen nagaan wat er is gebeurd; daar staat
geen inhoud in. Van gegevens die bij onze administratie horen, zoals facturen voor
adverteerders, houden we de wettelijke bewaartermijn aan.
</p>
<h2>Je account opheffen</h2>
<p>
Dat kan zelf, op elk moment, en het hoeft niet via ons te lopen:
<a href="/account-verwijderen">mijnbesluit.nl/account-verwijderen</a>. Dat adres werkt ook
als je de app niet meer hebt. In de app staat dezelfde knop bij Mijn profiel.
</p>
<p>
Wat er dan weggaat: je kluis, je bestanden, de berichten die nog klaarstonden, je lijst
met mensen, en je account zelf. Wat blijft: berichten die al bezorgd zijn - die zijn van
degene die ze heeft gekregen - en je naam in de lijst van mensen die jou hebben
toegevoegd. Dat laatste is hun aantekening, niet jouw gegeven; alles wat er aan jou
vastzat, gaat eraf.
</p>
<h2>Je rechten</h2>
<p>
Je mag inzien wat we van je hebben, het laten corrigeren, en het laten verwijderen. Het
meeste doe je zelf in de app of op de website. Lukt iets niet, mail dan
<a href="mailto:privacy@@mijnbesluit.nl">privacy@@mijnbesluit.nl</a>. Ben je het oneens
met hoe wij met je gegevens omgaan, dan kun je een klacht indienen bij de Autoriteit
Persoonsgegevens.
</p>
<h2>Als er iets misgaat</h2>
<p>
Bij een datalek melden wij dat bij de Autoriteit Persoonsgegevens, en bij jou als het
jouw gegevens raakt.
</p>
@@ -0,0 +1,95 @@
@* De uitleg over MijnBesluit, zonder kop en zonder knoppen eromheen.
Dit staat als los onderdeel zodat de website en de app dezelfde tekst tonen: op de website als
pagina met knoppen om aan te melden, in de app als popup vanaf het overzicht. De app en de site
delen geen project, dus dit bestand staat twee keer op schijf; een zelfcontrole vergelijkt ze
letterlijk, zodat ze niet uit elkaar gaan lopen.
Kop en knoppen staan er bewust niet in: op de website hoort er "Een account aanmaken" onder, en
in de app ben je al aangemeld. *@
<p>
Er zit van alles in je hoofd waar niemand anders bij kan. Het wachtwoord van je e-mail,
waar de polissen liggen, hoe je je uitvaart voor je ziet, en de dingen die je nog tegen
iemand had willen zeggen. Zolang alles goed gaat maakt dat niet uit. Gaat het niet goed,
dan staan de mensen die van je houden met lege handen &mdash; op het slechtst denkbare moment.
</p>
<p>
MijnBesluit is de plek waar dat wél ergens staat. Veilig opgeborgen zolang het van jou
alleen is, en beschikbaar op het moment dat jij hebt bepaald.
</p>
<h2>Vier dingen</h2>
<ul style="padding-left:1.2rem;margin:0 0 1.5rem">
<li style="margin-bottom:.7rem">
<strong>Je kluis.</strong> Wachtwoorden voor websites en apparaten, de pincode van
je telefoon, je sociale media, polissen, documenten, je uitvaartwensen. Alles gaat
versleuteld de opslag in. Per onderdeel kies je zelf of het alleen voor jou is,
pas na je overlijden vrijkomt, of op een datum die jij bepaalt. Je kunt een item ook
meteen &mdash; nog gewoon terwijl je er bent &mdash; met je nalatenschapsbeheerder
bespreken; die ziet het dan al, inclusief een eventuele foto of bijlage, zonder dat
daar een overlijden voor nodig is.
</li>
<li style="margin-bottom:.7rem">
<strong>Je mensen.</strong> De familie en vrienden die erbij horen. Je maakt hun
profiel aan en nodigt ze uit. Tot ze die uitnodiging aannemen beheer jij dat
profiel; daarna is het van hen en vullen ze hun eigen MijnBesluit in. Zo groeit
het vanzelf verder.
</li>
<li style="margin-bottom:.7rem">
<strong>Momenten.</strong> Een bericht of een video die je klaarzet voor iemand,
te bezorgen op een datum die jij kiest &mdash; of pas als jij er niet meer bent.
Een achttiende verjaardag, een huwelijk, of gewoon iets wat je nog wilde zeggen.
En je kunt zelf ook berichten ontvangen die een ander voor jou heeft klaargezet.
</li>
<li>
<strong>Je nalatenschap.</strong> Wie er straks mag regelen wat er geregeld moet
worden. Die persoon krijgt niet zomaar toegang: er moeten meerdere mensen
onafhankelijk melden dat je bent overleden &mdash; waaronder iemand die jij zelf hebt
aangewezen &mdash; en daarna heb jij nog een termijn om te laten weten dat je er gewoon
nog bent. Pas als die voorbij is, gaat het open.
</li>
</ul>
<h2>Hoe veilig is het?</h2>
<p>
Je legt hier je wachtwoorden en je laatste wensen vast. Dan moet je weten wat daarmee
gebeurt.
</p>
<ul style="padding-left:1.2rem">
<li style="margin-bottom:.5rem">
<strong>Alles gaat versleuteld de opslag in.</strong> Elk onderdeel krijgt zijn eigen
sleutel, en die sleutel wordt zelf ook weer versleuteld. Wie de database in handen
zou krijgen, heeft daar niets aan: er staat geen leesbare regel in.
</li>
<li style="margin-bottom:.5rem">
<strong>Bijlagen net zo.</strong> Een foto van je polis of een scan van je testament
wordt versleuteld opgeslagen en pas ontsleuteld op het moment dat jij hem opent.
</li>
<li style="margin-bottom:.5rem">
<strong>Er is geen wachtwoord.</strong> Je vult je e-mailadres in en krijgt een code
die tien minuten geldig is en één keer werkt. Geen wachtwoord betekent: niets om te
stelen, te raden of te hergebruiken. De code zelf staat ook versleuteld opgeslagen,
dus zelfs wij kunnen hem niet teruglezen.
</li>
<li style="margin-bottom:.5rem">
<strong>Niemand komt bij je kluis, ook wij niet.</strong> Wat je op "alleen voor
mijzelf" zet, blijft dat. Je nalatenschapsbeheerder krijgt alleen wat jij hebt
vrijgegeven, en pas op het moment dat jij hebt bepaald.
</li>
<li>
<strong>Er wordt bijgehouden wie wat doet.</strong> Elke keer dat er iets wordt
geopend, gewijzigd of vrijgegeven, wordt dat vastgelegd. Zo is achteraf na te gaan
wat er is gebeurd.
</li>
</ul>
<p>
Volledige zekerheid bestaat niet, en dat beweren we ook niet. Wat we wel doen is elke
keuze zo maken dat een fout of een inbraak zo min mogelijk oplevert.
</p>
<h2>Wat kost het?</h2>
<p style="margin-bottom:0">
Een eigen MijnBesluit is gratis. Wat je vastlegt blijft van jou, en je kunt alles wat je
hebt ingevoerd altijd zelf wijzigen of verwijderen.
</p>
@@ -0,0 +1,52 @@
@* De versiehistorie: nieuwe of wezenlijk gewijzigde functies, nieuwste bovenaan.
Zelfde opzet als Productuitleg.razor en Privacytekst.razor: op de website een popup vanaf
het overzicht, in de app hetzelfde. Dit bestand staat twee keer op schijf - de app en de site
delen geen project - en een zelfcontrole vergelijkt ze letterlijk.
Wat hier NIET in hoort: bugfixes, tekstuele correcties, opmaak die is rechtgezet. Dit is voor
"wat kan ik nu wat ik eerst niet kon", niet een logboek van elke wijziging in de code. *@
<p class="zacht" style="margin-top:0">
Nieuwe of wezenlijk gewijzigde functies, nieuwste bovenaan.
</p>
<h2 style="margin-top:0">20 september 2026</h2>
<ul style="padding-left:1.2rem">
<li style="margin-bottom:.5rem">
<strong>Rijbewijs en paspoort in de kluis.</strong> Twee nieuwe categorieën, met een
knop om meteen de voor- en achterkant (rijbewijs) of de foto-pagina (paspoort) te maken
of te kiezen.
</li>
<li style="margin-bottom:.5rem">
<strong>Mijn profiel als pop-up.</strong> Je eigen profiel bewerk je nu in dezelfde
pop-up met vaste "Opslaan"-knop onderin als bij de andere mensen in je familie, in de
app en op de website.
</li>
<li style="margin-bottom:.5rem">
<strong>Duidelijker voor de nalatenschapsbeheerder.</strong> Bij "Aan mij toevertrouwd"
zie je nu meteen hoeveel meldingen er nodig zijn en hoe lang de bezwaartermijn is.
</li>
<li>
<strong>Muziek en verdere wensen groeien mee.</strong> Die velden bij een kluisitem
passen zich nu vanzelf aan de lengte van de tekst aan, ook op de website.
</li>
</ul>
<h2>19 september 2026</h2>
<ul style="padding-left:1.2rem">
<li style="margin-bottom:.5rem">
<strong>Met mij gedeeld, ook in de app.</strong> Heeft iemand een kluisitem rechtstreeks
met jou gedeeld om het vast te bespreken - bijvoorbeeld een uitvaartwens - dan zie je
dat voortaan ook op je telefoon, niet meer alleen op de website.
</li>
<li style="margin-bottom:.5rem">
<strong>Een gedeeld of nagelaten kluisitem bekijken.</strong> Dat opent nu als pop-up in
dezelfde opmaak als het invulformulier, inclusief de foto's en documenten die erbij
horen.
</li>
<li>
<strong>Met de vinger naar beneden trekken ververst het scherm.</strong> Werkt op Kluis,
Mijn mensen, Momenten en Nalatenschap.
</li>
</ul>
@@ -0,0 +1,238 @@
@page "/account-verwijderen"
@using System.ComponentModel.DataAnnotations
@using Microsoft.AspNetCore.Authentication
@using Microsoft.AspNetCore.Authentication.Cookies
@inject AccountService Accounts
@inject AccountOpheffenService Opheffen
@inject IHttpContextAccessor HttpContexten
@* Je account opheffen. Deze pagina doet twee dingen tegelijk, en dat is met opzet:
1. Het is het pad vanuit je eigen profiel, voor wie is ingelogd.
2. Het is het openbare adres waar iemand die de app niet meer heeft alsnog kan laten
verwijderen. Google Play eist dat apart, en terecht: een verwijderknop die alleen in de app
zit, helpt niet als je de app al hebt weggegooid.
Statisch weergegeven, want er wordt een cookie gewist. Zie ook de inlogpagina: alle
formulieren worden altijd gerenderd en alleen verborgen. *@
<PageTitle>Account opheffen - MijnBesluit</PageTitle>
<div class="middenblok">
<h1>Je MijnBesluit opheffen</h1>
@if (Klaar)
{
<div class="melding goed">
Je account is opgeheven. Je kluis, je bestanden en de berichten die nog klaarstonden
zijn verwijderd.
</div>
<p>
Wat al bezorgd was, blijft bij degene die het heeft gekregen. Je kunt op elk moment
opnieuw beginnen met hetzelfde e-mailadres.
</p>
<p class="zacht"><a href="/">Terug naar MijnBesluit</a></p>
}
else
{
@if (Fout is not null) { <div class="melding fout">@Fout</div> }
@if (Gelukt is not null) { <div class="melding goed">@Gelukt</div> }
<div hidden="@ToonCodeveld">
<div class="kaart">
<p style="margin-top:0"><strong>Dit verdwijnt:</strong></p>
@if (Telling is not null)
{
<ul>
<li>@Telling.Kluisitems in je kluis</li>
<li>@Telling.Bestanden foto&#39;s en documenten</li>
<li>@Telling.BerichtenKlaar berichten die nog klaarstonden</li>
<li>je lijst met @Telling.Mensen mensen</li>
</ul>
}
else
{
<ul>
<li>alles wat in je kluis staat</li>
<li>je foto&#39;s en documenten</li>
<li>de berichten die nog klaarstonden</li>
<li>je lijst met mensen</li>
</ul>
}
<p><strong>Dit blijft:</strong></p>
<ul>
<li>
berichten die al bezorgd zijn. Die zijn van degene die ze heeft gekregen,
en die blijft ze ook kunnen lezen.
</li>
<li>
je naam in de lijst van mensen die jou hebben toegevoegd. Dat is hun
aantekening, niet jouw gegeven. Alles wat er aan jou vastzat - je
e-mailadres, je geboortedatum, je foto - gaat eraf.
</li>
</ul>
@if (Telling is { Nalatenschappen: > 0 })
{
<div class="melding let-op">
Je bent aangewezen als nalatenschapsbeheerder bij @Telling.Nalatenschappen
@(Telling.Nalatenschappen == 1 ? "persoon" : "mensen"). Die aanwijzing
vervalt. Laat het ze even weten, anders rekenen ze op iemand die er niet
meer is.
</div>
}
<p class="zacht">Er is geen prullenbak. Wij kunnen dit niet terughalen.</p>
</div>
<EditForm Model="Aanvraag" FormName="opheffen-code" OnValidSubmit="CodeAanvragenAsync" method="post">
<DataAnnotationsValidator />
<div class="kaart">
<div class="veld">
<label for="email">E-mailadres van je account</label>
<InputText id="email" @bind-Value="Aanvraag.Email" type="email"
readonly="@Ingelogd" autocomplete="username" inputmode="email" />
<ValidationMessage For="() => Aanvraag.Email" class="uitleg" />
<div class="uitleg">
We sturen er een code naartoe. Pas als je die invult gebeurt er iets,
dus niemand kan jouw account opheffen zonder bij je e-mail te kunnen.
</div>
</div>
<button type="submit" class="knop vol">Stuur me een code</button>
</div>
</EditForm>
</div>
<div hidden="@(!ToonCodeveld)">
<EditForm Model="Bevestig" FormName="opheffen-bevestigen" OnValidSubmit="VerwijderenAsync" method="post">
<DataAnnotationsValidator />
<div class="kaart">
<div class="veld">
<label for="email2">E-mailadres</label>
<InputText id="email2" @bind-Value="Bevestig.Email" type="email" readonly />
</div>
<div class="veld">
<label for="code">De code uit die e-mail</label>
<InputText id="code" @bind-Value="Bevestig.Code" class="codeveld" inputmode="numeric"
autocomplete="one-time-code" maxlength="6" />
<ValidationMessage For="() => Bevestig.Code" class="uitleg" />
</div>
<div class="melding let-op">
Hierna is het weg, en dat is niet terug te draaien.
</div>
<button type="submit" class="knop gevaar">Mijn account definitief opheffen</button>
</div>
</EditForm>
</div>
<p class="zacht"><a href="/">Toch maar niet</a></p>
}
</div>
@code {
[SupplyParameterFromForm(FormName = "opheffen-code")] private AanvraagGegevens Aanvraag { get; set; } = new();
[SupplyParameterFromForm(FormName = "opheffen-bevestigen")] private BevestigGegevens Bevestig { get; set; } = new();
[CascadingParameter] private Task<AuthenticationState>? AuthState { get; set; }
private bool Ingelogd;
private AccountOpheffenService.Telling? Telling;
private bool _codeVerstuurd;
private bool Klaar;
private string? Fout, Gelukt;
private bool ToonCodeveld => _codeVerstuurd || !string.IsNullOrWhiteSpace(Bevestig.Email);
protected override async Task OnInitializedAsync()
{
if (AuthState is null) return;
var gebruiker = (await AuthState).User;
if (gebruiker.UserId() is not { } gebruikerId) return;
Ingelogd = true;
// Voor wie is ingelogd vullen we het adres in en laten we zien wát er weggaat. Wie hier
// binnenkomt zonder in te loggen, krijgt hetzelfde verhaal maar zonder aantallen: die
// zouden we alleen kunnen tonen door op een ingetypt adres te zoeken, en dan vertelt
// deze pagina aan een vreemde hoeveel er in andermans kluis zit.
if (string.IsNullOrWhiteSpace(Aanvraag.Email))
Aanvraag.Email = gebruiker.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value ?? "";
var telling = await Opheffen.TelOpAsync(gebruikerId);
if (telling.Succeeded) Telling = telling.Value;
}
private async Task CodeAanvragenAsync()
{
var context = HttpContexten.HttpContext!;
var resultaat = await Accounts.VraagOpheffencodeAanAsync(
Aanvraag.Email, context.Connection.RemoteIpAddress?.ToString(), context.RequestAborted);
if (!resultaat.Succeeded)
{
Fout = resultaat.Error;
Gelukt = null;
return;
}
_codeVerstuurd = true;
Bevestig = new BevestigGegevens { Email = resultaat.Value!.Email };
Gelukt = "We hebben je een code gestuurd. Vul hem hieronder in.";
Fout = null;
}
private async Task VerwijderenAsync()
{
var context = HttpContexten.HttpContext!;
var controle = await Accounts.ControleerOpheffencodeAsync(
Bevestig.Email, Bevestig.Code, context.RequestAborted);
if (!controle.Succeeded)
{
_codeVerstuurd = true;
Fout = controle.Error;
Gelukt = null;
return;
}
var weg = await Opheffen.VerwijderAsync(controle.Value!.Id, context.RequestAborted);
if (!weg.Succeeded)
{
_codeVerstuurd = true;
Fout = weg.Error;
return;
}
// Het cookie eraf. Zonder dit loopt de volgende pagina vast op een account dat niet meer
// bestaat - en dat ziet eruit als een storing in plaats van als iets dat je zelf hebt
// gedaan.
await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
Klaar = true;
Fout = null;
Gelukt = null;
}
public class AanvraagGegevens
{
[Required(ErrorMessage = "Vul het e-mailadres van je account in.")]
[EmailAddress(ErrorMessage = "Dit is geen geldig e-mailadres!")]
public string Email { get; set; } = "";
}
public class BevestigGegevens
{
[Required] public string Email { get; set; } = "";
[Required(ErrorMessage = "Je moet de code invullen.")]
public string Code { get; set; } = "";
}
}
@@ -0,0 +1,197 @@
@page "/adverteren/campagnes"
@using MijnBesluit.Domain.Entities
@using MijnBesluit.Domain.Enums
@inject AdvertentieService Adverteren
@inject IHttpContextAccessor HttpContexten
@inject NavigationManager Navigatie
@* Het eigen scherm van een adverteerder: zijn campagnes, de stand ervan, en wat er werkelijk is
getoond.
Bewust statisch weergegeven en niet interactief. Wie hier is ingelogd, zit in een eigen cookie
met een eigen schema (zie Adverteerderslogin) - en dat cookie lees je bij elk verzoek uit
HttpContext. Bij een interactief scherm bestaat die HttpContext na de eerste render niet meer
en zou het nummer van de adverteerder ergens moeten blijven staan. Dat is precies het soort
ding dat je een keer verkeerd doet. *@
<PageTitle>Mijn advertenties - MijnBesluit</PageTitle>
<div>
@if (Ik is null)
{
<h1>Adverteren</h1>
<p>Je bent niet (meer) ingelogd. <a href="/adverteren">Log opnieuw in</a>.</p>
}
else
{
<div class="rij">
<h1 class="groei">@Ik.Bedrijfsnaam</h1>
<form method="post" action="/adverteren/uitloggen" style="margin:0">
<AntiforgeryToken />
<button type="submit" class="knop rustig">Uitloggen</button>
</form>
</div>
@if (Melding is not null) { <div class="melding goed">@Melding</div> }
@if (Ik.IsGeblokkeerd)
{
<div class="melding fout">
Er kunnen met dit account geen nieuwe advertenties worden ingediend. Neem contact
met ons op als je denkt dat dat niet klopt.
</div>
}
else
{
<p><a class="knop vol" href="/adverteren/nieuw">Nieuwe advertentie</a></p>
}
@if (Campagnes.Count == 0)
{
<div class="kaart">
<p>Je hebt nog geen advertenties. Met de knop hierboven stel je er een samen.</p>
</div>
}
@foreach (var campagne in Campagnes)
{
var totaal = Totalen.TryGetValue(campagne.Id, out var t) ? t : (0, 0);
var link = Adverteren.Betaallink(campagne);
<div class="kaart">
<div class="rij">
@if (campagne.HeeftAfbeelding)
{
<img src="/advertentie/@campagne.Id/afbeelding" alt="" class="advertentiebeeld" />
}
<div class="groei">
<span class="groot">@campagne.Tekst</span>
<div class="zacht">@campagne.Link</div>
</div>
<span class="label @Kleur(campagne.Stand)">@Standtekst(campagne)</span>
</div>
<div class="zacht" style="margin-top:.6rem">
@campagne.VanafDatum.ToString("dd-MM-yyyy") tot en met
@campagne.TotEnMetDatum.ToString("dd-MM-yyyy") &middot;
@campagne.PerUur keer per uur &middot; @Euro(campagne.BedragCenten)
</div>
@if (campagne.Stand == CampagneStand.WachtOpBetaling)
{
<div class="melding let-op" style="margin-top:.8rem">
<p style="margin:0 0 .6rem">
Te betalen: <strong>@Euro(campagne.BedragCenten)</strong>, onder vermelding van
<strong>@campagne.Betaalkenmerk</strong>.
</p>
@if (link is not null)
{
@* Een eigen tekst op de knop en niet het adres zelf: hier hoort te staan
wát je doet en voor hoeveel, niet hoe de link is opgebouwd. *@
<a class="knop vol" href="@link" target="_blank" rel="noopener noreferrer">
Betaal @Euro(campagne.BedragCenten) via bunq
</a>
<div class="uitleg">
Je betaalt bij bunq; die betaling komt niet vanzelf bij ons binnen.
We vinken hem met de hand af, meestal dezelfde dag nog.
</div>
}
else
{
<div class="uitleg">Neem contact met ons op voor de betaalgegevens.</div>
}
</div>
}
@if (campagne.Stand == CampagneStand.Afgewezen && campagne.AfwijzingReden is { Length: > 0 } reden)
{
<div class="melding fout" style="margin-top:.8rem">Reden: @reden</div>
}
@if (campagne.Stand is CampagneStand.Loopt or CampagneStand.Gestopt)
{
<div class="rij" style="margin-top:.8rem">
<div class="kaart" style="margin:0;flex:1">
<span class="groot">@totaal.Item1</span>
<div class="zacht">keer getoond</div>
</div>
<div class="kaart" style="margin:0;flex:1">
<span class="groot">@totaal.Item2</span>
<div class="zacht">keer aangeklikt</div>
</div>
</div>
<div class="uitleg">
Het geboekte aantal per uur is een maximum. Zijn er in een uur minder
bezoekers dan je hebt geboekt, dan wordt de rest niet ingehaald.
</div>
}
</div>
}
<p class="zacht"><a href="/adverteren">Uitleg over adverteren</a></p>
}
</div>
@code {
[SupplyParameterFromQuery(Name = "nieuw")] private long? Nieuw { get; set; }
private Adverteerder? Ik;
private IReadOnlyList<Campagne> Campagnes = Array.Empty<Campagne>();
private readonly Dictionary<long, (int, int)> Totalen = new();
private string? Melding;
protected override async Task OnInitializedAsync()
{
var context = HttpContexten.HttpContext;
if (context is null) return;
// Het nummer komt uit het cookie van het adverteerdersschema, bij elk verzoek opnieuw.
var id = await Adverteerderslogin.AdverteerderIdAsync(context);
if (id is null) return;
Ik = await Adverteren.AdverteerderAsync(id.Value, context.RequestAborted);
if (Ik is null) return;
Campagnes = await Adverteren.CampagnesAsync(Ik.Id, context.RequestAborted);
foreach (var campagne in Campagnes)
Totalen[campagne.Id] = await Adverteren.TotalenAsync(campagne.Id, context.RequestAborted);
if (Nieuw is not null && Campagnes.Any(c => c.Id == Nieuw))
Melding = "Je advertentie is ingediend. Hieronder staat wat er betaald moet worden.";
}
// Eén plek die weet hoe een bedrag eruitziet; zie AdvertentieService.Euro.
private static string Euro(int centen) => AdvertentieService.Euro(centen);
/// <summary>
/// Wat er in het labeltje staat. Een goedgekeurde campagne waarvan de einddatum voorbij is
/// heet hier "afgelopen" en niet "loopt": dat is geen aparte stand in de database - hij
/// verschijnt vanzelf niet meer - maar wel wat de adverteerder ziet als hij kijkt.
/// </summary>
private static string Standtekst(Campagne campagne)
{
if (campagne.Stand == CampagneStand.Loopt &&
campagne.TotEnMetDatum < DateOnly.FromDateTime(DateTime.Today))
return "afgelopen";
return campagne.Stand switch
{
CampagneStand.Concept => "concept",
CampagneStand.WachtOpBetaling => "wacht op betaling",
CampagneStand.WachtOpGoedkeuring => "wacht op goedkeuring",
CampagneStand.Loopt => "loopt",
CampagneStand.Afgewezen => "afgewezen",
CampagneStand.Gestopt => "gestopt",
_ => ""
};
}
private static string Kleur(CampagneStand stand) => stand switch
{
CampagneStand.Loopt => "goed",
CampagneStand.Afgewezen => "fout",
CampagneStand.Gestopt => "fout",
_ => ""
};
}
@@ -0,0 +1,184 @@
@page "/adverteren/nieuw"
@using System.ComponentModel.DataAnnotations
@using MijnBesluit.Domain.Entities
@inject AdvertentieService Adverteren
@inject IHttpContextAccessor HttpContexten
@inject NavigationManager Navigatie
@* Een advertentie samenstellen. Statisch weergegeven, net als het overzicht ernaast: het nummer
van de adverteerder komt bij elk verzoek uit zijn eigen cookie.
De prijs wordt twee keer uitgerekend. Eén keer hieronder in de pagina zelf, zodat je ziet wat
het kost terwijl je de datums verzet - dat is een gemak, verder niets. En één keer op de
server bij het indienen; dát is het bedrag dat geldt. Wie met de pagina knoeit, verandert
alleen wat hij zelf ziet staan. *@
<PageTitle>Nieuwe advertentie - MijnBesluit</PageTitle>
<div>
<h1>Nieuwe advertentie</h1>
@if (Fout is not null) { <div class="melding fout">@Fout</div> }
<EditForm Model="Boeking" FormName="advertentie" OnValidSubmit="IndienenAsync" method="post"
enctype="multipart/form-data">
<DataAnnotationsValidator />
<div class="kaart">
<div class="veld">
<label for="tekst">De tekst</label>
<InputTextArea id="tekst" @bind-Value="Boeking.Tekst" rows="3" maxlength="300" />
<div class="uitleg">
Eén of twee regels. Hoogstens 300 tekens, en wat er staat moet kloppen: we
lezen hem voordat hij geplaatst wordt.
</div>
<ValidationMessage For="() => Boeking.Tekst" class="uitleg" />
</div>
<div class="veld">
<label for="link">De link</label>
<InputText id="link" @bind-Value="Boeking.Link" type="url" placeholder="https://" />
<div class="uitleg">Waar iemand terechtkomt die op de balk klikt.</div>
<ValidationMessage For="() => Boeking.Link" class="uitleg" />
</div>
<div class="veld">
<label for="afbeelding">Logo of afbeelding</label>
@* Een gewoon bestandsveld en geen InputFile: die hoort bij een interactief
scherm. Dit formulier is een gewone POST, en de server pakt het bestand uit
Request.Form.Files. *@
<input id="afbeelding" name="afbeelding" type="file" accept="image/png,image/jpeg" />
<div class="uitleg">
Niet verplicht. We verkleinen hem zelf; in beeld is hij ongeveer 48 bij 48.
</div>
</div>
</div>
<div class="kaart">
<div class="raster twee">
@* oninput en niet @onchange: dit is een statische pagina, dus er is geen
Blazor die meekijkt. Het zijn gewone HTML-kenmerken, en die overleven ook een
pagina die via een link binnenkomt. De functie staat in adverteren.js. *@
<div class="veld">
<label for="vanaf">Vanaf</label>
<InputDate id="vanaf" @bind-Value="Boeking.Vanaf" oninput="mbPrijs()" onchange="mbPrijs()" />
</div>
<div class="veld">
<label for="tot">Tot en met</label>
<InputDate id="tot" @bind-Value="Boeking.TotEnMet" oninput="mbPrijs()" onchange="mbPrijs()" />
</div>
</div>
<div class="veld">
<label for="peruur">Hoe vaak per uur</label>
<InputNumber id="peruur" @bind-Value="Boeking.PerUur" min="1" max="@MaxPerUur"
oninput="mbPrijs()" onchange="mbPrijs()" />
<div class="uitleg">
Hoogstens @MaxPerUur keer per uur. Dit is een maximum en geen belofte: zijn er
in een uur minder bezoekers, dan wordt de rest niet ingehaald.
</div>
<ValidationMessage For="() => Boeking.PerUur" class="uitleg" />
</div>
@* De tarieven staan als kenmerk op dit vak, zodat adverteren.js ze kan lezen zonder
dat er bedragen in dat bestand staan. Wat daar wordt uitgerekend is een gemak, geen
bedrag: bij het indienen rekent de server het zelf uit. *@
<div class="melding" id="prijsvak"
data-tarief="@Adverteren.Tarieven.TariefCenten"
data-bodem="@Adverteren.Tarieven.MinimumCenten"
data-max="@MaxPerUur">
Dit kost <strong id="prijs">@Euro(Bedrag)</strong>
<span class="zacht" id="prijssom">(@Dagen dagen &times; @Boeking.PerUur per uur &times; @Adverteren.Tarieven.TariefCenten cent,
met een minimum van @Euro(Adverteren.Tarieven.MinimumCenten))</span>
</div>
<button type="submit" class="knop vol">Indienen</button>
<div class="uitleg">
Na het indienen krijg je een betaalverzoek. De advertentie gaat pas draaien nadat
de betaling binnen is en we naar de inhoud hebben gekeken.
</div>
</div>
</EditForm>
<p class="zacht"><a href="/adverteren/campagnes">Terug naar mijn advertenties</a></p>
</div>
@code {
[SupplyParameterFromForm(FormName = "advertentie")] private Boekinggegevens Boeking { get; set; } = new();
private Adverteerder? Ik;
private string? Fout;
private int MaxPerUur => Adverteren.Tarieven.MaxPerUur;
private int Dagen => Math.Max(Boeking.TotEnMet.DayNumber - Boeking.Vanaf.DayNumber + 1, 0);
private int Bedrag => Adverteren.BerekenBedrag(Boeking.Vanaf, Boeking.TotEnMet, Boeking.PerUur);
protected override async Task OnInitializedAsync()
{
var context = HttpContexten.HttpContext;
if (context is null) return;
if (await Adverteerderslogin.AdverteerderIdAsync(context) is not { } id)
{
Navigatie.NavigateTo("/adverteren", forceLoad: true);
return;
}
Ik = await Adverteren.AdverteerderAsync(id, context.RequestAborted);
}
private async Task IndienenAsync()
{
var context = HttpContexten.HttpContext!;
if (Ik is null) { Navigatie.NavigateTo("/adverteren", forceLoad: true); return; }
byte[]? afbeelding = null;
string? soort = null;
var bestand = context.Request.Form.Files["afbeelding"];
if (bestand is { Length: > 0 })
{
if (bestand.Length > Adverteren.Tarieven.MaxAfbeeldingBytes)
{
Fout = "De afbeelding is te groot.";
return;
}
using var geheugen = new MemoryStream();
await bestand.CopyToAsync(geheugen, context.RequestAborted);
afbeelding = geheugen.ToArray();
soort = bestand.ContentType;
}
var uitkomst = await Adverteren.DienInAsync(
Ik.Id, Boeking.Tekst, Boeking.Link, Boeking.Vanaf, Boeking.TotEnMet, Boeking.PerUur,
afbeelding, soort, context.RequestAborted);
if (!uitkomst.Succeeded)
{
Fout = uitkomst.Error;
return;
}
Navigatie.NavigateTo($"/adverteren/campagnes?nieuw={uitkomst.Value!.Id}", forceLoad: true);
}
// Eén plek die weet hoe een bedrag eruitziet; zie AdvertentieService.Euro.
private static string Euro(int centen) => AdvertentieService.Euro(centen);
public class Boekinggegevens
{
[Required(ErrorMessage = "Vul de tekst van de advertentie in.")]
[StringLength(300, ErrorMessage = "De tekst mag hoogstens 300 tekens lang zijn.")]
public string Tekst { get; set; } = "";
[Required(ErrorMessage = "Vul de link in.")]
public string Link { get; set; } = "";
public DateOnly Vanaf { get; set; } = DateOnly.FromDateTime(DateTime.Today).AddDays(1);
public DateOnly TotEnMet { get; set; } = DateOnly.FromDateTime(DateTime.Today).AddDays(30);
[Range(1, 100, ErrorMessage = "Vul in hoe vaak de advertentie per uur mag worden getoond.")]
public int PerUur { get; set; } = 5;
}
}
@@ -0,0 +1,262 @@
@page "/adverteren"
@using System.ComponentModel.DataAnnotations
@inject AccountService Accounts
@inject AdvertentieService Advertenties
@inject IHttpContextAccessor HttpContexten
@inject NavigationManager Navigatie
@* De voordeur van het adverteerdersgedeelte: wat het is, wat het kost, en inloggen.
Zelfde opzet als de inlogpagina: de pagina wordt statisch weergegeven, want alleen zo kan er
een cookie worden gezet, en alle formulieren worden ALTIJD gerenderd en alleen verborgen met
hidden. Bij een statische pagina is elke POST een nieuwe pagina-instantie; een formulier dat
op dat moment niet gerenderd wordt, bestaat voor de server niet en levert
"Cannot submit the form ... because no form on the page currently has that name" op. *@
<PageTitle>Adverteren op MijnBesluit</PageTitle>
<div>
<h1>Adverteren op MijnBesluit</h1>
<p class="zacht">
Eén balk boven het overzichtsscherm, met je logo, een regel tekst en een link naar je
eigen website. Je boekt zelf een periode en hoe vaak je per uur in beeld komt.
</p>
@if (Fout is not null) { <div class="melding fout">@Fout</div> }
@if (Gelukt is not null) { <div class="melding goed">@Gelukt</div> }
<div hidden="@ToonCodeveld">
<div class="kaart">
<h2>Wat het kost</h2>
<p>
Het bedrag is <strong>dagen &times; vertoningen per uur &times; @Centen(Tarief)</strong>,
met een minimum van @Euro(Bodem). Dertig dagen, vijf keer per uur komt dus uit op
@Euro(Voorbeeld).
</p>
<p class="zacht">
Dat aantal per uur is een maximum en geen belofte: zijn er in een uur minder
bezoekers dan je hebt geboekt, dan wordt de rest niet ingehaald. In je eigen
scherm zie je wat er werkelijk is getoond en aangeklikt.
</p>
<details class="uitklap">
<summary>Waar komt de advertentie te staan?</summary>
<p>
Boven de tegels op het overzichtsscherm, op de website en in de app, met het
woord <em>Advertentie</em> erbij. Niet in de kluis, niet bij de berichten aan
familie en nergens rond een overlijdensmelding. Dat is de afspraak met onze
gebruikers, en daar wijken we niet van af.
</p>
</details>
<details class="uitklap">
<summary>Hoe gaat het verder?</summary>
<p>
Je stelt de advertentie samen, betaalt via een betaalverzoek, en wij kijken
naar de inhoud. Pas na goedkeuring gaat hij draaien; je krijgt daar bericht
van. Een advertentie die niet past weigeren we, met opgaaf van reden.
</p>
</details>
</div>
<div class="raster twee">
<EditForm Model="Aanvraag" FormName="adverteerder-code" OnValidSubmit="CodeAanvragenAsync" method="post">
<DataAnnotationsValidator />
<div class="kaart">
<h2>Inloggen</h2>
<div class="veld">
<label for="email">E-mailadres</label>
<InputText id="email" @bind-Value="Aanvraag.Email" type="email"
autocomplete="username" inputmode="email" placeholder="alles in kleine letters" />
<ValidationMessage For="() => Aanvraag.Email" class="uitleg" />
<div class="uitleg">
Je krijgt een code per e-mail. Geen wachtwoord om te onthouden.
</div>
</div>
<button type="submit" class="knop vol">Code aanvragen</button>
</div>
</EditForm>
<EditForm Model="Nieuw" FormName="adverteerder-nieuw" OnValidSubmit="AanmeldenAsync" method="post">
<DataAnnotationsValidator />
<div class="kaart">
<h2>Nog geen account?</h2>
<div class="veld">
<label for="bedrijf">Bedrijfsnaam</label>
<InputText id="bedrijf" @bind-Value="Nieuw.Bedrijfsnaam" autocomplete="organization" />
<ValidationMessage For="() => Nieuw.Bedrijfsnaam" class="uitleg" />
</div>
<div class="veld">
<label for="contact">Contactpersoon</label>
<InputText id="contact" @bind-Value="Nieuw.Contactpersoon" autocomplete="name" />
</div>
<div class="veld">
<label for="email3">E-mailadres</label>
<InputText id="email3" @bind-Value="Nieuw.Email" type="email"
autocomplete="email" inputmode="email" />
<ValidationMessage For="() => Nieuw.Email" class="uitleg" />
</div>
<button type="submit" class="knop">Code aanvragen</button>
</div>
</EditForm>
</div>
</div>
<div hidden="@(!ToonCodeveld)">
<EditForm Model="Inlog" FormName="adverteerder-inloggen" OnValidSubmit="BinnenAsync" method="post">
<DataAnnotationsValidator />
<div class="kaart">
<div class="veld">
<label for="email2">E-mailadres</label>
<InputText id="email2" @bind-Value="Inlog.Email" type="email" readonly />
</div>
<div class="veld">
<label for="code">Code</label>
<InputText id="code" @bind-Value="Inlog.Code" class="codeveld" inputmode="numeric"
autocomplete="one-time-code" maxlength="6" />
<div class="uitleg">
De code is @((int)AccountService.CodeGeldigheid.TotalMinutes) minuten geldig.
Geen e-mail gekregen? Kijk ook in je ongewenste post, of
<a href="/adverteren">vraag een nieuwe aan</a>.
</div>
<ValidationMessage For="() => Inlog.Code" class="uitleg" />
</div>
<button type="submit" class="knop vol">Inloggen</button>
</div>
</EditForm>
</div>
<p class="zacht"><a href="/">Terug naar MijnBesluit</a></p>
</div>
@code {
[SupplyParameterFromForm(FormName = "adverteerder-code")] private AanvraagGegevens Aanvraag { get; set; } = new();
[SupplyParameterFromForm(FormName = "adverteerder-nieuw")] private NieuwGegevens Nieuw { get; set; } = new();
[SupplyParameterFromForm(FormName = "adverteerder-inloggen")] private InlogGegevens Inlog { get; set; } = new();
private bool _codeVerstuurd;
private string? Fout, Gelukt;
private int Tarief => Advertenties.Tarieven.TariefCenten;
private int Bodem => Advertenties.Tarieven.MinimumCenten;
private int Voorbeeld => Advertenties.BerekenBedrag(30, 5);
// Eén plek die weet hoe een bedrag eruitziet; zie AdvertentieService.Euro.
private static string Euro(int centen) => AdvertentieService.Euro(centen);
private static string Centen(int centen) => centen + " cent";
/// <summary>
/// Staat er al een e-mailadres in het codeformulier, dan is die stap eerder bereikt en hoort
/// het codeveld te staan - ook bij de eerste render van een POST, vóórdat de afhandeling
/// heeft gedraaid.
/// </summary>
private bool ToonCodeveld => _codeVerstuurd || !string.IsNullOrWhiteSpace(Inlog.Email);
private Task CodeAanvragenAsync() => StuurCodeAsync(Aanvraag.Email, null, null);
private Task AanmeldenAsync() => StuurCodeAsync(Nieuw.Email, Nieuw.Bedrijfsnaam, Nieuw.Contactpersoon);
private async Task StuurCodeAsync(string email, string? bedrijfsnaam, string? contactpersoon)
{
var context = HttpContexten.HttpContext!;
// De bedrijfsnaam gaat mee met de code en niet in dit scherm: zo kan niemand zich een
// bedrijfsnaam aanmeten zonder eerst bij het e-mailadres te kunnen, en hoeft de pagina
// niets te onthouden tussen twee formulieren door.
var resultaat = await Accounts.VraagAdverteerderscodeAanAsync(
email, bedrijfsnaam, contactpersoon,
context.Connection.RemoteIpAddress?.ToString(), context.RequestAborted);
if (!resultaat.Succeeded)
{
Fout = resultaat.Error;
Gelukt = null;
return;
}
_codeVerstuurd = true;
Inlog = new InlogGegevens { Email = resultaat.Value!.Email };
Gelukt = "We hebben je een code gestuurd. Vul hem hieronder in.";
Fout = null;
}
private async Task BinnenAsync()
{
var context = HttpContexten.HttpContext!;
// Eerst vastpakken: verderop wordt het formulier leeggemaakt, en dan is dit adres weg.
var adres = Inlog.Email;
var controle = await Accounts.ControleerAdverteerderscodeAsync(
adres, Inlog.Code, context.RequestAborted);
if (!controle.Succeeded)
{
_codeVerstuurd = true;
Fout = controle.Error;
Gelukt = null;
return;
}
var adverteerder = await Advertenties.AdverteerderByEmailAsync(adres, context.RequestAborted);
if (adverteerder is null)
{
// De code klopte, maar er is nog geen account en er is ook geen bedrijfsnaam
// meegegeven: dan heeft iemand het inlogformulier gebruikt terwijl hij nieuw is.
if (string.IsNullOrWhiteSpace(controle.Value!.Bedrijfsnaam))
{
Fout = "We kennen dit e-mailadres nog niet. Vul hieronder je bedrijfsnaam in, " +
"dan maken we een account aan.";
Gelukt = null;
Inlog = new InlogGegevens();
Nieuw = new NieuwGegevens { Email = adres };
return;
}
var aangemaakt = await Advertenties.MeldAanAsync(
controle.Value.Bedrijfsnaam, controle.Value.Contactpersoon, adres,
context.RequestAborted);
if (!aangemaakt.Succeeded)
{
Fout = aangemaakt.Error;
Gelukt = null;
return;
}
adverteerder = aangemaakt.Value!;
}
await Adverteerderslogin.MeldAanAsync(context, adverteerder);
Navigatie.NavigateTo("/adverteren/campagnes", forceLoad: true);
}
public class AanvraagGegevens
{
[Required(ErrorMessage = "Je moet een e-mailadres invullen!")]
[EmailAddress(ErrorMessage = "Dit is geen geldig e-mailadres!")]
public string Email { get; set; } = "";
}
public class NieuwGegevens
{
[Required(ErrorMessage = "Vul de naam van je bedrijf in.")]
public string Bedrijfsnaam { get; set; } = "";
public string Contactpersoon { get; set; } = "";
[Required(ErrorMessage = "Je moet een e-mailadres invullen!")]
[EmailAddress(ErrorMessage = "Dit is geen geldig e-mailadres!")]
public string Email { get; set; } = "";
}
public class InlogGegevens
{
[Required] public string Email { get; set; } = "";
[Required(ErrorMessage = "Je moet een code invullen!")]
public string Code { get; set; } = "";
}
}
@@ -0,0 +1,338 @@
@page "/beheer"
@attribute [Authorize]
@inject BeheerService Beheerkant
@rendermode InteractiveServer
<PageTitle>Beheer - MijnBesluit</PageTitle>
@* De beheerpagina. Twee handelingen: een overlijden terugdraaien dat ten onrechte is bevestigd,
en een samenvoeging terugdraaien die niet had gemoeten.
Zie BeheerService voor waarom er niet meer op staat. Kort: een beheerder kan geen kluis
openen en kan niet op naam zoeken. *@
@if (!Magdit)
{
<h1>Niet gevonden</h1>
<p class="zacht">Deze pagina bestaat niet, of hij is niet voor jou.</p>
}
else
{
<h1>Beheer</h1>
<p class="zacht">
Hier draai je twee dingen terug die niemand anders kan: een overlijden dat ten onrechte
is bevestigd, en een samenvoeging van twee profielen die niet had gemoeten. Voor het
eerste is dit nodig als de bezwaarlink uit de waarschuwingsmail verlopen is; de
betrokkene kan dan niet meer inloggen en heeft zelf geen weg terug.
</p>
@* Adverteren heeft een eigen scherm: dat gaat niet over terugdraaien maar over betalingen
afvinken en advertenties beoordelen, en daar hoor je dagelijks te komen in plaats van bij
hoge uitzondering. *@
<p><a href="/beheer/adverteren">Advertenties beheren &rsaquo;</a></p>
@if (Melding is not null) { <div class="melding @MeldingSoort">@Melding</div> }
@* ------------------------------------------------------------ statistieken *@
<div class="kaart">
<h2 style="margin-top:0">Gebruik van MijnBesluit</h2>
<p class="zacht" style="margin-top:0">
Aantallen, niet inhoud: hier is niets van iemands kluis of berichten te zien. Bedoeld
om de belasting in te schatten, niet om iemand te volgen.
</p>
@if (Stats is null)
{
<p class="zacht" style="margin-bottom:0">Statistieken zijn hier niet beschikbaar.</p>
}
else
{
<div class="statgrid">
<div class="statcel">
<span class="statgetal">@Stats.Profielen</span>
<span class="statlabel">Profielen (@Stats.GeclaimdeProfielen geclaimd)</span>
</div>
<div class="statcel">
<span class="statgetal">@Stats.Families</span>
<span class="statlabel">Families</span>
</div>
<div class="statcel">
<span class="statgetal">@Stats.KluisItems</span>
<span class="statlabel">Kluisitems</span>
</div>
<div class="statcel">
<span class="statgetal">@Stats.KluisBestanden</span>
<span class="statlabel">Bijlagen</span>
</div>
<div class="statcel">
<span class="statgetal">@Stats.Momenten</span>
<span class="statlabel">Momenten (@Stats.BezorgdeMomenten bezorgd)</span>
</div>
<div class="statcel">
<span class="statgetal">@Stats.Nalatenschapbeheerders</span>
<span class="statlabel">Aangewezen beheerders</span>
</div>
</div>
<Groeigrafiek Punten="Stats.GroeiPerDag" />
@if (Stats.ItemsPerCategorie.Count > 0)
{
<h3 style="margin-bottom:.6rem">Kluisitems per categorie</h3>
<div class="stapel">
@foreach (var (categorie, aantal) in Stats.ItemsPerCategorie.OrderByDescending(kv => kv.Value))
{
<div class="rij">
<span class="zacht" style="min-width:140px">@Kluisweergave.Naam(categorie)</span>
<div class="groei">
<div class="voortgang">
<span style="width:@(CategoriePercentage(aantal))%"></span>
</div>
</div>
<span class="zacht" style="min-width:2.5rem;text-align:right">@aantal</span>
</div>
}
</div>
}
}
</div>
<div class="kaart">
<h2 style="margin-top:0">Profiel opzoeken</h2>
<div class="veld">
<label for="adres">E-mailadres</label>
<input id="adres" type="email" @bind="Adres" placeholder="het volledige adres" />
<div class="uitleg">
Alleen het volledige adres, niet een deel ervan en niet op naam. Je hoort al te
weten wie je zoekt.
</div>
</div>
<button class="knop" disabled="@Bezig" @onclick="Zoeken">Zoeken</button>
</div>
@if (Gevonden is { } profiel)
{
<div class="kaart">
<h2 style="margin-top:0">@profiel.DisplayName</h2>
<dl style="margin:0">
<div class="rij" style="padding:.25rem 0">
<span class="zacht" style="min-width:190px">E-mailadres</span>
<span class="groei">@(profiel.Email ?? "onbekend")</span>
</div>
<div class="rij" style="padding:.25rem 0">
<span class="zacht" style="min-width:190px">Eigen account</span>
<span class="groei">@(profiel.IsGeclaimd ? "ja" : "nee, het profiel is nog niet geclaimd")</span>
</div>
<div class="rij" style="padding:.25rem 0">
<span class="zacht" style="min-width:190px">Meldingen</span>
<span class="groei">@profiel.Meldingen van @profiel.MeldingenNodig</span>
</div>
<div class="rij" style="padding:.25rem 0">
<span class="zacht" style="min-width:190px">Stand</span>
<span class="groei">@Stand(profiel.Stand)</span>
</div>
<div class="rij" style="padding:.25rem 0">
<span class="zacht" style="min-width:190px">Kan inloggen</span>
<span class="groei">@(profiel.AccountGeblokkeerd ? "nee, het account staat op overleden" : "ja")</span>
</div>
@if (profiel.OpgegaanIn is { } opgegaan)
{
<div class="rij" style="padding:.25rem 0">
<span class="zacht" style="min-width:190px">Opgegaan in</span>
<span class="groei">profiel @opgegaan</span>
</div>
}
</dl>
@if (profiel.OverlijdenBevestigdOp is { } bevestigd)
{
<div class="melding let-op" style="margin-top:1rem">
<p style="margin-top:0">
Het overlijden is bevestigd op @Datums.Datum(bevestigd)@(
profiel.Overlijdensdatum is { } datum ? $", met {Datums.Datum(datum)} als overlijdensdatum" : "").
</p>
<p>
Terugdraaien betekent: alle meldingen vervallen, het account wordt weer
actief, en de toegang die de nalatenschapsbeheerder had gekregen wordt
ingetrokken.
</p>
<div class="veld">
<label for="reden">Waarom draai je dit terug?</label>
<input id="reden" @bind="Reden" placeholder="bijvoorbeeld: telefonisch gesproken, leeft" />
<div class="uitleg">Dit komt in het logboek te staan, bij jouw naam.</div>
</div>
<button class="knop gevaar" disabled="@Bezig" @onclick="Herstellen">
Overlijden terugdraaien
</button>
</div>
}
else
{
<p class="zacht" style="margin-bottom:0">
Voor dit profiel is geen overlijden bevestigd. Er valt daar dus niets terug te draaien.
</p>
}
</div>
@* ------------------------------------------------------------ samenvoegingen *@
<div class="kaart">
<h2 style="margin-top:0">Samenvoegingen</h2>
@if (profiel.Samenvoegingen.Count == 0)
{
<p class="zacht" style="margin-bottom:0">
Dit profiel is nooit samengevoegd. Let op: samenvoegingen van vóór 15 september
2026 zijn niet vastgelegd en kunnen daarom ook niet worden teruggedraaid.
</p>
}
else
{
<p class="zacht" style="margin-top:0">
Terugdraaien zet alles wat toen is verhuisd terug bij het profiel dat is
opgegaan. Wat er ná die tijd bij het andere profiel is bijgekomen, blijft
daar staan &mdash; dat is daar aangemaakt en hoort daar.
</p>
<div class="stapel">
@foreach (var samenvoeging in profiel.Samenvoegingen)
{
<div class="kaart compact">
<div class="rij">
<div class="groei">
<span class="groot">
@samenvoeging.DuplicaatNaam (@samenvoeging.DuplicaatPersonId)
&rarr; @samenvoeging.DoelNaam (@samenvoeging.DoelPersonId)
</span>
<div class="zacht">
@Datums.DatumTijd(samenvoeging.SamengevoegdOp)
&middot; @samenvoeging.Stappen stap@(samenvoeging.Stappen == 1 ? "" : "pen")
</div>
</div>
@if (samenvoeging.IsTeruggedraaid)
{
<span class="label">Teruggedraaid</span>
}
</div>
@if (!samenvoeging.IsTeruggedraaid)
{
<div class="veld" style="margin-top:.8rem">
<label for="reden-@samenvoeging.Id">Waarom draai je dit terug?</label>
<input id="reden-@samenvoeging.Id" @bind="Terugreden"
placeholder="bijvoorbeeld: verkeerde uitnodiging aangenomen" />
<div class="uitleg">Dit komt in het logboek te staan, bij jouw naam.</div>
</div>
<button class="knop gevaar" disabled="@Bezig"
@onclick="() => TerugdraaienAsync(samenvoeging.Id)">
Samenvoeging terugdraaien
</button>
}
</div>
}
</div>
}
</div>
}
}
@code {
[CascadingParameter] private Task<AuthenticationState>? AuthState { get; set; }
private long _gebruikerId;
private bool Magdit, Bezig;
private string Adres = "";
private string Reden = "";
private string Terugreden = "";
private BeheerService.Profielbeeld? Gevonden;
private string? Melding, MeldingSoort;
private Statistieken? Stats;
protected override async Task OnInitializedAsync()
{
if (AuthState is null) return;
_gebruikerId = (await AuthState).User.UserId() ?? 0;
Magdit = await Beheerkant.IsBeheerderAsync(_gebruikerId);
if (!Magdit) return;
var statistieken = await Beheerkant.StatistiekenAsync(_gebruikerId);
if (statistieken.Succeeded) Stats = statistieken.Value;
}
/// <summary>Breedte van de staaf voor deze categorie, ten opzichte van de grootste categorie.</summary>
private int CategoriePercentage(int aantal)
{
var grootste = Stats!.ItemsPerCategorie.Values.Max();
return grootste == 0 ? 0 : Math.Max(2, aantal * 100 / grootste);
}
private async Task Zoeken()
{
Bezig = true;
Melding = null;
Gevonden = null;
try
{
var resultaat = await Beheerkant.ZoekOpAdresAsync(Adres, _gebruikerId);
if (resultaat.Succeeded) Gevonden = resultaat.Value;
else { Melding = resultaat.Error; MeldingSoort = "fout"; }
}
finally { Bezig = false; }
}
private async Task Herstellen()
{
if (Gevonden is not { } profiel) return;
Bezig = true;
Melding = null;
try
{
var resultaat = await Beheerkant.HerstelOverlijdenAsync(profiel.PersonId, _gebruikerId, Reden);
if (!resultaat.Succeeded) { Melding = resultaat.Error; MeldingSoort = "fout"; return; }
Melding = $"Het overlijden van {profiel.DisplayName} is teruggedraaid. " +
"Hij of zij kan weer inloggen, en de nalatenschapsbeheerder is zijn toegang kwijt.";
MeldingSoort = "goed";
Reden = "";
// Opnieuw ophalen, zodat het scherm laat zien wat er nu staat.
var opnieuw = await Beheerkant.ZoekOpAdresAsync(Adres, _gebruikerId);
Gevonden = opnieuw.Succeeded ? opnieuw.Value : null;
}
finally { Bezig = false; }
}
private async Task TerugdraaienAsync(long samenvoegingId)
{
Bezig = true;
Melding = null;
try
{
var resultaat = await Beheerkant.TerugdraaienAsync(samenvoegingId, _gebruikerId, Terugreden);
if (!resultaat.Succeeded) { Melding = resultaat.Error; MeldingSoort = "fout"; return; }
Melding = "De samenvoeging is teruggedraaid. De twee profielen staan weer los van elkaar.";
MeldingSoort = "goed";
Terugreden = "";
var opnieuw = await Beheerkant.ZoekOpAdresAsync(Adres, _gebruikerId);
Gevonden = opnieuw.Succeeded ? opnieuw.Value : null;
}
finally { Bezig = false; }
}
private static string Stand(DeathProcedureState stand) => stand switch
{
DeathProcedureState.Geen => "geen meldingen",
DeathProcedureState.Lopend => "procedure loopt",
DeathProcedureState.Betwist => "tegengesproken door de betrokkene",
DeathProcedureState.Bevestigd => "overlijden bevestigd",
_ => "onbekend"
};
}
@@ -0,0 +1,372 @@
@page "/beheer/adverteren"
@attribute [Authorize]
@inject BeheerService Beheerkant
@inject AdvertentieService Adverteren
@inject AdvertentietariefInstellingen TariefInstellingen
@rendermode InteractiveServer
<PageTitle>Adverteren - Beheer</PageTitle>
@* Het beheerscherm voor advertenties. Links de adverteerders, rechts hun campagnes.
Twee handelingen per campagne, en dat zijn twee verschillende vragen:
1. is het geld binnen (dat zie je in bunq, niet hier - er komt geen bericht terug);
2. mag dit naast onze tekst over uitvaarten staan.
Zie docs/Adverteren.md. *@
@if (!Magdit)
{
@* Dezelfde melding als een pagina die niet bestaat. Wie geen beheerder is, hoort niet te
weten dat hier iets te beheren valt. *@
<h1>Niet gevonden</h1>
<p class="zacht">Deze pagina bestaat niet, of hij is niet voor jou.</p>
}
else
{
<h1>Adverteren</h1>
@if (Melding is not null) { <div class="melding @MeldingSoort">@Melding</div> }
<div class="kaart" style="margin-bottom:1rem">
<h2 style="margin-top:0">Tarief</h2>
<p class="zacht" style="margin-top:0">
Het bedrag is dagen &times; vertoningen per uur &times; dit tarief per vertoning.
</p>
<div class="rij" style="align-items:flex-end;gap:.6rem;flex-wrap:wrap">
<div class="veld" style="margin:0">
<label for="tarief">Tarief (in centen)</label>
<input id="tarief" type="number" min="1" style="width:8rem" @bind="NieuwTarief" />
</div>
<button class="knop rustig" disabled="@Bezig" @onclick="TariefOpslaanAsync">Opslaan</button>
</div>
<p class="zacht" style="margin:.5rem 0 0">
Nu @HuidigTarief cent per vertoning per uur per dag. Dertig dagen en vijf keer per uur
komt daarmee op @AdvertentieService.Euro(30 * 5 * HuidigTarief).
</p>
</div>
@if (Wachtend.Count > 0)
{
<div class="kaart" style="border-color:var(--accent)">
<h2 style="margin-top:0">Er wacht iets op jou</h2>
<div class="stapel">
@foreach (var campagne in Wachtend)
{
<div class="rij">
<div class="groei">
<div class="groot">@campagne.Adverteerder?.Bedrijfsnaam</div>
<div class="zacht">
@Bedrag(campagne.BedragCenten) &middot; kenmerk @campagne.Betaalkenmerk
&middot; @Standnaam(campagne.Stand)
</div>
</div>
<button class="knop rustig" @onclick="() => Openen(campagne.AdverteerderId)">Bekijken</button>
</div>
}
</div>
</div>
}
<div class="raster lijst" style="margin-top:1rem; align-items:start">
<div class="kaart">
<h2 style="margin-top:0">Adverteerders</h2>
@if (Adverteerders.Count == 0)
{
<p class="zacht">Er is nog niemand aangemeld.</p>
}
else
{
<div class="stapel">
@foreach (var adverteerder in Adverteerders)
{
<button type="button"
class="kaart compact klikbaar @(Gekozen?.Id == adverteerder.Id ? "zelf" : "")"
style="width:100%;text-align:left"
@onclick="() => Openen(adverteerder.Id)">
<span class="groot">@adverteerder.Bedrijfsnaam</span>
<div class="zacht">
@adverteerder.Email
@if (adverteerder.IsGeblokkeerd) { <text> &middot; geblokkeerd</text> }
</div>
</button>
}
</div>
}
</div>
<div class="kaart">
@if (Gekozen is null)
{
<h2 style="margin-top:0">Advertenties</h2>
<p class="zacht">Kies links een adverteerder.</p>
}
else
{
<h2 style="margin-top:0">@Gekozen.Bedrijfsnaam</h2>
<p class="zacht" style="margin-top:0">
@Gekozen.Email
@if (!string.IsNullOrWhiteSpace(Gekozen.Contactpersoon)) { <text> &middot; @Gekozen.Contactpersoon</text> }
</p>
@if (Campagnes.Count == 0)
{
<p class="zacht">Deze adverteerder heeft nog geen advertenties.</p>
}
<div class="stapel">
@foreach (var campagne in Campagnes)
{
<div class="kaart compact">
<div class="rij">
<div class="groei">
<span class="groot">@campagne.Tekst</span>
<div class="zacht">
@campagne.VanafDatum.ToString("dd-MM-yyyy") t/m
@campagne.TotEnMetDatum.ToString("dd-MM-yyyy") &middot;
@campagne.PerUur&times; per uur &middot; @campagne.Dagen dagen
</div>
</div>
<span class="label @Standkleur(campagne.Stand)">@Standnaam(campagne.Stand)</span>
</div>
@if (campagne.HeeftAfbeelding)
{
<img src="/advertentie/@campagne.Id/afbeelding" alt=""
style="max-width:100%;max-height:120px;border-radius:10px;margin-top:.6rem" />
}
<div class="zacht" style="margin-top:.6rem">
Gaat naar <a href="@campagne.Link" target="_blank" rel="noopener noreferrer nofollow">@campagne.Link</a>
</div>
<div class="zacht" style="margin-top:.4rem">
@Bedrag(campagne.BedragCenten) &middot; kenmerk <strong>@campagne.Betaalkenmerk</strong>
@if (campagne.BetaaldOp is { } betaald)
{
<text> &middot; betaald op @betaald.ToLocalTime().ToString("dd-MM-yyyy")</text>
}
</div>
@if (Getoond.TryGetValue(campagne.Id, out var tellers))
{
<div class="zacht" style="margin-top:.4rem">
@tellers.Vertoningen getoond &middot; @tellers.Kliks aangeklikt
</div>
}
@if (campagne.AfwijzingReden is { Length: > 0 } reden)
{
<div class="melding let-op" style="margin-top:.6rem">Afgewezen: @reden</div>
}
@* De twee vinkjes. Goedkeuren kan pas als de betaling is afgevinkt;
de knop staat er daarom niet eerder. *@
<div class="rij" style="margin-top:.8rem">
@if (!campagne.IsBetaald)
{
<button class="knop" disabled="@Bezig" @onclick="() => Betaald(campagne.Id)">
Betaling gezien
</button>
}
else if (campagne.Stand != CampagneStand.Loopt)
{
<button class="knop" disabled="@Bezig" @onclick="() => Goedkeuren(campagne.Id)">
Goedkeuren
</button>
}
else
{
<button class="knop rustig" disabled="@Bezig" @onclick="() => Stopzetten(campagne.Id)">
Stopzetten
</button>
}
@if (campagne.Stand != CampagneStand.Afgewezen)
{
<button class="knop gevaar" disabled="@Bezig" @onclick="() => Afwijzen = campagne.Id">
Afwijzen
</button>
}
</div>
@if (Afwijzen == campagne.Id)
{
<div class="melding let-op" style="margin-top:.8rem">
<div class="veld">
<label for="reden-@campagne.Id">Waarom wijs je hem af?</label>
<input id="reden-@campagne.Id" @bind="Reden"
placeholder="Dit leest de adverteerder" />
</div>
<div class="rij">
<button class="knop gevaar" disabled="@Bezig" @onclick="() => AfwijzenNu(campagne.Id)">
Afwijzen en bericht sturen
</button>
<button class="knop rustig" disabled="@Bezig" @onclick="() => Afwijzen = null">
Annuleren
</button>
</div>
</div>
}
</div>
}
</div>
}
</div>
</div>
}
@code {
[CascadingParameter] private Task<AuthenticationState>? AuthState { get; set; }
private long _gebruikerId;
private bool Magdit, Bezig;
private IReadOnlyList<Adverteerder> Adverteerders = Array.Empty<Adverteerder>();
private IReadOnlyList<Campagne> Wachtend = Array.Empty<Campagne>();
private IReadOnlyList<Campagne> Campagnes = Array.Empty<Campagne>();
private Adverteerder? Gekozen;
/// <summary>Wat er per campagne werkelijk is getoond en aangeklikt.</summary>
private Dictionary<long, (int Vertoningen, int Kliks)> Getoond = new();
private long? Afwijzen;
private string Reden = "";
private string? Melding, MeldingSoort;
private int HuidigTarief;
private int NieuwTarief;
protected override async Task OnInitializedAsync()
{
if (AuthState is null) return;
_gebruikerId = (await AuthState).User.UserId() ?? 0;
Magdit = await Beheerkant.IsBeheerderAsync(_gebruikerId);
if (Magdit)
{
HuidigTarief = Adverteren.Tarieven.TariefCenten;
NieuwTarief = HuidigTarief;
await Verversen();
}
}
/// <summary>
/// Schrijft het tarief naar de instellingen. Deze pagina rekent zelf al met het oude tarief
/// verder tot een herlaad; nieuwe boekingen elders pakken het nieuwe tarief op zodra
/// ASP.NET Core het gewijzigde bestand heeft opgemerkt, meestal binnen enkele seconden.
/// </summary>
private async Task TariefOpslaanAsync()
{
Melding = null;
var probleem = AdvertentietariefInstellingen.Controleer(NieuwTarief);
if (probleem is not null)
{
Melding = probleem;
MeldingSoort = "fout";
return;
}
Bezig = true;
try
{
TariefInstellingen.SchrijfTarief(NieuwTarief);
HuidigTarief = NieuwTarief;
Melding = "Tarief opgeslagen. Nieuwe boekingen rekenen hiermee zodra dit is opgepikt.";
MeldingSoort = "goed";
}
catch (Exception ex)
{
Melding = ex.Message;
MeldingSoort = "fout";
}
finally { Bezig = false; }
await Task.CompletedTask;
}
private async Task Verversen()
{
Adverteerders = await Adverteren.AdverteerdersAsync();
Wachtend = await Adverteren.WachtOpBeheerderAsync();
if (Gekozen is not null)
{
Gekozen = await Adverteren.AdverteerderAsync(Gekozen.Id);
Campagnes = Gekozen is null ? Array.Empty<Campagne>()
: await Adverteren.CampagnesAsync(Gekozen.Id);
Getoond = new Dictionary<long, (int, int)>();
foreach (var campagne in Campagnes)
Getoond[campagne.Id] = await Adverteren.TotalenAsync(campagne.Id);
}
}
private async Task Openen(long adverteerderId)
{
Afwijzen = null;
Melding = null;
Gekozen = await Adverteren.AdverteerderAsync(adverteerderId);
await Verversen();
}
private Task Betaald(long campagneId) =>
DoenAsync(() => Adverteren.BetaaldAsync(campagneId, _gebruikerId), "De betaling staat genoteerd.");
private Task Goedkeuren(long campagneId) =>
DoenAsync(() => Adverteren.GoedkeurenAsync(campagneId, _gebruikerId), "Goedgekeurd. Hij draait binnen zijn periode.");
private Task Stopzetten(long campagneId) =>
DoenAsync(() => Adverteren.StopzettenAsync(campagneId, _gebruikerId), "Stopgezet.");
private async Task AfwijzenNu(long campagneId)
{
var reden = Reden;
if (await DoenAsync(() => Adverteren.AfwijzenAsync(campagneId, _gebruikerId, reden), "Afgewezen; de adverteerder heeft bericht."))
{
Afwijzen = null;
Reden = "";
}
}
/// <summary>
/// Eén plek voor het patroon "doe iets, toon de uitkomst, ververs". Zo staat de afhandeling
/// van fouten hier één keer en blijft er geen knop hangen als er iets misgaat.
/// </summary>
private async Task<bool> DoenAsync(Func<Task<Result>> werk, string gelukt)
{
Bezig = true;
Melding = null;
try
{
var uitkomst = await werk();
Melding = uitkomst.Succeeded ? gelukt : uitkomst.Error;
MeldingSoort = uitkomst.Succeeded ? "goed" : "fout";
await Verversen();
return uitkomst.Succeeded;
}
finally { Bezig = false; }
}
private static string Bedrag(int centen) => AdvertentieService.Euro(centen);
private static string Standnaam(CampagneStand stand) => stand switch
{
CampagneStand.Concept => "Concept",
CampagneStand.WachtOpBetaling => "Wacht op betaling",
CampagneStand.WachtOpGoedkeuring => "Wacht op goedkeuring",
CampagneStand.Loopt => "Loopt",
CampagneStand.Afgewezen => "Afgewezen",
_ => "Gestopt"
};
private static string Standkleur(CampagneStand stand) => stand switch
{
CampagneStand.Loopt => "geclaimd",
CampagneStand.WachtOpBetaling => "uitgenodigd",
CampagneStand.WachtOpGoedkeuring => "uitgenodigd",
_ => ""
};
}
@@ -0,0 +1,36 @@
@page "/Error"
@using System.Diagnostics
<PageTitle>Error</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
@code{
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
private string? RequestId { get; set; }
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
protected override void OnInitialized() =>
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
}
@@ -0,0 +1,593 @@
@page "/familie"
@attribute [Authorize]
@using MijnBesluit.Application.Abstractions
@inject FamilyService Families
@inject IFamilyRepository FamilieOpslag
@inject VaultService Kluis
@inject IJSRuntime JS
@rendermode InteractiveServer
<PageTitle>Mijn mensen - MijnBesluit</PageTitle>
@* Dezelfde indeling als in de app: een brede knop om iemand toe te voegen, daaronder
compacte kaarten met naam en wat het van je is. Wijzigen en verwijderen gaan via een
popup, zodat je op de kaart zelf alleen de drie knoppen ziet.
Wijzigen en verwijderen mag zolang het profiel niet geclaimd is - ook na een uitnodiging,
want een tikfout in een e-mailadres merk je pas als de mail nergens aankomt. *@
<h1>Mijn mensen</h1>
<p class="zacht">
Je familie en je vrienden &mdash; iedereen die erbij hoort. Voeg ze toe en nodig ze uit.
Zolang iemand de uitnodiging niet heeft aangenomen, beheer jij dat profiel. Daarna is het
van hem of haar.
</p>
@if (Melding is not null) { <div class="melding @MeldingSoort">@Melding</div> }
@if (Leden is null)
{
<p class="zacht">Bezig met laden&hellip;</p>
}
else
{
<button class="knop vol" style="margin-bottom:1rem" disabled="@Bezig" @onclick="Nieuw">
Toevoegen persoon
</button>
@* Jezelf bovenaan. Je staat niet in je eigen ledenlijst - 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="@Foto(_persoonId)" />
<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)
{
<div class="kaart compact">
<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="@Foto(lid.PersonId)" />
<div class="groei">
<span class="groot" title="@lid.DisplayName">@lid.DisplayName</span>
@* De status staat hier en niet naast de naam. Daar hield de naam op een
telefoon te weinig ruimte over; op deze regel past hij wel, en de
status staat nog steeds altijd rechts op de tegel. *@
<div class="onderregel">
<span class="zacht">@Onderregel(lid)</span>
@switch (lid.State)
{
case ProfileState.Geclaimd:
<span class="label geclaimd" title="Deze persoon beheert het profiel zelf">Geclaimd</span>
break;
case ProfileState.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>
@* 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. Wijzigen en uitnodigen
horen daar niet bij - dat kán niet meer zodra iemand heeft geclaimd.
Is het profiel geclaimd, dan staat "Geclaimd op ..." links op deze regel,
naast Verwijderen - zo blijft de tegel net zo laag als de andere. *@
<div class="rij" style="margin-top:.6rem">
@if (lid.State == ProfileState.Geclaimd)
{
<span class="zacht groei">
Geclaimd@(lid.ClaimedAt is { } sinds ? $" op {KortDatum(sinds)}" : "").
</span>
}
@if (lid.State != ProfileState.Geclaimd)
{
<button class="knop rustig klein" disabled="@Bezig" @onclick="() => Wijzigen(lid)"
title="Gegevens wijzigen">
&#9998; Wijzigen
</button>
@if (!string.IsNullOrWhiteSpace(lid.Email))
{
<button class="knop rustig klein" disabled="@Bezig" @onclick="() => Uitnodigen(lid.PersonId)"
title="@(lid.State == ProfileState.Uitgenodigd ? "Opnieuw uitnodigen" : "Uitnodiging sturen")">
&#9993; @(lid.State == ProfileState.Uitgenodigd ? "Opnieuw" : "Uitnodigen")
</button>
}
}
<button class="knop rustig klein" disabled="@Bezig" @onclick="() => Weghalen = lid"
title="Bij je mensen weghalen">
&#128465; Verwijderen
</button>
</div>
@if (lid.State != ProfileState.Geclaimd && string.IsNullOrWhiteSpace(lid.Email))
{
<div class="zacht" style="margin-top:.5rem">
Vul een e-mailadres in om @(lid.DisplayName) te kunnen uitnodigen.
</div>
}
</div>
}
</div>
@if (Leden.Count == 0)
{
<div class="leeg">
<span class="teken">&#128101;</span>
Er staat hier nog niemand. Voeg je partner of je kinderen toe;
zij krijgen een uitnodiging en kunnen daarna hun eigen MijnBesluit invullen.
</div>
}
}
@* ---------------------------------------------------------------- toevoegen en wijzigen *@
@if (Bewerken is not null)
{
<Popup Titel="@(Bewerken.PersoonId == 0 ? "Iemand toevoegen" : "Gegevens wijzigen")"
Bevestigtekst="Opslaan" Bezig="@Bezig"
Foutmelding="@(MeldingSoort == "fout" ? Melding : null)"
OpBevestigen="Opslaan" OpSluiten="Sluiten">
<div class="raster twee">
<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 MijnBesluit.Domain.Rules.Relaties.Keuzes)
{
<option value="@waarde">@naam</option>
}
</select>
</div>
<div class="veld">
<label for="gb">Geboortedatum</label>
@* Op de site gewoon een datumveld: de agendaknop van de browser werkt hier wel.
In de app staat een keuzelijstje, want in een webview doet die knop niets. *@
<input id="gb" type="date" max="@DateTime.Today.ToString("yyyy-MM-dd")"
@bind="Bewerken.Geboortedatum" />
<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" @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="@Uploaden" KanProfielfoto="true"
Uitleg="Een foto van hem samen met jou, een kopie van zijn paspoort, een brief. Met het sterretje maak je er zijn profielfoto van."
OpKiezen="BijlageKiezen"
OpVerwijderen="BijlageWeg"
OpProfielfoto="Profielfoto" />
}
@if (BewerkLid is { } lid)
{
<div class="zacht geschiedenis">
<div>Profiel aangemaakt op @Datum(lid.CreatedAt)</div>
@if (lid.InvitedAt is { } verstuurd)
{
<div>
Uitgenodigd op @Datum(verstuurd)@(lid.InvitationResends > 0
? $" ({lid.InvitationResends + 1}× verstuurd)" : "")
</div>
@if (lid.InvitationExpiresAt is { } tot)
{
<div>
@(tot > DateTime.UtcNow
? $"Uitnodiging geldig tot {Datum(tot)}"
: $"Uitnodiging verlopen op {Datum(tot)}")
</div>
}
}
else
{
<div>Nog niet uitgenodigd.</div>
}
</div>
}
</Popup>
}
@* ---------------------------------------------------------------- verwijderen *@
@if (Weghalen is not null)
{
@* Drie gevallen, zoals in de app.
1. Hij beheert je nalatenschap. Dan kan het niet, en dit scherm zegt 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 momenten voor hem klaar. Die verdwijnen mee, en dat is het soort ding dat je
wilt weten vóór je op "ja" drukt - 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 verwijderen.
</p>
</Popup>
}
else
{
<Popup Titel="@($"{Weghalen.DisplayName} verwijderen?")"
Bevestigtekst="Verwijderen" Gevaarlijk="true" Bezig="@Bezig"
OpBevestigen="() => Verwijderen(Weghalen.PersonId)" 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.State == ProfileState.Geclaimd)
{
<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 {
[CascadingParameter] private Task<AuthenticationState>? AuthState { get; set; }
private long _gebruikerId, _persoonId, _familieId;
private string _mijnNaam = "Mijn profiel";
private IReadOnlyList<FamilyMemberView>? Leden;
private string? Melding, MeldingSoort;
private bool Bezig;
/// <summary>Het formulier voor toevoegen en wijzigen; null als er niets openstaat.</summary>
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 van het profiel.</summary>
private FamilyMemberView? BewerkLid;
/// <summary>Wie er op verwijderen staat te wachten.</summary>
private FamilyMemberView? Weghalen;
/// <summary>De foto's en documenten bij de persoon die in het wijzigscherm staat.</summary>
private IReadOnlyList<VaultFile> Bijlagen = Array.Empty<VaultFile>();
private bool Uploaden;
/// <summary>Zoveel bestanden tegelijk kiezen mag. Blazor wil hier een bovengrens.</summary>
private const int MaxTegelijk = 10;
/// <summary>Per persoon het nummer van zijn profielfoto, voor wie er een heeft.</summary>
private IReadOnlyDictionary<long, long> Profielfotos = new Dictionary<long, long>();
protected override async Task OnInitializedAsync()
{
if (AuthState is null) return;
var user = (await AuthState).User;
_gebruikerId = user.UserId() ?? 0;
_persoonId = user.PersonId() ?? 0;
_mijnNaam = user.Naam() is { Length: > 0 } naam ? naam : "Mijn profiel";
var families = await FamilieOpslag.ListOwnedByPersonAsync(_persoonId);
_familieId = families.FirstOrDefault()?.Id ?? 0;
await Verversen();
}
private async Task Verversen()
{
if (_familieId == 0)
{
Leden = Array.Empty<FamilyMemberView>();
}
else
{
var alle = await Families.ListMembersAsync(_familieId, _gebruikerId);
Leden = alle.Where(l => l.PersonId != _persoonId).ToList();
}
// De profielfoto's in één vraag erbij. Alleen het nummer; de browser haalt het plaatje
// zelf op via /bestand/{id}/klein en bewaart het daarna in zijn eigen buffer.
Profielfotos = (await Kluis.ProfielfotosAsync(Leden.Select(l => l.PersonId).Append(_persoonId),
_gebruikerId))
.ToDictionary(f => f.PersoonId, f => f.BestandId);
}
/// <summary>Waar de profielfoto van deze persoon staat; null als hij er geen heeft.</summary>
private string? Foto(long persoonId) =>
Profielfotos.TryGetValue(persoonId, out var bestandId) ? $"/bestand/{bestandId}/klein" : null;
private void Nieuw()
{
Melding = null;
Weghalen = null;
BewerkLid = null;
Bijlagen = Array.Empty<VaultFile>();
Bewerken = new Formulier();
}
private async Task Wijzigen(FamilyMemberView lid)
{
Melding = 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 BijlagenLaden();
}
// ------------------------------------------------------------------ foto's en documenten
private async Task BijlagenLaden()
{
if (Bewerken is not { PersoonId: > 0 } formulier) { Bijlagen = Array.Empty<VaultFile>(); return; }
var resultaat = await Kluis.ListPersonFilesAsync(formulier.PersoonId, _gebruikerId);
Bijlagen = resultaat.Succeeded ? resultaat.Value! : Array.Empty<VaultFile>();
}
private async Task BijlageKiezen(InputFileChangeEventArgs e)
{
if (Bewerken is not { PersoonId: > 0 } formulier) return;
Uploaden = true;
Melding = null;
try
{
foreach (var bestand in e.GetMultipleFiles(MaxTegelijk))
{
if (bestand.Size > VaultService.MaxBestandsgrootte)
{
Melding = $"{bestand.Name} is te groot. Maximaal {VaultService.MaxBestandsgrootte / 1024 / 1024} MB.";
MeldingSoort = "fout";
continue;
}
using var stroom = bestand.OpenReadStream(VaultService.MaxBestandsgrootte);
using var buffer = new MemoryStream();
await stroom.CopyToAsync(buffer);
var resultaat = await Kluis.StoreFileAsync(
_gebruikerId, bestand.Name,
string.IsNullOrWhiteSpace(bestand.ContentType) ? "application/octet-stream" : bestand.ContentType,
buffer.ToArray(), subjectPersonId: formulier.PersoonId);
if (!resultaat.Succeeded) { Melding = resultaat.Error; MeldingSoort = "fout"; }
}
await BijlagenLaden();
}
catch (Exception ex)
{
Melding = $"Het bestand kon niet worden gelezen: {ex.Message}";
MeldingSoort = "fout";
}
finally { Uploaden = false; }
}
private async Task BijlageWeg(long bestandId)
{
var resultaat = await Kluis.DeleteFileAsync(bestandId, _gebruikerId);
if (!resultaat.Succeeded) { Melding = resultaat.Error; MeldingSoort = "fout"; }
await BijlagenLaden();
}
private async Task Profielfoto(long bestandId)
{
var resultaat = await Kluis.SetProfilePhotoAsync(bestandId, _gebruikerId);
if (!resultaat.Succeeded) { Melding = resultaat.Error; MeldingSoort = "fout"; }
await BijlagenLaden();
}
private async Task Opslaan()
{
var formulier = Bewerken!;
if (string.IsNullOrWhiteSpace(formulier.Voornaam) && string.IsNullOrWhiteSpace(formulier.Achternaam))
{
Melding = "Vul minstens een voor- of achternaam in.";
MeldingSoort = "fout";
await JS.InvokeVoidAsync("mijnbesluit.springNaarVeld", "vn");
return;
}
var email = string.IsNullOrWhiteSpace(formulier.Email) ? null : formulier.Email.Trim();
Bezig = true;
try
{
// AddMemberAsync geeft de nieuwe persoon terug, WijzigLidAsync alleen goed of fout.
// Daarom hier twee aanroepen in plaats van een ternaire: de uitkomsttypen verschillen.
bool gelukt;
string? fout;
var geboren = formulier.Geboortedatum is { } g ? DateOnly.FromDateTime(g) : (DateOnly?)null;
if (formulier.PersoonId == 0)
{
var uitkomst = await Families.AddMemberAsync(
_familieId, _gebruikerId, formulier.Voornaam.Trim(), formulier.Achternaam.Trim(),
email, formulier.Relatie, null, geboren);
gelukt = uitkomst.Succeeded;
fout = uitkomst.Error;
}
else
{
var uitkomst = await Families.WijzigLidAsync(
_familieId, formulier.PersoonId, _gebruikerId, formulier.Voornaam.Trim(),
formulier.Achternaam.Trim(), email, formulier.Relatie, null, geboren);
gelukt = uitkomst.Succeeded;
fout = uitkomst.Error;
}
if (gelukt)
{
Melding = formulier.PersoonId == 0 ? "Toegevoegd." : "De wijziging is opgeslagen.";
MeldingSoort = "goed";
Sluiten();
await Verversen();
}
else
{
Melding = fout;
MeldingSoort = "fout";
}
}
finally { Bezig = false; }
}
private async Task Verwijderen(long persoonId)
{
Bezig = true;
try
{
var resultaat = await Families.VerwijderLidAsync(_familieId, persoonId, _gebruikerId);
Melding = resultaat.Succeeded ? "De persoon staat hier niet meer bij." : resultaat.Error;
MeldingSoort = resultaat.Succeeded ? "goed" : "fout";
if (resultaat.Succeeded) { Weghalen = null; await Verversen(); }
}
finally { Bezig = false; }
}
private async Task Uitnodigen(long persoonId)
{
Bezig = true;
try
{
var resultaat = await Families.InviteAsync(_familieId, persoonId, _gebruikerId);
Melding = resultaat.Succeeded ? "De uitnodiging is verstuurd." : resultaat.Error;
MeldingSoort = resultaat.Succeeded ? "goed" : "fout";
await Verversen();
}
finally { Bezig = false; }
}
private void Sluiten()
{
Bewerken = null;
BewerkLid = null;
Bijlagen = Array.Empty<VaultFile>();
}
/// <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>
private static string Onderregel(FamilyMemberView lid)
{
var relatie = Relatie(lid);
if (lid.DateOfDeath is { } overleden)
return $"{relatie} · overleden {Datums.Datum(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;
}
/// <summary>Een moment in UTC leesbaar op het scherm zetten, in lokale tijd.</summary>
private static string Datum(DateTime moment) => Datums.Datum(moment);
private static string KortDatum(DateTime moment) => Datums.Kort(moment);
private static string Relatie(FamilyMemberView lid) =>
!string.IsNullOrWhiteSpace(lid.RelationLabel) ? lid.RelationLabel! : Omschrijving(lid.Relation);
// De lijst en de namen staan in het domein, zodat de website en de app er niet ieder een
// eigen versie van hebben. Zie MijnBesluit.Domain.Rules.Relaties.
private static string Omschrijving(RelationType r) => MijnBesluit.Domain.Rules.Relaties.Naam(r);
/// <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; } = "";
// Partner staat bovenaan de lijst; een keuzelijst hoort te openen op wat je ziet staan.
// Kind stond hier eerst, en dat is sinds de uitsplitsing geen keuze meer.
public RelationType Relatie { get; set; } = RelationType.Partner;
/// <summary>Null als er geen geboortedatum is ingevuld; dat mag.</summary>
public DateTime? Geboortedatum { get; set; }
}
}
@@ -0,0 +1,359 @@
@page "/"
@attribute [Authorize]
@inject OverzichtService Overzichten
@inject LegacyService NalatenschapService
@inject AdvertentieService Adverteren
@inject AccountService Accounts
@inject SamenvoegService Samenvoegen
@rendermode InteractiveServer
<PageTitle>MijnBesluit</PageTitle>
@* Het beginscherm, met dezelfde indeling als in de app: eerst wat er speelt, dan de tegels.
De aantallen staan achter de titel in plaats van op een regel eronder, zodat de tegels
laag blijven. *@
<AuthorizeView Context="auth">
<Authorized>
<h1>@Begroeting(auth.User.Naam())</h1>
@if (Fout is not null)
{
<div class="melding fout">@Fout</div>
}
@if (Melding is not null)
{
<div class="melding goed">@Melding</div>
}
@* Heeft iemand een profiel voor je aangemaakt op jouw adres? Dan staat die vraag hier,
bovenaan, tot je hem beantwoordt. Alleen jij kunt zeggen of dat over jou gaat;
koppelen op e-mailadres doet MijnBesluit niet. 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="() => Overnemen(aantekening.PersoonId)">Ja, dat ben ik</button>
<button class="knop rustig" disabled="@Bezig"
@onclick="() => Afwijzen(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.Soort == "overlijden" ? "let-op" : "")">
<span class="teken">@Teken(regel.Soort)</span>
<div class="groei">
<div>@regel.Tekst</div>
@if (regel.Datum is { } datum)
{
<div class="zacht">@Datums.MetDag(datum)</div>
}
</div>
</div>
}
</div>
@if (Gegevens.Regels.Any(r => r.Soort == "overlijden"))
{
<button class="knop" style="margin-bottom:1rem" disabled="@Bezig" @onclick="IkBenErNog">
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. *@
@if (Reclame is not null)
{
<a class="advertentie" href="/advertentie/@Reclame.Id/uit"
target="_blank" rel="noopener noreferrer nofollow">
@if (Reclame.HeeftAfbeelding)
{
<img src="/advertentie/@Reclame.Id/afbeelding" alt="" />
}
<span class="groei">
<span class="merkje">Advertentie</span>
<span class="tekst">@Reclame.Tekst</span>
</span>
</a>
}
<div class="raster twee">
<a class="kaart klikbaar" href="/kluis">
<span class="groot">Mijn kluis <span class="aantal">@(Gegevens?.AantalKluis ?? 0)</span></span>
</a>
<a class="kaart klikbaar" href="/familie">
<span class="groot">Mijn mensen <span class="aantal">@(Gegevens?.AantalFamilie ?? 0)</span></span>
</a>
@* Twee richtingen, dus twee aantallen; die staan achter de titel in hetzelfde
rondje als de andere tegels, met hun naam erbij zodat je niet hoeft te raden
welke welke is. Zo past de tegel op één regel, even hoog als de andere drie. *@
<a class="kaart klikbaar" href="/momenten">
<span class="groot">
Momenten
<span class="aantal" title="@(Gegevens?.AantalVoorMij ?? 0) voor mij, @(Gegevens?.AantalVanMij ?? 0) van mij">
&darr;@(Gegevens?.AantalVoorMij ?? 0) &middot; &uarr;@(Gegevens?.AantalVanMij ?? 0)
</span>
@if (Gegevens is { AantalNieuweBerichten: > 0 })
{
<span class="aantal nieuw">@Gegevens.AantalNieuweBerichten</span>
}
</span>
</a>
<a class="kaart klikbaar" href="/nalatenschap">
<span class="groot">Nalatenschap <span class="aantal">@(Gegevens?.AantalBeheerders ?? 0)</span></span>
</a>
</div>
@if (Gegevens is not null)
{
<details class="uitklap" style="margin-top:1.5rem">
<summary>Hoe ver vooruit wil je kijken?</summary>
<p>
Hierboven zie je wat er de komende tijd voor je klaarstaat. Zet het op 0 als je
liever verrast wordt.
</p>
<div class="rij">
<select @bind="Dagen" @bind:after="VooruitblikOpslaan" style="max-width:14rem">
<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>
</details>
}
@* Dezelfde uitleg als in de app en op de pagina /watismijnbesluit, maar hier als popup:
je hoeft dan niet weg van je overzicht en weer terug.
De privacyverklaring staat er om dezelfde reden bij, en op dezelfde plek als in de app.
Er is ook een pagina /privacy - die is er voor wie hem los wil kunnen delen en voor
Google Play, dat een adres wil dat je kunt aanklikken. Maar wie hier zit, hoort hem te
kunnen lezen zonder zijn overzicht kwijt te raken. De tekst zelf staat in
Privacytekst.razor, hetzelfde onderdeel dat /privacy en de app gebruiken. *@
<p style="margin-top:1rem">
<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 (Uitleg)
{
<Popup Titel="Wat is MijnBesluit?" Bevestigtekst="Sluiten" AlleenSluiten="true"
OpBevestigen="() => Uitleg = false" OpSluiten="() => Uitleg = false">
<Productuitleg />
</Popup>
}
@if (Privacy)
{
<Popup Titel="Privacy" Bevestigtekst="Sluiten" AlleenSluiten="true"
OpBevestigen="() => Privacy = false" OpSluiten="() => Privacy = false">
<Privacytekst />
</Popup>
}
@* Zelfde tekst als in de app: 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>
}
<div class="kaart">
<h2>Waar is MijnBesluit voor?</h2>
<p class="zacht" style="margin-bottom:.8rem">
Voor alles wat je zou willen doorgeven maar wat nu alleen in jouw hoofd zit.
</p>
<ul class="zacht" style="padding-left:1.2rem;margin:0">
<li>
<strong>Je kluis</strong> &mdash; wachtwoorden, de pincode van je telefoon,
polissen, documenten, je uitvaartwensen. Alles versleuteld, en jij bepaalt per
onderdeel of het alleen voor jou is, pas na je overlijden vrijkomt, of op een
datum die je zelf kiest.
</li>
<li>
<strong>Je mensen</strong> &mdash; familie en vrienden die je toevoegt en
uitnodigt. Zolang iemand de uitnodiging niet heeft aangenomen, beheer jij dat
profiel; daarna is het van hem of haar.
</li>
<li>
<strong>Momenten</strong> &mdash; een tekst- of videobericht dat je klaarzet
voor iemand. Te bezorgen op een datum die jij kiest, of pas na je overlijden.
Een verjaardag die je niet meer meemaakt, of gewoon iets wat je nog wilt zeggen.
</li>
<li>
<strong>Je nalatenschap</strong> &mdash; wie er straks mag regelen wat er
geregeld moet worden. Die persoon krijgt pas toegang als je overlijden is
bevestigd door meerdere mensen én jij er zelf niet op hebt gereageerd.
</li>
</ul>
</div>
</Authorized>
</AuthorizeView>
@code {
[CascadingParameter] private Task<AuthenticationState>? AuthState { get; set; }
private long _gebruikerId, _persoonId;
private Overzicht? Gegevens;
private int Dagen = 30;
/// <summary>Profielen die anderen op mijn adres hebben aangemaakt; alleen ik kan zeggen of ik dat ben.</summary>
private IReadOnlyList<AccountService.Aantekeningaanbod> Aantekeningen =
Array.Empty<AccountService.Aantekeningaanbod>();
/// <summary>Staat de uitleg open?</summary>
private bool Uitleg;
/// <summary>Staat de privacyverklaring open?</summary>
private bool Privacy;
private bool VersiehistorieOpen;
/// <summary>De advertentie voor deze keer, of null als er geen is.</summary>
private Campagne? Reclame;
private bool Bezig;
private string? Fout, Melding;
protected override async Task OnInitializedAsync()
{
if (AuthState is null) return;
var user = (await AuthState).User;
_gebruikerId = user.UserId() ?? 0;
_persoonId = user.PersonId() ?? 0;
await Laden();
}
private async Task Laden()
{
if (_gebruikerId == 0) return;
var uitkomst = await Overzichten.VoorAsync(_gebruikerId);
if (!uitkomst.Succeeded) { Fout = uitkomst.Error; return; }
Fout = null;
Gegevens = uitkomst.Value;
Dagen = Gegevens!.VooruitblikDagen;
// De keuze en het tellen horen bij elkaar: wat hier wordt uitgekozen komt ook echt in
// beeld. Eén advertentie per keer dat het overzicht wordt geopend; zie docs/Adverteren.md.
//
// Gaat er hier iets mis, dan hoort het overzicht er gewoon te staan. Reclame is het
// minst belangrijke op dit scherm.
try
{
Reclame = await Adverteren.KiesAsync();
if (Reclame is not null) await Adverteren.TelVertoningAsync(Reclame.Id);
}
catch (Exception)
{
Reclame = null;
}
// Heeft iemand een profiel voor mij aangemaakt? Apart opgehaald, en een fout hier hoort
// het overzicht niet weg te houden: dit is een vraag die best een keer later mag komen.
try { Aantekeningen = await Accounts.AantekeningenOverMijAsync(_gebruikerId); }
catch (Exception) { Aantekeningen = Array.Empty<AccountService.Aantekeningaanbod>(); }
}
/// <summary>"Ja, dat ben ik." Daarna staat alles wat die ander over je had bij jou.</summary>
private async Task Overnemen(long persoonId)
{
Bezig = true;
try
{
var uitkomst = await Accounts.NeemAantekeningOverAsync(persoonId, _gebruikerId, Samenvoegen);
Fout = uitkomst.Succeeded ? null : uitkomst.Error;
Melding = uitkomst.Succeeded ? "Klaar. Het profiel staat nu bij jou." : null;
if (uitkomst.Succeeded) await Laden();
}
finally { Bezig = false; }
}
/// <summary>"Nee, dat ben ik niet." De vraag komt niet terug.</summary>
private async Task Afwijzen(long persoonId)
{
Bezig = true;
try
{
var uitkomst = await Accounts.WijsAantekeningAfAsync(persoonId, _gebruikerId);
Fout = uitkomst.Succeeded ? null : uitkomst.Error;
if (uitkomst.Succeeded)
Aantekeningen = Aantekeningen.Where(a => a.PersoonId != persoonId).ToList();
}
finally { Bezig = false; }
}
private async Task VooruitblikOpslaan()
{
Bezig = true;
try
{
var uitkomst = await Overzichten.VooruitblikAsync(_gebruikerId, Dagen);
if (!uitkomst.Succeeded) { Fout = uitkomst.Error; return; }
await Laden();
}
finally { Bezig = false; }
}
private async Task IkBenErNog()
{
Bezig = true;
try
{
var uitkomst = await NalatenschapService.ObjectAsync(_persoonId, _gebruikerId);
Melding = uitkomst.Succeeded ? "Genoteerd. De melding is ingetrokken." : null;
Fout = uitkomst.Succeeded ? null : uitkomst.Error;
if (uitkomst.Succeeded) await Laden();
}
finally { Bezig = false; }
}
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
_ => "•"
};
/// <summary>
/// "Dag Jonathan" als we de naam kennen, anders alleen "Welkom". Nooit het e-mailadres:
/// dat is geen naam, en het staat raar boven een scherm over je nalatenschap.
/// </summary>
private static string Begroeting(string naam)
{
var voornaam = naam.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
return string.IsNullOrWhiteSpace(voornaam) ? "Welkom" : $"Dag {voornaam}";
}
}
@@ -0,0 +1,216 @@
@page "/inloggen"
@using System.ComponentModel.DataAnnotations
@using System.Security.Claims
@using Microsoft.AspNetCore.Authentication
@using Microsoft.AspNetCore.Authentication.Cookies
@inject AccountService Accounts
@inject IHttpContextAccessor HttpContexten
@inject NavigationManager Navigatie
@* Deze pagina wordt statisch weergegeven: alleen zo kan er bij het inloggen een cookie worden
gezet. Het gaat in twee stappen, net als in de WinDev-versie: eerst het e-mailadres, dan de
code die per e-mail binnenkomt.
Let op bij het aanpassen: alle drie de formulieren worden ALTIJD gerenderd, en alleen
verborgen met het hidden-attribuut. Bij een statische pagina is elke POST een nieuwe
pagina-instantie; een formulier dat op dat moment niet gerenderd wordt, bestaat voor de
server niet en levert "Cannot submit the form ... because no form on the page currently has
that name" op. *@
<PageTitle>Inloggen - MijnBesluit</PageTitle>
@* "inlog-scherm": een merkteken voor app.css, zodat de kopbalk op een telefoon zijn logo kan
laten vallen. Bij het inloggen mist er nog een cookie met de naam van de gebruiker; het
logo is dan het enige dat de balk vult, en op een telefoon duwt dat het inlogblok een heel
stuk lager dan nodig. Op een groter scherm is er ruimte genoeg en blijft het logo gewoon
staan - zie de media-query bij ".inlog-scherm" in app.css. *@
<div class="middenblok inlog-scherm">
<h1>Log in</h1>
@if (Fout is not null) { <div class="melding fout">@Fout</div> }
@if (Gelukt is not null) { <div class="melding goed">@Gelukt</div> }
<div hidden="@ToonCodeveld">
<EditForm Model="Aanvraag" FormName="code-aanvragen" OnValidSubmit="CodeAanvragen" method="post">
<DataAnnotationsValidator />
<div class="kaart">
<div class="veld">
<label for="email">E-mailadres</label>
<InputText id="email" @bind-Value="Aanvraag.Email" type="email"
autocomplete="username" inputmode="email" placeholder="alles in kleine letters" />
<ValidationMessage For="() => Aanvraag.Email" class="uitleg" />
</div>
<details class="uitklap">
<summary>Hoe werkt inloggen?</summary>
<p>
Wij sturen naar het opgegeven e-mailadres een code om het inloggen af te ronden.
Die code vul je hierna in. Je hoeft dus geen wachtwoord te onthouden.
</p>
</details>
<button type="submit" class="knop vol">Code aanvragen</button>
</div>
</EditForm>
</div>
<div hidden="@(!ToonCodeveld)">
<EditForm Model="Inlog" FormName="inloggen" OnValidSubmit="Aanmelden" method="post">
<DataAnnotationsValidator />
<div class="kaart">
<div class="veld">
<label for="email2">E-mailadres</label>
<InputText id="email2" @bind-Value="Inlog.Email" type="email" readonly />
</div>
<div class="veld">
<label for="code">Code</label>
<InputText id="code" @bind-Value="Inlog.Code" class="codeveld" inputmode="numeric"
autocomplete="one-time-code" maxlength="6" />
<div class="uitleg">
Vul hier de code in die wij hebben gestuurd naar
<span class="naar-adres">@AfgeschermdAdres</span>.
De code is @((int)AccountService.CodeGeldigheid.TotalMinutes) minuten geldig.
</div>
<ValidationMessage For="() => Inlog.Code" class="uitleg" />
</div>
<button type="submit" class="knop vol">Inloggen</button>
</div>
</EditForm>
<EditForm Model="Opnieuw" FormName="nieuwe-code" OnValidSubmit="OpnieuwAanvragen" method="post">
<div class="kaart">
<InputText @bind-Value="Opnieuw.Email" type="hidden" />
<div class="rij">
<div class="groei zacht">Geen e-mail ontvangen? Kijk ook even in je ongewenste post.</div>
<button type="submit" class="knop rustig">Nieuwe code aanvragen</button>
</div>
</div>
</EditForm>
</div>
<p class="zacht"><a href="/wat-is-mijnbesluit">Wat is MijnBesluit?</a></p>
<p class="zacht"><a href="/registreren">Ik ben nieuw en wil een account aanmaken</a></p>
<p class="zacht"><a href="/adverteren">Ik wil adverteren op MijnBesluit</a></p>
</div>
@code {
[SupplyParameterFromForm(FormName = "code-aanvragen")] private AanvraagGegevens Aanvraag { get; set; } = new();
[SupplyParameterFromForm(FormName = "inloggen")] private InlogGegevens Inlog { get; set; } = new();
[SupplyParameterFromForm(FormName = "nieuwe-code")] private AanvraagGegevens Opnieuw { get; set; } = new();
[SupplyParameterFromQuery(Name = "terug")] private string? Terug { get; set; }
private bool _codeVerstuurd;
private string? AfgeschermdAdres;
private string? Fout, Gelukt;
/// <summary>
/// Staat er al een e-mailadres in een van de formulieren van stap twee, dan is die stap
/// eerder bereikt en horen we het codeveld te tonen — ook bij de eerste render van een POST,
/// vóórdat de afhandeling heeft gedraaid.
/// </summary>
private bool ToonCodeveld =>
_codeVerstuurd || !string.IsNullOrWhiteSpace(Inlog.Email) || !string.IsNullOrWhiteSpace(Opnieuw.Email);
protected override void OnParametersSet()
{
if (AfgeschermdAdres is null && ToonCodeveld)
{
var adres = !string.IsNullOrWhiteSpace(Inlog.Email) ? Inlog.Email : Opnieuw.Email;
AfgeschermdAdres = Afschermen(adres);
}
}
private Task CodeAanvragen() => StuurCodeAsync(Aanvraag.Email);
private Task OpnieuwAanvragen() => StuurCodeAsync(Opnieuw.Email);
private async Task StuurCodeAsync(string email)
{
var context = HttpContexten.HttpContext!;
var resultaat = await Accounts.VraagInlogcodeAanAsync(
email, context.Connection.RemoteIpAddress?.ToString(), context.RequestAborted);
if (!resultaat.Succeeded)
{
Fout = resultaat.Error;
Gelukt = null;
return;
}
_codeVerstuurd = true;
AfgeschermdAdres = resultaat.Value!.AfgeschermdAdres;
Inlog = new InlogGegevens { Email = resultaat.Value.Email };
Opnieuw = new AanvraagGegevens { Email = resultaat.Value.Email };
Gelukt = "We hebben je een code gestuurd. Vul hem hieronder in.";
Fout = null;
}
private async Task Aanmelden()
{
var context = HttpContexten.HttpContext!;
var resultaat = await Accounts.LogInMetCodeAsync(Inlog.Email, Inlog.Code, context.RequestAborted);
if (!resultaat.Succeeded)
{
// Het codeveld blijft staan, met de melding erboven.
_codeVerstuurd = true;
Opnieuw = new AanvraagGegevens { Email = Inlog.Email };
AfgeschermdAdres = Afschermen(Inlog.Email);
Fout = resultaat.Error;
Gelukt = null;
return;
}
await MeldAanAsync(context, resultaat.Value!);
Navigatie.NavigateTo(VeiligeBestemming(Terug), forceLoad: true);
}
/// <summary>Zet het inlogcookie met wie er is ingelogd.</summary>
internal static async Task MeldAanAsync(HttpContext context, User gebruiker)
{
// DisplayName is een lege tekst als er geen naam is ingevuld, niet null - de oude
// terugval met ?? greep daar dus niet op en liet het e-mailadres staan. Een adres is
// geen naam; staat er niets, dan laat het scherm de begroeting gewoon weg.
var naam = gebruiker.Person?.DisplayName;
if (string.IsNullOrWhiteSpace(naam)) naam = "";
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, gebruiker.Id.ToString()),
new(ClaimTypes.Email, gebruiker.Email),
new(ClaimTypes.Name, naam),
new(HuidigeGebruiker.PersonIdClaim, gebruiker.PersonId.ToString()),
new("stamp", gebruiker.SecurityStamp.ToString()),
// Zonder deze claim toont MainLayout het menu-item "Beheer" nooit, ook niet voor
// wie in de database wel IsBeheerder = true heeft staan - dat is precies wat hij
// moet aansturen, zie HuidigeGebruiker.BeheerClaim.
new(HuidigeGebruiker.BeheerClaim, gebruiker.IsBeheerder ? "ja" : "nee")
};
await context.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme)));
}
/// <summary>Accepteert alleen paden binnen de site, zodat een link niet naar buiten kan doorsturen.</summary>
private static string VeiligeBestemming(string? terug) =>
string.IsNullOrWhiteSpace(terug) || terug.Contains("//") || terug.StartsWith('/') ? "/" : "/" + terug;
private static string Afschermen(string email) => new CodeVerstuurd(email, DateTime.UtcNow).AfgeschermdAdres;
public class AanvraagGegevens
{
[Required(ErrorMessage = "Je moet een e-mailadres invullen!")]
[EmailAddress(ErrorMessage = "Dit is geen geldig e-mailadres!")]
public string Email { get; set; } = "";
}
public class InlogGegevens
{
[Required] public string Email { get; set; } = "";
[Required(ErrorMessage = "Je moet een code invullen!")]
public string Code { get; set; } = "";
}
}
@@ -0,0 +1,665 @@
@page "/kluis"
@attribute [Authorize]
@inject VaultService KluisService
@inject LegacyService BeheerService
@inject IJSRuntime JS
@rendermode InteractiveServer
<PageTitle>Mijn kluis - MijnBesluit</PageTitle>
<h1>Mijn kluis</h1>
<p class="zacht">
Wat je hier vastlegt staat versleuteld opgeslagen. Per item bepaal je zelf of het privé blijft,
of dat het vrijkomt na je overlijden of op een datum die jij kiest.
</p>
@if (Melding is not null) { <div class="melding @MeldingSoort">@Melding</div> }
<div class="kaart">
<div class="rij">
<div class="groei">
<label for="cat">Soort</label>
<select id="cat" @bind="Filter" @bind:after="Verversen">
<option value="">Alles</option>
@foreach (var c in Categorieen)
{
<option value="@((int)c)">@Naam(c)</option>
}
</select>
</div>
@* Geen "Sluiten" meer: het formulier staat nu in een popup en die heeft zijn eigen
Annuleren. Deze knop begint altijd met een leeg item. *@
<button class="knop" @onclick="Beginnen">Toevoegen</button>
</div>
</div>
@if (ToonFormulier)
{
@* De EditForm staat om de popup heen en niet erin: de invoervelden hebben zijn context
nodig, en de knoppen zitten in de popup zelf. Enter in een veld slaat nog steeds op. *@
<EditForm Model="Nieuw" OnValidSubmit="Opslaan" FormName="kluisitem">
<Popup Titel="@(BewerkId == 0 ? "Iets vastleggen" : "Item wijzigen")"
Bevestigtekst="@(BewerkId == 0 ? "Opslaan in de kluis" : "Wijziging opslaan")"
OpBevestigen="Opslaan" OpSluiten="Annuleren">
<div class="veld">
<label for="soort">Wat wil je vastleggen?</label>
<InputSelect id="soort" @bind-Value="Nieuw.Categorie">
@foreach (var c in Categorieen) { <option value="@c">@Naam(c)</option> }
</InputSelect>
</div>
<div class="veld">
<label for="titel">Titel</label>
<InputText id="titel" @bind-Value="Nieuw.Titel" placeholder="@Voorbeeld(Nieuw.Categorie)" />
<div class="uitleg">De titel is niet versleuteld, zodat je kunt zoeken. Zet er dus niets geheims in.</div>
</div>
@switch (Nieuw.Categorie)
{
case VaultCategory.Wachtwoord:
<div class="raster twee">
<div class="veld"><label>Website</label><InputText @bind-Value="Nieuw.Website" /></div>
<div class="veld"><label>Gebruikersnaam</label><InputText @bind-Value="Nieuw.Gebruikersnaam" /></div>
</div>
<div class="veld"><label>Wachtwoord</label><InputText @bind-Value="Nieuw.Geheim" type="password" /></div>
break;
case VaultCategory.SocialMedia:
<div class="raster twee">
<div class="veld">
<label>Welk medium?</label>
<InputSelect @bind-Value="Nieuw.Platform">
<option value="">Kies&hellip;</option>
@foreach (var naam in SocialeMedia.Namen)
{
<option value="@naam">@naam</option>
}
</InputSelect>
</div>
<div class="veld"><label>Gebruikersnaam</label><InputText @bind-Value="Nieuw.Gebruikersnaam" /></div>
</div>
@if (Nieuw.Platform == SocialeMedia.Anders)
{
<div class="veld"><label>Welke dan?</label><InputText @bind-Value="Nieuw.PlatformAnders" /></div>
}
<div class="veld"><label>Wachtwoord</label><InputText @bind-Value="Nieuw.Geheim" type="password" /></div>
<div class="veld">
<label>Herstelcodes</label>
<InputTextArea @bind-Value="Nieuw.Herstelcodes" />
<div class="uitleg">De codes die je krijgt bij tweestapsverificatie. Zonder die codes komt niemand er nog in.</div>
</div>
break;
case VaultCategory.Apparaat:
<div class="veld"><label>Apparaat</label><InputText @bind-Value="Nieuw.Apparaat" /></div>
<div class="veld"><label>Gebruikersnaam</label><InputText @bind-Value="Nieuw.Gebruikersnaam" /></div>
<div class="veld"><label>Wachtwoord of code</label><InputText @bind-Value="Nieuw.Geheim" type="password" /></div>
break;
case VaultCategory.Pincode:
<div class="veld"><label>Waarvan</label><InputText @bind-Value="Nieuw.Apparaat" placeholder="Mijn telefoon" /></div>
<div class="veld"><label>Pincode</label><InputText @bind-Value="Nieuw.Pincode" type="password" /></div>
break;
case VaultCategory.Polis:
<div class="raster twee">
<div class="veld"><label>Verzekeraar</label><InputText @bind-Value="Nieuw.Verzekeraar" /></div>
<div class="veld"><label>Polisnummer</label><InputText @bind-Value="Nieuw.Polisnummer" /></div>
</div>
<div class="veld"><label>Telefoonnummer</label><InputText @bind-Value="Nieuw.Telefoon" /></div>
break;
case VaultCategory.Uitvaartwens:
<div class="raster twee">
<div class="veld">
<label>Begraven of cremeren</label>
<InputSelect @bind-Value="Nieuw.BegravenOfCremeren">
<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>
</InputSelect>
</div>
<div class="veld"><label>Locatie</label><InputText @bind-Value="Nieuw.Locatie" /></div>
</div>
<div class="veld"><label>Muziek</label><InputTextArea id="muziek" rows="1" @bind-Value="Nieuw.Muziek" /></div>
<div class="veld"><label>Wie mag spreken</label><InputText @bind-Value="Nieuw.Sprekers" /></div>
<div class="veld"><label>Verdere wensen</label><InputTextArea id="wensen" rows="4" @bind-Value="Nieuw.Wensen" /></div>
break;
case VaultCategory.Donorcodicil:
break;
}
@if (Nieuw.Categorie is VaultCategory.Uitvaartwens or VaultCategory.Donorcodicil)
{
<div class="veld">
<label>Bespreken met</label>
<InputSelect @bind-Value="Nieuw.BesprekenMetPersonId">
<option value="">Niemand (alleen voor mijzelf)</option>
@if (Beheerders is not null)
{
@foreach (var b in Beheerders)
{
<option value="@b.PersonId">@b.DisplayName</option>
}
}
</InputSelect>
<p class="zacht klein">
Kies je hier iemand, dan kan diegene dit item meteen lezen en met je bespreken -
je hoeft niet te wachten tot na je overlijden. Kies je niemand, dan blijft dit item
zoals altijd alleen voor jezelf.
</p>
</div>
}
<div class="veld">
<label for="notitie">Toelichting</label>
<InputTextArea id="notitie" @bind-Value="Nieuw.Notitie" />
</div>
@if (BewerkId == 0)
{
<div class="veld">
<label>Foto's en documenten</label>
<div class="uitleg">
Die kun je toevoegen zodra dit is opgeslagen; ze horen bij het item en
dat moet er eerst zijn.
</div>
</div>
}
else
{
<Bijlagenblok Bijlagen="@(Bijlagen ?? Array.Empty<VaultFile>())" Bezig="@Uploaden"
Uitleg="Een foto van je polis, een scan van je testament, de handleiding van de kluis zelf."
OpKiezen="BijlageKiezen"
OpVerwijderen="BijlageWeg" />
}
<div class="veld">
<label>Wanneer mag een ander dit zien?</label>
<div class="keuze">
<input type="radio" id="r0" name="vrijgave" checked="@(Nieuw.Vrijgave == ReleasePolicy.AlleenIkzelf)"
@onchange="@(() => Nieuw.Vrijgave = ReleasePolicy.AlleenIkzelf)" />
<label for="r0">Nooit &mdash; alleen ik kan hier bij</label>
</div>
<div class="keuze">
<input type="radio" id="r1" name="vrijgave" checked="@(Nieuw.Vrijgave == ReleasePolicy.NaOverlijden)"
@onchange="@(() => Nieuw.Vrijgave = ReleasePolicy.NaOverlijden)" />
<label for="r1">Na mijn overlijden, voor mijn nalatenschapsbeheerder</label>
</div>
<div class="keuze">
<input type="radio" id="r2" name="vrijgave" checked="@(Nieuw.Vrijgave == ReleasePolicy.OpDatum)"
@onchange="@(() => Nieuw.Vrijgave = ReleasePolicy.OpDatum)" />
<label for="r2">Op een datum</label>
</div>
@if (Nieuw.Vrijgave == ReleasePolicy.OpDatum)
{
<InputDate @bind-Value="Nieuw.VrijgaveDatum" />
}
</div>
</Popup>
</EditForm>
}
@if (Items is null)
{
<p class="zacht">Bezig met laden&hellip;</p>
}
else if (Items.Count == 0)
{
<div class="leeg">
<span class="teken">&#128274;</span>
Je kluis is nog leeg. Begin bijvoorbeeld met je uitvaartwensen.
</div>
}
else
{
<div class="stapel">
@foreach (var item in Items)
{
<div class="kaart">
<div class="rij">
<div class="groei">
<div class="groot">@item.Title</div>
<div class="zacht">@Naam(item.Category) &middot; @Vrijgave(item)</div>
@if (item.SharedManagerPersonId is { } gedeeldMet)
{
<div class="zacht">&#128172; Wordt besproken met @BeheerderNaam(gedeeldMet)</div>
}
</div>
<button class="knop rustig" @onclick="() => Openen(item.Id)">
@(Geopend == item.Id ? "Verbergen" : "Bekijken")
</button>
</div>
@if (Weghalen == item.Id)
{
<div class="melding let-op" style="margin-top:.9rem">
<strong>@item.Title</strong> verwijderen? Wat erin staat is daarna weg.
<div class="rij" style="margin-top:.7rem">
<button class="knop gevaar" @onclick="() => Verwijderen(item.Id)">Ja, verwijderen</button>
<button class="knop rustig" @onclick="() => Weghalen = null">Nee, bewaren</button>
</div>
</div>
}
else
{
<div class="rij" style="margin-top:.9rem">
<button class="knop rustig" @onclick="() => Wijzigen(item)">Wijzigen</button>
<button class="knop rustig" @onclick="() => Weghalen = item.Id">Verwijderen</button>
</div>
}
@if (Geopend == item.Id && Inhoud is not null)
{
<hr />
<dl style="margin:0">
@foreach (var (label, waarde) in Velden(Inhoud))
{
<div class="rij" style="padding:.25rem 0">
<span class="zacht" style="min-width:150px">@label</span>
<span class="groei">@waarde</span>
</div>
}
</dl>
<hr />
<h3>Bijlagen</h3>
@if (Bijlagen is { Count: > 0 })
{
<div class="stapel">
@foreach (var bijlage in Bijlagen)
{
<div class="rij">
<div class="groei">
<a href="/kluis/bestand/@bijlage.Id" target="_blank">@bijlage.FileName</a>
<div class="zacht">@Grootte(bijlage.SizeBytes) &middot; @Datums.Datum(bijlage.CreatedAt)</div>
</div>
<button class="knop rustig" @onclick="() => WeghalenBijlage = bijlage.Id">Verwijderen</button>
</div>
@if (WeghalenBijlage == bijlage.Id)
{
<div class="melding let-op" style="margin-bottom:.6rem">
<strong>@bijlage.FileName</strong> verwijderen?
<div class="rij" style="margin-top:.5rem">
<button class="knop gevaar" @onclick="() => BijlageWegBevestigd(bijlage.Id)">Ja, verwijderen</button>
<button class="knop rustig" @onclick="() => WeghalenBijlage = null">Nee, laat staan</button>
</div>
</div>
}
@if (bijlage.ContentType.StartsWith("image/"))
{
<img src="/kluis/bestand/@bijlage.Id" alt="@bijlage.FileName"
style="max-width:100%;border-radius:10px;margin:.4rem 0 1rem" />
}
else if (bijlage.ContentType.StartsWith("video/"))
{
<video src="/kluis/bestand/@bijlage.Id" controls
style="max-width:100%;border-radius:10px;margin:.4rem 0 1rem"></video>
}
}
</div>
}
else
{
<p class="zacht">Nog geen bijlagen. Denk aan een foto van je polis, of een scan van je testament.</p>
}
<div class="veld" style="margin-top:.8rem">
<label for="bijlage">Bestand toevoegen</label>
<InputFile id="bijlage" OnChange="BijlageKiezen" disabled="@Uploaden" multiple />
<div class="uitleg">
Maximaal @(VaultService.MaxBestandsgrootte / 1024 / 1024) MB per bestand. Alles wordt
versleuteld opgeslagen, net als de rest van je kluis.
</div>
</div>
@if (Uploaden)
{
<p class="zacht">Bezig met opslaan&hellip;</p>
}
}
</div>
}
</div>
}
@code {
[CascadingParameter] private Task<AuthenticationState>? AuthState { get; set; }
private long _gebruikerId, _persoonId;
private IReadOnlyList<VaultItem>? Items;
private VaultPayload? Inhoud;
private long? Geopend;
private bool ToonFormulier;
private string Filter = "";
private string? Melding, MeldingSoort;
private NieuwItem Nieuw = new();
private IReadOnlyList<LegacyService.BeheerderView>? Beheerders;
/// <summary>Het item dat bewerkt wordt; 0 betekent een nieuw item.</summary>
private long BewerkId;
/// <summary>Het item dat op verwijderen staat te wachten.</summary>
private long? Weghalen;
/// <summary>Welke bijlage bij het bekeken item op een "weet je het zeker?" staat te wachten.</summary>
private long? WeghalenBijlage;
/// <summary>De bijlagen bij het item dat openstaat of dat in het wijzigscherm staat.</summary>
private IReadOnlyList<VaultFile>? Bijlagen;
private bool Uploaden;
/// <summary>Zoveel bestanden tegelijk kiezen mag. Blazor wil hier een bovengrens.</summary>
private const int MaxTegelijk = 10;
private static readonly VaultCategory[] Categorieen =
{
VaultCategory.Uitvaartwens, VaultCategory.Donorcodicil, VaultCategory.Polis, VaultCategory.Wachtwoord,
VaultCategory.SocialMedia, VaultCategory.Apparaat, VaultCategory.Pincode,
VaultCategory.Rijbewijs, VaultCategory.Paspoort,
VaultCategory.Document, VaultCategory.Notitie, VaultCategory.Overig
};
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (ToonFormulier)
{
await JS.InvokeVoidAsync("mijnbesluit.laatMeegroeien", "muziek");
await JS.InvokeVoidAsync("mijnbesluit.laatMeegroeien", "wensen");
}
}
protected override async Task OnInitializedAsync()
{
if (AuthState is null) return;
var user = (await AuthState).User;
_gebruikerId = user.UserId() ?? 0;
_persoonId = user.PersonId() ?? 0;
Beheerders = await BeheerService.ListManagersAsync(_persoonId);
await Verversen();
}
private async Task Verversen()
{
VaultCategory? categorie = int.TryParse(Filter, out var c) ? (VaultCategory)c : null;
Items = await KluisService.ListAsync(_persoonId, categorie);
Geopend = null;
Inhoud = null;
}
private async Task Openen(long id)
{
if (Geopend == id) { Geopend = null; Inhoud = null; Bijlagen = null; return; }
var resultaat = await KluisService.OpenAsync(id, _gebruikerId);
if (resultaat.Succeeded)
{
Geopend = id;
Inhoud = resultaat.Value;
Melding = null;
await BijlagenLaden();
}
else { Melding = resultaat.Error; MeldingSoort = "fout"; }
}
/// <summary>
/// Bij welk item een bijlage hoort: het item dat in het wijzigscherm staat, of het item dat
/// je hebt opengeklapt. Zo werken de knoppen op allebei de plekken.
/// </summary>
private long? BijlageDoel => BewerkId != 0 ? BewerkId : Geopend;
private async Task BijlagenLaden()
{
if (BijlageDoel is not { } id) { Bijlagen = null; return; }
var resultaat = await KluisService.ListFilesAsync(id, _gebruikerId);
Bijlagen = resultaat.Succeeded ? resultaat.Value : Array.Empty<VaultFile>();
}
private async Task BijlageKiezen(InputFileChangeEventArgs e)
{
if (BijlageDoel is not { } itemId) return;
Uploaden = true;
Melding = null;
try
{
foreach (var bestand in e.GetMultipleFiles(MaxTegelijk))
{
if (bestand.Size > VaultService.MaxBestandsgrootte)
{
Melding = $"{bestand.Name} is te groot. Maximaal {VaultService.MaxBestandsgrootte / 1024 / 1024} MB.";
MeldingSoort = "fout";
continue;
}
using var stroom = bestand.OpenReadStream(VaultService.MaxBestandsgrootte);
using var buffer = new MemoryStream();
await stroom.CopyToAsync(buffer);
var resultaat = await KluisService.StoreFileAsync(_gebruikerId, bestand.Name,
string.IsNullOrWhiteSpace(bestand.ContentType) ? "application/octet-stream" : bestand.ContentType,
buffer.ToArray(), itemId);
if (resultaat.Succeeded) { Melding = $"{bestand.Name} is toegevoegd."; MeldingSoort = "goed"; }
else { Melding = resultaat.Error; MeldingSoort = "fout"; }
}
await BijlagenLaden();
}
catch (Exception ex)
{
Melding = $"Het bestand kon niet worden gelezen: {ex.Message}";
MeldingSoort = "fout";
}
finally
{
Uploaden = false;
}
}
private async Task BijlageWeg(long bestandId)
{
var resultaat = await KluisService.DeleteFileAsync(bestandId, _gebruikerId);
Melding = resultaat.Succeeded ? "De bijlage is verwijderd." : resultaat.Error;
MeldingSoort = resultaat.Succeeded ? "goed" : "fout";
await BijlagenLaden();
}
private async Task BijlageWegBevestigd(long bestandId)
{
WeghalenBijlage = null;
await BijlageWeg(bestandId);
}
private static string Grootte(long bytes) => bytes >= 1024 * 1024
? $"{bytes / 1024d / 1024d:0.#} MB"
: $"{Math.Max(1, bytes / 1024)} kB";
private async Task Wijzigen(VaultItem item)
{
// De inhoud staat versleuteld; die halen we op om het formulier te vullen.
var resultaat = await KluisService.OpenAsync(item.Id, _gebruikerId);
if (!resultaat.Succeeded)
{
Melding = resultaat.Error;
MeldingSoort = "fout";
return;
}
var p = resultaat.Value!;
Nieuw = new NieuwItem
{
Categorie = item.Category,
Titel = item.Title,
Notitie = p.Notes ?? "",
Website = p.Website ?? "",
Gebruikersnaam = p.Username ?? "",
Geheim = p.Secret ?? "",
Herstelcodes = p.RecoveryCodes ?? "",
Platform = p.Platform ?? "",
PlatformAnders = p.PlatformAnders ?? "",
Pincode = p.Pincode ?? "",
Apparaat = p.DeviceName ?? "",
Verzekeraar = p.Insurer ?? "",
Polisnummer = p.PolicyNumber ?? "",
Telefoon = p.ContactPhone ?? "",
BegravenOfCremeren = p.BurialOrCremation ?? "",
Locatie = p.Location ?? "",
Muziek = p.Music ?? "",
Sprekers = p.Speakers ?? "",
Wensen = p.Wishes ?? "",
BesprekenMetPersonId = item.SharedManagerPersonId,
Vrijgave = item.ReleasePolicy,
VrijgaveDatum = item.ReleaseAt is { } r ? DateOnly.FromDateTime(r) : null
};
BewerkId = item.Id;
ToonFormulier = true;
Weghalen = null;
Geopend = null;
Inhoud = null;
Melding = null;
await BijlagenLaden();
}
/// <summary>Opent het formulier met een leeg item, ook als er net iets anders openstond.</summary>
private void Beginnen()
{
BewerkId = 0;
Nieuw = new NieuwItem();
Bijlagen = null;
ToonFormulier = true;
}
private void Annuleren()
{
BewerkId = 0;
Nieuw = new NieuwItem();
Bijlagen = null;
ToonFormulier = false;
}
private async Task Verwijderen(long id)
{
var resultaat = await KluisService.DeleteAsync(id, _gebruikerId);
Melding = resultaat.Succeeded ? "Het item is verwijderd." : resultaat.Error;
MeldingSoort = resultaat.Succeeded ? "goed" : "fout";
Weghalen = null;
if (resultaat.Succeeded) await Verversen();
}
private async Task Opslaan()
{
var payload = new VaultPayload
{
Notes = Leeg(Nieuw.Notitie),
Website = Leeg(Nieuw.Website),
Username = Leeg(Nieuw.Gebruikersnaam),
Secret = Leeg(Nieuw.Geheim),
RecoveryCodes = Leeg(Nieuw.Herstelcodes),
Platform = Leeg(Nieuw.Platform),
PlatformAnders = Leeg(Nieuw.PlatformAnders),
Pincode = Leeg(Nieuw.Pincode),
DeviceName = Leeg(Nieuw.Apparaat),
Insurer = Leeg(Nieuw.Verzekeraar),
PolicyNumber = Leeg(Nieuw.Polisnummer),
ContactPhone = Leeg(Nieuw.Telefoon),
BurialOrCremation = Leeg(Nieuw.BegravenOfCremeren),
Location = Leeg(Nieuw.Locatie),
Music = Leeg(Nieuw.Muziek),
Speakers = Leeg(Nieuw.Sprekers),
Wishes = Leeg(Nieuw.Wensen)
};
DateTime? datum = Nieuw.Vrijgave == ReleasePolicy.OpDatum && Nieuw.VrijgaveDatum is { } d
? d.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc) : null;
// Nieuw geeft het item terug, wijzigen alleen goed of fout; hier maken we er één uitkomst van.
Result resultaat;
if (BewerkId == 0)
{
var gemaakt = await KluisService.CreateAsync(_gebruikerId, Nieuw.Categorie, Nieuw.Titel, payload,
Nieuw.Vrijgave, datum, Nieuw.BesprekenMetPersonId);
resultaat = gemaakt.Succeeded ? Result.Ok() : Result.Fail(gemaakt.Error!);
}
else
{
resultaat = await KluisService.UpdateAsync(BewerkId, _gebruikerId, Nieuw.Categorie, Nieuw.Titel,
payload, Nieuw.Vrijgave, datum, Nieuw.BesprekenMetPersonId);
}
if (resultaat.Succeeded)
{
Melding = BewerkId == 0 ? "Opgeslagen in je kluis." : "De wijziging is opgeslagen.";
MeldingSoort = "goed";
BewerkId = 0;
Nieuw = new NieuwItem();
Bijlagen = null;
ToonFormulier = false;
await Verversen();
}
else { Melding = resultaat.Error; MeldingSoort = "fout"; }
}
private static string? Leeg(string? s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim();
private string BeheerderNaam(long personId) =>
Beheerders?.FirstOrDefault(b => b.PersonId == personId)?.DisplayName ?? "een beheerder";
// Velden() en Naam() staan in Onderdelen/Kluisweergave.cs: de nalatenschapspagina toont
// precies hetzelfde, en twee kopieën van die lijst lopen vroeg of laat uit elkaar.
private static IEnumerable<(string, string)> Velden(VaultPayload p) => Kluisweergave.Velden(p);
private static string Naam(VaultCategory c) => Kluisweergave.Naam(c);
private static string Voorbeeld(VaultCategory c) => c switch
{
VaultCategory.Uitvaartwens => "Mijn uitvaart",
VaultCategory.Donorcodicil => "Mijn donorcodicil",
VaultCategory.Polis => "Uitvaartverzekering",
VaultCategory.Wachtwoord => "Inloggen bij de bank",
VaultCategory.Apparaat => "Laptop",
VaultCategory.Pincode => "Telefoon",
VaultCategory.SocialMedia => "Mijn Facebook",
_ => ""
};
private static string Vrijgave(VaultItem item) => item.ReleasePolicy switch
{
ReleasePolicy.NaOverlijden => "vrij na mijn overlijden",
ReleasePolicy.OpDatum => $"vrij op {Datums.Datum(item.ReleaseAt)}",
_ => "alleen voor mij"
};
public class NieuwItem
{
public VaultCategory Categorie { get; set; } = VaultCategory.Uitvaartwens;
public string Titel { get; set; } = "";
public string? Notitie { get; set; }
public string? Website { get; set; }
public string? Gebruikersnaam { get; set; }
public string? Geheim { get; set; }
public string? Herstelcodes { get; set; }
public string? Platform { get; set; }
public string? PlatformAnders { get; set; }
public string? Pincode { get; set; }
public string? Apparaat { get; set; }
public string? Verzekeraar { get; set; }
public string? Polisnummer { get; set; }
public string? Telefoon { get; set; }
public string? BegravenOfCremeren { get; set; }
public string? Locatie { get; set; }
public string? Muziek { get; set; }
public string? Sprekers { get; set; }
public string? Wensen { get; set; }
public long? BesprekenMetPersonId { get; set; }
public ReleasePolicy Vrijgave { get; set; } = ReleasePolicy.NaOverlijden;
public DateOnly? VrijgaveDatum { get; set; }
}
}
@@ -0,0 +1,78 @@
@page "/leefnog/{Token}"
@inject LegacyService Nalatenschap
@inject NavigationManager Navigatie
<PageTitle>Ik ben er nog - MijnBesluit</PageTitle>
@* Bezwaar maken tegen een overlijdensmelding, zonder in te loggen.
Dat "zonder inloggen" is geen gemak maar noodzaak. Zodra een overlijden is bevestigd staat het
account op "overleden" en kun je er niet meer in. Wie dan ten onrechte dood is verklaard, zou
zonder deze pagina vastzitten - en juist diegene heeft het het hardst nodig.
De link uit de e-mail is het bewijs, net als bij een uitnodiging. Hij komt alleen aan op het
adres van de betrokkene zelf. *@
<div class="middenblok">
@if (Naam is not null)
{
<h1>Fijn dat je er nog bent</h1>
<div class="melding goed">
<p style="margin-top:0">
We hebben genoteerd dat @Naam gewoon in leven is. Alle meldingen zijn ingetrokken
en niemand heeft toegang gekregen tot wat er in de kluis staat.
</p>
<p style="margin-bottom:0">
Is er eerder al toegang verleend, dan is die weer ingetrokken. Je kunt gewoon
weer inloggen.
</p>
</div>
<p><a class="knop" href="/inloggen">Naar het inloggen</a></p>
}
else
{
<h1>Ben je er nog?</h1>
<p class="zacht">
Er is bij MijnBesluit gemeld dat je bent overleden. Klopt dat niet, laat het dan
hieronder weten. Alle meldingen vervallen dan, en als er al iemand toegang had
gekregen tot je kluis, wordt die weer ingetrokken.
</p>
@if (Fout is not null) { <div class="melding fout">@Fout</div> }
<EditForm Model="Invoer" FormName="leefnog" OnValidSubmit="Bezwaar" method="post">
<div class="kaart">
<p class="zacht" style="margin-top:0">
Je hoeft hier niet voor in te loggen. Deze link uit je e-mail is genoeg.
</p>
<button type="submit" class="knop vol">Ik ben er nog</button>
</div>
</EditForm>
}
</div>
@code {
[Parameter] public string Token { get; set; } = "";
/// <summary>
/// Het model van het formulier. Bewust GEEN [SupplyParameterFromForm]: er zijn geen
/// invoervelden, er valt niets te binden, en met dat kenmerk erop zonder FormName zet de
/// binder het model op null. Zie Uitnodiging.razor, waar dat een harde fout opleverde.
/// </summary>
private LeegFormulier Invoer { get; } = new();
/// <summary>De naam van wie er nog is; null zolang er nog niets is bevestigd.</summary>
private string? Naam;
private string? Fout;
private async Task Bezwaar()
{
var resultaat = await Nalatenschap.BezwaarMetTokenAsync(Token);
if (resultaat.Succeeded) Naam = resultaat.Value;
else Fout = resultaat.Error;
}
/// <summary>De link is zelf het bewijs; er hoeft niets ingevuld te worden.</summary>
public class LeegFormulier { }
}
@@ -0,0 +1,88 @@
@page "/moment/{Token}"
@using MijnBesluit.Application.Services
@inject MessageService Berichten
@* De landingspagina achter een kijklink: waar iemand zonder MijnBesluit-account een voor hem
klaargezet bericht kan bekijken, foto's/video's/documenten inbegrepen, zonder in te loggen.
Bewust GEEN [Authorize] en GEEN @rendermode InteractiveServer - dit is een gewone, statisch
gerenderde pagina die alleen leest, precies zoals Uitnodiging.razor voor een uitnodigingslink.
De link zelf is het bewijs: wie hem heeft, mag zien wat erachter zit. Zie
MessageService.ViewByTokenAsync voor hoe dat versleuteld blijft tot dit moment. *@
<PageTitle>Een bericht voor jou - MijnBesluit</PageTitle>
<div class="middenblok">
@if (Fout is not null)
{
<h1>Deze link werkt niet (meer)</h1>
<div class="melding fout">@Fout</div>
<p class="zacht" style="margin-top:1rem">
Vraag degene die het bericht klaarzette om het opnieuw te versturen. Heb je zelf al
een MijnBesluit, log dan in - daar staan al je berichten gewoon bij elkaar.
</p>
}
else if (Weergave is not null)
{
<h1>@Weergave.Subject</h1>
<p class="zacht">Van @Weergave.AfzenderNaam.</p>
<div class="kaart">
<p style="white-space:pre-wrap">@Weergave.Text</p>
@if (Weergave.VaultFileId is not null)
{
<div class="rij bijlage" style="margin-top:1rem">
@if (IsAfbeelding(Weergave.BestandsType))
{
<a href="/moment/@Token/bestand" target="_blank" title="Groot bekijken">
<img class="bijlage-klein" src="/moment/@Token/bestand" alt="@Weergave.BestandsNaam" />
</a>
}
else if (IsVideo(Weergave.BestandsType))
{
<video controls style="max-width:100%"><source src="/moment/@Token/bestand" type="@Weergave.BestandsType" /></video>
}
else
{
<span class="teken">&#128196;</span>
}
<div class="groei">
<a href="/moment/@Token/bestand" target="_blank">@Weergave.BestandsNaam</a>
</div>
</div>
}
</div>
@if (Weergave.KanNogClaimen)
{
<p class="zacht" style="margin-top:1rem">
Wil je zelf ook een MijnBesluit beginnen - bijvoorbeeld om je eigen berichten aan
anderen klaar te zetten? Vraag degene die je toevoegde om een uitnodiging, of
<a href="/">kijk eerst rond op de website</a>.
</p>
}
<p class="zacht" style="margin-top:1rem">Deze link blijft nog 30 dagen werken vanaf het versturen.</p>
}
</div>
@code {
[Parameter] public string Token { get; set; } = "";
private MessageService.MomentWeergave? Weergave;
private string? Fout;
protected override async Task OnInitializedAsync()
{
var resultaat = await Berichten.ViewByTokenAsync(Token);
if (resultaat.Succeeded) Weergave = resultaat.Value;
else Fout = resultaat.Error;
}
private static bool IsAfbeelding(string? contentType) =>
contentType is not null && contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase);
private static bool IsVideo(string? contentType) =>
contentType is not null && contentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase);
}
@@ -0,0 +1,526 @@
@page "/momenten"
@attribute [Authorize]
@using MijnBesluit.Application.Abstractions
@inject MessageService Berichten
@inject FamilyService Families
@inject IFamilyRepository FamilieOpslag
@inject VaultService Kluis
@rendermode InteractiveServer
<PageTitle>Momenten - MijnBesluit</PageTitle>
<h1>Momenten</h1>
<p class="zacht">
Berichten die voor jou klaarstaan, en berichten die jij hebt klaargezet voor een ander &mdash;
nu, op een datum, of pas als je er niet meer bent.
</p>
@if (Melding is not null) { <div class="melding @MeldingSoort">@Melding</div> }
<div class="kaart">
<div class="rij">
<button class="knop @(Tab == "in" ? "" : "rustig")" @onclick='() => Tab = "in"'>Voor mij (@(Inkomend?.Count ?? 0))</button>
<button class="knop @(Tab == "uit" ? "" : "rustig")" @onclick='() => Tab = "uit"'>Door mij (@(Uitgaand?.Count ?? 0))</button>
<div class="groei"></div>
@* Zie Kluis.razor: de popup heeft zijn eigen Annuleren, dus hier geen "Sluiten". *@
<button class="knop rustig" @onclick="Beginnen">Nieuw bericht</button>
</div>
</div>
@if (ToonFormulier)
{
@* Zie Kluis.razor: de EditForm staat om de popup heen omdat de invoervelden zijn context
nodig hebben, terwijl de knoppen onderin de popup horen. *@
<EditForm Model="Nieuw" OnValidSubmit="Versturen" FormName="bericht">
<Popup Titel="@(BewerkId == 0 ? "Een bericht klaarzetten" : "Bericht wijzigen")"
Bevestigtekst="@(BewerkId == 0 ? "Klaarzetten" : "Wijziging opslaan")"
OpBevestigen="Versturen" OpSluiten="Annuleren">
<div class="veld">
<label for="aan">Voor wie?</label>
<InputSelect id="aan" @bind-Value="Nieuw.AanPersoonId">
<option value="0">Kies iemand</option>
@if (Leden is not null)
{
@foreach (var lid in Leden.Where(l => l.PersonId != _persoonId))
{
<option value="@lid.PersonId">@lid.DisplayName</option>
}
}
</InputSelect>
</div>
<div class="veld">
<label for="onderwerp">Onderwerp</label>
<InputText id="onderwerp" @bind-Value="Nieuw.Onderwerp" />
</div>
<div class="veld">
<label for="tekst">Je bericht</label>
<InputTextArea id="tekst" @bind-Value="Nieuw.Tekst" />
<div class="uitleg">De tekst wordt versleuteld opgeslagen en is pas leesbaar zodra het bericht bezorgd is.</div>
</div>
@if (BewerkId == 0)
{
<div class="veld">
<label>Foto's en documenten</label>
<div class="uitleg">
Die kun je toevoegen zodra het bericht klaarstaat; ze horen bij het
bericht en dat moet er eerst zijn.
</div>
</div>
}
else
{
<Bijlagenblok Bijlagen="Bijlagen" Bezig="@Uploaden"
Uitleg="Een foto, een brief die je hebt ingescand, of een filmpje. Het gaat mee met het bericht."
OpKiezen="BijlageKiezen"
OpVerwijderen="BijlageWeg" />
}
<div class="veld">
<label>Wanneer mag het bezorgd worden?</label>
<div class="keuze">
<input type="radio" id="b0" name="wanneer" checked="@(Nieuw.Wanneer == "nu")"
@onchange='() => Nieuw.Wanneer = "nu"' />
<label for="b0">Meteen</label>
</div>
<div class="keuze">
<input type="radio" id="b1" name="wanneer" checked="@(Nieuw.Wanneer == "datum")"
@onchange='() => Nieuw.Wanneer = "datum"' />
<label for="b1">Op een datum</label>
</div>
<div class="keuze">
<input type="radio" id="b2" name="wanneer" checked="@(Nieuw.Wanneer == "overlijden")"
@onchange='() => Nieuw.Wanneer = "overlijden"' />
<label for="b2">Pas als ik er niet meer ben</label>
</div>
@if (Nieuw.Wanneer == "datum")
{
<InputDate @bind-Value="Nieuw.Datum" />
}
</div>
</Popup>
</EditForm>
}
@if (Tab == "in")
{
@if (Inkomend is null) { <p class="zacht">Bezig met laden&hellip;</p> }
else if (Inkomend.Count == 0)
{
<div class="leeg"><span class="teken">&#127916;</span> Er staan nog geen berichten voor je klaar.</div>
}
else
{
<div class="stapel">
@foreach (var bericht in Inkomend)
{
<div class="kaart">
<div class="rij">
<div class="groei">
<div class="groot">@bericht.Subject</div>
<div class="zacht">
Bezorgd @Datums.Datum(bericht.DeliveredAt)
@if (bericht.Status == MessageStatus.Bezorgd) { <span class="label geclaimd">Nieuw</span> }
</div>
</div>
<button class="knop rustig" @onclick="() => Lezen(bericht.Id)">Lezen</button>
</div>
@if (Verwijderen == bericht.Id)
{
<div class="melding let-op" style="margin-top:.9rem">
Dit bericht verwijderen? Het verdwijnt dan alleen bij jou; @NaamVan(bericht.FromPersonId)
houdt het eigen exemplaar gewoon.
<div class="rij" style="margin-top:.7rem">
<button class="knop gevaar" @onclick="() => VerwijderenAsync(bericht.Id)">Ja, verwijderen</button>
<button class="knop rustig" @onclick="() => Verwijderen = null">Nee, laat staan</button>
</div>
</div>
}
else
{
<div class="rij" style="margin-top:.5rem;justify-content:flex-end">
<button class="knop rustig klein" title="Verwijderen" aria-label="Verwijderen"
@onclick="() => Verwijderen = bericht.Id">&#128465;&#65039;</button>
</div>
}
</div>
}
</div>
}
}
@if (Tab == "uit")
{
@if (Uitgaand is null) { <p class="zacht">Bezig met laden&hellip;</p> }
else if (Uitgaand.Count == 0)
{
<div class="leeg"><span class="teken">&#127916;</span> Je hebt nog niets klaargezet.</div>
}
else
{
<div class="stapel">
@foreach (var bericht in Uitgaand)
{
<div class="kaart">
<div class="rij">
<div class="groei">
<div class="groot">@bericht.Subject</div>
<div class="zacht">Voor @NaamVan(bericht.ToPersonId) &middot; @Bezorging(bericht)</div>
</div>
<span class="label">@Stand(bericht.Status)</span>
</div>
@if (bericht.Status is MessageStatus.Bezorgd or MessageStatus.Gelezen)
{
@if (Verwijderen == bericht.Id)
{
<div class="melding let-op" style="margin-top:.9rem">
Dit bericht verwijderen? Het verdwijnt dan alleen bij jou; @NaamVan(bericht.ToPersonId)
houdt het eigen exemplaar gewoon.
<div class="rij" style="margin-top:.7rem">
<button class="knop gevaar" @onclick="() => VerwijderenAsync(bericht.Id)">Ja, verwijderen</button>
<button class="knop rustig" @onclick="() => Verwijderen = null">Nee, laat staan</button>
</div>
</div>
}
else
{
<div class="rij" style="margin-top:.9rem;justify-content:flex-end">
<button class="knop rustig klein" @onclick="() => BekijkenUit(bericht.Id)">Bekijken</button>
<button class="knop rustig klein" title="Verwijderen" aria-label="Verwijderen"
@onclick="() => Verwijderen = bericht.Id">&#128465;&#65039;</button>
</div>
}
}
else if (Weghalen == bericht.Id)
{
<div class="melding let-op" style="margin-top:.9rem">
Dit bericht terugtrekken? Het wordt dan nooit bezorgd.
<div class="rij" style="margin-top:.7rem">
<button class="knop gevaar" @onclick="() => Intrekken(bericht.Id)">Ja, terugtrekken</button>
<button class="knop rustig" @onclick="() => Weghalen = null">Nee, laat staan</button>
</div>
</div>
}
else
{
<div class="rij" style="margin-top:.9rem">
<button class="knop rustig" @onclick="() => Wijzigen(bericht)">Wijzigen</button>
<button class="knop rustig" @onclick="() => Weghalen = bericht.Id">Terugtrekken</button>
</div>
}
</div>
}
</div>
}
}
@* Het bericht dat openstaat, in een popup - zoals overal elders op de website. Er stond hier
eerder een tekst die onder de tegel zelf uitklapte; dat was de enige plek waar lezen niet in
een popup ging. *@
@if (Open is { } openId && Inkomend is not null && Inkomend.FirstOrDefault(b => b.Id == openId) is { } openBericht)
{
<Popup Titel="@openBericht.Subject" Bevestigtekst="Sluiten" AlleenSluiten="true"
OpBevestigen="Dichtdoen" OpSluiten="Dichtdoen">
<p class="zacht" style="margin-top:0">Bezorgd @Datums.Datum(openBericht.DeliveredAt)</p>
<p style="white-space:pre-wrap;margin:0">@Tekst</p>
</Popup>
}
@* Je eigen verzonden bericht ter inzage, in dezelfde opmaak als hierboven. *@
@if (OpenUit is { } openUitId && Uitgaand is not null && Uitgaand.FirstOrDefault(b => b.Id == openUitId) is { } openUitBericht)
{
<Popup Titel="@openUitBericht.Subject" Bevestigtekst="Sluiten" AlleenSluiten="true"
OpBevestigen="DichtdoenUit" OpSluiten="DichtdoenUit">
<p class="zacht" style="margin-top:0">Voor @NaamVan(openUitBericht.ToPersonId) &middot; @Bezorging(openUitBericht)</p>
@* Alleen zolang het bericht al bezorgd is: daarvoor staat er nog niets om te tonen. *@
@if (openUitBericht.DeliveredAt is not null)
{
<p class="zacht" style="margin:.3rem 0 0">
Verzonden: @Datums.Datum(openUitBericht.DeliveredAt)<br />
Gelezen: @(openUitBericht.ReadAt is { } gelezenOp ? Datums.Datum(gelezenOp) : "nog niet")
</p>
}
<p style="white-space:pre-wrap;margin:.8rem 0 0">@TekstUit</p>
</Popup>
}
@code {
[CascadingParameter] private Task<AuthenticationState>? AuthState { get; set; }
private long _gebruikerId, _persoonId, _familieId;
private IReadOnlyList<Message>? Inkomend, Uitgaand;
private IReadOnlyList<FamilyMemberView>? Leden;
private string Tab = "in";
private long? Open;
private string? Tekst, Melding, MeldingSoort;
/// <summary>Welk eigen verzonden bericht ter inzage openstaat; los van Open hierboven, dat
/// is voor "Voor mij".</summary>
private long? OpenUit;
private string? TekstUit;
private bool ToonFormulier;
private NieuwBericht Nieuw = new();
/// <summary>Het bericht dat bewerkt wordt; 0 betekent een nieuw bericht.</summary>
private long BewerkId;
/// <summary>Het bericht dat op terugtrekken staat te wachten.</summary>
private long? Weghalen;
/// <summary>Welke tegel om bevestiging vraagt om te verwijderen - bij "Voor mij" of 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;
/// <summary>De bijlagen bij het bericht dat in het wijzigscherm staat.</summary>
private IReadOnlyList<VaultFile> Bijlagen = Array.Empty<VaultFile>();
private bool Uploaden;
/// <summary>Zoveel bestanden tegelijk kiezen mag. Blazor wil hier een bovengrens.</summary>
private const int MaxTegelijk = 10;
protected override async Task OnInitializedAsync()
{
if (AuthState is null) return;
var user = (await AuthState).User;
_gebruikerId = user.UserId() ?? 0;
_persoonId = user.PersonId() ?? 0;
_familieId = (await FamilieOpslag.ListOwnedByPersonAsync(_persoonId)).FirstOrDefault()?.Id ?? 0;
if (_familieId != 0) Leden = await Families.ListMembersAsync(_familieId, _gebruikerId);
await Verversen();
}
private async Task Verversen()
{
Inkomend = await Berichten.InboxAsync(_persoonId);
Uitgaand = await Berichten.OutboxAsync(_persoonId);
Open = null; Tekst = null;
}
private async Task Lezen(long id)
{
var resultaat = await Berichten.ReadAsync(id, _gebruikerId);
if (resultaat.Succeeded) { Open = id; Tekst = resultaat.Value; }
else { Melding = resultaat.Error; MeldingSoort = "fout"; }
}
/// <summary>Doet de popup met het gelezen bericht weer dicht.</summary>
private void Dichtdoen()
{
Open = null;
Tekst = null;
}
/// <summary>
/// Je eigen verzonden bericht nog eens bekijken, ook als het al bezorgd is. Anders dan
/// Lezen hierboven (voor "Voor mij", dat meteen ook als gelezen markeert): hier verandert
/// er niets aan de status.
/// </summary>
private async Task BekijkenUit(long id)
{
var resultaat = await Berichten.ReadSentAsync(id, _gebruikerId);
if (resultaat.Succeeded) { OpenUit = id; TekstUit = resultaat.Value; }
else { Melding = resultaat.Error; MeldingSoort = "fout"; }
}
/// <summary>Doet de popup met het eigen verzonden bericht weer dicht.</summary>
private void DichtdoenUit()
{
OpenUit = null;
TekstUit = null;
}
private async Task Wijzigen(Message bericht)
{
// De tekst staat versleuteld; die halen we op om het formulier te vullen. Let op:
// ConceptAsync en niet ReadAsync - dat laatste is voor de ontvanger van een bezorgd bericht.
var resultaat = await Berichten.ConceptAsync(bericht.Id, _gebruikerId);
if (!resultaat.Succeeded)
{
Melding = resultaat.Error;
MeldingSoort = "fout";
return;
}
Nieuw = new NieuwBericht
{
AanPersoonId = bericht.ToPersonId,
Onderwerp = bericht.Subject,
Tekst = resultaat.Value ?? "",
Wanneer = bericht.DeliverAfterDeath ? "overlijden" : bericht.DeliverAt is null ? "nu" : "datum",
Datum = bericht.DeliverAt is { } at ? DateOnly.FromDateTime(at.ToLocalTime()) : null
};
BewerkId = bericht.Id;
ToonFormulier = true;
Weghalen = null;
Tab = "uit";
Melding = null;
await BijlagenLaden();
}
// ------------------------------------------------------------------ foto's en documenten
private async Task BijlagenLaden()
{
if (BewerkId == 0) { Bijlagen = Array.Empty<VaultFile>(); return; }
var resultaat = await Kluis.ListMessageFilesAsync(BewerkId, _gebruikerId);
Bijlagen = resultaat.Succeeded ? resultaat.Value! : Array.Empty<VaultFile>();
}
private async Task BijlageKiezen(InputFileChangeEventArgs e)
{
if (BewerkId == 0) return;
Uploaden = true;
Melding = null;
try
{
foreach (var bestand in e.GetMultipleFiles(MaxTegelijk))
{
if (bestand.Size > VaultService.MaxBestandsgrootte)
{
Melding = $"{bestand.Name} is te groot. Maximaal {VaultService.MaxBestandsgrootte / 1024 / 1024} MB.";
MeldingSoort = "fout";
continue;
}
using var stroom = bestand.OpenReadStream(VaultService.MaxBestandsgrootte);
using var buffer = new MemoryStream();
await stroom.CopyToAsync(buffer);
var resultaat = await Kluis.StoreFileAsync(
_gebruikerId, bestand.Name,
string.IsNullOrWhiteSpace(bestand.ContentType) ? "application/octet-stream" : bestand.ContentType,
buffer.ToArray(), messageId: BewerkId);
if (!resultaat.Succeeded) { Melding = resultaat.Error; MeldingSoort = "fout"; }
}
await BijlagenLaden();
}
catch (Exception ex)
{
Melding = $"Het bestand kon niet worden gelezen: {ex.Message}";
MeldingSoort = "fout";
}
finally { Uploaden = false; }
}
private async Task BijlageWeg(long bestandId)
{
var resultaat = await Kluis.DeleteFileAsync(bestandId, _gebruikerId);
if (!resultaat.Succeeded) { Melding = resultaat.Error; MeldingSoort = "fout"; }
await BijlagenLaden();
}
/// <summary>Opent het formulier met een leeg bericht, ook als er net iets anders openstond.</summary>
private void Beginnen()
{
BewerkId = 0;
Nieuw = new NieuwBericht();
Bijlagen = Array.Empty<VaultFile>();
ToonFormulier = true;
}
private void Annuleren()
{
BewerkId = 0;
Nieuw = new NieuwBericht();
Bijlagen = Array.Empty<VaultFile>();
ToonFormulier = false;
}
private async Task Intrekken(long id)
{
var resultaat = await Berichten.WithdrawAsync(id, _gebruikerId);
Melding = resultaat.Succeeded ? "Het bericht is teruggetrokken." : resultaat.Error;
MeldingSoort = resultaat.Succeeded ? "goed" : "fout";
Weghalen = null;
if (resultaat.Succeeded) await Verversen();
}
/// <summary>
/// Een bericht uit je eigen lijst halen - bij "Voor mij" of bij "Van mij", bezorgd of niet.
/// Anders dan Intrekken hierboven (dat een bericht terugtrekt vóór het is bezorgd, zodat de
/// ander het nooit krijgt): dit ruimt alleen je eigen exemplaar op, de ander houdt het zijne.
/// </summary>
private async Task VerwijderenAsync(long id)
{
var resultaat = await Berichten.DeleteForMeAsync(id, _gebruikerId);
Melding = resultaat.Succeeded ? "Het bericht is verwijderd." : resultaat.Error;
MeldingSoort = resultaat.Succeeded ? "goed" : "fout";
Verwijderen = null;
if (resultaat.Succeeded) await Verversen();
}
private async Task Versturen()
{
if (BewerkId == 0 && Nieuw.AanPersoonId == 0)
{
Melding = "Kies eerst voor wie het bericht is.";
MeldingSoort = "fout";
return;
}
DateTime? datum = Nieuw.Wanneer == "datum" && Nieuw.Datum is { } d
? d.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc) : null;
Result resultaat;
if (BewerkId == 0)
{
var gemaakt = await Berichten.ComposeAsync(_gebruikerId, Nieuw.AanPersoonId, Nieuw.Onderwerp,
Nieuw.Tekst, datum, Nieuw.Wanneer == "overlijden");
resultaat = gemaakt.Succeeded ? Result.Ok() : Result.Fail(gemaakt.Error!);
}
else
{
resultaat = await Berichten.UpdateAsync(BewerkId, _gebruikerId, Nieuw.Onderwerp, Nieuw.Tekst,
datum, Nieuw.Wanneer == "overlijden");
}
if (resultaat.Succeeded)
{
Melding = BewerkId == 0 ? "Je bericht staat klaar." : "De wijziging is opgeslagen.";
MeldingSoort = "goed";
BewerkId = 0;
Nieuw = new NieuwBericht();
ToonFormulier = false;
await Verversen();
}
else { Melding = resultaat.Error; MeldingSoort = "fout"; }
}
private string NaamVan(long persoonId) =>
Leden?.FirstOrDefault(l => l.PersonId == persoonId)?.DisplayName ?? "onbekend";
private static string Bezorging(Message m) =>
m.DeliverAfterDeath ? "na mijn overlijden"
: m.DeliverAt is { } at ? $"op {Datums.Datum(at)}"
: "meteen";
private static string Stand(MessageStatus s) => s switch
{
MessageStatus.Gepland => "Klaargezet",
MessageStatus.Bezorgd => "Bezorgd",
MessageStatus.Gelezen => "Gelezen",
MessageStatus.Ingetrokken => "Ingetrokken",
_ => "Concept"
};
public class NieuwBericht
{
public long AanPersoonId { get; set; }
public string Onderwerp { get; set; } = "";
public string Tekst { get; set; } = "";
public string Wanneer { get; set; } = "overlijden";
public DateOnly? Datum { get; set; }
}
}
@@ -0,0 +1,557 @@
@page "/nalatenschap"
@attribute [Authorize]
@using MijnBesluit.Application.Abstractions
@inject LegacyService NalatenschapService
@inject FamilyService Families
@inject IFamilyRepository FamilieOpslag
@inject ILegacyRepository LegacyOpslag
@inject IPersonRepository Personen
@inject VaultService Kluis
@rendermode InteractiveServer
<PageTitle>Nalatenschap - MijnBesluit</PageTitle>
<h1>Nalatenschap</h1>
<p class="zacht">
Wie mag straks regelen wat jij hebt vastgelegd? En hoeveel mensen moeten melden dat je er
niet meer bent voordat dat gebeurt?
</p>
@if (Melding is not null) { <div class="melding @MeldingSoort">@Melding</div> }
@if (Stand.State == DeathProcedureState.Lopend && Stand.Missing == 0)
{
<div class="melding let-op">
<strong>Er is gemeld dat je zou zijn overleden.</strong>
@Stand.ActiveDeclarations mensen hebben dat doorgegeven. Klopt het niet, laat het dan
weten vóór @Datums.Datum(Stand.ObjectionDeadline).
<div style="margin-top:.7rem">
<button class="knop" @onclick="Bezwaar">Ik ben er nog</button>
</div>
</div>
}
else if (Stand.WachtOpBeheerder)
{
<div class="melding let-op">
Er @(Stand.ActiveDeclarations == 1 ? "is één melding" : $"zijn {Stand.ActiveDeclarations} meldingen")
binnengekomen over jouw profiel &mdash; 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.
<div style="margin-top:.7rem">
<button class="knop" @onclick="Bezwaar">Ik ben er nog</button>
</div>
</div>
}
else if (Stand.State == DeathProcedureState.Lopend)
{
<div class="melding let-op">
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.
<div style="margin-top:.7rem">
<button class="knop" @onclick="Bezwaar">Ik ben er nog</button>
</div>
</div>
}
@* ---------------------------------------------------------------- aan mij toevertrouwd *@
@if (Toevertrouwd is { 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)
{
<div class="kaart compact">
@* De naam en de knop op één regel, de toelichting daaronder over de volle
breedte. Naast de knop bleef er op een telefoon zo weinig over dat
"Overleden 3 maart 2026" over vier regels ging en de naam werd afgekapt. *@
<div class="rij persoon">
<Penning Naam="@nalatenschap.DisplayName" Bron="@Foto(nalatenschap.PersonId)" />
<span class="groot groei" title="@nalatenschap.DisplayName">@nalatenschap.DisplayName</span>
</div>
<div class="zacht" style="margin-top:.3rem">
@if (!nalatenschap.Afgerond)
{
<text>Je bent aangewezen &middot; volgorde @nalatenschap.Volgorde</text>
}
else
{
@if (nalatenschap.DateOfDeath is { } overleden)
{
<text>Overleden @Datums.Datum(overleden) &middot; </text>
}
<text>@nalatenschap.AantalItems item@(nalatenschap.AantalItems == 1 ? "" : "s") vrijgegeven</text>
}
</div>
@* Wat deze persoon zelf heeft ingesteld: zo weet je bij leven al waar je op
moet rekenen, en kun je het er nog met diegene over hebben. Alleen zolang
de procedure nog niet is afgerond - daarna is het geschiedenis, geen
vooruitzicht meer. *@
@if (!nalatenschap.Afgerond)
{
<div class="zacht" style="margin-top:.1rem">
@nalatenschap.RequiredDeclarations
meldingen nodig &middot; @nalatenschap.ObjectionDays dagen bezwaartermijn
</div>
}
@if (nalatenschap.Afgerond)
{
<button class="knop rustig" style="margin-top:.6rem"
@onclick="() => Openen(nalatenschap.PersonId)">
@(Bekeken == nalatenschap.PersonId ? "Sluiten" : "Bekijken")
</button>
}
@if (nalatenschap.Afgerond && Bekeken == nalatenschap.PersonId)
{
@if (Nagelaten is { 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 ?? new List<VaultItem>())
{
<div class="rij">
<div class="groei">
<span>@item.Title</span>
<div class="zacht">@Kluisweergave.Naam(item.Category)</div>
</div>
<button class="knop rustig" @onclick="() => ItemOpenen(item)">Openen</button>
</div>
}
</div>
}
</div>
}
</div>
</div>
}
@* ---------------------------------------------------------------- met mij gedeeld *@
@if (GedeeldMetMij is { Count: > 0 })
{
<div class="kaart">
<h2 style="margin-top:0">Met mij gedeeld</h2>
<p class="zacht" style="margin-top:0">
Deze uitvaartwens is met jou gedeeld om alvast te bespreken - je hoeft niet te
wachten op een overlijden om hem te lezen.
</p>
<div class="stapel">
@foreach (var item in GedeeldMetMij)
{
<div class="rij">
<div class="groei">
<span>@item.Title</span>
<div class="zacht">@Kluisweergave.Naam(item.Category)</div>
</div>
<button class="knop rustig" @onclick="() => ItemOpenen(item)">Openen</button>
</div>
}
</div>
</div>
}
@* ---------------------------------------------------------------- item bekijken *@
@* Dezelfde opmaak als het invulformulier in de kluis (.veld-blokken 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 (label, waarde) in Kluisweergave.Velden(Inhoud))
{
<div class="veld">
<label>@label</label>
<p style="margin:.2rem 0 0">@waarde</p>
</div>
}
</div>
@if (GeopendBijlagen is { Count: > 0 })
{
<hr />
<h3>Bijlagen</h3>
<div class="stapel">
@foreach (var bijlage in GeopendBijlagen)
{
<div class="rij">
<div class="groei">
<a href="/kluis/bestand/@bijlage.Id" target="_blank">@bijlage.FileName</a>
</div>
</div>
@if (bijlage.ContentType.StartsWith("image/"))
{
<img src="/kluis/bestand/@bijlage.Id" alt="@bijlage.FileName"
style="max-width:100%;border-radius:10px;margin:.4rem 0 1rem" />
}
else if (bijlage.ContentType.StartsWith("video/"))
{
<video src="/kluis/bestand/@bijlage.Id" controls
style="max-width:100%;border-radius:10px;margin:.4rem 0 1rem"></video>
}
}
</div>
}
</Popup>
}
<div class="kaart">
<h2>Mijn beheerders</h2>
@if (Beheerders is null) { <p class="zacht">Bezig met laden&hellip;</p> }
else 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="@NaamVan(beheerder.ManagerPersonId)" Bron="@Foto(beheerder.ManagerPersonId)" />
<div class="groei">
<div class="groot" title="@NaamVan(beheerder.ManagerPersonId)">@NaamVan(beheerder.ManagerPersonId)</div>
<div class="zacht">
Volgorde @beheerder.Priority
@if (beheerder.AccessGrantedAt is not null) { <text> &middot; heeft toegang gekregen</text> }
</div>
</div>
@* Wie je aanwijst moet je ook weer kunnen weghalen - als hij zelf overlijdt,
of als je het anders wilt regelen. Behalve als hij je kluis al heeft
gekregen: dan valt er niets meer terug te nemen. *@
@if (beheerder.AccessGrantedAt is null)
{
<span class="tegelknoppen">
<button class="knop rustig klein"
title="Niet meer laten beheren" aria-label="Niet meer laten beheren"
@onclick="() => Weghalen = beheerder.ManagerPersonId">&#128465;&#65039;</button>
</span>
}
</div>
@if (Weghalen == beheerder.ManagerPersonId)
{
<div class="melding let-op">
<strong>@NaamVan(beheerder.ManagerPersonId)</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" @onclick="() => BeheerderWeg(beheerder.ManagerPersonId)">
Ja, weghalen
</button>
<button class="knop rustig" @onclick="() => Weghalen = null">Nee, laat staan</button>
</div>
</div>
}
}
</div>
}
<hr />
<EditForm Model="Nieuw" OnValidSubmit="Aanwijzen" FormName="beheerder">
<div class="raster twee">
<div class="veld">
<label for="wie">Wie wijs je aan?</label>
<InputSelect id="wie" @bind-Value="Nieuw.PersoonId">
<option value="0">Kies iemand uit je familie</option>
@if (Leden is not null)
{
@foreach (var lid in Leden.Where(l => l.PersonId != _persoonId))
{
<option value="@lid.PersonId">@lid.DisplayName</option>
}
}
</InputSelect>
</div>
<div class="veld">
<label for="volgorde">Volgorde</label>
<InputNumber id="volgorde" @bind-Value="Nieuw.Volgorde" />
<div class="uitleg">1 is de eerste keus.</div>
</div>
</div>
<button type="submit" class="knop">Aanwijzen</button>
</EditForm>
</div>
<div class="kaart">
<h2>Hoeveel meldingen zijn er nodig?</h2>
<p class="zacht">
Standaard moeten drie 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="raster twee">
<div class="veld">
<label for="aantal">Aantal meldingen</label>
<input id="aantal" type="number" min="1" max="10" value="@Aantal" @onchange="AantalGewijzigd" />
</div>
<div class="veld">
<label for="dagen">Bezwaartermijn in dagen</label>
<input id="dagen" type="number" min="1" max="90" value="@Dagen" @onchange="DagenGewijzigd" />
</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 is { 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 &mdash; en blijft je kluis ook na je overlijden dicht.
</div>
}
<button class="knop rustig" @onclick="InstellingenOpslaan">Opslaan</button>
</div>
@* 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 voordat er iets gebeurt, waaronder één van iemand die
hij zelf als beheerder heeft aangewezen. Daarna krijgt de betrokkene eerst bericht.
</p>
<EditForm Model="Overlijden" OnValidSubmit="Melden" FormName="overlijden">
<div class="raster twee">
<div class="veld">
<label for="overleden">Wie?</label>
<InputSelect id="overleden" @bind-Value="Overlijden.PersoonId">
<option value="0">Kies iemand</option>
@foreach (var wie in Meldbaar)
{
<option value="@wie.PersonId">@wie.DisplayName</option>
}
</InputSelect>
</div>
<div class="veld">
<label for="datum">Datum van overlijden</label>
<InputDate id="datum" @bind-Value="Overlijden.Datum" />
</div>
</div>
<button type="submit" class="knop gevaar">Melding doen</button>
</EditForm>
</div>
}
@code {
[CascadingParameter] private Task<AuthenticationState>? AuthState { get; set; }
private long _gebruikerId, _persoonId, _familieId;
private IReadOnlyList<LegacyManager>? Beheerders;
private IReadOnlyList<FamilyMemberView>? Leden;
private DeathProcedureResult Stand;
private int Aantal = 3, Dagen = 14;
private string? Melding, MeldingSoort;
private NieuweBeheerder Nieuw = new();
private Overlijdensmelding Overlijden = new();
/// <summary>De nalatenschappen die aan mij zijn toevertrouwd, afgerond of nog niet.</summary>
private IReadOnlyList<LegacyService.ToevertrouwdView>? Toevertrouwd;
/// <summary>Kluisitems die iemand rechtstreeks met mij heeft gedeeld (nog geen overlijden nodig).</summary>
private IReadOnlyList<VaultItem>? GedeeldMetMij;
/// <summary>Per persoon het nummer van zijn profielfoto, voor wie er een heeft.</summary>
private IReadOnlyDictionary<long, long> Profielfotos = new Dictionary<long, long>();
/// <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<FamilyMemberView> Meldbaar =>
Leden?.Where(l => l.PersonId != _persoonId && l.DateOfDeath is null).ToList()
?? (IReadOnlyList<FamilyMemberView>)Array.Empty<FamilyMemberView>();
/// <summary>Welke nalatenschap openstaat, en welk item daarbinnen.</summary>
private long? Bekeken, GeopendItem;
private IReadOnlyList<VaultItem>? Nagelaten;
private VaultPayload? Inhoud;
private string? GeopendTitel;
private IReadOnlyList<VaultFile>? GeopendBijlagen;
protected override async Task OnInitializedAsync()
{
if (AuthState is null) return;
var user = (await AuthState).User;
_gebruikerId = user.UserId() ?? 0;
_persoonId = user.PersonId() ?? 0;
_familieId = (await FamilieOpslag.ListOwnedByPersonAsync(_persoonId)).FirstOrDefault()?.Id ?? 0;
if (_familieId != 0) Leden = await Families.ListMembersAsync(_familieId, _gebruikerId);
var ik = await Personen.FindByIdAsync(_persoonId);
if (ik is not null) { Aantal = ik.RequiredDeathDeclarations; Dagen = ik.DeathObjectionDays; }
await Verversen();
}
private async Task Verversen()
{
Beheerders = await LegacyOpslag.ListManagersForAsync(_persoonId);
Stand = await NalatenschapService.StatusAsync(_persoonId);
Toevertrouwd = await NalatenschapService.ToevertrouwdAsync(_gebruikerId);
var gedeeld = await Kluis.ListSharedWithMeAsync(_gebruikerId);
GedeeldMetMij = gedeeld.Succeeded ? gedeeld.Value : Array.Empty<VaultItem>();
// De profielfoto's van iedereen die op dit scherm staat, in één vraag. Alleen het
// nummer; het plaatje zelf haalt de browser op via /bestand/{id}/klein.
var wie = Beheerders.Select(b => b.ManagerPersonId)
.Concat(Toevertrouwd.Select(t => t.PersonId))
.Distinct();
Profielfotos = (await Kluis.ProfielfotosAsync(wie, _gebruikerId))
.ToDictionary(f => f.PersoonId, f => f.BestandId);
}
/// <summary>Waar de profielfoto van deze persoon staat; null als hij er geen heeft.</summary>
private string? Foto(long persoonId) =>
Profielfotos.TryGetValue(persoonId, out var bestandId) ? $"/bestand/{bestandId}/klein" : null;
// ------------------------------------------------------------------ aan mij toevertrouwd
private async Task Openen(long persoonId)
{
if (Bekeken == persoonId) { Sluiten(); return; }
var resultaat = await NalatenschapService.NalatenschapItemsAsync(persoonId, _gebruikerId);
if (!resultaat.Succeeded) { Melding = resultaat.Error; MeldingSoort = "fout"; return; }
Bekeken = persoonId;
Nagelaten = resultaat.Value;
ItemDichtdoen();
}
private void Sluiten()
{
Bekeken = null;
Nagelaten = null;
ItemDichtdoen();
}
/// <summary>
/// Opent één nagelaten item. De sleutel daarvoor is bij het bevestigen van het overlijden
/// voor jou klaargezet; VaultService gebruikt die vanzelf als het item niet van jou is.
/// </summary>
private async Task ItemOpenen(VaultItem item)
{
if (GeopendItem == item.Id) { ItemDichtdoen(); return; }
var resultaat = await Kluis.OpenAsync(item.Id, _gebruikerId);
if (!resultaat.Succeeded) { Melding = resultaat.Error; MeldingSoort = "fout"; return; }
GeopendItem = item.Id;
Inhoud = resultaat.Value;
GeopendTitel = item.Title;
// Bijlagen zijn optioneel: als ophalen mislukt (bv. geen toegang) tonen we het
// item gewoon zonder bijlagenlijst, in plaats van de hele popup te blokkeren.
var bijlagen = await Kluis.ListFilesAsync(item.Id, _gebruikerId);
GeopendBijlagen = bijlagen.Succeeded ? bijlagen.Value : Array.Empty<VaultFile>();
}
private void ItemDichtdoen()
{
GeopendItem = null;
Inhoud = null;
GeopendTitel = null;
GeopendBijlagen = null;
}
/// <summary>Van wie de vraag "weghalen?" openstaat.</summary>
private long? Weghalen;
private async Task BeheerderWeg(long persoonId)
{
var resultaat = await NalatenschapService.RemoveManagerAsync(_gebruikerId, persoonId);
Melding = resultaat.Succeeded
? $"{NaamVan(persoonId)} beheert je nalatenschap niet meer."
: resultaat.Error;
MeldingSoort = resultaat.Succeeded ? "goed" : "fout";
Weghalen = null;
await Verversen();
}
private async Task Aanwijzen()
{
if (Nieuw.PersoonId == 0) { Melding = "Kies eerst iemand."; MeldingSoort = "fout"; return; }
var resultaat = await NalatenschapService.AppointManagerAsync(_gebruikerId, Nieuw.PersoonId, Nieuw.Volgorde);
Melding = resultaat.Succeeded ? "Aangewezen." : resultaat.Error;
MeldingSoort = resultaat.Succeeded ? "goed" : "fout";
Nieuw = new NieuweBeheerder();
await Verversen();
}
private async Task Bezwaar()
{
var resultaat = await NalatenschapService.ObjectAsync(_persoonId, _gebruikerId);
Melding = resultaat.Succeeded
? "Goed dat je het laat weten. De meldingen zijn ingetrokken."
: resultaat.Error;
MeldingSoort = resultaat.Succeeded ? "goed" : "fout";
await Verversen();
}
private async Task Melden()
{
if (Overlijden.PersoonId == 0) { Melding = "Kies eerst iemand."; MeldingSoort = "fout"; return; }
var resultaat = await NalatenschapService.DeclareDeathAsync(Overlijden.PersoonId, _gebruikerId, Overlijden.Datum);
if (resultaat.Succeeded)
{
var stand = resultaat.Value;
Melding = stand.Missing > 0
? $"Je melding is genoteerd. Er zijn nog {stand.Missing} melding(en) nodig."
: "Je melding is genoteerd. De bezwaartermijn is ingegaan.";
MeldingSoort = "goed";
Overlijden = new Overlijdensmelding();
}
else { Melding = resultaat.Error; MeldingSoort = "fout"; }
await Verversen();
}
private void AantalGewijzigd(ChangeEventArgs e)
{ if (int.TryParse(e.Value?.ToString(), out var v)) Aantal = Math.Clamp(v, 1, 10); }
private void DagenGewijzigd(ChangeEventArgs e)
{ if (int.TryParse(e.Value?.ToString(), out var v)) Dagen = Math.Clamp(v, 0, 90); }
private async Task InstellingenOpslaan()
{
var resultaat = await Families.UpdatePersonAsync(_persoonId, _gebruikerId, p =>
{
p.RequiredDeathDeclarations = Aantal;
p.DeathObjectionDays = Dagen;
});
Melding = resultaat.Succeeded ? "Je instellingen zijn opgeslagen." : resultaat.Error;
MeldingSoort = resultaat.Succeeded ? "goed" : "fout";
}
private string NaamVan(long persoonId) =>
Leden?.FirstOrDefault(l => l.PersonId == persoonId)?.DisplayName ?? "onbekend";
public class NieuweBeheerder { public long PersoonId { get; set; } public int Volgorde { get; set; } = 1; }
public class Overlijdensmelding { public long PersoonId { get; set; } public DateOnly? Datum { get; set; } }
}
@@ -0,0 +1,20 @@
@page "/privacy"
@attribute [AllowAnonymous]
@* De privacyverklaring. Openbaar bereikbaar, want Google Play vraagt er een adres van, en wie
overweegt zijn wachtwoorden hier neer te leggen hoort het te kunnen lezen vóórdat hij een
account maakt.
Let op bij het aanpassen: wat hier staat moet waar zijn. Verandert er iets aan wat er wordt
opgeslagen, wie het kan zien of wat er naar buiten gaat, dan verandert deze pagina mee. Een
privacyverklaring die niet klopt is erger dan geen. *@
<PageTitle>Privacy - MijnBesluit</PageTitle>
<div class="middenblok" style="max-width:640px">
<h1>Privacy</h1>
<Privacytekst />
<p class="zacht"><a href="/">Terug naar MijnBesluit</a></p>
</div>
@@ -0,0 +1,368 @@
@page "/profiel"
@attribute [Authorize]
@inject AccountService Accounts
@inject VaultService Kluis
@inject MeldingService Meldingen
@inject NavigationManager Navigatie
@rendermode InteractiveServer
<PageTitle>Mijn profiel - MijnBesluit</PageTitle>
@* Je eigen gegevens. Dit is het enige profiel dat niet bij Mijn mensen staat - daar staat
iedereen behalve jij - dus zonder deze pagina 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. *@
@* Net als overal elders op de site: aantikken van je eigen kaart bij Mijn mensen opent dit als
popup, niet als eigen pagina. "Vast onderin" (.knop-vast-onder stond hier eerder) is precies
wat een Popup toch al gratis geeft via .popup-knoppen. De route /profiel blijft bestaan - de
kop en het menu linken er nog naar - maar toont nu meteen deze popup. *@
@if (Melding is not null && Mijn is null) { <div class="melding @MeldingSoort">@Melding</div> }
@if (Mijn is null)
{
<p class="zacht">Bezig met laden&hellip;</p>
}
else
{
<Popup Titel="Mijn profiel" Bevestigtekst="Opslaan" Bezig="@Bezig"
OpBevestigen="Opslaan" OpSluiten="Sluiten">
@if (Melding is not null) { <div class="melding @MeldingSoort">@Melding</div> }
@if (Gewisseld is not null)
{
<div class="melding goed">
<p style="margin-top:0">
Je e-mailadres is gewijzigd. Vanaf nu log je in met <strong>@Gewisseld</strong>.
Je huidige aanmelding loopt nog op je oude adres, dus log even opnieuw in.
</p>
@* Uitloggen gaat via een formulier en niet vanuit deze component: het cookie kan alleen
gewist worden terwijl het antwoord nog niet onderweg is. Zie Program.cs. *@
<form method="post" action="/uitloggen" style="margin:0">
<AntiforgeryToken />
<button type="submit" class="knop">Uitloggen en opnieuw inloggen</button>
</form>
</div>
}
<div class="raster twee">
<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 for="gb">Geboortedatum</label>
<input id="gb" type="date" max="@DateTime.Today.ToString("yyyy-MM-dd")" @bind="Geboortedatum" />
<div class="uitleg">Niet verplicht.</div>
</div>
<p class="zacht" style="margin:.8rem 0 0">Je bent hier sinds @Datum(Mijn.LidSinds).</p>
<div class="kaart" style="margin-top:1rem">
<h2 style="margin-top:0">E-mailadres</h2>
<p class="zacht" style="margin-top:0">
Hiermee log je in. Je huidige adres is <strong>@Mijn.Email</strong>.
</p>
<button class="knop rustig" disabled="@Bezig" @onclick="AdresWijzigen">
&#9993; Ander e-mailadres
</button>
</div>
@* ---------------------------------------------------------------- meldingen *@
<div class="kaart" style="margin-top:1rem">
<h2 style="margin-top:0">Meldingen op je telefoon</h2>
@if (Toestellen.Count == 0)
{
<p class="zacht" style="margin-top:0">
Er is nog geen telefoon aangemeld. Installeer de app, log in en geef toestemming
voor meldingen; daarna staat je toestel hier.
</p>
}
else
{
<p class="zacht" style="margin-top:0">Meldingen gaan naar:</p>
<ul style="margin:0 0 1rem">
@foreach (var toestel in Toestellen)
{
<li>
@(string.IsNullOrWhiteSpace(toestel.Naam) ? toestel.Platform : toestel.Naam)
<span class="zacht">&middot; laatst gezien @Datum(toestel.LaatstGezien)</span>
</li>
}
</ul>
}
@if (Proef is not null)
{
<div class="melding @(Proef.Gelukt ? "goed" : "let-op")">@Proef.Uitleg</div>
}
<button class="knop rustig" disabled="@Bezig" @onclick="ProefmeldingAsync">
&#128276; Stuur een proefmelding
</button>
<div class="uitleg">
Komt hij niet aan, dan zegt deze knop waar het op vastloopt.
</div>
</div>
<div class="kaart" style="margin-top:1rem">
<h2 style="margin-top:0">Mijn foto's en documenten</h2>
<Bijlagenblok Titel="Bestanden bij mijn profiel" Bijlagen="Bijlagen" Bezig="@Uploaden"
KanProfielfoto="true"
Uitleg="Een foto van jezelf, je paspoort, je donorcodicil. Met het sterretje maak je er je profielfoto van."
OpKiezen="BijlageKiezen"
OpVerwijderen="BijlageWeg"
OpProfielfoto="Profielfoto" />
</div>
@* Een link en geen knop: het opheffen zelf gebeurt op een eigen pagina. Die pagina wordt
statisch weergegeven, en dat is nodig ook - er wordt een inlogcookie gewist, en dat kan
niet tijdens het renderen van een interactief scherm. Diezelfde pagina is ook het
openbare adres waar iemand zonder app terechtkan; zie AccountVerwijderen.razor. *@
<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>
<a class="knop gevaar" href="/account-verwijderen">Mijn account opheffen</a>
</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="AdresStap" OpSluiten="() => Adres = null">
@if (!Adres.CodeGevraagd)
{
<div class="veld">
<label for="nieuw">Je nieuwe e-mailadres</label>
<input id="nieuw" type="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>
}
</Popup>
}
@code {
[CascadingParameter] private Task<AuthenticationState>? AuthState { get; set; }
private long _gebruikerId;
private EigenProfiel? Mijn;
private string Voornaam = "";
private string Achternaam = "";
private DateTime? Geboortedatum;
private string? Melding, MeldingSoort;
private bool Bezig;
/// <summary>Het adreswijzigingsformulier; null als het niet openstaat.</summary>
private Adreswissel? Adres;
/// <summary>Het nieuwe adres, zodra de wijziging rond is. Dan volgt alleen nog uitloggen.</summary>
private string? Gewisseld;
private IReadOnlyList<VaultFile> Bijlagen = Array.Empty<VaultFile>();
private bool Uploaden;
/// <summary>De toestellen waar meldingen heen gaan, en de uitkomst van een proefmelding.</summary>
private IReadOnlyList<Toestel> Toestellen = Array.Empty<Toestel>();
private MeldingService.Proefuitslag? Proef;
/// <summary>Zoveel bestanden tegelijk kiezen mag. Blazor wil hier een bovengrens.</summary>
private const int MaxTegelijk = 10;
protected override async Task OnInitializedAsync()
{
if (AuthState is null) return;
_gebruikerId = (await AuthState).User.UserId() ?? 0;
await Verversen();
}
private async Task Verversen()
{
var resultaat = await Accounts.EigenProfielAsync(_gebruikerId);
if (!resultaat.Succeeded) { Melding = resultaat.Error; MeldingSoort = "fout"; return; }
Mijn = resultaat.Value;
Voornaam = Mijn!.Voornaam;
Achternaam = Mijn.Achternaam;
Geboortedatum = Mijn.Geboortedatum?.ToDateTime(TimeOnly.MinValue);
Toestellen = (await Meldingen.ToestellenAsync(_gebruikerId)).Where(t => t.IsActief).ToList();
await BijlagenLaden();
}
/// <summary>
/// Stuurt een proefmelding naar je eigen telefoon. Blijft er een melding uit, dan zijn er
/// drie mogelijke oorzaken die er van buiten hetzelfde uitzien; deze knop zegt welke het is.
/// </summary>
private async Task ProefmeldingAsync()
{
Bezig = true;
Melding = null;
Proef = null;
try
{
Proef = await Meldingen.ProefAsync(_gebruikerId);
// Een verlopen kenmerk wordt bij het versturen afgemeld; dan klopt de lijst niet meer.
Toestellen = (await Meldingen.ToestellenAsync(_gebruikerId)).Where(t => t.IsActief).ToList();
}
finally { Bezig = false; }
}
private async Task Opslaan()
{
Bezig = true;
Melding = null;
try
{
var geboren = Geboortedatum is { } g ? DateOnly.FromDateTime(g) : (DateOnly?)null;
var resultaat = await Accounts.WijzigEigenProfielAsync(
_gebruikerId, Voornaam.Trim(), Achternaam.Trim(), geboren);
Melding = resultaat.Succeeded ? "Je gegevens zijn opgeslagen." : resultaat.Error;
MeldingSoort = resultaat.Succeeded ? "goed" : "fout";
if (resultaat.Succeeded) await Verversen();
}
finally { Bezig = false; }
}
/// <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()
{
Melding = null;
Adres = new Adreswissel();
}
/// <summary>Eén knop, twee stappen: eerst de code aanvragen, daarna hem bevestigen.</summary>
private async Task AdresStap()
{
var wissel = Adres!;
Bezig = true;
Melding = null;
try
{
if (!wissel.CodeGevraagd)
{
var gevraagd = await Accounts.VraagAdreswijzigingAanAsync(_gebruikerId, wissel.Nieuw.Trim());
if (!gevraagd.Succeeded) { Melding = gevraagd.Error; MeldingSoort = "fout"; return; }
wissel.Nieuw = wissel.Nieuw.Trim().ToLowerInvariant();
wissel.CodeGevraagd = true;
return;
}
var bevestigd = await Accounts.BevestigAdreswijzigingAsync(
_gebruikerId, wissel.Nieuw, wissel.Code.Trim());
if (!bevestigd.Succeeded) { Melding = bevestigd.Error; MeldingSoort = "fout"; return; }
Gewisseld = wissel.Nieuw;
Adres = null;
}
finally { Bezig = false; }
}
// ------------------------------------------------------------------ foto's en documenten
private async Task BijlagenLaden()
{
if (Mijn is null) { Bijlagen = Array.Empty<VaultFile>(); return; }
var resultaat = await Kluis.ListPersonFilesAsync(Mijn.PersoonId, _gebruikerId);
Bijlagen = resultaat.Succeeded ? resultaat.Value! : Array.Empty<VaultFile>();
}
private async Task BijlageKiezen(InputFileChangeEventArgs e)
{
if (Mijn is null) return;
Uploaden = true;
Melding = null;
try
{
foreach (var bestand in e.GetMultipleFiles(MaxTegelijk))
{
if (bestand.Size > VaultService.MaxBestandsgrootte)
{
Melding = $"{bestand.Name} is te groot. Maximaal {VaultService.MaxBestandsgrootte / 1024 / 1024} MB.";
MeldingSoort = "fout";
continue;
}
using var stroom = bestand.OpenReadStream(VaultService.MaxBestandsgrootte);
using var buffer = new MemoryStream();
await stroom.CopyToAsync(buffer);
var resultaat = await Kluis.StoreFileAsync(
_gebruikerId, bestand.Name,
string.IsNullOrWhiteSpace(bestand.ContentType) ? "application/octet-stream" : bestand.ContentType,
buffer.ToArray(), subjectPersonId: Mijn.PersoonId);
if (!resultaat.Succeeded) { Melding = resultaat.Error; MeldingSoort = "fout"; }
}
await BijlagenLaden();
}
catch (Exception ex)
{
Melding = $"Het bestand kon niet worden gelezen: {ex.Message}";
MeldingSoort = "fout";
}
finally { Uploaden = false; }
}
private async Task BijlageWeg(long bestandId)
{
var resultaat = await Kluis.DeleteFileAsync(bestandId, _gebruikerId);
if (!resultaat.Succeeded) { Melding = resultaat.Error; MeldingSoort = "fout"; }
await BijlagenLaden();
}
private async Task Profielfoto(long bestandId)
{
var resultaat = await Kluis.SetProfilePhotoAsync(bestandId, _gebruikerId);
if (!resultaat.Succeeded) { Melding = resultaat.Error; MeldingSoort = "fout"; }
await BijlagenLaden();
}
private static string Datum(DateTime moment) => Datums.Datum(moment);
/// <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,175 @@
@page "/registreren"
@using System.ComponentModel.DataAnnotations
@inject AccountService Accounts
@inject IHttpContextAccessor HttpContexten
@inject NavigationManager Navigatie
@* Zelfde opzet als de inlogpagina: alle formulieren worden altijd gerenderd en alleen
verborgen. Bij een statische pagina is elke POST een nieuwe pagina-instantie, dus een
formulier dat op dat moment niet gerenderd wordt, bestaat voor de server niet. *@
<PageTitle>Account aanmaken - MijnBesluit</PageTitle>
<div class="middenblok">
<h1>Begin je eigen MijnBesluit</h1>
<p class="zacht">
Je legt vast wat je wilt regelen, en zet berichten klaar voor de mensen om je heen.
Daarna nodig je je familie uit.
</p>
@if (Fout is not null) { <div class="melding fout">@Fout</div> }
@if (Gelukt is not null) { <div class="melding goed">@Gelukt</div> }
<div hidden="@ToonCodeveld">
<EditForm Model="Aanvraag" FormName="registratiecode" OnValidSubmit="CodeAanvragen" method="post">
<DataAnnotationsValidator />
<div class="kaart">
<div class="raster twee">
<div class="veld">
<label for="voornaam">Voornaam</label>
<InputText id="voornaam" @bind-Value="Aanvraag.Voornaam" autocomplete="given-name" />
<ValidationMessage For="() => Aanvraag.Voornaam" class="uitleg" />
</div>
<div class="veld">
<label for="achternaam">Achternaam</label>
<InputText id="achternaam" @bind-Value="Aanvraag.Achternaam" autocomplete="family-name" />
</div>
</div>
<div class="veld">
<label for="email">E-mailadres</label>
<InputText id="email" @bind-Value="Aanvraag.Email" type="email"
autocomplete="username" inputmode="email" placeholder="alles in kleine letters" />
<ValidationMessage For="() => Aanvraag.Email" class="uitleg" />
</div>
<details class="uitklap">
<summary>Waarom vragen jullie geen wachtwoord?</summary>
<p>
Bij MijnBesluit log je in met een code die we per e-mail sturen. Er is dus niets
te onthouden en niets dat op een andere website gelekt kan worden. Zorg er wel
voor dat je e-mail goed beveiligd is, want die is vanaf nu je sleutel.
</p>
</details>
<button type="submit" class="knop vol">Code aanvragen</button>
</div>
</EditForm>
</div>
<div hidden="@(!ToonCodeveld)">
<EditForm Model="Bevestig" FormName="registreren" OnValidSubmit="Aanmaken" method="post">
<DataAnnotationsValidator />
<div class="kaart">
<div class="veld">
<label for="email2">E-mailadres</label>
<InputText id="email2" @bind-Value="Bevestig.Email" type="email" readonly />
</div>
<div class="veld">
<label for="code">Code</label>
<InputText id="code" @bind-Value="Bevestig.Code" class="codeveld" inputmode="numeric"
autocomplete="one-time-code" maxlength="6" />
<div class="uitleg">
Vul hier de code in die wij hebben gestuurd naar
<span class="naar-adres">@AfgeschermdAdres</span>.
</div>
<ValidationMessage For="() => Bevestig.Code" class="uitleg" />
</div>
<button type="submit" class="knop vol">Account aanmaken</button>
</div>
</EditForm>
<EditForm Model="Opnieuw" FormName="nieuwe-registratiecode" OnValidSubmit="OpnieuwAanvragen" method="post">
<div class="kaart">
<InputText @bind-Value="Opnieuw.Email" type="hidden" />
<InputText @bind-Value="Opnieuw.Voornaam" type="hidden" />
<InputText @bind-Value="Opnieuw.Achternaam" type="hidden" />
<div class="rij">
<div class="groei zacht">Geen e-mail ontvangen? Kijk ook even in je ongewenste post.</div>
<button type="submit" class="knop rustig">Nieuwe code aanvragen</button>
</div>
</div>
</EditForm>
</div>
<p class="zacht">Heb je al een account? <a href="/inloggen">Inloggen</a>.</p>
</div>
@code {
[SupplyParameterFromForm(FormName = "registratiecode")] private AanvraagGegevens Aanvraag { get; set; } = new();
[SupplyParameterFromForm(FormName = "registreren")] private BevestigGegevens Bevestig { get; set; } = new();
[SupplyParameterFromForm(FormName = "nieuwe-registratiecode")] private AanvraagGegevens Opnieuw { get; set; } = new();
private bool _codeVerstuurd;
private string? AfgeschermdAdres;
private string? Fout, Gelukt;
private bool ToonCodeveld =>
_codeVerstuurd || !string.IsNullOrWhiteSpace(Bevestig.Email) || !string.IsNullOrWhiteSpace(Opnieuw.Email);
protected override void OnParametersSet()
{
if (AfgeschermdAdres is null && ToonCodeveld)
{
var adres = !string.IsNullOrWhiteSpace(Bevestig.Email) ? Bevestig.Email : Opnieuw.Email;
AfgeschermdAdres = new CodeVerstuurd(adres, DateTime.UtcNow).AfgeschermdAdres;
}
}
private Task CodeAanvragen() => StuurCodeAsync(Aanvraag.Email, Aanvraag.Voornaam, Aanvraag.Achternaam);
private Task OpnieuwAanvragen() => StuurCodeAsync(Opnieuw.Email, Opnieuw.Voornaam, Opnieuw.Achternaam);
private async Task StuurCodeAsync(string email, string voornaam, string achternaam)
{
var context = HttpContexten.HttpContext!;
var resultaat = await Accounts.VraagRegistratiecodeAanAsync(
email, voornaam, achternaam, context.Connection.RemoteIpAddress?.ToString(), context.RequestAborted);
if (!resultaat.Succeeded) { Fout = resultaat.Error; Gelukt = null; return; }
_codeVerstuurd = true;
AfgeschermdAdres = resultaat.Value!.AfgeschermdAdres;
Bevestig = new BevestigGegevens { Email = resultaat.Value.Email };
Opnieuw = new AanvraagGegevens { Email = resultaat.Value.Email, Voornaam = voornaam, Achternaam = achternaam };
Gelukt = "We hebben je een code gestuurd. Vul hem hieronder in.";
Fout = null;
}
private async Task Aanmaken()
{
var context = HttpContexten.HttpContext!;
var resultaat = await Accounts.RegistreerMetCodeAsync(Bevestig.Email, Bevestig.Code, context.RequestAborted);
if (!resultaat.Succeeded)
{
_codeVerstuurd = true;
AfgeschermdAdres = new CodeVerstuurd(Bevestig.Email, DateTime.UtcNow).AfgeschermdAdres;
Fout = resultaat.Error;
Gelukt = null;
return;
}
await Inloggen.MeldAanAsync(context, resultaat.Value!);
Navigatie.NavigateTo("/", forceLoad: true);
}
public class AanvraagGegevens
{
[Required(ErrorMessage = "Vul je voornaam in.")]
public string Voornaam { get; set; } = "";
public string Achternaam { get; set; } = "";
[Required(ErrorMessage = "Je moet een e-mailadres invullen!")]
[EmailAddress(ErrorMessage = "Dit is geen geldig e-mailadres!")]
public string Email { get; set; } = "";
}
public class BevestigGegevens
{
[Required] public string Email { get; set; } = "";
[Required(ErrorMessage = "Je moet een code invullen!")]
public string Code { get; set; } = "";
}
}
@@ -0,0 +1,8 @@
@* Uitloggen zit niet meer in een pagina maar in een eigen endpoint; zie Program.cs.
Waarom: een Blazor-pagina rendert pas als het antwoord al onderweg is. Het Set-Cookie
waarmee de aanmelding wordt gewist, kan dan niet meer worden meegestuurd - het uitloggen
leek te lukken, maar je bleef gewoon ingelogd.
Dit bestand is leeg gelaten in plaats van verwijderd, zodat het bij een volgende publicatie
niet per ongeluk terugkomt uit een oude map. *@
@@ -0,0 +1,88 @@
@page "/uitnodiging/{Token}"
@inject FamilyService Families
@inject AccountService Accounts
@inject SamenvoegService Samenvoegen
@inject IHttpContextAccessor HttpContexten
@inject NavigationManager Navigatie
<PageTitle>Uitnodiging - MijnBesluit</PageTitle>
<div class="middenblok">
<h1>Je bent uitgenodigd</h1>
<p class="zacht">
Iemand heeft in MijnBesluit een profiel voor je aangemaakt. Neem je de uitnodiging aan, dan
is dat profiel vanaf dat moment van jou: alleen jij kunt het dan nog wijzigen.
</p>
@if (Fout is not null)
{
<div class="melding fout">
@Fout
@if (LogEerstIn)
{
<p style="margin:.8rem 0 0">
<a class="knop" href="/inloggen?terug=uitnodiging/@Token">Inloggen</a>
</p>
}
</div>
}
<EditForm Model="Invoer" FormName="uitnodiging" OnValidSubmit="Aannemen" method="post">
<div class="kaart">
<p class="zacht" style="margin-top:0">
Je hoeft geen wachtwoord te bedenken. Bij MijnBesluit log je voortaan in met een code
die we je per e-mail sturen &mdash; naar hetzelfde adres waarop je deze uitnodiging kreeg.
</p>
<button type="submit" class="knop vol">Uitnodiging aannemen</button>
</div>
</EditForm>
</div>
@code {
[Parameter] public string Token { get; set; } = "";
/// <summary>
/// Het model van het formulier. Bewust GEEN [SupplyParameterFromForm]: dit formulier heeft
/// geen invoervelden, er valt dus niets te binden.
///
/// Met dat kenmerk erop ging het juist mis. Het kenmerk stond er zonder FormName, terwijl
/// het formulier FormName="uitnodiging" heeft. Die twee horen bij elkaar; kloppen ze niet,
/// dan zet de binder de waarde op null en is de "= new()" hierboven weg. EditForm kreeg dan
/// een Model van null en gooide "EditForm requires either a Model parameter" - een harde
/// fout bij het openen van de uitnodigingslink, nog voordat er iets te accepteren viel.
/// </summary>
private LeegFormulier Invoer { get; } = new();
private string? Fout;
/// <summary>Staat er een knop naar het inlogscherm bij de melding?</summary>
private bool LogEerstIn;
private async Task Aannemen()
{
var context = HttpContexten.HttpContext!;
// Ben je al ingelogd, dan neem je deze uitnodiging aan als jezelf en gaat het profiel
// op in dat van jou. Dat is hoe twee mensen die allebei een profiel voor je hebben
// aangemaakt tot één persoon worden.
var ingelogd = context.User.UserId();
var resultaat = await Families.AcceptInvitationAsync(
Token, Accounts, context.RequestAborted, ingelogd, Samenvoegen);
if (!resultaat.Succeeded)
{
Fout = resultaat.Error;
LogEerstIn = ingelogd is null && Fout is not null && Fout.Contains("Log eerst in");
return;
}
// Al ingelogd? Dan is er niets aan je aanmelding veranderd en gaan we gewoon verder.
if (ingelogd is null) await Inloggen.MeldAanAsync(context, resultaat.Value!);
Navigatie.NavigateTo("/", forceLoad: true);
}
/// <summary>De uitnodigingslink is zelf het bewijs; er hoeft niets ingevuld te worden.</summary>
public class LeegFormulier { }
}
@@ -0,0 +1,19 @@
@page "/wat-is-mijnbesluit"
@attribute [AllowAnonymous]
<PageTitle>Wat is MijnBesluit?</PageTitle>
<div class="middenblok" style="max-width:640px">
<h1>Wat is MijnBesluit?</h1>
@* De tekst zelf staat in Productuitleg, want de app toont hem ook - daar als popup vanaf
het overzicht. Kop en knoppen staan hier omdat ze per plek verschillen. *@
<Productuitleg />
<p style="margin-top:1.5rem">
<a class="knop" href="/registreren">Een account aanmaken</a>
<a class="knop rustig" href="/inloggen">Inloggen</a>
</p>
<p class="voet"><a href="/inloggen">Terug naar inloggen</a></p>
</div>
@@ -0,0 +1,10 @@
<Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData">
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
<NotAuthorized>
<RedirectNaarInloggen />
</NotAuthorized>
</AuthorizeRouteView>
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>
@@ -0,0 +1,20 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Http
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using MijnBesluit.Web
@using MijnBesluit.Web.Components
@using MijnBesluit.Web.Components.Account
@using MijnBesluit.Web.Components.Onderdelen
@using MijnBesluit.Application.Models
@using MijnBesluit.Application.Services
@using MijnBesluit.Domain.Entities
@using MijnBesluit.Domain.Enums
@using MijnBesluit.Domain.Rules
+35
View File
@@ -0,0 +1,35 @@
using System.Security.Claims;
namespace MijnBesluit.Web;
/// <summary>Leest wie er is ingelogd uit de claims van het cookie.</summary>
public static class HuidigeGebruiker
{
public const string PersonIdClaim = "mijnbesluit:person";
/// <summary>
/// Staat er bij het inloggen in, en zegt alleen of het menu-item Beheer getoond wordt.
/// De beheerpagina's kijken zelf nog een keer in de database of je het echt bent; deze
/// claim is dus geen sleutel, alleen een lampje. Zie MainLayout voor waarom het niet
/// live uit de database komt.
/// </summary>
public const string BeheerClaim = "mijnbesluit:beheer";
public static long? UserId(this ClaimsPrincipal? user)
{
var value = user?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
return long.TryParse(value, out var id) ? id : null;
}
public static long? PersonId(this ClaimsPrincipal? user)
{
var value = user?.FindFirst(PersonIdClaim)?.Value;
return long.TryParse(value, out var id) ? id : null;
}
public static bool IsBeheerder(this ClaimsPrincipal? user) =>
user?.FindFirst(BeheerClaim)?.Value == "ja";
public static string Naam(this ClaimsPrincipal? user) =>
user?.FindFirst(ClaimTypes.Name)?.Value ?? "";
}
@@ -0,0 +1,83 @@
using System.Collections.Concurrent;
using System.Text;
using Microsoft.Extensions.Logging;
namespace MijnBesluit.Web.Logging;
/// <summary>
/// Schrijft de log naar een bestand per dag. Bewust zelfgeschreven en klein: onder IIS is de
/// console nergens te zien, en de stdout-log van IIS staat standaard uit en loopt vol.
/// Zonder dit is een mislukte e-mail of databasefout op de server onzichtbaar.
/// </summary>
public sealed class BestandLoggerProvider : ILoggerProvider
{
private readonly string _map;
private readonly object _slot = new();
private readonly ConcurrentDictionary<string, BestandLogger> _loggers = new();
public BestandLoggerProvider(string map)
{
_map = map;
Directory.CreateDirectory(map);
}
public ILogger CreateLogger(string categoryName) =>
_loggers.GetOrAdd(categoryName, naam => new BestandLogger(this, naam));
public void Dispose() => _loggers.Clear();
internal void Schrijf(string regel)
{
var pad = Path.Combine(_map, $"mijnbesluit-{DateTime.Now:yyyy-MM-dd}.log");
// Eén schrijver tegelijk; de log is geen hot path en zo raken regels niet door elkaar.
lock (_slot)
{
try { File.AppendAllText(pad, regel, Encoding.UTF8); }
catch { /* Een log die zelf de applicatie omlegt is erger dan geen log. */ }
}
}
private sealed class BestandLogger : ILogger
{
private readonly BestandLoggerProvider _provider;
private readonly string _categorie;
public BestandLogger(BestandLoggerProvider provider, string categorie)
{
_provider = provider;
_categorie = categorie;
}
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state,
Exception? exception, Func<TState, Exception?, string> formatter)
{
if (!IsEnabled(logLevel)) return;
var tekst = new StringBuilder()
.Append(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"))
.Append(' ').Append(Niveau(logLevel))
.Append(' ').Append(_categorie)
.Append(' ').Append(formatter(state, exception))
.AppendLine();
if (exception is not null) tekst.AppendLine(exception.ToString());
_provider.Schrijf(tekst.ToString());
}
private static string Niveau(LogLevel niveau) => niveau switch
{
LogLevel.Trace => "TRACE",
LogLevel.Debug => "DEBUG",
LogLevel.Information => "INFO ",
LogLevel.Warning => "WAARSCHUWING",
LogLevel.Error => "FOUT ",
LogLevel.Critical => "ERNSTIG",
_ => "?"
};
}
}
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>mijnbesluit-web</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\MijnBesluit.Application\MijnBesluit.Application.csproj" />
<ProjectReference Include="..\MijnBesluit.Infrastructure\MijnBesluit.Infrastructure.csproj" />
</ItemGroup>
</Project>
+193
View File
@@ -0,0 +1,193 @@
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.DataProtection;
using MijnBesluit.Infrastructure;
using MijnBesluit.Infrastructure.Security;
using MijnBesluit.Web;
using MijnBesluit.Web.Components;
// De website van MijnBesluit. Roept de Application-laag rechtstreeks aan: geen tussenliggende
// webservice, dus ook geen extra laag om te onderhouden of te beveiligen.
// Eenmalig een sleutelbestand aanmaken, bijvoorbeeld bij het inrichten van een server:
// MijnBesluit.Web.exe --maak-sleutel D:\MijnBesluitData\keys\masterkey.json
// Zonder dit bestand is geen enkele kluis te openen; mét dit bestand en een databasekopie is
// alles leesbaar. Bewaar het dus apart van de database-back-up.
if (args.Length > 0 && args[0] == "--maak-sleutel")
{
var pad = args.Length > 1 ? args[1] : "keys/masterkey.json";
try
{
FileMasterKeyProtector.CreateKeyFile(pad);
Console.WriteLine($"Sleutelbestand aangemaakt: {Path.GetFullPath(pad)}");
Console.WriteLine("Maak hier NU een back-up van, op een andere plek dan de database-back-up.");
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine(ex.Message);
return 1;
}
}
var builder = WebApplication.CreateBuilder(args);
// Geheimen horen niet in de publicatiemap, want die wordt bij elke nieuwe publicatie
// overschreven. Wijs met "ExtraConfigFile" naar een bestand daarbuiten (zie
// appsettings.Production.json); wat daarin staat overschrijft de gewone instellingen.
//
// Bewust NIET optional: staat er een pad ingesteld en ontbreekt dat bestand, dan valt de
// applicatie stilletjes terug op de voorbeeldwaarden uit appsettings.json en krijg je even
// verderop een onbegrijpelijke "Access denied" van de database. Beter meteen hard stoppen
// met een melding die zegt wat er aan de hand is.
var extraInstellingen = builder.Configuration["ExtraConfigFile"];
if (!string.IsNullOrWhiteSpace(extraInstellingen))
{
if (!File.Exists(extraInstellingen))
{
throw new FileNotFoundException(
$"Het instellingenbestand '{extraInstellingen}' is niet gevonden. Het pad staat in " +
"appsettings.Production.json bij \"ExtraConfigFile\". Maak het bestand aan (zie " +
"docs/Uitrollen-naar-IIS.md, stap 6) of haal de instelling weg.",
extraInstellingen);
}
builder.Configuration.AddJsonFile(extraInstellingen, optional: false, reloadOnChange: true);
}
// Het advertentietarief wordt vanuit het beheerscherm gewijzigd (zie BeheerAdverteren.razor).
// Dat schrijft naar hetzelfde bestand als waar de instelling ook vandaan komt: het aparte
// productiebestand als dat is ingesteld, anders de gewone appsettings.json naast het programma.
builder.Services.AddSingleton(new MijnBesluit.Web.AdvertentietariefInstellingen(
!string.IsNullOrWhiteSpace(extraInstellingen)
? extraInstellingen!
: Path.Combine(AppContext.BaseDirectory, "appsettings.json")));
// Log naar een bestand, zodat je op de server kunt zien wát er misging. Staat er geen map
// ingesteld, dan verandert er niets (tijdens ontwikkelen is de console genoeg).
var logmap = builder.Configuration["Logging:Bestandsmap"];
if (!string.IsNullOrWhiteSpace(logmap))
builder.Logging.AddProvider(new MijnBesluit.Web.Logging.BestandLoggerProvider(logmap));
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
builder.Services.AddMijnBesluit(builder.Configuration);
// De sleutels waarmee ASP.NET cookies en formuliertokens beschermt. Standaard staan die in het
// profiel van het account waaronder de app draait; onder IIS bestaat dat profiel vaak niet,
// waardoor ze bij elke herstart van de app-pool weg zijn en iedereen ineens uitgelogd is of
// een antiforgery-fout krijgt. Daarom een vaste map, buiten de publicatiemap zodat een nieuwe
// publicatie ze niet weggooit.
var sleutelring = builder.Configuration["Security:DataProtectionPath"];
if (!string.IsNullOrWhiteSpace(sleutelring))
{
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(sleutelring))
.SetApplicationName("MijnBesluit");
}
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.LoginPath = "/inloggen";
options.LogoutPath = "/uitloggen";
options.AccessDeniedPath = "/inloggen";
options.ExpireTimeSpan = TimeSpan.FromHours(8);
options.SlidingExpiration = true;
options.Cookie.Name = "mijnbesluit";
options.Cookie.HttpOnly = true;
options.Cookie.SameSite = SameSiteMode.Lax;
// Alleen over https, zodat het cookie nooit over een open verbinding meegaat.
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
})
// Adverteerders hebben een eigen cookie, los van de gebruikers van MijnBesluit. Niet uit
// netheid: een adverteerder is geen gebruiker en hoort nooit per ongeluk als gebruiker te
// gelden. Met één cookie en een claim erin is dat één vergeten controle bij elkaar; met twee
// schema's kan het niet. Het cookie gaat ook alleen mee naar /adverteren.
.AddCookie(Adverteerderslogin.Schema, options =>
{
options.LoginPath = "/adverteren";
options.AccessDeniedPath = "/adverteren";
options.ExpireTimeSpan = TimeSpan.FromHours(8);
options.SlidingExpiration = true;
options.Cookie.Name = "mijnbesluit-adverteren";
options.Cookie.Path = "/adverteren";
options.Cookie.HttpOnly = true;
options.Cookie.SameSite = SameSiteMode.Lax;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
});
builder.Services.AddAuthorization();
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddHttpContextAccessor();
builder.Services.AddAntiforgery();
var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
app.UseHsts();
}
app.UseHttpsRedirection();
// Een paar kopteksten die weinig kosten en veel schelen.
app.Use(async (context, next) =>
{
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
context.Response.Headers["Referrer-Policy"] = "no-referrer";
context.Response.Headers["X-Frame-Options"] = "DENY";
await next();
});
app.UseStaticFiles();
// De volgorde is hier geen smaak. UseAntiforgery hoort NA UseAuthentication en
// UseAuthorization: het formuliertoken wordt aan de aangemelde gebruiker gekoppeld, en draait
// de controle voordat die gebruiker bekend is, dan gebeurt dat tegen een anonieme identiteit.
// Het gevolg is een formulier dat bij het opsturen wordt geweigerd - een kale HTTP 400 zonder
// verdere uitleg. Stond hier eerder andersom.
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
// Uitloggen is bewust een eigen endpoint en geen Blazor-pagina. In een pagina gebeurt het
// uitloggen tijdens het renderen, en dan is het antwoord vaak al onderweg; het Set-Cookie
// waarmee de aanmelding wordt gewist komt dan niet meer mee en je blijft gewoon ingelogd.
app.MapPost("/uitloggen", async (HttpContext context, IAntiforgery antiforgery) =>
{
try
{
await antiforgery.ValidateRequestAsync(context);
}
catch (AntiforgeryValidationException)
{
// Verlopen formuliertoken, bijvoorbeeld na een herstart. Dan is er niets te doen
// behalve opnieuw beginnen; het cookie wissen we voor de zekerheid ook.
await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return Results.Redirect("/inloggen");
}
await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return Results.Redirect("/inloggen");
});
// Uitloggen uit het adverteerdersgedeelte. Eigen endpoint om dezelfde reden als hierboven: het
// wissen van het cookie hoort niet tijdens het renderen van een pagina te gebeuren.
app.MapPost("/adverteren/uitloggen", async (HttpContext context, IAntiforgery antiforgery) =>
{
try { await antiforgery.ValidateRequestAsync(context); }
catch (AntiforgeryValidationException) { /* verlopen token; wissen doen we toch. */ }
await context.SignOutAsync(Adverteerderslogin.Schema);
return Results.Redirect("/adverteren");
});
app.MapBijlagen();
app.MapAdvertenties();
app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
app.Run();
return 0;
@@ -0,0 +1,23 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"profiles": {
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7014;http://localhost:5009",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5009",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,17 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Security": {
// Maakt bij de eerste start keys/masterkey.json aan als dat er nog niet is.
// Alleen voor ontwikkelen; zie docs/Beveiliging.md.
"MaakSleutelbestandAlsHetOntbreekt": true
},
"Smtp": {
// Uitnodigingen worden als html-bestand in de map mail-uitgaand gezet in plaats van verstuurd.
"WriteToDiskInsteadOfSending": true
}
}
@@ -0,0 +1,39 @@
{
// Instellingen voor de server. Hier staan GEEN geheimen: de connectiereeks en de
// e-mailinstellingen komen uit het bestand waar "ExtraConfigFile" naar wijst, dat buiten
// de publicatiemap staat en dus niet overschreven wordt bij een nieuwe publicatie.
"ExtraConfigFile": "C:\\MijnBesluitData\\appsettings.secrets.json",
"Security": {
// Zonder dit bestand is geen enkele kluis te openen. Eenmalig aanmaken met:
// MijnBesluit.Web.exe --maak-sleutel C:\MijnBesluitData\keys\masterkey.json
"MasterKeyFile": "C:\\MijnBesluitData\\keys\\masterkey.json",
// De sleutels waarmee ASP.NET cookies en formuliertokens beschermt. Zonder vaste map
// is iedereen uitgelogd zodra IIS de app-pool herstart.
"DataProtectionPath": "C:\\MijnBesluitData\\dataprotection"
},
"Storage": {
"Path": "C:\\MijnBesluitData\\kluisbestanden"
},
"Site": {
"Url": "https://mijnbesluit.nl"
},
"Logging": {
// Hierheen schrijft de applicatie zelf haar log, één bestand per dag. Zonder deze regel
// zie je op de server niet wat er misgaat, want de console van IIS is nergens zichtbaar.
"Bestandsmap": "C:\\MijnBesluitData\\logs",
"LogLevel": {
"Default": "Warning",
"MijnBesluit": "Information",
// Anders zie je niet eens dat de applicatie is opgestart en op welke poort.
"Microsoft.Hosting.Lifetime": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "mijnbesluit.nl;www.mijnbesluit.nl"
}
+27
View File
@@ -0,0 +1,27 @@
{
"ConnectionStrings": {
"MijnBesluit": "Server=localhost;Port=3306;Database=mijnbesluit;User Id=mijnbesluit_app;Password=VERVANG_DIT;"
},
"Security": {
"MasterKeyFile": "keys/masterkey.json"
},
"Storage": {
"Path": "kluisbestanden"
},
"Site": {
"Url": "https://localhost:7014"
},
"Smtp": {
"Host": "localhost",
"Port": 587,
"UseStartTls": true,
"FromAddress": "noreply@mijnbesluit.nl",
"FromName": "MijnBesluit",
"WriteToDiskInsteadOfSending": true,
"DropFolder": "mail-uitgaand"
},
"Logging": {
"LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" }
},
"AllowedHosts": "*"
}
+50
View File
@@ -0,0 +1,50 @@
// Laat de prijs meelopen terwijl een adverteerder datums en aantallen invult.
//
// Waarom dit een apart bestand is en niet een <script> in de pagina zelf: die pagina wordt
// statisch weergegeven, en Blazor vervangt bij het navigeren alleen de HTML. Een <script> dat
// op die manier binnenkomt wordt NIET uitgevoerd. Het gevolg was een prijs die niets deed zodra
// je via een link op het boekscherm kwam - en wel werkte als je de pagina zelf ververste, wat
// het extra verwarrend maakt. Dit bestand staat in de kop van elke pagina en is er dus altijd.
//
// Wat hier wordt uitgerekend is een gemak, geen bedrag. De server rekent bij het indienen zelf
// uit wat het kost; wie met deze pagina knoeit, verandert alleen wat hij zelf ziet staan.
window.mbPrijs = function () {
var vak = document.getElementById('prijsvak');
var vanaf = document.getElementById('vanaf');
var tot = document.getElementById('tot');
var perUur = document.getElementById('peruur');
var prijs = document.getElementById('prijs');
var som = document.getElementById('prijssom');
if (!vak || !vanaf || !tot || !perUur || !prijs) return;
var tarief = parseInt(vak.dataset.tarief, 10);
var bodem = parseInt(vak.dataset.bodem, 10);
var max = parseInt(vak.dataset.max, 10);
var van = Date.parse(vanaf.value);
var totdat = Date.parse(tot.value);
var keer = parseInt(perUur.value, 10);
function schrijf(bedrag, uitleg) {
prijs.textContent = bedrag;
if (som) som.textContent = uitleg;
}
if (isNaN(van) || isNaN(totdat) || isNaN(keer)) return schrijf('—', '');
if (totdat < van) return schrijf('—', 'de einddatum ligt voor de begindatum');
if (keer < 1) return schrijf('—', 'vul in hoe vaak per uur');
if (keer > max) return schrijf('—', 'hoogstens ' + max + ' keer per uur');
var dagen = Math.round((totdat - van) / 86400000) + 1;
var centen = Math.max(dagen * keer * tarief, bodem);
schrijf(euro(centen),
'(' + dagen + ' dagen × ' + keer + ' per uur × ' + tarief + ' cent, ' +
'met een minimum van ' + euro(bodem) + ')');
};
// Zelfde vorm als op de server: een komma, want hier leest een mens het.
function euro(centen) {
return '€ ' + (centen / 100).toFixed(2).replace('.', ',');
}
+577
View File
@@ -0,0 +1,577 @@
/* 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); }
/* Een <button> die eruit moet zien als een link (bijv. omdat hij naar een popup opent in
plaats van naar een andere pagina) - anders krijgt hij de standaard knoprand/achtergrond
van de browser mee. */
/* Een <button> die eruit moet zien als de "?"-uitleg-regel (zie .uitklap hieronder),
omdat hij net als die een popup opent in plaats van naar een andere pagina te gaan. */
.alsLink {
background: none; border: none; padding: 0; margin: .3rem 0; display: inline-flex;
align-items: center; gap: .4rem; cursor: pointer;
color: var(--accent-donker); font: inherit; font-size: .9rem;
}
.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;
}
/* ------------------------------------------------------------------ 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. */
padding-top: calc(4.5rem + env(safe-area-inset-top));
padding-bottom: calc(5.5rem + env(safe-area-inset-bottom));
}
/* 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;
}
/* Het logobestand is 512x512 voor scherp gebruik elders; zonder deze regel toont de browser
het op ware grootte en overspoelt het de hele kopbalk (en de rest van de pagina). */
.kop .merk img { height: 28px; width: auto; flex: none; }
.kop .merk .stip {
width: 26px; height: 26px; border-radius: 7px; flex: none;
background: var(--merk); color: #fff;
display: flex; align-items: center; justify-content: center;
font-size: .72rem; font-weight: 700; letter-spacing: -.02em;
}
.kop .rechts { margin-left: auto; display: flex; align-items: center; gap: .9rem; font-size: .92rem; }
/* Op een telefoon duwt het logo boven het inlogscherm de rest een heel stuk naar beneden -
op dat scherm staat er toch nog niets anders in de balk dan "Inloggen". Op een groter
scherm is daar ruimte genoeg voor en blijft het logo gewoon staan. Zie het "inlog-scherm"
-merkteken op Inloggen.razor. */
@media (max-width: 480px) {
.schil:has(.inlog-scherm) .kop .merk { display: none; }
}
.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); }
/* Twee kleine knoppen naast elkaar op een tegel, 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, met deze kleine tussenruimte, is dat
133. Daarom staan er hier iconen en geen woorden. */
.tegelknoppen { display: flex; gap: .35rem; flex: none; }
.tegelknoppen .knop { padding-left: .6rem; padding-right: .6rem; }
/* 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 met knoppen rechtsonder in de hoek, in plaats van naast de tekst.
Zo loopt het onderwerp door tot het eind van de tegel, boven de knoppen langs, en houdt het
op een telefoon van 360 pixels 222 pixels over in plaats van 133. Alleen de onderste regel
maakt ruimte vrij voor de knoppen; die staat er precies naast.
De hoogte blijft gelijk: de knoppen zweven over de ruimte die er toch al was. */
.kaart.tegel { position: relative; }
.kaart.tegel .tegelknoppen { position: absolute; right: 1rem; bottom: .85rem; }
.kaart.tegel .rij.persoon > .groei > .zacht:last-child { padding-right: 6.2rem; }
/* 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;
}
/* De regel onder de naam: "Partner (59)" links, de status rechts.
De status stond eerst naast de naam, maar dan houdt die op een telefoon zo weinig over dat
zelfs "Karin Ophuis" werd afgekapt. Hier gemeten: 115 pixels naast de naam tegen 242 op deze
regel. De status staat nog steeds altijd op dezelfde plek rechts op de tegel - alleen een
regel lager - en de naam krijgt de volle breedte. */
.onderregel { display: flex; align-items: center; gap: .6rem; }
.onderregel > .zacht { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.onderregel > .label { flex: none; }
/* 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; }
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. */
.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); }
/* --------------------------------------------------------------- statistieken (beheer) */
.statgrid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: .8rem; margin: .8rem 0 1.2rem;
}
.statcel {
background: var(--vlak-zacht); border-radius: var(--straal); padding: .8rem 1rem;
}
.statgetal { display: block; font-size: 1.6rem; font-weight: 600; color: var(--accent-donker); }
.statlabel { display: block; font-size: .85rem; color: var(--inkt-zacht); margin-top: .1rem; }
.grafiek { margin: .5rem 0 .5rem; }
.grafieksvg { width: 100%; height: 160px; display: block; }
.grafiek-as { stroke: var(--rand); stroke-width: 1; }
.grafiek-lijn { fill: none; stroke-width: 2.5; }
.grafiek-lijn-profielen { stroke: var(--accent); }
.grafiek-lijn-kluis { stroke: var(--waarschuwing); }
.grafiek-onderschrift { display: flex; justify-content: space-between; margin-top: .2rem; }
.grafiek-legenda { display: flex; align-items: center; flex-wrap: wrap; gap: .3rem; margin-top: .6rem; }
.grafiek-legendapunt {
display: inline-block; width: .7rem; height: .7rem; border-radius: 999px;
background: var(--accent); margin-right: .4rem;
}
.grafiek-legendapunt.grafiek-lijn-kluis { background: var(--waarschuwing); }
hr { border: none; border-top: 1px solid var(--rand); margin: 1.25rem 0; }
.middenblok { max-width: 420px; margin: 3rem auto; }
/* Op een telefoon staat het logo al niet meer boven het inlogscherm (zie hierboven); die
3rem erboven was juist bedoeld om ruimte te maken naast een balk die verder leeg was. Zonder
logo mag "Log in" gewoon vlak onder de balk beginnen. */
@media (max-width: 480px) {
.inlog-scherm { margin-top: 1rem; }
}
#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); }
.naar-adres { font-weight: 600; }
.voet { text-align: center; margin-top: 2rem; font-size: .9rem; }
.voet a { color: var(--inkt-zacht); }
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+33
View File
@@ -0,0 +1,33 @@
// 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, in plaats van een schuifbalk te tonen. Gebruikt
// voor Muziek en Verdere wensen bij een kluisitem: net zo prettig als je in één keer wilt zien
// wat je hebt opgeschreven. Zie textarea[data-meegroeit] in app.css.
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();
};