Spaces:
Runtime error
Runtime error
| "use client"; | |
| import { useState, useEffect } from "react"; | |
| import Modal from "./Modal"; | |
| import Button from "./Button"; | |
| import Input from "./Input"; | |
| type KiroAuthModalProps = { | |
| isOpen: boolean; | |
| providerId?: string; | |
| providerLabel?: string; | |
| onMethodSelect: (method: string, config?: Record<string, unknown>) => void; | |
| onClose: () => void; | |
| }; | |
| /** | |
| * Kiro Auth Method Selection Modal | |
| * Auto-detects token from AWS SSO cache or allows manual import | |
| */ | |
| export default function KiroAuthModal({ | |
| isOpen, | |
| providerId = "kiro", | |
| providerLabel = "Kiro", | |
| onMethodSelect, | |
| onClose, | |
| }: KiroAuthModalProps) { | |
| const [selectedMethod, setSelectedMethod] = useState(null); | |
| const [idcStartUrl, setIdcStartUrl] = useState(""); | |
| const [idcRegion, setIdcRegion] = useState("us-east-1"); | |
| const [refreshToken, setRefreshToken] = useState(""); | |
| const [error, setError] = useState(null); | |
| const [importing, setImporting] = useState(false); | |
| const [autoDetecting, setAutoDetecting] = useState(false); | |
| const [autoDetected, setAutoDetected] = useState(false); | |
| // IDC/organization credentials returned by auto-import when the SSO cache token | |
| // has a clientIdHash. Spread into the import POST body so the regional OIDC | |
| // endpoint is used for token refresh instead of the social path (#2059). | |
| const [idcCredentials, setIdcCredentials] = useState<Record<string, string> | null>(null); | |
| // Auto-detect token when import method is selected | |
| useEffect(() => { | |
| if (selectedMethod !== "import" || !isOpen) return; | |
| const autoDetect = async () => { | |
| setAutoDetecting(true); | |
| setError(null); | |
| setAutoDetected(false); | |
| setIdcCredentials(null); | |
| try { | |
| const res = await fetch( | |
| `/api/oauth/kiro/auto-import?targetProvider=${encodeURIComponent(providerId)}` | |
| ); | |
| const data = await res.json(); | |
| if (data.found) { | |
| setRefreshToken(data.refreshToken); | |
| setAutoDetected(true); | |
| // Store IDC/organization credentials if present in the auto-detect response | |
| if (data.clientId && data.clientSecret) { | |
| setIdcCredentials({ | |
| clientId: data.clientId, | |
| clientSecret: data.clientSecret, | |
| ...(data.region ? { region: data.region } : {}), | |
| ...(data.authMethod ? { authMethod: data.authMethod } : {}), | |
| ...(data.profileArn ? { profileArn: data.profileArn } : {}), | |
| }); | |
| } | |
| } else { | |
| setError(data.error || "Could not auto-detect token"); | |
| } | |
| } catch (err) { | |
| setError("Failed to auto-detect token"); | |
| } finally { | |
| setAutoDetecting(false); | |
| } | |
| }; | |
| autoDetect(); | |
| }, [providerId, selectedMethod, isOpen]); | |
| const handleMethodSelect = (method) => { | |
| setSelectedMethod(method); | |
| setError(null); | |
| }; | |
| const handleBack = () => { | |
| setSelectedMethod(null); | |
| setError(null); | |
| }; | |
| const handleImportToken = async () => { | |
| if (!refreshToken.trim()) { | |
| setError("Please enter a refresh token"); | |
| return; | |
| } | |
| setImporting(true); | |
| setError(null); | |
| try { | |
| const res = await fetch( | |
| `/api/oauth/kiro/import?targetProvider=${encodeURIComponent(providerId)}`, | |
| { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ | |
| refreshToken: refreshToken.trim(), | |
| ...(idcCredentials || {}), | |
| }), | |
| } | |
| ); | |
| const data = await res.json(); | |
| if (!res.ok) { | |
| throw new Error(data.error || "Import failed"); | |
| } | |
| // Success - close modal | |
| onClose(); | |
| } catch (err) { | |
| setError(err.message); | |
| } finally { | |
| setImporting(false); | |
| } | |
| }; | |
| const handleIdcContinue = () => { | |
| if (!idcStartUrl.trim()) { | |
| setError("Please enter your IDC start URL"); | |
| return; | |
| } | |
| onMethodSelect("idc", { startUrl: idcStartUrl.trim(), region: idcRegion }); | |
| }; | |
| const handleSocialLogin = (provider) => { | |
| onMethodSelect("social", { provider }); | |
| }; | |
| return ( | |
| <Modal isOpen={isOpen} title={`Connect ${providerLabel}`} onClose={onClose} size="lg"> | |
| <div className="flex flex-col gap-4"> | |
| {/* Method Selection */} | |
| {!selectedMethod && ( | |
| <div className="space-y-3"> | |
| <p className="text-sm text-text-muted mb-4">Choose your authentication method:</p> | |
| {/* AWS Builder ID */} | |
| <button | |
| onClick={() => onMethodSelect("builder-id")} | |
| className="w-full p-4 text-left border border-border rounded-lg hover:bg-sidebar transition-colors" | |
| > | |
| <div className="flex items-start gap-3"> | |
| <span className="material-symbols-outlined text-primary mt-0.5">shield</span> | |
| <div className="flex-1"> | |
| <h3 className="font-semibold mb-1">AWS Builder ID</h3> | |
| <p className="text-sm text-text-muted"> | |
| Recommended for most users. Sign in with the AWS account linked to{" "} | |
| {providerLabel}. | |
| </p> | |
| </div> | |
| </div> | |
| </button> | |
| {/* AWS IAM Identity Center (IDC) */} | |
| <button | |
| onClick={() => handleMethodSelect("idc")} | |
| className="w-full p-4 text-left border border-border rounded-lg hover:bg-sidebar transition-colors" | |
| > | |
| <div className="flex items-start gap-3"> | |
| <span className="material-symbols-outlined text-primary mt-0.5">business</span> | |
| <div className="flex-1"> | |
| <h3 className="font-semibold mb-1"> | |
| Your Organization (AWS IAM Identity Center) | |
| </h3> | |
| <p className="text-sm text-text-muted"> | |
| Use your company SSO start URL (example: https://your-org.awsapps.com/start). | |
| </p> | |
| </div> | |
| </div> | |
| </button> | |
| {/* Google Social Login */} | |
| <button | |
| onClick={() => handleSocialLogin("google")} | |
| className="w-full p-4 text-left border border-border rounded-lg hover:bg-sidebar transition-colors" | |
| > | |
| <div className="flex items-start gap-3"> | |
| <span className="material-symbols-outlined text-primary mt-0.5"> | |
| account_circle | |
| </span> | |
| <div className="flex-1"> | |
| <h3 className="font-semibold mb-1">Google Account</h3> | |
| <p className="text-sm text-text-muted">Login with your Google account.</p> | |
| </div> | |
| </div> | |
| </button> | |
| {/* GitHub Social Login */} | |
| <button | |
| onClick={() => handleSocialLogin("github")} | |
| className="w-full p-4 text-left border border-border rounded-lg hover:bg-sidebar transition-colors" | |
| > | |
| <div className="flex items-start gap-3"> | |
| <span className="material-symbols-outlined text-primary mt-0.5">code</span> | |
| <div className="flex-1"> | |
| <h3 className="font-semibold mb-1">GitHub Account</h3> | |
| <p className="text-sm text-text-muted">Login with your GitHub account.</p> | |
| </div> | |
| </div> | |
| </button> | |
| {/* Import Token */} | |
| <button | |
| onClick={() => handleMethodSelect("import")} | |
| className="w-full p-4 text-left border border-border rounded-lg hover:bg-sidebar transition-colors" | |
| > | |
| <div className="flex items-start gap-3"> | |
| <span className="material-symbols-outlined text-primary mt-0.5">file_upload</span> | |
| <div className="flex-1"> | |
| <h3 className="font-semibold mb-1">Import Token</h3> | |
| <p className="text-sm text-text-muted"> | |
| Paste a refresh token exported from {providerLabel}. | |
| </p> | |
| </div> | |
| </div> | |
| </button> | |
| </div> | |
| )} | |
| {/* IDC Configuration */} | |
| {selectedMethod === "idc" && ( | |
| <div className="space-y-4"> | |
| <div> | |
| <label className="block text-sm font-medium mb-2"> | |
| IDC Start URL <span className="text-red-500">*</span> | |
| </label> | |
| <Input | |
| value={idcStartUrl} | |
| onChange={(e) => setIdcStartUrl(e.target.value)} | |
| placeholder="https://your-org.awsapps.com/start" | |
| className="font-mono text-sm" | |
| /> | |
| <p className="text-xs text-text-muted mt-1"> | |
| Your organization's AWS IAM Identity Center URL | |
| </p> | |
| </div> | |
| <div> | |
| <label className="block text-sm font-medium mb-2">AWS Region</label> | |
| <Input | |
| value={idcRegion} | |
| onChange={(e) => setIdcRegion(e.target.value)} | |
| placeholder="us-east-1" | |
| className="font-mono text-sm" | |
| /> | |
| <p className="text-xs text-text-muted mt-1"> | |
| AWS region for your Identity Center (default: us-east-1) | |
| </p> | |
| </div> | |
| {error && <p className="text-sm text-red-600">{error}</p>} | |
| <div className="flex gap-2"> | |
| <Button onClick={handleIdcContinue} fullWidth> | |
| Continue | |
| </Button> | |
| <Button onClick={handleBack} variant="ghost" fullWidth> | |
| Back | |
| </Button> | |
| </div> | |
| </div> | |
| )} | |
| {/* Social Login Info (Google) */} | |
| {selectedMethod === "social-google" && ( | |
| <div className="space-y-4"> | |
| <div className="bg-amber-50 dark:bg-amber-900/20 p-4 rounded-lg border border-amber-200 dark:border-amber-800"> | |
| <div className="flex gap-2"> | |
| <span className="material-symbols-outlined text-amber-600 dark:text-amber-400"> | |
| info | |
| </span> | |
| <div className="flex-1 text-sm"> | |
| <p className="font-medium text-amber-900 dark:text-amber-100 mb-1"> | |
| Manual Callback Required | |
| </p> | |
| <p className="text-amber-800 dark:text-amber-200"> | |
| After login, you'll need to copy the callback URL from your browser and | |
| paste it back here. | |
| </p> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="flex gap-2"> | |
| <Button onClick={() => handleSocialLogin("google")} fullWidth> | |
| Continue with Google | |
| </Button> | |
| <Button onClick={handleBack} variant="ghost" fullWidth> | |
| Back | |
| </Button> | |
| </div> | |
| </div> | |
| )} | |
| {/* Social Login Info (GitHub) */} | |
| {selectedMethod === "social-github" && ( | |
| <div className="space-y-4"> | |
| <div className="bg-amber-50 dark:bg-amber-900/20 p-4 rounded-lg border border-amber-200 dark:border-amber-800"> | |
| <div className="flex gap-2"> | |
| <span className="material-symbols-outlined text-amber-600 dark:text-amber-400"> | |
| info | |
| </span> | |
| <div className="flex-1 text-sm"> | |
| <p className="font-medium text-amber-900 dark:text-amber-100 mb-1"> | |
| Manual Callback Required | |
| </p> | |
| <p className="text-amber-800 dark:text-amber-200"> | |
| After login, you'll need to copy the callback URL from your browser and | |
| paste it back here. | |
| </p> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="flex gap-2"> | |
| <Button onClick={() => handleSocialLogin("github")} fullWidth> | |
| Continue with GitHub | |
| </Button> | |
| <Button onClick={handleBack} variant="ghost" fullWidth> | |
| Back | |
| </Button> | |
| </div> | |
| </div> | |
| )} | |
| {/* Import Token */} | |
| {selectedMethod === "import" && ( | |
| <div className="space-y-4"> | |
| {/* Auto-detecting state */} | |
| {autoDetecting && ( | |
| <div className="text-center py-6"> | |
| <div className="size-16 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center"> | |
| <span className="material-symbols-outlined text-3xl text-primary animate-spin"> | |
| progress_activity | |
| </span> | |
| </div> | |
| <h3 className="text-lg font-semibold mb-2">Auto-detecting token...</h3> | |
| <p className="text-sm text-text-muted"> | |
| Reading {providerLabel} credentials from AWS SSO cache | |
| </p> | |
| </div> | |
| )} | |
| {/* Form (shown after auto-detect completes) */} | |
| {!autoDetecting && ( | |
| <> | |
| {/* Success message if auto-detected */} | |
| {autoDetected && ( | |
| <div className="bg-green-50 dark:bg-green-900/20 p-3 rounded-lg border border-green-200 dark:border-green-800"> | |
| <div className="flex gap-2"> | |
| <span className="material-symbols-outlined text-green-600 dark:text-green-400"> | |
| check_circle | |
| </span> | |
| <p className="text-sm text-green-800 dark:text-green-200"> | |
| Token auto-detected from {providerLabel} successfully! | |
| </p> | |
| </div> | |
| </div> | |
| )} | |
| {/* Info message if not auto-detected */} | |
| {!autoDetected && !error && ( | |
| <div className="bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg border border-blue-200 dark:border-blue-800"> | |
| <div className="flex gap-2"> | |
| <span className="material-symbols-outlined text-blue-600 dark:text-blue-400"> | |
| info | |
| </span> | |
| <p className="text-sm text-blue-800 dark:text-blue-200"> | |
| {providerLabel} token was not auto-detected. Please paste your refresh token | |
| manually. | |
| </p> | |
| </div> | |
| </div> | |
| )} | |
| <div> | |
| <label className="block text-sm font-medium mb-2"> | |
| Refresh Token <span className="text-red-500">*</span> | |
| </label> | |
| <Input | |
| value={refreshToken} | |
| onChange={(e) => setRefreshToken(e.target.value)} | |
| placeholder="Token will be auto-filled..." | |
| className="font-mono text-sm" | |
| /> | |
| </div> | |
| {error && ( | |
| <div className="bg-red-50 dark:bg-red-900/20 p-3 rounded-lg border border-red-200 dark:border-red-800"> | |
| <p className="text-sm text-red-600 dark:text-red-400">{error}</p> | |
| </div> | |
| )} | |
| <div className="flex gap-2"> | |
| <Button | |
| onClick={handleImportToken} | |
| fullWidth | |
| disabled={importing || !refreshToken.trim()} | |
| > | |
| {importing ? "Importing..." : "Import Token"} | |
| </Button> | |
| <Button onClick={handleBack} variant="ghost" fullWidth> | |
| Back | |
| </Button> | |
| </div> | |
| </> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| </Modal> | |
| ); | |
| } | |