diff --git a/.env.example b/.env.example index 8ea7b35..9e110a6 100644 --- a/.env.example +++ b/.env.example @@ -10,8 +10,12 @@ PYMTHOUSE_ISSUER_URL=http://localhost:3001/api/v1/oidc PYMTHOUSE_PUBLIC_CLIENT_ID= PYMTHOUSE_M2M_CLIENT_ID= PYMTHOUSE_M2M_CLIENT_SECRET= -# Remote signer url base returned to CLI clients after API-key exchange. -# Local pymthouse compose: http://127.0.0.1:8080 or hosted signer + +# Initiate login URI for device flow (register on pymthouse app): +# http://localhost:3002/api/auth/initiate-login +# Remote signer url base returned to CLI clients after API-key exchange. PYMTHOUSE_SIGNER_URL=http://127.0.0.1:8080 + +# Local pymthouse compose: http://127.0.0.1:8080 # Set to 1 for local dev when issuer uses http://127.0.0.1 PYMTHOUSE_ALLOW_INSECURE_HTTP= diff --git a/app/(auth)/device-approved/page.tsx b/app/(auth)/device-approved/page.tsx new file mode 100644 index 0000000..58aec18 --- /dev/null +++ b/app/(auth)/device-approved/page.tsx @@ -0,0 +1,29 @@ +import Link from "next/link"; +import { LivepeerSymbol } from "@/components/design-system/LivepeerLogo"; + +export default function DeviceApprovedPage() { + return ( +
+
+
+ +
+

+ Device login approved +

+

+ You can return to your terminal. The python-gateway device flow should + finish automatically in a few seconds. +

