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 d41350050b4b0ed231694d9b6860ad936929dfb8
parent cd768439d2d65ff5bdbf1e3c9c2285e56c05ef50
Author: Daniel GarcĂ­a <dani-garcia@users.noreply.github.com>
Date:   Wed,  3 Feb 2021 22:50:15 +0100

Merge pull request #1353 from BlackDex/admin-interface

Extra features for admin interface.
Diffstat:
Msrc/api/admin.rs | 59++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Msrc/config.rs | 12+++++++++---
Msrc/static/templates/admin/diagnostics.hbs | 27+++++++++++++++++++++++----
Msrc/static/templates/admin/organizations.hbs | 26++++++++++++++++++++++++++
Msrc/static/templates/admin/users.hbs | 70+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
5 files changed, 181 insertions(+), 13 deletions(-)

diff --git a/src/api/admin.rs b/src/api/admin.rs @@ -13,7 +13,7 @@ use rocket::{ use rocket_contrib::json::Json; use crate::{ - api::{ApiResult, EmptyResult, JsonResult}, + api::{ApiResult, EmptyResult, JsonResult, NumberOrString}, auth::{decode_admin, encode_jwt, generate_admin_claims, ClientIp}, config::ConfigBuilder, db::{backup_database, models::*, DbConn, DbConnType}, @@ -40,6 +40,7 @@ pub fn routes() -> Vec<Route> { disable_user, enable_user, remove_2fa, + update_user_org_type, update_revision_users, post_config, delete_config, @@ -47,6 +48,7 @@ pub fn routes() -> Vec<Route> { test_smtp, users_overview, organizations_overview, + delete_organization, diagnostics, get_diagnostics_config ] @@ -367,6 +369,41 @@ fn remove_2fa(uuid: String, _token: AdminToken, conn: DbConn) -> EmptyResult { user.save(&conn) } +#[derive(Deserialize, Debug)] +struct UserOrgTypeData { + user_type: NumberOrString, + user_uuid: String, + org_uuid: String, +} + +#[post("/users/org_type", data = "<data>")] +fn update_user_org_type(data: Json<UserOrgTypeData>, _token: AdminToken, conn: DbConn) -> EmptyResult { + let data: UserOrgTypeData = data.into_inner(); + + let mut user_to_edit = match UserOrganization::find_by_user_and_org(&data.user_uuid, &data.org_uuid, &conn) { + Some(user) => user, + None => err!("The specified user isn't member of the organization"), + }; + + let new_type = match UserOrgType::from_str(&data.user_type.into_string()) { + Some(new_type) => new_type as i32, + None => err!("Invalid type"), + }; + + if user_to_edit.atype == UserOrgType::Owner && new_type != UserOrgType::Owner { + // Removing owner permmission, check that there are at least another owner + let num_owners = UserOrganization::find_by_org_and_type(&data.org_uuid, UserOrgType::Owner as i32, &conn).len(); + + if num_owners <= 1 { + err!("Can't change the type of the last owner") + } + } + + user_to_edit.atype = new_type as i32; + user_to_edit.save(&conn) +} + + #[post("/users/update_revision")] fn update_revision_users(_token: AdminToken, conn: DbConn) -> EmptyResult { User::update_all_revisions(&conn) @@ -390,6 +427,12 @@ fn organizations_overview(_token: AdminToken, conn: DbConn) -> ApiResult<Html<St Ok(Html(text)) } +#[post("/organizations/<uuid>/delete")] +fn delete_organization(uuid: String, _token: AdminToken, conn: DbConn) -> EmptyResult { + let org = Organization::find_by_uuid(&uuid, &conn).map_res("Organization doesn't exist")?; + org.delete(&conn) +} + #[derive(Deserialize)] struct WebVaultVersion { version: String, @@ -443,7 +486,7 @@ fn diagnostics(_token: AdminToken, _conn: DbConn) -> ApiResult<Html<String>> { let web_vault_version: WebVaultVersion = serde_json::from_str(&vault_version_str)?; // Execute some environment checks - let running_within_docker = std::path::Path::new("/.dockerenv").exists(); + let running_within_docker = std::path::Path::new("/.dockerenv").exists() || std::path::Path::new("/run/.containerenv").exists(); let has_http_access = has_http_access(); let uses_proxy = env::var_os("HTTP_PROXY").is_some() || env::var_os("http_proxy").is_some() @@ -471,9 +514,15 @@ fn diagnostics(_token: AdminToken, _conn: DbConn) -> ApiResult<Html<String>> { } _ => "-".to_string(), }, - match get_github_api::<GitRelease>("https://api.github.com/repos/dani-garcia/bw_web_builds/releases/latest") { - Ok(r) => r.tag_name.trim_start_matches('v').to_string(), - _ => "-".to_string(), + // Do not fetch the web-vault version when running within Docker. + // The web-vault version is embedded within the container it self, and should not be updated manually + if running_within_docker { + "-".to_string() + } else { + match get_github_api::<GitRelease>("https://api.github.com/repos/dani-garcia/bw_web_builds/releases/latest") { + Ok(r) => r.tag_name.trim_start_matches('v').to_string(), + _ => "-".to_string(), + } }, ) } else { diff --git a/src/config.rs b/src/config.rs @@ -876,14 +876,20 @@ fn js_escape_helper<'reg, 'rc>( .param(0) .ok_or_else(|| RenderError::new("Param not found for helper \"js_escape\""))?; + let no_quote = h + .param(1) + .is_some(); + let value = param .value() .as_str() .ok_or_else(|| RenderError::new("Param for helper \"js_escape\" is not a String"))?; - let escaped_value = value.replace('\\', "").replace('\'', "\\x22").replace('\"', "\\x27"); - let quoted_value = format!("&quot;{}&quot;", escaped_value); + let mut escaped_value = value.replace('\\', "").replace('\'', "\\x22").replace('\"', "\\x27"); + if ! no_quote { + escaped_value = format!("&quot;{}&quot;", escaped_value); + } - out.write(&quoted_value)?; + out.write(&escaped_value)?; Ok(()) } diff --git a/src/static/templates/admin/diagnostics.hbs b/src/static/templates/admin/diagnostics.hbs @@ -27,12 +27,14 @@ <dd class="col-sm-7"> <span id="web-installed">{{diagnostics.web_vault_version}}</span> </dd> + {{#unless diagnostics.running_within_docker}} <dt class="col-sm-5">Web Latest <span class="badge badge-secondary d-none" id="web-failed" title="Unable to determine latest version.">Unknown</span> </dt> <dd class="col-sm-7"> <span id="web-latest">{{diagnostics.latest_web_build}}</span> </dd> + {{/unless}} </dl> </div> </div> @@ -93,8 +95,10 @@ </dd> <dt class="col-sm-5">Domain configuration - <span class="badge badge-success d-none" id="domain-success" title="Domain variable seems to be correct.">Ok</span> - <span class="badge badge-danger d-none" id="domain-warning" title="Domain variable is not configured correctly.&#013;&#010;Some features may not work as expected!">Error</span> + <span class="badge badge-success d-none" id="domain-success" title="The domain variable matches the browser location and seems to be configured correctly.">Match</span> + <span class="badge badge-danger d-none" id="domain-warning" title="The domain variable does not matches the browsers location.&#013;&#010;The domain variable does not seem to be configured correctly.&#013;&#010;Some features may not work as expected!">No Match</span> + <span class="badge badge-success d-none" id="https-success" title="Configurued to use HTTPS">HTTPS</span> + <span class="badge badge-danger d-none" id="https-warning" title="Not configured to use HTTPS.&#013;&#010;Some features may not work as expected!">No HTTPS</span> </dt> <dd class="col-sm-7"> <span id="domain-server" class="d-block"><b>Server:</b> <span id="domain-server-string">{{diagnostics.admin_url}}</span></span> @@ -139,6 +143,7 @@ dnsCheck = false; timeCheck = false; domainCheck = false; + httpsCheck = false; (() => { // ================================ // Date & Time Check @@ -181,10 +186,12 @@ } const webInstalled = document.getElementById('web-installed').innerText; - const webLatest = document.getElementById('web-latest').innerText; - checkVersions('server', serverInstalled, serverLatest, serverLatestCommit); + + {{#unless diagnostics.running_within_docker}} + const webLatest = document.getElementById('web-latest').innerText; checkVersions('web', webInstalled, webLatest); + {{/unless}} function checkVersions(platform, installed, latest, commit=null) { if (installed === '-' || latest === '-') { @@ -238,6 +245,14 @@ } else { document.getElementById('domain-warning').classList.remove('d-none'); } + + // Check for HTTPS at domain-server-string + if (document.getElementById('domain-server-string').innerText.toLowerCase().startsWith('https://') ) { + document.getElementById('https-success').classList.remove('d-none'); + httpsCheck = true; + } else { + document.getElementById('https-warning').classList.remove('d-none'); + } })(); // ================================ @@ -253,10 +268,14 @@ supportString += "* DNS Check: " + dnsCheck + "\n"; supportString += "* Time Check: " + timeCheck + "\n"; supportString += "* Domain Configuration Check: " + domainCheck + "\n"; + supportString += "* HTTPS Check: " + httpsCheck + "\n"; supportString += "* Database type: {{ diagnostics.db_type }}\n"; {{#case diagnostics.db_type "MySQL" "PostgreSQL"}} supportString += "* Database version: [PLEASE PROVIDE DATABASE VERSION]\n"; {{/case}} + supportString += "* Clients used: \n"; + supportString += "* Reverse proxy and version: \n"; + supportString += "* Other relevant information: \n"; jsonResponse = await fetch('{{urlpath}}/admin/diagnostics/config'); configJson = await jsonResponse.json(); diff --git a/src/static/templates/admin/organizations.hbs b/src/static/templates/admin/organizations.hbs @@ -10,6 +10,7 @@ <th>Users</th> <th>Items</th> <th>Attachments</th> + <th style="width: 120px; min-width: 120px;">Actions</th> </tr> </thead> <tbody> @@ -37,6 +38,9 @@ <span class="d-block"><strong>Size:</strong> {{attachment_size}}</span> {{/if}} </td> + <td style="font-size: 90%; text-align: right; padding-right: 15px"> + <a class="d-block" href="#" onclick='deleteOrganization({{jsesc Id}}, {{jsesc Name}}, {{jsesc BillingEmail}})'>Delete Organization</a> + </td> </tr> {{/each}} </tbody> @@ -50,6 +54,25 @@ <script src="{{urlpath}}/bwrs_static/jquery-3.5.1.slim.js"></script> <script src="{{urlpath}}/bwrs_static/datatables.js"></script> <script> + function deleteOrganization(id, name, billing_email) { + // First make sure the user wants to delete this organization + var continueDelete = confirm("WARNING: All data of this organization ("+ name +") will be lost!\nMake sure you have a backup, this cannot be undone!"); + if (continueDelete == true) { + var input_org_uuid = prompt("To delete the organization '" + name + " (" + billing_email +")', please type the organization uuid below.") + if (input_org_uuid != null) { + if (input_org_uuid == id) { + _post("{{urlpath}}/admin/organizations/" + id + "/delete", + "Organization deleted correctly", + "Error deleting organization"); + } else { + alert("Wrong organization uuid, please try again") + } + } + } + + return false; + } + document.querySelectorAll("img.identicon").forEach(function (e, i) { e.src = identicon(e.dataset.src); }); @@ -59,6 +82,9 @@ "responsive": true, "lengthMenu": [ [-1, 5, 10, 25, 50], ["All", 5, 10, 25, 50] ], "pageLength": -1, // Default show all + "columnDefs": [ + { "targets": 4, "searchable": false, "orderable": false } + ] }); }); </script> \ No newline at end of file diff --git a/src/static/templates/admin/users.hbs b/src/static/templates/admin/users.hbs @@ -57,7 +57,7 @@ <td> <div class="overflow-auto" style="max-height: 120px;"> {{#each Organizations}} - <span class="badge badge-primary" data-orgtype="{{Type}}">{{Name}}</span> + <button class="badge badge-primary" data-toggle="modal" data-target="#userOrgTypeDialog" data-orgtype="{{Type}}" data-orguuid="{{jsesc Id no_quote}}" data-orgname="{{jsesc Name no_quote}}" data-useremail="{{jsesc ../Email no_quote}}" data-useruuid="{{jsesc ../Id no_quote}}">{{Name}}</button> {{/each}} </div> </td> @@ -100,6 +100,41 @@ </form> </div> </div> + + <div id="userOrgTypeDialog" class="modal fade" tabindex="-1" role="dialog" aria-hidden="true"> + <div class="modal-dialog modal-dialog-centered modal-sm"> + <div class="modal-content"> + <div class="modal-header"> + <h6 class="modal-title" id="userOrgTypeDialogTitle"></h6> + <button type="button" class="close" data-dismiss="modal" aria-label="Close"> + <span aria-hidden="true">&times;</span> + </button> + </div> + <form class="form" id="userOrgTypeForm" onsubmit="updateUserOrgType(); return false;"> + <input type="hidden" name="user_uuid" id="userOrgTypeUserUuid" value=""> + <input type="hidden" name="org_uuid" id="userOrgTypeOrgUuid" value=""> + <div class="modal-body"> + <div class="radio"> + <label><input type="radio" value="2" class="form-radio-input" name="user_type" id="userOrgTypeUser">&nbsp;User</label> + </div> + <div class="radio"> + <label><input type="radio" value="3" class="form-radio-input" name="user_type" id="userOrgTypeManager">&nbsp;Manager</label> + </div> + <div class="radio"> + <label><input type="radio" value="1" class="form-radio-input" name="user_type" id="userOrgTypeAdmin">&nbsp;Admin</label> + </div> + <div class="radio"> + <label><input type="radio" value="0" class="form-radio-input" name="user_type" id="userOrgTypeOwner">&nbsp;Owner</label> + </div> + </div> + <div class="modal-footer"> + <button type="button" class="btn btn-sm btn-secondary" data-dismiss="modal">Cancel</button> + <button type="submit" class="btn btn-sm btn-primary">Change Role</button> + </div> + </form> + </div> + </div> + </div> </main> <link rel="stylesheet" href="{{urlpath}}/bwrs_static/datatables.css" /> @@ -220,4 +255,37 @@ ] }); }); + + var userOrgTypeDialog = document.getElementById('userOrgTypeDialog'); + // Fill the form and title + userOrgTypeDialog.addEventListener('show.bs.modal', function(event){ + let userOrgType = event.relatedTarget.getAttribute("data-orgtype"); + let userOrgTypeName = OrgTypes[userOrgType]["name"]; + let orgName = event.relatedTarget.getAttribute("data-orgname"); + let userEmail = event.relatedTarget.getAttribute("data-useremail"); + let orgUuid = event.relatedTarget.getAttribute("data-orguuid"); + let userUuid = event.relatedTarget.getAttribute("data-useruuid"); + + document.getElementById("userOrgTypeDialogTitle").innerHTML = "<b>Update User Type:</b><br><b>Organization:</b> " + orgName + "<br><b>User:</b> " + userEmail; + document.getElementById("userOrgTypeUserUuid").value = userUuid; + document.getElementById("userOrgTypeOrgUuid").value = orgUuid; + document.getElementById("userOrgType"+userOrgTypeName).checked = true; + }, false); + + // Prevent accidental submission of the form with valid elements after the modal has been hidden. + userOrgTypeDialog.addEventListener('hide.bs.modal', function(event){ + document.getElementById("userOrgTypeDialogTitle").innerHTML = ''; + document.getElementById("userOrgTypeUserUuid").value = ''; + document.getElementById("userOrgTypeOrgUuid").value = ''; + }, false); + + function updateUserOrgType() { + let orgForm = document.getElementById("userOrgTypeForm"); + const data = JSON.stringify(Object.fromEntries(new FormData(orgForm).entries())); + + _post("{{urlpath}}/admin/users/org_type", + "Updated organization type of the user successfully", + "Error updating organization type of the user", data); + return false; + } </script> \ No newline at end of file