diff --git a/apps/webapp/src/script/page/rightSidebar/addParticipants/addParticipants.tsx b/apps/webapp/src/script/page/rightSidebar/addParticipants/addParticipants.tsx index b0d26120c5f..686d3fa8080 100644 --- a/apps/webapp/src/script/page/rightSidebar/addParticipants/addParticipants.tsx +++ b/apps/webapp/src/script/page/rightSidebar/addParticipants/addParticipants.tsx @@ -17,7 +17,7 @@ * */ -import {FC, useCallback, useMemo, useState} from 'react'; +import {FC, useCallback, useEffect, useMemo, useState} from 'react'; import is from '@sindresorhus/is'; import {CONVERSATION_PROTOCOL} from '@wireapp/api-client/lib/team'; @@ -124,7 +124,27 @@ const AddParticipants: FC = ({ const [selectedContacts, setSelectedContacts] = useState([]); const [isInitialServiceSearch, setIsInitialServiceSearch] = useState(true); - const contacts = useMemo(() => { + const [collaborators, setCollaborators] = useState([]); + + useEffect(() => { + if (!isTeam) { + return undefined; + } + + let isCurrent = true; + + void teamRepository.getTeamCollaborators().then(fetchedCollaborators => { + if (isCurrent) { + setCollaborators(fetchedCollaborators); + } + }); + + return () => { + isCurrent = false; + }; + }, [isTeam, teamRepository]); + + const baseContacts = useMemo(() => { if (isTeam) { const isTeamOrServices = isTeamOnly || isServicesRoom; return isTeamOrServices ? teamMembers.toSorted(sortUsersByPriority) : teamUsers; @@ -132,6 +152,16 @@ const AddParticipants: FC = ({ return connectedUsers; }, [connectedUsers, isServicesRoom, isTeam, isTeamOnly, teamMembers, teamUsers]); + const contacts = useMemo(() => { + if (!collaborators.length) { + return baseContacts; + } + // Collaborators (team-level, without full team membership) are shown alongside team members/apps + const knownIds = new Set(baseContacts.map(contact => contact.id)); + const newCollaborators = collaborators.filter(collaborator => !knownIds.has(collaborator.id)); + return [...baseContacts, ...newCollaborators]; + }, [baseContacts, collaborators]); + const apps = useMemo(() => { const normalizedQuery = searchInput.trim().toLowerCase(); return contacts diff --git a/apps/webapp/src/script/repositories/team/TeamRepository.ts b/apps/webapp/src/script/repositories/team/TeamRepository.ts index 027a76a7106..1915e473ad1 100644 --- a/apps/webapp/src/script/repositories/team/TeamRepository.ts +++ b/apps/webapp/src/script/repositories/team/TeamRepository.ts @@ -388,6 +388,22 @@ export class TeamRepository extends TypedEventEmitter { return IntegrationMapper.mapServicesFromArray(servicesData, domain); } + async getTeamCollaborators(): Promise { + const teamId = this.teamState.team()?.id; + if (!teamId) { + return []; + } + + const domain = this.teamState.teamDomain(); + const selfId = this.userState.self().id; + const collaborators = await this.teamService.getCollaborators(teamId); + const collaboratorIds: QualifiedId[] = collaborators + .map(({user}) => ({domain, id: user})) + .filter(({id}) => id !== selfId); + + return this.userRepository.getUsersById(collaboratorIds); + } + readonly onTeamEvent = async (eventJson: any, source: EventSource): Promise => { if (this.teamState.isTeamDeleted()) { // We don't want to handle any events after the team has been deleted diff --git a/apps/webapp/src/script/repositories/team/TeamService.ts b/apps/webapp/src/script/repositories/team/TeamService.ts index 41447266bbc..1b6e403c3a9 100644 --- a/apps/webapp/src/script/repositories/team/TeamService.ts +++ b/apps/webapp/src/script/repositories/team/TeamService.ts @@ -24,7 +24,7 @@ import {TeamMigrationPayload} from '@wireapp/api-client/lib/team/invitation/team import type {LegalHoldMemberData} from '@wireapp/api-client/lib/team/legalhold/'; import type {MemberData, Members} from '@wireapp/api-client/lib/team/member/'; import type {Services} from '@wireapp/api-client/lib/team/service/'; -import type {TeamData} from '@wireapp/api-client/lib/team/team/'; +import type {TeamCollaborator, TeamData} from '@wireapp/api-client/lib/team/team/'; import {container} from 'tsyringe'; import {APIClient} from '../../service/apiClientSingleton'; @@ -64,6 +64,10 @@ export class TeamService { return this.apiClient.api.teams.service.getTeamServices(teamId); } + getCollaborators(teamId: string): Promise { + return this.apiClient.api.teams.team.getCollaborators(teamId); + } + async conversationHasGuestLink(conversationId: string): Promise { const {status} = await this.apiClient.api.conversation.getConversationGuestLinkFeature(conversationId); return status === 'enabled'; diff --git a/libraries/api-client/src/team/index.ts b/libraries/api-client/src/team/index.ts index a55c80e122a..8ab3df58eb4 100644 --- a/libraries/api-client/src/team/index.ts +++ b/libraries/api-client/src/team/index.ts @@ -20,7 +20,16 @@ export {NewTeamInvitation, TeamInvitation, TeamInvitationAPI, TeamInvitationChunk} from './invitation/'; export {LegalHoldAPI} from './legalhold/'; export {MemberAPI, MemberData, Members, Permissions, PermissionsData, Role} from './member/'; -export {NewTeamData, TeamAPI, TeamChunkData, TeamData, TeamInfo, UpdateTeamData} from './team/'; +export { + CollaboratorPermission, + NewTeamData, + TeamAPI, + TeamChunkData, + TeamCollaborator, + TeamData, + TeamInfo, + UpdateTeamData, +} from './team/'; export {PaymentAPI, PaymentData} from './payment/'; export {ServiceAPI, Service, ServiceWhitelistData} from './service/'; export {TeamError, InviteEmailInUseError, InvalidInvitationCodeError, ServiceNotFoundError} from './teamError'; diff --git a/libraries/api-client/src/team/team/collaborator/teamCollaborator.ts b/libraries/api-client/src/team/team/collaborator/teamCollaborator.ts new file mode 100644 index 00000000000..37a6832cd35 --- /dev/null +++ b/libraries/api-client/src/team/team/collaborator/teamCollaborator.ts @@ -0,0 +1,29 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +export enum CollaboratorPermission { + CREATE_TEAM_CONVERSATION = 'create_team_conversation', + IMPLICIT_CONNECTION = 'implicit_connection', +} + +export interface TeamCollaborator { + permissions: CollaboratorPermission[]; + team: string; + user: string; +} diff --git a/libraries/api-client/src/team/team/index.ts b/libraries/api-client/src/team/team/index.ts index 8194733223f..c6410a20f35 100644 --- a/libraries/api-client/src/team/team/index.ts +++ b/libraries/api-client/src/team/team/index.ts @@ -17,6 +17,7 @@ * */ +export * from './collaborator/teamCollaborator'; export * from './newTeamData'; export * from './teamApi'; export * from './teamChunkData'; diff --git a/libraries/api-client/src/team/team/teamApi.ts b/libraries/api-client/src/team/team/teamApi.ts index fcf8a52300d..8c172a238bf 100644 --- a/libraries/api-client/src/team/team/teamApi.ts +++ b/libraries/api-client/src/team/team/teamApi.ts @@ -21,6 +21,7 @@ import Axios, {AxiosRequestConfig} from 'axios'; import {NewAppRequest} from './app/newAppRequest'; import {NewAppResponse} from './app/newAppResponse'; +import {TeamCollaborator} from './collaborator/teamCollaborator'; import {LeadData} from './leadData'; import {TeamSizeData} from './teamSizeData'; import {UpdateTeamData} from './updateTeamData'; @@ -34,6 +35,7 @@ export class TeamAPI { public static readonly URL = { APPS: 'apps', + COLLABORATORS: 'collaborators', SIZE: 'size', TEAMS: '/teams', CONSENT: '/consent', @@ -143,6 +145,16 @@ export class TeamAPI { return response.data; } + public async getCollaborators(teamId: string): Promise { + const config: AxiosRequestConfig = { + method: 'get', + url: `${TeamAPI.URL.TEAMS}/${teamId}/${TeamAPI.URL.COLLABORATORS}`, + }; + + const response = await this.client.sendJSON(config); + return response.data; + } + public async postApp(teamId: string, app: NewAppRequest): Promise { const config: AxiosRequestConfig = { data: app,