diff --git a/_database/sql/migrations/AGENTS.md b/_database/sql/migrations/AGENTS.md index d5423532c..4f80e91ee 100644 --- a/_database/sql/migrations/AGENTS.md +++ b/_database/sql/migrations/AGENTS.md @@ -33,6 +33,7 @@ These are created with the `diesel` tool, and in particular: ## Rules +- Keep documentation in this directory as regular files, not symlinks. Diesel can treat a documentation symlink as a migration directory and reject the entire migration list. - Every `up.sql` must have a corresponding `down.sql` that cleanly reverses the migration. - For `CREATE TABLE` migrations, the `down.sql` is typically `DROP TABLE IF EXISTS table_name;`. - For `ALTER TABLE` migrations, the `down.sql` reverses the alteration (e.g. `DROP COLUMN`). diff --git a/_database/sql/migrations/CLAUDE.md b/_database/sql/migrations/CLAUDE.md deleted file mode 120000 index 47dc3e3d8..000000000 --- a/_database/sql/migrations/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md \ No newline at end of file diff --git a/_database/sql/migrations/CLAUDE.md b/_database/sql/migrations/CLAUDE.md new file mode 100644 index 000000000..5e5256509 --- /dev/null +++ b/_database/sql/migrations/CLAUDE.md @@ -0,0 +1,6 @@ +# SQL Migrations + +Read and follow [AGENTS.md](AGENTS.md) in this directory. + +This must remain a regular file. Diesel treats a symlink here as a migration +directory and fails with `Invalid migration directory`. diff --git a/_database/sql/user_login_challenges.md b/_database/sql/user_login_challenges.md index 5f5fc35f3..b6ed393e0 100644 --- a/_database/sql/user_login_challenges.md +++ b/_database/sql/user_login_challenges.md @@ -197,18 +197,23 @@ Production gateways can additionally enforce aggregate limits across replicas. A worker expires up to 1,000 abandoned challenges every 30 seconds. Audit retention is intentionally not an automatic purge policy in this change. -The website route is `https://app.getartcraft.com/login/desktop`. The token is kept +The production website route is `https://app.getartcraft.com/login/desktop`. Local +API requests use `http://localhost:4201/login/desktop`; run that webapp with +`USE_LOCAL_API=1` so approval reaches the same database. The token is kept in tab-scoped session storage through login, including Google login for accounts without a subscription. Returning to the page still requires explicit consent. The desktop displays the QR code, code comparison, countdown, and status. Opening the website invokes the native system browser. Network errors back off up to 30 -seconds; closing the dialog stops polling and ignores late UI results. An already -in-flight HTTP response may still update the native HTTP cookie jar. +seconds; closing the dialog stops polling and cancels the native handle. A response +arriving after cancellation cannot install credentials. Successful polling returns the signed value and the normal `Set-Cookie` header. -The Tauri command synchronizes the HTTP cookie jar with native credentials, scoped -to the configured API origin, and the UI verifies the ordinary session endpoint -before reporting success. No browser session is copied into the desktop. +Tauri commands use the Rust `artcraft_client` bindings for creation, polling, and +ordinary session verification, all against `AppEnvConfigs.storyteller_host`. Rust +keeps the private device token and signed session out of IPC/JavaScript; the UI +holds only a local handle and public display data. After verifying the downstream +session, Rust installs and persists the cookie in the native HTTP jar and updates +the credential manager. No browser session is copied into the desktop. ## Integration tests @@ -250,13 +255,15 @@ Coverage: - Eight browser tests: API/consent interaction, code/IP display, decline, expiry, invalid requests, safe return URLs, login continuation, and Google sign-in continuation without subscription gating or bridge credentials in SSO requests. -- Seven desktop UI tests: native browser invocation, QR/code values, polling, - successful credential synchronization and downstream session verification, - decline, timeout, network backoff, unmount, and unknown-state handling. -- Three native tests: HTTP response cookie to native credentials, retry safety, - exclusion of unrelated/expired cookies, and local/production origin isolation. +- Eight desktop UI tests: IPC-only transport (JavaScript HTTP is forbidden), + native browser invocation, QR/code values, polling, verified user results, + decline, timeout, network backoff, cancellation, and host/status diagnostics. +- Ten native tests: real API bindings against ephemeral loopback HTTP servers, + downstream verification before cookie installation, persistence, retries, + rejection, timeout, cancellation during HTTP, unknown states, no redirects, + redacted errors, local/production origin isolation, and visitor-only telemetry. -Frontend tests replace external HTTP and Google/native boundaries; they do not +Frontend tests replace external Google/native boundaries; they do not perform a live Google OAuth exchange or drive a packaged Tauri GUI. Backend tests use real MySQL and the production cookie signer/session guard with Redis disabled. Existing session-cache TTL behavior is unchanged. SQL lookups now reject expired diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/login_challenges/handlers.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/login_challenges/handlers.rs index 6be735b7b..abfc0837a 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/login_challenges/handlers.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/login_challenges/handlers.rs @@ -14,6 +14,7 @@ use super::security::{new_confirmation_code, new_secret, rate_limit, request_ip, const VERIFICATION_PAGE: &str = "https://app.getartcraft.com/login/desktop"; + pub fn configure(cfg: &mut web::ServiceConfig) { cfg.service(web::scope("/v1/login_challenges").wrap(DefaultHeaders::new().add(("Cache-Control", "no-store")).add(("Referrer-Policy", "no-referrer"))).app_data(web::JsonConfig::default().limit(1024)).route("/create", web::post().to(create)).route("/review", web::post().to(review)).route("/decide", web::post().to(decide)).route("/poll", web::post().to(poll))); } @@ -36,7 +37,7 @@ pub async fn create(request: HttpRequest, _body: web::Json &'static str { + let host = request.connection_info().host().to_owned(); + if host.starts_with("localhost:") || host.starts_with("127.0.0.1:") { + "http://localhost:4201/login/desktop" + } else { + VERIFICATION_PAGE + } +} + fn outcome(challenge: &LoginChallenge, expired: bool) -> Result { // Keep an explicit decline even when its deadline has since elapsed. let declined = challenge.maybe_failure_type.as_deref() == Some("user_declined"); @@ -148,3 +158,21 @@ fn outcome(challenge: &LoginChallenge, expired: bool) -> Result Result<(), CommonWebErr // Local web development is permitted only when the API is also local. let host = request.connection_info().host().to_owned(); let local_api = host.starts_with("localhost:") || host.starts_with("127.0.0.1:"); - if local_api && ["http://localhost:4201", "http://127.0.0.1:4201"].contains(&origin) { + if local_api && ["http://localhost:4200", "http://127.0.0.1:4200", "http://localhost:4201", "http://127.0.0.1:4201"].contains(&origin) { return Ok(()); } Err(CommonWebError::Forbidden) diff --git a/frontend/apps/artcraft-webapp/src/components/auth/signup-form.tsx b/frontend/apps/artcraft-webapp/src/components/auth/signup-form.tsx index c3c932fa3..c56a90185 100644 --- a/frontend/apps/artcraft-webapp/src/components/auth/signup-form.tsx +++ b/frontend/apps/artcraft-webapp/src/components/auth/signup-form.tsx @@ -21,6 +21,8 @@ interface SignupFormProps { signupSource: string; className?: string; autoFocus?: boolean; + /** Let the enclosing flow resume after Google, without a billing redirect. */ + continueAfterGoogleLogin?: boolean; } export const SignupForm = ({ @@ -28,6 +30,7 @@ export const SignupForm = ({ signupSource, className = "", autoFocus = false, + continueAfterGoogleLogin = false, }: SignupFormProps) => { const navigate = useNavigate(); const [email, setEmail] = useState(""); @@ -84,6 +87,10 @@ export const SignupForm = ({ }; const handleGoogleSuccess = async () => { + if (continueAfterGoogleLogin) { + onSuccess(); + return; + } // Refresh the session and check the subscription in parallel; users without // an active subscription are pushed to pricing, subscribers go home. const [, subscribed] = await Promise.all([ diff --git a/frontend/apps/artcraft-webapp/src/lib/login-bridge-context.ts b/frontend/apps/artcraft-webapp/src/lib/login-bridge-context.ts index 5adcba271..f547b3d07 100644 --- a/frontend/apps/artcraft-webapp/src/lib/login-bridge-context.ts +++ b/frontend/apps/artcraft-webapp/src/lib/login-bridge-context.ts @@ -1,27 +1 @@ -export const LOGIN_BRIDGE_PATH = "/login/desktop"; -const STORAGE_KEY = "artcraft_pending_desktop_approval"; -const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/; - -/** Capture the fragment before referral tracking or any third-party code can see it. */ -export function captureLoginBridgeContext(): string | null { - if (window.location.pathname !== LOGIN_BRIDGE_PATH) return null; - const fragment = new URLSearchParams(window.location.hash.slice(1)); - const supplied = fragment.get("approval_token"); - if (supplied !== null) { - // Remove even malformed input from the URL. It must not become attribution data. - window.history.replaceState(window.history.state, "", LOGIN_BRIDGE_PATH); - sessionStorage.removeItem(STORAGE_KEY); - if (TOKEN_PATTERN.test(supplied)) sessionStorage.setItem(STORAGE_KEY, supplied); - } - const stored = sessionStorage.getItem(STORAGE_KEY); - return stored && TOKEN_PATTERN.test(stored) ? stored : null; -} - -export function clearLoginBridgeContext(): void { - sessionStorage.removeItem(STORAGE_KEY); -} - -export function safeAuthReturnPath(value: string | null): string { - if (!value || !value.startsWith("/") || value.startsWith("//") || /[\\\u0000-\u001f]/.test(value)) return "/"; - return value; -} +export { captureLoginBridgeContext, clearLoginBridgeContext, LOGIN_BRIDGE_PATH, safeAuthReturnPath, authContinuationUrl } from "@frontend/login"; diff --git a/frontend/apps/artcraft-webapp/src/pages/forgot-password/forgot-password.tsx b/frontend/apps/artcraft-webapp/src/pages/forgot-password/forgot-password.tsx index 47942d4c5..b6b807d40 100644 --- a/frontend/apps/artcraft-webapp/src/pages/forgot-password/forgot-password.tsx +++ b/frontend/apps/artcraft-webapp/src/pages/forgot-password/forgot-password.tsx @@ -3,14 +3,17 @@ import { ArrowLeftIcon, LoaderCircleIcon, MailIcon } from "lucide-react"; import { Button } from "@storyteller/ui-button"; import { Input } from "@storyteller/ui-input"; import { useState } from "react"; -import { Link } from "react-router-dom"; +import { Link, useSearchParams } from "react-router-dom"; import { PasswordResetApi } from "@storyteller/api"; import { AuthHeader } from "../../components/auth/auth-layout"; import { AuthPageFrame } from "../../components/auth/auth-page-frame"; import Seo from "../../components/seo"; +import { authContinuationUrl } from "../../lib/login-bridge-context"; const ForgotPassword = () => { + const [searchParams] = useSearchParams(); + const from = searchParams.get("from"); const [email, setEmail] = useState(""); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -109,7 +112,7 @@ const ForgotPassword = () => { We've sent a password reset code to
{email}

