mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-11 23:46:50 +00:00
Merge branch 'dev' into refactor/save-button-positions
This commit is contained in:
@@ -269,7 +269,7 @@ export default function UsersTable({ users }: Props) {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{r.type !== "internal" && (
|
||||
{r.type === "internal" && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
generatePasswordResetCode(r.id);
|
||||
|
||||
432
src/components/AuthPageBrandingForm.tsx
Normal file
432
src/components/AuthPageBrandingForm.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useActionState, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import z from "zod";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "./Settings";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import type { GetLoginPageBrandingResponse } from "@server/routers/loginPage/types";
|
||||
import { Input } from "./ui/input";
|
||||
import { ExternalLink, InfoIcon, XIcon } from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { build } from "@server/build";
|
||||
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
|
||||
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
||||
|
||||
export type AuthPageCustomizationProps = {
|
||||
orgId: string;
|
||||
branding: GetLoginPageBrandingResponse | null;
|
||||
};
|
||||
|
||||
const AuthPageFormSchema = z.object({
|
||||
logoUrl: z.url().refine(
|
||||
async (url) => {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
return (
|
||||
response.status === 200 &&
|
||||
(response.headers.get("content-type") ?? "").startsWith(
|
||||
"image/"
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{
|
||||
error: "Invalid logo URL, must be a valid image URL"
|
||||
}
|
||||
),
|
||||
logoWidth: z.coerce.number<number>().min(1),
|
||||
logoHeight: z.coerce.number<number>().min(1),
|
||||
orgTitle: z.string().optional(),
|
||||
orgSubtitle: z.string().optional(),
|
||||
resourceTitle: z.string(),
|
||||
resourceSubtitle: z.string().optional(),
|
||||
primaryColor: z
|
||||
.string()
|
||||
.regex(/^#([0-9a-f]{6}|[0-9a-f]{3})$/i)
|
||||
.optional()
|
||||
});
|
||||
|
||||
export default function AuthPageBrandingForm({
|
||||
orgId,
|
||||
branding
|
||||
}: AuthPageCustomizationProps) {
|
||||
const env = useEnvContext();
|
||||
const api = createApiClient(env);
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const [, updateFormAction, isUpdatingBranding] = useActionState(
|
||||
updateBranding,
|
||||
null
|
||||
);
|
||||
const [, deleteFormAction, isDeletingBranding] = useActionState(
|
||||
deleteBranding,
|
||||
null
|
||||
);
|
||||
const [setIsDeleteModalOpen] = useState(false);
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(AuthPageFormSchema),
|
||||
defaultValues: {
|
||||
logoUrl: branding?.logoUrl ?? "",
|
||||
logoWidth: branding?.logoWidth ?? 100,
|
||||
logoHeight: branding?.logoHeight ?? 100,
|
||||
orgTitle: branding?.orgTitle ?? `Log in to {{orgName}}`,
|
||||
orgSubtitle: branding?.orgSubtitle ?? `Log in to {{orgName}}`,
|
||||
resourceTitle:
|
||||
branding?.resourceTitle ??
|
||||
`Authenticate to access {{resourceName}}`,
|
||||
resourceSubtitle:
|
||||
branding?.resourceSubtitle ??
|
||||
`Choose your preferred authentication method for {{resourceName}}`,
|
||||
primaryColor: branding?.primaryColor ?? `#f36117` // default pangolin primary color
|
||||
},
|
||||
disabled: !isPaidUser
|
||||
});
|
||||
|
||||
async function updateBranding() {
|
||||
const isValid = await form.trigger();
|
||||
const brandingData = form.getValues();
|
||||
|
||||
if (!isValid || !isPaidUser) return;
|
||||
try {
|
||||
const updateRes = await api.put(
|
||||
`/org/${orgId}/login-page-branding`,
|
||||
{
|
||||
...brandingData
|
||||
}
|
||||
);
|
||||
|
||||
if (updateRes.status === 200 || updateRes.status === 201) {
|
||||
router.refresh();
|
||||
toast({
|
||||
variant: "default",
|
||||
title: t("success"),
|
||||
description: t("authPageBrandingUpdated")
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("authPageErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
error,
|
||||
t("authPageErrorUpdateMessage")
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteBranding() {
|
||||
if (!isPaidUser) return;
|
||||
|
||||
try {
|
||||
const updateRes = await api.delete(
|
||||
`/org/${orgId}/login-page-branding`
|
||||
);
|
||||
|
||||
if (updateRes.status === 200) {
|
||||
router.refresh();
|
||||
form.reset();
|
||||
|
||||
toast({
|
||||
variant: "default",
|
||||
title: t("success"),
|
||||
description: t("authPageBrandingRemoved")
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("authPageErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
error,
|
||||
t("authPageErrorUpdateMessage")
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("authPageBranding")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("authPageBrandingDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<PaidFeaturesAlert />
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
action={updateFormAction}
|
||||
id="auth-page-branding-form"
|
||||
className="flex flex-col space-y-4 items-stretch"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="primaryColor"
|
||||
render={({ field }) => (
|
||||
<FormItem className="">
|
||||
<FormLabel>
|
||||
{t("brandingPrimaryColor")}
|
||||
</FormLabel>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<label
|
||||
className="size-8 rounded-sm"
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
backgroundColor:
|
||||
field.value
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
{...field}
|
||||
className="sr-only"
|
||||
/>
|
||||
</label>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid md:grid-cols-5 gap-3 items-start">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="logoUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-3">
|
||||
<FormLabel>
|
||||
{t("brandingLogoURL")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="md:col-span-2 flex gap-3 items-start">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="logoWidth"
|
||||
render={({ field }) => (
|
||||
<FormItem className="grow">
|
||||
<FormLabel>
|
||||
{t("brandingLogoWidth")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<span className="relative top-8">
|
||||
<XIcon className="text-muted-foreground size-4" />
|
||||
</span>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="logoHeight"
|
||||
render={({ field }) => (
|
||||
<FormItem className="grow">
|
||||
<FormLabel>
|
||||
{t(
|
||||
"brandingLogoHeight"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{build === "saas" && (
|
||||
<>
|
||||
<div className="mt-3 mb-6">
|
||||
<SettingsSectionTitle>
|
||||
{t(
|
||||
"organizationLoginPageTitle"
|
||||
)}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t(
|
||||
"organizationLoginPageDescription"
|
||||
)}
|
||||
</SettingsSectionDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="orgTitle"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-3">
|
||||
<FormLabel>
|
||||
{t(
|
||||
"brandingOrgTitle"
|
||||
)}
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="orgSubtitle"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-3">
|
||||
<FormLabel>
|
||||
{t(
|
||||
"brandingOrgSubtitle"
|
||||
)}
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-3 mb-6">
|
||||
<SettingsSectionTitle>
|
||||
{t("resourceLoginPageTitle")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("resourceLoginPageDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="resourceTitle"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-3">
|
||||
<FormLabel>
|
||||
{t("brandingResourceTitle")}
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="resourceSubtitle"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-3">
|
||||
<FormLabel>
|
||||
{t(
|
||||
"brandingResourceSubtitle"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-6 items-center">
|
||||
{branding && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
type="button"
|
||||
loading={isUpdatingBranding || isDeletingBranding}
|
||||
disabled={
|
||||
isUpdatingBranding ||
|
||||
isDeletingBranding ||
|
||||
!isPaidUser
|
||||
}
|
||||
onClick={() => {
|
||||
deleteFormAction();
|
||||
}}
|
||||
className="gap-1"
|
||||
>
|
||||
{t("removeAuthPageBranding")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
form="auth-page-branding-form"
|
||||
loading={isUpdatingBranding || isDeletingBranding}
|
||||
disabled={
|
||||
isUpdatingBranding ||
|
||||
isDeletingBranding ||
|
||||
!isPaidUser
|
||||
}
|
||||
>
|
||||
{t("saveAuthPageBranding")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type BrandingLogoProps = {
|
||||
logoPath?: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
@@ -41,13 +42,16 @@ export default function BrandingLogo(props: BrandingLogoProps) {
|
||||
return "/logo/word_mark_white.png";
|
||||
}
|
||||
|
||||
const path = getPath();
|
||||
setPath(path);
|
||||
}, [theme, env]);
|
||||
setPath(props.logoPath ?? getPath());
|
||||
}, [theme, env, props.logoPath]);
|
||||
|
||||
// we use `img` tag here because the `logoPath` could be any URL
|
||||
// and next.js `Image` component only accepts a restricted number of domains
|
||||
const Component = props.logoPath ? "img" : Image;
|
||||
|
||||
return (
|
||||
path && (
|
||||
<Image
|
||||
<Component
|
||||
src={path}
|
||||
alt="Logo"
|
||||
width={props.width}
|
||||
|
||||
@@ -41,6 +41,9 @@ export type InternalResourceRow = {
|
||||
// destinationPort: number | null;
|
||||
alias: string | null;
|
||||
niceId: string;
|
||||
tcpPortRangeString: string | null;
|
||||
udpPortRangeString: string | null;
|
||||
disableIcmp: boolean;
|
||||
};
|
||||
|
||||
type ClientResourcesTableProps = {
|
||||
|
||||
@@ -6,43 +6,22 @@ import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { useToast } from "@app/hooks/useToast";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
InviteUserBody,
|
||||
InviteUserResponse,
|
||||
ListUsersResponse
|
||||
} from "@server/routers/user";
|
||||
import { AxiosResponse } from "axios";
|
||||
import React, { useState } from "react";
|
||||
import React, { useActionState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import CopyTextBox from "@app/components/CopyTextBox";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { Description } from "@radix-ui/react-toast";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
import CopyToClipboard from "./CopyToClipboard";
|
||||
|
||||
@@ -57,7 +36,7 @@ type InviteUserFormProps = {
|
||||
warningText?: string;
|
||||
};
|
||||
|
||||
export default function InviteUserForm({
|
||||
export default function ConfirmDeleteDialog({
|
||||
open,
|
||||
setOpen,
|
||||
string,
|
||||
@@ -67,9 +46,7 @@ export default function InviteUserForm({
|
||||
dialog,
|
||||
warningText
|
||||
}: InviteUserFormProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
const [, formAction, loading] = useActionState(onSubmit, null);
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
@@ -86,21 +63,14 @@ export default function InviteUserForm({
|
||||
}
|
||||
});
|
||||
|
||||
function reset() {
|
||||
form.reset();
|
||||
}
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
setLoading(true);
|
||||
async function onSubmit() {
|
||||
try {
|
||||
await onConfirm();
|
||||
setOpen(false);
|
||||
reset();
|
||||
form.reset();
|
||||
} catch (error) {
|
||||
// Handle error if needed
|
||||
console.error("Confirmation failed:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +80,7 @@ export default function InviteUserForm({
|
||||
open={open}
|
||||
onOpenChange={(val) => {
|
||||
setOpen(val);
|
||||
reset();
|
||||
form.reset();
|
||||
}}
|
||||
>
|
||||
<CredenzaContent>
|
||||
@@ -136,7 +106,7 @@ export default function InviteUserForm({
|
||||
</div>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
action={formAction}
|
||||
className="space-y-4"
|
||||
id="confirm-delete-form"
|
||||
>
|
||||
@@ -146,7 +116,12 @@ export default function InviteUserForm({
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
<Input
|
||||
{...field}
|
||||
placeholder={t(
|
||||
"enterConfirmation"
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -42,15 +42,14 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ListClientsResponse } from "@server/routers/client/listClients";
|
||||
import { ListSitesResponse } from "@server/routers/site";
|
||||
import { ListUsersResponse } from "@server/routers/user";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AxiosResponse } from "axios";
|
||||
@@ -59,6 +58,82 @@ import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
// import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
|
||||
// Helper to validate port range string format
|
||||
const isValidPortRangeString = (val: string | undefined | null): boolean => {
|
||||
if (!val || val.trim() === "" || val.trim() === "*") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const parts = val.split(",").map((p) => p.trim());
|
||||
|
||||
for (const part of parts) {
|
||||
if (part === "") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (part.includes("-")) {
|
||||
const [start, end] = part.split("-").map((p) => p.trim());
|
||||
if (!start || !end) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const startPort = parseInt(start, 10);
|
||||
const endPort = parseInt(end, 10);
|
||||
|
||||
if (isNaN(startPort) || isNaN(endPort)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (startPort < 1 || startPort > 65535 || endPort < 1 || endPort > 65535) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (startPort > endPort) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
const port = parseInt(part, 10);
|
||||
if (isNaN(port)) {
|
||||
return false;
|
||||
}
|
||||
if (port < 1 || port > 65535) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Port range string schema for client-side validation
|
||||
const portRangeStringSchema = z
|
||||
.string()
|
||||
.optional()
|
||||
.nullable()
|
||||
.refine(
|
||||
(val) => isValidPortRangeString(val),
|
||||
{
|
||||
message:
|
||||
'Port range must be "*" for all ports, or a comma-separated list of ports and ranges (e.g., "80,443,8000-9000"). Ports must be between 1 and 65535.'
|
||||
}
|
||||
);
|
||||
|
||||
// Helper to determine the port mode from a port range string
|
||||
type PortMode = "all" | "blocked" | "custom";
|
||||
const getPortModeFromString = (val: string | undefined | null): PortMode => {
|
||||
if (val === "*") return "all";
|
||||
if (!val || val.trim() === "") return "blocked";
|
||||
return "custom";
|
||||
};
|
||||
|
||||
// Helper to get the port string for API from mode and custom value
|
||||
const getPortStringFromMode = (mode: PortMode, customValue: string): string | undefined => {
|
||||
if (mode === "all") return "*";
|
||||
if (mode === "blocked") return "";
|
||||
return customValue;
|
||||
};
|
||||
|
||||
type Site = ListSitesResponse["sites"][0];
|
||||
|
||||
@@ -103,6 +178,9 @@ export default function CreateInternalResourceDialog({
|
||||
// .max(65535, t("createInternalResourceDialogDestinationPortMax"))
|
||||
// .nullish(),
|
||||
alias: z.string().nullish(),
|
||||
tcpPortRangeString: portRangeStringSchema,
|
||||
udpPortRangeString: portRangeStringSchema,
|
||||
disableIcmp: z.boolean().optional(),
|
||||
roles: z
|
||||
.array(
|
||||
z.object({
|
||||
@@ -209,6 +287,12 @@ export default function CreateInternalResourceDialog({
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
// Port restriction UI state - default to "all" (*) for new resources
|
||||
const [tcpPortMode, setTcpPortMode] = useState<PortMode>("all");
|
||||
const [udpPortMode, setUdpPortMode] = useState<PortMode>("all");
|
||||
const [tcpCustomPorts, setTcpCustomPorts] = useState<string>("");
|
||||
const [udpCustomPorts, setUdpCustomPorts] = useState<string>("");
|
||||
|
||||
const availableSites = sites.filter(
|
||||
(site) => site.type === "newt" && site.subnet
|
||||
);
|
||||
@@ -224,6 +308,9 @@ export default function CreateInternalResourceDialog({
|
||||
destination: "",
|
||||
// destinationPort: undefined,
|
||||
alias: "",
|
||||
tcpPortRangeString: "*",
|
||||
udpPortRangeString: "*",
|
||||
disableIcmp: false,
|
||||
roles: [],
|
||||
users: [],
|
||||
clients: []
|
||||
@@ -232,6 +319,17 @@ export default function CreateInternalResourceDialog({
|
||||
|
||||
const mode = form.watch("mode");
|
||||
|
||||
// Update form values when port mode or custom ports change
|
||||
useEffect(() => {
|
||||
const tcpValue = getPortStringFromMode(tcpPortMode, tcpCustomPorts);
|
||||
form.setValue("tcpPortRangeString", tcpValue);
|
||||
}, [tcpPortMode, tcpCustomPorts, form]);
|
||||
|
||||
useEffect(() => {
|
||||
const udpValue = getPortStringFromMode(udpPortMode, udpCustomPorts);
|
||||
form.setValue("udpPortRangeString", udpValue);
|
||||
}, [udpPortMode, udpCustomPorts, form]);
|
||||
|
||||
// Helper function to check if destination contains letters (hostname vs IP)
|
||||
const isHostname = (destination: string): boolean => {
|
||||
return /[a-zA-Z]/.test(destination);
|
||||
@@ -258,10 +356,18 @@ export default function CreateInternalResourceDialog({
|
||||
destination: "",
|
||||
// destinationPort: undefined,
|
||||
alias: "",
|
||||
tcpPortRangeString: "*",
|
||||
udpPortRangeString: "*",
|
||||
disableIcmp: false,
|
||||
roles: [],
|
||||
users: [],
|
||||
clients: []
|
||||
});
|
||||
// Reset port mode state
|
||||
setTcpPortMode("all");
|
||||
setUdpPortMode("all");
|
||||
setTcpCustomPorts("");
|
||||
setUdpCustomPorts("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
@@ -304,6 +410,9 @@ export default function CreateInternalResourceDialog({
|
||||
data.alias.trim()
|
||||
? data.alias
|
||||
: undefined,
|
||||
tcpPortRangeString: data.tcpPortRangeString,
|
||||
udpPortRangeString: data.udpPortRangeString,
|
||||
disableIcmp: data.disableIcmp ?? false,
|
||||
roleIds: data.roles
|
||||
? data.roles.map((r) => parseInt(r.id))
|
||||
: [],
|
||||
@@ -727,6 +836,163 @@ export default function CreateInternalResourceDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Port Restrictions Section */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{t("portRestrictions")}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{/* TCP Ports */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tcpPortRangeString"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel className="min-w-10">
|
||||
TCP
|
||||
</FormLabel>
|
||||
{/*<InfoPopup
|
||||
info={t("tcpPortsDescription")}
|
||||
/>*/}
|
||||
<Select
|
||||
value={tcpPortMode}
|
||||
onValueChange={(value: PortMode) => {
|
||||
setTcpPortMode(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[110px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t("allPorts")}
|
||||
</SelectItem>
|
||||
<SelectItem value="blocked">
|
||||
{t("blocked")}
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
{t("custom")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{tcpPortMode === "custom" ? (
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="80,443,8000-9000"
|
||||
value={tcpCustomPorts}
|
||||
onChange={(e) =>
|
||||
setTcpCustomPorts(e.target.value)
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
</FormControl>
|
||||
) : (
|
||||
<Input
|
||||
disabled
|
||||
placeholder={
|
||||
tcpPortMode === "all"
|
||||
? t("allPortsAllowed")
|
||||
: t("allPortsBlocked")
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* UDP Ports */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="udpPortRangeString"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel className="min-w-10">
|
||||
UDP
|
||||
</FormLabel>
|
||||
{/*<InfoPopup
|
||||
info={t("udpPortsDescription")}
|
||||
/>*/}
|
||||
<Select
|
||||
value={udpPortMode}
|
||||
onValueChange={(value: PortMode) => {
|
||||
setUdpPortMode(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[110px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t("allPorts")}
|
||||
</SelectItem>
|
||||
<SelectItem value="blocked">
|
||||
{t("blocked")}
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
{t("custom")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{udpPortMode === "custom" ? (
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="53,123,500-600"
|
||||
value={udpCustomPorts}
|
||||
onChange={(e) =>
|
||||
setUdpCustomPorts(e.target.value)
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
</FormControl>
|
||||
) : (
|
||||
<Input
|
||||
disabled
|
||||
placeholder={
|
||||
udpPortMode === "all"
|
||||
? t("allPortsAllowed")
|
||||
: t("allPortsBlocked")
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* ICMP Toggle */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="disableIcmp"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel className="min-w-10">
|
||||
ICMP
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={!field.value}
|
||||
onCheckedChange={(checked) => field.onChange(!checked)}
|
||||
/>
|
||||
</FormControl>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{field.value ? t("blocked") : t("allowed")}
|
||||
</span>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Access Control Section */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
|
||||
@@ -179,7 +179,7 @@ const CredenzaFooter = ({ className, children, ...props }: CredenzaProps) => {
|
||||
return (
|
||||
<CredenzaFooter
|
||||
className={cn(
|
||||
"mt-8 md:mt-0 -mx-6 px-6 pt-6 border-t border-border",
|
||||
"mt-8 md:mt-0 -mx-6 px-6 pt-4 border-t border-border",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -94,6 +94,12 @@ export default function DomainPicker({
|
||||
const api = createApiClient({ env });
|
||||
const t = useTranslations();
|
||||
|
||||
console.log({
|
||||
defaultFullDomain,
|
||||
defaultSubdomain,
|
||||
defaultDomainId
|
||||
});
|
||||
|
||||
const { data = [], isLoading: loadingDomains } = useQuery(
|
||||
orgQueries.domains({ orgId })
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
@@ -36,17 +37,86 @@ import { toast } from "@app/hooks/useToast";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { ListRolesResponse } from "@server/routers/role";
|
||||
import { ListUsersResponse } from "@server/routers/user";
|
||||
import { ListSiteResourceRolesResponse } from "@server/routers/siteResource/listSiteResourceRoles";
|
||||
import { ListSiteResourceUsersResponse } from "@server/routers/siteResource/listSiteResourceUsers";
|
||||
import { ListSiteResourceClientsResponse } from "@server/routers/siteResource/listSiteResourceClients";
|
||||
import { ListClientsResponse } from "@server/routers/client/listClients";
|
||||
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { orgQueries, resourceQueries } from "@app/lib/queries";
|
||||
// import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
|
||||
// Helper to validate port range string format
|
||||
const isValidPortRangeString = (val: string | undefined | null): boolean => {
|
||||
if (!val || val.trim() === "" || val.trim() === "*") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const parts = val.split(",").map((p) => p.trim());
|
||||
|
||||
for (const part of parts) {
|
||||
if (part === "") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (part.includes("-")) {
|
||||
const [start, end] = part.split("-").map((p) => p.trim());
|
||||
if (!start || !end) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const startPort = parseInt(start, 10);
|
||||
const endPort = parseInt(end, 10);
|
||||
|
||||
if (isNaN(startPort) || isNaN(endPort)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (startPort < 1 || startPort > 65535 || endPort < 1 || endPort > 65535) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (startPort > endPort) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
const port = parseInt(part, 10);
|
||||
if (isNaN(port)) {
|
||||
return false;
|
||||
}
|
||||
if (port < 1 || port > 65535) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Port range string schema for client-side validation
|
||||
const portRangeStringSchema = z
|
||||
.string()
|
||||
.optional()
|
||||
.nullable()
|
||||
.refine(
|
||||
(val) => isValidPortRangeString(val),
|
||||
{
|
||||
message:
|
||||
'Port range must be "*" for all ports, or a comma-separated list of ports and ranges (e.g., "80,443,8000-9000"). Ports must be between 1 and 65535.'
|
||||
}
|
||||
);
|
||||
|
||||
// Helper to determine the port mode from a port range string
|
||||
type PortMode = "all" | "blocked" | "custom";
|
||||
const getPortModeFromString = (val: string | undefined | null): PortMode => {
|
||||
if (val === "*") return "all";
|
||||
if (!val || val.trim() === "") return "blocked";
|
||||
return "custom";
|
||||
};
|
||||
|
||||
// Helper to get the port string for API from mode and custom value
|
||||
const getPortStringFromMode = (mode: PortMode, customValue: string): string | undefined => {
|
||||
if (mode === "all") return "*";
|
||||
if (mode === "blocked") return "";
|
||||
return customValue;
|
||||
};
|
||||
|
||||
type InternalResourceData = {
|
||||
id: number;
|
||||
@@ -61,6 +131,9 @@ type InternalResourceData = {
|
||||
destination: string;
|
||||
// destinationPort?: number | null;
|
||||
alias?: string | null;
|
||||
tcpPortRangeString?: string | null;
|
||||
udpPortRangeString?: string | null;
|
||||
disableIcmp?: boolean;
|
||||
};
|
||||
|
||||
type EditInternalResourceDialogProps = {
|
||||
@@ -94,6 +167,9 @@ export default function EditInternalResourceDialog({
|
||||
destination: z.string().min(1),
|
||||
// destinationPort: z.int().positive().min(1, t("editInternalResourceDialogDestinationPortMin")).max(65535, t("editInternalResourceDialogDestinationPortMax")).nullish(),
|
||||
alias: z.string().nullish(),
|
||||
tcpPortRangeString: portRangeStringSchema,
|
||||
udpPortRangeString: portRangeStringSchema,
|
||||
disableIcmp: z.boolean().optional(),
|
||||
roles: z
|
||||
.array(
|
||||
z.object({
|
||||
@@ -255,6 +331,24 @@ export default function EditInternalResourceDialog({
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
// Port restriction UI state
|
||||
const [tcpPortMode, setTcpPortMode] = useState<PortMode>(
|
||||
getPortModeFromString(resource.tcpPortRangeString)
|
||||
);
|
||||
const [udpPortMode, setUdpPortMode] = useState<PortMode>(
|
||||
getPortModeFromString(resource.udpPortRangeString)
|
||||
);
|
||||
const [tcpCustomPorts, setTcpCustomPorts] = useState<string>(
|
||||
resource.tcpPortRangeString && resource.tcpPortRangeString !== "*"
|
||||
? resource.tcpPortRangeString
|
||||
: ""
|
||||
);
|
||||
const [udpCustomPorts, setUdpCustomPorts] = useState<string>(
|
||||
resource.udpPortRangeString && resource.udpPortRangeString !== "*"
|
||||
? resource.udpPortRangeString
|
||||
: ""
|
||||
);
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
@@ -265,6 +359,9 @@ export default function EditInternalResourceDialog({
|
||||
destination: resource.destination || "",
|
||||
// destinationPort: resource.destinationPort ?? undefined,
|
||||
alias: resource.alias ?? null,
|
||||
tcpPortRangeString: resource.tcpPortRangeString ?? "*",
|
||||
udpPortRangeString: resource.udpPortRangeString ?? "*",
|
||||
disableIcmp: resource.disableIcmp ?? false,
|
||||
roles: [],
|
||||
users: [],
|
||||
clients: []
|
||||
@@ -273,6 +370,17 @@ export default function EditInternalResourceDialog({
|
||||
|
||||
const mode = form.watch("mode");
|
||||
|
||||
// Update form values when port mode or custom ports change
|
||||
useEffect(() => {
|
||||
const tcpValue = getPortStringFromMode(tcpPortMode, tcpCustomPorts);
|
||||
form.setValue("tcpPortRangeString", tcpValue);
|
||||
}, [tcpPortMode, tcpCustomPorts, form]);
|
||||
|
||||
useEffect(() => {
|
||||
const udpValue = getPortStringFromMode(udpPortMode, udpCustomPorts);
|
||||
form.setValue("udpPortRangeString", udpValue);
|
||||
}, [udpPortMode, udpCustomPorts, form]);
|
||||
|
||||
// Helper function to check if destination contains letters (hostname vs IP)
|
||||
const isHostname = (destination: string): boolean => {
|
||||
return /[a-zA-Z]/.test(destination);
|
||||
@@ -327,6 +435,9 @@ export default function EditInternalResourceDialog({
|
||||
data.alias.trim()
|
||||
? data.alias
|
||||
: null,
|
||||
tcpPortRangeString: data.tcpPortRangeString,
|
||||
udpPortRangeString: data.udpPortRangeString,
|
||||
disableIcmp: data.disableIcmp ?? false,
|
||||
roleIds: (data.roles || []).map((r) => parseInt(r.id)),
|
||||
userIds: (data.users || []).map((u) => u.id),
|
||||
clientIds: (data.clients || []).map((c) => parseInt(c.id))
|
||||
@@ -396,10 +507,26 @@ export default function EditInternalResourceDialog({
|
||||
mode: resource.mode || "host",
|
||||
destination: resource.destination || "",
|
||||
alias: resource.alias ?? null,
|
||||
tcpPortRangeString: resource.tcpPortRangeString ?? "*",
|
||||
udpPortRangeString: resource.udpPortRangeString ?? "*",
|
||||
disableIcmp: resource.disableIcmp ?? false,
|
||||
roles: [],
|
||||
users: [],
|
||||
clients: []
|
||||
});
|
||||
// Reset port mode state
|
||||
setTcpPortMode(getPortModeFromString(resource.tcpPortRangeString));
|
||||
setUdpPortMode(getPortModeFromString(resource.udpPortRangeString));
|
||||
setTcpCustomPorts(
|
||||
resource.tcpPortRangeString && resource.tcpPortRangeString !== "*"
|
||||
? resource.tcpPortRangeString
|
||||
: ""
|
||||
);
|
||||
setUdpCustomPorts(
|
||||
resource.udpPortRangeString && resource.udpPortRangeString !== "*"
|
||||
? resource.udpPortRangeString
|
||||
: ""
|
||||
);
|
||||
previousResourceId.current = resource.id;
|
||||
}
|
||||
|
||||
@@ -438,10 +565,26 @@ export default function EditInternalResourceDialog({
|
||||
destination: resource.destination || "",
|
||||
// destinationPort: resource.destinationPort ?? undefined,
|
||||
alias: resource.alias ?? null,
|
||||
tcpPortRangeString: resource.tcpPortRangeString ?? "*",
|
||||
udpPortRangeString: resource.udpPortRangeString ?? "*",
|
||||
disableIcmp: resource.disableIcmp ?? false,
|
||||
roles: [],
|
||||
users: [],
|
||||
clients: []
|
||||
});
|
||||
// Reset port mode state
|
||||
setTcpPortMode(getPortModeFromString(resource.tcpPortRangeString));
|
||||
setUdpPortMode(getPortModeFromString(resource.udpPortRangeString));
|
||||
setTcpCustomPorts(
|
||||
resource.tcpPortRangeString && resource.tcpPortRangeString !== "*"
|
||||
? resource.tcpPortRangeString
|
||||
: ""
|
||||
);
|
||||
setUdpCustomPorts(
|
||||
resource.udpPortRangeString && resource.udpPortRangeString !== "*"
|
||||
? resource.udpPortRangeString
|
||||
: ""
|
||||
);
|
||||
// Reset previous resource ID to ensure clean state on next open
|
||||
previousResourceId.current = null;
|
||||
}
|
||||
@@ -674,6 +817,163 @@ export default function EditInternalResourceDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Port Restrictions Section */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{t("portRestrictions")}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{/* TCP Ports */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tcpPortRangeString"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel className="min-w-10">
|
||||
TCP
|
||||
</FormLabel>
|
||||
{/*<InfoPopup
|
||||
info={t("tcpPortsDescription")}
|
||||
/>*/}
|
||||
<Select
|
||||
value={tcpPortMode}
|
||||
onValueChange={(value: PortMode) => {
|
||||
setTcpPortMode(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[110px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t("allPorts")}
|
||||
</SelectItem>
|
||||
<SelectItem value="blocked">
|
||||
{t("blocked")}
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
{t("custom")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{tcpPortMode === "custom" ? (
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="80,443,8000-9000"
|
||||
value={tcpCustomPorts}
|
||||
onChange={(e) =>
|
||||
setTcpCustomPorts(e.target.value)
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
</FormControl>
|
||||
) : (
|
||||
<Input
|
||||
disabled
|
||||
placeholder={
|
||||
tcpPortMode === "all"
|
||||
? t("allPortsAllowed")
|
||||
: t("allPortsBlocked")
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* UDP Ports */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="udpPortRangeString"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel className="min-w-10">
|
||||
UDP
|
||||
</FormLabel>
|
||||
{/*<InfoPopup
|
||||
info={t("udpPortsDescription")}
|
||||
/>*/}
|
||||
<Select
|
||||
value={udpPortMode}
|
||||
onValueChange={(value: PortMode) => {
|
||||
setUdpPortMode(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[110px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t("allPorts")}
|
||||
</SelectItem>
|
||||
<SelectItem value="blocked">
|
||||
{t("blocked")}
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
{t("custom")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{udpPortMode === "custom" ? (
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="53,123,500-600"
|
||||
value={udpCustomPorts}
|
||||
onChange={(e) =>
|
||||
setUdpCustomPorts(e.target.value)
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
</FormControl>
|
||||
) : (
|
||||
<Input
|
||||
disabled
|
||||
placeholder={
|
||||
udpPortMode === "all"
|
||||
? t("allPortsAllowed")
|
||||
: t("allPortsBlocked")
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* ICMP Toggle */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="disableIcmp"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel className="min-w-10">
|
||||
ICMP
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={!field.value}
|
||||
onCheckedChange={(checked) => field.onChange(!checked)}
|
||||
/>
|
||||
</FormControl>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{field.value ? t("blocked") : t("allowed")}
|
||||
</span>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Access Control Section */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function ExitNodeInfoCard({}: ExitNodeInfoCardProps) {
|
||||
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription className="mt-4">
|
||||
<AlertDescription>
|
||||
<InfoSections cols={2}>
|
||||
<>
|
||||
<InfoSection>
|
||||
|
||||
49
src/components/ExitNodesDataTable.tsx
Normal file
49
src/components/ExitNodesDataTable.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
"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[];
|
||||
createRemoteExitNode?: () => void;
|
||||
onRefresh?: () => void;
|
||||
isRefreshing?: boolean;
|
||||
columnVisibility?: Record<string, boolean>;
|
||||
enableColumnVisibility?: boolean;
|
||||
}
|
||||
|
||||
export function ExitNodesDataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
createRemoteExitNode,
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
columnVisibility,
|
||||
enableColumnVisibility
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
title={t("remoteExitNodes")}
|
||||
searchPlaceholder={t("searchRemoteExitNodes")}
|
||||
searchColumn="name"
|
||||
onAdd={createRemoteExitNode}
|
||||
addButtonText={t("remoteExitNodeAdd")}
|
||||
onRefresh={onRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
defaultSort={{
|
||||
id: "name",
|
||||
desc: false
|
||||
}}
|
||||
columnVisibility={columnVisibility}
|
||||
enableColumnVisibility={enableColumnVisibility}
|
||||
stickyLeftColumn="name"
|
||||
stickyRightColumn="actions"
|
||||
/>
|
||||
);
|
||||
}
|
||||
339
src/components/ExitNodesTable.tsx
Normal file
339
src/components/ExitNodesTable.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||
import { ExitNodesDataTable } from "@app/components/ExitNodesDataTable";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
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 { useTranslations } from "next-intl";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
|
||||
export type RemoteExitNodeRow = {
|
||||
id: string;
|
||||
exitNodeId: number | null;
|
||||
name: string;
|
||||
address: string;
|
||||
endpoint: string;
|
||||
orgId: string;
|
||||
type: string | null;
|
||||
online: boolean;
|
||||
dateCreated: string;
|
||||
version?: string;
|
||||
};
|
||||
|
||||
type ExitNodesTableProps = {
|
||||
remoteExitNodes: RemoteExitNodeRow[];
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
export default function ExitNodesTable({
|
||||
remoteExitNodes,
|
||||
orgId
|
||||
}: ExitNodesTableProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selectedNode, setSelectedNode] = useState<RemoteExitNodeRow | null>(
|
||||
null
|
||||
);
|
||||
const [rows, setRows] = useState<RemoteExitNodeRow[]>(remoteExitNodes);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
const t = useTranslations();
|
||||
|
||||
useEffect(() => {
|
||||
setRows(remoteExitNodes);
|
||||
}, [remoteExitNodes]);
|
||||
|
||||
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 deleteRemoteExitNode = (remoteExitNodeId: string) => {
|
||||
api.delete(`/org/${orgId}/remote-exit-node/${remoteExitNodeId}`)
|
||||
.catch((e) => {
|
||||
console.error(t("remoteExitNodeErrorDelete"), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("remoteExitNodeErrorDelete"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("remoteExitNodeErrorDelete")
|
||||
)
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
setIsDeleteModalOpen(false);
|
||||
|
||||
const newRows = rows.filter(
|
||||
(row) => row.id !== remoteExitNodeId
|
||||
);
|
||||
setRows(newRows);
|
||||
});
|
||||
};
|
||||
|
||||
const columns: ExtendedColumnDef<RemoteExitNodeRow>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
friendlyName: t("name"),
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("name")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "online",
|
||||
friendlyName: t("online"),
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("online")}
|
||||
<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>{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>
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "type",
|
||||
friendlyName: t("connectionType"),
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("connectionType")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const originalRow = row.original;
|
||||
return (
|
||||
<Badge variant="secondary">
|
||||
{originalRow.type === "remoteExitNode"
|
||||
? "Remote Exit Node"
|
||||
: originalRow.type}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "address",
|
||||
friendlyName: "Address",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Address
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "endpoint",
|
||||
friendlyName: "Endpoint",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Endpoint
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "version",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Version
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const originalRow = row.original;
|
||||
return (
|
||||
<div className="flex items-center space-x-1">
|
||||
{originalRow.version && originalRow.version ? (
|
||||
<Badge variant="secondary">
|
||||
{"v" + originalRow.version}
|
||||
</Badge>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
header: () => <span className="p-3"></span>,
|
||||
cell: ({ row }) => {
|
||||
const nodeRow = row.original;
|
||||
const remoteExitNodeId = nodeRow.id;
|
||||
return (
|
||||
<div className="flex items-center gap-2 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={`/${nodeRow.orgId}/settings/remote-exit-nodes/${remoteExitNodeId}`}
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
{t("viewSettings")}
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedNode(nodeRow);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
{t("delete")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Link
|
||||
href={`/${nodeRow.orgId}/settings/remote-exit-nodes/${remoteExitNodeId}`}
|
||||
>
|
||||
<Button variant={"outline"}>
|
||||
{t("edit")}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedNode && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
setOpen={(val) => {
|
||||
setIsDeleteModalOpen(val);
|
||||
setSelectedNode(null);
|
||||
}}
|
||||
dialog={
|
||||
<div className="space-y-2">
|
||||
<p>{t("remoteExitNodeQuestionRemove")}</p>
|
||||
|
||||
<p>{t("remoteExitNodeMessageRemove")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t("remoteExitNodeConfirmDelete")}
|
||||
onConfirm={async () =>
|
||||
deleteRemoteExitNode(selectedNode!.id)
|
||||
}
|
||||
string={selectedNode.name}
|
||||
title={t("remoteExitNodeDelete")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ExitNodesDataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
createRemoteExitNode={() =>
|
||||
router.push(`/${orgId}/settings/remote-exit-nodes/create`)
|
||||
}
|
||||
onRefresh={refreshData}
|
||||
isRefreshing={isRefreshing}
|
||||
columnVisibility={{
|
||||
type: false,
|
||||
address: false,
|
||||
}}
|
||||
enableColumnVisibility={true}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -8,16 +8,17 @@ import { Badge } from "@app/components/ui/badge";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export type HorizontalTabs = Array<{
|
||||
export type TabItem = {
|
||||
title: string;
|
||||
href: string;
|
||||
icon?: React.ReactNode;
|
||||
showProfessional?: boolean;
|
||||
}>;
|
||||
exact?: boolean;
|
||||
};
|
||||
|
||||
interface HorizontalTabsProps {
|
||||
children: React.ReactNode;
|
||||
items: HorizontalTabs;
|
||||
items: TabItem[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -38,7 +39,8 @@ export function HorizontalTabs({
|
||||
.replace("{niceId}", params.niceId as string)
|
||||
.replace("{userId}", params.userId as string)
|
||||
.replace("{clientId}", params.clientId as string)
|
||||
.replace("{apiKeyId}", params.apiKeyId as string);
|
||||
.replace("{apiKeyId}", params.apiKeyId as string)
|
||||
.replace("{remoteExitNodeId}", params.remoteExitNodeId as string);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -49,8 +51,11 @@ export function HorizontalTabs({
|
||||
{items.map((item) => {
|
||||
const hydratedHref = hydrateHref(item.href);
|
||||
const isActive =
|
||||
pathname.startsWith(hydratedHref) &&
|
||||
(item.exact
|
||||
? pathname === hydratedHref
|
||||
: pathname.startsWith(hydratedHref)) &&
|
||||
!pathname.includes("create");
|
||||
|
||||
const isProfessional =
|
||||
item.showProfessional && !isUnlocked();
|
||||
const isDisabled =
|
||||
|
||||
@@ -49,7 +49,7 @@ export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
|
||||
|
||||
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="absolute inset-0 bg-background/83 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">
|
||||
|
||||
@@ -49,7 +49,7 @@ export function LayoutMobileMenu({
|
||||
|
||||
return (
|
||||
<div className="shrink-0 md:hidden">
|
||||
<div className="h-16 flex items-center px-4">
|
||||
<div className="h-16 flex items-center px-2">
|
||||
<div className="flex items-center gap-4">
|
||||
{showSidebar && (
|
||||
<div>
|
||||
|
||||
@@ -91,15 +91,12 @@ export function LogAnalyticsData(props: AnalyticsContentProps) {
|
||||
})
|
||||
);
|
||||
|
||||
const percentBlocked = stats
|
||||
? new Intl.NumberFormat(navigator.language, {
|
||||
maximumFractionDigits: 2
|
||||
}).format(
|
||||
stats.totalRequests
|
||||
? (stats.totalBlocked / stats.totalRequests) * 100
|
||||
: 0
|
||||
)
|
||||
: null;
|
||||
const percentBlocked =
|
||||
stats && stats.totalRequests > 0
|
||||
? new Intl.NumberFormat(navigator.language, {
|
||||
maximumFractionDigits: 2
|
||||
}).format((stats.totalBlocked / stats.totalRequests) * 100)
|
||||
: null;
|
||||
const totalRequests = stats
|
||||
? new Intl.NumberFormat(navigator.language, {
|
||||
maximumFractionDigits: 0
|
||||
|
||||
@@ -2,17 +2,14 @@
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import { build } from "@server/build";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
|
||||
export function SecurityFeaturesAlert() {
|
||||
export function PaidFeaturesAlert() {
|
||||
const t = useTranslations();
|
||||
const { isUnlocked } = useLicenseStatusContext();
|
||||
const subscriptionStatus = useSubscriptionStatusContext();
|
||||
|
||||
const { hasSaasSubscription, hasEnterpriseLicense } = usePaidStatus();
|
||||
return (
|
||||
<>
|
||||
{build === "saas" && !subscriptionStatus?.isSubscribed() ? (
|
||||
{build === "saas" && !hasSaasSubscription ? (
|
||||
<Alert variant="info" className="mb-6">
|
||||
<AlertDescription>
|
||||
{t("subscriptionRequiredToUse")}
|
||||
@@ -20,7 +17,7 @@ export function SecurityFeaturesAlert() {
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{build === "enterprise" && !isUnlocked() ? (
|
||||
{build === "enterprise" && !hasEnterpriseLicense ? (
|
||||
<Alert variant="info" className="mb-6">
|
||||
<AlertDescription>
|
||||
{t("licenseRequiredToUse")}
|
||||
@@ -27,6 +27,7 @@ function getActionsCategories(root: boolean) {
|
||||
[t("actionUpdateOrg")]: "updateOrg",
|
||||
[t("actionGetOrgUser")]: "getOrgUser",
|
||||
[t("actionInviteUser")]: "inviteUser",
|
||||
[t("actionRemoveInvitation")]: "removeInvitation",
|
||||
[t("actionListInvitations")]: "listInvitations",
|
||||
[t("actionRemoveUser")]: "removeUser",
|
||||
[t("actionListUsers")]: "listUsers",
|
||||
|
||||
@@ -39,16 +39,15 @@ import {
|
||||
resourceWhitelistProxy,
|
||||
resourceAccessProxy
|
||||
} from "@app/actions/server";
|
||||
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 BrandingLogo from "@app/components/BrandingLogo";
|
||||
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { build } from "@server/build";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
import { replacePlaceholder } from "@app/lib/replacePlaceholder";
|
||||
|
||||
const pinSchema = z.object({
|
||||
pin: z
|
||||
@@ -88,6 +87,14 @@ type ResourceAuthPortalProps = {
|
||||
redirect: string;
|
||||
idps?: LoginFormIDP[];
|
||||
orgId?: string;
|
||||
branding?: {
|
||||
logoUrl: string;
|
||||
logoWidth: number;
|
||||
logoHeight: number;
|
||||
primaryColor: string | null;
|
||||
resourceTitle: string;
|
||||
resourceSubtitle: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
@@ -104,7 +111,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
return colLength;
|
||||
};
|
||||
|
||||
const [numMethods, setNumMethods] = useState(getNumMethods());
|
||||
const [numMethods] = useState(() => getNumMethods());
|
||||
|
||||
const [passwordError, setPasswordError] = useState<string | null>(null);
|
||||
const [pincodeError, setPincodeError] = useState<string | null>(null);
|
||||
@@ -309,13 +316,19 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
}
|
||||
}
|
||||
|
||||
function getTitle() {
|
||||
function getTitle(resourceName: string) {
|
||||
if (
|
||||
isUnlocked() &&
|
||||
build !== "oss" &&
|
||||
env.branding.resourceAuthPage?.titleText
|
||||
isUnlocked() &&
|
||||
(!!env.branding.resourceAuthPage?.titleText ||
|
||||
!!props.branding?.resourceTitle)
|
||||
) {
|
||||
return env.branding.resourceAuthPage.titleText;
|
||||
if (props.branding?.resourceTitle) {
|
||||
return replacePlaceholder(props.branding?.resourceTitle, {
|
||||
resourceName
|
||||
});
|
||||
}
|
||||
return env.branding.resourceAuthPage?.titleText;
|
||||
}
|
||||
return t("authenticationRequired");
|
||||
}
|
||||
@@ -324,10 +337,16 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
if (
|
||||
isUnlocked() &&
|
||||
build !== "oss" &&
|
||||
env.branding.resourceAuthPage?.subtitleText
|
||||
(env.branding.resourceAuthPage?.subtitleText ||
|
||||
props.branding?.resourceSubtitle)
|
||||
) {
|
||||
return env.branding.resourceAuthPage.subtitleText
|
||||
.split("{{resourceName}}")
|
||||
if (props.branding?.resourceSubtitle) {
|
||||
return replacePlaceholder(props.branding?.resourceSubtitle, {
|
||||
resourceName
|
||||
});
|
||||
}
|
||||
return env.branding.resourceAuthPage?.subtitleText
|
||||
?.split("{{resourceName}}")
|
||||
.join(resourceName);
|
||||
}
|
||||
return numMethods > 1
|
||||
@@ -336,14 +355,23 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
}
|
||||
|
||||
const logoWidth = isUnlocked()
|
||||
? env.branding.logo?.authPage?.width || 100
|
||||
? (props.branding?.logoWidth ??
|
||||
env.branding.logo?.authPage?.width ??
|
||||
100)
|
||||
: 100;
|
||||
const logoHeight = isUnlocked()
|
||||
? env.branding.logo?.authPage?.height || 100
|
||||
? (props.branding?.logoHeight ??
|
||||
env.branding.logo?.authPage?.height ??
|
||||
100)
|
||||
: 100;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
// @ts-expect-error CSS variable
|
||||
"--primary": isUnlocked() ? props.branding?.primaryColor : null
|
||||
}}
|
||||
>
|
||||
{!accessDenied ? (
|
||||
<div>
|
||||
{isUnlocked() && build === "enterprise" ? (
|
||||
@@ -381,15 +409,19 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
<CardHeader>
|
||||
{isUnlocked() &&
|
||||
build !== "oss" &&
|
||||
env.branding?.resourceAuthPage?.showLogo && (
|
||||
(env.branding?.resourceAuthPage?.showLogo ||
|
||||
props.branding) && (
|
||||
<div className="flex flex-row items-center justify-center mb-3">
|
||||
<BrandingLogo
|
||||
height={logoHeight}
|
||||
width={logoWidth}
|
||||
logoPath={props.branding?.logoUrl}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<CardTitle>{getTitle()}</CardTitle>
|
||||
<CardTitle>
|
||||
{getTitle(props.resource.name)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{getSubtitle(props.resource.name)}
|
||||
</CardDescription>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
ArrowRight,
|
||||
ArrowUpDown,
|
||||
ArrowUpRight,
|
||||
Check,
|
||||
MoreHorizontal,
|
||||
X
|
||||
@@ -46,6 +47,7 @@ export type SiteRow = {
|
||||
address?: string;
|
||||
exitNodeName?: string;
|
||||
exitNodeEndpoint?: string;
|
||||
remoteExitNodeId?: string;
|
||||
};
|
||||
|
||||
type SitesTableProps = {
|
||||
@@ -303,27 +305,51 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const originalRow = row.original;
|
||||
return originalRow.exitNodeName ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>{originalRow.exitNodeName}</span>
|
||||
{build == "saas" &&
|
||||
originalRow.exitNodeName &&
|
||||
[
|
||||
"mercury",
|
||||
"venus",
|
||||
"earth",
|
||||
"mars",
|
||||
"jupiter",
|
||||
"saturn",
|
||||
"uranus",
|
||||
"neptune"
|
||||
].includes(
|
||||
originalRow.exitNodeName.toLowerCase()
|
||||
) && <Badge variant="secondary">Cloud</Badge>}
|
||||
</div>
|
||||
) : (
|
||||
"-"
|
||||
);
|
||||
if (!originalRow.exitNodeName) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
const isCloudNode =
|
||||
build == "saas" &&
|
||||
originalRow.exitNodeName &&
|
||||
[
|
||||
"mercury",
|
||||
"venus",
|
||||
"earth",
|
||||
"mars",
|
||||
"jupiter",
|
||||
"saturn",
|
||||
"uranus",
|
||||
"neptune"
|
||||
].includes(originalRow.exitNodeName.toLowerCase());
|
||||
|
||||
if (isCloudNode) {
|
||||
const capitalizedName =
|
||||
originalRow.exitNodeName.charAt(0).toUpperCase() +
|
||||
originalRow.exitNodeName.slice(1).toLowerCase();
|
||||
return (
|
||||
<Badge variant="secondary">
|
||||
Pangolin {capitalizedName}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// Self-hosted node
|
||||
if (originalRow.remoteExitNodeId) {
|
||||
return (
|
||||
<Link
|
||||
href={`/${originalRow.orgId}/settings/remote-exit-nodes/${originalRow.remoteExitNodeId}`}
|
||||
>
|
||||
<Button variant="outline">
|
||||
{originalRow.exitNodeName}
|
||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback if no remoteExitNodeId
|
||||
return <span>{originalRow.exitNodeName}</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -7,7 +7,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
export function TopLoader() {
|
||||
return (
|
||||
<>
|
||||
<NextTopLoader showSpinner={false} />
|
||||
<NextTopLoader showSpinner={false} color="var(--color-primary)" />
|
||||
<FinishingLoader />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -26,6 +26,11 @@ type ValidateOidcTokenParams = {
|
||||
stateCookie: string | undefined;
|
||||
idp: { name: string };
|
||||
loginPageId?: number;
|
||||
providerError?: {
|
||||
error: string;
|
||||
description?: string | null;
|
||||
uri?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
||||
@@ -35,14 +40,65 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isProviderError, setIsProviderError] = useState(false);
|
||||
|
||||
const { licenseStatus, isLicenseViolation } = useLicenseStatusContext();
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
useEffect(() => {
|
||||
async function validate() {
|
||||
let isCancelled = false;
|
||||
|
||||
async function runValidation() {
|
||||
setLoading(true);
|
||||
setIsProviderError(false);
|
||||
|
||||
if (props.providerError?.error) {
|
||||
const providerMessage =
|
||||
props.providerError.description ||
|
||||
t("idpErrorOidcProviderRejected", {
|
||||
error: props.providerError.error,
|
||||
defaultValue:
|
||||
"The identity provider returned an error: {error}."
|
||||
});
|
||||
const suffix = props.providerError.uri
|
||||
? ` (${props.providerError.uri})`
|
||||
: "";
|
||||
if (!isCancelled) {
|
||||
setIsProviderError(true);
|
||||
setError(`${providerMessage}${suffix}`);
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!props.code) {
|
||||
if (!isCancelled) {
|
||||
setIsProviderError(false);
|
||||
setError(
|
||||
t("idpErrorOidcMissingCode", {
|
||||
defaultValue:
|
||||
"The identity provider did not return an authorization code."
|
||||
})
|
||||
);
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!props.expectedState || !props.stateCookie) {
|
||||
if (!isCancelled) {
|
||||
setIsProviderError(false);
|
||||
setError(
|
||||
t("idpErrorOidcMissingState", {
|
||||
defaultValue:
|
||||
"The login request is missing state information. Please restart the login process."
|
||||
})
|
||||
);
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(t("idpOidcTokenValidating"), {
|
||||
code: props.code,
|
||||
@@ -57,22 +113,28 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
||||
try {
|
||||
const response = await validateOidcUrlCallbackProxy(
|
||||
props.idpId,
|
||||
props.code || "",
|
||||
props.expectedState || "",
|
||||
props.stateCookie || "",
|
||||
props.code,
|
||||
props.expectedState,
|
||||
props.stateCookie,
|
||||
props.loginPageId
|
||||
);
|
||||
|
||||
if (response.error) {
|
||||
setError(response.message);
|
||||
setLoading(false);
|
||||
if (!isCancelled) {
|
||||
setIsProviderError(false);
|
||||
setError(response.message);
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const data = response.data;
|
||||
if (!data) {
|
||||
setError("Unable to validate OIDC token");
|
||||
setLoading(false);
|
||||
if (!isCancelled) {
|
||||
setIsProviderError(false);
|
||||
setError("Unable to validate OIDC token");
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -82,8 +144,11 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
||||
router.push(env.app.dashboardUrl);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (!isCancelled) {
|
||||
setIsProviderError(false);
|
||||
setLoading(false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
if (redirectUrl.startsWith("http")) {
|
||||
window.location.href = data.redirectUrl; // this is validated by the parent using this component
|
||||
@@ -92,18 +157,27 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
setError(
|
||||
t("idpErrorOidcTokenValidating", {
|
||||
defaultValue:
|
||||
"An unexpected error occurred. Please try again."
|
||||
})
|
||||
);
|
||||
if (!isCancelled) {
|
||||
setIsProviderError(false);
|
||||
setError(
|
||||
t("idpErrorOidcTokenValidating", {
|
||||
defaultValue:
|
||||
"An unexpected error occurred. Please try again."
|
||||
})
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (!isCancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
validate();
|
||||
runValidation();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -134,12 +208,16 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
||||
<Alert variant="destructive" className="w-full">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<AlertDescription className="flex flex-col space-y-2">
|
||||
<span>
|
||||
{t("idpErrorConnectingTo", {
|
||||
name: props.idp.name
|
||||
})}
|
||||
<span className="text-sm font-medium">
|
||||
{isProviderError
|
||||
? error
|
||||
: t("idpErrorConnectingTo", {
|
||||
name: props.idp.name
|
||||
})}
|
||||
</span>
|
||||
<span className="text-xs">{error}</span>
|
||||
{!isProviderError && (
|
||||
<span className="text-xs">{error}</span>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -3,16 +3,8 @@
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useState, useEffect, forwardRef, useImperativeHandle } from "react";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@/components/ui/form";
|
||||
import { useState, useEffect, useActionState } from "react";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -51,9 +43,9 @@ import DomainPicker from "@app/components/DomainPicker";
|
||||
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
|
||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
|
||||
import { TierId } from "@server/lib/billing/tiers";
|
||||
import { build } from "@server/build";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { PaidFeaturesAlert } from "../PaidFeaturesAlert";
|
||||
|
||||
// Auth page form schema
|
||||
const AuthPageFormSchema = z.object({
|
||||
@@ -61,11 +53,10 @@ const AuthPageFormSchema = z.object({
|
||||
authPageSubdomain: z.string().optional()
|
||||
});
|
||||
|
||||
type AuthPageFormValues = z.infer<typeof AuthPageFormSchema>;
|
||||
|
||||
interface AuthPageSettingsProps {
|
||||
onSaveSuccess?: () => void;
|
||||
onSaveError?: (error: any) => void;
|
||||
loginPage: GetLoginPageResponse | null;
|
||||
}
|
||||
|
||||
export interface AuthPageSettingsRef {
|
||||
@@ -73,475 +64,428 @@ export interface AuthPageSettingsRef {
|
||||
hasUnsavedChanges: () => boolean;
|
||||
}
|
||||
|
||||
const AuthPageSettings = forwardRef<AuthPageSettingsRef, AuthPageSettingsProps>(
|
||||
({ onSaveSuccess, onSaveError }, ref) => {
|
||||
const { org } = useOrgContext();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
function AuthPageSettings({
|
||||
onSaveSuccess,
|
||||
onSaveError,
|
||||
loginPage: defaultLoginPage
|
||||
}: AuthPageSettingsProps) {
|
||||
const { org } = useOrgContext();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const subscription = useSubscriptionStatusContext();
|
||||
const { hasSaasSubscription } = usePaidStatus();
|
||||
|
||||
// Auth page domain state
|
||||
const [loginPage, setLoginPage] = useState<GetLoginPageResponse | null>(
|
||||
null
|
||||
);
|
||||
const [loginPageExists, setLoginPageExists] = useState(false);
|
||||
const [editDomainOpen, setEditDomainOpen] = useState(false);
|
||||
const [baseDomains, setBaseDomains] = useState<DomainRow[]>([]);
|
||||
const [selectedDomain, setSelectedDomain] = useState<{
|
||||
domainId: string;
|
||||
subdomain?: string;
|
||||
fullDomain: string;
|
||||
baseDomain: string;
|
||||
} | null>(null);
|
||||
const [loadingLoginPage, setLoadingLoginPage] = useState(true);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
const [loadingSave, setLoadingSave] = useState(false);
|
||||
// Auth page domain state
|
||||
const [loginPage, setLoginPage] = useState(defaultLoginPage);
|
||||
const [, formAction, isSubmitting] = useActionState(onSubmit, null);
|
||||
const [loginPageExists, setLoginPageExists] = useState(
|
||||
Boolean(defaultLoginPage)
|
||||
);
|
||||
const [editDomainOpen, setEditDomainOpen] = useState(false);
|
||||
const [baseDomains, setBaseDomains] = useState<DomainRow[]>([]);
|
||||
const [selectedDomain, setSelectedDomain] = useState<{
|
||||
domainId: string;
|
||||
subdomain?: string;
|
||||
fullDomain: string;
|
||||
baseDomain: string;
|
||||
} | null>(null);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(AuthPageFormSchema),
|
||||
defaultValues: {
|
||||
authPageDomainId: loginPage?.domainId || "",
|
||||
authPageSubdomain: loginPage?.subdomain || ""
|
||||
},
|
||||
mode: "onChange"
|
||||
});
|
||||
|
||||
// Expose save function to parent component
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
saveAuthSettings: async () => {
|
||||
await form.handleSubmit(onSubmit)();
|
||||
},
|
||||
hasUnsavedChanges: () => hasUnsavedChanges
|
||||
}),
|
||||
[form, hasUnsavedChanges]
|
||||
);
|
||||
|
||||
// Fetch login page and domains data
|
||||
useEffect(() => {
|
||||
const fetchLoginPage = async () => {
|
||||
try {
|
||||
const res = await api.get<
|
||||
AxiosResponse<GetLoginPageResponse>
|
||||
>(`/org/${org?.org.orgId}/login-page`);
|
||||
if (res.status === 200) {
|
||||
setLoginPage(res.data.data);
|
||||
setLoginPageExists(true);
|
||||
// Update form with login page data
|
||||
form.setValue(
|
||||
"authPageDomainId",
|
||||
res.data.data.domainId || ""
|
||||
);
|
||||
form.setValue(
|
||||
"authPageSubdomain",
|
||||
res.data.data.subdomain || ""
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
// Login page doesn't exist yet, that's okay
|
||||
setLoginPage(null);
|
||||
setLoginPageExists(false);
|
||||
} finally {
|
||||
setLoadingLoginPage(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDomains = async () => {
|
||||
try {
|
||||
const res = await api.get<
|
||||
AxiosResponse<ListDomainsResponse>
|
||||
>(`/org/${org?.org.orgId}/domains/`);
|
||||
if (res.status === 200) {
|
||||
const rawDomains = res.data.data.domains as DomainRow[];
|
||||
const domains = rawDomains.map((domain) => ({
|
||||
...domain,
|
||||
baseDomain: toUnicode(domain.baseDomain)
|
||||
}));
|
||||
setBaseDomains(domains);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch domains:", err);
|
||||
}
|
||||
};
|
||||
|
||||
if (org?.org.orgId) {
|
||||
fetchLoginPage();
|
||||
fetchDomains();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle domain selection from modal
|
||||
function handleDomainSelection(domain: {
|
||||
domainId: string;
|
||||
subdomain?: string;
|
||||
fullDomain: string;
|
||||
baseDomain: string;
|
||||
}) {
|
||||
form.setValue("authPageDomainId", domain.domainId);
|
||||
form.setValue("authPageSubdomain", domain.subdomain || "");
|
||||
setEditDomainOpen(false);
|
||||
|
||||
// Update loginPage state to show the selected domain immediately
|
||||
const sanitizedSubdomain = domain.subdomain
|
||||
? finalizeSubdomainSanitize(domain.subdomain)
|
||||
: "";
|
||||
|
||||
const sanitizedFullDomain = sanitizedSubdomain
|
||||
? `${sanitizedSubdomain}.${domain.baseDomain}`
|
||||
: domain.baseDomain;
|
||||
|
||||
// Only update loginPage state if a login page already exists
|
||||
if (loginPageExists && loginPage) {
|
||||
setLoginPage({
|
||||
...loginPage,
|
||||
domainId: domain.domainId,
|
||||
subdomain: sanitizedSubdomain,
|
||||
fullDomain: sanitizedFullDomain
|
||||
});
|
||||
}
|
||||
|
||||
setHasUnsavedChanges(true);
|
||||
}
|
||||
|
||||
// Clear auth page domain
|
||||
function clearAuthPageDomain() {
|
||||
form.setValue("authPageDomainId", "");
|
||||
form.setValue("authPageSubdomain", "");
|
||||
setLoginPage(null);
|
||||
setHasUnsavedChanges(true);
|
||||
}
|
||||
|
||||
async function onSubmit(data: AuthPageFormValues) {
|
||||
setLoadingSave(true);
|
||||
const form = useForm({
|
||||
resolver: zodResolver(AuthPageFormSchema),
|
||||
defaultValues: {
|
||||
authPageDomainId: loginPage?.domainId || "",
|
||||
authPageSubdomain: loginPage?.subdomain || ""
|
||||
},
|
||||
mode: "onChange"
|
||||
});
|
||||
|
||||
// Fetch login page and domains data
|
||||
useEffect(() => {
|
||||
const fetchDomains = async () => {
|
||||
try {
|
||||
// Handle auth page domain
|
||||
if (data.authPageDomainId) {
|
||||
if (
|
||||
build === "enterprise" ||
|
||||
(build === "saas" && subscription?.subscribed)
|
||||
) {
|
||||
const sanitizedSubdomain = data.authPageSubdomain
|
||||
? finalizeSubdomainSanitize(data.authPageSubdomain)
|
||||
: "";
|
||||
const res = await api.get<AxiosResponse<ListDomainsResponse>>(
|
||||
`/org/${org?.org.orgId}/domains/`
|
||||
);
|
||||
if (res.status === 200) {
|
||||
const rawDomains = res.data.data.domains as DomainRow[];
|
||||
const domains = rawDomains.map((domain) => ({
|
||||
...domain,
|
||||
baseDomain: toUnicode(domain.baseDomain)
|
||||
}));
|
||||
setBaseDomains(domains);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch domains:", err);
|
||||
}
|
||||
};
|
||||
|
||||
if (loginPageExists) {
|
||||
// Login page exists on server - need to update it
|
||||
// First, we need to get the loginPageId from the server since loginPage might be null locally
|
||||
let loginPageId: number;
|
||||
if (org?.org.orgId) {
|
||||
fetchDomains();
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (loginPage) {
|
||||
// We have the loginPage data locally
|
||||
loginPageId = loginPage.loginPageId;
|
||||
} else {
|
||||
// User cleared selection locally, but login page still exists on server
|
||||
// We need to fetch it to get the loginPageId
|
||||
const fetchRes = await api.get<
|
||||
AxiosResponse<GetLoginPageResponse>
|
||||
>(`/org/${org?.org.orgId}/login-page`);
|
||||
loginPageId = fetchRes.data.data.loginPageId;
|
||||
}
|
||||
// Handle domain selection from modal
|
||||
function handleDomainSelection(domain: {
|
||||
domainId: string;
|
||||
subdomain?: string;
|
||||
fullDomain: string;
|
||||
baseDomain: string;
|
||||
}) {
|
||||
form.setValue("authPageDomainId", domain.domainId);
|
||||
form.setValue("authPageSubdomain", domain.subdomain || "");
|
||||
setEditDomainOpen(false);
|
||||
|
||||
// Update existing auth page domain
|
||||
const updateRes = await api.post(
|
||||
`/org/${org?.org.orgId}/login-page/${loginPageId}`,
|
||||
{
|
||||
domainId: data.authPageDomainId,
|
||||
subdomain: sanitizedSubdomain || null
|
||||
}
|
||||
);
|
||||
// Update loginPage state to show the selected domain immediately
|
||||
const sanitizedSubdomain = domain.subdomain
|
||||
? finalizeSubdomainSanitize(domain.subdomain)
|
||||
: "";
|
||||
|
||||
if (updateRes.status === 201) {
|
||||
setLoginPage(updateRes.data.data);
|
||||
setLoginPageExists(true);
|
||||
}
|
||||
const sanitizedFullDomain = sanitizedSubdomain
|
||||
? `${sanitizedSubdomain}.${domain.baseDomain}`
|
||||
: domain.baseDomain;
|
||||
|
||||
// Only update loginPage state if a login page already exists
|
||||
if (loginPageExists && loginPage) {
|
||||
setLoginPage({
|
||||
...loginPage,
|
||||
domainId: domain.domainId,
|
||||
subdomain: sanitizedSubdomain,
|
||||
fullDomain: sanitizedFullDomain
|
||||
});
|
||||
}
|
||||
|
||||
setHasUnsavedChanges(true);
|
||||
}
|
||||
|
||||
// Clear auth page domain
|
||||
function clearAuthPageDomain() {
|
||||
form.setValue("authPageDomainId", "");
|
||||
form.setValue("authPageSubdomain", "");
|
||||
setLoginPage(null);
|
||||
setHasUnsavedChanges(true);
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
const data = form.getValues();
|
||||
|
||||
try {
|
||||
// Handle auth page domain
|
||||
if (data.authPageDomainId) {
|
||||
if (build === "enterprise" || hasSaasSubscription) {
|
||||
const sanitizedSubdomain = data.authPageSubdomain
|
||||
? finalizeSubdomainSanitize(data.authPageSubdomain)
|
||||
: "";
|
||||
|
||||
if (loginPageExists) {
|
||||
// Login page exists on server - need to update it
|
||||
// First, we need to get the loginPageId from the server since loginPage might be null locally
|
||||
let loginPageId: number;
|
||||
|
||||
if (loginPage) {
|
||||
// We have the loginPage data locally
|
||||
loginPageId = loginPage.loginPageId;
|
||||
} else {
|
||||
// No login page exists on server - create new one
|
||||
const createRes = await api.put(
|
||||
`/org/${org?.org.orgId}/login-page`,
|
||||
{
|
||||
domainId: data.authPageDomainId,
|
||||
subdomain: sanitizedSubdomain || null
|
||||
}
|
||||
);
|
||||
// User cleared selection locally, but login page still exists on server
|
||||
// We need to fetch it to get the loginPageId
|
||||
const fetchRes = await api.get<
|
||||
AxiosResponse<GetLoginPageResponse>
|
||||
>(`/org/${org?.org.orgId}/login-page`);
|
||||
loginPageId = fetchRes.data.data.loginPageId;
|
||||
}
|
||||
|
||||
if (createRes.status === 201) {
|
||||
setLoginPage(createRes.data.data);
|
||||
setLoginPageExists(true);
|
||||
// Update existing auth page domain
|
||||
const updateRes = await api.post(
|
||||
`/org/${org?.org.orgId}/login-page/${loginPageId}`,
|
||||
{
|
||||
domainId: data.authPageDomainId,
|
||||
subdomain: sanitizedSubdomain || null
|
||||
}
|
||||
);
|
||||
|
||||
if (updateRes.status === 201) {
|
||||
setLoginPage(updateRes.data.data);
|
||||
setLoginPageExists(true);
|
||||
}
|
||||
} else {
|
||||
// No login page exists on server - create new one
|
||||
const createRes = await api.put(
|
||||
`/org/${org?.org.orgId}/login-page`,
|
||||
{
|
||||
domainId: data.authPageDomainId,
|
||||
subdomain: sanitizedSubdomain || null
|
||||
}
|
||||
);
|
||||
|
||||
if (createRes.status === 201) {
|
||||
setLoginPage(createRes.data.data);
|
||||
setLoginPageExists(true);
|
||||
}
|
||||
}
|
||||
} else if (loginPageExists) {
|
||||
// Delete existing auth page domain if no domain selected
|
||||
let loginPageId: number;
|
||||
}
|
||||
} else if (loginPageExists) {
|
||||
// Delete existing auth page domain if no domain selected
|
||||
let loginPageId: number;
|
||||
|
||||
if (loginPage) {
|
||||
// We have the loginPage data locally
|
||||
loginPageId = loginPage.loginPageId;
|
||||
} else {
|
||||
// User cleared selection locally, but login page still exists on server
|
||||
// We need to fetch it to get the loginPageId
|
||||
const fetchRes = await api.get<
|
||||
AxiosResponse<GetLoginPageResponse>
|
||||
>(`/org/${org?.org.orgId}/login-page`);
|
||||
loginPageId = fetchRes.data.data.loginPageId;
|
||||
}
|
||||
|
||||
await api.delete(
|
||||
`/org/${org?.org.orgId}/login-page/${loginPageId}`
|
||||
);
|
||||
setLoginPage(null);
|
||||
setLoginPageExists(false);
|
||||
if (loginPage) {
|
||||
// We have the loginPage data locally
|
||||
loginPageId = loginPage.loginPageId;
|
||||
} else {
|
||||
// User cleared selection locally, but login page still exists on server
|
||||
// We need to fetch it to get the loginPageId
|
||||
const fetchRes = await api.get<
|
||||
AxiosResponse<GetLoginPageResponse>
|
||||
>(`/org/${org?.org.orgId}/login-page`);
|
||||
loginPageId = fetchRes.data.data.loginPageId;
|
||||
}
|
||||
|
||||
setHasUnsavedChanges(false);
|
||||
router.refresh();
|
||||
onSaveSuccess?.();
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("authPageErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("authPageErrorUpdateMessage")
|
||||
)
|
||||
});
|
||||
onSaveError?.(e);
|
||||
} finally {
|
||||
setLoadingSave(false);
|
||||
await api.delete(
|
||||
`/org/${org?.org.orgId}/login-page/${loginPageId}`
|
||||
);
|
||||
setLoginPage(null);
|
||||
setLoginPageExists(false);
|
||||
}
|
||||
|
||||
setHasUnsavedChanges(false);
|
||||
router.refresh();
|
||||
onSaveSuccess?.();
|
||||
toast({
|
||||
variant: "default",
|
||||
title: t("success"),
|
||||
description: t("authPageDomainUpdated")
|
||||
});
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("authPageErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("authPageErrorUpdateMessage")
|
||||
)
|
||||
});
|
||||
onSaveError?.(e);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("authPage")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("authPageDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
{build === "saas" && !subscription?.subscribed ? (
|
||||
<Alert variant="info" className="mb-6">
|
||||
<AlertDescription>
|
||||
{t("orgAuthPageDisabled")}{" "}
|
||||
{t("subscriptionRequiredToUse")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<SettingsSectionForm>
|
||||
{loadingLoginPage ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("loading")}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="auth-page-settings-form"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Label>{t("authPageDomain")}</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" />
|
||||
{loginPage &&
|
||||
!loginPage.domainId ? (
|
||||
<InfoPopup
|
||||
info={t(
|
||||
"domainNotFoundDescription"
|
||||
)}
|
||||
text={t(
|
||||
"domainNotFound"
|
||||
)}
|
||||
/>
|
||||
) : loginPage?.fullDomain ? (
|
||||
<a
|
||||
href={`${window.location.protocol}//${loginPage.fullDomain}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline"
|
||||
>
|
||||
{`${window.location.protocol}//${loginPage.fullDomain}`}
|
||||
</a>
|
||||
) : form.watch(
|
||||
"authPageDomainId"
|
||||
) ? (
|
||||
// Show selected domain from form state when no loginPage exists yet
|
||||
(() => {
|
||||
const selectedDomainId =
|
||||
form.watch(
|
||||
"authPageDomainId"
|
||||
);
|
||||
const selectedSubdomain =
|
||||
form.watch(
|
||||
"authPageSubdomain"
|
||||
);
|
||||
const domain =
|
||||
baseDomains.find(
|
||||
(d) =>
|
||||
d.domainId ===
|
||||
selectedDomainId
|
||||
);
|
||||
if (domain) {
|
||||
const sanitizedSubdomain =
|
||||
selectedSubdomain
|
||||
? finalizeSubdomainSanitize(
|
||||
selectedSubdomain
|
||||
)
|
||||
: "";
|
||||
const fullDomain =
|
||||
sanitizedSubdomain
|
||||
? `${sanitizedSubdomain}.${domain.baseDomain}`
|
||||
: domain.baseDomain;
|
||||
return fullDomain;
|
||||
}
|
||||
return t(
|
||||
"noDomainSet"
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
t("noDomainSet")
|
||||
)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setEditDomainOpen(
|
||||
true
|
||||
)
|
||||
}
|
||||
>
|
||||
{form.watch(
|
||||
"authPageDomainId"
|
||||
)
|
||||
? t("changeDomain")
|
||||
: t("selectDomain")}
|
||||
</Button>
|
||||
{form.watch(
|
||||
"authPageDomainId"
|
||||
) && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={
|
||||
clearAuthPageDomain
|
||||
}
|
||||
>
|
||||
<Trash2 size="14" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!form.watch(
|
||||
"authPageDomainId"
|
||||
) && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"addDomainToEnableCustomAuthPages"
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{env.flags.usePangolinDns &&
|
||||
(build === "enterprise" ||
|
||||
(build === "saas" &&
|
||||
subscription?.subscribed)) &&
|
||||
loginPage?.domainId &&
|
||||
loginPage?.fullDomain &&
|
||||
!hasUnsavedChanges && (
|
||||
<CertificateStatus
|
||||
orgId={
|
||||
org?.org.orgId || ""
|
||||
}
|
||||
domainId={
|
||||
loginPage.domainId
|
||||
}
|
||||
fullDomain={
|
||||
loginPage.fullDomain
|
||||
}
|
||||
autoFetch={true}
|
||||
showLabel={true}
|
||||
polling={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Domain Picker Modal */}
|
||||
<Credenza
|
||||
open={editDomainOpen}
|
||||
onOpenChange={(setOpen) => setEditDomainOpen(setOpen)}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{loginPage
|
||||
? t("editAuthPageDomain")
|
||||
: t("setAuthPageDomain")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("selectDomainForOrgAuthPage")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<DomainPicker
|
||||
hideFreeDomain={true}
|
||||
orgId={org?.org.orgId as string}
|
||||
cols={1}
|
||||
onDomainChange={(res) => {
|
||||
const selected = {
|
||||
domainId: res.domainId,
|
||||
subdomain: res.subdomain,
|
||||
fullDomain: res.fullDomain,
|
||||
baseDomain: res.baseDomain
|
||||
};
|
||||
setSelectedDomain(selected);
|
||||
}}
|
||||
/>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("cancel")}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (selectedDomain) {
|
||||
handleDomainSelection(selectedDomain);
|
||||
}
|
||||
}}
|
||||
disabled={!selectedDomain}
|
||||
>
|
||||
{t("selectDomain")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>{t("customDomain")}</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("authPageDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
|
||||
<PaidFeaturesAlert />
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
action={formAction}
|
||||
className="space-y-4"
|
||||
id="auth-page-settings-form"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Label>{t("authPageDomain")}</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" />
|
||||
{loginPage &&
|
||||
!loginPage.domainId ? (
|
||||
<InfoPopup
|
||||
info={t(
|
||||
"domainNotFoundDescription"
|
||||
)}
|
||||
text={t("domainNotFound")}
|
||||
/>
|
||||
) : loginPage?.fullDomain ? (
|
||||
<a
|
||||
href={`${window.location.protocol}//${loginPage.fullDomain}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline"
|
||||
>
|
||||
{`${window.location.protocol}//${loginPage.fullDomain}`}
|
||||
</a>
|
||||
) : form.watch(
|
||||
"authPageDomainId"
|
||||
) ? (
|
||||
// Show selected domain from form state when no loginPage exists yet
|
||||
(() => {
|
||||
const selectedDomainId =
|
||||
form.watch(
|
||||
"authPageDomainId"
|
||||
);
|
||||
const selectedSubdomain =
|
||||
form.watch(
|
||||
"authPageSubdomain"
|
||||
);
|
||||
const domain =
|
||||
baseDomains.find(
|
||||
(d) =>
|
||||
d.domainId ===
|
||||
selectedDomainId
|
||||
);
|
||||
if (domain) {
|
||||
const sanitizedSubdomain =
|
||||
selectedSubdomain
|
||||
? finalizeSubdomainSanitize(
|
||||
selectedSubdomain
|
||||
)
|
||||
: "";
|
||||
const fullDomain =
|
||||
sanitizedSubdomain
|
||||
? `${sanitizedSubdomain}.${domain.baseDomain}`
|
||||
: domain.baseDomain;
|
||||
return fullDomain;
|
||||
}
|
||||
return t("noDomainSet");
|
||||
})()
|
||||
) : (
|
||||
t("noDomainSet")
|
||||
)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setEditDomainOpen(true)
|
||||
}
|
||||
disabled={!hasSaasSubscription}
|
||||
>
|
||||
{form.watch("authPageDomainId")
|
||||
? t("changeDomain")
|
||||
: t("selectDomain")}
|
||||
</Button>
|
||||
{form.watch("authPageDomainId") && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={
|
||||
clearAuthPageDomain
|
||||
}
|
||||
disabled={
|
||||
!hasSaasSubscription
|
||||
}
|
||||
>
|
||||
<Trash2 size="14" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!form.watch("authPageDomainId") && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"addDomainToEnableCustomAuthPages"
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{env.flags.usePangolinDns &&
|
||||
(build === "enterprise" ||
|
||||
!hasSaasSubscription) &&
|
||||
loginPage?.domainId &&
|
||||
loginPage?.fullDomain &&
|
||||
!hasUnsavedChanges && (
|
||||
<CertificateStatus
|
||||
orgId={org?.org.orgId || ""}
|
||||
domainId={loginPage.domainId}
|
||||
fullDomain={
|
||||
loginPage.fullDomain
|
||||
}
|
||||
autoFetch={true}
|
||||
showLabel={true}
|
||||
polling={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<div className="flex justify-end mt-6">
|
||||
<Button
|
||||
type="submit"
|
||||
form="auth-page-settings-form"
|
||||
loading={isSubmitting}
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
!hasUnsavedChanges ||
|
||||
!hasSaasSubscription
|
||||
}
|
||||
>
|
||||
{t("saveAuthPageDomain")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Domain Picker Modal */}
|
||||
<Credenza
|
||||
open={editDomainOpen}
|
||||
onOpenChange={(setOpen) => setEditDomainOpen(setOpen)}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{loginPage
|
||||
? t("editAuthPageDomain")
|
||||
: t("setAuthPageDomain")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("selectDomainForOrgAuthPage")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<DomainPicker
|
||||
hideFreeDomain={true}
|
||||
orgId={org?.org.orgId as string}
|
||||
cols={1}
|
||||
onDomainChange={(res) => {
|
||||
const selected =
|
||||
res === null
|
||||
? null
|
||||
: {
|
||||
domainId: res.domainId,
|
||||
subdomain: res.subdomain,
|
||||
fullDomain: res.fullDomain,
|
||||
baseDomain: res.baseDomain
|
||||
};
|
||||
setSelectedDomain(selected);
|
||||
}}
|
||||
/>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("cancel")}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (selectedDomain) {
|
||||
handleDomainSelection(selectedDomain);
|
||||
}
|
||||
}}
|
||||
disabled={!selectedDomain || !hasSaasSubscription}
|
||||
>
|
||||
{t("selectDomain")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
AuthPageSettings.displayName = "AuthPageSettings";
|
||||
|
||||
|
||||
@@ -308,7 +308,7 @@ export const Autocomplete: React.FC<AutocompleteProps> = ({
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
className={cn(
|
||||
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 hover:bg-accent",
|
||||
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 hover:bg-accent",
|
||||
isSelected &&
|
||||
"bg-accent text-accent-foreground",
|
||||
classStyleProps?.commandItem
|
||||
|
||||
@@ -19,7 +19,7 @@ const buttonVariants = cva(
|
||||
outlinePrimary:
|
||||
"border border-primary bg-card hover:bg-primary/10 text-primary ",
|
||||
secondary:
|
||||
"bg-secondary border border-input border text-secondary-foreground hover:bg-secondary/80 ",
|
||||
"bg-muted border border-input border text-secondary-foreground hover:bg-muted/80 ",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
squareOutlinePrimary:
|
||||
"border border-primary bg-card hover:bg-primary/10 text-primary rounded-md ",
|
||||
|
||||
@@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card p-6 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:scale-95 data-[state=open]:scale-100 sm:rounded-lg",
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card px-6 pt-6 pb-4 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -59,7 +59,7 @@ const DialogHeader = ({
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left mb-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -44,7 +44,7 @@ function DropdownMenuContent({
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-sm",
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -237,7 +237,7 @@ function DropdownMenuSubContent({
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-sm",
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -29,7 +29,7 @@ function PopoverContent({
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-sm outline-hidden",
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-sm outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -60,7 +60,7 @@ function SelectContent({
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-sm",
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-sm",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
|
||||
@@ -31,7 +31,7 @@ const SheetOverlay = React.forwardRef<
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-card p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-100 data-[state=open]:duration-300",
|
||||
"fixed z-50 gap-4 bg-card px-6 pt-6 pb-1 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-100 data-[state=open]:duration-300",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
|
||||
@@ -45,7 +45,7 @@ function TooltipContent({
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
Reference in New Issue
Block a user