Add better loading spinner

This commit is contained in:
Owen
2026-06-03 17:41:56 -07:00
parent bc6fd0b399
commit e826d0dea6
2 changed files with 87 additions and 37 deletions

View File

@@ -3433,5 +3433,24 @@
"memberPortalNext": "Next", "memberPortalNext": "Next",
"httpSettings": "HTTP Settings", "httpSettings": "HTTP Settings",
"tcpSettings": "TCP Settings", "tcpSettings": "TCP Settings",
"udpSettings": "UDP Settings" "udpSettings": "UDP Settings",
"sshTitle": "SSH",
"sshConnectingDescription": "Establishing a secure connection…",
"sshConnecting": "Connecting…",
"sshInitializing": "Initializing…",
"sshSignInTitle": "Sign in to SSH",
"sshSignInDescription": "Enter your SSH credentials",
"sshPasswordTab": "Password",
"sshPrivateKeyTab": "Private Key",
"sshPrivateKeyField": "Private Key",
"sshPrivateKeyDisclaimer": "Your private key is not stored or visible to Pangolin. Alternatively, you can use short-lived certificates for seamless authentication using your existing Pangolin identity.",
"sshLearnMore": "Learn more",
"sshPrivateKeyFile": "Private Key File",
"sshAuthenticate": "Authenticate",
"sshTerminate": "Terminate",
"sshPoweredBy": "Powered by",
"sshErrorNoTarget": "No target specified",
"sshErrorWebSocket": "WebSocket connection failed",
"sshErrorAuthFailed": "Authentication failed",
"sshErrorConnectionClosed": "Connection closed before authentication completed"
} }

View File

