diff --git a/crates/ely_app/src/shell/internal_pages.rs b/crates/ely_app/src/shell/internal_pages.rs index 8b73f87..3db9a6e 100644 --- a/crates/ely_app/src/shell/internal_pages.rs +++ b/crates/ely_app/src/shell/internal_pages.rs @@ -1,6 +1,7 @@ mod about; mod advanced; mod appearance; +mod auth_callback; mod bookmarks; mod crash; mod download_actions; @@ -70,6 +71,7 @@ impl ElyShell { "ely://task-manager" => self.render_task_manager_page(snapshot), "ely://site-compatibility" => self.render_site_compatibility_page(snapshot), "ely://plugins" => self.render_plugin_catalog_page(snapshot, cx), + url if url.starts_with("ely://auth/callback") => self.render_auth_callback_page(url), url if url.starts_with("ely://crash/") => self.render_crash_route(snapshot, url, cx), url if url.starts_with("ely://plugin/") => { self.render_plugin_detail_page(snapshot, url, cx) diff --git a/crates/ely_app/src/shell/internal_pages/auth_callback.rs b/crates/ely_app/src/shell/internal_pages/auth_callback.rs new file mode 100644 index 0000000..30466fa --- /dev/null +++ b/crates/ely_app/src/shell/internal_pages/auth_callback.rs @@ -0,0 +1,229 @@ +use ely_design_system::colors; +use gpui::{AnyElement, IntoElement, ParentElement, Styled, div, px, rgb}; +use gpui_component::{IconName, StyledExt}; + +use crate::brand::{AUTH_CALLBACK_URL, SYNC_SERVICE_NAME}; + +use super::{ElyShell, render_canvas_surface}; + +#[derive(Clone, Debug, Eq, PartialEq)] +enum AuthCallbackState { + CodeReceived, + ProviderError(String), + MissingCode, +} + +impl ElyShell { + pub(super) fn render_auth_callback_page(&mut self, url: &str) -> AnyElement { + let state = auth_callback_state(url); + render_canvas_surface( + div() + .size_full() + .p_8() + .flex() + .flex_col() + .gap_5() + .child(render_auth_callback_header(&state)) + .child(render_auth_callback_rows(&state)), + ) + } +} + +fn render_auth_callback_header(state: &AuthCallbackState) -> AnyElement { + div() + .flex() + .items_end() + .justify_between() + .gap_4() + .child( + div() + .flex() + .flex_col() + .gap_2() + .child( + div().text_size(px(26.0)).text_color(rgb(colors::INK)).child("Auth Callback"), + ) + .child(div().text_sm().text_color(rgb(colors::MUTED)).child(SYNC_SERVICE_NAME)), + ) + .child( + div() + .flex() + .items_center() + .gap_2() + .text_xs() + .font_semibold() + .text_color(rgb(auth_callback_color(state))) + .child(auth_callback_icon(state)) + .child(auth_callback_badge(state)), + ) + .into_any_element() +} + +fn render_auth_callback_rows(state: &AuthCallbackState) -> AnyElement { + div() + .flex_1() + .min_h_0() + .flex() + .flex_col() + .border_t_1() + .border_color(rgb(colors::HAIRLINE)) + .child(auth_callback_row( + IconName::Globe, + "Service", + SYNC_SERVICE_NAME, + "Cloud account authority", + )) + .child(auth_callback_row( + IconName::ExternalLink, + "Callback", + AUTH_CALLBACK_URL, + "Desktop protocol", + )) + .child(auth_callback_row( + auth_callback_icon(state), + "Result", + auth_callback_result(state), + auth_callback_detail(state), + )) + .into_any_element() +} + +fn auth_callback_row( + icon: IconName, + label: &'static str, + value: impl Into, + detail: impl Into, +) -> AnyElement { + div() + .py_4() + .border_b_1() + .border_color(rgb(colors::HAIRLINE)) + .flex() + .items_center() + .justify_between() + .gap_4() + .child( + div() + .min_w_0() + .flex() + .items_center() + .gap_3() + .child(div().text_color(rgb(colors::MUTED)).child(icon)) + .child( + div() + .min_w_0() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_sm() + .font_semibold() + .text_color(rgb(colors::INK)) + .child(label), + ) + .child( + div() + .text_xs() + .truncate() + .text_color(rgb(colors::MUTED)) + .child(detail.into()), + ), + ), + ) + .child(div().text_xs().font_semibold().text_color(rgb(colors::MUTED)).child(value.into())) + .into_any_element() +} + +fn auth_callback_state(url: &str) -> AuthCallbackState { + if let Some(error) = query_value(url, "error") + && !error.is_empty() + { + return AuthCallbackState::ProviderError(error); + } + + if query_value(url, "code").is_some_and(|code| !code.is_empty()) { + return AuthCallbackState::CodeReceived; + } + + AuthCallbackState::MissingCode +} + +fn query_value(url: &str, name: &str) -> Option { + let query = url.strip_prefix(AUTH_CALLBACK_URL)?.strip_prefix('?')?; + query.split('&').find_map(|pair| { + let (key, value) = pair.split_once('=')?; + (key == name).then(|| value.to_string()) + }) +} + +fn auth_callback_icon(state: &AuthCallbackState) -> IconName { + match state { + AuthCallbackState::CodeReceived => IconName::CircleCheck, + AuthCallbackState::ProviderError(_) => IconName::TriangleAlert, + AuthCallbackState::MissingCode => IconName::Info, + } +} + +fn auth_callback_color(state: &AuthCallbackState) -> u32 { + match state { + AuthCallbackState::CodeReceived => colors::SUCCESS, + AuthCallbackState::ProviderError(_) => colors::ERROR, + AuthCallbackState::MissingCode => colors::MUTED, + } +} + +fn auth_callback_badge(state: &AuthCallbackState) -> &'static str { + match state { + AuthCallbackState::CodeReceived => "Code received", + AuthCallbackState::ProviderError(_) => "Provider error", + AuthCallbackState::MissingCode => "Callback pending", + } +} + +fn auth_callback_result(state: &AuthCallbackState) -> String { + match state { + AuthCallbackState::CodeReceived => "Authorization code".to_string(), + AuthCallbackState::ProviderError(error) => error.to_string(), + AuthCallbackState::MissingCode => "Awaiting code".to_string(), + } +} + +fn auth_callback_detail(state: &AuthCallbackState) -> &'static str { + match state { + AuthCallbackState::CodeReceived => "Ready for session exchange", + AuthCallbackState::ProviderError(_) => "Provider returned an OAuth error", + AuthCallbackState::MissingCode => "Callback code missing", + } +} + +#[cfg(test)] +mod tests { + use super::{AuthCallbackState, auth_callback_state, query_value}; + + #[test] + fn auth_callback_state_reads_code() { + assert_eq!( + auth_callback_state("ely://auth/callback?code=abc&state=nonce"), + AuthCallbackState::CodeReceived + ); + } + + #[test] + fn auth_callback_state_reads_provider_error() { + assert_eq!( + auth_callback_state("ely://auth/callback?error=access_denied"), + AuthCallbackState::ProviderError("access_denied".to_string()) + ); + } + + #[test] + fn auth_callback_state_handles_missing_code() { + assert_eq!(auth_callback_state("ely://auth/callback"), AuthCallbackState::MissingCode); + } + + #[test] + fn query_value_filters_other_routes() { + assert_eq!(query_value("ely://history?code=abc", "code"), None); + } +} diff --git a/crates/ely_browser_core/src/navigation.rs b/crates/ely_browser_core/src/navigation.rs index ad894b4..9df015f 100644 --- a/crates/ely_browser_core/src/navigation.rs +++ b/crates/ely_browser_core/src/navigation.rs @@ -25,6 +25,7 @@ fn internal_page_title(url: &str) -> Option<&'static str> { "ely://task-manager" => Some("Task Manager"), "ely://site-compatibility" => Some("Site Compatibility"), "ely://plugins" => Some("Plugin Marketplace"), + url if auth_callback_route(url) => Some("Auth Callback"), url if crash_route_tab_id(url).is_some() => Some("Tab Recovery"), url if plugin_detail_route_id(url).is_some() => Some("Plugin Details"), url if SiteOrigin::from_site_route(url).ok().flatten().is_some() => Some("Site Settings"), @@ -486,3 +487,7 @@ pub(crate) fn crash_route_tab_id(url: &str) -> Option<&str> { let tab_id = url.strip_prefix("ely://crash/")?; (!tab_id.is_empty() && !tab_id.contains('/')).then_some(tab_id) } + +fn auth_callback_route(url: &str) -> bool { + matches!(url, "ely://auth/callback") || url.starts_with("ely://auth/callback?") +} diff --git a/crates/ely_browser_core/tests/tabs.rs b/crates/ely_browser_core/tests/tabs.rs index 164454b..4c541ba 100644 --- a/crates/ely_browser_core/tests/tabs.rs +++ b/crates/ely_browser_core/tests/tabs.rs @@ -72,6 +72,19 @@ fn opened_tabs_record_active_tab_as_parent() -> Result<(), Box> { Ok(()) } +#[test] +fn auth_callback_tabs_receive_internal_title() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + + let tab_id = core.open_tab(UrlText::parse("ely://auth/callback?code=abc")?); + let snapshot = core.snapshot()?; + let tab = + snapshot.tabs.iter().find(|tab| tab.id() == &tab_id).ok_or(CoreError::MissingActiveTab)?; + + assert_eq!(tab.title(), "Auth Callback"); + Ok(()) +} + #[test] fn replacement_tabs_have_no_parent_tab() -> Result<(), Box> { let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;