/** * Full-screen QR sign-in dialog plus the request bridge that lets the command * bar open it. Commands run outside the React tree, so `requestDeviceSignInDialog` * hands the request to `DeviceSignInDialogHost`, which the shell mounts for the * life of the app and which owns the actual dialog. */ import { useEffect, useMemo, useRef, useState } from "react"; import type { AuthUser } from "../../../api-client"; import { t, tf } from "../../../i18n"; import { useAppLanguage } from "../../../i18n/react"; import { useShortcut, useViewport } from "../../../react/input"; import { colors } from "../../../theme/colors"; import { Box, Text, TextAttributes, useUiHost, useRendererHost } from "../../../ui"; import { Button } from "../../../components/ui/button"; import { renderAsciiText } from "../../../ui/ascii-font"; import { useDialog, useDialogKeyboard, type PromptContext } from "../../../ui/dialog"; import { renderQrLines, renderQrSvgDataUri } from "../../../ui/qr"; import { isPlainKey } from "../../../utils/keyboard"; import { DeviceSignInController, type DeviceSignInSnapshot } from "./device-signin"; // Phone cameras need dark-on-light no matter what the terminal theme is. const QR_FG = "#000000"; const QR_BG = "#ffffff"; export function deviceSignInStatus(snapshot: DeviceSignInSnapshot): { text: string; color: string } { switch (snapshot.phase) { case "approved": { const email = snapshot.user?.email?.trim() || snapshot.user?.username?.trim() || ""; return { text: tf("Approved as {email}", { email }), color: colors.positive }; } case "denied": return { text: t("Denied. Press enter to request a new code."), color: colors.negative }; case "error": return { text: t("Can't reach Gloomberb Cloud. Retrying..."), color: colors.warning }; case "waiting": return snapshot.error ? { text: t("Connection problem, retrying..."), color: colors.warning } : { text: t("Waiting for approval..."), color: colors.textDim }; default: return { text: t("Contacting Gloomberb Cloud..."), color: colors.textDim }; } } /** * QR code, human-readable code, verification URL, and live status. Shared by * the sign-in dialog and the onboarding account step. Degrades by height: the * QR needs its full module grid, so short terminals drop decoration first and * finally fall back to the code plus URL, which the mobile app also accepts. */ export function DeviceSignInPanel({ snapshot, height, shortcutScope = "device-signin:browser", }: { snapshot: DeviceSignInSnapshot; /** Rows available to this panel; drives the degradation tiers. */ height: number; /** * Scope for the browser key. A host that holds every key it does not use * (the sign-in gate) passes its own, so the key runs ahead of the hold. */ shortcutScope?: string; }) { useAppLanguage(); const renderer = useRendererHost(); const desktop = useUiHost().kind === "desktop-web"; // Cell lines still drive layout on desktop: they give the code its row/column // footprint, but the pixels come from the SVG below. const qrLines = useMemo( () => (snapshot.verificationUri ? renderQrLines(snapshot.verificationUri) : []), [snapshot.verificationUri], ); const qrImage = useMemo( () => (desktop && snapshot.verificationUri ? renderQrSvgDataUri(snapshot.verificationUri) : undefined), [desktop, snapshot.verificationUri], ); const status = deviceSignInStatus(snapshot); useShortcut((event) => { if (!isPlainKey(event, "b") || !snapshot.verificationUri) return; event.preventDefault(); event.stopPropagation(); void renderer.openExternal(snapshot.verificationUri).catch(() => {}); }, { scope: shortcutScope, phase: "before" }); // Reserve the browser button before fitting the QR, code, and status. const contentHeight = height - (snapshot.verificationUri ? 2 : 0); const showQr = qrLines.length > 0 && contentHeight >= qrLines.length + 2; const spacious = showQr && contentHeight >= qrLines.length + 8; const showUrl = !!snapshot.verificationUri && (!showQr || contentHeight >= qrLines.length + 4); return ( {snapshot.verificationUri &&