improve site and resource info cards and other small visual tweaks

This commit is contained in:
Milo Schwartz
2024-12-30 23:41:06 -05:00
parent e6263567a9
commit 172e0f07d5
31 changed files with 469 additions and 332 deletions

View File

@@ -13,6 +13,8 @@ type RolesPageProps = {
params: Promise<{ orgId: string }>;
};
export const dynamic = "force-dynamic";
export default async function RolesPage(props: RolesPageProps) {
const params = await props.params;

View File

@@ -11,9 +11,10 @@ import {
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbSeparator
} from "@/components/ui/breadcrumb";
import Link from "next/link";
import { cache } from "react";
interface UserLayoutProps {
children: React.ReactNode;
@@ -27,10 +28,13 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
let user = null;
try {
const res = await internal.get<AxiosResponse<GetOrgUserResponse>>(
`/org/${params.orgId}/user/${params.userId}`,
await authCookieHeader(),
const getOrgUser = cache(async () =>
internal.get<AxiosResponse<GetOrgUserResponse>>(
`/org/${params.orgId}/user/${params.userId}`,
await authCookieHeader()
)
);
const res = await getOrgUser();
user = res.data.data;
} catch {
redirect(`/${params.orgId}/settings/sites`);
@@ -39,8 +43,8 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
const sidebarNavItems = [
{
title: "Access Controls",
href: "/{orgId}/settings/access/users/{userId}/access-controls",
},
href: "/{orgId}/settings/access/users/{userId}/access-controls"
}
];
return (

View File

@@ -15,6 +15,8 @@ type UsersPageProps = {
params: Promise<{ orgId: string }>;
};
export const dynamic = "force-dynamic";
export default async function UsersPage(props: UsersPageProps) {
const params = await props.params;

View File

@@ -382,8 +382,8 @@ export default function ResourceAuthenticationPage() {
/>
)}
<div className="space-y-12 lg:max-w-2xl">
<section className="space-y-8">
<div className="space-y-12">
<section className="space-y-8 lg:max-w-2xl">
<SettingsSectionTitle
title="Users & Roles"
description="Configure which users and roles can visit this resource"
@@ -541,7 +541,7 @@ export default function ResourceAuthenticationPage() {
<Separator />
<section className="space-y-8">
<section className="space-y-8 lg:max-w-2xl">
<SettingsSectionTitle
title="Authentication Methods"
description="Allow access to the resource via additional auth methods"
@@ -613,109 +613,105 @@ export default function ResourceAuthenticationPage() {
)}
</div>
</div>
</section>
{env.EMAIL_ENABLED === "true" && (
<>
<Separator />
<div>
<div className="flex items-center space-x-2 mb-2">
<Switch
id="whitelist-toggle"
defaultChecked={
resource.emailWhitelistEnabled
}
onCheckedChange={(val) =>
setWhitelistEnabled(val)
}
/>
<Label htmlFor="whitelist-toggle">
Email Whitelist
</Label>
</div>
<span className="text-muted-foreground text-sm">
Enable resource whitelist to require
email-based authentication (one-time
passwords) for resource access.
</span>
<Separator />
<section className="space-y-8 lg:max-w-2xl">
{env.EMAIL_ENABLED === "true" && (
<>
<div>
<div className="flex items-center space-x-2 mb-2">
<Switch
id="whitelist-toggle"
defaultChecked={
resource.emailWhitelistEnabled
}
onCheckedChange={(val) =>
setWhitelistEnabled(val)
}
/>
<Label htmlFor="whitelist-toggle">
Email Whitelist
</Label>
</div>
<span className="text-muted-foreground text-sm">
Enable resource whitelist to require email-based
authentication (one-time passwords) for resource
access.
</span>
</div>
{whitelistEnabled && (
<Form {...whitelistForm}>
<form className="space-y-4">
<FormField
control={whitelistForm.control}
name="emails"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>
Whitelisted Emails
</FormLabel>
<FormControl>
{/* @ts-ignore */}
<TagInput
{...field}
activeTagIndex={
activeEmailTagIndex
}
validateTag={(
tag
) => {
return z
.string()
.email()
.safeParse(
tag
).success;
}}
setActiveTagIndex={
setActiveEmailTagIndex
}
placeholder="Enter an email"
tags={
whitelistForm.getValues()
.emails
}
setTags={(
newRoles
) => {
whitelistForm.setValue(
"emails",
newRoles as [
Tag,
...Tag[]
]
);
}}
allowDuplicates={
false
}
sortTags={true}
styleClasses={{
tag: {
body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full"
},
input: "text-base md:text-sm border-none bg-transparent text-inherit placeholder:text-inherit shadow-none",
inlineTagsContainer:
"bg-transparent p-2"
}}
/>
</FormControl>
</FormItem>
)}
/>
</form>
</Form>
)}
{whitelistEnabled && (
<Form {...whitelistForm}>
<form className="space-y-4">
<FormField
control={whitelistForm.control}
name="emails"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>
Whitelisted Emails
</FormLabel>
<FormControl>
{/* @ts-ignore */}
<TagInput
{...field}
activeTagIndex={
activeEmailTagIndex
}
validateTag={(tag) => {
return z
.string()
.email()
.safeParse(tag)
.success;
}}
setActiveTagIndex={
setActiveEmailTagIndex
}
placeholder="Enter an email"
tags={
whitelistForm.getValues()
.emails
}
setTags={(newRoles) => {
whitelistForm.setValue(
"emails",
newRoles as [
Tag,
...Tag[]
]
);
}}
allowDuplicates={false}
sortTags={true}
styleClasses={{
tag: {
body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full"
},
input: "text-base md:text-sm border-none bg-transparent text-inherit placeholder:text-inherit shadow-none",
inlineTagsContainer:
"bg-transparent p-2"
}}
/>
</FormControl>
</FormItem>
)}
/>
</form>
</Form>
)}
<Button
loading={loadingSaveWhitelist}
disabled={loadingSaveWhitelist}
onClick={saveWhitelist}
>
Save Whitelist
</Button>
</>
)}
<Button
loading={loadingSaveWhitelist}
disabled={loadingSaveWhitelist}
onClick={saveWhitelist}
>
Save Whitelist
</Button>
</>
)}
</section>
</div>
</>

