fractal/login/
mod.rs

1use adw::{prelude::*, subclass::prelude::*};
2use gettextrs::gettext;
3use gtk::{
4    gio, glib,
5    glib::{clone, closure_local},
6    CompositeTemplate,
7};
8use matrix_sdk::Client;
9use ruma::{
10    api::client::session::{get_login_types::v3::LoginType, login},
11    OwnedServerName,
12};
13use tracing::warn;
14use url::Url;
15
16mod advanced_dialog;
17mod greeter;
18mod homeserver_page;
19mod in_browser_page;
20mod method_page;
21mod session_setup_view;
22mod sso_idp_button;
23
24use self::{
25    advanced_dialog::LoginAdvancedDialog, greeter::Greeter, homeserver_page::LoginHomeserverPage,
26    in_browser_page::LoginInBrowserPage, method_page::LoginMethodPage,
27    session_setup_view::SessionSetupView,
28};
29use crate::{
30    components::OfflineBanner, prelude::*, secret::Secret, session::model::Session, spawn, toast,
31    Application, Window, RUNTIME, SETTINGS_KEY_CURRENT_SESSION,
32};
33
34/// A page of the login stack.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::EnumString, strum::AsRefStr)]
36#[strum(serialize_all = "kebab-case")]
37enum LoginPage {
38    /// The greeter page.
39    Greeter,
40    /// The homeserver page.
41    Homeserver,
42    /// The page to select a login method.
43    Method,
44    /// The page to log in with the browser.
45    InBrowser,
46    /// The loading page.
47    Loading,
48    /// The session setup stack.
49    SessionSetup,
50    /// The login is completed.
51    Completed,
52}
53
54mod imp {
55    use std::{
56        cell::{Cell, RefCell},
57        marker::PhantomData,
58        sync::LazyLock,
59    };
60
61    use glib::subclass::{InitializingObject, Signal};
62
63    use super::*;
64
65    #[derive(Debug, Default, CompositeTemplate, glib::Properties)]
66    #[template(resource = "/org/gnome/Fractal/ui/login/mod.ui")]
67    #[properties(wrapper_type = super::Login)]
68    pub struct Login {
69        #[template_child]
70        navigation: TemplateChild<adw::NavigationView>,
71        #[template_child]
72        greeter: TemplateChild<Greeter>,
73        #[template_child]
74        homeserver_page: TemplateChild<LoginHomeserverPage>,
75        #[template_child]
76        method_page: TemplateChild<LoginMethodPage>,
77        #[template_child]
78        in_browser_page: TemplateChild<LoginInBrowserPage>,
79        #[template_child]
80        done_button: TemplateChild<gtk::Button>,
81        /// Whether auto-discovery is enabled.
82        #[property(get, set = Self::set_autodiscovery, construct, explicit_notify, default = true)]
83        autodiscovery: Cell<bool>,
84        /// The login types supported by the homeserver.
85        login_types: RefCell<Vec<LoginType>>,
86        /// The domain of the homeserver to log into.
87        domain: RefCell<Option<OwnedServerName>>,
88        /// The domain of the homeserver to log into, as a string.
89        #[property(get = Self::domain_string)]
90        domain_string: PhantomData<Option<String>>,
91        /// The URL of the homeserver to log into.
92        homeserver_url: RefCell<Option<Url>>,
93        /// The URL of the homeserver to log into, as a string.
94        #[property(get = Self::homeserver_url_string)]
95        homeserver_url_string: PhantomData<Option<String>>,
96        /// The Matrix client used to log in.
97        client: RefCell<Option<Client>>,
98        /// The session that was just logged in.
99        session: RefCell<Option<Session>>,
100    }
101
102    #[glib::object_subclass]
103    impl ObjectSubclass for Login {
104        const NAME: &'static str = "Login";
105        type Type = super::Login;
106        type ParentType = adw::Bin;
107
108        fn class_init(klass: &mut Self::Class) {
109            OfflineBanner::ensure_type();
110
111            Self::bind_template(klass);
112            Self::bind_template_callbacks(klass);
113
114            klass.set_css_name("login");
115            klass.set_accessible_role(gtk::AccessibleRole::Group);
116
117            klass.install_action_async(
118                "login.sso",
119                Some(&Option::<String>::static_variant_type()),
120                |obj, _, variant| async move {
121                    let sso_idp_id = variant.and_then(|v| v.get::<Option<String>>()).flatten();
122                    obj.imp().show_in_browser_page(sso_idp_id, false);
123                },
124            );
125
126            klass.install_action_async("login.open-advanced", None, |obj, _, _| async move {
127                obj.imp().open_advanced_dialog().await;
128            });
129        }
130
131        fn instance_init(obj: &InitializingObject<Self>) {
132            obj.init_template();
133        }
134    }
135
136    #[glib::derived_properties]
137    impl ObjectImpl for Login {
138        fn signals() -> &'static [Signal] {
139            static SIGNALS: LazyLock<Vec<Signal>> = LazyLock::new(|| {
140                vec![
141                    // The login types changed.
142                    Signal::builder("login-types-changed").build(),
143                ]
144            });
145            SIGNALS.as_ref()
146        }
147
148        fn constructed(&self) {
149            let obj = self.obj();
150            obj.action_set_enabled("login.next", false);
151
152            self.parent_constructed();
153
154            let monitor = gio::NetworkMonitor::default();
155            monitor.connect_network_changed(clone!(
156                #[weak]
157                obj,
158                move |_, available| {
159                    obj.action_set_enabled("login.sso", available);
160                }
161            ));
162            obj.action_set_enabled("login.sso", monitor.is_network_available());
163
164            self.navigation.connect_visible_page_notify(clone!(
165                #[weak(rename_to = imp)]
166                self,
167                move |_| {
168                    imp.visible_page_changed();
169                }
170            ));
171        }
172
173        fn dispose(&self) {
174            self.drop_client();
175            self.drop_session();
176        }
177    }
178
179    impl WidgetImpl for Login {
180        fn grab_focus(&self) -> bool {
181            match self.visible_page() {
182                LoginPage::Greeter => self.greeter.grab_focus(),
183                LoginPage::Homeserver => self.homeserver_page.grab_focus(),
184                LoginPage::Method => self.method_page.grab_focus(),
185                LoginPage::InBrowser => self.in_browser_page.grab_focus(),
186                LoginPage::Loading => false,
187                LoginPage::SessionSetup => {
188                    if let Some(session_setup) = self.session_setup() {
189                        session_setup.grab_focus()
190                    } else {
191                        false
192                    }
193                }
194                LoginPage::Completed => self.done_button.grab_focus(),
195            }
196        }
197    }
198
199    impl BinImpl for Login {}
200    impl AccessibleImpl for Login {}
201
202    #[gtk::template_callbacks]
203    impl Login {
204        /// The visible page of the view.
205        pub(super) fn visible_page(&self) -> LoginPage {
206            self.navigation
207                .visible_page()
208                .and_then(|p| p.tag())
209                .and_then(|s| s.as_str().try_into().ok())
210                .unwrap()
211        }
212
213        /// Set whether auto-discovery is enabled.
214        pub fn set_autodiscovery(&self, autodiscovery: bool) {
215            if self.autodiscovery.get() == autodiscovery {
216                return;
217            }
218
219            self.autodiscovery.set(autodiscovery);
220            self.obj().notify_autodiscovery();
221        }
222
223        /// Get the session setup view, if any.
224        pub(super) fn session_setup(&self) -> Option<SessionSetupView> {
225            self.navigation
226                .find_page(LoginPage::SessionSetup.as_ref())
227                .and_downcast()
228        }
229
230        /// The visible page changed.
231        fn visible_page_changed(&self) {
232            match self.visible_page() {
233                LoginPage::Greeter => {
234                    self.clean();
235                }
236                LoginPage::Homeserver => {
237                    // Drop the client because it is bound to the homeserver.
238                    self.drop_client();
239                    // Drop the session because it is bound to the homeserver and account.
240                    self.drop_session();
241                    self.method_page.clean();
242                }
243                LoginPage::Method => {
244                    // Drop the session because it is bound to the account.
245                    self.drop_session();
246                }
247                _ => {}
248            }
249        }
250
251        /// The Matrix client.
252        pub(super) async fn client(&self) -> Option<Client> {
253            if let Some(client) = self.client.borrow().clone() {
254                return Some(client);
255            }
256
257            // If the client was dropped, try to recreate it.
258            self.homeserver_page.check_homeserver().await;
259            if let Some(client) = self.client.borrow().clone() {
260                return Some(client);
261            }
262
263            None
264        }
265
266        /// Set the Matrix client.
267        pub(super) fn set_client(&self, client: Option<Client>) {
268            let homeserver = client.as_ref().map(Client::homeserver);
269
270            self.set_homeserver_url(homeserver);
271            self.client.replace(client);
272        }
273
274        /// Drop the Matrix client.
275        pub(super) fn drop_client(&self) {
276            if let Some(client) = self.client.take() {
277                // The `Client` needs to access a tokio runtime when it is dropped.
278                let _guard = RUNTIME.enter();
279                drop(client);
280            }
281        }
282
283        /// Drop the session and clean up its data from the system.
284        fn drop_session(&self) {
285            if let Some(session) = self.session.take() {
286                spawn!(async move {
287                    let _ = session.log_out().await;
288                });
289            }
290        }
291
292        /// Set the domain of the homeserver to log into.
293        pub(super) fn set_domain(&self, domain: Option<OwnedServerName>) {
294            if *self.domain.borrow() == domain {
295                return;
296            }
297
298            self.domain.replace(domain);
299            self.obj().notify_domain_string();
300        }
301
302        /// The domain of the homeserver to log into.
303        ///
304        /// If autodiscovery is enabled, this is the server name, otherwise,
305        /// this is the prettified homeserver URL.
306        fn domain_string(&self) -> Option<String> {
307            if self.autodiscovery.get() {
308                self.domain.borrow().clone().map(Into::into)
309            } else {
310                self.homeserver_url_string()
311            }
312        }
313
314        /// The pretty-formatted URL of the homeserver to log into.
315        fn homeserver_url_string(&self) -> Option<String> {
316            self.homeserver_url
317                .borrow()
318                .as_ref()
319                .map(|url| url.as_ref().trim_end_matches('/').to_owned())
320        }
321
322        /// Set the URL of the homeserver to log into.
323        fn set_homeserver_url(&self, homeserver: Option<Url>) {
324            if *self.homeserver_url.borrow() == homeserver {
325                return;
326            }
327
328            self.homeserver_url.replace(homeserver);
329
330            let obj = self.obj();
331            obj.notify_homeserver_url_string();
332
333            if !self.autodiscovery.get() {
334                obj.notify_domain_string();
335            }
336        }
337
338        /// Set the login types supported by the homeserver.
339        pub(super) fn set_login_types(&self, types: Vec<LoginType>) {
340            self.login_types.replace(types);
341            self.obj().emit_by_name::<()>("login-types-changed", &[]);
342        }
343
344        /// The login types supported by the homeserver.
345        pub(super) fn login_types(&self) -> Vec<LoginType> {
346            self.login_types.borrow().clone()
347        }
348
349        /// Open the login advanced dialog.
350        async fn open_advanced_dialog(&self) {
351            let obj = self.obj();
352            let dialog = LoginAdvancedDialog::new();
353            obj.bind_property("autodiscovery", &dialog, "autodiscovery")
354                .sync_create()
355                .bidirectional()
356                .build();
357            dialog.run_future(&*obj).await;
358        }
359
360        /// Show the appropriate login page given the current login types.
361        pub(super) fn show_login_page(&self) {
362            let mut oidc_compatibility = false;
363            let mut supports_password = false;
364
365            for login_type in self.login_types.borrow().iter() {
366                match login_type {
367                    LoginType::Sso(sso) if sso.delegated_oidc_compatibility => {
368                        oidc_compatibility = true;
369                        // We do not care about password support at this point.
370                        break;
371                    }
372                    LoginType::Password(_) => {
373                        supports_password = true;
374                    }
375                    _ => {}
376                }
377            }
378
379            if oidc_compatibility || !supports_password {
380                self.show_in_browser_page(None, oidc_compatibility);
381            } else {
382                self.navigation.push_by_tag(LoginPage::Method.as_ref());
383            }
384        }
385
386        /// Show the page to log in with the browser with the given parameters.
387        fn show_in_browser_page(&self, sso_idp_id: Option<String>, oidc_compatibility: bool) {
388            self.in_browser_page.set_sso_idp_id(sso_idp_id);
389            self.in_browser_page
390                .set_oidc_compatibility(oidc_compatibility);
391
392            self.navigation.push_by_tag(LoginPage::InBrowser.as_ref());
393        }
394
395        /// Handle the given response after successfully logging in.
396        pub(super) async fn handle_login_response(&self, response: login::v3::Response) {
397            let client = self.client().await.expect("client was constructed");
398            // The homeserver could have changed with the login response so get it from the
399            // Client.
400            let homeserver = client.homeserver();
401
402            match Session::create(homeserver, (&response).into()).await {
403                Ok(session) => {
404                    self.init_session(session).await;
405                }
406                Err(error) => {
407                    warn!("Could not create session: {error}");
408                    let obj = self.obj();
409                    toast!(obj, error.to_user_facing());
410
411                    self.navigation.pop();
412                }
413            }
414        }
415
416        /// Initialize the given session.
417        async fn init_session(&self, session: Session) {
418            let setup_view = SessionSetupView::new(&session);
419            setup_view.connect_completed(clone!(
420                #[weak(rename_to = imp)]
421                self,
422                move |_| {
423                    imp.navigation.push_by_tag(LoginPage::Completed.as_ref());
424                }
425            ));
426            self.navigation.push(&setup_view);
427
428            self.drop_client();
429            self.session.replace(Some(session.clone()));
430
431            // Save ID of logging in session to GSettings
432            let settings = Application::default().settings();
433            if let Err(err) =
434                settings.set_string(SETTINGS_KEY_CURRENT_SESSION, session.session_id())
435            {
436                warn!("Could not save current session: {err}");
437            }
438
439            let session_info = session.info().clone();
440
441            if Secret::store_session(session_info).await.is_err() {
442                let obj = self.obj();
443                toast!(obj, gettext("Could not store session"));
444            }
445
446            session.prepare().await;
447        }
448
449        /// Finish the login process and show the session.
450        #[template_callback]
451        fn finish_login(&self) {
452            let Some(window) = self.obj().root().and_downcast::<Window>() else {
453                return;
454            };
455
456            if let Some(session) = self.session.take() {
457                window.add_session(session);
458            }
459
460            self.clean();
461        }
462
463        /// Reset the login stack.
464        pub(super) fn clean(&self) {
465            // Clean pages.
466            self.homeserver_page.clean();
467            self.method_page.clean();
468
469            // Clean data.
470            self.set_autodiscovery(true);
471            self.set_login_types(vec![]);
472            self.set_domain(None);
473            self.set_homeserver_url(None);
474            self.drop_client();
475            self.drop_session();
476
477            // Reinitialize UI.
478            self.navigation.pop_to_tag(LoginPage::Greeter.as_ref());
479            self.unfreeze();
480        }
481
482        /// Freeze the login screen.
483        pub(super) fn freeze(&self) {
484            self.navigation.set_sensitive(false);
485        }
486
487        /// Unfreeze the login screen.
488        pub(super) fn unfreeze(&self) {
489            self.navigation.set_sensitive(true);
490        }
491    }
492}
493
494glib::wrapper! {
495    /// A widget managing the login flows.
496    pub struct Login(ObjectSubclass<imp::Login>)
497        @extends gtk::Widget, adw::Bin, @implements gtk::Accessible;
498}
499
500impl Login {
501    pub fn new() -> Self {
502        glib::Object::new()
503    }
504
505    /// Set the Matrix client.
506    fn set_client(&self, client: Option<Client>) {
507        self.imp().set_client(client);
508    }
509
510    /// The Matrix client.
511    async fn client(&self) -> Option<Client> {
512        self.imp().client().await
513    }
514
515    /// Drop the Matrix client.
516    fn drop_client(&self) {
517        self.imp().drop_client();
518    }
519
520    /// Set the domain of the homeserver to log into.
521    fn set_domain(&self, domain: Option<OwnedServerName>) {
522        self.imp().set_domain(domain);
523    }
524
525    /// Set the login types supported by the homeserver.
526    fn set_login_types(&self, types: Vec<LoginType>) {
527        self.imp().set_login_types(types);
528    }
529
530    /// The login types supported by the homeserver.
531    fn login_types(&self) -> Vec<LoginType> {
532        self.imp().login_types()
533    }
534
535    /// Handle the given response after successfully logging in.
536    async fn handle_login_response(&self, response: login::v3::Response) {
537        self.imp().handle_login_response(response).await;
538    }
539
540    /// Show the appropriate login screen given the current login types.
541    fn show_login_page(&self) {
542        self.imp().show_login_page();
543    }
544
545    /// Freeze the login screen.
546    fn freeze(&self) {
547        self.imp().freeze();
548    }
549
550    /// Unfreeze the login screen.
551    fn unfreeze(&self) {
552        self.imp().unfreeze();
553    }
554
555    /// Connect to the signal emitted when the login types changed.
556    pub fn connect_login_types_changed<F: Fn(&Self) + 'static>(
557        &self,
558        f: F,
559    ) -> glib::SignalHandlerId {
560        self.connect_closure(
561            "login-types-changed",
562            true,
563            closure_local!(move |obj: Self| {
564                f(&obj);
565            }),
566        )
567    }
568}