login bridge

This commit is contained in:
Brandon Thomas 2026-09-26 00:41:23 -07:00
parent d9365e9783
commit 115daafcf7
11 changed files with 337 additions and 123 deletions

View File

@ -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())
}

View File

@ -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,

View File

@ -1 +1,2 @@
pub mod login_challenges;
pub mod password_auth;

View File

@ -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
}
}

View File

@ -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,

View File

@ -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,

View File

@ -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",

View File

@ -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("");

View File

@ -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" },
});

View File

@ -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" },
});
});
});

View File

@ -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}