use super::admin_actions::AdminActions; use super::settings_page::{render_customer_type_badge, MatchData, PageType, SettingsWidget}; use super::transfer_ownership_confirmation_modal::{ TransferOwnershipConfirmationEvent, TransferOwnershipConfirmationModal, }; use super::SettingsSection; use super::{ settings_page::{ render_separator, render_sub_header, SettingsPageMeta, SettingsPageViewHandle, }, tab_menu::Tabs, }; use crate::ai::AIRequestUsageModel; use crate::auth::auth_manager::{AuthManager, LoginGatedFeature}; use crate::auth::auth_state::AuthState; use crate::auth::auth_view_modal::AuthViewVariant; use crate::auth::{AuthStateProvider, UserUid}; use crate::menu::{self, Menu, MenuItem, MenuItemFields}; use crate::modal::{Modal, ModalEvent, ModalViewState}; use crate::pricing::PricingInfoModel; use crate::view_components::ToastFlavor; use crate::workspaces::team::{MembershipRole, TeamDeleteDisabledReason}; use crate::{ appearance::Appearance, channel::ChannelState, cloud_object::{model::persistence::CloudModel, CloudObjectEventEntrypoint, Space}, drive::cloud_action_confirmation_dialog::{ CloudActionConfirmationDialog, CloudActionConfirmationDialogEvent, CloudActionConfirmationDialogVariant, }, editor::{EditorView, Event as EditorEvent, SingleLineEditorOptions, TextOptions}, network::NetworkStatus, send_telemetry_from_ctx, server::{ cloud_objects::update_manager::UpdateManager, ids::ServerId, telemetry::TelemetryEvent, }, themes::{self, theme::Blend}, ui_components::icons::Icon, view_components::{ClickableTextInput, ClickableTextInputAction, ClickableTextInputEvent}, word_block_editor::{ChipEditorState, WordBlockEditorView, WordBlockEditorViewEvent}, workspace::WorkspaceAction, workspaces::{ team::{DiscoverableTeam, Team}, update_manager::{TeamUpdateManager, TeamUpdateManagerEvent}, user_workspaces::{UserWorkspaces, UserWorkspacesEvent}, workspace::{CustomerType, DelinquencyStatus, WorkspaceSizePolicy}, }, }; use core::default::Default; use email_address::EmailAddress; use itertools::Itertools; use lazy_static::lazy_static; use pathfinder_color::ColorU; use pathfinder_geometry::vector::vec2f; use regex::Regex; use serde::{Deserialize, Serialize}; use std::sync::Arc; use std::{cmp::Ordering, collections::HashSet}; use warp_core::ui::theme::color::internal_colors; use warpui::FocusContext; use warpui::{ clipboard::ClipboardContent, elements::{ Align, Border, ChildAnchor, ClippedScrollStateHandle, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Element, Flex, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius, SavePosition, ScrollTarget, ScrollToPositionMode, Shrinkable, Stack, Text, }, fonts::{Properties, Weight}, platform::Cursor, presenter::ChildView, ui_components::{ button::{ButtonVariant, TextAndIcon, TextAndIconAlignment}, components::{Coords, UiComponent, UiComponentStyles}, switch::SwitchStateHandle, text_input::TextInput, }, AppContext, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle, }; const TEAM_MEMBERS_HEADER_POSITION_ID: &str = "team_settings:team_members_header"; // Styling for team create page const TEAM_NAME_EDITOR_PLACEHOLDER_TEXT: &str = "Team name"; const CREATE_TEAM_BUTTON_LEFT_PADDING: f32 = 10.; const CREATE_TEAM_DESCRIPTION: &str = "When you create a team, you can collaborate on agent-driven development by sharing cloud agent runs, environments, automations, and artifacts. You can also create a shared knowledge store for teammates and agents alike."; // Styling for team management page const LEAVE_TEAM_BUTTON_LABEL: &str = "Leave team"; const DELETE_TEAM_BUTTON_LABEL: &str = "Delete team"; const CREATE_TEAM_BUTTON_LABEL: &str = "Create"; const APPROVE_DOMAINS_PLACEHOLDER: &str = "Domains, comma separated"; const EMAILS_PLACEHOLDER: &str = "Emails, comma separated"; const APPROVE_DOMAINS_BUTTON_LABEL: &str = "Set"; const SEND_EMAIL_INVITES_BUTTON_LABEL: &str = "Invite"; const BUTTON_WIDTH: f32 = 82.; const BUTTON_HEIGHT: f32 = 40.; const COPY_LINK_LEFT_PADDING: f32 = 7.; const LEAVE_TEAM_BUTTON_WIDTH: f32 = 115.; const SCROLLABLE_LIST_ITEM_PADDING: f32 = 10.; const CLOSE_BUTTON_ICON_SIZE: f32 = 20.; const CONTENT_SEPARATION_PADDING: f32 = 24.; const TEXT_FIELD_TOP_PADDING: f32 = 12.; const HORIZONTAL_BAR_TO_SUB_HEADER_PADDING: f32 = 9.; const COMPARE_PLANS_BUTTON_WIDTH: f32 = 120.; const SUBSECTION_HEADER_FONT_SIZE: f32 = 18.; const INVITE_LINK_PREFIX: &str = "/team/"; const INVALID_DOMAINS_INSTRUCTIONS: &str = "Some of the provided domains are invalid, or have already been added."; const INVITE_LINK_TOGGLE_INSTRUCTIONS: &str = "As an admin, you can choose whether to enable or disable the ability for team members to invite others by invitation link."; const INVITE_LINK_DOMAIN_RESTRICTIONS_INSTRUCTIONS: &str = "Only allow users with emails at specific domains to join your team through the invite link."; const INVITE_BY_EMAIL_EXPIRY_INSTRUCTIONS: &str = "Email invitations are valid for 7 days."; const INVALID_EMAILS_INSTRUCTIONS: &str = "Some of the provided email addresses are invalid, already invited, or members of the team."; const OFFLINE_TEXT: &str = "You are offline."; const LIMIT_HIT_ADMIN_TEXT: &str = "You've reached the team member limit for your plan. Upgrade to add more teammates."; const LIMIT_HIT_ADMIN_NOT_AUTO_UPGRADEABLE_TEXT: &str = "You've reached the team member limit for your plan. Contact support@warp.dev to add more teammates."; const LIMIT_HIT_NON_ADMIN_TEXT: &str = "You've reached the team member limit for your plan. Contact a team admin to add more teammates."; const DELINQUENT_ADMIN_NON_SELF_SERVE_TEXT: &str = "Team invites have been restricted due to a payment issue. Please contact support@warp.dev to restore access."; const DELINQUENT_NON_ADMIN_TEXT: &str = "Team invites have been restricted due to a payment issue. Please contact a team admin to restore access."; const DELINQUENT_ADMIN_SELF_SERVE_LINE_1_TEXT: &str = "Team invites have been restricted due to a subscription payment issue."; const DELINQUENT_ADMIN_SELF_SERVE_LINE_2_PREFIX_TEXT: &str = "Please "; const DELINQUENT_ADMIN_SELF_SERVE_LINE_2_LINK_TEXT: &str = "update your payment information"; const DELINQUENT_ADMIN_SELF_SERVE_LINE_2_SUFFIX_TEXT: &str = " to restore access."; const TEAM_LIMIT_EXCEEDED_ADMIN_NOT_AUTO_UPGRADEABLE_TEXT: &str = "You've exceeded the team member limit for your plan. Please contact support@warp.dev to upgrade your team."; const TEAM_LIMIT_EXCEEDED_NON_ADMIN_TEXT: &str = "You've exceeded the team member limit for your plan. Contact a team admin to upgrade your team."; const TEAM_LIMIT_EXCEEDED_ADMIN_UPGRADEABLE: &str = "You've exceeded the team member limit for your plan. Upgrade to add more teammates."; const MAX_CHIP_WIDTH: f32 = 280.; lazy_static! { static ref DOMAIN_NAME_REGEX: Regex = Regex::new(r"^([a-zA-Z0-9]+(-[a-zA-Z0-9]+)*\.)+[a-zA-Z0-9]{2,}$") .expect("regex should not fail to compile"); static ref EMAIL_INVITE_PENDING_COLOR: ColorU = ColorU::new(243, 185, 17, 255); static ref PAST_DUE_BADGE_COLOR: ColorU = ColorU::new(254, 253, 194, 255); static ref UNPAID_BADGE_COLOR: ColorU = ColorU::new(255, 130, 114, 255); static ref DELINQUENCY_BADGE_TEXT_COLOR: ColorU = ColorU::new(0, 0, 0, 190); } #[derive(Debug, Clone)] pub enum TeamsPageAction { LeaveTeam, ShowLeaveTeamConfirmationDialog, ShowDeleteTeamConfirmationDialog, CopyLink(String), CreateTeam, ChangeInviteViewOption(TeamsInviteOption), DeletePendingEmailInvitation { team_uid: ServerId, invitee_email: String, }, RemoveUserFromTeam { user_uid: UserUid, team_uid: ServerId, }, ToggleIsInviteLinkEnabled { team_uid: ServerId, current_state: bool, }, ResetInviteLinks { team_uid: ServerId, }, AddDomainRestrictions { team_uid: ServerId, }, DeleteDomainRestriction { domain_uid: ServerId, team_uid: ServerId, }, SendEmailInvites { team_uid: ServerId, }, OpenWarpDrive, GenerateUpgradeLink { team_uid: ServerId, }, GenerateStripeBillingPortalLink { team_uid: ServerId, }, OpenAdminPanel { team_uid: ServerId, }, ContactSupport, /// This action is for toggling the discoverability checkbox before a team is created. ToggleTeamDiscoverabilityBeforeCreation, /// This action is for toggling the discoverability toggle after a team has been created. ToggleTeamDiscoverability { team_uid: ServerId, current_state: bool, }, JoinTeamWithTeamDiscovery { team_uid: ServerId, }, ShowTransferOwnershipModal { new_owner_email: String, new_owner_uid: UserUid, team_uid: ServerId, }, OpenMemberActionsMenu { index: usize, }, CloseMemberActionsMenu, SetTeamMemberRole { team_uid: ServerId, user_uid: UserUid, role: MembershipRole, }, } impl TeamsPageAction { pub fn blocked_for_anonymous_user(&self) -> bool { use TeamsPageAction::*; matches!( self, LeaveTeam | ShowLeaveTeamConfirmationDialog | ShowDeleteTeamConfirmationDialog | CreateTeam | DeletePendingEmailInvitation { .. } | RemoveUserFromTeam { .. } | AddDomainRestrictions { .. } | DeleteDomainRestriction { .. } | SendEmailInvites { .. } | GenerateUpgradeLink { .. } | GenerateStripeBillingPortalLink { .. } | OpenAdminPanel { .. } | ContactSupport | ToggleTeamDiscoverabilityBeforeCreation | ToggleTeamDiscoverability { .. } | JoinTeamWithTeamDiscovery { .. } ) } } impl From<&TeamsPageAction> for LoginGatedFeature { fn from(val: &TeamsPageAction) -> LoginGatedFeature { use TeamsPageAction::*; match val { LeaveTeam => "Leave Team", ShowDeleteTeamConfirmationDialog => "Delete Team", CreateTeam => "Create Team", DeletePendingEmailInvitation { .. } => "Delete Pending Email Invitation", RemoveUserFromTeam { .. } => "Remove User From Team", AddDomainRestrictions { .. } => "Add Domain Restrictions", DeleteDomainRestriction { .. } => "Delete Domain Restriction", SendEmailInvites { .. } => "Send Email Invites", GenerateUpgradeLink { .. } => "Generate Upgrade Link", GenerateStripeBillingPortalLink { .. } => "Generate Stripe Billing Portal Link", OpenAdminPanel { .. } => "Open Admin Panel", ContactSupport => "Contact Support", ToggleTeamDiscoverability { .. } | ToggleTeamDiscoverabilityBeforeCreation => { "Toggle Team Discoverability" } JoinTeamWithTeamDiscovery { .. } => "Join Team With Team Discovery", _ => "Unknown reason", } } } impl TryFrom<&TeamsPageAction> for TelemetryEvent { type Error = anyhow::Error; fn try_from(action: &TeamsPageAction) -> Result { match action { TeamsPageAction::CopyLink(_) => Ok(TelemetryEvent::TeamLinkCopied), TeamsPageAction::ChangeInviteViewOption(option) => { Ok(TelemetryEvent::ChangedInviteViewOption(*option)) } TeamsPageAction::SendEmailInvites { .. } => Ok(TelemetryEvent::SendEmailInvites), // Some Team events are logged from the server so we do not want to log // them from the client as well. For more details see: // https://docs.google.com/document/d/1va3_qfkHtDFKZqYaMgNUn5nwU4f8NByzyhg1uolHlck/edit _ => Err(anyhow::anyhow!( "We do not log this telemetry event from the client." )), } } } #[derive(Clone)] pub enum TeamsPageViewEvent { TeamsChanged, OpenWarpDrive, ShowToast { message: String, flavor: ToastFlavor, }, } #[derive(Default)] struct TeamsWidgetMouseHandles { copy_link_button: MouseStateHandle, send_email_invites_button: MouseStateHandle, leave_team_button: MouseStateHandle, create_team_button: MouseStateHandle, approve_domains_button: MouseStateHandle, reset_invite_links_button: MouseStateHandle, invite_by_link_toggle_state: SwitchStateHandle, upgrade_link: MouseStateHandle, stripe_billing_portal_link: MouseStateHandle, manage_plan_link: MouseStateHandle, enterprise_contact_us_link: MouseStateHandle, invite_by_email_upgrade_button: MouseStateHandle, invite_by_email_billing_portal_link: MouseStateHandle, discoverable_team_toggle_state: SwitchStateHandle, checkbox_mouse_state: MouseStateHandle, admin_panel_button: MouseStateHandle, } /// TeamsInviteOption is whether the user is looking at invite-by-link or invite-by-email. #[derive(Clone, PartialEq, Eq, Debug, Default, Copy, Serialize, Deserialize)] pub enum TeamsInviteOption { #[default] Link, Email, } impl std::fmt::Display for TeamsInviteOption { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, "{}", match self { TeamsInviteOption::Link => "Link", TeamsInviteOption::Email => "Email", }, ) } } impl Tabs for TeamsInviteOption { fn action_on_click(&self, selection: TeamsInviteOption) -> TeamsPageAction { TeamsPageAction::ChangeInviteViewOption(selection) } fn label(&self, _team: &Team, _cloud_model: &CloudModel) -> String { self.tab_name() } } /// The order of the ItemState enum values determines the ordering of the members and /// invites list in the team management page (see `impl Ord for Item`` below). #[derive(Clone, PartialOrd, PartialEq, Eq, Ord)] enum ItemState { Expired, Pending, Owner, Admin, Valid, } #[derive(Clone)] struct ItemAction { icon: Icon, label: String, action: TeamsPageAction, } /// An item (team member, pending email invite, or domain) consists of its text, and actions associated with it. #[derive(Clone)] pub struct Item { text: String, actions: Vec, state: ItemState, } impl PartialEq for Item { fn eq(&self, other: &Self) -> bool { (&self.state, &self.text) == (&other.state, &other.text) } } impl Eq for Item {} impl PartialOrd for Item { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } impl Ord for Item { fn cmp(&self, other: &Self) -> Ordering { if self.state == other.state { self.text.cmp(&other.text) } else { self.state.cmp(&other.state) } } } #[derive(Clone)] struct DiscoverableTeamState { team: DiscoverableTeam, mouse_state_handle: MouseStateHandle, } impl DiscoverableTeamState { pub fn new(team: DiscoverableTeam) -> Self { Self { team, mouse_state_handle: Default::default(), } } } #[derive(Debug, Clone, Eq, PartialEq)] pub struct OpenTeamsSettingsModalArgs { pub invite_email: Option, } pub struct TeamsPageView { page: PageType, auth_state: Arc, create_team_editor: ViewHandle, approve_domains_block_editor: ViewHandle, approve_domains_block_editor_state: ChipEditorState, email_invites_block_editor: ViewHandle, email_invites_block_editor_state: ChipEditorState, // Note that rather than storing just the current workspace, we're storing the entire // ModelHandle. That's because eventually we'll be handling more than one workspace. user_workspaces: ModelHandle, ai_request_usage_model: ModelHandle, pricing_info_model: ModelHandle, cloud_model: ModelHandle, invite_view: TeamsInviteOption, team_members_mouse_state_handles: Vec, team_approved_domains_mouse_state_handles: Vec, delete_or_leave_team_confirmation_dialog: ViewHandle, show_delete_or_leave_team_confirmation_dialog: bool, transfer_ownership_modal_state: ModalViewState>, clipped_scroll_state: ClippedScrollStateHandle, discoverable_teams_states: Vec, rename_team_editor: ViewHandle, checkbox_value: bool, member_actions_menu: ViewHandle>, open_member_actions_menu_index: Option, } impl Entity for TeamsPageView { type Event = TeamsPageViewEvent; } impl TypedActionView for TeamsPageView { type Action = TeamsPageAction; fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { // Block anonymous users from performing team actions if AuthStateProvider::as_ref(ctx) .get() .is_anonymous_or_logged_out() && action.blocked_for_anonymous_user() { AuthManager::handle(ctx).update(ctx, |auth_manager, ctx| { auth_manager.attempt_login_gated_feature( action.into(), AuthViewVariant::RequireLoginCloseable, ctx, ) }); return; } match action { TeamsPageAction::CopyLink(link) => self.copy_invite_link(link, ctx), TeamsPageAction::LeaveTeam => self.leave_team(ctx), TeamsPageAction::CreateTeam => self.create_team(ctx), TeamsPageAction::RemoveUserFromTeam { user_uid, team_uid } => { self.remove_user_from_team(*user_uid, *team_uid, ctx) } TeamsPageAction::ChangeInviteViewOption(view_option) => { self.change_invite_view_option(view_option, ctx); } TeamsPageAction::SendEmailInvites { team_uid } => { self.send_email_invites(*team_uid, ctx); ctx.notify(); } TeamsPageAction::OpenWarpDrive => ctx.emit(TeamsPageViewEvent::OpenWarpDrive), TeamsPageAction::ShowLeaveTeamConfirmationDialog => { self.delete_or_leave_team_confirmation_dialog .update(ctx, |dialog, ctx| { dialog.set_variant(CloudActionConfirmationDialogVariant::LeaveTeam); ctx.notify(); }); self.show_delete_or_leave_team_confirmation_dialog = true; self.enable_confirmation_dialog_confirm_button(ctx); } TeamsPageAction::ShowDeleteTeamConfirmationDialog => { self.delete_or_leave_team_confirmation_dialog .update(ctx, |dialog, ctx| { dialog.set_variant(CloudActionConfirmationDialogVariant::DeleteTeam); ctx.notify(); }); self.show_delete_or_leave_team_confirmation_dialog = true; self.enable_confirmation_dialog_confirm_button(ctx); } TeamsPageAction::ToggleIsInviteLinkEnabled { team_uid, current_state, } => { let new_value = !current_state; self.set_is_invite_link_enabled(*team_uid, new_value, ctx); ctx.notify(); } TeamsPageAction::ResetInviteLinks { team_uid } => { self.reset_invite_links(*team_uid, ctx); ctx.notify(); } TeamsPageAction::DeletePendingEmailInvitation { team_uid, invitee_email, } => { self.delete_team_invite(*team_uid, invitee_email.clone(), ctx); ctx.notify(); } TeamsPageAction::AddDomainRestrictions { team_uid } => { self.add_domain_restrictions(*team_uid, ctx) } TeamsPageAction::DeleteDomainRestriction { domain_uid, team_uid, } => self.delete_domain_restriction(*team_uid, *domain_uid, ctx), TeamsPageAction::GenerateUpgradeLink { team_uid } => { self.generate_upgrade_link(*team_uid, ctx) } TeamsPageAction::GenerateStripeBillingPortalLink { team_uid } => { self.generate_stripe_billing_portal_link(*team_uid, ctx) } TeamsPageAction::OpenAdminPanel { team_uid } => { AdminActions::open_admin_panel(*team_uid, ctx); } TeamsPageAction::ContactSupport => { AdminActions::contact_support(ctx); } TeamsPageAction::ToggleTeamDiscoverability { team_uid, current_state, } => { self.set_team_discoverability(*team_uid, !current_state, ctx); ctx.notify(); } TeamsPageAction::JoinTeamWithTeamDiscovery { team_uid } => { self.join_team_with_team_discovery(*team_uid, ctx); ctx.notify(); } TeamsPageAction::ShowTransferOwnershipModal { new_owner_email, new_owner_uid, team_uid, } => { self.show_transfer_ownership_modal( new_owner_email.clone(), *new_owner_uid, *team_uid, ctx, ); } TeamsPageAction::ToggleTeamDiscoverabilityBeforeCreation => { self.checkbox_value = !self.checkbox_value; } TeamsPageAction::OpenMemberActionsMenu { index } => { self.open_member_actions_menu_for_item(*index, ctx); } TeamsPageAction::CloseMemberActionsMenu => { self.open_member_actions_menu_index = None; ctx.notify(); } TeamsPageAction::SetTeamMemberRole { team_uid, user_uid, role, } => { self.set_team_member_role(*user_uid, *team_uid, *role, ctx); } }; if let Ok(event) = TelemetryEvent::try_from(action) { send_telemetry_from_ctx!(event, ctx); } } } impl View for TeamsPageView { fn ui_name() -> &'static str { "TeamsPageView" } fn render(&self, app: &AppContext) -> Box { self.page.render(self, app) } fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext) { if focus_ctx.is_self_focused() && !self.user_workspaces.as_ref(ctx).has_teams() { ctx.focus(&self.create_team_editor); ctx.notify(); } } } impl TeamsPageView { fn editor( mut event_handler: F, placeholder: &str, ui_font_size: f32, ctx: &mut ViewContext, ) -> ViewHandle where F: 'static + FnMut(&mut TeamsPageView, &EditorEvent, &mut ViewContext), { let editor = ctx.add_typed_action_view(|ctx| { let options = SingleLineEditorOptions { text: TextOptions { font_size_override: Some(ui_font_size), ..Default::default() }, ..Default::default() }; EditorView::single_line(options, ctx) }); editor.update(ctx, |editor, ctx| { editor.clear_buffer_and_reset_undo_stack(ctx); editor.set_placeholder_text(placeholder, ctx); }); ctx.subscribe_to_view(&editor, move |me, _, event, ctx| { event_handler(me, event, ctx); }); editor } pub fn new(ctx: &mut ViewContext) -> Self { let user_workspaces = UserWorkspaces::handle(ctx); ctx.observe(&user_workspaces, |me, _, ctx| { me.update_team_members_state(ctx); me.update_approved_domains_state(ctx); }); ctx.subscribe_to_model(&user_workspaces, |me, _handle, event, ctx| { me.handle_model_event(event, ctx); ctx.notify(); }); let team_update_manager = TeamUpdateManager::handle(ctx); ctx.subscribe_to_model(&team_update_manager, |me, _handle, event, ctx| { me.handle_team_update_event(event, ctx); ctx.notify(); }); let cloud_model = CloudModel::handle(ctx); ctx.observe(&cloud_model, |me, _, ctx| { me.update_team_members_state(ctx); me.update_approved_domains_state(ctx); }); let pricing_info_model = PricingInfoModel::handle(ctx); let appearance = Appearance::as_ref(ctx); let font_size = appearance.ui_font_size(); let create_team_editor = Self::editor( |me, event, ctx| me.handle_editor_event(event, ctx), TEAM_NAME_EDITOR_PLACEHOLDER_TEXT, font_size, ctx, ); let approve_domains_block_editor = ctx.add_typed_action_view(|ctx| { WordBlockEditorView::new( ctx, APPROVE_DOMAINS_PLACEHOLDER, font_size, vec![',', ' '], MAX_CHIP_WIDTH, Box::new(Self::is_valid_domain), ) }); ctx.subscribe_to_view(&approve_domains_block_editor, |me, _, event, ctx| { me.handle_approve_domains_block_editor_event(event, ctx); }); let email_invites_block_editor = ctx.add_typed_action_view(|ctx| { WordBlockEditorView::new( ctx, EMAILS_PLACEHOLDER, font_size, vec![',', ' '], MAX_CHIP_WIDTH, Box::new(EmailAddress::is_valid), ) }); ctx.subscribe_to_view(&email_invites_block_editor, |me, _, event, ctx| { me.handle_email_invites_block_editor_event(event, ctx); }); let current_user_team = user_workspaces.as_ref(ctx).current_team(); let team_members_mouse_state_handles = current_user_team.map_or_else(Vec::new, |user_team| { user_team .members .iter() .map(|_| Default::default()) .collect() }); let team_approved_domains_mouse_state_handles = current_user_team.map_or_else(Vec::new, |user_team| { user_team .invite_link_domain_restrictions .iter() .map(|_| Default::default()) .collect() }); let team_name = current_user_team .map_or_else(|| "", |team| &team.name) .to_string(); let rename_team_editor = ctx.add_typed_action_view(|ctx| { let mut input = ClickableTextInput::new(team_name, ctx); input.set_placeholder_text("Your new team name", ctx); input }); ctx.subscribe_to_view(&rename_team_editor, |me, _, event, ctx| { me.handle_rename_team_editor_event(event, ctx); }); ctx.subscribe_to_model(&NetworkStatus::handle(ctx), move |_, _, _, ctx| { ctx.notify() }); let delete_or_leave_team_confirmation_dialog = ctx.add_typed_action_view(|_| CloudActionConfirmationDialog::new()); ctx.subscribe_to_view( &delete_or_leave_team_confirmation_dialog, |me, _, event, ctx| { me.handle_cloud_action_confirmation_dialog_event(event, ctx); }, ); let transfer_ownership_modal_body = ctx.add_typed_action_view(|_| TransferOwnershipConfirmationModal::new()); ctx.subscribe_to_view(&transfer_ownership_modal_body, |me, _, event, ctx| { me.handle_transfer_ownership_modal_event(event, ctx); }); let transfer_ownership_modal = ctx.add_typed_action_view(|ctx| { Modal::new( Some("Transfer team ownership?".to_string()), transfer_ownership_modal_body, ctx, ) .with_modal_style(UiComponentStyles { height: Some(220.), ..Default::default() }) .with_header_style(UiComponentStyles { padding: Some(Coords::uniform(24.).bottom(16.)), ..Default::default() }) .with_body_style(UiComponentStyles { padding: Some(Coords::uniform(24.).top(0.).bottom(12.)), height: Some(150.), ..Default::default() }) }); ctx.subscribe_to_view(&transfer_ownership_modal, |me, _, event, ctx| { me.handle_transfer_ownership_modal_close_event(event, ctx); }); let member_actions_menu = ctx.add_typed_action_view(|_| Menu::new().with_drop_shadow()); ctx.subscribe_to_view(&member_actions_menu, |me, _, event, ctx| { if let menu::Event::Close { .. } = event { me.open_member_actions_menu_index = None; ctx.notify(); } }); let page = PageType::new_monolith(TeamsWidget::default(), None, true); TeamsPageView { page, auth_state: AuthStateProvider::as_ref(ctx).get().clone(), create_team_editor, approve_domains_block_editor, approve_domains_block_editor_state: ChipEditorState { is_valid: false, is_empty: true, num_chips: 0, }, email_invites_block_editor, email_invites_block_editor_state: ChipEditorState { is_valid: false, is_empty: true, num_chips: 0, }, user_workspaces, ai_request_usage_model: AIRequestUsageModel::handle(ctx), pricing_info_model, cloud_model, invite_view: TeamsInviteOption::default(), team_members_mouse_state_handles, team_approved_domains_mouse_state_handles, clipped_scroll_state: Default::default(), delete_or_leave_team_confirmation_dialog, show_delete_or_leave_team_confirmation_dialog: false, transfer_ownership_modal_state: ModalViewState::new(transfer_ownership_modal), discoverable_teams_states: Vec::new(), rename_team_editor, checkbox_value: true, member_actions_menu, open_member_actions_menu_index: None, } } fn open_member_actions_menu_for_item(&mut self, index: usize, ctx: &mut ViewContext) { let Some(team) = self.user_workspaces.as_ref(ctx).current_team() else { return; }; let Some(current_user_email) = self.auth_state.user_email() else { return; }; let items = self.team_to_item_list(team, ¤t_user_email); let items_sorted = items.iter().sorted().collect_vec(); let Some(item) = items_sorted.get(index) else { return; }; if item.actions.is_empty() { return; } let menu_items: Vec> = item .actions .iter() .map(|item_action| { MenuItem::Item( MenuItemFields::new(item_action.label.clone()) .with_on_select_action(item_action.action.clone()) .with_icon(item_action.icon), ) }) .collect(); self.member_actions_menu.update(ctx, |menu, ctx| { menu.set_items(menu_items, ctx); }); self.open_member_actions_menu_index = Some(index); ctx.notify(); } fn handle_model_event( &mut self, event: &UserWorkspacesEvent, ctx: &mut ViewContext, ) { match event { UserWorkspacesEvent::EmailInviteSent => { self.email_invites_block_editor.update(ctx, |editor, ctx| { editor.clear_list_of_words(ctx); }); self.update_team_members_state(ctx); } UserWorkspacesEvent::EmailInviteRejected(err) => { self.update_team_members_state(ctx); self.show_error("Failed to send invite", Some(err), ctx) } UserWorkspacesEvent::TeamsChanged => { self.update_team_members_state(ctx); self.update_approved_domains_state(ctx); AIRequestUsageModel::handle(ctx).update(ctx, |usage_model, ctx| { usage_model.refresh_request_usage_async(ctx); }); ctx.emit(TeamsPageViewEvent::TeamsChanged); } UserWorkspacesEvent::ToggleInviteLinksSuccess => { self.show_success("Toggled invite links", ctx); ctx.notify(); } UserWorkspacesEvent::ToggleInviteLinksRejected(err) => { self.show_error("Failed to toggle invite links", Some(err), ctx); } UserWorkspacesEvent::ResetInviteLinks => { self.show_success("Reset invite links", ctx); ctx.notify(); } UserWorkspacesEvent::ResetInviteLinksRejected(err) => { self.show_error("Failed to reset invite links", Some(err), ctx); } UserWorkspacesEvent::DeleteTeamInvite => { self.update_team_members_state(ctx); self.show_success("Deleted invite", ctx); } UserWorkspacesEvent::DeleteTeamInviteRejected(err) => { self.show_error("Failed to delete invite", Some(err), ctx); } UserWorkspacesEvent::AddDomainRestrictionsSuccess => { self.approve_domains_block_editor .update(ctx, |editor, ctx| { editor.clear_list_of_words(ctx); }); self.update_approved_domains_state(ctx); } UserWorkspacesEvent::AddDomainRestrictionsRejected(err) => { self.show_error("Failed to add domain restriction", Some(err), ctx) } UserWorkspacesEvent::DeleteDomainRestrictionSuccess => { self.update_approved_domains_state(ctx); } UserWorkspacesEvent::DeleteDomainRestrictionRejected(err) => { self.show_error("Failed to delete domain restriction", Some(err), ctx) } UserWorkspacesEvent::GenerateUpgradeLink(upgrade_link) => { ctx.open_url(upgrade_link); } UserWorkspacesEvent::GenerateUpgradeLinkRejected(err) => self.show_error( "Failed to generate upgrade link. Please contact us at feedback@warp.dev", Some(err), ctx, ), UserWorkspacesEvent::GenerateStripeBillingPortalLink(billing_session_link) => { ctx.open_url(billing_session_link); } UserWorkspacesEvent::GenerateStripeBillingPortalLinkRejected(err) => self.show_error( "Failed to generate billing link. Please contact us at feedback@warp.dev", Some(err), ctx, ), UserWorkspacesEvent::ToggleTeamDiscoverabilitySuccess => { self.show_success("Toggled team discoverability", ctx); ctx.notify(); } UserWorkspacesEvent::ToggleTeamDiscoverabilityRejected(err) => { self.show_error("Failed to toggle team discoverability", Some(err), ctx); } UserWorkspacesEvent::JoinTeamWithTeamDiscoverySuccess => { // Force refresh of Warp Drive objects after joining a team UpdateManager::handle(ctx).update(ctx, move |update_manager, ctx| { update_manager.refresh_updated_objects(ctx); }); let message = self .user_workspaces .as_ref(ctx) .current_team() .map_or("Successfully joined team".to_string(), |team| { format!("Successfully joined {}", team.name) }); self.show_success(message, ctx); ctx.notify(); } UserWorkspacesEvent::JoinTeamWithTeamDiscoveryRejected(err) => { self.show_error("Failed to join team", Some(err), ctx); } UserWorkspacesEvent::FetchDiscoverableTeamsSuccess(teams) => { self.discoverable_teams_states = teams .iter() .map(|team| DiscoverableTeamState::new(team.clone())) .collect(); ctx.notify(); } UserWorkspacesEvent::FetchDiscoverableTeamsRejected(e) => { // Don't show toast, only log to sentry log::error!("Failed to fetch discoverable teams: {e:?}"); } UserWorkspacesEvent::TransferTeamOwnershipSuccess => { self.show_success("Successfully transferred team ownership", ctx); ctx.notify(); } UserWorkspacesEvent::TransferTeamOwnershipRejected(err) => { self.show_error("Failed to transfer team ownership", Some(err), ctx); } UserWorkspacesEvent::SetTeamMemberRoleSuccess => { self.update_team_members_state(ctx); self.show_success("Successfully updated team member role", ctx); } UserWorkspacesEvent::SetTeamMemberRoleRejected(err) => { self.show_error("Failed to update team member role", Some(err), ctx); } UserWorkspacesEvent::UpdateWorkspaceSettingsSuccess => { // as of right now, this is only emitted on the billing & usage page } UserWorkspacesEvent::UpdateWorkspaceSettingsRejected(_) => { // as of right now, this is only emitted on the billing & usage page } UserWorkspacesEvent::AiOveragesUpdated => { // AI overages update doesn't affect teams page display } UserWorkspacesEvent::PurchaseAddonCreditsSuccess => { // Addon credits purchase success is handled in billing_and_usage_page } UserWorkspacesEvent::PurchaseAddonCreditsRejected(_) => { // Addon credits purchase rejection is handled in billing_and_usage_page } UserWorkspacesEvent::CodebaseContextEnablementChanged => {} UserWorkspacesEvent::SunsettedToBuildDataUpdated => { // Build plan migration modal is handled by OneTimeModalModel } } } /// Scroll to the team membership settings. If an email is provided, it's prepopulated in the /// invite editor. pub fn open_team_members(&mut self, email: Option<&String>, ctx: &mut ViewContext) { if let Some(email) = email { self.email_invites_block_editor.update( ctx, |invite_editor: &mut WordBlockEditorView, ctx| { invite_editor.clear_list_of_words(ctx); invite_editor.add_word(email, ctx); ctx.focus(&self.email_invites_block_editor); }, ); } self.clipped_scroll_state.scroll_to_position(ScrollTarget { position_id: TEAM_MEMBERS_HEADER_POSITION_ID.to_string(), mode: ScrollToPositionMode::FullyIntoView, }); ctx.notify(); } fn handle_team_update_event( &mut self, event: &TeamUpdateManagerEvent, ctx: &mut ViewContext, ) { match event { TeamUpdateManagerEvent::LeaveError => { let error = "Error leaving team".to_string(); self.show_error(error, None, ctx); } TeamUpdateManagerEvent::LeaveSuccess => { self.show_success("Successfully left team", ctx); ctx.notify(); } TeamUpdateManagerEvent::RenameTeamSuccess => { self.show_success("Successfully renamed team", ctx) } TeamUpdateManagerEvent::RenameTeamError => { self.show_error("Failed to rename team", None, ctx) } } } fn handle_cloud_action_confirmation_dialog_event( &mut self, event: &CloudActionConfirmationDialogEvent, ctx: &mut ViewContext, ) { match event { CloudActionConfirmationDialogEvent::Cancel => { self.show_delete_or_leave_team_confirmation_dialog = false; ctx.notify(); } CloudActionConfirmationDialogEvent::Confirm => { self.leave_team(ctx); self.show_delete_or_leave_team_confirmation_dialog = false; } } } fn handle_transfer_ownership_modal_event( &mut self, event: &TransferOwnershipConfirmationEvent, ctx: &mut ViewContext, ) { match event { TransferOwnershipConfirmationEvent::Confirm { new_owner_uid, team_uid, } => { self.set_team_member_role(*new_owner_uid, *team_uid, MembershipRole::Owner, ctx); self.transfer_ownership_modal_state.close(); ctx.notify(); } TransferOwnershipConfirmationEvent::Cancel => { self.transfer_ownership_modal_state.close(); ctx.notify(); } } } fn handle_transfer_ownership_modal_close_event( &mut self, event: &ModalEvent, ctx: &mut ViewContext, ) { match event { ModalEvent::Close => { self.transfer_ownership_modal_state.close(); ctx.notify(); } } } fn show_transfer_ownership_modal( &mut self, new_owner_email: String, new_owner_uid: UserUid, team_uid: ServerId, ctx: &mut ViewContext, ) { self.transfer_ownership_modal_state .view .update(ctx, |modal, ctx| { modal.body().update(ctx, |body, ctx| { body.set_new_owner(new_owner_email, new_owner_uid, team_uid); ctx.notify(); }); }); self.transfer_ownership_modal_state.open(); ctx.notify(); } fn handle_approve_domains_block_editor_event( &mut self, event: &WordBlockEditorViewEvent, ctx: &mut ViewContext, ) { match event { WordBlockEditorViewEvent::WordListValidityChanged => { let editor = self.approve_domains_block_editor.as_ref(ctx); self.approve_domains_block_editor_state.is_empty = editor.get_list_of_words(ctx).is_empty(); self.approve_domains_block_editor_state.is_valid = editor.get_list_of_invalid_words(ctx).is_empty() && !self.approve_domains_block_editor_state.is_empty; self.approve_domains_block_editor_state.num_chips = editor.num_chips(); ctx.notify(); } WordBlockEditorViewEvent::Enter | WordBlockEditorViewEvent::Navigate(_) => (), WordBlockEditorViewEvent::Escape => ctx.focus_self(), } } fn handle_email_invites_block_editor_event( &mut self, event: &WordBlockEditorViewEvent, ctx: &mut ViewContext, ) { match event { WordBlockEditorViewEvent::WordListValidityChanged => { let editor = self.email_invites_block_editor.as_ref(ctx); self.email_invites_block_editor_state.is_empty = editor.get_list_of_words(ctx).is_empty(); self.email_invites_block_editor_state.is_valid = editor.get_list_of_invalid_words(ctx).is_empty() && !self.email_invites_block_editor_state.is_empty; self.email_invites_block_editor_state.num_chips = editor.num_chips(); ctx.notify(); } WordBlockEditorViewEvent::Enter | WordBlockEditorViewEvent::Navigate(_) => (), WordBlockEditorViewEvent::Escape => ctx.focus_self(), } } fn update_approved_domains_state(&mut self, ctx: &mut ViewContext) { self.update_approved_domains_mouse_state_handles(ctx); self.update_domains_validator(ctx); } fn update_approved_domains_mouse_state_handles(&mut self, ctx: &mut ViewContext) { if let Some(team) = self.user_workspaces.as_ref(ctx).current_team() { self.team_approved_domains_mouse_state_handles = team .invite_link_domain_restrictions .iter() .map(|_| Default::default()) .collect(); } ctx.notify(); } // Updates the validator used by `approve_domains_block_editor` to determine what color // to use when rendering the different word chips on the editor. fn update_domains_validator(&mut self, ctx: &mut ViewContext) { let current_domain_restrictions = if let Some(team) = self.user_workspaces.as_ref(ctx).current_team() { team.invite_link_domain_restrictions .iter() .map(|domain_restriction| domain_restriction.domain.clone()) .collect() } else { Vec::new() }; self.approve_domains_block_editor .update(ctx, |editor, ctx| { editor.with_validator( ctx, Box::new(move |word| { // word chip is a valid domain restriction if it's parsable AND it isn't already an existing domain restriction let lowercase_word = word.to_ascii_lowercase(); Self::is_valid_domain(&lowercase_word) && !current_domain_restrictions .iter() .any(|s| s == &lowercase_word) }), ); ctx.notify(); }); } fn update_team_members_state(&mut self, ctx: &mut ViewContext) { self.update_team_member_mouse_state_handles(ctx); self.update_email_validator(ctx); self.update_team_name(ctx); } fn update_team_member_mouse_state_handles(&mut self, ctx: &mut ViewContext) { if let Some(team) = self.user_workspaces.as_ref(ctx).current_team() { let total_length = team.pending_email_invites.len() + team.members.len(); self.team_members_mouse_state_handles = (0..total_length).map(|_| Default::default()).collect(); } ctx.notify(); } fn update_team_name(&mut self, ctx: &mut ViewContext) { if let Some(team) = self.user_workspaces.as_ref(ctx).current_team() { let team_name = team.name.clone(); self.rename_team_editor.update(ctx, |editor, ctx| { editor.handle_action(&ClickableTextInputAction::UpdateText(team_name), ctx) }); } } // Updates the validator used by `email_invites_block_editor` to determine what color // to use when rendering the different word chips on the editor. fn update_email_validator(&mut self, ctx: &mut ViewContext) { let (member_emails, invitee_emails) = if let Some(team) = self.user_workspaces.as_ref(ctx).current_team() { ( team.members .iter() .map(|member| member.email.clone()) .collect(), team.pending_email_invites .iter() .map(|invite| invite.invitee_email.clone()) .collect(), ) } else { (Vec::new(), Vec::new()) }; self.email_invites_block_editor.update(ctx, |editor, ctx| { editor.with_validator( ctx, Box::new(move |word| { // word chip is a valid email if its parsable AND it isn't already a team member / invitee let lowercase_word = word.to_ascii_lowercase(); EmailAddress::is_valid(&lowercase_word) && !member_emails.iter().any(|s| s == &lowercase_word) && !invitee_emails.iter().any(|s| s == &lowercase_word) }), ); ctx.notify(); }); } fn enable_confirmation_dialog_confirm_button(&mut self, ctx: &mut ViewContext) { self.delete_or_leave_team_confirmation_dialog .update(ctx, |dialog, _ctx| { dialog.set_confirmation_button_enabled(true); }) } fn show_toast( &mut self, message: impl Into, flavor: ToastFlavor, ctx: &mut ViewContext, ) { ctx.emit(TeamsPageViewEvent::ShowToast { message: message.into(), flavor, }); ctx.notify(); } fn show_success(&mut self, message: impl Into, ctx: &mut ViewContext) { self.show_toast(message, ToastFlavor::Success, ctx); } fn show_error( &mut self, error_msg: impl Into, error: Option<&anyhow::Error>, ctx: &mut ViewContext, ) { let message = error_msg.into(); self.show_toast(message.clone(), ToastFlavor::Error, ctx); // Log error to sentry if let Some(error) = error { log::error!("{message}: {error:#}"); } else { log::error!("{message}"); } } fn change_invite_view_option( &mut self, view_option: &TeamsInviteOption, ctx: &mut ViewContext, ) { self.invite_view = *view_option; self.update_team_members_state(ctx); } fn copy_invite_link(&mut self, link: &str, ctx: &mut ViewContext) { ctx.clipboard() .write(ClipboardContent::plain_text(link.to_string())); self.show_toast("Link copied to clipboard!", ToastFlavor::Default, ctx); } fn remove_user_from_team( &mut self, user_uid: UserUid, team_uid: ServerId, ctx: &mut ViewContext, ) { self.user_workspaces .update(ctx, move |user_workspaces, ctx| { user_workspaces.remove_user_from_team( user_uid, team_uid, CloudObjectEventEntrypoint::TeamSettings, ctx, ); }); } fn leave_team(&mut self, ctx: &mut ViewContext) { let team_uid = self.user_workspaces.as_ref(ctx).current_team_uid(); if let Some(team_uid) = team_uid { TeamUpdateManager::handle(ctx).update(ctx, |manager, ctx| { manager.leave_team(team_uid, CloudObjectEventEntrypoint::TeamSettings, ctx) }); } } fn create_team(&mut self, ctx: &mut ViewContext) { let team_name = self.create_team_editor.as_ref(ctx).buffer_text(ctx); TeamUpdateManager::handle(ctx).update(ctx, |manager, ctx| { manager.create_team( team_name, CloudObjectEventEntrypoint::TeamSettings, Some(self.checkbox_value), ctx, ); }); ctx.dispatch_typed_action(&WorkspaceAction::OpenWarpDrive); } fn set_team_member_role( &mut self, user_uid: UserUid, team_uid: ServerId, role: MembershipRole, ctx: &mut ViewContext, ) { self.user_workspaces .update(ctx, move |user_workspaces, ctx| { user_workspaces.set_team_member_role(user_uid, team_uid, role, ctx); }); } fn is_valid_domain(domain: &str) -> bool { DOMAIN_NAME_REGEX.is_match(domain) } fn add_domain_restrictions(&mut self, team_uid: ServerId, ctx: &mut ViewContext) { let editor = self.approve_domains_block_editor.as_ref(ctx); // Verify no invalid domains before continuing let invalid_domains = editor.get_list_of_invalid_words(ctx); if !invalid_domains.is_empty() { let error = format!("Invalid domains: {}", invalid_domains.len()); self.show_error(error, None, ctx); return; } // Don't do anything if list of domains is empty let domains = editor.get_list_of_words(ctx); if domains.is_empty() { return; } // Lowercase and deduplicate domains let unique_domains: Vec = domains .into_iter() .map(|word| word.to_ascii_lowercase()) .collect::>() .into_iter() .collect(); self.show_success( format!("Domain restrictions added: {}", unique_domains.len()), ctx, ); self.user_workspaces .update(ctx, move |user_workspaces, ctx| { user_workspaces.add_invite_link_domain_restrictions(team_uid, unique_domains, ctx); }); } fn delete_domain_restriction( &mut self, team_uid: ServerId, domain_uid: ServerId, ctx: &mut ViewContext, ) { self.user_workspaces .update(ctx, move |user_workspaces, ctx| { user_workspaces.delete_invite_link_domain_restriction(team_uid, domain_uid, ctx); }); } fn send_email_invites(&mut self, team_uid: ServerId, ctx: &mut ViewContext) { let editor = self.email_invites_block_editor.as_ref(ctx); // Verify no invalid emails before continuing let invalid_emails = editor.get_list_of_invalid_words(ctx); if !invalid_emails.is_empty() { let error = format!("Invalid emails: {}", invalid_emails.len()); self.show_error(error, None, ctx); return; } // Don't do anything if list of emails is empty let emails = editor.get_list_of_words(ctx); if emails.is_empty() { return; } // Lowercase and deduplicate emails let unique_emails: Vec = emails .into_iter() .map(|word| word.to_ascii_lowercase()) .collect::>() .into_iter() .collect(); let message = if unique_emails.len() == 1 { "Your invite is on the way!".to_string() } else { format!("Your {} invites are on the way!", unique_emails.len()) }; self.show_success(message, ctx); self.user_workspaces .update(ctx, move |user_workspaces, ctx| { user_workspaces.send_email_invites(team_uid, unique_emails, ctx); }) } fn set_is_invite_link_enabled( &mut self, team_uid: ServerId, new_value: bool, ctx: &mut ViewContext, ) { self.user_workspaces .update(ctx, move |user_workspaces, ctx| { user_workspaces.set_is_invite_link_enabled(team_uid, new_value, ctx); }); } fn reset_invite_links(&mut self, team_uid: ServerId, ctx: &mut ViewContext) { self.user_workspaces .update(ctx, move |user_workspaces, ctx| { user_workspaces.reset_invite_links(team_uid, ctx); }); } fn set_team_discoverability( &mut self, team_uid: ServerId, discoverable: bool, ctx: &mut ViewContext, ) { self.user_workspaces .update(ctx, move |user_workspaces, ctx| { user_workspaces.set_team_discoverability(team_uid, discoverable, ctx); }); } fn join_team_with_team_discovery(&mut self, team_uid: ServerId, ctx: &mut ViewContext) { self.user_workspaces .update(ctx, move |user_workspaces, ctx| { user_workspaces.join_team_with_team_discovery(team_uid, ctx); }); } fn delete_team_invite( &mut self, team_uid: ServerId, invitee_email: String, ctx: &mut ViewContext, ) { self.user_workspaces .update(ctx, move |user_workspaces, ctx| { user_workspaces.delete_team_invite(team_uid, invitee_email, ctx); }); } fn generate_upgrade_link(&mut self, team_uid: ServerId, ctx: &mut ViewContext) { self.user_workspaces .update(ctx, move |user_workspaces, ctx| { user_workspaces.generate_upgrade_link(team_uid, ctx); }); } fn generate_stripe_billing_portal_link( &mut self, team_uid: ServerId, ctx: &mut ViewContext, ) { self.user_workspaces .update(ctx, move |user_workspaces, ctx| { user_workspaces.generate_stripe_billing_portal_link(team_uid, ctx); }); } fn handle_editor_event(&mut self, event: &EditorEvent, ctx: &mut ViewContext) { match event { EditorEvent::Edited(_) => ctx.notify(), EditorEvent::Escape => ctx.focus_self(), _ => (), } } fn handle_rename_team_editor_event( &mut self, event: &ClickableTextInputEvent, ctx: &mut ViewContext, ) { match event { ClickableTextInputEvent::Submit(new_name) => { TeamUpdateManager::handle(ctx).update(ctx, |manager, ctx| { manager.rename_team(new_name.to_string(), ctx) }); self.rename_team_editor.update(ctx, |editor, ctx| { editor.handle_action( &ClickableTextInputAction::UpdateText(new_name.to_string()), ctx, ) }); ctx.notify(); } } } /// Find view handle of first input. If user team does not exist, this will default to /// the create_team_editor. fn focus_on_next_input(&mut self, ctx: &mut ViewContext) { let workspaces: &UserWorkspaces = self.user_workspaces.as_ref(ctx); if let Some(team) = workspaces.current_team() { if team.organization_settings.is_invite_link_enabled { ctx.focus(&self.approve_domains_block_editor); } else { ctx.focus(&self.email_invites_block_editor); } } else { ctx.focus(&self.create_team_editor); } ctx.notify(); } fn team_to_item_list(&self, team: &Team, current_user_email: &str) -> Vec { let mut combined = Vec::new(); let current_user_has_admin_permissions = team.has_admin_permissions(current_user_email); let current_user_has_owner_permissions = team.has_owner_permissions(current_user_email); // pending email invites team.pending_email_invites.iter().for_each(|email_invite| { let state = if email_invite.expired { ItemState::Expired } else { ItemState::Pending }; let actions = if current_user_has_admin_permissions { vec![ItemAction { icon: Icon::X, label: "Cancel invite".to_string(), action: TeamsPageAction::DeletePendingEmailInvitation { team_uid: team.uid, invitee_email: email_invite.invitee_email.clone(), }, }] } else { vec![] }; combined.push(Item { text: email_invite.invitee_email.clone(), actions, state, }); }); // team members team.members.iter().for_each(|member| { let team_member_has_owner_permissions = team.has_owner_permissions(&member.email); let team_member_has_admin_permissions = team.has_admin_permissions(&member.email); let state = if team_member_has_owner_permissions { ItemState::Owner } else if team_member_has_admin_permissions { ItemState::Admin } else { ItemState::Valid }; let mut actions = Vec::new(); if member.email != *current_user_email { // Owner can transfer ownership to non-owner members if current_user_has_owner_permissions && !team_member_has_owner_permissions { actions.push(ItemAction { icon: Icon::Users, label: "Transfer ownership".to_string(), action: TeamsPageAction::ShowTransferOwnershipModal { new_owner_email: member.email.clone(), new_owner_uid: member.uid, team_uid: team.uid, }, }); } // Admins can promote and demote other admins if team.is_multi_admin_enabled() && current_user_has_admin_permissions && !team_member_has_owner_permissions { if team_member_has_admin_permissions { actions.push(ItemAction { icon: Icon::ArrowDown, label: "Demote from admin".to_string(), action: TeamsPageAction::SetTeamMemberRole { team_uid: team.uid, user_uid: member.uid, role: MembershipRole::User, }, }); } else { actions.push(ItemAction { icon: Icon::ArrowUp, label: "Promote to admin".to_string(), action: TeamsPageAction::SetTeamMemberRole { team_uid: team.uid, user_uid: member.uid, role: MembershipRole::Admin, }, }); } } // Admins can remove non-owner members if current_user_has_admin_permissions && !team_member_has_owner_permissions { actions.push(ItemAction { icon: Icon::X, label: "Remove from team".to_string(), action: TeamsPageAction::RemoveUserFromTeam { user_uid: member.uid, team_uid: team.uid, }, }); } } combined.push(Item { text: member.email.clone(), actions, state, }); }); combined } } impl SettingsPageMeta for TeamsPageView { fn section() -> SettingsSection { SettingsSection::Teams } fn on_page_selected(&mut self, allow_steal_focus: bool, ctx: &mut ViewContext) { if allow_steal_focus { self.focus_on_next_input(ctx); } self.create_team_editor.update(ctx, |editor, ctx| { editor.clear_buffer_and_reset_undo_stack(ctx); }); // We want to immediately see if the user is part of a workspace rather than wait for the next poll. std::mem::drop( TeamUpdateManager::handle(ctx) .update(ctx, |manager, ctx| manager.refresh_workspace_metadata(ctx)), ); self.update_team_members_state(ctx); self.update_approved_domains_state(ctx); if NetworkStatus::as_ref(ctx).is_online() { self.user_workspaces .update(ctx, move |user_workspaces, ctx| { user_workspaces.fetch_discoverable_teams(ctx); }); } } fn should_render(&self, _ctx: &AppContext) -> bool { true } fn on_tab_pressed(&mut self, ctx: &mut ViewContext) { self.focus_on_next_input(ctx); } fn update_filter(&mut self, query: &str, ctx: &mut ViewContext) -> MatchData { self.page.update_filter(query, ctx) } fn scroll_to_widget(&mut self, widget_id: &'static str) { self.page.scroll_to_widget(widget_id) } fn clear_highlighted_widget(&mut self) { self.page.clear_highlighted_widget(); } } impl From> for SettingsPageViewHandle { fn from(view_handle: ViewHandle) -> Self { SettingsPageViewHandle::Teams(view_handle) } } #[derive(Default)] struct TeamsWidget { mouse_state_handles: TeamsWidgetMouseHandles, } impl TeamsWidget { /// Gets the per-seat costs (monthly and yearly) for the current team plan. /// Returns None if pricing info is unavailable or the plan doesn't support per-seat pricing. fn get_per_seat_costs( &self, team_metadata: &Team, pricing_info_model: &PricingInfoModel, ) -> Option<(f64, f64)> { let stripe_subscription_plan = (&team_metadata.billing_metadata).try_into().ok()?; let plan_pricing = pricing_info_model.plan_pricing(&stripe_subscription_plan)?; let monthly_cost = plan_pricing.monthly_plan_price_per_month_usd_cents as f64 / 100.; let yearly_cost = plan_pricing.yearly_plan_price_per_month_usd_cents as f64 * 12. / 100.; Some((monthly_cost, yearly_cost)) } fn render_team_member_cost_info( &self, team_metadata: &Team, pricing_info_model: &PricingInfoModel, appearance: &Appearance, has_admin_permissions: bool, ) -> Box { let prorated_message = if has_admin_permissions { "You'll be charged for a portion of the team member's usage of Warp." } else { "Your admin will be charged for a portion of the team member's usage of Warp." }; let additional_members_cost_money_msg = if let Some((monthly_cost, yearly_cost)) = self.get_per_seat_costs(team_metadata, pricing_info_model) { format!("Additional members are billed at your plan's per-user rate: ${monthly_cost:.0}/month or ${yearly_cost:.0}/year, depending on your billing interval. {prorated_message}") } else { format!( "Additional members are billed at your plan's per-user rate. {prorated_message}" ) }; let horizontal_padding = 16.; let theme = appearance.theme(); let currency_icon = Container::new( ConstrainedBox::new( Icon::CoinsStacked .to_warpui_icon(appearance.theme().active_ui_text_color().with_opacity(90)) .finish(), ) .with_max_height(20.) .with_max_width(20.) .finish(), ) .with_margin_right(horizontal_padding) .finish(); let member_pricing_header = Container::new(self.render_subsection_header("Team members".to_owned(), appearance)) .with_margin_bottom(8.) .finish(); let member_pricing_info = self.render_sub_text(additional_members_cost_money_msg, appearance, None); let text_column = Flex::column() .with_child(member_pricing_header) .with_child(member_pricing_info); let content_row = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_size(MainAxisSize::Max) .with_child(currency_icon) .with_child(Shrinkable::new(1., text_column.finish()).finish()); // Wrap in a container with styling similar to Alert Container::new(content_row.finish()) .with_vertical_padding(12.) .with_horizontal_padding(horizontal_padding) .with_background(themes::theme::Fill::from(internal_colors::neutral_4(theme))) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.))) .with_border( Border::all(1.) .with_border_fill(themes::theme::Fill::from(internal_colors::neutral_3(theme))), ) .finish() } fn render_team_management_page( &self, team_metadata: &Team, cloud_model: &CloudModel, ai_request_usage_model: &AIRequestUsageModel, view: &TeamsPageView, appearance: &Appearance, app: &AppContext, ) -> Box { let current_user_email = view.auth_state.user_email().unwrap_or_default(); let has_admin_permissions = team_metadata.has_admin_permissions(¤t_user_email); let is_owner = team_metadata.has_owner_permissions(¤t_user_email); let remaining_workspace_credits = ai_request_usage_model.total_current_workspace_bonus_credits_remaining(app); let delete_disabled_reason = team_metadata .get_delete_disabled_reason(¤t_user_email, remaining_workspace_credits); let mut main_content = Flex::column(); let chip_editor_style = UiComponentStyles::default() .set_background(appearance.theme().background().into()) .set_border_radius(CornerRadius::with_all(Radius::Pixels(3.))) .set_border_width(1.) .set_border_color(appearance.theme().foreground().with_opacity(20).into()) .set_padding(Coords::uniform(0.).top(4.).right(5.)); // 1) Team name header main_content.add_child(self.render_header( has_admin_permissions, team_metadata, view, appearance, )); // has_plan_limit will be true if the team has any shared object policy that // is not unlimited. let has_plan_limit = team_metadata .billing_metadata .tier .shared_notebooks_policy .map(|policy| !policy.is_unlimited) .unwrap_or_else(|| false) || team_metadata .billing_metadata .tier .shared_workflows_policy .map(|policy| !policy.is_unlimited) .unwrap_or_else(|| false); if has_plan_limit { // Render plan usage and limits main_content.add_child( Container::new(self.render_plan_usage(team_metadata, cloud_model, appearance, app)) .with_padding_top(CONTENT_SEPARATION_PADDING) .finish(), ) } // 2) Horizontal separator main_content.add_child( Container::new(render_separator(appearance)) .with_padding_top(CONTENT_SEPARATION_PADDING) .with_margin_bottom(HORIZONTAL_BAR_TO_SUB_HEADER_PADDING) .finish(), ); // 3) Team invitation flows (invite link / email invites) if let Some(workspace_size_policy) = team_metadata.billing_metadata.tier.workspace_size_policy { main_content.add_child(self.render_team_invitation_section( team_metadata, has_admin_permissions, view, appearance, chip_editor_style, workspace_size_policy, app, )); }; // 4) Team members main_content.add_child(self.render_team_members_section( team_metadata, ¤t_user_email, view, appearance, )); // 5) Team discoverability toggle if team_metadata.billing_metadata.customer_type != CustomerType::Enterprise && has_admin_permissions && team_metadata.is_eligible_for_discovery { main_content.add_child(self.render_discoverability_toggle_section( team_metadata, ¤t_user_email, appearance, )) } // 6) Deleting/leaving teams let mut button_row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center); let is_enterprise_team = team_metadata.billing_metadata.customer_type == CustomerType::Enterprise; // We don't allow users on enterprise teams to leave or delete their team, // since their enterprise agreement is tied to it, and it helps enforce that others // can't join some other team that doesn't have stricter security guarantees if !is_enterprise_team { button_row.add_child( Container::new(self.render_leave_or_delete_team_button( is_owner, delete_disabled_reason.is_none(), view, appearance, )) .with_padding_right(24.) .finish(), ); } // We show some help text if a team can't be deleted... if let Some(delete_disabled_reason) = delete_disabled_reason { // and if the current user actually has the perms to delete the team if has_admin_permissions && !is_enterprise_team { button_row.add_child( Container::new(self.render_delete_disabled_help_text( delete_disabled_reason, team_metadata.uid, appearance, )) .with_padding_right(24.) .finish(), ); } } main_content.add_child(button_row.finish()); main_content.finish() } fn render_header( &self, has_admin_permissions: bool, team: &Team, view: &TeamsPageView, appearance: &Appearance, ) -> Box { let mut team_name_header = Flex::row() .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_size(MainAxisSize::Max); let mut left_side = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_alignment(MainAxisAlignment::Start); if has_admin_permissions { left_side.add_child(ChildView::new(&view.rename_team_editor).finish()); } else { left_side.add_child( Text::new_inline(team.name.clone(), appearance.ui_font_family(), 24.) .with_style(Properties::default().weight(Weight::Bold)) .with_color(appearance.theme().active_ui_text_color().into()) .finish(), ); } if team.billing_metadata.customer_type != CustomerType::Unknown { left_side.add_child( Container::new(render_customer_type_badge( appearance, team.billing_metadata.customer_type.to_display_string(), )) .with_margin_left(12.) .finish(), ); } match team.billing_metadata.delinquency_status { DelinquencyStatus::PastDue => { left_side.add_child( Container::new(self.render_delinquency_badge( appearance, "PAST DUE".into(), themes::theme::Fill::from(*PAST_DUE_BADGE_COLOR).into(), )) .with_margin_left(8.) .finish(), ); } DelinquencyStatus::Unpaid => { left_side.add_child( Container::new(self.render_delinquency_badge( appearance, "UNPAID".into(), themes::theme::Fill::from(*UNPAID_BADGE_COLOR).into(), )) .with_margin_left(8.) .finish(), ); } DelinquencyStatus::NoDelinquency | DelinquencyStatus::TeamLimitExceeded | DelinquencyStatus::Unknown => (), } team_name_header.add_child(left_side.finish()); // Upgrade / billing links if has_admin_permissions { team_name_header.add_child(self.render_billing_links(team, appearance)); } team_name_header.finish() } fn render_contact_support_button(&self, appearance: &Appearance) -> Box { appearance .ui_builder() .button( ButtonVariant::Link, self.mouse_state_handles.enterprise_contact_us_link.clone(), ) .with_text_and_icon_label( TextAndIcon::new( TextAndIconAlignment::IconFirst, "Contact support", Icon::Phone.to_warpui_icon(appearance.theme().accent()), MainAxisSize::Min, MainAxisAlignment::Center, vec2f(14., 14.), ) .with_inner_padding(4.), ) .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(TeamsPageAction::ContactSupport); }) .finish() } fn render_manage_billing_button( &self, team_uid: ServerId, appearance: &Appearance, ) -> Box { appearance .ui_builder() .button( ButtonVariant::Link, self.mouse_state_handles.stripe_billing_portal_link.clone(), ) .with_text_and_icon_label( TextAndIcon::new( TextAndIconAlignment::IconFirst, "Manage billing", Icon::CoinsStacked.to_warpui_icon(appearance.theme().accent()), MainAxisSize::Min, MainAxisAlignment::Center, vec2f(14., 14.), ) .with_inner_padding(4.), ) .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(TeamsPageAction::GenerateStripeBillingPortalLink { team_uid, }); }) .finish() } fn render_admin_panel_button( &self, team_uid: ServerId, appearance: &Appearance, ) -> Box { appearance .ui_builder() .button( ButtonVariant::Link, self.mouse_state_handles.admin_panel_button.clone(), ) .with_text_and_icon_label( TextAndIcon::new( TextAndIconAlignment::IconFirst, "Open admin panel", Icon::Users.to_warpui_icon(appearance.theme().accent()), MainAxisSize::Min, MainAxisAlignment::Center, vec2f(14., 14.), ) .with_inner_padding(4.), ) .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(TeamsPageAction::OpenAdminPanel { team_uid }); }) .finish() } fn render_billing_links(&self, team: &Team, appearance: &Appearance) -> Box { let mut billing_links = Flex::row() .with_main_axis_alignment(MainAxisAlignment::End) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_size(MainAxisSize::Min); let team_uid = team.uid; // For enterprise we actually hide both upgrade/billing links and have a contact support link instead if team.billing_metadata.customer_type == CustomerType::Enterprise { billing_links.add_child( Container::new(self.render_contact_support_button(appearance)) .with_margin_left(12.) .finish(), ); } else { // If the team is upgradeable to self-serve tier, show them the upgrade link. if team.billing_metadata.can_upgrade_to_higher_tier_plan() { let description = if team.billing_metadata.can_upgrade_to_build_plan() { "Upgrade to Build" } else { match team.billing_metadata.customer_type { CustomerType::Prosumer => "Upgrade to Turbo plan", CustomerType::Turbo => "Upgrade to Lightspeed plan", _ => "Compare plans", } }; billing_links.add_child( Container::new(self.render_compare_plans_button( description, self.mouse_state_handles.upgrade_link.clone(), team_uid, appearance, None, )) .with_margin_left(12.) .finish(), ); } else if team.has_billing_history { billing_links.add_child( Container::new(self.render_manage_billing_button(team_uid, appearance)) .with_margin_left(12.) .finish(), ); } } billing_links.add_child( Container::new(self.render_admin_panel_button(team_uid, appearance)) .with_margin_left(12.) .finish(), ); billing_links.finish() } fn render_plan_usage( &self, team: &Team, cloud_model: &CloudModel, appearance: &Appearance, app: &AppContext, ) -> Box { let mut section = Flex::column(); let sub_header_text = match team.billing_metadata.customer_type { CustomerType::Free => "Free plan usage limits", _ => "Plan usage limits", }; section.add_child(self.render_subsection_header(sub_header_text.into(), appearance)); let mut shared_objects_usage_row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center); if let Some(policy) = team.billing_metadata.tier.shared_notebooks_policy { if !policy.is_unlimited { let mut shared_notebooks_column = Flex::column(); shared_notebooks_column.add_child( self.render_plan_usage_header("Shared Notebooks".into(), appearance), ); let num_shared_notebooks = cloud_model .active_notebooks_in_space(Space::Team { team_uid: team.uid }, app) .count(); shared_notebooks_column.add_child( Container::new(self.render_plan_usage_text( format!("{}/{}", num_shared_notebooks, policy.limit), appearance, )) .with_margin_top(4.) .finish(), ); shared_objects_usage_row.add_child( Container::new(shared_notebooks_column.finish()) .with_margin_right(64.) .finish(), ); } } if let Some(policy) = team.billing_metadata.tier.shared_workflows_policy { if !policy.is_unlimited { let mut shared_workflows_column = Flex::column(); shared_workflows_column.add_child( self.render_plan_usage_header("Shared Workflows".into(), appearance), ); let num_shared_workflows = cloud_model .active_workflows_in_space(Space::Team { team_uid: team.uid }, app) .count(); shared_workflows_column.add_child( Container::new(self.render_plan_usage_text( format!("{}/{}", num_shared_workflows, policy.limit), appearance, )) .with_margin_top(4.) .finish(), ); shared_objects_usage_row.add_child(shared_workflows_column.finish()); } } section.add_child( Container::new(shared_objects_usage_row.finish()) .with_margin_top(16.) .finish(), ); section.finish() } #[allow(clippy::too_many_arguments)] fn render_team_invitation_section( &self, team_metadata: &Team, has_admin_permissions: bool, view: &TeamsPageView, appearance: &Appearance, chip_editor_style: UiComponentStyles, workspace_size_policy: WorkspaceSizePolicy, app: &AppContext, ) -> Box { let mut invitation_section = Flex::column(); let pricing_info_model = view.pricing_info_model.as_ref(app); if team_metadata.billing_metadata.is_on_stripe_paid_plan() { let pricing_alert = self.render_team_member_cost_info( team_metadata, pricing_info_model, appearance, has_admin_permissions, ); invitation_section.add_child( Container::new(pricing_alert) .with_padding_bottom(24.) .finish(), ); } // Invite by link section // Only show invite-by-link if user is admin OR if invite links are enabled if team_metadata.organization_settings.is_invite_link_enabled || has_admin_permissions { invitation_section.add_child(self.render_invite_by_link_section( team_metadata, has_admin_permissions, view, appearance, chip_editor_style, )); } // Invite by email invitation_section.add_child(self.render_invite_by_email_section( team_metadata, view, appearance, chip_editor_style, workspace_size_policy, has_admin_permissions, )); invitation_section.finish() } fn render_invite_by_link_section( &self, team: &Team, has_admin_permissions: bool, view: &TeamsPageView, appearance: &Appearance, chip_editor_style: UiComponentStyles, ) -> Box { let mut section = Flex::column(); let mut invite_by_link_header_row = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_size(MainAxisSize::Max) .with_main_axis_alignment(MainAxisAlignment::SpaceBetween); // 1) "Invite by Link" subsection header invite_by_link_header_row .add_child(self.render_subsection_header("Invite by Link".to_owned(), appearance)); // 1.1) Toggle to the right of header only renders if user is admin if has_admin_permissions { let team_uid = team.uid; let current_state = team.organization_settings.is_invite_link_enabled; let invite_by_link_toggle = appearance .ui_builder() .switch(self.mouse_state_handles.invite_by_link_toggle_state.clone()) .check(team.organization_settings.is_invite_link_enabled) .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(TeamsPageAction::ToggleIsInviteLinkEnabled { team_uid, current_state, }) }); invite_by_link_header_row.add_child(invite_by_link_toggle.finish()); } section.add_child(invite_by_link_header_row.finish()); // 2) Instruction text for invite by link toggle if has_admin_permissions { section.add_child( Container::new(self.render_sub_text( INVITE_LINK_TOGGLE_INSTRUCTIONS.into(), appearance, Some(Coords::uniform(0.).right(48.)), )) .with_padding_top(8.) .finish(), ); } // 3) Invite link + domain restrictions // Only renders if invite by link is enabled if team.organization_settings.is_invite_link_enabled { section.add_child(self.render_copy_link_row(team, appearance)); // Render invite link reset text if admin user if has_admin_permissions { let team_uid = team.uid; section.add_child( Align::new( appearance .ui_builder() .link( "Reset links".into(), None, Some(Box::new(move |ctx| { ctx.dispatch_typed_action(TeamsPageAction::ResetInviteLinks { team_uid, }); })), self.mouse_state_handles.reset_invite_links_button.clone(), ) .soft_wrap(false) .build() .with_margin_top(8.) .finish(), ) .left() .finish(), ); } // Don't render restricted domains section if user is not an admin AND there are no domain restrictions if has_admin_permissions || !team.invite_link_domain_restrictions.is_empty() { section.add_child(self.render_approved_domains_section( team, has_admin_permissions, view, appearance, chip_editor_style, )); } } section.finish() } fn render_invite_by_email_section( &self, team: &Team, view: &TeamsPageView, appearance: &Appearance, chip_editor_style: UiComponentStyles, policy: WorkspaceSizePolicy, has_admin_permissions: bool, ) -> Box { let mut section = Flex::column(); // "Invite by Email" subsection header section.add_child( Container::new(self.render_subsection_header("Invite by Email".to_owned(), appearance)) .with_padding_top(CONTENT_SEPARATION_PADDING) .with_padding_bottom(8.) .finish(), ); match team.billing_metadata.delinquency_status { DelinquencyStatus::Unknown | DelinquencyStatus::NoDelinquency => { if policy.is_unlimited || policy.limit > team .members .len() .try_into() .expect("team size should be within max i64 range") { // Instruction text for invite by email expiry section.add_child( Container::new(self.render_sub_text( INVITE_BY_EMAIL_EXPIRY_INSTRUCTIONS.into(), appearance, Some(Coords::uniform(0.).right(48.)), )) .with_padding_bottom(TEXT_FIELD_TOP_PADDING) .finish(), ); // Email invite editor + button section.add_child( Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child( Shrinkable::new( 1., TextInput::new( view.email_invites_block_editor.clone(), chip_editor_style, ) .build() .finish(), ) .finish(), ) .with_child( self.render_send_email_invites_button(team.uid, view, appearance), ) .finish(), ); if !view.email_invites_block_editor_state.is_valid && !view.email_invites_block_editor_state.is_empty && view.email_invites_block_editor_state.num_chips > 0 { section.add_child( Container::new(self.render_error_sub_text( INVALID_EMAILS_INSTRUCTIONS.into(), appearance, )) .with_padding_top(8.) .finish(), ) } } else { // Team is not delinquent, but has hit their team size limit. let team_uid = team.uid; let limit_hit_text = if team.billing_metadata.can_upgrade_to_higher_tier_plan() { let mut limit_hit_text_and_upgrade_button = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_size(MainAxisSize::Max) .with_main_axis_alignment(MainAxisAlignment::SpaceBetween); let text = if has_admin_permissions { LIMIT_HIT_ADMIN_TEXT } else { LIMIT_HIT_NON_ADMIN_TEXT }; limit_hit_text_and_upgrade_button.add_child( Shrinkable::new( 1., self.render_sub_text( text.into(), appearance, Some(Coords::uniform(0.).right(12.)), ), ) .finish(), ); limit_hit_text_and_upgrade_button.add_child( self.render_compare_plans_button( "Compare plans", self.mouse_state_handles .invite_by_email_upgrade_button .clone(), team_uid, appearance, Some( self.button_properties() .set_width(COMPARE_PLANS_BUTTON_WIDTH), ), ), ); limit_hit_text_and_upgrade_button.finish() } else { // Otherwise, they've hit the team size limit, but are not able // to upgrade to team plan (e.g. they're on a tier that has // a limit on # of seats but it's not one of free/free preview/legacy/prosumer). // In that case show message to contact their admin/support with no // button to `/upgrade`. let text = if has_admin_permissions { LIMIT_HIT_ADMIN_NOT_AUTO_UPGRADEABLE_TEXT } else { LIMIT_HIT_NON_ADMIN_TEXT }; self.render_sub_text( text.into(), appearance, Some(Coords::uniform(0.).right(48.)), ) }; section.add_child( Container::new(limit_hit_text) .with_padding_bottom(CONTENT_SEPARATION_PADDING) .finish(), ); } } DelinquencyStatus::PastDue | DelinquencyStatus::Unpaid => { // If team has hit their team size limit: let team_uid = team.uid; let delinquent_text = if has_admin_permissions { // If the user is an admin, and team is on paid stripe plan, // then provide a clickable link to manage their billing. if team.billing_metadata.is_on_stripe_paid_plan() { let mut limit_exceeded_with_upgrade_text = Flex::column(); limit_exceeded_with_upgrade_text.add_child(self.render_sub_text( DELINQUENT_ADMIN_SELF_SERVE_LINE_1_TEXT.into(), appearance, None, )); let mut manage_billing_link_line = Flex::row(); manage_billing_link_line.add_child(self.render_sub_text( DELINQUENT_ADMIN_SELF_SERVE_LINE_2_PREFIX_TEXT.into(), appearance, None, )); manage_billing_link_line.add_child( appearance .ui_builder() .link( DELINQUENT_ADMIN_SELF_SERVE_LINE_2_LINK_TEXT.into(), None, Some(Box::new(move |ctx| { ctx.dispatch_typed_action( TeamsPageAction::GenerateStripeBillingPortalLink { team_uid, }, ); })), self.mouse_state_handles .invite_by_email_billing_portal_link .clone(), ) .soft_wrap(false) .build() .finish(), ); manage_billing_link_line.add_child(self.render_sub_text( DELINQUENT_ADMIN_SELF_SERVE_LINE_2_SUFFIX_TEXT.into(), appearance, None, )); limit_exceeded_with_upgrade_text .add_child(manage_billing_link_line.finish()); limit_exceeded_with_upgrade_text.finish() } else { // Otherwise, they're in delinquent state, but are not able to // update their billing information like self-serve tier (e.g. // delinquent enterprise customer). In that case show message to // contact support instead. self.render_sub_text( DELINQUENT_ADMIN_NON_SELF_SERVE_TEXT.into(), appearance, Some(Coords::uniform(0.).right(48.)), ) } } else { // If user is not admin, show them a message that asks them to contact // their admin to fix their billing instead. self.render_sub_text( DELINQUENT_NON_ADMIN_TEXT.into(), appearance, Some(Coords::uniform(0.).right(48.)), ) }; section.add_child( Container::new(delinquent_text) .with_padding_bottom(CONTENT_SEPARATION_PADDING) .finish(), ); } DelinquencyStatus::TeamLimitExceeded => { // If team has hit their team size limit: let team_uid = team.uid; let limit_exceeded_text = if team.billing_metadata.can_upgrade_to_higher_tier_plan() { let mut limit_exceeded_text_and_upgrade_button = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_size(MainAxisSize::Max) .with_main_axis_alignment(MainAxisAlignment::SpaceBetween); let text = if has_admin_permissions { TEAM_LIMIT_EXCEEDED_ADMIN_UPGRADEABLE } else { TEAM_LIMIT_EXCEEDED_NON_ADMIN_TEXT }; limit_exceeded_text_and_upgrade_button.add_child( Shrinkable::new( 1., self.render_sub_text( text.into(), appearance, Some(Coords::uniform(0.).right(12.)), ), ) .finish(), ); limit_exceeded_text_and_upgrade_button.add_child( self.render_compare_plans_button( "Compare plans", self.mouse_state_handles .invite_by_email_upgrade_button .clone(), team_uid, appearance, Some( self.button_properties() .set_width(COMPARE_PLANS_BUTTON_WIDTH), ), ), ); limit_exceeded_text_and_upgrade_button.finish() } else { // Otherwise, they've hit the team size limit, but are not able // to upgrade to team plan (e.g. they're on a tier that has // a limit on # of seats but it's not one of free/free preview/legacy/prosumer). // In that case show message to contact their admin/support with no // button to `/upgrade`. let text = if has_admin_permissions { TEAM_LIMIT_EXCEEDED_ADMIN_NOT_AUTO_UPGRADEABLE_TEXT } else { TEAM_LIMIT_EXCEEDED_NON_ADMIN_TEXT }; self.render_sub_text( text.into(), appearance, Some(Coords::uniform(0.).right(48.)), ) }; section.add_child( Container::new(limit_exceeded_text) .with_padding_bottom(CONTENT_SEPARATION_PADDING) .finish(), ); } }; Container::new(section.finish()) .with_padding_bottom(CONTENT_SEPARATION_PADDING) .finish() } fn render_team_members_section( &self, team: &Team, user_email: &str, view: &TeamsPageView, appearance: &Appearance, ) -> Box { let mut section = Flex::column().with_main_axis_size(MainAxisSize::Min); // 1) "Team Members" header section.add_child( SavePosition::new( Container::new( self.render_subsection_header("Team Members".to_owned(), appearance), ) .with_padding_bottom(16.) .finish(), TEAM_MEMBERS_HEADER_POSITION_ID, ) .finish(), ); // 2) List of team members section.add_child(self.render_item_list( view.team_to_item_list(team, user_email), view.team_members_mouse_state_handles.clone(), view, appearance, )); section.finish() } fn render_approved_domains_section( &self, team: &Team, has_admin_permissions: bool, view: &TeamsPageView, appearance: &Appearance, chip_editor_style: UiComponentStyles, ) -> Box { let mut section = Flex::column(); // 1) "Restrict by domain" header section.add_child( Container::new(self.render_sub_header("Restrict by domain".to_owned(), appearance)) .with_padding_top(16.) .finish(), ); // 2) Instruction text for domain restrictions + Domain approval mechanism (input box + button) if has_admin_permissions { section.add_child( Container::new(self.render_sub_text( INVITE_LINK_DOMAIN_RESTRICTIONS_INSTRUCTIONS.into(), appearance, Some(Coords::uniform(0.).right(48.)), )) .with_padding_top(8.) .finish(), ); section.add_child( Container::new( Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child( Shrinkable::new( 1., TextInput::new( view.approve_domains_block_editor.clone(), chip_editor_style, ) .build() .finish(), ) .finish(), ) .with_child(self.render_approve_domains_button(team.uid, view, appearance)) .finish(), ) .with_padding_top(TEXT_FIELD_TOP_PADDING) .finish(), ); if !view.approve_domains_block_editor_state.is_valid && !view.approve_domains_block_editor_state.is_empty && view.approve_domains_block_editor_state.num_chips > 0 { section.add_child( Container::new( self.render_error_sub_text(INVALID_DOMAINS_INSTRUCTIONS.into(), appearance), ) .with_padding_top(8.) .finish(), ) } } // 3) List of approved domains let domains_as_items: Vec = team .invite_link_domain_restrictions .iter() .map(|domain_restriction| { let actions = if has_admin_permissions { vec![ItemAction { icon: Icon::X, label: "Remove domain".to_string(), action: TeamsPageAction::DeleteDomainRestriction { domain_uid: domain_restriction.uid, team_uid: team.uid, }, }] } else { vec![] }; Item { text: domain_restriction.domain.clone(), actions, state: ItemState::Valid, } }) .collect(); if !domains_as_items.is_empty() { section.add_child( Container::new(self.render_item_list( domains_as_items, view.team_approved_domains_mouse_state_handles.clone(), view, appearance, )) .with_padding_top(CONTENT_SEPARATION_PADDING) .finish(), ); } section.finish() } fn render_approve_domains_button( &self, team_uid: ServerId, view: &TeamsPageView, appearance: &Appearance, ) -> Box { // Only render enabled button with action if domain list is valid. let (action, variant) = if view.approve_domains_block_editor_state.is_valid { ( Some(TeamsPageAction::AddDomainRestrictions { team_uid }), ButtonVariant::Accent, ) } else { (None, ButtonVariant::Basic) }; Container::new(self.render_button( APPROVE_DOMAINS_BUTTON_LABEL, variant, self.mouse_state_handles.approve_domains_button.clone(), action, self.button_properties(), appearance, )) .with_padding_left(COPY_LINK_LEFT_PADDING) .finish() } fn render_send_email_invites_button( &self, team_uid: ServerId, view: &TeamsPageView, appearance: &Appearance, ) -> Box { // Only render enabled button with action if email list is valid. let (action, variant) = if view.email_invites_block_editor_state.is_valid { ( Some(TeamsPageAction::SendEmailInvites { team_uid }), ButtonVariant::Accent, ) } else { (None, ButtonVariant::Basic) }; Container::new(self.render_button( SEND_EMAIL_INVITES_BUTTON_LABEL, variant, self.mouse_state_handles.send_email_invites_button.clone(), action, self.button_properties(), appearance, )) .with_padding_left(COPY_LINK_LEFT_PADDING) .finish() } fn button_properties(&self) -> UiComponentStyles { UiComponentStyles { font_weight: Some(Weight::Semibold), width: Some(BUTTON_WIDTH), height: Some(BUTTON_HEIGHT), padding: Some(Coords { top: 8., bottom: 8., left: 12., right: 12., }), font_size: Some(14.), ..Default::default() } } fn render_discoverability_toggle_section( &self, team: &Team, current_user_email: &str, appearance: &Appearance, ) -> Box { let mut section = Flex::column(); // Header let mut discoverable_header_row = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_size(MainAxisSize::Max) .with_main_axis_alignment(MainAxisAlignment::SpaceBetween); discoverable_header_row.add_child( Container::new(self.render_sub_header("Make team discoverable".to_owned(), appearance)) .with_padding_top(CONTENT_SEPARATION_PADDING) .finish(), ); // Toggle to the right of header let team_uid = team.uid; let current_state = team.organization_settings.is_discoverable; let discoverable_team_toggle = appearance .ui_builder() .switch( self.mouse_state_handles .discoverable_team_toggle_state .clone(), ) .check(team.organization_settings.is_discoverable) .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(TeamsPageAction::ToggleTeamDiscoverability { team_uid, current_state, }) }); discoverable_header_row.add_child( Container::new(discoverable_team_toggle.finish()) .with_padding_top(CONTENT_SEPARATION_PADDING) .finish(), ); section.add_child(discoverable_header_row.finish()); // Instruction text for toggle let domain = current_user_email.split('@').nth(1).unwrap_or(""); let team_discoverability_instructions = format!("Allow Warp users with an @{domain} email to find and join the team."); section.add_child( Container::new(self.render_sub_text( team_discoverability_instructions, appearance, Some(Coords::uniform(0.).right(48.)), )) .with_padding_top(8.) .finish(), ); section.finish() } fn render_leave_or_delete_team_button( &self, is_team_owner: bool, can_team_be_deleted: bool, view: &TeamsPageView, appearance: &Appearance, ) -> Box { let mut stack = Stack::new(); let (label, action) = if is_team_owner { ( DELETE_TEAM_BUTTON_LABEL, TeamsPageAction::ShowDeleteTeamConfirmationDialog, ) } else { ( LEAVE_TEAM_BUTTON_LABEL, TeamsPageAction::ShowLeaveTeamConfirmationDialog, ) }; let ui_builder = appearance.ui_builder().clone(); let button = ui_builder .button( ButtonVariant::Outlined, self.mouse_state_handles.leave_team_button.clone(), ) .with_style( self.button_properties() .set_font_color(appearance.theme().active_ui_text_color().into()) .set_width(LEAVE_TEAM_BUTTON_WIDTH), ) .with_centered_text_label(label.to_owned()); let hoverable = if is_team_owner && !can_team_be_deleted { button .with_disabled_styles(UiComponentStyles { background: Some(appearance.theme().surface_3().into()), border_color: Some(appearance.theme().surface_3().into()), font_color: Some( appearance .theme() .disabled_text_color(appearance.theme().background()) .into(), ), ..Default::default() }) .disabled() .build() } else { button .build() .with_cursor(Cursor::PointingHand) .on_click(move |ctx, _, _| ctx.dispatch_typed_action(action.clone())) }; stack.add_child( Container::new(hoverable.finish()) .with_padding_top(CONTENT_SEPARATION_PADDING) .with_padding_bottom(CONTENT_SEPARATION_PADDING) .finish(), ); if view.show_delete_or_leave_team_confirmation_dialog { stack.add_positioned_overlay_child( ChildView::new(&view.delete_or_leave_team_confirmation_dialog).finish(), OffsetPositioning::offset_from_parent( vec2f(0., 0.), ParentOffsetBounds::Unbounded, ParentAnchor::Center, ChildAnchor::BottomMiddle, ), ); } stack.finish() } fn render_delete_disabled_help_text( &self, delete_disabled_reason: TeamDeleteDisabledReason, team_uid: ServerId, appearance: &Appearance, ) -> Box { let description = self.render_sub_text( delete_disabled_reason.user_facing_message().into(), appearance, None, ); let mut children = vec![description]; if delete_disabled_reason == TeamDeleteDisabledReason::ActivePaidSubscription { let link = appearance .ui_builder() .link( "Manage plan".into(), None, Some(Box::new(move |ctx| { ctx.dispatch_typed_action( TeamsPageAction::GenerateStripeBillingPortalLink { team_uid }, ); })), self.mouse_state_handles.manage_plan_link.clone(), ) .build() .finish(); children.push(link); } Flex::column().with_children(children).finish() } fn render_delinquency_badge( &self, appearance: &Appearance, text: String, chip_color: ColorU, ) -> Box { Container::new( Text::new_inline(text, appearance.ui_font_family(), appearance.ui_font_size()) .with_color(*DELINQUENCY_BADGE_TEXT_COLOR) .with_style(Properties::default().weight(Weight::Medium)) .finish(), ) .with_uniform_padding(4.) .with_background(chip_color) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(3.))) .finish() } fn render_state_chip( &self, appearance: &Appearance, text: String, text_color: ColorU, chip_color: ColorU, font_size: f32, font_weight: Weight, ) -> Box { Container::new( Text::new_inline(text, appearance.ui_font_family(), font_size) .with_color(text_color) .with_style(Properties::default().weight(font_weight)) .finish(), ) .with_uniform_padding(6.) .with_background(chip_color) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(3.))) .with_margin_left(10.) .finish() } fn render_item_list( &self, items: Vec, mouse_state_handles: Vec, view: &TeamsPageView, appearance: &Appearance, ) -> Box { let all_items = items .iter() .sorted() .zip(mouse_state_handles.iter()) .enumerate() .map(|(idx, (item, handle))| { let mut row = Flex::row() .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_size(MainAxisSize::Max) .with_child( Shrinkable::new( 1., Text::new_inline( item.text.clone(), appearance.ui_font_family(), appearance.ui_font_size() - 1., ) .with_color(appearance.theme().active_ui_text_color().into()) .finish(), ) .finish(), ); let mut pending_and_close_row = Flex::row() .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_size(MainAxisSize::Min); match item.state { ItemState::Expired => { pending_and_close_row.add_child( self.render_state_chip( appearance, "EXPIRED".into(), appearance.theme().ui_error_color(), themes::theme::Fill::from(appearance.theme().ui_error_color()) .with_opacity(30) .into(), appearance.ui_font_size() - 1., Weight::Normal, ), ); } ItemState::Pending => { pending_and_close_row.add_child( self.render_state_chip( appearance, "PENDING".into(), *EMAIL_INVITE_PENDING_COLOR, themes::theme::Fill::from(*EMAIL_INVITE_PENDING_COLOR) .with_opacity(30) .into(), appearance.ui_font_size() - 1., Weight::Normal, ), ); } ItemState::Owner => { pending_and_close_row.add_child(self.render_state_chip( appearance, "OWNER".into(), appearance.theme().accent().into(), appearance.theme().accent().with_opacity(30).into(), appearance.ui_font_size() - 1., Weight::Normal, )); } ItemState::Admin => { pending_and_close_row.add_child( self.render_state_chip( appearance, "ADMIN".into(), appearance .theme() .background() .blend(&appearance.theme().foreground().with_opacity(60)) .into(), appearance .theme() .background() .blend(&appearance.theme().foreground().with_opacity(25)) .into(), appearance.ui_font_size() - 1., Weight::Normal, ), ); } ItemState::Valid => (), } match item.actions.len() { 0 => { // No actions - no button } 1 => { // Single action - show the action's icon let item_action = &item.actions[0]; let action = item_action.action.clone(); let icon = item_action.icon; pending_and_close_row.add_child( Container::new( Hoverable::new(handle.clone(), move |_mouse_state| { Container::new( ConstrainedBox::new( icon.to_warpui_icon( appearance .theme() .active_ui_text_color() .with_opacity(70), ) .finish(), ) .with_max_height(CLOSE_BUTTON_ICON_SIZE) .with_max_width(CLOSE_BUTTON_ICON_SIZE) .finish(), ) .with_uniform_padding(2.) .finish() }) .with_cursor(Cursor::PointingHand) .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(action.clone()) }) .finish(), ) .with_margin_left(10.) .finish(), ); } _ => { // Multiple actions - show dots menu let menu_is_open = view.open_member_actions_menu_index == Some(idx); let mut stack = Stack::new(); let dots_button = Hoverable::new(handle.clone(), |_mouse_state| { Container::new( ConstrainedBox::new( Icon::DotsVertical .to_warpui_icon( appearance .theme() .active_ui_text_color() .with_opacity(70), ) .finish(), ) .with_max_height(CLOSE_BUTTON_ICON_SIZE) .with_max_width(CLOSE_BUTTON_ICON_SIZE) .finish(), ) .with_uniform_padding(2.) .finish() }) .with_cursor(Cursor::PointingHand) .on_click(move |ctx, _, _| { if menu_is_open { ctx.dispatch_typed_action(TeamsPageAction::CloseMemberActionsMenu); } else { ctx.dispatch_typed_action(TeamsPageAction::OpenMemberActionsMenu { index: idx, }); } }) .finish(); stack.add_child(dots_button); // Show menu if this is the open index if view.open_member_actions_menu_index == Some(idx) { stack.add_positioned_overlay_child( ChildView::new(&view.member_actions_menu).finish(), OffsetPositioning::offset_from_parent( vec2f(0., 0.), ParentOffsetBounds::WindowByPosition, ParentAnchor::BottomRight, ChildAnchor::TopRight, ), ); } pending_and_close_row.add_child( Container::new(stack.finish()) .with_margin_left(10.) .finish(), ); } } row.add_child(pending_and_close_row.finish()); let list_element = Container::new(row.finish()).with_uniform_padding(SCROLLABLE_LIST_ITEM_PADDING); let container = if idx % 2 == 0 { list_element .with_background(internal_colors::fg_overlay_1(appearance.theme())) .finish() } else { list_element.finish() }; container }) .collect::>() .into_iter(); Flex::column() .with_main_axis_size(MainAxisSize::Min) .with_children(all_items) .finish() } fn render_copy_link_row( &self, team_metadata: &Team, appearance: &Appearance, ) -> Box { let mut section = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center); let (link_text, button_enabled) = match &team_metadata.invite_code { Some(invite_code) => { let link = format!( "{}{}{}", ChannelState::server_root_url(), INVITE_LINK_PREFIX, invite_code.code ); (link, true) } None => ("Failed to load invite link.".into(), false), }; let theme = appearance.theme(); let mut copy_button = appearance .ui_builder() .copy_button(14., self.mouse_state_handles.copy_link_button.clone()) .build(); if !button_enabled { copy_button = copy_button.disable(); } // 1) Invite link (with copy button) section.add_child( Shrinkable::new( 1., Container::new( Flex::row() .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) .with_main_axis_size(MainAxisSize::Max) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_children([ Shrinkable::new( 1., Container::new( Align::new( appearance .ui_builder() .span(link_text.clone()) .with_style(UiComponentStyles { font_color: Some( theme .main_text_color(theme.background()) .into_solid(), ), ..Default::default() }) .build() .finish(), ) .left() .finish(), ) .with_padding_bottom(10.) .with_padding_top(10.) .finish(), ) .finish(), copy_button .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(TeamsPageAction::CopyLink( link_text.clone(), )); }) .finish(), ]) .finish(), ) .with_background(theme.background()) .with_border(Border::all(1.).with_border_fill(theme.outline())) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.))) .with_padding_left(8.) .with_padding_right(8.) .finish(), ) .finish(), ); Container::new(section.finish()) .with_margin_top(TEXT_FIELD_TOP_PADDING) .finish() } fn render_sub_header(&self, text: String, appearance: &Appearance) -> Box { Align::new( appearance .ui_builder() .span(text) .with_style(UiComponentStyles { font_family_id: Some(appearance.ui_font_family()), font_weight: Some(Weight::Light), font_color: Some( appearance .theme() .active_ui_text_color() .with_opacity(90) .into(), ), font_size: Some(12.), ..Default::default() }) .build() .finish(), ) .left() .finish() } fn render_subsection_header(&self, text: String, appearance: &Appearance) -> Box { Align::new( appearance .ui_builder() .span(text) .with_style(UiComponentStyles { font_family_id: Some(appearance.ui_font_family()), font_weight: Some(Weight::Medium), font_color: Some( appearance .theme() .active_ui_text_color() .with_opacity(80) .into(), ), font_size: Some(SUBSECTION_HEADER_FONT_SIZE), ..Default::default() }) .build() .finish(), ) .left() .finish() } fn render_description(&self, text: String, appearance: &Appearance) -> Box { Text::new(text, appearance.ui_font_family(), 12.) .with_color( appearance .theme() .active_ui_text_color() .with_opacity(90) .into(), ) .finish() } fn render_sub_header_with_subtext_color( &self, appearance: &Appearance, text: String, ) -> Box { Container::new( Align::new( appearance .ui_builder() .span(text) .with_style(UiComponentStyles { font_family_id: Some(appearance.ui_font_family()), font_color: Some( appearance .theme() .sub_text_color(appearance.theme().background()) .into_solid(), ), font_size: Some(14.), ..Default::default() }) .build() .finish(), ) .left() .finish(), ) .with_padding_bottom(4.) .finish() } fn render_plan_usage_header(&self, text: String, appearance: &Appearance) -> Box { Align::new( appearance .ui_builder() .span(text) .with_style(UiComponentStyles { font_family_id: Some(appearance.ui_font_family()), font_weight: Some(Weight::Light), font_color: Some( appearance .theme() .active_ui_text_color() .with_opacity(40) .into(), ), font_size: Some(13.), ..Default::default() }) .build() .finish(), ) .left() .finish() } fn render_plan_usage_text(&self, text: String, appearance: &Appearance) -> Box { Align::new( appearance .ui_builder() .span(text) .with_style(UiComponentStyles { font_family_id: Some(appearance.ui_font_family()), font_weight: Some(Weight::Light), font_color: Some( appearance .theme() .active_ui_text_color() .with_opacity(60) .into(), ), font_size: Some(20.), ..Default::default() }) .build() .finish(), ) .left() .finish() } fn render_sub_text( &self, text: String, appearance: &Appearance, margin: Option, ) -> Box { appearance .ui_builder() .paragraph(text) .with_style(UiComponentStyles { margin, font_color: Some( appearance .theme() .active_ui_text_color() .with_opacity(60) .into(), ), ..Default::default() }) .build() .finish() } fn render_error_sub_text(&self, text: String, appearance: &Appearance) -> Box { appearance .ui_builder() .paragraph(text) .with_style(UiComponentStyles { margin: Some(Coords::uniform(0.).right(48.)), font_color: Some(appearance.theme().ui_error_color()), ..Default::default() }) .build() .finish() } fn render_create_team_page_with_banner( &self, view: &TeamsPageView, appearance: &Appearance, app: &AppContext, ) -> Box { let mut column = Flex::column(); column.add_child(self.render_create_team_page(view, appearance, app)); column.finish() } fn render_create_team_page( &self, view: &TeamsPageView, appearance: &Appearance, app: &AppContext, ) -> Box { let mut page = Flex::column(); // Title, subtitle, and description page.add_child(render_sub_header(appearance, "Teams".to_string(), None)); page.add_child( self.render_sub_header_with_subtext_color(appearance, "Create a team".to_string()), ); page.add_child( Container::new( self.render_description(CREATE_TEAM_DESCRIPTION.to_string(), appearance), ) .with_padding_top(6.) .finish(), ); if view.auth_state.is_on_work_domain().unwrap_or_default() { let checkbox = Container::new( appearance .ui_builder() .checkbox(self.mouse_state_handles.checkbox_mouse_state.clone(), None) .check(view.checkbox_value) .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action( TeamsPageAction::ToggleTeamDiscoverabilityBeforeCreation, ) }) .finish(), ) .with_margin_left(-4.) .finish(); let checkbox_row_text = if let Some(domain) = view.auth_state.user_email_domain() { format!("Allow Warp users with an @{domain} email to find and join the team.") } else { "Allow Warp users with the same email domain as you to find and join the team." .to_string() }; let checkbox_row = Container::new( Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child(checkbox) .with_child( Shrinkable::new(1., self.render_description(checkbox_row_text, appearance)) .finish(), ) .finish(), ) .with_padding_top(6.) .finish(); page.add_child(checkbox_row); } // Team name editor page.add_child( Container::new(self.render_create_team_actions(view, appearance, app)) .with_padding_top(12.) .with_padding_bottom(12.) .finish(), ); if !view.discoverable_teams_states.is_empty() { // Separator and subtitle page.add_child(render_separator(appearance)); page.add_child(self.render_sub_header_with_subtext_color( appearance, "Or, join an existing team within your company".to_string(), )); // Team discovery page.add_child(self.render_team_discovery_section(view, appearance)); } page.finish() } fn render_create_team_actions( &self, view: &TeamsPageView, appearance: &Appearance, app: &AppContext, ) -> Box { Flex::column() .with_child( Flex::row() .with_main_axis_size(MainAxisSize::Max) .with_child( Shrinkable::new( 1., self.render_editor(appearance, view.create_team_editor.clone(), None), ) .finish(), ) .with_child( Container::new(self.render_create_team_button(view, appearance, app)) .with_padding_left(CREATE_TEAM_BUTTON_LEFT_PADDING) .finish(), ) .finish(), ) .finish() } fn render_team_discovery_section( &self, view: &TeamsPageView, appearance: &Appearance, ) -> Box { let mut team_discovery = Flex::column(); // Sort teams so teams accepting invites with most teammates appear on top let mut sorted_teams = view.discoverable_teams_states.clone(); sorted_teams.sort_by_key(|team_state| { ( !team_state.team.team_accepting_invites, -team_state.team.num_members, ) }); // Render box for each team for team_state in &sorted_teams { team_discovery.add_child( Container::new(self.render_single_team_in_team_discovery(team_state, appearance)) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.))) .with_border(Border::all(1.).with_border_fill(appearance.theme().outline())) .with_uniform_padding(16.) .with_margin_top(12.) .finish(), ); } team_discovery.finish() } fn render_single_team_in_team_discovery( &self, team_state: &DiscoverableTeamState, appearance: &Appearance, ) -> Box { let mut single_team = Flex::column(); // Team name single_team.add_child(self.render_sub_header(team_state.team.name.to_string(), appearance)); // Number of teammates let teammate_string = if team_state.team.num_members == 1 { "1 teammate".to_string() } else { format!("{} teammates", team_state.team.num_members) }; single_team.add_child(self.render_sub_text(teammate_string, appearance, None)); // Call to action single_team.add_child( Container::new( self.render_sub_text( "Join this team and start collaborating on workflows, notebooks, and more." .to_string(), appearance, None, ), ) .with_padding_top(12.) .with_padding_bottom(12.) .finish(), ); // Join button single_team.add_child( Container::new(self.render_join_team_button(team_state, appearance)) .with_padding_top(12.) .finish(), ); single_team.finish() } fn render_editor( &self, appearance: &Appearance, editor: ViewHandle, width: Option, ) -> Box { let element = ConstrainedBox::new( appearance .ui_builder() .text_input(editor) .with_style(UiComponentStyles { background: Some(appearance.theme().background().into()), font_color: Some( appearance .theme() .main_text_color(appearance.theme().background()) .into_solid(), ), ..Default::default() }) .build() .finish(), ); if let Some(element_width) = width { element.with_width(element_width).finish() } else { element.finish() } } fn render_compare_plans_button( &self, text: &str, mouse_state_handle: MouseStateHandle, team_uid: ServerId, appearance: &Appearance, style: Option, ) -> Box { let icon_color = appearance.theme().accent(); let mut button = appearance .ui_builder() .button(ButtonVariant::Link, mouse_state_handle) .with_text_and_icon_label( TextAndIcon::new( TextAndIconAlignment::IconFirst, text.to_string(), Icon::CoinsStacked.to_warpui_icon(icon_color), MainAxisSize::Min, MainAxisAlignment::Center, vec2f(14., 14.), ) .with_inner_padding(4.), ); if let Some(style) = style { button = button.with_style(style); } button .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(TeamsPageAction::GenerateUpgradeLink { team_uid }); }) .finish() } fn render_button( &self, label: &str, variant: ButtonVariant, mouse_state_handle: MouseStateHandle, action: Option, styles: UiComponentStyles, appearance: &Appearance, ) -> Box { let button = appearance .ui_builder() .button(variant, mouse_state_handle) .with_style(styles) .with_centered_text_label(label.to_owned()) .build(); if let Some(action) = action { button .with_cursor(Cursor::PointingHand) .on_click(move |ctx, _, _| ctx.dispatch_typed_action(action.clone())) .finish() } else { button.finish() } } fn render_create_team_button( &self, view: &TeamsPageView, appearance: &Appearance, app: &AppContext, ) -> Box { let mut button = appearance .ui_builder() .button( ButtonVariant::Accent, self.mouse_state_handles.create_team_button.clone(), ) .with_centered_text_label(CREATE_TEAM_BUTTON_LABEL.to_owned()) .with_style(UiComponentStyles { font_color: Some( appearance .theme() .main_text_color(appearance.theme().accent()) .into_solid(), ), font_weight: Some(Weight::Medium), width: Some(100.), height: Some(38.), font_size: Some(14.), ..Default::default() }); if view .create_team_editor .as_ref(app) .buffer_text(app) .trim() .is_empty() { button = button .with_style(UiComponentStyles { font_color: Some( appearance .theme() .disabled_text_color(appearance.theme().background()) .into(), ), ..Default::default() }) .disabled(); } button .build() .with_cursor(Cursor::PointingHand) .on_click(move |ctx, _, _| ctx.dispatch_typed_action(TeamsPageAction::CreateTeam)) .finish() } fn render_join_team_button( &self, team_state: &DiscoverableTeamState, appearance: &Appearance, ) -> Box { if team_state.team.team_accepting_invites { self.render_button( "Join", ButtonVariant::Accent, team_state.mouse_state_handle.clone(), Some(TeamsPageAction::JoinTeamWithTeamDiscovery { team_uid: ServerId::from_string_lossy(&team_state.team.team_uid), }), UiComponentStyles { font_color: Some( appearance .theme() .main_text_color(appearance.theme().accent()) .into_solid(), ), font_weight: Some(Weight::Medium), height: Some(38.), font_size: Some(14.), ..Default::default() }, appearance, ) } else { appearance .ui_builder() .button(ButtonVariant::Accent, team_state.mouse_state_handle.clone()) .with_style(UiComponentStyles { font_weight: Some(Weight::Medium), height: Some(38.), font_size: Some(14.), ..Default::default() }) .with_centered_text_label("Contact Admin to request access".to_string()) .disabled() .build() .finish() } } } impl SettingsWidget for TeamsWidget { type View = TeamsPageView; fn search_terms(&self) -> &str { "invites teams team members" } fn render( &self, view: &Self::View, appearance: &Appearance, app: &AppContext, ) -> Box { // Main teams content: create a team, error state, team management. // We only want to show the teams page if the user is online. Otherwise, // we may not have some of the data we need to render the page, and // furthermore, it wouldn't be useful because the user can't do anything // related to team administration when offline. let content = if NetworkStatus::as_ref(app).is_online() { let teams = view.user_workspaces.as_ref(app); let cloud_model = view.cloud_model.as_ref(app); let ai_request_usage_model = view.ai_request_usage_model.as_ref(app); match teams.current_team() { Some(team) => self.render_team_management_page( team, cloud_model, ai_request_usage_model, view, appearance, app, ), None => self.render_create_team_page_with_banner(view, appearance, app), } } else { appearance .ui_builder() .span(OFFLINE_TEXT.to_string()) .build() .finish() }; let mut stack = Stack::new(); stack.add_child(Flex::column().with_child(content).finish()); if view.transfer_ownership_modal_state.is_open() { stack.add_positioned_overlay_child( view.transfer_ownership_modal_state.render(), OffsetPositioning::offset_from_parent( vec2f(0., 0.), ParentOffsetBounds::WindowByPosition, ParentAnchor::Center, ChildAnchor::Center, ), ); } stack.finish() } } #[cfg(test)] #[test] pub fn test_valid_domains() { assert!(!TeamsPageView::is_valid_domain("@warp.dev")); assert!(!TeamsPageView::is_valid_domain("warp,")); assert!(!TeamsPageView::is_valid_domain("warpdev")); assert!(!TeamsPageView::is_valid_domain(".dev")); assert!(!TeamsPageView::is_valid_domain("warp..dev")); assert!(!TeamsPageView::is_valid_domain(" ")); assert!(!TeamsPageView::is_valid_domain("warp!.dev")); assert!(!TeamsPageView::is_valid_domain("warp.dev>")); assert!(!TeamsPageView::is_valid_domain("warp.dev.")); assert!(TeamsPageView::is_valid_domain("app.warp.dev")); assert!(TeamsPageView::is_valid_domain("warp0.dev0")); assert!(TeamsPageView::is_valid_domain("warp.dev")); assert!(TeamsPageView::is_valid_domain("miniclip.com")); }