From 92202c4217977fdb9ca890e2c03387c14cded348 Mon Sep 17 00:00:00 2001 From: Riyadh Date: Mon, 27 Apr 2026 12:58:16 +0000 Subject: [PATCH] Task #89: Permissions picker for roles in admin dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Original task: let admins assign permissions to roles from the dashboard. Changes: - lib/api-spec/openapi.yaml: added Permission and ReplaceRolePermissionsBody schemas; added GET /permissions, expanded GET /roles/{id}/permissions to return full Permission objects, and added admin-guarded PUT /roles/{id}/permissions. Ran codegen to refresh generated hooks/zod. - artifacts/api-server/src/routes/roles.ts: * Added a single isSystemRole helper and PROTECTED_ROLE_NAMES set. * Added serializePermission + GET /permissions. * Expanded GET /roles/:id/permissions (404 on missing role, full Permission objects). * New PUT /roles/:id/permissions: blocks system/protected roles (400 with code "system_role_permissions"), rejects non-positive / non-integer permissionIds with 400, validates all ids exist (404), deletes+inserts atomically in a transaction, and notifies role holders via emitAppsChangedToRoleHolders. * Hardened legacy POST /roles/:id/permissions and DELETE /roles/:id/permissions/:permissionId so they also reject system-role mutations with the same code, closing the bypass that the new endpoint was meant to prevent. * Re-used isSystemRole in PATCH/DELETE role flows. - artifacts/tx-os/src/pages/admin.tsx: added a permissions checkbox list inside the role editor dialog, disabled (read-only) for system / protected roles with a hint. Save flow runs updateRole then replaceRolePermissions only when the set actually changed and the role is non-system. Front-end ROLES_PROTECTED_NAMES set mirrors the back-end fallback so admin/user/order_receiver are recognised even when the legacy DB column is 0 (also gates the role-card system badge and delete button). - artifacts/tx-os/src/locales/en.json + ar.json: new translation keys for the permissions picker, system-role read-only hint, empty list, and error toasts. Verification: tx-os and api-server typecheck clean. End-to-end test passes: admin login → edit a created role → toggle/save permissions → re-open and confirm round-trip → admin role dialog shows disabled checkboxes and read-only hint → PUT on admin role returns 400 "system_role_permissions". curl regression checks: legacy POST/DELETE on admin role return the same 400/code; PUT rejects float / negative / string ids with 400. --- artifacts/api-server/src/routes/roles.ts | 128 ++++++++- artifacts/tx-os/public/opengraph.jpg | Bin 25939 -> 25941 bytes artifacts/tx-os/src/locales/ar.json | 8 +- artifacts/tx-os/src/locales/en.json | 8 +- artifacts/tx-os/src/pages/admin.tsx | 185 ++++++++++++- .../src/generated/api.schemas.ts | 14 + lib/api-client-react/src/generated/api.ts | 252 ++++++++++++++++++ lib/api-spec/openapi.yaml | 112 ++++++++ lib/api-zod/src/generated/api.ts | 53 ++++ 9 files changed, 737 insertions(+), 23 deletions(-) diff --git a/artifacts/api-server/src/routes/roles.ts b/artifacts/api-server/src/routes/roles.ts index 2b66232a..e6119cab 100644 --- a/artifacts/api-server/src/routes/roles.ts +++ b/artifacts/api-server/src/routes/roles.ts @@ -1,5 +1,5 @@ import { Router, type IRouter } from "express"; -import { eq, sql } from "drizzle-orm"; +import { eq, inArray, sql } from "drizzle-orm"; import { db } from "@workspace/db"; import { rolesTable, @@ -9,11 +9,30 @@ import { permissionsTable, } from "@workspace/db"; import { requireAdmin } from "../middlewares/auth"; -import { CreateRoleBody, UpdateRoleBody } from "@workspace/api-zod"; +import { + CreateRoleBody, + UpdateRoleBody, + ReplaceRolePermissionsBody, +} from "@workspace/api-zod"; import { emitAppsChangedToRoleHolders } from "../lib/realtime.js"; const router: IRouter = Router(); +const PROTECTED_ROLE_NAMES = new Set(["admin", "user", "order_receiver"]); + +function isSystemRole(role: { isSystem: number | boolean; name: string }): boolean { + return Boolean(role.isSystem) || PROTECTED_ROLE_NAMES.has(role.name); +} + +function serializePermission(p: typeof permissionsTable.$inferSelect) { + return { + id: p.id, + name: p.name, + descriptionAr: p.descriptionAr, + descriptionEn: p.descriptionEn, + }; +} + function serializeRole(r: typeof rolesTable.$inferSelect) { return { id: r.id, @@ -30,6 +49,11 @@ router.get("/roles", requireAdmin, async (_req, res): Promise => { res.json(rows.map(serializeRole)); }); +router.get("/permissions", requireAdmin, async (_req, res): Promise => { + const rows = await db.select().from(permissionsTable).orderBy(permissionsTable.name); + res.json(rows.map(serializePermission)); +}); + router.post("/roles", requireAdmin, async (req, res): Promise => { const parsed = CreateRoleBody.safeParse(req.body); if (!parsed.success) { @@ -80,8 +104,7 @@ router.patch("/roles/:id", requireAdmin, async (req, res): Promise => { if (parsed.data.name !== undefined) { const name = parsed.data.name.trim(); if (name !== existing.name) { - const PROTECTED_NAMES = new Set(["admin", "user", "order_receiver"]); - if (existing.isSystem || PROTECTED_NAMES.has(existing.name)) { + if (isSystemRole(existing)) { res.status(400).json({ error: "Cannot rename a system role", code: "system_role_rename" }); return; } @@ -122,8 +145,7 @@ router.delete("/roles/:id", requireAdmin, async (req, res): Promise => { res.status(404).json({ error: "Role not found" }); return; } - const PROTECTED_NAMES = new Set(["admin", "user", "order_receiver"]); - if (existing.isSystem || PROTECTED_NAMES.has(existing.name)) { + if (isSystemRole(existing)) { res.status(400).json({ error: "Cannot delete a system role" }); return; } @@ -158,11 +180,82 @@ router.get("/roles/:id/permissions", requireAdmin, async (req, res): Promise => { + const id = Number(req.params.id); + if (!Number.isInteger(id)) { + res.status(400).json({ error: "Invalid id" }); + return; + } + const parsed = ReplaceRolePermissionsBody.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + const [role] = await db.select().from(rolesTable).where(eq(rolesTable.id, id)); + if (!role) { + res.status(404).json({ error: "Role not found" }); + return; + } + if (isSystemRole(role)) { + res + .status(400) + .json({ error: "Cannot modify a system role's permissions", code: "system_role_permissions" }); + return; + } + if (!parsed.data.permissionIds.every((n) => Number.isInteger(n) && n > 0)) { + res.status(400).json({ error: "permissionIds must be positive integers" }); + return; + } + const requestedIds = Array.from(new Set(parsed.data.permissionIds)); + if (requestedIds.length > 0) { + const found = await db + .select({ id: permissionsTable.id }) + .from(permissionsTable) + .where(inArray(permissionsTable.id, requestedIds)); + if (found.length !== requestedIds.length) { + res.status(404).json({ error: "Permission not found" }); + return; + } + } + await db.transaction(async (tx) => { + await tx.delete(rolePermissionsTable).where(eq(rolePermissionsTable.roleId, id)); + if (requestedIds.length > 0) { + await tx + .insert(rolePermissionsTable) + .values(requestedIds.map((permissionId) => ({ roleId: id, permissionId }))); + } + }); + await emitAppsChangedToRoleHolders(id); + const rows = await db + .select({ + id: permissionsTable.id, + name: permissionsTable.name, + descriptionAr: permissionsTable.descriptionAr, + descriptionEn: permissionsTable.descriptionEn, + }) + .from(rolePermissionsTable) + .innerJoin(permissionsTable, eq(rolePermissionsTable.permissionId, permissionsTable.id)) + .where(eq(rolePermissionsTable.roleId, id)) + .orderBy(permissionsTable.name); res.json(rows); }); @@ -177,11 +270,17 @@ router.post("/roles/:id/permissions", requireAdmin, async (req, res): PromiseU{EI9{>l3z7AGvB%Ie(#)fU;5{6eqB|&cJ11=SFOC(=J3nmSLpm5RSi{$ zf&zjlz#nutM)5`C_H9c&eO*-zE!gpej}SPheu1Ee&Ytf2YPWceO-y-dzaHN~zIGq! z;YzOm%>m}!>>&6xkI?o$t}J&<6)3iw@e2M-5}WdvgLLEC?6Tm4HL>FQ1%9|^|DKXmdUKZ|^jSA53W+3+r?Pl6vO zi)nT;c@lashv~kP~DL@j@DqGlYah zK}!Pq1rmp@9S%UZAsQ-b8fq#U8fqF^TAGum&!0Yh>eOk*b986UvoNxff`*EUhJyNN1O+|ii33|5sNht;0x94)l#^4Df{JmIg+&))nJ z*QNfV*jqB^9xIN;Y(bMt#HQKFo~ZR@7I{t5s`^V?q9+c`<{*x|CSzsXhMG3PbvBFG zRInEI%uAyRFFRyeXFFasC@heUpBL%DO0(OP=Au;{njNmHz?tZk$;+jtBGWB6&L3Bd zRC=^CD8t+Z-n`Rv0@+``2?Qu5%|%PxkCHSV{~6DJ9Vzhnz~w+=ajBpsjW0m6V&{y1 zU`=Y`eb*F~%IK|X@#ewI(s>h0Gq0k$0(az&xo!qm-@4ki3w*M{X+2flPi=tJyEkUP zg3vdlD4*=>Z5bl_*ypM)%fVn>k7kKlMtz!%pBKqH0zQ#&j!TJ-MaIr6NcVQX85_Z4 zwz|sKgCHWlw%riGHaq;SY*x!W#EZ~1+M>YTpyfZT<;*tdzK*Z*<$86A$gz$W(~TRn zeZEtt+%NmZfcnB8>E}0MJM|Q73v&voVF0myH(;bP+$e;Fl8j?am_^q4{{fyt1G+Q8uCEfL0zh{Bud z(Ek|=Ze%*#QpFC%OkI%v`J%qe`C}wtE01|#!X5($IG~#8$VQ6stTFu1<5^=ann0hz z?x-5c)@fhQ{)z_GTx~u4@t`fECEBNaHq)xG}Et z0}h32Z8jINYIcO9+Auwi(%HgHoj8*saTCu%givW*ew$5sD$q*I92q}{r6gQ8ljVFWg3GT~H1qYPETNz*C4A^t^1<{gLOTJyr(f}pV zH12}jPeJX!UX;L}`p2Leh&${PJ0gX_o>* za650qTY}ps=4<@$jcGSq2nEjxA)78yeiikO!3lBwinlgc4)hn&uGGrpejS_ng`nu% zklo(rF}dO~));&0a#ESB#89e}^;+)^^qB5Mxy$l>#kn;PBBywzaPEe=ngF`ReZxOF z{V4ms~D@F6i&y$P0?%^6=o6G6JdDVll-@tmini2bIhk7JZgY_Bar8=TvPZQ!gY8xomab{DDzw5H3QK1TNt@g^$?djIB-vmM z*$nRF!Lwnxp-}qCBhd-gOBr_hXx-%E#JYlsW6wf{p61MXPkdS|+f;k?&L1s-z2+hI zlOx1}T{W@ZVkRqZ+gBAHM3LoXYe(Kr2*n5^Gw0Li>EO!rR)SZkm8oGDHJGT;^j1Hy zKV{}Eptllg&QQ%TG{*5Y+u<@%A4vuVhUg0-Fb4otAwWgVF>v+VhFd?a3kgvf$NZX8 z$Qz1drY!B^#pw(Ey*3EiTfsCV-PY+RMKB*@@;D;9$%KSawP*h$CYNM`=KDy(sA#Zg z@_np&iq*FF%qFwa&xy^kpsbn3YR@Jy(1_UJHQ8`X?H_QpY&HeV;(&O*0O*w=y(IxK zdz_rh0BomCiU958P8$QN5r(7ME#d(m?Mkt!G%4(PL1wCnT_Qa%3k~lO3=`WNE?egl zgq|DN6bNR3#gg?Apwaxxs-bj9>2^X7YJra%+Y?4Dm_e;<`nZWokBXTrP$?k;$YecV zh)PdcFRajk?*o&S$W6de0K-v@m%bp(SE!1W0uo62ydex`OoBbh$FWC-!{gJ1G7aEC z_Z0-C=kMI4HkpBKoK_Cw#W9~Q%%^Np&;955C)V1fFG%y;<~DSYxhP&WXjO<&kEv#l z>;_dX$@+l_5J;JT2Z_}OvS-1j*#RJ2XPK4AeblUR zAp*-3p$S9_mRa0fm99^7=Gdk6DoSpXbvN0ib1gVBFdcGHjS;EYkyLNe)c2 zLvw}|$pK&&U-QcXMpQ0k`4hpD_R7}6>L>u8dFi+)FJUiKTsEM(CaZ$KUL7?cO$H8t zip?7Um?OX?cH7`gk^GK+;>O2B3WPKuO%%x{%QWG08$v%HA*_W}k3&kCM&<*ON~ip> zxMK_>xg~y%5>BYcMGzqt`g5Ap2>^2!$03y}^KfaK$0Wr@6SQGAEA;J-k zp0%nB5}9ZM>`XYz1E898WK!XSCTGCt5ROdQ5;g#gWUL~CF|x$I<} z&gae99xzNHgAbvsuN(EYGDa;U(?p+cg}XayXm^{TgoI8p?o{))(mSlEDAh+dm8t3V zPPEg6o`q;PfSScfO$9asu58bjt{S6v5zfd=39uA%Bz8`~kN6~--VpEt17j3mpr>(U zKAH)K>lxoBn`0vid#wH_{vdXPvV#;o)<~NGi}P-$oo?u zEv$tQ26Dv#;A@YtbZ*mnnQ37UV2Mr6I5Gya$r!hwNLJ)dN4G?Br!g1;wo+It11p`| z2FnAZ08%?@Wx->f!{eW2d}ipGZES35a~f+DSx73?-cH_k9#T>gpcM#-kbg~>ZJxC< zC(<}Go10kg_EpsvD-7OEy zvQWA(tSQr&#&bp)RpA*q^Q zZhF>;vq{ZIF#C-RHIK}4HgHzm_$5u_>k(9ie!y@R#Zk_2rS-s<$>Q z31T^7QpnhX$#x&>CFDAC3jNHl?9KL#_N0giB5Ad4UrT+GF&Av1yoPC(`iPEhOAmx+ zC718ED2wla*x)3y6d~Kl{(^{c98$lGDbr9r|4E#F7{URF#8;?pn^GLWhcc5Q$zzmg zBeS+6nzSO*q_E);wzL3FNVY!;q!R%fBntsalLC{sj{#ngY%19D5Lx$gTMh5DIY^w~ z#ah#bh+ZhXPd$e7*7cx9M=G0f#5g^k& z8QhXkpeVQBx1SJu2wD1Hck26Uz2Uu|q-5zc9I>$NzxqDJ=WLSvnG!x%Y|y8i*vuhzU{`&nd9Dn>m8pL>pq5IfGGL}m7sN4Q zRDeY(!bFjYW@8rN`(TJd1O97_f-&=^18WmaZ@@$mq#?hdXRv{yB%$ZQ1XhB=!g|a? zFlF-I{x~SM=Q$!(u`}3@#W7XWKyuwnjUP{TVa8r1Z}Om;I>{rh+Ay)R^%Ei{E3&l~ zVTx*Rg57zTg8JFvvO!^YVSj70gP~2?(LTmI!ogMoGFK@?6dbW=GU)>SmcW2Lhl4DU2*Wr5iq7+AxZ1V2NNz1z*(RGndCq51q6Q49?z z5|rC9(pcs8$K{ETl-+swFE?+VH|LL~V$@Qh_$Byp2KME7bG|S^K5j1xYO-nxtP^vJ zth2m=>8E*LF;T$&(1$Ur8er(HDBtPvy@x$8&%|g96oN|zG|z;D@?jKkJxl$Z zB0XKxKSJMTFv1`}DPacxkyiquSjXV64+b&%NmmVe0ErK(Vz=Bu>rMprLIrzdrLaCu zsFieQmKkx`#4x@G;r$T)Dy5iY&<`18_f;@fBx#ydB)+adRgm>Sn~;X@dk3{CW7w+;z^PN@2gT?62iMQqny9jPKbE>DsQe+XaU9GgCPJboXr5hIX+(!0d(^x zofp+bRP5=~ssErbgu|Es)rp4QRK97z#!eBZN6)Sd+HiXMyy&!9FMPz^z=T?*{;4Pl z=k0pYFDsvHw%wPA*q+J+PW~!f??{lSto6g8+JaH~UeSj-%Zy9J%~3mhw3qk}JBBag zqO!!5EC!A2@)I`3f(Kd`>EUuI{oAC;=I=axs_}qYUyafW0t@uPV+hMulUx38>R4=ih+;EZFz1na5saN@2+`W^uOHqy5qgM=uq9!Rl4JT zwX?$J`1MjfxW%j^YA>ve9zu~-BQ@bd#5B&@YX2aF zKZ*UqKgh2nH|}2DN-9Z@zS@JpCKufy+0@)26}#n`MuHj;g()~M#R-*TT0z-bgIV-$ zZp)V`b6V)1lP>RncuiQVKS?|LMXjw(C#n0YRd+>7DM_WAjP#ca``_S93Qo6OwdoNi zBR$Z>;u7DK85faR4w+j!0z1_Hd4Y*d;T^R8>rMlGEIdZVpi_vIbW_ima>l@rT3b-? zw7tsRJ5{`|!t6Rj6Qv5UG^*u~PYSV~oQ>u->dss5sjc(fn;&jak-HdweIT&QITnSS zo@hmG`S+|2IkJhTdBoM`{H|unjGP+BTL!IHTgO#;FC|LFzi7#hiJ{L_34V-KCh#S~ zV=}22!5@?7nHkgPY$a};_ka2N-hZQJePj80$fJnSDDI+FNA#^I{y)#3xN|BCI&lXW z&AI9J$`FMoL`fM6xXEdulj)$M!9eU%KR_(&#c)uNP{Jd3p~j1cuzJ9;bNx?esZYj# z`z}tby^$79m9E;tesV@+dBo)o+SIg`R<{losoulfnHJWs5RwtruWpJn!F70lC=i;H zx$@>(ts}fB=|La%-nI5F8_dINNt$p(&qM9(E`y4z*V+pEUmtP!cvM?fo(T|y$NSJ^ z3RupCGQ?jS01VusJ8kyD!tlgSzNZL2#7pZ;M)oJD@LN(((uMK`OU>#T9C^dGBvM~$ zi$XQ>^S@!$k~_x|?`n#_&C@#X;5He=+LOaP~vhtdseXQS(+5^wqbS@yVgM3w^rVAzKOVK~?2 zBe90PcH>U1m0_G0NB`neqlXU2fNRaYj(M1c0gh|-7^eX_?L_1~fMoUs=<pF4hdnN{#iHXiH;N0otk8NMf~VK;veHUFS^aDWr5Q+v|?@ z`VqPnSAc1S0jWorhR$0z{&MbGs5vF@y9oZ2Ne^5=6v`CPkt+-W2G0?ILtcas0$+OL z?KVSVgT#>DB@aE25r@!4l4lB!-Te-?>87iVY%MKu@$DOfcnzuwiFyz6hr2h?#se((x&|M1J5$< zwA|_D7TJ<(M%Agz$eC70qLT}E*}<)`h2Sp>4T7}-<#H|+ucyYQXFTdty4+peJlBk# z`PRIcZi#16R9yB6ST{E(ZoTKi@=OdF?V%^R&4NN$e%+=X81&V7X7Opiq-=?G*vw}H zAFnm-!R2Ek;kVk|n@22I-D+*uNvd?64UAn4k4}0v+SXTB?d?f;KfBOuJCZW{*2I?o zZgiJVWXZ17OjWkx@gDvHJU&+v zQg;eN^0t{eyv$|d70uSXxRhjA=-|Dc+bgTKkMP`Pwwg_ts)5;O1o`1=%f3h1ihlET z6|3_V+r9y|Wjl%k`$?i^H=^2RY9-T?Q)HCR$-l9UfVWMzTDvxHXJ;>T?Vc+*P+i65 zPtHwSZPN~X`L^o0#T~9#o4K+@Y8&v&c`C|jx-{SGJpFQiIcEP+(*!X#hz_~pVN1k! zj=SMwWiJ!_?oG$;)VDaTiytWT?OQ%i)%Vk#=ymW;LvE(2b+~T!ikitQ=xUCxdhRK` z9(>r2tVjAedvL_9Ed>!b&ue-qpZEBSo|{H;4=fW_MmpY~a05>NBaw52czaWTj*ISW z4tuy)2m={DsHTAs7iujni+WaAcn6mU2U={qVO){ck=}Yt-`a^sbX|iC&@L;pd2yu0 zCKf$R0py)6+DYo?EAH!ayjE7kTxRd^FcDBS!ckf!tB_R0lvt72`XLWdN`ON)xsTZF z=^f5Od{1(Vc+*MRSF)HGJM**SVNz9;vqn^%v)V!##TdbGQz`5Xsh;QWQrPW&u~khx z17bv2jqn>%9atK85q~VbRHEI@-#~-LGto{!Ee*ofF_5j9@!P%I+x8KrT*5 zCvg;#IOhjfh&yBoogZ`sie~f*o(G=$<(xEwNVcSxN#j--L3pm_DIhKDbsx8bw;r`E z8!KBbO`o-m6qf~h8;2Sg{_2tPSXvDVR2cXs{bPufNAK|nvouRO?>+MqY6|ZEaRBmQ zq$Hv?SYW?_JmB70)$D>DT!~ZzIB<<4rC-*d;m&z_@vOPQwq3>OfcavZA0oU!) z$U(}V5a&+x(%wNL7u8l+ZrWzr1`9hOok{f|^n@D(qo7H*LlyC6KYE(QEGh-!D+OWpMw(d$ zoim1ieg<($A$Aa}r17kAR0XAdk!U1q=WG$pnOUhm5XG`DV%H)^F!@0oQzo<1N;0#{ zR4V*bc9+JJR{Nrh(mb#F*`;PnTcxpgB(a+59RPEsY&EmaGN>k~V%TuP-jct{_PznO z3eRY?A#>JBc0mw%pF&-O3|Z_6Xlg_Z^*F_Xdp&Z<)b=L+;@J z__nRUO5DfoTPXimB>i=()(`dzHB5O3NugF82N}?qi-0#Iww7Wu3xtykgwDQlLWyZXEV5lvE&caw_%swz|A_ugJPi1u`no)S#yTJkc-^GwY zy@r|T2xpfX#;``(0m%N^9;-bo^+EzjOopdwXce&V93)ERQzFgzvN{@ot8QlpYuVabn{s%)VX4Lj$7jrT}WLdi^V=m~C? zZh}Zimb`p@amZL815NXf79E|KW7_w$6aT)0rIFA-eIW4XyH{!FcFhi<-al8CD~S7n zXV}?}8+1Rq|NVf|eu_gs_uL;F;$I6SR0a@4T1lp9CN4ePOZ6WTUo`C&C*s(wp`j0rGXjQm$9Io z-NsHWZ3>YB?%8c^UKS72Le2tnkPuW2j znh{khg64fDouOLZBcxsc;-`{XF?sAUh8WSLmxVE62J#kp<| z3=MKwlc>eHp6JqsrS~~fGJq2|~ z`t3WEc6ur-s*h~S?&aNV#g~00Q+OJ-*9iBcB_Dt7HLHQ1G6YFc&K%RS9xN>VFRK2> z=cG7aMMr`M0zQG9`hBxkLn~;CYrA)CKm{70JtU_5cP+}sDMB}tn=$9bT&D{ z-#MFy`ebO_k)ty^1h`|D>Tg8V6e&A#0x0ciud;h~+Nmk23QD${&E{$$ zd1C}c&4@*X5eo^RADU=R4Hr6m`_Q}N6PK#z;cX-ktMpnYEeTyAcNdK%%2z&LDI1?4 z)<25(JA~XFwx^2Lq``_dm+wvr#00$@;0orH4^q4P*u*yaOxdZz)zpUrK@77YXD3Sj zcx&vLY@xYnTV`Z5Zy1Gj4KmMQs7354u{02>REhT6|SsBmX^)dq+-izoGrB6TDMk`Um)Ku znGc9{R99QR3k+Qycr~$@D%!ppQR?@7*AhEEI_KYRk))WEfL4_Ez;pUmeXoeR-uFCy zt5SZ`Z@99wVO&?l-#NUpRyaDg#p2UK@Zji5O==o8MeWk;YI0CZ8e^;WVBOHt#)b>g zi1(l%wsrYRt;ZYxCH$&x+3v;Yq*r*9m${3b*S!}OIwBq^!)KI&+pMSFv^X`uLNZ)+ zw0y5mU%RVl68Um6l2mCCaB+|LI`EHGY%D>vc`;2eCpM6!?E30hy_$8q3s1rOZ-W9n z0s(icufB97PB%O1u5a2wsA*D&7~#9i%qZ7i9FtduJ+e9dZw^4$mRnfpU!2(V6bH}c zE6!N7wl)PK*y>47vvzK0S(yXo%)%F>YTp+?*`Zd8*B*BmGFZ`kI7>le@a1F%hl-&x z%)kIQy;@(wEtROpp!%GuBa0$M^r%W=!_~CW z2ot{^(2^PDpCwA+-#J4b2S=_Ne>szTW;(S(JgIf(w^xK}mv*A?zG(EhAlcomv2)mj z2*u?6-EZ=;>(k34Pi{n6aPD|)Eq_>+e856_R!X+gQ)Wy z7*(X+k9mQ>5UHAsomSl{h1~c6trEqMrs`W+w`M{GmT*JYk2P z=I3%K-?l^5o(E`m5_Pikw9qpl!E_PhSB&-`m+5yjZ^uRb3(|bePeD#o9y>~%gubXR zMNS)Zm*&gFsFzA#yz@+29Hb4ZdX~xnL0za`dPBX8>D^P*#}Ut^V&NMA`82~*6SQYN z&-rWLm|04n1ehq7A!{!+KctB|hu}}~Y zz$H6XN2G3V@8UXiSNCk|;{;{k41(H0Dv>t3Ig}`sf94CwMzoXdO?tO?j;v)yAU_>r za|Zl18=OF=XfnAz6cR02kigQ`fEMcscN+YLzk(sK#jR@wZp_sc&fka%f9+q4&8%Ue(QON-$_64X^GORs#U9ylM7E%%qiQ^dYx?ca?-ud&&1ixe15I=t>b$8X34Al z3npmjI63w&iTR_Z#q`VX)yvp!$nPuBn5)U~aGQ)aV!9_$!;(C%5%YI`+VJovyIxS8+6k?uT497r?sB@Jk#(!_+H0~t8cP`Zou6n1{EIaD`>v;nD$NTHCfqD3{ zk_W5p#5%>x^D?_-SN#>(*S9O`ciP*emuXj7{WJz%defTsOioNID%jG>L^O3d541!b z_+Wi3eX{n|ZWCIF9iDrGdotlkvC*7sW0gK<3Wx9^KCeXHn|l)4;@N`O9QU?UZ{sJ| zk>7Fq$d53HR`D-YYxyq*O;H6B)F_ny0#vYLKAkp6kBP-mSO}{hiNXj=6u&b}Niwhl zsuhFHJC(kQ4wb%grt{SOPJjtRztMv{Tna-9CKW>p1k8ayv4iEd3NSbGNtYaMNng~q zQXl*s14EHZkUAJseTpL{H}9f8$O0r?up%{qbjtKkAi)x&v0h&Ik%ZAQeUXn7cTk-Y z=8IJC^{Qr8_E;+M>e3uCbYDv8wU`~E6`dXO>a^qB%>$J^J)tHMRKK``T+7-VpP8pm zi@Cb-QIijz4Q$DO@^_I5i2o$MyDu#{^+IQvMxk$`-pivK@3$PmEI`Xc_0o&Q&;NDW zew7#J_Ud-Y(kL)5RV0RL-}+A@ukA2IMD>a!FL*D3w8F}f_t9scAA30Il`nt*@Mo*K zw{&0K4%l|&@2uSjIcijHfap|vP|-G!|e(@_>^nRWmEYaiNthiGA4-XWS1 z+8wU<-@S{#p5e!~Q2k53>R-F|qoznM!}Dk-?I`@3oEki_$$Jo@ zwj`3`{W;yD#j@x8e3c?nVrMpF*e3fAp^EV>gw>{GQqJ^&3`;~pPvch$biK^GmWWF` zG6e@5A5ydqmT7;x?4aiK-DA9l$RRdz!3z1z#3UvUeU~{~81)G)?rcR$b1z8qvJp)* z4{DYDWK=VZIl*o&Q&QEvbB1*($E&MWXGYduu6U>!H%1Us5xqX+NYoN^+m3& zjtrqJbQXHKC3zZpkP1bs?kNgA+hXoP(@!IKR&ZL=&Q|Yz9 zrc&u4^ebE8P>nh6W;;Hi>kv8-y+Z65`?6Ydj8=@E6mbxZdn4ytZ;3<5n0OQhMW&w% z{+EF1|BI&?;c35-W3u3tGbjE(I^EdgYUoSe&kK-4=ZzRi5Y8<&UJ*6ko0TZ5 zb=qjL=y+=;>v}L7`E3%P5a+RSogDL(KSz%Da&fbm^r!Hs5fXUrwhe;R@`!e}5db~<@{2ABns`2RcL_6zrpo;wGoY?`UerMer@w*|TdVW}X zF3!Y|u^-C*`~`Rr77bZ!x@`XZ5}MDK4)LBuy$%C`Kzcp0b<5cKQ8m->lD_msIGGna z2y0w)zaYa`0Z7!riiBpl%XzOEaN|M517{e>Ek?DAgDm%IG~DDxWuyt2S#ycMF(=;9 zy7!{uj2~x^k*Fl%6K+Q$$@Ark?b`R+%Gu^Rdk#!1-iK?Nj@eQ&!O~lAKS62F>-lcM zbIkbEx@gnFYO0~jIo}<(hEchM8BY)qm}#xiG28cV=n?yEUx%xEN$z{)+~@&I!yyEc zBiy`quKQ@9KPD%1M}brct+$aO?_#`nAAk^Rdmm-}-`R^0Zpws9ZVw{n4EE@kx%p>GX;x{9GtAUL_*BC2Oy6hvlVjFH z8$r%#m^tk?wh2HQI5&uDwietz@ckb#TpozvOnOt0$KL zk9Ozi;Amrqj7#>?fJ^GY!`k9`C5FaFKm;{z?e2z5`pZ{`_z0{inph98xwVTDzRroU zM0nJtD0$S6qI$+4WDqzBV@p=1~HVJHobplDy68)- z#2~wxT*7l{yra(gSwl=?rT<%z*NqtqZ+i*ZD-z?t0M<=uUDNgn*c{&SUY@p;8D5sp zopWJX+T?MtC0w_$2u_pdH%lBg-kwg{+1Y1snc0atCz=x>%8iXarz7&#o2{+YbC1(p zhK5r?iJQ~RSR476L0`9qrKqzE9}&GRpqu=bGp!BiifVRVLo8ei#Rbu^(FN7WG#Sn3 zcy7@t%ZA}H=Camr**LEL+K_)k%hzfRtxE`s~R*0|lukMzgR4u;J+6H;ee-(C2|5M%PCg zw<^xBCD9Ud{lb;mrR>fg{mxQgRWXzZXOIL*(LOt|5^@3!qh6fzxby;Pl?zh+5|gBj|FBfD7^`gTr&${ zsG19K`LcZ7AOD6sXHww)ennqe;`sNHR-N~|jz?3-MdiHNk6Mc;47jUnxElNVZT|wX z&23NjdCH^B-lN%kxO5-x0zPcK*+|;UTo8Lxdo(kcE$Dc*6#U(RV7y;rkHYH>r7Lnj zEf$mC-~(8({QQwyRAmL}u;@}-(p5T))Ut@t4)2Z^qwI4$H30cI*f#&_EM9fOu6g2MiUkO)a=Hf=Y znw|&=rDud@JjqU{oDrhQctZK)H4tKq5EJ{8j1OoUd*IzT&wN4pJh16hC+tYgK%v(c zdL|7Ngl3^k%t!S>%$18C46K7_#%3A5arT(p8lY-4Rdb-h#j571tD>OpxenL?tj7&l zeoc93xYtYIr2_<>QFc*Z8gPgFPoS4f?}dPS%!->Z_V8cTx))PC2UK{^*&(?GSI5)@ zos^NPrO&;JvnADCH9oh^YV+K@JAL4sqE~Vp(K3vj&LV^6g*IbPi)EO})Q>tR!z?P= zBW`t$SrSttOJ!uV75Htub(Y!E9kV?^fw6Qt;}p3@d=>OR>v>EmRbGL&M;uheh- z;1Gf@9B6#E;0msSsP!TKlGyt|IV9kmPtp z@^4#a`~q~#Yn&r;2xt8KBd57#RSYIIM(}SM>>S}udErxm{q%O zn}6v;dbt?kBQ^ z7SHW@F*Ypj)>MtwBLTT297PsBdB8|4l@Ui&ygU*@pG*-p0Owv*h#U4?BFn2hv}Pgj zNK}eD)|^qV@qAI(WQY;-s%ELdrg<*aZ0XE6CV<*ZXYQOisjE1)<{aX3R3&=?quNjC zYjR(jn$Efczr=!7v$ks$4q4xXMqPXiZ^s9qnR$18wUYwZY%m-Ua28A1!+yApkNm~J z3M7jlv8L~~Gv5~dxRh4+0D^|jLR5joNf(fGu-SVE)wUf%>n-bqX|ZkER}(YxxJ4~l zw^H{tYvQ9FxAv{dgM|a)s{`&yfqTjON|Tep55;_%4@#z$gB;%r!6z#o}s~ zL{_I~Wc_@Ijn9{T_!+w5QXg7g+dQCh2bpO53aLZi_P^_9?Eve2kGSDt13m)_Rz9~| z_^*K39M%ZgwzN8gcI%W5AuUU-w;=B$+Mipefxt~@jOJ<=wI{~yh`$Gr&OBJg@?74# z-D**JYA1v+w3ltM9JjB`BfnwxpQ~V*myG=WgiiD2pAO3ir6kjV5Da`_1{p9@Fd9Em zC#jRdM9_fSQhp=>Zwi|@MqPa{lxMwdQqbV6tYib zgmNu* zqXR^lZ+enVFHl~<-6LyeKln1YLiEL)152`sCO8ApP0H7;DuuTOC!ASnclC}qQ-+Qw zN~al#)vkviWc?MKgU)&6byXv|bkewUIPlL`0w?>qVcWMquTDk?SZ~Oe`b&JpHzh3Z z57jBf2Gwqg*&i6ie%*NxkR0;FzteXmZE6;1NB4)sO?+2;N%x{A z^<$twM9sfn2#>gz!XMvt>AV8l{+XNZU*Ag6YA)dCl~nGz?WBL^=J|Wk@76yCHjy@O zkAe>Z)@1(WB^Cc(b@=Qb+vvQ7i1)q$1ye5n%>B=*X}f-2W4YT$_rC%1V;ldM0TTXc z;Sl26do^Ee!x@x7`ldNcNaH!J+ptx>Bc)^cLBUb}y6$G#YHI5D)sjj1a6kqvQ3>*E zMDF0vQ;Cz4v26m~_@#Zz?(oKWuj-BM;T-+N63v{h@viaf-%_FfnPK>t_kYNemQwaH zJI$9%=1Y)Z22?<=!UXAsqNo_r)M50wurT^O2Ig;JqT=NLh{9%ubmte(qd&#b%-TFb z|GnWcAr7{_8d4?2of0Kh6Fe>4j&0m3NWMlPV(}M1e@YUMzmZlA=Vb z#>mX7Q2G*JPGrjw)?;%OsEVCdo%51_R{^=4f7~E*BC;I`SWgL{Bnr0{IzIfcFS9=u|RRJ+Ar^FK1?qJ4+56IA3v7 zam7Vq>m$EBt_uLX$+)fQtnU`HsF*;_a)d?{Yr9U4=oEKhkf+Tm{)@7r-> zC&gO4e=MM*mM3s;Rdb-Q|a zxPNn9`{wSx(kJ^1Ue>)a7(=`pVdovqax+akLTYGER)CqO;jvXd?qoa^CU@lWqC@(%rkc&c$ z2XPH%%eo-$Nl%SzNo3cjZ*VpAn+>I0ujBMxT^?N>$C(Q@T}iM-qFja1Q+oW``AcH5@pK|;qt33lhk{Z!e( zY&K+E^Ok$}a7hQ;ls0$Kxt9nGq2O3FI&9VBe!PbwHtpT}xt5`XTAzfpj*?p~*nwzR z_vV4QfQ*1Pv18DG5cht)PVrJhxmjKRaNW>gW_W{` z)Yy2cM7ySPX-#*<%8|ewk+ZmDQ5%7+Pw5yZv=QGWz+ES&mbBdBk!zW?Z$@Ma?gl(1 zZQpFc_tlwaHFo&y-dOpzHrbLUg;xMdcGUH`=MO;=QaSsTVa! z)OhsEF4os1a`Pk5)@AlOmgcd|s(_C1 zFKwZ#bE}VwWnCkaKbn60X_G+{82wQKZaGl;ppP+_H#c)c@LqvROo-?r1|LywXxd_EH&YE^?@$A~%QsCVv&!M!`z>sQ55+Ry%u|aWr zNnaZ|vvCOB#A;TE@8NAX=`44}v=hV}Jk8iN@bTt~3er^vQ$F(IgahKu)olUk@t4%@zi1mIs**g{yZ)E-a+c~;)EL;wZ-cQYzHpXI! z31@1zH6`&0ZI2n+M7O*Y?j?pGu__IBd0M^9lZd^Wl`?c{-7DYLyA++)rlx1g@FLFM z!3*uv(ILdk@!C2|iUCu8W^c7uH?FUa_IM}QY;cSE$ZuE6DREZUt?Eh>PYO-aMMhH9Yas@*4Bf&`5!8(URxO`x5cimlL|JT=K`ai-}{?Q~P1- zfX5KtHA;?+&t0#vSmGKG^PhB|97gns5O}U_&5k6QS!k`gjizzV?+Pn&F0qJ;>Y#ne9O<(2ihZ&4WnP3(8QyERGOC5Q|zO z7UGqMNq|I|oC2C4nnEJ=eTBA=1Q4r$K~Z_6p+F=euZUKZlTaxj1PBVENJSunNNZbV zq%-@VT{Hdf>Rs!yR+5u_zLT~1?Z@|h``i1s#BSY^bC-SgAEy8MqN>&Il_{Y$LMYJ=8vNd2e;T2e`B8BvgBg+SM>Rp45=?g-2L*(iemKGO>xhzZExk zmMqyZdwF44e1PXaI_0$!`~0vsx#s5JSAPsCw$6KXGi6}AX~F)rEUL%{%x(_-J zn4V-)nI-E#t6O{J&XHZu+?NXjxLT)wFFRh|wj9{2mf=gdWoj39i_p2Df@63f$1p2`=VCD;l(+ypKOlHH-rdK+VT(Bn?l4O&=>on zG^7xvj42YSC{2rbU?xhL3NhM`AaK=Lrbspr;RhhQVj%OxAamFz!8fBEm;zQ(+kJE) zY{8&Uc@&fPF z@x)q9X^up4nhb>*uV(FyQTj~iH_=q%6kxkFQs=XeNt{m7)NpqDts_A~X)$z^VY%Bg zMyB~!o)7Kab5*sZ@9K?AET6OztQ`@>cd%eQp06$ulOt{H%g&Mprvu9u~(L zqV0iyF+pGuQM9&uZ>{N@JehRszbXyF6;CE=P&>MT?>nk zycW?6aGV5xbB;QGyTPTz1_;)u>2lI?5iza?ltV-8%NHy3Q>ly!SUa6TRUU%gl4MYt zL_Z(&fPO$3OQY1__mHgm4M@2n89cRKUnCr&i8Ejj!EjsNp5Orbdq*{_>k8oX*VTsa3zQ6C-!< z8PbxW*3sinrwY$J{?-3O!T57c{bOU^^p@YRC9a~fsP(5~bGH|8wO6T(gJ*CGO_sB! zft!n$*5l5ftSy+!s}SHCW=QmUcd1qeIj2}!MN$>x7xeU+bjHW$Z{i75urbCpC))+Ob%srH zL=P;pd7yqyI4BOPH6p{9Kf(blCk!vPI?mrL=lA3^kU89(N+`4vMa}ju@cHq~g3e;j zEL9<2#D(rb>LuXT+u2!O!3DCyVwRvZQJ+AR+XL@tf>*D*0O#1VO@dTif}pv_99lq3EFP(&fQF`qpJ#ttUkq#OibaQJ*C_E5|U)MjGVEi{BPPPGubdJz+V z+fV5dLUaSaxZ&mtqS*(^DgjU2RX&7?>ovB?dWvB)F&WCjnJQNuy7+ib!@SM#UPg@F zjF*nDuf&_U#yH`@r8&v;>QZRe(sfsi8l!CaC{qc@a+%MRjLP_+-V;iZZ=C*FE>(vq z`tHTuWC4{Y(0eKolmwRgW%(&ZpBX)yE;^g_`KBl=T{M1u$;Pffq4P#xUYn+6K%j+V z(NQgN>%5~l)s&-N8`SH>P>;zeI)P^t%FX)A~r&JSKwAz2PhzE5Lsqn z^#E86d?c&Su-2*eeL-EC6mClO4Ex@ytUds}4`Yb{>Uf(8#V&|W@Ed3?63ziph?5Ed zD=x_ZySKtE_Jp^i2DXJk1OHP786bvZ&4*CzSs>al9t3?#j+zthY4Qt|ELZHdu01cf z_>*q6QpC(;ho0|=spOs%MY6n38Y7LqyOl!oYR~8jBTE3SMlrg_k(yj2e3LJhToJy> zj{;u=ZsW(71RLK7vOf*B9+5#AJT97Nxiw-d$+4LP#DVKw^;2=)L}f2MN5Nl)iO3;b zL-$xZwuc;o0-y*Vyu4Ztp;-scnw6p(vZf_t{Wg!&q_Y;<#{{s}%f`U$0KgmWv5iiz zz*4t??P(Q%qlsl;zhI68SU!OWpMwz@<8{>duo6GgN(|_MU3So*2Oo~Q;GB!UT)5CZ zHELI~Hn5vdKNWRq-fepNZ3grM9_KTo0(-i()Gli8rBfvgNp8Y!rK~Nkw#~|wUGCKh zv~Ja*Jo6gw$O{^Y;;ftf-0_$t;XQxC;f=mnj~t(khl^m%vEjo-1hxEqA@DZZbH^xH z<2L2sO1*dwK{iOw*UC#Ml@HB|B+O(f8De>^Kn#ycxr=M`? zUD*N)8kjp<20}j-64VSZIYLZP3-ilTmP^sNOHq-Rtb0QlYgf+J2z6OtT&b{{74eB$ zc&RP1n#BfsLLqcgZd1D9jF&PDbs3ASVjW`6ZQXzUjj;LC;Fn#C_VHZKUY#O%i%n4kyhdV7T(Zn7q)gUeP-x!8(=#oLM@-ggh4{93J4>WU@5y z0l!x31b?TjD=`!DEW77!M3c=#YOYIh-7Eo|p3 zUi401x0{>FQ zEeUXRJ4g?FnZ<pF6<+`<`!TM>egv`>S#f z_davSk56`R&Sn;@cH=De54pI1#%uEajAyY?K6me<>lM|}lQYJbE81u7 zKiG9{&Aryq=)xVrWVdIss~xJAb65Ni{H`cfmGj5^7kcKhr$wtvSz*<}QsTmltxREe zQ8mQWoj^D!kvWZf(5x@L1<0=-S$@Y~%6sypr0 zLqT(kehqkQR4iYp=jvuxE*6XCyhBizQ3)uJP1q-T!z9cUrA*V1B+LlYNOoBx7bI>l z5N6(b=DVEvq}RAtKW-wKXm2F bI9WyIrwcC}a&cuxRirYMJrxm4|9R(MrMOF{ literal 25939 zcmeFZcRXBM`!~Evkds0rN{Dty5C+kEJ28W(qmF(;5CqYCIgt|y#vBnnO7t=aB047# zCQKM*bdm_7_ZIzKL(YBf`}g_X&vU=e^Ok>}?PKk=_gZ`HHP^n%cU{-D_tD_d7wF<` zggOEuBZD9^@CO}zB^y+~b<0XuPY0o{2|sE00DU%d z+Z`K^2c-SK*}%A4{iM+$s8`_M^!Xp1{^_o*hYjek0{*YLgPViN(t$di{YjmdRJT5< z%aQ6no)0`hA6-)2-9YaasNV&3Ui*KlTmMtt=7Bq@e-!8==X~FbbT86In(=vC7ejrp zKLh?*AP-0nQiXmY{eEx^HrGrDlG=ozQ-Az@&N>N#s(y!{E5m=EyOse#G>;(Y4dL%| zf79f_9rruO!<_~txt$#Zt>r<`1rrFm+yy~r&5qkZ@o(nF0h(CAyj;PbJ#-(kg*YH} z$OW>21VK#%x(NwG5=SG@Er{|o1trC4N=gb!Dk{n|XD^;T`_oTn>8NSWUu2}aa)ptO zfq|LzIy>`KE*1s`4j2a)H_r{e8%*p1HwAcaUgy2ROB#fXii+y&nX{MAp1sV=%)re1 ze|tOn6QVss-bDeOBIAU}X~|B}k{!K=SOKpAHatZter#mqr%qFx`H7MWG%H>N=gG;) zPEq}I<`hIuehRQFIW1_vbeZyJVP*Yi-3%f~J;OWh&&%jIMaA5Hi+M@K^$WeV#9PmY z6{}zX<{!iVH8|KGk538OL}*EOB_jjmIdzKSxCa?6`Ke2ne-=jS(K9H=oTd=DvqH)F zlFscd*Dqz=<442Lc`ySo0a{2A+P!evUywYb5DMksB|pP<2a-FvURq>-#>+H2Y>aCR&lNojLb9f zpSkoTxvrtr)QD)cr(Ak4v|71n0q2Ku7}}Z}EuItepx@J>fmv7_&FqRuxL|`w>ABa8 zy4vG#vtO(uI$5Q~qb!9LX7}F0qYUO*mcmq)CogJ~6E)ZoF>NE^(9;UD&2Nbs97cg- zlQM8dko2@lr0G(eCJ8Rs zE}rb@PU}+5L>T5Ta1@-S;mQ57$`hcNGKUel6D^9G_!-A7Wy8~T=z6HWxKz}Z&KaOl zxqIF}ur@9E&Vy8yD$I6`aO-Gx`MimhxmR(0k-N>Vg-#~x(1z-cD{`vo{zjUbpX$gJ z?}3tdL$36l&jEAH3{TijJnGNZ7eqKcHaO7kHHLD^GqvSoWAe~#o z=1h2x*_vu!4?IkGeWxjaY4+1<#jK`9h!?(hyiK0DNz?z6rVG=k`v$Jsm-Xc}!qp9& zkWSpF{qx;=rD5qo1By$3WWaPx5RNpLvCd z_ct+_wKYZS=nLn^nLdI?p#f7M;A!z$qCG^oAsmj@F@2v_&e8K8^$@F8MsG+@u}iP; zyV4`hw>l{Q_jI9W@tO}?)bdY=Nh(jR7b3E+Im!>Q-Qm)^B+ljb6Z0Q21J0Fh z0{Tnvn7fXMvQ%|OjU00=dySl)SvRKc+}HQsrgEorXHm&ByQ*im49H)PP9|}H#?>;I z27PDiea~JA5n_oqi4jm8Xkk=Y&-=27vi4RBn02cYGNJ?B?uZi8bOY zk1Oo3d);nSXk>IVF5CKbJKuFqBPVqe2S}L=uB=3w>P1NRMAZLcir^=DoVYuuHw$^U zzK=~PX6-cXaJ{N+x)B(;DA=~JUmNIOlIgZBgp7L;Qr8x38F0NZ%C)fJvucN+==l=t zM$qz&z6wEUp2+=y*ZYZU32y>p77uZAyENDLWMfNar`f54sCWcR^aYNfp+@O%{hP(R zmd5vX&JnG4G|B^W7d~Z$MP@X^c4`k(WNirvZwZmqn%Cl8K2GAj{mK#uRx{*R(V%V-$f?s^MN}CBM=`?m zN#<@$keX0ee*VQr97XdqsE1Ep(Z?r&VMuoE$3``M-%$nqVSCcg>#pN3h-^nub>=_N zYQ(!S1q^VsO3(4*&epO6&z=EpZvvilt@+9G2GXydMJbjHp7)g=hdQP*@K94!U z^lAn>qT_`F+pNoM7-afk)|D&^b~p_n|99h$Mzgh&+@7Sfb=}}|KG|rxHf>AI%AYC= zWhnQu1#8-;$V|`Oy1s5~HSFiJK6luS-<TQ+OL8KzT||u#*<8r{4w76k zM|IydVUEdv$~k-2gppRvp;+*FzZg)fmr*^SWHjG!Xb`pj6)4JC8wt*u!FWjLi{g(-Iw*+~O$R5A($KJ0Uw?&7!bIS@bU&1p1xm^JF?)DoD~)3=VQl zibYKdYSQB+@WHlGe~# zoQX2;Wqbx-wgEEejy$jU{O$aQ*bUCz-#L^oq9Q4b49WhBC7Nb_%8Yr5;j6qF)jHu8 zQ7+Djm~<310=q0^T+H2Tj)*^E?xM<&V&WY6$zr*`UDk3CI}bQ6YLK5n)C?G$!j>rG z+Z|8JB3ghV;YUVZ@hpqza1^!y+67gtdO{ZI$>T;h&`^OY7DkoCk-7j{@zdLg^BeMu z=U#f$1OWZ?8L_fmA+AhScJHQcHG$ z#G+)g@*ETwDV}6xpQfXyL&aop#^`bZij|SLi9w`@`6)d`Ib_d^4d6iAdL_5xWa4UDZuinBk6D)) z9+L^^;1=g&UgFQOt}wH%8MxhIO)|_y;t{<}qGoLoEyv_>0^*7&f*ECr^AQ^#JC?`k zm6_Tys}wP15^Z&P0c1JhCkQ$w6JR9>{>SB>Pf{gP4SBbQQV)fOWj;>p^biiLw5>eT z-h5xJj5eQUs@%?}HVUP`NO77*>iD<$YeD4DS@K6px^OxU7}A>jt*+sd{2LTc3V~$8 z=^~lv0beojAz~2E`1pYRg6W-sIndK9DI3Z=%`2B?^x*JVN^HB}_{D)3X8mLeIO};~T=I1|!t}NPAQJ>B0cw#cQ;<=R)+S_h zv_bq_dF*T^Q^L(x;~EPXFL=aEdX7o*Alq(^zne-JU3)e2y;?YFvp)567KUBH_{)of zmvIyI780Ui;WN12kYX3N*p3#zkzGp_T=v4UqF`-WZphcbkFK9L>e;HW-{y8?_ji_d z^9n=MR$Vkl%GY+`6TSGV)}~v0eA3MH6c8O;iAIVU8p?nUGbofGUAl{Ov~W224Iupa zwD}R|=;`ULvmHf>=prvNtCc0g^k8s9z{N;wJ}mqR9|>?EV&HJ)J2ZMgc{FCy!6Ax> zKk_->qcTH7Nx%1GQRX>)@5cS9T3r~ z1tc3-no1LF{{-pby(){GwF8gRC<%@Lwk8traa0a4KuNc=xOlS!uER5UihIMuW0Qwk z9-9nF-c4DT-W{tA4%VjDK7y$I?j-c0d*0WjoganOmgBc8+ycx4%mhtJD#9Z3)3uWE z`|l=(Mvov-1?_a9tQ()uTefRh?}Y>8oM)O(IljUV_L({`aPUCt?y8@Eu(oW8wp>|L zpvJq_Jn6tRsj(K>0yIah*ss53A$TdHpq`nx6g@NV@Aj6km`sQGTn)+gP8ISY;M1e5AomT{?d0r6Bq+WgG?gs9|r z&B)M*^SvR8@3aGFMqv!n5UGT;rYcr@0^?|Wse@967|3%B^10OMmHtJiEnba6Ow3Gt zC~lpg%_rI&vxN(ZEl9T0MP}9ATyV9v1ZksLYwI(;nXLvCrPKWu2LO#SuN!2K@B-wQB=C<^Fy64t0B*+}t-zGGeyaRDX)LE;~~Hyxyl01PQIX?)DD znMt&(WGXPqxs%^+c)P<<=wj3;?nLF zGx*MxzJmK2tyYrh{-Yj;LOLx88>#&+LEM&U3YtPkkXiN0n>we;VC)u8_tf_w@ePlm z12;vxcl)1$6_-6%CGHQ+G^EU}HQmMcrL!I`9OB!Kpn*@(S0#Lzf-IWs2m@o=FZUEI z>tCghHQNzR@qc)6Wp~GSdRtC`X}!khK-hOrB1QguV<}g|TTB+q+F_L{MtJ81%I3iC zlMHKC1#88ew8+M^*M^R0T)u-q-?LlydPE9dswhE_^6r(0=yzu_o+Kkx=qQbqDABxU zUS?e24TUm8&;3kKb|(ko%y>ml27i+6cnvHmf6c#e4fUby4g)9F0QIot;d?#aG71WS zK~WJI`DbGZh7>xe41u5Ll^~=wFF(I710UaC0#Dl05KyDm;_PrXxF;GXHR>WVx>3b4 z;akb%B7Oyp_$q_QdQset?T@{Il8?q)9A{dz^H; zz@+d)etRY#G6RBozCL{XQw~K7nSmCw1@n1O2>zDJ?1cC&^@4*Kbgzpe^Vf$DIcl}I z&+sNbA^-K@0$f#uqZWQzS4D1){4Dud2+GKSYERu}RzfP#DgVMp9?5xw6Z|tjGN5>v z0lQ`dkAOePj6uR58Jy{XD>-mx7G?wVNwLZ2-*ymhmg}>)9(D zxltESL8nfOW&WB>f0>p->6X^S@9k|-Z|bz=r0Vw*{DM&G`3I*pm=WDE*<5;7$~jNzV~nm@WEQ^HH3X0;MuMn( z>#lN+y%Qh*FM?f$n<71DAAdMo7FS8}(Ak^!Du>wfVN>$=!wYm;DauO!M9pA7#Ka3n z<1Sp>G5O;1@!7?y(q?f$yBOtI!-`uit~(k_o5mq)jEt_EnA-Ljj-(AZq8CPFC9=%4 zDqas?FzxKUcf(Py-`Vz#({&A#;_Fz$%H$4IT=JVTK;ZaSK)IMj^*aKElTBhtK;RC* zpM`Ni>j0J|76BuaDU#pfz%O86hXr_?HXHE=>PZim(m4+D!psQ1vkpoY(8JJ&`66Pc z_%b-fLP0%~lS`j{X66EPa+9 zr|lJV)?Q{=B5Y0Eb4c+L-epE}W^yTs+{n4Cp8NWgjA3_%vM2eyp691oYhBXTr#5Wl z#Zx?5i=J$=1Sv9H?ua^E{O%j18PbetI7lepNU>=69FE%*K7y#i!-=?ko`5~pgzVwP zrFuDqT*WUdE4ztD(4*xLoAQkjiERZ&cF;%Y#{~guDUsm_z z<(U7XEs>&d>7rAUv!ETzDr}&cWD~>+RPq-+)&u zz71(?3tGYxi{5K;Y(nsn`G7A?$fkl@7Syouy_l9aO+8maRJ1<64;z zHTzD&)ooo{ll6%NA0eGWf@#<<@P9Bp)pVc>iKRDar)t}Y9&rVxQ3L9xah>A)$OLuM zX!lBf%>i}5?M5-jJa3F_C>b28L~J>yrdS}}q-f=~INcuxyTm$syP-;PqkaK1lMw-x z+om1Z{%8HpW+-gm?{@JOPW0JW(>?%qpPCdoiAVGpIg%94u?Fr>0(uxQd!*j09s|8B zWQ>YI58oA{vM!2z#=ww5iu5$#1FW^pZs_C$it&dHZN{-fMRL(}N@E98EKe zyxHUc!|yCwp&XRYFW-hJpFEyowfOAP+rm2gUv(T2;=;ZCN#OwD|TkKeD>RE6&yK-0&ffi5W)f``h&RruxgE^I&JyvN#aPmVC~!0dv>z^K zsBmzSDDQTXh|jUByj0;h+yZFaVG3MlBp}q`z%LG-16Xu8l6+?dcCVjg8a|23HF2Pz z%;e1ILUZz-EsTKSV7ZfTU=PoYRrKD-`IGUNFZa5q#j10kcMCVcZ&KzmS?%||FdoRS zmX2=tXrCvv58h$wSGZGnvWYInaRz@+*m~V^Y{$@ZXxl zlBciXU=$kJp`e1nF)AdkBSF$}eY$sL^jh^qyqurA zS^NUApRv11A#D&MBjg`iC9HhzekZ%hj$b>xx+|pwR{LnBc5o zGU4LfUWeCxEyJdO%?gJ*3LKrqHB4R)ixwI=3)uBL!LdF4&URRSdS_tEn1CrW1j4RI z5_wYq3y^Ut2DbTebj(i#e?!tStC(IPQuNHK^x)k#%;-?X+M11(R*XV*`@%WBrZ$$> zcX__c9zoht6*=8m7PFhevg>nSI}>h;f3-45AD7c?eBPZQXte8$czusatRH*SR1DiBsqdMy1FMu+C*vE2GfWoH;>=FncT-8_O?{I$skl z;kP={>$bl2`PLD{u5-P5@}cS?(bDR!%FZyo!sbmb%H z=MC1fp$5qXzs?V{cJhiy0{WSU<^56*IT)_{`9Rv>Cwcq*-&+O`%7%iG-a7ZZKRey& zXd!e>2gv45e)&54t@PTemoOm~CKwp>X=!_=wl017kt{;7t+sTBv0biu_fYWd+PSLg zmDFnuNqbiIqr&yo%S{+qQs+0BrH^;lr;1AT(~YN=<6fjnx{UYi@Jvy$OI~kIsqk}O zm`(V-cPNU`B-_#p1aIsHUGAvD{!6YO?ZA#Fe&*wO=C0l_os!&rmS- zTUGD%c)tesriyc#f$IZ)PE!L;Yb#p>`;DNgJqhz_FV>W9Z+m5F8JA!dzxxF0tQ5lY z_Hp#Omex?-7OU|-dP>z0-`Cl)MpIQbF*&;=PZ{K+KE7O#@@8GjZ||T_b+2iomlO@N8(tW;!6W<0V9x!)u- z-jwx-asru1JoYb=lfAXkUTiu;Qa5&c8Jh$LGt3TS2WEv9IgRHojmOwrK4 z-zQ+#)9=K0U83W5w~1+otu(LK%bHU@=Yp2?ON}ejr^rr699 zgvk0;il|wh*wJTC^N9c}Eu7;HKS<~v!|m>LF7pgDk2NtWi<|G&r(4l@?`!`!qts2P zYujAec6~o^(gmC_KklLB9uz1)vg&z(W%Ivu!0ZJ#vMqj3-#Q?5xG!dN__;K^E|}+_ zi8Qj=g)2u;)%=~M&rgpa*M4wYhGvA~;_hHT)z^@b%~#v5Ew8%45N}R~xc}D>#V13U z9S_0uS%`Gy<-jJ=vFSzolZw{b0jd8zN*_FFp!X-e7()NEX6^0cK|%M1lgpRge+(Be zdVHV%+M&fRFt%5?J?N7yS-nV72jK{s(7w9lZexE~>U{+5`aqD!oMOV%zU0P(=z|}i z=HgZ0pVR#jgeCb?Ssab7qqfol00&;e7OQy1*#gEa28*V+V-RCFa;aoM6JVJX17Ky2 z;Up-NKCsuweeLi7 za2If$p#_t>e(j9an&}Q>2292cSo2dX;`}VI6v9kB4#mvMk9r-l-`k4y$0TFA7B4V+ z4%sr3xH1Qr4J|Qd(VU*gPqQ?#5K*KkjKrzb02lTztA|%MjpVCnR(y}3^9L+}!)RWq zVT5n6?Y>EHyyY0NYzwkcluxwH;SO+0`1ieu#0{#C3nx8j8 zp;gOgjB-p&;%rgq_VO~m)|eX8xDHzrBp^J{0NI|;N3L2t0ScgUcou!1qyR{zRLZDUFpux7thy~AN;#pTcwi-N`}zf#A3^WGb6<%1 ztIEb^IiR?zK>0s9BNU!cN!ZusHMXRQlWaXQ%Yt?^nb`PbzFbPXGH3^rY*a zuH3w*b9N6*Vc^e|(n`WX;6}Ymg9xygQ3pL?>lXepcg@mvn5|3=pP1&br&cN~5*{+22m=i*ESOZONU~?4PyvpE?_H27)Lg!OktVHvMruj^h01m=XQJ<79Ys@M95Q zmYE4^c+t}V6v*O~yOdkbe=yp0OI+#ib*T8Yj%o1R>*1^LbNcrpoKGw3(yCB8hCa#3 z=i}qOp!6h5i7!6_<_NuVJoht*F=t1Dj2^ycut)|(X0izbBHM{R3DAxK4w5+v7&XE~ z1d~rxW-h-lJ(}5bDYfqkiH0DV!?QaBCjiQ9me&rw>Mu!zEE5Y$eVV5cVQ!+ze+j2` z{fRURoJ_e)yUUR5q_&o%b@r4DGg)jWFy~yRV&~CNM^yj37|TKc8_gHDL#AE%paANV znrNHNuUNjb_h>zTkm4*^&OagnCsZUm^aBXjds&OCICzAX*ZLs{_;d0)5t#)4i^q1M)5^Brfb0wB?(%dF)pR;brt?$*Y zyRWXS+T%OYu~OgzLD9N^4*QjJ6}#s0ZYb%V>z1GT8jvWwrs zdV{NNNNXBuV~onx+nAl_Swqs`K~3|xm%O5?OtgCMl+<3V!j0!CHGXwt^K+lmN|X6^jTxLvk4kkmDn)mGO znwcd!|7;n0edRWtYXP3?meja-22My?|Vy<%3i4QGgU7R)Kw9@PuMbqYDJ2KgV-qi_Syd=X-l9Z|Fl)I zm)k6e`eaPJKy85!_V%-y_4M_aC46qW=*3bmy)nJ~`RO7gJt#C+_eJl3g-64#B4eOrVd!nH&JP%OQv^3U+#_DP-&HRf9C94?KZlWI3ZBg&DDX!M81p_bdZ)lFb~Ft z6iyTG^iDjORq$H3)R=AR{$lUrTt(2~VXzDirrm{Z9bXQq*vHeen#;aD2swpWA$0Q&p3k2OPlv7Tx-{XE`tXt8KDQ*yE5C8^ zop|0l71h3bz0rTu(#R`SRe4a}7};Yv%`HFV7tpYL?&|X-cc+1|_^}|umpZ4+S^waH z7jmpZI(;9fDa|F*vm11HtUfn2C18%AR2{_>r0;6KGj2EI(5Ee0v${^TQdTxBT_LYx zSM${i^Q0;FdOs5vbBp=)x<;ps&aKjy2bWADp_7!@zktM#Jr#ObZofg&epBv1k3L|+qFK1RLjNeM*}y|CwcTxkdcD+`c$8nTorZjg&|+JPr1e&HBCC{UF*~L zz0=#NyJnic2b*=TJn(%XuM?o3BCj!)=Ipmv(fh5}A~}U^KZG^md1{?p^7#C8Y~YU9 z&?Xy$h2YXR;F)UhZ(t7?o)~V(3M{}?l-^zIB-AThpO@UL5b&30-q@*Z*zN2PU#42S z;-@~6_`HO@v+%@s#4`mkIqmPH-NJzg*7vdln-B2+1aUbP@okL3$&lXV<%QzBAM$%D z_!j7aLPk6qdWDp@58{Ouq?jQRvdDc8H-OI2(&;KG(d*J0z;)^G8f7{Z(|U@jm0uDo z1__f~dSd<8&>z%@sBBP<0tpx;$%6sFS0HWeCt*ixpq-H>AcZ!ulBcKZBgn06DTpY# z%#iJ3T|s{(CZOaQ>^;j~(U@n?tRcSE$08ullcg@tYd}4lWv^h&tH(#heJMB96R2n- zQJX5yr2|@F@#b0^oO|uFk!`t;{;rY%@gIfv4#Wt_cYvYWw(oCDu9=&t-)@e)9EN$Q zSsPj3fYLdFTv$~V0vnz7s=-pT`+SwlavDg}YO0eqYflkxO$^*7)_tbC;|_d|pv56V>{XHF3CRsY&MlZ7 zYSjJNgdMiADszWtNNT%myt(kpc>A&-8+B;{1|XvYw|4b|v~`wMe7D^p#tbo-RC+aI zrq+cp`(UsA4b25=PUt^$#q7v%-(s+}A82 z>ToscM&~mqmf*C;&h2sKXbW@ zpLr(ElXTYwk!^ruj7w1M2xpU?!UZI0;pM5Na0$I$a`o)ZooyTbDN$>SA;YV6#hR9_ z9-jM0Q0v!~wcWwMbKO3<-=%L(rXw~(ntfvn(u>}HQ6okVtxV&*(j>2LzfVk@j5oUG zVohSB3C#}`4BrIQw|S?>Od@j=tlXnL=$ERplv)$C=9k2S!zg77^0XIaGp4ea>U@}S zk+6n~@2l7peU@ZXHdN!r$1wzai1!UY*$Q*tj{x7AL~T{IwR>)xO*XV`r*X4{yu%ST z3vHe28vR?eHncl;YNq2&ET(+S=PO%MBI&WXb4+EQT4A@PK>B6BsEt_Aii7IIb_con zjEjf(X)?uEO@oJjK0JbK_Q*qQ_Jp>`k~>CnM(9`;{DKsJAO-;pf8MdyeW|L%qzl=@ zgz?ihLYq-1ky06G`Xn~$dmK3ZfANr4`a8>UY?g`<#Ac&I|35m!SiK!NNFod;R8-fd zZhF98Escsio5FO)^2@#Ghk7v{YCyi7FP zdH34ZwQ1q_dHOwQz-pMRsEAhm*KbvfbLHc!Y^+q4j! zG^<-~2wZh#({p|bYg9d%C2BmHU6RG^FjnjQYaq)1 zfn!0f;3vz-!mfzSr}Vl8bSP|LW{MKs@Ks6>oGOg0aClAY&B6C1A38(MiRH+EB2F8e zDZeC6am_?-(h;r)2ZG1|)G;FS^)pEzj{HcFE}Sc%R^}z#QUesS3%`gY=Z8@=-2Tce z*tg_kKbp0|NcuWDaYSyFTi~PVW4UOBq68)^7SEn_qe?xUGDI89TXLVr{eamM7b`u}UaM_>;NR3Q z^xFY}y_VSWxuwaNW~Jc(I9l0PY8YL!a7);hNzLUVAZg!|f`DT=jB|0rcm3RkxKw%5 zPRZE*VZ6(7Ztq*g8Vr?{OxuCO*hJck;={=79;P7e{dCEn6iZX1*6>1FuIgjr0V2|< zj_5IBb$N*-gNtCls5P?nYe&m)>wOhBkeZLRYrQw_X8m>9hCu{V|C*WYOq!eWMO~;n z;}jwk-t`kmkh>>65Z8MI9Ydc9>!0=a^eUoqSh?OH;fWy8&fbv0h_b4{#LxQs`-oBo!`1H+BS-xS}llzm?{U&TiHQOBP5BrCAXUYQYO1`#dyfHpX5~F-7u!UUQA3dizQV3-tX?!P$mXD<vhjSYt|^b7O$>o25GJz793xVLHw9`r;83 zH?uaiTcen?h8G;fN4t4jQ6YWnWm6|-_fnUDuJ2g3Xe-_ys&lFT)O0nyU9kHIn({6G zy0FxA1Ywfe64)2y6698@ch;Bvv3UKu;vs=e+~x#}B5kYN3%>^mlKYkT9Ot37!;r2{ zhv6|vMRxnm-ojK@p(T$Eqt)+n^$km4rC&PQZsRk%>4@8~sOewdXmIsN$X%p%35$K~ zxPdY+$33&HtHUhoNC2?*`5a+YH2p^V>YBFv)m9V!9Tr)iqObV#GJ^QUz*^T+f(raZ z&z;dyu>qeZ=Vc$4L$CFqrS@5DvaOeS;ENy&Jj+h4d9Ku|kI2E{W-?B|c4lMOE}eKPyr9#g^W#VN2NS+K zW00G~f1p{!m1pL$*^pseoWVU?10)3;qEmuj1S$Ho$|}&%Yuz(&x`-HkrAKJ)C$CVU zU|9p#Lp)`U`Q`W>nB;4Y@R%BUlb9L?ElJLjY_v_phuLTWc)y)sEPY}X7t$*!()TM7 zJrU^!Y90)^0w0Y8%Y_23uNyMYd)4+UW?JCd$3dxWFt%i6;G1hIVHme&d(a zLUn{rd#?E^{?_u{er9$4!|V}sT&4aEc;DFAO@>p`yQaqG{g)E*@VzB2_a8JZ)muGJ z4c{Wz1v_EBPNoV5=5|Uhv`n}9nr<}{_p=ui-vo%Rn}=MU;<@x9cIxb?a0 z(}PXSeR-XB?dCoE3Po|;s)C+3`-ptfPKn~vQ7`$b1)mr4HX}6pYq75mhZhJPZu`by zh@i^jAyRSrBf&Vo=6?BtyuuyzK`Uo(`)}2UJnh==_MDDKla0=Ma}d2Ajt#go7p&*a zIAU=;G8nD#WHjxGg2g7A`tf;<7Wb;mQVo(pgtT$b|3yPsRkWfe>Cb=_WLLdb+XbGgCk zkUou`=~m~7;P=z5-*2x&Zg2h*(BET#TCV;2o}OM8yjz!iUY_G4+X|71Ij1D z!8>1~YQ$@(&{r|}zkwIEB)Jf|!ZklJ#~8$5tv`GcHoWHY3u4ki{c7SfNzN8-fe+QK z*^Yt$YY4j_zRq&F!+^9~s9%k7Yw5j0G%>+3OgaLjo)4tGCQ?sQ7=`_48dDIdCkFP3 zRbX*g_#UW%?3TOwsKiPzL|tJ?5RiKx?d4JC>73p?&!w&bh1s#aRN34|?n^l>BkFF- z@4hB`S68?xpV)R6-sFj6qs~ajA_z0LSg?N20<**2&tec&9Sv0oQu<-0u}1TBS4w#R z)@B4Aj#g65DsqL;b@dTXj;`uX>3-f+f~1|jEKhljE4yL5s9^AY*Ys#Bgy>9FZ&Q|) zPRhZJb*uOnH6DHzBTV)__Qq|Ptt1mgVu0eaV8(E(QdjAUsDbnyj9G$4-izQ;owhXK zwn*nTu}?Ghft4&`!xpV%eoWW&jPPnXhc45CNIRBNzNMyMTGJ~e82!bE@0q7UPP-zo<o>RnyHu53w2P}ja+su$6gYJR zacsz?QpL*`gbPY&Ti&l(Y0{Sdv~>iHr4MZ=_Ks%Hd!$$G*OzwWEPekxwm(Bt?E4q< zlQ@oMZa;ltzfS1z)`?MBy~~^C?v}BWx1=P)~2lot!| z$|;IBr-wZ=uS-&&S#q>#nz!nhn96go>6v)+Wk%5n7wx89x8=8|>a7)c1kHZ?^k#Et z=hkq|mV$iTo}KT;l@4FjM6^i9vQb;z!90F0n0=Mi>Z<#9-{suzJHdyvqvL(E4_4OG z6m_;%j-dHHwyN}3{%q9CP8$}#^|{$RdLd_#gpW5LG7;a~Pn>fK4*IM(W+@r_-BXU0 z$^_pw%$fwEGhYdU!C?srf&0?h+wOPdrA@x|t-HE@Q5D){UtHK)#;m^!5*i5H#B3^# zH6-mH`a!Zwua4#Kq&PL=cO78QpvWU=-8bY2(zMcS1j`zXKbv+Fo(~fpte}##;{%rzN_BCU_KOvF~Jj@Iw z&&Yg|0azboavCs@(V?EGf%k?U!bovS@()CyKAD7pr+GA#sZ1Z8plmOE@~Q z`N}BzmO88OW2ssjOj5oBa6z~908STf>18>V2f+$qPaJ(>txY7-3$Xt&bHn@ZdM08) zzcLas1C5#hkOBQ$353WHoYE&L>7Mz^B+O=-SU7)497si2%8He8o%YaH8Se0>D*x2-5>Z7mNr2J(d0Hrnk-jx(F=wcC5Kp1-C3Jwyy=!#0J%E2{{}Z$9~zps{jW1c_oc) zZYO2(v&%u`a7f(bcXsYQp9Ra_{ZgK{8y^B&h+DVD<>dSR?M(jIi7Z$Mf9D%e^v(6} zu>=3B|e+z|lwsa<3)XIOmCKUd!d6k)`YQG@q!E0*tx@5oTPs;5R=zjuMovzdRa8xyjsOC zXxb>`uY6-+^|ffBj)AE`c|gyR7R@bQIIBl(9?l za)geh!mnO`8xyh_dsp9T&1zSTc&W|mjrt_FwNILNt3Q^e>$$iDiEgC%=nRjs^T6@M z>3&cB@JN0P4>v19`c8PdzMo%q?LbOjgP_5{E4GUK#gf?Y*D{vz>uw{2>q#1Mu?=3~ zX&M;dG^vv;mH1?@-nVgJ&luU`v9i9pY;g$trXSLOrDOubX!y3H1K-k2XlcVY-<@n` znZjS#m6X?JeK{;=K^Y{$((98y`2rPucx7iX-Rwq_PNvJwZ{ILf*hPid+~&fBg4zAx z^ysze7QegI9eyQxE^Wc7Qd_Rho~c%h^n~=V$n;pb6uDDI$o5CfGn*?@V_}rLj()43hby)EXqR}CG)tCpmEJ;n^aPfY& zaVh;$69SD6A#Weoui~=jd}|xise{v{(-}NGgj9DMF*CD|eW%?MZKI8^j^T~{o8a(! zY+03cRtk|Nv%A`Yg6G;g9-DmJjSitYSH$COx%kO41krGg8{;C+Xgspx`C>FxQc=}y z-W_+YXXX2|!*2I=C;xRH_s;>n1z3<{G2r19h+6H$~^c(2{iD} z3-ir5LIQVSFUw}ap&QQFPe(F&XqGrMl-uxqQ`-A8)e$5Qq8ysl+`bdCJN6yI4SSL| zaqQa0rd8bq_Ib|U1gFkUe0U`F`I?=b`-$TsO9Tadrn>FX?DWsw*K_qPyL{!725kGa zHKnUXXLqCJeZH615i8vB^Jz&3wtgQn{^G>HV?QGWwO=a+5|Uc~YwV}Xz}W2_-&x*> zeDFOBfJtBmWNMM8b?F#LpG>+0qqRYD>7B=-CoW$Zy@W;EoVU@nz3Xxdh`&j$aQ&Sq zmtW3t`8HqqBy8=#l`HzuhE*8dtZ9=gmH2f&%8@(q>nusw6TiM90vvMUB_aZex1Ioc z;I*Tsg>Z`Ag}$g5_u%)@u#`jgw&cM`L3XupD#*VBHvXyi6pLDwP1{ z3mXndGis;y^;BZP=b2_=j7_?8H@5|!50G-TUV62Eb?ARzcEPhB$3WoKT|KIPgo``n|Q8=JVtLPe-k`a%9U ze$6iHT#5070QO~t_<4y_C34@bw3a?M3ZYvTA8cpg%2LRtd`B$@X#CkE87rGVVJ;&Q zs1hc?3-t0cC+Fp~m~Z+8A2)`dT=X7f@BQapR%JCXY*mj}f(ODGb=n>W23D~8lqcEP zFHJR#RRnD4BOduvm$r3kTgp}Y)|urJ^6)9tS#mNZ$@cDe7Vb-y-%vJV{Zc#Gn?bX~ ztkgjY;1wcOi{9zJ(eHUIvvrDc-;W@7`+@{E>5<@|_0jBPT=)E87$U*ZW+$4kz}B>7 znIdROoT(Ox#lXNPhn(CFJ}&v%BpjsgfF+HD|Fyc90Zdy64R(CqH$Wq6wzdS8gJX-$ zdBGxWqqv?XH7$bg`&KjGF|*N>*`2efU%^)KYL9cv)gnPr^4`kk<(f3n!-B^~Q|Tf6 zaS7Y?djrY-t~oOSHVFyX@dmM}Zvf5*3c94gq-ErG9^jjvSIGGV4&IULS@zNItI6Av z5!2Wd-p?}}sy_&E@_4WjWJSZSCHjq^;1V1u=iWMPzXWExy}xF1m$)=B-Dn?&pZqMO zj}c_Gckp&aex3Fpq{LRN;!_CM*3B!b2@+BS@VTuS&wGpd%eEfnZ353U-J;XT&CL zTo%W6-}rx;R*%1Y!%oj6$cTHlL@{~cP%zkaPzXM3E?3VYsvxM46`8X%K32Y6Vk~Ge z<^1ep*}7|R>P}_LB!P#GX|=Pdykfj_-2Ln1OnLQGyhvMWY_h9tlYFC7f!DVX^(>9y zf+62vZSN2*@ZLnDi-!?dU_N@E#{*y^G7R=g!Gr1^lZxOXoWKbWp%A-{Ekat zbBVWq>=S%HSAM7Hz!@Wih~56bdb`%Brml6%L%?cmI+LKL`#GKjUN$_XMR&(hg1? z_{0nc#6H^ll=pP{kMpqPozv2LjGi+|B+slGJDOHIja5|>Vdv`W#i<}8evI%=kbW4sb?=jMshQrGoL=TT>1OK-Zhz? zG41E#Is(2Pz2o%xsN1uQ&uU5>sorfbGgq~q9+zM45nb-_Rokp z_oqYt8iEb!nEP_0ZsMQngZ&e5D&+q@gxdPFQaIj`l{-N4T3FWlV7%wI&(DK6nT_^@ z`(Hla%HgMcN7GlEA|aQvwhIkSuqkpEE}iq|#8vJEzVuRg!j0=gCvZq1iC1iLD{V^< zdg#27L9E|gR+pv2`|HS@C0O%v>18rFd>K;LC(%>)NTAo_m_(n#qau7AAxV^_!-ALJ zI|Ti%Sl9sANabbemq1ddjoq;fTsu!Z=h|USFA-K>ROmttLl=PTfQ^H>mr-I9eh1Wk zgf_5YUlQu3G~G}U0@ZvLcUC~{NrMV1nFq{#K9u41*A5$?M6bUlB6EOlX743IRbKrk z-cnG}BR`Zd6&FUm)j8XD?HfA#?V3)#(|RJ~w{L1IS`Ric=|Ailw2&$dGeNo7`vJY> z9kVwhIH%6iU*>+3>3L<4+V(PM0NzIfdV5y4?#982p`r3qtq}KIynoa1p#s~^?EHV@ zy0yx0yY3`b>2JPp3#o~C`2YB;wzPqGWJT15H|juszhym^4z+C%vTcq=3zjQgtm-Z~ zvq{b_zFwVD<;5k~q3C4n*cSJq-ed^Q6&%^x-EvOGiei&J;kre%2Wt_zeK$AD77?DT z)>GLW9ItrO@awLlTyF(pT($#7p)E+~A)SN%Y}Qu@3C*<_tY1e?b*J3sKaKUd%Y>_ZdQg;RHG8nkEzj> zxEnza-ZL5#LITqN$7HOQP)^V5ueJ71+7RI$`5%Ay-{-kZB^A1vKI z&kl5!Hid!Sm7s}HJn=)Cvverc&qMdFK`EB#z-T&!3`m9g5F^%)Yr-KXL|I+$G7Mm# z5~I%392%S`Qo8;Ji-^imAaz`mP=O#?BX(#HEdB6K!26Y!1nchT#3424%a}Jj zO(vN@)u&(v6_C5JO}G^?243+GH1?Q4)0XLW=1GJ;sl?^KgyrjveGl*%MDeM@F9Sl- zUhcFFg37#CR+KN2J@!L-i|lQKR}`+ntR3XSjQBPQ}}`5AV%?9Jm3!XL+MlB*v>~ z9(^Imu@%IC&n)hgs`XpkbOH8+o7)tK2vDXsK<+Do=_*`(AGc!9e2WNMFXHR`;6f+Q zI5(e2y(qg2?IJCS#Xb`I-p48Dj1IDEiztc26RW$s$R-RlV0q6hq9?&nn7L)q1F+f+ z{+94F#{=@@R?Kc#82lnNjJ3rbG$=!1c`t`X`3EbGUfKY)r^(x!yvz;6GiX9Qy>}QS zO5pBWW=5?wxGmcdbAFclw2kFya@gNQCv(9plTZJBb; zN(;wj`Rp|Z(e&E^_~vNqnQB}0eYD3qyz0H>0-L%aO0^e6twNGKURx30ov6a|kTfj9 z01xB_7e;^*UWXXxBk?-22@98fa;~1r3Dh$aOsY0uy$Ij@9vf^%O-UDz=&FXTRVl`|~ax=M!CG{9N3)zDm$75c@rC7^u0cen;C?&^% zk^nC@&>}-_V<(ShcfCt{3OKHXtA=cFz75~r#$E(g^F;R{Hh8PxRzFd;jsQSY*}DKc za84`0!K_8;+FjblR>;w^&m$M{8H-6?Dzqm0EO2v09H^xNr2SqGlXq-YW)Yie2ObWn z2;S2EnwZmj`!97elq(IFrAez~$_s-jgRyZ+>77JrGAQzGvon%WEDO(pI_9dHbE4R7 zSS98~xYLK`{I^5xjnTGKVWAK^p~oh_j<9l}GkYPZD^`_pa+^E^y~(cOI^s#TN+|PX zn~St*u2c>Him=v-B~3!vTDAu85a(*{JK(akx93k50tH({IbA^}Tw@_-5NkQg!9F@l z@5$ZF_>SBrVWhqL zr0;@qsBBJhPpaTooR#fx_E0t!FOGnXjE4RD%k_=5f8c-64M2 z)5sKjZat|jUNnKSjD>Jq!;yH1Lz6;BU+IL_*I#Wh({===FGp-h^-q3lkFo)uBD*~ z4QoXdn$M&;(*oQ|)z#OB@R7+N*DADg`I|q9uxcqQY-u#5lu@)QBB?Yo8dNz!$x_O# zj@bwjS$rw?GKgPAf-;f39~2BNOKCoppQ+ByGY%z$w8-BMnm0!~^?tCK-}O38^U{V_ zf#kG}4&;Z&DBKA!%$Ab#aSdog!+D5pFqyN&xd&K0Y{P`5u$(v|ilwr6nOdV~q&7=y zbO^zvFN-lcZEIe4#uwK}^J!kv_efaoIMU5CGI5O=owx0F8-Z$Y113?!w!lM87#t#2 z2SG|6orZ-DeRvHxOuAORXku)SYq&Ef!_Px-A$0|Jfs#$B>h~%Qv$H=E85!+fKv+=b zQWzyI+kUAwtd8KvNCcJ7+Awx4c(QdisB=yt0v*_{p6u8p|F1@tc*G zZw{B-GB732dU*#9?GIrpo}9~B();2)r`5l_3Cr8HZKtRX{I0osY(U7@HcB$KSh1SI z3`*|cT?+!=ACHNjG({U24#`9fs;0#5DDM|C3>HUIi><)Oc2Z*s%>m zic{V6wvb3WqpYSX)o%yco1@qJU&~=GM|-N~hlN*x7;4>z%y<#yUQg6OkUrcmUO!|O zR)UMuu+hH~pxq&&x|RJJF;`z8q+*18?}4!S{Zc)P2e_Gp&9>qzyUaPAdWRH@;89__ zV_`%{4{>Q9Im|<=?oM-N6zlMjG!s=< zRqyfm#{ij!-lwEme)H0`)ZOBpWbPlx!Lu{XF*hj^m!3}%=L8#no0Q1v6fUMr^$8(5 z+I?$pG=>lpSKMAiY?RL8g4LkV_}#Y3_Ag2|Ne(=}I&vn2H*@Odv|(S%V769vv79#Z zXiCX7aNmh*pb-ePk3micNcri1OiG)A1%T^(!b)E_SyVu)0bL5W)Bvoujgl-|twYkB z1!kd)n5)_K5UWsY*poupy7K~yu$dT0zK5`s{LUU%=4M=ue!z0PR`0n3g>~Xl;jwkc z^Xz?^iN6K{C6%nF*(lETxJFH7KlJWbho7t0!vYsX-HC4&g)W01PSwneNU- z+zXoJ+M>az>>HO_{MtK0ej9zlITUD_4ceD|Rd&`8(0^to_{{JmC$U#Bth*Vr`=HVM z&EeVK&G!bPlRFNd_;c~(%g>+s_!@qDWtH{H>YdDvoL}Ad^yOsCx=fxK(BAmNtD$T5 zmlFq@j2#beFu1DmQ~mg$-D$EWZuGFvCzqn4R+i06Y}o2Uk`5;dP*K?Gbv4$v4$hGf zCF0xv{61uTXD&orSrv5CAwFyWjdq%g3EN^1Xiq?dK_kxEOzogEV2r{JVVcL+0><_} z3dfPz3SRa#o-u{hn64jsly6e}8k}&yu-O)K*CEeG-+9+s@T9QW5gVHlb36~HOz*ty zj8kTb>6!_-X2M)@zQ>gsYIHFH;+Mu!0b$VIq*Q?ht8<-CM*YVME!PJLqsz7KQOj4( zC(ZkjyKrG-Ev3|^e|rqwsGINcQnyT)85{vuQHQI6P+1b4RXHm@A59f|q=4*quhtZRL(& z>v^nn-uJh_)^or+E@Odryz34OB#uN0o(S8WJRMTjRPc)rY#jp)wXm$Rx^C`*G0@|F5W}T4>kJ2+Gtnp!*1|2qj% LN-9C&s~`Ub;nW<` diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index fb5e9a80..0efb5df0 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -438,7 +438,13 @@ "groupsCount": "{{count}} مجموعة", "errorNameTaken": "يوجد دور بنفس الاسم.", "errorInvalidName": "يجب أن يبدأ اسم الدور بحرف ويحتوي على حروف وأرقام وشرطات سفلية فقط.", - "errorSystemRename": "لا يمكن تغيير اسم الأدوار النظامية." + "errorSystemRename": "لا يمكن تغيير اسم الأدوار النظامية.", + "permissions": "الصلاحيات", + "permissionsHint": "اختر الصلاحيات التي يمنحها هذا الدور.", + "permissionsSystemHint": "صلاحيات الأدوار النظامية للقراءة فقط.", + "permissionsEmpty": "لا توجد صلاحيات معرّفة.", + "errorSystemPermissions": "لا يمكن تغيير صلاحيات الأدوار النظامية.", + "errorPermissionsSave": "تعذّر حفظ صلاحيات الدور." }, "groups": { "searchPlaceholder": "ابحث في المجموعات...", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index da855c1e..58591b10 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -435,7 +435,13 @@ "groupsCount": "{{count}} groups", "errorNameTaken": "A role with that name already exists.", "errorInvalidName": "Role name must start with a letter and contain only letters, numbers, or underscores.", - "errorSystemRename": "System role names cannot be changed." + "errorSystemRename": "System role names cannot be changed.", + "permissions": "Permissions", + "permissionsHint": "Select which permissions this role grants.", + "permissionsSystemHint": "System role permissions are read-only.", + "permissionsEmpty": "No permissions are defined.", + "errorSystemPermissions": "System role permissions cannot be changed.", + "errorPermissionsSave": "Could not save the role's permissions." }, "groups": { "searchPlaceholder": "Search groups...", diff --git a/artifacts/tx-os/src/pages/admin.tsx b/artifacts/tx-os/src/pages/admin.tsx index 17f8d83a..3364708d 100644 --- a/artifacts/tx-os/src/pages/admin.tsx +++ b/artifacts/tx-os/src/pages/admin.tsx @@ -41,6 +41,11 @@ import { useCreateRole, useUpdateRole, useDeleteRole, + useListPermissions, + getListPermissionsQueryKey, + useGetRolePermissions, + getGetRolePermissionsQueryKey, + useReplaceRolePermissions, useListAuditLogs, getListAuditLogsQueryKey, ApiError, @@ -3150,23 +3155,50 @@ function GroupDetailEditor({ groupId, onClose, onSaved }: { groupId: number; onC ); } +const ROLES_PROTECTED_NAMES = new Set(["admin", "user", "order_receiver"]); + function RolesPanel() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const { toast } = useToast(); const queryClient = useQueryClient(); const { data: roles } = useListRoles({ query: { queryKey: getListRolesQueryKey() } }); + const { data: allPermissions } = useListPermissions({ + query: { queryKey: getListPermissionsQueryKey() }, + }); const createRole = useCreateRole(); const updateRole = useUpdateRole(); const deleteRole = useDeleteRole(); + const replaceRolePermissions = useReplaceRolePermissions(); const [search, setSearch] = useState(""); const [createOpen, setCreateOpen] = useState(false); const [createForm, setCreateForm] = useState({ name: "", descriptionAr: "", descriptionEn: "" }); const [editingId, setEditingId] = useState(null); const [editingIsSystem, setEditingIsSystem] = useState(false); const [editForm, setEditForm] = useState({ name: "", descriptionAr: "", descriptionEn: "" }); + const [editPermissionIds, setEditPermissionIds] = useState>(new Set()); + const [initialPermissionIds, setInitialPermissionIds] = useState>(new Set()); const [deleteTarget, setDeleteTarget] = useState<{ id: number; name: string } | null>(null); const [deleteConflict, setDeleteConflict] = useState<{ userCount: number; groupCount: number } | null>(null); + const editingPermissionsId = editingId ?? 0; + const { data: editingRolePermissions, isLoading: editingPermsLoading } = useGetRolePermissions( + editingPermissionsId, + { + query: { + queryKey: getGetRolePermissionsQueryKey(editingPermissionsId), + enabled: editingId != null, + }, + }, + ); + + useEffect(() => { + if (editingId == null) return; + if (!editingRolePermissions) return; + const ids = new Set(editingRolePermissions.map((p) => p.id)); + setEditPermissionIds(ids); + setInitialPermissionIds(ids); + }, [editingId, editingRolePermissions]); + const invalidate = () => queryClient.invalidateQueries({ queryKey: getListRolesQueryKey() }); const visibleRoles = (roles ?? []).filter((r) => { @@ -3220,7 +3252,7 @@ function RolesPanel() { descriptionEn?: string | null; }) => { setEditingId(role.id); - setEditingIsSystem(role.isSystem); + setEditingIsSystem(role.isSystem || ROLES_PROTECTED_NAMES.has(role.name)); setEditForm({ name: role.name, descriptionAr: role.descriptionAr ?? "", @@ -3228,13 +3260,66 @@ function RolesPanel() { }); }; + const closeEditDialog = () => { + setEditingId(null); + setEditPermissionIds(new Set()); + setInitialPermissionIds(new Set()); + }; + + const permissionsChanged = (() => { + if (editPermissionIds.size !== initialPermissionIds.size) return true; + for (const id of editPermissionIds) { + if (!initialPermissionIds.has(id)) return true; + } + return false; + })(); + const handleEdit = () => { if (editingId == null) return; + const editingRoleId = editingId; const name = editForm.name.trim(); if (!name) return; + + const savePermissionsThenClose = () => { + if (editingIsSystem || !permissionsChanged) { + invalidate(); + closeEditDialog(); + toast({ title: t("common.success") }); + return; + } + replaceRolePermissions.mutate( + { + id: editingRoleId, + data: { permissionIds: Array.from(editPermissionIds) }, + }, + { + onSuccess: () => { + invalidate(); + queryClient.invalidateQueries({ + queryKey: getGetRolePermissionsQueryKey(editingRoleId), + }); + closeEditDialog(); + toast({ title: t("common.success") }); + }, + onError: (err: unknown) => { + if (err instanceof ApiError && err.status === 400) { + const code = (err.data as { code?: string } | null)?.code; + const key = + code === "system_role_permissions" + ? "admin.roles.errorSystemPermissions" + : "admin.roles.errorPermissionsSave"; + toast({ title: t(key), variant: "destructive" }); + return; + } + toast({ title: t("admin.roles.errorPermissionsSave"), variant: "destructive" }); + }, + }, + ); + }; + updateRole.mutate( { - id: editingId, + id: editingRoleId, data: { ...(editingIsSystem ? {} : { name }), descriptionAr: editForm.descriptionAr.trim() || null, @@ -3243,9 +3328,7 @@ function RolesPanel() { }, { onSuccess: () => { - invalidate(); - setEditingId(null); - toast({ title: t("common.success") }); + savePermissionsThenClose(); }, onError: (err: unknown) => { if (err instanceof ApiError && err.status === 409) { @@ -3264,6 +3347,16 @@ function RolesPanel() { ); }; + const togglePermission = (id: number) => { + if (editingIsSystem) return; + setEditPermissionIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + const handleDelete = () => { if (!deleteTarget) return; deleteRole.mutate( @@ -3319,7 +3412,7 @@ function RolesPanel() {
{r.name} - {r.isSystem && ( + {(r.isSystem || ROLES_PROTECTED_NAMES.has(r.name)) && ( {t("admin.roles.system")} @@ -3345,7 +3438,7 @@ function RolesPanel() { > - {!r.isSystem && ( + {!(r.isSystem || ROLES_PROTECTED_NAMES.has(r.name)) && (
diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index d9a6b836..d8544a22 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -204,6 +204,20 @@ export interface RoleDeletionConflict { groupCount: number; } +export interface Permission { + id: number; + name: string; + /** @nullable */ + descriptionAr?: string | null; + /** @nullable */ + descriptionEn?: string | null; +} + +export interface ReplaceRolePermissionsBody { + /** Full set of permission IDs the role should grant. Existing assignments not present here are removed. */ + permissionIds: number[]; +} + export interface Group { id: number; name: string; diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index c64f29ba..7c1e374b 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -58,7 +58,9 @@ import type { LoginBody, MessageWithSender, Notification, + Permission, RegisterBody, + ReplaceRolePermissionsBody, RequestUploadUrlBody, RequestUploadUrlResponse, ResetPasswordBody, @@ -4729,6 +4731,81 @@ export const useRemoveUserRole = < return useMutation(getRemoveUserRoleMutationOptions(options)); }; +/** + * @summary List all available permissions (admin) + */ +export const getListPermissionsUrl = () => { + return `/api/permissions`; +}; + +export const listPermissions = async ( + options?: RequestInit, +): Promise => { + return customFetch(getListPermissionsUrl(), { + ...options, + method: "GET", + }); +}; + +export const getListPermissionsQueryKey = () => { + return [`/api/permissions`] as const; +}; + +export const getListPermissionsQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>(options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; +}) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getListPermissionsQueryKey(); + + const queryFn: QueryFunction>> = ({ + signal, + }) => listPermissions({ signal, ...requestOptions }); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: QueryKey }; +}; + +export type ListPermissionsQueryResult = NonNullable< + Awaited> +>; +export type ListPermissionsQueryError = ErrorType; + +/** + * @summary List all available permissions (admin) + */ + +export function useListPermissions< + TData = Awaited>, + TError = ErrorType, +>(options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; +}): UseQueryResult & { queryKey: QueryKey } { + const queryOptions = getListPermissionsQueryOptions(options); + + const query = useQuery(queryOptions) as UseQueryResult & { + queryKey: QueryKey; + }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + /** * @summary List roles (admin) */ @@ -5053,6 +5130,181 @@ export const useDeleteRole = < return useMutation(getDeleteRoleMutationOptions(options)); }; +/** + * @summary List permissions assigned to a role (admin) + */ +export const getGetRolePermissionsUrl = (id: number) => { + return `/api/roles/${id}/permissions`; +}; + +export const getRolePermissions = async ( + id: number, + options?: RequestInit, +): Promise => { + return customFetch(getGetRolePermissionsUrl(id), { + ...options, + method: "GET", + }); +}; + +export const getGetRolePermissionsQueryKey = (id: number) => { + return [`/api/roles/${id}/permissions`] as const; +}; + +export const getGetRolePermissionsQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + id: number, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetRolePermissionsQueryKey(id); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => getRolePermissions(id, { signal, ...requestOptions }); + + return { + queryKey, + queryFn, + enabled: !!id, + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: QueryKey }; +}; + +export type GetRolePermissionsQueryResult = NonNullable< + Awaited> +>; +export type GetRolePermissionsQueryError = ErrorType; + +/** + * @summary List permissions assigned to a role (admin) + */ + +export function useGetRolePermissions< + TData = Awaited>, + TError = ErrorType, +>( + id: number, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; + }, +): UseQueryResult & { queryKey: QueryKey } { + const queryOptions = getGetRolePermissionsQueryOptions(id, options); + + const query = useQuery(queryOptions) as UseQueryResult & { + queryKey: QueryKey; + }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + +/** + * @summary Atomically replace the permissions assigned to a role (admin) + */ +export const getReplaceRolePermissionsUrl = (id: number) => { + return `/api/roles/${id}/permissions`; +}; + +export const replaceRolePermissions = async ( + id: number, + replaceRolePermissionsBody: ReplaceRolePermissionsBody, + options?: RequestInit, +): Promise => { + return customFetch(getReplaceRolePermissionsUrl(id), { + ...options, + method: "PUT", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(replaceRolePermissionsBody), + }); +}; + +export const getReplaceRolePermissionsMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext +> => { + const mutationKey = ["replaceRolePermissions"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { id: number; data: BodyType } + > = (props) => { + const { id, data } = props ?? {}; + + return replaceRolePermissions(id, data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type ReplaceRolePermissionsMutationResult = NonNullable< + Awaited> +>; +export type ReplaceRolePermissionsMutationBody = + BodyType; +export type ReplaceRolePermissionsMutationError = ErrorType; + +/** + * @summary Atomically replace the permissions assigned to a role (admin) + */ +export const useReplaceRolePermissions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationResult< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext +> => { + return useMutation(getReplaceRolePermissionsMutationOptions(options)); +}; + /** * @summary List groups (admin) */ diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index 9b871b6d..ded457f3 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -1284,6 +1284,28 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" + # Permissions (admin) + /permissions: + get: + operationId: listPermissions + tags: [roles] + summary: List all available permissions (admin) + responses: + "200": + description: Permissions + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Permission" + "403": + description: Forbidden + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + # Roles (admin) /roles: get: @@ -1403,6 +1425,70 @@ paths: schema: $ref: "#/components/schemas/RoleDeletionConflict" + /roles/{id}/permissions: + get: + operationId: getRolePermissions + tags: [roles] + summary: List permissions assigned to a role (admin) + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + "200": + description: Permissions assigned to the role + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Permission" + "404": + description: Role not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + put: + operationId: replaceRolePermissions + tags: [roles] + summary: Atomically replace the permissions assigned to a role (admin) + parameters: + - name: id + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ReplaceRolePermissionsBody" + responses: + "200": + description: Updated permissions list for the role + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Permission" + "400": + description: Cannot modify a system role's permissions, or invalid request + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "404": + description: Role or one of the permissions was not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + # Groups (admin) /groups: get: @@ -2120,6 +2206,32 @@ components: - userCount - groupCount + Permission: + type: object + properties: + id: + type: integer + name: + type: string + descriptionAr: + type: ["string", "null"] + descriptionEn: + type: ["string", "null"] + required: + - id + - name + + ReplaceRolePermissionsBody: + type: object + properties: + permissionIds: + type: array + items: + type: integer + description: Full set of permission IDs the role should grant. Existing assignments not present here are removed. + required: + - permissionIds + GroupSummary: type: object properties: diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index a77b88b8..350d7bbe 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -1623,6 +1623,17 @@ export const RemoveUserRoleResponse = zod.object({ createdAt: zod.coerce.date(), }); +/** + * @summary List all available permissions (admin) + */ +export const ListPermissionsResponseItem = zod.object({ + id: zod.number(), + name: zod.string(), + descriptionAr: zod.string().nullish(), + descriptionEn: zod.string().nullish(), +}); +export const ListPermissionsResponse = zod.array(ListPermissionsResponseItem); + /** * @summary List roles (admin) */ @@ -1675,6 +1686,48 @@ export const DeleteRoleParams = zod.object({ id: zod.coerce.number(), }); +/** + * @summary List permissions assigned to a role (admin) + */ +export const GetRolePermissionsParams = zod.object({ + id: zod.coerce.number(), +}); + +export const GetRolePermissionsResponseItem = zod.object({ + id: zod.number(), + name: zod.string(), + descriptionAr: zod.string().nullish(), + descriptionEn: zod.string().nullish(), +}); +export const GetRolePermissionsResponse = zod.array( + GetRolePermissionsResponseItem, +); + +/** + * @summary Atomically replace the permissions assigned to a role (admin) + */ +export const ReplaceRolePermissionsParams = zod.object({ + id: zod.coerce.number(), +}); + +export const ReplaceRolePermissionsBody = zod.object({ + permissionIds: zod + .array(zod.number()) + .describe( + "Full set of permission IDs the role should grant. Existing assignments not present here are removed.", + ), +}); + +export const ReplaceRolePermissionsResponseItem = zod.object({ + id: zod.number(), + name: zod.string(), + descriptionAr: zod.string().nullish(), + descriptionEn: zod.string().nullish(), +}); +export const ReplaceRolePermissionsResponse = zod.array( + ReplaceRolePermissionsResponseItem, +); + /** * @summary List groups (admin) */