diff --git a/apps/reader/package.json b/apps/reader/package.json index 7281ea5..da5197f 100644 --- a/apps/reader/package.json +++ b/apps/reader/package.json @@ -22,6 +22,7 @@ }, "dependencies": { "@citation-js/plugin-csl": "0.8.2", + "@mdbase-dev/sdk": "file:../../vendor/mdbase-dev-sdk-0.0.0-9ab0f31d.tgz", "@mdbase-dev/ui": "0.1.0-beta.125", "@mdbase-reader/connect": "workspace:*", "@mdbase-reader/core": "workspace:*", diff --git a/apps/reader/src/ConnectReader.tsx b/apps/reader/src/ConnectReader.tsx index da85c5a..1613153 100644 --- a/apps/reader/src/ConnectReader.tsx +++ b/apps/reader/src/ConnectReader.tsx @@ -29,9 +29,11 @@ import { import { ConnectionRetry, SelectedAuthorizationAction } from "./ConnectionLayout.js"; import { readerErrorMessage } from "./errors.js"; import { ChevronRightIcon, CollectionIcon, PlusIcon } from "./icons.js"; +import { NativeSignIn } from "./NativeSignIn.js"; import { requestedAnnotation, requestedSourceId } from "./SourceDeepLink.js"; import { startReaderStartupTiming } from "./startup-timing.js"; +import type { NativeSignInBinding } from "./native-signin-ui.js"; import type { ReaderApp as ReaderAppComponent } from "./ReaderApp.js"; const loadReaderApp = (): Promise<{ readonly ReaderApp: typeof ReaderAppComponent }> => import("./ReaderApp.js"); @@ -54,7 +56,17 @@ async function startSession(): Promise : ; +} + +function LegacyConnectReader(): JSX.Element { const { reportError } = useFeedback(); const session = useSyncExternalStore(subscribe, snapshot, snapshot); const [error, setError] = useState(null); diff --git a/apps/reader/src/NativeSignIn.tsx b/apps/reader/src/NativeSignIn.tsx new file mode 100644 index 0000000..47a5441 --- /dev/null +++ b/apps/reader/src/NativeSignIn.tsx @@ -0,0 +1,165 @@ +import { ConnectLayout } from "@mdbase-dev/ui/screens"; +import { useEffect, useRef, useState, useSyncExternalStore, type JSX } from "react"; + +import { + SignInTabBlocked, + authorizeNativeSignIn, + signInCopy, + signInPresentation, + type createNativeSignIn, +} from "./native-signin-ui.js"; + +interface NativeSignInProps { + /** Actual SDK instance plus its UI tab reservation; caller owns the lifetime. */ + binding: ReturnType; + /** Close succeeded. Owner may show signed-out UI; original stores stay. */ + onSignedOut: () => void; +} + +/** Presentation/actions only: no polling, custody, grants or session engine. */ +export function NativeSignIn(props: NativeSignInProps): JSX.Element { + return ; +} + +function observeForeground( + session: NativeSignInProps["binding"]["session"], + onError: () => void, +): () => void { + const foreground = (): void => { + if (session.getSnapshot().status === "closed") { + return; + } + try { + session.setForeground(document.visibilityState === "visible"); + } catch { + onError(); + } + }; + document.addEventListener("visibilitychange", foreground); + if (session.getSnapshot().status !== "closed") { + try { + session.setForeground(document.visibilityState === "visible"); + } catch { + /* Retired owner. The next explicit action reports its failure. */ + } + } + return () => { + document.removeEventListener("visibilitychange", foreground); + }; +} + +function NativeSignInView({ binding, onSignedOut }: NativeSignInProps): JSX.Element { + const { session, portal } = binding; + const snapshot = useSyncExternalStore( + (listener) => session.subscribe(listener), + () => session.getSnapshot(), + () => session.getSnapshot(), + ); + const [problem, setProblem] = useState<"tab_blocked" | "interrupted" | null>(null); + const [working, setWorking] = useState(false); + const busy = useRef(false); + const mounted = useRef(false); + + useEffect(() => { + mounted.current = true; + const stop = observeForeground(session, () => { + if (mounted.current) { + setProblem("interrupted"); + } + }); + return () => { + mounted.current = false; + stop(); + // Borrowed SDK session: its actual owner handles unmount/HMR shutdown. + }; + }, [session]); + + const act = async (action: () => Promise): Promise => { + if (busy.current) { + return; + } + busy.current = true; + setWorking(true); + setProblem(null); + try { + await action(); + } catch (reason) { + if (mounted.current) { + setProblem(reason instanceof SignInTabBlocked ? "tab_blocked" : "interrupted"); + } + } finally { + if (mounted.current) { + busy.current = false; + setWorking(false); + } + } + }; + const view = signInPresentation(snapshot, "mdbase Reader", problem); + const signOut = async (): Promise => { + await session.close(); + portal.cancel(); + if (mounted.current) { + onSignedOut(); + } + }; + const authorize = (): ReturnType => + authorizeNativeSignIn(session, portal); + + return ( + +
+ {view.identity ?

{view.identity}

: null} +

+ {view.message} +

+ {view.unavailable ?

{signInCopy.unavailable}

: null} +
+ {view.waiting ? ( + + ) : !view.paired || view.error ? ( + + ) : null} + {view.paired ? ( + + ) : null} + {snapshot.selectedCollectionId ? ( + + ) : null} +
+
+
+ ); +} diff --git a/apps/reader/src/native-signin-ui.test.ts b/apps/reader/src/native-signin-ui.test.ts new file mode 100644 index 0000000..c86a733 --- /dev/null +++ b/apps/reader/src/native-signin-ui.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + openSignInPortal, + SignInTabBlocked, + signInCopy, + signInPresentation, + unavailableCollection, +} from "./native-signin-ui"; + +import type { AppWebSignInSnapshot } from "@mdbase-dev/sdk/app-host"; + +function snapshot(patch: Partial = {}): AppWebSignInSnapshot { + return { + status: "signed_out", + signIn: null, + collections: [], + selectedCollectionId: null, + problem: null, + timing: null, + ...patch, + }; +} +const paired: NonNullable = { + requestId: "request", + installationId: "installation", + deviceId: "device", + appId: "app", + kind: "app-runtime", + verificationUri: "https://mdbase.test/approve", + state: "paired", + accountId: "account", + accountEmail: "writer@example.test", + collectionIds: [], + createCollections: false, + requestedCreateCollections: false, +}; + +describe("native sign-in presentation, not an authentication engine", () => { + it("uses canonical signed-out wording", () => { + expect(signInPresentation(snapshot(), "TaskNotes Planner").message).toBe( + "Sign in with your mdbase account to sync TaskNotes Planner across your devices.", + ); + }); + it("renders SDK authorizing without introducing an app poller", () => { + const view = signInPresentation(snapshot({ status: "authorizing" }), "Planner"); + expect(view.message).toBe("Waiting for approval in the mdbase tab…"); + expect(view.waiting).toBe(true); + }); + it("shows email only from a paired SDK view", () => { + expect( + signInPresentation(snapshot({ status: "unselected", signIn: paired }), "Planner").message, + ).toBe("Signed in as writer@example.test"); + expect( + signInPresentation( + snapshot({ + status: "authorizing", + signIn: { ...paired, state: "account_selected" }, + }), + "Planner", + ).identity, + ).toBeNull(); + }); + it("does not invent an email or use account IDs as labels", () => { + const view = signInPresentation( + snapshot({ + status: "unselected", + signIn: { ...paired, accountEmail: null }, + }), + "Planner", + ); + expect(view.identity).toBeNull(); + expect(view.message).toBe(signInCopy.unavailable); + expect(view.message).not.toContain(paired.accountId); + }); + it("uses actual selected collection metadata for opening", () => { + const view = signInPresentation( + snapshot({ + status: "opening", + signIn: paired, + selectedCollectionId: "selected", + collections: [{ collectionId: "selected", displayName: "Research", role: "owner" }], + }), + "Planner", + ); + expect(view.message).toBe("Signed in. Opening Research…"); + }); + it("refuses to invent a collection name from an identifier", () => { + const view = signInPresentation( + snapshot({ status: "opening", selectedCollectionId: "private-id" }), + "Planner", + ); + expect(view.message).not.toContain("private-id"); + }); + it("does not mistake a generic refusal for the user declining", () => { + expect( + signInPresentation(snapshot({ status: "blocked", problem: "refused" }), "Planner").message, + ).toBe(signInCopy.interrupted); + }); + it("does not imply saved/sync or reminders capability from authorization", () => { + const view = signInPresentation(snapshot({ status: "unselected", signIn: paired }), "Planner"); + expect(JSON.stringify(view)).not.toMatch( + /saved on this device|reminders|Set up|Continue setup/, + ); + expect(view.unavailable).toBe(true); + }); + it("clears visible identity after SDK closure", () => { + const view = signInPresentation(snapshot({ status: "closed", signIn: paired }), "Planner"); + expect(view.paired).toBe(false); + expect(view.identity).toBeNull(); + }); + it("has a distinct blocked-tab action without rendering raw errors", () => { + const view = signInPresentation(snapshot(), "Planner", "tab_blocked"); + expect(view.message).toBe("Your browser blocked the sign-in tab."); + expect(view.action).toBe("Open sign-in"); + }); + it("opens a tab in the UI gesture, detaches the opener, and delegates only navigation", () => { + const tab = { opener: {}, location: { replace: vi.fn() }, close: vi.fn() }; + vi.stubGlobal("window", { open: vi.fn(() => tab) }); + try { + const portal = openSignInPortal(); + expect(window.open).toHaveBeenCalledWith("about:blank", "_blank"); + expect(tab.opener).toBeNull(); + portal.navigate("https://mdbase.test/approve"); + expect(tab.location.replace).toHaveBeenCalledWith("https://mdbase.test/approve"); + portal.close(); + expect(tab.close).toHaveBeenCalledOnce(); + } finally { + vi.unstubAllGlobals(); + } + }); + it("distinguishes an actually blocked tab", () => { + vi.stubGlobal("window", { open: () => null }); + try { + expect(openSignInPortal).toThrow(SignInTabBlocked); + } finally { + vi.unstubAllGlobals(); + } + }); + it("the unavailable collection callback never manufactures a connection", async () => { + // Only signal is observed; fake host/scope/collection data is not consumed. + await expect( + unavailableCollection({ + signal: new AbortController().signal, + } as Parameters[0]), + ).rejects.toThrow(signInCopy.unavailable); + }); + it("preserves original cancellation before the unavailable boundary", async () => { + const controller = new AbortController(); + const reason = new Error("original cancelled"); + controller.abort(reason); + await expect( + unavailableCollection({ signal: controller.signal } as Parameters< + typeof unavailableCollection + >[0]), + ).rejects.toBe(reason); + }); +}); diff --git a/apps/reader/src/native-signin-ui.ts b/apps/reader/src/native-signin-ui.ts new file mode 100644 index 0000000..a12882a --- /dev/null +++ b/apps/reader/src/native-signin-ui.ts @@ -0,0 +1,183 @@ +import { AppWebSignInSession } from "@mdbase-dev/sdk/app-host"; + +import type { + AppWebSignInSessionOptions, + AppWebSignInSnapshot, + AppWebSignInPortal, +} from "@mdbase-dev/sdk/app-host"; + +export type NativeSignInSession = Pick< + AppWebSignInSession, + | "getSnapshot" + | "subscribe" + | "start" + | "authorize" + | "renewExpiredPairing" + | "forget" + | "close" + | "setForeground" +>; +export const signInCopy = Object.freeze({ + signIn: "Sign in", + waiting: "Waiting for approval in the mdbase tab…", + reopen: "Open the mdbase tab again", + tabBlocked: "Your browser blocked the sign-in tab.", + openSignIn: "Open sign-in", + interrupted: "Sign-in was interrupted. Please try again.", + signOut: "Sign out", + unavailable: "Opening collections isn't available in this build yet.", +}); +export class SignInTabBlocked extends Error { + constructor() { + super(signInCopy.tabBlocked); + } +} + +/** UI portal only. Navigation URI comes from the SDK. */ +export function openSignInPortal(): AppWebSignInPortal { + const tab = window.open("about:blank", "_blank"); + if (!tab) { + throw new SignInTabBlocked(); + } + tab.opener = null; + return { + navigate(uri): void { + tab.location.replace(uri); + }, + close(): void { + tab.close(); + }, + }; +} +export interface SignInPortalUi { + reserve: () => void; + open: () => AppWebSignInPortal; + cancel: () => void; +} +export function createSignInPortalUi(): SignInPortalUi { + let reserved: AppWebSignInPortal | null = null; + return { + reserve(): void { + reserved ??= openSignInPortal(); + }, + open(): AppWebSignInPortal { + const portal = reserved ?? openSignInPortal(); + reserved = null; + return portal; + }, + cancel(): void { + reserved?.close(); + reserved = null; + }, + }; +} +// React identity only, never a native actor/record/mutation ID. +let nextViewKey = 0; +export interface NativeSignInBinding { + session: NativeSignInSession; + portal: SignInPortalUi; + viewKey: number; +} +/** Public SDK constructor, no collection/SQL/auth engine. */ +export function createNativeSignIn( + options: Omit, "openPortal" | "openCollection" | "autoSelect">, +): NativeSignInBinding { + options.signal.throwIfAborted(); + const portal = createSignInPortalUi(); + const session = new AppWebSignInSession({ + ...options, + openPortal: portal.open, + openCollection: unavailableCollection, + autoSelect: "never", + }); + return { session, portal, viewKey: ++nextViewKey }; +} +/** Reservation precedes every asynchronous SDK action. */ +export async function authorizeNativeSignIn( + session: NativeSignInSession, + portal: SignInPortalUi, +): Promise { + portal.reserve(); + try { + if (session.getSnapshot().status === "not_started") { + await session.start(); + } + if (session.getSnapshot().problem === "expired") { + await session.renewExpiredPairing(); + } + return await session.authorize(); + } catch (error) { + portal.cancel(); + throw error; + } +} +/** Never manufactures a connection or setup result; original cancellation wins. */ +export const unavailableCollection: AppWebSignInSessionOptions["openCollection"] = ({ + signal, +}): Promise => + Promise.resolve().then(() => { + signal.throwIfAborted(); + throw new Error(signInCopy.unavailable); + }); + +interface SignInPresentation { + message: string; + identity: string | null; + waiting: boolean; + paired: boolean; + unavailable: boolean; + error: boolean; + action: string; + disabled: boolean; +} +function messageFor( + snapshot: AppWebSignInSnapshot, + appName: string, + problem: "tab_blocked" | "interrupted" | null, + identity: string | null, + paired: boolean, +): string { + if (problem === "tab_blocked") { + return signInCopy.tabBlocked; + } + if (problem === "interrupted" || snapshot.status === "blocked") { + return signInCopy.interrupted; + } + if (snapshot.status === "authorizing") { + return signInCopy.waiting; + } + const selected = snapshot.collections.find( + ({ collectionId }) => collectionId === snapshot.selectedCollectionId, + ); + if (snapshot.status === "opening" && selected) { + return `Signed in. Opening ${selected.displayName}…`; + } + if (identity) { + return identity; + } + return paired + ? signInCopy.unavailable + : `Sign in with your mdbase account to sync ${appName} across your devices.`; +} +export function signInPresentation( + snapshot: AppWebSignInSnapshot, + appName: string, + problem: "tab_blocked" | "interrupted" | null = null, +): SignInPresentation { + const signIn = + snapshot.status !== "closed" && snapshot.signIn?.state === "paired" ? snapshot.signIn : null; + const email = signIn?.accountEmail; + const identity = email ? `Signed in as ${email}` : null; + const paired = signIn !== null; + const message = messageFor(snapshot, appName, problem, identity, paired); + return { + message, + identity: identity === message ? null : identity, + waiting: snapshot.status === "authorizing" && problem === null, + paired, + unavailable: paired && message !== signInCopy.unavailable, + error: problem !== null || snapshot.status === "blocked", + action: problem === "tab_blocked" ? signInCopy.openSignIn : signInCopy.signIn, + disabled: ["starting", "opening", "closed"].includes(snapshot.status), + }; +} diff --git a/docs/new-sdk.md b/docs/new-sdk.md index 6986713..fa88daa 100644 --- a/docs/new-sdk.md +++ b/docs/new-sdk.md @@ -74,3 +74,25 @@ The earlier SDK807 `5ba1314b` archive remains held and must not be reused. The corrected successor source/package pin was authorized after SDK808's actual merge and release qualification; it does not enable Reader's browser/session path. Journal integration remains last and local-only. + +## Public SDK sign-in UI + +The web app exposes an opt-in `ConnectReader.nativeSignIn` binding to the pinned +SDK's actual `AppWebSignInSession`. `createNativeSignIn(options)` requires explicit +owner-provided release, app identity, origins, runtime, locks and original signal; +it does not guess them or replace the default Connect/extension runtimes. + +Sign in reserves the tab in the user gesture before asynchronous SDK start or +expiry renewal. The SDK owns approval detection and protected state. The UI uses +canonical status/email, reopen-tab, popup-blocked and interrupted wording. It +shows only states the SDK can distinguish, not a fabricated decline or setup +result. Sign out waits for SDK close; it is local shutdown, not membership +revocation or protected-store deletion. Forget uses the actual selected SDK ID. +The owner closes its session on teardown and replaces it only after confirmed +close or explicit sign-in; retired views cannot publish into a replacement. + +The collection callback always refuses with **Opening collections isn't +available in this build yet.** It creates no collection client or setup engine. +Exact Reader native semantic pins await the shared native-digest producer; no +Markdown/hash/fixture substitute is used. UI/presentation tests are stand-ins, +not native authentication, Files, setup or LAB acceptance. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index acd0a79..3304546 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -133,6 +133,9 @@ importers: '@citation-js/plugin-csl': specifier: 0.8.2 version: 0.8.2(@citation-js/core@0.8.2) + '@mdbase-dev/sdk': + specifier: file:../../vendor/mdbase-dev-sdk-0.0.0-9ab0f31d.tgz + version: file:vendor/mdbase-dev-sdk-0.0.0-9ab0f31d.tgz '@mdbase-dev/ui': specifier: 0.1.0-beta.125 version: 0.1.0-beta.125(@codemirror/view@6.43.8)(react@19.2.8)