chore: improve UX in team settings

This commit is contained in:
Dhruwang
2026-02-11 12:30:11 +05:30
parent 48eff5b547
commit 07abd0f2ec
26 changed files with 730 additions and 262 deletions

View File

@@ -1030,6 +1030,8 @@ checksums:
environments/settings/teams/manage_team_disabled: 2aaa0557b403a5bc657ec9e8b19ac5ac
environments/settings/teams/manager_role_description: 020b8674cc330536239dafe5db087de6
environments/settings/teams/member: 1606dc30b369856b9dba1fe9aec425d2
environments/settings/teams/member_added_to_team: 82bd83175a2434b1cbe87a6550b0a956
environments/settings/teams/member_in_all_teams: df051e8db2dcb142948e7fc44ded10c3
environments/settings/teams/member_role_description: 8c2447cb929850b41619988be0b9b1ac
environments/settings/teams/member_role_info_message: 511f3b954486f9c8a3b043e498f21c69
environments/settings/teams/organization_role: 979b75fcc3696952e5922d659c839c10
@@ -1038,8 +1040,6 @@ checksums:
environments/settings/teams/please_fill_all_workspace_fields: 190fc5d3c63cc5ec49d77f587e619ed8
environments/settings/teams/read: 2494ca23d10e5b6381eb271aceeb5270
environments/settings/teams/read_write: 278a90dade128198d4c93ac00c345320
environments/settings/teams/select_member: 7f4a38312aabbbe3fe92756b57bd5d75
environments/settings/teams/select_workspace: 0ad989c23616c6a04faf23d9e63ed3f3
environments/settings/teams/team_admin: 5df68214685738029af678ae1d5912bb
environments/settings/teams/team_created_successfully: 5b0cc007e18053508fdebc9545cc2c05
environments/settings/teams/team_deleted_successfully: d0729ad8d982cc5d542f89291bf57c50

View File

@@ -1097,6 +1097,8 @@
"manage_team_disabled": "Nur Organisationsbesitzer, Manager und Team-Admins können Teams verwalten.",
"manager_role_description": "Manager können auf alle Workspaces zugreifen und Mitglieder hinzufügen oder entfernen.",
"member": "Mitglied",
"member_added_to_team": "Mitglied erfolgreich zum Team hinzugefügt",
"member_in_all_teams": "Mitglied ist bereits in allen Teams.",
"member_role_description": "Mitglieder können in ausgewählten Workspaces arbeiten.",
"member_role_info_message": "Um neuen Mitgliedern Zugriff auf einen Workspace zu gewähren, fügen Sie sie bitte unten einem Team hinzu. Mit Teams können Sie verwalten, wer Zugriff auf welchen Workspace hat.",
"organization_role": "Organisationsrolle",
@@ -1105,8 +1107,6 @@
"please_fill_all_workspace_fields": "Bitte füllen Sie alle Felder aus, um einen neuen Workspace hinzuzufügen.",
"read": "Lesen",
"read_write": "Lesen & Schreiben",
"select_member": "Mitglied auswählen",
"select_workspace": "Workspace auswählen",
"team_admin": "Team-Admin",
"team_created_successfully": "Team erfolgreich erstellt.",
"team_deleted_successfully": "Team erfolgreich gelöscht.",

View File

