feat(31-03): add puter-proxy API client and auth/key entry components

- puterProxyApi: storeToken, getAuthUrl, claimGoogleTokens, storeApiKey
- PuterAuthButton: loads Puter CDN script, triggers signIn popup, captures token
- GoogleOAuthButton: 3-second risk warning gate, opens OAuth popup, captures stateId
- ApiKeyEntryForm: provider dropdown (OpenAI/Anthropic/Groq) + password input
This commit is contained in:
Nexus Dev 2026-04-03 00:39:55 +00:00
parent 521ebe2752
commit 3796de8493
4 changed files with 315 additions and 0 deletions

20
ui/src/api/puter-proxy.ts Normal file
View file

@ -0,0 +1,20 @@
// [nexus] API client for puter-proxy, oauth/google, and api-keys endpoints
import { api } from "./client";
export const puterProxyApi = {
/** Store Puter token server-side after company creation */
storeToken: (companyId: string, token: string) =>
api.post("/puter-proxy/token", { companyId, token }),
/** Get Google OAuth authorization URL and stateId — no companyId needed (pre-company) */
getAuthUrl: () =>
api.post<{ url: string; stateId: string }>("/oauth/google/authorize", {}),
/** Claim Google OAuth tokens for this company after company creation */
claimGoogleTokens: (stateId: string, companyId: string) =>
api.post("/oauth/google/claim", { stateId, companyId }),
/** Store an API key server-side after company creation */
storeApiKey: (companyId: string, provider: string, apiKey: string) =>
api.post("/api-keys/store", { companyId, provider, apiKey }),
};

View file

