mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-16 05:13:36 +00:00
feat: initial ui redesign from demo
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { useConnectionLog } from "./ConnectionLogContext.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Copy,
|
||||
Info,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface ConnectionLogProps {
|
||||
isConnecting: boolean;
|
||||
isConnected: boolean;
|
||||
hasConnectionError: boolean;
|
||||
position: "top" | "bottom";
|
||||
}
|
||||
|
||||
export function ConnectionLog({
|
||||
isConnecting,
|
||||
isConnected,
|
||||
hasConnectionError,
|
||||
position,
|
||||
}: ConnectionLogProps) {
|
||||
const { t } = useTranslation();
|
||||
const { logs, clearLogs, isExpanded, toggleExpanded, setIsExpanded } =
|
||||
useConnectionLog();
|
||||
const logContainerRef = useRef<HTMLDivElement>(null);
|
||||
const lastLogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasConnectionError) {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
}, [hasConnectionError, setIsExpanded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isConnected && !hasConnectionError && !isConnecting) {
|
||||
clearLogs();
|
||||
}
|
||||
}, [isConnected, hasConnectionError, isConnecting, clearLogs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && lastLogRef.current) {
|
||||
lastLogRef.current.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [logs, isExpanded]);
|
||||
|
||||
const shouldShow =
|
||||
isConnecting || hasConnectionError || (logs.length > 0 && !isConnected);
|
||||
|
||||
if (!shouldShow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const copyLogsToClipboard = async () => {
|
||||
const logsText = logs
|
||||
.map((log) => {
|
||||
const time = log.timestamp.toLocaleTimeString();
|
||||
return `[${time}] [${log.type.toUpperCase()}] ${log.message}`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(logsText);
|
||||
toast.success(t("terminal.connectionLogCopied"));
|
||||
} catch {
|
||||
toast.error(t("terminal.connectionLogCopyFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "info":
|
||||
return <Info className="h-4 w-4 text-blue-500" />;
|
||||
case "success":
|
||||
return <CheckCircle2 className="h-4 w-4 text-green-500" />;
|
||||
case "warning":
|
||||
return <AlertTriangle className="h-4 w-4 text-yellow-500" />;
|
||||
case "error":
|
||||
return <XCircle className="h-4 w-4 text-red-500" />;
|
||||
default:
|
||||
return <Info className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getTextColor = (type: string) => {
|
||||
switch (type) {
|
||||
case "info":
|
||||
return "text-blue-400";
|
||||
case "success":
|
||||
return "text-green-400";
|
||||
case "warning":
|
||||
return "text-yellow-400";
|
||||
case "error":
|
||||
return "text-red-400";
|
||||
default:
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
};
|
||||
|
||||
const borderClass =
|
||||
position === "bottom" && !isExpanded
|
||||
? "border-t-2 border-border"
|
||||
: "border-b-2 border-border";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`absolute inset-0 z-[110] flex flex-col ${isExpanded || hasConnectionError ? "pointer-events-auto" : "pointer-events-none"} ${position === "top" ? "justify-start" : "justify-end"}`}
|
||||
>
|
||||
{(isExpanded || hasConnectionError) && (
|
||||
<div className="absolute inset-0 bg-bg-base pointer-events-auto" />
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`relative z-10 bg-bg-subtle pointer-events-auto ${isExpanded ? "flex flex-col h-full" : ""} ${!isExpanded ? borderClass : ""}`}
|
||||
>
|
||||
<div className="flex items-center justify-between px-3 py-2 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={toggleExpanded}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
{t("terminal.connectionLogTitle")} ({logs.length})
|
||||
</span>
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{logs.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={copyLogsToClipboard}
|
||||
title={t("terminal.connectionLogCopy")}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
className="flex-1 h-0 overflow-y-auto overflow-x-hidden thin-scrollbar border-t-2 border-border bg-bg-base"
|
||||
>
|
||||
<div className="px-3 py-2">
|
||||
{logs.length === 0 ? (
|
||||
<div className="py-4 text-center text-sm text-muted-foreground">
|
||||
{isConnecting
|
||||
? t("terminal.connectionLogConnecting")
|
||||
: t("terminal.connectionLogEmpty")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 font-mono text-xs">
|
||||
{logs.map((log, index) => (
|
||||
<div
|
||||
key={log.id}
|
||||
ref={index === logs.length - 1 ? lastLogRef : null}
|
||||
className="flex items-start gap-2"
|
||||
>
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{log.timestamp.toLocaleTimeString()}
|
||||
</span>
|
||||
<div className="shrink-0">{getIcon(log.type)}</div>
|
||||
<span
|
||||
className={`flex-1 min-w-0 break-all whitespace-pre-wrap ${getTextColor(
|
||||
log.type,
|
||||
)}`}
|
||||
>
|
||||
{log.message}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import React, { createContext, useContext, useState, useCallback } from "react";
|
||||
import type { LogEntry } from "@/types/connection-log.ts";
|
||||
|
||||
interface ConnectionLogContextType {
|
||||
logs: LogEntry[];
|
||||
addLog: (entry: Omit<LogEntry, "id" | "timestamp">) => void;
|
||||
setLogs: (entries: Omit<LogEntry, "id" | "timestamp">[]) => void;
|
||||
clearLogs: () => void;
|
||||
isExpanded: boolean;
|
||||
toggleExpanded: () => void;
|
||||
setIsExpanded: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
const ConnectionLogContext = createContext<
|
||||
ConnectionLogContextType | undefined
|
||||
>(undefined);
|
||||
|
||||
export function ConnectionLogProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [logs, setLogsState] = useState<LogEntry[]>([]);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const addLog = useCallback((entry: Omit<LogEntry, "id" | "timestamp">) => {
|
||||
const newLog: LogEntry = {
|
||||
...entry,
|
||||
id: `${Date.now()}-${Math.random()}`,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setLogsState((prev) => [...prev, newLog]);
|
||||
|
||||
if (entry.type === "error" || entry.type === "warning") {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setLogs = useCallback(
|
||||
(entries: Omit<LogEntry, "id" | "timestamp">[]) => {
|
||||
const newLogs = entries.map((entry, index) => ({
|
||||
...entry,
|
||||
id: `${Date.now()}-${index}-${Math.random()}`,
|
||||
timestamp: new Date(),
|
||||
}));
|
||||
setLogsState(newLogs);
|
||||
|
||||
if (entries.some((e) => e.type === "error" || e.type === "warning")) {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const clearLogs = useCallback(() => {
|
||||
setLogsState([]);
|
||||
setIsExpanded(false);
|
||||
}, []);
|
||||
|
||||
const toggleExpanded = useCallback(() => {
|
||||
setIsExpanded((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ConnectionLogContext.Provider
|
||||
value={{
|
||||
logs,
|
||||
addLog,
|
||||
setLogs,
|
||||
clearLogs,
|
||||
isExpanded,
|
||||
toggleExpanded,
|
||||
setIsExpanded,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ConnectionLogContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useConnectionLog() {
|
||||
const context = useContext(ConnectionLogContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useConnectionLog must be used within ConnectionLogProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/card.tsx";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/alert.tsx";
|
||||
import { Shield, AlertTriangle, Copy, Check } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface HostKeyVerificationDialogProps {
|
||||
isOpen: boolean;
|
||||
scenario: "new" | "changed";
|
||||
ip: string;
|
||||
port: number;
|
||||
hostname?: string;
|
||||
fingerprint: string;
|
||||
oldFingerprint?: string;
|
||||
keyType: string;
|
||||
oldKeyType?: string;
|
||||
algorithm: string;
|
||||
onAccept: () => void;
|
||||
onReject: () => void;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
export function HostKeyVerificationDialog({
|
||||
isOpen,
|
||||
scenario,
|
||||
ip,
|
||||
port,
|
||||
hostname,
|
||||
fingerprint,
|
||||
oldFingerprint,
|
||||
algorithm,
|
||||
onAccept,
|
||||
onReject,
|
||||
backgroundColor,
|
||||
}: HostKeyVerificationDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [copiedFingerprint, setCopiedFingerprint] = useState(false);
|
||||
const [copiedOldFingerprint, setCopiedOldFingerprint] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const copyToClipboard = (text: string, isOld: boolean = false) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
if (isOld) {
|
||||
setCopiedOldFingerprint(true);
|
||||
setTimeout(() => setCopiedOldFingerprint(false), 2000);
|
||||
} else {
|
||||
setCopiedFingerprint(true);
|
||||
setTimeout(() => setCopiedFingerprint(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const formatFingerprint = (fp: string) => {
|
||||
return fp.match(/.{1,2}/g)?.join(":") || fp;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-500 animate-in fade-in duration-200">
|
||||
<div
|
||||
className="absolute inset-0 bg-canvas rounded-md"
|
||||
style={{ backgroundColor: backgroundColor || undefined }}
|
||||
/>
|
||||
<Card className="w-full max-w-2xl mx-4 border-2 relative z-10 animate-in fade-in zoom-in-95 duration-200">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
{scenario === "new" ? (
|
||||
<Shield className="w-5 h-5" />
|
||||
) : (
|
||||
<AlertTriangle className="w-5 h-5 text-destructive" />
|
||||
)}
|
||||
<CardTitle>
|
||||
{scenario === "new"
|
||||
? t("hostKey.verifyNewHost")
|
||||
: t("hostKey.keyChangedWarning")}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{hostname || ip}:{port}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{scenario === "new" ? (
|
||||
<>
|
||||
<Alert>
|
||||
<Shield className="h-4 w-4" />
|
||||
<AlertTitle>{t("hostKey.firstConnectionTitle")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("hostKey.firstConnectionDescription")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">
|
||||
{t("hostKey.fingerprint")} ({algorithm.toUpperCase()})
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 rounded-md bg-muted p-3 font-mono text-xs break-all">
|
||||
{formatFingerprint(fingerprint)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(fingerprint)}
|
||||
className="shrink-0"
|
||||
>
|
||||
{copiedFingerprint ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{t("hostKey.verifyInstructions")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>{t("hostKey.securityWarning")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("hostKey.keyChangedDescription")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">
|
||||
{t("hostKey.previousKey")}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 rounded-md bg-muted p-3 font-mono text-xs break-all">
|
||||
{formatFingerprint(oldFingerprint || "")}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
copyToClipboard(oldFingerprint || "", true)
|
||||
}
|
||||
className="shrink-0"
|
||||
>
|
||||
{copiedOldFingerprint ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">
|
||||
{t("hostKey.newFingerprint")}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 rounded-md bg-muted p-3 font-mono text-xs break-all">
|
||||
{formatFingerprint(fingerprint)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(fingerprint)}
|
||||
className="shrink-0"
|
||||
>
|
||||
{copiedFingerprint ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onReject}
|
||||
className="flex-1"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onAccept}
|
||||
variant={scenario === "changed" ? "destructive" : "default"}
|
||||
className="flex-1"
|
||||
>
|
||||
{scenario === "new"
|
||||
? t("hostKey.acceptAndContinue")
|
||||
: t("hostKey.acceptNewKey")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Shield, ExternalLink, Loader2, AlertCircle } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface OPKSSHDialogProps {
|
||||
isOpen: boolean;
|
||||
authUrl: string;
|
||||
requestId: string;
|
||||
stage: "chooser" | "waiting" | "authenticating" | "completed" | "error";
|
||||
error?: string;
|
||||
providers?: Array<{ alias: string; issuer: string }>;
|
||||
onCancel: () => void;
|
||||
onOpenUrl: () => void;
|
||||
onSelectProvider?: (alias: string) => void;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
export function OPKSSHDialog({
|
||||
isOpen,
|
||||
authUrl,
|
||||
stage,
|
||||
error,
|
||||
providers,
|
||||
onCancel,
|
||||
onOpenUrl,
|
||||
onSelectProvider,
|
||||
backgroundColor,
|
||||
}: OPKSSHDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-500 animate-in fade-in duration-200">
|
||||
<div
|
||||
className="absolute inset-0 bg-canvas rounded-md"
|
||||
style={{ backgroundColor: backgroundColor || undefined }}
|
||||
/>
|
||||
<div className="bg-elevated border-2 border-edge rounded-lg p-6 max-w-xl w-full mx-4 relative z-10 animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-primary" />
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("terminal.opksshAuthRequired")}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{stage === "chooser" && (
|
||||
<>
|
||||
<p className="text-muted-foreground">
|
||||
{t("terminal.opksshAuthDescription")}
|
||||
</p>
|
||||
{providers && providers.length > 0 && onSelectProvider ? (
|
||||
<div className="space-y-2">
|
||||
{providers.map((provider) => (
|
||||
<Button
|
||||
key={provider.alias}
|
||||
type="button"
|
||||
onClick={() => onSelectProvider(provider.alias)}
|
||||
className="w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
{t("terminal.opksshSignInWith", {
|
||||
provider:
|
||||
provider.alias.charAt(0).toUpperCase() +
|
||||
provider.alias.slice(1),
|
||||
})}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
) : authUrl ? (
|
||||
<div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onOpenUrl}
|
||||
className="flex-1 flex items-center justify-center gap-2"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
{t("terminal.opksshOpenBrowser")}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{(stage === "waiting" || stage === "authenticating") && (
|
||||
<div className="flex items-center gap-3 py-4">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-primary" />
|
||||
<p className="text-muted-foreground">
|
||||
{stage === "waiting"
|
||||
? t("terminal.opksshWaitingForAuth")
|
||||
: t("terminal.opksshAuthenticating")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stage === "error" && error && (
|
||||
<>
|
||||
<div className="flex items-start gap-3 p-4 bg-destructive/10 border border-destructive/20 rounded-md">
|
||||
<AlertCircle className="w-5 h-5 text-destructive flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
{t("common.error")}
|
||||
</p>
|
||||
<p className="text-sm text-destructive/90 mt-1 whitespace-pre-wrap break-words">
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { PasswordInput } from "@/components/password-input.tsx";
|
||||
import { Label } from "@/components/label.tsx";
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface PassphraseDialogProps {
|
||||
isOpen: boolean;
|
||||
onSubmit: (passphrase: string) => void;
|
||||
onCancel: () => void;
|
||||
hostInfo: { ip: string; port: number; username: string; name?: string };
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
export function PassphraseDialog({
|
||||
isOpen,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
hostInfo,
|
||||
backgroundColor,
|
||||
}: PassphraseDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const hostDisplay = hostInfo.name
|
||||
? `${hostInfo.name} (${hostInfo.username}@${hostInfo.ip}:${hostInfo.port})`
|
||||
: `${hostInfo.username}@${hostInfo.ip}:${hostInfo.port}`;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-500 animate-in fade-in duration-200">
|
||||
<div
|
||||
className="absolute inset-0 bg-canvas rounded-md"
|
||||
style={{ backgroundColor: backgroundColor || undefined }}
|
||||
/>
|
||||
<div className="bg-elevated border-2 border-edge rounded-lg p-6 max-w-md w-full mx-4 relative z-10 animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound className="w-5 h-5 text-primary" />
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("auth.passphraseRequired")}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">{hostDisplay}</p>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const input = e.currentTarget.elements.namedItem(
|
||||
"passphrase",
|
||||
) as HTMLInputElement;
|
||||
if (input && input.value) {
|
||||
onSubmit(input.value);
|
||||
}
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<Label htmlFor="passphrase">
|
||||
{t("auth.passphraseRequiredDescription")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="passphrase"
|
||||
name="passphrase"
|
||||
autoFocus
|
||||
placeholder={t("placeholders.keyPassword")}
|
||||
className="mt-1.5"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" className="flex-1">
|
||||
{t("common.connect")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
className="flex-1"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/card.tsx";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { PasswordInput } from "@/components/password-input.tsx";
|
||||
import { Label } from "@/components/label.tsx";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/alert.tsx";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/tabs.tsx";
|
||||
import { Shield, AlertCircle, Upload } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import { oneDark } from "@codemirror/theme-one-dark";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
|
||||
interface SSHAuthDialogProps {
|
||||
isOpen: boolean;
|
||||
reason: "no_keyboard" | "auth_failed" | "timeout";
|
||||
onSubmit: (credentials: {
|
||||
password?: string;
|
||||
sshKey?: string;
|
||||
keyPassword?: string;
|
||||
}) => void;
|
||||
onCancel: () => void;
|
||||
hostInfo: {
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
name?: string;
|
||||
};
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
export function SSHAuthDialog({
|
||||
isOpen,
|
||||
reason,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
hostInfo,
|
||||
backgroundColor = "var(--bg-base)",
|
||||
}: SSHAuthDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [authTab, setAuthTab] = useState<"password" | "key">("password");
|
||||
const [password, setPassword] = useState("");
|
||||
const [sshKey, setSshKey] = useState("");
|
||||
const [keyPassword, setKeyPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const getReasonMessage = () => {
|
||||
switch (reason) {
|
||||
case "no_keyboard":
|
||||
return t("auth.sshNoKeyboardInteractive");
|
||||
case "auth_failed":
|
||||
return t("auth.sshAuthenticationFailed");
|
||||
case "timeout":
|
||||
return t("auth.sshAuthenticationTimeout");
|
||||
default:
|
||||
return t("auth.sshAuthenticationRequired");
|
||||
}
|
||||
};
|
||||
|
||||
const getReasonDescription = () => {
|
||||
switch (reason) {
|
||||
case "no_keyboard":
|
||||
return t("auth.sshNoKeyboardInteractiveDescription");
|
||||
case "auth_failed":
|
||||
return t("auth.sshAuthFailedDescription");
|
||||
case "timeout":
|
||||
return t("auth.sshTimeoutDescription");
|
||||
default:
|
||||
return t("auth.sshProvideCredentialsDescription");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const credentials: {
|
||||
password?: string;
|
||||
sshKey?: string;
|
||||
keyPassword?: string;
|
||||
} = {};
|
||||
|
||||
if (authTab === "password") {
|
||||
if (password !== "") {
|
||||
credentials.password = password;
|
||||
}
|
||||
} else {
|
||||
if (sshKey.trim()) {
|
||||
credentials.sshKey = sshKey;
|
||||
if (keyPassword.trim()) {
|
||||
credentials.keyPassword = keyPassword;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onSubmit(credentials);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyFileUpload = async (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
try {
|
||||
const fileContent = await file.text();
|
||||
setSshKey(fileContent);
|
||||
} catch (error) {
|
||||
console.error("Failed to read SSH key file:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const canSubmit = () => {
|
||||
if (authTab === "password") {
|
||||
return password !== "";
|
||||
} else {
|
||||
return sshKey.trim() !== "";
|
||||
}
|
||||
};
|
||||
|
||||
const hostDisplay = hostInfo.name
|
||||
? `${hostInfo.name} (${hostInfo.username}@${hostInfo.ip}:${hostInfo.port})`
|
||||
: `${hostInfo.username}@${hostInfo.ip}:${hostInfo.port}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 z-9999 flex items-center justify-center bg-canvas animate-in fade-in duration-200"
|
||||
style={{ backgroundColor }}
|
||||
>
|
||||
<Card className="w-full max-w-2xl mx-4 border-2 animate-in fade-in zoom-in-95 duration-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5" />
|
||||
{t("auth.sshAuthenticationRequired")}
|
||||
</CardTitle>
|
||||
<CardDescription>{hostDisplay}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert variant={reason === "auth_failed" ? "destructive" : "default"}>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>{getReasonMessage()}</AlertTitle>
|
||||
<AlertDescription>{getReasonDescription()}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Tabs
|
||||
value={authTab}
|
||||
onValueChange={(v) => setAuthTab(v as "password" | "key")}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="password">
|
||||
{t("credentials.password")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="key">{t("credentials.sshKey")}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="password" className="space-y-4 mt-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ssh-password">
|
||||
{t("credentials.password")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="ssh-password"
|
||||
placeholder={t("placeholders.enterPassword")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("auth.sshPasswordDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="key" className="space-y-4 mt-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ssh-key">
|
||||
{t("credentials.sshPrivateKey")}
|
||||
</Label>
|
||||
<div className="mb-2">
|
||||
<div className="relative inline-block w-full">
|
||||
<input
|
||||
id="key-upload"
|
||||
type="file"
|
||||
accept="*,.pem,.key,.txt,.ppk"
|
||||
onChange={handleKeyFileUpload}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full justify-start text-left"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
<span className="truncate">
|
||||
{t("credentials.uploadPrivateKeyFile")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<CodeMirror
|
||||
value={sshKey}
|
||||
onChange={(value) => setSshKey(value)}
|
||||
placeholder={t("placeholders.pastePrivateKey")}
|
||||
theme={oneDark}
|
||||
className="border border-input rounded-md"
|
||||
minHeight="200px"
|
||||
maxHeight="300px"
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
foldGutter: false,
|
||||
dropCursor: false,
|
||||
allowMultipleSelections: false,
|
||||
highlightSelectionMatches: false,
|
||||
searchKeymap: false,
|
||||
scrollPastEnd: false,
|
||||
}}
|
||||
extensions={[
|
||||
EditorView.theme({
|
||||
".cm-scroller": {
|
||||
overflow: "auto",
|
||||
scrollbarWidth: "thin",
|
||||
scrollbarColor:
|
||||
"var(--scrollbar-thumb) var(--scrollbar-track)",
|
||||
},
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ssh-key-password">
|
||||
{t("credentials.keyPassword")} ({t("common.optional")})
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="ssh-key-password"
|
||||
placeholder={t("placeholders.keyPassword")}
|
||||
value={keyPassword}
|
||||
onChange={(e) => setKeyPassword(e.target.value)}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("auth.sshKeyPasswordDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
className="flex-1"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!canSubmit() || loading}
|
||||
className="flex-1"
|
||||
>
|
||||
{loading ? t("common.connecting") : t("common.connect")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Input } from "@/components/input.tsx";
|
||||
import { Label } from "@/components/label.tsx";
|
||||
import { Shield } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface TOTPDialogProps {
|
||||
isOpen: boolean;
|
||||
prompt: string;
|
||||
onSubmit: (code: string) => void;
|
||||
onCancel: () => void;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
export function TOTPDialog({
|
||||
isOpen,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
backgroundColor,
|
||||
}: TOTPDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-500 animate-in fade-in duration-200">
|
||||
<div
|
||||
className="absolute inset-0 bg-canvas rounded-md"
|
||||
style={{ backgroundColor: backgroundColor || undefined }}
|
||||
/>
|
||||
<div className="bg-elevated border-2 border-edge rounded-lg p-6 max-w-md w-full mx-4 relative z-10 animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-primary" />
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("terminal.totpRequired")}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const input = e.currentTarget.elements.namedItem(
|
||||
"totpCode",
|
||||
) as HTMLInputElement;
|
||||
if (input && input.value.trim()) {
|
||||
onSubmit(input.value.trim());
|
||||
}
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<Label htmlFor="totpCode">{t("terminal.totpCodeLabel")}</Label>
|
||||
<Input
|
||||
id="totpCode"
|
||||
name="totpCode"
|
||||
type="text"
|
||||
autoFocus
|
||||
maxLength={6}
|
||||
pattern="[0-9]*"
|
||||
inputMode="numeric"
|
||||
placeholder="000000"
|
||||
className="text-center text-lg tracking-widest mt-1.5"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" className="flex-1">
|
||||
{t("terminal.totpVerify")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
className="flex-1"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Terminal, Monitor, Users, Clock } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface TmuxSessionInfo {
|
||||
name: string;
|
||||
created: number;
|
||||
lastActivity: number;
|
||||
windows: number;
|
||||
attachedClients: number;
|
||||
}
|
||||
|
||||
interface TmuxSessionPickerProps {
|
||||
isOpen: boolean;
|
||||
sessions: TmuxSessionInfo[];
|
||||
onSelect: (sessionName: string) => void;
|
||||
onCreateNew: () => void;
|
||||
onCancel: () => void;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
function formatTimestamp(
|
||||
unix: number,
|
||||
t: (key: string, opts?: Record<string, unknown>) => string,
|
||||
): string {
|
||||
if (!unix) return "---";
|
||||
const date = new Date(unix * 1000);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
const diffDays = Math.floor(diffHr / 24);
|
||||
|
||||
if (diffMin < 1) return t("terminal.tmuxTimeJustNow");
|
||||
if (diffMin < 60) return t("terminal.tmuxTimeMinutes", { count: diffMin });
|
||||
if (diffHr < 24) return t("terminal.tmuxTimeHours", { count: diffHr });
|
||||
if (diffDays < 7) return t("terminal.tmuxTimeDays", { count: diffDays });
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
export function TmuxSessionPicker({
|
||||
isOpen,
|
||||
sessions,
|
||||
onSelect,
|
||||
onCreateNew,
|
||||
onCancel,
|
||||
backgroundColor,
|
||||
}: TmuxSessionPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-500 animate-in fade-in duration-200">
|
||||
<div
|
||||
className="absolute inset-0 bg-canvas rounded-md"
|
||||
style={{ backgroundColor: backgroundColor || undefined }}
|
||||
/>
|
||||
<div className="bg-elevated border-2 border-edge rounded-lg p-6 max-w-md w-full mx-4 relative z-10 animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="w-5 h-5 text-primary" />
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("terminal.tmuxSessionPickerTitle")}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("terminal.tmuxSessionPickerDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2 mb-4 max-h-60 overflow-y-auto">
|
||||
{sessions.map((session) => (
|
||||
<button
|
||||
key={session.name}
|
||||
onClick={() => onSelect(session.name)}
|
||||
className="w-full text-left px-3 py-3 rounded-md border border-edge hover:bg-muted transition-colors"
|
||||
>
|
||||
<div className="font-mono text-sm font-medium">
|
||||
{session.name}
|
||||
</div>
|
||||
<div className="flex gap-3 mt-1 text-xs text-muted-foreground">
|
||||
<span
|
||||
className="flex items-center gap-1"
|
||||
title={t("terminal.tmuxWindows")}
|
||||
>
|
||||
<Monitor className="w-3 h-3" />
|
||||
{t("terminal.tmuxWindowCount", { count: session.windows })}
|
||||
</span>
|
||||
{session.attachedClients > 0 && (
|
||||
<span
|
||||
className="flex items-center gap-1"
|
||||
title={t("terminal.tmuxAttached")}
|
||||
>
|
||||
<Users className="w-3 h-3" />
|
||||
{t("terminal.tmuxAttachedCount", {
|
||||
count: session.attachedClients,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="flex items-center gap-1"
|
||||
title={t("terminal.tmuxLastActivity")}
|
||||
>
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatTimestamp(session.lastActivity, t)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={onCreateNew} variant="outline" className="flex-1">
|
||||
{t("terminal.tmuxCreateNew")}
|
||||
</Button>
|
||||
<Button onClick={onCancel} variant="outline" className="flex-1">
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Input } from "@/components/input.tsx";
|
||||
import { Label } from "@/components/label.tsx";
|
||||
import { Shield, Copy, ExternalLink } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface WarpgateDialogProps {
|
||||
isOpen: boolean;
|
||||
url: string;
|
||||
securityKey: string;
|
||||
onContinue: () => void;
|
||||
onCancel: () => void;
|
||||
onOpenUrl: () => void;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
export function WarpgateDialog({
|
||||
isOpen,
|
||||
url,
|
||||
securityKey,
|
||||
onContinue,
|
||||
onCancel,
|
||||
onOpenUrl,
|
||||
backgroundColor,
|
||||
}: WarpgateDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleCopyUrl = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
toast.success(t("common.copied"));
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
toast.error(t("common.copyFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-500 animate-in fade-in duration-200">
|
||||
<div
|
||||
className="absolute inset-0 bg-canvas rounded-md"
|
||||
style={{ backgroundColor: backgroundColor || undefined }}
|
||||
/>
|
||||
<div className="bg-elevated border-2 border-edge rounded-lg p-6 max-w-xl w-full mx-4 relative z-10 animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-primary" />
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("terminal.warpgateAuthRequired")}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-base font-semibold mb-2 block">
|
||||
{t("terminal.warpgateSecurityKey")}
|
||||
</Label>
|
||||
<div className="bg-base border-2 border-accent rounded-md p-4 text-center">
|
||||
<div className="text-3xl font-mono font-bold tracking-wider text-primary">
|
||||
{securityKey}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="warpgateUrl" className="text-base font-semibold">
|
||||
{t("terminal.warpgateAuthUrl")}
|
||||
</Label>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Input
|
||||
id="warpgateUrl"
|
||||
type="text"
|
||||
value={url}
|
||||
readOnly
|
||||
className="flex-1 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCopyUrl}
|
||||
title={t("common.copy")}
|
||||
>
|
||||
<Copy className={`w-4 h-4 ${copied ? "text-success" : ""}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onOpenUrl}
|
||||
className="flex-1 flex items-center justify-center gap-2"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
{t("terminal.warpgateOpenBrowser")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onContinue}
|
||||
className="flex-1"
|
||||
>
|
||||
{t("terminal.warpgateContinue")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
className="sm:w-auto"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user