From 1ffb15ea1ad7757ce46b4ec577ad414023db3c80 Mon Sep 17 00:00:00 2001 From: Suyog Tandel Date: Wed, 4 Feb 2026 23:25:14 +0530 Subject: [PATCH] feat(ui): implement passkeys handling and fetching with gpui --- .gitignore | 1 + Cargo.lock | 69 ++-- flake.lock | 18 +- shell.nix | 15 +- src/ui/rootview.rs | 47 ++- src/ui/views/passkeys.rs | 767 ++++++++++++++++++++++++++++----------- 6 files changed, 645 insertions(+), 272 deletions(-) diff --git a/.gitignore b/.gitignore index 03b9e93..8e816b2 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ result # misc docs-gpui-components src-svelte +gpui-component \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index d8650ca..272f0d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -92,9 +92,9 @@ checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" [[package]] name = "arc-swap" -version = "1.8.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d03449bb8ca2cc2ef70869af31463d1ae5ccc8fa3e334b307203fbf815207e" +checksum = "9ded5f9a03ac8f24d1b8a25101ee812cd32cdc8c50a4c50237de2c4915850e73" dependencies = [ "rustversion", ] @@ -729,9 +729,9 @@ checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] name = "bytemuck" -version = "1.24.0" +version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" dependencies = [ "bytemuck_derive", ] @@ -761,9 +761,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "calloop" @@ -820,9 +820,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.54" +version = "1.2.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" dependencies = [ "find-msvc-tools", "jobserver", @@ -1837,15 +1837,15 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "flate2" -version = "1.1.8" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", @@ -2820,13 +2820,12 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.19" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "bytes", "futures-channel", - "futures-core", "futures-util", "http", "http-body", @@ -4866,9 +4865,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -4878,9 +4877,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -4889,9 +4888,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" [[package]] name = "resvg" @@ -5213,9 +5212,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54e910108742c57a770f492731f99be216a52fadd361b06c8fb59d74ccc267d2" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ "dyn-clone", "indexmap", @@ -5227,9 +5226,9 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4908ad288c5035a8eb12cfdf0d49270def0a268ee162b75eeee0f85d155a7c45" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" dependencies = [ "proc-macro2", "quote", @@ -5539,9 +5538,9 @@ dependencies = [ [[package]] name = "slab" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "slotmap" @@ -6398,9 +6397,9 @@ dependencies = [ [[package]] name = "tree-sitter-language" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ae62f7eae5eb549c71b76658648b72cc6111f2d87d24a1e31fa907f4943e3ce" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" [[package]] name = "triomphe" @@ -7922,18 +7921,18 @@ checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" [[package]] name = "zerocopy" -version = "0.8.35" +version = "0.8.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdea86ddd5568519879b8187e1cf04e24fce28f7fe046ceecbce472ff19a2572" +checksum = "57cf3aa6855b23711ee9852dfc97dfaa51c45feaba5b645d0c777414d494a961" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.35" +version = "0.8.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c15e1b46eff7c6c91195752e0eeed8ef040e391cdece7c25376957d5f15df22" +checksum = "8a616990af1a287837c4fe6596ad77ef57948f787e46ce28e166facc0cc1cb75" dependencies = [ "proc-macro2", "quote", @@ -8016,9 +8015,9 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.17" +version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439" +checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" [[package]] name = "zune-core" diff --git a/flake.lock b/flake.lock index e1d7514..a457d30 100644 --- a/flake.lock +++ b/flake.lock @@ -6,11 +6,11 @@ "rust-analyzer-src": "rust-analyzer-src" }, "locked": { - "lastModified": 1770102568, - "narHash": "sha256-VYwA9FmakKJ3zLfAd7bdj9xIB9PzfISLoYh6eZl+EuQ=", + "lastModified": 1770188896, + "narHash": "sha256-ZBpEh6aTvdoZvIM8sojnr43FPyegq/sjUkNWtF4kDO8=", "owner": "nix-community", "repo": "fenix", - "rev": "592daa37b5a3175c61541329b64d6c1972303bc1", + "rev": "6dbe5750c68a55e7cb3f8b62883c4dbfeecf14d5", "type": "github" }, "original": { @@ -38,11 +38,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1770019141, - "narHash": "sha256-VKS4ZLNx4PNrABoB0L8KUpc1fE7CLpQXQs985tGfaCU=", + "lastModified": 1770115704, + "narHash": "sha256-KHFT9UWOF2yRPlAnSXQJh6uVcgNcWlFqqiAZ7OVlHNc=", "owner": "nixos", "repo": "nixpkgs", - "rev": "cb369ef2efd432b3cdf8622b0ffc0a97a02f3137", + "rev": "e6eae2ee2110f3d31110d5c222cd395303343b08", "type": "github" }, "original": { @@ -93,11 +93,11 @@ "rust-analyzer-src": { "flake": false, "locked": { - "lastModified": 1770026591, - "narHash": "sha256-VZlloygYDmozJwbZZkCSNpiPhNdOW/AA0b6LmNBZ3xU=", + "lastModified": 1770092556, + "narHash": "sha256-DcUKN1nzz7LhyZGJMhSBhY5zrl8/GjJJ9SNqqTd77OQ=", "owner": "rust-lang", "repo": "rust-analyzer", - "rev": "74eca73f3b0a41b80228b8e499c7547cc8b2effa", + "rev": "a84d92ff213e30fb00d7b812e07c4f67e99dcd29", "type": "github" }, "original": { diff --git a/shell.nix b/shell.nix index ec072a7..ccc11f7 100644 --- a/shell.nix +++ b/shell.nix @@ -1,4 +1,6 @@ -{ pkgs ? import { } }: +{ + pkgs ? import { }, +}: let libraries = with pkgs; [ @@ -31,10 +33,12 @@ let # Development tools rustc + clippy + rustfmt + rust-analyzer + rustPlatform.rustLibSrc mold cargo - deno - nodejs_22 # GPUI libxkbcommon @@ -48,9 +52,10 @@ in pkgs.mkShell { buildInputs = packages; + RUST_SRC_PATH = "${pkgs.rustPlatform.rustLibSrc}"; + shellHook = '' export LD_LIBRARY_PATH=${pkgs.lib.makeLibraryPath libraries}:$LD_LIBRARY_PATH - # export GTK_PATH=${pkgs.libcanberra-gtk3}/lib/gtk-3.0:${pkgs.packagekit}/lib/gtk-3.0:$GTK_PATH export XDG_DATA_DIRS=$GSETTINGS_SCHEMAS_PATH:$XDG_DATA_DIRS export RUSTFLAGS="-C link-arg=-fuse-ld=mold" @@ -59,6 +64,6 @@ pkgs.mkShell { # export WEBKIT_DISABLE_COMPOSITING_MODE=1 echo "Nix development environment loaded!" - echo "Available tools: rustc, cargo, deno, node, tauri" + echo "Available tools: rustc, cargo" ''; } diff --git a/src/ui/rootview.rs b/src/ui/rootview.rs index b6e4447..d5c861e 100644 --- a/src/ui/rootview.rs +++ b/src/ui/rootview.rs @@ -6,13 +6,14 @@ use crate::ui::{ colors, views::{ about::AboutView, config::ConfigView, home::HomeView, logs::LogsView, - passkeys::PasskeysView, security::SecurityView, + passkeys::PasskeysEvent, passkeys::PasskeysView, security::SecurityView, }, }; + use gpui::prelude::*; use gpui::*; use gpui_component::{ - ActiveTheme, Icon, IconName, TitleBar, + ActiveTheme, Icon, IconName, TitleBar, WindowExt, button::{Button, ButtonVariants}, h_flex, scroll::ScrollableElement, @@ -27,6 +28,7 @@ pub struct ApplicationRoot { sidebar_width: Pixels, refresh_button: Entity, config_view: Option>, + passkeys_view: Option>, } impl ApplicationRoot { @@ -50,6 +52,7 @@ impl ApplicationRoot { sidebar_width: px(255.), refresh_button, config_view: None, + passkeys_view: None, }; this.refresh_device_status(cx); this @@ -81,7 +84,14 @@ impl ApplicationRoot { if let Some(config_view) = &self.config_view { config_view.update(cx, |view, cx| { - view.update_device_status(Some(status), cx); + view.update_device_status(Some(status.clone()), cx); + }); + } + + if let Some(passkeys_view) = &self.passkeys_view { + let fido = self.state.fido_info.clone(); + passkeys_view.update(cx, |view, cx| { + view.update_device_status(Some(status.clone()), fido, cx); }); } } @@ -164,7 +174,36 @@ impl Render for ApplicationRoot { HomeView::build(&self.state, cx.theme()).into_any_element() } ActiveView::Passkeys => { - PasskeysView::build(cx.theme()).into_any_element() + let view = self.passkeys_view.get_or_insert_with(|| { + let view = cx.new(|cx| { + PasskeysView::new( + window, + cx, + self.state.device_status.clone(), + self.state.fido_info.clone(), + ) + }); + cx.subscribe_in( + &view, + window, + |_, _, event: &PasskeysEvent, window, cx| { + match event { + PasskeysEvent::Notification(msg) => { + window.push_notification( + msg.to_string(), + cx, + ); + } + PasskeysEvent::CloseDialog => { + window.close_dialog(cx); + } + } + }, + ) + .detach(); + view + }); + view.clone().into_any_element() } ActiveView::Configuration => { let view = self.config_view.get_or_insert_with(|| { diff --git a/src/ui/views/passkeys.rs b/src/ui/views/passkeys.rs index ec34489..065540b 100644 --- a/src/ui/views/passkeys.rs +++ b/src/ui/views/passkeys.rs @@ -1,97 +1,195 @@ +use crate::device::io; +use crate::device::types::{FidoDeviceInfo, FullDeviceStatus, StoredCredential}; use crate::ui::components::{card::Card, page_view::PageView}; use gpui::*; -use gpui_component::StyledExt; -use gpui_component::button::ButtonVariants; +use gpui_component::button::{Button, ButtonVariants}; use gpui_component::{ - Disableable, Icon, Sizable, Theme, badge::Badge, button::Button, h_flex, v_flex, + ActiveTheme, Disableable, Icon, Sizable, StyledExt, Theme, WindowExt, badge::Badge, h_flex, + input::Input, input::InputState, v_flex, }; -#[derive(Clone)] -struct StoredCredential { - credential_id: String, - user_id: String, - user_name: String, - user_display_name: String, - rp_id: String, - rp_name: Option, -} - -#[derive(Clone)] -struct FidoOptions { - client_pin: bool, -} - -#[derive(Clone)] -struct FidoInfo { - versions: Vec, - options: FidoOptions, - min_pin_length: u8, - resident_keys: bool, -} - -#[derive(Clone)] -struct DeviceState { - connected: bool, - has_fido: bool, - unlocked: bool, - fido_info: Option, +pub struct PasskeysView { + device_status: Option, + fido_info: Option, credentials: Vec, + unlocked: bool, + cached_pin: Option, + loading: bool, + + _task: Option>, + active_modal: Option, } -pub struct PasskeysView; +pub enum ActiveModal { + Unlock(Entity), + Delete { + cred: StoredCredential, + pin: String, + }, + ChangePin, + MinPin, +} + +pub enum PasskeysEvent { + Notification(String), + CloseDialog, +} + +impl EventEmitter for PasskeysView {} impl PasskeysView { - pub fn build(theme: &Theme) -> impl IntoElement { - // Mock Data - let device = DeviceState { - connected: true, - has_fido: true, - unlocked: true, - fido_info: Some(FidoInfo { - versions: vec!["FIDO2_1".to_string()], - options: FidoOptions { client_pin: true }, - min_pin_length: 4, - resident_keys: true, - }), - credentials: vec![ - StoredCredential { - credential_id: "0000".to_string(), - user_id: "u123".to_string(), - user_name: "j.doe".to_string(), - user_display_name: "Work Key".to_string(), - rp_id: "example.com".to_string(), - rp_name: None, - }, - StoredCredential { - credential_id: "1111".to_string(), - user_id: "u456".to_string(), - user_name: "developer".to_string(), - user_display_name: "Cloud Console".to_string(), - rp_id: "cloud.example.com".to_string(), - rp_name: None, - }, - ], - }; - - PageView::build( - "Passkeys", - "Manage your security PIN and the FIDO credentials (passkeys) stored on your device.", - if !device.connected { - Self::render_no_device(theme).into_any_element() - } else if !device.has_fido { - Self::render_not_supported(theme).into_any_element() - } else { - v_flex() - .gap_6() - .child(Self::render_pin_management(&device, theme)) - .child(Self::render_stored_passkeys(&device, theme)) - .into_any_element() - }, - theme, - ) + pub fn new( + _window: &mut Window, + _cx: &mut Context, + device_status: Option, + fido_info: Option, + ) -> Self { + Self { + device_status, + fido_info, + credentials: Vec::new(), + unlocked: false, + cached_pin: None, + loading: false, + _task: None, + active_modal: None, + } } - fn render_no_device(theme: &Theme) -> impl IntoElement { + pub fn update_device_status( + &mut self, + status: Option, + fido_info: Option, + cx: &mut Context, + ) { + if self.device_status == status && self.fido_info == fido_info { + return; + } + self.device_status = status; + self.fido_info = fido_info; + cx.notify(); + } + + fn unlock_storage(&mut self, pin: String, cx: &mut Context) { + if self.loading { + return; + } + self.loading = true; + cx.notify(); + + let entity = cx.entity().downgrade(); + + cx.spawn(async move |_, cx| { + let result = io::get_credentials(pin.clone()); + + let _ = entity.update(cx, |this, cx| { + this.loading = false; + match result { + Ok(creds) => { + this.unlocked = true; + this.cached_pin = Some(pin); + this.credentials = creds; + cx.emit(PasskeysEvent::CloseDialog); + } + Err(e) => { + let msg = format!("Failed to unlock: {}", e); + cx.emit(PasskeysEvent::Notification(msg)); + } + } + cx.notify(); + }); + }) + .detach(); + } + + fn lock_storage(&mut self, cx: &mut Context) { + self.unlocked = false; + self.cached_pin = None; + self.credentials.clear(); + cx.notify(); + } + + fn execute_delete( + &mut self, + credential_id: String, + pin: String, + cx: &mut Context, + ) { + if self.loading { + return; + } + self.loading = true; + cx.notify(); + + let entity = cx.entity().downgrade(); + + self._task = Some(cx.spawn(async move |_, cx| { + let result = io::delete_credential(pin.clone(), credential_id); + + let _ = entity.update(cx, |this, cx| { + match result { + Ok(_) => { + // Refresh credentials + let _ = this.refresh_credentials(pin, cx); + cx.emit(PasskeysEvent::CloseDialog); + cx.emit(PasskeysEvent::Notification("Credential deleted".to_string())); + } + Err(e) => { + this.loading = false; + let msg = format!("Error deleting: {}", e); + cx.emit(PasskeysEvent::Notification(msg)); + cx.notify(); + } + } + }); + })); + } + + fn refresh_credentials(&mut self, pin: String, cx: &mut Context) -> Task<()> { + let entity = cx.entity().downgrade(); + cx.spawn(async move |_, cx| { + let result = io::get_credentials(pin); + let _ = entity.update(cx, |this, cx| { + this.loading = false; + if let Ok(creds) = result { + this.credentials = creds; + } + cx.notify(); + }); + }) + } + + fn open_unlock_dialog(&mut self, window: &mut Window, cx: &mut Context) { + let pin_input = cx.new(|cx| InputState::new(window, cx).placeholder("Enter FIDO PIN")); + self.active_modal = Some(ActiveModal::Unlock(pin_input)); + cx.notify(); + } + + fn open_delete_dialog( + &mut self, + cred: &StoredCredential, + pin: String, + _window: &mut Window, + cx: &mut Context, + ) { + self.active_modal = Some(ActiveModal::Delete { + cred: cred.clone(), + pin, + }); + cx.notify(); + } + + fn open_change_pin_dialog(&mut self, window: &mut Window, cx: &mut Context) { + // TODO: Implement Change PIN Dialog + // Placeholder for now + window.open_dialog(cx, |dialog, _, _| { + dialog + .title("Not Implemented") + .child("Change PIN dialog coming soon") + }); + } + + fn render_no_device(&self, theme: &Theme) -> impl IntoElement { div() .flex() .items_center() @@ -108,7 +206,7 @@ impl PasskeysView { .into_any_element() } - fn render_not_supported(theme: &Theme) -> impl IntoElement { + fn render_not_supported(&self, theme: &Theme) -> impl IntoElement { div() .flex() .items_center() @@ -125,7 +223,10 @@ impl PasskeysView { .into_any_element() } - fn render_pin_management(device: &DeviceState, theme: &Theme) -> impl IntoElement { + fn render_pin_management(&self, cx: &mut Context) -> impl IntoElement { + let status_row = self.render_pin_status_row(cx).into_any_element(); + let min_len_row = self.render_min_pin_length_row(cx).into_any_element(); + Card::new() .title("PIN Management") .icon(Icon::default().path("icons/key.svg")) @@ -133,16 +234,23 @@ impl PasskeysView { .child( v_flex() .gap_4() - .child(Self::render_pin_status_row(device, theme)) - .child(Self::render_min_pin_length_row(device, theme)), + .child(status_row) + .child(min_len_row), ) } - fn render_pin_status_row(device: &DeviceState, theme: &Theme) -> impl IntoElement { - let pin_set = device + fn render_pin_status_row(&self, cx: &mut Context) -> impl IntoElement { + let pin_set = self .fido_info .as_ref() - .map_or(false, |f| f.options.client_pin); + .and_then(|f| f.options.get("clientPin").copied()) + .unwrap_or(false); + + let listener = cx.listener(|this, _, window, cx| { + this.open_change_pin_dialog(window, cx); + }); + + let theme = cx.theme(); div() .flex() @@ -166,19 +274,27 @@ impl PasskeysView { }), ), ) - .child(Button::new("change-pin-btn").outline().child(if pin_set { - "Change PIN" - } else { - "Set PIN" - })) + .child( + Button::new("change-pin-btn") + .outline() + .child(if pin_set { "Change PIN" } else { "Set PIN" }) + .on_click(listener), + ) } - fn render_min_pin_length_row(device: &DeviceState, theme: &Theme) -> impl IntoElement { - let min_len = device.fido_info.as_ref().map_or(4, |f| f.min_pin_length); - let pin_set = device + fn render_min_pin_length_row(&self, cx: &mut Context) -> impl IntoElement { + let min_len = self .fido_info .as_ref() - .map_or(false, |f| f.options.client_pin); + .map(|f| f.min_pin_length) + .unwrap_or(4); + let pin_set = self + .fido_info + .as_ref() + .and_then(|f| f.options.get("clientPin").copied()) + .unwrap_or(false); + + let theme = cx.theme(); div() .flex() @@ -206,125 +322,155 @@ impl PasskeysView { ) } - fn render_stored_passkeys(device: &DeviceState, theme: &Theme) -> impl IntoElement { + fn render_stored_passkeys(&self, cx: &mut Context) -> impl IntoElement { + if !self.unlocked { + self.render_locked_state(cx).into_any_element() + } else { + self.render_unlocked_state(cx).into_any_element() + } + } + + fn render_locked_state(&self, cx: &mut Context) -> impl IntoElement { + let listener = cx.listener(|this, _, window, cx| { + this.open_unlock_dialog(window, cx); + }); + let theme = cx.theme(); + Card::new() .title("Stored Passkeys") .icon(Icon::default().path("icons/key-round.svg")) .description("View and manage your resident credentials") - .child(if !device.unlocked { - Self::render_locked_state(theme).into_any_element() - } else { - Self::render_unlocked_state(device, theme).into_any_element() - }) - // header_right was effectively invisible/placeholder in original code: - // Some(div().child("View and manage...").invisible()) - // We can omit it or add it if needed, but it looked like a hack or mistake in original code? - // The original header_right was: - // Some(div().child("View and manage your resident credentials").text_sm().text_color(...).invisible()) - // This suggests it was maybe trying to take up space or something? But description handles the text. - // I will simplify and omit it, which is cleaner. - } - - fn render_locked_state(theme: &Theme) -> impl IntoElement { - v_flex() - .items_center() - .justify_center() - .gap_4() - .py_8() .child( - div().rounded_full().bg(theme.muted).p_4().child( - Icon::default() - .path("icons/shield.svg") - .size_8() - .text_color(theme.muted_foreground), - ), - ) - .child( - div() - .text_lg() - .font_semibold() - .child("Authentication Required"), - ) - .child( - div() - .text_color(theme.muted_foreground) - .text_sm() - .child("Unlock your device to view and manage passkeys."), - ) - .child( - Button::new("unlock-btn").child( - h_flex() - .gap_2() - .child(Icon::default().path("icons/lock-open.svg")) - .child("Unlock Storage"), - ), - ) - } - - fn render_unlocked_state(device: &DeviceState, theme: &Theme) -> impl IntoElement { - v_flex() - .gap_6() - .child( - h_flex() - .justify_between() + v_flex() .items_center() + .justify_center() + .gap_4() + .py_8() .child( - h_flex() - .gap_4() - .items_center() + div().rounded_full().bg(theme.muted).p_4().child( + Icon::default() + .path("icons/shield.svg") + .size_8() + .text_color(theme.muted_foreground), + ), + ) + .child( + div() + .text_lg() + .font_semibold() + .child("Authentication Required"), + ) + .child( + div() + .text_color(theme.muted_foreground) + .text_sm() .child( - Badge::new() - .child( - h_flex() - .gap_1() - .items_center() - .child( - Icon::default() - .path("icons/lock-open.svg") - .size_3p5(), - ) - .child("Unlocked"), - ) - .color(gpui::green()), - ) - .child(div().w_px().h_4().bg(theme.border)) - .child( - div() - .text_sm() - .text_color(theme.muted_foreground) - .child(format!( - "{} credentials stored", - device.credentials.len() - )), + "Unlock your device to view and manage passkeys.", ), ) .child( - Button::new("lock-storage-btn").outline().small().child( - h_flex() - .gap_2() - .child(Icon::default().path("icons/lock.svg").size_3p5()) - .child("Lock Storage"), - ), + Button::new("unlock-btn") + .child( + h_flex() + .gap_2() + .child(Icon::default().path("icons/lock-open.svg")) + .child("Unlock Storage"), + ) + .on_click(listener), ), ) - .child(if device.credentials.is_empty() { - Self::render_empty_credentials(theme).into_any_element() - } else { - div() - .grid() - .grid_cols(3) - .gap_4() - .children( - device - .credentials - .iter() - .map(|cred| Self::render_credential_card(cred, theme)), - ) - .into_any_element() - }) } - fn render_empty_credentials(theme: &Theme) -> impl IntoElement { + fn render_unlocked_state(&self, cx: &mut Context) -> impl IntoElement { + let creds_len = self.credentials.len(); + let lock_listener = cx.listener(|this, _, _, cx| { + this.lock_storage(cx); + }); + + // Prepare credential cards before getting theme + // We need to iterate self.credentials + // render_credential_card needs cx for listeners. + // It also needs theme. + // So render_credential_card must adhere to the pattern: create listeners, then get theme. + + // This is tricky for map. + // We can create a list of listeners first? + // Or simply `render_credential_card` should take `cx` and handle it. + // Yes, `render_credential_card(&self, cred, cx)`. + + let mut cards = Vec::new(); + for cred in &self.credentials { + cards.push(self.render_credential_card(cred, cx).into_any_element()); + } + + let theme = cx.theme(); + + Card::new() + .title("Stored Passkeys") + .icon(Icon::default().path("icons/key-round.svg")) + .description("View and manage your resident credentials") + .child( + v_flex() + .gap_6() + .child( + h_flex() + .justify_between() + .items_center() + .child( + h_flex() + .gap_4() + .items_center() + .child( + Badge::new() + .child( + h_flex() + .gap_1() + .items_center() + .child( + Icon::default() + .path("icons/lock-open.svg") + .size_3p5(), + ) + .child("Unlocked"), + ) + .color(gpui::green()), + ) + .child(div().w_px().h_4().bg(theme.border)) + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child(format!("{} credentials stored", creds_len)), + ), + ) + .child( + Button::new("lock-storage-btn") + .outline() + .small() + .child( + h_flex() + .gap_2() + .child(Icon::default().path("icons/lock.svg").size_3p5()) + .child("Lock Storage"), + ) + .on_click(lock_listener), + ), + ) + .child(if self.credentials.is_empty() { + self.render_empty_credentials_with_theme(theme).into_any_element() + } else { + div() + .grid() + .grid_cols(3) + .gap_4() + .children(cards) + .into_any_element() + }), + ) + } + + // Rework layout of render_empty_credentials to take theme, assuming call site handles it. + fn render_empty_credentials_with_theme(&self, theme: &Theme) -> impl IntoElement { v_flex() .items_center() .justify_center() @@ -334,7 +480,7 @@ impl PasskeysView { .rounded_xl() .gap_4() .child( - div() + div() .rounded_full() .bg(theme.muted) .p_4() @@ -342,21 +488,131 @@ impl PasskeysView { Icon::default() .path("icons/key-round.svg") .size_8() - .text_color(theme.muted_foreground) - ) + .text_color(theme.muted_foreground), + ), ) - .child(div().text_lg().font_semibold().child("No Passkeys Found")) + .child(div().text_lg().font_semibold().child("No Passkeys Found")) .child( div() .text_color(theme.muted_foreground) .text_sm() .text_center() .max_w(px(384.0)) - .child("This device doesn't have any resident credentials stored yet. Create passkeys on websites to see them here.") + .child("This device doesn't have any resident credentials stored yet. Create passkeys on websites to see them here."), ) } - fn render_credential_card(cred: &StoredCredential, theme: &Theme) -> impl IntoElement { + fn render_modal(&self, cx: &mut Context) -> impl IntoElement { + if let Some(modal) = &self.active_modal { + let theme = cx.theme(); + let content = match modal { + ActiveModal::Unlock(pin_input) => { + let pin_input_clone = pin_input.clone(); + v_flex() + .gap_4() + .child("Enter your device PIN to view saved passkeys") + .child(Input::new(pin_input)) + .child( + h_flex() + .gap_2() + .justify_end() + .child( + Button::new("cancel-unlock") + .label("Cancel") + .on_click(cx.listener(|this, _, _, cx| { + this.active_modal = None; + cx.notify(); + })) + ) + .child( + Button::new("confirm-unlock") + .primary() + .label("Unlock") + .on_click(cx.listener(move |this, _, _, cx| { + let pin = pin_input_clone.read(cx).text().to_string(); + if !pin.is_empty() { + this.unlock_storage(pin, cx); + this.active_modal = None; // Manual close on success + cx.notify(); + } + })) + ) + ) + }, + ActiveModal::Delete { cred, pin } => { + let cred_id = cred.credential_id.clone(); + let pin_str = pin.clone(); + let name = cred.rp_id.clone(); + + v_flex() + .gap_4() + .child(format!("Are you sure you want to delete the passkey for {}?", name)) + .child( + h_flex() + .gap_2() + .justify_end() + .child( + Button::new("cancel-del") + .label("Cancel") + .on_click(cx.listener(|this, _, _, cx| { + this.active_modal = None; + cx.notify(); + })) + ) + .child( + Button::new("confirm-del") + .danger() + .label("Delete") + .on_click(cx.listener(move |this, _, _, cx| { + this.execute_delete(cred_id.clone(), pin_str.clone(), cx); + this.active_modal = None; + cx.notify(); + })) + ) + ) + } + _ => div().child("Not Implemented"), + }; + + div() + .absolute() + .top_0() + .left_0() + .size_full() + .flex() + .items_center() + .justify_center() + .bg(gpui::black().opacity(0.5)) + .child( + div() + .w(px(400.0)) + .bg(theme.background) + .border_1() + .border_color(theme.border) + .rounded_lg() + .shadow_lg() + .p_6() + .child(content) + ) + .into_any_element() + } else { + div().into_any_element() + } + } + + fn render_credential_card( + &self, + cred: &StoredCredential, + cx: &mut Context, + ) -> impl IntoElement { + let cred_clone = cred.clone(); + + let delete_listener = cx.listener(move |this, _, window, cx| { + this.open_ask_delete_pin(cred_clone.clone(), window, cx); + }); + + let theme = cx.theme(); + div() .border_1() .border_color(theme.border) @@ -388,11 +644,7 @@ impl PasskeysView { ) .child( v_flex() - .child( - div().font_semibold().child( - cred.rp_name.clone().unwrap_or(cred.rp_id.clone()), - ), - ) + .child(div().font_semibold().child(cred.rp_name.clone())) .child( div() .text_sm() @@ -402,13 +654,90 @@ impl PasskeysView { ), ) .child( - Button::new("delete-cred-btn").ghost().small().child( - Icon::default() - .path("icons/trash-2.svg") - .size_4() - .text_color(theme.muted_foreground), - ), + Button::new("delete-cred-btn") + .ghost() + .small() + .child( + Icon::default() + .path("icons/trash-2.svg") + .size_4() + .text_color(theme.muted_foreground), + ) + .on_click(delete_listener), ), ) } + + fn open_ask_delete_pin( + &mut self, + cred: StoredCredential, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(pin) = &self.cached_pin { + self.open_delete_dialog(&cred, pin.clone(), window, cx); + } else { + window.push_notification("Session expired, please unlock again.", cx); + self.lock_storage(cx); + } + } +} + +impl Render for PasskeysView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let device_connected = self.device_status.is_some(); + if !device_connected { + // Static render, need theme. + // Since we don't need listeners here, we can just get theme. + let theme = cx.theme(); // borrow cx + return + PageView::build( + "Passkeys", + "Manage your security PIN and the FIDO credentials (passkeys) stored on your device.", + self.render_no_device(theme).into_any_element(), + theme + ).into_any_element(); + } + + let has_fido = self + .device_status + .as_ref() + .map(|s| s.method == crate::device::types::DeviceMethod::Fido) + .unwrap_or(false) + || self.fido_info.is_some(); + + if !has_fido { + let theme = cx.theme(); + return PageView::build( + "Passkeys", + "Manage your security PIN and the FIDO credentials (passkeys) stored on your device.", + self.render_not_supported(theme).into_any_element(), + theme + ).into_any_element(); + } + + // Main view + // 1. Render content (calls helpers which access cx for listeners and theme) + let content = v_flex() + .gap_6() + .child(self.render_pin_management(cx)) + .child(self.render_stored_passkeys(cx)); + + // 2. Get theme for PageView wrapper + let theme = cx.theme(); + + div() + .size_full() + .relative() // Critical for absolute modal + .child( + PageView::build( + "Passkeys", + "Manage your security PIN and the FIDO credentials (passkeys) stored on your device.", + content.into_any_element(), + theme, + ) + ) + .child(self.render_modal(cx)) + .into_any_element() + } }