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,
resourcePassword,
resourcePincode,
resourceWhitelist
resourceWhitelist,
sites
} from "@server/db";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
@ -94,9 +95,11 @@ export async function getUserResources(
enabled: resources.enabled,
sso: resources.sso,
protocol: resources.protocol,
emailWhitelistEnabled: resources.emailWhitelistEnabled
emailWhitelistEnabled: resources.emailWhitelistEnabled,
siteName: sites.name
})
.from(resources)
.leftJoin(sites, eq(sites.siteId, resources.siteId))
.where(
and(
inArray(resources.resourceId, accessibleResourceIds),
@ -124,7 +127,12 @@ export async function getUserResources(
domain: `${resource.ssl ? "https://" : "http://"}${resource.fullDomain}`,
enabled: resource.enabled,
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 { Input } from "@/components/ui/input";
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 { useEnvContext } from "@app/hooks/useEnvContext";
import { GetUserResourcesResponse } from "@server/routers/resource/getUserResources";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { useToast } from "@app/hooks/useToast";
// Update Resource type to include site information
type Resource = {
resourceId: number;
name: string;
@ -20,6 +28,13 @@ type Resource = {
enabled: boolean;
protected: boolean;
protocol: string;
// Auth method fields
sso?: boolean;
password?: boolean;
pincode?: boolean;
whitelist?: boolean;
// Site information
siteName?: string | null;
};
type MemberResourcesPortalProps = {
@ -66,30 +81,184 @@ const ResourceFavicon = ({ domain, enabled }: { domain: string; enabled: boolean
};
// 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) {
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>
Disabled
</Badge>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Resource Disabled</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
if (isProtected) {
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">
<ShieldCheck className="h-3 w-3" />
Protected
</Badge>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<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 (
<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">
<ShieldOff className="h-3 w-3" />
Unprotected
</Badge>
<div className="h-7 w-7 rounded-full flex items-center justify-center bg-orange-50 dark:bg-orange-950">
<ShieldOff className="h-3.5 w-3.5 text-orange-700 dark:text-orange-300" />
</div>
);
};
// 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 { env } = useEnvContext();
const api = createApiClient({ env });
const { toast } = useToast();
const [resources, setResources] = useState<Resource[]>([]);
const [filteredResources, setFilteredResources] = useState<Resource[]>([]);
@ -477,53 +647,72 @@ export default function MemberResourcesPortal({ orgId }: MemberResourcesPortalPr
) : (
<>
{/* 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) => (
<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">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<CardTitle className="text-lg font-bold text-foreground truncate mr-2 group-hover:text-primary transition-colors">
<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]">
<div className="p-6">
<div className="flex items-center justify-between gap-3">
<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}
</CardTitle>
<Badge variant="secondary" className="text-xs shrink-0 bg-muted/60 dark:bg-muted/80 text-muted-foreground">
Your Site
</Badge>
</TooltipTrigger>
<TooltipContent>
<p className="max-w-xs break-words">{resource.name}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</CardHeader>
<CardContent className="px-6 pb-6 flex-1 flex flex-col justify-between">
<div className="space-y-4">
{/* Resource URL with Favicon */}
<div className="flex items-center space-x-2">
<ResourceFavicon domain={resource.domain} enabled={resource.enabled} />
<div className="flex-shrink-0">
<ResourceInfo resource={resource} />
</div>
</div>
<div className="flex items-center gap-2 mt-3">
<button
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}
>
{resource.domain.replace(/^https?:\/\//, '')}
</button>
</div>
{/* Enhanced Status Badge */}
<div className="flex items-center">
<StatusBadge enabled={resource.enabled} protected={resource.protected} />
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
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>
{/* Open Resource Button */}
<div className="mt-4">
<div className="p-6 pt-0 mt-auto">
<Button
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"
size="sm"
disabled={!resource.enabled}
>
<ExternalLink className="h-3 w-3 mr-2" />
<ExternalLink className="h-3.5 w-3.5 mr-2" />
Open Resource
</Button>
</div>
</CardContent>
</Card>
))}
</div>