@@ -1097,6 +1097,8 @@
"manage_team_disabled": "Only organization owners, managers and team admins can manage teams.",
"manager_role_description": "Managers can access all workspaces and add and remove members.",
"member": "Member",
"member_added_to_team": "Member added to team successfully",
"member_in_all_teams": "Member is already in all teams.",
"member_role_description": "Members can work in selected workspaces.",
"member_role_info_message": "To give new members access to a workspace, please add them to a Team below. With Teams you can manage who has access to which workspace.",
"organization_role": "Organization role",
@@ -1105,8 +1107,6 @@
"please_fill_all_workspace_fields": "Please fill all the fields to add a new workspace.",
"read": "Read",
"read_write": "Read & Write",
"select_member": "Select member",
"select_workspace": "Select workspace",
"team_admin": "Team Admin",
"team_created_successfully": "Team created successfully",
"team_deleted_successfully": "Team deleted successfully",
@@ -1156,7 +1156,6 @@
"add_fallback_placeholder": "Add a placeholder to show if there is no value to recall.",
"add_hidden_field_id": "Add hidden field ID",
"add_highlight_border": "Add highlight border",
"add_logic": "Add logic",
"add_none_of_the_above": "Add “None of the Above”",
"add_option": "Add option",
@@ -1230,11 +1229,8 @@
"change_background": "Change background",
"change_question_type": "Change question type",
"change_survey_type": "Switching survey type affects existing access",
"change_the_background_to_a_color_image_or_animation": "Change the background to a color, image or animation.",
"change_the_placement_of_this_survey": "Change the placement of this survey.",
"changes_saved": "Changes saved.",
"changing_survey_type_will_remove_existing_distribution_channels": "Changing the survey type will affect how it can be shared. If respondents already have access links for the current type, they may lose access after the switch.",
"checkbox_label": "Checkbox Label",
@@ -1374,7 +1370,6 @@
"hide_progress_bar": "Hide progress bar",
"hide_question_settings": "Hide Question settings",
"hostname": "Hostname",
"if_you_need_more_please": "If you need more, please",
"if_you_really_want_that_answer_ask_until_you_get_it": "Keep showing whenever triggered until a response is submitted.",
"ignore_global_waiting_time": "Ignore Cooldown Period",
@@ -1471,7 +1466,6 @@
"protect_survey_with_pin_description": "Only users who have the PIN can access the survey.",
"publish": "Publish",
"question": "Question",
"question_deleted": "Question deleted.",
"question_duplicated": "Question duplicated.",
"question_id_updated": "Question ID updated",
@@ -1575,7 +1569,6 @@
"styling_set_to_theme_styles": "Styling set to theme styles",
"subheading": "Subheading",
"subtract": "Subtract -",
"survey_completed_heading": "Survey Completed",
"survey_completed_subheading": "This free & open-source survey has been closed",
"survey_display_settings": "Survey Display Settings",
@@ -2059,9 +2052,7 @@
"look": {
"add_background_color": "Add background color",
"add_background_color_description": "Add a background color to the logo container.",
"advanced_styling_field_border_radius": "Border Radius",
"advanced_styling_field_button_bg": "Button Background",
"advanced_styling_field_button_bg_description": "Fills the Next / Submit button.",
"advanced_styling_field_button_border_radius_description": "Rounds the button corners.",
@@ -2078,7 +2069,6 @@
"advanced_styling_field_description_size_description": "Scales the description text.",
"advanced_styling_field_description_weight": "Description Font Weight",
"advanced_styling_field_description_weight_description": "Makes description text lighter or bolder.",
"advanced_styling_field_font_size": "Font Size",
"advanced_styling_field_font_weight": "Font Weight",
"advanced_styling_field_headline_color": "Headline Color",
@@ -2090,7 +2080,6 @@
"advanced_styling_field_height": "Height",
"advanced_styling_field_indicator_bg": "Indicator Background",
"advanced_styling_field_indicator_bg_description": "Colors the filled portion of the bar.",
"advanced_styling_field_input_border_radius_description": "Rounds the input corners.",
"advanced_styling_field_input_font_size_description": "Scales the typed text in inputs.",
"advanced_styling_field_input_height_description": "Controls the input field height.",
@@ -2116,7 +2105,6 @@
"advanced_styling_field_track_bg_description": "Colors the unfilled portion of the bar.",
"advanced_styling_field_track_height": "Track Height",
"advanced_styling_field_track_height_description": "Controls the progress bar thickness.",
"advanced_styling_field_upper_label_color": "Headline Label Color",
"advanced_styling_field_upper_label_color_description": "Colors the small label above inputs.",
"advanced_styling_field_upper_label_size": "Headline Label Font Size",
@@ -2127,7 +2115,6 @@
"advanced_styling_section_headlines": "Headlines & Descriptions",
"advanced_styling_section_inputs": "Inputs",
"advanced_styling_section_options": "Options (Radio/Checkbox)",
"app_survey_placement": "App Survey Placement",
"app_survey_placement_settings_description": "Change where surveys will be shown in your web app or website.",
"email_customization": "Email Customization",

View File

@@ -1097,6 +1097,8 @@
"manage_team_disabled": "Solo los propietarios de la organización, gestores y administradores de equipo pueden gestionar equipos.",
"manager_role_description": "Los gestores pueden acceder a todos los proyectos y añadir y eliminar miembros.",
"member": "Miembro",
"member_added_to_team": "Miembro añadido al equipo correctamente",
"member_in_all_teams": "El miembro ya está en todos los equipos.",
"member_role_description": "Los miembros pueden trabajar en proyectos seleccionados.",
"member_role_info_message": "Para dar a los nuevos miembros acceso a un proyecto, por favor añádelos a un equipo a continuación. Con los equipos puedes gestionar quién tiene acceso a qué proyecto.",
"organization_role": "Rol en la organización",
@@ -1105,8 +1107,6 @@
"please_fill_all_workspace_fields": "Por favor, rellena todos los campos para añadir un proyecto nuevo.",
"read": "Lectura",
"read_write": "Lectura y escritura",
"select_member": "Seleccionar miembro",
"select_workspace": "Seleccionar proyecto",
"team_admin": "Administrador de equipo",
"team_created_successfully": "Equipo creado con éxito.",
"team_deleted_successfully": "Equipo eliminado correctamente.",

View File

@@ -1097,6 +1097,8 @@
"manage_team_disabled": "Seuls les propriétaires de l'organisation, les gestionnaires et les administrateurs d'équipe peuvent gérer les équipes.",
"manager_role_description": "Les gestionnaires peuvent accéder à tous les espaces de travail et ajouter ou supprimer des membres.",
"member": "Membre",
"member_added_to_team": "Membre ajouté à l'équipe avec succès",
"member_in_all_teams": "Le membre est déjà dans toutes les équipes.",
"member_role_description": "Les membres peuvent travailler dans les espaces de travail sélectionnés.",
"member_role_info_message": "Pour donner aux nouveaux membres l'accès à un espace de travail, veuillez les ajouter à une équipe ci-dessous. Avec les équipes, vous pouvez gérer qui a accès à quel espace de travail.",
"organization_role": "Rôle dans l'organisation",
@@ -1105,8 +1107,6 @@
"please_fill_all_workspace_fields": "Veuillez remplir tous les champs pour ajouter un nouvel espace de travail.",
"read": "Lire",
"read_write": "Lire et Écrire",
"select_member": "Sélectionner membre",
"select_workspace": "Sélectionner un espace de travail",
"team_admin": "Administrateur d'équipe",
"team_created_successfully": "Équipe créée avec succès.",
"team_deleted_successfully": "Équipe supprimée avec succès.",

View File

@@ -1097,6 +1097,8 @@
"manage_team_disabled": "Csak a szervezet tulajdonosai, kezelői és csapatadminisztrátorai kezelhetnek csapatokat.",
"manager_role_description": "A kezelők hozzáférhetnek az összes munkaterülethez, valamint tagokat adhatnak hozzá és távolíthatnak el.",
"member": "Tag",
"member_added_to_team": "Tag sikeresen hozzáadva a csapathoz",
"member_in_all_teams": "A tag már minden csapatban benne van.",
"member_role_description": "A tagok a kiválasztott munkaterületeken dolgozhatnak.",
"member_role_info_message": "Ahhoz, hogy az új tagoknak hozzáférést adjon egy munkaterülethez, adja hozzá őket lent egy csapathoz. A csapatokkal kezelheti azt, hogy kinek melyik munkaterülethez van hozzáférése.",
"organization_role": "Szervezeti szerep",
@@ -1105,8 +1107,6 @@
"please_fill_all_workspace_fields": "Töltse ki az összes mezőt egy új munkaterület hozzáadásához.",
"read": "Olvasás",
"read_write": "Olvasás és írás",
"select_member": "Tag kiválasztása",
"select_workspace": "Munkaterület kiválasztása",
"team_admin": "Csapatadminisztrátor",
"team_created_successfully": "A csapat sikeresen létrehozva",
"team_deleted_successfully": "A csapat sikeresen törölve",

View File

@@ -1097,6 +1097,8 @@
"manage_team_disabled": "組織のオーナー、管理者、チーム管理者のみがチームを管理できます。",
"manager_role_description": "マネージャーはすべてのワークスペースにアクセスでき、メンバーの追加と削除が可能です。",
"member": "メンバー",
"member_added_to_team": "メンバーがチームに正常に追加されました",
"member_in_all_teams": "メンバーはすでにすべてのチームに参加しています。",
"member_role_description": "メンバーは選択されたワークスペースで作業できます。",
"member_role_info_message": "新しいメンバーにワークスペースへのアクセス権を付与するには、以下のチームに追加してください。チームを使用すると、誰がどのワークスペースにアクセスできるかを管理できます。",
"organization_role": "組織の役割",
@@ -1105,8 +1107,6 @@
"please_fill_all_workspace_fields": "新しいワークスペースを追加するには、すべてのフィールドを入力してください。",
"read": "読み取り",
"read_write": "読み書き",
"select_member": "メンバーを選択",
"select_workspace": "ワークスペースを選択",
"team_admin": "チーム管理者",
"team_created_successfully": "チームを正常に作成しました。",
"team_deleted_successfully": "チームを正常に削除しました。",

View File

@@ -1097,6 +1097,8 @@
"manage_team_disabled": "Alleen organisatie-eigenaren, managers en teambeheerders kunnen teams beheren.",
"manager_role_description": "Managers hebben toegang tot alle projecten en kunnen leden toevoegen en verwijderen.",
"member": "Lid",
"member_added_to_team": "Lid succesvol toegevoegd aan team",
"member_in_all_teams": "Lid zit al in alle teams.",
"member_role_description": "Leden kunnen in geselecteerde projecten werken.",
"member_role_info_message": "Om nieuwe leden toegang te geven tot een project, voegt u ze hieronder toe aan een team. Met Teams kun je beheren wie toegang heeft tot welk project.",
"organization_role": "Organisatierol",
@@ -1105,8 +1107,6 @@
"please_fill_all_workspace_fields": "Vul alle velden in om een nieuwe werkruimte toe te voegen.",
"read": "Lezen",
"read_write": "Lezen en schrijven",
"select_member": "Selecteer lid",
"select_workspace": "Selecteer werkruimte",
"team_admin": "Teambeheerder",
"team_created_successfully": "Team succesvol aangemaakt.",
"team_deleted_successfully": "Team succesvol verwijderd.",

View File

@@ -1097,6 +1097,8 @@
"manage_team_disabled": "Apenas proprietários da organização, gerentes e administradores da equipe podem gerenciar equipes.",
"manager_role_description": "Gerentes podem acessar todos os espaços de trabalho e adicionar e remover membros.",
"member": "Membro",
"member_added_to_team": "Membro adicionado à equipe com sucesso",
"member_in_all_teams": "O membro já está em todas as equipes.",
"member_role_description": "Membros podem trabalhar em espaços de trabalho selecionados.",
"member_role_info_message": "Para dar acesso aos novos membros a um espaço de trabalho, adicione-os a uma equipe abaixo. Com as equipes você pode gerenciar quem tem acesso a qual espaço de trabalho.",
"organization_role": "Função na organização",
@@ -1105,8 +1107,6 @@
"please_fill_all_workspace_fields": "Preencha todos os campos para adicionar um novo espaço de trabalho.",
"read": "Leitura",
"read_write": "Leitura & Escrita",
"select_member": "Selecionar membro",
"select_workspace": "Selecionar espaço de trabalho",
"team_admin": "Administrador da equipe",
"team_created_successfully": "Equipe criada com sucesso.",
"team_deleted_successfully": "Equipe excluída com sucesso.",

View File

@@ -1097,6 +1097,8 @@
"manage_team_disabled": "Apenas os proprietários da organização, gestores e administradores de equipa podem gerir equipas.",
"manager_role_description": "Os gestores podem aceder a todos os espaços de trabalho e adicionar e remover membros.",
"member": "Membro",
"member_added_to_team": "Membro adicionado à equipa com sucesso",
"member_in_all_teams": "O membro já está em todas as equipas.",
"member_role_description": "Os membros podem trabalhar em espaços de trabalho selecionados.",
"member_role_info_message": "Para dar acesso aos novos membros a um espaço de trabalho, adicione-os a uma equipa abaixo. Com as equipas pode gerir quem tem acesso a cada espaço de trabalho.",
"organization_role": "Função na organização",
@@ -1105,8 +1107,6 @@
"please_fill_all_workspace_fields": "Preencha todos os campos para adicionar um novo espaço de trabalho.",
"read": "Ler",
"read_write": "Ler e Escrever",
"select_member": "Selecionar membro",
"select_workspace": "Selecionar espaço de trabalho",
"team_admin": "Administrador da Equipa",
"team_created_successfully": "Equipa criada com sucesso.",
"team_deleted_successfully": "Equipa eliminada com sucesso.",

View File

@@ -1097,6 +1097,8 @@
"manage_team_disabled": "Doar proprietarii de organizații, managerii și administratorii de echipă pot gestiona echipele.",
"manager_role_description": "Managerii pot accesa toate spațiile de lucru și pot adăuga sau elimina membri.",
"member": "Membru",
"member_added_to_team": "Membrul a fost adăugat cu succes în echipă",
"member_in_all_teams": "Membrul este deja în toate echipele.",
"member_role_description": "Membrii pot lucra în spațiile de lucru selectate.",
"member_role_info_message": "Pentru a oferi noilor membri acces la un spațiu de lucru, adăugați-i mai jos într-o echipă. Cu ajutorul echipelor puteți gestiona cine are acces la fiecare spațiu de lucru.",
"organization_role": "Rol în organizație",
@@ -1105,8 +1107,6 @@
"please_fill_all_workspace_fields": "Vă rugăm să completați toate câmpurile pentru a adăuga un nou spațiu de lucru.",
"read": "Citește",
"read_write": "Citire & Scriere",
"select_member": "Selectează membrul",
"select_workspace": "Selectați spațiul de lucru",
"team_admin": "Administrator Echipe",
"team_created_successfully": "Echipă creată cu succes",
"team_deleted_successfully": "Echipă ștearsă cu succes.",

View File

@@ -1097,6 +1097,8 @@
"manage_team_disabled": "Только владельцы организации, менеджеры и администраторы команд могут управлять командами.",
"manager_role_description": "Менеджеры имеют доступ ко всем проектам, а также могут добавлять и удалять участников.",
"member": "Участник",
"member_added_to_team": "Участник успешно добавлен в команду",
"member_in_all_teams": "Участник уже состоит во всех командах.",
"member_role_description": "Участники могут работать в выбранных проектах.",
"member_role_info_message": "Чтобы предоставить новым участникам доступ к проекту, добавьте их в команду ниже. С помощью команд вы можете управлять доступом к проектам.",
"organization_role": "Роль в организации",
@@ -1105,8 +1107,6 @@
"please_fill_all_workspace_fields": "Пожалуйста, заполните все поля для добавления нового рабочего пространства.",
"read": "Чтение",
"read_write": "Чтение и запись",
"select_member": "Выберите участника",
"select_workspace": "Выберите рабочее пространство",
"team_admin": "Администратор команды",
"team_created_successfully": "Команда успешно создана.",
"team_deleted_successfully": "Команда успешно удалена.",

View File

@@ -1097,6 +1097,8 @@
"manage_team_disabled": "Endast organisationsägare, administratörer och teamadministratörer kan hantera team.",
"manager_role_description": "Administratörer kan komma åt alla projekt och lägga till och ta bort medlemmar.",
"member": "Medlem",
"member_added_to_team": "Medlem har lagts till i teamet",
"member_in_all_teams": "Medlemmen är redan med i alla team.",
"member_role_description": "Medlemmar kan arbeta i valda projekt.",
"member_role_info_message": "För att ge nya medlemmar åtkomst till ett projekt, vänligen lägg till dem i ett team nedan. Med team kan du hantera vem som har åtkomst till vilket projekt.",
"organization_role": "Organisationsroll",
@@ -1105,8 +1107,6 @@
"please_fill_all_workspace_fields": "Fyll i alla fält för att lägga till en ny arbetsyta.",
"read": "Läs",
"read_write": "Läs och skriv",
"select_member": "Välj medlem",
"select_workspace": "Välj arbetsyta",
"team_admin": "Teamadministratör",
"team_created_successfully": "Team skapat.",
"team_deleted_successfully": "Team borttaget.",

View File

@@ -1097,6 +1097,8 @@
"manage_team_disabled": "只有 组织 拥有者、经理 和 团队 管理员 可以 管理 团队。",
"manager_role_description": "经理 可以 访问 所有 项目 并 添加 移除 成员。",
"member": "成员",
"member_added_to_team": "成员已成功加入团队",
"member_in_all_teams": "该成员已在所有团队中。",
"member_role_description": "成员 可以 在 选定 项目 中 工作。",
"member_role_info_message": "要 给 新 成员 访问 项目 ,请 将 他们 添加 到 下方 的 团队 。通过 团队 你 可以 管理 谁 可以 访问 哪个 项目 。",
"organization_role": "组织角色",
@@ -1105,8 +1107,6 @@
"please_fill_all_workspace_fields": "请填写所有字段以添加新工作区。",
"read": "阅读",
"read_write": "读 & 写",
"select_member": "选择成员",
"select_workspace": "选择工作区",
"team_admin": "团队管理员",
"team_created_successfully": "团队 创建 成功",
"team_deleted_successfully": "团队 删除 成功",

View File

@@ -1097,6 +1097,8 @@
"manage_team_disabled": "只有組織擁有者、管理員和團隊管理員才能管理團隊。",
"manager_role_description": "管理員可以存取所有專案,並新增和移除成員。",
"member": "成員",
"member_added_to_team": "已成功將成員加入團隊",
"member_in_all_teams": "該成員已在所有團隊中。",
"member_role_description": "成員可以在選定的專案中工作。",
"member_role_info_message": "若要授予新成員存取專案的權限,請將他們新增至下方的團隊。藉由團隊,您可以管理誰可以存取哪些專案。",
"organization_role": "組織角色",
@@ -1105,8 +1107,6 @@
"please_fill_all_workspace_fields": "請填寫所有欄位以新增工作區。",
"read": "讀取",
"read_write": "讀取和寫入",
"select_member": "選擇成員",
"select_workspace": "選擇工作區",
"team_admin": "團隊管理員",
"team_created_successfully": "團隊已成功建立。",
"team_deleted_successfully": "團隊已成功刪除。",

View File

@@ -2,6 +2,8 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { InvalidInputError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
@@ -161,3 +163,88 @@ export const getTeamRoleAction = authenticatedActionClient
.action(async ({ ctx, parsedInput }) => {
return await getTeamRoleByTeamIdUserId(parsedInput.teamId, ctx.user.id);
});
const ZAddMemberToTeamAction = z.object({
teamId: ZId,
userId: ZId,
role: z.enum(["admin", "contributor"]).default("contributor"),
});
export const addMemberToTeamAction = authenticatedActionClient.schema(ZAddMemberToTeamAction).action(
withAuditLogging(
"updated",
"team",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZAddMemberToTeamAction>;
}) => {
const organizationId = await getOrganizationIdFromTeamId(parsedInput.teamId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "team",
teamId: parsedInput.teamId,
minPermission: "admin",
},
],
});
await checkRoleManagementPermission(organizationId);
const teamDetails = await getTeamDetails(parsedInput.teamId);
if (!teamDetails) {
throw new ResourceNotFoundError("Team", parsedInput.teamId);
}
if (teamDetails.projects.length === 0) {
throw new InvalidInputError("Please add at least one workspace to the team " + teamDetails.name);
}
const isAlreadyMember = teamDetails.members.some((m) => m.userId === parsedInput.userId);
if (isAlreadyMember) {
return { data: true };
}
// Owners and managers are always team admins, never contributors
const userMembership = await getMembershipByUserIdOrganizationId(parsedInput.userId, organizationId);
const isOwnerOrManager = userMembership?.role === "owner" || userMembership?.role === "manager";
const teamRole = isOwnerOrManager ? "admin" : parsedInput.role;
const updatedMembers = [
...teamDetails.members.map((m) => ({ userId: m.userId, role: m.role })),
{ userId: parsedInput.userId, role: teamRole },
];
const updatedProjects = teamDetails.projects.map((p) => ({
projectId: p.projectId,
permission: p.permission,
}));
const result = await updateTeamDetails(parsedInput.teamId, {
name: teamDetails.name,
members: updatedMembers,
projects: updatedProjects,
});
if (!result) {
throw new UnknownError("Failed to add member to team");
}
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.teamId = parsedInput.teamId;
ctx.auditLoggingCtx.oldObject = teamDetails;
ctx.auditLoggingCtx.newObject = await getTeamDetails(parsedInput.teamId);
return { data: true };
}
)
);

