/** * Device Authorization Page * * Standalone page where users enter the code displayed by `emdash login` * to authorize a CLI or agent to access their account. * * Flow: * 1. User runs `emdash login` → sees a code like ABCD-1234 * 2. User opens this page in their browser (already logged in) * 3. User enters the code → clicks Authorize * 4. CLI receives tokens and saves them */ import { Button, Input } from "@cloudflare/kumo"; import { useQuery } from "@tanstack/react-query"; import * as React from "react"; import { apiFetch, API_BASE, parseApiResponse } from "../lib/api"; // ============================================================================ // Types // ============================================================================ interface UserInfo { id: string; email: string; name: string | null; role: number; } type PageState = "input" | "submitting" | "success" | "denied" | "error"; // ============================================================================ // Constants // ============================================================================ const ROLE_NAMES: Record = { 10: "Subscriber", 20: "Contributor", 30: "Author", 40: "Editor", 50: "Admin", }; const DEVICE_CODE_INVALID_CHARS_REGEX = /[^A-Z0-9-]/g; const DEVICE_CODE_HYPHEN_REGEX = /-/g; // ============================================================================ // Component // ============================================================================ export function DeviceAuthorizePage() { const [code, setCode] = React.useState(""); const [pageState, setPageState] = React.useState("input"); const [errorMessage, setErrorMessage] = React.useState(""); // Check if user is logged in const { data: user, isLoading, error: authError, } = useQuery({ queryKey: ["auth-me"], queryFn: async () => { const res = await apiFetch(`${API_BASE}/auth/me`); return parseApiResponse(res, "Not authenticated"); }, retry: false, }); // Pre-populate from URL query param (?code=ABCD-1234) React.useEffect(() => { const params = new URLSearchParams(window.location.search); const urlCode = params.get("code"); if (urlCode) { setCode(urlCode); } }, []); // Not authenticated — redirect to login React.useEffect(() => { if (!isLoading && (authError || !user)) { const returnUrl = encodeURIComponent(window.location.pathname + window.location.search); window.location.href = `/_emdash/admin/login?redirect=${returnUrl}`; } }, [isLoading, authError, user]); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); const trimmed = code.trim(); if (!trimmed) return; setPageState("submitting"); setErrorMessage(""); try { const res = await apiFetch(`${API_BASE}/oauth/device/authorize`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ user_code: trimmed, action: "approve" }), }); const data = await parseApiResponse<{ authorized: boolean }>(res, "Authorization failed"); setPageState(data.authorized ? "success" : "denied"); } catch (err) { setErrorMessage(err instanceof Error ? err.message : "Network error"); setPageState("error"); } } async function handleDeny(e: React.FormEvent) { e.preventDefault(); const trimmed = code.trim(); if (!trimmed) return; setPageState("submitting"); try { await apiFetch(`${API_BASE}/oauth/device/authorize`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ user_code: trimmed, action: "deny" }), }); setPageState("denied"); } catch { setPageState("denied"); } } // Format code as user types (insert hyphen after 4 chars) function handleCodeChange(e: React.ChangeEvent) { let value = e.target.value.toUpperCase().replace(DEVICE_CODE_INVALID_CHARS_REGEX, ""); // Auto-insert hyphen after 4 chars if not already present if (value.length === 4 && !value.includes("-")) { value = value + "-"; } // Limit to 9 chars (XXXX-XXXX) if (value.length > 9) { value = value.slice(0, 9); } setCode(value); } if (isLoading) { return (

Checking authentication...

); } if (!user) { return (

Redirecting to login...

); } return (
{/* Header */}

Authorize Device

Enter the code from your terminal

{/* Success state */} {pageState === "success" && (

Device authorized

You can close this page and return to your terminal.

Signed in as {user.email}

)} {/* Denied state */} {pageState === "denied" && (

Authorization denied

The device will not be granted access.

)} {/* Input / Error state */} {(pageState === "input" || pageState === "submitting" || pageState === "error") && (
{/* User badge */}
{(user.name || user.email).charAt(0).toUpperCase()}

{user.name || user.email}

{ROLE_NAMES[user.role] || "User"}

{/* Code input */} {/* Error message */} {pageState === "error" && errorMessage && (

{errorMessage}

)} {/* Actions */}

This will grant CLI access with your permissions.
Only authorize codes you recognize.

)}
); } // ============================================================================ // Layout wrapper // ============================================================================ function PageWrapper({ children }: { children: React.ReactNode }) { return (
{children}
); } // ============================================================================ // Icons (inline SVG to avoid dependency on icon library for this simple page) // ============================================================================ function TerminalIcon({ className }: { className?: string }) { return ( ); } function CheckIcon({ className }: { className?: string }) { return ( ); }