diff --git a/src/app.rs b/src/app.rs index 910787d4..bb3f4b8d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -16,7 +16,7 @@ use crate::{ event_source_modal::{EventSourceModalAction, EventSourceModalWidgetRefExt}, invite_modal::{InviteModalAction, InviteModalWidgetRefExt, mark_invite_modal_closed}, invite_screen::{InviteScreenWidgetRefExt, LeaveRoomResultAction}, main_desktop_ui::MainDesktopUiAction, navigation_tab_bar::{NavigationBarAction, SelectedTab}, new_message_context_menu::NewMessageContextMenuWidgetRefExt, room_context_menu::RoomContextMenuWidgetRefExt, room_screen::{InviteAction, MessageAction, RoomScreenWidgetRefExt, TimelineUpdate, clear_timeline_states}, rooms_list::{RoomsListAction, RoomsListRef, RoomsListUpdate, clear_all_invited_rooms, enqueue_rooms_list_update}, rooms_list_header::RoomsListHeaderAction, space_lobby::SpaceLobbyScreenWidgetRefExt, spaces_bar::SpacesBarRef }, i18n::{AppLanguage, tr_fmt, tr_key}, join_leave_room_modal::{ JoinLeaveModalKind, JoinLeaveRoomModalAction, JoinLeaveRoomModalWidgetRefExt - }, login::login_screen::LoginAction, logout::logout_confirm_modal::{LogoutAction, LogoutConfirmModalAction, LogoutConfirmModalWidgetRefExt}, persistence, profile::{user_profile::UserProfile, user_profile_cache::clear_user_profile_cache}, room::{BasicRoomDetails, FetchedRoomAvatar}, shared::{avatar::{AvatarState, AvatarWidgetRefExt}, confirmation_modal::{ConfirmationModalContent, ConfirmationModalWidgetRefExt}, file_upload_modal::{FilePreviewerAction, FileUploadModalWidgetRefExt}, image_viewer::{ImageViewerAction, LoadState}, popup_list::{PopupKind, enqueue_popup_notification}, room_filter_input_bar::FilterAction}, sliding_sync::{DirectMessageRoomAction, MatrixRequest, RemoteDirectorySearchKind, RemoteDirectorySearchResult, TimelineKind, AccountSwitchAction, current_user_id, get_client, submit_async_request, get_timeline_update_sender}, utils::RoomNameId, verification::VerificationAction, verification_modal::{ + }, login::login_screen::LoginAction, logout::logout_confirm_modal::{LogoutAction, LogoutConfirmModalAction, LogoutConfirmModalWidgetRefExt}, persistence, profile::{user_profile::UserProfile, user_profile_cache::clear_user_profile_cache}, register::RegisterAction, room::{BasicRoomDetails, FetchedRoomAvatar}, shared::{avatar::{AvatarState, AvatarWidgetRefExt}, confirmation_modal::{ConfirmationModalContent, ConfirmationModalWidgetRefExt}, file_upload_modal::{FilePreviewerAction, FileUploadModalWidgetRefExt}, image_viewer::{ImageViewerAction, LoadState}, popup_list::{PopupKind, enqueue_popup_notification}, room_filter_input_bar::FilterAction}, sliding_sync::{DirectMessageRoomAction, MatrixRequest, RemoteDirectorySearchKind, RemoteDirectorySearchResult, TimelineKind, AccountSwitchAction, current_user_id, get_client, submit_async_request, get_timeline_update_sender}, utils::RoomNameId, verification::VerificationAction, verification_modal::{ VerificationModalAction, VerificationModalWidgetRefExt, } @@ -126,6 +126,11 @@ script_mod! { login_screen := LoginScreen {} } + register_screen_view := View { + visible: false + register_screen := RegisterScreen {} + } + image_viewer_modal := Modal { content +: { width: Fill, height: Fill, @@ -792,6 +797,20 @@ impl MatchEvent for App { _ => {} } + if let Some(LoginAction::NavigateToRegister) = action.downcast_ref() { + 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; + } + + if let Some(RegisterAction::NavigateToLogin) = action.downcast_ref() { + 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); + continue; + } + if let Some(LoginAction::ShowLoginScreen) = action.downcast_ref() { if !self.app_state.adding_account { self.app_state.logged_in = false; @@ -1528,6 +1547,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) diff --git a/src/lib.rs b/src/lib.rs index bbee1b28..497b7ebe 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -47,6 +47,8 @@ pub mod i18n; /// Login screen pub mod login; +/// Account registration flow +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 3e827848..87a82833 100644 --- a/src/login/login_screen.rs +++ b/src/login/login_screen.rs @@ -3,7 +3,7 @@ use std::ops::Not; use makepad_widgets::*; use url::Url; -use crate::{app::AppState, i18n::{AppLanguage, tr_fmt, tr_key}, sliding_sync::{submit_async_request, AccountSwitchAction, LoginByPassword, LoginRequest, MatrixRequest, RegisterAccount}}; +use crate::{app::AppState, i18n::{AppLanguage, tr_fmt, tr_key}, sliding_sync::{submit_async_request, AccountSwitchAction, LoginByPassword, LoginRequest, MatrixRequest}}; use super::login_status_modal::{LoginStatusModalAction, LoginStatusModalWidgetExt}; @@ -162,19 +162,6 @@ script_mod! { } } - confirm_password_wrapper := View { - width: 275, height: Fit, - visible: false, - - confirm_password_input := RobrixTextInput { - width: 275, height: Fit - flow: Right, // do not wrap - padding: 10, - empty_text: "Confirm password" - is_password: true, - } - } - View { width: 275, height: Fit, flow: Down, @@ -222,61 +209,54 @@ script_mod! { text: "Login" } - login_only_view := View { - width: Fit, height: Fit, - flow: Down, - align: Align{x: 0.5, y: 0.5} - spacing: 15.0 + LineH { + width: 275 + margin: Inset{bottom: -5} + draw_bg.color: #C8C8C8 + } - LineH { - width: 275 - margin: Inset{bottom: -5} - draw_bg.color: #C8C8C8 + sso_prompt_label := Label { + width: Fit, height: Fit + padding: 0, + draw_text +: { + color: (COLOR_TEXT) + text_style: TITLE_TEXT {font_size: 11.0} } + text: "Or, login with an SSO provider:" + } - sso_prompt_label := Label { - width: Fit, height: Fit - padding: 0, - draw_text +: { - color: (COLOR_TEXT) - text_style: TITLE_TEXT {font_size: 11.0} + sso_view := View { + width: 275, height: Fit, + margin: Inset{left: 30, right: 5} // make the inner view 240 pixels wide + flow: Flow.Right{wrap: true}, + apple_button := mod.widgets.SsoButton { + image := mod.widgets.SsoImage { + src: crate_resource("self://resources/img/apple.png") } - text: "Or, login with an SSO provider:" } - - sso_view := View { - width: 275, height: Fit, - margin: Inset{left: 30, right: 5} // make the inner view 240 pixels wide - flow: Flow.Right{wrap: true}, - apple_button := mod.widgets.SsoButton { - image := mod.widgets.SsoImage { - src: crate_resource("self://resources/img/apple.png") - } - } - facebook_button := mod.widgets.SsoButton { - image := mod.widgets.SsoImage { - src: crate_resource("self://resources/img/facebook.png") - } + facebook_button := mod.widgets.SsoButton { + image := mod.widgets.SsoImage { + src: crate_resource("self://resources/img/facebook.png") } - github_button := mod.widgets.SsoButton { - image := mod.widgets.SsoImage { - src: crate_resource("self://resources/img/github.png") - } + } + github_button := mod.widgets.SsoButton { + image := mod.widgets.SsoImage { + src: crate_resource("self://resources/img/github.png") } - gitlab_button := mod.widgets.SsoButton { - image := mod.widgets.SsoImage { - src: crate_resource("self://resources/img/gitlab.png") - } + } + gitlab_button := mod.widgets.SsoButton { + image := mod.widgets.SsoImage { + src: crate_resource("self://resources/img/gitlab.png") } - google_button := mod.widgets.SsoButton { - image := mod.widgets.SsoImage { - src: crate_resource("self://resources/img/google.png") - } + } + google_button := mod.widgets.SsoButton { + image := mod.widgets.SsoImage { + src: crate_resource("self://resources/img/google.png") } - twitter_button := mod.widgets.SsoButton { - image := mod.widgets.SsoImage { - src: crate_resource("self://resources/img/x.png") - } + } + twitter_button := mod.widgets.SsoButton { + image := mod.widgets.SsoImage { + src: crate_resource("self://resources/img/x.png") } } } @@ -583,8 +563,6 @@ script_mod! { pub struct LoginScreen { #[source] source: ScriptObjectRef, #[deref] view: View, - /// Whether the screen is showing the in-app sign-up flow. - #[rust] signup_mode: bool, /// Whether the password field is currently showing plaintext. #[rust] password_visible: bool, /// Boolean to indicate if the SSO login process is still in flight @@ -637,45 +615,12 @@ impl LoginScreen { self.set_sso_pending_state(cx, false); } - fn sync_mode_texts(&mut self, cx: &mut Cx) { - self.view.label(cx, ids!(title)).set_text(cx, - if self.signup_mode { - tr_key(self.app_language, "login.title.create_account") - } else { - tr_key(self.app_language, "login.title.login_to_robrix") - } - ); - self.view.button(cx, ids!(login_button)).set_text(cx, - if self.signup_mode { - tr_key(self.app_language, "login.button.create_account") - } else { - tr_key(self.app_language, "login.button.login") - } - ); - self.view.label(cx, ids!(account_prompt_label)).set_text(cx, - if self.signup_mode { - tr_key(self.app_language, "login.account_prompt.already_have") - } else { - tr_key(self.app_language, "login.account_prompt.no_account") - } - ); - self.view.button(cx, ids!(mode_toggle_button)).set_text(cx, - if self.signup_mode { - tr_key(self.app_language, "login.mode_toggle.back_to_login") - } else { - tr_key(self.app_language, "login.mode_toggle.sign_up_here") - } - ); - } - fn set_app_language(&mut self, cx: &mut Cx, app_language: AppLanguage) { self.app_language = app_language; self.view.text_input(cx, ids!(user_id_input)) .set_empty_text(cx, tr_key(self.app_language, "login.input.user_id").to_string()); self.view.text_input(cx, ids!(password_input)) .set_empty_text(cx, tr_key(self.app_language, "login.input.password").to_string()); - self.view.text_input(cx, ids!(confirm_password_input)) - .set_empty_text(cx, tr_key(self.app_language, "login.input.confirm_password").to_string()); self.view.text_input(cx, ids!(homeserver_input)) .set_empty_text(cx, tr_key(self.app_language, "login.input.homeserver").to_string()); self.view.text_input(cx, ids!(proxy_address_input)) @@ -707,7 +652,12 @@ impl LoginScreen { let login_status_modal_inner = self.view.login_status_modal(cx, ids!(login_status_modal_inner)); login_status_modal_inner.set_title(cx, tr_key(self.app_language, "login_status_modal.title")); login_status_modal_inner.button_ref(cx).set_text(cx, tr_key(self.app_language, "login_status_modal.button.cancel")); - self.sync_mode_texts(cx); + self.view.label(cx, ids!(title)) + .set_text(cx, tr_key(self.app_language, "login.title.login_to_robrix")); + self.view.button(cx, ids!(login_button)) + .set_text(cx, tr_key(self.app_language, "login.button.login")); + self.view.label(cx, ids!(account_prompt_label)) + .set_text(cx, tr_key(self.app_language, "login.account_prompt.no_account")); } fn set_use_proxy_enabled(&mut self, cx: &mut Cx, enabled: bool) { @@ -825,18 +775,6 @@ impl LoginScreen { Ok(Some(proxy_url)) } - fn set_signup_mode(&mut self, cx: &mut Cx, signup_mode: bool) { - self.signup_mode = signup_mode; - self.view.view(cx, ids!(confirm_password_wrapper)).set_visible(cx, signup_mode); - self.view.view(cx, ids!(login_only_view)).set_visible(cx, !signup_mode); - self.sync_mode_texts(cx); - - if !signup_mode { - self.view.text_input(cx, ids!(confirm_password_input)).set_text(cx, ""); - } - - self.redraw(cx); - } } impl ScriptHook for LoginScreen { @@ -883,7 +821,6 @@ impl WidgetMatchEvent for LoginScreen { let cancel_button = self.view.button(cx, ids!(cancel_button)); let user_id_input = self.view.text_input(cx, ids!(user_id_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 homeserver_input = self.view.text_input(cx, ids!(homeserver_input)); let login_status_modal = self.view.modal(cx, ids!(login_status_modal)); @@ -964,18 +901,16 @@ impl WidgetMatchEvent for LoginScreen { } if mode_toggle_button.clicked(actions) { - self.set_signup_mode(cx, !self.signup_mode); + Cx::post_action(LoginAction::NavigateToRegister); } if login_button.clicked(actions) || user_id_input.returned(actions).is_some() || password_input.returned(actions).is_some() - || (self.signup_mode && confirm_password_input.returned(actions).is_some()) || homeserver_input.returned(actions).is_some() { let user_id = user_id_input.text().trim().to_owned(); let password = password_input.text(); - let confirm_password = confirm_password_input.text(); let homeserver = homeserver_input.text().trim().to_owned(); if user_id.is_empty() { login_status_modal_inner.set_title(cx, tr_key(self.app_language, "login.status.missing_user_id.title")); @@ -985,10 +920,6 @@ impl WidgetMatchEvent for LoginScreen { login_status_modal_inner.set_title(cx, tr_key(self.app_language, "login.status.missing_password.title")); login_status_modal_inner.set_status(cx, tr_key(self.app_language, "login.status.missing_password.body")); login_status_modal_inner.button_ref(cx).set_text(cx, tr_key(self.app_language, "login.status.okay")); - } else if self.signup_mode && password != confirm_password { - login_status_modal_inner.set_title(cx, tr_key(self.app_language, "login.status.password_mismatch.title")); - login_status_modal_inner.set_status(cx, tr_key(self.app_language, "login.status.password_mismatch.body")); - login_status_modal_inner.button_ref(cx).set_text(cx, tr_key(self.app_language, "login.status.okay")); } else { let proxy = match self.build_proxy_url_from_form(cx) { Ok(proxy) => proxy, @@ -1008,36 +939,19 @@ impl WidgetMatchEvent for LoginScreen { warning!("Failed to persist proxy configuration from login screen: {e}"); } self.last_failure_message_shown = None; - login_status_modal_inner.set_title(cx, if self.signup_mode { - tr_key(self.app_language, "login.status.creating_account.title") - } else { - tr_key(self.app_language, "login.status.logging_in.title") - }); + login_status_modal_inner.set_title(cx, tr_key(self.app_language, "login.status.logging_in.title")); login_status_modal_inner.set_status( cx, - if self.signup_mode { - tr_key(self.app_language, "login.status.creating_account.body") - } else { - tr_key(self.app_language, "login.status.logging_in.body") - }, + tr_key(self.app_language, "login.status.logging_in.body"), ); login_status_modal_inner.button_ref(cx).set_text(cx, tr_key(self.app_language, "login.status.cancel")); - submit_async_request(MatrixRequest::Login(if self.signup_mode { - LoginRequest::Register(RegisterAccount { - user_id, - password, - homeserver: homeserver.is_empty().not().then_some(homeserver), - proxy: proxy.clone(), - }) - } else { - LoginRequest::LoginByPassword(LoginByPassword { - user_id, - password, - homeserver: homeserver.is_empty().not().then_some(homeserver), - proxy: proxy.clone(), - is_add_account: self.adding_account, - }) - })); + submit_async_request(MatrixRequest::Login(LoginRequest::LoginByPassword(LoginByPassword { + user_id, + password, + homeserver: homeserver.is_empty().not().then_some(homeserver), + proxy: proxy.clone(), + is_add_account: self.adding_account, + }))); } login_status_modal.open(cx); self.redraw(cx); @@ -1090,11 +1004,9 @@ impl WidgetMatchEvent for LoginScreen { // The main `App` component handles showing the main screen // and hiding the login screen & login status modal. self.last_failure_message_shown = None; - self.set_signup_mode(cx, false); self.adding_account = false; user_id_input.set_text(cx, ""); password_input.set_text(cx, ""); - confirm_password_input.set_text(cx, ""); homeserver_input.set_text(cx, ""); // Reset title and buttons in case we were in add-account mode self.view.label(cx, ids!(title)).set_text(cx, tr_key(self.app_language, "login.title.login_to_robrix")); @@ -1108,11 +1020,7 @@ impl WidgetMatchEvent for LoginScreen { continue; } self.last_failure_message_shown = Some(error.clone()); - login_status_modal_inner.set_title(cx, if self.signup_mode { - tr_key(self.app_language, "login.status.account_creation_failed") - } else { - tr_key(self.app_language, "login.status.login_failed") - }); + login_status_modal_inner.set_title(cx, tr_key(self.app_language, "login.status.login_failed")); login_status_modal_inner.set_status(cx, error); let login_status_modal_button = login_status_modal_inner.button_ref(cx); login_status_modal_button.set_text(cx, tr_key(self.app_language, "login.status.okay")); @@ -1257,6 +1165,9 @@ pub enum LoginAction { /// Request to show the login screen in "add account" mode. /// This is used when the user wants to add another Matrix account. ShowAddAccountScreen, + /// User clicked "Sign up here"; the main App should hide the + /// login screen and show RegisterScreen. + NavigateToRegister, /// Request to cancel adding an account and return to the previous screen. CancelAddAccount, #[default] diff --git a/src/register/mod.rs b/src/register/mod.rs new file mode 100644 index 00000000..64f51151 --- /dev/null +++ b/src/register/mod.rs @@ -0,0 +1,104 @@ +//! Account registration feature. +//! +//! Covers the dual-mode register flow (OIDC for MAS-delegated servers, +//! UIAA wizard for legacy servers). See `specs/task-register-flow.spec.md`. + +use makepad_widgets::ScriptVm; + +pub mod register_screen; +pub mod register_status_modal; +pub mod validation; + +pub fn script_mod(vm: &mut ScriptVm) { + register_status_modal::script_mod(vm); + register_screen::script_mod(vm); +} + +use matrix_sdk::ruma::api::client::uiaa::UiaaInfo; +use makepad_widgets::ActionDefaultRef; + +/// Homeserver capabilities discovered before register branching. +#[derive(Clone, Debug)] +pub struct HsCapabilities { + /// Normalized base URL the client will use. + pub base_url: String, + /// True iff the server advertises `m.authentication.issuer` in + /// `.well-known/matrix/client` (MSC2965 / MAS delegation). + pub is_mas_native_oidc: bool, + /// True iff `POST /_matrix/client/v3/register` with empty body returns + /// 401 with parseable UIAA flows (NOT 403 M_FORBIDDEN). + pub registration_enabled: bool, + /// Optional UIAA probe result (empty when server requires MAS). + pub uiaa_probe: Option, + /// Identity providers harvested from `/_matrix/client/v3/login`. + /// Phase 1 populates but does not render; Phase 4 renders buttons. + pub sso_providers: Vec, + + /// URL to open in the system browser for MAS self-registration. + /// Derived as `/register` when a MAS issuer is discovered in + /// `.well-known` `m.authentication` (stable) or + /// `org.matrix.msc2965.authentication` (unstable). None for non-MAS + /// servers. Intentionally does NOT use MSC2965's `account` field — + /// that URL is for logged-in account management and loops when + /// opened unauthenticated. + pub mas_signup_url: Option, +} + +/// Minimal info per identity provider. Full matrix-sdk type is not +/// used because we only need name + id at this phase. +#[derive(Clone, Debug)] +pub struct IdentityProviderSummary { + pub id: String, + pub name: String, + pub icon_url: Option, +} + +/// Outcome classification of capability discovery. +/// +/// Derived from `HsCapabilities` by `mode()` below; used for UI display. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RegisterMode { + /// Server advertises MAS OAuth; register goes through browser. + MasWebOnly, + /// Server supports direct UIAA register. + Uiaa, + /// Server explicitly disallows registration. + Disabled, +} + +impl HsCapabilities { + /// Produce a human-routable mode. Follows element-web `Registration.tsx`: + /// MAS presence wins over UIAA even when both are possible. + pub fn mode(&self) -> RegisterMode { + if self.is_mas_native_oidc { + RegisterMode::MasWebOnly + } else if self.registration_enabled { + RegisterMode::Uiaa + } else { + RegisterMode::Disabled + } + } +} + +/// Actions produced by or consumed by the register feature. +/// +/// `Cx::post_action(RegisterAction::*)` from any widget; handled by `App` and +/// by `RegisterScreen`. +#[derive(Clone, Debug, Default)] +pub enum RegisterAction { + /// User clicked the back button on RegisterScreen. + NavigateToLogin, + /// Sliding-sync reports the result of capability discovery. + CapabilitiesDiscovered(HsCapabilities), + /// Capability discovery failed (network error, bad URL, 5xx). + DiscoveryFailed(String), + #[default] + None, +} + +impl ActionDefaultRef for RegisterAction { + fn default_ref() -> &'static Self { + static DEFAULT: RegisterAction = RegisterAction::None; + &DEFAULT + } +} diff --git a/src/register/register_screen.rs b/src/register/register_screen.rs new file mode 100644 index 00000000..a6b349db --- /dev/null +++ b/src/register/register_screen.rs @@ -0,0 +1,207 @@ +//! RegisterScreen widget: homeserver picker + capability display. +//! +//! Phase 1 renders: +//! - Back button (returns to login) +//! - Screen title +//! - Homeserver URL input +//! - Next button (triggers capability discovery) +//! - Three-state status area (MAS / UIAA / Disabled / errors) +//! +//! Phases 2-5 fill in OIDC launch / UIAA form / SSO buttons. + +use makepad_widgets::*; + +use crate::register::{HsCapabilities, RegisterAction, RegisterMode}; +use crate::register::validation::{normalize_homeserver_url, HomeserverUrlError}; +use crate::sliding_sync::{submit_async_request, MatrixRequest}; + +script_mod! { + use mod.prelude.widgets.* + use mod.widgets.* + + mod.widgets.RegisterScreen = #(RegisterScreen::register_widget(vm)) { + width: Fill, + height: Fill, + flow: Down, + padding: Inset { top: 24, right: 32, bottom: 24, left: 32 } + spacing: 16 + show_bg: true + draw_bg +: { + color: (COLOR_SECONDARY) + } + + back_button := RobrixIconButton { + width: Fit, + height: Fit, + text: "← Back to Login" + } + + title := Label { + width: Fit, + height: Fit, + text: "Create Account" + draw_text +: { + color: (COLOR_TEXT) + text_style: TITLE_TEXT {font_size: 16.0} + } + } + + homeserver_row := View { + width: Fill, + height: Fit, + flow: Down, + spacing: 4 + + Label { + text: "Homeserver URL" + draw_text +: { + color: (COLOR_TEXT) + text_style: REGULAR_TEXT {font_size: 10.0} + } + } + + homeserver_input := RobrixTextInput { + width: Fill, + height: 40, + empty_text: "matrix.org" + } + } + + next_button := RobrixIconButton { + width: Fit, + height: Fit, + text: "Next" + } + + status_area := View { + width: Fill, + height: Fit, + flow: Down, + spacing: 8, + visible: false + + status_label := Label { + width: Fill, + height: Fit, + text: "" + draw_text +: { + color: (COLOR_TEXT) + text_style: REGULAR_TEXT {font_size: 12.0} + } + } + } + } +} + +#[derive(Script, ScriptHook, Widget)] +pub struct RegisterScreen { + #[deref] view: View, + #[rust] last_discovery: Option, +} + +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.widget_match_event(cx, event, scope); + } + + fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + self.view.draw_walk(cx, scope, walk) + } +} + +impl WidgetMatchEvent for RegisterScreen { + fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions, _scope: &mut Scope) { + let back = self.view.button(cx, ids!(back_button)); + let next = self.view.button(cx, ids!(next_button)); + + if back.clicked(actions) { + Cx::post_action(RegisterAction::NavigateToLogin); + return; + } + + if next.clicked(actions) { + let raw = self.view.text_input(cx, ids!(homeserver_input)).text(); + match normalize_homeserver_url(&raw) { + Ok(url) => { + self.show_status(cx, "Checking server capabilities..."); + submit_async_request(MatrixRequest::DiscoverHomeserverCapabilities { url }); + } + Err(HomeserverUrlError::Empty) => { + self.show_status(cx, "Please enter a homeserver URL (e.g. matrix.org)."); + } + Err(HomeserverUrlError::UnsupportedScheme(s)) => { + self.show_status(cx, &format!("Unsupported scheme: {s}. Only http(s) is allowed.")); + } + Err(HomeserverUrlError::Invalid) => { + self.show_status(cx, "That URL looks invalid. Please check and try again."); + } + } + } + + // Capability discovery results. + for action in actions { + match action.downcast_ref::() { + Some(RegisterAction::CapabilitiesDiscovered(caps)) => { + match caps.mode() { + RegisterMode::MasWebOnly => { + match caps.mas_signup_url.as_deref() { + Some(url) => match robius_open::Uri::new(url).open() { + Ok(()) => { + self.show_status( + cx, + "Browser opened. Complete registration in your web browser, \ + then click ← Back to Login and sign in with your new account.", + ); + } + Err(e) => { + log!("robius_open failed for MAS signup url {url}: {e:?}"); + self.show_status( + cx, + &format!( + "Could not open the browser automatically. Please visit this URL manually:\n{url}" + ), + ); + } + }, + None => { + self.show_status( + cx, + "This server advertises browser-based registration but no signup URL was found.", + ); + } + } + } + RegisterMode::Uiaa => { + self.show_status( + cx, + "This server allows direct account creation. Phase 3 will handle the form.", + ); + } + RegisterMode::Disabled => { + self.show_status( + cx, + "This server does not allow registration. Please choose a different homeserver \ + or sign in with an existing account.", + ); + } + } + self.last_discovery = Some(caps.clone()); + } + Some(RegisterAction::DiscoveryFailed(err)) => { + self.show_status(cx, &format!("Could not reach that server: {err}")); + self.last_discovery = None; + } + _ => {} + } + } + } +} + +impl RegisterScreen { + fn show_status(&mut self, cx: &mut Cx, message: &str) { + self.view.view(cx, ids!(status_area)).set_visible(cx, true); + self.view.label(cx, ids!(status_label)).set_text(cx, message); + self.view.redraw(cx); + } +} diff --git a/src/register/register_status_modal.rs b/src/register/register_status_modal.rs new file mode 100644 index 00000000..132317e6 --- /dev/null +++ b/src/register/register_status_modal.rs @@ -0,0 +1,31 @@ +//! Status modal shared by both OIDC and UIAA branches. +//! +//! Task 1 scaffolds the widget; full wiring comes in later phases. + +use makepad_widgets::*; + +script_mod! { + use mod.prelude.widgets.* + use mod.widgets.* + + mod.widgets.RegisterStatusModal = #(RegisterStatusModal::register_widget(vm)) { + width: Fit, + height: Fit + + // TODO: Phase 2 wires title + status text + cancel button. + } +} + +#[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); + } + fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + self.view.draw_walk(cx, scope, walk) + } +} diff --git a/src/register/validation.rs b/src/register/validation.rs new file mode 100644 index 00000000..e305e321 --- /dev/null +++ b/src/register/validation.rs @@ -0,0 +1,107 @@ +//! URL / localpart / password validators for registration. + +use url::Url; + +/// Errors returned by homeserver URL normalization. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HomeserverUrlError { + /// Input was empty or whitespace only. + Empty, + /// Scheme is neither `http` nor `https`. + UnsupportedScheme(String), + /// URL could not be parsed. + Invalid, +} + +/// Normalize a user-entered homeserver URL. +/// +/// - Bare hostname (e.g. `matrix.org`) becomes `https://matrix.org`. +/// - Explicit `http(s)://` schemes are kept as-is. +/// - Any non-`http(s)` scheme is rejected. +/// - Trailing `/` is stripped. +/// - Empty string is rejected. +pub fn normalize_homeserver_url(input: &str) -> Result { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Err(HomeserverUrlError::Empty); + } + + // Strip trailing slash from input before adding scheme + let trimmed_slash = trimmed.trim_end_matches('/'); + + let with_scheme = if trimmed_slash.contains("://") { + trimmed_slash.to_string() + } else { + format!("https://{trimmed_slash}") + }; + + let url = Url::parse(&with_scheme).map_err(|_| HomeserverUrlError::Invalid)?; + + match url.scheme() { + "http" | "https" => {} + other => return Err(HomeserverUrlError::UnsupportedScheme(other.to_string())), + } + + // Return the URL string without trailing slash (url crate always adds one for domain-only URLs) + let canonical = url.as_str().trim_end_matches('/').to_string(); + Ok(canonical) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bare_hostname_gets_https() { + let url = normalize_homeserver_url("matrix.org").unwrap(); + assert_eq!(url.as_str(), "https://matrix.org"); + } + + #[test] + fn explicit_https_is_kept() { + let url = normalize_homeserver_url("https://alvin.meldry.com").unwrap(); + assert_eq!(url.as_str(), "https://alvin.meldry.com"); + } + + #[test] + fn explicit_http_is_kept() { + let url = normalize_homeserver_url("http://127.0.0.1:8128").unwrap(); + assert_eq!(url.as_str(), "http://127.0.0.1:8128"); + } + + #[test] + fn trailing_slash_is_stripped() { + let url = normalize_homeserver_url("https://matrix.org/").unwrap(); + assert_eq!(url.as_str(), "https://matrix.org"); + } + + #[test] + fn whitespace_is_trimmed() { + let url = normalize_homeserver_url(" matrix.org ").unwrap(); + assert_eq!(url.as_str(), "https://matrix.org"); + } + + #[test] + fn empty_input_is_rejected() { + assert_eq!( + normalize_homeserver_url(""), + Err(HomeserverUrlError::Empty), + ); + assert_eq!( + normalize_homeserver_url(" "), + Err(HomeserverUrlError::Empty), + ); + } + + #[test] + fn non_http_scheme_is_rejected() { + let result = normalize_homeserver_url("ftp://example.com"); + assert!(matches!(result, Err(HomeserverUrlError::UnsupportedScheme(ref s)) if s == "ftp")); + } + + #[test] + fn malformed_url_is_rejected() { + let result = normalize_homeserver_url("http:// /not a url"); + assert!(matches!(result, Err(HomeserverUrlError::Invalid))); + } +} diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index 80d121f6..10f0f10b 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -695,6 +695,13 @@ impl std::fmt::Display for TimelineKind { pub enum MatrixRequest { /// Request from the login screen to log in with the given credentials. Login(LoginRequest), + /// Probe a homeserver's registration capabilities. + /// Sent from RegisterScreen's Next button; result arrives via + /// `RegisterAction::CapabilitiesDiscovered` / `DiscoveryFailed`. + DiscoverHomeserverCapabilities { + /// Already-normalized homeserver URL (has scheme, no trailing slash). + url: String, + }, /// Request to switch to a different logged-in account. SwitchAccount { user_id: OwnedUserId, @@ -1338,6 +1345,19 @@ async fn matrix_worker_task( } } + MatrixRequest::DiscoverHomeserverCapabilities { url } => { + tokio::spawn(async move { + match discover_homeserver_capabilities(&url).await { + Ok(caps) => { + Cx::post_action(crate::register::RegisterAction::CapabilitiesDiscovered(caps)); + } + Err(e) => { + Cx::post_action(crate::register::RegisterAction::DiscoveryFailed(e.to_string())); + } + } + }); + } + MatrixRequest::SwitchAccount { user_id } => { // Check if the account exists in AccountManager if account_manager::get_client_for_user(&user_id).is_some() { @@ -6189,3 +6209,147 @@ pub async fn clear_app_state(config: &LogoutConfig) -> Result<()> { Err(_) => Err(anyhow!("Timed out waiting for UI-side app state cleanup")), } } + +/// Probe a homeserver's registration capabilities. +/// +/// Fetches in order: +/// 1. GET `.well-known/matrix/client` — discover base_url and MAS issuer (lenient) +/// 2. GET `/_matrix/client/versions` — liveness check (fatal) +/// 3. GET `/_matrix/client/v3/login` — enumerate SSO providers (non-fatal) +/// 4. POST `/_matrix/client/v3/register` empty body — harvest UIAA flows (fatal) +/// +/// Note: `matrix_sdk::reqwest::Response` does not expose `.json()`, so all +/// response bodies are read as text and parsed via `serde_json::from_str`. +async fn discover_homeserver_capabilities( + raw_url: &str, +) -> anyhow::Result { + use crate::register::{HsCapabilities, IdentityProviderSummary}; + use serde_json::Value; + + let http = matrix_sdk::reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build()?; + + // Helper: read response text and parse as JSON Value, returning Null on any failure. + async fn body_json(resp: matrix_sdk::reqwest::Response) -> Value { + match resp.text().await { + Ok(text) => serde_json::from_str::(&text).unwrap_or(Value::Null), + Err(_) => Value::Null, + } + } + + // Step 1: .well-known (lenient — default base_url = raw_url on failure). + let wk_url = format!("{raw_url}/.well-known/matrix/client"); + let (base_url, is_mas, mas_signup_url) = match http.get(&wk_url).send().await { + Ok(resp) if resp.status().is_success() => { + let body = body_json(resp).await; + let base = body + .get("m.homeserver") + .and_then(|m: &Value| m.get("base_url")) + .and_then(|v: &Value| v.as_str()) + .unwrap_or(raw_url) + .trim_end_matches('/') + .to_string(); + // Detect MAS and derive the signup URL in one pass. Prefer stable key. + // MAS exposes the self-registration form at `/register` when + // open registration is enabled; closed deployments return a polite + // "registration not available" page at the same path. The MSC2965 + // `account` field is for post-login account management (requires a + // session) — opening it while unauthenticated loops between + // /account/ and /login, so we do NOT use it here. + let (mas, mas_signup_url) = ["m.authentication", "org.matrix.msc2965.authentication"] + .iter() + .find_map(|key: &&str| { + let issuer = body.get(*key)?.get("issuer").and_then(|v: &Value| v.as_str())?; + let signup = format!("{}/register", issuer.trim_end_matches('/')); + Some((true, Some(signup))) + }) + .unwrap_or((false, None)); + (base, mas, mas_signup_url) + } + _ => (raw_url.trim_end_matches('/').to_string(), false, None), + }; + + // Step 2: versions — liveness (fatal if unreachable). + let versions_url = format!("{base_url}/_matrix/client/versions"); + http.get(&versions_url) + .send() + .await? + .error_for_status() + .map_err(|e| anyhow::anyhow!("homeserver unreachable: {e}"))?; + + // Step 3: /v3/login — SSO providers (non-fatal on failure). + let login_url = format!("{base_url}/_matrix/client/v3/login"); + let sso_providers = match http.get(&login_url).send().await { + Ok(resp) if resp.status().is_success() => { + let body = body_json(resp).await; + body.get("flows") + .and_then(|f: &Value| f.as_array()) + .map(|flows| { + flows + .iter() + .filter(|f: &&Value| { + f.get("type").and_then(|t: &Value| t.as_str()) == Some("m.login.sso") + }) + .flat_map(|f: &Value| { + f.get("identity_providers") + .and_then(|ip: &Value| ip.as_array()) + .cloned() + .unwrap_or_default() + }) + .filter_map(|p: Value| { + Some(IdentityProviderSummary { + id: p.get("id")?.as_str()?.to_string(), + name: p + .get("name") + .and_then(|n: &Value| n.as_str()) + .unwrap_or("") + .to_string(), + icon_url: p + .get("icon") + .and_then(|v: &Value| v.as_str()) + .map(String::from), + }) + }) + .collect() + }) + .unwrap_or_default() + } + _ => Vec::new(), + }; + + // Step 4: POST /register empty body — UIAA flow probe. + let register_url = format!("{base_url}/_matrix/client/v3/register"); + let reg_resp = http + .post(®ister_url) + .header("Content-Type", "application/json") + .body("{}") + .send() + .await?; + + let status = reg_resp.status(); + let body = body_json(reg_resp).await; + + let (registration_enabled, uiaa_probe) = if status == matrix_sdk::reqwest::StatusCode::UNAUTHORIZED { + // Expected UIAA challenge. + match serde_json::from_value(body.clone()) { + Ok(info) => (true, Some(info)), + Err(_) => (true, None), + } + } else if status == matrix_sdk::reqwest::StatusCode::FORBIDDEN + && body.get("errcode").and_then(|v: &Value| v.as_str()) == Some("M_FORBIDDEN") + { + (false, None) + } else { + (false, None) + }; + + Ok(HsCapabilities { + base_url, + is_mas_native_oidc: is_mas, + registration_enabled, + uiaa_probe, + sso_providers, + mas_signup_url, + }) +}