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
fe65166ecd
commit
f6db1f7882
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