- + @@ -125,7 +128,7 @@ const ForgotPassword = () => {
Back to Log in diff --git a/frontend/apps/artcraft-webapp/src/pages/forgot-password/verify-reset.tsx b/frontend/apps/artcraft-webapp/src/pages/forgot-password/verify-reset.tsx index 25066d27c..9ff6327e2 100644 --- a/frontend/apps/artcraft-webapp/src/pages/forgot-password/verify-reset.tsx +++ b/frontend/apps/artcraft-webapp/src/pages/forgot-password/verify-reset.tsx @@ -16,6 +16,8 @@ import { PasswordResetApi, BillingApi } from "@storyteller/api"; import { AuthHeader } from "../../components/auth/auth-layout"; import { AuthPageFrame } from "../../components/auth/auth-page-frame"; import Seo from "../../components/seo"; +import { authContinuationUrl, LOGIN_BRIDGE_PATH, safeAuthReturnPath } from "../../lib/login-bridge-context"; +import { refreshSession } from "../../lib/session"; const VerifyReset = () => { const navigate = useNavigate(); @@ -76,6 +78,13 @@ const VerifyReset = () => { setSuccess(true); window.dispatchEvent(new Event("auth-change")); + if (safeAuthReturnPath(searchParams.get("from")) === LOGIN_BRIDGE_PATH) { + await refreshSession(true); + setRedirectTo(LOGIN_BRIDGE_PATH); + setRedirectLabel("Review desktop login"); + return; + } + // Check if user has an active subscription to decide redirect try { const billingApi = new BillingApi(); @@ -235,7 +244,7 @@ const VerifyReset = () => {
Request a new code diff --git a/frontend/apps/artcraft-webapp/src/pages/login/desktop-login.tsx b/frontend/apps/artcraft-webapp/src/pages/login/desktop-login.tsx index bff3cc7e3..17d447f8f 100644 --- a/frontend/apps/artcraft-webapp/src/pages/login/desktop-login.tsx +++ b/frontend/apps/artcraft-webapp/src/pages/login/desktop-login.tsx @@ -1,91 +1,13 @@ -import { useEffect, useState } from "react"; -import { Link, Navigate } from "react-router-dom"; -import { HttpApiError, LoginChallengesApi, type LoginChallengeReview } from "@storyteller/api"; +import { Navigate } from "react-router-dom"; +import { DesktopLoginApproval, LOGIN_BRIDGE_PATH } from "@frontend/login"; import { useSession } from "../../lib/session"; -import { captureLoginBridgeContext, clearLoginBridgeContext, LOGIN_BRIDGE_PATH } from "../../lib/login-bridge-context"; export function DesktopLogin() { - const { loggedIn, authChecked } = useSession(); - const [token] = useState(captureLoginBridgeContext); - const [review, setReview] = useState(null); - const [matched, setMatched] = useState(false); - const [busy, setBusy] = useState(false); - const [message, setMessage] = useState(""); - const [finished, setFinished] = useState(false); - const [now, setNow] = useState(Date.now); - - useEffect(() => { - if (!token || !loggedIn) return; - let active = true; - new LoginChallengesApi().review(token).then((result) => { - if (active) { - setReview(result); - if (result.status !== "pending") { - setFinished(true); - setMessage(result.maybe_failure_type === "expired" ? "This login request expired. Start again in ArtCraft." : "This login request has already been completed."); - clearLoginBridgeContext(); - } - } - }).catch((error) => { - if (!active) return; - setMessage(error instanceof HttpApiError && error.status === 401 - ? "This login request or account session is no longer valid. Start a new login in ArtCraft." - : "Unable to load this login request. Return to ArtCraft and try again."); - }); - return () => { active = false; }; - }, [token, loggedIn]); - - useEffect(() => { - const timer = window.setInterval(() => setNow(Date.now()), 1000); - return () => window.clearInterval(timer); - }, []); - - const expired = review ? now >= Date.parse(review.expires_at) : false; - const decide = async (approve: boolean) => { - if (!token || busy || expired || (approve && !matched)) return; - setBusy(true); - setMessage(""); - try { - const result = await new LoginChallengesApi().decide(token, approve); - const approved = result.status === "approved" || result.status === "redeemed"; - const declined = result.status === "failed" && result.maybe_failure_type === "user_declined"; - const timedOut = result.status === "failed" && result.maybe_failure_type === "expired"; - if (!result.success || (!approved && !declined && !timedOut)) throw new Error("Unexpected login state"); - setFinished(true); - setMessage(approved ? "Desktop login approved. Return to ArtCraft to finish signing in." : declined ? "Desktop login declined. No session was created." : "This login request expired. Start again in ArtCraft."); - clearLoginBridgeContext(); - } catch { - setMessage("Unable to confirm your choice. Please try again."); - } finally { - setBusy(false); - } - }; - - if (!authChecked) return
Checking your account…
; - if (!token) return
Open a new login link or scan the QR code from ArtCraft.
; - if (!loggedIn) return ; - - return
-
- ArtCraft -

Log in to ArtCraft Desktop?

- {message &&

{message}

} - {expired && !finished &&

This request expired. Start a new login in ArtCraft.

} - {!finished && !expired && review && <> -

Authorize your desktop as {review.username}.

-

Requesting IP: {review.requesting_ip}

-

Make sure this code matches the code on your desktop:

-

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

- -
- - -
- } - {!review && !message &&

Loading login request…

} -
-
; + const { loggedIn, authChecked, user } = useSession(); + return } + />; } diff --git a/frontend/apps/artcraft-webapp/src/pages/login/login.tsx b/frontend/apps/artcraft-webapp/src/pages/login/login.tsx index bb45eefbf..bd8b4f47d 100644 --- a/frontend/apps/artcraft-webapp/src/pages/login/login.tsx +++ b/frontend/apps/artcraft-webapp/src/pages/login/login.tsx @@ -14,7 +14,7 @@ import { import Seo from "../../components/seo"; import { refreshSession } from "../../lib/session"; import { hasActiveSubscription } from "../../lib/billing"; -import { LOGIN_BRIDGE_PATH, safeAuthReturnPath } from "../../lib/login-bridge-context"; +import { authContinuationUrl, LOGIN_BRIDGE_PATH, safeAuthReturnPath } from "../../lib/login-bridge-context"; const Login = () => { const navigate = useNavigate(); @@ -111,7 +111,7 @@ const Login = () => { Password Forgot password? @@ -170,7 +170,7 @@ const Login = () => { Don't have an account?{" "} Sign up diff --git a/frontend/apps/artcraft-webapp/src/pages/signup/signup.tsx b/frontend/apps/artcraft-webapp/src/pages/signup/signup.tsx index 782b55fae..947795494 100644 --- a/frontend/apps/artcraft-webapp/src/pages/signup/signup.tsx +++ b/frontend/apps/artcraft-webapp/src/pages/signup/signup.tsx @@ -1,9 +1,14 @@ -import { Link, useNavigate } from "react-router-dom"; +import { Link, useNavigate, useSearchParams } from "react-router-dom"; import { AuthHeader, AuthFooter, SignupForm } from "../../components/auth"; import Seo from "../../components/seo"; +import { authContinuationUrl, LOGIN_BRIDGE_PATH, safeAuthReturnPath } from "../../lib/login-bridge-context"; +import { refreshSession } from "../../lib/session"; const Signup = () => { const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const from = searchParams.get("from"); + const isDesktopLogin = safeAuthReturnPath(from) === LOGIN_BRIDGE_PATH; return ( <> @@ -13,7 +18,11 @@ const Signup = () => { /> Make it yours.} subtitle="Create your account and start crafting." /> navigate("/welcome")} + onSuccess={async () => { + if (isDesktopLogin) await refreshSession(true); + navigate(isDesktopLogin ? LOGIN_BRIDGE_PATH : "/welcome"); + }} + continueAfterGoogleLogin={isDesktopLogin} signupSource="artcraft" /> @@ -21,7 +30,7 @@ const Signup = () => { Already have an account?{" "} Log in diff --git a/frontend/apps/artcraft-webapp/tailwind.config.js b/frontend/apps/artcraft-webapp/tailwind.config.js index 242a2aacc..62c455539 100644 --- a/frontend/apps/artcraft-webapp/tailwind.config.js +++ b/frontend/apps/artcraft-webapp/tailwind.config.js @@ -10,6 +10,7 @@ module.exports = { "{src,pages,components,app}/**/*!(*.stories|*.spec).{ts,tsx,html}", ), ...createGlobPatternsForDependencies(__dirname), + join(__dirname, "../../libs/login/src/**/*.{ts,tsx}"), "./app/index.html", "./app/src/**/*.{js,jsx,ts,tsx}", "./app/src/*.{js,jsx,ts,tsx}", diff --git a/frontend/apps/artcraft-webapp/tsconfig.app.json b/frontend/apps/artcraft-webapp/tsconfig.app.json index 77340f1f4..aef5e7b60 100644 --- a/frontend/apps/artcraft-webapp/tsconfig.app.json +++ b/frontend/apps/artcraft-webapp/tsconfig.app.json @@ -4,7 +4,9 @@ "outDir": "dist", "tsBuildInfoFile": "dist/tsconfig.app.tsbuildinfo", "jsx": "react-jsx", - "lib": ["dom"], + "lib": [ + "dom" + ], "types": [ "node", "@nx/react/typings/cssmodule.d.ts", @@ -141,6 +143,9 @@ }, { "path": "../../libs/components/keybinds/tsconfig.lib.json" + }, + { + "path": "../../libs/login/tsconfig.lib.json" } ] } diff --git a/frontend/apps/artcraft-webapp/tsconfig.json b/frontend/apps/artcraft-webapp/tsconfig.json index 2ce61abd5..7df9cf69f 100644 --- a/frontend/apps/artcraft-webapp/tsconfig.json +++ b/frontend/apps/artcraft-webapp/tsconfig.json @@ -17,12 +17,18 @@ { "path": "../../libs/components/moodboard" }, + { + "path": "../../libs/components/vfx" + }, { "path": "../../libs/components/slider-v2" }, { "path": "../../libs/components/toaster" }, + { + "path": "../../libs/login" + }, { "path": "../../libs/omni-gen" }, @@ -95,9 +101,6 @@ { "path": "../../libs/icons" }, - { - "path": "../../libs/components/vfx" - }, { "path": "../../libs/common" }, diff --git a/frontend/apps/artcraft-website/index.html b/frontend/apps/artcraft-website/index.html index f8e645e5a..ead2140f5 100644 --- a/frontend/apps/artcraft-website/index.html +++ b/frontend/apps/artcraft-website/index.html @@ -2,17 +2,24 @@ - diff --git a/frontend/apps/artcraft-website/src/app/app.tsx b/frontend/apps/artcraft-website/src/app/app.tsx index 05f2d66a7..6cf424e06 100644 --- a/frontend/apps/artcraft-website/src/app/app.tsx +++ b/frontend/apps/artcraft-website/src/app/app.tsx @@ -32,6 +32,7 @@ import Pricing from "../pages/pricing"; import Beta from "../pages/beta"; import Support from "../pages/support/support"; import Login from "../pages/login"; +import { DesktopLogin } from "../pages/login/desktop-login"; import Signup from "../pages/signup"; import ForgotPassword, { VerifyReset } from "../pages/forgot-password"; import Welcome from "../pages/welcome"; @@ -65,7 +66,7 @@ function appOrWebapp(localElement: ReactNode, webappPath: string): ReactNode { export function App() { // Prototype landing brings its own navbar (sticky, full-width, sharp). const { pathname } = useLocation(); - const hideGlobalNavbar = pathname === "/landingp0"; + const hideGlobalNavbar = pathname === "/landingp0" || pathname === "/login/desktop"; return (
@@ -96,6 +97,8 @@ export function App() { } /> } /> } /> + {/* Consent must stay on this origin even when app features redirect. */} + } /> {/* App routes — flip USE_WEBAPP_FOR_APP_FEATURES in config/links.ts to render these locally instead of redirecting to the webapp. */} diff --git a/frontend/apps/artcraft-website/src/components/auth/auth-gate-modal.tsx b/frontend/apps/artcraft-website/src/components/auth/auth-gate-modal.tsx index ebca685c3..801aabcdb 100644 --- a/frontend/apps/artcraft-website/src/components/auth/auth-gate-modal.tsx +++ b/frontend/apps/artcraft-website/src/components/auth/auth-gate-modal.tsx @@ -18,6 +18,7 @@ interface AuthGateModalProps { headline: string; subtitle: string; perks?: string[]; + initialMode?: "signup" | "login"; } /** @@ -33,8 +34,9 @@ export function AuthGateModal({ headline, subtitle, perks, + initialMode = "signup", }: AuthGateModalProps) { - const [mode, setMode] = useState<"signup" | "login">("signup"); + const [mode, setMode] = useState<"signup" | "login">(initialMode); const [googleError, setGoogleError] = useState(null); return ( diff --git a/frontend/apps/artcraft-website/src/main.tsx b/frontend/apps/artcraft-website/src/main.tsx index c9555bb4a..f7ac95870 100644 --- a/frontend/apps/artcraft-website/src/main.tsx +++ b/frontend/apps/artcraft-website/src/main.tsx @@ -8,6 +8,10 @@ import { captureLandingContext, getReferrer } from "@storyteller/common"; import { setOmniGenErrorNotifier } from "@storyteller/omni-gen"; import { setToastDelegate } from "@storyteller/ui-toaster"; import { toast } from "./components/toast/toast"; +import { captureLoginBridgeContext, LOGIN_BRIDGE_PATH } from "@frontend/login"; + +captureLoginBridgeContext(); +const isLoginBridge = window.location.pathname === LOGIN_BRIDGE_PATH; const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID; @@ -56,10 +60,10 @@ setToastDelegate({ // Persist landing context (referral username, landing URL, referrer) to apex- // domain cookies so attribution survives the getartcraft.com → // app.getartcraft.com hop. First visit wins. -captureLandingContext(); +if (!isLoginBridge) captureLandingContext(); // Fire-and-forget: log the referral once per browser session -if (!sessionStorage.getItem("referral_logged")) { +if (!isLoginBridge && !sessionStorage.getItem("referral_logged")) { sessionStorage.setItem("referral_logged", "1"); const referrer = getReferrer(); new UsersApi() diff --git a/frontend/apps/artcraft-website/src/pages/login/desktop-login.spec.tsx b/frontend/apps/artcraft-website/src/pages/login/desktop-login.spec.tsx new file mode 100644 index 000000000..5192f7b7e --- /dev/null +++ b/frontend/apps/artcraft-website/src/pages/login/desktop-login.spec.tsx @@ -0,0 +1,144 @@ +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { MemoryRouter } from "react-router-dom"; +import { DesktopLogin } from "./desktop-login"; +import { invalidateSession, useSessionStore } from "../../lib/session"; +import { clearLoginBridgeContext } from "@frontend/login"; + +const { transport } = vi.hoisted(() => ({ transport: vi.fn() })); +vi.mock("@storyteller/tauri-utils", () => ({ FetchProxy: transport })); +vi.mock("@storyteller/api", async () => ({ + ...(await import("../../../../../libs/api/src/lib/UsersApi")), + ...(await import("../../../../../libs/api/src/lib/LoginChallengesApi")), + HttpApiError: (await import("../../../../../libs/api/src/lib/ApiManager")).HttpApiError, +})); +vi.mock("@react-oauth/google", () => ({ GoogleLogin: ({ onSuccess }: any) => })); +vi.mock("@storyteller/common", () => ({ getLandingUrl: () => undefined, getReferralCode: () => undefined, getReferralUsername: () => undefined, getReferrer: () => undefined })); +vi.mock("@storyteller/icons", () => ({ DynamicIcon: () => null })); +vi.mock("@storyteller/ui-modal", () => ({ Modal: ({ children, isOpen, onClose }: any) => isOpen ?
{children}
: null })); +vi.mock("@storyteller/ui-button", () => ({ Button: ({ children, ...props }: any) => })); +vi.mock("@storyteller/ui-input", () => ({ Input: ({ inputClassName, ...props }: any) => })); + +const APPROVAL_TOKEN = "B".repeat(43); +const USER = { username: "google_user", user_token: "u_test" }; +const REVIEW = { + success: true, status: "pending", maybe_failure_type: null, + confirmation_code: "WDJBMJHT", requesting_ip: "2001:db8::42", + username: USER.username, expires_at: new Date(Date.now() + 1_200_000).toISOString(), +}; +let signedIn = false; +let authFails = false; + +beforeEach(() => { + signedIn = false; + authFails = false; + clearLoginBridgeContext(); + sessionStorage.clear(); + localStorage.clear(); + invalidateSession(); + useSessionStore.setState({ user: undefined, loggedIn: false, authChecked: true }); + window.history.replaceState(null, "", `/login/desktop#approval_token=${APPROVAL_TOKEN}`); + transport.mockReset().mockImplementation(async (url: string, init: RequestInit) => { + const path = new URL(url).pathname; + if (path === "/v1/session") return response({ success: true, logged_in: signedIn, user: signedIn ? USER : undefined }); + if (["/v1/login", "/v1/accounts/google_sso", "/v1/create_account"].includes(path)) { + signedIn = !authFails; + return response(authFails ? { success: false, error_message: "Invalid credentials" } : { success: true, signed_session: "browser_session" }); + } + if (path.endsWith("/review")) return response(REVIEW); + if (path.endsWith("/decide")) { + const { approve } = JSON.parse(String(init.body)); + return response({ success: true, status: approve ? "approved" : "failed", maybe_failure_type: approve ? null : "user_declined" }); + } + throw new Error(`Unexpected test request: ${path}`); + }); +}); +afterEach(cleanup); + +describe("website desktop login with real auth and approval clients", () => { + it.each(["google", "password"])("keeps %s sign-in on this origin and waits for explicit consent", async (method) => { + renderPage(); + expect(window.location.hash).toBe(""); + expect(sessionStorage.getItem("artcraft_pending_desktop_approval")).toBe(APPROVAL_TOKEN); + if (method === "google") { + fireEvent.click(screen.getByRole("button", { name: "Google sign in" })); + } else { + fireEvent.change(screen.getByPlaceholderText("you@example.com"), { target: { value: "user@example.com" } }); + fireEvent.change(screen.getByPlaceholderText("Your password"), { target: { value: "password123" } }); + fireEvent.click(screen.getByRole("button", { name: "Log in" })); + } + await screen.findByText(USER.username); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(screen.getByText("2001:db8::42")).toBeTruthy(); + expect(screen.getByText("WDJB-MJHT")).toBeTruthy(); + expect(bridgeCalls("decide")).toHaveLength(0); + expect(window.location.pathname).toBe("/login/desktop"); + const authCall = transport.mock.calls.find(([url]) => /\/(login|google_sso)$/.test(url)); + expect(authCall?.[1].body).not.toContain(APPROVAL_TOKEN); + const reviewCall = bridgeCalls("review")[0]; + expect(reviewCall[1].headers.session).toBe("browser_session"); + expect(JSON.parse(reviewCall[1].body)).toEqual({ approval_token: APPROVAL_TOKEN }); + fireEvent.click(screen.getByRole("checkbox")); + fireEvent.click(screen.getByRole("button", { name: "Approve desktop login" })); + await screen.findByText(/Desktop login approved/); + expect(bridgeCalls("decide")).toHaveLength(1); + expect(localStorage.getItem("artcraft_signed_session")).toBe("browser_session"); + expect(sessionStorage.getItem("artcraft_pending_desktop_approval")).toBeNull(); + expect(bridgeCalls("poll")).toHaveLength(0); + }); + + it("lets an existing session decline without another login or code confirmation", async () => { + signedIn = true; + useSessionStore.setState({ user: USER as any, loggedIn: true }); + renderPage(); + fireEvent.click(await screen.findByRole("button", { name: "Decline" })); + await screen.findByText("Desktop login declined. No session was created."); + expect(JSON.parse(bridgeCalls("decide")[0][1].body)).toEqual({ approval_token: APPROVAL_TOKEN, approve: false }); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(localStorage.getItem("artcraft_signed_session")).toBeNull(); + }); + + it("keeps a failed sign-in pending and permits a retry", async () => { + authFails = true; + renderPage(); + fireEvent.click(screen.getByRole("button", { name: "Google sign in" })); + await screen.findByText("Invalid credentials"); + expect(bridgeCalls("review")).toHaveLength(0); + expect(bridgeCalls("decide")).toHaveLength(0); + expect(sessionStorage.getItem("artcraft_pending_desktop_approval")).toBe(APPROVAL_TOKEN); + authFails = false; + fireEvent.click(screen.getByRole("button", { name: "Google sign in" })); + await screen.findByText(USER.username); + expect(bridgeCalls("decide")).toHaveLength(0); + }); + + it("closing sign-in preserves the challenge and does not approve or decline it", async () => { + renderPage(); + fireEvent.click(screen.getByRole("button", { name: "Close" })); + expect(screen.queryByRole("dialog")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Sign in" })); + expect(screen.getByRole("dialog")).toBeTruthy(); + await act(async () => {}); + expect(bridgeCalls("decide")).toHaveLength(0); + expect(sessionStorage.getItem("artcraft_pending_desktop_approval")).toBe(APPROVAL_TOKEN); + }); + + it("does not show sign-in or make bridge requests for a malformed link", async () => { + window.history.replaceState(null, "", "/login/desktop#approval_token=bad"); + renderPage(); + await screen.findByText(/Open a new login link/); + expect(window.location.hash).toBe(""); + expect(screen.queryByRole("dialog")).toBeNull(); + await waitFor(() => expect(bridgeCalls("review")).toHaveLength(0)); + }); +}); + +function renderPage() { + return render(); +} +function bridgeCalls(action: string) { + return transport.mock.calls.filter(([url]) => url.endsWith(`/login_challenges/${action}`)); +} +function response(body: unknown) { + return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } }); +} diff --git a/frontend/apps/artcraft-website/src/pages/login/desktop-login.tsx b/frontend/apps/artcraft-website/src/pages/login/desktop-login.tsx new file mode 100644 index 000000000..00b2bf6ec --- /dev/null +++ b/frontend/apps/artcraft-website/src/pages/login/desktop-login.tsx @@ -0,0 +1,36 @@ +import { useState } from "react"; +import { DesktopLoginApproval } from "@frontend/login"; +import { AuthGateModal } from "../../components/auth/auth-gate-modal"; +import { refreshSession, useSession } from "../../lib/session"; + +export function DesktopLogin() { + const { loggedIn, authChecked, user } = useSession(); + return } + />; +} + +// Keep sign-in on this origin: sessionStorage and mobile session headers are +// origin-scoped. Redirecting to the webapp would lose the pending approval. +function DesktopSignIn() { + const [open, setOpen] = useState(true); + return
+
+

Sign in to continue

+

Sign in to your ArtCraft account, then review the desktop login request. Signing in does not approve the request.

+ +
+ setOpen(false)} + initialMode="login" + onAuthed={() => { void refreshSession(true); }} + signupSource="artcraft_desktop_login" + headline="Sign in to ArtCraft" + subtitle="Next, you’ll review and confirm the desktop login request." + /> +
; +} diff --git a/frontend/apps/artcraft-website/tailwind.config.js b/frontend/apps/artcraft-website/tailwind.config.js index 4d2c6447b..13ce2ca70 100644 --- a/frontend/apps/artcraft-website/tailwind.config.js +++ b/frontend/apps/artcraft-website/tailwind.config.js @@ -10,6 +10,7 @@ module.exports = { "{src,pages,components,app}/**/*!(*.stories|*.spec).{ts,tsx,html}", ), ...createGlobPatternsForDependencies(__dirname), + join(__dirname, "../../libs/login/src/**/*.{ts,tsx}"), "./app/index.html", "./app/src/**/*.{js,jsx,ts,tsx}", "./app/src/*.{js,jsx,ts,tsx}", diff --git a/frontend/apps/artcraft-website/tsconfig.app.json b/frontend/apps/artcraft-website/tsconfig.app.json index f48775897..181ca14ff 100644 --- a/frontend/apps/artcraft-website/tsconfig.app.json +++ b/frontend/apps/artcraft-website/tsconfig.app.json @@ -4,7 +4,9 @@ "outDir": "dist", "tsBuildInfoFile": "dist/tsconfig.app.tsbuildinfo", "jsx": "react-jsx", - "lib": ["dom"], + "lib": [ + "dom" + ], "types": [ "node", "@nx/react/typings/cssmodule.d.ts", @@ -105,6 +107,9 @@ }, { "path": "../../libs/icons/tsconfig.lib.json" + }, + { + "path": "../../libs/login/tsconfig.lib.json" } ] } diff --git a/frontend/apps/artcraft-website/tsconfig.json b/frontend/apps/artcraft-website/tsconfig.json index 220683abd..7a7b04989 100644 --- a/frontend/apps/artcraft-website/tsconfig.json +++ b/frontend/apps/artcraft-website/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../../libs/components/gallery-modal" }, + { + "path": "../../libs/login" + }, { "path": "../../libs/components/toaster" }, @@ -62,15 +65,15 @@ { "path": "../../libs/components/modal" }, + { + "path": "../../libs/icons" + }, { "path": "../../libs/common" }, { "path": "../../libs/api" }, - { - "path": "../../libs/icons" - }, { "path": "./tsconfig.app.json" }, diff --git a/frontend/libs/login/src/index.ts b/frontend/libs/login/src/index.ts index 0513e8d1e..300faf70f 100644 --- a/frontend/libs/login/src/index.ts +++ b/frontend/libs/login/src/index.ts @@ -1,2 +1,4 @@ export * from './lib/login'; +export * from "./lib/login-bridge-context"; +export * from "./lib/desktop-login-approval"; diff --git a/frontend/libs/login/src/lib/desktop-login-approval.tsx b/frontend/libs/login/src/lib/desktop-login-approval.tsx new file mode 100644 index 000000000..962a7690c --- /dev/null +++ b/frontend/libs/login/src/lib/desktop-login-approval.tsx @@ -0,0 +1,127 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { Link } from "react-router-dom"; +import { HttpApiError, LoginChallengesApi, type LoginChallengeResult, type LoginChallengeReview } from "@storyteller/api"; +import { captureLoginBridgeContext, clearLoginBridgeContext } from "./login-bridge-context"; + +const EXPIRED_MESSAGE = "This login request expired. Start again in ArtCraft."; + +interface DesktopLoginApprovalProps { + loggedIn: boolean; + authChecked: boolean; + signIn: ReactNode; +} + +/** Shared browser consent UI. Signing into the website never approves a desktop. */ +export function DesktopLoginApproval({ loggedIn, authChecked, signIn }: DesktopLoginApprovalProps) { + const [token] = useState(captureLoginBridgeContext); + const [review, setReview] = useState(null); + const [matched, setMatched] = useState(false); + const [busy, setBusy] = useState(false); + const deciding = useRef(false); + const [message, setMessage] = useState(""); + const [finished, setFinished] = useState(false); + const [attempt, setAttempt] = useState(0); + const [now, setNow] = useState(Date.now); + + useEffect(() => { + if (!token || !loggedIn || !authChecked) return; + let active = true; + setReview(null); + setMessage(""); + setMatched(false); + new LoginChallengesApi().review(token).then((result) => { + if (!active) return; + const completion = completionMessage(result); + if (completion) { + setFinished(true); + setMessage(completion); + clearLoginBridgeContext(); + } else { + if (!Number.isFinite(Date.parse(result.expires_at)) || + !/^[BCDFGHJKLMNPQRSTVWXZ]{8}$/.test(result.confirmation_code) || + !result.username || !result.requesting_ip) throw new Error("Invalid login review"); + setNow(Date.now()); + setReview(result); + } + }).catch((error) => { + if (!active) return; + setMessage(error instanceof HttpApiError && error.status === 401 + ? "This login request or account session is no longer valid. Start a new login in ArtCraft." + : "Unable to load this login request. Please try again."); + }); + return () => { active = false; }; + }, [token, loggedIn, authChecked, attempt]); + + useEffect(() => { + const timer = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(timer); + }, []); + + const expired = !!review && now >= Date.parse(review.expires_at); + useEffect(() => { + if (expired) clearLoginBridgeContext(); + }, [expired]); + + const decide = async (approve: boolean) => { + if (!token || !review || !loggedIn || deciding.current || finished || + Date.now() >= Date.parse(review.expires_at) || (approve && !matched)) return; + deciding.current = true; + setBusy(true); + setMessage(""); + try { + const result = await new LoginChallengesApi().decide(token, approve); + const completion = completionMessage(result); + if (!completion) throw new Error("Unexpected login state"); + setFinished(true); + setMessage(completion); + clearLoginBridgeContext(); + } catch { + setMessage("Unable to confirm your choice. Please try again."); + } finally { + deciding.current = false; + setBusy(false); + } + }; + + if (!authChecked) return Checking your account…; + if (!token) return Open a new login link or scan the QR code from ArtCraft.; + if (!loggedIn) return <>{signIn}; + + return
+
+ ArtCraft +

Log in to ArtCraft Desktop?

+ {message &&

{message}

} + {expired && !finished &&

{EXPIRED_MESSAGE}

} + {!finished && !expired && review && <> +

Authorize your desktop as {review.username}.

+

Requesting IP: {review.requesting_ip}

+

Make sure this code matches the code on your desktop:

+

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

+ +
+ + +
+ } + {!review && !message &&

Loading login request…

} + {!review && message && !finished && } +
+
; +} + +function Status({ children }: { children: ReactNode }) { + return
{children}
; +} + +function completionMessage(result: LoginChallengeResult): string | null { + if (!result.success) throw new Error("Unsuccessful login response"); + if (result.status === "pending") return null; + if (result.status === "approved" || result.status === "redeemed") return "Desktop login approved. Return to ArtCraft to finish signing in."; + if (result.status === "failed" && result.maybe_failure_type === "user_declined") return "Desktop login declined. No session was created."; + if (result.status === "failed" && result.maybe_failure_type === "expired") return EXPIRED_MESSAGE; + throw new Error("Unknown login state"); +} diff --git a/frontend/libs/login/src/lib/login-bridge-context.ts b/frontend/libs/login/src/lib/login-bridge-context.ts new file mode 100644 index 000000000..b605d36d1 --- /dev/null +++ b/frontend/libs/login/src/lib/login-bridge-context.ts @@ -0,0 +1,39 @@ +export const LOGIN_BRIDGE_PATH = "/login/desktop"; +const STORAGE_KEY = "artcraft_pending_desktop_approval"; +const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/; +let memoryToken: string | null = null; + +/** Capture the fragment before referral tracking or any third-party code can see it. */ +export function captureLoginBridgeContext(): string | null { + if (window.location.pathname !== LOGIN_BRIDGE_PATH) return null; + const fragment = new URLSearchParams(window.location.hash.slice(1)); + const supplied = fragment.get("approval_token"); + if (window.location.hash) { + // Remove even malformed input from the URL. It must not become attribution data. + window.history.replaceState(window.history.state, "", LOGIN_BRIDGE_PATH); + clearLoginBridgeContext(); + if (supplied && TOKEN_PATTERN.test(supplied)) { + memoryToken = supplied; + try { sessionStorage.setItem(STORAGE_KEY, supplied); } catch { /* Storage disabled; keep the current tab working. */ } + } + } + let stored = memoryToken; + try { stored = sessionStorage.getItem(STORAGE_KEY) ?? memoryToken; } catch { /* Storage disabled. */ } + return stored && TOKEN_PATTERN.test(stored) ? stored : null; +} + +export function clearLoginBridgeContext(): void { + memoryToken = null; + try { sessionStorage.removeItem(STORAGE_KEY); } catch { /* Storage disabled. */ } +} + +/** Carry only a validated destination, never the approval credential, through auth. */ +export function authContinuationUrl(path: string, from: string | null): string { + const destination = safeAuthReturnPath(from); + return destination === "/" ? path : `${path}?from=${encodeURIComponent(destination)}`; +} + +export function safeAuthReturnPath(value: string | null): string { + if (!value || !value.startsWith("/") || value.startsWith("//") || /[\\\u0000-\u001f]/.test(value)) return "/"; + return value; +} diff --git a/frontend/libs/login/tsconfig.json b/frontend/libs/login/tsconfig.json index 8c5fc025f..b26d1df84 100644 --- a/frontend/libs/login/tsconfig.json +++ b/frontend/libs/login/tsconfig.json @@ -2,6 +2,9 @@ "files": [], "include": [], "references": [ + { + "path": "../api" + }, { "path": "./tsconfig.lib.json" }, diff --git a/frontend/libs/login/tsconfig.lib.json b/frontend/libs/login/tsconfig.lib.json index c43e2edba..059cc5add 100644 --- a/frontend/libs/login/tsconfig.lib.json +++ b/frontend/libs/login/tsconfig.lib.json @@ -44,5 +44,10 @@ "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx" + ], + "references": [ + { + "path": "../api/tsconfig.lib.json" + } ] } diff --git a/frontend/tsconfig.base.json b/frontend/tsconfig.base.json index 3f2f054f0..fa0f24507 100644 --- a/frontend/tsconfig.base.json +++ b/frontend/tsconfig.base.json @@ -24,8 +24,12 @@ ], "baseUrl": ".", "paths": { - "@storyteller/api": ["libs/api/src/index.ts"], - "@storyteller/markdown-content": ["libs/markdown-content/src/index.ts"] + "@storyteller/api": [ + "libs/api/src/index.ts" + ], + "@storyteller/markdown-content": [ + "libs/markdown-content/src/index.ts" + ] } } }