diff --git a/.gitignore b/.gitignore index 71b9c38..316ef1b 100644 --- a/.gitignore +++ b/.gitignore @@ -142,4 +142,5 @@ dist vite.config.js.timestamp-* vite.config.ts.timestamp-* .vite/ -database.db \ No newline at end of file +database.db +database.db* \ No newline at end of file diff --git a/_test_server_temp.js b/_test_server_temp.js new file mode 100644 index 0000000..579a8b0 --- /dev/null +++ b/_test_server_temp.js @@ -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); diff --git a/lib/auth.js b/lib/auth.js index cc6db11..17cdde1 100644 --- a/lib/auth.js +++ b/lib/auth.js @@ -12,6 +12,19 @@ const ROLES = { 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. // `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). @@ -45,4 +58,4 @@ function requireAuth(getDb, minRole = ROLES.USER) { }; } -module.exports = { ROLES, requireAuth }; +module.exports = { ROLES, ORG_ROLES, requireAuth }; diff --git a/lib/orgs.js b/lib/orgs.js new file mode 100644 index 0000000..0ec11a0 --- /dev/null +++ b/lib/orgs.js @@ -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 }; + diff --git a/migrations/0008_organizations.sql b/migrations/0008_organizations.sql new file mode 100644 index 0000000..2cbaddf --- /dev/null +++ b/migrations/0008_organizations.sql @@ -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; diff --git a/public/css/style.css b/public/css/style.css index 97b7882..28c9e2a 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -174,6 +174,42 @@ button.danger { 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 { list-style: none; margin: 0; diff --git a/public/dashboard/index.html b/public/dashboard/index.html index f80e86c..329499c 100644 --- a/public/dashboard/index.html +++ b/public/dashboard/index.html @@ -11,12 +11,22 @@ +
-
-

Shared access

-

Grant other users access to manage this place's settings, readers and access groups.

- +
+

Move to another organization

+

Move this place to a different organization you have Edit access in. Everyone in the current organization will lose access to it.

+
- - + +
- + - -
    +