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 ThemeSwitcher from "@app/components/ThemeSwitcher";
import { Separator } from "@app/components/ui/separator"; import AuthFooter from "@app/components/AuthFooter";
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 { Metadata } from "next"; import { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { cache } from "react";
export const metadata: Metadata = { export const metadata: Metadata = {
title: { title: {
@@ -22,29 +15,6 @@ type AuthLayoutProps = {
}; };
export default async function AuthLayout({ children }: 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 ( return (
<div className="h-full flex flex-col"> <div className="h-full flex flex-col">
<div className="hidden md:flex justify-end items-center p-3 space-x-2"> <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 className="w-full max-w-md p-3">{children}</div>
</div> </div>
{!hideFooter && ( <AuthFooter />
<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>
)}
</div> </div>
); );
} }

View File

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

View File

@@ -3,6 +3,7 @@ import { priv } from "@app/lib/api";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { GetBrowserTargetResponse } from "@server/routers/resource"; import { GetBrowserTargetResponse } from "@server/routers/resource";
import RdpClient from "./RdpClient"; import RdpClient from "./RdpClient";
import AuthFooter from "@app/components/AuthFooter";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -29,5 +30,14 @@ export default async function RdpPage() {
error = "No resource found for this domain"; 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 { Textarea } from "@/components/ui/textarea";
import type { SignSshKeyResponse } from "@server/private/routers/ssh"; import type { SignSshKeyResponse } from "@server/private/routers/ssh";
import { GetBrowserTargetResponse } from "@server/routers/resource"; import { GetBrowserTargetResponse } from "@server/routers/resource";
import { Card, CardContent } from "@app/components/ui/card";
import LoginCardHeader from "@app/components/LoginCardHeader";
type FormState = { type FormState = {
username: string; username: string;
@@ -259,20 +261,12 @@ export default function SshClient({
setConnected(false); 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. // In push mode, show a connecting/connected state without the login form.
if (signedKeyData && signedPrivateKey) { if (signedKeyData && signedPrivateKey) {
return ( return (
<div className="min-h-screen bg-background"> <>
{!connected && ( {!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"> <p className="text-muted-foreground">
{connectError {connectError
? connectError ? connectError
@@ -283,7 +277,7 @@ export default function SshClient({
</div> </div>
)} )}
{connected && ( {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"> <div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
<Button <Button
size="sm" size="sm"
@@ -300,121 +294,136 @@ export default function SshClient({
/> />
</div> </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 ( return (
<div className="min-h-screen bg-background"> <>
{!connected && ( {!connected && (
<div className="mx-auto max-w-2xl p-6"> <Card className="w-full">
<h1 className="mb-4 text-2xl font-semibold">SSH</h1> <LoginCardHeader subtitle="Connect via SSH" />
<CardContent className="pt-6">
<div className="space-y-4"> <div className="space-y-4">
<Field label="Username" id="username"> <Field label="Username" id="username">
<Input <Input
id="username" id="username"
value={form.username} value={form.username}
onChange={(e) => onChange={(e) =>
setForm({ setForm({
...form, ...form,
username: e.target.value 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()
} }
> placeholder="root"
Upload key file />
</Button> </Field>
{form.privateKey && ( <Field label="Password" id="password">
<button <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" type="button"
className="text-xs text-muted-foreground underline" variant="outline"
size="sm"
onClick={() => onClick={() =>
setForm((prev) => ({ fileInputRef.current?.click()
...prev,
privateKey: ""
}))
} }
> >
Clear Upload key file
</button> </Button>
)} {form.privateKey && (
</div> <button
<input type="button"
ref={fileInputRef} className="text-xs text-muted-foreground underline"
type="file" onClick={() =>
className="hidden" setForm((prev) => ({
accept=".pem,.key,.pub,*" ...prev,
onChange={handleKeyFile} privateKey: ""
/> }))
</Field> }
>
Clear
</button>
)}
</div>
<input
ref={fileInputRef}
type="file"
className="hidden"
accept=".pem,.key,.pub,*"
onChange={handleKeyFile}
/>
</Field>
{connectError && ( {connectError && (
<p className="text-destructive text-sm"> <p className="text-destructive text-sm">
{connectError} {connectError}
</p> </p>
)} )}
<Button <Button
onClick={() => connect()} onClick={() => connect()}
loading={connecting} loading={connecting}
disabled={ disabled={
!form.username || !form.username ||
(!form.password && !form.privateKey) (!form.password && !form.privateKey)
} }
className="w-full" className="w-full"
> >
{connecting ? "Connecting..." : "Connect"} {connecting ? "Connecting..." : "Connect"}
</Button> </Button>
</div> </div>
</div> </CardContent>
</Card>
)} )}
{connected && ( {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"> <div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
<Button <Button
size="sm" size="sm"
@@ -431,7 +440,7 @@ export default function SshClient({
/> />
</div> </div>
)} )}
</div> </>
); );
} }

View File

@@ -5,6 +5,7 @@ import { GetBrowserTargetResponse } from "@server/routers/resource";
import SshClient from "./SshClient"; import SshClient from "./SshClient";
import { SignSshKeyResponse } from "@server/private/routers/ssh"; import { SignSshKeyResponse } from "@server/private/routers/ssh";
import crypto from "crypto"; import crypto from "crypto";
import AuthFooter from "@app/components/AuthFooter";
function generateEphemeralKeyPair(): { function generateEphemeralKeyPair(): {
privateKeyPem: string; privateKeyPem: string;
@@ -82,11 +83,18 @@ export default async function SshPage() {
} }
return ( return (
<SshClient <div className="h-full flex flex-col">
target={target} <div className="flex-1 flex md:items-center justify-center">
error={error} <div className="w-full max-w-md p-3">
signedKeyData={signedKeyData} <SshClient
privateKey={privateKey} 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 { Label } from "@/components/ui/label";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { GetBrowserTargetResponse } from "@server/routers/resource"; import { GetBrowserTargetResponse } from "@server/routers/resource";
import { Card, CardContent } from "@app/components/ui/card";
import LoginCardHeader from "@app/components/LoginCardHeader";
type FormState = { type FormState = {
password: string; password: string;
@@ -146,39 +148,43 @@ export default function VncClient({
if (error) { if (error) {
return ( return (
<div className="min-h-screen bg-background flex items-center justify-center"> <Card className="w-full">
<p className="text-destructive">{error}</p> <LoginCardHeader subtitle="VNC" />
</div> <CardContent className="pt-6">
<p className="text-destructive text-sm">{error}</p>
</CardContent>
</Card>
); );
} }
return ( return (
<div className="min-h-screen bg-background"> <>
{!connected && ( {!connected && (
<div className="mx-auto max-w-2xl p-6"> <Card className="w-full">
<h1 className="mb-4 text-2xl font-semibold">VNC</h1> <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"> <Button onClick={connect} className="w-full">
<Field label="Password (optional)" id="password"> Connect
<Input </Button>
id="password" </div>
type="password" </CardContent>
value={form.password} </Card>
onChange={(e) =>
update("password", e.target.value)
}
/>
</Field>
<Button onClick={connect} className="w-full">
Connect
</Button>
</div>
</div>
)} )}
<div <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" }} style={{ display: connected ? "flex" : "none" }}
> >
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white"> <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" }} style={{ background: "#000" }}
/> />
</div> </div>
</div> </>
); );
} }

View File

@@ -3,6 +3,7 @@ import { priv } from "@app/lib/api";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { GetBrowserTargetResponse } from "@server/routers/resource"; import { GetBrowserTargetResponse } from "@server/routers/resource";
import VncClient from "./VncClient"; import VncClient from "./VncClient";
import AuthFooter from "@app/components/AuthFooter";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -28,5 +29,14 @@ export default async function VncPage() {
error = "No resource found for this domain"; 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>
);
}