Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 65 additions & 41 deletions kiloclaw/src/routes/access-gateway.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<AppEnv>,
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({
Expand All @@ -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 };
60 changes: 0 additions & 60 deletions src/app/(app)/claw/components/AccessCodeActions.tsx

This file was deleted.

7 changes: 2 additions & 5 deletions src/app/(app)/claw/components/ClawHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -43,10 +43,7 @@ export function ClawHeader({
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<AccessCodeActions
canShow={status === 'running' && !!gatewayReady}
gatewayUrl={gatewayUrl}
/>
<OpenClawButton canShow={status === 'running' && !!gatewayReady} gatewayUrl={gatewayUrl} />
</div>
</header>
);
Expand Down
55 changes: 55 additions & 0 deletions src/app/(app)/claw/components/OpenClawButton.tsx
Original file line number Diff line number Diff line change
@@ -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();
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING]: One-time access code wasted when popup is blocked

If window.open returns null (popup blocked), generateAccessCode() is still called and the one-time code is consumed server-side. The code && win guard on line 23 prevents navigation but the code is already redeemed.

Consider checking win before calling generateAccessCode():

const win = window.open('about:blank', '_blank');
if (!win) {
  toast.error('Popup blocked — please allow popups for this site');
  setIsOpening(false);
  return;
}
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 (
<Button
variant="primary"
className={OPEN_BUTTON_ACCENT_CLASS}
disabled={isOpening || isGenerating}
onClick={openWithAutoAuth}
>
{isOpening ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<ExternalLink className="mr-2 h-4 w-4" />
)}
{isOpening ? 'Opening...' : 'Open'}
</Button>
);
}
2 changes: 1 addition & 1 deletion src/app/(app)/claw/components/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export { AccessCodeActions } from './AccessCodeActions';
export { OpenClawButton } from './OpenClawButton';
export { ClawDashboard } from './ClawDashboard';
export { ClawHeader } from './ClawHeader';
export { CreateInstanceCard } from './CreateInstanceCard';
Expand Down
30 changes: 3 additions & 27 deletions src/app/(app)/claw/hooks/useAccessCode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,53 +8,29 @@ const AccessCodeResponse = z.object({
});

export function useAccessCode() {
const [accessCode, setAccessCode] = useState<string | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
const [isCopied, setIsCopied] = useState(false);

const generateAccessCode = useCallback(async () => {
const generateAccessCode = useCallback(async (): Promise<string | null> => {
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,
};
}