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)\nShow 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.
-
+
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
-
-
+
+
-