+
+ + Open dashboard + +
+
+
+ ); +} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 2b1807c..7035388 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -1,21 +1,31 @@ "use client"; -import { useEffect } from "react"; -import { useRouter } from "next/navigation"; +import { Suspense, useEffect } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; import { useAuth } from "@/components/dashboard/AuthContext"; import LoginPage from "@/components/dashboard/LoginPage"; -export default function LoginRoute() { +function LoginRouteInner() { const { isConnected } = useAuth(); const router = useRouter(); + const searchParams = useSearchParams(); + const deviceFlow = searchParams.get("flow") === "device"; useEffect(() => { - if (isConnected) { + if (isConnected && !deviceFlow) { router.replace("/home"); } - }, [isConnected, router]); + }, [isConnected, deviceFlow, router]); - if (isConnected) return null; + if (isConnected && !deviceFlow) return null; return ; } + +export default function LoginRoute() { + return ( + + + + ); +} diff --git a/app/api/auth/device/complete/route.ts b/app/api/auth/device/complete/route.ts new file mode 100644 index 0000000..6ec3d80 --- /dev/null +++ b/app/api/auth/device/complete/route.ts @@ -0,0 +1,71 @@ +import { NextRequest, NextResponse } from "next/server"; +import { PmtHouseError } from "@pymthouse/builder-sdk"; +import { + clearDeviceFlowCookie, + completeDashboardDeviceApproval, + readDeviceFlowCookie, +} from "@/lib/dashboard/device-flow"; + +export const runtime = "nodejs"; + +export async function POST(request: NextRequest) { + const deviceFlow = await readDeviceFlowCookie(); + if (!deviceFlow) { + return NextResponse.json( + { error: "no_pending_device_flow" }, + { status: 400 }, + ); + } + + let body: { externalUserId?: string; email?: string; name?: string }; + try { + body = (await request.json()) as typeof body; + } catch { + return NextResponse.json({ error: "invalid_json" }, { status: 400 }); + } + + const email = body.email?.trim() ?? ""; + const externalUserId = body.externalUserId?.trim() || email; + if (!externalUserId) { + return NextResponse.json( + { error: "invalid_request", error_description: "email is required" }, + { status: 400 }, + ); + } + + try { + await completeDashboardDeviceApproval({ + userCode: deviceFlow.userCode, + publicClientId: deviceFlow.clientId, + externalUserId, + email: email || externalUserId, + }); + + await clearDeviceFlowCookie(); + + return NextResponse.json({ + success: true, + redirectTo: "/device-approved", + }); + } catch (error) { + console.error("Device approval completion failed", error); + if (error instanceof PmtHouseError) { + return NextResponse.json( + { + error: error.code, + error_description: error.message, + }, + { status: error.status }, + ); + } + + return NextResponse.json( + { + error: "device_approval_failed", + error_description: + error instanceof Error ? error.message : "Unknown device approval error", + }, + { status: 500 }, + ); + } +} diff --git a/app/api/auth/initiate-login/route.ts b/app/api/auth/initiate-login/route.ts new file mode 100644 index 0000000..6757aa7 --- /dev/null +++ b/app/api/auth/initiate-login/route.ts @@ -0,0 +1,58 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + extractDeviceApprovalFromTargetLink, + validateDeviceInitiateLogin, +} from "@pymthouse/builder-sdk/device-initiate"; +import { setDeviceFlowCookie } from "@/lib/dashboard/device-flow"; + +export const runtime = "nodejs"; + +export async function GET(request: NextRequest) { + const issuerUrl = process.env.PYMTHOUSE_ISSUER_URL?.trim(); + if (!issuerUrl) { + return NextResponse.json( + { + error: "server_misconfigured", + error_description: "PYMTHOUSE_ISSUER_URL is required", + }, + { status: 503 }, + ); + } + + const iss = request.nextUrl.searchParams.get("iss")?.trim() ?? ""; + const targetLinkUri = + request.nextUrl.searchParams.get("target_link_uri")?.trim() ?? ""; + + const validation = validateDeviceInitiateLogin({ + expectedIssuerUrl: issuerUrl, + iss, + targetLinkUri, + }); + if (!validation.ok) { + return NextResponse.json( + { error: "invalid_request", error_description: validation.reason }, + { status: 400 }, + ); + } + + const extracted = extractDeviceApprovalFromTargetLink(targetLinkUri, { + expectedIssuerUrl: issuerUrl, + }); + if ("error" in extracted) { + return NextResponse.json( + { error: "invalid_request", error_description: extracted.error }, + { status: 400 }, + ); + } + + await setDeviceFlowCookie({ + iss, + targetLinkUri, + userCode: extracted.userCode, + clientId: extracted.publicClientId, + }); + + const loginUrl = new URL("/login", request.url); + loginUrl.searchParams.set("flow", "device"); + return NextResponse.redirect(loginUrl); +} diff --git a/app/api/signer/device/exchange/route.ts b/app/api/signer/device/exchange/route.ts new file mode 100644 index 0000000..b5aa55c --- /dev/null +++ b/app/api/signer/device/exchange/route.ts @@ -0,0 +1,38 @@ +import { createDeviceExchangeHandler } from "@pymthouse/builder-sdk/signer/server"; + +function readDeviceExchangeConfig() { + const issuerUrl = process.env.PYMTHOUSE_ISSUER_URL?.trim(); + const m2mClientId = process.env.PYMTHOUSE_M2M_CLIENT_ID?.trim(); + const m2mClientSecret = process.env.PYMTHOUSE_M2M_CLIENT_SECRET?.trim(); + if (!issuerUrl || !m2mClientId || !m2mClientSecret) { + return null; + } + const signerUrl = + process.env.PYMTHOUSE_SIGNER_URL?.trim() || + process.env.SIGNER_PUBLIC_URL?.trim() || + undefined; + return { + issuerUrl, + m2mClientId, + m2mClientSecret, + allowInsecureHttp: process.env.PYMTHOUSE_ALLOW_INSECURE_HTTP === "1", + signerUrl, + }; +} + +export async function POST(request: Request) { + const config = readDeviceExchangeConfig(); + if (!config) { + return Response.json( + { + error: "server_misconfigured", + error_description: + "PYMTHOUSE_ISSUER_URL, PYMTHOUSE_M2M_CLIENT_ID, and PYMTHOUSE_M2M_CLIENT_SECRET are required", + }, + { status: 503 }, + ); + } + + const handler = createDeviceExchangeHandler(config); + return handler(request); +} diff --git a/components/dashboard/LoginPage.tsx b/components/dashboard/LoginPage.tsx index 3ddd7f6..0f4081d 100644 --- a/components/dashboard/LoginPage.tsx +++ b/components/dashboard/LoginPage.tsx @@ -1,10 +1,10 @@ "use client"; -import { useState } from "react"; +import { Suspense, useState, useEffect, useCallback } from "react"; import Link from "next/link"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { motion, AnimatePresence } from "framer-motion"; -import { useAuth, type AuthProvider } from "@/components/dashboard/AuthContext"; +import { useAuth, type AuthProvider, type MockUser } from "@/components/dashboard/AuthContext"; import { LivepeerWordmark } from "@/components/design-system/LivepeerLogo"; function getInitials(name: string): string { @@ -65,7 +65,7 @@ interface LoginPageProps { initialMode?: "signin" | "signup"; } -export default function LoginPage({ initialMode = "signin" }: LoginPageProps = {}) { +function LoginPageContent({ initialMode = "signin" }: LoginPageProps = {}) { // Mode is owned by the route, not by local state — sibling pages // `/login` and `/signup` re-mount this component // with the appropriate `initialMode`. The footer toggle is a `` @@ -75,19 +75,78 @@ export default function LoginPage({ initialMode = "signin" }: LoginPageProps = { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [name, setName] = useState(""); - const { connect } = useAuth(); + const { connect, isConnected, user } = useAuth(); const router = useRouter(); + const searchParams = useSearchParams(); + const deviceFlow = searchParams.get("flow") === "device"; + const [deviceError, setDeviceError] = useState(null); + const [deviceSubmitting, setDeviceSubmitting] = useState(false); + + const finishDeviceFlow = useCallback( + async (profile: MockUser) => { + setDeviceSubmitting(true); + setDeviceError(null); + try { + const res = await fetch("/api/auth/device/complete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + externalUserId: profile.email, + email: profile.email, + name: profile.name, + }), + }); + const json = (await res.json().catch(() => ({}))) as { + success?: boolean; + redirectTo?: string; + error_description?: string; + error?: string; + }; + if (!res.ok || !json.success) { + throw new Error( + json.error_description || + json.error || + "Could not complete device approval with Pymthouse.", + ); + } + router.push(json.redirectTo || "/device-approved"); + } catch (err) { + setDeviceError( + err instanceof Error ? err.message : "Device approval failed.", + ); + setDeviceSubmitting(false); + } + }, + [router], + ); + + useEffect(() => { + if (deviceFlow && isConnected && user && !deviceSubmitting) { + void finishDeviceFlow(user); + } + }, [deviceFlow, isConnected, user, deviceSubmitting, finishDeviceFlow]); + + async function afterAuth(profile: MockUser) { + if (deviceFlow) { + setDeviceSubmitting(true); + } + connect(profile); + if (deviceFlow) { + await finishDeviceFlow(profile); + return; + } + router.push("/home"); + } function handleEmailSubmit(e?: React.FormEvent) { e?.preventDefault(); const displayName = name || email.split("@")[0] || "Demo User"; - connect({ + void afterAuth({ name: displayName, email: email || "demo@livepeer.org", initials: getInitials(displayName), provider: "email", }); - router.push("/home"); } function handleOAuthSubmit(provider: AuthProvider) { @@ -97,13 +156,20 @@ export default function LoginPage({ initialMode = "signin" }: LoginPageProps = { google: { name: "Rick Staa", email: "rick.staa@gmail.com" }, }; const profile = mockProfiles[provider as "github" | "google"]; - connect({ + void afterAuth({ name: profile.name, email: profile.email, initials: getInitials(profile.name), provider, }); - router.push("/home"); + } + + if (deviceFlow && deviceSubmitting) { + return ( +
+

Approving device login…

+
+ ); } // Each surface is built from a tight 3-weight hierarchy: @@ -154,6 +220,16 @@ export default function LoginPage({ initialMode = "signin" }: LoginPageProps = { + {deviceFlow && ( +

+ Complete sign-in to approve your pending device login. +

+ )} + + {deviceError && ( +

{deviceError}

+ )} + {/* OAuth — restrained pills, equal hierarchy, no Popular badge clutter */}