mirror of
https://github.com/dani-garcia/vaultwarden
synced 2024-11-26 13:50:21 +00:00
Merge pull request #1007 from BlackDex/admin-interface
Admin interface restyle
This commit is contained in:
commit
4eee6e7aee
12 changed files with 539 additions and 183 deletions
170
src/api/admin.rs
170
src/api/admin.rs
|
@ -23,7 +23,7 @@ pub fn routes() -> Vec<Route> {
|
||||||
|
|
||||||
routes![
|
routes![
|
||||||
admin_login,
|
admin_login,
|
||||||
get_users,
|
get_users_json,
|
||||||
post_admin_login,
|
post_admin_login,
|
||||||
admin_page,
|
admin_page,
|
||||||
invite_user,
|
invite_user,
|
||||||
|
@ -36,6 +36,9 @@ pub fn routes() -> Vec<Route> {
|
||||||
delete_config,
|
delete_config,
|
||||||
backup_db,
|
backup_db,
|
||||||
test_smtp,
|
test_smtp,
|
||||||
|
users_overview,
|
||||||
|
organizations_overview,
|
||||||
|
diagnostics,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -118,7 +121,9 @@ fn _validate_token(token: &str) -> bool {
|
||||||
struct AdminTemplateData {
|
struct AdminTemplateData {
|
||||||
page_content: String,
|
page_content: String,
|
||||||
version: Option<&'static str>,
|
version: Option<&'static str>,
|
||||||
users: Vec<Value>,
|
users: Option<Vec<Value>>,
|
||||||
|
organizations: Option<Vec<Value>>,
|
||||||
|
diagnostics: Option<Value>,
|
||||||
config: Value,
|
config: Value,
|
||||||
can_backup: bool,
|
can_backup: bool,
|
||||||
logged_in: bool,
|
logged_in: bool,
|
||||||
|
@ -126,15 +131,59 @@ struct AdminTemplateData {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AdminTemplateData {
|
impl AdminTemplateData {
|
||||||
fn new(users: Vec<Value>) -> Self {
|
fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
page_content: String::from("admin/page"),
|
page_content: String::from("admin/settings"),
|
||||||
version: VERSION,
|
version: VERSION,
|
||||||
users,
|
|
||||||
config: CONFIG.prepare_json(),
|
config: CONFIG.prepare_json(),
|
||||||
can_backup: *CAN_BACKUP,
|
can_backup: *CAN_BACKUP,
|
||||||
logged_in: true,
|
logged_in: true,
|
||||||
urlpath: CONFIG.domain_path(),
|
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)]
|
#[get("/", rank = 1)]
|
||||||
fn admin_page(_token: AdminToken, conn: DbConn) -> ApiResult<Html<String>> {
|
fn admin_page(_token: AdminToken, _conn: DbConn) -> ApiResult<Html<String>> {
|
||||||
let users = User::get_all(&conn);
|
let text = AdminTemplateData::new().render()?;
|
||||||
let users_json: Vec<Value> = users.iter().map(|u| u.to_json(&conn)).collect();
|
|
||||||
|
|
||||||
let text = AdminTemplateData::new(users_json).render()?;
|
|
||||||
Ok(Html(text))
|
Ok(Html(text))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -195,13 +241,29 @@ fn logout(mut cookies: Cookies) -> Result<Redirect, ()> {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/users")]
|
#[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 = User::get_all(&conn);
|
||||||
let users_json: Vec<Value> = users.iter().map(|u| u.to_json(&conn)).collect();
|
let users_json: Vec<Value> = users.iter().map(|u| u.to_json(&conn)).collect();
|
||||||
|
|
||||||
Ok(Json(Value::Array(users_json)))
|
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")]
|
#[post("/users/<uuid>/delete")]
|
||||||
fn delete_user(uuid: String, _token: AdminToken, conn: DbConn) -> EmptyResult {
|
fn delete_user(uuid: String, _token: AdminToken, conn: DbConn) -> EmptyResult {
|
||||||
let user = match User::find_by_uuid(&uuid, &conn) {
|
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)
|
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>")]
|
#[post("/config", data = "<data>")]
|
||||||
fn post_config(data: Json<ConfigBuilder>, _token: AdminToken) -> EmptyResult {
|
fn post_config(data: Json<ConfigBuilder>, _token: AdminToken) -> EmptyResult {
|
||||||
let data: ConfigBuilder = data.into_inner();
|
let data: ConfigBuilder = data.into_inner();
|
||||||
|
|
|
@ -78,6 +78,7 @@ fn static_files(filename: String) -> Result<Content<&'static [u8]>, Error> {
|
||||||
match filename.as_ref() {
|
match filename.as_ref() {
|
||||||
"mail-github.png" => Ok(Content(ContentType::PNG, include_bytes!("../static/images/mail-github.png"))),
|
"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"))),
|
"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"))),
|
"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"))),
|
"hibp.png" => Ok(Content(ContentType::PNG, include_bytes!("../static/images/hibp.png"))),
|
||||||
|
|
||||||
|
|
|
@ -700,7 +700,10 @@ where
|
||||||
|
|
||||||
reg!("admin/base");
|
reg!("admin/base");
|
||||||
reg!("admin/login");
|
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
|
// And then load user templates to overwrite the defaults
|
||||||
// Use .hbs extension for the files
|
// Use .hbs extension for the files
|
||||||
|
|
|
@ -355,6 +355,14 @@ impl Cipher {
|
||||||
.load::<Self>(&**conn).expect("Error loading ciphers")
|
.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> {
|
pub fn find_by_org(org_uuid: &str, conn: &DbConn) -> Vec<Self> {
|
||||||
ciphers::table
|
ciphers::table
|
||||||
.filter(ciphers::organization_uuid.eq(org_uuid))
|
.filter(ciphers::organization_uuid.eq(org_uuid))
|
||||||
|
|
|
@ -255,6 +255,10 @@ impl Organization {
|
||||||
.first::<Self>(&**conn)
|
.first::<Self>(&**conn)
|
||||||
.ok()
|
.ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_all(conn: &DbConn) -> Vec<Self> {
|
||||||
|
organizations::table.load::<Self>(&**conn).expect("Error loading organizations")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UserOrganization {
|
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;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.navbar img {
|
||||||
|
height: 24px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
</style>
|
</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>
|
</head>
|
||||||
|
|
||||||
<body class="bg-light">
|
<body class="bg-light">
|
||||||
<nav class="navbar navbar-expand-sm navbar-dark bg-dark fixed-top shadow">
|
<nav class="navbar navbar-expand-sm navbar-dark bg-dark fixed-top shadow mb-4">
|
||||||
<a class="navbar-brand" href="#">Bitwarden_rs</a>
|
<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">
|
<div class="navbar-collapse">
|
||||||
<ul class="navbar-nav">
|
<ul class="navbar-nav">
|
||||||
<li class="nav-item active">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{{urlpath}}/admin">Admin Panel</a>
|
<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>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{{urlpath}}/">Vault</a>
|
<a class="nav-link" href="{{urlpath}}/">Vault</a>
|
||||||
|
@ -54,14 +117,27 @@
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
{{#if logged_in}}
|
{{#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>
|
<a class="nav-link" href="{{urlpath}}/admin/logout">Log Out</a>
|
||||||
</li>
|
</li>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</ul>
|
</ul>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{{> (page_content) }}
|
{{> (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>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
117
src/static/templates/admin/diagnostics.hbs
Normal file
117
src/static/templates/admin/diagnostics.hbs
Normal 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>
|
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">
|
<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 id="config-block" class="align-items-center p-3 mb-3 bg-secondary rounded shadow">
|
||||||
<div>
|
<div>
|
||||||
<h6 class="text-white mb-3">Configuration</h6>
|
<h6 class="text-white mb-3">Configuration</h6>
|
||||||
|
@ -202,90 +138,6 @@
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<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() {
|
function smtpTest() {
|
||||||
test_email = document.getElementById("smtp-test-email");
|
test_email = document.getElementById("smtp-test-email");
|
||||||
data = JSON.stringify({ "email": test_email.value });
|
data = JSON.stringify({ "email": test_email.value });
|
||||||
|
@ -348,23 +200,6 @@
|
||||||
onChange(); // Trigger the event initially
|
onChange(); // Trigger the event initially
|
||||||
checkbox.addEventListener("change", onChange);
|
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
|
// These are formatted because otherwise the
|
||||||
// VSCode formatter breaks But they still work
|
// VSCode formatter breaks But they still work
|
||||||
// {{#each config}} {{#if grouptoggle}}
|
// {{#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