From 2c9fee34ded96d9f55deb78f2f67a56c2985b223 Mon Sep 17 00:00:00 2001
From: Torrey Turkovitz
Date: Mon, 13 Jul 2026 21:25:03 -0400
Subject: [PATCH 1/4] Add source access policy metadata
---
backend/api/controllers/sourceController.js | 54 +++++++++++++++++----
backend/models/source.js | 17 +++++++
2 files changed, 61 insertions(+), 10 deletions(-)
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/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) {
From 89b6d0144596ea73fc8ebade07bf311c1a498437 Mon Sep 17 00:00:00 2001
From: Torrey Turkovitz
Date: Tue, 14 Jul 2026 00:08:51 -0400
Subject: [PATCH 2/4] Add source access policy form fields
---
src/api/sources/types.ts | 11 ++
.../Settings/source/CreateEditSourceForm.tsx | 160 +++++++++++++++++-
2 files changed, 162 insertions(+), 9 deletions(-)
diff --git a/src/api/sources/types.ts b/src/api/sources/types.ts
index 98e6bf1ec..be4808b7d 100644
--- a/src/api/sources/types.ts
+++ b/src/api/sources/types.ts
@@ -1,5 +1,6 @@
import { hasId } from "../common";
import { Credential } from "../credentials/types";
+import type { Team } from "../teams/types";
interface SourceEvent {
datetime: string;
@@ -7,6 +8,14 @@ interface SourceEvent {
message: string;
}
+export type SourceAccessMode = "public" | "restricted" | "public_until";
+
+export interface SourceAccessPolicy {
+ mode: SourceAccessMode;
+ teams: Team[] | string[];
+ cutoffDate?: string | null;
+}
+
export interface Source extends hasId {
enabled: boolean;
unreadErrorCount: number;
@@ -23,6 +32,7 @@ export interface Source extends hasId {
keywords?: string;
regex?: string;
lists?: string;
+ accessPolicy?: SourceAccessPolicy;
__v: number;
lastReportDate?: string;
}
@@ -34,4 +44,5 @@ export interface EditableSource extends hasId {
url: string;
keywords?: string;
lists?: string;
+ accessPolicy?: SourceAccessPolicy;
}
diff --git a/src/pages/Settings/source/CreateEditSourceForm.tsx b/src/pages/Settings/source/CreateEditSourceForm.tsx
index fabf8573f..ee38eb701 100644
--- a/src/pages/Settings/source/CreateEditSourceForm.tsx
+++ b/src/pages/Settings/source/CreateEditSourceForm.tsx
@@ -6,7 +6,7 @@ import { useField } from "formik";
import { getCredentials } from "../../../api/credentials";
import { editSource, newSource } from "../../../api/sources";
-import type { Source } from "../../../api/sources/types";
+import type { Source, SourceAccessMode } from "../../../api/sources/types";
import { Listbox } from "@headlessui/react";
import FormikDropdown from "../../../components/FormikDropdown";
@@ -17,6 +17,9 @@ import { faChevronDown, faCheck } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { CredentialOption, CREDENTIAL_OPTIONS } from "../../../api/common";
+import { getTeams } from "../../../api/teams";
+import type { Team } from "../../../api/teams/types";
+
interface IProps {
source?: Source;
onClose: () => void;
@@ -50,6 +53,107 @@ const MastodonConditionalFields = () => {
);
};
+//helper for source access control
+//may change the date gating and go back to public vs private with team backing
+
+const getSourceAccessTeamIds = (source?: Source) => {
+ return (source?.accessPolicy?.teams || []).map((team) => {
+ if (typeof team === "string") return team;
+ return team._id;
+ });
+};
+
+const getSourceAccessInitialValues = (source?: Source) => ({
+ accessPolicyMode: source?.accessPolicy?.mode || "public",
+ accessPolicyTeams: getSourceAccessTeamIds(source),
+ accessPolicyCutoffDate: source?.accessPolicy?.cutoffDate
+ ? source.accessPolicy.cutoffDate.slice(0, 10)
+ : "",
+});
+
+const SourceAccessPolicyFields = ({ teams }: { teams?: Team[] }) => {
+ const [modeField] = useField("accessPolicyMode");
+ const [teamsField, , teamsHelpers] = useField("accessPolicyTeams");
+ const [cutoffField] = useField("accessPolicyCutoffDate");
+
+ const selectedTeamIds = teamsField.value || [];
+ const isRestricted =
+ modeField.value === "restricted" || modeField.value === "public_until";
+
+ const toggleTeam = (teamId: string) => {
+ if (selectedTeamIds.includes(teamId)) {
+ teamsHelpers.setValue(selectedTeamIds.filter((id) => id !== teamId));
+ return;
+ }
+
+ teamsHelpers.setValue([...selectedTeamIds, teamId]);
+ };
+
+ return (
+
+
Access Policy
+
+ Controls whether this source is broadly visible or restricted to specific teams.
+
+
+
+
+ {modeField.value === "public_until" && (
+
+
+
+
+ Data before this date is treated as public. Data after this date is restricted to the selected teams.
+
+
+ )}
+
+
+ {isRestricted && (
+
+
+
+ {teams && teams.length > 0 ? (
+ teams.map((team) => (
+
+ ))
+ ) : (
+
+ No teams available yet.
+
+ )}
+
+ )}
+
+ );
+};
+
const CreateEditSourceForm = ({ source, onClose }: IProps) => {
const [credentialType, setCredentialType] =
useState((source?.media as CredentialOption) || "ioda");
@@ -60,22 +164,49 @@ const CreateEditSourceForm = ({ source, onClose }: IProps) => {
staleTime: 50000,
});
+ const { data: teams } = useQuery(["teams"], getTeams, {
+ staleTime: 50000,
+ });
+
const defaultCredential =
credentials && credentials.find((cred) => cred.type === credentialType);
const credentialsList =
credentials && credentials.filter((cred) => cred.type === credentialType);
- function onSubmit(data: any) {
- data = { ...data, media: credentialType };
-
- if (!source) {
- doCreateSource.mutate(data);
- return;
- }
- doEditSource.mutate({ ...data, _id: source._id });
+ const sourceAccessInitialValues = getSourceAccessInitialValues(source);
+
+function onSubmit(data: any) {
+ const {
+ accessPolicyMode,
+ accessPolicyTeams,
+ accessPolicyCutoffDate,
+ ...sourceData
+ } = data;
+
+ const accessPolicy = {
+ mode: accessPolicyMode || "public",
+ teams: accessPolicyMode === "public" ? [] : accessPolicyTeams || [],
+ cutoffDate:
+ accessPolicyMode === "public_until"
+ ? accessPolicyCutoffDate || null
+ : null,
+ };
+
+ const payload = {
+ ...sourceData,
+ media: credentialType,
+ accessPolicy,
+ };
+
+ if (!source) {
+ doCreateSource.mutate(payload);
+ return;
}
+ doEditSource.mutate({ ...payload, _id: source._id });
+}
+
const doCreateSource = useMutation(newSource, {
onSuccess: () => {
onClose();
@@ -117,6 +248,7 @@ const CreateEditSourceForm = ({ source, onClose }: IProps) => {
credentials: source?.credentials._id || defaultCredential?._id,
sourceURL: source?.url || "",
url: "https://www.junkipedia.com/",
+ ...sourceAccessInitialValues,
}}
schema={JunkipediaSchema}
onSubmit={(values: IJunkipediaSchema) => {
@@ -137,6 +269,7 @@ const CreateEditSourceForm = ({ source, onClose }: IProps) => {
label={"API Credentials"}
name={"credentials"}
/>
+
);
@@ -159,6 +292,7 @@ const CreateEditSourceForm = ({ source, onClose }: IProps) => {
credentials: source?.credentials._id || defaultCredential?._id,
sourceURL: source?.url || "",
url: "",
+ ...sourceAccessInitialValues,
}}
schema={telegramBotSchema}
onSubmit={(values: ITelegramBotSchema) => {
@@ -202,6 +336,7 @@ const CreateEditSourceForm = ({ source, onClose }: IProps) => {
credentials: source?.credentials._id || defaultCredential?._id,
sourceURL: source?.url || "",
url: "",
+ ...sourceAccessInitialValues,
}}
schema={telegramUserSchema}
onSubmit={(values: ITelegramUserSchema) => {
@@ -232,6 +367,7 @@ const CreateEditSourceForm = ({ source, onClose }: IProps) => {
multiple entries with commas.
+
);
@@ -253,6 +389,7 @@ const CreateEditSourceForm = ({ source, onClose }: IProps) => {
credentials: source?.credentials._id || defaultCredential?._id,
sourceURL: source?.url || "",
url: "",
+ ...sourceAccessInitialValues,
}}
schema={iodaSchema}
onSubmit={(values: IodaSchema) => {
@@ -269,6 +406,7 @@ const CreateEditSourceForm = ({ source, onClose }: IProps) => {
label={"Two-Letter Country Code"}
name={"keywords"}
/>
+
);
@@ -289,6 +427,7 @@ const CreateEditSourceForm = ({ source, onClose }: IProps) => {
credentials: source?.credentials._id || defaultCredential?._id,
sourceURL: source?.url || "",
url: "",
+ ...sourceAccessInitialValues,
}}
schema={cloudflareSchema}
onSubmit={(values: CloudflareSchema) => {
@@ -305,6 +444,7 @@ const CreateEditSourceForm = ({ source, onClose }: IProps) => {
label={"Two-Letter Country Code"}
name={"keywords"}
/>
+
);
@@ -351,6 +491,7 @@ const CreateEditSourceForm = ({ source, onClose }: IProps) => {
credentials: source?.credentials._id || defaultCredential?._id,
sourceURL: source?.url || "",
url: "",
+ ...sourceAccessInitialValues,
}}
schema={mastodonSchema}
onSubmit={(values: IMastodonSchema) => {
@@ -390,6 +531,7 @@ const CreateEditSourceForm = ({ source, onClose }: IProps) => {
label={"Mastodon Credentials"}
name={"credentials"}
/>
+
);
From c3ec934421ea40798f9f6bcc0403e460d0f4f25b Mon Sep 17 00:00:00 2001
From: Torrey Turkovitz
Date: Tue, 14 Jul 2026 00:20:13 -0400
Subject: [PATCH 3/4] Add source access helper
---
backend/access/sourceAccess.js | 110 +++++++++++++++++++++++++++++++++
1 file changed, 110 insertions(+)
create mode 100644 backend/access/sourceAccess.js
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
From 52628b450dcafd44dd6325fa4b8ce391e2daeae9 Mon Sep 17 00:00:00 2001
From: Torrey Turkovitz
Date: Wed, 15 Jul 2026 21:32:35 -0400
Subject: [PATCH 4/4] Add team member management
---
backend/api/controllers/teamController.js | 137 +++++++++++++++++-
backend/api/routes/teamRoutes.js | 25 ++--
src/AppRouter.tsx | 10 +-
src/api/teams/index.ts | 27 ++++
src/pages/Settings/index.tsx | 1 +
src/pages/Settings/team/TeamDetails.tsx | 165 ++++++++++++++++++++--
src/pages/Settings/team/TeamsIndex.tsx | 15 +-
7 files changed, 347 insertions(+), 33 deletions(-)
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/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) => {
} />
>
}
- {sessionData?.role === "admin" && (
+ {(sessionData?.role === "admin" || sessionData?.role === "team_lead") && (
<>
- } />
- } />
- >
- )}
+ } />
+ } />
+ >
+)}
{ sessionData?.role === "admin"
&& (process.env.ENVIRONMENT === "development" || process.env.NODE_ENV === "development")
diff --git a/src/api/teams/index.ts b/src/api/teams/index.ts
index 1b7866b38..8f2e28607 100644
--- a/src/api/teams/index.ts
+++ b/src/api/teams/index.ts
@@ -27,5 +27,32 @@ export const createTeam = async (team: {
export const deleteTeam = async (teamId: string) => {
const { data } = await axios.delete("/api/team/" + teamId);
+ return data;
+};
+
+export const addTeamMember = async (params: {
+ teamId: string;
+ userId: string;
+ role: string;
+}) => {
+ const { data } = await axios.put(
+ "/api/team/" + params.teamId + "/member",
+ {
+ userId: params.userId,
+ role: params.role,
+ }
+ );
+
+ return data;
+};
+
+export const removeTeamMember = async (params: {
+ teamId: string;
+ userId: string;
+}) => {
+ const { data } = await axios.delete(
+ "/api/team/" + params.teamId + "/member/" + params.userId
+ );
+
return data;
};
\ No newline at end of file
diff --git a/src/pages/Settings/index.tsx b/src/pages/Settings/index.tsx
index 6a29522c2..c6fd724fa 100644
--- a/src/pages/Settings/index.tsx
+++ b/src/pages/Settings/index.tsx
@@ -26,6 +26,7 @@ export function menuLinks(role: string | undefined) {
case "team_lead":
return {
"Manage Users": { to: "users", icon: faUsersCog },
+ "Teams": { to: "teams", icon: faUsersCog },
// "Manage Tags": { to: "tags", icon: faTags },
"API Credentials": { to: "credentials", icon: faKey },
"Manage Sources": { to: "sources", icon: faCloudArrowDown },
diff --git a/src/pages/Settings/team/TeamDetails.tsx b/src/pages/Settings/team/TeamDetails.tsx
index bb7130db0..39097ce10 100644
--- a/src/pages/Settings/team/TeamDetails.tsx
+++ b/src/pages/Settings/team/TeamDetails.tsx
@@ -1,16 +1,23 @@
-import { useQuery } from "@tanstack/react-query";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useParams } from "react-router-dom";
-import { getTeam } from "../../../api/teams";
-import type { TeamMember } from "../../../api/teams/types";
+import { addTeamMember, getTeam, removeTeamMember } from "../../../api/teams";
+import { getManageableUsers } from "../../../api/users";
+import type { TeamMember, TeamDetailResponse } from "../../../api/teams/types";
import PlaceholderDiv from "../../../components/PlaceholderDiv";
+import { useState } from "react";
+
const MemberList = ({
title,
members,
+ onRemove,
+ removingUserId,
}: {
title: string;
members: TeamMember[];
+ onRemove?: (userId: string) => void;
+ removingUserId?: string;
}) => {
return (
@@ -22,7 +29,9 @@ const MemberList = ({
members.map((member) => (
{member.displayName || member.username}
@@ -31,6 +40,16 @@ const MemberList = ({
{member.email}
{member.role}
+ {onRemove && (
+
+ )}
))
) : (
@@ -42,17 +61,57 @@ const MemberList = ({
);
};
-const TeamDetails = () => {
+interface IProps {
+ session?: {
+ role?: string;
+ };
+}
+
+const TeamDetails = ({ session }: IProps) => {
const params = useParams();
+ const queryClient = useQueryClient();
+ const [selectedUserId, setSelectedUserId] = useState("");
+ const [selectedRole, setSelectedRole] = useState("viewer");
+ const [removingUserId, setRemovingUserId] = useState
();
+ const { data: users } = useQuery(["users", "manageable"], getManageableUsers);
+ const doAddMember = useMutation(addTeamMember, {
+ onSuccess: (updatedTeam: TeamDetailResponse) => {
+ queryClient.setQueryData(["teams", params.id], updatedTeam);
+ queryClient.invalidateQueries(["users"]);
+ queryClient.invalidateQueries(["users", "manageable"]);
+ setSelectedUserId("");
+ setSelectedRole("viewer");
+ },
+});
+
+const doRemoveMember = useMutation(removeTeamMember, {
+ onMutate: (variables) => {
+ setRemovingUserId(variables.userId);
+ },
+ onSuccess: (updatedTeam: TeamDetailResponse) => {
+ queryClient.setQueryData(["teams", params.id], updatedTeam);
+ queryClient.invalidateQueries(["users"]);
+ queryClient.invalidateQueries(["users", "manageable"]);
+ },
+ onSettled: () => {
+ setRemovingUserId(undefined);
+ },
+});
- const { data, isLoading } = useQuery(["teams", params.id], () => {
+const { data, isLoading } = useQuery(["teams", params.id], () => {
if (params.id) return getTeam(params.id);
return undefined;
});
- const members = data?.members || [];
+const members = data?.members || [];
+
+const existingMemberIds = new Set(members.map((member) => member._id));
+
+const availableUsers =
+ users?.filter((user) => !existingMemberIds.has(user._id)) || [];
const teamLeads = members.filter((member) => member.role === "team_lead");
+ const isTeamLead = session?.role === "team_lead";
const monitors = members.filter((member) => member.role === "monitor");
const viewers = members.filter((member) => member.role === "viewer");
const admins = members.filter((member) => member.role === "admin");
@@ -70,6 +129,11 @@ const TeamDetails = () => {
>
Back to Teams
+ {isTeamLead && (
+
+ Viewing team membership as a team lead.
+
+)}
@@ -89,11 +153,92 @@ const TeamDetails = () => {
+
+
Add team member
+
+
+
+
+
+
+
+
+
+
+ Adding a user as Team Lead changes their global role to team_lead.
+
+
-
-
-
+ {
+ if (!params.id) return;
+ doRemoveMember.mutate({ teamId: params.id, userId });
+ }}
+/>
+
+ {
+ if (!params.id) return;
+ doRemoveMember.mutate({ teamId: params.id, userId });
+ }}
+/>
+
+ {
+ if (!params.id) return;
+ doRemoveMember.mutate({ teamId: params.id, userId });
+ }}
+/>
{admins.length > 0 && (
diff --git a/src/pages/Settings/team/TeamsIndex.tsx b/src/pages/Settings/team/TeamsIndex.tsx
index d9faaa829..50e5f3cf3 100644
--- a/src/pages/Settings/team/TeamsIndex.tsx
+++ b/src/pages/Settings/team/TeamsIndex.tsx
@@ -2,14 +2,25 @@ import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { createTeam, deleteTeam, getTeams } from "../../../api/teams";
+
import { Link } from "react-router-dom";
import AggieButton from "../../../components/AggieButton";
import PlaceholderDiv from "../../../components/PlaceholderDiv";
-const TeamsIndex = () => {
+
+interface IProps {
+ session?: {
+ role?: string;
+ };
+}
+
+const TeamsIndex = ({ session }: IProps) => {
+
+ const isAdmin = session?.role === "admin";
+ const isTeamLead = session?.role === "team_lead";
const queryClient = useQueryClient();
- const { data: teams, isLoading } = useQuery(["teams"], getTeams);
+const { data: teams, isLoading } = useQuery(["teams", "all"], getTeams);
const [name, setName] = useState("");
const [description, setDescription] = useState("");