Shape the ssh/vnc/rdp login ui to match auth

This commit is contained in:
Owen
2026-05-28 21:12:55 -07:00
parent 5b814e37c4
commit 9a1db4948b
8 changed files with 364 additions and 310 deletions

View File

@@ -1,13 +1,6 @@
import ThemeSwitcher from "@app/components/ThemeSwitcher";
import { Separator } from "@app/components/ui/separator";
import { priv } from "@app/lib/api";
import { pullEnv } from "@app/lib/pullEnv";
import { build } from "@server/build";
import { GetLicenseStatusResponse } from "@server/routers/license/types";
import { AxiosResponse } from "axios";
import AuthFooter from "@app/components/AuthFooter";
import { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { cache } from "react";
export const metadata: Metadata = {
title: {
@@ -22,29 +15,6 @@ type AuthLayoutProps = {
};
export default async function AuthLayout({ children }: AuthLayoutProps) {
const env = pullEnv();
const t = await getTranslations();
let hideFooter = false;
let licenseStatus: GetLicenseStatusResponse | null = null;
if (build == "enterprise") {
const licenseStatusRes = await cache(
async () =>
await priv.get<AxiosResponse<GetLicenseStatusResponse>>(
"/license/status"
)
)();
licenseStatus = licenseStatusRes.data.data;
if (
env.branding.hideAuthLayoutFooter &&
licenseStatusRes.data.data.isHostLicensed &&
licenseStatusRes.data.data.isLicenseValid &&
licenseStatusRes.data.data.tier !== "personal"
) {
hideFooter = true;
}
}
return (
<div className="h-full flex flex-col">
<div className="hidden md:flex justify-end items-center p-3 space-x-2">
@@ -55,89 +25,7 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
<div className="w-full max-w-md p-3">{children}</div>
</div>
{!hideFooter && (
<footer className="hidden md:block w-full mt-12 py-3 mb-6 px-4">
<div className="container mx-auto flex flex-wrap justify-center items-center h-3 space-x-4 text-xs text-neutral-400 dark:text-neutral-600">
<a
href="https://pangolin.net"
target="_blank"
rel="noopener noreferrer"
aria-label="Built by Fossorial"
className="flex items-center space-x-2 whitespace-nowrap"
>
<span>
© {new Date().getFullYear()} Fossorial, Inc.
</span>
</a>
{build !== "saas" && (
<>
<Separator orientation="vertical" />
<a
href="https://pangolin.net"
target="_blank"
rel="noopener noreferrer"
aria-label="Built by Fossorial"
className="flex items-center space-x-2 whitespace-nowrap"
>
<span>
{process.env.BRANDING_APP_NAME ||
"Pangolin"}
</span>
</a>
</>
)}
<Separator orientation="vertical" />
<span>
{build === "oss"
? t("communityEdition")
: build === "enterprise"
? t("enterpriseEdition")
: t("pangolinCloud")}
</span>
{build === "enterprise" &&
licenseStatus?.isHostLicensed &&
licenseStatus?.isLicenseValid &&
licenseStatus?.tier === "personal" ? (
<>
<Separator orientation="vertical" />
<span>{t("personalUseOnly")}</span>
</>
) : null}
{build === "enterprise" &&
(!licenseStatus?.isHostLicensed ||
!licenseStatus?.isLicenseValid) ? (
<>
<Separator orientation="vertical" />
<span>{t("unlicensed")}</span>
</>
) : null}
{build === "saas" && (
<>
<Separator orientation="vertical" />
<a
href="https://pangolin.net/terms-of-service.html"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
className="flex items-center space-x-2 whitespace-nowrap"
>
<span>{t("termsOfService")}</span>
</a>
<Separator orientation="vertical" />
<a
href="https://pangolin.net/privacy-policy.html"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
className="flex items-center space-x-2 whitespace-nowrap"
>
<span>{t("privacyPolicy")}</span>
</a>
</>
)}
</div>
</footer>
)}
<AuthFooter />
</div>
);
}

View File

@@ -15,6 +15,8 @@ import type {
FileInfo
} from "@devolutions/iron-remote-desktop-rdp/dist";
import { GetBrowserTargetResponse } from "@server/routers/resource";
import { Card, CardContent } from "@app/components/ui/card";
import LoginCardHeader from "@app/components/LoginCardHeader";
declare module "react" {
namespace JSX {
@@ -307,48 +309,51 @@ export default function RdpClient({
if (error) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<p className="text-destructive">{error}</p>
</div>
<Card className="w-full">
<LoginCardHeader subtitle="RDP" />
<CardContent className="pt-6">
<p className="text-destructive text-sm">{error}</p>
</CardContent>
</Card>
);
}
return (
<div className="min-h-screen bg-background">
<>
{showLogin && (
<div className="mx-auto max-w-2xl p-6">
<h1 className="mb-4 text-2xl font-semibold">RDP</h1>
<div className="space-y-4">
<Field label="Domain" id="domain">
<Input
id="domain"
value={form.domain}
onChange={(e) =>
update("domain", e.target.value)
}
/>
</Field>
<Field label="Username" id="username">
<Input
id="username"
value={form.username}
onChange={(e) =>
update("username", e.target.value)
}
/>
</Field>
<Field label="Password" id="password">
<Input
id="password"
type="password"
value={form.password}
onChange={(e) =>
update("password", e.target.value)
}
/>
</Field>
{/*
<Card className="w-full">
<LoginCardHeader subtitle="Connect via RDP" />
<CardContent className="pt-6">
<div className="space-y-4">
<Field label="Domain" id="domain">
<Input
id="domain"
value={form.domain}
onChange={(e) =>
update("domain", e.target.value)
}
/>
</Field>
<Field label="Username" id="username">
<Input
id="username"
value={form.username}
onChange={(e) =>
update("username", e.target.value)
}
/>
</Field>
<Field label="Password" id="password">
<Input
id="password"
type="password"
value={form.password}
onChange={(e) =>
update("password", e.target.value)
}
/>
</Field>
{/*
<Field label="Pre Connection Blob (optional)" id="pcb">
<Input
id="pcb"
@@ -357,7 +362,7 @@ export default function RdpClient({
/>
</Field> */}
{/* <Field
{/* <Field
label="KDC Proxy URL (optional)"
id="kdcProxyUrl"
>
@@ -369,7 +374,7 @@ export default function RdpClient({
}
/>
</Field> */}
{/* <div className="flex items-center gap-2">
{/* <div className="flex items-center gap-2">
<Checkbox
id="enable_clipboard"
checked={form.enableClipboard}
@@ -381,20 +386,21 @@ export default function RdpClient({
Enable Clipboard
</Label>
</div> */}
<Button
onClick={startSession}
disabled={!moduleReady}
loading={connecting}
className="w-full"
>
{moduleReady ? "Connect" : "Loading module..."}
</Button>
</div>
</div>
<Button
onClick={startSession}
disabled={!moduleReady}
loading={connecting}
className="w-full"
>
{moduleReady ? "Connect" : "Loading module..."}
</Button>
</div>
</CardContent>
</Card>
)}
<div
className="flex h-screen flex-col bg-neutral-900"
className="fixed inset-0 z-50 flex flex-col bg-neutral-900"
style={{ display: showLogin ? "none" : "flex" }}
>
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
@@ -500,7 +506,7 @@ export default function RdpClient({
/>
)}
</div>
</div>
</>
);
}

View File

@@ -3,6 +3,7 @@ import { priv } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { GetBrowserTargetResponse } from "@server/routers/resource";
import RdpClient from "./RdpClient";
import AuthFooter from "@app/components/AuthFooter";
export const dynamic = "force-dynamic";
@@ -29,5 +30,14 @@ export default async function RdpPage() {
error = "No resource found for this domain";
}
return <RdpClient target={target} error={error} />;
return (
<div className="h-full flex flex-col">
<div className="flex-1 flex md:items-center justify-center">
<div className="w-full max-w-md p-3">
<RdpClient target={target} error={error} />
</div>
</div>
<AuthFooter />
</div>
);
}

View File

@@ -8,6 +8,8 @@ import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import type { SignSshKeyResponse } from "@server/private/routers/ssh";
import { GetBrowserTargetResponse } from "@server/routers/resource";
import { Card, CardContent } from "@app/components/ui/card";
import LoginCardHeader from "@app/components/LoginCardHeader";
type FormState = {
username: string;
@@ -259,20 +261,12 @@ export default function SshClient({
setConnected(false);
}
if (error) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<p className="text-destructive">{error}</p>
</div>
);
}
// In push mode, show a connecting/connected state without the login form.
if (signedKeyData && signedPrivateKey) {
return (
<div className="min-h-screen bg-background">
<>
{!connected && (
<div className="flex min-h-screen items-center justify-center">
<div className="flex items-center justify-center py-12">
<p className="text-muted-foreground">
{connectError
? connectError
@@ -283,7 +277,7 @@ export default function SshClient({
</div>
)}
{connected && (
<div className="flex h-screen flex-col bg-neutral-900">
<div className="fixed inset-0 z-50 flex flex-col bg-neutral-900">
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
<Button
size="sm"
@@ -300,121 +294,136 @@ export default function SshClient({
/>
</div>
)}
</div>
</>
);
}
if (error) {
return (
<Card className="w-full">
<LoginCardHeader subtitle="SSH" />
<CardContent className="pt-6">
<p className="text-destructive text-sm">{error}</p>
</CardContent>
</Card>
);
}
return (
<div className="min-h-screen bg-background">
<>
{!connected && (
<div className="mx-auto max-w-2xl p-6">
<h1 className="mb-4 text-2xl font-semibold">SSH</h1>
<div className="space-y-4">
<Field label="Username" id="username">
<Input
id="username"
value={form.username}
onChange={(e) =>
setForm({
...form,
username: e.target.value
})
}
placeholder="root"
/>
</Field>
<Field label="Password" id="password">
<Input
id="password"
type="password"
value={form.password}
onChange={(e) =>
setForm({
...form,
password: e.target.value
})
}
placeholder={
form.privateKey
? "Optional with key auth"
: ""
}
/>
</Field>
<Field label="Private Key (optional)" id="privateKey">
<Textarea
id="privateKey"
value={form.privateKey}
onChange={(e) =>
setForm({
...form,
privateKey: e.target.value
})
}
placeholder="Paste your private key here (PEM format)…"
rows={5}
className="font-mono text-xs"
/>
<div className="mt-1.5 flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
fileInputRef.current?.click()
<Card className="w-full">
<LoginCardHeader subtitle="Connect via SSH" />
<CardContent className="pt-6">
<div className="space-y-4">
<Field label="Username" id="username">
<Input
id="username"
value={form.username}
onChange={(e) =>
setForm({
...form,
username: e.target.value
})
}
>
Upload key file
</Button>
{form.privateKey && (
<button
placeholder="root"
/>
</Field>
<Field label="Password" id="password">
<Input
id="password"
type="password"
value={form.password}
onChange={(e) =>
setForm({
...form,
password: e.target.value
})
}
placeholder={
form.privateKey
? "Optional with key auth"
: ""
}
/>
</Field>
<Field
label="Private Key (optional)"
id="privateKey"
>
<Textarea
id="privateKey"
value={form.privateKey}
onChange={(e) =>
setForm({
...form,
privateKey: e.target.value
})
}
placeholder="Paste your private key here (PEM format)…"
rows={5}
className="font-mono text-xs"
/>
<div className="mt-1.5 flex items-center gap-2">
<Button
type="button"
className="text-xs text-muted-foreground underline"
variant="outline"
size="sm"
onClick={() =>
setForm((prev) => ({
...prev,
privateKey: ""
}))
fileInputRef.current?.click()
}
>
Clear
</button>
)}
</div>
<input
ref={fileInputRef}
type="file"
className="hidden"
accept=".pem,.key,.pub,*"
onChange={handleKeyFile}
/>
</Field>
Upload key file
</Button>
{form.privateKey && (
<button
type="button"
className="text-xs text-muted-foreground underline"
onClick={() =>
setForm((prev) => ({
...prev,
privateKey: ""
}))
}
>
Clear
</button>
)}
</div>
<input
ref={fileInputRef}
type="file"
className="hidden"
accept=".pem,.key,.pub,*"
onChange={handleKeyFile}
/>
</Field>
{connectError && (
<p className="text-destructive text-sm">
{connectError}
</p>
)}
{connectError && (
<p className="text-destructive text-sm">
{connectError}
</p>
)}
<Button
onClick={() => connect()}
loading={connecting}
disabled={
!form.username ||
(!form.password && !form.privateKey)
}
className="w-full"
>
{connecting ? "Connecting..." : "Connect"}
</Button>
</div>
</div>
<Button
onClick={() => connect()}
loading={connecting}
disabled={
!form.username ||
(!form.password && !form.privateKey)
}
className="w-full"
>
{connecting ? "Connecting..." : "Connect"}
</Button>
</div>
</CardContent>
</Card>
)}
{connected && (
<div className="flex h-screen flex-col bg-neutral-900">
<div className="fixed inset-0 z-50 flex flex-col bg-neutral-900">
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
<Button
size="sm"
@@ -431,7 +440,7 @@ export default function SshClient({
/>
</div>
)}
</div>
</>
);
}

View File

@@ -5,6 +5,7 @@ import { GetBrowserTargetResponse } from "@server/routers/resource";
import SshClient from "./SshClient";
import { SignSshKeyResponse } from "@server/private/routers/ssh";
import crypto from "crypto";
import AuthFooter from "@app/components/AuthFooter";
function generateEphemeralKeyPair(): {
privateKeyPem: string;
@@ -82,11 +83,18 @@ export default async function SshPage() {
}
return (
<SshClient
target={target}
error={error}
signedKeyData={signedKeyData}
privateKey={privateKey}
/>
<div className="h-full flex flex-col">
<div className="flex-1 flex md:items-center justify-center">
<div className="w-full max-w-md p-3">
<SshClient
target={target}
error={error}
signedKeyData={signedKeyData}
privateKey={privateKey}
/>
</div>
</div>
<AuthFooter />
</div>
);
}

View File

@@ -6,6 +6,8 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { toast } from "@app/hooks/useToast";
import { GetBrowserTargetResponse } from "@server/routers/resource";
import { Card, CardContent } from "@app/components/ui/card";
import LoginCardHeader from "@app/components/LoginCardHeader";
type FormState = {
password: string;
@@ -146,39 +148,43 @@ export default function VncClient({
if (error) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<p className="text-destructive">{error}</p>
</div>
<Card className="w-full">
<LoginCardHeader subtitle="VNC" />
<CardContent className="pt-6">
<p className="text-destructive text-sm">{error}</p>
</CardContent>
</Card>
);
}
return (
<div className="min-h-screen bg-background">
<>
{!connected && (
<div className="mx-auto max-w-2xl p-6">
<h1 className="mb-4 text-2xl font-semibold">VNC</h1>
<Card className="w-full">
<LoginCardHeader subtitle="Connect via VNC" />
<CardContent className="pt-6">
<div className="space-y-4">
<Field label="Password (optional)" id="password">
<Input
id="password"
type="password"
value={form.password}
onChange={(e) =>
update("password", e.target.value)
}
/>
</Field>
<div className="space-y-4">
<Field label="Password (optional)" id="password">
<Input
id="password"
type="password"
value={form.password}
onChange={(e) =>
update("password", e.target.value)
}
/>
</Field>
<Button onClick={connect} className="w-full">
Connect
</Button>
</div>
</div>
<Button onClick={connect} className="w-full">
Connect
</Button>
</div>
</CardContent>
</Card>
)}
<div
className="flex h-screen flex-col bg-neutral-900"
className="fixed inset-0 z-50 flex flex-col bg-neutral-900"
style={{ display: connected ? "flex" : "none" }}
>
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
@@ -223,7 +229,7 @@ export default function VncClient({
style={{ background: "#000" }}
/>
</div>
</div>
</>
);
}

View File

@@ -3,6 +3,7 @@ import { priv } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { GetBrowserTargetResponse } from "@server/routers/resource";
import VncClient from "./VncClient";
import AuthFooter from "@app/components/AuthFooter";
export const dynamic = "force-dynamic";
@@ -28,5 +29,14 @@ export default async function VncPage() {
error = "No resource found for this domain";
}
return <VncClient target={target} error={error} />;
return (
<div className="h-full flex flex-col">
<div className="flex-1 flex md:items-center justify-center">
<div className="w-full max-w-md p-3">
<VncClient target={target} error={error} />
</div>
</div>
<AuthFooter />
</div>
);
}

View File

@@ -0,0 +1,117 @@
import { Separator } from "@app/components/ui/separator";
import { priv } from "@app/lib/api";
import { pullEnv } from "@app/lib/pullEnv";
import { build } from "@server/build";
import { GetLicenseStatusResponse } from "@server/routers/license/types";
import { AxiosResponse } from "axios";
import { getTranslations } from "next-intl/server";
import { cache } from "react";
export default async function AuthFooter() {
const env = pullEnv();
const t = await getTranslations();
let hideFooter = false;
let licenseStatus: GetLicenseStatusResponse | null = null;
if (build === "enterprise") {
const licenseStatusRes = await cache(
async () =>
await priv.get<AxiosResponse<GetLicenseStatusResponse>>(
"/license/status"
)
)();
licenseStatus = licenseStatusRes.data.data;
if (
env.branding.hideAuthLayoutFooter &&
licenseStatusRes.data.data.isHostLicensed &&
licenseStatusRes.data.data.isLicenseValid &&
licenseStatusRes.data.data.tier !== "personal"
) {
hideFooter = true;
}
}
if (hideFooter) return null;
return (
<footer className="hidden md:block w-full mt-12 py-3 mb-6 px-4">
<div className="container mx-auto flex flex-wrap justify-center items-center h-3 space-x-4 text-xs text-neutral-400 dark:text-neutral-600">
<a
href="https://pangolin.net"
target="_blank"
rel="noopener noreferrer"
aria-label="Built by Fossorial"
className="flex items-center space-x-2 whitespace-nowrap"
>
<span>© {new Date().getFullYear()} Fossorial, Inc.</span>
</a>
{build !== "saas" && (
<>
<Separator orientation="vertical" />
<a
href="https://pangolin.net"
target="_blank"
rel="noopener noreferrer"
aria-label="Built by Fossorial"
className="flex items-center space-x-2 whitespace-nowrap"
>
<span>
{process.env.BRANDING_APP_NAME || "Pangolin"}
</span>
</a>
</>
)}
<Separator orientation="vertical" />
<span>
{build === "oss"
? t("communityEdition")
: build === "enterprise"
? t("enterpriseEdition")
: t("pangolinCloud")}
</span>
{build === "enterprise" &&
licenseStatus?.isHostLicensed &&
licenseStatus?.isLicenseValid &&
licenseStatus?.tier === "personal" ? (
<>
<Separator orientation="vertical" />
<span>{t("personalUseOnly")}</span>
</>
) : null}
{build === "enterprise" &&
(!licenseStatus?.isHostLicensed ||
!licenseStatus?.isLicenseValid) ? (
<>
<Separator orientation="vertical" />
<span>{t("unlicensed")}</span>
</>
) : null}
{build === "saas" && (
<>
<Separator orientation="vertical" />
<a
href="https://pangolin.net/terms-of-service.html"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
className="flex items-center space-x-2 whitespace-nowrap"
>
<span>{t("termsOfService")}</span>
</a>
<Separator orientation="vertical" />
<a
href="https://pangolin.net/privacy-policy.html"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
className="flex items-center space-x-2 whitespace-nowrap"
>
<span>{t("privacyPolicy")}</span>
</a>
</>
)}
</div>
</footer>
);
}