View File

@@ -1,7 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { Card } from "@/components/ui/card";
import { useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
@@ -14,7 +13,14 @@ import {
} from "lucide-react";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { useResourceContext } from "@app/hooks/useResourceContext";
import Link from "next/link";
import { Separator } from "@app/components/ui/separator";
import CopyToClipboard from "@app/components/CopyToClipboard";
import {
InfoSection,
InfoSectionContent,
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
type ResourceInfoBoxType = {};
@@ -28,86 +34,48 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
resource.subdomain
}.${org.org.domain}`;
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(fullUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error("Failed to copy text: ", err);
}
};
return (
<Alert>
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
Resource Information
</AlertTitle>
<AlertDescription className="mt-3">
<div className="space-y-3">
<div>
{authInfo.password ||
authInfo.pincode ||
authInfo.sso ||
authInfo.whitelist ? (
<div className="flex items-center space-x-2 text-green-500">
<ShieldCheck />
<span>
This resource is protected with at least one
auth method.
</span>
</div>
) : (
<div className="flex items-center space-x-2 text-yellow-500">
<ShieldOff />
<span>
This resource is not protected with any auth
method. Anyone can access this resource.
</span>
</div>
)}
</div>
<div className="flex items-center space-x-2 bg-muted p-1 pl-3 rounded-md lg:max-w-xl">
<LinkIcon className="h-4 w-4" />
<a
href={fullUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-mono flex-grow hover:underline truncate"
>
{fullUrl}
</a>
<Button
variant="outline"
size="sm"
onClick={copyToClipboard}
className="ml-2"
type="button"
>
{copied ? (
<CheckIcon className="h-4 w-4 text-green-500" />
<AlertDescription className="mt-4">
<InfoSections>
<InfoSection>
<InfoSectionTitle>Authentication</InfoSectionTitle>
<InfoSectionContent>
{authInfo.password ||
authInfo.pincode ||
authInfo.sso ||
authInfo.whitelist ? (
<div className="flex items-start space-x-2 text-green-500">
<ShieldCheck className="w-4 h-4 mt-0.5" />
<span>
This resource is protected with at least
one auth method.
</span>
</div>
) : (
<CopyIcon className="h-4 w-4" />
<div className="flex items-center space-x-2 text-yellow-500">
<ShieldOff className="w-4 h-4" />
<span>
This resource is not protected with any
auth method. Anyone can access this
resource.
</span>
</div>
)}
<span className="ml-2">
{copied ? "Copied!" : "Copy"}
</span>
</Button>
</div>
{/* <p className="mt-3">
To create a proxy to your private services,{" "}
<Link
href={`/${org.org.orgId}/settings/resources/${resource.resourceId}/connectivity`}
className="text-primary hover:underline"
>
add targets
</Link>{" "}
to this resource
</p> */}
</div>
</InfoSectionContent>
</InfoSection>
<Separator orientation="vertical" />
<InfoSection>
<InfoSectionTitle>URL</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard text={fullUrl} isLink={true} />
</InfoSectionContent>
</InfoSection>
</InfoSections>
</AlertDescription>
</Alert>
);

View File

@@ -429,7 +429,7 @@ export default function ReverseProxyTargets(props: {
</div>
</section>
<hr className="lg:max-w-2xl" />
<hr />
<section className="space-y-8">
<SettingsSectionTitle

View File

@@ -29,6 +29,7 @@ import { formatAxiosError } from "@app/lib/utils";
import { useToast } from "@app/hooks/useToast";
import { createApiClient } from "@app/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import CopyToClipboard from "@app/components/CopyToClipboard";
export type ResourceRow = {
id: number;
@@ -162,55 +163,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
cell: ({ row }) => {
const resourceRow = row.original;
return (
<div className="flex items-center">
<Link
href={resourceRow.domain}
target="_blank"
rel="noopener noreferrer"
className="hover:underline mr-2"
>
{resourceRow.domain}
</Link>
<Button
variant="ghost"
className="h-6 w-6 p-0"
onClick={() => {
navigator.clipboard.writeText(
resourceRow.domain
);
const originalIcon = document.querySelector(
`#icon-${resourceRow.id}`
);
if (originalIcon) {
originalIcon.classList.add("hidden");
}
const checkIcon = document.querySelector(
`#check-icon-${resourceRow.id}`
);
if (checkIcon) {
checkIcon.classList.remove("hidden");
setTimeout(() => {
checkIcon.classList.add("hidden");
if (originalIcon) {
originalIcon.classList.remove(
"hidden"
);
}
}, 2000);
}
}}
>
<Copy
id={`icon-${resourceRow.id}`}
className="h-4 w-4"
/>
<Check
id={`check-icon-${resourceRow.id}`}
className="hidden text-green-500 h-4 w-4"
/>
<span className="sr-only">Copy domain</span>
</Button>
</div>
<CopyToClipboard text={resourceRow.domain} isLink={true} />
);
}
},

