From 04e02d7f9fc40172ac1f93216b64d068fee3c38d Mon Sep 17 00:00:00 2001 From: BlackDex Date: Wed, 28 Dec 2022 20:05:10 +0100 Subject: [PATCH] Removed unsafe-inline JS from CSP and other fixes - Removed `unsafe-inline` for javascript from CSP. The admin interface now uses files instead of inline javascript. - Modified javascript to work not being inline. - Run eslint over javascript and fixed some items. - Added a `to_json` Handlebars helper. Used at the diagnostics page. - Changed `AdminTemplateData` struct to be smaller. The `config` was always added, but only used at one page. Same goes for `can_backup` and `version`. - Also inlined CSS. We can't remove the `unsafe-inline` from css, because that seems to break the web-vault currently. That might need some further checks. But for now the 404 page and all the admin pages are clear of inline scripts and styles. --- src/api/admin.rs | 40 ++- src/api/web.rs | 11 + src/config.rs | 15 ++ src/static/scripts/404.css | 26 ++ src/static/scripts/admin.css | 45 ++++ src/static/scripts/admin.js | 65 +++++ src/static/scripts/admin_diagnostics.js | 219 +++++++++++++++++ src/static/scripts/admin_organizations.js | 54 ++++ src/static/scripts/admin_settings.js | 180 ++++++++++++++ src/static/scripts/admin_users.js | 246 +++++++++++++++++++ src/static/scripts/bootstrap.css | 2 - src/static/templates/404.hbs | 28 +-- src/static/templates/admin/base.hbs | 96 +------- src/static/templates/admin/diagnostics.hbs | 207 +--------------- src/static/templates/admin/organizations.hbs | 45 +--- src/static/templates/admin/settings.hbs | 165 +------------ src/static/templates/admin/users.hbs | 202 +++------------ src/util.rs | 18 +- 18 files changed, 946 insertions(+), 718 deletions(-) create mode 100644 src/static/scripts/404.css create mode 100644 src/static/scripts/admin.css create mode 100644 src/static/scripts/admin.js create mode 100644 src/static/scripts/admin_diagnostics.js create mode 100644 src/static/scripts/admin_organizations.js create mode 100644 src/static/scripts/admin_settings.js create mode 100644 src/static/scripts/admin_users.js diff --git a/src/api/admin.rs b/src/api/admin.rs index 6c908bfc..fd2293d6 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -144,7 +144,6 @@ fn render_admin_login(msg: Option<&str>, redirect: Option) -> ApiResult< let msg = msg.map(|msg| format!("Error: {msg}")); let json = json!({ "page_content": "admin/login", - "version": VERSION, "error": msg, "redirect": redirect, "urlpath": CONFIG.domain_path() @@ -208,34 +207,16 @@ fn _validate_token(token: &str) -> bool { #[derive(Serialize)] struct AdminTemplateData { page_content: String, - version: Option<&'static str>, page_data: Option, - config: Value, - can_backup: bool, logged_in: bool, urlpath: String, } impl AdminTemplateData { - fn new() -> Self { - Self { - page_content: String::from("admin/settings"), - version: VERSION, - config: CONFIG.prepare_json(), - can_backup: *CAN_BACKUP, - logged_in: true, - urlpath: CONFIG.domain_path(), - page_data: None, - } - } - - fn with_data(page_content: &str, page_data: Value) -> Self { + fn new(page_content: &str, page_data: Value) -> Self { Self { page_content: String::from(page_content), - version: VERSION, page_data: Some(page_data), - config: CONFIG.prepare_json(), - can_backup: *CAN_BACKUP, logged_in: true, urlpath: CONFIG.domain_path(), } @@ -247,7 +228,11 @@ impl AdminTemplateData { } fn render_admin_page() -> ApiResult> { - let text = AdminTemplateData::new().render()?; + let settings_json = json!({ + "config": CONFIG.prepare_json(), + "can_backup": *CAN_BACKUP, + }); + let text = AdminTemplateData::new("admin/settings", settings_json).render()?; Ok(Html(text)) } @@ -342,7 +327,7 @@ async fn users_overview(_token: AdminToken, mut conn: DbConn) -> ApiResult ApiResu organizations_json.push(org); } - let text = AdminTemplateData::with_data("admin/organizations", json!(organizations_json)).render()?; + let text = AdminTemplateData::new("admin/organizations", json!(organizations_json)).render()?; Ok(Html(text)) } @@ -617,13 +602,14 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, mut conn: DbConn) let diagnostics_json = json!({ "dns_resolved": dns_resolved, + "current_release": VERSION, "latest_release": latest_release, "latest_commit": latest_commit, "web_vault_enabled": &CONFIG.web_vault_enabled(), "web_vault_version": web_vault_version.version, "latest_web_build": latest_web_build, "running_within_docker": running_within_docker, - "docker_base_image": docker_base_image(), + "docker_base_image": if running_within_docker { docker_base_image() } else { "Not applicable" }, "has_http_access": has_http_access, "ip_header_exists": &ip_header.0.is_some(), "ip_header_match": ip_header_name == CONFIG.ip_header(), @@ -634,11 +620,13 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, mut conn: DbConn) "db_version": get_sql_server_version(&mut conn).await, "admin_url": format!("{}/diagnostics", admin_url()), "overrides": &CONFIG.get_overrides().join(", "), + "host_arch": std::env::consts::ARCH, + "host_os": std::env::consts::OS, "server_time_local": Local::now().format("%Y-%m-%d %H:%M:%S %Z").to_string(), "server_time": Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(), // Run the date/time check as the last item to minimize the difference }); - let text = AdminTemplateData::with_data("admin/diagnostics", diagnostics_json).render()?; + let text = AdminTemplateData::new("admin/diagnostics", diagnostics_json).render()?; Ok(Html(text)) } diff --git a/src/api/web.rs b/src/api/web.rs index 3742a088..b8d1bb51 100644 --- a/src/api/web.rs +++ b/src/api/web.rs @@ -102,6 +102,17 @@ pub fn static_files(filename: String) -> Result<(ContentType, &'static [u8]), Er "hibp.png" => Ok((ContentType::PNG, include_bytes!("../static/images/hibp.png"))), "vaultwarden-icon.png" => Ok((ContentType::PNG, include_bytes!("../static/images/vaultwarden-icon.png"))), "vaultwarden-favicon.png" => Ok((ContentType::PNG, include_bytes!("../static/images/vaultwarden-favicon.png"))), + "404.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/404.css"))), + "admin.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/admin.css"))), + "admin.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin.js"))), + "admin_settings.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_settings.js"))), + "admin_users.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_users.js"))), + "admin_organizations.js" => { + Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_organizations.js"))) + } + "admin_diagnostics.js" => { + Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_diagnostics.js"))) + } "bootstrap.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/bootstrap.css"))), "bootstrap-native.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/bootstrap-native.js"))), "jdenticon.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/jdenticon.js"))), diff --git a/src/config.rs b/src/config.rs index add2fc5c..6db5154d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1099,6 +1099,7 @@ where // Register helpers hb.register_helper("case", Box::new(case_helper)); hb.register_helper("jsesc", Box::new(js_escape_helper)); + hb.register_helper("to_json", Box::new(to_json)); macro_rules! reg { ($name:expr) => {{ @@ -1196,3 +1197,17 @@ fn js_escape_helper<'reg, 'rc>( out.write(&escaped_value)?; Ok(()) } + +fn to_json<'reg, 'rc>( + h: &Helper<'reg, 'rc>, + _r: &'reg Handlebars<'_>, + _ctx: &'rc Context, + _rc: &mut RenderContext<'reg, 'rc>, + out: &mut dyn Output, +) -> HelperResult { + let param = h.param(0).ok_or_else(|| RenderError::new("Expected 1 parameter for \"to_json\""))?.value(); + let json = serde_json::to_string(param) + .map_err(|e| RenderError::new(format!("Can't serialize parameter to JSON: {}", e)))?; + out.write(&json)?; + Ok(()) +} diff --git a/src/static/scripts/404.css b/src/static/scripts/404.css new file mode 100644 index 00000000..c1024d2b --- /dev/null +++ b/src/static/scripts/404.css @@ -0,0 +1,26 @@ +body { + padding-top: 75px; +} +.vaultwarden-icon { + width: 48px; + height: 48px; + height: 32px; + width: auto; + margin: -5px 0 0 0; +} +.footer { + padding: 40px 0 40px 0; + border-top: 1px solid #dee2e6; +} +.container { + max-width: 980px; +} +.content { + padding-top: 20px; + padding-bottom: 20px; + padding-left: 15px; + padding-right: 15px; +} +.vw-404 { + max-width: 500px; width: 100%; +} \ No newline at end of file diff --git a/src/static/scripts/admin.css b/src/static/scripts/admin.css new file mode 100644 index 00000000..d77b5372 --- /dev/null +++ b/src/static/scripts/admin.css @@ -0,0 +1,45 @@ +body { + padding-top: 75px; +} +img { + width: 48px; + height: 48px; +} +.vaultwarden-icon { + height: 32px; + width: auto; + margin: -5px 0 0 0; +} +/* Special alert-row class to use Bootstrap v5.2+ variable colors */ +.alert-row { + --bs-alert-border: 1px solid var(--bs-alert-border-color); + color: var(--bs-alert-color); + background-color: var(--bs-alert-bg); + border: var(--bs-alert-border); +} + +#users-table .vw-created-at, #users-table .vw-last-active { + width: 85px; + min-width: 70px; +} +#users-table .vw-items { + width: 35px; + min-width: 35px; +} +#users-table .vw-organizations { + min-width: 120px; +} +#users-table .vw-actions, #orgs-table .vw-actions { + width: 130px; + min-width: 130px; +} +#users-table .vw-org-cell { + max-height: 120px; +} + +#support-string { + height: 16rem; +} +.vw-copy-toast { + width: 15rem; +} \ No newline at end of file diff --git a/src/static/scripts/admin.js b/src/static/scripts/admin.js new file mode 100644 index 00000000..7849ac19 --- /dev/null +++ b/src/static/scripts/admin.js @@ -0,0 +1,65 @@ +"use strict"; + +function getBaseUrl() { + // If the base URL is `https://vaultwarden.example.com/base/path/`, + // `window.location.href` should have one of the following forms: + // + // - `https://vaultwarden.example.com/base/path/` + // - `https://vaultwarden.example.com/base/path/#/some/route[?queryParam=...]` + // + // We want to get to just `https://vaultwarden.example.com/base/path`. + const baseUrl = window.location.href; + const adminPos = baseUrl.indexOf("/admin"); + return baseUrl.substring(0, adminPos != -1 ? adminPos : baseUrl.length); +} +const BASE_URL = getBaseUrl(); + +function reload() { + // Reload the page by setting the exact same href + // Using window.location.reload() could cause a repost. + window.location = window.location.href; +} + +function msg(text, reload_page = true) { + text && alert(text); + reload_page && reload(); +} + +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}); } + const respStatus = resp.status; + const 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); } + }); +} + +// onLoad events +document.addEventListener("DOMContentLoaded", (/*event*/) => { + // get current URL path and assign "active" class to the correct nav-item + const pathname = window.location.pathname; + if (pathname === "") return; + const navItem = document.querySelectorAll(`.navbar-nav .nav-item a[href="${pathname}"]`); + if (navItem.length === 1) { + navItem[0].className = navItem[0].className + " active"; + navItem[0].setAttribute("aria-current", "page"); + } +}); \ No newline at end of file diff --git a/src/static/scripts/admin_diagnostics.js b/src/static/scripts/admin_diagnostics.js new file mode 100644 index 00000000..84a7ecc5 --- /dev/null +++ b/src/static/scripts/admin_diagnostics.js @@ -0,0 +1,219 @@ +"use strict"; + +var dnsCheck = false; +var timeCheck = false; +var domainCheck = false; +var httpsCheck = false; + +// ================================ +// Date & Time Check +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} UTC`; + +// ================================ +// 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); + +function checkVersions(platform, installed, latest, commit=null) { + if (installed === "-" || latest === "-") { + document.getElementById(`${platform}-failed`).classList.remove("d-none"); + return; + } + + // Only check basic versions, no commit revisions + if (commit === null || installed.indexOf("-") === -1) { + if (installed !== latest) { + document.getElementById(`${platform}-warning`).classList.remove("d-none"); + } else { + document.getElementById(`${platform}-success`).classList.remove("d-none"); + } + } else { + // Check if this is a branched version. + const branchRegex = /(?:\s)\((.*?)\)/; + const branchMatch = installed.match(branchRegex); + if (branchMatch !== null) { + document.getElementById(`${platform}-branch`).classList.remove("d-none"); + } + + // This will remove branch info and check if there is a commit hash + const installedRegex = /(\d+\.\d+\.\d+)-(\w+)/; + const instMatch = installed.match(installedRegex); + + // It could be that a new tagged version has the same commit hash. + // In this case the version is the same but only the number is different + if (instMatch !== null) { + if (instMatch[2] === commit) { + // The commit hashes are the same, so latest version is installed + document.getElementById(`${platform}-success`).classList.remove("d-none"); + return; + } + } + + if (installed === latest) { + document.getElementById(`${platform}-success`).classList.remove("d-none"); + } else { + document.getElementById(`${platform}-warning`).classList.remove("d-none"); + } + } +} + +// ================================ +// Generate support string to be pasted on github or the forum +async function generateSupportString(dj) { + event.preventDefault(); + event.stopPropagation(); + + let supportString = "### Your environment (Generated via diagnostics page)\n"; + + supportString += `* Vaultwarden version: v${dj.current_release}\n`; + supportString += `* Web-vault version: v${dj.web_vault_version}\n`; + supportString += `* OS/Arch: ${dj.host_os}/${dj.host_arch}\n`; + supportString += `* Running within Docker: ${dj.running_within_docker} (Base: ${dj.docker_base_image})\n`; + supportString += "* Environment settings overridden: "; + if (dj.overrides != "") { + supportString += "true\n"; + } else { + supportString += "false\n"; + } + supportString += `* Uses a reverse proxy: ${dj.ip_header_exists}\n`; + if (dj.ip_header_exists) { + supportString += `* IP Header check: ${dj.ip_header_match} (${dj.ip_header_name})\n`; + } + supportString += `* Internet access: ${dj.has_http_access}\n`; + supportString += `* Internet access via a proxy: ${dj.uses_proxy}\n`; + supportString += `* DNS Check: ${dnsCheck}\n`; + supportString += `* Time Check: ${timeCheck}\n`; + supportString += `* Domain Configuration Check: ${domainCheck}\n`; + supportString += `* HTTPS Check: ${httpsCheck}\n`; + supportString += `* Database type: ${dj.db_type}\n`; + supportString += `* Database version: ${dj.db_version}\n`; + supportString += "* Clients used: \n"; + supportString += "* Reverse proxy and version: \n"; + supportString += "* Other relevant information: \n"; + + const jsonResponse = await fetch(`${BASE_URL}/admin/diagnostics/config`, { + "headers": { "Accept": "application/json" } + }); + if (!jsonResponse.ok) { + alert("Generation failed: " + jsonResponse.statusText); + throw new Error(jsonResponse); + } + const configJson = await jsonResponse.json(); + supportString += "\n### Config (Generated via diagnostics page)\n
Show Running Config\n"; + supportString += `\n**Environment settings which are overridden:** ${dj.overrides}\n`; + supportString += "\n\n```json\n" + JSON.stringify(configJson, undefined, 2) + "\n```\n
\n"; + + document.getElementById("support-string").innerText = supportString; + document.getElementById("support-string").classList.remove("d-none"); + document.getElementById("copy-support").classList.remove("d-none"); +} + +function copyToClipboard() { + event.preventDefault(); + event.stopPropagation(); + + const supportStr = document.getElementById("support-string").innerText; + const tmpCopyEl = document.createElement("textarea"); + + tmpCopyEl.setAttribute("id", "copy-support-string"); + tmpCopyEl.setAttribute("readonly", ""); + tmpCopyEl.value = supportStr; + tmpCopyEl.style.position = "absolute"; + tmpCopyEl.style.left = "-9999px"; + document.body.appendChild(tmpCopyEl); + tmpCopyEl.select(); + document.execCommand("copy"); + tmpCopyEl.remove(); + + new BSN.Toast("#toastClipboardCopy").show(); +} + +function checkTimeDrift(browserUTC, serverUTC) { + const timeDrift = ( + Date.parse(serverUTC.replace(" ", "T").replace(" UTC", "")) - + Date.parse(browserUTC.replace(" ", "T").replace(" UTC", "")) + ) / 1000; + if (timeDrift > 20 || timeDrift < -20) { + document.getElementById("time-warning").classList.remove("d-none"); + } else { + document.getElementById("time-success").classList.remove("d-none"); + timeCheck = true; + } +} + +function checkDomain(browserURL, serverURL) { + if (serverURL == browserURL) { + document.getElementById("domain-success").classList.remove("d-none"); + domainCheck = true; + } else { + document.getElementById("domain-warning").classList.remove("d-none"); + } + + // Check for HTTPS at domain-server-string + if (serverURL.startsWith("https://") ) { + document.getElementById("https-success").classList.remove("d-none"); + httpsCheck = true; + } else { + document.getElementById("https-warning").classList.remove("d-none"); + } +} + +function initVersionCheck(dj) { + const serverInstalled = dj.current_release; + const serverLatest = dj.latest_release; + const serverLatestCommit = dj.latest_commit; + + if (serverInstalled.indexOf("-") !== -1 && serverLatest !== "-" && serverLatestCommit !== "-") { + document.getElementById("server-latest-commit").classList.remove("d-none"); + } + checkVersions("server", serverInstalled, serverLatest, serverLatestCommit); + + if (!dj.running_within_docker) { + const webInstalled = dj.web_vault_version; + const webLatest = dj.latest_web_build; + checkVersions("web", webInstalled, webLatest); + } +} + +function checkDns(dns_resolved) { + if (isValidIp(dns_resolved)) { + document.getElementById("dns-success").classList.remove("d-none"); + dnsCheck = true; + } else { + document.getElementById("dns-warning").classList.remove("d-none"); + } +} + +function init(dj) { + // Time check + document.getElementById("time-browser-string").innerText = browserUTC; + checkTimeDrift(browserUTC, dj.server_time); + + // Domain check + const browserURL = location.href.toLowerCase(); + document.getElementById("domain-browser-string").innerText = browserURL; + checkDomain(browserURL, dj.admin_url.toLowerCase()); + + // Version check + initVersionCheck(dj); + + // DNS Check + checkDns(dj.dns_resolved); +} + +// onLoad events +document.addEventListener("DOMContentLoaded", (/*event*/) => { + const diag_json = JSON.parse(document.getElementById("diagnostics_json").innerText); + init(diag_json); + + document.getElementById("gen-support").addEventListener("click", () => { + generateSupportString(diag_json); + }); + document.getElementById("copy-support").addEventListener("click", copyToClipboard); +}); \ No newline at end of file diff --git a/src/static/scripts/admin_organizations.js b/src/static/scripts/admin_organizations.js new file mode 100644 index 00000000..ae15e2fd --- /dev/null +++ b/src/static/scripts/admin_organizations.js @@ -0,0 +1,54 @@ +"use strict"; + +function deleteOrganization() { + event.preventDefault(); + event.stopPropagation(); + const org_uuid = event.target.dataset.vwOrgUuid; + const org_name = event.target.dataset.vwOrgName; + const billing_email = event.target.dataset.vwBillingEmail; + if (!org_uuid) { + alert("Required parameters not found!"); + return false; + } + + // First make sure the user wants to delete this organization + const continueDelete = confirm(`WARNING: All data of this organization (${org_name}) will be lost!\nMake sure you have a backup, this cannot be undone!`); + if (continueDelete == true) { + const input_org_uuid = prompt(`To delete the organization "${org_name} (${billing_email})", please type the organization uuid below.`); + if (input_org_uuid != null) { + if (input_org_uuid == org_uuid) { + _post(`${BASE_URL}/admin/organizations/${org_uuid}/delete`, + "Organization deleted correctly", + "Error deleting organization" + ); + } else { + alert("Wrong organization uuid, please try again"); + } + } + } +} + +// onLoad events +document.addEventListener("DOMContentLoaded", (/*event*/) => { + jQuery("#orgs-table").DataTable({ + "stateSave": true, + "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 + }] + }); + + // Add click events for organization actions + document.querySelectorAll("button[vw-delete-organization]").forEach(btn => { + btn.addEventListener("click", deleteOrganization); + }); + + document.getElementById("reload").addEventListener("click", reload); +}); \ No newline at end of file diff --git a/src/static/scripts/admin_settings.js b/src/static/scripts/admin_settings.js new file mode 100644 index 00000000..4f248cbd --- /dev/null +++ b/src/static/scripts/admin_settings.js @@ -0,0 +1,180 @@ +"use strict"; + +function smtpTest() { + event.preventDefault(); + event.stopPropagation(); + if (formHasChanges(config_form)) { + alert("Config has been changed but not yet saved.\nPlease save the changes first before sending a test email."); + return false; + } + + const test_email = document.getElementById("smtp-test-email"); + + // Do a very very basic email address check. + if (test_email.value.match(/\S+@\S+/i) === null) { + test_email.parentElement.classList.add("was-validated"); + return false; + } + + const data = JSON.stringify({ "email": test_email.value }); + _post(`${BASE_URL}/admin/test/smtp/`, + "SMTP Test email sent correctly", + "Error sending SMTP test email", + data, false + ); +} + +function getFormData() { + let data = {}; + + document.querySelectorAll(".conf-checkbox").forEach(function (e) { + data[e.name] = e.checked; + }); + + document.querySelectorAll(".conf-number").forEach(function (e) { + data[e.name] = e.value ? +e.value : null; + }); + + document.querySelectorAll(".conf-text, .conf-password").forEach(function (e) { + data[e.name] = e.value || null; + }); + return data; +} + +function saveConfig() { + const data = JSON.stringify(getFormData()); + _post(`${BASE_URL}/admin/config/`, + "Config saved correctly", + "Error saving config", + data + ); + event.preventDefault(); +} + +function deleteConf() { + event.preventDefault(); + event.stopPropagation(); + const 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(`${BASE_URL}/admin/config/delete`, + "Config deleted correctly", + "Error deleting config" + ); + } else { + alert("Wrong input, please try again"); + } +} + +function backupDatabase() { + event.preventDefault(); + event.stopPropagation(); + _post(`${BASE_URL}/admin/config/backup_db`, + "Backup created successfully", + "Error creating backup", null, false + ); +} + +// Two functions to help check if there were changes to the form fields +// Useful for example during the smtp test to prevent people from clicking save before testing there new settings +function initChangeDetection(form) { + const ignore_fields = ["smtp-test-email"]; + Array.from(form).forEach((el) => { + if (! ignore_fields.includes(el.id)) { + el.dataset.origValue = el.value; + } + }); +} + +function formHasChanges(form) { + return Array.from(form).some(el => "origValue" in el.dataset && ( el.dataset.origValue !== el.value)); +} + +// This function will prevent submitting a from when someone presses enter. +function preventFormSubmitOnEnter(form) { + form.onkeypress = function(e) { + const key = e.charCode || e.keyCode || 0; + if (key == 13) { + e.preventDefault(); + } + }; +} + +// This function will hook into the smtp-test-email input field and will call the smtpTest() function when enter is pressed. +function submitTestEmailOnEnter() { + const smtp_test_email_input = document.getElementById("smtp-test-email"); + smtp_test_email_input.onkeypress = function(e) { + const key = e.charCode || e.keyCode || 0; + if (key == 13) { + e.preventDefault(); + smtpTest(); + } + }; +} + +// Colorize some settings which are high risk +function colorRiskSettings() { + const risk_items = document.getElementsByClassName("col-form-label"); + Array.from(risk_items).forEach((el) => { + if (el.innerText.toLowerCase().includes("risks") ) { + el.parentElement.className += " alert-danger"; + } + }); +} + +function toggleVis(evt) { + event.preventDefault(); + event.stopPropagation(); + + const elem = document.getElementById(evt.target.dataset.vwPwToggle); + const type = elem.getAttribute("type"); + if (type === "text") { + elem.setAttribute("type", "password"); + } else { + elem.setAttribute("type", "text"); + } +} + +function masterCheck(check_id, inputs_query) { + function onChanged(checkbox, inputs_query) { + return function _fn() { + document.querySelectorAll(inputs_query).forEach(function (e) { 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); +} + +const config_form = document.getElementById("config-form"); + +// onLoad events +document.addEventListener("DOMContentLoaded", (/*event*/) => { + initChangeDetection(config_form); + // Prevent enter to submitting the form and save the config. + // Users need to really click on save, this also to prevent accidental submits. + preventFormSubmitOnEnter(config_form); + + submitTestEmailOnEnter(); + colorRiskSettings(); + + document.querySelectorAll("input[id^='input__enable_']").forEach(group_toggle => { + const input_id = group_toggle.id.replace("input__enable_", "#g_"); + masterCheck(group_toggle.id, `${input_id} input`); + }); + + document.querySelectorAll("button[data-vw-pw-toggle]").forEach(password_toggle_btn => { + password_toggle_btn.addEventListener("click", toggleVis); + }); + + document.getElementById("backupDatabase").addEventListener("click", backupDatabase); + document.getElementById("deleteConf").addEventListener("click", deleteConf); + document.getElementById("smtpTest").addEventListener("click", smtpTest); + + config_form.addEventListener("submit", saveConfig); +}); \ No newline at end of file diff --git a/src/static/scripts/admin_users.js b/src/static/scripts/admin_users.js new file mode 100644 index 00000000..8f7ddf20 --- /dev/null +++ b/src/static/scripts/admin_users.js @@ -0,0 +1,246 @@ +"use strict"; + +function deleteUser() { + event.preventDefault(); + event.stopPropagation(); + const id = event.target.parentNode.dataset.vwUserUuid; + const email = event.target.parentNode.dataset.vwUserEmail; + if (!id || !email) { + alert("Required parameters not found!"); + return false; + } + const input_email = prompt(`To delete user "${email}", please type the email below`); + if (input_email != null) { + if (input_email == email) { + _post(`${BASE_URL}/admin/users/${id}/delete`, + "User deleted correctly", + "Error deleting user" + ); + } else { + alert("Wrong email, please try again"); + } + } +} + +function remove2fa() { + event.preventDefault(); + event.stopPropagation(); + const id = event.target.parentNode.dataset.vwUserUuid; + if (!id) { + alert("Required parameters not found!"); + return false; + } + _post(`${BASE_URL}/admin/users/${id}/remove-2fa`, + "2FA removed correctly", + "Error removing 2FA" + ); +} + +function deauthUser() { + event.preventDefault(); + event.stopPropagation(); + const id = event.target.parentNode.dataset.vwUserUuid; + if (!id) { + alert("Required parameters not found!"); + return false; + } + _post(`${BASE_URL}/admin/users/${id}/deauth`, + "Sessions deauthorized correctly", + "Error deauthorizing sessions" + ); +} + +function disableUser() { + event.preventDefault(); + event.stopPropagation(); + const id = event.target.parentNode.dataset.vwUserUuid; + const email = event.target.parentNode.dataset.vwUserEmail; + if (!id || !email) { + alert("Required parameters not found!"); + return false; + } + const confirmed = confirm(`Are you sure you want to disable user "${email}"? This will also deauthorize their sessions.`); + if (confirmed) { + _post(`${BASE_URL}/admin/users/${id}/disable`, + "User disabled successfully", + "Error disabling user" + ); + } +} + +function enableUser() { + event.preventDefault(); + event.stopPropagation(); + const id = event.target.parentNode.dataset.vwUserUuid; + const email = event.target.parentNode.dataset.vwUserEmail; + if (!id || !email) { + alert("Required parameters not found!"); + return false; + } + const confirmed = confirm(`Are you sure you want to enable user "${email}"?`); + if (confirmed) { + _post(`${BASE_URL}/admin/users/${id}/enable`, + "User enabled successfully", + "Error enabling user" + ); + } +} + +function updateRevisions() { + event.preventDefault(); + event.stopPropagation(); + _post(`${BASE_URL}/admin/users/update_revision`, + "Success, clients will sync next time they connect", + "Error forcing clients to sync" + ); +} + +function inviteUser() { + event.preventDefault(); + event.stopPropagation(); + const email = document.getElementById("inviteEmail"); + const data = JSON.stringify({ + "email": email.value + }); + email.value = ""; + _post(`${BASE_URL}/admin/invite/`, + "User invited correctly", + "Error inviting user", + data + ); +} + +const ORG_TYPES = { + "0": { + "name": "Owner", + "color": "orange" + }, + "1": { + "name": "Admin", + "color": "blueviolet" + }, + "2": { + "name": "User", + "color": "blue" + }, + "3": { + "name": "Manager", + "color": "green" + }, +}; + +// Special sort function to sort dates in ISO format +jQuery.extend(jQuery.fn.dataTableExt.oSort, { + "date-iso-pre": function(a) { + let x; + const sortDate = a.replace(/(<([^>]+)>)/gi, "").trim(); + if (sortDate !== "") { + const dtParts = sortDate.split(" "); + const timeParts = (undefined != dtParts[1]) ? dtParts[1].split(":") : ["00", "00", "00"]; + const dateParts = dtParts[0].split("-"); + x = (dateParts[0] + dateParts[1] + dateParts[2] + timeParts[0] + timeParts[1] + ((undefined != timeParts[2]) ? timeParts[2] : 0)) * 1; + if (isNaN(x)) { + x = 0; + } + } else { + x = Infinity; + } + return x; + }, + + "date-iso-asc": function(a, b) { + return a - b; + }, + + "date-iso-desc": function(a, b) { + return b - a; + } +}); + +const userOrgTypeDialog = document.getElementById("userOrgTypeDialog"); +// Fill the form and title +userOrgTypeDialog.addEventListener("show.bs.modal", function(event) { + // Get shared values + const userEmail = event.relatedTarget.parentNode.dataset.vwUserEmail; + const userUuid = event.relatedTarget.parentNode.dataset.vwUserUuid; + // Get org specific values + const userOrgType = event.relatedTarget.dataset.vwOrgType; + const userOrgTypeName = ORG_TYPES[userOrgType]["name"]; + const orgName = event.relatedTarget.dataset.vwOrgName; + const orgUuid = event.relatedTarget.dataset.vwOrgUuid; + + document.getElementById("userOrgTypeDialogTitle").innerHTML = `Update User Type:
Organization: ${orgName}
User: ${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() { + document.getElementById("userOrgTypeDialogTitle").innerHTML = ""; + document.getElementById("userOrgTypeUserUuid").value = ""; + document.getElementById("userOrgTypeOrgUuid").value = ""; +}, false); + +function updateUserOrgType() { + event.preventDefault(); + event.stopPropagation(); + + const data = JSON.stringify(Object.fromEntries(new FormData(event.target).entries())); + + _post(`${BASE_URL}/admin/users/org_type`, + "Updated organization type of the user successfully", + "Error updating organization type of the user", + data + ); +} + +// onLoad events +document.addEventListener("DOMContentLoaded", (/*event*/) => { + jQuery("#users-table").DataTable({ + "stateSave": true, + "responsive": true, + "lengthMenu": [ + [-1, 5, 10, 25, 50], + ["All", 5, 10, 25, 50] + ], + "pageLength": -1, // Default show all + "columnDefs": [{ + "targets": [1, 2], + "type": "date-iso" + }, { + "targets": 6, + "searchable": false, + "orderable": false + }] + }); + + // Color all the org buttons per type + document.querySelectorAll("button[data-vw-org-type]").forEach(function(e) { + const orgType = ORG_TYPES[e.dataset.vwOrgType]; + e.style.backgroundColor = orgType.color; + e.title = orgType.name; + }); + + // Add click events for user actions + document.querySelectorAll("button[vw-remove2fa]").forEach(btn => { + btn.addEventListener("click", remove2fa); + }); + document.querySelectorAll("button[vw-deauth-user]").forEach(btn => { + btn.addEventListener("click", deauthUser); + }); + document.querySelectorAll("button[vw-delete-user]").forEach(btn => { + btn.addEventListener("click", deleteUser); + }); + document.querySelectorAll("button[vw-disable-user]").forEach(btn => { + btn.addEventListener("click", disableUser); + }); + document.querySelectorAll("button[vw-enable-user]").forEach(btn => { + btn.addEventListener("click", enableUser); + }); + + document.getElementById("updateRevisions").addEventListener("click", updateRevisions); + document.getElementById("reload").addEventListener("click", reload); + document.getElementById("userOrgTypeForm").addEventListener("submit", updateUserOrgType); + document.getElementById("inviteUserForm").addEventListener("submit", inviteUser); +}); \ No newline at end of file diff --git a/src/static/scripts/bootstrap.css b/src/static/scripts/bootstrap.css index fa2da29b..614c226f 100644 --- a/src/static/scripts/bootstrap.css +++ b/src/static/scripts/bootstrap.css @@ -10874,5 +10874,3 @@ textarea.form-control-lg { display: none !important; } } - -/*# sourceMappingURL=bootstrap.css.map */ \ No newline at end of file diff --git a/src/static/templates/404.hbs b/src/static/templates/404.hbs index 230c30ca..064dc5a1 100644 --- a/src/static/templates/404.hbs +++ b/src/static/templates/404.hbs @@ -7,31 +7,7 @@ Page not found! - + @@ -53,7 +29,7 @@

Page not found!

Sorry, but the page you were looking for could not be found.

- Return to the web vault?

+ Return to the web vault?

You can return to the web-vault, or contact us.

diff --git a/src/static/templates/admin/base.hbs b/src/static/templates/admin/base.hbs index ba3e88d8..9b033b16 100644 --- a/src/static/templates/admin/base.hbs +++ b/src/static/templates/admin/base.hbs @@ -7,86 +7,9 @@ Vaultwarden Admin Panel - - + + -