@@ -15,9 +15,11 @@ import {
CardDescription CardDescription
} from "@app/components/ui/card"; } from "@app/components/ui/card";
import Link from "next/link"; import Link from "next/link";
import { ExternalLink } from "lucide-react"; import { ExternalLink, Loader2, AlertCircle } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import type { SignSshKeyResponse } from "@server/routers/ssh/types"; import type { SignSshKeyResponse } from "@server/routers/ssh/types";
import { useTranslations } from "next-intl";
type AuthTab = "password" | "privateKey"; type AuthTab = "password" | "privateKey";
@@ -57,6 +59,8 @@ export default function SshClient({
return { username: "", password: "", privateKey: "" }; return { username: "", password: "", privateKey: "" };
}); });
const t = useTranslations();
const [authTab, setAuthTab] = useState<AuthTab>("password"); const [authTab, setAuthTab] = useState<AuthTab>("password");
function handleKeyFile(e: React.ChangeEvent<HTMLInputElement>) { function handleKeyFile(e: React.ChangeEvent<HTMLInputElement>) {
@@ -184,7 +188,7 @@ export default function SshClient({
setConnecting(true); setConnecting(true);
if (!target) { if (!target) {
setConnectError("No target specified"); setConnectError(t("sshErrorNoTarget"));
setConnecting(false); setConnecting(false);
return; return;
} }
@@ -261,7 +265,7 @@ export default function SshClient({
authErrorShown = true; authErrorShown = true;
setConnecting(false); setConnecting(false);
setConnectError( setConnectError(
msg.error ?? "Authentication failed" msg.error ?? t("sshErrorAuthFailed")
); );
} else { } else {
xtermRef.current?.writeln( xtermRef.current?.writeln(
@@ -292,7 +296,7 @@ export default function SshClient({
ws.onerror = () => { ws.onerror = () => {
setConnecting(false); setConnecting(false);
setConnected(false); setConnected(false);
setConnectError("WebSocket connection failed"); setConnectError(t("sshErrorWebSocket"));
}; };
ws.onclose = (evt) => { ws.onclose = (evt) => {
@@ -306,9 +310,7 @@ export default function SshClient({
// If auth was never confirmed the login form is already visible; // If auth was never confirmed the login form is already visible;
// a generic error is shown only when no specific error was received. // a generic error is shown only when no specific error was received.
if (!authConfirmed && !authErrorShown) { if (!authConfirmed && !authErrorShown) {
setConnectError( setConnectError(t("sshErrorConnectionClosed"));
"Connection closed before authentication completed"
);
} }
}; };
} }
@@ -325,14 +327,38 @@ export default function SshClient({
return ( return (
<> <>
{!connected && ( {!connected && (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center">
<p className="text-muted-foreground"> <Card className="w-full max-w-md">
{connectError <CardHeader>
? connectError <CardTitle>{t("sshTitle")}</CardTitle>
: connecting <CardDescription>
? "Connecting…" {t("sshConnectingDescription")}
: "Initializing…"} </CardDescription>
</p> </CardHeader>
<CardContent className="flex flex-col items-center space-y-4">
{!connectError && (
<div className="flex items-center space-x-2">
<Loader2 className="h-5 w-5 animate-spin" />
<span>
{connecting
? t("sshConnecting")
: t("sshInitializing")}
</span>
</div>
)}
{connectError && (
<Alert
variant="destructive"
className="w-full"
>
<AlertCircle className="h-5 w-5" />
<AlertDescription>
{connectError}
</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
</div> </div>
)} )}
{connected && ( {connected && (
@@ -353,7 +379,7 @@ export default function SshClient({
<div> <div>
<div className="text-center mb-2"> <div className="text-center mb-2">
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
Powered by{" "} {t("sshPoweredBy")}{" "}
<Link <Link
href="https://pangolin.net/" href="https://pangolin.net/"
target="_blank" target="_blank"
@@ -366,7 +392,7 @@ export default function SshClient({
</div> </div>
<Card className="w-full"> <Card className="w-full">
<CardHeader> <CardHeader>
<CardTitle>SSH</CardTitle> <CardTitle>{t("sshTitle")}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-destructive text-sm">{error}</p> <p className="text-destructive text-sm">{error}</p>
@@ -382,7 +408,7 @@ export default function SshClient({
<div> <div>
<div className="text-center mb-2"> <div className="text-center mb-2">
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
Powered by{" "} {t("sshPoweredBy")}{" "}
<Link <Link
href="https://pangolin.net/" href="https://pangolin.net/"
target="_blank" target="_blank"
@@ -395,9 +421,9 @@ export default function SshClient({
</div> </div>
<Card className="w-full"> <Card className="w-full">
<CardHeader> <CardHeader>
<CardTitle>Sign in to SSH</CardTitle> <CardTitle>{t("sshSignInTitle")}</CardTitle>
<CardDescription> <CardDescription>
Enter credentials to access xxxx {t("sshSignInDescription")}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -417,8 +443,8 @@ export default function SshClient({
)} )}
> >
{tab === "password" {tab === "password"
? "Password" ? t("sshPasswordTab")
: "Private Key"} : t("sshPrivateKeyTab")}
</button> </button>
) )
)} )}
@@ -426,7 +452,10 @@ export default function SshClient({
{authTab === "password" && ( {authTab === "password" && (
<div className="space-y-4"> <div className="space-y-4">
<Field label="Username" id="username-pw"> <Field
label={t("username")}
id="username-pw"
>
<Input <Input
id="username-pw" id="username-pw"
value={form.username} value={form.username}
@@ -439,7 +468,7 @@ export default function SshClient({
placeholder="root" placeholder="root"
/> />
</Field> </Field>
<Field label="Password" id="password"> <Field label={t("password")} id="password">
<Input <Input
id="password" id="password"
type="password" type="password"
@@ -458,22 +487,21 @@ export default function SshClient({
{authTab === "privateKey" && ( {authTab === "privateKey" && (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Your private key is not stored or {t("sshPrivateKeyDisclaimer")}{" "}
visible to Pangolin. Alternatively, you
can use short-lived certificates for
seamless authentication using your
existing Pangolin identity.{" "}
<Link <Link
href="https://docs.pangolin.net/" href="https://docs.pangolin.net/"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="underline inline-flex items-center gap-1" className="underline inline-flex items-center gap-1"
> >
Learn more {t("sshLearnMore")}
<ExternalLink className="h-3 w-3" /> <ExternalLink className="h-3 w-3" />
</Link> </Link>
</p> </p>
<Field label="Username" id="username-key"> <Field
label={t("username")}
id="username-key"
>
<Input <Input
id="username-key" id="username-key"
value={form.username} value={form.username}
@@ -486,7 +514,10 @@ export default function SshClient({
placeholder="root" placeholder="root"
/> />
</Field> </Field>
<Field label="Private Key" id="privateKey"> <Field
label={t("sshPrivateKeyField")}
id="privateKey"
>
<Textarea <Textarea
id="privateKey" id="privateKey"
value={form.privateKey} value={form.privateKey}
@@ -502,7 +533,7 @@ export default function SshClient({
/> />
</Field> </Field>
<Field <Field
label="Private Key File" label={t("sshPrivateKeyFile")}
id="privateKeyFile" id="privateKeyFile"
> >
<Input <Input
@@ -534,8 +565,8 @@ export default function SshClient({
className="w-full" className="w-full"
> >
{connecting {connecting
? "Connecting..." ? t("sshConnecting")
: "Authenticate"} : t("sshAuthenticate")}
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
@@ -551,7 +582,7 @@ export default function SshClient({
variant="destructive" variant="destructive"
onClick={disconnect} onClick={disconnect}
> >
Terminate {t("sshTerminate")}
</Button> </Button>
</div> </div>
<div <div