mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
better UX for desktop
This commit is contained in:
@@ -115,6 +115,28 @@ describe("desktop native login bridge integration", () => {
|
||||
expect(success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores an in-flight redemption after Back and can start a new challenge", async () => {
|
||||
let resolve: (value: unknown) => void = () => {};
|
||||
const success = vi.fn();
|
||||
render(<DesktopLoginBridge onSuccess={success} />);
|
||||
await start("Scan to Login");
|
||||
native.mockImplementationOnce(() => new Promise((done) => { resolve = done; }));
|
||||
await tick(5000);
|
||||
await start("Back");
|
||||
expect(native).toHaveBeenCalledWith("storyteller_cancel_login_challenge_command", { challengeId: CHALLENGE_ID });
|
||||
await act(async () => { resolve({ status: "redeemed", maybe_user: USER }); });
|
||||
expect(success).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("img", { name: "Login QR" })).toBeNull();
|
||||
await tick(30_000);
|
||||
expect(calls("storyteller_poll_login_challenge_command")).toBe(1);
|
||||
|
||||
await start("Scan to Login");
|
||||
expect(screen.getByRole("img", { name: "Login QR" })).toBeTruthy();
|
||||
outcome = { status: "redeemed", maybe_user: USER };
|
||||
await tick(5000);
|
||||
expect(success).toHaveBeenCalledExactlyOnceWith(USER);
|
||||
});
|
||||
|
||||
it("fails closed on a future status", async () => {
|
||||
outcome = { status: "future_state", maybe_user: USER };
|
||||
render(<DesktopLoginBridge onSuccess={vi.fn()} />);
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { ArrowLeftIcon } from "lucide-react";
|
||||
import type { UserInfo } from "@storyteller/api";
|
||||
import { createDesktopLoginChallenge, pollDesktopLoginChallenge, cancelDesktopLoginChallenge, isDesktopLoginError, type DesktopLoginChallenge } from "./NativeLoginBridge";
|
||||
|
||||
const OpenUrl = (url: string) => invoke("plugin:opener|open_url", { url });
|
||||
|
||||
export function DesktopLoginBridge({ onSuccess, onStart }: { onSuccess: (user: UserInfo) => void; onStart?: () => void }) {
|
||||
export function DesktopLoginBridge({ onSuccess, onStart, onActiveChange }: { onSuccess: (user: UserInfo) => void; onStart?: () => void; onActiveChange?: (active: boolean) => void }) {
|
||||
const [challenge, setChallenge] = useState<DesktopLoginChallenge | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
@@ -14,6 +15,9 @@ export function DesktopLoginBridge({ onSuccess, onStart }: { onSuccess: (user: U
|
||||
const generation = useRef(0);
|
||||
const success = useRef(onSuccess);
|
||||
success.current = onSuccess;
|
||||
const isActive = busy || challenge !== null;
|
||||
|
||||
useEffect(() => { onActiveChange?.(isActive); }, [isActive, onActiveChange]);
|
||||
|
||||
useEffect(() => () => { generation.current += 1; }, []);
|
||||
useEffect(() => {
|
||||
@@ -25,6 +29,7 @@ export function DesktopLoginBridge({ onSuccess, onStart }: { onSuccess: (user: U
|
||||
useEffect(() => {
|
||||
if (!challenge) return;
|
||||
let active = true;
|
||||
const current = generation.current;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
let delay = Math.max(5, challenge.poll_interval_seconds) * 1000;
|
||||
const deadline = Date.parse(challenge.expires_at);
|
||||
@@ -33,11 +38,11 @@ export function DesktopLoginBridge({ onSuccess, onStart }: { onSuccess: (user: U
|
||||
setMessage(text);
|
||||
};
|
||||
const poll = async () => {
|
||||
if (!active) return;
|
||||
if (!active || current !== generation.current) return;
|
||||
if (Date.now() >= deadline) { finish("Login request expired. Start a new request."); return; }
|
||||
try {
|
||||
const result = await pollDesktopLoginChallenge(challenge.challenge_id);
|
||||
if (!active) return;
|
||||
if (!active || current !== generation.current) return;
|
||||
if (result.status === "redeemed") {
|
||||
if (!result.maybe_user) throw new Error("Native session verification did not return a user");
|
||||
active = false;
|
||||
@@ -57,7 +62,7 @@ export function DesktopLoginBridge({ onSuccess, onStart }: { onSuccess: (user: U
|
||||
delay = Math.max(5, challenge.poll_interval_seconds) * 1000;
|
||||
setMessage("Waiting for your confirmation on the website…");
|
||||
} catch (error) {
|
||||
if (!active) return;
|
||||
if (!active || current !== generation.current) return;
|
||||
if (isDesktopLoginError(error) && !error.retryable) {
|
||||
finish(error.message);
|
||||
return;
|
||||
@@ -99,17 +104,29 @@ export function DesktopLoginBridge({ onSuccess, onStart }: { onSuccess: (user: U
|
||||
}
|
||||
};
|
||||
|
||||
const back = () => {
|
||||
generation.current += 1;
|
||||
setChallenge(null);
|
||||
setBusy(false);
|
||||
setMessage("");
|
||||
};
|
||||
|
||||
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">
|
||||
{isActive && <div className="mb-4 text-left">
|
||||
<button type="button" className="inline-flex items-center gap-2 text-sm text-white/70 hover:text-white" onClick={back}>
|
||||
<ArrowLeftIcon size={16} aria-hidden="true" /> Back
|
||||
</button>
|
||||
</div>}
|
||||
{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>
|
||||
</> : busy ? <p className="text-sm text-white/70" role="status">Preparing login…</p> : <div className="flex flex-col gap-2">
|
||||
<button type="button" className="rounded-lg bg-white px-4 py-3 font-medium text-black" onClick={() => start(true)}>Login with Website</button>
|
||||
<button type="button" 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>}
|
||||
|
||||
@@ -54,6 +54,43 @@ describe("native login modal integration", () => {
|
||||
expect(screen.queryByRole("button", { name: "Login with Website" })).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["Login with Website", "Scan to Login"])("hides password login during %s and restores it with Back", async (label) => {
|
||||
const success = vi.fn();
|
||||
await act(async () => { render(<LoginModal isSignUp={false} onArtCraftAuthSuccess={success} />); });
|
||||
expect(screen.getByPlaceholderText("you@example.com or username")).toBeTruthy();
|
||||
await act(async () => { fireEvent.click(screen.getByRole("button", { name: label })); });
|
||||
expect(screen.getByRole("img", { name: "Login QR" })).toBeTruthy();
|
||||
expect(screen.queryByPlaceholderText("you@example.com or username")).toBeNull();
|
||||
expect(screen.queryByPlaceholderText("Min. 8 characters")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Sign up" })).toBeNull();
|
||||
|
||||
await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Back" })); });
|
||||
expect(screen.getByPlaceholderText("you@example.com or username")).toBeTruthy();
|
||||
expect(screen.getByPlaceholderText("Min. 8 characters")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Login with Website" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Scan to Login" })).toBeTruthy();
|
||||
expect(screen.queryByRole("img", { name: "Login QR" })).toBeNull();
|
||||
expect(native).toHaveBeenCalledWith("storyteller_cancel_login_challenge_command", { challengeId: "native_handle" });
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
|
||||
expect(native.mock.calls.filter(([command]) => command === "storyteller_poll_login_challenge_command")).toHaveLength(0);
|
||||
expect(success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hides password login while preparing a challenge and cancels a late creation after Back", async () => {
|
||||
let resolve: (value: unknown) => void = () => {};
|
||||
await act(async () => { render(<LoginModal isSignUp={false} />); });
|
||||
native.mockImplementationOnce(() => new Promise((done) => { resolve = done; }));
|
||||
await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Login with Website" })); });
|
||||
expect(screen.getByText("Preparing login…")).toBeTruthy();
|
||||
expect(screen.queryByPlaceholderText("Min. 8 characters")).toBeNull();
|
||||
await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Back" })); });
|
||||
await act(async () => { resolve({ challenge_id: "late_handle" }); });
|
||||
expect(screen.getByPlaceholderText("Min. 8 characters")).toBeTruthy();
|
||||
expect(screen.queryByRole("img", { name: "Login QR" })).toBeNull();
|
||||
expect(native).toHaveBeenCalledWith("storyteller_cancel_login_challenge_command", { challengeId: "late_handle" });
|
||||
expect(native.mock.calls.filter(([command]) => command === "plugin:opener|open_url")).toHaveLength(0);
|
||||
});
|
||||
|
||||
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; }));
|
||||
|
||||
@@ -39,6 +39,7 @@ export function LoginModal({
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [showDiscord, setShowDiscord] = useState(false);
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
const [isChallengeActive, setIsChallengeActive] = useState(false);
|
||||
|
||||
const authGeneration = useRef(0);
|
||||
const authSuccess = useRef(onArtCraftAuthSuccess);
|
||||
@@ -220,18 +221,18 @@ export function LoginModal({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!isSignUp && <DesktopLoginBridge onStart={() => { authGeneration.current += 1; }} onSuccess={(user) => {
|
||||
{!isSignUp && <DesktopLoginBridge onActiveChange={setIsChallengeActive} onStart={() => { authGeneration.current += 1; }} onSuccess={(user) => {
|
||||
authGeneration.current += 1;
|
||||
onArtCraftAuthSuccess?.(user);
|
||||
handleClose();
|
||||
}} />}
|
||||
<ArtCraftSignUp
|
||||
{!isChallengeActive && <ArtCraftSignUp
|
||||
onSubmit={handleAuthSubmit}
|
||||
isSignUp={isSignUp}
|
||||
onToggleMode={() => setIsSignUp((prev) => !prev)}
|
||||
errorMessage={errorMessage}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
/>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user