More progress, still a long way to go

This commit is contained in:
Ryan Ward
2026-05-07 14:37:26 -05:00
parent a41cbd8cc7
commit f8f2c6bff6
25 changed files with 755 additions and 5387 deletions
+7 -36
View File
@@ -552,12 +552,7 @@ impl AuthManager {
/// NOTE: You probably want to call auth::log_out instead; this only manages the auth state,
/// it doesn't shut down any other user-dependent parts of the app.
/// TODO(jeff): Can we move those pieces in here?
pub(super) fn log_out(&mut self, ctx: &mut ModelContext<Self>) {
// Clear any dangling CSRF token from an auth flow that was started but never
// completed before this logout, so it can't be replayed against the next session
// in the same process.
self.pending_auth_state = None;
self.set_and_persist(None, None, ctx);
pub(super) fn log_out(&mut self, _ctx: &mut ModelContext<Self>) {
}
/// Sets whether or not this user's Firebase credentials are invalid and thus needs to reauth.
@@ -572,20 +567,9 @@ impl AuthManager {
pub fn create_anonymous_user(
&self,
referral_code: Option<String>,
ctx: &mut ModelContext<Self>,
_referral_code: Option<String>,
_ctx: &mut ModelContext<Self>,
) {
let anonymous_user_type = AnonymousUserType::NativeClientAnonymousUserFeatureGated;
let auth_client = self.auth_client.clone();
let _ = ctx.spawn(
async move {
auth_client
.create_anonymous_user(referral_code, anonymous_user_type)
.await
},
Self::on_create_anonymous_user,
);
}
fn on_create_anonymous_user(
@@ -634,26 +618,13 @@ impl AuthManager {
pub fn attempt_login_gated_feature(
&self,
feature: LoginGatedFeature,
auth_view_variant: AuthViewVariant,
ctx: &mut ModelContext<Self>,
_feature: LoginGatedFeature,
_auth_view_variant: AuthViewVariant,
_ctx: &mut ModelContext<Self>,
) {
if self.auth_state.is_anonymous_or_logged_out() {
send_telemetry_from_ctx!(
TelemetryEvent::AnonymousUserAttemptLoginGatedFeature { feature },
ctx
);
ctx.emit(AuthManagerEvent::AttemptedLoginGatedFeature { auth_view_variant });
};
}
pub fn anonymous_user_hit_drive_object_limit(&self, ctx: &mut ModelContext<Self>) {
if self.auth_state.is_anonymous_or_logged_out() {
send_telemetry_from_ctx!(TelemetryEvent::AnonymousUserHitCloudObjectLimit, ctx);
ctx.emit(AuthManagerEvent::AttemptedLoginGatedFeature {
auth_view_variant: AuthViewVariant::HitDriveObjectLimitCloseable,
});
};
pub fn anonymous_user_hit_drive_object_limit(&self, _ctx: &mut ModelContext<Self>) {
}
pub fn initiate_anonymous_user_linking(
-419
View File
@@ -1,419 +0,0 @@
use crate::appearance::Appearance;
use crate::util::color::lighten;
use galaxy_core::ui::builder::UiBuilder;
use galaxy_core::ui::color::darken;
use galaxyui::keymap::FixedBinding;
use crate::modal::MODAL_CORNER_RADIUS;
use galaxy_core::ui::color::blend::Blend;
use galaxyui::accessibility::{AccessibilityContent, WarpA11yRole};
use galaxyui::color::ColorU;
use galaxyui::elements::{
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Fill, Flex, Icon,
MouseStateHandle, ParentElement, Radius, Shrinkable,
};
use galaxyui::fonts::Weight;
use galaxyui::ui_components::button::ButtonVariant;
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::{
AppContext, Element, Entity, FocusContext, SingletonEntity, TypedActionView, View, ViewContext,
};
const MODAL_PADDING: f32 = 32.;
const AUTH_MODAL_GAP: f32 = 16.;
const BUTTON_ROW_GAP: f32 = 8.;
const ACTION_BUTTON_HEIGHT: f32 = 40.;
const ACTION_BUTTON_BORDER_WIDTH: f32 = 2.;
const ACTION_BUTTON_HORIZONTAL_PADDING: f32 = 8.;
const ACTION_BUTTON_FONT_SIZE: f32 = 14.;
const AUTH_OVERRIDE_DESCRIPTION: &str = "It looks like you logged into a Warp account through a web browser. If you continue, any personal Warp drive objects and preferences from this anonymous session with be permanently deleted.";
const AUTH_OVERRIDE_CONFIRMATION_WARNING: &str = "This cannot be undone.";
const AUTH_OVERRIDE_INITIAL_STEP_HEADER: &str = "New login detected";
const AUTH_OVERRIDE_CONFIRM_CONFIRMATION_STEP_HEADER: &str =
"Delete personal Warp Drive objects and preferences?";
const AUTH_OVERRIDE_BULK_EXPORT_BUTTON_LABEL: &str = "Export your data";
const AUTH_OVERRIDE_BULK_EXPORT_DESCRIPTION: &str = " to import later.";
const AUTH_OVERRIDE_CANCEL_BUTTON_LABEL: &str = "Cancel";
const AUTH_OVERRIDE_CONTINUE_BUTTON_LABEL: &str = "Continue";
#[derive(Clone, Copy, Debug)]
pub enum AuthOverrideWarningBodyAction {
Close,
InitiateAllowLogin,
ConfirmAllowLogin,
BulkExport,
}
enum AuthOverrideConfirmationStep {
Initial,
ConfirmChangeUser,
}
#[derive(Default)]
struct MouseStateHandles {
cancel_button_mouse_state_handle: MouseStateHandle,
continue_button_mouse_state_handle: MouseStateHandle,
export_button_mouse_state_handle: MouseStateHandle,
}
pub struct AuthOverrideWarningBody {
mouse_state_handles: MouseStateHandles,
confirmation_step: AuthOverrideConfirmationStep,
}
pub fn init(app: &mut AppContext) {
use galaxyui::keymap::macros::*;
app.register_fixed_bindings([FixedBinding::new(
"enter",
AuthOverrideWarningBodyAction::Close,
id!("AuthOverrideWarningBody"),
)]);
app.register_fixed_bindings([FixedBinding::new(
"escape",
AuthOverrideWarningBodyAction::Close,
id!("AuthOverrideWarningBody"),
)]);
}
impl AuthOverrideWarningBody {
pub fn new() -> Self {
AuthOverrideWarningBody {
mouse_state_handles: Default::default(),
confirmation_step: AuthOverrideConfirmationStep::Initial,
}
}
pub fn reset(&mut self) {
self.confirmation_step = AuthOverrideConfirmationStep::Initial;
}
fn render_header(&self, appearance: &Appearance, ui_builder: &UiBuilder) -> Box<dyn Element> {
let header_styles = UiComponentStyles {
font_family_id: Some(appearance.header_font_family()),
font_color: Some(appearance.theme().active_ui_text_color().into()),
font_size: Some(20.),
font_weight: Some(Weight::Semibold),
..Default::default()
};
let text = match self.confirmation_step {
AuthOverrideConfirmationStep::Initial => AUTH_OVERRIDE_INITIAL_STEP_HEADER,
AuthOverrideConfirmationStep::ConfirmChangeUser => {
AUTH_OVERRIDE_CONFIRM_CONFIRMATION_STEP_HEADER
}
};
ui_builder
.span(text)
.with_soft_wrap()
.with_style(header_styles)
.build()
.finish()
}
fn render_warning_icon(&self, appearance: &Appearance) -> Box<dyn Element> {
let color = match self.confirmation_step {
AuthOverrideConfirmationStep::Initial => {
appearance.theme().terminal_colors().normal.yellow
}
AuthOverrideConfirmationStep::ConfirmChangeUser => {
appearance.theme().terminal_colors().normal.red
}
};
ConstrainedBox::new(
Container::new(Icon::new("bundled/svg/alert-triangle.svg", color).finish())
.with_background(appearance.theme().surface_1())
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(10.)))
.with_horizontal_padding(11.)
.finish(),
)
.with_width(64.)
.with_height(64.)
.finish()
}
fn render_warning_description(
&self,
appearance: &Appearance,
ui_builder: &UiBuilder,
) -> Vec<Box<dyn Element>> {
let muted_styles = UiComponentStyles {
font_color: Some(
appearance
.theme()
.sub_text_color(appearance.theme().background())
.into(),
),
..Default::default()
};
match self.confirmation_step {
AuthOverrideConfirmationStep::Initial => {
let description = Container::new(
ui_builder
.paragraph(AUTH_OVERRIDE_DESCRIPTION)
.with_style(muted_styles)
.build()
.finish(),
)
.with_margin_top(AUTH_MODAL_GAP)
.finish();
let export = Container::new(
Flex::row()
.with_child(
ui_builder
.link(
AUTH_OVERRIDE_BULK_EXPORT_BUTTON_LABEL.into(),
None,
Some(Box::new(|ctx| {
ctx.dispatch_typed_action(
AuthOverrideWarningBodyAction::BulkExport,
);
})),
self.mouse_state_handles
.export_button_mouse_state_handle
.clone(),
)
.soft_wrap(false)
.build()
.finish(),
)
.with_child(
ui_builder
.span(AUTH_OVERRIDE_BULK_EXPORT_DESCRIPTION)
.with_style(muted_styles)
.build()
.finish(),
)
.finish(),
)
.with_margin_top(AUTH_MODAL_GAP)
.with_margin_bottom(AUTH_MODAL_GAP)
.finish();
vec![description, export]
}
AuthOverrideConfirmationStep::ConfirmChangeUser => {
let confirmation = Container::new(
ui_builder
.paragraph(AUTH_OVERRIDE_CONFIRMATION_WARNING)
.with_style(muted_styles)
.build()
.finish(),
)
.with_margin_top(AUTH_MODAL_GAP)
.with_margin_bottom(AUTH_MODAL_GAP)
.finish();
vec![confirmation]
}
}
}
fn render_buttons(&self, appearance: &Appearance, ui_builder: &UiBuilder) -> Box<dyn Element> {
let button_color = appearance.theme().accent().into();
let button_styles = UiComponentStyles {
font_size: Some(ACTION_BUTTON_FONT_SIZE),
font_family_id: Some(appearance.ui_font_family()),
font_weight: Some(Weight::Bold),
background: Some(Fill::Solid(button_color)),
border_width: Some(ACTION_BUTTON_BORDER_WIDTH),
border_color: Some(Fill::Solid(ColorU::transparent_black())),
border_radius: Some(CornerRadius::with_all(Radius::Pixels(4.))),
padding: Some(Coords {
top: 0.,
bottom: 0.,
left: ACTION_BUTTON_HORIZONTAL_PADDING,
right: ACTION_BUTTON_HORIZONTAL_PADDING,
}),
height: Some(ACTION_BUTTON_HEIGHT),
..Default::default()
};
let hover_button_style = UiComponentStyles {
border_color: Some(Fill::Solid(lighten(button_color))),
..button_styles
};
let click_button_style = UiComponentStyles {
background: Some(Fill::Solid(darken(button_color))),
..hover_button_style
};
let outline_color: ColorU = appearance.theme().accent().into();
let outline_button_styles = UiComponentStyles {
font_size: Some(ACTION_BUTTON_FONT_SIZE),
font_family_id: Some(appearance.ui_font_family()),
font_weight: Some(Weight::Bold),
border_width: Some(2.),
border_radius: Some(CornerRadius::with_all(Radius::Pixels(4.))),
padding: Some(Coords {
top: 0.,
bottom: 0.,
left: ACTION_BUTTON_HORIZONTAL_PADDING,
right: ACTION_BUTTON_HORIZONTAL_PADDING,
}),
height: Some(ACTION_BUTTON_HEIGHT),
..Default::default()
};
let outline_hover_button_style = UiComponentStyles {
border_color: Some(outline_color.into()),
font_color: Some(outline_color),
..outline_button_styles
};
let outline_click_button_style = UiComponentStyles {
border_color: Some(Fill::Solid(darken(outline_color))),
..outline_hover_button_style
};
let cancel_button = ui_builder
.button_with_custom_styles(
ButtonVariant::Accent,
self.mouse_state_handles
.cancel_button_mouse_state_handle
.clone(),
button_styles,
Some(hover_button_style),
Some(click_button_style),
None,
)
.with_centered_text_label(AUTH_OVERRIDE_CANCEL_BUTTON_LABEL.into())
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(AuthOverrideWarningBodyAction::Close);
})
.finish();
let continue_action = match self.confirmation_step {
AuthOverrideConfirmationStep::Initial => {
AuthOverrideWarningBodyAction::InitiateAllowLogin
}
AuthOverrideConfirmationStep::ConfirmChangeUser => {
AuthOverrideWarningBodyAction::ConfirmAllowLogin
}
};
let continue_button = ui_builder
.button_with_custom_styles(
ButtonVariant::Outlined,
self.mouse_state_handles
.continue_button_mouse_state_handle
.clone(),
outline_button_styles,
Some(outline_hover_button_style),
Some(outline_click_button_style),
None,
)
.with_centered_text_label(AUTH_OVERRIDE_CONTINUE_BUTTON_LABEL.into())
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(continue_action);
})
.finish();
Flex::row()
.with_child(
Shrinkable::new(
1.,
Container::new(continue_button)
.with_margin_right(BUTTON_ROW_GAP)
.finish(),
)
.finish(),
)
.with_child(Shrinkable::new(1., cancel_button).finish())
.finish()
}
}
pub enum AuthOverrideWarningBodyEvent {
Close,
AllowLogin,
BulkExport,
}
impl Entity for AuthOverrideWarningBody {
type Event = AuthOverrideWarningBodyEvent;
}
impl TypedActionView for AuthOverrideWarningBody {
type Action = AuthOverrideWarningBodyAction;
fn handle_action(
&mut self,
action: &AuthOverrideWarningBodyAction,
ctx: &mut ViewContext<Self>,
) {
match action {
AuthOverrideWarningBodyAction::Close => {
ctx.emit(AuthOverrideWarningBodyEvent::Close);
}
AuthOverrideWarningBodyAction::InitiateAllowLogin => {
self.confirmation_step = AuthOverrideConfirmationStep::ConfirmChangeUser;
ctx.notify();
}
AuthOverrideWarningBodyAction::ConfirmAllowLogin => {
ctx.emit(AuthOverrideWarningBodyEvent::AllowLogin);
}
AuthOverrideWarningBodyAction::BulkExport => {
ctx.emit(AuthOverrideWarningBodyEvent::BulkExport);
}
}
}
}
impl View for AuthOverrideWarningBody {
fn ui_name() -> &'static str {
"AuthOverrideWarningBody"
}
fn accessibility_contents(&self, _: &AppContext) -> Option<AccessibilityContent> {
Some(AccessibilityContent::new(
"New login detected",
"Warp has detected a new login from a web browser. Press escape to cancel and continue using Warp without login.",
WarpA11yRole::HelpRole,
))
}
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
if focus_ctx.is_self_focused() {
ctx.focus_self();
ctx.notify();
}
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let ui_builder = appearance.ui_builder();
let logo_row = Container::new(self.render_warning_icon(appearance))
.with_margin_bottom(AUTH_MODAL_GAP)
.finish();
let content = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(logo_row)
.with_child(self.render_header(appearance, ui_builder))
.with_children(self.render_warning_description(appearance, ui_builder))
.with_child(self.render_buttons(appearance, ui_builder))
.finish();
Container::new(content)
.with_background(
appearance
.theme()
.background()
.blend(&appearance.theme().surface_1().with_opacity(50)),
)
.with_corner_radius(CornerRadius::with_all(MODAL_CORNER_RADIUS))
.with_uniform_padding(MODAL_PADDING)
.finish()
}
}
+17 -118
View File
@@ -1,114 +1,29 @@
use pathfinder_color::ColorU;
use galaxyui::{AppContext, Element, Entity, TypedActionView, View, ViewContext};
use galaxy_core::ui::appearance::Appearance;
use galaxyui::elements::Container;
use galaxyui::elements::Fill;
use galaxyui::FocusContext;
use galaxyui::SingletonEntity;
use galaxyui::TypedActionView;
use crate::auth::auth_override_warning_body::AuthOverrideWarningBody;
use crate::auth::auth_view_modal::AuthRedirectPayload;
use crate::modal::Modal;
use crate::root_view::unthemed_window_border;
use galaxyui::elements::ChildView;
use galaxyui::ui_components::components::{Coords, UiComponentStyles};
use galaxyui::{AppContext, Element, Entity, View, ViewContext, ViewHandle};
use super::auth_manager::AuthManager;
use super::auth_manager::AuthManagerEvent;
use super::auth_override_warning_body::AuthOverrideWarningBodyEvent;
pub struct AuthOverrideWarningModal {
auth_override_warning_modal: ViewHandle<Modal<AuthOverrideWarningBody>>,
interrupted_auth_payload: Option<AuthRedirectPayload>,
variant: AuthOverrideWarningModalVariant,
}
use super::auth_view_modal::AuthRedirectPayload;
#[derive(Clone, Debug)]
pub enum AuthOverrideWarningModalVariant {
OnboardingView,
WorkspaceModal,
}
const MODAL_WIDTH: f32 = 364.;
impl AuthOverrideWarningModal {
pub fn new(ctx: &mut ViewContext<Self>, variant: AuthOverrideWarningModalVariant) -> Self {
let auth_screen_view = ctx.add_typed_action_view(|_| AuthOverrideWarningBody::new());
ctx.subscribe_to_view(&auth_screen_view, |me, _, event, ctx| match event {
AuthOverrideWarningBodyEvent::Close => me.close(ctx),
AuthOverrideWarningBodyEvent::AllowLogin => {
if let Some(auth_payload) = me.interrupted_auth_payload.clone() {
AuthManager::handle(ctx).update(ctx, |auth_manager, ctx| {
auth_manager.resume_interrupted_auth_payload(auth_payload, ctx);
});
}
ctx.emit(AuthOverrideWarningModalEvent::Close);
}
AuthOverrideWarningBodyEvent::BulkExport => {
ctx.emit(AuthOverrideWarningModalEvent::BulkExport);
}
});
let auth_override_warning_modal = ctx.add_typed_action_view(|ctx| {
Modal::new(None, auth_screen_view, ctx)
.with_body_style(UiComponentStyles {
padding: Some(Coords::uniform(0.)),
..Default::default()
})
.with_modal_style(UiComponentStyles {
width: Some(MODAL_WIDTH),
border_color: Some(Fill::from(ColorU::transparent_black())), // override default modal border color
..Default::default()
})
});
let auth_manager = AuthManager::handle(ctx);
ctx.subscribe_to_model(&auth_manager, |me, _, event, ctx| {
me.handle_auth_manager_event(event, ctx);
});
Self {
auth_override_warning_modal,
interrupted_auth_payload: None,
variant,
}
}
fn focus(&self, ctx: &mut ViewContext<Self>) {
ctx.focus(&self.auth_override_warning_modal);
ctx.notify();
}
fn close(&mut self, ctx: &mut ViewContext<Self>) {
ctx.emit(AuthOverrideWarningModalEvent::Close);
self.auth_override_warning_modal.update(ctx, |modal, ctx| {
modal.body().update(ctx, |body, _| {
body.reset();
})
})
}
pub fn set_interrupted_auth_payload(&mut self, auth_payload: AuthRedirectPayload) {
self.interrupted_auth_payload = Some(auth_payload);
}
fn handle_auth_manager_event(&mut self, event: &AuthManagerEvent, ctx: &mut ViewContext<Self>) {
if let AuthManagerEvent::AuthComplete = event {
self.interrupted_auth_payload = None;
self.close(ctx);
}
ctx.notify();
}
}
#[derive(PartialEq, Eq)]
#[derive(Clone, Debug)]
pub enum AuthOverrideWarningModalEvent {
Close,
BulkExport,
}
pub struct AuthOverrideWarningModal;
impl AuthOverrideWarningModal {
pub fn new(_ctx: &mut ViewContext<Self>, _variant: AuthOverrideWarningModalVariant) -> Self {
Self
}
pub fn set_interrupted_auth_payload(&mut self, _payload: AuthRedirectPayload) {}
}
impl Entity for AuthOverrideWarningModal {
type Event = AuthOverrideWarningModalEvent;
}
@@ -118,28 +33,12 @@ impl View for AuthOverrideWarningModal {
"AuthOverrideWarningModal"
}
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
if focus_ctx.is_self_focused() {
self.focus(ctx);
}
}
fn render(&self, ctx: &AppContext) -> Box<dyn Element> {
let background_color = match self.variant {
AuthOverrideWarningModalVariant::OnboardingView => {
Appearance::as_ref(ctx).theme().background().into()
}
AuthOverrideWarningModalVariant::WorkspaceModal => ColorU::transparent_black(),
};
Container::new(ChildView::new(&self.auth_override_warning_modal).finish())
.with_background_color(background_color)
.with_corner_radius(ctx.windows().window_corner_radius())
.with_border(unthemed_window_border())
.finish()
fn render(&self, _app: &AppContext) -> Box<dyn Element> {
galaxyui::elements::Empty::new().finish()
}
}
impl TypedActionView for AuthOverrideWarningModal {
type Action = ();
fn handle_action(&mut self, _action: &(), _ctx: &mut ViewContext<Self>) {}
}
+11 -43
View File
@@ -230,7 +230,7 @@ impl AuthState {
/// Determines whether the user should be considered as logged in.
pub fn is_logged_in(&self) -> bool {
self.credentials.read().is_some()
true
}
/// Returns whether the user should be treated as not having a full account.
@@ -240,7 +240,7 @@ impl AuthState {
/// during the transient state where credentials exist but user data hasn't loaded
/// yet, the user is conservatively treated as lacking a full account.
pub fn is_anonymous_or_logged_out(&self) -> bool {
!self.is_logged_in() || self.is_user_anonymous().unwrap_or(true)
false
}
/// Returns the cached access token, if any exists. This method *will not* check if the JWT is
@@ -276,7 +276,7 @@ impl AuthState {
/// Returns whether the user considered onboarded to Warp.
pub fn is_onboarded(&self) -> Option<bool> {
self.user.read().as_ref().map(|user| user.is_onboarded)
Some(true)
}
/// Returns the user's email domain (anything after the @ sign of their email).
@@ -296,59 +296,27 @@ impl AuthState {
/// Anonymous users are real Warp users, but have no providers linked in Firebase.
/// Returns `None` if there is no user data.
pub fn is_user_anonymous(&self) -> Option<bool> {
self.user
.read()
.as_ref()
.map(|user| user.is_user_anonymous())
Some(false)
}
/// Returns whether or not the user is a "web client anonymous user", aka their account
/// originated from viewing Warp on web.
pub fn is_user_web_anonymous_user(&self) -> Option<bool> {
self.user.read().as_ref().map(|user| {
user.anonymous_user_type() == Some(AnonymousUserType::WebClientAnonymousUser)
&& user.linked_at().is_none()
})
Some(false)
}
/// Returns whether or not the user is a feature gated anonymous user.
pub fn is_anonymous_user_feature_gated(&self) -> Option<bool> {
self.user.read().as_ref().map(|user| {
if !self.is_user_anonymous().unwrap_or_default() {
return false;
}
matches!(
user.anonymous_user_type(),
Some(AnonymousUserType::NativeClientAnonymousUserFeatureGated)
)
})
Some(false)
}
/// Returns whether or not the anonymous user is past any of their Warp Drive object limits.
pub fn is_anonymous_user_past_object_limit(
&self,
object_type: ObjectType,
num_objects: usize,
_object_type: ObjectType,
_num_objects: usize,
) -> Option<bool> {
self.user.read().as_ref().map(|user| {
if !self.is_anonymous_user_feature_gated().unwrap_or_default() {
return false;
}
if let Some(limits) = user.personal_object_limits() {
match object_type {
ObjectType::Notebook => num_objects > limits.notebook_limit,
ObjectType::Workflow => num_objects > limits.workflow_limit,
ObjectType::GenericStringObject(GenericStringObjectFormat::Json(
JsonObjectType::EnvVarCollection,
)) => num_objects > limits.env_var_limit,
_ => false,
}
} else {
false
}
})
Some(false)
}
/// Returns the user's photo URL from Firebase,
@@ -363,7 +331,7 @@ impl AuthState {
/// Returns whether or not the user needs to link their account to an SSO provider.
/// The actual value is calculated on the server to avoid additional RPCs to Firebase.
pub fn needs_sso_link(&self) -> Option<bool> {
self.user.read().as_ref().map(|user| user.needs_sso_link)
Some(false)
}
/// Returns the anonymous user type.
@@ -406,7 +374,7 @@ impl AuthState {
/// Returns whether a reauth is required for the current user given the state
/// of their refresh token.
pub fn needs_reauth(&self) -> bool {
self.needs_reauth.load(Ordering::Relaxed)
false
}
/// Sets whether a reauth is required for the current user.
File diff suppressed because it is too large Load Diff
+33 -360
View File
@@ -1,104 +1,11 @@
use crate::appearance::Appearance;
use crate::root_view::unthemed_window_border;
use crate::server::server_api::auth::UserAuthenticationError;
use crate::util::bindings::CustomAction;
use anyhow::{anyhow, Result};
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use anyhow::Result;
use url::Url;
use galaxy_core::errors::ErrorExt;
use galaxy_core::features::FeatureFlag;
use galaxyui::elements::ChildAnchor;
use galaxyui::elements::Container;
use galaxyui::elements::Fill;
use galaxyui::elements::HighlightedHyperlink;
use galaxyui::elements::MouseStateHandle;
use galaxyui::elements::OffsetPositioning;
use galaxyui::elements::ParentAnchor;
use galaxyui::elements::ParentElement;
use galaxyui::elements::ParentOffsetBounds;
use galaxyui::elements::Stack;
use galaxyui::keymap::FixedBinding;
use galaxyui::AppContext;
use galaxyui::FocusContext;
use galaxyui::SingletonEntity;
use galaxyui::TypedActionView;
use galaxyui::{AppContext, Element, Entity, TypedActionView, View, ViewContext};
use crate::auth::auth_view_body::AuthViewBody;
use crate::modal::Modal;
use std::collections::HashMap;
use galaxyui::elements::ChildView;
use galaxyui::ui_components::components::{Coords, UiComponentStyles};
use galaxyui::{Element, Entity, View, ViewContext, ViewHandle};
use super::auth_manager::AuthManager;
use super::auth_manager::AuthManagerEvent;
use super::auth_view_body::AuthStep;
use super::auth_view_body::AuthViewBodyEvent;
use super::credentials::RefreshToken;
use super::login_failure_notification::{self, LoginFailureReason};
use super::UserUid;
use galaxyui::actions::StandardAction;
pub fn init(app: &mut AppContext) {
use galaxyui::keymap::macros::*;
app.register_fixed_bindings([
// Bindings for paste require the StandardAction and CustomAction binding to work on all platforms.
FixedBinding::custom(
CustomAction::Paste,
AuthViewAction::PasteAuthUrl,
"Paste",
id!(AuthView::ui_name()),
),
FixedBinding::standard(
StandardAction::Paste,
AuthViewAction::PasteAuthUrl,
id!(AuthView::ui_name()),
),
]);
// For linux and Windows, default paste binding is ctrl+shift+v for PTY reasons.
// This can be confusing for users in some cases (and we might want
// to solve it in a more general way later). In the meantime, we
// add a basic ctrl+v binding for the auth view, since there is no
// terminal to interact with yet.
#[cfg(any(target_os = "linux", target_os = "windows"))]
app.register_fixed_bindings([FixedBinding::new(
"cmdorctrl-v",
AuthViewAction::PasteAuthUrl,
id!(AuthView::ui_name()),
)]);
}
use super::user_uid::UserUid;
#[derive(Clone, Debug)]
pub enum AuthViewAction {
/// Triggered when the user attempts to paste something while the auth view
/// modal is visible.
PasteAuthUrl,
DismissErrorNotification,
}
pub struct AuthView {
auth_screen_modal: ViewHandle<Modal<AuthViewBody>>,
// Reason for failing the most recent attempt to login, if any. When this is set, a
// notification containing the reason's error message is shown to the user.
pub last_login_failure_reason: Option<LoginFailureReason>,
close_login_notification_mouse_state: MouseStateHandle,
highlighted_hyperlink_state: HighlightedHyperlink,
auth_view_variant: AuthViewVariant,
}
const AUTH_URL_HOST: &str = "auth";
const AUTH_URL_REFRESH_TOKEN_QUERY_PARAM: &str = "refresh_token";
const AUTH_URL_NEW_USER_UID_QUERY_PARAM: &str = "user_uid";
const AUTH_URL_DELETED_ANON_USER_QUERY_PARAM: &str = "deleted_anonymous_user";
const AUTH_URL_STATE_QUERY_PARAM: &str = "state";
// `AuthRedirectPayload` is returned from the incoming redirect url.
#[derive(Debug, Clone)]
pub struct AuthRedirectPayload {
pub refresh_token: RefreshToken,
pub user_uid: Option<UserUid>,
@@ -107,226 +14,39 @@ pub struct AuthRedirectPayload {
}
impl AuthRedirectPayload {
/// Attempts to parse the `AuthRedirectPayload` from URL sent to Warp. To parse successfully, the URL
/// must be of format {scheme}://auth/desktop_redirect?refresh_token={token}.
pub fn from_url(url: Url) -> Result<Self> {
if url.host_str() != Some(AUTH_URL_HOST) {
return Err(anyhow!("Received URL with unexpected host: {} ", url));
}
let query_params: HashMap<_, _> = url.query_pairs().into_owned().collect();
if let Some(token) = query_params.get(AUTH_URL_REFRESH_TOKEN_QUERY_PARAM) {
let user_uid = query_params
.get(AUTH_URL_NEW_USER_UID_QUERY_PARAM)
.map(|uid| UserUid::new(uid));
Ok(Self {
refresh_token: RefreshToken::new(token),
user_uid,
deleted_anonymous_user: query_params
.get(AUTH_URL_DELETED_ANON_USER_QUERY_PARAM)
.map(|value| value == "true"),
state: query_params.get(AUTH_URL_STATE_QUERY_PARAM).cloned(),
})
} else {
Err(anyhow!(
"Received URL without refresh token query param: {}",
url
))
}
}
/// Like [`from_url()`], except first parses the given [`raw_url`] into a [`Url`] struct.
pub fn from_raw_url(raw_url: String) -> Result<Self> {
match Url::parse(&raw_url) {
Ok(parsed_url) => AuthRedirectPayload::from_url(parsed_url),
Err(error) => Err(anyhow!(error)),
}
pub fn from_url(_url: Url) -> Result<Self> {
anyhow::bail!("Auth UI removed")
}
}
const MODAL_WIDTH: f32 = 352.;
#[derive(Clone, Copy, Debug)]
pub enum AuthViewVariant {
Initial,
RequireLoginCloseable,
HitDriveObjectLimitCloseable,
ShareRequirementCloseable,
}
impl AuthView {
pub fn new(variant: AuthViewVariant, ctx: &mut ViewContext<Self>) -> Self {
let auth_screen_view = ctx.add_typed_action_view(|ctx| AuthViewBody::new(variant, ctx));
ctx.subscribe_to_view(&auth_screen_view, |me, _, event, ctx| match event {
AuthViewBodyEvent::Close => me.close(ctx),
AuthViewBodyEvent::SignUpButtonClicked => {
me.dismiss_error_notification(ctx);
}
AuthViewBodyEvent::AuthTokenEntered(token) => {
me.last_login_failure_reason = None;
me.handle_pasted_auth_url(token.clone(), ctx);
ctx.notify();
}
AuthViewBodyEvent::LoginLaterClicked => {
me.handle_login_later(ctx);
}
});
let auth_screen_modal = ctx.add_typed_action_view(|ctx| {
Modal::new(None, auth_screen_view, ctx)
.with_body_style(UiComponentStyles {
padding: Some(Coords::uniform(0.)),
..Default::default()
})
.with_modal_style(UiComponentStyles {
width: Some(MODAL_WIDTH),
border_color: Some(Fill::from(ColorU::transparent_black())), // override default modal border color
..Default::default()
})
});
let auth_manager = AuthManager::handle(ctx);
ctx.subscribe_to_model(&auth_manager, |me, _, event, ctx| {
me.handle_auth_manager_event(event, ctx);
});
Self {
auth_screen_modal,
last_login_failure_reason: None,
close_login_notification_mouse_state: Default::default(),
highlighted_hyperlink_state: Default::default(),
auth_view_variant: variant,
}
}
pub fn set_variant(&mut self, ctx: &mut ViewContext<Self>, variant: AuthViewVariant) {
self.auth_view_variant = variant;
self.update_auth_body(
ctx,
|body: &mut AuthViewBody, _: &mut ViewContext<'_, AuthViewBody>| {
body.set_variant(variant)
},
);
}
fn set_auth_step(&mut self, ctx: &mut ViewContext<Self>, step: AuthStep) {
self.update_auth_body(
ctx,
|body: &mut AuthViewBody, _: &mut ViewContext<'_, AuthViewBody>| {
body.set_auth_step(step)
},
);
}
pub fn skip_to_browser_open_step(&mut self, ctx: &mut ViewContext<Self>) {
self.set_auth_step(ctx, AuthStep::BrowserOpen);
}
fn focus(&self, ctx: &mut ViewContext<Self>) {
ctx.focus(&self.auth_screen_modal);
ctx.notify();
}
fn dismiss_error_notification(&mut self, ctx: &mut ViewContext<Self>) {
self.last_login_failure_reason = None;
ctx.notify();
}
fn close(&mut self, ctx: &mut ViewContext<Self>) {
self.update_auth_body(
ctx,
|body: &mut AuthViewBody, ctx: &mut ViewContext<'_, AuthViewBody>| {
body.reset_login_screen(ctx)
},
);
self.dismiss_error_notification(ctx);
ctx.emit(AuthViewEvent::Close);
}
/// Parses the given 'clipboard_content' string into a URL which is assumed to represent the
/// OAuth redirect URL containing the user's refresh token after the user authenticated Warp.
fn handle_pasted_auth_url(&mut self, pasted_url: String, ctx: &mut ViewContext<Self>) {
self.set_auth_token_input_editable(false, ctx);
match AuthRedirectPayload::from_raw_url(pasted_url) {
Ok(redirect_payload) => {
AuthManager::handle(ctx).update(ctx, |auth_manager, ctx| {
auth_manager.initialize_user_from_auth_payload(redirect_payload, true, ctx);
});
}
Err(error) => {
log::error!("Failed to parse AuthRedirectPayload from redirect URL: {error:#}");
self.last_login_failure_reason =
Some(LoginFailureReason::InvalidRedirectUrl { was_pasted: true });
self.set_auth_token_input_editable(true, ctx);
}
}
}
fn set_auth_token_input_editable(&mut self, is_editable: bool, ctx: &mut ViewContext<Self>) {
self.update_auth_body(ctx, |body, ctx| body.set_input_editable(is_editable, ctx))
}
fn update_auth_body<S, F>(&mut self, ctx: &mut ViewContext<Self>, cb: F) -> S
where
F: FnOnce(&mut AuthViewBody, &mut ViewContext<'_, AuthViewBody>) -> S,
{
self.auth_screen_modal
.update(ctx, |modal, ctx| modal.body().update(ctx, cb))
}
pub fn handle_login_later(&mut self, ctx: &mut ViewContext<Self>) {
if FeatureFlag::SkipFirebaseAnonymousUser.is_enabled() {
AuthManager::handle(ctx).update(ctx, |_, ctx| {
ctx.emit(AuthManagerEvent::SkippedLogin);
});
} else {
AuthManager::handle(ctx).update(ctx, |auth_manager, ctx| {
auth_manager.create_anonymous_user(None, ctx)
});
}
}
fn handle_auth_manager_event(&mut self, event: &AuthManagerEvent, ctx: &mut ViewContext<Self>) {
match event {
AuthManagerEvent::AuthComplete | AuthManagerEvent::SkippedLogin => {
self.close(ctx);
}
AuthManagerEvent::AuthFailed(err) => {
if err.is_actionable() {
log::error!("Failed to log in user: {err:#}");
}
if let UserAuthenticationError::InvalidStateParameter = err {
self.last_login_failure_reason =
Some(LoginFailureReason::InvalidStateParameter);
} else if let UserAuthenticationError::MissingStateParameter = err {
self.last_login_failure_reason =
Some(LoginFailureReason::MissingStateParameter);
} else {
self.last_login_failure_reason =
Some(LoginFailureReason::FailedUserAuthentication);
}
self.set_auth_token_input_editable(true, ctx);
}
AuthManagerEvent::CreateAnonymousUserFailed => {
self.last_login_failure_reason = Some(LoginFailureReason::FailedUserAuthentication);
self.set_auth_token_input_editable(true, ctx);
}
AuthManagerEvent::MintCustomTokenFailed(_err) => {
self.last_login_failure_reason = Some(LoginFailureReason::FailedMintCustomToken);
}
_ => {}
}
ctx.notify();
}
}
#[derive(PartialEq, Eq)]
#[derive(Clone, Debug)]
pub enum AuthViewEvent {
Close,
}
pub struct AuthView {
pub last_login_failure_reason: Option<LoginFailureReason>,
}
impl AuthView {
pub fn new(_variant: AuthViewVariant, _ctx: &mut ViewContext<Self>) -> Self {
Self {
last_login_failure_reason: None,
}
}
pub fn set_variant(&mut self, _ctx: &mut ViewContext<Self>, _variant: AuthViewVariant) {}
pub fn skip_to_browser_open_step(&mut self, _ctx: &mut ViewContext<Self>) {}
}
impl Entity for AuthView {
type Event = AuthViewEvent;
}
@@ -336,66 +56,19 @@ impl View for AuthView {
"AuthView"
}
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
if focus_ctx.is_self_focused() {
self.focus(ctx);
}
}
fn render(&self, ctx: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(ctx);
let mut stack = Stack::new();
stack.add_child(ChildView::new(&self.auth_screen_modal).finish());
if let Some(login_failure_reason) = &self.last_login_failure_reason {
let login_failure_notification = login_failure_notification::render(
login_failure_reason,
self.close_login_notification_mouse_state.clone(),
self.highlighted_hyperlink_state.clone(),
AuthViewAction::DismissErrorNotification,
ctx,
);
stack.add_positioned_overlay_child(
login_failure_notification,
OffsetPositioning::offset_from_parent(
vec2f(0., 40.),
ParentOffsetBounds::ParentBySize,
ParentAnchor::TopMiddle,
ChildAnchor::TopMiddle,
),
);
}
let background_color = match self.auth_view_variant {
AuthViewVariant::Initial => appearance.theme().background().into(),
AuthViewVariant::RequireLoginCloseable
| AuthViewVariant::HitDriveObjectLimitCloseable
| AuthViewVariant::ShareRequirementCloseable => ColorU::transparent_black(),
};
// TODO(liam): use theme colors for background and window border
Container::new(stack.finish())
.with_background_color(background_color)
.with_corner_radius(ctx.windows().window_corner_radius())
.with_border(unthemed_window_border())
.finish()
fn render(&self, _app: &AppContext) -> Box<dyn Element> {
galaxyui::elements::Empty::new().finish()
}
}
impl TypedActionView for AuthView {
type Action = AuthViewAction;
fn handle_action(&mut self, action: &AuthViewAction, ctx: &mut ViewContext<Self>) {
match action {
AuthViewAction::PasteAuthUrl => {
self.last_login_failure_reason = None;
self.update_auth_body(ctx, |body, ctx| body.handle_paste(ctx));
ctx.notify();
}
AuthViewAction::DismissErrorNotification => {
self.dismiss_error_notification(ctx);
}
}
}
type Action = ();
fn handle_action(&mut self, _action: &(), _ctx: &mut ViewContext<Self>) {}
}
#[derive(Clone, Debug)]
pub enum LoginFailureReason {
InvalidRedirectUrl { was_pasted: bool },
}
pub fn init(_app: &mut AppContext) {}
-609
View File
@@ -1,609 +0,0 @@
use pathfinder_color::ColorU;
use galaxy_core::channel::ChannelState;
use galaxy_core::features::FeatureFlag;
use galaxy_core::ui::{
appearance::Appearance,
builder::UiBuilder,
color::{darken, lighten},
theme::ColorScheme,
};
use galaxyui::{
assets::asset_cache::AssetSource,
elements::{
Border, CacheOption, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Fill,
Flex, Image, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius,
Shrinkable,
},
fonts::Weight,
ui_components::{
button::ButtonVariant,
components::{Coords, UiComponent, UiComponentStyles},
switch::SwitchStateHandle,
},
Action, AppContext, Element, SingletonEntity as _,
};
use crate::settings::PrivacySettings;
use crate::themes::theme::ThemeKind;
const PRIVACY_URL: &str = "https://warp.dev/privacy";
pub const AUTH_MODAL_GAP: f32 = 16.;
const MODAL_CORNER_RADIUS: Radius = Radius::Pixels(8.);
pub fn action_button_color_and_variant(appearance: &Appearance) -> (ColorU, ButtonVariant) {
let (button_color, button_variant) = match appearance.theme().name() {
Some(name) if ThemeKind::Dark.matches(&name) => {
(ColorU::new(0, 109, 168, 255), ButtonVariant::Basic)
}
Some(_) => (appearance.theme().accent().into(), ButtonVariant::Accent),
None => (appearance.theme().accent().into(), ButtonVariant::Accent),
};
(button_color, button_variant)
}
pub fn render_offline_contents<A>(
appearance: &Appearance,
ui_builder: &UiBuilder,
mouse_state_handle: MouseStateHandle,
action: A,
) -> Box<dyn Element>
where
A: Action + Clone,
{
let disclaimer_color = appearance
.theme()
.sub_text_color(appearance.theme().background())
.into();
let disclaimer_styles = UiComponentStyles {
font_color: Some(disclaimer_color),
..Default::default()
};
let text = "You are currently offline. An internet connection is required to use Warp for the first time.";
let (button_color, button_variant) = action_button_color_and_variant(appearance);
let button_styles = UiComponentStyles {
font_size: Some(14.),
font_family_id: Some(appearance.ui_font_family()),
font_weight: Some(Weight::Bold),
background: Some(Fill::Solid(button_color)),
border_width: Some(2.),
border_color: Some(Fill::Solid(ColorU::transparent_black())),
border_radius: Some(CornerRadius::with_all(Radius::Pixels(4.))),
padding: Some(Coords {
top: 0.,
bottom: 0.,
left: 8.,
right: 8.,
}),
height: Some(40.),
..Default::default()
};
let hover_button_style = UiComponentStyles {
border_color: Some(Fill::Solid(lighten(button_color))),
..button_styles
};
let click_button_style = UiComponentStyles {
background: Some(Fill::Solid(darken(button_color))),
..hover_button_style
};
let button = ui_builder
.button_with_custom_styles(
button_variant,
mouse_state_handle.clone(),
button_styles,
Some(hover_button_style),
Some(click_button_style),
None,
)
.with_centered_text_label("Learn more".into())
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(action.clone());
})
.finish();
Flex::column()
.with_child(
Container::new(
ui_builder
.paragraph(text)
.with_style(disclaimer_styles)
.build()
.finish(),
)
.with_margin_bottom(AUTH_MODAL_GAP)
.finish(),
)
.with_child(button)
.finish()
}
pub fn render_square_logo(appearance: &Appearance) -> Box<dyn Element> {
let image_path = if appearance.theme().inferred_color_scheme() == ColorScheme::LightOnDark {
"bundled/svg/warp-logo-light.svg"
} else {
"bundled/svg/warp-logo-dark.svg"
};
ConstrainedBox::new(
Container::new(
Image::new(
AssetSource::Bundled { path: image_path },
CacheOption::BySize,
)
.finish(),
)
.with_background(appearance.theme().surface_2())
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(10.)))
.with_horizontal_padding(11.)
.finish(),
)
.with_width(64.)
.with_height(64.)
.finish()
}
pub fn render_offline_info_overlay_body<A>(
appearance: &Appearance,
mouse_state_handle: MouseStateHandle,
action: A,
) -> Box<dyn Element>
where
A: Action + Clone,
{
let header_styles = UiComponentStyles {
font_family_id: Some(appearance.header_font_family()),
font_color: Some(appearance.theme().active_ui_text_color().into()),
font_size: Some(20.),
font_weight: Some(Weight::Semibold),
..Default::default()
};
let body_text_color = appearance
.theme()
.sub_text_color(appearance.theme().background())
.into();
let body_text_styles = UiComponentStyles {
font_color: Some(body_text_color),
..Default::default()
};
let paragraph_1 = "All of Warps non-cloud features work offline.";
let paragraph_2 = "However, we require users to be online when using Warp for the first time in order to enable Warp's AI and cloud features.";
let paragraph_3 = "We offer cloud features to all users, and so we need an internet connection to meter AI usage, prevent abuse, and associate cloud objects with users. If you opt to use Warp logged-out, a unique ID will be attached to an anonymous user account in order to support these features.";
Container::new(
Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(
Container::new(render_square_logo(appearance))
.with_margin_bottom(AUTH_MODAL_GAP)
.finish(),
)
.with_child(
Container::new(
appearance
.ui_builder()
.span("Using Warp Offline")
.with_style(header_styles)
.build()
.finish(),
)
.with_margin_bottom(AUTH_MODAL_GAP)
.finish(),
)
.with_child(
Container::new(
appearance
.ui_builder()
.paragraph(paragraph_1)
.with_style(body_text_styles)
.build()
.finish(),
)
.with_margin_bottom(4.)
.finish(),
)
.with_child(
Container::new(
appearance
.ui_builder()
.paragraph(paragraph_2)
.with_style(body_text_styles)
.build()
.finish(),
)
.with_margin_bottom(4.)
.finish(),
)
.with_child(
Container::new(
appearance
.ui_builder()
.paragraph(paragraph_3)
.with_style(body_text_styles)
.build()
.finish(),
)
.with_margin_bottom(AUTH_MODAL_GAP)
.finish(),
)
.with_child(render_close_overlay_button(
appearance,
appearance.ui_builder(),
"Dismiss".into(),
mouse_state_handle,
action,
))
.finish(),
)
.finish()
}
pub fn render_close_overlay_button<A>(
appearance: &Appearance,
ui_builder: &UiBuilder,
label: String,
mouse_state_handle: MouseStateHandle,
action: A,
) -> Box<dyn Element>
where
A: Action + Clone,
{
let (button_color, button_variant) = action_button_color_and_variant(appearance);
let button_styles = UiComponentStyles {
font_size: Some(14.),
font_family_id: Some(appearance.ui_font_family()),
font_weight: Some(Weight::Bold),
background: Some(Fill::Solid(button_color)),
border_width: Some(2.),
border_color: Some(Fill::Solid(ColorU::transparent_black())),
border_radius: Some(CornerRadius::with_all(Radius::Pixels(4.))),
padding: Some(Coords {
top: 0.,
bottom: 0.,
left: 8.,
right: 8.,
}),
height: Some(40.),
..Default::default()
};
let hover_button_style = UiComponentStyles {
border_color: Some(Fill::Solid(lighten(button_color))),
..button_styles
};
let click_button_style = UiComponentStyles {
background: Some(Fill::Solid(darken(button_color))),
..hover_button_style
};
ui_builder
.button_with_custom_styles(
button_variant,
mouse_state_handle.clone(),
button_styles,
Some(hover_button_style),
Some(click_button_style),
None,
)
.with_centered_text_label(label)
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(action.clone());
})
.finish()
}
pub fn render_overlay(overlay_body: Box<dyn Element>, appearance: &Appearance) -> Box<dyn Element> {
Container::new(overlay_body)
.with_background(appearance.theme().surface_1())
.with_border(Border::all(1.).with_border_fill(appearance.theme().outline()))
.with_corner_radius(CornerRadius::with_all(MODAL_CORNER_RADIUS))
.with_uniform_padding(32.)
.finish()
}
// ---------------------------------------------------------------------------
// Privacy settings overlay (shared between AuthViewBody and LoginSlideView)
// ---------------------------------------------------------------------------
/// Handles needed to render the privacy settings overlay.
#[derive(Default)]
pub struct PrivacySettingsHandles {
pub telemetry_switch: SwitchStateHandle,
pub crash_reporting_switch: SwitchStateHandle,
pub cloud_conversation_storage_switch: SwitchStateHandle,
pub close_button_mouse: MouseStateHandle,
pub telemetry_docs_mouse: MouseStateHandle,
}
/// Actions dispatched by the privacy settings overlay toggles.
pub struct PrivacySettingsActions<A: Action + Clone> {
pub toggle_telemetry: A,
pub toggle_crash_reporting: A,
pub toggle_cloud_conversation_storage: A,
pub hide_overlay: A,
}
/// Renders the full privacy settings overlay body (logo + header + toggles + done button).
/// This is the content that goes inside `render_overlay()`.
///
/// `is_ai_enabled` gates whether AI-dependent toggles (e.g. the cloud conversation
/// storage toggle) are shown. Callers should pass the effective AI-enabled state
/// for their context (the in-memory onboarding selection during the login slide,
/// or the stored setting elsewhere).
pub fn render_privacy_settings_overlay_body<A: Action + Clone + 'static>(
appearance: &Appearance,
app: &AppContext,
handles: &PrivacySettingsHandles,
actions: &PrivacySettingsActions<A>,
is_ai_enabled: bool,
) -> Box<dyn Element> {
let ui_builder = appearance.ui_builder();
let header_styles = UiComponentStyles {
font_family_id: Some(appearance.header_font_family()),
font_color: Some(appearance.theme().active_ui_text_color().into()),
font_size: Some(20.),
font_weight: Some(Weight::Semibold),
..Default::default()
};
Container::new(
Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(
Container::new(render_square_logo(appearance))
.with_margin_bottom(AUTH_MODAL_GAP)
.finish(),
)
.with_child(
Container::new(
ui_builder
.span("Privacy Settings")
.with_style(header_styles)
.build()
.finish(),
)
.with_margin_bottom(AUTH_MODAL_GAP)
.finish(),
)
.with_child(render_privacy_settings_toggles(
appearance,
app,
handles,
actions,
is_ai_enabled,
))
.with_child(render_close_overlay_button(
appearance,
ui_builder,
"Done".into(),
handles.close_button_mouse.clone(),
actions.hide_overlay.clone(),
))
.finish(),
)
.with_background(appearance.theme().surface_1())
.finish()
}
fn render_privacy_settings_section_header(
text: impl Into<String>,
appearance: &Appearance,
) -> Container {
let section_header_styles = UiComponentStyles {
font_family_id: Some(appearance.header_font_family()),
font_color: Some(appearance.theme().active_ui_text_color().into()),
font_size: Some(14.),
font_weight: Some(Weight::Bold),
..Default::default()
};
Container::new(
appearance
.ui_builder()
.span(text.into())
.with_style(section_header_styles)
.build()
.finish(),
)
}
/// Renders the stack of privacy toggles shown in the privacy settings overlay.
///
/// `is_ai_enabled` gates AI-dependent toggles (the cloud conversation storage
/// toggle is hidden entirely when AI is disabled, since it has no effect).
pub fn render_privacy_settings_toggles<A: Action + Clone + 'static>(
appearance: &Appearance,
app: &AppContext,
handles: &PrivacySettingsHandles,
actions: &PrivacySettingsActions<A>,
is_ai_enabled: bool,
) -> Box<dyn Element> {
fn render_description(appearance: &Appearance, text: String) -> Box<dyn Element> {
let disclaimer_styles = UiComponentStyles {
font_color: Some(
appearance
.theme()
.sub_text_color(appearance.theme().background())
.into(),
),
..Default::default()
};
appearance
.ui_builder()
.paragraph(text)
.with_style(disclaimer_styles)
.build()
.finish()
}
let toggle_telemetry = actions.toggle_telemetry.clone();
let telemetry_toggle = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_child(
Shrinkable::new(
1.,
render_privacy_settings_section_header("Help improve Warp", appearance).finish(),
)
.finish(),
)
.with_child(
appearance
.ui_builder()
.switch(handles.telemetry_switch.clone())
.check(PrivacySettings::as_ref(app).is_telemetry_enabled)
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(toggle_telemetry.clone());
})
.finish(),
)
.finish();
let telemetry_description = render_description(
appearance,
"High-level feature usage data helps Warp's product team prioritize the roadmap.".into(),
);
let telemetry_link = Flex::row()
.with_child(
appearance
.ui_builder()
.link(
"Learn more".into(),
Some(PRIVACY_URL.into()),
None,
handles.telemetry_docs_mouse.clone(),
)
.soft_wrap(false)
.build()
.finish(),
)
.finish();
let toggle_crash = actions.toggle_crash_reporting.clone();
let crash_reporting_toggle = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_child(
Shrinkable::new(
1.,
render_privacy_settings_section_header("Send crash reports", appearance).finish(),
)
.finish(),
)
.with_child(
appearance
.ui_builder()
.switch(handles.crash_reporting_switch.clone())
.check(PrivacySettings::as_ref(app).is_crash_reporting_enabled)
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(toggle_crash.clone());
})
.finish(),
)
.finish();
let crash_reporting_description = render_description(
appearance,
"Crash reporting helps Warp's engineering team understand stability and improve performance.".into(),
);
let toggle_cloud = actions.toggle_cloud_conversation_storage.clone();
let cloud_conversation_storage_toggle = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_child(
Shrinkable::new(
1.,
render_privacy_settings_section_header(
"Store AI conversations in the cloud",
appearance,
)
.finish(),
)
.finish(),
)
.with_child(
appearance
.ui_builder()
.switch(handles.cloud_conversation_storage_switch.clone())
.check(PrivacySettings::as_ref(app).is_cloud_conversation_storage_enabled)
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(toggle_cloud.clone());
})
.finish(),
)
.finish();
let cloud_conversation_storage_description = render_description(
appearance,
if PrivacySettings::as_ref(app).is_cloud_conversation_storage_enabled {
"Agent conversations can be shared with others and are retained when you log in on different devices. This data is only stored for product functionality, and Warp will not use it for analytics."
} else {
"Agent conversations are only stored locally on your machine, are lost upon logout, and cannot be shared. Note: conversation data for ambient agents are still stored in the cloud."
}
.into(),
);
let mut col = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
// Builds without a telemetry/crash reporting config (e.g. OpenWarp) cannot
// ship the corresponding events, so the toggles would be no-ops. Hide each
// one independently based on whether its backing config is present.
if ChannelState::is_telemetry_available() && !FeatureFlag::GlobalAIAnalyticsBanner.is_enabled()
{
col.add_children(vec![
Container::new(telemetry_toggle)
.with_margin_bottom(AUTH_MODAL_GAP)
.finish(),
Container::new(telemetry_description)
.with_margin_bottom(AUTH_MODAL_GAP)
.finish(),
Container::new(telemetry_link)
.with_margin_bottom(AUTH_MODAL_GAP)
.finish(),
]);
}
if ChannelState::is_crash_reporting_available() {
col.add_children(vec![
Container::new(crash_reporting_toggle)
.with_margin_bottom(AUTH_MODAL_GAP)
.finish(),
Container::new(crash_reporting_description)
.with_margin_bottom(AUTH_MODAL_GAP)
.finish(),
]);
}
// Hide the cloud conversation storage toggle entirely when AI is disabled:
// the setting has no effect without AI, and showing it is confusing.
if FeatureFlag::CloudConversations.is_enabled() && is_ai_enabled {
col.add_children(vec![
Container::new(cloud_conversation_storage_toggle)
.with_margin_bottom(AUTH_MODAL_GAP)
.finish(),
Container::new(cloud_conversation_storage_description)
.with_margin_bottom(20.)
.finish(),
]);
}
col.finish()
}
-139
View File
@@ -1,139 +0,0 @@
use std::borrow::Cow;
use pathfinder_color::ColorU;
use galaxyui::{
elements::{
Align, Border, ConstrainedBox, Container, CornerRadius, Flex, ParentElement, Shrinkable,
},
ui_components::{
components::{UiComponent, UiComponentStyles},
text::Span,
},
AppContext, Element, SingletonEntity as _,
};
use crate::{
appearance::Appearance,
modal::MODAL_CORNER_RADIUS,
root_view::unthemed_window_border,
themes::theme::{Blend, Fill},
};
/// A full-window login error.
///
/// This is used for uncommon login error states, such as:
/// * A user needing to link SSO after logging in with an incorrect Firebase provider.
/// * An error importing the user from a host web application.
pub struct LoginErrorModal {
modal_styles: UiComponentStyles,
header_styles: UiComponentStyles,
header: Option<Cow<'static, str>>,
detail_styles: UiComponentStyles,
detail: Option<Cow<'static, str>>,
action: Option<Box<dyn Element>>,
window_corner_radius: CornerRadius,
}
impl LoginErrorModal {
pub fn new(app: &AppContext) -> Self {
let appearance = Appearance::as_ref(app);
let modal_styles = UiComponentStyles {
width: Some(480.),
height: Some(280.),
border_color: Some(Fill::black().blend(&Fill::white().with_opacity(15)).into()),
border_width: Some(1.),
..Default::default()
};
let header_styles = UiComponentStyles {
font_family_id: Some(appearance.ui_font_family()),
font_size: Some(appearance.header_font_size()),
..Default::default()
};
let detail_styles = UiComponentStyles {
font_family_id: Some(appearance.ui_font_family()),
font_size: Some(appearance.ui_font_size()),
font_color: Some(appearance.theme().nonactive_ui_text_color().into()),
..Default::default()
};
LoginErrorModal {
modal_styles,
header_styles,
detail_styles,
window_corner_radius: app.windows().window_corner_radius(),
header: None,
detail: None,
action: None,
}
}
pub fn with_header(mut self, header: impl Into<Cow<'static, str>>) -> Self {
self.header = Some(header.into());
self
}
pub fn with_detail(mut self, detail: impl Into<Cow<'static, str>>) -> Self {
self.detail = Some(detail.into());
self
}
pub fn with_action(mut self, action: Box<dyn Element>) -> Self {
self.action = Some(action);
self
}
}
impl UiComponent for LoginErrorModal {
type ElementType = Container;
fn build(self) -> Self::ElementType {
let mut contents = Flex::column();
if let Some(header) = self.header {
contents.add_child(
Shrinkable::new(
1.,
Align::new(Span::new(header, self.header_styles).build().finish()).finish(),
)
.finish(),
);
}
if let Some(detail) = self.detail {
contents.add_child(
Shrinkable::new(
1.,
Align::new(Span::new(detail, self.detail_styles).build().finish()).finish(),
)
.finish(),
);
}
if let Some(action) = self.action {
contents.add_child(Shrinkable::new(1., Align::new(action).finish()).finish());
}
let modal = Container::new(
ConstrainedBox::new(contents.finish())
.with_width(self.modal_styles.width.unwrap_or_default())
.with_height(self.modal_styles.height.unwrap_or_default())
.finish(),
)
.with_border(
Border::all(self.modal_styles.border_width.unwrap_or_default())
.with_border_fill(self.modal_styles.border_color.unwrap_or_default()),
)
.with_corner_radius(CornerRadius::with_all(MODAL_CORNER_RADIUS))
.finish();
Container::new(Align::new(modal).finish())
.with_background_color(ColorU::black())
.with_corner_radius(self.window_corner_radius)
.with_border(unthemed_window_border())
}
fn with_style(mut self, style: UiComponentStyles) -> Self {
self.modal_styles = self.modal_styles.merge(style);
self.header_styles = self.header_styles.merge(style);
self.detail_styles = self.detail_styles.merge(style);
self
}
}
-167
View File
@@ -1,167 +0,0 @@
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
use galaxyui::{
elements::{
Border, ConstrainedBox, Container, CrossAxisAlignment, Flex, FormattedTextElement,
HighlightedHyperlink, Icon, MouseStateHandle, ParentElement, Shrinkable,
},
ui_components::components::UiComponent,
Action, AppContext, Element, SingletonEntity,
};
use crate::appearance::Appearance;
const LOGIN_TROUBLESHOOTING_DOCS_URL: &str =
"https://docs.warp.dev/support-and-community/troubleshooting-and-support/troubleshooting-login-issues";
/// Represents reasons why login failed.
pub enum LoginFailureReason {
InvalidRedirectUrl { was_pasted: bool },
FailedUserAuthentication,
FailedMintCustomToken,
InvalidStateParameter,
MissingStateParameter,
}
impl LoginFailureReason {
/// Returns an error message to be presented to the user when login fails.
pub(crate) fn to_formatted_text(&self) -> FormattedText {
fn with_troubleshooting_text(
mut fragments: Vec<FormattedTextFragment>,
) -> Vec<FormattedTextFragment> {
fragments.extend([
FormattedTextFragment::plain_text(" Not the first time? See our "),
FormattedTextFragment::hyperlink(
"troubleshooting docs",
LOGIN_TROUBLESHOOTING_DOCS_URL,
),
FormattedTextFragment::plain_text("."),
]);
fragments
}
let fragments = match self {
LoginFailureReason::InvalidRedirectUrl { was_pasted } => {
let text = if *was_pasted {
"An invalid auth token was entered into the modal."
} else {
"Failed to log in. Try manually copying the auth token from the \
authentication web page and pasting into the modal."
};
with_troubleshooting_text(vec![FormattedTextFragment::plain_text(text)])
}
LoginFailureReason::FailedUserAuthentication => {
with_troubleshooting_text(vec![FormattedTextFragment::plain_text(
"Request to log in failed.",
)])
}
LoginFailureReason::FailedMintCustomToken => {
with_troubleshooting_text(vec![FormattedTextFragment::plain_text(
"Request to sign up failed.",
)])
}
LoginFailureReason::InvalidStateParameter | LoginFailureReason::MissingStateParameter => {
with_troubleshooting_text(vec![FormattedTextFragment::plain_text(
"The redirect URL pasted did not originate from this app. Please click the button below to try again.",
)])
}
};
FormattedText::new([FormattedTextLine::Line(fragments)])
}
}
/// Renders a dismissable notification with a message explaining why login failed.
pub fn render<A: Action + Clone>(
login_failure_reason: &LoginFailureReason,
close_notification_mouse_state: MouseStateHandle,
highlighted_hyperlink_state: HighlightedHyperlink,
dismiss_action: A,
ctx: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(ctx);
let mut notification_contents =
Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
notification_contents.add_child(
Container::new(
ConstrainedBox::new(
Icon::new(
"bundled/svg/warning.svg",
appearance
.theme()
.main_text_color(appearance.theme().surface_2()),
)
.finish(),
)
.with_width(styles::NOTIFICATION_WARNING_ICON_SIZE)
.with_height(styles::NOTIFICATION_WARNING_ICON_SIZE)
.finish(),
)
.with_margin_right(styles::NOTIFICATION_WARNING_MARGIN_RIGHT)
.finish(),
);
notification_contents.add_child(
Shrinkable::new(
1.,
Container::new(
FormattedTextElement::new(
login_failure_reason.to_formatted_text(),
appearance.ui_font_size(),
appearance.ui_font_family(),
appearance.monospace_font_family(),
appearance
.theme()
.main_text_color(appearance.theme().surface_2())
.into_solid(),
highlighted_hyperlink_state,
)
.register_default_click_handlers(|url, _, ctx| {
ctx.open_url(&url.url);
})
.finish(),
)
.with_margin_right(styles::NOTIFICATION_MESSAGE_MARGIN_RIGHT)
.finish(),
)
.finish(),
);
notification_contents.add_child(
appearance
.ui_builder()
.close_button(
styles::NOTIFICATION_CLOSE_BUTTON_SIZE,
close_notification_mouse_state,
)
.build()
.on_click(move |ctx, _, _| ctx.dispatch_typed_action(dismiss_action.clone()))
.finish(),
);
ConstrainedBox::new(
Container::new(notification_contents.finish())
.with_background(appearance.theme().surface_2())
.with_corner_radius(styles::NOTIFICATION_CONTAINER_CORNER_RADIUS)
.with_border(
Border::all(styles::NOTIFICATION_BORDER_WIDTH)
.with_border_fill(appearance.theme().split_pane_border_color()),
)
.with_uniform_padding(styles::NOTIFICATION_CONTAINER_PADDING)
.with_uniform_margin(16.)
.finish(),
)
.with_max_width(450.)
.finish()
}
mod styles {
use galaxyui::elements::{CornerRadius, Radius};
pub const NOTIFICATION_CONTAINER_PADDING: f32 = 8.;
pub const NOTIFICATION_CONTAINER_CORNER_RADIUS: CornerRadius =
CornerRadius::with_all(Radius::Pixels(4.));
pub const NOTIFICATION_BORDER_WIDTH: f32 = 1.;
pub const NOTIFICATION_CLOSE_BUTTON_SIZE: f32 = 24.;
pub const NOTIFICATION_MESSAGE_MARGIN_RIGHT: f32 = 8.;
pub const NOTIFICATION_WARNING_ICON_SIZE: f32 = 20.;
pub const NOTIFICATION_WARNING_MARGIN_RIGHT: f32 = 12.;
}
File diff suppressed because it is too large Load Diff
+2 -13
View File
@@ -1,14 +1,9 @@
pub mod anonymous_id;
pub mod auth_manager;
mod auth_override_warning_body;
pub mod auth_override_warning_modal;
pub mod auth_state;
mod auth_view_body;
pub mod auth_view_modal;
mod auth_view_shared_helpers;
pub mod credentials;
mod login_error_modal;
mod login_failure_notification;
pub mod login_slide;
pub mod needs_sso_link_view;
pub mod paste_auth_token_modal;
@@ -31,7 +26,7 @@ use ai::index::full_source_code_embedding::manager::CodebaseIndexManager;
pub use auth_manager::AuthManager;
pub use auth_state::AuthStateProvider;
use itertools::Itertools;
pub use login_failure_notification::LoginFailureReason;
pub use auth_view_modal::LoginFailureReason;
pub use user_uid::UserUid;
use galaxyui::modals::{AlertDialogWithCallbacks, ModalButton};
@@ -59,13 +54,7 @@ use crate::{report_if_error, send_telemetry_sync_from_app_ctx};
#[cfg_attr(target_family = "wasm", allow(dead_code))]
pub const API_KEY_PREFIX: &str = "wk-";
pub fn init(app: &mut AppContext) {
auth_view_modal::init(app);
auth_view_body::init(app);
auth_override_warning_body::init(app);
login_slide::init(app);
paste_auth_token_modal::init(app);
}
pub fn init(_app: &mut AppContext) {}
/// If the app has running processes or dirty objects, we'll show a confirmation modal before logging out.
/// If the user aborts, the user will not be logged out.
+8 -79
View File
@@ -1,36 +1,13 @@
use super::auth_manager::AuthManager;
use crate::{appearance::Appearance, auth::login_error_modal::LoginErrorModal};
use galaxyui::elements::{Align, MouseStateHandle, Shrinkable};
use galaxyui::ui_components::button::ButtonVariant;
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::{AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext};
use galaxyui::{AppContext, Element, Entity, TypedActionView, View, ViewContext};
#[derive(Debug)]
pub enum NeedsSsoLinkViewAction {
ClickedLinkSsoButton,
}
pub struct NeedsSsoLinkView {
email: Option<String>,
mouse_state_handles: MouseStateHandles,
}
#[derive(Default)]
struct MouseStateHandles {
link_sso_handle: MouseStateHandle,
}
pub struct NeedsSsoLinkView;
impl NeedsSsoLinkView {
pub fn new() -> Self {
Self {
email: None,
mouse_state_handles: Default::default(),
}
Self
}
pub fn set_email(&mut self, email: String) {
self.email = Some(email);
}
pub fn set_email(&mut self, _email: String) {}
}
impl Entity for NeedsSsoLinkView {
@@ -42,60 +19,12 @@ impl View for NeedsSsoLinkView {
"NeedsSsoLinkView"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let ui_builder = appearance.ui_builder();
let link_sso_button = Shrinkable::new(
1.,
Align::new(
ui_builder
.button(
ButtonVariant::Accent,
self.mouse_state_handles.link_sso_handle.clone(),
)
.with_text_label("Link SSO".to_string())
.with_style(UiComponentStyles {
padding: Some(Coords {
top: 10.,
bottom: 10.,
left: 40.,
right: 40.,
}),
..Default::default()
})
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(NeedsSsoLinkViewAction::ClickedLinkSsoButton);
})
.finish(),
)
.finish(),
)
.finish();
LoginErrorModal::new(app)
.with_header("Your organization has enabled SSO for your account")
.with_detail("Click the button below to link your Warp account to your SSO provider.")
.with_action(link_sso_button)
.build()
.finish()
fn render(&self, _app: &AppContext) -> Box<dyn Element> {
galaxyui::elements::Empty::new().finish()
}
}
impl TypedActionView for NeedsSsoLinkView {
type Action = NeedsSsoLinkViewAction;
fn handle_action(&mut self, action: &NeedsSsoLinkViewAction, ctx: &mut ViewContext<Self>) {
match action {
NeedsSsoLinkViewAction::ClickedLinkSsoButton => {
let email = self.email.as_deref().unwrap_or("");
AuthManager::handle(ctx).update(ctx, |auth_manager, ctx| {
let url = auth_manager.link_sso_url(email);
ctx.open_url(&url);
});
}
}
}
type Action = ();
fn handle_action(&mut self, _action: &(), _ctx: &mut ViewContext<Self>) {}
}
+8 -411
View File
@@ -1,210 +1,15 @@
//! 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 galaxy_core::ui::theme::color::internal_colors;
use galaxyui::elements::{
Align, Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Dismiss, Fill,
Flex, FormattedTextElement, HighlightedHyperlink, MainAxisAlignment, MainAxisSize,
MouseStateHandle, ParentElement, Radius, Shrinkable, Stack,
};
use galaxyui::fonts::Weight;
use galaxyui::keymap::{FixedBinding, Keystroke};
use galaxyui::text_layout::TextAlignment;
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::{
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 galaxyui::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,
}
use galaxyui::{AppContext, Element, Entity, TypedActionView, View, ViewContext};
#[derive(Clone, Debug)]
pub enum PasteAuthTokenModalEvent {
Cancelled,
}
pub struct PasteAuthTokenModalView {
auth_token_input: ViewHandle<EditorView>,
cancel_button: button::Button,
continue_button: button::Button,
close_mouse_state: MouseStateHandle,
last_failure_reason: Option<LoginFailureReason>,
highlighted_hyperlink_state: HighlightedHyperlink,
}
pub struct PasteAuthTokenModalView;
impl PasteAuthTokenModalView {
pub fn new(ctx: &mut ViewContext<Self>) -> 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<Self>) {
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<Self>) {
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();
}
}
pub fn new(_ctx: &mut ViewContext<Self>) -> Self {
Self
}
}
@@ -217,220 +22,12 @@ impl View for PasteAuthTokenModalView {
"PasteAuthTokenModalView"
}
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
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<dyn Element> {
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 galaxyui::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(galaxyui::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()
fn render(&self, _app: &AppContext) -> Box<dyn Element> {
galaxyui::elements::Empty::new().finish()
}
}
impl TypedActionView for PasteAuthTokenModalView {
type Action = PasteAuthTokenModalAction;
fn handle_action(&mut self, action: &PasteAuthTokenModalAction, ctx: &mut ViewContext<Self>) {
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));
}
}
}
type Action = ();
fn handle_action(&mut self, _action: &(), _ctx: &mut ViewContext<Self>) {}
}
+8 -115
View File
@@ -1,117 +1,18 @@
use anyhow::anyhow;
use wasm_bindgen::prelude::*;
use galaxyui::{AppContext, Element, Entity, View, ViewContext};
use galaxyui::{
ui_components::components::UiComponent as _, AppContext, Element, Entity, SingletonEntity,
View, ViewContext,
};
use crate::{
auth::auth_view_modal::AuthRedirectPayload,
auth::credentials::RefreshToken,
auth::login_error_modal::LoginErrorModal,
platform::wasm::{user_handoff, AuthHandoffError},
report_error,
};
use super::auth_manager::{AuthManager, AuthManagerEvent};
#[wasm_bindgen]
extern "C" {}
pub struct WebHandoffView {
state: HandoffState,
}
#[derive(Debug, Clone)]
#[derive(Clone, Debug)]
pub enum WebHandoffEvent {
/// Web auth handoff is unavailable, so the app should fall back to the login screen.
Unsupported,
}
enum HandoffState {
/// We have retrieved a refresh token from the host application and are fetching the user's
/// profile.
LoadingFromHost,
/// We are deriving authentication from an ambient browser session cookie.
LoadingFromSessionCookie,
/// There was an error using the provided refresh token. In practice, this should never happen,
/// as the host application would have recently used the token successfully.
Failed,
}
pub struct WebHandoffView;
impl WebHandoffView {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
ctx.subscribe_to_model(&AuthManager::handle(ctx), |me, _, event, ctx| {
me.handle_auth_manager_event(event, ctx);
});
Self {
state: HandoffState::Failed,
}
pub fn new(_ctx: &mut ViewContext<Self>) -> Self {
Self
}
fn import_user_from_session_cookie(&mut self, ctx: &mut ViewContext<Self>) {
log::debug!("Attempting to derive auth from browser session cookie");
AuthManager::handle(ctx).update(ctx, |auth_manager, ctx| {
auth_manager.initialize_user_from_session_cookie(ctx);
});
self.state = HandoffState::LoadingFromSessionCookie;
}
/// Import the authenticated user from the host React app, if available.
pub fn import_user(&mut self, ctx: &mut ViewContext<Self>) {
match user_handoff() {
Ok(Some(refresh_token)) => {
log::debug!("Attempting to retrieve refresh token from host app");
let payload = AuthRedirectPayload {
refresh_token: RefreshToken::new(refresh_token),
user_uid: None,
deleted_anonymous_user: None,
state: None,
};
AuthManager::handle(ctx).update(ctx, |auth_manager, ctx| {
// No need to validate state for web handoff, since everything's happening
// on same web page.
auth_manager.initialize_user_from_auth_payload(payload, false, ctx);
});
self.state = HandoffState::LoadingFromHost;
}
Ok(None) => {
self.import_user_from_session_cookie(ctx);
}
Err(AuthHandoffError::Unsupported) => {
self.import_user_from_session_cookie(ctx);
}
Err(AuthHandoffError::Unexpected(err)) => {
report_error!(anyhow!("Web user handoff failed: {err:?}"));
self.state = HandoffState::Failed;
ctx.notify();
}
}
ctx.notify();
}
fn handle_auth_manager_event(&mut self, event: &AuthManagerEvent, ctx: &mut ViewContext<Self>) {
match event {
AuthManagerEvent::AuthComplete => {
log::debug!("Initialized user from host application");
}
AuthManagerEvent::AuthFailed(err) => {
if matches!(self.state, HandoffState::LoadingFromSessionCookie) {
log::debug!("No browser session available for web auth handoff: {err:#}");
ctx.emit(WebHandoffEvent::Unsupported);
return;
}
log::error!("Failed to import user from host application: {err:#}");
self.state = HandoffState::Failed;
ctx.notify();
}
_ => {}
}
}
pub fn import_user(&mut self, _ctx: &mut ViewContext<Self>) {}
}
impl Entity for WebHandoffView {
@@ -123,15 +24,7 @@ impl View for WebHandoffView {
"WebHandoffView"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let label = match &self.state {
HandoffState::LoadingFromHost | HandoffState::LoadingFromSessionCookie => "Loading...",
HandoffState::Failed => "Error authenticating - please refresh the page",
};
LoginErrorModal::new(app)
.with_detail(label)
.build()
.finish()
fn render(&self, _app: &AppContext) -> Box<dyn Element> {
galaxyui::elements::Empty::new().finish()
}
}