Merge branch 'dev' into translation-nb-NO

This commit is contained in:
Elias Torstensen
2025-08-08 21:41:43 +02:00
committed by GitHub
359 changed files with 30625 additions and 8096 deletions

View 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>
);
}

View File

@@ -8,6 +8,7 @@ import { GetOrgUserResponse } from "@server/routers/user";
import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
import { cache } from "react";
import SetLastOrgCookie from "@app/components/SetLastOrgCookie";
export default async function OrgLayout(props: {
children: React.ReactNode;
@@ -52,6 +53,7 @@ export default async function OrgLayout(props: {
return (
<>
{props.children}
<SetLastOrgCookie orgId={orgId} />
</>
);
}

View File

@@ -2,14 +2,17 @@ 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";
import { authCookieHeader } from "@app/lib/api/cookies";
import { redirect } from "next/navigation";
import { Layout } from "@app/components/Layout";
import { orgLangingNavItems, orgNavItems, rootNavItems } from "../navigation";
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 }>;
@@ -18,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();
@@ -26,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>>(
@@ -34,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,25 +61,8 @@ export default async function OrgPage(props: OrgPageProps) {
return (
<UserProvider user={user}>
<Layout orgId={orgId} navItems={orgLangingNavItems} 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>
)}
<Layout orgId={orgId} navItems={[]} orgs={orgs}>
{overview && <MemberResourcesPortal orgId={orgId} />}
</Layout>
</UserProvider>
);

View File

@@ -18,6 +18,7 @@ import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import moment from "moment";
export type InvitationRow = {
id: string;
@@ -46,63 +47,69 @@ export default function InvitationsTable({
const { org } = useOrgContext();
const columns: ColumnDef<InvitationRow>[] = [
{
id: "dots",
cell: ({ row }) => {
const invitation = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">{t('openMenu')}</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setIsRegenerateModalOpen(true);
setSelectedInvitation(invitation);
}}
>
<span>{t('inviteRegenerate')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setIsDeleteModalOpen(true);
setSelectedInvitation(invitation);
}}
>
<span className="text-red-500">
{t('inviteRemove')}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
},
{
accessorKey: "email",
header: t('email')
header: t("email")
},
{
accessorKey: "expiresAt",
header: t('expiresAt'),
header: t("expiresAt"),
cell: ({ row }) => {
const expiresAt = new Date(row.original.expiresAt);
const isExpired = expiresAt < new Date();
return (
<span className={isExpired ? "text-red-500" : ""}>
{expiresAt.toLocaleString()}
{moment(expiresAt).format("lll")}
</span>
);
}
},
{
accessorKey: "role",
header: t('role')
header: t("role")
},
{
id: "dots",
cell: ({ row }) => {
const invitation = row.original;
return (
<div className="flex items-center justify-end gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setIsDeleteModalOpen(true);
setSelectedInvitation(invitation);
}}
>
<span className="text-red-500">
{t("inviteRemove")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant={"secondary"}
onClick={() => {
setIsRegenerateModalOpen(true);
setSelectedInvitation(invitation);
}}
>
<span>{t("inviteRegenerate")}</span>
</Button>
</div>
);
}
}
];
@@ -115,16 +122,18 @@ export default function InvitationsTable({
.catch((e) => {
toast({
variant: "destructive",
title: t('inviteRemoveError'),
description: t('inviteRemoveErrorDescription')
title: t("inviteRemoveError"),
description: t("inviteRemoveErrorDescription")
});
});
if (res && res.status === 200) {
toast({
variant: "default",
title: t('inviteRemoved'),
description: t('inviteRemovedDescription', {email: selectedInvitation.email})
title: t("inviteRemoved"),
description: t("inviteRemovedDescription", {
email: selectedInvitation.email
})
});
setInvitations((prev) =>
@@ -148,20 +157,18 @@ export default function InvitationsTable({
dialog={
<div className="space-y-4">
<p>
{t('inviteQuestionRemove', {email: selectedInvitation?.email || ""})}
</p>
<p>
{t('inviteMessageRemove')}
</p>
<p>
{t('inviteMessageConfirm')}
{t("inviteQuestionRemove", {
email: selectedInvitation?.email || ""
})}
</p>
<p>{t("inviteMessageRemove")}</p>
<p>{t("inviteMessageConfirm")}</p>
</div>
}
buttonText={t('inviteRemoveConfirm')}
buttonText={t("inviteRemoveConfirm")}
onConfirm={removeInvitation}
string={selectedInvitation?.email ?? ""}
title={t('inviteRemove')}
title={t("inviteRemove")}
/>
<RegenerateInvitationForm
open={isRegenerateModalOpen}

View File

@@ -201,7 +201,7 @@ export default function RegenerateInvitationForm({
setValidHours(parseInt(value))
}
>
<SelectTrigger>
<SelectTrigger className="w-full">
<SelectValue placeholder={t('inviteValidityPeriodSelect')} />
</SelectTrigger>
<SelectContent>

View File

@@ -159,7 +159,6 @@ export default function DeleteRoleForm({
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<div className="space-y-4">
<div className="space-y-4">
<p>
{t('accessRoleQuestionRemove', {name: roleToDelete.name})}
@@ -210,13 +209,13 @@ export default function DeleteRoleForm({
/>
</form>
</Form>
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t('close')}</Button>
</CredenzaClose>
<Button
variant="destructive"
type="submit"
form="remove-role-form"
loading={loading}

View File

@@ -19,7 +19,7 @@ import CreateRoleForm from "./CreateRoleForm";
import DeleteRoleForm from "./DeleteRoleForm";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from 'next-intl';
import { useTranslations } from "next-intl";
export type RoleRow = Role;
@@ -42,49 +42,6 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
const t = useTranslations();
const columns: ColumnDef<RoleRow>[] = [
{
id: "actions",
cell: ({ row }) => {
const roleRow = row.original;
return (
<>
<div>
{roleRow.isAdmin && (
<MoreHorizontal className="h-4 w-4 opacity-0" />
)}
{!roleRow.isAdmin && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">
{t('openMenu')}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setIsDeleteModalOpen(true);
setUserToRemove(roleRow);
}}
>
<span className="text-red-500">
{t('accessRoleDelete')}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</>
);
}
},
{
accessorKey: "name",
header: ({ column }) => {
@@ -95,7 +52,7 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t('name')}
{t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -103,7 +60,29 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
},
{
accessorKey: "description",
header: t('description')
header: t("description")
},
{
id: "actions",
cell: ({ row }) => {
const roleRow = row.original;
return (
<div className="flex items-center justify-end">
<Button
variant={"secondary"}
size="sm"
disabled={roleRow.isAdmin || false}
onClick={() => {
setIsDeleteModalOpen(true);
setUserToRemove(roleRow);
}}
>
{t("accessRoleDelete")}
</Button>
</div>
);
}
}
];

View File

@@ -6,8 +6,6 @@ import { cache } from "react";
import OrgProvider from "@app/providers/OrgProvider";
import { ListRolesResponse } from "@server/routers/role";
import RolesTable, { RoleRow } from "./RolesTable";
import { SidebarSettings } from "@app/components/SidebarSettings";
import AccessPageHeaderAndNav from "../AccessPageHeaderAndNav";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from 'next-intl/server';

View File

@@ -20,7 +20,7 @@ import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useUserContext } from "@app/hooks/useUserContext";
import { useTranslations } from 'next-intl';
import { useTranslations } from "next-intl";
export type UserRow = {
id: string;
@@ -51,65 +51,6 @@ export default function UsersTable({ users: u }: UsersTableProps) {
const t = useTranslations();
const columns: ColumnDef<UserRow>[] = [
{
id: "dots",
cell: ({ row }) => {
const userRow = row.original;
return (
<>
<div>
{userRow.isOwner && (
<MoreHorizontal className="h-4 w-4 opacity-0" />
)}
{!userRow.isOwner && (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">
{t('openMenu')}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Link
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
className="block w-full"
>
<DropdownMenuItem>
{t('accessUsersManage')}
</DropdownMenuItem>
</Link>
{`${userRow.username}-${userRow.idpId}` !==
`${user?.username}-${userRow.idpId}` && (
<DropdownMenuItem
onClick={() => {
setIsDeleteModalOpen(
true
);
setSelectedUser(
userRow
);
}}
>
<span className="text-red-500">
{t('accessUserRemove')}
</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div>
</>
);
}
},
{
accessorKey: "displayUsername",
header: ({ column }) => {
@@ -120,7 +61,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t('username')}
{t("username")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -136,7 +77,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t('identityProvider')}
{t("identityProvider")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -152,7 +93,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t('role')}
{t("role")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -176,12 +117,68 @@ export default function UsersTable({ users: u }: UsersTableProps) {
const userRow = row.original;
return (
<div className="flex items-center justify-end">
<>
<div>
{userRow.isOwner && (
<MoreHorizontal className="h-4 w-4 opacity-0" />
)}
{!userRow.isOwner && (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Link
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
className="block w-full"
>
<DropdownMenuItem>
{t("accessUsersManage")}
</DropdownMenuItem>
</Link>
{`${userRow.username}-${userRow.idpId}` !==
`${user?.username}-${userRow.idpId}` && (
<DropdownMenuItem
onClick={() => {
setIsDeleteModalOpen(
true
);
setSelectedUser(
userRow
);
}}
>
<span className="text-red-500">
{t(
"accessUserRemove"
)}
</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div>
</>
{userRow.isOwner && (
<Button
variant="ghost"
className="opacity-0 cursor-default"
variant={"secondary"}
className="ml-2"
size="sm"
disabled={true}
>
{t('placeholder')}
{t("manage")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
)}
{!userRow.isOwner && (
@@ -189,10 +186,12 @@ export default function UsersTable({ users: u }: UsersTableProps) {
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
>
<Button
variant={"outlinePrimary"}
variant={"secondary"}
className="ml-2"
size="sm"
disabled={userRow.isOwner}
>
{t('manage')}
{t("manage")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
@@ -210,10 +209,10 @@ export default function UsersTable({ users: u }: UsersTableProps) {
.catch((e) => {
toast({
variant: "destructive",
title: t('userErrorOrgRemove'),
title: t("userErrorOrgRemove"),
description: formatAxiosError(
e,
t('userErrorOrgRemoveDescription')
t("userErrorOrgRemoveDescription")
)
});
});
@@ -221,8 +220,10 @@ export default function UsersTable({ users: u }: UsersTableProps) {
if (res && res.status === 200) {
toast({
variant: "default",
title: t('userOrgRemoved'),
description: t('userOrgRemovedDescription', {email: selectedUser.email || ""})
title: t("userOrgRemoved"),
description: t("userOrgRemovedDescription", {
email: selectedUser.email || ""
})
});
setUsers((prev) =>
@@ -244,19 +245,21 @@ export default function UsersTable({ users: u }: UsersTableProps) {
dialog={
<div className="space-y-4">
<p>
{t('userQuestionOrgRemove', {email: selectedUser?.email || selectedUser?.name || selectedUser?.username || ""})}
{t("userQuestionOrgRemove", {
email:
selectedUser?.email ||
selectedUser?.name ||
selectedUser?.username ||
""
})}
</p>
<p>
{t('userMessageOrgRemove')}
</p>
<p>{t("userMessageOrgRemove")}</p>
<p>
{t('userMessageOrgConfirm')}
</p>
<p>{t("userMessageOrgConfirm")}</p>
</div>
}
buttonText={t('userRemoveOrgConfirm')}
buttonText={t("userRemoveOrgConfirm")}
onConfirm={removeUser}
string={
selectedUser?.email ||
@@ -264,14 +267,16 @@ export default function UsersTable({ users: u }: UsersTableProps) {
selectedUser?.username ||
""
}
title={t('userRemoveOrg')}
title={t("userRemoveOrg")}
/>
<UsersDataTable
columns={columns}
data={users}
inviteUser={() => {
router.push(`/${org?.org.orgId}/settings/access/users/create`);
router.push(
`/${org?.org.orgId}/settings/access/users/create`
);
}}
/>
</>

View File

@@ -5,17 +5,9 @@ import { authCookieHeader } from "@app/lib/api/cookies";
import { GetOrgUserResponse } from "@server/routers/user";
import OrgUserProvider from "@app/providers/OrgUserProvider";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from "@app/components/ui/breadcrumb";
import Link from "next/link";
import { cache } from "react";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from 'next-intl/server';
import { getTranslations } from "next-intl/server";
interface UserLayoutProps {
children: React.ReactNode;
@@ -45,7 +37,7 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
const navItems = [
{
title: t('accessControls'),
title: t("accessControls"),
href: "/{orgId}/settings/access/users/{userId}/access-controls"
}
];
@@ -54,12 +46,10 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
<>
<SettingsSectionTitle
title={`${user?.email}`}
description={t('userDescription2')}
description={t("userDescription2")}
/>
<OrgUserProvider orgUser={user}>
<HorizontalTabs items={navItems}>
{children}
</HorizontalTabs>
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
</OrgUserProvider>
</>
);

View File

@@ -9,7 +9,7 @@ import {
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { StrategySelect } from "@app/components/StrategySelect";
import { StrategyOption, StrategySelect } from "@app/components/StrategySelect";
import HeaderTitle from "@app/components/SettingsSectionTitle";
import { Button } from "@app/components/ui/button";
import { useParams, useRouter } from "next/navigation";
@@ -45,15 +45,10 @@ import { createApiClient } from "@app/lib/api";
import { Checkbox } from "@app/components/ui/checkbox";
import { ListIdpsResponse } from "@server/routers/idp";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
type UserType = "internal" | "oidc";
interface UserTypeOption {
id: UserType;
title: string;
description: string;
}
interface IdpOption {
idpId: number;
name: string;
@@ -78,40 +73,42 @@ export default function Page() {
const [dataLoaded, setDataLoaded] = useState(false);
const internalFormSchema = z.object({
email: z.string().email({ message: t('emailInvalid') }),
validForHours: z.string().min(1, { message: t('inviteValidityDuration') }),
roleId: z.string().min(1, { message: t('accessRoleSelectPlease') })
email: z.string().email({ message: t("emailInvalid") }),
validForHours: z
.string()
.min(1, { message: t("inviteValidityDuration") }),
roleId: z.string().min(1, { message: t("accessRoleSelectPlease") })
});
const externalFormSchema = z.object({
username: z.string().min(1, { message: t('usernameRequired') }),
username: z.string().min(1, { message: t("usernameRequired") }),
email: z
.string()
.email({ message: t('emailInvalid') })
.email({ message: t("emailInvalid") })
.optional()
.or(z.literal("")),
name: z.string().optional(),
roleId: z.string().min(1, { message: t('accessRoleSelectPlease') }),
idpId: z.string().min(1, { message: t('idpSelectPlease') })
roleId: z.string().min(1, { message: t("accessRoleSelectPlease") }),
idpId: z.string().min(1, { message: t("idpSelectPlease") })
});
const formatIdpType = (type: string) => {
switch (type.toLowerCase()) {
case "oidc":
return t('idpGenericOidc');
return t("idpGenericOidc");
default:
return type;
}
};
const validFor = [
{ hours: 24, name: t('day', {count: 1}) },
{ hours: 48, name: t('day', {count: 2}) },
{ hours: 72, name: t('day', {count: 3}) },
{ hours: 96, name: t('day', {count: 4}) },
{ hours: 120, name: t('day', {count: 5}) },
{ hours: 144, name: t('day', {count: 6}) },
{ hours: 168, name: t('day', {count: 7}) }
{ hours: 24, name: t("day", { count: 1 }) },
{ hours: 48, name: t("day", { count: 2 }) },
{ hours: 72, name: t("day", { count: 3 }) },
{ hours: 96, name: t("day", { count: 4 }) },
{ hours: 120, name: t("day", { count: 5 }) },
{ hours: 144, name: t("day", { count: 6 }) },
{ hours: 168, name: t("day", { count: 7 }) }
];
const internalForm = useForm<z.infer<typeof internalFormSchema>>({
@@ -145,6 +142,21 @@ export default function Page() {
}
}, [userType, env.email.emailEnabled, internalForm, externalForm]);
const [userTypes, setUserTypes] = useState<StrategyOption<string>[]>([
{
id: "internal",
title: t("userTypeInternal"),
description: t("userTypeInternalDescription"),
disabled: false
},
{
id: "oidc",
title: t("userTypeExternal"),
description: t("userTypeExternalDescription"),
disabled: true
}
]);
useEffect(() => {
if (!userType) {
return;
@@ -157,19 +169,16 @@ export default function Page() {
console.error(e);
toast({
variant: "destructive",
title: t('accessRoleErrorFetch'),
title: t("accessRoleErrorFetch"),
description: formatAxiosError(
e,
t('accessRoleErrorFetchDescription')
t("accessRoleErrorFetchDescription")
)
});
});
if (res?.status === 200) {
setRoles(res.data.data.roles);
if (userType === "internal") {
setDataLoaded(true);
}
}
}
@@ -180,26 +189,42 @@ export default function Page() {
console.error(e);
toast({
variant: "destructive",
title: t('idpErrorFetch'),
title: t("idpErrorFetch"),
description: formatAxiosError(
e,
t('idpErrorFetchDescription')
t("idpErrorFetchDescription")
)
});
});
if (res?.status === 200) {
setIdps(res.data.data.idps);
setDataLoaded(true);
if (res.data.data.idps.length) {
setUserTypes((prev) =>
prev.map((type) => {
if (type.id === "oidc") {
return {
...type,
disabled: false
};
}
return type;
})
);
}
}
}
setDataLoaded(false);
fetchRoles();
if (userType !== "internal") {
fetchIdps();
async function fetchInitialData() {
setDataLoaded(false);
await fetchRoles();
await fetchIdps();
setDataLoaded(true);
}
}, [userType]);
fetchInitialData();
}, []);
async function onSubmitInternal(
values: z.infer<typeof internalFormSchema>
@@ -220,16 +245,16 @@ export default function Page() {
if (e.response?.status === 409) {
toast({
variant: "destructive",
title: t('userErrorExists'),
description: t('userErrorExistsDescription')
title: t("userErrorExists"),
description: t("userErrorExistsDescription")
});
} else {
toast({
variant: "destructive",
title: t('inviteError'),
title: t("inviteError"),
description: formatAxiosError(
e,
t('inviteErrorDescription')
t("inviteErrorDescription")
)
});
}
@@ -239,8 +264,8 @@ export default function Page() {
setInviteLink(res.data.data.inviteLink);
toast({
variant: "default",
title: t('userInvited'),
description: t('userInvitedDescription')
title: t("userInvited"),
description: t("userInvitedDescription")
});
setExpiresInDays(parseInt(values.validForHours) / 24);
@@ -266,10 +291,10 @@ export default function Page() {
.catch((e) => {
toast({
variant: "destructive",
title: t('userErrorCreate'),
title: t("userErrorCreate"),
description: formatAxiosError(
e,
t('userErrorCreateDescription')
t("userErrorCreateDescription")
)
});
});
@@ -277,8 +302,8 @@ export default function Page() {
if (res && res.status === 201) {
toast({
variant: "default",
title: t('userCreated'),
description: t('userCreatedDescription')
title: t("userCreated"),
description: t("userCreatedDescription")
});
router.push(`/${orgId}/settings/access/users`);
}
@@ -286,25 +311,12 @@ export default function Page() {
setLoading(false);
}
const userTypes: ReadonlyArray<UserTypeOption> = [
{
id: "internal",
title: t('userTypeInternal'),
description: t('userTypeInternalDescription')
},
{
id: "oidc",
title: t('userTypeExternal'),
description: t('userTypeExternalDescription')
}
];
return (
<>
<div className="flex justify-between">
<HeaderTitle
title={t('accessUserCreate')}
description={t('accessUserCreateDescription')}
title={t("accessUserCreate")}
description={t("accessUserCreateDescription")}
/>
<Button
variant="outline"
@@ -312,219 +324,243 @@ export default function Page() {
router.push(`/${orgId}/settings/access/users`);
}}
>
{t('userSeeAll')}
{t("userSeeAll")}
</Button>
</div>
<div>
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('userTypeTitle')}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t('userTypeDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<StrategySelect
options={userTypes}
defaultValue={userType || undefined}
onChange={(value) => {
setUserType(value as UserType);
if (value === "internal") {
internalForm.reset();
} else if (value === "oidc") {
externalForm.reset();
setSelectedIdp(null);
}
}}
cols={2}
/>
</SettingsSectionBody>
</SettingsSection>
{!inviteLink && build !== "saas" ? (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("userTypeTitle")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("userTypeDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<StrategySelect
options={userTypes}
defaultValue={userType || undefined}
onChange={(value) => {
setUserType(value as UserType);
if (value === "internal") {
internalForm.reset();
} else if (value === "oidc") {
externalForm.reset();
setSelectedIdp(null);
}
}}
cols={2}
/>
</SettingsSectionBody>
</SettingsSection>
) : null}
{userType === "internal" && dataLoaded && (
<>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('userSettings')}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t('userSettingsDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...internalForm}>
<form
onSubmit={internalForm.handleSubmit(
onSubmitInternal
)}
className="space-y-4"
id="create-user-form"
>
<FormField
control={
internalForm.control
}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('email')}
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
{!inviteLink ? (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("userSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("userSettingsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...internalForm}>
<form
onSubmit={internalForm.handleSubmit(
onSubmitInternal
)}
/>
{env.email.emailEnabled && (
<div className="flex items-center space-x-2">
<Checkbox
id="send-email"
checked={sendEmail}
onCheckedChange={(
e
) =>
setSendEmail(
e as boolean
)
}
/>
<label
htmlFor="send-email"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t('inviteEmailSent')}
</label>
</div>
)}
<FormField
control={
internalForm.control
}
name="validForHours"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('inviteValid')}
</FormLabel>
<Select
onValueChange={
field.onChange
}
defaultValue={
field.value
}
>
className="space-y-4"
id="create-user-form"
>
<FormField
control={
internalForm.control
}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("email")}
</FormLabel>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('selectDuration')} />
</SelectTrigger>
<Input
{...field}
/>
</FormControl>
<SelectContent>
{validFor.map(
(
option
) => (
<SelectItem
key={
option.hours
}
value={option.hours.toString()}
>
{
option.name
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={
internalForm.control
}
name="roleId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('role')}
</FormLabel>
<Select
onValueChange={
field.onChange
<FormField
control={
internalForm.control
}
name="validForHours"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"inviteValid"
)}
</FormLabel>
<Select
onValueChange={
field.onChange
}
defaultValue={
field.value
}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t(
"selectDuration"
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{validFor.map(
(
option
) => (
<SelectItem
key={
option.hours
}
value={option.hours.toString()}
>
{
option.name
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={
internalForm.control
}
name="roleId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("role")}
</FormLabel>
<Select
onValueChange={
field.onChange
}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t(
"accessRoleSelect"
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map(
(
role
) => (
<SelectItem
key={
role.roleId
}
value={role.roleId.toString()}
>
{
role.name
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{env.email.emailEnabled && (
<div className="flex items-center space-x-2">
<Checkbox
id="send-email"
checked={
sendEmail
}
onCheckedChange={(
e
) =>
setSendEmail(
e as boolean
)
}
/>
<label
htmlFor="send-email"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('accessRoleSelect')} />
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map(
(
role
) => (
<SelectItem
key={
role.roleId
}
value={role.roleId.toString()}
>
{
role.name
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
{t(
"inviteEmailSent"
)}
</label>
</div>
)}
/>
{inviteLink && (
<div className="max-w-md space-y-4">
{sendEmail && (
<p>
{t('inviteEmailSentDescription')}
</p>
)}
{!sendEmail && (
<p>
{t('inviteSentDescription')}
</p>
)}
<p>
{t('inviteExpiresIn', {days: expiresInDays})}
</p>
<CopyTextBox
text={inviteLink}
wrapText={false}
/>
</div>
)}
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
) : (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("userInvited")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{sendEmail
? t(
"inviteEmailSentDescription"
)
: t("inviteSentDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<div className="space-y-4">
<p>
{t("inviteExpiresIn", {
days: expiresInDays
})}
</p>
<CopyTextBox
text={inviteLink}
wrapText={false}
/>
</div>
</SettingsSectionBody>
</SettingsSection>
)}
</>
)}
@@ -533,16 +569,16 @@ export default function Page() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('idpTitle')}
{t("idpTitle")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t('idpSelect')}
{t("idpSelect")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{idps.length === 0 ? (
<p className="text-muted-foreground">
{t('idpNotConfigured')}
{t("idpNotConfigured")}
</p>
) : (
<Form {...externalForm}>
@@ -581,7 +617,7 @@ export default function Page() {
idp || null
);
}}
cols={3}
cols={2}
/>
<FormMessage />
</FormItem>
@@ -596,10 +632,10 @@ export default function Page() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('userSettings')}
{t("userSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t('userSettingsDescription')}
{t("userSettingsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -620,7 +656,9 @@ export default function Page() {
render={({ field }) => (
<FormItem>
<FormLabel>
{t('username')}
{t(
"username"
)}
</FormLabel>
<FormControl>
<Input
@@ -628,7 +666,9 @@ export default function Page() {
/>
</FormControl>
<p className="text-sm text-muted-foreground mt-1">
{t('usernameUniq')}
{t(
"usernameUniq"
)}
</p>
<FormMessage />
</FormItem>
@@ -643,7 +683,9 @@ export default function Page() {
render={({ field }) => (
<FormItem>
<FormLabel>
{t('emailOptional')}
{t(
"emailOptional"
)}
</FormLabel>
<FormControl>
<Input
@@ -663,7 +705,9 @@ export default function Page() {
render={({ field }) => (
<FormItem>
<FormLabel>
{t('nameOptional')}
{t(
"nameOptional"
)}
</FormLabel>
<FormControl>
<Input
@@ -683,7 +727,7 @@ export default function Page() {
render={({ field }) => (
<FormItem>
<FormLabel>
{t('role')}
{t("role")}
</FormLabel>
<Select
onValueChange={
@@ -691,8 +735,12 @@ export default function Page() {
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('accessRoleSelect')} />
<SelectTrigger className="w-full">
<SelectValue
placeholder={t(
"accessRoleSelect"
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -736,19 +784,24 @@ export default function Page() {
router.push(`/${orgId}/settings/access/users`);
}}
>
{t('cancel')}
{t("cancel")}
</Button>
{userType && dataLoaded && (
<Button
type="submit"
form="create-user-form"
type={inviteLink ? "button" : "submit"}
form={inviteLink ? undefined : "create-user-form"}
loading={loading}
disabled={
loading ||
(userType === "internal" && inviteLink !== null)
disabled={loading}
onClick={
inviteLink
? () =>
router.push(
`/${orgId}/settings/access/users`
)
: undefined
}
>
{t('accessUserCreate')}
{inviteLink ? t("done") : t("accessUserCreate")}
</Button>
)}
</div>

View File

@@ -50,11 +50,14 @@ export default function OrgApiKeysTable({
const deleteSite = (apiKeyId: string) => {
api.delete(`/org/${orgId}/api-key/${apiKeyId}`)
.catch((e) => {
console.error(t('apiKeysErrorDelete'), e);
console.error(t("apiKeysErrorDelete"), e);
toast({
variant: "destructive",
title: t('apiKeysErrorDelete'),
description: formatAxiosError(e, t('apiKeysErrorDeleteMessage'))
title: t("apiKeysErrorDelete"),
description: formatAxiosError(
e,
t("apiKeysErrorDeleteMessage")
)
});
})
.then(() => {
@@ -68,41 +71,6 @@ export default function OrgApiKeysTable({
};
const columns: ColumnDef<OrgApiKeyRow>[] = [
{
id: "dots",
cell: ({ row }) => {
const apiKeyROw = row.original;
const router = useRouter();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">{t('openMenu')}</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setSelected(apiKeyROw);
}}
>
<span>{t('viewSettings')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSelected(apiKeyROw);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">{t('delete')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
},
{
accessorKey: "name",
header: ({ column }) => {
@@ -113,7 +81,7 @@ export default function OrgApiKeysTable({
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t('name')}
{t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -121,7 +89,7 @@ export default function OrgApiKeysTable({
},
{
accessorKey: "key",
header: t('key'),
header: t("key"),
cell: ({ row }) => {
const r = row.original;
return <span className="font-mono">{r.key}</span>;
@@ -129,10 +97,10 @@ export default function OrgApiKeysTable({
},
{
accessorKey: "createdAt",
header: t('createdAt'),
header: t("createdAt"),
cell: ({ row }) => {
const r = row.original;
return <span>{moment(r.createdAt).format("lll")} </span>;
return <span>{moment(r.createdAt).format("lll")}</span>;
}
},
{
@@ -141,9 +109,43 @@ export default function OrgApiKeysTable({
const r = 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">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setSelected(r);
}}
>
<span>{t("viewSettings")}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSelected(r);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link href={`/${orgId}/settings/api-keys/${r.id}`}>
<Button variant={"outlinePrimary"} className="ml-2">
{t('edit')}
<Button
variant={"secondary"}
className="ml-2"
size="sm"
>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
@@ -165,24 +167,23 @@ export default function OrgApiKeysTable({
dialog={
<div className="space-y-4">
<p>
{t('apiKeysQuestionRemove', {selectedApiKey: selected?.name || selected?.id})}
{t("apiKeysQuestionRemove", {
selectedApiKey:
selected?.name || selected?.id
})}
</p>
<p>
<b>
{t('apiKeysMessageRemove')}
</b>
<b>{t("apiKeysMessageRemove")}</b>
</p>
<p>
{t('apiKeysMessageConfirm')}
</p>
<p>{t("apiKeysMessageConfirm")}</p>
</div>
}
buttonText={t('apiKeysDeleteConfirm')}
buttonText={t("apiKeysDeleteConfirm")}
onConfirm={async () => deleteSite(selected!.id)}
string={selected.name}
title={t('apiKeysDelete')}
title={t("apiKeysDelete")}
/>
)}

View File

@@ -2,16 +2,7 @@ import { internal } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
import { authCookieHeader } from "@app/lib/api/cookies";
import { SidebarSettings } from "@app/components/SidebarSettings";
import Link from "next/link";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from "@app/components/ui/breadcrumb";
import { GetApiKeyResponse } from "@server/routers/apiKeys";
import ApiKeyProvider from "@app/providers/ApiKeyProvider";
import { HorizontalTabs } from "@app/components/HorizontalTabs";

View File

@@ -25,21 +25,12 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@app/components/ui/input";
import { InfoIcon } from "lucide-react";
import { Button } from "@app/components/ui/button";
import { Checkbox, CheckboxWithLabel } from "@app/components/ui/checkbox";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { AxiosResponse } from "axios";
import { useParams, useRouter } from "next/navigation";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from "@app/components/ui/breadcrumb";
import Link from "next/link";
import {
CreateOrgApiKeyBody,
CreateOrgApiKeyResponse
@@ -110,7 +101,7 @@ export default function Page() {
const copiedForm = useForm<CopiedFormValues>({
resolver: zodResolver(copiedFormSchema),
defaultValues: {
copied: false
copied: true
}
});
@@ -308,54 +299,54 @@ export default function Page() {
</AlertDescription>
</Alert>
<h4 className="font-semibold">
{t('apiKeysInfo')}
</h4>
{/* <h4 className="font-semibold"> */}
{/* {t('apiKeysInfo')} */}
{/* </h4> */}
<CopyTextBox
text={`${apiKey.apiKeyId}.${apiKey.apiKey}`}
/>
<Form {...copiedForm}>
<form
className="space-y-4"
id="copied-form"
>
<FormField
control={copiedForm.control}
name="copied"
render={({ field }) => (
<FormItem>
<div className="flex items-center space-x-2">
<Checkbox
id="terms"
defaultChecked={
copiedForm.getValues(
"copied"
) as boolean
}
onCheckedChange={(
e
) => {
copiedForm.setValue(
"copied",
e as boolean
);
}}
/>
<label
htmlFor="terms"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t('apiKeysConfirmCopy')}
</label>
</div>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
{/* <Form {...copiedForm}> */}
{/* <form */}
{/* className="space-y-4" */}
{/* id="copied-form" */}
{/* > */}
{/* <FormField */}
{/* control={copiedForm.control} */}
{/* name="copied" */}
{/* render={({ field }) => ( */}
{/* <FormItem> */}
{/* <div className="flex items-center space-x-2"> */}
{/* <Checkbox */}
{/* id="terms" */}
{/* defaultChecked={ */}
{/* copiedForm.getValues( */}
{/* "copied" */}
{/* ) as boolean */}
{/* } */}
{/* onCheckedChange={( */}
{/* e */}
{/* ) => { */}
{/* copiedForm.setValue( */}
{/* "copied", */}
{/* e as boolean */}
{/* ); */}
{/* }} */}
{/* /> */}
{/* <label */}
{/* htmlFor="terms" */}
{/* className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" */}
{/* > */}
{/* {t('apiKeysConfirmCopy')} */}
{/* </label> */}
{/* </div> */}
{/* <FormMessage /> */}
{/* </FormItem> */}
{/* )} */}
{/* /> */}
{/* </form> */}
{/* </Form> */}
</SettingsSectionBody>
</SettingsSection>
)}

View File

@@ -0,0 +1,30 @@
"use client";
import {
ColumnDef,
} from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
addClient?: () => void;
}
export function ClientsDataTable<TData, TValue>({
columns,
data,
addClient
}: DataTableProps<TData, TValue>) {
return (
<DataTable
columns={columns}
data={data}
title="Clients"
searchPlaceholder="Search clients..."
searchColumn="name"
onAdd={addClient}
addButtonText="Add Client"
/>
);
}

View File

@@ -0,0 +1,298 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ClientsDataTable } from "./ClientsDataTable";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { Button } from "@app/components/ui/button";
import {
ArrowRight,
ArrowUpDown,
ArrowUpRight,
Check,
MoreHorizontal,
X
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
export type ClientRow = {
id: number;
name: string;
subnet: string;
// siteIds: string;
mbIn: string;
mbOut: string;
orgId: string;
online: boolean;
};
type ClientTableProps = {
clients: ClientRow[];
orgId: string;
};
export default function ClientsTable({ clients, orgId }: ClientTableProps) {
const router = useRouter();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedClient, setSelectedClient] = useState<ClientRow | null>(
null
);
const [rows, setRows] = useState<ClientRow[]>(clients);
const api = createApiClient(useEnvContext());
const deleteClient = (clientId: number) => {
api.delete(`/client/${clientId}`)
.catch((e) => {
console.error("Error deleting client", e);
toast({
variant: "destructive",
title: "Error deleting client",
description: formatAxiosError(e, "Error deleting client")
});
})
.then(() => {
router.refresh();
setIsDeleteModalOpen(false);
const newRows = rows.filter((row) => row.id !== clientId);
setRows(newRows);
});
};
const columns: ColumnDef<ClientRow>[] = [
{
accessorKey: "name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
// {
// accessorKey: "siteName",
// header: ({ column }) => {
// return (
// <Button
// variant="ghost"
// onClick={() =>
// column.toggleSorting(column.getIsSorted() === "asc")
// }
// >
// Site
// <ArrowUpDown className="ml-2 h-4 w-4" />
// </Button>
// );
// },
// cell: ({ row }) => {
// const r = row.original;
// return (
// <Link href={`/${r.orgId}/settings/sites/${r.siteId}`}>
// <Button variant="outline">
// {r.siteName}
// <ArrowUpRight className="ml-2 h-4 w-4" />
// </Button>
// </Link>
// );
// }
// },
{
accessorKey: "online",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Connectivity
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const originalRow = row.original;
if (originalRow.online) {
return (
<span className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Connected</span>
</span>
);
} else {
return (
<span className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>Disconnected</span>
</span>
);
}
}
},
{
accessorKey: "mbIn",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Data In
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "mbOut",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Data Out
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "subnet",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Address
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
id: "actions",
cell: ({ row }) => {
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}`}
>
<Button variant={"secondary"} className="ml-2">
Edit
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
);
}
}
];
return (
<>
{selectedClient && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedClient(null);
}}
dialog={
<div className="space-y-4">
<p>
Are you sure you want to remove the client{" "}
<b>
{selectedClient?.name || selectedClient?.id}
</b>{" "}
from the site and organization?
</p>
<p>
<b>
Once removed, the client will no longer be
able to connect to the site.{" "}
</b>
</p>
<p>
To confirm, please type the name of the client
below.
</p>
</div>
}
buttonText="Confirm Delete Client"
onConfirm={async () => deleteClient(selectedClient!.id)}
string={selectedClient.name}
title="Delete Client"
/>
)}
<ClientsDataTable
columns={columns}
data={rows}
addClient={() => {
router.push(`/${orgId}/settings/clients/create`)
}}
/>
</>
);
}

View File

@@ -0,0 +1,54 @@
"use client";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { InfoIcon } from "lucide-react";
import { useClientContext } from "@app/hooks/useClientContext";
import {
InfoSection,
InfoSectionContent,
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">{t("clientInformation")}</AlertTitle>
<AlertDescription className="mt-4">
<InfoSections cols={2}>
<>
<InfoSection>
<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>{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>{t("offline")}</span>
</div>
)}
</InfoSectionContent>
</InfoSection>
</>
<InfoSection>
<InfoSectionTitle>{t("address")}</InfoSectionTitle>
<InfoSectionContent>
{client.subnet.split("/")[0]}
</InfoSectionContent>
</InfoSection>
</InfoSections>
</AlertDescription>
</Alert>
);
}

View File

@@ -0,0 +1,227 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useClientContext } from "@app/hooks/useClientContext";
import { useForm } from "react-hook-form";
import { toast } from "@app/hooks/useToast";
import { useRouter } from "next/navigation";
import {
SettingsContainer,
SettingsSection,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionForm,
SettingsSectionFooter
} from "@app/components/Settings";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
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"),
siteIds: z.array(
z.object({
id: z.string(),
text: z.string()
})
)
});
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);
const router = useRouter();
const [sites, setSites] = useState<Tag[]>([]);
const [clientSites, setClientSites] = useState<Tag[]>([]);
const [activeSitesTagIndex, setActiveSitesTagIndex] = useState<number | null>(null);
const form = useForm<GeneralFormValues>({
resolver: zodResolver(GeneralFormSchema),
defaultValues: {
name: client?.name,
siteIds: []
},
mode: "onChange"
});
// Fetch available sites and client's assigned sites
useEffect(() => {
const fetchSites = async () => {
try {
// Fetch all available sites
const res = await api.get<AxiosResponse<ListSitesResponse>>(
`/org/${client?.orgId}/sites/`
);
const availableSites = res.data.data.sites
.filter((s) => s.type === "newt" && s.subnet)
.map((site) => ({
id: site.siteId.toString(),
text: site.name
}));
setSites(availableSites);
// Filter sites to only include those assigned to the client
const assignedSites = availableSites.filter((site) =>
client?.siteIds?.includes(parseInt(site.id))
);
setClientSites(assignedSites);
// Set the default values for the form
form.setValue("siteIds", assignedSites);
} catch (e) {
toast({
variant: "destructive",
title: "Failed to fetch sites",
description: formatAxiosError(
e,
"An error occurred while fetching sites."
)
});
}
};
if (client?.clientId) {
fetchSites();
}
}, [client?.clientId, client?.orgId, api, form]);
async function onSubmit(data: GeneralFormValues) {
setLoading(true);
try {
await api.post(`/client/${client?.clientId}`, {
name: data.name,
siteIds: data.siteIds.map(site => site.id)
});
updateClient({ name: data.name });
toast({
title: t("clientUpdated"),
description: t("clientUpdatedDescription")
});
router.refresh();
} catch (e) {
toast({
variant: "destructive",
title: t("clientUpdateFailed"),
description: formatAxiosError(
e,
t("clientUpdateError")
)
});
} finally {
setLoading(false);
}
}
return (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("generalSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("generalSettingsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="general-settings-form"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("name")}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</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={(newTags) => {
form.setValue(
"siteIds",
newTags as [Tag, ...Tag[]]
);
}}
enableAutocomplete={true}
autocompleteOptions={sites}
allowDuplicates={false}
restrictTagsToAutocompleteOptions={true}
sortTags={true}
/>
<FormDescription>
{t("sitesDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
form="general-settings-form"
loading={loading}
disabled={loading}
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
);
}

View File

@@ -0,0 +1,57 @@
import { internal } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { authCookieHeader } from "@app/lib/api/cookies";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { GetClientResponse } from "@server/routers/client";
import ClientInfoCard from "./ClientInfoCard";
import ClientProvider from "@app/providers/ClientProvider";
import { redirect } from "next/navigation";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
type SettingsLayoutProps = {
children: React.ReactNode;
params: Promise<{ clientId: number; orgId: string }>;
}
export default async function SettingsLayout(props: SettingsLayoutProps) {
const params = await props.params;
const { children } = props;
let client = null;
try {
const res = await internal.get<AxiosResponse<GetClientResponse>>(
`/org/${params.orgId}/client/${params.clientId}`,
await authCookieHeader()
);
client = res.data.data;
} catch (error) {
console.error("Error fetching client data:", error);
redirect(`/${params.orgId}/settings/clients`);
}
const navItems = [
{
title: "General",
href: `/{orgId}/settings/clients/{clientId}/general`
}
];
return (
<>
<SettingsSectionTitle
title={`${client?.name} Settings`}
description="Configure the settings on your site"
/>
<ClientProvider client={client}>
<div className="space-y-6">
<ClientInfoCard />
<HorizontalTabs items={navItems}>
{children}
</HorizontalTabs>
</div>
</ClientProvider>
</>
);
}

View File

@@ -0,0 +1,8 @@
import { redirect } from "next/navigation";
export default async function ClientPage(props: {
params: Promise<{ orgId: string; clientId: number }>;
}) {
const params = await props.params;
redirect(`/${params.orgId}/settings/clients/${params.clientId}/general`);
}

View 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>
)}
</>
);
}

View File

@@ -0,0 +1,21 @@
import { redirect } from "next/navigation";
import { pullEnv } from "@app/lib/pullEnv";
export const dynamic = "force-dynamic";
interface SettingsLayoutProps {
children: React.ReactNode;
params: Promise<{ orgId: string }>;
}
export default async function SettingsLayout(props: SettingsLayoutProps) {
const params = await props.params;
const { children } = props;
const env = pullEnv();
if (!env.flags.enableClients) {
redirect(`/${params.orgId}/settings`);
}
return children;
}

View File

@@ -0,0 +1,58 @@
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
import { ClientRow } from "./ClientsTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { ListClientsResponse } from "@server/routers/client";
import ClientsTable from "./ClientsTable";
type ClientsPageProps = {
params: Promise<{ orgId: string }>;
};
export const dynamic = "force-dynamic";
export default async function ClientsPage(props: ClientsPageProps) {
const params = await props.params;
let clients: ListClientsResponse["clients"] = [];
try {
const res = await internal.get<AxiosResponse<ListClientsResponse>>(
`/org/${params.orgId}/clients`,
await authCookieHeader()
);
clients = res.data.data.clients;
} catch (e) {}
function formatSize(mb: number): string {
if (mb >= 1024 * 1024) {
return `${(mb / (1024 * 1024)).toFixed(2)} TB`;
} else if (mb >= 1024) {
return `${(mb / 1024).toFixed(2)} GB`;
} else {
return `${mb.toFixed(2)} MB`;
}
}
const clientRows: ClientRow[] = clients.map((client) => {
return {
name: client.name,
id: client.clientId,
subnet: client.subnet.split("/")[0],
mbIn: formatSize(client.megabytesIn || 0),
mbOut: formatSize(client.megabytesOut || 0),
orgId: params.orgId,
online: client.online
};
});
return (
<>
<SettingsSectionTitle
title="Manage Clients (beta)"
description="Clients are devices that can connect to your sites"
/>
<ClientsTable clients={clientRows} orgId={params.orgId} />
</>
);
}

View File

@@ -0,0 +1,516 @@
"use client";
import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { useToast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import { formatAxiosError } from "@app/lib/api";
import { CreateDomainResponse } from "@server/routers/domain/createOrgDomain";
import { StrategySelect } from "@app/components/StrategySelect";
import { AxiosResponse } from "axios";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon, AlertTriangle } from "lucide-react";
import CopyToClipboard from "@app/components/CopyToClipboard";
import {
InfoSection,
InfoSectionContent,
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { build } from "@server/build";
const formSchema = z.object({
baseDomain: z.string().min(1, "Domain is required"),
type: z.enum(["ns", "cname", "wildcard"])
});
type FormValues = z.infer<typeof formSchema>;
type CreateDomainFormProps = {
open: boolean;
setOpen: (open: boolean) => void;
onCreated?: (domain: CreateDomainResponse) => void;
};
export default function CreateDomainForm({
open,
setOpen,
onCreated
}: CreateDomainFormProps) {
const [loading, setLoading] = useState(false);
const [createdDomain, setCreatedDomain] =
useState<CreateDomainResponse | null>(null);
const api = createApiClient(useEnvContext());
const t = useTranslations();
const { toast } = useToast();
const { org } = useOrgContext();
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
baseDomain: "",
type: build == "oss" ? "wildcard" : "ns"
}
});
function reset() {
form.reset();
setLoading(false);
setCreatedDomain(null);
}
async function onSubmit(values: FormValues) {
setLoading(true);
try {
const response = await api.put<AxiosResponse<CreateDomainResponse>>(
`/org/${org.org.orgId}/domain`,
values
);
const domainData = response.data.data;
setCreatedDomain(domainData);
toast({
title: t("success"),
description: t("domainCreatedDescription")
});
onCreated?.(domainData);
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e),
variant: "destructive"
});
} finally {
setLoading(false);
}
}
const domainType = form.watch("type");
const baseDomain = form.watch("baseDomain");
let domainOptions: any = [];
if (build == "enterprise" || build == "saas") {
domainOptions = [
{
id: "ns",
title: t("selectDomainTypeNsName"),
description: t("selectDomainTypeNsDescription")
},
{
id: "cname",
title: t("selectDomainTypeCnameName"),
description: t("selectDomainTypeCnameDescription")
}
];
} else if (build == "oss") {
domainOptions = [
{
id: "wildcard",
title: t("selectDomainTypeWildcardName"),
description: t("selectDomainTypeWildcardDescription")
}
];
}
return (
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
reset();
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>{t("domainAdd")}</CredenzaTitle>
<CredenzaDescription>
{t("domainAddDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
{!createdDomain ? (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="create-domain-form"
>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<StrategySelect
options={domainOptions}
defaultValue={field.value}
onChange={field.onChange}
cols={1}
/>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="baseDomain"
render={({ field }) => (
<FormItem>
<FormLabel>{t("domain")}</FormLabel>
<FormControl>
<Input
placeholder="example.com"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
) : (
<div className="space-y-6">
<Alert variant="default">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("createDomainAddDnsRecords")}
</AlertTitle>
<AlertDescription>
{t("createDomainAddDnsRecordsDescription")}
</AlertDescription>
</Alert>
<div className="space-y-4">
{createdDomain.nsRecords &&
createdDomain.nsRecords.length > 0 && (
<div>
<h3 className="font-medium mb-3">
{t("createDomainNsRecords")}
</h3>
<InfoSections cols={1}>
<InfoSection>
<InfoSectionTitle>
{t("createDomainRecord")}
</InfoSectionTitle>
<InfoSectionContent>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainType"
)}
</span>
<span className="text-sm font-mono">
NS
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainName"
)}
</span>
<span className="text-sm font-mono">
{baseDomain}
</span>
</div>
<span className="text-sm font-medium">
{t(
"createDomainValue"
)}
</span>
{createdDomain.nsRecords.map(
(
nsRecord,
index
) => (
<div
className="flex justify-between items-center"
key={index}
>
<CopyToClipboard
text={
nsRecord
}
/>
</div>
)
)}
</div>
</InfoSectionContent>
</InfoSection>
</InfoSections>
</div>
)}
{createdDomain.cnameRecords &&
createdDomain.cnameRecords.length > 0 && (
<div>
<h3 className="font-medium mb-3">
{t("createDomainCnameRecords")}
</h3>
<InfoSections cols={1}>
{createdDomain.cnameRecords.map(
(cnameRecord, index) => (
<InfoSection
key={index}
>
<InfoSectionTitle>
{t(
"createDomainRecordNumber",
{
number:
index +
1
}
)}
</InfoSectionTitle>
<InfoSectionContent>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainType"
)}
</span>
<span className="text-sm font-mono">
CNAME
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainName"
)}
</span>
<span className="text-sm font-mono">
{
cnameRecord.baseDomain
}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainValue"
)}
</span>
<CopyToClipboard
text={
cnameRecord.value
}
/>
</div>
</div>
</InfoSectionContent>
</InfoSection>
)
)}
</InfoSections>
</div>
)}
{createdDomain.aRecords &&
createdDomain.aRecords.length > 0 && (
<div>
<h3 className="font-medium mb-3">
{t("createDomainARecords")}
</h3>
<InfoSections cols={1}>
{createdDomain.aRecords.map(
(aRecord, index) => (
<InfoSection
key={index}
>
<InfoSectionTitle>
{t(
"createDomainRecordNumber",
{
number:
index +
1
}
)}
</InfoSectionTitle>
<InfoSectionContent>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainType"
)}
</span>
<span className="text-sm font-mono">
A
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainName"
)}
</span>
<span className="text-sm font-mono">
{
aRecord.baseDomain
}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainValue"
)}
</span>
<span className="text-sm font-mono">
{
aRecord.value
}
</span>
</div>
</div>
</InfoSectionContent>
</InfoSection>
)
)}
</InfoSections>
</div>
)}
{createdDomain.txtRecords &&
createdDomain.txtRecords.length > 0 && (
<div>
<h3 className="font-medium mb-3">
{t("createDomainTxtRecords")}
</h3>
<InfoSections cols={1}>
{createdDomain.txtRecords.map(
(txtRecord, index) => (
<InfoSection
key={index}
>
<InfoSectionTitle>
{t(
"createDomainRecordNumber",
{
number:
index +
1
}
)}
</InfoSectionTitle>
<InfoSectionContent>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainType"
)}
</span>
<span className="text-sm font-mono">
TXT
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainName"
)}
</span>
<span className="text-sm font-mono">
{
txtRecord.baseDomain
}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainValue"
)}
</span>
<CopyToClipboard
text={
txtRecord.value
}
/>
</div>
</div>
</InfoSectionContent>
</InfoSection>
)
)}
</InfoSections>
</div>
)}
</div>
{build == "saas" ||
(build == "enterprise" && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("createDomainSaveTheseRecords")}
</AlertTitle>
<AlertDescription>
{t(
"createDomainSaveTheseRecordsDescription"
)}
</AlertDescription>
</Alert>
))}
<Alert variant="info">
<AlertTriangle className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("createDomainDnsPropagation")}
</AlertTitle>
<AlertDescription>
{t("createDomainDnsPropagationDescription")}
</AlertDescription>
</Alert>
</div>
)}
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("close")}</Button>
</CredenzaClose>
{!createdDomain && (
<Button
type="submit"
form="create-domain-form"
loading={loading}
disabled={loading}
>
{t("domainCreate")}
</Button>
)}
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}

View File

@@ -0,0 +1,37 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
import { useTranslations } from "next-intl";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
onAdd?: () => void;
onRefresh?: () => void;
isRefreshing?: boolean;
}
export function DomainsDataTable<TData, TValue>({
columns,
data,
onAdd,
onRefresh,
isRefreshing
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
title={t("domains")}
searchPlaceholder={t("domainsSearch")}
searchColumn="baseDomain"
addButtonText={t("domainAdd")}
onAdd={onAdd}
onRefresh={onRefresh}
isRefreshing={isRefreshing}
/>
);
}

View File

@@ -0,0 +1,278 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { DomainsDataTable } from "./DomainsDataTable";
import { Button } from "@app/components/ui/button";
import { ArrowUpDown } from "lucide-react";
import { useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { Badge } from "@app/components/ui/badge";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import CreateDomainForm from "./CreateDomainForm";
import { useToast } from "@app/hooks/useToast";
import { useOrgContext } from "@app/hooks/useOrgContext";
export type DomainRow = {
domainId: string;
baseDomain: string;
type: string;
verified: boolean;
failed: boolean;
tries: number;
configManaged: boolean;
};
type Props = {
domains: DomainRow[];
};
export default function DomainsTable({ domains }: Props) {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [selectedDomain, setSelectedDomain] = useState<DomainRow | null>(
null
);
const [isRefreshing, setIsRefreshing] = useState(false);
const [restartingDomains, setRestartingDomains] = useState<Set<string>>(
new Set()
);
const api = createApiClient(useEnvContext());
const router = useRouter();
const t = useTranslations();
const { toast } = useToast();
const { org } = useOrgContext();
const refreshData = async () => {
setIsRefreshing(true);
try {
await new Promise((resolve) => setTimeout(resolve, 200));
router.refresh();
} catch (error) {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
} finally {
setIsRefreshing(false);
}
};
const deleteDomain = async (domainId: string) => {
try {
await api.delete(`/org/${org.org.orgId}/domain/${domainId}`);
toast({
title: t("success"),
description: t("domainDeletedDescription")
});
setIsDeleteModalOpen(false);
refreshData();
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e),
variant: "destructive"
});
}
};
const restartDomain = async (domainId: string) => {
setRestartingDomains((prev) => new Set(prev).add(domainId));
try {
await api.post(`/org/${org.org.orgId}/domain/${domainId}/restart`);
toast({
title: t("success"),
description: t("domainRestartedDescription", {
fallback: "Domain verification restarted successfully"
})
});
refreshData();
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e),
variant: "destructive"
});
} finally {
setRestartingDomains((prev) => {
const newSet = new Set(prev);
newSet.delete(domainId);
return newSet;
});
}
};
const getTypeDisplay = (type: string) => {
switch (type) {
case "ns":
return t("selectDomainTypeNsName");
case "cname":
return t("selectDomainTypeCnameName");
case "wildcard":
return t("selectDomainTypeWildcardName");
default:
return type;
}
};
const columns: ColumnDef<DomainRow>[] = [
{
accessorKey: "baseDomain",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("domain")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "type",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("type")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const type = row.original.type;
return (
<Badge variant="secondary">{getTypeDisplay(type)}</Badge>
);
}
},
{
accessorKey: "verified",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("status")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const { verified, failed } = row.original;
if (verified) {
return <Badge variant="green">{t("verified")}</Badge>;
} else if (failed) {
return (
<Badge variant="destructive">
{t("failed", { fallback: "Failed" })}
</Badge>
);
} else {
return <Badge variant="yellow">{t("pending")}</Badge>;
}
}
},
{
id: "actions",
cell: ({ row }) => {
const domain = row.original;
const isRestarting = restartingDomains.has(domain.domainId);
return (
<div className="flex items-center justify-end gap-2">
{domain.failed && (
<Button
variant="outline"
size="sm"
onClick={() => restartDomain(domain.domainId)}
disabled={isRestarting}
>
{isRestarting
? t("restarting", {
fallback: "Restarting..."
})
: t("restart", { fallback: "Restart" })}
</Button>
)}
<Button
variant="secondary"
size="sm"
disabled={domain.configManaged}
onClick={() => {
setSelectedDomain(domain);
setIsDeleteModalOpen(true);
}}
>
{t("delete")}
</Button>
</div>
);
}
}
];
return (
<>
{selectedDomain && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedDomain(null);
}}
dialog={
<div className="space-y-4">
<p>
{t("domainQuestionRemove", {
domain: selectedDomain.baseDomain
})}
</p>
<p>
<b>{t("domainMessageRemove")}</b>
</p>
<p>{t("domainMessageConfirm")}</p>
</div>
}
buttonText={t("domainConfirmDelete")}
onConfirm={async () =>
deleteDomain(selectedDomain.domainId)
}
string={selectedDomain.baseDomain}
title={t("domainDelete")}
/>
)}
<CreateDomainForm
open={isCreateModalOpen}
setOpen={setIsCreateModalOpen}
onCreated={(domain) => {
refreshData();
}}
/>
<DomainsDataTable
columns={columns}
data={domains}
onAdd={() => setIsCreateModalOpen(true)}
onRefresh={refreshData}
isRefreshing={isRefreshing}
/>
</>
);
}

View File

@@ -0,0 +1,60 @@
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import DomainsTable, { DomainRow } from "./DomainsTable";
import { getTranslations } from "next-intl/server";
import { cache } from "react";
import { GetOrgResponse } from "@server/routers/org";
import { redirect } from "next/navigation";
import OrgProvider from "@app/providers/OrgProvider";
import { ListDomainsResponse } from "@server/routers/domain";
type Props = {
params: Promise<{ orgId: string }>;
};
export default async function DomainsPage(props: Props) {
const params = await props.params;
let domains: DomainRow[] = [];
try {
const res = await internal.get<
AxiosResponse<ListDomainsResponse>
>(`/org/${params.orgId}/domains`, await authCookieHeader());
domains = res.data.data.domains as DomainRow[];
} catch (e) {
console.error(e);
}
let org = null;
try {
const getOrg = cache(async () =>
internal.get<AxiosResponse<GetOrgResponse>>(
`/org/${params.orgId}`,
await authCookieHeader()
)
);
const res = await getOrg();
org = res.data.data;
} catch {
redirect(`/${params.orgId}`);
}
if (!org) {
}
const t = await getTranslations();
return (
<>
<OrgProvider org={org}>
<SettingsSectionTitle
title={t("domains")}
description={t("domainsDescription")}
/>
<DomainsTable domains={domains} />
</OrgProvider>
</>
);
}

View File

@@ -1,5 +1,4 @@
"use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { Button } from "@app/components/ui/button";
import { useOrgContext } from "@app/hooks/useOrgContext";
@@ -22,17 +21,9 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { formatAxiosError } from "@app/lib/api";
import { AlertTriangle, Trash2 } from "lucide-react";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle
} from "@/components/ui/card";
import { AxiosResponse } from "axios";
import { DeleteOrgResponse, ListUserOrgsResponse } from "@server/routers/org";
import { redirect, useRouter } from "next/navigation";
import { useRouter } from "next/navigation";
import {
SettingsContainer,
SettingsSection,
@@ -44,23 +35,26 @@ import {
SettingsSectionFooter
} from "@app/components/Settings";
import { useUserContext } from "@app/hooks/useUserContext";
import { useTranslations } from 'next-intl';
import { useTranslations } from "next-intl";
import { build } from "@server/build";
// Updated schema to include subnet field
const GeneralFormSchema = z.object({
name: z.string()
name: z.string(),
subnet: z.string().optional()
});
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
export default function GeneralPage() {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const { orgUser } = userOrgUserContext();
const router = useRouter();
const { org } = useOrgContext();
const api = createApiClient(useEnvContext());
const { user } = useUserContext();
const t = useTranslations();
const { env } = useEnvContext();
const [loadingDelete, setLoadingDelete] = useState(false);
const [loadingSave, setLoadingSave] = useState(false);
@@ -68,7 +62,8 @@ export default function GeneralPage() {
const form = useForm<GeneralFormValues>({
resolver: zodResolver(GeneralFormSchema),
defaultValues: {
name: org?.org.name
name: org?.org.name,
subnet: org?.org.subnet || "" // Add default value for subnet
},
mode: "onChange"
});
@@ -79,12 +74,10 @@ export default function GeneralPage() {
const res = await api.delete<AxiosResponse<DeleteOrgResponse>>(
`/org/${org?.org.orgId}`
);
toast({
title: t('orgDeleted'),
description: t('orgDeletedMessage')
title: t("orgDeleted"),
description: t("orgDeletedMessage")
});
if (res.status === 200) {
pickNewOrgAndNavigate();
}
@@ -92,8 +85,8 @@ export default function GeneralPage() {
console.error(err);
toast({
variant: "destructive",
title: t('orgErrorDelete'),
description: formatAxiosError(err, t('orgErrorDeleteMessage'))
title: t("orgErrorDelete"),
description: formatAxiosError(err, t("orgErrorDeleteMessage"))
});
} finally {
setLoadingDelete(false);
@@ -120,8 +113,8 @@ export default function GeneralPage() {
console.error(err);
toast({
variant: "destructive",
title: t('orgErrorFetch'),
description: formatAxiosError(err, t('orgErrorFetchMessage'))
title: t("orgErrorFetch"),
description: formatAxiosError(err, t("orgErrorFetchMessage"))
});
}
}
@@ -130,21 +123,21 @@ export default function GeneralPage() {
setLoadingSave(true);
await api
.post(`/org/${org?.org.orgId}`, {
name: data.name
name: data.name,
// subnet: data.subnet // Include subnet in the API request
})
.then(() => {
toast({
title: t('orgUpdated'),
description: t('orgUpdatedDescription')
title: t("orgUpdated"),
description: t("orgUpdatedDescription")
});
router.refresh();
})
.catch((e) => {
toast({
variant: "destructive",
title: t('orgErrorUpdate'),
description: formatAxiosError(e, t('orgErrorUpdateMessage'))
title: t("orgErrorUpdate"),
description: formatAxiosError(e, t("orgErrorUpdateMessage"))
});
})
.finally(() => {
@@ -162,32 +155,28 @@ export default function GeneralPage() {
dialog={
<div>
<p className="mb-2">
{t('orgQuestionRemove', {selectedOrg: org?.org.name})}
</p>
<p className="mb-2">
{t('orgMessageRemove')}
</p>
<p>
{t('orgMessageConfirm')}
{t("orgQuestionRemove", {
selectedOrg: org?.org.name
})}
</p>
<p className="mb-2">{t("orgMessageRemove")}</p>
<p>{t("orgMessageConfirm")}</p>
</div>
}
buttonText={t('orgDeleteConfirm')}
buttonText={t("orgDeleteConfirm")}
onConfirm={deleteOrg}
string={org?.org.name || ""}
title={t('orgDelete')}
title={t("orgDelete")}
/>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('orgGeneralSettings')}
{t("orgGeneralSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t('orgGeneralSettingsDescription')}
{t("orgGeneralSettingsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
@@ -201,22 +190,44 @@ export default function GeneralPage() {
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t('name')}</FormLabel>
<FormLabel>{t("name")}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
<FormDescription>
{t('orgDisplayName')}
{t("orgDisplayName")}
</FormDescription>
</FormItem>
)}
/>
{env.flags.enableClients && (
<FormField
control={form.control}
name="subnet"
render={({ field }) => (
<FormItem>
<FormLabel>Subnet</FormLabel>
<FormControl>
<Input
{...field}
disabled={true}
/>
</FormControl>
<FormMessage />
<FormDescription>
The subnet for this
organization's network
configuration.
</FormDescription>
</FormItem>
)}
/>
)}
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
@@ -224,31 +235,33 @@ export default function GeneralPage() {
loading={loadingSave}
disabled={loadingSave}
>
{t('saveGeneralSettings')}
</Button>
</SettingsSectionFooter>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>{t('orgDangerZone')}</SettingsSectionTitle>
<SettingsSectionDescription>
{t('orgDangerZoneDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionFooter>
<Button
variant="destructive"
onClick={() => setIsDeleteModalOpen(true)}
className="flex items-center gap-2"
loading={loadingDelete}
disabled={loadingDelete}
>
{t('orgDelete')}
{t("saveGeneralSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
{build === "oss" && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("orgDangerZone")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("orgDangerZoneDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionFooter>
<Button
variant="destructive"
onClick={() => setIsDeleteModalOpen(true)}
className="flex items-center gap-2"
loading={loadingDelete}
disabled={loadingDelete}
>
{t("orgDelete")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
)}
</SettingsContainer>
);
}

View File

@@ -1,16 +1,18 @@
import { Metadata } from "next";
import {
Cog,
Combine,
KeyRound,
LinkIcon,
Settings,
Users,
Waypoints
Waypoints,
Workflow
} from "lucide-react";
import { verifySession } from "@app/lib/auth/verifySession";
import { redirect } from "next/navigation";
import { internal } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { ListOrgsResponse } from "@server/routers/org";
import { GetOrgResponse, ListUserOrgsResponse } from "@server/routers/org";
import { authCookieHeader } from "@app/lib/api/cookies";
import { cache } from "react";
@@ -18,8 +20,9 @@ import { GetOrgUserResponse } from "@server/routers/user";
import UserProvider from "@app/providers/UserProvider";
import { Layout } from "@app/components/Layout";
import { SidebarNavItem, SidebarNavProps } from "@app/components/SidebarNav";
import { orgNavItems } from "@app/app/navigation";
import { getTranslations } from 'next-intl/server';
import { getTranslations } from "next-intl/server";
import { pullEnv } from "@app/lib/pullEnv";
import { orgNavSections } from "@app/app/navigation";
export const dynamic = "force-dynamic";
@@ -41,6 +44,8 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
const getUser = cache(verifySession);
const user = await getUser();
const env = pullEnv();
if (!user) {
redirect(`/`);
}
@@ -59,7 +64,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
const orgUser = await getOrgUser();
if (!orgUser.data.data.isAdmin && !orgUser.data.data.isOwner) {
throw new Error(t('userErrorNotAdminOrOwner'));
throw new Error(t("userErrorNotAdminOrOwner"));
}
} catch {
redirect(`/${params.orgId}`);
@@ -81,7 +86,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
return (
<UserProvider user={user}>
<Layout orgId={params.orgId} orgs={orgs} navItems={orgNavItems}>
<Layout orgId={params.orgId} orgs={orgs} navItems={orgNavSections(env.flags.enableClients)}>
{children}
</Layout>
</UserProvider>

View File

@@ -31,7 +31,9 @@ import CopyToClipboard from "@app/components/CopyToClipboard";
import { Switch } from "@app/components/ui/switch";
import { AxiosResponse } from "axios";
import { UpdateResourceResponse } from "@server/routers/resource";
import { useTranslations } from 'next-intl';
import { useTranslations } from "next-intl";
import { InfoPopup } from "@app/components/ui/info-popup";
import { Badge } from "@app/components/ui/badge";
export type ResourceRow = {
id: number;
@@ -45,6 +47,7 @@ export type ResourceRow = {
protocol: string;
proxyPort: number | null;
enabled: boolean;
domainId?: string;
};
type ResourcesTableProps = {
@@ -65,11 +68,11 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
const deleteResource = (resourceId: number) => {
api.delete(`/resource/${resourceId}`)
.catch((e) => {
console.error(t('resourceErrorDelte'), e);
console.error(t("resourceErrorDelte"), e);
toast({
variant: "destructive",
title: t('resourceErrorDelte'),
description: formatAxiosError(e, t('resourceErrorDelte'))
title: t("resourceErrorDelte"),
description: formatAxiosError(e, t("resourceErrorDelte"))
});
})
.then(() => {
@@ -89,50 +92,16 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
.catch((e) => {
toast({
variant: "destructive",
title: t('resourcesErrorUpdate'),
description: formatAxiosError(e, t('resourcesErrorUpdateDescription'))
title: t("resourcesErrorUpdate"),
description: formatAxiosError(
e,
t("resourcesErrorUpdateDescription")
)
});
});
}
const columns: ColumnDef<ResourceRow>[] = [
{
accessorKey: "dots",
header: "",
cell: ({ row }) => {
const resourceRow = row.original;
const router = useRouter();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">{t('openMenu')}</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Link
className="block w-full"
href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`}
>
<DropdownMenuItem>
{t('viewSettings')}
</DropdownMenuItem>
</Link>
<DropdownMenuItem
onClick={() => {
setSelectedResource(resourceRow);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">{t('delete')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
},
{
accessorKey: "name",
header: ({ column }) => {
@@ -143,7 +112,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t('name')}
{t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -159,7 +128,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t('site')}
{t("site")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -170,7 +139,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
<Link
href={`/${resourceRow.orgId}/settings/sites/${resourceRow.siteId}`}
>
<Button variant="outline">
<Button variant="outline" size="sm">
{resourceRow.site}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
@@ -180,7 +149,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
},
{
accessorKey: "protocol",
header: t('protocol'),
header: t("protocol"),
cell: ({ row }) => {
const resourceRow = row.original;
return <span>{resourceRow.protocol.toUpperCase()}</span>;
@@ -188,16 +157,21 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
},
{
accessorKey: "domain",
header: t('access'),
header: t("access"),
cell: ({ row }) => {
const resourceRow = row.original;
return (
<div>
<div className="flex items-center space-x-2">
{!resourceRow.http ? (
<CopyToClipboard
text={resourceRow.proxyPort!.toString()}
isLink={false}
/>
) : !resourceRow.domainId ? (
<InfoPopup
info={t("domainNotFoundDescription")}
text={t("domainNotFound")}
/>
) : (
<CopyToClipboard
text={resourceRow.domain}
@@ -218,7 +192,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t('authentication')}
{t("authentication")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -230,12 +204,12 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
{resourceRow.authState === "protected" ? (
<span className="text-green-500 flex items-center space-x-2">
<ShieldCheck className="w-4 h-4" />
<span>{t('protected')}</span>
<span>{t("protected")}</span>
</span>
) : resourceRow.authState === "not_protected" ? (
<span className="text-yellow-500 flex items-center space-x-2">
<ShieldOff className="w-4 h-4" />
<span>{t('notProtected')}</span>
<span>{t("notProtected")}</span>
</span>
) : (
<span>-</span>
@@ -246,10 +220,15 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
},
{
accessorKey: "enabled",
header: t('enabled'),
header: t("enabled"),
cell: ({ row }) => (
<Switch
defaultChecked={row.original.enabled}
defaultChecked={
row.original.http
? (!!row.original.domainId && row.original.enabled)
: row.original.enabled
}
disabled={row.original.http ? !row.original.domainId : false}
onCheckedChange={(val) =>
toggleResourceEnabled(val, row.original.id)
}
@@ -262,11 +241,45 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
const resourceRow = 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">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Link
className="block w-full"
href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`}
>
<DropdownMenuItem>
{t("viewSettings")}
</DropdownMenuItem>
</Link>
<DropdownMenuItem
onClick={() => {
setSelectedResource(resourceRow);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link
href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`}
>
<Button variant={"outlinePrimary"} className="ml-2">
{t('edit')}
<Button
variant={"secondary"}
className="ml-2"
size="sm"
>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
@@ -288,22 +301,22 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
dialog={
<div>
<p className="mb-2">
{t('resourceQuestionRemove', {selectedResource: selectedResource?.name || selectedResource?.id})}
{t("resourceQuestionRemove", {
selectedResource:
selectedResource?.name ||
selectedResource?.id
})}
</p>
<p className="mb-2">
{t('resourceMessageRemove')}
</p>
<p className="mb-2">{t("resourceMessageRemove")}</p>
<p>
{t('resourceMessageConfirm')}
</p>
<p>{t("resourceMessageConfirm")}</p>
</div>
}
buttonText={t('resourceDeleteConfirm')}
buttonText={t("resourceDeleteConfirm")}
onConfirm={async () => deleteResource(selectedResource!.id)}
string={selectedResource.name}
title={t('resourceDelete')}
title={t("resourceDelete")}
/>
)}

View File

@@ -10,10 +10,15 @@ import {
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useDockerSocket } from "@app/hooks/useDockerSocket";
import { useTranslations } from "next-intl";
import { AxiosResponse } from "axios";
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 = {};
@@ -30,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}>
@@ -38,7 +43,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
<>
<InfoSection>
<InfoSectionTitle>
{t('authentication')}
{t("authentication")}
</InfoSectionTitle>
<InfoSectionContent>
{authInfo.password ||
@@ -47,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>
@@ -67,7 +72,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>{t('site')}</InfoSectionTitle>
<InfoSectionTitle>{t("site")}</InfoSectionTitle>
<InfoSectionContent>
{resource.siteName}
</InfoSectionContent>
@@ -94,7 +99,9 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
) : (
<>
<InfoSection>
<InfoSectionTitle>{t('protocol')}</InfoSectionTitle>
<InfoSectionTitle>
{t("protocol")}
</InfoSectionTitle>
<InfoSectionContent>
<span>
{resource.protocol.toUpperCase()}
@@ -102,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()}
@@ -110,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>

View File

@@ -205,10 +205,10 @@ export default function ResourceAuthenticationPage() {
console.error(e);
toast({
variant: "destructive",
title: t('resourceErrorAuthFetch'),
title: t("resourceErrorAuthFetch"),
description: formatAxiosError(
e,
t('resourceErrorAuthFetchDescription')
t("resourceErrorAuthFetchDescription")
)
});
}
@@ -235,18 +235,18 @@ export default function ResourceAuthenticationPage() {
});
toast({
title: t('resourceWhitelistSave'),
description: t('resourceWhitelistSaveDescription')
title: t("resourceWhitelistSave"),
description: t("resourceWhitelistSaveDescription")
});
router.refresh();
} catch (e) {
console.error(e);
toast({
variant: "destructive",
title: t('resourceErrorWhitelistSave'),
title: t("resourceErrorWhitelistSave"),
description: formatAxiosError(
e,
t('resourceErrorWhitelistSaveDescription')
t("resourceErrorWhitelistSaveDescription")
)
});
} finally {
@@ -283,18 +283,18 @@ export default function ResourceAuthenticationPage() {
});
toast({
title: t('resourceAuthSettingsSave'),
description: t('resourceAuthSettingsSaveDescription')
title: t("resourceAuthSettingsSave"),
description: t("resourceAuthSettingsSaveDescription")
});
router.refresh();
} catch (e) {
console.error(e);
toast({
variant: "destructive",
title: t('resourceErrorUsersRolesSave'),
title: t("resourceErrorUsersRolesSave"),
description: formatAxiosError(
e,
t('resourceErrorUsersRolesSaveDescription')
t("resourceErrorUsersRolesSaveDescription")
)
});
} finally {
@@ -310,8 +310,8 @@ export default function ResourceAuthenticationPage() {
})
.then(() => {
toast({
title: t('resourcePasswordRemove'),
description: t('resourcePasswordRemoveDescription')
title: t("resourcePasswordRemove"),
description: t("resourcePasswordRemoveDescription")
});
updateAuthInfo({
@@ -322,10 +322,10 @@ export default function ResourceAuthenticationPage() {
.catch((e) => {
toast({
variant: "destructive",
title: t('resourceErrorPasswordRemove'),
title: t("resourceErrorPasswordRemove"),
description: formatAxiosError(
e,
t('resourceErrorPasswordRemoveDescription')
t("resourceErrorPasswordRemoveDescription")
)
});
})
@@ -340,8 +340,8 @@ export default function ResourceAuthenticationPage() {
})
.then(() => {
toast({
title: t('resourcePincodeRemove'),
description: t('resourcePincodeRemoveDescription')
title: t("resourcePincodeRemove"),
description: t("resourcePincodeRemoveDescription")
});
updateAuthInfo({
@@ -352,10 +352,10 @@ export default function ResourceAuthenticationPage() {
.catch((e) => {
toast({
variant: "destructive",
title: t('resourceErrorPincodeRemove'),
title: t("resourceErrorPincodeRemove"),
description: formatAxiosError(
e,
t('resourceErrorPincodeRemoveDescription')
t("resourceErrorPincodeRemoveDescription")
)
});
})
@@ -400,140 +400,151 @@ export default function ResourceAuthenticationPage() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('resourceUsersRoles')}
{t("resourceUsersRoles")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t('resourceUsersRolesDescription')}
{t("resourceUsersRolesDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SwitchInput
id="sso-toggle"
label={t('ssoUse')}
description={t('ssoUseDescription')}
defaultChecked={resource.sso}
onCheckedChange={(val) => setSsoEnabled(val)}
/>
<SettingsSectionForm>
<SwitchInput
id="sso-toggle"
label={t("ssoUse")}
defaultChecked={resource.sso}
onCheckedChange={(val) => setSsoEnabled(val)}
/>
<Form {...usersRolesForm}>
<form
onSubmit={usersRolesForm.handleSubmit(
onSubmitUsersRoles
)}
id="users-roles-form"
className="space-y-4"
>
{ssoEnabled && (
<>
<FormField
control={usersRolesForm.control}
name="roles"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>{t('roles')}</FormLabel>
<FormControl>
<TagInput
{...field}
activeTagIndex={
activeRolesTagIndex
}
setActiveTagIndex={
setActiveRolesTagIndex
}
placeholder={t('accessRoleSelect2')}
size="sm"
tags={
usersRolesForm.getValues()
.roles
}
setTags={(
newRoles
) => {
usersRolesForm.setValue(
"roles",
newRoles as [
Tag,
...Tag[]
]
);
}}
enableAutocomplete={
true
}
autocompleteOptions={
allRoles
}
allowDuplicates={
false
}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t('resourceRoleDescription')}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={usersRolesForm.control}
name="users"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>{t('users')}</FormLabel>
<FormControl>
<TagInput
{...field}
activeTagIndex={
activeUsersTagIndex
}
setActiveTagIndex={
setActiveUsersTagIndex
}
placeholder={t('accessUserSelect')}
tags={
usersRolesForm.getValues()
.users
}
size="sm"
setTags={(
newUsers
) => {
usersRolesForm.setValue(
"users",
newUsers as [
Tag,
...Tag[]
]
);
}}
enableAutocomplete={
true
}
autocompleteOptions={
allUsers
}
allowDuplicates={
false
}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
</form>
</Form>
<Form {...usersRolesForm}>
<form
onSubmit={usersRolesForm.handleSubmit(
onSubmitUsersRoles
)}
id="users-roles-form"
className="space-y-4"
>
{ssoEnabled && (
<>
<FormField
control={usersRolesForm.control}
name="roles"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>
{t("roles")}
</FormLabel>
<FormControl>
<TagInput
{...field}
activeTagIndex={
activeRolesTagIndex
}
setActiveTagIndex={
setActiveRolesTagIndex
}
placeholder={t(
"accessRoleSelect2"
)}
size="sm"
tags={
usersRolesForm.getValues()
.roles
}
setTags={(
newRoles
) => {
usersRolesForm.setValue(
"roles",
newRoles as [
Tag,
...Tag[]
]
);
}}
enableAutocomplete={
true
}
autocompleteOptions={
allRoles
}
allowDuplicates={
false
}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"resourceRoleDescription"
)}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={usersRolesForm.control}
name="users"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>
{t("users")}
</FormLabel>
<FormControl>
<TagInput
{...field}
activeTagIndex={
activeUsersTagIndex
}
setActiveTagIndex={
setActiveUsersTagIndex
}
placeholder={t(
"accessUserSelect"
)}
tags={
usersRolesForm.getValues()
.users
}
size="sm"
setTags={(
newUsers
) => {
usersRolesForm.setValue(
"users",
newUsers as [
Tag,
...Tag[]
]
);
}}
enableAutocomplete={
true
}
autocompleteOptions={
allUsers
}
allowDuplicates={
false
}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
@@ -542,7 +553,7 @@ export default function ResourceAuthenticationPage() {
disabled={loadingSaveUsersRoles}
form="users-roles-form"
>
{t('resourceUsersRolesSubmit')}
{t("resourceUsersRolesSubmit")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
@@ -550,170 +561,195 @@ export default function ResourceAuthenticationPage() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('resourceAuthMethods')}
{t("resourceAuthMethods")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t('resourceAuthMethodsDescriptions')}
{t("resourceAuthMethodsDescriptions")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{/* Password Protection */}
<div className="flex items-center justify-between">
<div
className={`flex items-center text-${!authInfo.password ? "neutral" : "green"}-500 space-x-2`}
>
<Key />
<span>
{t('resourcePasswordProtection', {status: authInfo.password? t('enabled') : t('disabled')})}
</span>
<SettingsSectionForm>
{/* Password Protection */}
<div className="flex items-center justify-between border rounded-md p-2 mb-4">
<div
className={`flex items-center ${!authInfo.password ? "text-muted-foreground" : "text-green-500"} text-sm space-x-2`}
>
<Key size="14" />
<span>
{t("resourcePasswordProtection", {
status: authInfo.password
? t("enabled")
: t("disabled")
})}
</span>
</div>
<Button
variant="secondary"
size="sm"
onClick={
authInfo.password
? removeResourcePassword
: () => setIsSetPasswordOpen(true)
}
loading={loadingRemoveResourcePassword}
>
{authInfo.password
? t("passwordRemove")
: t("passwordAdd")}
</Button>
</div>
<Button
variant="outlinePrimary"
onClick={
authInfo.password
? removeResourcePassword
: () => setIsSetPasswordOpen(true)
}
loading={loadingRemoveResourcePassword}
>
{authInfo.password
? t('passwordRemove')
: t('passwordAdd')}
</Button>
</div>
{/* PIN Code Protection */}
<div className="flex items-center justify-between">
<div
className={`flex items-center text-${!authInfo.pincode ? "neutral" : "green"}-500 space-x-2`}
>
<Binary />
<span>
{t('resourcePincodeProtection', {status: authInfo.pincode ? t('enabled') : t('disabled')})}
</span>
{/* PIN Code Protection */}
<div className="flex items-center justify-between border rounded-md p-2">
<div
className={`flex items-center ${!authInfo.pincode ? "text-muted-foreground" : "text-green-500"} space-x-2 text-sm`}
>
<Binary size="14" />
<span>
{t("resourcePincodeProtection", {
status: authInfo.pincode
? t("enabled")
: t("disabled")
})}
</span>
</div>
<Button
variant="secondary"
size="sm"
onClick={
authInfo.pincode
? removeResourcePincode
: () => setIsSetPincodeOpen(true)
}
loading={loadingRemoveResourcePincode}
>
{authInfo.pincode
? t("pincodeRemove")
: t("pincodeAdd")}
</Button>
</div>
<Button
variant="outlinePrimary"
onClick={
authInfo.pincode
? removeResourcePincode
: () => setIsSetPincodeOpen(true)
}
loading={loadingRemoveResourcePincode}
>
{authInfo.pincode
? t('pincodeRemove')
: t('pincodeAdd')}
</Button>
</div>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('otpEmailTitle')}
{t("otpEmailTitle")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t('otpEmailTitleDescription')}
{t("otpEmailTitleDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{!env.email.emailEnabled && (
<Alert variant="neutral" className="mb-4">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t('otpEmailSmtpRequired')}
</AlertTitle>
<AlertDescription>
{t('otpEmailSmtpRequiredDescription')}
</AlertDescription>
</Alert>
)}
<SwitchInput
id="whitelist-toggle"
label={t('otpEmailWhitelist')}
defaultChecked={resource.emailWhitelistEnabled}
onCheckedChange={setWhitelistEnabled}
disabled={!env.email.emailEnabled}
/>
<SettingsSectionForm>
{!env.email.emailEnabled && (
<Alert variant="neutral" className="mb-4">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("otpEmailSmtpRequired")}
</AlertTitle>
<AlertDescription>
{t("otpEmailSmtpRequiredDescription")}
</AlertDescription>
</Alert>
)}
<SwitchInput
id="whitelist-toggle"
label={t("otpEmailWhitelist")}
defaultChecked={resource.emailWhitelistEnabled}
onCheckedChange={setWhitelistEnabled}
disabled={!env.email.emailEnabled}
/>
{whitelistEnabled && env.email.emailEnabled && (
<Form {...whitelistForm}>
<form id="whitelist-form">
<FormField
control={whitelistForm.control}
name="emails"
render={({ field }) => (
<FormItem>
<FormLabel>
<InfoPopup
text={t('otpEmailWhitelistList')}
info={t('otpEmailWhitelistListDescription')}
/>
</FormLabel>
<FormControl>
{/* @ts-ignore */}
<TagInput
{...field}
activeTagIndex={
activeEmailTagIndex
}
size={"sm"}
validateTag={(
tag
) => {
return z
.string()
.email()
.or(
z
.string()
.regex(
/^\*@[\w.-]+\.[a-zA-Z]{2,}$/,
{
message: t('otpEmailErrorInvalid')
}
)
)
.safeParse(
tag
).success;
}}
setActiveTagIndex={
setActiveEmailTagIndex
}
placeholder={t('otpEmailEnter')}
tags={
whitelistForm.getValues()
.emails
}
setTags={(
newRoles
) => {
whitelistForm.setValue(
"emails",
newRoles as [
Tag,
...Tag[]
]
);
}}
allowDuplicates={
false
}
sortTags={true}
/>
</FormControl>
<FormDescription>
{t('otpEmailEnterDescription')}
</FormDescription>
</FormItem>
)}
/>
</form>
</Form>
)}
{whitelistEnabled && env.email.emailEnabled && (
<Form {...whitelistForm}>
<form id="whitelist-form">
<FormField
control={whitelistForm.control}
name="emails"
render={({ field }) => (
<FormItem>
<FormLabel>
<InfoPopup
text={t(
"otpEmailWhitelistList"
)}
info={t(
"otpEmailWhitelistListDescription"
)}
/>
</FormLabel>
<FormControl>
{/* @ts-ignore */}
<TagInput
{...field}
activeTagIndex={
activeEmailTagIndex
}
size={"sm"}
validateTag={(
tag
) => {
return z
.string()
.email()
.or(
z
.string()
.regex(
/^\*@[\w.-]+\.[a-zA-Z]{2,}$/,
{
message:
t(
"otpEmailErrorInvalid"
)
}
)
)
.safeParse(
tag
).success;
}}
setActiveTagIndex={
setActiveEmailTagIndex
}
placeholder={t(
"otpEmailEnter"
)}
tags={
whitelistForm.getValues()
.emails
}
setTags={(
newRoles
) => {
whitelistForm.setValue(
"emails",
newRoles as [
Tag,
...Tag[]
]
);
}}
allowDuplicates={
false
}
sortTags={true}
/>
</FormControl>
<FormDescription>
{t(
"otpEmailEnterDescription"
)}
</FormDescription>
</FormItem>
)}
/>
</form>
</Form>
)}
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
@@ -722,7 +758,7 @@ export default function ResourceAuthenticationPage() {
loading={loadingSaveWhitelist}
disabled={loadingSaveWhitelist}
>
{t('otpEmailWhitelistSave')}
{t("otpEmailWhitelistSave")}
</Button>
</SettingsSectionFooter>
</SettingsSection>

View File

@@ -66,6 +66,20 @@ 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,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} 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()
@@ -80,6 +94,7 @@ export default function GeneralForm() {
const { org } = useOrgContext();
const router = useRouter();
const t = useTranslations();
const [editDomainOpen, setEditDomainOpen] = useState(false);
const { env } = useEnvContext();
@@ -96,47 +111,39 @@ export default function GeneralForm() {
>([]);
const [loadingPage, setLoadingPage] = useState(true);
const [domainType, setDomainType] = useState<"subdomain" | "basedomain">(
resource.isBaseDomain ? "basedomain" : "subdomain"
const [resourceFullDomain, setResourceFullDomain] = useState(
`${resource.ssl ? "https" : "http"}://${resource.fullDomain}`
);
const [selectedDomain, setSelectedDomain] = useState<{
domainId: string;
subdomain?: string;
fullDomain: string;
} | null>(null);
const GeneralFormSchema = z
.object({
enabled: z.boolean(),
subdomain: z.string().optional(),
name: z.string().min(1).max(255),
proxyPort: z.number().optional(),
http: z.boolean(),
isBaseDomain: z.boolean().optional(),
domainId: z.string().optional()
domainId: z.string().optional(),
proxyPort: z.number().int().min(1).max(65535).optional(),
enableProxy: z.boolean().optional()
})
.refine(
(data) => {
if (!data.http) {
return z
.number()
.int()
.min(1)
.max(65535)
.safeParse(data.proxyPort).success;
// For non-HTTP resources, proxyPort should be defined
if (!resource.http) {
return data.proxyPort !== undefined;
}
return true;
// For HTTP resources, proxyPort should be undefined
return data.proxyPort === undefined;
},
{
message: t("proxyErrorInvalidPort"),
message: !resource.http
? "Port number is required for non-HTTP resources"
: "Port number should not be set for HTTP resources",
path: ["proxyPort"]
}
)
.refine(
(data) => {
if (data.http && !data.isBaseDomain) {
return subdomainSchema.safeParse(data.subdomain).success;
}
return true;
},
{
message: t("subdomainErrorInvalid"),
path: ["subdomain"]
}
);
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
@@ -144,12 +151,12 @@ export default function GeneralForm() {
const form = useForm<GeneralFormValues>({
resolver: zodResolver(GeneralFormSchema),
defaultValues: {
enabled: resource.enabled,
name: resource.name,
subdomain: resource.subdomain ? resource.subdomain : undefined,
proxyPort: resource.proxyPort ? resource.proxyPort : undefined,
http: resource.http,
isBaseDomain: resource.isBaseDomain ? true : false,
domainId: resource.domainId || undefined
domainId: resource.domainId || undefined,
proxyPort: resource.proxyPort || undefined,
enableProxy: resource.enableProxy || false
},
mode: "onChange"
});
@@ -209,11 +216,14 @@ export default function GeneralForm() {
.post<AxiosResponse<UpdateResourceResponse>>(
`resource/${resource?.resourceId}`,
{
enabled: data.enabled,
name: data.name,
subdomain: data.http ? data.subdomain : undefined,
subdomain: data.subdomain,
domainId: data.domainId,
proxyPort: data.proxyPort,
isBaseDomain: data.http ? data.isBaseDomain : undefined,
domainId: data.http ? data.domainId : undefined
...(!resource.http && {
enableProxy: data.enableProxy
})
}
)
.catch((e) => {
@@ -236,11 +246,14 @@ export default function GeneralForm() {
const resource = res.data.data;
updateResource({
enabled: data.enabled,
name: data.name,
subdomain: data.subdomain,
fullDomain: resource.fullDomain,
proxyPort: data.proxyPort,
isBaseDomain: data.isBaseDomain,
fullDomain: resource.fullDomain
...(!resource.http && {
enableProxy: data.enableProxy
}),
});
router.refresh();
@@ -282,469 +295,390 @@ export default function GeneralForm() {
setTransferLoading(false);
}
async function toggleResourceEnabled(val: boolean) {
const res = await api
.post<AxiosResponse<UpdateResourceResponse>>(
`resource/${resource.resourceId}`,
{
enabled: val
}
)
.catch((e) => {
toast({
variant: "destructive",
title: t("resourceErrorToggle"),
description: formatAxiosError(
e,
t("resourceErrorToggleDescription")
)
});
});
updateResource({
enabled: val
});
}
return (
!loadingPage && (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("resourceVisibilityTitle")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourceVisibilityTitleDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SwitchInput
id="enable-resource"
label={t("resourceEnable")}
defaultChecked={resource.enabled}
onCheckedChange={async (val) => {
await toggleResourceEnabled(val);
}}
/>
</SettingsSectionBody>
</SettingsSection>
<>
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("resourceGeneral")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourceGeneralDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("resourceGeneral")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourceGeneralDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form} key={formKey}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid grid-cols-1 md:grid-cols-2 gap-4"
id="general-settings-form"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{resource.http && (
<>
{env.flags
.allowBaseDomainResources && (
<FormField
control={form.control}
name="isBaseDomain"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"domainType"
)}
</FormLabel>
<Select
value={
domainType
}
onValueChange={(
val
) => {
setDomainType(
val ===
"basedomain"
? "basedomain"
: "subdomain"
);
form.setValue(
"isBaseDomain",
val ===
"basedomain"
? true
: false
);
}}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="subdomain">
{t(
"subdomain"
)}
</SelectItem>
<SelectItem value="basedomain">
{t(
"baseDomain"
)}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
<div className="col-span-2">
{domainType === "subdomain" ? (
<div className="w-fill space-y-2">
<FormLabel>
{t("subdomain")}
</FormLabel>
<div className="flex">
<div className="w-1/2">
<FormField
control={
form.control
}
name="subdomain"
render={({
field
}) => (
<FormItem>
<FormControl>
<Input
{...field}
className="border-r-0 rounded-r-none"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="w-1/2">
<FormField
control={
form.control
}
name="domainId"
render={({
field
}) => (
<FormItem>
<Select
onValueChange={
field.onChange
}
defaultValue={
field.value
}
value={
field.value
}
>
<FormControl>
<SelectTrigger className="rounded-l-none">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{baseDomains.map(
(
option
) => (
<SelectItem
key={
option.domainId
}
value={
option.domainId
}
>
.
{
option.baseDomain
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
</div>
) : (
<FormField
control={form.control}
name="domainId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"baseDomain"
)}
</FormLabel>
<Select
onValueChange={
field.onChange
}
defaultValue={
field.value ||
baseDomains[0]
?.domainId
}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{baseDomains.map(
(
option
) => (
<SelectItem
key={
option.domainId
}
value={
option.domainId
}
>
{
option.baseDomain
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
</>
)}
{!resource.http && (
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form} key={formKey}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="general-settings-form"
>
<FormField
control={form.control}
name="proxyPort"
name="enabled"
render={({ field }) => (
<FormItem className="col-span-2">
<div className="flex items-center space-x-2">
<FormControl>
<SwitchInput
id="enable-resource"
defaultChecked={
resource.enabled
}
label={t(
"resourceEnable"
)}
onCheckedChange={(
val
) =>
form.setValue(
"enabled",
val
)
}
/>
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"resourcePortNumber"
)}
{t("name")}
</FormLabel>
<FormControl>
<Input
type="number"
value={
field.value ??
""
}
onChange={(e) =>
field.onChange(
e.target
.value
? parseInt(
e
.target
.value
)
: null
)
}
/>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={saveLoading}
disabled={saveLoading}
form="general-settings-form"
>
{t("saveGeneralSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("resourceTransfer")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourceTransferDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...transferForm}>
<form
onSubmit={transferForm.handleSubmit(
onTransfer
)}
className="space-y-4"
id="transfer-form"
>
<FormField
control={transferForm.control}
name="siteId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("siteDestination")}
</FormLabel>
<Popover
open={open}
onOpenChange={setOpen}
>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
!field.value &&
"text-muted-foreground"
)}
>
{field.value
? sites.find(
(
site
) =>
site.siteId ===
field.value
)?.name
: t(
"siteSelect"
)}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput
placeholder={t(
"searchSites"
)}
/>
<CommandEmpty>
{!resource.http && (
<>
<FormField
control={form.control}
name="proxyPort"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"sitesNotFound"
"resourcePortNumber"
)}
</CommandEmpty>
<CommandGroup>
{sites.map(
(site) => (
<CommandItem
value={`${site.name}:${site.siteId}`}
key={
site.siteId
}
onSelect={() => {
transferForm.setValue(
"siteId",
site.siteId
);
setOpen(
false
);
}}
>
{
site.name
}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
site.siteId ===
field.value
? "opacity-100"
: "opacity-0"
)}
/>
</CommandItem>
)
</FormLabel>
<FormControl>
<Input
type="number"
value={
field.value ??
""
}
onChange={(
e
) =>
field.onChange(
e
.target
.value
? parseInt(
e
.target
.value
)
: undefined
)
}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"resourcePortNumberDescription"
)}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</FormDescription>
</FormItem>
)}
/>
<SettingsSectionFooter>
<Button
type="submit"
loading={transferLoading}
disabled={transferLoading}
form="transfer-form"
>
{t("resourceTransferSubmit")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
{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>
)}
/>
)}
</>
)}
{resource.http && (
<div className="space-y-2">
<Label>Domain</Label>
<div className="border p-2 rounded-md flex items-center justify-between">
<span className="text-sm text-muted-foreground flex items-center gap-2">
<Globe size="14" />
{resourceFullDomain}
</span>
<Button
variant="secondary"
type="button"
size="sm"
onClick={() =>
setEditDomainOpen(
true
)
}
>
Edit Domain
</Button>
</div>
</div>
)}
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
onClick={() => {
console.log(form.getValues());
}}
loading={saveLoading}
disabled={saveLoading}
form="general-settings-form"
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("resourceTransfer")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourceTransferDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...transferForm}>
<form
onSubmit={transferForm.handleSubmit(
onTransfer
)}
className="space-y-4"
id="transfer-form"
>
<FormField
control={transferForm.control}
name="siteId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("siteDestination")}
</FormLabel>
<Popover
open={open}
onOpenChange={setOpen}
>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
!field.value &&
"text-muted-foreground"
)}
>
{field.value
? sites.find(
(
site
) =>
site.siteId ===
field.value
)
?.name
: t(
"siteSelect"
)}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
className="w-full p-0"
align="start"
>
<Command>
<CommandInput
placeholder={t(
"searchSites"
)}
/>
<CommandEmpty>
{t(
"sitesNotFound"
)}
</CommandEmpty>
<CommandGroup>
{sites.map(
(
site
) => (
<CommandItem
value={`${site.name}:${site.siteId}`}
key={
site.siteId
}
onSelect={() => {
transferForm.setValue(
"siteId",
site.siteId
);
setOpen(
false
);
}}
>
{
site.name
}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
site.siteId ===
field.value
? "opacity-100"
: "opacity-0"
)}
/>
</CommandItem>
)
)}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={transferLoading}
disabled={transferLoading}
form="transfer-form"
>
{t("resourceTransferSubmit")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
<Credenza
open={editDomainOpen}
onOpenChange={(setOpen) => setEditDomainOpen(setOpen)}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>Edit Domain</CredenzaTitle>
<CredenzaDescription>
Select a domain for your resource
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<DomainPicker
orgId={orgId as string}
cols={1}
onDomainChange={(res) => {
const selected = {
domainId: res.domainId,
subdomain: res.subdomain,
fullDomain: res.fullDomain
};
setSelectedDomain(selected);
}}
/>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("cancel")}</Button>
</CredenzaClose>
<Button
onClick={() => {
if (selectedDomain) {
setResourceFullDomain(
selectedDomain.fullDomain
);
form.setValue(
"domainId",
selectedDomain.domainId
);
form.setValue(
"subdomain",
selectedDomain.subdomain
);
setEditDomainOpen(false);
}
}}
>
Select Domain
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
)
);
}

View File

@@ -75,6 +75,7 @@ import {
} from "@app/components/ui/collapsible";
import { ContainersSelector } from "@app/components/ContainersSelector";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
const addTargetSchema = z.object({
ip: z.string().refine(isTargetValid),
@@ -319,10 +320,13 @@ export default function ReverseProxyTargets(props: {
);
}
async function saveTargets() {
async function saveAllSettings() {
try {
setTargetsLoading(true);
setHttpsTlsLoading(true);
setProxySettingsLoading(true);
// Save targets
for (let target of targets) {
const data = {
ip: target.ip,
@@ -347,16 +351,36 @@ export default function ReverseProxyTargets(props: {
await api.delete(`/target/${targetId}`);
}
// Save sticky session setting
const stickySessionData = targetsSettingsForm.getValues();
await api.post(`/resource/${params.resourceId}`, {
stickySession: stickySessionData.stickySession
});
updateResource({ stickySession: stickySessionData.stickySession });
if (resource.http) {
// Gather all settings
const stickySessionData = targetsSettingsForm.getValues();
const tlsData = tlsSettingsForm.getValues();
const proxyData = proxySettingsForm.getValues();
// Combine into one payload
const payload = {
stickySession: stickySessionData.stickySession,
ssl: tlsData.ssl,
tlsServerName: tlsData.tlsServerName || null,
setHostHeader: proxyData.setHostHeader || null
};
// Single API call to update all settings
await api.post(`/resource/${params.resourceId}`, payload);
// Update local resource context
updateResource({
...resource,
stickySession: stickySessionData.stickySession,
ssl: tlsData.ssl,
tlsServerName: tlsData.tlsServerName || null,
setHostHeader: proxyData.setHostHeader || null
});
}
toast({
title: t("targetsUpdated"),
description: t("targetsUpdatedDescription")
title: t("settingsUpdated"),
description: t("settingsUpdatedDescription")
});
setTargetsToRemove([]);
@@ -365,73 +389,15 @@ export default function ReverseProxyTargets(props: {
console.error(err);
toast({
variant: "destructive",
title: t("targetsErrorUpdate"),
title: t("settingsErrorUpdate"),
description: formatAxiosError(
err,
t("targetsErrorUpdateDescription")
t("settingsErrorUpdateDescription")
)
});
} finally {
setTargetsLoading(false);
}
}
async function saveTlsSettings(data: TlsSettingsValues) {
try {
setHttpsTlsLoading(true);
await api.post(`/resource/${params.resourceId}`, {
ssl: data.ssl,
tlsServerName: data.tlsServerName || null
});
updateResource({
...resource,
ssl: data.ssl,
tlsServerName: data.tlsServerName || null
});
toast({
title: t("targetTlsUpdate"),
description: t("targetTlsUpdateDescription")
});
} catch (err) {
console.error(err);
toast({
variant: "destructive",
title: t("targetErrorTlsUpdate"),
description: formatAxiosError(
err,
t("targetErrorTlsUpdateDescription")
)
});
} finally {
setHttpsTlsLoading(false);
}
}
async function saveProxySettings(data: ProxySettingsValues) {
try {
setProxySettingsLoading(true);
await api.post(`/resource/${params.resourceId}`, {
setHostHeader: data.setHostHeader || null
});
updateResource({
...resource,
setHostHeader: data.setHostHeader || null
});
toast({
title: t("proxyUpdated"),
description: t("proxyUpdatedDescription")
});
} catch (err) {
console.error(err);
toast({
variant: "destructive",
title: t("proxyErrorUpdate"),
description: formatAxiosError(
err,
t("proxyErrorUpdateDescription")
)
});
} finally {
setProxySettingsLoading(false);
}
}
@@ -583,7 +549,7 @@ export default function ReverseProxyTargets(props: {
<Form {...targetsSettingsForm}>
<form
onSubmit={targetsSettingsForm.handleSubmit(
saveTargets
saveAllSettings
)}
className="space-y-4"
id="targets-settings-form"
@@ -651,7 +617,10 @@ export default function ReverseProxyTargets(props: {
);
}}
>
<SelectTrigger id="method">
<SelectTrigger
id="method"
className="w-full"
>
<SelectValue
placeholder={t(
"methodSelect"
@@ -734,7 +703,7 @@ export default function ReverseProxyTargets(props: {
/>
<Button
type="submit"
variant="outlinePrimary"
variant="secondary"
className="mt-6"
disabled={!(watchedIp && watchedPort)}
>
@@ -787,44 +756,34 @@ export default function ReverseProxyTargets(props: {
</TableRow>
)}
</TableBody>
<TableCaption>
{t("targetNoOneDescription")}
</TableCaption>
{/* <TableCaption> */}
{/* {t('targetNoOneDescription')} */}
{/* </TableCaption> */}
</Table>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
onClick={saveTargets}
loading={targetsLoading}
disabled={targetsLoading}
form="targets-settings-form"
>
{t("targetsSubmit")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
{resource.http && (
<SettingsSectionGrid cols={2}>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("targetTlsSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("targetTlsSettingsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...tlsSettingsForm}>
<form
onSubmit={tlsSettingsForm.handleSubmit(
saveTlsSettings
)}
className="space-y-4"
id="tls-settings-form"
>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("proxyAdditional")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("proxyAdditionalDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...tlsSettingsForm}>
<form
onSubmit={tlsSettingsForm.handleSubmit(
saveAllSettings
)}
className="space-y-4"
id="tls-settings-form"
>
{build == "oss" && (
<FormField
control={tlsSettingsForm.control}
name="ssl"
@@ -851,130 +810,84 @@ export default function ReverseProxyTargets(props: {
</FormItem>
)}
/>
<Collapsible
open={isAdvancedOpen}
onOpenChange={setIsAdvancedOpen}
className="space-y-2"
>
<div className="flex items-center justify-between space-x-4">
<CollapsibleTrigger asChild>
<Button
variant="text"
size="sm"
className="p-0 flex items-center justify-start gap-2 w-full"
>
<p className="text-sm text-muted-foreground">
{t(
"targetTlsSettingsAdvanced"
)}
</p>
<div>
<ChevronsUpDown className="h-4 w-4" />
<span className="sr-only">
Toggle
</span>
</div>
</Button>
</CollapsibleTrigger>
</div>
<CollapsibleContent className="space-y-2">
<FormField
control={
tlsSettingsForm.control
}
name="tlsServerName"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"targetTlsSni"
)}
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormDescription>
{t(
"targetTlsSniDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
<FormField
control={tlsSettingsForm.control}
name="tlsServerName"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("targetTlsSni")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"targetTlsSniDescription"
)}
/>
</CollapsibleContent>
</Collapsible>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={httpsTlsLoading}
form="tls-settings-form"
>
{t("targetTlsSubmit")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("proxyAdditional")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("proxyAdditionalDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...proxySettingsForm}>
<form
onSubmit={proxySettingsForm.handleSubmit(
saveProxySettings
</FormDescription>
<FormMessage />
</FormItem>
)}
className="space-y-4"
id="proxy-settings-form"
>
<FormField
control={proxySettingsForm.control}
name="setHostHeader"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("proxyCustomHeader")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"proxyCustomHeaderDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={proxySettingsLoading}
form="proxy-settings-form"
>
{t("targetTlsSubmit")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsSectionGrid>
/>
</form>
</Form>
</SettingsSectionForm>
<SettingsSectionForm>
<Form {...proxySettingsForm}>
<form
onSubmit={proxySettingsForm.handleSubmit(
saveAllSettings
)}
className="space-y-4"
id="proxy-settings-form"
>
<FormField
control={proxySettingsForm.control}
name="setHostHeader"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("proxyCustomHeader")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"proxyCustomHeaderDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
)}
<div className="flex justify-end mt-6">
<Button
onClick={saveAllSettings}
loading={
targetsLoading ||
httpsTlsLoading ||
proxySettingsLoading
}
disabled={
targetsLoading ||
httpsTlsLoading ||
proxySettingsLoading
}
>
{t("saveAllSettings")}
</Button>
</div>
</SettingsContainer>
);
}

View File

@@ -36,7 +36,6 @@ import {
TableBody,
TableCaption,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow
@@ -234,35 +233,6 @@ export default function ResourceRules(props: {
);
}
async function saveApplyRules(val: boolean) {
const res = await api
.post(`/resource/${params.resourceId}`, {
applyRules: val
})
.catch((err) => {
console.error(err);
toast({
variant: "destructive",
title: t('rulesErrorUpdate'),
description: formatAxiosError(
err,
t('rulesErrorUpdateDescription')
)
});
});
if (res && res.status === 200) {
setRulesEnabled(val);
updateResource({ applyRules: val });
toast({
title: t('rulesUpdated'),
description: t('rulesUpdatedDescription')
});
router.refresh();
}
}
function getValueHelpText(type: string) {
switch (type) {
case "CIDR":
@@ -274,9 +244,33 @@ export default function ResourceRules(props: {
}
}
async function saveRules() {
async function saveAllSettings() {
try {
setLoading(true);
// Save rules enabled state
const res = await api
.post(`/resource/${params.resourceId}`, {
applyRules: rulesEnabled
})
.catch((err) => {
console.error(err);
toast({
variant: "destructive",
title: t('rulesErrorUpdate'),
description: formatAxiosError(
err,
t('rulesErrorUpdateDescription')
)
});
throw err;
});
if (res && res.status === 200) {
updateResource({ applyRules: rulesEnabled });
}
// Save rules
for (let rule of rules) {
const data = {
action: rule.action,
@@ -543,67 +537,48 @@ export default function ResourceRules(props: {
return (
<SettingsContainer>
<Alert className="hidden md:block">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">{t('rulesAbout')}</AlertTitle>
<AlertDescription className="mt-4">
<div className="space-y-1 mb-4">
<p>
{t('rulesAboutDescription')}
</p>
</div>
<InfoSections cols={2}>
<InfoSection>
<InfoSectionTitle>{t('rulesActions')}</InfoSectionTitle>
<ul className="text-sm text-muted-foreground space-y-1">
<li className="flex items-center gap-2">
<Check className="text-green-500 w-4 h-4" />
{t('rulesActionAlwaysAllow')}
</li>
<li className="flex items-center gap-2">
<X className="text-red-500 w-4 h-4" />
{t('rulesActionAlwaysDeny')}
</li>
</ul>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t('rulesMatchCriteria')}
</InfoSectionTitle>
<ul className="text-sm text-muted-foreground space-y-1">
<li className="flex items-center gap-2">
{t('rulesMatchCriteriaIpAddress')}
</li>
<li className="flex items-center gap-2">
{t('rulesMatchCriteriaIpAddressRange')}
</li>
<li className="flex items-center gap-2">
{t('rulesMatchCriteriaUrl')}
</li>
</ul>
</InfoSection>
</InfoSections>
</AlertDescription>
</Alert>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>{t('rulesEnable')}</SettingsSectionTitle>
<SettingsSectionDescription>
{t('rulesEnableDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SwitchInput
id="rules-toggle"
label={t('rulesEnable')}
defaultChecked={rulesEnabled}
onCheckedChange={async (val) => {
await saveApplyRules(val);
}}
/>
</SettingsSectionBody>
</SettingsSection>
{/* <Alert className="hidden md:block"> */}
{/* <InfoIcon className="h-4 w-4" /> */}
{/* <AlertTitle className="font-semibold">{t('rulesAbout')}</AlertTitle> */}
{/* <AlertDescription className="mt-4"> */}
{/* <div className="space-y-1 mb-4"> */}
{/* <p> */}
{/* {t('rulesAboutDescription')} */}
{/* </p> */}
{/* </div> */}
{/* <InfoSections cols={2}> */}
{/* <InfoSection> */}
{/* <InfoSectionTitle>{t('rulesActions')}</InfoSectionTitle> */}
{/* <ul className="text-sm text-muted-foreground space-y-1"> */}
{/* <li className="flex items-center gap-2"> */}
{/* <Check className="text-green-500 w-4 h-4" /> */}
{/* {t('rulesActionAlwaysAllow')} */}
{/* </li> */}
{/* <li className="flex items-center gap-2"> */}
{/* <X className="text-red-500 w-4 h-4" /> */}
{/* {t('rulesActionAlwaysDeny')} */}
{/* </li> */}
{/* </ul> */}
{/* </InfoSection> */}
{/* <InfoSection> */}
{/* <InfoSectionTitle> */}
{/* {t('rulesMatchCriteria')} */}
{/* </InfoSectionTitle> */}
{/* <ul className="text-sm text-muted-foreground space-y-1"> */}
{/* <li className="flex items-center gap-2"> */}
{/* {t('rulesMatchCriteriaIpAddress')} */}
{/* </li> */}
{/* <li className="flex items-center gap-2"> */}
{/* {t('rulesMatchCriteriaIpAddressRange')} */}
{/* </li> */}
{/* <li className="flex items-center gap-2"> */}
{/* {t('rulesMatchCriteriaUrl')} */}
{/* </li> */}
{/* </ul> */}
{/* </InfoSection> */}
{/* </InfoSections> */}
{/* </AlertDescription> */}
{/* </Alert> */}
<SettingsSection>
<SettingsSectionHeader>
@@ -615,168 +590,179 @@ export default function ResourceRules(props: {
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<Form {...addRuleForm}>
<form
onSubmit={addRuleForm.handleSubmit(addRule)}
className="space-y-4"
>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 items-end">
<FormField
control={addRuleForm.control}
name="action"
render={({ field }) => (
<FormItem>
<FormLabel>{t('rulesAction')}</FormLabel>
<FormControl>
<Select
value={field.value}
onValueChange={
field.onChange
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ACCEPT">
{RuleAction.ACCEPT}
</SelectItem>
<SelectItem value="DROP">
{RuleAction.DROP}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={addRuleForm.control}
name="match"
render={({ field }) => (
<FormItem>
<FormLabel>{t('rulesMatchType')}</FormLabel>
<FormControl>
<Select
value={field.value}
onValueChange={
field.onChange
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{resource.http && (
<SelectItem value="PATH">
{RuleMatch.PATH}
<div className="space-y-6">
<div className="flex items-center space-x-2">
<SwitchInput
id="rules-toggle"
label={t('rulesEnable')}
defaultChecked={rulesEnabled}
onCheckedChange={(val) => setRulesEnabled(val)}
/>
</div>
<Form {...addRuleForm}>
<form
onSubmit={addRuleForm.handleSubmit(addRule)}
className="space-y-4"
>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 items-end">
<FormField
control={addRuleForm.control}
name="action"
render={({ field }) => (
<FormItem>
<FormLabel>{t('rulesAction')}</FormLabel>
<FormControl>
<Select
value={field.value}
onValueChange={
field.onChange
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ACCEPT">
{RuleAction.ACCEPT}
</SelectItem>
)}
<SelectItem value="IP">
{RuleMatch.IP}
</SelectItem>
<SelectItem value="CIDR">
{RuleMatch.CIDR}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={addRuleForm.control}
name="value"
render={({ field }) => (
<FormItem className="space-y-0 mb-2">
<InfoPopup
text={t('value')}
info={
getValueHelpText(
addRuleForm.watch(
"match"
)
) || ""
}
/>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
variant="outlinePrimary"
className="mb-2"
disabled={!rulesEnabled}
>
{t('ruleSubmit')}
</Button>
</div>
</form>
</Form>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef
.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
<SelectItem value="DROP">
{RuleAction.DROP}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={addRuleForm.control}
name="match"
render={({ field }) => (
<FormItem>
<FormLabel>{t('rulesMatchType')}</FormLabel>
<FormControl>
<Select
value={field.value}
onValueChange={
field.onChange
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{resource.http && (
<SelectItem value="PATH">
{RuleMatch.PATH}
</SelectItem>
)}
<SelectItem value="IP">
{RuleMatch.IP}
</SelectItem>
<SelectItem value="CIDR">
{RuleMatch.CIDR}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={addRuleForm.control}
name="value"
render={({ field }) => (
<FormItem className="gap-1">
<InfoPopup
text={t('value')}
info={
getValueHelpText(
addRuleForm.watch(
"match"
)
) || ""
}
/>
<FormControl>
<Input {...field}/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
variant="secondary"
disabled={!rulesEnabled}
>
{t('ruleSubmit')}
</Button>
</div>
</form>
</Form>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef
.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
{t('rulesNoOne')}
</TableCell>
</TableRow>
)}
</TableBody>
<TableCaption>
{t('rulesOrder')}
</TableCaption>
</Table>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
{t('rulesNoOne')}
</TableCell>
</TableRow>
)}
</TableBody>
{/* <TableCaption> */}
{/* {t('rulesOrder')} */}
{/* </TableCaption> */}
</Table>
</div>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
onClick={saveRules}
loading={loading}
disabled={loading}
>
{t('rulesSubmit')}
</Button>
</SettingsSectionFooter>
</SettingsSection>
<div className="flex justify-end">
<Button
onClick={saveAllSettings}
loading={loading}
disabled={loading}
>
{t('saveAllSettings')}
</Button>
</div>
</SettingsContainer>
);
}

View File

@@ -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";
@@ -63,6 +64,8 @@ import { SquareArrowOutUpRight } from "lucide-react";
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),
@@ -70,21 +73,15 @@ const baseResourceFormSchema = z.object({
http: z.boolean()
});
const httpResourceFormSchema = z.discriminatedUnion("isBaseDomain", [
z.object({
isBaseDomain: z.literal(true),
domainId: z.string().min(1)
}),
z.object({
isBaseDomain: z.literal(false),
domainId: z.string().min(1),
subdomain: z.string().pipe(subdomainSchema)
})
]);
const httpResourceFormSchema = z.object({
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>;
@@ -119,15 +116,18 @@ export default function Page() {
const resourceTypes: ReadonlyArray<ResourceTypeOption> = [
{
id: "http",
title: t('resourceHTTP'),
description: t('resourceHTTPDescription')
title: t("resourceHTTP"),
description: t("resourceHTTPDescription")
},
{
id: "raw",
title: t('resourceRaw'),
description: t('resourceRawDescription'),
disabled: !env.flags.allowRawResources
}
...(!env.flags.allowRawResources
? []
: [
{
id: "raw" as ResourceType,
title: t("resourceRaw"),
description: t("resourceRawDescription")
}
])
];
const baseForm = useForm<BaseResourceFormValues>({
@@ -140,18 +140,15 @@ export default function Page() {
const httpForm = useForm<HttpResourceFormValues>({
resolver: zodResolver(httpResourceFormSchema),
defaultValues: {
subdomain: "",
domainId: "",
isBaseDomain: false
}
defaultValues: {}
});
const tcpUdpForm = useForm<TcpUdpResourceFormValues>({
resolver: zodResolver(tcpUdpResourceFormSchema),
defaultValues: {
protocol: "tcp",
proxyPort: undefined
proxyPort: undefined,
enableProxy: false
}
});
@@ -170,25 +167,17 @@ export default function Page() {
if (isHttp) {
const httpData = httpForm.getValues();
if (httpData.isBaseDomain) {
Object.assign(payload, {
domainId: httpData.domainId,
isBaseDomain: true,
protocol: "tcp"
});
} else {
Object.assign(payload, {
subdomain: httpData.subdomain,
domainId: httpData.domainId,
isBaseDomain: false,
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
});
}
@@ -199,10 +188,10 @@ export default function Page() {
.catch((e) => {
toast({
variant: "destructive",
title: t('resourceErrorCreate'),
title: t("resourceErrorCreate"),
description: formatAxiosError(
e,
t('resourceErrorCreateDescription')
t("resourceErrorCreateDescription")
)
});
});
@@ -214,16 +203,23 @@ 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) {
console.error(t('resourceErrorCreateMessage'), e);
console.error(t("resourceErrorCreateMessage"), e);
toast({
variant: "destructive",
title: t('resourceErrorCreate'),
description:t('resourceErrorCreateMessageDescription')
title: t("resourceErrorCreate"),
description: t("resourceErrorCreateMessageDescription")
});
}
@@ -242,10 +238,10 @@ export default function Page() {
.catch((e) => {
toast({
variant: "destructive",
title: t('sitesErrorFetch'),
title: t("sitesErrorFetch"),
description: formatAxiosError(
e,
t('sitesErrorFetchDescription')
t("sitesErrorFetchDescription")
)
});
});
@@ -270,10 +266,10 @@ export default function Page() {
.catch((e) => {
toast({
variant: "destructive",
title: t('domainsErrorFetch'),
title: t("domainsErrorFetch"),
description: formatAxiosError(
e,
t('domainsErrorFetchDescription')
t("domainsErrorFetchDescription")
)
});
});
@@ -281,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);
// }
}
};
@@ -300,8 +296,8 @@ export default function Page() {
<>
<div className="flex justify-between">
<HeaderTitle
title={t('resourceCreate')}
description={t('resourceCreateDescription')}
title={t("resourceCreate")}
description={t("resourceCreateDescription")}
/>
<Button
variant="outline"
@@ -309,7 +305,7 @@ export default function Page() {
router.push(`/${orgId}/settings/resources`);
}}
>
{t('resourceSeeAll')}
{t("resourceSeeAll")}
</Button>
</div>
@@ -320,7 +316,7 @@ export default function Page() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('resourceInfo')}
{t("resourceInfo")}
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -336,7 +332,7 @@ export default function Page() {
render={({ field }) => (
<FormItem>
<FormLabel>
{t('name')}
{t("name")}
</FormLabel>
<FormControl>
<Input
@@ -345,7 +341,9 @@ export default function Page() {
</FormControl>
<FormMessage />
<FormDescription>
{t('resourceNameDescription')}
{t(
"resourceNameDescription"
)}
</FormDescription>
</FormItem>
)}
@@ -357,7 +355,7 @@ export default function Page() {
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>
{t('site')}
{t("site")}
</FormLabel>
<Popover>
<PopoverTrigger
@@ -382,17 +380,25 @@ export default function Page() {
field.value
)
?.name
: t('siteSelect')}
: t(
"siteSelect"
)}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0">
<Command>
<CommandInput placeholder={t('siteSearch')} />
<CommandInput
placeholder={t(
"siteSearch"
)}
/>
<CommandList>
<CommandEmpty>
{t('siteNotFound')}
{t(
"siteNotFound"
)}
</CommandEmpty>
<CommandGroup>
{sites.map(
@@ -433,7 +439,9 @@ export default function Page() {
</Popover>
<FormMessage />
<FormDescription>
{t('siteSelectionDescription')}
{t(
"siteSelectionDescription"
)}
</FormDescription>
</FormItem>
)}
@@ -444,253 +452,74 @@ export default function Page() {
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('resourceType')}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t('resourceTypeDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<StrategySelect
options={resourceTypes}
defaultValue="http"
onChange={(value) => {
baseForm.setValue(
"http",
value === "http"
);
}}
cols={2}
/>
</SettingsSectionBody>
</SettingsSection>
{resourceTypes.length > 1 && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("resourceType")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourceTypeDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<StrategySelect
options={resourceTypes}
defaultValue="http"
onChange={(value) => {
baseForm.setValue(
"http",
value === "http"
);
}}
cols={2}
/>
</SettingsSectionBody>
</SettingsSection>
)}
{baseForm.watch("http") ? (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('resourceHTTPSSettings')}
{t("resourceHTTPSSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t('resourceHTTPSSettingsDescription')}
{t(
"resourceHTTPSSettingsDescription"
)}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...httpForm}>
<form
className="space-y-4"
id="http-settings-form"
>
{env.flags
.allowBaseDomainResources && (
<FormField
control={
httpForm.control
}
name="isBaseDomain"
render={({
field
}) => (
<FormItem>
<FormLabel>
{t('domainType')}
</FormLabel>
<Select
value={
field.value
? "basedomain"
: "subdomain"
}
onValueChange={(
value
) => {
field.onChange(
value ===
"basedomain"
);
}}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="subdomain">
{t('subdomain')}
</SelectItem>
<SelectItem value="basedomain">
{t('baseDomain')}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
{!httpForm.watch(
"isBaseDomain"
) && (
<FormItem>
<FormLabel>
{t('subdomain')}
</FormLabel>
<div className="flex space-x-0">
<div className="w-1/2">
<FormField
control={
httpForm.control
}
name="subdomain"
render={({
field
}) => (
<FormItem>
<FormControl>
<Input
{...field}
className="border-r-0 rounded-r-none"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="w-1/2">
<FormField
control={
httpForm.control
}
name="domainId"
render={({
field
}) => (
<FormItem>
<Select
onValueChange={
field.onChange
}
value={
field.value
}
defaultValue={
field.value
}
>
<FormControl>
<SelectTrigger className="rounded-l-none">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{baseDomains.map(
(
option
) => (
<SelectItem
key={
option.domainId
}
value={
option.domainId
}
>
.
{
option.baseDomain
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<FormDescription>
{t('subdomnainDescription')}
</FormDescription>
</FormItem>
)}
{httpForm.watch(
"isBaseDomain"
) && (
<FormField
control={
httpForm.control
}
name="domainId"
render={({
field
}) => (
<FormItem>
<FormLabel>
{t('baseDomain')}
</FormLabel>
<Select
onValueChange={
field.onChange
}
defaultValue={
field.value
}
{...field}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{baseDomains.map(
(
option
) => (
<SelectItem
key={
option.domainId
}
value={
option.domainId
}
>
{
option.baseDomain
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
</form>
</Form>
</SettingsSectionForm>
<DomainPicker
orgId={orgId as string}
onDomainChange={(res) => {
httpForm.setValue(
"subdomain",
res.subdomain
);
httpForm.setValue(
"domainId",
res.domainId
);
console.log(
"Domain changed:",
res
);
}}
/>
</SettingsSectionBody>
</SettingsSection>
) : (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('resourceRawSettings')}
{t("resourceRawSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t('resourceRawSettingsDescription')}
{t(
"resourceRawSettingsDescription"
)}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -708,7 +537,9 @@ export default function Page() {
render={({ field }) => (
<FormItem>
<FormLabel>
{t('protocol')}
{t(
"protocol"
)}
</FormLabel>
<Select
onValueChange={
@@ -718,7 +549,11 @@ export default function Page() {
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('protocolSelect')} />
<SelectValue
placeholder={t(
"protocolSelect"
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -743,7 +578,9 @@ export default function Page() {
render={({ field }) => (
<FormItem>
<FormLabel>
{t('resourcePortNumber')}
{t(
"resourcePortNumber"
)}
</FormLabel>
<FormControl>
<Input
@@ -771,11 +608,53 @@ export default function Page() {
</FormControl>
<FormMessage />
<FormDescription>
{t('resourcePortNumberDescription')}
{t(
"resourcePortNumberDescription"
)}
</FormDescription>
</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>
@@ -788,27 +667,32 @@ export default function Page() {
type="button"
variant="outline"
onClick={() =>
router.push(`/${orgId}/settings/resources`)
router.push(
`/${orgId}/settings/resources`
)
}
>
{t('cancel')}
{t("cancel")}
</Button>
<Button
type="button"
onClick={async () => {
const isHttp = baseForm.watch("http");
const baseValid = await baseForm.trigger();
const baseValid =
await baseForm.trigger();
const settingsValid = isHttp
? await httpForm.trigger()
: await tcpUdpForm.trigger();
console.log(httpForm.getValues());
if (baseValid && settingsValid) {
onSubmit();
}
}}
loading={createLoading}
>
{t('resourceCreate')}
{t("resourceCreate")}
</Button>
</div>
</SettingsContainer>
@@ -817,17 +701,17 @@ export default function Page() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('resourceConfig')}
{t("resourceConfig")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t('resourceConfigDescription')}
{t("resourceConfigDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<div className="space-y-6">
<div className="space-y-4">
<h3 className="text-lg font-semibold">
{t('resourceAddEntrypoints')}
{t("resourceAddEntrypoints")}
</h3>
<CopyTextBox
text={`entryPoints:
@@ -839,7 +723,7 @@ export default function Page() {
<div className="space-y-4">
<h3 className="text-lg font-semibold">
{t('resourceExposePorts')}
{t("resourceExposePorts")}
</h3>
<CopyTextBox
text={`ports:
@@ -850,13 +734,11 @@ export default function Page() {
<Link
className="text-sm text-primary flex items-center gap-1"
href="https://docs.fossorial.io/Pangolin/tcp-udp"
href="https://docs.digpangolin.com/manage/resources/tcp-udp-resources"
target="_blank"
rel="noopener noreferrer"
>
<span>
{t('resourceLearnRaw')}
</span>
<span>{t("resourceLearnRaw")}</span>
<SquareArrowOutUpRight size={14} />
</Link>
</div>
@@ -868,20 +750,22 @@ export default function Page() {
type="button"
variant="outline"
onClick={() =>
router.push(`/${orgId}/settings/resources`)
router.push(
`/${orgId}/settings/resources`
)
}
>
{t('resourceBack')}
{t("resourceBack")}
</Button>
<Button
type="button"
onClick={() =>
router.push(
`/${orgId}/settings/resources/${resourceId}`
`/${orgId}/settings/resources/${resourceId}/proxy`
)
}
>
{t('resourceGoTo')}
{t("resourceGoTo")}
</Button>
</div>
</SettingsContainer>

View File

@@ -67,7 +67,8 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
resource.whitelist
? "protected"
: "not_protected",
enabled: resource.enabled
enabled: resource.enabled,
domainId: resource.domainId || undefined
};
});

View File

@@ -392,7 +392,7 @@ export default function CreateShareLinkForm({
defaultValue={field.value.toString()}
>
<FormControl>
<SelectTrigger>
<SelectTrigger className="w-full">
<SelectValue placeholder={t('selectDuration')} />
</SelectTrigger>
</FormControl>

View File

@@ -69,11 +69,8 @@ export default function ShareLinksTable({
async function deleteSharelink(id: string) {
await api.delete(`/access-token/${id}`).catch((e) => {
toast({
title: t('shareErrorDelete'),
description: formatAxiosError(
e,
t('shareErrorDeleteMessage')
)
title: t("shareErrorDelete"),
description: formatAxiosError(e, t("shareErrorDeleteMessage"))
});
});
@@ -81,53 +78,12 @@ export default function ShareLinksTable({
setRows(newRows);
toast({
title: t('shareDeleted'),
description: t('shareDeletedDescription')
title: t("shareDeleted"),
description: t("shareDeletedDescription")
});
}
const columns: ColumnDef<ShareLinkRow>[] = [
{
id: "actions",
cell: ({ row }) => {
const router = useRouter();
const resourceRow = row.original;
return (
<>
<div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">
{t('openMenu')}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
deleteSharelink(
resourceRow.accessTokenId
);
}}
>
<button className="text-red-500">
{t('delete')}
</button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
);
}
},
{
accessorKey: "resourceName",
header: ({ column }) => {
@@ -138,7 +94,7 @@ export default function ShareLinksTable({
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t('resource')}
{t("resource")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -147,7 +103,7 @@ export default function ShareLinksTable({
const r = row.original;
return (
<Link href={`/${orgId}/settings/resources/${r.resourceId}`}>
<Button variant="outline">
<Button variant="outline" size="sm">
{r.resourceName}{" "}
{r.siteName ? `(${r.siteName})` : ""}
<ArrowUpRight className="ml-2 h-4 w-4" />
@@ -166,7 +122,7 @@ export default function ShareLinksTable({
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t('title')}
{t("title")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -245,7 +201,7 @@ export default function ShareLinksTable({
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t('created')}
{t("created")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -265,7 +221,7 @@ export default function ShareLinksTable({
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t('expires')}
{t("expires")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -275,23 +231,50 @@ export default function ShareLinksTable({
if (r.expiresAt) {
return moment(r.expiresAt).format("lll");
}
return t('never');
return t("never");
}
},
{
id: "delete",
cell: ({ row }) => (
<div className="flex items-center justify-end space-x-2">
<Button
variant="outlinePrimary"
onClick={() =>
deleteSharelink(row.original.accessTokenId)
}
>
{t('delete')}
</Button>
</div>
)
cell: ({ row }) => {
const resourceRow = row.original;
return (
<div className="flex items-center justify-end space-x-2">
{/* <DropdownMenu> */}
{/* <DropdownMenuTrigger asChild> */}
{/* <Button variant="ghost" className="h-8 w-8 p-0"> */}
{/* <span className="sr-only"> */}
{/* {t("openMenu")} */}
{/* </span> */}
{/* <MoreHorizontal className="h-4 w-4" /> */}
{/* </Button> */}
{/* </DropdownMenuTrigger> */}
{/* <DropdownMenuContent align="end"> */}
{/* <DropdownMenuItem */}
{/* onClick={() => { */}
{/* deleteSharelink( */}
{/* resourceRow.accessTokenId */}
{/* ); */}
{/* }} */}
{/* > */}
{/* <button className="text-red-500"> */}
{/* {t("delete")} */}
{/* </button> */}
{/* </DropdownMenuItem> */}
{/* </DropdownMenuContent> */}
{/* </DropdownMenu> */}
<Button
variant="secondary"
size="sm"
onClick={() =>
deleteSharelink(row.original.accessTokenId)
}
>
{t("delete")}
</Button>
</div>
);
}
}
];

View File

@@ -1,461 +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 { useParams, useRouter } from "next/navigation";
import {
CreateSiteBody,
CreateSiteResponse,
PickSiteDefaultsResponse
} from "@server/routers/site";
import { generateKeypair } from "./[niceId]/wireguardConfig";
import CopyTextBox from "@app/components/CopyTextBox";
import { Checkbox } from "@app/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { SiteRow } from "./SitesTable";
import { AxiosResponse } from "axios";
import { Button } from "@app/components/ui/button";
import Link from "next/link";
import {
ArrowUpRight,
ChevronsUpDown,
Loader2,
SquareArrowOutUpRight
} from "lucide-react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger
} from "@app/components/ui/collapsible";
import LoaderPlaceholder from "@app/components/PlaceHolderLoader";
import { useTranslations } from "next-intl";
type CreateSiteFormProps = {
onCreate?: (site: SiteRow) => void;
setLoading?: (loading: boolean) => void;
setChecked?: (checked: boolean) => void;
orgId: string;
};
export default function CreateSiteForm({
onCreate,
setLoading,
setChecked,
orgId
}: CreateSiteFormProps) {
const api = createApiClient(useEnvContext());
const { env } = useEnvContext();
const [isLoading, setIsLoading] = useState(false);
const [isChecked, setIsChecked] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [keypair, setKeypair] = useState<{
publicKey: string;
privateKey: string;
} | null>(null);
const t = useTranslations();
const createSiteFormSchema = z.object({
name: z
.string()
.min(2, {
message: t('nameMin', {len: 2})
})
.max(30, {
message: t('nameMax', {len: 30})
}),
method: z.enum(["wireguard", "newt", "local"])
});
type CreateSiteFormValues = z.infer<typeof createSiteFormSchema>;
const defaultValues: Partial<CreateSiteFormValues> = {
name: "",
method: "newt"
};
const [siteDefaults, setSiteDefaults] =
useState<PickSiteDefaultsResponse | null>(null);
const [loadingPage, setLoadingPage] = useState(true);
const handleCheckboxChange = (checked: boolean) => {
// setChecked?.(checked);
setIsChecked(checked);
};
const form = useForm<CreateSiteFormValues>({
resolver: zodResolver(createSiteFormSchema),
defaultValues
});
const nameField = form.watch("name");
const methodField = form.watch("method");
useEffect(() => {
const nameIsValid = nameField?.length >= 2 && nameField?.length <= 30;
const isFormValid = methodField === "local" || isChecked;
// Only set checked to true if name is valid AND (method is local OR checkbox is checked)
setChecked?.(nameIsValid && isFormValid);
}, [nameField, methodField, isChecked, setChecked]);
useEffect(() => {
if (!open) return;
const load = async () => {
setLoadingPage(true);
// reset all values
setLoading?.(false);
setIsLoading(false);
form.reset();
setChecked?.(false);
setKeypair(null);
setSiteDefaults(null);
const generatedKeypair = generateKeypair();
setKeypair(generatedKeypair);
await api
.get(`/org/${orgId}/pick-site-defaults`)
.catch((e) => {
// update the default value of the form to be local method
form.setValue("method", "local");
})
.then((res) => {
if (res && res.status === 200) {
setSiteDefaults(res.data.data);
}
});
await new Promise((resolve) => setTimeout(resolve, 200));
setLoadingPage(false);
};
load();
}, [open]);
async function onSubmit(data: CreateSiteFormValues) {
setLoading?.(true);
setIsLoading(true);
let payload: CreateSiteBody = {
name: data.name,
type: data.method
};
if (data.method == "wireguard") {
if (!keypair || !siteDefaults) {
toast({
variant: "destructive",
title: t('siteErrorCreate'),
description: t('siteErrorCreateKeyPair')
});
setLoading?.(false);
setIsLoading(false);
return;
}
payload = {
...payload,
subnet: siteDefaults.subnet,
exitNodeId: siteDefaults.exitNodeId,
pubKey: keypair.publicKey
};
}
if (data.method === "newt") {
if (!siteDefaults) {
toast({
variant: "destructive",
title: t('siteErrorCreate'),
description: t('siteErrorCreateDefaults')
});
setLoading?.(false);
setIsLoading(false);
return;
}
payload = {
...payload,
subnet: siteDefaults.subnet,
exitNodeId: siteDefaults.exitNodeId,
secret: siteDefaults.newtSecret,
newtId: siteDefaults.newtId
};
}
const res = await api
.put<
AxiosResponse<CreateSiteResponse>
>(`/org/${orgId}/site/`, payload)
.catch((e) => {
toast({
variant: "destructive",
title: t('siteErrorCreate'),
description: formatAxiosError(e)
});
});
if (res && res.status === 201) {
const data = res.data.data;
onCreate?.({
name: data.name,
id: data.siteId,
nice: data.niceId.toString(),
mbIn:
data.type == "wireguard" || data.type == "newt"
? t('megabytes', {count: 0})
: "-",
mbOut:
data.type == "wireguard" || data.type == "newt"
? t('megabytes', {count: 0})
: "-",
orgId: orgId as string,
type: data.type as any,
online: false
});
}
setLoading?.(false);
setIsLoading(false);
}
const wgConfig =
keypair && siteDefaults
? `[Interface]
Address = ${siteDefaults.subnet}
ListenPort = 51820
PrivateKey = ${keypair.privateKey}
[Peer]
PublicKey = ${siteDefaults.publicKey}
AllowedIPs = ${siteDefaults.address.split("/")[0]}/32
Endpoint = ${siteDefaults.endpoint}:${siteDefaults.listenPort}
PersistentKeepalive = 5`
: "";
const newtConfig = `newt --id ${siteDefaults?.newtId} --secret ${siteDefaults?.newtSecret} --endpoint ${env.app.dashboardUrl}`;
const newtConfigDockerCompose = `services:
newt:
image: fosrl/newt
container_name: newt
restart: unless-stopped
environment:
- PANGOLIN_ENDPOINT=${env.app.dashboardUrl}
- NEWT_ID=${siteDefaults?.newtId}
- NEWT_SECRET=${siteDefaults?.newtSecret}`;
const newtConfigDockerRun = `docker run -dit fosrl/newt --id ${siteDefaults?.newtId} --secret ${siteDefaults?.newtSecret} --endpoint ${env.app.dashboardUrl}`;
return loadingPage ? (
<LoaderPlaceholder height="300px" />
) : (
<div className="space-y-4">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="create-site-form"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t('name')}</FormLabel>
<FormControl>
<Input autoComplete="off" {...field} />
</FormControl>
<FormMessage />
<FormDescription>
{t('siteNameDescription')}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="method"
render={({ field }) => (
<FormItem>
<FormLabel>{t('method')}</FormLabel>
<FormControl>
<Select
value={field.value}
onValueChange={field.onChange}
>
<SelectTrigger>
<SelectValue placeholder={t('methodSelect')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="local">
{t('local')}
</SelectItem>
<SelectItem
value="newt"
disabled={!siteDefaults}
>
Newt
</SelectItem>
<SelectItem
value="wireguard"
disabled={!siteDefaults}
>
WireGuard
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
<FormDescription>
{t('siteMethodDescription')}
</FormDescription>
</FormItem>
)}
/>
{form.watch("method") === "newt" && (
<Link
className="text-sm text-primary flex items-center gap-1"
href="https://docs.fossorial.io/Newt/install"
target="_blank"
rel="noopener noreferrer"
>
<span>
{t('siteLearnNewt')}
</span>
<SquareArrowOutUpRight size={14} />
</Link>
)}
<div className="w-full">
{form.watch("method") === "wireguard" && !isLoading ? (
<>
<CopyTextBox text={wgConfig} />
<span className="text-sm text-muted-foreground mt-2">
{t('siteSeeConfigOnce')}
</span>
</>
) : form.watch("method") === "wireguard" &&
isLoading ? (
<p>{t('siteLoadWGConfig')}</p>
) : form.watch("method") === "newt" && siteDefaults ? (
<>
<div className="mb-2">
<Collapsible
open={isOpen}
onOpenChange={setIsOpen}
className="space-y-2"
>
<div className="mx-auto mb-2">
<CopyTextBox
text={newtConfig}
wrapText={false}
/>
</div>
<span className="text-sm text-muted-foreground">
{t('siteSeeConfigOnce')}
</span>
<div className="flex items-center justify-between space-x-4">
<CollapsibleTrigger asChild>
<Button
variant="text"
size="sm"
className="p-0 flex items-center justify-between w-full"
>
<h4 className="text-sm font-semibold">
{t('siteDocker')}
</h4>
<div>
<ChevronsUpDown className="h-4 w-4" />
<span className="sr-only">
{t('toggle')}
</span>
</div>
</Button>
</CollapsibleTrigger>
</div>
<CollapsibleContent className="space-y-4">
<div className="space-y-2">
<b>{t('dockerCompose')}</b>
<CopyTextBox
text={
newtConfigDockerCompose
}
wrapText={false}
/>
</div>
<div className="space-y-2">
<b>{t('dockerRun')}</b>
<CopyTextBox
text={newtConfigDockerRun}
wrapText={false}
/>
</div>
</CollapsibleContent>
</Collapsible>
</div>
</>
) : null}
</div>
{form.watch("method") === "local" && (
<Link
className="text-sm text-primary flex items-center gap-1"
href="https://docs.fossorial.io/Pangolin/without-tunneling"
target="_blank"
rel="noopener noreferrer"
>
<span>{t('siteLearnLocal')}</span>
<SquareArrowOutUpRight size={14} />
</Link>
)}
{(form.watch("method") === "newt" ||
form.watch("method") === "wireguard") && (
<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"
>
{t('siteConfirmCopy')}
</label>
</div>
)}
</form>
</Form>
</div>
);
}

View File

@@ -1,82 +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 { SiteRow } from "./SitesTable";
import CreateSiteForm from "./CreateSiteForm";
import { useTranslations } from "next-intl";
type CreateSiteFormProps = {
open: boolean;
setOpen: (open: boolean) => void;
onCreate?: (site: SiteRow) => void;
orgId: string;
};
export default function CreateSiteFormModal({
open,
setOpen,
onCreate,
orgId
}: CreateSiteFormProps) {
const [loading, setLoading] = useState(false);
const [isChecked, setIsChecked] = useState(false);
const t = useTranslations();
return (
<>
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
setLoading(false);
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>{t('siteCreate')}</CredenzaTitle>
<CredenzaDescription>
{t('siteCreateDescription')}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<div className="max-w-md">
<CreateSiteForm
setLoading={(val) => setLoading(val)}
setChecked={(val) => setIsChecked(val)}
onCreate={onCreate}
orgId={orgId}
/>
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t('close')}</Button>
</CredenzaClose>
<Button
type="submit"
form="create-site-form"
loading={loading}
disabled={loading || !isChecked}
onClick={() => {
setOpen(false);
}}
>
{t('siteCreate')}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
);
}

View File

@@ -8,12 +8,16 @@ interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
createSite?: () => void;
onRefresh?: () => void;
isRefreshing?: boolean;
}
export function SitesDataTable<TData, TValue>({
columns,
data,
createSite
createSite,
onRefresh,
isRefreshing
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
@@ -27,6 +31,8 @@ export function SitesDataTable<TData, TValue>({
searchColumn="name"
onAdd={createSite}
addButtonText={t('siteAdd')}
onRefresh={onRefresh}
isRefreshing={isRefreshing}
defaultSort={{
id: "name",
desc: false

View File

@@ -5,10 +5,12 @@ import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ArrowRight, DockIcon as Docker, Globe, Server, X } from "lucide-react";
import Link from "next/link";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from 'next-intl';
export const SitesSplashCard = () => {
const [isDismissed, setIsDismissed] = useState(true);
const { env } = useEnvContext();
const key = "sites-splash-card-dismissed";
const t = useTranslations();
@@ -62,7 +64,7 @@ export const SitesSplashCard = () => {
<div className="mt-4">
<Link
href="https://docs.fossorial.io/Newt/install"
href="https://docs.digpangolin.com/manage/sites/install-site"
target="_blank"
rel="noopener noreferrer"
>

View File

@@ -1,6 +1,6 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { Column, ColumnDef } from "@tanstack/react-table";
import { SitesDataTable } from "./SitesDataTable";
import {
DropdownMenu,
@@ -19,16 +19,16 @@ import {
import Link from "next/link";
import { useRouter } from "next/navigation";
import { AxiosResponse } from "axios";
import { useState } from "react";
import CreateSiteForm from "./CreateSiteForm";
import { useState, useEffect } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
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 CreateSiteFormModal from "./CreateSiteModal";
import { useTranslations } from "next-intl";
import { parseDataSize } from '@app/lib/dataSize';
import { parseDataSize } from "@app/lib/dataSize";
import { Badge } from "@app/components/ui/badge";
import { InfoPopup } from "@app/components/ui/info-popup";
export type SiteRow = {
id: number;
@@ -38,7 +38,10 @@ export type SiteRow = {
mbOut: string;
orgId: string;
type: "newt" | "wireguard";
newtVersion?: string;
newtUpdateAvailable?: boolean;
online: boolean;
address?: string;
};
type SitesTableProps = {
@@ -52,18 +55,42 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null);
const [rows, setRows] = useState<SiteRow[]>(sites);
const [isRefreshing, setIsRefreshing] = useState(false);
const api = createApiClient(useEnvContext());
const t = useTranslations();
const { env } = useEnvContext();
// Update local state when props change (e.g., after refresh)
useEffect(() => {
setRows(sites);
}, [sites]);
const refreshData = async () => {
console.log("Data refreshed");
setIsRefreshing(true);
try {
await new Promise((resolve) => setTimeout(resolve, 200));
router.refresh();
} catch (error) {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
} finally {
setIsRefreshing(false);
}
};
const deleteSite = (siteId: number) => {
api.delete(`/site/${siteId}`)
.catch((e) => {
console.error(t('siteErrorDelete'), e);
console.error(t("siteErrorDelete"), e);
toast({
variant: "destructive",
title: t('siteErrorDelete'),
description: formatAxiosError(e, t('siteErrorDelete'))
title: t("siteErrorDelete"),
description: formatAxiosError(e, t("siteErrorDelete"))
});
})
.then(() => {
@@ -77,42 +104,6 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
};
const columns: ColumnDef<SiteRow>[] = [
{
id: "dots",
cell: ({ row }) => {
const siteRow = 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={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
>
<DropdownMenuItem>
{t('viewSettings')}
</DropdownMenuItem>
</Link>
<DropdownMenuItem
onClick={() => {
setSelectedSite(siteRow);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">{t('delete')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
},
{
accessorKey: "name",
header: ({ column }) => {
@@ -123,7 +114,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t('name')}
{t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -139,7 +130,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t('online')}
{t("online")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -154,14 +145,14 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
return (
<span className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>{t('online')}</span>
<span>{t("online")}</span>
</span>
);
} else {
return (
<span className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>{t('offline')}</span>
<span>{t("offline")}</span>
</span>
);
}
@@ -179,11 +170,19 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
className="hidden md:flex whitespace-nowrap"
>
{t('site')}
{t("site")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return (
<div className="hidden md:block whitespace-nowrap">
{row.original.nice}
</div>
);
}
},
{
@@ -196,13 +195,14 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t('dataIn')}
{t("dataIn")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
sortingFn: (rowA, rowB) =>
parseDataSize(rowA.original.mbIn) - parseDataSize(rowB.original.mbIn)
sortingFn: (rowA, rowB) =>
parseDataSize(rowA.original.mbIn) -
parseDataSize(rowB.original.mbIn)
},
{
accessorKey: "mbOut",
@@ -214,13 +214,14 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t('dataOut')}
{t("dataOut")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
sortingFn: (rowA, rowB) =>
parseDataSize(rowA.original.mbOut) - parseDataSize(rowB.original.mbOut),
parseDataSize(rowA.original.mbOut) -
parseDataSize(rowB.original.mbOut)
},
{
accessorKey: "type",
@@ -232,7 +233,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t('connectionType')}
{t("connectionType")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -242,8 +243,22 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
if (originalRow.type === "newt") {
return (
<div className="flex items-center space-x-2">
<span>Newt</span>
<div className="flex items-center space-x-1">
<Badge variant="secondary">
<div className="flex items-center space-x-2">
<span>Newt</span>
{originalRow.newtVersion && (
<span className="text-xs text-gray-500">
v{originalRow.newtVersion}
</span>
)}
</div>
</Badge>
{originalRow.newtUpdateAvailable && (
<InfoPopup
info={t("newtUpdateAvailableInfo")}
/>
)}
</div>
);
}
@@ -259,23 +274,68 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
if (originalRow.type === "local") {
return (
<div className="flex items-center space-x-2">
<span>{t('local')}</span>
<span>{t("local")}</span>
</div>
);
}
}
},
...(env.flags.enableClients ? [{
accessorKey: "address",
header: ({ column }: { column: Column<SiteRow, unknown> }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Address
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
}] : []),
{
id: "actions",
cell: ({ row }) => {
const siteRow = row.original;
return (
<div className="flex items-center justify-end">
<div className="flex items-center justify-end gap-2">
<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={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
>
<DropdownMenuItem>
{t("viewSettings")}
</DropdownMenuItem>
</Link>
<DropdownMenuItem
onClick={() => {
setSelectedSite(siteRow);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
>
<Button variant={"outlinePrimary"} className="ml-2">
{t('edit')}
<Button variant={"secondary"} size="sm">
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
@@ -297,22 +357,21 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
dialog={
<div className="space-y-4">
<p>
{t('siteQuestionRemove', {selectedSite: selectedSite?.name || selectedSite?.id})}
{t("siteQuestionRemove", {
selectedSite:
selectedSite?.name || selectedSite?.id
})}
</p>
<p>
{t('siteMessageRemove')}
</p>
<p>{t("siteMessageRemove")}</p>
<p>
{t('siteMessageConfirm')}
</p>
<p>{t("siteMessageConfirm")}</p>
</div>
}
buttonText={t('siteConfirmDelete')}
buttonText={t("siteConfirmDelete")}
onConfirm={async () => deleteSite(selectedSite!.id)}
string={selectedSite.name}
title={t('siteDelete')}
title={t("siteDelete")}
/>
)}
@@ -322,6 +381,8 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
createSite={() =>
router.push(`/${orgId}/settings/sites/create`)
}
onRefresh={refreshData}
isRefreshing={isRefreshing}
/>
</>
);

View File

@@ -10,12 +10,14 @@ import {
InfoSectionTitle
} from "@app/components/InfoSection";
import { useTranslations } from "next-intl";
import { useEnvContext } from "@app/hooks/useEnvContext";
type SiteInfoCardProps = {};
export default function SiteInfoCard({}: SiteInfoCardProps) {
const { site, updateSite } = useSiteContext();
const t = useTranslations();
const { env } = useEnvContext();
const getConnectionTypeString = (type: string) => {
if (type === "newt") {
@@ -23,32 +25,34 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
} else if (type === "wireguard") {
return "WireGuard";
} else if (type === "local") {
return t('local');
return t("local");
} else {
return t('unknown');
return t("unknown");
}
};
return (
<Alert>
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">{t('siteInfo')}</AlertTitle>
<AlertTitle className="font-semibold">{t("siteInfo")}</AlertTitle>
<AlertDescription className="mt-4">
<InfoSections cols={2}>
<InfoSections cols={env.flags.enableClients ? 3 : 2}>
{(site.type == "newt" || site.type == "wireguard") && (
<>
<InfoSection>
<InfoSectionTitle>{t('status')}</InfoSectionTitle>
<InfoSectionTitle>
{t("status")}
</InfoSectionTitle>
<InfoSectionContent>
{site.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>{t('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>{t('offline')}</span>
<span>{t("offline")}</span>
</div>
)}
</InfoSectionContent>
@@ -56,11 +60,22 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
</>
)}
<InfoSection>
<InfoSectionTitle>{t('connectionType')}</InfoSectionTitle>
<InfoSectionTitle>
{t("connectionType")}
</InfoSectionTitle>
<InfoSectionContent>
{getConnectionTypeString(site.type)}
</InfoSectionContent>
</InfoSection>
{env.flags.enableClients && (
<InfoSection>
<InfoSectionTitle>Address</InfoSectionTitle>
<InfoSectionContent>
{site.address?.split("/")[0]}
</InfoSectionContent>
</InfoSection>
)}
</InfoSections>
</AlertDescription>
</Alert>

View File

@@ -24,8 +24,7 @@ import {
SettingsSectionTitle,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionForm,
SettingsSectionFooter
SettingsSectionForm
} from "@app/components/Settings";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
@@ -34,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>;
@@ -45,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();
@@ -56,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"
});
@@ -67,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({
@@ -82,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({
@@ -125,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}
@@ -157,7 +208,7 @@ export default function GeneralPage() {
"enableDockerSocketDescription"
)}{" "}
<Link
href="https://docs.fossorial.io/Newt/overview#docker-socket-integration"
href="https://docs.digpangolin.com/manage/sites/configure-site#docker-socket-integration"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center"
@@ -177,18 +228,18 @@ export default function GeneralPage() {
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
form="general-settings-form"
loading={loading}
disabled={loading}
>
{t("saveGeneralSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
<div className="flex justify-end mt-6">
<Button
type="submit"
form="general-settings-form"
loading={loading}
disabled={loading}
>
Save All Settings
</Button>
</div>
</SettingsContainer>
);
}

View File

@@ -5,16 +5,7 @@ import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
import { authCookieHeader } from "@app/lib/api/cookies";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from "@app/components/ui/breadcrumb";
import SiteInfoCard from "./SiteInfoCard";
import { getTranslations } from "next-intl/server";

View File

@@ -21,7 +21,7 @@ import {
} from "@app/components/ui/form";
import HeaderTitle from "@app/components/SettingsSectionTitle";
import { z } from "zod";
import { useEffect, useState } from "react";
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";
@@ -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";
@@ -55,15 +56,8 @@ import {
import { toast } from "@app/hooks/useToast";
import { AxiosResponse } from "axios";
import { useParams, useRouter } from "next/navigation";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from "@app/components/ui/breadcrumb";
import Link from "next/link";
import { QRCodeCanvas } from "qrcode.react";
import { useTranslations } from "next-intl";
type SiteType = "newt" | "wireguard" | "local";
@@ -81,6 +75,7 @@ type Commands = {
windows: Record<string, string[]>;
docker: Record<string, string[]>;
podman: Record<string, string[]>;
nixos: Record<string, string[]>;
};
const platforms = [
@@ -89,7 +84,8 @@ const platforms = [
"podman",
"mac",
"windows",
"freebsd"
"freebsd",
"nixos"
] as const;
type Platform = (typeof platforms)[number];
@@ -105,22 +101,24 @@ export default function Page() {
.object({
name: z
.string()
.min(2, { message: t('nameMin', {len: 2}) })
.min(2, { message: t("nameMin", { len: 2 }) })
.max(30, {
message: t('nameMax', {len: 30})
message: t("nameMax", { len: 30 })
}),
method: z.enum(["newt", "wireguard", "local"]),
copied: z.boolean()
copied: z.boolean(),
clientAddress: z.string().optional()
})
.refine(
(data) => {
if (data.method !== "local") {
return data.copied;
// return data.copied;
return true;
}
return true;
},
{
message: t('sitesConfirmCopy'),
message: t("sitesConfirmCopy"),
path: ["copied"]
}
);
@@ -132,21 +130,29 @@ export default function Page() {
>([
{
id: "newt",
title: t('siteNewtTunnel'),
description: t('siteNewtTunnelDescription'),
title: t("siteNewtTunnel"),
description: t("siteNewtTunnelDescription"),
disabled: true
},
{
id: "wireguard",
title: t('siteWg'),
description: t('siteWgDescription'),
disabled: true
},
{
id: "local",
title: t('local'),
description: t('siteLocalDescription')
}
...(env.flags.disableBasicWireguardSites
? []
: [
{
id: "wireguard" as SiteType,
title: t("siteWg"),
description: t("siteWgDescription"),
disabled: true
}
]),
...(env.flags.disableLocalSites
? []
: [
{
id: "local" as SiteType,
title: t("local"),
description: t("siteLocalDescription")
}
])
]);
const [loadingPage, setLoadingPage] = useState(true);
@@ -158,7 +164,7 @@ export default function Page() {
const [newtId, setNewtId] = useState("");
const [newtSecret, setNewtSecret] = useState("");
const [newtEndpoint, setNewtEndpoint] = useState("");
const [clientAddress, setClientAddress] = useState("");
const [publicKey, setPublicKey] = useState("");
const [privateKey, setPrivateKey] = useState("");
const [wgConfig, setWgConfig] = useState("");
@@ -282,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);
@@ -301,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"];
}
@@ -318,13 +334,15 @@ WantedBy=default.target`
return "Podman";
case "freebsd":
return "FreeBSD";
case "nixos":
return "NixOS";
default:
return "Linux";
}
};
const getCommand = () => {
const placeholder = [t('unknownCommand')];
const placeholder = [t("unknownCommand")];
if (!commands) {
return placeholder;
}
@@ -362,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" />;
}
@@ -369,20 +389,28 @@ WantedBy=default.target`
const form = useForm<CreateSiteFormValues>({
resolver: zodResolver(createSiteFormSchema),
defaultValues: { name: "", copied: false, method: "newt" }
defaultValues: {
name: "",
copied: false,
method: "newt",
clientAddress: ""
}
});
async function onSubmit(data: CreateSiteFormValues) {
setCreateLoading(true);
let payload: CreateSiteBody = { name: data.name, type: data.method };
let payload: CreateSiteBody = {
name: data.name,
type: data.method as "newt" | "wireguard" | "local"
};
if (data.method == "wireguard") {
if (!siteDefaults || !wgConfig) {
toast({
variant: "destructive",
title: t('siteErrorCreate'),
description: t('siteErrorCreateKeyPair')
title: t("siteErrorCreate"),
description: t("siteErrorCreateKeyPair")
});
setCreateLoading(false);
return;
@@ -399,8 +427,8 @@ WantedBy=default.target`
if (!siteDefaults) {
toast({
variant: "destructive",
title: t('siteErrorCreate'),
description: t('siteErrorCreateDefaults')
title: t("siteErrorCreate"),
description: t("siteErrorCreateDefaults")
});
setCreateLoading(false);
return;
@@ -411,7 +439,8 @@ WantedBy=default.target`
subnet: siteDefaults.subnet,
exitNodeId: siteDefaults.exitNodeId,
secret: siteDefaults.newtSecret,
newtId: siteDefaults.newtId
newtId: siteDefaults.newtId,
address: clientAddress
};
}
@@ -422,7 +451,7 @@ WantedBy=default.target`
.catch((e) => {
toast({
variant: "destructive",
title: t('siteErrorCreate'),
title: t("siteErrorCreate"),
description: formatAxiosError(e)
});
});
@@ -448,14 +477,23 @@ WantedBy=default.target`
);
if (!response.ok) {
throw new Error(
t('newtErrorFetchReleases', {err: response.statusText})
t("newtErrorFetchReleases", {
err: response.statusText
})
);
}
const data = await response.json();
const latestVersion = data.tag_name;
newtVersion = latestVersion;
} catch (error) {
console.error(t('newtErrorFetchLatest', {err: error instanceof Error ? error.message : String(error)}));
console.error(
t("newtErrorFetchLatest", {
err:
error instanceof Error
? error.message
: String(error)
})
);
}
const generatedKeypair = generateKeypair();
@@ -481,10 +519,12 @@ WantedBy=default.target`
const newtId = data.newtId;
const newtSecret = data.newtSecret;
const newtEndpoint = data.endpoint;
const clientAddress = data.clientAddress;
setNewtId(newtId);
setNewtSecret(newtSecret);
setNewtEndpoint(newtEndpoint);
setClientAddress(clientAddress);
hydrateCommands(
newtId,
@@ -520,8 +560,8 @@ WantedBy=default.target`
<>
<div className="flex justify-between">
<HeaderTitle
title={t('siteCreate')}
description={t('siteCreateDescription2')}
title={t("siteCreate")}
description={t("siteCreateDescription2")}
/>
<Button
variant="outline"
@@ -529,7 +569,7 @@ WantedBy=default.target`
router.push(`/${orgId}/settings/sites`);
}}
>
{t('siteSeeAll')}
{t("siteSeeAll")}
</Button>
</div>
@@ -539,7 +579,7 @@ WantedBy=default.target`
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('siteInfo')}
{t("siteInfo")}
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -555,7 +595,7 @@ WantedBy=default.target`
render={({ field }) => (
<FormItem>
<FormLabel>
{t('name')}
{t("name")}
</FormLabel>
<FormControl>
<Input
@@ -564,55 +604,102 @@ WantedBy=default.target`
/>
</FormControl>
<FormMessage />
<FormDescription>
{t('siteNameDescription')}
</FormDescription>
</FormItem>
)}
/>
{env.flags.enableClients &&
form.watch("method") ===
"newt" && (
<FormField
control={form.control}
name="clientAddress"
render={({ field }) => (
<FormItem>
<FormLabel>
Site Address
</FormLabel>
<FormControl>
<Input
autoComplete="off"
value={
clientAddress
}
onChange={(
e
) => {
setClientAddress(
e
.target
.value
);
field.onChange(
e
.target
.value
);
}}
/>
</FormControl>
<FormMessage />
<FormDescription>
Specify the
IP address
of the host
for clients
to connect
to.
</FormDescription>
</FormItem>
)}
/>
)}
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('tunnelType')}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t('siteTunnelDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<StrategySelect
options={tunnelTypes}
defaultValue={form.getValues("method")}
onChange={(value) => {
form.setValue("method", value);
}}
cols={3}
/>
</SettingsSectionBody>
</SettingsSection>
{tunnelTypes.length > 1 && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("tunnelType")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("siteTunnelDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<StrategySelect
options={tunnelTypes}
defaultValue={form.getValues("method")}
onChange={(value) => {
form.setValue("method", value);
}}
cols={3}
/>
</SettingsSectionBody>
</SettingsSection>
)}
{form.watch("method") === "newt" && (
<>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('siteNewtCredentials')}
{t("siteNewtCredentials")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t('siteNewtCredentialsDescription')}
{t(
"siteNewtCredentialsDescription"
)}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<InfoSections cols={3}>
<InfoSection>
<InfoSectionTitle>
{t('newtEndpoint')}
{t("newtEndpoint")}
</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard
@@ -624,7 +711,7 @@ WantedBy=default.target`
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t('newtId')}
{t("newtId")}
</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard
@@ -634,7 +721,7 @@ WantedBy=default.target`
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t('newtSecretKey')}
{t("newtSecretKey")}
</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard
@@ -647,69 +734,70 @@ WantedBy=default.target`
<Alert variant="neutral" className="">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t('siteCredentialsSave')}
{t("siteCredentialsSave")}
</AlertTitle>
<AlertDescription>
{t('siteCredentialsSaveDescription')}
{t(
"siteCredentialsSaveDescription"
)}
</AlertDescription>
</Alert>
<Form {...form}>
<form
className="space-y-4"
id="create-site-form"
>
<FormField
control={form.control}
name="copied"
render={({ field }) => (
<FormItem>
<div className="flex items-center space-x-2">
<Checkbox
id="terms"
defaultChecked={
form.getValues(
"copied"
) as boolean
}
onCheckedChange={(
e
) => {
form.setValue(
"copied",
e as boolean
);
}}
/>
<label
htmlFor="terms"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t('siteConfirmCopy')}
</label>
</div>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
{/* <Form {...form}> */}
{/* <form */}
{/* className="space-y-4" */}
{/* id="create-site-form" */}
{/* > */}
{/* <FormField */}
{/* control={form.control} */}
{/* name="copied" */}
{/* render={({ field }) => ( */}
{/* <FormItem> */}
{/* <div className="flex items-center space-x-2"> */}
{/* <Checkbox */}
{/* id="terms" */}
{/* defaultChecked={ */}
{/* form.getValues( */}
{/* "copied" */}
{/* ) as boolean */}
{/* } */}
{/* onCheckedChange={( */}
{/* e */}
{/* ) => { */}
{/* form.setValue( */}
{/* "copied", */}
{/* e as boolean */}
{/* ); */}
{/* }} */}
{/* /> */}
{/* <label */}
{/* htmlFor="terms" */}
{/* className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" */}
{/* > */}
{/* {t('siteConfirmCopy')} */}
{/* </label> */}
{/* </div> */}
{/* <FormMessage /> */}
{/* </FormItem> */}
{/* )} */}
{/* /> */}
{/* </form> */}
{/* </Form> */}
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('siteInstallNewt')}
{t("siteInstallNewt")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t('siteInstallNewtDescription')}
{t("siteInstallNewtDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<div>
<p className="font-bold mb-3">
{t('operatingSystem')}
{t("operatingSystem")}
</p>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
{platforms.map((os) => (
@@ -720,7 +808,7 @@ WantedBy=default.target`
? "squareOutlinePrimary"
: "squareOutline"
}
className={`flex-1 min-w-[120px] ${platform === os ? "bg-primary/10" : ""}`}
className={`flex-1 min-w-[120px] ${platform === os ? "bg-primary/10" : ""} shadow-none`}
onClick={() => {
setPlatform(os);
}}
@@ -737,8 +825,8 @@ WantedBy=default.target`
{["docker", "podman"].includes(
platform
)
? t('method')
: t('architecture')}
? t("method")
: t("architecture")}
</p>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
{getArchitectures().map(
@@ -751,7 +839,7 @@ WantedBy=default.target`
? "squareOutlinePrimary"
: "squareOutline"
}
className={`flex-1 min-w-[120px] ${architecture === arch ? "bg-primary/10" : ""}`}
className={`flex-1 min-w-[120px] ${architecture === arch ? "bg-primary/10" : ""} shadow-none`}
onClick={() =>
setArchitecture(
arch
@@ -765,7 +853,7 @@ WantedBy=default.target`
</div>
<div className="pt-4">
<p className="font-bold mb-3">
{t('commands')}
{t("commands")}
</p>
<div className="mt-2">
<CopyTextBox
@@ -786,10 +874,10 @@ WantedBy=default.target`
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('WgConfiguration')}
{t("WgConfiguration")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t('WgConfigurationDescription')}
{t("WgConfigurationDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -810,53 +898,14 @@ WantedBy=default.target`
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t('siteCredentialsSave')}
{t("siteCredentialsSave")}
</AlertTitle>
<AlertDescription>
{t('siteCredentialsSaveDescription')}
{t(
"siteCredentialsSaveDescription"
)}
</AlertDescription>
</Alert>
<Form {...form}>
<form
className="space-y-4"
id="create-site-form"
>
<FormField
control={form.control}
name="copied"
render={({ field }) => (
<FormItem>
<div className="flex items-center space-x-2">
<Checkbox
id="terms"
defaultChecked={
form.getValues(
"copied"
) as boolean
}
onCheckedChange={(
e
) => {
form.setValue(
"copied",
e as boolean
);
}}
/>
<label
htmlFor="terms"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t('siteConfirmCopy')}
</label>
</div>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionBody>
</SettingsSection>
)}
@@ -870,15 +919,17 @@ WantedBy=default.target`
router.push(`/${orgId}/settings/sites`);
}}
>
{t('cancel')}
{t("cancel")}
</Button>
<Button
type="button"
loading={createLoading}
disabled={createLoading}
onClick={() => {
form.handleSubmit(onSubmit)();
}}
>
{t('siteCreate')}
{t("siteCreate")}
</Button>
</div>
</div>

View File

@@ -44,11 +44,14 @@ export default async function SitesPage(props: SitesPageProps) {
name: site.name,
id: site.siteId,
nice: site.niceId.toString(),
address: site.address?.split("/")[0],
mbIn: formatSize(site.megabytesIn || 0, site.type),
mbOut: formatSize(site.megabytesOut || 0, site.type),
orgId: params.orgId,
type: site.type as any,
online: site.online
online: site.online,
newtVersion: site.newtVersion || undefined,
newtUpdateAvailable: site.newtUpdateAvailable || false,
};
});

View File

@@ -46,11 +46,14 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
const deleteSite = (apiKeyId: string) => {
api.delete(`/api-key/${apiKeyId}`)
.catch((e) => {
console.error(t('apiKeysErrorDelete'), e);
console.error(t("apiKeysErrorDelete"), e);
toast({
variant: "destructive",
title: t('apiKeysErrorDelete'),
description: formatAxiosError(e, t('apiKeysErrorDeleteMessage'))
title: t("apiKeysErrorDelete"),
description: formatAxiosError(
e,
t("apiKeysErrorDeleteMessage")
)
});
})
.then(() => {
@@ -64,41 +67,6 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
};
const columns: ColumnDef<ApiKeyRow>[] = [
{
id: "dots",
cell: ({ row }) => {
const apiKeyROw = row.original;
const router = useRouter();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">{t('openMenu')}</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setSelected(apiKeyROw);
}}
>
<span>{t('viewSettings')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSelected(apiKeyROw);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">{t('delete')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
},
{
accessorKey: "name",
header: ({ column }) => {
@@ -109,7 +77,7 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t('name')}
{t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -117,7 +85,7 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
},
{
accessorKey: "key",
header: t('key'),
header: t("key"),
cell: ({ row }) => {
const r = row.original;
return <span className="font-mono">{r.key}</span>;
@@ -125,7 +93,7 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
},
{
accessorKey: "createdAt",
header: t('createdAt'),
header: t("createdAt"),
cell: ({ row }) => {
const r = row.original;
return <span>{moment(r.createdAt).format("lll")} </span>;
@@ -136,13 +104,44 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
cell: ({ row }) => {
const r = row.original;
return (
<div className="flex items-center justify-end">
<Link href={`/admin/api-keys/${r.id}`}>
<Button variant={"outlinePrimary"} className="ml-2">
{t('edit')}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
<div className="flex items-center justify-end gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setSelected(r);
}}
>
<span>{t("viewSettings")}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSelected(r);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center justify-end">
<Link href={`/admin/api-keys/${r.id}`}>
<Button variant={"secondary"} className="ml-2" size="sm">
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
</div>
);
}
@@ -161,24 +160,23 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
dialog={
<div className="space-y-4">
<p>
{t('apiKeysQuestionRemove', {selectedApiKey: selected?.name || selected?.id})}
{t("apiKeysQuestionRemove", {
selectedApiKey:
selected?.name || selected?.id
})}
</p>
<p>
<b>
{t('apiKeysMessageRemove')}
</b>
<b>{t("apiKeysMessageRemove")}</b>
</p>
<p>
{t('apiKeysMessageConfirm')}
</p>
<p>{t("apiKeysMessageConfirm")}</p>
</div>
}
buttonText={t('apiKeysDeleteConfirm')}
buttonText={t("apiKeysDeleteConfirm")}
onConfirm={async () => deleteSite(selected!.id)}
string={selected.name}
title={t('apiKeysDelete')}
title={t("apiKeysDelete")}
/>
)}

View File

@@ -2,16 +2,7 @@ import { internal } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
import { authCookieHeader } from "@app/lib/api/cookies";
import { SidebarSettings } from "@app/components/SidebarSettings";
import Link from "next/link";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from "@app/components/ui/breadcrumb";
import { GetApiKeyResponse } from "@server/routers/apiKeys";
import ApiKeyProvider from "@app/providers/ApiKeyProvider";
import { HorizontalTabs } from "@app/components/HorizontalTabs";

View File

@@ -25,21 +25,12 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@app/components/ui/input";
import { InfoIcon } from "lucide-react";
import { Button } from "@app/components/ui/button";
import { Checkbox, CheckboxWithLabel } from "@app/components/ui/checkbox";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { AxiosResponse } from "axios";
import { useParams, useRouter } from "next/navigation";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from "@app/components/ui/breadcrumb";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
CreateOrgApiKeyBody,
CreateOrgApiKeyResponse
@@ -108,7 +99,7 @@ export default function Page() {
const copiedForm = useForm<CopiedFormValues>({
resolver: zodResolver(copiedFormSchema),
defaultValues: {
copied: false
copied: true
}
});
@@ -299,54 +290,54 @@ export default function Page() {
</AlertDescription>
</Alert>
<h4 className="font-semibold">
{t('apiKeysInfo')}
</h4>
{/* <h4 className="font-semibold"> */}
{/* {t('apiKeysInfo')} */}
{/* </h4> */}
<CopyTextBox
text={`${apiKey.apiKeyId}.${apiKey.apiKey}`}
/>
<Form {...copiedForm}>
<form
className="space-y-4"
id="copied-form"
>
<FormField
control={copiedForm.control}
name="copied"
render={({ field }) => (
<FormItem>
<div className="flex items-center space-x-2">
<Checkbox
id="terms"
defaultChecked={
copiedForm.getValues(
"copied"
) as boolean
}
onCheckedChange={(
e
) => {
copiedForm.setValue(
"copied",
e as boolean
);
}}
/>
<label
htmlFor="terms"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t('apiKeysConfirmCopy')}
</label>
</div>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
{/* <Form {...copiedForm}> */}
{/* <form */}
{/* className="space-y-4" */}
{/* id="copied-form" */}
{/* > */}
{/* <FormField */}
{/* control={copiedForm.control} */}
{/* name="copied" */}
{/* render={({ field }) => ( */}
{/* <FormItem> */}
{/* <div className="flex items-center space-x-2"> */}
{/* <Checkbox */}
{/* id="terms" */}
{/* defaultChecked={ */}
{/* copiedForm.getValues( */}
{/* "copied" */}
{/* ) as boolean */}
{/* } */}
{/* onCheckedChange={( */}
{/* e */}
{/* ) => { */}
{/* copiedForm.setValue( */}
{/* "copied", */}
{/* e as boolean */}
{/* ); */}
{/* }} */}
{/* /> */}
{/* <label */}
{/* htmlFor="terms" */}
{/* className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" */}
{/* > */}
{/* {t('apiKeysConfirmCopy')} */}
{/* </label> */}
{/* </div> */}
{/* <FormMessage /> */}
{/* </FormItem> */}
{/* )} */}
{/* /> */}
{/* </form> */}
{/* </Form> */}
</SettingsSectionBody>
</SettingsSection>
)}

View File

@@ -43,14 +43,14 @@ export default function IdpTable({ idps }: Props) {
try {
await api.delete(`/idp/${idpId}`);
toast({
title: t('success'),
description: t('idpDeletedDescription')
title: t("success"),
description: t("idpDeletedDescription")
});
setIsDeleteModalOpen(false);
router.refresh();
} catch (e) {
toast({
title: t('error'),
title: t("error"),
description: formatAxiosError(e),
variant: "destructive"
});
@@ -67,41 +67,6 @@ export default function IdpTable({ idps }: Props) {
};
const columns: ColumnDef<IdpRow>[] = [
{
id: "dots",
cell: ({ row }) => {
const r = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">{t('openMenu')}</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Link
className="block w-full"
href={`/admin/idp/${r.idpId}/general`}
>
<DropdownMenuItem>
{t('viewSettings')}
</DropdownMenuItem>
</Link>
<DropdownMenuItem
onClick={() => {
setSelectedIdp(r);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">{t('delete')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
},
{
accessorKey: "idpId",
header: ({ column }) => {
@@ -128,7 +93,7 @@ export default function IdpTable({ idps }: Props) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t('name')}
{t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -144,7 +109,7 @@ export default function IdpTable({ idps }: Props) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t('type')}
{t("type")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -162,9 +127,43 @@ export default function IdpTable({ idps }: Props) {
const siteRow = 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">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Link
className="block w-full"
href={`/admin/idp/${siteRow.idpId}/general`}
>
<DropdownMenuItem>
{t("viewSettings")}
</DropdownMenuItem>
</Link>
<DropdownMenuItem
onClick={() => {
setSelectedIdp(siteRow);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link href={`/admin/idp/${siteRow.idpId}/general`}>
<Button variant={"outlinePrimary"} className="ml-2">
{t('edit')}
<Button
variant={"secondary"}
className="ml-2"
size="sm"
>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
@@ -186,22 +185,20 @@ export default function IdpTable({ idps }: Props) {
dialog={
<div className="space-y-4">
<p>
{t('idpQuestionRemove', {name: selectedIdp.name})}
{t("idpQuestionRemove", {
name: selectedIdp.name
})}
</p>
<p>
<b>
{t('idpMessageRemove')}
</b>
</p>
<p>
{t('idpMessageConfirm')}
<b>{t("idpMessageRemove")}</b>
</p>
<p>{t("idpMessageConfirm")}</p>
</div>
}
buttonText={t('idpConfirmDelete')}
buttonText={t("idpConfirmDelete")}
onConfirm={async () => deleteIdp(selectedIdp.idpId)}
string={selectedIdp.name}
title={t('idpDelete')}
title={t("idpDelete")}
/>
)}

View File

@@ -4,17 +4,7 @@ import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
import { authCookieHeader } from "@app/lib/api/cookies";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import { ProfessionalContentOverlay } from "@app/components/ProfessionalContentOverlay";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from "@app/components/ui/breadcrumb";
import { getTranslations } from "next-intl/server";
interface SettingsLayoutProps {

View File

@@ -140,7 +140,7 @@ export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props
return (
<div className="flex items-center justify-end">
<Button
variant={"outlinePrimary"}
variant={"secondary"}
className="ml-2"
onClick={() => onEdit(policy)}
>

View File

@@ -326,7 +326,7 @@ export default function PoliciesPage() {
{/*TODO(vlalx): Validate replacing */}
{t('orgPoliciesAboutDescription')}{" "}
<Link
href="https://docs.fossorial.io/Pangolin/Identity%20Providers/auto-provision"
href="https://docs.digpangolin.com/manage/identity-providers/auto-provisioning"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"

View File

@@ -1,5 +1,6 @@
import { Metadata } from "next";
import { Users } from "lucide-react";
import { TopbarNav } from "@app/components/TopbarNav";
import { KeyRound, Users } from "lucide-react";
import { verifySession } from "@app/lib/auth/verifySession";
import { redirect } from "next/navigation";
import { cache } from "react";
@@ -9,7 +10,7 @@ import { internal } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { authCookieHeader } from "@app/lib/api/cookies";
import { Layout } from "@app/components/Layout";
import { adminNavItems } from "../navigation";
import { adminNavSections } from "../navigation";
export const dynamic = "force-dynamic";
@@ -47,7 +48,7 @@ export default async function AdminLayout(props: LayoutProps) {
return (
<UserProvider user={user}>
<Layout orgs={orgs} navItems={adminNavItems}>
<Layout orgs={orgs} navItems={adminNavSections}>
{props.children}
</Layout>
</UserProvider>

View File

@@ -122,7 +122,7 @@ export function LicenseKeysDataTable({
cell: ({ row }) => (
<div className="flex items-center justify-end space-x-2">
<Button
variant="outlinePrimary"
variant="secondary"
onClick={() => onDelete(row.original)}
>
{t('delete')}

View File

@@ -3,7 +3,7 @@
import { ColumnDef } from "@tanstack/react-table";
import { UsersDataTable } from "./AdminUsersDataTable";
import { Button } from "@app/components/ui/button";
import { ArrowRight, ArrowUpDown } from "lucide-react";
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
@@ -12,6 +12,12 @@ import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import {
DropdownMenu,
DropdownMenuItem,
DropdownMenuContent,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
export type GlobalUserRow = {
id: string;
@@ -22,6 +28,8 @@ export type GlobalUserRow = {
idpId: number | null;
idpName: string;
dateCreated: string;
twoFactorEnabled: boolean | null;
twoFactorSetupRequested: boolean | null;
};
type Props = {
@@ -41,11 +49,11 @@ export default function UsersTable({ users }: Props) {
const deleteUser = (id: string) => {
api.delete(`/user/${id}`)
.catch((e) => {
console.error(t('userErrorDelete'), e);
console.error(t("userErrorDelete"), e);
toast({
variant: "destructive",
title: t('userErrorDelete'),
description: formatAxiosError(e, t('userErrorDelete'))
title: t("userErrorDelete"),
description: formatAxiosError(e, t("userErrorDelete"))
});
})
.then(() => {
@@ -84,7 +92,7 @@ export default function UsersTable({ users }: Props) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t('username')}
{t("username")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -100,7 +108,7 @@ export default function UsersTable({ users }: Props) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t('email')}
{t("email")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -116,7 +124,7 @@ export default function UsersTable({ users }: Props) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t('name')}
{t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -132,28 +140,85 @@ export default function UsersTable({ users }: Props) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t('identityProvider')}
{t("identityProvider")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "twoFactorEnabled",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("twoFactor")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const userRow = row.original;
return (
<div className="flex flex-row items-center gap-2">
<span>
{userRow.twoFactorEnabled ||
userRow.twoFactorSetupRequested ? (
<span className="text-green-500">
{t("enabled")}
</span>
) : (
<span>{t("disabled")}</span>
)}
</span>
</div>
);
}
},
{
id: "actions",
cell: ({ row }) => {
const r = row.original;
return (
<>
<div className="flex items-center justify-end">
<div className="flex items-center justify-end gap-2">
<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">
<DropdownMenuItem
onClick={() => {
setSelected(r);
setIsDeleteModalOpen(true);
}}
>
{t("delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant={"outlinePrimary"}
className="ml-2"
variant={"secondary"}
size="sm"
onClick={() => {
setSelected(r);
setIsDeleteModalOpen(true);
router.push(`/admin/users/${r.id}`);
}}
>
{t('delete')}
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</div>
</>
@@ -174,26 +239,27 @@ export default function UsersTable({ users }: Props) {
dialog={
<div className="space-y-4">
<p>
{t('userQuestionRemove', {selectedUser: selected?.email || selected?.name || selected?.username})}
{t("userQuestionRemove", {
selectedUser:
selected?.email ||
selected?.name ||
selected?.username
})}
</p>
<p>
<b>
{t('userMessageRemove')}
</b>
<b>{t("userMessageRemove")}</b>
</p>
<p>
{t('userMessageConfirm')}
</p>
<p>{t("userMessageConfirm")}</p>
</div>
}
buttonText={t('userDeleteConfirm')}
buttonText={t("userDeleteConfirm")}
onConfirm={async () => deleteUser(selected!.id)}
string={
selected.email || selected.name || selected.username
}
title={t('userDeleteServer')}
title={t("userDeleteServer")}
/>
)}

View File

@@ -0,0 +1,134 @@
"use client";
import { useEffect, useState } from "react";
import { SwitchInput } from "@app/components/SwitchInput";
import { Button } from "@app/components/ui/button";
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 { useTranslations } from "next-intl";
import { useParams } from "next/navigation";
import {
SettingsContainer,
SettingsSection,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionForm
} from "@app/components/Settings";
import { UserType } from "@server/types/UserTypes";
export default function GeneralPage() {
const { userId } = useParams();
const api = createApiClient(useEnvContext());
const t = useTranslations();
const [loadingData, setLoadingData] = useState(true);
const [loading, setLoading] = useState(false);
const [twoFactorEnabled, setTwoFactorEnabled] = useState(false);
const [userType, setUserType] = useState<UserType | null>(null);
useEffect(() => {
// Fetch current user 2FA status
const fetchUserData = async () => {
setLoadingData(true);
try {
const response = await api.get(`/user/${userId}`);
if (response.status === 200) {
const userData = response.data.data;
setTwoFactorEnabled(
userData.twoFactorEnabled ||
userData.twoFactorSetupRequested
);
setUserType(userData.type);
}
} catch (error) {
console.error("Failed to fetch user data:", error);
toast({
variant: "destructive",
title: t("userErrorDelete"),
description: formatAxiosError(error, t("userErrorDelete"))
});
}
setLoadingData(false);
};
fetchUserData();
}, [userId]);
const handleTwoFactorToggle = (enabled: boolean) => {
setTwoFactorEnabled(enabled);
};
const handleSaveSettings = async () => {
setLoading(true);
try {
console.log("twoFactorEnabled", twoFactorEnabled);
await api.post(`/user/${userId}/2fa`, {
twoFactorSetupRequested: twoFactorEnabled
});
setTwoFactorEnabled(twoFactorEnabled);
} catch (error) {
toast({
variant: "destructive",
title: t("otpErrorEnable"),
description: formatAxiosError(
error,
t("otpErrorEnableDescription")
)
});
} finally {
setLoading(false);
}
};
if (loadingData) {
return null;
}
return (
<>
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("general")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("userDescription2")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<div className="space-y-6">
<SwitchInput
id="two-factor-auth"
label={t("otpAuth")}
checked={twoFactorEnabled}
disabled={userType !== UserType.Internal}
onCheckedChange={handleTwoFactorToggle}
/>
</div>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
</SettingsContainer>
<div className="flex justify-end mt-6">
<Button
type="button"
loading={loading}
disabled={loading}
onClick={handleSaveSettings}
>
{t("targetTlsSubmit")}
</Button>
</div>
</>
);
}

View File

@@ -0,0 +1,55 @@
import { internal } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
import { authCookieHeader } from "@app/lib/api/cookies";
import { AdminGetUserResponse } from "@server/routers/user/adminGetUser";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import { cache } from "react";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from 'next-intl/server';
interface UserLayoutProps {
children: React.ReactNode;
params: Promise<{ userId: string }>;
}
export default async function UserLayoutProps(props: UserLayoutProps) {
const params = await props.params;
const { children } = props;
const t = await getTranslations();
let user = null;
try {
const getUser = cache(async () =>
internal.get<AxiosResponse<AdminGetUserResponse>>(
`/user/${params.userId}`,
await authCookieHeader()
)
);
const res = await getUser();
user = res.data.data;
} catch {
redirect(`/admin/users`);
}
const navItems = [
{
title: t('general'),
href: "/admin/users/{userId}/general"
}
];
return (
<>
<SettingsSectionTitle
title={`${user?.email || user?.name || user?.username}`}
description={t('userDescription2')}
/>
<HorizontalTabs items={navItems}>
{children}
</HorizontalTabs>
</>
);
}

View File

@@ -0,0 +1,8 @@
import { redirect } from "next/navigation";
export default async function UserPage(props: {
params: Promise<{ userId: string }>;
}) {
const { userId } = await props.params;
redirect(`/admin/users/${userId}/general`);
}

View File

@@ -38,7 +38,9 @@ export default async function UsersPage(props: PageProps) {
idpId: row.idpId,
idpName: row.idpName || t('idpNameInternal'),
dateCreated: row.dateCreated,
serverAdmin: row.serverAdmin
serverAdmin: row.serverAdmin,
twoFactorEnabled: row.twoFactorEnabled,
twoFactorSetupRequested: row.twoFactorSetupRequested
};
});

View File

@@ -0,0 +1,62 @@
"use client";
import { useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "@/components/ui/card";
import TwoFactorSetupForm from "@app/components/TwoFactorSetupForm";
import { useTranslations } from "next-intl";
import { cleanRedirect } from "@app/lib/cleanRedirect";
export default function Setup2FAPage() {
const router = useRouter();
const searchParams = useSearchParams();
const redirect = searchParams?.get("redirect");
const email = searchParams?.get("email");
const t = useTranslations();
// Redirect to login if no email is provided
useEffect(() => {
if (!email) {
router.push("/auth/login");
}
}, [email, router]);
const handleComplete = () => {
console.log("2FA setup complete", redirect, email);
if (redirect) {
const cleanUrl = cleanRedirect(redirect);
console.log("Redirecting to:", cleanUrl);
router.push(cleanUrl);
} else {
router.push("/");
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{t("otpSetup")}</CardTitle>
<CardDescription>
{t("adminEnabled2FaOnYourAccount", { email: email || "your account" })}
</CardDescription>
</CardHeader>
<CardContent>
<TwoFactorSetupForm
email={email || undefined}
onComplete={handleComplete}
submitButtonText="Continue"
showCancelButton={false}
/>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,4 +1,5 @@
import ProfileIcon from "@app/components/ProfileIcon";
import ThemeSwitcher from "@app/components/ThemeSwitcher";
import { Separator } from "@app/components/ui/separator";
import { priv } from "@app/lib/api";
import { verifySession } from "@app/lib/auth/verifySession";
@@ -23,6 +24,7 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
const getUser = cache(verifySession);
const user = await getUser();
const t = await getTranslations();
const hideFooter = true;
const licenseStatusRes = await cache(
async () =>
@@ -34,20 +36,18 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
return (
<div className="h-full flex flex-col">
{user && (
<UserProvider user={user}>
<div className="p-3 ml-auto">
<ProfileIcon />
</div>
</UserProvider>
)}
<div className="flex justify-end items-center p-3 space-x-2">
<ThemeSwitcher />
</div>
<div className="flex-1 flex items-center justify-center">
<div className="w-full max-w-md p-3">{children}</div>
</div>
{!(
licenseStatus.isHostLicensed && licenseStatus.isLicenseValid
hideFooter || (
licenseStatus.isHostLicensed &&
licenseStatus.isLicenseValid)
) && (
<footer className="hidden md:block w-full mt-12 py-3 mb-6 px-4">
<div className="container mx-auto flex flex-wrap justify-center items-center h-3 space-x-4 text-sm text-neutral-400 dark:text-neutral-600">
@@ -73,7 +73,7 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
aria-label="GitHub"
className="flex items-center space-x-2 whitespace-nowrap"
>
<span>{t('communityEdition')}</span>
<span>{t("communityEdition")}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"

View File

@@ -14,6 +14,7 @@ import { useRouter } from "next/navigation";
import { useEffect } from "react";
import Image from "next/image";
import { cleanRedirect } from "@app/lib/cleanRedirect";
import BrandingLogo from "@app/components/BrandingLogo";
import { useTranslations } from "next-intl";
type DashboardLoginFormProps = {
@@ -26,42 +27,24 @@ export default function DashboardLoginForm({
idps
}: DashboardLoginFormProps) {
const router = useRouter();
// const api = createApiClient(useEnvContext());
//
// useEffect(() => {
// const logout = async () => {
// try {
// await api.post("/auth/logout");
// console.log("user logged out");
// } catch (e) {}
// };
//
// logout();
// });
const { env } = useEnvContext();
const t = useTranslations();
function getSubtitle() {
return t("loginStart");
}
return (
<Card className="w-full max-w-md">
<CardHeader>
<Card className="shadow-md w-full max-w-md">
<CardHeader className="border-b">
<div className="flex flex-row items-center justify-center">
<Image
src={`/logo/pangolin_orange.svg`}
alt={t('pangolinLogoAlt')}
width={100}
height={100}
/>
<BrandingLogo height={58} width={175} />
</div>
<div className="text-center space-y-1">
<h1 className="text-2xl font-bold mt-1">
{t('welcome')}
</h1>
<p className="text-sm text-muted-foreground">
{t('loginStart')}
</p>
<div className="text-center space-y-1 pt-3">
<p className="text-muted-foreground">{getSubtitle()}</p>
</div>
</CardHeader>
<CardContent>
<CardContent className="pt-6">
<LoginForm
redirect={redirect}
idps={idps}

View File

@@ -20,7 +20,7 @@ export default async function Page(props: {
}) {
const searchParams = await props.searchParams;
const getUser = cache(verifySession);
const user = await getUser();
const user = await getUser({ skipCheckVerifyEmail: true });
const isInvite = searchParams?.redirect?.includes("/invite");
@@ -54,10 +54,10 @@ export default async function Page(props: {
<div className="flex flex-col items-center">
<Mail className="w-12 h-12 mb-4 text-primary" />
<h2 className="text-2xl font-bold mb-2 text-center">
{t('inviteAlready')}
{t("inviteAlready")}
</h2>
<p className="text-center">
{t('inviteAlreadyDescription')}
{t("inviteAlreadyDescription")}
</p>
</div>
</div>
@@ -67,7 +67,7 @@ export default async function Page(props: {
{(!signUpDisabled || isInvite) && (
<p className="text-center text-muted-foreground mt-4">
{t('authNoAccount')}{" "}
{t("authNoAccount")}{" "}
<Link
href={
!redirectUrl
@@ -76,7 +76,7 @@ export default async function Page(props: {
}
className="underline"
>
{t('signup')}
{t("signup")}
</Link>
</p>
)}

View File

@@ -54,12 +54,14 @@ export type ResetPasswordFormProps = {
emailParam?: string;
tokenParam?: string;
redirect?: string;
quickstart?: boolean;
};
export default function ResetPasswordForm({
emailParam,
tokenParam,
redirect
redirect,
quickstart
}: ResetPasswordFormProps) {
const router = useRouter();
@@ -184,17 +186,63 @@ export default function ResetPasswordForm({
return;
}
setSuccessMessage(t('passwordResetSuccess'));
setSuccessMessage(quickstart ? t('accountSetupSuccess') : t('passwordResetSuccess'));
setTimeout(() => {
if (redirect) {
const safe = cleanRedirect(redirect);
router.push(safe);
} else {
router.push("/login");
// Auto-login after successful password reset
try {
const loginRes = await api.post("/auth/login", {
email: form.getValues("email"),
password: form.getValues("password")
});
if (loginRes.data.data?.codeRequested) {
if (redirect) {
router.push(`/auth/login?redirect=${redirect}`);
} else {
router.push("/auth/login");
}
return;
}
setIsSubmitting(false);
}, 1500);
if (loginRes.data.data?.emailVerificationRequired) {
try {
await api.post("/auth/verify-email/request");
} catch (verificationError) {
console.error("Failed to send verification code:", verificationError);
}
if (redirect) {
router.push(`/auth/verify-email?redirect=${redirect}`);
} else {
router.push("/auth/verify-email");
}
return;
}
// Login successful, redirect
setTimeout(() => {
if (redirect) {
const safe = cleanRedirect(redirect);
router.push(safe);
} else {
router.push("/");
}
setIsSubmitting(false);
}, 1500);
} catch (loginError) {
// Auto-login failed, but password reset was successful
console.error("Auto-login failed:", loginError);
setTimeout(() => {
if (redirect) {
const safe = cleanRedirect(redirect);
router.push(safe);
} else {
router.push("/login");
}
setIsSubmitting(false);
}, 1500);
}
}
}
@@ -202,9 +250,14 @@ export default function ResetPasswordForm({
<div>
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{t('passwordReset')}</CardTitle>
<CardTitle>
{quickstart ? t('completeAccountSetup') : t('passwordReset')}
</CardTitle>
<CardDescription>
{t('passwordResetDescription')}
{quickstart
? t('completeAccountSetupDescription')
: t('passwordResetDescription')
}
</CardDescription>
</CardHeader>
<CardContent>
@@ -229,7 +282,10 @@ export default function ResetPasswordForm({
</FormControl>
<FormMessage />
<FormDescription>
{t('passwordResetSent')}
{quickstart
? t('accountSetupSent')
: t('passwordResetSent')
}
</FormDescription>
</FormItem>
)}
@@ -269,7 +325,10 @@ export default function ResetPasswordForm({
render={({ field }) => (
<FormItem>
<FormLabel>
{t('passwordResetCode')}
{quickstart
? t('accountSetupCode')
: t('passwordResetCode')
}
</FormLabel>
<FormControl>
<Input
@@ -279,7 +338,10 @@ export default function ResetPasswordForm({
</FormControl>
<FormMessage />
<FormDescription>
{t('passwordResetCodeDescription')}
{quickstart
? t('accountSetupCodeDescription')
: t('passwordResetCodeDescription')
}
</FormDescription>
</FormItem>
)}
@@ -292,7 +354,10 @@ export default function ResetPasswordForm({
render={({ field }) => (
<FormItem>
<FormLabel>
{t('passwordNew')}
{quickstart
? t('passwordCreate')
: t('passwordNew')
}
</FormLabel>
<FormControl>
<Input
@@ -310,7 +375,10 @@ export default function ResetPasswordForm({
render={({ field }) => (
<FormItem>
<FormLabel>
{t('passwordNewConfirm')}
{quickstart
? t('passwordCreateConfirm')
: t('passwordNewConfirm')
}
</FormLabel>
<FormControl>
<Input
@@ -407,7 +475,7 @@ export default function ResetPasswordForm({
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{state === "reset"
? t('passwordReset')
? (quickstart ? t('completeSetup') : t('passwordReset'))
: t('pincodeSubmit2')}
</Button>
)}
@@ -422,7 +490,10 @@ export default function ResetPasswordForm({
{isSubmitting && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{t('passwordResetSubmit')}
{quickstart
? t('accountSetupSubmit')
: t('passwordResetSubmit')
}
</Button>
)}

View File

@@ -13,6 +13,7 @@ export default async function Page(props: {
redirect: string | undefined;
email: string | undefined;
token: string | undefined;
quickstart?: string | undefined;
}>;
}) {
const searchParams = await props.searchParams;
@@ -35,6 +36,9 @@ export default async function Page(props: {
redirect={searchParams.redirect}
tokenParam={searchParams.token}
emailParam={searchParams.email}
quickstart={
searchParams.quickstart === "true" ? true : undefined
}
/>
<p className="text-center text-muted-foreground mt-4">
@@ -46,7 +50,7 @@ export default async function Page(props: {
}
className="underline"
>
{t('loginBack')}
{t("loginBack")}
</Link>
</p>
</>

View File

@@ -43,6 +43,7 @@ import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import Link from "next/link";
import Image from "next/image";
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
import { useTranslations } from "next-intl";
@@ -185,8 +186,8 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
setOtpState("otp_sent");
submitOtpForm.setValue("email", values.email);
toast({
title: t('otpEmailSent'),
description: t('otpEmailSentDescription')
title: t("otpEmailSent"),
description: t("otpEmailSentDescription")
});
return;
}
@@ -202,7 +203,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
.catch((e) => {
console.error(e);
setWhitelistError(
formatAxiosError(e, t('otpEmailErrorAuthenticate'))
formatAxiosError(e, t("otpEmailErrorAuthenticate"))
);
})
.then(() => setLoadingLogin(false));
@@ -227,7 +228,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
.catch((e) => {
console.error(e);
setPincodeError(
formatAxiosError(e, t('pincodeErrorAuthenticate'))
formatAxiosError(e, t("pincodeErrorAuthenticate"))
);
})
.then(() => setLoadingLogin(false));
@@ -255,7 +256,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
.catch((e) => {
console.error(e);
setPasswordError(
formatAxiosError(e, t('passwordErrorAuthenticate'))
formatAxiosError(e, t("passwordErrorAuthenticate"))
);
})
.finally(() => setLoadingLogin(false));
@@ -276,13 +277,23 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
}
}
function getTitle() {
return t("authenticationRequired");
}
function getSubtitle(resourceName: string) {
return numMethods > 1
? t("authenticationMethodChoose", { name: props.resource.name })
: t("authenticationRequest", { name: props.resource.name });
}
return (
<div>
{!accessDenied ? (
<div>
<div className="text-center mb-2">
<span className="text-sm text-muted-foreground">
{t('poweredBy')}{" "}
{t("poweredBy")}{" "}
<Link
href="https://github.com/fosrl/pangolin"
target="_blank"
@@ -295,11 +306,9 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
</div>
<Card>
<CardHeader>
<CardTitle>{t('authenticationRequired')}</CardTitle>
<CardTitle>{getTitle()}</CardTitle>
<CardDescription>
{numMethods > 1
? t('authenticationMethodChoose', {name: props.resource.name})
: t('authenticationRequest', {name: props.resource.name})}
{getSubtitle(props.resource.name)}
</CardDescription>
</CardHeader>
<CardContent>
@@ -329,19 +338,19 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
{props.methods.password && (
<TabsTrigger value="password">
<Key className="w-4 h-4 mr-1" />{" "}
{t('password')}
{t("password")}
</TabsTrigger>
)}
{props.methods.sso && (
<TabsTrigger value="sso">
<User className="w-4 h-4 mr-1" />{" "}
{t('user')}
{t("user")}
</TabsTrigger>
)}
{props.methods.whitelist && (
<TabsTrigger value="whitelist">
<AtSign className="w-4 h-4 mr-1" />{" "}
{t('email')}
{t("email")}
</TabsTrigger>
)}
</TabsList>
@@ -364,7 +373,9 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
render={({ field }) => (
<FormItem>
<FormLabel>
{t('pincodeInput')}
{t(
"pincodeInput"
)}
</FormLabel>
<FormControl>
<div className="flex justify-center">
@@ -433,7 +444,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
disabled={loadingLogin}
>
<LockIcon className="w-4 h-4 mr-2" />
{t('pincodeSubmit')}
{t("pincodeSubmit")}
</Button>
</form>
</Form>
@@ -459,7 +470,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
render={({ field }) => (
<FormItem>
<FormLabel>
{t('password')}
{t("password")}
</FormLabel>
<FormControl>
<Input
@@ -487,7 +498,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
disabled={loadingLogin}
>
<LockIcon className="w-4 h-4 mr-2" />
{t('passwordSubmit')}
{t("passwordSubmit")}
</Button>
</form>
</Form>
@@ -528,7 +539,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
render={({ field }) => (
<FormItem>
<FormLabel>
{t('email')}
{t("email")}
</FormLabel>
<FormControl>
<Input
@@ -537,7 +548,9 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
/>
</FormControl>
<FormDescription>
{t('otpEmailDescription')}
{t(
"otpEmailDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
@@ -559,7 +572,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
disabled={loadingLogin}
>
<Send className="w-4 h-4 mr-2" />
{t('otpEmailSend')}
{t("otpEmailSend")}
</Button>
</form>
</Form>
@@ -581,7 +594,9 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
render={({ field }) => (
<FormItem>
<FormLabel>
{t('otpEmail')}
{t(
"otpEmail"
)}
</FormLabel>
<FormControl>
<Input
@@ -609,7 +624,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
disabled={loadingLogin}
>
<LockIcon className="w-4 h-4 mr-2" />
{t('otpEmailSubmit')}
{t("otpEmailSubmit")}
</Button>
<Button
@@ -621,7 +636,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
submitOtpForm.reset();
}}
>
{t('backToEmail')}
{t("backToEmail")}
</Button>
</form>
</Form>
@@ -634,7 +649,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
{supporterStatus?.visible && (
<div className="text-center mt-2">
<span className="text-sm text-muted-foreground opacity-50">
{t('noSupportKey')}
{t("noSupportKey")}
</span>
</div>
)}

View File

@@ -59,9 +59,14 @@ export default async function ResourceAuthPage(props: {
try {
const serverResourceHost = new URL(authInfo.url).host;
const redirectHost = new URL(searchParams.redirect).host;
const redirectPort = new URL(searchParams.redirect).port;
const serverResourceHostWithPort = `${serverResourceHost}:${redirectPort}`;
if (serverResourceHost === redirectHost) {
redirectUrl = searchParams.redirect;
} else if ( serverResourceHostWithPort === redirectHost ) {
redirectUrl = searchParams.redirect;
}
} catch (e) {}
}

View File

@@ -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,
@@ -32,6 +33,8 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
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;
@@ -43,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"],
@@ -57,17 +72,21 @@ export default function SignupForm({
}: SignupFormProps) {
const router = useRouter();
const api = createApiClient(useEnvContext());
const { env } = useEnvContext();
const api = createApiClient({ env });
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
}
});
@@ -82,13 +101,12 @@ export default function SignupForm({
email,
password,
inviteId,
inviteToken
inviteToken,
termsAcceptedTimestamp: termsAgreedAt
})
.catch((e) => {
console.error(e);
setError(
formatAxiosError(e, t('signupError'))
);
setError(formatAxiosError(e, t("signupError")));
});
if (res && res.status === 200) {
@@ -115,27 +133,33 @@ export default function SignupForm({
setLoading(false);
}
function getSubtitle() {
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">
<CardHeader>
<Card className="w-full max-w-md shadow-md">
<CardHeader className="border-b">
<div className="flex flex-row items-center justify-center">
<Image
src={`/logo/pangolin_orange.svg`}
alt={t('pangolinLogoAlt')}
width="100"
height="100"
/>
<BrandingLogo height={58} width={175} />
</div>
<div className="text-center space-y-1">
<h1 className="text-2xl font-bold mt-1">
{t('welcome')}
</h1>
<p className="text-sm text-muted-foreground">
{t('authCreateAccount')}
</p>
<div className="text-center space-y-1 pt-3">
<p className="text-muted-foreground">{getSubtitle()}</p>
</div>
</CardHeader>
<CardContent>
<CardContent className="pt-6">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
@@ -146,7 +170,7 @@ export default function SignupForm({
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>{t('email')}</FormLabel>
<FormLabel>{t("email")}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
@@ -159,12 +183,9 @@ export default function SignupForm({
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>{t('password')}</FormLabel>
<FormLabel>{t("password")}</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -175,17 +196,64 @@ export default function SignupForm({
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>{t('confirmPassword')}</FormLabel>
<FormLabel>
{t("confirmPassword")}
</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</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">
@@ -194,7 +262,7 @@ export default function SignupForm({
)}
<Button type="submit" className="w-full">
{t('createAccount')}
{t("createAccount")}
</Button>
</form>
</Form>

View File

@@ -6,7 +6,7 @@ import { Mail } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { cache } from "react";
import { getTranslations } from 'next-intl/server';
import { getTranslations } from "next-intl/server";
export const dynamic = "force-dynamic";
@@ -15,7 +15,7 @@ export default async function Page(props: {
}) {
const searchParams = await props.searchParams;
const getUser = cache(verifySession);
const user = await getUser();
const user = await getUser({ skipCheckVerifyEmail: true });
const t = await getTranslations();
const env = pullEnv();
@@ -56,10 +56,10 @@ export default async function Page(props: {
<div className="flex flex-col items-center">
<Mail className="w-12 h-12 mb-4 text-primary" />
<h2 className="text-2xl font-bold mb-2 text-center">
{t('inviteAlready')}
{t("inviteAlready")}
</h2>
<p className="text-center">
{t('inviteAlreadyDescription')}
{t("inviteAlreadyDescription")}
</p>
</div>
</div>
@@ -72,7 +72,7 @@ export default async function Page(props: {
/>
<p className="text-center text-muted-foreground mt-4">
{t('signupQuestion')}{" "}
{t("signupQuestion")}{" "}
<Link
href={
!redirectUrl
@@ -81,7 +81,7 @@ export default async function Page(props: {
}
className="underline"
>
{t('login')}
{t("login")}
</Link>
</p>
</>

View File

@@ -10,7 +10,7 @@ import {
CardContent,
CardDescription,
CardHeader,
CardTitle,
CardTitle
} from "@/components/ui/card";
import {
Form,
@@ -19,21 +19,21 @@ import {
FormField,
FormItem,
FormLabel,
FormMessage,
FormMessage
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
InputOTPSlot
} from "@/components/ui/input-otp";
import { AxiosResponse } from "axios";
import { VerifyEmailResponse } from "@server/routers/auth";
import { Loader2 } from "lucide-react";
import { ArrowRight, IdCard, Loader2 } from "lucide-react";
import { Alert, AlertDescription } from "../../../components/ui/alert";
import { toast } from "@app/hooks/useToast";
import { useRouter } from "next/navigation";
import { formatAxiosError } from "@app/lib/api";;
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { cleanRedirect } from "@app/lib/cleanRedirect";
@@ -46,7 +46,7 @@ export type VerifyEmailFormProps = {
export default function VerifyEmailForm({
email,
redirect,
redirect
}: VerifyEmailFormProps) {
const router = useRouter();
const t = useTranslations();
@@ -58,19 +58,34 @@ export default function VerifyEmailForm({
const api = createApiClient(useEnvContext());
function logout() {
api.post("/auth/logout")
.catch((e) => {
console.error(t("logoutError"), e);
toast({
title: t("logoutError"),
description: formatAxiosError(e, t("logoutError"))
});
})
.then(() => {
router.push("/auth/login");
router.refresh();
});
}
const FormSchema = z.object({
email: z.string().email({ message: t('emailInvalid') }),
email: z.string().email({ message: t("emailInvalid") }),
pin: z.string().min(8, {
message: t('verificationCodeLengthRequirements'),
}),
message: t("verificationCodeLengthRequirements")
})
});
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
email: email,
pin: "",
},
pin: ""
}
});
async function onSubmit(data: z.infer<typeof FormSchema>) {
@@ -78,19 +93,17 @@ export default function VerifyEmailForm({
const res = await api
.post<AxiosResponse<VerifyEmailResponse>>("/auth/verify-email", {
code: data.pin,
code: data.pin
})
.catch((e) => {
setError(formatAxiosError(e, t('errorOccurred')));
console.error(t('emailErrorVerify'), e);
setError(formatAxiosError(e, t("errorOccurred")));
console.error(t("emailErrorVerify"), e);
setIsSubmitting(false);
});
if (res && res.data?.data?.valid) {
setError(null);
setSuccessMessage(
t('emailVerified')
);
setSuccessMessage(t("emailVerified"));
setTimeout(() => {
if (redirect) {
const safe = cleanRedirect(redirect);
@@ -107,16 +120,16 @@ export default function VerifyEmailForm({
setIsResending(true);
const res = await api.post("/auth/verify-email/request").catch((e) => {
setError(formatAxiosError(e, t('errorOccurred')));
console.error(t('verificationCodeErrorResend'), e);
setError(formatAxiosError(e, t("errorOccurred")));
console.error(t("verificationCodeErrorResend"), e);
});
if (res) {
setError(null);
toast({
variant: "default",
title: t('verificationCodeResend'),
description: t('verificationCodeResendDescription'),
title: t("verificationCodeResend"),
description: t("verificationCodeResendDescription")
});
}
@@ -127,40 +140,26 @@ export default function VerifyEmailForm({
<div>
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{t('emailVerify')}</CardTitle>
<CardTitle>{t("emailVerify")}</CardTitle>
<CardDescription>
{t('emailVerifyDescription')}
{t("emailVerifyDescription")}
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-center text-muted-foreground mb-4">
{email}
</p>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="verify-email-form"
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>{t('email')}</FormLabel>
<FormControl>
<Input
{...field}
disabled
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="pin"
render={({ field }) => (
<FormItem>
<FormLabel>{t('verificationCode')}</FormLabel>
<FormControl>
<div className="flex justify-center">
<InputOTP
@@ -197,13 +196,23 @@ export default function VerifyEmailForm({
</div>
</FormControl>
<FormMessage />
<FormDescription>
{t('verificationCodeEmailSent')}
</FormDescription>
</FormItem>
)}
/>
<div className="text-center text-muted-foreground">
<Button
type="button"
variant="link"
onClick={handleResendCode}
disabled={isResending}
>
{isResending
? t("emailVerifyResendProgress")
: t("emailVerifyResend")}
</Button>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
@@ -222,29 +231,26 @@ export default function VerifyEmailForm({
type="submit"
className="w-full"
disabled={isSubmitting}
form="verify-email-form"
>
{isSubmitting && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{t('submit')}
{t("submit")}
</Button>
<Button
type="button"
variant={"secondary"}
className="w-full"
onClick={logout}
>
Log in with another account
</Button>
</form>
</Form>
</CardContent>
</Card>
<div className="text-center text-muted-foreground mt-2">
<Button
type="button"
variant="link"
onClick={handleResendCode}
disabled={isResending}
>
{isResending
? t('emailVerifyResendProgress')
: t('emailVerifyResend')}
</Button>
</div>
</div>
);
}

View File

@@ -1,125 +1,142 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Space+Grotesk:wght@300..700&display=swap");
@import 'tw-animate-css';
@import 'tailwindcss';
@import "tw-animate-css";
@import "tailwindcss";
@custom-variant dark (&:is(.dark *));
:root {
--background: hsl(0 0% 98%);
--foreground: hsl(20 0% 10%);
--card: hsl(0 0% 100%);
--card-foreground: hsl(20 0% 10%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(20 0% 10%);
--primary: hsl(24.6 95% 53.1%);
--primary-foreground: hsl(60 9.1% 97.8%);
--secondary: hsl(60 4.8% 95.9%);
--secondary-foreground: hsl(24 9.8% 10%);
--muted: hsl(60 4.8% 85%);
--muted-foreground: hsl(25 5.3% 44.7%);
--accent: hsl(60 4.8% 90%);
--accent-foreground: hsl(24 9.8% 10%);
--destructive: hsl(0 84.2% 60.2%);
--destructive-foreground: hsl(60 9.1% 97.8%);
--border: hsl(20 5.9% 90%);
--input: hsl(20 5.9% 75%);
--ring: hsl(24.6 95% 53.1%);
--radius: 0.75rem;
--chart-1: hsl(12 76% 61%);
--chart-2: hsl(173 58% 39%);
--chart-3: hsl(197 37% 24%);
--chart-4: hsl(43 74% 66%);
--chart-5: hsl(27 87% 67%);
--radius: 0.65rem;
--background: oklch(0.99 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.6717 0.1946 41.93);
--primary-foreground: oklch(0.98 0.016 73.684);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.213 47.604);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.705 0.213 47.604);
--sidebar-primary-foreground: oklch(0.98 0.016 73.684);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.213 47.604);
}
.dark {
--background: hsl(20 0% 8%);
--foreground: hsl(60 9.1% 97.8%);
--card: hsl(20 0% 10%);
--card-foreground: hsl(60 9.1% 97.8%);
--popover: hsl(20 0% 10%);
--popover-foreground: hsl(60 9.1% 97.8%);
--primary: hsl(20.5 90.2% 48.2%);
--primary-foreground: hsl(60 9.1% 97.8%);
--secondary: hsl(12 6.5% 15%);
--secondary-foreground: hsl(60 9.1% 97.8%);
--muted: hsl(12 6.5% 25%);
--muted-foreground: hsl(24 5.4% 63.9%);
--accent: hsl(12 2.5% 15%);
--accent-foreground: hsl(60 9.1% 97.8%);
--destructive: hsl(0 72.2% 50.6%);
--destructive-foreground: hsl(60 9.1% 97.8%);
--border: hsl(12 6.5% 15%);
--input: hsl(12 6.5% 35%);
--ring: hsl(20.5 90.2% 48.2%);
--chart-1: hsl(220 70% 50%);
--chart-2: hsl(160 60% 45%);
--chart-3: hsl(30 80% 55%);
--chart-4: hsl(280 65% 60%);
--chart-5: hsl(340 75% 55%);
--background: oklch(0.20 0.006 285.885);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.6717 0.1946 41.93);
--primary-foreground: oklch(0.98 0.016 73.684);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.646 0.222 41.116);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.646 0.222 41.116);
--sidebar-primary-foreground: oklch(0.98 0.016 73.684);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.646 0.222 41.116);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--shadow-2xs: 0 1px 1px rgba(0, 0, 0, 0.03);
--inset-shadow-2xs: inset 0 1px 1px rgba(0, 0, 1, 0.03);
}
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
@layer base {
* {
@apply border-border;
}
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
body {
@apply bg-background text-foreground;
}
}
p {
word-break: keep-all;
white-space: normal;
}
word-break: keep-all;
white-space: normal;
}

View File

@@ -1,7 +1,6 @@
import type { Metadata } from "next";
import "./globals.css";
import { Inter } from "next/font/google";
import { Toaster } from "@/components/ui/toaster";
import { ThemeProvider } from "@app/providers/ThemeProvider";
import EnvProvider from "@app/providers/EnvProvider";
import { pullEnv } from "@app/lib/pullEnv";
@@ -11,14 +10,15 @@ import { AxiosResponse } from "axios";
import { IsSupporterKeyVisibleResponse } from "@server/routers/supporterKey";
import LicenseStatusProvider from "@app/providers/LicenseStatusProvider";
import { GetLicenseStatusResponse } from "@server/routers/license";
import LicenseViolation from "./components/LicenseViolation";
import LicenseViolation from "@app/components/LicenseViolation";
import { cache } from "react";
import { NextIntlClientProvider } from "next-intl";
import { getLocale } from "next-intl/server";
import { Toaster } from "@app/components/ui/toaster";
export const metadata: Metadata = {
title: `Dashboard - Pangolin`,
description: ""
description: "",
};
export const dynamic = "force-dynamic";
@@ -83,4 +83,4 @@ export default async function RootLayout({
</body>
</html>
);
}
}

View File

@@ -1,4 +1,5 @@
import { SidebarNavItem } from "@app/components/SidebarNav";
import { build } from "@server/build";
import {
Home,
Settings,
@@ -7,95 +8,130 @@ import {
Waypoints,
Combine,
Fingerprint,
Workflow,
KeyRound,
TicketCheck
TicketCheck,
User,
Globe, // Added from 'dev' branch
MonitorUp // Added from 'dev' branch
} from "lucide-react";
export type SidebarNavSection = { // Added from 'dev' branch
heading: string;
items: SidebarNavItem[];
};
// Merged from 'user-management-and-resources' branch
export const orgLangingNavItems: SidebarNavItem[] = [
{
title: "sidebarOverview",
title: "sidebarAccount",
href: "/{orgId}",
icon: <Home className="h-4 w-4" />
icon: <User className="h-4 w-4" />
}
];
export const rootNavItems: SidebarNavItem[] = [
export const orgNavSections = (
enableClients: boolean = true
): SidebarNavSection[] => [
{
title: "sidebarHome",
href: "/",
icon: <Home className="h-4 w-4" />
}
];
export const orgNavItems: SidebarNavItem[] = [
{
title: "sidebarSites",
href: "/{orgId}/settings/sites",
icon: <Combine className="h-4 w-4" />
},
{
title: "sidebarResources",
href: "/{orgId}/settings/resources",
icon: <Waypoints className="h-4 w-4" />
},
{
title: "sidebarAccessControl",
href: "/{orgId}/settings/access",
icon: <Users className="h-4 w-4" />,
autoExpand: true,
children: [
heading: "General",
items: [
{
title: "sidebarUsers",
href: "/{orgId}/settings/access/users",
children: [
{
title: "sidebarInvitations",
href: "/{orgId}/settings/access/invitations"
}
]
title: "sidebarSites",
href: "/{orgId}/settings/sites",
icon: <Combine className="h-4 w-4" />
},
{
title: "sidebarRoles",
href: "/{orgId}/settings/access/roles"
title: "sidebarResources",
href: "/{orgId}/settings/resources",
icon: <Waypoints className="h-4 w-4" />
},
...(enableClients
? [
{
title: "sidebarClients",
href: "/{orgId}/settings/clients",
icon: <MonitorUp className="h-4 w-4" />
}
]
: []),
{
title: "sidebarDomains",
href: "/{orgId}/settings/domains",
icon: <Globe className="h-4 w-4" />
}
]
},
{
title: "sidebarShareableLinks",
href: "/{orgId}/settings/share-links",
icon: <LinkIcon className="h-4 w-4" />
heading: "Access Control",
items: [
{
title: "sidebarUsers",
href: "/{orgId}/settings/access/users",
icon: <User className="h-4 w-4" />
},
{
title: "sidebarRoles",
href: "/{orgId}/settings/access/roles",
icon: <Users className="h-4 w-4" />
},
{
title: "sidebarInvitations",
href: "/{orgId}/settings/access/invitations",
icon: <TicketCheck className="h-4 w-4" />
},
{
title: "sidebarShareableLinks",
href: "/{orgId}/settings/share-links",
icon: <LinkIcon className="h-4 w-4" />
}
]
},
{
title: "sidebarApiKeys",
href: "/{orgId}/settings/api-keys",
icon: <KeyRound className="h-4 w-4" />
},
{
title: "sidebarSettings",
href: "/{orgId}/settings/general",
icon: <Settings className="h-4 w-4" />
heading: "Organization",
items: [
{
title: "sidebarApiKeys",
href: "/{orgId}/settings/api-keys",
icon: <KeyRound className="h-4 w-4" />
},
{
title: "sidebarSettings",
href: "/{orgId}/settings/general",
icon: <Settings className="h-4 w-4" />
}
]
}
];
export const adminNavItems: SidebarNavItem[] = [
export const adminNavSections: SidebarNavSection[] = [
{
title: "sidebarAllUsers",
href: "/admin/users",
icon: <Users className="h-4 w-4" />
},
{
title: "sidebarApiKeys",
href: "/admin/api-keys",
icon: <KeyRound className="h-4 w-4" />
},
{
title: "sidebarIdentityProviders",
href: "/admin/idp",
icon: <Fingerprint className="h-4 w-4" />
},
{
title: "sidebarLicense",
href: "/admin/license",
icon: <TicketCheck className="h-4 w-4" />
heading: "Admin",
items: [
{
title: "sidebarAllUsers",
href: "/admin/users",
icon: <Users className="h-4 w-4" />
},
{
title: "sidebarApiKeys",
href: "/admin/api-keys",
icon: <KeyRound className="h-4 w-4" />
},
{
title: "sidebarIdentityProviders",
href: "/admin/idp",
icon: <Fingerprint className="h-4 w-4" />
},
...(build == "enterprise"
? [
{
title: "sidebarLicense",
href: "/admin/license",
icon: <TicketCheck className="h-4 w-4" />
}
]
: [])
]
}
];

View File

@@ -6,12 +6,13 @@ import { ListUserOrgsResponse } from "@server/routers/org";
import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
import { cache } from "react";
import OrganizationLanding from "./components/OrganizationLanding";
import OrganizationLanding from "@app/components/OrganizationLanding";
import { pullEnv } from "@app/lib/pullEnv";
import { cleanRedirect } from "@app/lib/cleanRedirect";
import { Layout } from "@app/components/Layout";
import { rootNavItems } from "./navigation";
import { InitialSetupCompleteResponse } from "@server/routers/auth";
import { cookies } from "next/headers";
import { build } from "@server/build";
export const dynamic = "force-dynamic";
@@ -72,9 +73,26 @@ export default async function Page(props: {
}
}
const allCookies = await cookies();
const lastOrgCookie = allCookies.get("pangolin-last-org")?.value;
const lastOrgExists = orgs.some((org) => org.orgId === lastOrgCookie);
if (lastOrgExists) {
redirect(`/${lastOrgCookie}`);
} else {
const ownedOrg = orgs.find((org) => org.isOwner);
if (ownedOrg) {
redirect(`/${ownedOrg.orgId}`);
} else {
if (!env.flags.disableUserCreateOrg || user.serverAdmin) {
redirect("/setup");
}
}
}
return (
<UserProvider user={user}>
<Layout orgs={orgs} navItems={rootNavItems} showBreadcrumbs={false}>
<Layout orgs={orgs} navItems={[]}>
<div className="w-full max-w-md mx-auto md:mt-32 mt-4">
<OrganizationLanding
disableCreateOrg={

View File

@@ -6,7 +6,6 @@ import UserProvider from "@app/providers/UserProvider";
import { Metadata } from "next";
import { redirect } from "next/navigation";
import { cache } from "react";
import { rootNavItems } from "../navigation";
import { ListUserOrgsResponse } from "@server/routers/org";
import { internal } from "@app/lib/api";
import { AxiosResponse } from "axios";
@@ -54,11 +53,7 @@ export default async function SetupLayout({
return (
<>
<UserProvider user={user}>
<Layout
navItems={rootNavItems}
showBreadcrumbs={false}
orgs={orgs}
>
<Layout navItems={[]} orgs={orgs}>
<div className="w-full max-w-2xl mx-auto md:mt-32 mt-4">
{children}
</div>

View File

@@ -2,8 +2,6 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import Link from "next/link";
import { toast } from "@app/hooks/useToast";
import { useCallback, useEffect, useState } from "react";
import {
@@ -13,8 +11,7 @@ import {
CardHeader,
CardTitle
} from "@app/components/ui/card";
import CopyTextBox from "@app/components/CopyTextBox";
import { formatAxiosError } from "@app/lib/api";;
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { Separator } from "@/components/ui/separator";
@@ -32,7 +29,6 @@ import {
FormMessage
} from "@app/components/ui/form";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import CreateSiteForm from "../[orgId]/settings/sites/CreateSiteForm";
import { useTranslations } from "next-intl";
type Step = "org" | "site" | "resources";
@@ -41,6 +37,7 @@ export default function StepperForm() {
const [currentStep, setCurrentStep] = useState<Step>("org");
const [orgIdTaken, setOrgIdTaken] = useState(false);
const t = useTranslations();
const { env } = useEnvContext();
const [loading, setLoading] = useState(false);
const [isChecked, setIsChecked] = useState(false);
@@ -48,36 +45,62 @@ export default function StepperForm() {
const [orgCreated, setOrgCreated] = useState(false);
const orgSchema = z.object({
orgName: z.string().min(1, { message: t('orgNameRequired') }),
orgId: z.string().min(1, { message: t('orgIdRequired') })
orgName: z.string().min(1, { message: t("orgNameRequired") }),
orgId: z.string().min(1, { message: t("orgIdRequired") }),
subnet: z.string().min(1, { message: t("subnetRequired") })
});
const orgForm = useForm<z.infer<typeof orgSchema>>({
resolver: zodResolver(orgSchema),
defaultValues: {
orgName: "",
orgId: ""
orgId: "",
subnet: ""
}
});
const api = createApiClient(useEnvContext());
const router = useRouter();
const checkOrgIdAvailability = useCallback(async (value: string) => {
if (loading || orgCreated) {
return;
}
// Fetch default subnet on component mount
useEffect(() => {
fetchDefaultSubnet();
}, []);
const fetchDefaultSubnet = async () => {
try {
const res = await api.get(`/org/checkId`, {
params: {
orgId: value
}
const res = await api.get(`/pick-org-defaults`);
if (res && res.data && res.data.data) {
orgForm.setValue("subnet", res.data.data.subnet);
}
} catch (e) {
console.error("Failed to fetch default subnet:", e);
toast({
title: "Error",
description: "Failed to fetch default subnet",
variant: "destructive"
});
setOrgIdTaken(res.status !== 404);
} catch (error) {
setOrgIdTaken(false);
}
}, [loading, orgCreated, api]);
};
const checkOrgIdAvailability = useCallback(
async (value: string) => {
if (loading || orgCreated) {
return;
}
try {
const res = await api.get(`/org/checkId`, {
params: {
orgId: value
}
});
setOrgIdTaken(res.status !== 404);
} catch (error) {
setOrgIdTaken(false);
}
},
[loading, orgCreated, api]
);
const debouncedCheckOrgIdAvailability = useCallback(
debounce(checkOrgIdAvailability, 300),
@@ -105,7 +128,8 @@ export default function StepperForm() {
try {
const res = await api.put(`/org`, {
orgId: values.orgId,
name: values.orgName
name: values.orgName,
subnet: values.subnet
});
if (res && res.status === 201) {
@@ -114,9 +138,7 @@ export default function StepperForm() {
}
} catch (e) {
console.error(e);
setError(
formatAxiosError(e, t('orgErrorCreate'))
);
setError(formatAxiosError(e, t("orgErrorCreate")));
}
setLoading(false);
@@ -126,10 +148,8 @@ export default function StepperForm() {
<>
<Card>
<CardHeader>
<CardTitle>{t('setupNewOrg')}</CardTitle>
<CardDescription>
{t('setupCreate')}
</CardDescription>
<CardTitle>{t("setupNewOrg")}</CardTitle>
<CardDescription>{t("setupCreate")}</CardDescription>
</CardHeader>
<CardContent>
<section className="space-y-6">
@@ -151,7 +171,7 @@ export default function StepperForm() {
: "text-muted-foreground"
}`}
>
{t('setupCreateOrg')}
{t("setupCreateOrg")}
</span>
</div>
<div className="flex flex-col items-center">
@@ -171,7 +191,7 @@ export default function StepperForm() {
: "text-muted-foreground"
}`}
>
{t('siteCreate')}
{t("siteCreate")}
</span>
</div>
<div className="flex flex-col items-center">
@@ -191,7 +211,7 @@ export default function StepperForm() {
: "text-muted-foreground"
}`}
>
{t('setupCreateResources')}
{t("setupCreateResources")}
</span>
</div>
</div>
@@ -210,7 +230,7 @@ export default function StepperForm() {
render={({ field }) => (
<FormItem>
<FormLabel>
{t('setupOrgName')}
{t("setupOrgName")}
</FormLabel>
<FormControl>
<Input
@@ -218,8 +238,15 @@ export default function StepperForm() {
{...field}
onChange={(e) => {
// Prevent "/" in orgName input
const sanitizedValue = e.target.value.replace(/\//g, "-");
const orgId = generateId(sanitizedValue);
const sanitizedValue =
e.target.value.replace(
/\//g,
"-"
);
const orgId =
generateId(
sanitizedValue
);
orgForm.setValue(
"orgId",
orgId
@@ -232,12 +259,15 @@ export default function StepperForm() {
orgId
);
}}
value={field.value.replace(/\//g, "-")}
value={field.value.replace(
/\//g,
"-"
)}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t('orgDisplayName')}
{t("orgDisplayName")}
</FormDescription>
</FormItem>
)}
@@ -248,7 +278,7 @@ export default function StepperForm() {
render={({ field }) => (
<FormItem>
<FormLabel>
{t('orgId')}
{t("orgId")}
</FormLabel>
<FormControl>
<Input
@@ -258,16 +288,44 @@ export default function StepperForm() {
</FormControl>
<FormMessage />
<FormDescription>
{t('setupIdentifierMessage')}
{t(
"setupIdentifierMessage"
)}
</FormDescription>
</FormItem>
)}
/>
{env.flags.enableClients && (
<FormField
control={orgForm.control}
name="subnet"
render={({ field }) => (
<FormItem>
<FormLabel>
Subnet
</FormLabel>
<FormControl>
<Input
type="text"
{...field}
/>
</FormControl>
<FormMessage />
<FormDescription>
Network subnet for this
organization. A default
value has been provided.
</FormDescription>
</FormItem>
)}
/>
)}
{orgIdTaken && !orgCreated ? (
<Alert variant="destructive">
<AlertDescription>
{t('setupErrorIdentifier')}
{t("setupErrorIdentifier")}
</AlertDescription>
</Alert>
) : null}
@@ -290,7 +348,7 @@ export default function StepperForm() {
orgIdTaken
}
>
{t('setupCreateOrg')}
{t("setupCreateOrg")}
</Button>
</div>
</form>

View File

@@ -1,3 +0,0 @@
"use client";
export function AuthFooter() {}

View File

@@ -0,0 +1,50 @@
"use client";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTheme } from "next-themes";
import Image from "next/image";
import { useEffect, useState } from "react";
type BrandingLogoProps = {
width: number;
height: number;
};
export default function BrandingLogo(props: BrandingLogoProps) {
const { env } = useEnvContext();
const { theme } = useTheme();
const [path, setPath] = useState<string>(""); // Default logo path
useEffect(() => {
function getPath() {
let lightOrDark = theme;
if (theme === "system" || !theme) {
lightOrDark = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light";
}
if (lightOrDark === "light") {
return "/logo/word_mark_black.png";
}
return "/logo/word_mark_white.png";
}
const path = getPath();
setPath(path);
}, [theme, env]);
return (
path && (
<Image
src={path}
alt="Logo"
width={props.width}
height={props.height}
/>
)
);
}

View File

@@ -1,42 +0,0 @@
"use client";
import { usePathname } from "next/navigation";
import Link from "next/link";
import { ChevronRight } from "lucide-react";
import { cn } from "@app/lib/cn";
interface BreadcrumbItem {
label: string;
href: string;
}
export function Breadcrumbs() {
const pathname = usePathname();
const segments = pathname.split("/").filter(Boolean);
const breadcrumbs: BreadcrumbItem[] = segments.map((segment, index) => {
const href = `/${segments.slice(0, index + 1).join("/")}`;
let label = decodeURIComponent(segment);
return { label, href };
});
return (
<nav className="flex items-center space-x-1 text-muted-foreground">
{breadcrumbs.map((crumb, index) => (
<div key={crumb.href} className="flex items-center flex-nowrap">
{index !== 0 && <ChevronRight className="h-4 w-4 flex-shrink-0" />}
<Link
href={crumb.href}
className={cn(
"ml-1 hover:text-foreground whitespace-nowrap",
index === breadcrumbs.length - 1 &&
"text-foreground font-medium"
)}
>
{crumb.label}
</Link>
</div>
))}
</nav>
);
}

View File

@@ -72,7 +72,7 @@ export default function InviteUserForm({
const formSchema = z.object({
string: z.string().refine((val) => val === string, {
message: t('inviteErrorInvalidConfirmation')
message: t("inviteErrorInvalidConfirmation")
})
});
@@ -108,7 +108,9 @@ export default function InviteUserForm({
<CredenzaTitle>{title}</CredenzaTitle>
</CredenzaHeader>
<CredenzaBody>
<div className="mb-4 break-all overflow-hidden">{dialog}</div>
<div className="mb-4 break-all overflow-hidden">
{dialog}
</div>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
@@ -132,9 +134,10 @@ export default function InviteUserForm({
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t('close')}</Button>
<Button variant="outline">{t("close")}</Button>
</CredenzaClose>
<Button
variant={"destructive"}
type="submit"
form="confirm-delete-form"
loading={loading}

View File

@@ -40,7 +40,7 @@ export default function CopyTextBox({
>
<pre
ref={textRef}
className={`p-2 pr-16 text-sm w-full ${
className={`p-4 pr-16 text-sm w-full ${
wrapText
? "whitespace-pre-wrap break-words"
: "overflow-x-auto"

View File

@@ -32,7 +32,7 @@ const CopyToClipboard = ({ text, displayText, isLink }: CopyToClipboardProps) =>
href={text}
target="_blank"
rel="noopener noreferrer"
className="truncate hover:underline"
className="truncate hover:underline text-sm"
style={{ maxWidth: "100%" }} // Ensures truncation works within parent
title={text} // Shows full text on hover
>
@@ -40,7 +40,7 @@ const CopyToClipboard = ({ text, displayText, isLink }: CopyToClipboardProps) =>
</Link>
) : (
<span
className="truncate"
className="truncate text-sm"
style={{
maxWidth: "100%",
display: "block",

View File

@@ -0,0 +1,552 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
AlertCircle,
CheckCircle2,
Building2,
Zap,
ArrowUpDown
} from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { createApiClient, formatAxiosError } from "@/lib/api";
import { useEnvContext } from "@/hooks/useEnvContext";
import { toast } from "@/hooks/useToast";
import { ListDomainsResponse } from "@server/routers/domain/listDomains";
import { AxiosResponse } from "axios";
import { cn } from "@/lib/cn";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
type OrganizationDomain = {
domainId: string;
baseDomain: string;
verified: boolean;
type: "ns" | "cname" | "wildcard";
};
type AvailableOption = {
domainNamespaceId: string;
fullDomain: string;
domainId: string;
};
type DomainOption = {
id: string;
domain: string;
type: "organization" | "provided";
verified?: boolean;
domainType?: "ns" | "cname" | "wildcard";
domainId?: string;
domainNamespaceId?: string;
subdomain?: string;
};
interface DomainPickerProps {
orgId: string;
cols?: number;
onDomainChange?: (domainInfo: {
domainId: string;
domainNamespaceId?: string;
type: "organization" | "provided";
subdomain?: string;
fullDomain: string;
baseDomain: string;
}) => void;
}
export default function DomainPicker({
orgId,
cols,
onDomainChange
}: DomainPickerProps) {
const { env } = useEnvContext();
const api = createApiClient({ env });
const t = useTranslations();
const [userInput, setUserInput] = useState<string>("");
const [selectedOption, setSelectedOption] = useState<DomainOption | null>(
null
);
const [availableOptions, setAvailableOptions] = useState<AvailableOption[]>(
[]
);
const [isChecking, setIsChecking] = useState(false);
const [organizationDomains, setOrganizationDomains] = useState<
OrganizationDomain[]
>([]);
const [loadingDomains, setLoadingDomains] = useState(false);
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [activeTab, setActiveTab] = useState<
"all" | "organization" | "provided"
>("all");
const [providedDomainsShown, setProvidedDomainsShown] = useState(3);
useEffect(() => {
const loadOrganizationDomains = async () => {
setLoadingDomains(true);
try {
const response = await api.get<
AxiosResponse<ListDomainsResponse>
>(`/org/${orgId}/domains`);
if (response.status === 200) {
const domains = response.data.data.domains
.filter(
(domain) =>
domain.type === "ns" ||
domain.type === "cname" ||
domain.type === "wildcard"
)
.map((domain) => ({
...domain,
type: domain.type as "ns" | "cname" | "wildcard"
}));
setOrganizationDomains(domains);
}
} catch (error) {
console.error("Failed to load organization domains:", error);
toast({
variant: "destructive",
title: "Error",
description: "Failed to load organization domains"
});
} finally {
setLoadingDomains(false);
}
};
loadOrganizationDomains();
}, [orgId, api]);
// Generate domain options based on user input
const generateDomainOptions = (): DomainOption[] => {
const options: DomainOption[] = [];
if (!userInput.trim()) return options;
// Add organization domain options
organizationDomains.forEach((orgDomain) => {
if (orgDomain.type === "cname") {
// For CNAME domains, check if the user input matches exactly
if (
orgDomain.baseDomain.toLowerCase() ===
userInput.toLowerCase()
) {
options.push({
id: `org-${orgDomain.domainId}`,
domain: orgDomain.baseDomain,
type: "organization",
verified: orgDomain.verified,
domainType: "cname",
domainId: orgDomain.domainId
});
}
} else if (orgDomain.type === "ns") {
// For NS domains, check if the user input could be a subdomain
const userInputLower = userInput.toLowerCase();
const baseDomainLower = orgDomain.baseDomain.toLowerCase();
// Check if user input ends with the base domain
if (userInputLower.endsWith(`.${baseDomainLower}`)) {
const subdomain = userInputLower.slice(
0,
-(baseDomainLower.length + 1)
);
options.push({
id: `org-${orgDomain.domainId}`,
domain: userInput,
type: "organization",
verified: orgDomain.verified,
domainType: "ns",
domainId: orgDomain.domainId,
subdomain: subdomain
});
} else if (userInputLower === baseDomainLower) {
// Exact match for base domain
options.push({
id: `org-${orgDomain.domainId}`,
domain: orgDomain.baseDomain,
type: "organization",
verified: orgDomain.verified,
domainType: "ns",
domainId: orgDomain.domainId
});
}
} else if (orgDomain.type === "wildcard") {
// For wildcard domains, allow the base domain or multiple levels up
const userInputLower = userInput.toLowerCase();
const baseDomainLower = orgDomain.baseDomain.toLowerCase();
// Check if user input is exactly the base domain
if (userInputLower === baseDomainLower) {
options.push({
id: `org-${orgDomain.domainId}`,
domain: orgDomain.baseDomain,
type: "organization",
verified: orgDomain.verified,
domainType: "wildcard",
domainId: orgDomain.domainId
});
}
// Check if user input ends with the base domain (allows multiple level subdomains)
else if (userInputLower.endsWith(`.${baseDomainLower}`)) {
const subdomain = userInputLower.slice(
0,
-(baseDomainLower.length + 1)
);
// Allow multiple levels (subdomain can contain dots)
options.push({
id: `org-${orgDomain.domainId}`,
domain: userInput,
type: "organization",
verified: orgDomain.verified,
domainType: "wildcard",
domainId: orgDomain.domainId,
subdomain: subdomain
});
}
}
});
// Add provided domain options (always try to match provided domains)
availableOptions.forEach((option) => {
options.push({
id: `provided-${option.domainNamespaceId}`,
domain: option.fullDomain,
type: "provided",
domainNamespaceId: option.domainNamespaceId,
domainId: option.domainId
});
});
// Sort options
return options.sort((a, b) => {
const comparison = a.domain.localeCompare(b.domain);
return sortOrder === "asc" ? comparison : -comparison;
});
};
const domainOptions = generateDomainOptions();
// Filter options based on active tab
const filteredOptions = domainOptions.filter((option) => {
if (activeTab === "all") return true;
return option.type === activeTab;
});
// Separate organization and provided options for pagination
const organizationOptions = filteredOptions.filter(
(opt) => opt.type === "organization"
);
const allProvidedOptions = filteredOptions.filter(
(opt) => opt.type === "provided"
);
const providedOptions = allProvidedOptions.slice(0, providedDomainsShown);
const hasMoreProvided = allProvidedOptions.length > providedDomainsShown;
// Handle option selection
const handleOptionSelect = (option: DomainOption) => {
setSelectedOption(option);
if (option.type === "organization") {
if (option.domainType === "cname") {
onDomainChange?.({
domainId: option.domainId!,
type: "organization",
subdomain: undefined,
fullDomain: option.domain,
baseDomain: option.domain
});
} else if (option.domainType === "ns") {
const subdomain = option.subdomain || "";
onDomainChange?.({
domainId: option.domainId!,
type: "organization",
subdomain: subdomain || undefined,
fullDomain: option.domain,
baseDomain: option.domain
});
} else if (option.domainType === "wildcard") {
onDomainChange?.({
domainId: option.domainId!,
type: "organization",
subdomain: option.subdomain || undefined,
fullDomain: option.domain,
baseDomain: option.subdomain
? option.domain.split(".").slice(1).join(".")
: option.domain
});
}
} else if (option.type === "provided") {
// Extract subdomain from full domain
const parts = option.domain.split(".");
const subdomain = parts[0];
const baseDomain = parts.slice(1).join(".");
onDomainChange?.({
domainId: option.domainId!,
domainNamespaceId: option.domainNamespaceId,
type: "provided",
subdomain: subdomain,
fullDomain: option.domain,
baseDomain: baseDomain
});
}
};
return (
<div className="space-y-6">
{/* Domain Input */}
<div className="space-y-2">
<Label htmlFor="domain-input">
{t("domainPickerEnterDomain")}
</Label>
<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(
/[^a-zA-Z0-9.-]/g,
""
);
setUserInput(validInput);
// Clear selection when input changes
setSelectedOption(null);
}}
/>
<p className="text-sm text-muted-foreground">
{build === "saas"
? t("domainPickerDescriptionSaas")
: t("domainPickerDescription")}
</p>
</div>
{/* Tabs and Sort Toggle */}
{build === "saas" && (
<div className="flex justify-between items-center">
<Tabs
value={activeTab}
onValueChange={(value) =>
setActiveTab(
value as "all" | "organization" | "provided"
)
}
>
<TabsList>
<TabsTrigger value="all">
{t("domainPickerTabAll")}
</TabsTrigger>
<TabsTrigger value="organization">
{t("domainPickerTabOrganization")}
</TabsTrigger>
{build == "saas" && (
<TabsTrigger value="provided">
{t("domainPickerTabProvided")}
</TabsTrigger>
)}
</TabsList>
</Tabs>
<Button
variant="outline"
size="sm"
onClick={() =>
setSortOrder(sortOrder === "asc" ? "desc" : "asc")
}
>
<ArrowUpDown className="h-4 w-4 mr-2" />
{sortOrder === "asc"
? t("domainPickerSortAsc")
: t("domainPickerSortDesc")}
</Button>
</div>
)}
{/* Loading State */}
{isChecking && (
<div className="flex items-center justify-center p-8">
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
<span>{t("domainPickerCheckingAvailability")}</span>
</div>
</div>
)}
{/* No Options */}
{!isChecking &&
filteredOptions.length === 0 &&
userInput.trim() && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{t("domainPickerNoMatchingDomains")}
</AlertDescription>
</Alert>
)}
{/* Domain Options */}
{!isChecking && filteredOptions.length > 0 && (
<div className="space-y-4">
{/* Organization Domains */}
{organizationOptions.length > 0 && (
<div className="space-y-3">
{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/10"
: "border-input hover:bg-accent",
option.verified
? "cursor-pointer"
: "cursor-not-allowed opacity-60"
)}
onClick={() =>
option.verified &&
handleOptionSelect(option)
}
>
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center space-x-2">
<p className="font-mono text-sm">
{option.domain}
</p>
{/* <Badge */}
{/* variant={ */}
{/* option.domainType === */}
{/* "ns" */}
{/* ? "default" */}
{/* : "secondary" */}
{/* } */}
{/* > */}
{/* {option.domainType} */}
{/* </Badge> */}
{option.verified ? (
<CheckCircle2 className="h-3 w-3 text-green-500" />
) : (
<AlertCircle className="h-3 w-3 text-yellow-500" />
)}
</div>
{option.subdomain && (
<p className="text-xs text-muted-foreground mt-1">
{t(
"domainPickerSubdomain",
{
subdomain:
option.subdomain
}
)}
</p>
)}
{!option.verified && (
<p className="text-xs text-yellow-600 mt-1">
Domain is unverified
</p>
)}
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Provided Domains */}
{providedOptions.length > 0 && (
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Zap className="h-4 w-4" />
<div className="text-sm font-medium">
{t("domainPickerProvidedDomains")}
</div>
</div>
<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/10"
: "border-input",
"cursor-pointer hover:bg-accent"
)}
onClick={() =>
handleOptionSelect(option)
}
>
<div className="flex items-center justify-between">
<div>
<p className="font-mono text-sm">
{option.domain}
</p>
<p className="text-xs text-muted-foreground">
{t(
"domainPickerNamespace",
{
namespace:
option.domainNamespaceId as string
}
)}
</p>
</div>
{selectedOption?.id ===
option.id && (
<CheckCircle2 className="h-4 w-4 text-primary" />
)}
</div>
</div>
))}
</div>
{hasMoreProvided && (
<Button
variant="outline"
size="sm"
onClick={() =>
setProvidedDomainsShown(
(prev) => prev + 3
)
}
className="w-full"
>
{t("domainPickerShowMore")}
</Button>
)}
</div>
)}
</div>
)}
</div>
);
}
function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null;
return (...args: Parameters<T>) => {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => {
func(...args);
}, wait);
};
}

View File

@@ -0,0 +1,89 @@
"use client";
import { useState, useRef } from "react";
import { Button } from "@/components/ui/button";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import TwoFactorSetupForm from "./TwoFactorSetupForm";
import { useTranslations } from "next-intl";
import { useUserContext } from "@app/hooks/useUserContext";
type Enable2FaDialogProps = {
open: boolean;
setOpen: (val: boolean) => void;
};
export default function Enable2FaDialog({ open, setOpen }: Enable2FaDialogProps) {
const t = useTranslations();
const [currentStep, setCurrentStep] = useState(1);
const [loading, setLoading] = useState(false);
const formRef = useRef<{ handleSubmit: () => void }>(null);
const { user, updateUser } = useUserContext();
function reset() {
setCurrentStep(1);
setLoading(false);
}
const handleSubmit = () => {
if (formRef.current) {
formRef.current.handleSubmit();
}
};
return (
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
reset();
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
{t('otpSetup')}
</CredenzaTitle>
<CredenzaDescription>
{t('otpSetupDescription')}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<TwoFactorSetupForm
ref={formRef}
isDialog={true}
submitButtonText={t('submit')}
cancelButtonText="Close"
showCancelButton={false}
onComplete={() => {setOpen(false); updateUser({ twoFactorEnabled: true });}}
onStepChange={setCurrentStep}
onLoadingChange={setLoading}
/>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
{(currentStep === 1 || currentStep === 2) && (
<Button
type="button"
loading={loading}
disabled={loading}
onClick={handleSubmit}
>
{t('submit')}
</Button>
)}
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}

View File

@@ -1,46 +1,7 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AlertCircle, CheckCircle2 } from "lucide-react";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { AxiosResponse } from "axios";
import {
RequestTotpSecretBody,
RequestTotpSecretResponse,
VerifyTotpBody,
VerifyTotpResponse
} from "@server/routers/auth";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import CopyTextBox from "@app/components/CopyTextBox";
import { QRCodeCanvas, QRCodeSVG } from "qrcode.react";
import { useUserContext } from "@app/hooks/useUserContext";
import { useTranslations } from "next-intl";
import Enable2FaDialog from "./Enable2FaDialog";
type Enable2FaProps = {
open: boolean;
@@ -48,261 +9,5 @@ type Enable2FaProps = {
};
export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
const [step, setStep] = useState(1);
const [secretKey, setSecretKey] = useState("");
const [secretUri, setSecretUri] = useState("");
const [verificationCode, setVerificationCode] = useState("");
const [error, setError] = useState("");
const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(false);
const [backupCodes, setBackupCodes] = useState<string[]>([]);
const { user, updateUser } = useUserContext();
const api = createApiClient(useEnvContext());
const t = useTranslations();
const enableSchema = z.object({
password: z.string().min(1, { message: t('passwordRequired') })
});
const confirmSchema = z.object({
code: z.string().length(6, { message: t('pincodeInvalid') })
});
const enableForm = useForm<z.infer<typeof enableSchema>>({
resolver: zodResolver(enableSchema),
defaultValues: {
password: ""
}
});
const confirmForm = useForm<z.infer<typeof confirmSchema>>({
resolver: zodResolver(confirmSchema),
defaultValues: {
code: ""
}
});
const request2fa = async (values: z.infer<typeof enableSchema>) => {
setLoading(true);
const res = await api
.post<AxiosResponse<RequestTotpSecretResponse>>(
`/auth/2fa/request`,
{
password: values.password
} as RequestTotpSecretBody
)
.catch((e) => {
toast({
title: t('otpErrorEnable'),
description: formatAxiosError(
e,
t('otpErrorEnableDescription')
),
variant: "destructive"
});
});
if (res && res.data.data.secret) {
setSecretKey(res.data.data.secret);
setSecretUri(res.data.data.uri);
setStep(2);
}
setLoading(false);
};
const confirm2fa = async (values: z.infer<typeof confirmSchema>) => {
setLoading(true);
const res = await api
.post<AxiosResponse<VerifyTotpResponse>>(`/auth/2fa/enable`, {
code: values.code
} as VerifyTotpBody)
.catch((e) => {
toast({
title: t('otpErrorEnable'),
description: formatAxiosError(
e,
t('otpErrorEnableDescription')
),
variant: "destructive"
});
});
if (res && res.data.data.valid) {
setBackupCodes(res.data.data.backupCodes || []);
updateUser({ twoFactorEnabled: true });
setStep(3);
}
setLoading(false);
};
const handleVerify = () => {
if (verificationCode.length !== 6) {
setError(t('otpSetupCheckCode'));
return;
}
if (verificationCode === "123456") {
setSuccess(true);
setStep(3);
} else {
setError(t('otpSetupCheckCodeRetry'));
}
};
function reset() {
setLoading(false);
setStep(1);
setSecretKey("");
setSecretUri("");
setVerificationCode("");
setError("");
setSuccess(false);
setBackupCodes([]);
enableForm.reset();
confirmForm.reset();
}
return (
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
reset();
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
{t('otpSetup')}
</CredenzaTitle>
<CredenzaDescription>
{t('otpSetupDescription')}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
{step === 1 && (
<Form {...enableForm}>
<form
onSubmit={enableForm.handleSubmit(request2fa)}
className="space-y-4"
id="form"
>
<div className="space-y-4">
<FormField
control={enableForm.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>{t('password')}</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</form>
</Form>
)}
{step === 2 && (
<div className="space-y-4">
<p>
{t('otpSetupScanQr')}
</p>
<div className="h-[250px] mx-auto flex items-center justify-center">
<QRCodeCanvas value={secretUri} size={200} />
</div>
<div className="max-w-md mx-auto">
<CopyTextBox
text={secretUri}
wrapText={false}
/>
</div>
<Form {...confirmForm}>
<form
onSubmit={confirmForm.handleSubmit(
confirm2fa
)}
className="space-y-4"
id="form"
>
<div className="space-y-4">
<FormField
control={confirmForm.control}
name="code"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('otpSetupSecretCode')}
</FormLabel>
<FormControl>
<Input
type="code"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</form>
</Form>
</div>
)}
{step === 3 && (
<div className="space-y-4 text-center">
<CheckCircle2
className="mx-auto text-green-500"
size={48}
/>
<p className="font-semibold text-lg">
{t('otpSetupSuccess')}
</p>
<p>
{t('otpSetupSuccessStoreBackupCodes')}
</p>
<div className="max-w-md mx-auto">
<CopyTextBox text={backupCodes.join("\n")} />
</div>
</div>
)}
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
{(step === 1 || step === 2) && (
<Button
type="button"
loading={loading}
disabled={loading}
onClick={() => {
if (step === 1) {
enableForm.handleSubmit(request2fa)();
} else {
confirmForm.handleSubmit(confirm2fa)();
}
}}
>
{t('submit')}
</Button>
)}
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
return <Enable2FaDialog open={open} setOpen={setOpen} />;
}

View File

@@ -38,6 +38,7 @@ export function HorizontalTabs({
.replace("{resourceId}", params.resourceId as string)
.replace("{niceId}", params.niceId as string)
.replace("{userId}", params.userId as string)
.replace("{clientId}", params.clientId as string)
.replace("{apiKeyId}", params.apiKeyId as string);
}

View File

@@ -1,276 +1,82 @@
"use client";
import React, { useEffect, useState } from "react";
import { SidebarNav } from "@app/components/SidebarNav";
import { OrgSelector } from "@app/components/OrgSelector";
import React from "react";
import { cn } from "@app/lib/cn";
import { ListUserOrgsResponse } from "@server/routers/org";
import SupporterStatus from "@app/components/SupporterStatus";
import { Button } from "@app/components/ui/button";
import { ExternalLink, Menu, X, Server } from "lucide-react";
import Image from "next/image";
import ProfileIcon from "@app/components/ProfileIcon";
import {
Sheet,
SheetContent,
SheetTrigger,
SheetTitle,
SheetDescription
} from "@app/components/ui/sheet";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { Breadcrumbs } from "@app/components/Breadcrumbs";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useUserContext } from "@app/hooks/useUserContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useTheme } from "next-themes";
import { useTranslations } from "next-intl";
import type { SidebarNavSection } from "@app/app/navigation";
import { LayoutSidebar } from "@app/components/LayoutSidebar";
import { LayoutHeader } from "@app/components/LayoutHeader";
import { LayoutMobileMenu } from "@app/components/LayoutMobileMenu";
import { cookies } from "next/headers";
interface LayoutProps {
children: React.ReactNode;
orgId?: string;
orgs?: ListUserOrgsResponse["orgs"];
navItems?: Array<{
title: string;
href: string;
icon?: React.ReactNode;
children?: Array<{
title: string;
href: string;
icon?: React.ReactNode;
}>;
}>;
navItems?: SidebarNavSection[];
showSidebar?: boolean;
showBreadcrumbs?: boolean;
showHeader?: boolean;
showTopBar?: boolean;
defaultSidebarCollapsed?: boolean;
}
export function Layout({
export async function Layout({
children,
orgId,
orgs,
navItems = [],
showSidebar = true,
showBreadcrumbs = true,
showHeader = true,
showTopBar = true
showTopBar = true,
defaultSidebarCollapsed = false
}: LayoutProps) {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const { env } = useEnvContext();
const pathname = usePathname();
const isAdminPage = pathname?.startsWith("/admin");
const { user } = useUserContext();
const { isUnlocked } = useLicenseStatusContext();
const allCookies = await cookies();
const sidebarStateCookie = allCookies.get("pangolin-sidebar-state")?.value;
const { theme } = useTheme();
const [path, setPath] = useState<string>(""); // Default logo path
useEffect(() => {
function getPath() {
let lightOrDark = theme;
if (theme === "system" || !theme) {
lightOrDark = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light";
}
if (lightOrDark === "light") {
// return "/logo/word_mark_black.png";
return "/logo/pangolin_orange.svg";
}
// return "/logo/word_mark_white.png";
return "/logo/pangolin_orange.svg";
}
setPath(getPath());
}, [theme, env]);
const t = useTranslations();
const initialSidebarCollapsed =
sidebarStateCookie === "collapsed" ||
(sidebarStateCookie !== "expanded" && defaultSidebarCollapsed);
return (
<div className="flex flex-col h-screen overflow-hidden">
{/* Full width header */}
{showHeader && (
<div className="border-b shrink-0 bg-card">
<div className="h-16 flex items-center px-4">
<div className="flex items-center gap-4">
{showSidebar && (
<div className="md:hidden">
<Sheet
open={isMobileMenuOpen}
onOpenChange={setIsMobileMenuOpen}
>
<SheetTrigger asChild>
<Button variant="ghost" size="icon">
<Menu className="h-6 w-6" />
</Button>
</SheetTrigger>
<SheetContent
side="left"
className="w-64 p-0 flex flex-col h-full"
>
<SheetTitle className="sr-only">
{t('navbar')}
</SheetTitle>
<SheetDescription className="sr-only">
{t('navbarDescription')}
</SheetDescription>
<div className="flex-1 overflow-y-auto">
<div className="p-4">
<SidebarNav
items={navItems}
onItemClick={() =>
setIsMobileMenuOpen(
false
)
}
/>
</div>
{!isAdminPage &&
user.serverAdmin && (
<div className="p-4 border-t">
<Link
href="/admin"
className="flex items-center gap-3 text-muted-foreground hover:text-foreground transition-colors px-3 py-2 rounded-md w-full"
onClick={() =>
setIsMobileMenuOpen(
false
)
}
>
<Server className="h-4 w-4" />
{t('serverAdmin')}
</Link>
</div>
)}
</div>
<div className="p-4 space-y-4 border-t shrink-0">
<SupporterStatus />
<OrgSelector
orgId={orgId}
orgs={orgs}
/>
{env?.app?.version && (
<div className="text-xs text-muted-foreground text-center">
v{env.app.version}
</div>
)}
</div>
</SheetContent>
</Sheet>
</div>
)}
<Link
href="/"
className="flex items-center hidden md:block"
>
{path && (
<Image
src={path}
alt="Pangolin Logo"
width={35}
height={35}
priority={true}
quality={25}
/>
)}
</Link>
{showBreadcrumbs && (
<div className="hidden md:block overflow-x-auto scrollbar-hide">
<Breadcrumbs />
</div>
)}
</div>
{showTopBar && (
<div className="ml-auto flex items-center justify-end md:justify-between">
<div className="hidden md:flex items-center space-x-3 mr-6">
<Link
href="https://docs.fossorial.io"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors"
>
{t('navbarDocsLink')}
</Link>
</div>
<div>
<ProfileIcon />
</div>
</div>
)}
</div>
{showBreadcrumbs && (
<div className="md:hidden px-4 pb-2 overflow-x-auto scrollbar-hide">
<Breadcrumbs />
</div>
)}
</div>
<div className="flex h-screen overflow-hidden">
{/* Desktop Sidebar */}
{showSidebar && (
<LayoutSidebar
orgId={orgId}
orgs={orgs}
navItems={navItems}
defaultSidebarCollapsed={initialSidebarCollapsed}
/>
)}
<div className="flex flex-1 overflow-hidden">
{/* Desktop Sidebar */}
{showSidebar && (
<div className="hidden md:flex w-64 border-r bg-card flex-col h-full shrink-0">
<div className="flex-1 overflow-y-auto">
<div className="p-4">
<SidebarNav items={navItems} />
</div>
{!isAdminPage && user.serverAdmin && (
<div className="p-4 border-t">
<Link
href="/admin"
className="flex items-center gap-3 text-muted-foreground hover:text-foreground transition-colors px-3 py-2 rounded-md w-full"
>
<Server className="h-4 w-4" />
{t('serverAdmin')}
</Link>
</div>
)}
</div>
<div className="p-4 space-y-4 border-t shrink-0">
<SupporterStatus />
<OrgSelector orgId={orgId} orgs={orgs} />
<div className="space-y-2">
<div className="text-xs text-muted-foreground text-center">
<Link
href="https://github.com/fosrl/pangolin"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1"
>
{!isUnlocked()
? t('communityEdition')
: t('commercialEdition')}
<ExternalLink size={12} />
</Link>
</div>
{env?.app?.version && (
<div className="text-xs text-muted-foreground text-center">
v{env.app.version}
</div>
)}
</div>
</div>
</div>
{/* Main content area */}
<div
className={cn(
"flex-1 flex flex-col h-full min-w-0 relative",
!showSidebar && "w-full"
)}
>
{/* Mobile header */}
{showHeader && (
<LayoutMobileMenu
orgId={orgId}
orgs={orgs}
navItems={navItems}
showSidebar={showSidebar}
showTopBar={showTopBar}
/>
)}
{/* Desktop header */}
{showHeader && <LayoutHeader showTopBar={showTopBar} />}
{/* Main content */}
<div
className={cn(
"flex-1 flex flex-col h-full min-w-0",
!showSidebar && "w-full"
)}
>
<main className="flex-1 overflow-y-auto p-3 md:p-6 w-full">
<div className="container mx-auto max-w-12xl mb-12">
{children}
</div>
</main>
</div>
<main className="flex-1 overflow-y-auto p-3 md:p-6 w-full">
<div className={cn(
"container mx-auto max-w-12xl mb-12",
showHeader && "md:pt-20" // Add top padding only on desktop to account for fixed header
)}>
{children}
</div>
</main>
</div>
</div>
);

View File

@@ -0,0 +1,72 @@
"use client";
import React, { useEffect, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import ProfileIcon from "@app/components/ProfileIcon";
import ThemeSwitcher from "@app/components/ThemeSwitcher";
import { useTheme } from "next-themes";
interface LayoutHeaderProps {
showTopBar: boolean;
}
export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
const { theme } = useTheme();
const [path, setPath] = useState<string>("");
useEffect(() => {
function getPath() {
let lightOrDark = theme;
if (theme === "system" || !theme) {
lightOrDark = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light";
}
if (lightOrDark === "light") {
return "/logo/word_mark_black.png";
}
return "/logo/word_mark_white.png";
}
setPath(getPath());
}, [theme]);
return (
<div className="absolute top-0 left-0 right-0 z-50 hidden md:block">
<div className="absolute inset-0 bg-background/86 backdrop-blur-sm" />
<div className="relative z-10 px-6 py-2">
<div className="container mx-auto max-w-12xl">
<div className="h-16 flex items-center justify-between">
<div className="flex items-center gap-2">
<Link href="/" className="flex items-center">
{path && (
<Image
src={path}
alt="Pangolin"
width={98}
height={32}
className="h-8 w-auto"
/>
)}
</Link>
</div>
{showTopBar && (
<div className="flex items-center space-x-2">
<ThemeSwitcher />
<ProfileIcon />
</div>
)}
</div>
</div>
</div>
</div>
);
}
export default LayoutHeader;

View File

@@ -0,0 +1,142 @@
"use client";
import React, { useState } from "react";
import { SidebarNav } from "@app/components/SidebarNav";
import { OrgSelector } from "@app/components/OrgSelector";
import { cn } from "@app/lib/cn";
import { ListUserOrgsResponse } from "@server/routers/org";
import SupporterStatus from "@app/components/SupporterStatus";
import { Button } from "@app/components/ui/button";
import { Menu, Server } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useUserContext } from "@app/hooks/useUserContext";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import ProfileIcon from "@app/components/ProfileIcon";
import ThemeSwitcher from "@app/components/ThemeSwitcher";
import type { SidebarNavSection } from "@app/app/navigation";
import {
Sheet,
SheetContent,
SheetTrigger,
SheetTitle,
SheetDescription
} from "@app/components/ui/sheet";
import { Abel } from "next/font/google";
interface LayoutMobileMenuProps {
orgId?: string;
orgs?: ListUserOrgsResponse["orgs"];
navItems: SidebarNavSection[];
showSidebar: boolean;
showTopBar: boolean;
}
export function LayoutMobileMenu({
orgId,
orgs,
navItems,
showSidebar,
showTopBar
}: LayoutMobileMenuProps) {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const pathname = usePathname();
const isAdminPage = pathname?.startsWith("/admin");
const { user } = useUserContext();
const { env } = useEnvContext();
const t = useTranslations();
return (
<div className="shrink-0 md:hidden">
<div className="h-16 flex items-center px-4">
<div className="flex items-center gap-4">
{showSidebar && (
<div>
<Sheet
open={isMobileMenuOpen}
onOpenChange={setIsMobileMenuOpen}
>
<SheetTrigger asChild>
<Button variant="ghost" size="icon">
<Menu className="h-6 w-6" />
</Button>
</SheetTrigger>
<SheetContent
side="left"
className="w-64 p-0 flex flex-col h-full"
>
<SheetTitle className="sr-only">
{t("navbar")}
</SheetTitle>
<SheetDescription className="sr-only">
{t("navbarDescription")}
</SheetDescription>
<div className="flex-1 overflow-y-auto">
<div className="p-4">
<OrgSelector
orgId={orgId}
orgs={orgs}
/>
</div>
<div className="px-4">
{!isAdminPage &&
user.serverAdmin && (
<div className="pb-3">
<Link
href="/admin"
className={cn(
"flex items-center rounded transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-secondary/50 dark:hover:bg-secondary/20 rounded-md px-3 py-1.5"
)}
onClick={() =>
setIsMobileMenuOpen(
false
)
}
>
<span className="flex-shrink-0 mr-2">
<Server className="h-4 w-4" />
</span>
<span>
{t(
"serverAdmin"
)}
</span>
</Link>
</div>
)}
<SidebarNav
sections={navItems}
onItemClick={() =>
setIsMobileMenuOpen(false)
}
/>
</div>
</div>
<div className="p-4 space-y-4 border-t shrink-0">
<SupporterStatus />
{env?.app?.version && (
<div className="text-xs text-muted-foreground text-center">
v{env.app.version}
</div>
)}
</div>
</SheetContent>
</Sheet>
</div>
)}
</div>
{showTopBar && (
<div className="ml-auto flex items-center justify-end">
<div className="flex items-center space-x-2">
<ThemeSwitcher />
<ProfileIcon />
</div>
</div>
)}
</div>
</div>
);
}
export default LayoutMobileMenu;

View File

@@ -0,0 +1,178 @@
"use client";
import React, { useEffect, useState } from "react";
import { SidebarNav } from "@app/components/SidebarNav";
import { OrgSelector } from "@app/components/OrgSelector";
import { cn } from "@app/lib/cn";
import { ListUserOrgsResponse } from "@server/routers/org";
import SupporterStatus from "@app/components/SupporterStatus";
import { ExternalLink, Server, BookOpenText } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useUserContext } from "@app/hooks/useUserContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import type { SidebarNavSection } from "@app/app/navigation";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@app/components/ui/tooltip";
interface LayoutSidebarProps {
orgId?: string;
orgs?: ListUserOrgsResponse["orgs"];
navItems: SidebarNavSection[];
defaultSidebarCollapsed: boolean;
}
export function LayoutSidebar({
orgId,
orgs,
navItems,
defaultSidebarCollapsed
}: LayoutSidebarProps) {
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(
defaultSidebarCollapsed
);
const pathname = usePathname();
const isAdminPage = pathname?.startsWith("/admin");
const { user } = useUserContext();
const { isUnlocked } = useLicenseStatusContext();
const { env } = useEnvContext();
const t = useTranslations();
const setSidebarStateCookie = (collapsed: boolean) => {
if (typeof window !== "undefined") {
const isSecure = window.location.protocol === "https:";
document.cookie = `pangolin-sidebar-state=${collapsed ? "collapsed" : "expanded"}; path=/; max-age=${60 * 60 * 24 * 30}; samesite=lax${isSecure ? "; secure" : ""}`;
}
};
useEffect(() => {
setSidebarStateCookie(isSidebarCollapsed);
}, [isSidebarCollapsed]);
return (
<div
className={cn(
"hidden md:flex border-r bg-card flex-col h-full shrink-0 relative",
isSidebarCollapsed ? "w-16" : "w-64"
)}
>
<div className="flex-1 overflow-y-auto">
<div className="p-4">
<OrgSelector
orgId={orgId}
orgs={orgs}
isCollapsed={isSidebarCollapsed}
/>
</div>
<div className="px-4 pt-1">
{!isAdminPage && user.serverAdmin && (
<div className="pb-4">
<Link
href="/admin"
className={cn(
"flex items-center rounded transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-secondary/50 dark:hover:bg-secondary/20 rounded-md",
isSidebarCollapsed
? "px-2 py-2 justify-center"
: "px-3 py-1.5"
)}
title={
isSidebarCollapsed
? t("serverAdmin")
: undefined
}
>
<span
className={cn(
"flex-shrink-0",
!isSidebarCollapsed && "mr-2"
)}
>
<Server className="h-4 w-4" />
</span>
{!isSidebarCollapsed && (
<span>{t("serverAdmin")}</span>
)}
</Link>
</div>
)}
<SidebarNav
sections={navItems}
isCollapsed={isSidebarCollapsed}
/>
</div>
</div>
<div className="p-4 space-y-4 border-t shrink-0">
<SupporterStatus isCollapsed={isSidebarCollapsed} />
{!isSidebarCollapsed && (
<div className="space-y-2">
<div className="text-xs text-muted-foreground text-center">
<Link
href="https://github.com/fosrl/pangolin"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1"
>
{!isUnlocked()
? t("communityEdition")
: t("commercialEdition")}
<ExternalLink size={12} />
</Link>
</div>
<div className="text-xs text-muted-foreground ">
<Link
href="https://docs.digpangolin.com/"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1"
>
{t("documentation")}
<BookOpenText size={12} />
</Link>
</div>
{env?.app?.version && (
<div className="text-xs text-muted-foreground text-center">
v{env.app.version}
</div>
)}
</div>
)}
</div>
{/* Collapse button */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() =>
setIsSidebarCollapsed(!isSidebarCollapsed)
}
className="cursor-pointer absolute -right-2.5 top-1/2 transform -translate-y-1/2 w-2 h-8 rounded-full flex items-center justify-center transition-all duration-200 ease-in-out hover:scale-110 group z-[60]"
aria-label={
isSidebarCollapsed
? "Expand sidebar"
: "Collapse sidebar"
}
>
<div className="w-0.5 h-4 bg-current opacity-30 group-hover:opacity-100 transition-opacity duration-200" />
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
<p>
{isSidebarCollapsed
? t("sidebarExpand")
: t("sidebarCollapse")}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
}
export default LayoutSidebar;

View File

@@ -1,59 +1,62 @@
import { useLocale } from "next-intl";
import LocaleSwitcherSelect from './LocaleSwitcherSelect';
import LocaleSwitcherSelect from "./LocaleSwitcherSelect";
export default function LocaleSwitcher() {
const locale = useLocale();
const locale = useLocale();
return (
<LocaleSwitcherSelect
label="Select language"
defaultValue={locale}
items={[
{
value: 'en-US',
label: 'English'
},
{
value: 'fr-FR',
label: "Français"
},
{
value: 'de-DE',
label: 'Deutsch'
},
{
value: 'it-IT',
label: 'Italiano'
},
{
value: 'nl-NL',
label: 'Nederlands'
},
{
value: 'pl-PL',
label: 'Polski'
},
{
value: 'pt-PT',
label: 'Português'
},
{
value: 'es-ES',
label: 'Español'
},
{
value: 'tr-TR',
label: 'Türkçe'
},
{
value: 'zh-CN',
label: '简体中文'
},
{
value: 'nb-NO',
label: 'Norsk (Bokmål)'
}
]}
/>
);
}
return (
<LocaleSwitcherSelect
label="Select language"
defaultValue={locale}
items={[
{
value: "en-US",
label: "English"
},
{
value: "fr-FR",
label: "Français"
},
{
value: "de-DE",
label: "Deutsch"
},
{
value: "it-IT",
label: "Italiano"
},
{
value: "nl-NL",
label: "Nederlands"
},
{
value: "pl-PL",
label: "Polski"
},
{
value: "pt-PT",
label: "Português"
},
{
value: "es-ES",
label: "Español"
},
{
value: "tr-TR",
label: "Türkçe"
},
{
value: "zh-CN",
label: "简体中文"
},
{
value: "ko-KR",
label: "한국어"
},
{
value: "nb-NO",
label: "Norsk (Bokmål)"
]}
/>
);
}

View File

@@ -1,71 +1,71 @@
'use client';
"use client";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@app/components/ui/dropdown-menu';
import { Button } from '@app/components/ui/button';
import { Check, Globe, Languages } from 'lucide-react';
import clsx from 'clsx';
import { useTransition } from 'react';
import { Locale } from '@/i18n/config';
import { setUserLocale } from '@/services/locale';
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { Button } from "@app/components/ui/button";
import { Check, Globe, Languages } from "lucide-react";
import clsx from "clsx";
import { useTransition } from "react";
import { Locale } from "@/i18n/config";
import { setUserLocale } from "@/services/locale";
type Props = {
defaultValue: string;
items: Array<{ value: string; label: string }>;
label: string;
defaultValue: string;
items: Array<{ value: string; label: string }>;
label: string;
};
export default function LocaleSwitcherSelect({
defaultValue,
items,
label
defaultValue,
items,
label
}: Props) {
const [isPending, startTransition] = useTransition();
const [isPending, startTransition] = useTransition();
function onChange(value: string) {
const locale = value as Locale;
startTransition(() => {
setUserLocale(locale);
});
}
function onChange(value: string) {
const locale = value as Locale;
startTransition(() => {
setUserLocale(locale);
});
}
const selected = items.find((item) => item.value === defaultValue);
const selected = items.find((item) => item.value === defaultValue);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className={clsx(
'w-full rounded-sm h-8 gap-2 justify-start font-normal',
isPending && 'pointer-events-none'
)}
aria-label={label}
>
<Languages className="h-4 w-4" />
<span className="text-left flex-1">
{selected?.label ?? label}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[8rem]">
{items.map((item) => (
<DropdownMenuItem
key={item.value}
onClick={() => onChange(item.value)}
className="flex items-center gap-2"
>
{item.value === defaultValue && (
<Check className="h-4 w-4" />
)}
<span>{item.label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className={clsx(
"w-full rounded-sm h-8 gap-2 justify-start font-normal",
isPending && "pointer-events-none"
)}
aria-label={label}
>
<Languages className="h-4 w-4" />
<span className="text-left flex-1">
{selected?.label ?? label}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[8rem]">
{items.map((item) => (
<DropdownMenuItem
key={item.value}
onClick={() => onChange(item.value)}
className="flex items-center gap-2"
>
{item.value === defaultValue && (
<Check className="h-4 w-4" />
)}
<span>{item.label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -4,8 +4,8 @@ import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Button } from "@app/components/ui/button";
import { Input } from "@app/components/ui/input";
import {
Form,
FormControl,
@@ -13,20 +13,20 @@ import {
FormItem,
FormLabel,
FormMessage
} from "@/components/ui/form";
} from "@app/components/ui/form";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
} from "@app/components/ui/card";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { LoginResponse } from "@server/routers/auth";
import { useRouter } from "next/navigation";
import { AxiosResponse } from "axios";
import { formatAxiosError } from "@app/lib/api";
import { LockIcon } from "lucide-react";
import { LockIcon, FingerprintIcon } from "lucide-react";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import {
@@ -41,6 +41,7 @@ import Image from "next/image";
import { GenerateOidcUrlResponse } from "@server/routers/idp";
import { Separator } from "./ui/separator";
import { useTranslations } from "next-intl";
import { startAuthentication } from "@simplewebauthn/browser";
export type LoginFormIDP = {
idpId: number;
@@ -65,18 +66,17 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
const hasIdp = idps && idps.length > 0;
const [mfaRequested, setMfaRequested] = useState(false);
const [showSecurityKeyPrompt, setShowSecurityKeyPrompt] = useState(false);
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>>({
@@ -94,30 +94,135 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
}
});
async function initiateSecurityKeyAuth() {
setShowSecurityKeyPrompt(true);
setLoading(true);
setError(null);
try {
// Start WebAuthn authentication without email
const startRes = await api.post(
"/auth/security-key/authenticate/start",
{}
);
if (!startRes) {
setError(
t("securityKeyAuthError", {
defaultValue:
"Failed to start security key authentication"
})
);
return;
}
const { tempSessionId, ...options } = startRes.data.data;
// Perform WebAuthn authentication
try {
const credential = await startAuthentication(options);
// Verify authentication
const verifyRes = await api.post(
"/auth/security-key/authenticate/verify",
{ credential },
{
headers: {
"X-Temp-Session-Id": tempSessionId
}
}
);
if (verifyRes) {
if (onLogin) {
await onLogin();
}
}
} 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."
})
);
} else {
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 {
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"
})
)
);
} else {
console.error(e);
setError(
e.message ||
t("securityKeyAuthError", {
defaultValue:
"Failed to authenticate with security key"
})
);
}
} finally {
setLoading(false);
setShowSecurityKeyPrompt(false);
}
}
async function onSubmit(values: any) {
const { email, password } = form.getValues();
const { code } = mfaForm.getValues();
setLoading(true);
setError(null);
setShowSecurityKeyPrompt(false);
const res = await api
.post<AxiosResponse<LoginResponse>>("/auth/login", {
email,
password,
code
})
.catch((e) => {
console.error(e);
setError(
formatAxiosError(e, t('loginError'))
);
});
if (res) {
setError(null);
try {
const res = await api.post<AxiosResponse<LoginResponse>>(
"/auth/login",
{
email,
password,
code
}
);
const data = res.data.data;
if (data?.useSecurityKey) {
await initiateSecurityKeyAuth();
return;
}
if (data?.codeRequested) {
setMfaRequested(true);
setLoading(false);
@@ -134,12 +239,38 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
return;
}
if (data?.twoFactorSetupRequired) {
const setupUrl = `/auth/2fa/setup?email=${encodeURIComponent(email)}${redirect ? `&redirect=${encodeURIComponent(redirect)}` : ""}`;
router.push(setupUrl);
return;
}
if (onLogin) {
await onLogin();
}
} catch (e: any) {
if (e.isAxiosError) {
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"
})
);
return;
}
} finally {
setLoading(false);
}
setLoading(false);
}
async function loginWithIdp(idpId: number) {
@@ -154,7 +285,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
console.log(res);
if (!res) {
setError(t('loginError'));
setError(t("loginError"));
return;
}
@@ -167,6 +298,18 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
return (
<div className="space-y-4">
{showSecurityKeyPrompt && (
<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."
})}
</AlertDescription>
</Alert>
)}
{!mfaRequested && (
<>
<Form {...form}>
@@ -180,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>
@@ -195,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"
@@ -212,10 +357,20 @@ 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={loading}
>
{t("login")}
</Button>
</div>
</form>
</Form>
</>
@@ -224,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}>
@@ -250,10 +403,16 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
pattern={
REGEXP_ONLY_DIGITS_AND_CHARS
}
onChange={(e) => {
field.onChange(e);
if (e.length === 6) {
mfaForm.handleSubmit(onSubmit)();
onChange={(
value: string
) => {
field.onChange(value);
if (
value.length === 6
) {
mfaForm.handleSubmit(
onSubmit
)();
}
}}
>
@@ -304,21 +463,24 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
loading={loading}
disabled={loading}
>
{t('otpAuthSubmit')}
{t("otpAuthSubmit")}
</Button>
)}
{!mfaRequested && (
<>
<Button
type="submit"
form="form"
type="button"
variant="outline"
className="w-full"
onClick={initiateSecurityKeyAuth}
loading={loading}
disabled={loading}
disabled={loading || showSecurityKeyPrompt}
>
<LockIcon className="w-4 h-4 mr-2" />
{t('login')}
<FingerprintIcon className="w-4 h-4 mr-2" />
{t("securityKeyLogin", {
defaultValue: "Sign in with security key"
})}
</Button>
{hasIdp && (
@@ -329,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>
@@ -362,7 +524,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
mfaForm.reset();
}}
>
{t('otpAuthBack')}
{t("otpAuthBack")}
</Button>
)}
</div>

View File

@@ -15,10 +15,16 @@ import {
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@app/components/ui/tooltip";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { cn } from "@app/lib/cn";
import { ListUserOrgsResponse } from "@server/routers/org";
import { Check, ChevronsUpDown, Plus } from "lucide-react";
import { Check, ChevronsUpDown, Plus, Building2, Users } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useUserContext } from "@app/hooks/useUserContext";
@@ -27,94 +33,110 @@ import { useTranslations } from "next-intl";
interface OrgSelectorProps {
orgId?: string;
orgs?: ListUserOrgsResponse["orgs"];
isCollapsed?: boolean;
}
export function OrgSelector({ orgId, orgs }: OrgSelectorProps) {
export function OrgSelector({ orgId, orgs, isCollapsed = false }: OrgSelectorProps) {
const { user } = useUserContext();
const [open, setOpen] = useState(false);
const router = useRouter();
const { env } = useEnvContext();
const t = useTranslations();
return (
const selectedOrg = orgs?.find((org) => org.orgId === orgId);
const orgSelectorContent = (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="lg"
variant="secondary"
size={isCollapsed ? "icon" : "lg"}
role="combobox"
aria-expanded={open}
className="w-full h-12 px-3 py-4 bg-neutral hover:bg-neutral"
className={cn(
"shadow-xs",
isCollapsed ? "w-8 h-8" : "w-full h-12 px-3 py-4"
)}
>
<div className="flex items-center justify-between w-full">
<div className="flex flex-col items-start">
<span className="font-bold text-sm">
{t('org')}
</span>
<span className="text-sm text-muted-foreground">
{orgId
? orgs?.find(
(org) =>
org.orgId ===
orgId
)?.name
: t('noneSelected')}
</span>
{isCollapsed ? (
<Building2 className="h-4 w-4" />
) : (
<div className="flex items-center justify-between w-full min-w-0">
<div className="flex items-center min-w-0 flex-1">
<Building2 className="h-4 w-4 mr-2 shrink-0" />
<div className="flex flex-col items-start min-w-0 flex-1">
<span className="font-bold text-sm">
{t('org')}
</span>
<span className="text-sm text-muted-foreground truncate w-full text-left">
{selectedOrg?.name || t('noneSelected')}
</span>
</div>
</div>
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50 ml-2" />
</div>
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
</div>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[180px] p-0">
<Command>
<CommandInput placeholder={t('searchProgress')} />
<CommandEmpty>
{t('orgNotFound2')}
<PopoverContent className="w-[320px] p-0" align="start">
<Command className="rounded-lg">
<CommandInput
placeholder={t('searchProgress')}
className="border-0 focus:ring-0"
/>
<CommandEmpty className="py-6 text-center">
<div className="text-muted-foreground text-sm">
{t('orgNotFound2')}
</div>
</CommandEmpty>
{(!env.flags.disableUserCreateOrg ||
user.serverAdmin) && (
{(!env.flags.disableUserCreateOrg || user.serverAdmin) && (
<>
<CommandGroup heading={t('create')}>
<CommandGroup heading={t('create')} className="py-2">
<CommandList>
<CommandItem
onSelect={(
currentValue
) => {
router.push(
"/setup"
);
onSelect={() => {
setOpen(false);
router.push("/setup");
}}
className="mx-2 rounded-md"
>
<Plus className="mr-2 h-4 w-4" />
{t('setupNewOrg')}
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 mr-3">
<Plus className="h-4 w-4 text-primary" />
</div>
<div className="flex flex-col">
<span className="font-medium">{t('setupNewOrg')}</span>
<span className="text-xs text-muted-foreground">{t('createNewOrgDescription')}</span>
</div>
</CommandItem>
</CommandList>
</CommandGroup>
<CommandSeparator />
<CommandSeparator className="my-2" />
</>
)}
<CommandGroup heading={t('orgs')}>
<CommandGroup heading={t('orgs')} className="py-2">
<CommandList>
{orgs?.map((org) => (
<CommandItem
key={org.orgId}
onSelect={(
currentValue
) => {
router.push(
`/${org.orgId}/settings`
);
onSelect={() => {
setOpen(false);
router.push(`/${org.orgId}/settings`);
}}
className="mx-2 rounded-md"
>
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-muted mr-3">
<Users className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex flex-col flex-1">
<span className="font-medium">{org.name}</span>
<span className="text-xs text-muted-foreground">{t('organization')}</span>
</div>
<Check
className={cn(
"mr-2 h-4 w-4",
orgId === org.orgId
? "opacity-100"
: "opacity-0"
"h-4 w-4 text-primary",
orgId === org.orgId ? "opacity-100" : "opacity-0"
)}
/>
{org.name}
</CommandItem>
))}
</CommandList>
@@ -123,4 +145,24 @@ export function OrgSelector({ orgId, orgs }: OrgSelectorProps) {
</PopoverContent>
</Popover>
);
if (isCollapsed) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
{orgSelectorContent}
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
<div className="text-center">
<p className="font-medium">{selectedOrg?.name || t('noneSelected')}</p>
<p className="text-xs text-muted-foreground">{t('org')}</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return orgSelectorContent;
}

View File

@@ -11,6 +11,7 @@ import {
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { ArrowRight, Plus } from "lucide-react";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
interface Organization {
@@ -29,6 +30,8 @@ export default function OrganizationLanding({
}: OrganizationLandingProps) {
const [selectedOrg, setSelectedOrg] = useState<string | null>(null);
const { env } = useEnvContext();
const handleOrgClick = (orgId: string) => {
setSelectedOrg(orgId);
};
@@ -38,35 +41,31 @@ export default function OrganizationLanding({
function getDescriptionText() {
if (organizations.length === 0) {
if (!disableCreateOrg) {
return t('componentsErrorNoMemberCreate');
return t("componentsErrorNoMemberCreate");
} else {
return t('componentsErrorNoMember');
return t("componentsErrorNoMember");
}
}
return t('componentsMember', {count: organizations.length});
return t("componentsMember", { count: organizations.length });
}
return (
<Card>
<CardHeader>
<CardTitle>{t('welcome')}</CardTitle>
<CardTitle>{t("welcome")}</CardTitle>
<CardDescription>{getDescriptionText()}</CardDescription>
</CardHeader>
<CardContent>
{organizations.length === 0 ? (
disableCreateOrg ? (
<p className="text-center text-muted-foreground">
t('componentsErrorNoMember')
</p>
) : (
!disableCreateOrg && (
<Link href="/setup">
<Button
className="w-full h-auto py-3 text-lg"
size="lg"
>
<Plus className="mr-2 h-5 w-5" />
{t('componentsCreateOrg')}
{t("componentsCreateOrg")}
</Button>
</Link>
)

Some files were not shown because too many files have changed in this diff Show More