use freya::prelude::*; use freya::query::{MutationCapability, MutationStateData, UseMutation}; use freya::text_edit::Clipboard; use oneclient_core::LauncherError; use oneclient_core::auth::{AuthErrorGuidance, MicrosoftLoginSession}; use oneclient_core::notification::MicrosoftLoginStatus; use crate::components::{Button, Icon, IconType, OverlayPopup}; use crate::hooks::{ CancelMicrosoftLoginKeys, login_code_already_handled, reset_login_code_dedup, use_begin_microsoft_login, use_cancel_microsoft_login, use_finish_microsoft_login, use_microsoft_login_status, }; use crate::platform; use crate::theme::colors; use crate::ui::border_all_color; #[derive(Clone)] pub struct MicrosoftLogin { begin: UseMutation, cancel: UseMutation, pending_login: State>, cancelled: State, status: Option, locked: bool, pub pending: bool, pub error: Option, error_guidance: Option, } impl MicrosoftLogin { pub fn start(&self) { let mut cancelled = self.cancelled; cancelled.set(false); self.begin.mutate(()); } fn cancel(&self) { if self.locked { return; } if let Some(session) = self.pending_login.peek().clone() { self.cancel.mutate(CancelMicrosoftLoginKeys { state_token: session.dedupe_key().to_string(), }); } reset_login_code_dedup(); let mut cancelled = self.cancelled; let mut pending_login = self.pending_login; cancelled.set(true); pending_login.set(None); } pub fn popup(&self) -> Option { let handle = self.clone(); self.pending_login.read().clone().map(move |login| { microsoft_dialog( login, handle.clone(), handle.status.clone(), handle.error.clone(), handle.error_guidance.clone(), ) }) } } pub fn use_microsoft_login() -> MicrosoftLogin { let begin = use_begin_microsoft_login(); let finish = use_finish_microsoft_login(); let cancel = use_cancel_microsoft_login(); let mut pending_login = use_state(|| None::); let cancelled = use_state(|| false); let status = use_microsoft_login_status(); use_side_effect(move || { let session = match &*begin.read().state() { MutationStateData::Settled { res: Ok(session), .. } => Some(session.clone()), _ => None, }; let Some(session) = session else { return }; if login_code_already_handled(session.dedupe_key()) { return; } // open the browser immediately platform::open_url(session.auth_url()); finish.mutate(session.clone()); pending_login.set(Some(session)); }); use_side_effect(move || { if matches!( &*finish.read().state(), MutationStateData::Settled { res: Ok(_), .. } ) && pending_login.peek().is_some() { pending_login.set(None); } }); // is true once the flow has some status updates (just to ensure the user has seen the popup) and the flow is still in progress let locked = status.as_ref().is_some_and(|s| s.current > 0); let in_flight = begin.read().state().is_loading() || finish.read().state().is_loading(); let pending = in_flight && !*cancelled.read(); let error_info = mutation_err_info(&finish).or_else(|| mutation_err_info(&begin)); let (error, error_guidance) = match error_info { Some(info) => (Some(info.message), info.guidance), None => (None, None), }; MicrosoftLogin { begin, cancel, pending_login, cancelled, status, locked, pending, error, error_guidance, } } struct AuthErrorInfo { message: String, guidance: Option, } fn mutation_err_info(mutation: &UseMutation) -> Option where M: MutationCapability, { let read = mutation.read(); match &*read.state() { MutationStateData::Settled { res: Err(err), .. } | MutationStateData::Loading { res: Some(Err(err)), } => Some(AuthErrorInfo { message: err.to_string(), guidance: err.auth_guidance(), }), _ => None, } } fn microsoft_dialog( login: MicrosoftLoginSession, handle: MicrosoftLogin, status: Option, error: Option, guidance: Option, ) -> impl IntoElement { let close = handle.clone(); login_dialog( login.auth_url().to_string(), login.user_code().to_string(), login.verification_uri().to_string(), handle.locked, status, error, guidance, move || close.cancel(), ) } #[expect(clippy::too_many_arguments)] pub(crate) fn login_dialog( auth_url: String, user_code: String, verification_uri: String, locked: bool, status: Option, error: Option, guidance: Option, on_close: impl FnMut() + Clone + 'static, ) -> impl IntoElement { let mut close_scrim = on_close.clone(); let mut close_cancel = on_close; OverlayPopup::new() .on_close(move |()| close_scrim()) .child( rect() .width(Size::window_percent(100.)) .height(Size::window_percent(100.)) .center() .child( rect() .vertical() .width(Size::px(420.)) .max_width(Size::window_percent(90.)) .max_height(Size::window_percent(90.)) .cross_align(Alignment::Center) .spacing(18.) .padding(Gaps::new_all(28.)) .corner_radius(CornerRadius::new_all(16.)) .background(colors::page_elevated()) .border(border_all_color(1., colors::component_border())) .child( label() .text("Sign in to Microsoft") .font_size(18.) .font_weight(FontWeight::SEMI_BOLD) .color(colors::fg_primary()), ) .child( ScrollView::new() .width(Size::fill()) .height(Size::auto()) .max_height(Size::window_percent(70.)) .child( rect() .vertical() .width(Size::fill()) .cross_align(Alignment::Center) .spacing(18.) .child(browser_dialog_body(auth_url)) .child(dialog_divider()) .child(device_code_dialog_body( user_code, verification_uri, )) .child(status_row(status, error)) .maybe_child(guidance.map(guidance_block)), ), ) .maybe_child((!locked).then(|| { Button::new() .ghost() .on_press(move |_| close_cancel()) .text("Cancel") .into_element() })), ), ) .into_element() } fn dialog_divider() -> impl IntoElement { rect() .horizontal() .width(Size::fill()) .cross_align(Alignment::Center) .content(Content::Flex) .spacing(10.) .child( rect() .width(Size::flex(1.0)) .height(Size::px(1.)) .background(colors::component_border()), ) .child( label() .text("or") .font_size(11.) .color(colors::fg_secondary()), ) .child( rect() .width(Size::flex(1.0)) .height(Size::px(1.)) .background(colors::component_border()), ) .into_element() } fn status_row(status: Option, error: Option) -> impl IntoElement { if let Some(error) = error { return rect() .horizontal() .cross_align(Alignment::Center) .spacing(6.) .child( Icon::new(IconType::AlertTriangle) .size(13.) .color(colors::danger()), ) .child(label().text(error).font_size(12.).color(colors::danger())) .into_element(); } let text = match &status { Some(status) if status.total > 0 && status.current > 0 => { format!("{} ({}/{})", status.label, status.current, status.total) } _ => "Waiting for you to finish signing in...".to_string(), }; rect() .horizontal() .cross_align(Alignment::Center) .spacing(6.) .child( Icon::new(IconType::Loading02) .size(13.) .color(colors::brand()), ) .child( label() .text(text) .font_size(12.) .color(colors::fg_secondary()), ) .into_element() } fn guidance_block(guidance: AuthErrorGuidance) -> impl IntoElement { let mut steps = rect() .vertical() .width(Size::fill()) .spacing(6.) .child( label() .text("What you can do:") .font_size(12.) .font_weight(FontWeight::SEMI_BOLD) .color(colors::fg_primary()), ); for (index, step) in guidance.steps_to_fix.into_iter().enumerate() { steps = steps.child( rect() .horizontal() .width(Size::fill()) .spacing(6.) .child( label() .text(format!("{}.", index + 1)) .font_size(12.) .color(colors::fg_secondary()), ) .child( label() .text(step) .font_size(12.) .color(colors::fg_secondary()), ), ); } rect() .vertical() .width(Size::fill()) .spacing(10.) .padding(Gaps::new_all(14.)) .corner_radius(CornerRadius::new_all(12.)) .background(colors::component_bg()) .border(border_all_color(1., colors::component_border())) .child( label() .text(guidance.what_happened) .font_size(12.) .color(colors::fg_primary()), ) .child(steps) .into_element() } fn browser_dialog_body(auth_url: String) -> impl IntoElement { rect() .vertical() .width(Size::fill()) .cross_align(Alignment::Center) .spacing(12.) .child( label() .text("We opened the Microsoft sign-in page in your browser. Finish there and you'll be brought back automatically.") .font_size(13.) .color(colors::fg_secondary()), ) .child( Button::new() .primary() .on_press(move |_| platform::open_url(&auth_url)) .child(Icon::new(IconType::LinkExternal01).size(16.)) .text("Open in browser again"), ) .into_element() } fn device_code_dialog_body(code: String, verification_uri: String) -> impl IntoElement { let copy_code = code.clone(); rect() .vertical() .width(Size::fill()) .cross_align(Alignment::Center) .spacing(12.) .child( label() .text("Or enter this code at the Microsoft sign-in page:") .font_size(13.) .color(colors::fg_secondary()), ) .child( rect() .width(Size::fill()) .center() .padding(Gaps::new_symmetric(20., 16.)) .corner_radius(CornerRadius::new_all(14.)) .background(colors::component_bg()) .border(border_all_color(1., colors::brand())) .child( label() .text(code) .font_size(48.) .font_weight(FontWeight::BOLD) .color(colors::fg_primary()), ), ) .child( rect() .horizontal() .width(Size::fill()) .main_align(Alignment::Center) .spacing(8.) .child( Button::new() .secondary() .on_press(move |_| { if let Err(err) = Clipboard::set(copy_code.clone()) { tracing::warn!("clipboard copy failed: {err:?}"); } }) .child(Icon::new(IconType::Copy01).size(16.)) .text("Copy code"), ) .child( Button::new() .primary() .on_press(move |_| platform::open_url(&verification_uri)) .child(Icon::new(IconType::LinkExternal01).size(16.)) .text("Open in browser"), ), ) .into_element() }