Compare commits

...

89 Commits

Author SHA1 Message Date
D. Berge
302642f88d Fix JWT renewal over websocket 2025-07-25 14:21:26 +02:00
D. Berge
48e1369088 Fix host based authentication 2025-07-25 14:03:43 +02:00
D. Berge
daa700e7dc Add (temporarily disabled) menu option for vessel config.
The idea is to have a frontend access to a screen where duly
authorised users can modify vessel-wide configuration parameters.
2025-07-25 14:01:49 +02:00
D. Berge
8db2c8ce25 Use access rights mixin in Equipment view 2025-07-25 13:36:16 +02:00
D. Berge
890e48e078 Revert "Don't refresh projects if no user is logged in."
This reverts commit 3a0f720f2f.
2025-07-25 13:35:35 +02:00
D. Berge
11829555cf Add <v-tooltip/> showing permissions.
Hovering over the user avatar or a project name in the breadcrumbs
shows a tooltip with the relevant permissions.
2025-07-25 13:33:59 +02:00
D. Berge
07d8e97f74 Fix Markdown functions in root component 2025-07-25 13:32:30 +02:00
D. Berge
fc379aba14 Silence errors when refreshing projects.
We use this endpoint also to do autologins, so HTTP 403's are not
unexpected.
2025-07-25 13:31:28 +02:00
D. Berge
8cbacb9aa7 Allow silencing API request errors.
The {silent: true} option in the new `opts` argument to the
`api` action does the trick.
2025-07-25 13:30:26 +02:00
D. Berge
acb59035e4 Add missing file 2025-07-25 13:29:39 +02:00
D. Berge
b7d0ee7da7 Remove dead code from the frontend 2025-07-25 11:02:24 +02:00
D. Berge
3a0f720f2f Don't refresh projects if no user is logged in.
Avoids a 403.
2025-07-25 10:43:08 +02:00
D. Berge
6cf6fe29f4 Improve presentation of organisation component in project settings 2025-07-24 23:04:44 +02:00
D. Berge
6f0f2dadcc Add "actions" slot to DougalOrganisations component 2025-07-24 23:04:15 +02:00
D. Berge
64fba1adc3 Add project permissions tooltip to breadcrumbs 2025-07-24 23:03:41 +02:00
D. Berge
3ea82cb660 Fix reading of credentials for issuing JWT 2025-07-24 23:03:05 +02:00
D. Berge
84c1385f88 Refactor class User (clean up) 2025-07-24 23:02:30 +02:00
D. Berge
b1b7332216 Add access mixin to Project and use in child component 2025-07-24 20:43:22 +02:00
D. Berge
8e7451e17a Adapt the access rights mixin to new user management code 2025-07-24 20:42:25 +02:00
D. Berge
bdeb2b8742 Show organisation membership in user avatar 2025-07-24 20:41:07 +02:00
D. Berge
ccfabf84f7 Add user management page to frontend 2025-07-24 20:40:18 +02:00
D. Berge
5d4e219403 Refactor Vuex store to adapt to new User class 2025-07-24 20:38:51 +02:00
D. Berge
3b7e4c9f0b Add client-side User class derived from @dougal/user.
Adds methods to communicate with the backend.
2025-07-24 20:37:50 +02:00
D. Berge
683f5680b1 Add organisations configuration section to project settings UI 2025-07-24 20:36:45 +02:00
D. Berge
ce901a03a1 Add component for editing users 2025-07-24 20:35:46 +02:00
D. Berge
f8e5b74c1a Add components for editing organisations settings 2025-07-24 20:35:17 +02:00
D. Berge
ec41d26a7a Use @dougal/user, @dougal/organisations modules in frontend 2025-07-24 20:32:25 +02:00
D. Berge
386fd59900 Update API to handle permissions checks on most endpoints 2025-07-24 19:24:40 +02:00
D. Berge
e47020a21e Add /user endpoints to API 2025-07-24 19:23:43 +02:00
D. Berge
b8f58ac67c Add FIXME 2025-07-24 19:20:58 +02:00
D. Berge
b3e27ed1b9 Refactor auth.authentify.
We now get the user's details directly from the JWT token.
2025-07-24 19:15:36 +02:00
D. Berge
f5441d186f Refactor auth.access middleware.
It users @dougal/user and @dougal/organisations classes.
2025-07-24 19:14:19 +02:00
D. Berge
d58bc4d62e Remove unused code 2025-07-24 19:13:17 +02:00
D. Berge
01d1691def Fix login endpoint (checkValidCredentials is now async) 2025-07-24 19:09:39 +02:00
D. Berge
bc444fc066 Add dependency to project organisations cache 2025-07-24 18:48:22 +02:00
D. Berge
989ec84852 Refactor JWT credentials check to use class User 2025-07-24 18:36:34 +02:00
D. Berge
065f6617af Add class ServerUser derived from User.
Used on the backend. Adds methods to hash and check passwords and
to read from and save user data to the database.
2025-07-24 18:31:51 +02:00
D. Berge
825530c1fe Use @dougal/user, @dougal/organisations modules in backend 2025-07-24 18:27:59 +02:00
D. Berge
1ef8eb871f Add @dougal/user NodeJS module.
Abstracts the concept of User in the new permissions model.
2025-07-24 18:22:44 +02:00
D. Berge
2e9c603ab8 Add @dougal/organisations NodeJS module.
Abstracts the concept of Organisations in the new permissions model.
2025-07-24 18:21:02 +02:00
D. Berge
2657c42dcc Fix export statement 2025-07-13 11:13:31 +02:00
D. Berge
63e6af545a Fix typo 2025-07-13 11:13:09 +02:00
D. Berge
d6fb7404b1 Adapt version.get middleware to new permissions approach 2025-07-13 00:07:52 +02:00
D. Berge
8188766a81 Refactor access to info table.
To adapt to new permissions system.
2025-07-13 00:07:05 +02:00
D. Berge
b7ae657137 Add auth.operations middleware.
Adds an array of allowed operations on given context to the request
under `req.user.operations`.
2025-07-13 00:02:48 +02:00
D. Berge
1295ec2ee3 Add function to return allowed operations in a given context 2025-07-13 00:01:15 +02:00
D. Berge
7c6d3fe5ee Check permissions against vessel if not on a project endpoint 2025-07-12 16:49:10 +02:00
D. Berge
15570e0f3d orgAccess(user, null, op) returns vessel access permissions.
If instead of a project ID, orgAccess receives `null`, it will
check permissions against the installation's own vessel rather
than against a specific project.
2025-07-12 16:47:39 +02:00
D. Berge
d551e67042 Add vesselOrganisations() function 2025-07-12 16:47:10 +02:00
D. Berge
6b216f7406 Add library function to retrieve vessel information.
In the `keystore` table, we now store information for our own
vessel (usually, where the Dougal server is installed). This
is an access function to retrieve that information.

