Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
use uuid::Uuid;
use warp_core::user_preferences::GetUserPreferences;
/// Key used to persist the anonymous id to user defaults. We use "ExperimentId" as the key
/// since we use the persisted id to determine experiment groups, and we want to avoid
/// associating it with telemetry.
const ANONYMOUS_ID_KEY: &str = "ExperimentId";
/// Reads the persisted anonymous id from user defaults, if it exists and is a
/// valid uuid.
fn get_persisted_anonymous_id(ctx: &dyn GetUserPreferences) -> Option<Uuid> {
let anonymous_id = ctx
.private_user_preferences()
.read_value(ANONYMOUS_ID_KEY)
.unwrap_or_default()?;
match Uuid::parse_str(&anonymous_id) {
Ok(uuid) => Some(uuid),
Err(e) => {
log::warn!("Error parsing persisted anonymous id from user defaults: {e:?}");
None
}
}
}
/// Gets the persisted anonymous id if possible, otherwise generates a new uuid
/// and saves it to user defaults.
pub fn get_or_create_anonymous_id(ctx: &dyn GetUserPreferences) -> Uuid {
get_persisted_anonymous_id(ctx).unwrap_or_else(|| {
let uuid = Uuid::new_v4();
let _ = ctx
.private_user_preferences()
.write_value(ANONYMOUS_ID_KEY, uuid.to_string());
uuid
})
}
+888
View File
@@ -0,0 +1,888 @@
pub(super) mod user_persistence;
use std::result::Result as StdResult;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{anyhow, Result};
use settings::Setting as _;
use uuid::Uuid;
use warp_core::channel::ChannelState;
use warp_core::features::FeatureFlag;
use warp_graphql::mutations::create_anonymous_user::{
AnonymousUserType, CreateAnonymousUserResult,
};
use warpui::{clipboard::ClipboardContent, Entity, ModelContext, SingletonEntity, UpdateModel};
use super::auth_state::{AuthState, PersistAction};
use super::auth_view_modal::{AuthRedirectPayload, AuthViewVariant};
use super::credentials::{Credentials, FirebaseToken, LoginToken};
use super::user::User;
use super::AuthStateProvider;
use super::UserUid;
use crate::ai::llms::LLMPreferences;
use crate::ai::persisted_workspace::PersistedWorkspace;
use crate::ai::AIRequestUsageModel;
use crate::autoupdate::AutoupdateState;
use crate::persistence::ModelEvent;
use crate::server::cloud_objects::update_manager::UpdateManager;
use crate::server::server_api::auth::FetchUserResult;
use crate::server::server_api::ServerApiProvider;
use crate::server::{
graphql::get_user_facing_error_message,
server_api::{
auth::{
AnonymousUserCreationError, AuthClient, MintCustomTokenError, UserAuthenticationError,
},
ServerApi,
},
telemetry::AnonymousUserSignupEntrypoint,
};
use crate::settings::cloud_preferences_syncer::CloudPreferencesSyncer;
use crate::settings::initializer::SettingsInitializer;
use crate::settings::PrivacySettings;
use crate::terminal::general_settings::GeneralSettings;
use crate::terminal::shared_session::manager::Manager as SharedSessionManager;
#[cfg(target_family = "wasm")]
use crate::uri::browser_url_handler::{parse_current_url, update_browser_url};
use crate::workspaces::team_tester::TeamTesterStatus;
use crate::{
persistence, report_error, report_if_error, send_telemetry_from_ctx,
send_telemetry_sync_from_ctx, GlobalResourceHandlesProvider, TelemetryEvent,
};
#[cfg(target_family = "wasm")]
use url::Url;
use user_persistence::PersistedUser;
#[derive(Debug)]
pub enum AuthManagerEvent {
/// Successfully authenticated a user with no errors.
AuthComplete,
/// Failed to authenticate a user, due to a particular `UserAuthenticationError`.
AuthFailed(UserAuthenticationError),
/// Failed to create an anonymous user.
CreateAnonymousUserFailed,
/// The user chose to skip login entirely (no Firebase user created).
SkippedLogin,
/// The user now needs to reauthenticate. If the user needs to reauth, an `AuthFailed`
/// event might be triggered instead, but there are some code paths where we don't
/// refresh the entire user, only their token, which is when this event might be emitted.
NeedsReauth,
/// The user is anonymous and has attempted to access a login-gated feature or link.
AttemptedLoginGatedFeature {
auth_view_variant: AuthViewVariant,
},
// The current user is anonymous and the client has received a browser intent to sign in with a different Warp account.
// Holds an auth payload from the received browser intent.
LoginOverrideDetected(AuthRedirectPayload),
/// Failed to mint a new custom token for an anonymous user.
MintCustomTokenFailed(MintCustomTokenError),
/// Received a device authorization code as part of the device auth flow.
ReceivedDeviceAuthorizationCode {
#[cfg_attr(target_family = "wasm", allow(unused))]
verification_url: String,
#[cfg_attr(target_family = "wasm", allow(unused))]
verification_url_complete: Option<String>,
#[cfg_attr(target_family = "wasm", allow(unused))]
user_code: String,
},
}
pub type LoginGatedFeature = &'static str;
type URLConstructorCallback = Box<dyn FnOnce(Option<&str>) -> String>;
/// AuthManager is a singleton model which manages the currently logged-in user's state.
/// If you need to access the state, use `AuthStateProvider`.
pub struct AuthManager {
auth_state: Arc<AuthState>,
server_api: Arc<ServerApi>,
auth_client: Arc<dyn AuthClient>,
/// A generated state token that the web app must provide back to the client.
pending_auth_state: Option<String>,
}
impl AuthManager {
/// Creates a new instance of the AuthManager. The auth state must already be initialized through
/// [`AuthStateProvider`].
pub fn new(
server_api: Arc<ServerApi>,
auth_client: Arc<dyn AuthClient>,
ctx: &mut ModelContext<Self>,
) -> Self {
let auth_state = AuthStateProvider::as_ref(ctx).get().clone();
Self {
auth_state,
server_api,
auth_client,
pending_auth_state: None,
}
}
#[cfg(test)]
pub fn new_for_test(ctx: &mut ModelContext<Self>) -> Self {
use crate::server::server_api::ServerApiProvider;
let server_api = ServerApiProvider::as_ref(ctx).get();
let auth_state = AuthStateProvider::as_ref(ctx).get().clone();
Self {
auth_state,
server_api: server_api.clone(),
auth_client: server_api,
pending_auth_state: None,
}
}
/// Fetches and ultimately sets the user's auth state from an auth payload.
/// Typically, this function is triggered when a user clicks the intent link from their browser
/// back to Warp after login (or pastes the URL in the app).
pub fn initialize_user_from_auth_payload(
&mut self,
auth_payload: AuthRedirectPayload,
enforce_state_validation: bool,
ctx: &mut ModelContext<Self>,
) {
let AuthRedirectPayload {
refresh_token,
user_uid,
deleted_anonymous_user,
state,
} = auth_payload.clone();
if let Some(received_state) = &state {
if !self.consume_auth_state(received_state) {
if self.should_silently_ignore_stale_redirect(&user_uid) {
log::info!(
"Dropping auth redirect with stale state for already-logged-in user"
);
return;
}
ctx.emit(AuthManagerEvent::AuthFailed(
UserAuthenticationError::InvalidStateParameter,
));
return;
}
} else if enforce_state_validation {
if self.should_silently_ignore_stale_redirect(&user_uid) {
log::info!("Dropping auth redirect without state for already-logged-in user");
return;
}
ctx.emit(AuthManagerEvent::AuthFailed(
UserAuthenticationError::MissingStateParameter,
));
return;
}
let auth_client = self.auth_client.clone();
if self.auth_state.is_user_anonymous().unwrap_or_default() {
let incoming_user_matches_current_user = match user_uid {
None => false,
Some(incoming_user_uid) => self
.auth_state
.user_id()
.map(|current_user_uid| current_user_uid == incoming_user_uid)
.unwrap_or_default(),
};
if !incoming_user_matches_current_user && !deleted_anonymous_user.unwrap_or_default() {
ctx.emit(AuthManagerEvent::LoginOverrideDetected(auth_payload));
return;
}
send_telemetry_from_ctx!(TelemetryEvent::AnonymousUserLinkedFromBrowser, ctx);
}
let _ = ctx.spawn(
async move {
auth_client
.fetch_user(
LoginToken::Firebase(FirebaseToken::Refresh(refresh_token)),
false, /* for_refresh */
)
.await
},
Self::on_user_fetched,
);
}
pub fn resume_interrupted_auth_payload(
&mut self,
auth_payload: AuthRedirectPayload,
ctx: &mut ModelContext<Self>,
) {
let AuthRedirectPayload {
refresh_token,
user_uid: _,
deleted_anonymous_user: _,
state: _,
} = auth_payload;
let auth_client = self.auth_client.clone();
let _ = ctx.spawn(
async move {
auth_client
.fetch_user(
LoginToken::Firebase(FirebaseToken::Refresh(refresh_token)),
false, /* for_refresh */
)
.await
},
Self::on_user_fetched,
);
}
#[cfg(target_family = "wasm")]
pub fn initialize_user_from_session_cookie(&self, ctx: &mut ModelContext<Self>) {
let auth_client = self.auth_client.clone();
let _ = ctx.spawn(
async move {
auth_client
.fetch_user(LoginToken::SessionCookie, false)
.await
},
Self::on_user_fetched,
);
}
/// Refreshes the user's auth state using their existing credentials.
pub fn refresh_user(&self, ctx: &mut ModelContext<Self>) {
let Some(credentials) = self.auth_state.credentials() else {
log::warn!("Attempted to refresh user without credentials");
return;
};
let Some(token) = credentials.login_token() else {
log::info!("Attempted to refresh a user with no login token, skipping");
return;
};
let auth_client = self.auth_client.clone();
let _ = ctx.spawn(
async move { auth_client.fetch_user(token, true).await },
Self::on_user_fetched,
);
}
/// Authenticate asynchronously using the OAuth2 device authorization flow.
///
/// This is only used by the Warp CLI if running on a devic that does not have the Warp app installed.
#[cfg_attr(target_family = "wasm", allow(dead_code))]
pub fn authorize_device(&self, ctx: &mut ModelContext<Self>) {
// Clear any stale user state so old credentials don't interfere
// with the fresh device auth flow.
self.auth_state.set_credentials(None);
let auth_client = self.auth_client.clone();
// Request a device code the user can enter in their browser.
ctx.spawn(
async move { auth_client.request_device_code().await },
Self::on_device_code_received,
);
}
#[cfg_attr(target_family = "wasm", allow(dead_code))]
fn on_device_code_received(
&mut self,
result: Result<oauth2::StandardDeviceAuthorizationResponse, UserAuthenticationError>,
ctx: &mut ModelContext<Self>,
) {
match result {
Ok(details) => {
// Emit the device authorization details so that they can be shown to the user.
ctx.emit(AuthManagerEvent::ReceivedDeviceAuthorizationCode {
verification_url: details.verification_uri().to_string(),
verification_url_complete: details
.verification_uri_complete()
.map(|complete| complete.secret().to_string()),
user_code: details.user_code().secret().to_string(),
});
let auth_client = self.auth_client.clone();
ctx.spawn(
async move {
// Wait for the user to approve the device authorization request.
let token = auth_client
.exchange_device_access_token(&details, Duration::from_secs(600))
.await?;
// Exchange the custom access token for Firebase auth tokens and fetch the user.
auth_client
.fetch_user(LoginToken::Firebase(token), false)
.await
},
Self::on_user_fetched,
);
}
Err(err) => ctx.emit(AuthManagerEvent::AuthFailed(err)),
}
}
/// Callback for handling a successful fetch of a user from warp-server and Firebase.
/// This does the heavy-lifting of setting up all components of the application that depend
/// on a user's authenticated state, and emits events to subscribers that let them know
/// an auth event has occurred.
fn on_user_fetched(
&mut self,
fetch_user_result: StdResult<FetchUserResult, UserAuthenticationError>,
ctx: &mut ModelContext<Self>,
) {
match fetch_user_result {
Ok(fetch_user_result) => {
let FetchUserResult {
user,
credentials,
server_experiments,
from_refresh,
llms,
} = fetch_user_result;
self.set_and_persist(Some(user.clone()), Some(credentials), ctx);
self.set_needs_reauth(false, ctx);
// Must be called on the main thread.
#[cfg(feature = "crash_reporting")]
crate::crash_reporting::set_user_id(
user.local_id,
Some(user.metadata.email.clone()),
ctx,
);
ServerApiProvider::handle(ctx).update(ctx, |provider, ctx| {
provider.handle_experiments_fetched(server_experiments, ctx);
});
SettingsInitializer::handle(ctx).update(ctx, |initializer, ctx| {
initializer.handle_user_fetched(self.auth_state.clone(), ctx);
});
// Reset the initial-load condition so that any cloud preference
// sync waits for the *new* user's cloud objects rather than
// resolving immediately against stale data from a prior session.
// Only do this for non-refresh fetches (login/signup), not for
// token refreshes where the user identity hasn't changed.
if !from_refresh {
UpdateManager::handle(ctx).update(ctx, |manager, _| {
manager.reset_initial_load();
});
}
// Now that we have a user, start polling for team and cloud object information.
// The polling loop's first tick fires immediately, so there is no need for a
// separate out-of-band refresh here.
TeamTesterStatus::handle(ctx).update(ctx, |model, ctx| {
model.initiate_data_pollers(false, ctx);
});
CloudPreferencesSyncer::handle(ctx).update(ctx, |model, ctx| {
model.handle_user_fetched(self.auth_state.clone(), ctx)
});
AIRequestUsageModel::handle(ctx).update(ctx, |usage_model, ctx| {
usage_model.refresh_request_usage_async(ctx);
});
LLMPreferences::handle(ctx).update(ctx, |prefs, ctx| {
prefs.update_feature_model_choices(Ok(llms), ctx);
});
PersistedWorkspace::handle(ctx).update(ctx, |index_manager_updater, ctx| {
index_manager_updater.on_user_changed(ctx);
});
if !user.is_user_anonymous() {
GeneralSettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings
.did_non_anonymous_user_log_in
.set_value(true, ctx));
});
}
// Force refresh for shared sessions if user may have changed.
if !from_refresh {
SharedSessionManager::handle(ctx).update(ctx, |manager, ctx| {
manager.stop_all_shared_sessions(ctx);
manager.rejoin_all_shared_sessions(ctx);
});
}
let global_resource_handles =
GlobalResourceHandlesProvider::as_ref(ctx).get().clone();
// As part of Logout v0:
// Reconstruct the database if it was removed.
// Do nothing if the database was not removed.
persistence::reconstruct(&global_resource_handles.model_event_sender);
if let Some(model_event_sender) = &global_resource_handles.model_event_sender {
if let Err(e) =
model_event_sender.send(ModelEvent::UpsertCurrentUserInformation {
user_information: PersistedCurrentUserInformation {
email: self.auth_state.user_email().unwrap_or_default(),
},
})
{
log::error!("Error persisting user information to database: {e:?}");
};
}
// Fetch the user's privacy settings from the server if any or update the server settings.
let privacy_settings_handle = PrivacySettings::handle(ctx);
let privacy_settings_snapshot =
privacy_settings_handle.as_ref(ctx).get_snapshot(ctx);
ctx.update_model(&privacy_settings_handle, |privacy_settings, ctx| {
privacy_settings.fetch_or_update_settings(ctx);
});
// Now that the user is logged in, do the daily version check.
if FeatureFlag::Autoupdate.is_enabled() {
AutoupdateState::handle(ctx).update(ctx, |autoupdate_state, ctx| {
autoupdate_state.maybe_daily_check_for_update(ctx);
});
}
let server_api = self.server_api.clone();
let user_id = self.auth_state.user_id().unwrap_or_default();
let anonymous_id = self.auth_state.anonymous_id();
let _ = ctx.spawn(
// Synchronously add the identify and login event to the telemetry event queue and
// then flush the queue to ensure the events get to Rudderstack. We need to do this
// one-off because the login event happens only once for the user and we don't want
// to drop the event if the user quits the app before the next flush of the queue.
// TODO(alokedesai): Investigate a more robust way of handling events
// that don't get flushed to Rudderstack outside of this event specifically.
async move {
warpui::telemetry::record_identify_user_event(
user_id.as_string(),
anonymous_id.clone(),
warpui::time::get_current_time(),
);
warpui::telemetry::record_event(
Some(user_id.as_string()),
anonymous_id,
TelemetryEvent::Login.name().into(),
TelemetryEvent::Login.payload(),
TelemetryEvent::Login.contains_ugc(),
warpui::time::get_current_time(),
);
// Note that this snapshot might get overwritten to disabled after the server fetch.
// However, it is still fine to flush to Rudderstack here as the login event is low-risk
// and it is better to err on the side of over-reporting than under-reporting.
if let Err(e) = server_api
.flush_telemetry_events(privacy_settings_snapshot)
.await
{
log::info!("Failed to flush events from Telemetry queue: {e}");
}
server_api.notify_login().await;
},
|_, _, _| {},
);
// Once the user is authenticated, attempt to report the sandbox that Warp is running in, if any.
ctx.spawn(
async { warp_isolation_platform::detect() },
|_, platform, ctx| {
if let Some(platform) = platform {
send_telemetry_from_ctx!(
TelemetryEvent::DetectedIsolationPlatform { platform },
ctx
);
}
},
);
ctx.emit(AuthManagerEvent::AuthComplete);
}
Err(error) => {
match error {
UserAuthenticationError::DeniedAccessToken(_) => {
self.set_needs_reauth(true, ctx);
}
UserAuthenticationError::UserAccountDisabled(_) => {}
UserAuthenticationError::Unexpected(_) => {}
UserAuthenticationError::InvalidStateParameter => {}
UserAuthenticationError::MissingStateParameter => {}
}
ctx.emit(AuthManagerEvent::AuthFailed(error));
}
}
}
/// Sets the user and credentials in auth state and persists to secure storage.
/// Persistence depends on the credential type - currently, we only persist
/// state if authenticated via a Firebase token.
fn set_and_persist(
&self,
user: Option<User>,
credentials: Option<Credentials>,
ctx: &mut ModelContext<Self>,
) {
self.auth_state.set_user(user);
self.auth_state.set_credentials(credentials);
self.persist(ctx);
}
/// Persists (or removes) the current user and credentials to/from secure storage,
/// based on the current auth state.
fn persist(&self, ctx: &mut ModelContext<Self>) {
match self.auth_state.persist_action() {
PersistAction::Persist(persisted_user) => {
if persisted_user.auth_tokens.refresh_token.is_empty() {
log::warn!("Skipping user persistence due to empty refresh token");
return;
}
let _ = persisted_user.write_to_secure_storage(ctx).map_err(|err| {
log::warn!("Unable to persist user to secure storage: {err:?}");
});
}
PersistAction::Remove => {
let _ = PersistedUser::remove_from_secure_storage(ctx).map_err(|err| {
log::warn!("Unable to clear user from secure storage: {err:?}");
});
}
PersistAction::DoNothing => {}
}
}
/// Helper function for logging out the user.
/// 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);
}
/// Sets whether or not this user's Firebase credentials are invalid and thus needs to reauth.
pub fn set_needs_reauth(&self, needs_reauth: bool, ctx: &mut ModelContext<Self>) {
let became_true = self.auth_state.set_needs_reauth(needs_reauth);
if became_true {
send_telemetry_from_ctx!(TelemetryEvent::NeedsReauth, ctx);
ctx.emit(AuthManagerEvent::NeedsReauth);
}
}
pub fn create_anonymous_user(
&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(
&mut self,
response: Result<CreateAnonymousUserResult>,
ctx: &mut ModelContext<Self>,
) {
let custom_token = match response {
Ok(response_data) => match response_data {
CreateAnonymousUserResult::CreateAnonymousUserOutput(output) => Ok(output.id_token),
CreateAnonymousUserResult::UserFacingError(user_facing_error) => {
Err(AnonymousUserCreationError::UserFacingError(
get_user_facing_error_message(user_facing_error),
))
}
CreateAnonymousUserResult::Unknown => Err(AnonymousUserCreationError::Unknown),
},
Err(_) => Err(AnonymousUserCreationError::CreationFailed),
};
match custom_token {
Ok(custom_token) => {
// Exchange the custom token for an ID token.
let auth_client = self.auth_client.clone();
let _ = ctx.spawn(
async move {
auth_client
.fetch_user(
LoginToken::Firebase(FirebaseToken::Custom(custom_token)),
false, /* for_refresh */
)
.await
},
Self::on_user_fetched,
);
}
Err(err) => {
report_error!(
anyhow!(err).context("Encountered an error trying to create anonymous users")
);
ctx.emit(AuthManagerEvent::CreateAnonymousUserFailed);
}
}
}
pub fn attempt_login_gated_feature(
&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 initiate_anonymous_user_linking(
&self,
entrypoint: AnonymousUserSignupEntrypoint,
ctx: &mut ModelContext<Self>,
) {
let auth_client = self.auth_client.clone();
let _ = ctx.spawn(
async move { auth_client.fetch_new_custom_token().await },
move |me, response, ctx| {
let custom_token = me.auth_client.on_custom_token_fetched(response);
match custom_token {
Ok(custom_token) => {
// Send synchronously since this is an important event in the sign up funnel and we
// don't want to lose events if the user quits before the event queue is flushed.
send_telemetry_sync_from_ctx!(
TelemetryEvent::InitiateAnonymousUserSignup { entrypoint },
ctx
);
let login_options_url = me.login_options_url(&custom_token);
if cfg!(target_family = "wasm") {
#[cfg(target_family = "wasm")]
if let Some(current_url) = parse_current_url() {
update_browser_url(
Url::parse(&format!(
"{}?redirect_to={}",
login_options_url,
current_url.path()
))
.ok(),
true,
);
} else {
update_browser_url(Url::parse(&login_options_url).ok(), true);
}
} else {
ctx.open_url(&login_options_url);
}
}
Err(e) => {
ctx.emit(AuthManagerEvent::MintCustomTokenFailed(e));
}
}
},
);
}
// Opens a page in the web app and logs the user in using a customToken if they are an anonymous user.
// Accepts a callback that constructs the URL using the customToken to open a page and log in an anonymous user.
pub fn open_url_maybe_with_anonymous_token(
&self,
ctx: &mut ModelContext<Self>,
construct_url: URLConstructorCallback,
) {
if !self.auth_state.is_user_anonymous().unwrap_or_default()
|| !self.auth_state.is_logged_in()
{
// Not an anonymous Firebase user, or fully logged out — open URL without token.
let url: String = construct_url(None);
ctx.open_url(&url);
return;
}
let auth_client = self.auth_client.clone();
let _ = ctx.spawn(
async move { auth_client.fetch_new_custom_token().await },
move |me, response, ctx| {
let custom_token = me.auth_client.on_custom_token_fetched(response);
match custom_token {
Ok(custom_token) => {
let url: String = construct_url(Some(&custom_token));
ctx.open_url(&url);
}
Err(e) => {
report_error!(anyhow!(
"Failed to fetch custom token for authenticating anonymous user in browser: {e:?}"
))
}
};
},
);
}
pub fn copy_anonymous_user_linking_url_to_clipboard(&self, ctx: &mut ModelContext<Self>) {
if !self.auth_state.is_user_anonymous().unwrap_or_default() {
return;
}
let auth_client = self.auth_client.clone();
let _ = ctx.spawn(
async move { auth_client.fetch_new_custom_token().await },
move |me, response, ctx| {
let custom_token = me.auth_client.on_custom_token_fetched(response);
match custom_token {
Ok(custom_token) => {
let login_options_url = me.login_options_url(&custom_token);
ctx.clipboard().write(ClipboardContent {
plain_text: login_options_url,
paths: None,
..Default::default()
});
}
Err(e) => {
ctx.emit(AuthManagerEvent::MintCustomTokenFailed(e));
}
};
},
);
}
/// Generates a unique state parameter for the authentication flow.
fn generate_auth_state(&mut self) -> String {
let state = Uuid::new_v4().to_string();
self.pending_auth_state = Some(state.clone());
state
}
pub fn sign_up_url(&mut self) -> String {
let state = self.generate_auth_state();
format!(
// TODO: we should probably be able to remove the public_beta flag
"{}/signup/remote?scheme={}&state={}&public_beta=true",
ChannelState::server_root_url(),
ChannelState::url_scheme(),
state,
)
}
pub fn sign_in_url(&mut self) -> String {
let state = self.generate_auth_state();
format!(
"{}/login/remote?scheme={}&state={}",
ChannelState::server_root_url(),
ChannelState::url_scheme(),
state,
)
}
/// The upgrade confirmation page will kick the user back to the app with a refresh token
/// if we send a `state` query param to /upgrade
pub fn upgrade_url(&mut self) -> String {
let state = self.generate_auth_state();
format!(
"{}/upgrade?scheme={}&state={}",
ChannelState::server_root_url(),
ChannelState::url_scheme(),
state,
)
}
pub fn login_options_url(&mut self, custom_token: &str) -> String {
let state = self.generate_auth_state();
format!(
"{}/login_options/{}?state={}",
ChannelState::server_root_url(),
custom_token,
state,
)
}
pub fn link_sso_url(&mut self, email: &str) -> String {
let state = self.generate_auth_state();
format!(
"{}/link_sso?email={}&state={}",
ChannelState::server_root_url(),
email,
state,
)
}
/// Validates and consumes the pending auth state token. Returns `true` if the
/// provided state matches; in that case the pending state is cleared so the
/// CSRF token is single-use. A subsequent call with the same value will fail.
fn consume_auth_state(&mut self, received_state: &str) -> bool {
if self.pending_auth_state.as_deref() == Some(received_state) {
self.pending_auth_state = None;
true
} else {
false
}
}
/// Returns whether an auth redirect that failed state validation should be
/// silently dropped rather than surfaced as an error. This covers the
/// "user clicks the browser's 'Take me to Warp' button twice" case: once
/// they're fully logged in, a second redirect targeting the same user is
/// redundant and should not produce a user-visible error.
fn should_silently_ignore_stale_redirect(&self, incoming_user_uid: &Option<UserUid>) -> bool {
if self.auth_state.is_anonymous_or_logged_out() {
return false;
}
match (self.auth_state.user_id(), incoming_user_uid) {
(Some(current_uid), Some(incoming_uid)) => current_uid == *incoming_uid,
_ => false,
}
}
/// Sets the user as onboarded both on the server and locally.
/// This method:
/// 1. Updates the server by calling set_user_is_onboarded
/// 2. Updates the local auth state and persists the user data
pub fn set_user_onboarded(&self, ctx: &mut ModelContext<Self>) {
// Update server
let auth_client = self.auth_client.clone();
let _ = ctx.spawn(
async move { auth_client.set_user_is_onboarded().await },
|_, _, _| {},
);
// Update local auth state and persist
self.auth_state.set_is_onboarded(true);
self.persist(ctx);
}
}
#[derive(Clone, Debug)]
pub struct PersistedCurrentUserInformation {
pub email: String,
}
impl Entity for AuthManager {
type Event = AuthManagerEvent;
}
impl SingletonEntity for AuthManager {}
#[cfg(test)]
#[path = "auth_manager_test.rs"]
mod auth_manager_test;
@@ -0,0 +1,84 @@
use serde::{Deserialize, Serialize};
use warp_graphql::scalars::time::ServerTimestamp;
use warpui::AppContext;
use warpui_extras::secure_storage::{self, AppContextExt};
use crate::auth::{
user::{AnonymousUserType, FirebaseAuthTokens, PersonalObjectLimits, UserMetadata},
UserUid,
};
const USER_STORAGE_KEY: &str = "User";
/// Helper function to set `true` as the default for a serde field on PersistedUser.
fn default_as_true() -> bool {
true
}
/// The persisted representation of a user, serialized to/from the user's keychain.
/// This struct must remain backwards compatible with the existing keychain JSON format.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersistedUser {
/// Information about the user's authentication through Firebase.
#[serde(rename = "id_token")]
pub auth_tokens: FirebaseAuthTokens,
/// DO NOT USE! This used to be one of the two places we stored the user's Firebase refresh
/// token. Now, all callers should go through `auth_tokens` to access it.
#[serde(default)]
#[deprecated = "use auth_tokens.refresh_token instead"]
pub refresh_token: String,
/// The Firebase UID of this user.
pub local_id: UserUid,
/// Metadata about the user.
#[serde(flatten)]
pub metadata: UserMetadata,
/// Whether or not the user is onboarded.
#[serde(default = "default_as_true")]
pub is_onboarded: bool,
/// Whether or not the user needs to link their account via SSO due to an organization setting.
#[serde(default)]
pub needs_sso_link: bool,
/// What type of anonymous user this user is. May be `None` if they are not anonymous.
#[serde(default)]
pub anonymous_user_type: Option<AnonymousUserType>,
#[serde(default)]
pub linked_at: Option<ServerTimestamp>,
#[serde(default)]
pub personal_object_limits: Option<PersonalObjectLimits>,
/// Whether or not this user is on what we consider a "work" domain.
#[serde(default)]
pub is_on_work_domain: bool,
}
#[derive(Debug, thiserror::Error)]
pub enum UserPersistenceError {
/// The persisted user was not successfully read from or written to disk.
#[error("secure storage error")]
SecureStorageError(#[from] secure_storage::Error),
/// The persisted user on disk could not be decoded into a valid struct.
#[error("failed to serialize or deserialize a PersistedUser struct")]
SerializationError(#[from] serde_json::Error),
}
impl PersistedUser {
pub fn from_secure_storage(ctx: &AppContext) -> Result<PersistedUser, UserPersistenceError> {
let value = ctx.secure_storage().read_value(USER_STORAGE_KEY)?;
Ok(serde_json::from_str::<PersistedUser>(&value)?)
}
pub fn write_to_secure_storage(&self, ctx: &AppContext) -> Result<(), UserPersistenceError> {
let serialized_user = serde_json::to_string(self)?;
Ok(ctx
.secure_storage()
.write_value(USER_STORAGE_KEY, &serialized_user)?)
}
pub fn remove_from_secure_storage(ctx: &AppContext) -> Result<(), UserPersistenceError> {
Ok(ctx.secure_storage().remove_value(USER_STORAGE_KEY)?)
}
}
#[cfg(test)]
#[path = "user_persistence_test.rs"]
mod tests;
@@ -0,0 +1,166 @@
use chrono::DateTime;
use crate::auth::{
user::{FirebaseAuthTokens, PersonalObjectLimits, UserMetadata},
UserUid,
};
use super::PersistedUser;
/// Verifies that the JSON blob format as of March 6, 2026 can be deserialized correctly.
///
/// We must ALWAYS be backwards-compatible with the format here. The inlined JSON string can never change - it represents
/// data serialized on user devices.
#[test]
#[allow(deprecated)]
fn test_deserialize_2026_03_06_persisted_user() {
const BLOB: &str = r#"{"id_token":{"id_token":"test-id-token","refresh_token":"test-refresh-token","expiration_time":"2099-01-01T00:00:00Z"},"refresh_token":"","local_id":"test-uid","email":"test@example.com","display_name":"Test User","photo_url":"https://example.com/photo.jpg","is_onboarded":true,"needs_sso_link":false,"anonymous_user_type":null,"linked_at":null,"personal_object_limits":null,"is_on_work_domain":false}"#;
let user: PersistedUser =
serde_json::from_str(BLOB).expect("2026-03-06 JSON should deserialize");
assert_eq!(user.auth_tokens.id_token, "test-id-token");
assert_eq!(user.auth_tokens.refresh_token, "test-refresh-token");
assert_eq!(user.refresh_token, "");
assert_eq!(user.local_id.as_str(), "test-uid");
assert_eq!(user.metadata.email, "test@example.com");
assert_eq!(user.metadata.display_name.as_deref(), Some("Test User"));
assert_eq!(
user.metadata.photo_url.as_deref(),
Some("https://example.com/photo.jpg")
);
assert!(user.is_onboarded);
assert!(!user.needs_sso_link);
assert_eq!(user.anonymous_user_type, None);
assert_eq!(user.linked_at, None);
assert!(user.personal_object_limits.is_none());
assert!(!user.is_on_work_domain);
}
/// Verifies that serializing a PersistedUser produces the expected JSON string.
///
/// If this test fails, it means the serialization format has changed.
/// You should:
/// 1. Add a new dated deserialization test (see [`test_deserialize_2026_03_06_persisted_user`])
/// 2. Update the serialization test to match the new format
#[test]
#[allow(deprecated)]
fn test_serialize_persisted_user() {
const EXPECTED_BLOB: &str = r#"{"id_token":{"id_token":"test-id-token","refresh_token":"test-refresh-token","expiration_time":"2099-01-01T00:00:00Z"},"refresh_token":"","local_id":"test-uid","email":"test@example.com","display_name":"Test User","photo_url":"https://example.com/photo.jpg","is_onboarded":true,"needs_sso_link":false,"anonymous_user_type":null,"linked_at":null,"personal_object_limits":{"env_var_limit":10,"notebook_limit":20,"workflow_limit":30},"is_on_work_domain":false}"#;
let expiration_time = DateTime::parse_from_rfc3339("2099-01-01T00:00:00+00:00")
.expect("should parse expiration datetime");
let user = PersistedUser {
auth_tokens: FirebaseAuthTokens {
id_token: "test-id-token".to_string(),
refresh_token: "test-refresh-token".to_string(),
expiration_time,
},
refresh_token: String::new(),
local_id: UserUid::new("test-uid"),
metadata: UserMetadata {
email: "test@example.com".to_string(),
display_name: Some("Test User".to_string()),
photo_url: Some("https://example.com/photo.jpg".to_string()),
},
is_onboarded: true,
needs_sso_link: false,
anonymous_user_type: None,
linked_at: None,
personal_object_limits: Some(PersonalObjectLimits {
env_var_limit: 10,
notebook_limit: 20,
workflow_limit: 30,
}),
is_on_work_domain: false,
};
let serialized = serde_json::to_string(&user).expect("serialization should succeed");
assert_eq!(serialized, EXPECTED_BLOB);
}
/// Test serializing and deserializing persisted user data.
/// See warpui_extras::secure_storage::linux_test.rs for Linux-specific tests.
#[cfg(target_os = "windows")]
#[cfg_attr(windows, ignore = "passes locally but not in CI on Windows")]
#[test]
#[allow(deprecated)]
fn test_windows_user_persistence() {
use crate::auth::{AuthManager, AuthStateProvider};
use crate::server::{
datetime_ext::DateTimeExt, telemetry::context_provider::AppTelemetryContextProvider,
};
use crate::ServerApiProvider;
use chrono::DateTime;
use warp_core::channel::ChannelState;
use warpui::{App, SingletonEntity};
use warpui_extras::secure_storage;
App::test((), |mut app| async move {
app.add_singleton_model(|_ctx| ServerApiProvider::new_for_test());
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
app.add_singleton_model(AppTelemetryContextProvider::new_context_provider);
app.add_singleton_model(|ctx| {
secure_storage::register_with_dir(
ChannelState::data_domain().as_str(),
warp_core::paths::state_dir(),
ctx,
);
AuthManager::new_for_test(ctx)
});
let tokens = FirebaseAuthTokens {
id_token: String::from("This is an ID token."),
refresh_token: String::from("This is a refresh token."),
expiration_time: DateTime::now() + chrono::Duration::days(365),
};
let persisted_user = PersistedUser {
auth_tokens: tokens.clone(),
refresh_token: String::new(),
local_id: UserUid::new("test_uid"),
metadata: UserMetadata {
email: "test@test.com".to_string(),
display_name: Some(String::from("abcdef")),
photo_url: Some(String::from("some-photo-url")),
},
is_onboarded: true,
needs_sso_link: false,
anonymous_user_type: None,
linked_at: None,
personal_object_limits: None,
is_on_work_domain: false,
};
AuthManager::handle(&app).update(&mut app, |_auth_manager, ctx| {
// Write the test user to secure storage.
let write = persisted_user.write_to_secure_storage(ctx);
match &write {
Ok(()) => {}
Err(err) => {
println!("{err:?}");
}
}
assert!(write.is_ok());
// Read the persisted user back and ensure the fields match.
let stored = PersistedUser::from_secure_storage(ctx).unwrap();
assert_eq!(stored.auth_tokens.id_token, tokens.id_token);
assert_eq!(stored.auth_tokens.refresh_token, tokens.refresh_token);
assert_eq!(stored.auth_tokens.expiration_time, tokens.expiration_time);
assert_eq!(
stored.metadata.display_name,
persisted_user.metadata.display_name
);
assert_eq!(stored.metadata.email, persisted_user.metadata.email);
assert_eq!(stored.metadata.photo_url, persisted_user.metadata.photo_url);
// Remove the user from secure storage.
assert!(PersistedUser::remove_from_secure_storage(ctx).is_ok());
// Attempt to read a user back, which should fail.
let empty_user = PersistedUser::from_secure_storage(ctx);
assert!(empty_user.is_err());
})
});
}
+250
View File
@@ -0,0 +1,250 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use super::{AuthManager, AuthManagerEvent};
use crate::auth::{
auth_view_modal::AuthRedirectPayload,
credentials::{Credentials, RefreshToken},
user::{FirebaseAuthTokens, TEST_USER_UID},
AuthStateProvider, UserUid,
};
use crate::server::server_api::auth::UserAuthenticationError;
use crate::ServerApiProvider;
use warpui::{App, SingletonEntity};
fn initialize_app(app: &mut App) {
app.add_singleton_model(|_ctx| ServerApiProvider::new_for_test());
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
app.add_singleton_model(AuthManager::new_for_test);
}
/// Subscribes to `AuthManager` events and returns a flag that becomes `true`
/// if an `AuthFailed(InvalidStateParameter)` event is observed.
fn track_invalid_state_failures(app: &mut App) -> Arc<AtomicBool> {
let saw_invalid_state = Arc::new(AtomicBool::new(false));
let saw_invalid_state_for_closure = saw_invalid_state.clone();
app.update(|ctx| {
ctx.subscribe_to_model(&AuthManager::handle(ctx), move |_, event, _| {
if matches!(
event,
AuthManagerEvent::AuthFailed(UserAuthenticationError::InvalidStateParameter)
) {
saw_invalid_state_for_closure.store(true, Ordering::Relaxed);
}
});
});
saw_invalid_state
}
/// After a logged-in user successfully completes auth, pressing the browser's
/// "Take me to Warp" button a second time should silently drop the stale
/// redirect rather than surface an `InvalidStateParameter` error.
#[test]
fn test_duplicate_redirect_for_logged_in_user_is_silently_ignored() {
App::test((), |mut app| async move {
initialize_app(&mut app);
// Fail immediately if we see InvalidStateParameter at any point.
app.update(|ctx| {
ctx.subscribe_to_model(&AuthManager::handle(ctx), move |_, event, _| {
if matches!(
event,
AuthManagerEvent::AuthFailed(UserAuthenticationError::InvalidStateParameter)
) {
panic!("Test failed: Received InvalidStateParameter error");
}
});
});
// Generate a state token and create the auth payload that we'll use for both calls.
// The incoming user_uid matches the default test user so the stale second redirect
// qualifies for the silent-ignore branch.
let auth_payload = AuthManager::handle(&app).update(&mut app, |auth_manager, _ctx| {
let state = auth_manager.generate_auth_state();
AuthRedirectPayload {
refresh_token: RefreshToken::new("test_refresh_token"),
user_uid: Some(UserUid::new(TEST_USER_UID)),
deleted_anonymous_user: Some(false),
state: Some(state),
}
});
// First call: state validates and is consumed.
AuthManager::handle(&app).update(&mut app, |auth_manager, ctx| {
auth_manager.initialize_user_from_auth_payload(auth_payload.clone(), true, ctx);
});
// The CSRF token must be single-use: successful validation clears it.
AuthManager::handle(&app).update(&mut app, |auth_manager, _ctx| {
assert!(
auth_manager.pending_auth_state.is_none(),
"pending_auth_state should be cleared after successful validation"
);
});
// Second call with the same (now-consumed) state: the user is already
// logged in as the test user and the incoming user_uid matches, so we
// must silently drop the redirect without emitting any AuthFailed event.
AuthManager::handle(&app).update(&mut app, |auth_manager, ctx| {
auth_manager.initialize_user_from_auth_payload(auth_payload, true, ctx);
});
});
}
/// When the user is fully logged out, a redirect carrying a state that does
/// not match the pending token must surface an `InvalidStateParameter` error.
#[test]
fn test_stale_state_when_logged_out_emits_invalid_state_parameter() {
App::test((), |mut app| async move {
initialize_app(&mut app);
// Clear the default test user so we're fully logged out.
app.update(|ctx| {
let auth_state = AuthStateProvider::as_ref(ctx).get();
auth_state.set_user(None);
auth_state.set_credentials(None);
});
let saw_invalid_state = track_invalid_state_failures(&mut app);
// Generate a real pending state, then deliver a redirect whose state
// doesn't match it.
AuthManager::handle(&app).update(&mut app, |auth_manager, _ctx| {
let _known_state = auth_manager.generate_auth_state();
});
let bogus_payload = AuthRedirectPayload {
refresh_token: RefreshToken::new("test_refresh_token"),
user_uid: Some(UserUid::new("some_user_uid")),
deleted_anonymous_user: Some(false),
state: Some("not_the_real_state".to_owned()),
};
AuthManager::handle(&app).update(&mut app, |auth_manager, ctx| {
auth_manager.initialize_user_from_auth_payload(bogus_payload, true, ctx);
});
assert!(
saw_invalid_state.load(Ordering::Relaxed),
"expected AuthFailed(InvalidStateParameter) when logged out and state does not match"
);
});
}
/// Even when a user is logged in, a redirect with a bad state and a `user_uid`
/// that does NOT match the current user must surface an `InvalidStateParameter`
/// error: the silent-ignore branch is reserved for redirects that target the
/// same user who is already authenticated.
#[test]
fn test_mismatched_state_with_different_user_uid_emits_invalid_state_parameter() {
App::test((), |mut app| async move {
initialize_app(&mut app);
let saw_invalid_state = track_invalid_state_failures(&mut app);
// Test user is logged in by default; generate a pending state so the
// validation below fails because the incoming state is different.
AuthManager::handle(&app).update(&mut app, |auth_manager, _ctx| {
let _known_state = auth_manager.generate_auth_state();
});
// Attacker-style payload: bogus state, plus a user_uid that differs
// from the currently logged-in user's uid.
let attacker_payload = AuthRedirectPayload {
refresh_token: RefreshToken::new("attacker_refresh_token"),
user_uid: Some(UserUid::new("not_the_current_user")),
deleted_anonymous_user: Some(false),
state: Some("not_the_real_state".to_owned()),
};
AuthManager::handle(&app).update(&mut app, |auth_manager, ctx| {
auth_manager.initialize_user_from_auth_payload(attacker_payload, true, ctx);
});
assert!(
saw_invalid_state.load(Ordering::Relaxed),
"expected AuthFailed(InvalidStateParameter) when incoming user_uid differs from current user"
);
});
}
/// `log_out` must clear any pending CSRF state from an auth flow that was
/// started but never completed, so the token cannot be replayed against the
/// next session in the same process.
#[test]
fn test_log_out_clears_pending_auth_state() {
App::test((), |mut app| async move {
initialize_app(&mut app);
// `log_out` clears user+credentials and then calls `persist`, which
// routes to `PersistedUser::remove_from_secure_storage`. That requires
// a `SecureStorage` singleton, so register a no-op one for this test.
app.update(|ctx| {
warpui_extras::secure_storage::register_noop("warp_test", ctx);
});
AuthManager::handle(&app).update(&mut app, |auth_manager, ctx| {
let _pending = auth_manager.generate_auth_state();
assert!(
auth_manager.pending_auth_state.is_some(),
"precondition: generate_auth_state should populate pending_auth_state"
);
auth_manager.log_out(ctx);
assert!(
auth_manager.pending_auth_state.is_none(),
"log_out should clear pending_auth_state"
);
});
});
}
// These two tests verify that `persist` skips writing to secure storage under certain conditions.
// They rely on the fact that no secure storage singleton is registered in the test app: if
// `write_to_secure_storage` were ever called, it would panic trying to look up the unregistered
// singleton, causing the test to fail.
#[test]
fn test_persist_skips_when_refresh_token_is_empty() {
App::test((), |mut app| async move {
initialize_app(&mut app);
// Override default test credentials with Firebase tokens that have an empty refresh token.
app.update(|ctx| {
let tokens = FirebaseAuthTokens {
id_token: String::new(),
refresh_token: String::new(),
expiration_time: chrono::Utc::now().fixed_offset() + chrono::Duration::days(365),
};
AuthStateProvider::as_ref(ctx)
.get()
.set_credentials(Some(Credentials::Firebase(tokens)));
});
AuthManager::handle(&app).update(&mut app, |auth_manager, ctx| {
auth_manager.persist(ctx);
});
});
}
#[test]
fn test_persist_skips_when_api_key_authenticated() {
App::test((), |mut app| async move {
initialize_app(&mut app);
app.update(|ctx| {
AuthStateProvider::as_ref(ctx)
.get()
.set_credentials(Some(Credentials::ApiKey {
key: "wk-test-key".to_owned(),
owner_type: None,
}));
});
AuthManager::handle(&app).update(&mut app, |auth_manager, ctx| {
auth_manager.persist(ctx);
});
});
}
+419
View File
@@ -0,0 +1,419 @@
use crate::appearance::Appearance;
use crate::util::color::lighten;
use warp_core::ui::builder::UiBuilder;
use warp_core::ui::color::darken;
use warpui::keymap::FixedBinding;
use crate::modal::MODAL_CORNER_RADIUS;
use warp_core::ui::color::blend::Blend;
use warpui::accessibility::{AccessibilityContent, WarpA11yRole};
use warpui::color::ColorU;
use warpui::elements::{
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Fill, Flex, Icon,
MouseStateHandle, ParentElement, Radius, Shrinkable,
};
use warpui::fonts::Weight;
use warpui::ui_components::button::ButtonVariant;
use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use warpui::{
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 warpui::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()
}
}
+145
View File
@@ -0,0 +1,145 @@
use pathfinder_color::ColorU;
use warp_core::ui::appearance::Appearance;
use warpui::elements::Container;
use warpui::elements::Fill;
use warpui::FocusContext;
use warpui::SingletonEntity;
use warpui::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 warpui::elements::ChildView;
use warpui::ui_components::components::{Coords, UiComponentStyles};
use warpui::{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,
}
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)]
pub enum AuthOverrideWarningModalEvent {
Close,
BulkExport,
}
impl Entity for AuthOverrideWarningModal {
type Event = AuthOverrideWarningModalEvent;
}
impl View for AuthOverrideWarningModal {
fn ui_name() -> &'static str {
"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()
}
}
impl TypedActionView for AuthOverrideWarningModal {
type Action = ();
}
+518
View File
@@ -0,0 +1,518 @@
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use anyhow::anyhow;
use chrono::{DateTime, Duration, Utc};
use parking_lot::RwLock;
use uuid::Uuid;
use warp_core::channel::{Channel, ChannelState};
use warp_graphql::object_permissions::OwnerType;
use warpui::{AppContext, Entity, SingletonEntity};
use crate::{
cloud_object::{GenericStringObjectFormat, JsonObjectType, ObjectType},
report_error,
};
use super::{
anonymous_id::get_or_create_anonymous_id,
auth_manager::user_persistence::PersistedUser,
credentials::Credentials,
user::{AnonymousUserType, FirebaseAuthTokens, PersonalObjectLimits, PrincipalType, User},
UserUid, API_KEY_PREFIX,
};
const ANONYMOUS_USER_NOTIFICATION_BLOCK_TIMER: Duration = Duration::days(7);
/// Describes what persistence action to take based on the current auth state.
pub(super) enum PersistAction {
/// The user has Firebase credentials and should be persisted to secure storage.
Persist(Box<PersistedUser>),
/// The user has been logged out and should be removed from secure storage.
Remove,
/// No persistence action is needed (e.g. API key or test credentials).
DoNothing,
}
/// AuthState holds information about the currently-logged in user.
/// If you need to access AuthState, you can use the AuthStateProvider singleton model.
pub struct AuthState {
/// The currently logged-in User. None if the user isn't logged in currently.
user: RwLock<Option<User>>,
/// An anonymous UUID. Can be used to consistently identify an anonymous user who is not logged in.
anonymous_id: Uuid,
/// State that indicates whether the current user's refresh token has been
/// invalidated, meaning a reauth is required.
needs_reauth: AtomicBool,
/// The current authentication credentials.
credentials: RwLock<Option<Credentials>>,
}
impl AuthState {
fn new(ctx: &AppContext) -> Self {
Self {
user: RwLock::new(None),
anonymous_id: get_or_create_anonymous_id(ctx),
needs_reauth: AtomicBool::new(false),
credentials: RwLock::new(None),
}
}
#[cfg(any(test, feature = "integration_tests"))]
pub fn new_for_test() -> Self {
Self {
user: RwLock::new(Some(User::test())),
anonymous_id: Uuid::new_v4(),
needs_reauth: AtomicBool::new(false),
credentials: RwLock::new(Some(Credentials::Test)),
}
}
/// Creates and initializes auth state. Checks, in order:
/// 1. Test user (test/integration/skip_login builds)
/// 2. Provided API key
/// 3. WARP_USER_SECRET environment variable
/// 4. Persisted user from secure storage
#[cfg_attr(target_family = "wasm", allow(dead_code))]
pub fn initialize(ctx: &AppContext, api_key: Option<String>) -> Self {
let state = Self::new(ctx);
if Self::should_use_test_user() {
state.set_user(Some(User::test()));
#[cfg(any(test, feature = "integration_tests", feature = "skip_login"))]
state.set_credentials(Some(Credentials::Test));
return state;
}
if let Some(api_key_value) = api_key {
log::info!("Authenticating via API key");
let formatted = if api_key_value.starts_with(API_KEY_PREFIX) {
api_key_value
} else {
format!("{API_KEY_PREFIX}{api_key_value}")
};
state.set_credentials(Some(Credentials::ApiKey {
key: formatted,
owner_type: None,
}));
return state;
}
// Try WARP_USER_SECRET environment variable.
if let Some(persisted) = option_env!("WARP_USER_SECRET")
.and_then(|s| serde_json::from_str::<PersistedUser>(s).ok())
{
state.apply_persisted_user(persisted);
return state;
}
// Try reading from secure storage.
match PersistedUser::from_secure_storage(ctx) {
Ok(persisted) => {
if persisted.auth_tokens.refresh_token.is_empty() {
log::warn!(
"Found persisted user with empty refresh token; clearing secure storage entry"
);
let _ = PersistedUser::remove_from_secure_storage(ctx).map_err(|err| {
log::warn!("Unable to clear invalid user from secure storage: {err:?}");
});
} else {
state.apply_persisted_user(persisted);
}
}
Err(err) => {
log::info!("Unable to read user from secure storage: {err:?}");
}
}
state
}
fn should_use_test_user() -> bool {
cfg!(any(test, feature = "skip_login")) || ChannelState::channel() == Channel::Integration
}
/// Determines the appropriate persistence action based on the current auth state.
pub(super) fn persist_action(&self) -> PersistAction {
let user = self.user.read().clone();
let credentials = self.credentials.read().clone();
match (user, credentials) {
(Some(user), Some(Credentials::Firebase(firebase_tokens))) => {
let anonymous_user_type = user.anonymous_user_type();
let linked_at = user.linked_at();
let personal_object_limits = user.personal_object_limits();
#[allow(deprecated)]
let persisted = PersistedUser {
auth_tokens: firebase_tokens,
refresh_token: String::new(),
local_id: user.local_id,
metadata: user.metadata,
is_onboarded: user.is_onboarded,
needs_sso_link: user.needs_sso_link,
anonymous_user_type,
linked_at,
personal_object_limits,
is_on_work_domain: user.is_on_work_domain,
};
PersistAction::Persist(Box::new(persisted))
}
// Remove persisted auth state if it is unset in-memory.
(None, None) => PersistAction::Remove,
// Do not persist if using API keys, session cookies, or test credentials.
(Some(_), Some(Credentials::ApiKey { .. })) => PersistAction::DoNothing,
(Some(_), Some(Credentials::SessionCookie)) => PersistAction::DoNothing,
#[cfg(any(test, feature = "integration_tests", feature = "skip_login"))]
(Some(_), Some(Credentials::Test)) => PersistAction::DoNothing,
// Credentials without a user, or user without credentials - transient states
// during initialization or refresh; no persistence action needed.
(None, Some(_)) | (Some(_), None) => PersistAction::DoNothing,
}
}
/// Applies a deserialized PersistedUser, splitting it into User and Credentials.
fn apply_persisted_user(&self, persisted: PersistedUser) {
let user = User {
is_onboarded: persisted.is_onboarded,
local_id: persisted.local_id,
metadata: persisted.metadata,
needs_sso_link: persisted.needs_sso_link,
anonymous_user_type: persisted.anonymous_user_type,
is_on_work_domain: persisted.is_on_work_domain,
linked_at: persisted.linked_at,
personal_object_limits: persisted.personal_object_limits,
principal_type: PrincipalType::default(),
};
*self.user.write() = Some(user);
if persisted.auth_tokens.refresh_token.is_empty() {
log::warn!("Skipping credentials update due to empty refresh token");
return;
}
*self.credentials.write() = Some(Credentials::Firebase(persisted.auth_tokens));
}
/// Sets the user. This should only be called by the AuthManager, to ensure
/// side-effects are handled properly (e.g. notifying other models, persisting
/// the user to secure storage, etc.).
pub(super) fn set_user(&self, user: Option<User>) {
*self.user.write() = user;
}
/// Returns the current credentials.
pub fn credentials(&self) -> Option<Credentials> {
self.credentials.read().clone()
}
/// Sets the credentials. Should only be called within the auth module.
pub(super) fn set_credentials(&self, credentials: Option<Credentials>) {
*self.credentials.write() = credentials;
}
/// Updates the Firebase auth tokens within the current credentials.
/// Reports an error if the current credentials are not Firebase.
pub(crate) fn update_firebase_tokens(&self, new_auth_tokens: FirebaseAuthTokens) {
let mut write_lock = self.credentials.write();
if let Some(Credentials::Firebase(tokens)) = write_lock.as_mut() {
*tokens = new_auth_tokens;
} else {
report_error!(anyhow!(
"Tried to update Firebase tokens without Firebase credentials"
));
}
}
/// Determines whether the user should be considered as logged in.
pub fn is_logged_in(&self) -> bool {
self.credentials.read().is_some()
}
/// Returns whether the user should be treated as not having a full account.
/// True if the user is anonymous OR if there is no user at all (fully logged out).
///
/// Note: uses `unwrap_or(true)` intentionally (not `unwrap_or_default()`) so that
/// 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)
}
/// Returns the cached access token, if any exists. This method *will not* check if the JWT is
/// still valid! Usually, you want to use [`ServerApi::get_or_refresh_access_token`] instead!
pub fn get_access_token_ignoring_validity(&self) -> Option<String> {
let credentials = self.credentials.read();
credentials.as_ref()?.bearer_token().bearer_token()
}
/// Returns the user's display name.
pub fn username_for_display(&self) -> Option<String> {
Some(self.user.read().as_ref()?.username_for_display().to_owned())
}
/// Returns the user's display name, does NOT fall back to email.
pub fn display_name(&self) -> Option<String> {
self.user
.read()
.as_ref()
.and_then(|user| user.display_name().to_owned())
}
/// Returns the user's email. Note the non-obvious semantics of this function:
/// If the user is logged in and not anonymous, the email will always be populated.
/// If the user is logged in and anonymous, their email will be an empty string.
/// If the user is not logged in, their email will be `None`.
pub fn user_email(&self) -> Option<String> {
self.user
.read()
.as_ref()
.map(|user| user.metadata.email.clone())
}
/// 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)
}
/// Returns the user's email domain (anything after the @ sign of their email).
pub fn user_email_domain(&self) -> Option<String> {
self.user.read().as_ref().map(|user| {
user.metadata
.email
.clone()
.split('@')
.nth(1)
.unwrap_or("")
.to_string()
})
}
/// Returns whether or not the user is anonymous.
/// 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())
}
/// 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()
})
}
/// 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)
)
})
}
/// 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,
) -> 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
}
})
}
/// Returns the user's photo URL from Firebase,
/// typically acquired from linking a provider like Google/GitHub.
pub fn user_photo_url(&self) -> Option<String> {
self.user
.read()
.as_ref()
.and_then(|user| user.metadata.photo_url.clone())
}
/// 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)
}
/// Returns the anonymous user type.
/// Note that a `Some()` value here does NOT mean the user is still anonymous;
/// they might have since signed up, but we keep their anonymous user type around.
pub fn anonymous_user_type(&self) -> Option<AnonymousUserType> {
self.user
.read()
.as_ref()
.and_then(|user| user.anonymous_user_type())
}
/// Returns the personal object limits the user has.
/// Currently, only anonymous users have limits.
pub fn personal_object_limits(&self) -> Option<PersonalObjectLimits> {
self.user
.read()
.as_ref()
.and_then(|user| user.personal_object_limits())
}
/// Set whether or not the user is onboarded.
pub fn set_is_onboarded(&self, is_onboarded: bool) {
if let Some(user) = self.user.write().as_mut() {
user.is_onboarded = is_onboarded;
}
}
/// If the user is logged in, returns their Firebase UID. Otherwise, returns None.
pub fn user_id(&self) -> Option<UserUid> {
self.user.read().as_ref().map(|user| user.local_id)
}
/// Returns the user's anonymous id.
/// The anonymous id will be consistent across the app's lifetime. It is a random UUID.
pub fn anonymous_id(&self) -> String {
self.anonymous_id.to_string()
}
/// 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)
}
/// Sets whether a reauth is required for the current user.
/// Returns whether or not the reauth state was changed from false to true.
pub(super) fn set_needs_reauth(&self, new_needs_reauth: bool) -> bool {
let prev_needs_reauth = self.needs_reauth.swap(new_needs_reauth, Ordering::Relaxed);
!prev_needs_reauth && new_needs_reauth
}
/// Returns whether or not the renotification block to encourage anonymous users to sign up
/// has expired.
pub fn anonymous_user_renotification_block_expired(
&self,
last_time_opt: Option<String>,
) -> bool {
self.is_anonymous_user_feature_gated().unwrap_or_default()
&& last_time_opt
.and_then(|last_time_string| last_time_string.parse::<DateTime<Utc>>().ok())
.is_none_or(|last_time| {
Utc::now() - ANONYMOUS_USER_NOTIFICATION_BLOCK_TIMER >= last_time
})
}
/// Returns whether or not the user is on a work domain.
/// This calculation is done on the server, using a list of
pub fn is_on_work_domain(&self) -> Option<bool> {
self.user.read().as_ref().map(|user| user.is_on_work_domain)
}
/// Returns whether the current user is authenticated via API key.
pub fn is_api_key_authenticated(&self) -> bool {
matches!(
self.credentials.read().as_ref(),
Some(Credentials::ApiKey { .. })
)
}
/// Returns the API key if using API key authentication.
pub fn api_key(&self) -> Option<String> {
let credentials = self.credentials.read();
credentials.as_ref()?.as_api_key().map(|s| s.to_owned())
}
/// Returns the type of principal (user or service account).
pub fn principal_type(&self) -> Option<PrincipalType> {
self.user.read().as_ref().map(|user| user.principal_type)
}
/// Returns whether the authenticated principal is a service account.
pub fn is_service_account(&self) -> bool {
matches!(self.principal_type(), Some(PrincipalType::ServiceAccount))
}
/// Returns the owner type of the currently-authenticated API key.
pub fn api_key_owner_type(&self) -> Option<OwnerType> {
self.credentials.read().as_ref()?.api_key_owner_type()
}
}
// Adapter for the [`warp_managed_secrets`] crate, which needs to access the current user.
impl warp_managed_secrets::ActorProvider for AuthState {
fn actor_uid(&self) -> Option<String> {
self.user_id().map(|uid| uid.as_string())
}
}
/// AuthStateProvider is a singleton model which provides a reference to the global AuthState.
pub struct AuthStateProvider {
auth_state: Arc<AuthState>,
}
impl AuthStateProvider {
pub fn new(auth_state: Arc<AuthState>) -> Self {
Self { auth_state }
}
#[cfg(test)]
pub fn new_for_test() -> Self {
Self {
auth_state: Arc::new(AuthState::new_for_test()),
}
}
/// Constructs a provider backed by a fully logged-out `AuthState` (no user,
/// no credentials). Used by unit tests that need to exercise code paths
/// gated on `AuthState::user_id()` / `UserWorkspaces::personal_drive()`
/// returning `None`.
#[cfg(test)]
pub fn new_logged_out_for_test() -> Self {
Self {
auth_state: Arc::new(AuthState {
user: RwLock::new(None),
anonymous_id: Uuid::new_v4(),
needs_reauth: AtomicBool::new(false),
credentials: RwLock::new(None),
}),
}
}
pub fn get(&self) -> &Arc<AuthState> {
&self.auth_state
}
}
impl Entity for AuthStateProvider {
type Event = ();
}
impl SingletonEntity for AuthStateProvider {}
File diff suppressed because it is too large Load Diff
+401
View File
@@ -0,0 +1,401 @@
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 url::Url;
use warp_core::errors::ErrorExt;
use warp_core::features::FeatureFlag;
use warpui::elements::ChildAnchor;
use warpui::elements::Container;
use warpui::elements::Fill;
use warpui::elements::HighlightedHyperlink;
use warpui::elements::MouseStateHandle;
use warpui::elements::OffsetPositioning;
use warpui::elements::ParentAnchor;
use warpui::elements::ParentElement;
use warpui::elements::ParentOffsetBounds;
use warpui::elements::Stack;
use warpui::keymap::FixedBinding;
use warpui::AppContext;
use warpui::FocusContext;
use warpui::SingletonEntity;
use warpui::TypedActionView;
use crate::auth::auth_view_body::AuthViewBody;
use crate::modal::Modal;
use std::collections::HashMap;
use warpui::elements::ChildView;
use warpui::ui_components::components::{Coords, UiComponentStyles};
use warpui::{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 warpui::actions::StandardAction;
pub fn init(app: &mut AppContext) {
use warpui::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()),
)]);
}
#[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>,
pub deleted_anonymous_user: Option<bool>,
pub state: Option<String>,
}
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)),
}
}
}
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)]
pub enum AuthViewEvent {
Close,
}
impl Entity for AuthView {
type Event = AuthViewEvent;
}
impl View for AuthView {
fn ui_name() -> &'static str {
"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()
}
}
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);
}
}
}
}
+609
View File
@@ -0,0 +1,609 @@
use pathfinder_color::ColorU;
use warp_core::channel::ChannelState;
use warp_core::features::FeatureFlag;
use warp_core::ui::{
appearance::Appearance,
builder::UiBuilder,
color::{darken, lighten},
theme::ColorScheme,
};
use warpui::{
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()
}
+215
View File
@@ -0,0 +1,215 @@
//! Representation of Warp user credentials.
//!
//! The primary representation is [`Credentials`], which is the source of truth for how a user is
//! authenticated to Warp.
//!
//! Credentials can be split into two halves:
//! * [`LoginToken`], which is a long-lived token that we use to fetch user information.
//! When using Firebase, this is an OAuth2 refresh token.
//! * [`AuthToken`], which is a short-lived token that's included in all other server requests.
//! When using Firebase, this is an OAuth2 access token.
use warp_graphql::object_permissions::OwnerType;
use super::user::FirebaseAuthTokens;
/// Represents the different ways a user can authenticate with Warp.
#[derive(Clone, Debug)]
pub enum Credentials {
/// Firebase authentication with ID token and refresh token.
Firebase(FirebaseAuthTokens),
/// API key for direct server authentication.
ApiKey {
key: String,
/// The owner type for this API key. Only set after user info is fetched from the server.
owner_type: Option<OwnerType>,
},
/// Authentication derived from an ambient browser session cookie.
SessionCookie,
/// Test credentials used in unit tests, integration tests, and skip_login builds.
#[cfg(any(test, feature = "integration_tests", feature = "skip_login"))]
Test,
}
impl Credentials {
/// Returns the Firebase auth tokens if this is a Firebase credential.
pub fn as_firebase(&self) -> Option<&FirebaseAuthTokens> {
match self {
Credentials::Firebase(tokens) => Some(tokens),
Credentials::ApiKey { .. } => None,
Credentials::SessionCookie => None,
#[cfg(any(test, feature = "integration_tests", feature = "skip_login"))]
Credentials::Test => None,
}
}
/// Returns the API key string if this is an API key credential.
pub fn as_api_key(&self) -> Option<&str> {
match self {
Credentials::ApiKey { key, .. } => Some(key),
Credentials::Firebase(_) => None,
Credentials::SessionCookie => None,
#[cfg(any(test, feature = "integration_tests", feature = "skip_login"))]
Credentials::Test => None,
}
}
/// Returns the owner type if this is an API key credential.
pub fn api_key_owner_type(&self) -> Option<OwnerType> {
match self {
Credentials::ApiKey { owner_type, .. } => *owner_type,
Credentials::Firebase(_) => None,
Credentials::SessionCookie => None,
#[cfg(any(test, feature = "integration_tests", feature = "skip_login"))]
Credentials::Test => None,
}
}
/// Returns the Firebase refresh token if this is a Firebase credential.
pub fn refresh_token(&self) -> Option<&str> {
match self {
Credentials::Firebase(tokens) => Some(&tokens.refresh_token),
Credentials::ApiKey { .. } => None,
Credentials::SessionCookie => None,
#[cfg(any(test, feature = "integration_tests", feature = "skip_login"))]
Credentials::Test => None,
}
}
/// Returns the short-lived token to use in HTTP requests to the server.
pub fn bearer_token(&self) -> AuthToken {
match self {
Credentials::Firebase(tokens) => AuthToken::Firebase(tokens.id_token.clone()),
Credentials::ApiKey { key, .. } => AuthToken::ApiKey(key.clone()),
Credentials::SessionCookie => AuthToken::NoAuth,
#[cfg(any(test, feature = "integration_tests", feature = "skip_login"))]
Credentials::Test => AuthToken::NoAuth,
}
}
/// Get the long-lived login token for these credentials. Returns `None` if there is no such token.
pub fn login_token(&self) -> Option<LoginToken> {
match self {
Credentials::Firebase(tokens) => Some(LoginToken::Firebase(FirebaseToken::Refresh(
RefreshToken::new(&tokens.refresh_token),
))),
Credentials::ApiKey { key, .. } => Some(LoginToken::ApiKey(key.clone())),
Credentials::SessionCookie => Some(LoginToken::SessionCookie),
#[cfg(any(test, feature = "integration_tests", feature = "skip_login"))]
Credentials::Test => None,
}
}
}
/// Represents different types of authentication tokens.
#[derive(Debug, Clone)]
pub enum AuthToken {
/// Firebase short-lived access token.
Firebase(String),
/// API key for direct server authentication.
ApiKey(String),
/// No authentication token available (e.g. session cookie auth or test credentials).
#[cfg_attr(
not(any(test, feature = "integration_tests", feature = "skip_login")),
allow(dead_code)
)]
NoAuth,
}
impl AuthToken {
/// Returns the token string to use in an Authorization header, or `None` if auth is not
/// header-based (e.g. session cookie) or there is no auth.
pub fn as_bearer_token(&self) -> Option<&str> {
match self {
AuthToken::Firebase(token) => Some(token),
AuthToken::ApiKey(key) => Some(key),
AuthToken::NoAuth => None,
}
}
/// Returns the bearer token as an owned string, or `None` if auth is not header-based.
pub fn bearer_token(&self) -> Option<String> {
match self {
AuthToken::Firebase(token) => Some(token.clone()),
AuthToken::ApiKey(key) => Some(key.clone()),
AuthToken::NoAuth => None,
}
}
}
/// Long-lived credentials exchanged for user information.
#[derive(Debug)]
pub enum LoginToken {
/// A Firebase token to be exchanged for auth tokens.
Firebase(FirebaseToken),
/// An API key for direct server authentication.
ApiKey(String),
/// Authentication derived from an ambient browser session cookie.
SessionCookie,
}
/// The type of firebase token that can be used to authenticate a user.
/// For logged in users and anonymous users, we use a refresh token.
/// We use a short-lived custom token when we first create and fetch a new anonymous user.
/// In both cases the token can be exchanged for a short lived access token.
#[derive(Debug)]
pub enum FirebaseToken {
/// The token type for a logged in user.
Refresh(RefreshToken),
/// The token type for an anonymous user.
Custom(String),
}
impl FirebaseToken {
/// Returns the url for trading this long lived token into an access token.
pub fn access_token_url(&self, api_key: &str) -> String {
// See https://firebase.google.com/docs/reference/rest/auth for info on these
// authentication endpoints.
match self {
FirebaseToken::Refresh(_) => {
format!("https://securetoken.googleapis.com/v1/token?key={api_key}")
}
FirebaseToken::Custom(_) => {
format!("https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key={api_key}")
}
}
}
/// Returns the POST body for to include when trading this long lived token into an access token.
pub fn access_token_request_body(&self) -> Vec<(&str, &str)> {
match self {
FirebaseToken::Refresh(refresh_token) => vec![
("grant_type", "refresh_token"),
("refresh_token", refresh_token.get()),
],
FirebaseToken::Custom(custom_token) => {
vec![("returnSecureToken", "true"), ("token", custom_token)]
}
}
}
/// Returns the proxy URL for trading this long lived token into an access token.
/// Used when the initial request to Firebase fails and we want to try and proxy the request
/// through our server.
pub fn proxy_url(&self, server_root: &str, api_key: &str) -> String {
match self {
FirebaseToken::Refresh(_) => format!("{server_root}/proxy/token?key={api_key}"),
FirebaseToken::Custom(_) => {
format!("{server_root}/proxy/customToken?key={api_key}")
}
}
}
}
#[derive(Debug, Clone)]
pub struct RefreshToken(String);
impl RefreshToken {
pub fn new(token: impl Into<String>) -> Self {
Self(token.into())
}
pub fn get(&self) -> &str {
self.0.as_str()
}
}
+139
View File
@@ -0,0 +1,139 @@
use std::borrow::Cow;
use pathfinder_color::ColorU;
use warpui::{
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
@@ -0,0 +1,167 @@
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
use warpui::{
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 warpui::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
+325
View File
@@ -0,0 +1,325 @@
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;
pub mod user;
pub mod user_uid;
#[cfg(target_family = "wasm")]
pub mod web_handoff;
use crate::ai::agent_conversations_model::AgentConversationsModel;
use crate::ai::blocklist::BlocklistAIHistoryModel;
use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel;
use crate::ai_assistant::requests::REQUEST_LIMIT_INFO_CACHE_KEY;
use crate::code::editor_management::{CodeEditorStatus, CodeEditorSummary};
use crate::env_vars::manager::EnvVarCollectionManager;
use crate::notebooks::manager::NotebookManager;
use crate::terminal::general_settings::GeneralSettings;
use crate::workflows::manager::WorkflowManager;
use ::settings::{Setting, SettingsManager, ToggleableSetting};
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 user_uid::UserUid;
use warpui::modals::{AlertDialogWithCallbacks, ModalButton};
use warp_core::user_preferences::GetUserPreferences as _;
use warpui::{AppContext, SingletonEntity};
use crate::cloud_object::model::persistence::CloudModel;
use crate::focus_running_window_and_show_native_modal;
use crate::palette::PaletteMode;
use crate::server::cloud_objects::update_manager::UpdateManager;
use crate::server::sync_queue::SyncQueue;
use crate::server::telemetry::{PaletteSource, TelemetryEvent};
use crate::session_management::{RunningSessionSummary, SessionNavigationData};
use crate::settings::{
CloudPreferencesSettings, PrivacySettings, CRASH_REPORTING_ENABLED_DEFAULTS_KEY,
TELEMETRY_ENABLED_DEFAULTS_KEY,
};
use crate::terminal::shared_session::manager::Manager as SharedSessionManager;
use crate::workspace::{Workspace, WorkspaceAction};
use crate::workspaces::update_manager::TeamUpdateManager;
use crate::{persistence, GlobalResourceHandlesProvider};
use crate::{report_if_error, send_telemetry_sync_from_app_ctx};
/// Prefix for API keys used in authentication
#[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);
}
/// 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.
pub fn maybe_log_out(app: &mut AppContext) {
send_telemetry_sync_from_app_ctx!(TelemetryEvent::UserInitiatedLogOut, app);
let sessions = SessionNavigationData::all_sessions(app).collect_vec();
let num_long_running_commands = RunningSessionSummary::new(&sessions)
.long_running_cmds
.len();
let num_shared_sessions = crate::session_management::num_shared_sessions(app);
let num_unsaved_objects =
CloudModel::as_ref(app).num_unsaved_objects_to_warn_about_before_quitting();
let code_editors = CodeEditorStatus::all_editors(app).collect_vec();
let code_editor_summary = CodeEditorSummary::new(&code_editors);
let num_unsaved_files = code_editor_summary.unsaved_changes.len();
let show_warning_before_log_out = *GeneralSettings::as_ref(app)
.show_warning_before_quitting
.value();
if show_warning_before_log_out
&& (num_long_running_commands > 0
|| num_shared_sessions > 0
|| num_unsaved_objects > 0
|| num_unsaved_files > 0)
{
send_telemetry_sync_from_app_ctx!(TelemetryEvent::LogOutModalShown, app);
let mut button_data = vec![ModalButton::for_app("Yes, log out", |ctx| {
log_out(ctx);
})];
let mut info_text_vec: Vec<String> = vec![];
if num_long_running_commands > 0 {
let plural = if num_long_running_commands > 1 {
"processes"
} else {
"process"
};
info_text_vec.push(format!(
"You have {num_long_running_commands} {plural} running."
));
button_data.push(ModalButton::for_app("Show running processes", move |ctx| {
send_telemetry_sync_from_app_ctx!(
TelemetryEvent::LogOutModalCancel { nav_palette: true },
ctx
);
let windowing_model = ctx.windows();
let window_id = if let Some(active_window_id) = windowing_model.active_window() {
active_window_id
} else if let Some(window_id) = ctx.window_ids().collect_vec().first() {
let window_id = *window_id;
windowing_model.show_window_and_focus_app(window_id);
window_id
} else {
return;
};
if let Some(workspaces) = ctx.views_of_type::<Workspace>(window_id) {
if let Some(handle) = workspaces.first() {
ctx.dispatch_typed_action_for_view(
window_id,
handle.id(),
&WorkspaceAction::OpenPalette {
mode: PaletteMode::Navigation,
source: PaletteSource::LogOutModal,
query: Some("running".to_owned()),
},
);
}
}
}))
}
if num_shared_sessions > 0 {
let plural = if num_shared_sessions > 1 {
"sessions"
} else {
"session"
};
info_text_vec.push(format!("You have {num_shared_sessions} shared {plural}."));
}
if num_unsaved_objects > 0 {
let plural = if num_unsaved_objects > 1 {
"objects"
} else {
"object"
};
info_text_vec.push(format!(
"You have {num_unsaved_objects} unsynced Warp Drive {plural}. \
Logging out will cause you to lose the {plural}."
));
}
if num_unsaved_files > 0 {
let plural = if num_unsaved_files > 1 {
"files"
} else {
"file"
};
info_text_vec.push(format!(
"You have {num_unsaved_files} unsaved {plural}. \
Logging out will cause you to lose the {plural}."
));
}
button_data.push(ModalButton::for_app("Cancel", move |ctx| {
send_telemetry_sync_from_app_ctx!(
TelemetryEvent::LogOutModalCancel { nav_palette: false },
ctx
);
}));
let alert_data = AlertDialogWithCallbacks::for_app(
"Log out?",
info_text_vec.join("\n"),
button_data,
move |ctx| {
GeneralSettings::handle(ctx).update(ctx, |general_settings, ctx| {
report_if_error!(general_settings
.show_warning_before_quitting
.toggle_and_save_value(ctx));
});
},
);
// On mac, we show the native platform modal. On platforms that don't support a native modal,
// we show the custom warp modal.
if cfg!(all(not(target_family = "wasm"), target_os = "macos")) {
app.show_native_platform_modal(alert_data);
} else {
let sessions = SessionNavigationData::all_sessions(app).collect_vec();
let sessions_summary = RunningSessionSummary::new(&sessions);
focus_running_window_and_show_native_modal(sessions_summary, alert_data, app);
}
} else {
log_out(app);
}
}
// Log out the user, clears workspace state, stops running processes, and deletes database.
pub fn log_out(app: &mut AppContext) {
send_telemetry_sync_from_app_ctx!(TelemetryEvent::LogOut, app);
CodebaseIndexManager::handle(app).update(app, |index_manager, ctx| {
index_manager.reset_codebase_indexing(ctx);
});
let global_resource_handles = GlobalResourceHandlesProvider::as_ref(app).get();
// As part of Logout v0, we remove sqlite3 so sessions and cloud objects don't persist between accounts.
// TODO: Implement per-user scoping of sqlite3.
persistence::remove(&global_resource_handles.model_event_sender);
AuthManager::handle(app).update(app, |auth_manager, ctx| {
auth_manager.log_out(ctx);
});
AIExecutionProfilesModel::handle(app).update(app, |ai_execution_profiles_model, _| {
ai_execution_profiles_model.reset();
});
BlocklistAIHistoryModel::handle(app).update(app, |history_model, _| {
history_model.reset();
});
AgentConversationsModel::handle(app).update(app, |agent_conversations_model, _| {
agent_conversations_model.reset();
});
CloudModel::handle(app).update(app, |cloud_model, _| {
cloud_model.reset();
});
// Clear the sync queue so that we don't try to sync the old user's objects to the new user.
SyncQueue::handle(app).update(app, |sync_queue, _| {
sync_queue.clear();
});
// Stop the cloud object and workspace metadata polling loops that were started on login.
UpdateManager::handle(app).update(app, |manager, _| {
manager.stop_polling_for_updated_objects();
});
TeamUpdateManager::handle(app).update(app, |manager, _| {
manager.stop_polling_for_workspace_metadata_updates();
});
remove_cloud_persisted_settings(app);
NotebookManager::handle(app).update(app, |manager, _| manager.reset());
EnvVarCollectionManager::handle(app).update(app, |manager, _| manager.reset());
WorkflowManager::handle(app).update(app, |manager, _| manager.reset());
// Stop and leave all shared sessions
SharedSessionManager::handle(app).update(app, |manager, ctx| {
manager.stop_all_shared_sessions(ctx);
manager.clear_joined();
});
// Dispatch action on root view of every open window so the state can be updated
// correctly.
let window_ids = app.window_ids().collect_vec();
for window_id in window_ids {
if let Some(root_view_id) = app.root_view_id(window_id) {
app.dispatch_action(
window_id,
&[root_view_id],
"root_view:log_out",
&(),
log::Level::Info,
);
}
}
#[cfg(target_family = "wasm")]
crate::platform::wasm::emit_event(crate::platform::wasm::WarpEvent::LoggedOut);
}
// Remove the cloud persisted settings from user defaults.
// When a user signs out, we remove cloud persisted settings of their account.
// This is so they do not experience the old settings when they log in with a different account.
// Partial deletion of user defaults is a stopgap for Logout v0. The correct solution is:
fn remove_cloud_persisted_settings(app: &mut AppContext) {
let is_settings_sync_enabled = *CloudPreferencesSettings::as_ref(app).settings_sync_enabled;
if is_settings_sync_enabled {
SettingsManager::handle(app).update(app, |settings_manager, ctx| {
let errors = settings_manager.clear_cloud_settings_local_state(ctx);
for e in errors {
log::error!("Failed to remove cloud synced setting from user defaults: {e:?}");
}
});
}
if let Err(e) = app
.private_user_preferences()
.remove_value(TELEMETRY_ENABLED_DEFAULTS_KEY)
{
log::error!("Failed to remove Telemetry Enabled Defaults Key from user defaults: {e:?}");
}
if let Err(e) = app
.private_user_preferences()
.remove_value(CRASH_REPORTING_ENABLED_DEFAULTS_KEY)
{
log::error!(
"Failed to remove Crash Reporting Enabled Defaults Key from user defaults: {e:?}"
);
}
if let Err(e) = app
.private_user_preferences()
.remove_value(REQUEST_LIMIT_INFO_CACHE_KEY)
{
log::error!("Failed to remove Request Limit Defaults Key from user defaults: {e:?}");
}
// Reset the Privacy Settings in the login screen to default values.
PrivacySettings::handle(app).update(app, |privacy_settings, _| {
privacy_settings.refresh_to_default();
});
}
+101
View File
@@ -0,0 +1,101 @@
use super::auth_manager::AuthManager;
use crate::{appearance::Appearance, auth::login_error_modal::LoginErrorModal};
use warpui::elements::{Align, MouseStateHandle, Shrinkable};
use warpui::ui_components::button::ButtonVariant;
use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use warpui::{AppContext, Element, Entity, SingletonEntity, 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,
}
impl NeedsSsoLinkView {
pub fn new() -> Self {
Self {
email: None,
mouse_state_handles: Default::default(),
}
}
pub fn set_email(&mut self, email: String) {
self.email = Some(email);
}
}
impl Entity for NeedsSsoLinkView {
type Event = ();
}
impl View for NeedsSsoLinkView {
fn ui_name() -> &'static str {
"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()
}
}
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);
});
}
}
}
}
+436
View File
@@ -0,0 +1,436 @@
//! 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<EditorView>,
cancel_button: button::Button,
continue_button: button::Button,
close_mouse_state: MouseStateHandle,
last_failure_reason: Option<LoginFailureReason>,
highlighted_hyperlink_state: HighlightedHyperlink,
}
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();
}
}
}
}
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<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 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<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));
}
}
}
}
+216
View File
@@ -0,0 +1,216 @@
use crate::server::datetime_ext::DateTimeExt;
use anyhow::{anyhow, Result};
use chrono::{DateTime, FixedOffset};
use serde::{Deserialize, Serialize};
use warp_graphql::{queries::get_user::FirebaseProfile, scalars::time::ServerTimestamp};
use super::UserUid;
pub use warp_server_client::auth::{TEST_USER_EMAIL, TEST_USER_UID};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum AnonymousUserType {
/// An anonymous user created from the native client.
NativeClientAnonymousUser,
/// An anonymous user created from the native client with feature (rather than time-based) gating.
NativeClientAnonymousUserFeatureGated,
/// An anonymous user created from the web client.
WebClientAnonymousUser,
}
/// Type of principal making the authenticated request.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum PrincipalType {
#[default]
User,
ServiceAccount,
}
impl From<warp_graphql::queries::get_user::PrincipalType> for PrincipalType {
fn from(value: warp_graphql::queries::get_user::PrincipalType) -> Self {
use warp_graphql::queries::get_user::PrincipalType as GqlPrincipalType;
match value {
GqlPrincipalType::User => PrincipalType::User,
GqlPrincipalType::ServiceAccount => PrincipalType::ServiceAccount,
}
}
}
impl TryFrom<warp_graphql::mutations::create_anonymous_user::AnonymousUserType>
for AnonymousUserType
{
type Error = anyhow::Error;
fn try_from(
value: warp_graphql::mutations::create_anonymous_user::AnonymousUserType,
) -> Result<Self, Self::Error> {
match value {
warp_graphql::mutations::create_anonymous_user::AnonymousUserType::NativeClientAnonymousUser => Ok(AnonymousUserType::NativeClientAnonymousUser),
warp_graphql::mutations::create_anonymous_user::AnonymousUserType::NativeClientAnonymousUserFeatureGated => Ok(AnonymousUserType::NativeClientAnonymousUserFeatureGated),
warp_graphql::mutations::create_anonymous_user::AnonymousUserType::WebClientAnonymousUser => Ok(AnonymousUserType::WebClientAnonymousUser),
warp_graphql::mutations::create_anonymous_user::AnonymousUserType::Other(_) => {
Err(anyhow!("could not convert unknown anonymous user type"))
},
}
}
}
#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
pub struct PersonalObjectLimits {
pub env_var_limit: usize,
pub notebook_limit: usize,
pub workflow_limit: usize,
}
impl TryFrom<warp_graphql::queries::get_user::AnonymousUserPersonalObjectLimits>
for PersonalObjectLimits
{
type Error = anyhow::Error;
fn try_from(
value: warp_graphql::queries::get_user::AnonymousUserPersonalObjectLimits,
) -> Result<Self, Self::Error> {
Ok(Self {
env_var_limit: value.env_var_limit as usize,
notebook_limit: value.notebook_limit as usize,
workflow_limit: value.workflow_limit as usize,
})
}
}
/// The in-memory representation of a logged-in User.
/// This does not include authentication credentials, which are stored separately
/// in the `Credentials` enum.
#[derive(Debug, Clone)]
pub struct User {
/// The Firebase UID of this user.
pub local_id: UserUid,
/// Metadata about the user.
pub metadata: UserMetadata,
/// Whether or not the user is onboarded.
pub is_onboarded: bool,
/// Whether or not the user needs to link their account via SSO due to an organization setting.
pub needs_sso_link: bool,
/// What type of anonymous user this user is. May be `None` if they are not anonymous.
pub anonymous_user_type: Option<AnonymousUserType>,
/// Whether or not this user is on what we consider a "work" domain, meaning the domain isn't
/// from a general email provider (e.g. gmail.com, hotmail.com, proton.me, etc.).
/// Calculated on warp-server.
pub is_on_work_domain: bool,
pub linked_at: Option<ServerTimestamp>,
pub personal_object_limits: Option<PersonalObjectLimits>,
/// Type of principal (user or service account). Fetched fresh from the server
/// on each login/refresh.
pub principal_type: PrincipalType,
}
/// This struct holds extra information about the user. Most of this information comes directly
/// from Firebase.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct UserMetadata {
/// The user's email. NOTE: unlike other fields which use `Option`s to denote null values,
/// an anonymous user will have an empty string as their email here.
pub email: String,
/// The user's display name from Firebase. We should prefer showing this over their email, if
/// we can. Typically this is only populated when using a non-email provider like GitHub.
pub display_name: Option<String>,
/// A URL for their profile picture.
pub photo_url: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FirebaseAuthTokens {
/// ID tokens are Firebase tokens, which are short-lived tokens that are used to authenticate
/// requests to the server. These are obtained by exchanging long-lived refresh tokens.
pub id_token: String,
/// Refresh tokens are long-lived tokens that can be exchanged for short-lived access tokens
/// (stored in the id_token field). We use the refresh token to get a new ID token when the
/// current one expires.
/// Note that there are two types of refresh tokens we store in this field:
/// "Refresh tokens": these are used for logged-in users.
/// "Custom tokens": these are used for anonymous firebase users.
pub refresh_token: String,
/// When the ID token expires. If the token has expired, or will expire soon, we should
/// fetch a new ID token using the user's refresh token.
pub expiration_time: DateTime<FixedOffset>,
}
impl FirebaseAuthTokens {
pub fn from_response(
id_token: String,
refresh_token: String,
expires_in: String,
) -> Result<Self, anyhow::Error> {
Ok(Self {
id_token,
expiration_time: chrono::DateTime::now()
+ chrono::Duration::seconds(
expires_in.parse::<i64>().map_err(anyhow::Error::from)?,
),
refresh_token,
})
}
}
impl User {
/// The name for the user that we display. This is the user's display name, if set. If not set,
/// we then fallback to email (which is always set).
pub fn username_for_display(&self) -> &str {
let user_metadata = &self.metadata;
user_metadata
.display_name
.as_deref()
.unwrap_or(user_metadata.email.as_str())
}
/// The display name of the user. Does not fall back to email.
pub fn display_name(&self) -> Option<String> {
self.metadata.display_name.clone()
}
pub fn test() -> Self {
Self {
local_id: UserUid::new(TEST_USER_UID),
metadata: UserMetadata {
email: TEST_USER_EMAIL.to_string(),
display_name: None,
photo_url: None,
},
is_onboarded: true,
needs_sso_link: false,
anonymous_user_type: None,
is_on_work_domain: false,
linked_at: None,
personal_object_limits: None,
principal_type: PrincipalType::User,
}
}
pub fn is_user_anonymous(&self) -> bool {
self.anonymous_user_type().is_some() && self.linked_at().is_none()
}
pub fn anonymous_user_type(&self) -> Option<AnonymousUserType> {
self.anonymous_user_type
}
pub fn personal_object_limits(&self) -> Option<PersonalObjectLimits> {
self.personal_object_limits
}
pub fn linked_at(&self) -> Option<ServerTimestamp> {
self.linked_at
}
}
impl From<FirebaseProfile> for UserMetadata {
fn from(value: FirebaseProfile) -> Self {
Self {
email: value.email.unwrap_or_default(),
display_name: value.display_name,
photo_url: value.photo_url,
}
}
}
#[cfg(test)]
#[path = "user_test.rs"]
mod tests;
+36
View File
@@ -0,0 +1,36 @@
use super::*;
use anyhow::Result;
use warp_graphql::queries::get_user::FirebaseProfile;
#[test]
fn test_parse_user_profile() -> Result<()> {
let response: FirebaseProfile = serde_json::from_str(
r#"{
"uid": "test_local_id",
"email": "test_user@example.com",
"displayName": "Test User",
"photoUrl": "https://photourl.example.com/1234",
"needsSsoLink": true
}"#,
)?;
let user = User {
is_onboarded: true,
local_id: UserUid::new("test_local_id"),
metadata: response.into(),
needs_sso_link: true,
anonymous_user_type: None,
is_on_work_domain: false,
linked_at: None,
personal_object_limits: None,
principal_type: PrincipalType::User,
};
assert_eq!(user.metadata.display_name.as_deref(), Some("Test User"));
assert_eq!(user.metadata.email, "test_user@example.com");
assert_eq!(
user.metadata.photo_url.as_deref(),
Some("https://photourl.example.com/1234")
);
assert!(user.needs_sso_link);
Ok(())
}
+1
View File
@@ -0,0 +1 @@
pub use warp_server_client::auth::user_uid::*;
+137
View File
@@ -0,0 +1,137 @@
use anyhow::anyhow;
use wasm_bindgen::prelude::*;
use warpui::{
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)]
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,
}
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,
}
}
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();
}
_ => {}
}
}
}
impl Entity for WebHandoffView {
type Event = WebHandoffEvent;
}
impl View for WebHandoffView {
fn ui_name() -> &'static str {
"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()
}
}