//! Modal shown when the user clicks "Click here to paste your token from //! the browser" on the onboarding agent-slide upgrade-prompt bar. Accepts a //! pasted auth redirect URL and routes it through //! `AuthManager::initialize_user_from_auth_payload`. //! //! This lives in the app crate (not the onboarding crate) because it reuses //! `EditorView` for the text input, which the onboarding crate doesn't //! depend on. use crate::appearance::Appearance; use crate::auth::auth_manager::{AuthManager, AuthManagerEvent}; use crate::auth::auth_view_modal::AuthRedirectPayload; use crate::auth::login_failure_notification::LoginFailureReason; use crate::editor::{ EditorView, InteractionState, SingleLineEditorOptions, TextColors, TextOptions, }; use crate::server::server_api::auth::UserAuthenticationError; use crate::themes::theme::Fill as ThemeFill; use crate::util::bindings::CustomAction; use pathfinder_color::ColorU; use ui_components::{button, Component as _, Options as _}; use warp_core::ui::theme::color::internal_colors; use warpui::elements::{ Align, Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Dismiss, Fill, Flex, FormattedTextElement, HighlightedHyperlink, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable, Stack, }; use warpui::fonts::Weight; use warpui::keymap::{FixedBinding, Keystroke}; use warpui::text_layout::TextAlignment; use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles}; use warpui::{ actions::StandardAction, AppContext, Element, Entity, FocusContext, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle, }; const MODAL_WIDTH: f32 = 460.; const AUTH_TOKEN_INPUT_BORDER_RADIUS: Radius = Radius::Pixels(4.); pub fn init(app: &mut AppContext) { use warpui::keymap::macros::*; app.register_fixed_bindings([ FixedBinding::new( "enter", PasteAuthTokenModalAction::Confirm, id!(PasteAuthTokenModalView::ui_name()), ), FixedBinding::new( "escape", PasteAuthTokenModalAction::Cancel, id!(PasteAuthTokenModalView::ui_name()), ), FixedBinding::custom( CustomAction::Paste, PasteAuthTokenModalAction::PasteIntoEditor, "Paste", id!(PasteAuthTokenModalView::ui_name()), ), FixedBinding::standard( StandardAction::Paste, PasteAuthTokenModalAction::PasteIntoEditor, id!(PasteAuthTokenModalView::ui_name()), ), ]); #[cfg(any(target_os = "linux", target_os = "windows"))] app.register_fixed_bindings([FixedBinding::new( "cmdorctrl-v", PasteAuthTokenModalAction::PasteIntoEditor, id!(PasteAuthTokenModalView::ui_name()), )]); } #[derive(Clone, Copy, Debug)] pub enum PasteAuthTokenModalAction { Confirm, Cancel, /// Cmd+V/Ctrl+V at the modal level — routes paste into the editor even /// when focus is still on the modal itself rather than the input. PasteIntoEditor, } #[derive(Clone, Debug)] pub enum PasteAuthTokenModalEvent { Cancelled, } pub struct PasteAuthTokenModalView { auth_token_input: ViewHandle, cancel_button: button::Button, continue_button: button::Button, close_mouse_state: MouseStateHandle, last_failure_reason: Option, highlighted_hyperlink_state: HighlightedHyperlink, } impl PasteAuthTokenModalView { pub fn new(ctx: &mut ViewContext) -> Self { let auth_token_input = ctx.add_typed_action_view(|ctx| { let appearance = Appearance::as_ref(ctx); let theme = appearance.theme(); let bg_solid = theme.surface_2().into_solid(); let default_color = ThemeFill::Solid(internal_colors::text_main(theme, bg_solid)); let disabled_color = ThemeFill::Solid(internal_colors::text_disabled(theme, bg_solid)); let hint_color = ThemeFill::Solid(internal_colors::text_sub(theme, bg_solid)); let mut editor = EditorView::single_line( SingleLineEditorOptions { text: TextOptions { font_size_override: Some(12.), font_family_override: Some(appearance.ui_font_family()), text_colors_override: Some(TextColors { default_color, disabled_color, hint_color, }), ..Default::default() }, soft_wrap: false, ..Default::default() }, ctx, ); editor.set_placeholder_text("Enter auth token", ctx); editor }); // When the editor sees an Enter/Paste/etc. commit, submit the current // buffer text upward. This matches the semantics of the inline editor // in `login_slide.rs`. ctx.subscribe_to_view(&auth_token_input, |me, _, event, ctx| { use crate::editor::Event::{AltEnter, CmdEnter, Enter, Paste, ShiftEnter}; match event { AltEnter | CmdEnter | Enter | Paste | ShiftEnter => { me.submit(ctx); } _ => {} }; ctx.notify(); }); // Handle AuthFailed for attempts that originated from this modal: show // an inline error and re-enable the editor so the user can try again. ctx.subscribe_to_model(&AuthManager::handle(ctx), |me, _, event, ctx| { if let AuthManagerEvent::AuthFailed(err) = event { me.last_failure_reason = Some(match err { UserAuthenticationError::InvalidStateParameter => { LoginFailureReason::InvalidStateParameter } UserAuthenticationError::MissingStateParameter => { LoginFailureReason::MissingStateParameter } UserAuthenticationError::DeniedAccessToken(_) | UserAuthenticationError::UserAccountDisabled(_) | UserAuthenticationError::Unexpected(_) => { LoginFailureReason::FailedUserAuthentication } }); me.set_editor_enabled(true, ctx); ctx.notify(); } }); Self { auth_token_input, cancel_button: button::Button::default(), continue_button: button::Button::default(), close_mouse_state: MouseStateHandle::default(), last_failure_reason: None, highlighted_hyperlink_state: HighlightedHyperlink::default(), } } /// Disables the editor while the auth request is in flight. Re-enabled /// automatically on `AuthManagerEvent::AuthFailed` or on local parse /// failure in `submit`. fn set_editor_enabled(&mut self, is_enabled: bool, ctx: &mut ViewContext) { let state = if is_enabled { InteractionState::Editable } else { InteractionState::Disabled }; self.auth_token_input .update(ctx, |editor, ctx| editor.set_interaction_state(state, ctx)); } fn submit(&mut self, ctx: &mut ViewContext) { let text = self.auth_token_input.as_ref(ctx).buffer_text(ctx); if text.trim().is_empty() { return; } // Clear any previous error before the next attempt. self.last_failure_reason = None; self.set_editor_enabled(false, ctx); match AuthRedirectPayload::from_raw_url(text) { Ok(payload) => { AuthManager::handle(ctx).update(ctx, |auth_manager, ctx| { auth_manager.initialize_user_from_auth_payload(payload, true, ctx); }); } Err(error) => { log::error!("Failed to parse pasted auth URL: {error:#}"); self.last_failure_reason = Some(LoginFailureReason::InvalidRedirectUrl { was_pasted: true }); self.set_editor_enabled(true, ctx); ctx.notify(); } } } } impl Entity for PasteAuthTokenModalView { type Event = PasteAuthTokenModalEvent; } impl View for PasteAuthTokenModalView { fn ui_name() -> &'static str { "PasteAuthTokenModalView" } fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext) { if focus_ctx.is_self_focused() { // Redirect focus to the editor so keystrokes immediately appear // in the input field. ctx.focus(&self.auth_token_input); ctx.notify(); } } fn render(&self, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); let dialog_surface = theme.surface_1(); let dialog_surface_solid = dialog_surface.into_solid(); let border_color = internal_colors::neutral_4(theme); let input_bg = theme.surface_2(); let input_bg_solid = input_bg.into_solid(); let input_text_color: ColorU = internal_colors::text_main(theme, input_bg_solid); let ui_builder = appearance.ui_builder(); let title = FormattedTextElement::from_str( "Paste your auth token below", appearance.ui_font_family(), 16., ) .with_color(internal_colors::text_main(theme, dialog_surface_solid)) .with_weight(Weight::Bold) .with_line_height_ratio(1.25) .finish(); let close_button = ui_builder .close_button(24., self.close_mouse_state.clone()) .build() .on_click(|ctx: &mut warpui::EventContext, _, _| { ctx.dispatch_typed_action(PasteAuthTokenModalAction::Cancel); }) .finish(); let title_row = Flex::row() .with_main_axis_size(MainAxisSize::Max) .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) .with_cross_axis_alignment(CrossAxisAlignment::Start) .with_child(Shrinkable::new(1., title).finish()) .with_child(close_button) .finish(); let subtitle_color = internal_colors::text_sub(theme, dialog_surface_solid); let subtitle = FormattedTextElement::from_str( "Paste your auth token from the browser to get complete login.", appearance.ui_font_family(), 14., ) .with_color(subtitle_color) .with_weight(Weight::Normal) .with_alignment(TextAlignment::Left) .with_line_height_ratio(1.2) .finish(); let input = ui_builder .text_input(self.auth_token_input.clone()) .with_style(UiComponentStyles { background: Some(input_bg.into()), border_width: Some(1.), border_color: Some(Fill::Solid(border_color)), border_radius: Some(CornerRadius::with_all(AUTH_TOKEN_INPUT_BORDER_RADIUS)), font_color: Some(input_text_color), padding: Some(Coords { top: 12., bottom: 12., left: 16., right: 16., }), ..Default::default() }) .build() .finish(); let mut body = Flex::column() .with_cross_axis_alignment(CrossAxisAlignment::Stretch) .with_child( Container::new(subtitle) .with_margin_top(8.) .with_margin_bottom(16.) .finish(), ) .with_child(input); if let Some(reason) = &self.last_failure_reason { let error_text = FormattedTextElement::new( reason.to_formatted_text(), 14., appearance.ui_font_family(), appearance.monospace_font_family(), theme.ui_error_color(), self.highlighted_hyperlink_state.clone(), ) .register_default_click_handlers(|url, _, ctx| { ctx.open_url(&url.url); }) .finish(); body = body.with_child(Container::new(error_text).with_margin_top(8.).finish()); } let body = body.finish(); let cancel_button = self.cancel_button.render( appearance, button::Params { content: button::Content::Label("Cancel".into()), theme: &button::themes::Naked, options: button::Options { on_click: Some(Box::new(|ctx, _app, _pos| { ctx.dispatch_typed_action(PasteAuthTokenModalAction::Cancel); })), ..button::Options::default(appearance) }, }, ); let enter = Keystroke::parse("enter").unwrap_or_default(); let continue_button = self.continue_button.render( appearance, button::Params { content: button::Content::Label("Continue".into()), theme: &button::themes::Primary, options: button::Options { keystroke: Some(enter), on_click: Some(Box::new(|ctx, _app, _pos| { ctx.dispatch_typed_action(PasteAuthTokenModalAction::Confirm); })), ..button::Options::default(appearance) }, }, ); let footer = Container::new( Flex::row() .with_main_axis_size(MainAxisSize::Max) .with_main_axis_alignment(MainAxisAlignment::End) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child(cancel_button) .with_child( Container::new(continue_button) .with_margin_left(8.) .finish(), ) .finish(), ) .with_border(Border::top(1.).with_border_color(border_color)) .with_horizontal_padding(24.) .with_vertical_padding(12.) .finish(); let dialog = Flex::column() .with_cross_axis_alignment(CrossAxisAlignment::Stretch) .with_child( Container::new(title_row) .with_horizontal_padding(24.) .with_padding_top(24.) .with_padding_bottom(12.) .finish(), ) .with_child( Container::new(body) .with_horizontal_padding(24.) .with_padding_bottom(16.) .finish(), ) .with_child(footer) .finish(); let modal = ConstrainedBox::new( Container::new(dialog) .with_background(dialog_surface) .with_border(Border::all(1.).with_border_color(border_color)) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.))) .finish(), ) .with_width(MODAL_WIDTH) .finish(); // Dim backdrop with click-to-dismiss behavior (matches the mockup). let mut stack = Stack::new(); stack.add_child( Container::new(warpui::elements::Empty::new().finish()) .with_background_color(ColorU::new(0, 0, 0, 179)) .finish(), ); stack.add_child( Dismiss::new(Align::new(modal).finish()) .on_dismiss(|ctx, _app| { ctx.dispatch_typed_action(PasteAuthTokenModalAction::Cancel); }) .finish(), ); stack.finish() } } impl TypedActionView for PasteAuthTokenModalView { type Action = PasteAuthTokenModalAction; fn handle_action(&mut self, action: &PasteAuthTokenModalAction, ctx: &mut ViewContext) { match action { PasteAuthTokenModalAction::Confirm => { self.submit(ctx); } PasteAuthTokenModalAction::Cancel => { ctx.emit(PasteAuthTokenModalEvent::Cancelled); } PasteAuthTokenModalAction::PasteIntoEditor => { self.auth_token_input .update(ctx, |editor, ctx| editor.paste(ctx)); } } } }