View File

@@ -13,6 +13,8 @@ type ResourcesPageProps = {
params: Promise<{ orgId: string }>;
};
export const dynamic = "force-dynamic";
export default async function ResourcesPage(props: ResourcesPageProps) {
const params = await props.params;
let resources: ListResourcesResponse["resources"] = [];

View File

@@ -13,6 +13,8 @@ type ShareLinksPageProps = {
params: Promise<{ orgId: string }>;
};
export const dynamic = "force-dynamic";
export default async function ShareLinksPage(props: ShareLinksPageProps) {
const params = await props.params;

View File

@@ -0,0 +1,56 @@
"use client";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { InfoIcon } from "lucide-react";
import { useSiteContext } from "@app/hooks/useSiteContext";
import { Separator } from "@app/components/ui/separator";
import {
InfoSection,
InfoSectionContent,
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
type SiteInfoCardProps = {};
export default function SiteInfoCard({}: SiteInfoCardProps) {
const { site, updateSite } = useSiteContext();
return (
<Alert>
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">Site Information</AlertTitle>
<AlertDescription className="mt-4">
<InfoSections>
<InfoSection>
<InfoSectionTitle>Status</InfoSectionTitle>
<InfoSectionContent>
{site.online ? (
<div className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Online</span>
</div>
) : (
<div className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>Offline</span>
</div>
)}
</InfoSectionContent>
</InfoSection>
<Separator orientation="vertical" />
<InfoSection>
<InfoSectionTitle>Connection Type</InfoSectionTitle>
<InfoSectionContent>
{site.type === "newt"
? "Newt"
: site.type === "wireguard"
? "WireGuard"
: "Unknown"}
</InfoSectionContent>
</InfoSection>
</InfoSections>
</AlertDescription>
</Alert>
);
}

View File

@@ -67,7 +67,7 @@ export default function GeneralPage() {
return (
<>
<div className="space-y-8">
<div className="space-y-8 max-w-xl">
<SettingsSectionTitle
title="General Settings"
description="Configure the general settings for this site"

View File

@@ -13,8 +13,9 @@ import {
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbSeparator
} from "@app/components/ui/breadcrumb";
import SiteInfoCard from "./components/SiteInfoCard";
interface SettingsLayoutProps {
children: React.ReactNode;
@@ -30,7 +31,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
try {
const res = await internal.get<AxiosResponse<GetSiteResponse>>(
`/org/${params.orgId}/site/${params.niceId}`,
await authCookieHeader(),
await authCookieHeader()
);
site = res.data.data;
} catch {
@@ -40,8 +41,8 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
const sidebarNavItems = [
{
title: "General",
href: "/{orgId}/settings/sites/{niceId}/general",
},
href: "/{orgId}/settings/sites/{niceId}/general"
}
];
return (
@@ -66,10 +67,10 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
/>
<SiteProvider site={site}>
<SidebarSettings
sidebarNavItems={sidebarNavItems}
limitWidth={true}
>
<SidebarSettings sidebarNavItems={sidebarNavItems}>
<div className="mb-8">
<SiteInfoCard />
</div>
{children}
</SidebarSettings>
</SiteProvider>

View File

@@ -9,6 +9,8 @@ type SitesPageProps = {
params: Promise<{ orgId: string }>;
};
export const dynamic = "force-dynamic";
export default async function SitesPage(props: SitesPageProps) {
const params = await props.params;
let sites: ListSitesResponse["sites"] = [];

View File

@@ -51,23 +51,29 @@ export default async function RootLayout({
>
{children}
<footer className="w-full mt-6 py-3">
<div className="container mx-auto flex justify-center items-center h-5 space-x-4 text-sm text-neutral-400 select-none">
<div>Built by Fossorial</div>
<footer className="w-full mt-12 py-3 mb-4">
<div className="container mx-auto flex flex-wrap justify-center items-center h-4 space-x-4 text-sm text-neutral-400 select-none">
<div className="whitespace-nowrap">
Pangolin
</div>
<Separator orientation="vertical" />
<div className="whitespace-nowrap">
Built by Fossorial
</div>
<Separator orientation="vertical" />
<a
href="https://github.com/fosrl/pangolin"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
className="flex items-center space-x-3 underline"
className="flex items-center space-x-3 whitespace-nowrap"
>
<span>Open Source</span>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-4 h-4"
className="w-3 h-3"
>
<path d="M12 0C5.37 0 0 5.373 0 12c0 5.303 3.438 9.8 8.207 11.385.6.11.82-.26.82-.577v-2.17c-3.338.726-4.042-1.61-4.042-1.61-.546-1.385-1.333-1.755-1.333-1.755-1.09-.744.082-.73.082-.73 1.205.085 1.84 1.24 1.84 1.24 1.07 1.835 2.807 1.305 3.492.997.107-.775.42-1.305.763-1.605-2.665-.305-5.467-1.335-5.467-5.93 0-1.31.468-2.382 1.236-3.22-.123-.303-.535-1.523.117-3.176 0 0 1.008-.322 3.3 1.23a11.52 11.52 0 013.006-.403c1.02.005 2.045.137 3.006.403 2.29-1.552 3.295-1.23 3.295-1.23.654 1.653.242 2.873.12 3.176.77.838 1.235 1.91 1.235 3.22 0 4.605-2.805 5.623-5.475 5.92.43.37.814 1.1.814 2.22v3.293c0 .32.217.693.825.576C20.565 21.795 24 17.298 24 12 24 5.373 18.627 0 12 0z" />
</svg>
@@ -75,7 +81,9 @@ export default async function RootLayout({
{version && (
<>
<Separator orientation="vertical" />
<div>v{version}</div>
<div className="whitespace-nowrap">
v{version}
</div>
</>
)}
</div>

View File

@@ -0,0 +1,52 @@
import { Check, Copy } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
type CopyToClipboardProps = {
text: string;
isLink?: boolean;
};
const CopyToClipboard = ({ text, isLink }: CopyToClipboardProps) => {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 2000);
};
return (
<div className="flex items-center">
{isLink ? (
<Link
href={text}
target="_blank"
rel="noopener noreferrer"
className="hover:underline mr-2"
>
{text}
</Link>
) : (
<span className="mr-2">{text}</span>
)}
<button
type="button"
className="h-6 w-6 p-0 flex items-center justify-center cursor-pointer"
onClick={handleCopy}
>
{!copied ? (
<Copy className="h-4 w-4" />
) : (
<Check className="text-green-500 h-4 w-4" />
)}
<span className="sr-only">Copy text</span>
</button>
</div>
);
};
export default CopyToClipboard;

View File

@@ -90,11 +90,19 @@ const CredenzaContent = ({ className, children, ...props }: CredenzaProps) => {
const CredenzaContent = isDesktop ? DialogContent : SheetContent;
return (
return isDesktop ? (
<CredenzaContent
className={cn("overflow-y-auto max-h-screen", className)}
{...props}
>
{children}
</CredenzaContent>
) : (
<CredenzaContent
className={cn("overflow-y-auto max-h-screen", className)}
{...props}
side={"bottom"}
onOpenAutoFocus={(e) => e.preventDefault()}
>
{children}
</CredenzaContent>

View File

@@ -0,0 +1,31 @@
"use client";
export function InfoSections({ children }: { children: React.ReactNode }) {
return (
<div className="grid grid-cols-1 md:gap-4 gap-2 md:grid-cols-[1fr_auto_1fr] md:items-start">
{children}
</div>
);
}
export function InfoSection({ children }: { children: React.ReactNode }) {
return <div className="space-y-1">{children}</div>;
}
export function InfoSectionTitle({ children }: { children: React.ReactNode }) {
return <div className="font-semibold">{children}</div>;
}
export function InfoSectionContent({
children
}: {
children: React.ReactNode;
}) {
return <div className="break-words">{children}</div>;
}
export function Divider() {
return (
<div className="hidden md:block border-l border-gray-300 h-auto mx-4"></div>
);
}

View File

@@ -65,7 +65,7 @@ export default function ProfileIcon() {
<Enable2FaForm open={openEnable2fa} setOpen={setOpenEnable2fa} />
<Disable2FaForm open={openDisable2fa} setOpen={setOpenDisable2fa} />
<div className="flex items-center gap-4 flex-grow min-w-0">
<div className="flex items-center md:gap-4 gap-2 flex-grow min-w-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button

View File

@@ -31,7 +31,7 @@ const SheetOverlay = React.forwardRef<
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-100 data-[state=open]:duration-300",
{
variants: {
side: {
@@ -80,7 +80,7 @@ const SheetHeader = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
"flex flex-col text-center sm:text-left mb-4",
className
)}
{...props}

View File

@@ -2,7 +2,7 @@ import { GetSiteResponse } from "@server/routers/site/getSite";
import { createContext } from "react";
interface SiteContextType {
site: GetSiteResponse | null;
site: GetSiteResponse;
updateSite: (updatedSite: Partial<GetSiteResponse>) => void;
}

View File

@@ -6,14 +6,14 @@ import { useState } from "react";
interface SiteProviderProps {
children: React.ReactNode;
site: GetSiteResponse | null;
site: GetSiteResponse;
}
export function SiteProvider({
children,
site: serverSite,
site: serverSite
}: SiteProviderProps) {
const [site, setSite] = useState<GetSiteResponse | null>(serverSite);
const [site, setSite] = useState<GetSiteResponse>(serverSite);
const updateSite = (updatedSite: Partial<GetSiteResponse>) => {
if (!site) {
@@ -25,7 +25,7 @@ export function SiteProvider({
}
return {
...prev,
...updatedSite,
...updatedSite
};
});
};