login bridge
This commit is contained in:
parent
d9365e9783
commit
115daafcf7
|
|
@ -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<UserFeatureFlag>,
|
||||
|
||||
// Premium plans:
|
||||
|
|
@ -97,3 +99,12 @@ pub struct SessionInfoSuccessResponse {
|
|||
pub logged_in: bool,
|
||||
pub user: Option<SessionUserInfo>,
|
||||
}
|
||||
|
||||
// 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<BTreeSet<UserFeatureFlag>, D::Error>
|
||||
where D: Deserializer<'de> {
|
||||
let names = Vec::<String>::deserialize(deserializer)?;
|
||||
Ok(names.into_iter().filter_map(|name| UserFeatureFlag::from_str(&name).ok()).collect())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<T: DeserializeOwned>(
|
||||
pub(crate) async fn send<T: DeserializeOwned>(
|
||||
&self,
|
||||
request: RequestBuilder,
|
||||
path: &str,
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
pub mod login_challenges;
|
||||
pub mod password_auth;
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
impl LoginChallengeClient {
|
||||
pub async fn password_login(&self, request: &PasswordLoginRequest) -> Result<(PasswordAuthResponse, Option<String>), 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<String>), LoginChallengeClientError> {
|
||||
let path = "/v1/create_account";
|
||||
self.send(self.request(Method::POST, path).json(request), path).await
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<Option<SessionUserInfo>, 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<SessionUserInfo, DesktopLoginError> {
|
||||
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<SessionUserInfo, DesktopLoginError> {
|
||||
password_auth(&config.storyteller_host, &manager, &http_cookie_jar(&app)?, None, Some(request)).await
|
||||
}
|
||||
|
||||
async fn current_login_session(client: &LoginChallengeClient, jar: &Arc<CookieStoreMutex>) -> Result<Option<SessionUserInfo>, 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<CookieStoreMutex>, login: Option<PasswordLoginRequest>, signup: Option<PasswordSignupRequest>) -> Result<SessionUserInfo, DesktopLoginError> {
|
||||
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<Arc<CookieStoreMutex>, DesktopLoginError> {
|
||||
Ok(app.try_state::<Http>().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<CookieStoreMutex>) -> 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<CookieStoreMutex>) -> 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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<DesktopLoginChallenge | null>(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("");
|
||||
|
|
|
|||
|
|
@ -28,3 +28,11 @@ export const cancelDesktopLoginChallenge = (challengeId: string) => invoke<void>
|
|||
export function isDesktopLoginError(error: unknown): error is DesktopLoginError {
|
||||
return typeof error === "object" && error !== null && "message" in error && "retryable" in error;
|
||||
}
|
||||
|
||||
export const getNativeLoginSession = () => invoke<UserInfo | null>("storyteller_get_login_session_command");
|
||||
export const passwordLogin = (usernameOrEmail: string, password: string) => invoke<UserInfo>("storyteller_password_login_command", {
|
||||
request: { username_or_email: usernameOrEmail, password },
|
||||
});
|
||||
export const passwordSignup = (username: string, email: string, password: string, passwordConfirmation: string) => invoke<UserInfo>("storyteller_password_signup_command", {
|
||||
request: { username, email_address: email, password, password_confirmation: passwordConfirmation, signup_source: "artcraft" },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) => <button {...props}>{children}</button> }));
|
||||
vi.mock("@storyteller/ui-input", () => ({ Input: ({ inputClassName, ...props }: any) => <input {...props} /> }));
|
||||
vi.mock("qrcode.react", () => ({ QRCodeSVG: () => <svg role="img" aria-label="Login QR" /> }));
|
||||
|
||||
describe('LoginModal', () => {
|
||||
|
||||
it('should render successfully', () => {
|
||||
const { baseElement } = render(<LoginModal />);
|
||||
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(<LoginModal />); });
|
||||
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(<LoginModal isSignUp={false} onArtCraftAuthSuccess={success} />); });
|
||||
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(<LoginModal isSignUp={signup} onArtCraftAuthSuccess={success} />); });
|
||||
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" },
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<DesktopLoginBridge onSuccess={(user) => {
|
||||
{!isSignUp && <DesktopLoginBridge onStart={() => { authGeneration.current += 1; }} onSuccess={(user) => {
|
||||
authGeneration.current += 1;
|
||||
onArtCraftAuthSuccess?.(user);
|
||||
handleClose();
|
||||
}} />
|
||||
}} />}
|
||||
<ArtCraftSignUp
|
||||
onSubmit={handleAuthSubmit}
|
||||
isSignUp={isSignUp}
|
||||
|
|
|
|||
Loading…
Reference in New Issue