The info stored for the vessel looks like this:

```yaml
type: vessel
key: ego
data:
    imo: 9631890
    mmsi: 257419000
    name: Havila Charisma
    contacts:
        -
            name: HC OM
            phone: tel:+47123456789
            email: hc.om@magseisfairfield.com
    organisations:
        Havila Charisma:
            read: true
            write: true
            edit: true
```
2025-07-12 16:42:28 +02:00
D. Berge
a7e02c526b Add function argument defaults.
This will cause the function to return a safe (false) value
rather than erroring.
2025-07-12 16:40:18 +02:00
D. Berge
55855d66e9 Remove dead code 2025-07-12 12:14:12 +02:00
D. Berge
ae79d90fef Remove obsolete Vuex getters 2025-07-12 11:31:38 +02:00
D. Berge
c8b2047483 Refactor client-side access checks.
Go from a Vuex based to a mixin based approach.
2025-07-12 11:31:38 +02:00
D. Berge
d21cde20fc Add mixin to check access rights client-side.
This replaces the Vuex getters approach (writeaccess, adminaccess)
which, as access rights are no longer global but dependent on each
project's settings, are no longer appropriate.
2025-07-12 11:31:38 +02:00
D. Berge
10580ea3ec Create server-side organisations module 2025-07-12 11:31:38 +02:00
D. Berge
25f83d1eb3 Share access() function between front and back end.
This is so that any changes to the code are reflected on both sides.
2025-07-12 11:31:38 +02:00
D. Berge
dc294b5b50 Change prefix used for storing user preferences.
The `role` value no longer exists; we're replacing that with the
user ID.
2025-07-12 11:31:38 +02:00
D. Berge
b035d3481c Ensure users have at least read access to most endpoints 2025-07-11 22:49:28 +02:00
D. Berge
ca4a14ffd9 Use new orgs based method for authorisation 2025-07-11 22:48:44 +02:00
D. Berge
d77f7f66db Refresh organisations cache on project update 2025-07-11 22:48:06 +02:00
D. Berge
6b6f545b9f Filter list of projects to only those readable by user 2025-07-11 22:47:32 +02:00
D. Berge
bdf62e2d8b Show project orgs in projects list 2025-07-11 22:46:47 +02:00
D. Berge
1895168889 Show user orgs in avatar 2025-07-11 22:46:47 +02:00
D. Berge
8c875ea2f9 Return organisations as part of the projects listing 2025-07-11 22:46:47 +02:00
D. Berge
addbe2d572 Refactor user authentication code to use database 2025-07-11 22:46:47 +02:00
D. Berge
85f092b9e1 Upgrade minimum required database version 2025-07-11 22:46:47 +02:00
D. Berge
eb99d74e4a Add database upgrade file 38.
Adds default user (superuser).
2025-07-11 22:46:47 +02:00
D. Berge
e65afdcaa1 Add database upgrade file 37.
Creates `keystore` table.
2025-07-11 22:46:47 +02:00
D. Berge
0b7e9e1d01 Add functions to check operation access via organisations 2025-07-11 22:46:47 +02:00
D. Berge
9ad17de4cb Merge branch '76-add-configuration-gui' into 'devel'
Resolve "Add configuration GUI"

Closes #294, #295, #296, #298, #76, #297, #129, #313, #312, #305, #264, #307, #303, #300, #301, #302, #290, #291, #292, and #293

See merge request wgp/dougal/software!17
2025-07-09 18:11:50 +00:00
D. Berge
071fd7438b Reimplement <dougal-project-settings-online-line-name-format/>.
Closes #297.
2025-07-09 16:45:35 +02:00
D. Berge
9cc21ba06a Mark planned reshoots as such 2025-07-09 16:40:48 +02:00
D. Berge
712b20c596 Add API endpoint to retrieve line name properties.
This will be needed by the configuration GUI.
2025-07-09 16:38:41 +02:00
D. Berge
8bbe3aee70 Make planned line names configurable.
Line names are made up based on:

* Certain properties defined by the system
* Values assigned to those properties either by the system
  or by the user (line number, sequence, attempt, etc.)
* A line format specification configured by the user for each
  project (`online.line.lineNameBuilder.fields`)

Closes #129.
2025-07-09 16:30:26 +02:00
D. Berge
dc22bb95fd Disable 'no_fire' test due to changes in Smartsource software 2025-07-03 11:48:42 +02:00
D. Berge
0ef2e60d15 Do not fail on non-existing property 2025-07-03 11:44:52 +02:00
D. Berge
289d50d9c1 Update caniuse database 2025-06-27 00:23:37 +02:00
D. Berge
3189a06d75 Change tcpdump flags to capture on any interface 2025-06-27 00:05:23 +02:00
D. Berge
9ef551db76 Fix logical→physical path conversion for absolute paths 2025-06-26 23:57:19 +02:00
D. Berge
e6669026fa Add validation messages for final P1/11 lineNameInfo 2025-06-26 23:48:35 +02:00
D. Berge
12082b91a3 Add validation messages for raw P1/11 lineNameInfo 2025-06-26 23:47:38 +02:00
D. Berge
7db9155899 Add default fields for raw P1/11 lineNameInfo 2025-06-26 23:46:49 +02:00
D. Berge
f8692afad3 Add named slots to DougalProjectSettingsFileMatchingParameters.
Used to display error or information messages.
2025-06-26 23:41:51 +02:00
D. Berge
028cab5188 Add default fields for raw P1/11 lineNameInfo 2025-06-26 23:41:00 +02:00
D. Berge
fc73fbfb9f Add GUI for editing lineNameInfo of final P1/111 2025-06-26 23:40:28 +02:00
D. Berge
96a8d3689a Add defaults for lineNameInfo text and fields 2025-06-26 23:39:47 +02:00
D. Berge
7a7106e735 Default to text if no field type is specified. 2024-08-22 18:44:24 +02:00
D. Berge
d5a10ca273 Allow also str as a field type specifier 2024-08-22 18:43:57 +02:00
106 changed files with 5055 additions and 623 deletions

