mirror of
https://github.com/fosrl/pangolin.git
synced 2025-08-20 01:08:41 +02:00
Add member portal functionality - extracted from feature/member-landing-page
This commit is contained in:
parent
5c929badeb
commit
bcc2c59f08
6 changed files with 938 additions and 26 deletions
|
@ -233,6 +233,12 @@ authenticated.get(
|
||||||
resource.listResources
|
resource.listResources
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/user-resources",
|
||||||
|
verifyOrgAccess,
|
||||||
|
resource.getUserResources
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/domains",
|
"/org/:orgId/domains",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
|
|
168
server/routers/resource/getUserResources.ts
Normal file
168
server/routers/resource/getUserResources.ts
Normal file
|
@ -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<any> {
|
||||||
|
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;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
|
@ -22,3 +22,4 @@ export * from "./createResourceRule";
|
||||||
export * from "./deleteResourceRule";
|
export * from "./deleteResourceRule";
|
||||||
export * from "./listResourceRules";
|
export * from "./listResourceRules";
|
||||||
export * from "./updateResourceRule";
|
export * from "./updateResourceRule";
|
||||||
|
export * from "./getUserResources";
|
732
src/app/[orgId]/MemberResourcesPortal.tsx
Normal file
732
src/app/[orgId]/MemberResourcesPortal.tsx
Normal file
|
@ -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 <Globe className="h-4 w-4 text-muted-foreground flex-shrink-0" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-4 w-4 flex-shrink-0">
|
||||||
|
{!faviconLoaded && (
|
||||||
|
<div className="absolute inset-0 bg-muted animate-pulse rounded-sm"></div>
|
||||||
|
)}
|
||||||
|
<img
|
||||||
|
src={faviconUrl}
|
||||||
|
alt={`${cleanDomain} favicon`}
|
||||||
|
className={`h-4 w-4 rounded-sm transition-opacity ${faviconLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||||
|
onLoad={handleFaviconLoad}
|
||||||
|
onError={handleFaviconError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Enhanced status badge component
|
||||||
|
const StatusBadge = ({ enabled, protected: isProtected, resource }: { enabled: boolean; protected: boolean; resource: Resource }) => {
|
||||||
|
if (!enabled) {
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Resource Disabled</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isProtected) {
|
||||||
|
return (
|
||||||
|
<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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 mt-8">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Showing {startItem}-{endItem} of {totalItems} resources
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPageChange(currentPage - 1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{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 (
|
||||||
|
<span key={page} className="px-2 text-muted-foreground">
|
||||||
|
...
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={page}
|
||||||
|
variant={currentPage === page ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPageChange(page)}
|
||||||
|
className="w-8 h-8 p-0"
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPageChange(currentPage + 1)}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Loading skeleton component
|
||||||
|
const ResourceCardSkeleton = () => (
|
||||||
|
<Card className="rounded-lg bg-card text-card-foreground border-2 flex flex-col w-full animate-pulse">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="h-6 bg-muted rounded w-3/4"></div>
|
||||||
|
<div className="h-5 bg-muted rounded w-16"></div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-6 pb-6 flex-1 flex flex-col justify-between">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="h-4 w-4 bg-muted rounded"></div>
|
||||||
|
<div className="h-4 bg-muted rounded w-1/2"></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="h-4 w-4 bg-muted rounded"></div>
|
||||||
|
<div className="h-4 bg-muted rounded w-1/3"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="h-8 bg-muted rounded w-full"></div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function MemberResourcesPortal({ orgId }: MemberResourcesPortalProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const { env } = useEnvContext();
|
||||||
|
const api = createApiClient({ env });
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [resources, setResources] = useState<Resource[]>([]);
|
||||||
|
const [filteredResources, setFilteredResources] = useState<Resource[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(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<GetUserResourcesResponse>(
|
||||||
|
`/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 (
|
||||||
|
<div className="container mx-auto max-w-12xl">
|
||||||
|
<SettingsSectionTitle
|
||||||
|
title="Resources"
|
||||||
|
description="Resources you have access to in this organization"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Search and Sort Controls - Skeleton */}
|
||||||
|
<div className="mb-6 flex flex-col sm:flex-row gap-4 justify-start">
|
||||||
|
<div className="relative w-full sm:w-80">
|
||||||
|
<div className="h-10 bg-muted rounded animate-pulse"></div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full sm:w-36">
|
||||||
|
<div className="h-10 bg-muted rounded animate-pulse"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading Skeletons */}
|
||||||
|
<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">
|
||||||
|
{Array.from({ length: 12 }).map((_, index) => (
|
||||||
|
<ResourceCardSkeleton key={index} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto max-w-12xl">
|
||||||
|
<SettingsSectionTitle
|
||||||
|
title="Resources"
|
||||||
|
description="Resources you have access to in this organization"
|
||||||
|
/>
|
||||||
|
<Card className="border-destructive/50 bg-destructive/5 dark:bg-destructive/10">
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-20 text-center">
|
||||||
|
<div className="mb-6">
|
||||||
|
<AlertCircle className="h-16 w-16 text-destructive/60" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-semibold text-foreground mb-3">
|
||||||
|
Unable to Load Resources
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground max-w-lg text-base mb-6">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={handleRetry}
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
Try Again
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto max-w-12xl">
|
||||||
|
<SettingsSectionTitle
|
||||||
|
title="Resources"
|
||||||
|
description="Resources you have access to in this organization"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Search and Sort Controls with Refresh */}
|
||||||
|
<div className="mb-6 flex flex-col sm:flex-row gap-4 justify-between items-start">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 justify-start flex-1">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative w-full sm:w-80">
|
||||||
|
<Input
|
||||||
|
placeholder="Search resources..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full pl-8"
|
||||||
|
/>
|
||||||
|
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sort */}
|
||||||
|
<div className="w-full sm:w-36">
|
||||||
|
<Select value={sortBy} onValueChange={setSortBy}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Sort by..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="name-asc">Name A-Z</SelectItem>
|
||||||
|
<SelectItem value="name-desc">Name Z-A</SelectItem>
|
||||||
|
<SelectItem value="domain-asc">Domain A-Z</SelectItem>
|
||||||
|
<SelectItem value="domain-desc">Domain Z-A</SelectItem>
|
||||||
|
<SelectItem value="status-enabled">Enabled First</SelectItem>
|
||||||
|
<SelectItem value="status-disabled">Disabled First</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Refresh Button */}
|
||||||
|
<Button
|
||||||
|
onClick={handleRefresh}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={refreshing}
|
||||||
|
className="gap-2 shrink-0"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Resources Content */}
|
||||||
|
{filteredResources.length === 0 ? (
|
||||||
|
/* Enhanced Empty State */
|
||||||
|
<Card className="border-muted/50 bg-muted/5 dark:bg-muted/10">
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-20 text-center">
|
||||||
|
<div className="mb-8 p-4 rounded-full bg-muted/20 dark:bg-muted/30">
|
||||||
|
{searchQuery ? (
|
||||||
|
<Search className="h-12 w-12 text-muted-foreground/70" />
|
||||||
|
) : (
|
||||||
|
<Globe className="h-12 w-12 text-muted-foreground/70" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-2xl font-semibold text-foreground mb-3">
|
||||||
|
{searchQuery ? "No Resources Found" : "No Resources Available"}
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground max-w-lg text-base mb-6">
|
||||||
|
{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."
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
|
{searchQuery ? (
|
||||||
|
<Button
|
||||||
|
onClick={() => setSearchQuery("")}
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
Clear Search
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={handleRefresh}
|
||||||
|
variant="outline"
|
||||||
|
disabled={refreshing}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||||
|
Refresh Resources
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Resources Grid */}
|
||||||
|
<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 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>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p className="max-w-xs break-words">{resource.name}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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-muted-foreground font-medium text-left truncate flex-1"
|
||||||
|
disabled={!resource.enabled}
|
||||||
|
>
|
||||||
|
{resource.domain.replace(/^https?:\/\//, '')}
|
||||||
|
</button>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div className="p-6 pt-0 mt-auto">
|
||||||
|
<Button
|
||||||
|
onClick={() => handleOpenResource(resource)}
|
||||||
|
className="w-full h-9 transition-all group-hover:shadow-sm"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={!resource.enabled}
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5 mr-2" />
|
||||||
|
Open Resource
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination Controls */}
|
||||||
|
<PaginationControls
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
totalItems={filteredResources.length}
|
||||||
|
itemsPerPage={itemsPerPage}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -2,6 +2,7 @@ import { verifySession } from "@app/lib/auth/verifySession";
|
||||||
import UserProvider from "@app/providers/UserProvider";
|
import UserProvider from "@app/providers/UserProvider";
|
||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
import OrganizationLandingCard from "./OrganizationLandingCard";
|
import OrganizationLandingCard from "./OrganizationLandingCard";
|
||||||
|
import MemberResourcesPortal from "./MemberResourcesPortal";
|
||||||
import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview";
|
import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview";
|
||||||
import { internal } from "@app/lib/api";
|
import { internal } from "@app/lib/api";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
|
@ -9,6 +10,9 @@ import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { Layout } from "@app/components/Layout";
|
import { Layout } from "@app/components/Layout";
|
||||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
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 = {
|
type OrgPageProps = {
|
||||||
params: Promise<{ orgId: string }>;
|
params: Promise<{ orgId: string }>;
|
||||||
|
@ -17,6 +21,7 @@ type OrgPageProps = {
|
||||||
export default async function OrgPage(props: OrgPageProps) {
|
export default async function OrgPage(props: OrgPageProps) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const orgId = params.orgId;
|
const orgId = params.orgId;
|
||||||
|
const env = pullEnv();
|
||||||
|
|
||||||
const getUser = cache(verifySession);
|
const getUser = cache(verifySession);
|
||||||
const user = await getUser();
|
const user = await getUser();
|
||||||
|
@ -25,7 +30,6 @@ export default async function OrgPage(props: OrgPageProps) {
|
||||||
redirect("/");
|
redirect("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
let redirectToSettings = false;
|
|
||||||
let overview: GetOrgOverviewResponse | undefined;
|
let overview: GetOrgOverviewResponse | undefined;
|
||||||
try {
|
try {
|
||||||
const res = await internal.get<AxiosResponse<GetOrgOverviewResponse>>(
|
const res = await internal.get<AxiosResponse<GetOrgOverviewResponse>>(
|
||||||
|
@ -33,16 +37,14 @@ export default async function OrgPage(props: OrgPageProps) {
|
||||||
await authCookieHeader()
|
await authCookieHeader()
|
||||||
);
|
);
|
||||||
overview = res.data.data;
|
overview = res.data.data;
|
||||||
|
|
||||||
if (overview.isAdmin || overview.isOwner) {
|
|
||||||
redirectToSettings = true;
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
if (redirectToSettings) {
|
// If user is admin or owner, redirect to settings
|
||||||
|
if (overview?.isAdmin || overview?.isOwner) {
|
||||||
redirect(`/${orgId}/settings`);
|
redirect(`/${orgId}/settings`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For non-admin users, show the member resources portal
|
||||||
let orgs: ListUserOrgsResponse["orgs"] = [];
|
let orgs: ListUserOrgsResponse["orgs"] = [];
|
||||||
try {
|
try {
|
||||||
const getOrgs = cache(async () =>
|
const getOrgs = cache(async () =>
|
||||||
|
@ -61,21 +63,8 @@ export default async function OrgPage(props: OrgPageProps) {
|
||||||
<UserProvider user={user}>
|
<UserProvider user={user}>
|
||||||
<Layout orgId={orgId} navItems={[]} orgs={orgs}>
|
<Layout orgId={orgId} navItems={[]} orgs={orgs}>
|
||||||
{overview && (
|
{overview && (
|
||||||
<div className="w-full max-w-4xl mx-auto md:mt-32 mt-4">
|
<div className="w-full px-4 py-6">
|
||||||
<OrganizationLandingCard
|
<MemberResourcesPortal orgId={orgId} />
|
||||||
overview={{
|
|
||||||
orgId: overview.orgId,
|
|
||||||
orgName: overview.orgName,
|
|
||||||
stats: {
|
|
||||||
users: overview.numUsers,
|
|
||||||
sites: overview.numSites,
|
|
||||||
resources: overview.numResources
|
|
||||||
},
|
|
||||||
isAdmin: overview.isAdmin,
|
|
||||||
isOwner: overview.isOwner,
|
|
||||||
userRole: overview.userRoleName
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
|
@ -12,15 +12,31 @@ import {
|
||||||
KeyRound,
|
KeyRound,
|
||||||
TicketCheck,
|
TicketCheck,
|
||||||
User,
|
User,
|
||||||
Globe,
|
Globe, // Added from 'dev' branch
|
||||||
MonitorUp
|
MonitorUp // Added from 'dev' branch
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export type SidebarNavSection = {
|
export type SidebarNavSection = { // Added from 'dev' branch
|
||||||
heading: string;
|
heading: string;
|
||||||
items: SidebarNavItem[];
|
items: SidebarNavItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Merged from 'user-management-and-resources' branch
|
||||||
|
export const orgLangingNavItems: SidebarNavItem[] = [
|
||||||
|
{
|
||||||
|
title: "sidebarAccount",
|
||||||
|
href: "/{orgId}",
|
||||||
|
icon: <User className="h-4 w-4" />,
|
||||||
|
autoExpand: true,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
title: "sidebarResources",
|
||||||
|
href: "/{orgId}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
export const orgNavSections = (
|
export const orgNavSections = (
|
||||||
enableClients: boolean = true
|
enableClients: boolean = true
|
||||||
): SidebarNavSection[] => [
|
): SidebarNavSection[] => [
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue