Eerste commit
This commit is contained in:
@@ -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 |
@@ -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
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<configuration>
|
||||||
|
<packageSources><clear /></packageSources>
|
||||||
|
</configuration>
|
||||||
@@ -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.
|
||||||
@@ -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;
|
||||||
@@ -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';
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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`;
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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`.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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>?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
|
||||||
@@ -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.
|
||||||
@@ -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`.
|
||||||
@@ -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.
|
||||||
@@ -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` — 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 — 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 — of de instelling verwijderen — 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 — 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.
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<Application x:Class="MijnBesluit.Agentvenster.App"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
StartupUri="Hoofdvenster.xaml">
|
||||||
|
</Application>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
namespace MijnBesluit.Agentvenster;
|
||||||
|
|
||||||
|
public partial class App : Application
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<Window x:Class="MijnBesluit.Agentvenster.Hoofdvenster"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
Title="MijnBesluit uitrolagent" Height="620" Width="860"
|
||||||
|
Icon="agentvenster.ico"
|
||||||
|
WindowStartupLocation="CenterScreen" Background="#FAF7F2"
|
||||||
|
Closing="Venster_Closing">
|
||||||
|
|
||||||
|
<Grid Margin="16">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="*" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<StackPanel Grid.Row="0" Margin="0,0,0,12">
|
||||||
|
<TextBlock Text="MijnBesluit uitrolagent" FontSize="22" FontWeight="Bold" Foreground="#2A2724" />
|
||||||
|
<TextBlock x:Name="Doelregel" Foreground="#6B645C" Margin="0,4,0,0" TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Eén regel die van een afstand leesbaar is. Dit venster staat op een server waar je
|
||||||
|
niet naar zit te kijken; als je er langsloopt wil je in één oogopslag weten of hij
|
||||||
|
staat te wachten of bezig is. -->
|
||||||
|
<Border Grid.Row="1" x:Name="Standvak" Background="#EEF2DA" BorderBrush="#C1D13F"
|
||||||
|
BorderThickness="1" CornerRadius="6" Padding="12,10" Margin="0,0,0,12">
|
||||||
|
<TextBlock x:Name="Standregel" FontSize="15" FontWeight="Bold" Foreground="#3A3E1E"
|
||||||
|
Text="Bezig met opstarten…" TextWrapping="Wrap" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<StackPanel Grid.Row="2" Orientation="Horizontal" Margin="0,0,0,12">
|
||||||
|
<Button x:Name="KnopWacht" Content="Stoppen met wachten" Padding="14,7"
|
||||||
|
Click="KnopWacht_Click" />
|
||||||
|
<Button x:Name="KnopLeeg" Content="Logboek leegmaken" Padding="14,7" Margin="10,0,0,0"
|
||||||
|
Click="KnopLeeg_Click" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Border Grid.Row="3" BorderBrush="#E6E0D7" BorderThickness="1" Background="White" CornerRadius="6">
|
||||||
|
<TextBox x:Name="Logvenster" IsReadOnly="True" BorderThickness="0" Padding="10"
|
||||||
|
FontFamily="Consolas" FontSize="12"
|
||||||
|
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled"
|
||||||
|
TextWrapping="Wrap" AcceptsReturn="True" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<ProgressBar Grid.Row="4" x:Name="Balk" Height="10" Margin="0,12,0,0" />
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using MijnBesluit.Uitrolagent;
|
||||||
|
|
||||||
|
namespace MijnBesluit.Agentvenster;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Het venster op de SERVER. Eén ding: staan wachten tot er vanaf de ontwikkelmachine een
|
||||||
|
/// opdracht binnenkomt, en laten zien wat er dan gebeurt.
|
||||||
|
///
|
||||||
|
/// Waarom dit er is: het wachten kon al met MijnBesluit.Uitrolagent.exe --wacht, maar dan zit je
|
||||||
|
/// met een consolevenster dat je eerst moet openen en dat blijft staan tot je het wegklikt. Op
|
||||||
|
/// een server die je via een externe-bureaubladverbinding bedient is dat net het soort venster
|
||||||
|
/// dat je per ongeluk sluit of niet terugvindt. Dit is hetzelfde, maar zichtbaar: je
|
||||||
|
/// dubbelklikt, hij staat te wachten, en je ziet de uitrol regel voor regel voorbijkomen -
|
||||||
|
/// precies zoals het uitrolvenster dat op je eigen pc doet.
|
||||||
|
///
|
||||||
|
/// Er wordt hier niets nagebouwd. De lus is dezelfde <see cref="Wachtstand"/> als in de console;
|
||||||
|
/// dit venster geeft hem alleen een andere plek om zijn regels naartoe te schrijven.
|
||||||
|
/// </summary>
|
||||||
|
public partial class Hoofdvenster : Window
|
||||||
|
{
|
||||||
|
private readonly Agentinstellingen? _instellingen;
|
||||||
|
private CancellationTokenSource? _stopper;
|
||||||
|
private Task? _lus;
|
||||||
|
private bool _bezig;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Zoveel regels houden we vast. Dit venster kan dagen open blijven staan, en een tekstvak
|
||||||
|
/// dat almaar aangroeit wordt op een gegeven moment traag - juist op het moment dat je erin
|
||||||
|
/// wilt kijken.
|
||||||
|
/// </summary>
|
||||||
|
private const int MaximaalRegels = 4000;
|
||||||
|
|
||||||
|
public Hoofdvenster()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
var pad = Path.Combine(AppContext.BaseDirectory, "uitrolagent.json");
|
||||||
|
|
||||||
|
Agentinstellingen gelezen;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
gelezen = Agentinstellingen.Lees(pad, Meld);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Doelregel.Text = pad;
|
||||||
|
Stand("Het instellingenbestand is niet te lezen.", true);
|
||||||
|
Meld($"{pad} kon niet worden gelezen: {ex.Message}", true);
|
||||||
|
Meld("Herstel het bestand en start dit venster opnieuw.", false);
|
||||||
|
KnopWacht.IsEnabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!File.Exists(pad))
|
||||||
|
{
|
||||||
|
// Niet blokkeren: zonder bestand gelden de standaardwaarden, en die kloppen op een
|
||||||
|
// gewone installatie vaak gewoon. Maar wél zeggen, want als de paden er straks naast
|
||||||
|
// zitten is dít de reden.
|
||||||
|
Meld($"Let op: {pad} staat er niet. Ik gebruik de standaardinstellingen.", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
_instellingen = gelezen;
|
||||||
|
|
||||||
|
// Hardop zeggen waar hij zijn instellingen vandaan haalt. Publiceren zet uitrolagent.json
|
||||||
|
// terug op de versie uit de projectmap; wat je niet kwijt wilt, hoort in het geheimenbestand
|
||||||
|
// dat daar per sleutel overheen gaat. Die twee regels hieronder zijn het verschil tussen
|
||||||
|
// "hij pakt mijn wachtwoord niet" en zien waaróm.
|
||||||
|
Meld($"Instellingen: {pad}", false);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(gelezen.ExtraConfigFile))
|
||||||
|
Meld(File.Exists(gelezen.ExtraConfigFile)
|
||||||
|
? $"Aangevuld uit {gelezen.ExtraConfigFile}."
|
||||||
|
: $"Er is geen {gelezen.ExtraConfigFile}; alles komt uit het bestand hierboven.",
|
||||||
|
false);
|
||||||
|
|
||||||
|
Doelregel.Text = $"Kijkt in: {gelezen.Opdrachtmap} • Scripts: {gelezen.Scriptmap}";
|
||||||
|
|
||||||
|
if (!Directory.Exists(gelezen.Scriptmap))
|
||||||
|
Meld($"Let op: de scriptmap {gelezen.Scriptmap} bestaat niet. " +
|
||||||
|
"Een uitrol zal daarop stuklopen.", true);
|
||||||
|
|
||||||
|
Loaded += (_, _) => Beginnen();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ de lus
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Begint met wachten. Gebeurt vanzelf bij het openen: je start dit venster omdat je gaat
|
||||||
|
/// uitrollen, dus nog een knop indrukken voegt niets toe.
|
||||||
|
/// </summary>
|
||||||
|
private void Beginnen()
|
||||||
|
{
|
||||||
|
if (_instellingen is null || _lus is not null) return;
|
||||||
|
|
||||||
|
_stopper = new CancellationTokenSource();
|
||||||
|
var teken = _stopper.Token;
|
||||||
|
|
||||||
|
KnopWacht.Content = "Stoppen met wachten";
|
||||||
|
Stand("Wacht op een opdracht van je pc.", false);
|
||||||
|
|
||||||
|
var wachtstand = new Wachtstand(_instellingen, Meld, Bezig);
|
||||||
|
|
||||||
|
// Op een eigen draad, zodat het venster blijft reageren terwijl de lus draait en een
|
||||||
|
// uitrol een half uur kan duren.
|
||||||
|
_lus = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try { await wachtstand.LoopAsync(teken); }
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
|
catch (Exception ex) { Meld($"De agent is gestopt: {ex.Message}", true); }
|
||||||
|
}, teken);
|
||||||
|
|
||||||
|
_ = _lus.ContinueWith(_ => Dispatcher.Invoke(() =>
|
||||||
|
{
|
||||||
|
_lus = null;
|
||||||
|
Bezig(false);
|
||||||
|
KnopWacht.Content = "Wachten";
|
||||||
|
Stand("Staat stil. Er wordt niets opgepakt.", true);
|
||||||
|
}), TaskScheduler.Default);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Stoppen()
|
||||||
|
{
|
||||||
|
if (_lus is null) return;
|
||||||
|
|
||||||
|
Meld("Stoppen gevraagd.", false);
|
||||||
|
_stopper?.Cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ knoppen
|
||||||
|
|
||||||
|
private void KnopWacht_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_lus is null) Beginnen();
|
||||||
|
else Stoppen();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void KnopLeeg_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
Logvenster.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sluiten terwijl er een uitrol loopt is het ene geval waarin dit venster tegenspreekt. Een
|
||||||
|
/// halve uitrol laat een site achter die niet meer bij zijn database past, en dat merk je
|
||||||
|
/// pas als er iemand op de website zit.
|
||||||
|
/// </summary>
|
||||||
|
private void Venster_Closing(object sender, System.ComponentModel.CancelEventArgs e)
|
||||||
|
{
|
||||||
|
if (_bezig)
|
||||||
|
{
|
||||||
|
var antwoord = MessageBox.Show(
|
||||||
|
"Er loopt op dit moment een uitrol.\n\n" +
|
||||||
|
"Dit venster sluiten breekt hem af, en dan staat er straks misschien een halve " +
|
||||||
|
"versie op deze server.\n\nToch sluiten?",
|
||||||
|
"Er is nog iets bezig", MessageBoxButton.YesNo, MessageBoxImage.Warning,
|
||||||
|
MessageBoxResult.No);
|
||||||
|
|
||||||
|
if (antwoord != MessageBoxResult.Yes)
|
||||||
|
{
|
||||||
|
e.Cancel = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_stopper?.Cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ meekijken
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Waar elke regel van de wachtstand binnenkomt. Komt van een andere draad, dus eerst terug
|
||||||
|
/// naar die van het venster.
|
||||||
|
/// </summary>
|
||||||
|
private void Meld(string tekst, bool isFout)
|
||||||
|
{
|
||||||
|
if (!Dispatcher.CheckAccess()) { Dispatcher.Invoke(() => Meld(tekst, isFout)); return; }
|
||||||
|
|
||||||
|
// Een lege regel krijgt geen tijdstempel. De PowerShell-scripts gebruiken witregels om hun
|
||||||
|
// uitvoer leesbaar te houden; met een klokje ervoor worden dat juist losse regels ruis.
|
||||||
|
Logvenster.AppendText(string.IsNullOrWhiteSpace(tekst)
|
||||||
|
? Environment.NewLine
|
||||||
|
: $"{DateTime.Now:HH:mm:ss} {(isFout ? "! " : " ")}{tekst}{Environment.NewLine}");
|
||||||
|
Inkorten();
|
||||||
|
Logvenster.ScrollToEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Gooit de oudste regels weg als het er te veel worden. Zie MaximaalRegels.</summary>
|
||||||
|
private void Inkorten()
|
||||||
|
{
|
||||||
|
if (Logvenster.LineCount <= MaximaalRegels) return;
|
||||||
|
|
||||||
|
var vanaf = Logvenster.GetCharacterIndexFromLineIndex(Logvenster.LineCount - MaximaalRegels / 2);
|
||||||
|
if (vanaf > 0) Logvenster.Text = Logvenster.Text[vanaf..];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Aan zodra er een opdracht wordt uitgevoerd, uit als hij klaar is. De balk loopt zolang er
|
||||||
|
/// iets gebeurt: uitrollen duurt minuten, en zonder teken van leven ga je je afvragen of hij
|
||||||
|
/// nog wel iets doet.
|
||||||
|
/// </summary>
|
||||||
|
private void Bezig(bool bezig)
|
||||||
|
{
|
||||||
|
if (!Dispatcher.CheckAccess()) { Dispatcher.Invoke(() => Bezig(bezig)); return; }
|
||||||
|
|
||||||
|
_bezig = bezig;
|
||||||
|
Balk.IsIndeterminate = bezig;
|
||||||
|
|
||||||
|
if (bezig) Stand("Bezig met uitrollen. Laat dit venster staan.", false);
|
||||||
|
else if (_lus is not null) Stand($"Wacht op een opdracht van je pc. " +
|
||||||
|
$"Laatste uitrol: {DateTime.Now:HH:mm:ss}.", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Stand(string tekst, bool isWaarschuwing)
|
||||||
|
{
|
||||||
|
if (!Dispatcher.CheckAccess()) { Dispatcher.Invoke(() => Stand(tekst, isWaarschuwing)); return; }
|
||||||
|
|
||||||
|
Standregel.Text = tekst;
|
||||||
|
|
||||||
|
Standvak.Background = isWaarschuwing ? new SolidColorBrush(Color.FromRgb(0xFA, 0xEF, 0xE2))
|
||||||
|
: new SolidColorBrush(Color.FromRgb(0xEE, 0xF2, 0xDA));
|
||||||
|
Standvak.BorderBrush = isWaarschuwing ? new SolidColorBrush(Color.FromRgb(0xD9, 0xA3, 0x4A))
|
||||||
|
: new SolidColorBrush(Color.FromRgb(0xC1, 0xD1, 0x3F));
|
||||||
|
Standregel.Foreground = isWaarschuwing ? new SolidColorBrush(Color.FromRgb(0x6B, 0x45, 0x12))
|
||||||
|
: new SolidColorBrush(Color.FromRgb(0x3A, 0x3E, 0x1E));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<!-- Het agentvenster: hetzelfde wachten als de wachtstand van MijnBesluit.Uitrolagent.exe,
|
||||||
|
maar met een gezicht. Bedoeld voor de SERVER; het uitrolvenster staat op de
|
||||||
|
ontwikkelmachine.
|
||||||
|
|
||||||
|
Waarom een apart project en niet een venster in de agent zelf: die agent is een
|
||||||
|
consoleprogramma en moet dat blijven. Zet je daar WPF in, dan wordt het een WinExe zonder
|
||||||
|
console, en dan werken zijn schakelaars voor zip, luisteren en een nieuwe sleutel alleen
|
||||||
|
nog met kunstgrepen (AttachConsole en aanverwante ellende - zie de SocketServer). Zo staat
|
||||||
|
het los: de agent doet het werk, dit project kijkt mee.
|
||||||
|
|
||||||
|
Let op bij het aanpassen van deze uitleg: twee streepjes achter elkaar mogen niet in een
|
||||||
|
XML-commentaar. Schrijf schakelaars dus uit in woorden. Zelfcontrole "Geen projectbestand
|
||||||
|
met een onmogelijk commentaar" bewaakt dat. -->
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net8.0-windows</TargetFramework>
|
||||||
|
<UseWPF>true</UseWPF>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<AssemblyName>MijnBesluit.Agentvenster</AssemblyName>
|
||||||
|
<RootNamespace>MijnBesluit.Agentvenster</RootNamespace>
|
||||||
|
<PlatformTarget>x64</PlatformTarget>
|
||||||
|
|
||||||
|
<ApplicationIcon>agentvenster.ico</ApplicationIcon>
|
||||||
|
|
||||||
|
<!-- Directory.Build.props zet InvariantGlobalization voor de hele solution aan. WPF kan daar
|
||||||
|
niet tegen: zonder ICU valt het venster om bij de eerste letter die het wil tekenen, en
|
||||||
|
omdat het een WinExe is zie je daar niets van - je dubbelklikt en er gebeurt niets.
|
||||||
|
Zie de gelijkluidende regel in MijnBesluit.Uitroller.csproj. -->
|
||||||
|
<InvariantGlobalization>false</InvariantGlobalization>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- De agent zelf. Een verwijzing naar een Exe-project is gewoon toegestaan: .NET levert
|
||||||
|
daarvan ook een dll op. Zo draait hier dezelfde Wachtstand als in de console - niet een
|
||||||
|
tweede uitvoering die na een half jaar net iets anders doet. -->
|
||||||
|
<ProjectReference Include="..\MijnBesluit.Uitrolagent\MijnBesluit.Uitrolagent.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Ook als Resource, zodat het venster hem in zijn eigen titelbalk kan zetten. -->
|
||||||
|
<Resource Include="agentvenster.ico" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<Application xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||||
|
x:Class="MijnBesluit.App.App">
|
||||||
|
</Application>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using MijnBesluit.AppUi;
|
||||||
|
|
||||||
|
namespace MijnBesluit.App;
|
||||||
|
|
||||||
|
public partial class App : Application
|
||||||
|
{
|
||||||
|
private readonly Terugknop _terugknop;
|
||||||
|
|
||||||
|
public App(Terugknop terugknop)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
_terugknop = terugknop;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Window CreateWindow(IActivationState? activationState)
|
||||||
|
{
|
||||||
|
var venster = new Window(new HoofdPagina(_terugknop)) { Title = "MijnBesluit" };
|
||||||
|
|
||||||
|
// Android verbreekt de socketverbinding zodra de app een tijdje op de achtergrond staat -
|
||||||
|
// bijvoorbeeld terwijl iemand zijn e-mail leest om de inlogcode op te halen. Bij het
|
||||||
|
// terugkomen halen we de gegevens opnieuw op, zodat de melding "geen verbinding"
|
||||||
|
// verdwijnt zonder dat de gebruiker ergens op hoeft te drukken.
|
||||||
|
venster.Resumed += async (_, _) =>
|
||||||
|
{
|
||||||
|
var diensten = IPlatformApplication.Current?.Services;
|
||||||
|
|
||||||
|
// Eerst kijken of de app zichzelf had moeten uitloggen terwijl hij in de achtergrond
|
||||||
|
// stond. Dat is het geval waar automatisch uitloggen voor bedoeld is - een telefoon
|
||||||
|
// die een uur in een tas zit - en het hoort te gebeuren vóór we gegevens ophalen.
|
||||||
|
diensten?.GetService<Uitlogklok>()?.Hervatten();
|
||||||
|
|
||||||
|
var toestand = diensten?.GetService<AppToestand>();
|
||||||
|
if (toestand is { IsAangemeld: true }) await toestand.VerversAsync();
|
||||||
|
};
|
||||||
|
|
||||||
|
return venster;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
@namespace MijnBesluit.App
|
||||||
|
|
||||||
|
@* De ingang van de app. De schermen zelf staan in MijnBesluit.AppUi, zodat website en app
|
||||||
|
dezelfde blijven; hier wordt alleen verteld waar ze te vinden zijn. *@
|
||||||
|
|
||||||
|
<Router AppAssembly="@typeof(Hoofdscherm).Assembly"
|
||||||
|
AdditionalAssemblies="new[] { typeof(MijnBesluit.AppUi.Schermen.Start).Assembly }">
|
||||||
|
<Found Context="routeData">
|
||||||
|
<RouteView RouteData="@routeData" DefaultLayout="@typeof(AppSchil)" />
|
||||||
|
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
|
||||||
|
</Found>
|
||||||
|
<NotFound>
|
||||||
|
<LayoutView Layout="@typeof(AppSchil)">
|
||||||
|
<div class="leeg">
|
||||||
|
<span class="teken">🤔</span>
|
||||||
|
Dit scherm bestaat niet. Ga terug naar het <a href="/">overzicht</a>.
|
||||||
|
</div>
|
||||||
|
</LayoutView>
|
||||||
|
</NotFound>
|
||||||
|
</Router>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
@using Microsoft.AspNetCore.Components.Routing
|
||||||
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
|
@using MijnBesluit.App
|
||||||
|
@using MijnBesluit.AppUi
|
||||||
|
@using MijnBesluit.AppUi.Layout
|
||||||
|
@using MijnBesluit.AppUi.Schermen
|
||||||
|
@using MijnBesluit.Client
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||||
|
xmlns:b="clr-namespace:Microsoft.AspNetCore.Components.WebView.Maui;assembly=Microsoft.AspNetCore.Components.WebView.Maui"
|
||||||
|
xmlns:local="clr-namespace:MijnBesluit.App"
|
||||||
|
x:Class="MijnBesluit.App.HoofdPagina"
|
||||||
|
BackgroundColor="#faf7f2">
|
||||||
|
|
||||||
|
<!-- De hele app is één webview met de Blazor-schermen erin. Dezelfde schermen als op
|
||||||
|
de website, zodat een wijziging maar op één plek hoeft. -->
|
||||||
|
<b:BlazorWebView x:Name="webview" HostPage="wwwroot/index.html">
|
||||||
|
<b:BlazorWebView.RootComponents>
|
||||||
|
<b:RootComponent Selector="#app" ComponentType="{x:Type local:Hoofdscherm}" />
|
||||||
|
</b:BlazorWebView.RootComponents>
|
||||||
|
</b:BlazorWebView>
|
||||||
|
|
||||||
|
</ContentPage>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using MijnBesluit.AppUi;
|
||||||
|
|
||||||
|
namespace MijnBesluit.App;
|
||||||
|
|
||||||
|
public partial class HoofdPagina : ContentPage
|
||||||
|
{
|
||||||
|
private readonly Terugknop _terugknop;
|
||||||
|
|
||||||
|
public HoofdPagina(Terugknop terugknop)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
_terugknop = terugknop;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// De terugknop van het toestel. Android stuurt hem hierheen; geven we false terug, dan doet
|
||||||
|
/// het toestel wat het altijd deed en gaat de app dicht.
|
||||||
|
///
|
||||||
|
/// De schermen bepalen zelf wat terug betekent - een popup dicht, terug naar het overzicht -
|
||||||
|
/// via <see cref="Terugknop"/>. Hier staat alleen de doorgifte, want de webview zelf weet
|
||||||
|
/// niets van de knoppen van het toestel.
|
||||||
|
/// </summary>
|
||||||
|
protected override bool OnBackButtonPressed() => _terugknop.Ingedrukt();
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using MijnBesluit.AppUi;
|
||||||
|
using MijnBesluit.Client;
|
||||||
|
|
||||||
|
namespace MijnBesluit.App;
|
||||||
|
|
||||||
|
public static class MauiProgram
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Waar de SocketServer draait. Voor het testen op een emulator wijst 10.0.2.2 naar je
|
||||||
|
/// eigen pc; op een echt toestel vul je hier de servernaam in.
|
||||||
|
/// </summary>
|
||||||
|
public const string Server = "mijnbesluit.nl";
|
||||||
|
public const int Poort = 5200;
|
||||||
|
|
||||||
|
public static MauiApp CreateMauiApp()
|
||||||
|
{
|
||||||
|
var builder = MauiApp.CreateBuilder();
|
||||||
|
builder.UseMauiApp<App>();
|
||||||
|
|
||||||
|
builder.Services.AddMauiBlazorWebView();
|
||||||
|
|
||||||
|
// Eén verbinding en één sessie voor de hele app: alle schermen delen hem.
|
||||||
|
builder.Services.AddSingleton<ITokenopslag, ToestelTokenopslag>();
|
||||||
|
builder.Services.AddSingleton(sp => new MijnBesluitClient(
|
||||||
|
Server, Poort, tls: true, sp.GetRequiredService<ITokenopslag>()));
|
||||||
|
builder.Services.AddSingleton(sp => new AppToestand(
|
||||||
|
sp.GetRequiredService<MijnBesluitClient>(), sp.GetRequiredService<IMeldingen>()));
|
||||||
|
|
||||||
|
// Het kiezen van bestanden en het opnemen van video: dat kan de webview niet zelf.
|
||||||
|
builder.Services.AddSingleton<IBestandskiezer, Toestelbestanden>();
|
||||||
|
|
||||||
|
// Een advertentie opent bij de adverteerder, in de browser van de telefoon en niet in de
|
||||||
|
// app. Ook dat kan de webview niet zelf.
|
||||||
|
builder.Services.AddSingleton<IBuitenlink, Toestelbuitenlink>();
|
||||||
|
|
||||||
|
// De terugknop van het toestel. Eén exemplaar voor de hele app: de pagina geeft de druk
|
||||||
|
// erin, de schermen in de webview melden zich erop aan.
|
||||||
|
builder.Services.AddSingleton<Terugknop>();
|
||||||
|
|
||||||
|
// Het "trek naar beneden om te verversen"-gebaar: de schil meldt hier dat er is
|
||||||
|
// getrokken, het scherm dat op dat moment openstaat luistert en haalt zijn gegevens
|
||||||
|
// opnieuw op. Zie VernieuwSignaal.
|
||||||
|
builder.Services.AddSingleton<VernieuwSignaal>();
|
||||||
|
|
||||||
|
// Het slot van het toestel: vingerafdruk, gezicht of de code van de telefoon zelf.
|
||||||
|
builder.Services.AddSingleton<IToestelslot, Toestelslot>();
|
||||||
|
|
||||||
|
// Instellingen die alleen op dit toestel gelden, en de klok die de app zichzelf laat
|
||||||
|
// vergrendelen. Eén exemplaar, want de schil bewaakt hem en het instellingenscherm zet hem.
|
||||||
|
//
|
||||||
|
// De standaardtermijn hangt af van het toestel: kan de telefoon om een vingerafdruk of
|
||||||
|
// code vragen, dan gaat de klok standaard aan - dan kost vergeten je één aanraking.
|
||||||
|
// Kan dat niet, dan kost vergeten je een e-mail met een code, en dat leggen we niemand
|
||||||
|
// ongevraagd op.
|
||||||
|
builder.Services.AddSingleton<IAppinstellingen, Toestelinstellingen>();
|
||||||
|
builder.Services.AddSingleton(sp => new Uitlogklok(
|
||||||
|
sp.GetRequiredService<IAppinstellingen>(),
|
||||||
|
sp.GetRequiredService<IToestelslot>().Beschikbaar ? Uitlogklok.StandaardMetSlot : 0,
|
||||||
|
sp.GetRequiredService<IToestelslot>().Beschikbaar));
|
||||||
|
|
||||||
|
// Meldingen op het toestel. Zonder Firebase levert dit niets op en werkt de app
|
||||||
|
// gewoon door; zie docs/Meldingen.md.
|
||||||
|
builder.Services.AddSingleton<IMeldingen, Toestelmeldingen>();
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
builder.Services.AddBlazorWebViewDeveloperTools();
|
||||||
|
#endif
|
||||||
|
|
||||||
|
return builder.Build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Razor">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<!-- De schermen zijn voor allebei dezelfde; alleen wat het toestel zelf doet verschilt.
|
||||||
|
Zie Platforms\ en de #if-blokken in Toestelslot.cs en Toestelmeldingen.cs.
|
||||||
|
|
||||||
|
iOS staat standaard UIT, en dat is geen halfheid maar de enige werkbare stand. Zodra
|
||||||
|
net10.0-ios in deze lijst staat, wil elke build - ook een build van alleen de APK - de
|
||||||
|
iOS-werkbelasting en een Mac hebben. Op een machine zonder die twee struikelt dan het
|
||||||
|
maken van de Android-app over iets wat er niets mee te maken heeft.
|
||||||
|
|
||||||
|
Aanzetten doe je per keer:
|
||||||
|
|
||||||
|
dotnet build -p:MetIos=true
|
||||||
|
tools\Apk-maken.ps1 (blijft gewoon Android, hier verandert niets)
|
||||||
|
|
||||||
|
of blijvend, door MetIos in een Directory.Build.props of in de omgeving te zetten op de
|
||||||
|
machine die wél aan een Mac hangt. Zie docs/iOS-app.md. -->
|
||||||
|
<TargetFrameworks>net10.0-android</TargetFrameworks>
|
||||||
|
<TargetFrameworks Condition="'$(MetIos)' == 'true'">$(TargetFrameworks);net10.0-ios</TargetFrameworks>
|
||||||
|
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<RootNamespace>MijnBesluit.App</RootNamespace>
|
||||||
|
<UseMaui>true</UseMaui>
|
||||||
|
<SingleProject>true</SingleProject>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
|
||||||
|
<ApplicationTitle>MijnBesluit</ApplicationTitle>
|
||||||
|
<ApplicationId>nl.mijnbesluit.app</ApplicationId>
|
||||||
|
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
|
||||||
|
<ApplicationVersion>1</ApplicationVersion>
|
||||||
|
|
||||||
|
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">24.0</SupportedOSPlatformVersion>
|
||||||
|
|
||||||
|
<!-- Op papier kan MAUI vanaf iOS 12.2, maar onder 13 zijn er meldingen van apps die op een
|
||||||
|
echt toestel omvallen, en 15.0 is wat het sjabloon zelf gebruikt. Dat draait op een
|
||||||
|
iPhone 6s en nieuwer - toestellen uit 2015. Lager gaan levert je een handvol toestellen
|
||||||
|
op en een soort fouten dat je niet kunt nabootsen. -->
|
||||||
|
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">15.0</SupportedOSPlatformVersion>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Het echte logo. Het pictogram is alleen het witte woordmerk op een doorzichtige
|
||||||
|
achtergrond, met het logogroen eronder: zo maakt Android er zelf een rond, vierkant of
|
||||||
|
druppelvormig pictogram van zonder dat er letters afvallen. Het woordmerk staat daarom
|
||||||
|
op zeventig procent - alles buiten die cirkel kan een toestel wegsnijden.
|
||||||
|
Het startscherm laat het hele logo zien op de rustige achtergrondkleur. -->
|
||||||
|
<MauiIcon Include="Resources\AppIcon\appicon.png" Color="#C1D13F" />
|
||||||
|
<MauiSplashScreen Include="Resources\Splash\splash.png" Color="#faf7f2" BaseSize="128,128" />
|
||||||
|
<MauiFont Include="Resources\Fonts\*" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Sinds .NET 8 brengt <UseMaui>true</UseMaui> deze packages niet meer vanzelf mee.
|
||||||
|
$(MauiVersion) volgt de werkbelasting die op deze machine staat, dus hier hoeft geen
|
||||||
|
versienummer met de hand bijgehouden te worden. -->
|
||||||
|
<PackageReference Include="Microsoft.Maui.Controls" Version="$(MauiVersion)" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebView.Maui" Version="$(MauiVersion)" />
|
||||||
|
|
||||||
|
<ProjectReference Include="..\MijnBesluit.AppUi\MijnBesluit.AppUi.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Meldingen op de telefoon, via Firebase.
|
||||||
|
|
||||||
|
Dit blok doet niets zolang google-services.json er niet staat, en gaat vanzelf aan zodra
|
||||||
|
je hem uit de Firebase-console in Platforms\Android zet. Zo is er geen stap "en vergeet
|
||||||
|
het csproj niet aan te passen" die je een keer overslaat, en blijft het project bouwen op
|
||||||
|
een machine waar dat bestand niet staat.
|
||||||
|
|
||||||
|
De schakelaar FIREBASE hoort bij de #if in Toestelmeldingen.cs: zonder het package is die
|
||||||
|
code niet te compileren, dus die staat uit tot het package er is.
|
||||||
|
|
||||||
|
Zie docs/Meldingen.md. -->
|
||||||
|
<PropertyGroup Condition="Exists('$(MSBuildProjectDirectory)\Platforms\Android\google-services.json')">
|
||||||
|
<DefineConstants>$(DefineConstants);FIREBASE</DefineConstants>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android' AND Exists('$(MSBuildProjectDirectory)\Platforms\Android\google-services.json')">
|
||||||
|
<!-- Binding op com.google.firebase:firebase-messaging. Het versienummer moet passen bij de
|
||||||
|
werkbelasting op deze machine; 125.1.1.1 hoort bij net10.0-android. Klaagt de build over
|
||||||
|
versies, kijk dan in NuGet welke bij jouw .NET-versie hoort. -->
|
||||||
|
<PackageReference Include="Xamarin.Firebase.Messaging" Version="125.1.1.1" />
|
||||||
|
|
||||||
|
<!-- Firebase's afhankelijkheden pinnen oudere AndroidX .Ktx-packages, terwijl MAUI de
|
||||||
|
bijbehorende basispackages al nieuwer binnenhaalt. Dat gaf niet alleen de NU1608-
|
||||||
|
waarschuwingen, maar ook een harde fout bij het dexen: sinds AndroidX Fragment 1.9.0
|
||||||
|
zitten de Kotlin-uitbreidingen in het hoofdpackage zelf, dus kwam bijvoorbeeld
|
||||||
|
androidx.fragment.app.FragmentManagerKt twee keer binnen: een keer uit fragment.aar
|
||||||
|
en een keer uit het oude fragment-ktx.aar.
|
||||||
|
|
||||||
|
Door deze vijf expliciet op de versie te zetten die bij het al opgeloste basispackage
|
||||||
|
hoort, zijn de .Ktx-packages leeg (ze verwijzen alleen nog naar het hoofdpackage) en
|
||||||
|
is de dubbele klasse weg. Verhoogt een nieuwere MAUI de basispackages, dan komen de
|
||||||
|
NU1608-waarschuwingen terug; dat is het signaal om deze nummers mee te verhogen, niet
|
||||||
|
om het blok weg te halen. De nummers komen uit NuGet: Fragment.Ktx 1.9.0 bestaat,
|
||||||
|
Collection.Ktx 1.6.0.1, en de drie Lifecycle-packages op 2.11.0.1. -->
|
||||||
|
<PackageReference Include="Xamarin.AndroidX.Fragment.Ktx" Version="1.9.0" />
|
||||||
|
<PackageReference Include="Xamarin.AndroidX.Collection.Ktx" Version="1.6.0.1" />
|
||||||
|
<PackageReference Include="Xamarin.AndroidX.Lifecycle.LiveData" Version="2.11.0.1" />
|
||||||
|
<PackageReference Include="Xamarin.AndroidX.Lifecycle.LiveData.Core.Ktx" Version="2.11.0.1" />
|
||||||
|
<PackageReference Include="Xamarin.AndroidX.Lifecycle.Process" Version="2.11.0.1" />
|
||||||
|
<GoogleServicesJson Include="Platforms\Android\google-services.json" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<application android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/appicon"
|
||||||
|
android:roundIcon="@mipmap/appicon_round"
|
||||||
|
android:supportsRtl="true">
|
||||||
|
|
||||||
|
<!-- Op welk kanaal een melding terechtkomt die binnenkomt terwijl de app dicht is. Zonder
|
||||||
|
deze regel maakt Firebase daar zelf een kanaal "Overig" voor aan, en staat dezelfde
|
||||||
|
soort melding dus op twee plekken in de instellingen van de telefoon. De naam hoort
|
||||||
|
gelijk te zijn aan Meldingsdienst.Kanaal. -->
|
||||||
|
<meta-data android:name="com.google.firebase.messaging.default_notification_channel_id"
|
||||||
|
android:value="mijnbesluit-berichten" />
|
||||||
|
</application>
|
||||||
|
|
||||||
|
<!-- Om de app weer te openen nadat hij zichzelf heeft vergrendeld. Dit geeft geen toegang
|
||||||
|
tot vingerafdrukken: de app krijgt alleen ja of nee terug van het toestel. -->
|
||||||
|
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||||
|
|
||||||
|
<!-- De app praat met de SocketServer. -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
|
<!-- Camera en galerij, voor foto's bij een kluisitem en video's bij een moment.
|
||||||
|
Hier stond eerder bewust niets: hoe minder rechten, hoe minder er mis kan gaan. Maar
|
||||||
|
zonder CAMERA meldt Android dat het toestel niet kan opnemen, en verdwijnt de knop
|
||||||
|
"Video opnemen" uit de app zonder dat er iets misgaat of iets over te zien is.
|
||||||
|
|
||||||
|
Wat er NIET bij staat en ook niet bij hoort: locatie, contacten, en het schrijven naar
|
||||||
|
de opslag van de telefoon. We lezen alleen wat de gebruiker zelf aanwijst. -->
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||||
|
|
||||||
|
<!-- Voor Android 12 en ouder; vanaf 13 doen de twee regels hierboven het werk. -->
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="32" />
|
||||||
|
|
||||||
|
<!-- Niet verplicht: een toestel zonder camera mag de app gewoon installeren, dan valt
|
||||||
|
alleen de opnameknop weg. -->
|
||||||
|
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using Android.App;
|
||||||
|
using Android.Content.PM;
|
||||||
|
using Android.OS;
|
||||||
|
|
||||||
|
namespace MijnBesluit.App;
|
||||||
|
|
||||||
|
[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true,
|
||||||
|
LaunchMode = LaunchMode.SingleTop,
|
||||||
|
ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation |
|
||||||
|
ConfigChanges.UiMode | ConfigChanges.ScreenLayout |
|
||||||
|
ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
|
||||||
|
public class MainActivity : MauiAppCompatActivity
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using Android.App;
|
||||||
|
using Android.Runtime;
|
||||||
|
|
||||||
|
namespace MijnBesluit.App;
|
||||||
|
|
||||||
|
[Application]
|
||||||
|
public class MainApplication : MauiApplication
|
||||||
|
{
|
||||||
|
public MainApplication(IntPtr handle, JniHandleOwnership ownership) : base(handle, ownership) { }
|
||||||
|
|
||||||
|
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
|
||||||
|
|
||||||
|
public override void OnCreate()
|
||||||
|
{
|
||||||
|
base.OnCreate();
|
||||||
|
|
||||||
|
#if FIREBASE
|
||||||
|
// Het meldingenkanaal moet er zijn voordat de eerste melding binnenkomt, ook als die
|
||||||
|
// binnenkomt terwijl de app dicht is. Hier en niet in de dienst zelf, want dit is het
|
||||||
|
// eerste wat draait.
|
||||||
|
Meldingsdienst.MaakKanaal(this);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
#if FIREBASE
|
||||||
|
using Android.App;
|
||||||
|
using Android.Content;
|
||||||
|
using AndroidX.Core.App;
|
||||||
|
using Firebase.Messaging;
|
||||||
|
|
||||||
|
namespace MijnBesluit.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Vangt meldingen op die binnenkomen terwijl de app openstaat.
|
||||||
|
///
|
||||||
|
/// Android zet een melding alleen zelf in de balk als de app op de achtergrond staat. Staat hij
|
||||||
|
/// open, dan geeft Android hem door aan deze dienst en gebeurt er verder niets - tenzij we hem
|
||||||
|
/// hier zelf tonen. Dat was precies het geval waarin de proefmelding "verstuurd" heette en er
|
||||||
|
/// op de telefoon niets te zien was.
|
||||||
|
///
|
||||||
|
/// De tekst komt uit het data-deel van het bericht en niet uit het notification-deel. Beide
|
||||||
|
/// staan erin (zie FirebasePush), maar het data-deel is het stuk waar de naamgeving in de
|
||||||
|
/// Android-binding niet per versie verschilt.
|
||||||
|
/// </summary>
|
||||||
|
[Service(Exported = false)]
|
||||||
|
[IntentFilter(new[] { "com.google.firebase.MESSAGING_EVENT" })]
|
||||||
|
public class Meldingsdienst : FirebaseMessagingService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Het kanaal waarop deze meldingen binnenkomen. Vanaf Android 8 hoort elke melding bij een
|
||||||
|
/// kanaal, en kan de gebruiker per kanaal zeggen hoe opdringerig het mag zijn. De naam staat
|
||||||
|
/// ook in AndroidManifest.xml, zodat meldingen die binnenkomen terwijl de app dicht is op
|
||||||
|
/// hetzelfde kanaal terechtkomen en niet onder "Overig".
|
||||||
|
/// </summary>
|
||||||
|
public const string Kanaal = "mijnbesluit-berichten";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maakt het kanaal aan als het er nog niet is. Mag zo vaak aangeroepen worden als je wilt;
|
||||||
|
/// Android werkt een bestaand kanaal dan alleen bij. Gebeurt bij het starten van de app, want
|
||||||
|
/// een melding die binnenkomt terwijl de app dicht is heeft het kanaal al nodig.
|
||||||
|
/// </summary>
|
||||||
|
public static void MaakKanaal(Context context)
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsAndroidVersionAtLeast(26)) return;
|
||||||
|
|
||||||
|
var kanaal = new NotificationChannel(Kanaal, "Berichten", NotificationImportance.High)
|
||||||
|
{
|
||||||
|
Description = "Laat weten dat er iets voor je klaarstaat. Wat erin staat komt er nooit bij te staan."
|
||||||
|
};
|
||||||
|
|
||||||
|
var beheer = (NotificationManager?)context.GetSystemService(Context.NotificationService);
|
||||||
|
beheer?.CreateNotificationChannel(kanaal);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void OnMessageReceived(RemoteMessage bericht)
|
||||||
|
{
|
||||||
|
var gegevens = bericht.Data;
|
||||||
|
|
||||||
|
gegevens.TryGetValue("titel", out var titel);
|
||||||
|
gegevens.TryGetValue("tekst", out var tekst);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(titel)) titel = "MijnBesluit";
|
||||||
|
if (string.IsNullOrWhiteSpace(tekst)) tekst = "Er staat iets voor je klaar.";
|
||||||
|
|
||||||
|
Toon(this, titel, tekst);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Firebase geeft een toestel af en toe een nieuw kenmerk. Het oude werkt dan niet meer.
|
||||||
|
/// De app meldt zich bij elke aanmelding opnieuw aan met het kenmerk van dat moment, dus dat
|
||||||
|
/// herstelt zichzelf zodra je weer inlogt. Zolang je ingelogd blijft kan er een melding
|
||||||
|
/// tussendoor vallen; dat is bewust niet hier opgelost, want hier is geen verbinding met de
|
||||||
|
/// server en geen sleutel om mee te praten.
|
||||||
|
/// </summary>
|
||||||
|
public override void OnNewToken(string kenmerk) => base.OnNewToken(kenmerk);
|
||||||
|
|
||||||
|
private static void Toon(Context context, string titel, string tekst)
|
||||||
|
{
|
||||||
|
MaakKanaal(context);
|
||||||
|
|
||||||
|
// Aantikken opent de app. SingleTop, zodat je niet een tweede keer hetzelfde scherm
|
||||||
|
// opent bovenop het scherm waar je al was.
|
||||||
|
var openen = new Intent(context, typeof(MainActivity));
|
||||||
|
openen.AddFlags(ActivityFlags.ClearTop | ActivityFlags.SingleTop);
|
||||||
|
|
||||||
|
var tik = PendingIntent.GetActivity(context, 0, openen,
|
||||||
|
PendingIntentFlags.UpdateCurrent | PendingIntentFlags.Immutable);
|
||||||
|
|
||||||
|
// ApplicationInfo.Icon en niet een eigen tekening: dat is een nummer dat Android zelf
|
||||||
|
// bijhoudt, dus hier hoeft geen tweede plaatje bijgehouden te worden dat kan ontbreken.
|
||||||
|
var melding = new NotificationCompat.Builder(context, Kanaal)
|
||||||
|
.SetContentTitle(titel)
|
||||||
|
.SetContentText(tekst)
|
||||||
|
.SetStyle(new NotificationCompat.BigTextStyle().BigText(tekst))
|
||||||
|
.SetSmallIcon(context.ApplicationInfo!.Icon)
|
||||||
|
.SetAutoCancel(true)
|
||||||
|
.SetPriority((int)NotificationPriority.High)
|
||||||
|
.SetContentIntent(tik)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
// Elk bericht een eigen nummer, anders vervangt de tweede melding de eerste.
|
||||||
|
NotificationManagerCompat.From(context).Notify(Volgnummer(), melding);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int _laatste;
|
||||||
|
private static int Volgnummer() => Interlocked.Increment(ref _laatste);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"project_info": {
|
||||||
|
"project_number": "1047194010071",
|
||||||
|
"project_id": "mijnbesluit-f04b5",
|
||||||
|
"storage_bucket": "mijnbesluit-f04b5.firebasestorage.app"
|
||||||
|
},
|
||||||
|
"client": [
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:1047194010071:android:815622efe5c7b59a084b92",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "nl.mijnbesluit.app"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "AIzaSyDPDKCc6tQm8Juh9LwvvWlxmUFTrMCU4So"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"appinvite_service": {
|
||||||
|
"other_platform_oauth_client": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"configuration_version": "1"
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using Foundation;
|
||||||
|
|
||||||
|
namespace MijnBesluit.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// De iOS-kant van het opstarten. Tegenhanger van MainApplication op Android: hij doet niets
|
||||||
|
/// meer dan dezelfde MauiProgram aanroepen, want alles wat de app ís staat in gedeelde code.
|
||||||
|
/// </summary>
|
||||||
|
[Register("AppDelegate")]
|
||||||
|
public class AppDelegate : MauiUIApplicationDelegate
|
||||||
|
{
|
||||||
|
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>LSRequiresIPhoneOS</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<key>UIDeviceFamily</key>
|
||||||
|
<array>
|
||||||
|
<!-- 1 = iPhone, 2 = iPad. De schermen zijn gemaakt voor een telefoon, maar een iPad
|
||||||
|
weigeren levert alleen een gebruiker op die het niet snapt. -->
|
||||||
|
<integer>1</integer>
|
||||||
|
<integer>2</integer>
|
||||||
|
</array>
|
||||||
|
|
||||||
|
<key>UIRequiredDeviceCapabilities</key>
|
||||||
|
<array>
|
||||||
|
<string>arm64</string>
|
||||||
|
</array>
|
||||||
|
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
|
||||||
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
|
||||||
|
<key>XSAppIconAssets</key>
|
||||||
|
<string>Assets.xcassets/appicon.appiconset</string>
|
||||||
|
|
||||||
|
<!-- ==========================================================================
|
||||||
|
Waarvoor de app iets van het toestel vraagt.
|
||||||
|
|
||||||
|
Deze teksten zijn geen formaliteit. iOS laat ze letterlijk zien op het moment
|
||||||
|
dat de app het vraagt, en als de tekst ontbreekt sluit iOS de app ter plekke
|
||||||
|
af - geen foutmelding, hij is gewoon weg. Dat is de klassieke "hij doet het
|
||||||
|
op Android wel"-val.
|
||||||
|
|
||||||
|
Ze staan in het Nederlands omdat de app Nederlands is; iemand die hier "ja"
|
||||||
|
op moet zeggen, hoort te kunnen lezen waarvoor.
|
||||||
|
========================================================================== -->
|
||||||
|
|
||||||
|
<key>NSFaceIDUsageDescription</key>
|
||||||
|
<string>Om MijnBesluit te openen zonder telkens een code uit je mail te halen. Je gezicht wordt door je telefoon gecontroleerd; MijnBesluit krijgt het nooit te zien.</string>
|
||||||
|
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>Om een foto of video te maken die je in je kluis bewaart of als bericht meestuurt.</string>
|
||||||
|
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>Om het geluid op te nemen bij een videobericht.</string>
|
||||||
|
|
||||||
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
|
<string>Om een foto of document uit je fotobibliotheek toe te voegen aan je kluis of aan een bericht.</string>
|
||||||
|
|
||||||
|
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||||
|
<string>Om een foto uit MijnBesluit op te slaan in je fotobibliotheek.</string>
|
||||||
|
|
||||||
|
<!-- ==========================================================================
|
||||||
|
BEWUST NOG NIET INGEVULD: ITSAppUsesNonExemptEncryption.
|
||||||
|
|
||||||
|
Apple vraagt bij elke inzending of de app encryptie gebruikt die buiten de
|
||||||
|
vrijstellingen valt. Zet je hier `false`, dan verklaar je dat dat niet zo is -
|
||||||
|
en dat is een verklaring met juridisch gewicht, geen vinkje. Deze app
|
||||||
|
versleutelt een kluis met gegevens, dus dat antwoord is niet vanzelfsprekend
|
||||||
|
"nee" zoals bij een app die alleen HTTPS gebruikt.
|
||||||
|
|
||||||
|
Laat dit staan zoals het is: zonder deze sleutel vraagt App Store Connect het
|
||||||
|
bij elke inzending, en dan beantwoord je het bewust. Wil je er vanaf, laat het
|
||||||
|
dan eerst nakijken door iemand die deze regels kent, en zet het antwoord er
|
||||||
|
daarna bij met de datum en de reden erbij in dit bestand.
|
||||||
|
|
||||||
|
Wat hier NIET hoort: een uitzondering op App Transport Security. De verbinding
|
||||||
|
met de SocketServer is een eigen TLS-socket en geen URL-verkeer, dus ATS gaat
|
||||||
|
er niet over. Een uitzondering toevoegen die je niet nodig hebt, levert alleen
|
||||||
|
vragen op bij de beoordeling.
|
||||||
|
========================================================================== -->
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using ObjCRuntime;
|
||||||
|
using UIKit;
|
||||||
|
|
||||||
|
namespace MijnBesluit.App;
|
||||||
|
|
||||||
|
/// <summary>Het startpunt op iOS. Android heeft dit niet nodig; daar start Android de Activity.</summary>
|
||||||
|
public class Program
|
||||||
|
{
|
||||||
|
private static void Main(string[] args)
|
||||||
|
{
|
||||||
|
// Vanaf hier neemt UIKit het over en roept het AppDelegate aan.
|
||||||
|
UIApplication.Main(args, null, typeof(AppDelegate));
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,36 @@
|
|||||||
|
using MijnBesluit.Client;
|
||||||
|
|
||||||
|
namespace MijnBesluit.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bewaart het sessietoken in de beveiligde opslag van het toestel: op Android is dat de
|
||||||
|
/// Keystore, op iOS de Keychain. Dus niet in een gewoon bestand, want daar kan een andere
|
||||||
|
/// app of iemand met het toestel in handen bij.
|
||||||
|
/// </summary>
|
||||||
|
public class ToestelTokenopslag : ITokenopslag
|
||||||
|
{
|
||||||
|
private const string Sleutel = "mijnbesluit-sessie";
|
||||||
|
|
||||||
|
public async Task<string?> LeesAsync()
|
||||||
|
{
|
||||||
|
try { return await SecureStorage.Default.GetAsync(Sleutel); }
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Op sommige toestellen is de beveiligde opslag stuk na een herstel uit back-up.
|
||||||
|
// Dan is er gewoon geen sessie en logt de gebruiker opnieuw in.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SchrijfAsync(string token)
|
||||||
|
{
|
||||||
|
try { await SecureStorage.Default.SetAsync(Sleutel, token); }
|
||||||
|
catch { /* Niet kunnen onthouden is vervelend, maar geen reden om de app te laten vallen. */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task WisAsync()
|
||||||
|
{
|
||||||
|
SecureStorage.Default.Remove(Sleutel);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
using Microsoft.Maui.Graphics;
|
||||||
|
using Microsoft.Maui.Graphics.Platform;
|
||||||
|
using MijnBesluit.AppUi;
|
||||||
|
|
||||||
|
namespace MijnBesluit.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Het kiezen en maken van bestanden op een Android- of iOS-toestel, met de kiezers die MAUI
|
||||||
|
/// daarvoor meelevert. De schermen zien hier niets van; die kennen alleen
|
||||||
|
/// <see cref="IBestandskiezer"/>.
|
||||||
|
///
|
||||||
|
/// Foto's worden hier verkleind voordat ze de deur uit gaan. Een toestelcamera maakt tegenwoordig
|
||||||
|
/// bestanden van tien megabyte of meer, en die gaan in stukken van een halve megabyte over de
|
||||||
|
/// lijn - op een mobiele verbinding duurt dat te lang voor iets waar je alleen maar naar kijkt.
|
||||||
|
/// Naar 1600 pixels terugbrengen scheelt een factor tien tot twintig en is op een telefoon niet
|
||||||
|
/// te zien. Video's blijven zoals ze zijn: opnieuw coderen kost meer tijd dan het oplevert.
|
||||||
|
/// </summary>
|
||||||
|
public class Toestelbestanden : IBestandskiezer
|
||||||
|
{
|
||||||
|
/// <summary>De langste zijde van een foto die wordt verstuurd.</summary>
|
||||||
|
public const int MaxZijde = 1600;
|
||||||
|
|
||||||
|
/// <summary>Hoe hard er op de foto mag worden ingeleverd. 0.8 is ruim voldoende om te tonen.</summary>
|
||||||
|
private const float Kwaliteit = 0.8f;
|
||||||
|
|
||||||
|
/// <summary>Onder deze grootte is verkleinen de moeite niet waard.</summary>
|
||||||
|
private const int VerkleinVanaf = 400 * 1024;
|
||||||
|
|
||||||
|
public bool KanCamera => MediaPicker.Default.IsCaptureSupported;
|
||||||
|
|
||||||
|
public async Task<GekozenBestand?> KiesDocumentAsync()
|
||||||
|
{
|
||||||
|
var gekozen = await FilePicker.Default.PickAsync(new PickOptions
|
||||||
|
{
|
||||||
|
PickerTitle = "Kies een document"
|
||||||
|
});
|
||||||
|
|
||||||
|
return await LeesAsync(gekozen, "application/octet-stream");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<GekozenBestand?> MaakFotoAsync()
|
||||||
|
{
|
||||||
|
if (!MediaPicker.Default.IsCaptureSupported) return null;
|
||||||
|
|
||||||
|
// De camera geeft een naam van tweeëndertig tekens zonder betekenis. Die staat straks in
|
||||||
|
// je kluis en zegt je niets; het moment waarop je de foto maakte wel.
|
||||||
|
return await LeesAsync(await MediaPicker.Default.CapturePhotoAsync(), "image/jpeg",
|
||||||
|
Eigennaam("Foto", ".jpg"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<GekozenBestand?> KiesFotoAsync()
|
||||||
|
{
|
||||||
|
// PickPhotosAsync in plaats van PickPhotoAsync: die laatste is vervallen. We nemen de
|
||||||
|
// eerste foto; meerdere tegelijk kiezen komt later als het nodig blijkt.
|
||||||
|
var gekozen = await MediaPicker.Default.PickPhotosAsync();
|
||||||
|
return await LeesAsync(gekozen?.FirstOrDefault(), "image/jpeg");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<GekozenBestand?> NeemVideoOpAsync()
|
||||||
|
{
|
||||||
|
if (!MediaPicker.Default.IsCaptureSupported) return null;
|
||||||
|
return await LeesAsync(await MediaPicker.Default.CaptureVideoAsync(), "video/mp4",
|
||||||
|
Eigennaam("Video", ".mp4"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<GekozenBestand?> KiesVideoAsync()
|
||||||
|
{
|
||||||
|
// PickVideosAsync in plaats van PickVideoAsync, om dezelfde reden als bij de foto's: die
|
||||||
|
// laatste is vervallen. We nemen de eerste video; meerdere tegelijk kiezen komt later als
|
||||||
|
// het nodig blijkt.
|
||||||
|
var gekozen = await MediaPicker.Default.PickVideosAsync();
|
||||||
|
return await LeesAsync(gekozen?.FirstOrDefault(), "video/mp4");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Leest het gekozen bestand helemaal in. Dat kan omdat er een grens op de grootte staat;
|
||||||
|
/// zonder die grens zou een lange video-opname het geheugen van de app opmaken.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<GekozenBestand?> LeesAsync(FileResult? gekozen, string standaardSoort,
|
||||||
|
string? naam = null)
|
||||||
|
{
|
||||||
|
if (gekozen is null) return null;
|
||||||
|
|
||||||
|
using var stroom = await gekozen.OpenReadAsync();
|
||||||
|
using var buffer = new MemoryStream();
|
||||||
|
await stroom.CopyToAsync(buffer);
|
||||||
|
|
||||||
|
var soort = string.IsNullOrWhiteSpace(gekozen.ContentType) ? standaardSoort : gekozen.ContentType;
|
||||||
|
var inhoud = buffer.ToArray();
|
||||||
|
|
||||||
|
if (soort.StartsWith("image/", StringComparison.OrdinalIgnoreCase) && inhoud.Length > VerkleinVanaf)
|
||||||
|
inhoud = Verklein(inhoud) ?? inhoud;
|
||||||
|
|
||||||
|
return new GekozenBestand(naam ?? Bruikbaar(gekozen.FileName), soort, inhoud);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Een naam waar je later nog iets aan hebt: "Foto 15-09-2026 14.10". De tijd erbij, want op
|
||||||
|
/// één dag maak je er zo drie en dan wil je ze uit elkaar kunnen houden.
|
||||||
|
/// </summary>
|
||||||
|
private static string Eigennaam(string soort, string extensie) =>
|
||||||
|
// De punt tussen uren en minuten staat tussen enkele aanhalingstekens. Dat is hoe een
|
||||||
|
// opmaakpatroon in .NET zegt "letterlijk dit teken"; een backslash zou hier een
|
||||||
|
// ongeldige ontsnappingsreeks in C# zijn, en een kale punt hoort bij .fff.
|
||||||
|
$"{soort} {DateTime.Now:dd-MM-yyyy HH'.'mm}{extensie}";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Een naam die het toestel zelf aandraagt, tenzij die nergens op slaat. Een galerij geeft
|
||||||
|
/// soms alleen een lange reeks tekens terug; daar is een datum beter dan.
|
||||||
|
/// </summary>
|
||||||
|
private static string Bruikbaar(string? naam)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(naam)) return Eigennaam("Bestand", "");
|
||||||
|
|
||||||
|
var kaal = Path.GetFileNameWithoutExtension(naam);
|
||||||
|
var extensie = Path.GetExtension(naam);
|
||||||
|
|
||||||
|
// Twintig tekens of meer, en geen enkele klinker of streepje: dat is geen naam maar een
|
||||||
|
// kenmerk. Denk aan 824c9d46a1b749ab9f92986b.
|
||||||
|
var onleesbaar = kaal.Length >= 20 && kaal.All(char.IsLetterOrDigit)
|
||||||
|
&& !kaal.Any(c => "aeiouAEIOU".Contains(c));
|
||||||
|
|
||||||
|
return onleesbaar ? Eigennaam("Bestand", extensie) : naam;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Brengt een foto terug tot <see cref="MaxZijde"/> pixels op de langste zijde.
|
||||||
|
///
|
||||||
|
/// Gaat er iets mis - een bestand dat geen afbeelding blijkt, een formaat dat het toestel
|
||||||
|
/// niet kent - dan geven we null terug en gaat het origineel gewoon de deur uit. Een foto
|
||||||
|
/// die traag verstuurt is vervelend; een foto die helemaal niet aankomt is erger.
|
||||||
|
/// </summary>
|
||||||
|
private static byte[]? Verklein(byte[] inhoud)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var bron = new MemoryStream(inhoud);
|
||||||
|
using var afbeelding = PlatformImage.FromStream(bron);
|
||||||
|
|
||||||
|
// Downsize doet niets als de foto al kleiner is; dan zijn we alsnog klaar, want het
|
||||||
|
// opnieuw opslaan levert bij die grootte weinig op.
|
||||||
|
if (afbeelding.Width <= MaxZijde && afbeelding.Height <= MaxZijde) return null;
|
||||||
|
|
||||||
|
using var kleiner = afbeelding.Downsize(MaxZijde, disposeOriginal: false);
|
||||||
|
using var uit = new MemoryStream();
|
||||||
|
kleiner.Save(uit, ImageFormat.Jpeg, Kwaliteit);
|
||||||
|
|
||||||
|
var verkleind = uit.ToArray();
|
||||||
|
|
||||||
|
// Alleen als het echt scheelt. Een enkele keer is het resultaat groter dan het
|
||||||
|
// origineel; dan is het origineel de betere keus.
|
||||||
|
return verkleind.Length > 0 && verkleind.Length < inhoud.Length ? verkleind : null;
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using MijnBesluit.AppUi;
|
||||||
|
|
||||||
|
namespace MijnBesluit.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opent een adres in de browser van de telefoon. Bewust buiten de app: een advertentielink die
|
||||||
|
/// binnen de webview opent maakt van MijnBesluit een browser, en dan staat iemand op de website
|
||||||
|
/// van een adverteerder zonder weg terug naar zijn eigen kluis.
|
||||||
|
/// </summary>
|
||||||
|
public class Toestelbuitenlink : IBuitenlink
|
||||||
|
{
|
||||||
|
public async Task OpenAsync(string adres)
|
||||||
|
{
|
||||||
|
if (!Uri.TryCreate(adres, UriKind.Absolute, out var adresje)) return;
|
||||||
|
|
||||||
|
// Alleen http en https. Een ander schema kan een andere app openen, en dat is niet wat
|
||||||
|
// hier hoort te gebeuren.
|
||||||
|
if (adresje.Scheme != Uri.UriSchemeHttp && adresje.Scheme != Uri.UriSchemeHttps) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Browser.Default.OpenAsync(adresje, BrowserLaunchMode.SystemPreferred);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Geen browser op dit toestel, of de gebruiker brak het af. Dat is geen reden om de
|
||||||
|
// app te laten vallen.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using MijnBesluit.AppUi;
|
||||||
|
|
||||||
|
namespace MijnBesluit.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Instellingen die alleen op dit toestel gelden, in de gewone voorkeursopslag van Android.
|
||||||
|
///
|
||||||
|
/// Bewust niet in de Keystore: daar hoort alleen in wat geheim is. Hoe lang het duurt voordat
|
||||||
|
/// de app zichzelf uitlogt is geen geheim, en de Keystore is traag en kan stuk na een herstel
|
||||||
|
/// uit back-up.
|
||||||
|
/// </summary>
|
||||||
|
public class Toestelinstellingen : IAppinstellingen
|
||||||
|
{
|
||||||
|
public int Lees(string sleutel, int standaard)
|
||||||
|
{
|
||||||
|
try { return Preferences.Default.Get(sleutel, standaard); }
|
||||||
|
catch { return standaard; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Schrijf(string sleutel, int waarde)
|
||||||
|
{
|
||||||
|
try { Preferences.Default.Set(sleutel, waarde); }
|
||||||
|
catch { /* Niet kunnen onthouden is vervelend, maar geen reden om de app te laten vallen. */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
using MijnBesluit.AppUi;
|
||||||
|
|
||||||
|
#if ANDROID && FIREBASE
|
||||||
|
// AsAsync() maakt van een Task van Google Play Services een gewone await-bare Task. Het is een
|
||||||
|
// uitbreidingsmethode, dus zonder deze using bestaat hij niet en zegt de compiler alleen dat
|
||||||
|
// "Task" hem niet kent. Staat achter dezelfde schakelaar als de code die hem gebruikt: zonder
|
||||||
|
// het Firebase-package bestaat deze naamruimte niet.
|
||||||
|
using Android.Gms.Extensions;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace MijnBesluit.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Haalt het Firebase-kenmerk van dit toestel op.
|
||||||
|
///
|
||||||
|
/// Let op: de regels hieronder met <c>#if ANDROID</c> werken pas als het package
|
||||||
|
/// <c>Xamarin.Firebase.Messaging</c> in MijnBesluit.App zit én <c>google-services.json</c>
|
||||||
|
/// in Platforms\Android staat. Zolang dat er niet is, geeft deze klasse null terug en werkt
|
||||||
|
/// de app gewoon - alleen zonder meldingen. Zie docs/Meldingen.md.
|
||||||
|
///
|
||||||
|
/// Op iOS is dat nu ook zo: er komt geen kenmerk en dus geen melding, en de rest van de app
|
||||||
|
/// werkt gewoon. Dat is met opzet geen half werkende poging. Meldingen op iOS lopen via APNs,
|
||||||
|
/// en daarvoor is meer nodig dan code: een APNs-sleutel uit het Apple Developer-account, de
|
||||||
|
/// Push Notifications-capability, een provisioning profile en een Entitlements.plist. Dat hangt
|
||||||
|
/// aan het account en niet aan dit bestand, dus het komt erbij zodra dat account er is.
|
||||||
|
/// Zie docs/iOS-app.md.
|
||||||
|
/// </summary>
|
||||||
|
public class Toestelmeldingen : IMeldingen
|
||||||
|
{
|
||||||
|
public string Platform =>
|
||||||
|
#if IOS
|
||||||
|
"ios";
|
||||||
|
#else
|
||||||
|
"android";
|
||||||
|
#endif
|
||||||
|
|
||||||
|
public string? Toestelnaam => DeviceInfo.Current.Name;
|
||||||
|
|
||||||
|
public async Task<string?> KenmerkAsync()
|
||||||
|
{
|
||||||
|
// Android 13 en hoger vraagt apart toestemming voor meldingen. Zonder die toestemming
|
||||||
|
// komt er wel een kenmerk, maar ziet de gebruiker nooit iets.
|
||||||
|
if (!await ToestemmingAsync()) return null;
|
||||||
|
|
||||||
|
#if ANDROID && FIREBASE
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// GetToken staat in deze versie van de binding als verouderd gemarkeerd, maar het is
|
||||||
|
// wel de methode die werkt en waar Google zelf naar verwijst. Zodra er een opvolger
|
||||||
|
// is die het kenmerk teruggeeft, hoort die hier te komen.
|
||||||
|
var taak = Firebase.Messaging.FirebaseMessaging.Instance.GetToken();
|
||||||
|
var kenmerk = await taak.AsAsync<Java.Lang.String>();
|
||||||
|
return kenmerk?.ToString();
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Geen Google Play-diensten op dit toestel, of Firebase niet ingericht.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
return null;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<bool> ToestemmingAsync()
|
||||||
|
{
|
||||||
|
#if ANDROID
|
||||||
|
if (!OperatingSystem.IsAndroidVersionAtLeast(33)) return true;
|
||||||
|
|
||||||
|
var stand = await Permissions.CheckStatusAsync<Permissions.PostNotifications>();
|
||||||
|
if (stand != PermissionStatus.Granted)
|
||||||
|
stand = await Permissions.RequestAsync<Permissions.PostNotifications>();
|
||||||
|
|
||||||
|
return stand == PermissionStatus.Granted;
|
||||||
|
#else
|
||||||
|
return await Task.FromResult(true);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
using MijnBesluit.AppUi;
|
||||||
|
|
||||||
|
#if ANDROID
|
||||||
|
using Android.App;
|
||||||
|
using Android.Content;
|
||||||
|
using Android.Hardware.Biometrics;
|
||||||
|
using Android.OS;
|
||||||
|
#elif IOS
|
||||||
|
using LocalAuthentication;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace MijnBesluit.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Vraagt het toestel om iemand te controleren: vingerafdruk, gezicht, of de pincode of het
|
||||||
|
/// patroon van de telefoon zelf.
|
||||||
|
///
|
||||||
|
/// Wat hier NIET gebeurt: de app vraagt geen eigen pincode en bewaart er ook geen. Er is dus
|
||||||
|
/// niets bij te houden, niets te vergeten en niets te lekken. De telefoon doet de controle in
|
||||||
|
/// een apart stuk hardware waar geen enkele app bij kan, en geeft alleen ja of nee terug.
|
||||||
|
///
|
||||||
|
/// Vanaf Android 10. Daarvoor bestaat dit venster nog niet in de vorm waarin het ook de code
|
||||||
|
/// van de telefoon accepteert, en dan zou een vingerafdrukloze telefoon er niet meer in komen.
|
||||||
|
/// Op oudere toestellen is <see cref="Beschikbaar"/> dus onwaar en logt de app uit zoals eerst.
|
||||||
|
///
|
||||||
|
/// Op iOS doet LocalAuthentication hetzelfde, met dezelfde afspraak: Face ID of Touch ID, en
|
||||||
|
/// de toestelcode als terugval. Dat laatste zit daar in de keuze van het beleid
|
||||||
|
/// (DeviceOwnerAuthentication) in plaats van in een aparte schakelaar.
|
||||||
|
/// </summary>
|
||||||
|
public class Toestelslot : IToestelslot
|
||||||
|
{
|
||||||
|
#if ANDROID
|
||||||
|
public bool Beschikbaar
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
// Zonder schermslot heeft vergrendelen geen zin: dan komt iedereen die de telefoon
|
||||||
|
// in handen heeft er met één veeg weer in, en is uitloggen de enige echte grendel.
|
||||||
|
if (!OperatingSystem.IsAndroidVersionAtLeast(29)) return false;
|
||||||
|
|
||||||
|
var bewaking = (KeyguardManager?)Platform.AppContext.GetSystemService(Context.KeyguardService);
|
||||||
|
return bewaking?.IsDeviceSecure == true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<bool> OntgrendelAsync(string titel, string uitleg)
|
||||||
|
{
|
||||||
|
if (!Beschikbaar) return Task.FromResult(false);
|
||||||
|
|
||||||
|
var activiteit = Platform.CurrentActivity;
|
||||||
|
if (activiteit is null) return Task.FromResult(false);
|
||||||
|
|
||||||
|
var klaar = new TaskCompletionSource<bool>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// SetDeviceCredentialAllowed is vanaf Android 11 als verouderd gemarkeerd, maar het
|
||||||
|
// is de enige manier die vanaf Android 10 werkt én de code van de telefoon als
|
||||||
|
// terugval toelaat. De opvolger (SetAllowedAuthenticators) bestaat pas vanaf 11.
|
||||||
|
// Zolang de app vanaf 10 werkt, hoort dit hier te staan.
|
||||||
|
var venster = new BiometricPrompt.Builder(activiteit)
|
||||||
|
.SetTitle(titel)
|
||||||
|
.SetDescription(uitleg)
|
||||||
|
.SetDeviceCredentialAllowed(true)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
// Geen annuleersein dat we zelf afbreken: het venster heeft zijn eigen knop, en de
|
||||||
|
// uitkomst daarvan komt via OnAuthenticationError binnen.
|
||||||
|
venster.Authenticate(new CancellationSignal(), activiteit.MainExecutor!, new Antwoord(klaar));
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Een toestel dat hier struikelt hoort de app niet te laten vallen. Het slot blijft
|
||||||
|
// dan dicht en je kunt nog altijd helemaal uitloggen.
|
||||||
|
klaar.TrySetResult(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return klaar.Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Wat het venster terugmeldt.</summary>
|
||||||
|
private sealed class Antwoord : BiometricPrompt.AuthenticationCallback
|
||||||
|
{
|
||||||
|
private readonly TaskCompletionSource<bool> _klaar;
|
||||||
|
|
||||||
|
public Antwoord(TaskCompletionSource<bool> klaar) => _klaar = klaar;
|
||||||
|
|
||||||
|
public override void OnAuthenticationSucceeded(BiometricPrompt.AuthenticationResult? uitkomst) =>
|
||||||
|
_klaar.TrySetResult(true);
|
||||||
|
|
||||||
|
/// <summary>Afgebroken, te vaak mis, of het toestel kan het niet. Hoe dan ook: niet open.</summary>
|
||||||
|
public override void OnAuthenticationError(BiometricErrorCode code, Java.Lang.ICharSequence? tekst) =>
|
||||||
|
_klaar.TrySetResult(false);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Eén poging die niet klopte - een vinger die verkeerd lag. Het venster blijft staan en
|
||||||
|
/// laat het opnieuw proberen, dus hier hoort niets te gebeuren. Pas als het toestel er
|
||||||
|
/// mee ophoudt komt OnAuthenticationError.
|
||||||
|
/// </summary>
|
||||||
|
public override void OnAuthenticationFailed() { }
|
||||||
|
}
|
||||||
|
#elif IOS
|
||||||
|
public bool Beschikbaar
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
// DeviceOwnerAuthentication en niet DeviceOwnerAuthenticationWithBiometrics: het
|
||||||
|
// eerste is "Face ID of Touch ID, en anders de code van de telefoon", het tweede
|
||||||
|
// eist een vingerafdruk of gezicht en niets anders. Dat laatste zou betekenen dat
|
||||||
|
// een toestel met alleen een cijfercode er niet meer in komt - precies de gebruiker
|
||||||
|
// die we op Android ook niet buitensluiten.
|
||||||
|
using var vraag = new LAContext();
|
||||||
|
return vraag.CanEvaluatePolicy(LAPolicy.DeviceOwnerAuthentication, out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> OntgrendelAsync(string titel, string uitleg)
|
||||||
|
{
|
||||||
|
// iOS toont één regel tekst en geen kop, dus de titel valt hier weg. Wat er in beeld
|
||||||
|
// komt is de uitleg; die hoort dus op zichzelf te kunnen staan.
|
||||||
|
//
|
||||||
|
// Let op: staat NSFaceIDUsageDescription niet in Info.plist, dan sluit iOS de app hier
|
||||||
|
// ter plekke af - geen foutmelding, hij is weg. Zie Platforms/iOS/Info.plist; er is een
|
||||||
|
// zelfcontrole die daarop let.
|
||||||
|
using var vraag = new LAContext();
|
||||||
|
|
||||||
|
if (!vraag.CanEvaluatePolicy(LAPolicy.DeviceOwnerAuthentication, out _)) return false;
|
||||||
|
|
||||||
|
// Afgebroken, te vaak mis, of het toestel kan het niet: dan komt hier onwaar uit en
|
||||||
|
// blijft het slot dicht. De fout zelf laten we liggen - er valt voor de gebruiker niets
|
||||||
|
// mee te doen, en op het slotscherm staat al wat hij nog wél kan.
|
||||||
|
var (gelukt, _) = await vraag.EvaluatePolicyAsync(LAPolicy.DeviceOwnerAuthentication, uitleg);
|
||||||
|
return gelukt;
|
||||||
|
}
|
||||||
|
|
||||||
|
#else
|
||||||
|
public bool Beschikbaar => false;
|
||||||
|
|
||||||
|
public Task<bool> OntgrendelAsync(string titel, string uitleg) => Task.FromResult(false);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="nl">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<title>MijnBesluit</title>
|
||||||
|
|
||||||
|
<!-- Dezelfde opmaak als de website; het bestand komt uit MijnBesluit.AppUi. -->
|
||||||
|
<link rel="stylesheet" href="_content/MijnBesluit.AppUi/app.css" />
|
||||||
|
<style>
|
||||||
|
/* Ruimte voor de statusbalk en de knoppen onderaan de telefoon. */
|
||||||
|
body { padding: env(safe-area-inset-top) env(safe-area-inset-right)
|
||||||
|
env(safe-area-inset-bottom) env(safe-area-inset-left); }
|
||||||
|
|
||||||
|
#blazor-error-ui {
|
||||||
|
display: none; position: fixed; bottom: 0; left: 0; right: 0; z-index: 1000;
|
||||||
|
background: #fbf1e3; color: #8a5a22; padding: .8rem 1rem;
|
||||||
|
font: 15px "Segoe UI", system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<div style="padding:2rem;text-align:center;color:#6b645c;font:17px 'Segoe UI',system-ui,sans-serif">
|
||||||
|
Even geduld…
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="blazor-error-ui">
|
||||||
|
Er ging iets mis. <a href="" class="reload">Opnieuw laden</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="_content/MijnBesluit.AppUi/validatie.js"></script>
|
||||||
|
<script src="_framework/blazor.webview.js" autostart="false"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
using MijnBesluit.Client;
|
||||||
|
|
||||||
|
namespace MijnBesluit.AppUi;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wat de app over de lopende sessie weet. Eén exemplaar per app (singleton), zodat elk scherm
|
||||||
|
/// dezelfde verbinding en dezelfde aanmelding gebruikt.
|
||||||
|
///
|
||||||
|
/// Hier zit bewust geen regelgeving: wat mag en niet mag bepaalt de server. Dit is alleen het
|
||||||
|
/// geheugen van de schermen.
|
||||||
|
/// </summary>
|
||||||
|
public class AppToestand
|
||||||
|
{
|
||||||
|
private string? _laatsteKenmerk;
|
||||||
|
|
||||||
|
/// <summary>Optioneel: zonder dit werkt de app gewoon, alleen zonder meldingen.</summary>
|
||||||
|
public IMeldingen? Meldingen { get; }
|
||||||
|
|
||||||
|
public AppToestand(MijnBesluitClient client, IMeldingen? meldingen = null)
|
||||||
|
{
|
||||||
|
Client = client;
|
||||||
|
Meldingen = meldingen;
|
||||||
|
Client.VerbindingVeranderd += nu => { Verbonden = nu; Veranderd?.Invoke(); };
|
||||||
|
Client.MeldingOntvangen += melding =>
|
||||||
|
{
|
||||||
|
Verbonden = true;
|
||||||
|
LaatsteMelding = melding;
|
||||||
|
Veranderd?.Invoke();
|
||||||
|
};
|
||||||
|
|
||||||
|
// De server kent ons token niet meer. Het is aan de kant van de client al opgeruimd;
|
||||||
|
// hier gooien we weg wat er nog van die sessie in het geheugen stond.
|
||||||
|
Client.SessieVerlopen += () =>
|
||||||
|
{
|
||||||
|
Familie = null;
|
||||||
|
LaatsteMelding = null;
|
||||||
|
SessieVerlopen?.Invoke();
|
||||||
|
Veranderd?.Invoke();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public MijnBesluitClient Client { get; }
|
||||||
|
|
||||||
|
/// <summary>Er is iets veranderd waar de schermen op moeten reageren.</summary>
|
||||||
|
public event Action? Veranderd;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// De sessie is aan de serverkant weg. De schil stuurt je dan naar het inlogscherm: blijven
|
||||||
|
/// staan met een knop "opnieuw proberen" heeft geen zin, want dat lukt nooit meer.
|
||||||
|
/// </summary>
|
||||||
|
public event Action? SessieVerlopen;
|
||||||
|
|
||||||
|
public bool IsAangemeld => Client.IsAangemeld;
|
||||||
|
public long PersoonId => Client.PersoonId;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Staat er verbinding met de SocketServer? Puur om te tonen. Wegvallen is normaal: zodra
|
||||||
|
/// de app op de achtergrond gaat verbreekt Android de verbinding, en de eerstvolgende
|
||||||
|
/// aanroep zet hem weer op.
|
||||||
|
/// </summary>
|
||||||
|
public bool Verbonden { get; private set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Het laatste bericht dat de server uit zichzelf stuurde.</summary>
|
||||||
|
public string? LaatsteMelding { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>De familie waar de schermen mee werken; bij de meeste mensen is er maar één.</summary>
|
||||||
|
public FamilieOverzicht? Familie { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Je eigen kleine profielfoto, klaar om in een img-element te zetten; null als je er geen
|
||||||
|
/// hebt. Schermen die jou als persoon tonen gebruiken deze, zodat het overal hetzelfde
|
||||||
|
/// plaatje is en niemand hem apart hoeft op te halen.
|
||||||
|
/// </summary>
|
||||||
|
public string? MijnFoto => Client.MijnProfiel?.Profielfotobron;
|
||||||
|
|
||||||
|
/// <summary>Je eigen naam, of een lege tekst zolang die niet bekend is.</summary>
|
||||||
|
public string MijnNaam => Client.MijnProfiel?.Naam ?? "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Haalt op wat elk scherm nodig heeft. Wordt aangeroepen na het aanmelden en bij het
|
||||||
|
/// hervatten van een sessie, zodat de schermen zelf niets hoeven te onthouden.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<Uitkomst> VerversAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var families = await Client.FamiliesAsync(ct);
|
||||||
|
if (!families.Gelukt) return Uitkomst.Fout(families.Melding!);
|
||||||
|
|
||||||
|
// Bij het hervatten van een sessie is het eigen profiel al opgehaald; na het inloggen
|
||||||
|
// met een code nog niet. Dan halen we het hier alsnog op, zodat elk scherm je naam en
|
||||||
|
// je kleine profielfoto heeft zonder er zelf om te hoeven vragen. Gaat het mis, dan is
|
||||||
|
// dat geen reden om het scherm niet te tonen: het is een plaatje.
|
||||||
|
if (Client.MijnProfiel is null)
|
||||||
|
{
|
||||||
|
try { await Client.EigenProfielAsync(ct); }
|
||||||
|
catch (Exception) { /* dan maar zonder foto */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
Familie = families.Waarde!.FirstOrDefault(f => f.Eigenaar) ?? families.Waarde!.FirstOrDefault();
|
||||||
|
Verbonden = true;
|
||||||
|
Veranderd?.Invoke();
|
||||||
|
return Uitkomst.Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Probeert de vorige sessie op te pakken. Lukt dat niet, dan moet er ingelogd worden.</summary>
|
||||||
|
public async Task<bool> HervatAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (!await Client.HervatSessieAsync(ct)) return false;
|
||||||
|
await VerversAsync(ct);
|
||||||
|
await MeldToestelAanAsync(ct);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Meldt dit toestel aan voor meldingen. Gebeurt bij elk aanmelden en bij elke hervatte
|
||||||
|
/// sessie, want het kenmerk van Firebase verandert af en toe vanzelf - bijvoorbeeld na het
|
||||||
|
/// terugzetten van een back-up. Blijft het oude kenmerk dan staan, dan gaan de meldingen
|
||||||
|
/// het luchtledige in.
|
||||||
|
/// </summary>
|
||||||
|
public async Task MeldToestelAanAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (Meldingen is null || !IsAangemeld) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var kenmerk = await Meldingen.KenmerkAsync();
|
||||||
|
if (string.IsNullOrWhiteSpace(kenmerk)) return;
|
||||||
|
|
||||||
|
_laatsteKenmerk = kenmerk;
|
||||||
|
await Client.ToestelAanmeldenAsync(kenmerk, Meldingen.Platform, Meldingen.Toestelnaam, ct);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Geen meldingen is vervelend, maar mag het gebruik van de app niet in de weg zitten.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UitloggenAsync()
|
||||||
|
{
|
||||||
|
// Eerst het toestel afmelden: anders krijgt de volgende die op deze telefoon inlogt
|
||||||
|
// de meldingen van de vorige te zien.
|
||||||
|
if (_laatsteKenmerk is not null)
|
||||||
|
{
|
||||||
|
try { await Client.ToestelAfmeldenAsync(_laatsteKenmerk); } catch (Exception) { }
|
||||||
|
_laatsteKenmerk = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Client.UitloggenAsync();
|
||||||
|
Familie = null;
|
||||||
|
LaatsteMelding = null;
|
||||||
|
Veranderd?.Invoke();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
namespace MijnBesluit.AppUi;
|
||||||
|
|
||||||
|
/// <summary>Een bestand dat de gebruiker heeft gekozen of net heeft gemaakt.</summary>
|
||||||
|
public record GekozenBestand(string Naam, string Soort, byte[] Inhoud)
|
||||||
|
{
|
||||||
|
public bool IsAfbeelding => Soort.StartsWith("image/", StringComparison.OrdinalIgnoreCase);
|
||||||
|
public bool IsVideo => Soort.StartsWith("video/", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Het kiezen van een bestand op het toestel.
|
||||||
|
///
|
||||||
|
/// Dit staat bewust als afspraak in de schermen en niet als code: een webview kan zelf geen
|
||||||
|
/// bestandskiezer of camera openen, dat moet de app eromheen doen. De schermen hoeven daardoor
|
||||||
|
/// niets te weten van Android of iOS.
|
||||||
|
///
|
||||||
|
/// Maken en kiezen staan los van elkaar. Dat is geen dubbeling: wie een foto van zijn paspoort
|
||||||
|
/// wil toevoegen, maakt hem ter plekke; wie de foto van vorige zomer bedoelt, zoekt hem op.
|
||||||
|
/// </summary>
|
||||||
|
public interface IBestandskiezer
|
||||||
|
{
|
||||||
|
/// <summary>Een document kiezen: pdf, scan, tekstbestand.</summary>
|
||||||
|
Task<GekozenBestand?> KiesDocumentAsync();
|
||||||
|
|
||||||
|
/// <summary>Een foto maken met de camera.</summary>
|
||||||
|
Task<GekozenBestand?> MaakFotoAsync();
|
||||||
|
|
||||||
|
/// <summary>Een foto kiezen uit de galerij.</summary>
|
||||||
|
Task<GekozenBestand?> KiesFotoAsync();
|
||||||
|
|
||||||
|
/// <summary>Een video opnemen met de camera.</summary>
|
||||||
|
Task<GekozenBestand?> NeemVideoOpAsync();
|
||||||
|
|
||||||
|
/// <summary>Een video kiezen uit de galerij.</summary>
|
||||||
|
Task<GekozenBestand?> KiesVideoAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Heeft dit toestel een camera die we mogen gebruiken? Zo niet, dan tonen we de knoppen
|
||||||
|
/// voor maken en opnemen niet - een knop die niets doet is erger dan geen knop.
|
||||||
|
/// </summary>
|
||||||
|
bool KanCamera { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wordt gebruikt als er geen echte kiezer is aangemeld, bijvoorbeeld wanneer de schermen in
|
||||||
|
/// een gewone browser draaien. Levert nooit een bestand op, maar laat ook niets omvallen.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GeenBestandskiezer : IBestandskiezer
|
||||||
|
{
|
||||||
|
public Task<GekozenBestand?> KiesDocumentAsync() => Task.FromResult<GekozenBestand?>(null);
|
||||||
|
public Task<GekozenBestand?> MaakFotoAsync() => Task.FromResult<GekozenBestand?>(null);
|
||||||
|
public Task<GekozenBestand?> KiesFotoAsync() => Task.FromResult<GekozenBestand?>(null);
|
||||||
|
public Task<GekozenBestand?> NeemVideoOpAsync() => Task.FromResult<GekozenBestand?>(null);
|
||||||
|
public Task<GekozenBestand?> KiesVideoAsync() => Task.FromResult<GekozenBestand?>(null);
|
||||||
|
public bool KanCamera => false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using MijnBesluit.Client;
|
||||||
|
|
||||||
|
namespace MijnBesluit.AppUi;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bijlagen die iemand heeft gekozen terwijl het ding waar ze bij horen nog niet bestaat.
|
||||||
|
///
|
||||||
|
/// Bij een persoon kun je meteen een foto toevoegen: die persoon staat er al. Bij een nieuw
|
||||||
|
/// kluisitem of een nieuw bericht niet — daar is nog geen nummer om het bestand aan te hangen.
|
||||||
|
/// Daar stond daarom tot nu toe alleen een zinnetje "dat kan zodra dit is opgeslagen". Dat is
|
||||||
|
/// een uitleg van onze boekhouding, en daar heeft iemand die een foto van zijn polis wil
|
||||||
|
/// toevoegen niets aan.
|
||||||
|
///
|
||||||
|
/// Dus houden we ze hier vast tot het opslaan klaar is, en sturen ze daarna alsnog. De inhoud
|
||||||
|
/// zit toch al in het geheugen: de bestandskiezer geeft de bytes terug, ook als we ze meteen
|
||||||
|
/// zouden versturen. Ze even vasthouden kost dus niets extra's.
|
||||||
|
///
|
||||||
|
/// In het bijlagenblok krijgen ze een negatief nummer. Dat is het teken dat ze nog nergens
|
||||||
|
/// staan: het blok laat ze zien met "wordt opgeslagen", probeert ze niet op te halen, en
|
||||||
|
/// verwijderen haalt ze hier weg in plaats van bij de server.
|
||||||
|
/// </summary>
|
||||||
|
public class Bijlagewachtrij
|
||||||
|
{
|
||||||
|
private readonly List<GekozenBestand> _wachtenden = new();
|
||||||
|
|
||||||
|
public int Aantal => _wachtenden.Count;
|
||||||
|
public bool Leeg => _wachtenden.Count == 0;
|
||||||
|
|
||||||
|
public void VoegToe(GekozenBestand bestand) => _wachtenden.Add(bestand);
|
||||||
|
public void Leegmaken() => _wachtenden.Clear();
|
||||||
|
|
||||||
|
/// <summary>Of dit nummer bij een wachtende bijlage hoort en niet bij een opgeslagen.</summary>
|
||||||
|
public static bool Wacht(Bijlage bijlage) => bijlage.Id <= 0;
|
||||||
|
|
||||||
|
/// <summary>Hoe de wachtenden eruitzien in het bijlagenblok.</summary>
|
||||||
|
public IReadOnlyList<Bijlage> AlsBijlagen(DateTime nu) =>
|
||||||
|
_wachtenden.Select((b, i) => new Bijlage(-(i + 1), b.Naam, b.Soort, b.Inhoud.LongLength, nu))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
public void Verwijder(Bijlage bijlage)
|
||||||
|
{
|
||||||
|
var plek = (int)(-bijlage.Id) - 1;
|
||||||
|
if (plek >= 0 && plek < _wachtenden.Count) _wachtenden.RemoveAt(plek);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verstuurt alles wat wachtte, nu het nummer bekend is, en maakt de wachtrij leeg.
|
||||||
|
///
|
||||||
|
/// Geeft de eerste melding terug die misging, of null als alles is gelukt. Bij een fout
|
||||||
|
/// stopt hij: gaat de verbinding eruit, dan gaan de volgende ook mis, en dan is één
|
||||||
|
/// melding duidelijker dan vijf dezelfde.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<string?> VerstuurAsync(MijnBesluitClient client, long? kluisItemId = null,
|
||||||
|
long? berichtId = null, long? persoonId = null,
|
||||||
|
Action<string, int>? voortgang = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
foreach (var bestand in _wachtenden.ToList())
|
||||||
|
{
|
||||||
|
voortgang?.Invoke(bestand.Naam, 0);
|
||||||
|
|
||||||
|
var uitkomst = await client.BestandVersturenAsync(
|
||||||
|
bestand.Naam, bestand.Soort, bestand.Inhoud, kluisItemId,
|
||||||
|
(gedaan, totaal) => voortgang?.Invoke(bestand.Naam,
|
||||||
|
totaal == 0 ? 100 : (int)(gedaan * 100 / totaal)),
|
||||||
|
persoonId, berichtId, ct);
|
||||||
|
|
||||||
|
if (!uitkomst.Gelukt)
|
||||||
|
{
|
||||||
|
// Wat al is verstuurd staat er; de rest blijft wachten, zodat iemand het opnieuw
|
||||||
|
// kan proberen zonder alles opnieuw te kiezen.
|
||||||
|
return uitkomst.Melding;
|
||||||
|
}
|
||||||
|
|
||||||
|
_wachtenden.Remove(bestand);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace MijnBesluit.AppUi;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Een adres buiten de app openen, in de browser van de telefoon.
|
||||||
|
///
|
||||||
|
/// Net als de bestandskiezer staat dit als afspraak in de schermen en niet als code: een webview
|
||||||
|
/// kan dit niet zelf, dat moet de app eromheen doen. En het hóórt naar buiten te gaan - een
|
||||||
|
/// advertentielink die binnen de app opent maakt van MijnBesluit een browser, en dan staat iemand
|
||||||
|
/// op de website van een adverteerder zonder terugknop naar zijn eigen kluis.
|
||||||
|
/// </summary>
|
||||||
|
public interface IBuitenlink
|
||||||
|
{
|
||||||
|
Task OpenAsync(string adres);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Wordt gebruikt als er geen implementatie is. Doet niets, en klapt dus ook niet.</summary>
|
||||||
|
public sealed class Geenbuitenlink : IBuitenlink
|
||||||
|
{
|
||||||
|
public Task OpenAsync(string adres) => Task.CompletedTask;
|
||||||
|
}
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
@inherits LayoutComponentBase
|
||||||
|
@implements IDisposable
|
||||||
|
@using Microsoft.JSInterop
|
||||||
|
@inject AppToestand Toestand
|
||||||
|
@inject NavigationManager Navigatie
|
||||||
|
@inject Terugknop Terugknoppen
|
||||||
|
@inject Uitlogklok Klok
|
||||||
|
@inject IToestelslot Slot
|
||||||
|
@inject VernieuwSignaal Vernieuwen
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
|
||||||
|
@* De schil van de app: een vaste balk boven, een vaste balk onder, en het scherm daartussen.
|
||||||
|
In de balk staat waar je bent - op het overzicht de naam van de app, elders de naam van het
|
||||||
|
scherm. Zo hoeft die naam niet nog eens als kop in het scherm zelf. *@
|
||||||
|
|
||||||
|
@* Elke aanraking en elke toets telt als "er gebeurt iets". Ze bubbelen hierheen, dus één
|
||||||
|
plek volstaat en de schermen hoeven er niets voor te doen. Zonder dit zou de app uitloggen
|
||||||
|
terwijl iemand zit te typen. *@
|
||||||
|
@if (Bezigmetstarten)
|
||||||
|
{
|
||||||
|
@* Nog niets: we weten op dit moment niet of er een sessie op dit toestel ligt, en dus ook
|
||||||
|
niet of de app open mag. Zolang dat zo is hoort er geen overzicht in beeld te komen dat
|
||||||
|
een tel later achter een slotscherm verdwijnt - en hoort het scherm erachter ook nog geen
|
||||||
|
gegevens op te halen. Dit duurt zo lang als het lezen van de beveiligde opslag: een wip. *@
|
||||||
|
<div class="schil app slotscherm">
|
||||||
|
<img class="merkgroot" src="_content/MijnBesluit.AppUi/logo.png" alt="" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (Vergrendeld)
|
||||||
|
{
|
||||||
|
@* Niets van de app eronder: geen balk, geen navigatie, geen inhoud. Een grendel die je nog
|
||||||
|
door het raam laat kijken is geen grendel. *@
|
||||||
|
<div class="schil app slotscherm">
|
||||||
|
<img class="merkgroot" src="_content/MijnBesluit.AppUi/logo.png" alt="" />
|
||||||
|
|
||||||
|
<h1>De app staat op slot</h1>
|
||||||
|
@* Twee redenen, twee zinnen. "Er is een tijd niets gebeurd" klopt niet als je de app
|
||||||
|
net zelf hebt geopend, en een zin die niet klopt laat iemand zoeken naar wat er mis is. *@
|
||||||
|
<p class="zacht">
|
||||||
|
@if (DoorStilte)
|
||||||
|
{
|
||||||
|
<text>Er is een tijd niets gebeurd, dus is je kluis dichtgegaan. Je bent nog aangemeld:</text>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<text>Je kluis gaat dicht zodra je de app wegleg. Je bent nog aangemeld:</text>
|
||||||
|
}
|
||||||
|
ontgrendelen kan met je vingerafdruk of de code van je telefoon.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button class="knop vol" disabled="@Ontgrendelen" @onclick="OntgrendelenAsync">
|
||||||
|
@(Ontgrendelen ? "Even wachten…" : "Ontgrendelen")
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p style="margin-top:1.2rem">
|
||||||
|
<button type="button" class="alsLink kaal" @onclick="UitloggenAsync">
|
||||||
|
Toch helemaal uitloggen
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="schil app" @onpointerdown="Aangeraakt" @onkeydown="Aangeraakt">
|
||||||
|
<header class="kop vast">
|
||||||
|
@if (OpOverzicht)
|
||||||
|
{
|
||||||
|
<span class="merk"><img class="stip" src="_content/MijnBesluit.AppUi/logo.png" alt="" /> MijnBesluit</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<button class="terug" @onclick='() => Navigatie.NavigateTo("/")' aria-label="Terug naar overzicht">
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<span class="merk">@Schermnaam</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="rechts">
|
||||||
|
@* Een tekentje en niet het woord "Geen verbinding". Op een telefoon van 360 pixels
|
||||||
|
duwde dat labeltje de schermnaam tot een paar pixels in elkaar, en verbinding
|
||||||
|
verliezen is hier normaal: Android verbreekt hem zodra de app even op de
|
||||||
|
achtergrond staat, en de eerstvolgende aanroep zet hem weer op. Iets wat komt en
|
||||||
|
gaat hoort de rest van de balk niet overhoop te gooien. *@
|
||||||
|
@if (!Toestand.Verbonden)
|
||||||
|
{
|
||||||
|
<span class="geenverbinding" title="Geen verbinding" aria-label="Geen verbinding">⚠</span>
|
||||||
|
}
|
||||||
|
@* Geen poppetje meer naar Mijn profiel. Je eigen kaart staat bovenaan Mijn mensen en
|
||||||
|
brengt je daar ook heen; twee ingangen naar hetzelfde scherm kostten hier de
|
||||||
|
breedte die de schermnaam nodig had - "Mijn mensen" viel over twee regels. *@
|
||||||
|
@if (Toestand.IsAangemeld)
|
||||||
|
{
|
||||||
|
<a href="/instellingen" title="Instellingen" aria-label="Instellingen">⚙</a>
|
||||||
|
<a href="/uitloggen">Uitloggen</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="inhoud" id="app-inhoud">
|
||||||
|
@Body
|
||||||
|
</main>
|
||||||
|
|
||||||
|
@* Op het overzicht is er niets meer om naar terug te gaan. Eén druk sluit de app daar dan
|
||||||
|
ook niet meteen: dat gebeurde te vaak per ongeluk, midden in het invullen. *@
|
||||||
|
@if (Afsluiten)
|
||||||
|
{
|
||||||
|
<div class="afsluithint">Nog een keer terug om te sluiten</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (Toestand.IsAangemeld)
|
||||||
|
{
|
||||||
|
<nav class="nav vast">
|
||||||
|
<NavLink href="/" Match="NavLinkMatch.All" ActiveClass="actief"><span class="teken">☉</span> Overzicht</NavLink>
|
||||||
|
<NavLink href="/familie" ActiveClass="actief"><span class="teken">👥</span> Mensen</NavLink>
|
||||||
|
<NavLink href="/kluis" ActiveClass="actief"><span class="teken">🔒</span> Kluis</NavLink>
|
||||||
|
<NavLink href="/momenten" ActiveClass="actief"><span class="teken">🎬</span> Momenten</NavLink>
|
||||||
|
<NavLink href="/nalatenschap" ActiveClass="actief"><span class="teken">🌿</span> Nalatenschap</NavLink>
|
||||||
|
</nav>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private string Pad = "/";
|
||||||
|
|
||||||
|
/// <summary>Er is één keer op terug gedrukt op het overzicht; nog een keer sluit de app.</summary>
|
||||||
|
private bool Afsluiten;
|
||||||
|
|
||||||
|
/// <summary>Zolang de schil er staat vangt hij de terugknop van het toestel af.</summary>
|
||||||
|
private IDisposable? Terugmelding;
|
||||||
|
|
||||||
|
private bool OpOverzicht => Pad is "/" or "";
|
||||||
|
|
||||||
|
/// <summary>De app is dicht tot iemand zich bij het toestel meldt. De sessie blijft staan.</summary>
|
||||||
|
private bool Vergrendeld;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// De app is net geopend en kijkt nog of er een sessie op dit toestel ligt. Zolang dit waar
|
||||||
|
/// is komt er geen scherm in beeld; zie de uitleg boven in dit bestand.
|
||||||
|
/// </summary>
|
||||||
|
private bool Bezigmetstarten = true;
|
||||||
|
|
||||||
|
/// <summary>Er staat een venster van het toestel open; niet er nog een bovenop zetten.</summary>
|
||||||
|
private bool Ontgrendelen;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ging het slot erop omdat er een tijd niets gebeurde, of omdat de app net openging? Alleen
|
||||||
|
/// voor de zin op het slotscherm; het slot zelf is in beide gevallen hetzelfde.
|
||||||
|
/// </summary>
|
||||||
|
private bool DoorStilte;
|
||||||
|
|
||||||
|
/// <summary>De naam van het scherm, voor in de balk.</summary>
|
||||||
|
private string Schermnaam => Pad switch
|
||||||
|
{
|
||||||
|
"/familie" => "Mijn mensen",
|
||||||
|
"/kluis" => "Mijn kluis",
|
||||||
|
"/momenten" => "Momenten",
|
||||||
|
"/nalatenschap" => "Nalatenschap",
|
||||||
|
"/profiel" => "Mijn profiel",
|
||||||
|
"/instellingen" => "Instellingen",
|
||||||
|
"/inloggen" => "Inloggen",
|
||||||
|
_ => "MijnBesluit"
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Loopt zolang de schil er staat en kijkt of de stilte lang genoeg heeft geduurd.</summary>
|
||||||
|
private readonly CancellationTokenSource _stoppen = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Op welke schermen het trek-naar-beneden-gebaar iets doet. De schil staat om elk scherm
|
||||||
|
/// heen - ook het overzicht, de instellingen, Mijn profiel - maar alleen deze vier hebben
|
||||||
|
/// een lijst die het waard is om handmatig te verversen zonder eerst weg te navigeren.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly string[] TeVerversenPaden = { "/kluis", "/familie", "/momenten", "/nalatenschap" };
|
||||||
|
|
||||||
|
private DotNetObjectReference<AppSchil>? _vernieuwverwijzing;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
Toestand.Veranderd += Herteken;
|
||||||
|
Toestand.SessieVerlopen += NaEenVerlopenSessie;
|
||||||
|
Navigatie.LocationChanged += Verplaatst;
|
||||||
|
Klok.Hervat += NaHetHervatten;
|
||||||
|
_ = WaaktAsync(_stoppen.Token);
|
||||||
|
|
||||||
|
// Deze meldt zich als eerste aan en ligt dus onderop: popups die later opengaan zijn
|
||||||
|
// eerder aan de beurt, en pas als er niets meer openstaat gaat het over navigeren.
|
||||||
|
Terugmelding = Terugknoppen.Aanmelden(Terug);
|
||||||
|
|
||||||
|
Bijwerken();
|
||||||
|
|
||||||
|
await BijHetOpenenAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
// Niet alleen bij firstRender: die allereerste tekening is bijna altijd nog het
|
||||||
|
// opstart- of slotscherm (zie hierboven), en dáár staat geen #app-inhoud in de pagina -
|
||||||
|
// dus het koppelen zou dan stil mislukken en, omdat firstRender maar één keer waar is,
|
||||||
|
// nooit meer opnieuw worden geprobeerd. In plaats daarvan proberen we het na elke
|
||||||
|
// tekening; validatie.js zet zelf een vlaggetje zodra het is gelukt, dus zodra
|
||||||
|
// #app-inhoud er eenmaal wél staat, gebeurt het precies één keer en daarna niets meer.
|
||||||
|
_vernieuwverwijzing ??= DotNetObjectReference.Create(this);
|
||||||
|
await JS.InvokeVoidAsync("mijnbesluit.pullToVernieuwen", "app-inhoud", _vernieuwverwijzing, TeVerversenPaden);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Aangeroepen vanuit validatie.js zodra er ver genoeg naar beneden is getrokken op een van
|
||||||
|
/// de schermen uit <see cref="TeVerversenPaden"/>. Het scherm dat op dat moment openstaat
|
||||||
|
/// luistert zelf mee via VernieuwSignaal; de schil hoeft niet te weten welk scherm dat is.
|
||||||
|
/// </summary>
|
||||||
|
[JSInvokable]
|
||||||
|
public Task OpVernieuwenAsync() => Vernieuwen.VraagAanAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wat er gebeurt zodra de app opengaat, vóór er een scherm in beeld komt.
|
||||||
|
///
|
||||||
|
/// De afspraak is: na de eerste keer inloggen met een code kom je erin met je vingerafdruk
|
||||||
|
/// of de code van je telefoon, tot je zelf uitlogt. Daarom wordt hier maar één ding gevraagd,
|
||||||
|
/// en wel aan het toestel: ligt hier een sessie? De server komt er niet aan te pas.
|
||||||
|
///
|
||||||
|
/// Dat verschil is waar het eerder misging. Het slot ging pas op de app nadat de server het
|
||||||
|
/// token had goedgekeurd. Een telefoon die net wakker wordt heeft nog geen netwerk, en na
|
||||||
|
/// een herstart van de service kende de server de sessie niet meer - en dan stond je op het
|
||||||
|
/// inlogscherm met een vraag om je e-mailadres, terwijl je telefoon prima wist wie je was.
|
||||||
|
/// Nu is het andersom: eerst je vingerafdruk, daarna pas de vraag of de sessie nog loopt.
|
||||||
|
/// </summary>
|
||||||
|
private async Task BijHetOpenenAsync()
|
||||||
|
{
|
||||||
|
var opSlot = false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Zonder toestelslot heeft dit geen zin: daar is uitloggen de enige grendel, en dat
|
||||||
|
// doet de uitlogklok al. Zie Uitlogklok.
|
||||||
|
opSlot = Slot.Beschikbaar && await Toestand.Client.HeeftBewaardeSessieAsync();
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Kan de beveiligde opslag niet gelezen worden, dan is er geen sessie om te
|
||||||
|
// beschermen en komt iemand vanzelf op het inlogscherm uit.
|
||||||
|
}
|
||||||
|
|
||||||
|
Bezigmetstarten = false;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
if (opSlot) await OpSlotAsync(doorStilte: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// De terugknop van het toestel. Overal behalve op het overzicht brengt hij je daarheen -
|
||||||
|
/// hetzelfde als het pijltje linksboven. Op het overzicht zelf is er niets meer achter,
|
||||||
|
/// en pas een tweede druk binnen twee seconden laat de app sluiten.
|
||||||
|
/// </summary>
|
||||||
|
private bool Terug()
|
||||||
|
{
|
||||||
|
// Op slot komt de terugknop nergens: die zou je anders zo de app uit sturen, en dan
|
||||||
|
// staat er bij de volgende start weer een open kluis.
|
||||||
|
if (Vergrendeld) return true;
|
||||||
|
|
||||||
|
// Het inlogscherm telt hier als eindpunt. Daar teruggaan naar het overzicht stuurt je
|
||||||
|
// meteen weer naar het inlogscherm; dan kom je met de terugknop nergens meer.
|
||||||
|
if (!OpOverzicht && Pad != "/inloggen")
|
||||||
|
{
|
||||||
|
InvokeAsync(() => Navigatie.NavigateTo("/"));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Afsluiten) return false; // tweede keer: het toestel mag zijn gang gaan
|
||||||
|
|
||||||
|
Afsluiten = true;
|
||||||
|
InvokeAsync(StateHasChanged);
|
||||||
|
_ = VergeetAfsluitenAsync();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task VergeetAfsluitenAsync()
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(2));
|
||||||
|
Afsluiten = false;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Verplaatst(object? afzender, LocationChangedEventArgs e)
|
||||||
|
{
|
||||||
|
Klok.Aangeraakt(); // van scherm wisselen is ook iets doen
|
||||||
|
Afsluiten = false; // je bent ergens anders; de waarschuwing hoort niet mee te reizen
|
||||||
|
Bijwerken();
|
||||||
|
InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Bijwerken()
|
||||||
|
{
|
||||||
|
var relatief = Navigatie.ToBaseRelativePath(Navigatie.Uri);
|
||||||
|
var vraagteken = relatief.IndexOf('?');
|
||||||
|
if (vraagteken >= 0) relatief = relatief[..vraagteken];
|
||||||
|
Pad = "/" + relatief.TrimStart('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Aangeraakt() => Klok.Aangeraakt();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// De server kent ons token niet meer - bijvoorbeeld omdat de SocketServer opnieuw is
|
||||||
|
/// gestart; die bewaart zijn sessies in het geheugen. Dan hoort de app naar het inlogscherm
|
||||||
|
/// te gaan in plaats van te blijven staan met een knop die nooit meer kan lukken.
|
||||||
|
///
|
||||||
|
/// Ook het slot gaat eraf: er valt niets meer te beschermen, en een slotscherm dat je na het
|
||||||
|
/// ontgrendelen op een dode sessie zet is alleen maar verwarrend.
|
||||||
|
/// </summary>
|
||||||
|
private void NaEenVerlopenSessie() => InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
Vergrendeld = false;
|
||||||
|
Navigatie.NavigateTo("/inloggen");
|
||||||
|
});
|
||||||
|
|
||||||
|
/// <summary>De app komt terug uit de achtergrond: meteen kijken, niet pas bij de volgende tik.</summary>
|
||||||
|
private void NaHetHervatten() => InvokeAsync(async () =>
|
||||||
|
{
|
||||||
|
// Stond hij al op slot, dan hoort het venster meteen weer in beeld te komen: daar is
|
||||||
|
// de gebruiker voor teruggekomen.
|
||||||
|
if (Vergrendeld) { await OntgrendelenAsync(); return; }
|
||||||
|
|
||||||
|
if (Toestand.IsAangemeld && Klok.IsVerlopen(DateTime.UtcNow)) await TijdIsOmAsync();
|
||||||
|
});
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// De termijn is voorbij. Kan het toestel om een vingerafdruk of code vragen, dan gaat de
|
||||||
|
/// app op slot en blijft de sessie staan. Kan dat niet, dan is uitloggen de enige grendel
|
||||||
|
/// die er is.
|
||||||
|
/// </summary>
|
||||||
|
private async Task TijdIsOmAsync()
|
||||||
|
{
|
||||||
|
if (!Slot.Beschikbaar) { await UitloggenAsync(); return; }
|
||||||
|
|
||||||
|
await OpSlotAsync(doorStilte: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Het slot erop, en de lijn naar de server dicht.
|
||||||
|
///
|
||||||
|
/// Dat laatste is geen zuinigheid maar netheid: wat dicht is hoeft niet bewaakt te worden, en
|
||||||
|
/// een app op slot heeft de server niets te vragen. Het token blijft staan en de sessie aan de
|
||||||
|
/// serverkant ook - die hangt aan het token, niet aan de verbinding - dus na het ontgrendelen
|
||||||
|
/// ben je gewoon weer waar je was.
|
||||||
|
///
|
||||||
|
/// In de praktijk is de server ons meestal voor: die verbreekt een verbinding waarover tien
|
||||||
|
/// minuten niets gebeurt, en de app gaat pas na een kwartier op slot. Dit is dus vooral het
|
||||||
|
/// geval waarin iemand zelf het slot erop zet, of de termijn korter heeft gezet.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="doorStilte">
|
||||||
|
/// Waar als de klok afliep, onwaar als de app net openging. Alleen voor de tekst op het
|
||||||
|
/// slotscherm.
|
||||||
|
/// </param>
|
||||||
|
private async Task OpSlotAsync(bool doorStilte)
|
||||||
|
{
|
||||||
|
DoorStilte = doorStilte;
|
||||||
|
Vergrendeld = true;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
await Toestand.Client.VerbindingSluitenAsync();
|
||||||
|
|
||||||
|
await OntgrendelenAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OntgrendelenAsync()
|
||||||
|
{
|
||||||
|
if (Ontgrendelen) return;
|
||||||
|
|
||||||
|
Ontgrendelen = true;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var open = await Slot.OntgrendelAsync(
|
||||||
|
"MijnBesluit openen",
|
||||||
|
"De app is vergrendeld omdat er een tijd niets gebeurde.");
|
||||||
|
|
||||||
|
if (!open) return; // afgebroken of mislukt: het slot blijft dicht
|
||||||
|
|
||||||
|
Vergrendeld = false;
|
||||||
|
Klok.Aangeraakt();
|
||||||
|
|
||||||
|
// De lijn alvast weer opzetten. Hoeft niet - het eerste verzoek doet het ook - maar
|
||||||
|
// zo staat hij er al terwijl iemand nog naar zijn scherm kijkt, in plaats van dat
|
||||||
|
// zijn eerste handeling op een TLS-handdruk wacht.
|
||||||
|
await Toestand.Client.VerbindingOpenenAsync();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Ontgrendelen = false;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UitloggenAsync()
|
||||||
|
{
|
||||||
|
Vergrendeld = false;
|
||||||
|
await Toestand.UitloggenAsync();
|
||||||
|
Navigatie.NavigateTo("/inloggen");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Kijkt elke halve minuut of de app zichzelf moet uitloggen.
|
||||||
|
///
|
||||||
|
/// Elke halve minuut en niet vaker: de termijn staat in minuten, dus nauwkeuriger hoeft
|
||||||
|
/// niet, en op een telefoon is elke wakkere seconde accu. Er wordt op de wandklok gerekend,
|
||||||
|
/// dus een uur in een broekzak telt net zo goed mee als een uur kijken - ook al heeft deze
|
||||||
|
/// lus dan niet gedraaid.
|
||||||
|
/// </summary>
|
||||||
|
private async Task WaaktAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var tik = new PeriodicTimer(TimeSpan.FromSeconds(30));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (await tik.WaitForNextTickAsync(ct))
|
||||||
|
{
|
||||||
|
// Staat hij al op slot, dan is er niets meer te bewaken tot hij weer opengaat.
|
||||||
|
if (!Toestand.IsAangemeld || Vergrendeld || !Klok.IsVerlopen(DateTime.UtcNow)) continue;
|
||||||
|
|
||||||
|
await InvokeAsync(TijdIsOmAsync);
|
||||||
|
|
||||||
|
if (!Toestand.IsAangemeld) return; // uitgelogd; er valt niets meer te bewaken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// De schil is weg. Niets aan de hand.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Herteken() => InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Toestand.Veranderd -= Herteken;
|
||||||
|
Toestand.SessieVerlopen -= NaEenVerlopenSessie;
|
||||||
|
Navigatie.LocationChanged -= Verplaatst;
|
||||||
|
Terugmelding?.Dispose();
|
||||||
|
Klok.Hervat -= NaHetHervatten;
|
||||||
|
_stoppen.Cancel();
|
||||||
|
_stoppen.Dispose();
|
||||||
|
_vernieuwverwijzing?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
namespace MijnBesluit.AppUi;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Meldingen op het toestel zelf.
|
||||||
|
///
|
||||||
|
/// Net als bij het kiezen van bestanden: de schermen weten hier niets van Android of iOS, ze
|
||||||
|
/// vragen alleen om het kenmerk waarmee de server een melding kan sturen. Firebase levert dat
|
||||||
|
/// kenmerk, en het verandert af en toe vanzelf - daarom wordt het bij elke start opnieuw
|
||||||
|
/// opgehaald en doorgegeven.
|
||||||
|
/// </summary>
|
||||||
|
public interface IMeldingen
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Vraagt toestemming (Android 13 en hoger vraagt dat apart) en haalt het kenmerk op.
|
||||||
|
/// Geeft null als de gebruiker geen meldingen wil of als Firebase niet is ingesteld.
|
||||||
|
/// </summary>
|
||||||
|
Task<string?> KenmerkAsync();
|
||||||
|
|
||||||
|
/// <summary>Waar dit op draait: "android" of "ios".</summary>
|
||||||
|
string Platform { get; }
|
||||||
|
|
||||||
|
/// <summary>De naam van het toestel, om in het overzicht te kunnen tonen.</summary>
|
||||||
|
string? Toestelnaam { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Voor als er geen meldingen zijn ingericht; alles werkt dan gewoon, zonder push.</summary>
|
||||||
|
public sealed class GeenMeldingen : IMeldingen
|
||||||
|
{
|
||||||
|
public Task<string?> KenmerkAsync() => Task.FromResult<string?>(null);
|
||||||
|
public string Platform => "onbekend";
|
||||||
|
public string? Toestelnaam => null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Razor">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<AddRazorSupportForMvc>false</AddRazorSupportForMvc>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Bewust een package en geen FrameworkReference naar Microsoft.AspNetCore.App: een
|
||||||
|
Android-app draait niet op de gedeelde ASP.NET Core-runtime, dus daar is die
|
||||||
|
verwijzing niet op te lossen (NETSDK1082). Met het package erbij werkt dit project
|
||||||
|
zowel in de website als in de app. -->
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="8.0.13" />
|
||||||
|
<ProjectReference Include="..\MijnBesluit.Client\MijnBesluit.Client.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
@* Foto's, documenten en opnamen bij een kluisitem, een persoon of een bericht.
|
||||||
|
|
||||||
|
Eén component voor alle drie, zodat ze er hetzelfde uitzien en zich hetzelfde gedragen.
|
||||||
|
Wat er per plek verschilt zijn de knoppen die je meegeeft: bij een persoon kun je een foto
|
||||||
|
aanwijzen als profielfoto, bij een kluisitem niet.
|
||||||
|
|
||||||
|
Het bekijken zit hier ingebouwd en gaat niet via de schermen eromheen. Dat scheelt vier keer
|
||||||
|
dezelfde code, en belangrijker: het werkt daardoor overal hetzelfde. *@
|
||||||
|
|
||||||
|
@inject AppToestand Toestand
|
||||||
|
|
||||||
|
<div class="veld">
|
||||||
|
<label>@Titel</label>
|
||||||
|
|
||||||
|
@if (Bijlagen.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="stapel" style="margin-bottom:.6rem">
|
||||||
|
@foreach (var bijlage in Bijlagen)
|
||||||
|
{
|
||||||
|
<div class="rij bijlage">
|
||||||
|
@* De naam en het tekentje samen zijn de knop om hem te bekijken. Een aparte
|
||||||
|
oogknop erbij zou de rij op een telefoon te vol maken. *@
|
||||||
|
<button type="button" class="bijlage-openen groei" disabled="@Bezig"
|
||||||
|
title="Bekijken" @onclick="() => BekijkAsync(bijlage)">
|
||||||
|
<span class="teken">@Teken(bijlage)</span>
|
||||||
|
<span class="groei">
|
||||||
|
<span class="naam">@bijlage.FileName</span>
|
||||||
|
<span class="zacht">
|
||||||
|
@bijlage.Grootte
|
||||||
|
@if (bijlage.IsProfilePhoto) { <text> · profielfoto</text> }
|
||||||
|
@if (Bijlagewachtrij.Wacht(bijlage)) { <text> · nog niet opgeslagen</text> }
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (KanProfielfoto && bijlage.IsAfbeelding && !bijlage.IsProfilePhoto
|
||||||
|
&& !Bijlagewachtrij.Wacht(bijlage))
|
||||||
|
{
|
||||||
|
<button type="button" class="knop rustig klein" disabled="@Bezig"
|
||||||
|
title="Als profielfoto gebruiken"
|
||||||
|
@onclick="() => OpProfielfoto.InvokeAsync(bijlage)">★</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
<button type="button" class="knop rustig klein" disabled="@Bezig"
|
||||||
|
title="Verwijderen"
|
||||||
|
@onclick="() => Weghalen = bijlage">🗑</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Verwijderen is onomkeerbaar - de bijlage is daarna echt weg - dus eerst
|
||||||
|
bevestigen, net als overal elders waar iets wordt weggehaald. *@
|
||||||
|
@if (Weghalen == bijlage)
|
||||||
|
{
|
||||||
|
<div class="melding let-op" style="margin:0 0 .6rem">
|
||||||
|
<strong>@bijlage.FileName</strong> verwijderen?
|
||||||
|
<div class="rij" style="margin-top:.5rem">
|
||||||
|
<button type="button" class="knop gevaar" disabled="@Bezig"
|
||||||
|
@onclick="() => VerwijderBevestigdAsync(bijlage)">Ja, verwijderen</button>
|
||||||
|
<button type="button" class="knop rustig" disabled="@Bezig"
|
||||||
|
@onclick="() => Weghalen = null">Nee, laat staan</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (Bezig && Overzetten is not null)
|
||||||
|
{
|
||||||
|
<div class="zacht">@Overzetten</div>
|
||||||
|
<div class="voortgang" style="margin:.3rem 0 .6rem"><span style="width:@(Percentage)%"></span></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Maken en kiezen staan los van elkaar, want het zijn twee verschillende handelingen: een
|
||||||
|
foto van je paspoort maak je ter plekke, die van vorige zomer zoek je op. Zonder camera
|
||||||
|
blijven alleen de kiesknoppen over. *@
|
||||||
|
<div class="rij bijlage-acties">
|
||||||
|
@if (KanCamera)
|
||||||
|
{
|
||||||
|
<button type="button" class="knop rustig klein" disabled="@Bezig" title="Foto maken"
|
||||||
|
aria-label="Foto maken" @onclick="OpFotoMaken">📷</button>
|
||||||
|
}
|
||||||
|
<button type="button" class="knop rustig klein" disabled="@Bezig" title="Foto kiezen"
|
||||||
|
aria-label="Foto kiezen" @onclick="OpFoto">🖼️</button>
|
||||||
|
|
||||||
|
@if (KanVideo)
|
||||||
|
{
|
||||||
|
@if (KanCamera)
|
||||||
|
{
|
||||||
|
<button type="button" class="knop rustig klein" disabled="@Bezig" title="Video opnemen"
|
||||||
|
aria-label="Video opnemen" @onclick="OpVideo">🎥</button>
|
||||||
|
}
|
||||||
|
<button type="button" class="knop rustig klein" disabled="@Bezig" title="Video kiezen"
|
||||||
|
aria-label="Video kiezen" @onclick="OpVideoKiezen">🏞️</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
<button type="button" class="knop rustig klein" disabled="@Bezig" title="Document toevoegen"
|
||||||
|
aria-label="Document toevoegen" @onclick="OpDocument">📄</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (Uitleg is not null)
|
||||||
|
{
|
||||||
|
<div class="uitleg">@Uitleg</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* ---------------------------------------------------------------- bekijken *@
|
||||||
|
@if (Bekeken is not null)
|
||||||
|
{
|
||||||
|
<Popup Titel="@Bekeken.FileName" Bevestigtekst="Sluiten" AlleenSluiten="true"
|
||||||
|
OpBevestigen="Dichtdoen" OpSluiten="Dichtdoen">
|
||||||
|
|
||||||
|
@if (Ophalen)
|
||||||
|
{
|
||||||
|
<p class="zacht">Bezig met ophalen…</p>
|
||||||
|
<div class="voortgang"><span style="width:@(Vordering)%"></span></div>
|
||||||
|
}
|
||||||
|
else if (Kijkfout is not null)
|
||||||
|
{
|
||||||
|
<div class="melding @(Bijlagewachtrij.Wacht(Bekeken) ? "let-op" : "fout")">@Kijkfout</div>
|
||||||
|
}
|
||||||
|
else if (Bron is not null && Bekeken.IsAfbeelding)
|
||||||
|
{
|
||||||
|
<img src="@Bron" alt="@Bekeken.FileName" class="bijlage-groot" />
|
||||||
|
}
|
||||||
|
else if (Bron is not null && Bekeken.IsVideo)
|
||||||
|
{
|
||||||
|
<video src="@Bron" controls class="bijlage-groot"></video>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p class="zacht">
|
||||||
|
Dit soort bestand is hier niet te tonen. Op de website kun je het downloaden.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</Popup>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public string Titel { get; set; } = "Foto's en documenten";
|
||||||
|
[Parameter] public string? Uitleg { get; set; }
|
||||||
|
|
||||||
|
[Parameter] public IReadOnlyList<Bijlage> Bijlagen { get; set; } = Array.Empty<Bijlage>();
|
||||||
|
[Parameter] public bool Bezig { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Mogen hier video's bij? Bij een persoon niet, bij een bericht en een kluisitem wel.</summary>
|
||||||
|
[Parameter] public bool KanVideo { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Heeft dit toestel een camera die we mogen gebruiken? Zo niet, dan blijven alleen de
|
||||||
|
/// knoppen over waarmee je iets opzoekt dat er al staat.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter] public bool KanCamera { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Mag hier een foto worden aangewezen als profielfoto? Alleen bij een persoon.</summary>
|
||||||
|
[Parameter] public bool KanProfielfoto { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Tekst en percentage van een lopende overdracht; null als er niets loopt.</summary>
|
||||||
|
[Parameter] public string? Overzetten { get; set; }
|
||||||
|
[Parameter] public int Percentage { get; set; }
|
||||||
|
|
||||||
|
[Parameter] public EventCallback OpFotoMaken { get; set; }
|
||||||
|
[Parameter] public EventCallback OpFoto { get; set; }
|
||||||
|
[Parameter] public EventCallback OpDocument { get; set; }
|
||||||
|
[Parameter] public EventCallback OpVideo { get; set; }
|
||||||
|
[Parameter] public EventCallback OpVideoKiezen { get; set; }
|
||||||
|
[Parameter] public EventCallback<Bijlage> OpVerwijderen { get; set; }
|
||||||
|
[Parameter] public EventCallback<Bijlage> OpProfielfoto { get; set; }
|
||||||
|
|
||||||
|
/// <summary>De bijlage die op een "weet je het zeker?" staat te wachten om verwijderd te worden.</summary>
|
||||||
|
private Bijlage? Weghalen;
|
||||||
|
|
||||||
|
/// <summary>De bijlage die openstaat om te bekijken; null als er niets openstaat.</summary>
|
||||||
|
private Bijlage? Bekeken;
|
||||||
|
|
||||||
|
/// <summary>De inhoud als verwijzing die een img- of video-element rechtstreeks aankan.</summary>
|
||||||
|
private string? Bron;
|
||||||
|
|
||||||
|
private bool Ophalen;
|
||||||
|
private int Vordering;
|
||||||
|
private string? Kijkfout;
|
||||||
|
|
||||||
|
private async Task BekijkAsync(Bijlage bijlage)
|
||||||
|
{
|
||||||
|
Bekeken = bijlage;
|
||||||
|
Bron = null;
|
||||||
|
Kijkfout = null;
|
||||||
|
|
||||||
|
// Een bijlage die nog wacht staat nergens, dus valt er ook niets op te halen. Dat is geen
|
||||||
|
// fout; de popup zegt gewoon wat er aan de hand is.
|
||||||
|
if (Bijlagewachtrij.Wacht(bijlage))
|
||||||
|
{
|
||||||
|
Kijkfout = "Deze staat er nog niet. Sla eerst op, dan kun je hem bekijken.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Een document halen we niet op: we kunnen het toch niet tonen, en het kan tientallen
|
||||||
|
// megabytes zijn. De popup zegt dan meteen waar je het wél kunt openen.
|
||||||
|
if (!bijlage.IsAfbeelding && !bijlage.IsVideo) return;
|
||||||
|
|
||||||
|
Ophalen = true;
|
||||||
|
Vordering = 0;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
var uitkomst = await Toestand.Client.BestandOphalenAsync(bijlage.Id,
|
||||||
|
(gedaan, totaal) =>
|
||||||
|
{
|
||||||
|
Vordering = totaal == 0 ? 100 : (int)(gedaan * 100 / totaal);
|
||||||
|
InvokeAsync(StateHasChanged);
|
||||||
|
});
|
||||||
|
|
||||||
|
Ophalen = false;
|
||||||
|
|
||||||
|
if (!uitkomst.Gelukt) { Kijkfout = uitkomst.Melding; return; }
|
||||||
|
|
||||||
|
// De webview haalt zijn plaatjes niet van een server, dus geven we de inhoud mee in de
|
||||||
|
// verwijzing zelf. Dat kan omdat er een grens op de bestandsgrootte staat.
|
||||||
|
Bron = $"data:{uitkomst.Waarde!.Soort};base64,{Convert.ToBase64String(uitkomst.Waarde.Inhoud)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Dichtdoen()
|
||||||
|
{
|
||||||
|
Bekeken = null;
|
||||||
|
Bron = null;
|
||||||
|
Kijkfout = null;
|
||||||
|
Ophalen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task VerwijderBevestigdAsync(Bijlage bijlage)
|
||||||
|
{
|
||||||
|
Weghalen = null;
|
||||||
|
await OpVerwijderen.InvokeAsync(bijlage);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Teken(Bijlage bijlage) =>
|
||||||
|
bijlage.IsAfbeelding ? "\U0001F4F7" : bijlage.IsVideo ? "\U0001F3AC" : "\U0001F4C4";
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
@* Een datum kiezen met drie keuzelijsten in plaats van <input type="date">.
|
||||||
|
|
||||||
|
Reden: in de webview waar de app in draait opent de agendaknop van een datumveld niet - je
|
||||||
|
ziet dan een veld waar niets mee gebeurt. Bovendien is kiezen op een telefoon makkelijker
|
||||||
|
dan typen, zeker voor wie niet zo handig is met een toetsenbord.
|
||||||
|
|
||||||
|
Werkt zowel vooruit (een bezorgmoment) als achteruit (een geboortedatum); zet daarvoor
|
||||||
|
VanJaar, TotJaar en Aflopend. Met Optioneel mag het veld leeg blijven. *@
|
||||||
|
|
||||||
|
<div class="datumkiezer">
|
||||||
|
<select class="dag" aria-label="Dag" @bind="Dag">
|
||||||
|
@for (var d = 1; d <= 31; d++)
|
||||||
|
{
|
||||||
|
<option value="@d">@d</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select class="maand" aria-label="Maand" @bind="Maand">
|
||||||
|
@for (var m = 1; m <= 12; m++)
|
||||||
|
{
|
||||||
|
<option value="@m">@Maanden[m - 1]</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select class="jaar" aria-label="Jaar" @bind="Jaar">
|
||||||
|
@if (Optioneel)
|
||||||
|
{
|
||||||
|
<option value="0">—</option>
|
||||||
|
}
|
||||||
|
@foreach (var j in Jaren)
|
||||||
|
{
|
||||||
|
<option value="@j">@j</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public DateTime? Waarde { get; set; }
|
||||||
|
[Parameter] public EventCallback<DateTime?> WaardeChanged { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Hoeveel jaren er te kiezen zijn vanaf dit jaar. Genegeerd als TotJaar is gezet.</summary>
|
||||||
|
[Parameter] public int JarenVooruit { get; set; } = 60;
|
||||||
|
|
||||||
|
[Parameter] public int? VanJaar { get; set; }
|
||||||
|
[Parameter] public int? TotJaar { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Nieuwste jaar bovenaan. Voor een geboortedatum scheelt dat veel scrollen.</summary>
|
||||||
|
[Parameter] public bool Aflopend { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Mag het veld leeg blijven? Dan komt er een streepje bij de jaren.</summary>
|
||||||
|
[Parameter] public bool Optioneel { get; set; }
|
||||||
|
|
||||||
|
// Afgekort ("Feb.", "Mrt.") en niet voluit: in de drie keuzelijsten naast elkaar duwde
|
||||||
|
// "september" op een smalle telefoon de jaarkolom uit beeld. De lijst staat in
|
||||||
|
// MijnBesluit.Client, samen met de website-versie in het domein; een zelfcontrole houdt
|
||||||
|
// die twee gelijk.
|
||||||
|
private static readonly string[] Maanden = MijnBesluit.Client.Datums.KorteMaanden;
|
||||||
|
|
||||||
|
private int Eerste => VanJaar ?? DateTime.Today.Year;
|
||||||
|
private int Laatste => TotJaar ?? (DateTime.Today.Year + JarenVooruit);
|
||||||
|
|
||||||
|
private IEnumerable<int> Jaren
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var reeks = Enumerable.Range(Eerste, Math.Max(1, Laatste - Eerste + 1));
|
||||||
|
return Aflopend ? reeks.Reverse() : reeks;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Jaar 0 betekent: geen datum gekozen. Alleen mogelijk als Optioneel aan staat.</summary>
|
||||||
|
private bool Leeg => _jaar == 0;
|
||||||
|
|
||||||
|
/// <summary>Het jaar dat bovenaan de lijst staat: bij Aflopend het nieuwste, anders het oudste.</summary>
|
||||||
|
private int StandaardJaar => Aflopend ? Laatste : Eerste;
|
||||||
|
|
||||||
|
private int _dag = DateTime.Today.Day;
|
||||||
|
private int _maand = DateTime.Today.Month;
|
||||||
|
private int _jaar = DateTime.Today.Year;
|
||||||
|
|
||||||
|
// Dag en maand staan nooit op slot, ook niet als het jaar nog op "-" staat: je moet een
|
||||||
|
// datum in elke volgorde kunnen invullen, niet per se bij het jaar beginnen. Kies je dag of
|
||||||
|
// maand terwijl er nog geen jaar gekozen is, dan springt het jaar vanzelf naar wat er
|
||||||
|
// bovenaan de lijst staat - daarmee telt de datum meteen als gekozen, zonder dat je apart
|
||||||
|
// nog het jaar hoeft aan te wijzen als de standaardwaarde al klopt.
|
||||||
|
private int Dag
|
||||||
|
{
|
||||||
|
get => _dag;
|
||||||
|
set { _dag = value; ActiveerAlsLeeg(); _ = Doorgeven(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private int Maand
|
||||||
|
{
|
||||||
|
get => _maand;
|
||||||
|
set { _maand = value; ActiveerAlsLeeg(); _ = Doorgeven(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private int Jaar { get => _jaar; set { _jaar = value; _ = Doorgeven(); } }
|
||||||
|
|
||||||
|
private void ActiveerAlsLeeg()
|
||||||
|
{
|
||||||
|
if (Optioneel && _jaar == 0) _jaar = StandaardJaar;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
if (Waarde is { } d)
|
||||||
|
{
|
||||||
|
_dag = d.Day;
|
||||||
|
_maand = d.Month;
|
||||||
|
_jaar = d.Year;
|
||||||
|
}
|
||||||
|
else if (Optioneel)
|
||||||
|
{
|
||||||
|
_jaar = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task Doorgeven()
|
||||||
|
{
|
||||||
|
if (Leeg) return WaardeChanged.InvokeAsync(null);
|
||||||
|
|
||||||
|
// 31 februari bestaat niet; dan pakken we de laatste dag van die maand. Zo kan de
|
||||||
|
// gebruiker de drie lijstjes in elke volgorde instellen zonder foutmelding.
|
||||||
|
var dagen = DateTime.DaysInMonth(_jaar, _maand);
|
||||||
|
var dag = Math.Min(_dag, dagen);
|
||||||
|
|
||||||
|
return WaardeChanged.InvokeAsync(new DateTime(_jaar, _maand, dag, 12, 0, 0, DateTimeKind.Local));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
@* Het ronde penningetje voor een persoon: zijn profielfoto, en anders zijn beginletters.
|
||||||
|
|
||||||
|
Eén component voor alle lijsten waar mensen in staan - Mijn mensen, je beheerders, de
|
||||||
|
nalatenschappen die aan jou zijn toevertrouwd. Ze horen er hetzelfde uit te zien, en dat
|
||||||
|
blijft alleen zo als het op één plek staat.
|
||||||
|
|
||||||
|
Meegeven doe je alleen een naam en waar het plaatje vandaan komt; hoe dat plaatje eruit
|
||||||
|
ziet is aan het stijlblad. Zonder bron valt hij terug op de letters, even groot, zodat de
|
||||||
|
lijst niet verspringt zodra er ergens een foto bij komt. *@
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(Bron))
|
||||||
|
{
|
||||||
|
<img class="penning" src="@Bron" alt="@Naam" title="@Naam" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="penning" title="@Naam">@Beginletters(Naam)</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>De naam van de persoon; alleen gebruikt voor de letters en de tekstballon.</summary>
|
||||||
|
[Parameter] public string Naam { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Waar het plaatje vandaan komt. Op de website een adres van de server, in de app de
|
||||||
|
/// inhoud zelf. Leeg of null betekent: geen foto, dus de letters.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter] public string? Bron { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// De eerste letter van de voornaam en van de achternaam. Bij één woord alleen de eerste,
|
||||||
|
/// bij een lege naam een vraagteken - er moet altijd iets in de penning staan.
|
||||||
|
/// </summary>
|
||||||
|
public static string Beginletters(string? naam)
|
||||||
|
{
|
||||||
|
var delen = (naam ?? "").Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (delen.Length == 0) return "?";
|
||||||
|
return delen.Length == 1
|
||||||
|
? delen[0][..1].ToUpperInvariant()
|
||||||
|
: (delen[0][..1] + delen[^1][..1]).ToUpperInvariant();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
@* Een venster over het scherm heen, voor wijzigen en bevestigen.
|
||||||
|
|
||||||
|
Bewust geen JavaScript-dialoog: die ziet er op elk toestel anders uit en is niet te
|
||||||
|
vormgeven. Dit is gewoon een laag over het scherm, met de knoppen onderaan waar een duim
|
||||||
|
ze kan bereiken. *@
|
||||||
|
|
||||||
|
@implements IDisposable
|
||||||
|
@inject Terugknop Terugknoppen
|
||||||
|
|
||||||
|
<div class="popup-achtergrond" @onclick="Sluiten">
|
||||||
|
<div class="popup" @onclick:stopPropagation="true">
|
||||||
|
<h2>@Titel</h2>
|
||||||
|
|
||||||
|
@ChildContent
|
||||||
|
|
||||||
|
@if (Foutmelding is not null)
|
||||||
|
{
|
||||||
|
<div class="melding fout" style="margin-top:.8rem">@Foutmelding</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="rij popup-knoppen">
|
||||||
|
<button class="knop @(Gevaarlijk ? "gevaar" : "")" disabled="@Bezig" @onclick="Bevestigen">
|
||||||
|
@Bevestigtekst
|
||||||
|
</button>
|
||||||
|
@if (!AlleenSluiten)
|
||||||
|
{
|
||||||
|
<button class="knop rustig" disabled="@Bezig" @onclick="Sluiten">Annuleren</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public string Titel { get; set; } = "";
|
||||||
|
[Parameter] public string Bevestigtekst { get; set; } = "Opslaan";
|
||||||
|
|
||||||
|
/// <summary>Kleurt de knop als waarschuwing; voor verwijderen.</summary>
|
||||||
|
[Parameter] public bool Gevaarlijk { get; set; }
|
||||||
|
|
||||||
|
[Parameter] public bool Bezig { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Er valt niets te bevestigen of af te breken, alleen dicht te doen. Dan is "Annuleren"
|
||||||
|
/// naast "Sluiten" twee knoppen voor hetzelfde. Voor een popup die alleen iets laat zien.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter] public bool AlleenSluiten { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Waarom Bevestigen niet is gelukt (bijvoorbeeld een verplicht veld dat leeg is). Staat
|
||||||
|
/// IN de popup, niet op de pagina erachter - die is aan het oog onttrokken zolang de popup
|
||||||
|
/// openstaat, dus een melding die daar verschijnt ziet niemand.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter] public string? Foutmelding { get; set; }
|
||||||
|
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||||
|
[Parameter] public EventCallback OpBevestigen { get; set; }
|
||||||
|
[Parameter] public EventCallback OpSluiten { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Zolang dit venster openstaat vangt het de terugknop van het toestel af.</summary>
|
||||||
|
private IDisposable? Terugmelding;
|
||||||
|
|
||||||
|
protected override void OnInitialized() =>
|
||||||
|
Terugmelding = Terugknoppen.Aanmelden(() =>
|
||||||
|
{
|
||||||
|
// Terug betekent hier hetzelfde als Annuleren: dicht, zonder op te slaan. Dat is wat
|
||||||
|
// iemand verwacht, en het is de veilige kant - per ongeluk opslaan kun je niet
|
||||||
|
// terugdraaien, per ongeluk annuleren wel.
|
||||||
|
InvokeAsync(Sluiten);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
public void Dispose() => Terugmelding?.Dispose();
|
||||||
|
|
||||||
|
private Task Bevestigen() => OpBevestigen.InvokeAsync();
|
||||||
|
private Task Sluiten() => OpSluiten.InvokeAsync();
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
@* De privacyverklaring zelf. Staat hier als onderdeel en niet in een pagina, omdat de app hem
|
||||||
|
ook laat zien - daar in een popup vanaf het overzicht, hier als pagina op /privacy.
|
||||||
|
|
||||||
|
Dit bestand staat twee keer op schijf: de app en de site delen geen project. Een zelfcontrole
|
||||||
|
vergelijkt ze letterlijk, want twee versies van je privacyverklaring is erger dan twee versies
|
||||||
|
van een knop: het is het document waar iemand zich op beroept.
|
||||||
|
|
||||||
|
Verandert er iets aan wat er wordt opgeslagen, wie het kan zien of wat er naar buiten gaat, dan
|
||||||
|
verandert deze tekst mee - en dan hoort de datum hieronder mee te veranderen. *@
|
||||||
|
|
||||||
|
<p class="zacht">Laatst bijgewerkt op 19 september 2026.</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
MijnBesluit is een digitale kluis. Je legt er vast wat er geregeld moet worden, en je zet
|
||||||
|
berichten klaar voor de mensen om je heen. Dat zijn je meest persoonlijke gegevens, en
|
||||||
|
daarom staat hier zo precies mogelijk wat ermee gebeurt.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Wie is verantwoordelijk</h2>
|
||||||
|
<p>
|
||||||
|
MijnBesluit is een dienst van <strong>Kwik-Bit Automatisering B.V.</strong><br />
|
||||||
|
Boulevard 1945 nr. 216, 7511 AG Enschede<br />
|
||||||
|
KvK-nummer 97571792
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Vragen over je gegevens kun je stellen via
|
||||||
|
<a href="mailto:privacy@@mijnbesluit.nl">privacy@@mijnbesluit.nl</a>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Wat we vastleggen</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Je e-mailadres.</strong> Daarmee log je in; we sturen er een code naartoe.</li>
|
||||||
|
<li><strong>Je naam en geboortedatum</strong>, als je die invult.</li>
|
||||||
|
<li><strong>Wat je in je kluis zet</strong>: titels, notities, wachtwoorden, polissen,
|
||||||
|
wensen, en de bestanden die je toevoegt.</li>
|
||||||
|
<li><strong>De mensen die je toevoegt</strong>: hun naam, en het e-mailadres dat je
|
||||||
|
invult om ze uit te nodigen.</li>
|
||||||
|
<li><strong>De berichten</strong> die je voor iemand klaarzet, en wanneer ze bezorgd zijn.</li>
|
||||||
|
<li><strong>Een logboek</strong> van handelingen: wie wanneer inlogde, wat er werd
|
||||||
|
gewijzigd, wanneer een bericht is bezorgd. Daar staat geen inhoud in.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Wat er versleuteld is</h2>
|
||||||
|
<p>
|
||||||
|
Alles wat in je kluis staat en alles wat in je berichten staat, is versleuteld met een
|
||||||
|
sleutel die bij jouw account hoort. Ook de bestanden die je toevoegt: die staan
|
||||||
|
versleuteld op schijf. Wie de database in handen krijgt zonder de sleutel, ziet er niets
|
||||||
|
van.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Eén uitzondering, en die noemen we liever zelf:</strong> de kleine versie van een
|
||||||
|
profielfoto - het rondje van veertig pixels in een lijst - is niet versleuteld. Dat is een
|
||||||
|
afweging geweest: een profielfoto is juist bedoeld om aan anderen te laten zien, en hem
|
||||||
|
als kluismateriaal behandelen zou betekenen dat we voor elke kijker sleutels moeten
|
||||||
|
uitdelen en weer intrekken. De foto op ware grootte is wél versleuteld.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Het verkeer tussen de app en onze server gaat over een versleutelde verbinding, net als
|
||||||
|
de website.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Wie kan wat zien</h2>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>Jij</strong> ziet je eigen kluis. Niemand anders, ook wij niet: wij kunnen je
|
||||||
|
kluisitems niet lezen zonder jouw account.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Mensen met wie je in een familie zit</strong> zien elkaars naam en
|
||||||
|
profielfoto. Niet elkaars kluis.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Iemand die je hebt aangewezen als nalatenschapsbeheerder</strong> krijgt
|
||||||
|
toegang tot wat je daarvoor hebt bestemd, en pas nadat je overlijden is bevestigd
|
||||||
|
volgens de procedure die in de app staat.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Iemand met wie je een kluisitem alvast "bespreekt".</strong> Dat kies je zelf,
|
||||||
|
per item: die persoon ziet het dan meteen, inclusief een eventuele foto of bijlage,
|
||||||
|
zonder dat daar een overlijden voor nodig is. Zonder die keuze ziet niemand het.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Een beheerder van MijnBesluit</strong> kan een profiel opzoeken op volledig
|
||||||
|
e-mailadres en een ten onrechte bevestigd overlijden terugdraaien. Hij kan géén kluis
|
||||||
|
openen. Elke handeling van een beheerder komt in het logboek.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Wat er naar buiten gaat</h2>
|
||||||
|
<p>
|
||||||
|
Wij verkopen geen gegevens en er staan geen meetscripts of volgcookies op deze site.
|
||||||
|
Er zijn twee partijen die noodzakelijkerwijs iets te zien krijgen:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>Google (Firebase Cloud Messaging)</strong> bezorgt de meldingen op je
|
||||||
|
telefoon. Daarvoor gaat een kenmerk van je toestel naar Google, met de titel en de
|
||||||
|
korte tekst van de melding. De inhoud van een bericht of een kluisitem gaat daar
|
||||||
|
nooit in mee.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>De verzender van onze e-mail</strong>, voor de inlogcodes en uitnodigingen.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Adverteerders krijgen niets over jou te zien. Er wordt per advertentie geteld hoe vaak
|
||||||
|
hij is getoond en aangeklikt, per uur - meer niet. Geen profielen, geen kenmerken, geen
|
||||||
|
bezoeker die door de applicatie heen te volgen is.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Hoe lang we het bewaren</h2>
|
||||||
|
<p>
|
||||||
|
Zolang je account bestaat. Hef je het op, dan wordt het verwijderd - zie hieronder. Het
|
||||||
|
logboek blijft staan, omdat je achteraf moet kunnen nagaan wat er is gebeurd; daar staat
|
||||||
|
geen inhoud in. Van gegevens die bij onze administratie horen, zoals facturen voor
|
||||||
|
adverteerders, houden we de wettelijke bewaartermijn aan.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Je account opheffen</h2>
|
||||||
|
<p>
|
||||||
|
Dat kan zelf, op elk moment, en het hoeft niet via ons te lopen:
|
||||||
|
<a href="/account-verwijderen">mijnbesluit.nl/account-verwijderen</a>. Dat adres werkt ook
|
||||||
|
als je de app niet meer hebt. In de app staat dezelfde knop bij Mijn profiel.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Wat er dan weggaat: je kluis, je bestanden, de berichten die nog klaarstonden, je lijst
|
||||||
|
met mensen, en je account zelf. Wat blijft: berichten die al bezorgd zijn - die zijn van
|
||||||
|
degene die ze heeft gekregen - en je naam in de lijst van mensen die jou hebben
|
||||||
|
toegevoegd. Dat laatste is hun aantekening, niet jouw gegeven; alles wat er aan jou
|
||||||
|
vastzat, gaat eraf.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Je rechten</h2>
|
||||||
|
<p>
|
||||||
|
Je mag inzien wat we van je hebben, het laten corrigeren, en het laten verwijderen. Het
|
||||||
|
meeste doe je zelf in de app of op de website. Lukt iets niet, mail dan
|
||||||
|
<a href="mailto:privacy@@mijnbesluit.nl">privacy@@mijnbesluit.nl</a>. Ben je het oneens
|
||||||
|
met hoe wij met je gegevens omgaan, dan kun je een klacht indienen bij de Autoriteit
|
||||||
|
Persoonsgegevens.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Als er iets misgaat</h2>
|
||||||
|
<p>
|
||||||
|
Bij een datalek melden wij dat bij de Autoriteit Persoonsgegevens, en bij jou als het
|
||||||
|
jouw gegevens raakt.
|
||||||
|
</p>
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
@* De uitleg over MijnBesluit, zonder kop en zonder knoppen eromheen.
|
||||||
|
|
||||||
|
Dit staat als los onderdeel zodat de website en de app dezelfde tekst tonen: op de website als
|
||||||
|
pagina met knoppen om aan te melden, in de app als popup vanaf het overzicht. De app en de site
|
||||||
|
delen geen project, dus dit bestand staat twee keer op schijf; een zelfcontrole vergelijkt ze
|
||||||
|
letterlijk, zodat ze niet uit elkaar gaan lopen.
|
||||||
|
|
||||||
|
Kop en knoppen staan er bewust niet in: op de website hoort er "Een account aanmaken" onder, en
|
||||||
|
in de app ben je al aangemeld. *@
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Er zit van alles in je hoofd waar niemand anders bij kan. Het wachtwoord van je e-mail,
|
||||||
|
waar de polissen liggen, hoe je je uitvaart voor je ziet, en de dingen die je nog tegen
|
||||||
|
iemand had willen zeggen. Zolang alles goed gaat maakt dat niet uit. Gaat het niet goed,
|
||||||
|
dan staan de mensen die van je houden met lege handen — op het slechtst denkbare moment.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
MijnBesluit is de plek waar dat wél ergens staat. Veilig opgeborgen zolang het van jou
|
||||||
|
alleen is, en beschikbaar op het moment dat jij hebt bepaald.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Vier dingen</h2>
|
||||||
|
<ul style="padding-left:1.2rem;margin:0 0 1.5rem">
|
||||||
|
<li style="margin-bottom:.7rem">
|
||||||
|
<strong>Je kluis.</strong> Wachtwoorden voor websites en apparaten, de pincode van
|
||||||
|
je telefoon, je sociale media, polissen, documenten, je uitvaartwensen. Alles gaat
|
||||||
|
versleuteld de opslag in. Per onderdeel kies je zelf of het alleen voor jou is,
|
||||||
|
pas na je overlijden vrijkomt, of op een datum die jij bepaalt. Je kunt een item ook
|
||||||
|
meteen — nog gewoon terwijl je er bent — met je nalatenschapsbeheerder
|
||||||
|
bespreken; die ziet het dan al, inclusief een eventuele foto of bijlage, zonder dat
|
||||||
|
daar een overlijden voor nodig is.
|
||||||
|
</li>
|
||||||
|
<li style="margin-bottom:.7rem">
|
||||||
|
<strong>Je mensen.</strong> De familie en vrienden die erbij horen. Je maakt hun
|
||||||
|
profiel aan en nodigt ze uit. Tot ze die uitnodiging aannemen beheer jij dat
|
||||||
|
profiel; daarna is het van hen en vullen ze hun eigen MijnBesluit in. Zo groeit
|
||||||
|
het vanzelf verder.
|
||||||
|
</li>
|
||||||
|
<li style="margin-bottom:.7rem">
|
||||||
|
<strong>Momenten.</strong> Een bericht of een video die je klaarzet voor iemand,
|
||||||
|
te bezorgen op een datum die jij kiest — of pas als jij er niet meer bent.
|
||||||
|
Een achttiende verjaardag, een huwelijk, of gewoon iets wat je nog wilde zeggen.
|
||||||
|
En je kunt zelf ook berichten ontvangen die een ander voor jou heeft klaargezet.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Je nalatenschap.</strong> Wie er straks mag regelen wat er geregeld moet
|
||||||
|
worden. Die persoon krijgt niet zomaar toegang: er moeten meerdere mensen
|
||||||
|
onafhankelijk melden dat je bent overleden — waaronder iemand die jij zelf hebt
|
||||||
|
aangewezen — en daarna heb jij nog een termijn om te laten weten dat je er gewoon
|
||||||
|
nog bent. Pas als die voorbij is, gaat het open.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Hoe veilig is het?</h2>
|
||||||
|
<p>
|
||||||
|
Je legt hier je wachtwoorden en je laatste wensen vast. Dan moet je weten wat daarmee
|
||||||
|
gebeurt.
|
||||||
|
</p>
|
||||||
|
<ul style="padding-left:1.2rem">
|
||||||
|
<li style="margin-bottom:.5rem">
|
||||||
|
<strong>Alles gaat versleuteld de opslag in.</strong> Elk onderdeel krijgt zijn eigen
|
||||||
|
sleutel, en die sleutel wordt zelf ook weer versleuteld. Wie de database in handen
|
||||||
|
zou krijgen, heeft daar niets aan: er staat geen leesbare regel in.
|
||||||
|
</li>
|
||||||
|
<li style="margin-bottom:.5rem">
|
||||||
|
<strong>Bijlagen net zo.</strong> Een foto van je polis of een scan van je testament
|
||||||
|
wordt versleuteld opgeslagen en pas ontsleuteld op het moment dat jij hem opent.
|
||||||
|
</li>
|
||||||
|
<li style="margin-bottom:.5rem">
|
||||||
|
<strong>Er is geen wachtwoord.</strong> Je vult je e-mailadres in en krijgt een code
|
||||||
|
die tien minuten geldig is en één keer werkt. Geen wachtwoord betekent: niets om te
|
||||||
|
stelen, te raden of te hergebruiken. De code zelf staat ook versleuteld opgeslagen,
|
||||||
|
dus zelfs wij kunnen hem niet teruglezen.
|
||||||
|
</li>
|
||||||
|
<li style="margin-bottom:.5rem">
|
||||||
|
<strong>Niemand komt bij je kluis, ook wij niet.</strong> Wat je op "alleen voor
|
||||||
|
mijzelf" zet, blijft dat. Je nalatenschapsbeheerder krijgt alleen wat jij hebt
|
||||||
|
vrijgegeven, en pas op het moment dat jij hebt bepaald.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Er wordt bijgehouden wie wat doet.</strong> Elke keer dat er iets wordt
|
||||||
|
geopend, gewijzigd of vrijgegeven, wordt dat vastgelegd. Zo is achteraf na te gaan
|
||||||
|
wat er is gebeurd.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Volledige zekerheid bestaat niet, en dat beweren we ook niet. Wat we wel doen is elke
|
||||||
|
keuze zo maken dat een fout of een inbraak zo min mogelijk oplevert.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Wat kost het?</h2>
|
||||||
|
<p style="margin-bottom:0">
|
||||||
|
Een eigen MijnBesluit is gratis. Wat je vastlegt blijft van jou, en je kunt alles wat je
|
||||||
|
hebt ingevoerd altijd zelf wijzigen of verwijderen.
|
||||||
|
</p>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
@* De versiehistorie: nieuwe of wezenlijk gewijzigde functies, nieuwste bovenaan.
|
||||||
|
|
||||||
|
Zelfde opzet als Productuitleg.razor en Privacytekst.razor: op de website een popup vanaf
|
||||||
|
het overzicht, in de app hetzelfde. Dit bestand staat twee keer op schijf - de app en de site
|
||||||
|
delen geen project - en een zelfcontrole vergelijkt ze letterlijk.
|
||||||
|
|
||||||
|
Wat hier NIET in hoort: bugfixes, tekstuele correcties, opmaak die is rechtgezet. Dit is voor
|
||||||
|
"wat kan ik nu wat ik eerst niet kon", niet een logboek van elke wijziging in de code. *@
|
||||||
|
|
||||||
|
<p class="zacht" style="margin-top:0">
|
||||||
|
Nieuwe of wezenlijk gewijzigde functies, nieuwste bovenaan.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 style="margin-top:0">20 september 2026</h2>
|
||||||
|
<ul style="padding-left:1.2rem">
|
||||||
|
<li style="margin-bottom:.5rem">
|
||||||
|
<strong>Rijbewijs en paspoort in de kluis.</strong> Twee nieuwe categorieën, met een
|
||||||
|
knop om meteen de voor- en achterkant (rijbewijs) of de foto-pagina (paspoort) te maken
|
||||||
|
of te kiezen.
|
||||||
|
</li>
|
||||||
|
<li style="margin-bottom:.5rem">
|
||||||
|
<strong>Mijn profiel als pop-up.</strong> Je eigen profiel bewerk je nu in dezelfde
|
||||||
|
pop-up met vaste "Opslaan"-knop onderin als bij de andere mensen in je familie, in de
|
||||||
|
app en op de website.
|
||||||
|
</li>
|
||||||
|
<li style="margin-bottom:.5rem">
|
||||||
|
<strong>Duidelijker voor de nalatenschapsbeheerder.</strong> Bij "Aan mij toevertrouwd"
|
||||||
|
zie je nu meteen hoeveel meldingen er nodig zijn en hoe lang de bezwaartermijn is.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Muziek en verdere wensen groeien mee.</strong> Die velden bij een kluisitem
|
||||||
|
passen zich nu vanzelf aan de lengte van de tekst aan, ook op de website.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>19 september 2026</h2>
|
||||||
|
<ul style="padding-left:1.2rem">
|
||||||
|
<li style="margin-bottom:.5rem">
|
||||||
|
<strong>Met mij gedeeld, ook in de app.</strong> Heeft iemand een kluisitem rechtstreeks
|
||||||
|
met jou gedeeld om het vast te bespreken - bijvoorbeeld een uitvaartwens - dan zie je
|
||||||
|
dat voortaan ook op je telefoon, niet meer alleen op de website.
|
||||||
|
</li>
|
||||||
|
<li style="margin-bottom:.5rem">
|
||||||
|
<strong>Een gedeeld of nagelaten kluisitem bekijken.</strong> Dat opent nu als pop-up in
|
||||||
|
dezelfde opmaak als het invulformulier, inclusief de foto's en documenten die erbij
|
||||||
|
horen.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Met de vinger naar beneden trekken ververst het scherm.</strong> Werkt op Kluis,
|
||||||
|
Mijn mensen, Momenten en Nalatenschap.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
using MijnBesluit.Client;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using Microsoft.AspNetCore.Components.Web;
|
||||||
|
|
||||||
|
namespace MijnBesluit.AppUi;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gedeelde basis voor de schermen achter het inloggen: haalt de gegevens op, houdt één
|
||||||
|
/// foutmelding bij en stuurt wie niet is aangemeld naar het inlogscherm.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class SchermBasis : ComponentBase, IDisposable
|
||||||
|
{
|
||||||
|
[Inject] protected AppToestand Toestand { get; set; } = default!;
|
||||||
|
[Inject] protected NavigationManager Navigatie { get; set; } = default!;
|
||||||
|
[Inject] private VernieuwSignaal Vernieuwen { get; set; } = default!;
|
||||||
|
|
||||||
|
/// <summary>Wordt getoond in een rode balk bovenaan het scherm.</summary>
|
||||||
|
protected string? Fout { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Er loopt iets; knoppen zijn dan uit zodat er niet twee keer wordt gedrukt.</summary>
|
||||||
|
protected bool Bezig { get; set; }
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
Toestand.Veranderd += OpVerandering;
|
||||||
|
|
||||||
|
// Op PersoonId en niet op IsAangemeld: een bewaard token zegt alleen dat we ooit zijn
|
||||||
|
// ingelogd, niet dat de app al weet wie hij is. Lukte het oppakken van de sessie de
|
||||||
|
// vorige keer niet omdat er even geen verbinding was, dan staat het token er nog en
|
||||||
|
// proberen we het hier gewoon opnieuw.
|
||||||
|
//
|
||||||
|
// En naar het inlogscherm alleen als er daarna echt geen token meer is. Dat betekent
|
||||||
|
// dat de server de sessie niet meer kent - de enige goede reden om iemand opnieuw te
|
||||||
|
// laten inloggen. Een haperende verbinding hoort dat niet te doen: dan komt er een rode
|
||||||
|
// balk "geen verbinding" en blijft de sessie staan.
|
||||||
|
if (Toestand.PersoonId == 0 && !await Toestand.HervatAsync() && !Toestand.IsAangemeld)
|
||||||
|
{
|
||||||
|
Navigatie.NavigateTo("/inloggen");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LaadAsync();
|
||||||
|
|
||||||
|
// Alleen het scherm dat nu open staat luistert naar het trek-naar-beneden-gebaar; zie
|
||||||
|
// VernieuwSignaal. Er wordt niet naar Bezig gekeken - dat gebruiken de schermen zelf al
|
||||||
|
// voor hun eigen knoppen en zou hier de verversing kunnen blokkeren zonder reden.
|
||||||
|
Vernieuwen.Gevraagd += OpVernieuwenGevraagdAsync;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Hier haalt het scherm zijn eigen gegevens op.</summary>
|
||||||
|
protected virtual Task LaadAsync() => Task.CompletedTask;
|
||||||
|
|
||||||
|
private async Task OpVernieuwenGevraagdAsync()
|
||||||
|
{
|
||||||
|
await LaadAsync();
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Voert iets uit dat kan mislukken en zet de melding klaar. Zo staat in elk scherm maar
|
||||||
|
/// één keer de afhandeling van fouten, en blijft de knop niet hangen als het misgaat.
|
||||||
|
/// </summary>
|
||||||
|
protected async Task<bool> ProbeerAsync(Func<Task<Uitkomst>> werk)
|
||||||
|
{
|
||||||
|
Bezig = true;
|
||||||
|
Fout = null;
|
||||||
|
StateHasChanged();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var uitkomst = await werk();
|
||||||
|
if (!uitkomst.Gelukt) Fout = uitkomst.Melding;
|
||||||
|
return uitkomst.Gelukt;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Bezig = false;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enter of spatie op een tegel die je normaal aantikt. Zo'n tegel is geen knop voor de
|
||||||
|
/// browser, dus zonder dit is hij alleen met een vinger of muis te bedienen - en niet met
|
||||||
|
/// een toetsenbord of een schermlezer.
|
||||||
|
/// </summary>
|
||||||
|
protected static Task ToetsAsync(KeyboardEventArgs toets, Func<Task> doen) =>
|
||||||
|
toets.Key is "Enter" or " " ? doen() : Task.CompletedTask;
|
||||||
|
|
||||||
|
private void OpVerandering() => InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
|
public virtual void Dispose()
|
||||||
|
{
|
||||||
|
Toestand.Veranderd -= OpVerandering;
|
||||||
|
Vernieuwen.Gevraagd -= OpVernieuwenGevraagdAsync;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Kleine hulpmiddelen om waarden leesbaar op het scherm te zetten.</summary>
|
||||||
|
public static class Tonen
|
||||||
|
{
|
||||||
|
// De maandnamen komen niet uit de taalinstelling van het toestel. Die bepaalde ze eerst, en
|
||||||
|
// op een telefoon die op Engels staat las de lijst "9 June 2031" terwijl de rest van het
|
||||||
|
// scherm Nederlands is. Een app die maar in één taal bestaat hoort ook maar in één taal te
|
||||||
|
// rekenen.
|
||||||
|
//
|
||||||
|
// De lijst zelf staat in MijnBesluit.Client, naast de website-versie in het domein; een
|
||||||
|
// zelfcontrole houdt die twee gelijk. De website liep tegen precies hetzelfde aan.
|
||||||
|
private static string[] Maanden => MijnBesluit.Client.Datums.Maanden;
|
||||||
|
private static string[] KorteMaanden => MijnBesluit.Client.Datums.KorteMaanden;
|
||||||
|
|
||||||
|
public static string Datum(DateTime? moment)
|
||||||
|
{
|
||||||
|
if (moment is null) return "";
|
||||||
|
var d = moment.Value.ToLocalTime();
|
||||||
|
return $"{d.Day} {Maanden[d.Month - 1]} {d.Year}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string DatumTijd(DateTime? moment)
|
||||||
|
{
|
||||||
|
if (moment is null) return "";
|
||||||
|
var d = moment.Value.ToLocalTime();
|
||||||
|
return $"{Datum(d)} om {d:HH:mm}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// De datum zoals hij op een tegel staat: "9 Jun. 2031". Daar telt elke pixel, en de maand
|
||||||
|
/// voluit duwde het jaartal van de regel af - "22 Septem...".
|
||||||
|
/// </summary>
|
||||||
|
public static string Kort(DateTime? moment)
|
||||||
|
{
|
||||||
|
if (moment is null) return "";
|
||||||
|
var d = moment.Value.ToLocalTime();
|
||||||
|
return $"{d.Day} {KorteMaanden[d.Month - 1]} {d.Year}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hetzelfde voor een kale datum zonder tijd - een geboortedag, een sterfdag. Die hoort
|
||||||
|
/// niet omgerekend te worden naar een tijdzone: 9 juni is 9 juni, waar je ook bent.
|
||||||
|
/// </summary>
|
||||||
|
public static string Kort(DateOnly? dag) =>
|
||||||
|
dag is null ? "" : $"{dag.Value.Day} {KorteMaanden[dag.Value.Month - 1]} {dag.Value.Year}";
|
||||||
|
|
||||||
|
/// <summary>De nummers komen overeen met VaultCategory in het domein.</summary>
|
||||||
|
public static string Categorie(int categorie) => categorie switch
|
||||||
|
{
|
||||||
|
1 => "Uitvaartwens",
|
||||||
|
2 => "Polis",
|
||||||
|
3 => "Wachtwoord",
|
||||||
|
4 => "Apparaat",
|
||||||
|
5 => "Pincode",
|
||||||
|
6 => "Document",
|
||||||
|
7 => "Notitie",
|
||||||
|
8 => "Sociale media",
|
||||||
|
_ => "Overig"
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hoe een relatie op het scherm heet. De lijst staat in MijnBesluit.Client, zodat de app en
|
||||||
|
/// de website er niet ieder een eigen versie van hebben.
|
||||||
|
/// </summary>
|
||||||
|
public static string Relatie(int relatie) => MijnBesluit.Client.Relaties.Naam(relatie);
|
||||||
|
|
||||||
|
public static string Vrijgave(int beleid) => beleid switch
|
||||||
|
{
|
||||||
|
0 => "Alleen voor mijzelf",
|
||||||
|
1 => "Na mijn overlijden",
|
||||||
|
2 => "Op een datum",
|
||||||
|
_ => ""
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Het tekentje bij de vrijgave. Het takje is hetzelfde als dat van Nalatenschap onderin,
|
||||||
|
/// en de kalender staat voor een datum - zo hoeft niemand een nieuwe taal te leren.
|
||||||
|
///
|
||||||
|
/// Voor "Alleen voor mijzelf" staat hier niets: dat ben jij, en daar hoort je eigen
|
||||||
|
/// profielfoto, geen plaatje. Het scherm zet daar een penning neer.
|
||||||
|
/// </summary>
|
||||||
|
public static string Vrijgaveteken(int beleid) => beleid switch
|
||||||
|
{
|
||||||
|
1 => "\U0001F33F", // takje, net als bij Nalatenschap
|
||||||
|
2 => "\U0001F4C5", // kalender
|
||||||
|
_ => ""
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,568 @@
|
|||||||
|
@page "/familie"
|
||||||
|
@inherits SchermBasis
|
||||||
|
@using Microsoft.JSInterop
|
||||||
|
@inject IBestandskiezer Bestandskiezer
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
|
||||||
|
@* Familie samenstellen en uitnodigen. Dit is het hart van MijnBesluit: zodra iemand de
|
||||||
|
uitnodiging aanneemt en zelf zijn familie invult, wordt het profiel van hem. Jij ziet hem
|
||||||
|
dan nog wel staan, maar kunt er niets meer in wijzigen. Tot dat moment mag je alles
|
||||||
|
aanpassen - ook een e-mailadres waar een tikfout in zit.
|
||||||
|
|
||||||
|
De kop staat in de balk erboven, dus hier niet nog een keer. *@
|
||||||
|
|
||||||
|
@if (Fout is not null)
|
||||||
|
{
|
||||||
|
<div class="melding fout">@Fout</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (Gelukt is not null)
|
||||||
|
{
|
||||||
|
<div class="melding goed">@Gelukt</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<button class="knop vol" style="margin-bottom:1rem" disabled="@Bezig" @onclick="Nieuw">
|
||||||
|
Toevoegen persoon
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@* Jezelf bovenaan. Je staat verderop niet tussen de anderen - je bent geen familielid van
|
||||||
|
jezelf - maar dit is wel de plek waar mensen je zoeken als ze hun eigen gegevens willen
|
||||||
|
wijzigen. De kaart doet zelf niets; hij brengt je naar Mijn profiel. *@
|
||||||
|
<a class="kaart compact klikbaar zelf" href="/profiel" title="Mijn profiel">
|
||||||
|
<div class="rij persoon">
|
||||||
|
<Penning Naam="@Mijnnaam" Bron="@Ikzelf?.Profielfotobron" />
|
||||||
|
<div class="groei">
|
||||||
|
<span class="groot" title="@Mijnnaam">@Mijnnaam</span>
|
||||||
|
<div class="onderregel">
|
||||||
|
<span class="zacht">Je eigen gegevens en foto</span>
|
||||||
|
<span class="label geclaimd">Jijzelf</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="stapel">
|
||||||
|
@foreach (var lid in Leden.Where(l => l.PersonId != Toestand.PersoonId))
|
||||||
|
{
|
||||||
|
@* Dezelfde tegel als bij Momenten: foto voorop, knoppen als tekentjes rechtsonder.
|
||||||
|
Aantikken opent het wijzigscherm. Een profiel dat iemand zelf beheert mag je niet
|
||||||
|
wijzigen en heeft geen knoppen: dat is dus geen tegel en ook niet aan te tikken. *@
|
||||||
|
<div class="kaart compact @Tegelklasse(lid)"
|
||||||
|
role="@(lid.IsGeclaimd ? null : "button")" tabindex="@(lid.IsGeclaimd ? null : "0")"
|
||||||
|
title="@(lid.IsGeclaimd ? null : "Gegevens wijzigen")"
|
||||||
|
@onclick="() => TegelWijzigenAsync(lid)"
|
||||||
|
@onkeydown="e => ToetsAsync(e, () => TegelWijzigenAsync(lid))">
|
||||||
|
<div class="rij persoon">
|
||||||
|
@* De profielfoto als die er is, anders de beginletters. Even groot, zodat de
|
||||||
|
lijst niet verspringt zodra er ergens een foto bij komt. *@
|
||||||
|
<Penning Naam="@lid.DisplayName" Bron="@lid.Profielfotobron" />
|
||||||
|
|
||||||
|
@* Drie regels, net als bij Momenten en Mijn kluis: naam, relatie met de status
|
||||||
|
rechts, en daaronder de regel met de knoppen. Op Mijn mensen staat op die
|
||||||
|
onderste regel verder niets - dat mag. Zo staat de status op elke tegel
|
||||||
|
pal boven de knoppen, en op elke tegel op dezelfde plek. *@
|
||||||
|
<div class="groei">
|
||||||
|
<span class="groot" title="@lid.DisplayName">@lid.DisplayName</span>
|
||||||
|
|
||||||
|
<div class="onderregel">
|
||||||
|
<span class="zacht">@Omschrijving(lid)</span>
|
||||||
|
|
||||||
|
@switch (lid.State)
|
||||||
|
{
|
||||||
|
case ProfielStand.Geclaimd:
|
||||||
|
<span class="label geclaimd" title="@lid.DisplayName beheert dit profiel zelf">Geclaimd</span>
|
||||||
|
break;
|
||||||
|
case ProfielStand.Uitgenodigd:
|
||||||
|
<span class="label uitgenodigd" title="De uitnodiging staat nog open">Uitgenodigd</span>
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
<span class="label" title="Jij beheert dit profiel">Door jou</span>
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Een eigen regel, buiten .groei en dus uitgelijnd met de linkerkant van de foto -
|
||||||
|
niet met de naam ernaast. Wijzigen staat er niet bij: daarvoor tik je de tegel
|
||||||
|
zelf aan. stopPropagation, want anders opent een druk op de prullenbak ook meteen
|
||||||
|
het wijzigscherm. Wat elke knop doet staat in title en aria-label, dus een
|
||||||
|
schermlezer hoort het gewoon. *@
|
||||||
|
<div class="onderregel" style="margin-top:.5rem">
|
||||||
|
@* Bij een geclaimd profiel staat hier wanneer dat gebeurde, op dezelfde regel
|
||||||
|
als de prullenbak. Kort: "Geclaimd op 15 Sep. 2026" past ernaast, zonder dat
|
||||||
|
de tegel hoger wordt. *@
|
||||||
|
<span class="zacht">
|
||||||
|
@if (lid.IsGeclaimd)
|
||||||
|
{
|
||||||
|
@:Geclaimd@(lid.ClaimedAt is { } sinds ? $" op {Tonen.Kort(sinds)}" : "").
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
@* De prullenbak staat op ELKE tegel, ook bij een profiel dat iemand zelf
|
||||||
|
beheert. Dat je Karin uit je lijst haalt zegt niets over haar eigen
|
||||||
|
MijnBesluit: haar profiel blijft van haar, het verdwijnt alleen hier.
|
||||||
|
Dat dit eerder niet kon was geen regel maar een gevolg - de knoppen stonden
|
||||||
|
samen in één "alleen als je mag wijzigen"-blok, en weghalen is iets anders
|
||||||
|
dan wijzigen.
|
||||||
|
|
||||||
|
Jezelf staat hier niet tussen; daarvoor hef je je account op. *@
|
||||||
|
<span class="tegelknoppen">
|
||||||
|
@if (!lid.IsGeclaimd && !string.IsNullOrWhiteSpace(lid.Email))
|
||||||
|
{
|
||||||
|
<button class="knop rustig klein" disabled="@Bezig" @onclick="() => UitnodigenAsync(lid)"
|
||||||
|
@onclick:stopPropagation="true"
|
||||||
|
title="@(lid.State == ProfielStand.Uitgenodigd ? "Opnieuw uitnodigen" : "Uitnodigen")"
|
||||||
|
aria-label="@(lid.State == ProfielStand.Uitgenodigd ? "Opnieuw uitnodigen" : "Uitnodigen")">✉️</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
<button class="knop rustig klein" disabled="@Bezig" @onclick="() => Weghalen = lid"
|
||||||
|
@onclick:stopPropagation="true"
|
||||||
|
title="Bij je mensen weghalen" aria-label="Bij je mensen weghalen">🗑️</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (Leden.Count <= 1)
|
||||||
|
{
|
||||||
|
<div class="leeg">
|
||||||
|
<span class="teken">👥</span>
|
||||||
|
Er staat hier nog niemand. Voeg je partner of je kinderen toe;
|
||||||
|
zij krijgen een uitnodiging en kunnen daarna hun eigen MijnBesluit invullen.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ---------------------------------------------------------------- toevoegen en wijzigen *@
|
||||||
|
@if (Bewerken is not null)
|
||||||
|
{
|
||||||
|
<Popup Titel="@(Bewerken.PersoonId == 0 ? "Iemand toevoegen" : "Gegevens wijzigen")"
|
||||||
|
Bevestigtekst="Opslaan" Bezig="@Bezig" Foutmelding="@Fout"
|
||||||
|
OpBevestigen="OpslaanAsync" OpSluiten="Sluiten">
|
||||||
|
|
||||||
|
<div class="raster">
|
||||||
|
<div class="veld"><label for="vn">Voornaam</label><input id="vn" @bind="Bewerken.Voornaam" /></div>
|
||||||
|
<div class="veld"><label for="an">Achternaam</label><input id="an" @bind="Bewerken.Achternaam" /></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="veld">
|
||||||
|
<label for="rel">Wat is het van je?</label>
|
||||||
|
<select id="rel" @bind="Bewerken.Relatie">
|
||||||
|
@foreach (var (waarde, naam) in Relaties)
|
||||||
|
{
|
||||||
|
<option value="@waarde">@naam</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="veld">
|
||||||
|
<label>Geboortedatum</label>
|
||||||
|
<Datumkiezer Waarde="Bewerken.Geboortedatum"
|
||||||
|
WaardeChanged="d => Bewerken.Geboortedatum = d"
|
||||||
|
Optioneel="true" Aflopend="true"
|
||||||
|
VanJaar="1900" TotJaar="@DateTime.Today.Year" />
|
||||||
|
<div class="uitleg">Niet verplicht. Met een datum staat je familie bovenaan op leeftijd.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="veld">
|
||||||
|
<label for="em">E-mailadres</label>
|
||||||
|
<input id="em" type="email" inputmode="email" @bind="Bewerken.Email" />
|
||||||
|
<div class="uitleg">Met een adres kun je hem uitnodigen. Zonder adres beheer je het profiel zelf.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (Bewerken.PersoonId == 0)
|
||||||
|
{
|
||||||
|
<div class="veld">
|
||||||
|
<label>Foto's en documenten</label>
|
||||||
|
<div class="uitleg">
|
||||||
|
Die kun je toevoegen zodra deze persoon is opgeslagen; ze horen bij hem en
|
||||||
|
hij moet er eerst zijn.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<Bijlagenblok Bijlagen="Bijlagen" Bezig="@Bezig"
|
||||||
|
KanVideo="true" KanCamera="@Bestandskiezer.KanCamera" KanProfielfoto="true"
|
||||||
|
Overzetten="@Overzetten" Percentage="@Percentage"
|
||||||
|
Uitleg="Een foto van hem samen met jou, een kopie van zijn paspoort, een brief. Met het sterretje maak je er zijn profielfoto van."
|
||||||
|
OpFotoMaken="() => ToevoegenAsync(Soort.FotoMaken)"
|
||||||
|
OpFoto="() => ToevoegenAsync(Soort.Foto)"
|
||||||
|
OpDocument="() => ToevoegenAsync(Soort.Document)"
|
||||||
|
OpVideo="() => ToevoegenAsync(Soort.VideoOpnemen)"
|
||||||
|
OpVideoKiezen="() => ToevoegenAsync(Soort.Video)"
|
||||||
|
OpVerwijderen="BijlageWegAsync"
|
||||||
|
OpProfielfoto="ProfielfotoAsync" />
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (BewerkLid is { } lid)
|
||||||
|
{
|
||||||
|
<div class="zacht geschiedenis">
|
||||||
|
<div>Profiel aangemaakt op @Tonen.Datum(lid.CreatedAt)</div>
|
||||||
|
|
||||||
|
@if (lid.InvitedAt is { } verstuurd)
|
||||||
|
{
|
||||||
|
<div>
|
||||||
|
Uitgenodigd op @Tonen.Datum(verstuurd)@(lid.InvitationResends > 0
|
||||||
|
? $" ({lid.InvitationResends + 1}× verstuurd)" : "")
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (lid.InvitationExpiresAt is { } tot)
|
||||||
|
{
|
||||||
|
<div>
|
||||||
|
@(tot > DateTime.UtcNow
|
||||||
|
? $"Uitnodiging geldig tot {Tonen.Datum(tot)}"
|
||||||
|
: $"Uitnodiging verlopen op {Tonen.Datum(tot)}")
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div>Nog niet uitgenodigd.</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</Popup>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ---------------------------------------------------------------- verwijderen *@
|
||||||
|
@if (Weghalen is not null)
|
||||||
|
{
|
||||||
|
@* Drie gevallen, en ze verdienen alle drie een ander scherm.
|
||||||
|
|
||||||
|
1. Hij beheert je nalatenschap. Dan kan het niet, en zegt dit scherm waar je dat weghaalt.
|
||||||
|
De server weigert het ook; dit is er zodat je niet eerst op "verwijderen" hoeft te
|
||||||
|
drukken om erachter te komen.
|
||||||
|
2. Er staan berichten voor hem klaar. Die verdwijnen mee, en dat is het soort ding dat je
|
||||||
|
wilt weten vóór je ja zegt - niet erna.
|
||||||
|
3. Gewoon weghalen. *@
|
||||||
|
@if (Weghalen.IsBeheerder)
|
||||||
|
{
|
||||||
|
<Popup Titel="@($"{Weghalen.DisplayName} kan niet weg")"
|
||||||
|
Bevestigtekst="Begrepen" AlleenSluiten="true" Bezig="@Bezig"
|
||||||
|
OpBevestigen="() => Weghalen = null" OpSluiten="() => Weghalen = null">
|
||||||
|
|
||||||
|
<p>
|
||||||
|
@Weghalen.DisplayName is aangewezen om je nalatenschap te beheren. Zolang dat zo
|
||||||
|
is, hoort hij bij je mensen te staan.
|
||||||
|
</p>
|
||||||
|
<p class="zacht">
|
||||||
|
Haal hem eerst weg bij <strong>Nalatenschap</strong>. Dat is met opzet een aparte
|
||||||
|
handeling: wie je nalatenschap beheert, is een besluit dat je niet per ongeluk
|
||||||
|
terugdraait door iemand uit een lijst te vegen.
|
||||||
|
</p>
|
||||||
|
</Popup>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<Popup Titel="@($"{Weghalen.DisplayName} verwijderen?")"
|
||||||
|
Bevestigtekst="Verwijderen" Gevaarlijk="true" Bezig="@Bezig"
|
||||||
|
OpBevestigen="VerwijderenAsync" OpSluiten="() => Weghalen = null">
|
||||||
|
|
||||||
|
<p>
|
||||||
|
@Weghalen.DisplayName verdwijnt uit je lijst. Een uitnodiging die nog openstaat
|
||||||
|
vervalt daarmee.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@if (Weghalen.MomentenKlaar > 0)
|
||||||
|
{
|
||||||
|
<div class="melding let-op">
|
||||||
|
Er @(Weghalen.MomentenKlaar == 1 ? "staat nog 1 moment" : $"staan nog {Weghalen.MomentenKlaar} momenten")
|
||||||
|
voor @Weghalen.DisplayName klaar. @(Weghalen.MomentenKlaar == 1 ? "Dat verdwijnt" : "Die verdwijnen") mee
|
||||||
|
en @(Weghalen.MomentenKlaar == 1 ? "wordt" : "worden") dus nooit bezorgd.
|
||||||
|
Wat al bezorgd is blijft staan.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<p class="zacht">
|
||||||
|
@if (Weghalen.IsGeclaimd)
|
||||||
|
{
|
||||||
|
<text>
|
||||||
|
@Weghalen.DisplayName beheert dit profiel zelf. Het verdwijnt alleen bij
|
||||||
|
jou; zijn eigen MijnBesluit blijft zoals het is.
|
||||||
|
</text>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<text>
|
||||||
|
Het profiel zelf blijft bestaan; hij kan in andere families staan of zijn
|
||||||
|
eigen MijnBesluit hebben.
|
||||||
|
</text>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</Popup>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private IReadOnlyList<Familielid> Leden = Array.Empty<Familielid>();
|
||||||
|
|
||||||
|
/// <summary>Jouw eigen regel uit de ledenlijst; daar staat je profielfoto in.</summary>
|
||||||
|
private Familielid? Ikzelf => Leden.FirstOrDefault(l => l.PersonId == Toestand.PersoonId);
|
||||||
|
|
||||||
|
private string Mijnnaam => Ikzelf?.DisplayName is { Length: > 0 } naam ? naam : "Mijn profiel";
|
||||||
|
private Formulier? Bewerken;
|
||||||
|
|
||||||
|
/// <summary>Het lid dat bij <see cref="Bewerken"/> hoort; null bij toevoegen.
|
||||||
|
/// Nodig omdat het formulier alleen de te wijzigen velden bevat, niet de geschiedenis.</summary>
|
||||||
|
private Familielid? BewerkLid;
|
||||||
|
|
||||||
|
private string? Gelukt;
|
||||||
|
|
||||||
|
/// <summary>Wie er op verwijderen staat te wachten; null als er niemand weg hoeft.</summary>
|
||||||
|
private Familielid? Weghalen;
|
||||||
|
|
||||||
|
/// <summary>De foto's en documenten bij de persoon die in het wijzigscherm staat.</summary>
|
||||||
|
private IReadOnlyList<Bijlage> Bijlagen = Array.Empty<Bijlage>();
|
||||||
|
|
||||||
|
/// <summary>Tekst bij de voortgangsbalk tijdens een overdracht; null als er niets loopt.</summary>
|
||||||
|
private string? Overzetten;
|
||||||
|
private int Percentage;
|
||||||
|
|
||||||
|
private enum Soort { Document, Foto, FotoMaken, Video, VideoOpnemen }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wat je kunt kiezen. De lijst zelf staat in MijnBesluit.Client, naast de website-versie in
|
||||||
|
/// het domein; een zelfcontrole houdt die twee gelijk.
|
||||||
|
/// </summary>
|
||||||
|
private static (int Waarde, string Naam)[] Relaties => MijnBesluit.Client.Relaties.Keuzes;
|
||||||
|
|
||||||
|
protected override async Task LaadAsync()
|
||||||
|
{
|
||||||
|
if (Toestand.Familie is null)
|
||||||
|
{
|
||||||
|
var ververst = await Toestand.VerversAsync();
|
||||||
|
if (!ververst.Gelukt) { Fout = ververst.Melding; return; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Toestand.Familie is null) return;
|
||||||
|
|
||||||
|
var leden = await Toestand.Client.FamilieledenAsync(Toestand.Familie.Id);
|
||||||
|
if (leden.Gelukt) Leden = leden.Waarde!;
|
||||||
|
else Fout = leden.Melding;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Nieuw()
|
||||||
|
{
|
||||||
|
Gelukt = null;
|
||||||
|
Fout = null;
|
||||||
|
Weghalen = null;
|
||||||
|
BewerkLid = null;
|
||||||
|
Bijlagen = Array.Empty<Bijlage>();
|
||||||
|
Bewerken = new Formulier();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task WijzigenAsync(Familielid lid)
|
||||||
|
{
|
||||||
|
Gelukt = null;
|
||||||
|
Fout = null;
|
||||||
|
Weghalen = null;
|
||||||
|
BewerkLid = lid;
|
||||||
|
Bewerken = new Formulier
|
||||||
|
{
|
||||||
|
PersoonId = lid.PersonId,
|
||||||
|
Voornaam = lid.FirstName,
|
||||||
|
Achternaam = lid.LastName,
|
||||||
|
Email = lid.Email ?? "",
|
||||||
|
Relatie = lid.Relation,
|
||||||
|
Geboortedatum = lid.BirthDate?.ToDateTime(TimeOnly.MinValue)
|
||||||
|
};
|
||||||
|
|
||||||
|
await BijlagenLadenAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OpslaanAsync()
|
||||||
|
{
|
||||||
|
var formulier = Bewerken!;
|
||||||
|
if (string.IsNullOrWhiteSpace(formulier.Voornaam) && string.IsNullOrWhiteSpace(formulier.Achternaam))
|
||||||
|
{
|
||||||
|
Fout = "Vul minstens een voor- of achternaam in.";
|
||||||
|
await JS.InvokeVoidAsync("mijnbesluit.springNaarVeld", "vn");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var email = string.IsNullOrWhiteSpace(formulier.Email) ? null : formulier.Email.Trim();
|
||||||
|
|
||||||
|
if (await ProbeerAsync(async () =>
|
||||||
|
{
|
||||||
|
var geboren = formulier.Geboortedatum is { } g ? DateOnly.FromDateTime(g) : (DateOnly?)null;
|
||||||
|
|
||||||
|
var r = formulier.PersoonId == 0
|
||||||
|
? await Toestand.Client.VoegFamilielidToeAsync(
|
||||||
|
Toestand.Familie!.Id, formulier.Voornaam.Trim(), formulier.Achternaam.Trim(),
|
||||||
|
email, formulier.Relatie, geboren)
|
||||||
|
: await Toestand.Client.WijzigFamilielidAsync(
|
||||||
|
Toestand.Familie!.Id, formulier.PersoonId, formulier.Voornaam.Trim(),
|
||||||
|
formulier.Achternaam.Trim(), email, formulier.Relatie, null, geboren);
|
||||||
|
|
||||||
|
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
|
||||||
|
}))
|
||||||
|
{
|
||||||
|
Gelukt = formulier.PersoonId == 0 ? "Toegevoegd." : "De wijziging is opgeslagen.";
|
||||||
|
Sluiten();
|
||||||
|
await LaadAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task VerwijderenAsync()
|
||||||
|
{
|
||||||
|
var lid = Weghalen!;
|
||||||
|
Gelukt = null;
|
||||||
|
|
||||||
|
if (await ProbeerAsync(async () =>
|
||||||
|
{
|
||||||
|
var r = await Toestand.Client.VerwijderFamilielidAsync(Toestand.Familie!.Id, lid.PersonId);
|
||||||
|
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
|
||||||
|
}))
|
||||||
|
{
|
||||||
|
Gelukt = $"{lid.DisplayName} staat hier niet meer bij.";
|
||||||
|
Weghalen = null;
|
||||||
|
await LaadAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UitnodigenAsync(Familielid lid)
|
||||||
|
{
|
||||||
|
Gelukt = null;
|
||||||
|
if (await ProbeerAsync(async () =>
|
||||||
|
{
|
||||||
|
var r = await Toestand.Client.NodigUitAsync(Toestand.Familie!.Id, lid.PersonId);
|
||||||
|
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
|
||||||
|
}))
|
||||||
|
{
|
||||||
|
Gelukt = $"De uitnodiging is naar {lid.Email} gestuurd.";
|
||||||
|
await LaadAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ foto's en documenten
|
||||||
|
|
||||||
|
private async Task BijlagenLadenAsync()
|
||||||
|
{
|
||||||
|
if (Bewerken is not { PersoonId: > 0 } formulier) { Bijlagen = Array.Empty<Bijlage>(); return; }
|
||||||
|
|
||||||
|
var lijst = await Toestand.Client.PersoonsfotosAsync(formulier.PersoonId);
|
||||||
|
Bijlagen = lijst.Gelukt ? lijst.Waarde! : Array.Empty<Bijlage>();
|
||||||
|
if (!lijst.Gelukt) Fout = lijst.Melding;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ToevoegenAsync(Soort soort)
|
||||||
|
{
|
||||||
|
if (Bewerken is not { PersoonId: > 0 } formulier) return;
|
||||||
|
|
||||||
|
Fout = null;
|
||||||
|
var gekozen = soort switch
|
||||||
|
{
|
||||||
|
Soort.Document => await Bestandskiezer.KiesDocumentAsync(),
|
||||||
|
Soort.Foto => await Bestandskiezer.KiesFotoAsync(),
|
||||||
|
Soort.FotoMaken => await Bestandskiezer.MaakFotoAsync(),
|
||||||
|
Soort.Video => await Bestandskiezer.KiesVideoAsync(),
|
||||||
|
_ => await Bestandskiezer.NeemVideoOpAsync()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Niets gekozen of afgebroken: gewoon niets doen, geen foutmelding.
|
||||||
|
if (gekozen is null) return;
|
||||||
|
|
||||||
|
Overzetten = $"{gekozen.Naam} wordt opgeslagen…";
|
||||||
|
Percentage = 0;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
var uitkomst = await Toestand.Client.BestandVersturenAsync(
|
||||||
|
gekozen.Naam, gekozen.Soort, gekozen.Inhoud, null,
|
||||||
|
(gedaan, totaal) =>
|
||||||
|
{
|
||||||
|
Percentage = totaal == 0 ? 100 : (int)(gedaan * 100 / totaal);
|
||||||
|
InvokeAsync(StateHasChanged);
|
||||||
|
},
|
||||||
|
persoonId: formulier.PersoonId);
|
||||||
|
|
||||||
|
Overzetten = null;
|
||||||
|
if (!uitkomst.Gelukt) Fout = uitkomst.Melding;
|
||||||
|
else await BijlagenLadenAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task BijlageWegAsync(Bijlage bijlage)
|
||||||
|
{
|
||||||
|
if (await ProbeerAsync(async () =>
|
||||||
|
{
|
||||||
|
var r = await Toestand.Client.BijlageVerwijderenAsync(bijlage.Id);
|
||||||
|
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
|
||||||
|
}))
|
||||||
|
{
|
||||||
|
await BijlagenLadenAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Wijst deze foto aan als profielfoto; een eerdere profielfoto blijft gewoon staan.</summary>
|
||||||
|
private async Task ProfielfotoAsync(Bijlage bijlage)
|
||||||
|
{
|
||||||
|
if (await ProbeerAsync(async () =>
|
||||||
|
{
|
||||||
|
var r = await Toestand.Client.ProfielfotoAsync(bijlage.Id);
|
||||||
|
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
|
||||||
|
}))
|
||||||
|
{
|
||||||
|
await BijlagenLadenAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Sluiten()
|
||||||
|
{
|
||||||
|
Bewerken = null;
|
||||||
|
BewerkLid = null;
|
||||||
|
Bijlagen = Array.Empty<Bijlage>();
|
||||||
|
Overzetten = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Eén formulier voor toevoegen en wijzigen; PersoonId 0 betekent nieuw.</summary>
|
||||||
|
private class Formulier
|
||||||
|
{
|
||||||
|
public long PersoonId { get; set; }
|
||||||
|
public string Voornaam { get; set; } = "";
|
||||||
|
public string Achternaam { get; set; } = "";
|
||||||
|
public string Email { get; set; } = "";
|
||||||
|
public int Relatie { get; set; } = 1;
|
||||||
|
|
||||||
|
/// <summary>Null als er geen geboortedatum is ingevuld; dat mag.</summary>
|
||||||
|
public DateTime? Geboortedatum { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// De regel onder de naam: "Partner (59)". Kort gehouden, want de status staat ernaast en
|
||||||
|
/// die heeft ook ruimte nodig. Is iemand overleden, dan is dat belangrijker dan zijn leeftijd.
|
||||||
|
/// </summary>
|
||||||
|
/// <summary>
|
||||||
|
/// Welke tegelklasse deze kaart krijgt. Bij een profiel dat iemand zelf beheert staan er
|
||||||
|
/// geen knoppen en valt er niets te wijzigen: dan is het geen tegel en ook niet aan te
|
||||||
|
/// tikken.
|
||||||
|
/// </summary>
|
||||||
|
private static string Tegelklasse(Familielid lid) =>
|
||||||
|
lid.IsGeclaimd ? "" : "tegel klikbaar";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// De tegel is aangetikt. Een profiel dat iemand zelf beheert mag je niet wijzigen, dus
|
||||||
|
/// dan gebeurt er niets.
|
||||||
|
/// </summary>
|
||||||
|
private Task TegelWijzigenAsync(Familielid lid) =>
|
||||||
|
lid.IsGeclaimd ? Task.CompletedTask : WijzigenAsync(lid);
|
||||||
|
|
||||||
|
private static string Omschrijving(Familielid lid)
|
||||||
|
{
|
||||||
|
var relatie = Tonen.Relatie(lid.Relation);
|
||||||
|
|
||||||
|
if (lid.DateOfDeath is { } overleden)
|
||||||
|
return $"{relatie} · overleden {Tonen.Kort(overleden)}";
|
||||||
|
|
||||||
|
return lid.BirthDate is { } geboren ? $"{relatie} ({Leeftijd(geboren)})" : relatie;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Leeftijd in hele jaren, zoals je hem zelf zou uitrekenen.</summary>
|
||||||
|
private static int Leeftijd(DateOnly geboren)
|
||||||
|
{
|
||||||
|
var vandaag = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var jaren = vandaag.Year - geboren.Year;
|
||||||
|
if (geboren.AddYears(jaren) > vandaag) jaren--;
|
||||||
|
return jaren;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
@page "/inloggen"
|
||||||
|
@inject AppToestand Toestand
|
||||||
|
@inject NavigationManager Navigatie
|
||||||
|
|
||||||
|
@* Aanmelden gaat in twee stappen: e-mailadres, dan de code uit de mail. Geen wachtwoorden;
|
||||||
|
er valt dus ook niets te vergeten of te laten uitlekken. *@
|
||||||
|
|
||||||
|
<div class="middenblok">
|
||||||
|
@* Hier stond het logo groot. Weggehaald: de balk erboven zegt al "Inloggen", en het logo
|
||||||
|
duwde "Welkom terug" en het invulveld zo ver naar beneden dat je op een telefoon eerst
|
||||||
|
naar een leeg scherm keek. Op de website staat hij wel: daar is de pagina hoog genoeg en
|
||||||
|
is het het eerste wat iemand van MijnBesluit ziet. *@
|
||||||
|
<h1>@(NieuwAccount ? "Begin je eigen MijnBesluit" : "Welkom terug")</h1>
|
||||||
|
<p class="zacht">
|
||||||
|
@(NieuwAccount
|
||||||
|
? "Je legt vast wat je wilt regelen, en zet berichten klaar voor de mensen om je heen."
|
||||||
|
: "Vul je e-mailadres in; je krijgt een code van zes cijfers om mee binnen te komen.")
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@if (Fout is not null)
|
||||||
|
{
|
||||||
|
<div class="melding fout">@Fout</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="kaart">
|
||||||
|
@if (!CodeVerstuurd)
|
||||||
|
{
|
||||||
|
@if (NieuwAccount)
|
||||||
|
{
|
||||||
|
<div class="raster">
|
||||||
|
<div class="veld">
|
||||||
|
<label for="voornaam">Voornaam</label>
|
||||||
|
<input id="voornaam" @bind="Voornaam" autocomplete="given-name" />
|
||||||
|
</div>
|
||||||
|
<div class="veld">
|
||||||
|
<label for="achternaam">Achternaam</label>
|
||||||
|
<input id="achternaam" @bind="Achternaam" autocomplete="family-name" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="veld">
|
||||||
|
<label for="email">E-mailadres</label>
|
||||||
|
<input id="email" type="email" inputmode="email" autocomplete="email"
|
||||||
|
@bind="Email" @bind:event="oninput" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="knop vol" disabled="@Bezig" @onclick="CodeAanvragenAsync">
|
||||||
|
@(Bezig ? "Even geduld…" : "Code aanvragen")
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p>We hebben een code gestuurd naar <span class="naar-adres">@Afgeschermd</span>.</p>
|
||||||
|
|
||||||
|
<div class="veld">
|
||||||
|
<label for="code">Code uit de e-mail</label>
|
||||||
|
<input id="code" class="codeveld" inputmode="numeric" autocomplete="one-time-code"
|
||||||
|
maxlength="7" @bind="Code" @bind:event="oninput" />
|
||||||
|
<div class="uitleg">De code blijft een kwartier geldig.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="knop vol" disabled="@(Bezig || Code.Length < 6)" @onclick="AanmeldenAsync">
|
||||||
|
@(Bezig ? "Even geduld…" : "Verder")
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="knop rustig vol" style="margin-top:.6rem" disabled="@Bezig" @onclick="OpnieuwAsync">
|
||||||
|
Ik heb geen code ontvangen
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="voet">
|
||||||
|
@if (NieuwAccount)
|
||||||
|
{
|
||||||
|
<span>Heb je al een account? <a href="" @onclick="NaarInloggen" @onclick:preventDefault>Inloggen</a>.</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span>Nog geen account? <a href="" @onclick="NaarRegistreren" @onclick:preventDefault>Begin hier</a>.</span>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter, SupplyParameterFromQuery(Name = "nieuw")]
|
||||||
|
public bool NieuwAccount { get; set; }
|
||||||
|
|
||||||
|
private string Email = "";
|
||||||
|
private string Voornaam = "";
|
||||||
|
private string Achternaam = "";
|
||||||
|
private string Code = "";
|
||||||
|
private string? Afgeschermd;
|
||||||
|
private bool CodeVerstuurd;
|
||||||
|
private bool Bezig;
|
||||||
|
private string? Fout;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
// Wie nog een geldige sessie op het toestel heeft, hoeft hier niet te zijn.
|
||||||
|
if (await Toestand.HervatAsync()) Navigatie.NavigateTo("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CodeAanvragenAsync()
|
||||||
|
{
|
||||||
|
Bezig = true;
|
||||||
|
Fout = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var uitkomst = NieuwAccount
|
||||||
|
? await Toestand.Client.VraagRegistratiecodeAanAsync(Email.Trim(), Voornaam.Trim(), Achternaam.Trim())
|
||||||
|
: await Toestand.Client.VraagInlogcodeAanAsync(Email.Trim());
|
||||||
|
|
||||||
|
if (!uitkomst.Gelukt) { Fout = uitkomst.Melding; return; }
|
||||||
|
|
||||||
|
Afgeschermd = uitkomst.Waarde!.VerstuurdNaar;
|
||||||
|
CodeVerstuurd = true;
|
||||||
|
Code = "";
|
||||||
|
}
|
||||||
|
finally { Bezig = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AanmeldenAsync()
|
||||||
|
{
|
||||||
|
Bezig = true;
|
||||||
|
Fout = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var uitkomst = NieuwAccount
|
||||||
|
? await Toestand.Client.RegistreerAsync(Email.Trim(), Code)
|
||||||
|
: await Toestand.Client.LogInAsync(Email.Trim(), Code);
|
||||||
|
|
||||||
|
if (!uitkomst.Gelukt) { Fout = uitkomst.Melding; Code = ""; return; }
|
||||||
|
|
||||||
|
await Toestand.VerversAsync();
|
||||||
|
await Toestand.MeldToestelAanAsync();
|
||||||
|
Navigatie.NavigateTo("/");
|
||||||
|
}
|
||||||
|
finally { Bezig = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OpnieuwAsync()
|
||||||
|
{
|
||||||
|
CodeVerstuurd = false;
|
||||||
|
await CodeAanvragenAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void NaarInloggen() { NieuwAccount = false; Opnieuw(); }
|
||||||
|
private void NaarRegistreren() { NieuwAccount = true; Opnieuw(); }
|
||||||
|
|
||||||
|
private void Opnieuw()
|
||||||
|
{
|
||||||
|
CodeVerstuurd = false;
|
||||||
|
Code = "";
|
||||||
|
Fout = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
@page "/instellingen"
|
||||||
|
@inject AppToestand Toestand
|
||||||
|
@inject Uitlogklok Klok
|
||||||
|
@inject IToestelslot Slot
|
||||||
|
@inject NavigationManager Navigatie
|
||||||
|
|
||||||
|
@* Wat je op dít toestel kunt instellen. Alles hier blijft op de telefoon: de server weet er
|
||||||
|
niets van, en op een andere telefoon staat het dus apart.
|
||||||
|
|
||||||
|
De kop staat in de balk erboven, dus hier niet nog een keer. *@
|
||||||
|
|
||||||
|
@if (Gelukt is not null)
|
||||||
|
{
|
||||||
|
<div class="melding goed">@Gelukt</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="kaart">
|
||||||
|
<h2 style="margin-top:0">@(Slot.Beschikbaar ? "Automatisch op slot" : "Automatisch uitloggen")</h2>
|
||||||
|
<p class="zacht" style="margin-top:0">
|
||||||
|
De app blijft normaal aangemeld, zodat je niet elke keer een code uit je mail hoeft te
|
||||||
|
halen. Maar op een telefoon die op tafel ligt of kwijtraakt, staat je kluis dan open.
|
||||||
|
Hier zet je een termijn: gebeurt er zo lang niets, dan
|
||||||
|
@(Slot.Beschikbaar ? "gaat de app op slot" : "logt de app zichzelf uit").
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="veld">
|
||||||
|
<label for="minuten">@(Slot.Beschikbaar ? "Op slot na" : "Uitloggen na")</label>
|
||||||
|
@* Met een toestelslot staat "uit" er niet bij. Het slot kost je één aanraking; die keuze
|
||||||
|
levert dus bijna niets op en betekent wel dat een gevonden telefoon een open kluis is.
|
||||||
|
Zonder toestelslot mag hij er wél staan - daar is de prijs van de termijn een nieuwe
|
||||||
|
code per e-mail, en dat is te zwaar om iemand ongevraagd op te leggen. *@
|
||||||
|
<select id="minuten" @bind="Minuten" @bind:after="OpslaanAsync">
|
||||||
|
@foreach (var keuze in (Slot.Beschikbaar ? Uitlogklok.KeuzesMetSlot : Uitlogklok.Keuzes))
|
||||||
|
{
|
||||||
|
<option value="@keuze">@Uitlogklok.Naam(keuze)</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
@* Dezelfde instelling, maar wat hij kost verschilt per toestel. Dat hoort erbij te
|
||||||
|
staan: het is het verschil tussen één aanraking en een e-mail afwachten. *@
|
||||||
|
<div class="uitleg">
|
||||||
|
@if (Slot.Beschikbaar)
|
||||||
|
{
|
||||||
|
<text>
|
||||||
|
Je blijft aangemeld. Openen gaat met je vingerafdruk of de code van je
|
||||||
|
telefoon - dezelfde waarmee je het toestel zelf ontgrendelt. MijnBesluit
|
||||||
|
krijgt die nooit te zien; de telefoon zegt alleen ja of nee.
|
||||||
|
</text>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<text>
|
||||||
|
Let op: op dit toestel is dit echt uitloggen. Je moet daarna een nieuwe code
|
||||||
|
per e-mail aanvragen, net als de eerste keer. Dat komt doordat deze telefoon
|
||||||
|
geen schermslot heeft, of te oud is om erom te kunnen vragen. Daarom staat
|
||||||
|
het hier standaard uit.
|
||||||
|
</text>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kaart" style="margin-top:1rem">
|
||||||
|
<h2 style="margin-top:0">Meldingen</h2>
|
||||||
|
<p class="zacht" style="margin:0">
|
||||||
|
Of je telefoon iets laat weten als er een bericht klaarstaat, regel je bij
|
||||||
|
<a href="/profiel">Mijn profiel</a>. Daar staat ook de knop om een proefmelding te sturen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Dezelfde tekst als op mijnbesluit.nl/privacy, uit hetzelfde bestand - zie Privacytekst.razor.
|
||||||
|
In een popup en niet in de browser: een verklaring die de app uit stuurt, is er een die
|
||||||
|
niemand leest. *@
|
||||||
|
<div class="kaart" style="margin-top:1rem">
|
||||||
|
<h2 style="margin-top:0">Privacy</h2>
|
||||||
|
<p class="zacht" style="margin-top:0">
|
||||||
|
Wat we vastleggen, wat versleuteld is, wie wat kan zien, en hoe je alles laat verwijderen.
|
||||||
|
</p>
|
||||||
|
<button type="button" class="knop rustig" @onclick="() => Privacy = true">
|
||||||
|
Privacyverklaring lezen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (Privacy)
|
||||||
|
{
|
||||||
|
<Popup Titel="Privacy" Bevestigtekst="Sluiten" AlleenSluiten="true"
|
||||||
|
OpBevestigen="() => Privacy = false" OpSluiten="() => Privacy = false">
|
||||||
|
<Privacytekst />
|
||||||
|
</Popup>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>Staat de privacyverklaring open?</summary>
|
||||||
|
private bool Privacy;
|
||||||
|
|
||||||
|
private int Minuten;
|
||||||
|
private string? Gelukt;
|
||||||
|
|
||||||
|
// De klok tilt een nul zelf op naar de standaard zodra dit toestel om een vingerafdruk kan
|
||||||
|
// vragen, dus wat hier binnenkomt staat altijd in de lijst.
|
||||||
|
protected override void OnInitialized() => Minuten = Klok.Minuten;
|
||||||
|
|
||||||
|
private Task OpslaanAsync()
|
||||||
|
{
|
||||||
|
Klok.Minuten = Minuten;
|
||||||
|
|
||||||
|
var termijn = Uitlogklok.Naam(Minuten).ToLowerInvariant().Replace("na ", "");
|
||||||
|
|
||||||
|
Gelukt = Minuten == 0
|
||||||
|
? "De app blijft open staan."
|
||||||
|
: Slot.Beschikbaar
|
||||||
|
? $"De app gaat op slot als er {termijn} niets gebeurt."
|
||||||
|
: $"De app logt zichzelf uit als er {termijn} niets gebeurt.";
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,609 @@
|
|||||||
|
@page "/kluis"
|
||||||
|
@inherits SchermBasis
|
||||||
|
@using Microsoft.JSInterop
|
||||||
|
@inject IBestandskiezer Bestandskiezer
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
|
||||||
|
@* De kluis: wachtwoorden, pincodes, polissen en uitvaartwensen. Alles wat hier binnenkomt
|
||||||
|
wordt op de server versleuteld; de app ziet de inhoud alleen als hij erom vraagt. *@
|
||||||
|
|
||||||
|
@* De kop staat in de balk erboven, dus hier niet nog een keer.
|
||||||
|
|
||||||
|
Bovenaan filteren en toevoegen op één regel, net als op de website: het soort links, de knop
|
||||||
|
ernaast. Het filteren gebeurt bij de server en niet hier in de lijst - dan hoeft de telefoon
|
||||||
|
niet alles op te halen om er drie dingen uit te laten zien. *@
|
||||||
|
@* Geen label boven de keuzelijst: "Alles" of "Wachtwoord" zegt zelf al genoeg, en het scheelt
|
||||||
|
een regel bovenaan een scherm waar de lijst de hoofdzaak is. Voor een schermlezer staat het er
|
||||||
|
nog wel, in aria-label.
|
||||||
|
|
||||||
|
De keuzelijst en de knop zijn even hoog doordat de rij op stretch staat: dan worden ze allebei
|
||||||
|
zo hoog als de rij. Een vaste hoogte meegeven zou ook kunnen, maar dan loopt het uit elkaar
|
||||||
|
zodra iemand zijn lettergrootte verandert. *@
|
||||||
|
<div class="rij kluiskop">
|
||||||
|
<select class="groei" aria-label="Filter op soort" disabled="@Bezig" @bind="Filter" @bind:after="LaadAsync">
|
||||||
|
<option value="">Alles</option>
|
||||||
|
<option value="3">Wachtwoord</option>
|
||||||
|
<option value="8">Sociale media</option>
|
||||||
|
<option value="4">Apparaat</option>
|
||||||
|
<option value="5">Pincode</option>
|
||||||
|
<option value="2">Polis</option>
|
||||||
|
<option value="1">Uitvaartwens</option>
|
||||||
|
<option value="9">Donorcodicil</option>
|
||||||
|
<option value="10">Rijbewijs</option>
|
||||||
|
<option value="11">Paspoort</option>
|
||||||
|
<option value="6">Document</option>
|
||||||
|
<option value="7">Notitie</option>
|
||||||
|
<option value="99">Overig</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button class="knop" disabled="@Bezig" @onclick="() => Nieuw()">Toevoegen</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (Fout is not null)
|
||||||
|
{
|
||||||
|
<div class="melding fout">@Fout</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (Bewerken is not null)
|
||||||
|
{
|
||||||
|
<Popup Titel="@(ItemId == 0 ? "Nieuw item" : "Item wijzigen")"
|
||||||
|
Bevestigtekst="Opslaan" Bezig="@Bezig"
|
||||||
|
OpBevestigen="OpslaanAsync" OpSluiten="Afbreken">
|
||||||
|
|
||||||
|
<div class="veld">
|
||||||
|
<label for="soort">Wat leg je vast?</label>
|
||||||
|
@* De nummers komen overeen met VaultCategory in het domein. Overig is 99, niet 9:
|
||||||
|
dat stond hier eerder verkeerd en leverde een categorie op die nergens bij hoort. *@
|
||||||
|
<select id="soort" @bind="Categorie">
|
||||||
|
<option value="3">Wachtwoord</option>
|
||||||
|
<option value="8">Sociale media</option>
|
||||||
|
<option value="4">Apparaat</option>
|
||||||
|
<option value="5">Pincode</option>
|
||||||
|
<option value="2">Polis</option>
|
||||||
|
<option value="1">Uitvaartwens</option>
|
||||||
|
<option value="9">Donorcodicil</option>
|
||||||
|
<option value="10">Rijbewijs</option>
|
||||||
|
<option value="11">Paspoort</option>
|
||||||
|
<option value="6">Document</option>
|
||||||
|
<option value="7">Notitie</option>
|
||||||
|
<option value="99">Overig</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="veld">
|
||||||
|
<label for="titel">Naam</label>
|
||||||
|
<input id="titel" @bind="Titel" placeholder="Waar herken je het aan?" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@switch (Categorie)
|
||||||
|
{
|
||||||
|
case 3:
|
||||||
|
<div class="veld"><label for="site">Website</label><input id="site" @bind="Bewerken.Website" /></div>
|
||||||
|
<div class="veld"><label for="gebruiker">Gebruikersnaam</label><input id="gebruiker" @bind="Bewerken.Username" /></div>
|
||||||
|
<div class="veld"><label for="geheim">Wachtwoord</label><input id="geheim" @bind="Bewerken.Secret" /></div>
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 8:
|
||||||
|
<div class="veld">
|
||||||
|
<label for="medium">Welk medium?</label>
|
||||||
|
<select id="medium" @bind="Bewerken.Platform">
|
||||||
|
<option value="">Kies…</option>
|
||||||
|
@foreach (var naam in SocialeMedia.Namen)
|
||||||
|
{
|
||||||
|
<option value="@naam">@naam</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
@if (Bewerken.Platform == SocialeMedia.Anders)
|
||||||
|
{
|
||||||
|
<div class="veld">
|
||||||
|
<label for="medium-anders">Welke dan?</label>
|
||||||
|
<input id="medium-anders" @bind="Bewerken.PlatformAnders" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="veld"><label for="sm-gebruiker">Gebruikersnaam</label><input id="sm-gebruiker" @bind="Bewerken.Username" /></div>
|
||||||
|
<div class="veld"><label for="sm-geheim">Wachtwoord</label><input id="sm-geheim" @bind="Bewerken.Secret" /></div>
|
||||||
|
<div class="veld">
|
||||||
|
<label for="sm-herstel">Herstelcodes</label>
|
||||||
|
<textarea id="sm-herstel" rows="2" @bind="Bewerken.RecoveryCodes"></textarea>
|
||||||
|
<div class="uitleg">De codes die je krijgt bij tweestapsverificatie. Zonder die codes komt niemand er nog in.</div>
|
||||||
|
</div>
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 4:
|
||||||
|
<div class="veld"><label for="apparaat">Apparaat</label><input id="apparaat" @bind="Bewerken.DeviceName" /></div>
|
||||||
|
<div class="veld"><label for="app-gebruiker">Gebruikersnaam</label><input id="app-gebruiker" @bind="Bewerken.Username" /></div>
|
||||||
|
<div class="veld"><label for="app-geheim">Wachtwoord of code</label><input id="app-geheim" @bind="Bewerken.Secret" /></div>
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 5:
|
||||||
|
<div class="veld"><label for="waarvan">Waarvan</label><input id="waarvan" @bind="Bewerken.DeviceName" placeholder="Mijn telefoon" /></div>
|
||||||
|
<div class="veld"><label for="pin">Pincode</label><input id="pin" inputmode="numeric" @bind="Bewerken.Pincode" /></div>
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 2:
|
||||||
|
<div class="veld"><label for="maats">Verzekeraar</label><input id="maats" @bind="Bewerken.Insurer" /></div>
|
||||||
|
<div class="veld"><label for="polis">Polisnummer</label><input id="polis" @bind="Bewerken.PolicyNumber" /></div>
|
||||||
|
<div class="veld"><label for="tel">Telefoonnummer</label><input id="tel" inputmode="tel" @bind="Bewerken.ContactPhone" /></div>
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 1:
|
||||||
|
<div class="veld">
|
||||||
|
<label for="wijze">Begraven of cremeren</label>
|
||||||
|
<select id="wijze" @bind="Bewerken.BurialOrCremation">
|
||||||
|
<option value="">Nog niet gekozen</option>
|
||||||
|
<option value="Begraven">Begraven</option>
|
||||||
|
<option value="Cremeren">Cremeren</option>
|
||||||
|
<option value="Aan de wetenschap">Aan de wetenschap</option>
|
||||||
|
<option value="Nabestaanden beslissen">Nabestaanden beslissen</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="veld"><label for="plek">Plek</label><input id="plek" @bind="Bewerken.Location" /></div>
|
||||||
|
<div class="veld"><label for="muziek">Muziek</label><textarea id="muziek" rows="1" @bind="Bewerken.Music"></textarea></div>
|
||||||
|
<div class="veld"><label for="wensen">Wensen</label><textarea id="wensen" rows="4" @bind="Bewerken.Wishes"></textarea></div>
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 9:
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 10:
|
||||||
|
case 11:
|
||||||
|
// Geen eigen velden: alleen de titel, de foto's hieronder en de gewone
|
||||||
|
// toelichting. Zie de gerichte maak-knoppen vlak boven de bijlagenlijst.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (Categorie is 1 or 9)
|
||||||
|
{
|
||||||
|
<div class="veld">
|
||||||
|
<label for="bespreken">Bespreken met</label>
|
||||||
|
<select id="bespreken" @bind="BesprekenMetTekst">
|
||||||
|
<option value="">Niemand (alleen voor mijzelf)</option>
|
||||||
|
@foreach (var b in Beheerders)
|
||||||
|
{
|
||||||
|
<option value="@b.PersonId">@b.DisplayName</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<div class="uitleg">
|
||||||
|
Kies je hier iemand, dan kan diegene dit item meteen lezen en met je bespreken,
|
||||||
|
nog vóór je overlijden. Kies je niemand, dan blijft dit item zoals altijd alleen
|
||||||
|
voor jezelf.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="veld">
|
||||||
|
<label for="notitie">Toelichting</label>
|
||||||
|
<textarea id="notitie" rows="3" @bind="Bewerken.Notes"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Rijbewijs en paspoort vragen om specifieke kanten, niet om "een foto, welke dan
|
||||||
|
ook" - dus staan hier twee (of één) gerichte knoppen boven de gewone bijlagenlijst.
|
||||||
|
De naam waarmee de foto wordt opgeslagen zegt meteen welke kant het is; dat scheelt
|
||||||
|
een apart veld erbij, en de bijlagenlijst hieronder laat ze meteen zien. *@
|
||||||
|
@if (Bestandskiezer.KanCamera && Categorie == 10)
|
||||||
|
{
|
||||||
|
<div class="rij" style="gap:.5rem;margin-bottom:.6rem">
|
||||||
|
<button type="button" class="knop rustig groei" disabled="@Bezig"
|
||||||
|
@onclick='() => MaakGerichteFotoAsync("Rijbewijs voorkant")'>
|
||||||
|
📷 Voorkant maken
|
||||||
|
</button>
|
||||||
|
<button type="button" class="knop rustig groei" disabled="@Bezig"
|
||||||
|
@onclick='() => MaakGerichteFotoAsync("Rijbewijs achterkant")'>
|
||||||
|
📷 Achterkant maken
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (Bestandskiezer.KanCamera && Categorie == 11)
|
||||||
|
{
|
||||||
|
<div class="rij" style="gap:.5rem;margin-bottom:.6rem">
|
||||||
|
<button type="button" class="knop rustig groei" disabled="@Bezig"
|
||||||
|
@onclick='() => MaakGerichteFotoAsync("Paspoort")'>
|
||||||
|
📷 Foto maken
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Ook bij een nieuw item. Hier was geen nummer om een bestand aan te hangen, dus stond
|
||||||
|
er een zinnetje "dat kan zodra dit is opgeslagen" - een uitleg van onze boekhouding
|
||||||
|
waar iemand die een foto van zijn polis wil toevoegen niets aan heeft. Nu wachten ze
|
||||||
|
in Wachtrij en gaan ze mee zodra het opslaan klaar is. *@
|
||||||
|
<Bijlagenblok Bijlagen="@AlleBijlagen" Bezig="@Bezig"
|
||||||
|
KanVideo="true" KanCamera="@Bestandskiezer.KanCamera"
|
||||||
|
Overzetten="@Overzetten" Percentage="@Percentage"
|
||||||
|
Uitleg="@(ItemId == 0
|
||||||
|
? "Een foto van je polis, een scan van je testament. Ze worden meegestuurd zodra je opslaat."
|
||||||
|
: "Een foto van je polis, een scan van je testament, de handleiding van de kluis zelf.")"
|
||||||
|
OpFotoMaken="() => ToevoegenAsync(Soort.FotoMaken)"
|
||||||
|
OpFoto="() => ToevoegenAsync(Soort.Foto)"
|
||||||
|
OpDocument="() => ToevoegenAsync(Soort.Document)"
|
||||||
|
OpVideo="() => ToevoegenAsync(Soort.VideoOpnemen)"
|
||||||
|
OpVideoKiezen="() => ToevoegenAsync(Soort.Video)"
|
||||||
|
OpVerwijderen="BijlageWegAsync" />
|
||||||
|
|
||||||
|
<div class="veld">
|
||||||
|
<label for="vrijgave">Wanneer mag dit gezien worden?</label>
|
||||||
|
<select id="vrijgave" @bind="Vrijgave">
|
||||||
|
<option value="0">Alleen voor mijzelf</option>
|
||||||
|
<option value="1">Na mijn overlijden</option>
|
||||||
|
<option value="2">Op een datum</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (Vrijgave == 2)
|
||||||
|
{
|
||||||
|
<div class="veld">
|
||||||
|
<label>Vrijgeven op</label>
|
||||||
|
<Datumkiezer @bind-Waarde="VrijgaveDatum" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
</Popup>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (Items.Count == 0 && Bewerken is null)
|
||||||
|
{
|
||||||
|
@* Met een filter aan betekent "niets te zien" iets anders dan met een lege kluis: dan is er
|
||||||
|
wél iets, alleen niet van deze soort. *@
|
||||||
|
<div class="leeg">
|
||||||
|
<span class="teken">🔒</span>
|
||||||
|
@if (Filter.Length > 0)
|
||||||
|
{
|
||||||
|
<text>Van deze soort staat er nog niets in je kluis.</text>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<text>
|
||||||
|
Je kluis is nog leeg. Begin met wat je nu al kwijt zou raken:
|
||||||
|
het wachtwoord van je e-mail, of de pincode van je telefoon.
|
||||||
|
</text>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="stapel">
|
||||||
|
@foreach (var item in Items)
|
||||||
|
{
|
||||||
|
@* Dezelfde tegel als bij Momenten en Mijn mensen: drie regels over de volle breedte,
|
||||||
|
de knoppen als tekentjes rechtsonder. Een kluisitem heeft geen foto, dus begint de
|
||||||
|
tegel meteen met de titel.
|
||||||
|
|
||||||
|
Aantikken opent het scherm met de inhoud. Dat is hetzelfde scherm als wijzigen: het
|
||||||
|
liet toch al alles zien wat er in het item staat, inclusief de bijlagen. Er stond hier
|
||||||
|
eerder een uitklap ónder de tegel die precies hetzelfde toonde - twee plekken om te
|
||||||
|
onderhouden, en twee manieren om hetzelfde te doen. *@
|
||||||
|
<div class="kaart compact tegel klikbaar"
|
||||||
|
role="button" tabindex="0" title="Bekijken en wijzigen"
|
||||||
|
@onclick="() => TegelOpenenAsync(item)"
|
||||||
|
@onkeydown="e => ToetsAsync(e, () => TegelOpenenAsync(item))">
|
||||||
|
<div class="rij">
|
||||||
|
<div class="groei">
|
||||||
|
<span class="groot" title="@item.Title">@item.Title</span>
|
||||||
|
<div class="zacht">@Tonen.Categorie(item.Category)</div>
|
||||||
|
|
||||||
|
@* stopPropagation, want anders opent een druk op de prullenbak ook meteen
|
||||||
|
het scherm met de inhoud. *@
|
||||||
|
<div class="onderregel">
|
||||||
|
@* Een tekentje voor de regel, even hoog als de tekst ernaast. Het zegt
|
||||||
|
in een oogopslag wat er met dit item gebeurt; de tekst blijft staan,
|
||||||
|
want een takje raadt niemand. Bij "Alleen voor mijzelf" is dat jij,
|
||||||
|
dus staat daar je eigen profielfoto - en heb je die niet, dan je
|
||||||
|
initialen, net als overal waar mensen in een lijst staan. *@
|
||||||
|
<span class="zacht regelicoon">
|
||||||
|
@if (item.ReleasePolicy == 0)
|
||||||
|
{
|
||||||
|
<span class="penningetje"><Penning Naam="@Toestand.MijnNaam" Bron="@Toestand.MijnFoto" /></span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="teken" aria-hidden="true">@Tonen.Vrijgaveteken(item.ReleasePolicy)</span>
|
||||||
|
}
|
||||||
|
@Tonen.Vrijgave(item.ReleasePolicy)
|
||||||
|
</span>
|
||||||
|
|
||||||
|
@if (Weghalen != item.Id)
|
||||||
|
{
|
||||||
|
<span class="tegelknoppen">
|
||||||
|
<button class="knop rustig klein" disabled="@Bezig"
|
||||||
|
title="Verwijderen" aria-label="Verwijderen"
|
||||||
|
@onclick="() => Weghalen = item.Id"
|
||||||
|
@onclick:stopPropagation="true">🗑️</button>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (Weghalen == item.Id)
|
||||||
|
{
|
||||||
|
@* Alles wat hieronder uitklapt zit ín de tegel, en een tik op de tegel opent of sluit hem.
|
||||||
|
Zonder stopPropagation klapt hij dus weer dicht zodra je de video aanraakt, een
|
||||||
|
bijlage aantikt of op "nee" drukt. *@
|
||||||
|
<div class="melding let-op" style="margin:.8rem 0 0"
|
||||||
|
@onclick="() => { }" @onclick:stopPropagation="true">
|
||||||
|
<strong>@item.Title</strong> verwijderen? Wat erin staat is daarna weg.
|
||||||
|
<div class="rij" style="margin-top:.7rem">
|
||||||
|
<button class="knop gevaar" disabled="@Bezig" @onclick="() => VerwijderenAsync(item.Id)">
|
||||||
|
Ja, verwijderen
|
||||||
|
</button>
|
||||||
|
<button class="knop rustig" disabled="@Bezig" @onclick="() => Weghalen = null">Nee, bewaren</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private IReadOnlyList<KluisItem> Items = Array.Empty<KluisItem>();
|
||||||
|
private KluisInhoud? Bewerken;
|
||||||
|
|
||||||
|
/// <summary>Op welk soort de lijst gefilterd staat; leeg is alles. Zelfde opzet als op de website.</summary>
|
||||||
|
private string Filter = "";
|
||||||
|
|
||||||
|
private int Categorie = 3;
|
||||||
|
private IReadOnlyList<Beheerder> Beheerders = Array.Empty<Beheerder>();
|
||||||
|
private string BesprekenMetTekst = "";
|
||||||
|
private long? BesprekenMetPersonId => long.TryParse(BesprekenMetTekst, out var p) ? p : null;
|
||||||
|
private int Vrijgave = 1;
|
||||||
|
private DateTime? VrijgaveDatum = DateTime.Today.AddYears(1);
|
||||||
|
private string Titel = "";
|
||||||
|
|
||||||
|
/// <summary>0 als het om een nieuw item gaat.</summary>
|
||||||
|
private long ItemId;
|
||||||
|
|
||||||
|
/// <summary>Welk item er op verwijderen staat te wachten; null als er niets weg hoeft.</summary>
|
||||||
|
private long? Weghalen;
|
||||||
|
|
||||||
|
/// <summary>De bijlagen bij het item dat openstaat.</summary>
|
||||||
|
private IReadOnlyList<Bijlage> Bijlagen = Array.Empty<Bijlage>();
|
||||||
|
|
||||||
|
/// <summary>Tekst bij de voortgangsbalk tijdens het versturen of ophalen; null als er niets loopt.</summary>
|
||||||
|
private string? Overzetten;
|
||||||
|
private int Percentage;
|
||||||
|
|
||||||
|
private enum Soort { Document, Foto, FotoMaken, Video, VideoOpnemen }
|
||||||
|
|
||||||
|
/// <summary>Bijlagen die zijn gekozen voor een item dat nog niet bestaat.</summary>
|
||||||
|
private readonly Bijlagewachtrij Wachtrij = new();
|
||||||
|
|
||||||
|
/// <summary>Wat er opgeslagen staat plus wat er nog wacht; samen wat het scherm laat zien.</summary>
|
||||||
|
private IReadOnlyList<Bijlage> AlleBijlagen =>
|
||||||
|
Wachtrij.Leeg ? Bijlagen
|
||||||
|
: Bijlagen.Concat(Wachtrij.AlsBijlagen(DateTime.UtcNow)).ToList();
|
||||||
|
|
||||||
|
protected override async Task LaadAsync()
|
||||||
|
{
|
||||||
|
// Leeg is "alles". Een nummer dat niet te lezen valt telt ook als alles: liever de hele
|
||||||
|
// lijst dan een leeg scherm zonder uitleg.
|
||||||
|
int? categorie = int.TryParse(Filter, out var nummer) ? nummer : null;
|
||||||
|
|
||||||
|
var lijst = await Toestand.Client.KluisAsync(categorie);
|
||||||
|
if (lijst.Gelukt) Items = lijst.Waarde!;
|
||||||
|
else Fout = lijst.Melding;
|
||||||
|
|
||||||
|
var beheerders = await Toestand.Client.BeheerdersAsync();
|
||||||
|
if (beheerders.Gelukt) Beheerders = beheerders.Waarde!;
|
||||||
|
|
||||||
|
// De vraag "verwijderen?" hoorde bij een item dat er na het filteren misschien niet
|
||||||
|
// meer staat.
|
||||||
|
Weghalen = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Het wijzigscherm dichtdoen zonder op te slaan; wat wachtte gaat dan ook weg.</summary>
|
||||||
|
private void Afbreken()
|
||||||
|
{
|
||||||
|
Wachtrij.Leegmaken();
|
||||||
|
Bewerken = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Nieuw()
|
||||||
|
{
|
||||||
|
Wachtrij.Leegmaken(); // wat er van een vorige keer nog wachtte hoort hier niet bij
|
||||||
|
Bijlagen = Array.Empty<Bijlage>();
|
||||||
|
Bewerken = new KluisInhoud();
|
||||||
|
ItemId = 0;
|
||||||
|
Titel = "";
|
||||||
|
Categorie = 3;
|
||||||
|
Vrijgave = 1;
|
||||||
|
VrijgaveDatum = DateTime.Today.AddYears(1);
|
||||||
|
BesprekenMetTekst = "";
|
||||||
|
Weghalen = null;
|
||||||
|
Fout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task WijzigenAsync(KluisItem item)
|
||||||
|
{
|
||||||
|
Fout = null;
|
||||||
|
Weghalen = null;
|
||||||
|
Wachtrij.Leegmaken();
|
||||||
|
|
||||||
|
// De inhoud staat versleuteld op de server; die halen we op om het formulier te vullen.
|
||||||
|
var inhoud = await Toestand.Client.KluisOpenenAsync(item.Id);
|
||||||
|
if (!inhoud.Gelukt) { Fout = inhoud.Melding; return; }
|
||||||
|
|
||||||
|
Bewerken = inhoud.Waarde;
|
||||||
|
ItemId = item.Id;
|
||||||
|
Titel = item.Title;
|
||||||
|
Categorie = item.Category;
|
||||||
|
Vrijgave = item.ReleasePolicy;
|
||||||
|
VrijgaveDatum = item.ReleaseAt?.ToLocalTime() ?? DateTime.Today.AddYears(1);
|
||||||
|
BesprekenMetTekst = item.SharedManagerPersonId?.ToString() ?? "";
|
||||||
|
|
||||||
|
// De bijlagen horen bij het item, dus die halen we er meteen bij.
|
||||||
|
await BijlagenLadenAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task VerwijderenAsync(long itemId)
|
||||||
|
{
|
||||||
|
if (await ProbeerAsync(async () =>
|
||||||
|
{
|
||||||
|
var r = await Toestand.Client.KluisVerwijderenAsync(itemId);
|
||||||
|
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
|
||||||
|
}))
|
||||||
|
{
|
||||||
|
Weghalen = null;
|
||||||
|
Bijlagen = Array.Empty<Bijlage>();
|
||||||
|
await LaadAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OpslaanAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(Titel)) { Fout = "Geef het item een naam, anders weet niemand later waar het over gaat."; return; }
|
||||||
|
|
||||||
|
var datum = Vrijgave == 2 ? VrijgaveDatum : null;
|
||||||
|
if (Vrijgave == 2 && datum is null) { Fout = "Kies een datum waarop dit vrijkomt."; return; }
|
||||||
|
|
||||||
|
long nieuwItemId = 0;
|
||||||
|
|
||||||
|
if (await ProbeerAsync(async () =>
|
||||||
|
{
|
||||||
|
var r = ItemId == 0
|
||||||
|
? await Toestand.Client.KluisOpslaanAsync(Categorie, Titel.Trim(), Bewerken!, Vrijgave, datum, BesprekenMetPersonId)
|
||||||
|
: await Toestand.Client.KluisWijzigenAsync(ItemId, Categorie, Titel.Trim(), Bewerken!, Vrijgave, datum, BesprekenMetPersonId);
|
||||||
|
|
||||||
|
if (!r.Gelukt) return Uitkomst.Fout(r.Melding!);
|
||||||
|
|
||||||
|
if (ItemId == 0 && r.Waarde.TryGetProperty("itemId", out var nummer))
|
||||||
|
nieuwItemId = nummer.GetInt64();
|
||||||
|
|
||||||
|
return Uitkomst.Ok();
|
||||||
|
}))
|
||||||
|
{
|
||||||
|
// Nu pas kunnen de bijlagen mee: het item bestaat en heeft een nummer.
|
||||||
|
var doel = ItemId == 0 ? nieuwItemId : ItemId;
|
||||||
|
if (doel != 0 && !Wachtrij.Leeg)
|
||||||
|
{
|
||||||
|
var misging = await Wachtrij.VerstuurAsync(Toestand.Client, kluisItemId: doel,
|
||||||
|
voortgang: (naam, procent) =>
|
||||||
|
{
|
||||||
|
Overzetten = $"{naam} wordt opgeslagen…";
|
||||||
|
Percentage = procent;
|
||||||
|
InvokeAsync(StateHasChanged);
|
||||||
|
});
|
||||||
|
|
||||||
|
Overzetten = null;
|
||||||
|
if (misging is not null) Fout = misging;
|
||||||
|
}
|
||||||
|
|
||||||
|
Bewerken = null;
|
||||||
|
await LaadAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// De tegel is aangetikt: dat opent het scherm met de inhoud. Dat is hetzelfde scherm als
|
||||||
|
/// wijzigen - het toont alles wat er in het item staat en je kunt het meteen aanpassen.
|
||||||
|
/// Staat de vraag "verwijderen?" open, dan gebeurt er niets: dan hoort de aandacht bij die
|
||||||
|
/// vraag te blijven.
|
||||||
|
/// </summary>
|
||||||
|
private Task TegelOpenenAsync(KluisItem item) =>
|
||||||
|
Weghalen == item.Id ? Task.CompletedTask : WijzigenAsync(item);
|
||||||
|
|
||||||
|
private async Task BijlagenLadenAsync()
|
||||||
|
{
|
||||||
|
if (BijlageDoel is not { } id) { Bijlagen = Array.Empty<Bijlage>(); return; }
|
||||||
|
|
||||||
|
var lijst = await Toestand.Client.BijlagenAsync(id);
|
||||||
|
Bijlagen = lijst.Gelukt ? lijst.Waarde! : Array.Empty<Bijlage>();
|
||||||
|
if (!lijst.Gelukt) Fout = lijst.Melding;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bij welk item een bijlage hoort: het item dat in het scherm staat. Bij een nieuw item is
|
||||||
|
/// er nog geen nummer; dan wachten de bijlagen in de wachtrij tot het opslaan klaar is.
|
||||||
|
/// </summary>
|
||||||
|
private long? BijlageDoel => Bewerken is not null && ItemId != 0 ? ItemId : null;
|
||||||
|
|
||||||
|
private async Task ToevoegenAsync(Soort soort)
|
||||||
|
{
|
||||||
|
Fout = null;
|
||||||
|
var gekozen = soort switch
|
||||||
|
{
|
||||||
|
Soort.Document => await Bestandskiezer.KiesDocumentAsync(),
|
||||||
|
Soort.Foto => await Bestandskiezer.KiesFotoAsync(),
|
||||||
|
Soort.FotoMaken => await Bestandskiezer.MaakFotoAsync(),
|
||||||
|
Soort.Video => await Bestandskiezer.KiesVideoAsync(),
|
||||||
|
_ => await Bestandskiezer.NeemVideoOpAsync()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Niets gekozen of afgebroken: gewoon niets doen, geen foutmelding.
|
||||||
|
if (gekozen is null) return;
|
||||||
|
|
||||||
|
await VerwerkGekozenAsync(gekozen);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Voor Rijbewijs en Paspoort: meteen de camera openen voor één specifieke kant, met een
|
||||||
|
/// vaste, herkenbare naam - zo zie je in de bijlagenlijst meteen welke kant het is, zonder
|
||||||
|
/// dat daar een apart veld voor nodig is.
|
||||||
|
/// </summary>
|
||||||
|
private async Task MaakGerichteFotoAsync(string label)
|
||||||
|
{
|
||||||
|
Fout = null;
|
||||||
|
var gekozen = await Bestandskiezer.MaakFotoAsync();
|
||||||
|
if (gekozen is null) return;
|
||||||
|
|
||||||
|
var punt = gekozen.Naam.LastIndexOf('.');
|
||||||
|
var extensie = punt >= 0 ? gekozen.Naam[punt..] : ".jpg";
|
||||||
|
await VerwerkGekozenAsync(gekozen with { Naam = $"{label}{extensie}" });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task VerwerkGekozenAsync(GekozenBestand gekozen)
|
||||||
|
{
|
||||||
|
// Nog geen nummer om het aan te hangen: dan wacht het tot na het opslaan.
|
||||||
|
if (BijlageDoel is not { } itemId)
|
||||||
|
{
|
||||||
|
Wachtrij.VoegToe(gekozen);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Overzetten = $"{gekozen.Naam} wordt opgeslagen…";
|
||||||
|
Percentage = 0;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
var uitkomst = await Toestand.Client.BestandVersturenAsync(
|
||||||
|
gekozen.Naam, gekozen.Soort, gekozen.Inhoud, itemId,
|
||||||
|
(gedaan, totaal) =>
|
||||||
|
{
|
||||||
|
Percentage = totaal == 0 ? 100 : (int)(gedaan * 100 / totaal);
|
||||||
|
InvokeAsync(StateHasChanged);
|
||||||
|
});
|
||||||
|
|
||||||
|
Overzetten = null;
|
||||||
|
if (!uitkomst.Gelukt) Fout = uitkomst.Melding;
|
||||||
|
else await BijlagenLadenAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task BijlageWegAsync(Bijlage bijlage)
|
||||||
|
{
|
||||||
|
// Wat nog wacht staat nergens; dat halen we hier weg en niet bij de server.
|
||||||
|
if (Bijlagewachtrij.Wacht(bijlage)) { Wachtrij.Verwijder(bijlage); return; }
|
||||||
|
|
||||||
|
if (await ProbeerAsync(async () =>
|
||||||
|
{
|
||||||
|
var r = await Toestand.Client.BijlageVerwijderenAsync(bijlage.Id);
|
||||||
|
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
|
||||||
|
}))
|
||||||
|
{
|
||||||
|
await BijlagenLadenAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Muziek en Wensen mogen meerdere regels worden; dan is een vast aantal regels niet
|
||||||
|
/// genoeg en een schuifbalk niet fijn op een telefoon. Deze twee velden groeien daarom
|
||||||
|
/// mee met wat erin staat - alleen hier in de app, op de website hebben ze de ruimte
|
||||||
|
/// al staan.
|
||||||
|
/// </summary>
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (Bewerken is not null)
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("mijnbesluit.laatMeegroeien", "muziek");
|
||||||
|
await JS.InvokeVoidAsync("mijnbesluit.laatMeegroeien", "wensen");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,854 @@
|
|||||||
|
@page "/momenten"
|
||||||
|
@inherits SchermBasis
|
||||||
|
@inject IBestandskiezer Bestandskiezer
|
||||||
|
|
||||||
|
@* Berichten die je klaarzet voor later: op een datum, of pas na je overlijden.
|
||||||
|
Zolang een bericht niet bezorgd is, mag je het wijzigen of terugtrekken. *@
|
||||||
|
|
||||||
|
@* De kop staat in de balk erboven, dus hier niet nog een keer. *@
|
||||||
|
<button class="knop vol" style="margin-bottom:1rem" disabled="@Bezig" @onclick="Nieuw">
|
||||||
|
Bericht klaarzetten
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (Fout is not null)
|
||||||
|
{
|
||||||
|
<div class="melding fout">@Fout</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (Gelukt is not null)
|
||||||
|
{
|
||||||
|
<div class="melding goed">@Gelukt</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Twee tegels die zeggen wat er is, en waarmee je kiest wat je ziet.
|
||||||
|
|
||||||
|
Waarom niet allebei de lijsten onder elkaar, zoals het was: "Voor mij" is bij bijna iedereen
|
||||||
|
leeg - berichten komen pas na een datum of na een overlijden - en dat betekende dat je elke
|
||||||
|
keer langs een lege doos moest scrollen om bij je eigen berichten te komen. Het getal zegt
|
||||||
|
nu wat die lijst bevat, zonder dat je ernaartoe hoeft.
|
||||||
|
|
||||||
|
Welke tegel openstaat bij binnenkomst wordt niet vastgezet maar afgeleid: staat er iets
|
||||||
|
nieuws voor je klaar, dan is dát wat je wilt zien. Zie Standaardkant. *@
|
||||||
|
<div class="rij keuzetegels" role="tablist" style="margin-bottom:1rem">
|
||||||
|
<button class="kaart compact keuzetegel @(Kant == "van" ? "aan" : "")"
|
||||||
|
role="tab" aria-selected="@(Kant == "van")" @onclick='() => Kant = "van"'>
|
||||||
|
<span class="groot">Van mij</span>
|
||||||
|
<span class="zacht">@Uitgaand.Count</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="kaart compact keuzetegel @(Kant == "voor" ? "aan" : "") @(AantalNieuw > 0 ? "nieuw" : "")"
|
||||||
|
role="tab" aria-selected="@(Kant == "voor")" @onclick='() => Kant = "voor"'>
|
||||||
|
<span class="groot">Voor mij</span>
|
||||||
|
<span class="zacht">@Inkomend.Count@(AantalNieuw > 0 ? $" · {AantalNieuw} nieuw" : "")</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (Schrijven is not null)
|
||||||
|
{
|
||||||
|
<Popup Titel="@(Schrijven.BerichtId == 0 ? "Een bericht klaarzetten" : "Bericht wijzigen")"
|
||||||
|
Bevestigtekst="@(Schrijven.BerichtId == 0 ? "Klaarzetten" : "Opslaan")" Bezig="@Bezig"
|
||||||
|
OpBevestigen="OpslaanAsync" OpSluiten="Sluiten">
|
||||||
|
|
||||||
|
@if (Schrijven.BerichtId == 0)
|
||||||
|
{
|
||||||
|
<div class="veld">
|
||||||
|
<label for="aan">Voor wie is het?</label>
|
||||||
|
<select id="aan" @bind="Schrijven.AanPersoonId">
|
||||||
|
<option value="0">Kies iemand</option>
|
||||||
|
@foreach (var lid in Leden.Where(l => l.PersonId != Toestand.PersoonId))
|
||||||
|
{
|
||||||
|
<option value="@lid.PersonId">@lid.DisplayName</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p class="zacht">Voor @Naam(Schrijven.AanPersoonId)</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="veld"><label for="ond">Onderwerp</label><input id="ond" @bind="Schrijven.Onderwerp" /></div>
|
||||||
|
<div class="veld"><label for="tekst">Je bericht</label><textarea id="tekst" rows="7" @bind="Schrijven.Tekst"></textarea></div>
|
||||||
|
|
||||||
|
@* "Een opname erbij" is iets anders dan een bijlage: daarmee ís het bericht een video,
|
||||||
|
in plaats van een tekst met iets eronder. Die opname gaat als los bestand mee en wordt
|
||||||
|
bij het opslaan aan het bericht geknoopt; bijlagen staan daaronder. *@
|
||||||
|
@if (Schrijven.BerichtId == 0)
|
||||||
|
{
|
||||||
|
@if (Bestandskiezer.KanCamera)
|
||||||
|
{
|
||||||
|
<div class="veld">
|
||||||
|
<label>Een opname erbij</label>
|
||||||
|
|
||||||
|
@if (Schrijven.BestandId is not null)
|
||||||
|
{
|
||||||
|
<div class="rij">
|
||||||
|
<span class="groei">@Schrijven.Bestandsnaam</span>
|
||||||
|
<button class="knop rustig" disabled="@Bezig" @onclick="OpnameWeghalen">Weghalen</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (Overzetten is null)
|
||||||
|
{
|
||||||
|
<button class="knop rustig" disabled="@Bezig" @onclick="VideoOpnemenAsync">Video opnemen</button>
|
||||||
|
<div class="uitleg">
|
||||||
|
Een kort filmpje zegt meer dan een brief. Maximaal
|
||||||
|
@(MaxMegabyte) MB, dus houd het bij een minuut of wat.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (Overzetten is not null)
|
||||||
|
{
|
||||||
|
<div class="veld">
|
||||||
|
<div class="zacht">@Overzetten</div>
|
||||||
|
<div class="voortgang"><span style="width:@(Percentage)%"></span></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Ook bij een nieuw bericht. Hier was geen nummer om een bestand aan te hangen, dus
|
||||||
|
stond er een zinnetje "dat kan zodra het bericht klaarstaat" - een uitleg van onze
|
||||||
|
boekhouding waar iemand die een foto wil meesturen niets aan heeft. Nu wachten ze in
|
||||||
|
Wachtrij en gaan ze mee zodra het bericht is klaargezet. *@
|
||||||
|
<Bijlagenblok Bijlagen="@AlleBijlagen" Bezig="@Bezig"
|
||||||
|
KanVideo="true" KanCamera="@Bestandskiezer.KanCamera"
|
||||||
|
Overzetten="@Overzetten" Percentage="@Percentage"
|
||||||
|
Uitleg="@(Schrijven.BerichtId == 0
|
||||||
|
? "Een foto, een ingescande brief, een filmpje. Ze gaan mee zodra je het bericht klaarzet."
|
||||||
|
: "Een foto, een brief die je hebt ingescand, of een filmpje. Het gaat mee met het bericht.")"
|
||||||
|
OpFotoMaken="() => ToevoegenAsync(Soort.FotoMaken)"
|
||||||
|
OpFoto="() => ToevoegenAsync(Soort.Foto)"
|
||||||
|
OpDocument="() => ToevoegenAsync(Soort.Document)"
|
||||||
|
OpVideo="() => ToevoegenAsync(Soort.VideoOpnemen)"
|
||||||
|
OpVideoKiezen="() => ToevoegenAsync(Soort.Video)"
|
||||||
|
OpVerwijderen="BijlageWegAsync" />
|
||||||
|
|
||||||
|
<div class="veld">
|
||||||
|
<label for="wanneer">Wanneer mag het bezorgd worden?</label>
|
||||||
|
<select id="wanneer" @bind="Schrijven.Wanneer">
|
||||||
|
<option value="overlijden">Pas na mijn overlijden</option>
|
||||||
|
<option value="datum">Op een datum</option>
|
||||||
|
<option value="nu">Meteen</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (Schrijven.Wanneer == "datum")
|
||||||
|
{
|
||||||
|
<div class="veld">
|
||||||
|
<label>Bezorgen op</label>
|
||||||
|
<Datumkiezer @bind-Waarde="Schrijven.BezorgOp" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
</Popup>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (Kant == "voor")
|
||||||
|
{
|
||||||
|
@if (Inkomend.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="leeg"><span class="teken">🎬</span> Er is nog niets voor je bezorgd.</div>
|
||||||
|
}
|
||||||
|
<div class="stapel">
|
||||||
|
@foreach (var bericht in Inkomend)
|
||||||
|
{
|
||||||
|
@* Drie regels naast de foto: onderwerp, van wie, wanneer. In die volgorde, want zo lees
|
||||||
|
je een lijst: eerst waar het over gaat, dan van wie. Wat niet past wordt afgekapt in
|
||||||
|
plaats van afgebroken - een tegel van vijf regels hoog zet de rest buiten beeld. *@
|
||||||
|
<div class="kaart compact tegel klikbaar @(bericht.IsNieuw ? "nieuw" : "")"
|
||||||
|
role="button" tabindex="0"
|
||||||
|
title="Lezen"
|
||||||
|
@onclick="() => LezenAsync(bericht.Id)"
|
||||||
|
@onkeydown="e => ToetsAsync(e, () => LezenAsync(bericht.Id))">
|
||||||
|
<div class="rij persoon">
|
||||||
|
<Penning Naam="@Naam(bericht.FromPersonId)" Bron="@Foto(bericht.FromPersonId)" />
|
||||||
|
<div class="groei">
|
||||||
|
<span class="groot" title="@bericht.Subject">@bericht.Subject</span>
|
||||||
|
<div class="zacht">@Naam(bericht.FromPersonId)</div>
|
||||||
|
|
||||||
|
<div class="onderregel">
|
||||||
|
<span class="zacht">
|
||||||
|
@* Bij een nieuw bericht valt "bezorgd" weg. Anders duwde die regel de
|
||||||
|
datum van het scherm af - en juist de datum wil je hier lezen. *@
|
||||||
|
@if (bericht.IsNieuw) { <text>Nieuw · </text> } else { <text>Bezorgd </text> }
|
||||||
|
@Tonen.Kort(bericht.DeliveredAt)
|
||||||
|
@if (bericht.HeeftBestand) { <text> · met opname</text> }
|
||||||
|
</span>
|
||||||
|
|
||||||
|
@if (Verwijderen != bericht.Id)
|
||||||
|
{
|
||||||
|
<span class="tegelknoppen">
|
||||||
|
<button class="knop rustig klein" disabled="@Bezig"
|
||||||
|
title="Verwijderen" aria-label="Verwijderen"
|
||||||
|
@onclick="() => Verwijderen = bericht.Id"
|
||||||
|
@onclick:stopPropagation="true">🗑️</button>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (Verwijderen == bericht.Id)
|
||||||
|
{
|
||||||
|
@* Zie de toelichting bij dezelfde constructie onder "Van mij": stopPropagation
|
||||||
|
overal hieronder, anders klapt dit weer dicht - of erger, opent het de
|
||||||
|
leespopup - zodra je "ja" of "nee" aantikt. *@
|
||||||
|
<div class="melding let-op" style="margin:.8rem 0 0"
|
||||||
|
@onclick="() => { }" @onclick:stopPropagation="true">
|
||||||
|
Dit bericht verwijderen? Het verdwijnt dan alleen bij jou; @Naam(bericht.FromPersonId)
|
||||||
|
houdt het eigen exemplaar gewoon.
|
||||||
|
<div class="rij" style="margin-top:.7rem">
|
||||||
|
<button class="knop gevaar" disabled="@Bezig"
|
||||||
|
@onclick="() => VerwijderenAsync(bericht.Id)"
|
||||||
|
@onclick:stopPropagation="true">
|
||||||
|
Ja, verwijderen
|
||||||
|
</button>
|
||||||
|
<button class="knop rustig" disabled="@Bezig"
|
||||||
|
@onclick="() => Verwijderen = null"
|
||||||
|
@onclick:stopPropagation="true">Nee, laat staan</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Het bericht dat openstaat, in een popup - net als overal elders in de app. De tegel zelf
|
||||||
|
klapt niet meer uit: dat gaf op een tegel met veel tekst een rommelig, wisselend lange
|
||||||
|
lijst, en overal elders in de app hoort lezen in een popup te zitten. *@
|
||||||
|
@if (Gelezen is { } gelezenId && Inkomend.FirstOrDefault(b => b.Id == gelezenId) is { } open)
|
||||||
|
{
|
||||||
|
<Popup Titel="@open.Subject" Bevestigtekst="Sluiten" AlleenSluiten="true"
|
||||||
|
OpBevestigen="Dichtdoen" OpSluiten="Dichtdoen">
|
||||||
|
<p class="zacht" style="margin-top:0">@Naam(open.FromPersonId) · bezorgd @Tonen.Kort(open.DeliveredAt)</p>
|
||||||
|
<p style="margin:.8rem 0 0; white-space:pre-wrap">@Berichttekst</p>
|
||||||
|
|
||||||
|
@if (Opname is not null)
|
||||||
|
{
|
||||||
|
<video src="@Opname" controls style="max-width:100%;border-radius:10px;margin-top:.6rem"></video>
|
||||||
|
}
|
||||||
|
else if (open.HeeftBestand && Overzetten is not null)
|
||||||
|
{
|
||||||
|
<div style="margin-top:.6rem">
|
||||||
|
<div class="zacht">@Overzetten</div>
|
||||||
|
<div class="voortgang"><span style="width:@(Percentage)%"></span></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</Popup>
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (Kant == "van")
|
||||||
|
{
|
||||||
|
@if (Uitgaand.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="leeg">
|
||||||
|
<span class="teken">💬</span>
|
||||||
|
Zet iets klaar voor iemand. Een brief voor later komt vaak harder aan dan je denkt.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="stapel">
|
||||||
|
@foreach (var bericht in Uitgaand)
|
||||||
|
{
|
||||||
|
@* Aantikken is wijzigen; zolang het bericht nog niet bezorgd is tenminste. Daarna staat
|
||||||
|
het vast en doet de tegel niets meer. *@
|
||||||
|
<div class="kaart compact tegel klikbaar"
|
||||||
|
role="button" tabindex="0"
|
||||||
|
title="@(Bezorgd(bericht) ? "Bekijken" : "Bericht wijzigen")"
|
||||||
|
@onclick="() => TegelWijzigenAsync(bericht)"
|
||||||
|
@onkeydown="e => ToetsAsync(e, () => TegelWijzigenAsync(bericht))">
|
||||||
|
<div class="rij persoon">
|
||||||
|
<Penning Naam="@Naam(bericht.ToPersonId)" Bron="@Foto(bericht.ToPersonId)" />
|
||||||
|
<div class="groei">
|
||||||
|
<span class="groot" title="@bericht.Subject">@bericht.Subject</span>
|
||||||
|
<div class="zacht">@Naam(bericht.ToPersonId)</div>
|
||||||
|
|
||||||
|
@* Alleen terugtrekken staat er nog als knop; wijzigen doe je door de tegel
|
||||||
|
aan te tikken. stopPropagation, want anders opent een druk op de
|
||||||
|
prullenbak ook meteen het wijzigscherm. *@
|
||||||
|
<div class="onderregel">
|
||||||
|
@* Hetzelfde tekentje als in de kluis, even hoog als de tekst: het takje
|
||||||
|
voor na je overlijden, de kalender voor een datum. Het staat vóór de
|
||||||
|
tekst en dus links uitgelijnd met de naam erboven. De tekst blijft
|
||||||
|
staan; een takje raadt niemand. *@
|
||||||
|
<span class="zacht regelicoon">
|
||||||
|
<span class="teken" aria-hidden="true">@Bezorgteken(bericht)</span>
|
||||||
|
@Bezorging(bericht)
|
||||||
|
</span>
|
||||||
|
|
||||||
|
@if (!Bezorgd(bericht) && Weghalen != bericht.Id)
|
||||||
|
{
|
||||||
|
<span class="tegelknoppen">
|
||||||
|
<button class="knop rustig klein" disabled="@Bezig"
|
||||||
|
title="Terugtrekken" aria-label="Terugtrekken"
|
||||||
|
@onclick="() => Weghalen = bericht.Id"
|
||||||
|
@onclick:stopPropagation="true">🗑️</button>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
else if (Bezorgd(bericht) && Verwijderen != bericht.Id)
|
||||||
|
{
|
||||||
|
<span class="tegelknoppen">
|
||||||
|
<button class="knop rustig klein" disabled="@Bezig"
|
||||||
|
title="Verwijderen" aria-label="Verwijderen"
|
||||||
|
@onclick="() => Verwijderen = bericht.Id"
|
||||||
|
@onclick:stopPropagation="true">🗑️</button>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (Weghalen == bericht.Id)
|
||||||
|
{
|
||||||
|
@* Alles wat hieronder uitklapt zit ín de tegel, en een tik op de tegel opent of sluit hem.
|
||||||
|
Zonder stopPropagation klapt hij dus weer dicht zodra je de video aanraakt, een
|
||||||
|
bijlage aantikt of op "nee" drukt. *@
|
||||||
|
<div class="melding let-op" style="margin:.8rem 0 0"
|
||||||
|
@onclick="() => { }" @onclick:stopPropagation="true">
|
||||||
|
Dit bericht terugtrekken? Het wordt dan nooit bezorgd.
|
||||||
|
<div class="rij" style="margin-top:.7rem">
|
||||||
|
<button class="knop gevaar" disabled="@Bezig" @onclick="() => IntrekkenAsync(bericht.Id)">
|
||||||
|
Ja, terugtrekken
|
||||||
|
</button>
|
||||||
|
<button class="knop rustig" disabled="@Bezig" @onclick="() => Weghalen = null">Nee, laat staan</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (Verwijderen == bericht.Id)
|
||||||
|
{
|
||||||
|
<div class="melding let-op" style="margin:.8rem 0 0"
|
||||||
|
@onclick="() => { }" @onclick:stopPropagation="true">
|
||||||
|
Dit bericht verwijderen? Het verdwijnt dan alleen bij jou; @Naam(bericht.ToPersonId)
|
||||||
|
houdt het eigen exemplaar gewoon.
|
||||||
|
<div class="rij" style="margin-top:.7rem">
|
||||||
|
<button class="knop gevaar" disabled="@Bezig"
|
||||||
|
@onclick="() => VerwijderenAsync(bericht.Id)">
|
||||||
|
Ja, verwijderen
|
||||||
|
</button>
|
||||||
|
<button class="knop rustig" disabled="@Bezig" @onclick="() => Verwijderen = null">Nee, laat staan</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Je eigen verzonden bericht ter inzage, in dezelfde opmaak als "Voor mij" hierboven. *@
|
||||||
|
@if (GelezenUit is { } gelezenUitId && Uitgaand.FirstOrDefault(b => b.Id == gelezenUitId) is { } openUit)
|
||||||
|
{
|
||||||
|
<Popup Titel="@openUit.Subject" Bevestigtekst="Sluiten" AlleenSluiten="true"
|
||||||
|
OpBevestigen="DichtdoenUit" OpSluiten="DichtdoenUit">
|
||||||
|
<p class="zacht" style="margin-top:0">Voor @Naam(openUit.ToPersonId) · @Bezorging(openUit)</p>
|
||||||
|
|
||||||
|
@* Alleen zolang het bericht al bezorgd is: daarvoor staat er nog niets om te tonen. *@
|
||||||
|
@if (openUit.DeliveredAt is not null)
|
||||||
|
{
|
||||||
|
<p class="zacht" style="margin:.3rem 0 0">
|
||||||
|
Verzonden: @Tonen.Kort(openUit.DeliveredAt)<br />
|
||||||
|
Gelezen: @(openUit.ReadAt is { } gelezenOp ? Tonen.Kort(gelezenOp) : "nog niet")
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<p style="margin:.8rem 0 0; white-space:pre-wrap">@BerichttekstUit</p>
|
||||||
|
|
||||||
|
@if (Opname is not null)
|
||||||
|
{
|
||||||
|
<video src="@Opname" controls style="max-width:100%;border-radius:10px;margin-top:.6rem"></video>
|
||||||
|
}
|
||||||
|
else if (openUit.HeeftBestand && Overzetten is not null)
|
||||||
|
{
|
||||||
|
<div style="margin-top:.6rem">
|
||||||
|
<div class="zacht">@Overzetten</div>
|
||||||
|
<div class="voortgang"><span style="width:@(Percentage)%"></span></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</Popup>
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private IReadOnlyList<Bericht> Inkomend = Array.Empty<Bericht>();
|
||||||
|
private IReadOnlyList<Bericht> Uitgaand = Array.Empty<Bericht>();
|
||||||
|
private IReadOnlyList<Familielid> Leden = Array.Empty<Familielid>();
|
||||||
|
|
||||||
|
/// <summary>Welke tegel openstaat: "van" of "voor". Zie Standaardkant.</summary>
|
||||||
|
private string Kant = "van";
|
||||||
|
|
||||||
|
/// <summary>Of de kant al een keer door de gebruiker of door het laden is gekozen.</summary>
|
||||||
|
private bool KantGekozen;
|
||||||
|
|
||||||
|
/// <summary>Hoeveel binnengekomen berichten nog niet gelezen zijn.</summary>
|
||||||
|
private int AantalNieuw => Inkomend.Count(b => b.IsNieuw);
|
||||||
|
|
||||||
|
private Formulier? Schrijven;
|
||||||
|
private long? Gelezen;
|
||||||
|
private long? Weghalen;
|
||||||
|
|
||||||
|
/// <summary>Welke tegel om bevestiging vraagt om te verwijderen - bij "Voor mij" én bij
|
||||||
|
/// "Van mij" (ook als het bericht al bezorgd is, in tegenstelling tot Weghalen hierboven,
|
||||||
|
/// dat alleen voor terugtrekken vóór bezorging is).</summary>
|
||||||
|
private long? Verwijderen;
|
||||||
|
private string? Berichttekst;
|
||||||
|
|
||||||
|
/// <summary>Welk eigen verzonden bericht ter inzage openstaat; los van Gelezen hierboven,
|
||||||
|
/// dat is voor "Voor mij".</summary>
|
||||||
|
private long? GelezenUit;
|
||||||
|
private string? BerichttekstUit;
|
||||||
|
private string? Gelukt;
|
||||||
|
|
||||||
|
/// <summary>De opname bij het bericht dat openstaat, als data-URL.</summary>
|
||||||
|
private string? Opname;
|
||||||
|
|
||||||
|
/// <summary>Tekst bij de voortgangsbalk; null als er niets loopt.</summary>
|
||||||
|
private string? Overzetten;
|
||||||
|
private int Percentage;
|
||||||
|
|
||||||
|
/// <summary>De bijlagen bij het bericht dat in het wijzigscherm staat.</summary>
|
||||||
|
private IReadOnlyList<Bijlage> Bijlagen = Array.Empty<Bijlage>();
|
||||||
|
|
||||||
|
private enum Soort { Document, Foto, FotoMaken, Video, VideoOpnemen }
|
||||||
|
|
||||||
|
private static int MaxMegabyte => 50;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Kiest één keer, bij het openen, welke tegel openstaat.
|
||||||
|
///
|
||||||
|
/// "Van mij" is de gewone stand: dat is waar je komt om iets klaar te zetten of na te kijken.
|
||||||
|
/// Maar staat er iets nieuws vóór jou, dan is dat het enige wat op dat moment telt - een
|
||||||
|
/// bericht van iemand die er niet meer is, laat je niet achter een tegel staan.
|
||||||
|
///
|
||||||
|
/// Eén keer, want daarna heeft de gebruiker zelf gekozen. Bij elk herladen opnieuw beslissen
|
||||||
|
/// zou betekenen dat het scherm onder je handen omspringt zodra je een bericht wegklikt.
|
||||||
|
/// </summary>
|
||||||
|
private void Standaardkant()
|
||||||
|
{
|
||||||
|
if (KantGekozen) return;
|
||||||
|
|
||||||
|
Kant = AantalNieuw > 0 ? "voor" : "van";
|
||||||
|
KantGekozen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task LaadAsync()
|
||||||
|
{
|
||||||
|
var inkomend = await Toestand.Client.InkomendAsync();
|
||||||
|
if (inkomend.Gelukt) Inkomend = inkomend.Waarde!; else Fout = inkomend.Melding;
|
||||||
|
|
||||||
|
var uitgaand = await Toestand.Client.UitgaandAsync();
|
||||||
|
if (uitgaand.Gelukt) Uitgaand = uitgaand.Waarde!; else Fout ??= uitgaand.Melding;
|
||||||
|
|
||||||
|
Standaardkant();
|
||||||
|
|
||||||
|
if (Toestand.Familie is not null)
|
||||||
|
{
|
||||||
|
var leden = await Toestand.Client.FamilieledenAsync(Toestand.Familie.Id);
|
||||||
|
if (leden.Gelukt) Leden = leden.Waarde!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Nieuw()
|
||||||
|
{
|
||||||
|
Gelukt = null;
|
||||||
|
Fout = null;
|
||||||
|
Weghalen = null;
|
||||||
|
Bijlagen = Array.Empty<Bijlage>();
|
||||||
|
Schrijven = new Formulier { BezorgOp = DateTime.Today.AddYears(1) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task WijzigenAsync(Bericht bericht)
|
||||||
|
{
|
||||||
|
Gelukt = null;
|
||||||
|
Fout = null;
|
||||||
|
Weghalen = null;
|
||||||
|
|
||||||
|
// De tekst staat versleuteld op de server; die halen we op om het scherm te vullen.
|
||||||
|
var tekst = await Toestand.Client.BerichtConceptAsync(bericht.Id);
|
||||||
|
if (!tekst.Gelukt) { Fout = tekst.Melding; return; }
|
||||||
|
|
||||||
|
Schrijven = new Formulier
|
||||||
|
{
|
||||||
|
BerichtId = bericht.Id,
|
||||||
|
AanPersoonId = bericht.ToPersonId,
|
||||||
|
Onderwerp = bericht.Subject,
|
||||||
|
Tekst = tekst.Waarde!,
|
||||||
|
Wanneer = bericht.DeliverAfterDeath ? "overlijden" : bericht.DeliverAt is null ? "nu" : "datum",
|
||||||
|
BezorgOp = bericht.DeliverAt?.ToLocalTime() ?? DateTime.Today.AddYears(1)
|
||||||
|
};
|
||||||
|
|
||||||
|
await BijlagenLadenAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ foto's en documenten
|
||||||
|
|
||||||
|
private async Task BijlagenLadenAsync()
|
||||||
|
{
|
||||||
|
if (Schrijven is not { BerichtId: > 0 } formulier) { Bijlagen = Array.Empty<Bijlage>(); return; }
|
||||||
|
|
||||||
|
var lijst = await Toestand.Client.BerichtbijlagenAsync(formulier.BerichtId);
|
||||||
|
Bijlagen = lijst.Gelukt ? lijst.Waarde! : Array.Empty<Bijlage>();
|
||||||
|
if (!lijst.Gelukt) Fout = lijst.Melding;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ToevoegenAsync(Soort soort)
|
||||||
|
{
|
||||||
|
if (Schrijven is null) return;
|
||||||
|
|
||||||
|
Fout = null;
|
||||||
|
var gekozen = soort switch
|
||||||
|
{
|
||||||
|
Soort.Document => await Bestandskiezer.KiesDocumentAsync(),
|
||||||
|
Soort.Foto => await Bestandskiezer.KiesFotoAsync(),
|
||||||
|
Soort.FotoMaken => await Bestandskiezer.MaakFotoAsync(),
|
||||||
|
Soort.Video => await Bestandskiezer.KiesVideoAsync(),
|
||||||
|
_ => await Bestandskiezer.NeemVideoOpAsync()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (gekozen is null) return;
|
||||||
|
|
||||||
|
// Nog geen nummer om het aan te hangen: dan wacht het tot het bericht is klaargezet.
|
||||||
|
if (Schrijven is not { BerichtId: > 0 } formulier)
|
||||||
|
{
|
||||||
|
Wachtrij.VoegToe(gekozen);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Overzetten = $"{gekozen.Naam} wordt opgeslagen…";
|
||||||
|
Percentage = 0;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
var uitkomst = await Toestand.Client.BestandVersturenAsync(
|
||||||
|
gekozen.Naam, gekozen.Soort, gekozen.Inhoud, null,
|
||||||
|
(gedaan, totaal) =>
|
||||||
|
{
|
||||||
|
Percentage = totaal == 0 ? 100 : (int)(gedaan * 100 / totaal);
|
||||||
|
InvokeAsync(StateHasChanged);
|
||||||
|
},
|
||||||
|
berichtId: formulier.BerichtId);
|
||||||
|
|
||||||
|
Overzetten = null;
|
||||||
|
if (!uitkomst.Gelukt) Fout = uitkomst.Melding;
|
||||||
|
else await BijlagenLadenAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task BijlageWegAsync(Bijlage bijlage)
|
||||||
|
{
|
||||||
|
// Wat nog wacht staat nergens; dat halen we hier weg en niet bij de server.
|
||||||
|
if (Bijlagewachtrij.Wacht(bijlage)) { Wachtrij.Verwijder(bijlage); return; }
|
||||||
|
|
||||||
|
if (await ProbeerAsync(async () =>
|
||||||
|
{
|
||||||
|
var r = await Toestand.Client.BijlageVerwijderenAsync(bijlage.Id);
|
||||||
|
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
|
||||||
|
}))
|
||||||
|
{
|
||||||
|
await BijlagenLadenAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task VideoOpnemenAsync()
|
||||||
|
{
|
||||||
|
Fout = null;
|
||||||
|
var opname = await Bestandskiezer.NeemVideoOpAsync();
|
||||||
|
if (opname is null) return;
|
||||||
|
|
||||||
|
Overzetten = "De opname wordt opgeslagen…";
|
||||||
|
Percentage = 0;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
// De opname gaat als los bestand naar de kluis; het bericht verwijst er straks naar.
|
||||||
|
var uitkomst = await Toestand.Client.BestandVersturenAsync(
|
||||||
|
opname.Naam, opname.Soort, opname.Inhoud, null,
|
||||||
|
(gedaan, totaal) =>
|
||||||
|
{
|
||||||
|
Percentage = totaal == 0 ? 100 : (int)(gedaan * 100 / totaal);
|
||||||
|
InvokeAsync(StateHasChanged);
|
||||||
|
});
|
||||||
|
|
||||||
|
Overzetten = null;
|
||||||
|
|
||||||
|
if (!uitkomst.Gelukt) { Fout = uitkomst.Melding; return; }
|
||||||
|
|
||||||
|
Schrijven!.BestandId = uitkomst.Waarde;
|
||||||
|
Schrijven.Bestandsnaam = opname.Naam;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Sluiten()
|
||||||
|
{
|
||||||
|
Wachtrij.Leegmaken();
|
||||||
|
Schrijven = null;
|
||||||
|
Bijlagen = Array.Empty<Bijlage>();
|
||||||
|
Overzetten = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Bijlagen die zijn gekozen voor een bericht dat nog niet bestaat.</summary>
|
||||||
|
private readonly Bijlagewachtrij Wachtrij = new();
|
||||||
|
|
||||||
|
/// <summary>Wat er opgeslagen staat plus wat er nog wacht; samen wat het scherm laat zien.</summary>
|
||||||
|
private IReadOnlyList<Bijlage> AlleBijlagen =>
|
||||||
|
Wachtrij.Leeg ? Bijlagen
|
||||||
|
: Bijlagen.Concat(Wachtrij.AlsBijlagen(DateTime.UtcNow)).ToList();
|
||||||
|
|
||||||
|
private void OpnameWeghalen()
|
||||||
|
{
|
||||||
|
if (Schrijven is null) return;
|
||||||
|
Schrijven.BestandId = null;
|
||||||
|
Schrijven.Bestandsnaam = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OpslaanAsync()
|
||||||
|
{
|
||||||
|
var f = Schrijven!;
|
||||||
|
if (f.BerichtId == 0 && f.AanPersoonId == 0) { Fout = "Kies eerst voor wie het bericht is."; return; }
|
||||||
|
if (string.IsNullOrWhiteSpace(f.Onderwerp)) { Fout = "Geef het bericht een onderwerp."; return; }
|
||||||
|
if (string.IsNullOrWhiteSpace(f.Tekst) && f.BestandId is null)
|
||||||
|
{
|
||||||
|
Fout = "Er staat nog niets in je bericht. Schrijf iets, of neem een video op.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var naOverlijden = f.Wanneer == "overlijden";
|
||||||
|
var bezorgOp = f.Wanneer == "datum" ? f.BezorgOp : null;
|
||||||
|
|
||||||
|
if (f.Wanneer == "datum" && bezorgOp is null) { Fout = "Kies een datum."; return; }
|
||||||
|
|
||||||
|
long nieuwBerichtId = 0;
|
||||||
|
|
||||||
|
if (await ProbeerAsync(async () =>
|
||||||
|
{
|
||||||
|
var r = f.BerichtId == 0
|
||||||
|
? await Toestand.Client.BerichtKlaarzettenAsync(
|
||||||
|
f.AanPersoonId, f.Onderwerp.Trim(), f.Tekst, bezorgOp, naOverlijden,
|
||||||
|
soort: f.BestandId is null ? 0 : 1, bestandId: f.BestandId)
|
||||||
|
: await Toestand.Client.BerichtWijzigenAsync(
|
||||||
|
f.BerichtId, f.Onderwerp.Trim(), f.Tekst, bezorgOp, naOverlijden);
|
||||||
|
|
||||||
|
if (!r.Gelukt) return Uitkomst.Fout(r.Melding!);
|
||||||
|
|
||||||
|
if (f.BerichtId == 0 && r.Waarde.TryGetProperty("berichtId", out var nummer))
|
||||||
|
nieuwBerichtId = nummer.GetInt64();
|
||||||
|
|
||||||
|
return Uitkomst.Ok();
|
||||||
|
}))
|
||||||
|
{
|
||||||
|
// Nu pas kunnen de bijlagen mee: het bericht bestaat en heeft een nummer.
|
||||||
|
var doel = f.BerichtId == 0 ? nieuwBerichtId : f.BerichtId;
|
||||||
|
if (doel != 0 && !Wachtrij.Leeg)
|
||||||
|
{
|
||||||
|
var misging = await Wachtrij.VerstuurAsync(Toestand.Client, berichtId: doel,
|
||||||
|
voortgang: (naam, procent) =>
|
||||||
|
{
|
||||||
|
Overzetten = $"{naam} wordt opgeslagen…";
|
||||||
|
Percentage = procent;
|
||||||
|
InvokeAsync(StateHasChanged);
|
||||||
|
});
|
||||||
|
|
||||||
|
Overzetten = null;
|
||||||
|
if (misging is not null) Fout = misging;
|
||||||
|
}
|
||||||
|
|
||||||
|
Gelukt = f.BerichtId == 0 ? "Het bericht staat klaar." : "De wijziging is opgeslagen.";
|
||||||
|
Schrijven = null;
|
||||||
|
Bijlagen = Array.Empty<Bijlage>();
|
||||||
|
await LaadAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task IntrekkenAsync(long berichtId)
|
||||||
|
{
|
||||||
|
if (await ProbeerAsync(async () =>
|
||||||
|
{
|
||||||
|
var r = await Toestand.Client.BerichtIntrekkenAsync(berichtId);
|
||||||
|
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
|
||||||
|
}))
|
||||||
|
{
|
||||||
|
Gelukt = "Het bericht is teruggetrokken.";
|
||||||
|
Weghalen = null;
|
||||||
|
await LaadAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Een bericht uit je eigen lijst halen - bij "Voor mij" of bij "Van mij", bezorgd of niet.
|
||||||
|
/// Dit is iets anders dan IntrekkenAsync hierboven: dat trekt een bericht terug vóór het is
|
||||||
|
/// bezorgd (de ander krijgt het dan nooit), dit ruimt alleen je eigen exemplaar op nadat het
|
||||||
|
/// er al was - de ander houdt het zijne gewoon.
|
||||||
|
/// </summary>
|
||||||
|
private async Task VerwijderenAsync(long berichtId)
|
||||||
|
{
|
||||||
|
if (await ProbeerAsync(async () =>
|
||||||
|
{
|
||||||
|
var r = await Toestand.Client.BerichtVerwijderenAsync(berichtId);
|
||||||
|
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
|
||||||
|
}))
|
||||||
|
{
|
||||||
|
Gelukt = "Het bericht is verwijderd.";
|
||||||
|
Verwijderen = null;
|
||||||
|
await LaadAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Doet de popup met het gelezen bericht weer dicht.</summary>
|
||||||
|
private void Dichtdoen()
|
||||||
|
{
|
||||||
|
Gelezen = null;
|
||||||
|
Berichttekst = null;
|
||||||
|
Opname = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Doet de popup met het eigen verzonden bericht weer dicht.</summary>
|
||||||
|
private void DichtdoenUit()
|
||||||
|
{
|
||||||
|
GelezenUit = null;
|
||||||
|
BerichttekstUit = null;
|
||||||
|
Opname = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Je eigen verzonden bericht nog eens bekijken, ook als het al bezorgd is. Anders dan
|
||||||
|
/// LezenAsync hierboven (voor "Voor mij", dat meteen ook als gelezen markeert en de lijst
|
||||||
|
/// herlaadt): hier verandert er niets aan de status, dus is er ook niets om te herladen.
|
||||||
|
/// </summary>
|
||||||
|
private async Task BekijkenUitAsync(long id)
|
||||||
|
{
|
||||||
|
Opname = null;
|
||||||
|
|
||||||
|
var gelukt = await ProbeerAsync(async () =>
|
||||||
|
{
|
||||||
|
var r = await Toestand.Client.BerichtVerzondenLezenAsync(id);
|
||||||
|
if (!r.Gelukt) return Uitkomst.Fout(r.Melding!);
|
||||||
|
BerichttekstUit = r.Waarde;
|
||||||
|
GelezenUit = id;
|
||||||
|
return Uitkomst.Ok();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (gelukt && Uitgaand.FirstOrDefault(b => b.Id == id) is { VaultFileId: { } bestandId })
|
||||||
|
{
|
||||||
|
Overzetten = "De opname wordt opgehaald…";
|
||||||
|
Percentage = 0;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
var bestand = await Toestand.Client.BestandOphalenAsync(bestandId,
|
||||||
|
(gedaan, totaal) =>
|
||||||
|
{
|
||||||
|
Percentage = totaal == 0 ? 100 : (int)(gedaan * 100 / totaal);
|
||||||
|
InvokeAsync(StateHasChanged);
|
||||||
|
});
|
||||||
|
|
||||||
|
Overzetten = null;
|
||||||
|
|
||||||
|
if (bestand.Gelukt)
|
||||||
|
{
|
||||||
|
Opname = $"data:{bestand.Waarde!.Soort};base64,{Convert.ToBase64String(bestand.Waarde.Inhoud)}";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Fout = bestand.Melding;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LezenAsync(long id)
|
||||||
|
{
|
||||||
|
Opname = null;
|
||||||
|
|
||||||
|
var gelukt = await ProbeerAsync(async () =>
|
||||||
|
{
|
||||||
|
var r = await Toestand.Client.BerichtLezenAsync(id);
|
||||||
|
if (!r.Gelukt) return Uitkomst.Fout(r.Melding!);
|
||||||
|
Berichttekst = r.Waarde;
|
||||||
|
Gelezen = id;
|
||||||
|
return Uitkomst.Ok();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (gelukt && Inkomend.FirstOrDefault(b => b.Id == id) is { VaultFileId: { } bestandId })
|
||||||
|
{
|
||||||
|
Overzetten = "De opname wordt opgehaald…";
|
||||||
|
Percentage = 0;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
var bestand = await Toestand.Client.BestandOphalenAsync(bestandId,
|
||||||
|
(gedaan, totaal) =>
|
||||||
|
{
|
||||||
|
Percentage = totaal == 0 ? 100 : (int)(gedaan * 100 / totaal);
|
||||||
|
InvokeAsync(StateHasChanged);
|
||||||
|
});
|
||||||
|
|
||||||
|
Overzetten = null;
|
||||||
|
|
||||||
|
if (bestand.Gelukt)
|
||||||
|
{
|
||||||
|
Opname = $"data:{bestand.Waarde!.Soort};base64,{Convert.ToBase64String(bestand.Waarde.Inhoud)}";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Fout = bestand.Melding;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await LaadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// De tegel is aangetikt. Staat er al een bevestiging open (terugtrekken of verwijderen),
|
||||||
|
/// dan gebeurt er niets - de aandacht hoort daarbij. Is het bericht al bezorgd, dan opent
|
||||||
|
/// het ter inzage (BekijkenUitAsync); anders open je het om te wijzigen.
|
||||||
|
/// </summary>
|
||||||
|
private Task TegelWijzigenAsync(Bericht bericht) =>
|
||||||
|
Weghalen == bericht.Id || Verwijderen == bericht.Id ? Task.CompletedTask
|
||||||
|
: Bezorgd(bericht) ? BekijkenUitAsync(bericht.Id)
|
||||||
|
: WijzigenAsync(bericht);
|
||||||
|
|
||||||
|
/// <summary>2 = bezorgd, 3 = gelezen.</summary>
|
||||||
|
private static bool Bezorgd(Bericht b) => b.Status >= 2;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Zolang het bericht nog klaarstaat, hoort hier te staan wannéér het weggaat. Is het
|
||||||
|
/// eenmaal onderweg, dan is dat niet meer interessant - dan wil je weten of het is
|
||||||
|
/// aangekomen, en of het al gelezen is. Zonder deze voorrang bleef hier voor altijd "zo
|
||||||
|
/// snel mogelijk" staan, ook nadat het allang bezorgd en gelezen was.
|
||||||
|
/// </summary>
|
||||||
|
private static string Bezorging(Bericht b) =>
|
||||||
|
b.Status == 3 ? "gelezen"
|
||||||
|
: b.Status == 2 ? "verzonden"
|
||||||
|
: b.DeliverAfterDeath ? "na mijn overlijden"
|
||||||
|
: b.DeliverAt is { } op ? Tonen.Kort(op)
|
||||||
|
: "zo snel mogelijk";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Het tekentje bij die tekst. Staat hier vlak naast <see cref="Bezorging"/>, zodat de twee
|
||||||
|
/// niet uit elkaar kunnen lopen: elk geval daar hoort er hier ook een te hebben.
|
||||||
|
///
|
||||||
|
/// Takje en kalender zijn dezelfde als in de kluis. De envelop is voor een bericht dat nog
|
||||||
|
/// klaarstaat om meteen weg te gaan; het vinkje voor verzonden, het oog voor gelezen.
|
||||||
|
/// </summary>
|
||||||
|
private static string Bezorgteken(Bericht b) =>
|
||||||
|
b.Status == 3 ? "\U0001F441\uFE0F"
|
||||||
|
: b.Status == 2 ? "\u2705"
|
||||||
|
: b.DeliverAfterDeath ? "\U0001F33F"
|
||||||
|
: b.DeliverAt is not null ? "\U0001F4C5"
|
||||||
|
: "\u2709";
|
||||||
|
|
||||||
|
private string Naam(long persoonId) =>
|
||||||
|
Leden.FirstOrDefault(l => l.PersonId == persoonId)?.DisplayName ?? "iemand";
|
||||||
|
|
||||||
|
/// <summary>De profielfoto van deze persoon; null als hij er geen heeft of niet in je lijst staat.</summary>
|
||||||
|
private string? Foto(long persoonId) =>
|
||||||
|
Leden.FirstOrDefault(l => l.PersonId == persoonId)?.Profielfotobron;
|
||||||
|
|
||||||
|
private class Formulier
|
||||||
|
{
|
||||||
|
public long BerichtId { get; set; }
|
||||||
|
public long AanPersoonId { get; set; }
|
||||||
|
public string Onderwerp { get; set; } = "";
|
||||||
|
public string Tekst { get; set; } = "";
|
||||||
|
public string Wanneer { get; set; } = "overlijden";
|
||||||
|
public DateTime? BezorgOp { get; set; }
|
||||||
|
|
||||||
|
/// <summary>De opname die bij dit bericht hoort, als die er is.</summary>
|
||||||
|
public long? BestandId { get; set; }
|
||||||
|
public string? Bestandsnaam { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,745 @@
|
|||||||
|
@page "/nalatenschap"
|
||||||
|
@inherits SchermBasis
|
||||||
|
|
||||||
|
@* Wie mag er straks bij, hoe wordt vastgesteld dat iemand is overleden, en hoe geef je door
|
||||||
|
dat iemand anders is overleden. Dezelfde onderdelen als op de website; de kop staat in de
|
||||||
|
balk erboven, dus hier niet nog een keer. *@
|
||||||
|
|
||||||
|
@if (Fout is not null)
|
||||||
|
{
|
||||||
|
<div class="melding fout">@Fout</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (Gelukt is not null)
|
||||||
|
{
|
||||||
|
<div class="melding goed">@Gelukt</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ---------------------------------------------------------------- loopt er iets *@
|
||||||
|
@if (Stand is { LooptEenProcedure: true })
|
||||||
|
{
|
||||||
|
<div class="melding let-op">
|
||||||
|
@if (Stand.WachtOpBeheerder)
|
||||||
|
{
|
||||||
|
<text>
|
||||||
|
Er @(Stand.ActiveDeclarations == 1 ? "is één melding" : $"zijn {Stand.ActiveDeclarations} meldingen")
|
||||||
|
binnengekomen over jouw profiel — genoeg in aantal, maar er zit nog geen melding bij
|
||||||
|
van iemand die jij als beheerder hebt aangewezen. Zolang die er niet is gebeurt er niets.
|
||||||
|
</text>
|
||||||
|
}
|
||||||
|
else if (Stand.Missing == 0)
|
||||||
|
{
|
||||||
|
<strong>Er is gemeld dat je zou zijn overleden.</strong>
|
||||||
|
<text> Klopt het niet, laat het dan weten</text>
|
||||||
|
@if (Stand.ObjectionDeadline is { } tot) { <text> vóór @Tonen.Datum(tot)</text> }
|
||||||
|
<text>.</text>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<text>
|
||||||
|
Er @(Stand.ActiveDeclarations == 1 ? "is één melding" : $"zijn {Stand.ActiveDeclarations} meldingen")
|
||||||
|
binnengekomen over jouw profiel. Er
|
||||||
|
@(Stand.Missing == 1 ? "is er nog één" : $"zijn er nog {Stand.Missing}") nodig
|
||||||
|
voordat de procedure begint.
|
||||||
|
</text>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="voortgang" style="margin-top:.7rem"><span style="width:@(Breedte)%"></span></div>
|
||||||
|
|
||||||
|
<button class="knop" style="margin-top:.8rem" disabled="@Bezig" @onclick="IkBenErNogAsync">
|
||||||
|
Ik ben er nog
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (Stand is { IsBevestigd: true })
|
||||||
|
{
|
||||||
|
<div class="melding let-op">Het overlijden is bevestigd. De beheerder heeft toegang gekregen.</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ---------------------------------------------------------------- aan mij toevertrouwd *@
|
||||||
|
@if (Toevertrouwd.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="kaart">
|
||||||
|
<h2 style="margin-top:0">Aan mij toevertrouwd</h2>
|
||||||
|
<p class="zacht" style="margin-top:0">
|
||||||
|
Deze mensen hebben jou aangewezen om hun nalatenschap te regelen. Zolang zij er zijn
|
||||||
|
valt er niets in te zien; dat verandert pas als de procedure is afgerond.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="stapel">
|
||||||
|
@foreach (var nalatenschap in Toevertrouwd)
|
||||||
|
{
|
||||||
|
@* Dezelfde tegel als bij Momenten en Mijn mensen: foto voorop, de tekst over de
|
||||||
|
volle breedte, de knop als tekentje rechtsonder in de hoek van de rij. Naast
|
||||||
|
de naam bleef er op een telefoon zo weinig over dat "Overleden 3 Mrt. 2026"
|
||||||
|
over vier regels ging en de naam werd afgekapt. *@
|
||||||
|
<div class="kaart compact @(nalatenschap.Afgerond ? "tegel klikbaar" : "")"
|
||||||
|
role="@(nalatenschap.Afgerond ? "button" : null)"
|
||||||
|
tabindex="@(nalatenschap.Afgerond ? "0" : null)"
|
||||||
|
title="@(!nalatenschap.Afgerond ? null
|
||||||
|
: Bekeken == nalatenschap.PersonId ? "Sluiten" : "Bekijken")"
|
||||||
|
@onclick="() => TegelOpenenAsync(nalatenschap)"
|
||||||
|
@onkeydown="e => ToetsAsync(e, () => TegelOpenenAsync(nalatenschap))">
|
||||||
|
<div class="rij persoon">
|
||||||
|
<Penning Naam="@nalatenschap.DisplayName" Bron="@nalatenschap.Profielfotobron" />
|
||||||
|
|
||||||
|
@* Drie regels, net als bij Momenten. Twee regels kon ook, maar dan botst
|
||||||
|
het driehoekje rechtsonder tegen de naam aan zodra die lang is. *@
|
||||||
|
<div class="groei">
|
||||||
|
<span class="groot" title="@nalatenschap.DisplayName">@nalatenschap.DisplayName</span>
|
||||||
|
|
||||||
|
<div class="zacht">
|
||||||
|
@if (!nalatenschap.Afgerond)
|
||||||
|
{
|
||||||
|
<text>Je bent aangewezen</text>
|
||||||
|
}
|
||||||
|
else if (nalatenschap.DateOfDeath is { } overleden)
|
||||||
|
{
|
||||||
|
<text>Overleden @Tonen.Kort(overleden)</text>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Wat @nalatenschap.DisplayName zelf heeft ingesteld: zo weet je bij
|
||||||
|
leven al waar je op moet rekenen, en kun je het er nog met diegene
|
||||||
|
over hebben in plaats van er pas achter te komen zodra het zover
|
||||||
|
is. Alleen zolang de procedure nog niet is afgerond - daarna is
|
||||||
|
het geen vooruitzicht meer maar geschiedenis. *@
|
||||||
|
@if (!nalatenschap.Afgerond)
|
||||||
|
{
|
||||||
|
<div class="zacht" style="margin-top:.1rem">
|
||||||
|
@nalatenschap.RequiredDeclarations
|
||||||
|
meldingen nodig · @nalatenschap.ObjectionDays dagen bezwaartermijn
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* De onderste regel draagt rechts het wijzertje. Geen knop: je klapt
|
||||||
|
de kluis open door de tegel aan te tikken. *@
|
||||||
|
<div class="onderregel">
|
||||||
|
<span class="zacht">
|
||||||
|
@if (!nalatenschap.Afgerond)
|
||||||
|
{
|
||||||
|
<text>Volgorde @nalatenschap.Volgorde</text>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<text>@nalatenschap.AantalItems item@(nalatenschap.AantalItems == 1 ? "" : "s")</text>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
@if (nalatenschap.Afgerond)
|
||||||
|
{
|
||||||
|
<span class="tegelknoppen">
|
||||||
|
<span class="tegelteken" aria-hidden="true">@(Bekeken == nalatenschap.PersonId ? "▲" : "▼")</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (nalatenschap.Afgerond && Bekeken == nalatenschap.PersonId)
|
||||||
|
{
|
||||||
|
@* Alles hieronder zit ín de tegel, en een tik op de tegel klapt hem open
|
||||||
|
of dicht. Zonder stopPropagation klapt hij dus weer dicht zodra je een
|
||||||
|
item eronder aantikt. *@
|
||||||
|
<div @onclick="() => { }" @onclick:stopPropagation="true">
|
||||||
|
@if (Nagelaten.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="zacht" style="margin:.8rem 0 0">
|
||||||
|
Er is niets vrijgegeven. @nalatenschap.DisplayName heeft alles op
|
||||||
|
"alleen voor mijzelf" laten staan; dat blijft ook nu dicht.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="stapel" style="margin-top:.8rem">
|
||||||
|
@foreach (var item in Nagelaten)
|
||||||
|
{
|
||||||
|
<div class="rij">
|
||||||
|
<div class="groei">
|
||||||
|
<span>@item.Title</span>
|
||||||
|
<div class="zacht">@Tonen.Categorie(item.Category)</div>
|
||||||
|
</div>
|
||||||
|
<button class="knop rustig klein" disabled="@Bezig" @onclick:stopPropagation="true"
|
||||||
|
@onclick="() => ItemOpenenAsync(item)">Openen</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ---------------------------------------------------------------- met mij gedeeld *@
|
||||||
|
@if (GedeeldMetMij.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="kaart">
|
||||||
|
<h2 style="margin-top:0">Met mij gedeeld</h2>
|
||||||
|
<p class="zacht" style="margin-top:0">
|
||||||
|
Dit is met jou gedeeld om alvast te bespreken - je hoeft niet te wachten op een
|
||||||
|
overlijden om het te lezen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="stapel">
|
||||||
|
@foreach (var item in GedeeldMetMij)
|
||||||
|
{
|
||||||
|
<div class="rij">
|
||||||
|
<div class="groei">
|
||||||
|
<span>@item.Title</span>
|
||||||
|
<div class="zacht">@Tonen.Categorie(item.Category)</div>
|
||||||
|
</div>
|
||||||
|
<button class="knop rustig klein" disabled="@Bezig" @onclick="() => ItemOpenenAsync(item)">Openen</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ---------------------------------------------------------------- item bekijken *@
|
||||||
|
@* Dezelfde opmaak als het invulformulier in de kluis (velden onder elkaar), maar zonder
|
||||||
|
invoervelden: dit is een ander item dan het jouwe, dus er valt hier niets te wijzigen -
|
||||||
|
alleen te lezen en, als er iets bij hangt, te bekijken. *@
|
||||||
|
@if (GeopendItem is not null && Inhoud is not null)
|
||||||
|
{
|
||||||
|
<Popup Titel="@GeopendTitel" Bevestigtekst="Sluiten" AlleenSluiten="true"
|
||||||
|
OpBevestigen="ItemDichtdoen" OpSluiten="ItemDichtdoen">
|
||||||
|
<div class="stapel">
|
||||||
|
@foreach (var regel in Regels(Inhoud))
|
||||||
|
{
|
||||||
|
<div class="veld">
|
||||||
|
<label>@regel.Naam</label>
|
||||||
|
<p style="margin:.2rem 0 0">@regel.Waarde</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (GeopendBijlagen.Count > 0)
|
||||||
|
{
|
||||||
|
<hr />
|
||||||
|
<h3>Bijlagen</h3>
|
||||||
|
<div class="stapel">
|
||||||
|
@foreach (var bijlage in GeopendBijlagen)
|
||||||
|
{
|
||||||
|
<div class="rij bijlage">
|
||||||
|
<button type="button" class="bijlage-openen groei" disabled="@Bezig"
|
||||||
|
title="Bekijken" @onclick="() => BijlageBekijkenAsync(bijlage)">
|
||||||
|
<span class="teken">@(bijlage.IsAfbeelding ? "\U0001F4F7" : bijlage.IsVideo ? "\U0001F3AC" : "\U0001F4C4")</span>
|
||||||
|
<span class="groei">
|
||||||
|
<span class="naam">@bijlage.FileName</span>
|
||||||
|
<span class="zacht">@bijlage.Grootte</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</Popup>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (BekekenBijlage is not null)
|
||||||
|
{
|
||||||
|
<Popup Titel="@BekekenBijlage.FileName" Bevestigtekst="Sluiten" AlleenSluiten="true"
|
||||||
|
OpBevestigen="BijlageDichtdoen" OpSluiten="BijlageDichtdoen">
|
||||||
|
|
||||||
|
@if (BijlageOphalen)
|
||||||
|
{
|
||||||
|
<p class="zacht">Bezig met ophalen…</p>
|
||||||
|
<div class="voortgang"><span style="width:@(BijlageVordering)%"></span></div>
|
||||||
|
}
|
||||||
|
else if (BijlageKijkfout is not null)
|
||||||
|
{
|
||||||
|
<div class="melding fout">@BijlageKijkfout</div>
|
||||||
|
}
|
||||||
|
else if (BijlageBron is not null && BekekenBijlage.IsAfbeelding)
|
||||||
|
{
|
||||||
|
<img src="@BijlageBron" alt="@BekekenBijlage.FileName" class="bijlage-groot" />
|
||||||
|
}
|
||||||
|
else if (BijlageBron is not null && BekekenBijlage.IsVideo)
|
||||||
|
{
|
||||||
|
<video src="@BijlageBron" controls class="bijlage-groot"></video>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p class="zacht">
|
||||||
|
Dit soort bestand is hier niet te tonen. Op de website kun je het downloaden.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</Popup>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ---------------------------------------------------------------- mijn beheerders *@
|
||||||
|
<div class="kaart">
|
||||||
|
<h2>Mijn beheerders</h2>
|
||||||
|
|
||||||
|
@if (Beheerders.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="zacht">Je hebt nog niemand aangewezen. Zonder beheerder blijft je kluis dicht.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="stapel">
|
||||||
|
@foreach (var beheerder in Beheerders)
|
||||||
|
{
|
||||||
|
<div class="rij persoon">
|
||||||
|
<Penning Naam="@beheerder.DisplayName" Bron="@beheerder.Profielfotobron" />
|
||||||
|
<div class="groei">
|
||||||
|
<span class="groot" title="@beheerder.DisplayName">@beheerder.DisplayName</span>
|
||||||
|
<div class="zacht">
|
||||||
|
Volgorde @beheerder.Priority
|
||||||
|
@if (beheerder.HeeftToegang) { <text> · heeft toegang gekregen</text> }
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Wie je aanwijst moet je ook weer kunnen weghalen - als hij zelf overlijdt,
|
||||||
|
of als je het anders wilt regelen. Behalve als hij je kluis al heeft
|
||||||
|
gekregen: dan valt er niets meer terug te nemen, en dat zegt de server
|
||||||
|
ook zo als je het toch probeert. *@
|
||||||
|
@if (!beheerder.HeeftToegang)
|
||||||
|
{
|
||||||
|
<span class="tegelknoppen">
|
||||||
|
<button class="knop rustig klein" disabled="@Bezig"
|
||||||
|
title="Niet meer laten beheren" aria-label="Niet meer laten beheren"
|
||||||
|
@onclick="() => Weghalen = beheerder">🗑️</button>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (Weghalen?.PersonId == beheerder.PersonId)
|
||||||
|
{
|
||||||
|
<div class="melding let-op">
|
||||||
|
<strong>@beheerder.DisplayName</strong> niet meer laten beheren? Je kluis
|
||||||
|
gaat dan niet meer naar hem of haar. Wijs wel iemand anders aan: zonder
|
||||||
|
beheerder blijft je kluis ook na je overlijden dicht.
|
||||||
|
<div class="rij" style="margin-top:.7rem">
|
||||||
|
<button class="knop gevaar" disabled="@Bezig"
|
||||||
|
@onclick="() => BeheerderWegAsync(beheerder)">
|
||||||
|
Ja, weghalen
|
||||||
|
</button>
|
||||||
|
<button class="knop rustig" disabled="@Bezig" @onclick="() => Weghalen = null">
|
||||||
|
Nee, laat staan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<button class="knop vol" style="margin-top:1rem" disabled="@Bezig" @onclick="() => Aanwijzen = true">
|
||||||
|
Iemand aanwijzen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* ---------------------------------------------------------------- de procedure *@
|
||||||
|
<div class="kaart">
|
||||||
|
<h2>Hoeveel meldingen zijn er nodig?</h2>
|
||||||
|
<p class="zacht">
|
||||||
|
Er moeten meerdere verschillende mensen melden dat je bent overleden. Daarna krijg je
|
||||||
|
zelf nog een termijn om dat tegen te spreken. Pas als die verstrijkt, krijgt je beheerder
|
||||||
|
toegang.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="veld">
|
||||||
|
<label for="aantal">Aantal meldingen</label>
|
||||||
|
<select id="aantal" @bind="AantalMeldingen">
|
||||||
|
@for (var n = 1; n <= 10; n++)
|
||||||
|
{
|
||||||
|
<option value="@n">@n</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="veld">
|
||||||
|
<label for="dagen">Bezwaartermijn in dagen</label>
|
||||||
|
@* Een vrij getal, net als op de website. Hier stond een keuzelijstje met zes vaste
|
||||||
|
waarden; dat is een beperking die nergens op sloeg - de server heeft altijd alles
|
||||||
|
tussen 1 en 90 toegestaan. *@
|
||||||
|
<input id="dagen" type="number" inputmode="numeric" min="1" max="90" @bind="Bezwaardagen" />
|
||||||
|
<div class="uitleg">Tussen 1 en 90 dagen. Zoveel tijd krijg je om een melding tegen te spreken.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Zonder beheerder komt de procedure niet op gang, hoeveel mensen er ook melden. Dat hoort
|
||||||
|
hier te staan en niet pas als het zover is. *@
|
||||||
|
@if (Beheerders.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="melding let-op" style="margin-top:.8rem">
|
||||||
|
Je hebt nog niemand aangewezen. Melden mag iedereen uit Mijn mensen, maar de procedure
|
||||||
|
begint pas te lopen als er een melding van een van jouw beheerders bij zit. Zonder
|
||||||
|
beheerder gebeurt er dus niets.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<button class="knop rustig" disabled="@Bezig" @onclick="InstellingenOpslaanAsync">Opslaan</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* ---------------------------------------------------------------- iemand melden *@
|
||||||
|
@* Melden mag over iedereen uit je eigen mensen. Staat daar niemand in, dan staat dit blok er
|
||||||
|
niet: een knop met een lege lijst is geen knop. *@
|
||||||
|
@if (Meldbaar.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="kaart">
|
||||||
|
<h2>Iemand als overleden melden</h2>
|
||||||
|
<p class="zacht">
|
||||||
|
Is iemand uit Mijn mensen overleden? Geef het hier door. Er zijn meerdere
|
||||||
|
onafhankelijke meldingen nodig, waaronder één van iemand die hij zelf als beheerder
|
||||||
|
heeft aangewezen. Daarna krijgt de betrokkene eerst bericht.
|
||||||
|
</p>
|
||||||
|
<button class="knop gevaar" disabled="@Bezig" @onclick="() => Melden = true">Melding doen</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ---------------------------------------------------------------- popups *@
|
||||||
|
@if (Aanwijzen)
|
||||||
|
{
|
||||||
|
<Popup Titel="Iemand aanwijzen" Bevestigtekst="Aanwijzen" Bezig="@Bezig"
|
||||||
|
OpBevestigen="AanwijzenAsync" OpSluiten="() => Aanwijzen = false">
|
||||||
|
|
||||||
|
<div class="veld">
|
||||||
|
<label for="beheerder">Wie wijs je aan?</label>
|
||||||
|
<select id="beheerder" @bind="BeheerderId">
|
||||||
|
<option value="0">Kies iemand</option>
|
||||||
|
@foreach (var lid in Kandidaten)
|
||||||
|
{
|
||||||
|
<option value="@lid.PersonId">@lid.DisplayName</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="veld">
|
||||||
|
<label for="volgorde">Volgorde</label>
|
||||||
|
<select id="volgorde" @bind="Volgorde">
|
||||||
|
@for (var n = 1; n <= 5; n++)
|
||||||
|
{
|
||||||
|
<option value="@n">@n</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<div class="uitleg">1 is de eerste keus. Wijs je er meer aan, dan is dit de volgorde.</div>
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (Melden)
|
||||||
|
{
|
||||||
|
<Popup Titel="Iemand als overleden melden" Bevestigtekst="Melding doen" Gevaarlijk="true"
|
||||||
|
Bezig="@Bezig" OpBevestigen="MeldenAsync" OpSluiten="() => Melden = false">
|
||||||
|
|
||||||
|
<div class="veld">
|
||||||
|
<label for="overledene">Wie?</label>
|
||||||
|
<select id="overledene" @bind="OverledeneId">
|
||||||
|
<option value="0">Kies iemand</option>
|
||||||
|
@foreach (var wie in Meldbaar)
|
||||||
|
{
|
||||||
|
<option value="@wie.PersonId">@wie.DisplayName</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="veld">
|
||||||
|
<label>Datum van overlijden</label>
|
||||||
|
<Datumkiezer Waarde="Overlijdensdatum" WaardeChanged="d => Overlijdensdatum = d"
|
||||||
|
Optioneel="true" Aflopend="true"
|
||||||
|
VanJaar="@(DateTime.Today.Year - 5)" TotJaar="@DateTime.Today.Year" />
|
||||||
|
<div class="uitleg">Niet verplicht als je het niet precies weet.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="zacht">
|
||||||
|
Deze melding is niet zomaar iets: de betrokkene krijgt er bericht van. Doe hem alleen
|
||||||
|
als je het zeker weet.
|
||||||
|
</p>
|
||||||
|
</Popup>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private IReadOnlyList<Familielid> Leden = Array.Empty<Familielid>();
|
||||||
|
private IReadOnlyList<Beheerder> Beheerders = Array.Empty<Beheerder>();
|
||||||
|
private NalatenschapStand? Stand;
|
||||||
|
private string? Gelukt;
|
||||||
|
|
||||||
|
private bool Aanwijzen, Melden;
|
||||||
|
private long BeheerderId, OverledeneId;
|
||||||
|
private int Volgorde = 1;
|
||||||
|
private DateTime? Overlijdensdatum;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Over wie jij een overlijdensmelding mag doen: iedereen uit je eigen mensen die nog niet
|
||||||
|
/// als overleden staat. Dezelfde kring die de server aanhoudt, zodat het scherm en de regel
|
||||||
|
/// het over dezelfde mensen hebben.
|
||||||
|
/// </summary>
|
||||||
|
private IReadOnlyList<Familielid> Meldbaar =>
|
||||||
|
Leden.Where(l => l.PersonId != Toestand.PersoonId && l.DateOfDeath is null).ToList();
|
||||||
|
|
||||||
|
private int AantalMeldingen = 3;
|
||||||
|
private int Bezwaardagen = 14;
|
||||||
|
|
||||||
|
/// <summary>De nalatenschappen die aan mij zijn toevertrouwd en waarvan de procedure rond is.</summary>
|
||||||
|
private IReadOnlyList<Toevertrouwd> Toevertrouwd = Array.Empty<Toevertrouwd>();
|
||||||
|
|
||||||
|
/// <summary>Kluisitems die iemand rechtstreeks met mij heeft gedeeld (nog geen overlijden nodig).</summary>
|
||||||
|
private IReadOnlyList<KluisItem> GedeeldMetMij = Array.Empty<KluisItem>();
|
||||||
|
|
||||||
|
/// <summary>Welke nalatenschap openstaat, en welk item daarbinnen.</summary>
|
||||||
|
private long? Bekeken, GeopendItem;
|
||||||
|
private IReadOnlyList<KluisItem> Nagelaten = Array.Empty<KluisItem>();
|
||||||
|
private KluisInhoud? Inhoud;
|
||||||
|
private string? GeopendTitel;
|
||||||
|
private IReadOnlyList<Bijlage> GeopendBijlagen = Array.Empty<Bijlage>();
|
||||||
|
|
||||||
|
/// <summary>De bijlage die openstaat om te bekijken; null als er niets openstaat.</summary>
|
||||||
|
private Bijlage? BekekenBijlage;
|
||||||
|
private string? BijlageBron;
|
||||||
|
private bool BijlageOphalen;
|
||||||
|
private int BijlageVordering;
|
||||||
|
private string? BijlageKijkfout;
|
||||||
|
|
||||||
|
/// <summary>Wie er nog aangewezen kan worden: iedereen behalve jezelf en wie er al staat.</summary>
|
||||||
|
private IEnumerable<Familielid> Kandidaten =>
|
||||||
|
Leden.Where(l => l.PersonId != Toestand.PersoonId
|
||||||
|
&& Beheerders.All(b => b.PersonId != l.PersonId));
|
||||||
|
|
||||||
|
private int Breedte => Stand is null || Stand.RequiredDeclarations == 0
|
||||||
|
? 0
|
||||||
|
: Math.Min(100, Stand.ActiveDeclarations * 100 / Stand.RequiredDeclarations);
|
||||||
|
|
||||||
|
protected override async Task LaadAsync()
|
||||||
|
{
|
||||||
|
if (Toestand.Familie is null) await Toestand.VerversAsync();
|
||||||
|
|
||||||
|
if (Toestand.Familie is not null)
|
||||||
|
{
|
||||||
|
var leden = await Toestand.Client.FamilieledenAsync(Toestand.Familie.Id);
|
||||||
|
if (leden.Gelukt) Leden = leden.Waarde!;
|
||||||
|
}
|
||||||
|
|
||||||
|
var beheerders = await Toestand.Client.BeheerdersAsync();
|
||||||
|
if (beheerders.Gelukt) Beheerders = beheerders.Waarde!;
|
||||||
|
|
||||||
|
var toevertrouwd = await Toestand.Client.ToevertrouwdAsync();
|
||||||
|
if (toevertrouwd.Gelukt) Toevertrouwd = toevertrouwd.Waarde!;
|
||||||
|
|
||||||
|
var gedeeld = await Toestand.Client.GedeeldMetMijAsync();
|
||||||
|
if (gedeeld.Gelukt) GedeeldMetMij = gedeeld.Waarde!;
|
||||||
|
|
||||||
|
var stand = await Toestand.Client.NalatenschapStandAsync();
|
||||||
|
if (stand.Gelukt)
|
||||||
|
{
|
||||||
|
Stand = stand.Waarde;
|
||||||
|
|
||||||
|
// Allebei de velden terugzetten op wat er is opgeslagen. Het aantal dagen ontbrak
|
||||||
|
// hier: dat liet zich wijzigen en opslaan, maar sprong bij het herladen terug naar
|
||||||
|
// veertien - waardoor het leek alsof het niet werd bewaard.
|
||||||
|
AantalMeldingen = Stand!.RequiredDeclarations;
|
||||||
|
Bezwaardagen = Stand.ObjectionDays;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Fout = stand.Melding;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ aan mij toevertrouwd
|
||||||
|
|
||||||
|
/// <summary>De beheerder waarvan de vraag "weghalen?" openstaat.</summary>
|
||||||
|
private Beheerder? Weghalen;
|
||||||
|
|
||||||
|
private async Task BeheerderWegAsync(Beheerder beheerder)
|
||||||
|
{
|
||||||
|
if (await ProbeerAsync(async () =>
|
||||||
|
{
|
||||||
|
var r = await Toestand.Client.VerwijderBeheerderAsync(beheerder.PersonId);
|
||||||
|
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
|
||||||
|
}))
|
||||||
|
{
|
||||||
|
Gelukt = $"{beheerder.DisplayName} beheert je nalatenschap niet meer.";
|
||||||
|
Weghalen = null;
|
||||||
|
await LaadAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>De tegel is aangetikt. Zolang de procedure loopt valt er niets in te zien.</summary>
|
||||||
|
private Task TegelOpenenAsync(Toevertrouwd nalatenschap) =>
|
||||||
|
nalatenschap.Afgerond ? OpenenAsync(nalatenschap.PersonId) : Task.CompletedTask;
|
||||||
|
|
||||||
|
private async Task OpenenAsync(long persoonId)
|
||||||
|
{
|
||||||
|
if (Bekeken == persoonId) { Dichtdoen(); return; }
|
||||||
|
|
||||||
|
if (await ProbeerAsync(async () =>
|
||||||
|
{
|
||||||
|
var r = await Toestand.Client.NalatenschapItemsAsync(persoonId);
|
||||||
|
if (!r.Gelukt) return Uitkomst.Fout(r.Melding!);
|
||||||
|
|
||||||
|
Bekeken = persoonId;
|
||||||
|
Nagelaten = r.Waarde!;
|
||||||
|
ItemDichtdoen();
|
||||||
|
return Uitkomst.Ok();
|
||||||
|
}))
|
||||||
|
{
|
||||||
|
Gelukt = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Dichtdoen()
|
||||||
|
{
|
||||||
|
Bekeken = null;
|
||||||
|
Nagelaten = Array.Empty<KluisItem>();
|
||||||
|
ItemDichtdoen();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opent één nagelaten of gedeeld item. De sleutel daarvoor is bij het bevestigen van het
|
||||||
|
/// overlijden, of bij het delen zelf, voor jou klaargezet; de server gebruikt die vanzelf
|
||||||
|
/// als het item niet van jou is.
|
||||||
|
/// </summary>
|
||||||
|
private async Task ItemOpenenAsync(KluisItem item)
|
||||||
|
{
|
||||||
|
if (GeopendItem == item.Id) { ItemDichtdoen(); return; }
|
||||||
|
|
||||||
|
await ProbeerAsync(async () =>
|
||||||
|
{
|
||||||
|
var r = await Toestand.Client.KluisOpenenAsync(item.Id);
|
||||||
|
if (!r.Gelukt) return Uitkomst.Fout(r.Melding!);
|
||||||
|
|
||||||
|
GeopendItem = item.Id;
|
||||||
|
Inhoud = r.Waarde;
|
||||||
|
GeopendTitel = item.Title;
|
||||||
|
|
||||||
|
// Bijlagen zijn hier optioneel: als ophalen mislukt tonen we het item gewoon
|
||||||
|
// zonder bijlagenlijst, in plaats van de hele popup te blokkeren.
|
||||||
|
var bijlagen = await Toestand.Client.BijlagenAsync(item.Id);
|
||||||
|
GeopendBijlagen = bijlagen.Gelukt ? bijlagen.Waarde! : Array.Empty<Bijlage>();
|
||||||
|
return Uitkomst.Ok();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ItemDichtdoen()
|
||||||
|
{
|
||||||
|
GeopendItem = null;
|
||||||
|
Inhoud = null;
|
||||||
|
GeopendTitel = null;
|
||||||
|
GeopendBijlagen = Array.Empty<Bijlage>();
|
||||||
|
BijlageDichtdoen();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task BijlageBekijkenAsync(Bijlage bijlage)
|
||||||
|
{
|
||||||
|
BekekenBijlage = bijlage;
|
||||||
|
BijlageBron = null;
|
||||||
|
BijlageKijkfout = null;
|
||||||
|
|
||||||
|
// Een document halen we niet op: we kunnen het toch niet tonen, en het kan tientallen
|
||||||
|
// megabytes zijn. De popup zegt dan meteen waar je het wél kunt openen.
|
||||||
|
if (!bijlage.IsAfbeelding && !bijlage.IsVideo) return;
|
||||||
|
|
||||||
|
BijlageOphalen = true;
|
||||||
|
BijlageVordering = 0;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
var uitkomst = await Toestand.Client.BestandOphalenAsync(bijlage.Id,
|
||||||
|
(gedaan, totaal) =>
|
||||||
|
{
|
||||||
|
BijlageVordering = totaal == 0 ? 100 : (int)(gedaan * 100 / totaal);
|
||||||
|
InvokeAsync(StateHasChanged);
|
||||||
|
});
|
||||||
|
|
||||||
|
BijlageOphalen = false;
|
||||||
|
|
||||||
|
if (!uitkomst.Gelukt) { BijlageKijkfout = uitkomst.Melding; return; }
|
||||||
|
|
||||||
|
// De webview haalt zijn plaatjes niet van een server, dus geven we de inhoud mee in de
|
||||||
|
// verwijzing zelf. Dat kan omdat er een grens op de bestandsgrootte staat.
|
||||||
|
BijlageBron = $"data:{uitkomst.Waarde!.Soort};base64,{Convert.ToBase64String(uitkomst.Waarde.Inhoud)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BijlageDichtdoen()
|
||||||
|
{
|
||||||
|
BekekenBijlage = null;
|
||||||
|
BijlageBron = null;
|
||||||
|
BijlageKijkfout = null;
|
||||||
|
BijlageOphalen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Dezelfde velden als in Mijn kluis; wat de eigenaar zag, ziet de beheerder ook.</summary>
|
||||||
|
private static IEnumerable<(string Naam, string Waarde)> Regels(KluisInhoud i)
|
||||||
|
{
|
||||||
|
(string, string?)[] alles =
|
||||||
|
{
|
||||||
|
("Website", i.Website), ("Gebruikersnaam", i.Username), ("Wachtwoord", i.Secret),
|
||||||
|
("Apparaat", i.DeviceName), ("Pincode", i.Pincode), ("Herstelcodes", i.RecoveryCodes),
|
||||||
|
("Medium", i.Platform), ("Welk medium", i.PlatformAnders),
|
||||||
|
("Verzekeraar", i.Insurer), ("Polisnummer", i.PolicyNumber), ("Telefoon", i.ContactPhone),
|
||||||
|
("Begraven of cremeren", i.BurialOrCremation), ("Plek", i.Location), ("Muziek", i.Music),
|
||||||
|
("Sprekers", i.Speakers), ("Wensen", i.Wishes), ("Toelichting", i.Notes)
|
||||||
|
};
|
||||||
|
|
||||||
|
return alles.Where(r => !string.IsNullOrWhiteSpace(r.Item2)).Select(r => (r.Item1, r.Item2!));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AanwijzenAsync()
|
||||||
|
{
|
||||||
|
Gelukt = null;
|
||||||
|
if (BeheerderId == 0) { Fout = "Kies eerst iemand."; return; }
|
||||||
|
|
||||||
|
if (await ProbeerAsync(async () =>
|
||||||
|
{
|
||||||
|
var r = await Toestand.Client.WijsBeheerderAanAsync(BeheerderId, Volgorde);
|
||||||
|
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
|
||||||
|
}))
|
||||||
|
{
|
||||||
|
Gelukt = "De beheerder is vastgelegd.";
|
||||||
|
Aanwijzen = false;
|
||||||
|
BeheerderId = 0;
|
||||||
|
Volgorde = 1;
|
||||||
|
await LaadAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task InstellingenOpslaanAsync()
|
||||||
|
{
|
||||||
|
Gelukt = null;
|
||||||
|
if (await ProbeerAsync(async () =>
|
||||||
|
{
|
||||||
|
var r = await Toestand.Client.NalatenschapInstellingenAsync(AantalMeldingen, Bezwaardagen);
|
||||||
|
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
|
||||||
|
}))
|
||||||
|
{
|
||||||
|
Gelukt = "Je instellingen zijn opgeslagen.";
|
||||||
|
await LaadAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task MeldenAsync()
|
||||||
|
{
|
||||||
|
Gelukt = null;
|
||||||
|
if (OverledeneId == 0) { Fout = "Kies eerst iemand."; return; }
|
||||||
|
|
||||||
|
var datum = Overlijdensdatum is { } d ? DateOnly.FromDateTime(d) : (DateOnly?)null;
|
||||||
|
|
||||||
|
if (await ProbeerAsync(async () =>
|
||||||
|
{
|
||||||
|
var r = await Toestand.Client.MeldOverlijdenAsync(OverledeneId, datum);
|
||||||
|
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
|
||||||
|
}))
|
||||||
|
{
|
||||||
|
Gelukt = "Je melding is genoteerd.";
|
||||||
|
Melden = false;
|
||||||
|
OverledeneId = 0;
|
||||||
|
Overlijdensdatum = null;
|
||||||
|
await LaadAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task IkBenErNogAsync()
|
||||||
|
{
|
||||||
|
Gelukt = null;
|
||||||
|
if (await ProbeerAsync(async () =>
|
||||||
|
{
|
||||||
|
var r = await Toestand.Client.IkBenErNogAsync();
|
||||||
|
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
|
||||||
|
}))
|
||||||
|
{
|
||||||
|
Gelukt = "We hebben doorgegeven dat je er nog bent.";
|
||||||
|
await LaadAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,472 @@
|
|||||||
|
@page "/profiel"
|
||||||
|
@inherits SchermBasis
|
||||||
|
@inject IBestandskiezer Bestandskiezer
|
||||||
|
|
||||||
|
@* Je eigen gegevens. Dit is het enige profiel dat niet bij Mijn mensen staat - daar staat
|
||||||
|
iedereen behalve jij - dus zonder dit scherm kon je je eigen naam of adres nergens kwijt.
|
||||||
|
|
||||||
|
Het e-mailadres staat bewust apart van de rest. Dat is je inlog: wie dat zomaar kan
|
||||||
|
verzetten, kan zichzelf buitensluiten of een ander account overnemen. Daarom gaat er eerst
|
||||||
|
een code naar het nieuwe adres. De kop staat in de balk erboven, dus hier niet nog een keer. *@
|
||||||
|
|
||||||
|
@* Net als overal elders: aantikken van je eigen kaart bij Mijn mensen opent dit als popup,
|
||||||
|
niet als eigen pagina met een knop onderaan. Die knop stond hier ooit vast onderin gezet
|
||||||
|
(.knop-vast-onder), maar "vast onderin" is precies wat een Popup toch al gratis geeft - zie
|
||||||
|
.popup-knoppen. De route /profiel blijft bestaan (de kop, Instellingen en het menu linken er
|
||||||
|
nog naar), maar toont nu meteen deze popup in plaats van een gewone pagina. *@
|
||||||
|
@if (Mijn is null)
|
||||||
|
{
|
||||||
|
@if (Fout is not null)
|
||||||
|
{
|
||||||
|
<div class="melding fout">@Fout</div>
|
||||||
|
}
|
||||||
|
<p class="zacht">Bezig met laden…</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<Popup Titel="Mijn profiel" Bevestigtekst="Opslaan" Bezig="@Bezig"
|
||||||
|
OpBevestigen="OpslaanAsync" OpSluiten="Sluiten">
|
||||||
|
|
||||||
|
@if (Fout is not null)
|
||||||
|
{
|
||||||
|
<div class="melding fout">@Fout</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (Gelukt is not null)
|
||||||
|
{
|
||||||
|
<div class="melding goed">@Gelukt</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="raster">
|
||||||
|
<div class="veld"><label for="vn">Voornaam</label><input id="vn" @bind="Voornaam" /></div>
|
||||||
|
<div class="veld"><label for="an">Achternaam</label><input id="an" @bind="Achternaam" /></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="veld">
|
||||||
|
<label>Geboortedatum</label>
|
||||||
|
<Datumkiezer Waarde="Geboortedatum" WaardeChanged="d => Geboortedatum = d"
|
||||||
|
Optioneel="true" Aflopend="true"
|
||||||
|
VanJaar="1900" TotJaar="@DateTime.Today.Year" />
|
||||||
|
<div class="uitleg">Niet verplicht.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="zacht" style="margin:.8rem 0 0">
|
||||||
|
Je bent hier sinds @Tonen.Datum(Mijn.LidSinds).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@* ---------------------------------------------------------------- meldingen *@
|
||||||
|
<div class="kaart" style="margin-top:1rem">
|
||||||
|
<h2 style="margin-top:0">Meldingen</h2>
|
||||||
|
<p class="zacht" style="margin-top:0">
|
||||||
|
Als er een bericht voor je klaarstaat, laat je telefoon dat weten. Wat erin staat
|
||||||
|
komt er nooit bij te staan — alleen dát er iets is, en van wie.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@if (Proef is not null)
|
||||||
|
{
|
||||||
|
<div class="melding @(Proef.Gelukt ? "goed" : "let-op")">@Proef.Uitleg</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<button class="knop rustig" disabled="@Bezig" @onclick="ProefmeldingAsync">
|
||||||
|
🔔 Stuur me een proefmelding
|
||||||
|
</button>
|
||||||
|
<div class="uitleg">
|
||||||
|
Komt er niets, dan zegt deze knop waar het op vastloopt.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kaart" style="margin-top:1rem">
|
||||||
|
<h2 style="margin-top:0">E-mailadres</h2>
|
||||||
|
<p class="zacht" style="margin-top:0">
|
||||||
|
Hiermee log je in. Je huidige adres is <strong>@Mijn.Email</strong>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button class="knop rustig" disabled="@Bezig" @onclick="AdresWijzigen">
|
||||||
|
✉ Ander e-mailadres
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kaart" style="margin-top:1rem">
|
||||||
|
<Bijlagenblok Titel="Mijn foto's en documenten" Bijlagen="Bijlagen" Bezig="@Bezig"
|
||||||
|
KanVideo="true" KanCamera="@Bestandskiezer.KanCamera" KanProfielfoto="true"
|
||||||
|
Overzetten="@Overzetten" Percentage="@Percentage"
|
||||||
|
Uitleg="Een foto van jezelf, je paspoort, je donorcodicil. Met het sterretje maak je er je profielfoto van."
|
||||||
|
OpFotoMaken="() => ToevoegenAsync(Soort.FotoMaken)"
|
||||||
|
OpFoto="() => ToevoegenAsync(Soort.Foto)"
|
||||||
|
OpDocument="() => ToevoegenAsync(Soort.Document)"
|
||||||
|
OpVideo="() => ToevoegenAsync(Soort.VideoOpnemen)"
|
||||||
|
OpVideoKiezen="() => ToevoegenAsync(Soort.Video)"
|
||||||
|
OpVerwijderen="BijlageWegAsync"
|
||||||
|
OpProfielfoto="ProfielfotoAsync" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Onderaan, en met een rustige knop: dit is niets om per ongeluk aan te tikken. Dat het er
|
||||||
|
moet zijn is een eis van Google Play, maar het is ook gewoon eerlijk - wie een kluis
|
||||||
|
sluit, hoort erop te kunnen rekenen dat hij leeg is. *@
|
||||||
|
<div class="kaart" style="margin-top:1rem">
|
||||||
|
<h2 style="margin-top:0">Je MijnBesluit opheffen</h2>
|
||||||
|
<p class="zacht" style="margin-top:0">
|
||||||
|
Je kluis, je bestanden en de berichten die nog klaarstaan worden verwijderd. Dat is
|
||||||
|
niet terug te draaien.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button class="knop rustig" disabled="@Bezig" @onclick="OpheffenBeginnenAsync">
|
||||||
|
🗑️ Mijn account opheffen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ---------------------------------------------------------------- account opheffen *@
|
||||||
|
@if (Opheffen is not null)
|
||||||
|
{
|
||||||
|
<Popup Titel="Je MijnBesluit opheffen"
|
||||||
|
Bevestigtekst="@(Opheffen.CodeGevraagd ? "Definitief opheffen" : "Stuur me een code")"
|
||||||
|
Bezig="@Bezig" OpBevestigen="OpheffenStapAsync" OpSluiten="() => Opheffen = null">
|
||||||
|
|
||||||
|
@if (!Opheffen.CodeGevraagd)
|
||||||
|
{
|
||||||
|
<p>Dit verdwijnt:</p>
|
||||||
|
<ul>
|
||||||
|
<li>@Opheffen.Telling.Kluisitems in je kluis</li>
|
||||||
|
<li>@Opheffen.Telling.Bestanden foto's en documenten</li>
|
||||||
|
<li>@Opheffen.Telling.BerichtenKlaar berichten die nog klaarstonden</li>
|
||||||
|
<li>je lijst met @Opheffen.Telling.Mensen mensen</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>Dit blijft:</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
@Opheffen.Telling.BerichtenBezorgd berichten die al bezorgd zijn - die zijn
|
||||||
|
van degene die ze heeft gekregen
|
||||||
|
</li>
|
||||||
|
<li>je naam in de lijst van mensen die jou hebben toegevoegd</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
@if (Opheffen.Telling.Nalatenschappen > 0)
|
||||||
|
{
|
||||||
|
<div class="melding let-op">
|
||||||
|
Je bent aangewezen als nalatenschapsbeheerder bij
|
||||||
|
@Opheffen.Telling.Nalatenschappen @(Opheffen.Telling.Nalatenschappen == 1 ? "persoon" : "mensen").
|
||||||
|
Die aanwijzing vervalt. Zeg het ze even, anders rekenen ze op iemand die er niet meer is.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<p class="zacht">
|
||||||
|
We sturen eerst een code naar je e-mailadres. Zo kan niemand die je telefoon in
|
||||||
|
handen krijgt je kluis leeggooien.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p>Er is een code gestuurd naar <strong>@Mijn?.Email</strong>.</p>
|
||||||
|
|
||||||
|
<div class="veld">
|
||||||
|
<label for="opheffencode">De code uit die e-mail</label>
|
||||||
|
<input id="opheffencode" inputmode="numeric" maxlength="6" @bind="Opheffen.Code" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="melding let-op">
|
||||||
|
Hierna is het weg. Er is geen prullenbak en wij kunnen het niet terughalen.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</Popup>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ---------------------------------------------------------------- e-mailadres wijzigen *@
|
||||||
|
@if (Adres is not null)
|
||||||
|
{
|
||||||
|
<Popup Titel="Ander e-mailadres"
|
||||||
|
Bevestigtekst="@(Adres.CodeGevraagd ? "Bevestigen" : "Stuur me een code")"
|
||||||
|
Bezig="@Bezig" OpBevestigen="AdresStapAsync" OpSluiten="() => Adres = null">
|
||||||
|
|
||||||
|
@if (!Adres.CodeGevraagd)
|
||||||
|
{
|
||||||
|
<div class="veld">
|
||||||
|
<label for="nieuw">Je nieuwe e-mailadres</label>
|
||||||
|
<input id="nieuw" type="email" inputmode="email" @bind="Adres.Nieuw" />
|
||||||
|
<div class="uitleg">
|
||||||
|
We sturen er een code naartoe. Pas als je die invult verandert je adres,
|
||||||
|
dus een tikfout kan je hier niet buitensluiten.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p>Er is een code gestuurd naar <strong>@Adres.Nieuw</strong>.</p>
|
||||||
|
|
||||||
|
<div class="veld">
|
||||||
|
<label for="code">De code uit die e-mail</label>
|
||||||
|
<input id="code" inputmode="numeric" maxlength="6" @bind="Adres.Code" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="zacht">
|
||||||
|
Zodra dit klaar is word je uitgelogd. Log daarna weer in met je nieuwe adres.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</Popup>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private EigenProfiel? Mijn;
|
||||||
|
|
||||||
|
private string Voornaam = "";
|
||||||
|
private string Achternaam = "";
|
||||||
|
private DateTime? Geboortedatum;
|
||||||
|
|
||||||
|
private string? Gelukt;
|
||||||
|
|
||||||
|
/// <summary>Het adreswijzigingsformulier; null als het niet openstaat.</summary>
|
||||||
|
private Adreswissel? Adres;
|
||||||
|
|
||||||
|
/// <summary>Het opheffen van het account; null als het niet openstaat.</summary>
|
||||||
|
private Opheffing? Opheffen;
|
||||||
|
|
||||||
|
private IReadOnlyList<Bijlage> Bijlagen = Array.Empty<Bijlage>();
|
||||||
|
|
||||||
|
/// <summary>Tekst bij de voortgangsbalk tijdens een overdracht; null als er niets loopt.</summary>
|
||||||
|
private string? Overzetten;
|
||||||
|
private int Percentage;
|
||||||
|
|
||||||
|
private enum Soort { Document, Foto, FotoMaken, Video, VideoOpnemen }
|
||||||
|
|
||||||
|
/// <summary>De uitkomst van de laatste proefmelding; null als er nog niet is geprobeerd.</summary>
|
||||||
|
private Proefmelding? Proef;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Vraagt de server een proefmelding naar dit toestel te sturen.
|
||||||
|
///
|
||||||
|
/// Dit is de knop waarmee je staand naast je telefoon kunt zien of de hele keten werkt: de
|
||||||
|
/// app heeft een kenmerk, de server kent het, en Google levert af. Blijft er een melding uit,
|
||||||
|
/// dan zegt het antwoord welke van die drie het is.
|
||||||
|
/// </summary>
|
||||||
|
private async Task ProefmeldingAsync()
|
||||||
|
{
|
||||||
|
Gelukt = null;
|
||||||
|
Proef = null;
|
||||||
|
|
||||||
|
await ProbeerAsync(async () =>
|
||||||
|
{
|
||||||
|
var r = await Toestand.Client.ProefmeldingAsync();
|
||||||
|
if (!r.Gelukt) return Uitkomst.Fout(r.Melding!);
|
||||||
|
|
||||||
|
Proef = r.Waarde;
|
||||||
|
return Uitkomst.Ok();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task LaadAsync()
|
||||||
|
{
|
||||||
|
var profiel = await Toestand.Client.EigenProfielAsync();
|
||||||
|
if (!profiel.Gelukt) { Fout = profiel.Melding; return; }
|
||||||
|
|
||||||
|
Mijn = profiel.Waarde;
|
||||||
|
Voornaam = Mijn!.Voornaam;
|
||||||
|
Achternaam = Mijn.Achternaam;
|
||||||
|
Geboortedatum = Mijn.Geboortedatum?.ToDateTime(TimeOnly.MinValue);
|
||||||
|
|
||||||
|
await BijlagenLadenAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OpslaanAsync()
|
||||||
|
{
|
||||||
|
Gelukt = null;
|
||||||
|
|
||||||
|
if (await ProbeerAsync(async () =>
|
||||||
|
{
|
||||||
|
var geboren = Geboortedatum is { } g ? DateOnly.FromDateTime(g) : (DateOnly?)null;
|
||||||
|
var r = await Toestand.Client.EigenProfielOpslaanAsync(
|
||||||
|
Voornaam.Trim(), Achternaam.Trim(), geboren);
|
||||||
|
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
|
||||||
|
}))
|
||||||
|
{
|
||||||
|
// Dit scherm is bereikt via de eigen tegel bovenaan Mijn mensen; wie op Opslaan
|
||||||
|
// drukt verwacht daar weer terug te komen, niet op ditzelfde formulier te blijven
|
||||||
|
// staan met alleen een tekstje erboven.
|
||||||
|
Navigatie.NavigateTo("/familie");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Annuleren, of de popup gewoon dichttikken: terug naar waar de tegel vandaan kwam.</summary>
|
||||||
|
private void Sluiten() => Navigatie.NavigateTo("/familie");
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ e-mailadres
|
||||||
|
|
||||||
|
private void AdresWijzigen()
|
||||||
|
{
|
||||||
|
Gelukt = null;
|
||||||
|
Fout = null;
|
||||||
|
Adres = new Adreswissel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Eén knop, twee stappen: eerst de code aanvragen, daarna hem bevestigen.</summary>
|
||||||
|
private async Task AdresStapAsync()
|
||||||
|
{
|
||||||
|
var wissel = Adres!;
|
||||||
|
|
||||||
|
if (!wissel.CodeGevraagd)
|
||||||
|
{
|
||||||
|
if (await ProbeerAsync(async () =>
|
||||||
|
{
|
||||||
|
var r = await Toestand.Client.AdreswijzigingAanvragenAsync(wissel.Nieuw.Trim());
|
||||||
|
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
|
||||||
|
}))
|
||||||
|
{
|
||||||
|
wissel.Nieuw = wissel.Nieuw.Trim();
|
||||||
|
wissel.CodeGevraagd = true;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await ProbeerAsync(async () =>
|
||||||
|
{
|
||||||
|
var r = await Toestand.Client.AdreswijzigingBevestigenAsync(wissel.Nieuw, wissel.Code.Trim());
|
||||||
|
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
|
||||||
|
}))
|
||||||
|
{
|
||||||
|
// De sessie is aan de serverkant ongeldig gemaakt, dus hier ook opruimen: anders
|
||||||
|
// loopt het volgende scherm tegen "je sessie is verlopen" aan zonder uitleg.
|
||||||
|
Adres = null;
|
||||||
|
await Toestand.UitloggenAsync();
|
||||||
|
Navigatie.NavigateTo("/inloggen");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ foto's en documenten
|
||||||
|
|
||||||
|
private async Task BijlagenLadenAsync()
|
||||||
|
{
|
||||||
|
if (Mijn is null) { Bijlagen = Array.Empty<Bijlage>(); return; }
|
||||||
|
|
||||||
|
var lijst = await Toestand.Client.PersoonsfotosAsync(Mijn.PersoonId);
|
||||||
|
Bijlagen = lijst.Gelukt ? lijst.Waarde! : Array.Empty<Bijlage>();
|
||||||
|
if (!lijst.Gelukt) Fout = lijst.Melding;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ToevoegenAsync(Soort soort)
|
||||||
|
{
|
||||||
|
if (Mijn is null) return;
|
||||||
|
|
||||||
|
Fout = null;
|
||||||
|
var gekozen = soort switch
|
||||||
|
{
|
||||||
|
Soort.Document => await Bestandskiezer.KiesDocumentAsync(),
|
||||||
|
Soort.Foto => await Bestandskiezer.KiesFotoAsync(),
|
||||||
|
Soort.FotoMaken => await Bestandskiezer.MaakFotoAsync(),
|
||||||
|
Soort.Video => await Bestandskiezer.KiesVideoAsync(),
|
||||||
|
_ => await Bestandskiezer.NeemVideoOpAsync()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (gekozen is null) return;
|
||||||
|
|
||||||
|
Overzetten = $"{gekozen.Naam} wordt opgeslagen…";
|
||||||
|
Percentage = 0;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
var uitkomst = await Toestand.Client.BestandVersturenAsync(
|
||||||
|
gekozen.Naam, gekozen.Soort, gekozen.Inhoud, null,
|
||||||
|
(gedaan, totaal) =>
|
||||||
|
{
|
||||||
|
Percentage = totaal == 0 ? 100 : (int)(gedaan * 100 / totaal);
|
||||||
|
InvokeAsync(StateHasChanged);
|
||||||
|
},
|
||||||
|
persoonId: Mijn.PersoonId);
|
||||||
|
|
||||||
|
Overzetten = null;
|
||||||
|
if (!uitkomst.Gelukt) Fout = uitkomst.Melding;
|
||||||
|
else await BijlagenLadenAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task BijlageWegAsync(Bijlage bijlage)
|
||||||
|
{
|
||||||
|
if (await ProbeerAsync(async () =>
|
||||||
|
{
|
||||||
|
var r = await Toestand.Client.BijlageVerwijderenAsync(bijlage.Id);
|
||||||
|
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
|
||||||
|
}))
|
||||||
|
{
|
||||||
|
await BijlagenLadenAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProfielfotoAsync(Bijlage bijlage)
|
||||||
|
{
|
||||||
|
if (await ProbeerAsync(async () =>
|
||||||
|
{
|
||||||
|
var r = await Toestand.Client.ProfielfotoAsync(bijlage.Id);
|
||||||
|
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
|
||||||
|
}))
|
||||||
|
{
|
||||||
|
await BijlagenLadenAsync();
|
||||||
|
|
||||||
|
// Het profiel nog een keer ophalen, want daar zit je kleine foto in - en die staat
|
||||||
|
// ook op andere schermen. Zonder dit zou je in de kluis je oude foto blijven zien
|
||||||
|
// tot de app opnieuw was gestart.
|
||||||
|
await Toestand.Client.EigenProfielAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ account opheffen
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Haalt eerst op wát er weggaat. "Weet je het zeker?" is een lege vraag; "je kluis met 23
|
||||||
|
/// items en 4 berichten die nog klaarstonden" is er een waar iemand over nadenkt.
|
||||||
|
/// </summary>
|
||||||
|
private async Task OpheffenBeginnenAsync()
|
||||||
|
{
|
||||||
|
await ProbeerAsync(async () =>
|
||||||
|
{
|
||||||
|
var r = await Toestand.Client.OpheffenTellingAsync();
|
||||||
|
if (!r.Gelukt) return Uitkomst.Fout(r.Melding!);
|
||||||
|
|
||||||
|
Opheffen = new Opheffing { Telling = r.Waarde! };
|
||||||
|
return Uitkomst.Ok();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OpheffenStapAsync()
|
||||||
|
{
|
||||||
|
if (Opheffen is null) return;
|
||||||
|
|
||||||
|
if (!Opheffen.CodeGevraagd)
|
||||||
|
{
|
||||||
|
await ProbeerAsync(async () =>
|
||||||
|
{
|
||||||
|
var r = await Toestand.Client.OpheffenCodeAanvragenAsync();
|
||||||
|
if (!r.Gelukt) return Uitkomst.Fout(r.Melding!);
|
||||||
|
|
||||||
|
Opheffen.CodeGevraagd = true;
|
||||||
|
return Uitkomst.Ok();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ProbeerAsync(async () =>
|
||||||
|
{
|
||||||
|
var r = await Toestand.Client.OpheffenBevestigenAsync(Opheffen.Code.Trim());
|
||||||
|
if (!r.Gelukt) return Uitkomst.Fout(r.Melding!);
|
||||||
|
|
||||||
|
// Het account bestaat niet meer, dus er valt niets meer te tonen. De client heeft
|
||||||
|
// zijn bewaarde sessie al opgeruimd; wij sturen alleen het scherm nog naar de deur.
|
||||||
|
Opheffen = null;
|
||||||
|
Navigatie.NavigateTo("/inloggen");
|
||||||
|
return Uitkomst.Ok();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Het opheffen: eerst zien wat er weggaat, daarna de code die dat bevestigt.</summary>
|
||||||
|
private class Opheffing
|
||||||
|
{
|
||||||
|
public MijnBesluit.Client.Opheffing Telling { get; set; } = new(0, 0, 0, 0, 0, 0);
|
||||||
|
public bool CodeGevraagd { get; set; }
|
||||||
|
public string Code { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Het adreswijzigingsformulier: eerst een adres, daarna de code die daarheen ging.</summary>
|
||||||
|
private class Adreswissel
|
||||||
|
{
|
||||||
|
public string Nieuw { get; set; } = "";
|
||||||
|
public string Code { get; set; } = "";
|
||||||
|
public bool CodeGevraagd { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
@page "/"
|
||||||
|
@inherits SchermBasis
|
||||||
|
@inject IBuitenlink Buitenlink
|
||||||
|
|
||||||
|
@* Het beginscherm. Geen kop: die staat in de balk erboven.
|
||||||
|
|
||||||
|
Bovenaan wat er speelt - wie jouw nalatenschap beheert, wat er aankomt - en daaronder de
|
||||||
|
tegels. Staat er niets, dan beginnen de tegels bovenaan. *@
|
||||||
|
|
||||||
|
@if (Fout is not null)
|
||||||
|
{
|
||||||
|
<div class="melding fout">
|
||||||
|
@Fout
|
||||||
|
<div style="margin-top:.7rem">
|
||||||
|
<button class="knop rustig" disabled="@Bezig" @onclick="LaadAsync">Opnieuw proberen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (Toestand.LaatsteMelding is not null)
|
||||||
|
{
|
||||||
|
<div class="melding goed">@Toestand.LaatsteMelding</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Heeft iemand een profiel voor je aangemaakt? Dan staat die vraag hier, bovenaan, tot je hem
|
||||||
|
beantwoordt. Bewust hier en niet als popup bij het aanmelden: iemand kan je ook toevoegen
|
||||||
|
nadat jij je al had aangemeld, en een popup die je wegklikt is een vraag die je nooit meer
|
||||||
|
krijgt. Zie docs/Identiteit.md. *@
|
||||||
|
@foreach (var aantekening in Aantekeningen)
|
||||||
|
{
|
||||||
|
<div class="kaart" style="margin-bottom:1rem">
|
||||||
|
<h2 style="margin-top:0">Ben jij dit?</h2>
|
||||||
|
<p style="margin-top:0">
|
||||||
|
<strong>@aantekening.Van</strong> heeft een profiel voor je aangemaakt op jouw
|
||||||
|
e-mailadres@(aantekening.Relatie is { Length: > 0 } r ? $", als {r.ToLowerInvariant()}" : "").
|
||||||
|
</p>
|
||||||
|
<p class="zacht" style="margin-top:0">
|
||||||
|
Ben jij dat, dan voegen we het bij je eigen profiel: wat hij over je heeft ingevuld
|
||||||
|
komt bij jou te staan, en vanaf dat moment bepaal jij wat er staat. Ben jij het niet,
|
||||||
|
dan blijft alles zoals het is en vragen we het niet nog eens.
|
||||||
|
</p>
|
||||||
|
<div class="knoppen">
|
||||||
|
<button class="knop vol" disabled="@Bezig"
|
||||||
|
@onclick="() => OvernemenAsync(aantekening.PersoonId)">Ja, dat ben ik</button>
|
||||||
|
<button class="knop rustig" disabled="@Bezig"
|
||||||
|
@onclick="() => AfwijzenAsync(aantekening.PersoonId)">Nee, dat ben ik niet</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (Gegevens is { Regels.Count: > 0 })
|
||||||
|
{
|
||||||
|
<div class="stapel speelt">
|
||||||
|
@foreach (var regel in Gegevens.Regels)
|
||||||
|
{
|
||||||
|
<div class="regel @(regel.IsWaarschuwing ? "let-op" : "")">
|
||||||
|
<span class="teken">@Teken(regel.Soort)</span>
|
||||||
|
<div class="groei">
|
||||||
|
<div>@regel.Tekst</div>
|
||||||
|
@if (regel.Datum is { } datum)
|
||||||
|
{
|
||||||
|
<div class="zacht">@Tonen.Datum(datum)</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (Gegevens.Regels.Any(r => r.IsWaarschuwing))
|
||||||
|
{
|
||||||
|
<button class="knop" style="margin-bottom:1rem" disabled="@Bezig" @onclick="IkBenErNogAsync">
|
||||||
|
Ik ben er nog
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@* De advertentie staat hier en niet bovenaan: "wat er speelt" gaat voor. Iemand die net leest
|
||||||
|
dat er een overlijden op zijn naam is gemeld, hoort daar geen reclame boven te zien. Zie
|
||||||
|
docs/Adverteren.md.
|
||||||
|
|
||||||
|
Een knop en geen link: hij hoort in de browser van de telefoon te openen en niet in de app.
|
||||||
|
Zonder dat zou MijnBesluit zelf een browser worden, en sta je op de site van een adverteerder
|
||||||
|
zonder weg terug naar je eigen kluis. *@
|
||||||
|
@if (Reclame is not null)
|
||||||
|
{
|
||||||
|
<button type="button" class="advertentie" style="width:100%;text-align:left"
|
||||||
|
@onclick="() => Buitenlink.OpenAsync(Reclame.Link)">
|
||||||
|
@if (Reclame.Afbeelding is { Length: > 0 } plaatje)
|
||||||
|
{
|
||||||
|
<img src="@plaatje" alt="" />
|
||||||
|
}
|
||||||
|
<span class="groei">
|
||||||
|
<span class="merkje">Advertentie</span>
|
||||||
|
<span class="tekst">@Reclame.Tekst</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="raster">
|
||||||
|
<a class="kaart klikbaar" href="/kluis">
|
||||||
|
<span class="groot">Mijn kluis <span class="aantal">@(Gegevens?.AantalKluis ?? 0)</span></span>
|
||||||
|
</a>
|
||||||
|
<a class="kaart klikbaar" href="/familie">
|
||||||
|
<span class="groot">Mijn mensen <span class="aantal">@(Gegevens?.AantalFamilie ?? 0)</span></span>
|
||||||
|
</a>
|
||||||
|
@* Twee richtingen, dus twee aantallen; die staan achter de titel in hetzelfde rondje als
|
||||||
|
de andere tegels, met hun naam erbij zodat je niet hoeft te raden welke welke is. Zo
|
||||||
|
past de tegel op één regel, even hoog als de andere drie. *@
|
||||||
|
<a class="kaart klikbaar" href="/momenten">
|
||||||
|
<span class="groot">
|
||||||
|
Momenten
|
||||||
|
<span class="aantal" title="@(Gegevens?.AantalVoorMij ?? 0) voor mij, @(Gegevens?.AantalVanMij ?? 0) van mij">
|
||||||
|
↓@(Gegevens?.AantalVoorMij ?? 0) · ↑@(Gegevens?.AantalVanMij ?? 0)
|
||||||
|
</span>
|
||||||
|
@if (Gegevens is { AantalNieuweBerichten: > 0 })
|
||||||
|
{
|
||||||
|
<span class="aantal nieuw">@Gegevens.AantalNieuweBerichten</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<a class="kaart klikbaar" href="/nalatenschap">
|
||||||
|
<span class="groot">Nalatenschap <span class="aantal">@(Gegevens?.AantalBeheerders ?? 0)</span></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Drie links onderaan. De eerste twee openen een popup: uitklappen ín het scherm kon ook, maar
|
||||||
|
dan schuift de rest weg terwijl je iets aan het lezen of instellen bent; in een popup blijf je
|
||||||
|
waar je was.
|
||||||
|
|
||||||
|
De derde ook. De tekst staat als onderdeel op schijf (Privacytekst.razor) en niet alleen op de
|
||||||
|
website: een privacyverklaring die de app uit stuurt naar een browser is er een die niemand
|
||||||
|
leest. Dat onderdeel staat twee keer op schijf - de app en de site delen geen project - en een
|
||||||
|
zelfcontrole vergelijkt ze letterlijk. *@
|
||||||
|
<p style="margin-top:1.5rem">
|
||||||
|
@if (Gegevens is not null)
|
||||||
|
{
|
||||||
|
<button type="button" class="alsLink" @onclick="() => Vooruitblik = true">Hoe ver vooruit wil je kijken?</button>
|
||||||
|
<br />
|
||||||
|
}
|
||||||
|
<button type="button" class="alsLink" @onclick="() => Uitleg = true">Wat is MijnBesluit?</button>
|
||||||
|
<br />
|
||||||
|
<button type="button" class="alsLink" @onclick="() => Privacy = true">Privacyverklaring</button>
|
||||||
|
<br />
|
||||||
|
<button type="button" class="alsLink" @onclick="() => VersiehistorieOpen = true">Versiehistorie</button>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@if (Vooruitblik)
|
||||||
|
{
|
||||||
|
<Popup Titel="Hoe ver vooruit wil je kijken?" Bevestigtekst="Sluiten" AlleenSluiten="true"
|
||||||
|
OpBevestigen="() => Vooruitblik = false" OpSluiten="() => Vooruitblik = false">
|
||||||
|
<p style="margin-top:0">
|
||||||
|
Op dit scherm zie je wat er de komende tijd voor je klaarstaat. Zet het op 0 als je
|
||||||
|
liever verrast wordt.
|
||||||
|
</p>
|
||||||
|
<div class="veld">
|
||||||
|
<label for="vooruit">Vooruit kijken</label>
|
||||||
|
<select id="vooruit" @bind="Dagen" @bind:after="VooruitblikOpslaanAsync">
|
||||||
|
<option value="0">Niets vooruit tonen</option>
|
||||||
|
<option value="7">7 dagen</option>
|
||||||
|
<option value="14">14 dagen</option>
|
||||||
|
<option value="30">30 dagen</option>
|
||||||
|
<option value="60">60 dagen</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Dezelfde tekst als op mijnbesluit.nl/privacy - letterlijk hetzelfde bestand. *@
|
||||||
|
@if (Privacy)
|
||||||
|
{
|
||||||
|
<Popup Titel="Privacy" Bevestigtekst="Sluiten" AlleenSluiten="true"
|
||||||
|
OpBevestigen="() => Privacy = false" OpSluiten="() => Privacy = false">
|
||||||
|
<Privacytekst />
|
||||||
|
</Popup>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Zelfde tekst als op de website - Versiehistorie.razor staat twee keer op schijf en een
|
||||||
|
zelfcontrole vergelijkt ze letterlijk. *@
|
||||||
|
@if (VersiehistorieOpen)
|
||||||
|
{
|
||||||
|
<Popup Titel="Versiehistorie" Bevestigtekst="Sluiten" AlleenSluiten="true"
|
||||||
|
OpBevestigen="() => VersiehistorieOpen = false" OpSluiten="() => VersiehistorieOpen = false">
|
||||||
|
<Versiehistorie />
|
||||||
|
</Popup>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Dezelfde uitleg als op de website. *@
|
||||||
|
@if (Uitleg)
|
||||||
|
{
|
||||||
|
<Popup Titel="Wat is MijnBesluit?" Bevestigtekst="Sluiten" AlleenSluiten="true"
|
||||||
|
OpBevestigen="() => Uitleg = false" OpSluiten="() => Uitleg = false">
|
||||||
|
<Productuitleg />
|
||||||
|
</Popup>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>Staat de uitleg open?</summary>
|
||||||
|
private bool Uitleg;
|
||||||
|
|
||||||
|
/// <summary>Staat de privacyverklaring open?</summary>
|
||||||
|
private bool Privacy;
|
||||||
|
|
||||||
|
/// <summary>Staat de versiehistorie open?</summary>
|
||||||
|
private bool VersiehistorieOpen;
|
||||||
|
|
||||||
|
/// <summary>Staat het schermpje voor de vooruitblik open?</summary>
|
||||||
|
private bool Vooruitblik;
|
||||||
|
|
||||||
|
/// <summary>Profielen die anderen op mijn adres hebben aangemaakt; alleen ik kan zeggen of ik dat ben.</summary>
|
||||||
|
private IReadOnlyList<Aantekening> Aantekeningen = Array.Empty<Aantekening>();
|
||||||
|
|
||||||
|
private Overzicht? Gegevens;
|
||||||
|
private int Dagen = 30;
|
||||||
|
|
||||||
|
/// <summary>De advertentie voor deze keer, of null als er geen is.</summary>
|
||||||
|
private Advertentie? Reclame;
|
||||||
|
|
||||||
|
protected override async Task LaadAsync()
|
||||||
|
{
|
||||||
|
Fout = null;
|
||||||
|
|
||||||
|
var overzicht = await Toestand.Client.OverzichtAsync();
|
||||||
|
if (!overzicht.Gelukt) { Fout = overzicht.Melding; return; }
|
||||||
|
|
||||||
|
Gegevens = overzicht.Waarde;
|
||||||
|
Dagen = Gegevens!.VooruitblikDagen;
|
||||||
|
|
||||||
|
// Apart opgevraagd en niet meegestuurd met het overzicht: het opvragen telt de vertoning
|
||||||
|
// mee, en dat hoort alleen te gebeuren als de tegel ook echt in beeld komt. Gaat het mis,
|
||||||
|
// dan staat het overzicht er gewoon - reclame is het minst belangrijke op dit scherm.
|
||||||
|
var reclame = await Toestand.Client.AdvertentieAsync();
|
||||||
|
Reclame = reclame.Gelukt ? reclame.Waarde : null;
|
||||||
|
|
||||||
|
// Ook apart, en ook zonder de rest te laten vallen als het misgaat: dit is een vraag die
|
||||||
|
// best een keer later mag komen, en het overzicht hoort er hoe dan ook te staan.
|
||||||
|
var aantekeningen = await Toestand.Client.AantekeningenOverMijAsync();
|
||||||
|
Aantekeningen = aantekeningen.Gelukt ? aantekeningen.Waarde! : Array.Empty<Aantekening>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>"Ja, dat ben ik." Daarna staat alles wat die ander over je had bij jou.</summary>
|
||||||
|
private async Task OvernemenAsync(long persoonId)
|
||||||
|
{
|
||||||
|
if (await ProbeerAsync(async () =>
|
||||||
|
{
|
||||||
|
var r = await Toestand.Client.AantekeningOvernemenAsync(persoonId);
|
||||||
|
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
|
||||||
|
}))
|
||||||
|
{
|
||||||
|
// De familie is veranderd; die staat in het geheugen van de app.
|
||||||
|
await Toestand.VerversAsync();
|
||||||
|
await LaadAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>"Nee, dat ben ik niet." De vraag komt niet terug.</summary>
|
||||||
|
private async Task AfwijzenAsync(long persoonId)
|
||||||
|
{
|
||||||
|
if (await ProbeerAsync(async () =>
|
||||||
|
{
|
||||||
|
var r = await Toestand.Client.AantekeningAfwijzenAsync(persoonId);
|
||||||
|
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
|
||||||
|
}))
|
||||||
|
{
|
||||||
|
Aantekeningen = Aantekeningen.Where(a => a.PersoonId != persoonId).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task VooruitblikOpslaanAsync()
|
||||||
|
{
|
||||||
|
if (await ProbeerAsync(async () =>
|
||||||
|
{
|
||||||
|
var r = await Toestand.Client.VooruitblikAsync(Dagen);
|
||||||
|
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
|
||||||
|
}))
|
||||||
|
{
|
||||||
|
await LaadAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task IkBenErNogAsync()
|
||||||
|
{
|
||||||
|
if (await ProbeerAsync(async () =>
|
||||||
|
{
|
||||||
|
var r = await Toestand.Client.IkBenErNogAsync();
|
||||||
|
return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!);
|
||||||
|
}))
|
||||||
|
{
|
||||||
|
await LaadAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Teken(string soort) => soort switch
|
||||||
|
{
|
||||||
|
"beheerder" => "\U0001F33F", // blad: iemand beheert jouw nalatenschap
|
||||||
|
"beheer-voor" => "\U0001F91D", // handdruk: jij beheert die van een ander
|
||||||
|
"moment" => "✉", // envelop
|
||||||
|
"overlijden" => "⚠", // waarschuwing
|
||||||
|
_ => "•"
|
||||||
|
};
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user