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) => { } /> } - {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/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/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/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"} /> + ); 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("");