mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-13 08:26:40 +00:00
Merge branch 'dev' into clients-user
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { useClientContext } from "@app/hooks/useClientContext";
|
||||
import {
|
||||
InfoSection,
|
||||
|
||||
@@ -86,20 +86,15 @@ export default function CreateInternalResourceDialog({
|
||||
.string()
|
||||
.min(1, t("createInternalResourceDialogNameRequired"))
|
||||
.max(255, t("createInternalResourceDialogNameMaxLength")),
|
||||
siteId: z.number().int().positive(t("createInternalResourceDialogPleaseSelectSite")),
|
||||
mode: z.enum(["host", "cidr", "port"]),
|
||||
protocol: z.enum(["tcp", "udp"]).nullish(),
|
||||
proxyPort: z
|
||||
.number()
|
||||
.int()
|
||||
destination: z.string().min(1),
|
||||
siteId: z.int().positive(t("createInternalResourceDialogPleaseSelectSite")),
|
||||
protocol: z.enum(["tcp", "udp"]),
|
||||
proxyPort: z.int()
|
||||
.positive()
|
||||
.min(1, t("createInternalResourceDialogProxyPortMin"))
|
||||
.max(65535, t("createInternalResourceDialogProxyPortMax"))
|
||||
.nullish(),
|
||||
destination: z.string().min(1),
|
||||
destinationPort: z
|
||||
.number()
|
||||
.int()
|
||||
.max(65535, t("createInternalResourceDialogProxyPortMax")),
|
||||
destinationPort: z.int()
|
||||
.positive()
|
||||
.min(1, t("createInternalResourceDialogDestinationPortMin"))
|
||||
.max(65535, t("createInternalResourceDialogDestinationPortMax"))
|
||||
|
||||
@@ -108,7 +108,7 @@ export default function CreateShareLinkForm({
|
||||
resourceName: z.string(),
|
||||
resourceUrl: z.string(),
|
||||
timeUnit: z.string(),
|
||||
timeValue: z.coerce.number().int().positive().min(1),
|
||||
timeValue: z.coerce.number<number>().int().positive().min(1),
|
||||
title: z.string().optional()
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTranslations } from "next-intl";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { DNSRecordsDataTable } from "./DNSRecordsDataTable";
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
|
||||
export type DNSRecordRow = {
|
||||
id: string;
|
||||
@@ -25,6 +26,30 @@ export default function DNSRecordsTable({
|
||||
type
|
||||
}: Props) {
|
||||
const t = useTranslations();
|
||||
const env = useEnvContext();
|
||||
|
||||
const statusColumn: ColumnDef<DNSRecordRow> = {
|
||||
accessorKey: "verified",
|
||||
header: ({ column }) => {
|
||||
return <div>{t("status")}</div>;
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const verified = row.original.verified;
|
||||
return verified ? (
|
||||
type === "wildcard" ? (
|
||||
<Badge variant="outlinePrimary">
|
||||
{t("manual", { fallback: "Manual" })}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="green">{t("verified")}</Badge>
|
||||
)
|
||||
) : (
|
||||
<Badge variant="yellow">
|
||||
{t("pending", { fallback: "Pending" })}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ExtendedColumnDef<DNSRecordRow>[] = [
|
||||
{
|
||||
@@ -86,29 +111,7 @@ export default function DNSRecordsTable({
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "verified",
|
||||
friendlyName: t("status"),
|
||||
header: ({ column }) => {
|
||||
return <div>{t("status")}</div>;
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const verified = row.original.verified;
|
||||
return verified ? (
|
||||
type === "wildcard" ? (
|
||||
<Badge variant="outlinePrimary">
|
||||
{t("manual", { fallback: "Manual" })}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="green">{t("verified")}</Badge>
|
||||
)
|
||||
) : (
|
||||
<Badge variant="yellow">
|
||||
{t("pending", { fallback: "Pending" })}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
}
|
||||
...(env.env.flags.usePangolinDns ? [statusColumn] : [])
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "@app/components/InfoSection";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
|
||||
type DomainInfoCardProps = {
|
||||
failed: boolean;
|
||||
@@ -22,6 +23,7 @@ export default function DomainInfoCard({
|
||||
type
|
||||
}: DomainInfoCardProps) {
|
||||
const t = useTranslations();
|
||||
const env = useEnvContext();
|
||||
|
||||
const getTypeDisplay = (type: string) => {
|
||||
switch (type) {
|
||||
@@ -46,32 +48,34 @@ export default function DomainInfoCard({
|
||||
<span>{getTypeDisplay(type ? type : "")}</span>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{failed ? (
|
||||
<Badge variant="red">
|
||||
{t("failed", { fallback: "Failed" })}
|
||||
</Badge>
|
||||
) : verified ? (
|
||||
type === "wildcard" ? (
|
||||
<Badge variant="outlinePrimary">
|
||||
{t("manual", {
|
||||
fallback: "Manual"
|
||||
})}
|
||||
{env.env.flags.usePangolinDns && (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{failed ? (
|
||||
<Badge variant="red">
|
||||
{t("failed", { fallback: "Failed" })}
|
||||
</Badge>
|
||||
) : verified ? (
|
||||
type === "wildcard" ? (
|
||||
<Badge variant="outlinePrimary">
|
||||
{t("manual", {
|
||||
fallback: "Manual"
|
||||
})}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="green">
|
||||
{t("verified")}
|
||||
</Badge>
|
||||
)
|
||||
) : (
|
||||
<Badge variant="green">
|
||||
{t("verified")}
|
||||
<Badge variant="yellow">
|
||||
{t("pending", { fallback: "Pending" })}
|
||||
</Badge>
|
||||
)
|
||||
) : (
|
||||
<Badge variant="yellow">
|
||||
{t("pending", { fallback: "Pending" })}
|
||||
</Badge>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
)}
|
||||
</InfoSections>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@@ -56,7 +56,8 @@ export default function DomainsTable({ domains, orgId }: Props) {
|
||||
const [restartingDomains, setRestartingDomains] = useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
const api = createApiClient(useEnvContext());
|
||||
const env = useEnvContext();
|
||||
const api = createApiClient(env);
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
const { toast } = useToast();
|
||||
@@ -135,6 +136,41 @@ export default function DomainsTable({ domains, orgId }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const statusColumn: ColumnDef<DomainRow> = {
|
||||
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, type } = row.original;
|
||||
if (verified) {
|
||||
return type == "wildcard" ? (
|
||||
<Badge variant="outlinePrimary">{t("manual")}</Badge>
|
||||
) : (
|
||||
<Badge variant="green">{t("verified")}</Badge>
|
||||
);
|
||||
} else if (failed) {
|
||||
return (
|
||||
<Badge variant="red">
|
||||
{t("failed", { fallback: "Failed" })}
|
||||
</Badge>
|
||||
);
|
||||
} else {
|
||||
return <Badge variant="yellow">{t("pending")}</Badge>;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ExtendedColumnDef<DomainRow>[] = [
|
||||
{
|
||||
accessorKey: "baseDomain",
|
||||
@@ -177,41 +213,7 @@ export default function DomainsTable({ domains, orgId }: Props) {
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "verified",
|
||||
friendlyName: t("status"),
|
||||
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, type } = row.original;
|
||||
if (verified) {
|
||||
return type == "wildcard" ? (
|
||||
<Badge variant="outlinePrimary">{t("manual")}</Badge>
|
||||
) : (
|
||||
<Badge variant="green">{t("verified")}</Badge>
|
||||
);
|
||||
} else if (failed) {
|
||||
return (
|
||||
<Badge variant="red">
|
||||
{t("failed", { fallback: "Failed" })}
|
||||
</Badge>
|
||||
);
|
||||
} else {
|
||||
return <Badge variant="yellow">{t("pending")}</Badge>;
|
||||
}
|
||||
}
|
||||
},
|
||||
...(env.env.flags.usePangolinDns ? [statusColumn] : []),
|
||||
{
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
|
||||
@@ -84,9 +84,9 @@ export default function EditInternalResourceDialog({
|
||||
name: z.string().min(1, t("editInternalResourceDialogNameRequired")).max(255, t("editInternalResourceDialogNameMaxLength")),
|
||||
mode: z.enum(["host", "cidr", "port"]),
|
||||
protocol: z.enum(["tcp", "udp"]).nullish(),
|
||||
proxyPort: z.number().int().positive().min(1, t("editInternalResourceDialogProxyPortMin")).max(65535, t("editInternalResourceDialogProxyPortMax")).nullish(),
|
||||
proxyPort: z.int().positive().min(1, t("editInternalResourceDialogProxyPortMin")).max(65535, t("editInternalResourceDialogProxyPortMax")).nullish(),
|
||||
destination: z.string().min(1),
|
||||
destinationPort: z.number().int().positive().min(1, t("editInternalResourceDialogDestinationPortMin")).max(65535, t("editInternalResourceDialogDestinationPortMax")).nullish(),
|
||||
destinationPort: z.int().positive().min(1, t("editInternalResourceDialogDestinationPortMin")).max(65535, t("editInternalResourceDialogDestinationPortMax")).nullish(),
|
||||
alias: z.string().nullish(),
|
||||
roles: z.array(
|
||||
z.object({
|
||||
|
||||
52
src/components/ExitNodeInfoCard.tsx
Normal file
52
src/components/ExitNodeInfoCard.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import {
|
||||
InfoSection,
|
||||
InfoSectionContent,
|
||||
InfoSections,
|
||||
InfoSectionTitle
|
||||
} from "@app/components/InfoSection";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRemoteExitNodeContext } from "@app/hooks/useRemoteExitNodeContext";
|
||||
|
||||
type ExitNodeInfoCardProps = {};
|
||||
|
||||
export default function ExitNodeInfoCard({}: ExitNodeInfoCardProps) {
|
||||
const { remoteExitNode, updateRemoteExitNode } = useRemoteExitNodeContext();
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription className="mt-4">
|
||||
<InfoSections cols={2}>
|
||||
<>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{remoteExitNode.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>
|
||||
{remoteExitNode.address}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</InfoSections>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -63,7 +63,7 @@ export default function GenerateLicenseKeyForm({
|
||||
|
||||
// Personal form schema
|
||||
const personalFormSchema = z.object({
|
||||
email: z.string().email(),
|
||||
email: z.email(),
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().min(1),
|
||||
primaryUse: z.string().min(1),
|
||||
@@ -75,14 +75,14 @@ export default function GenerateLicenseKeyForm({
|
||||
|
||||
// Business form schema
|
||||
const businessFormSchema = z.object({
|
||||
email: z.string().email(),
|
||||
email: z.email(),
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().min(1),
|
||||
jobTitle: z.string().min(1),
|
||||
primaryUse: z.string().min(1),
|
||||
industry: z.string().min(1),
|
||||
prospectiveUsers: z.coerce.number().optional(),
|
||||
prospectiveSites: z.coerce.number().optional(),
|
||||
prospectiveUsers: z.coerce.number<number>().optional(),
|
||||
prospectiveSites: z.coerce.number<number>().optional(),
|
||||
companyName: z.string().min(1),
|
||||
countryOfResidence: z.string().min(1),
|
||||
stateProvinceRegion: z.string().min(1),
|
||||
|
||||
@@ -80,24 +80,20 @@ export default function HealthCheckDialog({
|
||||
hcMethod: z
|
||||
.string()
|
||||
.min(1, { message: t("healthCheckMethodRequired") }),
|
||||
hcInterval: z
|
||||
.number()
|
||||
.int()
|
||||
hcInterval: z.int()
|
||||
.positive()
|
||||
.min(5, { message: t("healthCheckIntervalMin") }),
|
||||
hcTimeout: z
|
||||
.number()
|
||||
.int()
|
||||
hcTimeout: z.int()
|
||||
.positive()
|
||||
.min(1, { message: t("healthCheckTimeoutMin") }),
|
||||
hcStatus: z.number().int().positive().min(100).optional().nullable(),
|
||||
hcStatus: z.int().positive().min(100).optional().nullable(),
|
||||
hcHeaders: z.array(z.object({ name: z.string(), value: z.string() })).nullable().optional(),
|
||||
hcScheme: z.string().optional(),
|
||||
hcHostname: z.string(),
|
||||
hcPort: z.number().positive().gt(0).lte(65535),
|
||||
hcFollowRedirects: z.boolean(),
|
||||
hcMode: z.string(),
|
||||
hcUnhealthyInterval: z.number().int().positive().min(5)
|
||||
hcUnhealthyInterval: z.int().positive().min(5)
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof healthCheckSchema>>({
|
||||
|
||||
@@ -59,8 +59,8 @@ export function IdpCreateWizard({ onSubmit, defaultValues, loading = false }: Id
|
||||
type: z.enum(["oidc"]),
|
||||
clientId: z.string().min(1, { message: t('idpClientIdRequired') }),
|
||||
clientSecret: z.string().min(1, { message: t('idpClientSecretRequired') }),
|
||||
authUrl: z.string().url({ message: t('idpErrorAuthUrlInvalid') }),
|
||||
tokenUrl: z.string().url({ message: t('idpErrorTokenUrlInvalid') }),
|
||||
authUrl: z.url({ message: t('idpErrorAuthUrlInvalid') }),
|
||||
tokenUrl: z.url({ message: t('idpErrorTokenUrlInvalid') }),
|
||||
identifierPath: z
|
||||
.string()
|
||||
.min(1, { message: t('idpPathRequired') }),
|
||||
|
||||
@@ -50,5 +50,11 @@ export function InfoSectionContent({
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <div className={cn("break-words", className)}>{children}</div>;
|
||||
return (
|
||||
<div className={cn("min-w-0 overflow-hidden", className)}>
|
||||
<div className="w-full truncate [&>div.flex]:min-w-0 [&>div.flex]:!whitespace-normal [&>div.flex>span]:truncate [&>div.flex>a]:truncate">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,11 @@ import {
|
||||
import { build } from "@server/build";
|
||||
import SidebarLicenseButton from "./SidebarLicenseButton";
|
||||
import { SidebarSupportButton } from "./SidebarSupportButton";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const ProductUpdates = dynamic(() => import("./ProductUpdates"), {
|
||||
ssr: false
|
||||
});
|
||||
|
||||
interface LayoutSidebarProps {
|
||||
orgId?: string;
|
||||
@@ -101,7 +106,7 @@ export function LayoutSidebar({
|
||||
<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",
|
||||
"flex items-center 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"
|
||||
@@ -114,7 +119,7 @@ export function LayoutSidebar({
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex-shrink-0",
|
||||
"shrink-0",
|
||||
!isSidebarCollapsed && "mr-2"
|
||||
)}
|
||||
>
|
||||
@@ -133,7 +138,9 @@ export function LayoutSidebar({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4 shrink-0">
|
||||
<div className="p-4 flex flex-col gap-4 shrink-0">
|
||||
<ProductUpdates isCollapsed={isSidebarCollapsed} />
|
||||
|
||||
{build === "enterprise" && (
|
||||
<div className="mb-3">
|
||||
<SidebarLicenseButton
|
||||
@@ -148,7 +155,9 @@ export function LayoutSidebar({
|
||||
)}
|
||||
{build === "saas" && (
|
||||
<div className="mb-3">
|
||||
<SidebarSupportButton isCollapsed={isSidebarCollapsed} />
|
||||
<SidebarSupportButton
|
||||
isCollapsed={isSidebarCollapsed}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!isSidebarCollapsed && (
|
||||
|
||||
379
src/components/ProductUpdates.tsx
Normal file
379
src/components/ProductUpdates.tsx
Normal file
@@ -0,0 +1,379 @@
|
||||
"use client";
|
||||
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useLocalStorage } from "@app/hooks/useLocalStorage";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import {
|
||||
type LatestVersionResponse,
|
||||
type ProductUpdate,
|
||||
productUpdatesQueries
|
||||
} from "@app/lib/queries";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowRight,
|
||||
BellIcon,
|
||||
ChevronRightIcon,
|
||||
ExternalLinkIcon,
|
||||
RocketIcon,
|
||||
XIcon
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Transition } from "@headlessui/react";
|
||||
import * as React from "react";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import { Button } from "./ui/button";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { timeAgoFormatter } from "@app/lib/timeAgoFormatter";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger
|
||||
} from "./ui/tooltip";
|
||||
|
||||
export default function ProductUpdates({
|
||||
isCollapsed
|
||||
}: {
|
||||
isCollapsed?: boolean;
|
||||
}) {
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const data = useQueries({
|
||||
queries: [
|
||||
productUpdatesQueries.list(env.app.notifications.product_updates),
|
||||
productUpdatesQueries.latestVersion(
|
||||
env.app.notifications.new_releases
|
||||
)
|
||||
],
|
||||
combine(result) {
|
||||
if (result[0].isLoading || result[1].isLoading) return null;
|
||||
return {
|
||||
updates: result[0].data?.data ?? [],
|
||||
latestVersion: result[1].data
|
||||
};
|
||||
}
|
||||
});
|
||||
const t = useTranslations();
|
||||
const [showMoreUpdatesText, setShowMoreUpdatesText] = React.useState(false);
|
||||
|
||||
// we delay the small text animation so that the user can notice it
|
||||
React.useEffect(() => {
|
||||
const timeout = setTimeout(() => setShowMoreUpdatesText(true), 600);
|
||||
return () => clearTimeout(timeout);
|
||||
}, []);
|
||||
|
||||
const [ignoredVersionUpdate, setIgnoredVersionUpdate] = useLocalStorage<
|
||||
string | null
|
||||
>("product-updates:skip-version", null);
|
||||
|
||||
const [productUpdatesRead, setProductUpdatesRead] = useLocalStorage<
|
||||
number[]
|
||||
>("product-updates:read", []);
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const showNewVersionPopup = Boolean(
|
||||
data?.latestVersion?.data &&
|
||||
ignoredVersionUpdate !==
|
||||
data.latestVersion.data?.pangolin.latestVersion &&
|
||||
env.app.version !== data.latestVersion.data?.pangolin.latestVersion
|
||||
);
|
||||
|
||||
const filteredUpdates = data.updates.filter(
|
||||
(update) => !productUpdatesRead.includes(update.id)
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-2 overflow-clip",
|
||||
isCollapsed && "hidden"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<small
|
||||
className={cn(
|
||||
"text-xs text-muted-foreground flex items-center gap-1 mt-2",
|
||||
showMoreUpdatesText
|
||||
? "animate-in fade-in duration-300"
|
||||
: "opacity-0"
|
||||
)}
|
||||
>
|
||||
{filteredUpdates.length > 0 && (
|
||||
<>
|
||||
<BellIcon className="flex-none size-3" />
|
||||
<span>
|
||||
{showNewVersionPopup
|
||||
? t("productUpdateMoreInfo", {
|
||||
noOfUpdates: filteredUpdates.length
|
||||
})
|
||||
: t("productUpdateInfo", {
|
||||
noOfUpdates: filteredUpdates.length
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</small>
|
||||
<ProductUpdatesListPopup
|
||||
updates={filteredUpdates}
|
||||
show={filteredUpdates.length > 0}
|
||||
onDimissAll={() =>
|
||||
setProductUpdatesRead([
|
||||
...productUpdatesRead,
|
||||
...filteredUpdates.map((update) => update.id)
|
||||
])
|
||||
}
|
||||
onDimiss={(id) =>
|
||||
setProductUpdatesRead([...productUpdatesRead, id])
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<NewVersionAvailable
|
||||
version={data.latestVersion?.data}
|
||||
onDimiss={() => {
|
||||
setIgnoredVersionUpdate(
|
||||
data.latestVersion?.data?.pangolin.latestVersion ?? null
|
||||
);
|
||||
}}
|
||||
show={showNewVersionPopup}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ProductUpdatesListPopupProps = {
|
||||
updates: ProductUpdate[];
|
||||
show: boolean;
|
||||
onDimiss: (id: number) => void;
|
||||
onDimissAll: () => void;
|
||||
};
|
||||
|
||||
function ProductUpdatesListPopup({
|
||||
updates,
|
||||
show,
|
||||
onDimiss,
|
||||
onDimissAll
|
||||
}: ProductUpdatesListPopupProps) {
|
||||
const [showContent, setShowContent] = React.useState(false);
|
||||
const [popoverOpen, setPopoverOpen] = React.useState(false);
|
||||
const t = useTranslations();
|
||||
|
||||
// we need to delay the initial opening state to have an animation on `appear`
|
||||
React.useEffect(() => {
|
||||
if (show) {
|
||||
requestAnimationFrame(() => setShowContent(true));
|
||||
}
|
||||
}, [show]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (updates.length === 0) {
|
||||
setShowContent(false);
|
||||
setPopoverOpen(false);
|
||||
}
|
||||
}, [updates.length]);
|
||||
|
||||
return (
|
||||
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
|
||||
<Transition show={showContent}>
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-1 cursor-pointer block",
|
||||
"rounded-md border bg-muted p-2 py-3 w-full flex items-start gap-2 text-sm",
|
||||
"transition duration-300 ease-in-out",
|
||||
"data-closed:opacity-0 data-closed:translate-y-full"
|
||||
)}
|
||||
>
|
||||
<div className="rounded-md bg-muted-foreground/20 p-2">
|
||||
<BellIcon className="flex-none size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 flex-1">
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<p className="font-medium text-start">
|
||||
{t("productUpdateWhatsNew")}
|
||||
</p>
|
||||
<div className="p-1 cursor-pointer ml-auto">
|
||||
<ChevronRightIcon className="size-4 flex-none" />
|
||||
</div>
|
||||
</div>
|
||||
<small
|
||||
className={cn(
|
||||
"text-start text-muted-foreground",
|
||||
"overflow-hidden h-8",
|
||||
"[-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box]"
|
||||
)}
|
||||
>
|
||||
{updates[0]?.contents}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
</Transition>
|
||||
<PopoverContent
|
||||
side="right"
|
||||
align="end"
|
||||
sideOffset={10}
|
||||
className="p-0 flex flex-col w-85"
|
||||
>
|
||||
<div className="p-3 flex justify-between border-b items-center">
|
||||
<span className="text-sm inline-flex gap-2 items-center font-medium">
|
||||
{t("productUpdateTitle")}
|
||||
{updates.length > 0 && (
|
||||
<Badge variant="secondary">{updates.length}</Badge>
|
||||
)}
|
||||
</span>
|
||||
<Button variant="outline" onClick={onDimissAll}>
|
||||
{t("dismissAll")}
|
||||
</Button>
|
||||
</div>
|
||||
<ol className="p-3 flex flex-col gap-1 max-h-112 overflow-y-auto">
|
||||
{updates.length === 0 && (
|
||||
<small className="border rounded-md flex p-4 border-dashed justify-center items-center text-muted-foreground">
|
||||
{t("productUpdateEmpty")}
|
||||
</small>
|
||||
)}
|
||||
{updates.map((update) => (
|
||||
<li
|
||||
key={update.id}
|
||||
className="border rounded-md flex flex-col p-4 gap-2.5 group hover:bg-accent relative"
|
||||
>
|
||||
<div className="flex justify-between gap-2 items-start">
|
||||
<h4 className="text-sm font-medium inline-flex items-start gap-1">
|
||||
<span>{update.title}</span>
|
||||
<Badge
|
||||
variant={
|
||||
update.type === "Important"
|
||||
? "yellow"
|
||||
: "secondary"
|
||||
}
|
||||
className={cn(
|
||||
update.type === "New" &&
|
||||
"bg-black text-white dark:bg-white dark:text-black"
|
||||
)}
|
||||
>
|
||||
{update.type}
|
||||
</Badge>
|
||||
</h4>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="p-1 py-1 opacity-100 h-auto group-hover:opacity-100"
|
||||
onClick={() =>
|
||||
onDimiss(update.id)
|
||||
}
|
||||
>
|
||||
<XIcon className="flex-none size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
>
|
||||
{t("dismiss")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<small className="text-muted-foreground">
|
||||
{update.contents}{" "}
|
||||
{update.link && (
|
||||
<a
|
||||
href={update.link}
|
||||
target="_blank"
|
||||
className="underline text-foreground inline-flex flex-wrap items-center gap-1 text-xs"
|
||||
>
|
||||
Read more{" "}
|
||||
<ExternalLinkIcon className="size-3 flex-none" />
|
||||
</a>
|
||||
)}
|
||||
</small>
|
||||
</div>
|
||||
<time
|
||||
dateTime={update.publishedAt.toLocaleString()}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
{timeAgoFormatter(update.publishedAt)}
|
||||
</time>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
type NewVersionAvailableProps = {
|
||||
onDimiss: () => void;
|
||||
show: boolean;
|
||||
version: LatestVersionResponse | null | undefined;
|
||||
};
|
||||
|
||||
function NewVersionAvailable({
|
||||
version,
|
||||
show,
|
||||
onDimiss
|
||||
}: NewVersionAvailableProps) {
|
||||
const t = useTranslations();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
// we need to delay the initial opening state to have an animation on `appear`
|
||||
React.useEffect(() => {
|
||||
if (show) {
|
||||
requestAnimationFrame(() => setOpen(true));
|
||||
}
|
||||
}, [show]);
|
||||
|
||||
return (
|
||||
<Transition show={open}>
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-2",
|
||||
"rounded-md border bg-muted p-2 py-3 w-full flex items-start gap-2 text-sm",
|
||||
"transition duration-300 ease-in-out",
|
||||
"data-closed:opacity-0 data-closed:translate-y-full"
|
||||
)}
|
||||
>
|
||||
{version && (
|
||||
<>
|
||||
<div className="rounded-md bg-muted-foreground/20 p-2">
|
||||
<RocketIcon className="flex-none size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="font-medium">
|
||||
{t("pangolinUpdateAvailable")}
|
||||
</p>
|
||||
<small className="text-muted-foreground">
|
||||
{t("pangolinUpdateAvailableInfo", {
|
||||
version: version.pangolin.latestVersion
|
||||
})}
|
||||
</small>
|
||||
<a
|
||||
href={version.pangolin.releaseNotes}
|
||||
target="_blank"
|
||||
className="inline-flex items-center gap-0.5 text-xs font-medium"
|
||||
>
|
||||
<span>
|
||||
{t("pangolinUpdateAvailableReleaseNotes")}
|
||||
</span>
|
||||
<ArrowRight className="flex-none size-3" />
|
||||
</a>
|
||||
</div>
|
||||
<button
|
||||
className="p-1 cursor-pointer"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onDimiss();
|
||||
}}
|
||||
>
|
||||
<XIcon className="size-4 flex-none" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
216
src/components/RegenerateCredentialsModal.tsx
Normal file
216
src/components/RegenerateCredentialsModal.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { InfoIcon, AlertTriangle } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { InfoSection, InfoSectionContent, InfoSections, InfoSectionTitle } from "@app/components/InfoSection";
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import CopyTextBox from "@app/components/CopyTextBox";
|
||||
import { QRCodeCanvas } from "qrcode.react";
|
||||
|
||||
type CredentialType = "site-wireguard" | "site-newt" | "client-olm" | "remote-exit-node";
|
||||
|
||||
interface RegenerateCredentialsModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
type: CredentialType;
|
||||
onConfirmRegenerate: () => Promise<void>;
|
||||
dashboardUrl: string;
|
||||
credentials?: {
|
||||
// For WireGuard sites
|
||||
wgConfig?: string;
|
||||
|
||||
Id?: string;
|
||||
Secret?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function RegenerateCredentialsModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
type,
|
||||
onConfirmRegenerate,
|
||||
dashboardUrl,
|
||||
credentials
|
||||
}: RegenerateCredentialsModalProps) {
|
||||
const t = useTranslations();
|
||||
const [stage, setStage] = useState<"confirm" | "show">("confirm");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await onConfirmRegenerate();
|
||||
setStage("show");
|
||||
} catch (error) {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setStage("confirm");
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const getTitle = () => {
|
||||
if (stage === "confirm") {
|
||||
return t("regeneratecredentials");
|
||||
}
|
||||
switch (type) {
|
||||
case "site-wireguard":
|
||||
return t("WgConfiguration");
|
||||
case "site-newt":
|
||||
return t("siteNewtCredentials");
|
||||
case "client-olm":
|
||||
return t("clientOlmCredentials");
|
||||
case "remote-exit-node":
|
||||
return t("remoteExitNodeCreate.generate.title");
|
||||
}
|
||||
};
|
||||
|
||||
const getDescription = () => {
|
||||
if (stage === "confirm") {
|
||||
return t("regenerateCredentialsWarning");
|
||||
}
|
||||
switch (type) {
|
||||
case "site-wireguard":
|
||||
return t("WgConfigurationDescription");
|
||||
case "site-newt":
|
||||
return t("siteNewtCredentialsDescription");
|
||||
case "client-olm":
|
||||
return t("clientOlmCredentialsDescription");
|
||||
case "remote-exit-node":
|
||||
return t("remoteExitNodeCreate.generate.description");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Credenza open={open} onOpenChange={onOpenChange}>
|
||||
<CredenzaContent className="max-h-[80vh] flex flex-col">
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{getTitle()}</CredenzaTitle>
|
||||
<CredenzaDescription>{getDescription()}</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
|
||||
<CredenzaBody className="overflow-y-auto px-4">
|
||||
{stage === "confirm" ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
{t("warning")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("regenerateCredentialsConfirmation")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
{credentials?.wgConfig && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<CopyTextBox text={credentials.wgConfig} />
|
||||
<div className="relative w-fit border rounded-md">
|
||||
<div className="bg-white p-6 rounded-md">
|
||||
<QRCodeCanvas
|
||||
value={credentials.wgConfig}
|
||||
size={168}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert variant="neutral">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
{t("copyandsavethesecredentials")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("copyandsavethesecredentialsdescription")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{credentials?.Id && credentials.Secret && (
|
||||
<div className="space-y-4">
|
||||
<InfoSections cols={1}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("endpoint")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CopyToClipboard text={dashboardUrl} />
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("Id")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CopyToClipboard text={credentials?.Id} />
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("SecretKey")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CopyToClipboard text={credentials?.Secret} />
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</InfoSections>
|
||||
<Alert variant="neutral">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
{t("copyandsavethesecredentials")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("copyandsavethesecredentialsdescription")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CredenzaBody>
|
||||
|
||||
<CredenzaFooter>
|
||||
{stage === "confirm" ? (
|
||||
<>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("cancel")}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
variant="destructive"
|
||||
>
|
||||
{t("confirm")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button onClick={handleClose} className="w-full">
|
||||
{t("close")}
|
||||
</Button>
|
||||
)}
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
@@ -47,7 +47,7 @@ import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const requestSchema = z.object({
|
||||
email: z.string().email()
|
||||
email: z.email()
|
||||
});
|
||||
|
||||
export type ResetPasswordFormProps = {
|
||||
@@ -88,7 +88,7 @@ export default function ResetPasswordForm({
|
||||
|
||||
const formSchema = z
|
||||
.object({
|
||||
email: z.string().email({ message: t('emailInvalid') }),
|
||||
email: z.email({ message: t('emailInvalid') }),
|
||||
token: z.string().min(8, { message: t('tokenInvalid') }),
|
||||
password: passwordSchema,
|
||||
confirmPassword: passwordSchema
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { InfoIcon, ShieldCheck, ShieldOff } from "lucide-react";
|
||||
import { ShieldCheck, ShieldOff, Eye, EyeOff } from "lucide-react";
|
||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import {
|
||||
@@ -17,21 +17,30 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
|
||||
type ResourceInfoBoxType = {};
|
||||
|
||||
export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
const { resource, authInfo } = useResourceContext();
|
||||
export default function ResourceInfoBox({ }: ResourceInfoBoxType) {
|
||||
const { resource, authInfo, updateResource } = useResourceContext();
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const fullUrl = `${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`;
|
||||
|
||||
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{/* 4 cols because of the certs */}
|
||||
<InfoSections
|
||||
cols={resource.http && env.flags.usePangolinDns ? 4 : 3}
|
||||
cols={resource.http && env.flags.usePangolinDns ? 5 : 4}
|
||||
>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("identifier")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{resource.niceId}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
{resource.http ? (
|
||||
<>
|
||||
<InfoSection>
|
||||
@@ -40,17 +49,17 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{authInfo.password ||
|
||||
authInfo.pincode ||
|
||||
authInfo.sso ||
|
||||
authInfo.whitelist ||
|
||||
authInfo.headerAuth ? (
|
||||
<div className="flex items-start space-x-2 text-green-500">
|
||||
<ShieldCheck className="w-4 h-4 mt-0.5" />
|
||||
authInfo.pincode ||
|
||||
authInfo.sso ||
|
||||
authInfo.whitelist ||
|
||||
authInfo.headerAuth ? (
|
||||
<div className="flex items-start space-x-2">
|
||||
<ShieldCheck className="w-4 h-4 mt-0.5 flex-shrink-0 text-green-500" />
|
||||
<span>{t("protected")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2 text-yellow-500">
|
||||
<ShieldOff className="w-4 h-4" />
|
||||
<ShieldOff className="w-4 h-4 flex-shrink-0" />
|
||||
<span>{t("notProtected")}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -91,9 +100,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
{t("protocol")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<span>
|
||||
{resource.protocol.toUpperCase()}
|
||||
</span>
|
||||
{resource.protocol.toUpperCase()}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
@@ -155,11 +162,17 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("visibility")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<span>
|
||||
{resource.enabled
|
||||
? t("enabled")
|
||||
: t("disabled")}
|
||||
</span>
|
||||
{resource.enabled ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Eye className="w-4 h-4 flex-shrink-0 text-green-500" />
|
||||
<span>{t("enabled")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<EyeOff className="w-4 h-4 flex-shrink-0 text-neutral-500" />
|
||||
<span>{t("disabled")}</span>
|
||||
</div>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</InfoSections>
|
||||
|
||||
@@ -18,9 +18,9 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuCheckboxItem
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
@@ -153,7 +153,7 @@ function StatusIcon({
|
||||
case "offline":
|
||||
return <XCircle className={`${iconClass} text-destructive`} />;
|
||||
case "unknown":
|
||||
return <Clock className={`${iconClass} text-gray-400`} />;
|
||||
return <Clock className={`${iconClass} text-muted-foreground`} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -491,8 +491,8 @@ export default function ResourcesTable({
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusIcon status="unknown" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
No targets
|
||||
<span className="text-sm">
|
||||
{t("resourcesTableNoTargets")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -511,14 +511,14 @@ export default function ResourcesTable({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex items-center gap-2 h-8"
|
||||
className="flex items-center gap-2 h-8 px-0 font-normal"
|
||||
>
|
||||
<StatusIcon status={overallStatus} />
|
||||
<span className="text-sm">
|
||||
{overallStatus === "online" && "Healthy"}
|
||||
{overallStatus === "degraded" && "Degraded"}
|
||||
{overallStatus === "offline" && "Offline"}
|
||||
{overallStatus === "unknown" && "Unknown"}
|
||||
{overallStatus === "online" && t("resourcesTableHealthy")}
|
||||
{overallStatus === "degraded" && t("resourcesTableDegraded")}
|
||||
{overallStatus === "offline" && t("resourcesTableOffline")}
|
||||
{overallStatus === "unknown" && t("resourcesTableUnknown")}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
@@ -572,8 +572,8 @@ export default function ResourcesTable({
|
||||
</div>
|
||||
<span className="text-muted-foreground">
|
||||
{!target.enabled
|
||||
? "Disabled"
|
||||
: "Not monitored"}
|
||||
? t("disabled")
|
||||
: t("resourcesTableNotMonitored")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
@@ -606,6 +606,7 @@ export default function ResourcesTable({
|
||||
{
|
||||
accessorKey: "nice",
|
||||
friendlyName: t("resource"),
|
||||
enableHiding: true,
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
@@ -719,13 +720,13 @@ export default function ResourcesTable({
|
||||
return (
|
||||
<div>
|
||||
{resourceRow.authState === "protected" ? (
|
||||
<span className="text-green-500 flex items-center space-x-2">
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
<span className="flex items-center space-x-2">
|
||||
<ShieldCheck className="w-4 h-4 text-green-500" />
|
||||
<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 className="flex items-center space-x-2">
|
||||
<ShieldOff className="w-4 h-4 text-yellow-500" />
|
||||
<span>{t("notProtected")}</span>
|
||||
</span>
|
||||
) : (
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { useSiteContext } from "@app/hooks/useSiteContext";
|
||||
import {
|
||||
InfoSection,
|
||||
@@ -12,9 +11,10 @@ import {
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
|
||||
|
||||
type SiteInfoCardProps = {};
|
||||
|
||||
export default function SiteInfoCard({}: SiteInfoCardProps) {
|
||||
export default function SiteInfoCard({ }: SiteInfoCardProps) {
|
||||
const { site, updateSite } = useSiteContext();
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
@@ -31,10 +31,19 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
<InfoSections cols={env.flags.enableClients ? 3 : 2}>
|
||||
<InfoSections cols={env.flags.enableClients ? 4 : 3}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("identifier")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{site.niceId}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
{(site.type == "newt" || site.type == "wireguard") && (
|
||||
<>
|
||||
<InfoSection>
|
||||
|
||||
@@ -167,31 +167,6 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "nice",
|
||||
friendlyName: t("site"),
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
className="hidden md:flex whitespace-nowrap"
|
||||
>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "mbIn",
|
||||
friendlyName: t("dataIn"),
|
||||
|
||||
@@ -74,8 +74,12 @@ export default function SupporterStatus({ isCollapsed = false }: SupporterStatus
|
||||
const formSchema = z.object({
|
||||
githubUsername: z
|
||||
.string()
|
||||
.nonempty({ message: "GitHub username is required" }),
|
||||
key: z.string().nonempty({ message: "Supporter key is required" })
|
||||
.nonempty({
|
||||
error: "GitHub username is required"
|
||||
}),
|
||||
key: z.string().nonempty({
|
||||
error: "Supporter key is required"
|
||||
})
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
|
||||
@@ -74,7 +74,7 @@ export default function VerifyEmailForm({
|
||||
}
|
||||
|
||||
const FormSchema = z.object({
|
||||
email: z.string().email({ message: t("emailInvalid") }),
|
||||
email: z.email({ message: t("emailInvalid") }),
|
||||
pin: z.string().min(8, {
|
||||
message: t("verificationCodeLengthRequirements")
|
||||
})
|
||||
|
||||
29
src/components/react-query-provider.tsx
Normal file
29
src/components/react-query-provider.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
import * as React from "react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
export type ReactQueryProviderProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function ReactQueryProvider({ children }: ReactQueryProviderProps) {
|
||||
const [queryClient] = React.useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 2, // retry twice by default
|
||||
staleTime: 5 * 60 * 1_000 // 5 minutes
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
<ReactQueryDevtools position="bottom" />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,8 @@ const badgeVariants = cva(
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground",
|
||||
outlinePrimary: "border-transparent bg-transparent border-primary text-primary",
|
||||
outlinePrimary:
|
||||
"border-transparent bg-transparent border-primary text-primary",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground",
|
||||
destructive:
|
||||
@@ -18,12 +19,12 @@ const badgeVariants = cva(
|
||||
outline: "text-foreground",
|
||||
green: "border-green-600 bg-green-500/20 text-green-700 dark:text-green-300",
|
||||
yellow: "border-yellow-600 bg-yellow-500/20 text-yellow-700 dark:text-yellow-300",
|
||||
red: "border-red-400 bg-red-300/20 text-red-600 dark:text-red-300",
|
||||
},
|
||||
red: "border-red-400 bg-red-300/20 text-red-600 dark:text-red-300"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
variant: "default"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user