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
+9
View File
@@ -0,0 +1,9 @@
bin/
obj/
.vs/
*.user
keys/
kluisbestanden/
mail-uitgaand/
appsettings.*.local.json
uitroller.secrets.json
@@ -0,0 +1,137 @@
%PDF-1.4
%“Œ‹ž ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R /F2 3 0 R /F3 4 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
>>
endobj
5 0 obj
<<
/Contents 12 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 11 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
6 0 obj
<<
/Contents 13 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 11 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
7 0 obj
<<
/Contents 14 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 11 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
8 0 obj
<<
/Contents 15 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 11 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
9 0 obj
<<
/PageMode /UseNone /Pages 11 0 R /Type /Catalog
>>
endobj
10 0 obj
<<
/Author (\(anonymous\)) /CreationDate (D:20260918062200+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260918062200+00'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (\(unspecified\)) /Title (Stappenplan: Google Play en Apple App Store \204 MijnBesluit) /Trapped /False
>>
endobj
11 0 obj
<<
/Count 4 /Kids [ 5 0 R 6 0 R 7 0 R 8 0 R ] /Type /Pages
>>
endobj
12 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1971
>>
stream
Gatm<96])L(u=@VHqVm$>g&F?Z^TtI0rJ_ISMi:WYRsuW"LdEs%@lS4c]nsl%$(sg;G<$#l$M96"X=s>em'R?dEN]FrP44LFSA%Z>Fb+Fe,-b8IkTI>8`-fNaGC`;?=:%ZO"_*^QJ;<^b7/;IElbORa7W[eP7+J6HJ@4dfYSf0ftpuY'D"Q6F+VJl`\dH?6qOYZWn+#J0'R7GGAh0)r=k4e$a4u5ERlp^CNjB6B?d$-S'9MmEBJ)nh5;4e4CBk6#%,k:^QDMW=#GsW5G4'@-`Nn[WVB%?VsC(m*No%P(q[6LLiJI(1QKiqmnXa#(idR"FVoeeAb^A8)n>P=j$hh+O:h43.h&0a2_"\b[(IL6rM2,3oUUND:EJ37:/62[>A&XEo/BPhRMZKOA6c/!6^>_\/nA;96L0fS;B\+gjI&\OPp\=Ok]^E=U$jd4*r#Pd7;h%.*a+Wg^iT:o"5A`mA]A)sD;q.&GoRPV1j>dlLGp#mZYF9)P<Z[ae_)BL$o#_N;%U5PCk?&!DR6S!"Q?t_4P!LUb=_*<Shf30+r`_:]eTEa`j\Su)r@7YY334<7'.qA,2@9MrF6kV_ktM%--pKG\&3TbA[6^?m;k5BnN$`UYTRtHGo41pDVkp'*e#./38)Jr2r=(f#Hh^og^T=Ta^6Q\mt[<-WJCSQ1`Ao=QDI\r;WX[skFFfgN#efrrh+be!3+uo;QW&Ek5ME(n@]fCW]lBe)c+.qS.W0p[VPQL3bT<=[[dPD@!K<cdp.^QE(AMc?ojr0klRrq2KJ;Cr"L?;k#%)M/P<D^PFm2lpk8D6(l&+?QS#GYM6)>1F)/k8$q5>h,dK?2rmhduS&)RGn;`k/16^mE"L.V$*Z_%nQ_0ClRtdd);G?2J7LUC#G`7]GlS8!#!uNR`A1$iK$3-d%OeY>+^QJ>l$$<qLp45ZiQ9i]SfX0GS(k^396O(.mQPR7J29iAi;U.rhc:_F4?WE?V==*?4:O4J=[H%m5e]Nkpnu?^a!EXgb)a(*+nL.P#2(3fnfbU4U6KbUils%POU!j-E<ts;oBIXSajY3`W=n93]<+"7E?DmW(I4[;p>j$++<I=q22I_7umV,.X13FU4Nb<Y$%7W_1='FUO=\`Z)AqeWHZPXn9c14W#jZ7$V(kuZsid50TZE?MND0IR;c"dQ:4!E;9DhQgN,26JHHaZfDgB0P5G1W$@nF_'7G"t>Wm2e06^F4f0_kp'>=\9S#FEZ,2)ofT9FP6rD2pus"Ep-<p,ZdWsKPHq+V"LhVl*c9Vf!q]jkZ9"q]K!bZg5!:(59n$pQAN[_"h?+<@PZ@&*3cH?>:1e>FqD"1\R?l6?NA%7Ym:F`<dD>5/:'a3KS9H[ca)B\+T"hU.306YJ@u0KAPX3.U.,'W1g6A4RZV#?"LsCJ<ll(/#)QRWe1.G_Y!N/+QJZ3!NpngV%6(2+<Rm7EErI>fRf18>EZg?C&_jC4YKL'KL^CpZep.bb6=e=Z3%d.deJJO-02_uQ3EBX+X^f\\1&'ce`pFGLrNn1HFj.[6.?4"JH(^2SIW.AdY-h$*dHJ0/%@bMHM@'W6/]N-N]RbZ.Lfmr:m7&gVIf5`=/QHPCNf$el)V'CJl7'2A=-]TIDn!ueW<?;3^#.s:OlLJbB'[e'M*_Dq0i@kfM2M5_6JG`?ZOPR"8@=6a2>eQ%IZirJ$#_<V^e8cD/b59p-uJ1S#!ER=2L[Lf;<='0dk,\[)guOe9l6\*Hn:_8n>jCt?5uYbH*(K2X*c6mGeX%-"U2E'Vs0nJhT0I&#QS/gd4;4D@(*^oe9,n&'2cq@QO7snd<R;jobq>$'%8Ki^]3<W]c-?hct,+25p3&:>mP*uL;PK@d/+-`im1$a?D]MM)_*VXIhdY<gF&+$IQ%K?/K66!"`*B9Un;&s\8GYQ:Hu3(.G?jsRG7BHbim=NGn.<Q+c+;?&8p)e:fG%>ghff.I-X2@O.YVQ$[O0]](cg$W+uS~>endstream
endobj
13 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 494
>>
stream
Garo=c#9++&B4,9(%8Yl[#U-j=-'C?OfJ,:L/Z'F_!I@mYUO@nCB+,?YTKj=@Y*T!Dr,EN-qHO:r`1&Da`7?RTL.^D-mK/kYP=Um^B/P$-;pXZX:,`,$C/RJpMj>HWR7f)bX=3Z+js&)'uMZ.g_VK.8;kQ&NAY&I_Y,UDF!b*D=?0m/G>Kof$\t;<3jU^g6/Fmr;oei(a@;l$$mmEg`"4s:1SZ(WSfVKP[@pN%%Nr'%.BC@4m."7_HpGF/%"6B&IJR%[hjV77d'4T1s4l&dWBD"ca5Y8dY04Nq#Prl@DP2'hD2a`>9BmuM^Vf>lZS`2j9+T><V6+aq&Ql?a2'*dc1>pfkL[aRK]BKj6gR>oK1m[TtWAqDJX[O'R1Mm1U3k&nX=]Nsu%eIoMfM.L:U%-'`1`RH<8BWhSakm*aJLWV.if_CKGiCN]j9ra"i.$7OqdUb3#cGl"]A)?@f&Cs-aLAbY8!Z*k)oqiCVthJ1$[IW;kl~>endstream
endobj
14 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2384
>>
stream
Gatm<gN)%.&q0LUoYX^h1*n[775q-QC6OX@GS;HrOciC4q1U"9$8tIp#Y7Scom7UQ-tst&3`]B-#nUcl+.PM(Qb)t%p,gEQhG!.p4-s35-8TZE-_SR/3h)Z;FlLt8qju7YGpAX[]n,4(qBNY.B#hB1jS6Wq5Oun[%R%Si;jj.`9XgF%5a)UTGi\RC$I@`=>-Ei.'ngKD)c[6VC<8-hWu!F&E_Va)fmquL3FkN:fJ6oK-9lJ9A3A8;C/nQ[k=H]'5O<`LXl!O9ojB8Wh?jTu`N;[D,W3PmY@9Ufq1`5=EN*L(=c-*MED"ftQgaiakgkI#cJJnu/u3KkT!,HfZC/DV^?;p(VGcJes#guTc%T:^V5716,>:raUEYuO/V]od`'U9)6XL1P$Rm^G:n#Wi5"mRmOj$C)XR@7GMs3/L<mC_uEa`qCm7=&a=JAM,)E=Ru79\.G`a"GCZmcC#ZL3G84N;53=<(UlE-N5^\&'!7OQ!&7aZIK+KeV[^s4`bGYs6!E'GuJ*1[OklJ37("#b=!4$bRX.4f@JS6^Ki"5_.eZfDFFqLV_PG_pQBYnj^<p[g!A5bOaMk[sARTbO'XP=;Q9J_87<S1f`/IV':I?KU,`Q4^,Z%h*Dc,oYTe[`"=iEiS#IAXr`,u#,?&Xm?<o@mMZn4h1e([phNNJ+oBdJNcqD>HOt=A^32\a`gqL>AYN^o0?Z$M`g9;_9HJ,BDbc#d7$L$VHapLG.2_inDE%'?D<tq%;\>AeHDV@<Lo&0Mc(uhd$CAdl!MJ-?TBf/=n\4Ad#CP28T&@dA+2#RM;cUW$Q_d<s0Pal6<b2p+;MPJEWSL_Sjt=FJCG[4Vl5EX_ct=*:jODVNLuq/,Y5$E.TTuNj.0b"``jB%Empr+c_mgDlTg)P=O<!RgAV00S@b?qoDa]W4YJaC5b^t:+Ct6_&o,NK<deFN@4B):%^u3[&$03%CAX_CgWE($X^laC\<bD[6\GWjk+S0L]Qe?'VbqWu76ul('1rI'Z9">A*T5BH%2T])pQR[u[0R&)l"#W1SAH&>ik';`&IINI1HNl]>KFpPCLfEGPqBJm?^*m"/Af(?2Te+;6mY'>F[l!u[5Yg-2cdnEu\%cUFWl:t>UQ4JFYti2FRL8A.jN$K-fV*4]0+ab\=i=5.oC"GqeOgJX`Y=aDendU+LGm)lc&=3HPa0[Ar8HoEa#)mo$i>0COie$!_?,Wa"Up\d!/mKZQ3L?mB&jA_Y#fNrKB;tnQiD-<&X_,:#9X;?*1I@@'#1M,g-#6#,#^bN-^=]-Gl(7[FM#l<9^l`>\:a2Oo\kEVo*0cJI9JJo!K7;$"=0?L22@mqSpP7@/^b1CpXF(;/)]\hb<4`<$R?*I4n_gfU$KO;Wh>qen%qWBq:h5L[><tTfSJV>8u?]I3MrOX3Af>;6lnf=6)9aV+Ub"%4V+)VYlQ+7?HJVJ.s,[e4u[Ssa+qt+s&8ahX(8=pjZRTC6K`WoP,&kilk\Fhm6cH1%r]8']"Jn(I^os:?/?Pa>O:u(mEFE1Xn*h']KhGW5HqY[+hbVfXHC?O"mIc)1>-]_N_[Op*N]]&(OD*@Ck0/(l!3>]'88k7Q)QT)X)*]U/YtoMmgn3t^hS"<D1I,%)ei34Tk`"NO\0I1qU9I+`F.LiLm]hWr(.X$Ci-jsF]DF6q#VR<SG[YFBJQ;e,d1Te1CXu2Tk0Jk\4C-?Asm%*lq4'to%p_Gf\k09*r])"k8`"j]U?*%TK0eaYt3ih_iap3Z4%dX6Dm_daLTH'c+8pj/b9QA/d\HHR`#_)/7LAd>RlsMMW93]^HQXlUO[+nB/g_4^5H@Uo`m\Zrr0k#dAj1f1(&?AdgK!Qn1E((;g<.:nSQa.l>!p;\*G@nTsgEb[V:=FgJ,1`2[)9D"I0S6*BV4/qO9j2_]A[SFtn=p;L]A:E(U3_a.B`T69]8^%\6E"JHN[j-J&j4@W;@XMsCi?[g7Q,JW6?ZE]k6oF9:(S(q@"Nlj]FSm%#0O7o).W\T.q-ojP]Af<*Y%U]<cJ<%E57>>6BpS+sePL)&>(bcmV,G(#7a'j\Mj/]s,K#%"g@o^oD^iU?Us7b_04;^Fh_ZkI,!)k(`'C'GB>0]d;-hCFkei`2g<mFX,]-4NW9:mi[M2V^;`WD.s9""0DnXX]APVVJSXZQB>Zh9:.XAZbo0SFaji:Va?aT2g@66Y(m2bG4W@_+_U.#"395fA'F)b(je5ZDc2kST%H&1mH4[SdStP>ZS7P-#Alp_N`>f;6oJ@=B4]mb;U8p7qX0Kjj/_(jDk\&T9]k1A+'*3==+eM.r".r7QTH>Qt)bsfUhACK;s6o+Xa0EDKH`M-eBi$1M[_N;DTgPVN>c#R^I&S))eqT`T`D73M)Np]@e3@&J1N2+3JrQ0E~>endstream
endobj
15 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 969
>>
stream
Gas1]bEC=.&A70Vjqt$bCKZ+,K7]qm[Yi+o>;!3EnLCsn,:hH!Y@hAN-OU<%-'"dhmN-(G*!1H]oNUtgD[O"bjhO53p:qmg6#d.uMUhG95=_RObE@NKOJIKu4NT%5Q<BeJJnA.,`Bg'rbOU0a_)<h2o7@+>=Kjat!k*Pg&b[kZ_1YEH^PRT"3;e]#(:#_p*-%Ps^dK1)&I6=0s1m7\`'cC4M_bi0Y_\&hoH8D$dNfUO<!W/hN\@6,3D6(0J]FAUju+JC-G5FiES1QI`?QRsACmN07k$#0BP7i5TJUc1%.;XR-k>FGB"U"fepR2nOE-;'V8X3mNR2'IO/a4k_+mu@:?`)HS1cMj+8"EoTi7``$QFtiYhlL#AVKX4m05&nUQle^<FsG3(qkL6EMc]HVi5sfCe7/FV$$DT*_:r("rNo*K%pAsOSc:E.Yj8_du[*^0m]"r3P$9Dg8j"_@1#5]&uF*.(A&4:T(Gp(<5Jk)is=0;<\;hDJJki3j!,_u<r-chDM2_AKMih`28*4MC(g8k%VT'9LE6#A"t-G]%A$BPg<>Xn+R,Z#>4h#4(h4T)@1Z6^mbPO2l\9S1rWfWbq^p>UfAoTOVH1sY\QbhDk]husqs=],WO/jM"!.KWID,69/YXQNWHLN)"Kkeh>CgO01GfCe*HWQpPYmF,ndnD=]epfk-2MI'Y`k?Dh[Jjq+;s,G4l-Shl$N2t"]u\\j'oRu@QHaGSZes"^KLo79F"XM=!>liI9At_5"\/-jC(f5P0:i6NrXWOF)]Rn$_nYnWq&)=lE:hH$SKl6YKrZ$S7NSo0#=:?3VY;AJq)d%55SU/53J#'-BBQ9a=_Cn-;ho5PJ=@lCXfO?<h*(=[Ah.bo$*Io_tn[6j1G_@IWW*L-.EUlSMXI%qEq)#p_1dfduCuql_3/.5)9XmIg46r@-mAR4t)OsgVSeNBBc8e`\\^Qk>)$OZYs7E$PnngO8~>endstream
endobj
xref
0 16
0000000000 65535 f
0000000061 00000 n
0000000112 00000 n
0000000219 00000 n
0000000331 00000 n
0000000436 00000 n
0000000641 00000 n
0000000846 00000 n
0000001051 00000 n
0000001256 00000 n
0000001325 00000 n
0000001653 00000 n
0000001731 00000 n
0000003794 00000 n
0000004379 00000 n
0000006855 00000 n
trailer
<<
/ID
[<9da3931a87c0b69c417bac11bd5b9730><9da3931a87c0b69c417bac11bd5b9730>]
% ReportLab generated PDF document -- digest (opensource)
/Info 10 0 R
/Root 9 0 R
/Size 16
>>
startxref
7915
%%EOF
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+17
View File
@@ -0,0 +1,17 @@
<Project>
<PropertyGroup>
<!-- Doelversie op één plek. Zet dit op net10.0 zodra je op .NET 10 wilt draaien;
de MAUI-app (net10.0-android) kan deze bibliotheken ook op net8.0 gewoon gebruiken. -->
<MijnBesluitTargetFramework>net8.0</MijnBesluitTargetFramework>
<!-- Vastgezet op de taalversie die bij net8.0 hoort, niet "latest".
Met "latest" hangt de betekenis van de code af van welke SDK er toevallig staat: een
nieuwere compiler koos bij `array.Contains(x)` de span-versie in plaats van
Enumerable.Contains, wat Entity Framework niet kan vertalen. Dat compileert gewoon en
valt pas op de server om. Bump dit samen met MijnBesluitTargetFramework. -->
<LangVersion>12.0</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
</Project>
+132
View File
@@ -0,0 +1,132 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{D0E3CF39-A5FF-4341-BAA9-2CD8D13A5B30}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MijnBesluit.Domain", "src\MijnBesluit.Domain\MijnBesluit.Domain.csproj", "{78C68EDC-CD50-4B72-969F-DA7FFD9CC953}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MijnBesluit.Application", "src\MijnBesluit.Application\MijnBesluit.Application.csproj", "{ED94DF5F-A8A1-447A-917D-B75ACE9DA78A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MijnBesluit.Protocol", "src\MijnBesluit.Protocol\MijnBesluit.Protocol.csproj", "{05A2CA84-85FB-4043-B86B-1558521484BE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MijnBesluit.Server", "src\MijnBesluit.Server\MijnBesluit.Server.csproj", "{9EB558C7-5637-4E6E-9A42-5874423778CA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MijnBesluit.Infrastructure", "src\MijnBesluit.Infrastructure\MijnBesluit.Infrastructure.csproj", "{CFEB6CB0-D389-4C0A-9909-82671191C261}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MijnBesluit.SocketServer", "src\MijnBesluit.SocketServer\MijnBesluit.SocketServer.csproj", "{D2FB773B-BFDD-4292-831F-5CF4D3C2DB31}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MijnBesluit.Web", "src\MijnBesluit.Web\MijnBesluit.Web.csproj", "{30ABA559-48CF-4EAF-A197-58A6514E641F}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{A8A4E659-A6B3-4C25-A7B9-7B894462ACE3}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MijnBesluit.SelfCheck", "tests\MijnBesluit.SelfCheck\MijnBesluit.SelfCheck.csproj", "{93F37592-FAB2-453C-9E60-B058F7BDB736}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MijnBesluit.ServiceBeheer", "src\MijnBesluit.ServiceBeheer\MijnBesluit.ServiceBeheer.csproj", "{EF9D3638-EBB0-447C-AE9B-C1A08FBB12D8}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MijnBesluit.Client", "src\MijnBesluit.Client\MijnBesluit.Client.csproj", "{2A06B933-9B91-4CC9-A7B1-D54398EC053C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MijnBesluit.AppUi", "src\MijnBesluit.AppUi\MijnBesluit.AppUi.csproj", "{61BAC227-5B8D-47B6-9F9E-A7B7921992E4}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MijnBesluit.App", "src\MijnBesluit.App\MijnBesluit.App.csproj", "{D9E4EDB8-2209-4FB1-B737-2EBC7BF00BB1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MijnBesluit.Uitrol", "src\MijnBesluit.Uitrol\MijnBesluit.Uitrol.csproj", "{6426481C-74D7-4F38-910E-026B64DB8655}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MijnBesluit.Uitrolagent", "src\MijnBesluit.Uitrolagent\MijnBesluit.Uitrolagent.csproj", "{C00A7624-20AA-41B8-A4FF-EA672C4FC440}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MijnBesluit.Uitroller", "src\MijnBesluit.Uitroller\MijnBesluit.Uitroller.csproj", "{8484E1EE-82AA-4CF8-AE43-9ABF93B70A84}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MijnBesluit.Agentvenster", "src\MijnBesluit.Agentvenster\MijnBesluit.Agentvenster.csproj", "{2C3F1A57-9B4D-4E62-8A71-5D6E0C48B913}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{78C68EDC-CD50-4B72-969F-DA7FFD9CC953}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{78C68EDC-CD50-4B72-969F-DA7FFD9CC953}.Debug|Any CPU.Build.0 = Debug|Any CPU
{78C68EDC-CD50-4B72-969F-DA7FFD9CC953}.Release|Any CPU.ActiveCfg = Release|Any CPU
{78C68EDC-CD50-4B72-969F-DA7FFD9CC953}.Release|Any CPU.Build.0 = Release|Any CPU
{ED94DF5F-A8A1-447A-917D-B75ACE9DA78A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{ED94DF5F-A8A1-447A-917D-B75ACE9DA78A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{ED94DF5F-A8A1-447A-917D-B75ACE9DA78A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{ED94DF5F-A8A1-447A-917D-B75ACE9DA78A}.Release|Any CPU.Build.0 = Release|Any CPU
{05A2CA84-85FB-4043-B86B-1558521484BE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{05A2CA84-85FB-4043-B86B-1558521484BE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{05A2CA84-85FB-4043-B86B-1558521484BE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{05A2CA84-85FB-4043-B86B-1558521484BE}.Release|Any CPU.Build.0 = Release|Any CPU
{9EB558C7-5637-4E6E-9A42-5874423778CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9EB558C7-5637-4E6E-9A42-5874423778CA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9EB558C7-5637-4E6E-9A42-5874423778CA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9EB558C7-5637-4E6E-9A42-5874423778CA}.Release|Any CPU.Build.0 = Release|Any CPU
{CFEB6CB0-D389-4C0A-9909-82671191C261}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CFEB6CB0-D389-4C0A-9909-82671191C261}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CFEB6CB0-D389-4C0A-9909-82671191C261}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CFEB6CB0-D389-4C0A-9909-82671191C261}.Release|Any CPU.Build.0 = Release|Any CPU
{D2FB773B-BFDD-4292-831F-5CF4D3C2DB31}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D2FB773B-BFDD-4292-831F-5CF4D3C2DB31}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D2FB773B-BFDD-4292-831F-5CF4D3C2DB31}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D2FB773B-BFDD-4292-831F-5CF4D3C2DB31}.Release|Any CPU.Build.0 = Release|Any CPU
{30ABA559-48CF-4EAF-A197-58A6514E641F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{30ABA559-48CF-4EAF-A197-58A6514E641F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{30ABA559-48CF-4EAF-A197-58A6514E641F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{30ABA559-48CF-4EAF-A197-58A6514E641F}.Release|Any CPU.Build.0 = Release|Any CPU
{93F37592-FAB2-453C-9E60-B058F7BDB736}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{93F37592-FAB2-453C-9E60-B058F7BDB736}.Debug|Any CPU.Build.0 = Debug|Any CPU
{93F37592-FAB2-453C-9E60-B058F7BDB736}.Release|Any CPU.ActiveCfg = Release|Any CPU
{93F37592-FAB2-453C-9E60-B058F7BDB736}.Release|Any CPU.Build.0 = Release|Any CPU
{EF9D3638-EBB0-447C-AE9B-C1A08FBB12D8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EF9D3638-EBB0-447C-AE9B-C1A08FBB12D8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EF9D3638-EBB0-447C-AE9B-C1A08FBB12D8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EF9D3638-EBB0-447C-AE9B-C1A08FBB12D8}.Release|Any CPU.Build.0 = Release|Any CPU
{2A06B933-9B91-4CC9-A7B1-D54398EC053C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2A06B933-9B91-4CC9-A7B1-D54398EC053C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2A06B933-9B91-4CC9-A7B1-D54398EC053C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2A06B933-9B91-4CC9-A7B1-D54398EC053C}.Release|Any CPU.Build.0 = Release|Any CPU
{61BAC227-5B8D-47B6-9F9E-A7B7921992E4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{61BAC227-5B8D-47B6-9F9E-A7B7921992E4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{61BAC227-5B8D-47B6-9F9E-A7B7921992E4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{61BAC227-5B8D-47B6-9F9E-A7B7921992E4}.Release|Any CPU.Build.0 = Release|Any CPU
{D9E4EDB8-2209-4FB1-B737-2EBC7BF00BB1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D9E4EDB8-2209-4FB1-B737-2EBC7BF00BB1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D9E4EDB8-2209-4FB1-B737-2EBC7BF00BB1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D9E4EDB8-2209-4FB1-B737-2EBC7BF00BB1}.Release|Any CPU.Build.0 = Release|Any CPU
{6426481C-74D7-4F38-910E-026B64DB8655}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6426481C-74D7-4F38-910E-026B64DB8655}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6426481C-74D7-4F38-910E-026B64DB8655}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6426481C-74D7-4F38-910E-026B64DB8655}.Release|Any CPU.Build.0 = Release|Any CPU
{C00A7624-20AA-41B8-A4FF-EA672C4FC440}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C00A7624-20AA-41B8-A4FF-EA672C4FC440}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C00A7624-20AA-41B8-A4FF-EA672C4FC440}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C00A7624-20AA-41B8-A4FF-EA672C4FC440}.Release|Any CPU.Build.0 = Release|Any CPU
{8484E1EE-82AA-4CF8-AE43-9ABF93B70A84}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8484E1EE-82AA-4CF8-AE43-9ABF93B70A84}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8484E1EE-82AA-4CF8-AE43-9ABF93B70A84}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8484E1EE-82AA-4CF8-AE43-9ABF93B70A84}.Release|Any CPU.Build.0 = Release|Any CPU
{2C3F1A57-9B4D-4E62-8A71-5D6E0C48B913}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2C3F1A57-9B4D-4E62-8A71-5D6E0C48B913}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2C3F1A57-9B4D-4E62-8A71-5D6E0C48B913}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2C3F1A57-9B4D-4E62-8A71-5D6E0C48B913}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{78C68EDC-CD50-4B72-969F-DA7FFD9CC953} = {D0E3CF39-A5FF-4341-BAA9-2CD8D13A5B30}
{ED94DF5F-A8A1-447A-917D-B75ACE9DA78A} = {D0E3CF39-A5FF-4341-BAA9-2CD8D13A5B30}
{05A2CA84-85FB-4043-B86B-1558521484BE} = {D0E3CF39-A5FF-4341-BAA9-2CD8D13A5B30}
{9EB558C7-5637-4E6E-9A42-5874423778CA} = {D0E3CF39-A5FF-4341-BAA9-2CD8D13A5B30}
{CFEB6CB0-D389-4C0A-9909-82671191C261} = {D0E3CF39-A5FF-4341-BAA9-2CD8D13A5B30}
{D2FB773B-BFDD-4292-831F-5CF4D3C2DB31} = {D0E3CF39-A5FF-4341-BAA9-2CD8D13A5B30}
{30ABA559-48CF-4EAF-A197-58A6514E641F} = {D0E3CF39-A5FF-4341-BAA9-2CD8D13A5B30}
{93F37592-FAB2-453C-9E60-B058F7BDB736} = {A8A4E659-A6B3-4C25-A7B9-7B894462ACE3}
{EF9D3638-EBB0-447C-AE9B-C1A08FBB12D8} = {D0E3CF39-A5FF-4341-BAA9-2CD8D13A5B30}
{2A06B933-9B91-4CC9-A7B1-D54398EC053C} = {D0E3CF39-A5FF-4341-BAA9-2CD8D13A5B30}
{61BAC227-5B8D-47B6-9F9E-A7B7921992E4} = {D0E3CF39-A5FF-4341-BAA9-2CD8D13A5B30}
{D9E4EDB8-2209-4FB1-B737-2EBC7BF00BB1} = {D0E3CF39-A5FF-4341-BAA9-2CD8D13A5B30}
{6426481C-74D7-4F38-910E-026B64DB8655} = {D0E3CF39-A5FF-4341-BAA9-2CD8D13A5B30}
{C00A7624-20AA-41B8-A4FF-EA672C4FC440} = {D0E3CF39-A5FF-4341-BAA9-2CD8D13A5B30}
EndGlobalSection
EndGlobal
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<!-- Let op: deze sectie staat hier alleen zodat de kernprojecten zonder internet te bouwen zijn.
Haal het <clear/>-blok weg (of verwijder dit bestand) zodra je in Visual Studio de EF Core-
en ASP.NET-packages wilt ophalen. -->
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
</packageSources>
</configuration>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources><clear /></packageSources>
</configuration>
+123
View File
@@ -0,0 +1,123 @@
# MijnBesluit
Een platform om je beslissingen bij leven vast te leggen in een digitale kluis, en om berichten
te versturen aan familie en vrienden — nu, op een datum in de toekomst, of pas nadat je er niet
meer bent.
Dit is de .NET-opzet: één solution in Visual Studio, een website, een zelfstandige SocketServer
voor de app, en een MariaDB-database.
## Snel aan de praat
### 1. Database
```
mysql -u root -p < db\01_schema.sql
mysql -u root -p < db\02_applicatiegebruiker.sql
```
Draaide er al een oudere versie? Draai dan ook `db\03_inloggen_met_code.sql`.
Pas eerst het wachtwoord in `02_applicatiegebruiker.sql` aan. Wil je een andere schemanaam dan
`mijnbesluit`, vervang die dan in beide scripts.
### 2. Sleutelbestand
Bij ontwikkelen hoef je niets te doen: de website maakt `keys/masterkey.json` bij de eerste start
zelf aan, omdat `Security:MaakSleutelbestandAlsHetOntbreekt` in `appsettings.Development.json`
aanstaat.
Voor een echte omgeving maak je het bestand eenmalig zelf aan en zet je die instelling **niet** aan:
```csharp
MijnBesluit.Infrastructure.Security.FileMasterKeyProtector.CreateKeyFile(@"D:\MijnBesluit\keys\masterkey.json");
```
Zonder dit bestand is geen enkele kluis meer te openen. Maak er een aparte back-up van, op een
andere plek dan de database-back-up. Zie `docs/Beveiliging.md`.
### 3. Instellingen
```
dotnet user-secrets set "ConnectionStrings:MijnBesluit" "Server=localhost;Port=3306;Database=mijnbesluit;User Id=mijnbesluit_app;Password=..." --project src\MijnBesluit.Web
dotnet user-secrets set "ConnectionStrings:MijnBesluit" "..." --project src\MijnBesluit.SocketServer
```
Tijdens ontwikkelen staat `Smtp:WriteToDiskInsteadOfSending` aan: uitnodigingsmails worden dan
als html-bestand in `mail-uitgaand` gezet in plaats van verstuurd. Handig om de uitnodigingslink
op te zoeken.
### 4. Draaien
```
dotnet run --project src\MijnBesluit.Web # de website
dotnet run --project src\MijnBesluit.SocketServer -- --service # de server voor de app
```
In Visual Studio: stel beide projecten in als startprojecten (rechtsklik op de solution →
*Opstartprojecten configureren* → *Meerdere startprojecten*). Geef het SocketServer-project
`--service` mee als argument, anders verschijnt het beheerschermpje in plaats van de server.
### 5. De SocketServer als Windows-service
```
dotnet publish src\MijnBesluit.SocketServer -c Release -r win-x64 --self-contained false -o D:\MijnBesluit\publish
```
Klik daarna met de rechtermuisknop op `MijnBesluit.SocketServer.exe` en kies *Als administrator
uitvoeren*. Er verschijnt een schermpje met een poortnummer en een knop **Install**. Is de
service al geïnstalleerd, dan staat er **UnInstall**. Zie `docs/SocketServer-installeren.md`.
Uitgebreidere uitleg met een rondje door alle schermen staat in `docs/Website-testen.md`.
## De regels controleren
```
dotnet run --project tests\MijnBesluit.SelfCheck
```
Dit loopt vijfenveertig scenario's door zonder database, webserver of packages: wachtwoorden,
versleuteling, inloggen met een code per e-mail, de claim-overgang van een profiel, de overlijdensprocedure met bezwaartermijn, de
kluistoegang van een nalatenschapsbeheerder, geplande berichten, de socketverbinding van app tot
kluis, het draadgedrag van de server en het opslaan van het poortnummer. Het draait in een paar seconden — goed om even te doen voordat je iets aan de regels
verandert.
## Mappen
```
db/ SQL voor MariaDB
docs/ Architectuur, beveiliging, socketprotocol
src/MijnBesluit.Domain Entiteiten en regels
src/MijnBesluit.Application Handelingen en versleuteling
src/MijnBesluit.Protocol Socketprotocol: framing, server, client
src/MijnBesluit.Server Dispatcher en sessies voor de socketkant
src/MijnBesluit.Infrastructure EF Core, sleutels, bestanden, e-mail
src/MijnBesluit.ServiceBeheer De Windows-service installeren en verwijderen
src/MijnBesluit.SocketServer De zelfstandige service, het beheerschermpje en de achtergrondtaken
src/MijnBesluit.Web De website (Blazor)
tests/MijnBesluit.SelfCheck Controle van de regels
```
## .NET-versie
Alles staat op `net8.0`; `MijnBesluit.SocketServer` op `net8.0-windows` en vast op 64 bits,
omdat daar het WinForms-schermpje en de Windows-service in zitten. Wil je naar .NET 10 (zoals RenHDashboardMaui), pas dan
`Directory.Build.props` en de `<TargetFramework>` in de csproj-bestanden aan. De MAUI-app op
`net10.0-android` kan deze bibliotheken ook op net8.0 gewoon gebruiken.
## Naar de server
Zie `docs/Uitrollen-naar-IIS.md` voor het uitrollen op een Windows-server met IIS en MariaDB.
## Wat er nog moet gebeuren
Zie `docs/Volgende-stappen.md`.
## Waarom het is zoals het is
Een paar keuzes zijn niet vanzelfsprekend en niet uit de code af te lezen. Die staan apart:
- `docs/Identiteit.md` — dubbele profielen, claimen en samenvoegen. Waarom er niet op naam en
geboortedatum wordt gezocht, en waarom de kleine versie van een profielfoto niet versleuteld is.
- `docs/Beveiliging.md` — de drie lagen versleuteling en wat er gebeurt als het sleutelbestand weg is.
- `docs/E-mail.md` — de eigen mailserver, SPF, DKIM en DMARC.
+368
View File
@@ -0,0 +1,368 @@
-- ============================================================================
-- MijnBesluit - databaseschema voor MariaDB 10.6 of nieuwer
--
-- Aanmaken (in cmd, NIET in PowerShell - die kent "<" niet):
-- mysql -u root -p < 01_schema.sql
-- In PowerShell of HeidiSQL: zie docs/Website-testen.md.
--
-- Het script mag meerdere keren gedraaid worden; bestaande tabellen blijven ongemoeid.
--
-- De schemanaam staat hieronder één keer. Wil je een bestaande database
-- gebruiken, vervang dan `mijnbesluit` overal (of laat CREATE DATABASE weg
-- en zet er een USE <jouw naam>; voor in de plaats).
--
-- Opzet:
-- * Alles utf8mb4 en InnoDB, zodat namen met accenten en emoji goed gaan.
-- * Geheimen staan als VARBINARY in de tabel: versleuteld met AES-256-GCM,
-- nooit leesbaar. Zie docs/Beveiliging.md voor hoe de sleutels lopen.
-- * Datums worden in UTC bewaard (DATETIME, geen TIMESTAMP), zodat zomertijd
-- nooit een bezorgmoment kan verschuiven.
-- ============================================================================
CREATE DATABASE IF NOT EXISTS `mijnbesluit`
DEFAULT CHARACTER SET utf8mb4
DEFAULT COLLATE utf8mb4_unicode_ci;
USE `mijnbesluit`;
SET FOREIGN_KEY_CHECKS = 0;
-- ---------------------------------------------------------------- personen ---
-- Een profiel. Kan bestaan zonder account: dan is het aangemaakt door iemand
-- anders en wacht het op een uitnodiging.
CREATE TABLE IF NOT EXISTS `persons` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`first_name` VARCHAR(80) NOT NULL DEFAULT '',
`last_name` VARCHAR(80) NOT NULL DEFAULT '',
`nickname` VARCHAR(80) NULL,
`email` VARCHAR(255) NULL,
`mobile_number` VARCHAR(50) NULL,
`birth_date` DATE NULL,
`date_of_death` DATE NULL,
-- Wie het profiel aanmaakte (historie) en wie het nu beheert.
`created_by_user_id` BIGINT NULL,
`managed_by_user_id` BIGINT NULL,
-- Zodra dit gevuld is, is het profiel van de persoon zelf.
`claimed_by_user_id` BIGINT NULL,
`claimed_at` DATETIME(3) NULL,
-- Aantal meldingen dat nodig is om overlijden te bevestigen, en hoeveel dagen
-- de betrokkene daarna nog heeft om bezwaar te maken.
`required_death_declarations` INT NOT NULL DEFAULT 3,
`death_objection_days` INT NOT NULL DEFAULT 14,
`death_confirmed_at` DATETIME(3) NULL,
`death_objected_at` DATETIME(3) NULL,
-- Wie heeft gezegd dat deze aantekening niet over hem gaat. Zie db/17_aantekening_afgewezen.sql:
-- dan komt die vraag niet bij elke volgende keer opnieuw.
`link_declined_by_user_id` BIGINT NULL,
`link_declined_at` DATETIME(3) NULL,
`created_at` DATETIME(3) NOT NULL,
`updated_at` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`),
-- Met opzet NIET uniek. Twee mensen mogen allebei hun eigen aantekening over dezelfde
-- persoon hebben; ze worden pas één zodra die persoon zelf beide uitnodigingen aanneemt.
-- Zie docs/Identiteit.md en db/15_adres_niet_uniek.sql. Bij `users.email` is uniek juist
-- wél goed: dat is een account.
KEY `ix_persons_email` (`email`),
KEY `ix_persons_claimed_by` (`claimed_by_user_id`),
KEY `ix_persons_managed_by` (`managed_by_user_id`),
KEY `ix_persons_death` (`death_confirmed_at`)
) ENGINE=InnoDB;
-- ---------------------------------------------------------------- accounts ---
CREATE TABLE IF NOT EXISTS `users` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`person_id` BIGINT NOT NULL,
`email` VARCHAR(255) NOT NULL,
`security_stamp` CHAR(36) NOT NULL,
-- De datasleutel van deze gebruiker, versleuteld met de mastersleutel.
`wrapped_data_key` VARBINARY(255) NOT NULL,
`status` TINYINT NOT NULL DEFAULT 0, -- 0 actief, 1 geblokkeerd, 2 overleden
`email_confirmed_at` DATETIME(3) NULL,
`two_factor_enabled` TINYINT(1) NOT NULL DEFAULT 0,
`failed_login_count` INT NOT NULL DEFAULT 0,
`locked_until` DATETIME(3) NULL,
`last_login_at` DATETIME(3) NULL,
`created_at` DATETIME(3) NOT NULL,
`updated_at` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_users_email` (`email`),
UNIQUE KEY `ux_users_person` (`person_id`),
CONSTRAINT `fk_users_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`)
) ENGINE=InnoDB;
-- `persons` en `users` verwijzen naar elkaar, dus deze twee sleutels kunnen pas gelegd worden
-- als beide tabellen bestaan. ALTER TABLE kent geen "IF NOT EXISTS" voor een constraint, dus
-- wordt er eerst gekeken of hij er al ligt: zo kun je dit script gerust nog een keer draaien.
SET @fk_claimed := (SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = DATABASE()
AND TABLE_NAME = 'persons'
AND CONSTRAINT_NAME = 'fk_persons_claimed_by');
SET @sql := IF(@fk_claimed = 0,
'ALTER TABLE `persons` ADD CONSTRAINT `fk_persons_claimed_by` FOREIGN KEY (`claimed_by_user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL',
'DO 0');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_managed := (SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = DATABASE()
AND TABLE_NAME = 'persons'
AND CONSTRAINT_NAME = 'fk_persons_managed_by');
SET @sql := IF(@fk_managed = 0,
'ALTER TABLE `persons` ADD CONSTRAINT `fk_persons_managed_by` FOREIGN KEY (`managed_by_user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL',
'DO 0');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ---------------------------------------------------------------- families ---
CREATE TABLE IF NOT EXISTS `families` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`name` VARCHAR(120) NOT NULL,
`owner_person_id` BIGINT NOT NULL,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` DATETIME(3) NOT NULL,
`updated_at` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`),
KEY `ix_families_owner` (`owner_person_id`),
CONSTRAINT `fk_families_owner` FOREIGN KEY (`owner_person_id`) REFERENCES `persons` (`id`)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `family_members` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`family_id` BIGINT NOT NULL,
`person_id` BIGINT NOT NULL,
`relation` INT NOT NULL DEFAULT 0,
`relation_label` VARCHAR(50) NULL,
`added_by_user_id` BIGINT NULL,
`created_at` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_family_member` (`family_id`, `person_id`),
KEY `ix_family_members_person` (`person_id`),
CONSTRAINT `fk_fm_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_fm_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB;
-- ------------------------------------------------------------ uitnodigingen ---
-- Alleen de hash van het token staat hier; het token zelf bestaat enkel in de e-mail.
CREATE TABLE IF NOT EXISTS `invitations` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`person_id` BIGINT NOT NULL,
`family_id` BIGINT NOT NULL,
`email` VARCHAR(255) NOT NULL,
`token_hash` VARBINARY(32) NOT NULL,
`status` INT NOT NULL DEFAULT 0,
`created_by_user_id` BIGINT NOT NULL,
`created_at` DATETIME(3) NOT NULL,
`sent_at` DATETIME(3) NULL,
`expires_at` DATETIME(3) NOT NULL,
`accepted_at` DATETIME(3) NULL,
`resend_count` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_invitations_token` (`token_hash`),
KEY `ix_invitations_person` (`person_id`, `status`),
CONSTRAINT `fk_inv_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_inv_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB;
-- ------------------------------------------------------------------- kluis ---
-- Bijlagen: de bytes staan versleuteld buiten de database, hier alleen de verwijzing.
CREATE TABLE IF NOT EXISTS `vault_files` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`owner_person_id` BIGINT NOT NULL,
`file_name` VARCHAR(255) NOT NULL,
`content_type` VARCHAR(120) NOT NULL DEFAULT 'application/octet-stream',
`size_bytes` BIGINT NOT NULL DEFAULT 0,
`storage_key` VARCHAR(255) NOT NULL,
`nonce` VARBINARY(12) NOT NULL,
`tag` VARBINARY(16) NOT NULL,
`wrapped_content_key` VARBINARY(96) NOT NULL,
`sha256` VARBINARY(32) NOT NULL,
`created_at` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`),
KEY `ix_vault_files_owner` (`owner_person_id`),
CONSTRAINT `fk_vf_owner` FOREIGN KEY (`owner_person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB;
-- Kluisitems. `title` en `category` zijn leesbaar zodat er gezocht kan worden;
-- de inhoud zit versleuteld in `ciphertext`.
CREATE TABLE IF NOT EXISTS `vault_items` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`owner_person_id` BIGINT NOT NULL,
`category` INT NOT NULL,
`title` VARCHAR(150) NOT NULL,
`ciphertext` LONGBLOB NOT NULL,
`nonce` VARBINARY(12) NOT NULL,
`tag` VARBINARY(16) NOT NULL,
`wrapped_content_key` VARBINARY(96) NOT NULL,
`release_policy` INT NOT NULL DEFAULT 0, -- 0 alleen ikzelf, 1 na overlijden, 2 op datum
`release_at` DATETIME(3) NULL,
`vault_file_id` BIGINT NULL,
`created_at` DATETIME(3) NOT NULL,
`updated_at` DATETIME(3) NOT NULL,
`deleted_at` DATETIME(3) NULL,
PRIMARY KEY (`id`),
KEY `ix_vault_items_owner` (`owner_person_id`, `category`),
KEY `ix_vault_items_release` (`release_policy`, `release_at`),
CONSTRAINT `fk_vi_owner` FOREIGN KEY (`owner_person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_vi_file` FOREIGN KEY (`vault_file_id`) REFERENCES `vault_files` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB;
-- Toegang van een ander tot een item: dezelfde contentsleutel, ingepakt met
-- de datasleutel van die ander. Zonder rij hier is er geen toegang, punt.
CREATE TABLE IF NOT EXISTS `content_grants` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`resource_type` VARCHAR(30) NOT NULL, -- VaultItem | VaultFile | Message
`resource_id` BIGINT NOT NULL,
`user_id` BIGINT NOT NULL,
`wrapped_content_key` VARBINARY(96) NOT NULL,
`reason` VARCHAR(50) NOT NULL, -- ontvanger | nalatenschap | gedeeld
`granted_at` DATETIME(3) NOT NULL,
`revoked_at` DATETIME(3) NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_grant` (`resource_type`, `resource_id`, `user_id`),
KEY `ix_grants_user` (`user_id`),
CONSTRAINT `fk_grant_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB;
-- ---------------------------------------------------------------- berichten ---
CREATE TABLE IF NOT EXISTS `messages` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`from_person_id` BIGINT NOT NULL,
`to_person_id` BIGINT NOT NULL,
`kind` INT NOT NULL DEFAULT 0, -- 0 tekst, 1 video, 2 audio, 3 document
`subject` VARCHAR(200) NOT NULL,
`ciphertext` LONGBLOB NOT NULL,
`nonce` VARBINARY(12) NOT NULL,
`tag` VARBINARY(16) NOT NULL,
`wrapped_content_key` VARBINARY(96) NOT NULL,
`vault_file_id` BIGINT NULL,
`deliver_at` DATETIME(3) NULL,
`deliver_after_death` TINYINT(1) NOT NULL DEFAULT 0,
`status` INT NOT NULL DEFAULT 0, -- 0 concept, 1 gepland, 2 bezorgd, 3 gelezen, 4 ingetrokken
`delivered_at` DATETIME(3) NULL,
`read_at` DATETIME(3) NULL,
`created_at` DATETIME(3) NOT NULL,
`updated_at` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`),
KEY `ix_messages_to` (`to_person_id`, `status`),
KEY `ix_messages_from` (`from_person_id`),
-- De index waarop de bezorgtaak draait.
KEY `ix_messages_due` (`status`, `deliver_at`, `deliver_after_death`),
CONSTRAINT `fk_msg_from` FOREIGN KEY (`from_person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_msg_to` FOREIGN KEY (`to_person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_msg_file` FOREIGN KEY (`vault_file_id`) REFERENCES `vault_files` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB;
-- ------------------------------------------------------- overlijden & nalatenschap ---
-- Eén melding per gebruiker per profiel; dat dwingt de unieke sleutel af.
CREATE TABLE IF NOT EXISTS `death_declarations` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`person_id` BIGINT NOT NULL,
`declared_by_user_id` BIGINT NOT NULL,
`date_of_death` DATE NULL,
`note` VARCHAR(500) NULL,
`declared_at` DATETIME(3) NOT NULL,
`revoked_at` DATETIME(3) NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_declaration` (`person_id`, `declared_by_user_id`),
CONSTRAINT `fk_dd_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_dd_user` FOREIGN KEY (`declared_by_user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `legacy_managers` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`person_id` BIGINT NOT NULL,
`manager_person_id` BIGINT NOT NULL,
`priority` INT NOT NULL DEFAULT 1,
`accepted_at` DATETIME(3) NULL,
`access_granted_at` DATETIME(3) NULL,
`created_at` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_legacy_manager` (`person_id`, `manager_person_id`),
CONSTRAINT `fk_lm_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_lm_manager` FOREIGN KEY (`manager_person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB;
-- ------------------------------------------------------------------ sessies ---
CREATE TABLE IF NOT EXISTS `refresh_tokens` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`user_id` BIGINT NOT NULL,
`token_hash` VARBINARY(32) NOT NULL,
`device` VARCHAR(120) NULL,
`created_by_ip` VARCHAR(45) NULL,
`created_at` DATETIME(3) NOT NULL,
`expires_at` DATETIME(3) NOT NULL,
`revoked_at` DATETIME(3) NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_refresh_token` (`token_hash`),
KEY `ix_refresh_user` (`user_id`, `expires_at`),
CONSTRAINT `fk_rt_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB;
-- ---------------------------------------------------------------- sessies ---
-- Een aanmelding van de app. Staat in de database en niet in het geheugen van de SocketServer,
-- want anders logt elke herstart van de service iedereen uit - en dan is de vingerafdruk waarmee
-- je de app opent niets waard. Het token zelf staat er niet in, alleen de SHA-256 ervan.
CREATE TABLE IF NOT EXISTS `sessies` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`token_hash` VARBINARY(32) NOT NULL,
`user_id` BIGINT NOT NULL,
`person_id` BIGINT NOT NULL,
`toestel` VARCHAR(120) NULL,
`aangemaakt_op` DATETIME(3) NOT NULL,
`verloopt_op` DATETIME(3) NOT NULL,
`laatst_gezien` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_sessies_token` (`token_hash`),
KEY `ix_sessies_verloopt` (`verloopt_op`),
CONSTRAINT `fk_sessies_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB;
-- ------------------------------------------------------------- inlogcodes ---
-- Er zijn geen wachtwoorden: je logt in met een code die per e-mail komt.
-- De code staat hier niet leesbaar in, alleen de hash ervan.
CREATE TABLE IF NOT EXISTS `login_codes` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`email` VARCHAR(255) NOT NULL,
`purpose` INT NOT NULL DEFAULT 0, -- 0 inloggen, 1 registreren
`code_hash` VARCHAR(255) NOT NULL,
`first_name` VARCHAR(80) NULL, -- alleen bij registreren
`last_name` VARCHAR(80) NULL,
`created_at` DATETIME(3) NOT NULL,
`expires_at` DATETIME(3) NOT NULL,
`consumed_at` DATETIME(3) NULL,
`attempts` INT NOT NULL DEFAULT 0,
`requested_from_ip` VARCHAR(45) NULL,
PRIMARY KEY (`id`),
-- De index waarop het opzoeken van een openstaande code draait.
KEY `ix_login_codes_open` (`email`, `purpose`, `expires_at`),
KEY `ix_login_codes_created` (`email`, `created_at`)
) ENGINE=InnoDB;
-- ----------------------------------------------------------------- logboek ---
-- Alleen toevoegen, nooit wijzigen. Bij een kluis wil je kunnen nagaan wie wat wanneer deed.
CREATE TABLE IF NOT EXISTS `audit_log` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`user_id` BIGINT NULL,
`action` INT NOT NULL,
`entity_type` VARCHAR(50) NULL,
`entity_id` BIGINT NULL,
`ip_address` VARCHAR(45) NULL,
`user_agent` VARCHAR(255) NULL,
`details` VARCHAR(1000) NULL,
`created_at` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`),
KEY `ix_audit_user_time` (`user_id`, `created_at`),
KEY `ix_audit_entity` (`entity_type`, `entity_id`)
) ENGINE=InnoDB;
SET FOREIGN_KEY_CHECKS = 1;
+63
View File
@@ -0,0 +1,63 @@
-- ============================================================================
-- Een aparte databasegebruiker voor de applicatie, met alleen de rechten die
-- hij nodig heeft: geen CREATE, geen DROP, geen ALTER. Schemawijzigingen doe
-- je met een migratie onder je eigen account, niet vanuit de draaiende
-- applicatie.
--
-- Vervang het wachtwoord hieronder en bewaar het niet in de broncode --
-- zie docs/Beveiliging.md, "Waar de geheimen staan".
--
-- Draaien (in cmd, NIET in PowerShell):
-- mysql -u root -p < 02_applicatiegebruiker.sql
--
-- Opnieuw draaien mag en is veilig: CREATE USER IF NOT EXISTS slaat de hele regel over als de
-- gebruiker al bestaat, dus het wachtwoord verandert niet. Dat is precies wat je doet als er
-- tabellen bij zijn gekomen.
--
-- LET OP: komt er een tabel bij, dan hoort hij hier ook bij te staan. Zonder recht krijgt de
-- applicatie "SELECT command denied" - en dat merk je pas als iemand dat scherm opent. Er is
-- een zelftest die elke CREATE TABLE in deze map naast deze lijst legt.
--
-- En hier, in DIT bestand, en nergens anders. Sommige migratiescripts deelden vroeger zelf
-- rechten uit, vlak onder hun CREATE TABLE. Dat leest prettig, maar de scripts vanaf 03 worden
-- ook door de uitrolagent gedraaid met een beperkt databaseaccount, en dat mag geen rechten
-- uitdelen: de uitrol struikelde er dan op. Rechten uitdelen is inrichten, en dat doet een mens
-- één keer als root. De zelftest bewaakt ook dat.
-- ============================================================================
CREATE USER IF NOT EXISTS 'mijnbesluit_app'@'localhost'
IDENTIFIED BY 'VERVANG_DIT_WACHTWOORD';
-- De rechten worden per tabel gegeven en niet in één keer op de hele database.
-- Dat is met opzet: rechten in MariaDB zijn optellend, dus een recht dat op
-- databaseniveau is gegeven kun je op tabelniveau niet meer afnemen. Wil je
-- het logboek onwijzigbaar houden, dan mag UPDATE/DELETE op de database dus
-- nooit gegeven worden.
GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`persons` TO 'mijnbesluit_app'@'localhost';
GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`users` TO 'mijnbesluit_app'@'localhost';
GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`families` TO 'mijnbesluit_app'@'localhost';
GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`family_members` TO 'mijnbesluit_app'@'localhost';
GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`invitations` TO 'mijnbesluit_app'@'localhost';
GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`vault_items` TO 'mijnbesluit_app'@'localhost';
GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`vault_files` TO 'mijnbesluit_app'@'localhost';
GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`content_grants` TO 'mijnbesluit_app'@'localhost';
GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`messages` TO 'mijnbesluit_app'@'localhost';
GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`death_declarations` TO 'mijnbesluit_app'@'localhost';
GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`legacy_managers` TO 'mijnbesluit_app'@'localhost';
GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`refresh_tokens` TO 'mijnbesluit_app'@'localhost';
GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`login_codes` TO 'mijnbesluit_app'@'localhost';
GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`toestellen` TO 'mijnbesluit_app'@'localhost';
GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`samenvoegingen` TO 'mijnbesluit_app'@'localhost';
GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`adverteerders` TO 'mijnbesluit_app'@'localhost';
GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`campagnes` TO 'mijnbesluit_app'@'localhost';
GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`campagne_uren` TO 'mijnbesluit_app'@'localhost';
GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`sessies` TO 'mijnbesluit_app'@'localhost';
-- Het logboek mag er alleen bij komen en uit gelezen worden, niet uit verdwijnen.
GRANT SELECT, INSERT ON `mijnbesluit`.`audit_log` TO 'mijnbesluit_app'@'localhost';
FLUSH PRIVILEGES;
-- Controle: audit_log hoort alleen SELECT en INSERT te hebben, de rest alles behalve DDL.
SHOW GRANTS FOR 'mijnbesluit_app'@'localhost';
+58
View File
@@ -0,0 +1,58 @@
-- ============================================================================
-- Overstap van wachtwoorden naar inloggen met een code per e-mail.
--
-- Draai dit op een database die met een eerdere versie van 01_schema.sql is
-- aangemaakt. Op een verse database is dit niet nodig: 01_schema.sql bevat
-- alles al.
--
-- Draaien (in cmd, NIET in PowerShell), of in HeidiSQL via
-- Bestand -> SQL-bestand uitvoeren:
-- mysql -u root -p < 03_inloggen_met_code.sql
--
-- Het script mag meerdere keren gedraaid worden.
-- ============================================================================
USE `mijnbesluit`;
-- 1. De tabel met eenmalige codes.
CREATE TABLE IF NOT EXISTS `login_codes` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`email` VARCHAR(255) NOT NULL,
`purpose` INT NOT NULL DEFAULT 0, -- 0 inloggen, 1 registreren
`code_hash` VARCHAR(255) NOT NULL,
`first_name` VARCHAR(80) NULL,
`last_name` VARCHAR(80) NULL,
`created_at` DATETIME(3) NOT NULL,
`expires_at` DATETIME(3) NOT NULL,
`consumed_at` DATETIME(3) NULL,
`attempts` INT NOT NULL DEFAULT 0,
`requested_from_ip` VARCHAR(45) NULL,
PRIMARY KEY (`id`),
KEY `ix_login_codes_open` (`email`, `purpose`, `expires_at`),
KEY `ix_login_codes_created` (`email`, `created_at`)
) ENGINE=InnoDB;
-- 2. De wachtwoordkolom weg. ALTER TABLE kent geen "DROP COLUMN IF EXISTS" in
-- elke MariaDB-versie, dus eerst kijken of hij er staat.
SET @kolom := (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'users'
AND COLUMN_NAME = 'password_hash');
SET @sql := IF(@kolom > 0, 'ALTER TABLE `users` DROP COLUMN `password_hash`', 'DO 0');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 3. De rechten voor de applicatiegebruiker staan NIET hier maar in 02_applicatiegebruiker.sql.
--
-- Waarom: de scripts vanaf 03 worden ook door de uitrolagent gedraaid, met een beperkt
-- databaseaccount dat geen rechten mág uitdelen. Een GRANT hier laat die hele uitrol struikelen,
-- en dat account meer macht geven is de verkeerde kant op. Rechten uitdelen is inrichten; dat
-- doet een mens één keer als root. Een zelfcontrole legt elke CREATE TABLE in deze map naast de
-- lijst in 02, dus vergeten kan niet ongemerkt.
-- Controle.
SELECT 'login_codes' AS tabel,
(SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'login_codes') AS bestaat,
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'password_hash') AS wachtwoordkolom_nog_aanwezig;
+55
View File
@@ -0,0 +1,55 @@
-- MijnBesluit - bijlagen bij kluisitems
--
-- Voegt twee kolommen toe aan `vault_files`:
-- vault_item_id : bij welk kluisitem dit bestand hoort. Leeg bij een bestand dat op zichzelf
-- staat, zoals de opname bij een videobericht.
-- deleted_at : gevuld zodra het bestand verwijderd is.
--
-- Draaien met:
-- mysql -u root -p mijnbesluit < db\04_bijlagen.sql
--
-- Opnieuw draaien mag: er gebeurt dan niets.
USE `mijnbesluit`;
-- ---------------------------------------------------------------- vault_item_id
SET @bestaat := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'vault_files' AND COLUMN_NAME = 'vault_item_id');
SET @sql := IF(@bestaat = 0,
'ALTER TABLE `vault_files` ADD COLUMN `vault_item_id` BIGINT NULL AFTER `owner_person_id`',
'SELECT "vault_item_id bestaat al" AS melding');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ---------------------------------------------------------------- deleted_at
SET @bestaat := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'vault_files' AND COLUMN_NAME = 'deleted_at');
SET @sql := IF(@bestaat = 0,
'ALTER TABLE `vault_files` ADD COLUMN `deleted_at` DATETIME(3) NULL',
'SELECT "deleted_at bestaat al" AS melding');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ---------------------------------------------------------------- index + relatie
SET @bestaat := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'vault_files' AND INDEX_NAME = 'ix_vault_files_item');
SET @sql := IF(@bestaat = 0,
'CREATE INDEX `ix_vault_files_item` ON `vault_files` (`vault_item_id`)',
'SELECT "index bestaat al" AS melding');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- Verdwijnt het kluisitem, dan verdwijnt de koppeling; het bestand zelf ruimt de applicatie op.
SET @bestaat := (
SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'vault_files' AND CONSTRAINT_NAME = 'fk_vf_item');
SET @sql := IF(@bestaat = 0,
'ALTER TABLE `vault_files` ADD CONSTRAINT `fk_vf_item` FOREIGN KEY (`vault_item_id`) REFERENCES `vault_items` (`id`) ON DELETE SET NULL',
'SELECT "fk_vf_item bestaat al" AS melding');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SELECT 'Klaar: bijlagen toegevoegd aan vault_files.' AS melding;
+38
View File
@@ -0,0 +1,38 @@
-- MijnBesluit - toestellen voor push-meldingen
--
-- Elke app meldt zich met een kenmerk van Firebase (Android) of APNs (iOS). Daarmee kan de
-- SocketServer een melding sturen als er een bericht wordt bezorgd, ook als de app dicht is.
--
-- Draaien met:
-- mysql -u root -p mijnbesluit < db\05_meldingen.sql
--
-- Opnieuw draaien mag: er gebeurt dan niets.
USE `mijnbesluit`;
CREATE TABLE IF NOT EXISTS `toestellen` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`user_id` BIGINT NOT NULL,
-- Het kenmerk van Google is lang; 512 is ruim genoeg en past nog in een unieke index.
`kenmerk` VARCHAR(512) NOT NULL,
`platform` VARCHAR(20) NOT NULL DEFAULT 'android',
`naam` VARCHAR(120) NULL,
`aangemeld_op` DATETIME(3) NOT NULL,
`laatst_gezien` DATETIME(3) NOT NULL,
`afgemeld_op` DATETIME(3) NULL,
PRIMARY KEY (`id`),
-- Eén kenmerk hoort bij één toestel. Wisselt de gebruiker, dan verhuist de regel mee.
UNIQUE KEY `ux_toestellen_kenmerk` (`kenmerk`),
KEY `ix_toestellen_user` (`user_id`),
CONSTRAINT `fk_toestel_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB;
-- De rechten voor de applicatiegebruiker staan NIET hier maar in 02_applicatiegebruiker.sql.
--
-- Waarom: de scripts vanaf 03 worden ook door de uitrolagent gedraaid, met een beperkt
-- databaseaccount dat geen rechten mág uitdelen. Een GRANT hier laat die hele uitrol struikelen,
-- en dat account meer macht geven is de verkeerde kant op. Rechten uitdelen is inrichten; dat
-- doet een mens één keer als root. Een zelfcontrole legt elke CREATE TABLE in deze map naast de
-- lijst in 02, dus vergeten kan niet ongemerkt.
SELECT 'Klaar: tabel toestellen aangemaakt.' AS melding;
+22
View File
@@ -0,0 +1,22 @@
-- MijnBesluit - instelling voor het overzicht
--
-- Voegt aan `users` toe hoeveel dagen vooruit iemand op zijn overzicht wil zien wat eraan komt.
-- 0 betekent: niets tonen. Standaard 30, maximaal 60.
--
-- Draaien met:
-- mysql -u root -p mijnbesluit < db\06_vooruitblik.sql
--
-- Opnieuw draaien mag: er gebeurt dan niets.
USE `mijnbesluit`;
SET @bestaat := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'vooruitblik_dagen');
SET @sql := IF(@bestaat = 0,
'ALTER TABLE `users` ADD COLUMN `vooruitblik_dagen` INT NOT NULL DEFAULT 30',
'SELECT "vooruitblik_dagen bestaat al" AS melding');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SELECT 'Klaar: vooruitblik-instelling toegevoegd aan users.' AS melding;
+89
View File
@@ -0,0 +1,89 @@
-- MijnBesluit - foto's bij personen en berichten
--
-- Tot nu toe kon een bestand alleen bij een kluisitem horen. Er komen twee koppelingen bij,
-- plus een vinkje voor de profielfoto:
--
-- subject_person_id : bij welke persoon deze foto hoort. Let op het verschil met
-- owner_person_id: dat is wie de foto bewaart, dit is wie erop staat.
-- message_id : bij welk bericht deze foto hoort.
-- is_profile_photo : dit is de foto die bij de persoon op de kaart komt te staan.
-- Er is er hoogstens een per persoon; de applicatie bewaakt dat.
--
-- Draaien met:
-- mysql -u root -p mijnbesluit < db\07_fotos.sql
--
-- Opnieuw draaien mag: er gebeurt dan niets.
USE `mijnbesluit`;
-- ---------------------------------------------------------------- subject_person_id
SET @bestaat := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'vault_files' AND COLUMN_NAME = 'subject_person_id');
SET @sql := IF(@bestaat = 0,
'ALTER TABLE `vault_files` ADD COLUMN `subject_person_id` BIGINT NULL AFTER `vault_item_id`',
'SELECT "subject_person_id bestaat al" AS melding');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ---------------------------------------------------------------- message_id
SET @bestaat := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'vault_files' AND COLUMN_NAME = 'message_id');
SET @sql := IF(@bestaat = 0,
'ALTER TABLE `vault_files` ADD COLUMN `message_id` BIGINT NULL AFTER `subject_person_id`',
'SELECT "message_id bestaat al" AS melding');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ---------------------------------------------------------------- is_profile_photo
SET @bestaat := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'vault_files' AND COLUMN_NAME = 'is_profile_photo');
SET @sql := IF(@bestaat = 0,
'ALTER TABLE `vault_files` ADD COLUMN `is_profile_photo` TINYINT(1) NOT NULL DEFAULT 0',
'SELECT "is_profile_photo bestaat al" AS melding');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ---------------------------------------------------------------- indexen
SET @bestaat := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'vault_files' AND INDEX_NAME = 'ix_vault_files_subject');
SET @sql := IF(@bestaat = 0,
'CREATE INDEX `ix_vault_files_subject` ON `vault_files` (`subject_person_id`)',
'SELECT "ix_vault_files_subject bestaat al" AS melding');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @bestaat := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'vault_files' AND INDEX_NAME = 'ix_vault_files_message');
SET @sql := IF(@bestaat = 0,
'CREATE INDEX `ix_vault_files_message` ON `vault_files` (`message_id`)',
'SELECT "ix_vault_files_message bestaat al" AS melding');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ---------------------------------------------------------------- relaties
-- Verdwijnt de persoon of het bericht, dan verdwijnt de koppeling; het bestand zelf ruimt de
-- applicatie op, want de versleutelde inhoud staat buiten de database.
SET @bestaat := (
SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'vault_files' AND CONSTRAINT_NAME = 'fk_vf_subject');
SET @sql := IF(@bestaat = 0,
'ALTER TABLE `vault_files` ADD CONSTRAINT `fk_vf_subject` FOREIGN KEY (`subject_person_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL',
'SELECT "fk_vf_subject bestaat al" AS melding');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @bestaat := (
SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'vault_files' AND CONSTRAINT_NAME = 'fk_vf_message');
SET @sql := IF(@bestaat = 0,
'ALTER TABLE `vault_files` ADD CONSTRAINT `fk_vf_message` FOREIGN KEY (`message_id`) REFERENCES `messages` (`id`) ON DELETE SET NULL',
'SELECT "fk_vf_message bestaat al" AS melding');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SELECT 'Klaar: foto''s bij personen en berichten toegevoegd aan vault_files.' AS melding;
+55
View File
@@ -0,0 +1,55 @@
-- MijnBesluit - kleine versie van een foto, om in lijstjes te tonen
--
-- Een profielfoto op de kaart van een persoon is een plaatje van hooguit veertig pixels. De
-- foto zelf is al gauw een paar honderd kilobyte, en tien personen in een lijst zijn dan een
-- paar megabyte - over een mobiele verbinding merk je dat meteen.
--
-- Daarom slaan we bij een foto meteen een kleine versie op. Die is versleuteld met dezelfde
-- sleutel als de foto zelf, dus er staat niets leesbaars in de database:
--
-- thumbnail : de kleine versie, versleuteld
-- thumbnail_nonce : het eenmalige getal van die versleuteling
-- thumbnail_tag : het echtheidskenmerk van die versleuteling
--
-- Alle drie leeg betekent: er is geen kleine versie. Dat is geen fout - bij een document of een
-- video is er niets te verkleinen, en bij foto's van voor deze wijziging is hij er nog niet.
-- Het scherm valt dan terug op de beginletters, precies zoals het zonder foto ook doet.
--
-- Draaien met:
-- mysql -u root -p mijnbesluit < db\08_thumbnails.sql
--
-- Opnieuw draaien mag: er gebeurt dan niets.
USE `mijnbesluit`;
-- ---------------------------------------------------------------- thumbnail
SET @bestaat := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'vault_files' AND COLUMN_NAME = 'thumbnail');
SET @sql := IF(@bestaat = 0,
'ALTER TABLE `vault_files` ADD COLUMN `thumbnail` BLOB NULL',
'SELECT "thumbnail bestaat al" AS melding');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ---------------------------------------------------------------- thumbnail_nonce
SET @bestaat := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'vault_files' AND COLUMN_NAME = 'thumbnail_nonce');
SET @sql := IF(@bestaat = 0,
'ALTER TABLE `vault_files` ADD COLUMN `thumbnail_nonce` VARBINARY(12) NULL',
'SELECT "thumbnail_nonce bestaat al" AS melding');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ---------------------------------------------------------------- thumbnail_tag
SET @bestaat := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'vault_files' AND COLUMN_NAME = 'thumbnail_tag');
SET @sql := IF(@bestaat = 0,
'ALTER TABLE `vault_files` ADD COLUMN `thumbnail_tag` VARBINARY(16) NULL',
'SELECT "thumbnail_tag bestaat al" AS melding');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SELECT 'Klaar. De kleine versies worden vanaf nu bij het opslaan van een foto meegemaakt.' AS melding;
+54
View File
@@ -0,0 +1,54 @@
-- MijnBesluit - profielen samenvoegen
--
-- Twee mensen kunnen allebei een profiel aanmaken voor dezelfde persoon. Jij voegt je dochter
-- toe, je ex-partner doet dat ook: twee rijen in `persons`, allebei een eerlijke aantekening
-- van degene die hem maakte. Dat is geen fout, zolang niemand het profiel heeft geclaimd.
--
-- Zodra zij beide uitnodigingen aanneemt, zegt zij zelf dat het om haar gaat. Dan gaat het ene
-- profiel op in het andere. Het duplicaat blijft staan - er hangt geschiedenis aan, en een
-- verwijderde rij is niet terug te halen - maar wijst voortaan naar waar hij in is opgegaan:
--
-- merged_into_person_id : het profiel waarin deze is opgegaan. Leeg = gewoon in gebruik.
--
-- Alles wat naar het duplicaat verwees is op dat moment verhuisd: lidmaatschappen, berichten,
-- foto's, uitnodigingen en rollen in een nalatenschap.
--
-- Draaien met:
-- mysql -u root -p mijnbesluit < db\09_samenvoegen.sql
--
-- Opnieuw draaien mag: er gebeurt dan niets.
USE `mijnbesluit`;
-- ---------------------------------------------------------------- merged_into_person_id
SET @bestaat := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'persons' AND COLUMN_NAME = 'merged_into_person_id');
SET @sql := IF(@bestaat = 0,
'ALTER TABLE `persons` ADD COLUMN `merged_into_person_id` BIGINT NULL',
'SELECT "merged_into_person_id bestaat al" AS melding');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ---------------------------------------------------------------- index
-- Elke lijst met mensen filtert hierop, dus hij wordt vaak gelezen.
SET @bestaat := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'persons' AND INDEX_NAME = 'ix_persons_merged');
SET @sql := IF(@bestaat = 0,
'CREATE INDEX `ix_persons_merged` ON `persons` (`merged_into_person_id`)',
'SELECT "ix_persons_merged bestaat al" AS melding');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ---------------------------------------------------------------- verwijzing naar zichzelf
SET @bestaat := (
SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'persons' AND CONSTRAINT_NAME = 'fk_persons_merged');
SET @sql := IF(@bestaat = 0,
'ALTER TABLE `persons` ADD CONSTRAINT `fk_persons_merged` FOREIGN KEY (`merged_into_person_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL',
'SELECT "fk_persons_merged bestaat al" AS melding');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SELECT 'Klaar. Profielen kunnen vanaf nu worden samengevoegd.' AS melding;
+53
View File
@@ -0,0 +1,53 @@
-- MijnBesluit - bezwaar maken zonder in te loggen
--
-- Als er gemeld wordt dat je bent overleden, krijg je daar bericht van met de vraag of dat
-- klopt. Tot nu toe moest je inloggen om te zeggen dat je er nog bent. Dat gaat mis op precies
-- het moment dat het ertoe doet: zodra het overlijden bevestigd is, staat je account op
-- "overleden" en kun je niet meer inloggen. Dan is er geen weg terug.
--
-- Daarom gaat er voortaan een link mee in die e-mail, die werkt zonder inloggen - net als een
-- uitnodiging. Alleen de versleutelde vorm van dat kenmerk staat hier; de link zelf staat
-- uitsluitend in de e-mail.
--
-- objection_token_hash : SHA-256 van het kenmerk uit de link
-- objection_token_expires : tot wanneer die link werkt
--
-- Draaien met:
-- mysql -u root -p mijnbesluit < db\10_bezwaarlink.sql
--
-- Opnieuw draaien mag: er gebeurt dan niets.
USE `mijnbesluit`;
-- ---------------------------------------------------------------- objection_token_hash
SET @bestaat := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'persons' AND COLUMN_NAME = 'objection_token_hash');
SET @sql := IF(@bestaat = 0,
'ALTER TABLE `persons` ADD COLUMN `objection_token_hash` VARBINARY(32) NULL',
'SELECT "objection_token_hash bestaat al" AS melding');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ---------------------------------------------------------------- objection_token_expires
SET @bestaat := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'persons' AND COLUMN_NAME = 'objection_token_expires');
SET @sql := IF(@bestaat = 0,
'ALTER TABLE `persons` ADD COLUMN `objection_token_expires` DATETIME(3) NULL',
'SELECT "objection_token_expires bestaat al" AS melding');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ---------------------------------------------------------------- index
-- De link wordt opgezocht op dit kenmerk, dus zonder index is dat een tabelscan.
SET @bestaat := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'persons' AND INDEX_NAME = 'ix_persons_objection');
SET @sql := IF(@bestaat = 0,
'CREATE INDEX `ix_persons_objection` ON `persons` (`objection_token_hash`)',
'SELECT "ix_persons_objection bestaat al" AS melding');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SELECT 'Klaar. De waarschuwingsmail krijgt vanaf nu een bezwaarlink.' AS melding;
+37
View File
@@ -0,0 +1,37 @@
-- MijnBesluit - een beheerder
--
-- Er is één situatie waar niemand meer uit komt: een overlijden dat ten onrechte is bevestigd,
-- terwijl de bezwaarlink uit de waarschuwingsmail is verlopen. Het account staat dan op
-- "overleden", inloggen kan niet meer, en de enige weg terug is weg.
--
-- Daarvoor is een beheerder. Bewust zo klein mogelijk gehouden:
--
-- * een beheerder kan een profiel opzoeken op het VOLLEDIGE e-mailadres, niet op naam. Zoeken
-- op naam zou een zoekmachine op mensen zijn - zie docs/Identiteit.md.
-- * een beheerder kan een bevestigd overlijden terugdraaien, met een reden die wordt vastgelegd.
-- * een beheerder kan GEEN kluis openen. Die handeling geeft nooit toegang, hij neemt alleen
-- toegang weg.
--
-- Beheerder word je alleen hier, met de hand:
-- UPDATE `users` SET `is_beheerder` = 1 WHERE `email` = 'jij@voorbeeld.nl';
--
-- Er is met opzet geen scherm om iemand beheerder te maken. Wat niet via de applicatie kan,
-- kan ook niet via een lek in de applicatie.
--
-- Draaien met:
-- mysql -u root -p mijnbesluit < db\11_beheerder.sql
--
-- Opnieuw draaien mag: er gebeurt dan niets.
USE `mijnbesluit`;
SET @bestaat := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'is_beheerder');
SET @sql := IF(@bestaat = 0,
'ALTER TABLE `users` ADD COLUMN `is_beheerder` TINYINT(1) NOT NULL DEFAULT 0',
'SELECT "is_beheerder bestaat al" AS melding');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SELECT 'Klaar. Maak iemand beheerder met: UPDATE `users` SET `is_beheerder` = 1 WHERE `email` = ...' AS melding;
+51
View File
@@ -0,0 +1,51 @@
-- MijnBesluit - het bonnetje van een samenvoeging
--
-- Twee profielen van dezelfde persoon worden er één zodra zij zelf beide uitnodigingen aanneemt.
-- Tot nu toe was dat onomkeerbaar, en dat stond ook zo in docs/Identiteit.md bij "wat hier nog
-- niet is opgelost".
--
-- Waarom terugdraaien niet zomaar kan: na afloop is niet meer te zien wélke lidmaatschappen,
-- berichten en bestanden bij het duplicaat hoorden. Ze staan gewoon bij het doel, tussen alles
-- wat daar al stond. Gokken zou andermans gegevens meesleuren.
--
-- Daarom deze tabel. Bij elke samenvoeging komt hier één regel in te staan met precies wat er is
-- verhuisd, als JSON. Terugdraaien is dan die lijst in omgekeerde volgorde aflopen. Regels die
-- bij het samenvoegen zijn weggegooid - een dubbel lidmaatschap, een dubbele beheerder - staan
-- er als volledige kopie in, want die moeten opnieuw worden aangemaakt.
--
-- De regel blijft ook na het terugdraaien staan, met undone_at gevuld. Dat er ooit is
-- samengevoegd is zelf ook geschiedenis.
--
-- Let op: samenvoegingen van VOOR deze tabel hebben geen bonnetje en zijn dus niet terug te
-- draaien. Dat is geen fout die te repareren valt; de gegevens om het mee te doen bestaan niet.
--
-- Draaien met:
-- mysql -u root -p mijnbesluit < db\12_samenvoegingen.sql
--
-- Opnieuw draaien mag: er gebeurt dan niets.
USE `mijnbesluit`;
CREATE TABLE IF NOT EXISTS `samenvoegingen` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`duplicate_person_id` BIGINT NOT NULL,
`target_person_id` BIGINT NOT NULL,
`by_user_id` BIGINT NOT NULL,
`merged_at` DATETIME(6) NOT NULL,
`steps` LONGTEXT NOT NULL,
`undone_at` DATETIME(6) NULL,
`undone_by_user_id` BIGINT NULL,
`reason` VARCHAR(500) NULL,
PRIMARY KEY (`id`),
KEY `ix_samenvoegingen_duplicaat` (`duplicate_person_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- De rechten voor de applicatiegebruiker staan NIET hier maar in 02_applicatiegebruiker.sql.
--
-- Waarom: de scripts vanaf 03 worden ook door de uitrolagent gedraaid, met een beperkt
-- databaseaccount dat geen rechten mág uitdelen. Een GRANT hier laat die hele uitrol struikelen,
-- en dat account meer macht geven is de verkeerde kant op. Rechten uitdelen is inrichten; dat
-- doet een mens één keer als root. Een zelfcontrole legt elke CREATE TABLE in deze map naast de
-- lijst in 02, dus vergeten kan niet ongemerkt.
SELECT 'Klaar. Samenvoegingen worden vanaf nu vastgelegd en kunnen worden teruggedraaid.' AS melding;
+123
View File
@@ -0,0 +1,123 @@
-- ============================================================================
-- Adverteren op MijnBesluit: adverteerders, campagnes en tellers.
--
-- Lees docs/Adverteren.md voordat je hier iets aan verandert. Daar staat waarom de balk laag
-- is, hoe er wordt gekozen wie er getoond wordt, en waarom goedkeuring vooraf gaat.
--
-- Draaien met:
-- mysql -u root -p mijnbesluit < db\13_adverteren.sql
--
-- Opnieuw draaien mag: er gebeurt dan niets.
-- ============================================================================
USE `mijnbesluit`;
-- ------------------------------------------------------------ adverteerders ---
-- Een bedrijf dat adverteert. Bewust een eigen tabel en géén regel in `users`:
--
-- * Een adverteerder is een bedrijf, geen persoon met een kluis, familie of nalatenschap.
-- Hem in `users` zetten zou betekenen dat elke query over gebruikers hem moet uitzonderen,
-- en één vergeten uitzondering is er één te veel in een applicatie die over nalatenschappen
-- gaat.
-- * Het scheidt de twee werelden ook fysiek: wie in het adverteerdersdeel inbreekt, staat nog
-- steeds nergens bij de kluizen in de buurt.
--
-- Inloggen gaat net als bij gebruikers met een code per e-mail; die codes staan in dezelfde
-- tabel `login_codes`, met een eigen doel-nummer.
CREATE TABLE IF NOT EXISTS `adverteerders` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`company_name` VARCHAR(160) NOT NULL,
`contact_name` VARCHAR(160) NOT NULL DEFAULT '',
`email` VARCHAR(255) NOT NULL,
`phone` VARCHAR(50) NULL,
`address` VARCHAR(160) NULL,
`postal_code` VARCHAR(20) NULL,
`city` VARCHAR(120) NULL,
`country` VARCHAR(80) NULL,
`coc_number` VARCHAR(40) NULL, -- KvK
`vat_number` VARCHAR(40) NULL, -- btw
`created_at` DATETIME(6) NOT NULL,
`last_seen_at` DATETIME(6) NULL,
`blocked_at` DATETIME(6) NULL, -- geen nieuwe campagnes meer; bestaande blijven staan
PRIMARY KEY (`id`),
UNIQUE KEY `ux_adverteerders_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ---------------------------------------------------------------- campagnes ---
-- Eén geboekte advertentie: wat er staat, wanneer hij mag draaien, hoe vaak per uur, wat hij
-- kost en hoe ver hij is.
--
-- De afbeelding staat hier als blob en niet in `vault_files`. Die tabel is voor spullen van
-- gebruikers en is versleuteld met sleutels per gebruiker; een advertentielogo is juist
-- openbaar en hoort daar niet tussen te staan. Hij wordt bij het opslaan verkleind, dus groot
-- kan hij niet worden.
--
-- `amount_cents` wordt bij het boeken berekend en dan vastgelegd. Verandert het tarief later,
-- dan verandert een al geboekte campagne niet mee - je hebt afgesproken wat je afsprak.
CREATE TABLE IF NOT EXISTS `campagnes` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`advertiser_id` BIGINT NOT NULL,
`text` VARCHAR(300) NOT NULL,
`link` VARCHAR(500) NOT NULL,
`image` LONGBLOB NULL,
`image_type` VARCHAR(100) NULL,
`starts_on` DATE NOT NULL,
`ends_on` DATE NOT NULL,
`per_hour` INT NOT NULL,
`amount_cents` INT NOT NULL,
`rate_cents` INT NOT NULL, -- het tarief waarmee gerekend is, voor de verantwoording
`payment_reference` VARCHAR(40) NULL, -- wat de adverteerder bij zijn bunq-betaling zet
`paid_at` DATETIME(6) NULL,
`paid_by_user_id` BIGINT NULL, -- welke beheerder het afvinkte
`state` TINYINT NOT NULL DEFAULT 0,
`approved_at` DATETIME(6) NULL,
`approved_by_user_id` BIGINT NULL,
`rejection_reason` VARCHAR(500) NULL,
`created_at` DATETIME(6) NOT NULL,
`updated_at` DATETIME(6) NULL,
PRIMARY KEY (`id`),
KEY `ix_campagnes_adverteerder` (`advertiser_id`),
-- De vraag "welke campagnes mogen nu draaien" komt bij élke keer dat iemand het overzicht
-- opent. Die moet op een index kunnen landen en niet op de hele tabel.
KEY `ix_campagnes_lopend` (`state`, `starts_on`, `ends_on`),
CONSTRAINT `fk_campagnes_adverteerder` FOREIGN KEY (`advertiser_id`)
REFERENCES `adverteerders` (`id`) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------------- vertoningen ---
-- Eén regel per campagne per klokuur, met tellers erin. Níét één regel per vertoning: bij een
-- paar honderd bezoekers per uur loopt zo'n tabel eindeloos vol, en voor het kiezen én voor de
-- verantwoording is een teller per uur genoeg.
--
-- Het uur staat in UTC, op het hele uur afgerond. De unieke sleutel is wat het optellen veilig
-- maakt: twee bezoekers tegelijk leveren een INSERT ... ON DUPLICATE KEY UPDATE op en geen
-- tweede regel.
CREATE TABLE IF NOT EXISTS `campagne_uren` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`campaign_id` BIGINT NOT NULL,
`hour_utc` DATETIME(6) NOT NULL,
`shown` INT NOT NULL DEFAULT 0,
`clicks` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_campagne_uren` (`campaign_id`, `hour_utc`),
CONSTRAINT `fk_campagne_uren_campagne` FOREIGN KEY (`campaign_id`)
REFERENCES `campagnes` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- De rechten voor de applicatiegebruiker staan NIET hier maar in 02_applicatiegebruiker.sql.
--
-- Waarom: de scripts vanaf 03 worden ook door de uitrolagent gedraaid, met een beperkt
-- databaseaccount dat geen rechten mág uitdelen. Een GRANT hier laat die hele uitrol struikelen,
-- en dat account meer macht geven is de verkeerde kant op. Rechten uitdelen is inrichten; dat
-- doet een mens één keer als root. Een zelfcontrole legt elke CREATE TABLE in deze map naast de
-- lijst in 02, dus vergeten kan niet ongemerkt.
SELECT 'Klaar. Adverteerders, campagnes en tellers staan klaar.' AS melding;
+38
View File
@@ -0,0 +1,38 @@
-- MijnBesluit - onthouden welke inlogcode ECHT is gebruikt
--
-- Er zit een rem op het aanvragen van codes: niet meer dan vijf per uur voor hetzelfde adres.
-- Die rem is er tegen één ding: iemand die het postvak van een ander volgooit door steeds
-- opnieuw een code voor dat adres aan te vragen.
--
-- Die telling keek naar alle codes van het afgelopen uur, ook de codes die gewoon zijn gebruikt
-- om in te loggen. Wie vijf keer in een uur inlogde, sloot zichzelf dus een uur buiten - en dat
-- gebeurt precies wanneer er íéts anders aan de hand is en je het een paar keer opnieuw
-- probeert. Dat is de verkeerde kant om te falen: de rem hoort een vreemde te stoppen, niet de
-- eigenaar.
--
-- Waarom daar een nieuwe kolom voor nodig is: `consumed_at` betekent "deze code werkt niet
-- meer", en dat wordt ook gezet als een nieuwe aanvraag de vorige ongeldig maakt, en als een
-- code te vaak fout is ingevuld. Precies die twee gevallen MOETEN meetellen - anders kan een
-- vreemde de rem leegtrekken door codes aan te vragen of expres fout in te vullen.
--
-- `signed_in_at` staat alleen gevuld als de code goed is ingevuld en er iemand mee is
-- binnengekomen. Alleen dát is het bewijs dat de aanvrager zelf in dat postvak zit, en alleen
-- die codes tellen niet mee voor de rem.
--
-- Draaien met:
-- mysql -u root -p mijnbesluit < db\14_inlogcode_gebruikt.sql
--
-- Opnieuw draaien mag: er gebeurt dan niets.
USE `mijnbesluit`;
SET @bestaat := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'login_codes' AND COLUMN_NAME = 'signed_in_at');
SET @sql := IF(@bestaat = 0,
'ALTER TABLE `login_codes` ADD COLUMN `signed_in_at` DATETIME(3) NULL',
'SELECT "signed_in_at bestaat al" AS melding');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SELECT 'Klaar.' AS melding;
+46
View File
@@ -0,0 +1,46 @@
-- MijnBesluit - twee mensen mogen dezelfde persoon kennen
--
-- Op `persons.email` stond een UNIQUE-index. Dat botste met het uitgangspunt van
-- docs/Identiteit.md: iedereen heeft zijn EIGEN aantekening over een ander, en die aantekeningen
-- worden pas één zodra de persoon zelf beide uitnodigingen aanneemt.
--
-- Wat er misging: voegde je iemand toe met een e-mailadres dat al ergens in de database stond -
-- bij wie dan ook, ook bij een wildvreemde - dan liep het opslaan stuk op die index. Voor de
-- gebruiker was dat een knop die niets deed. En het gevolg was erger dan een foutmelding: wie
-- het adres van iemand het eerst invulde, bepaalde daarmee dat niemand anders die persoon nog
-- kon toevoegen. In een applicatie waarin twee ouders allebei hun dochter toevoegen is dat
-- precies de verkeerde kant om.
--
-- Uniek blijft het wél bij `users.email`: één account per e-mailadres. Dat is iets anders dan
-- een aantekening over iemand, en dat moet uniek blijven, anders weet niemand wie er inlogt.
--
-- De index zelf blijft staan, alleen niet meer uniek: er wordt op adres gezocht.
--
-- Draaien met:
-- mysql -u root -p mijnbesluit < db\15_adres_niet_uniek.sql
--
-- Opnieuw draaien mag: er gebeurt dan niets.
USE `mijnbesluit`;
SET @uniek := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'persons'
AND INDEX_NAME = 'ux_persons_email');
SET @sql := IF(@uniek > 0,
'ALTER TABLE `persons` DROP INDEX `ux_persons_email`',
'SELECT "ux_persons_email bestaat niet (meer)" AS melding');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @gewoon := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'persons'
AND INDEX_NAME = 'ix_persons_email');
SET @sql := IF(@gewoon = 0,
'ALTER TABLE `persons` ADD INDEX `ix_persons_email` (`email`)',
'SELECT "ix_persons_email bestaat al" AS melding');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SELECT 'Klaar.' AS melding;
+54
View File
@@ -0,0 +1,54 @@
-- MijnBesluit - aanmeldingen van de app bewaren, zodat ze een herstart overleven
--
-- Waarom deze tabel er komt: de app blijft aangemeld en komt er daarna in met de vingerafdruk
-- of de code van de telefoon. Dat werkt alleen zolang de server het sessietoken nog kent. De
-- sessies stonden in het geheugen van de SocketServer, dus elke herstart van de service - een
-- uitrol, een Windows-update 's nachts, een reboot van de server - gooide ze allemaal weg. Het
-- toestel wist dan nog wie je was, de server niet meer, en je moest weer een code uit je mail
-- halen. Precies wat de vingerafdruk zou moeten voorkomen.
--
-- Het token zelf staat er NIET in, alleen de SHA-256 ervan - dezelfde afspraak als bij de
-- inlogcodes. Wie de database in handen krijgt, krijgt daarmee dus geen werkende sessies mee:
-- uit een hash valt geen token terug te rekenen.
--
-- `verloopt_op` schuift op bij gebruik, maar niet bij elk verzoek. Dat zou een schrijfactie per
-- aanroep betekenen voor iets waar een uur speling volstaat bij een termijn van veertien dagen.
--
-- Draaien met:
-- mysql -u root -p mijnbesluit < db\16_sessies.sql
--
-- Opnieuw draaien mag: er gebeurt dan niets.
USE `mijnbesluit`;
CREATE TABLE IF NOT EXISTS `sessies` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`token_hash` VARBINARY(32) NOT NULL, -- SHA-256 van het token; nooit het token zelf
`user_id` BIGINT NOT NULL,
`person_id` BIGINT NOT NULL,
`toestel` VARCHAR(120) NULL,
`aangemaakt_op` DATETIME(3) NOT NULL,
`verloopt_op` DATETIME(3) NOT NULL,
`laatst_gezien` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_sessies_token` (`token_hash`),
KEY `ix_sessies_verloopt` (`verloopt_op`),
CONSTRAINT `fk_sessies_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB;
-- DE RECHTEN STAAN HIER MET OPZET NIET.
--
-- De verleiding was groot: een nieuwe tabel zonder GRANT is onzichtbaar tot iemand hem gebruikt,
-- en dan zegt de server "INSERT command denied" terwijl de gebruiker alleen "Er ging iets mis"
-- ziet. Precies wat er op 17 september gebeurde - inloggen in de app liep erop stuk.
--
-- Maar de scripts vanaf 03 worden ook door de uitrolagent gedraaid, met een beperkt
-- databaseaccount dat helemaal geen rechten mág uitdelen. Een GRANT hier zou die hele uitrol
-- laten struikelen, en de oplossing "geef dat account dan GRANT-rechten" maakt het uitrolkanaal
-- machtiger dan het hoeft te zijn. Rechten uitdelen is inrichten, en dat doet een mens één keer.
--
-- De rechten staan dus in 02_applicatiegebruiker.sql, en een zelfcontrole
-- ("Elke tabel geeft de applicatiegebruiker rechten") legt elke CREATE TABLE in deze map naast
-- die lijst. Die vangt het al vóór de uitrol, en dat is de goede plek.
SELECT 'Klaar. Vergeet 02_applicatiegebruiker.sql niet als dit een nieuwe server is.' AS melding;
+34
View File
@@ -0,0 +1,34 @@
-- MijnBesluit - onthouden dat iemand "nee, dat ben ik niet" heeft gezegd
--
-- Waar dit voor is: maakt iemand een account aan op een adres waarover een ander al een
-- aantekening heeft staan, dan vragen we hem of hij dat is. Hij beslist; koppelen op adres doen
-- we niet, want een adres is geen identiteit (twee mensen kunnen er een delen, en één mens heeft
-- er vaak meerdere). Zie docs/Identiteit.md.
--
-- Zegt hij nee, dan hoort die vraag niet bij elke volgende keer opnieuw te komen. Dat is wat
-- hier wordt bewaard. Alleen "nee": een "ja" is geen vlaggetje maar een samenvoeging, en dan
-- bestaat die aantekening als aparte rij niet meer (merged_into_person_id).
--
-- Degene die de aantekening maakte merkt hier niets van. Hij krijgt geen melding dat er iets
-- gevraagd is en geen melding dat het antwoord nee was - anders zou het weigeren zelf een
-- mededeling worden over wie er wel of niet een account heeft.
--
-- Draaien met:
-- mysql -u root -p mijnbesluit < db\17_aantekening_afgewezen.sql
--
-- Opnieuw draaien mag: er gebeurt dan niets.
USE `mijnbesluit`;
SET @bestaat := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'persons' AND COLUMN_NAME = 'link_declined_by_user_id');
SET @sql := IF(@bestaat = 0,
'ALTER TABLE `persons`
ADD COLUMN `link_declined_by_user_id` BIGINT NULL,
ADD COLUMN `link_declined_at` DATETIME(3) NULL',
'SELECT "link_declined_by_user_id bestaat al" AS melding');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SELECT 'Klaar.' AS melding;
+105
View File
@@ -0,0 +1,105 @@
-- MijnBesluit - kijklink voor een moment aan iemand zonder account
--
-- Zet je een moment klaar voor iemand die (nog) geen MijnBesluit-account heeft, dan krijgt hij
-- voortaan een e-mail met een link naar een landingspagina: daar is het bericht - tekst en een
-- eventuele foto, video of document - te zien zonder in te loggen. Zelfde opzet als de
-- uitnodigings- en bezwaarlink: alleen de hash van het token staat in de database, het token
-- zelf staat uitsluitend in de e-mail.
--
-- view_token_hash : SHA-256 van het token uit de link
-- view_token_expires : tot wanneer die link werkt (30 dagen na het versturen)
-- view_token_sent_at : wanneer de linkmail voor het eerst is verstuurd
-- viewed_at : wanneer de landingspagina daadwerkelijk is geopend
-- view_reminder_sent_at : wanneer de herinnering is verstuurd (nooit meer dan één keer)
-- wrapped_content_key_for_token: de contentsleutel, ingepakt met een sleutel uit het token zelf
-- wrapped_vault_key_for_token : dezelfde inpakslag, voor de bijlage bij dit bericht
--
-- Draaien met:
-- mysql -u root -p mijnbesluit < db\18_kijklink.sql
--
-- Opnieuw draaien mag: er gebeurt dan niets.
USE `mijnbesluit`;
-- ---------------------------------------------------------------- view_token_hash
SET @bestaat := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'messages' AND COLUMN_NAME = 'view_token_hash');
SET @sql := IF(@bestaat = 0,
'ALTER TABLE `messages` ADD COLUMN `view_token_hash` VARBINARY(32) NULL',
'SELECT "view_token_hash bestaat al" AS melding');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ---------------------------------------------------------------- view_token_expires
SET @bestaat := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'messages' AND COLUMN_NAME = 'view_token_expires');
SET @sql := IF(@bestaat = 0,
'ALTER TABLE `messages` ADD COLUMN `view_token_expires` DATETIME(3) NULL',
'SELECT "view_token_expires bestaat al" AS melding');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ---------------------------------------------------------------- view_token_sent_at
SET @bestaat := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'messages' AND COLUMN_NAME = 'view_token_sent_at');
SET @sql := IF(@bestaat = 0,
'ALTER TABLE `messages` ADD COLUMN `view_token_sent_at` DATETIME(3) NULL',
'SELECT "view_token_sent_at bestaat al" AS melding');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ---------------------------------------------------------------- viewed_at
SET @bestaat := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'messages' AND COLUMN_NAME = 'viewed_at');
SET @sql := IF(@bestaat = 0,
'ALTER TABLE `messages` ADD COLUMN `viewed_at` DATETIME(3) NULL',
'SELECT "viewed_at bestaat al" AS melding');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ---------------------------------------------------------------- view_reminder_sent_at
SET @bestaat := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'messages' AND COLUMN_NAME = 'view_reminder_sent_at');
SET @sql := IF(@bestaat = 0,
'ALTER TABLE `messages` ADD COLUMN `view_reminder_sent_at` DATETIME(3) NULL',
'SELECT "view_reminder_sent_at bestaat al" AS melding');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ---------------------------------------------------------------- wrapped_content_key_for_token
SET @bestaat := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'messages' AND COLUMN_NAME = 'wrapped_content_key_for_token');
SET @sql := IF(@bestaat = 0,
'ALTER TABLE `messages` ADD COLUMN `wrapped_content_key_for_token` VARBINARY(96) NULL',
'SELECT "wrapped_content_key_for_token bestaat al" AS melding');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ---------------------------------------------------------------- wrapped_vault_key_for_token
SET @bestaat := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'messages' AND COLUMN_NAME = 'wrapped_vault_key_for_token');
SET @sql := IF(@bestaat = 0,
'ALTER TABLE `messages` ADD COLUMN `wrapped_vault_key_for_token` VARBINARY(96) NULL',
'SELECT "wrapped_vault_key_for_token bestaat al" AS melding');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ---------------------------------------------------------------- index
-- De landingspagina zoekt het bericht op dit kenmerk op, dus zonder index is dat een tabelscan.
SET @bestaat := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'messages' AND INDEX_NAME = 'ix_messages_view_token');
SET @sql := IF(@bestaat = 0,
'CREATE INDEX `ix_messages_view_token` ON `messages` (`view_token_hash`)',
'SELECT "ix_messages_view_token bestaat al" AS melding');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SELECT 'Klaar. Momenten voor iemand zonder account krijgen vanaf nu een kijklink per e-mail.' AS melding;
+28
View File
@@ -0,0 +1,28 @@
-- MijnBesluit - beheerder kiezen om een uitvaartwens vooraf te bespreken
--
-- Bij een kluisitem van het soort Uitvaartwens kan de eigenaar voortaan één van zijn
-- nalatenschapsbeheerders kiezen. Die beheerder krijgt dan meteen leestoegang tot dit ene item
-- (via content_grants, reden "gedeeld"), nog vóór er sprake is van een overlijden - zodat het te
-- bespreken is en vooraf al duidelijk is hoe en wat. Kiest de eigenaar niemand, dan blijft het
-- item zoals altijd alleen voor hemzelf.
--
-- shared_manager_person_id : de gekozen beheerder (persons.id); NULL = niemand gekozen
--
-- Draaien met:
-- mysql -u root -p mijnbesluit < db\19_uitvaartwens_beheerder.sql
--
-- Opnieuw draaien mag: er gebeurt dan niets.
USE `mijnbesluit`;
-- ---------------------------------------------------------------- shared_manager_person_id
SET @bestaat := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'vault_items' AND COLUMN_NAME = 'shared_manager_person_id');
SET @sql := IF(@bestaat = 0,
'ALTER TABLE `vault_items` ADD COLUMN `shared_manager_person_id` BIGINT NULL',
'SELECT "shared_manager_person_id bestaat al" AS melding');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SELECT 'Klaar. Bij een uitvaartwens is voortaan een beheerder te kiezen om vooraf mee te bespreken.' AS melding;
+8
View File
@@ -0,0 +1,8 @@
-- MijnBesluit - een bezorgd bericht uit je eigen lijst verwijderen
--
-- Wie een bezorgd bericht "verwijdert" haalt het alleen bij zichzelf weg; de ander heeft er nog
-- steeds recht op. Vandaar twee vlaggen in plaats van één verwijderdatum: de afzender en de
-- ontvanger ruimen onafhankelijk van elkaar op. De rij zelf blijft altijd bestaan.
ALTER TABLE `messages`
ADD COLUMN `hidden_by_sender` TINYINT(1) NOT NULL DEFAULT 0 AFTER `read_at`,
ADD COLUMN `hidden_by_recipient` TINYINT(1) NOT NULL DEFAULT 0 AFTER `hidden_by_sender`;
+256
View File
@@ -0,0 +1,256 @@
# Adverteren op MijnBesluit
Een balk op het overzichtsscherm met een korte tekst en een klein logo. Adverteerders boeken
zelf een periode en een aantal vertoningen per uur, betalen via bunq, en na goedkeuring door de
beheerder gaat de campagne draaien.
Dit stuk beschrijft hoe dat werkt en waarom. Lees het voordat je eraan begint te bouwen.
## Waar de advertentie wél en niet komt
**Wel:** op het overzichtsscherm, op de website en in de app. Een brede tegel met een lichte
achtergrond, bóven de vier tegels maar **onder "wat er speelt"** — die regel waarschuwt je dat er
een overlijden op jouw naam is gemeld of dat er iets voor je klaarstaat, en die gaat altijd voor.
**Niet:** in de kluis, bij momenten, bij de nalatenschap, en nergens in de schermen rond een
overlijdensmelding. Iemand die een bericht aan zijn kinderen klaarzet of net heeft gehoord dat
er een overlijden op zijn naam is gemeld, hoort daar geen reclame naast te zien. Dat is niet
preutsheid maar productbehoud: dat is precies het moment waarop mensen weglopen.
De tegel is laag — een afbeelding, een regel of twee tekst — en draagt zichtbaar het woord
**Advertentie**. Een advertentie die zich voordoet als onderdeel van de applicatie kost je meer
vertrouwen dan hij oplevert.
*(Eerder stond hier dat de balk ónder de tegels hoort. Dat is op 16 september 2026 gewijzigd: hij
staat nu bovenaan, maar wel achter "wat er speelt". De reden om hem laag en herkenbaar te houden
blijft staan.)*
## Hoe wordt gekozen wie er getoond wordt
Per keer dat iemand het overzicht opent is er ruimte voor één advertentie. Er kunnen meerdere
campagnes tegelijk lopen, elk met hun eigen aantal per uur. De keuze gaat zo:
1. Zoek alle campagnes die nu mogen draaien: goedgekeurd, betaald, en binnen hun periode.
2. Kijk per campagne hoe vaak hij dit klokuur al is getoond.
3. Laat de campagne zien die het verst achterloopt op zijn eigen aantal - dus de laagste
verhouding *getoond gedeeld door geboekt*.
4. Zijn ze allemaal op hun aantal, dan verschijnt er niets. Geen opvulling, geen herhaling.
5. Tel de vertoning op.
Zo krijgt wie meer boekt ook meer, zonder dat de een de ander verdringt, en zonder dat iemand
over zijn ingekochte aantal heen gaat.
**Let op wat dit betekent voor je voorwaarden.** Het geboekte aantal is een *maximum*, geen
belofte. Zijn er in een uur minder bezoekers dan er vertoningen zijn ingekocht, dan haalt
niemand zijn aantal. De adverteerder moet daarom in zijn eigen scherm kunnen zien wat er
werkelijk is getoond.
## Wat er wordt opgeslagen
### Adverteerder
Bedrijfsnaam, contactpersoon, e-mailadres, telefoonnummer, adres, postcode, plaats, land,
KvK-nummer en btw-nummer. Dit zijn bedrijfsgegevens, geen persoonsgegevens van bezoekers, maar
ze vallen wel onder je administratie: de belastingdienst wil ze zeven jaar kunnen zien.
### Campagne
- Wie hem heeft geboekt
- De tekst, het logo en de link waar hij heen gaat
- Begindatum en einddatum
- Aantal vertoningen per uur
- Het bedrag, het betaalkenmerk, en wanneer de betaling is gezien
- De stand: `concept`, `wacht-op-betaling`, `wacht-op-goedkeuring`, `loopt`, `afgewezen`,
`gestopt` - met bij een afwijzing de reden erbij, zodat de adverteerder weet waarom
### Vertoningen
Eén regel per campagne per klokuur met een teller erin, niet één regel per vertoning. Bij een
paar honderd bezoekers per uur scheelt dat een tabel die anders eindeloos volloopt, en voor het
kiezen én voor de verantwoording is het genoeg.
Kliks worden apart geteld, ook per uur.
## Het betaalpad
Bunq.me is een betaalverzoek, geen kassa. Er komt **geen bericht terug** naar MijnBesluit als er
betaald is. De applicatie kan dus niet zelf vaststellen dat een campagne betaald is.
Daarom loopt het zo:
1. De adverteerder stelt zijn campagne samen en ziet wat het kost.
2. Hij krijgt de bunq.me-link, met het campagnenummer als omschrijving.
3. De campagne komt op `wacht-op-betaling`.
4. De beheerder ziet in zijn bunq dat er betaald is, met dat nummer erbij, en vinkt de campagne
af als betaald.
5. In hetzelfde scherm keurt hij de inhoud goed of af.
6. Pas dan gaat hij draaien, vanaf de begindatum.
### De betaallink
`AdvertentieService.Betaallink` zet hem in elkaar:
```
https://bunq.me/KwikBit?amount=30.00&description=MB-00012
```
Het adres staat in de instellingen onder `Adverteren:BunqMe`; een rekening wisselt eerder dan de
code. Laat je die instelling leeg, dan komt er geen knop en staan het bedrag en het kenmerk er
gewoon om met de hand over te maken - een knop naar de verkeerde rekening is erger dan geen knop.
Twee dingen die makkelijk misgaan en daarom in een zelfcontrole vastliggen:
* **Het bedrag krijgt een punt**, wat de taalinstelling van de server ook is. Zonder dat maakt
een Nederlandse Windows er `30,00` van, en dan leest bunq een ander bedrag of helemaal geen.
* **Het kenmerk gaat gecodeerd mee.** Het is `MB-00012`: kort genoeg om over te typen als iemand
de link niet gebruikt.
Op de knop staat wat je doet en voor hoeveel ("Betaal € 30,00 via bunq"), niet waar de link heen
gaat. Dezelfde link staat in de bevestigingsmail, want niet iedereen komt terug in het scherm.
Stap 4 en 5 zijn twee vinkjes in één scherm. Wil je dit later automatisch, dan kan dat met de
bunq-API in plaats van een bunq.me-link; dat is een aparte verbouwing en vraagt sleutels en een
terugmeldadres.
## Waarom goedkeuring vooraf
Zonder goedkeuring kan iedereen die betaalt zijn advertentie naast jouw tekst over uitvaarten
krijgen. Je zou het achteraf kunnen weghalen, maar dan is het al gezien. De vertraging voor de
adverteerder is klein - je kijkt er dezelfde dag naar - en het verschil in wat er mis kan gaan
is groot.
## De prijs
bedrag = dagen x vertoningen per uur x tarief
met een bodem eronder. Begin- en einddag tellen allebei mee. Het tarief en de bodem staan in de
instellingen (`Adverteren:TariefCenten`, `Adverteren:MinimumCenten`) en niet in de code: het is
het soort getal dat je na drie maanden wilt bijstellen. Standaard 20 cent en een bodem van
10 euro.
Een voorbeeld: 30 dagen, 5 keer per uur = 150 x 20 cent = **30 euro**.
Hoe kom je aan zo'n getal? Reken terug naar wat de adverteerder hoogstens kan krijgen. Vijf keer
per uur, 24 uur, 30 dagen is 3600 vertoningen; 30 euro is dan ruim 8 euro per duizend. Aan de
lage kant voor een klein, goed omschreven publiek — en dat hoort ook, want dat maximum haalt hij
nooit: 's nachts opent niemand het overzicht.
Bij een campagne wordt het gebruikte tarief mee opgeslagen (`rate_cents`). Verandert het tarief
later, dan verandert een al geboekte campagne niet mee — anders valt een oude factuur niet meer
na te rekenen.
## Wat er nog beslist moet worden
- **Facturen.** Moet MijnBesluit ze maken, of doe je dat zelf? Met btw erop hoort er een factuur
met een doorlopend nummer bij.
- **Terugbetalen.** Wat als een campagne door een storing niet heeft gedraaid?
- **Algemene voorwaarden.** Daarin hoort in elk geval te staan dat het aantal een maximum is en
dat je een advertentie mag weigeren.
## Hoe het in de code zit
| Wat | Waar |
|---|---|
| Tabellen | `db/13_adverteren.sql`: `adverteerders`, `campagnes`, `campagne_uren` |
| Gegevens | `MijnBesluit.Domain/Entities/Adverteren.cs` |
| Regels | `MijnBesluit.Application/Services/AdvertentieService.cs` |
| Opslag | `AdvertentieRepository` in Infrastructure |
| Controles | `tests/MijnBesluit.SelfCheck/AdvertentieChecks.cs` |
Een adverteerder is een **eigen tabel** en geen regel in `users`. Hij is een bedrijf, geen persoon
met een kluis, familie of nalatenschap. Hem bij de gebruikers zetten zou betekenen dat elke vraag
over gebruikers hem moet uitzonderen, en één vergeten uitzondering is er één te veel in een
applicatie die over nalatenschappen gaat. Inloggen gaat wel op dezelfde manier: een code van zes
cijfers per e-mail, met een eigen doel-nummer in `login_codes`.
De afbeelding staat als blob bij de campagne en niet bij `vault_files`. Die tabel is voor spullen
van gebruikers en is versleuteld met sleutels per gebruiker; een advertentie is juist openbaar.
Hij wordt bij het opslaan verkleind.
Wat er bewust **niet** in zit: iets wat een advertentie koppelt aan wie hem ziet. Er wordt per
campagne per uur geteld, meer niet. Geen profielen, geen kenmerken, geen bezoeker die door de
applicatie heen te volgen is. Dit is een applicatie waarin mensen hun wachtwoorden en hun laatste
woorden bewaren; daar hoort geen advertentienetwerk in te zitten dat meekijkt.
De beheerder krijgt een e-mail zodra er een campagne is ingediend. Vanaf dat moment ligt het bij
hem: betaling afvinken en de inhoud beoordelen. Goedkeuren kán niet voordat de betaling is
afgevinkt — anders draait er iets waarvoor niet betaald is, en dat merk je pas als het voorbij is.
## In welke volgorde te bouwen
1. ~~Adverteerder en campagne vastleggen, met een beheerscherm om te betalen-af-te-vinken en goed
te keuren.~~ **Klaar.** `/beheer/adverteren`, bereikbaar vanaf `/beheer`.
2. ~~De balk op het overzicht, met de keuzeregels en de tellers.~~ **Klaar.** Op de website
rechtstreeks vanuit `AdvertentieService`; in de app via de socketactie `home.advertentie`.
3. ~~Het scherm waarin een adverteerder zichzelf aanmeldt en een campagne samenstelt, met de
bunq.me-link erin.~~ **Klaar.** `/adverteren` en `/adverteren/nieuw`.
4. ~~Het scherm waarin hij ziet wat er werkelijk is getoond.~~ **Klaar.** `/adverteren/campagnes`.
Zo staat het werkende deel er eerst, en kun je met één handmatig ingevoerde campagne al zien of
die balk doet wat je wilt voordat er een adverteerder bij komt.
## Het adverteerdersgedeelte
Drie pagina's, allemaal **statisch weergegeven** en niet interactief:
* `/adverteren` - wat het is, wat het kost, en inloggen met een code per e-mail. De ingang staat
op het inlogscherm, onder "Ik ben nieuw en wil een account aanmaken".
* `/adverteren/nieuw` - de campagne samenstellen.
* `/adverteren/campagnes` - zijn campagnes, de stand, de betaalregel, en wat er is getoond.
**Een adverteerder logt in met een eigen cookie en een eigen schema** (`Adverteerderslogin`),
los van de gebruikers van MijnBesluit, en dat cookie gaat alleen mee naar `/adverteren`. Dat is
geen netheid. Met één cookie en een claim erin 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
kan dat niet misgaan. Om dezelfde reden zijn die schermen niet interactief: het nummer van de
adverteerder komt bij elk verzoek vers uit dat cookie en hoeft dus nergens te blijven staan.
De code die hij per e-mail krijgt heeft een **eigen doel** (`LoginCodePurpose.Adverteerder`).
Een adverteerderscode geeft dus nooit toegang tot een kluis, en een gewone inlogcode werkt niet
in het adverteerdersgedeelte. Dat ligt vast in de zelfcontrole "een code voor adverteerders is
geen inlogcode".
Wie nieuw is vult zijn bedrijfsnaam in vóórdat hij de code aanvraagt. Die naam wordt bij de code
bewaard en pas gebruikt zodra de code klopt - net als de voornaam bij het registreren van een
gebruiker. Zo kan niemand zich een bedrijfsnaam aanmeten zonder eerst bij dat e-mailadres te
kunnen.
De prijs loopt mee terwijl je invult, met een klein stukje javascript in de pagina zelf. Dat is
een gemak en geen bedrag: wat het kost rekent de server bij het indienen zelf uit.
### Een campagne met de hand invoeren om het te proberen
Er is nog geen scherm waarin een adverteerder zichzelf aanmeldt, dus voor een proef zet je er één
met de hand in. Daarna zie je hem op `/beheer/adverteren` staan, vink je de betaling af, keur je
hem goed, en staat hij op het overzicht.
```sql
INSERT INTO adverteerders (company_name, contact_name, email, created_at)
VALUES ('Uitvaartzorg Noord', 'Piet', 'piet@voorbeeld.nl', UTC_TIMESTAMP(6));
INSERT INTO campagnes (advertiser_id, text, link, starts_on, ends_on, per_hour,
amount_cents, rate_cents, payment_reference, state, created_at)
VALUES (LAST_INSERT_ID(), 'Persoonlijk afscheid, dichtbij', 'https://voorbeeld.nl',
CURDATE(), CURDATE() + INTERVAL 30 DAY, 5, 3000, 20, 'MB-HAND', 1, UTC_TIMESTAMP(6));
```
`state` 1 is *wacht op betaling*; de rest gaat via het beheerscherm.
## Het klikpad
| Adres | Wat het doet |
|---|---|
| `/advertentie/{id}/afbeelding` | De afbeelding. Openbaar, en een jaar in de browsercache |
| `/advertentie/{id}/uit` | Telt de klik en stuurt door naar de adverteerder |
Allebei zonder inlog: een advertentie is openbaar, en een plaatje dat alleen met een sessie te
laden is werkt niet in de app.
De tegel linkt naar `/uit` en niet rechtstreeks naar de adverteerder. Met javascript tellen zou
ook kunnen, maar dan telt hij niet mee als er iets misgaat onderweg. Dit is één plek en één
telling. De doorverwijzing is bewust *niet* permanent: die blijft anders in de browser hangen en
dan telt een tweede klik niet meer mee.
In de app opent de tegel in de browser van de telefoon (`IBuitenlink`), niet in de app zelf. Zou
hij binnen de webview openen, dan wordt MijnBesluit een browser en staat iemand op de site van een
adverteerder zonder weg terug naar zijn eigen kluis.
+326
View File
@@ -0,0 +1,326 @@
# De Android-app
De app is dezelfde applicatie als de website, met dezelfde schermen. Wat verschilt, is de weg
naar de gegevens: de website praat rechtstreeks met MariaDB, de app praat via de SocketServer.
## Hoe het in elkaar zit
| Project | Wat het is | Te testen? |
|---|---|---|
| `MijnBesluit.Client` | De aanroepen over de socket, met modellen voor de app. Geen regels, alleen vertalen. | Ja — 10 controles in de zelftest, tegen een echte socketserver |
| `MijnBesluit.AppUi` | De schermen (Blazor). Inloggen, overzicht, kluis, familie, momenten, nalatenschap. | Compileert; het gedrag zit grotendeels in de laag eronder |
| `MijnBesluit.App` | De Android-schil: één webview met de schermen erin. | Nee — dit moet in Visual Studio op een toestel |
De schermen staan bewust in een eigen project en niet in de app zelf. Zo kan de website ze
later overnemen, en hoeft een wijziging maar op één plek.
## Wat je nodig hebt in Visual Studio
De MAUI-werkbelasting (".NET Multi-platform App UI development"). Heb je die al voor
RenHDashboardMaui, dan ben je er al. Het project staat op `net10.0-android`, net als dat project.
Klopt je versie niet, pas dan `TargetFrameworks` in `src\MijnBesluit.App\MijnBesluit.App.csproj`
aan naar wat je wél hebt staan; de andere projecten hoeven niet mee te veranderen.
## Eerste keer draaien
1. Rechtermuis op `MijnBesluit.App` → **Set as Startup Project**.
2. Kies bovenin een Android-emulator of je eigen toestel.
3. F5.
Verschijnt bij het bouwen een melding dat `Microsoft.AspNetCore.Components.WebView.Maui`
ontbreekt, dan levert jouw werkbelasting die niet vanzelf. Voeg hem dan toe via
**Manage NuGet Packages** op `MijnBesluit.App`.
## Waar de app verbinding mee zoekt
In `MauiProgram.cs` staat bovenaan:
```csharp
public const string Server = "mijnbesluit.nl";
public const int Poort = 5200;
```
- **Op een emulator, met de server op je eigen pc:** zet `Server` op `10.0.2.2` — dat is vanuit
de emulator jouw pc — en zet `tls: true` om naar `tls: false` zolang er geen certificaat is.
- **Op een echt toestel:** laat `mijnbesluit.nl` staan.
## Een APK maken
```powershell
D:\MijnBesluit\tools\Apk-maken.ps1 -Snel # snel, om zelf te proberen
D:\MijnBesluit\tools\Apk-maken.ps1 # ondertekend, om weg te geven
D:\MijnBesluit\tools\Apk-maken.ps1 -Aab # bundel voor de Play Store
```
De debug-versie is meteen te installeren op je eigen toestel (wel eerst "installeren uit
onbekende bron" toestaan). Die wordt gebouwd met `-p:EmbedAssembliesIntoApk=true`: bij Debug
laat MAUI de .NET-assemblies anders buiten het pakket, omdat Visual Studio ze bij het debuggen
los naar het toestel duwt. Zonder die instelling installeert de APK wel, maar klapt de app bij
het starten meteen dicht. De release-versie vraagt de eerste keer om een sleutelbestand aan te
maken; dat komt in `D:\MijnBesluitSleutels`.
**Bewaar dat sleutelbestand apart, net als de mastersleutel van de kluis.** Android herkent een
app aan de sleutel waarmee hij is ondertekend. Raak je hem kwijt, dan is er geen nieuwe versie
meer uit te brengen die op de toestellen van je gebruikers over de oude heen gaat — ook niet met
hulp van Google. Dan moet iedereen de app verwijderen en opnieuw installeren, en is alles wat
lokaal op het toestel stond weg.
Voor de Play Store lever je geen APK aan maar een AAB (Android App Bundle): `-Aab`. Dat is
dezelfde opdracht met een ander pakketformaat, hetzelfde sleutelbestand en hetzelfde wachtwoord.
Google maakt uit die bundel per toestel een APK, zodat niemand de onderdelen voor andere
toestellen meedownloadt.
Een AAB kun je niet zelf op een telefoon zetten. Voor het uitproberen en voor het rondsturen aan
je eigen testers blijf je dus de APK gebruiken; de AAB is alleen voor de Play Console.
## De serverkant
Staat klaar sinds 14 september 2026:
- De SocketServer draait als Windows-service op mijnbesluit.nl, poort 5200.
- Poort 5200 is open; `Test-NetConnection mijnbesluit.nl -Port 5200` geeft True.
- TLS 1.3, met het certificaat van mijnbesluit.nl uit de opslag WebHosting
(`B1BAFBB8A37A2899A2706FE037DDAB0F1694EFD1`, geldig tot 1 april 2027).
- Database en e-mail werken via de socket: `SocketServer-proef.ps1 -Email <adres>` levert een
inlogcode in het postvak.
Controleer het met:
```powershell
D:\MijnBesluit\tools\SocketServer-proef.ps1
D:\MijnBesluit\tools\SocketServer-proef.ps1 -Email jouw@adres.nl
```
Let op de vervaldatum van het certificaat: bij vernieuwing verandert de vingerafdruk en moet
`Socket:CertificateThumbprint` mee, gevolgd door `Restart-Service MijnBesluitSocket`. De service
zet bij het opstarten in het logboek in welke opslag hij het certificaat vond en tot wanneer het
geldig is.
## Wat de app op het toestel bewaart
Alleen het sessietoken, in de Keystore van Android (`ToestelTokenopslag`). Geen kluisinhoud,
geen wachtwoorden: die worden opgehaald op het moment dat je ze bekijkt, en zijn weg zodra je
het scherm sluit. Uitloggen wist het token van het toestel.
## De terugknop van het toestel
Android heeft een eigen terugknop, en die sloot standaard de hele app — ook midden in een
formulier. Dat loopt nu via `Terugknop` in `MijnBesluit.AppUi`.
Hoe het werkt: wie iets open heeft staan meldt zich daar aan en weer af zodra het dicht is. De
laatste aanmelding is als eerste aan de beurt, dus in de volgorde waarin de dingen over elkaar
heen liggen. In de praktijk:
1. Staat er een foto open in een bijlagenvenster, dan gaat die dicht.
2. Staat er een wijzigscherm open, dan gaat dat dicht — zonder op te slaan, net als Annuleren.
3. Zit je in een scherm, dan ga je naar het overzicht.
4. Ben je op het overzicht, dan verschijnt "Nog een keer terug om te sluiten". Pas een tweede
druk binnen twee seconden sluit de app.
De schermen hoeven hier niets voor te doen: `AppSchil` regelt stap 3 en 4, en `Popup` regelt
stap 1 en 2. Een scherm dat er later bij komt doet automatisch mee.
`HoofdPagina.OnBackButtonPressed` is het enige stukje MAUI erin: dat geeft de druk door en laat
Android zijn gang gaan zodra niemand hem afhandelt.
## Vanzelf op slot
Achter het tandwiel in de balk staat één instelling: na hoeveel minuten stilte de app dichtgaat.
`Uitlogklok` houdt bij wanneer er voor het laatst iets gebeurde; de schil vangt elke aanraking en
elke toets op — die bubbelen naar de buitenste laag, dus dat is één plek — en kijkt elke halve
minuut of de termijn voorbij is.
**Wat er dan gebeurt, hangt af van het toestel.**
| Toestel | Wat er gebeurt | Standaard |
|---|---|---|
| Heeft een schermslot, Android 10 of nieuwer | De app gaat op slot. De sessie blijft staan; openen gaat met vingerafdruk, gezicht of de code van de telefoon. | Aan, na 15 minuten — en "uit" bestaat daar niet |
| Geen schermslot, of ouder | De app logt écht uit: het sessietoken gaat van het toestel af en er moet een nieuwe code per e-mail komen. | Uit |
**Op een toestel met een schermslot kan de klok niet uit.** Die keuze staat niet in de lijst, en
een nul die er van vroeger nog in staat wordt opgetild naar het kwartier - in de klok zelf, niet
in het scherm, zodat ook de schil ervan uitgaat. Het slot kost daar één aanraking; uit zetten
levert dus bijna niets op en betekent wel dat een gevonden telefoon een open kluis is. Zonder
schermslot ligt dat andersom en mag "uit" er wél staan.
**Op slot gaat de lijn dicht.** Zodra de app vergrendelt, verbreekt hij de verbinding met de
SocketServer; na het ontgrendelen zet hij hem weer op. Het token blijft staan en de sessie aan de
serverkant ook - die hangt aan het token en niet aan de verbinding - dus je bent gewoon weer waar
je was.
Waarom dat de moeite is: de server verbreekt zelf een verbinding waarover tien minuten niets
gebeurt, maar dat is een terugval, geen zekerheid. Een telefoon die in een tas verdwijnt of zijn
netwerk kwijtraakt, laat een half-open socket achter tot die tien minuten om zijn. Zelf dichtdoen
maakt er een zekerheid van, scheelt een plek van de vijfhonderd, en zorgt dat je na het
ontgrendelen een verse verbinding hebt in plaats van een die misschien nog leeft.
**Bij het openen vraagt hij altijd, en hij vraagt het aan de telefoon.** Ligt er een sessie op
dit toestel en kan het om een vingerafdruk vragen, dan komt dat venster - ongeacht de klok. De
klok gaat over wat er gebeurt terwijl de app openstaat; dit gaat over het openen zelf. Zo is de
app afsluiten en opnieuw starten geen manier om langs het slot te komen.
Let op *waarop* dat gebeurt: op wat er in de beveiligde opslag van de telefoon ligt, niet op een
goedkeuring van de server (`HeeftBewaardeSessieAsync`, en `BijHetOpenenAsync` in de schil). Dat
was eerst andersom, en dat was fout. Het slot ging pas op de app nadat de server het token had
goedgekeurd, dus een telefoon zonder netwerk - of een server die net opnieuw was gestart - stuurde
je naar het inlogscherm met een vraag om je e-mailadres, terwijl je telefoon prima wist wie je
was. Nu komt eerst je vingerafdruk en pas daarna de vraag of de sessie nog loopt. Zolang de app
dat nog niet weet, toont de schil alleen het logo: geen scherm dat een tel later achter een slot
verdwijnt, en geen scherm dat alvast gegevens gaat ophalen.
Dat de sessie ook echt nog loopt, is de andere helft van hetzelfde verhaal: de SocketServer
bewaart zijn sessies sinds `db/16_sessies.sql` in de database en niet meer alleen in het
geheugen. Zonder dat logde elke uitrol of reboot van de server iedereen uit, en was de
vingerafdruk niets waard. Zie `docs/Socketprotocol.md`, hoofdstuk Sessies.
Dat verschil zit in de prijs van vergeten. Met een toestelslot kost het je één aanraking, dus mag
het standaard aan. Zonder kost het een e-mail afwachten, en dat leg je niemand ongevraagd op. Wie
zélf "uit" kiest houdt uit: die nul staat dan in de opslag en is iets anders dan niets gekozen
hebben. Er is een zelftest die dat verschil bewaakt.
`IToestelslot` vraagt het toestel om iemand te controleren; `Toestelslot` in MijnBesluit.App doet
dat met `BiometricPrompt` en `SetDeviceCredentialAllowed(true)`, zodat de code van de telefoon
altijd als terugval werkt. **De app krijgt alleen ja of nee terug.** Er is geen eigen pincode, er
wordt niets bewaard en er valt hier dus ook niets te lekken; de controle gebeurt in een apart
stuk hardware waar geen enkele app bij kan.
`SetDeviceCredentialAllowed` staat vanaf Android 11 als verouderd gemarkeerd. De opvolger
(`SetAllowedAuthenticators`) bestaat pas vanaf 11, en de app werkt vanaf 10. Zolang dat zo is
hoort deze hier te staan; gaat de ondergrens ooit naar 11, dan mag het om.
Op slot laat de schil **niets** van de app meer zien — geen balk, geen navigatie, geen inhoud —
en slikt de terugknop van het toestel. Een grendel die je nog door het raam laat kijken is geen
grendel. Op het slotscherm staat ook "toch helemaal uitloggen", voor wie de telefoon uit handen
geeft.
Twee dingen die verder met opzet zo zijn:
- **Er wordt op de wandklok gerekend, niet op een teller.** Een telefoon die een uur in een tas
zit telt dus net zo goed mee, ook al heeft de lus in de schil dan niet gedraaid. Bij het
terugkomen uit de achtergrond seint `App.xaml.cs` de klok meteen in, vóór er gegevens worden
opgehaald — dat is juist het geval waar dit voor bedoeld is. Stond de app al op slot, dan komt
het venster van het toestel meteen weer in beeld.
- De termijn staat in de gewone voorkeursopslag van Android (`Toestelinstellingen`), niet in de
Keystore: daar hoort alleen in wat geheim is, en hoe lang de app wacht is dat niet. Het
sessietoken staat er wél in (`ToestelTokenopslag`), en dat is ook precies wat achter dit slot
blijft liggen terwijl de app dicht is.
### Een sessie gaat er alleen af als de server hem weigert
Een app die op slot staat, staat op de achtergrond, en Android sluit zo'n app vaak gewoon af.
Terugkomen is dan geen ontwaken maar een koude start: de app pakt het bewaarde token op en vraagt
de server wie hij is. Op dat moment is het netwerk van de telefoon soms nog niet wakker.
Dat mislukken is **geen** reden om het token weg te gooien. Deed de app dat wel, dan moest je een
code per e-mail aanvragen terwijl er niets mis was met je sessie — precies op het moment dat je
dacht dat je alleen even je vinger hoefde te leggen. Alleen de server mag een sessie ongeldig
verklaren (foutcode `sessie-verlopen`); dan ruimt `SessieKwijtAsync` het token op en gaat de app
naar het inlogscherm. Blijft het token staan, dan blijft de app op zijn scherm met "geen
verbinding" en probeert hij het bij de volgende handeling opnieuw.
De zelfcontrole "App: geen verbinding gooit het bewaarde token niet weg" houdt beide kanten vast.
## De tegels in de lijsten
Momenten, Mijn mensen, Mijn kluis en Nalatenschap tonen allemaal dezelfde tegel:
* Als er een profielfoto is, staat die vooraan. Anders de beginletters, even groot, zodat een
lijst niet verspringt zodra er ergens een foto bij komt.
* Drie regels ernaast, altijd in dezelfde opbouw:
1. waar je op zoekt - het onderwerp, de naam, de titel. Loopt door tot de rand.
2. de toelichting, met rechts eventueel een labeltje (de status bij Mijn mensen).
3. de rest, met rechts de knoppen. Die regel mag leeg zijn; bij Mijn mensen is dat zo.
* **De tegel zelf aantikken opent hem.** Bij Momenten en Nalatenschap klapt er iets uit, bij
Mijn mensen en Mijn kluis opent het scherm met de gegevens. Daar staat dus geen knop voor.
* Rechtsonder staan alleen de andere acties, als tekentje: de prullenbak, de envelop om iemand
uit te nodigen. Een wijzertje (het driehoekje) laat zien dat er iets uitklapt; dat is geen
knop. Elke knop heeft een `title` en een `aria-label`, dus een schermlezer noemt hem gewoon.
Het labeltje op regel 2 en de knoppen op regel 3 staan daardoor op elke tegel op dezelfde plek,
pal boven elkaar. Dat is de reden dat de knoppen op de onderste regel staan en er niet overheen
zweven: een knop is 40 pixels hoog, en dat is anderhalve tekstregel - hij kwam dus altijd over
dat labeltje heen. De tegel is daar een pixel of vijftien hoger van (ongeveer 126 in plaats van
110), en niets loopt meer over elkaar.
Datums staan afgekort: *9 Jun. 2031*. De maandnamen staan met de hand in `Tonen` en komen niet
uit de taalinstelling van het toestel; op een telefoon die op Engels staat las de lijst anders
"9 June 2031" terwijl de rest van het scherm Nederlands is.
Mijn kluis heeft bovenaan een keuzelijst om op soort te filteren, met de knop Toevoegen ernaast,
net als op de website. Het filteren gebeurt bij de server (`KluisAsync` neemt een categorie mee)
en niet in de lijst op het toestel: dan hoeft de telefoon niet alles op te halen om er drie
dingen uit te laten zien.
Bij Mijn kluis is bekijken en wijzigen hetzelfde scherm. Er was eerder ook een uitklap onder de
tegel die precies dezelfde gegevens en bijlagen toonde: twee plekken om te onderhouden en twee
manieren om hetzelfde te doen.
## Het logo
Het logo staat op vier plekken, allemaal vanuit hetzelfde bestand:
| Waar | Bestand |
|---|---|
| Inlogscherm van de app, groot | `src/MijnBesluit.AppUi/wwwroot/logo.png` |
| Merkteken in de balk van de app, klein | hetzelfde bestand |
| Kop en inlogpagina van de website | `src/MijnBesluit.Web/wwwroot/logo.png` |
| Pictogram en startscherm van de app | `Resources/AppIcon/appicon.png`, `Resources/Splash/splash.png` |
Het pictogram is **alleen het witte woordmerk op een doorzichtige achtergrond**, met het
logogroen eronder via `Color="#C1D13F"` in het csproj. Zo maakt Android er zelf een rond,
vierkant of druppelvormig pictogram van. Het woordmerk staat op zeventig procent van het vlak:
alles buiten die cirkel mag een toestel wegsnijden, en op die maat blijft het hele woordmerk
staan, hoe een telefoon het ook maskeert.
Op 26 pixels - het merkteken in de balk - is het woordmerk niet te lezen. Dat hoeft ook niet:
daar staat de naam in letters ernaast. Groot en leesbaar staat het op het inlogscherm.
Nog niet gedaan: de melding op de telefoon gebruikt het app-pictogram als klein teken, en Android
maakt daar een silhouet van. Een woordmerk wordt op 24 pixels een vlek. Een eigen, eenvoudig
tekentje in `Platforms/Android/Resources/drawable` zou dat oplossen.
## Wat er nog niet in zit
- Meldingen op de telefoon als er een bericht bezorgd wordt — de SocketServer kan uit zichzelf
een melding sturen, maar een push-melding als de app dicht is vraagt om Firebase
- iOS: `net10.0-ios` toevoegen aan `TargetFrameworks` en een Mac om op te bouwen
- Een grens aan hoe lang de app op slot mag blijven staan. Nu blijft de sessie liggen tot iemand
hem opent of uitlogt; na een maand ongebruikt zou hij zichzelf kunnen opruimen.
## Naar de Google Play Store
Stand op 16 september 2026. De eisen van Google wijzigen; kijk ze na op
<https://support.google.com/googleplay/android-developer>.
**Wat al klopt:** de app bouwt op `targetSdkVersion 36`, en dat is sinds 31 augustus 2026 de eis
voor nieuwe apps. De rechten zijn zuinig (internet, camera, media, vingerafdruk, meldingen) en de
meldingstoestemming wordt op het moment zelf gevraagd.
**Wat er is gebouwd omdat Google het eist:**
* **Account opheffen**, in de app én op `/account-verwijderen` zonder app. Zie
`docs/Beveiliging.md`.
* **Een privacyverklaring** op `/privacy`, met een link erheen vanaf het inlogscherm en vanuit
Instellingen in de app.
**Wat er nog moet:**
1. **Een organisatie-account** in de Play Console, op naam van het bedrijf - geen persoonlijk
account. Persoonlijke accounts van na 13 november 2023 moeten eerst een gesloten test draaien
met 12 testers die 14 dagen onafgebroken meedoen; organisatie-accounts niet. Een organisatie
wordt wel geverifieerd (KvK, D-U-N-S), en dat duurt weken. Begin daarmee.
2. **Een AAB in plaats van een APK:**
`dotnet publish -f net10.0-android -c Release -p:AndroidPackageFormat=aab`
3. **Een upload-keystore**, bewaard zoals de masterkey: apart, met back-up, buiten de projectmap.
Zet Play App Signing aan; dan kan Google een nieuwe upload-sleutel geven als hij ooit weg is.
Let op dat de app die gebruikers krijgen door Google opnieuw wordt ondertekend - voor de
pushmeldingen maakt dat niets uit, maar alles wat aan een certificaatvingerafdruk hangt moet
je met de sleutel van Google registreren.
4. **`ApplicationVersion` omhoog bij elke upload.** Staat nu op 1.
5. **Het Data safety-formulier**, de leeftijdsclassificatie en de winkelvermelding: icoon
512x512, banner 1024x500, minimaal twee schermafbeeldingen, korte en lange beschrijving, en
het adres van de privacyverklaring.
6. **"Bevat advertenties" aanvinken** zodra de advertentiebalk meedraait. De inhoud van die
advertenties valt dan onder het beleid van Google; dat is een reden te meer om ze vooraf zelf
goed te keuren.
+155
View File
@@ -0,0 +1,155 @@
# MijnBesluit — architectuur
## Wat het is
Een platform om je beslissingen bij leven vast te leggen in een digitale kluis, en om berichten
te versturen naar familie en vrienden — nu of in de toekomst, en ook pas na je overlijden.
## De vorm in één plaatje
```
Website (Blazor) Android / iOS (MAUI)
| |
| in-proces | TLS-socket, poort 5200
v v
+---------------------------------------------+
| MijnBesluit.Server (dispatcher, sessies) | <- alleen voor de socketkant
+---------------------------------------------+
\ /
v v
+---------------------------------------------+
| MijnBesluit.Application |
| AccountService, FamilyService, VaultService, |
| MessageService, LegacyService + crypto |
+---------------------------------------------+
|
+---------------------------------------------+
| MijnBesluit.Domain (entiteiten + regels) |
+---------------------------------------------+
|
+---------------------------------------------+
| MijnBesluit.Infrastructure (EF Core) |
+---------------------------------------------+
|
MariaDB + versleutelde bestanden op schijf
```
De regels staan één keer, in Application en Domain. De website en de app zijn allebei niet meer
dan een manier om diezelfde regels aan te roepen. Wat op de website niet mag, kan dus ook via de
app niet — dat hoeft nergens tweemaal te worden afgedwongen.
## De projecten
| Project | Wat het doet | Externe packages |
|---|---|---|
| `MijnBesluit.Domain` | Entiteiten en de regels: eigendom van profielen, overlijdensprocedure, vrijgave van berichten | geen |
| `MijnBesluit.Application` | De handelingen: registreren, uitnodigen, kluis, berichten, nalatenschap. Plus alle versleuteling | geen |
| `MijnBesluit.Protocol` | Het socketprotocol: framing, de server-luisteraar en de client voor de app | geen |
| `MijnBesluit.Server` | Vertaalt socketverzoeken naar Application-aanroepen; sessiebeheer | geen |
| `MijnBesluit.Infrastructure` | EF Core op MariaDB, sleutelbestand, bestandsopslag, e-mail | EF Core, Pomelo |
| `MijnBesluit.ServiceBeheer` | De Windows-service installeren, verwijderen, starten en stoppen | geen |
| `MijnBesluit.SocketServer` | De zelfstandige service voor de app, het beheerschermpje en de achtergrondtaken | Hosting, WinForms |
| `MijnBesluit.Web` | De website in Blazor | geen |
| `MijnBesluit.SelfCheck` | Controleert de regels zonder database of webserver | geen |
Dat de eerste vier geen enkel extern package nodig hebben is met opzet: daardoor zijn de regels
en het protocol te bouwen en te controleren zonder dat er ook maar iets geïnstalleerd hoeft te
worden, en is `MijnBesluit.Protocol` zonder gedoe te hergebruiken in de MAUI-app.
## Geen webservice, geen IIS
De website is Blazor Server en roept de Application-laag rechtstreeks aan, in hetzelfde proces.
Er zit dus geen webservice tussen die serialiseert, over HTTP praat en weer deserialiseert.
De app praat met `MijnBesluit.SocketServer`: een zelfstandig draaiende Windows-service (64 bits)
met een eigen TCP-luisteraar. Daar komt geen IIS aan te pas.
Dezelfde exe is ook zijn eigen installatieprogramma: start Windows hem, dan draait de server;
start jij hem met de rechtermuisknop als administrator, dan verschijnt een schermpje met een
poortnummer en een knop **Install** — of **UnInstall** als hij al geïnstalleerd is. Zie
`docs/SocketServer-installeren.md`.
```
dotnet publish src\MijnBesluit.SocketServer -c Release -r win-x64 --self-contained false -o D:\MijnBesluit\publish
```
Binnen de service:
* één thread accepteert verbindingen;
* elke verbinding krijgt een eigen thread met een blokkerende leeslus;
* elk verzoek gaat naar zijn eigen thread, zodat een trage databaseactie de volgende verzoeken
van diezelfde app niet ophoudt. Elk verzoek krijgt daarmee ook zijn eigen databasecontext.
## Hoe een profiel van eigenaar wisselt
Dit is de kern van hoe MijnBesluit zich verspreidt.
1. Jan maakt een account. Hij krijgt een profiel (dat hij meteen zelf bezit) en een familieproject.
2. Jan voegt zijn dochter Eva toe. Er komt een profiel voor Eva, **beheerd door Jan**. Jan kan
alles aan dat profiel wijzigen.
3. Jan stuurt een uitnodiging naar Eva's e-mailadres. In de database staat alleen de SHA-256 van
het uitnodigingstoken; het token zelf bestaat enkel in die ene e-mail.
4. Eva klikt op de link en kiest een wachtwoord. Op dat moment wordt het profiel **geclaimd**:
`claimed_by_user_id` wordt gevuld en `managed_by_user_id` leeggemaakt.
5. Vanaf nu kan Jan niets meer aan Eva's profiel wijzigen. Hij ziet haar nog steeds in zijn
familie staan, met het label "geclaimd".
6. Eva krijgt haar eigen familieproject en nodigt op haar beurt anderen uit.
De regel staat op één plek: `ProfileOwnership.CanEdit`. Alles wat een profiel wil wijzigen gaat
daarlangs.
## Overlijden en nalatenschap
0. Je wijst beheerders aan, op volgorde. Zij zien dat meteen staan onder *Aan mij toevertrouwd*,
met hun volgorde erbij — daar reken je tenslotte op als het zover is. Meer dan de aanwijzing
zien ze niet: geen datum, geen aantallen, geen kluis.
Weghalen kan ook (`legacy.remove-manager`). Dat hoort erbij: een beheerder kan zelf overlijden,
en aanwijzen zonder kunnen terugnemen is geen keuze. Eén geval kan niet — iemand die je kluis
al heeft gekregen. Dan is de procedure afgerond en heeft hij gezien wat erin stond; die regel
weghalen zou alleen de administratie opschonen en niet wat er gebeurd is.
1. **Iedereen uit je kring** kan melden dat je bent overleden: de mensen die jij hebt toegevoegd
en die hun uitnodiging hebben aangenomen. Eén melding per gebruiker, afgedwongen met een
unieke sleutel in de database.
2. **Minstens één van die meldingen komt van een aangewezen beheerder.** Zolang die er niet is,
begint er niets: geen bericht aan de betrokkene, geen termijn die verloopt.
Waarom niet "alleen beheerders mogen melden", wat voor de hand lag: dan heb je evenveel
beheerders nodig als er meldingen vereist zijn. Wijs je er twee aan terwijl er drie meldingen
nodig zijn — wat de meeste mensen zullen doen — dan kan de procedure nooit worden afgerond en
gebeurt er bij een overlijden niets. Stilzwijgend, want niemand merkt het.
Zo werkt het nu dus niet. Het *aantal* haal je met je kring, want die merkt een overlijden als
eerste en is groot genoeg. De *beheerder* is de stem die er in elk geval bij moet zitten. Twee
verschillende soorten mensen moeten het dus eens zijn voordat er iets in beweging komt.
Blijft die beheerdersmelding uit, dan zegt het nalatenschapsscherm dat met zoveel woorden
(`WachtOpBeheerder`). En wie nog niemand heeft aangewezen, krijgt daar een waarschuwing: zonder
beheerder blijft de kluis ook na een overlijden dicht.
3. Zodra aan allebei is voldaan (standaard drie meldingen, per profiel instelbaar), krijgt de
betrokkene zelf een e-mail: *klopt dit niet, laat het weten*.
4. Reageert die persoon binnen de bezwaartermijn (standaard 14 dagen), dan vervallen alle
meldingen en gebeurt er niets.
5. Reageert die persoon niet, dan wordt het overlijden bevestigd. De aangewezen
nalatenschapsbeheerders krijgen toegang tot precies die kluisitems die zijn gemarkeerd als
"vrijgeven na overlijden" — de rest blijft dicht. Berichten met "pas als ik er niet meer ben"
worden bezorgd.
Een **lopende** procedure ziet er bij de beheerder precies zo uit als iemand die springlevend is.
Dat is met opzet: anders kon hij aan zijn eigen scherm aflezen dat er meldingen binnen zijn over
iemand die dat nog kan tegenspreken. Pas bij stap 5 verandert de kaart.
Later kan een koppeling met de BRP (de opvolger van de GBA) stap 1 tot en met 4 vervangen door
één betrouwbare bron. De rest van de code verandert dan niet: alleen wie `DeathConfirmedAt`
vult, verandert.
## Achtergrondtaken
`AchtergrondTaken` in de SocketServer draait elk kwartier en doet drie dingen: berichten bezorgen
waarvan de datum is bereikt of de afzender bevestigd is overleden, bezwaartermijnen die zijn
afgelopen afronden, en verlopen sessies opruimen.
## Tijd
Alles wordt in UTC opgeslagen en pas op het scherm omgerekend naar lokale tijd. Een bericht dat
op 25 december bezorgd moet worden, verschuift zo nooit door zomertijd.
+208
View File
@@ -0,0 +1,208 @@
# MijnBesluit — beveiliging
In een kluis met wachtwoorden, pincodes en uitvaartwensen is beveiliging geen bijzaak. Dit
document beschrijft precies hoe het werkt, zodat je het kunt controleren en er later bewust van
kunt afwijken.
## Inloggen met een code per e-mail
Er zijn geen wachtwoorden. Je vult je e-mailadres in, krijgt een code van zes cijfers toegestuurd
en vult die in. Dat is dezelfde gang van zaken als in de WinDev-versie, met drie verschillen.
**De code staat niet leesbaar opgeslagen.** In de WinDev-versie stond hij in
`mlPeople.ActivationCode` en werd hij bovendien in een paginavariabele (`gsInlogcode`) aan de
browser meegegeven; wie de paginabron bekeek, kon zo inloggen. Hier gaat alleen een hash de
database in (PBKDF2-HMAC-SHA512), en de code verlaat de server nooit anders dan per e-mail.
**Zes cijfers in plaats van vier.** `Random(1111,9999)` gaf ruim achtduizend mogelijkheden; nu
zijn het er een miljoen. En `Random` is voorspelbaar zodra je een paar uitkomsten kent, dus de
code komt uit `RandomNumberGenerator`.
**Er zitten remmen op.** Een code is 15 minuten geldig, werkt één keer, en na vijf misgokken
wordt hij weggegooid. Een nieuwe code maakt de vorige ongeldig. Per e-mailadres mag er hooguit
één code per 30 seconden en vijf per uur worden aangevraagd, zodat niemands postvak volloopt.
Die vijf per uur tellen alleen de codes waarmee **niet** is ingelogd (`signed_in_at` leeg). De
rem is er tegen iemand die andermans postvak volgooit, en wie de code invult zit zelf in dat
postvak. Telden geslaagde aanmeldingen wel mee, dan sloot je jezelf buiten na vijf keer inloggen
in een uur — en dat gebeurt precies wanneer er iets anders aan de hand is en je het opnieuw
probeert. Een code die is verdrongen door een nieuwe aanvraag of die is stukgeraden telt wél
mee: allebei die dingen kan een vreemde doen die alleen het adres kent, en anders was de rem in
twee stappen leeg te trekken.
Alles staat als constante bovenin `AccountService`, dus aanpassen is één regel.
## Een account opheffen
Zelf, op elk moment, in de app bij Mijn profiel en op `/account-verwijderen`. Dat webadres werkt
ook zonder in te loggen en zonder de app - Google Play eist dat apart, en terecht: een
verwijderknop die alleen in de app zit helpt niet als je de app al hebt weggegooid.
**Er komt een verse code per e-mail aan te pas**, met een eigen doel (`LoginCodePurpose.Opheffen`)
zodat een gewone inlogcode hier niet volstaat. Een openstaande sessie op een telefoon die iemand
anders in handen krijgt, hoort een kluis niet leeg te kunnen gooien.
De regel waar alles op teruggaat: **weg is wat van jou is, blijven doet wat van een ander is.**
In deze applicatie liggen die twee dichter bij elkaar dan je zou denken, dus staat per soort in
`AccountOpheffenService` waarom het de ene of de andere kant op valt. Kort:
| Weg | Blijft |
|---|---|
| Je kluis, je bestanden, je sleutel | Berichten die al bezorgd zijn - die zijn van de ontvanger |
| Berichten die nog klaarstonden | Je naam in de lijst van wie jou heeft toegevoegd |
| Je eigen families en je aantekeningen over anderen | Aantekeningen die ook aan iemand anders hangen |
| Je aanwijzingen in nalatenschappen, beide kanten op | Het logboek (daar staat geen inhoud in) |
| Je toestellen, je sessies, je inlogcodes | |
Dat bezorgde berichten leesbaar blijven is geen toeval: bij het bezorgen krijgt de ontvanger zijn
eigen kopie van de sleutel (zie `MessageService`). Jouw sleutel weggooien maakt ze dus niet stuk.
Zonder die eigenschap zou "bezorgd blijft" een belofte zijn die we niet waarmaken.
Het scherm laat eerst zien wát er weggaat - aantallen, geen "weet je het zeker?". En wie ergens
als nalatenschapsbeheerder is aangewezen, krijgt dat er apart bij te zien: die aanwijzing vervalt,
en de ander rekent erop.
> **Wat dit betekent.** Wie bij iemands e-mail kan, kan bij diens kluis. Dat is de prijs van
> inloggen zonder wachtwoord — al gold dat bij wachtwoorden met e-mailherstel net zo goed. Het
> alternatief, wachtwoord én code, is veiliger maar legt een drempel bij precies de groep waar
> MijnBesluit voor bedoeld is. Wil je later toch twee stappen: het staat allemaal in
> `AccountService`, de rest van de code verandert niet.
Bij inloggen wordt gezegd dat een e-mailadres onbekend is ("Maak dan eerst een account aan").
Dat verraadt of iemand deelnemer is. Bewust: zonder die melding raken mensen die zich vergissen
in hun adres het spoor bijster. Wil je dat niet prijsgeven, geef dan altijd hetzelfde antwoord
en stuur bij een onbekend adres een mail met de tekst dat er geen account is.
## De kluis: drie lagen
```
inhoud van een item --AES-256-GCM--> cijfertekst in de database
^
| contentsleutel (CEK), per item willekeurig
|
CEK --AES-256-GCM--> wrapped_content_key (ingepakt met de datasleutel van de eigenaar)
^
| datasleutel (DEK), per gebruiker
|
DEK --AES-256-GCM--> wrapped_data_key (ingepakt met de mastersleutel van de server)
```
Er staat dus nergens een sleutel in leesbare vorm in de database. Wie de database kopieert heeft
er niets aan zolang hij het sleutelbestand niet ook heeft.
Waarom drie lagen en niet twee? Omdat toegang geven aan een ander dan neerkomt op één handeling:
de contentsleutel nóg een keer inpakken, nu met de datasleutel van die ander. Dat is precies wat
er gebeurt als een bericht wordt bezorgd of als een nalatenschapsbeheerder toegang krijgt. De
inhoud zelf hoeft niet opnieuw versleuteld te worden, en de eigenaar houdt zijn eigen sleutel.
AES-256-GCM is *authenticated encryption*: bij elke ontsleuteling wordt de authenticatietag
gecontroleerd. Één gewijzigde byte in de database en het openen mislukt met een duidelijke fout,
in plaats van dat er stilletjes onzin uit komt.
## Wat is niet versleuteld?
Bewust leesbaar, omdat er anders niet te zoeken of te sorteren valt:
- de titel en de categorie van een kluisitem;
- het onderwerp, de afzender, de ontvanger en de bezorgdatum van een bericht;
- namen, e-mailadressen en relaties.
Zet dus geen geheimen in een titel. Dat staat ook zo in het scherm waar je een item aanmaakt.
## De mastersleutel
Die staat in een sleutelbestand (standaard `keys/masterkey.json`), als een sleutelring: meerdere
genummerde sleutels waarvan er één de actieve is. In een ingepakte datasleutel staat het
versienummer vooraan, zodat je kunt roteren zonder oude gegevens onleesbaar te maken.
Eenmalig aanmaken:
```csharp
FileMasterKeyProtector.CreateKeyFile("keys/masterkey.json");
```
Drie dingen om goed te doen:
1. **Zet het bestand niet in dezelfde back-up als de database.** Anders heeft wie de back-up
steelt alsnog alles.
2. **Beperk de rechten** tot het account waaronder de service draait.
3. **Maak er een aparte back-up van, op een andere plek.** Raak je dit bestand kwijt, dan is
iedere kluis onherstelbaar dicht. Er is geen achterdeur — dat is het punt.
Voor productie is een sleutelkluis of HSM beter. Vervang dan alleen `FileMasterKeyProtector`
door een eigen implementatie van `IMasterKeyProtector`; de rest van de code verandert niet.
Merk op: omdat de server de mastersleutel heeft, kan iemand met volledige serverrechten in
theorie bij de kluisinhoud. Dat is de prijs van deze opzet, en dat is bewust gekozen: het maakt
wachtwoordherstel mogelijk en zorgt dat de nalatenschapsbeheerder ook echt toegang krijgt als de
overledene zelf niets meer kan doen. Wil je dat niet, dan is de hybride variant nodig waarbij de
datasleutel ook met het wachtwoord van de gebruiker wordt ingepakt — maar dan is een vergeten
wachtwoord gelijk aan een verloren kluis.
## Bestanden
Bijlagen staan versleuteld op schijf, buiten de database, in mappen per jaar en maand. De
oorspronkelijke bestandsnaam komt niet in het pad voor (die kan al iets verraden); wat op schijf
staat heet `<willekeurige-guid>.bin`. Van de onversleutelde inhoud wordt een SHA-256 bewaard,
zodat beschadiging vast te stellen is. Paden worden gecontroleerd, zodat een opslagsleutel nooit
buiten de map kan wijzen.
## Tokens
Uitnodigingstokens en vernieuwtokens zijn 32 willekeurige bytes uit een cryptografische
generator, URL-veilig gecodeerd. In de database staat alleen de SHA-256 ervan. Een uitnodiging is
30 dagen geldig en eenmalig bruikbaar.
## Verbindingen
De website draait achter HTTPS met HSTS. Het sessiecookie is `HttpOnly`, `SameSite=Lax` en
`Secure`.
De SocketServer gebruikt TLS 1.2 of 1.3, met een servercertificaat uit een pfx-bestand of uit de
certificaatopslag van Windows. Zonder certificaat start de server wel, maar onversleuteld, met
een waarschuwing in het logboek — dat is uitsluitend bedoeld voor lokaal testen. Er gaan
wachtwoorden en pincodes over deze verbinding.
> **Verschil met de RenH-socketserver.** Daar wordt de verbinding beveiligd met een
> "socketsleutel": een tijdstempel, versleuteld met AES-128-CBC waarvan de sleutel de MD5 is van
> een vast wachtwoord in de broncode. Dat werkt prima als bewijs dat de client bij het systeem
> hoort en dat het bericht vers is, maar het versleutelt de inhoud niet en het wachtwoord staat
> in elke uitgeleverde app. Voor een dashboard met verkoopcijfers is dat een andere afweging dan
> voor een kluis met pincodes. Daarom hier TLS, waarbij de hele verbinding versleuteld is en het
> certificaat aan de serverkant blijft.
## Het logboek
`audit_log` legt vast wie wat wanneer deed: inloggen, mislukte pogingen, profielwijzigingen,
uitnodigingen, het openen van een kluisitem, overlijdensmeldingen en het vrijgeven van een
nalatenschap. De applicatiegebruiker in de database heeft op die tabel alleen INSERT-rechten
(zie `db/02_applicatiegebruiker.sql`), zodat regels niet achteraf gewijzigd of verwijderd kunnen
worden.
## Foutmeldingen
De SocketServer stuurt bij een onverwachte fout altijd dezelfde neutrale tekst terug. Wat er echt
misging staat in het logboek van de server, niet op het scherm van de gebruiker. Zo lekken er
geen servernamen, tabelnamen of stacktraces naar buiten.
## Waar de geheimen staan
Zet de connectiereeks en het SMTP-wachtwoord **niet** in `appsettings.json` in je repository. Bij
ontwikkelen:
```
dotnet user-secrets set "ConnectionStrings:MijnBesluit" "Server=localhost;Database=mijnbesluit;User Id=...;Password=..." --project src\MijnBesluit.Web
```
In productie: omgevingsvariabelen of een sleutelkluis.
## Wat er nog niet is
Eerlijk overzicht van wat bewust nog ontbreekt:
- **Twee-factor-authenticatie.** Het veld staat in de database, de implementatie nog niet.
- **Snelheidsbegrenzing per IP-adres.** Er is wel een blokkade per account na vijf mislukte
pogingen, maar nog geen begrenzing op verzoekniveau.
- **Certificaat-pinning in de app.** Aan te raden zodra de app publiek is.
- **Een onafhankelijke beveiligingsaudit.** Voor een product dat dit soort gegevens bewaart is
dat geen luxe.
+228
View File
@@ -0,0 +1,228 @@
# E-mail: hoe MijnBesluit post verstuurt
Zonder e-mail werkt MijnBesluit niet. Inloggen gaat met een code per mail, en uitnodigingen aan
familie gaan per mail. Elke storing hier is dus een storing in het hart van de applicatie.
## Hoe het nu loopt
Sinds september 2026 gaat alle uitgaande post via de **eigen Kerio Connect** op dezelfde server
als de website. Het domein mijnbesluit.nl staat daar als domein ingericht, met de mailboxen
`noreply@mijnbesluit.nl` en `info@mijnbesluit.nl`.
```
MijnBesluit (website + SocketServer)
-> 127.0.0.1:25 (Kerio Connect, zelfde machine)
-> poort 25 naar buiten -> Gmail en de rest
```
De afzender is `noreply@mijnbesluit.nl`. Dat mag nu, want het domein staat op onze eigen server;
bij Solcon kon het niet (zie "Wat er al een keer misging").
## Waar de instellingen staan
In `C:\MijnBesluitData\appsettings.secrets.json` op de server, in het blok `Smtp`. Dat bestand
wordt gelezen door **zowel de website als de SocketServer**, zodat er één plek is:
```json
"Smtp": {
"Host": "127.0.0.1",
"Port": 25,
"UseStartTls": false,
"Username": "",
"Password": "",
"FromAddress": "noreply@mijnbesluit.nl",
"FromName": "MijnBesluit",
"WriteToDiskInsteadOfSending": false,
"RedirectAllTo": ""
}
```
### Waarom gebruikersnaam en wachtwoord leeg zijn
Kerio staat relayen toe voor de groep "Local clients", en `127.0.0.1` zit daarin. Post die van
deze server zelf komt hoeft dus niet in te loggen. Dat is niet alleen eenvoudiger maar ook beter:
er staat geen mailwachtwoord meer in dit bestand.
**Vul hier niet alsnog een wachtwoord in.** Klopt het niet, dan krijg je geen foutmelding maar
traagheid - zie hieronder.
Na een wijziging moet je herstarten; de instellingen worden één keer bij het opstarten gelezen
en daarna vastgehouden, ondanks `reloadOnChange`:
```powershell
Restart-Service MijnBesluitSocket
Restart-WebAppPool mijnbesluit
```
### Waarom `127.0.0.1` en niet `localhost`
`localhost` lost Windows eerst op naar het IPv6-adres `::1`. Luistert Kerio daar niet, dan wacht
.NET het verbindingstijdslimiet af voordat hij IPv4 probeert. Het resultaat: de mail komt wél
aan, maar het antwoord aan de app duurt meer dan tien seconden en het proefscript geeft een
time-out. Met een hard IPv4-adres is het binnen een seconde klaar.
**Dit geldt net zo goed voor de database.** MariaDB luistert standaard alleen op 127.0.0.1, dus
`Server=localhost` in de connectiereeks kost bij elke nieuwe verbinding dezelfde verloren
seconden - merkbaar als de eerste bezoeker na een herstart van de app-pool lang moet wachten.
Zet ook daar `Server=127.0.0.1`. Meet het verschil zo:
```powershell
Measure-Command { .\tools\SocketServer-proef.ps1 } # raakt de database niet
Measure-Command { .\tools\SocketServer-proef.ps1 -Email ... -Nieuw } # raakt hem wel
```
### Waarom geen TLS op die stap
Kerio presenteert op 587 nog een zelfondertekend certificaat. `System.Net.Mail.SmtpClient`
weigert dat botweg (`The remote certificate is invalid according to the validation procedure`,
met `RemoteCertificateNameMismatch, RemoteCertificateChainErrors`), en de controle uitzetten is
in een applicatie die wachtwoorden bewaart het verkeerde ruilmiddel.
Over loopback is dat te verdedigen: het pakket verlaat de machine niet. Maar het hoort tijdelijk
te zijn - zie "Wat hier nog niet goed aan is".
Let op: `SmtpClient` kan geen implicit TLS. Poort 465 werkt dus nooit; dat moet 587 met STARTTLS
zijn zodra er een geldig certificaat is.
## De DNS voor mijnbesluit.nl
Bij Strato. Alles hieronder is september 2026 ingericht; daarvóór had het domein helemaal geen
TXT-records.
| Naam | Type | Waarde |
|---|---|---|
| `mijnbesluit.nl` | A | 85.215.171.61 |
| `mijnbesluit.nl` | MX | `mail.kwik-bit.nl`, prioriteit hoog |
| `mijnbesluit.nl` | TXT | `v=spf1 ip4:85.215.171.61 ~all` |
| `mail._domainkey.mijnbesluit.nl` | TXT | de DKIM-sleutel die Kerio toont |
| `_dmarc.mijnbesluit.nl` | TXT | `v=DMARC1; p=none; rua=mailto:info@mijnbesluit.nl` |
Aandachtspunten:
* In het MX-record hoort een **naam**, geen IP-adres. Een MX naar `85.215.171.61.` wordt door
mailservers die zich aan de regels houden geweigerd.
* Strato's eigen SPF- en DMARC-knoppen staan op "geen", zodat er geen tweede record naast het
onze komt. Twee SPF-records betekent dat geen van beide werkt.
* De reverse lookup van 85.215.171.61 geeft `mail.kwik-bit.nl`, en die naam wijst terug naar
hetzelfde IP. Die heen-en-terugcontrole is wat Gmail eist; hij hoeft niet bij het
afzenderdomein te horen. De Internet hostname in Kerio moet gelijk zijn aan die PTR.
### Bekend probleem: ns3 loopt achter
De twee nameservers van Strato geven niet hetzelfde antwoord: `ns4.stratoserver.net` heeft het
DKIM-record, `ns3.stratoserver.net` valt terug op een wildcard-CNAME naar `mijnbesluit.nl`.
DKIM slaagt daardoor ongeveer de helft van de keren. SPF staat op het domein zelf en heeft er
geen last van, en SPF alleen is genoeg voor DMARC-uitlijning - de post komt dus gewoon aan.
Controleren:
```powershell
$ns = (Resolve-DnsName mijnbesluit.nl -Type NS -Server 8.8.8.8).NameHost
foreach ($s in $ns) {
"--- $s"
Resolve-DnsName mail._domainkey.mijnbesluit.nl -Type TXT -Server $s -ErrorAction SilentlyContinue |
Select-Object Type, @{n='Waarde';e={ if ($_.Strings) { ($_.Strings -join '').Substring(0,40) } else { $_.NameHost } }}
}
```
Staat ns3 er over een paar dagen nog steeds anders in, dan is dat een melding aan Strato:
*"`mail._domainkey.mijnbesluit.nl` geeft op ns3 een wildcard-CNAME en op ns4 het TXT-record."*
## Testen
Van binnen naar buiten, zodat je weet wáár het misgaat:
```powershell
# 1. de hele keten: socket, database, mailserver
.\tools\SocketServer-proef.ps1 -Email iemand@example.nl -Nieuw
# 2. alleen de mailserver
$c = Get-Credential
Send-MailMessage -SmtpServer mail.kwik-bit.nl -Port 587 -UseSsl -Credential $c `
-From "noreply@mijnbesluit.nl" -To "..." -Subject "Proef" -Body "Proef"
```
`-Nieuw` vraagt een registratiecode in plaats van een inlogcode, zodat je naar een adres kunt
testen waar nog geen account bij hoort. Er wordt pas een account aangemaakt als de code wordt
ingevuld.
Komt de mail aan bij Gmail, open hem dan via *Origineel weergeven*: daar staat of SPF, DKIM en
DMARC geslaagd zijn. Dat is de enige echte controle.
## Wat er al een keer misging
### 0. Een voorbeeldtekst als wachtwoord kost negen seconden per inlogcode
Het langste dat dit project ons heeft gekost, en het stond al die tijd in het instellingenbestand:
```json
"Password": "het wachtwoord van die mailbox",
```
Dat is de voorbeeldtekst uit de handleiding, nooit vervangen. Het gevolg is níet een foutmelding.
Kerio weigert de aanmelding, .NET probeert het opnieuw met een andere methode, en valt daarna
terug op versturen zonder aanmelden - wat mag, want het IP-adres staat in "Local clients". De
mail komt dus gewoon aan. Alleen duurt hij **negen seconden**, elke keer, en die staat de
gebruiker bij het inloggen uit te zitten.
Wat het zo lastig maakte om te vinden: alles wat je van buitenaf meet is snel. Kerio doet 5 ms,
met en zonder inloggen. Maar dat meet je met Windows PowerShell, en dat is .NET Framework 4.8 -
een andere implementatie van `SmtpClient` dan de .NET 8 waar de applicatie op draait, die andere
keuzes maakt bij het aanmelden. Appels met peren.
Lessen voor de volgende keer:
* Controleer het instellingenbestand **als eerste**, niet als laatste.
* Windows PowerShell is geen goede proef voor .NET-gedrag. Gebruik `pwsh` als je het na wilt doen.
* De tijdmeting in `SmtpEmailSender` splitst het wachten in "wachten op een vrije draad" en
"het SMTP-gesprek zelf". Die twee getallen wezen het aan; laat ze staan.
De applicatie waarschuwt nu bij het opstarten als het SMTP-wachtwoord spaties bevat - een echt
wachtwoord doet dat zelden en een voorbeeldzin altijd.
### 1. De afzender moet bij het account horen
Solcon weigerde elk afzenderadres dat niet bij het inlogaccount hoorde:
```
Mailbox unavailable. The server response was: Verification failed for <noreply@mijnbesluit.nl>
```
Dat is met de eigen mailserver verdwenen.
### 2. Elke mail heeft een Message-ID nodig
```
550-5.7.1 Messages missing a valid Message-ID header are not accepted
```
.NET zet die koptekst niet zelf, en niet elke mailserver vult hem aan. `SmtpEmailSender` zet hem
er daarom zelf op; zie `Berichtkenmerk` in de Application-laag.
### 3. Het domein moet zeggen wie er namens hem mag versturen
```
550-5.7.26 Gmail requires all senders to authenticate with either SPF or DKIM
```
Vandaar de records hierboven.
## Wat hier nog niet goed aan is
**Kerio heeft geen geldig certificaat.** Zelfondertekend, op de verkeerde naam. Daardoor moet de
applicatie over loopback zonder TLS praten, krijgt elke mailclient een waarschuwing, en kunnen
andere mailservers de TLS-verbinding weigeren. De oplossing is een Let's Encrypt-certificaat voor
`mail.kwik-bit.nl` met **win-acme** op die server - die kan via IIS op poort 80 valideren, en na
afloop een script draaien dat het certificaat in Kerio zet. Kerio's eigen Let's Encrypt-knop
werkt hier niet, want die wil poort 80 zelf hebben en die is van IIS.
Daarna kan het `Smtp`-blok terug naar `mail.kwik-bit.nl` op poort 587 met `UseStartTls: true`.
**Je ziet niet of een mail aankomt.** De applicatie weet alleen of Kerio hem heeft aangenomen.
Wordt hij daarna alsnog geweigerd, dan komt die melding als bounce terug op `noreply@` en ziet de
gebruiker alleen "de code is verstuurd" terwijl er niets aankomt. De DMARC-rapporten naar
`info@mijnbesluit.nl` geven daar wel zicht op, maar pas achteraf en per dag.
**`p=none` in DMARC.** Dat is de juiste stand om mee te beginnen: je krijgt rapporten zonder dat
er post geweigerd wordt. Gaat het een paar weken goed, dan kan het naar `p=quarantine` en het
SPF-record van `~all` naar `-all`.
+275
View File
@@ -0,0 +1,275 @@
# Wie is wie: dubbele profielen, claimen en samenvoegen
Dit document legt vast hoe MijnBesluit omgaat met de vraag "is dit dezelfde persoon?". Het is
geschreven na een ontwerpgesprek op 15 september 2026, omdat de keuzes hier niet vanzelfsprekend
zijn en de redenen erachter over een paar maanden niet meer uit de code te halen zijn.
## Het probleem
Jonathan voegt zijn dochter Ysabeau toe als dochter. Zijn ex-partner voegt dezelfde Ysabeau toe,
ook als dochter. Straks doen haar grootouders dat ook. Uiteindelijk maakt Ysabeau zelf een
account en wil ze haar eigen profiel beheren — en als ze daar een foto zet, horen haar familie en
vrienden die te zien.
Zonder maatregelen levert dat vier keer dezelfde persoon op.
## De kern van de oplossing
**Een niet-geclaimd profiel is een privé-aantekening van degene die hem maakte.**
Wat Jonathan heeft aangemaakt is niet "Ysabeau", het is *zijn aantekening over zijn dochter*. Dat
zijn ex haar eigen aantekening heeft is normaal en eerlijk. Dubbele profielen zijn vóór het
claimen dus geen fout.
Het wordt pas een probleem op het moment dat Ysabeau zelf zegt: die ben ik. Daarmee verschuift de
hele vraag — niet "hoe voorkom ik duplicaten bij het aanmaken", maar "hoe smelten ze samen op het
moment van claimen".
## Wat we bewust NIET doen
**Niet zoeken op naam en geboortedatum.** MyHeritage doet dat wel: je typt een naam en
geboortedatum en krijgt "bedoelt u deze?". Dat werkt daar, maar het lekt. Wie mag zo'n vraag
stellen? Als iemand "Ysabeau Flietstra, 12 maart 1998" intikt en het systeem zegt "die kennen
we", dan weet die persoon nu dat ze hier zit. Met genoeg pogingen is dat een zoekmachine op
mensen, en dat is precies wat MijnBesluit niet moet zijn.
**Niet stilzwijgend koppelen op e-mailadres.** (Wél vrágen; zie het hoofdstuk hieronder over de
vraag bij het aanmelden. Het verschil is wie er beslist.) Dit zat er wél in, tot 15 september 2026:
`AddMemberAsync` zocht een bestaand profiel op adres en hing dat aan je familie. Gevolg: wie het
adres van iemand kende, zag meteen wat een ander over die persoon had ingevuld — naam,
geboortedatum, profielfoto. Dat is dezelfde zoekmachine, via een andere deur.
De regel is nu: **je hergebruikt alleen je eigen aantekeningen, nooit die van een ander.** Voeg
je iemand toe aan een tweede familie van jezelf, dan is dat wél hetzelfde profiel — anders zie je
je eigen dochter twee keer.
## Hoe het dan wel samenkomt
**De uitnodiging is de match.** Jonathan nodigt Ysabeau uit op haar adres, zijn ex doet dat ook,
misschien op een ander adres. Ze neemt beide uitnodigingen aan. Daarmee heeft zíj gezegd dat
beide profielen haar zijn — en zij is de enige die dat mag zeggen.
Concreet:
| Situatie | Wat er gebeurt |
|---|---|
| Eerste uitnodiging, nog geen account | Er wordt een account gemaakt en het profiel wordt geclaimd. Zoals het altijd al ging. |
| Tweede uitnodiging, **ingelogd** | Het profiel uit de uitnodiging gaat op in het profiel dat je al hebt. |
| Tweede uitnodiging, **niet ingelogd**, adres is bekend | Geweigerd, met de uitleg dat je eerst moet inloggen. Een tweede account zou juist het probleem veroorzaken. |
Dit werkt ook als beide ouders een ander e-mailadres van haar hadden: het adres speelt geen rol
bij het koppelen, alleen de link die zij aanklikt.
## De vraag bij het aanmelden (17 september 2026)
De uitnodiging is de match, maar hij is niet de enige weg. Wat er in de praktijk gebeurde: iemand
voegt een vriend toe met zijn e-mailadres, die vriend maakt zelf een account aan, en dan staan er
twee losse dingen naast elkaar die allebei over dezelfde man gaan. De vriend moet dan wachten tot
er een uitnodiging komt die de ander misschien nooit verstuurt.
Automatisch koppelen op adres is daar niet het antwoord op, om twee redenen. Het lekt (zie
hierboven), en een adres is geen identiteit: twee mensen kunnen er één delen - een echtpaar, een
ouder die het adres van zijn kind gebruikt, een `info@` - en sinds `db/15` kunnen er ook meerdere
aantekeningen met hetzelfde adres bestaan. Automatisch koppelen moet dan gokken welke, en een
verkeerde gok maakt van twee mensen stilletjes één. Dat krijg je er niet meer uit.
Daarom vragen we het aan de enige die het antwoord weet. Wie is aangemeld, ziet bovenaan zijn
overzicht staan: *"X heeft een profiel voor je aangemaakt op jouw e-mailadres, als vriend. Ben jij
dat?"* Met twee knoppen, en dat is alles.
| Antwoord | Wat er gebeurt |
|---|---|
| **Ja, dat ben ik** | Dezelfde samenvoeging als bij een uitnodiging die je ingelogd aanneemt. |
| **Nee, dat ben ik niet** | Er verandert niets, en de vraag komt niet terug (`persons.link_declined_by_user_id`, `db/17`). |
| **Niets** | Er verandert niets. De vraag blijft staan tot hij hem beantwoordt. |
Waarom dat mag zonder dat het lekt: wie deze vraag krijgt, heeft zojuist met een code in dat
postvak bewezen dat het adres van hem is. De enige die iets te zien krijgt is dus de eigenaar van
het adres - en over zichzelf. Hij ziet de naam van degene die de aantekening bijhoudt en wat hij
van hem zou zijn; dat is precies genoeg om de vraag te kunnen beantwoorden, en niet meer.
Andersom gebeurt er niets: degene die de aantekening maakte krijgt geen melding dat er iets is
gevraagd, en geen melding dat het antwoord nee was. Anders zou het weigeren zélf een mededeling
worden over wie er wel of niet een account heeft.
De vraag staat op het overzicht en niet als popup bij het aanmelden. Iemand kan je ook toevoegen
nádat jij je al had aangemeld, en een popup die je wegklikt is een vraag die je nooit meer krijgt.
Er is hierdoor **geen apart goedkeuringsverzoek nodig** voor het toevoegen van iemand die al een
account heeft. Dat stond eerst in het plan, maar het lost zichzelf op: wie nooit andermans
profiel adopteert, geeft ook niets weg. Wil je die persoon er echt bij, dan nodig je uit — en die
uitnodiging *is* de toestemming.
## Wat er verhuist bij het samenvoegen
Zie `SamenvoegService`. Een niet-geclaimd profiel bezit weinig, want kluisitems horen bij de
persoon áchter een account. Wat er wel aan hangt:
- **lidmaatschappen** — staat het doel al in die familie, dan verdwijnt het dubbele lidmaatschap;
de relatie die er al stond blijft leidend, want die is ingevuld door de eigenaar van die familie
- **berichten** die voor het duplicaat klaarstonden of ervandaan kwamen
- **foto's** waar de persoon op staat; hoogstens één blijft profielfoto
- **uitnodigingen**, ook de gebruikte
- **rollen in een nalatenschap** — een dubbele aanwijzing of een tweede overlijdensmelding van
dezelfde gebruiker verdwijnt, anders vervalst dat het aantal
- **lege velden** worden aangevuld vanuit het duplicaat, nooit overschreven: wat bij het doel
staat komt van de persoon zelf en weegt zwaarder
Het duplicaat wordt **niet verwijderd**. Hij blijft staan met `merged_into_person_id` naar waar
hij in is opgegaan — er hangt geschiedenis aan, en een verwijderde rij is niet terug te halen.
Twee grenzen, allebei met een controle erop:
- Je kunt alleen samenvoegen met **je eigen** profiel.
- Twee **geclaimde** profielen gaan nooit automatisch samen. Dat zijn twee echte accounts met
ieder een eigen kluis; daar hoort een mens naar te kijken.
## De profielfoto
Hier lag een echte afweging. Zet Ysabeau zelf een foto, dan is die versleuteld met háár sleutel.
Haar vader zou hem dan niet kunnen zien — precies het tegenovergestelde van de bedoeling.
**Gekozen: de kleine versie is niet versleuteld, de foto op ware grootte wel.**
De redenering: een profielfoto is juist het ding dat je aan andere mensen laat zien. Hem
behandelen als kluismateriaal is niet eerlijk over wat het is, en zou betekenen dat je voor elke
kijker een sleutel moet uitdelen en weer intrekken zodra families veranderen.
De prijs is echt en bewust genomen: wie toegang heeft tot de database, ziet gezichten van 160
pixels. Alles waar het in MijnBesluit om gaat — wachtwoorden, polissen, uitvaartwensen, berichten
— blijft versleuteld, net als de foto op ware grootte.
**Wie hem mag zien:** iedereen met wie je in een familie zit. Dat is de kring die mensen zelf
samenstellen door elkaar toe te voegen en uit te nodigen. Die controle staat in `VaultService` en
niet in de schermen: een toegangsregel hoort niet af te hangen van de vraag of elk scherm eraan
denkt.
Kleine versies uit de allereerste opzet waren nog wél versleuteld. Die worden bij het eerste
gebruik automatisch vervangen; daar is geen migratie voor nodig.
**Waar hij staat:** overal waar een mens in een lijst staat — Mijn mensen, je beheerders, de
nalatenschappen die aan jou zijn toevertrouwd — en in app en website hetzelfde. Dat zit in één
onderdeel, `Penning.razor`. Dat bestand staat twee keer op schijf (de app en de site delen geen
project) en een zelfcontrole vergelijkt ze letterlijk, zodat ze niet uit elkaar lopen.
**Jezelf** sta je niet tussen je eigen mensen — je bent geen familielid van jezelf. Bovenaan Mijn
mensen staat daarom een eigen kaart die naar Mijn profiel gaat. Daar wijzig je je naam, je
geboortedatum, je foto en je e-mailadres; dat laatste met een code naar het nieuwe adres. Zonder
die kaart gingen mensen hun eigen gegevens zoeken in de lijst waar ze juist niet in staan.
## Het adres van een persoon is niet uniek
Dat lijkt een detail van de database, maar het is de hele regel hierboven in één index.
Op `persons.email` stond een `UNIQUE`-index. Het gevolg: voegde je iemand toe met een adres dat
al ergens in de database stond - bij wie dan ook, ook bij een wildvreemde - dan liep het opslaan
stuk. Voor de gebruiker was dat een knop die niets deed. Wie een adres het eerst invulde,
bepaalde daarmee dat niemand anders die persoon nog kon toevoegen. In een applicatie waar twee
ouders allebei hun dochter toevoegen is dat precies de verkeerde kant om.
Sinds `db/15_adres_niet_uniek.sql` is het een gewone index. Wat dat betekent voor de code:
- **Zoeken op adres kan meer dan één profiel opleveren.** `ListByEmailAsync` geeft ze allemaal;
`FindByEmailAsync` geeft er één, geclaimde eerst, zodat je in elk geval altijd dezelfde
terugkrijgt.
- **Wie zijn eigen profiel zoekt, moet daarop filteren** en niet op de eerste treffer. Met de
eerste de beste kreeg je die van een ander te pakken, viel de eigendomscontrole uit, en maakte
je een tweede aantekening aan voor iemand die je zelf al had staan.
- **Bij het wijzigen is alleen een botsing met je eigen profiel een bezwaar.** Dat een ander
dezelfde persoon kent, is geen fout maar het uitgangspunt.
- **Een account maken koppelt niet meer op adres.** Wie zonder uitnodiging een account maakt,
krijgt zijn eigen profiel; de aantekening van een ander komt bij hem terecht op het moment dat
hij díens uitnodiging aanneemt. Zoeken op adres betekende andermans aantekening adopteren
zonder dat die ander er iets over te zeggen had, en met meerdere aantekeningen was het
bovendien de vraag welke je dan kreeg.
Bij `users.email` blijft uniek juist wél gelden: dat is een account, en er kan er maar één per
adres zijn. Een zelfcontrole bewaakt allebei die kanten.
## Wat hier nog niet is opgelost
- **Mensen zonder e-mailadres** kunnen nooit claimen, dus hun duplicaten blijven bestaan. Voor
een kluis en berichten maakt dat niet uit. Zodra er ooit een gedeelde stamboom komt, wordt het
wel een vraag.
- **Samenvoegingen van vóór 15 september 2026** zijn niet terug te draaien. Daar is geen bonnetje
van (zie hieronder); de gegevens om het mee te doen bestaan eenvoudigweg niet. Dat is geen fout
die nog te repareren valt.
## Samenvoegen terugdraaien
Samenvoegen was lange tijd onomkeerbaar, en dat stond hier ook als open punt. Dat is het nu niet
meer.
Waarom het niet zomaar kon: ná een samenvoeging is niet meer te zien wélke lidmaatschappen,
berichten en bestanden bij het duplicaat hoorden. Ze staan gewoon bij het doel, tussen alles wat
daar al stond. Een gok daarop zou andermans gegevens meesleuren.
**Daarom schrijft elke samenvoeging nu een bonnetje.** In de tabel `samenvoegingen` komt per
samenvoeging één regel te staan met precies wat er is verhuisd, als lijstje stappen in JSON.
Terugdraaien is die lijst in omgekeerde volgorde aflopen. Regels die bij het samenvoegen zijn
weggegooid — een dubbel lidmaatschap, een dubbele beheerder — staan er als volledige kopie in,
want die moeten opnieuw worden aangemaakt.
Drie dingen die daarbij met opzet zo zijn:
- **Wat er ná de samenvoeging is bijgekomen, blijft staan.** Wat Ysabeau daarna zelf in haar kluis
heeft gelegd, is van haar en hoort bij haar. Alleen wat in het bonnetje staat gaat terug.
- **Een veld dat we hebben aangevuld gaat alleen leeg als het nog steeds is wat wij invulden.**
Heeft de persoon het daarna zelf aangepast, dan is het van hem en blijft het staan.
- **Terugdraaien mag alleen een beheerder.** Samenvoegen doet de persoon zelf — alleen hij mag
zeggen dat twee profielen hem zijn — maar het raakt ook de gegevens van degene die het
duplicaat ooit aanmaakte. Gaat het mis, dan hoort er een mens naar te kijken.
Is het doelprofiel zelf later ergens in opgegaan, dan weigert het terugdraaien: het bonnetje
wijst dan niet meer naar waar de gegevens staan. Die latere samenvoeging gaat eerst terug.
Het bonnetje blijft na afloop staan, met wanneer en waarom het is teruggedraaid. Dát er ooit is
samengevoegd, is zelf ook geschiedenis.
## De beheerder
Er is één beheerpagina, `/beheer`, en die is met opzet bijna leeg. Hij bestaat voor de twee
situaties waar niemand anders uit komt: een overlijden dat ten onrechte is bevestigd terwijl de
bezwaarlink verlopen is — het account staat dan op "overleden", inloggen kan niet meer, en er is
geen weg terug — en een samenvoeging die niet had gemoeten.
Wat een beheerder wél kan:
- een profiel opzoeken op het **volledige** e-mailadres
- een bevestigd overlijden terugdraaien, met een verplichte reden die in het logboek komt
- een samenvoeging terugdraaien, ook met een verplichte reden
Beide handelingen hebben iets gemeen dat ze veilig maakt: ze geven nooit toegang. De ene neemt
toegang weg, de andere zet terug wat er stond.
Wat hij niet kan, en ook nooit moet kunnen:
- **een kluis openen.** Zijn enige handeling neemt toegang wég en geeft hem nooit. Wie dat
verandert, haalt de bodem onder het hele product vandaan.
- **op naam zoeken.** Dat zou de zoekmachine op mensen zijn die hierboven wordt afgewezen. Je
moet al weten wie je zoekt, omdat diegene contact met je heeft opgenomen.
- **iemand beheerder maken.** Dat gaat met de hand in de database:
`UPDATE users SET is_beheerder = 1 WHERE email = '...'`. Wat niet via de applicatie kan, kan
ook niet via een lek in de applicatie.
Wie geen beheerder is, krijgt op die pagina "Niet gevonden" te zien — niet "geen toegang". Een
pagina waarvan je niet weet dat hij bestaat, ga je ook niet proberen.
### Het menu-item Beheer
In het menu staat Beheer alleen voor een beheerder. Of dat zo is, komt uit het inlogcookie
(de claim `mijnbesluit:beheer`) en niet uit de database.
Dat is niet uit zuinigheid. Het menu zit in de layout, en die staat op élke pagina; hij wordt
tegelijk met de pagina zelf opgebouwd. Vroeg de layout het op bij de database, dan liep die
vraag gelijk op met de vraag van de pagina — over dezelfde DbContext, en die kan er maar één
tegelijk aan. Het gevolg was `A second operation was started on this context instance`: een
foutpagina die er soms wel en soms niet was, afhankelijk van welke vraag net eerder klaar was.
Een layout hoort niets op te halen; de zelfcontrole "Een layout haalt zelf niets op" houdt dat
vast.
De prijs is dat iemand die net beheerder is gemaakt het menu-item pas ziet na opnieuw inloggen.
Dat is te overzien, want de claim is geen sleutel maar een lampje: `/beheer` en
`/beheer/adverteren` kijken zelf nog in de database of je het écht bent, en doen dat bij elke
handeling opnieuw. Wordt iemands beheerderschap ingetrokken, dan is hij dus meteen geweerd —
alleen blijft het menu-item nog staan tot hij uitlogt.
+148
View File
@@ -0,0 +1,148 @@
# Meldingen op de telefoon
Als er een bericht voor iemand wordt bezorgd, hoort zijn telefoon dat te laten weten - ook als
de app dicht is. Daarvoor is Firebase Cloud Messaging van Google nodig; een andere weg is er op
Android niet.
De applicatie is er klaar voor. Wat er nog moet gebeuren is een Firebase-project aanmaken en
twee bestanden op de juiste plek zetten. Aan de code hoeft niets te veranderen: het csproj gaat
vanzelf aan zodra het eerste bestand er staat.
## Wat er al werkt
| Onderdeel | Waar |
|---|---|
| Toestellen bijhouden | tabel `toestellen`, `MeldingService` |
| Aanmelden vanuit de app | bij elk inloggen en elke hervatte sessie |
| Afmelden bij uitloggen | zodat de volgende gebruiker op die telefoon niet jouw meldingen krijgt |
| Versturen bij bezorging | `MessageService.DeliverAsync` |
| Praten met Google | `FirebasePush`, met een zelfgeschreven ondertekening |
| Opruimen van dode kenmerken | een toestel dat Google niet meer kent wordt meteen afgemeld |
Zonder servicesleutel gebeurt er niets en werkt alles gewoon door - er gaan alleen geen
meldingen uit.
**Wat er bewust níét in een melding staat:** de inhoud. Een melding verschijnt op een scherm dat
ook zichtbaar is als de telefoon op tafel ligt. Er staat alleen dat er iets klaarstaat en van
wie. Daar is een controle voor in de zelftest.
## Stap 1: het Firebase-project
1. Ga naar console.firebase.google.com en maak een project, bijvoorbeeld **MijnBesluit**.
Google Analytics kun je overslaan.
2. Voeg een **Android-app** toe met pakketnaam `nl.mijnbesluit.app` - precies zoals hij in
`MijnBesluit.App.csproj` bij `ApplicationId` staat.
3. Download `google-services.json` en zet hem in `src\MijnBesluit.App\Platforms\Android\`.
Een Play Console-account is hier niet voor nodig; Firebase is gratis en staat los van de
Play Store.
## Stap 2: niets
Dit stond hier vroeger als een handmatige aanpassing van het csproj. Dat is nu niet meer nodig:
in `MijnBesluit.App.csproj` staat een blok dat zichzelf aanzet zodra `google-services.json` er
staat. Het haalt dan het package `Xamarin.Firebase.Messaging` erbij, koppelt het json-bestand,
en zet de schakelaar `FIREBASE` aan die bij de `#if` in `Toestelmeldingen.cs` hoort.
Staat het bestand er niet, dan gebeurt er niets en bouwt het project gewoon. Dat is met opzet:
er is nu geen stap die je een keer kunt overslaan, en het project blijft te bouwen op een machine
waar dat bestand niet staat.
Het versienummer in dat blok (`125.1.1.1`, bij `net10.0-android`) moet passen bij de
werkbelasting op je machine. Klaagt de build over versies, kijk dan in NuGet welke bij jouw
.NET-versie hoort en pas alleen dat nummer aan.
In hetzelfde blok staan vijf AndroidX-packages met een versienummer erbij. Die horen niet bij
Firebase zelf, maar lossen een botsing op: Firebase vraagt om oudere `.Ktx`-packages, terwijl
MAUI de bijbehorende basispackages al nieuwer binnenhaalt. Sinds Fragment 1.9.0 zitten de
Kotlin-uitbreidingen in het hoofdpackage, dus kwam dezelfde klasse twee keer binnen en brak de
build bij het dexen (`Type androidx.fragment.app.FragmentManagerKt is defined multiple times`).
Komen de NU1608-waarschuwingen ooit terug, dan heeft een nieuwere MAUI de basispackages weer
opgehoogd: zoek in NuGet de bijpassende `.Ktx`-versie op en hoog die nummers mee op. Weghalen
lost het niet op, dan komt de dubbele klasse terug.
Na het wijzigen van die versies: gooi `src\MijnBesluit.App\obj` en `bin` weg voordat je opnieuw
bouwt. In `obj` staan de uitgepakte Java-bibliotheken van de vorige build, en die worden niet
altijd opgeruimd.
## Stap 3: de servicesleutel op de server
1. In Firebase: **Projectinstellingen > Serviceaccounts > Nieuwe persoonlijke sleutel genereren**.
Er wordt een json-bestand gedownload.
2. Zet dat op de server als `C:\MijnBesluitData\keys\firebase.json`.
3. Zet in `C:\MijnBesluitData\appsettings.secrets.json`:
```json
"Push": {
"ServiceAccountFile": "C:\\MijnBesluitData\\keys\\firebase.json"
}
```
4. `Restart-Service MijnBesluitSocket`.
**Dat bestand is een sleutel tot je Firebase-project.** Bewaar het net zo zorgvuldig als
`masterkey.json` en zet het nooit in de publicatiemap - die wordt bij elke publicatie
overschreven, en erger: dan staat hij in het zip-bestand dat je rondstuurt.
In het logboek van de service staat bij het opstarten of het gelukt is:
```
Meldingen staan aan voor Firebase-project mijnbesluit-...
```
## Stap 4: controleren
1. App opnieuw installeren en inloggen. Android vraagt nu toestemming voor meldingen - die
vraag komt van `Permissions.PostNotifications`, verplicht sinds Android 13.
2. **Ga naar Mijn profiel en druk op "Stuur me een proefmelding".** Dat kan ook op de website,
bij Mijn profiel onder *Meldingen op je telefoon*.
Die knop loopt de hele keten langs en zegt waar het op vastloopt:
| Wat er staat | Wat eraan mankeert |
|---|---|
| "Op de server staat geen servicesleutel voor Firebase" | stap 3 is niet gedaan, of het pad klopt niet |
| "Er is geen toestel aangemeld" | de app heeft geen kenmerk; zie *Als er niets komt* hieronder |
| "Verstuurd naar je toestel" en je ziet niets | meldingen staan op de telefoon zelf uit |
| "Google nam de melding niet aan" | het kenmerk is verlopen; opnieuw inloggen in de app |
| Je ziet de melding | klaar |
Hij gaat alleen naar je eigen toestellen. Iemand anders een melding kunnen sturen zou een manier
zijn om mensen lastig te vallen; daar is een controle voor in de zelftest.
Wil je het echte pad ook een keer zien: zet vanaf de website een bericht voor jezelf klaar met
bezorging **meteen**. De achtergrondtaak van de SocketServer bezorgt het binnen een kwartier, en
dan hoort de melding vanzelf te verschijnen.
## Als er niets komt
* **Geen regel in `toestellen`** - de app krijgt geen kenmerk. Meestal ontbreekt
`google-services.json` in de APK die op het toestel staat (is hij ná het bouwen toegevoegd,
bouw dan opnieuw), of heeft de gebruiker de toestemmingsvraag weggeklikt.
```sql
SELECT id, user_id, platform, naam, laatst_gezien FROM toestellen;
```
* **Wel een regel, geen melding** - kijk in het logboek van de service. Staat er "Google gaf
geen toegangstoken", dan klopt de servicesleutel niet. Staat er "Melding niet afgeleverd",
dan zegt Google waarom.
* **"Verstuurd naar je toestel", maar niets te zien terwijl de app openstaat** - Android zet een
melding alleen zelf in de balk als de app op de achtergrond staat. Staat de app open, dan geeft
Android hem door aan `Meldingsdienst` in `Platforms\Android`, en die toont hem. Ontbreekt die
klasse, of is hij niet als dienst aangemeld, dan verdwijnt de melding geruisloos. Daarom gaan
titel en tekst twee keer mee in het bericht: in het `notification`-deel voor Android zelf, en
in het `data`-deel voor de app. Zie `FirebasePush.StuurAsync`.
* **Werkte eerst, nu niet meer** - het kenmerk van Firebase verandert af en toe vanzelf,
bijvoorbeeld na het terugzetten van een back-up. De app meldt zich daarom bij elke start
opnieuw aan; werkt dat niet, dan komt de app niet langs het inloggen.
## Wat hier nog niet in zit
* **iOS.** Daar loopt het via APNs, met een sleutel uit het Apple Developer Program (€99 per
jaar). Firebase kan dat doorgeven, dus de serverkant blijft hetzelfde.
* **Meldingen bij andere gebeurtenissen.** Nu alleen bij een bezorgd bericht. Een overlijdens-
melding of een uitnodiging zou net zo goed een melding kunnen zijn; de plek om dat aan te
roepen is `MeldingService.StuurAsync`.
* **Instellen per gebruiker.** Wie geen meldingen wil, kan ze nu alleen op het toestel zelf
uitzetten.
+201
View File
@@ -0,0 +1,201 @@
# De SocketServer installeren als Windows-service
`MijnBesluit.SocketServer.exe` is twee dingen tegelijk, net als je in WinDev gewend bent:
* start **Windows** hem, dan draait de server;
* start **jij** hem, dan verschijnt een beheerschermpje.
Eén exe om uit te leveren dus, en geen los installatieprogramma.
## Bouwen
```powershell
D:\MijnBesluit\tools\SocketServer-publiceren.ps1
```
Dat levert `D:\publish\mijnbesluit-socketserver.zip`. Of met de hand:
```
dotnet publish src\MijnBesluit.SocketServer -c Release -r win-x64 --self-contained false -o D:\MijnBesluit\publish
```
Het project staat vast op `x64`; er komt dus geen 32-bits versie uit, ook niet op een machine
waar dat de standaard zou zijn. Wil je dat er op de doelmachine helemaal geen .NET geïnstalleerd
hoeft te zijn, gebruik dan `--self-contained true`; de map wordt dan een stuk groter maar draait
op zichzelf.
## Op mijnbesluit.nl, naast de website
De SocketServer draait náást IIS op dezelfde machine en praat met dezelfde database. Drie dingen
moeten daarbij kloppen, anders krijgt de app straks een lege of onleesbare kluis te zien.
**1. Dezelfde geheimen.** `appsettings.json` wijst met `ExtraConfigFile` naar
`C:\MijnBesluitData\appsettings.secrets.json` — precies het bestand dat de website al gebruikt.
Eén plek dus voor de databasegegevens en de SMTP-instellingen, en ze overleven een nieuwe
publicatie. Ontbreekt dat bestand, dan stopt de service meteen met een duidelijke melding in
plaats van een onbegrijpelijke "Access denied" van de database.
**2. Hetzelfde sleutelbestand.** `Security:MasterKeyFile` staat op
`C:\MijnBesluitData\keys\masterkey.json`, hetzelfde bestand als de website gebruikt. Met een
ánder sleutelbestand is geen enkel kluisitem te openen dat via de website is opgeslagen. Ook
`Storage:Path` moet dezelfde map zijn.
Het serviceaccount **LocalSystem** moet dat sleutelbestand kunnen lezen.
**3. Een certificaat.** Zonder certificaat luistert de server onversleuteld, en daar gaan
wachtwoorden en pincodes overheen. Het certificaat van mijnbesluit.nl uit IIS is te hergebruiken:
De snelste weg is IIS vragen welk certificaat hij voor de site gebruikt:
```powershell
Import-Module WebAdministration
Get-ChildItem IIS:\SslBindings | Select-Object IPAddress, Port, Host, Thumbprint, Store
```
Staat er geen regel met `mijnbesluit.nl`, dan gebruikt die site de regel met IP-adres
`0.0.0.0` — dat is het certificaat voor alle sites zonder eigen regel.
Om te zien welke namen zo'n certificaat dekt:
```powershell
Get-ChildItem Cert:\LocalMachine\WebHosting |
Select-Object Subject, @{n='Namen';e={$_.DnsNameList -join ', '}}, Thumbprint, NotAfter, HasPrivateKey
```
Zet de vingerafdruk in `Socket:CertificateThumbprint`. De server kijkt in de opslagen **My** én
**WebHosting**; die laatste is waar hostingpanelen en Let's Encrypt-tools hun certificaten
neerzetten. Spaties maken niet uit. Let op de
vervaldatum: wordt het certificaat vernieuwd, dan verandert de vingerafdruk en moet deze
instelling mee — een herstart van de service is dan nodig.
**Poort 5200 open.** In de firewall van de server:
```powershell
New-NetFirewallRule -DisplayName "MijnBesluit SocketServer" -Direction Inbound `
-Protocol TCP -LocalPort 5200 -Action Allow
```
En bij je hoster, als daar nog een firewall voor staat. Controleer het daarna vanaf je eigen pc
met `Test-NetConnection mijnbesluit.nl -Port 5200`.
## Installeren
1. Klik met de **rechtermuisknop** op `MijnBesluit.SocketServer.exe` en kies
**Als administrator uitvoeren**.
2. Het schermpje verschijnt met de status *Niet geïnstalleerd*.
3. Vul het poortnummer in (standaard 5200). Onder het veld zie je meteen of die poort vrij is.
4. Klik op **Install**.
Wat er dan gebeurt:
* het poortnummer wordt in `appsettings.json` naast de exe gezet — dat bestand leest de service
straks zelf;
* de service wordt aangemaakt onder de naam `MijnBesluitSocket`, met weergavenaam
*MijnBesluit SocketServer*, opstarttype **Automatisch** en account **LocalSystem**;
* Windows krijgt de opdracht de service zelf opnieuw te starten als hij onverwacht stopt: na
10 seconden, dan na 30, en daarna elke 2 minuten;
* de service wordt meteen gestart.
## Verwijderen
Rechtermuisknop → **Als administrator uitvoeren**. Het schermpje laat nu *Draait* of
*Geïnstalleerd, gestopt* zien, en de knop heet **UnInstall**. Er wordt eerst gevraagd of je het
zeker weet; daarna wordt de service gestopt en verwijderd.
Zolang de service bestaat kun je de poort niet wijzigen — dat staat er ook bij. Wil je een andere
poort: verwijderen, poort aanpassen, opnieuw installeren.
## Zonder beheerdersrechten
Start je de exe gewoon met een dubbelklik, dan verschijnt hetzelfde schermpje, maar zijn de
knoppen uitgeschakeld en staat er een knop **Opnieuw starten als administrator**. Die start
dezelfde exe opnieuw met de UAC-vraag. Klik je die weg, dan gebeurt er niets.
Het programma vraagt dus niet uit zichzelf om rechten bij het starten: alleen als je iets wilt
doen waarvoor ze nodig zijn.
## Waar de service zijn spullen zoekt
Windows start een service met `C:\Windows\System32` als werkmap. Alle relatieve paden in
`appsettings.json` worden daarom omgezet naar paden **naast de exe**:
| Instelling | Standaard | Wordt |
|---|---|---|
| `Security:MasterKeyFile` | `keys/masterkey.json` | `<publish-map>\keys\masterkey.json` |
| `Storage:Path` | `kluisbestanden` | `<publish-map>\kluisbestanden` |
| `Smtp:DropFolder` | `mail-uitgaand` | `<publish-map>\mail-uitgaand` |
Zet je er een volledig pad neer (`D:\MijnBesluit\keys\masterkey.json`), dan wordt dat gebruikt
zoals het er staat. Voor het sleutelbestand is dat vaak handiger: dan overleeft het een nieuwe
publish.
Het serviceaccount **LocalSystem** moet het sleutelbestand kunnen lezen en in de map met
kluisbestanden kunnen schrijven.
## Hoe de server met verbindingen omgaat
* Eén thread accepteert verbindingen en doet verder niets.
* Elke verbinding krijgt zijn **eigen thread**, met een blokkerende leeslus. Die thread heet
`MijnBesluit-verbinding-<nummer>`, dus in een dump of in de debugger zie je meteen welke
verbinding waar hangt.
* Elk binnengekomen verzoek krijgt een **eigen thread**. De leeslus gaat meteen door met lezen,
dus een trage databaseactie houdt de volgende verzoeken van diezelfde app niet op. Per
verbinding lopen er maximaal `Socket:MaxVerzoekenPerVerbinding` tegelijk (standaard 8) en over
de hele server `Socket:MaxVerzoekenTotaal` (standaard 200); daarboven wordt er van die
verbinding even niet gelezen, wat vanzelf tegendruk geeft.
Bewust een echte thread en niet de threadpool: de afhandeling blokkeert op de database en op
bestanden, en dat zou de pool uithongeren — die groeit maar langzaam, waardoor verzoeken
alsnog achter elkaar zouden gaan lopen in plaats van naast elkaar. Een verzoekthread krijgt
512 kB stapelruimte (`Socket` hoeft daar niets voor te weten; zie `RequestThreadStackBytes`).
* Het wegschrijven van antwoorden is geserialiseerd: er kunnen nooit twee antwoorden door elkaar
over de lijn gaan. Antwoorden kunnen wél in een andere volgorde terugkomen dan de verzoeken
binnenkwamen — de app koppelt ze op `requestId`.
* Bij afsluiten wacht de server tot de lopende verzoeken van een verbinding klaar zijn, zodat er
niet naar een al opgeruimde verbinding wordt geschreven.
Omdat elk verzoek op zijn eigen thread loopt, krijgt het ook zijn eigen databasecontext: een
EF Core-context is niet bedoeld om door meerdere threads tegelijk gebruikt te worden.
## Instellingen
```json
"Socket": {
"Address": "0.0.0.0",
"Port": 5200,
"MaxVerbindingen": 500,
"MaxVerzoekenPerVerbinding": 8,
"MaxVerzoekenTotaal": 200,
"CertificateFile": "D:\\MijnBesluit\\certificaat.pfx",
"CertificatePassword": "...",
"CertificateThumbprint": ""
}
```
`CertificateThumbprint` pakt een certificaat uit de machine-certificaatopslag van Windows —
handig als je er al een hebt dat automatisch vernieuwd wordt. Spaties in de vingerafdruk maken
niet uit; die worden eruit gehaald.
## Als er iets misgaat
* **Je wilt meekijken terwijl hij draait.** Stop de service en start dezelfde exe zelf met
`--service`:
```
net stop MijnBesluitSocket
cd C:\MijnBesluitSocket
MijnBesluit.SocketServer.exe --service
```
Er verschijnt dan een venster met het logboek erin; daar komt ook langs waarom een verzoek
misging (`Fout bij '<actie>': …`). Afsluiten met Ctrl+C, en daarna `net start MijnBesluitSocket`
niet vergeten. Liever een bestand? Zet er `> log.txt 2>&1` achter; dan blijft het venster weg
en vult dat bestand zich terwijl hij draait.
* **De service start niet.** Kijk in *Logboeken → Windows-logboeken → Toepassing*. De meest
voorkomende oorzaak is dat de poort al bezet is; dat staat er dan letterlijk.
* **"De service is verwijderd maar Windows is er nog niet mee klaar."** Er staat nog een venster
*Services* open met de service geselecteerd. Sluit dat en probeer het over een halve minuut
opnieuw.
* **Het schermpje zegt dat de poort bezet is.** Met `netstat -ano | findstr :5200` zie je welk
proces erop luistert.
+176
View File
@@ -0,0 +1,176 @@
# Het socketprotocol van MijnBesluit
De Android- en iOS-app praten met `MijnBesluit.SocketServer` over een eigen TCP-verbinding.
Geen IIS, geen webservice.
## De vorm van een bericht
```
[4 bytes lengte, big-endian][UTF-8 JSON]
```
De lengte staat er dus vóór. De ontvanger weet daardoor exact wanneer een bericht af is.
> **Waarom niet de `**BYE**`-markering uit de RenH-socketserver?**
> Omdat TCP geen berichtgrenzen kent. Wie in de datastroom naar een markering zoekt, loopt vast
> zodra die tekencombinatie toevallig in de inhoud voorkomt — precies het probleem dat in
> `RHSocketClient` op 10-09-2026 is gerepareerd, toen een leesgrens midden in een groot antwoord
> op een fragment "BYE" viel. Met een lengte vooraan kan dat niet gebeuren, en het scheelt ook
> het base64-coderen van alles wat er over de lijn gaat. `SelfCheck` bevat een controle die
> precies dit randgeval nabootst: een bericht met `**BYE**` en `BYE` in de inhoud, 400 kB groot,
> dat ongeschonden aankomt.
## Eén verbinding, meerdere verzoeken
`RHSocketClient` zet per aanroep een nieuwe verbinding op. Hier blijft één verbinding open:
- verzoeken mogen elkaar overlappen; antwoorden worden teruggekoppeld op `requestId`;
- de server kan uit zichzelf een bericht sturen (een bericht dat binnenkomt, een
overlijdensmelding over jouw profiel) — de app herkent dat aan het ontbreken van een
bijbehorend openstaand verzoek;
- er is niet bij elk verzoek opnieuw een TCP- en TLS-handdruk nodig.
## Verzoek
```json
{
"requestId": "8f2c...",
"action": "vault.list",
"token": "<sessietoken>",
"version": 1,
"device": "Pixel 8",
"payload": { "category": 3 }
}
```
## Antwoord
```json
{
"requestId": "8f2c...",
"status": "Ok",
"message": null,
"payload": [ { "id": 12, "title": "Bank", "category": 3 } ]
}
```
`status` is `"Ok"` of `"Error"` — dezelfde opzet als je RenH-socketserver, zodat het vertrouwd
aanvoelt. Bij `"Error"` staat in `message` een tekst die je zo aan de gebruiker kunt tonen.
## Acties
| Actie | Sessie nodig | Wat het doet |
|---|---|---|
| `ping` | nee | Controle of de server leeft; meldt ook of de verbinding versleuteld is |
| `auth.request-code` | nee | Stuurt een inlogcode naar een bestaand account |
| `auth.signin` | nee | Inloggen met e-mailadres en code; geeft een sessietoken |
| `auth.request-register-code` | nee | Stuurt een code om een account aan te maken; naam gaat mee |
| `auth.register` | nee | Maakt het account aan met de ontvangen code |
| `auth.accept-invitation` | nee | Uitnodiging aannemen en het profiel claimen (geen code nodig) |
| `auth.signout` | ja | Sessie beëindigen |
| `family.members` | ja | Familieleden met hun eigendomsstatus |
| `family.add-member` | ja | Iemand toevoegen |
| `family.invite` | ja | Uitnodiging per e-mail versturen |
| `vault.list` | ja | Titels en categorieën (geen inhoud) |
| `vault.open` | ja | Eén item ontsleutelen |
| `vault.save` | ja | Item versleuteld opslaan |
| `message.inbox` / `message.outbox` | ja | Berichten voor en door jou |
| `message.read` | ja | Bericht lezen en als gelezen markeren |
| `message.compose` | ja | Bericht klaarzetten |
| `legacy.declare-death` | ja | Overlijden melden |
| `legacy.object` | ja | Een melding over jezelf tegenspreken |
| `legacy.status` | ja | Stand van de procedure |
| `home.advertentie` | ja | De advertentie voor het overzicht, of leeg. Het opvragen telt de vertoning mee |
| `legacy.appoint-manager` | ja | Nalatenschapsbeheerder aanwijzen |
| `legacy.remove-manager` | ja | Een beheerder weer weghalen |
Een actie die een sessie vereist en die zonder geldig token binnenkomt, krijgt altijd:
*Je sessie is verlopen. Log opnieuw in.*, met `"code": "sessie-verlopen"` erbij.
Dat `code`-veld is er voor de gevallen waarin de app iets anders moet doen dan de melding tonen.
Bij `sessie-verlopen` gooit de client het bewaarde token weg en stuurt de schil je naar het
inlogscherm. Op de tekst van de melding sturen zou ook werken, tot iemand er een komma in zet.
De app krijgt `sessie-verlopen` alleen als de server de sessie echt niet meer kent. Een mislukt
verzoek is iets anders: geen bereik, een server die net opstart. Dan blijft het token staan en
probeert de app het de volgende keer gewoon opnieuw. Dat onderscheid is het verschil tussen een
hik en een e-mail met een code.
## Gebruik vanuit de app
`MijnBesluit.Protocol` heeft geen enkel extern package nodig en kan daardoor rechtstreeks in het
MAUI-project worden opgenomen.
```csharp
var verbinding = new MijnBesluitSocketClient("socket.mijnbesluit.nl", 5200, useTls: true);
verbinding.PushReceived += melding =>
Console.WriteLine($"Van de server: {melding.Message}");
// Stap 1: code aanvragen. Die komt per e-mail binnen.
await verbinding.SendAsync(SocketActions.RequestLoginCode, new { email = "jan@example.nl" });
// Stap 2: inloggen met de code die de gebruiker heeft ingetypt.
var inloggen = await verbinding.SendAsync(SocketActions.SignIn,
new { email = "jan@example.nl", code = ingevuldeCode });
if (inloggen.Status == "Ok")
{
verbinding.Token = /* token uit inloggen.Payload */;
var kluis = await verbinding.SendAsync(SocketActions.VaultList);
}
```
Tijdens ontwikkelen mag `AcceptAnyCertificate = true` om met een zelfondertekend certificaat te
werken. Zet dat in een uitgeleverde app nooit aan: dan is TLS zijn waarde kwijt.
## Threads
De server accepteert verbindingen op één thread, geeft elke verbinding een eigen thread met een
blokkerende leeslus, en handelt elk binnengekomen verzoek af op zijn eigen thread. Antwoorden
kunnen daardoor in een andere volgorde terugkomen dan de verzoeken binnenkwamen; daar is
`requestId` voor. Het wegschrijven is geserialiseerd, dus twee antwoorden raken nooit door elkaar.
Zie `docs/SocketServer-installeren.md` voor de instellingen en de grenzen.
## Sessies
Een sessie leeft veertien dagen en schuift op bij elk verzoek. Ze staan in het geheugen én in de
tabel `sessies` (`db/16_sessies.sql`).
Het geheugen is er voor de snelheid: een gewoon verzoek raakt geen database. De tabel is er om
een herstart te overleven. Dat laatste is geen luxe: de app blijft aangemeld en gaat daarna open
met een vingerafdruk of de code van de telefoon. Stonden de sessies alleen in het geheugen, dan
was elke uitrol, elke Windows-update 's nachts en elke reboot genoeg om iedereen weer een code
per e-mail te laten opvragen - het toestel wist dan nog wie je was, de server niet meer. De
database wordt alleen geraadpleegd bij een token dat niet in het geheugen staat, dus in de
praktijk bij het eerste verzoek van elk toestel na een herstart.
In de tabel staat de SHA-256 van het token, nooit het token zelf: uit een hash valt geen token
terug te rekenen. Dezelfde afspraak als bij de inlogcodes.
De vervaltijd schuift op bij gebruik, maar wordt niet bij elk verzoek weggeschreven - zie
`SessionStore.SchuifopDrempel`. Bij een termijn van veertien dagen maakt een uur speling niets
uit, en het scheelt een schrijfactie per aanroep.
Gaat de database niet, dan komt die fout naar buiten als een gewone fout en niet als
"je sessie is verlopen". Ook dat is het verschil tussen een hik en een e-mail met een code.
De tabel `refresh_tokens` uit het oorspronkelijke schema wordt nergens gebruikt; `sessies` is
niet hetzelfde (die heeft de persoon erbij, en er is hier geen apart toegangstoken).
## Poort en certificaat
Standaard poort 5200. In te stellen in `appsettings.json`:
```json
"Socket": {
"Address": "0.0.0.0",
"Port": 5200,
"CertificateFile": "D:\\MijnBesluit\\certificaat.pfx",
"CertificatePassword": "..."
}
```
Of met de vingerafdruk van een certificaat uit de Windows-certificaatopslag
(`CertificateThumbprint`), bijvoorbeeld een certificaat dat je al met Let's Encrypt vernieuwt.
@@ -0,0 +1,118 @@
%PDF-1.4
%“Œ‹ž ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R /F2 3 0 R /F3 4 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
>>
endobj
5 0 obj
<<
/Contents 11 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 10 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
6 0 obj
<<
/Contents 12 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 10 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
7 0 obj
<<
/Contents 13 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 10 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
8 0 obj
<<
/PageMode /UseNone /Pages 10 0 R /Type /Catalog
>>
endobj
9 0 obj
<<
/Author (\(anonymous\)) /CreationDate (D:20260918054241+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260918054241+00'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (\(unspecified\)) /Title (Stappenplan: Google Play en Apple App Store \204 MijnBesluit) /Trapped /False
>>
endobj
10 0 obj
<<
/Count 3 /Kids [ 5 0 R 6 0 R 7 0 R ] /Type /Pages
>>
endobj
11 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1726
>>
stream
Gatm;=``:f&:XAWkXUdC'9-nkc)tlgQ#!^A2Q9==18'I@F:>2P.fT,<NJ'QW!?9c:+O?bGSj'mfRZiF9Dh$q1dFEnV^Z37LRf"H!17'>[k`<j)Ii!LEe$k_efb(-t$Q+s&#k)2i3UK%?:1uYM:!<r]a)e1Q/oW-ZLmd<8@?]N)0Ya69HQi"I+>TuPNP5Zq;CnfIFXa@-1g/8f-&lj3Jl7*YF%A$P%huW%SK$h+Q.]K-)]b<(h,_%83e?`(*3.KOZW\,GZH=(ce("'nHEVIS8c"m4?R-#j>cf]s03oN.HX0DqBA3OM3,S0`E5BE4jW(#4+ESWaVo,(@Tmtp+'^Z2e\UMK60FBPUkk`s%^@*&R]Ap2i]tUtf^;ZVn!8J*53uC*9aKjRt?V(Dke>69iH&_\RjD9RkeFC'E()3enTg\#UZU1#g29f[+MDo(C>([Z9pTd8,a8_17N3C8jPNT7,Y4$KNd9>keqk]f9l=qWg9']q&";\=kQ\W)mL.9W(p0FqAD#/b2>>\<+%5otqi4(6+MuTfO6H:S?cFqUERIs;h=g`D1eAeoJV%?4FJ[hZ`=%oGAc/![dXB%6>c3-P(rEK0W^pU+Ub4H%A[4F7ORF+p\>*^ngfZ5hMg&3W,+$:aRJj-N"KDWk2%HDRl\h)i=X*FL#W"O[#6JprY3O\Z9,trjCO\=?q+bi":]a2oYT.fY]bOGdT\aIAliipf*ZFFC/6<4pei[*2>oTI`C\8tgin1DT4G5rD%60<+E_0g]U[(CC$$X[1'7u3]YZMZ;-nm0,f?F]$6Vq9FASHj\"r=I>cS`1u=OBRJlmNfDAE:hE9V9'oe-DF>%W0J?\q-5i^TL:AeONZjCOp2ou$7D_UHPT5>1VG]>A<JnV:odmH!']2ZlmH-'PX']<2ukD,FkS!*#65c4!8UE2-QIcOGbS^7ZQ6rYK9\!6KOb"mhnmB0\O_cAla&lIT\aT>L'uN%Pi[-t4k4Suq3H*(B+&EFZ_iOU4(c(W*3mfTGL!p_eQQV9FF(Jia/qMXWWF/FMV3QDf1'X16s?)4h?o.P88h'2D,g4d<'`$5F"1@gcG_lR1!4T<JVALMfLL/5FMTODdLPAa<]2GpT-m!r_ZN\VP^Bj6,6,1tr1j2?)6?tP>/\IupeP%2EM:Nc;p/u(X'-2lD*U8#AQj>/*BX/K=@<J!PcSPn;9B9pE)BaWV@fH+-4&WFNTm!N9[A/?@t%KJ.PojBQW_N'<3^>Hm$LYdPNRj%/_AYOMQ1q.!a%$r?+luCW+D\r-1RlcC,)n*R7MjOFV8Sa>R(/5XVX0fn_WLcD_`"SPMH_iWL2Y].T>9F$)ZL)XSH.6WMJBE"pkFD$om9:^`BhV[L3OtfESF699ddC0*&g!=nMm2b>Xr9o?`Kh]F57)k:k3cbqVT7EqJF.Y[iL#,quFZ&r->tB[gDnUOE71BX!"ahqB(2`=<m8;pp;geOG<Ri2i'OTh?qW4Y&9hN\%:*1sP31a^Yb)UhNpj'ItQ2M[.-)5$4Qb\dZdRN^\,.Q3!ScA"&7"3c7tM`,?l1:fZAdR8*uMl#hbfW&$F\BAMVcJ\ohe&Dh9>f\q)Pd6_#@gj:_3h%Z^nX[/t-dfgTmRhbY<^.mqX'k1)Vqg+OmI*9TS7B1:;k&\)5EL7m^VD_jiTd5LFFVrk)lEj^\E#JetkQhd^-Xut/KhZ@gI;o(m!ncDNY;MiZDG=@4I=iPaO6IjT0)c*pfgmL~>endstream
endobj
12 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2270
>>
stream
Gatm<gN)%.&q0LUoYXXf1*n[772I;Y&';a]4<#Z>OcjNTq#qrc)E(0+#YI_eom7UQ-tsqe^;MKo"EA4IDO`n@97;1mq]069YG_$jX\5fedlFsi'@?@RSPDu[3H:OS6dHX)?KfgN0QPrf(nr+P*T-jYj,a(dk`O=9HiCgY1-*1<DEH+!VAZ9#a;saaT#VLd?55mY$/[S=F=HI[UHVC)T08t62,/R1"9-7%O:hG!bD;^Wr4oS$nO+Ja<V7B)-["5'Gj(-qIY4ln_,T^4OX+>RX\@oR%)nC%@_>CF5d0age10l<ASIBV6.Wj7DuFkCcFLO3?V?S8<97LNT2j?N1OH%sK5SH%9'4ZQ5E\$4a_D8Wa6;_V<^iu<*U/f+0a&dXQ84F?qG\"KUpq+*<L\-DT:UNTQ'9-g?iU"'cFt^;EXiqNb6lSoT$lbO-)M<L![t0&b*kdRk#t5SQSK`,VA6i:(r_lH34e_^\.JJ<X=DkaU!s#%^#I#AXNn3K:K9%.rqt'h^_la8nSm[rjS/QGMp@h[,oi+-E!EI,G,Z8\KFj[Rp;:3h@&qm%]O*<F*,P9dh/b3pF!!-'47jT!0!p(\KgBZkWkS.!9`h^1/[9PIZiW3WUY8jaL!RMj,A;qO[6rSP*J+Jb@=O+0BhTOk*>cEFMS1_6f9Z7uBV.?@E0iVuHVm<%F73UWd`WG0-;0BhALS81pepF@alBf#`S$/\]6jNXP-1$#B6oF.G)PjMD8m+n<AL]AEFr*W.)RTMNc,#m#u<\r:%.%SpAK=Ln\*;N1Vt*APc688k=)A6;Ij(V`4AW1Y)K=/4ul_1IgJL%?J"*ULHb!*,qgh4aloafLe[da1b`+b*#+%(Frf5;g\3Is;@s#uQKrWYf92q=ho7^@.do,MF7YYJDe'=_XlR\Sb_3DeFPZ:9IsK[Z:\:"R@"3IYTu7g2-MgNCqK!5Lg-heNM#8pS^d49tk]0)g(IFBdBl8T5^p2.RU-O+pk;nO-%=qjqc1GJ1aAU156s$*bk;8<u$onmE7.b3Qckg7/,)(VN`(KXuY:>V+aq1lO%$).)ff0QsD9>2>A$7$_=E[@bmk:<<2cEIWY`IBMl)[]1/BG@k_nTb/iFeS54Bo:08'Wq\X]NgT&5ZX.".0%/\riB1`Z$fudN\6>U)Uk;]PSA],r:Ojq4JCs<:mm\B);J(`_>m[QuPXpT=)^H_m'ZJ7CtVn,LV,2W)a1Z6fT2r2C_q,!_iB@iN1R+j(ai,4D"W6XD#C/e_4_#\4JQR8E[+2,b;Vo51PST$4e)pfib;=G<$US.H:a:AgNPgDA>[Nei.,,.!Wd"k=TUnP$bEB>i"L&2fPDS]"u9ik7)oAZMU_*Gg:P0*;7`^T'@S8'HY0/)kq]#;>0)\Pcs?>\VN=+::^hXnB&g]pg>c\LP3R?W:M!\*B:9$:kE"ZHo+[`$pf1J>UEZmf/'C2TV7)/@UZF$l7,2.KZpB9du-U;Ah$g2pH"e[AP6T2`VTak3EWCT?#AMlk*;rERGeE\S0Ct1Ep.GbGnbmR'.N*F`V\-<:YFHJbNcZ&G2s#f>]FfGSRJ0ub!kk6<jpI"TgI/V3Ijn1be]g(6$asel(,K]A8*iGYe.q?CZh5HkQ<Y_`WU#d$l-r"!$53r5\LID8Aue9,81I([6O#G)*[-QeP'84r!&3N9L`&A<ejNL"96t#c8Z57kW@hP3:L5OqQ<'Rl*#:Yc@As_!@OXMi&cPkEfu>OYO*cNX,;gP'ojcu<@G#NpcPi^`Po7m[VT$=\?60^X9`Pcr]G?Ki.[X>J\qqO_sNZH0i.2JH3d@$Y]B*\"YA`8Mm(J6[1h0n9OMW&iS"N$^;Ou7Dc.'L"NTSZNNj/.*fLptJa9QO>^P`SN8%EmcAq;N@kL)#>o^nRR,#W&k>`41KHX!gQ+E!X-;s/(I,La7Sn699_t'hm2HKs-4DK&LNUgSCJJTq2[1q$_@m^@ek%;N`>X-gd7"MWW(j[DV0uFS)'UUT9Z%q1MSYYgfXZc((0^g>',r_dI;_8sVge'E#5l-$F96%#U@lG'F/aDt4!CJee*a2]Zs%;?g3oTBM,L&`LT72u,;4ZiHq7U15h]DqEG3]tO*;RUS:)7t5DA$j"fUQ`Ee8m&jO"4^cVA;'_^91&jVC5U?6EFo+fCBA?imF0N)YruR3k^o2rFb4Yh,HVYA"jST67Z&AeJm`Q+u%'OmBAbHdjD)`PmU8]C#%u-[SO#*#\V`Bo;(PM;Tp9B?FFY,\pnL4D[q7Y$KcS:g?ds0Zpd6%npftTN[4~>endstream
endobj
13 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 969
>>
stream
Gas1]bEC=.&A70Vjqt$bCKZ+,K7]qm[Yi+o>;!3EnLCsn,:hH!Y@hAN-OU<%-'"dhmN-(G*!1H]oNUtgD[O"bjhO53p:qmg6#d.uMUhG95=_RObE@NKOJIKu4NT%5Q<BeJJnA.,`Bg'rbOU0a_)<h2o7@+>=Kjat!k*Pg&b[kZ_1YEH^PRT"3;e]#(:#_p*-%Ps^dK1)&I6=0s1m7\`'cC4M_bi0Y_\&hoH8D$dNfUO<!W/hN\@6,3D6(0J]FAUju+JC-G5FiES1QI`?QRsACmN07k$#0BP7i5TJUc1%.;XR-k>FGB"U"fepR2nOE-;'V8X3mNR2'IO/a4k_+mu@:?`)HS1cMj+8"EoTi7``$QFtiYhlL#AVKX4m05&nUQle^<FsG3(qkL6EMc]HVi5sfCe7/FV$$DT*_:r("rNo*K%pAsOSc:E.Yj8_du[*^0m]"r3P$9Dg8j"_@1#5]&uF*.(A&4:T(Gp(<5Jk)is=0;<\;hDJJki3j!,_u<r-chDM2_AKMih`28*4MC(g8k%VT'9LE6#A"t-G]%A$BPg<>Xn+R,Z#>4h#4(h4T)@1Z6^mbPO2l\9S1rWfWbq^p>UfAoTOVH1sY\QbhDk]husqs=],WO/jM"!.KWID,69/YXQNWHLN)"Kkeh>CgO01GfCe*HWQpPYmF,ndnD=]epfk-2MI'Y`k?Dh[Jjq+;s,G4l-Shl$N2t"]u\\j'oRu@QHaGSZes"^KLo79F"XM=!>liI9At_5"\/-jC(f5P0:i6NrXWOF)]Rn$_nYnWq&)=lE:hH$SKl6YKrZ$S7NSo0#=:?3VY;AJq)d%55SU/53J#'-BBQ9a=_Cn-;ho5PJ=@lCXfO?<h*(=[Ah.bo$*Io_tn[6j1G_@IWW*L-.EUlSMXI%qEq)#p_1dfduCuql_3/.5)9XmIg46r@-mAR4t)OsgVSeNBBc8e`\\^Qk>)$OZYs7E$PnngO8~>endstream
endobj
xref
0 14
0000000000 65535 f
0000000061 00000 n
0000000112 00000 n
0000000219 00000 n
0000000331 00000 n
0000000436 00000 n
0000000641 00000 n
0000000846 00000 n
0000001051 00000 n
0000001120 00000 n
0000001447 00000 n
0000001519 00000 n
0000003337 00000 n
0000005699 00000 n
trailer
<<
/ID
[<8caba89788e8ba064a7d9a534fafdf0e><8caba89788e8ba064a7d9a534fafdf0e>]
% ReportLab generated PDF document -- digest (opensource)
/Info 9 0 R
/Root 8 0 R
/Size 14
>>
startxref
6759
%%EOF
+105
View File
@@ -0,0 +1,105 @@
%PDF-1.4
%“Œ‹ž ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R /F2 3 0 R /F3 4 0 R /F4 5 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/BaseFont /Symbol /Name /F3 /Subtype /Type1 /Type /Font
>>
endobj
5 0 obj
<<
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F4 /Subtype /Type1 /Type /Font
>>
endobj
6 0 obj
<<
/Contents 11 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 10 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
7 0 obj
<<
/Contents 12 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 10 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
8 0 obj
<<
/PageMode /UseNone /Pages 10 0 R /Type /Catalog
>>
endobj
9 0 obj
<<
/Author (\(anonymous\)) /CreationDate (D:20260918051107+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260918051107+00'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (\(unspecified\)) /Title (Testscenario: overlijden melden \204 MijnBesluit) /Trapped /False
>>
endobj
10 0 obj
<<
/Count 2 /Kids [ 6 0 R 7 0 R ] /Type /Pages
>>
endobj
11 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2101
>>
stream
Gb!;d?$"^h'Rf.GgddkXZ;t=goV#dHD'tl"P;Gu)_TRNH=<o.A#rAF,s5%fu+:08S[c<cLN%kNNpO8Z[mTB\GGR3nb[O/R,e)bM"KL=c[$.&0Ur/Q4Hp\sT;_ju?g"X@Y*B]osY0a^BPLWc!!I`QOsgDr:bfqII&0'/!4p(T)7j#'Lij;8R.pDjEg'tPRtq.`=rYq!n*VG`iJlj[O8.So[d;CS_JJsQi`YA!ik?Crj&Q_3d2o$e=):J]aXm#EbpR(*;]AU1f?%\$OD(jJE!=K)7+YL72d,[4R&`!M/tmZaFY*m<4fgSSOZI9`^FWRb"5P@H\jdH(U\FI\YXJp)D<dBg]P-NlO;LS<l<ics:Q97Qrt9?dr^nq)u=L*KG[6D*`$4p[]l'Iu9]96a19qHmc).n+.fkliZh[/>5lJ`kinJWVfH7I]UefTp<q+G):dL94NqA36Xq@nukebn@#-d@Ko">*2Ed.<Eod);e*nLL!UXJY]Y4+K94`cga\[2!cSgMr(>`Y,!9Z%04S34g-?qa$fR'3d*-u>fHpUP\itKat=bHqpG*451\>lm>p'"ZL)taaS+dpV@iA:d@PCa9V6[u;O5K$;Pp]cA?dnhdcmJ^aVAn/rPIVoiP2AGmpMkhq3l/BVZec5AET8$<aOOHm0:9iPWS/;.h-_?Y;^EIP@ns='&]K4<AJ-AA/eI0@^^sO%[.uF,"mGt[Zm8A81+;QLMm'!Q.l5UnUn.OLMO[?4-5j-9J4*t8GF6D^tI1)*5f'V3$i'he&uX`<A"K/ii'M_Ff%i>)FE/W(eNQ*fs5.MAQ^'S>mk%YaJ5-M[FZmV%%M_>`OH"Hg2%Z75;P@-XY:$b`#Cng..c@.j2AES0VtGoG`sYH^ZsKaccWGk#c7aMfgrHk(J!?Da5'S)3Y]sUEJ3Ze7s8j==eZ,Yl%QGLh4p`27sfQ+aO#3+H[aACJu3i_2/=0mC8k/BBFq-/%#K_9g>Ji@!?&t$hl8OVaB!J1[0N?Fa*ZZ\YuX:6`K`kNi*K\#fQ*0h5ktlV1Boq]=*#t[0%<Y`:I=1eYZJr:Z7Ioe37&7+k`Psj6/L"++K!B,F_=fpcPrYuXX00g]OA".8],K5iBf,m9VV,jqK-(CGsS3s3ZGjTq*.g\38^n?DDGcQ".UbK@Qc5=2u<Q;QrjgQ^u^\]HA3bYB\+&4.Ug:;iu:A(cn6(RFU-sE%(8H]*QSf'eWO79ce^SlY'Q%<7+:dAQe'YDj.Og:[6(l=0'FQXC"./ccCfpK<-+g;cP%-ejX+_[-D"DNBGr)K67O9W^q:VH^4LY-OXcVN*tDMBa."Gd4p\k;oBqNE54G.pq-UG7bd0\lf@;\^5nS<NrTso8*U)d=l9-Q!imaDC\bKdk,G/GX%%oOWMK?I7Pc(:R_>EQ+eD,6qRZ>'rKTarcVb=Na:UA$:\^^YgH7?2qY\!jLF2^#I(`7;NWB;eoj$9M6[U@JQ0:j[rXdrp\$+2dIDOOY^:99/.>Y<B1"3;`UP"PLrMh?[hE!Bdig&u-Z^/f)Lft+V""qBuKclT&GT?n7s.!W6Ld-*^D*:U]"*-`iBf21YJ<joPW$@Ld[:3E!T"8UuO"h5TD)X.3hDIcp\_\b)53a^r5+3o]ODJ&`i#FeoY"3Ta`2K0jtee/A8+?8h@O*NW^7I^tS%+ni#s.ltRd,-iA)QnjO.CY#qeHVu\c9\n<mIT6ilVphXNLq*p1WHu'CNMcE[j$2:[iuBEa0"7]ErR"^mX?sYq_*QU]BALXE]KKP$7,]WWQ]n'**9S\Lb%):hs6Tt^T(E'A`UjZDG[q+*f-#^/p+)D-O?enGE$aBV81V@XR_+T4?B]Jb$<\/g$Dk+biDPC:>u'\"#)D/%K.A;9Ea6K9^ik$L1(S98TM`3UIcQOYh^b^A3:MQ(YoA0aYFDYMJU"2&6)u=a[OV%@NNWb^4=EKTK1USF?=IEQ_3CjMA?B)-EJgNC+Gn@`XR!%[*;bP-THcGfjO2j_&PE7c-;DgU".pdVQj7BnNkeCM:t;=RP,%a,:A'JHS&f^0bEN*\^K[G%RX:NL-$qf"$0tk5$k\2_EfBh)KiDsf8jWSmPUoh-\4I#)GGYG;=B!bfI/o~>endstream
endobj
12 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 420
>>
stream
GasbV;,;fu'SYH?(&*h.dnseOokhn%`,'G)5457jQ$#E]V/r'>hL[^'jM?r+h"7,?pY6HMj?MH!AI1'P0YYGTA)OP@#pR1JE6En.!-cRB7Oa65L+HmAZ]o`Bn2)];c@Y3Rkgr1P-mKR-5W#5]_1#1!OsTUF+./?j!qBen)>-E!.h<`Aa3b(I2ic3eOpH:=3,+h"iija=,$l;;h`](ZP$tRTKG1n=b3$,fRi<1Fhg$1:[Y5+&1K.9i#OTlK^Fur.:0.Y%Pl8s,eS)+++\uCsRCbOJ=GK&07o\3PKeO]PG#Frkm!5bgRU9?fAkEiUWgOK;4)!k;rV3ZsdLAdg4loXk*egg+j`pHqbeN@7>P[4M;ONM&q2f/N!bMAn\=DZ^.YTeEq,$i([`M_>k$\'R6pDgo&=^]?_&%PWZ1e~>endstream
endobj
xref
0 13
0000000000 65535 f
0000000061 00000 n
0000000122 00000 n
0000000229 00000 n
0000000341 00000 n
0000000418 00000 n
0000000523 00000 n
0000000728 00000 n
0000000933 00000 n
0000001002 00000 n
0000001317 00000 n
0000001383 00000 n
0000003576 00000 n
trailer
<<
/ID
[<d96d84fe3f7af5feff08db6aa9f36b11><d96d84fe3f7af5feff08db6aa9f36b11>]
% ReportLab generated PDF document -- digest (opensource)
/Info 9 0 R
/Root 8 0 R
/Size 13
>>
startxref
4087
%%EOF
+253
View File
@@ -0,0 +1,253 @@
# Uitrollen met een venster
Twee programma's: eentje op je ontwikkelmachine met vinkjes en een knop, en eentje op de server
die het pakket aanneemt en installeert.
Ze vervangen de PowerShell-scripts niet — ze roepen ze aan. `Publiceren.ps1`, `Uitrollen.ps1` en
`SocketServer-uitrollen.ps1` doen dit werk al maanden, inclusief het bewaren van de vorige versie
en het terugzetten daarvan als er iets misgaat. Die logica nog een keer in C# schrijven zou
betekenen dat er twee versies zijn van iets dat maar op één manier goed is.
## Drie werkwijzen
De agent kan het op drie manieren, en welke je nodig hebt hangt af van hoe je bij de server kunt.
**Wachten op een opdracht — dit is meestal wat je wilt.** Je start de agent op de server, kiest
"wachten", en laat dat venster staan. Vanaf dat moment doe je alles op je eigen pc: vinkjes
aanzetten, op **Bouwen en uitrollen** drukken, en meekijken. Het uitrolvenster zet de pakketten in
je publicatiemap en legt er een briefje bij (`uitrol-opdracht.json`); de agent ziet dat briefje
via de doorgegeven schijf van je externe-bureaubladverbinding, controleert per pakket de hash, en
rolt uit in de vaste volgorde. Wat hij doet schrijft hij terug in `uitrol-log.txt` in diezelfde
map, en het venster op je pc leest dat regel voor regel mee.
Geen poort, geen certificaat, geen gedeeld geheim — en op de server hoef je niets in te tikken.
Met een VPN werkt dezelfde weg, alleen wijst `Opdrachtmap` dan naar een gedeelde map op je pc in
plaats van naar `\\tsclient`.
Het blijft met opzet een apart briefje en niet "er staat een nieuwe zip, ga je gang": anders rolt
de agent uit zodra je om een heel andere reden een keer bouwt. Een opdracht hoort te ontstaan
doordat iemand op een knop drukt.
**Zelf uitrollen, zonder netwerk.** Je staat al op de server — via RDP of aan de console — en het
pakket staat ergens waar die server bij kan, bijvoorbeeld op de schijf die je met je
externe-bureaubladverbinding hebt doorgegeven. Start de agent, kies "een pakket uitrollen dat op
deze server staat", en geef het pad op:
```
\\tsclient\D\publish\mijnbesluit.zip
```
Hier komt geen poort aan te pas, geen certificaat en geen gedeeld geheim. Dat is geen gat in de
beveiliging: dat je op deze server bent ingelogd ís de toegangscontrole. Wie zover komt, heeft
geen pakket nodig om schade aan te richten.
**Op afstand, met een druk op de knop.** Je pc stuurt het pakket naar de agent en die rolt het
uit. Dat vraagt wél een poort, en daarmee alles hieronder: TLS, een handtekening en een firewall.
> **Let op: RDP is geen tunnel.** Je ziet het bureaublad van de server, maar je pc kan daarmee
> niet bij een poort op die server. De luisterstand heeft dus alleen zin als je de server echt
> over een netwerk kunt bereiken — een VPN. Zolang dat er niet is, gebruik je de eerste werkwijze:
> bouwen op je pc, het pakket over de doorgegeven schijf, en uitrollen op de server.
Het uitrolvenster op je pc is in beide gevallen nuttig: bouwen en inpakken doet het altijd. Alleen
het versturen werkt pas als de luisterstand kan.
## Waar je je bewust van moet zijn
Dit gaat over de luisterstand. Rol je uit terwijl je zelf op de server staat, dan is er niets van
dit alles nodig.
**Dit is een kanaal waarmee code op je server terechtkomt.** Dat is niet een bijwerking maar het
doel, en het is dezelfde server waar de kluizen en de masterkey staan. Wie bij dit kanaal kan,
kan alles wat de server kan.
Daarom zitten er vier grendels op, en die zijn geen van alle optioneel:
1. **De poort staat alleen open op het besloten netwerk.** De agent luistert wel op alles, maar de
firewall van de server hoort alles te weigeren behalve het VPN-bereik. Dit is geen poort voor
het open internet.
2. **TLS**, met hetzelfde certificaat als de SocketServer. Zonder certificaat weigert de agent te
starten.
3. **Een handtekening per opdracht**, met een gedeeld geheim. TLS zegt dat niemand meeleest, niet
wie er aanbelt — en op een besloten netwerk zit meer dan alleen jouw pc. In de handtekening
zitten de soort, de hash van het pakket, een tijdstip en een eenmalig nummer. Een opgevangen
opdracht is dus na twee minuten niets meer waard, kan geen tweede keer, en past niet op een
ander pakket.
4. **Een vaste lijst met wat de agent mag**: de site uitrollen, de SocketServer uitrollen, de
databasescripts draaien. Er zit geen "voer dit uit" bij. Een agent die opdrachten van buiten
uitvoert is een achterdeur met een nette naam.
En hij draait alleen als jij hem start. Geen Windows-service: een poort die dag en nacht openstaat
voor iets wat je een paar keer per week doet, staat vooral open als je er niet bent.
Vijf zelfcontroles bewaken de handtekening: verkeerd geheim, te kort geheim, een ander pakket
onder dezelfde handtekening, dezelfde opdracht twee keer, en een opdracht die te oud is.
## Eenmalig inrichten
Stap 1 en de firewallregel zijn alleen nodig voor de luisterstand. Rol je uit terwijl je zelf op
de server staat, dan volstaan stap 2 (zonder `Geheim` en zonder certificaat) en stap 3.
### 1. Een geheim maken — alleen voor de luisterstand
Op de server, in de map waar de agent staat:
```
MijnBesluit.Uitrolagent.exe --nieuwe-sleutel
```
Zet de uitkomst in `uitrolagent.json` **op de server** en in `uitroller.json` **op je pc**. Niet in
de projectmap, niet in een e-mail, niet in een schermafbeelding.
### 2. De agent neerzetten
Publiceer `src\MijnBesluit.Agentvenster` en zet de uitkomst op de server, bijvoorbeeld in
`C:\Beheer\uitrolagent`. Dat is het venster; de agent zelf komt als projectverwijzing mee, dus in
die ene map staan daarna allebei:
| Bestand | Waarvoor |
|---|---|
| `MijnBesluit.Agentvenster.exe` | Het venster. Dubbelklikken, hij staat te wachten. Dit is wat je dagelijks gebruikt. |
| `MijnBesluit.Uitrolagent.exe` | Hetzelfde werk zonder venster, plus `--nieuwe-sleutel`, `--zip` en `--luister` |
Vul `uitrolagent.json` in:
| Instelling | Wat erin hoort |
|---|---|
| `Geheim` | De sleutel uit stap 1 |
| `Poort` | 5210, of iets anders dat vrij is |
| `CertificateThumbprint` | Hetzelfde certificaat als de SocketServer gebruikt |
| `Scriptmap` | Waar `Uitrollen.ps1` en `SocketServer-uitrollen.ps1` staan, bijvoorbeeld `C:\Beheer` |
| `Werkmap` | Waar binnengekomen pakketten landen |
| `Opdrachtmap` | Waar de agent naar een briefje van je pc kijkt als hij staat te wachten. Over RDP: `\\tsclient\D\publish` |
| `DbGebruiker` / `DbWachtwoord` | Alleen nodig voor databasescripts. Neem een eigen account dat mag wijzigen; niet root als het niet hoeft |
> **Let op bij opnieuw publiceren.** Een publicatie zet `uitrolagent.json` terug op de versie uit de
> projectmap — met lege velden. Alles wat je niet elke keer opnieuw wilt intikken hoort daarom in
> `C:\MijnBesluitData\uitrolagent.secrets.json`. Dat bestand gaat **per sleutel** over
> `uitrolagent.json` heen, dus je zet er alleen in wat afwijkt: `Geheim`, `MysqlExe`, `DbGebruiker`,
> `DbWachtwoord`, en `Opdrachtmap` of `Scriptmap` als die bij jou anders zijn. Het agentvenster zegt
> bij het opstarten in zijn logboek welke twee bestanden hij heeft gelezen.
Zet de firewall goed: poort 5210 alleen vanaf het VPN-bereik.
### 3. Het venster neerzetten
Publiceer `src\MijnBesluit.Uitroller` en vul `uitroller.json` in: de server, de poort, hetzelfde
geheim, en waar je projectmap staat.
## De vinkjes staan er al
Bij het openen kijkt het venster wat er sinds de vorige uitrol is gewijzigd, en zet de vinkjes
daarop. Het zegt er ook bij waarom: *"De website: Home.razor is gewijzigd."*
Waarom dat de moeite is: na een middag werken weet niemand meer precies wat hij heeft aangeraakt.
Dan vink je te veel aan — kost tijd — of te weinig, en dan draait er een website tegen een
SocketServer die iets anders verwacht. Dat laatste kost een avond zoeken naar een fout die er niet
is.
Wat waarin terechtkomt staat in `Wijzigingen.cs` en wordt door een zelfcontrole bewaakt. De
gedeelde projecten tellen mee bij allebei: een wijziging in Domain of Application zit zowel in de
website als in de SocketServer. Protocol zit in de SocketServer en in de app, maar niet in de
website — die praat niet over de socket.
Twee dingen die met opzet zo zijn:
- **Bij twijfel staat hij aan.** Is er nog nooit vanuit dit venster uitgerold, dan staat alles
aan. Te veel uitrollen kost een minuut.
- **Onthouden gebeurt op het moment van het nieuwste bestand**, niet op de klok van het moment
waarop het lukte. Wijzig je iets terwijl hij bouwt, dan zou dat laatste die wijziging als
uitgerold aanmerken terwijl hij er niet in zit — en dan staat het vinkje de volgende keer ten
onrechte uit.
Het blijft een voorstel: je kunt elk vinkje zelf aan- en uitzetten. Wat er onthouden wordt staat
in `laatste-uitrol.json` naast de exe.
## Uitrollen met een wachtende agent
1. Op de server, één keer: dubbelklik `MijnBesluit.Agentvenster.exe`. Hij staat meteen te
wachten — er is geen knop om eerst in te drukken. Laat dat venster staan zolang je werkt.
Controleer dat `Opdrachtmap` in `uitrolagent.json` naar je publicatiemap wijst; over een
externe-bureaubladverbinding is dat `\\tsclient\D\publish`.
2. Op je pc: start `MijnBesluit.Uitroller.exe`, zet de vinkjes en druk op **Bouwen en uitrollen**.
Meer is het niet. Je ziet in het venster op je eigen scherm wat er op de server gebeurt, inclusief
de regels van `Uitrollen.ps1` zelf.
### Het agentvenster
Twee vensters die op elkaar lijken, en dat is de bedoeling: op je pc staat het uitrolvenster, op de
server het agentvenster. Dezelfde regels komen op allebei de schermen voorbij.
Wat je op de server ziet:
- **Een balk bovenin** met wat hij doet: *Wacht op een opdracht van je pc* of *Bezig met uitrollen*.
Die regel is van een meter afstand te lezen, want dit venster staat op een machine waar je niet
naar zit te kijken.
- **Het logboek**, regel voor regel, inclusief de uitvoer van de PowerShell-scripts. Dat is niet
anders dan vroeger — het gaat alleen niet meer naar een consolescherm.
- **Een voortgangsbalk** die loopt zolang er iets gebeurt. Uitrollen duurt minuten; zonder teken
van leven ga je je afvragen of hij nog wel iets doet.
- **Stoppen met wachten**, als je hem wilt laten staan zonder dat hij opdrachten oppakt.
Sluiten terwijl er een uitrol loopt vraagt hij na. Een uitrol halverwege afbreken laat een website
achter die niet meer bij zijn database past, en dat merk je pas als er iemand op zit.
Er draait geen PowerShell-venster meer waar je omheen moet werken: het venster start PowerShell
zelf, vangt elke regel op en zet die in het logboek. De console-agent bestaat nog wel, en die heb
je ook nodig — voor `--nieuwe-sleutel`, voor `--zip` als je een pakket uitrolt dat al op de server
staat, en voor `--luister` zodra er een VPN is.
Valt de verbinding weg terwijl de agent bezig is, dan maakt hij zijn werk gewoon af — alleen het
meekijken stopt. Op het scherm van de server staat hetzelfde.
## Uitrollen terwijl je zelf op de server staat
1. Op je pc: start `MijnBesluit.Uitroller.exe`, zet de vinkjes en druk op **Bouwen en uitrollen**.
Kan hij de agent niet bereiken, dan staan de pakketten alsnog klaar in je publicatiemap —
bouwen en inpakken gebeurt namelijk hier.
2. Op de server: start `MijnBesluit.Uitrolagent.exe`, kies **2** en geef het pad op naar de zip op
je doorgegeven schijf.
Of, als je liever niets aanwijst, in één regel:
```
MijnBesluit.Uitrolagent.exe --zip \\tsclient\D\publish\mijnbesluit.zip --soort site
```
`--soort` is `site`, `socketserver` of `database`.
## Uitrollen op afstand (vraagt een VPN)
1. Op de server: start `MijnBesluit.Uitrolagent.exe` en kies **3**, of start hem met `--luister`.
Er verschijnt een venster dat zegt dat hij luistert. Laat het openstaan.
2. Op je pc: start `MijnBesluit.Uitroller.exe`, druk eerst op **Verbinding proberen**. Dat kost
een seconde en scheelt de teleurstelling van een kwartier bouwen voor niets.
3. Zet de vinkjes en druk op **Bouwen en uitrollen**.
**De volgorde ligt vast en is niet die van de vinkjes:** eerst de databasescripts, dan de website,
dan de SocketServer, en de APK helemaal achteraan. Een nieuwe kolom moet er zijn voordat de code
erom vraagt; andersom staat de site stuk in de tussentijd. De SocketServer is als laatste omdat
het herstarten daarvan iedereen met de app uitlogt.
Wat er op de server gebeurt lees je mee in het venster, regel voor regel. Loopt de verbinding weg
halverwege, dan gaat het uitrollen op de server gewoon door — daar staat hetzelfde in beeld.
## De databasescripts
De agent draait de scripts uit `db\` **vanaf nummer 03**, in volgorde. Nummer 01 maakt het schema
aan en 02 maakt de applicatiegebruiker met zijn wachtwoord; dat is inrichten, geen bijwerken, en
dat hoort een mens één keer met de hand te doen. Een agent die accounts kan aanmaken is een agent
die meer kan dan hij moet.
Alle scripts gaan er elke keer overheen. Dat mag: ze zijn zo geschreven dat opnieuw draaien niets
doet — kijk maar in de kop van elk script. Dat is eerlijker dan bijhouden wat er al gedraaid is;
zo'n administratie kan ernaast zitten, en dan mis je er precies één.
## Als er iets misgaat
Het uitrolscript zet bij een fout de vorige versie zelf terug en zegt dat in het logboek. Gebeurt
dat niet, dan staan de vorige versies op de server in `_vorige-versies` naast de sitemap.
De agent doet één ding tegelijk. Krijg je "er loopt al een uitrol", dan is er een eerdere poging
nog bezig of vastgelopen; kijk op het scherm van de agent.
+332
View File
@@ -0,0 +1,332 @@
# MijnBesluit uitrollen op een Windows-server met IIS
Uitgangspunt: `mijnbesluit.nl` wijst al naar de server en daar draait IIS.
> **Dit is geen webservice zoals in WinDev.** IIS doet hier alleen de voordeur: hij neemt het
> verzoek aan en geeft het door aan het proces van de applicatie. Er zit geen SOAP, geen extra
> serialisatie en geen tweede laag tussen. De website praat vanuit dat proces rechtstreeks met
> MariaDB.
## 1. Wat er op de server moet staan
**MariaDB 10.6 of nieuwer.** Haal de MSI van mariadb.org, installeer met een sterk
root-wachtwoord en laat "Enable networking" uit staan als de website op dezelfde server draait —
dan is de database alleen via localhost te bereiken en kan niemand er van buitenaf bij. Staat de
database op een andere machine, zet `bind-address` dan op het interne adres en open poort 3306
alleen voor de webserver.
**Het .NET 8 Hosting Bundle** — dus niet de SDK. Zoek op "ASP.NET Core Hosting Bundle" en neem
de 8.0-versie. Die installeert de runtime én de IIS-module die het verzoek doorgeeft. Daarna:
```
net stop was /y
net start w3svc
```
**WebSocket Protocol in IIS.** Dit wordt vaak vergeten en dan werkt de site half: pagina's laden
wel, maar knoppen doen niets. Blazor Server houdt namelijk een websocket open. Aanzetten via
Serverbeheer → Rollen en onderdelen toevoegen → Webserver (IIS) → Webserver →
Toepassingsontwikkeling → **WebSocket Protocol**.
## 2. Mappen op de server
Zet de gegevens **buiten** de map waar je publiceert. Alles in de publicatiemap wordt bij een
volgende publicatie overschreven, en juist deze drie dingen mag je nooit kwijtraken:
```
D:\MijnBesluitData\
keys\masterkey.json de sleutel van alle kluizen
dataprotection\ cookiesleutels van ASP.NET
kluisbestanden\ versleutelde bijlagen
appsettings.secrets.json connectiereeks en e-mailinstellingen
D:\Sites\mijnbesluit\ hier komt de gepubliceerde applicatie
```
## 3. Database inrichten
### 3a. MariaDB installeren (als hij er nog niet staat)
Haal de Windows-MSI van <https://mariadb.org/download/> — neem de "Stable"-versie, 64 bits.
Tijdens het installeren zijn er drie keuzes die er echt toe doen:
**Root-wachtwoord.** Bedenk een lang, willekeurig wachtwoord en zet het in je wachtwoordbeheer.
Je hebt het hierna twee keer nodig en daarna bijna nooit meer.
**"Enable networking".** Draait de website op dezelfde server, **haal dit vinkje dan weg**, of
zet daaronder het adres op `127.0.0.1`. De database is dan alleen vanaf de server zelf te
bereiken en er is van buitenaf simpelweg geen deur om op te kloppen. Dat scheelt je de hele
categorie problemen van een database die per ongeluk aan het internet hangt.
**"Use UTF8 as default server's character set".** Aanvinken. Het schema zet utf8mb4 overal zelf
al, maar dit voorkomt verrassingen bij alles wat je er later handmatig bij maakt.
De installatie zet een Windows-service neer die `MariaDB` heet en automatisch meestart. Controleer
dat even in `services.msc`.
Installeer op de server meteen ook **HeidiSQL** (zit bij de MariaDB-installatie, of los te
downloaden). Dat werkt prettiger dan de opdrachtregel, en omdat je toch op de server bent
ingelogd hoef je de database niet open te zetten.
### 3b. Database en gebruiker aanmaken
Kopieer de map `db` uit het project naar de server, bijvoorbeeld naar `D:\db`.
Pas eerst in `02_applicatiegebruiker.sql` de regel met `VERVANG_DIT_WACHTWOORD` aan. Kies een
ander wachtwoord dan op je ontwikkelmachine: raakt die ene uit, dan staat de andere er niet mee
open.
**Met HeidiSQL op de server:** verbinden als `root` op `127.0.0.1`, dan *Bestand →
SQL-bestand uitvoeren* (Ctrl+Shift+O), eerst `01_schema.sql`, daarna `02_applicatiegebruiker.sql`.
**Of vanaf de opdrachtregel**, in een **cmd**-venster (niet PowerShell, die kent `<` niet):
```
"C:\Program Files\MariaDB 11.4\bin\mysql.exe" -u root -p < D:\db\01_schema.sql
"C:\Program Files\MariaDB 11.4\bin\mysql.exe" -u root -p < D:\db\02_applicatiegebruiker.sql
```
Het versienummer in dat pad verschilt per installatie; kijk even in `C:\Program Files`.
Beide scripts mogen meerdere keren gedraaid worden; bestaande tabellen en rechten blijven staan.
### 3c. Controleren
```sql
SHOW DATABASES;
USE mijnbesluit;
SHOW TABLES; -- hoort 14 tabellen te geven
SHOW GRANTS FOR 'mijnbesluit_app'@'localhost';
```
Bij de rechten hoort `audit_log` alleen SELECT en INSERT te hebben, de andere tabellen ook UPDATE
en DELETE, en nergens CREATE, ALTER of DROP. Zo kan de applicatie het logboek wel vullen maar
niet opschonen, en kan een fout in de code nooit een tabel weggooien.
Test daarna of het applicatieaccount echt werkt, want daar loopt het straks op vast:
```
"C:\Program Files\MariaDB 11.4\bin\mysql.exe" -u mijnbesluit_app -p -D mijnbesluit -e "SELECT COUNT(*) FROM users;"
```
Komt daar `0` uit, dan klopt zowel het wachtwoord als de rechten. Dat wachtwoord zet je straks in
`appsettings.secrets.json` (stap 6).
### 3d. Vanaf je eigen pc meekijken
Zet MariaDB daarvoor **niet** open naar buiten. Twee betere manieren: werk in HeidiSQL op de
server zelf via de externe-bureaubladverbinding, of maak in HeidiSQL op je eigen pc een
verbinding van het type *SSH-tunnel* als er een SSH-server op de machine draait. Een database die
rechtstreeks aan het internet hangt wordt binnen een dag gevonden en geprobeerd.
## 4. Publiceren — in het vervolg met twee opdrachten
> Liever met vinkjes en een knop dan met twee PowerShell-vensters? Zie
> [Uitrollen met een venster](Uitrollen-met-het-venster.md). Die twee programma's roepen dezelfde
> scripts aan als hieronder staan; de scripts blijven dus de waarheid.
Net als de "publish site" uit WinDev, maar dan in twee stappen omdat de server niet bij jouw
schijven kan.
**Op je eigen pc:**
```powershell
cd D:\MijnBesluit
.\tools\Publiceren.ps1
```
Dat bouwt de Release-versie, gooit `appsettings.Development.json` eruit (die hoort niet op een
server) en maakt er `D:\publish\mijnbesluit.zip` van.
**Op de server**, in een PowerShell-venster als administrator:
```powershell
.\Uitrollen.ps1 -Zip \\tsclient\D\publish\mijnbesluit.zip
```
Dat script:
* haalt het pad en de toepassingsgroep uit IIS, dus je hoeft niets in te typen;
* waarschuwt als de toepassingsgroep niet op "Geen beheerde code" staat;
* pakt eerst helemaal uit en zet de site pas daarna een paar seconden stil;
* bewaart de vorige versie in `_vorige-versies` naast de sitemap;
* zet bij een fout de vorige versie automatisch terug;
* controleert daarna of `https://mijnbesluit.nl/inloggen` antwoordt — het leidt dat adres af uit
de bindingen van de site;
* ruimt alles op behalve de laatste vijf versies.
Kopieer `Uitrollen.ps1` één keer naar de server, bijvoorbeeld naar `C:\Beheer`. Werkt de
stationsdoorverwijzing van je externe-bureaubladverbinding niet, kopieer dan eerst het
zip-bestand naar de server en geef dat pad op:
```powershell
.\Uitrollen.ps1 -Zip D:\mijnbesluit.zip
```
Terugvallen op de vorige versie kan altijd; het script laat de precieze opdrachten zien als de
controle mislukt.
### Handmatig publiceren
Liever zelf, of eenmalig:
## 4b. Handmatig publiceren
Op je ontwikkelmachine:
```
dotnet publish src\MijnBesluit.Web -c Release -o D:\publish\mijnbesluit
```
Kopieer de inhoud van `D:\publish\mijnbesluit` naar `D:\Sites\mijnbesluit` op de server. In
Visual Studio kan het ook met Publiceren → Map, of met Web Deploy als dat op de server aanstaat.
`dotnet publish` maakt zelf een `web.config` aan met de juiste verwijzing naar de IIS-module;
daar hoef je niets aan te doen.
## 5. De site in IIS
**Toepassingsgroep** aanmaken, naam `MijnBesluit`:
* .NET CLR-versie: **Geen beheerde code**. Dat lijkt vreemd, maar klopt: de applicatie draait in
een eigen proces en IIS hoeft er geen .NET voor te laden.
* Identiteit: `ApplicationPoolIdentity` (heet dan `IIS AppPool\MijnBesluit`).
* Bij Geavanceerde instellingen: **Leegloop-time-out op 0** en **Vaste interval voor recyclen op
0**. Anders wordt de applicatie na twintig minuten zonder bezoek afgesloten en duurt het
eerste bezoek daarna onnodig lang.
**Website** aanmaken:
* Fysiek pad: `D:\Sites\mijnbesluit`
* Toepassingsgroep: `MijnBesluit`
* Binding: https op poort 443, hostnaam `mijnbesluit.nl`, met je certificaat. Doe hetzelfde voor
`www.mijnbesluit.nl` als je die gebruikt.
* Een binding op poort 80 mag, maar laat die alleen doorsturen naar https — de applicatie doet
dat zelf ook.
**Rechten.** Geef `IIS AppPool\MijnBesluit`:
* Lezen en uitvoeren op `D:\Sites\mijnbesluit`
* **Wijzigen** op `D:\MijnBesluitData` (hij moet in `dataprotection` en `kluisbestanden` kunnen
schrijven, en `keys\masterkey.json` kunnen lezen)
## 6. Instellingen op de server
In `appsettings.Production.json` (die is meegepubliceerd) staan alleen paden en de site-URL.
De geheimen komen uit het bestand waar `ExtraConfigFile` naar wijst. Maak
`D:\MijnBesluitData\appsettings.secrets.json`:
```json
{
"ConnectionStrings": {
"MijnBesluit": "Server=localhost;Port=3306;Database=mijnbesluit;User Id=mijnbesluit_app;Password=HET-WACHTWOORD;"
},
"Smtp": {
"Host": "smtp.solcon.nl",
"Port": 587,
"UseStartTls": true,
"Username": "flietstra03",
"Password": "HET-MAILWACHTWOORD",
"FromAddress": "jonathan@flietstra.nl",
"FromName": "MijnBesluit",
"WriteToDiskInsteadOfSending": false
}
}
```
Let op het afzenderadres. Een mailserver van een provider accepteert vaak alleen een afzender
die bij het account hoort: Solcon weigert `noreply@mijnbesluit.nl` met *"Verification failed"*.
Gebruik daarom voorlopig je eigen adres. Zodra mijnbesluit.nl een eigen mailbox of verzenddienst
heeft, zet je `Host`, `Username`, `Password` en `FromAddress` daarop om — dat is ook nodig voor
SPF en DKIM, anders komen uitnodigingen bij Gmail en Outlook in de spam.
Zo test je een afzenderadres los van de applicatie, op de server zelf:
```powershell
$cred = Get-Credential
Send-MailMessage -SmtpServer smtp.solcon.nl -Port 587 -UseSsl -Credential $cred `
-From "jonathan@flietstra.nl" -To "jonathan@flietstra.nl" -Subject "test" -Body "test"
```
Let op: **geen** `RedirectAllTo` hier. Die hoort alleen op je testmachine; op de server moet post
gewoon naar de echte ontvanger.
Geef dit bestand alleen leesrecht aan `IIS AppPool\MijnBesluit` en aan beheerders.
## 7. Het sleutelbestand aanmaken
Eenmalig, op de server, in een opdrachtvenster:
```
cd D:\Sites\mijnbesluit
MijnBesluit.Web.exe --maak-sleutel D:\MijnBesluitData\keys\masterkey.json
```
Hij weigert een bestaand bestand te overschrijven — met opzet, want dat zou elke bestaande kluis
in één klap onleesbaar maken.
**Maak hier meteen een back-up van, op een andere plek dan je database-back-up.** Zonder dit
bestand is niets meer te openen; mét dit bestand én een databasekopie is alles leesbaar. Die twee
horen dus niet bij elkaar te liggen.
## 8. Controleren
Ga naar `https://mijnbesluit.nl/inloggen`. Werkt dat, controleer dan ook:
* Vraag een code aan. Komt de e-mail aan, dan klopt de SMTP-instelling.
* Log in en klik door naar Kluis. Werken de knoppen, dan doet de websocket het.
* Herstart de toepassingsgroep in IIS en ververs de pagina. Blijf je ingelogd, dan staat de
sleutelring van ASP.NET goed. Word je uitgelogd, kijk dan naar `Security:DataProtectionPath`
en de schrijfrechten op die map.
## 9. Back-up
Drie dingen, en het liefst niet op dezelfde plek:
1. De database. Bijvoorbeeld dagelijks met een taak:
`mysqldump -u root -p --single-transaction mijnbesluit > D:\Backup\mijnbesluit-%DATE%.sql`
2. `D:\MijnBesluitData\kluisbestanden` — de versleutelde bijlagen.
3. `D:\MijnBesluitData\keys\masterkey.json` — **apart**, en niet in dezelfde back-upset als 1 en 2.
Beproef ook eens een keer of terugzetten werkt. Een back-up die je nooit hebt teruggezet is een
aanname, geen back-up.
## 10. Als er iets misgaat
**Kijk eerst in de log.** De applicatie schrijft zelf naar `C:\MijnBesluitData\logs`, één
bestand per dag. Daar staat de échte fout in; de bezoeker krijgt alleen een nette melding.
```powershell
Get-ChildItem C:\MijnBesluitData\logs | Sort-Object LastWriteTime -Descending |
Select-Object -First 1 | Get-Content -Tail 60
```
**"We konden de e-mail met je code niet versturen"** — de SMTP-instellingen. Ontbreekt het
blok `Smtp` in `appsettings.secrets.json`, dan probeert de applicatie `localhost:587` en is er
niets om verbinding mee te maken. Staat het blok er wel, dan zegt de log wat de mailserver
antwoordde: een time-out betekent dat de poort dichtstaat op de server, en een 5xx-antwoord dat
de mailserver de server niet als afzender accepteert.
**HTTP 500.19** — IIS kan `web.config` niet lezen. Meestal is het Hosting Bundle niet
geïnstalleerd, of is het na IIS geïnstalleerd. Installeer het (opnieuw) en herstart IIS.
**HTTP 500.30** — de applicatie start niet. Zet in `web.config` tijdelijk
`stdoutLogEnabled="true"` en kijk in de map `logs`. Negen van de tien keer is het de
connectiereeks of een ontbrekend sleutelbestand.
**HTTP 502.5** — verkeerde runtime-versie, of het Hosting Bundle ontbreekt.
**De pagina laadt maar knoppen doen niets** — WebSocket Protocol staat niet aan in IIS. Zie stap 1.
**HTTP 400 bij het insturen van een formulier** — de sleutelring van ASP.NET is weg. Zie stap 8.
**"Access denied for user"** — het wachtwoord in `appsettings.secrets.json` komt niet overeen met
dat in de database. Test het eerst met de MariaDB-client op de server zelf.
## 11. En de SocketServer?
Die heb je pas nodig zodra de Android-app er is; de website werkt er los van. Wil je hem alvast
draaien: publiceer `MijnBesluit.SocketServer`, zet hem op dezelfde server neer, gebruik hetzelfde
`D:\MijnBesluitData` en installeer hem als service met het beheerschermpje. Open dan poort 5200
in de firewall. Zie `docs/SocketServer-installeren.md`.
+72
View File
@@ -0,0 +1,72 @@
# Volgende stappen
Wat er staat, en wat er nog moet. Eerlijk overzicht.
## Werkt en is gecontroleerd
- Domein en regels: eigendom van profielen, overlijdensprocedure, vrijgave van berichten.
- Versleuteling: AES-256-GCM in drie lagen, wachtwoordhashing, tokens.
- De handelingen: registreren, inloggen, familie, uitnodigen, claimen, kluis, berichten,
nalatenschap.
- Het socketprotocol: framing, server, client, sessies, dispatcher.
- De website in Blazor: inloggen, registreren, uitnodiging aannemen, overzicht, familie, kluis,
momenten, nalatenschap.
- Databaseschema voor MariaDB.
- De SocketServer als 64-bits Windows-service, met een beheerschermpje voor Install/UnInstall,
een eigen thread per verbinding en elk verzoek op zijn eigen thread.
- Inloggen en registreren met een code per e-mail, zonder wachtwoorden.
- De app-kant: `MijnBesluit.Client` (aanroepen over de socket) en `MijnBesluit.AppUi` (de
schermen). Zie `docs/Android-app.md`.
- Zesenvijftig controles in `MijnBesluit.SelfCheck`, alle groen.
## Nog te bouwen
### 1. De Android-app afmaken
De drie projecten staan er: `MijnBesluit.Client`, `MijnBesluit.AppUi` en de schil
`MijnBesluit.App`. De eerste twee bouwen en zijn getest; de schil is alleen op een toestel te
beproeven. Hoe je hem draait staat in `docs/Android-app.md`.
De serverkant staat: de SocketServer draait op mijnbesluit.nl, poort 5200 is open, en de
verbinding loopt over TLS 1.3 met het certificaat van het domein. Database en e-mail werken er
ook overheen.
Wat nu nog moet: de app op een echt toestel beproeven (`tools\Apk-maken.ps1 -Debug`), en daarna: opnieuw verbinden als het toestel uit de slaapstand komt, biometrisch ontgrendelen
voordat de kluis opengaat, en push-meldingen (Firebase) voor een bericht dat bezorgd wordt
terwijl de app dicht is.
### 2. Bijlagen in de schermen
`VaultService.StoreFileAsync` werkt, maar er is nog geen scherm om een document, foto of video te
uploaden en terug te bekijken. Denk aan een maximumgrootte en aan streamen in plaats van het hele
bestand in het geheugen laden.
### 3. Videoberichten
`MessageKind.Video` bestaat in het model. Opnemen in de app, versleuteld opslaan als
`VaultFile`, en tonen bij het lezen van het bericht.
### 5. Twee-factor-authenticatie
Het veld staat in de database. TOTP is de logische keuze; het geheim hoort versleuteld te worden
met dezelfde envelope-opzet als de kluis.
### 6. Koppeling met de BRP
Dit vervangt op termijn de drie meldingen. Alleen de plek waar `DeathConfirmedAt` gevuld wordt
verandert; de rest van de code niet. Houd de handmatige procedure ernaast: niet elke deelnemer
komt uit de BRP.
### 7. iOS
Zodra Android draait, is dat vooral een kwestie van een tweede doelplatform in de MAUI-csproj en
een Mac om mee te bouwen.
### 8. Voordat het live gaat
- Een onafhankelijke beveiligingsaudit.
- Snelheidsbegrenzing per IP-adres op inloggen en registreren.
- Beproef het terugzetten van een back-up — inclusief het sleutelbestand.
- Verwerkersovereenkomst en privacyverklaring: dit zijn bijzondere persoonsgegevens.
- Bedenk wat er gebeurt als MijnBesluit zelf ooit stopt. Mensen vertrouwen je hun
uitvaartwensen toe; daar hoort een antwoord bij.
+215
View File
@@ -0,0 +1,215 @@
# De website lokaal testen
Drie stappen, daarna draait hij.
> **Draaide de website al met een oudere versie?** Draai dan eerst
> `db\03_inloggen_met_code.sql`: die voegt de tabel voor de inlogcodes toe en haalt de
> wachtwoordkolom weg. Op een verse database is dat niet nodig.
## 1. Database aanmaken
Het makkelijkst gaat dit **in HeidiSQL**: maak verbinding, dan *Bestand → SQL-bestand uitvoeren*
(Ctrl+Shift+O) en kies `D:\MijnBesluit\db\01_schema.sql`. Geen gedoe met paden of shells.
Wil je het toch vanaf de opdrachtregel, let dan op: **dit werkt niet in PowerShell.** PowerShell
kent het teken `<` niet en geeft *"The '<' operator is reserved for future use."* Gebruik een
**cmd**-venster:
```
mysql -u root -p < D:\MijnBesluit\db\01_schema.sql
```
Moet het per se in PowerShell, dan kan het zo:
```powershell
Get-Content D:\MijnBesluit\db\01_schema.sql | mysql -u root -p
```
Staat `mysql` niet in je pad, gebruik dan het volledige pad — bij MariaDB is dat meestal
`"C:\Program Files\MariaDB 11.4\bin\mysql.exe"`. Bij nieuwere versies heet het programma
`mariadb.exe`; dat werkt precies hetzelfde.
Daarna hetzelfde voor `02_applicatiegebruiker.sql`, nadat je daarin `VERVANG_DIT_WACHTWOORD` hebt
aangepast. Wil je voor het testen gewoon je eigen MariaDB-account gebruiken, dan kun je dat tweede
script overslaan.
Beide scripts mogen meerdere keren gedraaid worden; bestaande tabellen en rechten blijven staan.
Controleer achteraf in HeidiSQL of de database `mijnbesluit` er staat met dertien tabellen.
## 2. Wachtwoord en connectiereeks
Bedenk eerst één wachtwoord voor de databasegebruiker. Dat moet op **twee** plekken hetzelfde
staan, en dat is waar het vaak misgaat.
**In de database.** Heb je `02_applicatiegebruiker.sql` gedraaid zonder
`VERVANG_DIT_WACHTWOORD` aan te passen, dan is dat nu letterlijk het wachtwoord. Zet het goed:
```sql
ALTER USER 'mijnbesluit_app'@'localhost' IDENTIFIED BY 'HIER-JOUW-WACHTWOORD';
FLUSH PRIVILEGES;
```
**In de applicatie.** Niet in `appsettings.json` (dat bestand gaat mee de broncodebeheer in),
maar in user secrets:
```
cd D:\MijnBesluit
dotnet user-secrets set "ConnectionStrings:MijnBesluit" "Server=localhost;Port=3306;Database=mijnbesluit;User Id=mijnbesluit_app;Password=HIER-JOUW-WACHTWOORD;" --project src\MijnBesluit.Web
```
Controleer met `dotnet user-secrets list --project src\MijnBesluit.Web` wat er echt staat.
In Visual Studio kan het ook: rechtsklik op het project → *Gebruikersgeheimen beheren*.
Wil je eerst alleen zien dát alles draait, zet dan tijdelijk je eigen `root`-account in de
reeks. Weet je dan dat het werkt, schakel daarna pas over op `mijnbesluit_app`.
## 3. Starten
Zorg dat het ontwikkelcertificaat van .NET vertrouwd is (eenmalig):
```
dotnet dev-certs https --trust
```
Start dan het project:
```
dotnet run --project src\MijnBesluit.Web --launch-profile https
```
Of in Visual Studio: zet `MijnBesluit.Web` als startproject en druk op F5. Kies het profiel
**https**; het inlogcookie is met opzet als *Secure* gemarkeerd en werkt dus niet over gewoon http.
De site komt op **https://localhost:7014**.
Het sleutelbestand hoef je niet zelf aan te maken: bij de eerste start zet de website
`keys/masterkey.json` neer in de uitvoermap, omdat dat in `appsettings.Development.json` aanstaat.
Gooi die map weg en alles wat je tijdens het testen in de kluis hebt gezet, is onleesbaar — dat is
precies de bedoeling.
## Een rondje door de applicatie
1. **Registreren** op `/registreren`. Vul je naam en e-mailadres in en klik op *Code aanvragen*.
Er is geen wachtwoord: je krijgt een code van zes cijfers toegestuurd. Tijdens ontwikkelen
wordt die niet verstuurd maar als html-bestand neergezet in
`src\MijnBesluit.Web\bin\Debug\net8.0\mail-uitgaand` &mdash; open het nieuwste bestand, lees de
code en vul hem in. Daarna heb je meteen een eigen profiel en familieproject.
2. **Kluis** → *Toevoegen*. Kies bijvoorbeeld *Uitvaartwens* en zet de vrijgave op
"Na mijn overlijden". Sla op en klik daarna op *Bekijken*: de inhoud wordt ter plekke
ontsleuteld. Kijk in HeidiSQL in de tabel `vault_items`: in de kolom `ciphertext` staat
onleesbare rommel, alleen `title` en `category` zijn leesbaar.
3. **Familie** → iemand toevoegen met een e-mailadres, daarna *Uitnodiging sturen*.
Er wordt niets verstuurd tijdens ontwikkelen: de mail komt als html-bestand in
`src\MijnBesluit.Web\bin\Debug\net8.0\mail-uitgaand`. Open het nieuwste bestand in je browser
en klik op de link — of kopieer de `/uitnodiging/...`-link naar een **privévenster**.
4. **De uitnodiging aannemen** in dat privévenster (anders log je jezelf uit). Er is niets in te
vullen: de link uit de e-mail is zelf al het bewijs dat je bij dat adres kunt. Ga daarna terug
naar je eerste venster, ververs *Familie*: het profiel staat nu op **Geclaimd** en je kunt er
niets meer aan wijzigen.
5. **Momenten** → *Nieuw bericht* voor die persoon, met "Pas als ik er niet meer ben". In het
privévenster zie je bij Momenten nog niets staan — zo hoort het.
6. **Nalatenschap** → wijs die persoon aan als beheerder. Zet het aantal meldingen op 1 en de
bezwaartermijn op 0 dagen om de procedure te kunnen doorlopen zonder drie accounts.
Meld jezelf vanuit het privévenster als overleden, en kijk wat er gebeurt.
Voor stap 6 heb je geen achtergrondtaak nodig zolang het aantal meldingen gehaald wordt en de
bezwaartermijn 0 is; de bevestiging gebeurt dan meteen. Staat er wél een termijn, dan is het de
achtergrondtaak in de SocketServer die dat later afrondt.
## Als er iets niet werkt
**"The '<' operator is reserved for future use"** — je zit in PowerShell. Zie stap 1.
**"'mysql' is not recognized as an internal or external command"** — het programma staat niet in
je pad. Gebruik het volledige pad naar `mysql.exe` of `mariadb.exe`, of draai het script in
HeidiSQL.
**Je krijgt geen e-mail met de code** — tijdens ontwikkelen wordt er niets verstuurd. Kijk in
`src\MijnBesluit.Web\bin\Debug\net8.0\mail-uitgaand`; daar staat de mail als html-bestand.
Wil je wel echt versturen, zet dan `Smtp:WriteToDiskInsteadOfSending` op `false` en vul de
SMTP-gegevens in (zie hieronder).
**"Deze code is verlopen of al gebruikt"** — een code werkt één keer en 15 minuten. Vraag een
nieuwe aan met de knop onder het codeveld.
**"Access denied for user 'root'@'localhost'"** — verkeerd wachtwoord. Let op: achter `-p` komt
géén spatie als je het wachtwoord meteen meegeeft; laat `-p` liever leeg, dan wordt er om
gevraagd.
**Je krijgt geen e-mail met de code** — tijdens ontwikkelen wordt er niets verstuurd. Kijk in
`src\MijnBesluit.Web\bin\Debug\net8.0\mail-uitgaand`; daar staat de mail als html-bestand.
Wil je wel echt versturen, zet dan `Smtp:WriteToDiskInsteadOfSending` op `false` en vul de
SMTP-gegevens in (zie hieronder).
**"Deze code is verlopen of al gebruikt"** — een code werkt één keer en 15 minuten. Vraag een
nieuwe aan met de knop onder het codeveld.
**"Access denied for user 'mijnbesluit_app'@'localhost' (using password: YES)"** — het
wachtwoord in user secrets is niet hetzelfde als dat in de database. Zie stap 2. Het ligt niet
aan `localhost` versus `127.0.0.1`: een account dat als `@localhost` is aangemaakt, werkt ook
over TCP. Test het account eerst in HeidiSQL met precies hetzelfde wachtwoord.
**"Unable to connect to any of the specified MySQL hosts"** — MariaDB draait niet, of de
connectiereeks klopt niet. Test de reeks eerst in HeidiSQL met hetzelfde account.
**"Geen connectiereeks 'MijnBesluit' gevonden"** — de user secret staat op het verkeerde project.
Controleer met `dotnet user-secrets list --project src\MijnBesluit.Web`.
**Je wordt na het inloggen meteen weer uitgelogd** — je draait over http in plaats van https.
Gebruik het profiel *https*.
**Waarschuwing over het certificaat in de browser** — `dotnet dev-certs https --trust` nog niet
gedraaid, of de browser moet opnieuw opgestart worden.
**Je ziet je eigen wijziging niet** — Blazor Server houdt een verbinding open. Ververs met
Ctrl+F5 als een scherm blijft hangen.
## De app erbij
De website heeft de SocketServer niet nodig; die is alleen voor de Android/iOS-app. Wil je beide
tegelijk draaien, start het SocketServer-project dan met het argument `--service`, anders krijg je
het installatieschermpje in plaats van de server. Zie `docs/SocketServer-installeren.md`.
## Echte e-mail versturen
Tijdens ontwikkelen worden mails weggeschreven naar een map. Wil je ze echt versturen, zet dan de
SMTP-gegevens in user secrets &mdash; niet in `appsettings.json`, want dat bestand gaat mee de
broncodebeheer in:
```
cd D:\MijnBesluit
dotnet user-secrets set "Smtp:Host" "mail.jouwprovider.nl" --project src\MijnBesluit.Web
dotnet user-secrets set "Smtp:Port" "587" --project src\MijnBesluit.Web
dotnet user-secrets set "Smtp:UseStartTls" "true" --project src\MijnBesluit.Web
dotnet user-secrets set "Smtp:Username" "noreply@mijnbesluit.nl" --project src\MijnBesluit.Web
dotnet user-secrets set "Smtp:Password" "HIER-HET-WACHTWOORD" --project src\MijnBesluit.Web
dotnet user-secrets set "Smtp:FromAddress" "noreply@mijnbesluit.nl" --project src\MijnBesluit.Web
dotnet user-secrets set "Smtp:FromName" "MijnBesluit" --project src\MijnBesluit.Web
dotnet user-secrets set "Smtp:WriteToDiskInsteadOfSending" "false" --project src\MijnBesluit.Web
```
### Veilig oefenen met echte adressen
Zodra je echt verstuurt, komen uitnodigingen ook echt aan. Wil je met de namen en adressen van je
eigen familie oefenen zonder ze lastig te vallen, zet dan:
```
dotnet user-secrets set "Smtp:RedirectAllTo" "jouw@eigenadres.nl" --project src\MijnBesluit.Web
```
Alle uitgaande post gaat dan naar dat ene adres. Het oorspronkelijke adres staat in het onderwerp
(`[TEST aan eva@example.nl] ...`) en bovenaan de mail, dus je ziet precies wie hem gekregen zou
hebben. Leeglaten &mdash; of de instelling verwijderen &mdash; en het gaat weer gewoon naar de
echte ontvanger.
Dezelfde regels gelden voor `--project src\MijnBesluit.SocketServer`; die verstuurt ook mail
(uitnodigingen, bezorgde berichten, meldingen over de nalatenschap).
Voor de SocketServer als Windows-service werkt user secrets niet &mdash; die draait onder
LocalSystem. Zet de instellingen daar in `appsettings.json` naast de exe, of beter: in
omgevingsvariabelen (`Smtp__Password`), zodat het wachtwoord niet in een bestand staat dat
meegekopieerd wordt.
Wil je later van provider wisselen, dan is dit de enige plek die verandert; in de code staat
nergens een mailserver.
+72
View File
@@ -0,0 +1,72 @@
# De iOS-app
De app voor iPhone en iPad is dezelfde app als die voor Android. `MijnBesluit.AppUi` (alle
schermen), `MijnBesluit.Client` en het socketprotocol zijn gedeeld; op iOS draait diezelfde HTML
in een WKWebView in plaats van een Android WebView. Wat verschilt is alleen wat het toestel zelf
doet.
Dit document gaat over dat verschil, en over wat Apple wil.
## Wat er klaarstaat
- `net10.0-ios` staat in `MijnBesluit.App.csproj`, maar **standaard uit**. Aanzetten met
`-p:MetIos=true`, of door `MetIos` blijvend te zetten op de machine die aan een Mac hangt.
Dat moet wel zo: staat de iOS-doelomgeving er onvoorwaardelijk in, dan wil ook een build van
alleen de Android-APK de iOS-werkbelasting en een Mac hebben, en struikelt het maken van de
APK over iets wat er niets mee te maken heeft.
- `Platforms/iOS/` heeft `AppDelegate.cs`, `Program.cs` en `Info.plist`.
- `Toestelslot` heeft een iOS-tak: Face ID of Touch ID via LocalAuthentication, met de code van
de telefoon als terugval.
- De gebruikstoelichtingen staan in `Info.plist`.
## Wat er nog moet
**Een Mac.** Apple's bouwgereedschap draait alleen op macOS. Visual Studio op Windows verbindt
er over het netwerk mee ("Pair to Mac", over SSH) en laat de Mac compileren en ondertekenen. Op
die Mac moet Xcode staan, één keer met de hand geopend zijn, en Remote Login aan. Staat er geen
Mac, haal `net10.0-ios` dan tijdelijk uit `TargetFrameworks`; Android blijft dan gewoon bouwen.
**Het Apple Developer Program**, $99 per jaar. Als organisatie heb je nodig: een echte
rechtspersoon, een D-U-N-S-nummer daarvoor, een e-mailadres op je eigen domein en een werkende
website op dat domein. Dat D-U-N-S-nummer heb je voor Google Play ook nodig - vraag het één keer
aan.
**Meldingen via APNs.** `Toestelmeldingen` geeft op iOS nu null terug: geen kenmerk, dus geen
meldingen, en verder werkt alles. Dat komt erbij zodra het account er is, want het hangt eraan
vast: een APNs-sleutel, de Push Notifications-capability, een provisioning profile en een
`Entitlements.plist`.
## Twee vallen waar je vroeg in loopt
**Ontbrekende gebruikstoelichting = de app sluit.** Vraag je om Face ID zonder
`NSFaceIDUsageDescription` in `Info.plist`, dan sluit iOS de app ter plekke af. Geen foutmelding,
geen crashmelding die ergens op wijst - hij is gewoon weg. Hetzelfde geldt voor camera, microfoon
en fotobibliotheek. Ze staan er nu allemaal in; komt er ooit iets bij wat het toestel moet
vrijgeven, dan hoort de tekst er in dezelfde wijziging bij te staan. Een zelfcontrole bewaakt dat.
**De socket op poort 5200.** Dat is een eigen TLS-verbinding en geen URL-verkeer, dus App
Transport Security gaat er niet over en je hebt er géén ATS-uitzondering voor nodig. Een
uitzondering toevoegen die je niet nodig hebt levert alleen vragen op bij de beoordeling. Maar
probeer die verbinding wel vroeg op een echt toestel, en ook over 4G en niet alleen over wifi.
## Wat Apple bij het inzenden vraagt
- **Account verwijderen in de app.** Verplicht. Staat er al.
- **App Privacy** - hetzelfde soort formulier als Databeveiliging bij Google. De
privacyverklaring staat in `Privacytekst.razor`.
- **Exportverklaring** (`ITSAppUsesNonExemptEncryption`). Deze app versleutelt een kluis, dus dat
antwoord is niet vanzelfsprekend "nee" zoals bij een app die alleen HTTPS gebruikt. De sleutel
staat bewust NIET in `Info.plist`: dan vraagt App Store Connect het bij elke inzending en
beantwoord je het bewust. Zie de toelichting in dat bestand.
- **Sign in with Apple** is NIET verplicht. Dat geldt alleen als je inloggen via Google of
Facebook aanbiedt; MijnBesluit doet e-mail met een code.
## De volgorde
1. D-U-N-S aanvragen voor de B.V. (kan weken duren, en Google Play wacht er ook op)
2. Apple Developer Program, als organisatie
3. Mac regelen, Xcode erop, Pair to Mac aan de praat
4. Bouwen en op een echt toestel zetten
5. Uitproberen: inloggen met een code, de socket, de kluis, het slot met Face ID
6. Meldingen via APNs
7. Inzenden
+5
View File
@@ -0,0 +1,5 @@
<Application x:Class="MijnBesluit.Agentvenster.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
StartupUri="Hoofdvenster.xaml">
</Application>
+7
View File
@@ -0,0 +1,7 @@
using System.Windows;
namespace MijnBesluit.Agentvenster;
public partial class App : Application
{
}
@@ -0,0 +1,48 @@
<Window x:Class="MijnBesluit.Agentvenster.Hoofdvenster"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="MijnBesluit uitrolagent" Height="620" Width="860"
Icon="agentvenster.ico"
WindowStartupLocation="CenterScreen" Background="#FAF7F2"
Closing="Venster_Closing">
<Grid Margin="16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<StackPanel Grid.Row="0" Margin="0,0,0,12">
<TextBlock Text="MijnBesluit uitrolagent" FontSize="22" FontWeight="Bold" Foreground="#2A2724" />
<TextBlock x:Name="Doelregel" Foreground="#6B645C" Margin="0,4,0,0" TextWrapping="Wrap" />
</StackPanel>
<!-- Eén regel die van een afstand leesbaar is. Dit venster staat op een server waar je
niet naar zit te kijken; als je er langsloopt wil je in één oogopslag weten of hij
staat te wachten of bezig is. -->
<Border Grid.Row="1" x:Name="Standvak" Background="#EEF2DA" BorderBrush="#C1D13F"
BorderThickness="1" CornerRadius="6" Padding="12,10" Margin="0,0,0,12">
<TextBlock x:Name="Standregel" FontSize="15" FontWeight="Bold" Foreground="#3A3E1E"
Text="Bezig met opstarten…" TextWrapping="Wrap" />
</Border>
<StackPanel Grid.Row="2" Orientation="Horizontal" Margin="0,0,0,12">
<Button x:Name="KnopWacht" Content="Stoppen met wachten" Padding="14,7"
Click="KnopWacht_Click" />
<Button x:Name="KnopLeeg" Content="Logboek leegmaken" Padding="14,7" Margin="10,0,0,0"
Click="KnopLeeg_Click" />
</StackPanel>
<Border Grid.Row="3" BorderBrush="#E6E0D7" BorderThickness="1" Background="White" CornerRadius="6">
<TextBox x:Name="Logvenster" IsReadOnly="True" BorderThickness="0" Padding="10"
FontFamily="Consolas" FontSize="12"
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled"
TextWrapping="Wrap" AcceptsReturn="True" />
</Border>
<ProgressBar Grid.Row="4" x:Name="Balk" Height="10" Margin="0,12,0,0" />
</Grid>
</Window>
@@ -0,0 +1,230 @@
using System.IO;
using System.Windows;
using System.Windows.Media;
using MijnBesluit.Uitrolagent;
namespace MijnBesluit.Agentvenster;
/// <summary>
/// Het venster op de SERVER. Eén ding: staan wachten tot er vanaf de ontwikkelmachine een
/// opdracht binnenkomt, en laten zien wat er dan gebeurt.
///
/// Waarom dit er is: het wachten kon al met MijnBesluit.Uitrolagent.exe --wacht, maar dan zit je
/// met een consolevenster dat je eerst moet openen en dat blijft staan tot je het wegklikt. Op
/// een server die je via een externe-bureaubladverbinding bedient is dat net het soort venster
/// dat je per ongeluk sluit of niet terugvindt. Dit is hetzelfde, maar zichtbaar: je
/// dubbelklikt, hij staat te wachten, en je ziet de uitrol regel voor regel voorbijkomen -
/// precies zoals het uitrolvenster dat op je eigen pc doet.
///
/// Er wordt hier niets nagebouwd. De lus is dezelfde <see cref="Wachtstand"/> als in de console;
/// dit venster geeft hem alleen een andere plek om zijn regels naartoe te schrijven.
/// </summary>
public partial class Hoofdvenster : Window
{
private readonly Agentinstellingen? _instellingen;
private CancellationTokenSource? _stopper;
private Task? _lus;
private bool _bezig;
/// <summary>
/// Zoveel regels houden we vast. Dit venster kan dagen open blijven staan, en een tekstvak
/// dat almaar aangroeit wordt op een gegeven moment traag - juist op het moment dat je erin
/// wilt kijken.
/// </summary>
private const int MaximaalRegels = 4000;
public Hoofdvenster()
{
InitializeComponent();
var pad = Path.Combine(AppContext.BaseDirectory, "uitrolagent.json");
Agentinstellingen gelezen;
try
{
gelezen = Agentinstellingen.Lees(pad, Meld);
}
catch (Exception ex)
{
Doelregel.Text = pad;
Stand("Het instellingenbestand is niet te lezen.", true);
Meld($"{pad} kon niet worden gelezen: {ex.Message}", true);
Meld("Herstel het bestand en start dit venster opnieuw.", false);
KnopWacht.IsEnabled = false;
return;
}
if (!File.Exists(pad))
{
// Niet blokkeren: zonder bestand gelden de standaardwaarden, en die kloppen op een
// gewone installatie vaak gewoon. Maar wél zeggen, want als de paden er straks naast
// zitten is dít de reden.
Meld($"Let op: {pad} staat er niet. Ik gebruik de standaardinstellingen.", true);
}
_instellingen = gelezen;
// Hardop zeggen waar hij zijn instellingen vandaan haalt. Publiceren zet uitrolagent.json
// terug op de versie uit de projectmap; wat je niet kwijt wilt, hoort in het geheimenbestand
// dat daar per sleutel overheen gaat. Die twee regels hieronder zijn het verschil tussen
// "hij pakt mijn wachtwoord niet" en zien waaróm.
Meld($"Instellingen: {pad}", false);
if (!string.IsNullOrWhiteSpace(gelezen.ExtraConfigFile))
Meld(File.Exists(gelezen.ExtraConfigFile)
? $"Aangevuld uit {gelezen.ExtraConfigFile}."
: $"Er is geen {gelezen.ExtraConfigFile}; alles komt uit het bestand hierboven.",
false);
Doelregel.Text = $"Kijkt in: {gelezen.Opdrachtmap} • Scripts: {gelezen.Scriptmap}";
if (!Directory.Exists(gelezen.Scriptmap))
Meld($"Let op: de scriptmap {gelezen.Scriptmap} bestaat niet. " +
"Een uitrol zal daarop stuklopen.", true);
Loaded += (_, _) => Beginnen();
}
// ------------------------------------------------------------------ de lus
/// <summary>
/// Begint met wachten. Gebeurt vanzelf bij het openen: je start dit venster omdat je gaat
/// uitrollen, dus nog een knop indrukken voegt niets toe.
/// </summary>
private void Beginnen()
{
if (_instellingen is null || _lus is not null) return;
_stopper = new CancellationTokenSource();
var teken = _stopper.Token;
KnopWacht.Content = "Stoppen met wachten";
Stand("Wacht op een opdracht van je pc.", false);
var wachtstand = new Wachtstand(_instellingen, Meld, Bezig);
// Op een eigen draad, zodat het venster blijft reageren terwijl de lus draait en een
// uitrol een half uur kan duren.
_lus = Task.Run(async () =>
{
try { await wachtstand.LoopAsync(teken); }
catch (OperationCanceledException) { }
catch (Exception ex) { Meld($"De agent is gestopt: {ex.Message}", true); }
}, teken);
_ = _lus.ContinueWith(_ => Dispatcher.Invoke(() =>
{
_lus = null;
Bezig(false);
KnopWacht.Content = "Wachten";
Stand("Staat stil. Er wordt niets opgepakt.", true);
}), TaskScheduler.Default);
}
private void Stoppen()
{
if (_lus is null) return;
Meld("Stoppen gevraagd.", false);
_stopper?.Cancel();
}
// ------------------------------------------------------------------ knoppen
private void KnopWacht_Click(object sender, RoutedEventArgs e)
{
if (_lus is null) Beginnen();
else Stoppen();
}
private void KnopLeeg_Click(object sender, RoutedEventArgs e)
{
Logvenster.Clear();
}
/// <summary>
/// Sluiten terwijl er een uitrol loopt is het ene geval waarin dit venster tegenspreekt. Een
/// halve uitrol laat een site achter die niet meer bij zijn database past, en dat merk je
/// pas als er iemand op de website zit.
/// </summary>
private void Venster_Closing(object sender, System.ComponentModel.CancelEventArgs e)
{
if (_bezig)
{
var antwoord = MessageBox.Show(
"Er loopt op dit moment een uitrol.\n\n" +
"Dit venster sluiten breekt hem af, en dan staat er straks misschien een halve " +
"versie op deze server.\n\nToch sluiten?",
"Er is nog iets bezig", MessageBoxButton.YesNo, MessageBoxImage.Warning,
MessageBoxResult.No);
if (antwoord != MessageBoxResult.Yes)
{
e.Cancel = true;
return;
}
}
_stopper?.Cancel();
}
// ------------------------------------------------------------------ meekijken
/// <summary>
/// Waar elke regel van de wachtstand binnenkomt. Komt van een andere draad, dus eerst terug
/// naar die van het venster.
/// </summary>
private void Meld(string tekst, bool isFout)
{
if (!Dispatcher.CheckAccess()) { Dispatcher.Invoke(() => Meld(tekst, isFout)); return; }
// Een lege regel krijgt geen tijdstempel. De PowerShell-scripts gebruiken witregels om hun
// uitvoer leesbaar te houden; met een klokje ervoor worden dat juist losse regels ruis.
Logvenster.AppendText(string.IsNullOrWhiteSpace(tekst)
? Environment.NewLine
: $"{DateTime.Now:HH:mm:ss} {(isFout ? "! " : " ")}{tekst}{Environment.NewLine}");
Inkorten();
Logvenster.ScrollToEnd();
}
/// <summary>Gooit de oudste regels weg als het er te veel worden. Zie MaximaalRegels.</summary>
private void Inkorten()
{
if (Logvenster.LineCount <= MaximaalRegels) return;
var vanaf = Logvenster.GetCharacterIndexFromLineIndex(Logvenster.LineCount - MaximaalRegels / 2);
if (vanaf > 0) Logvenster.Text = Logvenster.Text[vanaf..];
}
/// <summary>
/// Aan zodra er een opdracht wordt uitgevoerd, uit als hij klaar is. De balk loopt zolang er
/// iets gebeurt: uitrollen duurt minuten, en zonder teken van leven ga je je afvragen of hij
/// nog wel iets doet.
/// </summary>
private void Bezig(bool bezig)
{
if (!Dispatcher.CheckAccess()) { Dispatcher.Invoke(() => Bezig(bezig)); return; }
_bezig = bezig;
Balk.IsIndeterminate = bezig;
if (bezig) Stand("Bezig met uitrollen. Laat dit venster staan.", false);
else if (_lus is not null) Stand($"Wacht op een opdracht van je pc. " +
$"Laatste uitrol: {DateTime.Now:HH:mm:ss}.", false);
}
private void Stand(string tekst, bool isWaarschuwing)
{
if (!Dispatcher.CheckAccess()) { Dispatcher.Invoke(() => Stand(tekst, isWaarschuwing)); return; }
Standregel.Text = tekst;
Standvak.Background = isWaarschuwing ? new SolidColorBrush(Color.FromRgb(0xFA, 0xEF, 0xE2))
: new SolidColorBrush(Color.FromRgb(0xEE, 0xF2, 0xDA));
Standvak.BorderBrush = isWaarschuwing ? new SolidColorBrush(Color.FromRgb(0xD9, 0xA3, 0x4A))
: new SolidColorBrush(Color.FromRgb(0xC1, 0xD1, 0x3F));
Standregel.Foreground = isWaarschuwing ? new SolidColorBrush(Color.FromRgb(0x6B, 0x45, 0x12))
: new SolidColorBrush(Color.FromRgb(0x3A, 0x3E, 0x1E));
}
}
@@ -0,0 +1,47 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- Het agentvenster: hetzelfde wachten als de wachtstand van MijnBesluit.Uitrolagent.exe,
maar met een gezicht. Bedoeld voor de SERVER; het uitrolvenster staat op de
ontwikkelmachine.
Waarom een apart project en niet een venster in de agent zelf: die agent is een
consoleprogramma en moet dat blijven. Zet je daar WPF in, dan wordt het een WinExe zonder
console, en dan werken zijn schakelaars voor zip, luisteren en een nieuwe sleutel alleen
nog met kunstgrepen (AttachConsole en aanverwante ellende - zie de SocketServer). Zo staat
het los: de agent doet het werk, dit project kijkt mee.
Let op bij het aanpassen van deze uitleg: twee streepjes achter elkaar mogen niet in een
XML-commentaar. Schrijf schakelaars dus uit in woorden. Zelfcontrole "Geen projectbestand
met een onmogelijk commentaar" bewaakt dat. -->
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>MijnBesluit.Agentvenster</AssemblyName>
<RootNamespace>MijnBesluit.Agentvenster</RootNamespace>
<PlatformTarget>x64</PlatformTarget>
<ApplicationIcon>agentvenster.ico</ApplicationIcon>
<!-- Directory.Build.props zet InvariantGlobalization voor de hele solution aan. WPF kan daar
niet tegen: zonder ICU valt het venster om bij de eerste letter die het wil tekenen, en
omdat het een WinExe is zie je daar niets van - je dubbelklikt en er gebeurt niets.
Zie de gelijkluidende regel in MijnBesluit.Uitroller.csproj. -->
<InvariantGlobalization>false</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<!-- De agent zelf. Een verwijzing naar een Exe-project is gewoon toegestaan: .NET levert
daarvan ook een dll op. Zo draait hier dezelfde Wachtstand als in de console - niet een
tweede uitvoering die na een half jaar net iets anders doet. -->
<ProjectReference Include="..\MijnBesluit.Uitrolagent\MijnBesluit.Uitrolagent.csproj" />
</ItemGroup>
<ItemGroup>
<!-- Ook als Resource, zodat het venster hem in zijn eigen titelbalk kan zetten. -->
<Resource Include="agentvenster.ico" />
</ItemGroup>
</Project>
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

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

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

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

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