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:
parent
521ebe2752
commit
3796de8493
4 changed files with 315 additions and 0 deletions
20
ui/src/api/puter-proxy.ts
Normal file
20
ui/src/api/puter-proxy.ts
Normal 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 }),
|
||||||
|
};
|
||||||
83
ui/src/components/onboarding/ApiKeyEntryForm.tsx
Normal file
83
ui/src/components/onboarding/ApiKeyEntryForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
ui/src/components/onboarding/GoogleOAuthButton.tsx
Normal file
107
ui/src/components/onboarding/GoogleOAuthButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
ui/src/components/onboarding/PuterAuthButton.tsx
Normal file
105
ui/src/components/onboarding/PuterAuthButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue