dev (#1)
Reviewed-on: #1 Co-authored-by: ChrisChrome <chris@chrischro.me> Co-committed-by: ChrisChrome <chris@chrischro.me>
This commit is contained in:
parent
5bcda71741
commit
13cfa7247f
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -143,3 +143,4 @@ vite.config.js.timestamp-*
|
||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
.vite/
|
.vite/
|
||||||
database.db
|
database.db
|
||||||
|
database.db*
|
||||||
73
_test_server_temp.js
Normal file
73
_test_server_temp.js
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
// Temporary test harness - deleted after verification. Points at a scratch copy of the DB so the
|
||||||
|
// real database.db is never touched.
|
||||||
|
require('dotenv').config({ quiet: true });
|
||||||
|
const express = require('express');
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const app = express();
|
||||||
|
const session = require('express-session');
|
||||||
|
const port = 3999;
|
||||||
|
|
||||||
|
const db = new sqlite3.Database('/tmp/database_full_test.db', (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Failed to connect to the database:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
console.log('Connected to the SQLite database.');
|
||||||
|
require('./migrations')(db).then(() => {
|
||||||
|
app.listen(port, () => {
|
||||||
|
console.log(`NOTXCS API is running on port ${port}`);
|
||||||
|
});
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('Failed to run migrations:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(express.static('public'));
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
app.use(session({
|
||||||
|
secret: 'test-secret',
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: true,
|
||||||
|
cookie: { secure: false }
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
console.log(`${req.method} ${req.originalUrl}`);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const routesPath = path.join(__dirname, 'routes');
|
||||||
|
|
||||||
|
function loadRoutes(dir, basePath = '/') {
|
||||||
|
const files = fs.readdirSync(dir);
|
||||||
|
|
||||||
|
files.forEach((file) => {
|
||||||
|
const fullPath = path.join(dir, file);
|
||||||
|
const stat = fs.statSync(fullPath);
|
||||||
|
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
loadRoutes(fullPath, path.join(basePath, file));
|
||||||
|
} else if (file.endsWith('.js')) {
|
||||||
|
const routeName = path.basename(file, '.js');
|
||||||
|
const routeUrl = path.join(basePath, routeName === 'index' ? '' : routeName).replace(/\\/g, '/');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const route = require(fullPath)(db);
|
||||||
|
if (typeof route === 'function' || route.name === 'router') {
|
||||||
|
app.use(routeUrl, route);
|
||||||
|
console.log(`Loaded route: ${routeUrl}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error loading route ${fullPath}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadRoutes(routesPath);
|
||||||
15
lib/auth.js
15
lib/auth.js
|
|
@ -12,6 +12,19 @@ const ROLES = {
|
||||||
SUPERADMIN: 4
|
SUPERADMIN: 4
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Organization membership roles. These are scoped to a single organization only and are completely
|
||||||
|
// independent of the platform-wide `ROLES` above (e.g. an org Admin is not a platform admin).
|
||||||
|
// 1 = View: read-only access to the org's places, readers, ACL entries, access groups and logs.
|
||||||
|
// 2 = Edit: everything View can do, plus create/edit/delete places, readers, ACL entries and
|
||||||
|
// access groups, regenerate API keys, and engage/lift place-level lockdowns.
|
||||||
|
// 3 = Admin: everything Edit can do, plus manage organization membership (add/remove members,
|
||||||
|
// change roles), rename the organization, and delete it (once it owns no places).
|
||||||
|
const ORG_ROLES = {
|
||||||
|
VIEW: 1,
|
||||||
|
EDIT: 2,
|
||||||
|
ADMIN: 3
|
||||||
|
};
|
||||||
|
|
||||||
// Returns Express middleware that requires an authenticated session and (optionally) a minimum role.
|
// Returns Express middleware that requires an authenticated session and (optionally) a minimum role.
|
||||||
// `getDb` is a function returning the current db instance, since route modules assign `db` after this
|
// `getDb` is a function returning the current db instance, since route modules assign `db` after this
|
||||||
// middleware is registered (see routes/dashboard.js and routes/admin.js).
|
// middleware is registered (see routes/dashboard.js and routes/admin.js).
|
||||||
|
|
@ -45,4 +58,4 @@ function requireAuth(getDb, minRole = ROLES.USER) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { ROLES, requireAuth };
|
module.exports = { ROLES, ORG_ROLES, requireAuth };
|
||||||
|
|
|
||||||
39
lib/orgs.js
Normal file
39
lib/orgs.js
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
const { ROLES, ORG_ROLES } = require('./auth');
|
||||||
|
|
||||||
|
// Creates a new default personal organization for a freshly-created user and makes them an Admin
|
||||||
|
// member of it. Used both at registration (routes/auth.js) and when a platform admin creates a user
|
||||||
|
// directly (routes/admin.js), so every user always has at least one organization to create places in.
|
||||||
|
function createDefaultOrg(db, userId, username, callback) {
|
||||||
|
db.run(`INSERT INTO organizations (name, ownerId) VALUES (?, ?)`, [`Personal (${username})`, userId], function (err) {
|
||||||
|
if (err) return callback(err);
|
||||||
|
|
||||||
|
const orgId = this.lastID;
|
||||||
|
db.run(`INSERT INTO organization_members (orgId, userId, role, invitedBy) VALUES (?, ?, ?, ?)`, [orgId, userId, ORG_ROLES.ADMIN, userId], (err) => {
|
||||||
|
if (err) return callback(err);
|
||||||
|
callback(null, orgId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Looks up the caller's effective role within an organization: their actual membership role if
|
||||||
|
// they're a member, or an Admin-level "bypass" if they're not a member but hold a platform-wide
|
||||||
|
// elevated/admin/superadmin role (mirroring the old per-place ownership bypass). Calls back with
|
||||||
|
// `{ role, isMember, bypass }`, or `{ role: null }` if the user has no access at all.
|
||||||
|
function getEffectiveOrgRole(db, orgId, user, callback) {
|
||||||
|
db.get(`SELECT role FROM organization_members WHERE orgId = ? AND userId = ?`, [orgId, user.id], (err, member) => {
|
||||||
|
if (err) return callback(err);
|
||||||
|
|
||||||
|
if (member) {
|
||||||
|
return callback(null, { role: member.role, isMember: true, bypass: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.role >= ROLES.ELEVATED) {
|
||||||
|
return callback(null, { role: ORG_ROLES.ADMIN, isMember: false, bypass: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
callback(null, { role: null, isMember: false, bypass: false });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createDefaultOrg, getEffectiveOrgRole };
|
||||||
|
|
||||||
56
migrations/0008_organizations.sql
Normal file
56
migrations/0008_organizations.sql
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
-- Organizations replace per-place shared access: every place belongs to an organization, and access
|
||||||
|
-- to it is determined by the caller's role/membership in that organization rather than by direct
|
||||||
|
-- ownership or one-off per-place grants (see the now-dropped place_access table, below).
|
||||||
|
CREATE TABLE IF NOT EXISTS organizations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
ownerId INTEGER,
|
||||||
|
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY(ownerId) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Membership roles are scoped to the organization only (independent of the platform-wide role in
|
||||||
|
-- users.role): 1 = View (read-only), 2 = Edit (manage places/readers/ACL/access groups), 3 = Admin
|
||||||
|
-- (Edit, plus manage organization membership/roles and rename/delete the organization itself).
|
||||||
|
CREATE TABLE IF NOT EXISTS organization_members (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
orgId INTEGER NOT NULL,
|
||||||
|
userId INTEGER NOT NULL,
|
||||||
|
role INTEGER NOT NULL DEFAULT 1,
|
||||||
|
invitedBy INTEGER,
|
||||||
|
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY(orgId) REFERENCES organizations(id),
|
||||||
|
FOREIGN KEY(userId) REFERENCES users(id),
|
||||||
|
FOREIGN KEY(invitedBy) REFERENCES users(id),
|
||||||
|
UNIQUE(orgId, userId)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_org_members_userId ON organization_members(userId);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_org_members_orgId ON organization_members(orgId);
|
||||||
|
|
||||||
|
ALTER TABLE places ADD COLUMN orgId INTEGER REFERENCES organizations(id);
|
||||||
|
|
||||||
|
-- Auto-create a default personal organization for every existing user.
|
||||||
|
INSERT INTO organizations (name, ownerId)
|
||||||
|
SELECT 'Personal (' || username || ')', id FROM users;
|
||||||
|
|
||||||
|
-- Make every user an Admin member of their own new default organization.
|
||||||
|
INSERT INTO organization_members (orgId, userId, role, invitedBy)
|
||||||
|
SELECT organizations.id, organizations.ownerId, 3, organizations.ownerId
|
||||||
|
FROM organizations;
|
||||||
|
|
||||||
|
-- Move every personally-owned place into its owner's new default organization.
|
||||||
|
UPDATE places
|
||||||
|
SET orgId = (SELECT id FROM organizations WHERE organizations.ownerId = places.ownerId)
|
||||||
|
WHERE ownerId IS NOT NULL;
|
||||||
|
|
||||||
|
-- Carry over any existing per-place shared access grants as Edit members of the place's new
|
||||||
|
-- organization, then drop the now-unused table - organizations replace this feature entirely.
|
||||||
|
INSERT OR IGNORE INTO organization_members (orgId, userId, role, invitedBy, createdAt)
|
||||||
|
SELECT places.orgId, place_access.userId, 2, place_access.grantedBy, place_access.createdAt
|
||||||
|
FROM place_access
|
||||||
|
JOIN places ON places.id = place_access.placeId
|
||||||
|
WHERE places.orgId IS NOT NULL;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS place_access;
|
||||||
|
|
@ -174,6 +174,42 @@ button.danger {
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.org-switcher {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-switcher label {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-switcher-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-switcher-row select {
|
||||||
|
flex: 1;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-switcher-row button {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-pill {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: rgba(79, 124, 255, 0.15);
|
||||||
|
color: var(--accent);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.place-list {
|
.place-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,22 @@
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<h2>NOTXCS</h2>
|
<h2>NOTXCS</h2>
|
||||||
<div class="user-info">Signed in as <strong id="username-display"></strong> <span id="role-badge" class="badge type-badge"></span></div>
|
<div class="user-info">Signed in as <strong id="username-display"></strong> <span id="role-badge" class="badge type-badge"></span></div>
|
||||||
|
|
||||||
|
<div class="org-switcher">
|
||||||
|
<label for="org-select">Organization</label>
|
||||||
|
<div class="org-switcher-row">
|
||||||
|
<select id="org-select"></select>
|
||||||
|
<button type="button" class="secondary" id="manage-org-btn" title="Manage organization">Manage</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="secondary" id="new-org-btn" style="width:100%;margin-top:8px;">+ New organization</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<a id="admin-link" class="hidden" href="/admin" style="display:block;margin-bottom:12px;color:var(--accent);font-size:13px;">Manage users</a>
|
<a id="admin-link" class="hidden" href="/admin" style="display:block;margin-bottom:12px;color:var(--accent);font-size:13px;">Manage users</a>
|
||||||
<button class="secondary" id="logout-btn" style="margin-bottom: 16px;">Log out</button>
|
<button class="secondary" id="logout-btn" style="margin-bottom: 16px;">Log out</button>
|
||||||
|
|
||||||
<ul class="place-list" id="place-list"></ul>
|
<ul class="place-list" id="place-list"></ul>
|
||||||
|
|
||||||
<div class="card" style="margin-top: 16px;">
|
<div class="card hidden" id="add-place-card" style="margin-top: 16px;">
|
||||||
<h3 style="margin-top:0;font-size:14px;">Add place</h3>
|
<h3 style="margin-top:0;font-size:14px;">Add place</h3>
|
||||||
<form id="add-place-form">
|
<form id="add-place-form">
|
||||||
<label for="place-name">Name <span style="color:var(--muted);font-weight:normal;">(optional)</span></label>
|
<label for="place-name">Name <span style="color:var(--muted);font-weight:normal;">(optional)</span></label>
|
||||||
|
|
@ -32,6 +42,7 @@
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|
||||||
<main class="main">
|
<main class="main">
|
||||||
<div id="lockdown-banner" class="error hidden" style="display:block;margin-bottom:16px;">
|
<div id="lockdown-banner" class="error hidden" style="display:block;margin-bottom:16px;">
|
||||||
Site-wide lockdown is active - every reader at every place is disabled until an admin lifts it.
|
Site-wide lockdown is active - every reader at every place is disabled until an admin lifts it.
|
||||||
|
|
@ -121,23 +132,22 @@
|
||||||
<ul class="acl-list" id="access-groups-list"></ul>
|
<ul class="acl-list" id="access-groups-list"></ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" id="shared-access-card">
|
<div class="card" id="move-org-card">
|
||||||
<h3>Shared access</h3>
|
<h3>Move to another organization</h3>
|
||||||
<p style="margin-top:-8px;color:var(--muted);font-size:13px;">Grant other users access to manage this place's settings, readers and access groups.</p>
|
<p style="margin-top:-8px;color:var(--muted);font-size:13px;">Move this place to a different organization you have Edit access in. Everyone in the current organization will lose access to it.</p>
|
||||||
<form id="add-shared-access-form" class="inline-form">
|
<form id="move-org-form" class="inline-form">
|
||||||
<div class="field-group">
|
<div class="field-group">
|
||||||
<label for="shared-access-username">Username</label>
|
<label for="move-org-select">Organization</label>
|
||||||
<input type="text" id="shared-access-username" required>
|
<select id="move-org-select"></select>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="primary">Grant access</button>
|
<button type="submit" class="secondary">Move place</button>
|
||||||
</form>
|
</form>
|
||||||
<div id="shared-access-empty" class="empty-state hidden">No other users have been granted access to this place.</div>
|
|
||||||
<ul class="acl-list" id="shared-access-list"></ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div id="acl-modal" class="modal-overlay hidden">
|
<div id="acl-modal" class="modal-overlay hidden">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
|
|
@ -246,7 +256,74 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="new-org-modal" class="modal-overlay hidden">
|
||||||
|
<div class="modal" style="max-width:420px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>New organization</h2>
|
||||||
|
<button class="secondary" id="new-org-close-btn">Close</button>
|
||||||
|
</div>
|
||||||
|
<form id="new-org-form">
|
||||||
|
<label for="new-org-name">Organization name</label>
|
||||||
|
<input type="text" id="new-org-name" required>
|
||||||
|
<button type="submit" class="primary">Create organization</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="org-modal" class="modal-overlay hidden">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Manage organization — <span id="org-modal-name"></span></h2>
|
||||||
|
<button class="secondary" id="org-modal-close-btn">Close</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" id="org-rename-card">
|
||||||
|
<h3>Organization name</h3>
|
||||||
|
<form id="org-rename-form" class="inline-form">
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="org-rename-input">Name</label>
|
||||||
|
<input type="text" id="org-rename-input" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="primary">Save</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" id="org-add-member-card">
|
||||||
|
<h3>Add member</h3>
|
||||||
|
<p style="margin-top:-8px;color:var(--muted);font-size:13px;">Add another user to this organization by username, and choose their role within it.</p>
|
||||||
|
<form id="org-add-member-form" class="inline-form">
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="org-member-username">Username</label>
|
||||||
|
<input type="text" id="org-member-username" required>
|
||||||
|
</div>
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="org-member-role">Role</label>
|
||||||
|
<select id="org-member-role">
|
||||||
|
<option value="1">View only</option>
|
||||||
|
<option value="2">Edit</option>
|
||||||
|
<option value="3">Admin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="primary">Add member</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Members</h3>
|
||||||
|
<div id="org-members-empty" class="empty-state hidden">No members yet.</div>
|
||||||
|
<ul class="acl-list" id="org-members-list"></ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" id="org-delete-card">
|
||||||
|
<h3>Delete organization</h3>
|
||||||
|
<p style="margin-top:-8px;color:var(--muted);font-size:13px;">The organization must own no places before it can be deleted.</p>
|
||||||
|
<button type="button" class="danger" id="org-delete-btn">Delete organization</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="/js/dashboard.js"></script>
|
<script src="/js/dashboard.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,10 @@ let currentPlaceId = null;
|
||||||
let places = [];
|
let places = [];
|
||||||
let accessGroups = [];
|
let accessGroups = [];
|
||||||
let currentUser = null;
|
let currentUser = null;
|
||||||
let currentPlaceAccessLevel = null;
|
let currentOrgId = null;
|
||||||
|
let orgs = [];
|
||||||
|
let currentOrgRole = null;
|
||||||
|
let currentOrgBypass = false;
|
||||||
|
|
||||||
const ROLE_LABELS = {
|
const ROLE_LABELS = {
|
||||||
1: 'User',
|
1: 'User',
|
||||||
|
|
@ -11,6 +14,12 @@ const ROLE_LABELS = {
|
||||||
4: 'Superadmin'
|
4: 'Superadmin'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ORG_ROLE_LABELS = {
|
||||||
|
1: 'View only',
|
||||||
|
2: 'Edit',
|
||||||
|
3: 'Admin'
|
||||||
|
};
|
||||||
|
|
||||||
async function api(path, options = {}) {
|
async function api(path, options = {}) {
|
||||||
const res = await fetch(path, {
|
const res = await fetch(path, {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|
@ -38,7 +47,7 @@ async function init() {
|
||||||
document.getElementById('set-api-key-form').classList.toggle('hidden', me.user.role < 3);
|
document.getElementById('set-api-key-form').classList.toggle('hidden', me.user.role < 3);
|
||||||
|
|
||||||
await loadLockdownBanner();
|
await loadLockdownBanner();
|
||||||
await loadPlaces();
|
await loadOrgs();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadLockdownBanner() {
|
async function loadLockdownBanner() {
|
||||||
|
|
@ -49,8 +58,259 @@ async function loadLockdownBanner() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Organizations ---
|
||||||
|
|
||||||
|
const LAST_ORG_STORAGE_KEY = 'notxcs_last_org_id';
|
||||||
|
|
||||||
|
async function loadOrgs() {
|
||||||
|
const data = await api('/dashboard/orgs');
|
||||||
|
orgs = data.orgs || [];
|
||||||
|
renderOrgSelect();
|
||||||
|
|
||||||
|
if (orgs.length === 0) {
|
||||||
|
currentOrgId = null;
|
||||||
|
currentOrgRole = null;
|
||||||
|
currentOrgBypass = false;
|
||||||
|
updateOrgGatedUI();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = parseInt(localStorage.getItem(LAST_ORG_STORAGE_KEY), 10);
|
||||||
|
const initialOrg = orgs.find(o => o.id === stored) || orgs[0];
|
||||||
|
await selectOrg(initialOrg.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOrgSelect() {
|
||||||
|
const select = document.getElementById('org-select');
|
||||||
|
select.innerHTML = '';
|
||||||
|
orgs.forEach((org) => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = org.id;
|
||||||
|
option.textContent = org.name;
|
||||||
|
if (org.id === currentOrgId) option.selected = true;
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectOrg(orgId) {
|
||||||
|
currentOrgId = orgId;
|
||||||
|
const org = orgs.find(o => o.id === orgId);
|
||||||
|
currentOrgRole = org ? org.role : null;
|
||||||
|
currentOrgBypass = org ? org.bypass : false;
|
||||||
|
localStorage.setItem(LAST_ORG_STORAGE_KEY, String(orgId));
|
||||||
|
renderOrgSelect();
|
||||||
|
updateOrgGatedUI();
|
||||||
|
|
||||||
|
currentPlaceId = null;
|
||||||
|
document.getElementById('place-view').classList.add('hidden');
|
||||||
|
document.getElementById('no-place').classList.remove('hidden');
|
||||||
|
|
||||||
|
await loadPlaces();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shows/hides UI that depends on the caller's role within the current organization: Edit is needed
|
||||||
|
// to add places, Admin is needed to manage the organization itself (rename/members/delete).
|
||||||
|
function updateOrgGatedUI() {
|
||||||
|
const canEdit = currentOrgRole >= 2;
|
||||||
|
const canAdmin = currentOrgRole >= 3;
|
||||||
|
document.getElementById('add-place-card').classList.toggle('hidden', !canEdit);
|
||||||
|
document.getElementById('manage-org-btn').classList.toggle('hidden', !canAdmin || !currentOrgId);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('org-select').addEventListener('change', (e) => {
|
||||||
|
selectOrg(parseInt(e.target.value, 10));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('new-org-btn').addEventListener('click', () => {
|
||||||
|
document.getElementById('new-org-form').reset();
|
||||||
|
document.getElementById('new-org-modal').classList.remove('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('new-org-close-btn').addEventListener('click', () => {
|
||||||
|
document.getElementById('new-org-modal').classList.add('hidden');
|
||||||
|
});
|
||||||
|
document.getElementById('new-org-modal').addEventListener('click', (e) => {
|
||||||
|
if (e.target.id === 'new-org-modal') document.getElementById('new-org-modal').classList.add('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('new-org-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const name = document.getElementById('new-org-name').value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
const result = await api('/dashboard/orgs', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
document.getElementById('new-org-modal').classList.add('hidden');
|
||||||
|
await loadOrgs();
|
||||||
|
await selectOrg(result.org.id);
|
||||||
|
} else {
|
||||||
|
alert(result.message || 'Failed to create organization');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Organization management modal (rename, members, delete) ---
|
||||||
|
|
||||||
|
let orgMembers = [];
|
||||||
|
|
||||||
|
async function openOrgModal() {
|
||||||
|
if (!currentOrgId) return;
|
||||||
|
const org = orgs.find(o => o.id === currentOrgId);
|
||||||
|
document.getElementById('org-modal-name').textContent = org ? org.name : '';
|
||||||
|
document.getElementById('org-rename-input').value = org ? org.name : '';
|
||||||
|
document.getElementById('org-add-member-form').reset();
|
||||||
|
document.getElementById('org-modal').classList.remove('hidden');
|
||||||
|
await loadOrgMembers();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeOrgModal() {
|
||||||
|
document.getElementById('org-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('manage-org-btn').addEventListener('click', openOrgModal);
|
||||||
|
document.getElementById('org-modal-close-btn').addEventListener('click', closeOrgModal);
|
||||||
|
document.getElementById('org-modal').addEventListener('click', (e) => {
|
||||||
|
if (e.target.id === 'org-modal') closeOrgModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('org-rename-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!currentOrgId) return;
|
||||||
|
const name = document.getElementById('org-rename-input').value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
const result = await api(`/dashboard/orgs/${encodeURIComponent(currentOrgId)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ name })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
await loadOrgs();
|
||||||
|
document.getElementById('org-modal-name').textContent = name;
|
||||||
|
} else {
|
||||||
|
alert(result.message || 'Failed to rename organization');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadOrgMembers() {
|
||||||
|
if (!currentOrgId) return;
|
||||||
|
const data = await api(`/dashboard/orgs/${encodeURIComponent(currentOrgId)}/members`);
|
||||||
|
orgMembers = data.members || [];
|
||||||
|
renderOrgMembersList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOrgMembersList() {
|
||||||
|
const list = document.getElementById('org-members-list');
|
||||||
|
const empty = document.getElementById('org-members-empty');
|
||||||
|
list.innerHTML = '';
|
||||||
|
|
||||||
|
if (orgMembers.length === 0) {
|
||||||
|
empty.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
empty.classList.add('hidden');
|
||||||
|
|
||||||
|
const org = orgs.find(o => o.id === currentOrgId);
|
||||||
|
const isOwner = (userId) => org && userId === org.ownerId;
|
||||||
|
|
||||||
|
orgMembers.forEach((member) => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'acl-entry';
|
||||||
|
|
||||||
|
if (isOwner(member.userId)) {
|
||||||
|
li.innerHTML = `
|
||||||
|
<span class="acl-description">${escapeHtml(member.username)}</span>
|
||||||
|
<span class="role-pill">Admin (owner)</span>
|
||||||
|
`;
|
||||||
|
list.appendChild(li);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleOptions = Object.entries(ORG_ROLE_LABELS)
|
||||||
|
.map(([value, label]) => `<option value="${value}" ${parseInt(value, 10) === member.role ? 'selected' : ''}>${label}</option>`)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
li.innerHTML = `
|
||||||
|
<span class="acl-description">${escapeHtml(member.username)}</span>
|
||||||
|
<select class="org-member-role-select">${roleOptions}</select>
|
||||||
|
<button class="secondary org-member-save-btn">Save</button>
|
||||||
|
<button class="danger org-member-remove-btn">Remove</button>
|
||||||
|
`;
|
||||||
|
li.querySelector('.org-member-save-btn').addEventListener('click', () => saveOrgMemberRole(member.userId, li));
|
||||||
|
li.querySelector('.org-member-remove-btn').addEventListener('click', () => removeOrgMember(member.userId, member.username));
|
||||||
|
list.appendChild(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveOrgMemberRole(userId, li) {
|
||||||
|
const role = parseInt(li.querySelector('.org-member-role-select').value, 10);
|
||||||
|
const result = await api(`/dashboard/orgs/${encodeURIComponent(currentOrgId)}/members/${encodeURIComponent(userId)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ role })
|
||||||
|
});
|
||||||
|
if (result.success) {
|
||||||
|
await loadOrgMembers();
|
||||||
|
} else {
|
||||||
|
alert(result.message || 'Failed to update member role');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeOrgMember(userId, username) {
|
||||||
|
if (!confirm(`Remove ${username} from this organization?`)) return;
|
||||||
|
const result = await api(`/dashboard/orgs/${encodeURIComponent(currentOrgId)}/members/${encodeURIComponent(userId)}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
if (result.success) {
|
||||||
|
await loadOrgMembers();
|
||||||
|
} else {
|
||||||
|
alert(result.message || 'Failed to remove member');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('org-add-member-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!currentOrgId) return;
|
||||||
|
|
||||||
|
const username = document.getElementById('org-member-username').value.trim();
|
||||||
|
const role = parseInt(document.getElementById('org-member-role').value, 10);
|
||||||
|
if (!username) return;
|
||||||
|
|
||||||
|
const result = await api(`/dashboard/orgs/${encodeURIComponent(currentOrgId)}/members`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ username, role })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
document.getElementById('org-add-member-form').reset();
|
||||||
|
await loadOrgMembers();
|
||||||
|
} else {
|
||||||
|
alert(result.message || 'Failed to add member');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('org-delete-btn').addEventListener('click', async () => {
|
||||||
|
if (!currentOrgId) return;
|
||||||
|
if (!confirm('Delete this organization? It must own no places.')) return;
|
||||||
|
|
||||||
|
const result = await api(`/dashboard/orgs/${encodeURIComponent(currentOrgId)}`, { method: 'DELETE' });
|
||||||
|
if (result.success) {
|
||||||
|
closeOrgModal();
|
||||||
|
await loadOrgs();
|
||||||
|
} else {
|
||||||
|
alert(result.message || 'Failed to delete organization');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function loadPlaces() {
|
async function loadPlaces() {
|
||||||
const data = await api('/dashboard/places');
|
if (!currentOrgId) {
|
||||||
|
places = [];
|
||||||
|
renderPlaceList();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await api(`/dashboard/places?orgId=${encodeURIComponent(currentOrgId)}`);
|
||||||
places = data.places || [];
|
places = data.places || [];
|
||||||
renderPlaceList();
|
renderPlaceList();
|
||||||
}
|
}
|
||||||
|
|
@ -80,18 +340,13 @@ function renderPlaceList() {
|
||||||
|
|
||||||
li.appendChild(row);
|
li.appendChild(row);
|
||||||
|
|
||||||
if (place.ownerUsername) {
|
|
||||||
const ownerSpan = document.createElement('span');
|
|
||||||
ownerSpan.className = 'place-list-owner';
|
|
||||||
ownerSpan.textContent = `Owner: ${place.ownerUsername}`;
|
|
||||||
li.appendChild(ownerSpan);
|
|
||||||
}
|
|
||||||
|
|
||||||
li.addEventListener('click', () => selectPlace(place.id));
|
li.addEventListener('click', () => selectPlace(place.id));
|
||||||
list.appendChild(li);
|
list.appendChild(li);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let currentPlaceCanEdit = false;
|
||||||
|
|
||||||
async function selectPlace(placeId) {
|
async function selectPlace(placeId) {
|
||||||
currentPlaceId = placeId;
|
currentPlaceId = placeId;
|
||||||
renderPlaceList();
|
renderPlaceList();
|
||||||
|
|
@ -99,8 +354,6 @@ async function selectPlace(placeId) {
|
||||||
const data = await api(`/dashboard/places/${encodeURIComponent(placeId)}`);
|
const data = await api(`/dashboard/places/${encodeURIComponent(placeId)}`);
|
||||||
if (!data.success) return;
|
if (!data.success) return;
|
||||||
|
|
||||||
currentPlaceAccessLevel = data.accessLevel;
|
|
||||||
|
|
||||||
document.getElementById('no-place').classList.add('hidden');
|
document.getElementById('no-place').classList.add('hidden');
|
||||||
document.getElementById('place-view').classList.remove('hidden');
|
document.getElementById('place-view').classList.remove('hidden');
|
||||||
document.getElementById('place-title').textContent = data.place.name || placeId;
|
document.getElementById('place-title').textContent = data.place.name || placeId;
|
||||||
|
|
@ -114,19 +367,60 @@ async function selectPlace(placeId) {
|
||||||
document.getElementById('toggle-api-key-btn').textContent = 'Show';
|
document.getElementById('toggle-api-key-btn').textContent = 'Show';
|
||||||
document.getElementById('place-api-key-custom').value = '';
|
document.getElementById('place-api-key-custom').value = '';
|
||||||
|
|
||||||
const canManageOwnership = currentPlaceAccessLevel === 'owner' || currentPlaceAccessLevel === 'bypass';
|
currentPlaceCanEdit = currentOrgRole >= 2;
|
||||||
document.getElementById('delete-place-btn').classList.toggle('hidden', !canManageOwnership);
|
const canEdit = currentPlaceCanEdit;
|
||||||
document.getElementById('shared-access-card').classList.toggle('hidden', !canManageOwnership);
|
document.getElementById('delete-place-btn').classList.toggle('hidden', !canEdit);
|
||||||
|
document.getElementById('move-org-card').classList.toggle('hidden', !canEdit);
|
||||||
|
document.getElementById('set-place-name-form').querySelector('button').disabled = !canEdit;
|
||||||
|
document.getElementById('regenerate-api-key-btn').disabled = !canEdit;
|
||||||
|
document.getElementById('add-reader-form').querySelector('button').disabled = !canEdit;
|
||||||
|
document.getElementById('add-access-group-form').querySelector('button').disabled = !canEdit;
|
||||||
|
|
||||||
|
if (canEdit) {
|
||||||
|
populateMoveOrgSelect();
|
||||||
|
}
|
||||||
|
|
||||||
updatePlaceLockdownUI(!!data.place.lockdown);
|
updatePlaceLockdownUI(!!data.place.lockdown);
|
||||||
|
|
||||||
renderReaders(data.accessPoints || []);
|
renderReaders(data.accessPoints || []);
|
||||||
await loadAccessGroups();
|
await loadAccessGroups();
|
||||||
if (canManageOwnership) {
|
|
||||||
await loadSharedAccess();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function populateMoveOrgSelect() {
|
||||||
|
const select = document.getElementById('move-org-select');
|
||||||
|
select.innerHTML = '';
|
||||||
|
orgs.filter(o => o.role >= 2 && o.id !== currentOrgId).forEach((org) => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = org.id;
|
||||||
|
option.textContent = org.name;
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
document.getElementById('move-org-form').querySelector('button').disabled = select.options.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('move-org-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!currentPlaceId) return;
|
||||||
|
const destOrgId = parseInt(document.getElementById('move-org-select').value, 10);
|
||||||
|
if (!destOrgId) return;
|
||||||
|
if (!confirm('Move this place to the selected organization? Members of the current organization will lose access to it.')) return;
|
||||||
|
|
||||||
|
const result = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/move`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ orgId: destOrgId })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
currentPlaceId = null;
|
||||||
|
document.getElementById('place-view').classList.add('hidden');
|
||||||
|
document.getElementById('no-place').classList.remove('hidden');
|
||||||
|
await loadPlaces();
|
||||||
|
} else {
|
||||||
|
alert(result.message || 'Failed to move place');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
function updatePlaceLockdownUI(active) {
|
function updatePlaceLockdownUI(active) {
|
||||||
const engageBtn = document.getElementById('engage-place-lockdown-btn');
|
const engageBtn = document.getElementById('engage-place-lockdown-btn');
|
||||||
const liftBtn = document.getElementById('lift-place-lockdown-btn');
|
const liftBtn = document.getElementById('lift-place-lockdown-btn');
|
||||||
|
|
@ -134,10 +428,10 @@ function updatePlaceLockdownUI(active) {
|
||||||
|
|
||||||
if (active) {
|
if (active) {
|
||||||
engageBtn.classList.add('hidden');
|
engageBtn.classList.add('hidden');
|
||||||
liftBtn.classList.remove('hidden');
|
liftBtn.classList.toggle('hidden', !currentPlaceCanEdit);
|
||||||
banner.classList.remove('hidden');
|
banner.classList.remove('hidden');
|
||||||
} else {
|
} else {
|
||||||
engageBtn.classList.remove('hidden');
|
engageBtn.classList.toggle('hidden', !currentPlaceCanEdit);
|
||||||
liftBtn.classList.add('hidden');
|
liftBtn.classList.add('hidden');
|
||||||
banner.classList.add('hidden');
|
banner.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
@ -168,26 +462,28 @@ function renderReaders(accessPoints) {
|
||||||
<div class="reader-id">${escapeHtml(ap.id)}</div>
|
<div class="reader-id">${escapeHtml(ap.id)}</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<span>Enabled</span>
|
<span>Enabled</span>
|
||||||
<input type="checkbox" data-field="enabled" ${ap.enabled ? 'checked' : ''}>
|
<input type="checkbox" data-field="enabled" ${ap.enabled ? 'checked' : ''} ${currentPlaceCanEdit ? '' : 'disabled'}>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<span>Armed</span>
|
<span>Armed</span>
|
||||||
<input type="checkbox" data-field="armState" ${ap.armState ? 'checked' : ''}>
|
<input type="checkbox" data-field="armState" ${ap.armState ? 'checked' : ''} ${currentPlaceCanEdit ? '' : 'disabled'}>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<span>Unlock time (s)</span>
|
<span>Unlock time (s)</span>
|
||||||
<input type="number" data-field="unlockTime" value="${ap.unlockTime}" min="0">
|
<input type="number" data-field="unlockTime" value="${ap.unlockTime}" min="0" ${currentPlaceCanEdit ? '' : 'disabled'}>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="secondary save-btn">Save</button>
|
${currentPlaceCanEdit ? '<button class="secondary save-btn">Save</button>' : ''}
|
||||||
<button class="secondary acl-btn">Manage access</button>
|
<button class="secondary acl-btn">Manage access</button>
|
||||||
<button class="secondary logs-btn">View logs</button>
|
<button class="secondary logs-btn">View logs</button>
|
||||||
<button class="danger delete-btn">Delete</button>
|
${currentPlaceCanEdit ? '<button class="danger delete-btn">Delete</button>' : ''}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
card.querySelector('.save-btn').addEventListener('click', () => saveReader(ap.id, card));
|
if (currentPlaceCanEdit) {
|
||||||
card.querySelector('.delete-btn').addEventListener('click', () => deleteReader(ap.id));
|
card.querySelector('.save-btn').addEventListener('click', () => saveReader(ap.id, card));
|
||||||
|
card.querySelector('.delete-btn').addEventListener('click', () => deleteReader(ap.id));
|
||||||
|
}
|
||||||
card.querySelector('.acl-btn').addEventListener('click', () => openAclModal(ap.id, ap.name));
|
card.querySelector('.acl-btn').addEventListener('click', () => openAclModal(ap.id, ap.name));
|
||||||
card.querySelector('.logs-btn').addEventListener('click', () => openLogsModal(ap.id, ap.name));
|
card.querySelector('.logs-btn').addEventListener('click', () => openLogsModal(ap.id, ap.name));
|
||||||
|
|
||||||
|
|
@ -223,11 +519,12 @@ async function deleteReader(readerId) {
|
||||||
|
|
||||||
document.getElementById('add-place-form').addEventListener('submit', async (e) => {
|
document.getElementById('add-place-form').addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (!currentOrgId) return;
|
||||||
const id = document.getElementById('place-id').value.trim();
|
const id = document.getElementById('place-id').value.trim();
|
||||||
const apiKey = document.getElementById('place-api-key').value.trim();
|
const apiKey = document.getElementById('place-api-key').value.trim();
|
||||||
const name = document.getElementById('place-name').value.trim();
|
const name = document.getElementById('place-name').value.trim();
|
||||||
|
|
||||||
const body = {};
|
const body = { orgId: currentOrgId };
|
||||||
if (id) body.id = id;
|
if (id) body.id = id;
|
||||||
if (apiKey) body.apiKey = apiKey;
|
if (apiKey) body.apiKey = apiKey;
|
||||||
if (name) body.name = name;
|
if (name) body.name = name;
|
||||||
|
|
@ -509,6 +806,7 @@ async function openAclModal(readerId, readerName) {
|
||||||
document.getElementById('acl-reader-name').textContent = readerName;
|
document.getElementById('acl-reader-name').textContent = readerName;
|
||||||
document.getElementById('acl-modal').classList.remove('hidden');
|
document.getElementById('acl-modal').classList.remove('hidden');
|
||||||
document.getElementById('add-acl-form').reset();
|
document.getElementById('add-acl-form').reset();
|
||||||
|
document.getElementById('add-acl-form').classList.toggle('hidden', !currentPlaceCanEdit);
|
||||||
updateAclFormFields();
|
updateAclFormFields();
|
||||||
await loadAcl();
|
await loadAcl();
|
||||||
}
|
}
|
||||||
|
|
@ -541,9 +839,11 @@ function renderAclList(entries) {
|
||||||
li.innerHTML = `
|
li.innerHTML = `
|
||||||
<span class="badge type-badge">${escapeHtml(ACL_TYPE_LABELS[entry.type] || 'Unknown')}</span>
|
<span class="badge type-badge">${escapeHtml(ACL_TYPE_LABELS[entry.type] || 'Unknown')}</span>
|
||||||
<span class="acl-description">${escapeHtml(aclEntryDescription(entry))}</span>
|
<span class="acl-description">${escapeHtml(aclEntryDescription(entry))}</span>
|
||||||
<button class="danger acl-delete-btn">Remove</button>
|
${currentPlaceCanEdit ? '<button class="danger acl-delete-btn">Remove</button>' : ''}
|
||||||
`;
|
`;
|
||||||
li.querySelector('.acl-delete-btn').addEventListener('click', () => deleteAclEntry(entry.id));
|
if (currentPlaceCanEdit) {
|
||||||
|
li.querySelector('.acl-delete-btn').addEventListener('click', () => deleteAclEntry(entry.id));
|
||||||
|
}
|
||||||
list.appendChild(li);
|
list.appendChild(li);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -626,10 +926,12 @@ function renderAccessGroupsList() {
|
||||||
<span class="badge type-badge">Group</span>
|
<span class="badge type-badge">Group</span>
|
||||||
<span class="acl-description">${escapeHtml(group.name)}</span>
|
<span class="acl-description">${escapeHtml(group.name)}</span>
|
||||||
<button class="secondary group-manage-btn">Manage members</button>
|
<button class="secondary group-manage-btn">Manage members</button>
|
||||||
<button class="danger group-delete-btn">Delete</button>
|
${currentPlaceCanEdit ? '<button class="danger group-delete-btn">Delete</button>' : ''}
|
||||||
`;
|
`;
|
||||||
li.querySelector('.group-manage-btn').addEventListener('click', () => openGroupModal(group.id, group.name));
|
li.querySelector('.group-manage-btn').addEventListener('click', () => openGroupModal(group.id, group.name));
|
||||||
li.querySelector('.group-delete-btn').addEventListener('click', () => deleteAccessGroup(group.id));
|
if (currentPlaceCanEdit) {
|
||||||
|
li.querySelector('.group-delete-btn').addEventListener('click', () => deleteAccessGroup(group.id));
|
||||||
|
}
|
||||||
list.appendChild(li);
|
list.appendChild(li);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -702,6 +1004,7 @@ async function openGroupModal(groupId, groupName) {
|
||||||
document.getElementById('group-name').textContent = groupName;
|
document.getElementById('group-name').textContent = groupName;
|
||||||
document.getElementById('group-modal').classList.remove('hidden');
|
document.getElementById('group-modal').classList.remove('hidden');
|
||||||
document.getElementById('add-group-member-form').reset();
|
document.getElementById('add-group-member-form').reset();
|
||||||
|
document.getElementById('add-group-member-form').classList.toggle('hidden', !currentPlaceCanEdit);
|
||||||
updateGroupMemberFormFields();
|
updateGroupMemberFormFields();
|
||||||
await loadGroupMembers();
|
await loadGroupMembers();
|
||||||
}
|
}
|
||||||
|
|
@ -734,9 +1037,11 @@ function renderGroupMembersList(members) {
|
||||||
li.innerHTML = `
|
li.innerHTML = `
|
||||||
<span class="badge type-badge">${escapeHtml(ACL_TYPE_LABELS[member.type] || 'Unknown')}</span>
|
<span class="badge type-badge">${escapeHtml(ACL_TYPE_LABELS[member.type] || 'Unknown')}</span>
|
||||||
<span class="acl-description">${escapeHtml(aclEntryDescription(member))}</span>
|
<span class="acl-description">${escapeHtml(aclEntryDescription(member))}</span>
|
||||||
<button class="danger group-member-delete-btn">Remove</button>
|
${currentPlaceCanEdit ? '<button class="danger group-member-delete-btn">Remove</button>' : ''}
|
||||||
`;
|
`;
|
||||||
li.querySelector('.group-member-delete-btn').addEventListener('click', () => deleteGroupMember(member.id));
|
if (currentPlaceCanEdit) {
|
||||||
|
li.querySelector('.group-member-delete-btn').addEventListener('click', () => deleteGroupMember(member.id));
|
||||||
|
}
|
||||||
list.appendChild(li);
|
list.appendChild(li);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -789,67 +1094,6 @@ document.getElementById('group-modal').addEventListener('click', (e) => {
|
||||||
if (e.target.id === 'group-modal') closeGroupModal();
|
if (e.target.id === 'group-modal') closeGroupModal();
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Shared place access (owner/elevated/admin can grant other users access to a place's settings) ---
|
|
||||||
|
|
||||||
async function loadSharedAccess() {
|
|
||||||
if (!currentPlaceId) return;
|
|
||||||
const data = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/access`);
|
|
||||||
renderSharedAccessList(data.grants || []);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSharedAccessList(grants) {
|
|
||||||
const list = document.getElementById('shared-access-list');
|
|
||||||
const empty = document.getElementById('shared-access-empty');
|
|
||||||
list.innerHTML = '';
|
|
||||||
|
|
||||||
if (grants.length === 0) {
|
|
||||||
empty.classList.remove('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
empty.classList.add('hidden');
|
|
||||||
|
|
||||||
grants.forEach((grant) => {
|
|
||||||
const li = document.createElement('li');
|
|
||||||
li.className = 'acl-entry';
|
|
||||||
li.innerHTML = `
|
|
||||||
<span class="acl-description">${escapeHtml(grant.username)}</span>
|
|
||||||
<button class="danger shared-access-revoke-btn">Revoke</button>
|
|
||||||
`;
|
|
||||||
li.querySelector('.shared-access-revoke-btn').addEventListener('click', () => revokeSharedAccess(grant.userId));
|
|
||||||
list.appendChild(li);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function revokeSharedAccess(userId) {
|
|
||||||
if (!currentPlaceId) return;
|
|
||||||
if (!confirm('Revoke this user\'s access to the place?')) return;
|
|
||||||
|
|
||||||
await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/access/${encodeURIComponent(userId)}`, {
|
|
||||||
method: 'DELETE'
|
|
||||||
});
|
|
||||||
await loadSharedAccess();
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('add-shared-access-form').addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!currentPlaceId) return;
|
|
||||||
|
|
||||||
const username = document.getElementById('shared-access-username').value.trim();
|
|
||||||
if (!username) return;
|
|
||||||
|
|
||||||
const result = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/access`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ username })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
document.getElementById('shared-access-username').value = '';
|
|
||||||
await loadSharedAccess();
|
|
||||||
} else {
|
|
||||||
alert(result.message || 'Failed to grant access');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Scan logs viewing ---
|
// --- Scan logs viewing ---
|
||||||
|
|
||||||
let currentLogsReaderId = null;
|
let currentLogsReaderId = null;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ const express = require('express');
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { requireAuth, ROLES } = require('../lib/auth');
|
const { requireAuth, ROLES } = require('../lib/auth');
|
||||||
|
const { createDefaultOrg } = require('../lib/orgs');
|
||||||
|
|
||||||
let db;
|
let db;
|
||||||
|
|
||||||
|
|
@ -82,7 +83,17 @@ router.post('/users', (req, res) => {
|
||||||
console.error('Failed to create user:', err.message);
|
console.error('Failed to create user:', err.message);
|
||||||
return res.status(500).json({ success: false, message: "Internal server error" });
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
}
|
}
|
||||||
res.json({ success: true, user: { id: this.lastID, username, role } });
|
|
||||||
|
const userId = this.lastID;
|
||||||
|
|
||||||
|
// Give the new user a default personal organization, same as if they'd self-registered.
|
||||||
|
createDefaultOrg(db, userId, username, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Failed to create default organization:', err.message);
|
||||||
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
|
}
|
||||||
|
res.json({ success: true, user: { id: userId, username, role } });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -169,18 +180,18 @@ router.delete('/users/:userId', (req, res) => {
|
||||||
return res.status(403).json({ success: false, message: "Only a superadmin can delete an admin or superadmin account" });
|
return res.status(403).json({ success: false, message: "Only a superadmin can delete an admin or superadmin account" });
|
||||||
}
|
}
|
||||||
|
|
||||||
db.get(`SELECT COUNT(*) as count FROM places WHERE ownerId = ?`, [userId], (err, row) => {
|
db.get(`SELECT COUNT(*) as count FROM organizations WHERE ownerId = ?`, [userId], (err, row) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('Failed to check owned places:', err.message);
|
console.error('Failed to check owned organizations:', err.message);
|
||||||
return res.status(500).json({ success: false, message: "Internal server error" });
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
}
|
}
|
||||||
if (row.count > 0) {
|
if (row.count > 0) {
|
||||||
return res.status(409).json({ success: false, message: "This user still owns places. Reassign or delete them first." });
|
return res.status(409).json({ success: false, message: "This user still owns organizations. Delete or reassign them first." });
|
||||||
}
|
}
|
||||||
|
|
||||||
db.run(`DELETE FROM place_access WHERE userId = ?`, [userId], (err) => {
|
db.run(`DELETE FROM organization_members WHERE userId = ?`, [userId], (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('Failed to delete access grants for user:', err.message);
|
console.error('Failed to delete organization memberships for user:', err.message);
|
||||||
return res.status(500).json({ success: false, message: "Internal server error" });
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ const express = require('express');
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { ROLES } = require('../lib/auth');
|
const { ROLES } = require('../lib/auth');
|
||||||
|
const { createDefaultOrg } = require('../lib/orgs');
|
||||||
|
|
||||||
let db;
|
let db;
|
||||||
|
|
||||||
|
|
@ -53,9 +54,20 @@ router.post('/register', (req, res) => {
|
||||||
return res.status(500).json({ success: false, message: "Internal server error" });
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
}
|
}
|
||||||
|
|
||||||
req.session.userId = this.lastID;
|
const userId = this.lastID;
|
||||||
req.session.username = username;
|
|
||||||
res.json({ success: true, user: { id: this.lastID, username, role } });
|
// Every new user gets a default personal organization (replacing the old per-place
|
||||||
|
// sharing model), and is made an Admin member of it so they can invite others later.
|
||||||
|
createDefaultOrg(db, userId, username, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Failed to create default organization:', err.message);
|
||||||
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
|
}
|
||||||
|
|
||||||
|
req.session.userId = userId;
|
||||||
|
req.session.username = username;
|
||||||
|
res.json({ success: true, user: { id: userId, username, role } });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,9 @@ const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { requireAuth, ROLES } = require('../lib/auth');
|
const { requireAuth, ROLES, ORG_ROLES } = require('../lib/auth');
|
||||||
const { generatePlaceId, generateApiKey, generateReaderId } = require('../lib/generators');
|
const { generatePlaceId, generateApiKey, generateReaderId } = require('../lib/generators');
|
||||||
|
const { getEffectiveOrgRole } = require('../lib/orgs');
|
||||||
|
|
||||||
const KIT_TEMPLATE_PATH = path.join(__dirname, '..', 'xcs-template.rbxmx');
|
const KIT_TEMPLATE_PATH = path.join(__dirname, '..', 'xcs-template.rbxmx');
|
||||||
|
|
||||||
|
|
@ -14,8 +15,52 @@ module.exports = (dbInit) => {
|
||||||
return router;
|
return router;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Loads the place, granting access if the current user owns it, holds an elevated/admin role
|
// Loads an organization by id and determines the caller's effective role in it (their membership
|
||||||
// (which bypasses ownership checks entirely), or has been explicitly granted shared access to it.
|
// role, or an Admin-level bypass for platform elevated/admin/superadmin users). Calls back with
|
||||||
|
// `{ ok: true, org, role, bypass }` on success, or `{ ok: false, status, message }` on failure -
|
||||||
|
// used both by the `loadOrg` middleware below and directly wherever an orgId comes from a request
|
||||||
|
// body rather than a route param (e.g. POST /places).
|
||||||
|
function checkOrgAccess(orgId, user, minRole, callback) {
|
||||||
|
if (!orgId || Number.isNaN(orgId)) {
|
||||||
|
return callback(null, { ok: false, status: 400, message: "A valid organization id is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.get(`SELECT * FROM organizations WHERE id = ?`, [orgId], (err, org) => {
|
||||||
|
if (err) return callback(err);
|
||||||
|
if (!org) return callback(null, { ok: false, status: 404, message: "Organization not found" });
|
||||||
|
|
||||||
|
getEffectiveOrgRole(db, orgId, user, (err, result) => {
|
||||||
|
if (err) return callback(err);
|
||||||
|
if (!result.role) return callback(null, { ok: false, status: 404, message: "Organization not found" });
|
||||||
|
if (result.role < minRole) return callback(null, { ok: false, status: 403, message: "Insufficient permissions in this organization" });
|
||||||
|
callback(null, { ok: true, org, role: result.role, bypass: result.bypass });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Express middleware version of checkOrgAccess, for routes with an :orgId param.
|
||||||
|
function loadOrg(minRole = ORG_ROLES.VIEW) {
|
||||||
|
return (req, res, next) => {
|
||||||
|
const orgId = parseInt(req.params.orgId, 10);
|
||||||
|
checkOrgAccess(orgId, req.user, minRole, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Failed to check organization access:', err.message);
|
||||||
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
|
}
|
||||||
|
if (!result.ok) {
|
||||||
|
return res.status(result.status).json({ success: false, message: result.message });
|
||||||
|
}
|
||||||
|
req.org = result.org;
|
||||||
|
req.orgRole = result.role;
|
||||||
|
req.orgBypass = result.bypass;
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loads the place, granting access based on the caller's role in the organization that owns it:
|
||||||
|
// any membership role (View/Edit/Admin) grants at least read access, while platform elevated/admin
|
||||||
|
// users get an Admin-level bypass even if they aren't a member of the org.
|
||||||
function loadPlace(req, res, next) {
|
function loadPlace(req, res, next) {
|
||||||
const { placeId } = req.params;
|
const { placeId } = req.params;
|
||||||
db.get(`SELECT * FROM places WHERE id = ?`, [placeId], (err, place) => {
|
db.get(`SELECT * FROM places WHERE id = ?`, [placeId], (err, place) => {
|
||||||
|
|
@ -27,40 +72,40 @@ function loadPlace(req, res, next) {
|
||||||
return res.status(404).json({ success: false, message: "Place not found" });
|
return res.status(404).json({ success: false, message: "Place not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (place.ownerId === req.user.id) {
|
checkOrgAccess(place.orgId, req.user, ORG_ROLES.VIEW, (err, result) => {
|
||||||
req.place = place;
|
|
||||||
req.placeAccessLevel = 'owner';
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.user.role >= ROLES.ELEVATED) {
|
|
||||||
req.place = place;
|
|
||||||
req.placeAccessLevel = 'bypass';
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
db.get(`SELECT 1 FROM place_access WHERE placeId = ? AND userId = ?`, [placeId, req.user.id], (err, grant) => {
|
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('Failed to check place access:', err.message);
|
console.error('Failed to check organization access:', err.message);
|
||||||
return res.status(500).json({ success: false, message: "Internal server error" });
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
}
|
}
|
||||||
if (!grant) {
|
if (!result.ok) {
|
||||||
return res.status(404).json({ success: false, message: "Place not found" });
|
return res.status(404).json({ success: false, message: "Place not found" });
|
||||||
}
|
}
|
||||||
req.place = place;
|
req.place = place;
|
||||||
req.placeAccessLevel = 'shared';
|
req.org = result.org;
|
||||||
|
req.orgRole = result.role;
|
||||||
|
req.orgBypass = result.bypass;
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only the owner (or an elevated/admin user, who bypasses ownership checks) may perform this action,
|
// Requires Edit (or higher) organization access to the place loaded by `loadPlace` - used for every
|
||||||
// e.g. deleting the place or managing who else has shared access to it.
|
// route that creates/modifies/deletes a place, reader, ACL entry or access group. View-only members
|
||||||
function requireOwnerOrBypass(req, res, next) {
|
// can still use the GET routes above, which only require `loadPlace` on its own.
|
||||||
if (req.placeAccessLevel === 'owner' || req.placeAccessLevel === 'bypass') {
|
function requireOrgEdit(req, res, next) {
|
||||||
|
if (req.orgRole >= ORG_ROLES.EDIT) {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
return res.status(403).json({ success: false, message: "Only the place owner or an elevated user can perform this action" });
|
return res.status(403).json({ success: false, message: "You need Edit access in this organization to perform this action" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Requires Admin organization access - used for managing organization membership/roles and for
|
||||||
|
// renaming/deleting the organization itself.
|
||||||
|
function requireOrgAdmin(req, res, next) {
|
||||||
|
if (req.orgRole >= ORG_ROLES.ADMIN) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
return res.status(403).json({ success: false, message: "You need Admin access in this organization to perform this action" });
|
||||||
}
|
}
|
||||||
|
|
||||||
router.use(requireAuth(() => db));
|
router.use(requireAuth(() => db));
|
||||||
|
|
@ -77,71 +122,316 @@ router.get('/lockdown', (req, res) => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// List places owned by, or shared with, the current user. Elevated/admin users see every place.
|
// --- Organizations ---
|
||||||
// Includes the owner's username (as `ownerUsername`) so the sidebar can display who owns each place.
|
|
||||||
router.get('/places', (req, res) => {
|
// List organizations the current user belongs to, plus (for platform elevated/admin/superadmin
|
||||||
|
// users) every other organization too, so they retain the old "see everything" bypass. Each entry
|
||||||
|
// includes the caller's effective role in that org (`role`) and whether it comes from platform
|
||||||
|
// bypass rather than real membership (`bypass`), so the frontend can grey out admin-only actions
|
||||||
|
// for orgs the user doesn't actually belong to.
|
||||||
|
router.get('/orgs', (req, res) => {
|
||||||
if (req.user.role >= ROLES.ELEVATED) {
|
if (req.user.role >= ROLES.ELEVATED) {
|
||||||
return db.all(
|
return db.all(
|
||||||
`SELECT places.*, users.username AS ownerUsername FROM places LEFT JOIN users ON users.id = places.ownerId`,
|
`SELECT organizations.*, organization_members.role AS memberRole
|
||||||
[],
|
FROM organizations
|
||||||
(err, places) => {
|
LEFT JOIN organization_members ON organization_members.orgId = organizations.id AND organization_members.userId = ?
|
||||||
|
ORDER BY organizations.id`,
|
||||||
|
[req.user.id],
|
||||||
|
(err, orgs) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('Failed to retrieve places:', err.message);
|
console.error('Failed to retrieve organizations:', err.message);
|
||||||
return res.status(500).json({ success: false, message: "Internal server error" });
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
}
|
}
|
||||||
res.json({ success: true, places });
|
res.json({
|
||||||
|
success: true,
|
||||||
|
orgs: orgs.map(o => ({
|
||||||
|
id: o.id,
|
||||||
|
name: o.name,
|
||||||
|
ownerId: o.ownerId,
|
||||||
|
createdAt: o.createdAt,
|
||||||
|
role: o.memberRole != null ? o.memberRole : ORG_ROLES.ADMIN,
|
||||||
|
isMember: o.memberRole != null,
|
||||||
|
bypass: o.memberRole == null
|
||||||
|
}))
|
||||||
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
db.all(
|
db.all(
|
||||||
`SELECT places.*, users.username AS ownerUsername FROM places LEFT JOIN users ON users.id = places.ownerId WHERE places.ownerId = ? OR places.id IN (SELECT placeId FROM place_access WHERE userId = ?)`,
|
`SELECT organizations.*, organization_members.role AS memberRole
|
||||||
[req.user.id, req.user.id],
|
FROM organizations
|
||||||
(err, places) => {
|
JOIN organization_members ON organization_members.orgId = organizations.id
|
||||||
|
WHERE organization_members.userId = ?
|
||||||
|
ORDER BY organizations.id`,
|
||||||
|
[req.user.id],
|
||||||
|
(err, orgs) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('Failed to retrieve places:', err.message);
|
console.error('Failed to retrieve organizations:', err.message);
|
||||||
return res.status(500).json({ success: false, message: "Internal server error" });
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
}
|
}
|
||||||
res.json({ success: true, places });
|
res.json({
|
||||||
|
success: true,
|
||||||
|
orgs: orgs.map(o => ({
|
||||||
|
id: o.id,
|
||||||
|
name: o.name,
|
||||||
|
ownerId: o.ownerId,
|
||||||
|
createdAt: o.createdAt,
|
||||||
|
role: o.memberRole,
|
||||||
|
isMember: true,
|
||||||
|
bypass: false
|
||||||
|
}))
|
||||||
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create a new place. Only admins (and superadmins) may supply a custom id/apiKey - everyone else
|
// Create a new organization. The creator becomes its owner and an Admin member.
|
||||||
// always gets a generated (random UUID / random 64-char) id and apiKey. Admins that leave either
|
router.post('/orgs', (req, res) => {
|
||||||
// field blank also get one generated for them.
|
const name = req.body && req.body.name ? String(req.body.name).trim() : '';
|
||||||
router.post('/places', (req, res) => {
|
if (!name) {
|
||||||
const canSetCustomValues = req.user.role >= ROLES.ADMIN;
|
return res.status(400).json({ success: false, message: "Organization name is required" });
|
||||||
const { settings } = req.body || {};
|
|
||||||
let { id, apiKey, name } = req.body || {};
|
|
||||||
|
|
||||||
if (!canSetCustomValues && (id || apiKey)) {
|
|
||||||
return res.status(403).json({ success: false, message: "Only admins can set a custom place id or API key" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we reach here, either the caller is an admin, or both id and apiKey were already falsy.
|
db.run(`INSERT INTO organizations (name, ownerId) VALUES (?, ?)`, [name, req.user.id], function (err) {
|
||||||
id = id ? String(id).trim() : '';
|
|
||||||
apiKey = apiKey ? String(apiKey).trim() : '';
|
|
||||||
name = name ? String(name).trim() : '';
|
|
||||||
|
|
||||||
if (!id) id = generatePlaceId();
|
|
||||||
if (!apiKey) apiKey = generateApiKey();
|
|
||||||
|
|
||||||
db.get(`SELECT id FROM places WHERE id = ?`, [id], (err, existing) => {
|
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('Failed to check for existing place:', err.message);
|
console.error('Failed to create organization:', err.message);
|
||||||
return res.status(500).json({ success: false, message: "Internal server error" });
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
}
|
}
|
||||||
if (existing) {
|
|
||||||
return res.status(409).json({ success: false, message: "A place with that id already exists" });
|
|
||||||
}
|
|
||||||
|
|
||||||
db.run(`INSERT INTO places (id, apiKey, settings, ownerId, name) VALUES (?, ?, ?, ?, ?)`, [id, apiKey, settings || '{}', req.user.id, name || null], (err) => {
|
const orgId = this.lastID;
|
||||||
|
db.run(`INSERT INTO organization_members (orgId, userId, role, invitedBy) VALUES (?, ?, ?, ?)`, [orgId, req.user.id, ORG_ROLES.ADMIN, req.user.id], (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('Failed to create place:', err.message);
|
console.error('Failed to add creator to new organization:', err.message);
|
||||||
return res.status(500).json({ success: false, message: "Internal server error" });
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
}
|
}
|
||||||
res.json({ success: true, place: { id, apiKey, settings: settings || '{}', ownerId: req.user.id, name: name || null } });
|
res.json({ success: true, org: { id: orgId, name, ownerId: req.user.id, role: ORG_ROLES.ADMIN } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rename an organization
|
||||||
|
router.put('/orgs/:orgId', loadOrg(ORG_ROLES.ADMIN), (req, res) => {
|
||||||
|
const name = req.body && req.body.name ? String(req.body.name).trim() : '';
|
||||||
|
if (!name) {
|
||||||
|
return res.status(400).json({ success: false, message: "Organization name is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.run(`UPDATE organizations SET name = ? WHERE id = ?`, [name, req.org.id], (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Failed to rename organization:', err.message);
|
||||||
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
|
}
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete an organization. Only allowed once it owns no places, so places are never silently
|
||||||
|
// orphaned - the caller must move or delete them first.
|
||||||
|
router.delete('/orgs/:orgId', loadOrg(ORG_ROLES.ADMIN), (req, res) => {
|
||||||
|
db.get(`SELECT COUNT(*) as count FROM places WHERE orgId = ?`, [req.org.id], (err, row) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Failed to check places for organization:', err.message);
|
||||||
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
|
}
|
||||||
|
if (row.count > 0) {
|
||||||
|
return res.status(409).json({ success: false, message: "This organization still owns places. Move or delete them first." });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.run(`DELETE FROM organization_members WHERE orgId = ?`, [req.org.id], (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Failed to delete organization members:', err.message);
|
||||||
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
|
}
|
||||||
|
db.run(`DELETE FROM organizations WHERE id = ?`, [req.org.id], (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Failed to delete organization:', err.message);
|
||||||
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
|
}
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// List members of an organization. Any member (View and up) can see who else is in the org, but
|
||||||
|
// only Admins can add/remove members or change roles (see routes below).
|
||||||
|
router.get('/orgs/:orgId/members', loadOrg(ORG_ROLES.VIEW), (req, res) => {
|
||||||
|
db.all(
|
||||||
|
`SELECT organization_members.id, organization_members.userId, users.username, organization_members.role, organization_members.createdAt
|
||||||
|
FROM organization_members JOIN users ON users.id = organization_members.userId
|
||||||
|
WHERE organization_members.orgId = ?
|
||||||
|
ORDER BY organization_members.role DESC, users.username`,
|
||||||
|
[req.org.id],
|
||||||
|
(err, members) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Failed to retrieve organization members:', err.message);
|
||||||
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
|
}
|
||||||
|
res.json({ success: true, members });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const VALID_ORG_ROLES = [ORG_ROLES.VIEW, ORG_ROLES.EDIT, ORG_ROLES.ADMIN];
|
||||||
|
|
||||||
|
// Add a member to the organization by username, with a chosen role (View/Edit/Admin, scoped to
|
||||||
|
// this organization only - not a platform-wide role). Only Admins may do this.
|
||||||
|
router.post('/orgs/:orgId/members', loadOrg(ORG_ROLES.ADMIN), (req, res) => {
|
||||||
|
const { username } = req.body || {};
|
||||||
|
const role = req.body && req.body.role !== undefined ? parseInt(req.body.role, 10) : ORG_ROLES.VIEW;
|
||||||
|
|
||||||
|
if (!username) {
|
||||||
|
return res.status(400).json({ success: false, message: "Username is required" });
|
||||||
|
}
|
||||||
|
if (!VALID_ORG_ROLES.includes(role)) {
|
||||||
|
return res.status(400).json({ success: false, message: "Invalid role" });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.get(`SELECT id FROM users WHERE username = ?`, [username], (err, user) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Failed to look up user:', err.message);
|
||||||
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
|
}
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ success: false, message: "No user with that username was found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO organization_members (orgId, userId, role, invitedBy) VALUES (?, ?, ?, ?)`,
|
||||||
|
[req.org.id, user.id, role, req.user.id],
|
||||||
|
function (err) {
|
||||||
|
if (err) {
|
||||||
|
if (err.message && err.message.includes('UNIQUE')) {
|
||||||
|
return res.status(409).json({ success: false, message: "That user is already a member of this organization" });
|
||||||
|
}
|
||||||
|
console.error('Failed to add organization member:', err.message);
|
||||||
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
|
}
|
||||||
|
res.json({ success: true, member: { id: this.lastID, orgId: req.org.id, userId: user.id, username, role } });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Change a member's role within the organization. Only Admins may do this, and the organization's
|
||||||
|
// owner can never be demoted (their role always stays Admin), so the org can't be left without an
|
||||||
|
// admin who's guaranteed access.
|
||||||
|
router.put('/orgs/:orgId/members/:userId', loadOrg(ORG_ROLES.ADMIN), (req, res) => {
|
||||||
|
const targetUserId = parseInt(req.params.userId, 10);
|
||||||
|
const role = parseInt(req.body ? req.body.role : undefined, 10);
|
||||||
|
|
||||||
|
if (!VALID_ORG_ROLES.includes(role)) {
|
||||||
|
return res.status(400).json({ success: false, message: "Invalid role" });
|
||||||
|
}
|
||||||
|
if (targetUserId === req.org.ownerId && role !== ORG_ROLES.ADMIN) {
|
||||||
|
return res.status(400).json({ success: false, message: "The organization owner must remain an Admin" });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.run(`UPDATE organization_members SET role = ? WHERE orgId = ? AND userId = ?`, [role, req.org.id, targetUserId], function (err) {
|
||||||
|
if (err) {
|
||||||
|
console.error('Failed to update organization member:', err.message);
|
||||||
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
|
}
|
||||||
|
if (this.changes === 0) {
|
||||||
|
return res.status(404).json({ success: false, message: "That user is not a member of this organization" });
|
||||||
|
}
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove a member from the organization. Only Admins may do this, and the organization's owner can
|
||||||
|
// never be removed (they must delete the organization itself instead, once it owns no places).
|
||||||
|
router.delete('/orgs/:orgId/members/:userId', loadOrg(ORG_ROLES.ADMIN), (req, res) => {
|
||||||
|
const targetUserId = parseInt(req.params.userId, 10);
|
||||||
|
|
||||||
|
if (targetUserId === req.org.ownerId) {
|
||||||
|
return res.status(400).json({ success: false, message: "The organization owner cannot be removed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.run(`DELETE FROM organization_members WHERE orgId = ? AND userId = ?`, [req.org.id, targetUserId], (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Failed to remove organization member:', err.message);
|
||||||
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
|
}
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Places (each belongs to exactly one organization; access is governed by org membership) ---
|
||||||
|
|
||||||
|
// List places belonging to the given organization (?orgId=). Requires at least View access to
|
||||||
|
// that organization.
|
||||||
|
router.get('/places', (req, res) => {
|
||||||
|
const orgId = parseInt(req.query.orgId, 10);
|
||||||
|
|
||||||
|
checkOrgAccess(orgId, req.user, ORG_ROLES.VIEW, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Failed to check organization access:', err.message);
|
||||||
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
|
}
|
||||||
|
if (!result.ok) {
|
||||||
|
return res.status(result.status).json({ success: false, message: result.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.all(`SELECT * FROM places WHERE orgId = ?`, [orgId], (err, places) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Failed to retrieve places:', err.message);
|
||||||
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
|
}
|
||||||
|
res.json({ success: true, places, orgRole: result.role });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a new place within an organization (body.orgId). Requires Edit access in that organization.
|
||||||
|
// Only admins (and superadmins) may supply a custom id/apiKey - everyone else always gets a
|
||||||
|
// generated (random UUID / random 64-char) id and apiKey. Admins that leave either field blank also
|
||||||
|
// get one generated for them.
|
||||||
|
router.post('/places', (req, res) => {
|
||||||
|
const orgId = parseInt(req.body ? req.body.orgId : undefined, 10);
|
||||||
|
|
||||||
|
checkOrgAccess(orgId, req.user, ORG_ROLES.EDIT, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Failed to check organization access:', err.message);
|
||||||
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
|
}
|
||||||
|
if (!result.ok) {
|
||||||
|
return res.status(result.status).json({ success: false, message: result.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSetCustomValues = req.user.role >= ROLES.ADMIN;
|
||||||
|
const { settings } = req.body || {};
|
||||||
|
let { id, apiKey, name } = req.body || {};
|
||||||
|
|
||||||
|
if (!canSetCustomValues && (id || apiKey)) {
|
||||||
|
return res.status(403).json({ success: false, message: "Only admins can set a custom place id or API key" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we reach here, either the caller is an admin, or both id and apiKey were already falsy.
|
||||||
|
id = id ? String(id).trim() : '';
|
||||||
|
apiKey = apiKey ? String(apiKey).trim() : '';
|
||||||
|
name = name ? String(name).trim() : '';
|
||||||
|
|
||||||
|
if (!id) id = generatePlaceId();
|
||||||
|
if (!apiKey) apiKey = generateApiKey();
|
||||||
|
|
||||||
|
db.get(`SELECT id FROM places WHERE id = ?`, [id], (err, existing) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Failed to check for existing place:', err.message);
|
||||||
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
|
}
|
||||||
|
if (existing) {
|
||||||
|
return res.status(409).json({ success: false, message: "A place with that id already exists" });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.run(`INSERT INTO places (id, apiKey, settings, ownerId, name, orgId) VALUES (?, ?, ?, ?, ?, ?)`, [id, apiKey, settings || '{}', req.user.id, name || null, orgId], (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Failed to create place:', err.message);
|
||||||
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
|
}
|
||||||
|
res.json({ success: true, place: { id, apiKey, settings: settings || '{}', ownerId: req.user.id, name: name || null, orgId } });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -153,14 +443,15 @@ router.get('/places/:placeId', loadPlace, (req, res) => {
|
||||||
console.error('Failed to retrieve access points:', err.message);
|
console.error('Failed to retrieve access points:', err.message);
|
||||||
return res.status(500).json({ success: false, message: "Internal server error" });
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
}
|
}
|
||||||
res.json({ success: true, place: req.place, accessPoints, accessLevel: req.placeAccessLevel });
|
res.json({ success: true, place: req.place, accessPoints, orgRole: req.orgRole });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update a place's apiKey/settings. Only admins (and superadmins) may set a custom apiKey value -
|
// Update a place's apiKey/settings. Requires Edit access in the place's organization. Only admins
|
||||||
// everyone else must use the regenerate-key endpoint below. Admins that explicitly send a blank
|
// (and superadmins) may set a custom apiKey value - everyone else must use the regenerate-key
|
||||||
// apiKey get a freshly generated one instead of clearing it.
|
// endpoint below. Admins that explicitly send a blank apiKey get a freshly generated one instead
|
||||||
router.put('/places/:placeId', loadPlace, (req, res) => {
|
// of clearing it.
|
||||||
|
router.put('/places/:placeId', loadPlace, requireOrgEdit, (req, res) => {
|
||||||
const canSetCustomValues = req.user.role >= ROLES.ADMIN;
|
const canSetCustomValues = req.user.role >= ROLES.ADMIN;
|
||||||
|
|
||||||
if (req.body.apiKey !== undefined && !canSetCustomValues) {
|
if (req.body.apiKey !== undefined && !canSetCustomValues) {
|
||||||
|
|
@ -183,10 +474,34 @@ router.put('/places/:placeId', loadPlace, (req, res) => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Regenerate a place's API key to a new random value. Anyone with access to the place (owner,
|
// Move a place to a different organization. Requires Edit access in the place's current
|
||||||
// shared access, or elevated/admin bypass) may do this - unlike setting a custom value, this
|
// organization, and at least Edit access in the destination organization.
|
||||||
// doesn't require admin privileges since it never lets the caller choose the resulting key.
|
router.post('/places/:placeId/move', loadPlace, requireOrgEdit, (req, res) => {
|
||||||
router.post('/places/:placeId/regenerate-key', loadPlace, (req, res) => {
|
const destOrgId = parseInt(req.body ? req.body.orgId : undefined, 10);
|
||||||
|
|
||||||
|
checkOrgAccess(destOrgId, req.user, ORG_ROLES.EDIT, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Failed to check destination organization access:', err.message);
|
||||||
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
|
}
|
||||||
|
if (!result.ok) {
|
||||||
|
return res.status(result.status).json({ success: false, message: result.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.run(`UPDATE places SET orgId = ? WHERE id = ?`, [destOrgId, req.place.id], (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Failed to move place:', err.message);
|
||||||
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
|
}
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regenerate a place's API key to a new random value. Requires Edit access in the place's
|
||||||
|
// organization - unlike setting a custom value, this doesn't require the platform admin role since
|
||||||
|
// it never lets the caller choose the resulting key.
|
||||||
|
router.post('/places/:placeId/regenerate-key', loadPlace, requireOrgEdit, (req, res) => {
|
||||||
const apiKey = generateApiKey();
|
const apiKey = generateApiKey();
|
||||||
|
|
||||||
db.run(`UPDATE places SET apiKey = ? WHERE id = ?`, [apiKey, req.place.id], (err) => {
|
db.run(`UPDATE places SET apiKey = ? WHERE id = ?`, [apiKey, req.place.id], (err) => {
|
||||||
|
|
@ -199,9 +514,9 @@ router.post('/places/:placeId/regenerate-key', loadPlace, (req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Engage a lockdown for this place only, disabling every reader at this place until it's lifted.
|
// Engage a lockdown for this place only, disabling every reader at this place until it's lifted.
|
||||||
// Anyone with access to the place (owner, shared access, or elevated/admin bypass) may do this -
|
// Requires Edit access in the place's organization - unlike the global (all-places) lockdown in
|
||||||
// unlike the global (all-places) lockdown in routes/admin.js, it doesn't require the admin role.
|
// routes/admin.js, it doesn't require the platform admin role.
|
||||||
router.post('/places/:placeId/lockdown', loadPlace, (req, res) => {
|
router.post('/places/:placeId/lockdown', loadPlace, requireOrgEdit, (req, res) => {
|
||||||
db.run(
|
db.run(
|
||||||
`UPDATE places SET lockdown = 1, lockdownActivatedBy = ?, lockdownActivatedAt = CURRENT_TIMESTAMP WHERE id = ?`,
|
`UPDATE places SET lockdown = 1, lockdownActivatedBy = ?, lockdownActivatedAt = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||||
[req.user.id, req.place.id],
|
[req.user.id, req.place.id],
|
||||||
|
|
@ -216,7 +531,7 @@ router.post('/places/:placeId/lockdown', loadPlace, (req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Lift this place's lockdown, restoring every reader at this place to its own configured state.
|
// Lift this place's lockdown, restoring every reader at this place to its own configured state.
|
||||||
router.delete('/places/:placeId/lockdown', loadPlace, (req, res) => {
|
router.delete('/places/:placeId/lockdown', loadPlace, requireOrgEdit, (req, res) => {
|
||||||
db.run(
|
db.run(
|
||||||
`UPDATE places SET lockdown = 0, lockdownActivatedBy = NULL, lockdownActivatedAt = NULL WHERE id = ?`,
|
`UPDATE places SET lockdown = 0, lockdownActivatedBy = NULL, lockdownActivatedAt = NULL WHERE id = ?`,
|
||||||
[req.place.id],
|
[req.place.id],
|
||||||
|
|
@ -231,7 +546,7 @@ router.delete('/places/:placeId/lockdown', loadPlace, (req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Download the place's Roblox kit (xcs-template.rbxmx) with the placeId/apiKey placeholders
|
// Download the place's Roblox kit (xcs-template.rbxmx) with the placeId/apiKey placeholders
|
||||||
// filled in for this specific place. Available to anyone with access to the place.
|
// filled in for this specific place. Available to anyone with (at least View) access to the place.
|
||||||
router.get('/places/:placeId/kit', loadPlace, (req, res) => {
|
router.get('/places/:placeId/kit', loadPlace, (req, res) => {
|
||||||
fs.readFile(KIT_TEMPLATE_PATH, 'utf8', (err, template) => {
|
fs.readFile(KIT_TEMPLATE_PATH, 'utf8', (err, template) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
|
|
@ -250,8 +565,9 @@ router.get('/places/:placeId/kit', loadPlace, (req, res) => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete a place (and its readers/acl entries/access groups/scan logs/shared access grants)
|
// Delete a place (and its readers/acl entries/access groups/scan logs). Requires Edit access in
|
||||||
router.delete('/places/:placeId', loadPlace, requireOwnerOrBypass, (req, res) => {
|
// the place's organization.
|
||||||
|
router.delete('/places/:placeId', loadPlace, requireOrgEdit, (req, res) => {
|
||||||
db.all(`SELECT id FROM access_points WHERE placeId = ?`, [req.place.id], (err, accessPoints) => {
|
db.all(`SELECT id FROM access_points WHERE placeId = ?`, [req.place.id], (err, accessPoints) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('Failed to retrieve access points:', err.message);
|
console.error('Failed to retrieve access points:', err.message);
|
||||||
|
|
@ -300,19 +616,12 @@ router.delete('/places/:placeId', loadPlace, requireOwnerOrBypass, (req, res) =>
|
||||||
return res.status(500).json({ success: false, message: "Internal server error" });
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
}
|
}
|
||||||
|
|
||||||
db.run(`DELETE FROM place_access WHERE placeId = ?`, [req.place.id], (err) => {
|
db.run(`DELETE FROM places WHERE id = ?`, [req.place.id], (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('Failed to delete access grants for place:', err.message);
|
console.error('Failed to delete place:', err.message);
|
||||||
return res.status(500).json({ success: false, message: "Internal server error" });
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
}
|
}
|
||||||
|
res.json({ success: true });
|
||||||
db.run(`DELETE FROM places WHERE id = ?`, [req.place.id], (err) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('Failed to delete place:', err.message);
|
|
||||||
return res.status(500).json({ success: false, message: "Internal server error" });
|
|
||||||
}
|
|
||||||
res.json({ success: true });
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -337,7 +646,7 @@ function loadOwnedAccessPoint(req, res, next) {
|
||||||
// Create a new reader (access point) for a place. The reader id is always
|
// Create a new reader (access point) for a place. The reader id is always
|
||||||
// generated server-side as a random 16-character alphanumeric string; no
|
// generated server-side as a random 16-character alphanumeric string; no
|
||||||
// caller-supplied id is ever accepted.
|
// caller-supplied id is ever accepted.
|
||||||
router.post('/places/:placeId/access-points', loadPlace, (req, res) => {
|
router.post('/places/:placeId/access-points', loadPlace, requireOrgEdit, (req, res) => {
|
||||||
const { name } = req.body || {};
|
const { name } = req.body || {};
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return res.status(400).json({ success: false, message: "Reader name is required" });
|
return res.status(400).json({ success: false, message: "Reader name is required" });
|
||||||
|
|
@ -379,7 +688,7 @@ router.post('/places/:placeId/access-points', loadPlace, (req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update a reader's settings
|
// Update a reader's settings
|
||||||
router.put('/places/:placeId/access-points/:accessPointId', loadPlace, loadOwnedAccessPoint, (req, res) => {
|
router.put('/places/:placeId/access-points/:accessPointId', loadPlace, requireOrgEdit, loadOwnedAccessPoint, (req, res) => {
|
||||||
const ap = req.accessPoint;
|
const ap = req.accessPoint;
|
||||||
const name = req.body.name !== undefined ? req.body.name : ap.name;
|
const name = req.body.name !== undefined ? req.body.name : ap.name;
|
||||||
const enabled = req.body.enabled !== undefined ? (req.body.enabled ? 1 : 0) : ap.enabled;
|
const enabled = req.body.enabled !== undefined ? (req.body.enabled ? 1 : 0) : ap.enabled;
|
||||||
|
|
@ -404,7 +713,7 @@ router.put('/places/:placeId/access-points/:accessPointId', loadPlace, loadOwned
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete a reader
|
// Delete a reader
|
||||||
router.delete('/places/:placeId/access-points/:accessPointId', loadPlace, loadOwnedAccessPoint, (req, res) => {
|
router.delete('/places/:placeId/access-points/:accessPointId', loadPlace, requireOrgEdit, loadOwnedAccessPoint, (req, res) => {
|
||||||
db.run(`DELETE FROM acl WHERE accessPoint = ?`, [req.accessPoint.id], (err) => {
|
db.run(`DELETE FROM acl WHERE accessPoint = ?`, [req.accessPoint.id], (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('Failed to delete ACL entries for reader:', err.message);
|
console.error('Failed to delete ACL entries for reader:', err.message);
|
||||||
|
|
@ -454,7 +763,7 @@ router.get('/places/:placeId/access-points/:accessPointId/acl', loadPlace, loadO
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add an ACL entry for a reader
|
// Add an ACL entry for a reader
|
||||||
router.post('/places/:placeId/access-points/:accessPointId/acl', loadPlace, loadOwnedAccessPoint, (req, res) => {
|
router.post('/places/:placeId/access-points/:accessPointId/acl', loadPlace, requireOrgEdit, loadOwnedAccessPoint, (req, res) => {
|
||||||
const { type, data } = req.body || {};
|
const { type, data } = req.body || {};
|
||||||
if (type === undefined || !data) {
|
if (type === undefined || !data) {
|
||||||
return res.status(400).json({ success: false, message: "ACL type and data are required" });
|
return res.status(400).json({ success: false, message: "ACL type and data are required" });
|
||||||
|
|
@ -470,7 +779,7 @@ router.post('/places/:placeId/access-points/:accessPointId/acl', loadPlace, load
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete an ACL entry
|
// Delete an ACL entry
|
||||||
router.delete('/places/:placeId/access-points/:accessPointId/acl/:aclId', loadPlace, loadOwnedAccessPoint, (req, res) => {
|
router.delete('/places/:placeId/access-points/:accessPointId/acl/:aclId', loadPlace, requireOrgEdit, loadOwnedAccessPoint, (req, res) => {
|
||||||
db.run(`DELETE FROM acl WHERE id = ? AND accessPoint = ?`, [req.params.aclId, req.accessPoint.id], (err) => {
|
db.run(`DELETE FROM acl WHERE id = ? AND accessPoint = ?`, [req.params.aclId, req.accessPoint.id], (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('Failed to delete ACL entry:', err.message);
|
console.error('Failed to delete ACL entry:', err.message);
|
||||||
|
|
@ -506,7 +815,7 @@ router.get('/places/:placeId/access-groups', loadPlace, (req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create an access group (e.g. "Staff") for a place
|
// Create an access group (e.g. "Staff") for a place
|
||||||
router.post('/places/:placeId/access-groups', loadPlace, (req, res) => {
|
router.post('/places/:placeId/access-groups', loadPlace, requireOrgEdit, (req, res) => {
|
||||||
const { name } = req.body || {};
|
const { name } = req.body || {};
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return res.status(400).json({ success: false, message: "Access group name is required" });
|
return res.status(400).json({ success: false, message: "Access group name is required" });
|
||||||
|
|
@ -522,7 +831,7 @@ router.post('/places/:placeId/access-groups', loadPlace, (req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Rename an access group
|
// Rename an access group
|
||||||
router.put('/places/:placeId/access-groups/:groupId', loadPlace, loadOwnedAccessGroup, (req, res) => {
|
router.put('/places/:placeId/access-groups/:groupId', loadPlace, requireOrgEdit, loadOwnedAccessGroup, (req, res) => {
|
||||||
const name = req.body.name !== undefined ? req.body.name : req.accessGroup.name;
|
const name = req.body.name !== undefined ? req.body.name : req.accessGroup.name;
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return res.status(400).json({ success: false, message: "Access group name is required" });
|
return res.status(400).json({ success: false, message: "Access group name is required" });
|
||||||
|
|
@ -538,7 +847,7 @@ router.put('/places/:placeId/access-groups/:groupId', loadPlace, loadOwnedAccess
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete an access group (and its members, and any ACL entries referencing it)
|
// Delete an access group (and its members, and any ACL entries referencing it)
|
||||||
router.delete('/places/:placeId/access-groups/:groupId', loadPlace, loadOwnedAccessGroup, (req, res) => {
|
router.delete('/places/:placeId/access-groups/:groupId', loadPlace, requireOrgEdit, loadOwnedAccessGroup, (req, res) => {
|
||||||
db.run(`DELETE FROM access_group_members WHERE groupId = ?`, [req.accessGroup.id], (err) => {
|
db.run(`DELETE FROM access_group_members WHERE groupId = ?`, [req.accessGroup.id], (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('Failed to delete access group members:', err.message);
|
console.error('Failed to delete access group members:', err.message);
|
||||||
|
|
@ -573,7 +882,7 @@ router.get('/places/:placeId/access-groups/:groupId/members', loadPlace, loadOwn
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add a member (user, card, group rank rule, etc) to an access group
|
// Add a member (user, card, group rank rule, etc) to an access group
|
||||||
router.post('/places/:placeId/access-groups/:groupId/members', loadPlace, loadOwnedAccessGroup, (req, res) => {
|
router.post('/places/:placeId/access-groups/:groupId/members', loadPlace, requireOrgEdit, loadOwnedAccessGroup, (req, res) => {
|
||||||
const { type, data } = req.body || {};
|
const { type, data } = req.body || {};
|
||||||
if (type === undefined || !data) {
|
if (type === undefined || !data) {
|
||||||
return res.status(400).json({ success: false, message: "Member type and data are required" });
|
return res.status(400).json({ success: false, message: "Member type and data are required" });
|
||||||
|
|
@ -593,7 +902,7 @@ router.post('/places/:placeId/access-groups/:groupId/members', loadPlace, loadOw
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove a member from an access group
|
// Remove a member from an access group
|
||||||
router.delete('/places/:placeId/access-groups/:groupId/members/:memberId', loadPlace, loadOwnedAccessGroup, (req, res) => {
|
router.delete('/places/:placeId/access-groups/:groupId/members/:memberId', loadPlace, requireOrgEdit, loadOwnedAccessGroup, (req, res) => {
|
||||||
db.run(`DELETE FROM access_group_members WHERE id = ? AND groupId = ?`, [req.params.memberId, req.accessGroup.id], (err) => {
|
db.run(`DELETE FROM access_group_members WHERE id = ? AND groupId = ?`, [req.params.memberId, req.accessGroup.id], (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('Failed to remove access group member:', err.message);
|
console.error('Failed to remove access group member:', err.message);
|
||||||
|
|
@ -603,69 +912,3 @@ router.delete('/places/:placeId/access-groups/:groupId/members/:memberId', loadP
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Shared place access (lets an owner grant other users access to a place's settings) ---
|
|
||||||
|
|
||||||
// List users who have been granted shared access to this place
|
|
||||||
router.get('/places/:placeId/access', loadPlace, requireOwnerOrBypass, (req, res) => {
|
|
||||||
db.all(
|
|
||||||
`SELECT place_access.id, place_access.userId, users.username, place_access.createdAt
|
|
||||||
FROM place_access JOIN users ON users.id = place_access.userId
|
|
||||||
WHERE place_access.placeId = ?`,
|
|
||||||
[req.place.id],
|
|
||||||
(err, grants) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('Failed to retrieve access grants:', err.message);
|
|
||||||
return res.status(500).json({ success: false, message: "Internal server error" });
|
|
||||||
}
|
|
||||||
res.json({ success: true, grants });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Grant another user access to this place's settings, by username
|
|
||||||
router.post('/places/:placeId/access', loadPlace, requireOwnerOrBypass, (req, res) => {
|
|
||||||
const { username } = req.body || {};
|
|
||||||
if (!username) {
|
|
||||||
return res.status(400).json({ success: false, message: "Username is required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
db.get(`SELECT id FROM users WHERE username = ?`, [username], (err, user) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('Failed to look up user:', err.message);
|
|
||||||
return res.status(500).json({ success: false, message: "Internal server error" });
|
|
||||||
}
|
|
||||||
if (!user) {
|
|
||||||
return res.status(404).json({ success: false, message: "No user with that username was found" });
|
|
||||||
}
|
|
||||||
if (user.id === req.place.ownerId) {
|
|
||||||
return res.status(400).json({ success: false, message: "The place owner already has full access" });
|
|
||||||
}
|
|
||||||
|
|
||||||
db.run(
|
|
||||||
`INSERT INTO place_access (placeId, userId, grantedBy) VALUES (?, ?, ?)`,
|
|
||||||
[req.place.id, user.id, req.user.id],
|
|
||||||
function (err) {
|
|
||||||
if (err) {
|
|
||||||
if (err.message && err.message.includes('UNIQUE')) {
|
|
||||||
return res.status(409).json({ success: false, message: "That user already has access to this place" });
|
|
||||||
}
|
|
||||||
console.error('Failed to grant place access:', err.message);
|
|
||||||
return res.status(500).json({ success: false, message: "Internal server error" });
|
|
||||||
}
|
|
||||||
res.json({ success: true, grant: { id: this.lastID, placeId: req.place.id, userId: user.id, username } });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Revoke a user's shared access to this place
|
|
||||||
router.delete('/places/:placeId/access/:userId', loadPlace, requireOwnerOrBypass, (req, res) => {
|
|
||||||
db.run(`DELETE FROM place_access WHERE placeId = ? AND userId = ?`, [req.place.id, req.params.userId], (err) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('Failed to revoke place access:', err.message);
|
|
||||||
return res.status(500).json({ success: false, message: "Internal server error" });
|
|
||||||
}
|
|
||||||
res.json({ success: true });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue