first commit

This commit is contained in:
Matt Kane
2026-04-01 10:44:22 +01:00
commit 43fcb9a131
1789 changed files with 395041 additions and 0 deletions

View File

@@ -0,0 +1,331 @@
/**
* 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<number, string> = {
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<PageState>("input");
const [errorMessage, setErrorMessage] = React.useState("");
// Check if user is logged in
const {
data: user,
isLoading,
error: authError,
} = useQuery<UserInfo>({
queryKey: ["auth-me"],
queryFn: async () => {
const res = await apiFetch(`${API_BASE}/auth/me`);
return parseApiResponse<UserInfo>(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<HTMLInputElement>) {
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 (
<PageWrapper>
<p className="text-kumo-subtle text-sm">Checking authentication...</p>
</PageWrapper>
);
}
if (!user) {
return (
<PageWrapper>
<p className="text-kumo-subtle text-sm">Redirecting to login...</p>
</PageWrapper>
);
}
return (
<PageWrapper>
<div className="w-full max-w-sm">
{/* Header */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-kumo-brand/10 mb-4">
<TerminalIcon className="w-6 h-6 text-kumo-brand" />
</div>
<h1 className="text-xl font-semibold tracking-tight">Authorize Device</h1>
<p className="text-kumo-subtle text-sm mt-1.5">Enter the code from your terminal</p>
</div>
{/* Success state */}
{pageState === "success" && (
<div className="rounded-lg border border-green-200 bg-green-50 dark:border-green-900 dark:bg-green-950/50 p-6 text-center">
<div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-green-100 dark:bg-green-900/50 mb-3">
<CheckIcon className="w-5 h-5 text-green-600 dark:text-green-400" />
</div>
<h2 className="font-medium text-green-900 dark:text-green-100">Device authorized</h2>
<p className="text-sm text-green-700 dark:text-green-300 mt-1">
You can close this page and return to your terminal.
</p>
<p className="text-xs text-kumo-subtle mt-3">Signed in as {user.email}</p>
</div>
)}
{/* Denied state */}
{pageState === "denied" && (
<div className="rounded-lg border border-kumo-line p-6 text-center">
<h2 className="font-medium">Authorization denied</h2>
<p className="text-sm text-kumo-subtle mt-1">The device will not be granted access.</p>
<Button
className="mt-4"
variant="outline"
onClick={() => {
setPageState("input");
setCode("");
}}
>
Try another code
</Button>
</div>
)}
{/* Input / Error state */}
{(pageState === "input" || pageState === "submitting" || pageState === "error") && (
<form onSubmit={handleSubmit}>
<div className="rounded-lg border border-kumo-line bg-kumo-base p-6">
{/* User badge */}
<div className="flex items-center gap-2 mb-5 pb-4 border-b border-kumo-line">
<div className="w-8 h-8 rounded-full bg-kumo-tint flex items-center justify-center text-xs font-medium">
{(user.name || user.email).charAt(0).toUpperCase()}
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{user.name || user.email}</p>
<p className="text-xs text-kumo-subtle">{ROLE_NAMES[user.role] || "User"}</p>
</div>
</div>
{/* Code input */}
<label className="block text-sm font-medium mb-2" htmlFor="user-code">
Device code
</label>
<Input
id="user-code"
type="text"
value={code}
onChange={handleCodeChange}
placeholder="XXXX-XXXX"
className="text-center text-lg font-mono tracking-widest"
autoFocus
autoComplete="off"
spellCheck={false}
disabled={pageState === "submitting"}
/>
{/* Error message */}
{pageState === "error" && errorMessage && (
<p className="text-sm text-kumo-danger mt-2">{errorMessage}</p>
)}
{/* Actions */}
<div className="flex gap-2 mt-4">
<Button
type="submit"
className="flex-1"
disabled={
code.replace(DEVICE_CODE_HYPHEN_REGEX, "").length < 8 ||
pageState === "submitting"
}
>
{pageState === "submitting" ? "Authorizing..." : "Authorize"}
</Button>
<Button
type="button"
variant="outline"
onClick={handleDeny}
disabled={
code.replace(DEVICE_CODE_HYPHEN_REGEX, "").length < 8 ||
pageState === "submitting"
}
>
Deny
</Button>
</div>
</div>
<p className="text-xs text-kumo-subtle text-center mt-4">
This will grant CLI access with your permissions.
<br />
Only authorize codes you recognize.
</p>
</form>
)}
</div>
</PageWrapper>
);
}
// ============================================================================
// Layout wrapper
// ============================================================================
function PageWrapper({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen flex items-center justify-center bg-kumo-base p-4">
<div className="w-full max-w-sm">{children}</div>
</div>
);
}
// ============================================================================
// Icons (inline SVG to avoid dependency on icon library for this simple page)
// ============================================================================
function TerminalIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="4 17 10 11 4 5" />
<line x1="12" y1="19" x2="20" y2="19" />
</svg>
);
}
function CheckIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
);
}