diff --git a/src/api/admin.rs b/src/api/admin.rs index dcafaa06..8678e0d9 100644 --- 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 index 7f47ae7c..a97a1c96 100644 --- 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 index 9434c39a..e5b46d37 100644 --- a/src/config.rs +++ b/src/config.rs @@ -700,7 +700,10 @@ where reg!("admin/base"); reg!("admin/login"); - reg!("admin/page"); + reg!("admin/settings"); + reg!("admin/users"); + reg!("admin/organizations"); + reg!("admin/diagnostics"); // And then load user templates to overwrite the defaults // Use .hbs extension for the files diff --git a/src/db/models/cipher.rs b/src/db/models/cipher.rs index 1e717ca4..94d7d1ec 100644 --- 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 index 442d1969..8ce476c6 100644 --- 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 index b045df54..70658e18 100644 Binary files a/src/static/images/logo-gray.png and b/src/static/images/logo-gray.png differ diff --git a/src/static/images/shield-white.png b/src/static/images/shield-white.png new file mode 100644 index 00000000..3400efe7 Binary files /dev/null and b/src/static/images/shield-white.png differ diff --git a/src/static/templates/admin/base.hbs b/src/static/templates/admin/base.hbs index e3948d06..a7270b9c 100644 --- a/src/static/templates/admin/base.hbs +++ b/src/static/templates/admin/base.hbs @@ -29,16 +29,79 @@ width: 48px; height: 48px; } + + .navbar img { + height: 24px; + width: auto; + } </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 new file mode 100644 index 00000000..dbf82c1e --- /dev/null +++ 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 new file mode 100644 index 00000000..6dbcbbbf --- /dev/null +++ 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/settings.hbs similarity index 60% rename from src/static/templates/admin/page.hbs rename to src/static/templates/admin/settings.hbs index 0560bde5..eb3c74e3 100644 --- a/src/static/templates/admin/page.hbs +++ b/src/static/templates/admin/settings.hbs @@ -1,68 +1,4 @@ <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> @@ -202,90 +138,6 @@ </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 }); @@ -348,23 +200,6 @@ 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}} diff --git a/src/static/templates/admin/users.hbs b/src/static/templates/admin/users.hbs new file mode 100644 index 00000000..d3e12f78 --- /dev/null +++ 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