diff --git a/crates/ely_app/src/shell/internal_pages.rs b/crates/ely_app/src/shell/internal_pages.rs index b2af90a..6791f3a 100644 --- a/crates/ely_app/src/shell/internal_pages.rs +++ b/crates/ely_app/src/shell/internal_pages.rs @@ -15,6 +15,7 @@ mod search; mod settings; mod shortcuts; mod sidebar_tabs; +mod site_permissions_settings; mod site_settings; mod spaces; mod sync; @@ -60,6 +61,9 @@ impl ElyShell { "ely://settings/privacy-security" => self.render_privacy_security_page(snapshot, cx), "ely://settings/downloads" => self.render_download_settings_page(snapshot, cx), "ely://settings/spaces" => self.render_spaces_page(snapshot, cx), + "ely://settings/site-permissions" => { + self.render_site_permissions_settings_page(snapshot, cx) + } "ely://settings/shortcuts" => self.render_shortcuts_page(snapshot), "ely://settings/plugins" => self.render_plugins_page(snapshot, cx), "ely://settings/profiles" => self.render_profiles_page(snapshot, cx), diff --git a/crates/ely_app/src/shell/internal_pages/settings.rs b/crates/ely_app/src/shell/internal_pages/settings.rs index 8bfc213..a9bccc5 100644 --- a/crates/ely_app/src/shell/internal_pages/settings.rs +++ b/crates/ely_app/src/shell/internal_pages/settings.rs @@ -54,6 +54,12 @@ const SETTINGS_ROUTES: &[SettingsRoute] = &[ detail: "Profile download location and save behavior.", route: "ely://settings/downloads", }, + SettingsRoute { + icon: IconName::Globe, + title: "Site Permissions", + detail: "Profile-scoped site permissions and local audit state.", + route: "ely://settings/site-permissions", + }, SettingsRoute { icon: IconName::CircleUser, title: "Profiles", diff --git a/crates/ely_app/src/shell/internal_pages/site_permissions_settings.rs b/crates/ely_app/src/shell/internal_pages/site_permissions_settings.rs new file mode 100644 index 0000000..09fd167 --- /dev/null +++ b/crates/ely_app/src/shell/internal_pages/site_permissions_settings.rs @@ -0,0 +1,238 @@ +use ely_browser_core::BrowserSnapshot; +use ely_design_system::colors; +use ely_domain::{SiteOrigin, SitePermissionDecision, SitePermissionEntry}; +use gpui::{AnyElement, Context, IntoElement, ParentElement, Styled, div, px, rgb}; +use gpui_component::{ + IconName, Sizable, StyledExt, + button::{Button, ButtonVariants}, + scroll::ScrollableElement, +}; + +use super::{ElyShell, render_canvas_surface}; + +impl ElyShell { + pub(super) fn render_site_permissions_settings_page( + &mut self, + snapshot: &BrowserSnapshot, + cx: &mut Context, + ) -> AnyElement { + render_canvas_surface( + div() + .size_full() + .p_8() + .flex() + .flex_col() + .gap_5() + .child(render_site_permissions_header(snapshot)) + .child(render_site_permissions_summary(snapshot)) + .child(render_site_permissions_list(snapshot, cx)), + ) + } +} + +fn render_site_permissions_header(snapshot: &BrowserSnapshot) -> AnyElement { + div() + .flex() + .items_end() + .justify_between() + .gap_4() + .child( + div() + .min_w_0() + .flex() + .flex_col() + .gap_2() + .child( + div() + .text_size(px(26.0)) + .text_color(rgb(colors::INK)) + .child("Site Permissions"), + ) + .child( + div() + .text_sm() + .truncate() + .text_color(rgb(colors::MUTED)) + .child(format!("Profile: {}", snapshot.active_profile_name)), + ), + ) + .child( + div() + .flex() + .items_center() + .gap_2() + .text_xs() + .font_semibold() + .text_color(rgb(colors::MUTED)) + .child(IconName::Globe) + .child("Profile scoped"), + ) + .into_any_element() +} + +fn render_site_permissions_summary(snapshot: &BrowserSnapshot) -> AnyElement { + div() + .border_t_1() + .border_b_1() + .border_color(rgb(colors::HAIRLINE)) + .py_3() + .flex() + .items_center() + .justify_between() + .gap_4() + .children([ + site_permission_metric("Configured", snapshot.site_permissions.len()), + site_permission_metric("Allowed", allowed_count(snapshot)), + site_permission_metric("Denied", denied_count(snapshot)), + site_permission_metric("Audit Events", snapshot.site_permission_audit_events.len()), + ]) + .into_any_element() +} + +fn site_permission_metric(label: &'static str, value: usize) -> AnyElement { + div() + .min_w_0() + .flex() + .flex_col() + .gap_1() + .child(div().text_xs().text_color(rgb(colors::MUTED)).child(label)) + .child( + div().text_sm().font_semibold().text_color(rgb(colors::INK)).child(value.to_string()), + ) + .into_any_element() +} + +fn render_site_permissions_list( + snapshot: &BrowserSnapshot, + cx: &mut Context, +) -> AnyElement { + if snapshot.site_permissions.is_empty() { + return div() + .flex_1() + .border_t_1() + .border_color(rgb(colors::HAIRLINE)) + .pt_5() + .text_sm() + .text_color(rgb(colors::MUTED)) + .child("No site permissions are configured for this Profile.") + .into_any_element(); + } + + div() + .flex_1() + .min_h_0() + .flex() + .flex_col() + .overflow_y_scrollbar() + .border_t_1() + .border_color(rgb(colors::HAIRLINE)) + .children( + snapshot + .site_permissions + .iter() + .enumerate() + .map(|(index, entry)| render_site_permission_entry(index, entry, cx)), + ) + .into_any_element() +} + +fn render_site_permission_entry( + index: usize, + entry: &SitePermissionEntry, + cx: &mut Context, +) -> AnyElement { + let origin = entry.origin().clone(); + let route = site_settings_route(&origin); + let decision = entry.decision(); + + div() + .py_3() + .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(permission_decision_color(decision))) + .child(permission_decision_icon(decision)), + ) + .child( + div() + .min_w_0() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_sm() + .font_semibold() + .truncate() + .text_color(rgb(colors::INK)) + .child(entry.origin().as_str().to_string()), + ) + .child(div().text_xs().truncate().text_color(rgb(colors::MUTED)).child( + format!("{} - {}", entry.feature().label(), entry.decision().label()), + )), + ), + ) + .child( + Button::new(("open-site-permission-settings", index)) + .ghost() + .xsmall() + .label("Open") + .tooltip("Open Site Settings") + .on_click(cx.listener(move |shell, _, window, cx| { + shell.open_internal_tab(&route, window, cx); + })), + ) + .into_any_element() +} + +fn allowed_count(snapshot: &BrowserSnapshot) -> usize { + snapshot + .site_permissions + .iter() + .filter(|entry| { + matches!( + entry.decision(), + SitePermissionDecision::AllowOnce | SitePermissionDecision::AllowAlways + ) + }) + .count() +} + +fn denied_count(snapshot: &BrowserSnapshot) -> usize { + snapshot + .site_permissions + .iter() + .filter(|entry| entry.decision() == SitePermissionDecision::DenyAlways) + .count() +} + +fn site_settings_route(origin: &SiteOrigin) -> String { + format!("ely://site/{}", origin.as_str()) +} + +fn permission_decision_color(decision: SitePermissionDecision) -> u32 { + match decision { + SitePermissionDecision::AllowOnce | SitePermissionDecision::AllowAlways => colors::SUCCESS, + SitePermissionDecision::DenyAlways => colors::ERROR, + } +} + +fn permission_decision_icon(decision: SitePermissionDecision) -> IconName { + match decision { + SitePermissionDecision::AllowOnce | SitePermissionDecision::AllowAlways => { + IconName::CircleCheck + } + SitePermissionDecision::DenyAlways => IconName::CircleX, + } +} diff --git a/crates/ely_browser_core/src/navigation.rs b/crates/ely_browser_core/src/navigation.rs index b56cf06..55ffa33 100644 --- a/crates/ely_browser_core/src/navigation.rs +++ b/crates/ely_browser_core/src/navigation.rs @@ -31,6 +31,7 @@ fn internal_page_title(url: &str) -> Option<&'static str> { "ely://settings/privacy-security" => Some("Privacy & Security Settings"), "ely://settings/downloads" => Some("Downloads Settings"), "ely://settings/spaces" => Some("Space Settings"), + "ely://settings/site-permissions" => Some("Site Permissions Settings"), "ely://settings/shortcuts" => Some("Shortcut Settings"), "ely://settings/plugins" => Some("Plugin Settings"), "ely://settings/profiles" => Some("Profile Settings"), @@ -172,6 +173,11 @@ fn settings_page_route(query: &str) -> Option<&'static str> { Some("ely://settings/downloads") } "space" | "spaces" | "space settings" | "spaces settings" => Some("ely://settings/spaces"), + "site permission" + | "site permissions" + | "site permissions settings" + | "permissions" + | "permission settings" => Some("ely://settings/site-permissions"), "shortcut" | "shortcuts" | "keyboard" | "keyboard shortcuts" => { Some("ely://settings/shortcuts") } diff --git a/crates/ely_browser_core/tests/settings_routes.rs b/crates/ely_browser_core/tests/settings_routes.rs index e0cbf02..78e6d85 100644 --- a/crates/ely_browser_core/tests/settings_routes.rs +++ b/crates/ely_browser_core/tests/settings_routes.rs @@ -149,3 +149,24 @@ fn settings_scoped_search_opens_downloads_page() -> Result<(), Box> { assert_eq!(core.snapshot()?.command_query, ""); Ok(()) } + +#[test] +fn settings_scoped_search_opens_site_permissions_page() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + + core.set_command_query("@settings site permissions"); + let intent = core.submit_command()?; + let active_tab = core.active_tab()?; + + assert_eq!( + intent, + Some(CommandIntent::ScopedSearch { + scope: CommandScope::Settings, + query: "site permissions".to_string(), + }) + ); + assert_eq!(active_tab.title(), "Site Permissions Settings"); + assert_eq!(active_tab.url().as_str(), "ely://settings/site-permissions"); + assert_eq!(core.snapshot()?.command_query, ""); + Ok(()) +}