From 115daafcf7bec29936e29db20bb428afcc005d39 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 26 Sep 2026 00:41:23 -0700 Subject: [PATCH] login bridge --- .../src/users/session_info.rs | 11 ++ .../src/endpoints/users/login_challenges.rs | 9 +- .../src/endpoints/users/mod.rs | 1 + .../src/endpoints/users/password_auth.rs | 38 +++++ crates/desktop/artcraft/src/lib.rs | 5 +- .../storyteller_login_bridge_commands.rs | 141 ++++++++++++------ .../storyteller_login_bridge_tests.rs | 51 ++++++- .../src/lib/DesktopLoginBridge.tsx | 3 +- .../login-modal/src/lib/NativeLoginBridge.ts | 8 + .../login-modal/src/lib/login-modal.spec.tsx | 95 +++++++++++- .../login-modal/src/lib/login-modal.tsx | 98 ++++-------- 11 files changed, 337 insertions(+), 123 deletions(-) create mode 100644 crates/api_clients/artcraft/artcraft_client/src/endpoints/users/password_auth.rs 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 09b6d26a40..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,6 +1,7 @@ use crate::common::responses::user_details_light::UserDetailsLight; use enums::by_table::users::user_feature_flag::UserFeatureFlag; use serde_derive::{Deserialize, Serialize}; +use serde::{Deserialize as DeserializeTrait, Deserializer}; use std::collections::BTreeSet; use tokens::tokens::users::UserToken; use utoipa::ToSchema; @@ -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: @@ -97,3 +99,12 @@ pub struct SessionInfoSuccessResponse { 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/src/endpoints/users/login_challenges.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/users/login_challenges.rs index 2116c826cf..dbdf063f6e 100644 --- a/crates/api_clients/artcraft/artcraft_client/src/endpoints/users/login_challenges.rs +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/users/login_challenges.rs @@ -69,9 +69,10 @@ impl LoginChallengeClient { return false; }; let origin_allowed = match self.api_host { - ApiHost::Localhost { .. } => ["http://localhost:4201", "http://127.0.0.1:4201"] + 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::Storyteller => url.origin().ascii_serialization() == "https://app.getartcraft.com", ApiHost::FakeYou => false, }; origin_allowed @@ -126,14 +127,14 @@ impl LoginChallengeClient { Ok(result) } - fn request(&self, method: Method, path: &str) -> RequestBuilder { + pub(crate) fn request(&self, method: Method, path: &str) -> RequestBuilder { self .client .request(method, format!("{}{path}", self.api_origin())) .header("Accept", "application/json") } - async fn send( + pub(crate) async fn send( &self, request: RequestBuilder, path: &str, 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 index e3e166c558..e391a7f44a 100644 --- a/crates/api_clients/artcraft/artcraft_client/src/endpoints/users/mod.rs +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/users/mod.rs @@ -1 +1,2 @@ pub mod login_challenges; +pub mod password_auth; diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/users/password_auth.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/users/password_auth.rs new file mode 100644 index 0000000000..6804164b9e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/users/password_auth.rs @@ -0,0 +1,38 @@ +use reqwest::Method; +use serde::{Deserialize, Serialize}; + +use super::login_challenges::{LoginChallengeClient, LoginChallengeClientError}; + +// No Debug: these request/response types contain credentials. +#[derive(Deserialize, Serialize)] +pub struct PasswordLoginRequest { + pub username_or_email: String, + pub password: String, +} + +#[derive(Deserialize, Serialize)] +pub struct PasswordSignupRequest { + pub username: String, + pub email_address: String, + pub password: String, + pub password_confirmation: String, + pub signup_source: String, +} + +#[derive(Deserialize)] +pub struct PasswordAuthResponse { + pub success: bool, + pub signed_session: Option, +} + +impl LoginChallengeClient { + pub async fn password_login(&self, request: &PasswordLoginRequest) -> Result<(PasswordAuthResponse, Option), LoginChallengeClientError> { + let path = "/v1/login"; + self.send(self.request(Method::POST, path).json(request), path).await + } + + pub async fn password_signup(&self, request: &PasswordSignupRequest) -> Result<(PasswordAuthResponse, Option), LoginChallengeClientError> { + let path = "/v1/create_account"; + self.send(self.request(Method::POST, path).json(request), path).await + } +} diff --git a/crates/desktop/artcraft/src/lib.rs b/crates/desktop/artcraft/src/lib.rs index 3590a7e985..389db3cb54 100644 --- a/crates/desktop/artcraft/src/lib.rs +++ b/crates/desktop/artcraft/src/lib.rs @@ -63,7 +63,7 @@ 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::storyteller_login_bridge_commands::{DesktopLoginBridgeState, storyteller_create_login_challenge_command, storyteller_poll_login_challenge_command, storyteller_cancel_login_challenge_command}; +use crate::services::storyteller::commands::storyteller_login_bridge_commands::{DesktopLoginBridgeState, 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}; 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; @@ -261,6 +261,9 @@ 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, diff --git a/crates/desktop/artcraft/src/services/storyteller/commands/storyteller_login_bridge_commands.rs b/crates/desktop/artcraft/src/services/storyteller/commands/storyteller_login_bridge_commands.rs index bbd4538588..23469d5457 100644 --- a/crates/desktop/artcraft/src/services/storyteller/commands/storyteller_login_bridge_commands.rs +++ b/crates/desktop/artcraft/src/services/storyteller/commands/storyteller_login_bridge_commands.rs @@ -7,6 +7,7 @@ use artcraft_client::endpoints::users::login_challenges::{ LoginChallengeClient, LoginChallengeClientError, }; use artcraft_client::utils::api_host::ApiHost; +use artcraft_client::endpoints::users::password_auth::{PasswordLoginRequest, PasswordSignupRequest}; use chrono::{DateTime, Utc}; use log::{info, warn}; use serde::Serialize; @@ -90,6 +91,58 @@ pub async fn storyteller_cancel_login_challenge_command( Ok(()) } +#[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(|e| DesktopLoginError::from_client(&config.storyteller_host.to_api_hostname_and_scheme(), e))?; + current_login_session(&client, &http_cookie_jar(&app)?).await +} + +#[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 +} + +#[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 +} + +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)) +} + +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) +} + +fn http_cookie_jar(app: &AppHandle) -> Result, DesktopLoginError> { + Ok(app.try_state::().ok_or_else(|| DesktopLoginError::local("HTTP cookie store unavailable"))?.cookies_jar.clone()) +} + async fn begin_challenge( host: &ApiHost, state: &DesktopLoginBridgeState, @@ -175,24 +228,7 @@ async fn poll_challenge( let set_cookie = polled .session_set_cookie .ok_or_else(|| DesktopLoginError::local("Login server did not set a session cookie"))?; - 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 = challenge - .client - .session(&signed) - .await - .map_err(|e| DesktopLoginError::from_client(&origin, e))?; - let user = session - .user - .filter(|_| session.success && session.logged_in) - .ok_or_else(|| DesktopLoginError::local("The redeemed session could not authenticate"))?; + 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. @@ -202,32 +238,10 @@ async fn poll_challenge( if challenge.expires_at <= Utc::now() { return Ok(expired()); } - 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(), &challenge.client.api_url()); - let credentials = get_credentials_from_cookie_store(&candidate, &challenge.client.api_url()) - .map_err(|_| DesktopLoginError::local("Unable to read session cookie"))?; - if credentials.session.as_ref().map(|c| c.as_str()) != Some(signed.as_str()) { - 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; + install_session_cookie(&challenge.client, &signed, cookie, manager, jar)?; } // Keep the challenge for idempotent IPC retries; it expires on its original deadline. - 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"))?; + persist_session_cookie(jar).await?; info!("Website login complete: origin={}", origin); Ok(DesktopLoginOutcome { status: LoginChallengeState::Redeemed, @@ -236,6 +250,49 @@ async fn poll_challenge( }) } +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, 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 index 205062d3be..97e5043536 100644 --- 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 @@ -64,6 +64,10 @@ async fn approved_session_is_verified_installed_persisted_and_retried_entirely_i .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); @@ -221,6 +225,51 @@ fn website_origin_must_match_native_api_environment() { 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, @@ -354,7 +403,7 @@ fn session() -> Step { "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":[],"fakeyou_plan":"free","storyteller_stream_plan":"free" + "maybe_feature_flags":["studio", "use_qt", "future_server_flag"],"fakeyou_plan":"free","storyteller_stream_plan":"free" }); for field in [ "can_access_studio", diff --git a/frontend/libs/components/login-modal/src/lib/DesktopLoginBridge.tsx b/frontend/libs/components/login-modal/src/lib/DesktopLoginBridge.tsx index aab4de7d20..89fbe8cdc1 100644 --- a/frontend/libs/components/login-modal/src/lib/DesktopLoginBridge.tsx +++ b/frontend/libs/components/login-modal/src/lib/DesktopLoginBridge.tsx @@ -6,7 +6,7 @@ import { createDesktopLoginChallenge, pollDesktopLoginChallenge, cancelDesktopLo const OpenUrl = (url: string) => invoke("plugin:opener|open_url", { url }); -export function DesktopLoginBridge({ onSuccess }: { onSuccess: (user: UserInfo) => void }) { +export function DesktopLoginBridge({ onSuccess, onStart }: { onSuccess: (user: UserInfo) => void; onStart?: () => void }) { const [challenge, setChallenge] = useState(null); const [busy, setBusy] = useState(false); const [message, setMessage] = useState(""); @@ -77,6 +77,7 @@ export function DesktopLoginBridge({ onSuccess }: { onSuccess: (user: UserInfo) const start = async (openBrowser: boolean) => { if (busy) return; + onStart?.(); const current = ++generation.current; setBusy(true); setMessage(""); diff --git a/frontend/libs/components/login-modal/src/lib/NativeLoginBridge.ts b/frontend/libs/components/login-modal/src/lib/NativeLoginBridge.ts index 70f5a631b9..1ede30cc9e 100644 --- a/frontend/libs/components/login-modal/src/lib/NativeLoginBridge.ts +++ b/frontend/libs/components/login-modal/src/lib/NativeLoginBridge.ts @@ -28,3 +28,11 @@ export const cancelDesktopLoginChallenge = (challengeId: string) => invoke 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..5e81c61c66 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,91 @@ -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([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(useLoginModalStore.getState().isOpen).toBe(false); + 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" }, + }); }); - }); 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 00929fc553..d4d34def6b 100644 --- a/frontend/libs/components/login-modal/src/lib/login-modal.tsx +++ b/frontend/libs/components/login-modal/src/lib/login-modal.tsx @@ -1,15 +1,13 @@ 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 { DesktopLoginBridge } from "./DesktopLoginBridge"; 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"; @@ -42,32 +40,35 @@ export function LoginModal({ const [showDiscord, setShowDiscord] = useState(false); const [showSuccess, setShowSuccess] = useState(false); - 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; - // 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); 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(() => { @@ -92,56 +93,20 @@ export function LoginModal({ passwordConfirmation: string ) => { setIsLoading(true); - const usersApi = new UsersApi(); + setErrorMessage(""); + const generation = ++authGeneration.current; try { - let signupResponse, loginResponse; - 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, - }); - } else { - loginResponse = await usersApi.Login({ - usernameOrEmail: username || email, - password, - }); - } - - if (!loginResponse.success) { - setErrorMessage( - loginResponse.errorMessage || "Login failed, please try again." - ); - setIsLoading(false); - return; - } - + const user = isSignUp + ? await passwordSignup(username, email, password, passwordConfirmation) + : await passwordLogin(username || email, password); + if (generation !== authGeneration.current) 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."); + onArtCraftAuthSuccess?.(user); + setShowDiscord(true); + } 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); } }; @@ -255,10 +220,11 @@ export function LoginModal({

- { + {!isSignUp && { authGeneration.current += 1; }} onSuccess={(user) => { + authGeneration.current += 1; onArtCraftAuthSuccess?.(user); handleClose(); - }} /> + }} />}