first commit
This commit is contained in:
331
packages/admin/src/components/DeviceAuthorizePage.tsx
Normal file
331
packages/admin/src/components/DeviceAuthorizePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user