mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-15 01:16:38 +00:00
Merge branch 'main' into copilot/fix-1112
This commit is contained in:
718
src/app/[orgId]/MemberResourcesPortal.tsx
Normal file
718
src/app/[orgId]/MemberResourcesPortal.tsx
Normal file
@@ -0,0 +1,718 @@
|
||||
"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 { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
ExternalLink,
|
||||
Globe,
|
||||
Search,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Key,
|
||||
KeyRound,
|
||||
Fingerprint,
|
||||
AtSign,
|
||||
Copy,
|
||||
InfoIcon,
|
||||
Combine
|
||||
} from "lucide-react";
|
||||
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";
|
||||
import { InfoPopup } from "@/components/ui/info-popup";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
// 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>
|
||||
);
|
||||
};
|
||||
|
||||
// Resource Info component
|
||||
const ResourceInfo = ({ resource }: { resource: Resource }) => {
|
||||
const hasAuthMethods =
|
||||
resource.sso ||
|
||||
resource.password ||
|
||||
resource.pincode ||
|
||||
resource.whitelist;
|
||||
|
||||
const infoContent = (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Site Information */}
|
||||
{resource.siteName && (
|
||||
<div>
|
||||
<div className="text-xs font-medium mb-1.5">Site</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Combine 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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return <InfoPopup>{infoContent}</InfoPopup>;
|
||||
};
|
||||
|
||||
// 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 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(() => {
|
||||
const 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>
|
||||
<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 bg-card"
|
||||
/>
|
||||
<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 className="bg-card">
|
||||
<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>
|
||||
<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}>
|
||||
<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">
|
||||
<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 text-muted-foreground"
|
||||
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 { 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<AxiosResponse<GetOrgOverviewResponse>>(
|
||||
@@ -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 () =>
|
||||
@@ -60,24 +62,7 @@ export default async function OrgPage(props: OrgPageProps) {
|
||||
return (
|
||||
<UserProvider user={user}>
|
||||
<Layout orgId={orgId} navItems={[]} orgs={orgs}>
|
||||
{overview && (
|
||||
<div className="w-full max-w-4xl mx-auto md:mt-32 mt-4">
|
||||
<OrganizationLandingCard
|
||||
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>
|
||||
)}
|
||||
{overview && <MemberResourcesPortal orgId={orgId} />}
|
||||
</Layout>
|
||||
</UserProvider>
|
||||
);
|
||||
|
||||
@@ -25,7 +25,6 @@ import { toast } from "@app/hooks/useToast";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import CreateClientFormModal from "./CreateClientsModal";
|
||||
|
||||
export type ClientRow = {
|
||||
id: number;
|
||||
@@ -76,42 +75,6 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) {
|
||||
};
|
||||
|
||||
const columns: ColumnDef<ClientRow>[] = [
|
||||
{
|
||||
id: "dots",
|
||||
cell: ({ row }) => {
|
||||
const clientRow = row.original;
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{/* <Link */}
|
||||
{/* className="block w-full" */}
|
||||
{/* href={`/${clientRow.orgId}/settings/sites/${clientRow.nice}`} */}
|
||||
{/* > */}
|
||||
{/* <DropdownMenuItem> */}
|
||||
{/* View settings */}
|
||||
{/* </DropdownMenuItem> */}
|
||||
{/* </Link> */}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedClient(clientRow);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => {
|
||||
@@ -243,6 +206,33 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) {
|
||||
const clientRow = row.original;
|
||||
return (
|
||||
<div className="flex items-center justify-end">
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{/* <Link */}
|
||||
{/* className="block w-full" */}
|
||||
{/* href={`/${clientRow.orgId}/settings/sites/${clientRow.nice}`} */}
|
||||
{/* > */}
|
||||
{/* <DropdownMenuItem> */}
|
||||
{/* View settings */}
|
||||
{/* </DropdownMenuItem> */}
|
||||
{/* </Link> */}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedClient(clientRow);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Link
|
||||
href={`/${clientRow.orgId}/settings/clients/${clientRow.id}`}
|
||||
>
|
||||
@@ -259,15 +249,6 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateClientFormModal
|
||||
open={isCreateModalOpen}
|
||||
setOpen={setIsCreateModalOpen}
|
||||
onCreate={(val) => {
|
||||
setRows([val, ...rows]);
|
||||
}}
|
||||
orgId={orgId}
|
||||
/>
|
||||
|
||||
{selectedClient && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
@@ -309,7 +290,7 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) {
|
||||
columns={columns}
|
||||
data={rows}
|
||||
addClient={() => {
|
||||
setIsCreateModalOpen(true);
|
||||
router.push(`/${orgId}/settings/clients/create`)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -1,349 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import CopyTextBox from "@app/components/CopyTextBox";
|
||||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { ClientRow } from "./ClientsTable";
|
||||
import {
|
||||
CreateClientBody,
|
||||
CreateClientResponse,
|
||||
PickClientDefaultsResponse
|
||||
} from "@server/routers/client";
|
||||
import { ListSitesResponse } from "@server/routers/site";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "@app/components/ui/command";
|
||||
import { ScrollArea } from "@app/components/ui/scroll-area";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { X } from "lucide-react";
|
||||
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||
|
||||
const createClientFormSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(2, {
|
||||
message: "Name must be at least 2 characters."
|
||||
})
|
||||
.max(30, {
|
||||
message: "Name must not be longer than 30 characters."
|
||||
}),
|
||||
siteIds: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
text: z.string()
|
||||
})
|
||||
)
|
||||
.refine((val) => val.length > 0, {
|
||||
message: "At least one site is required."
|
||||
}),
|
||||
subnet: z.string().min(1, {
|
||||
message: "Subnet is required."
|
||||
})
|
||||
});
|
||||
|
||||
type CreateClientFormValues = z.infer<typeof createClientFormSchema>;
|
||||
|
||||
const defaultValues: Partial<CreateClientFormValues> = {
|
||||
name: "",
|
||||
siteIds: [],
|
||||
subnet: ""
|
||||
};
|
||||
|
||||
type CreateClientFormProps = {
|
||||
onCreate?: (client: ClientRow) => void;
|
||||
setLoading?: (loading: boolean) => void;
|
||||
setChecked?: (checked: boolean) => void;
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
export default function CreateClientForm({
|
||||
onCreate,
|
||||
setLoading,
|
||||
setChecked,
|
||||
orgId
|
||||
}: CreateClientFormProps) {
|
||||
const api = createApiClient(useEnvContext());
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const [sites, setSites] = useState<Tag[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isChecked, setIsChecked] = useState(false);
|
||||
const [clientDefaults, setClientDefaults] =
|
||||
useState<PickClientDefaultsResponse | null>(null);
|
||||
const [olmCommand, setOlmCommand] = useState<string | null>(null);
|
||||
const [selectedSites, setSelectedSites] = useState<
|
||||
Array<{ id: number; name: string }>
|
||||
>([]);
|
||||
const [activeSitesTagIndex, setActiveSitesTagIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
const handleCheckboxChange = (checked: boolean) => {
|
||||
setIsChecked(checked);
|
||||
if (setChecked) {
|
||||
setChecked(checked);
|
||||
}
|
||||
};
|
||||
|
||||
const form = useForm<CreateClientFormValues>({
|
||||
resolver: zodResolver(createClientFormSchema),
|
||||
defaultValues
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
// reset all values
|
||||
setLoading?.(false);
|
||||
setIsLoading(false);
|
||||
form.reset();
|
||||
setChecked?.(false);
|
||||
setClientDefaults(null);
|
||||
setSelectedSites([]);
|
||||
|
||||
const fetchSites = async () => {
|
||||
const res = await api.get<AxiosResponse<ListSitesResponse>>(
|
||||
`/org/${orgId}/sites/`
|
||||
);
|
||||
const sites = res.data.data.sites.filter(
|
||||
(s) => s.type === "newt" && s.subnet
|
||||
);
|
||||
setSites(
|
||||
sites.map((site) => ({
|
||||
id: site.siteId.toString(),
|
||||
text: site.name
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
const fetchDefaults = async () => {
|
||||
api.get(`/org/${orgId}/pick-client-defaults`)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: `Error fetching client defaults`,
|
||||
description: formatAxiosError(e)
|
||||
});
|
||||
})
|
||||
.then((res) => {
|
||||
if (res && res.status === 200) {
|
||||
const data = res.data.data;
|
||||
setClientDefaults(data);
|
||||
const olmConfig = `olm --id ${data?.olmId} --secret ${data?.olmSecret} --endpoint ${env.app.dashboardUrl}`;
|
||||
setOlmCommand(olmConfig);
|
||||
|
||||
// Set the subnet value from client defaults
|
||||
if (data?.subnet) {
|
||||
form.setValue("subnet", data.subnet);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
fetchSites();
|
||||
fetchDefaults();
|
||||
}, [open]);
|
||||
|
||||
async function onSubmit(data: CreateClientFormValues) {
|
||||
setLoading?.(true);
|
||||
setIsLoading(true);
|
||||
|
||||
if (!clientDefaults) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error creating client",
|
||||
description: "Client defaults not found"
|
||||
});
|
||||
setLoading?.(false);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: data.name,
|
||||
siteIds: data.siteIds.map((site) => parseInt(site.id)),
|
||||
olmId: clientDefaults.olmId,
|
||||
secret: clientDefaults.olmSecret,
|
||||
subnet: data.subnet,
|
||||
type: "olm"
|
||||
} as CreateClientBody;
|
||||
|
||||
const res = await api
|
||||
.put<
|
||||
AxiosResponse<CreateClientResponse>
|
||||
>(`/org/${orgId}/client`, payload)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error creating client",
|
||||
description: formatAxiosError(e)
|
||||
});
|
||||
});
|
||||
|
||||
if (res && res.status === 201) {
|
||||
const data = res.data.data;
|
||||
|
||||
onCreate?.({
|
||||
name: data.name,
|
||||
id: data.clientId,
|
||||
subnet: data.subnet,
|
||||
mbIn: "0 MB",
|
||||
mbOut: "0 MB",
|
||||
orgId: orgId as string,
|
||||
online: false
|
||||
});
|
||||
}
|
||||
|
||||
setLoading?.(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="create-client-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
placeholder="Client name"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="subnet"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
placeholder="Subnet"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The address that this client will use for
|
||||
connectivity.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="siteIds"
|
||||
render={(field) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>Sites</FormLabel>
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={activeSitesTagIndex}
|
||||
setActiveTagIndex={setActiveSitesTagIndex}
|
||||
placeholder="Select sites"
|
||||
size="sm"
|
||||
tags={form.getValues().siteIds}
|
||||
setTags={(newTags) => {
|
||||
form.setValue(
|
||||
"siteIds",
|
||||
newTags as [Tag, ...Tag[]]
|
||||
);
|
||||
}}
|
||||
enableAutocomplete={true}
|
||||
autocompleteOptions={sites}
|
||||
allowDuplicates={false}
|
||||
restrictTagsToAutocompleteOptions={true}
|
||||
sortTags={true}
|
||||
/>
|
||||
<FormDescription>
|
||||
The client will have connectivity to the
|
||||
selected sites. The sites must be configured
|
||||
to accept client connections.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{olmCommand && (
|
||||
<div className="w-full">
|
||||
<div className="mb-2">
|
||||
<div className="mx-auto">
|
||||
<CopyTextBox
|
||||
text={olmCommand}
|
||||
wrapText={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
You will only be able to see the configuration
|
||||
once.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="terms"
|
||||
checked={isChecked}
|
||||
onCheckedChange={handleCheckboxChange}
|
||||
/>
|
||||
<label
|
||||
htmlFor="terms"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
I have copied the config
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import CreateClientForm from "./CreateClientsForm";
|
||||
import { ClientRow } from "./ClientsTable";
|
||||
|
||||
type CreateClientFormProps = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
onCreate?: (client: ClientRow) => void;
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
export default function CreateClientFormModal({
|
||||
open,
|
||||
setOpen,
|
||||
onCreate,
|
||||
orgId
|
||||
}: CreateClientFormProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isChecked, setIsChecked] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Credenza
|
||||
open={open}
|
||||
onOpenChange={(val) => {
|
||||
setOpen(val);
|
||||
setLoading(false);
|
||||
}}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>Create Client</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
Create a new client to connect to your sites
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<div className="max-w-md">
|
||||
<CreateClientForm
|
||||
setLoading={(val) => setLoading(val)}
|
||||
setChecked={(val) => setIsChecked(val)}
|
||||
onCreate={onCreate}
|
||||
orgId={orgId}
|
||||
/>
|
||||
</div>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
form="create-client-form"
|
||||
loading={loading}
|
||||
disabled={loading || !isChecked}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Create Client
|
||||
</Button>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</CredenzaClose>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -9,38 +9,40 @@ import {
|
||||
InfoSections,
|
||||
InfoSectionTitle
|
||||
} from "@app/components/InfoSection";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type ClientInfoCardProps = {};
|
||||
|
||||
export default function SiteInfoCard({}: ClientInfoCardProps) {
|
||||
const { client, updateClient } = useClientContext();
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<Alert>
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">Client Information</AlertTitle>
|
||||
<AlertTitle className="font-semibold">{t("clientInformation")}</AlertTitle>
|
||||
<AlertDescription className="mt-4">
|
||||
<InfoSections cols={2}>
|
||||
<>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>Status</InfoSectionTitle>
|
||||
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{client.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>
|
||||
<span>{t("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>
|
||||
<span>{t("offline")}</span>
|
||||
</div>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>Address</InfoSectionTitle>
|
||||
<InfoSectionTitle>{t("address")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{client.subnet.split("/")[0]}
|
||||
</InfoSectionContent>
|
||||
|
||||
@@ -34,6 +34,7 @@ import { useEffect, useState } from "react";
|
||||
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { ListSitesResponse } from "@server/routers/site";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const GeneralFormSchema = z.object({
|
||||
name: z.string().nonempty("Name is required"),
|
||||
@@ -48,6 +49,7 @@ const GeneralFormSchema = z.object({
|
||||
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
||||
|
||||
export default function GeneralPage() {
|
||||
const t = useTranslations();
|
||||
const { client, updateClient } = useClientContext();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -119,18 +121,18 @@ export default function GeneralPage() {
|
||||
updateClient({ name: data.name });
|
||||
|
||||
toast({
|
||||
title: "Client updated",
|
||||
description: "The client has been updated."
|
||||
title: t("clientUpdated"),
|
||||
description: t("clientUpdatedDescription")
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to update client",
|
||||
title: t("clientUpdateFailed"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while updating the client."
|
||||
t("clientUpdateError")
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
@@ -143,10 +145,10 @@ export default function GeneralPage() {
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
General Settings
|
||||
{t("generalSettings")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Configure the general settings for this client
|
||||
{t("generalSettingsDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
@@ -163,15 +165,11 @@ export default function GeneralPage() {
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormLabel>{t("name")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
This is the display name of the
|
||||
client.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -181,12 +179,12 @@ export default function GeneralPage() {
|
||||
name="siteIds"
|
||||
render={(field) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>Sites</FormLabel>
|
||||
<FormLabel>{t("sites")}</FormLabel>
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={activeSitesTagIndex}
|
||||
setActiveTagIndex={setActiveSitesTagIndex}
|
||||
placeholder="Select sites"
|
||||
placeholder={t("selectSites")}
|
||||
size="sm"
|
||||
tags={form.getValues().siteIds}
|
||||
setTags={(newTags) => {
|
||||
@@ -202,9 +200,7 @@ export default function GeneralPage() {
|
||||
sortTags={true}
|
||||
/>
|
||||
<FormDescription>
|
||||
The client will have connectivity to the
|
||||
selected sites. The sites must be configured
|
||||
to accept client connections.
|
||||
{t("sitesDescription")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -222,7 +218,7 @@ export default function GeneralPage() {
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
Save Settings
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
|
||||
708
src/app/[orgId]/settings/clients/create/page.tsx
Normal file
708
src/app/[orgId]/settings/clients/create/page.tsx
Normal file
@@ -0,0 +1,708 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { StrategySelect } from "@app/components/StrategySelect";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
||||
import { z } from "zod";
|
||||
import { createElement, useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { InfoIcon, Terminal } from "lucide-react";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import CopyTextBox from "@app/components/CopyTextBox";
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import {
|
||||
InfoSection,
|
||||
InfoSectionContent,
|
||||
InfoSections,
|
||||
InfoSectionTitle
|
||||
} from "@app/components/InfoSection";
|
||||
import {
|
||||
FaApple,
|
||||
FaCubes,
|
||||
FaDocker,
|
||||
FaFreebsd,
|
||||
FaWindows
|
||||
} from "react-icons/fa";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import {
|
||||
CreateClientBody,
|
||||
CreateClientResponse,
|
||||
PickClientDefaultsResponse
|
||||
} from "@server/routers/client";
|
||||
import { ListSitesResponse } from "@server/routers/site";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type ClientType = "olm";
|
||||
|
||||
interface TunnelTypeOption {
|
||||
id: ClientType;
|
||||
title: string;
|
||||
description: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
type Commands = {
|
||||
mac: Record<string, string[]>;
|
||||
linux: Record<string, string[]>;
|
||||
windows: Record<string, string[]>;
|
||||
};
|
||||
|
||||
const platforms = ["linux", "mac", "windows"] as const;
|
||||
|
||||
type Platform = (typeof platforms)[number];
|
||||
|
||||
export default function Page() {
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const { orgId } = useParams();
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const createClientFormSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(2, { message: t("nameMin", { len: 2 }) })
|
||||
.max(30, { message: t("nameMax", { len: 30 }) }),
|
||||
method: z.enum(["olm"]),
|
||||
siteIds: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
text: z.string()
|
||||
})
|
||||
)
|
||||
.refine((val) => val.length > 0, {
|
||||
message: t("siteRequired")
|
||||
}),
|
||||
subnet: z.string().ip().min(1, {
|
||||
message: t("subnetRequired")
|
||||
})
|
||||
});
|
||||
|
||||
type CreateClientFormValues = z.infer<typeof createClientFormSchema>;
|
||||
|
||||
const [tunnelTypes, setTunnelTypes] = useState<
|
||||
ReadonlyArray<TunnelTypeOption>
|
||||
>([
|
||||
{
|
||||
id: "olm",
|
||||
title: t("olmTunnel"),
|
||||
description: t("olmTunnelDescription"),
|
||||
disabled: true
|
||||
}
|
||||
]);
|
||||
|
||||
const [loadingPage, setLoadingPage] = useState(true);
|
||||
const [sites, setSites] = useState<Tag[]>([]);
|
||||
const [activeSitesTagIndex, setActiveSitesTagIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
const [platform, setPlatform] = useState<Platform>("linux");
|
||||
const [architecture, setArchitecture] = useState("amd64");
|
||||
const [commands, setCommands] = useState<Commands | null>(null);
|
||||
|
||||
const [olmId, setOlmId] = useState("");
|
||||
const [olmSecret, setOlmSecret] = useState("");
|
||||
const [olmCommand, setOlmCommand] = useState("");
|
||||
|
||||
const [createLoading, setCreateLoading] = useState(false);
|
||||
|
||||
const [clientDefaults, setClientDefaults] =
|
||||
useState<PickClientDefaultsResponse | null>(null);
|
||||
|
||||
const hydrateCommands = (
|
||||
id: string,
|
||||
secret: string,
|
||||
endpoint: string,
|
||||
version: string
|
||||
) => {
|
||||
const commands = {
|
||||
mac: {
|
||||
"Apple Silicon (arm64)": [
|
||||
`curl -L -o olm "https://github.com/fosrl/olm/releases/download/${version}/olm_darwin_arm64" && chmod +x ./olm`,
|
||||
`sudo ./olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
],
|
||||
"Intel x64 (amd64)": [
|
||||
`curl -L -o olm "https://github.com/fosrl/olm/releases/download/${version}/olm_darwin_amd64" && chmod +x ./olm`,
|
||||
`sudo ./olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
]
|
||||
},
|
||||
linux: {
|
||||
amd64: [
|
||||
`wget -O olm "https://github.com/fosrl/olm/releases/download/${version}/olm_linux_amd64" && chmod +x ./olm`,
|
||||
`sudo ./olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
],
|
||||
arm64: [
|
||||
`wget -O olm "https://github.com/fosrl/olm/releases/download/${version}/olm_linux_arm64" && chmod +x ./olm`,
|
||||
`sudo ./olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
],
|
||||
arm32: [
|
||||
`wget -O olm "https://github.com/fosrl/olm/releases/download/${version}/olm_linux_arm32" && chmod +x ./olm`,
|
||||
`sudo ./olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
],
|
||||
arm32v6: [
|
||||
`wget -O olm "https://github.com/fosrl/olm/releases/download/${version}/olm_linux_arm32v6" && chmod +x ./olm`,
|
||||
`sudo ./olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
],
|
||||
riscv64: [
|
||||
`wget -O olm "https://github.com/fosrl/olm/releases/download/${version}/olm_linux_riscv64" && chmod +x ./olm`,
|
||||
`sudo ./olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
]
|
||||
},
|
||||
windows: {
|
||||
x64: [
|
||||
`curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/olm_windows_installer.exe"`,
|
||||
`# Run the installer to install olm and wintun`,
|
||||
`olm.exe --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
]
|
||||
}
|
||||
};
|
||||
setCommands(commands);
|
||||
};
|
||||
|
||||
const getArchitectures = () => {
|
||||
switch (platform) {
|
||||
case "linux":
|
||||
return ["amd64", "arm64", "arm32", "arm32v6", "riscv64"];
|
||||
case "mac":
|
||||
return ["Apple Silicon (arm64)", "Intel x64 (amd64)"];
|
||||
case "windows":
|
||||
return ["x64"];
|
||||
default:
|
||||
return ["x64"];
|
||||
}
|
||||
};
|
||||
|
||||
const getPlatformName = (platformName: string) => {
|
||||
switch (platformName) {
|
||||
case "windows":
|
||||
return "Windows";
|
||||
case "mac":
|
||||
return "macOS";
|
||||
case "docker":
|
||||
return "Docker";
|
||||
default:
|
||||
return "Linux";
|
||||
}
|
||||
};
|
||||
|
||||
const getCommand = () => {
|
||||
const placeholder = [t("unknownCommand")];
|
||||
if (!commands) {
|
||||
return placeholder;
|
||||
}
|
||||
let platformCommands = commands[platform as keyof Commands];
|
||||
|
||||
if (!platformCommands) {
|
||||
// get first key
|
||||
const firstPlatform = Object.keys(commands)[0] as Platform;
|
||||
platformCommands = commands[firstPlatform as keyof Commands];
|
||||
|
||||
setPlatform(firstPlatform);
|
||||
}
|
||||
|
||||
let architectureCommands = platformCommands[architecture];
|
||||
if (!architectureCommands) {
|
||||
// get first key
|
||||
const firstArchitecture = Object.keys(platformCommands)[0];
|
||||
architectureCommands = platformCommands[firstArchitecture];
|
||||
|
||||
setArchitecture(firstArchitecture);
|
||||
}
|
||||
|
||||
return architectureCommands || placeholder;
|
||||
};
|
||||
|
||||
const getPlatformIcon = (platformName: string) => {
|
||||
switch (platformName) {
|
||||
case "windows":
|
||||
return <FaWindows className="h-4 w-4 mr-2" />;
|
||||
case "mac":
|
||||
return <FaApple className="h-4 w-4 mr-2" />;
|
||||
case "docker":
|
||||
return <FaDocker className="h-4 w-4 mr-2" />;
|
||||
case "podman":
|
||||
return <FaCubes className="h-4 w-4 mr-2" />;
|
||||
case "freebsd":
|
||||
return <FaFreebsd className="h-4 w-4 mr-2" />;
|
||||
default:
|
||||
return <Terminal className="h-4 w-4 mr-2" />;
|
||||
}
|
||||
};
|
||||
|
||||
const form = useForm<CreateClientFormValues>({
|
||||
resolver: zodResolver(createClientFormSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
method: "olm",
|
||||
siteIds: [],
|
||||
subnet: ""
|
||||
}
|
||||
});
|
||||
|
||||
async function onSubmit(data: CreateClientFormValues) {
|
||||
setCreateLoading(true);
|
||||
|
||||
if (!clientDefaults) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("errorCreatingClient"),
|
||||
description: t("clientDefaultsNotFound")
|
||||
});
|
||||
setCreateLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let payload: CreateClientBody = {
|
||||
name: data.name,
|
||||
type: data.method as "olm",
|
||||
siteIds: data.siteIds.map((site) => parseInt(site.id)),
|
||||
olmId: clientDefaults.olmId,
|
||||
secret: clientDefaults.olmSecret,
|
||||
subnet: data.subnet
|
||||
};
|
||||
|
||||
const res = await api
|
||||
.put<
|
||||
AxiosResponse<CreateClientResponse>
|
||||
>(`/org/${orgId}/client`, payload)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("errorCreatingClient"),
|
||||
description: formatAxiosError(e)
|
||||
});
|
||||
});
|
||||
|
||||
if (res && res.status === 201) {
|
||||
const data = res.data.data;
|
||||
router.push(`/${orgId}/settings/clients/${data.clientId}`);
|
||||
}
|
||||
|
||||
setCreateLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setLoadingPage(true);
|
||||
|
||||
// Fetch available sites
|
||||
|
||||
const res = await api.get<AxiosResponse<ListSitesResponse>>(
|
||||
`/org/${orgId}/sites/`
|
||||
);
|
||||
const sites = res.data.data.sites.filter(
|
||||
(s) => s.type === "newt" && s.subnet
|
||||
);
|
||||
setSites(
|
||||
sites.map((site) => ({
|
||||
id: site.siteId.toString(),
|
||||
text: site.name
|
||||
}))
|
||||
);
|
||||
|
||||
let olmVersion = "latest";
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://api.github.com/repos/fosrl/olm/releases/latest`
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
t("olmErrorFetchReleases", {
|
||||
err: response.statusText
|
||||
})
|
||||
);
|
||||
}
|
||||
const data = await response.json();
|
||||
const latestVersion = data.tag_name;
|
||||
olmVersion = latestVersion;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
t("olmErrorFetchLatest", {
|
||||
err:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: String(error)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await api
|
||||
.get(`/org/${orgId}/pick-client-defaults`)
|
||||
.catch((e) => {
|
||||
form.setValue("method", "olm");
|
||||
})
|
||||
.then((res) => {
|
||||
if (res && res.status === 200) {
|
||||
const data = res.data.data;
|
||||
|
||||
setClientDefaults(data);
|
||||
|
||||
const olmId = data.olmId;
|
||||
const olmSecret = data.olmSecret;
|
||||
const olmCommand = `olm --id ${olmId} --secret ${olmSecret} --endpoint ${env.app.dashboardUrl}`;
|
||||
|
||||
setOlmId(olmId);
|
||||
setOlmSecret(olmSecret);
|
||||
setOlmCommand(olmCommand);
|
||||
|
||||
hydrateCommands(
|
||||
olmId,
|
||||
olmSecret,
|
||||
env.app.dashboardUrl,
|
||||
olmVersion
|
||||
);
|
||||
|
||||
if (data.subnet) {
|
||||
form.setValue("subnet", data.subnet);
|
||||
}
|
||||
|
||||
setTunnelTypes((prev: any) => {
|
||||
return prev.map((item: any) => {
|
||||
return { ...item, disabled: false };
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
setLoadingPage(false);
|
||||
};
|
||||
|
||||
load();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<HeaderTitle
|
||||
title={t("createClient")}
|
||||
description={t("createClientDescription")}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
router.push(`/${orgId}/settings/clients`);
|
||||
}}
|
||||
>
|
||||
{t("seeAllClients")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!loadingPage && (
|
||||
<div>
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("clientInformation")}
|
||||
</SettingsSectionTitle>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-4"
|
||||
id="create-client-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("name")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="subnet"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("address")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
placeholder={t("subnetPlaceholder")}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t("addressDescription")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="siteIds"
|
||||
render={(field) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>
|
||||
{t("sites")}
|
||||
</FormLabel>
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={
|
||||
activeSitesTagIndex
|
||||
}
|
||||
setActiveTagIndex={
|
||||
setActiveSitesTagIndex
|
||||
}
|
||||
placeholder={t("selectSites")}
|
||||
size="sm"
|
||||
tags={
|
||||
form.getValues()
|
||||
.siteIds
|
||||
}
|
||||
setTags={(
|
||||
olmags
|
||||
) => {
|
||||
form.setValue(
|
||||
"siteIds",
|
||||
olmags as [
|
||||
Tag,
|
||||
...Tag[]
|
||||
]
|
||||
);
|
||||
}}
|
||||
enableAutocomplete={
|
||||
true
|
||||
}
|
||||
autocompleteOptions={
|
||||
sites
|
||||
}
|
||||
allowDuplicates={
|
||||
false
|
||||
}
|
||||
restrictTagsToAutocompleteOptions={
|
||||
true
|
||||
}
|
||||
sortTags={true}
|
||||
/>
|
||||
<FormDescription>
|
||||
{t("sitesDescription")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
{form.watch("method") === "olm" && (
|
||||
<>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("clientOlmCredentials")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("clientOlmCredentialsDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<InfoSections cols={3}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("olmEndpoint")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CopyToClipboard
|
||||
text={
|
||||
env.app.dashboardUrl
|
||||
}
|
||||
/>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("olmId")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CopyToClipboard
|
||||
text={olmId}
|
||||
/>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("olmSecretKey")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CopyToClipboard
|
||||
text={olmSecret}
|
||||
/>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</InfoSections>
|
||||
|
||||
<Alert variant="neutral" className="">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
{t("clientCredentialsSave")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t(
|
||||
"clientCredentialsSaveDescription"
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("clientInstallOlm")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("clientInstallOlmDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<div>
|
||||
<p className="font-bold mb-3">
|
||||
{t("operatingSystem")}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
{platforms.map((os) => (
|
||||
<Button
|
||||
key={os}
|
||||
variant={
|
||||
platform === os
|
||||
? "squareOutlinePrimary"
|
||||
: "squareOutline"
|
||||
}
|
||||
className={`flex-1 min-w-[120px] ${platform === os ? "bg-primary/10" : ""} shadow-none`}
|
||||
onClick={() => {
|
||||
setPlatform(os);
|
||||
}}
|
||||
>
|
||||
{getPlatformIcon(os)}
|
||||
{getPlatformName(os)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="font-bold mb-3">
|
||||
{["docker", "podman"].includes(
|
||||
platform
|
||||
)
|
||||
? t("method")
|
||||
: t("architecture")}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
{getArchitectures().map(
|
||||
(arch) => (
|
||||
<Button
|
||||
key={arch}
|
||||
variant={
|
||||
architecture ===
|
||||
arch
|
||||
? "squareOutlinePrimary"
|
||||
: "squareOutline"
|
||||
}
|
||||
className={`flex-1 min-w-[120px] ${architecture === arch ? "bg-primary/10" : ""} shadow-none`}
|
||||
onClick={() =>
|
||||
setArchitecture(
|
||||
arch
|
||||
)
|
||||
}
|
||||
>
|
||||
{arch}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div className="pt-4">
|
||||
<p className="font-bold mb-3">
|
||||
{t("commands")}
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<CopyTextBox
|
||||
text={getCommand().join(
|
||||
"\n"
|
||||
)}
|
||||
outline={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
</>
|
||||
)}
|
||||
</SettingsContainer>
|
||||
|
||||
<div className="flex justify-end space-x-2 mt-8">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
router.push(`/${orgId}/settings/clients`);
|
||||
}}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
loading={createLoading}
|
||||
disabled={createLoading}
|
||||
onClick={() => {
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
>
|
||||
{t("createClient")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -48,7 +48,7 @@ export default async function ClientsPage(props: ClientsPageProps) {
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title="Manage Clients"
|
||||
title="Manage Clients (beta)"
|
||||
description="Clients are devices that can connect to your sites"
|
||||
/>
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RotateCw } from "lucide-react";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { build } from "@server/build";
|
||||
|
||||
type ResourceInfoBoxType = {};
|
||||
|
||||
@@ -34,7 +35,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
<Alert>
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
{t('resourceInfo')}
|
||||
{t("resourceInfo")}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="mt-4">
|
||||
<InfoSections cols={4}>
|
||||
@@ -42,7 +43,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
<>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t('authentication')}
|
||||
{t("authentication")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{authInfo.password ||
|
||||
@@ -51,12 +52,12 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
authInfo.whitelist ? (
|
||||
<div className="flex items-start space-x-2 text-green-500">
|
||||
<ShieldCheck className="w-4 h-4 mt-0.5" />
|
||||
<span>{t('protected')}</span>
|
||||
<span>{t("protected")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2 text-yellow-500">
|
||||
<ShieldOff className="w-4 h-4" />
|
||||
<span>{t('notProtected')}</span>
|
||||
<span>{t("notProtected")}</span>
|
||||
</div>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
@@ -71,7 +72,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t('site')}</InfoSectionTitle>
|
||||
<InfoSectionTitle>{t("site")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{resource.siteName}
|
||||
</InfoSectionContent>
|
||||
@@ -98,7 +99,9 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
) : (
|
||||
<>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t('protocol')}</InfoSectionTitle>
|
||||
<InfoSectionTitle>
|
||||
{t("protocol")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<span>
|
||||
{resource.protocol.toUpperCase()}
|
||||
@@ -106,7 +109,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t('port')}</InfoSectionTitle>
|
||||
<InfoSectionTitle>{t("port")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CopyToClipboard
|
||||
text={resource.proxyPort!.toString()}
|
||||
@@ -114,13 +117,29 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
/>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
{build == "oss" && (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("externalProxyEnabled")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<span>
|
||||
{resource.enableProxy
|
||||
? t("enabled")
|
||||
: t("disabled")}
|
||||
</span>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t('visibility')}</InfoSectionTitle>
|
||||
<InfoSectionTitle>{t("visibility")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<span>
|
||||
{resource.enabled ? t('enabled') : t('disabled')}
|
||||
{resource.enabled
|
||||
? t("enabled")
|
||||
: t("disabled")}
|
||||
</span>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
} from "@server/routers/resource";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
@@ -78,6 +79,7 @@ import {
|
||||
} from "@app/components/Credenza";
|
||||
import DomainPicker from "@app/components/DomainPicker";
|
||||
import { Globe } from "lucide-react";
|
||||
import { build } from "@server/build";
|
||||
|
||||
const TransferFormSchema = z.object({
|
||||
siteId: z.number()
|
||||
@@ -118,25 +120,31 @@ export default function GeneralForm() {
|
||||
fullDomain: string;
|
||||
} | null>(null);
|
||||
|
||||
const GeneralFormSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
subdomain: z.string().optional(),
|
||||
name: z.string().min(1).max(255),
|
||||
domainId: z.string().optional(),
|
||||
proxyPort: z.number().int().min(1).max(65535).optional()
|
||||
}).refine((data) => {
|
||||
// For non-HTTP resources, proxyPort should be defined
|
||||
if (!resource.http) {
|
||||
return data.proxyPort !== undefined;
|
||||
}
|
||||
// For HTTP resources, proxyPort should be undefined
|
||||
return data.proxyPort === undefined;
|
||||
}, {
|
||||
message: !resource.http
|
||||
? "Port number is required for non-HTTP resources"
|
||||
: "Port number should not be set for HTTP resources",
|
||||
path: ["proxyPort"]
|
||||
});
|
||||
const GeneralFormSchema = z
|
||||
.object({
|
||||
enabled: z.boolean(),
|
||||
subdomain: z.string().optional(),
|
||||
name: z.string().min(1).max(255),
|
||||
domainId: z.string().optional(),
|
||||
proxyPort: z.number().int().min(1).max(65535).optional(),
|
||||
enableProxy: z.boolean().optional()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
// For non-HTTP resources, proxyPort should be defined
|
||||
if (!resource.http) {
|
||||
return data.proxyPort !== undefined;
|
||||
}
|
||||
// For HTTP resources, proxyPort should be undefined
|
||||
return data.proxyPort === undefined;
|
||||
},
|
||||
{
|
||||
message: !resource.http
|
||||
? "Port number is required for non-HTTP resources"
|
||||
: "Port number should not be set for HTTP resources",
|
||||
path: ["proxyPort"]
|
||||
}
|
||||
);
|
||||
|
||||
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
||||
|
||||
@@ -147,7 +155,8 @@ export default function GeneralForm() {
|
||||
name: resource.name,
|
||||
subdomain: resource.subdomain ? resource.subdomain : undefined,
|
||||
domainId: resource.domainId || undefined,
|
||||
proxyPort: resource.proxyPort || undefined
|
||||
proxyPort: resource.proxyPort || undefined,
|
||||
enableProxy: resource.enableProxy || false
|
||||
},
|
||||
mode: "onChange"
|
||||
});
|
||||
@@ -211,7 +220,10 @@ export default function GeneralForm() {
|
||||
name: data.name,
|
||||
subdomain: data.subdomain,
|
||||
domainId: data.domainId,
|
||||
proxyPort: data.proxyPort
|
||||
proxyPort: data.proxyPort,
|
||||
...(!resource.http && {
|
||||
enableProxy: data.enableProxy
|
||||
})
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
@@ -238,7 +250,10 @@ export default function GeneralForm() {
|
||||
name: data.name,
|
||||
subdomain: data.subdomain,
|
||||
fullDomain: resource.fullDomain,
|
||||
proxyPort: data.proxyPort
|
||||
proxyPort: data.proxyPort,
|
||||
...(!resource.http && {
|
||||
enableProxy: data.enableProxy
|
||||
}),
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
@@ -357,16 +372,29 @@ export default function GeneralForm() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("resourcePortNumber")}
|
||||
{t(
|
||||
"resourcePortNumber"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) =>
|
||||
value={
|
||||
field.value ??
|
||||
""
|
||||
}
|
||||
onChange={(
|
||||
e
|
||||
) =>
|
||||
field.onChange(
|
||||
e.target.value
|
||||
? parseInt(e.target.value)
|
||||
e
|
||||
.target
|
||||
.value
|
||||
? parseInt(
|
||||
e
|
||||
.target
|
||||
.value
|
||||
)
|
||||
: undefined
|
||||
)
|
||||
}
|
||||
@@ -374,11 +402,49 @@ export default function GeneralForm() {
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t("resourcePortNumberDescription")}
|
||||
{t(
|
||||
"resourcePortNumberDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{build == "oss" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enableProxy"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
variant={
|
||||
"outlinePrimarySquare"
|
||||
}
|
||||
checked={
|
||||
field.value
|
||||
}
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel>
|
||||
{t(
|
||||
"resourceEnableProxy"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"resourceEnableProxyDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -574,6 +640,7 @@ export default function GeneralForm() {
|
||||
<CredenzaBody>
|
||||
<DomainPicker
|
||||
orgId={orgId as string}
|
||||
cols={1}
|
||||
onDomainChange={(res) => {
|
||||
const selected = {
|
||||
domainId: res.domainId,
|
||||
|
||||
@@ -25,6 +25,7 @@ import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { ListSitesResponse } from "@server/routers/site";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
@@ -64,6 +65,7 @@ import CopyTextBox from "@app/components/CopyTextBox";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import DomainPicker from "@app/components/DomainPicker";
|
||||
import { build } from "@server/build";
|
||||
|
||||
const baseResourceFormSchema = z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
@@ -72,13 +74,14 @@ const baseResourceFormSchema = z.object({
|
||||
});
|
||||
|
||||
const httpResourceFormSchema = z.object({
|
||||
domainId: z.string().optional(),
|
||||
domainId: z.string().nonempty(),
|
||||
subdomain: z.string().optional()
|
||||
});
|
||||
|
||||
const tcpUdpResourceFormSchema = z.object({
|
||||
protocol: z.string(),
|
||||
proxyPort: z.number().int().min(1).max(65535)
|
||||
proxyPort: z.number().int().min(1).max(65535),
|
||||
enableProxy: z.boolean().default(false)
|
||||
});
|
||||
|
||||
type BaseResourceFormValues = z.infer<typeof baseResourceFormSchema>;
|
||||
@@ -144,7 +147,8 @@ export default function Page() {
|
||||
resolver: zodResolver(tcpUdpResourceFormSchema),
|
||||
defaultValues: {
|
||||
protocol: "tcp",
|
||||
proxyPort: undefined
|
||||
proxyPort: undefined,
|
||||
enableProxy: false
|
||||
}
|
||||
});
|
||||
|
||||
@@ -163,16 +167,17 @@ export default function Page() {
|
||||
|
||||
if (isHttp) {
|
||||
const httpData = httpForm.getValues();
|
||||
Object.assign(payload, {
|
||||
subdomain: httpData.subdomain,
|
||||
domainId: httpData.domainId,
|
||||
protocol: "tcp",
|
||||
});
|
||||
Object.assign(payload, {
|
||||
subdomain: httpData.subdomain,
|
||||
domainId: httpData.domainId,
|
||||
protocol: "tcp"
|
||||
});
|
||||
} else {
|
||||
const tcpUdpData = tcpUdpForm.getValues();
|
||||
Object.assign(payload, {
|
||||
protocol: tcpUdpData.protocol,
|
||||
proxyPort: tcpUdpData.proxyPort
|
||||
proxyPort: tcpUdpData.proxyPort,
|
||||
enableProxy: tcpUdpData.enableProxy
|
||||
});
|
||||
}
|
||||
|
||||
@@ -198,8 +203,15 @@ export default function Page() {
|
||||
if (isHttp) {
|
||||
router.push(`/${orgId}/settings/resources/${id}`);
|
||||
} else {
|
||||
setShowSnippets(true);
|
||||
router.refresh();
|
||||
const tcpUdpData = tcpUdpForm.getValues();
|
||||
// Only show config snippets if enableProxy is explicitly true
|
||||
if (tcpUdpData.enableProxy === true) {
|
||||
setShowSnippets(true);
|
||||
router.refresh();
|
||||
} else {
|
||||
// If enableProxy is false or undefined, go directly to resource page
|
||||
router.push(`/${orgId}/settings/resources/${id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -265,9 +277,9 @@ export default function Page() {
|
||||
if (res?.status === 200) {
|
||||
const domains = res.data.data.domains;
|
||||
setBaseDomains(domains);
|
||||
if (domains.length) {
|
||||
httpForm.setValue("domainId", domains[0].domainId);
|
||||
}
|
||||
// if (domains.length) {
|
||||
// httpForm.setValue("domainId", domains[0].domainId);
|
||||
// }
|
||||
}
|
||||
};
|
||||
|
||||
@@ -603,6 +615,46 @@ export default function Page() {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{build == "oss" && (
|
||||
<FormField
|
||||
control={
|
||||
tcpUdpForm.control
|
||||
}
|
||||
name="enableProxy"
|
||||
render={({
|
||||
field
|
||||
}) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
variant={
|
||||
"outlinePrimarySquare"
|
||||
}
|
||||
checked={
|
||||
field.value
|
||||
}
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel>
|
||||
{t(
|
||||
"resourceEnableProxy"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"resourceEnableProxyDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
@@ -632,6 +684,8 @@ export default function Page() {
|
||||
? await httpForm.trigger()
|
||||
: await tcpUdpForm.trigger();
|
||||
|
||||
console.log(httpForm.getValues());
|
||||
|
||||
if (baseValid && settingsValid) {
|
||||
onSubmit();
|
||||
}
|
||||
|
||||
@@ -33,10 +33,17 @@ import { useState } from "react";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||
|
||||
const GeneralFormSchema = z.object({
|
||||
name: z.string().nonempty("Name is required"),
|
||||
dockerSocketEnabled: z.boolean().optional()
|
||||
dockerSocketEnabled: z.boolean().optional(),
|
||||
remoteSubnets: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
text: z.string()
|
||||
})
|
||||
).optional()
|
||||
});
|
||||
|
||||
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
||||
@@ -44,9 +51,11 @@ type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
||||
export default function GeneralPage() {
|
||||
const { site, updateSite } = useSiteContext();
|
||||
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeCidrTagIndex, setActiveCidrTagIndex] = useState<number | null>(null);
|
||||
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
@@ -55,7 +64,13 @@ export default function GeneralPage() {
|
||||
resolver: zodResolver(GeneralFormSchema),
|
||||
defaultValues: {
|
||||
name: site?.name,
|
||||
dockerSocketEnabled: site?.dockerSocketEnabled ?? false
|
||||
dockerSocketEnabled: site?.dockerSocketEnabled ?? false,
|
||||
remoteSubnets: site?.remoteSubnets
|
||||
? site.remoteSubnets.split(',').map((subnet, index) => ({
|
||||
id: subnet.trim(),
|
||||
text: subnet.trim()
|
||||
}))
|
||||
: []
|
||||
},
|
||||
mode: "onChange"
|
||||
});
|
||||
@@ -66,7 +81,8 @@ export default function GeneralPage() {
|
||||
await api
|
||||
.post(`/site/${site?.siteId}`, {
|
||||
name: data.name,
|
||||
dockerSocketEnabled: data.dockerSocketEnabled
|
||||
dockerSocketEnabled: data.dockerSocketEnabled,
|
||||
remoteSubnets: data.remoteSubnets?.map(subnet => subnet.text).join(',') || ''
|
||||
})
|
||||
.catch((e) => {
|
||||
toast({
|
||||
@@ -81,7 +97,8 @@ export default function GeneralPage() {
|
||||
|
||||
updateSite({
|
||||
name: data.name,
|
||||
dockerSocketEnabled: data.dockerSocketEnabled
|
||||
dockerSocketEnabled: data.dockerSocketEnabled,
|
||||
remoteSubnets: data.remoteSubnets?.map(subnet => subnet.text).join(',') || ''
|
||||
});
|
||||
|
||||
toast({
|
||||
@@ -124,12 +141,47 @@ export default function GeneralPage() {
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t("siteNameDescription")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="remoteSubnets"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("remoteSubnets")}</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={activeCidrTagIndex}
|
||||
setActiveTagIndex={setActiveCidrTagIndex}
|
||||
placeholder={t("enterCidrRange")}
|
||||
size="sm"
|
||||
tags={form.getValues().remoteSubnets || []}
|
||||
setTags={(newSubnets) => {
|
||||
form.setValue(
|
||||
"remoteSubnets",
|
||||
newSubnets as Tag[]
|
||||
);
|
||||
}}
|
||||
validateTag={(tag) => {
|
||||
// Basic CIDR validation regex
|
||||
const cidrRegex = /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/;
|
||||
return cidrRegex.test(tag);
|
||||
}}
|
||||
allowDuplicates={false}
|
||||
sortTags={true}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("remoteSubnetsDescription")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{site && site.type === "newt" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
FaFreebsd,
|
||||
FaWindows
|
||||
} from "react-icons/fa";
|
||||
import { SiNixos } from "react-icons/si";
|
||||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { generateKeypair } from "../[niceId]/wireguardConfig";
|
||||
@@ -74,6 +75,7 @@ type Commands = {
|
||||
windows: Record<string, string[]>;
|
||||
docker: Record<string, string[]>;
|
||||
podman: Record<string, string[]>;
|
||||
nixos: Record<string, string[]>;
|
||||
};
|
||||
|
||||
const platforms = [
|
||||
@@ -82,7 +84,8 @@ const platforms = [
|
||||
"podman",
|
||||
"mac",
|
||||
"windows",
|
||||
"freebsd"
|
||||
"freebsd",
|
||||
"nixos"
|
||||
] as const;
|
||||
|
||||
type Platform = (typeof platforms)[number];
|
||||
@@ -285,6 +288,14 @@ WantedBy=default.target`
|
||||
"Podman Run": [
|
||||
`podman run -dit docker.io/fosrl/newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
]
|
||||
},
|
||||
nixos: {
|
||||
x86_64: [
|
||||
`nix run 'nixpkgs#fosrl-newt' --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
],
|
||||
aarch64: [
|
||||
`nix run 'nixpkgs#fosrl-newt' --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
]
|
||||
}
|
||||
};
|
||||
setCommands(commands);
|
||||
@@ -304,6 +315,8 @@ WantedBy=default.target`
|
||||
return ["Podman Quadlet", "Podman Run"];
|
||||
case "freebsd":
|
||||
return ["amd64", "arm64"];
|
||||
case "nixos":
|
||||
return ["x86_64", "aarch64"];
|
||||
default:
|
||||
return ["x64"];
|
||||
}
|
||||
@@ -321,6 +334,8 @@ WantedBy=default.target`
|
||||
return "Podman";
|
||||
case "freebsd":
|
||||
return "FreeBSD";
|
||||
case "nixos":
|
||||
return "NixOS";
|
||||
default:
|
||||
return "Linux";
|
||||
}
|
||||
@@ -365,6 +380,8 @@ WantedBy=default.target`
|
||||
return <FaCubes className="h-4 w-4 mr-2" />;
|
||||
case "freebsd":
|
||||
return <FaFreebsd className="h-4 w-4 mr-2" />;
|
||||
case "nixos":
|
||||
return <SiNixos className="h-4 w-4 mr-2" />;
|
||||
default:
|
||||
return <Terminal className="h-4 w-4 mr-2" />;
|
||||
}
|
||||
@@ -587,11 +604,6 @@ WantedBy=default.target`
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t(
|
||||
"siteNameDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as z from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -33,6 +34,7 @@ import Image from "next/image";
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
import { useTranslations } from "next-intl";
|
||||
import BrandingLogo from "@app/components/BrandingLogo";
|
||||
import { build } from "@server/build";
|
||||
|
||||
type SignupFormProps = {
|
||||
redirect?: string;
|
||||
@@ -44,7 +46,19 @@ const formSchema = z
|
||||
.object({
|
||||
email: z.string().email({ message: "Invalid email address" }),
|
||||
password: passwordSchema,
|
||||
confirmPassword: passwordSchema
|
||||
confirmPassword: passwordSchema,
|
||||
agreeToTerms: z.boolean().refine(
|
||||
(val) => {
|
||||
if (build === "saas") {
|
||||
val === true;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message:
|
||||
"You must agree to the terms of service and privacy policy"
|
||||
}
|
||||
)
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
path: ["confirmPassword"],
|
||||
@@ -64,13 +78,15 @@ export default function SignupForm({
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [termsAgreedAt, setTermsAgreedAt] = useState<string | null>(null);
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
confirmPassword: ""
|
||||
confirmPassword: "",
|
||||
agreeToTerms: false
|
||||
}
|
||||
});
|
||||
|
||||
@@ -85,7 +101,8 @@ export default function SignupForm({
|
||||
email,
|
||||
password,
|
||||
inviteId,
|
||||
inviteToken
|
||||
inviteToken,
|
||||
termsAcceptedTimestamp: termsAgreedAt
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
@@ -120,14 +137,23 @@ export default function SignupForm({
|
||||
return t("authCreateAccount");
|
||||
}
|
||||
|
||||
const handleTermsChange = (checked: boolean) => {
|
||||
if (checked) {
|
||||
const isoNow = new Date().toISOString();
|
||||
console.log("Terms agreed at:", isoNow);
|
||||
setTermsAgreedAt(isoNow);
|
||||
form.setValue("agreeToTerms", true);
|
||||
} else {
|
||||
form.setValue("agreeToTerms", false);
|
||||
setTermsAgreedAt(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md shadow-md">
|
||||
<CardHeader className="border-b">
|
||||
<div className="flex flex-row items-center justify-center">
|
||||
<BrandingLogo
|
||||
height={58}
|
||||
width={175}
|
||||
/>
|
||||
<BrandingLogo height={58} width={175} />
|
||||
</div>
|
||||
<div className="text-center space-y-1 pt-3">
|
||||
<p className="text-muted-foreground">{getSubtitle()}</p>
|
||||
@@ -180,6 +206,54 @@ export default function SignupForm({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{build === "saas" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="agreeToTerms"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange(checked);
|
||||
handleTermsChange(
|
||||
checked as boolean
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="leading-none">
|
||||
<FormLabel className="text-sm font-normal">
|
||||
{t("signUpTerms.IAgreeToThe")}
|
||||
<a
|
||||
href="https://digpangolin.com/terms-of-service.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{t(
|
||||
"signUpTerms.termsOfService"
|
||||
)}
|
||||
</a>
|
||||
{t("signUpTerms.and")}
|
||||
<a
|
||||
href="https://digpangolin.com/privacy-policy.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{t(
|
||||
"signUpTerms.privacyPolicy"
|
||||
)}
|
||||
</a>
|
||||
</FormLabel>
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
|
||||
@@ -12,15 +12,24 @@ 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: <User className="h-4 w-4" />
|
||||
}
|
||||
];
|
||||
|
||||
export const orgNavSections = (
|
||||
enableClients: boolean = true
|
||||
): SidebarNavSection[] => [
|
||||
|
||||
@@ -49,6 +49,7 @@ type DomainOption = {
|
||||
|
||||
interface DomainPickerProps {
|
||||
orgId: string;
|
||||
cols?: number;
|
||||
onDomainChange?: (domainInfo: {
|
||||
domainId: string;
|
||||
domainNamespaceId?: string;
|
||||
@@ -61,6 +62,7 @@ interface DomainPickerProps {
|
||||
|
||||
export default function DomainPicker({
|
||||
orgId,
|
||||
cols,
|
||||
onDomainChange
|
||||
}: DomainPickerProps) {
|
||||
const { env } = useEnvContext();
|
||||
@@ -127,9 +129,6 @@ export default function DomainPicker({
|
||||
|
||||
if (!userInput.trim()) return options;
|
||||
|
||||
// Check if input is more than one level deep (contains multiple dots)
|
||||
const isMultiLevel = (userInput.match(/\./g) || []).length > 1;
|
||||
|
||||
// Add organization domain options
|
||||
organizationDomains.forEach((orgDomain) => {
|
||||
if (orgDomain.type === "cname") {
|
||||
@@ -309,6 +308,7 @@ export default function DomainPicker({
|
||||
<Input
|
||||
id="domain-input"
|
||||
value={userInput}
|
||||
className="max-w-xl"
|
||||
onChange={(e) => {
|
||||
// Only allow letters, numbers, hyphens, and periods
|
||||
const validInput = e.target.value.replace(
|
||||
@@ -316,6 +316,8 @@ export default function DomainPicker({
|
||||
""
|
||||
);
|
||||
setUserInput(validInput);
|
||||
// Clear selection when input changes
|
||||
setSelectedOption(null);
|
||||
}}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -382,7 +384,7 @@ export default function DomainPicker({
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{t("domainPickerNoMatchingDomains", { userInput })}
|
||||
{t("domainPickerNoMatchingDomains")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -393,23 +395,25 @@ export default function DomainPicker({
|
||||
{/* Organization Domains */}
|
||||
{organizationOptions.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Building2 className="h-4 w-4" />
|
||||
<h4 className="text-sm font-medium">
|
||||
{t("domainPickerOrganizationDomains")}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{build !== "oss" && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Building2 className="h-4 w-4" />
|
||||
<h4 className="text-sm font-medium">
|
||||
{t("domainPickerOrganizationDomains")}
|
||||
</h4>
|
||||
</div>
|
||||
)}
|
||||
<div className={`grid gap-2 ${cols ? `grid-cols-${cols}` : 'grid-cols-1 sm:grid-cols-2'}`}>
|
||||
{organizationOptions.map((option) => (
|
||||
<div
|
||||
key={option.id}
|
||||
className={cn(
|
||||
"transition-all p-3 rounded-lg border",
|
||||
selectedOption?.id === option.id
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-input",
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-input hover:bg-accent",
|
||||
option.verified
|
||||
? "cursor-pointer hover:bg-accent"
|
||||
? "cursor-pointer"
|
||||
: "cursor-not-allowed opacity-60"
|
||||
)}
|
||||
onClick={() =>
|
||||
@@ -456,10 +460,6 @@ export default function DomainPicker({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{selectedOption?.id ===
|
||||
option.id && (
|
||||
<CheckCircle2 className="h-4 w-4 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -476,14 +476,14 @@ export default function DomainPicker({
|
||||
{t("domainPickerProvidedDomains")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className={`grid gap-2 ${cols ? `grid-cols-${cols}` : 'grid-cols-1 sm:grid-cols-2'}`}>
|
||||
{providedOptions.map((option) => (
|
||||
<div
|
||||
key={option.id}
|
||||
className={cn(
|
||||
"transition-all p-3 rounded-lg border",
|
||||
selectedOption?.id === option.id
|
||||
? "border-primary bg-primary/5"
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-input",
|
||||
"cursor-pointer hover:bg-accent"
|
||||
)}
|
||||
|
||||
@@ -63,7 +63,6 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [securityKeyLoading, setSecurityKeyLoading] = useState(false);
|
||||
const hasIdp = idps && idps.length > 0;
|
||||
|
||||
const [mfaRequested, setMfaRequested] = useState(false);
|
||||
@@ -72,14 +71,12 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.string().email({ message: t('emailInvalid') }),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, { message: t('passwordRequirementsChars') })
|
||||
email: z.string().email({ message: t("emailInvalid") }),
|
||||
password: z.string().min(8, { message: t("passwordRequirementsChars") })
|
||||
});
|
||||
|
||||
const mfaSchema = z.object({
|
||||
code: z.string().length(6, { message: t('pincodeInvalid') })
|
||||
code: z.string().length(6, { message: t("pincodeInvalid") })
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
@@ -99,17 +96,23 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||
|
||||
async function initiateSecurityKeyAuth() {
|
||||
setShowSecurityKeyPrompt(true);
|
||||
setSecurityKeyLoading(true);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Start WebAuthn authentication without email
|
||||
const startRes = await api.post("/auth/security-key/authenticate/start", {});
|
||||
const startRes = await api.post(
|
||||
"/auth/security-key/authenticate/start",
|
||||
{}
|
||||
);
|
||||
|
||||
if (!startRes) {
|
||||
setError(t('securityKeyAuthError', {
|
||||
defaultValue: "Failed to start security key authentication"
|
||||
}));
|
||||
setError(
|
||||
t("securityKeyAuthError", {
|
||||
defaultValue:
|
||||
"Failed to start security key authentication"
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -125,7 +128,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||
{ credential },
|
||||
{
|
||||
headers: {
|
||||
'X-Temp-Session-Id': tempSessionId
|
||||
"X-Temp-Session-Id": tempSessionId
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -136,39 +139,61 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.name === 'NotAllowedError') {
|
||||
if (error.message.includes('denied permission')) {
|
||||
setError(t('securityKeyPermissionDenied', {
|
||||
defaultValue: "Please allow access to your security key to continue signing in."
|
||||
}));
|
||||
if (error.name === "NotAllowedError") {
|
||||
if (error.message.includes("denied permission")) {
|
||||
setError(
|
||||
t("securityKeyPermissionDenied", {
|
||||
defaultValue:
|
||||
"Please allow access to your security key to continue signing in."
|
||||
})
|
||||
);
|
||||
} else {
|
||||
setError(t('securityKeyRemovedTooQuickly', {
|
||||
defaultValue: "Please keep your security key connected until the sign-in process completes."
|
||||
}));
|
||||
setError(
|
||||
t("securityKeyRemovedTooQuickly", {
|
||||
defaultValue:
|
||||
"Please keep your security key connected until the sign-in process completes."
|
||||
})
|
||||
);
|
||||
}
|
||||
} else if (error.name === 'NotSupportedError') {
|
||||
setError(t('securityKeyNotSupported', {
|
||||
defaultValue: "Your security key may not be compatible. Please try a different security key."
|
||||
}));
|
||||
} else if (error.name === "NotSupportedError") {
|
||||
setError(
|
||||
t("securityKeyNotSupported", {
|
||||
defaultValue:
|
||||
"Your security key may not be compatible. Please try a different security key."
|
||||
})
|
||||
);
|
||||
} else {
|
||||
setError(t('securityKeyUnknownError', {
|
||||
defaultValue: "There was a problem using your security key. Please try again."
|
||||
}));
|
||||
setError(
|
||||
t("securityKeyUnknownError", {
|
||||
defaultValue:
|
||||
"There was a problem using your security key. Please try again."
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.isAxiosError) {
|
||||
setError(formatAxiosError(e, t('securityKeyAuthError', {
|
||||
defaultValue: "Failed to authenticate with security key"
|
||||
})));
|
||||
setError(
|
||||
formatAxiosError(
|
||||
e,
|
||||
t("securityKeyAuthError", {
|
||||
defaultValue:
|
||||
"Failed to authenticate with security key"
|
||||
})
|
||||
)
|
||||
);
|
||||
} else {
|
||||
console.error(e);
|
||||
setError(e.message || t('securityKeyAuthError', {
|
||||
defaultValue: "Failed to authenticate with security key"
|
||||
}));
|
||||
setError(
|
||||
e.message ||
|
||||
t("securityKeyAuthError", {
|
||||
defaultValue:
|
||||
"Failed to authenticate with security key"
|
||||
})
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setSecurityKeyLoading(false);
|
||||
setLoading(false);
|
||||
setShowSecurityKeyPrompt(false);
|
||||
}
|
||||
}
|
||||
@@ -182,11 +207,14 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||
setShowSecurityKeyPrompt(false);
|
||||
|
||||
try {
|
||||
const res = await api.post<AxiosResponse<LoginResponse>>("/auth/login", {
|
||||
email,
|
||||
password,
|
||||
code
|
||||
});
|
||||
const res = await api.post<AxiosResponse<LoginResponse>>(
|
||||
"/auth/login",
|
||||
{
|
||||
email,
|
||||
password,
|
||||
code
|
||||
}
|
||||
);
|
||||
|
||||
const data = res.data.data;
|
||||
|
||||
@@ -212,7 +240,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||
}
|
||||
|
||||
if (data?.twoFactorSetupRequired) {
|
||||
const setupUrl = `/auth/2fa/setup?email=${encodeURIComponent(email)}${redirect ? `&redirect=${encodeURIComponent(redirect)}` : ''}`;
|
||||
const setupUrl = `/auth/2fa/setup?email=${encodeURIComponent(email)}${redirect ? `&redirect=${encodeURIComponent(redirect)}` : ""}`;
|
||||
router.push(setupUrl);
|
||||
return;
|
||||
}
|
||||
@@ -222,16 +250,22 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.isAxiosError) {
|
||||
const errorMessage = formatAxiosError(e, t('loginError', {
|
||||
defaultValue: "Failed to log in"
|
||||
}));
|
||||
const errorMessage = formatAxiosError(
|
||||
e,
|
||||
t("loginError", {
|
||||
defaultValue: "Failed to log in"
|
||||
})
|
||||
);
|
||||
setError(errorMessage);
|
||||
return;
|
||||
} else {
|
||||
console.error(e);
|
||||
setError(e.message || t('loginError', {
|
||||
defaultValue: "Failed to log in"
|
||||
}));
|
||||
setError(
|
||||
e.message ||
|
||||
t("loginError", {
|
||||
defaultValue: "Failed to log in"
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
@@ -251,7 +285,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||
console.log(res);
|
||||
|
||||
if (!res) {
|
||||
setError(t('loginError'));
|
||||
setError(t("loginError"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -268,8 +302,9 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||
<Alert>
|
||||
<FingerprintIcon className="w-5 h-5 mr-2" />
|
||||
<AlertDescription>
|
||||
{t('securityKeyPrompt', {
|
||||
defaultValue: "Please verify your identity using your security key. Make sure your security key is connected and ready."
|
||||
{t("securityKeyPrompt", {
|
||||
defaultValue:
|
||||
"Please verify your identity using your security key. Make sure your security key is connected and ready."
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
@@ -288,7 +323,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('email')}</FormLabel>
|
||||
<FormLabel>{t("email")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
@@ -303,7 +338,9 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('password')}</FormLabel>
|
||||
<FormLabel>
|
||||
{t("password")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
@@ -320,18 +357,18 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||
href={`/auth/reset-password${form.getValues().email ? `?email=${form.getValues().email}` : ""}`}
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
{t('passwordForgot')}
|
||||
{t("passwordForgot")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? t('idpConnectingToProcess', {
|
||||
defaultValue: "Connecting..."
|
||||
}) : t('login', {
|
||||
defaultValue: "Log in"
|
||||
})}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
loading={loading}
|
||||
>
|
||||
{t("login")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -342,11 +379,9 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||
{mfaRequested && (
|
||||
<>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-medium">
|
||||
{t('otpAuth')}
|
||||
</h3>
|
||||
<h3 className="text-lg font-medium">{t("otpAuth")}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('otpAuthDescription')}
|
||||
{t("otpAuthDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Form {...mfaForm}>
|
||||
@@ -368,10 +403,16 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||
pattern={
|
||||
REGEXP_ONLY_DIGITS_AND_CHARS
|
||||
}
|
||||
onChange={(value: string) => {
|
||||
onChange={(
|
||||
value: string
|
||||
) => {
|
||||
field.onChange(value);
|
||||
if (value.length === 6) {
|
||||
mfaForm.handleSubmit(onSubmit)();
|
||||
if (
|
||||
value.length === 6
|
||||
) {
|
||||
mfaForm.handleSubmit(
|
||||
onSubmit
|
||||
)();
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -422,7 +463,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{t('otpAuthSubmit')}
|
||||
{t("otpAuthSubmit")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -433,11 +474,11 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={initiateSecurityKeyAuth}
|
||||
loading={securityKeyLoading}
|
||||
disabled={securityKeyLoading || showSecurityKeyPrompt}
|
||||
loading={loading}
|
||||
disabled={loading || showSecurityKeyPrompt}
|
||||
>
|
||||
<FingerprintIcon className="w-4 h-4 mr-2" />
|
||||
{t('securityKeyLogin', {
|
||||
{t("securityKeyLogin", {
|
||||
defaultValue: "Sign in with security key"
|
||||
})}
|
||||
</Button>
|
||||
@@ -450,7 +491,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="px-2 bg-card text-muted-foreground">
|
||||
{t('idpContinue')}
|
||||
{t("idpContinue")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -483,7 +524,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||
mfaForm.reset();
|
||||
}}
|
||||
>
|
||||
{t('otpAuthBack')}
|
||||
{t("otpAuthBack")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,11 +11,12 @@ import { Button } from "@/components/ui/button";
|
||||
|
||||
interface InfoPopupProps {
|
||||
text?: string;
|
||||
info: string;
|
||||
info?: string;
|
||||
trigger?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function InfoPopup({ text, info, trigger }: InfoPopupProps) {
|
||||
export function InfoPopup({ text, info, trigger, children }: InfoPopupProps) {
|
||||
const defaultTrigger = (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -35,7 +36,12 @@ export function InfoPopup({ text, info, trigger }: InfoPopupProps) {
|
||||
{trigger ?? defaultTrigger}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80">
|
||||
<p className="text-sm text-muted-foreground">{info}</p>
|
||||
{children ||
|
||||
(info && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{info}
|
||||
</p>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@ const ToastViewport = React.forwardRef<
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed bottom-0 left-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 md:max-w-[420px]",
|
||||
"fixed bottom-0 left-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 md:top-0 md:bottom-auto md:left-1/2 md:-translate-x-1/2 md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
Reference in New Issue
Block a user