dev #1

Merged
ChrisChrome merged 2 commits from dev into main 2026-07-03 09:48:55 -06:00
11 changed files with 1086 additions and 281 deletions

3
.gitignore vendored
View file

@ -142,4 +142,5 @@ dist
vite.config.js.timestamp-* 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
View 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);

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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