From 3c66deb5cc7a4387e4176d2a5bdd3f321f09a6bd Mon Sep 17 00:00:00 2001 From: BlackDex Date: Thu, 28 May 2020 10:42:36 +0200 Subject: [PATCH 1/2] Redesign of the admin interface. Main changes: - Splitted up settings and users into two separate pages. - Added verified shield when the e-mail address has been verified. - Added the amount of personal items in the database to the users overview. - Added Organizations and Diagnostics pages. - Shows if DNS resolving works. - Shows if there is a posible time drift. - Shows current versions of server and web-vault. - Optimized logo-gray.png using optipng Items which can be added later: - Amount of cipher items accessible for a user, not only his personal items. - Amount of users per Org - Version update check in the diagnostics overview. - Copy/Pasteable runtime config which has sensitive data changed or removed for support questions either on the forum or github issues. - Option to delete Orgs and all its passwords (when there are no members anymore). - Etc.... --- src/api/admin.rs | 128 ++++++++++++-- src/api/web.rs | 1 + src/config.rs | 5 +- src/db/models/cipher.rs | 8 + src/db/models/organization.rs | 4 + src/static/images/logo-gray.png | Bin 7580 -> 5901 bytes src/static/images/shield-white.png | Bin 0 -> 1905 bytes src/static/templates/admin/base.hbs | 88 +++++++++- src/static/templates/admin/diagnostics.hbs | 73 ++++++++ src/static/templates/admin/organizations.hbs | 30 ++++ .../admin/{page.hbs => settings.hbs} | 165 ------------------ src/static/templates/admin/users.hbs | 134 ++++++++++++++ 12 files changed, 453 insertions(+), 183 deletions(-) create mode 100644 src/static/images/shield-white.png create mode 100644 src/static/templates/admin/diagnostics.hbs create mode 100644 src/static/templates/admin/organizations.hbs rename src/static/templates/admin/{page.hbs => settings.hbs} (60%) create mode 100644 src/static/templates/admin/users.hbs diff --git a/src/api/admin.rs b/src/api/admin.rs index dcafaa0..8678e0d 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -23,7 +23,7 @@ pub fn routes() -> Vec { routes![ admin_login, - get_users, + get_users_json, post_admin_login, admin_page, invite_user, @@ -36,6 +36,9 @@ pub fn routes() -> Vec { delete_config, backup_db, test_smtp, + users_overview, + organizations_overview, + diagnostics, ] } @@ -118,7 +121,9 @@ fn _validate_token(token: &str) -> bool { struct AdminTemplateData { page_content: String, version: Option<&'static str>, - users: Vec, + users: Option>, + organizations: Option>, + diagnostics: Option, config: Value, can_backup: bool, logged_in: bool, @@ -126,15 +131,59 @@ struct AdminTemplateData { } impl AdminTemplateData { - fn new(users: Vec) -> Self { + fn new() -> Self { Self { - page_content: String::from("admin/page"), + page_content: String::from("admin/settings"), version: VERSION, - users, config: CONFIG.prepare_json(), can_backup: *CAN_BACKUP, logged_in: true, urlpath: CONFIG.domain_path(), + users: None, + organizations: None, + diagnostics: None, + } + } + + fn users(users: Vec) -> Self { + Self { + page_content: String::from("admin/users"), + version: VERSION, + users: Some(users), + config: CONFIG.prepare_json(), + can_backup: *CAN_BACKUP, + logged_in: true, + urlpath: CONFIG.domain_path(), + organizations: None, + diagnostics: None, + } + } + + fn organizations(organizations: Vec) -> Self { + Self { + page_content: String::from("admin/organizations"), + version: VERSION, + organizations: Some(organizations), + config: CONFIG.prepare_json(), + can_backup: *CAN_BACKUP, + logged_in: true, + urlpath: CONFIG.domain_path(), + users: None, + diagnostics: None, + } + } + + fn diagnostics(diagnostics: Value) -> Self { + Self { + page_content: String::from("admin/diagnostics"), + version: VERSION, + organizations: None, + config: CONFIG.prepare_json(), + can_backup: *CAN_BACKUP, + logged_in: true, + urlpath: CONFIG.domain_path(), + users: None, + diagnostics: Some(diagnostics), } } @@ -144,11 +193,8 @@ impl AdminTemplateData { } #[get("/", rank = 1)] -fn admin_page(_token: AdminToken, conn: DbConn) -> ApiResult> { - let users = User::get_all(&conn); - let users_json: Vec = users.iter().map(|u| u.to_json(&conn)).collect(); - - let text = AdminTemplateData::new(users_json).render()?; +fn admin_page(_token: AdminToken, _conn: DbConn) -> ApiResult> { + let text = AdminTemplateData::new().render()?; Ok(Html(text)) } @@ -195,13 +241,29 @@ fn logout(mut cookies: Cookies) -> Result { } #[get("/users")] -fn get_users(_token: AdminToken, conn: DbConn) -> JsonResult { +fn get_users_json(_token: AdminToken, conn: DbConn) -> JsonResult { let users = User::get_all(&conn); let users_json: Vec = users.iter().map(|u| u.to_json(&conn)).collect(); Ok(Json(Value::Array(users_json))) } +#[get("/users/overview")] +fn users_overview(_token: AdminToken, conn: DbConn) -> ApiResult> { + let users = User::get_all(&conn); + let users_json: Vec = users.iter() + .map(|u| { + let mut usr = u.to_json(&conn); + if let Some(ciphers) = Cipher::count_owned_by_user(&u.uuid, &conn) { + usr["cipher_count"] = json!(ciphers); + }; + usr + }).collect(); + + let text = AdminTemplateData::users(users_json).render()?; + Ok(Html(text)) +} + #[post("/users//delete")] fn delete_user(uuid: String, _token: AdminToken, conn: DbConn) -> EmptyResult { let user = match User::find_by_uuid(&uuid, &conn) { @@ -242,6 +304,50 @@ fn update_revision_users(_token: AdminToken, conn: DbConn) -> EmptyResult { User::update_all_revisions(&conn) } +#[get("/organizations/overview")] +fn organizations_overview(_token: AdminToken, conn: DbConn) -> ApiResult> { + let organizations = Organization::get_all(&conn); + let organizations_json: Vec = organizations.iter().map(|o| o.to_json()).collect(); + + let text = AdminTemplateData::organizations(organizations_json).render()?; + Ok(Html(text)) +} + +#[derive(Deserialize, Serialize, Debug)] +#[allow(non_snake_case)] +pub struct WebVaultVersion { + version: String, +} + +#[get("/diagnostics")] +fn diagnostics(_token: AdminToken, _conn: DbConn) -> ApiResult> { + use std::net::ToSocketAddrs; + use chrono::prelude::*; + use crate::util::read_file_string; + + let vault_version_path = format!("{}/{}", CONFIG.web_vault_folder(), "version.json"); + let vault_version_str = read_file_string(&vault_version_path)?; + let web_vault_version: WebVaultVersion = serde_json::from_str(&vault_version_str)?; + + let github_ips = ("github.com", 0).to_socket_addrs().map(|mut i| i.next()); + let dns_resolved = match github_ips { + Ok(Some(a)) => a.ip().to_string() , + _ => "Could not resolve domain name.".to_string(), + }; + + let dt = Utc::now(); + let server_time = dt.format("%Y-%m-%d %H:%M:%S").to_string(); + + let diagnostics_json = json!({ + "dns_resolved": dns_resolved, + "server_time": server_time, + "web_vault_version": web_vault_version.version, + }); + + let text = AdminTemplateData::diagnostics(diagnostics_json).render()?; + Ok(Html(text)) +} + #[post("/config", data = "")] fn post_config(data: Json, _token: AdminToken) -> EmptyResult { let data: ConfigBuilder = data.into_inner(); diff --git a/src/api/web.rs b/src/api/web.rs index 7f47ae7..a97a1c9 100644 --- a/src/api/web.rs +++ b/src/api/web.rs @@ -78,6 +78,7 @@ fn static_files(filename: String) -> Result, Error> { match filename.as_ref() { "mail-github.png" => Ok(Content(ContentType::PNG, include_bytes!("../static/images/mail-github.png"))), "logo-gray.png" => Ok(Content(ContentType::PNG, include_bytes!("../static/images/logo-gray.png"))), + "shield-white.png" => Ok(Content(ContentType::PNG, include_bytes!("../static/images/shield-white.png"))), "error-x.svg" => Ok(Content(ContentType::SVG, include_bytes!("../static/images/error-x.svg"))), "hibp.png" => Ok(Content(ContentType::PNG, include_bytes!("../static/images/hibp.png"))), diff --git a/src/config.rs b/src/config.rs index 9434c39..e5b46d3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -700,7 +700,10 @@ where reg!("admin/base"); reg!("admin/login"); - reg!("admin/page"); + reg!("admin/settings"); + reg!("admin/users"); + reg!("admin/organizations"); + reg!("admin/diagnostics"); // And then load user templates to overwrite the defaults // Use .hbs extension for the files diff --git a/src/db/models/cipher.rs b/src/db/models/cipher.rs index 1e717ca..94d7d1e 100644 --- a/src/db/models/cipher.rs +++ b/src/db/models/cipher.rs @@ -355,6 +355,14 @@ impl Cipher { .load::(&**conn).expect("Error loading ciphers") } + pub fn count_owned_by_user(user_uuid: &str, conn: &DbConn) -> Option { + ciphers::table + .filter(ciphers::user_uuid.eq(user_uuid)) + .count() + .first::(&**conn) + .ok() + } + pub fn find_by_org(org_uuid: &str, conn: &DbConn) -> Vec { ciphers::table .filter(ciphers::organization_uuid.eq(org_uuid)) diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index 442d196..8ce476c 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -255,6 +255,10 @@ impl Organization { .first::(&**conn) .ok() } + + pub fn get_all(conn: &DbConn) -> Vec { + organizations::table.load::(&**conn).expect("Error loading organizations") + } } impl UserOrganization { diff --git a/src/static/images/logo-gray.png b/src/static/images/logo-gray.png index b045df54c0c95f2e18ff48a75a8369565bc4142a..70658e18f91dbd54aebdd4c7998bfc9713b1e0e1 100644 GIT binary patch literal 5901 zcmV+o7xL(dP)$@YTnLal_blL^yS%s`=a zgEC|?K=Tlt&d|_K(e$5^&}r39Go7TNRUc{75Q92R8XMR|C3b*7jBpaL*N>$~(n|N7 z`#k5|b03n=cmD~o_TGEWJ>Tzqk8{2g3UT4Wg$oxhT)6bPEPim|k76fB&nMRsYa^{I z4qdUeD8&^+?qPL}sMqTBb9YUKHt;XPg-f5yzEa}2^j?syqbimJfsZsUs)$8zM`XZcWGzV0+4GIwhYkRa!IQ;` z5a!Cpg+nKB(3$CZ=!g~5r&qbMapBMj9CRjn96Dm&>gi&hs~Z;%oq$0n)$7oySVE`9 z)r|{>PACy5=)`*+It}agadqRup%XCZ4EH#6?Aj+pxWaMa&|v}{aZS7+UXYMW)?Dzv zHGd8MM)&NG^gDC}u5es9bl5=WUEzed5?zU}B-gLhN?LQJ*8e3AekZLAzdC$c51nm& z4jry;TsU+XL1*}-?SCjP$Cp#f6Rm4a# z>C`OIT<7Y>g+nKlWMk?4o0ukqT`pRa&h;I1N|wD;!oQOcG5Gr;S62=l4jpRfq;m0^ ztb@)+dmK7K*gStFHb~Ya8{vyXheL-$#{qOQxugMfhI@WRvLF~^2Oviv8kThq9S$81 z9j4Hk&ZTP6K7>xwF%rwV{wb3s%Z03AFLUT{=y2%JLuW2G)~C>EyIF!F7TcGgkZ<+W zV~azFLx)4hF?7cJ7&=aoWzx$WIvhG2I`q)lnNv7)%!Xv1Lx)4Bk7blYCwN1$#-YQZ z!=b|*I=c;_BR|WbW3)ntLx)3$Lx%x$CJdmH`7Vc!`2s@@9S$819S6|algrd%I_Tu; zen3Zv2#Q#MvQ2`o1G3Jc!=b~W;~8}Jz3MJ>bhyciWs}8x6){hq>Q)owMi`*xr2>#ubMh5>dn5vTGYimJ z0ceG%foc=ALe`~cf+vFe*VQlHex6vMm~4q(Zf|}a6VSovXeR2f2v3Ykeb(ORTa1I^t{so(w1~nR6 zUDY7{o05H76M{PI3_AFJ0jz5lHGzZ_nI3RbhAUn&{xieNA;1ZMpjPiogzQFui)PGTu7U`8+ z=af{%QYRGOjOes}owbc?)C>dtm2OxciL_|@Lmh>r(9w@D&}XGq&b8fR1@K=rClwC5 z{arQp3qZ@NtuJNOR`aL&1)cf%^ta?yub~6xS86u~XQ-1y$NDemp(7U5Je(8fipEw_ zZ2)tXLhVhl4&0Bd*^JzZ`qb%aO>5r-v2v=$a1jJ++dx~s1h^9>@g^bMjDzSWFIwnm z?o}5H#&4>%BT2A>LPz(VN@m}w71XX_Rgm%vI@yJZ@8|=`?m`E2nS<;Q&W8&Qbb!-F z927NkKS*sQ6%dy0NuwL>z;Kwr@5?lWUs2^&dz&mlpiwRqIkth*W()>(SLvXG^nh)& z2E1p5ri7fTL8xBT8v;8B!*N4(#%hygxapp=e;E`@RY-my4Aaie?2s*`1Pm{;EkR&-JaS4k=qf8PkXAW&4MOah&5BDhZLSZS>QnxzP^GLk?CvheB@sH>ConO1|y zmrQ6-ryo1wTNfvEfLAZ*X0fW%xQ?@J5OxWD(%n^%cb>%_qKM%S<)Ol)YURX2OV=C750E>&La;NbfC0( znRJF~t2E@I<%yq}54j>xKsG`O9cLhf?3@}kxHN|p;Xns^F6H|}woZ{ugXkd*eJ3T* z(7F9h_n~8*`nJc8)f^Wr=s;Enc8Xa|WTP$`_L}U9la8 z!Ct8mJBRZ9q2PXKtpHh1rMN8z*QS^2P;_6LDJ?}W;x_sWzqg5&KA`| zE(w(QE|NjVnVkdKaE_`zp>Z91=s?jC>i0+DdjbJ;@=U%{IUvw6uBRh@({1S34;cGZ z%RL005?KsF9pH3!1vJn~u0bb43ms==B%m~~!^%Z)(!%bYNN|A}iZ0-55F^X58ffUa zU6>tD`Rq0XS}g7l7<8bR3|9WB>s(dn0>fm8qW;DNZK7GlY^(eZ9Y=V4*+GZIF~YRd zD(*+FG<_cw=or^Xb{{%814f_N#4b@0FzCQaN96hE_46{0gQZ#np^*E~!O5(!a;}^f z3o-6z4Cs{E*p845T6a5aZ9De-f{s5&(BKXj>s34Eb4Bo=0|j;!g5QRXhQ00H>#);A ztE70T)5}n$PxYG{=9j9)j*%T!B1RZ})_{3f0X5(O0dk8uO+?f)} zR8>so_%gONPzWA$oUMz_kY4Jx_xUI^L)(sU>X#9fnRFE=)Xtj$f8eCO2vT zoo#MI2XmIPEA`bo&_M=xgxtB(I9LMClib+=;S4%X--&JcdtAka##5EuGp)726cy~C zGwc&|#&e09>^XF>_w+{S!qI_*4pxzLGv`V(-|G-}2Mb!cj2(Swo!?Y3;RLg&d#NAHqt)Augk z5Zb^pM8~0H5Eie}I9E6h7F60ai*4bU1E;DU4AVJWrFr=kNaoOC4xObN7e4DPbRPM& z?Bj7HggyJYg&R3^+<^{~HV>0KR~iSaNQW(rjZ-!_tD_44g}*_ zDOTmVNNU_@jwqoyZTsSZPPH$glNe6EBA@dXI=^-K=)vgsCC!Oj^0~zETQ74LI=E`v zPb-o&c1oSbxzgA}1q&szwOEiN<0>>eiY~^Kd0#00v(JQ!)|H5`R5NZbc_$q)0bk$bFkpW2lyKxSf~(`{qoY%VOV4W zImt@Fb@bgjywER8=f=^m$;af^TI6k-=&|wvqP@_8d(HNbBWpEGM8p;Zb$OtqgcnBQ`*^W>2f=7uhbV^=Fbv%Mj zWYbtOaUfHeePZOlw5w;>K}We{bZfjMzlQwfh1hpyzMQ@{dB?6yV$dV#G^`v6L9i>Y zU|E&MT}#Jd=A`2ssn{extK8roMcjL^fDUp}hnLV{=;$CPODXv6by_7kSb^l0d}(l5 zOqb}w$@b7$8d$hx;^F9uh`3hK)5*s_F5kD=jV`lFd7FJcA#A_v*=jy_44sHGD-G9% z>!?k~c*mbbAqPv)^mvs7sm|RTZ=r)YIt5nv!aAd)?rqxKQ|K%W%xzcxQeJffI!gnY z4`==${jxTiBd?BqWolTt#9QdJtR2NjDeQ|#Sz8-Up<}n_%jz6@)FWQG;N0 z^2f?=Y~SoPblUcgx~CZ^Y!(w&MR%cNU$#jsqUL?8yl1&NAKKr)n>H_@aSD>b+;y;O zC`fQp$xdAqP5*id9oRx~VsWCjv^{iS(7Jb6vp>+;pj9LQ(4o6*PE>FXoibK`*VpR?H^}baaZcL6=sb%oLgfu#NAUx5_s-0=2pNxby_6Bt9b_+3pPTsBJOz4nD>>eTjs7_ily4AIN@KWv-`do}b zyv<|iz!5h(n?v6Q9Ec#cj_WR#r|y-qX=1Cqcl%!Pw&VlR=bS@ldFasA+;2`jHS)jF zclDb|V=ttCU%YMm-r>DFgoPnDy37US*nV*5(6I|8vx*rtOF7YIMY`eVGAX!_Q@jO2 zb?pYd{elkqeQ9hpy&a2ZxggtQYwlvlQ|JI0*fO+bNV;k)6aBgq=oBxT|G->y>f^J2 zI`O^uTe9J@!}vEdGeWreJm)*hJ%dhxWKhyB?xQYKj%P`AER#Y90OU4D*QKhfaSpr~ z4KimFtalM2UF6jK*kx{HsTe0CaFEkX2avi;aMliBN9Z5utOu0G7GwWGINbkzdq&xG z=Vm3G+m_p%zB_q1^-TO&>}OHab$zQ$F7zSn=KwJ#w(H41?s1Zb3(zvZY&GuiYww zKm7HUoLDxVeSu9;fZtX92Hjrv1v>g*KWIVL0u_09Om^_xX#JH7N zAYuuIRY0MW7@QxSdT`g_rF}c4v3ukdmM%)XKKr}3jqKmI>+tmN?M$-_AlpNyy9&4H z7IIiY2WK;e1L%~TuW{CfyUDC2y}b!q+dXmyTor?_SXH0V{d=FF13P?33yRR04cX?w z$l6s`>KE#?fjBNyG9IC=r>j@FwWNaTXaZlL6WWj%T)6hYwaY_)ens|<_=`-TvvT_< z%Hidq+%*f=CI;E9&cT3A)oRFCr^)eEiWPL|u!9*h%F^S)B*H+v1yanDwGM4r*H-ck zIxXxHI>Bh*xzh&nbvLl9Mm$350gdUX^3418eQe}qme7f~qqB~WO%(5)Hqb5hBYCp= zt?{f{pP}>l&2xvMFNtSHUzz!<**{Ah73p@MUl@65_Nm#gBz`2GiM=%SvEuu@hmKKp z1t@jda=xvSyB;i|vp%bpad2Z5RWNwViu2|Cu!BXrN*1)PVryk87AWx^)@SG}4dhZYUrv2tJeS+HM~pr#ou#sG&PKnwM?A1K zy*u^!nJ0Iqmj*%s5-{+K053JfH!p*a6bZ~5hMVyLt&iUF;wxY{XM^*m)U7MQU!aBi zX3LZwYK78{M8PYETjQwU=8BqbxV^0DzwYP&<5=jqA+dPH!YyMTN(>e^-g|Z8Po&jm z(p!2b`le*R7kw4{>YY}4d^PdpJ=YXBCI;tkTDWELiVznrT)0?;mD}Oo}rs@bjCNb}&|rGc@e_@cBXet&p=%TRh};s@dd@q&Cbws6y6bWQ?Rkoe-rXmd~1 z(q#vKb#eP$SM%QE!i9??QLfMYZTiP|KeT*RC^Uch)B~w=$#aXJee$Z-&aH#7qvL;< z-okr{3l}cTq`0wo-5ob8o7(^NDdEuHANtzeLi@jNzI^HC`>rc)5TWm@&?L-$v*YF@7zyjJ}p zHjAfq-0Gk2VERNqVMfph5vKVBb^w+2LK?j)e=BRW2xS9gWj!?;`NToxnIJ}0uY8Lt zK_Qx*l+n`Z%;`(E;>FSNm&O(b*_l*W`?Ok%|M_OXkx#}nj72F+laG!pOz}UD27{=m zsO`+m%=5w#q)6-)HNeplN`Fj3X<2uvE#wMVmx%rA?gX5p|8Vo>E@=6t2 z2oMnH;&rYd_uFQljf6nNbTwb|eQV86(@FC&m9&eP3-adge01t+cOQ;@m3f%0t2`x6 zBHm~3<8#VE08@czl7F=hZ*hh<_@CJ1Xz~%j=t5#13eb^}07sOF+qBlM3u*5Zv#5}o zBmgsi+8`(Pm-OAIgYQv(^fNBFZKFdKF?T zEd0x^rw^n)E;TQIbfiK~kpuP9&@e>~EIU?`3~3q`b1Zbx$V3rwKj%BvMus)eNS!A! ztx#88JidkL0&>c_=f4v@Xnywunw$7a!FFW`MFI#D!TcJv_~=3?`n1qs(B9(7$qAcc z#1u$!0%ShXV^i&#S6Enx+i-=JK(a3FDa&5n<#68UDmmZELq59SK{5V!{H{lLn(lwz ztG4W>Ab|N2e~KZr+nS&JS4ozOE`r2{$g7*$>UV!VZJWEBs_=vhD;Z$!V1Ru@PVK|_ zLLSI8>;6s{rig)aU{#4xRw9Ox4|7nOe7U4R|yHHmxf0P8+B4B0Xr)8=jqCHKGj*>ZSr4OO_ z*RYi^Bo8kyHBvL2qu<=@AqM`hAMC+xU0li%daZDxJtXcG_;5g=q7O1qp*WY}z@n*;P&{D5Nsj@$=-yC{dochte)Hc?PI#!L zr6n?u1}{ydT?isiyYiA5&k(ItLXT9@c~@D?f42>6#qi&~cvB#8XC=c!6pIZ?6PXZk z-ce|#Cml$xL>bu&HvH*u1nDqCJwMe>G;wkN6>Xyw1s(5Uwv=w4M)&s_e%dxLvX?G0_SQgQ1nSe>(iS6u!Hb!@s_#oh-3RF5AMj zJ3$_{r!NKRkOSt#2B%89B(tQs?EUUKbRKGbxa%2_&R$=syN&!ljtK$r22qq=e@uQr zcUmG;{AC21n?E$*eZye?Mhc;)NGi0Zv5aij`yx{*3*-3gbfVE~d*2if_Ivjg{$!c3 zxkVxwfUH`VnlmvcXH^FWYuo^F(QEVx=-9hgh=4|pTu7a|Yq4MM;RDE!nvs@C8N7(& z>__X&jMDIKGIIxz>C+=}1WOSh_h zKKdAKxtNXM?ZEOK5;GM8x9XBLj&HVq9KWj-UIu4?Cx&PxZ^`)yejj%<8I~3(dUv2P z1x#^s3uoVhQq9B;Go5TzbxJ@O-6ooDMu;YT*cEzqsbnuiM-IH|y8C;W87yr~d~WdW zsw@Y*g`cpDu4Qt?bFMyWmA0-md6(ijreI5yq@LFdL3{MAqm}f_Wm$e#uF+#E_aM>= z5fDFh>A4Mequwe3enY$g4Q+=XkC`bIjM=Pn1Q&xPGt1OrgjS(3Z*S#06By%P^Ja!! z(Q2|p6*y+w@2En|M4Y` zZk=5UF_`JMp0uXdC!f{!41$?yQgXQx z-U<>~bE%>MJvL!&Fiphe5K42xBbKshO|GNEp2ViqXb(fV7yNbIC)X(w zB1o^T31B?fi>YF?C>5SOcwOOJQv< z-lXIOj;p7$Q6xYP7#MrPhN+}+oP-ryuH2+@5Y*;1UhCaSOCf}0Yk0_8@+Lhwu(D2J z>Dmx5%RN_LRvzm8sFRpVS$gla{$)jIbfcSDq?eunH**j8AI4>2li4w5! zD-gOak2D&)))*M><5Y<5vv$Uia_cDl=*b$-fQqEvY z=?}V{&sE1=e*G*;twMi)f3mS~{tYDi9W!@n><**3cX~xM zn#)Sj{!MhSCUdLxGeJi1GfUi*9jhdSutp0HgEB&4>flQEDXQnX&hlpSGJ(%|Xd~pHiC#ib>?#m2oc< zJn=;%B4=D}FRQ!m6r;KNOgbh${;2xZ{ToqT!ilxBv$roy7_^a|bMxj_i1-Q0u`TA= zj%0&(PbqiXx0bAlKNW-`el8?M#Js6$urRMB0%{vd8F!{@8n2$dyk$s;`jl7_0}-_Q zX~E_w?YxpR_retiYyV~d5Al)Sly~)pe%-4sQo!7^En24Y!i+R}roTXEj;EFRzjU?s zyoc3wCU_f88w=KyauRO$)%ZNQ*5kX|4uUnB#COrdF??KvK%RD?h?JC+0D5$Os^Oyu zIDs0ekygY)-@hq3lEPSg@~KlMX;Ut=vlMN)QusYut#TP7Q9%F*H1_+we4ltsiis9& z6p^U2o}|KV{H}4#Rp8AW)=%io6~+X2A*HcnQY{MNsd&94z%r3h@s$Uci{Ou0hjIT- z$!N^(PdQ0EWRqC$+gep4TuS_9)jKqjSHX5n2xu@aOHk!T1`iFPq^BtWV|{Hso&W;a zD!6{8?YJhoOxtHh<(9x!_d43rOIC`!Ng44gdbe=1!~I$64Tw@0Eu5DT5<`d8Kp^xi zDh+t)$b49l1XBa+DNKy-(3XGBo=YIw3ntYqJtt?!ho_L6p@a0WNuFnV(C7$w{#?fb zr8f?^@R75Wri`~RYCMU_IQnn{+11_M@&=vr)q*yejR5wD=ipQCw?+s0k91S|CKEnX znsss`ls)^z!Y$4PCOt*_A_jLt5M8}^i_r^YA~M}JjiE?tBx8u$wbn__eKaS|gl%(f zgzOOz<@~36yF3?zEOD#~7&ztyfaR-+GGqv6svB>$ZKyA2oLNZGiluW{qceDg-z`l5 zqYR1B8SM!j+Su5*vJyFU{xD_+CBy`8n}r{kIiSI-4+;{y_4V|Ysgbzs9RxrWNEwC7 z`RkIytqY%L3(2NNB*y!B)Gu2t!XsYjy|aa@N^GgI{w9F z$qAPdn`{@S+y-1Z{|+js6%rN>U{6*bZ-M~bv2619<5i(J>NXR)f_mBq9{WbE&5(;o z-*NvtDR!WEW{umj)48+XKaaqviPAiW9x56%8vD&2(y>0KCay~^e6`Ruc1Y1`? zjF|P(KgHCpC-_levoZ6uJAc@*-c(4Uv*^);z%lsBw6xcjU!XwpnSl_!??gYk`txVH zK`W6`H^~bRJyuYNDT15E%W0opGY@c@Jr^gHPfT9qYIo&&Ss$ZN|iP3+BbVlHsUB8NXy-`SUkrlw&?V!uUbN+^YE zV#_cmmOv7cP6Bk5@ROrF2L}_{PJ~-h&z8qxG@&#TJA=j7!vaGMsj;tZYLm+CPqrYaxVP z1j*Mm2Q_^6D=I3cp5{hZZQW|yT>8rjC6?`7T( z%vN5Yf)kSaV-nv}|D_=0q{KvDj^+)Ygd)sTVxSEKJjVj92tgcgUlzxZMzrh1U4=pk z3J#otSn#DvjfM4P{Z4lB5L;GNbrnVAbVR>vE$4l3>RB(bnO8(?;kDWXMdd%WXJ<3{ zlajFqYSF@j@lI>;tdhMs`_R3}kKyRE64@SF*(dz4VRl4=9T*xi#j|cCS6B%&_#tp| zDHx{soapM@INwVIeAw$5SLv;wH`%dIg^XUAz{qMfP3zf|Do3kTIaCVw$yjuzJFU8X zB(xs8bGW#>#g2V5IQrhAvi=?PJ+Xq@h!WZZmlTg3EG{nUbH?{DB~R<+&uz<*FL@0x z0Z<`eipaB=;nl~~6Kdp%!M(A0r1G?&H65C9mo^~k5QA(}7E?#(Af3aVD#Sk2L55$7 z`?j!z#N^6!T2$UxUS2-JqmtRqGGDx%r`?;W$}LB+^T}t;LnPHN;Q8~p*|oJbLL9L$ zr7cT%)L|}H#FG(;Ztu+<3L%PL`N4SV5_Xa<9I@hsa+Bg)nBFaVkiVI9!?30`4UDa5 z9+h9_@F7Ejh~(lB`PUi7JF7P_hDJudY(YanQYjdnV<=41f+NGMLslaCJ|xI5=|t;g z5Bd{k{)WkcrN$DkDy{me;IvMq-e(=aoXuo`KI3xy($BJZ;K{oYXZRauRb(cBa_A&B!JX z+FVy^^7&x|(d`SJ3+98XAN|T4;obI?H0mz^&UuT^dQGqPpC3Z_^d=|7PQ6qJk&JQE zmA+8xcekJl`E!SouqVMWGuwer08|WkXJdHDy~d)_d%L5fBhUdQwV;gQfRBTmrL(`= zg({@=i4KFN{BE``S1-mCd&>lvKYz}lGUd$*Mr9a!5$u)=p4O7fwe)(pxko<8x7~03 zGZOMRJgVPTNp7uae~u2L^(A*~0llE=Md1joAp=T|l0D^ULt?^Yru~nn8JwJ)jBg|c zD%qHeQ2sqmvbF&MSM3u{?>m;9+K%s;=;b{Z`qq67pV_}@Fl!Tcx&%~2ylIe0XUWqm{RJrO z&_a;>L!l40wW+@JND?6&GuqYQ*=N9EP?C{C#G-Uczdy;yHaJ%{?`Dcp-+M&9{C!zj z7`Glf4poD!d2>A6dJJk74QiZzkpt0@9d`c28jyj2V(F)hlLN&Lp)DkLEoJh{^t#j8 z#m(ho^{?LT07X{oCOM#~U=&kXPz^2R|xW=&xb`6R1K-PrGP zoFn!^&`H!4B2z8YKUFr{yjFlmTE5xyvQbm)tlSnJVGXbBZ!5G(Ud982(4LA^5+M{u zM&|P^As{6_x>tkbXdG9wiuG!Q!K#s#^?hg+MR41S2mk6}ipraaQ6we?wv4g7|w z22gz9am;1oqse^+wLyz6QY5obE2D5q4@f=yyf6Rr*$UI7)yk23@-OIcD8>3$j!llq zzHfk7!tu0jb_j|Zi7U@&oK1qDF2P;CNuRoBqgW%xJZP{`8SkAo^}H(4l>TWF^CPMW zsl0`@p_I0{9on6l*u(z1`udYy7T2bDjh<~q%-rvc$JC(_R+z%yx?Y~tp-pdV@&(+g zcB|)W=}Kf}>gwt?eo^O$)((!M+uyn-+Am>7DVh|NJBt=xF}$}m(eEH9Kv#p&=EJzs z8>?N(`uy%mDTpK%mx4*)!g*PF`8KHMbPxyY=e#^TS1fx+MH&67IE4SHQ#iIAFA`rx z82U}Ny1F{}^z^jUWz|YJIGr`(Ji&<=R*ct+h`N`(rQWh(!bui9{P=roXNYb*@8JmP zHVpnJhX`=kE*C2P$m%HcBr2+punKm+JqBl>wRGpnTIXA&&5QF$00sVVl5K+4DgQZ~ zmYRCHq+LiyRNv5SAaupw`s;6{F{&gAV8TTK{*?Zks@>};t^;^=NxoD_hioGC3F4!B zJOcVbF(I9~5)hC`36xB@s7zakc$kqn`DtyOhl%Q+o9y{qG^*(kKq%^M{A21S_WYK6 z3ZES#Yx-l_5at76djJ*^u&M__b*ggjZHEva2ePoS4O2(aym<7jMQ;9M$Y3g(@uq_1r@oA|U} zMO8YBFpI{`Rc-vEuBD3)oU*(CEauhojP_d&DQsMF1w~edba=)k; z+gk3d%XVn;$L@R`#uuB$r}9kIhv#ogRB<)G&QJZpkG3Rsd+Yv{`U9h>b-zflprN#K zTg1nww&?P++W~>$FIq+?jqAwtzQu!J&yI?IXTM3g8M|e=Q3T&~Gl~SkZXGYVu^7sp z8WFlUgbpujHOJ$@=0)%^4KUE0$|~|KAH*7qcUOiALUfHMaQGl5^xSa=B_AG0swsP8 zRmxo!+&Z{UXAm8*@(A?kItdrNC@YQ_6-J+ALFB`)n{jn;g*a9DJ}%6G7Yli~I+CcDt8$lA?2*x#>& zyxKR}`@$YhS2>rOd>C6xxF^k{^~fTWP>%#4QZ7>l{Za#MOx0FYxSivoW#81)luJ-X zJBTw4;h6=B_KfPB-!}G|_M|;O+BD+u8D80Mx#9CfO8n#lF_teq$jFOjQIZ__d0>Zv z7!^*7aQ#8HL2qw3VmY1bJo$-M#h|Bj&M@O+dmz2rS93MA2#!R>Eg z&r}cjs&VJMrRD_B`*G;V+EVc>zKy#qY8a-Fp)!(Rx}nE&FXMdBnFS((Uw_KnuHhmF zaN7MDmZxnm3#E`(vv8z|>=+GGoV$b5d?qf*>7KI{ag~(^w;poKoCm08hOPw0E|My& z^y0uNc5MCdI0he!ovu;jUHcR^?rap^-(H9o{jBY{7s68gf(%%%T6)cOYPcmh!P_%Z z>-eI=$_CQZPXhG4w<+GljN_+gd1cwI^mcw8cx?LP0r2ty-$mKLPK6oSL-;XS{T8X) z{sfLxt@ifzl1-2`H#Pa)ZXE{i%>KOf%+)l)HXuXB7gy?()-D{QSzHzFnS4PLtF#A% zYqRXE)0qMSa{D~~@fQY;+s3z!EQvq+VZi|>^sw}PgZvn~Mxu2XW6*g(nsvo;$GZoY z-h4mQ+QAE2gQd2kkfa8-Uw_81f!mDISZ`=+2G`$)7Q^81aG)d#5g6C;6}NLQn3Ve> z&B?ZWFp<>w%slyZGd}yC;STi}wxbQhZa9)t4OK_kq4)L4sAs@FJzTz2T`YIo*%sKN~cfh9l z{?h(w=0ROvgkn}>Z?)m+f0}Lc*?D<+GwONT@*mRD&QoN4kCNccxFG&$r}_zgTsl_i z#>H_?r1Cs)a;)QjH+$BdEa0V3szzBt%W6Bt#f7pAYfGXZgWCG~delyd1ZdbXf&vDF zi^u*my?Xtp{QNsO_9hK-yO^wVm5;rM?&!@OU6pJln}|VBT%YURWlZPrdW;7e#5s#= zBHAJWvy+u>Y?@ zoOE~+8#q(?FUf-jk6r8M!liM6P?$$PGc{F;Z5I2(O2h|Xjm;!-JMGwA=nmYKel`4q zIcRtxF)=alM&@+5c;r9w_PyW?Zi^F^cbjb;Uz{KMHZzSJeH?svc-RtpKp2Fsdyrt} ze>lEUjw7o62iWiZytsjYcIVs3=O%iV{E+d^JWB zl>`Kxk_KZ0QNR~cL^3h3(YbD2yY=1cU3=HN-=k#$Za@`rb09ciDs0`OvmF?F4{Qg0 z{50hI&$_$*?vr2d_xS$qcLRC{kQ)R3131&w=8uI|e>WTDqJh5P6GDiBiwHqf#=k}2 zpNKfXw)=YXM7WBLTodXgBkF!By+MTI{vd@GkJ7J2xImO7T&100oSkHxhh&^jjN^#H z@g(DdnZV&F?!i4s6i^b0$Kq$lz9YN#cNxK*4K?F12=ig~eE7nQBz)(O@UinyIM@*6{IqFL0x{$mpk=9&S&ic8a z9JlkdIQwcRsT2O@4-rmiBMSGY2!~ISjBCA&OA%Lof?%98NCZxKK8KwI<0J`Au5ijZ z1R=PTQo~S=a4BW{dvb(RUdRcNQFCsMUjZjsb`9nT=hbiu>vIwuIziIPX1z^>;}Pn{ zIluu8l&lHkUf2!GM7Y|8zVG45?Bnp`c#mYk`lPR+VvY#6qsTM8_I=!x_0({I(1;RJ z1l;UcYoD%HOP}%QnZ`4(XBVd(ry#YaVzwuqJ4;=};oCwDiS{8VZXQe^pStt3|0NHrt zJ6`j&p->U$A5YiGpBiTZxARKtlGr>>b}iWLc2N_RYi@$y0vq8`cnY;=5=Rv{?qv*; z)tPp93Lg^=dEEAE6mf?O=)1Uu?$>Rb13c=<6pk9`FXlJGqoQy22!eeJvy5F_Gm|U) zm(6|43iZ>NjThG}lH&|2^JLE&5XPdP6tuIOzPfFFQPQ1<29@;|YL|SSHXS&F%6)_T zZRU{lZi5lJ+<7ZCFewhHsRiueOx$x+LG8`7j&B4CjgI}9da6EY&E-*JX&vlHKh8GN z^C0)B@PqOQ4m(}zDkmoPaf-Ogah??z#R8)SbSgc#9Yw||?w4pgJ|-LHPlLwzr7$wB z`C?LUev+S+D)1D;dG+(~e0C)OmyXk`ON))qIJXD(gR}BUg%D)k!t?9r1?SaHHLFwE zimPf-x2Vx@dM2=43Qjk;?kel;&gX1fJ$w0+P_cXGo$gto5%KH&JrhZ3pDa|O1a zIA8rts$fSEHNrT>_$c!tTjDeDjlyr>g5!=5;2x7VgrqpvDCcsvDV|5hH<96#W_dUq z+5}$XmkTA{?QQ^mK+T{{sXPCtM826>L5=P=uI?&l33H78-HrLOpd+=_cH9yCHI`mB zqZW7ybjAP|I`g#P@$YtF@9)g7uGRK2{l;;Hb$2^g)sz^XuuP|4f#I}ndpvA=WA~k9 zPt=q+-n7h+H-zMS)AP7O^XOfp_3Gz_RxRcaq!kw~e_`nez1ld+JUTX(=3TsDV&+lbdb{2-!=Osj`w*s~=E?LmT%j(1 z!$|InSo#?24A2>H0wtfjL}F*1>FYKkoc^;>y8Ku}n`oYF8k)*=Q%&UZ&fT{9LFgkK zn-u^3;pe5RU`Oe z%zH4tTpNYRKOe5eeRCNcf$MxvLKNrWR`z{<7(c9ZZZ+lT*tRa?uemsf+ls3u#TN_= zD*7&b07db>;OrJP^9c8Sd{Z)P5au)$f`?G7R`G$r-SIKgFnH&6rdzg+%<4Nh)L3^l z^Yhl#`r)w=Wr%a7uZ!(6y{jGGH!?308oVZET+Ut)(&g*Md~+jJaPHIRQ%lXImaS9I reL6J6aO3(hy8Qq9+e?>m{f@r?#;PM3!e*ux00000NkvXXu0mjfC{wFq literal 0 HcmV?d00001 diff --git a/src/static/templates/admin/base.hbs b/src/static/templates/admin/base.hbs index e3948d0..a7270b9 100644 --- a/src/static/templates/admin/base.hbs +++ b/src/static/templates/admin/base.hbs @@ -29,16 +29,79 @@ width: 48px; height: 48px; } + + .navbar img { + height: 24px; + width: auto; + } + + -