vw_small

Hardened fork of Vaultwarden (https://github.com/dani-garcia/vaultwarden) with fewer features.
git clone https://git.philomathiclife.com/repos/vw_small
Log | Files | Refs | README

commit 3c66deb5cc7a4387e4176d2a5bdd3f321f09a6bd
parent 4146612a32b11d7a174b8ae997ed5eb317684de2
Author: BlackDex <black.dex@gmail.com>
Date:   Thu, 28 May 2020 10:42:36 +0200

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....

Diffstat:
Msrc/api/admin.rs | 128++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Msrc/api/web.rs | 1+
Msrc/config.rs | 5++++-
Msrc/db/models/cipher.rs | 8++++++++
Msrc/db/models/organization.rs | 4++++
Msrc/static/images/logo-gray.png | 0
Asrc/static/images/shield-white.png | 0
Msrc/static/templates/admin/base.hbs | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Asrc/static/templates/admin/diagnostics.hbs | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/static/templates/admin/organizations.hbs | 31+++++++++++++++++++++++++++++++
Dsrc/static/templates/admin/page.hbs | 373-------------------------------------------------------------------------------
Asrc/static/templates/admin/settings.hbs | 208+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/static/templates/admin/users.hbs | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
13 files changed, 665 insertions(+), 391 deletions(-)

diff --git a/src/api/admin.rs b/src/api/admin.rs @@ -23,7 +23,7 @@ pub fn routes() -> Vec<Route> { routes![ admin_login, - get_users, + get_users_json, post_admin_login, admin_page, invite_user, @@ -36,6 +36,9 @@ pub fn routes() -> Vec<Route> { 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<Value>, + users: Option<Vec<Value>>, + organizations: Option<Vec<Value>>, + diagnostics: Option<Value>, config: Value, can_backup: bool, logged_in: bool, @@ -126,15 +131,59 @@ struct AdminTemplateData { } impl AdminTemplateData { - fn new(users: Vec<Value>) -> 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<Value>) -> 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<Value>) -> 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<Html<String>> { - let users = User::get_all(&conn); - let users_json: Vec<Value> = users.iter().map(|u| u.to_json(&conn)).collect(); - - let text = AdminTemplateData::new(users_json).render()?; +fn admin_page(_token: AdminToken, _conn: DbConn) -> ApiResult<Html<String>> { + let text = AdminTemplateData::new().render()?; Ok(Html(text)) } @@ -195,13 +241,29 @@ fn logout(mut cookies: Cookies) -> Result<Redirect, ()> { } #[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<Value> = 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<Html<String>> { + let users = User::get_all(&conn); + let users_json: Vec<Value> = 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/<uuid>/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<Html<String>> { + let organizations = Organization::get_all(&conn); + let organizations_json: Vec<Value> = 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<Html<String>> { + 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 = "<data>")] fn post_config(data: Json<ConfigBuilder>, _token: AdminToken) -> EmptyResult { let data: ConfigBuilder = data.into_inner(); diff --git a/src/api/web.rs b/src/api/web.rs @@ -78,6 +78,7 @@ fn static_files(filename: String) -> Result<Content<&'static [u8]>, 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 @@ -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 @@ -355,6 +355,14 @@ impl Cipher { .load::<Self>(&**conn).expect("Error loading ciphers") } + pub fn count_owned_by_user(user_uuid: &str, conn: &DbConn) -> Option<i64> { + ciphers::table + .filter(ciphers::user_uuid.eq(user_uuid)) + .count() + .first::<i64>(&**conn) + .ok() + } + pub fn find_by_org(org_uuid: &str, conn: &DbConn) -> Vec<Self> { ciphers::table .filter(ciphers::organization_uuid.eq(org_uuid)) diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs @@ -255,6 +255,10 @@ impl Organization { .first::<Self>(&**conn) .ok() } + + pub fn get_all(conn: &DbConn) -> Vec<Self> { + organizations::table.load::<Self>(&**conn).expect("Error loading organizations") + } } impl UserOrganization { diff --git a/src/static/images/logo-gray.png b/src/static/images/logo-gray.png Binary files differ. diff --git a/src/static/images/shield-white.png b/src/static/images/shield-white.png Binary files differ. diff --git 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; + } </style> + <script> + function reload() { window.location.reload(); } + function msg(text, reload_page = true) { + text && alert(text); + reload_page && reload(); + } + function identicon(email) { + const data = new Identicon(md5(email), { size: 48, format: 'svg' }); + return "data:image/svg+xml;base64," + data.toString(); + } + function toggleVis(input_id) { + const elem = document.getElementById(input_id); + const type = elem.getAttribute("type"); + if (type === "text") { + elem.setAttribute("type", "password"); + } else { + elem.setAttribute("type", "text"); + } + return false; + } + function _post(url, successMsg, errMsg, body, reload_page = true) { + fetch(url, { + method: 'POST', + body: body, + mode: "same-origin", + credentials: "same-origin", + headers: { "Content-Type": "application/json" } + }).then( resp => { + if (resp.ok) { msg(successMsg, reload_page); return Promise.reject({error: false}); } + respStatus = resp.status; + respStatusText = resp.statusText; + return resp.text(); + }).then( respText => { + try { + const respJson = JSON.parse(respText); + return respJson ? respJson.ErrorModel.Message : "Unknown error"; + } catch (e) { + return Promise.reject({body:respStatus + ' - ' + respStatusText, error: true}); + } + }).then( apiMsg => { + msg(errMsg + "\n" + apiMsg, reload_page); + }).catch( e => { + if (e.error === false) { return true; } + else { msg(errMsg + "\n" + e.body, reload_page); } + }); + } + </script> + </head> <body class="bg-light"> - <nav class="navbar navbar-expand-sm navbar-dark bg-dark fixed-top shadow"> - <a class="navbar-brand" href="#">Bitwarden_rs</a> + <nav class="navbar navbar-expand-sm navbar-dark bg-dark fixed-top shadow mb-4"> + <div class="container"> + <a class="navbar-brand" href="{{urlpath}}/admin"><img class="pr-1" src="{{urlpath}}/bwrs_static/shield-white.png">Bitwarden_rs Admin</a> <div class="navbar-collapse"> <ul class="navbar-nav"> - <li class="nav-item active"> - <a class="nav-link" href="{{urlpath}}/admin">Admin Panel</a> + <li class="nav-item"> + <a class="nav-link" href="{{urlpath}}/admin">Settings</a> + </li> + <li class="nav-item"> + <a class="nav-link" href="{{urlpath}}/admin/users/overview">Users</a> + </li> + <li class="nav-item"> + <a class="nav-link" href="{{urlpath}}/admin/organizations/overview">Organizations</a> + </li> + <li class="nav-item"> + <a class="nav-link" href="{{urlpath}}/admin/diagnostics">Diagnostics</a> </li> <li class="nav-item"> <a class="nav-link" href="{{urlpath}}/">Vault</a> @@ -54,14 +117,27 @@ {{/if}} {{#if logged_in}} - <li class="nav-item"> + <li class="nav-item rounded btn-secondary"> <a class="nav-link" href="{{urlpath}}/admin/logout">Log Out</a> </li> {{/if}} </ul> + </div> </nav> {{> (page_content) }} + + <script> + // get current URL path and assign 'active' class to the correct nav-item + (function () { + var pathname = window.location.pathname; + if (pathname === "") return; + var navItem = document.querySelectorAll('.navbar-nav .nav-item a[href="'+pathname+'"]'); + if (navItem.length === 1) { + navItem[0].parentElement.className = navItem[0].parentElement.className + ' active'; + } + })(); + </script> </body> -</html> +</html> +\ No newline at end of file diff --git a/src/static/templates/admin/diagnostics.hbs b/src/static/templates/admin/diagnostics.hbs @@ -0,0 +1,73 @@ +<main class="container"> + <div id="diagnostics-block" class="my-3 p-3 bg-white rounded shadow"> + <h6 class="border-bottom pb-2 mb-2">Diagnostics</h6> + + <h3>Version</h3> + <div class="row"> + <div class="col-md"> + <dl class="row"> + <dt class="col-sm-5">Server Installed</dt> + <dd class="col-sm-7"> + <span id="server-installed">{{version}}</span> + </dd> + <dt class="col-sm-5">Web Installed</dt> + <dd class="col-sm-7"> + <span id="web-installed">{{diagnostics.web_vault_version}}</span> + </dd> + </dl> + </div> + </div> + + <h3>Checks</h3> + <div class="row"> + <div class="col-md"> + <dl class="row"> + <dt class="col-sm-5">DNS (github.com) + <span class="badge badge-success d-none" id="dns-success" title="DNS Resolving works!">Ok</span> + <span class="badge badge-danger d-none" id="dns-warning" title="DNS Resolving failed. Please fix.">Error</span> + </dt> + <dd class="col-sm-7"> + <span id="dns-resolved">{{diagnostics.dns_resolved}}</span> + </dd> + + <dt class="col-sm-5">Date & Time (UTC) + <span class="badge badge-success d-none" id="time-success" title="Time offsets seem to be correct.">Ok</span> + <span class="badge badge-danger d-none" id="time-warning" title="Time offsets are too mouch at drift.">Error</span> + </dt> + <dd class="col-sm-7"> + <span id="time-server" class="d-block"><b>Server:</b> <span id="time-server-string">{{diagnostics.server_time}}</span></span> + <span id="time-browser" class="d-block"><b>Browser:</b> <span id="time-browser-string"></span></span> + </dd> + </dl> + </div> + </div> + </div> +</main> + +<script> + const d = new Date(); + const year = d.getUTCFullYear(); + const month = String((d.getUTCMonth()+1)).padStart(2, '0'); + const day = String(d.getUTCDate()).padStart(2, '0'); + const hour = String(d.getUTCHours()).padStart(2, '0'); + const minute = String(d.getUTCMinutes()).padStart(2, '0'); + const seconds = String(d.getUTCSeconds()).padStart(2, '0'); + const browserUTC = year + '-' + month + '-' + day + ' ' + hour + ':' + minute + ':' + seconds; + document.getElementById("time-browser-string").innerText = browserUTC; + + const serverUTC = document.getElementById("time-server-string").innerText; + const timeDrift = (Date.parse(serverUTC) - Date.parse(browserUTC)) / 1000; + if (timeDrift > 30 || timeDrift < -30) { + document.getElementById('time-warning').classList.remove('d-none'); + } else { + document.getElementById('time-success').classList.remove('d-none'); + } + + // Check if the output is a valid IP + const isValidIp = value => (/^(?:(?:^|\.)(?:2(?:5[0-5]|[0-4]\d)|1?\d?\d)){4}$/.test(value) ? true : false); + if (isValidIp(document.getElementById('dns-resolved').innerText)) { + document.getElementById('dns-success').classList.remove('d-none'); + } else { + document.getElementById('dns-warning').classList.remove('d-none'); + } +</script> +\ No newline at end of file diff --git a/src/static/templates/admin/organizations.hbs b/src/static/templates/admin/organizations.hbs @@ -0,0 +1,30 @@ +<main class="container"> + <div id="organizations-block" class="my-3 p-3 bg-white rounded shadow"> + <h6 class="border-bottom pb-2 mb-0">Organizations</h6> + + <div id="organizations-list"> + {{#each organizations}} + <div class="media pt-3"> + <img class="mr-2 rounded identicon" data-src="{{Name}}_{{BillingEmail}}"> + <div class="media-body pb-3 mb-0 small border-bottom"> + <div class="row justify-content-between"> + <div class="col"> + <strong>{{Name}}</strong> + {{#if Id}} + <span class="badge badge-success ml-2">{{Id}}</span> + {{/if}} + <span class="d-block">{{BillingEmail}}</span> + </div> + </div> + </div> + </div> + {{/each}} + </div> + </div> +</main> + +<script> + document.querySelectorAll("img.identicon").forEach(function (e, i) { + e.src = identicon(e.dataset.src); + }); +</script> +\ No newline at end of file diff --git a/src/static/templates/admin/page.hbs b/src/static/templates/admin/page.hbs @@ -1,373 +0,0 @@ -<main class="container"> - <div id="users-block" class="my-3 p-3 bg-white rounded shadow"> - <h6 class="border-bottom pb-2 mb-0">Registered Users</h6> - - <div id="users-list"> - {{#each users}} - <div class="media pt-3"> - <img class="mr-2 rounded identicon" data-src="{{Email}}"> - <div class="media-body pb-3 mb-0 small border-bottom"> - <div class="row justify-content-between"> - <div class="col"> - <strong>{{Name}}</strong> - {{#if TwoFactorEnabled}} - <span class="badge badge-success ml-2">2FA</span> - {{/if}} - {{#case _Status 1}} - <span class="badge badge-warning ml-2">Invited</span> - {{/case}} - <span class="d-block">{{Email}}</span> - </div> - <div class="col"> - <strong> Organizations: </strong> - <span class="d-block"> - {{#each Organizations}} - <span class="badge badge-primary" data-orgtype="{{Type}}">{{Name}}</span> - {{/each}} - </span> - </div> - <div style="flex: 0 0 300px; font-size: 90%; text-align: right; padding-right: 15px"> - {{#if TwoFactorEnabled}} - <a class="mr-2" href="#" onclick='remove2fa({{jsesc Id}})'>Remove all 2FA</a> - {{/if}} - - <a class="mr-2" href="#" onclick='deauthUser({{jsesc Id}})'>Deauthorize sessions</a> - <a class="mr-2" href="#" onclick='deleteUser({{jsesc Id}}, {{jsesc Email}})'>Delete User</a> - </div> - </div> - </div> - </div> - {{/each}} - - </div> - - <div class="mt-3"> - <button type="button" class="btn btn-sm btn-link" onclick="updateRevisions();" - title="Force all clients to fetch new data next time they connect. Useful after restoring a backup to remove any stale data."> - Force clients to resync - </button> - - <button type="button" class="btn btn-sm btn-primary float-right" onclick="reload();">Reload users</button> - </div> - </div> - - <div id="invite-form-block" class="align-items-center p-3 mb-3 text-white-50 bg-secondary rounded shadow"> - <div> - <h6 class="mb-0 text-white">Invite User</h6> - <small>Email:</small> - - <form class="form-inline" id="invite-form" onsubmit="inviteUser(); return false;"> - <input type="email" class="form-control w-50 mr-2" id="email-invite" placeholder="Enter email"> - <button type="submit" class="btn btn-primary">Invite</button> - </form> - </div> - </div> - - <div id="config-block" class="align-items-center p-3 mb-3 bg-secondary rounded shadow"> - <div> - <h6 class="text-white mb-3">Configuration</h6> - <div class="small text-white mb-3"> - NOTE: The settings here override the environment variables. Once saved, it's recommended to stop setting - them to avoid confusion. This does not apply to the read-only section, which can only be set through the - environment. - </div> - - <form class="form accordion" id="config-form" onsubmit="saveConfig(); return false;"> - {{#each config}} - {{#if groupdoc}} - <div class="card bg-light mb-3"> - <div class="card-header"><button type="button" class="btn btn-link collapsed" data-toggle="collapse" - data-target="#g_{{group}}">{{groupdoc}}</button></div> - <div id="g_{{group}}" class="card-body collapse" data-parent="#config-form"> - {{#each elements}} - {{#if editable}} - <div class="form-group row" title="[{{name}}] {{doc.description}}"> - {{#case type "text" "number" "password"}} - <label for="input_{{name}}" class="col-sm-3 col-form-label">{{doc.name}}</label> - <div class="col-sm-8 input-group"> - <input class="form-control conf-{{type}}" id="input_{{name}}" type="{{type}}" - name="{{name}}" value="{{value}}" {{#if default}} placeholder="Default: {{default}}" - {{/if}}> - - {{#case type "password"}} - <div class="input-group-append"> - <button class="btn btn-outline-secondary" type="button" - onclick="toggleVis('input_{{name}}');">Show/hide</button> - </div> - {{/case}} - </div> - {{/case}} - {{#case type "checkbox"}} - <div class="col-sm-3">{{doc.name}}</div> - <div class="col-sm-8"> - <div class="form-check"> - <input class="form-check-input conf-{{type}}" type="checkbox" id="input_{{name}}" - name="{{name}}" {{#if value}} checked {{/if}}> - - <label class="form-check-label" for="input_{{name}}"> Default: {{default}} </label> - </div> - </div> - {{/case}} - </div> - {{/if}} - {{/each}} - {{#case group "smtp"}} - <div class="form-group row pt-3 border-top" title="Send a test email to given email address"> - <label for="smtp-test-email" class="col-sm-3 col-form-label">Test SMTP</label> - <div class="col-sm-8 input-group"> - <input class="form-control" id="smtp-test-email" type="email" placeholder="Enter test email"> - <div class="input-group-append"> - <button type="button" class="btn btn-outline-primary" onclick="smtpTest(); return false;">Send test email</button> - </div> - </div> - </div> - {{/case}} - </div> - </div> - {{/if}} - {{/each}} - - <div class="card bg-light mb-3"> - <div class="card-header"><button type="button" class="btn btn-link collapsed" data-toggle="collapse" - data-target="#g_readonly">Read-Only Config</button></div> - <div id="g_readonly" class="card-body collapse" data-parent="#config-form"> - <div class="small mb-3"> - NOTE: These options can't be modified in the editor because they would require the server - to be restarted. To modify them, you need to set the correct environment variables when - launching the server. You can check the variable names in the tooltips of each option. - </div> - - {{#each config}} - {{#each elements}} - {{#unless editable}} - <div class="form-group row" title="[{{name}}] {{doc.description}}"> - {{#case type "text" "number" "password"}} - <label for="input_{{name}}" class="col-sm-3 col-form-label">{{doc.name}}</label> - <div class="col-sm-8 input-group"> - <input readonly class="form-control" id="input_{{name}}" type="{{type}}" - value="{{value}}" {{#if default}} placeholder="Default: {{default}}" {{/if}}> - - {{#case type "password"}} - <div class="input-group-append"> - <button class="btn btn-outline-secondary" type="button" - onclick="toggleVis('input_{{name}}');">Show/hide</button> - </div> - {{/case}} - </div> - {{/case}} - {{#case type "checkbox"}} - <div class="col-sm-3">{{doc.name}}</div> - <div class="col-sm-8"> - <div class="form-check"> - <input disabled class="form-check-input" type="checkbox" id="input_{{name}}" - {{#if value}} checked {{/if}}> - - <label class="form-check-label" for="input_{{name}}"> Default: {{default}} </label> - </div> - </div> - {{/case}} - </div> - {{/unless}} - {{/each}} - {{/each}} - - </div> - </div> - - {{#if can_backup}} - <div class="card bg-light mb-3"> - <div class="card-header"><button type="button" class="btn btn-link collapsed" data-toggle="collapse" - data-target="#g_database">Backup Database</button></div> - <div id="g_database" class="card-body collapse" data-parent="#config-form"> - <div class="small mb-3"> - NOTE: A local installation of sqlite3 is required for this section to work. - </div> - <button type="button" class="btn btn-primary" onclick="backupDatabase();">Backup Database</button> - </div> - </div> - {{/if}} - - <button type="submit" class="btn btn-primary">Save</button> - <button type="button" class="btn btn-danger float-right" onclick="deleteConf();">Reset defaults</button> - </form> - </div> - </div> -</main> - -<style> - #config-block ::placeholder { - /* Most modern browsers support this now. */ - color: orangered; - } -</style> - -<script> - function reload() { window.location.reload(); } - function msg(text, reload_page = true) { - text && alert(text); - reload_page && reload(); - } - function identicon(email) { - const data = new Identicon(md5(email), { size: 48, format: 'svg' }); - return "data:image/svg+xml;base64," + data.toString(); - } - function toggleVis(input_id) { - const elem = document.getElementById(input_id); - const type = elem.getAttribute("type"); - if (type === "text") { - elem.setAttribute("type", "password"); - } else { - elem.setAttribute("type", "text"); - } - return false; - } - function _post(url, successMsg, errMsg, body, reload_page = true) { - fetch(url, { - method: 'POST', - body: body, - mode: "same-origin", - credentials: "same-origin", - headers: { "Content-Type": "application/json" } - }).then( resp => { - if (resp.ok) { msg(successMsg, reload_page); return Promise.reject({error: false}); } - respStatus = resp.status; - respStatusText = resp.statusText; - return resp.text(); - }).then( respText => { - try { - const respJson = JSON.parse(respText); - return respJson ? respJson.ErrorModel.Message : "Unknown error"; - } catch (e) { - return Promise.reject({body:respStatus + ' - ' + respStatusText, error: true}); - } - }).then( apiMsg => { - msg(errMsg + "\n" + apiMsg, reload_page); - }).catch( e => { - if (e.error === false) { return true; } - else { msg(errMsg + "\n" + e.body, reload_page); } - }); - } - function deleteUser(id, mail) { - var input_mail = prompt("To delete user '" + mail + "', please type the email below") - if (input_mail != null) { - if (input_mail == mail) { - _post("{{urlpath}}/admin/users/" + id + "/delete", - "User deleted correctly", - "Error deleting user"); - } else { - alert("Wrong email, please try again") - } - } - return false; - } - function remove2fa(id) { - _post("{{urlpath}}/admin/users/" + id + "/remove-2fa", - "2FA removed correctly", - "Error removing 2FA"); - return false; - } - function deauthUser(id) { - _post("{{urlpath}}/admin/users/" + id + "/deauth", - "Sessions deauthorized correctly", - "Error deauthorizing sessions"); - return false; - } - function updateRevisions() { - _post("{{urlpath}}/admin/users/update_revision", - "Success, clients will sync next time they connect", - "Error forcing clients to sync"); - return false; - } - function inviteUser() { - inv = document.getElementById("email-invite"); - data = JSON.stringify({ "email": inv.value }); - inv.value = ""; - _post("{{urlpath}}/admin/invite/", "User invited correctly", - "Error inviting user", data); - return false; - } - function smtpTest() { - test_email = document.getElementById("smtp-test-email"); - data = JSON.stringify({ "email": test_email.value }); - _post("{{urlpath}}/admin/test/smtp/", - "SMTP Test email sent correctly", - "Error sending SMTP test email", data, false); - return false; - } - function getFormData() { - let data = {}; - - document.querySelectorAll(".conf-checkbox").forEach(function (e, i) { - data[e.name] = e.checked; - }); - - document.querySelectorAll(".conf-number").forEach(function (e, i) { - data[e.name] = e.value ? +e.value : null; - }); - - document.querySelectorAll(".conf-text, .conf-password").forEach(function (e, i) { - data[e.name] = e.value || null; - }); - return data; - } - function saveConfig() { - data = JSON.stringify(getFormData()); - _post("{{urlpath}}/admin/config/", "Config saved correctly", - "Error saving config", data); - return false; - } - function deleteConf() { - var input = prompt("This will remove all user configurations, and restore the defaults and the " + - "values set by the environment. This operation could be dangerous. Type 'DELETE' to proceed:"); - if (input === "DELETE") { - _post("{{urlpath}}/admin/config/delete", - "Config deleted correctly", - "Error deleting config"); - } else { - alert("Wrong input, please try again") - } - - return false; - } - function backupDatabase() { - _post("{{urlpath}}/admin/config/backup_db", - "Backup created successfully", - "Error creating backup", null, false); - return false; - } - function masterCheck(check_id, inputs_query) { - function onChanged(checkbox, inputs_query) { - return function _fn() { - document.querySelectorAll(inputs_query).forEach(function (e, i) { e.disabled = !checkbox.checked; }); - checkbox.disabled = false; - }; - }; - - const checkbox = document.getElementById(check_id); - const onChange = onChanged(checkbox, inputs_query); - onChange(); // Trigger the event initially - checkbox.addEventListener("change", onChange); - } - let OrgTypes = { - "0": { "name": "Owner", "color": "orange" }, - "1": { "name": "Admin", "color": "blueviolet" }, - "2": { "name": "User", "color": "blue" }, - "3": { "name": "Manager", "color": "green" }, - }; - - document.querySelectorAll("img.identicon").forEach(function (e, i) { - e.src = identicon(e.dataset.src); - }); - - document.querySelectorAll("[data-orgtype]").forEach(function (e, i) { - let orgtype = OrgTypes[e.dataset.orgtype]; - e.style.backgroundColor = orgtype.color; - e.title = orgtype.name; - }); - - // These are formatted because otherwise the - // VSCode formatter breaks But they still work - // {{#each config}} {{#if grouptoggle}} - masterCheck("input_{{grouptoggle}}", "#g_{{group}} input"); - // {{/if}} {{/each}} -</script> diff --git a/src/static/templates/admin/settings.hbs b/src/static/templates/admin/settings.hbs @@ -0,0 +1,208 @@ +<main class="container"> + <div id="config-block" class="align-items-center p-3 mb-3 bg-secondary rounded shadow"> + <div> + <h6 class="text-white mb-3">Configuration</h6> + <div class="small text-white mb-3"> + NOTE: The settings here override the environment variables. Once saved, it's recommended to stop setting + them to avoid confusion. This does not apply to the read-only section, which can only be set through the + environment. + </div> + + <form class="form accordion" id="config-form" onsubmit="saveConfig(); return false;"> + {{#each config}} + {{#if groupdoc}} + <div class="card bg-light mb-3"> + <div class="card-header"><button type="button" class="btn btn-link collapsed" data-toggle="collapse" + data-target="#g_{{group}}">{{groupdoc}}</button></div> + <div id="g_{{group}}" class="card-body collapse" data-parent="#config-form"> + {{#each elements}} + {{#if editable}} + <div class="form-group row" title="[{{name}}] {{doc.description}}"> + {{#case type "text" "number" "password"}} + <label for="input_{{name}}" class="col-sm-3 col-form-label">{{doc.name}}</label> + <div class="col-sm-8 input-group"> + <input class="form-control conf-{{type}}" id="input_{{name}}" type="{{type}}" + name="{{name}}" value="{{value}}" {{#if default}} placeholder="Default: {{default}}" + {{/if}}> + + {{#case type "password"}} + <div class="input-group-append"> + <button class="btn btn-outline-secondary" type="button" + onclick="toggleVis('input_{{name}}');">Show/hide</button> + </div> + {{/case}} + </div> + {{/case}} + {{#case type "checkbox"}} + <div class="col-sm-3">{{doc.name}}</div> + <div class="col-sm-8"> + <div class="form-check"> + <input class="form-check-input conf-{{type}}" type="checkbox" id="input_{{name}}" + name="{{name}}" {{#if value}} checked {{/if}}> + + <label class="form-check-label" for="input_{{name}}"> Default: {{default}} </label> + </div> + </div> + {{/case}} + </div> + {{/if}} + {{/each}} + {{#case group "smtp"}} + <div class="form-group row pt-3 border-top" title="Send a test email to given email address"> + <label for="smtp-test-email" class="col-sm-3 col-form-label">Test SMTP</label> + <div class="col-sm-8 input-group"> + <input class="form-control" id="smtp-test-email" type="email" placeholder="Enter test email"> + <div class="input-group-append"> + <button type="button" class="btn btn-outline-primary" onclick="smtpTest(); return false;">Send test email</button> + </div> + </div> + </div> + {{/case}} + </div> + </div> + {{/if}} + {{/each}} + + <div class="card bg-light mb-3"> + <div class="card-header"><button type="button" class="btn btn-link collapsed" data-toggle="collapse" + data-target="#g_readonly">Read-Only Config</button></div> + <div id="g_readonly" class="card-body collapse" data-parent="#config-form"> + <div class="small mb-3"> + NOTE: These options can't be modified in the editor because they would require the server + to be restarted. To modify them, you need to set the correct environment variables when + launching the server. You can check the variable names in the tooltips of each option. + </div> + + {{#each config}} + {{#each elements}} + {{#unless editable}} + <div class="form-group row" title="[{{name}}] {{doc.description}}"> + {{#case type "text" "number" "password"}} + <label for="input_{{name}}" class="col-sm-3 col-form-label">{{doc.name}}</label> + <div class="col-sm-8 input-group"> + <input readonly class="form-control" id="input_{{name}}" type="{{type}}" + value="{{value}}" {{#if default}} placeholder="Default: {{default}}" {{/if}}> + + {{#case type "password"}} + <div class="input-group-append"> + <button class="btn btn-outline-secondary" type="button" + onclick="toggleVis('input_{{name}}');">Show/hide</button> + </div> + {{/case}} + </div> + {{/case}} + {{#case type "checkbox"}} + <div class="col-sm-3">{{doc.name}}</div> + <div class="col-sm-8"> + <div class="form-check"> + <input disabled class="form-check-input" type="checkbox" id="input_{{name}}" + {{#if value}} checked {{/if}}> + + <label class="form-check-label" for="input_{{name}}"> Default: {{default}} </label> + </div> + </div> + {{/case}} + </div> + {{/unless}} + {{/each}} + {{/each}} + + </div> + </div> + + {{#if can_backup}} + <div class="card bg-light mb-3"> + <div class="card-header"><button type="button" class="btn btn-link collapsed" data-toggle="collapse" + data-target="#g_database">Backup Database</button></div> + <div id="g_database" class="card-body collapse" data-parent="#config-form"> + <div class="small mb-3"> + NOTE: A local installation of sqlite3 is required for this section to work. + </div> + <button type="button" class="btn btn-primary" onclick="backupDatabase();">Backup Database</button> + </div> + </div> + {{/if}} + + <button type="submit" class="btn btn-primary">Save</button> + <button type="button" class="btn btn-danger float-right" onclick="deleteConf();">Reset defaults</button> + </form> + </div> + </div> +</main> + +<style> + #config-block ::placeholder { + /* Most modern browsers support this now. */ + color: orangered; + } +</style> + +<script> + function smtpTest() { + test_email = document.getElementById("smtp-test-email"); + data = JSON.stringify({ "email": test_email.value }); + _post("{{urlpath}}/admin/test/smtp/", + "SMTP Test email sent correctly", + "Error sending SMTP test email", data, false); + return false; + } + function getFormData() { + let data = {}; + + document.querySelectorAll(".conf-checkbox").forEach(function (e, i) { + data[e.name] = e.checked; + }); + + document.querySelectorAll(".conf-number").forEach(function (e, i) { + data[e.name] = e.value ? +e.value : null; + }); + + document.querySelectorAll(".conf-text, .conf-password").forEach(function (e, i) { + data[e.name] = e.value || null; + }); + return data; + } + function saveConfig() { + data = JSON.stringify(getFormData()); + _post("{{urlpath}}/admin/config/", "Config saved correctly", + "Error saving config", data); + return false; + } + function deleteConf() { + var input = prompt("This will remove all user configurations, and restore the defaults and the " + + "values set by the environment. This operation could be dangerous. Type 'DELETE' to proceed:"); + if (input === "DELETE") { + _post("{{urlpath}}/admin/config/delete", + "Config deleted correctly", + "Error deleting config"); + } else { + alert("Wrong input, please try again") + } + + return false; + } + function backupDatabase() { + _post("{{urlpath}}/admin/config/backup_db", + "Backup created successfully", + "Error creating backup", null, false); + return false; + } + function masterCheck(check_id, inputs_query) { + function onChanged(checkbox, inputs_query) { + return function _fn() { + document.querySelectorAll(inputs_query).forEach(function (e, i) { e.disabled = !checkbox.checked; }); + checkbox.disabled = false; + }; + }; + + const checkbox = document.getElementById(check_id); + const onChange = onChanged(checkbox, inputs_query); + onChange(); // Trigger the event initially + checkbox.addEventListener("change", onChange); + } + // These are formatted because otherwise the + // VSCode formatter breaks But they still work + // {{#each config}} {{#if grouptoggle}} + masterCheck("input_{{grouptoggle}}", "#g_{{group}} input"); + // {{/if}} {{/each}} +</script> diff --git a/src/static/templates/admin/users.hbs b/src/static/templates/admin/users.hbs @@ -0,0 +1,134 @@ +<main class="container"> + <div id="users-block" class="my-3 p-3 bg-white rounded shadow"> + <h6 class="border-bottom pb-2 mb-0">Registered Users</h6> + + <div id="users-list"> + {{#each users}} + <div class="media pt-3"> + <img class="mr-2 rounded identicon" data-src="{{Email}}"> + <div class="media-body pb-3 mb-0 small border-bottom"> + <div class="row justify-content-between"> + <div class="col"> + <strong>{{Name}}</strong> + {{#if TwoFactorEnabled}} + <span class="badge badge-success ml-2">2FA</span> + {{/if}} + {{#case _Status 1}} + <span class="badge badge-warning ml-2">Invited</span> + {{/case}} + <span class="d-block">{{Email}} + {{#if EmailVerified}} + <span class="badge badge-success ml-2">Verified</span> + {{/if}} + </span> + </div> + <div class="col"> + <strong> Personal Items: </strong> + <span class="d-block"> + {{cipher_count}} + </span> + </div> + <div class="col-4"> + <strong> Organizations: </strong> + <span class="d-block"> + {{#each Organizations}} + <span class="badge badge-primary" data-orgtype="{{Type}}">{{Name}}</span> + {{/each}} + </span> + </div> + <div class="col" style="font-size: 90%; text-align: right; padding-right: 15px"> + {{#if TwoFactorEnabled}} + <a class="mr-2" href="#" onclick='remove2fa({{jsesc Id}})'>Remove all 2FA</a> + {{/if}} + + <a class="mr-2" href="#" onclick='deauthUser({{jsesc Id}})'>Deauthorize sessions</a> + <a class="mr-2" href="#" onclick='deleteUser({{jsesc Id}}, {{jsesc Email}})'>Delete User</a> + </div> + </div> + </div> + </div> + {{/each}} + + </div> + + <div class="mt-3"> + <button type="button" class="btn btn-sm btn-link" onclick="updateRevisions();" + title="Force all clients to fetch new data next time they connect. Useful after restoring a backup to remove any stale data."> + Force clients to resync + </button> + + <button type="button" class="btn btn-sm btn-primary float-right" onclick="reload();">Reload users</button> + </div> + </div> + + <div id="invite-form-block" class="align-items-center p-3 mb-3 text-white-50 bg-secondary rounded shadow"> + <div> + <h6 class="mb-0 text-white">Invite User</h6> + <small>Email:</small> + + <form class="form-inline" id="invite-form" onsubmit="inviteUser(); return false;"> + <input type="email" class="form-control w-50 mr-2" id="email-invite" placeholder="Enter email"> + <button type="submit" class="btn btn-primary">Invite</button> + </form> + </div> + </div> +</main> + +<script> + function deleteUser(id, mail) { + var input_mail = prompt("To delete user '" + mail + "', please type the email below") + if (input_mail != null) { + if (input_mail == mail) { + _post("{{urlpath}}/admin/users/" + id + "/delete", + "User deleted correctly", + "Error deleting user"); + } else { + alert("Wrong email, please try again") + } + } + return false; + } + function remove2fa(id) { + _post("{{urlpath}}/admin/users/" + id + "/remove-2fa", + "2FA removed correctly", + "Error removing 2FA"); + return false; + } + function deauthUser(id) { + _post("{{urlpath}}/admin/users/" + id + "/deauth", + "Sessions deauthorized correctly", + "Error deauthorizing sessions"); + return false; + } + function updateRevisions() { + _post("{{urlpath}}/admin/users/update_revision", + "Success, clients will sync next time they connect", + "Error forcing clients to sync"); + return false; + } + function inviteUser() { + inv = document.getElementById("email-invite"); + data = JSON.stringify({ "email": inv.value }); + inv.value = ""; + _post("{{urlpath}}/admin/invite/", "User invited correctly", + "Error inviting user", data); + return false; + } + + let OrgTypes = { + "0": { "name": "Owner", "color": "orange" }, + "1": { "name": "Admin", "color": "blueviolet" }, + "2": { "name": "User", "color": "blue" }, + "3": { "name": "Manager", "color": "green" }, + }; + + document.querySelectorAll("img.identicon").forEach(function (e, i) { + e.src = identicon(e.dataset.src); + }); + + document.querySelectorAll("[data-orgtype]").forEach(function (e, i) { + let orgtype = OrgTypes[e.dataset.orgtype]; + e.style.backgroundColor = orgtype.color; + e.title = orgtype.name; + }); +</script> +\ No newline at end of file