mirror of
https://github.com/dani-garcia/vaultwarden
synced 2024-12-13 17:22:58 +01:00
Redesign of the admin interface.
Main changes: - Splitted up settings and users into two separate pages. - Added verified shield when the e-mail address has been verified. - Added the amount of personal items in the database to the users overview. - Added Organizations and Diagnostics pages. - Shows if DNS resolving works. - Shows if there is a posible time drift. - Shows current versions of server and web-vault. - Optimized logo-gray.png using optipng Items which can be added later: - Amount of cipher items accessible for a user, not only his personal items. - Amount of users per Org - Version update check in the diagnostics overview. - Copy/Pasteable runtime config which has sensitive data changed or removed for support questions either on the forum or github issues. - Option to delete Orgs and all its passwords (when there are no members anymore). - Etc....
This commit is contained in:
parent
4146612a32
commit
3c66deb5cc
12 changed files with 453 additions and 183 deletions
128
src/api/admin.rs
128
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();
|
||||
|
|
|
@ -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"))),
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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 |
BIN
src/static/images/shield-white.png
Normal file
BIN
src/static/images/shield-white.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.9 KiB |
|
@ -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>
|
73
src/static/templates/admin/diagnostics.hbs
Normal file
73
src/static/templates/admin/diagnostics.hbs
Normal file
|
@ -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>
|
30
src/static/templates/admin/organizations.hbs
Normal file
30
src/static/templates/admin/organizations.hbs
Normal 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>
|
|
@ -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}}
|
134
src/static/templates/admin/users.hbs
Normal file
134
src/static/templates/admin/users.hbs
Normal 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>
|
Loading…
Reference in a new issue