- Escape unescaped entities (react/no-unescaped-entities) in step-remote, step-welcome, projects-view, settings-panels - Add targeted eslint-disable-next-line for react-hooks/set-state-in-effect on established async-fetch and prop-sync patterns in useEffect bodies: chat-mode, file-content-viewer, files-view, step-dev-root, projects-view, settings-panels, update-banner, visualizer-view, carousel, use-mobile - Add targeted eslint-disable-next-line for react-hooks/purity on Date.now() display timestamps in streaming chat messages (chat-mode) - Remove now-unused eslint-disable directives (projects-view, settings-panels) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
425 lines
12 KiB
TypeScript
425 lines
12 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
ArrowRight,
|
||
CheckCircle2,
|
||
Eye,
|
||
EyeOff,
|
||
KeyRound,
|
||
LoaderCircle,
|
||
MessageSquare,
|
||
SkipForward,
|
||
} from "lucide-react";
|
||
import { motion } from "motion/react";
|
||
import { useCallback, useEffect, useState } from "react";
|
||
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { authFetch } from "@/lib/auth";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
// ─── Types ──────────────────────────────────────────────────────────
|
||
|
||
type RemoteChannel = "slack" | "discord" | "telegram";
|
||
|
||
interface RemoteQuestionsApiResponse {
|
||
config: {
|
||
channel: RemoteChannel;
|
||
channelId: string;
|
||
timeoutMinutes: number;
|
||
pollIntervalSeconds: number;
|
||
} | null;
|
||
envVarSet: boolean;
|
||
tokenSet: boolean;
|
||
envVarName: string | null;
|
||
status: string;
|
||
error?: string;
|
||
}
|
||
|
||
const CHANNEL_OPTIONS: {
|
||
value: RemoteChannel;
|
||
label: string;
|
||
description: string;
|
||
}[] = [
|
||
{
|
||
value: "slack",
|
||
label: "Slack",
|
||
description: "Get notified in a Slack channel",
|
||
},
|
||
{
|
||
value: "discord",
|
||
label: "Discord",
|
||
description: "Get notified in a Discord channel",
|
||
},
|
||
{
|
||
value: "telegram",
|
||
label: "Telegram",
|
||
description: "Get notified via Telegram bot",
|
||
},
|
||
];
|
||
|
||
const CHANNEL_ID_HINTS: Record<RemoteChannel, string> = {
|
||
slack: "Channel ID (e.g. C01ABCD2EFG)",
|
||
discord: "Channel ID (17–20 digit number)",
|
||
telegram: "Chat ID (numeric, may start with -)",
|
||
};
|
||
|
||
const CHANNEL_ID_PATTERNS: Record<RemoteChannel, RegExp> = {
|
||
slack: /^[A-Z0-9]{9,12}$/,
|
||
discord: /^\d{17,20}$/,
|
||
telegram: /^-?\d{5,20}$/,
|
||
};
|
||
|
||
const ENV_KEYS: Record<RemoteChannel, string> = {
|
||
slack: "SLACK_BOT_TOKEN",
|
||
discord: "DISCORD_BOT_TOKEN",
|
||
telegram: "TELEGRAM_BOT_TOKEN",
|
||
};
|
||
|
||
// ─── Component ──────────────────────────────────────────────────────
|
||
|
||
interface StepRemoteProps {
|
||
onBack: () => void;
|
||
onNext: () => void;
|
||
}
|
||
|
||
export function StepRemote({ onBack, onNext }: StepRemoteProps) {
|
||
const [channel, setChannel] = useState<RemoteChannel | null>(null);
|
||
const [channelId, setChannelId] = useState("");
|
||
const [saving, setSaving] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [success, setSuccess] = useState(false);
|
||
const [alreadyConfigured, setAlreadyConfigured] = useState(false);
|
||
const [loading, setLoading] = useState(true);
|
||
const [botToken, setBotToken] = useState("");
|
||
const [showToken, setShowToken] = useState(false);
|
||
const [savingToken, setSavingToken] = useState(false);
|
||
const [tokenSet, setTokenSet] = useState(false);
|
||
const [tokenSuccess, setTokenSuccess] = useState<string | null>(null);
|
||
|
||
// Check if already configured
|
||
useEffect(() => {
|
||
authFetch("/api/remote-questions", { cache: "no-store" })
|
||
.then((res) => res.json())
|
||
.then((data: RemoteQuestionsApiResponse) => {
|
||
if (data.tokenSet) setTokenSet(true);
|
||
if (data.status === "configured" && data.config) {
|
||
setAlreadyConfigured(true);
|
||
setChannel(data.config.channel);
|
||
setChannelId(data.config.channelId);
|
||
setSuccess(true);
|
||
}
|
||
})
|
||
.catch(() => {})
|
||
.finally(() => setLoading(false));
|
||
}, []);
|
||
|
||
const channelIdValid =
|
||
channel !== null &&
|
||
channelId.trim().length > 0 &&
|
||
CHANNEL_ID_PATTERNS[channel].test(channelId.trim());
|
||
|
||
const handleSave = useCallback(async () => {
|
||
if (!channel || !channelIdValid) return;
|
||
setSaving(true);
|
||
setError(null);
|
||
|
||
try {
|
||
const res = await authFetch("/api/remote-questions", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
channel,
|
||
channelId: channelId.trim(),
|
||
timeoutMinutes: 5,
|
||
pollIntervalSeconds: 5,
|
||
}),
|
||
});
|
||
const json = await res.json();
|
||
if (!res.ok) {
|
||
setError(json.error ?? `Save failed (${res.status})`);
|
||
return;
|
||
}
|
||
setSuccess(true);
|
||
setAlreadyConfigured(true);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Failed to save");
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}, [channel, channelId, channelIdValid]);
|
||
|
||
const handleSaveToken = useCallback(async () => {
|
||
if (!channel || !botToken.trim()) return;
|
||
setSavingToken(true);
|
||
setError(null);
|
||
setTokenSuccess(null);
|
||
try {
|
||
const res = await authFetch("/api/remote-questions", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ channel, token: botToken.trim() }),
|
||
});
|
||
const json = await res.json();
|
||
if (!res.ok) {
|
||
setError(json.error ?? `Token save failed (${res.status})`);
|
||
return;
|
||
}
|
||
setTokenSuccess(`Token saved (${json.masked})`);
|
||
setTokenSet(true);
|
||
setBotToken("");
|
||
setShowToken(false);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Failed to save token");
|
||
} finally {
|
||
setSavingToken(false);
|
||
}
|
||
}, [channel, botToken]);
|
||
|
||
return (
|
||
<div className="flex flex-col items-center">
|
||
{/* Icon */}
|
||
<motion.div
|
||
initial={{ opacity: 0, scale: 0.85 }}
|
||
animate={{ opacity: 1, scale: 1 }}
|
||
transition={{ type: "spring", duration: 0.5, bounce: 0 }}
|
||
className="mb-8"
|
||
>
|
||
<div className="flex h-14 w-14 items-center justify-center rounded-xl border border-border/50 bg-card/50">
|
||
<MessageSquare
|
||
className="h-7 w-7 text-foreground/80"
|
||
strokeWidth={1.5}
|
||
/>
|
||
</div>
|
||
</motion.div>
|
||
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 12 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ delay: 0.06, duration: 0.4 }}
|
||
className="text-center"
|
||
>
|
||
<h2 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
|
||
Remote notifications
|
||
</h2>
|
||
<p className="mt-2 max-w-sm text-sm leading-relaxed text-muted-foreground">
|
||
Get notified when SF needs your input. Connect a chat channel and the
|
||
agent pings you instead of waiting silently.
|
||
</p>
|
||
</motion.div>
|
||
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 16 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ delay: 0.12, duration: 0.45 }}
|
||
className="mt-8 w-full max-w-md space-y-5"
|
||
>
|
||
{/* Already configured banner */}
|
||
{success && (
|
||
<div className="flex items-center gap-3 rounded-xl border border-success/15 bg-success/[0.04] px-4 py-3 text-sm">
|
||
<CheckCircle2 className="h-4 w-4 shrink-0 text-success" />
|
||
<span className="text-muted-foreground">
|
||
{alreadyConfigured && !saving
|
||
? `Connected to ${channel ?? "channel"}`
|
||
: "Configuration saved"}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Channel picker */}
|
||
{!loading && (
|
||
<div className="space-y-2">
|
||
<div className="text-xs font-medium text-muted-foreground">
|
||
Channel
|
||
</div>
|
||
<div className="grid grid-cols-3 gap-2">
|
||
{CHANNEL_OPTIONS.map((opt) => (
|
||
<button
|
||
key={opt.value}
|
||
type="button"
|
||
onClick={() => {
|
||
setChannel(opt.value);
|
||
setError(null);
|
||
if (success && !alreadyConfigured) setSuccess(false);
|
||
}}
|
||
disabled={saving}
|
||
className={cn(
|
||
"rounded-xl border px-3 py-3 text-left transition-all duration-200",
|
||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||
"active:scale-[0.97]",
|
||
channel === opt.value
|
||
? "border-foreground/30 bg-foreground/[0.06]"
|
||
: "border-border/50 bg-card/50 hover:border-foreground/15 hover:bg-card/50",
|
||
)}
|
||
>
|
||
<div className="text-sm font-medium text-foreground">
|
||
{opt.label}
|
||
</div>
|
||
<div className="mt-0.5 text-[11px] text-muted-foreground">
|
||
{opt.description}
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Channel ID input */}
|
||
{channel && !loading && (
|
||
<div className="space-y-2">
|
||
<div className="text-xs font-medium text-muted-foreground">
|
||
Channel ID
|
||
</div>
|
||
<Input
|
||
value={channelId}
|
||
onChange={(e) => {
|
||
setChannelId(e.target.value);
|
||
if (error) setError(null);
|
||
}}
|
||
placeholder={CHANNEL_ID_HINTS[channel]}
|
||
disabled={saving}
|
||
className="font-mono text-sm"
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" && channelIdValid) {
|
||
void handleSave();
|
||
}
|
||
}}
|
||
/>
|
||
{channelId.trim().length > 0 &&
|
||
!CHANNEL_ID_PATTERNS[channel].test(channelId.trim()) && (
|
||
<p className="text-xs text-destructive/70">
|
||
Doesn't match the expected format for {channel}
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Bot token input */}
|
||
{channel && !loading && (
|
||
<div className="space-y-2">
|
||
<div className="text-xs font-medium text-muted-foreground">
|
||
Bot token
|
||
{tokenSet && (
|
||
<span className="ml-2 text-success">✓ configured</span>
|
||
)}
|
||
</div>
|
||
|
||
{tokenSuccess && (
|
||
<div className="flex items-center gap-2 rounded-xl border border-success/15 bg-success/[0.04] px-3 py-2 text-xs text-muted-foreground">
|
||
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-success" />
|
||
{tokenSuccess}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex gap-2">
|
||
<div className="relative flex-1">
|
||
<Input
|
||
type={showToken ? "text" : "password"}
|
||
value={botToken}
|
||
onChange={(e) => setBotToken(e.target.value)}
|
||
placeholder={`Paste your ${ENV_KEYS[channel]}`}
|
||
disabled={savingToken}
|
||
className="pr-9 font-mono text-sm"
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" && botToken.trim())
|
||
void handleSaveToken();
|
||
}}
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowToken((v) => !v)}
|
||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-muted-foreground transition-colors"
|
||
>
|
||
{showToken ? (
|
||
<EyeOff className="h-3.5 w-3.5" />
|
||
) : (
|
||
<Eye className="h-3.5 w-3.5" />
|
||
)}
|
||
</button>
|
||
</div>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() => void handleSaveToken()}
|
||
disabled={!botToken.trim() || savingToken}
|
||
className="gap-1.5 transition-transform active:scale-[0.96]"
|
||
>
|
||
{savingToken ? (
|
||
<LoaderCircle className="h-3.5 w-3.5 animate-spin" />
|
||
) : (
|
||
<KeyRound className="h-3.5 w-3.5" />
|
||
)}
|
||
Save
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Error */}
|
||
{error && (
|
||
<div className="rounded-xl border border-destructive/20 bg-destructive/[0.06] px-4 py-3 text-sm text-destructive">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{/* Save button */}
|
||
{channel && channelId.trim().length > 0 && !success && (
|
||
<Button
|
||
onClick={() => void handleSave()}
|
||
disabled={!channelIdValid || saving}
|
||
className="gap-2 transition-transform active:scale-[0.96]"
|
||
>
|
||
{saving ? (
|
||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||
) : (
|
||
<CheckCircle2 className="h-4 w-4" />
|
||
)}
|
||
Save & connect
|
||
</Button>
|
||
)}
|
||
|
||
{loading && (
|
||
<div className="flex items-center gap-2 py-4 text-xs text-muted-foreground">
|
||
<LoaderCircle className="h-3.5 w-3.5 animate-spin" />
|
||
Checking existing configuration…
|
||
</div>
|
||
)}
|
||
</motion.div>
|
||
|
||
{/* Navigation */}
|
||
<motion.div
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
transition={{ delay: 0.2, 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>
|
||
<div className="flex items-center gap-2">
|
||
{!success && (
|
||
<Button
|
||
variant="ghost"
|
||
onClick={onNext}
|
||
className="gap-1.5 text-muted-foreground transition-transform active:scale-[0.96]"
|
||
>
|
||
Skip
|
||
<SkipForward className="h-3.5 w-3.5" />
|
||
</Button>
|
||
)}
|
||
<Button
|
||
onClick={onNext}
|
||
className="group gap-2 transition-transform active:scale-[0.96]"
|
||
>
|
||
{success ? "Continue" : "Continue"}
|
||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
||
</Button>
|
||
</div>
|
||
</motion.div>
|
||
</div>
|
||
);
|
||
}
|