diff --git a/AGENTS.md b/AGENTS.md index 4a5a3ba24a..20f537cc64 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,7 @@ explicitly authorizes a cache cleanup. - Prefer `use` imports over inline fully-qualified paths; only qualify inline for true one-offs or std collisions - TypeScript with Nx, React, Vite, Zustand, and Three.js - Use two spaces for indentation +- Each Tauri command belongs in its own file module, named after the command. Keep shared state, types, and helpers in separate support modules; see the [Storyteller command conventions](crates/desktop/artcraft/src/services/storyteller/commands/AGENTS.md). ### File Layout diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/users/login_challenges.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/users/login_challenges.rs new file mode 100644 index 0000000000..5fe75abe9f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/users/login_challenges.rs @@ -0,0 +1,73 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Deserialize, Serialize, ToSchema)] +pub struct CreateLoginChallengeRequest {} + +#[derive(Deserialize, Serialize, ToSchema)] +pub struct ReviewLoginChallengeRequest { + pub approval_token: String, +} + +#[derive(Deserialize, Serialize, ToSchema)] +pub struct DecideLoginChallengeRequest { + pub approval_token: String, + pub approve: bool, +} + +#[derive(Deserialize, Serialize, ToSchema)] +pub struct PollLoginChallengeRequest { + pub device_token: String, +} + +#[derive(Deserialize, Serialize, ToSchema)] +pub struct CreateLoginChallengeResponse { + pub success: bool, + pub device_token: String, + pub verification_url: String, + pub confirmation_code: String, + pub expires_at: DateTime, + pub poll_interval_seconds: u32, +} + +#[derive(Deserialize, Serialize, ToSchema)] +pub struct ReviewLoginChallengeResponse { + pub success: bool, + pub status: LoginChallengeState, + pub maybe_failure_type: Option, + pub confirmation_code: String, + pub requesting_ip: String, + pub expires_at: DateTime, + pub username: String, +} + +#[derive(Deserialize, Serialize, ToSchema)] +pub struct LoginChallengeResponse { + pub success: bool, + pub status: LoginChallengeState, + pub maybe_failure_type: Option, + /// Present only to the device after the redemption transaction commits. + #[serde(skip_serializing_if = "Option::is_none")] + pub maybe_signed_session: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum LoginChallengeState { + Pending, + Approved, + Redeemed, + Failed, + #[serde(other)] + Unknown, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum LoginChallengeFailure { + UserDeclined, + Expired, + #[serde(other)] + Unknown, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/users/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/users/mod.rs index a2a81c8136..22a849dbb5 100644 --- a/crates/api_clients/artcraft/artcraft_api_defs/src/users/mod.rs +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/users/mod.rs @@ -2,4 +2,5 @@ pub mod change_password; pub mod edit_email; pub mod edit_username; pub mod login; -pub mod session_info; \ No newline at end of file +pub mod session_info; +pub mod login_challenges; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/users/session_info.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/users/session_info.rs index a4baac7c97..190a917dea 100644 --- a/crates/api_clients/artcraft/artcraft_api_defs/src/users/session_info.rs +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/users/session_info.rs @@ -1,11 +1,12 @@ use crate::common::responses::user_details_light::UserDetailsLight; use enums::by_table::users::user_feature_flag::UserFeatureFlag; -use serde_derive::Serialize; +use serde_derive::{Deserialize, Serialize}; +use serde::{Deserialize as DeserializeTrait, Deserializer}; use std::collections::BTreeSet; use tokens::tokens::users::UserToken; use utoipa::ToSchema; -#[derive(Serialize, Copy, Clone, ToSchema)] +#[derive(Serialize, Deserialize, Copy, Clone, ToSchema)] #[serde(rename_all = "snake_case")] pub enum FakeYouPlan { Free, @@ -14,7 +15,7 @@ pub enum FakeYouPlan { Pro, } -#[derive(Serialize, Copy, Clone, ToSchema)] +#[derive(Serialize, Deserialize, Copy, Clone, ToSchema)] #[serde(rename_all = "snake_case")] pub enum StorytellerStreamPlan { Free, @@ -23,7 +24,7 @@ pub enum StorytellerStreamPlan { Pro, } -#[derive(Serialize, ToSchema)] +#[derive(Serialize, Deserialize, ToSchema)] pub struct SessionUserInfo { pub core_info: UserDetailsLight, @@ -41,6 +42,7 @@ pub struct SessionUserInfo { /// Collection of feature / rollout flags /// This is the proper place to detect if a user has access to some rollout (non-paywall) feature. /// NB: The BTreeSet maintains order so React doesn't introduce re-render state bugs when order changes + #[serde(default, deserialize_with = "deserialize_known_feature_flags")] pub maybe_feature_flags: BTreeSet, // Premium plans: @@ -76,7 +78,7 @@ pub struct SessionUserInfo { pub can_delete_users: bool, } -#[derive(Serialize, ToSchema)] +#[derive(Serialize, Deserialize, ToSchema)] pub struct SessionOnboardingState { /// If true, the user hasn't set their email. pub email_not_set: bool, @@ -91,9 +93,18 @@ pub struct SessionOnboardingState { pub username_not_customized: bool, } -#[derive(Serialize, ToSchema)] +#[derive(Serialize, Deserialize, ToSchema)] pub struct SessionInfoSuccessResponse { pub success: bool, pub logged_in: bool, pub user: Option, } + +// Server rollouts may introduce flags before a desktop release understands them. +// Ignore only unknown flag names; malformed values and authentication fields +// still fail decoding. Unknown flags never grant a client-side capability. +fn deserialize_known_feature_flags<'de, D>(deserializer: D) -> Result, D::Error> +where D: Deserializer<'de> { + let names = Vec::::deserialize(deserializer)?; + Ok(names.into_iter().filter_map(|name| UserFeatureFlag::from_str(&name).ok()).collect()) +} diff --git a/crates/api_clients/artcraft/artcraft_client/AGENTS.md b/crates/api_clients/artcraft/artcraft_client/AGENTS.md new file mode 100644 index 0000000000..759ae5aab0 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/AGENTS.md @@ -0,0 +1,27 @@ +# artcraft_client + +Rust HTTP endpoint bindings used by the ArtCraft Tauri desktop app. + +## Endpoint layout + +- Each HTTP endpoint binding must live in its own descriptively named `.rs` file. + This applies to both free functions and methods implemented on a shared client. + Do not combine several routes into a catch-all file such as `login_challenges.rs` + or `password_auth.rs`. +- Group related bindings in a directory module. Keep `mod.rs` for module declarations; + it must not contain endpoint implementations. For example: + `endpoints/users/login_challenges/create_login_challenge.rs` and + `endpoints/users/login_challenges/poll_login_challenge.rs`. +- Keep each endpoint's route constant, request construction, and endpoint-specific + request/response types in its file. Keep shared wire types in `artcraft_api_defs` + or a dedicated shared type module; do not duplicate them across endpoint files. +- Put shared HTTP transport, client setup, and common error handling in a separate + utility module. A transport module must not accumulate endpoint bindings. +- Update callers to import types from their new modules when splitting bindings. + Preserve request methods, paths, headers, error behavior, and cookie handling. + +## Validation + +Run `cargo check --offline -p artcraft_client` for client changes. For native login +bindings, also run `SQLX_OFFLINE=true cargo test --offline -p artcraft --lib +login_bridge_tests`; these tests use local HTTP fixtures and temporary cookie jars. diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/mod.rs index 7c92178019..aae0a14e44 100644 --- a/crates/api_clients/artcraft/artcraft_client/src/endpoints/mod.rs +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/mod.rs @@ -7,3 +7,4 @@ pub mod media_files; pub mod prompts; pub mod stripe_artcraft; pub mod subscriptions; +pub mod users; diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/users/login_challenges/create_login_challenge.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/users/login_challenges/create_login_challenge.rs new file mode 100644 index 0000000000..44feaf28ed --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/users/login_challenges/create_login_challenge.rs @@ -0,0 +1,16 @@ +use artcraft_api_defs::users::login_challenges::{CreateLoginChallengeRequest, CreateLoginChallengeResponse}; +use reqwest::Method; + +use crate::utils::login_challenge_client::{LoginChallengeClient, LoginChallengeClientError}; + +pub const CREATE_PATH: &str = "/v1/login_challenges/create"; + +impl LoginChallengeClient { + pub async fn create(&self) -> Result { + let request = self + .request(Method::POST, CREATE_PATH) + .json(&CreateLoginChallengeRequest {}); + let (result, _) = self.send(request, CREATE_PATH).await?; + Ok(result) + } +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/users/login_challenges/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/users/login_challenges/mod.rs new file mode 100644 index 0000000000..18a2a278ee --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/users/login_challenges/mod.rs @@ -0,0 +1,2 @@ +pub mod create_login_challenge; +pub mod poll_login_challenge; diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/users/login_challenges/poll_login_challenge.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/users/login_challenges/poll_login_challenge.rs new file mode 100644 index 0000000000..89ce499356 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/users/login_challenges/poll_login_challenge.rs @@ -0,0 +1,30 @@ +use artcraft_api_defs::users::login_challenges::{LoginChallengeResponse, PollLoginChallengeRequest}; +use reqwest::Method; + +use crate::utils::login_challenge_client::{LoginChallengeClient, LoginChallengeClientError}; + +pub const POLL_PATH: &str = "/v1/login_challenges/poll"; + +// Deliberately no Debug/Serialize: the wire result contains a bearer credential. +pub struct PollLoginChallengeResult { + pub response: LoginChallengeResponse, + pub session_set_cookie: Option, +} + +impl LoginChallengeClient { + pub async fn poll( + &self, + device_token: &str, + ) -> Result { + let request = self + .request(Method::POST, POLL_PATH) + .json(&PollLoginChallengeRequest { + device_token: device_token.to_owned(), + }); + let (response, session_set_cookie) = self.send(request, POLL_PATH).await?; + Ok(PollLoginChallengeResult { + response, + session_set_cookie, + }) + } +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/users/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/users/mod.rs new file mode 100644 index 0000000000..9311baced0 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/users/mod.rs @@ -0,0 +1,5 @@ +pub mod login_challenges; +pub mod password_auth_response; +pub mod password_login; +pub mod password_signup; +pub mod session_info; diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/users/password_auth_response.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/users/password_auth_response.rs new file mode 100644 index 0000000000..aa0e8345ab --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/users/password_auth_response.rs @@ -0,0 +1,8 @@ +use serde::Deserialize; + +// No Debug: the response contains a bearer credential. +#[derive(Deserialize)] +pub struct PasswordAuthResponse { + pub success: bool, + pub signed_session: Option, +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/users/password_login.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/users/password_login.rs new file mode 100644 index 0000000000..1cd7614c95 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/users/password_login.rs @@ -0,0 +1,20 @@ +use reqwest::Method; +use serde::{Deserialize, Serialize}; + +use crate::utils::login_challenge_client::{LoginChallengeClient, LoginChallengeClientError}; +use super::password_auth_response::PasswordAuthResponse; + +pub const LOGIN_PATH: &str = "/v1/login"; + +// No Debug: the request contains credentials. +#[derive(Deserialize, Serialize)] +pub struct PasswordLoginRequest { + pub username_or_email: String, + pub password: String, +} + +impl LoginChallengeClient { + pub async fn password_login(&self, request: &PasswordLoginRequest) -> Result<(PasswordAuthResponse, Option), LoginChallengeClientError> { + self.send(self.request(Method::POST, LOGIN_PATH).json(request), LOGIN_PATH).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/users/password_signup.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/users/password_signup.rs new file mode 100644 index 0000000000..87e2c0de0e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/users/password_signup.rs @@ -0,0 +1,23 @@ +use reqwest::Method; +use serde::{Deserialize, Serialize}; + +use crate::utils::login_challenge_client::{LoginChallengeClient, LoginChallengeClientError}; +use super::password_auth_response::PasswordAuthResponse; + +pub const SIGNUP_PATH: &str = "/v1/create_account"; + +// No Debug: the request contains credentials. +#[derive(Deserialize, Serialize)] +pub struct PasswordSignupRequest { + pub username: String, + pub email_address: String, + pub password: String, + pub password_confirmation: String, + pub signup_source: String, +} + +impl LoginChallengeClient { + pub async fn password_signup(&self, request: &PasswordSignupRequest) -> Result<(PasswordAuthResponse, Option), LoginChallengeClientError> { + self.send(self.request(Method::POST, SIGNUP_PATH).json(request), SIGNUP_PATH).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/users/session_info.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/users/session_info.rs new file mode 100644 index 0000000000..8df2a3c51f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/users/session_info.rs @@ -0,0 +1,19 @@ +use artcraft_api_defs::users::session_info::SessionInfoSuccessResponse; +use reqwest::Method; + +use crate::utils::login_challenge_client::{LoginChallengeClient, LoginChallengeClientError}; + +pub const SESSION_PATH: &str = "/v1/session"; + +impl LoginChallengeClient { + pub async fn session( + &self, + signed_session: &str, + ) -> Result { + let request = self + .request(Method::GET, SESSION_PATH) + .header("Cookie", format!("session={signed_session}")); + let (result, _) = self.send(request, SESSION_PATH).await?; + Ok(result) + } +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/utils/login_challenge_client.rs b/crates/api_clients/artcraft/artcraft_client/src/utils/login_challenge_client.rs new file mode 100644 index 0000000000..b6c8ffe5c7 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/utils/login_challenge_client.rs @@ -0,0 +1,151 @@ +use std::time::Duration; + +use log::{info, warn}; +use reqwest::header::SET_COOKIE; +use reqwest::{Client, Method, RequestBuilder}; +use serde::de::DeserializeOwned; +use url::Url; + +use crate::utils::api_host::ApiHost; +use crate::utils::constants::USER_AGENT; + +/// Native transport for the session bridge. No request/response bodies, cookies, +/// or challenge credentials are logged, even at debug level. Never follows redirects. +#[derive(Clone)] +pub struct LoginChallengeClient { + client: Client, + api_host: ApiHost, +} + +#[derive(Debug)] +pub struct LoginChallengeClientError { + pub status: Option, + pub message: &'static str, +} + +impl LoginChallengeClient { + pub fn new(api_host: &ApiHost) -> Result { + let mut builder = Client::builder(); + if matches!(api_host, ApiHost::Localhost { .. }) { + builder = builder.no_proxy(); + } + let client = builder + .timeout(Duration::from_secs(15)) + .redirect(reqwest::redirect::Policy::none()) + .user_agent(USER_AGENT) + .build() + .map_err(|_| LoginChallengeClientError::invalid("Unable to initialize login client"))?; + Ok(Self { + client, + api_host: api_host.clone(), + }) + } + + pub fn api_origin(&self) -> String { + self.api_host.to_api_hostname_and_scheme() + } + + pub fn api_url(&self) -> Url { + Url::parse(&format!("{}/", self.api_origin())).expect("ApiHost must produce a valid origin") + } + + pub fn allows_verification_url(&self, value: &str) -> bool { + let Ok(url) = Url::parse(value) else { + return false; + }; + let origin_allowed = match self.api_host { + ApiHost::Localhost { .. } => ["http://localhost:4200", "http://127.0.0.1:4200", "http://localhost:4201", "http://127.0.0.1:4201"] + .contains(&url.origin().ascii_serialization().as_str()), + ApiHost::Storyteller => ["https://app.getartcraft.com", "https://getartcraft.com", "https://www.getartcraft.com"] + .contains(&url.origin().ascii_serialization().as_str()), + ApiHost::FakeYou => false, + }; + origin_allowed + && url.username().is_empty() + && url.password().is_none() + && url.path() == "/login/desktop" + && url.query().is_none() + && url + .fragment() + .and_then(|f| f.strip_prefix("approval_token=")) + .map(|s| { + s.len() == 43 + && s + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') + }) + .unwrap_or(false) + } + + pub(crate) fn request(&self, method: Method, path: &str) -> RequestBuilder { + self + .client + .request(method, format!("{}{path}", self.api_origin())) + .header("Accept", "application/json") + } + + pub(crate) async fn send( + &self, + request: RequestBuilder, + path: &str, + ) -> Result<(T, Option), LoginChallengeClientError> { + let origin = self.api_origin(); + info!("Website login request: origin={} path={}", origin, path); + let response = request.send().await.map_err(|_| { + warn!( + "Website login network failure: origin={} path={}", + origin, path + ); + LoginChallengeClientError::invalid("Unable to reach the login server") + })?; + let status = response.status(); + if !status.is_success() { + warn!( + "Website login HTTP failure: origin={} path={} status={}", + origin, + path, + status.as_u16() + ); + return Err(LoginChallengeClientError { + status: Some(status.as_u16()), + message: "Login server rejected the request", + }); + } + let session_cookie = response + .headers() + .get_all(SET_COOKIE) + .iter() + .filter_map(|v| v.to_str().ok()) + .find(|v| { + cookie::Cookie::parse(*v) + .map(|c| c.name() == "session") + .unwrap_or(false) + }) + .map(str::to_owned); + let body = response.json().await.map_err(|_| { + warn!( + "Website login invalid response: origin={} path={}", + origin, path + ); + LoginChallengeClientError::invalid("Invalid response from login server") + })?; + Ok((body, session_cookie)) + } +} + +impl LoginChallengeClientError { + pub fn invalid(message: &'static str) -> Self { + Self { + status: None, + message, + } + } +} + +impl std::fmt::Display for LoginChallengeClientError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} (HTTP {:?})", self.message, self.status) + } +} + +impl std::error::Error for LoginChallengeClientError {} diff --git a/crates/api_clients/artcraft/artcraft_client/src/utils/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/utils/mod.rs index 070f199125..8cfd5064ed 100644 --- a/crates/api_clients/artcraft/artcraft_client/src/utils/mod.rs +++ b/crates/api_clients/artcraft/artcraft_client/src/utils/mod.rs @@ -7,3 +7,4 @@ pub (crate) mod http_get_anonymous; pub mod api_host; pub mod constants; pub mod status_codes; +pub mod login_challenge_client; diff --git a/crates/desktop/artcraft/src/core/threads/main_window_thread/persist_storyteller_cookies_task.rs b/crates/desktop/artcraft/src/core/threads/main_window_thread/persist_storyteller_cookies_task.rs index 1c26f73623..e0cae73ce3 100644 --- a/crates/desktop/artcraft/src/core/threads/main_window_thread/persist_storyteller_cookies_task.rs +++ b/crates/desktop/artcraft/src/core/threads/main_window_thread/persist_storyteller_cookies_task.rs @@ -1,10 +1,10 @@ +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; use crate::core::state::app_startup_time::AppStartupTime; use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; use anyhow::anyhow; use chrono::TimeDelta; use errors::AnyhowResult; use log::{error, info}; -use once_cell::sync::Lazy; use reqwest::Url; use artcraft_client::credentials::storyteller_avt_cookie::StorytellerAvtCookie; use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; @@ -13,24 +13,13 @@ use tauri::{AppHandle, Manager}; use tauri_plugin_http::reqwest_cookie_store::CookieStore; use tauri_plugin_http::Http; -const STORYTELLER_ROOT_COOKIE_URL_STR: &str = "https://api.storyteller.ai/"; - -static STORYTELLER_ROOT_COOKIE_URL: Lazy = Lazy::new(|| { - Url::parse(STORYTELLER_ROOT_COOKIE_URL_STR).expect("URL should parse") -}); - -const AVT_COOKIE_NAME : &str = "visitor"; -const SESSION_COOKIE_NAME : &str = "session"; +const AVT_COOKIE_NAME: &str = "visitor"; +const SESSION_COOKIE_NAME: &str = "session"; /// There's some kind of race condition that we need to wait for before inspecting cookies. const RACE_CONDITION_WAIT_TIME: TimeDelta = TimeDelta::milliseconds(5000); -pub async fn persist_storyteller_cookies_task( - app: &AppHandle, - storyteller_credential_manager: &StorytellerCredentialManager, - app_startup_time: &AppStartupTime, -) -> AnyhowResult<()> { - +pub async fn persist_storyteller_cookies_task(app: &AppHandle, storyteller_credential_manager: &StorytellerCredentialManager, app_startup_time: &AppStartupTime) -> AnyhowResult<()> { //if app_startup_time.time_delta_since() < RACE_CONDITION_WAIT_TIME { // // NB: There's an issue when the "main window thread" inquires about the // // webview cookies shortly after app startup. When it attempts to dump the @@ -41,33 +30,30 @@ pub async fn persist_storyteller_cookies_task( //} let maybe_http = app.try_state::(); - + let http = match maybe_http { None => { error!("No HTTP plugin found"); return Err(anyhow!("No HTTP plugin found")); - } + }, Some(http) => http, }; - + match http.cookies_jar.store.lock() { Err(err) => { error!("Failed to lock cookie jar: {:?}", err); return Err(anyhow!("Failed to lock cookie jar")); - } + }, Ok(cookie_jar) => { - sync_tauri_credentials(&cookie_jar, storyteller_credential_manager)?; - } + sync_tauri_credentials(&cookie_jar, storyteller_credential_manager, &storyteller_cookie_url(app)?)?; + }, } - + Ok(()) } -fn sync_tauri_credentials( - cookie_store: &CookieStore, - storyteller_credential_manager: &StorytellerCredentialManager, -) -> AnyhowResult<()> { - let current_http_plugin_credentials = get_credentials_from_cookie_store(cookie_store)?; +pub(crate) fn sync_tauri_credentials(cookie_store: &CookieStore, storyteller_credential_manager: &StorytellerCredentialManager, api_url: &Url) -> AnyhowResult<()> { + let current_http_plugin_credentials = get_credentials_from_cookie_store(cookie_store, api_url)?; let mut replace_credentials = true; @@ -78,31 +64,80 @@ fn sync_tauri_credentials( replace_credentials = false; } } - + if replace_credentials { info!("Syncing ArtCraft credentials ..."); storyteller_credential_manager.set_credentials(¤t_http_plugin_credentials)?; // NB: tauri-plugin-http stores the credentials on disk, so we can defer to that for now. //storyteller_credential_manager.persist_all_to_disk()?; } - + Ok(()) } -pub fn get_credentials_from_cookie_store(cookie_jar: &CookieStore) -> AnyhowResult { +pub fn get_credentials_from_cookie_store(cookie_jar: &CookieStore, api_url: &Url) -> AnyhowResult { let mut avt_cookie = None; let mut session_cookie = None; - for cookie in cookie_jar.iter_unexpired() { - if let Some(avt) = StorytellerAvtCookie::maybe_from_cookie(&cookie) { - avt_cookie = Some(avt); - } else if let Some(session) = StorytellerSessionCookie::maybe_from_cookie(&cookie) { - session_cookie = Some(session); + // Only cookies applicable to our API origin can become ArtCraft credentials. + for (name, value) in cookie_jar.get_request_values(api_url) { + match name { + AVT_COOKIE_NAME => avt_cookie = Some(StorytellerAvtCookie::new(value.to_owned())), + SESSION_COOKIE_NAME => session_cookie = Some(StorytellerSessionCookie::new(value.to_owned())), + _ => {}, } } - Ok(StorytellerCredentialSet::initialize( - avt_cookie, - session_cookie, - )) + Ok(StorytellerCredentialSet::initialize(avt_cookie, session_cookie)) +} + +pub(crate) fn storyteller_cookie_url(app: &AppHandle) -> AnyhowResult { + let config = app.try_state::().ok_or_else(|| anyhow!("API configuration unavailable"))?; + Ok(Url::parse(&format!("{}/", config.storyteller_host.to_api_hostname_and_scheme()))?) +} + +#[cfg(test)] +mod login_bridge_tests { + use super::*; + use crate::core::state::data_dir::app_data_root::AppDataRoot; + use tauri_plugin_http::reqwest_cookie_store::RawCookie; + + #[test] + fn approved_response_cookie_reaches_native_credentials() { + let directory = tempfile::tempdir().unwrap(); + let root = AppDataRoot::create_existing(directory.path()).unwrap(); + let manager = StorytellerCredentialManager::initialize_empty(&root); + let mut jar = CookieStore::default(); + let url = Url::parse("https://api.storyteller.ai/v1/login_challenges/poll").unwrap(); + jar.store_response_cookies([RawCookie::parse("session=signed_backend_cookie; Secure; HttpOnly; Path=/; Max-Age=3600").unwrap().into_owned(), RawCookie::parse("visitor=existing_visitor; Secure; Path=/; Max-Age=3600").unwrap().into_owned()].into_iter(), &url); + sync_tauri_credentials(&jar, &manager, &url).unwrap(); + let credentials = manager.get_credentials_required().unwrap(); + assert_eq!(credentials.session.unwrap().as_str(), "signed_backend_cookie"); + assert_eq!(credentials.avt.unwrap().as_str(), "existing_visitor"); + // Retrying the same successful poll is idempotent for native credentials too. + sync_tauri_credentials(&jar, &manager, &url).unwrap(); + assert_eq!(manager.get_credentials_required().unwrap().session.unwrap().as_str(), "signed_backend_cookie"); + } + + #[test] + fn unrelated_and_expired_cookies_cannot_become_artcraft_sessions() { + let url = Url::parse("https://api.storyteller.ai/").unwrap(); + let mut jar = CookieStore::default(); + jar.store_response_cookies([RawCookie::parse("session=unrelated; Path=/; Max-Age=3600").unwrap().into_owned()].into_iter(), &Url::parse("https://unrelated.example/").unwrap()); + assert!(get_credentials_from_cookie_store(&jar, &url).unwrap().session.is_none()); + jar.store_response_cookies([RawCookie::parse("session=expired; Secure; Path=/; Max-Age=0").unwrap().into_owned()].into_iter(), &url); + assert!(get_credentials_from_cookie_store(&jar, &url).unwrap().session.is_none()); + } + + #[test] + fn configured_local_api_does_not_import_production_cookies() { + let production = Url::parse("https://api.storyteller.ai/").unwrap(); + let local = Url::parse("http://localhost:12345/").unwrap(); + let mut jar = CookieStore::default(); + jar.store_response_cookies([RawCookie::parse("session=production; Secure; Path=/").unwrap().into_owned()].into_iter(), &production); + assert!(get_credentials_from_cookie_store(&jar, &local).unwrap().session.is_none()); + jar.store_response_cookies([RawCookie::parse("session=development; Path=/").unwrap().into_owned()].into_iter(), &local); + assert_eq!(get_credentials_from_cookie_store(&jar, &local).unwrap().session.unwrap().as_str(), "development"); + assert_eq!(get_credentials_from_cookie_store(&jar, &production).unwrap().session.unwrap().as_str(), "production"); + } } diff --git a/crates/desktop/artcraft/src/lib.rs b/crates/desktop/artcraft/src/lib.rs index 3b678d13dd..643c671371 100644 --- a/crates/desktop/artcraft/src/lib.rs +++ b/crates/desktop/artcraft/src/lib.rs @@ -63,6 +63,13 @@ use crate::services::sora::commands::sora_logout_command::sora_logout_command; use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; use crate::services::sora::state::sora_task_queue::SoraTaskQueue; use crate::services::sora::threads::sora_task_polling::sora_task_polling_thread::sora_task_polling_thread; +use crate::services::storyteller::commands::login_bridge::DesktopLoginBridgeState; +use crate::services::storyteller::commands::storyteller_cancel_login_challenge_command::storyteller_cancel_login_challenge_command; +use crate::services::storyteller::commands::storyteller_create_login_challenge_command::storyteller_create_login_challenge_command; +use crate::services::storyteller::commands::storyteller_get_login_session_command::storyteller_get_login_session_command; +use crate::services::storyteller::commands::storyteller_password_login_command::storyteller_password_login_command; +use crate::services::storyteller::commands::storyteller_password_signup_command::storyteller_password_signup_command; +use crate::services::storyteller::commands::storyteller_poll_login_challenge_command::storyteller_poll_login_challenge_command; use crate::services::storyteller::commands::storyteller_get_credits_command::storyteller_get_credits_command; use crate::services::storyteller::commands::storyteller_get_subscription_command::storyteller_get_subscription_command; use crate::services::storyteller::commands::storyteller_purge_credentials_command::storyteller_purge_credentials_command; @@ -201,6 +208,7 @@ pub fn run() { }) .manage(app_data_root) .manage(app_env_configs) + .manage(DesktopLoginBridgeState::default()) .manage(app_preferences) .manage(artcraft_platform_info) .manage(artcraft_usage_tracker) @@ -259,6 +267,12 @@ pub fn run() { set_provider_order_command, sora_get_credential_info_command, sora_logout_command, + storyteller_get_login_session_command, + storyteller_password_login_command, + storyteller_password_signup_command, + storyteller_create_login_challenge_command, + storyteller_poll_login_challenge_command, + storyteller_cancel_login_challenge_command, storyteller_get_credits_command, storyteller_get_subscription_command, storyteller_open_credits_purchase_command, diff --git a/crates/desktop/artcraft/src/services/storyteller/commands/AGENTS.md b/crates/desktop/artcraft/src/services/storyteller/commands/AGENTS.md new file mode 100644 index 0000000000..1d20ccf2d0 --- /dev/null +++ b/crates/desktop/artcraft/src/services/storyteller/commands/AGENTS.md @@ -0,0 +1,17 @@ +# Tauri Commands + +- Put each `#[tauri::command]` function in its own `.rs` file module named after + that command, such as `storyteller_create_login_challenge_command.rs`. Do not + collect multiple commands in a catch-all `*_commands.rs` file. +- Keep `mod.rs` for module declarations. Put shared state, response/error types, + and implementation helpers in separate support modules, with the narrowest + visibility needed by their callers. +- Update the imports used by `tauri::generate_handler!` when moving commands. + Preserve IPC command names and arguments unless changing that contract is + explicitly part of the task. +- Authentication HTTP requests run in Rust through `artcraft_client` endpoint + bindings. The frontend invokes Tauri commands; it does not call the API + directly, and desktop commands do not connect to the backend MySQL database. +- For login bridge changes, run the existing native fixture tests with + `SQLX_OFFLINE=true cargo test --offline -p artcraft --lib login_bridge_tests`. + These tests use loopback HTTP servers and temporary cookie stores. diff --git a/crates/desktop/artcraft/src/services/storyteller/commands/login_bridge.rs b/crates/desktop/artcraft/src/services/storyteller/commands/login_bridge.rs new file mode 100644 index 0000000000..863860c0c2 --- /dev/null +++ b/crates/desktop/artcraft/src/services/storyteller/commands/login_bridge.rs @@ -0,0 +1,340 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use artcraft_api_defs::users::login_challenges::{LoginChallengeFailure, LoginChallengeState}; +use artcraft_api_defs::users::session_info::SessionUserInfo; +use artcraft_client::endpoints::users::password_login::PasswordLoginRequest; +use artcraft_client::endpoints::users::password_signup::PasswordSignupRequest; +use artcraft_client::utils::api_host::ApiHost; +use artcraft_client::utils::login_challenge_client::{ + LoginChallengeClient, LoginChallengeClientError, +}; +use chrono::{DateTime, Utc}; +use log::{info, warn}; +use serde::Serialize; +use tauri::{AppHandle, Manager}; +use tauri_plugin_http::reqwest_cookie_store::{CookieStoreMutex, RawCookie}; +use tauri_plugin_http::Http; +use tokio::sync::Mutex; +use uuid_utils::uuid::generate_random_uuid; + +use crate::core::threads::main_window_thread::persist_storyteller_cookies_task::get_credentials_from_cookie_store; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; + +const MAX_PENDING_CHALLENGES: usize = 8; + +/// All bearer credentials stay in Rust. The UI holds only a local lookup handle. +#[derive(Default)] +pub struct DesktopLoginBridgeState { + pending: Mutex>, +} + +#[derive(Clone)] +struct PendingChallenge { + device_token: String, + expires_at: DateTime, + client: LoginChallengeClient, +} + +#[derive(Serialize)] +pub struct DesktopLoginChallenge { + pub challenge_id: String, + pub verification_url: String, + pub confirmation_code: String, + pub expires_at: DateTime, + pub poll_interval_seconds: u32, +} + +#[derive(Serialize)] +pub struct DesktopLoginOutcome { + pub status: LoginChallengeState, + pub maybe_failure_type: Option, + pub maybe_user: Option, +} + +#[derive(Debug, Serialize)] +pub struct DesktopLoginError { + pub status: Option, + pub message: String, + pub retryable: bool, +} + +impl DesktopLoginError { + fn local(message: &str) -> Self { + Self { + status: None, + message: message.to_owned(), + retryable: false, + } + } + + pub(super) fn from_client(origin: &str, error: LoginChallengeClientError) -> Self { + warn!( + "Website login failed: origin={} status={:?} reason={}", + origin, error.status, error.message + ); + Self { + retryable: error + .status + .map(|s| s == 429 || s >= 500) + .unwrap_or(error.message == "Unable to reach the login server"), + status: error.status, + message: format!("{}: {} (HTTP {:?})", origin, error.message, error.status), + } + } +} + +pub(super) async fn current_login_session( + client: &LoginChallengeClient, + jar: &Arc, +) -> Result, DesktopLoginError> { + let credentials = { + let store = jar + .store + .lock() + .map_err(|_| DesktopLoginError::local("HTTP cookie store unavailable"))?; + get_credentials_from_cookie_store(&store, &client.api_url()) + .map_err(|_| DesktopLoginError::local("Unable to read session cookie"))? + }; + let Some(signed) = credentials.session else { + return Ok(None); + }; + let session = match client.session(signed.as_str()).await { + Ok(session) => session, + Err(error) if error.status == Some(401) => return Ok(None), + Err(error) => return Err(DesktopLoginError::from_client(&client.api_origin(), error)), + }; + Ok( + session + .user + .filter(|_| session.success && session.logged_in), + ) +} + +pub(super) async fn password_auth( + host: &ApiHost, + manager: &StorytellerCredentialManager, + jar: &Arc, + login: Option, + signup: Option, +) -> Result { + let client = LoginChallengeClient::new(host) + .map_err(|e| DesktopLoginError::from_client(&host.to_api_hostname_and_scheme(), e))?; + let response = match (login, signup) { + (Some(request), None) => client.password_login(&request).await, + (None, Some(request)) => client.password_signup(&request).await, + _ => return Err(DesktopLoginError::local("Invalid authentication request")), + } + .map_err(|e| DesktopLoginError::from_client(&client.api_origin(), e))?; + let (response, cookie) = response; + if !response.success { + return Err(DesktopLoginError::local( + "Sign-in failed. Check your account details and try again.", + )); + } + let signed = response + .signed_session + .ok_or_else(|| DesktopLoginError::local("Login server did not issue a session"))?; + let cookie = + cookie.ok_or_else(|| DesktopLoginError::local("Login server did not set a session cookie"))?; + let (cookie, user) = verify_session(&client, &signed, cookie).await?; + install_session_cookie(&client, &signed, cookie, manager, jar)?; + persist_session_cookie(jar).await?; + Ok(user) +} + +pub(super) fn http_cookie_jar(app: &AppHandle) -> Result, DesktopLoginError> { + Ok( + app + .try_state::() + .ok_or_else(|| DesktopLoginError::local("HTTP cookie store unavailable"))? + .cookies_jar + .clone(), + ) +} + +pub(super) async fn begin_challenge( + host: &ApiHost, + state: &DesktopLoginBridgeState, +) -> Result { + let client = LoginChallengeClient::new(host) + .map_err(|e| DesktopLoginError::from_client(&host.to_api_hostname_and_scheme(), e))?; + let created = client + .create() + .await + .map_err(|e| DesktopLoginError::from_client(&client.api_origin(), e))?; + if !created.success + || !client.allows_verification_url(&created.verification_url) + || created.device_token.len() != 43 + || created.expires_at <= Utc::now() + { + return Err(DesktopLoginError::local( + "Invalid login challenge or website/API environment mismatch", + )); + } + let mut pending = state.pending.lock().await; + pending.retain(|_, value| value.expires_at > Utc::now()); + if pending.len() >= MAX_PENDING_CHALLENGES { + return Err(DesktopLoginError::local("Too many pending login attempts")); + } + let challenge_id = generate_random_uuid(); + pending.insert( + challenge_id.clone(), + PendingChallenge { + device_token: created.device_token, + expires_at: created.expires_at, + client, + }, + ); + Ok(DesktopLoginChallenge { + challenge_id, + verification_url: created.verification_url, + confirmation_code: created.confirmation_code, + expires_at: created.expires_at, + poll_interval_seconds: created.poll_interval_seconds.max(5), + }) +} + +pub(super) async fn poll_challenge( + state: &DesktopLoginBridgeState, + manager: &StorytellerCredentialManager, + jar: &Arc, + challenge_id: &str, +) -> Result { + let challenge = { + let mut pending = state.pending.lock().await; + let challenge = pending.get(challenge_id).cloned().ok_or_else(|| { + DesktopLoginError::local("Login attempt was cancelled or is no longer available") + })?; + if challenge.expires_at <= Utc::now() { + pending.remove(challenge_id); + return Ok(expired()); + } + challenge + }; + let origin = challenge.client.api_origin(); + let polled = challenge + .client + .poll(&challenge.device_token) + .await + .map_err(|e| DesktopLoginError::from_client(&origin, e))?; + if !polled.response.success || polled.response.status == LoginChallengeState::Unknown { + return Err(DesktopLoginError::local("Unrecognized login response")); + } + if polled.response.status != LoginChallengeState::Redeemed { + if polled.response.status == LoginChallengeState::Failed { + state.pending.lock().await.remove(challenge_id); + } + return Ok(DesktopLoginOutcome { + status: polled.response.status, + maybe_failure_type: polled.response.maybe_failure_type, + maybe_user: None, + }); + } + let signed = polled + .response + .maybe_signed_session + .ok_or_else(|| DesktopLoginError::local("Login server did not issue a session"))?; + let set_cookie = polled + .session_set_cookie + .ok_or_else(|| DesktopLoginError::local("Login server did not set a session cookie"))?; + let (cookie, user) = verify_session(&challenge.client, &signed, set_cookie).await?; + { + let pending = state.pending.lock().await; + // A cancellation during either HTTP request must prevent local installation. + if !pending.contains_key(challenge_id) { + return Err(DesktopLoginError::local("Login attempt was cancelled")); + } + if challenge.expires_at <= Utc::now() { + return Ok(expired()); + } + install_session_cookie(&challenge.client, &signed, cookie, manager, jar)?; + } + // Keep the challenge for idempotent IPC retries; it expires on its original deadline. + persist_session_cookie(jar).await?; + info!("Website login complete: origin={}", origin); + Ok(DesktopLoginOutcome { + status: LoginChallengeState::Redeemed, + maybe_failure_type: None, + maybe_user: Some(user), + }) +} + +pub(super) async fn cancel_challenge(state: &DesktopLoginBridgeState, challenge_id: &str) { + state.pending.lock().await.remove(challenge_id); +} + +async fn verify_session( + client: &LoginChallengeClient, + signed: &str, + set_cookie: String, +) -> Result<(RawCookie<'static>, SessionUserInfo), DesktopLoginError> { + let cookie = RawCookie::parse(set_cookie) + .map_err(|_| DesktopLoginError::local("Invalid session cookie"))? + .into_owned(); + if cookie.name() != "session" || cookie.value() != signed { + return Err(DesktopLoginError::local( + "Session cookie did not match the redeemed session", + )); + } + // Validate on the SAME native API host before publishing any new credentials. + let session = client + .session(&signed) + .await + .map_err(|e| DesktopLoginError::from_client(&client.api_origin(), e))?; + let user = session + .user + .filter(|_| session.success && session.logged_in) + .ok_or_else(|| DesktopLoginError::local("The redeemed session could not authenticate"))?; + Ok((cookie, user)) +} + +fn install_session_cookie( + client: &LoginChallengeClient, + signed: &str, + cookie: RawCookie<'static>, + manager: &StorytellerCredentialManager, + jar: &Arc, +) -> Result<(), DesktopLoginError> { + let mut store = jar + .store + .lock() + .map_err(|_| DesktopLoginError::local("HTTP cookie store unavailable"))?; + let mut candidate = store.clone(); + candidate.store_response_cookies([cookie].into_iter(), &client.api_url()); + let credentials = get_credentials_from_cookie_store(&candidate, &client.api_url()) + .map_err(|_| DesktopLoginError::local("Unable to read session cookie"))?; + if credentials.session.as_ref().map(|c| c.as_str()) != Some(signed) { + return Err(DesktopLoginError::local( + "Session cookie does not apply to the configured API host", + )); + } + manager + .set_credentials(&credentials) + .map_err(|_| DesktopLoginError::local("Unable to store native login credentials"))?; + *store = candidate; + Ok(()) +} + +async fn persist_session_cookie(jar: &Arc) -> Result<(), DesktopLoginError> { + let saved = jar + .request_save() + .map_err(|_| DesktopLoginError::local("Unable to persist login cookie"))?; + tokio::task::spawn_blocking(move || saved.recv()) + .await + .map_err(|_| DesktopLoginError::local("Unable to persist login cookie"))? + .map_err(|_| DesktopLoginError::local("Unable to persist login cookie"))?; + Ok(()) +} + +fn expired() -> DesktopLoginOutcome { + DesktopLoginOutcome { + status: LoginChallengeState::Failed, + maybe_failure_type: Some(LoginChallengeFailure::Expired), + maybe_user: None, + } +} + +#[cfg(test)] +#[path = "storyteller_login_bridge_tests.rs"] +mod login_bridge_tests; diff --git a/crates/desktop/artcraft/src/services/storyteller/commands/mod.rs b/crates/desktop/artcraft/src/services/storyteller/commands/mod.rs index 294ba3f6cd..b526c8f6f0 100644 --- a/crates/desktop/artcraft/src/services/storyteller/commands/mod.rs +++ b/crates/desktop/artcraft/src/services/storyteller/commands/mod.rs @@ -2,4 +2,11 @@ pub mod storyteller_purge_credentials_command; pub mod storyteller_get_credits_command; pub mod storyteller_get_subscription_command; pub mod stripe_checkout; -pub mod stripe_customer_portal; \ No newline at end of file +pub mod stripe_customer_portal; +pub(crate) mod login_bridge; +pub mod storyteller_cancel_login_challenge_command; +pub mod storyteller_create_login_challenge_command; +pub mod storyteller_get_login_session_command; +pub mod storyteller_password_login_command; +pub mod storyteller_password_signup_command; +pub mod storyteller_poll_login_challenge_command; diff --git a/crates/desktop/artcraft/src/services/storyteller/commands/storyteller_cancel_login_challenge_command.rs b/crates/desktop/artcraft/src/services/storyteller/commands/storyteller_cancel_login_challenge_command.rs new file mode 100644 index 0000000000..b29e1139b9 --- /dev/null +++ b/crates/desktop/artcraft/src/services/storyteller/commands/storyteller_cancel_login_challenge_command.rs @@ -0,0 +1,14 @@ +use tauri::State; + +use crate::services::storyteller::commands::login_bridge::{ + cancel_challenge, DesktopLoginBridgeState, DesktopLoginError, +}; + +#[tauri::command] +pub async fn storyteller_cancel_login_challenge_command( + state: State<'_, DesktopLoginBridgeState>, + challenge_id: String, +) -> Result<(), DesktopLoginError> { + cancel_challenge(&state, &challenge_id).await; + Ok(()) +} diff --git a/crates/desktop/artcraft/src/services/storyteller/commands/storyteller_create_login_challenge_command.rs b/crates/desktop/artcraft/src/services/storyteller/commands/storyteller_create_login_challenge_command.rs new file mode 100644 index 0000000000..78b4dfef00 --- /dev/null +++ b/crates/desktop/artcraft/src/services/storyteller/commands/storyteller_create_login_challenge_command.rs @@ -0,0 +1,14 @@ +use tauri::State; + +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::services::storyteller::commands::login_bridge::{ + begin_challenge, DesktopLoginBridgeState, DesktopLoginChallenge, DesktopLoginError, +}; + +#[tauri::command] +pub async fn storyteller_create_login_challenge_command( + config: State<'_, AppEnvConfigs>, + state: State<'_, DesktopLoginBridgeState>, +) -> Result { + begin_challenge(&config.storyteller_host, &state).await +} diff --git a/crates/desktop/artcraft/src/services/storyteller/commands/storyteller_get_login_session_command.rs b/crates/desktop/artcraft/src/services/storyteller/commands/storyteller_get_login_session_command.rs new file mode 100644 index 0000000000..56f4be5317 --- /dev/null +++ b/crates/desktop/artcraft/src/services/storyteller/commands/storyteller_get_login_session_command.rs @@ -0,0 +1,19 @@ +use artcraft_api_defs::users::session_info::SessionUserInfo; +use artcraft_client::utils::login_challenge_client::LoginChallengeClient; +use tauri::{AppHandle, State}; + +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::services::storyteller::commands::login_bridge::{ + current_login_session, http_cookie_jar, DesktopLoginError, +}; + +#[tauri::command] +pub async fn storyteller_get_login_session_command( + app: AppHandle, + config: State<'_, AppEnvConfigs>, +) -> Result, DesktopLoginError> { + let client = LoginChallengeClient::new(&config.storyteller_host).map_err(|error| { + DesktopLoginError::from_client(&config.storyteller_host.to_api_hostname_and_scheme(), error) + })?; + current_login_session(&client, &http_cookie_jar(&app)?).await +} diff --git a/crates/desktop/artcraft/src/services/storyteller/commands/storyteller_login_bridge_tests.rs b/crates/desktop/artcraft/src/services/storyteller/commands/storyteller_login_bridge_tests.rs new file mode 100644 index 0000000000..2233f72997 --- /dev/null +++ b/crates/desktop/artcraft/src/services/storyteller/commands/storyteller_login_bridge_tests.rs @@ -0,0 +1,436 @@ +//! Commands' native implementation + real ArtCraft bindings + loopback HTTP. +//! All listeners bind to an ephemeral local port; no production host is contacted. +use super::*; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use artcraft_client::credentials::storyteller_avt_cookie::StorytellerAvtCookie; +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; +use serde_json::{json, Value}; +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::thread; +use std::time::Duration; +use tauri_plugin_http::reqwest_cookie_store::CookieStore; + +const SIGNED: &str = "signed_native_session_fixture"; +const DEVICE: &str = "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD"; + +#[tokio::test] +async fn approved_session_is_verified_installed_persisted_and_retried_entirely_in_rust() { + let server = server(vec![ + create(), + pending(), + redeemed(), + session(), + redeemed(), + session(), + ]); + let h = harness(); + // Native installation must preserve visitor cookies and avoid importing other origins. + h.jar.store.lock().unwrap().store_response_cookies( + [RawCookie::parse("visitor=existing; Path=/; Max-Age=3600") + .unwrap() + .into_owned()] + .into_iter(), + &server_url(&server), + ); + h.manager + .set_credentials(&StorytellerCredentialSet::initialize_with_just_avt( + StorytellerAvtCookie::new("existing".into()), + )) + .unwrap(); + let created = begin_challenge(&server.host, &h.state).await.unwrap(); + let public = serde_json::to_string(&created).unwrap(); + assert!(!public.contains(DEVICE)); + assert!(!public.contains("device_token")); + assert!(h + .manager + .get_credentials_required() + .unwrap() + .session + .is_none()); + let wait = poll_challenge(&h.state, &h.manager, &h.jar, &created.challenge_id) + .await + .unwrap(); + assert_eq!(wait.status, LoginChallengeState::Pending); + assert!(h + .manager + .get_credentials_required() + .unwrap() + .session + .is_none()); + for _ in 0..2 { + let result = poll_challenge(&h.state, &h.manager, &h.jar, &created.challenge_id) + .await + .unwrap(); + assert_eq!(result.status, LoginChallengeState::Redeemed); + assert_eq!(result.maybe_user.as_ref().unwrap().username, "google_user"); + // This was the real failure: use_qt exists on the server, not this desktop. + let flags = &result.maybe_user.as_ref().unwrap().maybe_feature_flags; + assert_eq!(flags.len(), 1); + assert_eq!(flags.first().unwrap().to_str(), "studio"); + assert!(!serde_json::to_string(&result).unwrap().contains(SIGNED)); + let credentials = h.manager.get_credentials_required().unwrap(); + assert_eq!(credentials.session.unwrap().as_str(), SIGNED); + assert_eq!(credentials.avt.unwrap().as_str(), "existing"); + assert!(std::fs::read_to_string(&h.jar.path) + .unwrap() + .contains(SIGNED)); + } + server.thread.join().unwrap(); +} + +#[tokio::test] +async fn rejection_and_timeout_never_install_a_session() { + for expired in [false, true] { + let mut steps = vec![create()]; + if !expired { + steps.push(step( + "/v1/login_challenges/poll", + json!({"success":true,"status":"failed","maybe_failure_type":"user_declined"}), + )); + } + let server = server(steps); + let h = harness(); + let created = begin_challenge(&server.host, &h.state).await.unwrap(); + if expired { + h.state + .pending + .lock() + .await + .get_mut(&created.challenge_id) + .unwrap() + .expires_at = Utc::now(); + } + let result = poll_challenge(&h.state, &h.manager, &h.jar, &created.challenge_id) + .await + .unwrap(); + assert_eq!(result.status, LoginChallengeState::Failed); + assert_eq!( + result.maybe_failure_type, + Some(if expired { + LoginChallengeFailure::Expired + } else { + LoginChallengeFailure::UserDeclined + }) + ); + assert!(h.manager.get_credentials().unwrap().is_none()); + assert!(h + .jar + .store + .lock() + .unwrap() + .iter_unexpired() + .next() + .is_none()); + server.thread.join().unwrap(); + } +} + +#[tokio::test] +async fn invalid_downstream_session_is_not_installed_and_error_has_host_without_credentials() { + let mut invalid = session(); + invalid.status = 401; + invalid.body = json!({"echo": SIGNED}); + let server = server(vec![create(), redeemed(), invalid]); + let h = harness(); + let created = begin_challenge(&server.host, &h.state).await.unwrap(); + let error = poll_challenge(&h.state, &h.manager, &h.jar, &created.challenge_id) + .await + .err() + .unwrap(); + assert_eq!(error.status, Some(401)); + assert!(!error.retryable); + assert!(error + .message + .contains(&server.host.to_api_hostname_and_scheme())); + assert!(!format!("{error:?}").contains(SIGNED)); + assert!(h.manager.get_credentials().unwrap().is_none()); + assert!(h + .jar + .store + .lock() + .unwrap() + .iter_unexpired() + .next() + .is_none()); + server.thread.join().unwrap(); +} + +#[tokio::test] +async fn cancelling_during_http_prevents_late_cookie_installation() { + let mut delayed = redeemed(); + delayed.delay = Duration::from_millis(150); + let server = server(vec![create(), delayed, session()]); + let h = harness(); + let created = begin_challenge(&server.host, &h.state).await.unwrap(); + let (result, _) = tokio::join!( + poll_challenge(&h.state, &h.manager, &h.jar, &created.challenge_id), + async { + tokio::time::sleep(Duration::from_millis(30)).await; + cancel_challenge(&h.state, &created.challenge_id).await; + } + ); + assert!(result.is_err()); + assert!(h.manager.get_credentials().unwrap().is_none()); + assert!(h + .jar + .store + .lock() + .unwrap() + .iter_unexpired() + .next() + .is_none()); + server.thread.join().unwrap(); +} + +#[tokio::test] +async fn unknown_states_and_redirects_fail_closed() { + for redirect in [false, true] { + let mut result = step( + "/v1/login_challenges/poll", + json!({"success":true,"status":"future_state","maybe_signed_session":SIGNED}), + ); + if redirect { + result.status = 302; + result.headers = "Location: https://must-never-be-contacted.invalid/\r\n".into(); + } + let server = server(vec![create(), result]); + let h = harness(); + let created = begin_challenge(&server.host, &h.state).await.unwrap(); + let error = poll_challenge(&h.state, &h.manager, &h.jar, &created.challenge_id) + .await + .err() + .unwrap(); + assert!(!error.retryable); + if redirect { + assert_eq!(error.status, Some(302)); + } + assert!(h.manager.get_credentials().unwrap().is_none()); + server.thread.join().unwrap(); + } +} + +#[test] +fn website_origin_must_match_native_api_environment() { + let local = LoginChallengeClient::new(&ApiHost::Localhost { port: 12345 }).unwrap(); + let prod = LoginChallengeClient::new(&ApiHost::Storyteller).unwrap(); + let fragment = format!("#approval_token={}", "A".repeat(43)); + assert!(local.allows_verification_url(&format!("http://localhost:4201/login/desktop{fragment}"))); + assert!(!local.allows_verification_url(&format!( + "https://app.getartcraft.com/login/desktop{fragment}" + ))); + assert!(prod.allows_verification_url(&format!( + "https://app.getartcraft.com/login/desktop{fragment}" + ))); + assert!(!prod.allows_verification_url(&format!("http://localhost:4201/login/desktop{fragment}"))); +} + +#[tokio::test] +async fn malformed_session_data_still_cannot_install_credentials() { + for (field, value) in [("maybe_feature_flags", json!([42])), ("username", Value::Null)] { + let mut invalid = session(); + invalid.body["user"][field] = value; + let server = server(vec![create(), redeemed(), invalid]); + let h = harness(); + let created = begin_challenge(&server.host, &h.state).await.unwrap(); + assert!(poll_challenge(&h.state, &h.manager, &h.jar, &created.challenge_id).await.is_err()); + assert!(h.manager.get_credentials().unwrap().is_none()); + assert!(h.jar.store.lock().unwrap().iter_unexpired().next().is_none()); + server.thread.join().unwrap(); + } +} + +#[tokio::test] +async fn password_login_and_signup_verify_persist_and_recheck_via_native_api() { + for signup in [false, true] { + let mut auth = redeemed(); + auth.path = if signup { "/v1/create_account" } else { "/v1/login" }; + auth.body = json!({"success":true,"signed_session":SIGNED}); + let server = server(vec![auth, session(), session()]); + let h = harness(); + let (login, signup) = if signup { + (None, Some(PasswordSignupRequest { username: "google_user".into(), email_address: "user@example.test".into(), password: "fixture_password".into(), password_confirmation: "fixture_password".into(), signup_source: "artcraft".into() })) + } else { + (Some(PasswordLoginRequest { username_or_email: "google_user".into(), password: "fixture_password".into() }), None) + }; + let user = password_auth(&server.host, &h.manager, &h.jar, login, signup).await.unwrap(); + assert_eq!(user.username, "google_user"); + assert_eq!(h.manager.get_credentials_required().unwrap().session.unwrap().as_str(), SIGNED); + assert!(std::fs::read_to_string(&h.jar.path).unwrap().contains(SIGNED)); + let client = LoginChallengeClient::new(&server.host).unwrap(); + assert_eq!(current_login_session(&client, &h.jar).await.unwrap().unwrap().username, "google_user"); + server.thread.join().unwrap(); + } +} + +#[tokio::test] +async fn empty_native_cookie_jar_needs_no_session_http_request() { + let h = harness(); + let client = LoginChallengeClient::new(&ApiHost::Localhost { port: 1 }).unwrap(); + assert!(current_login_session(&client, &h.jar).await.unwrap().is_none()); +} + +struct Harness { + _directory: tempfile::TempDir, + state: DesktopLoginBridgeState, + manager: StorytellerCredentialManager, + jar: Arc, +} + +struct Server { + host: ApiHost, + thread: thread::JoinHandle<()>, +} + +struct Step { + path: &'static str, + status: u16, + body: Value, + headers: String, + delay: Duration, +} + +fn harness() -> Harness { + let directory = tempfile::tempdir().unwrap(); + let root = AppDataRoot::create_existing(directory.path()).unwrap(); + let manager = StorytellerCredentialManager::initialize_empty(&root); + let jar = Arc::new(CookieStoreMutex::new( + directory.path().join("cookies.json"), + CookieStore::default(), + )); + Harness { + _directory: directory, + state: DesktopLoginBridgeState::default(), + manager, + jar, + } +} + +fn server_url(server: &Server) -> reqwest::Url { + reqwest::Url::parse(&format!("{}/", server.host.to_api_hostname_and_scheme())).unwrap() +} + +fn server(steps: Vec) -> Server { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let thread = thread::spawn(move || { + for step in steps { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(10))) + .unwrap(); + let mut data = Vec::new(); + let mut buffer = [0; 4096]; + loop { + let count = stream.read(&mut buffer).unwrap(); + assert!(count > 0); + data.extend_from_slice(&buffer[..count]); + if let Some(end) = data.windows(4).position(|v| v == b"\r\n\r\n") { + let headers = String::from_utf8_lossy(&data[..end]).to_lowercase(); + let length: usize = headers + .lines() + .find_map(|s| s.strip_prefix("content-length:")) + .map(|s| s.trim().parse().unwrap()) + .unwrap_or(0); + if data.len() >= end + 4 + length { + break; + } + } + } + let request = String::from_utf8(data).unwrap(); + assert_eq!(request.split_whitespace().nth(1), Some(step.path)); + assert!(request + .to_lowercase() + .contains(&format!("host: localhost:{port}"))); + if step.path == "/v1/session" { + assert!(request.starts_with("GET ")); + assert!(request.contains(&format!("session={SIGNED}"))); + } else { + assert!(request.starts_with("POST ")); + assert!(!request.to_lowercase().contains("cookie:")); + if step.path.ends_with("/poll") { + let body: Value = + serde_json::from_str(request.split_once("\r\n\r\n").unwrap().1).unwrap(); + assert_eq!(body, json!({"device_token":DEVICE})); + } + } + thread::sleep(step.delay); + let body = step.body.to_string(); + write!(stream, "HTTP/1.1 {} Test\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n{}\r\n{}", step.status, body.len(), step.headers, body).unwrap(); + } + }); + Server { + host: ApiHost::Localhost { port: port.into() }, + thread, + } +} + +fn step(path: &'static str, body: Value) -> Step { + Step { + path, + body, + status: 200, + headers: String::new(), + delay: Duration::ZERO, + } +} + +fn create() -> Step { + step( + "/v1/login_challenges/create", + json!({"success":true,"device_token":DEVICE,"verification_url":format!("http://localhost:4201/login/desktop#approval_token={}","A".repeat(43)),"confirmation_code":"WDJBMJHT","expires_at":Utc::now()+chrono::Duration::minutes(20),"poll_interval_seconds":5}), + ) +} + +fn pending() -> Step { + step( + "/v1/login_challenges/poll", + json!({"success":true,"status":"pending","maybe_failure_type":null}), + ) +} + +fn redeemed() -> Step { + let mut response = step( + "/v1/login_challenges/poll", + json!({"success":true,"status":"redeemed","maybe_failure_type":null,"maybe_signed_session":SIGNED}), + ); + response.headers = format!("Set-Cookie: session={SIGNED}; HttpOnly; Path=/; Max-Age=3600\r\n"); + response +} + +fn session() -> Step { + let mut user = json!({ + "user_token":"u_native_test", "username":"google_user", "display_name":"Google User", "email_gravatar_hash":"fixture", + "core_info":{"user_token":"u_native_test","username":"google_user","display_name":"Google User","gravatar_hash":"fixture","default_avatar":{"image_index":1,"color_index":1}}, + "onboarding":{"email_not_set":false,"email_not_confirmed":false,"password_not_set":true,"username_not_customized":false}, + "maybe_feature_flags":["studio", "use_qt", "future_server_flag"],"fakeyou_plan":"free","storyteller_stream_plan":"free" + }); + for field in [ + "can_access_studio", + "can_use_tts", + "can_use_w2l", + "can_delete_own_tts_results", + "can_delete_own_w2l_results", + "can_delete_own_account", + "can_upload_tts_models", + "can_upload_w2l_templates", + "can_delete_own_tts_models", + "can_delete_own_w2l_templates", + "can_approve_w2l_templates", + "can_edit_other_users_profiles", + "can_edit_other_users_tts_models", + "can_edit_other_users_w2l_templates", + "can_delete_other_users_tts_models", + "can_delete_other_users_w2l_templates", + "can_delete_other_users_tts_results", + "can_delete_other_users_w2l_results", + "can_ban_users", + "can_delete_users", + ] { + user[field] = json!(false); + } + step( + "/v1/session", + json!({"success":true,"logged_in":true,"user":user}), + ) +} diff --git a/crates/desktop/artcraft/src/services/storyteller/commands/storyteller_password_login_command.rs b/crates/desktop/artcraft/src/services/storyteller/commands/storyteller_password_login_command.rs new file mode 100644 index 0000000000..e9367a4916 --- /dev/null +++ b/crates/desktop/artcraft/src/services/storyteller/commands/storyteller_password_login_command.rs @@ -0,0 +1,26 @@ +use artcraft_api_defs::users::session_info::SessionUserInfo; +use artcraft_client::endpoints::users::password_login::PasswordLoginRequest; +use tauri::{AppHandle, State}; + +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::services::storyteller::commands::login_bridge::{ + http_cookie_jar, password_auth, DesktopLoginError, +}; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; + +#[tauri::command] +pub async fn storyteller_password_login_command( + app: AppHandle, + config: State<'_, AppEnvConfigs>, + manager: State<'_, StorytellerCredentialManager>, + request: PasswordLoginRequest, +) -> Result { + password_auth( + &config.storyteller_host, + &manager, + &http_cookie_jar(&app)?, + Some(request), + None, + ) + .await +} diff --git a/crates/desktop/artcraft/src/services/storyteller/commands/storyteller_password_signup_command.rs b/crates/desktop/artcraft/src/services/storyteller/commands/storyteller_password_signup_command.rs new file mode 100644 index 0000000000..694764c655 --- /dev/null +++ b/crates/desktop/artcraft/src/services/storyteller/commands/storyteller_password_signup_command.rs @@ -0,0 +1,26 @@ +use artcraft_api_defs::users::session_info::SessionUserInfo; +use artcraft_client::endpoints::users::password_signup::PasswordSignupRequest; +use tauri::{AppHandle, State}; + +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::services::storyteller::commands::login_bridge::{ + http_cookie_jar, password_auth, DesktopLoginError, +}; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; + +#[tauri::command] +pub async fn storyteller_password_signup_command( + app: AppHandle, + config: State<'_, AppEnvConfigs>, + manager: State<'_, StorytellerCredentialManager>, + request: PasswordSignupRequest, +) -> Result { + password_auth( + &config.storyteller_host, + &manager, + &http_cookie_jar(&app)?, + None, + Some(request), + ) + .await +} diff --git a/crates/desktop/artcraft/src/services/storyteller/commands/storyteller_poll_login_challenge_command.rs b/crates/desktop/artcraft/src/services/storyteller/commands/storyteller_poll_login_challenge_command.rs new file mode 100644 index 0000000000..549efa116c --- /dev/null +++ b/crates/desktop/artcraft/src/services/storyteller/commands/storyteller_poll_login_challenge_command.rs @@ -0,0 +1,17 @@ +use tauri::{AppHandle, State}; + +use crate::services::storyteller::commands::login_bridge::{ + http_cookie_jar, poll_challenge, DesktopLoginBridgeState, DesktopLoginError, DesktopLoginOutcome, +}; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; + +#[tauri::command] +pub async fn storyteller_poll_login_challenge_command( + app: AppHandle, + state: State<'_, DesktopLoginBridgeState>, + manager: State<'_, StorytellerCredentialManager>, + challenge_id: String, +) -> Result { + let jar = http_cookie_jar(&app)?; + poll_challenge(&state, &manager, &jar, &challenge_id).await +} diff --git a/crates/desktop/artcraft/src/services/storyteller/threads/storyteller_activity_thread.rs b/crates/desktop/artcraft/src/services/storyteller/threads/storyteller_activity_thread.rs index dae9c58222..c5de1af300 100644 --- a/crates/desktop/artcraft/src/services/storyteller/threads/storyteller_activity_thread.rs +++ b/crates/desktop/artcraft/src/services/storyteller/threads/storyteller_activity_thread.rs @@ -3,9 +3,10 @@ use crate::core::state::artcraft_platform_info::ArtcraftPlatformInfo; use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker; use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; use anyhow::anyhow; -use artcraft_api_defs::analytics::log_active_user::LogAppActiveUserRequest; +use artcraft_api_defs::analytics::log_active_user::{LogAppActiveUserRequest, LOG_ACTIVE_USER_V2_PATH}; use artcraft_client::endpoints::analytics::log_active_user_v2::log_active_user_v2; use artcraft_client::error::api_error::ApiError; +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; use artcraft_client::error::storyteller_error::StorytellerError; use errors::AnyhowResult; use log::{debug, error, info}; @@ -38,7 +39,7 @@ pub async fn storyteller_activity_thread( &app_session_token, ).await; if let Err(err) = res { - error!("An error occurred: {:?}", err); + error!("Activity request failed: origin={} path={} error={:?}", app_env_configs.storyteller_host.to_api_hostname_and_scheme(), LOG_ACTIVE_USER_V2_PATH, err); } // NB: Sleep if an error occurs. tokio::time::sleep(std::time::Duration::from_millis(ERROR_SLEEP_MILLIS)).await; @@ -62,7 +63,7 @@ async fn polling_loop( continue; } Some(creds) => { - if creds.is_empty() { + if !has_activity_session(&creds) { tokio::time::sleep(std::time::Duration::from_millis(5_000)).await; continue; } @@ -123,7 +124,31 @@ async fn polling_loop( }; // Wait at least a minute, no matter what the server tells us. - let wait_millis = std::cmp::min(wait_millis, 60_000); + let wait_millis = std::cmp::max(wait_millis, 60_000); tokio::time::sleep(std::time::Duration::from_millis(wait_millis)).await; } } + +// Anonymous visitor tracking is not authentication for active_user_v2. +fn has_activity_session(creds: &StorytellerCredentialSet) -> bool { + creds.session.as_ref().map(|session| !session.as_str().is_empty()).unwrap_or(false) +} + +#[cfg(test)] +mod login_bridge_tests { + use super::has_activity_session; + use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; + use artcraft_client::credentials::storyteller_avt_cookie::StorytellerAvtCookie; + use artcraft_client::credentials::storyteller_session_cookie::StorytellerSessionCookie; + + #[test] + fn anonymous_visitor_cookie_does_not_authorize_activity_requests() { + assert!(!has_activity_session(&StorytellerCredentialSet::empty())); + let visitor_only = StorytellerCredentialSet::initialize_with_just_avt(StorytellerAvtCookie::new("visitor_fixture".into())); + assert!(!has_activity_session(&visitor_only)); + let empty_session = StorytellerCredentialSet::initialize_with_just_cookie(StorytellerSessionCookie::new(String::new())); + assert!(!has_activity_session(&empty_session)); + let signed_in = StorytellerCredentialSet::initialize_with_just_cookie(StorytellerSessionCookie::new("signed_session_fixture".into())); + assert!(has_activity_session(&signed_in)); + } +} diff --git a/frontend/libs/components/login-modal/src/lib/DesktopLoginBridge.spec.tsx b/frontend/libs/components/login-modal/src/lib/DesktopLoginBridge.spec.tsx new file mode 100644 index 0000000000..d56dbb9de9 --- /dev/null +++ b/frontend/libs/components/login-modal/src/lib/DesktopLoginBridge.spec.tsx @@ -0,0 +1,167 @@ +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DesktopLoginBridge } from "./DesktopLoginBridge"; + +const { native, forbiddenFetch } = vi.hoisted(() => ({ native: vi.fn(), forbiddenFetch: vi.fn(() => { throw new Error("JavaScript must never call HTTP for login"); }) })); +vi.mock("@tauri-apps/api/core", () => ({ invoke: native })); +vi.mock("@storyteller/tauri-utils", () => ({ FetchProxy: forbiddenFetch })); +vi.mock("@storyteller/api", () => { throw new Error("Login UI must only import API types"); }); +vi.mock("qrcode.react", () => ({ QRCodeSVG: ({ value }: { value: string }) => })); + +const CHALLENGE_ID = "local-handle"; +const APPROVAL_URL = `https://app.getartcraft.com/login/desktop#approval_token=${"A".repeat(43)}`; +const USER = { user_token: "user_fixture", username: "google_user" }; +let outcome: Record; +let expiresIn: number; + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-25T12:00:00Z")); + vi.stubGlobal("fetch", forbiddenFetch); + localStorage.clear(); + native.mockReset(); + forbiddenFetch.mockClear(); + expiresIn = 1_200_000; + outcome = { status: "pending", maybe_failure_type: null, maybe_user: null }; + native.mockImplementation(async (command: string) => { + if (command === "storyteller_create_login_challenge_command") return { + challenge_id: CHALLENGE_ID, verification_url: APPROVAL_URL, + confirmation_code: "WDJBMJHT", expires_at: new Date(Date.now() + expiresIn).toISOString(), poll_interval_seconds: 5, + }; + if (command === "storyteller_poll_login_challenge_command") return outcome; + if (command === "storyteller_cancel_login_challenge_command" || command === "plugin:opener|open_url") return; + throw new Error(`Unexpected native command: ${command}`); + }); +}); +afterEach(() => { + cleanup(); + expect(forbiddenFetch).not.toHaveBeenCalled(); + expect(localStorage.getItem("artcraft_signed_session")).toBeNull(); + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe("desktop native login bridge integration", () => { + it("creates through IPC and opens the native browser with the matching QR/code", async () => { + render(); + await start("Login with Website"); + expect(native).toHaveBeenCalledWith("storyteller_create_login_challenge_command"); + expect(native).toHaveBeenCalledWith("plugin:opener|open_url", { url: APPROVAL_URL }); + expect(screen.getByRole("img", { name: "Login QR" }).getAttribute("data-value")).toBe(APPROVAL_URL); + expect(screen.getByText("WDJB-MJHT")).toBeTruthy(); + }); + + it("polls with only a local handle and accepts the natively verified user without seeing credentials", async () => { + const success = vi.fn(); + render(); + await start("Scan to Login"); + await tick(5000); + expect(success).not.toHaveBeenCalled(); + outcome = { status: "redeemed", maybe_failure_type: null, maybe_user: USER }; + await tick(5000); + expect(native).toHaveBeenCalledWith("storyteller_poll_login_challenge_command", { challengeId: CHALLENGE_ID }); + expect(success).toHaveBeenCalledWith(USER); + expect(native).toHaveBeenCalledWith("storyteller_cancel_login_challenge_command", { challengeId: CHALLENGE_ID }); + const count = calls("storyteller_poll_login_challenge_command"); + await tick(30_000); + expect(calls("storyteller_poll_login_challenge_command")).toBe(count); + }); + + it("stops after decline", async () => { + const success = vi.fn(); + outcome = { status: "failed", maybe_failure_type: "user_declined" }; + render(); + await start("Scan to Login"); + await tick(5000); + expect(screen.getByText("Login was declined on the website.")).toBeTruthy(); + expect(success).not.toHaveBeenCalled(); + await tick(60_000); + expect(calls("storyteller_poll_login_challenge_command")).toBe(1); + }); + + it("expires locally and cancels the native attempt without creating another", async () => { + expiresIn = 5000; + render(); + await start("Scan to Login"); + await tick(5000); + expect(screen.getByText("Login request expired. Start a new request.")).toBeTruthy(); + expect(calls("storyteller_create_login_challenge_command")).toBe(1); + expect(calls("storyteller_poll_login_challenge_command")).toBe(0); + expect(calls("storyteller_cancel_login_challenge_command")).toBe(1); + }); + + it("backs off a native network failure and retries the same local handle", async () => { + render(); + await start("Scan to Login"); + native.mockRejectedValueOnce({ message: "Disconnected", status: null, retryable: true }); + await tick(5000); + expect(screen.getByText("Connection interrupted. Retrying…")).toBeTruthy(); + await tick(9999); + expect(calls("storyteller_poll_login_challenge_command")).toBe(1); + await tick(1); + expect(calls("storyteller_poll_login_challenge_command")).toBe(2); + }); + + it("cancels on unmount and ignores a late command result", async () => { + let resolve: (value: unknown) => void = () => {}; + const success = vi.fn(); + const view = render(); + await start("Scan to Login"); + native.mockImplementationOnce(() => new Promise((done) => { resolve = done; })); + await tick(5000); + view.unmount(); + expect(native).toHaveBeenCalledWith("storyteller_cancel_login_challenge_command", { challengeId: CHALLENGE_ID }); + await act(async () => { resolve({ status: "redeemed", maybe_user: USER }); }); + expect(success).not.toHaveBeenCalled(); + }); + + it("ignores an in-flight redemption after Back and can start a new challenge", async () => { + let resolve: (value: unknown) => void = () => {}; + const success = vi.fn(); + render(); + await start("Scan to Login"); + native.mockImplementationOnce(() => new Promise((done) => { resolve = done; })); + await tick(5000); + await start("Back"); + expect(native).toHaveBeenCalledWith("storyteller_cancel_login_challenge_command", { challengeId: CHALLENGE_ID }); + await act(async () => { resolve({ status: "redeemed", maybe_user: USER }); }); + expect(success).not.toHaveBeenCalled(); + expect(screen.queryByRole("img", { name: "Login QR" })).toBeNull(); + await tick(30_000); + expect(calls("storyteller_poll_login_challenge_command")).toBe(1); + + await start("Scan to Login"); + expect(screen.getByRole("img", { name: "Login QR" })).toBeTruthy(); + outcome = { status: "redeemed", maybe_user: USER }; + await tick(5000); + expect(success).toHaveBeenCalledExactlyOnceWith(USER); + }); + + it("fails closed on a future status", async () => { + outcome = { status: "future_state", maybe_user: USER }; + render(); + await start("Scan to Login"); + await tick(5000); + expect(screen.getByText("This login request could not be completed. Start a new request.")).toBeTruthy(); + }); + + it("shows the native host/status diagnostic and stops on terminal HTTP errors", async () => { + render(); + await start("Scan to Login"); + native.mockRejectedValueOnce({ message: "http://localhost:12345: Login server rejected the request (HTTP 401)", status: 401, retryable: false }); + await tick(5000); + expect(screen.getByText(/localhost:12345.*401/)).toBeTruthy(); + await tick(60_000); + expect(calls("storyteller_poll_login_challenge_command")).toBe(1); + }); +}); + +async function start(label: string) { + await act(async () => { fireEvent.click(screen.getByRole("button", { name: label })); }); +} +async function tick(ms: number) { + await act(async () => { await vi.advanceTimersByTimeAsync(ms); }); +} +function calls(command: string) { + return native.mock.calls.filter(([name]) => name === command).length; +} diff --git a/frontend/libs/components/login-modal/src/lib/DesktopLoginBridge.tsx b/frontend/libs/components/login-modal/src/lib/DesktopLoginBridge.tsx new file mode 100644 index 0000000000..fbf4bc68e3 --- /dev/null +++ b/frontend/libs/components/login-modal/src/lib/DesktopLoginBridge.tsx @@ -0,0 +1,134 @@ +import { useEffect, useRef, useState } from "react"; +import { QRCodeSVG } from "qrcode.react"; +import { invoke } from "@tauri-apps/api/core"; +import { ArrowLeftIcon } from "lucide-react"; +import type { UserInfo } from "@storyteller/api"; +import { createDesktopLoginChallenge, pollDesktopLoginChallenge, cancelDesktopLoginChallenge, isDesktopLoginError, type DesktopLoginChallenge } from "./NativeLoginBridge"; + +const OpenUrl = (url: string) => invoke("plugin:opener|open_url", { url }); + +export function DesktopLoginBridge({ onSuccess, onStart, onActiveChange }: { onSuccess: (user: UserInfo) => void; onStart?: () => void; onActiveChange?: (active: boolean) => void }) { + const [challenge, setChallenge] = useState(null); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(""); + const [now, setNow] = useState(Date.now); + const generation = useRef(0); + const success = useRef(onSuccess); + success.current = onSuccess; + const isActive = busy || challenge !== null; + + useEffect(() => { onActiveChange?.(isActive); }, [isActive, onActiveChange]); + + useEffect(() => () => { generation.current += 1; }, []); + useEffect(() => { + if (!challenge) return; + const timer = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(timer); + }, [challenge]); + + useEffect(() => { + if (!challenge) return; + let active = true; + const current = generation.current; + let timer: ReturnType; + let delay = Math.max(5, challenge.poll_interval_seconds) * 1000; + const deadline = Date.parse(challenge.expires_at); + const finish = (text: string) => { + setChallenge(null); + setMessage(text); + }; + const poll = async () => { + if (!active || current !== generation.current) return; + if (Date.now() >= deadline) { finish("Login request expired. Start a new request."); return; } + try { + const result = await pollDesktopLoginChallenge(challenge.challenge_id); + if (!active || current !== generation.current) return; + if (result.status === "redeemed") { + if (!result.maybe_user) throw new Error("Native session verification did not return a user"); + active = false; + setChallenge(null); + setMessage("Signed in successfully."); + success.current(result.maybe_user); + return; + } + if (result.status === "failed") { + finish(result.maybe_failure_type === "user_declined" ? "Login was declined on the website." : result.maybe_failure_type === "expired" ? "Login request expired. Start a new request." : "Login failed. Start a new request."); + return; + } + if (!["pending", "approved"].includes(result.status)) { + finish("This login request could not be completed. Start a new request."); + return; + } + delay = Math.max(5, challenge.poll_interval_seconds) * 1000; + setMessage("Waiting for your confirmation on the website…"); + } catch (error) { + if (!active || current !== generation.current) return; + if (isDesktopLoginError(error) && !error.retryable) { + finish(error.message); + return; + } + delay = Math.min(delay * 2, 30_000); + setMessage("Connection interrupted. Retrying…"); + } + if (active) timer = setTimeout(poll, Math.min(delay, Math.max(0, deadline - Date.now()))); + }; + timer = setTimeout(poll, Math.min(delay, Math.max(0, deadline - Date.now()))); + return () => { + active = false; + clearTimeout(timer); + void cancelDesktopLoginChallenge(challenge.challenge_id).catch(() => {}); + }; + }, [challenge]); + + const start = async (openBrowser: boolean) => { + if (busy) return; + onStart?.(); + const current = ++generation.current; + setBusy(true); + setMessage(""); + try { + const created = await createDesktopLoginChallenge(); + if (current !== generation.current) { + void cancelDesktopLoginChallenge(created.challenge_id).catch(() => {}); + return; + } + // Rust validates the approval URL against the configured API environment. + setNow(Date.now()); + setChallenge(created); + setMessage("Waiting for your confirmation on the website…"); + if (openBrowser) await OpenUrl(created.verification_url); + } catch (error) { + if (current === generation.current) setMessage(isDesktopLoginError(error) ? error.message : "Unable to open website login. Try again or scan the QR code."); + } finally { + if (current === generation.current) setBusy(false); + } + }; + + const back = () => { + generation.current += 1; + setChallenge(null); + setBusy(false); + setMessage(""); + }; + + const remaining = challenge ? Math.max(0, Math.ceil((Date.parse(challenge.expires_at) - now) / 1000)) : 0; + return
+ {isActive &&
+ +
} + {challenge ? <> +

Scan with your phone or approve in your browser.

+ +

{challenge.confirmation_code.slice(0, 4)}-{challenge.confirmation_code.slice(4)}

+

Verify this code on the website. Expires in {Math.floor(remaining / 60)}:{String(remaining % 60).padStart(2, "0")}

+ + : busy ?

Preparing login…

:
+ + +

Use Google or any account already signed in on the website.

+
} + {message &&

{message}

} +
; +} diff --git a/frontend/libs/components/login-modal/src/lib/LoginSuccess.module.css b/frontend/libs/components/login-modal/src/lib/LoginSuccess.module.css new file mode 100644 index 0000000000..7db4a2e1ec --- /dev/null +++ b/frontend/libs/components/login-modal/src/lib/LoginSuccess.module.css @@ -0,0 +1,86 @@ +.panel { + position: relative; + isolation: isolate; + display: flex; + width: 100%; + flex-direction: column; + align-items: center; + overflow: hidden; + padding: 56px 32px; + text-align: center; + background: radial-gradient(ellipse at 50% 0%, rgb(94 234 212 / 9%), transparent 70%); + animation: arrive 450ms ease-out both; +} + +.glow { + position: absolute; + z-index: -1; + top: -52px; + left: calc(50% - 144px); + width: 288px; + height: 288px; + border-radius: 50%; + background: radial-gradient(circle, rgb(94 234 212 / 24%), rgb(129 140 248 / 12%) 45%, transparent 70%); + filter: blur(24px); + pointer-events: none; + animation: glow 2400ms ease-in-out infinite; +} + +.badge { + display: flex; + width: 76px; + height: 76px; + align-items: center; + justify-content: center; + margin-bottom: 28px; + border: 1px solid rgb(153 246 228 / 35%); + border-radius: 50%; + color: #b4f7de; + background: linear-gradient(145deg, rgb(153 246 228 / 16%), rgb(94 234 212 / 4%)); + box-shadow: 0 0 0 8px rgb(94 234 212 / 4%), 0 0 44px rgb(94 234 212 / 18%), inset 0 1px 0 rgb(255 255 255 / 12%); +} + +.heading { + margin: 0; + max-width: 100%; + color: rgb(255 255 255 / 80%); + font-size: 18px; + font-weight: 500; + line-height: 1.5; +} + +.username { + display: block; + margin-top: 4px; + overflow-wrap: anywhere; + color: #b4f7de; + background: linear-gradient(100deg, #d4ffec, #99f6e4 55%, #c7d2fe); + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + font-size: 30px; + font-weight: 600; + letter-spacing: -0.025em; + line-height: 1.25; +} + +.caption { + margin: 14px 0 0; + color: rgb(255 255 255 / 50%); + font-size: 14px; + line-height: 1.5; +} + +@keyframes arrive { + from { opacity: 0; transform: translateY(8px) scale(0.98); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +@keyframes glow { + 0%, 100% { opacity: 0.7; transform: scale(0.95); } + 50% { opacity: 1; transform: scale(1.06); } +} + +@media (prefers-reduced-motion: reduce) { + .panel, .glow { animation: none; } +} diff --git a/frontend/libs/components/login-modal/src/lib/LoginSuccess.tsx b/frontend/libs/components/login-modal/src/lib/LoginSuccess.tsx new file mode 100644 index 0000000000..08ebca8721 --- /dev/null +++ b/frontend/libs/components/login-modal/src/lib/LoginSuccess.tsx @@ -0,0 +1,18 @@ +import { CheckIcon } from "lucide-react"; +import styles from "./LoginSuccess.module.css"; + +export function LoginSuccess({ username }: { username: string }) { + return ( +
+ + ); +} diff --git a/frontend/libs/components/login-modal/src/lib/NativeLoginBridge.ts b/frontend/libs/components/login-modal/src/lib/NativeLoginBridge.ts new file mode 100644 index 0000000000..1ede30cc9e --- /dev/null +++ b/frontend/libs/components/login-modal/src/lib/NativeLoginBridge.ts @@ -0,0 +1,38 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { UserInfo } from "@storyteller/api"; + +export interface DesktopLoginChallenge { + challenge_id: string; + verification_url: string; + confirmation_code: string; + expires_at: string; + poll_interval_seconds: number; +} + +export interface DesktopLoginOutcome { + status: string; + maybe_failure_type: string | null; + maybe_user: UserInfo | null; +} + +export interface DesktopLoginError { + status: number | null; + message: string; + retryable: boolean; +} + +export const createDesktopLoginChallenge = () => invoke("storyteller_create_login_challenge_command"); +export const pollDesktopLoginChallenge = (challengeId: string) => invoke("storyteller_poll_login_challenge_command", { challengeId }); +export const cancelDesktopLoginChallenge = (challengeId: string) => invoke("storyteller_cancel_login_challenge_command", { challengeId }); + +export function isDesktopLoginError(error: unknown): error is DesktopLoginError { + return typeof error === "object" && error !== null && "message" in error && "retryable" in error; +} + +export const getNativeLoginSession = () => invoke("storyteller_get_login_session_command"); +export const passwordLogin = (usernameOrEmail: string, password: string) => invoke("storyteller_password_login_command", { + request: { username_or_email: usernameOrEmail, password }, +}); +export const passwordSignup = (username: string, email: string, password: string, passwordConfirmation: string) => invoke("storyteller_password_signup_command", { + request: { username, email_address: email, password, password_confirmation: passwordConfirmation, signup_source: "artcraft" }, +}); diff --git a/frontend/libs/components/login-modal/src/lib/login-modal.spec.tsx b/frontend/libs/components/login-modal/src/lib/login-modal.spec.tsx index 14abc2b9bb..dcffc9a10b 100644 --- a/frontend/libs/components/login-modal/src/lib/login-modal.spec.tsx +++ b/frontend/libs/components/login-modal/src/lib/login-modal.spec.tsx @@ -1,12 +1,182 @@ -import { render } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { LoginModal } from "./login-modal"; +import { useLoginModalStore } from "./useLoginModalStore"; -import LoginModal from './login-modal'; +const { native, forbiddenFetch } = vi.hoisted(() => ({ native: vi.fn(), forbiddenFetch: vi.fn(() => { throw new Error("Authentication HTTP must run in Rust"); }) })); +vi.mock("@tauri-apps/api/core", () => ({ invoke: native })); +vi.mock("@storyteller/api", () => { throw new Error("Authentication UI must not import API clients"); }); +vi.mock("@storyteller/tauri-utils", () => ({ FetchProxy: forbiddenFetch })); +vi.mock("@headlessui/react", () => ({ Transition: ({ show, children }: any) => show ? children : null, TransitionChild: ({ children }: any) => children })); +vi.mock("@storyteller/icons", () => ({ DynamicIcon: () => null, DiscordIcon: () => null })); +vi.mock("@storyteller/ui-button", () => ({ Button: ({ children, variant, icon, iconFlip, ...props }: any) => })); +vi.mock("@storyteller/ui-input", () => ({ Input: ({ inputClassName, ...props }: any) => })); +vi.mock("qrcode.react", () => ({ QRCodeSVG: () => })); -describe('LoginModal', () => { - - it('should render successfully', () => { - const { baseElement } = render(); - expect(baseElement).toBeTruthy(); +const USER = { username: "google_user", user_token: "u_test" }; + +beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal("fetch", forbiddenFetch); + forbiddenFetch.mockClear(); + localStorage.clear(); + useLoginModalStore.setState({ isOpen: true, recheckTrigger: 0 }); + native.mockReset().mockImplementation(async (command: string) => { + if (command === "storyteller_get_login_session_command") return null; + if (command === "storyteller_create_login_challenge_command") return { + challenge_id: "native_handle", verification_url: `https://app.getartcraft.com/login/desktop#approval_token=${"A".repeat(43)}`, + confirmation_code: "WDJBMJHT", expires_at: new Date(Date.now() + 1_200_000).toISOString(), poll_interval_seconds: 5, + }; + if (command === "storyteller_poll_login_challenge_command") return { status: "redeemed", maybe_user: USER }; + if (command === "storyteller_password_login_command" || command === "storyteller_password_signup_command") return USER; + if (command === "storyteller_cancel_login_challenge_command" || command === "plugin:opener|open_url") return; + throw new Error(`Unexpected command: ${command}`); + }); +}); +afterEach(() => { + cleanup(); + expect(forbiddenFetch).not.toHaveBeenCalled(); + expect(localStorage.getItem("artcraft_signed_session")).toBeNull(); + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe("native login modal integration", () => { + it("shows website and QR login only on the login screen", async () => { + await act(async () => { render(); }); + expect(screen.getByText("Create your account")).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Login with Website" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Scan to Login" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Log in" })); + expect(screen.getByRole("button", { name: "Login with Website" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Scan to Login" })).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Sign up" })); + expect(screen.queryByRole("button", { name: "Login with Website" })).toBeNull(); + }); + + it.each(["Login with Website", "Scan to Login"])("hides password login during %s and restores it with Back", async (label) => { + const success = vi.fn(); + await act(async () => { render(); }); + expect(screen.getByPlaceholderText("you@example.com or username")).toBeTruthy(); + await act(async () => { fireEvent.click(screen.getByRole("button", { name: label })); }); + expect(screen.getByRole("img", { name: "Login QR" })).toBeTruthy(); + expect(screen.queryByPlaceholderText("you@example.com or username")).toBeNull(); + expect(screen.queryByPlaceholderText("Min. 8 characters")).toBeNull(); + expect(screen.queryByRole("button", { name: "Sign up" })).toBeNull(); + + await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Back" })); }); + expect(screen.getByPlaceholderText("you@example.com or username")).toBeTruthy(); + expect(screen.getByPlaceholderText("Min. 8 characters")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Login with Website" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Scan to Login" })).toBeTruthy(); + expect(screen.queryByRole("img", { name: "Login QR" })).toBeNull(); + expect(native).toHaveBeenCalledWith("storyteller_cancel_login_challenge_command", { challengeId: "native_handle" }); + await act(async () => { await vi.advanceTimersByTimeAsync(30_000); }); + expect(native.mock.calls.filter(([command]) => command === "storyteller_poll_login_challenge_command")).toHaveLength(0); + expect(success).not.toHaveBeenCalled(); + }); + + it("hides password login while preparing a challenge and cancels a late creation after Back", async () => { + let resolve: (value: unknown) => void = () => {}; + await act(async () => { render(); }); + native.mockImplementationOnce(() => new Promise((done) => { resolve = done; })); + await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Login with Website" })); }); + expect(screen.getByText("Preparing login…")).toBeTruthy(); + expect(screen.queryByPlaceholderText("Min. 8 characters")).toBeNull(); + await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Back" })); }); + await act(async () => { resolve({ challenge_id: "late_handle" }); }); + expect(screen.getByPlaceholderText("Min. 8 characters")).toBeTruthy(); + expect(screen.queryByRole("img", { name: "Login QR" })).toBeNull(); + expect(native).toHaveBeenCalledWith("storyteller_cancel_login_challenge_command", { challengeId: "late_handle" }); + expect(native.mock.calls.filter(([command]) => command === "plugin:opener|open_url")).toHaveLength(0); + }); + + it.each([true, false])("keeps the completed native login when a stale session check resolves (before completion: %s)", async (beforeCompletion) => { + let resolveSession: (value: unknown) => void = () => {}; + native.mockImplementationOnce(() => new Promise((resolve) => { resolveSession = resolve; })); + const success = vi.fn(); + await act(async () => { render(); }); + await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Scan to Login" })); }); + if (beforeCompletion) await act(async () => { resolveSession(null); }); + expect(screen.getByRole("img", { name: "Login QR" })).toBeTruthy(); + await act(async () => { await vi.advanceTimersByTimeAsync(5000); }); + expect(success).toHaveBeenCalledExactlyOnceWith(USER); + if (!beforeCompletion) await act(async () => { resolveSession(null); }); + expect(screen.getByRole("heading", { name: "Logged in as google_user" })).toBeTruthy(); + expect(screen.queryByRole("img", { name: "Login QR" })).toBeNull(); + expect(useLoginModalStore.getState().isOpen).toBe(true); + await act(async () => { await vi.advanceTimersByTimeAsync(2999); }); + expect(screen.getByRole("heading", { name: "Logged in as google_user" })).toBeTruthy(); + await act(async () => { await vi.advanceTimersByTimeAsync(1); }); + expect(useLoginModalStore.getState().isOpen).toBe(false); + expect(screen.queryByRole("heading", { name: "Logged in as google_user" })).toBeNull(); + expect(screen.queryByText("Create your account")).toBeNull(); + expect(native).toHaveBeenCalledWith("storyteller_poll_login_challenge_command", { challengeId: "native_handle" }); + }); + + it.each([false, true])("submits password authentication only through Rust (signup: %s)", async (signup) => { + const success = vi.fn(); + await act(async () => { render(); }); + if (signup) { + fireEvent.change(screen.getByPlaceholderText("Username"), { target: { value: "native_user" } }); + fireEvent.change(screen.getByPlaceholderText("you@example.com"), { target: { value: "user@example.com" } }); + fireEvent.change(screen.getByPlaceholderText("Re-enter password"), { target: { value: "password123" } }); + } else { + fireEvent.change(screen.getByPlaceholderText("you@example.com or username"), { target: { value: "native_user" } }); + } + fireEvent.change(screen.getByPlaceholderText("Min. 8 characters"), { target: { value: "password123" } }); + const submit = screen.getAllByRole("button", { name: signup ? "Sign up" : "Log in" }).find((button) => button.getAttribute("type") === "submit")!; + await act(async () => { fireEvent.click(submit); }); + expect(success).toHaveBeenCalledExactlyOnceWith(USER); + expect(native).toHaveBeenCalledWith(signup ? "storyteller_password_signup_command" : "storyteller_password_login_command", { + request: signup ? { username: "native_user", email_address: "user@example.com", password: "password123", password_confirmation: "password123", signup_source: "artcraft" } : { username_or_email: "native_user", password: "password123" }, + }); + if (!signup) { + expect(screen.getByRole("heading", { name: "Logged in as google_user" })).toBeTruthy(); + expect(screen.queryByText("Join Our Community")).toBeNull(); + await act(async () => { await vi.advanceTimersByTimeAsync(3000); }); + expect(useLoginModalStore.getState().isOpen).toBe(false); + } + }); + + it("dismisses the confirmation once even when callbacks change", async () => { + const firstClose = vi.fn(); + const latestClose = vi.fn(); + const success = vi.fn(); + const view = render(); + await act(async () => {}); + await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Login with Website" })); }); + await act(async () => { await vi.advanceTimersByTimeAsync(5000); }); + expect(screen.getByRole("status").textContent).toContain("Logged in as google_user"); + await act(async () => { await vi.advanceTimersByTimeAsync(1500); }); + view.rerender(); + await act(async () => { await vi.advanceTimersByTimeAsync(1500); }); + expect(useLoginModalStore.getState().isOpen).toBe(false); + expect(firstClose).not.toHaveBeenCalled(); + expect(latestClose).toHaveBeenCalledTimes(1); + expect(success).toHaveBeenCalledExactlyOnceWith(USER); + await act(async () => { await vi.advanceTimersByTimeAsync(10_000); }); + expect(latestClose).toHaveBeenCalledTimes(1); + }); + + it("cleans up the confirmation timer on unmount", async () => { + const onClose = vi.fn(); + const view = render(); + await act(async () => {}); + await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Scan to Login" })); }); + await act(async () => { await vi.advanceTimersByTimeAsync(5000); }); + expect(screen.getByRole("heading", { name: "Logged in as google_user" })).toBeTruthy(); + view.unmount(); + await act(async () => { await vi.advanceTimersByTimeAsync(10_000); }); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("restores an existing session without showing the login confirmation", async () => { + native.mockResolvedValueOnce(USER); + const success = vi.fn(); + await act(async () => { render(); }); + expect(success).toHaveBeenCalledExactlyOnceWith(USER); + expect(useLoginModalStore.getState().isOpen).toBe(false); + expect(screen.queryByRole("heading", { name: "Logged in as google_user" })).toBeNull(); }); - }); diff --git a/frontend/libs/components/login-modal/src/lib/login-modal.tsx b/frontend/libs/components/login-modal/src/lib/login-modal.tsx index aaf5b16d7d..9f2628c8c0 100644 --- a/frontend/libs/components/login-modal/src/lib/login-modal.tsx +++ b/frontend/libs/components/login-modal/src/lib/login-modal.tsx @@ -1,17 +1,19 @@ import { Button } from "@storyteller/ui-button"; import { Transition, TransitionChild } from "@headlessui/react"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { ArrowRightIcon } from "lucide-react"; import { DiscordIcon } from "@storyteller/icons"; +import type { UserInfo } from "@storyteller/api"; +import { DesktopLoginBridge } from "./DesktopLoginBridge"; +import { LoginSuccess } from "./LoginSuccess"; import { ArtCraftSignUp } from "./artcraft-signup"; -import { UsersApi } from "@storyteller/api"; +import { getNativeLoginSession, passwordLogin, passwordSignup, isDesktopLoginError } from "./NativeLoginBridge"; import { useLoginModalStore } from "./useLoginModalStore"; -const SIGNUP_SOURCE_ARTCRAFT = "artcraft"; - // Webapp auth-showcase video (swap by passing `videoUrl`). const DEFAULT_SHOWCASE_VIDEO = "https://player.vimeo.com/video/1169289718?background=1&autoplay=1&loop=1&muted=1"; +const LOGIN_SUCCESS_DURATION_MS = 3000; interface LoginModalProps { onClose?: () => void; @@ -40,39 +42,56 @@ export function LoginModal({ const [errorMessage, setErrorMessage] = useState(""); const [showDiscord, setShowDiscord] = useState(false); const [showSuccess, setShowSuccess] = useState(false); + const [isChallengeActive, setIsChallengeActive] = useState(false); + const [loggedInUsername, setLoggedInUsername] = useState(null); - const checkArtCraftLogin = async () => { - const usersApi = new UsersApi(); - const session = await usersApi.GetSession(); - const loggedIn = session.data?.loggedIn; - return loggedIn; - }; + const authGeneration = useRef(0); + const authSuccess = useRef(onArtCraftAuthSuccess); + authSuccess.current = onArtCraftAuthSuccess; + const afterClose = useRef(onClose); + afterClose.current = onClose; - // Check session on mount and when recheckTrigger changes. + // A stale startup/recheck response must never reopen the modal after login. useEffect(() => { - checkArtCraftLogin().then((loggedIn) => { - if (loggedIn) { + const generation = ++authGeneration.current; + let active = true; + getNativeLoginSession().then((user) => { + if (!active || generation !== authGeneration.current) return; + if (user) { setIsLoggedInArtCraft(true); + authSuccess.current?.(user); closeModal(); } else { - // Reset modal state to initial values. setIsLoading(false); setIsSignUp(initialIsSignUp); setErrorMessage(""); setShowDiscord(false); setShowSuccess(false); + setLoggedInUsername(null); setIsLoggedInArtCraft(false); - - const { openModal } = useLoginModalStore.getState(); - openModal(); + useLoginModalStore.getState().openModal(); } + }).catch((error) => { + if (!active || generation !== authGeneration.current) return; + setErrorMessage(isDesktopLoginError(error) ? error.message : "Unable to check your account. Please sign in again."); + useLoginModalStore.getState().openModal(); }); + return () => { active = false; }; }, [recheckTrigger, closeModal, initialIsSignUp]); useEffect(() => { if (onOpenChange) onOpenChange(isOpen); }, [isOpen, onOpenChange]); + useEffect(() => { + if (loggedInUsername === null || !isOpen) return; + const timer = window.setTimeout(() => { + closeModal(); + afterClose.current?.(); + }, LOGIN_SUCCESS_DURATION_MS); + return () => window.clearTimeout(timer); + }, [loggedInUsername, isOpen, closeModal]); + const handleClose = () => { closeModal(); onClose?.(); @@ -84,6 +103,15 @@ export function LoginModal({ setShowSuccess(true); }; + const handleLoginSuccess = (user: UserInfo) => { + authGeneration.current += 1; + setIsLoading(false); + setIsLoggedInArtCraft(true); + setIsChallengeActive(false); + setLoggedInUsername(user.username); + authSuccess.current?.(user); + }; + const handleAuthSubmit = async ( username: string, email: string, @@ -91,56 +119,24 @@ export function LoginModal({ passwordConfirmation: string ) => { setIsLoading(true); - const usersApi = new UsersApi(); + setErrorMessage(""); + const generation = ++authGeneration.current; try { - let signupResponse, loginResponse; + const user = isSignUp + ? await passwordSignup(username, email, password, passwordConfirmation) + : await passwordLogin(username || email, password); + if (generation !== authGeneration.current) return; if (isSignUp) { - signupResponse = await usersApi.Signup({ - username, - email, - password, - passwordConfirmation, - signupSource: SIGNUP_SOURCE_ARTCRAFT, - }); - - if (!signupResponse.success) { - setErrorMessage( - signupResponse.errorMessage || "Signup failed, please try again." - ); - setIsLoading(false); - return; - } - loginResponse = await usersApi.Login({ - usernameOrEmail: username || email, - password, - }); + setIsLoggedInArtCraft(true); + authSuccess.current?.(user); + setShowDiscord(true); } else { - loginResponse = await usersApi.Login({ - usernameOrEmail: username || email, - password, - }); + handleLoginSuccess(user); } - - if (!loginResponse.success) { - setErrorMessage( - loginResponse.errorMessage || "Login failed, please try again." - ); - setIsLoading(false); - return; - } - - setIsLoggedInArtCraft(true); - if (onArtCraftAuthSuccess) { - const session = await usersApi.GetSession(); - const userInfo = session.data?.user; - if (userInfo) onArtCraftAuthSuccess(userInfo); - } - setShowDiscord(true); // Onboarding step after successful auth. - } catch (e) { - console.error(e); - setErrorMessage("An unexpected error occurred. Please try again."); + } catch (error) { + if (generation === authGeneration.current) setErrorMessage(isDesktopLoginError(error) ? error.message : "An unexpected error occurred. Please try again."); } finally { - setIsLoading(false); + if (generation === authGeneration.current) setIsLoading(false); } }; @@ -204,7 +200,7 @@ export function LoginModal({ const inOnboarding = showDiscord || showSuccess; return ( - + setLoggedInUsername(null)}>
e.stopPropagation()} > - {inOnboarding ? ( + {loggedInUsername !== null ? ( + + ) : inOnboarding ? ( renderOnboarding() ) : ( <> @@ -254,13 +252,14 @@ export function LoginModal({

- { authGeneration.current += 1; }} onSuccess={handleLoginSuccess} />} + {!isChallengeActive && setIsSignUp((prev) => !prev)} errorMessage={errorMessage} isLoading={isLoading} - /> + />}
diff --git a/frontend/libs/components/login-modal/tsconfig.lib.json b/frontend/libs/components/login-modal/tsconfig.lib.json index e56427351c..45cf3e4614 100644 --- a/frontend/libs/components/login-modal/tsconfig.lib.json +++ b/frontend/libs/components/login-modal/tsconfig.lib.json @@ -54,6 +54,9 @@ }, { "path": "../input/tsconfig.lib.json" + }, + { + "path": "../../tauri-api/tsconfig.lib.json" } ] } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8c3c39e7e2..bdd555656c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -49,6 +49,7 @@ "mediabunny": "^1.29.1", "opencut-wasm": "^0.2.10", "posthog-js": "^1.141.0", + "qrcode.react": "4.2.0", "radix-ui": "^1.4.3", "react": "18.3.1", "react-copy-to-clipboard": "^5.1.0", @@ -20479,6 +20480,15 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/qs": { "version": "6.15.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 9e7bf64e63..331f036e43 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -38,6 +38,7 @@ "mediabunny": "^1.29.1", "opencut-wasm": "^0.2.10", "posthog-js": "^1.141.0", + "qrcode.react": "4.2.0", "radix-ui": "^1.4.3", "react": "18.3.1", "react-copy-to-clipboard": "^5.1.0",