commit 3c66deb5cc7a4387e4176d2a5bdd3f321f09a6bd
parent 4146612a32b11d7a174b8ae997ed5eb317684de2
Author: BlackDex <black.dex@gmail.com>
Date: Thu, 28 May 2020 10:42:36 +0200
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....
Diffstat:
13 files changed, 665 insertions(+), 391 deletions(-)
diff --git a/src/api/admin.rs b/src/api/admin.rs
@@ -23,7 +23,7 @@ pub fn routes() -> Vec<Route> {
routes![
admin_login,
- get_users,
+ get_users_json,
post_admin_login,
admin_page,
invite_user,
@@ -36,6 +36,9 @@ pub fn routes() -> Vec<Route> {
delete_config,
backup_db,
test_smtp,
+ users_overview,
+ organizations_overview,
+ diagnostics,
]
}
@@ -118,7 +121,9 @@ fn _validate_token(token: &str) -> bool {
struct AdminTemplateData {
page_content: String,
version: Option<&'static str>,
- users: Vec<Value>,
+ users: Option<Vec<Value>>,
+ organizations: Option<Vec<Value>>,
+ diagnostics: Option<Value>,
config: Value,
can_backup: bool,
logged_in: bool,
@@ -126,15 +131,59 @@ struct AdminTemplateData {
}
impl AdminTemplateData {
- fn new(users: Vec<Value>) -> Self {
+ fn new() -> Self {
Self {
- page_content: String::from("admin/page"),
+ page_content: String::from("admin/settings"),
version: VERSION,
- users,
config: CONFIG.prepare_json(),
can_backup: *CAN_BACKUP,
logged_in: true,
urlpath: CONFIG.domain_path(),
+ users: None,
+ organizations: None,
+ diagnostics: None,
+ }
+ }
+
+ fn users(users: Vec<Value>) -> Self {
+ Self {
+ page_content: String::from("admin/users"),
+ version: VERSION,
+ users: Some(users),
+ config: CONFIG.prepare_json(),
+ can_backup: *CAN_BACKUP,
+ logged_in: true,
+ urlpath: CONFIG.domain_path(),
+ organizations: None,
+ diagnostics: None,
+ }
+ }
+
+ fn organizations(organizations: Vec<Value>) -> Self {
+ Self {
+ page_content: String::from("admin/organizations"),
+ version: VERSION,
+ organizations: Some(organizations),
+ config: CONFIG.prepare_json(),
+ can_backup: *CAN_BACKUP,
+ logged_in: true,
+ urlpath: CONFIG.domain_path(),
+ users: None,
+ diagnostics: None,
+ }
+ }
+
+ fn diagnostics(diagnostics: Value) -> Self {
+ Self {
+ page_content: String::from("admin/diagnostics"),
+ version: VERSION,
+ organizations: None,
+ config: CONFIG.prepare_json(),
+ can_backup: *CAN_BACKUP,
+ logged_in: true,
+ urlpath: CONFIG.domain_path(),
+ users: None,
+ diagnostics: Some(diagnostics),
}
}
@@ -144,11 +193,8 @@ impl AdminTemplateData {
}
#[get("/", rank = 1)]
-fn admin_page(_token: AdminToken, conn: DbConn) -> ApiResult<Html<String>> {
- let users = User::get_all(&conn);
- let users_json: Vec<Value> = users.iter().map(|u| u.to_json(&conn)).collect();
-
- let text = AdminTemplateData::new(users_json).render()?;
+fn admin_page(_token: AdminToken, _conn: DbConn) -> ApiResult<Html<String>> {
+ let text = AdminTemplateData::new().render()?;
Ok(Html(text))
}
@@ -195,13 +241,29 @@ fn logout(mut cookies: Cookies) -> Result<Redirect, ()> {
}
#[get("/users")]
-fn get_users(_token: AdminToken, conn: DbConn) -> JsonResult {
+fn get_users_json(_token: AdminToken, conn: DbConn) -> JsonResult {
let users = User::get_all(&conn);
let users_json: Vec<Value> = users.iter().map(|u| u.to_json(&conn)).collect();
Ok(Json(Value::Array(users_json)))
}
+#[get("/users/overview")]
+fn users_overview(_token: AdminToken, conn: DbConn) -> ApiResult<Html<String>> {
+ let users = User::get_all(&conn);
+ let users_json: Vec<Value> = users.iter()
+ .map(|u| {
+ let mut usr = u.to_json(&conn);
+ if let Some(ciphers) = Cipher::count_owned_by_user(&u.uuid, &conn) {
+ usr["cipher_count"] = json!(ciphers);
+ };
+ usr
+ }).collect();
+
+ let text = AdminTemplateData::users(users_json).render()?;
+ Ok(Html(text))
+}
+
#[post("/users/<uuid>/delete")]
fn delete_user(uuid: String, _token: AdminToken, conn: DbConn) -> EmptyResult {
let user = match User::find_by_uuid(&uuid, &conn) {
@@ -242,6 +304,50 @@ fn update_revision_users(_token: AdminToken, conn: DbConn) -> EmptyResult {
User::update_all_revisions(&conn)
}
+#[get("/organizations/overview")]
+fn organizations_overview(_token: AdminToken, conn: DbConn) -> ApiResult<Html<String>> {
+ let organizations = Organization::get_all(&conn);
+ let organizations_json: Vec<Value> = organizations.iter().map(|o| o.to_json()).collect();
+
+ let text = AdminTemplateData::organizations(organizations_json).render()?;
+ Ok(Html(text))
+}
+
+#[derive(Deserialize, Serialize, Debug)]
+#[allow(non_snake_case)]
+pub struct WebVaultVersion {
+ version: String,
+}
+
+#[get("/diagnostics")]
+fn diagnostics(_token: AdminToken, _conn: DbConn) -> ApiResult<Html<String>> {
+ use std::net::ToSocketAddrs;
+ use chrono::prelude::*;
+ use crate::util::read_file_string;
+
+ let vault_version_path = format!("{}/{}", CONFIG.web_vault_folder(), "version.json");
+ let vault_version_str = read_file_string(&vault_version_path)?;
+ let web_vault_version: WebVaultVersion = serde_json::from_str(&vault_version_str)?;
+
+ let github_ips = ("github.com", 0).to_socket_addrs().map(|mut i| i.next());
+ let dns_resolved = match github_ips {
+ Ok(Some(a)) => a.ip().to_string() ,
+ _ => "Could not resolve domain name.".to_string(),
+ };
+
+ let dt = Utc::now();
+ let server_time = dt.format("%Y-%m-%d %H:%M:%S").to_string();
+
+ let diagnostics_json = json!({
+ "dns_resolved": dns_resolved,
+ "server_time": server_time,
+ "web_vault_version": web_vault_version.version,
+ });
+
+ let text = AdminTemplateData::diagnostics(diagnostics_json).render()?;
+ Ok(Html(text))
+}
+
#[post("/config", data = "<data>")]
fn post_config(data: Json<ConfigBuilder>, _token: AdminToken) -> EmptyResult {
let data: ConfigBuilder = data.into_inner();
diff --git a/src/api/web.rs b/src/api/web.rs
@@ -78,6 +78,7 @@ fn static_files(filename: String) -> Result<Content<&'static [u8]>, Error> {
match filename.as_ref() {
"mail-github.png" => Ok(Content(ContentType::PNG, include_bytes!("../static/images/mail-github.png"))),
"logo-gray.png" => Ok(Content(ContentType::PNG, include_bytes!("../static/images/logo-gray.png"))),
+ "shield-white.png" => Ok(Content(ContentType::PNG, include_bytes!("../static/images/shield-white.png"))),
"error-x.svg" => Ok(Content(ContentType::SVG, include_bytes!("../static/images/error-x.svg"))),
"hibp.png" => Ok(Content(ContentType::PNG, include_bytes!("../static/images/hibp.png"))),
diff --git a/src/config.rs b/src/config.rs
@@ -700,7 +700,10 @@ where
reg!("admin/base");
reg!("admin/login");
- reg!("admin/page");
+ reg!("admin/settings");
+ reg!("admin/users");
+ reg!("admin/organizations");
+ reg!("admin/diagnostics");
// And then load user templates to overwrite the defaults
// Use .hbs extension for the files
diff --git a/src/db/models/cipher.rs b/src/db/models/cipher.rs
@@ -355,6 +355,14 @@ impl Cipher {
.load::<Self>(&**conn).expect("Error loading ciphers")
}
+ pub fn count_owned_by_user(user_uuid: &str, conn: &DbConn) -> Option<i64> {
+ ciphers::table
+ .filter(ciphers::user_uuid.eq(user_uuid))
+ .count()
+ .first::<i64>(&**conn)
+ .ok()
+ }
+
pub fn find_by_org(org_uuid: &str, conn: &DbConn) -> Vec<Self> {
ciphers::table
.filter(ciphers::organization_uuid.eq(org_uuid))
diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs
@@ -255,6 +255,10 @@ impl Organization {
.first::<Self>(&**conn)
.ok()
}
+
+ pub fn get_all(conn: &DbConn) -> Vec<Self> {
+ organizations::table.load::<Self>(&**conn).expect("Error loading organizations")
+ }
}
impl UserOrganization {
diff --git a/src/static/images/logo-gray.png b/src/static/images/logo-gray.png
Binary files differ.
diff --git a/src/static/images/shield-white.png b/src/static/images/shield-white.png
Binary files differ.
diff --git a/src/static/templates/admin/base.hbs b/src/static/templates/admin/base.hbs
@@ -29,16 +29,79 @@
width: 48px;
height: 48px;
}
+
+ .navbar img {
+ height: 24px;
+ width: auto;
+ }
</style>
+ <script>
+ function reload() { window.location.reload(); }
+ function msg(text, reload_page = true) {
+ text && alert(text);
+ reload_page && reload();
+ }
+ function identicon(email) {
+ const data = new Identicon(md5(email), { size: 48, format: 'svg' });
+ return "data:image/svg+xml;base64," + data.toString();
+ }
+ function toggleVis(input_id) {
+ const elem = document.getElementById(input_id);
+ const type = elem.getAttribute("type");
+ if (type === "text") {
+ elem.setAttribute("type", "password");
+ } else {
+ elem.setAttribute("type", "text");
+ }
+ return false;
+ }
+ function _post(url, successMsg, errMsg, body, reload_page = true) {
+ fetch(url, {
+ method: 'POST',
+ body: body,
+ mode: "same-origin",
+ credentials: "same-origin",
+ headers: { "Content-Type": "application/json" }
+ }).then( resp => {
+ if (resp.ok) { msg(successMsg, reload_page); return Promise.reject({error: false}); }
+ respStatus = resp.status;
+ respStatusText = resp.statusText;
+ return resp.text();
+ }).then( respText => {
+ try {
+ const respJson = JSON.parse(respText);
+ return respJson ? respJson.ErrorModel.Message : "Unknown error";
+ } catch (e) {
+ return Promise.reject({body:respStatus + ' - ' + respStatusText, error: true});
+ }
+ }).then( apiMsg => {
+ msg(errMsg + "\n" + apiMsg, reload_page);
+ }).catch( e => {
+ if (e.error === false) { return true; }
+ else { msg(errMsg + "\n" + e.body, reload_page); }
+ });
+ }
+ </script>
+
</head>
<body class="bg-light">
- <nav class="navbar navbar-expand-sm navbar-dark bg-dark fixed-top shadow">
- <a class="navbar-brand" href="#">Bitwarden_rs</a>
+ <nav class="navbar navbar-expand-sm navbar-dark bg-dark fixed-top shadow mb-4">
+ <div class="container">
+ <a class="navbar-brand" href="{{urlpath}}/admin"><img class="pr-1" src="{{urlpath}}/bwrs_static/shield-white.png">Bitwarden_rs Admin</a>
<div class="navbar-collapse">
<ul class="navbar-nav">
- <li class="nav-item active">
- <a class="nav-link" href="{{urlpath}}/admin">Admin Panel</a>
+ <li class="nav-item">
+ <a class="nav-link" href="{{urlpath}}/admin">Settings</a>
+ </li>
+ <li class="nav-item">
+ <a class="nav-link" href="{{urlpath}}/admin/users/overview">Users</a>
+ </li>
+ <li class="nav-item">
+ <a class="nav-link" href="{{urlpath}}/admin/organizations/overview">Organizations</a>
+ </li>
+ <li class="nav-item">
+ <a class="nav-link" href="{{urlpath}}/admin/diagnostics">Diagnostics</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{urlpath}}/">Vault</a>
@@ -54,14 +117,27 @@
{{/if}}
{{#if logged_in}}
- <li class="nav-item">
+ <li class="nav-item rounded btn-secondary">
<a class="nav-link" href="{{urlpath}}/admin/logout">Log Out</a>
</li>
{{/if}}
</ul>
+ </div>
</nav>
{{> (page_content) }}
+
+ <script>
+ // get current URL path and assign 'active' class to the correct nav-item
+ (function () {
+ var pathname = window.location.pathname;
+ if (pathname === "") return;
+ var navItem = document.querySelectorAll('.navbar-nav .nav-item a[href="'+pathname+'"]');
+ if (navItem.length === 1) {
+ navItem[0].parentElement.className = navItem[0].parentElement.className + ' active';
+ }
+ })();
+ </script>
</body>
-</html>
+</html>
+\ No newline at end of file
diff --git a/src/static/templates/admin/diagnostics.hbs b/src/static/templates/admin/diagnostics.hbs
@@ -0,0 +1,73 @@
+<main class="container">
+ <div id="diagnostics-block" class="my-3 p-3 bg-white rounded shadow">
+ <h6 class="border-bottom pb-2 mb-2">Diagnostics</h6>
+
+ <h3>Version</h3>
+ <div class="row">
+ <div class="col-md">
+ <dl class="row">
+ <dt class="col-sm-5">Server Installed</dt>
+ <dd class="col-sm-7">
+ <span id="server-installed">{{version}}</span>
+ </dd>
+ <dt class="col-sm-5">Web Installed</dt>
+ <dd class="col-sm-7">
+ <span id="web-installed">{{diagnostics.web_vault_version}}</span>
+ </dd>
+ </dl>
+ </div>
+ </div>
+
+ <h3>Checks</h3>
+ <div class="row">
+ <div class="col-md">
+ <dl class="row">
+ <dt class="col-sm-5">DNS (github.com)
+ <span class="badge badge-success d-none" id="dns-success" title="DNS Resolving works!">Ok</span>
+ <span class="badge badge-danger d-none" id="dns-warning" title="DNS Resolving failed. Please fix.">Error</span>
+ </dt>
+ <dd class="col-sm-7">
+ <span id="dns-resolved">{{diagnostics.dns_resolved}}</span>
+ </dd>
+
+ <dt class="col-sm-5">Date & Time (UTC)
+ <span class="badge badge-success d-none" id="time-success" title="Time offsets seem to be correct.">Ok</span>
+ <span class="badge badge-danger d-none" id="time-warning" title="Time offsets are too mouch at drift.">Error</span>
+ </dt>
+ <dd class="col-sm-7">
+ <span id="time-server" class="d-block"><b>Server:</b> <span id="time-server-string">{{diagnostics.server_time}}</span></span>
+ <span id="time-browser" class="d-block"><b>Browser:</b> <span id="time-browser-string"></span></span>
+ </dd>
+ </dl>
+ </div>
+ </div>
+ </div>
+</main>
+
+<script>
+ const d = new Date();
+ const year = d.getUTCFullYear();
+ const month = String((d.getUTCMonth()+1)).padStart(2, '0');
+ const day = String(d.getUTCDate()).padStart(2, '0');
+ const hour = String(d.getUTCHours()).padStart(2, '0');
+ const minute = String(d.getUTCMinutes()).padStart(2, '0');
+ const seconds = String(d.getUTCSeconds()).padStart(2, '0');
+ const browserUTC = year + '-' + month + '-' + day + ' ' + hour + ':' + minute + ':' + seconds;
+ document.getElementById("time-browser-string").innerText = browserUTC;
+
+ const serverUTC = document.getElementById("time-server-string").innerText;
+ const timeDrift = (Date.parse(serverUTC) - Date.parse(browserUTC)) / 1000;
+ if (timeDrift > 30 || timeDrift < -30) {
+ document.getElementById('time-warning').classList.remove('d-none');
+ } else {
+ document.getElementById('time-success').classList.remove('d-none');
+ }
+
+ // Check if the output is a valid IP
+ const isValidIp = value => (/^(?:(?:^|\.)(?:2(?:5[0-5]|[0-4]\d)|1?\d?\d)){4}$/.test(value) ? true : false);
+ if (isValidIp(document.getElementById('dns-resolved').innerText)) {
+ document.getElementById('dns-success').classList.remove('d-none');
+ } else {
+ document.getElementById('dns-warning').classList.remove('d-none');
+ }
+</script>
+\ No newline at end of file
diff --git a/src/static/templates/admin/organizations.hbs b/src/static/templates/admin/organizations.hbs
@@ -0,0 +1,30 @@
+<main class="container">
+ <div id="organizations-block" class="my-3 p-3 bg-white rounded shadow">
+ <h6 class="border-bottom pb-2 mb-0">Organizations</h6>
+
+ <div id="organizations-list">
+ {{#each organizations}}
+ <div class="media pt-3">
+ <img class="mr-2 rounded identicon" data-src="{{Name}}_{{BillingEmail}}">
+ <div class="media-body pb-3 mb-0 small border-bottom">
+ <div class="row justify-content-between">
+ <div class="col">
+ <strong>{{Name}}</strong>
+ {{#if Id}}
+ <span class="badge badge-success ml-2">{{Id}}</span>
+ {{/if}}
+ <span class="d-block">{{BillingEmail}}</span>
+ </div>
+ </div>
+ </div>
+ </div>
+ {{/each}}
+ </div>
+ </div>
+</main>
+
+<script>
+ document.querySelectorAll("img.identicon").forEach(function (e, i) {
+ e.src = identicon(e.dataset.src);
+ });
+</script>
+\ No newline at end of file
diff --git a/src/static/templates/admin/page.hbs b/src/static/templates/admin/page.hbs
@@ -1,373 +0,0 @@
-<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>
- <div class="small text-white mb-3">
- NOTE: The settings here override the environment variables. Once saved, it's recommended to stop setting
- them to avoid confusion. This does not apply to the read-only section, which can only be set through the
- environment.
- </div>
-
- <form class="form accordion" id="config-form" onsubmit="saveConfig(); return false;">
- {{#each config}}
- {{#if groupdoc}}
- <div class="card bg-light mb-3">
- <div class="card-header"><button type="button" class="btn btn-link collapsed" data-toggle="collapse"
- data-target="#g_{{group}}">{{groupdoc}}</button></div>
- <div id="g_{{group}}" class="card-body collapse" data-parent="#config-form">
- {{#each elements}}
- {{#if editable}}
- <div class="form-group row" title="[{{name}}] {{doc.description}}">
- {{#case type "text" "number" "password"}}
- <label for="input_{{name}}" class="col-sm-3 col-form-label">{{doc.name}}</label>
- <div class="col-sm-8 input-group">
- <input class="form-control conf-{{type}}" id="input_{{name}}" type="{{type}}"
- name="{{name}}" value="{{value}}" {{#if default}} placeholder="Default: {{default}}"
- {{/if}}>
-
- {{#case type "password"}}
- <div class="input-group-append">
- <button class="btn btn-outline-secondary" type="button"
- onclick="toggleVis('input_{{name}}');">Show/hide</button>
- </div>
- {{/case}}
- </div>
- {{/case}}
- {{#case type "checkbox"}}
- <div class="col-sm-3">{{doc.name}}</div>
- <div class="col-sm-8">
- <div class="form-check">
- <input class="form-check-input conf-{{type}}" type="checkbox" id="input_{{name}}"
- name="{{name}}" {{#if value}} checked {{/if}}>
-
- <label class="form-check-label" for="input_{{name}}"> Default: {{default}} </label>
- </div>
- </div>
- {{/case}}
- </div>
- {{/if}}
- {{/each}}
- {{#case group "smtp"}}
- <div class="form-group row pt-3 border-top" title="Send a test email to given email address">
- <label for="smtp-test-email" class="col-sm-3 col-form-label">Test SMTP</label>
- <div class="col-sm-8 input-group">
- <input class="form-control" id="smtp-test-email" type="email" placeholder="Enter test email">
- <div class="input-group-append">
- <button type="button" class="btn btn-outline-primary" onclick="smtpTest(); return false;">Send test email</button>
- </div>
- </div>
- </div>
- {{/case}}
- </div>
- </div>
- {{/if}}
- {{/each}}
-
- <div class="card bg-light mb-3">
- <div class="card-header"><button type="button" class="btn btn-link collapsed" data-toggle="collapse"
- data-target="#g_readonly">Read-Only Config</button></div>
- <div id="g_readonly" class="card-body collapse" data-parent="#config-form">
- <div class="small mb-3">
- NOTE: These options can't be modified in the editor because they would require the server
- to be restarted. To modify them, you need to set the correct environment variables when
- launching the server. You can check the variable names in the tooltips of each option.
- </div>
-
- {{#each config}}
- {{#each elements}}
- {{#unless editable}}
- <div class="form-group row" title="[{{name}}] {{doc.description}}">
- {{#case type "text" "number" "password"}}
- <label for="input_{{name}}" class="col-sm-3 col-form-label">{{doc.name}}</label>
- <div class="col-sm-8 input-group">
- <input readonly class="form-control" id="input_{{name}}" type="{{type}}"
- value="{{value}}" {{#if default}} placeholder="Default: {{default}}" {{/if}}>
-
- {{#case type "password"}}
- <div class="input-group-append">
- <button class="btn btn-outline-secondary" type="button"
- onclick="toggleVis('input_{{name}}');">Show/hide</button>
- </div>
- {{/case}}
- </div>
- {{/case}}
- {{#case type "checkbox"}}
- <div class="col-sm-3">{{doc.name}}</div>
- <div class="col-sm-8">
- <div class="form-check">
- <input disabled class="form-check-input" type="checkbox" id="input_{{name}}"
- {{#if value}} checked {{/if}}>
-
- <label class="form-check-label" for="input_{{name}}"> Default: {{default}} </label>
- </div>
- </div>
- {{/case}}
- </div>
- {{/unless}}
- {{/each}}
- {{/each}}
-
- </div>
- </div>
-
- {{#if can_backup}}
- <div class="card bg-light mb-3">
- <div class="card-header"><button type="button" class="btn btn-link collapsed" data-toggle="collapse"
- data-target="#g_database">Backup Database</button></div>
- <div id="g_database" class="card-body collapse" data-parent="#config-form">
- <div class="small mb-3">
- NOTE: A local installation of sqlite3 is required for this section to work.
- </div>
- <button type="button" class="btn btn-primary" onclick="backupDatabase();">Backup Database</button>
- </div>
- </div>
- {{/if}}
-
- <button type="submit" class="btn btn-primary">Save</button>
- <button type="button" class="btn btn-danger float-right" onclick="deleteConf();">Reset defaults</button>
- </form>
- </div>
- </div>
-</main>
-
-<style>
- #config-block ::placeholder {
- /* Most modern browsers support this now. */
- color: orangered;
- }
-</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 });
- _post("{{urlpath}}/admin/test/smtp/",
- "SMTP Test email sent correctly",
- "Error sending SMTP test email", data, false);
- return false;
- }
- function getFormData() {
- let data = {};
-
- document.querySelectorAll(".conf-checkbox").forEach(function (e, i) {
- data[e.name] = e.checked;
- });
-
- document.querySelectorAll(".conf-number").forEach(function (e, i) {
- data[e.name] = e.value ? +e.value : null;
- });
-
- document.querySelectorAll(".conf-text, .conf-password").forEach(function (e, i) {
- data[e.name] = e.value || null;
- });
- return data;
- }
- function saveConfig() {
- data = JSON.stringify(getFormData());
- _post("{{urlpath}}/admin/config/", "Config saved correctly",
- "Error saving config", data);
- return false;
- }
- function deleteConf() {
- var input = prompt("This will remove all user configurations, and restore the defaults and the " +
- "values set by the environment. This operation could be dangerous. Type 'DELETE' to proceed:");
- if (input === "DELETE") {
- _post("{{urlpath}}/admin/config/delete",
- "Config deleted correctly",
- "Error deleting config");
- } else {
- alert("Wrong input, please try again")
- }
-
- return false;
- }
- function backupDatabase() {
- _post("{{urlpath}}/admin/config/backup_db",
- "Backup created successfully",
- "Error creating backup", null, false);
- return false;
- }
- function masterCheck(check_id, inputs_query) {
- function onChanged(checkbox, inputs_query) {
- return function _fn() {
- document.querySelectorAll(inputs_query).forEach(function (e, i) { e.disabled = !checkbox.checked; });
- checkbox.disabled = false;
- };
- };
-
- const checkbox = document.getElementById(check_id);
- const onChange = onChanged(checkbox, inputs_query);
- onChange(); // Trigger the event initially
- checkbox.addEventListener("change", onChange);
- }
- 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}}
- masterCheck("input_{{grouptoggle}}", "#g_{{group}} input");
- // {{/if}} {{/each}}
-</script>
diff --git a/src/static/templates/admin/settings.hbs b/src/static/templates/admin/settings.hbs
@@ -0,0 +1,208 @@
+<main class="container">
+ <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>
+ <div class="small text-white mb-3">
+ NOTE: The settings here override the environment variables. Once saved, it's recommended to stop setting
+ them to avoid confusion. This does not apply to the read-only section, which can only be set through the
+ environment.
+ </div>
+
+ <form class="form accordion" id="config-form" onsubmit="saveConfig(); return false;">
+ {{#each config}}
+ {{#if groupdoc}}
+ <div class="card bg-light mb-3">
+ <div class="card-header"><button type="button" class="btn btn-link collapsed" data-toggle="collapse"
+ data-target="#g_{{group}}">{{groupdoc}}</button></div>
+ <div id="g_{{group}}" class="card-body collapse" data-parent="#config-form">
+ {{#each elements}}
+ {{#if editable}}
+ <div class="form-group row" title="[{{name}}] {{doc.description}}">
+ {{#case type "text" "number" "password"}}
+ <label for="input_{{name}}" class="col-sm-3 col-form-label">{{doc.name}}</label>
+ <div class="col-sm-8 input-group">
+ <input class="form-control conf-{{type}}" id="input_{{name}}" type="{{type}}"
+ name="{{name}}" value="{{value}}" {{#if default}} placeholder="Default: {{default}}"
+ {{/if}}>
+
+ {{#case type "password"}}
+ <div class="input-group-append">
+ <button class="btn btn-outline-secondary" type="button"
+ onclick="toggleVis('input_{{name}}');">Show/hide</button>
+ </div>
+ {{/case}}
+ </div>
+ {{/case}}
+ {{#case type "checkbox"}}
+ <div class="col-sm-3">{{doc.name}}</div>
+ <div class="col-sm-8">
+ <div class="form-check">
+ <input class="form-check-input conf-{{type}}" type="checkbox" id="input_{{name}}"
+ name="{{name}}" {{#if value}} checked {{/if}}>
+
+ <label class="form-check-label" for="input_{{name}}"> Default: {{default}} </label>
+ </div>
+ </div>
+ {{/case}}
+ </div>
+ {{/if}}
+ {{/each}}
+ {{#case group "smtp"}}
+ <div class="form-group row pt-3 border-top" title="Send a test email to given email address">
+ <label for="smtp-test-email" class="col-sm-3 col-form-label">Test SMTP</label>
+ <div class="col-sm-8 input-group">
+ <input class="form-control" id="smtp-test-email" type="email" placeholder="Enter test email">
+ <div class="input-group-append">
+ <button type="button" class="btn btn-outline-primary" onclick="smtpTest(); return false;">Send test email</button>
+ </div>
+ </div>
+ </div>
+ {{/case}}
+ </div>
+ </div>
+ {{/if}}
+ {{/each}}
+
+ <div class="card bg-light mb-3">
+ <div class="card-header"><button type="button" class="btn btn-link collapsed" data-toggle="collapse"
+ data-target="#g_readonly">Read-Only Config</button></div>
+ <div id="g_readonly" class="card-body collapse" data-parent="#config-form">
+ <div class="small mb-3">
+ NOTE: These options can't be modified in the editor because they would require the server
+ to be restarted. To modify them, you need to set the correct environment variables when
+ launching the server. You can check the variable names in the tooltips of each option.
+ </div>
+
+ {{#each config}}
+ {{#each elements}}
+ {{#unless editable}}
+ <div class="form-group row" title="[{{name}}] {{doc.description}}">
+ {{#case type "text" "number" "password"}}
+ <label for="input_{{name}}" class="col-sm-3 col-form-label">{{doc.name}}</label>
+ <div class="col-sm-8 input-group">
+ <input readonly class="form-control" id="input_{{name}}" type="{{type}}"
+ value="{{value}}" {{#if default}} placeholder="Default: {{default}}" {{/if}}>
+
+ {{#case type "password"}}
+ <div class="input-group-append">
+ <button class="btn btn-outline-secondary" type="button"
+ onclick="toggleVis('input_{{name}}');">Show/hide</button>
+ </div>
+ {{/case}}
+ </div>
+ {{/case}}
+ {{#case type "checkbox"}}
+ <div class="col-sm-3">{{doc.name}}</div>
+ <div class="col-sm-8">
+ <div class="form-check">
+ <input disabled class="form-check-input" type="checkbox" id="input_{{name}}"
+ {{#if value}} checked {{/if}}>
+
+ <label class="form-check-label" for="input_{{name}}"> Default: {{default}} </label>
+ </div>
+ </div>
+ {{/case}}
+ </div>
+ {{/unless}}
+ {{/each}}
+ {{/each}}
+
+ </div>
+ </div>
+
+ {{#if can_backup}}
+ <div class="card bg-light mb-3">
+ <div class="card-header"><button type="button" class="btn btn-link collapsed" data-toggle="collapse"
+ data-target="#g_database">Backup Database</button></div>
+ <div id="g_database" class="card-body collapse" data-parent="#config-form">
+ <div class="small mb-3">
+ NOTE: A local installation of sqlite3 is required for this section to work.
+ </div>
+ <button type="button" class="btn btn-primary" onclick="backupDatabase();">Backup Database</button>
+ </div>
+ </div>
+ {{/if}}
+
+ <button type="submit" class="btn btn-primary">Save</button>
+ <button type="button" class="btn btn-danger float-right" onclick="deleteConf();">Reset defaults</button>
+ </form>
+ </div>
+ </div>
+</main>
+
+<style>
+ #config-block ::placeholder {
+ /* Most modern browsers support this now. */
+ color: orangered;
+ }
+</style>
+
+<script>
+ function smtpTest() {
+ test_email = document.getElementById("smtp-test-email");
+ data = JSON.stringify({ "email": test_email.value });
+ _post("{{urlpath}}/admin/test/smtp/",
+ "SMTP Test email sent correctly",
+ "Error sending SMTP test email", data, false);
+ return false;
+ }
+ function getFormData() {
+ let data = {};
+
+ document.querySelectorAll(".conf-checkbox").forEach(function (e, i) {
+ data[e.name] = e.checked;
+ });
+
+ document.querySelectorAll(".conf-number").forEach(function (e, i) {
+ data[e.name] = e.value ? +e.value : null;
+ });
+
+ document.querySelectorAll(".conf-text, .conf-password").forEach(function (e, i) {
+ data[e.name] = e.value || null;
+ });
+ return data;
+ }
+ function saveConfig() {
+ data = JSON.stringify(getFormData());
+ _post("{{urlpath}}/admin/config/", "Config saved correctly",
+ "Error saving config", data);
+ return false;
+ }
+ function deleteConf() {
+ var input = prompt("This will remove all user configurations, and restore the defaults and the " +
+ "values set by the environment. This operation could be dangerous. Type 'DELETE' to proceed:");
+ if (input === "DELETE") {
+ _post("{{urlpath}}/admin/config/delete",
+ "Config deleted correctly",
+ "Error deleting config");
+ } else {
+ alert("Wrong input, please try again")
+ }
+
+ return false;
+ }
+ function backupDatabase() {
+ _post("{{urlpath}}/admin/config/backup_db",
+ "Backup created successfully",
+ "Error creating backup", null, false);
+ return false;
+ }
+ function masterCheck(check_id, inputs_query) {
+ function onChanged(checkbox, inputs_query) {
+ return function _fn() {
+ document.querySelectorAll(inputs_query).forEach(function (e, i) { e.disabled = !checkbox.checked; });
+ checkbox.disabled = false;
+ };
+ };
+
+ const checkbox = document.getElementById(check_id);
+ const onChange = onChanged(checkbox, inputs_query);
+ onChange(); // Trigger the event initially
+ checkbox.addEventListener("change", onChange);
+ }
+ // These are formatted because otherwise the
+ // VSCode formatter breaks But they still work
+ // {{#each config}} {{#if grouptoggle}}
+ masterCheck("input_{{grouptoggle}}", "#g_{{group}} input");
+ // {{/if}} {{/each}}
+</script>
diff --git a/src/static/templates/admin/users.hbs b/src/static/templates/admin/users.hbs
@@ -0,0 +1,134 @@
+<main class="container">
+ <div id="users-block" class="my-3 p-3 bg-white rounded shadow">
+ <h6 class="border-bottom pb-2 mb-0">Registered Users</h6>
+
+ <div id="users-list">
+ {{#each users}}
+ <div class="media pt-3">
+ <img class="mr-2 rounded identicon" data-src="{{Email}}">
+ <div class="media-body pb-3 mb-0 small border-bottom">
+ <div class="row justify-content-between">
+ <div class="col">
+ <strong>{{Name}}</strong>
+ {{#if TwoFactorEnabled}}
+ <span class="badge badge-success ml-2">2FA</span>
+ {{/if}}
+ {{#case _Status 1}}
+ <span class="badge badge-warning ml-2">Invited</span>
+ {{/case}}
+ <span class="d-block">{{Email}}
+ {{#if EmailVerified}}
+ <span class="badge badge-success ml-2">Verified</span>
+ {{/if}}
+ </span>
+ </div>
+ <div class="col">
+ <strong> Personal Items: </strong>
+ <span class="d-block">
+ {{cipher_count}}
+ </span>
+ </div>
+ <div class="col-4">
+ <strong> Organizations: </strong>
+ <span class="d-block">
+ {{#each Organizations}}
+ <span class="badge badge-primary" data-orgtype="{{Type}}">{{Name}}</span>
+ {{/each}}
+ </span>
+ </div>
+ <div class="col" style="font-size: 90%; text-align: right; padding-right: 15px">
+ {{#if TwoFactorEnabled}}
+ <a class="mr-2" href="#" onclick='remove2fa({{jsesc Id}})'>Remove all 2FA</a>
+ {{/if}}
+
+ <a class="mr-2" href="#" onclick='deauthUser({{jsesc Id}})'>Deauthorize sessions</a>
+ <a class="mr-2" href="#" onclick='deleteUser({{jsesc Id}}, {{jsesc Email}})'>Delete User</a>
+ </div>
+ </div>
+ </div>
+ </div>
+ {{/each}}
+
+ </div>
+
+ <div class="mt-3">
+ <button type="button" class="btn btn-sm btn-link" onclick="updateRevisions();"
+ title="Force all clients to fetch new data next time they connect. Useful after restoring a backup to remove any stale data.">
+ Force clients to resync
+ </button>
+
+ <button type="button" class="btn btn-sm btn-primary float-right" onclick="reload();">Reload users</button>
+ </div>
+ </div>
+
+ <div id="invite-form-block" class="align-items-center p-3 mb-3 text-white-50 bg-secondary rounded shadow">
+ <div>
+ <h6 class="mb-0 text-white">Invite User</h6>
+ <small>Email:</small>
+
+ <form class="form-inline" id="invite-form" onsubmit="inviteUser(); return false;">
+ <input type="email" class="form-control w-50 mr-2" id="email-invite" placeholder="Enter email">
+ <button type="submit" class="btn btn-primary">Invite</button>
+ </form>
+ </div>
+ </div>
+</main>
+
+<script>
+ function deleteUser(id, mail) {
+ var input_mail = prompt("To delete user '" + mail + "', please type the email below")
+ if (input_mail != null) {
+ if (input_mail == mail) {
+ _post("{{urlpath}}/admin/users/" + id + "/delete",
+ "User deleted correctly",
+ "Error deleting user");
+ } else {
+ alert("Wrong email, please try again")
+ }
+ }
+ return false;
+ }
+ function remove2fa(id) {
+ _post("{{urlpath}}/admin/users/" + id + "/remove-2fa",
+ "2FA removed correctly",
+ "Error removing 2FA");
+ return false;
+ }
+ function deauthUser(id) {
+ _post("{{urlpath}}/admin/users/" + id + "/deauth",
+ "Sessions deauthorized correctly",
+ "Error deauthorizing sessions");
+ return false;
+ }
+ function updateRevisions() {
+ _post("{{urlpath}}/admin/users/update_revision",
+ "Success, clients will sync next time they connect",
+ "Error forcing clients to sync");
+ return false;
+ }
+ function inviteUser() {
+ inv = document.getElementById("email-invite");
+ data = JSON.stringify({ "email": inv.value });
+ inv.value = "";
+ _post("{{urlpath}}/admin/invite/", "User invited correctly",
+ "Error inviting user", data);
+ return false;
+ }
+
+ let OrgTypes = {
+ "0": { "name": "Owner", "color": "orange" },
+ "1": { "name": "Admin", "color": "blueviolet" },
+ "2": { "name": "User", "color": "blue" },
+ "3": { "name": "Manager", "color": "green" },
+ };
+
+ document.querySelectorAll("img.identicon").forEach(function (e, i) {
+ e.src = identicon(e.dataset.src);
+ });
+
+ document.querySelectorAll("[data-orgtype]").forEach(function (e, i) {
+ let orgtype = OrgTypes[e.dataset.orgtype];
+ e.style.backgroundColor = orgtype.color;
+ e.title = orgtype.name;
+ });
+</script>
+\ No newline at end of file