Merge pull request #1007 from BlackDex/admin-interface

Admin interface restyle
This commit is contained in:
Daniel García 2020-05-28 20:54:11 +02:00 committed by GitHub
commit 4eee6e7aee
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 539 additions and 183 deletions

View File

@ -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,92 @@ 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,
}
fn get_github_api(url: &str) -> Result<Value, Error> {
use reqwest::{header::USER_AGENT, blocking::Client};
let github_api = Client::builder().build()?;
let res = github_api
.get(url)
.header(USER_AGENT, "Bitwarden_RS")
.send()?;
let res_status = res.status();
if res_status != 200 {
error!("Could not retrieve '{}', response code: {}", url, res_status);
}
let value: Value = res.error_for_status()?.json()?;
Ok(value)
}
#[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 bitwarden_rs_releases = get_github_api("https://api.github.com/repos/dani-garcia/bitwarden_rs/releases/latest");
let latest_release = match &bitwarden_rs_releases {
Ok(j) => j["tag_name"].as_str().unwrap(),
_ => "-",
};
let bitwarden_rs_commits = get_github_api("https://api.github.com/repos/dani-garcia/bitwarden_rs/commits/master");
let mut latest_commit = match &bitwarden_rs_commits {
Ok(j) => j["sha"].as_str().unwrap(),
_ => "-",
};
if latest_commit.len() >= 8 {
latest_commit = &latest_commit[..8];
}
let bw_web_builds_releases = get_github_api("https://api.github.com/repos/dani-garcia/bw_web_builds/releases/latest");
let latest_web_build = match &bw_web_builds_releases {
Ok(j) => j["tag_name"].as_str().unwrap(),
_ => "-",
};
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,
"latest_release": latest_release,
"latest_commit": latest_commit,
"latest_web_build": latest_web_build.replace("v", ""),
});
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();

View File

@ -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"))),

View File

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

View File

@ -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))

View File

@ -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 {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

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

View File

@ -0,0 +1,117 @@
<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
<span class="badge badge-success d-none" id="server-success" title="Latest version is installed.">Ok</span>
<span class="badge badge-warning d-none" id="server-warning" title="There seems to be an update available.">Update</span>
<span class="badge badge-danger d-none" id="server-failed" title="Unable to determine latest version.">Unknown</span>
</dt>
<dd class="col-sm-7">
<span id="server-installed">{{version}}</span>
</dd>
<dt class="col-sm-5">Server Latest</dt>
<dd class="col-sm-7">
<span id="server-latest">{{diagnostics.latest_release}}<span id="server-latest-commit" class="d-none">-{{diagnostics.latest_commit}}</span></span>
</dd>
<dt class="col-sm-5">Web Installed
<span class="badge badge-success d-none" id="web-success" title="Latest version is installed.">Ok</span>
<span class="badge badge-warning d-none" id="web-warning" title="There seems to be an update available.">Update</span>
<span class="badge badge-danger d-none" id="web-failed" title="Unable to determine latest version.">Unknown</span>
</dt>
<dd class="col-sm-7">
<span id="web-installed">{{diagnostics.web_vault_version}}</span>
</dd>
<dt class="col-sm-5">Web Latest</dt>
<dd class="col-sm-7">
<span id="web-latest">{{diagnostics.latest_web_build}}</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');
}
let serverInstalled = document.getElementById('server-installed').innerText;
let serverLatest = document.getElementById('server-latest').innerText;
if (serverInstalled.indexOf('-') > -1 && serverLatest !== '-') {
document.getElementById('server-latest-commit').classList.remove('d-none');
serverLatest += document.getElementById('server-latest-commit').innerText;
}
const webInstalled = document.getElementById('web-installed').innerText;
const webLatest = document.getElementById('web-latest').innerText;
checkVersions('server', serverInstalled, serverLatest);
checkVersions('web', webInstalled, webLatest);
function checkVersions(platform, installed, latest) {
if (installed === '-' || latest === '-') {
document.getElementById(platform + '-failed').classList.remove('d-none');
return;
}
if (installed !== latest) {
document.getElementById(platform + '-warning').classList.remove('d-none');
} else {
document.getElementById(platform + '-success').classList.remove('d-none');
}
}
})();
</script>

View File

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

View File

@ -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}}

View File

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