diff --git a/server/routers/external.ts b/server/routers/external.ts index 6f0b04dc..5bae553e 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -233,6 +233,12 @@ authenticated.get( resource.listResources ); +authenticated.get( + "/org/:orgId/user-resources", + verifyOrgAccess, + resource.getUserResources +); + authenticated.get( "/org/:orgId/domains", verifyOrgAccess, diff --git a/server/routers/resource/getUserResources.ts b/server/routers/resource/getUserResources.ts new file mode 100644 index 00000000..681ec4d0 --- /dev/null +++ b/server/routers/resource/getUserResources.ts @@ -0,0 +1,168 @@ +import { Request, Response, NextFunction } from "express"; +import { db } from "@server/db"; +import { and, eq, or, inArray } from "drizzle-orm"; +import { + resources, + userResources, + roleResources, + userOrgs, + roles, + resourcePassword, + resourcePincode, + resourceWhitelist, + sites +} from "@server/db"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { response } from "@server/lib/response"; + +export async function getUserResources( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const { orgId } = req.params; + const userId = req.user?.userId; + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + // First get the user's role in the organization + const userOrgResult = await db + .select({ + roleId: userOrgs.roleId + }) + .from(userOrgs) + .where( + and( + eq(userOrgs.userId, userId), + eq(userOrgs.orgId, orgId) + ) + ) + .limit(1); + + if (userOrgResult.length === 0) { + return next( + createHttpError(HttpCode.FORBIDDEN, "User not in organization") + ); + } + + const userRoleId = userOrgResult[0].roleId; + + // Get resources accessible through direct assignment or role assignment + const directResourcesQuery = db + .select({ resourceId: userResources.resourceId }) + .from(userResources) + .where(eq(userResources.userId, userId)); + + const roleResourcesQuery = db + .select({ resourceId: roleResources.resourceId }) + .from(roleResources) + .where(eq(roleResources.roleId, userRoleId)); + + const [directResources, roleResourceResults] = await Promise.all([ + directResourcesQuery, + roleResourcesQuery + ]); + + // Combine all accessible resource IDs + const accessibleResourceIds = [ + ...directResources.map(r => r.resourceId), + ...roleResourceResults.map(r => r.resourceId) + ]; + + if (accessibleResourceIds.length === 0) { + return response(res, { + data: { resources: [] }, + success: true, + error: false, + message: "No resources found", + status: HttpCode.OK + }); + } + + // Get resource details for accessible resources + const resourcesData = await db + .select({ + resourceId: resources.resourceId, + name: resources.name, + fullDomain: resources.fullDomain, + ssl: resources.ssl, + enabled: resources.enabled, + sso: resources.sso, + protocol: resources.protocol, + emailWhitelistEnabled: resources.emailWhitelistEnabled, + siteName: sites.name + }) + .from(resources) + .leftJoin(sites, eq(sites.siteId, resources.siteId)) + .where( + and( + inArray(resources.resourceId, accessibleResourceIds), + eq(resources.orgId, orgId), + eq(resources.enabled, true) + ) + ); + + // Check for password, pincode, and whitelist protection for each resource + const resourcesWithAuth = await Promise.all( + resourcesData.map(async (resource) => { + const [passwordCheck, pincodeCheck, whitelistCheck] = await Promise.all([ + db.select().from(resourcePassword).where(eq(resourcePassword.resourceId, resource.resourceId)).limit(1), + db.select().from(resourcePincode).where(eq(resourcePincode.resourceId, resource.resourceId)).limit(1), + db.select().from(resourceWhitelist).where(eq(resourceWhitelist.resourceId, resource.resourceId)).limit(1) + ]); + + const hasPassword = passwordCheck.length > 0; + const hasPincode = pincodeCheck.length > 0; + const hasWhitelist = whitelistCheck.length > 0 || resource.emailWhitelistEnabled; + + return { + resourceId: resource.resourceId, + name: resource.name, + domain: `${resource.ssl ? "https://" : "http://"}${resource.fullDomain}`, + enabled: resource.enabled, + protected: !!(resource.sso || hasPassword || hasPincode || hasWhitelist), + protocol: resource.protocol, + sso: resource.sso, + password: hasPassword, + pincode: hasPincode, + whitelist: hasWhitelist, + siteName: resource.siteName + }; + }) + ); + + return response(res, { + data: { resources: resourcesWithAuth }, + success: true, + error: false, + message: "User resources retrieved successfully", + status: HttpCode.OK + }); + + } catch (error) { + console.error("Error fetching user resources:", error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "Internal server error") + ); + } +} + +export type GetUserResourcesResponse = { + success: boolean; + data: { + resources: Array<{ + resourceId: number; + name: string; + domain: string; + enabled: boolean; + protected: boolean; + protocol: string; + }>; + }; +}; \ No newline at end of file diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index 03c9ffbe..f97fcdf4 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -21,4 +21,5 @@ export * from "./getExchangeToken"; export * from "./createResourceRule"; export * from "./deleteResourceRule"; export * from "./listResourceRules"; -export * from "./updateResourceRule"; \ No newline at end of file +export * from "./updateResourceRule"; +export * from "./getUserResources"; \ No newline at end of file diff --git a/src/app/[orgId]/MemberResourcesPortal.tsx b/src/app/[orgId]/MemberResourcesPortal.tsx new file mode 100644 index 00000000..142d5516 --- /dev/null +++ b/src/app/[orgId]/MemberResourcesPortal.tsx @@ -0,0 +1,732 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useTranslations } from "next-intl"; +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; +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, 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; + domain: string; + enabled: boolean; + protected: boolean; + protocol: string; + // Auth method fields + sso?: boolean; + password?: boolean; + pincode?: boolean; + whitelist?: boolean; + // Site information + siteName?: string | null; +}; + +type MemberResourcesPortalProps = { + orgId: string; +}; + +// Favicon component with fallback +const ResourceFavicon = ({ domain, enabled }: { domain: string; enabled: boolean }) => { + const [faviconError, setFaviconError] = useState(false); + const [faviconLoaded, setFaviconLoaded] = useState(false); + + // Extract domain for favicon URL + const cleanDomain = domain.replace(/^https?:\/\//, '').split('/')[0]; + const faviconUrl = `https://www.google.com/s2/favicons?domain=${cleanDomain}&sz=32`; + + const handleFaviconLoad = () => { + setFaviconLoaded(true); + setFaviconError(false); + }; + + const handleFaviconError = () => { + setFaviconError(true); + setFaviconLoaded(false); + }; + + if (faviconError || !enabled) { + return ; + } + + return ( +
+ {!faviconLoaded && ( +
+ )} + {`${cleanDomain} +
+ ); +}; + +// Enhanced status badge component +const StatusBadge = ({ enabled, protected: isProtected, resource }: { enabled: boolean; protected: boolean; resource: Resource }) => { + if (!enabled) { + return ( + + + +
+
+
+
+ +

Resource Disabled

+
+
+
+ ); + } + + if (isProtected) { + return ( + + + +
+ +
+
+ +

Protected Resource

+
+

Authentication Methods:

+
+ {resource.sso && ( +
+
+ +
+ Single Sign-On (SSO) +
+ )} + {resource.password && ( +
+
+ +
+ Password Protected +
+ )} + {resource.pincode && ( +
+
+ +
+ PIN Code +
+ )} + {resource.whitelist && ( +
+
+ +
+ Email Whitelist +
+ )} +
+
+
+
+
+ ); + } + + return ( +
+ +
+ ); +}; + +// Resource Info component +const ResourceInfo = ({ resource }: { resource: Resource }) => { + const hasAuthMethods = resource.sso || resource.password || resource.pincode || resource.whitelist; + + return ( + + + +
+ +
+
+ + {/* Site Information */} + {resource.siteName && ( +
+
Site
+
+ + {resource.siteName} +
+
+ )} + + {/* Authentication Methods */} + {hasAuthMethods && ( +
+
Authentication Methods
+
+ {resource.sso && ( +
+
+ +
+ Single Sign-On (SSO) +
+ )} + {resource.password && ( +
+
+ +
+ Password Protected +
+ )} + {resource.pincode && ( +
+
+ +
+ PIN Code +
+ )} + {resource.whitelist && ( +
+
+ +
+ Email Whitelist +
+ )} +
+
+ )} + + {/* Resource Status - if disabled */} + {!resource.enabled && ( +
+
+ + Resource Disabled +
+
+ )} +
+
+
+ ); +}; + +// Site badge component +const SiteBadge = ({ resource }: { resource: Resource }) => { + if (!resource.siteName) { + return null; + } + + return ( + + + +
+ +
+
+ +

{resource.siteName}

+
+
+
+ ); +}; + +// Pagination component +const PaginationControls = ({ + currentPage, + totalPages, + onPageChange, + totalItems, + itemsPerPage +}: { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; + totalItems: number; + itemsPerPage: number; +}) => { + const startItem = (currentPage - 1) * itemsPerPage + 1; + const endItem = Math.min(currentPage * itemsPerPage, totalItems); + + if (totalPages <= 1) return null; + + return ( +
+
+ Showing {startItem}-{endItem} of {totalItems} resources +
+ +
+ + +
+ {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => { + // Show first page, last page, current page, and 2 pages around current + const showPage = + page === 1 || + page === totalPages || + Math.abs(page - currentPage) <= 1; + + const showEllipsis = + (page === 2 && currentPage > 4) || + (page === totalPages - 1 && currentPage < totalPages - 3); + + if (!showPage && !showEllipsis) return null; + + if (showEllipsis) { + return ( + + ... + + ); + } + + return ( + + ); + })} +
+ + +
+
+ ); +}; + +// Loading skeleton component +const ResourceCardSkeleton = () => ( + + +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+); + +export default function MemberResourcesPortal({ orgId }: MemberResourcesPortalProps) { + const t = useTranslations(); + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const { toast } = useToast(); + + const [resources, setResources] = useState([]); + const [filteredResources, setFilteredResources] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const [sortBy, setSortBy] = useState("name-asc"); + const [refreshing, setRefreshing] = useState(false); + + // Pagination state + const [currentPage, setCurrentPage] = useState(1); + const itemsPerPage = 12; // 3x4 grid on desktop + + const fetchUserResources = async (isRefresh = false) => { + try { + if (isRefresh) { + setRefreshing(true); + } else { + setLoading(true); + } + setError(null); + + const response = await api.get( + `/org/${orgId}/user-resources` + ); + + if (response.data.success) { + setResources(response.data.data.resources); + setFilteredResources(response.data.data.resources); + } else { + setError("Failed to load resources"); + } + } catch (err) { + console.error("Error fetching user resources:", err); + setError("Failed to load resources. Please check your connection and try again."); + } finally { + setLoading(false); + setRefreshing(false); + } + }; + + useEffect(() => { + fetchUserResources(); + }, [orgId, api]); + + // Filter and sort resources + useEffect(() => { + let filtered = resources.filter(resource => + resource.name.toLowerCase().includes(searchQuery.toLowerCase()) || + resource.domain.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + // Sort resources + filtered.sort((a, b) => { + switch (sortBy) { + case "name-asc": + return a.name.localeCompare(b.name); + case "name-desc": + return b.name.localeCompare(a.name); + case "domain-asc": + return a.domain.localeCompare(b.domain); + case "domain-desc": + return b.domain.localeCompare(a.domain); + case "status-enabled": + // Enabled first, then protected vs unprotected + if (a.enabled !== b.enabled) return b.enabled ? 1 : -1; + return b.protected ? 1 : -1; + case "status-disabled": + // Disabled first, then unprotected vs protected + if (a.enabled !== b.enabled) return a.enabled ? 1 : -1; + return a.protected ? 1 : -1; + default: + return a.name.localeCompare(b.name); + } + }); + + setFilteredResources(filtered); + + // Reset to first page when search/sort changes + setCurrentPage(1); + }, [resources, searchQuery, sortBy]); + + // Calculate pagination + const totalPages = Math.ceil(filteredResources.length / itemsPerPage); + const startIndex = (currentPage - 1) * itemsPerPage; + const paginatedResources = filteredResources.slice(startIndex, startIndex + itemsPerPage); + + const handleOpenResource = (resource: Resource) => { + // Open the resource in a new tab + window.open(resource.domain, '_blank'); + }; + + const handleRefresh = () => { + fetchUserResources(true); + }; + + const handleRetry = () => { + fetchUserResources(); + }; + + const handlePageChange = (page: number) => { + setCurrentPage(page); + // Scroll to top when page changes + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + if (loading) { + return ( +
+ + + {/* Search and Sort Controls - Skeleton */} +
+
+
+
+
+
+
+
+ + {/* Loading Skeletons */} +
+ {Array.from({ length: 12 }).map((_, index) => ( + + ))} +
+
+ ); + } + + if (error) { + return ( +
+ + + +
+ +
+

+ Unable to Load Resources +

+

+ {error} +

+ +
+
+
+ ); + } + + return ( +
+ + + {/* Search and Sort Controls with Refresh */} +
+
+ {/* Search */} +
+ setSearchQuery(e.target.value)} + className="w-full pl-8" + /> + +
+ + {/* Sort */} +
+ +
+
+ + {/* Refresh Button */} + +
+ + {/* Resources Content */} + {filteredResources.length === 0 ? ( + /* Enhanced Empty State */ + + +
+ {searchQuery ? ( + + ) : ( + + )} +
+

+ {searchQuery ? "No Resources Found" : "No Resources Available"} +

+

+ {searchQuery + ? `No resources match "${searchQuery}". Try adjusting your search terms or clearing the search to see all resources.` + : "You don't have access to any resources yet. Contact your administrator to get access to resources you need." + } +

+
+ {searchQuery ? ( + + ) : ( + + )} +
+
+
+ ) : ( + <> + {/* Resources Grid */} +
+ {paginatedResources.map((resource) => ( + +
+
+
+
+ +
+ + + + + {resource.name} + + + +

{resource.name}

+
+
+
+
+ +
+ +
+
+ +
+ + +
+
+ +
+ +
+
+ ))} +
+ + {/* Pagination Controls */} + + + )} +
+ ); +} \ No newline at end of file diff --git a/src/app/[orgId]/page.tsx b/src/app/[orgId]/page.tsx index d19a6dcc..9a1dda94 100644 --- a/src/app/[orgId]/page.tsx +++ b/src/app/[orgId]/page.tsx @@ -2,6 +2,7 @@ import { verifySession } from "@app/lib/auth/verifySession"; import UserProvider from "@app/providers/UserProvider"; import { cache } from "react"; import OrganizationLandingCard from "./OrganizationLandingCard"; +import MemberResourcesPortal from "./MemberResourcesPortal"; import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview"; import { internal } from "@app/lib/api"; import { AxiosResponse } from "axios"; @@ -9,6 +10,9 @@ import { authCookieHeader } from "@app/lib/api/cookies"; import { redirect } from "next/navigation"; import { Layout } from "@app/components/Layout"; import { ListUserOrgsResponse } from "@server/routers/org"; +import { pullEnv } from "@app/lib/pullEnv"; +import EnvProvider from "@app/providers/EnvProvider"; +import { orgLangingNavItems } from "@app/app/navigation"; type OrgPageProps = { params: Promise<{ orgId: string }>; @@ -17,6 +21,7 @@ type OrgPageProps = { export default async function OrgPage(props: OrgPageProps) { const params = await props.params; const orgId = params.orgId; + const env = pullEnv(); const getUser = cache(verifySession); const user = await getUser(); @@ -25,7 +30,6 @@ export default async function OrgPage(props: OrgPageProps) { redirect("/"); } - let redirectToSettings = false; let overview: GetOrgOverviewResponse | undefined; try { const res = await internal.get>( @@ -33,16 +37,14 @@ export default async function OrgPage(props: OrgPageProps) { await authCookieHeader() ); overview = res.data.data; - - if (overview.isAdmin || overview.isOwner) { - redirectToSettings = true; - } } catch (e) {} - if (redirectToSettings) { + // If user is admin or owner, redirect to settings + if (overview?.isAdmin || overview?.isOwner) { redirect(`/${orgId}/settings`); } + // For non-admin users, show the member resources portal let orgs: ListUserOrgsResponse["orgs"] = []; try { const getOrgs = cache(async () => @@ -61,21 +63,8 @@ export default async function OrgPage(props: OrgPageProps) { {overview && ( -
- +
+
)} diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index a18659f2..b8f60abd 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -12,15 +12,31 @@ import { KeyRound, TicketCheck, User, - Globe, - MonitorUp + Globe, // Added from 'dev' branch + MonitorUp // Added from 'dev' branch } from "lucide-react"; -export type SidebarNavSection = { +export type SidebarNavSection = { // Added from 'dev' branch heading: string; items: SidebarNavItem[]; }; +// Merged from 'user-management-and-resources' branch +export const orgLangingNavItems: SidebarNavItem[] = [ + { + title: "sidebarAccount", + href: "/{orgId}", + icon: , + autoExpand: true, + children: [ + { + title: "sidebarResources", + href: "/{orgId}" + } + ] + } +]; + export const orgNavSections = ( enableClients: boolean = true ): SidebarNavSection[] => [ @@ -125,4 +141,4 @@ export const adminNavSections: SidebarNavSection[] = [ : []) ] } -]; +]; \ No newline at end of file