singularity-forge/web/components/sf/onboarding/step-authenticate.tsx
2026-05-05 14:46:18 +02:00

604 lines
19 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import {
ArrowRight,
ArrowUpRight,
CheckCircle2,
ClipboardCopy,
ExternalLink,
KeyRound,
LoaderCircle,
RotateCcw,
ShieldAlert,
ShieldCheck,
XCircle,
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
import type {
WorkspaceOnboardingFlowState,
WorkspaceOnboardingProviderState,
WorkspaceOnboardingRequestState,
WorkspaceOnboardingState,
WorkspaceOnboardingValidationResult,
} from "@/lib/sf-workspace-store";
// ─── Error parsing ──────────────────────────────────────────────────
function parseValidationError(raw: string | null | undefined): {
title: string;
detail: string | null;
} {
if (!raw) return { title: "Validation failed", detail: null };
const jsonInStatusMatch = raw.match(/^\d{3}\s+[^:]+:\s*(.+)$/s);
const jsonCandidate = jsonInStatusMatch?.[1] ?? raw;
try {
const parsed = JSON.parse(jsonCandidate);
if (typeof parsed === "object" && parsed !== null) {
const message =
parsed.error_details?.message ??
parsed.error?.message ??
parsed.message ??
parsed.error ??
null;
if (typeof message === "string" && message.length > 0) {
if (/subscription.*(ended|expired|cancelled)/i.test(message))
return {
title: "Subscription expired",
detail:
message.replace(/\.$/, "") +
". Check your plan status with this provider.",
};
if (/rate.limit/i.test(message))
return {
title: "Rate limited",
detail: "Too many requests. Wait a moment and try again.",
};
if (/invalid.*key|invalid.*token|incorrect.*key/i.test(message))
return {
title: "Invalid credentials",
detail: "The API key was rejected. Double-check and try again.",
};
if (/quota|billing|payment/i.test(message))
return { title: "Billing issue", detail: message };
return { title: "Provider error", detail: message };
}
}
} catch {
/* not JSON */
}
if (/^401\b/i.test(raw))
return {
title: "Unauthorized",
detail: "The credentials were rejected. Double-check your API key.",
};
if (/^403\b/i.test(raw))
return {
title: "Access denied",
detail:
"Your account doesn't have access. Check your subscription or permissions.",
};
if (/^429\b/i.test(raw))
return {
title: "Rate limited",
detail: "Too many requests. Wait a moment and try again.",
};
if (/^5\d{2}\b/i.test(raw))
return {
title: "Server error",
detail: "The provider returned an error. Try again in a minute.",
};
return {
title: "Validation failed",
detail: raw.length > 200 ? raw.slice(0, 200) + "…" : raw,
};
}
/** Extract a device code from instructions/prompt text */
function extractDeviceCode(flow: WorkspaceOnboardingFlowState): string | null {
const sources = [flow.prompt?.message, flow.auth?.instructions].filter(
Boolean,
);
for (const src of sources) {
const match = src?.match(
/(?:code|Code)[:\s]+([A-Z0-9]{4}[-][A-Z0-9]{4})/i,
);
if (match) return match[1];
}
return null;
}
// ─── Component ──────────────────────────────────────────────────────
interface StepAuthenticateProps {
provider: WorkspaceOnboardingProviderState;
activeFlow: WorkspaceOnboardingFlowState | null;
lastValidation: WorkspaceOnboardingValidationResult | null;
requestState: WorkspaceOnboardingRequestState;
requestProviderId: string | null;
onSaveApiKey: (
providerId: string,
apiKey: string,
) => Promise<WorkspaceOnboardingState | null>;
onStartFlow: (providerId: string) => void;
onSubmitFlowInput: (flowId: string, input: string) => void;
onCancelFlow: (flowId: string) => void;
onBack: () => void;
onNext: () => void;
bridgeRefreshPhase: "idle" | "pending" | "succeeded" | "failed";
bridgeRefreshError: string | null;
}
export function StepAuthenticate({
provider,
activeFlow,
lastValidation,
requestState,
requestProviderId,
onSaveApiKey,
onStartFlow,
onSubmitFlowInput,
onCancelFlow,
onBack,
onNext,
bridgeRefreshPhase,
bridgeRefreshError,
}: StepAuthenticateProps) {
const [apiKey, setApiKey] = useState("");
const [flowInput, setFlowInput] = useState("");
const [copied, setCopied] = useState(false);
const isBusy = requestState !== "idle";
const isThisProviderBusy = requestProviderId === provider.id && isBusy;
const isValidated =
lastValidation?.status === "succeeded" &&
lastValidation.providerId === provider.id;
const isBridgeDone =
bridgeRefreshPhase === "succeeded" || bridgeRefreshPhase === "idle";
const canProceed = isValidated && isBridgeDone;
const validationFailed =
lastValidation?.status === "failed" &&
lastValidation.providerId === provider.id;
const parsedError = validationFailed
? parseValidationError(lastValidation.message)
: null;
const _isOAuthOnly = !provider.supports.apiKey && provider.supports.oauth;
const hasOAuth = provider.supports.oauth && provider.supports.oauthAvailable;
const hasApiKey = provider.supports.apiKey;
// Active flow state
const flowActive =
activeFlow && activeFlow.providerId === provider.id && !canProceed;
const flowFailed = flowActive && activeFlow.status === "failed";
const flowRunning =
flowActive &&
(activeFlow.status === "running" ||
activeFlow.status === "awaiting_browser_auth");
const flowWaiting = flowActive && activeFlow.status === "awaiting_input";
const deviceCode = flowActive ? extractDeviceCode(activeFlow) : null;
useEffect(() => {
if (lastValidation?.status !== "succeeded") return;
const t = window.setTimeout(() => setApiKey(""), 0);
return () => window.clearTimeout(t);
}, [lastValidation?.status]);
useEffect(() => {
const t = window.setTimeout(() => setFlowInput(""), 0);
return () => window.clearTimeout(t);
}, []);
useEffect(() => {
if (!copied) return;
const t = window.setTimeout(() => setCopied(false), 2000);
return () => window.clearTimeout(t);
}, [copied]);
const copyCode = (code: string) => {
navigator.clipboard
.writeText(code)
.then(() => setCopied(true))
.catch(() => {});
};
return (
<div className="flex flex-col items-center">
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="text-center"
>
<h2 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
Connect {provider.label}
</h2>
<p className="mt-2 max-w-sm text-sm leading-relaxed text-muted-foreground">
{canProceed
? "Authenticated and ready to go."
: hasApiKey && hasOAuth
? "Paste an API key or sign in through your browser."
: hasApiKey
? "Paste your API key to authenticate."
: "Sign in through your browser to authenticate."}
</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.08, duration: 0.45 }}
className="mt-8 w-full max-w-md space-y-4"
>
{/* ─── Success state ─── */}
<AnimatePresence>
{canProceed && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: "spring", duration: 0.4, bounce: 0 }}
className="flex flex-col items-center gap-3 rounded-xl border border-success/15 bg-success/[0.04] px-6 py-6 text-center"
>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-success/15">
<ShieldCheck className="h-5 w-5 text-success" />
</div>
<div className="text-sm font-medium text-foreground">
{provider.label} authenticated
</div>
</motion.div>
)}
</AnimatePresence>
{/* ─── Validation error ─── */}
{validationFailed && parsedError && (
<div className="flex items-start gap-3 rounded-xl border border-destructive/20 bg-destructive/[0.06] px-4 py-3 text-sm">
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
<div>
<div className="font-medium text-destructive">
{parsedError.title}
</div>
{parsedError.detail && (
<div className="mt-0.5 text-muted-foreground">
{parsedError.detail}
</div>
)}
</div>
</div>
)}
{/* ─── Bridge refresh ─── */}
{bridgeRefreshPhase === "pending" && (
<div className="space-y-2">
<div className="flex items-center gap-3 rounded-xl border border-foreground/10 bg-foreground/[0.03] px-4 py-3 text-sm text-foreground/80">
<LoaderCircle className="h-4 w-4 shrink-0 animate-spin" />
Connecting to provider
</div>
<Progress value={66} className="h-1" />
</div>
)}
{bridgeRefreshPhase === "failed" && bridgeRefreshError && (
<div className="flex items-start gap-3 rounded-xl border border-destructive/20 bg-destructive/[0.06] px-4 py-3 text-sm">
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
<div>
<div className="font-medium text-destructive">
Connection failed
</div>
<div className="mt-0.5 text-muted-foreground">
{bridgeRefreshError}
</div>
</div>
</div>
)}
{/* ─── API key form ─── */}
{hasApiKey && !canProceed && (
<div className="space-y-3 rounded-xl border border-border/50 bg-card/50 p-4">
<div className="text-sm font-medium text-foreground">API key</div>
<form
className="space-y-3"
onSubmit={async (e) => {
e.preventDefault();
if (!apiKey.trim()) return;
const next = await onSaveApiKey(provider.id, apiKey);
if (
next &&
!next.locked &&
(next.bridgeAuthRefresh.phase === "succeeded" ||
next.bridgeAuthRefresh.phase === "idle")
) {
onNext();
}
}}
>
<Input
data-testid="onboarding-api-key-input"
type="password"
autoComplete="off"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={`Paste your ${provider.label} API key`}
disabled={isBusy}
className="font-mono text-sm"
/>
<div className="flex items-center gap-2">
<Button
type="submit"
disabled={!apiKey.trim() || isBusy}
className="gap-2 transition-transform active:scale-[0.96]"
data-testid="onboarding-save-api-key"
>
{isThisProviderBusy && requestState === "saving_api_key" ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<KeyRound className="h-4 w-4" />
)}
Validate & save
</Button>
</div>
</form>
</div>
)}
{/* ─── OAuth section ─── */}
{hasOAuth && !canProceed && (
<div className="space-y-3">
{/* Divider between API key and OAuth */}
{hasApiKey && (
<div className="flex items-center gap-3 py-1">
<div className="h-px flex-1 bg-border/50" />
<span className="text-xs text-muted-foreground">or</span>
<div className="h-px flex-1 bg-border/50" />
</div>
)}
{/* ─── No active flow: show start button ─── */}
{!flowActive && (
<div className="rounded-xl border border-border/50 bg-card/50 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium text-foreground">
Browser sign-in
</div>
<p className="mt-0.5 text-xs text-muted-foreground">
Opens a new tab to authenticate with {provider.label}
</p>
</div>
<Button
variant="outline"
disabled={isBusy}
onClick={() => onStartFlow(provider.id)}
className="shrink-0 gap-2 transition-transform active:scale-[0.96]"
data-testid="onboarding-start-provider-flow"
>
{isThisProviderBusy &&
requestState === "starting_provider_flow" ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<ArrowUpRight className="h-4 w-4" />
)}
Sign in
</Button>
</div>
</div>
)}
{/* ─── Active flow: device code UX ─── */}
{flowActive && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="rounded-xl border border-border/50 bg-card/50 p-4 space-y-4"
data-testid="onboarding-active-flow"
>
{/* Device code — big and prominent */}
{deviceCode && (
<div className="flex flex-col items-center gap-3 py-2">
<div className="text-xs text-muted-foreground">
Enter this code on the sign-in page
</div>
<button
type="button"
onClick={() => copyCode(deviceCode)}
className="group flex items-center gap-3 rounded-lg border border-border bg-background/50 px-5 py-3 transition-colors hover:border-foreground/20 active:scale-[0.98]"
>
<span className="font-mono text-2xl font-bold tracking-[0.15em] text-foreground">
{deviceCode}
</span>
<span className="text-muted-foreground transition-colors group-hover:text-muted-foreground">
{copied ? (
<CheckCircle2 className="h-4 w-4 text-success" />
) : (
<ClipboardCopy className="h-4 w-4" />
)}
</span>
</button>
<div className="text-[11px] text-muted-foreground">
{copied ? "Copied!" : "Click to copy"}
</div>
</div>
)}
{/* Instructions text (when no device code extracted) */}
{!deviceCode && activeFlow.auth?.instructions && (
<p className="text-sm text-muted-foreground">
{activeFlow.auth.instructions}
</p>
)}
{/* Open sign-in page button */}
{activeFlow.auth?.url && (
<Button
asChild
className="w-full gap-2 transition-transform active:scale-[0.96]"
>
<a
href={activeFlow.auth.url}
target="_blank"
rel="noreferrer"
>
<ExternalLink className="h-4 w-4" />
Open sign-in page
</a>
</Button>
)}
{/* Status indicator */}
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-2 text-muted-foreground">
{flowRunning && (
<>
<LoaderCircle className="h-3 w-3 animate-spin" />
<span>Waiting for authentication</span>
</>
)}
{flowFailed && (
<>
<XCircle className="h-3 w-3 text-destructive" />
<span className="text-destructive">
Sign-in failed or timed out
</span>
</>
)}
{flowWaiting && !deviceCode && (
<>
<LoaderCircle className="h-3 w-3 animate-spin" />
<span>Waiting for input</span>
</>
)}
</div>
<div className="flex items-center gap-1">
{flowFailed && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onStartFlow(provider.id)}
disabled={isBusy}
className="h-7 gap-1.5 text-xs text-muted-foreground"
>
<RotateCcw className="h-3 w-3" />
Retry
</Button>
)}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onCancelFlow(activeFlow.flowId)}
disabled={isBusy}
className="h-7 text-xs text-muted-foreground"
>
Cancel
</Button>
</div>
</div>
{/* Generic prompt input (non-device-code) */}
{activeFlow.prompt && !deviceCode && (
<form
className="space-y-2 border-t border-border/50 pt-3"
onSubmit={(e) => {
e.preventDefault();
if (!activeFlow.prompt?.allowEmpty && !flowInput.trim())
return;
onSubmitFlowInput(activeFlow.flowId, flowInput);
}}
>
<div className="text-xs text-muted-foreground">
{activeFlow.prompt.message}
</div>
<div className="flex gap-2">
<Input
data-testid="onboarding-flow-input"
value={flowInput}
onChange={(e) => setFlowInput(e.target.value)}
placeholder={
activeFlow.prompt.placeholder || "Enter value"
}
disabled={isBusy}
className="text-sm"
/>
<Button
type="submit"
disabled={
isBusy ||
(!activeFlow.prompt.allowEmpty && !flowInput.trim())
}
className="shrink-0 transition-transform active:scale-[0.96]"
>
{requestState === "submitting_provider_flow_input" ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
"Submit"
)}
</Button>
</div>
</form>
)}
{/* Progress messages */}
{activeFlow.progress.length > 0 && (
<div className="space-y-1 border-t border-border/50 pt-3">
{activeFlow.progress.map((message, i) => (
<div
key={`${activeFlow.flowId}-${i}`}
className="text-xs text-muted-foreground"
>
{message}
</div>
))}
</div>
)}
</motion.div>
)}
</div>
)}
{/* OAuth unavailable */}
{provider.supports.oauth &&
!provider.supports.oauthAvailable &&
!hasApiKey && (
<div className="rounded-xl border border-border/50 bg-card/50 px-4 py-3.5 text-sm text-muted-foreground">
Browser sign-in is not available in this runtime. Go back and
choose a provider with API-key support.
</div>
)}
</motion.div>
{/* Navigation */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.15, duration: 0.3 }}
className="mt-8 flex w-full max-w-md items-center justify-between"
>
<Button
variant="ghost"
onClick={onBack}
className="text-muted-foreground transition-transform active:scale-[0.96]"
>
Back
</Button>
<Button
onClick={onNext}
disabled={!canProceed}
className="group gap-2 transition-transform active:scale-[0.96]"
data-testid="onboarding-auth-continue"
>
Configure another provider
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</Button>
</motion.div>
</div>
);
}