{member.displayName || member.username} @@ -31,6 +40,16 @@ const MemberList = ({ {member.email}
{member.role}
+ {onRemove && ( + + )}diff --git a/backend/access/sourceAccess.js b/backend/access/sourceAccess.js
new file mode 100644
index 000000000..8107e77f0
--- /dev/null
+++ b/backend/access/sourceAccess.js
@@ -0,0 +1,110 @@
+'use strict';
+
+const normalizeIds = (values) => {
+ if (!Array.isArray(values)) return [];
+
+ return values
+ .filter(Boolean)
+ .map((value) => {
+ if (value._id) return String(value._id);
+ return String(value);
+ });
+};
+
+const getUserTeamIds = (user) => {
+ if (!user || !Array.isArray(user.teams)) return [];
+ return normalizeIds(user.teams);
+};
+
+const getSourcePolicy = (source) => {
+ return source && source.accessPolicy
+ ? source.accessPolicy
+ : {
+ mode: 'public',
+ teams: [],
+ cutoffDate: null,
+ };
+};
+
+const hasTeamOverlap = (userTeamIds, allowedTeamIds) => {
+ const userTeams = new Set(userTeamIds);
+ return allowedTeamIds.some((teamId) => userTeams.has(teamId));
+};
+
+const isAdmin = (user) => {
+ return user && user.role === 'admin';
+};
+
+const canAccessRestrictedPolicy = (user, policy) => {
+ if (isAdmin(user)) return true;
+
+ const userTeamIds = getUserTeamIds(user);
+ const allowedTeamIds = normalizeIds(policy.teams);
+
+ if (allowedTeamIds.length === 0) return false;
+
+ return hasTeamOverlap(userTeamIds, allowedTeamIds);
+};
+
+const canViewSource = (user, source) => {
+ if (isAdmin(user)) return true;
+
+ const policy = getSourcePolicy(source);
+
+ if (!policy.mode || policy.mode === 'public') {
+ return true;
+ }
+
+ if (policy.mode === 'restricted') {
+ return canAccessRestrictedPolicy(user, policy);
+ }
+
+ if (policy.mode === 'public_until') {
+ return canAccessRestrictedPolicy(user, policy);
+ }
+
+ return false;
+};
+
+const canViewSourceDataForDate = (user, source, recordDate) => {
+ if (isAdmin(user)) return true;
+
+ const policy = getSourcePolicy(source);
+
+ if (!policy.mode || policy.mode === 'public') {
+ return true;
+ }
+
+ if (policy.mode === 'restricted') {
+ return canAccessRestrictedPolicy(user, policy);
+ }
+
+ if (policy.mode === 'public_until') {
+ if (!policy.cutoffDate) {
+ return canAccessRestrictedPolicy(user, policy);
+ }
+
+ if (!recordDate) {
+ return canAccessRestrictedPolicy(user, policy);
+ }
+
+ const cutoffDate = new Date(policy.cutoffDate);
+ const itemDate = new Date(recordDate);
+
+ if (itemDate < cutoffDate) {
+ return true;
+ }
+
+ return canAccessRestrictedPolicy(user, policy);
+ }
+
+ return false;
+};
+
+module.exports = {
+ canViewSource,
+ canViewSourceDataForDate,
+ getSourcePolicy,
+ getUserTeamIds,
+ normalizeIds,
+};
\ No newline at end of file
diff --git a/backend/api/controllers/sourceController.js b/backend/api/controllers/sourceController.js
index 965906e36..e9bc907ea 100644
--- a/backend/api/controllers/sourceController.js
+++ b/backend/api/controllers/sourceController.js
@@ -4,15 +4,24 @@
var Source = require('../../models/source');
var _ = require('lodash');
+var sourcePopulate = [
+ { path: 'user', select: 'username' },
+ { path: 'credentials' },
+ { path: 'accessPolicy.teams', select: 'name description active' },
+];
+
// Create a new Source
exports.source_create = (req, res) => {
// set user as the logged in user
if (req.user) req.body.user = req.user._id;
+
+ normalizeAccessPolicy(req.body);
+
Source.create(req.body, function (err, source) {
if (err) {
return res.status(err.status).send(err.message);
}
-
+
res.status(200).send(source);
});
}
@@ -20,11 +29,8 @@ exports.source_create = (req, res) => {
// Get a list of all sources
exports.source_sources = (req, res) => {
// Find all, exclude `events` field, populate user
- Source.find({}, '-events', { sort: 'nickname' })
- .populate([
- { path: 'user', select: 'username' },
- { path: 'credentials' }
- ])
+ Source.find({}, '-events', { sort: 'nickname' })
+ .populate(sourcePopulate)
.exec(function (err, sources) {
if (err) res.status(err.status).send(err.message);
else res.status(200).send(sources);
@@ -37,10 +43,7 @@ exports.source_details = (req, res) => {
else if (!source) return res.sendStatus(404);
Source.populate(
source,
- [
- { path: 'user', select: 'username' },
- { path: 'credentials' }
- ],
+ sourcePopulate,
function (err, source) {
if (err) res.status(err.status).send(err.message);
else res.status(200).send(source);
@@ -48,6 +51,35 @@ exports.source_details = (req, res) => {
});
}
+//helper for source.lpopulate
+var normalizeAccessPolicy = function (sourceData) {
+ if (!sourceData.accessPolicy) return;
+
+ var accessPolicy = sourceData.accessPolicy;
+
+ if (!accessPolicy.mode) {
+ accessPolicy.mode = 'public';
+ }
+
+ if (!Array.isArray(accessPolicy.teams)) {
+ accessPolicy.teams = [];
+ }
+
+ if (accessPolicy.cutoffDate === '') {
+ accessPolicy.cutoffDate = null;
+ }
+
+ if (accessPolicy.mode === 'public') {
+ accessPolicy.teams = [];
+ accessPolicy.cutoffDate = null;
+ }
+
+ if (accessPolicy.mode === 'restricted') {
+ accessPolicy.cutoffDate = null;
+ }
+};
+
+
exports.source_update = (req, res, next) => {
if (req.params._id === '_events') return next();
// Find source to update
@@ -55,6 +87,8 @@ exports.source_update = (req, res, next) => {
if (err) return res.status(err.status).send(err.message);
if (!source) return res.sendStatus(404);
+ normalizeAccessPolicy(req.body);
+
// Update the actual values
_.forEach(_.omit(req.body, ['_id', 'user', 'events']), function (val, key) {
source[key] = val;
diff --git a/backend/api/controllers/teamController.js b/backend/api/controllers/teamController.js
index 5d2e9dcaf..a7ecc12d4 100644
--- a/backend/api/controllers/teamController.js
+++ b/backend/api/controllers/teamController.js
@@ -2,10 +2,20 @@
const User = require('../../models/user');
const Team = require('../../models/team');
+const assignableRoles = ['viewer', 'monitor', 'team_lead'];
+
+const canManageTeams = (user) => {
+ return user && ['admin', 'team_lead'].includes(user.role);
+};
+
// Get all teams
exports.team_list = (req, res) => {
if (!req.user) return res.status(401).send('Unauthenticated.');
+ if (!canManageTeams(req.user)) {
+ return res.status(403).send('Unauthorized to view teams.');
+ }
+
Team.find({})
.sort({ name: 1 })
.lean()
@@ -61,10 +71,9 @@ exports.team_manageable_list = async (req, res) => {
exports.team_detail = async (req, res) => {
if (!req.user) return res.status(401).send('Unauthenticated.');
- if (req.user.role !== 'admin') {
- return res.status(403).send('Unauthorized to view team details.');
- }
-
+ if (!canManageTeams(req.user)) {
+ return res.status(403).send('Unauthorized to view team details.');
+}
try {
const team = await Team.findById(req.params._id)
.lean();
@@ -97,6 +106,10 @@ exports.team_detail = async (req, res) => {
exports.team_create = (req, res) => {
if (!req.user) return res.status(401).send('Unauthenticated.');
+ if (!canManageTeams(req.user)) {
+ return res.status(403).send('Unauthorized to create teams.');
+ }
+
const payload = {
name: req.body.name,
description: req.body.description || '',
@@ -114,13 +127,123 @@ exports.team_create = (req, res) => {
});
};
+// Add or update a user's membership in a team
+exports.team_add_member = async (req, res) => {
+ if (!req.user) return res.status(401).send('Unauthenticated.');
+
+ if (!canManageTeams(req.user)) {
+ return res.status(403).send('Unauthorized to manage team members.');
+ }
+
+ const userId = req.body.userId;
+ const role = req.body.role;
+
+ if (!userId) {
+ return res.status(400).send('Please provide a userId.');
+ }
+
+ if (!assignableRoles.includes(role)) {
+ return res.status(400).send('Role must be viewer, monitor, or team_lead.');
+ }
+
+ try {
+ const team = await Team.findById(req.params._id).lean();
+
+ if (!team) {
+ return res.sendStatus(404);
+ }
+
+ const user = await User.findById(userId).select('-password');
+
+ if (!user) {
+ return res.status(404).send('User not found.');
+ }
+
+ if (user.role === 'admin') {
+ return res.status(403).send('Admin users cannot be assigned from the team page.');
+ }
+
+ user.role = role;
+ user.teams = user.teams || [];
+
+ const alreadyInTeam = user.teams.some(
+ (teamId) => String(teamId) === String(req.params._id)
+ );
+
+ if (!alreadyInTeam) {
+ user.teams.push(req.params._id);
+ }
+
+ await user.save();
+
+ const members = await User.find({ teams: req.params._id })
+ .select('_id username displayName email role createdBy')
+ .sort({ role: 1, username: 1 })
+ .lean();
+
+ return res.status(200).send({
+ team,
+ members,
+ });
+ } catch (err) {
+ return res
+ .status(err.status || 500)
+ .send(err.message || 'Team member update failed');
+ }
+};
+
+// Remove a user from a team
+exports.team_remove_member = async (req, res) => {
+ if (!req.user) return res.status(401).send('Unauthenticated.');
+
+ if (!canManageTeams(req.user)) {
+ return res.status(403).send('Unauthorized to manage team members.');
+ }
+
+ try {
+ const team = await Team.findById(req.params._id).lean();
+
+ if (!team) {
+ return res.sendStatus(404);
+ }
+
+ const user = await User.findById(req.params.userId).select('-password');
+
+ if (!user) {
+ return res.status(404).send('User not found.');
+ }
+
+ if (user.role === 'admin') {
+ return res.status(403).send('Admin users cannot be removed from teams here.');
+ }
+
+ await User.findByIdAndUpdate(req.params.userId, {
+ $pull: { teams: req.params._id },
+ });
+
+ const members = await User.find({ teams: req.params._id })
+ .select('_id username displayName email role createdBy')
+ .sort({ role: 1, username: 1 })
+ .lean();
+
+ return res.status(200).send({
+ team,
+ members,
+ });
+ } catch (err) {
+ return res
+ .status(err.status || 500)
+ .send(err.message || 'Team member removal failed');
+ }
+};
+
// Delete a team
exports.team_delete = async (req, res) => {
if (!req.user) return res.status(401).send('Unauthenticated.');
- if (req.user.role !== 'admin') {
- return res.status(403).send('Unauthorized to delete teams.');
- }
+if (!canManageTeams(req.user)) {
+ return res.status(403).send('Unauthorized to delete teams.');
+}
try {
const team = await Team.findById(req.params._id).lean();
diff --git a/backend/api/routes/teamRoutes.js b/backend/api/routes/teamRoutes.js
index 86f5b704b..2d80fa6f8 100644
--- a/backend/api/routes/teamRoutes.js
+++ b/backend/api/routes/teamRoutes.js
@@ -2,19 +2,32 @@
const express = require('express');
const router = express.Router();
const teamController = require('../controllers/teamController');
-const User = require('../../models/user');
// Get teams manageable by current user
router.get('/manageable', teamController.team_manageable_list);
// Get all teams
-router.get('', User.can('admin users'), teamController.team_list);
+router.get('', teamController.team_list);
+
+// Add or update a team member
+router.put('/:_id/member', teamController.team_add_member);
+
+// Remove a team member
+router.delete('/:_id/member/:userId', teamController.team_remove_member);
// Get a team with its assigned users
router.get('/:_id', teamController.team_detail);
// Create a team
-router.post('', User.can('admin users'), teamController.team_create);
+router.post('', teamController.team_create);
+
+// Delete a team
+router.delete('/:_id', teamController.team_delete);
+
+/*
+test for api call for delete
+console.log('Loaded teamRoutes with DELETE /:_id');
+*/
/*
router.delete('/test-delete', (req, res) => {
@@ -22,10 +35,4 @@ router.delete('/test-delete', (req, res) => {
});
*/
-// Delete a team
-router.delete('/:_id', teamController.team_delete);
-
-//test for api call for delete
-//console.log('Loaded teamRoutes with DELETE /:_id');
-
module.exports = router;
\ No newline at end of file
diff --git a/backend/models/source.js b/backend/models/source.js
index 0f4e8ca35..8895b3d21 100644
--- a/backend/models/source.js
+++ b/backend/models/source.js
@@ -43,6 +43,23 @@ var sourceSchema = new mongoose.Schema({
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: false },
tags: { type: [String], default: [] },
credentials: { type: mongoose.Schema.Types.ObjectId, ref: 'Credentials', required: true },
+ accessPolicy: {
+ mode: {
+ type: String,
+ enum: ['public', 'restricted', 'public_until'],
+ default: 'public',
+ index: true,
+ },
+ teams: [{
+ type: mongoose.Schema.Types.ObjectId,
+ ref: 'Team',
+ index: true,
+ }],
+ cutoffDate: {
+ type: Date,
+ default: null,
+ },
+ },
});
sourceSchema.pre('save', function (next) {
diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx
index 1b8f6cc65..8d463db48 100644
--- a/src/AppRouter.tsx
+++ b/src/AppRouter.tsx
@@ -103,12 +103,12 @@ const PrivateRoutes = ({ sessionData }: IPrivateRouteProps) => {
+ Controls whether this source is broadly visible or restricted to specific teams.
+
+ Data before this date is treated as public. Data after this date is restricted to the selected teams.
+
+ No teams available yet.
+ Access Policy
+
{member.displayName || member.username} @@ -31,6 +40,16 @@ const MemberList = ({ {member.email}
{member.role}
+ {onRemove && ( + + )}+ Viewing team membership as a team lead. +
+)}+ Adding a user as Team Lead changes their global role to team_lead. +
+