View File

@@ -0,0 +1,137 @@
"use client";
import { XIcon } from "lucide-react";
import { Control } from "react-hook-form";
import { useTranslation } from "react-i18next";
import {
TOrganizationMember,
TTeamRole,
TTeamSettingsFormSchema,
ZTeamRole,
} from "@/modules/ee/teams/team-list/types/team";
import { Button } from "@/modules/ui/components/button";
import { FormError, FormField, FormItem } from "@/modules/ui/components/form";
import { InputCombobox } from "@/modules/ui/components/input-combo-box";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
export interface MemberRowProps {
index: number;
member: { userId: string; role: TTeamRole };
memberOpts: { value: string; label: string }[];
control: Control<TTeamSettingsFormSchema>;
orgMembers: TOrganizationMember[];
watchMembers: { userId: string; role: TTeamRole }[];
initialMemberIds: Set<string>;
isOwnerOrManager: boolean;
isTeamAdminMember: boolean;
isTeamContributorMember: boolean;
currentUserId: string;
onMemberSelectionChange: (index: number, userId: string) => void;
onRemoveMember: (index: number) => void;
memberCount: number;
}
export function MemberRow(props: Readonly<MemberRowProps>) {
const {
index,
member,
memberOpts,
control,
orgMembers,
watchMembers,
initialMemberIds,
isOwnerOrManager,
isTeamAdminMember,
isTeamContributorMember,
currentUserId,
onMemberSelectionChange,
onRemoveMember,
memberCount,
} = props;
const { t } = useTranslation();
const chosenMember = orgMembers.find((m) => m.id === watchMembers[index]?.userId);
const canEditWhenNoMember = isOwnerOrManager || isTeamAdminMember;
const isRoleSelectDisabled =
chosenMember === undefined
? !canEditWhenNoMember
: chosenMember.role === "owner" ||
chosenMember.role === "manager" ||
isTeamContributorMember ||
chosenMember.id === currentUserId;
return (
<div className="flex gap-2.5">
<FormField
control={control}
name={`members.${index}.userId`}
render={({ field, fieldState: { error } }) => {
const isExistingMember = member.userId && initialMemberIds.has(member.userId);
const isSelectDisabled = isExistingMember || (!isOwnerOrManager && !isTeamAdminMember);
return (
<FormItem className="flex-1">
<div className={isSelectDisabled ? "pointer-events-none opacity-50" : undefined}>
<InputCombobox
id={`member-select-${index}`}
options={memberOpts}
value={field.value || null}
onChangeValue={(val) => {
const value = typeof val === "string" ? val : "";
field.onChange(value);
onMemberSelectionChange(index, value);
}}
showSearch
searchPlaceholder={t("common.search")}
comboboxClasses="flex-1 min-w-0 w-full"
emptyDropdownText={t("environments.surveys.edit.no_option_found")}
/>
</div>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</FormItem>
);
}}
/>
<FormField
control={control}
name={`members.${index}.role`}
render={({ field }) => {
const roleOptions = [
{ value: ZTeamRole.enum.admin, label: t("environments.settings.teams.team_admin") },
{
value: ZTeamRole.enum.contributor,
label: t("environments.settings.teams.contributor"),
},
];
return (
<FormItem className="flex-1">
<div className={isRoleSelectDisabled ? "pointer-events-none opacity-50" : undefined}>
<InputCombobox
id={`member-role-select-${index}`}
options={roleOptions}
value={field.value}
onChangeValue={(val) => field.onChange(val)}
showSearch={false}
comboboxClasses="flex-1 min-w-0 w-full"
/>
</div>
</FormItem>
);
}}
/>
{memberCount > 1 && (
<TooltipRenderer tooltipContent={t("common.remove_from_team")}>
<Button
size="icon"
type="button"
variant="destructive"
className="shrink-0"
disabled={!isOwnerOrManager && (!isTeamAdminMember || member.userId === currentUserId)}
onClick={() => onRemoveMember(index)}>
<XIcon className="h-4 w-4" />
</Button>
</TooltipRenderer>
)}
</div>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { PlusIcon, Trash2Icon, XIcon } from "lucide-react";
import { PlusIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useMemo } from "react";
import { FormProvider, SubmitHandler, useForm, useWatch } from "react-hook-form";
@@ -13,6 +13,8 @@ import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { ZTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import { updateTeamDetailsAction } from "@/modules/ee/teams/team-list/actions";
import { DeleteTeam } from "@/modules/ee/teams/team-list/components/team-settings/delete-team";
import { MemberRow } from "@/modules/ee/teams/team-list/components/team-settings/member-row";
import { WorkspaceRow } from "@/modules/ee/teams/team-list/components/team-settings/workspace-row";
import { TOrganizationProject } from "@/modules/ee/teams/team-list/types/project";
import {
TOrganizationMember,
@@ -36,13 +38,6 @@ import {
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { Input } from "@/modules/ui/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { Muted } from "@/modules/ui/components/typography";
@@ -187,14 +182,16 @@ export const TeamSettingsModal = ({
const currentMemberId = watchMembers[index]?.userId;
return orgMembers
.filter((om) => !selectedMemberIds.includes(om?.id) || om?.id === currentMemberId)
.map((om) => ({ label: om?.name, value: om?.id }));
.map((om) => ({ label: om?.name ?? "", value: om?.id }))
.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: "base" }));
};
const getProjectOptionsForIndex = (index: number) => {
const currentProjectId = watchProjects[index]?.projectId;
return orgProjects
.filter((op) => !selectedProjectIds.includes(op?.id) || op?.id === currentProjectId)
.map((op) => ({ label: op?.name, value: op?.id }));
.map((op) => ({ label: op?.name ?? "", value: op?.id }))
.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: "base" }));
};
const handleMemberSelectionChange = (index: number, userId: string) => {
@@ -262,110 +259,25 @@ export const TeamSettingsModal = ({
render={({ fieldState: { error } }) => (
<FormItem className="flex-1">
<div className="space-y-2 overflow-y-auto">
{watchMembers.map((member, index) => {
const memberOpts = getMemberOptionsForIndex(index);
return (
<div key={`member-${member.userId}-${index}`} className="flex gap-2.5">
<FormField
control={control}
name={`members.${index}.userId`}
render={({ field, fieldState: { error } }) => {
// Disable user select for existing members (can only remove or change role)
const isExistingMember =
member.userId && initialMemberIds.has(member.userId);
const isSelectDisabled =
isExistingMember || (!isOwnerOrManager && !isTeamAdminMember);
return (
<FormItem className="flex-1">
<Select
onValueChange={(val) => {
field.onChange(val);
handleMemberSelectionChange(index, val);
}}
disabled={isSelectDisabled}
value={member.userId}>
<SelectTrigger>
<SelectValue
placeholder={t("environments.settings.teams.select_member")}
/>
</SelectTrigger>
<SelectContent>
{memberOpts.map((option) => (
<SelectItem
key={option.value}
value={option.value}
id={`member-${index}-option`}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{error?.message && (
<FormError className="text-left">{error.message}</FormError>
)}
</FormItem>
);
}}
/>
<FormField
control={control}
name={`members.${index}.role`}
render={({ field }) => (
<FormItem className="flex-1">
<Select
onValueChange={field.onChange}
value={member.role}
disabled={(() => {
const chosenMember = orgMembers.find(
(m) => m.id === watchMembers[index]?.userId
);
if (!chosenMember) return !isOwnerOrManager && !isTeamAdminMember;
return (
chosenMember.role === "owner" ||
chosenMember.role === "manager" ||
isTeamContributorMember ||
chosenMember.id === currentUserId
);
})()}>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ZTeamRole.enum.admin}>
{t("environments.settings.teams.team_admin")}
</SelectItem>
<SelectItem value={ZTeamRole.enum.contributor}>
{t("environments.settings.teams.contributor")}
</SelectItem>
</SelectContent>
</Select>
</FormItem>
)}
/>
{/* Delete Button for Member */}
{watchMembers.length > 1 && (
<TooltipRenderer tooltipContent={t("common.remove_from_team")}>
<Button
size="icon"
type="button"
variant="destructive"
className="shrink-0"
disabled={
!isOwnerOrManager &&
(!isTeamAdminMember || member.userId === currentUserId)
}
onClick={() => handleRemoveMember(index)}>
<XIcon className="h-4 w-4" />
</Button>
</TooltipRenderer>
)}
</div>
);
})}
{watchMembers.map((member, index) => (
<MemberRow
key={`member-${member.userId}-${index}`}
index={index}
member={member}
memberOpts={getMemberOptionsForIndex(index)}
control={control}
orgMembers={orgMembers}
watchMembers={watchMembers}
initialMemberIds={initialMemberIds}
isOwnerOrManager={isOwnerOrManager}
isTeamAdminMember={isTeamAdminMember}
isTeamContributorMember={isTeamContributorMember}
currentUserId={currentUserId}
onMemberSelectionChange={handleMemberSelectionChange}
onRemoveMember={handleRemoveMember}
memberCount={watchMembers.length}
/>
))}
</div>
{error?.root?.message && (
<FormError className="text-left">{error.root.message}</FormError>
@@ -411,90 +323,19 @@ export const TeamSettingsModal = ({
render={({ fieldState: { error } }) => (
<FormItem className="flex-1">
<div className="space-y-2">
{watchProjects.map((project, index) => {
const projectOpts = getProjectOptionsForIndex(index);
return (
<div key={`project-${project.projectId}-${index}`} className="flex gap-2.5">
<FormField
control={control}
name={`projects.${index}.projectId`}
render={({ field, fieldState: { error } }) => {
// Disable project select for existing projects (can only remove or change permission)
const isExistingProject =
project.projectId && initialProjectIds.has(project.projectId);
const isSelectDisabled = isExistingProject || !isOwnerOrManager;
return (
<FormItem className="flex-1">
<Select
onValueChange={field.onChange}
value={project.projectId}
disabled={isSelectDisabled}>
<SelectTrigger>
<SelectValue
placeholder={t("environments.settings.teams.select_workspace")}
/>
</SelectTrigger>
<SelectContent>
{projectOpts.map((option) => (
<SelectItem
key={option.value}
value={option.value}
id={`project-${index}-option`}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{error?.message && (
<FormError className="text-left">{error.message}</FormError>
)}
</FormItem>
);
}}
/>
<FormField
control={control}
name={`projects.${index}.permission`}
render={({ field }) => (
<FormItem className="flex-1">
<Select
onValueChange={field.onChange}
value={project.permission}
disabled={!isOwnerOrManager}>
<SelectTrigger>
<SelectValue placeholder="Select project role" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ZTeamPermission.enum.read}>
{t("environments.settings.teams.read")}
</SelectItem>
<SelectItem value={ZTeamPermission.enum.readWrite}>
{t("environments.settings.teams.read_write")}
</SelectItem>
<SelectItem value={ZTeamPermission.enum.manage}>
{t("environments.settings.teams.manage")}
</SelectItem>
</SelectContent>
</Select>
</FormItem>
)}
/>
{watchProjects.length > 1 && (
<Button
size="icon"
type="button"
variant="secondary"
className="shrink-0"
disabled={!isOwnerOrManager}
onClick={() => handleRemoveProject(index)}>
<Trash2Icon className="h-4 w-4" />
</Button>
)}
</div>
);
})}
{watchProjects.map((project, index) => (
<WorkspaceRow
key={`workspace-${project.projectId}-${index}`}
index={index}
project={project}
projectOpts={getProjectOptionsForIndex(index)}
control={control}
initialProjectIds={initialProjectIds}
isOwnerOrManager={isOwnerOrManager}
onRemoveProject={handleRemoveProject}
projectCount={watchProjects.length}
/>
))}
</div>
{error?.root?.message && (
<FormError className="text-left">{error.root.message}</FormError>

View File

@@ -0,0 +1,115 @@
"use client";
import { Trash2Icon } from "lucide-react";
import { Control } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { ZTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import { TTeamSettingsFormSchema } from "@/modules/ee/teams/team-list/types/team";
import { Button } from "@/modules/ui/components/button";
import { FormError, FormField, FormItem } from "@/modules/ui/components/form";
import { InputCombobox } from "@/modules/ui/components/input-combo-box";
export interface WorkspaceRowProps {
index: number;
project: { projectId: string; permission: string };
projectOpts: { value: string; label: string }[];
control: Control<TTeamSettingsFormSchema>;
initialProjectIds: Set<string>;
isOwnerOrManager: boolean;
onRemoveProject: (index: number) => void;
projectCount: number;
}
export function WorkspaceRow(props: Readonly<WorkspaceRowProps>) {
const {
index,
project,
projectOpts,
control,
initialProjectIds,
isOwnerOrManager,
onRemoveProject,
projectCount,
} = props;
const { t } = useTranslation();
return (
<div className="flex gap-2.5">
<FormField
control={control}
name={`projects.${index}.projectId`}
render={({ field, fieldState: { error } }) => {
const isExistingProject = project.projectId && initialProjectIds.has(project.projectId);
const isSelectDisabled = isExistingProject || !isOwnerOrManager;
return (
<FormItem className="flex-1">
<div className={isSelectDisabled ? "pointer-events-none opacity-50" : undefined}>
<InputCombobox
id={`project-select-${index}`}
options={projectOpts}
value={field.value || null}
onChangeValue={(val) => {
const value = typeof val === "string" ? val : "";
field.onChange(value);
}}
showSearch
searchPlaceholder={t("common.search")}
comboboxClasses="flex-1 min-w-0 w-full"
emptyDropdownText={t("environments.surveys.edit.no_option_found")}
/>
</div>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</FormItem>
);
}}
/>
<FormField
control={control}
name={`projects.${index}.permission`}
render={({ field }) => {
const permissionOptions = [
{
value: ZTeamPermission.enum.read,
label: t("environments.settings.teams.read"),
},
{
value: ZTeamPermission.enum.readWrite,
label: t("environments.settings.teams.read_write"),
},
{
value: ZTeamPermission.enum.manage,
label: t("environments.settings.teams.manage"),
},
];
return (
<FormItem className="flex-1">
<div className={isOwnerOrManager ? undefined : "pointer-events-none opacity-50"}>
<InputCombobox
id={`project-permission-select-${index}`}
options={permissionOptions}
value={field.value}
onChangeValue={(val) => field.onChange(val)}
showSearch={false}
comboboxClasses="flex-1 min-w-0 w-full"
/>
</div>
</FormItem>
);
}}
/>
{projectCount > 1 && (
<Button
size="icon"
type="button"
variant="secondary"
className="shrink-0"
disabled={!isOwnerOrManager}
onClick={() => onRemoveProject(index)}>
<Trash2Icon className="h-4 w-4" />
</Button>
)}
</div>
);
}

View File

@@ -8,6 +8,7 @@ import {
deleteTeam,
getOtherTeams,
getTeamDetails,
getTeamIdsByUserIds,
getTeams,
getTeamsByOrganizationId,
getUserTeams,
@@ -24,6 +25,9 @@ vi.mock("@formbricks/database", () => ({
update: vi.fn(),
delete: vi.fn(),
},
teamUser: {
findMany: vi.fn(),
},
membership: { findUnique: vi.fn(), count: vi.fn() },
project: { count: vi.fn() },
environment: { findMany: vi.fn() },
@@ -81,6 +85,40 @@ describe("getTeamsByOrganizationId", () => {
});
});
describe("getTeamIdsByUserIds", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns mapped team IDs by user ID", async () => {
vi.mocked(prisma.teamUser.findMany).mockResolvedValueOnce([
{ userId: "u1", teamId: "t1" },
{ userId: "u1", teamId: "t2" },
{ userId: "u2", teamId: "t1" },
]);
const result = await getTeamIdsByUserIds(["u1", "u2"], "org1");
expect(result).toEqual({
u1: ["t1", "t2"],
u2: ["t1"],
});
});
test("returns empty object when userIds is empty", async () => {
const result = await getTeamIdsByUserIds([], "org1");
expect(result).toEqual({});
expect(prisma.teamUser.findMany).not.toHaveBeenCalled();
});
test("returns empty object when no team memberships exist", async () => {
vi.mocked(prisma.teamUser.findMany).mockResolvedValueOnce([]);
const result = await getTeamIdsByUserIds(["u1"], "org1");
expect(result).toEqual({});
});
test("throws DatabaseError on Prisma error", async () => {
vi.mocked(prisma.teamUser.findMany).mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
);
await expect(getTeamIdsByUserIds(["u1"], "org1")).rejects.toThrow(DatabaseError);
});
});
describe("getUserTeams", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -95,6 +95,34 @@ export const getUserTeams = reactCache(
}
);
export const getTeamIdsByUserIds = reactCache(
async (userIds: string[], organizationId: string): Promise<Record<string, string[]>> => {
validateInputs([organizationId, ZId]);
if (userIds.length === 0) return {};
try {
const teamUsers = await prisma.teamUser.findMany({
where: {
userId: { in: userIds },
team: { organizationId },
},
select: { userId: true, teamId: true },
});
const map: Record<string, string[]> = {};
for (const tu of teamUsers) {
if (!map[tu.userId]) map[tu.userId] = [];
map[tu.userId].push(tu.teamId);
}
return map;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
}
);
export const getOtherTeams = reactCache(
async (userId: string, organizationId: string): Promise<TOtherTeam[]> => {
validateInputs([userId, z.string()], [organizationId, ZId]);

View File

@@ -0,0 +1,180 @@
"use client";
import { UsersRoundIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TMember } from "@formbricks/types/memberships";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { addMemberToTeamAction } from "@/modules/ee/teams/team-list/actions";
import { TOrganizationTeam, ZTeamRole } from "@/modules/ee/teams/team-list/types/team";
import { Button } from "@/modules/ui/components/button";
import { FormControl, FormField, FormItem, FormLabel, FormProvider } from "@/modules/ui/components/form";
import { InputCombobox } from "@/modules/ui/components/input-combo-box";
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
type AssignToTeamFormValues = {
teamId: string | null;
role: "admin" | "contributor";
};
interface AssignToTeamPopoverProps {
member: TMember;
assignableTeams: TOrganizationTeam[];
memberTeamIdsMap: Record<string, string[]>;
}
export const AssignToTeamPopover = ({
member,
assignableTeams,
memberTeamIdsMap,
}: Readonly<AssignToTeamPopoverProps>) => {
const router = useRouter();
const { t } = useTranslation();
const [isAssigningToTeam, setIsAssigningToTeam] = useState(false);
const [open, setOpen] = useState(false);
const assignToTeamForm = useForm<AssignToTeamFormValues>({
defaultValues: { teamId: null, role: "contributor" },
});
const memberTeamIds = memberTeamIdsMap[member.userId] ?? [];
const teamsToAssign = assignableTeams.filter((team) => !memberTeamIds.includes(team.id));
const canAssignToTeam = teamsToAssign.length > 0;
const isOwnerOrManager = member.role === "owner" || member.role === "manager";
const teamOptions = useMemo(
() =>
teamsToAssign
.map((team) => ({ value: team.id, label: team.name }))
.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: "base" })),
[teamsToAssign]
);
const roleOptions = useMemo(
() => [
{ value: ZTeamRole.enum.admin, label: t("environments.settings.teams.team_admin") },
{ value: ZTeamRole.enum.contributor, label: t("environments.settings.teams.contributor") },
],
[t]
);
const handleAssignToTeam = async (values: AssignToTeamFormValues) => {
if (!values.teamId) return;
try {
setIsAssigningToTeam(true);
const result = await addMemberToTeamAction({
teamId: values.teamId,
userId: member.userId,
role: isOwnerOrManager ? "admin" : values.role,
});
if (result?.data) {
toast.success(t("environments.settings.teams.member_added_to_team"));
setOpen(false);
assignToTeamForm.reset({ teamId: null, role: "contributor" });
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage);
}
} catch (err) {
toast.error(`${t("common.error")}: ${err.message}`);
} finally {
setIsAssigningToTeam(false);
}
};
const handleOpenChange = (newOpen: boolean) => {
setOpen(newOpen);
if (!newOpen) {
assignToTeamForm.reset({ teamId: null, role: "contributor" });
}
};
const getTooltip = () => {
if (canAssignToTeam) return t("common.add_to_team");
if (assignableTeams.length === 0) {
return t("environments.settings.teams.create_first_team_message");
}
return t("environments.settings.teams.member_in_all_teams");
};
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<TooltipRenderer tooltipContent={getTooltip()} shouldRender>
<PopoverTrigger asChild>
<Button
variant="secondary"
size="icon"
id="assignToTeamButton"
disabled={isAssigningToTeam || !canAssignToTeam}>
<UsersRoundIcon />
</Button>
</PopoverTrigger>
</TooltipRenderer>
<PopoverContent align="end" className="w-72">
<FormProvider {...assignToTeamForm}>
<form className="space-y-3" onSubmit={assignToTeamForm.handleSubmit(handleAssignToTeam)}>
<FormField
control={assignToTeamForm.control}
name="teamId"
render={({ field }) => (
<FormItem>
<FormLabel>{t("common.team_name")}</FormLabel>
<FormControl>
<InputCombobox
id="assign-team-combobox"
options={teamOptions}
value={field.value}
onChangeValue={(val) => field.onChange(typeof val === "string" ? val : null)}
showSearch
searchPlaceholder={t("common.search")}
comboboxClasses="w-full text-sm"
emptyDropdownText={t("environments.surveys.edit.no_option_found")}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={assignToTeamForm.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>{t("common.team_role")}</FormLabel>
{isOwnerOrManager ? (
<div className="flex h-10 items-center rounded-md border border-slate-300 bg-slate-50 px-3 text-sm text-slate-600">
{t("environments.settings.teams.team_admin")}
</div>
) : (
<FormControl>
<InputCombobox
id="assign-role-combobox"
options={roleOptions}
value={field.value}
onChangeValue={(val) => field.onChange(val as "admin" | "contributor")}
showSearch={false}
comboboxClasses="w-full text-sm"
/>
</FormControl>
)}
</FormItem>
)}
/>
<Button
type="submit"
size="sm"
className="w-full"
disabled={!assignToTeamForm.watch("teamId") || isAssigningToTeam}
loading={isAssigningToTeam}>
{t("common.add_to_team")}
</Button>
</form>
</FormProvider>
</PopoverContent>
</Popover>
);
};

View File

@@ -2,6 +2,8 @@ import { TOrganizationRole } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { getTeamIdsByUserIds } from "@/modules/ee/teams/team-list/lib/team";
import { TOrganizationTeam } from "@/modules/ee/teams/team-list/types/team";
import { MembersInfo } from "@/modules/organization/settings/teams/components/edit-memberships/members-info";
import { getInvitesByOrganizationId } from "@/modules/organization/settings/teams/lib/invite";
import { getMembershipByOrganizationId } from "@/modules/organization/settings/teams/lib/membership";
@@ -12,6 +14,9 @@ interface EditMembershipsProps {
role: TOrganizationRole;
isAccessControlAllowed: boolean;
isUserManagementDisabledFromUi: boolean;
teams: TOrganizationTeam[];
isOwnerOrManager: boolean;
userAdminTeamIds?: string[];
}
export const EditMemberships = async ({
@@ -20,26 +25,35 @@ export const EditMemberships = async ({
role,
isAccessControlAllowed,
isUserManagementDisabledFromUi,
teams,
isOwnerOrManager,
userAdminTeamIds,
}: EditMembershipsProps) => {
const members = await getMembershipByOrganizationId(organization.id);
const invites = await getInvitesByOrganizationId(organization.id);
const t = await getTranslate();
const memberUserIds = (members ?? []).map((m) => m.userId);
const memberTeamIdsMap =
memberUserIds.length > 0 ? await getTeamIdsByUserIds(memberUserIds, organization.id) : {};
const assignableTeams = isOwnerOrManager
? teams
: teams.filter((team) => userAdminTeamIds?.includes(team.id));
return (
<div>
<div className="rounded-lg border border-slate-200">
<div className="flex h-12 w-full max-w-full items-center gap-x-4 rounded-t-lg bg-slate-100 px-4 text-left text-sm font-semibold text-slate-900">
<div className="w-1/2 overflow-hidden">{t("common.full_name")}</div>
<div className="w-1/2 overflow-hidden">{t("common.email")}</div>
<div className="flex grid h-12 w-full max-w-full grid-cols-12 items-center gap-x-4 rounded-t-lg bg-slate-100 px-4 text-left text-sm font-semibold text-slate-900">
<div className="col-span-2 overflow-hidden">{t("common.full_name")}</div>
<div className="col-span-3 overflow-hidden">{t("common.email")}</div>
{isAccessControlAllowed && (
<div className="min-w-[100px] whitespace-nowrap">{t("common.role")}</div>
)}
{isAccessControlAllowed && <div className="col-span-2 whitespace-nowrap">{t("common.role")}</div>}
<div className="min-w-[80px] whitespace-nowrap">{t("common.status")}</div>
<div className="col-span-2 whitespace-nowrap">{t("common.status")}</div>
{!isUserManagementDisabledFromUi && (
<div className="min-w-[125px] whitespace-nowrap">{t("common.actions")}</div>
<div className="col-span-3 whitespace-nowrap">{t("common.actions")}</div>
)}
</div>
@@ -53,6 +67,8 @@ export const EditMemberships = async ({
isAccessControlAllowed={isAccessControlAllowed}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isUserManagementDisabledFromUi={isUserManagementDisabledFromUi}
assignableTeams={assignableTeams}
memberTeamIdsMap={memberTeamIdsMap}
/>
)}
</div>

View File

@@ -8,12 +8,14 @@ import { useTranslation } from "react-i18next";
import { TMember } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { TOrganizationTeam } from "@/modules/ee/teams/team-list/types/team";
import {
createInviteTokenAction,
deleteInviteAction,
deleteMembershipAction,
resendInviteAction,
} from "@/modules/organization/settings/teams/actions";
import { AssignToTeamPopover } from "@/modules/organization/settings/teams/components/edit-memberships/assign-to-team-popover";
import { ShareInviteModal } from "@/modules/organization/settings/teams/components/invite-member/share-invite-modal";
import { TInvite } from "@/modules/organization/settings/teams/types/invites";
import { Button } from "@/modules/ui/components/button";
@@ -25,15 +27,25 @@ interface MemberActionsProps {
member?: TMember;
invite?: TInvite;
showDeleteButton?: boolean;
assignableTeams: TOrganizationTeam[];
memberTeamIdsMap: Record<string, string[]>;
isAccessControlAllowed: boolean;
}
export const MemberActions = ({ organization, member, invite, showDeleteButton }: MemberActionsProps) => {
export const MemberActions = ({
organization,
member,
invite,
showDeleteButton,
assignableTeams,
memberTeamIdsMap,
isAccessControlAllowed,
}: MemberActionsProps) => {
const router = useRouter();
const { t } = useTranslation();
const [isDeleteMemberModalOpen, setDeleteMemberModalOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [showShareInviteModal, setShowShareInviteModal] = useState(false);
const [shareInviteToken, setShareInviteToken] = useState("");
const handleDeleteMember = async () => {
@@ -123,6 +135,8 @@ export const MemberActions = ({ organization, member, invite, showDeleteButton }
}
};
const showAssignToTeamButton = Boolean(member) && isAccessControlAllowed;
return (
<div className="flex gap-2">
<TooltipRenderer tooltipContent={t("common.delete")} shouldRender={!!showDeleteButton}>
@@ -166,6 +180,14 @@ export const MemberActions = ({ organization, member, invite, showDeleteButton }
</Button>
</TooltipRenderer>
{showAssignToTeamButton && member && (
<AssignToTeamPopover
member={member}
assignableTeams={assignableTeams}
memberTeamIdsMap={memberTeamIdsMap}
/>
)}
<DeleteDialog
open={isDeleteMemberModalOpen}
setOpen={setDeleteMemberModalOpen}

View File

@@ -6,6 +6,7 @@ import { TOrganization } from "@formbricks/types/organizations";
import { getAccessFlags } from "@/lib/membership/utils";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { EditMembershipRole } from "@/modules/ee/role-management/components/edit-membership-role";
import { TOrganizationTeam } from "@/modules/ee/teams/team-list/types/team";
import { MemberActions } from "@/modules/organization/settings/teams/components/edit-memberships/member-actions";
import { isInviteExpired } from "@/modules/organization/settings/teams/lib/utils";
import { TInvite } from "@/modules/organization/settings/teams/types/invites";
@@ -21,6 +22,8 @@ interface MembersInfoProps {
isAccessControlAllowed: boolean;
isFormbricksCloud: boolean;
isUserManagementDisabledFromUi: boolean;
assignableTeams: TOrganizationTeam[];
memberTeamIdsMap: Record<string, string[]>;
}
// Type guard to check if member is an invitee
@@ -37,6 +40,8 @@ export const MembersInfo = ({
isAccessControlAllowed,
isFormbricksCloud,
isUserManagementDisabledFromUi,
assignableTeams,
memberTeamIdsMap,
}: MembersInfoProps) => {
const allMembers = [...members, ...invites];
const { t } = useTranslation();
@@ -96,17 +101,17 @@ export const MembersInfo = ({
{allMembers.map((member) => (
<div
id="singleMemberInfo"
className="flex w-full max-w-full items-center gap-x-4 text-left text-sm text-slate-900"
className="flex grid w-full max-w-full grid-cols-12 items-center gap-x-4 text-left text-sm text-slate-900"
key={member.email}>
<div className="ph-no-capture w-1/2 overflow-hidden">
<div className="ph-no-capture col-span-2 overflow-hidden">
<p className="w-full truncate">{member.name}</p>
</div>
<div className="ph-no-capture w-1/2 overflow-hidden">
<div className="ph-no-capture col-span-3 overflow-hidden">
<p className="w-full truncate"> {member.email}</p>
</div>
{isAccessControlAllowed && allMembers?.length > 0 && (
<div className="ph-no-capture min-w-[100px]">
<div className="ph-no-capture col-span-2">
<EditMembershipRole
currentUserRole={currentUserRole}
memberRole={member.role}
@@ -121,15 +126,20 @@ export const MembersInfo = ({
/>
</div>
)}
<div className="min-w-[80px]">{getMembershipBadge(member)}</div>
<div className="col-span-2 flex items-center">{getMembershipBadge(member)}</div>
{!isUserManagementDisabledFromUi && (
<MemberActions
organization={organization}
member={!isInvitee(member) ? member : undefined}
invite={isInvitee(member) ? member : undefined}
showDeleteButton={showDeleteButton(member)}
/>
<div className="col-span-3">
<MemberActions
organization={organization}
member={isInvitee(member) ? undefined : member}
invite={isInvitee(member) ? member : undefined}
showDeleteButton={showDeleteButton(member)}
assignableTeams={assignableTeams}
memberTeamIdsMap={memberTeamIdsMap}
isAccessControlAllowed={isAccessControlAllowed}
/>
</div>
)}
</div>
))}

View File

@@ -3,6 +3,7 @@ import { TOrganizationRole } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { INVITE_DISABLED, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { getAccessFlags } from "@/lib/membership/utils";
import { getTranslate } from "@/lingodotdev/server";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { getTeamsWhereUserIsAdmin } from "@/modules/ee/teams/lib/roles";
@@ -56,6 +57,9 @@ export const MembersView = async ({
teams = (await getTeamsByOrganizationId(organization.id)) ?? [];
}
const { isOwner, isManager } = getAccessFlags(membershipRole);
const isOwnerOrManager = isOwner || isManager;
return (
<SettingsCard
title={t("environments.settings.general.manage_members")}
@@ -87,6 +91,9 @@ export const MembersView = async ({
currentUserId={currentUserId}
role={membershipRole}
isUserManagementDisabledFromUi={isUserManagementDisabledFromUi}
teams={teams}
isOwnerOrManager={isOwnerOrManager}
userAdminTeamIds={userAdminTeamIds}
/>
</Suspense>
)}