diff --git a/kiloclaw/src/routes/access-gateway.ts b/kiloclaw/src/routes/access-gateway.ts index 1ebc745d3..afb24e0b2 100644 --- a/kiloclaw/src/routes/access-gateway.ts +++ b/kiloclaw/src/routes/access-gateway.ts @@ -1,4 +1,4 @@ -import { Hono } from 'hono'; +import { type Context, Hono } from 'hono'; import { getCookie, setCookie } from 'hono/cookie'; import type { AppEnv } from '../types'; import { KILOCLAW_AUTH_COOKIE, KILOCLAW_AUTH_COOKIE_MAX_AGE } from '../config'; @@ -159,63 +159,40 @@ async function hasValidCookie( return result.success && result.userId === userId; } -accessGatewayRoutes.get('/kilo-access-gateway', async c => { - const userId = c.req.query('userId'); - if (!userId) { - return c.text('Missing userId parameter', 400); - } - - // If the user already has a valid cookie, derive the gateway token and redirect - const secret = c.env.NEXTAUTH_SECRET; - if (secret) { - const cookie = getCookie(c, KILOCLAW_AUTH_COOKIE); - if (await hasValidCookie(cookie, userId, secret, c.env.WORKER_ENV)) { - const redirectUrl = await buildRedirectUrl(userId, c.env.GATEWAY_TOKEN_SECRET); - return c.redirect(redirectUrl); - } - } - - return c.html(renderPage({ userId })); -}); - -accessGatewayRoutes.post('/kilo-access-gateway', async c => { - const body = await c.req.parseBody(); - const code = typeof body.code === 'string' ? body.code.trim().toUpperCase() : ''; - const userId = typeof body.userId === 'string' ? body.userId.trim() : ''; - - if (!code || !userId) { - return c.html(renderPage({ userId, error: 'Access code and user ID are required.' }), 400); - } - +/** + * Validate an access code, set the auth cookie, and return the redirect URL. + * Returns an error object if validation fails (caller should show an error). + */ +async function redeemCodeAndSetCookie( + c: Context, + code: string, + userId: string +): Promise<{ redirectUrl: string } | { error: string; status: 401 | 500 }> { const connectionString = c.env.HYPERDRIVE?.connectionString; if (!connectionString) { console.error('[access-gateway] HYPERDRIVE not configured'); - return c.html(renderPage({ userId, error: 'Server configuration error.' }), 500); + return { error: 'Server configuration error.', status: 500 }; } const secret = c.env.NEXTAUTH_SECRET; if (!secret) { console.error('[access-gateway] NEXTAUTH_SECRET not configured'); - return c.html(renderPage({ userId, error: 'Server configuration error.' }), 500); + return { error: 'Server configuration error.', status: 500 }; } const db = getWorkerDb(connectionString); const redeemedUserId = await validateAndRedeemAccessCode(db, code, userId); if (!redeemedUserId) { - return c.html( - renderPage({ - userId, - error: 'Invalid or expired access code. Please generate a new one from your dashboard.', - }), - 401 - ); + return { + error: 'Invalid or expired access code. Please generate a new one from your dashboard.', + status: 401, + }; } - // Look up the user's pepper so the JWT matches what authMiddleware expects const user = await findPepperByUserId(db, redeemedUserId); if (!user) { - return c.html(renderPage({ userId, error: 'User not found.' }), 401); + return { error: 'User not found.', status: 401 }; } const token = await signKiloToken({ @@ -234,7 +211,54 @@ accessGatewayRoutes.post('/kilo-access-gateway', async c => { }); const redirectUrl = await buildRedirectUrl(redeemedUserId, c.env.GATEWAY_TOKEN_SECRET); - return c.html(renderLoadingPage(redirectUrl)); + return { redirectUrl }; +} + +accessGatewayRoutes.get('/kilo-access-gateway', async c => { + const userId = c.req.query('userId'); + if (!userId) { + return c.text('Missing userId parameter', 400); + } + + // If the user already has a valid cookie, derive the gateway token and redirect + const secret = c.env.NEXTAUTH_SECRET; + if (secret) { + const cookie = getCookie(c, KILOCLAW_AUTH_COOKIE); + if (await hasValidCookie(cookie, userId, secret, c.env.WORKER_ENV)) { + const redirectUrl = await buildRedirectUrl(userId, c.env.GATEWAY_TOKEN_SECRET); + return c.redirect(redirectUrl); + } + } + + // If an auth_code is provided in the URL, validate it directly (auto-auth flow). + // This lets the dashboard embed the code in the Open link so users skip manual entry. + const authCode = c.req.query('auth_code')?.trim().toUpperCase(); + if (authCode) { + const result = await redeemCodeAndSetCookie(c, authCode, userId); + if ('redirectUrl' in result) { + return c.html(renderLoadingPage(result.redirectUrl)); + } + // Code was invalid/expired — fall through to the manual form with the error + return c.html(renderPage({ userId, error: result.error }), result.status); + } + + return c.html(renderPage({ userId })); +}); + +accessGatewayRoutes.post('/kilo-access-gateway', async c => { + const body = await c.req.parseBody(); + const code = typeof body.code === 'string' ? body.code.trim().toUpperCase() : ''; + const userId = typeof body.userId === 'string' ? body.userId.trim() : ''; + + if (!code || !userId) { + return c.html(renderPage({ userId, error: 'Access code and user ID are required.' }), 400); + } + + const result = await redeemCodeAndSetCookie(c, code, userId); + if ('redirectUrl' in result) { + return c.html(renderLoadingPage(result.redirectUrl)); + } + return c.html(renderPage({ userId, error: result.error }), result.status); }); export { accessGatewayRoutes }; diff --git a/src/app/(app)/claw/components/AccessCodeActions.tsx b/src/app/(app)/claw/components/AccessCodeActions.tsx deleted file mode 100644 index 9f4b6e652..000000000 --- a/src/app/(app)/claw/components/AccessCodeActions.tsx +++ /dev/null @@ -1,60 +0,0 @@ -'use client'; - -import { Check, Copy, ExternalLink, KeyRound } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; -import { useAccessCode } from '../hooks/useAccessCode'; - -const OPEN_BUTTON_ACCENT_CLASS = - 'animate-pulse-once bg-[oklch(95%_0.15_108)] text-black shadow-[0_0_20px_rgba(237,255,0,0.3)] ring-[oklch(95%_0.15_108)]/20 transition-all duration-500 ease-in-out hover:bg-[oklch(95%_0.15_108)]/90 hover:ring-[oklch(95%_0.15_108)]/40'; - -export function AccessCodeActions({ - canShow, - gatewayUrl, -}: { - canShow: boolean; - gatewayUrl: string; -}) { - const { accessCode, isGenerating, isCopied, generateAccessCode, copyAccessCode } = - useAccessCode(); - - if (!canShow) return null; - - return ( - <> - - - - - {accessCode && ( - -
-

One-time code (expires in 10 min)

-
- - {accessCode} - - -
-
-
- )} -
- - - ); -} diff --git a/src/app/(app)/claw/components/ClawHeader.tsx b/src/app/(app)/claw/components/ClawHeader.tsx index 008af18a0..d64e34b7c 100644 --- a/src/app/(app)/claw/components/ClawHeader.tsx +++ b/src/app/(app)/claw/components/ClawHeader.tsx @@ -2,7 +2,7 @@ import { Badge } from '@/components/ui/badge'; import KiloCrabIcon from '@/components/KiloCrabIcon'; -import { AccessCodeActions } from './AccessCodeActions'; +import { OpenClawButton } from './OpenClawButton'; import { CLAW_STATUS_BADGE, type ClawState } from './claw.types'; export function ClawHeader({ @@ -43,10 +43,7 @@ export function ClawHeader({
- +
); diff --git a/src/app/(app)/claw/components/OpenClawButton.tsx b/src/app/(app)/claw/components/OpenClawButton.tsx new file mode 100644 index 000000000..e1bb362e4 --- /dev/null +++ b/src/app/(app)/claw/components/OpenClawButton.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { useCallback, useState } from 'react'; +import { ExternalLink, Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui/button'; +import { useAccessCode } from '../hooks/useAccessCode'; + +const OPEN_BUTTON_ACCENT_CLASS = + 'animate-pulse-once bg-[oklch(95%_0.15_108)] text-black shadow-[0_0_20px_rgba(237,255,0,0.3)] ring-[oklch(95%_0.15_108)]/20 transition-all duration-500 ease-in-out hover:bg-[oklch(95%_0.15_108)]/90 hover:ring-[oklch(95%_0.15_108)]/40'; + +export function OpenClawButton({ canShow, gatewayUrl }: { canShow: boolean; gatewayUrl: string }) { + const { isGenerating, generateAccessCode } = useAccessCode(); + const [isOpening, setIsOpening] = useState(false); + + // Open the window synchronously (in the click handler's call stack) to avoid + // popup blockers, then navigate it once the access code arrives. + const openWithAutoAuth = useCallback(async () => { + setIsOpening(true); + const win = window.open('about:blank', '_blank'); + try { + const code = await generateAccessCode(); + if (code && win) { + const url = new URL(gatewayUrl, window.location.origin); + url.searchParams.set('auth_code', code); + win.location.href = url.toString(); + } else { + win?.close(); + } + } catch { + win?.close(); + toast.error('Failed to open KiloClaw — invalid gateway URL'); + } finally { + setIsOpening(false); + } + }, [gatewayUrl, generateAccessCode]); + + if (!canShow) return null; + + return ( + + ); +} diff --git a/src/app/(app)/claw/components/index.ts b/src/app/(app)/claw/components/index.ts index 3e6491542..a9872f18f 100644 --- a/src/app/(app)/claw/components/index.ts +++ b/src/app/(app)/claw/components/index.ts @@ -1,4 +1,4 @@ -export { AccessCodeActions } from './AccessCodeActions'; +export { OpenClawButton } from './OpenClawButton'; export { ClawDashboard } from './ClawDashboard'; export { ClawHeader } from './ClawHeader'; export { CreateInstanceCard } from './CreateInstanceCard'; diff --git a/src/app/(app)/claw/hooks/useAccessCode.ts b/src/app/(app)/claw/hooks/useAccessCode.ts index 080895014..8dfbe65e0 100644 --- a/src/app/(app)/claw/hooks/useAccessCode.ts +++ b/src/app/(app)/claw/hooks/useAccessCode.ts @@ -8,53 +8,29 @@ const AccessCodeResponse = z.object({ }); export function useAccessCode() { - const [accessCode, setAccessCode] = useState(null); const [isGenerating, setIsGenerating] = useState(false); - const [isCopied, setIsCopied] = useState(false); - const generateAccessCode = useCallback(async () => { + const generateAccessCode = useCallback(async (): Promise => { setIsGenerating(true); try { const res = await fetch('/api/kiloclaw/access-code', { method: 'POST' }); if (!res.ok) throw new Error('Failed to generate access code'); const data = AccessCodeResponse.parse(await res.json()); - setAccessCode(data.code); - setIsCopied(false); + return data.code; } catch (err) { const message = err instanceof z.ZodError ? 'Unexpected response from access code API' : 'Failed to generate access code'; toast.error(message); + return null; } finally { setIsGenerating(false); } }, []); - const copyAccessCode = useCallback(async () => { - if (!accessCode) return; - if (typeof navigator === 'undefined' || typeof navigator.clipboard?.writeText !== 'function') { - setIsCopied(false); - toast.error('Clipboard is not available in this environment'); - return; - } - - try { - await navigator.clipboard.writeText(accessCode); - setIsCopied(true); - toast.success('Access code copied'); - setTimeout(() => setIsCopied(false), 2000); - } catch { - setIsCopied(false); - toast.error('Failed to copy access code'); - } - }, [accessCode]); - return { - accessCode, isGenerating, - isCopied, generateAccessCode, - copyAccessCode, }; }