feat: 3-tier role system across all platform apps

Portal:
- Replace is_admin boolean with role column (admin/advanced/standard)
- Settings UI: 3-tier portal role select + per-app role dropdowns
- /portal-return endpoint: re-establishes session from JWT for sub-app back-links
- /api/internal/nv-users: internal endpoint for NetworkView user sync
- portal/migrate_roles.py: one-time DB migration script
- Role badges (admin/advanced/standard) in topbar and settings table

DigiServer:
- Add editor and viewer roles (portal advanced->editor, standard->viewer)
- PlaylistPermission model: grant viewer users edit access to specific playlists
- app/utils/access.py: shared editor_required, admin_required, can_edit_playlist helpers
- Content routes: editor_required on create/delete, per-playlist permission check on mutations
- Admin: playlist_permissions route + template to manage viewer playlist grants
- Base template: hide Admin nav for viewers, Portal button (⬡) returns to portal
- content_list_new: hide create/delete for viewers; Manage vs View button per permission
- manage_playlist_content: view-only mode when user lacks edit permission

NetworkView:
- backend/src/middleware/rbac.js: requireRole + requireWriteAccess helpers
- site_permissions table: one site per advanced user
- All mutating routes guarded (admin=all, advanced=assigned site, viewer=read-only)
- Portal SSO auto-upsert: user row synced from X-Auth-Role on every request
- GET /api/users: merges portal users list with local NV data (all 4 portal users visible)
- GET/PUT /api/users/:id/site-permission: assign site to advanced user
- Settings Users tab: role badges, site dropdown for advanced, (portal only) indicator
- Sidebar: ⬡ Portal button between Settings and Logout
- Frontend build: VITE_API_BASE=/networkview/api now set in start-dev.sh

IT Assets / Server Monitor:
- portal_sso.py updated: map advanced->editor/viewer, standard->readonly
- AdminUser model: add editor role + is_editor property
This commit is contained in:
ske087
2026-07-07 00:08:46 +03:00
parent 5762dd420d
commit 1f6217d347
41 changed files with 1028 additions and 153 deletions
+5 -5
View File
@@ -1,5 +1,5 @@
18392 36054
18395 36057
18398 36060
18401 36063
18405 36066
+10 -1
View File
@@ -13,7 +13,16 @@ class AdminUser(UserMixin, db.Model):
full_name = db.Column(db.String(200), nullable=True) full_name = db.Column(db.String(200), nullable=True)
email = db.Column(db.String(200), unique=True, nullable=False) email = db.Column(db.String(200), unique=True, nullable=False)
password_hash = db.Column(db.String(256), nullable=False) password_hash = db.Column(db.String(256), nullable=False)
role = db.Column(db.String(30), default='admin') # admin, readonly role = db.Column(db.String(30), default='readonly') # admin | editor | readonly
@property
def is_admin(self):
return self.role == 'admin'
@property
def is_editor(self):
"""True for admin and editor roles (can manage assets)."""
return self.role in ('admin', 'editor')
is_active = db.Column(db.Boolean, default=True) is_active = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
last_login = db.Column(db.DateTime, nullable=True) last_login = db.Column(db.DateTime, nullable=True)
+16 -2
View File
@@ -3,7 +3,12 @@ Portal SSO middleware for IT Asset Management.
When the umbrella nginx verifies the portal JWT it sets two headers: When the umbrella nginx verifies the portal JWT it sets two headers:
X-Auth-Username — the portal username X-Auth-Username — the portal username
X-Auth-Role — 'admin' or 'user' X-Auth-Role — 'admin' | 'advanced' | 'standard'
Portal role → IT Assets local role mapping:
admin → admin (full access including settings)
advanced → editor (manage assets/assignments, no system settings)
standard → readonly (view-only)
This before_request handler reads those headers and auto-logs in the This before_request handler reads those headers and auto-logs in the
corresponding local AdminUser, creating them on first access if needed. corresponding local AdminUser, creating them on first access if needed.
@@ -32,11 +37,20 @@ def init_portal_sso(app):
login_user(user, remember=False) login_user(user, remember=False)
def _portal_role_to_local(portal_role):
"""Map a portal role string to the IT Assets local role."""
if portal_role == 'admin':
return 'admin'
if portal_role == 'advanced':
return 'editor'
return 'readonly' # 'standard' or anything unknown
def _get_or_create_user(username, role): def _get_or_create_user(username, role):
from app.models.admin_user import AdminUser from app.models.admin_user import AdminUser
from app.extensions import db from app.extensions import db
target_role = 'admin' if role == 'admin' else 'readonly' target_role = _portal_role_to_local(role)
try: try:
user = AdminUser.query.filter_by(username=username).first() user = AdminUser.query.filter_by(username=username).first()
if not user: if not user:
+10
View File
@@ -120,4 +120,14 @@ db.exec(`
); );
`); `);
// ── Site permissions (advanced role: one site per user) ──────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS site_permissions (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
granted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id)
);
`);
module.exports = db; module.exports = db;
+35 -5
View File
@@ -11,25 +11,55 @@ app.use(express.json());
app.use(morgan('dev')); app.use(morgan('dev'));
// ── Actor middleware ────────────────────────────────────────────────────────── // ── Actor middleware ──────────────────────────────────────────────────────────
// The external main app sets x-user-id + x-username after token verification. // The external main app sets x-user-id + x-username + x-auth-role after token verification.
// Direct calls can use x-api-key (validated against users.api_key). // Direct calls can use x-api-key (validated against users.api_key).
const VALID_ROLES = new Set(['admin', 'advanced', 'standard']);
const db = require('./db'); const db = require('./db');
app.use((req, _res, next) => { app.use((req, _res, next) => {
const userId = req.headers['x-user-id']; const userId = req.headers['x-user-id'];
const username = req.headers['x-username']; const username = req.headers['x-username'];
const apiKey = req.headers['x-api-key']; const apiKey = req.headers['x-api-key'];
const headerRole = (req.headers['x-auth-role'] || '').trim();
const portalRole = VALID_ROLES.has(headerRole) ? headerRole : 'standard';
if (userId && username) { if (userId && username) {
req.actor = { id: userId, username: String(username) }; req.actor = { id: String(userId), username: String(username), role: portalRole };
// ── Portal SSO sync ──────────────────────────────────────────────────────
// Upsert the user row so the local users table always reflects the portal.
// Map portal roles → NV local roles: admin→admin, advanced→advanced, standard→viewer
const localRole = portalRole === 'admin' ? 'admin'
: portalRole === 'advanced' ? 'advanced'
: 'viewer';
try {
const existing = db.prepare('SELECT id, role FROM users WHERE username = ?').get(username);
if (!existing) {
const { v4: uuidv4 } = require('uuid');
db.prepare(
'INSERT INTO users (id, username, role, is_active) VALUES (?, ?, ?, 1)'
).run(String(userId), String(username), localRole);
} else if (existing.role !== localRole) {
db.prepare('UPDATE users SET role = ?, updated_at = CURRENT_TIMESTAMP WHERE username = ?')
.run(localRole, String(username));
}
} catch (_) { /* best-effort; never break the request */ }
} else if (apiKey) { } else if (apiKey) {
const user = db.prepare('SELECT id, username FROM users WHERE api_key = ? AND is_active = 1').get(apiKey); const user = db.prepare('SELECT id, username, role FROM users WHERE api_key = ? AND is_active = 1').get(apiKey);
req.actor = user ? { id: user.id, username: user.username } : { id: null, username: 'api-key-invalid' }; req.actor = user
? { id: user.id, username: user.username, role: 'admin' }
: { id: null, username: 'api-key-invalid', role: 'standard' };
} else { } else {
req.actor = { id: null, username: 'anonymous' }; req.actor = { id: null, username: 'anonymous', role: 'standard' };
} }
next(); next();
}); });
// ── RBAC helpers (used by route files) ───────────────────────────────────────
// requireRole / requireWriteAccess are defined in middleware/rbac.js
app.locals.requireRole = require('./middleware/rbac').requireRole;
// API routes // API routes
app.use('/api/sites', require('./routes/sites')); app.use('/api/sites', require('./routes/sites'));
app.use('/api/rooms', require('./routes/rooms')); app.use('/api/rooms', require('./routes/rooms'));
@@ -0,0 +1,95 @@
/**
* RBAC middleware for NetworkView.
*
* Role tiers (from portal via X-Auth-Role header):
* admin full CRUD on everything
* advanced CRUD scoped to ONE assigned site (site_permissions table)
* standard read-only (GET only)
*
* Helpers exported:
* requireRole(minRole) rejects if actor tier < minRole
* requireWriteAccess(getSiteId) admin: always; advanced: only their site;
* standard: never
*/
const db = require('../db');
// Numeric tier so we can do >= comparisons
const TIERS = { standard: 0, advanced: 1, admin: 2 };
/** Reject if actor's role tier is below minRole. */
function requireRole(minRole) {
const min = TIERS[minRole] ?? 0;
return (req, res, next) => {
const tier = TIERS[req.actor?.role] ?? 0;
if (tier < min) {
return res.status(403).json({
error: `Requires ${minRole} role (you have ${req.actor?.role ?? 'none'})`,
});
}
next();
};
}
/**
* requireWriteAccess(getSiteIdFn)
*
* getSiteIdFn(req) → siteId string (or null/undefined)
* called only when role is 'advanced' to resolve which site is being touched.
* for 'admin' it is never called.
* for 'standard' it is never called (rejected immediately).
*
* Usage:
* router.post('/', requireWriteAccess(req => req.body.site_id), handler)
* router.delete('/:id', requireWriteAccess(req => {
* const room = db.prepare('SELECT site_id FROM rooms WHERE id=?').get(req.params.id);
* return room?.site_id;
* }), handler)
*/
function requireWriteAccess(getSiteIdFn) {
return (req, res, next) => {
const role = req.actor?.role ?? 'standard';
if (role === 'admin') return next();
if (role !== 'advanced') {
return res.status(403).json({ error: 'Write access requires advanced or admin role.' });
}
// advanced: check site_permissions
const userId = req.actor?.id;
if (!userId) {
return res.status(403).json({ error: 'No user identity for permission check.' });
}
const perm = db.prepare(
'SELECT site_id FROM site_permissions WHERE user_id = ?'
).get(userId);
if (!perm) {
return res.status(403).json({
error: 'Advanced user has no site assigned. Ask an admin to assign a site.',
});
}
// Resolve the target site from the request
let targetSiteId;
try {
targetSiteId = getSiteIdFn(req);
} catch (_) {
targetSiteId = null;
}
if (!targetSiteId || targetSiteId !== perm.site_id) {
return res.status(403).json({
error: 'You can only modify your assigned site.',
});
}
// Attach for downstream use
req.permittedSiteId = perm.site_id;
next();
};
}
module.exports = { requireRole, requireWriteAccess, TIERS };
+23 -6
View File
@@ -2,8 +2,25 @@ const express = require('express');
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const db = require('../db'); const db = require('../db');
const { logAudit } = require('../audit'); const { logAudit } = require('../audit');
const { requireWriteAccess } = require('../middleware/rbac');
const router = express.Router(); const router = express.Router();
const siteIdFromComponentBody = req => {
if (!req.body.rack_id) return null;
const rack = db.prepare('SELECT room_id FROM racks WHERE id=?').get(req.body.rack_id);
if (!rack?.room_id) return null;
const r = db.prepare('SELECT site_id FROM rooms WHERE id=?').get(rack.room_id);
return r?.site_id;
};
const siteIdFromComponentPk = req => {
const c = db.prepare('SELECT rack_id FROM components WHERE id=?').get(req.params.id ?? req.params.componentId);
if (!c?.rack_id) return null;
const rack = db.prepare('SELECT room_id FROM racks WHERE id=?').get(c.rack_id);
if (!rack?.room_id) return null;
const r = db.prepare('SELECT site_id FROM rooms WHERE id=?').get(rack.room_id);
return r?.site_id;
};
function checkPositionOverlap(rackId, position, heightUnits, excludeId = null) { function checkPositionOverlap(rackId, position, heightUnits, excludeId = null) {
const end = position + heightUnits - 1; const end = position + heightUnits - 1;
const existing = db.prepare(` const existing = db.prepare(`
@@ -54,7 +71,7 @@ router.get('/:id', (req, res) => {
}); });
// POST /api/components // POST /api/components
router.post('/', (req, res) => { router.post('/', requireWriteAccess(siteIdFromComponentBody), (req, res) => {
const { const {
rack_id, name, type, position, height_units, rack_id, name, type, position, height_units,
manufacturer, model, serial_number, asset_tag, manufacturer, model, serial_number, asset_tag,
@@ -103,7 +120,7 @@ router.post('/', (req, res) => {
}); });
// PUT /api/components/:id // PUT /api/components/:id
router.put('/:id', (req, res) => { router.put('/:id', requireWriteAccess(siteIdFromComponentPk), (req, res) => {
const component = db.prepare('SELECT * FROM components WHERE id = ?').get(req.params.id); const component = db.prepare('SELECT * FROM components WHERE id = ?').get(req.params.id);
if (!component) return res.status(404).json({ error: 'Component not found' }); if (!component) return res.status(404).json({ error: 'Component not found' });
@@ -162,7 +179,7 @@ router.put('/:id', (req, res) => {
}); });
// DELETE /api/components/:id // DELETE /api/components/:id
router.delete('/:id', (req, res) => { router.delete('/:id', requireWriteAccess(siteIdFromComponentPk), (req, res) => {
const component = db.prepare('SELECT * FROM components WHERE id = ?').get(req.params.id); const component = db.prepare('SELECT * FROM components WHERE id = ?').get(req.params.id);
if (!component) return res.status(404).json({ error: 'Component not found' }); if (!component) return res.status(404).json({ error: 'Component not found' });
@@ -180,7 +197,7 @@ router.get('/:id/ports', (req, res) => {
}); });
// POST /api/components/:id/ports // POST /api/components/:id/ports
router.post('/:id/ports', (req, res) => { router.post('/:id/ports', requireWriteAccess(siteIdFromComponentPk), (req, res) => {
const component = db.prepare('SELECT id, type, port_count FROM components WHERE id = ?').get(req.params.id); const component = db.prepare('SELECT id, type, port_count FROM components WHERE id = ?').get(req.params.id);
if (!component) return res.status(404).json({ error: 'Component not found' }); if (!component) return res.status(404).json({ error: 'Component not found' });
@@ -216,7 +233,7 @@ router.post('/:id/ports', (req, res) => {
}); });
// PUT /api/components/:componentId/ports/:portId // PUT /api/components/:componentId/ports/:portId
router.put('/:componentId/ports/:portId', (req, res) => { router.put('/:componentId/ports/:portId', requireWriteAccess(siteIdFromComponentPk), (req, res) => {
const port = db.prepare('SELECT * FROM ports WHERE id = ? AND component_id = ?').get(req.params.portId, req.params.componentId); const port = db.prepare('SELECT * FROM ports WHERE id = ? AND component_id = ?').get(req.params.portId, req.params.componentId);
if (!port) return res.status(404).json({ error: 'Port not found' }); if (!port) return res.status(404).json({ error: 'Port not found' });
@@ -249,7 +266,7 @@ router.put('/:componentId/ports/:portId', (req, res) => {
}); });
// DELETE /api/components/:componentId/ports/:portId // DELETE /api/components/:componentId/ports/:portId
router.delete('/:componentId/ports/:portId', (req, res) => { router.delete('/:componentId/ports/:portId', requireWriteAccess(siteIdFromComponentPk), (req, res) => {
db.prepare('DELETE FROM ports WHERE id = ? AND component_id = ?').run(req.params.portId, req.params.componentId); db.prepare('DELETE FROM ports WHERE id = ? AND component_id = ?').run(req.params.portId, req.params.componentId);
res.json({ ok: true }); res.json({ ok: true });
}); });
+16 -3
View File
@@ -2,8 +2,21 @@ const express = require('express');
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const db = require('../db'); const db = require('../db');
const { logAudit } = require('../audit'); const { logAudit } = require('../audit');
const { requireWriteAccess } = require('../middleware/rbac');
const router = express.Router(); const router = express.Router();
const siteIdFromRackBody = req => {
if (!req.body.room_id) return null;
const r = db.prepare('SELECT site_id FROM rooms WHERE id=?').get(req.body.room_id);
return r?.site_id;
};
const siteIdFromRackPk = req => {
const rack = db.prepare('SELECT room_id FROM racks WHERE id=?').get(req.params.id);
if (!rack?.room_id) return null;
const r = db.prepare('SELECT site_id FROM rooms WHERE id=?').get(rack.room_id);
return r?.site_id;
};
// GET /api/racks?roomId= // GET /api/racks?roomId=
router.get('/', (req, res) => { router.get('/', (req, res) => {
const { roomId } = req.query; const { roomId } = req.query;
@@ -45,7 +58,7 @@ router.get('/:id', (req, res) => {
}); });
// POST /api/racks // POST /api/racks
router.post('/', (req, res) => { router.post('/', requireWriteAccess(siteIdFromRackBody), (req, res) => {
const { room_id, name, total_units, manufacturer, model, notes } = req.body; const { room_id, name, total_units, manufacturer, model, notes } = req.body;
if (!name) return res.status(400).json({ error: 'name is required' }); if (!name) return res.status(400).json({ error: 'name is required' });
@@ -66,7 +79,7 @@ router.post('/', (req, res) => {
}); });
// PUT /api/racks/:id // PUT /api/racks/:id
router.put('/:id', (req, res) => { router.put('/:id', requireWriteAccess(siteIdFromRackPk), (req, res) => {
const rack = db.prepare('SELECT * FROM racks WHERE id = ?').get(req.params.id); const rack = db.prepare('SELECT * FROM racks WHERE id = ?').get(req.params.id);
if (!rack) return res.status(404).json({ error: 'Rack not found' }); if (!rack) return res.status(404).json({ error: 'Rack not found' });
@@ -98,7 +111,7 @@ router.put('/:id', (req, res) => {
}); });
// DELETE /api/racks/:id // DELETE /api/racks/:id
router.delete('/:id', (req, res) => { router.delete('/:id', requireWriteAccess(siteIdFromRackPk), (req, res) => {
const rack = db.prepare('SELECT * FROM racks WHERE id = ?').get(req.params.id); const rack = db.prepare('SELECT * FROM racks WHERE id = ?').get(req.params.id);
if (!rack) return res.status(404).json({ error: 'Rack not found' }); if (!rack) return res.status(404).json({ error: 'Rack not found' });
+11 -3
View File
@@ -2,8 +2,16 @@ const express = require('express');
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const db = require('../db'); const db = require('../db');
const { logAudit } = require('../audit'); const { logAudit } = require('../audit');
const { requireWriteAccess } = require('../middleware/rbac');
const router = express.Router(); const router = express.Router();
// Resolvers for site_id from a room id or room body
const siteIdFromRoomId = req => req.body.site_id;
const siteIdFromRoomPk = req => {
const r = db.prepare('SELECT site_id FROM rooms WHERE id=?').get(req.params.id);
return r?.site_id;
};
// GET /api/rooms?siteId= // GET /api/rooms?siteId=
router.get('/', (req, res) => { router.get('/', (req, res) => {
const { siteId } = req.query; const { siteId } = req.query;
@@ -39,7 +47,7 @@ router.get('/:id', (req, res) => {
}); });
// POST /api/rooms // POST /api/rooms
router.post('/', (req, res) => { router.post('/', requireWriteAccess(siteIdFromRoomId), (req, res) => {
const { site_id, name, notes } = req.body; const { site_id, name, notes } = req.body;
if (!site_id || !name) return res.status(400).json({ error: 'site_id and name are required' }); if (!site_id || !name) return res.status(400).json({ error: 'site_id and name are required' });
@@ -56,7 +64,7 @@ router.post('/', (req, res) => {
}); });
// PUT /api/rooms/:id // PUT /api/rooms/:id
router.put('/:id', (req, res) => { router.put('/:id', requireWriteAccess(siteIdFromRoomPk), (req, res) => {
const room = db.prepare('SELECT * FROM rooms WHERE id = ?').get(req.params.id); const room = db.prepare('SELECT * FROM rooms WHERE id = ?').get(req.params.id);
if (!room) return res.status(404).json({ error: 'Room not found' }); if (!room) return res.status(404).json({ error: 'Room not found' });
@@ -78,7 +86,7 @@ router.put('/:id', (req, res) => {
}); });
// DELETE /api/rooms/:id // DELETE /api/rooms/:id
router.delete('/:id', (req, res) => { router.delete('/:id', requireWriteAccess(siteIdFromRoomPk), (req, res) => {
const room = db.prepare('SELECT * FROM rooms WHERE id = ?').get(req.params.id); const room = db.prepare('SELECT * FROM rooms WHERE id = ?').get(req.params.id);
if (!room) return res.status(404).json({ error: 'Room not found' }); if (!room) return res.status(404).json({ error: 'Room not found' });
+10 -6
View File
@@ -2,8 +2,12 @@ const express = require('express');
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const db = require('../db'); const db = require('../db');
const { logAudit } = require('../audit'); const { logAudit } = require('../audit');
const { requireRole, requireWriteAccess } = require('../middleware/rbac');
const router = express.Router(); const router = express.Router();
// For advanced users: the site itself IS the permission boundary
// PUT is allowed if they own that site; POST (create) and DELETE are admin-only.
// GET /api/sites // GET /api/sites
router.get('/', (req, res) => { router.get('/', (req, res) => {
const sites = db.prepare(` const sites = db.prepare(`
@@ -33,8 +37,8 @@ router.get('/:id', (req, res) => {
res.json({ ...site, rooms }); res.json({ ...site, rooms });
}); });
// POST /api/sites // POST /api/sites — admin only (creating a new site is a structural change)
router.post('/', (req, res) => { router.post('/', requireRole('admin'), (req, res) => {
const { name, location, notes } = req.body; const { name, location, notes } = req.body;
if (!name) return res.status(400).json({ error: 'Name is required' }); if (!name) return res.status(400).json({ error: 'Name is required' });
@@ -47,8 +51,8 @@ router.post('/', (req, res) => {
res.status(201).json(created); res.status(201).json(created);
}); });
// PUT /api/sites/:id // PUT /api/sites/:id — admin always; advanced only if this is their assigned site
router.put('/:id', (req, res) => { router.put('/:id', requireWriteAccess(req => req.params.id), (req, res) => {
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id); const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id);
if (!site) return res.status(404).json({ error: 'Site not found' }); if (!site) return res.status(404).json({ error: 'Site not found' });
@@ -71,8 +75,8 @@ router.put('/:id', (req, res) => {
res.json(db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id)); res.json(db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id));
}); });
// DELETE /api/sites/:id // DELETE /api/sites/:id — admin only
router.delete('/:id', (req, res) => { router.delete('/:id', requireRole('admin'), (req, res) => {
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id); const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id);
if (!site) return res.status(404).json({ error: 'Site not found' }); if (!site) return res.status(404).json({ error: 'Site not found' });
+134 -18
View File
@@ -1,40 +1,121 @@
/** /**
* /api/users * /api/users
* *
* Manages NetworkView users. Authentication is expected to be handled by * Manages NetworkView users. The GET / endpoint merges data from:
* the external main app; this API accepts an `x-user-id` / `x-username` * 1. The local NV users table (api_key status, site_permissions)
* header (set by the gateway after verifying the bearer token) and an * 2. The portal's /api/internal/nv-users endpoint (all portal users with NV access)
* `x-api-key` header for direct service-to-service calls.
* *
* Roles: admin | editor | viewer * This ensures portal users appear in Settings even before their first NV login.
*/ */
const express = require('express'); const express = require('express');
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const crypto = require('crypto'); const crypto = require('crypto');
const http = require('http');
const db = require('../db'); const db = require('../db');
const { logAudit } = require('../audit'); const { logAudit } = require('../audit');
const { requireRole } = require('../middleware/rbac');
const router = express.Router(); const router = express.Router();
// ── Config ────────────────────────────────────────────────────────────────────
const PORTAL_URL = process.env.PORTAL_INTERNAL_URL || 'http://localhost:5001';
const INTERNAL_TOKEN = process.env.INTERNAL_SYNC_SECRET || 'change-this-internal-secret';
// ── Helpers ────────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────────
const safeUser = u => ({ const safeUser = u => {
id: u.id, const perm = db.prepare(
username: u.username, 'SELECT sp.site_id, s.name AS site_name FROM site_permissions sp LEFT JOIN sites s ON s.id = sp.site_id WHERE sp.user_id = ?'
email: u.email, ).get(u.id);
role: u.role, return {
is_active: u.is_active, id: u.id,
has_api_key: !!u.api_key, username: u.username,
created_at: u.created_at, email: u.email,
updated_at: u.updated_at, role: u.role,
}); is_active: Boolean(u.is_active),
has_api_key: !!u.api_key,
site_permission: perm ? { site_id: perm.site_id, site_name: perm.site_name } : null,
created_at: u.created_at,
updated_at: u.updated_at,
};
};
/** Fetch portal NV-users list. Returns [] on any error (best-effort). */
function fetchPortalUsers() {
return new Promise(resolve => {
const url = new URL('/api/internal/nv-users', PORTAL_URL);
const req = http.request(
{ hostname: url.hostname, port: url.port || 80, path: url.pathname,
method: 'GET', headers: { 'X-Internal-Token': INTERNAL_TOKEN } },
res => {
let body = '';
res.on('data', c => { body += c; });
res.on('end', () => {
try { resolve(res.statusCode === 200 ? JSON.parse(body) : []); }
catch (_) { resolve([]); }
});
}
);
req.on('error', () => resolve([]));
req.setTimeout(3000, () => { req.destroy(); resolve([]); });
req.end();
});
}
// ── GET /api/users ──────────────────────────────────────────────────────────── // ── GET /api/users ────────────────────────────────────────────────────────────
router.get('/', (req, res) => { // Merges portal users (with NV access) and local NV user rows.
const users = db.prepare('SELECT * FROM users ORDER BY username').all(); router.get('/', async (req, res) => {
res.json(users.map(safeUser)); // Local NV users keyed by username
const localUsers = db.prepare('SELECT * FROM users ORDER BY username').all();
const localByUsername = Object.fromEntries(localUsers.map(u => [u.username, u]));
// Portal users (all who have NV access)
const portalUsers = await fetchPortalUsers();
const seen = new Set();
const result = [];
for (const pu of portalUsers) {
seen.add(pu.username);
const local = localByUsername[pu.username];
if (local) {
// Merge: use portal role (source of truth) but keep NV-local extras
result.push(safeUser({ ...local, role: _mapPortalRole(pu.nv_role) }));
} else {
// Portal user not yet in NV — show with no local extras
const perm = null;
result.push({
id: pu.portal_id,
username: pu.username,
email: pu.email,
role: _mapPortalRole(pu.nv_role),
is_active: true,
has_api_key: false,
site_permission: null,
created_at: null,
updated_at: null,
not_yet_synced: true, // hint for UI
});
}
}
// Include any local-only users not in portal (e.g. API-key-only accounts)
for (const u of localUsers) {
if (!seen.has(u.username)) result.push(safeUser(u));
}
result.sort((a, b) => a.username.localeCompare(b.username));
res.json(result);
}); });
/** Map portal role string → NV local role string */
function _mapPortalRole(role) {
if (role === 'admin') return 'admin';
if (role === 'advanced') return 'advanced';
return 'viewer';
}
// ── GET /api/users/:id ──────────────────────────────────────────────────────── // ── GET /api/users/:id ────────────────────────────────────────────────────────
router.get('/:id', (req, res) => { router.get('/:id', (req, res) => {
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
@@ -113,6 +194,41 @@ router.delete('/:id', (req, res) => {
res.json({ ok: true }); res.json({ ok: true });
}); });
// ── GET /api/users/:id/site-permission ───────────────────────────────────────
router.get('/:id/site-permission', requireRole('admin'), (req, res) => {
const user = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
const perm = db.prepare(
'SELECT sp.site_id, s.name AS site_name FROM site_permissions sp LEFT JOIN sites s ON s.id = sp.site_id WHERE sp.user_id = ?'
).get(req.params.id);
res.json(perm ?? { site_id: null, site_name: null });
});
// ── PUT /api/users/:id/site-permission ───────────────────────────────────────
// Body: { site_id: "<uuid>" } or { site_id: null } to clear
router.put('/:id/site-permission', requireRole('admin'), (req, res) => {
const user = db.prepare('SELECT id, username FROM users WHERE id = ?').get(req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
const { site_id } = req.body;
if (!site_id) {
db.prepare('DELETE FROM site_permissions WHERE user_id = ?').run(req.params.id);
logAudit(req, { action: 'update', entityType: 'user', entityId: user.id, entityName: user.username, changes: 'site_permission cleared' });
return res.json({ ok: true, site_id: null });
}
const site = db.prepare('SELECT id, name FROM sites WHERE id = ?').get(site_id);
if (!site) return res.status(404).json({ error: 'Site not found' });
db.prepare(
'INSERT INTO site_permissions (user_id, site_id) VALUES (?, ?) ON CONFLICT(user_id) DO UPDATE SET site_id=excluded.site_id, granted_at=CURRENT_TIMESTAMP'
).run(req.params.id, site_id);
logAudit(req, { action: 'update', entityType: 'user', entityId: user.id, entityName: user.username, changes: `site_permission set to ${site.name}` });
res.json({ ok: true, site_id, site_name: site.name });
});
// ── POST /api/users/:id/api-key (generate / rotate) ───────────────────────── // ── POST /api/users/:id/api-key (generate / rotate) ─────────────────────────
router.post('/:id/api-key', (req, res) => { router.post('/:id/api-key', (req, res) => {
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
+4
View File
@@ -84,6 +84,10 @@ export const rotateApiKey = (id: string) =>
request<{ api_key: string }>(`/users/${id}/api-key`, { method: 'POST' }); request<{ api_key: string }>(`/users/${id}/api-key`, { method: 'POST' });
export const revokeApiKey = (id: string) => export const revokeApiKey = (id: string) =>
request(`/users/${id}/api-key`, { method: 'DELETE' }); request(`/users/${id}/api-key`, { method: 'DELETE' });
export const setSitePermission = (userId: string, siteId: string | null) =>
request<{ ok: boolean; site_id: string | null }>(`/users/${userId}/site-permission`, {
method: 'PUT', body: JSON.stringify({ site_id: siteId }),
});
// --- Audit --- // --- Audit ---
export const getAuditLog = (params: { export const getAuditLog = (params: {
+33 -17
View File
@@ -19,6 +19,7 @@ export default function Sidebar() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [addSiteName, setAddSiteName] = useState(''); const [addSiteName, setAddSiteName] = useState('');
const [showAddSite, setShowAddSite] = useState(false); const [showAddSite, setShowAddSite] = useState(false);
const [addSiteErr, setAddSiteErr] = useState<string | null>(null);
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
@@ -74,11 +75,16 @@ export default function Sidebar() {
const handleAddSite = async (e: React.FormEvent) => { const handleAddSite = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!addSiteName.trim()) return; if (!addSiteName.trim()) return;
const site = await api.createSite({ name: addSiteName.trim() }); setAddSiteErr(null);
setAddSiteName(''); try {
setShowAddSite(false); const site = await api.createSite({ name: addSiteName.trim() });
await loadTree(); setAddSiteName('');
navigate(`/sites/${site.id}`); setShowAddSite(false);
await loadTree();
navigate(`/sites/${site.id}`);
} catch (ex: unknown) {
setAddSiteErr(ex instanceof Error ? ex.message : String(ex));
}
}; };
useEffect(() => { useEffect(() => {
@@ -147,18 +153,21 @@ export default function Sidebar() {
</div> </div>
{showAddSite && ( {showAddSite && (
<form onSubmit={handleAddSite} className="inline-add-form"> <>
<input <form onSubmit={handleAddSite} className="inline-add-form">
autoFocus <input
type="text" autoFocus
placeholder="Site name..." type="text"
value={addSiteName} placeholder="Site name..."
onChange={e => setAddSiteName(e.target.value)} value={addSiteName}
className="inline-input" onChange={e => setAddSiteName(e.target.value)}
/> className="inline-input"
<button type="submit" className="inline-btn">Add</button> />
<button type="button" className="inline-btn secondary" onClick={() => setShowAddSite(false)}></button> <button type="submit" className="inline-btn">Add</button>
</form> <button type="button" className="inline-btn secondary" onClick={() => { setShowAddSite(false); setAddSiteErr(null); }}></button>
</form>
{addSiteErr && <div className="inline-error">{addSiteErr}</div>}
</>
)} )}
{loading && <div className="sidebar-loading">Loading...</div>} {loading && <div className="sidebar-loading">Loading...</div>}
@@ -229,6 +238,13 @@ export default function Sidebar() {
<Link to="/settings" className={`sidebar-footer-link ${isActive('/settings') ? 'active' : ''}`}> <Link to="/settings" className={`sidebar-footer-link ${isActive('/settings') ? 'active' : ''}`}>
Settings Settings
</Link> </Link>
<a
href="/portal-return"
className="sidebar-footer-link"
title="Back to Enterprise Portal"
>
Portal
</a>
<button <button
className="sidebar-footer-link sidebar-logout-btn" className="sidebar-footer-link sidebar-logout-btn"
onClick={() => { window.location.href = (import.meta.env.VITE_PORTAL_LOGOUT_URL as string | undefined) ?? '/logout'; }} onClick={() => { window.location.href = (import.meta.env.VITE_PORTAL_LOGOUT_URL as string | undefined) ?? '/logout'; }}
+3
View File
@@ -172,6 +172,7 @@ a:hover { text-decoration: underline; }
} }
.inline-input { .inline-input {
flex: 1; flex: 1;
min-width: 0;
background: var(--surface2); background: var(--surface2);
border: 1px solid var(--accent); border: 1px solid var(--accent);
border-radius: var(--radius); border-radius: var(--radius);
@@ -181,6 +182,7 @@ a:hover { text-decoration: underline; }
outline: none; outline: none;
} }
.inline-btn { .inline-btn {
flex-shrink: 0;
background: var(--accent-dim); background: var(--accent-dim);
border: none; border: none;
border-radius: var(--radius); border-radius: var(--radius);
@@ -190,6 +192,7 @@ a:hover { text-decoration: underline; }
padding: 4px 8px; padding: 4px 8px;
} }
.inline-btn.secondary { background: var(--surface3); } .inline-btn.secondary { background: var(--surface3); }
.inline-error { padding: 2px 8px 4px; color: var(--danger); font-size: 11px; }
/* Tree nodes */ /* Tree nodes */
.tree-node { display: flex; flex-direction: column; } .tree-node { display: flex; flex-direction: column; }
+56 -13
View File
@@ -14,9 +14,10 @@ const ACTION_COLORS: Record<string, string> = {
}; };
const ROLE_COLORS: Record<string, string> = { const ROLE_COLORS: Record<string, string> = {
admin: '#f87171', admin: '#f87171',
editor: '#f59e0b', advanced: '#f59e0b',
viewer: '#8b949e', editor: '#f59e0b',
viewer: '#8b949e',
}; };
function formatTs(ts: string) { function formatTs(ts: string) {
@@ -40,6 +41,7 @@ function StatsPanel({ stats }: { stats: DbStats }) {
// ── Sub-section: Users ──────────────────────────────────────────────────────── // ── Sub-section: Users ────────────────────────────────────────────────────────
function UsersPanel() { function UsersPanel() {
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [sites, setSites] = useState<{ id: string; name: string }[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showAdd, setShowAdd] = useState(false); const [showAdd, setShowAdd] = useState(false);
const [form, setForm] = useState({ username: '', email: '', role: 'viewer' }); const [form, setForm] = useState({ username: '', email: '', role: 'viewer' });
@@ -48,7 +50,9 @@ function UsersPanel() {
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
setUsers(await api.getUsers()); const [us, ss] = await Promise.all([api.getUsers(), api.getSites()]);
setUsers(us);
setSites(ss);
setLoading(false); setLoading(false);
}, []); }, []);
@@ -90,6 +94,21 @@ function UsersPanel() {
await load(); await load();
}; };
const handleSitePermission = async (u: User, siteId: string | null) => {
try {
await api.setSitePermission(u.id, siteId);
await load();
} catch (ex: unknown) {
alert(ex instanceof Error ? ex.message : String(ex));
}
};
const ROLE_LABEL: Record<string, string> = {
admin: '★ admin',
advanced: '✏ advanced',
viewer: '👁 viewer',
};
return ( return (
<div className="settings-section"> <div className="settings-section">
<div className="settings-section-header"> <div className="settings-section-header">
@@ -97,8 +116,8 @@ function UsersPanel() {
<button className="btn-primary btn-sm" onClick={() => setShowAdd(v => !v)}>+ Add User</button> <button className="btn-primary btn-sm" onClick={() => setShowAdd(v => !v)}>+ Add User</button>
</div> </div>
<p className="settings-desc"> <p className="settings-desc">
User accounts for this NetworkView instance. Authentication is delegated to your central app Users are automatically synced from the Enterprise Portal on each login.
set <code>X-User-Id</code> + <code>X-Username</code> headers on API requests, or use per-user API keys. Roles: <strong>admin</strong> full access · <strong>advanced</strong> CRUD on one assigned site · <strong>viewer</strong> read-only.
</p> </p>
{showAdd && ( {showAdd && (
@@ -115,9 +134,9 @@ function UsersPanel() {
className="modal-select" className="modal-select"
value={form.role} onChange={e => setForm(f => ({ ...f, role: e.target.value }))} value={form.role} onChange={e => setForm(f => ({ ...f, role: e.target.value }))}
> >
<option value="viewer">Viewer</option> <option value="viewer">👁 Viewer read-only</option>
<option value="editor">Editor</option> <option value="advanced"> Advanced one site</option>
<option value="admin">Admin</option> <option value="admin"> Admin full access</option>
</select> </select>
{err && <div className="settings-error">{err}</div>} {err && <div className="settings-error">{err}</div>}
<div className="form-actions"> <div className="form-actions">
@@ -142,19 +161,43 @@ function UsersPanel() {
<table className="settings-user-table"> <table className="settings-user-table">
<thead> <thead>
<tr> <tr>
<th>Username</th><th>Email</th><th>Role</th><th>Status</th><th>API Key</th><th>Actions</th> <th>Username</th><th>Email</th><th>Role</th><th>Assigned Site</th><th>Status</th><th>API Key</th><th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{users.map(u => ( {users.map(u => (
<tr key={u.id} style={{ opacity: u.is_active ? 1 : 0.5 }}> <tr key={u.id} style={{ opacity: u.is_active ? 1 : 0.5 }}>
<td><strong>{u.username}</strong></td> <td><strong>{u.username}</strong>{u.not_yet_synced && (
<span title="User exists in portal but hasn't logged into NetworkView yet"
style={{ marginLeft: 6, fontSize: 10, color: 'var(--text3)', verticalAlign: 'middle' }}>
(portal only)
</span>
)}</td>
<td style={{ color: 'var(--text3)', fontSize: 12 }}>{u.email ?? '—'}</td> <td style={{ color: 'var(--text3)', fontSize: 12 }}>{u.email ?? '—'}</td>
<td> <td>
<span className="status-badge" style={{ color: ROLE_COLORS[u.role] ?? '#8b949e', borderColor: ROLE_COLORS[u.role] ?? '#8b949e' }}> <span className="status-badge" style={{ color: ROLE_COLORS[u.role] ?? '#8b949e', borderColor: ROLE_COLORS[u.role] ?? '#8b949e' }}>
{u.role} {ROLE_LABEL[u.role] ?? u.role}
</span> </span>
</td> </td>
<td style={{ minWidth: 160 }}>
{u.role === 'advanced' ? (
<select
className="modal-select"
style={{ fontSize: 12, padding: '2px 6px' }}
value={u.site_permission?.site_id ?? ''}
onChange={e => handleSitePermission(u, e.target.value || null)}
>
<option value=""> no site assigned </option>
{sites.map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
) : (
<span style={{ color: 'var(--text3)', fontSize: 12 }}>
{u.role === 'admin' ? 'all sites' : '—'}
</span>
)}
</td>
<td> <td>
<span className={`status-badge status-${u.is_active ? 'active' : 'decommissioned'}`}> <span className={`status-badge status-${u.is_active ? 'active' : 'decommissioned'}`}>
{u.is_active ? 'active' : 'inactive'} {u.is_active ? 'active' : 'inactive'}
@@ -177,7 +220,7 @@ function UsersPanel() {
</tr> </tr>
))} ))}
{users.length === 0 && ( {users.length === 0 && (
<tr><td colSpan={6} style={{ textAlign: 'center', color: 'var(--text3)', padding: 20 }}>No users yet</td></tr> <tr><td colSpan={7} style={{ textAlign: 'center', color: 'var(--text3)', padding: 20 }}>No users yet</td></tr>
)} )}
</tbody> </tbody>
</table> </table>
+5 -3
View File
@@ -143,11 +143,13 @@ export interface User {
id: string; id: string;
username: string; username: string;
email?: string; email?: string;
role: 'admin' | 'editor' | 'viewer'; role: 'admin' | 'advanced' | 'viewer';
is_active: boolean; is_active: boolean;
has_api_key: boolean; has_api_key: boolean;
created_at: string; site_permission: { site_id: string; site_name: string } | null;
updated_at: string; not_yet_synced?: boolean;
created_at: string | null;
updated_at: string | null;
} }
export interface AuditEntry { export interface AuditEntry {
+13 -3
View File
@@ -3,7 +3,12 @@ Portal SSO middleware for Server Monitor.
When the umbrella nginx verifies the portal JWT it sets two headers: When the umbrella nginx verifies the portal JWT it sets two headers:
X-Auth-Username — the portal username X-Auth-Username — the portal username
X-Auth-Role — 'admin' or 'user' X-Auth-Role — 'admin' | 'advanced' | 'standard'
Portal role → Server Monitor access level:
admin — full access: view all, run Ansible, manage config
advanced — can view all data and trigger safe operations
standard — read-only dashboard view
This before_request handler stores them in Flask's g so templates This before_request handler stores them in Flask's g so templates
and routes can access the current user without a local user DB. and routes can access the current user without a local user DB.
@@ -17,11 +22,16 @@ def init_portal_sso(app):
@app.before_request @app.before_request
def _portal_sso(): def _portal_sso():
g.portal_user = request.headers.get('X-Auth-Username', '').strip() or None g.portal_user = request.headers.get('X-Auth-Username', '').strip() or None
g.portal_role = request.headers.get('X-Auth-Role', 'user').strip() raw_role = request.headers.get('X-Auth-Role', 'standard').strip()
# Normalise to the three known tiers; unknown values fall back to 'standard'
g.portal_role = raw_role if raw_role in ('admin', 'advanced', 'standard') else 'standard'
@app.context_processor @app.context_processor
def _inject_portal_user(): def _inject_portal_user():
return { return {
'portal_user': getattr(g, 'portal_user', None), 'portal_user': getattr(g, 'portal_user', None),
'portal_role': getattr(g, 'portal_role', 'user'), 'portal_role': getattr(g, 'portal_role', 'standard'),
# Convenience booleans available in all templates
'portal_is_admin': getattr(g, 'portal_role', 'standard') == 'admin',
'portal_is_advanced': getattr(g, 'portal_role', 'standard') in ('admin', 'advanced'),
} }
+55 -14
View File
@@ -9,26 +9,15 @@ from typing import Optional
from app.extensions import db, bcrypt from app.extensions import db, bcrypt
from app.models import User, Player, Content, ServerLog, Playlist, HTTPSConfig from app.models import User, Player, Content, ServerLog, Playlist, HTTPSConfig
from app.models.playlist_permission import PlaylistPermission
from app.utils.logger import log_action from app.utils.logger import log_action
from app.utils.caddy_manager import CaddyConfigGenerator from app.utils.caddy_manager import CaddyConfigGenerator
from app.utils.nginx_config_reader import get_nginx_status from app.utils.nginx_config_reader import get_nginx_status
from app.utils.access import admin_required, editor_required
admin_bp = Blueprint('admin', __name__, url_prefix='/admin') admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
# admin_required and editor_required imported from app.utils.access
def admin_required(f):
"""Decorator to require admin role for route access."""
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
flash('Please login to access this page.', 'warning')
return redirect(url_for('auth.login'))
if current_user.role != 'admin':
log_action('warning', f'Unauthorized admin access attempt by {current_user.username}')
flash('You do not have permission to access this page.', 'danger')
return redirect(url_for('main.dashboard'))
return f(*args, **kwargs)
return decorated_function
@admin_bp.route('/') @admin_bp.route('/')
@@ -203,6 +192,58 @@ def user_management():
return redirect(url_for('admin.admin_panel')) return redirect(url_for('admin.admin_panel'))
# ── Playlist permissions (viewer-role users) ───────────────────────────────────
@admin_bp.route('/user/<int:user_id>/playlist-permissions')
@login_required
@admin_required
def playlist_permissions(user_id: int):
"""Show/manage which playlists a viewer-role user may edit."""
user = User.query.get_or_404(user_id)
playlists = Playlist.query.order_by(Playlist.name).all()
granted_ids = {
p.playlist_id
for p in PlaylistPermission.query.filter_by(user_id=user_id).all()
}
return render_template(
'admin/playlist_permissions.html',
target_user=user,
playlists=playlists,
granted_ids=granted_ids,
)
@admin_bp.route('/user/<int:user_id>/playlist-permissions/save', methods=['POST'])
@login_required
@admin_required
def save_playlist_permissions(user_id: int):
"""Save (overwrite) the playlist edit permissions for a viewer user."""
user = User.query.get_or_404(user_id)
all_playlists = Playlist.query.all()
# The form sends one checkbox per playlist: name="playlist_<id>" value="1"
new_ids = {
pl.id for pl in all_playlists
if request.form.get(f'playlist_{pl.id}') == '1'
}
# Current grants
existing = {p.playlist_id: p for p in PlaylistPermission.query.filter_by(user_id=user_id).all()}
# Add new grants
for pid in new_ids - set(existing.keys()):
db.session.add(PlaylistPermission(user_id=user_id, playlist_id=pid))
# Remove revoked grants
for pid in set(existing.keys()) - new_ids:
db.session.delete(existing[pid])
db.session.commit()
log_action('info', f'Playlist permissions updated for user "{user.username}" by {current_user.username}')
flash(f'Playlist permissions updated for "{user.username}".', 'success')
return redirect(url_for('admin.playlist_permissions', user_id=user_id))
@admin_bp.route('/user/<int:user_id>/password', methods=['POST']) @admin_bp.route('/user/<int:user_id>/password', methods=['POST'])
@login_required @login_required
@admin_required @admin_required
+28 -7
View File
@@ -1,7 +1,7 @@
"""Content blueprint - New playlist-centric workflow.""" """Content blueprint - New playlist-centric workflow."""
from flask import (Blueprint, render_template, request, redirect, url_for, from flask import (Blueprint, render_template, request, redirect, url_for,
flash, jsonify, current_app) flash, jsonify, current_app)
from flask_login import login_required from flask_login import login_required, current_user
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from typing import Optional from typing import Optional
import os import os
@@ -15,6 +15,7 @@ from app.models import Content, Playlist, Player
from app.models.playlist import playlist_content from app.models.playlist import playlist_content
from app.utils.logger import log_action from app.utils.logger import log_action
from app.utils.uploads import process_video_file, set_upload_progress from app.utils.uploads import process_video_file, set_upload_progress
from app.utils.access import editor_required, can_edit_playlist, get_editable_playlist_ids
# Store for background processing status # Store for background processing status
_background_tasks = {} _background_tasks = {}
@@ -30,12 +31,14 @@ def content_list():
media_files = Content.query.order_by(Content.uploaded_at.desc()).limit(3).all() # Only last 3 media_files = Content.query.order_by(Content.uploaded_at.desc()).limit(3).all() # Only last 3
total_media_count = Content.query.count() # Total count for display total_media_count = Content.query.count() # Total count for display
players = Player.query.order_by(Player.name).all() players = Player.query.order_by(Player.name).all()
editable_ids = get_editable_playlist_ids(current_user)
return render_template('content/content_list_new.html', return render_template('content/content_list_new.html',
playlists=playlists, playlists=playlists,
media_files=media_files, media_files=media_files,
total_media_count=total_media_count, total_media_count=total_media_count,
players=players) players=players,
editable_ids=editable_ids)
@content_bp.route('/media-library') @content_bp.route('/media-library')
@@ -68,6 +71,7 @@ def media_library():
@content_bp.route('/media/<int:media_id>/delete', methods=['POST']) @content_bp.route('/media/<int:media_id>/delete', methods=['POST'])
@login_required @login_required
@editor_required
def delete_media(media_id: int): def delete_media(media_id: int):
"""Delete a media file and remove it from all playlists.""" """Delete a media file and remove it from all playlists."""
try: try:
@@ -132,6 +136,7 @@ def delete_media(media_id: int):
@content_bp.route('/playlist/create', methods=['POST']) @content_bp.route('/playlist/create', methods=['POST'])
@login_required @login_required
@editor_required
def create_playlist(): def create_playlist():
"""Create a new playlist.""" """Create a new playlist."""
try: try:
@@ -170,6 +175,7 @@ def create_playlist():
@content_bp.route('/playlist/<int:playlist_id>/delete', methods=['POST']) @content_bp.route('/playlist/<int:playlist_id>/delete', methods=['POST'])
@login_required @login_required
@editor_required
def delete_playlist(playlist_id: int): def delete_playlist(playlist_id: int):
"""Delete a playlist.""" """Delete a playlist."""
playlist = Playlist.query.get_or_404(playlist_id) playlist = Playlist.query.get_or_404(playlist_id)
@@ -200,27 +206,32 @@ def delete_playlist(playlist_id: int):
def manage_playlist_content(playlist_id: int): def manage_playlist_content(playlist_id: int):
"""Manage content in a specific playlist.""" """Manage content in a specific playlist."""
playlist = Playlist.query.get_or_404(playlist_id) playlist = Playlist.query.get_or_404(playlist_id)
can_edit = can_edit_playlist(current_user, playlist_id)
# Get content in playlist (ordered) # Get content in playlist (ordered)
playlist_content = playlist.get_content_ordered() playlist_content = playlist.get_content_ordered()
# Get all available content not in this playlist. # Get all available content not in this playlist.
# Web links are created on demand per playlist, so they are not offered # Web links are created on demand per playlist, so they are not offered
# as reusable library items here. # as reusable library items here.
all_content = Content.query.filter(Content.content_type != 'weblink').all() all_content = Content.query.filter(Content.content_type != 'weblink').all()
playlist_content_ids = {c.id for c in playlist_content} playlist_content_ids = {c.id for c in playlist_content}
available_content = [c for c in all_content if c.id not in playlist_content_ids] available_content = [c for c in all_content if c.id not in playlist_content_ids]
return render_template('content/manage_playlist_content.html', return render_template('content/manage_playlist_content.html',
playlist=playlist, playlist=playlist,
playlist_content=playlist_content, playlist_content=playlist_content,
available_content=available_content) available_content=available_content,
can_edit=can_edit)
@content_bp.route('/playlist/<int:playlist_id>/add-content', methods=['POST']) @content_bp.route('/playlist/<int:playlist_id>/add-content', methods=['POST'])
@login_required @login_required
def add_content_to_playlist(playlist_id: int): def add_content_to_playlist(playlist_id: int):
"""Add content to playlist.""" """Add content to playlist."""
if not can_edit_playlist(current_user, playlist_id):
flash('You do not have permission to edit this playlist.', 'danger')
return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id))
playlist = Playlist.query.get_or_404(playlist_id) playlist = Playlist.query.get_or_404(playlist_id)
try: try:
@@ -351,6 +362,9 @@ def add_weblink():
@login_required @login_required
def add_weblink_to_playlist(playlist_id: int): def add_weblink_to_playlist(playlist_id: int):
"""Create a web link content item and add it to the playlist.""" """Create a web link content item and add it to the playlist."""
if not can_edit_playlist(current_user, playlist_id):
flash('You do not have permission to edit this playlist.', 'danger')
return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id))
playlist = Playlist.query.get_or_404(playlist_id) playlist = Playlist.query.get_or_404(playlist_id)
try: try:
@@ -417,6 +431,9 @@ def add_weblink_to_playlist(playlist_id: int):
@login_required @login_required
def remove_content_from_playlist(playlist_id: int, content_id: int): def remove_content_from_playlist(playlist_id: int, content_id: int):
"""Remove content from playlist.""" """Remove content from playlist."""
if not can_edit_playlist(current_user, playlist_id):
flash('You do not have permission to edit this playlist.', 'danger')
return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id))
playlist = Playlist.query.get_or_404(playlist_id) playlist = Playlist.query.get_or_404(playlist_id)
try: try:
@@ -454,6 +471,8 @@ def remove_content_from_playlist(playlist_id: int, content_id: int):
@login_required @login_required
def bulk_remove_from_playlist(playlist_id: int): def bulk_remove_from_playlist(playlist_id: int):
"""Remove multiple content items from playlist.""" """Remove multiple content items from playlist."""
if not can_edit_playlist(current_user, playlist_id):
return jsonify({'success': False, 'message': 'Permission denied'}), 403
playlist = Playlist.query.get_or_404(playlist_id) playlist = Playlist.query.get_or_404(playlist_id)
try: try:
@@ -496,6 +515,8 @@ def bulk_remove_from_playlist(playlist_id: int):
@login_required @login_required
def reorder_playlist_content(playlist_id: int): def reorder_playlist_content(playlist_id: int):
"""Reorder content in playlist.""" """Reorder content in playlist."""
if not can_edit_playlist(current_user, playlist_id):
return jsonify({'success': False, 'message': 'Permission denied'}), 403
playlist = Playlist.query.get_or_404(playlist_id) playlist = Playlist.query.get_or_404(playlist_id)
try: try:
+8 -2
View File
@@ -40,8 +40,14 @@ def sync_user():
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
username = (data.get('username') or '').strip() username = (data.get('username') or '').strip()
role_raw = (data.get('role') or 'user').strip() role_raw = (data.get('role') or 'viewer').strip()
role = 'admin' if role_raw == 'admin' else 'user' # Accept both old ('user') and new ('advanced'/'standard') role names from portal
if role_raw == 'admin':
role = 'admin'
elif role_raw in ('advanced', 'editor'):
role = 'editor'
else:
role = 'viewer'
if not username: if not username:
return jsonify({'error': 'username required'}), 400 return jsonify({'error': 'username required'}), 400
+1
View File
@@ -9,6 +9,7 @@ from app.models.player_feedback import PlayerFeedback
from app.models.player_edit import PlayerEdit from app.models.player_edit import PlayerEdit
from app.models.player_user import PlayerUser from app.models.player_user import PlayerUser
from app.models.https_config import HTTPSConfig from app.models.https_config import HTTPSConfig
from app.models.playlist_permission import PlaylistPermission
__all__ = [ __all__ = [
'User', 'User',
@@ -0,0 +1,28 @@
"""Per-user playlist edit permission for viewer-role accounts."""
from datetime import datetime
from app.extensions import db
class PlaylistPermission(db.Model):
"""
Grants a viewer-role user edit access to a specific playlist.
admin / editor users have implicit access to all playlists;
this table is only consulted for the 'viewer' role.
"""
__tablename__ = 'playlist_permissions'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False)
playlist_id = db.Column(db.Integer, db.ForeignKey('playlist.id', ondelete='CASCADE'), nullable=False)
granted_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
user = db.relationship('User', backref=db.backref('playlist_permissions', lazy='dynamic', cascade='all, delete-orphan'))
playlist = db.relationship('Playlist', backref=db.backref('permitted_users', lazy='dynamic', cascade='all, delete-orphan'))
__table_args__ = (
db.UniqueConstraint('user_id', 'playlist_id', name='uq_user_playlist_perm'),
)
def __repr__(self):
return f'<PlaylistPermission user={self.user_id} playlist={self.playlist_id}>'
+12 -1
View File
@@ -24,7 +24,8 @@ class User(db.Model, UserMixin):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False, index=True) username = db.Column(db.String(80), unique=True, nullable=False, index=True)
password = db.Column(db.String(120), nullable=False) password = db.Column(db.String(120), nullable=False)
role = db.Column(db.String(20), nullable=False, default='user', index=True) role = db.Column(db.String(20), nullable=False, default='viewer', index=True)
# Valid roles: 'admin' | 'editor' | 'viewer'
theme = db.Column(db.String(20), default='light') theme = db.Column(db.String(20), default='light')
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
last_login = db.Column(db.DateTime, nullable=True) last_login = db.Column(db.DateTime, nullable=True)
@@ -37,6 +38,16 @@ class User(db.Model, UserMixin):
def is_admin(self) -> bool: def is_admin(self) -> bool:
"""Check if user has admin role.""" """Check if user has admin role."""
return self.role == 'admin' return self.role == 'admin'
@property
def is_editor(self) -> bool:
"""True for admin and editor roles (can manage content)."""
return self.role in ('admin', 'editor')
@property
def is_viewer(self) -> bool:
"""True for all authenticated users (read access)."""
return True
def update_last_login(self) -> None: def update_last_login(self) -> None:
"""Update last login timestamp.""" """Update last login timestamp."""
@@ -0,0 +1,77 @@
{% extends "base.html" %}
{% block title %}Playlist Permissions — {{ target_user.username }}{% endblock %}
{% block content %}
<div style="margin-bottom: 1.5rem;">
<a href="{{ url_for('admin.user_management') }}" class="btn btn-secondary">← Back to Users</a>
</div>
<div class="card">
<h2 style="margin-bottom: 0.25rem;">
🎬 Playlist Edit Permissions
</h2>
<p style="color: var(--text-secondary); margin-bottom: 1.5rem;">
User: <strong>{{ target_user.username }}</strong>
<span class="badge badge-{{ 'success' if target_user.role == 'editor' else 'secondary' }}" style="margin-left: 0.5rem;">
{{ target_user.role }}
</span>
</p>
{% if target_user.role in ('admin', 'editor') %}
<div class="alert alert-info" style="background:#d1ecf1; border-left:4px solid #17a2b8; color:#0c5460; padding:1rem; border-radius:6px;">
️ This user already has <strong>{{ target_user.role }}</strong> access — they can edit <em>all</em> playlists automatically.
Playlist-level permissions are only relevant for <strong>viewer</strong> role users.
</div>
{% else %}
<p style="color: var(--text-secondary); margin-bottom: 1.5rem; font-size: 0.9rem;">
Select which playlists this viewer may add, remove and reorder content in.
They will still be able to <em>view</em> all playlists regardless of this setting.
</p>
<form method="POST" action="{{ url_for('admin.save_playlist_permissions', user_id=target_user.id) }}">
{% if playlists %}
<table style="width:100%; border-collapse:collapse; margin-bottom:1.5rem;">
<thead>
<tr style="border-bottom: 2px solid var(--border-color);">
<th style="padding:10px 12px; text-align:left;">Playlist</th>
<th style="padding:10px 12px; text-align:left; color: var(--text-secondary); font-size:0.85rem;">Description</th>
<th style="padding:10px 12px; text-align:center;">Can Edit?</th>
</tr>
</thead>
<tbody>
{% for playlist in playlists %}
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding:12px;">
<strong>{{ playlist.name }}</strong>
<div style="font-size:0.8rem; color: var(--text-secondary);">
{{ playlist.content_count }} items · {{ playlist.player_count }} player(s)
</div>
</td>
<td style="padding:12px; color: var(--text-secondary); font-size:0.9rem;">
{{ playlist.description or '—' }}
</td>
<td style="padding:12px; text-align:center;">
<label style="display:inline-flex; align-items:center; gap:8px; cursor:pointer;">
<input type="checkbox"
name="playlist_{{ playlist.id }}"
value="1"
{{ 'checked' if playlist.id in granted_ids else '' }}
style="width:18px; height:18px; cursor:pointer;">
</label>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div style="display:flex; gap:1rem;">
<button type="submit" class="btn btn-primary">💾 Save Permissions</button>
<a href="{{ url_for('admin.user_management') }}" class="btn btn-secondary">Cancel</a>
</div>
{% else %}
<p style="color: var(--text-secondary);">No playlists created yet.</p>
{% endif %}
</form>
{% endif %}
</div>
{% endblock %}
@@ -27,6 +27,7 @@
<th>Role</th> <th>Role</th>
<th>Created At</th> <th>Created At</th>
<th>Last Login</th> <th>Last Login</th>
<th>Permissions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -40,12 +41,22 @@
{% endif %} {% endif %}
</td> </td>
<td> <td>
<span class="badge badge-{{ 'success' if user.role == 'admin' else 'secondary' }}"> <span class="badge badge-{{ 'success' if user.role == 'admin' else ('warning' if user.role == 'editor' else 'secondary') }}">
{{ user.role|capitalize }} {{ user.role|capitalize }}
</span> </span>
</td> </td>
<td>{{ user.created_at | localtime if user.created_at else 'N/A' }}</td> <td>{{ user.created_at | localtime if user.created_at else 'N/A' }}</td>
<td>{{ user.last_login | localtime if user.last_login else 'Never' }}</td> <td>{{ user.last_login | localtime if user.last_login else 'Never' }}</td>
<td>
{% if user.role == 'viewer' %}
<a href="{{ url_for('admin.playlist_permissions', user_id=user.id) }}"
class="btn btn-sm" style="font-size:12px; padding:4px 10px;">
🎬 Playlists
</a>
{% else %}
<span style="color:#999; font-size:12px;">all access</span>
{% endif %}
</td>
</tr> </tr>
{% endfor %} {% endfor %}
{% else %} {% else %}
+10 -1
View File
@@ -393,8 +393,17 @@
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt=""> <img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="">
Playlists Playlists
</a> </a>
{% if current_user.role in ('admin', 'editor') %}
<a href="{{ url_for('admin.admin_panel') }}">Admin</a> <a href="{{ url_for('admin.admin_panel') }}">Admin</a>
<a href="{{ url_for('auth.logout') }}">Logout ({{ current_user.username }})</a> {% endif %}
<a href="/portal-return" title="Back to Enterprise Portal Dashboard"
style="background: rgba(255,255,255,0.15); border: 1px solid rgba(255,255,255,0.3);">
⬡ Portal
</a>
<a href="{{ url_for('auth.logout') }}" style="display:flex; flex-direction:column; align-items:center; line-height:1.2;">
<span>Logout ({{ current_user.username }})</span>
<span style="font-size:0.7em; opacity:0.7; text-transform:uppercase; letter-spacing:0.05em;">{{ current_user.role }}</span>
</a>
<button class="dark-mode-toggle" onclick="toggleDarkMode()" title="Toggle Dark Mode"> <button class="dark-mode-toggle" onclick="toggleDarkMode()" title="Toggle Dark Mode">
<img id="theme-icon" src="{{ url_for('static', filename='icons/moon.svg') }}" alt="Toggle theme"> <img id="theme-icon" src="{{ url_for('static', filename='icons/moon.svg') }}" alt="Toggle theme">
</button> </button>
@@ -264,7 +264,8 @@
</h1> </h1>
<div class="main-grid"> <div class="main-grid">
<!-- Create Playlist Card --> <!-- Create Playlist Card — editor/admin only -->
{% if current_user.role in ('admin', 'editor') %}
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h2 style="display: flex; align-items: center; gap: 0.5rem;"> <h2 style="display: flex; align-items: center; gap: 0.5rem;">
@@ -300,6 +301,9 @@
</button> </button>
</form> </form>
</div> </div>
{% endif %}
</form>
</div>
<!-- Upload Media Card --> <!-- Upload Media Card -->
<div class="card"> <div class="card">
@@ -395,8 +399,9 @@
<div class="playlist-actions"> <div class="playlist-actions">
<a href="{{ url_for('content.manage_playlist_content', playlist_id=playlist.id) }}" <a href="{{ url_for('content.manage_playlist_content', playlist_id=playlist.id) }}"
class="btn btn-primary btn-sm"> class="btn btn-primary btn-sm">
✏️ Manage {% if playlist.id in editable_ids %}✏️ Manage{% else %}👁 View{% endif %}
</a> </a>
{% if current_user.role in ('admin', 'editor') %}
<form method="POST" <form method="POST"
action="{{ url_for('content.delete_playlist', playlist_id=playlist.id) }}" action="{{ url_for('content.delete_playlist', playlist_id=playlist.id) }}"
style="display: inline;" style="display: inline;"
@@ -406,6 +411,7 @@
Delete Delete
</button> </button>
</form> </form>
{% endif %}
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
@@ -274,19 +274,26 @@
</div> </div>
</div> </div>
<div style="margin-bottom: 20px;"> <div style="margin-bottom: 20px; display:flex; align-items:center; gap:1rem;">
<a href="{{ url_for('content.content_list') }}" class="btn btn-secondary"> <a href="{{ url_for('content.content_list') }}" class="btn btn-secondary">
← Back to Playlists ← Back to Playlists
</a> </a>
{% if not can_edit %}
<span style="background:#fff3cd; color:#856404; padding:6px 14px; border-radius:6px; font-size:0.85rem; border:1px solid #ffc107;">
👁 View-only — you don't have edit permission for this playlist
</span>
{% endif %}
</div> </div>
<div class="content-grid"> <div class="content-grid">
<div class="card"> <div class="card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h2 style="margin: 0;">📋 Playlist Content (Drag to Reorder)</h2> <h2 style="margin: 0;">📋 Playlist Content{% if can_edit %} (Drag to Reorder){% endif %}</h2>
{% if can_edit %}
<button id="bulk-delete-btn" class="btn btn-danger" style="display: none;" onclick="bulkDeleteSelected()"> <button id="bulk-delete-btn" class="btn btn-danger" style="display: none;" onclick="bulkDeleteSelected()">
🗑️ Delete Selected (<span id="selected-count">0</span>) 🗑️ Delete Selected (<span id="selected-count">0</span>)
</button> </button>
{% endif %}
</div> </div>
{% if playlist_content %} {% if playlist_content %}
@@ -308,11 +315,9 @@
</thead> </thead>
<tbody id="playlist-tbody"> <tbody id="playlist-tbody">
{% for content in playlist_content %} {% for content in playlist_content %}
<tr class="draggable-row" draggable="true" data-content-id="{{ content.id }}"> <tr class="{% if can_edit %}draggable-row{% endif %}" {% if can_edit %}draggable="true"{% endif %} data-content-id="{{ content.id }}">
<td> <td>{% if can_edit %}<input type="checkbox" class="content-checkbox" data-content-id="{{ content.id }}" onchange="updateBulkDeleteButton()">{% endif %}</td>
<input type="checkbox" class="content-checkbox" data-content-id="{{ content.id }}" onchange="updateBulkDeleteButton()"> <td>{% if can_edit %}<span class="drag-handle">⋮⋮</span>{% endif %}</td>
</td>
<td><span class="drag-handle">⋮⋮</span></td>
<td>{{ loop.index }}</td> <td>{{ loop.index }}</td>
<td> <td>
{% if content.content_type == 'weblink' %} {% if content.content_type == 'weblink' %}
@@ -384,6 +389,7 @@
{% endif %} {% endif %}
</td> </td>
<td> <td>
{% if can_edit %}
<form method="POST" <form method="POST"
action="{{ url_for('content.remove_content_from_playlist', playlist_id=playlist.id, content_id=content.id) }}" action="{{ url_for('content.remove_content_from_playlist', playlist_id=playlist.id, content_id=content.id) }}"
style="display: inline;" style="display: inline;"
@@ -392,6 +398,10 @@
</button> </button>
</form> </form>
{% else %}
<span style="color:#999;"></span>
{% endif %}
</td> </form>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -406,6 +416,7 @@
</div> </div>
<div class="card"> <div class="card">
{% if can_edit %}
<h2 style="margin-bottom: 20px;"> Add Content</h2> <h2 style="margin-bottom: 20px;"> Add Content</h2>
<div style="margin-bottom: 24px; padding-bottom: 24px; border-bottom: 1px solid #e0e0e0;"> <div style="margin-bottom: 24px; padding-bottom: 24px; border-bottom: 1px solid #e0e0e0;">
@@ -457,6 +468,15 @@
<p>All available content has been added to this playlist!</p> <p>All available content has been added to this playlist!</p>
</div> </div>
{% endif %} {% endif %}
{% else %}
{# Viewer without edit permission on this playlist #}
<div style="text-align:center; padding:40px; color:#999;">
<div style="font-size:3rem; margin-bottom:1rem;">🔒</div>
<p>You have view-only access to this playlist.</p>
<p style="font-size:0.85rem; margin-top:0.5rem;">Contact an administrator to request edit access.</p>
</div>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
+64
View File
@@ -0,0 +1,64 @@
"""
Shared role / access helpers for DigiServer blueprints.
Import these instead of duplicating decorators in every blueprint.
"""
from functools import wraps
from flask import abort, flash, redirect, url_for
from flask_login import current_user
# ── Role-gate decorators ──────────────────────────────────────────────────────
def editor_required(f):
"""Allow admin and editor roles; redirect viewers with a flash message."""
@wraps(f)
def decorated(*args, **kwargs):
if not current_user.is_authenticated:
return redirect(url_for('auth.login'))
if current_user.role not in ('admin', 'editor'):
flash('You need editor or admin privileges to perform this action.', 'danger')
return redirect(url_for('main.dashboard'))
return f(*args, **kwargs)
return decorated
def admin_required(f):
"""Allow admin role only."""
@wraps(f)
def decorated(*args, **kwargs):
if not current_user.is_authenticated:
return redirect(url_for('auth.login'))
if current_user.role != 'admin':
flash('Administrator access required.', 'danger')
return redirect(url_for('main.dashboard'))
return f(*args, **kwargs)
return decorated
# ── Playlist permission check ─────────────────────────────────────────────────
def can_edit_playlist(user, playlist_id: int) -> bool:
"""
Return True if *user* is allowed to edit the given playlist.
- admin / editor always True
- viewer True only when a PlaylistPermission row exists
"""
if user.role in ('admin', 'editor'):
return True
from app.models.playlist_permission import PlaylistPermission
return PlaylistPermission.query.filter_by(
user_id=user.id, playlist_id=playlist_id
).first() is not None
def get_editable_playlist_ids(user) -> set:
"""Return the set of playlist IDs the user may edit (used in list views)."""
if user.role in ('admin', 'editor'):
# Import here to avoid circular imports at module load time
from app.models.playlist import Playlist
return {p.id for p in Playlist.query.with_entities(Playlist.id).all()}
from app.models.playlist_permission import PlaylistPermission
rows = PlaylistPermission.query.filter_by(user_id=user.id).all()
return {r.playlist_id for r in rows}
+16 -2
View File
@@ -3,7 +3,12 @@ Portal SSO middleware for DigiServer v2.
When the umbrella nginx verifies the portal JWT it sets two headers: When the umbrella nginx verifies the portal JWT it sets two headers:
X-Auth-Username the portal username X-Auth-Username the portal username
X-Auth-Role 'admin' or 'user' X-Auth-Role 'admin' | 'advanced' | 'standard'
Portal role DigiServer local role mapping:
admin admin (full access including user management)
advanced editor (manage content/playlists, no user management)
standard viewer (read-only)
This before_request handler reads those headers and auto-logs in the This before_request handler reads those headers and auto-logs in the
corresponding local DigiServer user, creating them on first access if corresponding local DigiServer user, creating them on first access if
@@ -32,12 +37,21 @@ def init_portal_sso(app):
login_user(user, remember=False) login_user(user, remember=False)
def _portal_role_to_local(portal_role):
"""Map a portal role string to the DigiServer local role."""
if portal_role == 'admin':
return 'admin'
if portal_role == 'advanced':
return 'editor'
return 'viewer' # 'standard' or anything unknown
def _get_or_create_user(username, role): def _get_or_create_user(username, role):
from app.models.user import User from app.models.user import User
from app.extensions import db, bcrypt from app.extensions import db, bcrypt
try: try:
target_role = 'admin' if role == 'admin' else 'user' target_role = _portal_role_to_local(role)
user = User.query.filter_by(username=username).first() user = User.query.filter_by(username=username).first()
if not user: if not user:
hashed_pw = bcrypt.generate_password_hash(secrets.token_hex(32)).decode('utf-8') hashed_pw = bcrypt.generate_password_hash(secrets.token_hex(32)).decode('utf-8')
+1 -1
View File
@@ -50,7 +50,7 @@ def _seed_admin(app):
username=app.config['ADMIN_USERNAME'], username=app.config['ADMIN_USERNAME'],
email=app.config['ADMIN_EMAIL'], email=app.config['ADMIN_EMAIL'],
password_hash=generate_password_hash(app.config['ADMIN_PASSWORD']), password_hash=generate_password_hash(app.config['ADMIN_PASSWORD']),
is_admin=True, role='admin',
is_active=True, is_active=True,
) )
db.session.add(admin) db.session.add(admin)
+21 -3
View File
@@ -3,6 +3,12 @@ from flask_login import UserMixin
from app.extensions import db, login_manager from app.extensions import db, login_manager
# Valid portal-level roles
PORTAL_ROLES = ('admin', 'advanced', 'standard')
# Valid per-app role overrides (same tier names)
APP_ROLES = ('admin', 'advanced', 'standard')
class PortalUser(UserMixin, db.Model): class PortalUser(UserMixin, db.Model):
__tablename__ = 'portal_users' __tablename__ = 'portal_users'
@@ -10,7 +16,9 @@ class PortalUser(UserMixin, db.Model):
username = db.Column(db.String(80), unique=True, nullable=False, index=True) username = db.Column(db.String(80), unique=True, nullable=False, index=True)
email = db.Column(db.String(200), unique=True, nullable=False) email = db.Column(db.String(200), unique=True, nullable=False)
password_hash = db.Column(db.String(256), nullable=False) password_hash = db.Column(db.String(256), nullable=False)
is_admin = db.Column(db.Boolean, default=False) # role: 'admin' | 'advanced' | 'standard'
# (replaces the old is_admin Boolean — kept as property for template compat)
role = db.Column(db.String(20), nullable=False, default='standard')
is_active = db.Column(db.Boolean, default=True) is_active = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
last_login = db.Column(db.DateTime, nullable=True) last_login = db.Column(db.DateTime, nullable=True)
@@ -18,6 +26,16 @@ class PortalUser(UserMixin, db.Model):
app_accesses = db.relationship('AppAccess', backref='user', lazy='dynamic', cascade='all, delete-orphan') app_accesses = db.relationship('AppAccess', backref='user', lazy='dynamic', cascade='all, delete-orphan')
api_keys = db.relationship('ApiKey', backref='user', lazy='dynamic', cascade='all, delete-orphan') api_keys = db.relationship('ApiKey', backref='user', lazy='dynamic', cascade='all, delete-orphan')
@property
def is_admin(self):
"""True when portal role is 'admin'. Kept for template/decorator backward compat."""
return self.role == 'admin'
@property
def is_advanced(self):
"""True when portal role is 'admin' or 'advanced'."""
return self.role in ('admin', 'advanced')
def get_accessible_apps(self): def get_accessible_apps(self):
return [a.app_name for a in self.app_accesses.filter_by(is_active=True).all()] return [a.app_name for a in self.app_accesses.filter_by(is_active=True).all()]
@@ -25,12 +43,12 @@ class PortalUser(UserMixin, db.Model):
return self.app_accesses.filter_by(app_name=app_name, is_active=True).first() is not None return self.app_accesses.filter_by(app_name=app_name, is_active=True).first() is not None
def app_role(self, app_name): def app_role(self, app_name):
"""Return the per-app role override ('admin'|'user'), or None if not set.""" """Return the per-app role override, or None if not set."""
access = self.app_accesses.filter_by(app_name=app_name, is_active=True).first() access = self.app_accesses.filter_by(app_name=app_name, is_active=True).first()
return access.app_role if access else None return access.app_role if access else None
def __repr__(self): def __repr__(self):
return f'<PortalUser {self.username}>' return f'<PortalUser {self.username} role={self.role}>'
@login_manager.user_loader @login_manager.user_loader
+37 -1
View File
@@ -1,5 +1,6 @@
from flask import Blueprint, request, make_response, current_app from flask import Blueprint, request, make_response, current_app, jsonify
import jwt import jwt
import secrets as _secrets
bp = Blueprint('api', __name__, url_prefix='/api') bp = Blueprint('api', __name__, url_prefix='/api')
@@ -70,3 +71,38 @@ def _app_from_uri(uri):
if uri.startswith('/srvmonitor/'): if uri.startswith('/srvmonitor/'):
return 'srvmonitor' return 'srvmonitor'
return None return None
@bp.route('/internal/nv-users')
def nv_users_internal():
"""
Internal endpoint for the NetworkView backend to fetch all portal users
that have NetworkView access, together with their assigned NV role.
Protected by X-Internal-Token header (shared INTERNAL_SYNC_SECRET).
"""
secret = current_app.config.get('INTERNAL_SYNC_SECRET', '')
provided = request.headers.get('X-Internal-Token', '')
if not secret or not _secrets.compare_digest(provided, provided and secret):
# constant-time compare — reject if secret mismatch or empty
if not secret or not provided or not _secrets.compare_digest(provided, secret):
return jsonify({'error': 'forbidden'}), 403
from app.models.user import PortalUser
from app.models.app_access import AppAccess
users = PortalUser.query.filter_by(is_active=True).order_by(PortalUser.username).all()
result = []
for u in users:
nv_access = AppAccess.query.filter_by(
user_id=u.id, app_name='networkview', is_active=True
).first()
if nv_access is None:
continue # skip users who have no NV access at all
result.append({
'portal_id': str(u.id),
'username': u.username,
'email': u.email,
'portal_role': u.role,
'nv_role': nv_access.app_role or u.role, # per-app override or portal role
})
return jsonify(result)
+38 -1
View File
@@ -19,7 +19,7 @@ def _issue_portal_cookie(user, response):
'sub': user.username, 'sub': user.username,
'user_id': user.id, 'user_id': user.id,
'email': user.email, 'email': user.email,
'role': 'admin' if user.is_admin else 'user', 'role': user.role,
'apps': user.get_accessible_apps(), 'apps': user.get_accessible_apps(),
'iss': 'enterprise-digital-platform', 'iss': 'enterprise-digital-platform',
'iat': now, 'iat': now,
@@ -74,3 +74,40 @@ def logout():
resp.delete_cookie(current_app.config['PORTAL_COOKIE_NAME'], path='/') resp.delete_cookie(current_app.config['PORTAL_COOKIE_NAME'], path='/')
flash('You have been signed out.', 'info') flash('You have been signed out.', 'info')
return resp return resp
@bp.route('/portal-return')
def portal_return():
"""
Re-establish a portal Flask-Login session from the JWT cookie.
Sub-apps link here instead of '/' so the user is always properly
logged in to the portal even if the session cookie expired while
they were working in a sub-app.
"""
# Already logged in — just go to the dashboard
if current_user.is_authenticated:
return redirect(url_for('dashboard.index'))
token = request.cookies.get(current_app.config['PORTAL_COOKIE_NAME'])
if not token:
return redirect(url_for('auth.login'))
try:
payload = jwt.decode(
token,
current_app.config['PORTAL_JWT_SECRET'],
algorithms=['HS256'],
options={'require': ['exp', 'sub', 'user_id']},
)
except jwt.InvalidTokenError:
return redirect(url_for('auth.login'))
user = PortalUser.query.get(payload.get('user_id'))
if not user or not user.is_active:
return redirect(url_for('auth.login'))
login_user(user, remember=False)
resp = make_response(redirect(url_for('dashboard.index')))
_issue_portal_cookie(user, resp) # refresh the JWT while we're here
return resp
+14 -8
View File
@@ -9,6 +9,9 @@ from app.models.api_key import ApiKey
bp = Blueprint('settings', __name__, url_prefix='/settings') bp = Blueprint('settings', __name__, url_prefix='/settings')
VALID_APP_ROLES = {'admin', 'advanced', 'standard'}
VALID_PORTAL_ROLES = {'admin', 'advanced', 'standard'}
def _sync_user_to_app(app_id, username, role): def _sync_user_to_app(app_id, username, role):
""" """
@@ -76,7 +79,10 @@ def new_user():
username = request.form.get('username', '').strip() username = request.form.get('username', '').strip()
email = request.form.get('email', '').strip() email = request.form.get('email', '').strip()
password = request.form.get('password', '') password = request.form.get('password', '')
is_admin = request.form.get('is_admin') == 'on' portal_role = request.form.get('portal_role', 'standard').strip()
if portal_role not in VALID_PORTAL_ROLES:
portal_role = 'standard'
is_admin = portal_role == 'admin' # kept for legacy checks
if not username or not email or not password: if not username or not email or not password:
flash('Username, email and password are required.', 'danger') flash('Username, email and password are required.', 'danger')
@@ -90,7 +96,7 @@ def new_user():
username=username, username=username,
email=email, email=email,
password_hash=generate_password_hash(password), password_hash=generate_password_hash(password),
is_admin=is_admin, role=portal_role,
is_active=True, is_active=True,
) )
db.session.add(user) db.session.add(user)
@@ -99,7 +105,7 @@ def new_user():
for app in registered_apps: for app in registered_apps:
app_id = app['id'] app_id = app['id']
role_val = request.form.get(f'role_{app_id}', 'none').strip() role_val = request.form.get(f'role_{app_id}', 'none').strip()
if role_val in ('admin', 'user'): if role_val in VALID_APP_ROLES:
db.session.add(AppAccess(user_id=user.id, app_name=app_id, db.session.add(AppAccess(user_id=user.id, app_name=app_id,
is_active=True, app_role=role_val)) is_active=True, app_role=role_val))
@@ -109,7 +115,7 @@ def new_user():
for app in registered_apps: for app in registered_apps:
app_id = app['id'] app_id = app['id']
role_val = request.form.get(f'role_{app_id}', 'none').strip() role_val = request.form.get(f'role_{app_id}', 'none').strip()
if role_val in ('admin', 'user'): if role_val in VALID_APP_ROLES:
_sync_user_to_app(app_id, username, role_val) _sync_user_to_app(app_id, username, role_val)
flash(f'User "{username}" created successfully.', 'success') flash(f'User "{username}" created successfully.', 'success')
@@ -127,10 +133,10 @@ def update_access(user_id):
for app in registered_apps: for app in registered_apps:
app_id = app['id'] app_id = app['id']
# The UI sends role_<app_id> = 'admin' | 'user' | 'none' # The UI sends role_<app_id> = 'admin' | 'advanced' | 'standard' | 'none'
role_val = request.form.get(f'role_{app_id}', 'none').strip() role_val = request.form.get(f'role_{app_id}', 'none').strip()
should_have = role_val in ('admin', 'user') should_have = role_val in VALID_APP_ROLES
app_role = role_val if role_val in ('admin', 'user') else None app_role = role_val if role_val in VALID_APP_ROLES else None
existing = AppAccess.query.filter_by(user_id=user.id, app_name=app_id).first() existing = AppAccess.query.filter_by(user_id=user.id, app_name=app_id).first()
if existing: if existing:
@@ -146,7 +152,7 @@ def update_access(user_id):
for app in registered_apps: for app in registered_apps:
app_id = app['id'] app_id = app['id']
role_val = request.form.get(f'role_{app_id}', 'none').strip() role_val = request.form.get(f'role_{app_id}', 'none').strip()
if role_val in ('admin', 'user'): if role_val in VALID_APP_ROLES:
_sync_user_to_app(app_id, user.username, role_val) _sync_user_to_app(app_id, user.username, role_val)
flash(f'Access for "{user.username}" updated.', 'success') flash(f'Access for "{user.username}" updated.', 'success')
+4 -2
View File
@@ -105,12 +105,13 @@ code { font-family: 'SFMono-Regular', Consolas, monospace; font-size: 0.85em; }
font-size: 0.7rem; font-size: 0.7rem;
padding: 2px 6px; padding: 2px 6px;
border-radius: 20px; border-radius: 20px;
background: rgba(56,139,253,0.2);
color: var(--accent-blue);
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.04em; letter-spacing: 0.04em;
} }
.role-tag--admin { background: rgba(56,139,253,0.2); color: var(--accent-blue); }
.role-tag--advanced { background: rgba(245,158,11,0.2); color: #f59e0b; }
.role-tag--standard { background: rgba(110,118,129,0.15); color: var(--text-secondary); }
.btn-logout { .btn-logout {
color: var(--text-muted); color: var(--text-muted);
@@ -376,6 +377,7 @@ code { font-family: 'SFMono-Regular', Consolas, monospace; font-size: 0.85em; }
letter-spacing: 0.04em; letter-spacing: 0.04em;
} }
.badge-admin { background: rgba(56,139,253,0.2); color: var(--accent-blue); } .badge-admin { background: rgba(56,139,253,0.2); color: var(--accent-blue); }
.badge-advanced { background: rgba(245,158,11,0.2); color: #f59e0b; }
.badge-user { background: rgba(110,118,129,0.2); color: var(--text-secondary); } .badge-user { background: rgba(110,118,129,0.2); color: var(--text-secondary); }
.badge-active { background: rgba(63,185,80,0.2); color: var(--accent-green); } .badge-active { background: rgba(63,185,80,0.2); color: var(--accent-green); }
.badge-inactive { background: rgba(110,118,129,0.2); color: var(--text-muted); } .badge-inactive { background: rgba(110,118,129,0.2); color: var(--text-muted); }
+7 -1
View File
@@ -22,7 +22,13 @@
<div class="topbar-user"> <div class="topbar-user">
<span class="user-badge">{{ current_user.username[0].upper() }}</span> <span class="user-badge">{{ current_user.username[0].upper() }}</span>
<span class="user-name">{{ current_user.username }}</span> <span class="user-name">{{ current_user.username }}</span>
{% if current_user.is_admin %}<span class="role-tag">admin</span>{% endif %} {% if current_user.role == 'admin' %}
<span class="role-tag role-tag--admin">admin</span>
{% elif current_user.role == 'advanced' %}
<span class="role-tag role-tag--advanced">advanced</span>
{% else %}
<span class="role-tag role-tag--standard">standard</span>
{% endif %}
<a href="{{ url_for('auth.logout') }}" class="btn-logout" title="Sign out"></a> <a href="{{ url_for('auth.logout') }}" class="btn-logout" title="Sign out"></a>
</div> </div>
</header> </header>
+11 -5
View File
@@ -41,10 +41,12 @@
<td><strong>{{ user.username }}</strong></td> <td><strong>{{ user.username }}</strong></td>
<td class="text-muted">{{ user.email }}</td> <td class="text-muted">{{ user.email }}</td>
<td> <td>
{% if user.is_admin %} {% if user.role == 'admin' %}
<span class="badge badge-admin">admin</span> <span class="badge badge-admin">admin</span>
{% elif user.role == 'advanced' %}
<span class="badge badge-advanced">advanced</span>
{% else %} {% else %}
<span class="badge badge-user">user</span> <span class="badge badge-user">standard</span>
{% endif %} {% endif %}
</td> </td>
<td class="text-muted"> <td class="text-muted">
@@ -80,9 +82,13 @@
<option value="none" {% if not (cur_access and cur_access.is_active) %}selected{% endif %}> <option value="none" {% if not (cur_access and cur_access.is_active) %}selected{% endif %}>
— no access — no access
</option> </option>
<option value="user" <option value="standard"
{% if cur_access and cur_access.is_active and (not cur_access.app_role or cur_access.app_role == 'user') %}selected{% endif %}> {% if cur_access and cur_access.is_active and cur_access.app_role == 'standard' %}selected{% endif %}>
✓ user 👁 standard
</option>
<option value="advanced"
{% if cur_access and cur_access.is_active and cur_access.app_role == 'advanced' %}selected{% endif %}>
✏ advanced
</option> </option>
<option value="admin" <option value="admin"
{% if cur_access and cur_access.is_active and cur_access.app_role == 'admin' %}selected{% endif %}> {% if cur_access and cur_access.is_active and cur_access.app_role == 'admin' %}selected{% endif %}>
+9 -8
View File
@@ -34,13 +34,13 @@
<label class="form-label">Password</label> <label class="form-label">Password</label>
<input type="password" name="password" class="form-input" required /> <input type="password" name="password" class="form-input" required />
</div> </div>
<div class="form-group form-group--check"> <div class="form-group">
<label class="form-label">Portal Role</label> <label class="form-label">Portal Role</label>
<label class="toggle"> <select name="portal_role" class="form-select">
<input type="checkbox" name="is_admin" /> <option value="standard">Standard User — read-only access in apps</option>
<span class="toggle-slider"></span> <option value="advanced">Advanced User — create &amp; edit content in apps</option>
<span class="toggle-label">Platform administrator</span> <option value="admin">Administrator — full access + user management</option>
</label> </select>
</div> </div>
</div> </div>
@@ -61,8 +61,9 @@
<select name="role_{{ app['id'] }}" class="form-select app-role-new-select" <select name="role_{{ app['id'] }}" class="form-select app-role-new-select"
data-color="{{ app.color }}"> data-color="{{ app.color }}">
<option value="none">— No access</option> <option value="none">— No access</option>
<option value="user">✓ User</option> <option value="standard">👁 Standard — read-only</option>
<option value="admin"> Admin</option> <option value="advanced"> Advanced — create &amp; edit</option>
<option value="admin">★ Admin — full access</option>
</select> </select>
</div> </div>
{% endfor %} {% endfor %}
+56
View File
@@ -0,0 +1,56 @@
"""
Migration: Add 3-tier role system to the portal DB.
Replaces the old binary is_admin boolean with a role column
('admin' | 'advanced' | 'standard') on portal_users, and updates
existing app_access.app_role values from the old 'user' string
to the new 'standard' string.
Run once against your existing portal.db:
cd portal
python migrate_roles.py
"""
import os
import sqlite3
DB_PATH = os.environ.get(
'DATABASE_URL',
os.path.join(os.path.dirname(__file__), 'data', 'portal.db'),
).replace('sqlite:///', '')
if not os.path.exists(DB_PATH):
# Try the instance folder as a fallback
DB_PATH = os.path.join(os.path.dirname(__file__), 'instance', 'portal.db')
print(f'Migrating: {DB_PATH}')
con = sqlite3.connect(DB_PATH)
cur = con.cursor()
# ── 1. portal_users: add role column if missing ────────────────────────────────
cols = {row[1] for row in cur.execute("PRAGMA table_info(portal_users)")}
if 'role' not in cols:
print(' Adding portal_users.role column …')
cur.execute("ALTER TABLE portal_users ADD COLUMN role TEXT NOT NULL DEFAULT 'standard'")
# Populate from the old is_admin boolean
if 'is_admin' in cols:
cur.execute("""
UPDATE portal_users
SET role = CASE WHEN is_admin = 1 THEN 'admin' ELSE 'standard' END
""")
print(' Populated role from is_admin.')
con.commit()
else:
print(' portal_users.role already exists — skipping add.')
# ── 2. app_access: rename old 'user' role value to 'standard' ─────────────────
updated = cur.execute(
"UPDATE app_access SET app_role = 'standard' WHERE app_role = 'user'"
).rowcount
if updated:
print(f' Updated {updated} app_access rows: user → standard')
con.commit()
print('Migration complete.')
con.close()
+5 -1
View File
@@ -105,7 +105,7 @@ VITE_CFG="$ROOT/NetworkView/frontend/vite.config.ts"
if [[ ! -f "$NV_DIST/index.html" ]] \ if [[ ! -f "$NV_DIST/index.html" ]] \
|| find "$ROOT/NetworkView/frontend/src" -newer "$NV_DIST/index.html" -name "*.ts" -o -name "*.tsx" -o -name "*.css" 2>/dev/null | grep -q .; then || find "$ROOT/NetworkView/frontend/src" -newer "$NV_DIST/index.html" -name "*.ts" -o -name "*.tsx" -o -name "*.css" 2>/dev/null | grep -q .; then
info "Building NetworkView frontend..." info "Building NetworkView frontend..."
VITE_BASE_PATH=/networkview/ \ VITE_BASE_PATH=/networkview/ VITE_API_BASE=/networkview/api \
npm --prefix "$ROOT/NetworkView/frontend" run build --silent npm --prefix "$ROOT/NetworkView/frontend" run build --silent
fi fi
@@ -309,6 +309,7 @@ http {
auth_request /portal-verify; auth_request /portal-verify;
auth_request_set \$auth_user_id \$upstream_http_x_auth_user_id; auth_request_set \$auth_user_id \$upstream_http_x_auth_user_id;
auth_request_set \$auth_username \$upstream_http_x_auth_username; auth_request_set \$auth_username \$upstream_http_x_auth_username;
auth_request_set \$auth_role \$upstream_http_x_auth_role;
proxy_pass http://nv_backend_upstream/api/; proxy_pass http://nv_backend_upstream/api/;
proxy_set_header Host \$host; proxy_set_header Host \$host;
@@ -317,6 +318,7 @@ http {
proxy_set_header X-Forwarded-Proto \$scheme; proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-User-Id \$auth_user_id; proxy_set_header X-User-Id \$auth_user_id;
proxy_set_header X-Username \$auth_username; proxy_set_header X-Username \$auth_username;
proxy_set_header X-Auth-Role \$auth_role;
} }
# NetworkView SPA — served from pre-built dist/ # NetworkView SPA — served from pre-built dist/
@@ -435,6 +437,8 @@ if module_enabled "networkview"; then
start_bg "networkview-backend" \ start_bg "networkview-backend" \
env PORT=$NV_BACKEND_PORT \ env PORT=$NV_BACKEND_PORT \
PORTAL_JWT_SECRET=change-this-jwt-secret-in-production \ PORTAL_JWT_SECRET=change-this-jwt-secret-in-production \
PORTAL_INTERNAL_URL="http://127.0.0.1:${PORTAL_PORT}" \
INTERNAL_SYNC_SECRET="${INTERNAL_SYNC_SECRET:-change-this-internal-secret}" \
NODE_ENV=development \ NODE_ENV=development \
DB_PATH="$ROOT/NetworkView/data/networkview.db" \ DB_PATH="$ROOT/NetworkView/data/networkview.db" \
node "$ROOT/NetworkView/backend/src/index.js" node "$ROOT/NetworkView/backend/src/index.js"