Files
galaxy/app/src/settings_view/teams_page.rs
T

4098 lines
153 KiB
Rust

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<Self, Self::Error> {
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<ItemAction>,
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<Ordering> {
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<String>,
}
pub struct TeamsPageView {
page: PageType<Self>,
auth_state: Arc<AuthState>,
create_team_editor: ViewHandle<EditorView>,
approve_domains_block_editor: ViewHandle<WordBlockEditorView>,
approve_domains_block_editor_state: ChipEditorState,
email_invites_block_editor: ViewHandle<WordBlockEditorView>,
email_invites_block_editor_state: ChipEditorState,
// Note that rather than storing just the current workspace, we're storing the entire
// ModelHandle<UserWorkspaces>. That's because eventually we'll be handling more than one workspace.
user_workspaces: ModelHandle<UserWorkspaces>,
ai_request_usage_model: ModelHandle<AIRequestUsageModel>,
pricing_info_model: ModelHandle<PricingInfoModel>,
cloud_model: ModelHandle<CloudModel>,
invite_view: TeamsInviteOption,
team_members_mouse_state_handles: Vec<MouseStateHandle>,
team_approved_domains_mouse_state_handles: Vec<MouseStateHandle>,
delete_or_leave_team_confirmation_dialog: ViewHandle<CloudActionConfirmationDialog>,
show_delete_or_leave_team_confirmation_dialog: bool,
transfer_ownership_modal_state: ModalViewState<Modal<TransferOwnershipConfirmationModal>>,
clipped_scroll_state: ClippedScrollStateHandle,
discoverable_teams_states: Vec<DiscoverableTeamState>,
rename_team_editor: ViewHandle<ClickableTextInput>,
checkbox_value: bool,
member_actions_menu: ViewHandle<Menu<TeamsPageAction>>,
open_member_actions_menu_index: Option<usize>,
}
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<Self>) {
// 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<dyn Element> {
self.page.render(self, app)
}
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
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<F>(
mut event_handler: F,
placeholder: &str,
ui_font_size: f32,
ctx: &mut ViewContext<TeamsPageView>,
) -> ViewHandle<EditorView>
where
F: 'static + FnMut(&mut TeamsPageView, &EditorEvent, &mut ViewContext<TeamsPageView>),
{
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<TeamsPageView>) -> 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<Self>) {
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, &current_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<MenuItem<TeamsPageAction>> = 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<TeamsPageView>,
) {
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<Self>) {
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<TeamsPageView>,
) {
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<Self>,
) {
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<Self>,
) {
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<Self>,
) {
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>,
) {
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<Self>,
) {
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<Self>,
) {
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>) {
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<Self>) {
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<Self>) {
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>) {
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<Self>) {
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<Self>) {
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<Self>) {
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>) {
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<String>,
flavor: ToastFlavor,
ctx: &mut ViewContext<Self>,
) {
ctx.emit(TeamsPageViewEvent::ShowToast {
message: message.into(),
flavor,
});
ctx.notify();
}
fn show_success(&mut self, message: impl Into<String>, ctx: &mut ViewContext<Self>) {
self.show_toast(message, ToastFlavor::Success, ctx);
}
fn show_error(
&mut self,
error_msg: impl Into<String>,
error: Option<&anyhow::Error>,
ctx: &mut ViewContext<Self>,
) {
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>,
) {
self.invite_view = *view_option;
self.update_team_members_state(ctx);
}
fn copy_invite_link(&mut self, link: &str, ctx: &mut ViewContext<Self>) {
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>,
) {
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<Self>) {
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<Self>) {
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>,
) {
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<Self>) {
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<String> = domains
.into_iter()
.map(|word| word.to_ascii_lowercase())
.collect::<HashSet<String>>()
.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>,
) {
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<Self>) {
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<String> = emails
.into_iter()
.map(|word| word.to_ascii_lowercase())
.collect::<HashSet<String>>()
.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>,
) {
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>) {
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>,
) {
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>) {
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>,
) {
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>) {
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>,
) {
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<Self>) {
match event {
EditorEvent::Edited(_) => ctx.notify(),
EditorEvent::Escape => ctx.focus_self(),
_ => (),
}
}
fn handle_rename_team_editor_event(
&mut self,
event: &ClickableTextInputEvent,
ctx: &mut ViewContext<Self>,
) {
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<Self>) {
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<Item> {
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<Self>) {
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>) {
self.focus_on_next_input(ctx);
}
fn update_filter(&mut self, query: &str, ctx: &mut ViewContext<Self>) -> 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<ViewHandle<TeamsPageView>> for SettingsPageViewHandle {
fn from(view_handle: ViewHandle<TeamsPageView>) -> 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<dyn Element> {
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<dyn Element> {
let current_user_email = view.auth_state.user_email().unwrap_or_default();
let has_admin_permissions = team_metadata.has_admin_permissions(&current_user_email);
let is_owner = team_metadata.has_owner_permissions(&current_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(&current_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,
&current_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,
&current_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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<Item> = 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<dyn Element> {
// 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<dyn Element> {
// 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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<Item>,
mouse_state_handles: Vec<MouseStateHandle>,
view: &TeamsPageView,
appearance: &Appearance,
) -> Box<dyn Element> {
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::<Vec<_>>()
.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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<Coords>,
) -> Box<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<EditorView>,
width: Option<f32>,
) -> Box<dyn Element> {
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<UiComponentStyles>,
) -> Box<dyn Element> {
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<TeamsPageAction>,
styles: UiComponentStyles,
appearance: &Appearance,
) -> Box<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
// 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"));
}