From f6db1f7882d6a2eef0825e2da25e67950c9af52e Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Fri, 3 Apr 2026 00:39:55 +0000 Subject: [PATCH] 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 --- ui/src/api/puter-proxy.ts | 20 ++++ .../components/onboarding/ApiKeyEntryForm.tsx | 83 ++++++++++++++ .../onboarding/GoogleOAuthButton.tsx | 107 ++++++++++++++++++ .../components/onboarding/PuterAuthButton.tsx | 105 +++++++++++++++++ 4 files changed, 315 insertions(+) create mode 100644 ui/src/api/puter-proxy.ts create mode 100644 ui/src/components/onboarding/ApiKeyEntryForm.tsx create mode 100644 ui/src/components/onboarding/GoogleOAuthButton.tsx create mode 100644 ui/src/components/onboarding/PuterAuthButton.tsx diff --git a/ui/src/api/puter-proxy.ts b/ui/src/api/puter-proxy.ts new file mode 100644 index 00000000..91e78625 --- /dev/null +++ b/ui/src/api/puter-proxy.ts @@ -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 }), +}; diff --git a/ui/src/components/onboarding/ApiKeyEntryForm.tsx b/ui/src/components/onboarding/ApiKeyEntryForm.tsx new file mode 100644 index 00000000..b8b32b50 --- /dev/null +++ b/ui/src/components/onboarding/ApiKeyEntryForm.tsx @@ -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 ( +
+
+ + +
+ +
+ + setApiKey(e.target.value)} + disabled={saved} + autoComplete="off" + className="font-mono text-sm" + /> +
+ + +
+ ); +} diff --git a/ui/src/components/onboarding/GoogleOAuthButton.tsx b/ui/src/components/onboarding/GoogleOAuthButton.tsx new file mode 100644 index 00000000..ac965ae2 --- /dev/null +++ b/ui/src/components/onboarding/GoogleOAuthButton.tsx @@ -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((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 ( +
+ {/* Policy-risk warning — always visible when this component is rendered */} +
+

Policy risk -- read before continuing

+

+ 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. +

+
+ + +
+ ); +} diff --git a/ui/src/components/onboarding/PuterAuthButton.tsx b/ui/src/components/onboarding/PuterAuthButton.tsx new file mode 100644 index 00000000..8ce26324 --- /dev/null +++ b/ui/src/components/onboarding/PuterAuthButton.tsx @@ -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 { + 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 ( + + ); +}