mirror of
https://gitlab.com/wgp/dougal/software.git
synced 2025-12-06 07:17:07 +00:00
Compare commits
90 Commits
v2024.19.1
...
177-refact
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c5ea7f30a | ||
|
|
302642f88d | ||
|
|
48e1369088 | ||
|
|
daa700e7dc | ||
|
|
8db2c8ce25 | ||
|
|
890e48e078 | ||
|
|
11829555cf | ||
|
|
07d8e97f74 | ||
|
|
fc379aba14 | ||
|
|
8cbacb9aa7 | ||
|
|
acb59035e4 | ||
|
|
b7d0ee7da7 | ||
|
|
3a0f720f2f | ||
|
|
6cf6fe29f4 | ||
|
|
6f0f2dadcc | ||
|
|
64fba1adc3 | ||
|
|
3ea82cb660 | ||
|
|
84c1385f88 | ||
|
|
b1b7332216 | ||
|
|
8e7451e17a | ||
|
|
bdeb2b8742 | ||
|
|
ccfabf84f7 | ||
|
|
5d4e219403 | ||
|
|
3b7e4c9f0b | ||
|
|
683f5680b1 | ||
|
|
ce901a03a1 | ||
|
|
f8e5b74c1a | ||
|
|
ec41d26a7a | ||
|
|
386fd59900 | ||
|
|
e47020a21e | ||
|
|
b8f58ac67c | ||
|
|
b3e27ed1b9 | ||
|
|
f5441d186f | ||
|
|
d58bc4d62e | ||
|
|
01d1691def | ||
|
|
bc444fc066 | ||
|
|
989ec84852 | ||
|
|
065f6617af | ||
|
|
825530c1fe | ||
|
|
1ef8eb871f | ||
|
|
2e9c603ab8 | ||
|
|
2657c42dcc | ||
|
|
63e6af545a | ||
|
|
d6fb7404b1 | ||
|
|
8188766a81 | ||
|
|
b7ae657137 | ||
|
|
1295ec2ee3 | ||
|
|
7c6d3fe5ee | ||
|
|
15570e0f3d | ||
|
|
d551e67042 | ||
|
|
6b216f7406 | ||
|
|
a7e02c526b | ||
|
|
55855d66e9 | ||
|
|
ae79d90fef | ||
|
|
c8b2047483 | ||
|
|
d21cde20fc | ||
|
|
10580ea3ec | ||
|
|
25f83d1eb3 | ||
|
|
dc294b5b50 | ||
|
|
b035d3481c | ||
|
|
ca4a14ffd9 | ||
|
|
d77f7f66db | ||
|
|
6b6f545b9f | ||
|
|
bdf62e2d8b | ||
|
|
1895168889 | ||
|
|
8c875ea2f9 | ||
|
|
addbe2d572 | ||
|
|
85f092b9e1 | ||
|
|
eb99d74e4a | ||
|
|
e65afdcaa1 | ||
|
|
0b7e9e1d01 | ||
|
|
9ad17de4cb | ||
|
|
071fd7438b | ||
|
|
9cc21ba06a | ||
|
|
712b20c596 | ||
|
|
8bbe3aee70 | ||
|
|
dc22bb95fd | ||
|
|
0ef2e60d15 | ||
|
|
289d50d9c1 | ||
|
|
3189a06d75 | ||
|
|
9ef551db76 | ||
|
|
e6669026fa | ||
|
|
12082b91a3 | ||
|
|
7db9155899 | ||
|
|
f8692afad3 | ||
|
|
028cab5188 | ||
|
|
fc73fbfb9f | ||
|
|
96a8d3689a | ||
|
|
7a7106e735 | ||
|
|
d5a10ca273 |
@@ -18,6 +18,7 @@ transform = {
|
||||
"int": lambda v: builtins.int(float(v)),
|
||||
"float": float,
|
||||
"string": str,
|
||||
"str": str,
|
||||
"bool": to_bool
|
||||
}
|
||||
|
||||
@@ -34,7 +35,7 @@ def parse_line (line, fields, fixed = None):
|
||||
|
||||
for key in fields:
|
||||
spec = fields[key]
|
||||
transformer = transform[spec["type"]]
|
||||
transformer = transform[spec.get("type", "str")]
|
||||
pos_from = spec["offset"]
|
||||
pos_to = pos_from + spec["length"]
|
||||
text = line[pos_from:pos_to]
|
||||
|
||||
109
etc/db/upgrades/upgrade37-v0.6.0-add-keystore-table.sql
Normal file
109
etc/db/upgrades/upgrade37-v0.6.0-add-keystore-table.sql
Normal file
@@ -0,0 +1,109 @@
|
||||
-- Fix final_lines_summary view
|
||||
--
|
||||
-- New schema version: 0.6.0
|
||||
--
|
||||
-- ATTENTION:
|
||||
--
|
||||
-- ENSURE YOU HAVE BACKED UP THE DATABASE BEFORE RUNNING THIS SCRIPT.
|
||||
--
|
||||
--
|
||||
-- NOTE: This upgrade only affects the `public` schema.
|
||||
-- NOTE: Each application starts a transaction, which must be committed
|
||||
-- or rolled back.
|
||||
--
|
||||
-- This update adds a `keystore` table, intended for storing arbitrary
|
||||
-- key / value pairs which, unlike, the `info` tables, is not meant to
|
||||
-- be directly accessible via the API. Its main purpose as of this writing
|
||||
-- is to store user definitions (see #176, #177, #180).
|
||||
--
|
||||
-- To apply, run as the dougal user:
|
||||
--
|
||||
-- psql <<EOF
|
||||
-- \i $THIS_FILE
|
||||
-- COMMIT;
|
||||
-- EOF
|
||||
--
|
||||
-- NOTE: It can be applied multiple times without ill effect.
|
||||
--
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE OR REPLACE PROCEDURE pg_temp.show_notice (notice text) AS $$
|
||||
BEGIN
|
||||
RAISE NOTICE '%', notice;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE PROCEDURE pg_temp.upgrade_database () AS $outer$
|
||||
BEGIN
|
||||
|
||||
RAISE NOTICE 'Updating schema %', 'public';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS keystore (
|
||||
type TEXT NOT NULL, -- A class of data to be stored
|
||||
key TEXT NOT NULL, -- A key that is unique for the class and access type
|
||||
last_modified TIMESTAMP -- To detect update conflicts
|
||||
DEFAULT CURRENT_TIMESTAMP,
|
||||
data jsonb,
|
||||
PRIMARY KEY (type, key) -- Composite primary key
|
||||
);
|
||||
|
||||
-- Create a function to update the last_modified timestamp
|
||||
CREATE OR REPLACE FUNCTION update_last_modified()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.last_modified = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create a trigger that calls the function before each update
|
||||
CREATE OR REPLACE TRIGGER update_keystore_last_modified
|
||||
BEFORE UPDATE ON keystore
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_last_modified();
|
||||
|
||||
END;
|
||||
$outer$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE PROCEDURE pg_temp.upgrade () AS $outer$
|
||||
DECLARE
|
||||
row RECORD;
|
||||
current_db_version TEXT;
|
||||
BEGIN
|
||||
|
||||
SELECT value->>'db_schema' INTO current_db_version FROM public.info WHERE key = 'version';
|
||||
|
||||
IF current_db_version >= '0.6.0' THEN
|
||||
RAISE EXCEPTION
|
||||
USING MESSAGE='Patch already applied';
|
||||
END IF;
|
||||
|
||||
IF current_db_version != '0.5.3' THEN
|
||||
RAISE EXCEPTION
|
||||
USING MESSAGE='Invalid database version: ' || current_db_version,
|
||||
HINT='Ensure all previous patches have been applied.';
|
||||
END IF;
|
||||
|
||||
CALL pg_temp.upgrade_database();
|
||||
END;
|
||||
$outer$ LANGUAGE plpgsql;
|
||||
|
||||
CALL pg_temp.upgrade();
|
||||
|
||||
CALL pg_temp.show_notice('Cleaning up');
|
||||
DROP PROCEDURE pg_temp.upgrade_database ();
|
||||
DROP PROCEDURE pg_temp.upgrade ();
|
||||
|
||||
CALL pg_temp.show_notice('Updating db_schema version');
|
||||
INSERT INTO public.info VALUES ('version', '{"db_schema": "0.6.0"}')
|
||||
ON CONFLICT (key) DO UPDATE
|
||||
SET value = public.info.value || '{"db_schema": "0.6.0"}' WHERE public.info.key = 'version';
|
||||
|
||||
|
||||
CALL pg_temp.show_notice('All done. You may now run "COMMIT;" to persist the changes');
|
||||
DROP PROCEDURE pg_temp.show_notice (notice text);
|
||||
|
||||
--
|
||||
--NOTE Run `COMMIT;` now if all went well
|
||||
--
|
||||
107
etc/db/upgrades/upgrade38-v0.6.1-add-default-user.sql
Normal file
107
etc/db/upgrades/upgrade38-v0.6.1-add-default-user.sql
Normal file
@@ -0,0 +1,107 @@
|
||||
-- Fix final_lines_summary view
|
||||
--
|
||||
-- New schema version: 0.6.1
|
||||
--
|
||||
-- ATTENTION:
|
||||
--
|
||||
-- ENSURE YOU HAVE BACKED UP THE DATABASE BEFORE RUNNING THIS SCRIPT.
|
||||
--
|
||||
--
|
||||
-- NOTE: This upgrade only affects the `public` schema.
|
||||
-- NOTE: Each application starts a transaction, which must be committed
|
||||
-- or rolled back.
|
||||
--
|
||||
-- This update adds a default user to the system (see #176, #177, #180).
|
||||
-- The default user can only be invoked by connecting from localhost.
|
||||
--
|
||||
-- This user has full access to every project via the organisations
|
||||
-- permissions wildcard: `{"*": {read: true, write: true, edit: true}}`
|
||||
-- and can be used to bootstrap the system by creating other users
|
||||
-- and assigning organisational permissions.
|
||||
--
|
||||
-- To apply, run as the dougal user:
|
||||
--
|
||||
-- psql <<EOF
|
||||
-- \i $THIS_FILE
|
||||
-- COMMIT;
|
||||
-- EOF
|
||||
--
|
||||
-- NOTE: It can be applied multiple times without ill effect.
|
||||
--
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE OR REPLACE PROCEDURE pg_temp.show_notice (notice text) AS $$
|
||||
BEGIN
|
||||
RAISE NOTICE '%', notice;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE PROCEDURE pg_temp.upgrade_database () AS $outer$
|
||||
BEGIN
|
||||
|
||||
RAISE NOTICE 'Updating schema %', 'public';
|
||||
|
||||
INSERT INTO keystore (type, key, data)
|
||||
VALUES ('user', '6f1e7159-4ca0-4ae4-ab4e-89078166cc10', '
|
||||
{
|
||||
"id": "6f1e7159-4ca0-4ae4-ab4e-89078166cc10",
|
||||
"ip": "127.0.0.0/24",
|
||||
"name": "☠️",
|
||||
"colour": "red",
|
||||
"active": true,
|
||||
"organisations": {
|
||||
"*": {
|
||||
"read": true,
|
||||
"write": true,
|
||||
"edit": true
|
||||
}
|
||||
}
|
||||
}
|
||||
'::jsonb)
|
||||
ON CONFLICT (type, key) DO NOTHING;
|
||||
|
||||
END;
|
||||
$outer$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE PROCEDURE pg_temp.upgrade () AS $outer$
|
||||
DECLARE
|
||||
row RECORD;
|
||||
current_db_version TEXT;
|
||||
BEGIN
|
||||
|
||||
SELECT value->>'db_schema' INTO current_db_version FROM public.info WHERE key = 'version';
|
||||
|
||||
IF current_db_version >= '0.6.1' THEN
|
||||
RAISE EXCEPTION
|
||||
USING MESSAGE='Patch already applied';
|
||||
END IF;
|
||||
|
||||
IF current_db_version != '0.6.0' THEN
|
||||
RAISE EXCEPTION
|
||||
USING MESSAGE='Invalid database version: ' || current_db_version,
|
||||
HINT='Ensure all previous patches have been applied.';
|
||||
END IF;
|
||||
|
||||
CALL pg_temp.upgrade_database();
|
||||
END;
|
||||
$outer$ LANGUAGE plpgsql;
|
||||
|
||||
CALL pg_temp.upgrade();
|
||||
|
||||
CALL pg_temp.show_notice('Cleaning up');
|
||||
DROP PROCEDURE pg_temp.upgrade_database ();
|
||||
DROP PROCEDURE pg_temp.upgrade ();
|
||||
|
||||
CALL pg_temp.show_notice('Updating db_schema version');
|
||||
INSERT INTO public.info VALUES ('version', '{"db_schema": "0.6.1"}')
|
||||
ON CONFLICT (key) DO UPDATE
|
||||
SET value = public.info.value || '{"db_schema": "0.6.1"}' WHERE public.info.key = 'version';
|
||||
|
||||
|
||||
CALL pg_temp.show_notice('All done. You may now run "COMMIT;" to persist the changes');
|
||||
DROP PROCEDURE pg_temp.show_notice (notice text);
|
||||
|
||||
--
|
||||
--NOTE Run `COMMIT;` now if all went well
|
||||
--
|
||||
@@ -45,11 +45,13 @@
|
||||
name: "No fire"
|
||||
id: no_fire
|
||||
check: |
|
||||
const currentShot = currentItem;
|
||||
const gunData = currentItem._("raw_meta.smsrc");
|
||||
(gunData && gunData.guns && gunData.guns.length != gunData.num_active)
|
||||
? `Source ${gunData.src_number}: No fire (${gunData.guns.length - gunData.num_active} guns)`
|
||||
: true;
|
||||
// const currentShot = currentItem;
|
||||
// const gunData = currentItem._("raw_meta.smsrc");
|
||||
// (gunData && gunData.guns && gunData.guns.length != gunData.num_active)
|
||||
// ? `Source ${gunData.src_number}: No fire (${gunData.guns.length - gunData.num_active} guns)`
|
||||
// : true;
|
||||
// Disabled due to changes in Smartsource software. It now returns all guns on every shot, not just active ones.
|
||||
true
|
||||
|
||||
-
|
||||
name: "Pressure errors"
|
||||
|
||||
75
lib/modules/@dougal/organisations/Organisation.js
Normal file
75
lib/modules/@dougal/organisations/Organisation.js
Normal file
@@ -0,0 +1,75 @@
|
||||
|
||||
class Organisation {
|
||||
|
||||
constructor (data) {
|
||||
|
||||
this.read = !!data?.read;
|
||||
this.write = !!data?.write;
|
||||
this.edit = !!data?.edit;
|
||||
|
||||
this.other = {};
|
||||
|
||||
return new Proxy(this, {
|
||||
get (target, prop) {
|
||||
if (prop in target) {
|
||||
return target[prop]
|
||||
} else {
|
||||
return target.other[prop];
|
||||
}
|
||||
},
|
||||
|
||||
set (target, prop, value) {
|
||||
const oldValue = target[prop] !== undefined ? target[prop] : target.other[prop];
|
||||
const newValue = Boolean(value);
|
||||
|
||||
if (["read", "write", "edit"].includes(prop)) {
|
||||
target[prop] = newValue;
|
||||
} else {
|
||||
target.other[prop] = newValue;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toJSON () {
|
||||
return {
|
||||
read: this.read,
|
||||
write: this.write,
|
||||
edit: this.edit,
|
||||
...this.other
|
||||
}
|
||||
}
|
||||
|
||||
toString (replacer, space) {
|
||||
return JSON.stringify(this.toJSON(), replacer, space);
|
||||
}
|
||||
|
||||
/** Limit the operations to only those allowed by `other`
|
||||
*/
|
||||
filter (other) {
|
||||
const filteredOrganisation = new Organisation();
|
||||
|
||||
filteredOrganisation.read = this.read && other.read;
|
||||
filteredOrganisation.write = this.write && other.write;
|
||||
filteredOrganisation.edit = this.edit && other.edit;
|
||||
|
||||
return filteredOrganisation;
|
||||
}
|
||||
|
||||
intersect (other) {
|
||||
return this.filter(other);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = Organisation; // CJS export
|
||||
}
|
||||
|
||||
// ESM export
|
||||
if (typeof exports !== 'undefined' && !exports.default) {
|
||||
exports.default = Organisation; // ESM export
|
||||
}
|
||||
225
lib/modules/@dougal/organisations/Organisations.js
Normal file
225
lib/modules/@dougal/organisations/Organisations.js
Normal file
@@ -0,0 +1,225 @@
|
||||
const Organisation = require('./Organisation');
|
||||
|
||||
class Organisations {
|
||||
|
||||
#values = {}
|
||||
|
||||
#overlord
|
||||
|
||||
static entries (orgs) {
|
||||
return orgs.names().map(name => [name, orgs.get(name)]);
|
||||
}
|
||||
|
||||
constructor (data, overlord) {
|
||||
if (data instanceof Organisations) {
|
||||
for (const [name, value] of Organisations.entries(data)) {
|
||||
this.set(name, new Organisation(value));
|
||||
}
|
||||
} else if (data instanceof Object) {
|
||||
for (const [name, value] of Object.entries(data)) {
|
||||
this.set(name, new Organisation(value));
|
||||
}
|
||||
} else if (data instanceof String) {
|
||||
this.set(data, new Organisation());
|
||||
} else if (typeof data !== "undefined") {
|
||||
throw new Error("Invalid constructor argument");
|
||||
}
|
||||
|
||||
if (overlord) {
|
||||
this.#overlord = overlord;
|
||||
}
|
||||
}
|
||||
|
||||
get values () {
|
||||
return this.#values;
|
||||
}
|
||||
|
||||
get length () {
|
||||
return this.names().length;
|
||||
}
|
||||
|
||||
get overlord () {
|
||||
return this.#overlord;
|
||||
}
|
||||
|
||||
set overlord (v) {
|
||||
this.#overlord = new Organisations(v);
|
||||
}
|
||||
|
||||
/** Get the operations for `name`
|
||||
*/
|
||||
get (name) {
|
||||
const key = Object.keys(this.values).find( k => k.toLowerCase() == name.toLowerCase() ) ?? name;
|
||||
return this.values[key];
|
||||
}
|
||||
|
||||
/** Set the operations for `name` to `value`
|
||||
*
|
||||
* If we have an overlord, ensure we cannot:
|
||||
*
|
||||
* 1. Add new organisations which the overlord
|
||||
* is not a member of
|
||||
* 2. Access operations that the overlord is not
|
||||
* allowed to access
|
||||
*/
|
||||
set (name, value) {
|
||||
name = String(name).trim();
|
||||
const key = Object.keys(this.values).find( k => k.toLowerCase() == name.toLowerCase() ) ?? name;
|
||||
const org = new Organisation(value);
|
||||
|
||||
if (this.overlord) {
|
||||
const parent = this.overlord.get(key) ?? this.overlord.get("*");
|
||||
if (parent) {
|
||||
this.values[key] = parent.filter(org);
|
||||
}
|
||||
} else {
|
||||
this.values[key] = new Organisation(value);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Enable the operation `op` in all organisations
|
||||
*/
|
||||
enableOperation (op) {
|
||||
if (this.overlord) {
|
||||
Object.keys(this.#values)
|
||||
.filter( key => (this.overlord.get(key) ?? this.overlord.get("*"))?.[op] )
|
||||
.forEach( key => this.#values[key][op] = true );
|
||||
} else {
|
||||
Object.values(this.#values).forEach( org => org[op] = true );
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Disable the operation `op` in all organisations
|
||||
*/
|
||||
disableOperation (op) {
|
||||
Object.values(this.#values).forEach( org => org[op] = false );
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Create a new organisation object limited by the caller's rights
|
||||
*
|
||||
* The spawned Organisations instance will have the same organisations
|
||||
* and rights as the caller minus the applied `mask`. With the default
|
||||
* mask, the spawned object will inherit all rights except for `edit`
|
||||
* rights.
|
||||
*
|
||||
* The "*" organisation must be explicitly assigned. It is not inherited.
|
||||
*/
|
||||
spawn (mask = {read: true, write: true, edit: false}) {
|
||||
|
||||
const parent = new Organisations();
|
||||
const wildcard = this.get("*").edit; // If true, we can spawn everywhere
|
||||
|
||||
this.entries().forEach( ([k, v]) => {
|
||||
// if (k != "*") { // This organisation is not inherited
|
||||
if (v.edit || wildcard) { // We have the right to spawn in this organisation
|
||||
const o = new Organisation({
|
||||
read: v.read && mask.read,
|
||||
write: v.write && mask.write,
|
||||
edit: v.edit && mask.edit
|
||||
});
|
||||
parent.set(k, o);
|
||||
}
|
||||
// }
|
||||
});
|
||||
|
||||
return new Organisations({}, parent);
|
||||
}
|
||||
|
||||
remove (name) {
|
||||
const key = Object.keys(this.values).find( k => k.toLowerCase() == name.toLowerCase() ) ?? name;
|
||||
delete this.values[key];
|
||||
}
|
||||
|
||||
/** Return the list of organisation names
|
||||
*/
|
||||
names () {
|
||||
return Object.keys(this.values);
|
||||
}
|
||||
|
||||
/** Same as this.get(name)
|
||||
*/
|
||||
value (name) {
|
||||
return this.values[name];
|
||||
}
|
||||
|
||||
/** Same as Object.prototype.entries
|
||||
*/
|
||||
entries () {
|
||||
return this.names().map( name => [ name, this.value(name) ] );
|
||||
}
|
||||
|
||||
/** Return true if the named organisation is present
|
||||
*/
|
||||
has (name) {
|
||||
return Boolean(this.value(name));
|
||||
}
|
||||
|
||||
/** Return only those of our organisations
|
||||
* and operations present in `other`
|
||||
*/
|
||||
filter (other) {
|
||||
const filteredOrganisations = new Organisations();
|
||||
|
||||
const wildcard = other.value("*");
|
||||
|
||||
for (const [name, org] of this.entries()) {
|
||||
const ownOrg = other.value(name) ?? wildcard;
|
||||
if (ownOrg) {
|
||||
filteredOrganisations.set(name, org.filter(ownOrg))
|
||||
}
|
||||
}
|
||||
|
||||
return filteredOrganisations;
|
||||
}
|
||||
|
||||
/** Return only those organisations
|
||||
* that have access to the required
|
||||
* operation
|
||||
*/
|
||||
accessToOperation (op) {
|
||||
const filteredOrganisations = new Organisations();
|
||||
|
||||
for (const [name, org] of this.entries()) {
|
||||
if (org[op]) {
|
||||
filteredOrganisations.set(name, org);
|
||||
}
|
||||
}
|
||||
|
||||
return filteredOrganisations;
|
||||
}
|
||||
|
||||
toJSON () {
|
||||
const obj = {};
|
||||
for (const key in this.values) {
|
||||
obj[key] = this.values[key].toJSON();
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
toString (replacer, space) {
|
||||
return JSON.stringify(this.toJSON(), replacer, space);
|
||||
}
|
||||
|
||||
*[Symbol.iterator] () {
|
||||
for (const [name, operations] of this.entries()) {
|
||||
yield {name, operations};
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = Organisations; // CJS export
|
||||
}
|
||||
|
||||
// ESM export
|
||||
if (typeof exports !== 'undefined' && !exports.default) {
|
||||
exports.default = Organisations; // ESM export
|
||||
}
|
||||
5
lib/modules/@dougal/organisations/index.js
Normal file
5
lib/modules/@dougal/organisations/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
|
||||
module.exports = {
|
||||
Organisation: require('./Organisation'),
|
||||
Organisations: require('./Organisations')
|
||||
}
|
||||
12
lib/modules/@dougal/organisations/package.json
Normal file
12
lib/modules/@dougal/organisations/package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@dougal/organisations",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": ""
|
||||
}
|
||||
364
lib/modules/@dougal/user/User.js
Normal file
364
lib/modules/@dougal/user/User.js
Normal file
@@ -0,0 +1,364 @@
|
||||
const EventEmitter = require('events');
|
||||
const { Organisations } = require('@dougal/organisations');
|
||||
|
||||
function randomUUID () {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||
const r = Math.random() * 16 | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
class User extends EventEmitter {
|
||||
|
||||
// Valid field names
|
||||
static fields = [ "ip", "host", "name", "email", "description", "colour", "active", "organisations", "meta" ]
|
||||
|
||||
static validUUID (str) {
|
||||
const uuidv4Rx = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
return uuidv4Rx.test(str);
|
||||
}
|
||||
|
||||
static validIPv4 (str) {
|
||||
const ipv4Rx = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\/([0-9]|[1-2][0-9]|3[0-2]))?$/;
|
||||
return ipv4Rx.test(str);
|
||||
}
|
||||
|
||||
static validIPv6 (str) {
|
||||
const ipv6Rx = /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,7}:|(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,5}(?::[0-9a-fA-F]{1,4}){1,2}|(?:[0-9a-fA-F]{1,4}:){1,4}(?::[0-9a-fA-F]{1,4}){1,3}|(?:[0-9a-fA-F]{1,4}:){1,3}(?::[0-9a-fA-F]{1,4}){1,4}|(?:[0-9a-fA-F]{1,4}:){1,2}(?::[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:(?::[0-9a-fA-F]{1,4}){1,6}|:((?::[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(?::[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(?:ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|(2[0-4][0-9]|[01]?[0-9][0-9]?))|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|(2[0-4][0-9]|[01]?[0-9][0-9]?))))$/;
|
||||
return ipv6Rx.test(str);
|
||||
}
|
||||
|
||||
static validHostname (str) {
|
||||
const hostnameRx = /^(?=.{1,253}$)(?:(?!-)[A-Za-z0-9-]{1,63}(?<!-)\.)+[A-Za-z]{2,}$/;
|
||||
return hostnameRx.test(str);
|
||||
}
|
||||
|
||||
#setString (k, v) {
|
||||
if (typeof v === "undefined") {
|
||||
this.values[k] = v;
|
||||
} else {
|
||||
this.values[k] = String(v).trim();
|
||||
}
|
||||
this.emit("changed", k, v);
|
||||
this.#updateTimestamp();
|
||||
}
|
||||
|
||||
#updateTimestamp (v) {
|
||||
if (typeof v === "undefined") {
|
||||
this.#timestamp = (new Date()).valueOf();
|
||||
} else {
|
||||
this.#timestamp = (new Date(v)).valueOf();
|
||||
}
|
||||
this.emit("last_modified", this.#timestamp);
|
||||
}
|
||||
|
||||
// Create a new instance of `other`, where `other` is
|
||||
// an instance of User or of a derived class
|
||||
#clone (other = this) {
|
||||
const clone = new this.constructor();
|
||||
Object.assign(clone.values, other.values);
|
||||
clone.organisations = new Organisations(other.organisations);
|
||||
return clone;
|
||||
}
|
||||
|
||||
values = {}
|
||||
|
||||
#timestamp
|
||||
|
||||
constructor (data) {
|
||||
super();
|
||||
|
||||
User.fields.forEach( f => this[f] = data?.[f] );
|
||||
this.values.id = data?.id ?? randomUUID();
|
||||
this.values.active = !!this.active;
|
||||
this.values.hash = data?.hash;
|
||||
this.values.password = data?.password;
|
||||
this.values.organisations = new Organisations(data?.organisations);
|
||||
this.#updateTimestamp(data?.last_modified);
|
||||
}
|
||||
|
||||
/*
|
||||
* Getters
|
||||
*/
|
||||
|
||||
get id () { return this.values.id }
|
||||
|
||||
get ip () { return this.values.ip }
|
||||
|
||||
get host () { return this.values.host }
|
||||
|
||||
get name () { return this.values.name }
|
||||
|
||||
get email () { return this.values.email }
|
||||
|
||||
get description () { return this.values.description }
|
||||
|
||||
get colour () { return this.values.colour }
|
||||
|
||||
get active () { return this.values.active }
|
||||
|
||||
get organisations () { return this.values.organisations }
|
||||
|
||||
get password () { return this.values.password }
|
||||
|
||||
get timestamp () { return new Date(this.#timestamp) }
|
||||
|
||||
/*
|
||||
* Setters
|
||||
*/
|
||||
|
||||
set id (v) {
|
||||
if (typeof v === "undefined") {
|
||||
this.values.id = randomUUID();
|
||||
} else if (User.validUUID(v)) {
|
||||
this.values.id = v;
|
||||
} else {
|
||||
throw new Error("Invalid ID format (must be UUIDv4)");
|
||||
}
|
||||
this.emit("changed", "id", this.values.id);
|
||||
this.#updateTimestamp();
|
||||
}
|
||||
|
||||
set ip (v) {
|
||||
if (User.validIPv4(v) || User.validIPv6(v) || typeof v === "undefined") {
|
||||
this.values.ip = v;
|
||||
} else {
|
||||
throw new Error("Invalid IP address or subnet");
|
||||
}
|
||||
this.emit("changed", "ip", this.values.ip);
|
||||
this.#updateTimestamp();
|
||||
}
|
||||
|
||||
set host (v) {
|
||||
if (User.validHostname(v) || typeof v === "undefined") {
|
||||
this.values.host = v;
|
||||
} else {
|
||||
throw new Error("Invalid hostname");
|
||||
}
|
||||
this.emit("changed", "host", this.values.host);
|
||||
this.#updateTimestamp();
|
||||
}
|
||||
|
||||
set name (v) {
|
||||
this.#setString("name", v);
|
||||
}
|
||||
|
||||
set email (v) {
|
||||
// TODO should validate, buy hey!
|
||||
this.#setString("email", v);
|
||||
}
|
||||
|
||||
set description (v) {
|
||||
this.#setString("description", v);
|
||||
}
|
||||
|
||||
set colour (v) {
|
||||
this.#setString("colour", v);
|
||||
}
|
||||
|
||||
set active (v) {
|
||||
this.values.active = !!v;
|
||||
this.emit("changed", "active", this.values.active);
|
||||
this.#updateTimestamp();
|
||||
}
|
||||
|
||||
set organisations (v) {
|
||||
this.values.organisations = new Organisations(v);
|
||||
this.emit("changed", "organisations", this.values.organisations);
|
||||
this.#updateTimestamp();
|
||||
}
|
||||
|
||||
set password (v) {
|
||||
this.values.password = v;
|
||||
this.emit("changed", "password", this.values.password);
|
||||
this.#updateTimestamp();
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Validation methods
|
||||
*/
|
||||
|
||||
get errors () {
|
||||
let err = [];
|
||||
|
||||
if (!this.id) err.push("ERR_NO_ID");
|
||||
if (!this.name) err.push("ERR_NO_NAME");
|
||||
if (!this.organisations.length) err.push("ERR_NO_ORG");
|
||||
|
||||
return err;
|
||||
}
|
||||
|
||||
get isValid () {
|
||||
return this.errors.length == 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* Filtering methods
|
||||
*/
|
||||
|
||||
filter (other) {
|
||||
// const filteredUser = new User(this);
|
||||
const filteredUser = this.#clone();
|
||||
filteredUser.organisations = this.organisations.filter(other.organisations);
|
||||
return filteredUser;
|
||||
}
|
||||
|
||||
/** Return users that are visible to me.
|
||||
*
|
||||
* These are users with which at leas one common organisation
|
||||
* with read, write or delete access to.
|
||||
*
|
||||
* If we are wildcarded ("*"), we see everyone.
|
||||
*
|
||||
* If a peer is wildcarded, they can be seen by everone.
|
||||
*/
|
||||
peers (list) {
|
||||
if (this.organisations.value("*")) {
|
||||
return list;
|
||||
} else {
|
||||
return list.filter( user => this.canRead(user) );
|
||||
// return list.filter( user =>
|
||||
// user.organisations.value("*") ||
|
||||
// user.organisations.filter(this.organisations).length > 0
|
||||
// this.organisations.filter(user.organisations).length > 0
|
||||
// );
|
||||
}
|
||||
}
|
||||
|
||||
/** Return users that I can edit
|
||||
*
|
||||
* These users must belong to an organisation
|
||||
* over which I have edit rights.
|
||||
*
|
||||
* If we are edit wildcarded, we can edit everyone.
|
||||
*/
|
||||
editablePeers (list) {
|
||||
const editableOrgs = this.organisations.accessToOperation("edit");
|
||||
if (editableOrgs.value("*")) {
|
||||
return list;
|
||||
} else {
|
||||
return list.filter( user => this.canEdit(user) );
|
||||
// editableOrgs.filter(user.organisations).length > 0
|
||||
// );
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* General methods
|
||||
*/
|
||||
|
||||
/** Return `true` if we are `other`
|
||||
*/
|
||||
is (other) {
|
||||
return this.id == other.id;
|
||||
}
|
||||
|
||||
canDo (operation, other) {
|
||||
if (this.organisations.get('*')?.[operation])
|
||||
return true;
|
||||
|
||||
if (other instanceof User) {
|
||||
return other.organisations.names().some(name => this.organisations.get(name)?.[operation]);
|
||||
} else if (other instanceof Organisations) {
|
||||
return other.accessToOperation(operation).names().some(name => this.organisations.get(name)?.[operation]);
|
||||
} else if (other?.organisations) {
|
||||
return this.canDo(operation, new Organisations(other.organisations));
|
||||
} else if (other instanceof Object) {
|
||||
return this.canDo(operation, new Organisations(other));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
canRead (other) {
|
||||
return this.canDo("read", other);
|
||||
}
|
||||
|
||||
canWrite (other) {
|
||||
return this.canDo("write", other);
|
||||
}
|
||||
|
||||
canEdit (other) {
|
||||
return this.canDo("edit", other);
|
||||
}
|
||||
|
||||
/** Perform an edit on another user
|
||||
*
|
||||
* Syntax: user.edit(other).to(another);
|
||||
*
|
||||
* Applies to `other` the changes described in `another`
|
||||
* that are permitted to `user`. The argument `another`
|
||||
* must be a plain object (not a `User` instance) with
|
||||
* only the properties that are to be changed.
|
||||
*
|
||||
* NOTE: Organisations are not merged, they are overwritten
|
||||
* and then filtered to ensure that the edited user does not
|
||||
* gain more privileges than those granted to the editing
|
||||
* user.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* // This causes user test77 to set user x23 to
|
||||
* // inactive
|
||||
* test77.edit(x23).to({active: false})
|
||||
*/
|
||||
edit (other) {
|
||||
if (this.canEdit(other)) {
|
||||
return {
|
||||
to: (another) => {
|
||||
const newUser = Object.assign(this.#clone(other), another);
|
||||
return newUser.filter(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Do not fail or throw but return undefined
|
||||
}
|
||||
|
||||
/** Create a new user similar to us except it doesn't have `edit` rights
|
||||
* by default
|
||||
*/
|
||||
spawn (init = {}, mask = {read: true, write: true, edit: false}) {
|
||||
// const user = new User(init);
|
||||
const user = this.#clone(init);
|
||||
user.organisations = this.organisations.accessToOperation("edit").disableOperation("edit");
|
||||
user.organisations.overlord = this.organisations;
|
||||
return user;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*
|
||||
* Conversion and presentation methods
|
||||
*/
|
||||
|
||||
toJSON () {
|
||||
return {
|
||||
id: this.id,
|
||||
ip: this.ip,
|
||||
host: this.host,
|
||||
name: this.name,
|
||||
email: this.email,
|
||||
description: this.description,
|
||||
colour: this.colour,
|
||||
active: this.active,
|
||||
organisations: this.organisations.toJSON(),
|
||||
password: this.password
|
||||
}
|
||||
}
|
||||
|
||||
toString (replacer, space) {
|
||||
return JSON.stringify(this.toJSON(), replacer, space);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = User; // CJS export
|
||||
}
|
||||
|
||||
// ESM export
|
||||
if (typeof exports !== 'undefined' && !exports.default) {
|
||||
exports.default = User; // ESM export
|
||||
}
|
||||
4
lib/modules/@dougal/user/index.js
Normal file
4
lib/modules/@dougal/user/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
|
||||
module.exports = {
|
||||
User: require('./User')
|
||||
}
|
||||
24
lib/modules/@dougal/user/package-lock.json
generated
Normal file
24
lib/modules/@dougal/user/package-lock.json
generated
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@dougal/user",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@dougal/user",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@dougal/organisations": "file:../organisations"
|
||||
}
|
||||
},
|
||||
"../organisations": {
|
||||
"version": "1.0.0",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@dougal/organisations": {
|
||||
"resolved": "../organisations",
|
||||
"link": true
|
||||
}
|
||||
}
|
||||
}
|
||||
15
lib/modules/@dougal/user/package.json
Normal file
15
lib/modules/@dougal/user/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@dougal/user",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"@dougal/organisations": "file:../organisations"
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ module.exports = {
|
||||
'@vue/cli-plugin-babel/preset'
|
||||
],
|
||||
plugins: [
|
||||
'@babel/plugin-proposal-logical-assignment-operators'
|
||||
'@babel/plugin-proposal-logical-assignment-operators',
|
||||
'@babel/plugin-transform-private-methods'
|
||||
]
|
||||
}
|
||||
|
||||
537
lib/www/client/source/package-lock.json
generated
537
lib/www/client/source/package-lock.json
generated
@@ -9,6 +9,8 @@
|
||||
"version": "0.0.0",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@dougal/organisations": "file:../../../modules/@dougal/organisations",
|
||||
"@dougal/user": "file:../../../modules/@dougal/user",
|
||||
"@mdi/font": "^7.2.96",
|
||||
"buffer": "^6.0.3",
|
||||
"core-js": "^3.6.5",
|
||||
@@ -33,6 +35,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/plugin-proposal-logical-assignment-operators": "^7.14.5",
|
||||
"@babel/plugin-transform-private-methods": "^7.27.1",
|
||||
"@vue/cli-plugin-babel": "^5.0.8",
|
||||
"@vue/cli-plugin-router": "^5.0.8",
|
||||
"@vue/cli-plugin-vuex": "^5.0.8",
|
||||
@@ -46,6 +49,17 @@
|
||||
"vuetify-loader": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"../../../modules/@dougal/organisations": {
|
||||
"version": "1.0.0",
|
||||
"license": "ISC"
|
||||
},
|
||||
"../../../modules/@dougal/user": {
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@dougal/organisations": "file:../organisations"
|
||||
}
|
||||
},
|
||||
"node_modules/@achrinza/node-ipc": {
|
||||
"version": "9.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@achrinza/node-ipc/-/node-ipc-9.2.8.tgz",
|
||||
@@ -73,13 +87,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.22.13",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz",
|
||||
"integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==",
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/highlight": "^7.22.13",
|
||||
"chalk": "^2.4.2"
|
||||
"@babel/helper-validator-identifier": "^7.27.1",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -125,27 +140,28 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator": {
|
||||
"version": "7.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.0.tgz",
|
||||
"integrity": "sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==",
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz",
|
||||
"integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.23.0",
|
||||
"@jridgewell/gen-mapping": "^0.3.2",
|
||||
"@jridgewell/trace-mapping": "^0.3.17",
|
||||
"jsesc": "^2.5.1"
|
||||
"@babel/parser": "^7.28.0",
|
||||
"@babel/types": "^7.28.0",
|
||||
"@jridgewell/gen-mapping": "^0.3.12",
|
||||
"@jridgewell/trace-mapping": "^0.3.28",
|
||||
"jsesc": "^3.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-annotate-as-pure": {
|
||||
"version": "7.22.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.22.5.tgz",
|
||||
"integrity": "sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg==",
|
||||
"version": "7.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz",
|
||||
"integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.22.5"
|
||||
"@babel/types": "^7.27.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -180,19 +196,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-create-class-features-plugin": {
|
||||
"version": "7.22.15",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.22.15.tgz",
|
||||
"integrity": "sha512-jKkwA59IXcvSaiK2UN45kKwSC9o+KuoXsBDvHvU/7BecYIp8GQ2UwrVvFgJASUT+hBnwJx6MhvMCuMzwZZ7jlg==",
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.1.tgz",
|
||||
"integrity": "sha512-QwGAmuvM17btKU5VqXfb+Giw4JcN0hjuufz3DYnpeVDvZLAObloM77bhMXiqry3Iio+Ai4phVRDwl6WU10+r5A==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/helper-annotate-as-pure": "^7.22.5",
|
||||
"@babel/helper-environment-visitor": "^7.22.5",
|
||||
"@babel/helper-function-name": "^7.22.5",
|
||||
"@babel/helper-member-expression-to-functions": "^7.22.15",
|
||||
"@babel/helper-optimise-call-expression": "^7.22.5",
|
||||
"@babel/helper-replace-supers": "^7.22.9",
|
||||
"@babel/helper-skip-transparent-expression-wrappers": "^7.22.5",
|
||||
"@babel/helper-split-export-declaration": "^7.22.6",
|
||||
"@babel/helper-annotate-as-pure": "^7.27.1",
|
||||
"@babel/helper-member-expression-to-functions": "^7.27.1",
|
||||
"@babel/helper-optimise-call-expression": "^7.27.1",
|
||||
"@babel/helper-replace-supers": "^7.27.1",
|
||||
"@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
|
||||
"@babel/traverse": "^7.27.1",
|
||||
"semver": "^6.3.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -257,6 +271,15 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-globals": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
|
||||
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-hoist-variables": {
|
||||
"version": "7.22.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz",
|
||||
@@ -270,12 +293,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-member-expression-to-functions": {
|
||||
"version": "7.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.23.0.tgz",
|
||||
"integrity": "sha512-6gfrPwh7OuT6gZyJZvd6WbTfrqAo7vm4xCzAXOusKqq/vWdKXphTpj5klHKNmRUU6/QRGlBsyU9mAIPaWHlqJA==",
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz",
|
||||
"integrity": "sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.23.0"
|
||||
"@babel/traverse": "^7.27.1",
|
||||
"@babel/types": "^7.27.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -313,21 +337,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-optimise-call-expression": {
|
||||
"version": "7.22.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.22.5.tgz",
|
||||
"integrity": "sha512-HBwaojN0xFRx4yIvpwGqxiV2tUfl7401jlok564NgB9EHS1y6QT17FmKWm4ztqjeVdXLuC4fSvHc5ePpQjoTbw==",
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz",
|
||||
"integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.22.5"
|
||||
"@babel/types": "^7.27.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-plugin-utils": {
|
||||
"version": "7.22.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.22.5.tgz",
|
||||
"integrity": "sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg==",
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz",
|
||||
"integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -351,14 +375,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-replace-supers": {
|
||||
"version": "7.22.20",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.22.20.tgz",
|
||||
"integrity": "sha512-qsW0In3dbwQUbK8kejJ4R7IHVGwHJlV6lpG6UA7a9hSa2YEiAib+N1T2kr6PEeUT+Fl7najmSOS6SmAwCHK6Tw==",
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz",
|
||||
"integrity": "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/helper-environment-visitor": "^7.22.20",
|
||||
"@babel/helper-member-expression-to-functions": "^7.22.15",
|
||||
"@babel/helper-optimise-call-expression": "^7.22.5"
|
||||
"@babel/helper-member-expression-to-functions": "^7.27.1",
|
||||
"@babel/helper-optimise-call-expression": "^7.27.1",
|
||||
"@babel/traverse": "^7.27.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -380,12 +404,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-skip-transparent-expression-wrappers": {
|
||||
"version": "7.22.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.22.5.tgz",
|
||||
"integrity": "sha512-tK14r66JZKiC43p8Ki33yLBVJKlQDFoA8GYN67lWCDCqoL6EMMSuM9b+Iff2jHaM/RRFYl7K+iiru7hbRqNx8Q==",
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz",
|
||||
"integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.22.5"
|
||||
"@babel/traverse": "^7.27.1",
|
||||
"@babel/types": "^7.27.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -404,18 +429,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.22.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz",
|
||||
"integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==",
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.22.20",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz",
|
||||
"integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==",
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
|
||||
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -458,25 +483,14 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/highlight": {
|
||||
"version": "7.22.20",
|
||||
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz",
|
||||
"integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==",
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz",
|
||||
"integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.22.20",
|
||||
"chalk": "^2.4.2",
|
||||
"js-tokens": "^4.0.0"
|
||||
"@babel/types": "^7.28.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.0.tgz",
|
||||
"integrity": "sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
},
|
||||
@@ -1399,13 +1413,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-private-methods": {
|
||||
"version": "7.22.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.22.5.tgz",
|
||||
"integrity": "sha512-PPjh4gyrQnGe97JTalgRGMuU4icsZFnWkzicB/fUtzlKUqvsWBKEpPPfr5a2JiyirZkHxnAqkQMO5Z5B2kK3fA==",
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.27.1.tgz",
|
||||
"integrity": "sha512-10FVt+X55AjRAYI9BrdISN9/AQWHqldOeZDUoLyif1Kn05a56xVBXb8ZouL8pZ9jem8QpXaOt8TS7RHUIS+GPA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/helper-create-class-features-plugin": "^7.22.5",
|
||||
"@babel/helper-plugin-utils": "^7.22.5"
|
||||
"@babel/helper-create-class-features-plugin": "^7.27.1",
|
||||
"@babel/helper-plugin-utils": "^7.27.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -1764,49 +1778,45 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.22.15",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz",
|
||||
"integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==",
|
||||
"version": "7.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
|
||||
"integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.22.13",
|
||||
"@babel/parser": "^7.22.15",
|
||||
"@babel/types": "^7.22.15"
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/parser": "^7.27.2",
|
||||
"@babel/types": "^7.27.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/traverse": {
|
||||
"version": "7.23.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.2.tgz",
|
||||
"integrity": "sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==",
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz",
|
||||
"integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.22.13",
|
||||
"@babel/generator": "^7.23.0",
|
||||
"@babel/helper-environment-visitor": "^7.22.20",
|
||||
"@babel/helper-function-name": "^7.23.0",
|
||||
"@babel/helper-hoist-variables": "^7.22.5",
|
||||
"@babel/helper-split-export-declaration": "^7.22.6",
|
||||
"@babel/parser": "^7.23.0",
|
||||
"@babel/types": "^7.23.0",
|
||||
"debug": "^4.1.0",
|
||||
"globals": "^11.1.0"
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.0",
|
||||
"@babel/helper-globals": "^7.28.0",
|
||||
"@babel/parser": "^7.28.0",
|
||||
"@babel/template": "^7.27.2",
|
||||
"@babel/types": "^7.28.0",
|
||||
"debug": "^4.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz",
|
||||
"integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==",
|
||||
"version": "7.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.1.tgz",
|
||||
"integrity": "sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.22.5",
|
||||
"@babel/helper-validator-identifier": "^7.22.20",
|
||||
"to-fast-properties": "^2.0.0"
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.27.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -1821,6 +1831,14 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dougal/organisations": {
|
||||
"resolved": "../../../modules/@dougal/organisations",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@dougal/user": {
|
||||
"resolved": "../../../modules/@dougal/user",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@hapi/hoek": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
|
||||
@@ -1837,17 +1855,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz",
|
||||
"integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==",
|
||||
"version": "0.3.12",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz",
|
||||
"integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/set-array": "^1.0.1",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.10",
|
||||
"@jridgewell/trace-mapping": "^0.3.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
@@ -1859,15 +1873,6 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/set-array": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz",
|
||||
"integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/source-map": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz",
|
||||
@@ -1879,15 +1884,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.4.15",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz",
|
||||
"integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==",
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz",
|
||||
"integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.20",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz",
|
||||
"integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==",
|
||||
"version": "0.3.29",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz",
|
||||
"integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
@@ -3777,9 +3782,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001616",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001616.tgz",
|
||||
"integrity": "sha512-RHVYKov7IcdNjVHJFNY/78RdG4oGVjbayxv8u5IO74Wv7Hlq4PnJE6mo/OjFijjVFNy5ijnCt6H3IIo4t+wfEw==",
|
||||
"version": "1.0.30001726",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001726.tgz",
|
||||
"integrity": "sha512-VQAUIUzBiZ/UnlM28fSp2CRF3ivUn1BWEvxMcVTNwpw91Py1pGbPIyIKtd+tzct9C3ouceCVdGAXxZOpZAsgdw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -3794,7 +3799,8 @@
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
]
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/case-sensitive-paths-webpack-plugin": {
|
||||
"version": "2.4.0",
|
||||
@@ -6831,15 +6837,15 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/jsesc": {
|
||||
"version": "2.5.2",
|
||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz",
|
||||
"integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==",
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"jsesc": "bin/jsesc"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/json-parse-better-errors": {
|
||||
@@ -8260,9 +8266,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
|
||||
"integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==",
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
@@ -10302,15 +10308,6 @@
|
||||
"integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/to-fast-properties": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
|
||||
"integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
@@ -11445,13 +11442,14 @@
|
||||
}
|
||||
},
|
||||
"@babel/code-frame": {
|
||||
"version": "7.22.13",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz",
|
||||
"integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==",
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/highlight": "^7.22.13",
|
||||
"chalk": "^2.4.2"
|
||||
"@babel/helper-validator-identifier": "^7.27.1",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"@babel/compat-data": {
|
||||
@@ -11484,24 +11482,25 @@
|
||||
}
|
||||
},
|
||||
"@babel/generator": {
|
||||
"version": "7.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.0.tgz",
|
||||
"integrity": "sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==",
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz",
|
||||
"integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/types": "^7.23.0",
|
||||
"@jridgewell/gen-mapping": "^0.3.2",
|
||||
"@jridgewell/trace-mapping": "^0.3.17",
|
||||
"jsesc": "^2.5.1"
|
||||
"@babel/parser": "^7.28.0",
|
||||
"@babel/types": "^7.28.0",
|
||||
"@jridgewell/gen-mapping": "^0.3.12",
|
||||
"@jridgewell/trace-mapping": "^0.3.28",
|
||||
"jsesc": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"@babel/helper-annotate-as-pure": {
|
||||
"version": "7.22.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.22.5.tgz",
|
||||
"integrity": "sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg==",
|
||||
"version": "7.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz",
|
||||
"integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/types": "^7.22.5"
|
||||
"@babel/types": "^7.27.3"
|
||||
}
|
||||
},
|
||||
"@babel/helper-builder-binary-assignment-operator-visitor": {
|
||||
@@ -11527,19 +11526,17 @@
|
||||
}
|
||||
},
|
||||
"@babel/helper-create-class-features-plugin": {
|
||||
"version": "7.22.15",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.22.15.tgz",
|
||||
"integrity": "sha512-jKkwA59IXcvSaiK2UN45kKwSC9o+KuoXsBDvHvU/7BecYIp8GQ2UwrVvFgJASUT+hBnwJx6MhvMCuMzwZZ7jlg==",
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.1.tgz",
|
||||
"integrity": "sha512-QwGAmuvM17btKU5VqXfb+Giw4JcN0hjuufz3DYnpeVDvZLAObloM77bhMXiqry3Iio+Ai4phVRDwl6WU10+r5A==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/helper-annotate-as-pure": "^7.22.5",
|
||||
"@babel/helper-environment-visitor": "^7.22.5",
|
||||
"@babel/helper-function-name": "^7.22.5",
|
||||
"@babel/helper-member-expression-to-functions": "^7.22.15",
|
||||
"@babel/helper-optimise-call-expression": "^7.22.5",
|
||||
"@babel/helper-replace-supers": "^7.22.9",
|
||||
"@babel/helper-skip-transparent-expression-wrappers": "^7.22.5",
|
||||
"@babel/helper-split-export-declaration": "^7.22.6",
|
||||
"@babel/helper-annotate-as-pure": "^7.27.1",
|
||||
"@babel/helper-member-expression-to-functions": "^7.27.1",
|
||||
"@babel/helper-optimise-call-expression": "^7.27.1",
|
||||
"@babel/helper-replace-supers": "^7.27.1",
|
||||
"@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
|
||||
"@babel/traverse": "^7.27.1",
|
||||
"semver": "^6.3.1"
|
||||
}
|
||||
},
|
||||
@@ -11583,6 +11580,12 @@
|
||||
"@babel/types": "^7.23.0"
|
||||
}
|
||||
},
|
||||
"@babel/helper-globals": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
|
||||
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
|
||||
"dev": true
|
||||
},
|
||||
"@babel/helper-hoist-variables": {
|
||||
"version": "7.22.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz",
|
||||
@@ -11593,12 +11596,13 @@
|
||||
}
|
||||
},
|
||||
"@babel/helper-member-expression-to-functions": {
|
||||
"version": "7.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.23.0.tgz",
|
||||
"integrity": "sha512-6gfrPwh7OuT6gZyJZvd6WbTfrqAo7vm4xCzAXOusKqq/vWdKXphTpj5klHKNmRUU6/QRGlBsyU9mAIPaWHlqJA==",
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz",
|
||||
"integrity": "sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/types": "^7.23.0"
|
||||
"@babel/traverse": "^7.27.1",
|
||||
"@babel/types": "^7.27.1"
|
||||
}
|
||||
},
|
||||
"@babel/helper-module-imports": {
|
||||
@@ -11624,18 +11628,18 @@
|
||||
}
|
||||
},
|
||||
"@babel/helper-optimise-call-expression": {
|
||||
"version": "7.22.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.22.5.tgz",
|
||||
"integrity": "sha512-HBwaojN0xFRx4yIvpwGqxiV2tUfl7401jlok564NgB9EHS1y6QT17FmKWm4ztqjeVdXLuC4fSvHc5ePpQjoTbw==",
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz",
|
||||
"integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/types": "^7.22.5"
|
||||
"@babel/types": "^7.27.1"
|
||||
}
|
||||
},
|
||||
"@babel/helper-plugin-utils": {
|
||||
"version": "7.22.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.22.5.tgz",
|
||||
"integrity": "sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg==",
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz",
|
||||
"integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==",
|
||||
"dev": true
|
||||
},
|
||||
"@babel/helper-remap-async-to-generator": {
|
||||
@@ -11650,14 +11654,14 @@
|
||||
}
|
||||
},
|
||||
"@babel/helper-replace-supers": {
|
||||
"version": "7.22.20",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.22.20.tgz",
|
||||
"integrity": "sha512-qsW0In3dbwQUbK8kejJ4R7IHVGwHJlV6lpG6UA7a9hSa2YEiAib+N1T2kr6PEeUT+Fl7najmSOS6SmAwCHK6Tw==",
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz",
|
||||
"integrity": "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/helper-environment-visitor": "^7.22.20",
|
||||
"@babel/helper-member-expression-to-functions": "^7.22.15",
|
||||
"@babel/helper-optimise-call-expression": "^7.22.5"
|
||||
"@babel/helper-member-expression-to-functions": "^7.27.1",
|
||||
"@babel/helper-optimise-call-expression": "^7.27.1",
|
||||
"@babel/traverse": "^7.27.1"
|
||||
}
|
||||
},
|
||||
"@babel/helper-simple-access": {
|
||||
@@ -11670,12 +11674,13 @@
|
||||
}
|
||||
},
|
||||
"@babel/helper-skip-transparent-expression-wrappers": {
|
||||
"version": "7.22.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.22.5.tgz",
|
||||
"integrity": "sha512-tK14r66JZKiC43p8Ki33yLBVJKlQDFoA8GYN67lWCDCqoL6EMMSuM9b+Iff2jHaM/RRFYl7K+iiru7hbRqNx8Q==",
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz",
|
||||
"integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/types": "^7.22.5"
|
||||
"@babel/traverse": "^7.27.1",
|
||||
"@babel/types": "^7.27.1"
|
||||
}
|
||||
},
|
||||
"@babel/helper-split-export-declaration": {
|
||||
@@ -11688,15 +11693,15 @@
|
||||
}
|
||||
},
|
||||
"@babel/helper-string-parser": {
|
||||
"version": "7.22.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz",
|
||||
"integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==",
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||
"dev": true
|
||||
},
|
||||
"@babel/helper-validator-identifier": {
|
||||
"version": "7.22.20",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz",
|
||||
"integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==",
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
|
||||
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
|
||||
"dev": true
|
||||
},
|
||||
"@babel/helper-validator-option": {
|
||||
@@ -11727,23 +11732,15 @@
|
||||
"@babel/types": "^7.17.0"
|
||||
}
|
||||
},
|
||||
"@babel/highlight": {
|
||||
"version": "7.22.20",
|
||||
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz",
|
||||
"integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==",
|
||||
"@babel/parser": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz",
|
||||
"integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/helper-validator-identifier": "^7.22.20",
|
||||
"chalk": "^2.4.2",
|
||||
"js-tokens": "^4.0.0"
|
||||
"@babel/types": "^7.28.0"
|
||||
}
|
||||
},
|
||||
"@babel/parser": {
|
||||
"version": "7.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.0.tgz",
|
||||
"integrity": "sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==",
|
||||
"dev": true
|
||||
},
|
||||
"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": {
|
||||
"version": "7.22.15",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.22.15.tgz",
|
||||
@@ -12335,13 +12332,13 @@
|
||||
}
|
||||
},
|
||||
"@babel/plugin-transform-private-methods": {
|
||||
"version": "7.22.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.22.5.tgz",
|
||||
"integrity": "sha512-PPjh4gyrQnGe97JTalgRGMuU4icsZFnWkzicB/fUtzlKUqvsWBKEpPPfr5a2JiyirZkHxnAqkQMO5Z5B2kK3fA==",
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.27.1.tgz",
|
||||
"integrity": "sha512-10FVt+X55AjRAYI9BrdISN9/AQWHqldOeZDUoLyif1Kn05a56xVBXb8ZouL8pZ9jem8QpXaOt8TS7RHUIS+GPA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/helper-create-class-features-plugin": "^7.22.5",
|
||||
"@babel/helper-plugin-utils": "^7.22.5"
|
||||
"@babel/helper-create-class-features-plugin": "^7.27.1",
|
||||
"@babel/helper-plugin-utils": "^7.27.1"
|
||||
}
|
||||
},
|
||||
"@babel/plugin-transform-private-property-in-object": {
|
||||
@@ -12598,43 +12595,39 @@
|
||||
}
|
||||
},
|
||||
"@babel/template": {
|
||||
"version": "7.22.15",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz",
|
||||
"integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==",
|
||||
"version": "7.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
|
||||
"integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/code-frame": "^7.22.13",
|
||||
"@babel/parser": "^7.22.15",
|
||||
"@babel/types": "^7.22.15"
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/parser": "^7.27.2",
|
||||
"@babel/types": "^7.27.1"
|
||||
}
|
||||
},
|
||||
"@babel/traverse": {
|
||||
"version": "7.23.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.2.tgz",
|
||||
"integrity": "sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==",
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz",
|
||||
"integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/code-frame": "^7.22.13",
|
||||
"@babel/generator": "^7.23.0",
|
||||
"@babel/helper-environment-visitor": "^7.22.20",
|
||||
"@babel/helper-function-name": "^7.23.0",
|
||||
"@babel/helper-hoist-variables": "^7.22.5",
|
||||
"@babel/helper-split-export-declaration": "^7.22.6",
|
||||
"@babel/parser": "^7.23.0",
|
||||
"@babel/types": "^7.23.0",
|
||||
"debug": "^4.1.0",
|
||||
"globals": "^11.1.0"
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.0",
|
||||
"@babel/helper-globals": "^7.28.0",
|
||||
"@babel/parser": "^7.28.0",
|
||||
"@babel/template": "^7.27.2",
|
||||
"@babel/types": "^7.28.0",
|
||||
"debug": "^4.3.1"
|
||||
}
|
||||
},
|
||||
"@babel/types": {
|
||||
"version": "7.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz",
|
||||
"integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==",
|
||||
"version": "7.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.1.tgz",
|
||||
"integrity": "sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/helper-string-parser": "^7.22.5",
|
||||
"@babel/helper-validator-identifier": "^7.22.20",
|
||||
"to-fast-properties": "^2.0.0"
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.27.1"
|
||||
}
|
||||
},
|
||||
"@discoveryjs/json-ext": {
|
||||
@@ -12643,6 +12636,15 @@
|
||||
"integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==",
|
||||
"dev": true
|
||||
},
|
||||
"@dougal/organisations": {
|
||||
"version": "file:../../../modules/@dougal/organisations"
|
||||
},
|
||||
"@dougal/user": {
|
||||
"version": "file:../../../modules/@dougal/user",
|
||||
"requires": {
|
||||
"@dougal/organisations": "file:../organisations"
|
||||
}
|
||||
},
|
||||
"@hapi/hoek": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
|
||||
@@ -12659,14 +12661,13 @@
|
||||
}
|
||||
},
|
||||
"@jridgewell/gen-mapping": {
|
||||
"version": "0.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz",
|
||||
"integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==",
|
||||
"version": "0.3.12",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz",
|
||||
"integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@jridgewell/set-array": "^1.0.1",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.10",
|
||||
"@jridgewell/trace-mapping": "^0.3.9"
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"@jridgewell/resolve-uri": {
|
||||
@@ -12675,12 +12676,6 @@
|
||||
"integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==",
|
||||
"dev": true
|
||||
},
|
||||
"@jridgewell/set-array": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz",
|
||||
"integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==",
|
||||
"dev": true
|
||||
},
|
||||
"@jridgewell/source-map": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz",
|
||||
@@ -12692,15 +12687,15 @@
|
||||
}
|
||||
},
|
||||
"@jridgewell/sourcemap-codec": {
|
||||
"version": "1.4.15",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz",
|
||||
"integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==",
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz",
|
||||
"integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==",
|
||||
"dev": true
|
||||
},
|
||||
"@jridgewell/trace-mapping": {
|
||||
"version": "0.3.20",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz",
|
||||
"integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==",
|
||||
"version": "0.3.29",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz",
|
||||
"integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
@@ -14189,9 +14184,9 @@
|
||||
}
|
||||
},
|
||||
"caniuse-lite": {
|
||||
"version": "1.0.30001616",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001616.tgz",
|
||||
"integrity": "sha512-RHVYKov7IcdNjVHJFNY/78RdG4oGVjbayxv8u5IO74Wv7Hlq4PnJE6mo/OjFijjVFNy5ijnCt6H3IIo4t+wfEw==",
|
||||
"version": "1.0.30001726",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001726.tgz",
|
||||
"integrity": "sha512-VQAUIUzBiZ/UnlM28fSp2CRF3ivUn1BWEvxMcVTNwpw91Py1pGbPIyIKtd+tzct9C3ouceCVdGAXxZOpZAsgdw==",
|
||||
"dev": true
|
||||
},
|
||||
"case-sensitive-paths-webpack-plugin": {
|
||||
@@ -16422,9 +16417,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"jsesc": {
|
||||
"version": "2.5.2",
|
||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz",
|
||||
"integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==",
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
|
||||
"dev": true
|
||||
},
|
||||
"json-parse-better-errors": {
|
||||
@@ -17516,9 +17511,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"picocolors": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
|
||||
"integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==",
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true
|
||||
},
|
||||
"picomatch": {
|
||||
@@ -18985,12 +18980,6 @@
|
||||
"integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==",
|
||||
"dev": true
|
||||
},
|
||||
"to-fast-properties": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
|
||||
"integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=",
|
||||
"dev": true
|
||||
},
|
||||
"to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
"build": "vue-cli-service build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dougal/organisations": "file:../../../modules/@dougal/organisations",
|
||||
"@dougal/user": "file:../../../modules/@dougal/user",
|
||||
"@mdi/font": "^7.2.96",
|
||||
"buffer": "^6.0.3",
|
||||
"core-js": "^3.6.5",
|
||||
@@ -31,6 +33,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/plugin-proposal-logical-assignment-operators": "^7.14.5",
|
||||
"@babel/plugin-transform-private-methods": "^7.27.1",
|
||||
"@vue/cli-plugin-babel": "^5.0.8",
|
||||
"@vue/cli-plugin-router": "^5.0.8",
|
||||
"@vue/cli-plugin-vuex": "^5.0.8",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
max-width="600"
|
||||
>
|
||||
<template v-slot:activator="{ on, attrs }">
|
||||
<v-btn v-if="adminaccess"
|
||||
<v-btn v-if="adminaccess()"
|
||||
title="Create a new project from scratch. Generally, it's preferable to clone an existing project (right-click → ‘Clone’)"
|
||||
small
|
||||
outlined
|
||||
@@ -31,6 +31,7 @@
|
||||
<script>
|
||||
import { mapActions, mapGetters } from 'vuex';
|
||||
import DougalProjectSettingsNameIdGeodetics from '@/components/project-settings/name-id-geodetics'
|
||||
import AccessMixin from '@/mixins/access';
|
||||
|
||||
export default {
|
||||
name: 'DougalAppBarExtensionProjectList',
|
||||
@@ -39,6 +40,10 @@ export default {
|
||||
DougalProjectSettingsNameIdGeodetics
|
||||
},
|
||||
|
||||
mixins: [
|
||||
AccessMixin
|
||||
],
|
||||
|
||||
data() {
|
||||
return {
|
||||
dialogOpen: false,
|
||||
@@ -50,10 +55,6 @@ export default {
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters(["adminaccess"])
|
||||
},
|
||||
|
||||
methods: {
|
||||
async save (data) {
|
||||
this.dialogOpen = false;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<v-tabs :value="tab" show-arrows v-if="page != 'configuration'">
|
||||
<v-tab v-for="tab, index in tabs" :key="index" link :to="tabLink(tab.href)" v-text="tab.text"></v-tab>
|
||||
<template v-if="adminaccess">
|
||||
<template v-if="adminaccess()">
|
||||
<v-spacer></v-spacer>
|
||||
<v-tab :to="tabLink('configuration')" class="orange--text darken-3" title="Edit project settings"><v-icon small left color="orange darken-3">mdi-cog-outline</v-icon> Settings</v-tab>
|
||||
</template>
|
||||
@@ -15,9 +15,15 @@
|
||||
|
||||
<script>
|
||||
import { mapActions, mapGetters } from 'vuex';
|
||||
import AccessMixin from '@/mixins/access';
|
||||
|
||||
export default {
|
||||
name: 'DougalAppBarExtensionProject',
|
||||
|
||||
mixins: [
|
||||
AccessMixin
|
||||
],
|
||||
|
||||
data() {
|
||||
return {
|
||||
tabs: [
|
||||
@@ -44,7 +50,6 @@ export default {
|
||||
return this.tabs.findIndex(t => t.href == this.page);
|
||||
},
|
||||
|
||||
...mapGetters(["adminaccess"])
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
@@ -229,9 +229,9 @@ export default {
|
||||
],
|
||||
|
||||
props: {
|
||||
text: String,
|
||||
text: { type: String, default: "" },
|
||||
fixed: { type: Array, default: () => [] },
|
||||
fields: Object,
|
||||
fields: { type: Object, default: () => ({}) },
|
||||
multiline: Boolean,
|
||||
numberedLines: [ Boolean, Number ],
|
||||
maxHeight: String,
|
||||
@@ -473,7 +473,7 @@ export default {
|
||||
},
|
||||
|
||||
reset () {
|
||||
this.text_ = this.text.replaceAll("\r", "");
|
||||
this.text_ = this?.text.replaceAll("\r", "");
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
<template>
|
||||
<v-row dense no-gutters>
|
||||
|
||||
<v-col>
|
||||
<slot name="prepend"></slot>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="2">
|
||||
<v-chip v-if="value.item && !readonly"
|
||||
outlined
|
||||
label
|
||||
small
|
||||
:color="colour"
|
||||
:title="description"
|
||||
>{{name}}</v-chip>
|
||||
<v-select v-else-if="items.length && !readonly"
|
||||
label="Item"
|
||||
:items=items
|
||||
v-model="value.item"
|
||||
dense
|
||||
title="Select an item to use as a field"
|
||||
></v-select>
|
||||
</v-col>
|
||||
|
||||
<v-col>
|
||||
<v-select v-if="type == 'boolean'"
|
||||
label="Condition"
|
||||
:items="[true, false]"
|
||||
v-model="value.when"
|
||||
dense
|
||||
title="Use this configuration only when the value of this item matches the selected state. This allows the user to configure different values for true and false conditions."
|
||||
></v-select>
|
||||
</v-col>
|
||||
|
||||
<v-col>
|
||||
<v-text-field v-if="type == 'boolean' || type == 'text'"
|
||||
class="ml-3"
|
||||
dense
|
||||
label="Value"
|
||||
v-model="value.value"
|
||||
title="This literal text will be inserted at the designated position"
|
||||
></v-text-field>
|
||||
<v-menu v-else-if="type == 'number'"
|
||||
max-width="600"
|
||||
:close-on-content-click="false"
|
||||
offset-y
|
||||
>
|
||||
<template v-slot:activator="{ on, attrs }">
|
||||
<v-chip
|
||||
class="ml-3"
|
||||
small
|
||||
:light="$vuetify.theme.isDark"
|
||||
:dark="!$vuetify.theme.isDark"
|
||||
:color="value.scale_offset != null || value.scale_multiplier != null ? 'primary' : ''"
|
||||
:title="`Number scaling${ value.scale_offset != null ? ('\nOffset: ' + value.scale_offset) : '' }${ value.scale_multiplier != null ? ('\nMultiplier: ' + value.scale_multiplier) : ''}`"
|
||||
v-bind="attrs"
|
||||
v-on="on"
|
||||
>
|
||||
<v-icon small>mdi-ruler</v-icon>
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<v-card rounded outlined>
|
||||
<v-card-text>
|
||||
<v-row dense no-gutters>
|
||||
<v-text-field
|
||||
type="number"
|
||||
dense
|
||||
clearable
|
||||
label="Offset"
|
||||
title="Offset the value by this amount (after scaling)"
|
||||
v-model.number="value.scale_offset"
|
||||
></v-text-field>
|
||||
</v-row>
|
||||
<v-row dense no-gutters>
|
||||
<v-text-field
|
||||
type="number"
|
||||
dense
|
||||
clearable
|
||||
label="Scale"
|
||||
title="Mutiply the value by this amount (before scaling)"
|
||||
v-model.number="value.scale_multiplier"
|
||||
></v-text-field>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</v-col>
|
||||
|
||||
<v-col>
|
||||
<v-text-field
|
||||
class="ml-3"
|
||||
dense
|
||||
label="From"
|
||||
type="number"
|
||||
min="0"
|
||||
v-model.number="value.offset"
|
||||
:readonly="readonly"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
|
||||
<v-col>
|
||||
<v-text-field
|
||||
class="ml-3"
|
||||
dense
|
||||
label="Length"
|
||||
type="number"
|
||||
min="0"
|
||||
v-model.number="value.length"
|
||||
:readonly="readonly"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
|
||||
<v-col>
|
||||
<v-menu v-if="value.length > 1"
|
||||
max-width="600"
|
||||
:close-on-content-click="false"
|
||||
offset-y
|
||||
:disabled="!(value.length>1)"
|
||||
>
|
||||
<template v-slot:activator="{ on, attrs }">
|
||||
<v-chip
|
||||
class="ml-3"
|
||||
small
|
||||
:light="$vuetify.theme.isDark"
|
||||
:dark="!$vuetify.theme.isDark"
|
||||
title="Text alignment"
|
||||
v-bind="attrs"
|
||||
v-on="on"
|
||||
:disabled="!(value.length>1)"
|
||||
>
|
||||
<v-icon small v-if="value.pad_side=='right'">mdi-format-align-left</v-icon>
|
||||
<v-icon small v-else-if="value.pad_side=='left'">mdi-format-align-right</v-icon>
|
||||
<v-icon small v-else>mdi-format-align-justify</v-icon>
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<v-card rounded outlined>
|
||||
<v-card-text>
|
||||
<v-row dense no-gutters>
|
||||
<v-select
|
||||
label="Alignment"
|
||||
clearable
|
||||
:items='[{text:"Left", value:"right"}, {text:"Right", value:"left"}]'
|
||||
v-model="value.pad_side"
|
||||
></v-select>
|
||||
</v-row>
|
||||
<v-row dense no-gutters v-if="value.pad_side">
|
||||
<v-text-field
|
||||
dense
|
||||
label="Pad character"
|
||||
title="Fill the width of the field on the opposite side by padding with this character"
|
||||
v-model="value.pad_string"
|
||||
></v-text-field>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</v-col>
|
||||
|
||||
<v-col>
|
||||
<slot name="append"></slot>
|
||||
</v-col>
|
||||
|
||||
</v-row>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.input {
|
||||
flex: 1 1 auto;
|
||||
line-height: 20px;
|
||||
padding: 8px 0 8px;
|
||||
min-height: 32px;
|
||||
max-height: 32px;
|
||||
max-width: 100%;
|
||||
min-width: 0px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input >>> .chunk {
|
||||
padding-inline: 1px;
|
||||
border: 1px solid;
|
||||
}
|
||||
|
||||
.input >>> .chunk-empty {
|
||||
padding-inline: 1px;
|
||||
}
|
||||
|
||||
.input >>> .chunk-overlap {
|
||||
padding-inline: 1px;
|
||||
border: 1px solid grey;
|
||||
color: grey;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "DougalFixedStringEncoderField",
|
||||
|
||||
components: {
|
||||
},
|
||||
|
||||
props: {
|
||||
value: Object,
|
||||
properties: Object,
|
||||
colour: String,
|
||||
readonly: Boolean,
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
"value.value": function (value, old) {
|
||||
if (value != null && String(value).length > this.value.length) {
|
||||
this.value.length = String(value).length;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
|
||||
field: {
|
||||
get () {
|
||||
return this.value;
|
||||
},
|
||||
set (v) {
|
||||
console.log("input", v);
|
||||
this.$emit("input", v);
|
||||
}
|
||||
},
|
||||
|
||||
item () {
|
||||
return this.properties?.[this.value?.item] ?? {};
|
||||
},
|
||||
|
||||
items () {
|
||||
return Object.entries(this.properties).map(i => ({text: i[1].summary ?? i[0], value: i[0]}))
|
||||
},
|
||||
|
||||
name () {
|
||||
// TODO Use properties[item].summary or similar
|
||||
return this.item?.summary ?? this.value.item ?? "???";
|
||||
},
|
||||
|
||||
type () {
|
||||
return this.item?.type ?? typeof this.value?.item ?? "undefined";
|
||||
},
|
||||
|
||||
description () {
|
||||
return this.item?.description;
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
reset () {
|
||||
}
|
||||
},
|
||||
|
||||
mounted () {
|
||||
this.reset();
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,351 @@
|
||||
<template>
|
||||
<v-input
|
||||
class="v-text-field"
|
||||
:hint="hint"
|
||||
persistent-hint
|
||||
:value="text"
|
||||
>
|
||||
<label
|
||||
class="v-label"
|
||||
:class="[ $vuetify.theme.isDark && 'theme--dark', text && text.length && 'v-label--active' ]"
|
||||
style="left: 0px; right: auto; position: absolute;"
|
||||
>{{ label }}</label>
|
||||
<div class="input" slot="default"
|
||||
v-html="html"
|
||||
>
|
||||
</div>
|
||||
<template slot="append">
|
||||
<v-menu
|
||||
scrollable
|
||||
offset-y
|
||||
:close-on-content-click="false"
|
||||
>
|
||||
|
||||
<template v-slot:activator="{on, attrs}">
|
||||
<v-btn
|
||||
icon
|
||||
v-bind="attrs"
|
||||
v-on="on"
|
||||
>
|
||||
<v-icon title="Configure sample values">mdi-list-box-outline</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<v-card>
|
||||
<v-card-title>Sample values</v-card-title>
|
||||
<v-card-subtitle>Enter sample values to test your configuration</v-card-subtitle>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-card-text>
|
||||
|
||||
<v-container>
|
||||
<v-row v-for="(prop, key) in properties" :key="key">
|
||||
<template v-if="prop.type == 'boolean'">
|
||||
<v-col cols="6" align-self="center">
|
||||
<v-chip
|
||||
outlined
|
||||
label
|
||||
small
|
||||
:color="getHSLColourFor(key)"
|
||||
:title="prop.description"
|
||||
>{{prop.summary || key}}</v-chip>
|
||||
</v-col>
|
||||
<v-col cols="6" align-self="center">
|
||||
<v-simple-checkbox v-model="values[key]"></v-simple-checkbox>
|
||||
</v-col>
|
||||
</template>
|
||||
<template v-else-if="key != 'text'">
|
||||
<v-col cols="6" align-self="center">
|
||||
<v-chip
|
||||
outlined
|
||||
label
|
||||
small
|
||||
:color="getHSLColourFor(key)"
|
||||
:title="prop.description"
|
||||
>{{prop.summary || key}}</v-chip>
|
||||
</v-col>
|
||||
<v-col cols="6" align-self="center">
|
||||
<v-text-field v-if="prop.type == 'number'"
|
||||
:type="prop.type"
|
||||
:label="prop.summary || key"
|
||||
:hint="prop.description"
|
||||
v-model.number="values[key]"
|
||||
></v-text-field>
|
||||
<v-text-field v-else
|
||||
:type="prop.type"
|
||||
:label="prop.summary || key"
|
||||
:hint="prop.description"
|
||||
v-model="values[key]"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
</template>
|
||||
</v-row>
|
||||
</v-container>
|
||||
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
</v-menu>
|
||||
</template>
|
||||
<v-icon slot="prepend">mdi-list</v-icon>
|
||||
</v-input>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.input {
|
||||
flex: 1 1 auto;
|
||||
line-height: 20px;
|
||||
padding: 8px 0 8px;
|
||||
min-height: 32px;
|
||||
max-height: 32px;
|
||||
max-width: 100%;
|
||||
min-width: 0px;
|
||||
width: 100%;
|
||||
white-space-collapse: preserve;
|
||||
}
|
||||
|
||||
.multiline {
|
||||
font-family: mono;
|
||||
white-space: pre;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.multiline >>> .line-number {
|
||||
display: inline-block;
|
||||
font-size: 75%;
|
||||
width: 5ex;
|
||||
margin-inline-end: 1ex;
|
||||
text-align: right;
|
||||
border: none;
|
||||
position: relative;
|
||||
top: -1px;
|
||||
}
|
||||
|
||||
.input, .multiline >>> .chunk-field {
|
||||
padding-inline: 1px;
|
||||
border: 1px solid;
|
||||
}
|
||||
|
||||
.input, .multiline >>> .chunk-fixed {
|
||||
padding-inline: 1px;
|
||||
border: 1px dashed;
|
||||
}
|
||||
|
||||
.input, .multiline >>> .chunk-empty {
|
||||
padding-inline: 1px;
|
||||
}
|
||||
|
||||
.input, .multiline >>> .chunk-overlap {
|
||||
padding-inline: 1px;
|
||||
border: 1px solid grey;
|
||||
color: grey;
|
||||
}
|
||||
|
||||
.input >>> .chunk-mismatch {
|
||||
padding-inline: 1px;
|
||||
border: 2px solid red !important;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { getHSLColourFor } from '@/lib/hsl'
|
||||
|
||||
export default {
|
||||
name: "DougalFixedStringEncoderSample",
|
||||
|
||||
components: {
|
||||
},
|
||||
|
||||
mixins: [
|
||||
{
|
||||
methods: {
|
||||
getHSLColourFor
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
props: {
|
||||
properties: { type: Object, default: () => ({}) },
|
||||
fields: { type: Array, default: () => [] },
|
||||
values: { type: Object, default: () => ({}) },
|
||||
readonly: Boolean,
|
||||
label: String,
|
||||
hint: String,
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
|
||||
chunks () {
|
||||
const properties = this.properties;
|
||||
const fields = this.fields;
|
||||
const values = this.values;
|
||||
const str = "";
|
||||
const chunks = [];
|
||||
|
||||
for (const field of fields) {
|
||||
const value = this.fieldValue(properties, field, values);
|
||||
|
||||
if (value != null) {
|
||||
const chunk = {
|
||||
start: field.offset,
|
||||
end: field.offset + field.length - 1,
|
||||
colour: this.getHSLColourFor(field.item),
|
||||
class: field.item == "text" ? "fixed" : "field",
|
||||
text: value
|
||||
}
|
||||
chunks.push(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
return chunks;
|
||||
},
|
||||
|
||||
text () {
|
||||
return this.sample(this.properties, this.fields, this.values);
|
||||
},
|
||||
|
||||
html () {
|
||||
return this.renderTextLine(this.text);
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
watch: {
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
fieldValue (properties, field, values) {
|
||||
let value;
|
||||
|
||||
if (field.item == "text") {
|
||||
value = field.value;
|
||||
} else if (properties[field.item]?.type == "boolean") {
|
||||
if (values[field.item] === field.when) {
|
||||
value = field.value;
|
||||
}
|
||||
} else {
|
||||
value = values[field.item];
|
||||
}
|
||||
|
||||
if (value != null) {
|
||||
|
||||
if (properties[field.item]?.type == "number") {
|
||||
if (field.scale_multiplier != null) {
|
||||
value *= field.scale_multiplier;
|
||||
}
|
||||
if (field.scale_offset != null) {
|
||||
value += field.scale_offset;
|
||||
}
|
||||
|
||||
if (field.format == "integer") {
|
||||
value = Math.round(value);
|
||||
}
|
||||
}
|
||||
|
||||
value = String(value);
|
||||
if (field.pad_side == "left") {
|
||||
value = value.padStart(field.length, field.pad_string ?? " ");
|
||||
} else if (field.pad_side == "right") {
|
||||
value = value.padEnd(field.length, field.pad_string ?? " ");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
},
|
||||
|
||||
sample (properties, fields, values, str = "") {
|
||||
|
||||
const length = fields.reduce( (acc, cur) => (cur.offset + cur.length) > acc ? (cur.offset + cur.length) : acc, str.length )
|
||||
|
||||
str = str.padEnd(length);
|
||||
|
||||
for (const field of fields) {
|
||||
//console.log("FIELD", field);
|
||||
const value = this.fieldValue(properties, field, values);
|
||||
if (value != null) {
|
||||
str = str.slice(0, field.offset) + value + str.slice(field.offset + field.length);
|
||||
}
|
||||
}
|
||||
|
||||
return str;
|
||||
},
|
||||
|
||||
/** Return a `<span>` opening tag.
|
||||
*/
|
||||
style (name, colour) {
|
||||
return colour
|
||||
? `<span class="${name}" style="color:${colour};border-color:${colour}">`
|
||||
: `<span class="${name}">`;
|
||||
},
|
||||
|
||||
/** Return an array of the intervals that intersect `pos`.
|
||||
* May be empty.
|
||||
*/
|
||||
chunksFor (pos) {
|
||||
return this.chunks.filter( chunk =>
|
||||
pos >= chunk.start &&
|
||||
pos <= chunk.end
|
||||
)
|
||||
},
|
||||
|
||||
/*
|
||||
* Algorithm:
|
||||
*
|
||||
* Go through every character of one line of text and determine in which
|
||||
* part(s) it falls in, if any. Collect adjacent same parts into <span/>
|
||||
* elements.
|
||||
*/
|
||||
renderTextLine (text) {
|
||||
const parts = [];
|
||||
|
||||
let prevStyle;
|
||||
|
||||
for (const pos in text) {
|
||||
const chunks = this.chunksFor(pos);
|
||||
const isEmpty = chunks.length == 0;
|
||||
const isOverlap = chunks.length > 1;
|
||||
const isMismatch = chunks[0]?.text &&
|
||||
(text.substring(chunks[0].start, chunks[0].end+1) != chunks[0].text);
|
||||
|
||||
const style = isEmpty
|
||||
? this.style("chunk-empty")
|
||||
: isMismatch
|
||||
? this.style("chunk-mismatch", chunks[0].colour)
|
||||
: isOverlap
|
||||
? this.style("chunk-overlap")
|
||||
: this.style("chunk-"+chunks[0].class, chunks[0].colour);
|
||||
|
||||
if (style != prevStyle) {
|
||||
if (prevStyle) {
|
||||
parts.push("</span>");
|
||||
}
|
||||
parts.push(style);
|
||||
}
|
||||
parts.push(text[pos]);
|
||||
prevStyle = style;
|
||||
}
|
||||
|
||||
if (parts.length) {
|
||||
parts.push("</span>");
|
||||
}
|
||||
|
||||
return parts.join("");
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
mounted () {
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,307 @@
|
||||
<template>
|
||||
<v-card flat elevation="0">
|
||||
<v-card-title v-if="title">{{ title }}</v-card-title>
|
||||
<v-card-subtitle v-if="subtitle">{{ subtitle }}</v-card-subtitle>
|
||||
<v-card-text>
|
||||
<v-form>
|
||||
|
||||
<!-- Sample text -->
|
||||
|
||||
<dougal-fixed-string-encoder-sample
|
||||
:label="label"
|
||||
:hint="hint"
|
||||
:properties="properties"
|
||||
:fields="fields"
|
||||
:values.sync="values"
|
||||
></dougal-fixed-string-encoder-sample>
|
||||
|
||||
<!-- Fields -->
|
||||
|
||||
<v-container>
|
||||
|
||||
<v-row no-gutters class="mb-2">
|
||||
<h4>Fields</h4>
|
||||
</v-row>
|
||||
|
||||
<dougal-fixed-string-encoder-field v-for="(field, key) in fields" :key="key"
|
||||
v-model="fields[key]"
|
||||
:properties="properties"
|
||||
:colour="getHSLColourFor(field.item)"
|
||||
:readonly="readonly"
|
||||
>
|
||||
<template v-slot:append v-if="editableFieldList && !readonly">
|
||||
<v-btn
|
||||
class="ml-3"
|
||||
fab
|
||||
text
|
||||
small
|
||||
title="Remove this field"
|
||||
>
|
||||
<v-icon
|
||||
color="error"
|
||||
@click="removeField(key)"
|
||||
>mdi-minus</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</dougal-fixed-string-encoder-field>
|
||||
|
||||
<v-row no-gutters class="mb-2" v-if="editableFieldList && !readonly">
|
||||
<h4>Add new field</h4>
|
||||
</v-row>
|
||||
|
||||
<dougal-fixed-string-encoder-field v-if="editableFieldList && !readonly"
|
||||
v-model="newField"
|
||||
:properties="properties"
|
||||
:colour="getHSLColourFor(newField.item)"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-btn v-if="isFieldDirty(newField)"
|
||||
top
|
||||
text
|
||||
small
|
||||
title="Reset"
|
||||
>
|
||||
<v-icon
|
||||
color="warning"
|
||||
@click="resetField(newField)"
|
||||
>mdi-backspace-reverse-outline</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<template v-slot:append>
|
||||
<v-btn
|
||||
class="ml-3"
|
||||
fab
|
||||
text
|
||||
small
|
||||
title="Add field"
|
||||
:disabled="isFieldValid(newField) !== true"
|
||||
>
|
||||
<v-icon
|
||||
color="primary"
|
||||
@click="addField(newField)"
|
||||
>mdi-plus</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</dougal-fixed-string-encoder-field>
|
||||
|
||||
</v-container>
|
||||
|
||||
|
||||
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.input {
|
||||
flex: 1 1 auto;
|
||||
line-height: 20px;
|
||||
padding: 8px 0 8px;
|
||||
min-height: 32px;
|
||||
max-height: 32px;
|
||||
max-width: 100%;
|
||||
min-width: 0px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input, .multiline >>> .chunk-field {
|
||||
padding-inline: 1px;
|
||||
border: 1px solid;
|
||||
}
|
||||
|
||||
.input, .multiline >>> .chunk-fixed {
|
||||
padding-inline: 1px;
|
||||
border: 1px dashed;
|
||||
}
|
||||
|
||||
.input, .multiline >>> .chunk-empty {
|
||||
padding-inline: 1px;
|
||||
}
|
||||
|
||||
.input, .multiline >>> .chunk-overlap {
|
||||
padding-inline: 1px;
|
||||
border: 1px solid grey;
|
||||
color: grey;
|
||||
}
|
||||
|
||||
.input >>> .chunk-mismatch {
|
||||
padding-inline: 1px;
|
||||
border: 2px solid red !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { getHSLColourFor } from '@/lib/hsl'
|
||||
import DougalFixedStringEncoderField from './fixed-string-encoder-field'
|
||||
import DougalFixedStringEncoderSample from './fixed-string-encoder-sample'
|
||||
|
||||
export default {
|
||||
name: "DougalFixedStringEncoder",
|
||||
|
||||
components: {
|
||||
DougalFixedStringEncoderField,
|
||||
DougalFixedStringEncoderSample
|
||||
},
|
||||
|
||||
mixins: [
|
||||
{
|
||||
methods: {
|
||||
getHSLColourFor
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
props: {
|
||||
properties: { type: Object },
|
||||
fields: { type: Array },
|
||||
values: { type: Object },
|
||||
editableFieldList: { type: Boolean, default: true },
|
||||
readonly: Boolean,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
label: String,
|
||||
hint: String,
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
//< The reason for not using this.text directly is that at some point
|
||||
//< we might extend this component to allow editing the sample text.
|
||||
text_: "",
|
||||
//< The value of a fixed string that should be always present at a specific position
|
||||
fixedName: "",
|
||||
fixedOffset: 0,
|
||||
//< The name of a new field to add.
|
||||
fieldName: "",
|
||||
newField: {
|
||||
item: null,
|
||||
when: null,
|
||||
offset: null,
|
||||
length: null,
|
||||
value: null,
|
||||
pad_side: null,
|
||||
pad_string: null
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
|
||||
chunks () {
|
||||
const properties = this.properties;
|
||||
const fields = this.fields;
|
||||
const values = this.values;
|
||||
const str = "";
|
||||
const chunks = [];
|
||||
|
||||
for (const field of fields) {
|
||||
|
||||
//console.log("FIELD", structuredClone(field));
|
||||
//console.log("VALUES DATA", values[field.item]);
|
||||
let value;
|
||||
|
||||
if (field.item == "text") {
|
||||
value = field.value;
|
||||
} else if (properties[field.item]?.type == "boolean") {
|
||||
if (values[field.item] === field.when) {
|
||||
value = field.value;
|
||||
}
|
||||
} else {
|
||||
value = values[field.item];
|
||||
}
|
||||
|
||||
if (value != null) {
|
||||
|
||||
value = String(value);
|
||||
if (field.pad_side == "left") {
|
||||
value = value.padStart(field.length, field.pad_string);
|
||||
} else {
|
||||
value = value.padEnd(field.length, field.pad_string);
|
||||
}
|
||||
|
||||
const chunk = {
|
||||
start: field.offset,
|
||||
end: field.offset + field.length - 1,
|
||||
colour: this.getHSLColourFor(field.item),
|
||||
class: field.item == "text" ? "fixed" : "field",
|
||||
text: value
|
||||
}
|
||||
|
||||
//console.log("CHUNK", chunk);
|
||||
chunks.push(chunk);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return chunks;
|
||||
},
|
||||
|
||||
html () {
|
||||
return this.renderTextLine(this.sample(this.properties, this.fields, this.values));
|
||||
//return this.sample(this.properties, this.fields, this.values);
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
watch: {
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
isFieldDirty (field) {
|
||||
return Object.entries(field).reduce( (acc, cur) => cur[1] === null ? acc : true, false );
|
||||
},
|
||||
|
||||
isFieldValid (field) {
|
||||
if (!field.item) return "Missing item";
|
||||
if (typeof field.offset !== "number" || field.offset < 0) return "Missing offset";
|
||||
if (typeof field.length !== "number" || field.length < 1) return "Missing length";
|
||||
if (!this.properties[field.item]) return "Unrecognised property";
|
||||
if (this.properties[field.item].type == "text" && !field.value?.length) return "Missing value";
|
||||
if (this.properties[field.item].type == "boolean" && !field.value?.length) return "Missing value (boolean)";
|
||||
if(!!field.pad_side && !field.pad_string) return "Missing pad string";
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
resetField (field) {
|
||||
field.item = null;
|
||||
field.when = null;
|
||||
field.offset = null;
|
||||
field.length = null;
|
||||
field.value = null;
|
||||
field.pad_side = null;
|
||||
field.pad_string = null;
|
||||
|
||||
return field;
|
||||
},
|
||||
|
||||
addField (field) {
|
||||
if (this.isFieldValid(field)) {
|
||||
const fields = structuredClone(this.fields);
|
||||
fields.push({...field});
|
||||
this.resetField(field);
|
||||
console.log("update:fields", fields);
|
||||
this.$emit("update:fields", fields);
|
||||
}
|
||||
},
|
||||
|
||||
removeField (key) {
|
||||
console.log("REMOVE", "update:fields", key, this.fields);
|
||||
const fields = structuredClone(this.fields);
|
||||
fields.splice(key, 1);
|
||||
this.$emit("update:fields", fields);
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
mounted () {
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
@@ -127,7 +127,7 @@ export default {
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters(['user', 'writeaccess', 'loading', 'serverEvent'])
|
||||
...mapGetters(['user', 'loading', 'serverEvent'])
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
@@ -32,16 +32,61 @@
|
||||
</template>
|
||||
|
||||
<v-list dense>
|
||||
<v-list-item :href="`/settings/equipment`">
|
||||
<v-list-item-title>Equipment list</v-list-item-title>
|
||||
<v-list-item href="/settings/equipment">
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>Equipment list</v-list-item-title>
|
||||
<v-list-item-subtitle>Manage the list of equipment reported in logs</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
<v-list-item-action><v-icon small>mdi-view-list</v-icon></v-list-item-action>
|
||||
</v-list-item>
|
||||
<template v-if="false">
|
||||
<v-divider></v-divider>
|
||||
<v-list-item href="/settings">
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>Local settings</v-list-item-title>
|
||||
<v-list-item-subtitle>Manage this vessel's configuration</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
<v-list-item-action><v-icon small>mdi-ferry</v-icon></v-list-item-action>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-list>
|
||||
|
||||
</v-menu>
|
||||
|
||||
|
||||
<v-breadcrumbs :items="path"></v-breadcrumbs>
|
||||
<v-breadcrumbs :items="path">
|
||||
<template v-slot:item="{ item }">
|
||||
<v-breadcrumbs-item :href="item.href" :disabled="item.disabled" v-if="item.organisations">
|
||||
<v-tooltip bottom>
|
||||
<template v-slot:activator="{ on, attrs }">
|
||||
<span v-bind="attrs" v-on="on">{{ item.text }}</span>
|
||||
</template>
|
||||
<div class="text-overline">Project permissions</div>
|
||||
<v-simple-table dense>
|
||||
<template v-slot:default>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Organisation</th><th>Read</th><th>Write</th><th>Edit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(operations, name) in item.organisations">
|
||||
<td v-if="name == '*'"><v-chip small label color="primary">All</v-chip></td>
|
||||
<td v-else><v-chip small label outlined>{{ name }}</v-chip></td>
|
||||
<td>{{ operations.read ? "✔" : " " }}</td>
|
||||
<td>{{ operations.write ? "✔" : " " }}</td>
|
||||
<td>{{ operations.edit ? "✔" : " " }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</template>
|
||||
</v-simple-table>
|
||||
</v-tooltip>
|
||||
</v-breadcrumbs-item>
|
||||
<v-breadcrumbs-item :href="item.href" :disabled="item.disabled" v-else>
|
||||
{{ item.text }}
|
||||
</v-breadcrumbs-item>
|
||||
</template>
|
||||
</v-breadcrumbs>
|
||||
|
||||
<template v-if="$route.name != 'Login'">
|
||||
<v-btn text link to="/login" v-if="!user && !loading">Log in</v-btn>
|
||||
@@ -50,10 +95,37 @@
|
||||
<v-menu
|
||||
offset-y
|
||||
>
|
||||
<template v-slot:activator="{on, attrs}">
|
||||
<v-avatar :color="user.colour || 'primary'" :title="`${user.name} (${user.role})`" v-bind="attrs" v-on="on">
|
||||
<span class="white--text">{{user.name.slice(0, 5)}}</span>
|
||||
</v-avatar>
|
||||
<template v-slot:activator="{ on: menu, attrs }">
|
||||
<v-tooltip bottom>
|
||||
<template v-slot:activator="{ on: tooltip }">
|
||||
<v-avatar :color="user.colour || 'primary'" v-bind="attrs" v-on="{...tooltip, ...menu}">
|
||||
<span class="white--text">{{user.name.slice(0, 5)}}</span>
|
||||
</v-avatar>
|
||||
</template>
|
||||
<div class="text-overline">{{ user.name }}</div>
|
||||
<v-card flat class="my-1" v-if="user.description">
|
||||
<v-card-text class="pb-1" v-html="$root.markdown(user.description)">
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
<v-simple-table dense>
|
||||
<template v-slot:default>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Organisation</th><th>Read</th><th>Write</th><th>Edit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="org in user.organisations">
|
||||
<td v-if="org.name == '*'"><v-chip small label color="primary">All</v-chip></td>
|
||||
<td v-else><v-chip small label outlined>{{ org.name }}</v-chip></td>
|
||||
<td>{{ org.operations.read ? "✔" : " " }}</td>
|
||||
<td>{{ org.operations.write ? "✔" : " " }}</td>
|
||||
<td>{{ org.operations.edit ? "✔" : " " }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</template>
|
||||
</v-simple-table>
|
||||
</v-tooltip>
|
||||
</template>
|
||||
|
||||
<v-list dense>
|
||||
@@ -66,8 +138,29 @@
|
||||
</v-list-item>
|
||||
<v-list-item link to="/logout" v-else>
|
||||
<v-list-item-icon><v-icon small>mdi-logout</v-icon></v-list-item-icon>
|
||||
<v-list-item-title>Log out</v-list-item-title>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>Log out</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
<v-divider></v-divider>
|
||||
<template v-if="canManageUsers">
|
||||
<v-list-item link to="/users">
|
||||
<v-list-item-icon><v-icon small>mdi-account-multiple</v-icon></v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>Manage users</v-list-item-title>
|
||||
<v-list-item-subtitle>Add, edit and remove users</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</template>
|
||||
<template v-else-if="user && !user.autologin">
|
||||
<v-list-item link :to="`/users/${user.id}`">
|
||||
<v-list-item-icon><v-icon small>mdi-account</v-icon></v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>User profile</v-list-item-title>
|
||||
<v-list-item-subtitle>Edit your user profile</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-list>
|
||||
|
||||
</v-menu>
|
||||
@@ -102,6 +195,19 @@ export default {
|
||||
.pop()?.component;
|
||||
},
|
||||
|
||||
title () {
|
||||
return this.user.name + "\n" + [...this.user.organisations].map( ({name, operations}) => {
|
||||
if (name == "*") name = "All organisations";
|
||||
let str = name+": ";
|
||||
str += [ "read", "write", "edit" ].map( op => operations[op] ? op : null ).filter( op => op ).join(", ");
|
||||
return str;
|
||||
}).join("\n")
|
||||
},
|
||||
|
||||
canManageUsers () {
|
||||
return this.user.organisations.accessToOperation("edit").length;
|
||||
},
|
||||
|
||||
...mapGetters(['user', 'loading'])
|
||||
},
|
||||
|
||||
|
||||
112
lib/www/client/source/src/components/organisations-item.vue
Normal file
112
lib/www/client/source/src/components/organisations-item.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<v-row dense no-gutters>
|
||||
|
||||
<v-col>
|
||||
<slot name="prepend"></slot>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="6">
|
||||
<v-text-field
|
||||
class="mr-5"
|
||||
dense
|
||||
label="Name"
|
||||
:value="name"
|
||||
:readonly="true"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
|
||||
<v-col>
|
||||
<v-checkbox
|
||||
class="mr-3"
|
||||
label="Read"
|
||||
v-model="operations.read"
|
||||
:readonly="readonly"
|
||||
></v-checkbox>
|
||||
</v-col>
|
||||
|
||||
<v-col>
|
||||
<v-checkbox
|
||||
class="mr-3"
|
||||
label="Write"
|
||||
v-model="operations.write"
|
||||
:readonly="readonly"
|
||||
></v-checkbox>
|
||||
</v-col>
|
||||
|
||||
<v-col>
|
||||
<v-checkbox
|
||||
class="mr-3"
|
||||
label="Edit"
|
||||
v-model="operations.edit"
|
||||
:readonly="readonly"
|
||||
></v-checkbox>
|
||||
</v-col>
|
||||
|
||||
<v-col>
|
||||
<!-- Just to fill the twelve-column grid -->
|
||||
<!--
|
||||
NOTE: this column could also be used for
|
||||
a popdown menu with additional operations
|
||||
if needed.
|
||||
-->
|
||||
</v-col>
|
||||
|
||||
<v-col>
|
||||
<slot name="append"></slot>
|
||||
</v-col>
|
||||
|
||||
</v-row>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { Organisations } from '@dougal/organisations';
|
||||
|
||||
export default {
|
||||
name: "DougalOrganisationsItem",
|
||||
|
||||
props: {
|
||||
name: String,
|
||||
value: Object,
|
||||
readonly: Boolean,
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
operations: {...this.value}
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
value: {
|
||||
handler (newValue) {
|
||||
this.operations = {...this.value};
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
|
||||
operations: {
|
||||
handler (newValue) {
|
||||
if (["read", "write", "edit"].some( k => newValue[k] != this.value[k] )) {
|
||||
// Only emit if a value has actually changed
|
||||
this.$emit("input", {...newValue});
|
||||
}
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
reset () {
|
||||
}
|
||||
},
|
||||
|
||||
mounted () {
|
||||
this.reset();
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
191
lib/www/client/source/src/components/organisations.vue
Normal file
191
lib/www/client/source/src/components/organisations.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-title>Organisations</v-card-title>
|
||||
<v-card-subtitle>Organisation access</v-card-subtitle>
|
||||
<v-card-text>
|
||||
<v-form>
|
||||
|
||||
<v-container>
|
||||
|
||||
<dougal-organisations-item v-for="organisation in localOrganisations.names()"
|
||||
:key="organisation"
|
||||
:name="organisation"
|
||||
:value="localOrganisations.get(organisation)"
|
||||
@input="setOrganisation(organisation, $event)"
|
||||
>
|
||||
<template v-slot:append v-if="!readonly">
|
||||
<v-btn
|
||||
class="ml-3"
|
||||
fab
|
||||
text
|
||||
small
|
||||
title="Remove this organisation"
|
||||
>
|
||||
<v-icon
|
||||
color="error"
|
||||
@click="removeOrganisation(organisation)"
|
||||
>mdi-minus</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</dougal-organisations-item>
|
||||
|
||||
|
||||
<v-row no-gutters class="mb-2" v-if="!readonly">
|
||||
<h4>Add organisation</h4>
|
||||
</v-row>
|
||||
|
||||
<v-row no-gutters class="mb-2" v-if="!readonly">
|
||||
<v-combobox v-if="canCreateOrganisations"
|
||||
label="Organisation"
|
||||
:items="remainingOrganisations"
|
||||
v-model="organisationName"
|
||||
@input.native="organisationName = $event.srcElement.value"
|
||||
@keyup.enter="addOrganisation()"
|
||||
></v-combobox>
|
||||
<v-select v-else
|
||||
label="Organisation"
|
||||
:items="remainingOrganisations"
|
||||
v-model="organisationName"
|
||||
></v-select>
|
||||
<v-btn
|
||||
class="ml-3"
|
||||
fab
|
||||
text
|
||||
small
|
||||
title="Add organisation"
|
||||
:disabled="!(organisationName && organisationName.length)"
|
||||
@click="addOrganisation()"
|
||||
>
|
||||
<v-icon
|
||||
color="primary"
|
||||
>mdi-plus</v-icon>
|
||||
</v-btn>
|
||||
</v-row>
|
||||
|
||||
</v-container>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<slot name="actions" v-bind="{ self, organisations, readonly, validationErrors, canCreateOrganisations }">
|
||||
</slot>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Organisations } from '@dougal/organisations';
|
||||
import DougalOrganisationsItem from './organisations-item';
|
||||
|
||||
|
||||
export default {
|
||||
name: "DougalOrganisations",
|
||||
|
||||
components: {
|
||||
DougalOrganisationsItem
|
||||
},
|
||||
|
||||
props: {
|
||||
self: Object,
|
||||
organisations: Object,
|
||||
readonly: Boolean
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
organisationName: "",
|
||||
localOrganisations: this.setLocalOrganisations(this.organisations)
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
|
||||
availableOrganisations () {
|
||||
return this.self.organisations.names();
|
||||
},
|
||||
|
||||
// Organisations available to add.
|
||||
// These are the organisations in `availableOrganisations`
|
||||
// minus any that have already been added.
|
||||
// The special value "*" (meaning "every organisation")
|
||||
// is not included.
|
||||
remainingOrganisations () {
|
||||
const orgs = [];
|
||||
|
||||
for (const org of this.availableOrganisations) {
|
||||
if (org != "*" && !this.localOrganisations.has(org)) {
|
||||
orgs.push(org);
|
||||
}
|
||||
}
|
||||
|
||||
return orgs;
|
||||
},
|
||||
|
||||
canCreateOrganisations () {
|
||||
return this.self.organisations.value("*")?.edit ?? false;
|
||||
},
|
||||
|
||||
validationErrors () {
|
||||
const errors = [];
|
||||
|
||||
// Check if there is at least one organisation
|
||||
if (this.localOrganisations.length) {
|
||||
errors.push("ERR_NO_ORGS");
|
||||
}
|
||||
|
||||
// Check if at least one organisation has edit rights
|
||||
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
watch: {
|
||||
organisations (newValue) {
|
||||
this.localOrganisations = this.setLocalOrganisations(newValue);
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
setLocalOrganisations (value) {
|
||||
return new Organisations(this.organisations);
|
||||
},
|
||||
|
||||
setOrganisation(name, value) {
|
||||
this.localOrganisations.set(name, value);
|
||||
this.$emit("update:organisations", new Organisations(this.localOrganisations));
|
||||
},
|
||||
|
||||
addOrganisation () {
|
||||
const key = this.organisationName;
|
||||
if (!this.localOrganisations.has(key)) {
|
||||
this.localOrganisations.set(key);
|
||||
this.$emit("update:organisations", this.localOrganisations);
|
||||
}
|
||||
this.organisationName = "";
|
||||
},
|
||||
|
||||
removeOrganisation (key) {
|
||||
if (this.localOrganisations.has(key)) {
|
||||
this.localOrganisations.remove(key);
|
||||
}
|
||||
this.$emit("update:organisations", this.localOrganisations);
|
||||
},
|
||||
|
||||
reset () {
|
||||
},
|
||||
|
||||
save () {
|
||||
},
|
||||
|
||||
back () {
|
||||
this.$emit('close');
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
mounted () {
|
||||
this.reset();
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
@@ -18,6 +18,7 @@
|
||||
A list of directories which are searched for matching files.
|
||||
</v-card-subtitle>
|
||||
<v-card-text>
|
||||
<slot name="paths"></slot>
|
||||
<v-form>
|
||||
<v-text-field v-for="(item, index) in paths" :key="index"
|
||||
v-model="paths[index]"
|
||||
@@ -61,6 +62,7 @@
|
||||
A list of <a href="https://en.wikipedia.org/wiki/Glob_(programming)" target="_blank">glob patterns</a> expanding to match the files of interest. Note that Linux is case-sensitive.
|
||||
</v-card-subtitle>
|
||||
<v-card-text>
|
||||
<slot name="globs"></slot>
|
||||
<v-form>
|
||||
<v-text-field v-for="(item, index) in globs" :key="index"
|
||||
v-model="globs[index]"
|
||||
@@ -98,6 +100,7 @@
|
||||
<b v-if="lineNameInfo">Note: Use the <a @click.stop="tab=3">line info</a> tab preferentially.</b>
|
||||
</v-card-subtitle>
|
||||
<v-card-text>
|
||||
<slot name="pattern"></slot>
|
||||
<v-form>
|
||||
<v-text-field
|
||||
class="mb-5"
|
||||
@@ -156,6 +159,7 @@
|
||||
Line information that will be extracted from file names
|
||||
</v-card-subtitle>
|
||||
<v-card-text>
|
||||
<slot name="line-info"></slot>
|
||||
<v-form>
|
||||
<v-text-field
|
||||
label="Example file name"
|
||||
|
||||
@@ -7,8 +7,32 @@
|
||||
:is-valid="isValid"
|
||||
:save="save"
|
||||
>
|
||||
</dougal-project-settings-file-matching-parameters>
|
||||
<template v-slot:paths v-if="validationErrors.includes('ERR_PATHS')">
|
||||
<v-alert type="warning">
|
||||
At least one path entry is required.<br/>
|
||||
<ul>
|
||||
<li>If you have final P1/11 files in multiple paths (e.g., each file in its own sequence directory), enter here the parent directory.</li>
|
||||
<li>If files are across multiple paths without a common ancestor, you must add multiple entries here.</li>
|
||||
</ul>
|
||||
</v-alert>
|
||||
</template>
|
||||
|
||||
<template v-slot:globs v-if="validationErrors.includes('ERR_GLOBS')">
|
||||
<v-alert type="warning">
|
||||
At least one glob expression is required.
|
||||
</v-alert>
|
||||
</template>
|
||||
|
||||
<template v-slot:line-info v-if="validationErrors.includes('ERR_LINEINFO')">
|
||||
<v-alert type="warning">
|
||||
At least the following fields are required:
|
||||
<ul>
|
||||
<li><code>line</code> (integer, the preplot line number)</li>
|
||||
<li><code>sequence</code> (integer, the acquisition sequence number)</li>
|
||||
</ul>
|
||||
</v-alert>
|
||||
</template>
|
||||
</dougal-project-settings-file-matching-parameters>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -91,6 +115,14 @@ export default {
|
||||
lineNameInfo: {
|
||||
example: "",
|
||||
fields: {
|
||||
line: {
|
||||
length: 4,
|
||||
type: "int"
|
||||
},
|
||||
sequence: {
|
||||
length: 3,
|
||||
type: "int"
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -109,18 +141,38 @@ export default {
|
||||
globs: this.cwo?.globs,
|
||||
paths: this.cwo?.paths,
|
||||
pattern: this.cwo?.pattern,
|
||||
lineNameInfo: this.cwo?.lineNameInfo
|
||||
lineNameInfo: this.cwo?.lineNameInfo ?? {}
|
||||
};
|
||||
},
|
||||
|
||||
validationErrors () {
|
||||
const errors = [];
|
||||
|
||||
if (!this.cwo?.paths.length || !this.cwo?.paths[0].length) {
|
||||
// "Missing path: we need at least one directory where to search for matching files"
|
||||
errors.push("ERR_PATHS");
|
||||
}
|
||||
|
||||
if (!this.cwo?.globs.length) {
|
||||
// "Missing globs: we need at least one glob to search for matching files"
|
||||
errors.push("ERR_GLOBS");
|
||||
}
|
||||
|
||||
if (this.cwo?.lineNameInfo) {
|
||||
const pass = !this.cwo?.lineNameInfo?.fields || [ "line", "sequence" ].every( i =>
|
||||
["offset", "length"].every( j => j in (this.cwo?.lineNameInfo?.fields?.[i] ?? {}) ));
|
||||
|
||||
if (!pass) {
|
||||
// "Missing field info: We need at least 'line' and 'sequence' fields"
|
||||
errors.push("ERR_LINEINFO")
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
},
|
||||
|
||||
isValid () {
|
||||
return !!(this.cwo?.paths.length && this.cwo?.globs.length && (
|
||||
this.cwo?.pattern?.regex &&
|
||||
["direction", "line", "sequence"].every( i => this.cwo?.pattern?.captures?.includes(i) )) || (
|
||||
this.cwo?.lineNameInfo &&
|
||||
this.cwo?.lineNameInfo?.fields &&
|
||||
[ "line", "sequence", "incr" ].every( i =>
|
||||
["offset", "length"].every( j => j in this.cwo?.lineNameInfo.fields[i] ))));
|
||||
return this.validationErrors.length == 0;
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
@@ -7,6 +7,31 @@
|
||||
:is-valid="isValid"
|
||||
:save="save"
|
||||
>
|
||||
<template v-slot:paths v-if="validationErrors.includes('ERR_PATHS')">
|
||||
<v-alert type="warning">
|
||||
At least one path entry is required.<br/>
|
||||
<ul>
|
||||
<li>If you have final P1/11 files in multiple paths (e.g., each file in its own sequence directory), enter here the parent directory.</li>
|
||||
<li>If files are across multiple paths without a common ancestor, you must add multiple entries here.</li>
|
||||
</ul>
|
||||
</v-alert>
|
||||
</template>
|
||||
|
||||
<template v-slot:globs v-if="validationErrors.includes('ERR_GLOBS')">
|
||||
<v-alert type="warning">
|
||||
At least one glob expression is required.
|
||||
</v-alert>
|
||||
</template>
|
||||
|
||||
<template v-slot:line-info v-if="validationErrors.includes('ERR_LINEINFO')">
|
||||
<v-alert type="warning">
|
||||
At least the following fields are required:
|
||||
<ul>
|
||||
<li><code>line</code> (integer, the preplot line number)</li>
|
||||
<li><code>sequence</code> (integer, the acquisition sequence number)</li>
|
||||
</ul>
|
||||
</v-alert>
|
||||
</template>
|
||||
</dougal-project-settings-file-matching-parameters>
|
||||
|
||||
</template>
|
||||
@@ -91,6 +116,14 @@ export default {
|
||||
lineNameInfo: {
|
||||
example: "",
|
||||
fields: {
|
||||
line: {
|
||||
length: 4,
|
||||
type: "int"
|
||||
},
|
||||
sequence: {
|
||||
length: 3,
|
||||
type: "int"
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -113,14 +146,34 @@ export default {
|
||||
};
|
||||
},
|
||||
|
||||
validationErrors () {
|
||||
const errors = [];
|
||||
|
||||
if (!this.cwo?.paths.length || !this.cwo?.paths[0].length) {
|
||||
// "Missing path: we need at least one directory where to search for matching files"
|
||||
errors.push("ERR_PATHS");
|
||||
}
|
||||
|
||||
if (!this.cwo?.globs.length) {
|
||||
// "Missing globs: we need at least one glob to search for matching files"
|
||||
errors.push("ERR_GLOBS");
|
||||
}
|
||||
|
||||
if (this.cwo?.lineNameInfo) {
|
||||
const pass = !this.cwo?.lineNameInfo?.fields || [ "line", "sequence" ].every( i =>
|
||||
["offset", "length"].every( j => j in (this.cwo?.lineNameInfo?.fields?.[i] ?? {}) ));
|
||||
|
||||
if (!pass) {
|
||||
// "Missing field info: We need at least 'line' and 'sequence' fields"
|
||||
errors.push("ERR_LINEINFO")
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
},
|
||||
|
||||
isValid () {
|
||||
return !!(this.cwo?.paths.length && this.cwo?.globs.length && (
|
||||
this.cwo?.pattern?.regex &&
|
||||
["direction", "line", "sequence"].every( i => this.cwo?.pattern?.captures?.includes(i) )) || (
|
||||
this.cwo?.lineNameInfo &&
|
||||
this.cwo?.lineNameInfo?.fields &&
|
||||
[ "line", "sequence", "incr" ].every( i =>
|
||||
["offset", "length"].every( j => j in this.cwo?.lineNameInfo.fields[i] ))));
|
||||
return this.validationErrors.length == 0;
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
@@ -4,20 +4,16 @@
|
||||
<v-card-subtitle>Line name decoding configuration for real-time data</v-card-subtitle>
|
||||
<v-card-text>
|
||||
<v-form>
|
||||
<v-text-field
|
||||
label="Example file name"
|
||||
hint="Enter the name of a representative file to make it easier to visualise your configuration"
|
||||
persistent-hint
|
||||
v-model="cwo.lineNameInfo.example"
|
||||
></v-text-field>
|
||||
|
||||
<dougal-fixed-string-decoder
|
||||
<dougal-fixed-string-encoder
|
||||
title="Line name format"
|
||||
subtitle="Format of line names as configured in the navigation system"
|
||||
label="Example line name"
|
||||
hint="Visualise your line name configuration with example values"
|
||||
:multiline="true"
|
||||
:text="cwo.lineNameInfo.example"
|
||||
:fields="cwo.lineNameInfo.fields"
|
||||
></dougal-fixed-string-decoder>
|
||||
:properties="properties"
|
||||
:fields.sync="fields_"
|
||||
:values.sync="values_"
|
||||
></dougal-fixed-string-encoder>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
@@ -50,16 +46,19 @@
|
||||
|
||||
<script>
|
||||
import { deepSet } from '@/lib/utils';
|
||||
import DougalFixedStringDecoder from '@/components/decoder/fixed-string-decoder';
|
||||
import DougalFixedStringEncoder from '@/components/encoder/fixed-string-encoder';
|
||||
|
||||
export default {
|
||||
name: "DougalProjectSettingsOnlineLineNameFormat",
|
||||
|
||||
components: {
|
||||
DougalFixedStringDecoder
|
||||
DougalFixedStringEncoder
|
||||
},
|
||||
|
||||
props: {
|
||||
fields: Array,
|
||||
values: Object,
|
||||
properties: Object,
|
||||
value: Object
|
||||
},
|
||||
|
||||
@@ -68,53 +67,27 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
},
|
||||
|
||||
computed: {
|
||||
// Current working object.
|
||||
// A shortcut so we don't have to specify the full path
|
||||
// on every input control. It also makes it easier to
|
||||
// change that path if necessary. Finally, it ensures that
|
||||
// the properties being modified are always available.
|
||||
cwo: {
|
||||
|
||||
fields_: {
|
||||
get () {
|
||||
if (this.value) {
|
||||
if (!this.value?.online?.line) {
|
||||
deepSet(this.value, [ "online", "line" ], {
|
||||
lineNameInfo: {
|
||||
example: "",
|
||||
fields: {
|
||||
line: {
|
||||
length: 4,
|
||||
type: "int"
|
||||
},
|
||||
sequence: {
|
||||
length: 3,
|
||||
type: "int"
|
||||
},
|
||||
incr: {
|
||||
length: 1,
|
||||
type: "bool"
|
||||
},
|
||||
attempt: {
|
||||
length: 1,
|
||||
type: "int"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
return this.value.online.line;
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
return this.fields;
|
||||
},
|
||||
|
||||
set (v) {
|
||||
if (this.value) {
|
||||
deepSet(this.value, [ "online", "line" ], v);
|
||||
}
|
||||
this.$emit("update", structuredClone({values: this.values, fields: v}));
|
||||
}
|
||||
},
|
||||
|
||||
values_: {
|
||||
get () {
|
||||
return this.values;
|
||||
},
|
||||
set (v) {
|
||||
this.$emit("update", structuredClone({values: v, fields: this.fields}));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<v-card flat>
|
||||
<v-card-text>
|
||||
<dougal-organisations
|
||||
:self="user"
|
||||
:organisations.sync="organisations_"
|
||||
>
|
||||
<template v-slot:actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn
|
||||
color="secondary"
|
||||
@click="back"
|
||||
>Back</v-btn>
|
||||
</template>
|
||||
</dougal-organisations>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions, mapGetters } from 'vuex'
|
||||
import DougalOrganisations from '../organisations'
|
||||
|
||||
|
||||
export default {
|
||||
name: "DougalProjectSettingsOrganisations",
|
||||
|
||||
components: {
|
||||
DougalOrganisations
|
||||
},
|
||||
|
||||
props: {
|
||||
organisations: Object,
|
||||
value: Object
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
|
||||
organisations_: {
|
||||
get () {
|
||||
return this.organisations;
|
||||
},
|
||||
|
||||
set (v) {
|
||||
this.$emit("input", {
|
||||
...this.value,
|
||||
organisations: v.toJSON()
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
...mapGetters(['user', 'loading', 'serverEvent'])
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
reset () {
|
||||
},
|
||||
|
||||
save () {
|
||||
},
|
||||
|
||||
back () {
|
||||
this.$emit('close');
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
mounted () {
|
||||
this.reset();
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
256
lib/www/client/source/src/components/user-settings.vue
Normal file
256
lib/www/client/source/src/components/user-settings.vue
Normal file
@@ -0,0 +1,256 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
User {{ name }} <v-chip class="mx-3" small>{{id}}</v-chip>
|
||||
<v-chip v-if="self.id == value.id"
|
||||
small
|
||||
color="primary"
|
||||
>It's me!</v-chip>
|
||||
</v-card-title>
|
||||
<v-card-subtitle>User settings</v-card-subtitle>
|
||||
<v-card-text>
|
||||
<v-form>
|
||||
<!--
|
||||
<v-text-field
|
||||
label="User ID"
|
||||
hint="Unique user ID (read-only)"
|
||||
persistent-hint
|
||||
readonly
|
||||
disabled
|
||||
v-model="id"
|
||||
>
|
||||
</v-text-field>
|
||||
-->
|
||||
|
||||
<v-switch
|
||||
dense
|
||||
label="Active"
|
||||
:title="(self.id == value.id) ? 'You cannot make yourself inactive' : active ? 'Make this user inactive' : 'Make this user active'"
|
||||
:disabled="self.id == value.id"
|
||||
v-model="active"
|
||||
></v-switch>
|
||||
|
||||
<label class="mr-3 pt-5">Colour
|
||||
<v-menu v-model="colourMenu"
|
||||
:close-on-content-click="false"
|
||||
offset-y
|
||||
>
|
||||
<template v-slot:activator="{ on, attrs }">
|
||||
<v-btn
|
||||
:title="colour"
|
||||
dense
|
||||
small
|
||||
icon
|
||||
v-on="on"
|
||||
><v-icon :color="colour">mdi-palette</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-color-picker
|
||||
dot-size="25"
|
||||
mode="hexa"
|
||||
swatches-max-height="200"
|
||||
v-model="colour"
|
||||
></v-color-picker>
|
||||
</v-menu>
|
||||
</label>
|
||||
|
||||
<v-text-field
|
||||
v-if="showIp || ip"
|
||||
label="IP address"
|
||||
hint="IP address or subnet specification for auto-login"
|
||||
v-model="ip"
|
||||
>
|
||||
</v-text-field>
|
||||
|
||||
<v-text-field
|
||||
v-if="showHost || host"
|
||||
label="Host name"
|
||||
hint="Hostname (for auto-login)"
|
||||
v-model="host"
|
||||
>
|
||||
</v-text-field>
|
||||
|
||||
<v-text-field
|
||||
label="Name"
|
||||
hint="User name"
|
||||
v-model="name"
|
||||
>
|
||||
</v-text-field>
|
||||
|
||||
<v-text-field
|
||||
v-if="showPasswordField"
|
||||
:type="visiblePassword ? 'text' : 'password'"
|
||||
:append-icon="visiblePassword ? 'mdi-eye' : 'mdi-eye-off'"
|
||||
@click:append="visiblePassword = !visiblePassword"
|
||||
label="Password"
|
||||
hint="User password"
|
||||
v-model="password"
|
||||
>
|
||||
</v-text-field>
|
||||
|
||||
<v-text-field
|
||||
label="Email"
|
||||
hint="Email address"
|
||||
v-model="email"
|
||||
>
|
||||
</v-text-field>
|
||||
|
||||
<v-textarea
|
||||
class="mb-5"
|
||||
label="Remarks"
|
||||
hint="User description (visible to the user)"
|
||||
auto-grow
|
||||
v-model="description"
|
||||
></v-textarea>
|
||||
|
||||
<dougal-organisations
|
||||
:self="self"
|
||||
:organisations.sync="organisations"
|
||||
></dougal-organisations>
|
||||
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<slot name="actions" v-bind="{ isValid, hasErrors, errors, dirty }"></slot>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions, mapGetters } from 'vuex';
|
||||
import { User } from '@/lib/user';
|
||||
|
||||
import DougalOrganisations from './organisations'
|
||||
|
||||
export default {
|
||||
name: "DougalUserSettings",
|
||||
|
||||
components: {
|
||||
DougalOrganisations
|
||||
},
|
||||
|
||||
props: {
|
||||
value: Object,
|
||||
self: Object, // User calling the dialogue
|
||||
|
||||
// The next three props determine whether the
|
||||
// ip, host, and password fields are shown even
|
||||
// when null / empty. If non-null, those fields
|
||||
// are always shown
|
||||
showIp: { type: Boolean, default: false },
|
||||
showHost: { type: Boolean, default: false },
|
||||
showPassword: { type: Boolean, default: false },
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
colourMenu: null,
|
||||
visiblePassword: false
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
|
||||
id () { return this.value.id },
|
||||
|
||||
ip: {
|
||||
get () { return this.value.ip },
|
||||
set (v) { this.input("ip", v) }
|
||||
},
|
||||
|
||||
host: {
|
||||
get () { return this.value.host },
|
||||
set (v) { this.input("host", v) }
|
||||
},
|
||||
|
||||
name: {
|
||||
get () { return this.value.name },
|
||||
set (v) { this.input("name", v) }
|
||||
},
|
||||
|
||||
password: {
|
||||
get () { return this.value.password },
|
||||
set (v) { this.input("password", v) }
|
||||
},
|
||||
|
||||
active: {
|
||||
get () { return this.value.active },
|
||||
set (v) { this.input("active", v) }
|
||||
},
|
||||
|
||||
email: {
|
||||
get () { return this.value.email },
|
||||
set (v) { this.input("email", v) }
|
||||
},
|
||||
|
||||
colour: {
|
||||
get () { return this.value.colour },
|
||||
set (v) { this.input("colour", v) }
|
||||
},
|
||||
|
||||
description: {
|
||||
get () { return this.value.description },
|
||||
set (v) { this.input("description", v) }
|
||||
},
|
||||
|
||||
organisations: {
|
||||
get () { return this.value.organisations },
|
||||
set (v) { this.input("organisations", v) }
|
||||
},
|
||||
|
||||
errors () {
|
||||
return this.value.errors;
|
||||
},
|
||||
|
||||
hasErrors () {
|
||||
return !this.isValid;
|
||||
},
|
||||
|
||||
isValid () {
|
||||
return this.value.isValid;
|
||||
},
|
||||
|
||||
dirty () {
|
||||
return this.value?.dirty ?? false;
|
||||
},
|
||||
|
||||
showPasswordField () {
|
||||
return this.password || (this.showPassword &&
|
||||
!(this.showIp || this.ip || this.showHost || this.host));
|
||||
},
|
||||
|
||||
...mapGetters(['user', 'loading', 'serverEvent'])
|
||||
},
|
||||
|
||||
watch: {
|
||||
validationErrors () {
|
||||
this.$emit("update:errors", this.validationErrors);
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
input (k, v) {
|
||||
const user = new User(this.value);
|
||||
user[k] = v;
|
||||
this.$emit("input", user);
|
||||
},
|
||||
|
||||
reset () {
|
||||
},
|
||||
|
||||
save () {
|
||||
},
|
||||
|
||||
back () {
|
||||
this.$emit('close');
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
mounted () {
|
||||
this.reset();
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
97
lib/www/client/source/src/lib/user/User.js
Normal file
97
lib/www/client/source/src/lib/user/User.js
Normal file
@@ -0,0 +1,97 @@
|
||||
|
||||
import { User as BaseUser } from '@dougal/user';
|
||||
|
||||
class User extends BaseUser {
|
||||
|
||||
api // Instance of Vuex api method
|
||||
dirty // Whether the values have changed since last saved
|
||||
|
||||
constructor (data, client) {
|
||||
super (data);
|
||||
|
||||
if (client) {
|
||||
this.api = client;
|
||||
} else if (data instanceof User) {
|
||||
this.api = data.api;
|
||||
}
|
||||
|
||||
this.dirty = false;
|
||||
this.on("changed", () => this.dirty = true);
|
||||
}
|
||||
|
||||
static async fromAPI (api, id) {
|
||||
if (id) {
|
||||
const url = `/user/${id}`;
|
||||
const res = await api([url]);
|
||||
return new User(res, api);
|
||||
} else {
|
||||
const url = `/user`;
|
||||
const res = await api([url]);
|
||||
return res?.map( row => new User(row, api) );
|
||||
}
|
||||
}
|
||||
|
||||
/** Save this user to the server
|
||||
*
|
||||
* If this is a new user, the `api` parameter must be
|
||||
* supplied and this will result in a `POST` request.
|
||||
* For an existing user coming from the database,
|
||||
* `this.api` will be used for a `PUT` request.
|
||||
*/
|
||||
async save (api) {
|
||||
if (this.api) {
|
||||
const url = `/user/${this.id}`;
|
||||
const init = {
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
method: "PUT",
|
||||
body: this.toJSON()
|
||||
};
|
||||
const res = await this.api([url, init]);
|
||||
if (res) {
|
||||
this.dirty = false;
|
||||
return new User(res, this.api);
|
||||
} else {
|
||||
// Something has gone wrong
|
||||
console.log("Something has gone wrong (PUT)");
|
||||
}
|
||||
} else if (api) {
|
||||
const url = `/user`;
|
||||
const init = {
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
method: "POST",
|
||||
body: this.toJSON()
|
||||
}
|
||||
const res = await api([url, init]);
|
||||
if (res) {
|
||||
return new User(res, api);
|
||||
} else {
|
||||
// Something has gone wrong
|
||||
console.log("Something has gone wrong (POST)");
|
||||
}
|
||||
} else {
|
||||
throw new Error("Don't know how to save this user");
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete this user from the server
|
||||
*/
|
||||
async remove () {
|
||||
const url = `/user/${this.id}`;
|
||||
const init = {
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
method: "PUT",
|
||||
body: this.toJSON()
|
||||
};
|
||||
const res = await this.api([url, init]);
|
||||
console.log("remove RES", res);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default User;
|
||||
5
lib/www/client/source/src/lib/user/index.js
Normal file
5
lib/www/client/source/src/lib/user/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import User from './User'
|
||||
|
||||
export {
|
||||
User
|
||||
}
|
||||
@@ -47,9 +47,11 @@ new Vue({
|
||||
methods: {
|
||||
|
||||
markdown (value) {
|
||||
return typeof value == "string"
|
||||
? marked(value)
|
||||
: value;
|
||||
return markdown(value);
|
||||
},
|
||||
|
||||
markdownInline (value) {
|
||||
return markdownInline(value);
|
||||
},
|
||||
|
||||
showSnack(text, colour = "primary") {
|
||||
|
||||
35
lib/www/client/source/src/mixins/access.js
Normal file
35
lib/www/client/source/src/mixins/access.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { mapGetters } from 'vuex';
|
||||
import { Organisations } from '@dougal/organisations';
|
||||
|
||||
export default {
|
||||
name: "AccessMixin",
|
||||
|
||||
computed: {
|
||||
...mapGetters(['user', 'projectConfiguration'])
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
access (operation, organisations) {
|
||||
if (this.user) {
|
||||
if (!organisations) organisations = this.projectConfiguration?.organisations;
|
||||
if (!organisations instanceof Organisations) {
|
||||
organisations = new Organisations(organisations);
|
||||
}
|
||||
return this.user.canDo(operation, organisations);
|
||||
}
|
||||
},
|
||||
|
||||
readaccess (item) {
|
||||
return this.access('read', item);
|
||||
},
|
||||
|
||||
writeaccess (item) {
|
||||
return this.access('write', item);
|
||||
},
|
||||
|
||||
adminaccess (item) {
|
||||
return this.access('edit', item);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import QC from '../views/QC.vue'
|
||||
import Graphs from '../views/Graphs.vue'
|
||||
import Map from '../views/Map.vue'
|
||||
import ProjectSettings from '../views/ProjectSettings.vue'
|
||||
import Users from '../views/Users.vue'
|
||||
import DougalAppBarExtensionProject from '../components/app-bar-extension-project'
|
||||
import DougalAppBarExtensionProjectList from '../components/app-bar-extension-project-list'
|
||||
|
||||
@@ -49,6 +50,19 @@ Vue.use(VueRouter)
|
||||
name: "equipment",
|
||||
component: () => import(/* webpackChunkName: "about" */ '../views/Equipment.vue')
|
||||
},
|
||||
{
|
||||
pathToRegexpOptions: { strict: true },
|
||||
path: "/users",
|
||||
redirect: "/users/"
|
||||
},
|
||||
{
|
||||
pathToRegexpOptions: { strict: true },
|
||||
name: "Users",
|
||||
path: "/users/",
|
||||
component: Users,
|
||||
meta: {
|
||||
}
|
||||
},
|
||||
{
|
||||
pathToRegexpOptions: { strict: true },
|
||||
path: "/login",
|
||||
@@ -103,7 +117,9 @@ Vue.use(VueRouter)
|
||||
{ text: "Projects", href: "/projects" },
|
||||
{
|
||||
text: (ctx) => ctx.$store.state.project.projectName || "…",
|
||||
href: (ctx) => `/projects/${ctx.$store.state.project.projectId || ctx.$route.params.project || ""}/`
|
||||
href: (ctx) => `/projects/${ctx.$store.state.project.projectId || ctx.$route.params.project || ""}/`,
|
||||
title: (ctx) => Object.entries(ctx.$store.getters.projectConfiguration?.organisations ?? {}).map( ([org, ops]) => `* ${org}: ${Object.entries(ops).filter( ([k, v]) => v ).map( ([k, v]) => k ).join(", ")}`).join("\n"),
|
||||
organisations: (ctx) => ctx.$store.getters.projectConfiguration?.organisations ?? {}
|
||||
}
|
||||
],
|
||||
appBarExtension: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
async function api ({state, getters, commit, dispatch}, [resource, init = {}, cb]) {
|
||||
async function api ({state, getters, commit, dispatch}, [resource, init = {}, cb, opts = {}]) {
|
||||
try {
|
||||
commit("queueRequest");
|
||||
if (init && init.hasOwnProperty("body")) {
|
||||
@@ -49,7 +49,9 @@ async function api ({state, getters, commit, dispatch}, [resource, init = {}, cb
|
||||
message = body.message;
|
||||
}
|
||||
}
|
||||
await dispatch('showSnack', [message, "warning"]);
|
||||
if (!opts?.silent) {
|
||||
await dispatch('showSnack', [message, "warning"]);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err && err.name == "AbortError") return;
|
||||
|
||||
@@ -13,7 +13,7 @@ async function refreshProjects ({commit, dispatch, state, rootState}) {
|
||||
const init = {
|
||||
signal: state.loading.signal
|
||||
};
|
||||
const res = await dispatch('api', [url, init]);
|
||||
const res = await dispatch('api', [url, init, null, {silent:true}]);
|
||||
|
||||
if (res) {
|
||||
commit('setProjects', res);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import jwt_decode from 'jwt-decode';
|
||||
import { User } from '@/lib/user';
|
||||
|
||||
async function login ({commit, dispatch}, loginRequest) {
|
||||
const url = "/login";
|
||||
@@ -34,13 +35,13 @@ function cookieChanged (cookie) {
|
||||
return browserCookie != cookie;
|
||||
}
|
||||
|
||||
function setCredentials ({state, commit, getters, dispatch}, {force, token} = {}) {
|
||||
function setCredentials ({state, commit, getters, dispatch, rootState}, {force, token} = {}) {
|
||||
if (token || force || cookieChanged(state.cookie)) {
|
||||
try {
|
||||
const cookie = browserCookie();
|
||||
const decoded = (token ?? cookie) ? jwt_decode(token ?? cookie.split("=")[1]) : null;
|
||||
commit('setCookie', (cookie ?? (token && ("JWT="+token))) || undefined);
|
||||
commit('setUser', decoded);
|
||||
commit('setUser', decoded ? new User(decoded, rootState.api.api) : null);
|
||||
} catch (err) {
|
||||
if (err.name == "InvalidTokenError") {
|
||||
console.warn("Failed to decode", browserCookie());
|
||||
@@ -56,11 +57,11 @@ function setCredentials ({state, commit, getters, dispatch}, {force, token} = {}
|
||||
* Save user preferences to localStorage and store.
|
||||
*
|
||||
* User preferences are identified by a key that gets
|
||||
* prefixed with the user name and role. The value can
|
||||
* prefixed with the user ID. The value can
|
||||
* be anything that JSON.stringify can parse.
|
||||
*/
|
||||
function saveUserPreference ({state, commit}, [key, value]) {
|
||||
const k = `${state.user?.name}.${state.user?.role}.${key}`;
|
||||
const k = `${state.user?.id}.${key}`;
|
||||
|
||||
if (value !== undefined) {
|
||||
localStorage.setItem(k, JSON.stringify(value));
|
||||
@@ -79,7 +80,7 @@ function saveUserPreference ({state, commit}, [key, value]) {
|
||||
|
||||
async function loadUserPreferences ({state, commit}) {
|
||||
// Get all keys which are of interest to us
|
||||
const prefix = `${state.user?.name}.${state.user?.role}`;
|
||||
const prefix = `${state.user?.id}`;
|
||||
const keys = Object.keys(localStorage).filter( k => k.startsWith(prefix) );
|
||||
|
||||
// Build the preferences object
|
||||
|
||||
@@ -9,16 +9,8 @@ function jwt (state) {
|
||||
}
|
||||
}
|
||||
|
||||
function writeaccess (state) {
|
||||
return state.user && ["user", "admin"].includes(state.user.role);
|
||||
}
|
||||
|
||||
function adminaccess (state) {
|
||||
return state.user && state.user.role == "admin";
|
||||
}
|
||||
|
||||
function preferences (state) {
|
||||
return state.preferences;
|
||||
}
|
||||
|
||||
export default { user, jwt, writeaccess, adminaccess, preferences };
|
||||
export default { user, jwt, preferences };
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
@input="closeDialog"
|
||||
>
|
||||
<template v-slot:activator="{ on, attrs }">
|
||||
<v-btn v-if="writeaccess"
|
||||
<v-btn v-if="writeaccess()"
|
||||
small
|
||||
color="primary"
|
||||
v-bind="attrs"
|
||||
@@ -182,7 +182,7 @@
|
||||
</v-container>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-btn v-if="writeaccess"
|
||||
<v-btn v-if="writeaccess()"
|
||||
small
|
||||
text
|
||||
color="primary"
|
||||
@@ -205,7 +205,7 @@
|
||||
</v-btn>
|
||||
</v-btn-toggle>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn v-if="writeaccess"
|
||||
<v-btn v-if="writeaccess()"
|
||||
small
|
||||
dark
|
||||
color="red"
|
||||
@@ -247,7 +247,7 @@
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn v-if="writeaccess"
|
||||
<v-btn v-if="writeaccess()"
|
||||
small
|
||||
dark
|
||||
color="red"
|
||||
@@ -303,10 +303,15 @@
|
||||
|
||||
<script>
|
||||
import { mapActions, mapGetters } from 'vuex';
|
||||
import AccessMixin from '@/mixins/access';
|
||||
|
||||
export default {
|
||||
name: "Equipment",
|
||||
|
||||
mixins: [
|
||||
AccessMixin
|
||||
],
|
||||
|
||||
data () {
|
||||
return {
|
||||
latest: [],
|
||||
@@ -395,7 +400,7 @@ export default {
|
||||
return null;
|
||||
},
|
||||
|
||||
...mapGetters(['user', 'writeaccess', 'loading', 'serverEvent'])
|
||||
...mapGetters(['user', 'loading', 'serverEvent'])
|
||||
|
||||
},
|
||||
|
||||
|
||||
@@ -240,7 +240,7 @@ export default {
|
||||
return this.sequences[0]?.sequence;
|
||||
},
|
||||
|
||||
...mapGetters(['user', 'preferences', 'writeaccess', 'loading', 'serverEvent'])
|
||||
...mapGetters(['user', 'preferences', 'loading', 'serverEvent'])
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
|
||||
<v-menu v-if="writeaccess"
|
||||
<v-menu v-if="writeaccess()"
|
||||
v-model="contextMenuShow"
|
||||
:position-x="contextMenuX"
|
||||
:position-y="contextMenuY"
|
||||
@@ -164,7 +164,7 @@
|
||||
</v-text-field>
|
||||
<div v-else>
|
||||
<span v-html="$options.filters.markdownInline(item.remarks)"></span>
|
||||
<v-btn v-if="writeaccess && edit === null"
|
||||
<v-btn v-if="writeaccess() && edit === null"
|
||||
icon
|
||||
small
|
||||
title="Edit"
|
||||
@@ -196,6 +196,7 @@
|
||||
<script>
|
||||
import { mapActions, mapGetters } from 'vuex';
|
||||
import DougalLineStatus from '@/components/line-status';
|
||||
import AccessMixin from '@/mixins/access';
|
||||
|
||||
export default {
|
||||
name: "LineList",
|
||||
@@ -204,6 +205,10 @@ export default {
|
||||
DougalLineStatus
|
||||
},
|
||||
|
||||
mixins: [
|
||||
AccessMixin
|
||||
],
|
||||
|
||||
data () {
|
||||
return {
|
||||
headers: [
|
||||
@@ -281,7 +286,7 @@ export default {
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters(['user', 'writeaccess', 'linesLoading', 'lines', 'sequences', 'plannedSequences'])
|
||||
...mapGetters(['user', 'linesLoading', 'lines', 'sequences', 'plannedSequences'])
|
||||
},
|
||||
|
||||
watch: {
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
>mdi-format-list-numbered</v-icon>
|
||||
</a>
|
||||
|
||||
<dougal-event-edit v-if="writeaccess"
|
||||
<dougal-event-edit v-if="$parent.writeaccess()"
|
||||
v-model="eventDialog"
|
||||
v-bind="editedEvent"
|
||||
:available-labels="userLabels"
|
||||
@@ -54,7 +54,7 @@
|
||||
>
|
||||
</dougal-event-edit>
|
||||
|
||||
<dougal-event-edit-labels v-if="writeaccess"
|
||||
<dougal-event-edit-labels v-if="$parent.writeaccess()"
|
||||
v-model="eventLabelsDialog"
|
||||
:labels="userLabels"
|
||||
:selected="contextMenuItem ? contextMenuItem.labels||[] : []"
|
||||
@@ -171,7 +171,7 @@
|
||||
<v-card-text>
|
||||
|
||||
<!-- BEGIN Context menu for log entries -->
|
||||
<v-menu v-if="writeaccess"
|
||||
<v-menu v-if="$parent.writeaccess()"
|
||||
v-model="contextMenuShow"
|
||||
:position-x="contextMenuX"
|
||||
:position-y="contextMenuY"
|
||||
@@ -325,7 +325,7 @@
|
||||
@click="labelSearch=label"
|
||||
>{{label}}</v-chip>
|
||||
</span>
|
||||
<dougal-event-edit-history v-if="entry.has_edits && writeaccess"
|
||||
<dougal-event-edit-history v-if="entry.has_edits && $parent.writeaccess()"
|
||||
:id="entry.id"
|
||||
:disabled="eventsLoading"
|
||||
:labels="labels"
|
||||
@@ -541,7 +541,7 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
...mapGetters(['user', 'writeaccess', 'eventsLoading', 'online', 'sequence', 'line', 'point', 'position', 'timestamp', 'lineName', 'events', 'labels', 'userLabels', 'projectConfiguration']),
|
||||
...mapGetters(['user', 'eventsLoading', 'online', 'sequence', 'line', 'point', 'position', 'timestamp', 'lineName', 'events', 'labels', 'userLabels', 'projectConfiguration']),
|
||||
...mapState({projectSchema: state => state.project.projectSchema})
|
||||
|
||||
},
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
|
||||
<v-menu v-if="writeaccess"
|
||||
<v-menu v-if="writeaccess()"
|
||||
v-model="contextMenuShow"
|
||||
:position-x="contextMenuX"
|
||||
:position-y="contextMenuY"
|
||||
@@ -68,7 +68,7 @@
|
||||
<v-card class="mb-5" flat>
|
||||
<v-card-title class="text-overline">
|
||||
Comments
|
||||
<template v-if="writeaccess">
|
||||
<template v-if="writeaccess()">
|
||||
<v-btn v-if="!editRemarks"
|
||||
class="ml-3"
|
||||
small
|
||||
@@ -131,7 +131,7 @@
|
||||
</template>
|
||||
|
||||
<template v-slot:item.sequence="{item, value}">
|
||||
<v-edit-dialog v-if="writeaccess"
|
||||
<v-edit-dialog v-if="writeaccess()"
|
||||
large
|
||||
@open="editItem(item, 'sequence')"
|
||||
@save="edit = null"
|
||||
@@ -156,7 +156,7 @@
|
||||
</template>
|
||||
|
||||
<template v-slot:item.name="{item, value}">
|
||||
<v-edit-dialog v-if="writeaccess"
|
||||
<v-edit-dialog v-if="writeaccess()"
|
||||
large
|
||||
@open="editItem(item, 'name')"
|
||||
@save="edit = null"
|
||||
@@ -175,7 +175,7 @@
|
||||
</template>
|
||||
|
||||
<template v-slot:item.fsp="{item, value}">
|
||||
<v-edit-dialog v-if="writeaccess"
|
||||
<v-edit-dialog v-if="writeaccess()"
|
||||
large
|
||||
@open="editItem(item, 'fsp')"
|
||||
@save="edit = null"
|
||||
@@ -195,7 +195,7 @@
|
||||
</template>
|
||||
|
||||
<template v-slot:item.lsp="{item, value}">
|
||||
<v-edit-dialog v-if="writeaccess"
|
||||
<v-edit-dialog v-if="writeaccess()"
|
||||
large
|
||||
@open="editItem(item, 'lsp')"
|
||||
@save="edit = null"
|
||||
@@ -215,7 +215,7 @@
|
||||
</template>
|
||||
|
||||
<template v-slot:item.ts0="{item, value}">
|
||||
<v-edit-dialog v-if="writeaccess"
|
||||
<v-edit-dialog v-if="writeaccess()"
|
||||
large
|
||||
@open="editItem(item, 'ts0', item.ts0.toISOString())"
|
||||
@save="edit = null"
|
||||
@@ -235,7 +235,7 @@
|
||||
</template>
|
||||
|
||||
<template v-slot:item.ts1="{item, value}">
|
||||
<v-edit-dialog v-if="writeaccess"
|
||||
<v-edit-dialog v-if="writeaccess()"
|
||||
large
|
||||
@open="editItem(item, 'ts1', item.ts1.toISOString())"
|
||||
@save="edit = null"
|
||||
@@ -263,7 +263,7 @@
|
||||
</template>
|
||||
|
||||
<template v-slot:item.remarks="{item}">
|
||||
<v-text-field v-if="writeaccess && edit && edit.sequence == item.sequence && edit.key == 'remarks'"
|
||||
<v-text-field v-if="writeaccess() && edit && edit.sequence == item.sequence && edit.key == 'remarks'"
|
||||
type="text"
|
||||
v-model="edit.value"
|
||||
prepend-icon="mdi-restore"
|
||||
@@ -275,7 +275,7 @@
|
||||
</v-text-field>
|
||||
<div v-else>
|
||||
<span v-html="$options.filters.markdownInline(item.remarks)"></span>
|
||||
<v-btn v-if="edit === null && writeaccess"
|
||||
<v-btn v-if="edit === null && writeaccess()"
|
||||
icon
|
||||
small
|
||||
title="Edit"
|
||||
@@ -289,7 +289,7 @@
|
||||
</template>
|
||||
|
||||
<template v-slot:item.speed="{item}">
|
||||
<v-edit-dialog v-if="writeaccess"
|
||||
<v-edit-dialog v-if="writeaccess()"
|
||||
large
|
||||
@open="editItem(item, 'speed', knots(item).toFixed(1))"
|
||||
@save="edit = null"
|
||||
@@ -311,7 +311,7 @@
|
||||
</template>
|
||||
|
||||
<template v-slot:item.lag="{item}">
|
||||
<v-edit-dialog v-if="writeaccess"
|
||||
<v-edit-dialog v-if="writeaccess()"
|
||||
large
|
||||
@open="editItem(item, 'lagAfter', Math.round(lagAfter(item)/(60*1000)))"
|
||||
@save="edit = null"
|
||||
@@ -344,6 +344,7 @@
|
||||
<script>
|
||||
import suncalc from 'suncalc';
|
||||
import { mapActions, mapGetters } from 'vuex';
|
||||
import AccessMixin from '@/mixins/access';
|
||||
|
||||
export default {
|
||||
name: "Plan",
|
||||
@@ -351,6 +352,10 @@ export default {
|
||||
components: {
|
||||
},
|
||||
|
||||
mixins: [
|
||||
AccessMixin
|
||||
],
|
||||
|
||||
data () {
|
||||
return {
|
||||
headers: [
|
||||
@@ -557,7 +562,7 @@ export default {
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters(['user', 'writeaccess', 'plannedSequencesLoading', 'plannedSequences', 'planRemarks'])
|
||||
...mapGetters(['user', 'plannedSequencesLoading', 'plannedSequences', 'planRemarks'])
|
||||
},
|
||||
|
||||
watch: {
|
||||
|
||||
@@ -20,9 +20,15 @@
|
||||
|
||||
<script>
|
||||
import { mapActions, mapGetters } from 'vuex'
|
||||
import AccessMixin from '@/mixins/access';
|
||||
|
||||
export default {
|
||||
name: 'Project',
|
||||
|
||||
mixins: [
|
||||
AccessMixin
|
||||
],
|
||||
|
||||
components: {
|
||||
},
|
||||
data () {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
>mdi-archive-outline</v-icon>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a :href="`/projects/${item.pid}`">{{value}}</a>
|
||||
<a :href="`/projects/${item.pid}`" :title="title(item)">{{value}}</a>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
|
||||
</v-data-table>
|
||||
|
||||
<v-menu v-if="adminaccess"
|
||||
<v-menu v-if="adminaccess(contextMenuItem)"
|
||||
v-model="contextMenuShow"
|
||||
:position-x="contextMenuX"
|
||||
:position-y="contextMenuY"
|
||||
@@ -106,6 +106,7 @@ td p:last-of-type {
|
||||
<script>
|
||||
import { mapActions, mapGetters } from 'vuex';
|
||||
import DougalProjectSettingsNameIdRootpath from '@/components/project-settings/name-id-rootpath'
|
||||
import AccessMixin from '@/mixins/access';
|
||||
|
||||
export default {
|
||||
name: "ProjectList",
|
||||
@@ -114,6 +115,10 @@ export default {
|
||||
DougalProjectSettingsNameIdRootpath
|
||||
},
|
||||
|
||||
mixins: [
|
||||
AccessMixin
|
||||
],
|
||||
|
||||
data () {
|
||||
return {
|
||||
headers: [
|
||||
@@ -179,7 +184,7 @@ export default {
|
||||
: this.items.filter(i => !i.archived);
|
||||
},
|
||||
|
||||
...mapGetters(['loading', 'serverEvent', 'adminaccess', 'projects'])
|
||||
...mapGetters(['loading', 'serverEvent', 'projects'])
|
||||
},
|
||||
|
||||
watch: {
|
||||
@@ -205,6 +210,15 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
title (item) {
|
||||
if (item.organisations) {
|
||||
return "Access:\n" + Object.entries(item.organisations).map( org =>
|
||||
`• ${org[0]} (${Object.entries(org[1]).filter( access => access[1] ).map( access => access[0] ).join(", ")})`
|
||||
).join("\n")
|
||||
}
|
||||
return "";
|
||||
},
|
||||
|
||||
async load () {
|
||||
await this.list();
|
||||
const promises = [];
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
:is="activeComponent"
|
||||
v-bind="activeValues"
|
||||
v-model.sync="configuration"
|
||||
@update="activeUpdateHandler"
|
||||
@close="deselect"
|
||||
></component>
|
||||
</v-col>
|
||||
@@ -227,6 +228,7 @@ import { deepSet } from '@/lib/utils';
|
||||
import DougalJsonBuilder from '@/components/json-builder/json-builder';
|
||||
|
||||
import DougalProjectSettingsNameId from '@/components/project-settings/name-id';
|
||||
import DougalProjectSettingsOrganisations from '@/components/project-settings/organisations';
|
||||
import DougalProjectSettingsGroups from '@/components/project-settings/groups';
|
||||
import DougalProjectSettingsGeodetics from '@/components/project-settings/geodetics';
|
||||
import DougalProjectSettingsBinning from '@/components/project-settings/binning';
|
||||
@@ -247,6 +249,7 @@ import DougalProjectSettingsNotImplemented from '@/components/project-settings/n
|
||||
|
||||
const components = {
|
||||
name_id: DougalProjectSettingsNameId,
|
||||
organisations: DougalProjectSettingsOrganisations,
|
||||
groups: DougalProjectSettingsGroups,
|
||||
geodetics: DougalProjectSettingsGeodetics,
|
||||
binning: DougalProjectSettingsBinning,
|
||||
@@ -279,6 +282,7 @@ export default {
|
||||
data () {
|
||||
return {
|
||||
configuration: null,
|
||||
settings: null,
|
||||
active: [],
|
||||
open: [],
|
||||
files: [],
|
||||
@@ -302,6 +306,13 @@ export default {
|
||||
name: obj?.name
|
||||
})
|
||||
},
|
||||
{
|
||||
id: "organisations",
|
||||
name: "Organisations",
|
||||
values: (obj) => ({
|
||||
organisations: obj?.organisations
|
||||
})
|
||||
},
|
||||
{
|
||||
id: "groups",
|
||||
name: "Groups",
|
||||
@@ -400,7 +411,8 @@ export default {
|
||||
rootPath: obj.rootPath,
|
||||
globs: obj.final.p111.globs,
|
||||
paths: obj.final.p111.paths,
|
||||
pattern: obj.final.p111.pattern
|
||||
pattern: obj.final.p111.pattern,
|
||||
lineNameInfo: obj.final?.p111?.lineNameInfo
|
||||
})
|
||||
},
|
||||
{
|
||||
@@ -419,8 +431,17 @@ export default {
|
||||
id: "line_name_format",
|
||||
name: "Line name format",
|
||||
values: (obj) => ({
|
||||
lineNameInfo: obj?.online?.line?.lineNameInfo
|
||||
})
|
||||
fields: this.makeSection(obj, "online.line.lineNameBuilder.fields", []),
|
||||
values: this.makeSection(obj, "online.line.lineNameBuilder.values",
|
||||
Object.fromEntries(
|
||||
Object.keys(this.settings.lineNameBuilder.properties ?? {}).map( k => [k, undefined] ))),
|
||||
properties: this.settings.lineNameBuilder.properties ?? {}
|
||||
}),
|
||||
update: (obj) => {
|
||||
const configuration = structuredClone(this.configuration);
|
||||
deepSet(configuration, ["online", "line", "lineNameBuilder"], obj);
|
||||
this.configuration = configuration;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "planner_settings",
|
||||
@@ -517,6 +538,12 @@ export default {
|
||||
this.activeItem.values(this.configuration);
|
||||
},
|
||||
|
||||
activeUpdateHandler () {
|
||||
return this.activeItem?.update ?? ((obj) => {
|
||||
console.warn("Unhandled update event on", this.activeItem?.id, obj);
|
||||
})
|
||||
},
|
||||
|
||||
surveyState: {
|
||||
get () {
|
||||
return !this.configuration?.archived;
|
||||
@@ -553,11 +580,40 @@ export default {
|
||||
return messages;
|
||||
},
|
||||
|
||||
...mapGetters(['user', 'writeaccess', 'loading', 'serverEvent'])
|
||||
...mapGetters(['user', 'loading', 'serverEvent'])
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
makeSection (obj, path, defaultVaue) {
|
||||
|
||||
function reduced (obj = {}, path = []) {
|
||||
return path.reduce( (acc, cur) =>
|
||||
{
|
||||
if (!(cur in acc)) {
|
||||
acc[cur] = {} ;
|
||||
};
|
||||
return acc[cur]
|
||||
}, obj);
|
||||
}
|
||||
|
||||
if (!obj) {
|
||||
obj = {};
|
||||
}
|
||||
|
||||
if (typeof path == "string") {
|
||||
path = path.split(".");
|
||||
}
|
||||
|
||||
let value = reduced(obj, path);
|
||||
|
||||
const key = path.pop();
|
||||
if (!Object.keys(value ?? {}).length && defaultVaue !== undefined) {
|
||||
reduced(obj, path)[key] = defaultVaue;
|
||||
}
|
||||
return reduced(obj, path)[key];
|
||||
},
|
||||
|
||||
async getConfiguration () {
|
||||
this.configuration = null;
|
||||
const url = `/project/${this.$route.params.project}/configuration`;
|
||||
@@ -570,6 +626,21 @@ export default {
|
||||
this.dirty = false;
|
||||
},
|
||||
|
||||
async getSettings () {
|
||||
this.settings = null;
|
||||
let url = `/project/${this.$route.params.project}/linename/properties`;
|
||||
const init = {
|
||||
headers: {
|
||||
"If-None-Match": "" // Ensure we get a fresh response
|
||||
}
|
||||
};
|
||||
this.settings = {
|
||||
lineNameBuilder: {
|
||||
properties: await this.api([url, init])
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
makeTree (obj, id=0) {
|
||||
const isObject = typeof obj === "object" && !Array.isArray(obj) && obj !== null;
|
||||
return isObject
|
||||
@@ -680,6 +751,7 @@ export default {
|
||||
},
|
||||
|
||||
async mounted () {
|
||||
this.getSettings();
|
||||
this.getConfiguration();
|
||||
},
|
||||
|
||||
|
||||
@@ -82,13 +82,13 @@
|
||||
small
|
||||
:color="labels[label] && labels[label].view.colour"
|
||||
:title="labels[label] && labels[label].view.description"
|
||||
:close="writeaccess && label == 'QCAccepted'"
|
||||
:close="writeaccess() && label == 'QCAccepted'"
|
||||
@click:close="unaccept(item)"
|
||||
>
|
||||
{{label}}
|
||||
</v-chip>
|
||||
|
||||
<dougal-qc-acceptance v-if="writeaccess"
|
||||
<dougal-qc-acceptance v-if="writeaccess()"
|
||||
:item="item"
|
||||
@accept="accept"
|
||||
@unaccept="unaccept"
|
||||
@@ -105,7 +105,7 @@
|
||||
>
|
||||
</v-chip>
|
||||
|
||||
<dougal-qc-acceptance v-if="writeaccess"
|
||||
<dougal-qc-acceptance v-if="writeaccess()"
|
||||
:item="item"
|
||||
@accept="accept"
|
||||
@unaccept="unaccept"
|
||||
@@ -113,7 +113,7 @@
|
||||
|
||||
</div>
|
||||
<div class="text--secondary" v-else>
|
||||
<dougal-qc-acceptance v-if="writeaccess"
|
||||
<dougal-qc-acceptance v-if="writeaccess()"
|
||||
:item="item"
|
||||
@accept="accept"
|
||||
@unaccept="unaccept"
|
||||
@@ -140,6 +140,7 @@
|
||||
import { mapActions, mapGetters } from 'vuex';
|
||||
import { withParentProps } from '@/lib/utils';
|
||||
import DougalQcAcceptance from '@/components/qc-acceptance';
|
||||
import AccessMixin from '@/mixins/access';
|
||||
|
||||
export default {
|
||||
name: "QC",
|
||||
@@ -148,6 +149,10 @@ export default {
|
||||
DougalQcAcceptance
|
||||
},
|
||||
|
||||
mixins: [
|
||||
AccessMixin
|
||||
],
|
||||
|
||||
data () {
|
||||
return {
|
||||
updatedOn: null,
|
||||
@@ -227,7 +232,7 @@ export default {
|
||||
return values;
|
||||
},
|
||||
|
||||
...mapGetters(['writeaccess', 'loading'])
|
||||
...mapGetters(['loading'])
|
||||
},
|
||||
|
||||
watch: {
|
||||
|
||||
@@ -28,10 +28,10 @@
|
||||
offset-y
|
||||
>
|
||||
<v-list dense v-if="contextMenuItem">
|
||||
<v-list-item @click="addToPlan(false); contextMenuShow=false" v-if="writeaccess">
|
||||
<v-list-item @click="addToPlan(false); contextMenuShow=false" v-if="writeaccess()">
|
||||
<v-list-item-title>Reshoot</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click="addToPlan(true); contextMenuShow=false" v-if="writeaccess">
|
||||
<v-list-item @click="addToPlan(true); contextMenuShow=false" v-if="writeaccess()">
|
||||
<v-list-item-title>Reshoot with overlap</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
@@ -85,7 +85,7 @@
|
||||
|
||||
<!-- Item is not in queue -->
|
||||
<v-list-item
|
||||
v-if="writeaccess && !contextMenuItemInTransferQueue"
|
||||
v-if="writeaccess() && !contextMenuItemInTransferQueue"
|
||||
@click="addToTransferQueue(); contextMenuShow=false"
|
||||
>
|
||||
<v-list-item-content>
|
||||
@@ -97,7 +97,7 @@
|
||||
</v-list-item>
|
||||
<!-- Item queued, not yet sent -->
|
||||
<v-list-item two-line
|
||||
v-else-if="writeaccess && contextMenuItemInTransferQueue.status == 'queued'"
|
||||
v-else-if="writeaccess() && contextMenuItemInTransferQueue.status == 'queued'"
|
||||
@click="removeFromTransferQueue(); contextMenuShow=false"
|
||||
>
|
||||
<v-list-item-content>
|
||||
@@ -112,7 +112,7 @@
|
||||
</v-list-item>
|
||||
<!-- Item already sent -->
|
||||
<v-list-item two-line
|
||||
v-else-if="writeaccess && contextMenuItemInTransferQueue.status == 'sent'"
|
||||
v-else-if="writeaccess() && contextMenuItemInTransferQueue.status == 'sent'"
|
||||
@click="addToTransferQueue(); contextMenuShow=false"
|
||||
>
|
||||
<v-list-item-content>
|
||||
@@ -127,7 +127,7 @@
|
||||
</v-list-item>
|
||||
<!-- Item sending was cancelled -->
|
||||
<v-list-item two-line
|
||||
v-else-if="writeaccess && contextMenuItemInTransferQueue.status == 'cancelled'"
|
||||
v-else-if="writeaccess() && contextMenuItemInTransferQueue.status == 'cancelled'"
|
||||
@click="addToTransferQueue(); contextMenuShow=false"
|
||||
>
|
||||
<v-list-item-content>
|
||||
@@ -170,7 +170,7 @@
|
||||
<v-card outlined class="flex-grow-1">
|
||||
<v-card-title>
|
||||
Acquisition remarks
|
||||
<template v-if="writeaccess">
|
||||
<template v-if="writeaccess()">
|
||||
<template v-if="edit && edit.sequence == item.sequence && edit.key == 'remarks'">
|
||||
<v-btn
|
||||
class="ml-3"
|
||||
@@ -222,7 +222,7 @@
|
||||
<v-card outlined class="flex-grow-1" v-if="item.remarks_final !== null">
|
||||
<v-card-title>
|
||||
Processing remarks
|
||||
<template v-if="writeaccess">
|
||||
<template v-if="writeaccess()">
|
||||
<template v-if="edit && edit.sequence == item.sequence && edit.key == 'remarks_final'">
|
||||
<v-btn
|
||||
class="ml-3"
|
||||
@@ -492,10 +492,15 @@ tr :nth-child(5), tr :nth-child(8), tr :nth-child(11), tr :nth-child(14) {
|
||||
import { mapActions, mapGetters } from 'vuex';
|
||||
import { basename } from 'path';
|
||||
import throttle from '@/lib/throttle';
|
||||
import AccessMixin from '@/mixins/access';
|
||||
|
||||
export default {
|
||||
name: "SequenceList",
|
||||
|
||||
mixins: [
|
||||
AccessMixin
|
||||
],
|
||||
|
||||
data () {
|
||||
return {
|
||||
headers: [
|
||||
@@ -616,7 +621,7 @@ export default {
|
||||
return this.queuedItems.find(i => i.payload.sequence == this.contextMenuItem.sequence);
|
||||
},
|
||||
|
||||
...mapGetters(['user', 'writeaccess', 'sequencesLoading', 'sequences'])
|
||||
...mapGetters(['user', 'sequencesLoading', 'sequences'])
|
||||
},
|
||||
|
||||
watch: {
|
||||
@@ -712,7 +717,11 @@ export default {
|
||||
line: this.contextMenuItem.line,
|
||||
fsp: sp0,
|
||||
lsp: sp1,
|
||||
remarks: `Reshoot of sequence ${this.contextMenuItem.sequence}.`
|
||||
remarks: `Reshoot of sequence ${this.contextMenuItem.sequence}.`,
|
||||
meta: {
|
||||
is_reshoot: true,
|
||||
original_sequence: this.contextMenuItem.sequence
|
||||
}
|
||||
}
|
||||
console.log("Plan", payload);
|
||||
const url = `/project/${this.$route.params.project}/plan`;
|
||||
|
||||
451
lib/www/client/source/src/views/Users.vue
Normal file
451
lib/www/client/source/src/views/Users.vue
Normal file
@@ -0,0 +1,451 @@
|
||||
<template>
|
||||
<v-container fluid>
|
||||
|
||||
<v-overlay :value="loading && !users" absolute>
|
||||
<v-progress-circular
|
||||
indeterminate
|
||||
size="64"
|
||||
></v-progress-circular>
|
||||
</v-overlay>
|
||||
|
||||
<v-overlay :value="!users && !loading" absolute opacity="0.8">
|
||||
<v-row justify="center">
|
||||
<v-alert
|
||||
type="error"
|
||||
>
|
||||
The configuration could not be loaded.
|
||||
</v-alert>
|
||||
</v-row>
|
||||
<v-row justify="center">
|
||||
<v-btn color="primary" @click="getUsers">Retry</v-btn>
|
||||
</v-row>
|
||||
</v-overlay>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="4" max-height="100%">
|
||||
<v-toolbar
|
||||
dense
|
||||
flat
|
||||
>
|
||||
<v-toolbar-title>
|
||||
User management
|
||||
</v-toolbar-title>
|
||||
<v-btn v-if="!showIP && !showHost"
|
||||
class="ml-3"
|
||||
small
|
||||
icon
|
||||
title="Show password field (allows changing a user's password)"
|
||||
@click="showPassword = !showPassword"
|
||||
>
|
||||
<v-icon small>{{ showPassword ? "mdi-eye" : "mdi-eye-off" }}</v-icon>
|
||||
</v-btn>
|
||||
<v-spacer/>
|
||||
<template v-if="isDirty">
|
||||
<v-icon color="warning" @click="reset" title="Discard changes">mdi-undo</v-icon>
|
||||
<v-spacer/>
|
||||
<v-icon left color="primary" @click="saveToFile" title="Save changes to file">mdi-content-save-outline</v-icon>
|
||||
<v-icon color="primary" @click="upload" title="Upload changes to server">mdi-cloud-upload</v-icon>
|
||||
</template>
|
||||
</v-toolbar>
|
||||
|
||||
<!--
|
||||
<v-btn
|
||||
class="mb-5"
|
||||
color="primary"
|
||||
small
|
||||
@click="addUser"
|
||||
>
|
||||
<v-icon left small>mdi-account-plus-outline</v-icon>
|
||||
Add user
|
||||
</v-btn>
|
||||
-->
|
||||
|
||||
<v-menu
|
||||
offset-y
|
||||
>
|
||||
<template v-slot:activator="{ on, attrs }">
|
||||
<v-btn
|
||||
class="mb-5"
|
||||
color="primary"
|
||||
small
|
||||
v-bind="attrs"
|
||||
v-on="on"
|
||||
>
|
||||
<v-icon left small>mdi-account-plus-outline</v-icon>
|
||||
Add user
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<v-list>
|
||||
<v-list-item>
|
||||
<v-btn
|
||||
class="mb-5"
|
||||
width="100%"
|
||||
color="blue"
|
||||
dark
|
||||
small
|
||||
@click="addUser({password: true})"
|
||||
>
|
||||
Add named user
|
||||
</v-btn>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-btn
|
||||
class="mb-5"
|
||||
width="100%"
|
||||
color="green"
|
||||
dark
|
||||
small
|
||||
@click="addUser({ip: true})"
|
||||
>
|
||||
Add IP user
|
||||
</v-btn>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-btn
|
||||
class="mb-5"
|
||||
width="100%"
|
||||
color="cyan"
|
||||
dark
|
||||
small
|
||||
@click="addUser({host: true})"
|
||||
>
|
||||
Add host user
|
||||
</v-btn>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
</v-menu>
|
||||
|
||||
<v-treeview
|
||||
ref="tree"
|
||||
dense
|
||||
open-on-click
|
||||
activatable
|
||||
hoverable
|
||||
:multiple-active="false"
|
||||
:active.sync="active"
|
||||
:open.sync="open"
|
||||
:items="treeview"
|
||||
style="cursor:pointer;"
|
||||
>
|
||||
<template v-slot:prepend="{ item, open }">
|
||||
<v-icon v-if="userIsDirty(item.value)"
|
||||
small
|
||||
color="warning"
|
||||
title="Discard changes"
|
||||
@click="reset(item)"
|
||||
>mdi-undo</v-icon>
|
||||
</template>
|
||||
<template v-slot:label="{ item, open }">
|
||||
<v-chip v-if="item.value"
|
||||
small
|
||||
>
|
||||
{{ item.id }}
|
||||
</v-chip>
|
||||
<template v-else>
|
||||
{{ item.id }}
|
||||
</template>
|
||||
</template>
|
||||
<template v-slot:append="{ item, open }">
|
||||
<v-icon v-if="userIsDirty(item.value)"
|
||||
small
|
||||
color="primary"
|
||||
title="Save changes"
|
||||
@click.stop="saveUser(item.value)"
|
||||
>mdi-cloud-upload</v-icon>
|
||||
<v-btn v-else-if="item.value"
|
||||
small
|
||||
icon
|
||||
@click="remove(item)"
|
||||
>
|
||||
<v-icon
|
||||
small
|
||||
color="danger"
|
||||
title="Delete this user"
|
||||
>mdi-trash-can-outline</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-treeview>
|
||||
|
||||
</v-col>
|
||||
|
||||
<v-col cols="8">
|
||||
<dougal-user-settings v-if="newUser"
|
||||
:self="user"
|
||||
:show-ip="showIP"
|
||||
:show-host="showHost"
|
||||
:show-password="showPassword"
|
||||
v-model="newUser"
|
||||
>
|
||||
<template v-slot:actions="{ hasErrors }">
|
||||
<v-btn
|
||||
color="primary"
|
||||
:disabled="hasErrors"
|
||||
@click="saveUser(newUser)"
|
||||
>Save</v-btn>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn
|
||||
color="warning"
|
||||
@click="newUser=null"
|
||||
>Cancel</v-btn>
|
||||
</template>
|
||||
</dougal-user-settings>
|
||||
<dougal-user-settings v-else-if="activeUser"
|
||||
:self="user"
|
||||
:show-password="showPassword"
|
||||
v-model="activeUser"
|
||||
>
|
||||
<template v-slot:actions="{ hasErrors, dirty }">
|
||||
<v-btn
|
||||
v-if="dirty"
|
||||
color="primary"
|
||||
:disabled="hasErrors"
|
||||
@click="saveUser(activeUser)"
|
||||
>Save</v-btn>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn
|
||||
v-if="dirty"
|
||||
color="warning"
|
||||
@click="reset(activeUser)"
|
||||
>Cancel</v-btn>
|
||||
</template>
|
||||
</dougal-user-settings>
|
||||
<v-card v-else>
|
||||
<v-card-text>
|
||||
Select an user to edit from the list
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
|
||||
<script>
|
||||
import YAML from 'yaml';
|
||||
import { mapActions, mapGetters } from 'vuex';
|
||||
import { deepSet } from '@/lib/utils';
|
||||
import { User } from '@/lib/user';
|
||||
|
||||
import DougalUserSettings from '@/components/user-settings';
|
||||
|
||||
export default {
|
||||
name: "DougalUsers",
|
||||
|
||||
components: {
|
||||
DougalUserSettings
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
users: [],
|
||||
active: [],
|
||||
open: [],
|
||||
newUser: null,
|
||||
showIP: null,
|
||||
showHost: null,
|
||||
showPassword: null,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
|
||||
isDirty () {
|
||||
return this.users.some( user => user.dirty );
|
||||
},
|
||||
|
||||
treeview () {
|
||||
const tree = {};
|
||||
|
||||
for (const user of this.users) {
|
||||
if (!tree[user.name]) {
|
||||
tree[user.name] = {
|
||||
id: user.name,
|
||||
name: user.name,
|
||||
children: []
|
||||
}
|
||||
}
|
||||
|
||||
tree[user.name].children.push({
|
||||
id: user.id,
|
||||
name: user.id,
|
||||
value: user
|
||||
});
|
||||
}
|
||||
|
||||
return Object.values(tree);
|
||||
},
|
||||
|
||||
activeID () {
|
||||
return this.active[0];
|
||||
},
|
||||
|
||||
activeUser: {
|
||||
get () {
|
||||
if (this.activeID) {
|
||||
return this.users.find(i => i.id == this.activeID);
|
||||
}
|
||||
},
|
||||
|
||||
set (v) {
|
||||
if (this.activeID) {
|
||||
const idx = this.users.findIndex( i => i.id == this.activeID );
|
||||
if (idx != -1) {
|
||||
this.users.splice(idx, 1, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
...mapGetters(['user', 'loading', 'serverEvent'])
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
async getUsers () {
|
||||
return await User.fromAPI(this.api) ?? [];
|
||||
/*
|
||||
const url = `/user`;
|
||||
const init = {
|
||||
headers: {
|
||||
"If-None-Match": "" // Ensure we get a fresh response
|
||||
}
|
||||
};
|
||||
return await this.api([url, init]);
|
||||
*/
|
||||
},
|
||||
|
||||
userIsDirty (user) {
|
||||
return user?.dirty;
|
||||
},
|
||||
|
||||
addUser (opts={}) {
|
||||
|
||||
this.showIP = opts?.ip;
|
||||
this.showHost = opts?.host;
|
||||
this.showPassword = opts?.password;
|
||||
|
||||
this.newUser = this.user.spawn();
|
||||
// Do not add the wildcard org by default.
|
||||
// It can still be added by hand if needed
|
||||
this.newUser.organisations.remove("*");
|
||||
},
|
||||
|
||||
async saveUser (user) {
|
||||
if (user.api) {
|
||||
console.log("Existing user");
|
||||
} else {
|
||||
console.log("New user");
|
||||
}
|
||||
if (user == this.newUser) {
|
||||
console.log("POST new user");
|
||||
} else {
|
||||
console.log("PUT existing user");
|
||||
}
|
||||
console.log(user);
|
||||
return await user.save(this.api);
|
||||
},
|
||||
|
||||
async saveAll () {
|
||||
let count = 0;
|
||||
const dirtyUsers = this.users.filter( user => user.dirty );
|
||||
for (const user of dirtyUsers) {
|
||||
this.showSnack([`Saving ${user.name} – ${++count} of ${dirtyUsers.length} users`, "info"]);
|
||||
await user.save(this.api);
|
||||
}
|
||||
if (count) {
|
||||
this.showSnack([`${dirtyUsers.length} users saved`, "success"]);
|
||||
}
|
||||
},
|
||||
|
||||
async remove (item) {
|
||||
// TODO: Ask for confirmation before removing!
|
||||
const url = `/user/${item.id}`;
|
||||
const init = { method: "DELETE" };
|
||||
const res = await this.api([url, init]);
|
||||
this.reset();
|
||||
},
|
||||
|
||||
replace(user) {
|
||||
const idx = this.users.findIndex(i => i.id == user.id);
|
||||
if (idx != -1) {
|
||||
this.users.splice(idx, 1, user);
|
||||
}
|
||||
},
|
||||
|
||||
async saveToFile () {
|
||||
const payload = YAML.stringify(this.users);
|
||||
const blob = new Blob([payload], {type: "application/yaml"});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const filename = "dougal-users.yaml";
|
||||
|
||||
const element = document.createElement('a');
|
||||
element.download = filename;
|
||||
element.href = url;
|
||||
element.click();
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
|
||||
async upload () {
|
||||
let success = true;
|
||||
const dirty = this.users.filter(i => i.$isDirty);
|
||||
let count = 0;
|
||||
for (const user of dirty) {
|
||||
const body = {...user};
|
||||
delete body.$isDirty;
|
||||
const url = `/user/${user.id}`;
|
||||
const init = {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body
|
||||
};
|
||||
const res = await this.api([url, init]);
|
||||
if (res && res.id) {
|
||||
// In case the server decided to apply any changes
|
||||
this.showSnack([`User ${user.id} uploaded to server (${++count}/${dirty.length})`, "success"]);
|
||||
this.$nextTick( () => {
|
||||
this.replace(res);
|
||||
});
|
||||
} else {
|
||||
success = false;
|
||||
this.showSnack([`Failed to save user ${user.name} (${user.id})`, "warning"])
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async reset (item) {
|
||||
this.active = [];
|
||||
this.open = [];
|
||||
const users = await this.getUsers();
|
||||
|
||||
if (item) {
|
||||
// Reset only this user
|
||||
const id = item.id;
|
||||
|
||||
const idx0 = this.users.findIndex(i => i.id == id);
|
||||
const idx1 = users.findIndex(i => i.id == id);
|
||||
|
||||
if (idx0 != -1 && idx1 != -1) {
|
||||
this.users.splice(idx0, 1, users[idx1]);
|
||||
}
|
||||
} else {
|
||||
// Reset all
|
||||
this.users = users;
|
||||
}
|
||||
this.$refs.tree.updateAll(true);
|
||||
},
|
||||
|
||||
...mapActions(["api", "showSnack"])
|
||||
|
||||
},
|
||||
|
||||
async mounted () {
|
||||
await this.reset();
|
||||
this.$refs.tree.updateAll(true);
|
||||
},
|
||||
|
||||
}
|
||||
</script>
|
||||
@@ -83,7 +83,7 @@ app.map({
|
||||
post: [ mw.user.logout ]
|
||||
},
|
||||
'/version': {
|
||||
get: [ mw.version.get ]
|
||||
get: [ mw.auth.operations, mw.version.get ]
|
||||
},
|
||||
'/': {
|
||||
get: [ mw.openapi.get ]
|
||||
@@ -94,6 +94,8 @@ app.map({
|
||||
// WARNING Every route from here onwards requires authentication!
|
||||
//
|
||||
app.use(mw.auth.authentify);
|
||||
// Users must be authenticated to access anything below here
|
||||
app.use(mw.auth.access.user);
|
||||
|
||||
// Don't process the request if the data hasn't changed
|
||||
app.use(mw.etag.ifNoneMatch);
|
||||
@@ -101,20 +103,20 @@ app.use(mw.etag.ifNoneMatch);
|
||||
// We must be authenticated before we can access these
|
||||
app.map({
|
||||
'/project': {
|
||||
get: [ mw.project.get ], // Get list of projects
|
||||
get: [ mw.project.get ], // Get list of projects, filtered by `read` access
|
||||
post: [ mw.auth.access.admin, mw.project.post ], // Create a new project
|
||||
},
|
||||
'/project/:project': {
|
||||
get: [ mw.project.summary.get ], // Get project data
|
||||
delete: [ mw.auth.access.admin, mw.project.delete ], // Delete a project (only if empty)
|
||||
delete: [ mw.auth.access.edit, mw.project.delete ], // Delete a project (only if empty)
|
||||
},
|
||||
'/project/:project/summary': {
|
||||
get: [ mw.project.summary.get ],
|
||||
get: [ mw.auth.access.read, mw.project.summary.get ],
|
||||
},
|
||||
'/project/:project/configuration': {
|
||||
get: [ mw.project.configuration.get ], // Get project configuration
|
||||
patch: [ mw.auth.access.admin, mw.project.configuration.patch ], // Modify project configuration
|
||||
put: [ mw.auth.access.admin, mw.project.configuration.put ], // Overwrite configuration
|
||||
patch: [ mw.auth.access.edit, mw.project.configuration.patch ], // Modify project configuration
|
||||
put: [ mw.auth.access.edit, mw.project.configuration.put ], // Overwrite configuration
|
||||
},
|
||||
|
||||
/*
|
||||
@@ -122,25 +124,25 @@ app.map({
|
||||
*/
|
||||
|
||||
'/project/:project/gis': {
|
||||
get: [ mw.etag.noSave, mw.gis.project.bbox ]
|
||||
get: [ mw.auth.access.read, mw.etag.noSave, mw.gis.project.bbox ]
|
||||
},
|
||||
'/project/:project/gis/preplot': {
|
||||
get: [ mw.etag.noSave, mw.gis.project.preplot ]
|
||||
get: [ mw.auth.access.read, mw.etag.noSave, mw.gis.project.preplot ]
|
||||
},
|
||||
'/project/:project/gis/preplot/:featuretype(line|point)': {
|
||||
get: [ mw.etag.noSave, mw.gis.project.preplot ]
|
||||
get: [ mw.auth.access.read, mw.etag.noSave, mw.gis.project.preplot ]
|
||||
},
|
||||
'/project/:project/gis/raw/:featuretype(line|point)': {
|
||||
get: [ mw.etag.noSave, mw.gis.project.raw ]
|
||||
get: [ mw.auth.access.read, mw.etag.noSave, mw.gis.project.raw ]
|
||||
},
|
||||
'/project/:project/gis/final/:featuretype(line|point)': {
|
||||
get: [ mw.etag.noSave, mw.gis.project.final ]
|
||||
get: [ mw.auth.access.read, mw.etag.noSave, mw.gis.project.final ]
|
||||
},
|
||||
'/project/:project/gis/layer': {
|
||||
get: [ mw.etag.noSave, mw.gis.project.layer.get ]
|
||||
get: [ mw.auth.access.read, mw.etag.noSave, mw.gis.project.layer.get ]
|
||||
},
|
||||
'/project/:project/gis/layer/:name': {
|
||||
get: [ mw.etag.noSave, mw.gis.project.layer.get ]
|
||||
get: [ mw.auth.access.read, mw.etag.noSave, mw.gis.project.layer.get ]
|
||||
},
|
||||
|
||||
/*
|
||||
@@ -148,10 +150,10 @@ app.map({
|
||||
*/
|
||||
|
||||
'/project/:project/line/': {
|
||||
get: [ mw.line.list ],
|
||||
get: [ mw.auth.access.read, mw.line.list ],
|
||||
},
|
||||
'/project/:project/line/:line': {
|
||||
// get: [ mw.line.get ],
|
||||
// get: [ mw.auth.access.read, mw.line.get ],
|
||||
patch: [ mw.auth.access.write, mw.line.patch ],
|
||||
},
|
||||
|
||||
@@ -160,13 +162,13 @@ app.map({
|
||||
*/
|
||||
|
||||
'/project/:project/sequence/': {
|
||||
get: [ mw.sequence.list ],
|
||||
get: [ mw.auth.access.read, mw.sequence.list ],
|
||||
},
|
||||
'/project/:project/sequence/:sequence': {
|
||||
get: [ mw.sequence.get ],
|
||||
get: [ mw.auth.access.read, mw.sequence.get ],
|
||||
patch: [ mw.auth.access.write, mw.sequence.patch ],
|
||||
'/:point': {
|
||||
get: [ mw.sequence.point.get ]
|
||||
get: [ mw.auth.access.read, mw.sequence.point.get ]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -175,34 +177,48 @@ app.map({
|
||||
*/
|
||||
|
||||
'/project/:project/plan/': {
|
||||
get: [ mw.plan.list ],
|
||||
get: [ mw.auth.access.read, mw.plan.list ],
|
||||
put: [ mw.auth.access.write, mw.plan.put ],
|
||||
post: [ mw.auth.access.write, mw.plan.post ]
|
||||
},
|
||||
'/project/:project/plan/:sequence': {
|
||||
// get: [ mw.plan.get ],
|
||||
// get: [ mw.auth.access.read, mw.plan.get ],
|
||||
patch: [ mw.auth.access.write, mw.plan.patch ],
|
||||
delete: [ mw.auth.access.write, mw.plan.delete ]
|
||||
},
|
||||
|
||||
/*
|
||||
* Line name endpoints
|
||||
*
|
||||
*/
|
||||
|
||||
// Read access is sufficient for the next two endpoints
|
||||
|
||||
'/project/:project/linename': {
|
||||
post: [ mw.auth.access.read, mw.linename.post ], // Get a linename
|
||||
},
|
||||
'/project/:project/linename/properties': {
|
||||
get: [ mw.auth.access.read, mw.linename.properties.get ], // Get linename properties
|
||||
},
|
||||
|
||||
/*
|
||||
* Event log endpoints
|
||||
*/
|
||||
|
||||
'/project/:project/event/': {
|
||||
get: [ mw.event.list ],
|
||||
get: [ mw.auth.access.read, mw.event.list ],
|
||||
post: [ mw.auth.access.write, mw.event.post ],
|
||||
put: [ mw.auth.access.write, mw.event.put ],
|
||||
delete: [ mw.auth.access.write, mw.event.delete ],
|
||||
'changes/:since': {
|
||||
get: [ mw.event.changes ]
|
||||
get: [ mw.auth.access.read, mw.event.changes ]
|
||||
},
|
||||
// TODO Rename -/:sequence → sequence/:sequence
|
||||
'-/:sequence/': { // NOTE: We need to avoid conflict with the next endpoint ☹
|
||||
get: [ mw.event.sequence.get ],
|
||||
get: [ mw.auth.access.read, mw.event.sequence.get ],
|
||||
},
|
||||
':id/': {
|
||||
get: [ mw.event.get ],
|
||||
get: [ mw.auth.access.read, mw.event.get ],
|
||||
put: [ mw.auth.access.write, mw.event.put ],
|
||||
patch: [ mw.auth.access.write, mw.event.patch ],
|
||||
delete: [mw.auth.access.write, mw.event.delete ]
|
||||
@@ -216,17 +232,17 @@ app.map({
|
||||
'/project/:project/qc': {
|
||||
'/results': {
|
||||
// Get all QC results for :project
|
||||
get: [ mw.etag.noSave, mw.qc.results.get ],
|
||||
get: [ mw.auth.access.read, mw.etag.noSave, mw.qc.results.get ],
|
||||
|
||||
// Delete all QC results for :project
|
||||
delete: [ mw.etag.noSave, mw.auth.access.write, mw.qc.results.delete ],
|
||||
delete: [ mw.auth.access.write, mw.etag.noSave, mw.qc.results.delete ],
|
||||
|
||||
'/accept': {
|
||||
post: [ mw.etag.noSave, mw.auth.access.write, mw.qc.results.accept ]
|
||||
post: [ mw.auth.access.write, mw.etag.noSave, mw.qc.results.accept ]
|
||||
},
|
||||
|
||||
'/unaccept': {
|
||||
post: [ mw.etag.noSave, mw.auth.access.write, mw.qc.results.unaccept ]
|
||||
post: [ mw.auth.access.write, mw.etag.noSave, mw.qc.results.unaccept ]
|
||||
},
|
||||
|
||||
'/sequence/:sequence': {
|
||||
@@ -234,7 +250,7 @@ app.map({
|
||||
get: [ mw.etag.noSave, mw.qc.results.get ],
|
||||
|
||||
// Delete QC results for :project, :sequence
|
||||
delete: [ mw.etag.noSave, mw.auth.access.write, mw.qc.results.delete ]
|
||||
delete: [ mw.auth.access.write, mw.etag.noSave, mw.qc.results.delete ]
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -244,18 +260,18 @@ app.map({
|
||||
*/
|
||||
|
||||
'/project/:project/label/': {
|
||||
get: [ mw.label.list ],
|
||||
get: [ mw.auth.access.read, mw.label.list ],
|
||||
// post: [ mw.label.post ],
|
||||
},
|
||||
'/project/:project/configuration/:path(*)?': {
|
||||
get: [ mw.configuration.get ],
|
||||
get: [ mw.auth.access.read, mw.configuration.get ],
|
||||
// post: [ mw.auth.access.admin, mw.label.post ],
|
||||
},
|
||||
'/project/:project/info/:path(*)': {
|
||||
get: [ mw.info.get ],
|
||||
post: [ mw.auth.access.write, mw.info.post ],
|
||||
put: [ mw.auth.access.write, mw.info.put ],
|
||||
delete: [ mw.auth.access.write, mw.info.delete ]
|
||||
get: [ mw.auth.operations, mw.auth.access.read, mw.info.get ],
|
||||
post: [ mw.auth.operations, mw.auth.access.write, mw.info.post ],
|
||||
put: [ mw.auth.operations, mw.auth.access.write, mw.info.put ],
|
||||
delete: [ mw.auth.operations, mw.auth.access.write, mw.info.delete ]
|
||||
},
|
||||
'/project/:project/meta/': {
|
||||
put: [ mw.auth.access.write, mw.meta.put ],
|
||||
@@ -265,7 +281,7 @@ app.map({
|
||||
// GET:
|
||||
// `/raw/sequences/qc/missing_shots`,
|
||||
// `/final/points/qc/sync_warn/results
|
||||
get: [ mw.meta.get ],
|
||||
get: [ mw.auth.access.read, mw.meta.get ],
|
||||
// // PUT:
|
||||
// // `/raw/qc/missing_shots` ← { sequence: …, value: … }
|
||||
// put: [ mw.meta.put ]
|
||||
@@ -283,7 +299,7 @@ app.map({
|
||||
'/files/?:path(*)': {
|
||||
get: [ mw.auth.access.write, mw.etag.noSave, mw.files.get ]
|
||||
},
|
||||
'/navdata/': {
|
||||
'/navdata/': { // TODO These endpoints should probably need read access auth
|
||||
get: [ mw.etag.noSave, mw.navdata.get ],
|
||||
'gis/:featuretype(line|point)': {
|
||||
get: [ mw.etag.noSave, mw.gis.navdata.get ]
|
||||
@@ -291,10 +307,10 @@ app.map({
|
||||
},
|
||||
'/info/': {
|
||||
':path(*)': {
|
||||
get: [ mw.info.get ],
|
||||
put: [ mw.auth.access.write, mw.info.put ],
|
||||
post: [ mw.auth.access.write, mw.info.post ],
|
||||
delete: [ mw.auth.access.write, mw.info.delete ]
|
||||
get: [ mw.auth.operations, mw.info.get ],
|
||||
put: [ mw.auth.operations, mw.auth.access.write, mw.info.put ],
|
||||
post: [ mw.auth.operations, mw.auth.access.write, mw.info.post ],
|
||||
delete: [ mw.auth.operations, mw.auth.access.write, mw.info.delete ]
|
||||
}
|
||||
},
|
||||
'/queue/outgoing/': {
|
||||
@@ -317,17 +333,17 @@ app.map({
|
||||
},
|
||||
'/rss/': {
|
||||
get: [ mw.rss.get ]
|
||||
}
|
||||
},
|
||||
//
|
||||
// '/user': {
|
||||
// get: [ mw.user.get ],
|
||||
// post: [ mw.user.put ]
|
||||
// },
|
||||
// '/user/:user': {
|
||||
// get: [ mw.user.get ],
|
||||
// put: [ mw.user.put ],
|
||||
// // delete: [ mw.user.delete ]
|
||||
// },
|
||||
'/user': {
|
||||
get: [ mw.auth.access.read, mw.etag.noSave, mw.user.list ],
|
||||
post: [ mw.auth.access.edit, mw.etag.noSave, mw.user.post ],
|
||||
},
|
||||
'/user/:user_id': {
|
||||
get: [ mw.user.get ],
|
||||
put: [ mw.user.put ],
|
||||
delete: [ mw.user.delete ]
|
||||
},
|
||||
//
|
||||
});
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@ module.exports = async function (req, res, next) {
|
||||
|
||||
try {
|
||||
const d = await diagnostics();
|
||||
if (req.user?.role != "admin" && req.user?.role != "user") {
|
||||
}
|
||||
res.status(200).json(d);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
|
||||
@@ -1,6 +1,61 @@
|
||||
const { projectOrganisations, vesselOrganisations/*, orgAccess */} = require('../../../lib/db/project/organisations');
|
||||
const ServerUser = require('../../../lib/db/user/User');
|
||||
const { Organisations } = require('@dougal/organisations');
|
||||
|
||||
/** Second-order function.
|
||||
* Returns a middleware that checks if the user has access to
|
||||
* `operation` in the project identified by `req.params.project`
|
||||
* or, if `req.params.project` is not defined, against the vessel
|
||||
* access permissions.
|
||||
*/
|
||||
function operation (operation) {
|
||||
return async function (req, res, next) {
|
||||
const user = new ServerUser(req.user);
|
||||
if (req.params.project) {
|
||||
const projectOrgs = new Organisations(await projectOrganisations(req.params.project));
|
||||
const availableOrgs = projectOrgs.accessToOperation(operation).filter(user.organisations);
|
||||
console.log("Operation: ", operation);
|
||||
console.log("User: ", user.name);
|
||||
console.log("User orgs: ", user.organisations);
|
||||
console.log("Project orgs: ", projectOrgs);
|
||||
console.log("Available orgs: ", availableOrgs);
|
||||
if (availableOrgs.length > 0) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const vesselOrgs = new Organisations(await vesselOrganisations());
|
||||
const availableOrgs = vesselOrgs.accessToOperation(operation).filter(user.organisations);
|
||||
console.log("Operation: ", operation);
|
||||
console.log("User: ", user.name);
|
||||
console.log("User orgs: ", user.organisations);
|
||||
console.log("Vessel orgs: ", vesselOrgs);
|
||||
console.log("Available orgs: ", availableOrgs);
|
||||
if (availableOrgs.length > 0) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
}
|
||||
next({status: 403, message: "Access denied"});
|
||||
}
|
||||
}
|
||||
// function operation (operation) {
|
||||
// return async function (req, res, next) {
|
||||
// if (await orgAccess(req.user?.organisations, req.params.project ?? null, operation)) {
|
||||
// next();
|
||||
// } else {
|
||||
// next({status: 403, message: "Access denied"});
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
async function read (req, res, next) {
|
||||
// Everyone can access
|
||||
async function all (req, res, next) {
|
||||
next();
|
||||
}
|
||||
|
||||
// Any logged in user can access
|
||||
async function user (req, res, next) {
|
||||
if (req.user) {
|
||||
next();
|
||||
} else {
|
||||
@@ -8,70 +63,27 @@ async function read (req, res, next) {
|
||||
}
|
||||
}
|
||||
|
||||
async function write (req, res, next) {
|
||||
if (req.user && (req.user.role == "user" || req.user.role == "admin")) {
|
||||
next();
|
||||
} else {
|
||||
next({status: 403, message: "Access denied"});
|
||||
}
|
||||
}
|
||||
|
||||
// Any user who is an admin of at least one organisation
|
||||
async function admin (req, res, next) {
|
||||
if (req.user && req.user.role == "admin") {
|
||||
next();
|
||||
} else {
|
||||
next({status: 403, message: "Access denied"});
|
||||
}
|
||||
}
|
||||
|
||||
/** Return a middleware to check for arbitrary roles.
|
||||
*
|
||||
* Examples:
|
||||
*
|
||||
* req1 = {user: {role: "admin"}};
|
||||
*
|
||||
* role("admin")(req1) → true
|
||||
* role("user")(req1) → false
|
||||
* role(["user", "admin"])(req1) → true
|
||||
* role("guest")(req1) → false
|
||||
*
|
||||
* req2 = {user: {role: ["admin", "user"]}}
|
||||
*
|
||||
* role("admin")(req2) → true
|
||||
* role("user")(req2) → true
|
||||
* role(["user", "admin"])(req2) → true
|
||||
* role("guest")(req2) → false
|
||||
*
|
||||
* To check for role1 AND role2, use two middlewares:
|
||||
*
|
||||
* [role("role1"), role("role2")]
|
||||
*
|
||||
*/
|
||||
async function role (required_role) {
|
||||
|
||||
const roles = Array.isArray(required_role)
|
||||
? required_role
|
||||
: [ required_role ];
|
||||
|
||||
function check (user_role) {
|
||||
if (Array.isArray(user_role)) {
|
||||
return user_role.some(check);
|
||||
} else {
|
||||
return roles.includes(user_role);
|
||||
}
|
||||
}
|
||||
|
||||
return (req, res, next) => {
|
||||
if (req.user && check(req.user?.role)) {
|
||||
if (req.user) {
|
||||
const user = new ServerUser(req.user);
|
||||
if (user.operations.accessToOperation("edit").length > 0) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
next({status: 403, message: "Access denied"});
|
||||
};
|
||||
}
|
||||
next({status: 403, message: "Access denied"});
|
||||
}
|
||||
|
||||
const read = operation('read');
|
||||
const write = operation('write');
|
||||
const edit = operation('edit');
|
||||
|
||||
module.exports = {
|
||||
all,
|
||||
user,
|
||||
read,
|
||||
write,
|
||||
edit,
|
||||
admin,
|
||||
role
|
||||
};
|
||||
|
||||
@@ -2,16 +2,24 @@ const dns = require('dns');
|
||||
const { Netmask } = require('netmask');
|
||||
const cfg = require('../../../lib/config');
|
||||
const jwt = require('../../../lib/jwt');
|
||||
const user = require('../../../lib/db/user');
|
||||
const ServerUser = require('../../../lib/db/user/User');
|
||||
|
||||
async function authorisedIP (req, res) {
|
||||
const validIPs = cfg._("global.users.login.ip") || {};
|
||||
for (const key in validIPs) {
|
||||
const block = new Netmask(key);
|
||||
const validIPs = await user.ip({active: true}); // Get all active IP logins
|
||||
validIPs.forEach( i => i.$block = new Netmask(i.ip) );
|
||||
validIPs.sort( (a, b) => b.$block.bitmask - a.$block.netmask ); // More specific IPs have precedence
|
||||
for (const ip of validIPs) {
|
||||
const block = ip.$block;
|
||||
if (block.contains(req.ip)) {
|
||||
const payload = Object.assign({
|
||||
const payload = {
|
||||
...ip,
|
||||
ip: req.ip,
|
||||
autologin: true
|
||||
}, validIPs[key]);
|
||||
};
|
||||
delete payload.$block;
|
||||
delete payload.hash;
|
||||
delete payload.active;
|
||||
jwt.issue(payload, req, res);
|
||||
return true;
|
||||
}
|
||||
@@ -20,16 +28,19 @@ async function authorisedIP (req, res) {
|
||||
}
|
||||
|
||||
async function authorisedHost (req, res) {
|
||||
const validHosts = cfg._("global.users.login.host") || {};
|
||||
const validHosts = await user.host({active: true}); // Get all active host logins
|
||||
for (const key in validHosts) {
|
||||
try {
|
||||
const ip = await dns.promises.resolve(key);
|
||||
if (ip == req.ip) {
|
||||
const payload = Object.assign({
|
||||
const payload = {
|
||||
...validHosts[key],
|
||||
ip: req.ip,
|
||||
host: key,
|
||||
autologin: true
|
||||
}, validHosts[key]);
|
||||
};
|
||||
delete payload.$block;
|
||||
delete payload.hash;
|
||||
delete payload.active;
|
||||
jwt.issue(payload, req, res);
|
||||
return true;
|
||||
}
|
||||
@@ -42,6 +53,17 @@ async function authorisedHost (req, res) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// TODO: Check client TLS certificates
|
||||
// Probably will do this via Nginx with
|
||||
// ssl_verify_client optional;
|
||||
// and then putting either of the
|
||||
// $ssl_client_s_dn or $ssl_client_escaped_cert
|
||||
// variables into an HTTP header for Node
|
||||
// to check (naturally, it must be ensured
|
||||
// that a user cannot just insert the header
|
||||
// in a request).
|
||||
|
||||
|
||||
async function auth (req, res, next) {
|
||||
|
||||
if (res.headersSent) {
|
||||
@@ -60,12 +82,11 @@ async function auth (req, res, next) {
|
||||
if (req.user.exp) {
|
||||
const ttl = req.user.exp - Date.now()/1000;
|
||||
if (ttl < cfg.jwt.options.expiresIn/2) {
|
||||
const credentials = cfg._("global.users.login.user").find(i => i.name == req.user.name && i.role == req.user.role);
|
||||
const credentials = await ServerUser.fromSQL(null, req.user.id);
|
||||
if (credentials) {
|
||||
// Refresh token
|
||||
payload = Object.assign({}, credentials);
|
||||
delete payload.hash;
|
||||
jwt.issue(Object.assign({}, credentials), req, res);
|
||||
payload = Object.assign({}, credentials.toJSON());
|
||||
jwt.issue(Object.assign({}, credentials.toJSON()), req, res);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
exports.jwt = require('./jwt');
|
||||
exports.authentify = require('./authentify');
|
||||
exports.access = require('./access');
|
||||
exports.operations = require('./operations'); // FIXME this is crap
|
||||
|
||||
10
lib/www/server/api/middleware/auth/operations.js
Normal file
10
lib/www/server/api/middleware/auth/operations.js
Normal file
@@ -0,0 +1,10 @@
|
||||
const { allowedOperations } = require('../../../lib/db/project/organisations');
|
||||
|
||||
async function operations (req, res, next) {
|
||||
if (req.user) {
|
||||
req.user.operations = await allowedOperations(req.user?.organisations, req.params.project ?? null);
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = operations;
|
||||
@@ -3,6 +3,7 @@ module.exports = {
|
||||
files: require('./files'),
|
||||
plan: require('./plan'),
|
||||
line: require('./line'),
|
||||
linename: require('./linename'),
|
||||
project: require('./project'),
|
||||
sequence: require('./sequence'),
|
||||
user: require('./user'),
|
||||
|
||||
@@ -4,7 +4,7 @@ const { info } = require('../../../lib/db');
|
||||
module.exports = async function (req, res, next) {
|
||||
|
||||
try {
|
||||
await info.delete(req.params.project, req.params.path, undefined, req.user.role);
|
||||
await info.delete(req.params.project, req.params.path, undefined, req.user?.operations);
|
||||
res.status(204).send();
|
||||
next();
|
||||
} catch (err) {
|
||||
|
||||
@@ -4,7 +4,7 @@ const { info } = require('../../../lib/db');
|
||||
module.exports = async function (req, res, next) {
|
||||
|
||||
try {
|
||||
res.status(200).json(await info.get(req.params.project, req.params.path, req.query, req.user.role));
|
||||
res.status(200).json(await info.get(req.params.project, req.params.path, req.query, req.user?.operations));
|
||||
} catch (err) {
|
||||
if (err instanceof TypeError) {
|
||||
res.status(404).json(null);
|
||||
|
||||
@@ -6,7 +6,7 @@ module.exports = async function (req, res, next) {
|
||||
try {
|
||||
const payload = req.body;
|
||||
|
||||
await info.post(req.params.project, req.params.path, payload, undefined, req.user.role);
|
||||
await info.post(req.params.project, req.params.path, payload, undefined, req.user?.operations);
|
||||
res.status(201).send();
|
||||
next();
|
||||
} catch (err) {
|
||||
|
||||
@@ -6,7 +6,7 @@ module.exports = async function (req, res, next) {
|
||||
try {
|
||||
const payload = req.body;
|
||||
|
||||
await info.put(req.params.project, req.params.path, payload, undefined, req.user.role);
|
||||
await info.put(req.params.project, req.params.path, payload, undefined, req.user?.operations);
|
||||
res.status(201).send();
|
||||
next();
|
||||
} catch (err) {
|
||||
|
||||
4
lib/www/server/api/middleware/linename/index.js
Normal file
4
lib/www/server/api/middleware/linename/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
properties: require('./properties'),
|
||||
post: require('./post'),
|
||||
};
|
||||
21
lib/www/server/api/middleware/linename/post.js
Normal file
21
lib/www/server/api/middleware/linename/post.js
Normal file
@@ -0,0 +1,21 @@
|
||||
|
||||
const { linename } = require('../../../lib/db/linename');
|
||||
|
||||
module.exports = async function (req, res, next) {
|
||||
|
||||
try {
|
||||
const payload = req.body;
|
||||
|
||||
const line = await linename.post(req.params.project, payload);
|
||||
if (line) {
|
||||
res.status(200).type("text/plain").send(line);
|
||||
} else {
|
||||
res.status(404).send();
|
||||
}
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
21
lib/www/server/api/middleware/linename/properties/get.js
Normal file
21
lib/www/server/api/middleware/linename/properties/get.js
Normal file
@@ -0,0 +1,21 @@
|
||||
|
||||
const { linename } = require('../../../../lib/db');
|
||||
|
||||
module.exports = async function (req, res, next) {
|
||||
|
||||
try {
|
||||
const payload = req.body;
|
||||
|
||||
const properties = await linename.properties.get(req.params.project, payload);
|
||||
if (properties) {
|
||||
res.status(200).send(properties);
|
||||
} else {
|
||||
res.status(404).send();
|
||||
}
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
get: require('./get'),
|
||||
};
|
||||
@@ -4,7 +4,7 @@ const { plan, info } = require('../../../../lib/db');
|
||||
const json = async function (req, res, next) {
|
||||
try {
|
||||
const sequences = await plan.list(req.params.project, req.query) ?? [];
|
||||
const remarks = await info.get(req.params.project, "plan/remarks", req.query, req.user.role) ?? null;
|
||||
const remarks = await info.get(req.params.project, "plan/remarks", req.query, req.user?.operations) ?? null;
|
||||
const response = {
|
||||
remarks,
|
||||
sequences
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
|
||||
const { project} = require('../../../lib/db');
|
||||
const { project } = require('../../../lib/db');
|
||||
|
||||
module.exports = async function (req, res, next) {
|
||||
|
||||
try {
|
||||
res.status(200).send(await project.get());
|
||||
const accessibleProjects = project.organisations.orgFilter(
|
||||
req.user?.organisations,
|
||||
await project.get(),
|
||||
'read'
|
||||
);
|
||||
res.status(200).send(accessibleProjects);
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ const { qc } = require('../../../../lib/db');
|
||||
module.exports = async function (req, res, next) {
|
||||
|
||||
try {
|
||||
res.status(200).json(await qc.results.get(req.params.project, req.params.sequence, req.query, req.user.role));
|
||||
res.status(200).json(await qc.results.get(req.params.project, req.params.sequence, req.query));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
return;
|
||||
|
||||
32
lib/www/server/api/middleware/user/delete.js
Normal file
32
lib/www/server/api/middleware/user/delete.js
Normal file
@@ -0,0 +1,32 @@
|
||||
|
||||
// const { user } = require('../../../lib/db');
|
||||
// const organisations = require('../../../lib/organisations');
|
||||
const ServerUser = require('../../../lib/db/user/User');
|
||||
|
||||
module.exports = async function (req, res, next) {
|
||||
|
||||
try {
|
||||
if (req.params.user_id == req.user?.id) {
|
||||
throw {status: 403, message: "Cannot self-delete"};
|
||||
} else {
|
||||
const requestor = new ServerUser(req.user);
|
||||
const target = await ServerUser.fromSQL(null, req.params.user_id);
|
||||
|
||||
if (requestor.canEdit(target)) {
|
||||
if (await target.remove()) {
|
||||
res.status(204).send();
|
||||
} else {
|
||||
// Delete did not return a successful response. We assume this
|
||||
// is because the user did not exist in the first place so we
|
||||
// still return a success response
|
||||
res.status(202).send();
|
||||
}
|
||||
} else {
|
||||
throw {status: 403, message: "Access denied"};
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
const ServerUser = require('../../../lib/db/user/User');
|
||||
|
||||
module.exports = async function (req, res, next) {
|
||||
|
||||
try {
|
||||
const user = new ServerUser(req.user);
|
||||
const target = await ServerUser.fromSQL(null, req.params.user_id);
|
||||
|
||||
if (requestor.canRead(target)) {
|
||||
res.status(200).send(target.toJSON());
|
||||
} else {
|
||||
throw {status: 403, message: "Access denied"};
|
||||
}
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
|
||||
exports.login = require('./login');
|
||||
exports.logout = require('./logout');
|
||||
module.exports = {
|
||||
login: require('./login'),
|
||||
logout: require('./logout'),
|
||||
list: require('./list'),
|
||||
get: require('./get'),
|
||||
post: require('./post'),
|
||||
put: require('./put'),
|
||||
delete: require('./delete'),
|
||||
}
|
||||
|
||||
27
lib/www/server/api/middleware/user/list.js
Normal file
27
lib/www/server/api/middleware/user/list.js
Normal file
@@ -0,0 +1,27 @@
|
||||
|
||||
const ServerUser = require('../../../lib/db/user/User');
|
||||
const { pool } = require('../../../lib/db/connection');
|
||||
|
||||
module.exports = async function (req, res, next) {
|
||||
|
||||
try {
|
||||
const requestor = new ServerUser(req.user, pool);
|
||||
console.log("REQUESTOR", requestor.toJSON());
|
||||
if (requestor.name) {
|
||||
const allUsers = await ServerUser.fromSQL(); // Get all users
|
||||
const listableUsers = requestor.editablePeers(allUsers);
|
||||
res.status(200).send(listableUsers.map(u => u.toJSON()));
|
||||
}
|
||||
|
||||
// const userOrgs = organisations.extract(req.user?.organisations ?? {}, [ "write", "edit" ]);
|
||||
// const users = await user.list(userOrgs.includes("*") ? null : userOrgs); // null: list all
|
||||
// console.log("user", JSON.stringify(req.user, null, 4));
|
||||
// console.log("userOrgs", userOrgs);
|
||||
// console.log("users", users);
|
||||
// res.status(200).send(users);
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
|
||||
};
|
||||
@@ -4,7 +4,7 @@ const jwt = require('../../../lib/jwt');
|
||||
async function login (req, res, next) {
|
||||
if (req.body) {
|
||||
const {user, password} = req.body;
|
||||
const payload = jwt.checkValidCredentials({user, password});
|
||||
const payload = await jwt.checkValidCredentials({user, password});
|
||||
if (payload) {
|
||||
jwt.issue(payload, req, res);
|
||||
res.status(204).send();
|
||||
|
||||
27
lib/www/server/api/middleware/user/post.js
Normal file
27
lib/www/server/api/middleware/user/post.js
Normal file
@@ -0,0 +1,27 @@
|
||||
|
||||
const ServerUser = require('../../../lib/db/user/User');
|
||||
const { pool } = require('../../../lib/db/connection');
|
||||
|
||||
module.exports = async function (req, res, next) {
|
||||
|
||||
try {
|
||||
// const data = await user.create(req.body, req.user?.organisations);
|
||||
// res.status(203).send(data);
|
||||
const requestor = new ServerUser(req.user, pool);
|
||||
if (requestor.name) {
|
||||
const newUser = new ServerUser(req.body);
|
||||
newUser.filter(requestor);
|
||||
newUser.client = pool;
|
||||
console.log("newUser", newUser.toJSON());
|
||||
const asSaved = await newUser.save();
|
||||
console.log("asSaved", asSaved);
|
||||
if (asSaved instanceof ServerUser) {
|
||||
res.status(200).send(asSaved.toJSON());
|
||||
}
|
||||
}
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
|
||||
const ServerUser = require('../../../lib/db/user/User');
|
||||
|
||||
module.exports = async function (req, res, next) {
|
||||
|
||||
try {
|
||||
// const data = await user.create(req.body, req.user?.organisations);
|
||||
// res.status(203).send(data);
|
||||
const requestor = new ServerUser(req.user);
|
||||
if (requestor.name) {
|
||||
const target = await ServerUser.fromSQL(null, req.params.user_id);
|
||||
const changes = req.body;
|
||||
|
||||
if (requestor.id == target.id || requestor.canEdit(target)) {
|
||||
|
||||
if (requestor.id == target.id) {
|
||||
// User cannot self-deactivate
|
||||
newUser.active = requestor.active;
|
||||
}
|
||||
|
||||
const edited = await requestor.edit(target).to(changes).save();
|
||||
|
||||
if (edited instanceof ServerUser) {
|
||||
res.status(200).send(edited.toJSON());
|
||||
} else {
|
||||
console.log("Unexpected result", edited);
|
||||
throw({status: 500, message: "Unexpected response when editing user"});
|
||||
}
|
||||
} else {
|
||||
next({status: 403, message: "Access denied"});
|
||||
}
|
||||
}
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ module.exports = async function (req, res, next) {
|
||||
|
||||
try {
|
||||
const v = await version();
|
||||
if (req.user?.role != "admin" && req.user?.role != "user") {
|
||||
if (!["write", "edit"].some( op => req.user?.operations.includes(op) )) {
|
||||
delete v.os;
|
||||
delete v.db;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ class DetectProjectConfigurationChange {
|
||||
DEBUG("Project configuration change detected")
|
||||
|
||||
const projects = await project.get();
|
||||
project.organisations.setCache(projects);
|
||||
|
||||
const _ctx_data = {};
|
||||
for (let pid of projects.map(i => i.pid)) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
module.exports = {
|
||||
project: require('./project'),
|
||||
line: require('./line'),
|
||||
linename: require('./linename'),
|
||||
sequence: require('./sequence'),
|
||||
event: require('./event'),
|
||||
plan: require('./plan'),
|
||||
@@ -12,5 +13,6 @@ module.exports = {
|
||||
meta: require('./meta'),
|
||||
navdata: require('./navdata'),
|
||||
queue: require('./queue'),
|
||||
qc: require('./qc')
|
||||
qc: require('./qc'),
|
||||
user: require('./user'),
|
||||
};
|
||||
|
||||
@@ -30,7 +30,7 @@ const dictionary = {
|
||||
config: {
|
||||
// Configuration (site-wide or survey)
|
||||
// Nobody except admin can access
|
||||
_: { _: false, admin: true }
|
||||
_: { _: false, edit: true }
|
||||
},
|
||||
qc: {
|
||||
// QC results (survey)
|
||||
@@ -42,13 +42,13 @@ const dictionary = {
|
||||
// Equipment info (site)
|
||||
// Everyone can read, user + admin can alter
|
||||
get: { _: true },
|
||||
_ : { _: false, user: true, admin: true }
|
||||
_ : { _: false, write: true, edit: true }
|
||||
},
|
||||
contact: {
|
||||
// Contact details (basically an example entry)
|
||||
// Everyone can read, admin can alter
|
||||
get: { _: true },
|
||||
_ : { _: false, admin: true },
|
||||
_ : { _: false, edit: true },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ const dictionary = {
|
||||
*
|
||||
* @a key {String} is the object of the action.
|
||||
* @a verb {String} is the action.
|
||||
* @a role {String} is the subject of the action.
|
||||
* @a operations {Array} is one of the subjects of the action.
|
||||
*
|
||||
* @returns {Boolean} `true` is the action is allowed,
|
||||
* `false` if it is not.
|
||||
@@ -67,12 +67,17 @@ const dictionary = {
|
||||
* by `_`, others are entered explicitly.
|
||||
*
|
||||
*/
|
||||
function checkPermission (key, verb, role) {
|
||||
function checkPermission (key, verb, operations) {
|
||||
const entry = dictionary[key]
|
||||
if (entry) {
|
||||
const action = entry[verb] ?? entry._
|
||||
if (action) {
|
||||
return action[role] ?? action._ ?? false;
|
||||
for (const op of operations) {
|
||||
if ((op in action)) {
|
||||
return action[op];
|
||||
}
|
||||
}
|
||||
return action._ ?? false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
const { setSurvey, transaction } = require('../connection');
|
||||
const checkPermission = require('./check-permission');
|
||||
|
||||
async function del (projectId, path, opts = {}, role) {
|
||||
async function del (projectId, path, opts = {}, operations = []) {
|
||||
const client = await setSurvey(projectId);
|
||||
const [key, ...jsonpath] = (path||"").split("/").filter(i => i.length);
|
||||
|
||||
if (!checkPermission(key, "delete", role)) {
|
||||
if (!checkPermission(key, "delete", operations)) {
|
||||
throw {status: 403, message: "Forbidden"};
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
const { setSurvey } = require('../connection');
|
||||
const checkPermission = require('./check-permission');
|
||||
|
||||
async function get (projectId, path, opts = {}, role) {
|
||||
async function get (projectId, path, opts = {}, operations = []) {
|
||||
const client = await setSurvey(projectId);
|
||||
const [key, ...subkey] = path.split("/").filter(i => i.trim().length);
|
||||
|
||||
if (!checkPermission(key, "get", role)) {
|
||||
if (!checkPermission(key, "get", operations)) {
|
||||
throw {status: 403, message: "Forbidden"};
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
const { setSurvey, transaction } = require('../connection');
|
||||
const checkPermission = require('./check-permission');
|
||||
|
||||
async function post (projectId, path, payload, opts = {}, role) {
|
||||
async function post (projectId, path, payload, opts = {}, operations = []) {
|
||||
const client = await setSurvey(projectId);
|
||||
const [key, ...jsonpath] = (path||"").split("/").filter(i => i.length);
|
||||
|
||||
if (!checkPermission(key, "post", role)) {
|
||||
if (!checkPermission(key, "post", operations)) {
|
||||
throw {status: 403, message: "Forbidden"};
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
const { setSurvey, transaction } = require('../connection');
|
||||
const checkPermission = require('./check-permission');
|
||||
|
||||
async function put (projectId, path, payload, opts = {}, role) {
|
||||
async function put (projectId, path, payload, opts = {}, operations = []) {
|
||||
const client = await setSurvey(projectId);
|
||||
const [key, ...jsonpath] = (path??"").split("/").filter(i => i.length);
|
||||
|
||||
if (role !== null && !checkPermission(key, "put", role)) {
|
||||
if (!checkPermission(key, "put", operations)) {
|
||||
throw {status: 403, message: "Forbidden"};
|
||||
return;
|
||||
}
|
||||
|
||||
4
lib/www/server/lib/db/linename/index.js
Normal file
4
lib/www/server/lib/db/linename/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
properties: require('./properties'),
|
||||
post: require('./post'),
|
||||
};
|
||||
39
lib/www/server/lib/db/linename/post.js
Normal file
39
lib/www/server/lib/db/linename/post.js
Normal file
@@ -0,0 +1,39 @@
|
||||
const { setSurvey, transaction } = require('../connection');
|
||||
const lib = require('../plan/lib');
|
||||
|
||||
async function post (projectId, payload, opts = {}) {
|
||||
|
||||
const client = await setSurvey(projectId);
|
||||
try {
|
||||
|
||||
if (!payload.sequence) {
|
||||
payload.sequence = await lib.getSequence(client);
|
||||
}
|
||||
// if (!payload.ts0 || !payload.ts1) {
|
||||
// const ts = await lib.getTimestamps(client, projectId, payload);
|
||||
// if (!payload.ts0) {
|
||||
// payload.ts0 = ts.ts0;
|
||||
// }
|
||||
// if (!payload.ts1) {
|
||||
// payload.ts1 = ts.ts1;
|
||||
// }
|
||||
// }
|
||||
const name = await lib.getLineName(client, projectId, payload);
|
||||
|
||||
return name;
|
||||
} catch (err) {
|
||||
if (err.code && Math.trunc(err.code/1000) == 23) {
|
||||
// Class 23 — Integrity Constraint Violation
|
||||
console.error(err);
|
||||
throw { status: 400, message: "Malformed request" };
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
module.exports = post;
|
||||
15
lib/www/server/lib/db/linename/properties/get.js
Normal file
15
lib/www/server/lib/db/linename/properties/get.js
Normal file
@@ -0,0 +1,15 @@
|
||||
const lib = require('../../plan/lib');
|
||||
|
||||
async function get (projectId, payload, opts = {}) {
|
||||
|
||||
try {
|
||||
|
||||
return await lib.getLineNameProperties();
|
||||
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = get;
|
||||
3
lib/www/server/lib/db/linename/properties/index.js
Normal file
3
lib/www/server/lib/db/linename/properties/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
get: require('./get'),
|
||||
};
|
||||
@@ -1,6 +1,12 @@
|
||||
const YAML = require('yaml');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
|
||||
const alert = require("../../../alerts");
|
||||
const configuration = require('../../configuration');
|
||||
|
||||
let lineNameProperties;
|
||||
|
||||
async function getDistance (client, payload) {
|
||||
const text = `
|
||||
SELECT ST_Distance(pp0.geometry, pp1.geometry) distance
|
||||
@@ -88,8 +94,6 @@ async function getPlanned (client) {
|
||||
|
||||
|
||||
async function getLineName (client, projectId, payload) {
|
||||
// FIXME TODO Get line name script from configuration
|
||||
// Ref.: https://gitlab.com/wgp/dougal/software/-/issues/129
|
||||
|
||||
// This is to monitor #165
|
||||
// https://gitlab.com/wgp/dougal/software/-/issues/incident/165
|
||||
@@ -97,6 +101,36 @@ async function getLineName (client, projectId, payload) {
|
||||
alert({function: "getLineName", client, projectId, payload});
|
||||
}
|
||||
|
||||
const lineNameBuilder = await configuration.get(projectId, "online/line/lineNameBuilder");
|
||||
const fields = lineNameBuilder?.fields;
|
||||
|
||||
if (fields) {
|
||||
const properties = await getLineNameProperties();
|
||||
const values = await getLineNameValues(client, projectId, payload, lineNameBuilder?.values);
|
||||
return buildLineName(properties, fields, values, payload?.name);
|
||||
} else {
|
||||
// TODO send a user notification via WS to let them know
|
||||
// they haven't configured the line name parameters
|
||||
}
|
||||
|
||||
// return undefined
|
||||
}
|
||||
|
||||
/** Get line properties that go into making a line name.
|
||||
*
|
||||
* The properties are defined in a separate YAML file for
|
||||
* convenience.
|
||||
*/
|
||||
async function getLineNameProperties () {
|
||||
if (!lineNameProperties) {
|
||||
const buffer = await fs.readFile(path.join(__dirname, 'linename-properties.yaml'));
|
||||
lineNameProperties = YAML.parse(buffer.toString());
|
||||
}
|
||||
|
||||
return lineNameProperties;
|
||||
}
|
||||
|
||||
async function getLineNameValues (client, projectId, payload, otherValues = {}) {
|
||||
const planned = await getPlanned(client);
|
||||
const previous = await getSequencesForLine(client, payload.line);
|
||||
const attempt = planned.filter(r => r.line == payload.line).concat(previous).length;
|
||||
@@ -104,9 +138,79 @@ async function getLineName (client, projectId, payload) {
|
||||
const incr = p.lsp > p.fsp;
|
||||
const sequence = p.sequence || 1;
|
||||
const line = p.line;
|
||||
return `${incr?"1":"2"}0${line}${attempt}${sequence.toString().padStart(3, "0")}S00000`;
|
||||
|
||||
return {
|
||||
...structuredClone(otherValues),
|
||||
line_number: payload.line,
|
||||
sequence_number: payload.sequence || 1,
|
||||
original_sequence: payload.meta?.original_sequence,
|
||||
pass_number: attempt,
|
||||
is_prime: attempt == 0,
|
||||
is_reshoot: payload.meta?.is_reshoot ?? (!payload.meta?.is_infill && attempt > 0),
|
||||
is_infill: payload.meta?.is_infill ?? false,
|
||||
direction: null, // TODO
|
||||
is_incrementing: incr
|
||||
};
|
||||
}
|
||||
|
||||
/** Compute the string representation of a line name field
|
||||
*/
|
||||
function fieldValue (properties, field, values) {
|
||||
let value;
|
||||
|
||||
if (field.item == "text") {
|
||||
value = field.value;
|
||||
} else if (properties[field.item]?.type == "boolean") {
|
||||
if (values[field.item] === field.when) {
|
||||
value = field.value;
|
||||
}
|
||||
} else {
|
||||
value = values[field.item];
|
||||
}
|
||||
|
||||
if (value != null) {
|
||||
|
||||
if (properties[field.item]?.type == "number") {
|
||||
if (field.scale_multiplier != null) {
|
||||
value *= field.scale_multiplier;
|
||||
}
|
||||
if (field.scale_offset != null) {
|
||||
value += field.scale_offset;
|
||||
}
|
||||
|
||||
if (field.format == "integer") {
|
||||
value = Math.round(value);
|
||||
}
|
||||
}
|
||||
|
||||
value = String(value);
|
||||
if (field.pad_side == "left") {
|
||||
value = value.padStart(field.length, field.pad_string ?? " ");
|
||||
} else if (field.pad_side == "right") {
|
||||
value = value.padEnd(field.length, field.pad_string ?? " ");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Build a line name out of its component properties, fields and values.
|
||||
*
|
||||
* NOTE: This is the same function as available client-side on
|
||||
* `fixed-string-encoder.vue`. Consider merging them.
|
||||
*/
|
||||
function buildLineName (properties, fields, values, str = "") {
|
||||
const length = fields.reduce( (acc, cur) => (cur.offset + cur.length) > acc ? (cur.offset + cur.length) : acc, str.length )
|
||||
str = str.padEnd(length);
|
||||
for (const field of fields) {
|
||||
const value = fieldValue(properties, field, values);
|
||||
if (value != null) {
|
||||
str = str.slice(0, field.offset) + value + str.slice(field.offset + field.length);
|
||||
}
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getDistance,
|
||||
@@ -114,5 +218,8 @@ module.exports = {
|
||||
getTimestamps,
|
||||
getSequencesForLine,
|
||||
getPlanned,
|
||||
getLineName
|
||||
getLineNameProperties,
|
||||
getLineNameValues,
|
||||
getLineName,
|
||||
buildLineName
|
||||
};
|
||||
|
||||
51
lib/www/server/lib/db/plan/lib/linename-properties.yaml
Normal file
51
lib/www/server/lib/db/plan/lib/linename-properties.yaml
Normal file
@@ -0,0 +1,51 @@
|
||||
#
|
||||
# These are the properties that can be used to build
|
||||
# line names.
|
||||
#
|
||||
|
||||
line_number:
|
||||
summary: Line number
|
||||
description: The sailline number that is to be acquired
|
||||
type: number
|
||||
format: integer
|
||||
sequence_number:
|
||||
summary: Sequence
|
||||
description: The sequence number that will be assigned to this line
|
||||
type: number
|
||||
format: integer
|
||||
original_sequence:
|
||||
summary: Original sequence
|
||||
description: The original sequence number of the line that is being reshot
|
||||
type: number
|
||||
format: integer
|
||||
pass_number:
|
||||
summary: Pass number
|
||||
description: The number of times this line, or section of line, has been shot
|
||||
type: number
|
||||
format: integer
|
||||
is_prime:
|
||||
summary: Prime line
|
||||
description: Whether this is the first time this line is being acquired
|
||||
type: boolean
|
||||
is_reshoot:
|
||||
summary: Reshoot
|
||||
description: Whether this is a reshoot (mutually exclusive with `is_prime` and `is_infill`)
|
||||
type: boolean
|
||||
is_infill:
|
||||
summary: Infill line
|
||||
description: Whether this is an infill line (mutually exclusive with `is_prime` and `is_reshoot`)
|
||||
type: boolean
|
||||
direction:
|
||||
summary: Line azimuth
|
||||
direction: The line azimuth in the Incrementing shotpoints direction
|
||||
type: number
|
||||
format: float
|
||||
is_incrementing:
|
||||
summary: Incrementing
|
||||
description: Whether the line is being shot low to high point numbers or vice versa
|
||||
type: boolean
|
||||
text:
|
||||
summary: Fixed text
|
||||
description: Arbitrary user-entered text (line prefix, suffix, etc.)
|
||||
type: text
|
||||
|
||||
@@ -7,6 +7,7 @@ async function get () {
|
||||
name,
|
||||
schema,
|
||||
COALESCE(meta->'groups', '[]'::jsonb) AS groups,
|
||||
COALESCE(meta->'organisations', '{}'::jsonb) AS organisations,
|
||||
COALESCE(meta->'archived', 'false'::jsonb) AS archived
|
||||
FROM public.projects;
|
||||
`;
|
||||
|
||||
@@ -6,4 +6,5 @@ module.exports = {
|
||||
delete: require('./delete'),
|
||||
summary: require('./summary'),
|
||||
configuration: require('./configuration'),
|
||||
organisations: require('./organisations'),
|
||||
}
|
||||
|
||||
92
lib/www/server/lib/db/project/organisations.js
Normal file
92
lib/www/server/lib/db/project/organisations.js
Normal file
@@ -0,0 +1,92 @@
|
||||
const { setSurvey, pool } = require('../connection');
|
||||
const vessel = require('../vessel');
|
||||
const { access, operations } = require('../../organisations');
|
||||
const { ALERT, ERROR, WARNING, NOTICE, INFO, DEBUG } = require('DOUGAL_ROOT/debug')(__filename);
|
||||
|
||||
// Cache the per-project organisations access here
|
||||
let projectsCache;
|
||||
|
||||
|
||||
// Prime the cache directly from the database
|
||||
async function read () {
|
||||
DEBUG("Reading projects from database");
|
||||
const text = `
|
||||
SELECT
|
||||
pid,
|
||||
meta->'organisations' AS organisations
|
||||
FROM public.projects;
|
||||
`;
|
||||
const res = await pool.query(text);
|
||||
return Object.fromEntries(res.rows.map( row => [ row.pid, row.organisations ] ));
|
||||
}
|
||||
|
||||
/*
|
||||
* Called from the database events project configuration
|
||||
* change handler
|
||||
*/
|
||||
function setCache (projects) {
|
||||
DEBUG("Setting project organisations cache via setCache()");
|
||||
projectsCache = Object.fromEntries(Object.entries(projects).map( row => [ row[0], row[1].organisations ] ));
|
||||
}
|
||||
|
||||
/*
|
||||
* Use this function to read `projectsCache`. Do NOT
|
||||
* read `projectsCache` directly as it might be undefined
|
||||
*/
|
||||
async function projectOrganisations (pid) {
|
||||
if (!projectsCache) {
|
||||
DEBUG("Priming projectsCache");
|
||||
projectsCache = await read();
|
||||
}
|
||||
|
||||
return projectsCache[pid] ?? {}; // Every project should have an `organisations` property, but…
|
||||
}
|
||||
|
||||
/*
|
||||
* Returns organisations associated with a vessel.
|
||||
* If no `vesselID` is provided, returns own vessel.
|
||||
*/
|
||||
async function vesselOrganisations (vesselID) {
|
||||
return (await vessel.info(vesselID))?.organisations ?? {};
|
||||
}
|
||||
|
||||
/** Check whether a user has access to the project given by `pid`.
|
||||
*
|
||||
* If `pid` is `null`, check against vessel access.
|
||||
*/
|
||||
async function orgAccess (userOrgs, pid, operation) {
|
||||
const itemOrgs = pid === null
|
||||
? await vesselOrganisations()
|
||||
: await projectOrganisations(pid);
|
||||
|
||||
return access(userOrgs, itemOrgs, operation);
|
||||
}
|
||||
|
||||
/** Check to which operations the user has access to in the
|
||||
* project given by 'pid`.
|
||||
*
|
||||
* If `pid` is `null`, check against vessel access.
|
||||
*/
|
||||
async function allowedOperations (userOrgs, pid) {
|
||||
const itemOrgs = pid === null
|
||||
? await vesselOrganisations()
|
||||
: await projectOrganisations(pid);
|
||||
|
||||
return operations(userOrgs, itemOrgs);
|
||||
}
|
||||
|
||||
/*
|
||||
* Filter an array of objects by organisation access to a given operation
|
||||
*/
|
||||
function orgFilter (userOrgs, list, operation, fn = (item) => item.organisations ) {
|
||||
return list.filter ( (item) => access(userOrgs, fn(item), operation) );
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
setCache,
|
||||
projectOrganisations,
|
||||
vesselOrganisations,
|
||||
orgAccess,
|
||||
allowedOperations,
|
||||
orgFilter
|
||||
};
|
||||
216
lib/www/server/lib/db/user/User.js
Normal file
216
lib/www/server/lib/db/user/User.js
Normal file
@@ -0,0 +1,216 @@
|
||||
const util = require('util');
|
||||
const hashPassword = require('./hashPassword');
|
||||
const { User } = require('@dougal/user');
|
||||
const { pool } = require('../connection');
|
||||
// const Organisations = require('../../organisations/Organisations');
|
||||
|
||||
|
||||
class ServerUser extends User {
|
||||
|
||||
client // PostgreSQL client connection
|
||||
|
||||
constructor (data, client) {
|
||||
super (data);
|
||||
if (data?.password) this.password = data.password;
|
||||
if (data?.hash) this.values.hash = data.hash;
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
/*
|
||||
* Getters
|
||||
*/
|
||||
|
||||
get hash () {
|
||||
return this.values.hash;
|
||||
}
|
||||
|
||||
/*
|
||||
* Setters
|
||||
*/
|
||||
|
||||
set password (v) {
|
||||
if (v?.length) {
|
||||
this.values.hash = hashPassword(this.id, v);
|
||||
} else {
|
||||
delete this.values.hash;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Validation methods
|
||||
*/
|
||||
|
||||
/*
|
||||
* Filtering methods
|
||||
*/
|
||||
|
||||
/*
|
||||
* General methods
|
||||
*/
|
||||
|
||||
checkPassword (text) {
|
||||
return Boolean(this.hash && hashPassword(this.id, text) == this.hash);
|
||||
}
|
||||
|
||||
/*
|
||||
* Conversion and presentation methods
|
||||
*/
|
||||
|
||||
toJSON () {
|
||||
return {
|
||||
...super.toJSON(),
|
||||
hash: this.hash
|
||||
}
|
||||
}
|
||||
|
||||
[util.inspect.custom] (depth, options) {
|
||||
return `User ${this.id} (${this.name}) { ${this.organisations.entries().map( ([k, v]) => {
|
||||
let str = "";
|
||||
str += k+": ";
|
||||
str += v.read ? "r" : "-";
|
||||
str += v.write ? "w" : "-";
|
||||
str += v.edit ? "e" : "-";
|
||||
return str;
|
||||
}).join("; ")} }`;
|
||||
}
|
||||
|
||||
/*
|
||||
* SQL methods
|
||||
*/
|
||||
|
||||
/** Read user(s) from the database
|
||||
*
|
||||
* @a client is a database connection
|
||||
* @a id is an optional user ID
|
||||
*
|
||||
* If `id` is not provided, it will return
|
||||
* all users in the database.
|
||||
*/
|
||||
static async fromSQL (client, id) {
|
||||
if (!client) {
|
||||
client = pool;
|
||||
}
|
||||
|
||||
if (id) {
|
||||
// Get a specific user
|
||||
const text = `
|
||||
SELECT key, data, last_modified
|
||||
FROM public.keystore
|
||||
WHERE type = 'user' AND key = $1;
|
||||
`;
|
||||
|
||||
const res = await client.query(text, [ id ]);
|
||||
const obj = res.rows[0];
|
||||
|
||||
if (obj) {
|
||||
return new ServerUser({...obj.data, id: obj.key, last_modified: obj.last_modified}, client);
|
||||
}
|
||||
} else {
|
||||
// Get all users
|
||||
const text = `
|
||||
SELECT key, data, last_modified
|
||||
FROM public.keystore
|
||||
WHERE type = 'user';
|
||||
`;
|
||||
|
||||
const res = await client.query(text);
|
||||
|
||||
return res.rows?.map(row => new ServerUser({
|
||||
...row.data,
|
||||
id: row.key,
|
||||
last_modified: row.last_modified
|
||||
}, client));
|
||||
}
|
||||
}
|
||||
|
||||
static async authenticateSQL (name, password, client) {
|
||||
if (!client) {
|
||||
client = pool;
|
||||
}
|
||||
|
||||
const text = `
|
||||
SELECT key, data, last_modified
|
||||
FROM public.keystore
|
||||
WHERE type = 'user'
|
||||
AND data->>'name' = $1
|
||||
ORDER BY key;
|
||||
`;
|
||||
|
||||
const res = await client.query(text, [name]);
|
||||
for (const row of res.rows) {
|
||||
const user = new ServerUser({
|
||||
...row.data,
|
||||
id: row.key,
|
||||
last_modified: row.last_modified
|
||||
}, client);
|
||||
if (user.checkPassword(password)) {
|
||||
user.password = null; // Remove the hash from the reply
|
||||
return user;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Save this user to the database
|
||||
*/
|
||||
async save () {
|
||||
if (!this.client) {
|
||||
this.client = pool;
|
||||
}
|
||||
|
||||
const data = this.toJSON();
|
||||
const id = data.id;
|
||||
delete data.id;
|
||||
|
||||
const text = `
|
||||
INSERT INTO public.keystore AS t
|
||||
(type, key, data)
|
||||
VALUES ('user', $1, $2::jsonb)
|
||||
ON CONFLICT (type, key)
|
||||
DO UPDATE SET
|
||||
data = EXCLUDED.data
|
||||
WHERE
|
||||
t.type = EXCLUDED.type AND t.key = EXCLUDED.key
|
||||
RETURNING key, data, last_modified;
|
||||
`;
|
||||
|
||||
const res = await this.client?.query(text, [ id, data ]);
|
||||
|
||||
if (res?.rows?.length) {
|
||||
const row = res.rows[0];
|
||||
return new ServerUser({
|
||||
...row.data,
|
||||
id: row.key,
|
||||
last_modified: row.last_modified
|
||||
}, this.client);
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete this user from the database
|
||||
*/
|
||||
async remove () {
|
||||
if (!this.client) {
|
||||
this.client = pool;
|
||||
}
|
||||
|
||||
const text = `
|
||||
DELETE FROM public.keystore
|
||||
WHERE type = 'user' AND key = $1;
|
||||
`;
|
||||
|
||||
const res = await this.client?.query(text, [ this.id ]);
|
||||
console.log("ID", this.id);
|
||||
console.log(res);
|
||||
return res.rowCount;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = ServerUser; // CJS export
|
||||
}
|
||||
|
||||
// ESM export
|
||||
if (typeof exports !== 'undefined' && !exports.default) {
|
||||
exports.default = ServerUser; // ESM export
|
||||
}
|
||||
9
lib/www/server/lib/db/user/hashPassword.js
Normal file
9
lib/www/server/lib/db/user/hashPassword.js
Normal file
@@ -0,0 +1,9 @@
|
||||
const crypto = require('crypto');
|
||||
|
||||
function hashPassword (userID, password) {
|
||||
return crypto
|
||||
.pbkdf2Sync(password, 'Dougal'+userID, 10712, 48, 'sha512')
|
||||
.toString('base64');
|
||||
}
|
||||
|
||||
module.exports = hashPassword;
|
||||
62
lib/www/server/lib/db/user/index.js
Normal file
62
lib/www/server/lib/db/user/index.js
Normal file
@@ -0,0 +1,62 @@
|
||||
const { pool } = require('../connection');
|
||||
|
||||
const IPUser = Symbol("IPUser");
|
||||
const HostUser = Symbol("HostUser");
|
||||
const NamedUser = Symbol("NamedUser");
|
||||
|
||||
async function userOfType(type, opts = {}) {
|
||||
opts = {active: true, ...opts};
|
||||
|
||||
let propertyName;
|
||||
switch (type) {
|
||||
case IPUser:
|
||||
propertyName = "ip";
|
||||
break;
|
||||
case HostUser:
|
||||
propertyName = "host";
|
||||
break;
|
||||
case NamedUser:
|
||||
propertyName = "hash";
|
||||
break;
|
||||
}
|
||||
|
||||
if (propertyName) {
|
||||
|
||||
const text = `
|
||||
SELECT *
|
||||
FROM keystore
|
||||
WHERE type = 'user' AND data ? $1
|
||||
`;
|
||||
|
||||
const res = await pool.query(text, [ propertyName ]);
|
||||
|
||||
const users = res.rows.map( row => ({
|
||||
...row.data,
|
||||
id: row.key,
|
||||
hash: (opts.insecure === true ? row.data.hash : undefined)
|
||||
}) ).filter( row => opts.active == null || opts.active === row.active );
|
||||
|
||||
return users;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
async function ip (opts = {}) {
|
||||
return await userOfType(IPUser, opts);
|
||||
}
|
||||
|
||||
async function host (opts = {}) {
|
||||
return await userOfType(HostUser, opts);
|
||||
}
|
||||
|
||||
async function named (opts = {}) {
|
||||
return await userOfType(NamedUser, opts);
|
||||
}
|
||||
|
||||
|
||||
|
||||
module.exports = {
|
||||
ip,
|
||||
host,
|
||||
named,
|
||||
}
|
||||
23
lib/www/server/lib/db/vessel/index.js
Normal file
23
lib/www/server/lib/db/vessel/index.js
Normal file
@@ -0,0 +1,23 @@
|
||||
const { pool } = require('../connection');
|
||||
|
||||
/** Retrieve vessel info.
|
||||
*
|
||||
* @a vesselID The ID of the target vessel. Defaults to `ego`
|
||||
* which is this server's parent vessel.
|
||||
*/
|
||||
async function info (vesselID = "ego") {
|
||||
|
||||
const text = `
|
||||
SELECT *
|
||||
FROM keystore
|
||||
WHERE type = 'vessel' AND key = $1;
|
||||
`;
|
||||
|
||||
const res = await pool.query(text, [ vesselID ]);
|
||||
return res.rows[0]?.data ?? {};
|
||||
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
info
|
||||
};
|
||||
@@ -1,9 +1,9 @@
|
||||
const crypto = require('crypto');
|
||||
const JWT = require('jsonwebtoken');
|
||||
const cfg = require('./config');
|
||||
const ServerUser = require('./db/user/User');
|
||||
|
||||
|
||||
function checkValidCredentials ({user, password, jwt}) {
|
||||
async function checkValidCredentials ({user, password, jwt}) {
|
||||
if (jwt) {
|
||||
try {
|
||||
const decoded = JWT.verify(jwt, cfg.jwt.secret, {maxAge: "1d"});
|
||||
@@ -16,16 +16,7 @@ function checkValidCredentials ({user, password, jwt}) {
|
||||
return; // Invalid JWT
|
||||
}
|
||||
} else if (user && password) {
|
||||
const hash = crypto
|
||||
.pbkdf2Sync(password, 'Dougal'+user, 10712, 48, 'sha512')
|
||||
.toString('base64');
|
||||
for (const credentials of cfg._("global.users.login.user") || []) {
|
||||
if (credentials.name == user && credentials.hash == hash) {
|
||||
const payload = {...credentials};
|
||||
delete payload.hash;
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
return (await ServerUser.authenticateSQL(user, password))?.toJSON();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
47
lib/www/server/lib/organisations/access.js
Normal file
47
lib/www/server/lib/organisations/access.js
Normal file
@@ -0,0 +1,47 @@
|
||||
|
||||
/** Check whether the user has access to the required operation
|
||||
* @a userOrgs is the user's organisations
|
||||
* @a projectOrgs is the project's organisations
|
||||
* @a operation is the desired operation (read, write, etc.)
|
||||
*
|
||||
* @return `true` is user has access to `operation` through
|
||||
* a common organisation, `false` otherwise.
|
||||
*/
|
||||
function access (userOrgs = {}, projectOrgs = {}, operation = undefined) {
|
||||
// console.log("userOrgs", userOrgs);
|
||||
// console.log("projectOrgs", projectOrgs);
|
||||
// console.log("operation", operation);
|
||||
|
||||
for (const userOrg in userOrgs) {
|
||||
if (userOrg in projectOrgs) {
|
||||
// Found an organisation in common between user and project
|
||||
// (there might be many)
|
||||
if (projectOrgs[userOrg][operation] == true && userOrgs[userOrg][operation] == true) {
|
||||
// For this one, the project grants the required operation
|
||||
// access to the organisation, and the organisation grants the
|
||||
// required operation access to the user, so authorisation is
|
||||
// given.
|
||||
// console.log("Access granted via organisation", userOrg, projectOrgs[userOrg][operation], userOrgs[userOrg][operation]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ("*" in userOrgs) {
|
||||
// Aha! A wildcard user
|
||||
// Return true if at least one organisation grants access
|
||||
// to this operation
|
||||
// console.log("Checking via wildcard");
|
||||
return (Object.values(projectOrgs).some( org => org[operation] ))
|
||||
}
|
||||
return false;
|
||||
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = access; // CJS export
|
||||
}
|
||||
|
||||
// ESM export
|
||||
if (typeof exports !== 'undefined' && !exports.default) {
|
||||
exports.default = access; // ESM export
|
||||
}
|
||||
4
lib/www/server/lib/organisations/index.js
Normal file
4
lib/www/server/lib/organisations/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
access: require('./access.js'),
|
||||
operations: require('./operations.js'),
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user