View File

@@ -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]

View 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
--

View 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
--

View File

@@ -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"

View 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
}

View 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
}

View File

@@ -0,0 +1,5 @@
module.exports = {
Organisation: require('./Organisation'),
Organisations: require('./Organisations')
}

View 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": ""
}

View 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
}

View File

@@ -0,0 +1,4 @@
module.exports = {
User: require('./User')
}

View 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
}
}
}

View 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"
}
}

View File

@@ -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'
]
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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;

View File

@@ -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: {

View File

@@ -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", "");
}
},

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -127,7 +127,7 @@ export default {
},
computed: {
...mapGetters(['user', 'writeaccess', 'loading', 'serverEvent'])
...mapGetters(['user', 'loading', 'serverEvent'])
},
methods: {

View File

@@ -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'])
},

View 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>

View 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>

View File

@@ -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"

View File

@@ -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;
}
},

View File

@@ -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;
}
},

View File

@@ -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}));
}
}
},

View File

@@ -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>

View 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>

View 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;

View File

@@ -0,0 +1,5 @@
import User from './User'
export {
User
}

View File

@@ -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") {

View 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);
}
}
}

View File

@@ -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: {

View File

@@ -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;

View File

@@ -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);

View File

@@ -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

View File

@@ -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 };

View File

@@ -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'])
},

View File

@@ -240,7 +240,7 @@ export default {
return this.sequences[0]?.sequence;
},
...mapGetters(['user', 'preferences', 'writeaccess', 'loading', 'serverEvent'])
...mapGetters(['user', 'preferences', 'loading', 'serverEvent'])
},
methods: {

View File

@@ -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: {

View File

@@ -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})
},

View File

@@ -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: {

View File

@@ -20,9 +20,15 @@
<script>
import { mapActions, mapGetters } from 'vuex'
import AccessMixin from '@/mixins/access';
export default {
name: 'Project',
mixins: [
AccessMixin
],
components: {
},
data () {

View File

@@ -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 = [];

View File

@@ -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();
},

View File

@@ -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: {

View File

@@ -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`;

View 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>

View File

@@ -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 ]
},
//
});

View File

@@ -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);

View File

@@ -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
};

View File

@@ -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);
}
}
}

View File

@@ -2,3 +2,4 @@
exports.jwt = require('./jwt');
exports.authentify = require('./authentify');
exports.access = require('./access');
exports.operations = require('./operations'); // FIXME this is crap

View 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;

View File

@@ -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'),

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -0,0 +1,4 @@
module.exports = {
properties: require('./properties'),
post: require('./post'),
};

View 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);
}
};

View 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);
}
};

View File

@@ -0,0 +1,3 @@
module.exports = {
get: require('./get'),
};

View File

@@ -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

View File

@@ -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);
}
};

View File

@@ -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;

View 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);
}
};

View File

@@ -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);
}
};

View File

@@ -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'),
}

View 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);
}
};

View File

@@ -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();

View 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);
}
};

View File

@@ -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);
}
};

View File

@@ -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;
}

View File

@@ -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)) {

View File

@@ -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'),
};

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,4 @@
module.exports = {
properties: require('./properties'),
post: require('./post'),
};

View 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;

View 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;

View File

@@ -0,0 +1,3 @@
module.exports = {
get: require('./get'),
};

View File

@@ -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
};

View 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

View File

@@ -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;
`;

View File

@@ -6,4 +6,5 @@ module.exports = {
delete: require('./delete'),
summary: require('./summary'),
configuration: require('./configuration'),
organisations: require('./organisations'),
}

View 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
};

View 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
}

View 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;

View 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,
}

View 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
};

View File

@@ -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();
}
}

View 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
}

View 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