Improved resource card layout and favicon handling

This commit is contained in:
Adrian Astles 2025-07-19 00:54:56 +08:00
parent f6ae379caf
commit 97b267e7ae
2 changed files with 250 additions and 53 deletions

View file

@ -9,7 +9,8 @@ import {
roles, roles,
resourcePassword, resourcePassword,
resourcePincode, resourcePincode,
resourceWhitelist resourceWhitelist,
sites
} from "@server/db"; } from "@server/db";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@ -94,9 +95,11 @@ export async function getUserResources(
enabled: resources.enabled, enabled: resources.enabled,
sso: resources.sso, sso: resources.sso,
protocol: resources.protocol, protocol: resources.protocol,
emailWhitelistEnabled: resources.emailWhitelistEnabled emailWhitelistEnabled: resources.emailWhitelistEnabled,
siteName: sites.name
}) })
.from(resources) .from(resources)
.leftJoin(sites, eq(sites.siteId, resources.siteId))
.where( .where(
and( and(
inArray(resources.resourceId, accessibleResourceIds), inArray(resources.resourceId, accessibleResourceIds),
@ -124,7 +127,12 @@ export async function getUserResources(
domain: `${resource.ssl ? "https://" : "http://"}${resource.fullDomain}`, domain: `${resource.ssl ? "https://" : "http://"}${resource.fullDomain}`,
enabled: resource.enabled, enabled: resource.enabled,
protected: !!(resource.sso || hasPassword || hasPincode || hasWhitelist), protected: !!(resource.sso || hasPassword || hasPincode || hasWhitelist),
protocol: resource.protocol protocol: resource.protocol,
sso: resource.sso,
password: hasPassword,
pincode: hasPincode,
whitelist: hasWhitelist,
siteName: resource.siteName
}; };
}) })
); );

View file

