diff --git a/src/app.rs b/src/app.rs index a43705797..053f6693a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -11,7 +11,7 @@ use crate::{ event_source_modal::{EventSourceModalAction, EventSourceModalWidgetRefExt}, invite_modal::{InviteModalAction, InviteModalWidgetRefExt}, main_desktop_ui::MainDesktopUiAction, navigation_tab_bar::{NavigationBarAction, SelectedTab}, new_message_context_menu::NewMessageContextMenuWidgetRefExt, room_context_menu::RoomContextMenuWidgetRefExt, room_screen::{InviteAction, MessageAction, clear_timeline_states}, rooms_list::{RoomsListAction, RoomsListRef, RoomsListUpdate, clear_all_invited_rooms, enqueue_rooms_list_update} }, join_leave_room_modal::{ JoinLeaveModalKind, JoinLeaveRoomModalAction, JoinLeaveRoomModalWidgetRefExt - }, login::login_screen::LoginAction, logout::logout_confirm_modal::{LogoutAction, LogoutConfirmModalAction, LogoutConfirmModalWidgetRefExt}, persistence, profile::user_profile_cache::clear_user_profile_cache, room::BasicRoomDetails, shared::{confirmation_modal::{ConfirmationModalContent, ConfirmationModalWidgetRefExt}, image_viewer::{ImageViewerAction, LoadState}, popup_list::{PopupKind, enqueue_popup_notification}}, sliding_sync::{DirectMessageRoomAction, MatrixRequest, current_user_id, submit_async_request}, utils::RoomNameId, verification::VerificationAction, verification_modal::{ + }, login::login_screen::LoginAction, logout::logout_confirm_modal::{LogoutAction, LogoutConfirmModalAction, LogoutConfirmModalWidgetRefExt}, persistence, profile::user_profile_cache::clear_user_profile_cache, register::register_screen::{RegisterAction, RegisterScreen}, room::BasicRoomDetails, shared::{confirmation_modal::{ConfirmationModalContent, ConfirmationModalWidgetRefExt}, image_viewer::{ImageViewerAction, LoadState}, popup_list::{PopupKind, enqueue_popup_notification}}, sliding_sync::{DirectMessageRoomAction, MatrixRequest, current_user_id, submit_async_request}, utils::RoomNameId, verification::VerificationAction, verification_modal::{ VerificationModalAction, VerificationModalWidgetRefExt, } @@ -63,6 +63,10 @@ script_mod! { visible: true login_screen := LoginScreen {} } + register_screen_view := View { + visible: false + register_screen := RegisterScreen {} + } image_viewer_modal := Modal { content +: { @@ -270,11 +274,59 @@ impl MatchEvent for App { _ => {} } - if let Some(LoginAction::LoginSuccess) = action.downcast_ref() { - log!("Received LoginAction::LoginSuccess, hiding login view."); - self.app_state.logged_in = true; - self.update_login_visibility(cx); - self.ui.redraw(cx); + // Handle login-related actions + if let Some(login_action) = action.downcast_ref::() { + match login_action { + LoginAction::LoginSuccess => { + log!("Received LoginAction::LoginSuccess, hiding login view."); + self.app_state.logged_in = true; + self.update_login_visibility(cx); + self.ui.redraw(cx); + } + LoginAction::NavigateToRegister => { + log!("Navigating from login to register screen"); + // Start from a clean register screen state + if let Some(mut register_screen_ref) = self + .ui + .widget(cx, ids!(register_screen_view.register_screen)) + .borrow_mut::() + { + register_screen_ref.reset_screen_state(cx); + } + self.ui.view(cx, ids!(login_screen_view)).set_visible(cx, false); + self.ui.view(cx, ids!(register_screen_view)).set_visible(cx, true); + self.ui.redraw(cx); + } + _ => {} + } + continue; + } + + // Handle register-related actions + if let Some(register_action) = action.downcast_ref::() { + match register_action { + RegisterAction::NavigateToLogin => { + log!("Navigating from register to login screen"); + // Reset the register screen state before hiding it + if let Some(mut register_screen_ref) = self.ui.widget(cx, ids!(register_screen_view.register_screen)).borrow_mut::() { + register_screen_ref.reset_screen_state(cx); + } + self.ui.view(cx, ids!(register_screen_view)).set_visible(cx, false); + self.ui.view(cx, ids!(login_screen_view)).set_visible(cx, true); + self.ui.redraw(cx); + } + RegisterAction::RegistrationSuccess => { + log!("Registration successful, transitioning to logged in state"); + // Clear register screen state after successful registration + if let Some(mut register_screen_ref) = self.ui.widget(cx, ids!(register_screen_view.register_screen)).borrow_mut::() { + register_screen_ref.reset_screen_state(cx); + } + self.app_state.logged_in = true; + self.update_login_visibility(cx); + self.ui.redraw(cx); + } + _ => {} + } continue; } @@ -598,6 +650,7 @@ impl AppMain for App { crate::profile::script_mod(vm); crate::home::script_mod(vm); crate::login::script_mod(vm); + crate::register::script_mod(vm); crate::logout::script_mod(vm); self::script_mod(vm) @@ -653,6 +706,7 @@ impl App { .close(cx); } self.ui.view(cx, ids!(login_screen_view)).set_visible(cx, show_login); + self.ui.view(cx, ids!(register_screen_view)).set_visible(cx, false); self.ui.view(cx, ids!(home_screen_view)).set_visible(cx, !show_login); } diff --git a/src/lib.rs b/src/lib.rs index 346c0314b..a7e41638a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -45,6 +45,8 @@ pub mod settings; /// Login screen pub mod login; +/// Register screen +pub mod register; /// Logout confirmation and state management pub mod logout; /// Core UI content: the main home screen (rooms list), room screen. diff --git a/src/login/login_screen.rs b/src/login/login_screen.rs index 30844badc..7046d4fa9 100644 --- a/src/login/login_screen.rs +++ b/src/login/login_screen.rs @@ -312,8 +312,6 @@ script_mod! { } } -static MATRIX_SIGN_UP_URL: &str = "https://matrix.org/docs/chat_basics/matrix-for-im/#creating-a-matrix-account"; - #[derive(Script, ScriptHook, Widget)] pub struct LoginScreen { #[source] source: ScriptObjectRef, @@ -361,8 +359,7 @@ impl MatchEvent for LoginScreen { } if signup_button.clicked(actions) { - log!("Opening URL \"{}\"", MATRIX_SIGN_UP_URL); - let _ = robius_open::Uri::new(MATRIX_SIGN_UP_URL).open(); + cx.action(LoginAction::NavigateToRegister); } if login_button.clicked(actions) @@ -410,6 +407,7 @@ impl MatchEvent for LoginScreen { } // Handle login-related actions received from background async tasks. + // Skip processing if the login screen is not visible (e.g., user is on register screen) match action.downcast_ref() { Some(LoginAction::CliAutoLogin { user_id, homeserver }) => { user_id_input.set_text(cx, user_id); @@ -490,7 +488,8 @@ impl MatchEvent for LoginScreen { submit_async_request(MatrixRequest::SpawnSSOServer{ identity_provider_id: format!("oidc-{}",brand), brand: brand.to_string(), - homeserver_url: homeserver_input.text() + homeserver_url: homeserver_input.text(), + is_registration: false, }); } } @@ -529,6 +528,8 @@ pub enum LoginAction { /// When an SSO-based login is pendng, pressing the cancel button will send /// an HTTP request to this SSO server URL to gracefully shut it down. SsoSetRedirectUrl(Url), + /// Navigate to the register screen. + NavigateToRegister, #[default] None, } diff --git a/src/register/mod.rs b/src/register/mod.rs new file mode 100644 index 000000000..ca69f9a43 --- /dev/null +++ b/src/register/mod.rs @@ -0,0 +1,10 @@ +use makepad_widgets::ScriptVm; + +pub mod register_screen; +pub mod register_status_modal; +mod validation; + +pub fn script_mod(vm: &mut ScriptVm) { + register_status_modal::script_mod(vm); + register_screen::script_mod(vm); +} diff --git a/src/register/register_screen.rs b/src/register/register_screen.rs new file mode 100644 index 000000000..23764a62c --- /dev/null +++ b/src/register/register_screen.rs @@ -0,0 +1,889 @@ +//! Registration screen implementation with support for both password-based and SSO registration. +//! +//! # Supported Registration Methods +//! +//! ## 1. Password-based Registration (Custom Homeservers) +//! For custom Matrix homeservers, users can register with username/password: +//! - Minimum password length: 8 characters +//! - Automatic UIA handling for `m.login.dummy` and `m.login.registration_token` +//! - Basic URL validation for custom homeserver addresses +//! +//! ## 2. SSO Registration (matrix.org) +//! For matrix.org, registration uses Google SSO by default: +//! - **Why Google SSO?** Following Element's implementation, matrix.org primarily uses +//! Google OAuth as the main SSO provider for public registrations +//! - The SSO flow is shared with login - Matrix server automatically determines whether +//! to create a new account or login an existing user based on the OAuth identity +//! - UI provides clear feedback during SSO process (button disabled, status modal) +//! +//! # Registration Flow +//! +//! ```text +//! Password Registration: SSO Registration: +//! User → Username/Password → Server User → Continue with SSO → Browser OAuth +//! ↓ ↓ +//! UIA Challenge (if needed) Google Authentication +//! ↓ ↓ +//! Auto-handle m.login.dummy OAuth Callback +//! ↓ ↓ +//! Registration Success Auto Login/Register +//! ``` +//! +//! # SSO Action Handling Design +//! +//! The register screen uses source-aware SSO handling: +//! +//! ## How It Works +//! 1. Register screen sends `SpawnSSOServer` with `is_registration: true` +//! 2. `sliding_sync.rs` sends appropriate actions based on this flag: +//! - For registration: `RegisterAction::SsoRegistrationPending/Status/Success/Failure` +//! - For login: `LoginAction::SsoPending/Status/LoginSuccess/LoginFailure` +//! 3. Each screen only receives and handles its own actions +//! +//! ## Benefits +//! - **Zero Coupling:** Login and register screens are completely independent +//! - **Clear Intent:** The SSO flow knows its purpose from the start +//! - **No Action Conversion:** No need to intercept and convert actions +//! - **Maintainable:** Each screen has its own clear action flow +//! +//! # Implementation Notes +//! - SSO at protocol level doesn't distinguish login/register - server decides based on account existence +//! - Registration currently supports the dummy + registration token UIA stages; more complex flows +//! (captcha, email verification, etc.) are not yet implemented and will prompt users to register via web + +use makepad_widgets::*; +use url::Url; +use crate::shared::popup_list::{PopupKind, enqueue_popup_notification}; +use crate::sliding_sync::{submit_async_request, MatrixRequest, RegisterRequest}; +use crate::login::login_screen::LoginAction; +use super::{ + register_status_modal::RegisterStatusModalAction, + validation::{ + MIN_PASSWORD_CHARS, needs_custom_homeserver_input, normalize_custom_homeserver, + normalize_username, password_has_min_chars, + }, +}; + +script_mod! { + use mod.prelude.widgets.* + use mod.widgets.* + + + mod.widgets.MaskableButton = RobrixIconButton { + draw_bg +: { + mask: instance(0.0) + pixel: fn() { + let base_color = mix(self.color, mix(self.color, self.color_hover, 0.2), self.hover); + let gray = dot(base_color.rgb, vec3(0.299, 0.587, 0.114)); + return mix(base_color, vec4(gray, gray, gray, base_color.a), self.mask); + } + } + } + + mod.widgets.HomeserverAddressInput = RobrixTextInput { + width: Fill, height: Fit + flow: Right, // do not wrap + padding: Inset{top: 6, bottom: 6, left: 10, right: 10} + empty_text: "https://your-server.com" + draw_bg +: { + border_radius: 6.0 + border_size: 0.5 + border_color: (COLOR_SECONDARY_DARKER) + border_color_hover: (COLOR_ACTIVE_PRIMARY) + border_color_focus: (COLOR_ACTIVE_PRIMARY) + border_color_down: (COLOR_ACTIVE_PRIMARY_DARKER) + border_color_empty: (COLOR_SECONDARY_DARKER) + } + draw_text +: { + text_style: TITLE_TEXT {font_size: 10.0} + } + } + + mod.widgets.RegisterScreen = set_type_default() do #(RegisterScreen::register_widget(vm)) { + ..mod.widgets.SolidView + + width: Fill, height: Fill, + align: Align{x: 0.5, y: 0.5} + show_bg: true, + draw_bg +: { + color: COLOR_SECONDARY + } + + ScrollYView { + width: Fill, height: Fill, + // Note: *do NOT* vertically center this, it will break scrolling. + align: Align{x: 0.5} + show_bg: true, + draw_bg.color: (COLOR_SECONDARY) + + View { + margin: Inset{top: 20, bottom: 20} + width: Fill + height: Fit + align: Align{x: 0.5} + flow: Overlay, + + View { + width: Fill + height: Fit + flow: Down + align: Align{x: 0.5} + padding: Inset{top: 20, bottom: 20} + margin: Inset{top: 20, bottom: 20} + spacing: 12.0 + + logo_image := Image { + fit: ImageFit.Smallest, + width: 80 + src: (mod.widgets.IMG_APP_LOGO), + } + + title := Label { + width: Fit, height: Fit + margin: Inset{ bottom: 5 } + padding: 0, + draw_text +: { + color: (COLOR_TEXT) + text_style: TITLE_TEXT {font_size: 16.0} + } + text: "Create account" + } + + // Homeserver selection area + View { + width: 275, height: Fit + flow: Down + spacing: 3 + + homeserver_selector := View { + width: Fill, height: Fit + flow: Right + align: Align{x: 0.0, y: 0.5} + padding: Inset{left: 10, right: 10, top: 5, bottom: 5} + spacing: 5 + + selected_homeserver := Label { + width: Fill, height: Fit + draw_text +: { + color: (COLOR_TEXT) + text_style: REGULAR_TEXT {font_size: 12} + } + text: "matrix.org" + } + + edit_button := RobrixIconButton { + width: Fit, height: Fit + padding: Inset{left: 8, right: 8, top: 4, bottom: 4} + text: "Edit" + } + } + + View { + width: 275, height: Fit, + flow: Right, + padding: Inset{top: 3, left: 2, right: 2} + spacing: 0.0, + align: Align{x: 0.5, y: 0.5} + + LineH { draw_bg.color: (COLOR_SECONDARY_DARKER) } + + homeserver_description := Label { + width: Fit, height: Fit + padding: 0 + draw_text +: { + color: (COLOR_FG_DISABLED) + text_style: REGULAR_TEXT {font_size: 9} + } + text: "Homeserver (optional)" + } + + LineH { draw_bg.color: (COLOR_SECONDARY_DARKER) } + } + } + + // Homeserver selection options (initially hidden) + homeserver_options := View { + width: 275, height: Fit + flow: Down + spacing: 5 + visible: false + padding: Inset{left: 5, right: 5, top: 4, bottom: 4} + + Label { + width: Fill, height: Fit + draw_text +: { + color: (COLOR_FG_DISABLED) + text_style: REGULAR_TEXT {font_size: 10} + } + text: "Select a homeserver:" + } + + matrix_option := RobrixIconButton { + width: Fill, height: Fit + padding: Inset{left: 10, right: 10, top: 6, bottom: 6} + draw_bg +: { + color: (COLOR_TRANSPARENT) + color_hover: (COLOR_BG_DISABLED) + border_size: 0.5 + border_color: (COLOR_SECONDARY_DARKER) + } + draw_text +: { + color: (COLOR_TEXT) + text_style: REGULAR_TEXT {font_size: 11} + } + text: "[*] matrix.org" + } + + other_option := RobrixIconButton { + width: Fill, height: Fit + padding: Inset{left: 10, right: 10, top: 6, bottom: 6} + draw_bg +: { + color: (COLOR_TRANSPARENT) + color_hover: (COLOR_BG_DISABLED) + border_size: 0.5 + border_color: (COLOR_SECONDARY_DARKER) + } + draw_text +: { + color: (COLOR_TEXT) + text_style: REGULAR_TEXT {font_size: 11} + } + text: "[ ] Other homeserver" + } + + custom_homeserver := View { + width: 275, height: Fit + visible: false + margin: Inset{top: 2} + + custom_homeserver_input := mod.widgets.HomeserverAddressInput { + width: 275, height: Fit + } + } + } + + // Dynamic registration area + sso_area := View { + width: 275, height: Fit + flow: Down + spacing: 10 + visible: true + + sso_button := MaskableButton { + width: Fill, height: 40 + padding: 10 + margin: Inset{top: 10} + align: Align{x: 0.5, y: 0.5} + draw_bg +: { + color: (COLOR_ACTIVE_PRIMARY) + mask: 0.0 + } + draw_text +: { + color: (COLOR_PRIMARY) + text_style: REGULAR_TEXT {} + } + text: "Continue with SSO" + } + } + + password_area := View { + width: 275, height: Fit + flow: Down + spacing: 10 + visible: false + + username_input := RobrixTextInput { + width: Fill, height: Fit + flow: Right, + padding: 10, + empty_text: "Username" + } + + password_input := RobrixTextInput { + width: Fill, height: Fit + flow: Right, + padding: 10, + empty_text: "Password" + is_password: true, + } + + confirm_password_input := RobrixTextInput { + width: Fill, height: Fit + flow: Right, + padding: 10, + empty_text: "Confirm Password" + is_password: true, + } + + registration_token_area := View { + width: Fill, height: Fit + flow: Down + spacing: 4 + visible: false + + Label { + width: Fill, height: Fit + draw_text +: { + color: (COLOR_TEXT) + text_style: REGULAR_TEXT {font_size: 10} + } + text: "This server requires a registration token from the administrator." + } + + registration_token_input := RobrixTextInput { + width: Fill, height: Fit + padding: 10, + empty_text: "Registration token" + is_password: true, + } + } + + register_button := RobrixIconButton { + width: Fill, height: 40 + padding: 10 + margin: Inset{top: 5, bottom: 10} + align: Align{x: 0.5, y: 0.5} + text: "Register" + } + } + + View { + width: 275, + height: Fit, + flow: Right, + spacing: 0.0, + align: Align{x: 0.5, y: 0.5} + + left_line := LineH { + draw_bg.color: (COLOR_SECONDARY_DARKER) + } + + Label { + width: Fit, height: Fit + padding: Inset{left: 1, right: 1, top: 0, bottom: 0} + draw_text +: { + color: (COLOR_FG_DISABLED) + text_style: REGULAR_TEXT {} + } + text: "Already have an account?" + } + + right_line := LineH { + draw_bg.color: (COLOR_SECONDARY_DARKER) + } + } + + login_button := RobrixIconButton { + width: Fit, height: Fit + padding: Inset{left: 15, right: 15, top: 10, bottom: 10} + margin: Inset{bottom: 5} + align: Align{x: 0.5, y: 0.5} + text: "Back to Login" + } + } + + // Modal for registration status (both password and SSO) + status_modal := Modal { + can_dismiss: false, + content +: { + status_modal_inner := RegisterStatusModal {} + } + } + } + } + } +} + +#[derive(Script, Widget)] +pub struct RegisterScreen { + #[source] source: ScriptObjectRef, + #[deref] + view: View, + #[rust] + is_homeserver_editing: bool, + #[rust] + selected_homeserver: String, + #[rust] + pending_custom_homeserver: bool, + #[rust] + sso_pending: bool, + #[rust] + sso_redirect_url: Option, + #[rust] + registration_token_required: bool, +} + +impl ScriptHook for RegisterScreen { + fn on_after_new(&mut self, _vm: &mut ScriptVm) { + self.selected_homeserver = "matrix.org".to_string(); + } +} + +impl RegisterScreen { + fn toggle_homeserver_options(&mut self, cx: &mut Cx) { + self.is_homeserver_editing = !self.is_homeserver_editing; + self.view + .view(cx, ids!(homeserver_options)) + .set_visible(cx, self.is_homeserver_editing); + self.redraw(cx); + } + + /// Updates the radio-style homeserver option buttons to reflect the current selection. + fn update_homeserver_option_buttons(&self, cx: &mut Cx) { + let is_matrix_org = !self.pending_custom_homeserver; + let matrix_btn = self.view.button(cx, ids!(matrix_option)); + let other_btn = self.view.button(cx, ids!(other_option)); + if is_matrix_org { + matrix_btn.set_text(cx, "[*] matrix.org"); + other_btn.set_text(cx, "[ ] Other homeserver"); + } else { + matrix_btn.set_text(cx, "[ ] matrix.org"); + other_btn.set_text(cx, "[*] Other homeserver"); + } + } + + fn show_warning(&self, message: &str) { + enqueue_popup_notification(message.to_string(), PopupKind::Warning, Some(3.0)); + } + + fn update_button_mask(&self, button: &mut ButtonRef, cx: &mut Cx, mask: f32) { + script_apply_eval!(cx, button, { + draw_bg: { mask: #(mask) } + }); + } + + fn set_registration_token_required(&mut self, cx: &mut Cx, required: bool) { + self.registration_token_required = required; + self.view + .view(cx, ids!(registration_token_area)) + .set_visible(cx, required); + if !required { + self.view + .text_input(cx, ids!(registration_token_input)) + .set_text(cx, ""); + } + self.redraw(cx); + } + + fn reset_modal_state(&mut self, cx: &mut Cx) { + let register_button = self.view.button(cx, ids!(register_button)); + register_button.set_enabled(cx, true); + register_button.reset_hover(cx); + self.redraw(cx); + } + + fn update_registration_mode(&mut self, cx: &mut Cx) { + let is_matrix_org = !self.pending_custom_homeserver + && (self.selected_homeserver == "matrix.org" || self.selected_homeserver.is_empty()); + + // Update UI based on homeserver selection + self.view + .view(cx, ids!(sso_area)) + .set_visible(cx, is_matrix_org); + self.view + .view(cx, ids!(password_area)) + .set_visible(cx, !is_matrix_org); + + // Update description text + let desc_label = self.view.label(cx, ids!(homeserver_description)); + if is_matrix_org { + desc_label.set_text(cx, "Join millions for free on the largest public server"); + } else { + desc_label.set_text(cx, "Use your custom Matrix homeserver"); + } + + self.redraw(cx); + } + + /// Reset the registration screen to its initial state + /// This should be called when navigating away from the registration screen + pub fn reset_screen_state(&mut self, cx: &mut Cx) { + // Reset internal state + self.is_homeserver_editing = false; + self.selected_homeserver = "matrix.org".to_string(); + self.pending_custom_homeserver = false; + self.sso_pending = false; + self.sso_redirect_url = None; + self.set_registration_token_required(cx, false); + + // Reset homeserver selection UI + self.view + .view(cx, ids!(homeserver_options)) + .set_visible(cx, false); + self.view + .label(cx, ids!(selected_homeserver)) + .set_text(cx, "matrix.org"); + self.view + .view(cx, ids!(custom_homeserver)) + .set_visible(cx, false); + + // Reset homeserver option buttons + self.update_homeserver_option_buttons(cx); + + // Clear input fields + self.view.text_input(cx, ids!(username_input)).set_text(cx, ""); + self.view.text_input(cx, ids!(password_input)).set_text(cx, ""); + self.view + .text_input(cx, ids!(confirm_password_input)) + .set_text(cx, ""); + self.view + .text_input(cx, ids!(custom_homeserver_input)) + .set_text(cx, ""); + + // Reset button states + let register_button = self.view.button(cx, ids!(register_button)); + register_button.set_enabled(cx, true); + register_button.reset_hover(cx); + + let mut sso_button = self.view.button(cx, ids!(sso_button)); + sso_button.set_enabled(cx, true); + sso_button.reset_hover(cx); + self.update_button_mask(&mut sso_button, cx, 0.0); + + // Close any open modals + self.view.modal(cx, ids!(status_modal)).close(cx); + + // Update registration mode to show correct UI for matrix.org + self.update_registration_mode(cx); + + self.redraw(cx); + } +} + +impl Widget for RegisterScreen { + fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + self.view.handle_event(cx, event, scope); + self.match_event(cx, event); + } + + fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + self.view.draw_walk(cx, scope, walk) + } +} + +impl MatchEvent for RegisterScreen { + fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) { + let login_button = self.view.button(cx, ids!(login_button)); + let edit_button = self.view.button(cx, ids!(edit_button)); + let mut sso_button = self.view.button(cx, ids!(sso_button)); + + if login_button.clicked(actions) { + cx.action(RegisterAction::NavigateToLogin); + } + + // Handle Edit button click + if edit_button.clicked(actions) { + self.toggle_homeserver_options(cx); + } + + // Handle SSO button click for matrix.org + if sso_button.clicked(actions) && !self.sso_pending { + // Mark SSO as pending for this screen + self.sso_pending = true; + self.update_button_mask(&mut sso_button, cx, 1.0); + + // Show SSO registration modal immediately + self.view.label(cx, ids!(status_modal_inner.status)) + .set_text(cx, "Opening your browser...\n\nPlease complete registration in your browser, then return to Robrix."); + self.view.button(cx, ids!(status_modal_inner.cancel_button)) + .set_text(cx, "Cancel"); + self.view.modal(cx, ids!(status_modal)).open(cx); + self.redraw(cx); + + // Use the same SSO flow as login screen - spawn SSO server with Google provider + // This follows Element's implementation where SSO login and registration share the same OAuth flow + // The Matrix server will handle whether to create a new account or login existing user + submit_async_request(MatrixRequest::SpawnSSOServer { + identity_provider_id: "oidc-google".to_string(), + brand: "google".to_string(), + homeserver_url: String::new(), // Use default matrix.org + is_registration: true, + }); + } + + // Handle homeserver selection buttons + let matrix_option_button = self.view.button(cx, ids!(matrix_option)); + let other_option_button = self.view.button(cx, ids!(other_option)); + + if matrix_option_button.clicked(actions) { + self.selected_homeserver = "matrix.org".to_string(); + self.pending_custom_homeserver = false; + self.view + .label(cx, ids!(selected_homeserver)) + .set_text(cx, "matrix.org"); + self.view + .view(cx, ids!(custom_homeserver)) + .set_visible(cx, false); + + // Update button styles to show selection + self.update_homeserver_option_buttons(cx); + + self.is_homeserver_editing = false; + self.view + .view(cx, ids!(homeserver_options)) + .set_visible(cx, false); + self.update_registration_mode(cx); + } + + if other_option_button.clicked(actions) { + self.pending_custom_homeserver = true; + self.selected_homeserver.clear(); + self.view + .view(cx, ids!(custom_homeserver)) + .set_visible(cx, true); + self.view + .label(cx, ids!(selected_homeserver)) + .set_text(cx, "Custom homeserver (required)"); + + // Update button styles to show selection + self.update_homeserver_option_buttons(cx); + self.update_registration_mode(cx); + } + + // Handle custom homeserver input + if let Some(text_event) = self + .view + .text_input(cx, ids!(custom_homeserver_input)) + .changed(actions) + { + if let Some(normalized) = normalize_custom_homeserver(&text_event) { + self.selected_homeserver = normalized.clone(); + self.pending_custom_homeserver = false; + self.view + .label(cx, ids!(selected_homeserver)) + .set_text(cx, &normalized); + } else { + self.pending_custom_homeserver = true; + self.selected_homeserver.clear(); + self.view + .label(cx, ids!(selected_homeserver)) + .set_text(cx, "Custom homeserver (required)"); + } + + self.update_registration_mode(cx); + } + + // Handle password-based registration + let register_button = self.view.button(cx, ids!(register_button)); + let username_input = self.view.text_input(cx, ids!(username_input)); + let password_input = self.view.text_input(cx, ids!(password_input)); + let confirm_password_input = self.view.text_input(cx, ids!(confirm_password_input)); + let registration_token_input = self.view.text_input(cx, ids!(registration_token_input)); + + if register_button.clicked(actions) + || username_input.returned(actions).is_some() + || password_input.returned(actions).is_some() + || confirm_password_input.returned(actions).is_some() + || registration_token_input.returned(actions).is_some() + { + let raw_username = username_input.text(); + let password = password_input.text(); + let confirm_password = confirm_password_input.text(); + let registration_token = registration_token_input.text(); + + let username = match normalize_username(&raw_username) { + Ok(username) => username, + Err(error_msg) => { + self.show_warning(error_msg); + return; + } + }; + + if username != raw_username { + username_input.set_text(cx, &username); + } + + if password.is_empty() { + self.show_warning("Password is required"); + return; + } + + // Check password strength - minimum 8 characters + if !password_has_min_chars(&password, MIN_PASSWORD_CHARS) { + self.show_warning("Password must be at least 8 characters"); + return; + } + + if password != confirm_password { + self.show_warning("Passwords do not match"); + return; + } + + if needs_custom_homeserver_input( + self.pending_custom_homeserver, + &self.selected_homeserver, + ) { + self.show_warning("Please enter a valid custom homeserver URL"); + return; + } + + let homeserver = if self.selected_homeserver == "matrix.org" { + None + } else { + Some(self.selected_homeserver.clone()) + }; + + // Disable register button to prevent duplicate submissions + register_button.set_enabled(cx, false); + + // Show registration status modal with appropriate text for password registration + self.view.label(cx, ids!(status_modal_inner.status)) + .set_text(cx, "Registering account, please wait..."); + self.view.label(cx, ids!(status_modal_inner.title)) + .set_text(cx, "Registration Status"); + self.view.button(cx, ids!(status_modal_inner.cancel_button)) + .set_text(cx, "Cancel"); + self.view.modal(cx, ids!(status_modal)).open(cx); + self.redraw(cx); + + // Submit registration request + submit_async_request(MatrixRequest::Register(RegisterRequest { + username, + password, + homeserver, + registration_token: { + let token = registration_token.trim(); + if token.is_empty() { + None + } else { + Some(token.to_string()) + } + }, + })); + } + + // Handle modal closing for both success and failure in one place + for action in actions { + // Handle RegisterStatusModal close action + if let Some(RegisterStatusModalAction::Close { was_internal }) = + action.downcast_ref::() + { + if *was_internal { + self.view.modal(cx, ids!(status_modal)).close(cx); + } + if self.sso_pending { + // SSO flow canceled/dismissed - re-enable SSO button so user can retry + if let Some(sso_redirect_url) = &self.sso_redirect_url { + let request_id = id!(REGISTER_SSO_CANCEL_BUTTON); + let request = HttpRequest::new( + format!("{}/?login_token=", sso_redirect_url), + HttpMethod::GET, + ); + cx.http_request(request_id, request); + } + self.sso_pending = false; + self.sso_redirect_url = None; + self.update_button_mask(&mut sso_button, cx, 0.0); + } else { + // Password registration - reset register button + self.reset_modal_state(cx); + } + self.redraw(cx); + } + + // Handle SSO completion from login flow + // SSO success ultimately goes through the login flow, so we listen for LoginSuccess + if self.sso_pending { + if let Some(LoginAction::LoginSuccess) = action.downcast_ref::() { + // SSO registration successful - post action and let the handler below do cleanup + cx.action(RegisterAction::RegistrationSuccess); + } + } + + if let Some(reg_action) = action.downcast_ref::() { + match reg_action { + RegisterAction::SsoRegistrationPending(pending) => { + // Update pending state (modal already shown when button clicked) + if !*pending { + // SSO ended + self.sso_pending = false; + self.sso_redirect_url = None; + self.update_button_mask(&mut sso_button, cx, 0.0); + self.view.modal(cx, ids!(status_modal)).close(cx); + } + self.redraw(cx); + } + RegisterAction::SsoRegistrationStatus { status } => { + // Update SSO status in modal (only if our modal is already open) + if self.sso_pending { + self.view.label(cx, ids!(status_modal_inner.status)) + .set_text(cx, status); + self.view.button(cx, ids!(status_modal_inner.cancel_button)) + .set_text(cx, "Cancel"); + self.redraw(cx); + } + } + RegisterAction::RegistrationTokenRequired => { + if !self.registration_token_required { + self.set_registration_token_required(cx, true); + self.view + .text_input(cx, ids!(registration_token_input)) + .set_key_focus(cx); + } + } + RegisterAction::SsoSetRedirectUrl(url) => { + self.sso_redirect_url = Some(url.to_string()); + } + RegisterAction::RegistrationSuccess => { + // Close modal and let app.rs handle screen transition + self.view.modal(cx, ids!(status_modal)).close(cx); + if self.sso_pending { + self.sso_pending = false; + self.sso_redirect_url = None; + self.update_button_mask(&mut sso_button, cx, 0.0); + } + self.redraw(cx); + } + RegisterAction::RegistrationFailure(error) => { + // Show error and reset buttons + if self.sso_pending { + self.show_warning(error); + self.sso_pending = false; + self.sso_redirect_url = None; + self.update_button_mask(&mut sso_button, cx, 0.0); + } + self.view.modal(cx, ids!(status_modal)).close(cx); + self.reset_modal_state(cx); + } + _ => {} + } + } + } + } +} + +/// Actions for the registration screen. +/// +/// These actions handle both password-based and SSO registration flows. +/// SSO actions are completely independent from LoginAction to ensure +/// no interference between login and register screens. +#[derive(Clone, Default, Debug)] +pub enum RegisterAction { + /// User requested to go back to the login screen + NavigateToLogin, + /// Registration completed successfully (both password and SSO) + RegistrationSuccess, + /// Registration failed with error message (both password and SSO) + RegistrationFailure(String), + /// SSO registration state changed + /// - `true`: SSO flow started, button should be disabled + /// - `false`: SSO flow ended, button should be re-enabled + SsoRegistrationPending(bool), + /// SSO registration progress update (e.g., "Opening browser...") + SsoRegistrationStatus { + status: String, + }, + /// Set the SSO redirect URL for cancellation + SsoSetRedirectUrl(Url), + /// Password registration flow needs a server-provided token + RegistrationTokenRequired, + #[default] + None, +} + +impl RegisterScreenRef { + pub fn reset_screen_state(&self, cx: &mut Cx) { + if let Some(mut inner) = self.borrow_mut() { + inner.reset_screen_state(cx); + } + } +} diff --git a/src/register/register_status_modal.rs b/src/register/register_status_modal.rs new file mode 100644 index 000000000..5ea637a0d --- /dev/null +++ b/src/register/register_status_modal.rs @@ -0,0 +1,125 @@ +use makepad_widgets::*; + +script_mod! { + use mod.prelude.widgets.* + use mod.widgets.* + + + // A modal dialog that displays registration progress + // Note: Matrix SDK's register function has a built-in 60s timeout + mod.widgets.RegisterStatusModal = #(RegisterStatusModal::register_widget(vm)) { + width: Fit, + height: Fit, + align: Align{x: 0.5} + + RoundedView { + // Keep parity with LoginStatusModal width by averaging both legacy widths. + width: ((320+250)/2), + height: Fit, + flow: Down, + align: Align{x: 0.5} + padding: 25, + spacing: 10, + + show_bg: true + draw_bg +: { + color: #CCC + border_radius: 3.0 + } + + View { + width: Fill, + height: Fit, + flow: Right + padding: Inset{top: 0, bottom: 10} + align: Align{x: 0.5, y: 0.0} + + title := Label { + text: "Registration Status" + draw_text +: { + text_style: TITLE_TEXT {font_size: 13}, + color: #000 + } + } + } + + status := Label { + width: Fill + margin: Inset{top: 5, bottom: 5} + align: Align{x: 0.5, y: 0.0} + flow: Flow.Right{wrap: true} + draw_text +: { + text_style: REGULAR_TEXT { + font_size: 11.5, + }, + color: #000 + }, + text: "Registering account, please wait..." + } + + View { + width: Fill, + height: Fit, + flow: Right + align: Align{x: 1.0} + margin: Inset{top: 10} + + cancel_button := RobrixIconButton { + align: Align{x: 0.5, y: 0.5} + width: Fit, height: Fit + padding: 12 + text: "Cancel" + } + } + } + } +} + +#[derive(Script, ScriptHook, Widget)] +pub struct RegisterStatusModal { + #[deref] view: View, +} + + +impl Widget for RegisterStatusModal { + fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + self.view.handle_event(cx, event, scope); + self.match_event(cx, event); + } + + fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + self.view.draw_walk(cx, scope, walk) + } +} + +impl MatchEvent for RegisterStatusModal { + fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) { + // Check if modal was dismissed (ESC key or click outside) + let modal_dismissed = actions + .iter() + .any(|a| matches!(a.downcast_ref(), Some(ModalAction::Dismissed))); + + // Check if Abort button was clicked + let abort_clicked = self.view.button(cx, ids!(cancel_button)).clicked(actions); + + if abort_clicked || modal_dismissed { + // Send action to close the modal with appropriate was_internal flag + // Note: This doesn't actually cancel the registration request (still running in background) + cx.action(RegisterStatusModalAction::Close { + was_internal: abort_clicked + }); + } + } +} + +#[derive(Clone, Default, Debug)] +pub enum RegisterStatusModalAction { + /// The modal requested to be closed + Close { + /// Whether the modal was closed by clicking an internal button (Abort) + /// or being dismissed externally (ESC or click outside) + was_internal: bool, + }, + #[default] + None, +} diff --git a/src/register/validation.rs b/src/register/validation.rs new file mode 100644 index 000000000..2bc055709 --- /dev/null +++ b/src/register/validation.rs @@ -0,0 +1,108 @@ +use url::Url; + +pub(super) const MIN_PASSWORD_CHARS: usize = 8; + +pub(super) fn password_has_min_chars(password: &str, min: usize) -> bool { + password.chars().count() >= min +} + +pub(super) fn validate_username_localpart(raw: &str) -> Result<&str, &'static str> { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err("Username is required"); + } + + if trimmed.starts_with('@') || trimmed.contains(':') { + return Err("Use a username like alice (not a full Matrix ID)"); + } + + if trimmed + .chars() + .all(|c| matches!(c, 'a'..='z' | '0'..='9' | '.' | '_' | '=' | '/' | '-')) + { + Ok(trimmed) + } else { + Err("Username can only use lowercase letters, numbers, and . _ = / -") + } +} + +pub(super) fn normalize_username(raw: &str) -> Result { + validate_username_localpart(raw).map(ToOwned::to_owned) +} + +pub(super) fn normalize_custom_homeserver(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + + let normalized = if trimmed.contains("://") { + trimmed.to_string() + } else { + format!("https://{trimmed}") + }; + + let url = Url::parse(&normalized).ok()?; + matches!(url.scheme(), "http" | "https").then_some(normalized) +} + +pub(super) fn needs_custom_homeserver_input( + pending_custom_homeserver: bool, + selected_homeserver: &str, +) -> bool { + pending_custom_homeserver || selected_homeserver.trim().is_empty() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn username_validation_rejects_matrix_id_uppercase_and_spaces() { + assert!(validate_username_localpart("@Alice:matrix.org").is_err()); + assert!(validate_username_localpart("Alice").is_err()); + assert!(validate_username_localpart("alice user").is_err()); + } + + #[test] + fn username_validation_accepts_matrix_localpart_charset() { + assert!(validate_username_localpart("alice_123.-=/").is_ok()); + } + + #[test] + fn password_min_length_counts_unicode_chars_not_bytes() { + assert!(password_has_min_chars("密码安全1234", 8)); + assert!(!password_has_min_chars("密码12", 8)); + } + + #[test] + fn normalize_username_trims_and_rejects_empty() { + assert!(normalize_username(" ").is_err()); + assert_eq!(normalize_username(" alice ").unwrap(), "alice"); + } + + #[test] + fn normalize_custom_homeserver_adds_https_for_bare_domain() { + assert_eq!( + normalize_custom_homeserver("my-server.example").as_deref(), + Some("https://my-server.example"), + ); + } + + #[test] + fn normalize_custom_homeserver_rejects_non_http_schemes() { + assert_eq!(normalize_custom_homeserver("ftp://evil.com"), None); + assert_eq!(normalize_custom_homeserver("file:///etc/passwd"), None); + assert_eq!(normalize_custom_homeserver("javascript://alert(1)"), None); + // http and https are accepted + assert!(normalize_custom_homeserver("http://my-server.example").is_some()); + assert!(normalize_custom_homeserver("https://my-server.example").is_some()); + } + + #[test] + fn choosing_other_requires_explicit_custom_value_before_submit() { + assert!(needs_custom_homeserver_input(true, "matrix.org")); + assert!(needs_custom_homeserver_input(true, "")); + assert!(!needs_custom_homeserver_input(false, "https://my-server.example")); + } +} diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index 669eee440..56fe9c6d9 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -9,7 +9,7 @@ use makepad_widgets::{error, log, warning, Cx, SignalToUI}; use matrix_sdk_base::crypto::{DecryptionSettings, TrustRequirement}; use matrix_sdk::{ config::RequestConfig, encryption::EncryptionSettings, event_handler::EventHandlerDropGuard, media::MediaRequestParameters, room::{edit::EditedContent, reply::Reply, IncludeRelations, RelationsOptions, RoomMember}, ruma::{ - api::{Direction, client::{profile::{AvatarUrl, DisplayName}, receipt::create_receipt::v3::ReceiptType}}, events::{ + api::{Direction, client::{account::register::v3::Request as MatrixRegisterRequest, profile::{AvatarUrl, DisplayName}, receipt::create_receipt::v3::ReceiptType}}, events::{ relation::RelationType, room::{ message::RoomMessageEventContent, power_levels::RoomPowerLevels, MediaSource @@ -21,7 +21,7 @@ use matrix_sdk_ui::{ RoomListService, Timeline, encryption_sync_service, room_list_service::{RoomListItem, RoomListLoadingState, SyncIndicator, filters}, sync_service::{self, SyncService}, timeline::{LatestEventValue, RoomExt, TimelineEventItemId, TimelineFocus, TimelineItem, TimelineReadReceiptTracking, TimelineDetails} }; use robius_open::Uri; -use ruma::{OwnedRoomOrAliasId, RoomId, events::tag::Tags}; +use ruma::{OwnedRoomOrAliasId, RoomId, api::client::{error::ErrorKind, uiaa::{AuthData, AuthType, Dummy, RegistrationToken, UiaaInfo}}, events::tag::Tags}; use tokio::{ runtime::Handle, sync::{broadcast, mpsc::{Sender, UnboundedReceiver, UnboundedSender}, watch, Notify}, task::JoinHandle, time::error::Elapsed, @@ -36,7 +36,7 @@ use crate::{ }, login::login_screen::LoginAction, logout::{logout_confirm_modal::LogoutAction, logout_state_machine::{LogoutConfig, is_logout_in_progress, logout_with_state_machine}}, media_cache::{MediaCacheEntry, MediaCacheEntryRef}, persistence::{self, ClientSessionPersisted, load_app_state}, profile::{ user_profile::UserProfile, user_profile_cache::{UserProfileUpdate, enqueue_user_profile_update}, - }, room::{FetchedRoomAvatar, FetchedRoomPreview, RoomPreviewAction}, shared::{ + }, register::register_screen::RegisterAction, room::{FetchedRoomAvatar, FetchedRoomPreview, RoomPreviewAction}, shared::{ avatar::AvatarState, html_or_plaintext::MatrixLinkPillState, jump_to_bottom_button::UnreadMessageCount, popup_list::{PopupKind, enqueue_popup_notification} }, space_service_sync::space_service_loop, utils::{self, AVATAR_THUMBNAIL_FORMAT, RoomNameId, VecDiff, avatar_from_room_name}, verification::add_verification_event_handlers_and_sync_client }; @@ -94,6 +94,34 @@ impl From for Cli { } } +fn next_supported_uia_stage(uiaa_info: &UiaaInfo) -> Option { + fn is_stage_supported(stage: &AuthType) -> bool { + matches!(stage, AuthType::Dummy | AuthType::RegistrationToken) + } + + fn stage_completed(uiaa_info: &UiaaInfo, stage: &AuthType) -> bool { + uiaa_info.completed.iter().any(|completed| completed == stage) + } + + for flow in &uiaa_info.flows { + if flow.stages.iter().all(is_stage_supported) { + for stage in &flow.stages { + if !stage_completed(uiaa_info, stage) { + return Some(stage.clone()); + } + } + } + } + None +} + +#[derive(Debug)] +enum RegisterFlowError { + TokenRequired, + TokenInvalid, + UnsupportedUia, + Other(String), +} /// Build a new client. async fn build_client( @@ -222,6 +250,130 @@ async fn login( } } +/// Registers a new user on the given Matrix homeserver using the given username and password. +/// +/// This function handles the Matrix User-Interactive Authentication (UIA) flow automatically: +/// 1. First attempt: Send registration without auth to discover what authentication is required +/// 2. If server returns 401 with UIA info: +/// - Check what auth types are supported (dummy, registration_token) +/// - Automatically handle supported types (dummy + registration_token) +/// - Fail gracefully for unsupported types +/// 3. Upon success, returns the registered client and session information +/// +/// Supported UIA types: +/// - `m.login.dummy`: Simple acknowledgment, handled automatically +/// - `m.login.registration_token`: Requires pre-shared token from user (obtained out-of-band) +/// - Others (captcha, email, etc.): Not supported +async fn register_user(register_request: RegisterRequest) -> std::result::Result<(Client, ClientSessionPersisted), RegisterFlowError> { + // Only pass the homeserver to build_client; username/password are not needed there. + let cli = Cli { + homeserver: register_request.homeserver, + ..Default::default() + }; + + let (client, client_session) = build_client(&cli, app_data_dir()) + .await + .map_err(|e| RegisterFlowError::Other(e.to_string()))?; + + let mut req = MatrixRegisterRequest::new(); + req.username = Some(register_request.username.as_str().into()); + req.password = Some(register_request.password.as_str().into()); + req.initial_device_display_name = Some("robrix-register".into()); + + let sanitized_registration_token = register_request + .registration_token + .as_deref() + .map(|token| token.trim()) + .filter(|token| !token.is_empty()) + .map(|token| token.to_string()); + + // Attempt initial registration + let register_result = client.matrix_auth().register(req.clone()).await; + + // Handle registration result + match register_result { + Ok(_) => { + // Registration succeeded without UIA (e.g., open registration) + log!("Registration succeeded without UIA"); + } + Err(e) => { + // Check if it's a UIA error that we can handle + if let Some(mut uiaa_info) = e.as_uiaa_response().cloned() { + loop { + let Some(next_stage) = next_supported_uia_stage(&uiaa_info) else { + return Err(RegisterFlowError::UnsupportedUia); + }; + + match next_stage { + AuthType::RegistrationToken => { + let Some(token) = sanitized_registration_token.as_ref() else { + Cx::post_action(RegisterAction::RegistrationTokenRequired); + return Err(RegisterFlowError::TokenRequired); + }; + let mut auth_data = RegistrationToken::new(token.clone()); + auth_data.session = uiaa_info.session.clone(); + req.auth = Some(AuthData::RegistrationToken(auth_data)); + } + AuthType::Dummy => { + let mut dummy = Dummy::new(); + dummy.session = uiaa_info.session.clone(); + req.auth = Some(AuthData::Dummy(dummy)); + } + _ => { + return Err(RegisterFlowError::UnsupportedUia); + } + } + + match client.matrix_auth().register(req.clone()).await { + Ok(_) => break, + Err(err) => { + // Try structured error detection first via the UIAA auth_error field. + let err_string = err.to_string(); + let is_token_error = err.as_uiaa_response() + .and_then(|info| info.auth_error.as_ref()) + .is_some_and(|e| matches!(e.kind, ErrorKind::InvalidParam | ErrorKind::Unauthorized)) + // Fallback to string matching for servers that don't use standard error codes. + || err_string.contains("Invalid registration token") + || err_string.contains("M_INVALID_PARAM"); + + if is_token_error { + return Err(RegisterFlowError::TokenInvalid); + } + + if let Some(next_info) = err.as_uiaa_response().cloned() { + uiaa_info = next_info; + continue; + } else { + return Err(RegisterFlowError::Other(err_string)); + } + } + } + } + } else { + // Other registration errors + return Err(RegisterFlowError::Other(e.to_string())); + } + } + } + + // Check if the client is logged in after registration + // The Matrix SDK automatically logs in the user upon successful registration + // This check confirms that the registration was successful and the session is established + if client.matrix_auth().logged_in() { + log!("Registration successful for user: {}", register_request.username); + let status = format!("Registered as {}.\n → Loading rooms...", register_request.username); + enqueue_rooms_list_update(RoomsListUpdate::Status { status }); + + // Session persistence is deferred: after registration we post LoginBySSOSuccess, + // which saves the session (avoids double-saving here). + Ok((client, client_session)) + } else { + let err_msg = "Registration completed but user is not logged in".to_string(); + enqueue_popup_notification(err_msg.clone(), PopupKind::Error, None); + Err(RegisterFlowError::Other(err_msg)) + } +} + /// Which direction to paginate in. /// @@ -379,6 +531,8 @@ impl std::fmt::Display for TimelineKind { pub enum MatrixRequest { /// Request from the login screen to log in with the given credentials. Login(LoginRequest), + /// Request from the register screen to create a new account with the given credentials. + Register(RegisterRequest), /// Request to logout. Logout { is_desktop: bool, @@ -590,6 +744,8 @@ pub enum MatrixRequest { brand: String, homeserver_url: String, identity_provider_id: String, + /// Whether this SSO request is for registration (true) or login (false) + is_registration: bool, }, /// Subscribe to typing notices for the given room. /// @@ -684,7 +840,6 @@ pub enum LoginRequest{ LoginBySSOSuccess(Client, ClientSessionPersisted), LoginByCli, HomeserverLoginTypesQuery(String), - } /// Information needed to log in to a Matrix homeserver. pub struct LoginByPassword { @@ -693,6 +848,15 @@ pub struct LoginByPassword { pub homeserver: Option, } +/// Information needed to register a new user on a Matrix homeserver. +pub struct RegisterRequest { + pub username: String, + pub password: String, + pub homeserver: Option, + /// Registration token if required by the server (configured in server's config) + pub registration_token: Option, +} + /// The entry point for the worker task that runs Matrix-related operations. /// @@ -719,6 +883,62 @@ async fn matrix_worker_task( } } + MatrixRequest::Register(register_request) => { + // Handle registration in a spawned task + let rt = Handle::current(); + rt.spawn(async move { + match register_user(register_request).await { + Ok((client, client_session)) => { + // Send RegistrationSuccess action to close the registration modal + Cx::post_action(RegisterAction::RegistrationSuccess); + + // After successful registration, we need to initialize all the global state + // (CLIENT, SLIDING_SYNC, room lists, etc.) which is handled by the main login flow. + // + // We use LoginBySSOSuccess (not LoginByPassword) because: + // 1. Registration already authenticated us - we have a valid client and session + // 2. We don't need to login again (which LoginByPassword would do) + // 3. LoginBySSOSuccess simply passes through the already-authenticated client + // without making any network requests - it only saves the session locally + // + // The name is misleading - it's really "UseAuthenticatedClient" that works for + // any pre-authenticated client (SSO, registration, restored session, etc.) + let login_req = LoginRequest::LoginBySSOSuccess(client, client_session); + submit_async_request(MatrixRequest::Login(login_req)); + } + Err(e) => { + let (error_msg, popup_kind, show_popup) = match e { + RegisterFlowError::TokenRequired => ( + String::from("Registration token required. Please enter your token and try again."), + PopupKind::Warning, + false, // Token input field appearing is sufficient feedback + ), + RegisterFlowError::TokenInvalid => ( + String::from("Invalid registration token. Please check the token and try again."), + PopupKind::Error, + true, + ), + RegisterFlowError::UnsupportedUia => ( + String::from("This server requires verification steps that are not yet supported. Please use the web client to register."), + PopupKind::Error, + true, + ), + RegisterFlowError::Other(msg) => ( + format!("Registration failed: {msg}"), + PopupKind::Error, + true, + ), + }; + error!("{error_msg}"); + if show_popup { + enqueue_popup_notification(error_msg.clone(), popup_kind, None); + } + Cx::post_action(RegisterAction::RegistrationFailure(error_msg)); + } + } + }); + } + MatrixRequest::Logout { is_desktop } => { log!("Received MatrixRequest::Logout, is_desktop: {}", is_desktop); let _logout_task = Handle::current().spawn(async move { @@ -1554,8 +1774,8 @@ async fn matrix_worker_task( subscribers_pinned_events.insert(room_id, subscribe_pinned_events_task); } - MatrixRequest::SpawnSSOServer { brand, homeserver_url, identity_provider_id} => { - spawn_sso_server(brand, homeserver_url, identity_provider_id, login_sender.clone()).await; + MatrixRequest::SpawnSSOServer { brand, homeserver_url, identity_provider_id, is_registration } => { + spawn_sso_server(brand, homeserver_url, identity_provider_id, is_registration, login_sender.clone()).await; } MatrixRequest::ResolveRoomAlias(room_alias) => { @@ -4021,14 +4241,23 @@ async fn spawn_sso_server( brand: String, homeserver_url: String, identity_provider_id: String, + is_registration: bool, login_sender: Sender, ) { - Cx::post_action(LoginAction::SsoPending(true)); - // Post a status update to inform the user that we're waiting for the client to be built. - Cx::post_action(LoginAction::Status { - title: "Initializing client...".into(), - status: "Please wait while Matrix builds and configures the client object for login.".into(), - }); + // Post different actions based on whether it's registration or login + if is_registration { + Cx::post_action(RegisterAction::SsoRegistrationPending(true)); + Cx::post_action(RegisterAction::SsoRegistrationStatus { + status: "Please wait while Matrix builds and configures the client object for registration.".into(), + }); + } else { + Cx::post_action(LoginAction::SsoPending(true)); + // Post a status update to inform the user that we're waiting for the client to be built. + Cx::post_action(LoginAction::Status { + title: "Initializing client...".into(), + status: "Please wait while Matrix builds and configures the client object for login.".into(), + }); + } // Wait for the notification that the client has been built DEFAULT_SSO_CLIENT_NOTIFIER.notified().await; @@ -4040,6 +4269,30 @@ async fn spawn_sso_server( let client_and_session_opt = DEFAULT_SSO_CLIENT.lock().unwrap().take(); Handle::current().spawn(async move { + // Helper closures to dispatch the correct action type based on whether + // this SSO flow is for registration or login. + let post_pending = |pending: bool| { + if is_registration { + Cx::post_action(RegisterAction::SsoRegistrationPending(pending)); + } else { + Cx::post_action(LoginAction::SsoPending(pending)); + } + }; + let post_status = |title: &str, status: String| { + if is_registration { + Cx::post_action(RegisterAction::SsoRegistrationStatus { status }); + } else { + Cx::post_action(LoginAction::Status { title: title.to_string(), status }); + } + }; + let post_failure = |msg: String| { + if is_registration { + Cx::post_action(RegisterAction::RegistrationFailure(msg)); + } else { + Cx::post_action(LoginAction::LoginFailure(msg)); + } + }; + // Try to use the DEFAULT_SSO_CLIENT that we proactively created // during initialization (to speed up opening the SSO browser window). let mut client_and_session = client_and_session_opt; @@ -4067,39 +4320,52 @@ async fn spawn_sso_server( } let Some((client, client_session)) = client_and_session else { - Cx::post_action(LoginAction::LoginFailure( + let action = if is_registration { "register" } else { "login" }; + post_failure( if let Some(err) = build_client_error { - format!("Could not create client object. Please try to login again.\n\nError: {err}") + format!("Could not create client object. Please try to {action} again.\n\nError: {err}") } else { - String::from("Could not create client object. Please try to login again.") + format!("Could not create client object. Please try to {action} again.") } - )); + ); // This ensures that the called to `DEFAULT_SSO_CLIENT_NOTIFIER.notified()` // at the top of this function will not block upon the next login attempt. DEFAULT_SSO_CLIENT_NOTIFIER.notify_one(); - Cx::post_action(LoginAction::SsoPending(false)); + post_pending(false); return; }; let mut is_logged_in = false; - Cx::post_action(LoginAction::Status { - title: "Opening your browser...".into(), - status: "Please finish logging in using your browser, and then come back to Robrix.".into(), - }); + post_status( + "Opening your browser...", + if is_registration { + "Please finish registration using your browser, and then come back to Robrix.".into() + } else { + "Please finish logging in using your browser, and then come back to Robrix.".into() + } + ); + let is_registration_for_sso = is_registration; match client .matrix_auth() - .login_sso(|sso_url: String| async move { - let url = Url::parse(&sso_url)?; - for (key, value) in url.query_pairs() { - if key == "redirectUrl" { - let redirect_url = Url::parse(&value)?; - Cx::post_action(LoginAction::SsoSetRedirectUrl(redirect_url)); - break + .login_sso(move |sso_url: String| { + let is_registration = is_registration_for_sso; + async move { + let url = Url::parse(&sso_url)?; + for (key, value) in url.query_pairs() { + if key == "redirectUrl" { + let redirect_url = Url::parse(&value)?; + if is_registration { + Cx::post_action(RegisterAction::SsoSetRedirectUrl(redirect_url)); + } else { + Cx::post_action(LoginAction::SsoSetRedirectUrl(redirect_url)); + } + break + } } + Uri::new(&sso_url).open().map_err(|err| + Error::Io(io::Error::other(format!("Unable to open SSO login url. Error: {:?}", err))) + ) } - Uri::new(&sso_url).open().map_err(|err| - Error::Io(io::Error::other(format!("Unable to open SSO login url. Error: {:?}", err))) - ) }) .identity_provider_id(&identity_provider_id) .initial_device_display_name(&format!("robrix-sso-{brand}")) @@ -4116,9 +4382,9 @@ async fn spawn_sso_server( if !is_logged_in { if let Err(e) = login_sender.send(LoginRequest::LoginBySSOSuccess(client, client_session)).await { error!("Error sending login request to login_sender: {e:?}"); - Cx::post_action(LoginAction::LoginFailure(String::from( + post_failure(String::from( "BUG: failed to send login request to matrix worker thread." - ))); + )); } enqueue_rooms_list_update(RoomsListUpdate::Status { status: format!( @@ -4131,7 +4397,8 @@ async fn spawn_sso_server( Err(e) => { if !is_logged_in { error!("SSO Login failed: {e:?}"); - Cx::post_action(LoginAction::LoginFailure(format!("SSO login failed: {e}"))); + let action = if is_registration { "registration" } else { "login" }; + post_failure(format!("SSO {action} failed: {e}")); } } } @@ -4139,7 +4406,7 @@ async fn spawn_sso_server( // This ensures that the called to `DEFAULT_SSO_CLIENT_NOTIFIER.notified()` // at the top of this function will not block upon the next login attempt. DEFAULT_SSO_CLIENT_NOTIFIER.notify_one(); - Cx::post_action(LoginAction::SsoPending(false)); + post_pending(false); }); }