WIP SSO OTP BRIDGE

This commit is contained in:
Brandon Thomas
2026-09-25 23:23:13 -07:00
parent 95f8f659a5
commit 0fd38bc51a
12 changed files with 439 additions and 40 deletions
@@ -1,10 +1,10 @@
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::app_startup_time::AppStartupTime;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use anyhow::anyhow;
use chrono::TimeDelta;
use errors::AnyhowResult;
use log::{error, info};
use once_cell::sync::Lazy;
use reqwest::Url;
use artcraft_client::credentials::storyteller_avt_cookie::StorytellerAvtCookie;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
@@ -13,24 +13,13 @@ use tauri::{AppHandle, Manager};
use tauri_plugin_http::reqwest_cookie_store::CookieStore;
use tauri_plugin_http::Http;
const STORYTELLER_ROOT_COOKIE_URL_STR: &str = "https://api.storyteller.ai/";
static STORYTELLER_ROOT_COOKIE_URL: Lazy<Url> = Lazy::new(|| {
Url::parse(STORYTELLER_ROOT_COOKIE_URL_STR).expect("URL should parse")
});
const AVT_COOKIE_NAME : &str = "visitor";
const SESSION_COOKIE_NAME : &str = "session";
const AVT_COOKIE_NAME: &str = "visitor";
const SESSION_COOKIE_NAME: &str = "session";
/// There's some kind of race condition that we need to wait for before inspecting cookies.
const RACE_CONDITION_WAIT_TIME: TimeDelta = TimeDelta::milliseconds(5000);
pub async fn persist_storyteller_cookies_task(
app: &AppHandle,
storyteller_credential_manager: &StorytellerCredentialManager,
app_startup_time: &AppStartupTime,
) -> AnyhowResult<()> {
pub async fn persist_storyteller_cookies_task(app: &AppHandle, storyteller_credential_manager: &StorytellerCredentialManager, app_startup_time: &AppStartupTime) -> AnyhowResult<()> {
//if app_startup_time.time_delta_since() < RACE_CONDITION_WAIT_TIME {
// // NB: There's an issue when the "main window thread" inquires about the
// // webview cookies shortly after app startup. When it attempts to dump the
@@ -41,33 +30,30 @@ pub async fn persist_storyteller_cookies_task(
//}
let maybe_http = app.try_state::<Http>();
let http = match maybe_http {
None => {
error!("No HTTP plugin found");
return Err(anyhow!("No HTTP plugin found"));
}
},
Some(http) => http,
};
match http.cookies_jar.store.lock() {
Err(err) => {
error!("Failed to lock cookie jar: {:?}", err);
return Err(anyhow!("Failed to lock cookie jar"));
}
},
Ok(cookie_jar) => {
sync_tauri_credentials(&cookie_jar, storyteller_credential_manager)?;
}
sync_tauri_credentials(&cookie_jar, storyteller_credential_manager, &storyteller_cookie_url(app)?)?;
},
}
Ok(())
}
fn sync_tauri_credentials(
cookie_store: &CookieStore,
storyteller_credential_manager: &StorytellerCredentialManager,
) -> AnyhowResult<()> {
let current_http_plugin_credentials = get_credentials_from_cookie_store(cookie_store)?;
pub(crate) fn sync_tauri_credentials(cookie_store: &CookieStore, storyteller_credential_manager: &StorytellerCredentialManager, api_url: &Url) -> AnyhowResult<()> {
let current_http_plugin_credentials = get_credentials_from_cookie_store(cookie_store, api_url)?;
let mut replace_credentials = true;
@@ -78,31 +64,80 @@ fn sync_tauri_credentials(
replace_credentials = false;
}
}
if replace_credentials {
info!("Syncing ArtCraft credentials ...");
storyteller_credential_manager.set_credentials(&current_http_plugin_credentials)?;
// NB: tauri-plugin-http stores the credentials on disk, so we can defer to that for now.
//storyteller_credential_manager.persist_all_to_disk()?;
}
Ok(())
}
pub fn get_credentials_from_cookie_store(cookie_jar: &CookieStore) -> AnyhowResult<StorytellerCredentialSet> {
pub fn get_credentials_from_cookie_store(cookie_jar: &CookieStore, api_url: &Url) -> AnyhowResult<StorytellerCredentialSet> {
let mut avt_cookie = None;
let mut session_cookie = None;
for cookie in cookie_jar.iter_unexpired() {
if let Some(avt) = StorytellerAvtCookie::maybe_from_cookie(&cookie) {
avt_cookie = Some(avt);
} else if let Some(session) = StorytellerSessionCookie::maybe_from_cookie(&cookie) {
session_cookie = Some(session);
// Only cookies applicable to our API origin can become ArtCraft credentials.
for (name, value) in cookie_jar.get_request_values(api_url) {
match name {
AVT_COOKIE_NAME => avt_cookie = Some(StorytellerAvtCookie::new(value.to_owned())),
SESSION_COOKIE_NAME => session_cookie = Some(StorytellerSessionCookie::new(value.to_owned())),
_ => {},
}
}
Ok(StorytellerCredentialSet::initialize(
avt_cookie,
session_cookie,
))
Ok(StorytellerCredentialSet::initialize(avt_cookie, session_cookie))
}
pub(crate) fn storyteller_cookie_url(app: &AppHandle) -> AnyhowResult<Url> {
let config = app.try_state::<AppEnvConfigs>().ok_or_else(|| anyhow!("API configuration unavailable"))?;
Ok(Url::parse(&format!("{}/", config.storyteller_host.to_api_hostname_and_scheme()))?)
}
#[cfg(test)]
mod login_bridge_tests {
use super::*;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use tauri_plugin_http::reqwest_cookie_store::RawCookie;
#[test]
fn approved_response_cookie_reaches_native_credentials() {
let directory = tempfile::tempdir().unwrap();
let root = AppDataRoot::create_existing(directory.path()).unwrap();
let manager = StorytellerCredentialManager::initialize_empty(&root);
let mut jar = CookieStore::default();
let url = Url::parse("https://api.storyteller.ai/v1/login_challenges/poll").unwrap();
jar.store_response_cookies([RawCookie::parse("session=signed_backend_cookie; Secure; HttpOnly; Path=/; Max-Age=3600").unwrap().into_owned(), RawCookie::parse("visitor=existing_visitor; Secure; Path=/; Max-Age=3600").unwrap().into_owned()].into_iter(), &url);
sync_tauri_credentials(&jar, &manager, &url).unwrap();
let credentials = manager.get_credentials_required().unwrap();
assert_eq!(credentials.session.unwrap().as_str(), "signed_backend_cookie");
assert_eq!(credentials.avt.unwrap().as_str(), "existing_visitor");
// Retrying the same successful poll is idempotent for native credentials too.
sync_tauri_credentials(&jar, &manager, &url).unwrap();
assert_eq!(manager.get_credentials_required().unwrap().session.unwrap().as_str(), "signed_backend_cookie");
}
#[test]
fn unrelated_and_expired_cookies_cannot_become_artcraft_sessions() {
let url = Url::parse("https://api.storyteller.ai/").unwrap();
let mut jar = CookieStore::default();
jar.store_response_cookies([RawCookie::parse("session=unrelated; Path=/; Max-Age=3600").unwrap().into_owned()].into_iter(), &Url::parse("https://unrelated.example/").unwrap());
assert!(get_credentials_from_cookie_store(&jar, &url).unwrap().session.is_none());
jar.store_response_cookies([RawCookie::parse("session=expired; Secure; Path=/; Max-Age=0").unwrap().into_owned()].into_iter(), &url);
assert!(get_credentials_from_cookie_store(&jar, &url).unwrap().session.is_none());
}
#[test]
fn configured_local_api_does_not_import_production_cookies() {
let production = Url::parse("https://api.storyteller.ai/").unwrap();
let local = Url::parse("http://localhost:12345/").unwrap();
let mut jar = CookieStore::default();
jar.store_response_cookies([RawCookie::parse("session=production; Secure; Path=/").unwrap().into_owned()].into_iter(), &production);
assert!(get_credentials_from_cookie_store(&jar, &local).unwrap().session.is_none());
jar.store_response_cookies([RawCookie::parse("session=development; Path=/").unwrap().into_owned()].into_iter(), &local);
assert_eq!(get_credentials_from_cookie_store(&jar, &local).unwrap().session.unwrap().as_str(), "development");
assert_eq!(get_credentials_from_cookie_store(&jar, &production).unwrap().session.unwrap().as_str(), "production");
}
}
+2
View File
@@ -63,6 +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_sync_login_session_command::storyteller_sync_login_session_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;
@@ -259,6 +260,7 @@ pub fn run() {
set_provider_order_command,
sora_get_credential_info_command,
sora_logout_command,
storyteller_sync_login_session_command,
storyteller_get_credits_command,
storyteller_get_subscription_command,
storyteller_open_credits_purchase_command,
@@ -2,4 +2,5 @@ pub mod storyteller_purge_credentials_command;
pub mod storyteller_get_credits_command;
pub mod storyteller_get_subscription_command;
pub mod stripe_checkout;
pub mod stripe_customer_portal;
pub mod stripe_customer_portal;
pub mod storyteller_sync_login_session_command;
@@ -0,0 +1,23 @@
use log::warn;
use tauri::{AppHandle, Manager, State};
use tauri_plugin_http::Http;
use crate::core::threads::main_window_thread::persist_storyteller_cookies_task::{get_credentials_from_cookie_store, sync_tauri_credentials, storyteller_cookie_url};
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
/// Synchronize the HTTP plugin's newly received session with native API clients.
/// No cookie is accepted from the webview/IPC caller or created by this command.
#[tauri::command]
pub fn storyteller_sync_login_session_command(app: AppHandle, manager: State<'_, StorytellerCredentialManager>) -> Result<(), String> {
let http = app.try_state::<Http>().ok_or("HTTP client unavailable")?;
let jar = http.cookies_jar.store.lock().map_err(|_| "HTTP cookie store unavailable")?;
let url = storyteller_cookie_url(&app).map_err(|_| "API configuration unavailable")?;
let credentials = get_credentials_from_cookie_store(&jar, &url).map_err(|_| "Unable to read login session")?;
if credentials.session.is_none() {
return Err("The API did not set a desktop session cookie".into());
}
sync_tauri_credentials(&jar, &manager, &url).map_err(|error| {
warn!("Unable to synchronize desktop login credentials: {}", error);
"Unable to synchronize desktop login".into()
})
}
+1
View File
@@ -21,6 +21,7 @@ export * from "./lib/PasswordResetApi.js";
export * from "./lib/UserApiKeysApi.js";
export * from "./lib/UserReferralCodesApi.js";
export * from "./lib/UsersApi.js";
export * from "./lib/LoginChallengesApi.js";
export * from "./lib/VideoApi.js";
export * from "./lib/WeightsApi.js";
export * from "./lib/GetCdnOrigin.js";
@@ -0,0 +1,56 @@
import { ApiManager, storeSignedSession } from "./ApiManager.js";
export interface LoginChallenge {
success: boolean;
device_token: string;
verification_url: string;
confirmation_code: string;
expires_at: string;
poll_interval_seconds: number;
}
export interface LoginChallengeResult {
success: boolean;
// Kept open to future values: callers must fail closed on an unknown state.
status: string;
maybe_failure_type: string | null;
maybe_signed_session?: string;
}
export interface LoginChallengeReview extends LoginChallengeResult {
confirmation_code: string;
requesting_ip: string;
expires_at: string;
username: string;
}
export class LoginChallengesApi extends ApiManager {
create(): Promise<LoginChallenge> {
return this.request("create", {});
}
review(approvalToken: string): Promise<LoginChallengeReview> {
return this.request("review", { approval_token: approvalToken });
}
decide(approvalToken: string, approve: boolean): Promise<LoginChallengeResult> {
return this.request("decide", { approval_token: approvalToken, approve });
}
poll(deviceToken: string): Promise<LoginChallengeResult> {
return this.request("poll", { device_token: deviceToken });
}
acceptSession(result: LoginChallengeResult): void {
if (result.status !== "redeemed" || !result.success || !result.maybe_signed_session) {
throw new Error("The desktop login has not completed.");
}
storeSignedSession(result.maybe_signed_session);
}
private request<T>(action: string, body: Record<string, unknown>): Promise<T> {
return this.fetch(`${this.getApiSchemeAndHost()}/v1/login_challenges/${action}`, {
method: "POST", body,
});
}
}
@@ -0,0 +1,144 @@
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DesktopLoginBridge } from "./DesktopLoginBridge";
const { transport, native } = vi.hoisted(() => ({ transport: vi.fn(), native: vi.fn() }));
vi.mock("@storyteller/tauri-utils", () => ({ FetchProxy: transport }));
vi.mock("@tauri-apps/api/core", () => ({ invoke: native }));
vi.mock("@storyteller/tauri-api", async () => await import("../../../../tauri-api/src/lib/opener/OpenUrl"));
vi.mock("@storyteller/api", async () => ({
...(await import("../../../../api/src/lib/LoginChallengesApi")),
...(await import("../../../../api/src/lib/UsersApi")),
HttpApiError: (await import("../../../../api/src/lib/ApiManager")).HttpApiError,
}));
vi.mock("qrcode.react", () => ({ QRCodeSVG: ({ value }: { value: string }) => <svg role="img" aria-label="Login QR" data-value={value} /> }));
const DEVICE = "D".repeat(43);
const APPROVAL_URL = `https://app.getartcraft.com/login/desktop#approval_token=${"A".repeat(43)}`;
const USER = { user_token: "user_fixture", username: "google_user" };
let outcome: Record<string, unknown>;
let expiresIn: number;
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-09-25T12:00:00Z"));
localStorage.clear();
transport.mockReset();
native.mockReset().mockResolvedValue(undefined);
expiresIn = 1_200_000;
outcome = { success: true, status: "pending", maybe_failure_type: null };
transport.mockImplementation(async (url: string) => {
if (url.endsWith("/create")) return response({
success: true, device_token: DEVICE, verification_url: APPROVAL_URL,
confirmation_code: "WDJBMJHT", expires_at: new Date(Date.now() + expiresIn).toISOString(), poll_interval_seconds: 5,
});
if (url.endsWith("/poll")) return response(outcome);
if (url.endsWith("/v1/session")) return response({ success: true, logged_in: true, user: USER });
throw new Error("Unexpected test URL");
});
});
afterEach(() => { cleanup(); vi.useRealTimers(); });
describe("desktop login bridge integration", () => {
it("opens the native browser with only the approval token and displays the matching QR/code", async () => {
render(<DesktopLoginBridge onSuccess={vi.fn()} />);
await start("Login with Website");
expect(native).toHaveBeenCalledWith("plugin:opener|open_url", { url: APPROVAL_URL });
expect(screen.getByRole("img", { name: "Login QR" }).getAttribute("data-value")).toBe(APPROVAL_URL);
expect(screen.getByText("WDJB-MJHT")).toBeTruthy();
expect(document.body.textContent).not.toContain(DEVICE);
expect(localStorage.getItem("artcraft_signed_session")).toBeNull();
});
it("polls until redeemed, synchronizes native credentials, then verifies the downstream session", async () => {
const success = vi.fn();
render(<DesktopLoginBridge onSuccess={success} />);
await start("Scan to Login");
expect(native).not.toHaveBeenCalled();
await tick(5000);
expect(success).not.toHaveBeenCalled();
expect(localStorage.getItem("artcraft_signed_session")).toBeNull();
outcome = { success: true, status: "redeemed", maybe_failure_type: null, maybe_signed_session: "signed_session_from_api" };
await tick(5000);
expect(native).toHaveBeenCalledWith("storyteller_sync_login_session_command");
expect(localStorage.getItem("artcraft_signed_session")).toBe("signed_session_from_api");
expect(success).toHaveBeenCalledWith(USER);
const sessionCall = transport.mock.calls.find(([url]) => url.endsWith("/v1/session"));
expect(sessionCall?.[1].headers.session).toBe("signed_session_from_api");
const count = transport.mock.calls.length;
await tick(30_000);
expect(transport).toHaveBeenCalledTimes(count);
});
it("stops after decline without persisting or synchronizing a session", async () => {
const success = vi.fn();
outcome = { success: true, status: "failed", maybe_failure_type: "user_declined" };
render(<DesktopLoginBridge onSuccess={success} />);
await start("Scan to Login");
await tick(5000);
expect(screen.getByText("Login was declined on the website.")).toBeTruthy();
expect(success).not.toHaveBeenCalled();
expect(native).not.toHaveBeenCalled();
expect(localStorage.getItem("artcraft_signed_session")).toBeNull();
await tick(60_000);
expect(transport).toHaveBeenCalledTimes(2);
});
it("expires locally without automatically creating another challenge", async () => {
expiresIn = 5000;
render(<DesktopLoginBridge onSuccess={vi.fn()} />);
await start("Scan to Login");
await tick(5000);
expect(screen.getByText("Login request expired. Start a new request.")).toBeTruthy();
expect(transport).toHaveBeenCalledTimes(1);
});
it("backs off a network failure and retries the same device token", async () => {
render(<DesktopLoginBridge onSuccess={vi.fn()} />);
await start("Scan to Login");
transport.mockRejectedValueOnce(new Error("Disconnected"));
await tick(5000);
expect(screen.getByText("Connection interrupted. Retrying…")).toBeTruthy();
await tick(9999);
expect(transport).toHaveBeenCalledTimes(2);
await tick(1);
expect(transport).toHaveBeenCalledTimes(3);
expect(JSON.parse(transport.mock.calls[2][1].body)).toEqual({ device_token: DEVICE });
});
it("does not accept an in-flight response after the dialog unmounts", async () => {
let resolve: (value: Response) => void = () => {};
const success = vi.fn();
const view = render(<DesktopLoginBridge onSuccess={success} />);
await start("Scan to Login");
transport.mockImplementationOnce(() => new Promise<Response>((done) => { resolve = done; }));
await tick(5000);
view.unmount();
await act(async () => { resolve(response({ success: true, status: "redeemed", maybe_signed_session: "late_session" })); });
expect(success).not.toHaveBeenCalled();
expect(native).not.toHaveBeenCalled();
expect(localStorage.getItem("artcraft_signed_session")).toBeNull();
});
it("fails closed on a future status instead of accepting its session value", async () => {
outcome = { success: true, status: "future_state", maybe_signed_session: "untrusted_session" };
render(<DesktopLoginBridge onSuccess={vi.fn()} />);
await start("Scan to Login");
await tick(5000);
expect(screen.getByText("This login request could not be completed. Start a new request.")).toBeTruthy();
expect(localStorage.getItem("artcraft_signed_session")).toBeNull();
expect(native).not.toHaveBeenCalled();
});
});
async function start(label: string) {
await act(async () => { fireEvent.click(screen.getByRole("button", { name: label })); });
}
async function tick(ms: number) {
await act(async () => { await vi.advanceTimersByTimeAsync(ms); });
}
function response(body: unknown) {
return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } });
}
@@ -0,0 +1,118 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { QRCodeSVG } from "qrcode.react";
import { invoke } from "@tauri-apps/api/core";
import { LoginChallengesApi, UsersApi, HttpApiError, type LoginChallenge, type UserInfo } from "@storyteller/api";
import { OpenUrl } from "@storyteller/tauri-api";
export function DesktopLoginBridge({ onSuccess }: { onSuccess: (user: UserInfo) => void }) {
const api = useMemo(() => new LoginChallengesApi(), []);
const [challenge, setChallenge] = useState<LoginChallenge | null>(null);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState("");
const [now, setNow] = useState(Date.now);
const generation = useRef(0);
const success = useRef(onSuccess);
success.current = onSuccess;
useEffect(() => () => { generation.current += 1; }, []);
useEffect(() => {
if (!challenge) return;
const timer = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(timer);
}, [challenge]);
useEffect(() => {
if (!challenge) return;
let active = true;
let timer: ReturnType<typeof setTimeout>;
let delay = Math.max(5, challenge.poll_interval_seconds) * 1000;
const deadline = Date.parse(challenge.expires_at);
const finish = (text: string) => {
setChallenge(null);
setMessage(text);
};
const poll = async () => {
if (!active) return;
if (Date.now() >= deadline) { finish("Login request expired. Start a new request."); return; }
try {
const result = await api.poll(challenge.device_token);
if (!active) return;
if (result.status === "redeemed") {
api.acceptSession(result);
// The response's Set-Cookie is already in Tauri's HTTP cookie jar.
// Synchronize the native API client before declaring login complete.
await invoke("storyteller_sync_login_session_command");
if (!active) return;
const session = await new UsersApi().GetSession();
if (!session.success || !session.data?.loggedIn || !session.data.user) throw new Error("Session verification failed");
if (!active) return;
active = false;
setChallenge(null);
setMessage("Signed in successfully.");
success.current(session.data.user);
return;
}
if (result.status === "failed") {
finish(result.maybe_failure_type === "user_declined" ? "Login was declined on the website." : result.maybe_failure_type === "expired" ? "Login request expired. Start a new request." : "Login failed. Start a new request.");
return;
}
if (!result.success || !["pending", "approved"].includes(result.status)) {
finish("This login request could not be completed. Start a new request.");
return;
}
delay = Math.max(5, challenge.poll_interval_seconds) * 1000;
setMessage("Waiting for your confirmation on the website…");
} catch (error) {
if (!active) return;
if (error instanceof HttpApiError && [400, 401, 403, 404].includes(error.status)) {
finish("This login request is no longer valid. Start a new request.");
return;
}
delay = Math.min(delay * 2, 30_000);
setMessage("Connection interrupted. Retrying…");
}
if (active) timer = setTimeout(poll, Math.min(delay, Math.max(0, deadline - Date.now())));
};
timer = setTimeout(poll, Math.min(delay, Math.max(0, deadline - Date.now())));
return () => { active = false; clearTimeout(timer); };
}, [api, challenge]);
const start = async (openBrowser: boolean) => {
if (busy) return;
const current = ++generation.current;
setBusy(true);
setMessage("");
try {
const created = await api.create();
if (current !== generation.current) return;
const url = new URL(created.verification_url);
if (!created.success || url.origin !== "https://app.getartcraft.com" || url.pathname !== "/login/desktop" || !Number.isFinite(Date.parse(created.expires_at))) {
throw new Error("Invalid login challenge");
}
setNow(Date.now());
setChallenge(created);
setMessage("Waiting for your confirmation on the website…");
if (openBrowser) await OpenUrl(created.verification_url);
} catch {
if (current === generation.current) setMessage("Unable to open website login. Try again or scan the QR code.");
} finally {
if (current === generation.current) setBusy(false);
}
};
const remaining = challenge ? Math.max(0, Math.ceil((Date.parse(challenge.expires_at) - now) / 1000)) : 0;
return <section aria-label="Website login" className="mb-6 rounded-xl border border-white/15 p-4 text-center">
{challenge ? <>
<p className="mb-3 text-sm text-white/70">Scan with your phone or approve in your browser.</p>
<QRCodeSVG value={challenge.verification_url} size={192} marginSize={4} title="Scan to approve desktop login" className="mx-auto rounded-lg" />
<p className="my-3 font-mono text-2xl tracking-widest">{challenge.confirmation_code.slice(0, 4)}-{challenge.confirmation_code.slice(4)}</p>
<p className="mb-3 text-xs text-white/60">Verify this code on the website. Expires in {Math.floor(remaining / 60)}:{String(remaining % 60).padStart(2, "0")}</p>
<button type="button" className="rounded-lg border border-white/30 px-4 py-2" onClick={() => OpenUrl(challenge.verification_url).catch(() => setMessage("Unable to open the browser. Scan the QR code instead."))}>Open website</button>
</> : <div className="flex flex-col gap-2">
<button type="button" disabled={busy} className="rounded-lg bg-white px-4 py-3 font-medium text-black disabled:opacity-50" onClick={() => start(true)}>{busy ? "Preparing login…" : "Login with Website"}</button>
<button type="button" disabled={busy} className="rounded-lg border border-white/30 px-4 py-2" onClick={() => start(false)}>Scan to Login</button>
<p className="text-xs text-white/50">Use Google or any account already signed in on the website.</p>
</div>}
{message && <p className="mt-3 text-sm text-white/70" role="status">{message}</p>}
</section>;
}
@@ -3,6 +3,7 @@ import { Transition, TransitionChild } from "@headlessui/react";
import { useState, useEffect } 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 { useLoginModalStore } from "./useLoginModalStore";
@@ -254,6 +255,10 @@ export function LoginModal({
</p>
</div>
<DesktopLoginBridge onSuccess={(user) => {
onArtCraftAuthSuccess?.(user);
handleClose();
}} />
<ArtCraftSignUp
onSubmit={handleAuthSubmit}
isSignUp={isSignUp}
@@ -54,6 +54,9 @@
},
{
"path": "../input/tsconfig.lib.json"
},
{
"path": "../../tauri-api/tsconfig.lib.json"
}
]
}
+10
View File
@@ -49,6 +49,7 @@
"mediabunny": "^1.29.1",
"opencut-wasm": "^0.2.10",
"posthog-js": "^1.141.0",
"qrcode.react": "4.2.0",
"radix-ui": "^1.4.3",
"react": "18.3.1",
"react-copy-to-clipboard": "^5.1.0",
@@ -20479,6 +20480,15 @@
"node": ">=6"
}
},
"node_modules/qrcode.react": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
+1
View File
@@ -38,6 +38,7 @@
"mediabunny": "^1.29.1",
"opencut-wasm": "^0.2.10",
"posthog-js": "^1.141.0",
"qrcode.react": "4.2.0",
"radix-ui": "^1.4.3",
"react": "18.3.1",
"react-copy-to-clipboard": "^5.1.0",