From 3b3369a23647be11e872e41adf10d350f56e0c86 Mon Sep 17 00:00:00 2001 From: Shweelan Samson Date: Thu, 14 Feb 2019 14:53:44 +0300 Subject: [PATCH] Add Permissions and Users pages --- lib/common/authorizer.ts | 2 +- lib/db.ts | 28 +++ lib/local-cache.ts | 75 ++++++- lib/ui.ts | 38 +--- lib/ui/api-functions.ts | 10 + lib/ui/api.ts | 4 + lib/ui/db.ts | 35 +++ tools/configure-ui | 103 ++++++--- ui/admin-menu.ts | 20 ++ ui/app.ts | 10 +- ui/layout.ts | 4 +- ui/permissions-page.ts | 465 ++++++++++++++++++++++++++++++++++++++ ui/put-form-component.ts | 26 ++- ui/smart-query.ts | 8 +- ui/store.ts | 4 +- ui/users-page.ts | 468 +++++++++++++++++++++++++++++++++++++++ 16 files changed, 1223 insertions(+), 77 deletions(-) create mode 100644 ui/permissions-page.ts create mode 100644 ui/users-page.ts diff --git a/lib/common/authorizer.ts b/lib/common/authorizer.ts index 6091aba..a399c49 100644 --- a/lib/common/authorizer.ts +++ b/lib/common/authorizer.ts @@ -2,7 +2,7 @@ import pass from "./validators/pass"; import test from "./validators/test"; import { PermissionSet } from "../types"; -const validators = { pass, test }; +export const validators = { pass, test }; export default class Authorizer { private permissionSets: PermissionSet[]; diff --git a/lib/db.ts b/lib/db.ts index 7d87d6a..327b356 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -33,6 +33,8 @@ export let tasksCollection, faultsCollection, filesCollection, operationsCollection, + permissionsCollection, + usersCollection, configCollection; export let client; @@ -57,6 +59,8 @@ export function connect(callback): void { virtualParametersCollection = db.collection("virtualParameters"); faultsCollection = db.collection("faults"); operationsCollection = db.collection("operations"); + permissionsCollection = db.collection("permissions"); + usersCollection = db.collection("users"); configCollection = db.collection("config"); callback(); @@ -931,3 +935,27 @@ export function getConfig(callback): void { ); }); } + +export function getPermissions( + callback: ( + err?: Error, + permissions?: { + role: string; + resource: string; + access: number; + filter: string; + validate: string; + }[] + ) => void +): void { + permissionsCollection.find().toArray(callback); +} + +export function getUsers( + callback: ( + err?: Error, + users?: { _id: string; password: string; roles: string }[] + ) => void +): void { + usersCollection.find().toArray(callback); +} diff --git a/lib/local-cache.ts b/lib/local-cache.ts index 9ac9cd0..7c1c701 100644 --- a/lib/local-cache.ts +++ b/lib/local-cache.ts @@ -26,7 +26,20 @@ import * as query from "./query"; import * as logger from "./logger"; import * as scheduling from "./scheduling"; import * as expression from "./common/expression"; -import { Preset } from "./types"; +import { parse } from "./common/expression-parser"; +import { Preset, Expression } from "./types"; + +interface Permissions { + [role: string]: { + [access: number]: { + [resource: string]: { + access: number; + filter: Expression; + validate: { [validator: string]: boolean }; + }; + }; + }; +} const REFRESH = 3000; const EVICT_TIMEOUT = 60000; @@ -301,6 +314,48 @@ function refresh(callback): void { }) ); + promises.push( + new Promise((resolve, reject) => { + db.getPermissions((err, perms) => { + if (err) return void reject(err); + const permissions: Permissions = {}; + for (const p of perms) { + if (!permissions[p.role]) permissions[p.role] = {}; + if (!permissions[p.role][p.access]) + permissions[p.role][p.access] = {}; + + const validate = {}; + permissions[p.role][p.access][p.resource] = { + access: p.access, + filter: parse(p.filter || "true"), + validate: validate + }; + + for (const v of (p.validate || "").split(",")) + validate[v.trim()] = true; + } + + resolve(permissions); + }); + }) + ); + + promises.push( + new Promise((resolve, reject) => { + db.getUsers((err, _users) => { + if (err) return void reject(err); + const users = {}; + for (const user of _users) { + users[user._id] = { + password: user.password, + roles: user.roles.split(",").map(s => s.trim()) + }; + } + resolve(users); + }); + }) + ); + promises.push( new Promise((resolve, reject) => { db.getConfig((err, conf) => { @@ -311,11 +366,7 @@ function refresh(callback): void { const _config = {}; for (const c of conf) { - if ( - c.id.startsWith("auth.") || - c.id.startsWith("permissions.") || - c.id.startsWith("ui.") - ) { + if (c.id.startsWith("ui.")) { const keys = c.id.split("."); let ref = _config; while (keys.length > 1) { @@ -343,7 +394,9 @@ function refresh(callback): void { provisions: res[1], virtualParameters: res[2], files: res[3], - config: res[4] + permissions: res[4], + users: res[5], + config: res[6] }; if (currentSnapshot) { @@ -465,18 +518,18 @@ export function getConfig( return expression.evaluate(snapshot.config[key], context, now || Date.now()); } -export function getAuthConfig(snapshotKey): {} { +export function getUsers(snapshotKey): {} { const snapshot = snapshots.get(snapshotKey); if (!snapshot) throw new Error("Cache snapshot does not exist"); - return snapshot.config.auth; + return snapshot.users; } -export function getPermissionsConfig(snapshotKey): {} { +export function getPermissions(snapshotKey): {} { const snapshot = snapshots.get(snapshotKey); if (!snapshot) throw new Error("Cache snapshot does not exist"); - return snapshot.config.permissions; + return snapshot.permissions; } export function getUiConfig(snapshotKey): {} { diff --git a/lib/ui.ts b/lib/ui.ts index 36826a9..012dda4 100644 --- a/lib/ui.ts +++ b/lib/ui.ts @@ -9,7 +9,6 @@ import koaJwt from "koa-jwt"; import * as config from "./config"; import api from "./ui/api"; import Authorizer from "./common/authorizer"; -import { parse } from "./common/expression-parser"; import * as logger from "./logger"; import * as localCache from "./local-cache"; import { PermissionSet } from "./types"; @@ -27,41 +26,18 @@ const JWT_SECRET = "" + config.get("UI_JWT_SECRET"); const JWT_COOKIE = "genieacs-ui-jwt"; function getPermissionSets(ctx): PermissionSet[] { - const allPermissions = localCache.getPermissionsConfig( - ctx.state.configSnapshot + const allPermissions = localCache.getPermissions(ctx.state.configSnapshot); + const permissionSets = ctx.state.user.roles.map(role => + Object.values(allPermissions[role] || {}) ); - const permissionSets = ctx.state.user.roles.map(role => { - return Object.values(allPermissions[role] || {}).map(p => { - p = Object.assign({}, p); - for (let [k, v] of Object.entries(p)) { - p[k] = v = Object.assign({}, v); - if ( - [ - "devices", - "faults", - "presets", - "provisions", - "virtualParameters", - "files" - ].includes(k) - ) { - if (v.filter == null || v.filter === "") v.filter = true; - else v.filter = parse(v.filter); - } - } - return p; - }); - }); return permissionSets; } function authSimple(ctx, username, password): Promise { - const authConfig = localCache.getAuthConfig(ctx.state.configSnapshot); - const user = authConfig["simple"].users[username]; - if (user && user.password === password) { - const roles = user.roles.split(",").map(s => s.trim()); - return Promise.resolve(roles); - } + const users = localCache.getUsers(ctx.state.configSnapshot); + const user = users[username]; + if (user && user.password === password) return Promise.resolve(user.roles); + return Promise.resolve(null); } diff --git a/lib/ui/api-functions.ts b/lib/ui/api-functions.ts index dfa58b2..b68a5d4 100644 --- a/lib/ui/api-functions.ts +++ b/lib/ui/api-functions.ts @@ -65,6 +65,14 @@ export function deleteResource(resource, id): Promise { case "config": promise = db.deleteConfig(id); break; + + case "permissions": + promise = db.deletePermission(id); + break; + + case "users": + promise = db.deleteUser(id); + break; } promise @@ -183,6 +191,8 @@ export function putResource(resource, id, data): Promise { else if (resource === "virtualParameters") promise = db.putVirtualParameter(id, data); else if (resource === "config") promise = db.putConfig(id, data); + else if (resource === "permissions") promise = db.putPermission(id, data); + else if (resource === "users") promise = db.putUser(id, data); promise .then(() => { diff --git a/lib/ui/api.ts b/lib/ui/api.ts index a43e1c3..be3126c 100644 --- a/lib/ui/api.ts +++ b/lib/ui/api.ts @@ -26,6 +26,8 @@ const RESOURCE_IDS = { files: "_id", virtualParameters: "_id", config: "_id", + permissions: "_id", + users: "_id", faults: "_id", tasks: "_id" }; @@ -37,6 +39,8 @@ const resources = { files: 0 | RESOURCE_DELETE, virtualParameters: 0 | RESOURCE_DELETE | RESOURCE_PUT, config: 0 | RESOURCE_DELETE | RESOURCE_PUT, + permissions: 0 | RESOURCE_DELETE | RESOURCE_PUT, + users: 0 | RESOURCE_DELETE | RESOURCE_PUT, faults: 0 | RESOURCE_DELETE, tasks: 0 }; diff --git a/lib/ui/db.ts b/lib/ui/db.ts index 039eeb3..bbe2880 100644 --- a/lib/ui/db.ts +++ b/lib/ui/db.ts @@ -107,6 +107,9 @@ export function query( : options.projection ); } + + if (resource === "users") cursor.project({ password: 0 }); + if (options.skip) cursor.skip(options.skip); if (options.limit) cursor.limit(options.limit); @@ -282,6 +285,38 @@ export function deleteConfig(id): Promise { return deleteResource("config", id); } +export function putPermission(id, object): Promise { + return putResource("permissions", id, object); +} + +export function deletePermission(id): Promise { + return deleteResource("permissions", id); +} + +export function putUser(id, object): Promise { + return new Promise((resolve, reject) => { + getClient() + .then(client => { + const collection = client.db().collection("users"); + // update instead of replace to keep the password if not set by user + collection.updateOne( + { _id: id }, + { $set: object }, + { upsert: true }, + err => { + if (err) return void reject(err); + resolve(); + } + ); + }) + .catch(reject); + }); +} + +export function deleteUser(id): Promise { + return deleteResource("users", id); +} + export function putFile(filename, metadata, contentStream): Promise { return new Promise((resolve, reject) => { getClient() diff --git a/tools/configure-ui b/tools/configure-ui index a899ed0..8707022 100755 --- a/tools/configure-ui +++ b/tools/configure-ui @@ -2,32 +2,38 @@ import { MongoClient } from "mongodb"; import { get } from "../lib/config"; +const permissionsData = [ + { role: "admin", resource: "devices", access: 3, validate: ["pass"] }, + { role: "admin", resource: "faults", access: 3, validate: ["pass"] }, + { role: "admin", resource: "files", access: 3, validate: ["pass"] }, + { role: "admin", resource: "presets", access: 3, validate: ["pass"] }, + { role: "admin", resource: "provisions", access: 3, validate: ["pass"] }, + { role: "admin", resource: "config", access: 3, validate: ["pass"] }, + { role: "admin", resource: "permissions", access: 3, validate: ["pass"] }, + { role: "admin", resource: "users", access: 3, validate: ["pass"] }, + { + role: "admin", + resource: "virtualParameters", + access: 3, + validate: ["pass"] + }, + { role: "view", resource: "devices", access: 2, validate: [] }, + { role: "view", resource: "faults", access: 2, validate: [] }, + { role: "view", resource: "files", access: 2, validate: [] }, + { role: "view", resource: "presets", access: 2, validate: [] }, + { role: "view", resource: "provisions", access: 2, validate: [] }, + { role: "view", resource: "config", access: 2, validate: [] }, + { role: "view", resource: "users", access: 2, validate: [] }, + { role: "view", resource: "permissions", access: 2, validate: [] }, + { role: "view", resource: "virtualParameters", access: 2, validate: [] } +]; + +const usersData = [ + { username: "admin", password: "admin", roles: ["admin"] }, + { username: "view", password: "view", roles: ["view"] } +]; + const configData = { - "auth.simple.users.admin.password": "'admin'", - "auth.simple.users.admin.roles": "'admin'", - "auth.simple.users.view.password": "'view'", - "auth.simple.users.view.roles": "'view'", - "permissions.admin.0.devices.access": "3", - "permissions.admin.0.devices.validate.pass": "true", - "permissions.admin.0.faults.access": "3", - "permissions.admin.0.faults.validate.pass": "true", - "permissions.admin.0.files.access": "3", - "permissions.admin.0.files.validate.pass": "true", - "permissions.admin.0.presets.access": "3", - "permissions.admin.0.presets.validate.pass": "true", - "permissions.admin.0.provisions.access": "3", - "permissions.admin.0.provisions.validate.pass": "true", - "permissions.admin.0.config.access": "3", - "permissions.admin.0.config.validate.pass": "true", - "permissions.admin.0.virtualParameters.access": "3", - "permissions.admin.0.virtualParameters.validate.pass": "true", - "permissions.view.0.devices.access": "2", - "permissions.view.0.faults.access": "2", - "permissions.view.0.files.access": "2", - "permissions.view.0.presets.access": "2", - "permissions.view.0.config.access": "2", - "permissions.view.0.provisions.access": "2", - "permissions.view.0.virtualParameters.access": "2", "ui.device.0.type": "'tags'", "ui.device.1.type": "'ping'", "ui.device.2.parameters.0.components.0.type": "'parameter'", @@ -162,13 +168,54 @@ async function run(): Promise { const client = await MongoClient.connect(get("MONGODB_CONNECTION_URL"), { useNewUrlParser: true }); - const collection = client.db().collection("config"); - await collection.deleteMany({}); + + const deletes = []; + for (const col of ["permissions", "users", "config"]) { + deletes.push( + client + .db() + .collection(col) + .deleteMany({}) + ); + } + + await Promise.all(deletes); + + const inserts = []; const objs = Object.entries(configData).map(c => ({ _id: c[0], value: c[1] })); - await collection.insertMany(objs); + inserts.push( + client + .db() + .collection("config") + .insertMany(objs) + ); + const perms = permissionsData.map(p => { + p.validate = (p.validate || []).join(","); + return Object.assign({ _id: `${p.role}:${p.resource}:${p.access}` }, p); + }); + inserts.push( + client + .db() + .collection("permissions") + .insertMany(perms) + ); + const users = usersData.map(p => { + p.roles = (p.roles || []).join(","); + p._id = p.username; + delete p.username; + return p; + }); + inserts.push( + client + .db() + .collection("users") + .insertMany(users) + ); + + await Promise.all(inserts); client.close(); } diff --git a/ui/admin-menu.ts b/ui/admin-menu.ts index 50f9add..46d2424 100644 --- a/ui/admin-menu.ts +++ b/ui/admin-menu.ts @@ -57,6 +57,26 @@ const component: ClosureComponent = (): Component => { ); } + if (window.authorizer.hasAccess("permissions", 1)) { + tabs.push( + m( + "li", + { class: active["permissions"] }, + m("a", { href: "#!/admin/permissions" }, "Permissions") + ) + ); + } + + if (window.authorizer.hasAccess("users", 1)) { + tabs.push( + m( + "li", + { class: active["users"] }, + m("a", { href: "#!/admin/users" }, "Users") + ) + ); + } + return m("nav#side-menu", m("ul", tabs)); } }; diff --git a/ui/app.ts b/ui/app.ts index 2f02062..4a6b07e 100644 --- a/ui/app.ts +++ b/ui/app.ts @@ -12,6 +12,8 @@ import * as provisionsPage from "./provisions-page"; import * as virtualParametersPage from "./virtual-parameters-page"; import * as filesPage from "./files-page"; import * as configPage from "./config-page"; +import * as permissionsPage from "./permissions-page"; +import * as usersPage from "./users-page"; import Authorizer from "../lib//common/authorizer"; import * as notifications from "./notifications"; import { contextifyComponent } from "./components"; @@ -32,7 +34,9 @@ const adminPages = [ "provisions", "virtualParameters", "files", - "config" + "config", + "users", + "permissions" ]; let state; @@ -125,5 +129,7 @@ m.route(document.body, "/overview", { virtualParametersPage ), "/admin/files": pagify("files", filesPage), - "/admin/config": pagify("config", configPage) + "/admin/config": pagify("config", configPage), + "/admin/users": pagify("users", usersPage), + "/admin/permissions": pagify("permissions", permissionsPage) }); diff --git a/ui/layout.ts b/ui/layout.ts index 8b52edb..01a9a26 100644 --- a/ui/layout.ts +++ b/ui/layout.ts @@ -10,7 +10,9 @@ const adminPages = [ "provisions", "virtualParameters", "files", - "config" + "config", + "users", + "permissions" ]; const component: ClosureComponent = (): Component => { diff --git a/ui/permissions-page.ts b/ui/permissions-page.ts new file mode 100644 index 0000000..1d83e17 --- /dev/null +++ b/ui/permissions-page.ts @@ -0,0 +1,465 @@ +"use strict"; + +import { m } from "./components"; + +import config from "./config"; +import * as store from "./store"; +import * as notifications from "./notifications"; +import memoize from "../lib/common/memoize"; +import putFormComponent from "./put-form-component"; +import * as overlay from "./overlay"; +import * as smartQuery from "./smart-query"; +import { validators } from "../lib/common/authorizer"; +import { map, parse, stringify } from "../lib/common/expression-parser"; +import filterComponent from "./filter-component"; +import { Children, ClosureComponent, Component } from "mithril"; + +const PAGE_SIZE = config.ui.pageSize || 10; + +const memoizedParse = memoize(parse); +const memoizedJsonParse = memoize(JSON.parse); + +const attributes = [ + { id: "role", label: "Role" }, + { + id: "resource", + label: "Resource", + type: "combo", + options: [ + "config", + "devices", + "faults", + "files", + "permissions", + "presets", + "provisions", + "virtualParameters" + ] + }, + { id: "filter", label: "Filter", unsortable: true }, + { + id: "access", + label: "Access", + type: "combo", + options: ["1: count", "2: read", "3: write"] + }, + { + id: "validate", + label: "Validators", + type: "multi", + options: Object.keys(validators), + unsortable: true + } +]; + +const unpackSmartQuery = memoize(query => { + return map(query, e => { + if (Array.isArray(e) && e[0] === "FUNC" && e[1] === "Q") + return smartQuery.unpack("permissions", e[2], e[3]); + return e; + }); +}); + +interface ValidationErrors { + [prop: string]: string; +} + +function putActionHandler(action, _object): Promise { + return new Promise((resolve, reject) => { + const object = Object.assign({}, _object); + if (action === "save") { + if (!object.role) return void resolve({ role: "Role can not be empty" }); + if (!object.resource) + return void resolve({ resource: "Resource can not be empty" }); + if (!object.access) + return void resolve({ access: "Access can not be empty" }); + + if (object.access === "3: write") object.access = 3; + else if (object.access === "2: read") object.access = 2; + else if (object.access === "1: count") object.access = 1; + else return void resolve({ access: "Invalid access level" }); + + object.filter = object.filter || ""; + try { + memoizedParse(object.filter); + } catch (err) { + return void resolve({ + filter: "Filter must be valid expression" + }); + } + + object.validate = (object.validate || []).join(","); + + const id = `${object.role}:${object.resource}:${object.access}`; + + store + .resourceExists("permissions", id) + .then(exists => { + if (exists) { + store.fulfill(0, Date.now()); + return void resolve({ _id: "Permission already exists" }); + } + + store + .putResource("permissions", id, object) + .then(() => { + notifications.push("success", `Permission created"}`); + store.fulfill(0, Date.now()); + resolve(); + }) + .catch(reject); + }) + .catch(reject); + } else if (action === "delete") { + store + .deleteResource("permissions", object["_id"]) + .then(() => { + notifications.push("success", "Permission deleted"); + store.fulfill(0, Date.now()); + resolve(); + }) + .catch(err => { + store.fulfill(0, Date.now()); + reject(err); + }); + } else { + reject(new Error("Undefined action")); + } + }); +} + +const formData = { + resource: "permissions", + attributes: attributes +}; + +const getDownloadUrl = memoize(filter => { + const cols = {}; + for (const attr of attributes) cols[attr.label] = attr.id; + return `/api/permissions.csv?${m.buildQueryString({ + filter: stringify(filter), + columns: JSON.stringify(cols) + })}`; +}); + +export function init(args): Promise<{}> { + if (!window.authorizer.hasAccess("permissions", 2)) { + return Promise.reject( + new Error("You are not authorized to view this page") + ); + } + const sort = args.sort; + const filter = args.filter; + return Promise.resolve({ filter, sort }); +} + +function renderTable( + permissionsResponse, + total, + selected, + showMoreCallback, + downloadUrl, + sort, + onSortChange +): Children { + const permissions = permissionsResponse.value; + const selectAll = m("input", { + type: "checkbox", + checked: permissions.length && selected.size === permissions.length, + onchange: e => { + for (const permission of permissions) { + if (e.target.checked) selected.add(permission["_id"]); + else selected.delete(permission["_id"]); + } + }, + disabled: !total + }); + + const labels = [m("th", selectAll)]; + for (const attr of attributes) { + const label = attr.label; + + if (attr.unsortable) { + labels.push(m("th", label)); + continue; + } + + let direction = 1; + + let symbol = "\u2981"; + if (sort[attr.id] > 0) symbol = "\u2bc6"; + else if (sort[attr.id] < 0) symbol = "\u2bc5"; + + const sortable = m( + "button", + { + onclick: () => { + if (sort[attr.id] > 0) direction *= -1; + return onSortChange(JSON.stringify({ [attr.id]: direction })); + } + }, + symbol + ); + + labels.push(m("th", [label, sortable])); + } + + const rows = []; + for (const permission of permissions) { + const checkbox = m("input", { + type: "checkbox", + checked: selected.has(permission["_id"]), + onchange: e => { + if (e.target.checked) selected.add(permission["_id"]); + else selected.delete(permission["_id"]); + }, + onclick: e => { + e.stopPropagation(); + e.redraw = false; + } + }); + + const tds = [m("td", checkbox)]; + for (const attr of attributes) { + let val = permission[attr.id]; + if (attr.id === "access") { + if (val === 1) val = "1: count"; + else if (val === 2) val = "2: read"; + else if (val === 3) val = "3: write"; + } + tds.push(m("td", val)); + } + + tds.push( + m( + "td.table-row-links", + m( + "button", + { + title: "Delete permission", + onclick: () => { + if ( + !confirm(`Deleting ${permission._id} permission. Are you sure?`) + ) + return; + + putActionHandler("delete", permission).catch(err => { + throw err; + }); + } + }, + "✕" + ) + ) + ); + + rows.push( + m( + "tr", + { + onclick: e => { + if (["INPUT", "BUTTON", "A"].includes(e.target.nodeName)) { + e.redraw = false; + return; + } + + if (!selected.delete(permission["_id"])) + selected.add(permission["_id"]); + } + }, + tds + ) + ); + } + + if (!rows.length) { + rows.push( + m("tr.empty", m("td", { colspan: labels.length }, "No permissions")) + ); + } + + const footerElements = []; + if (total != null) footerElements.push(`${permissions.length}/${total}`); + else footerElements.push(`${permissions.length}`); + + footerElements.push( + m( + "button", + { + title: "Show more permissions", + onclick: showMoreCallback, + disabled: permissions.length >= total || !permissionsResponse.fulfilled + }, + "More" + ) + ); + + if (downloadUrl) { + footerElements.push( + m("a.download-csv", { href: downloadUrl, download: "" }, "Download") + ); + } + + const tfoot = m( + "tfoot", + m("tr", m("td", { colspan: labels.length }, footerElements)) + ); + + const buttons = [ + m( + "button.primary", + { + title: "Delete selected permissions", + disabled: !selected.size, + onclick: e => { + e.redraw = false; + e.target.disabled = true; + Promise.all( + Array.from(selected).map(id => + store.deleteResource("permissions", id) + ) + ) + .then(res => { + notifications.push( + "success", + `${res.length} permissions deleted` + ); + store.fulfill(0, Date.now()); + }) + .catch(err => { + notifications.push("error", err.message); + store.fulfill(0, Date.now()); + }); + } + }, + "Delete" + ) + ]; + + if (window.authorizer.hasAccess("permissions", 3)) { + buttons.push( + m( + "button.primary", + { + title: "Create new permission", + onclick: () => { + const cb = (): Children => { + return m( + putFormComponent, + Object.assign( + { + actionHandler: (action, object) => { + return new Promise(resolve => { + putActionHandler(action, object) + .then(errors => { + const errorList = errors + ? Object.values(errors) + : []; + if (errorList.length) { + for (const err of errorList) + notifications.push("error", err); + } else { + overlay.close(cb); + } + resolve(); + }) + .catch(err => { + notifications.push("error", err.message); + resolve(); + }); + }); + } + }, + formData + ) + ); + }; + overlay.open(cb); + } + }, + "New" + ) + ); + } + + return [ + m( + "table.table.highlight", + m("thead", m("tr", labels)), + m("tbody", rows), + tfoot + ), + m("div.actions-bar", buttons) + ]; +} + +export const component: ClosureComponent = (): Component => { + return { + view: vnode => { + document.title = "Permissions - GenieACS"; + + function showMore(): void { + vnode.state["showCount"] = + (vnode.state["showCount"] || PAGE_SIZE) + PAGE_SIZE; + m.redraw(); + } + + function onFilterChanged(filter): void { + const ops = { filter }; + if (vnode.attrs["sort"]) ops["sort"] = vnode.attrs["sort"]; + m.route.set(m.route.get(), ops); + } + + function onSortChange(sort): void { + const ops = { sort }; + if (vnode.attrs["filter"]) ops["filter"] = vnode.attrs["filter"]; + m.route.set(m.route.get(), ops); + } + + const sort = vnode.attrs["sort"] + ? memoizedJsonParse(vnode.attrs["sort"]) + : {}; + let filter = vnode.attrs["filter"] + ? memoizedParse(vnode.attrs["filter"]) + : true; + filter = unpackSmartQuery(filter); + + const permissions = store.fetch("permissions", filter, { + limit: vnode.state["showCount"] || PAGE_SIZE, + sort: sort + }); + + const count = store.count("permissions", filter); + + const selected = new Set(); + if (vnode.state["selected"]) { + for (const permission of permissions.value) { + if (vnode.state["selected"].has(permission["_id"])) + selected.add(permission["_id"]); + } + } + vnode.state["selected"] = selected; + + const downloadUrl = getDownloadUrl(filter); + + const attrs = {}; + attrs["resource"] = "permissions"; + attrs["filter"] = vnode.attrs["filter"]; + attrs["onChange"] = onFilterChanged; + + return [ + m("h1", "Listing permissions"), + m(filterComponent, attrs), + renderTable( + permissions, + count.value, + selected, + showMore, + downloadUrl, + sort, + onSortChange + ) + ]; + } + }; +}; diff --git a/ui/put-form-component.ts b/ui/put-form-component.ts index 6e00f5a..4b0b09a 100644 --- a/ui/put-form-component.ts +++ b/ui/put-form-component.ts @@ -34,6 +34,30 @@ function createField(current, attr, focus): Children { }, options ); + } else if (attr.type === "multi") { + const currentSelected = new Set(current.object[attr.id]); + const options = attr.options.map(op => { + const id = `${attr.id}-${op}`; + const opts = { + type: "checkbox", + id: id, + value: op, + oncreate: _vnode => { + if (focus && !options.length) _vnode.dom.focus(); + if (currentSelected.has(op)) _vnode.dom.checked = true; + }, + onchange: e => { + if (e.target.checked) currentSelected.add(op); + else currentSelected.delete(op); + current.object[attr.id] = Array.from(currentSelected); + e.redraw = false; + } + }; + + return m("tr", [m("td", m("input", opts)), m("td", op)]); + }); + + return m("table", options); } else if (attr.type === "code") { return m("textarea", { name: attr.id, @@ -80,7 +104,7 @@ function createField(current, attr, focus): Children { } return m("input", { - type: "text", + type: attr.type === "password" ? "password" : "text", name: attr.id, disabled: attr.id === "_id" && !current.isNew, value: current.object[attr.id], diff --git a/ui/smart-query.ts b/ui/smart-query.ts index 69c9b02..a3eb448 100644 --- a/ui/smart-query.ts +++ b/ui/smart-query.ts @@ -30,7 +30,13 @@ const resources = { type: "string" }, Version: { parameter: ["PARAM", "metadata.version"], type: "string" } - } + }, + permissions: { + Role: { parameter: ["PARAM", "role"], type: "string" }, + Resource: { parameter: ["PARAM", "resource"], type: "string" }, + Access: { parameter: ["PARAM", "access"], type: "number" } + }, + users: { Username: { parameter: ["PARAM", "_id"], type: "string" } } }; for (const v of Object.values(config.ui.filters as { diff --git a/ui/store.ts b/ui/store.ts index 2fbae2e..3988670 100644 --- a/ui/store.ts +++ b/ui/store.ts @@ -38,7 +38,9 @@ for (const r of [ "provisions", "virtualParameters", "files", - "config" + "config", + "users", + "permissions" ]) { resources[r] = { objects: new Map(), diff --git a/ui/users-page.ts b/ui/users-page.ts new file mode 100644 index 0000000..229f020 --- /dev/null +++ b/ui/users-page.ts @@ -0,0 +1,468 @@ +"use strict"; + +import { m } from "./components"; + +import config from "./config"; +import * as store from "./store"; +import * as notifications from "./notifications"; +import memoize from "../lib/common/memoize"; +import putFormComponent from "./put-form-component"; +import * as overlay from "./overlay"; +import * as smartQuery from "./smart-query"; +import { map, parse, stringify } from "../lib/common/expression-parser"; +import filterComponent from "./filter-component"; +import { Children, ClosureComponent, Component } from "mithril"; + +const PAGE_SIZE = config.ui.pageSize || 10; + +const memoizedParse = memoize(parse); +const memoizedJsonParse = memoize(JSON.parse); + +const attributes = [ + { id: "_id", label: "Username" }, + { id: "password", label: "Password", type: "password", unsortable: true }, + { + id: "roles", + label: "Roles", + type: "multi", + options: [], + unsortable: true + } +]; + +const unpackSmartQuery = memoize(query => { + return map(query, e => { + if (Array.isArray(e) && e[0] === "FUNC" && e[1] === "Q") + return smartQuery.unpack("users", e[2], e[3]); + return e; + }); +}); + +interface ValidationErrors { + [prop: string]: string; +} + +function putActionHandler(action, _object, isNew): Promise { + return new Promise((resolve, reject) => { + const object = Object.assign({}, _object); + if (action === "save") { + const id = object["_id"]; + delete object["_id"]; + + if (!id) return void resolve({ _id: "ID can not be empty" }); + + if (!Array.isArray(object.roles) || !object.roles.length) + return void resolve({ roles: "Role(s) must be selected" }); + + if (typeof object.password !== "string" || !object.password.length) { + if (isNew) + return void resolve({ password: "Password can not be empty" }); + else delete object.password; + } + + object.roles = object.roles.join(","); + + store + .resourceExists("users", id) + .then(exists => { + if (exists && isNew) { + store.fulfill(0, Date.now()); + return void resolve({ _id: "User already exists" }); + } + + if (!exists && !isNew) { + store.fulfill(0, Date.now()); + return void resolve({ _id: "User does not exist" }); + } + + store + .putResource("users", id, object) + .then(() => { + notifications.push( + "success", + `User ${exists ? "updated" : "created"}` + ); + store.fulfill(0, Date.now()); + resolve(); + }) + .catch(reject); + }) + .catch(reject); + } else if (action === "delete") { + store + .deleteResource("users", object["_id"]) + .then(() => { + notifications.push("success", "User deleted"); + store.fulfill(0, Date.now()); + resolve(); + }) + .catch(err => { + store.fulfill(0, Date.now()); + reject(err); + }); + } else { + reject(new Error("Undefined action")); + } + }); +} + +const formData = { + resource: "users", + attributes: attributes +}; + +const getDownloadUrl = memoize(filter => { + const cols = {}; + for (const attr of attributes) + if (attr.id !== "password") cols[attr.label] = attr.id; + + return `/api/users.csv?${m.buildQueryString({ + filter: stringify(filter), + columns: JSON.stringify(cols) + })}`; +}); + +export function init(args): Promise<{}> { + if (!window.authorizer.hasAccess("users", 2)) { + return Promise.reject( + new Error("You are not authorized to view this page") + ); + } + + const sort = args.sort; + const filter = args.filter; + return Promise.resolve({ filter, sort }); +} + +function renderTable( + usersResponse, + total, + selected, + showMoreCallback, + downloadUrl, + sort, + onSortChange +): Children { + const users = usersResponse.value; + const selectAll = m("input", { + type: "checkbox", + checked: users.length && selected.size === users.length, + onchange: e => { + for (const user of users) { + if (e.target.checked) selected.add(user["_id"]); + else selected.delete(user["_id"]); + } + }, + disabled: !total + }); + + const labels = [m("th", selectAll)]; + for (const attr of attributes) { + if (attr.id === "password") continue; + + const label = attr.label; + + if (attr.unsortable) { + labels.push(m("th", label)); + continue; + } + + let direction = 1; + + let symbol = "\u2981"; + if (sort[attr.id] > 0) symbol = "\u2bc6"; + else if (sort[attr.id] < 0) symbol = "\u2bc5"; + + const sortable = m( + "button", + { + onclick: () => { + if (sort[attr.id] > 0) direction *= -1; + return onSortChange(JSON.stringify({ [attr.id]: direction })); + } + }, + symbol + ); + + labels.push(m("th", [label, sortable])); + } + + const rows = []; + for (const user of users) { + const checkbox = m("input", { + type: "checkbox", + checked: selected.has(user["_id"]), + onchange: e => { + if (e.target.checked) selected.add(user["_id"]); + else selected.delete(user["_id"]); + }, + onclick: e => { + e.stopPropagation(); + e.redraw = false; + } + }); + + const tds = [m("td", checkbox)]; + for (const attr of attributes) + if (attr.id !== "password") tds.push(m("td", user[attr.id])); + + tds.push( + m( + "td.table-row-links", + m( + "a", + { + onclick: () => { + const cb = (): Children => { + return m( + putFormComponent, + Object.assign( + { + base: { + _id: user._id, + roles: user.roles.split(",") + }, + actionHandler: (action, object) => { + return new Promise(resolve => { + putActionHandler(action, object, false) + .then(errors => { + const errorList = errors + ? Object.values(errors) + : []; + if (errorList.length) { + for (const err of errorList) + notifications.push("error", err); + } else { + overlay.close(cb); + } + resolve(); + }) + .catch(err => { + notifications.push("error", err.message); + resolve(); + }); + }); + } + }, + formData + ) + ); + }; + overlay.open(cb); + } + }, + "Show" + ) + ) + ); + + rows.push( + m( + "tr", + { + onclick: e => { + if (["INPUT", "BUTTON", "A"].includes(e.target.nodeName)) { + e.redraw = false; + return; + } + + if (!selected.delete(user["_id"])) selected.add(user["_id"]); + } + }, + tds + ) + ); + } + + if (!rows.length) + rows.push(m("tr.empty", m("td", { colspan: labels.length }, "No users"))); + + const footerElements = []; + if (total != null) footerElements.push(`${users.length}/${total}`); + else footerElements.push(`${users.length}`); + + footerElements.push( + m( + "button", + { + title: "Show more users", + onclick: showMoreCallback, + disabled: users.length >= total || !usersResponse.fulfilled + }, + "More" + ) + ); + + if (downloadUrl) { + footerElements.push( + m("a.download-csv", { href: downloadUrl, download: "" }, "Download") + ); + } + + const tfoot = m( + "tfoot", + m("tr", m("td", { colspan: labels.length }, footerElements)) + ); + + const buttons = [ + m( + "button.primary", + { + title: "Delete selected users", + disabled: !selected.size, + onclick: e => { + e.redraw = false; + e.target.disabled = true; + Promise.all( + Array.from(selected).map(id => store.deleteResource("users", id)) + ) + .then(res => { + notifications.push("success", `${res.length} users deleted`); + store.fulfill(0, Date.now()); + }) + .catch(err => { + notifications.push("error", err.message); + store.fulfill(0, Date.now()); + }); + } + }, + "Delete" + ) + ]; + + if (window.authorizer.hasAccess("users", 3)) { + buttons.push( + m( + "button.primary", + { + title: "Create new user", + onclick: () => { + const cb = (): Children => { + return m( + putFormComponent, + Object.assign( + { + actionHandler: (action, object) => { + return new Promise(resolve => { + putActionHandler(action, object, true) + .then(errors => { + const errorList = errors + ? Object.values(errors) + : []; + if (errorList.length) { + for (const err of errorList) + notifications.push("error", err); + } else { + overlay.close(cb); + } + resolve(); + }) + .catch(err => { + notifications.push("error", err.message); + resolve(); + }); + }); + } + }, + formData + ) + ); + }; + overlay.open(cb); + } + }, + "New" + ) + ); + } + + return [ + m( + "table.table.highlight", + m("thead", m("tr", labels)), + m("tbody", rows), + tfoot + ), + m("div.actions-bar", buttons) + ]; +} + +export const component: ClosureComponent = (): Component => { + return { + view: vnode => { + document.title = "Users - GenieACS"; + + function showMore(): void { + vnode.state["showCount"] = + (vnode.state["showCount"] || PAGE_SIZE) + PAGE_SIZE; + m.redraw(); + } + + function onFilterChanged(filter): void { + const ops = { filter }; + if (vnode.attrs["sort"]) ops["sort"] = vnode.attrs["sort"]; + m.route.set(m.route.get(), ops); + } + + function onSortChange(sort): void { + const ops = { sort }; + if (vnode.attrs["filter"]) ops["filter"] = vnode.attrs["filter"]; + m.route.set(m.route.get(), ops); + } + + const sort = vnode.attrs["sort"] + ? memoizedJsonParse(vnode.attrs["sort"]) + : {}; + let filter = vnode.attrs["filter"] + ? memoizedParse(vnode.attrs["filter"]) + : true; + filter = unpackSmartQuery(filter); + + const users = store.fetch("users", filter, { + limit: vnode.state["showCount"] || PAGE_SIZE, + sort: sort + }); + + const count = store.count("users", filter); + + // Getting the roles + const permissions = store.fetch("permissions", true); + if (permissions.fulfilled) { + for (const attr of attributes) { + if (attr.id === "roles") + attr.options = [...new Set(permissions.value.map(p => p.role))]; + } + } + + const selected = new Set(); + if (vnode.state["selected"]) { + for (const user of users.value) { + if (vnode.state["selected"].has(user["_id"])) + selected.add(user["_id"]); + } + } + vnode.state["selected"] = selected; + + const downloadUrl = getDownloadUrl(filter); + + const attrs = {}; + attrs["resource"] = "users"; + attrs["filter"] = vnode.attrs["filter"]; + attrs["onChange"] = onFilterChanged; + + return [ + m("h1", "Listing users"), + m(filterComponent, attrs), + renderTable( + users, + count.value, + selected, + showMore, + downloadUrl, + sort, + onSortChange + ) + ]; + } + }; +};