@ -0,0 +1,83 @@
// [nexus] API key entry form — provider dropdown + secret key input
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { CheckCircle } from "lucide-react";
interface ApiKeyEntryFormProps {
onSave: (provider: string, apiKey: string) => void;
onError: (msg: string) => void;
}
const PROVIDERS = [
{ value: "openai", label: "OpenAI" },
{ value: "anthropic", label: "Anthropic" },
{ value: "groq", label: "Groq" },
];
export function ApiKeyEntryForm({ onSave, onError }: ApiKeyEntryFormProps) {
const [provider, setProvider] = useState("openai");
const [apiKey, setApiKey] = useState("");
const [saved, setSaved] = useState(false);
function handleSave() {
if (!apiKey.trim()) {
onError("Could not save API key. Check the key is valid and try again.");
return;
}
onSave(provider, apiKey.trim());
setSaved(true);
}
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2">
<Label htmlFor="api-key-provider">Provider</Label>
<select
id="api-key-provider"
value={provider}
onChange={(e) => setProvider(e.target.value)}
disabled={saved}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{PROVIDERS.map((p) => (
<option key={p.value} value={p.value}>
{p.label}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="api-key-value">API key</Label>
<Input
id="api-key-value"
type="password"
placeholder="sk-..."
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
disabled={saved}
autoComplete="off"
className="font-mono text-sm"
/>
</div>
<Button
type="button"
onClick={handleSave}
disabled={saved || !apiKey.trim()}
className="w-full"
>
{saved ? (
<span className="flex items-center gap-2">
<CheckCircle className="h-4 w-4" />
API key saved
</span>
) : (
"Save API key"
)}
</Button>
</div>
);
}

View file

@ -0,0 +1,107 @@
// [nexus] Google OAuth button — 3-second risk warning gate, opens popup, captures stateId
import { useState, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import { CheckCircle } from "lucide-react";
import { puterProxyApi } from "@/api/puter-proxy";
interface GoogleOAuthButtonProps {
onSuccess: (stateId: string) => void;
onError: (msg: string) => void;
}
export function GoogleOAuthButton({ onSuccess, onError }: GoogleOAuthButtonProps) {
const [loading, setLoading] = useState(false);
const [warningTimer, setWarningTimer] = useState(false);
const [connected, setConnected] = useState(false);
// 3-second gate — button stays disabled until user has read the risk warning
useEffect(() => {
const id = setTimeout(() => setWarningTimer(true), 3000);
return () => clearTimeout(id);
}, []);
async function handleClick() {
setLoading(true);
try {
const { url, stateId } = await puterProxyApi.getAuthUrl();
const popup = window.open(url, "_blank", "popup,width=600,height=700");
if (!popup) {
throw new Error("Popup blocked");
}
// Poll for popup close — when closed, assume callback stored tokens server-side
await new Promise<void>((resolve) => {
const interval = setInterval(() => {
if (popup.closed) {
clearInterval(interval);
resolve();
}
}, 500);
});
onSuccess(stateId);
setConnected(true);
} catch {
onError("Google sign-in failed. Try again or use an API key instead.");
} finally {
setLoading(false);
}
}
return (
<div className="flex flex-col gap-3">
{/* Policy-risk warning — always visible when this component is rendered */}
<div role="alert" className="text-sm text-destructive bg-destructive/10 rounded-md px-3 py-2">
<p className="font-medium">Policy risk -- read before continuing</p>
<p className="mt-1">
Google has suspended accounts that used third-party apps with Gemini credentials. This may
affect your Gmail and Workspace access. Use a Google AI Studio API key instead if you want
to avoid this risk.
</p>
</div>
<Button
type="button"
variant="outline"
onClick={handleClick}
disabled={!warningTimer || loading || connected}
aria-busy={loading}
className="w-full"
>
{loading ? (
<span className="flex items-center gap-2">
<svg
className="h-4 w-4 animate-spin"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
Signing in...
</span>
) : connected ? (
<span className="flex items-center gap-2">
<CheckCircle className="h-4 w-4" />
Google connected
</span>
) : (
"Sign in with Google"
)}
</Button>
</div>
);
}

View file

@ -0,0 +1,105 @@
// [nexus] Puter auth button — loads CDN script, triggers signIn popup, captures token
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { CheckCircle, LogIn } from "lucide-react";
interface PuterAuthButtonProps {
onSuccess: (token: string) => void;
onError: (msg: string) => void;
}
function loadScript(): Promise<void> {
return new Promise((resolve, reject) => {
if ((window as any).puter) {
resolve();
return;
}
const script = document.createElement("script");
script.src = "https://js.puter.com/v2/";
script.onload = () => resolve();
script.onerror = () => reject(new Error("Failed to load Puter SDK"));
document.head.appendChild(script);
});
}
export function PuterAuthButton({ onSuccess, onError }: PuterAuthButtonProps) {
const [loading, setLoading] = useState(false);
const [connected, setConnected] = useState(false);
async function handleClick() {
setLoading(true);
try {
await loadScript();
// signIn must be called in the same async chain as the click event for popup to work
await (window as any).puter.auth.signIn();
// Extract token — try multiple access patterns (Pitfall 1: token location varies)
let token: string | undefined =
(window as any).puter?.authToken ??
(window as any).puter?.auth?.token;
if (!token) {
const user = await (window as any).puter?.auth?.getUser?.();
token = user?.token;
}
if (!token) {
console.warn("[nexus] Puter token is undefined after signIn — user may not be authenticated");
}
onSuccess(token ?? "");
setConnected(true);
} catch {
onError("Puter sign-in failed. Check your Puter.com account and try again.");
} finally {
setLoading(false);
}
}
return (
<Button
type="button"
onClick={handleClick}
disabled={loading || connected}
aria-busy={loading}
className="w-full"
>
{loading ? (
<span className="flex items-center gap-2">
<svg
className="h-4 w-4 animate-spin"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
Connecting to Puter...
</span>
) : connected ? (
<span className="flex items-center gap-2">
<CheckCircle className="h-4 w-4" />
Puter connected
</span>
) : (
<span className="flex items-center gap-2">
<LogIn className="h-4 w-4" />
Continue with Puter
</span>
)}
</Button>
);
}