@ -7,12 +7,20 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ExternalLink, Globe, ShieldCheck, Search, RefreshCw, AlertCircle, Plus, Shield, ShieldOff, ChevronLeft, ChevronRight } from "lucide-react"; import { ExternalLink, Globe, ShieldCheck, Search, RefreshCw, AlertCircle, Plus, Shield, ShieldOff, ChevronLeft, ChevronRight, Building2, Key, KeyRound, Fingerprint, AtSign, Copy, InfoIcon } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { createApiClient } from "@app/lib/api"; import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { GetUserResourcesResponse } from "@server/routers/resource/getUserResources"; import { GetUserResourcesResponse } from "@server/routers/resource/getUserResources";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { useToast } from "@app/hooks/useToast";
// Update Resource type to include site information
type Resource = { type Resource = {
resourceId: number; resourceId: number;
name: string; name: string;
@ -20,6 +28,13 @@ type Resource = {
enabled: boolean; enabled: boolean;
protected: boolean; protected: boolean;
protocol: string; protocol: string;
// Auth method fields
sso?: boolean;
password?: boolean;
pincode?: boolean;
whitelist?: boolean;
// Site information
siteName?: string | null;
}; };
type MemberResourcesPortalProps = { type MemberResourcesPortalProps = {
@ -66,30 +81,184 @@ const ResourceFavicon = ({ domain, enabled }: { domain: string; enabled: boolean
}; };
// Enhanced status badge component // Enhanced status badge component
const StatusBadge = ({ enabled, protected: isProtected }: { enabled: boolean; protected: boolean }) => { const StatusBadge = ({ enabled, protected: isProtected, resource }: { enabled: boolean; protected: boolean; resource: Resource }) => {
if (!enabled) { if (!enabled) {
return ( return (
<Badge variant="secondary" className="gap-1.5 bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-700"> <TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="h-7 w-7 rounded-full flex items-center justify-center bg-gray-100 dark:bg-gray-800">
<div className="h-2 w-2 bg-gray-400 dark:bg-gray-500 rounded-full"></div> <div className="h-2 w-2 bg-gray-400 dark:bg-gray-500 rounded-full"></div>
Disabled </div>
</Badge> </TooltipTrigger>
<TooltipContent>
<p>Resource Disabled</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
); );
} }
if (isProtected) { if (isProtected) {
return ( return (
<Badge variant="secondary" className="gap-1.5 bg-green-50 dark:bg-green-950 text-green-700 dark:text-green-300 border-green-200 dark:border-green-800"> <TooltipProvider>
<ShieldCheck className="h-3 w-3" /> <Tooltip>
Protected <TooltipTrigger>
</Badge> <div className="h-7 w-7 rounded-full flex items-center justify-center bg-green-50 dark:bg-green-950">
<ShieldCheck className="h-3.5 w-3.5 text-green-700 dark:text-green-300" />
</div>
</TooltipTrigger>
<TooltipContent className="flex flex-col gap-2">
<p className="font-medium">Protected Resource</p>
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">Authentication Methods:</p>
<div className="flex flex-col gap-1.5">
{resource.sso && (
<div className="flex items-center gap-1.5 text-sm">
<div className="h-6 w-6 rounded-full flex items-center justify-center bg-blue-50 dark:bg-blue-950">
<Key className="h-3 w-3 text-blue-700 dark:text-blue-300" />
</div>
<span>Single Sign-On (SSO)</span>
</div>
)}
{resource.password && (
<div className="flex items-center gap-1.5 text-sm">
<div className="h-6 w-6 rounded-full flex items-center justify-center bg-purple-50 dark:bg-purple-950">
<KeyRound className="h-3 w-3 text-purple-700 dark:text-purple-300" />
</div>
<span>Password Protected</span>
</div>
)}
{resource.pincode && (
<div className="flex items-center gap-1.5 text-sm">
<div className="h-6 w-6 rounded-full flex items-center justify-center bg-emerald-50 dark:bg-emerald-950">
<Fingerprint className="h-3 w-3 text-emerald-700 dark:text-emerald-300" />
</div>
<span>PIN Code</span>
</div>
)}
{resource.whitelist && (
<div className="flex items-center gap-1.5 text-sm">
<div className="h-6 w-6 rounded-full flex items-center justify-center bg-amber-50 dark:bg-amber-950">
<AtSign className="h-3 w-3 text-amber-700 dark:text-amber-300" />
</div>
<span>Email Whitelist</span>
</div>
)}
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
); );
} }
return ( return (
<Badge variant="secondary" className="gap-1.5 bg-orange-50 dark:bg-orange-950 text-orange-700 dark:text-orange-300 border-orange-200 dark:border-orange-800"> <div className="h-7 w-7 rounded-full flex items-center justify-center bg-orange-50 dark:bg-orange-950">
<ShieldOff className="h-3 w-3" /> <ShieldOff className="h-3.5 w-3.5 text-orange-700 dark:text-orange-300" />
Unprotected </div>
</Badge> );
};
// Resource Info component
const ResourceInfo = ({ resource }: { resource: Resource }) => {
const hasAuthMethods = resource.sso || resource.password || resource.pincode || resource.whitelist;
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="h-7 w-7 rounded-full flex items-center justify-center bg-muted hover:bg-muted/80 transition-colors">
<InfoIcon className="h-4 w-4 text-muted-foreground" />
</div>
</TooltipTrigger>
<TooltipContent className="flex flex-col gap-3 w-[280px] p-3 bg-card border-2">
{/* Site Information */}
{resource.siteName && (
<div>
<div className="text-xs font-medium mb-1.5">Site</div>
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-foreground shrink-0" />
<span className="text-sm">{resource.siteName}</span>
</div>
</div>
)}
{/* Authentication Methods */}
{hasAuthMethods && (
<div className={resource.siteName ? "border-t border-border pt-2" : ""}>
<div className="text-xs font-medium mb-1.5">Authentication Methods</div>
<div className="flex flex-col gap-1.5">
{resource.sso && (
<div className="flex items-center gap-2">
<div className="h-5 w-5 rounded-full flex items-center justify-center bg-blue-50/50 dark:bg-blue-950/50">
<Key className="h-3 w-3 text-blue-700 dark:text-blue-300" />
</div>
<span className="text-sm">Single Sign-On (SSO)</span>
</div>
)}
{resource.password && (
<div className="flex items-center gap-2">
<div className="h-5 w-5 rounded-full flex items-center justify-center bg-purple-50/50 dark:bg-purple-950/50">
<KeyRound className="h-3 w-3 text-purple-700 dark:text-purple-300" />
</div>
<span className="text-sm">Password Protected</span>
</div>
)}
{resource.pincode && (
<div className="flex items-center gap-2">
<div className="h-5 w-5 rounded-full flex items-center justify-center bg-emerald-50/50 dark:bg-emerald-950/50">
<Fingerprint className="h-3 w-3 text-emerald-700 dark:text-emerald-300" />
</div>
<span className="text-sm">PIN Code</span>
</div>
)}
{resource.whitelist && (
<div className="flex items-center gap-2">
<div className="h-5 w-5 rounded-full flex items-center justify-center bg-amber-50/50 dark:bg-amber-950/50">
<AtSign className="h-3 w-3 text-amber-700 dark:text-amber-300" />
</div>
<span className="text-sm">Email Whitelist</span>
</div>
)}
</div>
</div>
)}
{/* Resource Status - if disabled */}
{!resource.enabled && (
<div className={`${(resource.siteName || hasAuthMethods) ? "border-t border-border pt-2" : ""}`}>
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-destructive shrink-0" />
<span className="text-sm text-destructive">Resource Disabled</span>
</div>
</div>
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
// Site badge component
const SiteBadge = ({ resource }: { resource: Resource }) => {
if (!resource.siteName) {
return null;
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="h-7 w-7 rounded-full flex items-center justify-center bg-muted/60 dark:bg-muted/80">
<Building2 className="h-3.5 w-3.5 text-muted-foreground" />
</div>
</TooltipTrigger>
<TooltipContent>
<p>{resource.siteName}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
); );
}; };
@ -212,6 +381,7 @@ export default function MemberResourcesPortal({ orgId }: MemberResourcesPortalPr
const t = useTranslations(); const t = useTranslations();
const { env } = useEnvContext(); const { env } = useEnvContext();
const api = createApiClient({ env }); const api = createApiClient({ env });
const { toast } = useToast();
const [resources, setResources] = useState<Resource[]>([]); const [resources, setResources] = useState<Resource[]>([]);
const [filteredResources, setFilteredResources] = useState<Resource[]>([]); const [filteredResources, setFilteredResources] = useState<Resource[]>([]);
@ -477,53 +647,72 @@ export default function MemberResourcesPortal({ orgId }: MemberResourcesPortalPr
) : ( ) : (
<> <>
{/* Resources Grid */} {/* Resources Grid */}
<div className="grid gap-4 sm:gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 auto-cols-fr"> <div className="grid gap-5 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-4 auto-cols-fr">
{paginatedResources.map((resource) => ( {paginatedResources.map((resource) => (
<Card key={resource.resourceId} className="rounded-lg bg-card text-card-foreground hover:shadow-lg transition-all duration-200 border-2 hover:border-primary/20 dark:hover:border-primary/30 flex flex-col w-full group"> <Card key={resource.resourceId} className="rounded-lg bg-card text-card-foreground hover:shadow-lg transition-all duration-200 border-2 hover:border-primary/20 dark:hover:border-primary/30 flex flex-col w-full group min-h-[200px]">
<CardHeader className="pb-3"> <div className="p-6">
<div className="flex items-start justify-between"> <div className="flex items-center justify-between gap-3">
<CardTitle className="text-lg font-bold text-foreground truncate mr-2 group-hover:text-primary transition-colors"> <div className="flex items-center min-w-0 flex-1 gap-3 overflow-hidden">
<div className="flex-shrink-0">
<ResourceFavicon domain={resource.domain} enabled={resource.enabled} />
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger className="min-w-0 max-w-full">
<CardTitle className="text-lg font-bold text-foreground truncate group-hover:text-primary transition-colors">
{resource.name} {resource.name}
</CardTitle> </CardTitle>
<Badge variant="secondary" className="text-xs shrink-0 bg-muted/60 dark:bg-muted/80 text-muted-foreground"> </TooltipTrigger>
Your Site <TooltipContent>
</Badge> <p className="max-w-xs break-words">{resource.name}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div> </div>
</CardHeader>
<CardContent className="px-6 pb-6 flex-1 flex flex-col justify-between"> <div className="flex-shrink-0">
<div className="space-y-4"> <ResourceInfo resource={resource} />
{/* Resource URL with Favicon */} </div>
<div className="flex items-center space-x-2"> </div>
<ResourceFavicon domain={resource.domain} enabled={resource.enabled} />
<div className="flex items-center gap-2 mt-3">
<button <button
onClick={() => handleOpenResource(resource)} onClick={() => handleOpenResource(resource)}
className="text-sm text-blue-500 dark:text-blue-400 font-medium hover:underline text-left truncate transition-colors hover:text-blue-600 dark:hover:text-blue-300" className="text-sm text-muted-foreground font-medium text-left truncate flex-1"
disabled={!resource.enabled} disabled={!resource.enabled}
> >
{resource.domain.replace(/^https?:\/\//, '')} {resource.domain.replace(/^https?:\/\//, '')}
</button> </button>
</div> <Button
variant="ghost"
{/* Enhanced Status Badge */} size="icon"
<div className="flex items-center"> className="h-8 w-8"
<StatusBadge enabled={resource.enabled} protected={resource.protected} /> onClick={() => {
navigator.clipboard.writeText(resource.domain);
toast({
title: "Copied to clipboard",
description: "Resource URL has been copied to your clipboard.",
duration: 2000
});
}}
>
<Copy className="h-4 w-4" />
</Button>
</div> </div>
</div> </div>
{/* Open Resource Button */} <div className="p-6 pt-0 mt-auto">
<div className="mt-4">
<Button <Button
onClick={() => handleOpenResource(resource)} onClick={() => handleOpenResource(resource)}
className="w-full h-8 transition-all group-hover:shadow-sm" className="w-full h-9 transition-all group-hover:shadow-sm"
variant="outline" variant="outline"
size="sm" size="sm"
disabled={!resource.enabled} disabled={!resource.enabled}
> >
<ExternalLink className="h-3 w-3 mr-2" /> <ExternalLink className="h-3.5 w-3.5 mr-2" />
Open Resource Open Resource
</Button> </Button>
</div> </div>
</CardContent>
</Card> </Card>
))} ))}
</div> </div>