Merge branch 'dev' into feat/login-page-customization

This commit is contained in:
miloschwartz
2025-12-17 11:41:17 -05:00
660 changed files with 19695 additions and 12803 deletions

View File

@@ -64,10 +64,8 @@ export default function Page() {
clientSecret: z
.string()
.min(1, { message: t("idpClientSecretRequired") }),
authUrl: z.url({ message: t("idpErrorAuthUrlInvalid") })
.optional(),
tokenUrl: z.url({ message: t("idpErrorTokenUrlInvalid") })
.optional(),
authUrl: z.url({ message: t("idpErrorAuthUrlInvalid") }).optional(),
tokenUrl: z.url({ message: t("idpErrorTokenUrlInvalid") }).optional(),
identifierPath: z
.string()
.min(1, { message: t("idpPathRequired") })
@@ -379,9 +377,11 @@ export default function Page() {
>
<AutoProvisionConfigWidget
control={form.control}
autoProvision={form.watch(
"autoProvision"
) as boolean} // is this right?
autoProvision={
form.watch(
"autoProvision"
) as boolean
} // is this right?
onAutoProvisionChange={(checked) => {
form.setValue(
"autoProvision",

View File

@@ -19,18 +19,17 @@ export function ExitNodesDataTable<TData, TValue>({
onRefresh,
isRefreshing
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
title={t('remoteExitNodes')}
searchPlaceholder={t('searchRemoteExitNodes')}
title={t("remoteExitNodes")}
searchPlaceholder={t("searchRemoteExitNodes")}
searchColumn="name"
onAdd={createRemoteExitNode}
addButtonText={t('remoteExitNodeAdd')}
addButtonText={t("remoteExitNodeAdd")}
onRefresh={onRefresh}
isRefreshing={isRefreshing}
defaultSort={{

View File

@@ -231,12 +231,22 @@ export default function ExitNodesTable({
},
cell: ({ row }) => {
const originalRow = row.original;
return originalRow.version || "-";
return (
<div className="flex items-center space-x-1">
{originalRow.version && originalRow.version ? (
<Badge variant="secondary">
{"v" + originalRow.version}
</Badge>
) : (
"-"
)}
</div>
);
}
},
{
id: "actions",
header: () => (<span className="p-3">{t("actions")}</span>),
header: () => <span className="p-3">{t("actions")}</span>,
cell: ({ row }) => {
const nodeRow = row.original;
const remoteExitNodeId = nodeRow.id;
@@ -294,10 +304,8 @@ export default function ExitNodesTable({
setSelectedNode(null);
}}
dialog={
<div>
<p>
{t("remoteExitNodeQuestionRemove")}
</p>
<div className="space-y-2">
<p>{t("remoteExitNodeQuestionRemove")}</p>
<p>{t("remoteExitNodeMessageRemove")}</p>
</div>

View File

@@ -6,6 +6,7 @@ import {
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
@@ -21,11 +22,20 @@ import {
QuickStartRemoteExitNodeResponse
} from "@server/routers/remoteExitNode/types";
import { useRemoteExitNodeContext } from "@app/hooks/useRemoteExitNodeContext";
import RegenerateCredentialsModal from "@app/components/RegenerateCredentialsModal";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { build } from "@server/build";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@app/components/ui/tooltip";
import { SecurityFeaturesAlert } from "@app/components/SecurityFeaturesAlert";
import {
InfoSection,
InfoSectionContent,
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import CopyToClipboard from "@app/components/CopyToClipboard";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon } from "lucide-react";
export default function CredentialsPage() {
const { env } = useEnvContext();
@@ -36,7 +46,16 @@ export default function CredentialsPage() {
const { remoteExitNode } = useRemoteExitNodeContext();
const [modalOpen, setModalOpen] = useState(false);
const [credentials, setCredentials] = useState<PickRemoteExitNodeDefaultsResponse | null>(null);
const [credentials, setCredentials] =
useState<PickRemoteExitNodeDefaultsResponse | null>(null);
const [currentRemoteExitNodeId, setCurrentRemoteExitNodeId] = useState<
string | null
>(remoteExitNode.remoteExitNodeId);
const [regeneratedSecret, setRegeneratedSecret] = useState<string | null>(
null
);
const [showCredentialsAlert, setShowCredentialsAlert] = useState(false);
const [shouldDisconnect, setShouldDisconnect] = useState(true);
const { licenseStatus, isUnlocked } = useLicenseStatusContext();
const subscription = useSubscriptionStatusContext();
@@ -48,86 +67,213 @@ export default function CredentialsPage() {
return isEnterpriseNotLicensed || isSaasNotSubscribed;
};
const handleConfirmRegenerate = async () => {
try {
const response = await api.get<
AxiosResponse<PickRemoteExitNodeDefaultsResponse>
>(`/org/${orgId}/pick-remote-exit-node-defaults`);
const response = await api.get<AxiosResponse<PickRemoteExitNodeDefaultsResponse>>(
`/org/${orgId}/pick-remote-exit-node-defaults`
);
const data = response.data.data;
setCredentials(data);
const data = response.data.data;
setCredentials(data);
await api.put<AxiosResponse<QuickStartRemoteExitNodeResponse>>(
`/re-key/${orgId}/reGenerate-remote-exit-node-secret`,
{
const rekeyRes = await api.put<
AxiosResponse<QuickStartRemoteExitNodeResponse>
>(`/re-key/${orgId}/regenerate-remote-exit-node-secret`, {
remoteExitNodeId: remoteExitNode.remoteExitNodeId,
secret: data.secret,
disconnect: shouldDisconnect
});
if (rekeyRes && rekeyRes.status === 200) {
const rekeyData = rekeyRes.data.data;
if (rekeyData && rekeyData.remoteExitNodeId) {
setCurrentRemoteExitNodeId(rekeyData.remoteExitNodeId);
setRegeneratedSecret(data.secret);
setCredentials({
...data,
remoteExitNodeId: rekeyData.remoteExitNodeId
});
setShowCredentialsAlert(true);
}
}
);
toast({
title: t("credentialsSaved"),
description: t("credentialsSavedDescription")
});
router.refresh();
};
const getCredentials = () => {
if (credentials) {
return {
Id: remoteExitNode.remoteExitNodeId,
Secret: credentials.secret
};
toast({
title: t("credentialsSaved"),
description: t("credentialsSavedDescription")
});
} catch (error) {
toast({
variant: "destructive",
title: t("error") || "Error",
description:
formatAxiosError(error) ||
t("credentialsRegenerateError") ||
"Failed to regenerate credentials"
});
}
return undefined;
};
const getConfirmationString = () => {
return (
remoteExitNode?.name ||
remoteExitNode?.remoteExitNodeId ||
"My remote exit node"
);
};
const displayRemoteExitNodeId =
currentRemoteExitNodeId || remoteExitNode?.remoteExitNodeId || null;
const displaySecret = regeneratedSecret || null;
return (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("generatedcredentials")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("regenerateCredentials")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<>
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("generatedcredentials")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("regenerateCredentials")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SecurityFeaturesAlert />
<SettingsSectionBody>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="inline-block">
<Button
onClick={() => setModalOpen(true)}
disabled={isSecurityFeatureDisabled()}
>
{t("regeneratecredentials")}
</Button>
</div>
</TooltipTrigger>
<InfoSections cols={3}>
<InfoSection>
<InfoSectionTitle>
{t("endpoint") || "Endpoint"}
</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard
text={env.app.dashboardUrl}
/>
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t("remoteExitNodeId") ||
"Remote Exit Node ID"}
</InfoSectionTitle>
<InfoSectionContent>
{displayRemoteExitNodeId ? (
<CopyToClipboard
text={displayRemoteExitNodeId}
/>
) : (
<span>{"••••••••••••••••"}</span>
)}
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t("secretKey") || "Secret Key"}
</InfoSectionTitle>
<InfoSectionContent>
{displaySecret ? (
<CopyToClipboard text={displaySecret} />
) : (
<span>
{"••••••••••••••••••••••••••••••••"}
</span>
)}
</InfoSectionContent>
</InfoSection>
</InfoSections>
{isSecurityFeatureDisabled() && (
<TooltipContent side="top">
{t("featureDisabledTooltip")}
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
</SettingsSectionBody>
</SettingsSection>
{showCredentialsAlert && displaySecret && (
<Alert variant="neutral" className="mt-4">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("credentialsSave") ||
"Save the Credentials"}
</AlertTitle>
<AlertDescription>
{t("credentialsSaveDescription") ||
"You will only be able to see this once. Make sure to copy it to a secure place."}
</AlertDescription>
</Alert>
)}
</SettingsSectionBody>
{build !== "oss" && (
<SettingsSectionFooter>
<Button
variant="outline"
onClick={() => {
setShouldDisconnect(false);
setModalOpen(true);
}}
disabled={isSecurityFeatureDisabled()}
>
{t("regenerateCredentialsButton")}
</Button>
<Button
onClick={() => {
setShouldDisconnect(true);
setModalOpen(true);
}}
disabled={isSecurityFeatureDisabled()}
>
{t("remoteExitNodeRegenerateAndDisconnect")}
</Button>
</SettingsSectionFooter>
)}
</SettingsSection>
</SettingsContainer>
<RegenerateCredentialsModal
<ConfirmDeleteDialog
open={modalOpen}
onOpenChange={setModalOpen}
type="remote-exit-node"
onConfirmRegenerate={handleConfirmRegenerate}
dashboardUrl={env.app.dashboardUrl}
credentials={getCredentials()}
setOpen={(val) => {
setModalOpen(val);
// Prevent modal from reopening during refresh
if (!val) {
setTimeout(() => {
router.refresh();
}, 150);
}
}}
dialog={
<div className="space-y-2">
{shouldDisconnect ? (
<>
<p>
{t(
"remoteExitNodeRegenerateAndDisconnectConfirmation"
)}
</p>
<p>
{t(
"remoteExitNodeRegenerateAndDisconnectWarning"
)}
</p>
</>
) : (
<>
<p>
{t(
"remoteExitNodeRegenerateCredentialsConfirmation"
)}
</p>
<p>
{t(
"remoteExitNodeRegenerateCredentialsWarning"
)}
</p>
</>
)}
</div>
}
buttonText={
shouldDisconnect
? t("remoteExitNodeRegenerateAndDisconnect")
: t("regenerateCredentialsButton")
}
onConfirm={handleConfirmRegenerate}
string={getConfirmationString()}
title={t("regenerateCredentials")}
warningText={t("cannotbeUndone")}
/>
</SettingsContainer>
</>
);
}
}

View File

@@ -35,7 +35,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
const navItems = [
{
title: t('credentials'),
title: t("credentials"),
href: "/{orgId}/settings/remote-exit-nodes/{remoteExitNodeId}/credentials"
}
];

View File

@@ -86,7 +86,7 @@ export default function CreateRemoteExitNodePage() {
useEffect(() => {
const remoteExitNodeId = searchParams.get("remoteExitNodeId");
const remoteExitNodeSecret = searchParams.get("remoteExitNodeSecret");
if (remoteExitNodeId && remoteExitNodeSecret) {
setStrategy("adopt");
form.setValue("remoteExitNodeId", remoteExitNodeId);

View File

@@ -1,7 +1,9 @@
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
import InvitationsTable, { InvitationRow } from "../../../../../components/InvitationsTable";
import InvitationsTable, {
InvitationRow
} from "../../../../../components/InvitationsTable";
import { GetOrgResponse } from "@server/routers/org";
import { cache } from "react";
import OrgProvider from "@app/providers/OrgProvider";
@@ -9,7 +11,7 @@ import UserProvider from "@app/providers/UserProvider";
import { verifySession } from "@app/lib/auth/verifySession";
import AccessPageHeaderAndNav from "../../../../../components/AccessPageHeaderAndNav";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from 'next-intl/server';
import { getTranslations } from "next-intl/server";
type InvitationsPageProps = {
params: Promise<{ orgId: string }>;
@@ -68,7 +70,7 @@ export default async function InvitationsPage(props: InvitationsPageProps) {
id: invite.inviteId,
email: invite.email,
expiresAt: new Date(Number(invite.expiresAt)).toISOString(),
role: invite.roleName || t('accessRoleUnknown'),
role: invite.roleName || t("accessRoleUnknown"),
roleId: invite.roleId
};
});
@@ -76,8 +78,8 @@ export default async function InvitationsPage(props: InvitationsPageProps) {
return (
<>
<SettingsSectionTitle
title={t('inviteTitle')}
description={t('inviteDescription')}
title={t("inviteTitle")}
description={t("inviteDescription")}
/>
<UserProvider user={user!}>
<OrgProvider org={org}>

View File

@@ -7,7 +7,7 @@ import OrgProvider from "@app/providers/OrgProvider";
import { ListRolesResponse } from "@server/routers/role";
import RolesTable, { RoleRow } from "../../../../../components/RolesTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from 'next-intl/server';
import { getTranslations } from "next-intl/server";
type RolesPageProps = {
params: Promise<{ orgId: string }>;
@@ -66,8 +66,8 @@ export default async function RolesPage(props: RolesPageProps) {
return (
<>
<SettingsSectionTitle
title={t('accessRolesManage')}
description={t('accessRolesDescription')}
title={t("accessRolesManage")}
description={t("accessRolesDescription")}
/>
<OrgProvider org={org}>
<RolesTable roles={roleRows} />

View File

@@ -106,7 +106,8 @@ export default function Page() {
const genericOidcFormSchema = z.object({
username: z.string().min(1, { message: t("usernameRequired") }),
email: z.email({ message: t("emailInvalid") })
email: z
.email({ message: t("emailInvalid") })
.optional()
.or(z.literal("")),
name: z.string().optional(),

View File

@@ -10,7 +10,7 @@ import UserProvider from "@app/providers/UserProvider";
import { verifySession } from "@app/lib/auth/verifySession";
import AccessPageHeaderAndNav from "../../../../../components/AccessPageHeaderAndNav";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from 'next-intl/server';
import { getTranslations } from "next-intl/server";
type UsersPageProps = {
params: Promise<{ orgId: string }>;
@@ -79,9 +79,11 @@ export default async function UsersPage(props: UsersPageProps) {
type: user.type,
idpVariant: user.idpVariant,
idpId: user.idpId,
idpName: user.idpName || t('idpNameInternal'),
status: t('userConfirmed'),
role: user.isOwner ? t('accessRoleOwner') : user.roleName || t('accessRoleMember'),
idpName: user.idpName || t("idpNameInternal"),
status: t("userConfirmed"),
role: user.isOwner
? t("accessRoleOwner")
: user.roleName || t("accessRoleMember"),
isOwner: user.isOwner || false
};
});
@@ -89,8 +91,8 @@ export default async function UsersPage(props: UsersPageProps) {
return (
<>
<SettingsSectionTitle
title={t('accessUsersManage')}
description={t('accessUsersDescription')}
title={t("accessUsersManage")}
description={t("accessUsersDescription")}
/>
<UserProvider user={user!}>
<OrgProvider org={org}>

View File

@@ -6,7 +6,7 @@ import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { GetApiKeyResponse } from "@server/routers/apiKeys";
import ApiKeyProvider from "@app/providers/ApiKeyProvider";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import { getTranslations } from 'next-intl/server';
import { getTranslations } from "next-intl/server";
interface SettingsLayoutProps {
children: React.ReactNode;
@@ -33,14 +33,16 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
const navItems = [
{
title: t('apiKeysPermissionsTitle'),
title: t("apiKeysPermissionsTitle"),
href: "/{orgId}/settings/api-keys/{apiKeyId}/permissions"
}
];
return (
<>
<SettingsSectionTitle title={t('apiKeysSettings', {apiKeyName: apiKey?.name})} />
<SettingsSectionTitle
title={t("apiKeysSettings", { apiKeyName: apiKey?.name })}
/>
<ApiKeyProvider apiKey={apiKey}>
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>

View File

@@ -4,5 +4,7 @@ export default async function ApiKeysPage(props: {
params: Promise<{ orgId: string; apiKeyId: string }>;
}) {
const params = await props.params;
redirect(`/${params.orgId}/settings/api-keys/${params.apiKeyId}/permissions`);
redirect(
`/${params.orgId}/settings/api-keys/${params.apiKeyId}/permissions`
);
}

View File

@@ -45,10 +45,10 @@ export default function Page() {
.catch((e) => {
toast({
variant: "destructive",
title: t('apiKeysPermissionsErrorLoadingActions'),
title: t("apiKeysPermissionsErrorLoadingActions"),
description: formatAxiosError(
e,
t('apiKeysPermissionsErrorLoadingActions')
t("apiKeysPermissionsErrorLoadingActions")
)
});
});
@@ -79,18 +79,18 @@ export default function Page() {
)
})
.catch((e) => {
console.error(t('apiKeysErrorSetPermission'), e);
console.error(t("apiKeysErrorSetPermission"), e);
toast({
variant: "destructive",
title: t('apiKeysErrorSetPermission'),
title: t("apiKeysErrorSetPermission"),
description: formatAxiosError(e)
});
});
if (actionsRes && actionsRes.status === 200) {
toast({
title: t('apiKeysPermissionsUpdated'),
description: t('apiKeysPermissionsUpdatedDescription')
title: t("apiKeysPermissionsUpdated"),
description: t("apiKeysPermissionsUpdatedDescription")
});
}
@@ -104,10 +104,12 @@ export default function Page() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('apiKeysPermissionsGeneralSettings')}
{t("apiKeysPermissionsGeneralSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t('apiKeysPermissionsGeneralSettingsDescription')}
{t(
"apiKeysPermissionsGeneralSettingsDescription"
)}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -124,7 +126,7 @@ export default function Page() {
loading={loadingSavePermissions}
disabled={loadingSavePermissions}
>
{t('apiKeysPermissionsSave')}
{t("apiKeysPermissionsSave")}
</Button>
</SettingsSectionFooter>
</SettingsSectionBody>

View File

@@ -66,10 +66,10 @@ export default function Page() {
name: z
.string()
.min(2, {
message: t('nameMin', {len: 2})
message: t("nameMin", { len: 2 })
})
.max(255, {
message: t('nameMax', {len: 255})
message: t("nameMax", { len: 255 })
})
});
@@ -84,7 +84,7 @@ export default function Page() {
return data.copied;
},
{
message: t('apiKeysConfirmCopy2'),
message: t("apiKeysConfirmCopy2"),
path: ["copied"]
}
);
@@ -119,7 +119,7 @@ export default function Page() {
.catch((e) => {
toast({
variant: "destructive",
title: t('apiKeysErrorCreate'),
title: t("apiKeysErrorCreate"),
description: formatAxiosError(e)
});
});
@@ -140,10 +140,10 @@ export default function Page() {
)
})
.catch((e) => {
console.error(t('apiKeysErrorSetPermission'), e);
console.error(t("apiKeysErrorSetPermission"), e);
toast({
variant: "destructive",
title: t('apiKeysErrorSetPermission'),
title: t("apiKeysErrorSetPermission"),
description: formatAxiosError(e)
});
});
@@ -182,8 +182,8 @@ export default function Page() {
<>
<div className="flex justify-between">
<HeaderTitle
title={t('apiKeysCreate')}
description={t('apiKeysCreateDescription')}
title={t("apiKeysCreate")}
description={t("apiKeysCreateDescription")}
/>
<Button
variant="outline"
@@ -191,7 +191,7 @@ export default function Page() {
router.push(`/${orgId}/settings/api-keys`);
}}
>
{t('apiKeysSeeAll')}
{t("apiKeysSeeAll")}
</Button>
</div>
@@ -203,7 +203,7 @@ export default function Page() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('apiKeysTitle')}
{t("apiKeysTitle")}
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -224,7 +224,7 @@ export default function Page() {
render={({ field }) => (
<FormItem>
<FormLabel>
{t('name')}
{t("name")}
</FormLabel>
<FormControl>
<Input
@@ -245,10 +245,12 @@ export default function Page() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('apiKeysGeneralSettings')}
{t("apiKeysGeneralSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t('apiKeysGeneralSettingsDescription')}
{t(
"apiKeysGeneralSettingsDescription"
)}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -267,14 +269,14 @@ export default function Page() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('apiKeysList')}
{t("apiKeysList")}
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody>
<InfoSections cols={2}>
<InfoSection>
<InfoSectionTitle>
{t('name')}
{t("name")}
</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard
@@ -284,7 +286,7 @@ export default function Page() {
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t('created')}
{t("created")}
</InfoSectionTitle>
<InfoSectionContent>
{moment(
@@ -297,10 +299,10 @@ export default function Page() {
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t('apiKeysSave')}
{t("apiKeysSave")}
</AlertTitle>
<AlertDescription>
{t('apiKeysSaveDescription')}
{t("apiKeysSaveDescription")}
</AlertDescription>
</Alert>
@@ -367,7 +369,7 @@ export default function Page() {
router.push(`/${orgId}/settings/api-keys`);
}}
>
{t('cancel')}
{t("cancel")}
</Button>
)}
{!apiKey && (
@@ -379,7 +381,7 @@ export default function Page() {
form.handleSubmit(onSubmit)();
}}
>
{t('generate')}
{t("generate")}
</Button>
)}
@@ -390,7 +392,7 @@ export default function Page() {
copiedForm.handleSubmit(onCopiedSubmit)();
}}
>
{t('done')}
{t("done")}
</Button>
)}
</div>

View File

@@ -2,9 +2,11 @@ import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import OrgApiKeysTable, { OrgApiKeyRow } from "../../../../components/OrgApiKeysTable";
import OrgApiKeysTable, {
OrgApiKeyRow
} from "../../../../components/OrgApiKeysTable";
import { ListOrgApiKeysResponse } from "@server/routers/apiKeys";
import { getTranslations } from 'next-intl/server';
import { getTranslations } from "next-intl/server";
type ApiKeyPageProps = {
params: Promise<{ orgId: string }>;
@@ -37,8 +39,8 @@ export default async function ApiKeysPage(props: ApiKeyPageProps) {
return (
<>
<SettingsSectionTitle
title={t('apiKeysManage')}
description={t('apiKeysDescription')}
title={t("apiKeysManage")}
description={t("apiKeysDescription")}
/>
<OrgApiKeysTable apiKeys={rows} orgId={params.orgId} />

View File

@@ -1,132 +0,0 @@
"use client";
import RegenerateCredentialsModal from "@app/components/RegenerateCredentialsModal";
import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { Button } from "@app/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@app/components/ui/tooltip";
import { useClientContext } from "@app/hooks/useClientContext";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api";
import { build } from "@server/build";
import { PickClientDefaultsResponse } from "@server/routers/client";
import { useTranslations } from "next-intl";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
export default function CredentialsPage() {
const { env } = useEnvContext();
const api = createApiClient({ env });
const { orgId } = useParams();
const router = useRouter();
const t = useTranslations();
const { client } = useClientContext();
const [modalOpen, setModalOpen] = useState(false);
const [clientDefaults, setClientDefaults] =
useState<PickClientDefaultsResponse | null>(null);
const { licenseStatus, isUnlocked } = useLicenseStatusContext();
const subscription = useSubscriptionStatusContext();
const isSecurityFeatureDisabled = () => {
const isEnterpriseNotLicensed = build === "enterprise" && !isUnlocked();
const isSaasNotSubscribed =
build === "saas" && !subscription?.isSubscribed();
return isEnterpriseNotLicensed || isSaasNotSubscribed;
};
const handleConfirmRegenerate = async () => {
const res = await api.get(`/org/${orgId}/pick-client-defaults`);
if (res && res.status === 200) {
const data = res.data.data;
setClientDefaults(data);
await api.post(
`/re-key/${client?.clientId}/regenerate-client-secret`,
{
olmId: data.olmId,
secret: data.olmSecret
}
);
toast({
title: t("credentialsSaved"),
description: t("credentialsSavedDescription")
});
router.refresh();
}
};
const getCredentials = () => {
if (clientDefaults) {
return {
Id: clientDefaults.olmId,
Secret: clientDefaults.olmSecret
};
}
return undefined;
};
return (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("generatedcredentials")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("regenerateCredentials")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="inline-block">
<Button
onClick={() => setModalOpen(true)}
disabled={isSecurityFeatureDisabled()}
>
{t("regeneratecredentials")}
</Button>
</div>
</TooltipTrigger>
{isSecurityFeatureDisabled() && (
<TooltipContent side="top">
{t("featureDisabledTooltip")}
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
</SettingsSectionBody>
</SettingsSection>
<RegenerateCredentialsModal
open={modalOpen}
onOpenChange={setModalOpen}
type="client-olm"
onConfirmRegenerate={handleConfirmRegenerate}
dashboardUrl={env.app.dashboardUrl}
credentials={getCredentials()}
/>
</SettingsContainer>
);
}

View File

@@ -0,0 +1,257 @@
"use client";
import { useState } from "react";
import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { Button } from "@app/components/ui/button";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { PickClientDefaultsResponse } from "@server/routers/client";
import { useClientContext } from "@app/hooks/useClientContext";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
import { build } from "@server/build";
import { SecurityFeaturesAlert } from "@app/components/SecurityFeaturesAlert";
import {
InfoSection,
InfoSectionContent,
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import CopyToClipboard from "@app/components/CopyToClipboard";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon } from "lucide-react";
export default function CredentialsPage() {
const { env } = useEnvContext();
const api = createApiClient({ env });
const { orgId } = useParams();
const router = useRouter();
const t = useTranslations();
const { client } = useClientContext();
const [modalOpen, setModalOpen] = useState(false);
const [clientDefaults, setClientDefaults] =
useState<PickClientDefaultsResponse | null>(null);
const [currentOlmId, setCurrentOlmId] = useState<string | null>(
client.olmId
);
const [regeneratedSecret, setRegeneratedSecret] = useState<string | null>(
null
);
const [showCredentialsAlert, setShowCredentialsAlert] = useState(false);
const [shouldDisconnect, setShouldDisconnect] = useState(true);
const { licenseStatus, isUnlocked } = useLicenseStatusContext();
const subscription = useSubscriptionStatusContext();
const isSecurityFeatureDisabled = () => {
const isEnterpriseNotLicensed = build === "enterprise" && !isUnlocked();
const isSaasNotSubscribed =
build === "saas" && !subscription?.isSubscribed();
return isEnterpriseNotLicensed || isSaasNotSubscribed;
};
const handleConfirmRegenerate = async () => {
try {
const res = await api.get(`/org/${orgId}/pick-client-defaults`);
if (res && res.status === 200) {
const data = res.data.data;
const rekeyRes = await api.post(
`/re-key/${client?.clientId}/regenerate-client-secret`,
{
secret: data.olmSecret,
disconnect: shouldDisconnect
}
);
if (rekeyRes && rekeyRes.status === 200) {
const rekeyData = rekeyRes.data.data;
if (rekeyData && rekeyData.olmId) {
setCurrentOlmId(rekeyData.olmId);
setRegeneratedSecret(data.olmSecret);
setClientDefaults({
...data,
olmId: rekeyData.olmId
});
setShowCredentialsAlert(true);
}
}
toast({
title: t("credentialsSaved"),
description: t("credentialsSavedDescription")
});
}
} catch (error) {
toast({
variant: "destructive",
title: t("error") || "Error",
description:
formatAxiosError(error) ||
t("credentialsRegenerateError") ||
"Failed to regenerate credentials"
});
}
};
const getConfirmationString = () => {
return client?.name || client?.clientId?.toString() || "My client";
};
const displayOlmId = currentOlmId || clientDefaults?.olmId || null;
const displaySecret = regeneratedSecret || null;
return (
<>
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("clientOlmCredentials")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("clientOlmCredentialsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SecurityFeaturesAlert />
<InfoSections cols={3}>
<InfoSection>
<InfoSectionTitle>
{t("olmEndpoint")}
</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard
text={env.app.dashboardUrl}
/>
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t("olmId")}
</InfoSectionTitle>
<InfoSectionContent>
{displayOlmId ? (
<CopyToClipboard text={displayOlmId} />
) : (
<span>{"••••••••••••••••"}</span>
)}
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t("olmSecretKey")}
</InfoSectionTitle>
<InfoSectionContent>
{displaySecret ? (
<CopyToClipboard text={displaySecret} />
) : (
<span>
{"••••••••••••••••••••••••••••••••"}
</span>
)}
</InfoSectionContent>
</InfoSection>
</InfoSections>
{showCredentialsAlert && displaySecret && (
<Alert variant="neutral" className="mt-4">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("clientCredentialsSave")}
</AlertTitle>
<AlertDescription>
{t("clientCredentialsSaveDescription")}
</AlertDescription>
</Alert>
)}
</SettingsSectionBody>
{build !== "oss" && (
<SettingsSectionFooter>
<Button
variant="outline"
onClick={() => {
setShouldDisconnect(false);
setModalOpen(true);
}}
disabled={isSecurityFeatureDisabled()}
>
{t("regenerateCredentialsButton")}
</Button>
<Button
onClick={() => {
setShouldDisconnect(true);
setModalOpen(true);
}}
disabled={isSecurityFeatureDisabled()}
>
{t("clientRegenerateAndDisconnect")}
</Button>
</SettingsSectionFooter>
)}
</SettingsSection>
</SettingsContainer>
<ConfirmDeleteDialog
open={modalOpen}
setOpen={(val) => {
setModalOpen(val);
// Prevent modal from reopening during refresh
if (!val) {
setTimeout(() => {
router.refresh();
}, 150);
}
}}
dialog={
<div className="space-y-2">
{shouldDisconnect ? (
<>
<p>
{t(
"clientRegenerateAndDisconnectConfirmation"
)}
</p>
<p>
{t("clientRegenerateAndDisconnectWarning")}
</p>
</>
) : (
<>
<p>
{t(
"clientRegenerateCredentialsConfirmation"
)}
</p>
<p>{t("clientRegenerateCredentialsWarning")}</p>
</>
)}
</div>
}
buttonText={
shouldDisconnect
? t("clientRegenerateAndDisconnect")
: t("regenerateCredentialsButton")
}
onConfirm={handleConfirmRegenerate}
string={getConfirmationString()}
title={t("regenerateCredentials")}
warningText={t("cannotbeUndone")}
/>
</>
);
}

View File

@@ -34,7 +34,8 @@ import { useForm } from "react-hook-form";
import { z } from "zod";
const GeneralFormSchema = z.object({
name: z.string().nonempty("Name is required")
name: z.string().nonempty("Name is required"),
niceId: z.string().min(1).max(255).optional()
});
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
@@ -49,7 +50,8 @@ export default function GeneralPage() {
const form = useForm({
resolver: zodResolver(GeneralFormSchema),
defaultValues: {
name: client?.name
name: client?.name,
niceId: client?.niceId || ""
},
mode: "onChange"
});
@@ -84,10 +86,11 @@ export default function GeneralPage() {
try {
await api.post(`/client/${client?.clientId}`, {
name: data.name
name: data.name,
niceId: data.niceId
});
updateClient({ name: data.name });
updateClient({ name: data.name, niceId: data.niceId });
toast({
title: t("clientUpdated"),
@@ -139,6 +142,28 @@ export default function GeneralPage() {
</FormItem>
)}
/>
<FormField
control={form.control}
name="niceId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("identifier")}
</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t(
"enterIdentifier"
)}
className="flex-1"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>

View File

@@ -4,7 +4,6 @@ import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import ClientProvider from "@app/providers/ClientProvider";
import { build } from "@server/build";
import { GetClientResponse } from "@server/routers/client";
import { AxiosResponse } from "axios";
import { getTranslations } from "next-intl/server";
@@ -12,7 +11,7 @@ import { redirect } from "next/navigation";
type SettingsLayoutProps = {
children: React.ReactNode;
params: Promise<{ clientId: number | string; orgId: string }>;
params: Promise<{ niceId: number | string; orgId: string }>;
};
export default async function SettingsLayout(props: SettingsLayoutProps) {
@@ -22,8 +21,12 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
let client = null;
try {
console.log(
"making request to ",
`/org/${params.orgId}/client/${params.niceId}`
);
const res = await internal.get<AxiosResponse<GetClientResponse>>(
`/client/${params.clientId}`,
`/org/${params.orgId}/client/${params.niceId}`,
await authCookieHeader()
);
client = res.data.data;
@@ -37,16 +40,12 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
const navItems = [
{
title: t("general"),
href: `/{orgId}/settings/clients/machine/{clientId}/general`
href: `/{orgId}/settings/clients/machine/{niceId}/general`
},
...(build === "enterprise"
? [
{
title: t("credentials"),
href: `/{orgId}/settings/clients/machine/{clientId}/credentials`
}
]
: [])
{
title: t("credentials"),
href: `/{orgId}/settings/clients/machine/{niceId}/credentials`
}
];
return (

View File

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

View File

@@ -136,7 +136,7 @@ export default function Page() {
All: [
{
title: t("install"),
command: `curl -fsSL https://pangolin.net/get-olm.sh | bash`
command: `curl -fsSL https://static.pangolin.net/get-olm.sh | bash`
},
{
title: t("run"),
@@ -276,7 +276,7 @@ export default function Page() {
if (res && res.status === 201) {
const data = res.data.data;
router.push(`/${orgId}/settings/clients/machine/${data.clientId}`);
router.push(`/${orgId}/settings/clients/machine/${data.niceId}`);
}
setCreateLoading(false);

View File

@@ -56,7 +56,9 @@ export default async function ClientsPage(props: ClientsPageProps) {
olmUpdateAvailable: client.olmUpdateAvailable || false,
userId: client.userId,
username: client.username,
userEmail: client.userEmail
userEmail: client.userEmail,
niceId: client.niceId,
agent: client.agent
};
};

View File

@@ -53,7 +53,9 @@ export default async function ClientsPage(props: ClientsPageProps) {
olmUpdateAvailable: client.olmUpdateAvailable || false,
userId: client.userId,
username: client.username,
userEmail: client.userEmail
userEmail: client.userEmail,
niceId: client.niceId,
agent: client.agent
};
};

View File

@@ -5,7 +5,6 @@ import OrgProvider from "@app/providers/OrgProvider";
import OrgUserProvider from "@app/providers/OrgUserProvider";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser";

View File

@@ -102,7 +102,12 @@ const LOG_RETENTION_OPTIONS = [
{ label: "logRetention14Days", value: 14 },
{ label: "logRetention30Days", value: 30 },
{ label: "logRetention90Days", value: 90 },
...(build !== "saas" ? [{ label: "logRetentionForever", value: -1 }] : [])
...(build != "saas"
? [
{ label: "logRetentionForever", value: -1 },
{ label: "logRetentionEndOfFollowingYear", value: 9001 }
]
: [])
];
export default function GeneralPage() {
@@ -265,7 +270,7 @@ export default function GeneralPage() {
setIsDeleteModalOpen(val);
}}
dialog={
<div>
<div className="space-y-2">
<p>{t("orgQuestionRemove")}</p>
<p>{t("orgMessageRemove")}</p>
</div>
@@ -279,7 +284,7 @@ export default function GeneralPage() {
open={isSecurityPolicyConfirmOpen}
setOpen={setIsSecurityPolicyConfirmOpen}
dialog={
<div>
<div className="space-y-2">
<p>{t("securityPolicyChangeDescription")}</p>
</div>
}

View File

@@ -1,16 +1,12 @@
"use client";
import { Button } from "@app/components/ui/button";
import { toast } from "@app/hooks/useToast";
import { useState, useRef, useEffect } from "react";
import { useState, useRef, useEffect, useTransition } from "react";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import {
getStoredPageSize,
LogDataTable,
setStoredPageSize
} from "@app/components/LogDataTable";
import { LogDataTable } from "@app/components/LogDataTable";
import { ColumnDef } from "@tanstack/react-table";
import { DateTimeValue } from "@app/components/DateTimePicker";
import { ArrowUpRight, Key, User } from "lucide-react";
@@ -21,21 +17,22 @@ import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusCo
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { build } from "@server/build";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
import axios from "axios";
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
export default function GeneralPage() {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const router = useRouter();
const searchParams = useSearchParams();
const api = createApiClient(useEnvContext());
const t = useTranslations();
const { env } = useEnvContext();
const { orgId } = useParams();
const subscription = useSubscriptionStatusContext();
const { isUnlocked } = useLicenseStatusContext();
const [rows, setRows] = useState<any[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const [isExporting, startTransition] = useTransition();
const [filterAttributes, setFilterAttributes] = useState<{
actors: string[];
resources: {
@@ -70,9 +67,7 @@ export default function GeneralPage() {
const [isLoading, setIsLoading] = useState(false);
// Initialize page size from storage or default
const [pageSize, setPageSize] = useState<number>(() => {
return getStoredPageSize("access-audit-logs", 20);
});
const [pageSize, setPageSize] = useStoredPageSize("access-audit-logs", 20);
// Set default date range to last 24 hours
const getDefaultDateRange = () => {
@@ -91,11 +86,11 @@ export default function GeneralPage() {
}
const now = new Date();
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const lastWeek = getSevenDaysAgo();
return {
startDate: {
date: yesterday
date: lastWeek
},
endDate: {
date: now
@@ -148,7 +143,6 @@ export default function GeneralPage() {
// Handle page size changes
const handlePageSizeChange = (newPageSize: number) => {
setPageSize(newPageSize);
setStoredPageSize(newPageSize, "access-audit-logs");
setCurrentPage(0); // Reset to first page when changing page size
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
};
@@ -309,8 +303,6 @@ export default function GeneralPage() {
const exportData = async () => {
try {
setIsExporting(true);
// Prepare query params for export
const params: any = {
timeStart: dateRange.startDate?.date
@@ -339,11 +331,21 @@ export default function GeneralPage() {
document.body.appendChild(link);
link.click();
link.parentNode?.removeChild(link);
setIsExporting(false);
} catch (error) {
let apiErrorMessage: string | null = null;
if (axios.isAxiosError(error) && error.response) {
const data = error.response.data;
if (data instanceof Blob && data.type === "application/json") {
// Parse the Blob as JSON
const text = await data.text();
const errorData = JSON.parse(text);
apiErrorMessage = errorData.message;
}
}
toast({
title: t("error"),
description: t("exportError"),
description: apiErrorMessage ?? t("exportError"),
variant: "destructive"
});
}
@@ -631,7 +633,7 @@ export default function GeneralPage() {
title={t("accessLogs")}
onRefresh={refreshData}
isRefreshing={isRefreshing}
onExport={exportData}
onExport={() => startTransition(exportData)}
isExporting={isExporting}
onDateRangeChange={handleDateRangeChange}
dateRange={{

View File

@@ -1,32 +1,28 @@
"use client";
import { Button } from "@app/components/ui/button";
import { toast } from "@app/hooks/useToast";
import { useState, useRef, useEffect } from "react";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import {
getStoredPageSize,
LogDataTable,
setStoredPageSize
} from "@app/components/LogDataTable";
import { ColumnDef } from "@tanstack/react-table";
import { DateTimeValue } from "@app/components/DateTimePicker";
import { Key, User } from "lucide-react";
import { ColumnFilter } from "@app/components/ColumnFilter";
import { DateTimeValue } from "@app/components/DateTimePicker";
import { LogDataTable } from "@app/components/LogDataTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { build } from "@server/build";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
import { build } from "@server/build";
import { ColumnDef } from "@tanstack/react-table";
import axios from "axios";
import { Key, User } from "lucide-react";
import { useTranslations } from "next-intl";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
export default function GeneralPage() {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const router = useRouter();
const api = createApiClient(useEnvContext());
const t = useTranslations();
const { env } = useEnvContext();
const { orgId } = useParams();
const searchParams = useSearchParams();
const subscription = useSubscriptionStatusContext();
@@ -34,7 +30,7 @@ export default function GeneralPage() {
const [rows, setRows] = useState<any[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const [isExporting, startTransition] = useTransition();
const [filterAttributes, setFilterAttributes] = useState<{
actors: string[];
actions: string[];
@@ -58,9 +54,7 @@ export default function GeneralPage() {
const [isLoading, setIsLoading] = useState(false);
// Initialize page size from storage or default
const [pageSize, setPageSize] = useState<number>(() => {
return getStoredPageSize("action-audit-logs", 20);
});
const [pageSize, setPageSize] = useStoredPageSize("action-audit-logs", 20);
// Set default date range to last 24 hours
const getDefaultDateRange = () => {
@@ -79,11 +73,11 @@ export default function GeneralPage() {
}
const now = new Date();
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const lastWeek = getSevenDaysAgo();
return {
startDate: {
date: yesterday
date: lastWeek
},
endDate: {
date: now
@@ -136,7 +130,6 @@ export default function GeneralPage() {
// Handle page size changes
const handlePageSizeChange = (newPageSize: number) => {
setPageSize(newPageSize);
setStoredPageSize(newPageSize, "action-audit-logs");
setCurrentPage(0); // Reset to first page when changing page size
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
};
@@ -293,8 +286,6 @@ export default function GeneralPage() {
const exportData = async () => {
try {
setIsExporting(true);
// Prepare query params for export
const params: any = {
timeStart: dateRange.startDate?.date
@@ -323,11 +314,21 @@ export default function GeneralPage() {
document.body.appendChild(link);
link.click();
link.parentNode?.removeChild(link);
setIsExporting(false);
} catch (error) {
let apiErrorMessage: string | null = null;
if (axios.isAxiosError(error) && error.response) {
const data = error.response.data;
if (data instanceof Blob && data.type === "application/json") {
// Parse the Blob as JSON
const text = await data.text();
const errorData = JSON.parse(text);
apiErrorMessage = errorData.message;
}
}
toast({
title: t("error"),
description: t("exportError"),
description: apiErrorMessage ?? t("exportError"),
variant: "destructive"
});
}
@@ -484,7 +485,7 @@ export default function GeneralPage() {
searchColumn="action"
onRefresh={refreshData}
isRefreshing={isRefreshing}
onExport={exportData}
onExport={() => startTransition(exportData)}
isExporting={isExporting}
onDateRangeChange={handleDateRangeChange}
dateRange={{

View File

@@ -1,30 +1,32 @@
"use client";
import { Button } from "@app/components/ui/button";
import { toast } from "@app/hooks/useToast";
import { useState, useRef, useEffect } from "react";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { getStoredPageSize, LogDataTable, setStoredPageSize } from "@app/components/LogDataTable";
import { ColumnDef } from "@tanstack/react-table";
import { DateTimeValue } from "@app/components/DateTimePicker";
import { Key, RouteOff, User, Lock, Unlock, ArrowUpRight } from "lucide-react";
import Link from "next/link";
import { ColumnFilter } from "@app/components/ColumnFilter";
import { DateTimeValue } from "@app/components/DateTimePicker";
import { LogDataTable } from "@app/components/LogDataTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { Button } from "@app/components/ui/button";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api";
import { useTranslations } from "next-intl";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
import { ColumnDef } from "@tanstack/react-table";
import axios from "axios";
import { ArrowUpRight, Key, Lock, Unlock, User } from "lucide-react";
import Link from "next/link";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
export default function GeneralPage() {
const router = useRouter();
const api = createApiClient(useEnvContext());
const t = useTranslations();
const { env } = useEnvContext();
const { orgId } = useParams();
const searchParams = useSearchParams();
const [rows, setRows] = useState<any[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const [isExporting, startTransition] = useTransition();
// Pagination state
const [totalCount, setTotalCount] = useState<number>(0);
@@ -32,9 +34,7 @@ export default function GeneralPage() {
const [isLoading, setIsLoading] = useState(false);
// Initialize page size from storage or default
const [pageSize, setPageSize] = useState<number>(() => {
return getStoredPageSize("request-audit-logs", 20);
});
const [pageSize, setPageSize] = useStoredPageSize("request-audit-logs", 20);
const [filterAttributes, setFilterAttributes] = useState<{
actors: string[];
@@ -91,11 +91,11 @@ export default function GeneralPage() {
}
const now = new Date();
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const lastWeek = getSevenDaysAgo();
return {
startDate: {
date: yesterday
date: lastWeek
},
endDate: {
date: now
@@ -148,7 +148,6 @@ export default function GeneralPage() {
// Handle page size changes
const handlePageSizeChange = (newPageSize: number) => {
setPageSize(newPageSize);
setStoredPageSize(newPageSize, "request-audit-logs");
setCurrentPage(0); // Reset to first page when changing page size
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
};
@@ -298,8 +297,6 @@ export default function GeneralPage() {
const exportData = async () => {
try {
setIsExporting(true);
// Prepare query params for export
const params: any = {
timeStart: dateRange.startDate?.date
@@ -331,11 +328,21 @@ export default function GeneralPage() {
document.body.appendChild(link);
link.click();
link.parentNode?.removeChild(link);
setIsExporting(false);
} catch (error) {
let apiErrorMessage: string | null = null;
if (axios.isAxiosError(error) && error.response) {
const data = error.response.data;
if (data instanceof Blob && data.type === "application/json") {
// Parse the Blob as JSON
const text = await data.text();
const errorData = JSON.parse(text);
apiErrorMessage = errorData.message;
}
}
toast({
title: t("error"),
description: t("exportError"),
description: apiErrorMessage ?? t("exportError"),
variant: "destructive"
});
}
@@ -757,8 +764,8 @@ export default function GeneralPage() {
return (
<>
<SettingsSectionTitle
title={t('requestLogs')}
description={t('requestLogsDescription')}
title={t("requestLogs")}
description={t("requestLogsDescription")}
/>
<LogDataTable
@@ -769,7 +776,7 @@ export default function GeneralPage() {
searchColumn="host"
onRefresh={refreshData}
isRefreshing={isRefreshing}
onExport={exportData}
onExport={() => startTransition(exportData)}
isExporting={isExporting}
onDateRangeChange={handleDateRangeChange}
dateRange={{

View File

@@ -66,7 +66,11 @@ export default async function ClientResourcesPage(
destination: siteResource.destination,
// destinationPort: siteResource.destinationPort,
alias: siteResource.alias || null,
siteNiceId: siteResource.siteNiceId
siteNiceId: siteResource.siteNiceId,
niceId: siteResource.niceId,
tcpPortRangeString: siteResource.tcpPortRangeString || null,
udpPortRangeString: siteResource.udpPortRangeString || null,
disableIcmp: siteResource.disableIcmp || false,
};
}
);

View File

@@ -837,7 +837,9 @@ export default function ResourceAuthenticationPage() {
<Bot size="14" />
<span>
{authInfo.headerAuth
? t("resourceHeaderAuthProtectionEnabled")
? t(
"resourceHeaderAuthProtectionEnabled"
)
: t(
"resourceHeaderAuthProtectionDisabled"
)}
@@ -921,7 +923,8 @@ export default function ResourceAuthenticationPage() {
validateTag={(
tag
) => {
return z.email()
return z
.email()
.or(
z
.string()

View File

@@ -16,7 +16,7 @@ import {
import { Input } from "@/components/ui/input";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { ListSitesResponse } from "@server/routers/site";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { AxiosResponse } from "axios";
import { useParams, useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
@@ -90,8 +90,15 @@ export default function GeneralForm() {
const [resourceFullDomain, setResourceFullDomain] = useState(
`${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`
);
const resourceFullDomainName = useMemo(() => {
const url = new URL(resourceFullDomain);
return url.hostname;
}, [resourceFullDomain]);
const [selectedDomain, setSelectedDomain] = useState<{
domainId: string;
domainNamespaceId?: string;
subdomain?: string;
fullDomain: string;
baseDomain: string;
@@ -104,7 +111,7 @@ export default function GeneralForm() {
name: z.string().min(1).max(255),
niceId: z.string().min(1).max(255).optional(),
domainId: z.string().optional(),
proxyPort: z.int().min(1).max(65535).optional(),
proxyPort: z.int().min(1).max(65535).optional()
// enableProxy: z.boolean().optional()
})
.refine(
@@ -134,7 +141,7 @@ export default function GeneralForm() {
niceId: resource.niceId,
subdomain: resource.subdomain ? resource.subdomain : undefined,
domainId: resource.domainId || undefined,
proxyPort: resource.proxyPort || undefined,
proxyPort: resource.proxyPort || undefined
// enableProxy: resource.enableProxy || false
},
mode: "onChange"
@@ -168,7 +175,7 @@ export default function GeneralForm() {
const rawDomains = res.data.data.domains as DomainRow[];
const domains = rawDomains.map((domain) => ({
...domain,
baseDomain: toUnicode(domain.baseDomain),
baseDomain: toUnicode(domain.baseDomain)
}));
setBaseDomains(domains);
setFormKey((key) => key + 1);
@@ -195,9 +202,11 @@ export default function GeneralForm() {
enabled: data.enabled,
name: data.name,
niceId: data.niceId,
subdomain: data.subdomain ? toASCII(data.subdomain) : undefined,
subdomain: data.subdomain
? toASCII(data.subdomain)
: undefined,
domainId: data.domainId,
proxyPort: data.proxyPort,
proxyPort: data.proxyPort
// ...(!resource.http && {
// enableProxy: data.enableProxy
// })
@@ -222,8 +231,9 @@ export default function GeneralForm() {
name: data.name,
niceId: data.niceId,
subdomain: data.subdomain,
fullDomain: resource.fullDomain,
fullDomain: updated.fullDomain,
proxyPort: data.proxyPort,
domainId: data.domainId
// ...(!resource.http && {
// enableProxy: data.enableProxy
// })
@@ -235,7 +245,9 @@ export default function GeneralForm() {
});
if (data.niceId && data.niceId !== resource?.niceId) {
router.replace(`/${updated.orgId}/settings/resources/proxy/${data.niceId}/general`);
router.replace(
`/${updated.orgId}/settings/resources/proxy/${data.niceId}/general`
);
} else {
router.refresh();
}
@@ -320,11 +332,15 @@ export default function GeneralForm() {
name="niceId"
render={({ field }) => (
<FormItem>
<FormLabel>{t("identifier")}</FormLabel>
<FormLabel>
{t("identifier")}
</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t("enterIdentifier")}
placeholder={t(
"enterIdentifier"
)}
className="flex-1"
/>
</FormControl>
@@ -360,10 +376,10 @@ export default function GeneralForm() {
.target
.value
? parseInt(
e
.target
.value
)
e
.target
.value
)
: undefined
)
}
@@ -480,13 +496,27 @@ export default function GeneralForm() {
<DomainPicker
orgId={orgId as string}
cols={1}
defaultSubdomain={
form.getValues("subdomain") ??
resource.subdomain
}
defaultDomainId={
form.getValues("domainId") ??
resource.domainId
}
defaultFullDomain={resourceFullDomainName}
onDomainChange={(res) => {
const selected = {
domainId: res.domainId,
subdomain: res.subdomain,
fullDomain: res.fullDomain,
baseDomain: res.baseDomain
};
const selected =
res === null
? null
: {
domainId: res.domainId,
subdomain: res.subdomain,
fullDomain: res.fullDomain,
baseDomain: res.baseDomain,
domainNamespaceId:
res.domainNamespaceId
};
setSelectedDomain(selected);
}}
/>
@@ -498,17 +528,29 @@ export default function GeneralForm() {
<Button
onClick={() => {
if (selectedDomain) {
const sanitizedSubdomain = selectedDomain.subdomain
? finalizeSubdomainSanitize(selectedDomain.subdomain)
: "";
const sanitizedSubdomain =
selectedDomain.subdomain
? finalizeSubdomainSanitize(
selectedDomain.subdomain
)
: "";
const sanitizedFullDomain = sanitizedSubdomain
? `${sanitizedSubdomain}.${selectedDomain.baseDomain}`
: selectedDomain.baseDomain;
const sanitizedFullDomain =
sanitizedSubdomain
? `${sanitizedSubdomain}.${selectedDomain.baseDomain}`
: selectedDomain.baseDomain;
setResourceFullDomain(`${resource.ssl ? "https" : "http"}://${sanitizedFullDomain}`);
form.setValue("domainId", selectedDomain.domainId);
form.setValue("subdomain", sanitizedSubdomain);
setResourceFullDomain(
`${resource.ssl ? "https" : "http"}://${sanitizedFullDomain}`
);
form.setValue(
"domainId",
selectedDomain.domainId
);
form.setValue(
"subdomain",
sanitizedSubdomain
);
setEditDomainOpen(false);
}

View File

@@ -14,7 +14,7 @@ import OrgProvider from "@app/providers/OrgProvider";
import { cache } from "react";
import ResourceInfoBox from "@app/components/ResourceInfoBox";
import { GetSiteResponse } from "@server/routers/site";
import { getTranslations } from 'next-intl/server';
import { getTranslations } from "next-intl/server";
interface ResourceLayoutProps {
children: React.ReactNode;
@@ -76,22 +76,22 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
const navItems = [
{
title: t('general'),
title: t("general"),
href: `/{orgId}/settings/resources/proxy/{niceId}/general`
},
{
title: t('proxy'),
title: t("proxy"),
href: `/{orgId}/settings/resources/proxy/{niceId}/proxy`
}
];
if (resource.http) {
navItems.push({
title: t('authentication'),
title: t("authentication"),
href: `/{orgId}/settings/resources/proxy/{niceId}/authentication`
});
navItems.push({
title: t('rules'),
title: t("rules"),
href: `/{orgId}/settings/resources/proxy/{niceId}/rules`
});
}
@@ -99,15 +99,12 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
return (
<>
<SettingsSectionTitle
title={t('resourceSetting', {resourceName: resource?.name})}
description={t('resourceSettingDescription')}
title={t("resourceSetting", { resourceName: resource?.name })}
description={t("resourceSettingDescription")}
/>
<OrgProvider org={org}>
<ResourceProvider
resource={resource}
authInfo={authInfo}
>
<ResourceProvider resource={resource} authInfo={authInfo}>
<div className="space-y-6">
<ResourceInfoBox />
<HorizontalTabs items={navItems}>

View File

@@ -123,10 +123,9 @@ const addTargetSchema = z
ip: z.string().refine(isTargetValid),
method: z.string().nullable(),
port: z.coerce.number<number>().int().positive(),
siteId: z.int()
.positive({
error: "You must select a site for a target."
}),
siteId: z.int().positive({
error: "You must select a site for a target."
}),
path: z.string().optional().nullable(),
pathMatchType: z
.enum(["exact", "prefix", "regex"])
@@ -179,8 +178,12 @@ const addTargetSchema = z
return false;
}
// If rewritePathType is provided, rewritePath must be provided
// Exception: stripPrefix can have an empty rewritePath (to just strip the prefix)
if (data.rewritePathType && !data.rewritePath) {
return false;
// Allow empty rewritePath for stripPrefix type
if (data.rewritePathType !== "stripPrefix") {
return false;
}
}
return true;
},
@@ -546,11 +549,11 @@ export default function ReverseProxyTargets(props: {
prev.map((t) =>
t.targetId === target.targetId
? {
...t,
targetId: response.data.data.targetId,
new: false,
updated: false
}
...t,
targetId: response.data.data.targetId,
new: false,
updated: false
}
: t
)
);
@@ -607,16 +610,16 @@ export default function ReverseProxyTargets(props: {
const newTarget: LocalTarget = {
...data,
path: isHttp ? (data.path || null) : null,
pathMatchType: isHttp ? (data.pathMatchType || null) : null,
rewritePath: isHttp ? (data.rewritePath || null) : null,
rewritePathType: isHttp ? (data.rewritePathType || null) : null,
path: isHttp ? data.path || null : null,
pathMatchType: isHttp ? data.pathMatchType || null : null,
rewritePath: isHttp ? data.rewritePath || null : null,
rewritePathType: isHttp ? data.rewritePathType || null : null,
siteType: site?.type || null,
enabled: true,
targetId: new Date().getTime(),
new: true,
resourceId: resource.resourceId,
priority: isHttp ? (data.priority || 100) : 100,
priority: isHttp ? data.priority || 100 : 100,
hcEnabled: false,
hcPath: null,
hcMethod: null,
@@ -631,7 +634,7 @@ export default function ReverseProxyTargets(props: {
hcStatus: null,
hcMode: null,
hcUnhealthyInterval: null,
hcTlsServerName: null,
hcTlsServerName: null
};
setTargets([...targets, newTarget]);
@@ -653,11 +656,11 @@ export default function ReverseProxyTargets(props: {
targets.map((target) =>
target.targetId === targetId
? {
...target,
...data,
updated: true,
siteType: site ? site.type : target.siteType
}
...target,
...data,
updated: true,
siteType: site ? site.type : target.siteType
}
: target
)
);
@@ -668,10 +671,10 @@ export default function ReverseProxyTargets(props: {
targets.map((target) =>
target.targetId === targetId
? {
...target,
...config,
updated: true
}
...target,
...config,
updated: true
}
: target
)
);
@@ -733,7 +736,7 @@ export default function ReverseProxyTargets(props: {
hcStatus: target.hcStatus || null,
hcUnhealthyInterval: target.hcUnhealthyInterval || null,
hcMode: target.hcMode || null,
hcTlsServerName: target.hcTlsServerName,
hcTlsServerName: target.hcTlsServerName
};
// Only include path-related fields for HTTP resources
@@ -833,7 +836,7 @@ export default function ReverseProxyTargets(props: {
const priorityColumn: ColumnDef<LocalTarget> = {
id: "priority",
header: () => (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 p-3">
{t("priority")}
<TooltipProvider>
<Tooltip>
@@ -877,7 +880,7 @@ export default function ReverseProxyTargets(props: {
const healthCheckColumn: ColumnDef<LocalTarget> = {
accessorKey: "healthCheck",
header: () => (<span className="p-3">{t("healthCheck")}</span>),
header: () => <span className="p-3">{t("healthCheck")}</span>,
cell: ({ row }) => {
const status = row.original.hcHealth || "unknown";
const isEnabled = row.original.hcEnabled;
@@ -923,18 +926,17 @@ export default function ReverseProxyTargets(props: {
{row.original.siteType === "newt" ? (
<Button
variant="outline"
className="flex items-center justify-between gap-2 p-2 w-full text-left cursor-pointer"
className="flex items-center gap-2 w-full text-left cursor-pointer"
onClick={() =>
openHealthCheckDialog(row.original)
}
>
<Badge variant={getStatusColor(status)}>
<div className="flex items-center gap-1">
{getStatusIcon(status)}
{getStatusText(status)}
</div>
</Badge>
<Settings className="h-4 w-4" />
<div
className={`flex items-center gap-1 ${status === "healthy" ? "text-green-500" : status === "unhealthy" ? "text-destructive" : ""}`}
>
<Settings className="h-4 w-4" />
{getStatusText(status)}
</div>
</Button>
) : (
<span>-</span>
@@ -949,7 +951,7 @@ export default function ReverseProxyTargets(props: {
const matchPathColumn: ColumnDef<LocalTarget> = {
accessorKey: "path",
header: () => (<span className="p-3">{t("matchPath")}</span>),
header: () => <span className="p-3">{t("matchPath")}</span>,
cell: ({ row }) => {
const hasPathMatch = !!(
row.original.path || row.original.pathMatchType
@@ -1011,7 +1013,7 @@ export default function ReverseProxyTargets(props: {
const addressColumn: ColumnDef<LocalTarget> = {
accessorKey: "address",
header: () => (<span className="p-3">{t("address")}</span>),
header: () => <span className="p-3">{t("address")}</span>,
cell: ({ row }) => {
const selectedSite = sites.find(
(site) => site.siteId === row.original.siteId
@@ -1064,7 +1066,7 @@ export default function ReverseProxyTargets(props: {
className={cn(
"w-[180px] justify-between text-sm border-r pr-4 rounded-none h-8 hover:bg-transparent",
!row.original.siteId &&
"text-muted-foreground"
"text-muted-foreground"
)}
>
<span className="truncate max-w-[150px]">
@@ -1132,8 +1134,12 @@ export default function ReverseProxyTargets(props: {
{row.original.method || "http"}
</SelectTrigger>
<SelectContent>
<SelectItem value="http">http</SelectItem>
<SelectItem value="https">https</SelectItem>
<SelectItem value="http">
http
</SelectItem>
<SelectItem value="https">
https
</SelectItem>
<SelectItem value="h2c">h2c</SelectItem>
</SelectContent>
</Select>
@@ -1147,7 +1153,7 @@ export default function ReverseProxyTargets(props: {
<Input
defaultValue={row.original.ip}
placeholder="IP / Hostname"
placeholder="Host"
className="flex-1 min-w-[120px] pl-0 border-none placeholder-gray-400"
onBlur={(e) => {
const input = e.target.value.trim();
@@ -1225,7 +1231,7 @@ export default function ReverseProxyTargets(props: {
const rewritePathColumn: ColumnDef<LocalTarget> = {
accessorKey: "rewritePath",
header: () => (<span className="p-3">{t("rewritePath")}</span>),
header: () => <span className="p-3">{t("rewritePath")}</span>,
cell: ({ row }) => {
const hasRewritePath = !!(
row.original.rewritePath || row.original.rewritePathType
@@ -1295,7 +1301,7 @@ export default function ReverseProxyTargets(props: {
const enabledColumn: ColumnDef<LocalTarget> = {
accessorKey: "enabled",
header: () => (<span className="p-3">{t("enabled")}</span>),
header: () => <span className="p-3">{t("enabled")}</span>,
cell: ({ row }) => (
<div className="flex items-center justify-center w-full">
<Switch
@@ -1316,7 +1322,7 @@ export default function ReverseProxyTargets(props: {
const actionsColumn: ColumnDef<LocalTarget> = {
id: "actions",
header: () => (<span className="p-3">{t("actions")}</span>),
header: () => <span className="p-3">{t("actions")}</span>,
cell: ({ row }) => (
<div className="flex items-center w-full">
<Button
@@ -1399,21 +1405,30 @@ export default function ReverseProxyTargets(props: {
<TableRow key={headerGroup.id}>
{headerGroup.headers.map(
(header) => {
const isActionsColumn = header.column.id === "actions";
const isActionsColumn =
header.column
.id ===
"actions";
return (
<TableHead
key={header.id}
className={isActionsColumn ? "sticky right-0 z-10 w-auto min-w-fit bg-card" : ""}
key={
header.id
}
className={
isActionsColumn
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: ""
}
>
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
header
.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
);
}
@@ -1430,13 +1445,20 @@ export default function ReverseProxyTargets(props: {
{row
.getVisibleCells()
.map((cell) => {
const isActionsColumn = cell.column.id === "actions";
const isActionsColumn =
cell.column
.id ===
"actions";
return (
<TableCell
key={
cell.id
}
className={isActionsColumn ? "sticky right-0 z-10 w-auto min-w-fit bg-card" : ""}
className={
isActionsColumn
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: ""
}
>
{flexRender(
cell
@@ -1492,7 +1514,7 @@ export default function ReverseProxyTargets(props: {
</div>
</>
) : (
<div className="text-center py-8 border-2 border-dashed border-muted rounded-lg p-4">
<div className="text-center p-4">
<p className="text-muted-foreground mb-4">
{t("targetNoOne")}
</p>
@@ -1721,7 +1743,9 @@ export default function ReverseProxyTargets(props: {
defaultChecked={
field.value || false
}
onCheckedChange={(val) => {
onCheckedChange={(
val
) => {
field.onChange(val);
}}
/>
@@ -1730,19 +1754,37 @@ export default function ReverseProxyTargets(props: {
)}
/>
{proxySettingsForm.watch("proxyProtocol") && (
{proxySettingsForm.watch(
"proxyProtocol"
) && (
<>
<FormField
control={proxySettingsForm.control}
control={
proxySettingsForm.control
}
name="proxyProtocolVersion"
render={({ field }) => (
<FormItem>
<FormLabel>{t("proxyProtocolVersion")}</FormLabel>
<FormLabel>
{t(
"proxyProtocolVersion"
)}
</FormLabel>
<FormControl>
<Select
value={String(field.value || 1)}
onValueChange={(value) =>
field.onChange(parseInt(value, 10))
value={String(
field.value ||
1
)}
onValueChange={(
value
) =>
field.onChange(
parseInt(
value,
10
)
)
}
>
<SelectTrigger>
@@ -1750,16 +1792,22 @@ export default function ReverseProxyTargets(props: {
</SelectTrigger>
<SelectContent>
<SelectItem value="1">
{t("version1")}
{t(
"version1"
)}
</SelectItem>
<SelectItem value="2">
{t("version2")}
{t(
"version2"
)}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
{t("versionDescription")}
{t(
"versionDescription"
)}
</FormDescription>
</FormItem>
)}
@@ -1768,7 +1816,10 @@ export default function ReverseProxyTargets(props: {
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
<strong>{t("warning")}:</strong> {t("proxyProtocolWarning")}
<strong>
{t("warning")}:
</strong>{" "}
{t("proxyProtocolWarning")}
</AlertDescription>
</Alert>
</>
@@ -1835,8 +1886,9 @@ export default function ReverseProxyTargets(props: {
hcUnhealthyInterval:
selectedTargetForHealthCheck.hcUnhealthyInterval ||
30,
hcTlsServerName: selectedTargetForHealthCheck.hcTlsServerName ||
undefined,
hcTlsServerName:
selectedTargetForHealthCheck.hcTlsServerName ||
undefined
}}
onChanges={async (config) => {
if (selectedTargetForHealthCheck) {

View File

@@ -114,23 +114,25 @@ export default function ResourceRules(props: {
const [rulesEnabled, setRulesEnabled] = useState(resource.applyRules);
const [openCountrySelect, setOpenCountrySelect] = useState(false);
const [countrySelectValue, setCountrySelectValue] = useState("");
const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] = useState(false);
const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] =
useState(false);
const router = useRouter();
const t = useTranslations();
const { env } = useEnvContext();
const isMaxmindAvailable = env.server.maxmind_db_path && env.server.maxmind_db_path.length > 0;
const isMaxmindAvailable =
env.server.maxmind_db_path && env.server.maxmind_db_path.length > 0;
const RuleAction = {
ACCEPT: t('alwaysAllow'),
DROP: t('alwaysDeny'),
PASS: t('passToAuth')
ACCEPT: t("alwaysAllow"),
DROP: t("alwaysDeny"),
PASS: t("passToAuth")
} as const;
const RuleMatch = {
PATH: t('path'),
PATH: t("path"),
IP: "IP",
CIDR: t('ipAddressRange'),
COUNTRY: t('country')
CIDR: t("ipAddressRange"),
COUNTRY: t("country")
} as const;
const addRuleForm = useForm({
@@ -155,10 +157,10 @@ export default function ResourceRules(props: {
console.error(err);
toast({
variant: "destructive",
title: t('rulesErrorFetch'),
title: t("rulesErrorFetch"),
description: formatAxiosError(
err,
t('rulesErrorFetchDescription')
t("rulesErrorFetchDescription")
)
});
} finally {
@@ -179,8 +181,8 @@ export default function ResourceRules(props: {
if (isDuplicate) {
toast({
variant: "destructive",
title: t('rulesErrorDuplicate'),
description: t('rulesErrorDuplicateDescription')
title: t("rulesErrorDuplicate"),
description: t("rulesErrorDuplicateDescription")
});
return;
}
@@ -188,8 +190,8 @@ export default function ResourceRules(props: {
if (data.match === "CIDR" && !isValidCIDR(data.value)) {
toast({
variant: "destructive",
title: t('rulesErrorInvalidIpAddressRange'),
description: t('rulesErrorInvalidIpAddressRangeDescription')
title: t("rulesErrorInvalidIpAddressRange"),
description: t("rulesErrorInvalidIpAddressRangeDescription")
});
setLoading(false);
return;
@@ -197,8 +199,8 @@ export default function ResourceRules(props: {
if (data.match === "PATH" && !isValidUrlGlobPattern(data.value)) {
toast({
variant: "destructive",
title: t('rulesErrorInvalidUrl'),
description: t('rulesErrorInvalidUrlDescription')
title: t("rulesErrorInvalidUrl"),
description: t("rulesErrorInvalidUrlDescription")
});
setLoading(false);
return;
@@ -206,17 +208,22 @@ export default function ResourceRules(props: {
if (data.match === "IP" && !isValidIP(data.value)) {
toast({
variant: "destructive",
title: t('rulesErrorInvalidIpAddress'),
description: t('rulesErrorInvalidIpAddressDescription')
title: t("rulesErrorInvalidIpAddress"),
description: t("rulesErrorInvalidIpAddressDescription")
});
setLoading(false);
return;
}
if (data.match === "COUNTRY" && !COUNTRIES.some(c => c.code === data.value)) {
if (
data.match === "COUNTRY" &&
!COUNTRIES.some((c) => c.code === data.value)
) {
toast({
variant: "destructive",
title: t('rulesErrorInvalidCountry'),
description: t('rulesErrorInvalidCountryDescription') || "Invalid country code."
title: t("rulesErrorInvalidCountry"),
description:
t("rulesErrorInvalidCountryDescription") ||
"Invalid country code."
});
setLoading(false);
return;
@@ -265,13 +272,13 @@ export default function ResourceRules(props: {
function getValueHelpText(type: string) {
switch (type) {
case "CIDR":
return t('rulesMatchIpAddressRangeDescription');
return t("rulesMatchIpAddressRangeDescription");
case "IP":
return t('rulesMatchIpAddress');
return t("rulesMatchIpAddress");
case "PATH":
return t('rulesMatchUrl');
return t("rulesMatchUrl");
case "COUNTRY":
return t('rulesMatchCountry');
return t("rulesMatchCountry");
}
}
@@ -288,10 +295,10 @@ export default function ResourceRules(props: {
console.error(err);
toast({
variant: "destructive",
title: t('rulesErrorUpdate'),
title: t("rulesErrorUpdate"),
description: formatAxiosError(
err,
t('rulesErrorUpdateDescription')
t("rulesErrorUpdateDescription")
)
});
throw err;
@@ -314,8 +321,10 @@ export default function ResourceRules(props: {
if (rule.match === "CIDR" && !isValidCIDR(rule.value)) {
toast({
variant: "destructive",
title: t('rulesErrorInvalidIpAddressRange'),
description: t('rulesErrorInvalidIpAddressRangeDescription')
title: t("rulesErrorInvalidIpAddressRange"),
description: t(
"rulesErrorInvalidIpAddressRangeDescription"
)
});
setLoading(false);
return;
@@ -326,8 +335,8 @@ export default function ResourceRules(props: {
) {
toast({
variant: "destructive",
title: t('rulesErrorInvalidUrl'),
description: t('rulesErrorInvalidUrlDescription')
title: t("rulesErrorInvalidUrl"),
description: t("rulesErrorInvalidUrlDescription")
});
setLoading(false);
return;
@@ -335,8 +344,8 @@ export default function ResourceRules(props: {
if (rule.match === "IP" && !isValidIP(rule.value)) {
toast({
variant: "destructive",
title: t('rulesErrorInvalidIpAddress'),
description: t('rulesErrorInvalidIpAddressDescription')
title: t("rulesErrorInvalidIpAddress"),
description: t("rulesErrorInvalidIpAddressDescription")
});
setLoading(false);
return;
@@ -345,8 +354,8 @@ export default function ResourceRules(props: {
if (rule.priority === undefined) {
toast({
variant: "destructive",
title: t('rulesErrorInvalidPriority'),
description: t('rulesErrorInvalidPriorityDescription')
title: t("rulesErrorInvalidPriority"),
description: t("rulesErrorInvalidPriorityDescription")
});
setLoading(false);
return;
@@ -357,8 +366,8 @@ export default function ResourceRules(props: {
if (priorities.length !== new Set(priorities).size) {
toast({
variant: "destructive",
title: t('rulesErrorDuplicatePriority'),
description: t('rulesErrorDuplicatePriorityDescription')
title: t("rulesErrorDuplicatePriority"),
description: t("rulesErrorDuplicatePriorityDescription")
});
setLoading(false);
return;
@@ -397,8 +406,8 @@ export default function ResourceRules(props: {
}
toast({
title: t('ruleUpdated'),
description: t('ruleUpdatedDescription')
title: t("ruleUpdated"),
description: t("ruleUpdatedDescription")
});
setRulesToRemove([]);
@@ -407,10 +416,10 @@ export default function ResourceRules(props: {
console.error(err);
toast({
variant: "destructive",
title: t('ruleErrorUpdate'),
title: t("ruleErrorUpdate"),
description: formatAxiosError(
err,
t('ruleErrorUpdateDescription')
t("ruleErrorUpdateDescription")
)
});
}
@@ -428,7 +437,7 @@ export default function ResourceRules(props: {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t('rulesPriority')}
{t("rulesPriority")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -440,15 +449,19 @@ export default function ResourceRules(props: {
type="number"
onClick={(e) => e.currentTarget.focus()}
onBlur={(e) => {
const parsed = z.int()
const parsed = z.coerce
.number()
.int()
.optional()
.safeParse(e.target.value);
if (!parsed.data) {
if (!parsed.success) {
toast({
variant: "destructive",
title: t('rulesErrorInvalidIpAddress'), // correct priority or IP?
description: t('rulesErrorInvalidPriorityDescription')
title: t("rulesErrorInvalidPriority"), // correct priority or IP?
description: t(
"rulesErrorInvalidPriorityDescription"
)
});
setLoading(false);
return;
@@ -463,7 +476,7 @@ export default function ResourceRules(props: {
},
{
accessorKey: "action",
header: () => (<span className="p-3">{t('rulesAction')}</span>),
header: () => <span className="p-3">{t("rulesAction")}</span>,
cell: ({ row }) => (
<Select
defaultValue={row.original.action}
@@ -486,12 +499,18 @@ export default function ResourceRules(props: {
},
{
accessorKey: "match",
header: () => (<span className="p-3">{t('rulesMatchType')}</span>),
header: () => <span className="p-3">{t("rulesMatchType")}</span>,
cell: ({ row }) => (
<Select
defaultValue={row.original.match}
onValueChange={(value: "CIDR" | "IP" | "PATH" | "COUNTRY") =>
updateRule(row.original.ruleId, { match: value, value: value === "COUNTRY" ? "US" : row.original.value })
onValueChange={(
value: "CIDR" | "IP" | "PATH" | "COUNTRY"
) =>
updateRule(row.original.ruleId, {
match: value,
value:
value === "COUNTRY" ? "US" : row.original.value
})
}
>
<SelectTrigger className="min-w-[125px]">
@@ -502,7 +521,9 @@ export default function ResourceRules(props: {
<SelectItem value="IP">{RuleMatch.IP}</SelectItem>
<SelectItem value="CIDR">{RuleMatch.CIDR}</SelectItem>
{isMaxmindAvailable && (
<SelectItem value="COUNTRY">{RuleMatch.COUNTRY}</SelectItem>
<SelectItem value="COUNTRY">
{RuleMatch.COUNTRY}
</SelectItem>
)}
</SelectContent>
</Select>
@@ -510,8 +531,8 @@ export default function ResourceRules(props: {
},
{
accessorKey: "value",
header: () => (<span className="p-3">{t('value')}</span>),
cell: ({ row }) => (
header: () => <span className="p-3">{t("value")}</span>,
cell: ({ row }) =>
row.original.match === "COUNTRY" ? (
<Popover>
<PopoverTrigger asChild>
@@ -521,29 +542,43 @@ export default function ResourceRules(props: {
className="min-w-[200px] justify-between"
>
{row.original.value
? COUNTRIES.find((country) => country.code === row.original.value)?.name +
" (" + row.original.value + ")"
: t('selectCountry')}
? COUNTRIES.find(
(country) =>
country.code ===
row.original.value
)?.name +
" (" +
row.original.value +
")"
: t("selectCountry")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="min-w-[200px] p-0">
<Command>
<CommandInput placeholder={t('searchCountries')} />
<CommandInput
placeholder={t("searchCountries")}
/>
<CommandList>
<CommandEmpty>{t('noCountryFound')}</CommandEmpty>
<CommandEmpty>
{t("noCountryFound")}
</CommandEmpty>
<CommandGroup>
{COUNTRIES.map((country) => (
<CommandItem
key={country.code}
value={country.name}
onSelect={() => {
updateRule(row.original.ruleId, { value: country.code });
updateRule(
row.original.ruleId,
{ value: country.code }
);
}}
>
<Check
className={`mr-2 h-4 w-4 ${
row.original.value === country.code
row.original.value ===
country.code
? "opacity-100"
: "opacity-0"
}`}
@@ -567,11 +602,10 @@ export default function ResourceRules(props: {
}
/>
)
)
},
{
accessorKey: "enabled",
header: () => (<span className="p-3">{t('enabled')}</span>),
header: () => <span className="p-3">{t("enabled")}</span>,
cell: ({ row }) => (
<Switch
defaultChecked={row.original.enabled}
@@ -583,14 +617,14 @@ export default function ResourceRules(props: {
},
{
id: "actions",
header: () => (<span className="p-3">{t('actions')}</span>),
header: () => <span className="p-3">{t("actions")}</span>,
cell: ({ row }) => (
<div className="flex items-center space-x-2">
<Button
variant="outline"
onClick={() => removeRule(row.original.ruleId)}
>
{t('delete')}
{t("delete")}
</Button>
</div>
)
@@ -664,10 +698,10 @@ export default function ResourceRules(props: {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('rulesResource')}
{t("rulesResource")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t('rulesResourceDescription')}
{t("rulesResourceDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -675,7 +709,7 @@ export default function ResourceRules(props: {
<div className="flex items-center space-x-2">
<SwitchInput
id="rules-toggle"
label={t('rulesEnable')}
label={t("rulesEnable")}
defaultChecked={rulesEnabled}
onCheckedChange={(val) => setRulesEnabled(val)}
/>
@@ -692,7 +726,9 @@ export default function ResourceRules(props: {
name="action"
render={({ field }) => (
<FormItem>
<FormLabel>{t('rulesAction')}</FormLabel>
<FormLabel>
{t("rulesAction")}
</FormLabel>
<FormControl>
<Select
value={field.value}
@@ -705,13 +741,19 @@ export default function ResourceRules(props: {
</SelectTrigger>
<SelectContent>
<SelectItem value="ACCEPT">
{RuleAction.ACCEPT}
{
RuleAction.ACCEPT
}
</SelectItem>
<SelectItem value="DROP">
{RuleAction.DROP}
{
RuleAction.DROP
}
</SelectItem>
<SelectItem value="PASS">
{RuleAction.PASS}
{
RuleAction.PASS
}
</SelectItem>
</SelectContent>
</Select>
@@ -725,11 +767,15 @@ export default function ResourceRules(props: {
name="match"
render={({ field }) => (
<FormItem>
<FormLabel>{t('rulesMatchType')}</FormLabel>
<FormLabel>
{t("rulesMatchType")}
</FormLabel>
<FormControl>
<Select
value={field.value}
onValueChange={field.onChange}
onValueChange={
field.onChange
}
>
<SelectTrigger className="w-full">
<SelectValue />
@@ -737,7 +783,9 @@ export default function ResourceRules(props: {
<SelectContent>
{resource.http && (
<SelectItem value="PATH">
{RuleMatch.PATH}
{
RuleMatch.PATH
}
</SelectItem>
)}
<SelectItem value="IP">
@@ -748,7 +796,9 @@ export default function ResourceRules(props: {
</SelectItem>
{isMaxmindAvailable && (
<SelectItem value="COUNTRY">
{RuleMatch.COUNTRY}
{
RuleMatch.COUNTRY
}
</SelectItem>
)}
</SelectContent>
@@ -764,7 +814,7 @@ export default function ResourceRules(props: {
render={({ field }) => (
<FormItem className="gap-1">
<InfoPopup
text={t('value')}
text={t("value")}
info={
getValueHelpText(
addRuleForm.watch(
@@ -774,47 +824,100 @@ export default function ResourceRules(props: {
}
/>
<FormControl>
{addRuleForm.watch("match") === "COUNTRY" ? (
<Popover open={openAddRuleCountrySelect} onOpenChange={setOpenAddRuleCountrySelect}>
<PopoverTrigger asChild>
{addRuleForm.watch(
"match"
) === "COUNTRY" ? (
<Popover
open={
openAddRuleCountrySelect
}
onOpenChange={
setOpenAddRuleCountrySelect
}
>
<PopoverTrigger
asChild
>
<Button
variant="outline"
role="combobox"
aria-expanded={openAddRuleCountrySelect}
aria-expanded={
openAddRuleCountrySelect
}
className="w-full justify-between"
>
{field.value
? COUNTRIES.find((country) => country.code === field.value)?.name +
" (" + field.value + ")"
: t('selectCountry')}
? COUNTRIES.find(
(
country
) =>
country.code ===
field.value
)
?.name +
" (" +
field.value +
")"
: t(
"selectCountry"
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder={t('searchCountries')} />
<CommandInput
placeholder={t(
"searchCountries"
)}
/>
<CommandList>
<CommandEmpty>{t('noCountryFound')}</CommandEmpty>
<CommandEmpty>
{t(
"noCountryFound"
)}
</CommandEmpty>
<CommandGroup>
{COUNTRIES.map((country) => (
<CommandItem
key={country.code}
value={country.name}
onSelect={() => {
field.onChange(country.code);
setOpenAddRuleCountrySelect(false);
}}
>
<Check
className={`mr-2 h-4 w-4 ${
field.value === country.code
? "opacity-100"
: "opacity-0"
}`}
/>
{country.name} ({country.code})
</CommandItem>
))}
{COUNTRIES.map(
(
country
) => (
<CommandItem
key={
country.code
}
value={
country.name
}
onSelect={() => {
field.onChange(
country.code
);
setOpenAddRuleCountrySelect(
false
);
}}
>
<Check
className={`mr-2 h-4 w-4 ${
field.value ===
country.code
? "opacity-100"
: "opacity-0"
}`}
/>
{
country.name
}{" "}
(
{
country.code
}
)
</CommandItem>
)
)}
</CommandGroup>
</CommandList>
</Command>
@@ -833,7 +936,7 @@ export default function ResourceRules(props: {
variant="outline"
disabled={!rulesEnabled}
>
{t('ruleSubmit')}
{t("ruleSubmit")}
</Button>
</div>
</form>
@@ -843,16 +946,22 @@ export default function ResourceRules(props: {
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const isActionsColumn = header.column.id === "actions";
const isActionsColumn =
header.column.id === "actions";
return (
<TableHead
key={header.id}
className={isActionsColumn ? "sticky right-0 z-10 w-auto min-w-fit bg-card" : ""}
className={
isActionsColumn
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: ""
}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef
header.column
.columnDef
.header,
header.getContext()
)}
@@ -866,20 +975,30 @@ export default function ResourceRules(props: {
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => {
const isActionsColumn = cell.column.id === "actions";
return (
<TableCell
key={cell.id}
className={isActionsColumn ? "sticky right-0 z-10 w-auto min-w-fit bg-card" : ""}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
);
})}
{row
.getVisibleCells()
.map((cell) => {
const isActionsColumn =
cell.column.id ===
"actions";
return (
<TableCell
key={cell.id}
className={
isActionsColumn
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: ""
}
>
{flexRender(
cell.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
);
})}
</TableRow>
))
) : (
@@ -888,7 +1007,7 @@ export default function ResourceRules(props: {
colSpan={columns.length}
className="h-24 text-center"
>
{t('rulesNoOne')}
{t("rulesNoOne")}
</TableCell>
</TableRow>
)}
@@ -907,7 +1026,7 @@ export default function ResourceRules(props: {
loading={loading}
disabled={loading}
>
{t('saveAllSettings')}
{t("saveAllSettings")}
</Button>
</div>
</SettingsContainer>

View File

@@ -190,8 +190,12 @@ const addTargetSchema = z
return false;
}
// If rewritePathType is provided, rewritePath must be provided
// Exception: stripPrefix can have an empty rewritePath (to just strip the prefix)
if (data.rewritePathType && !data.rewritePath) {
return false;
// Allow empty rewritePath for stripPrefix type
if (data.rewritePathType !== "stripPrefix") {
return false;
}
}
return true;
},
@@ -432,16 +436,16 @@ export default function Page() {
const newTarget: LocalTarget = {
...data,
path: isHttp ? (data.path || null) : null,
pathMatchType: isHttp ? (data.pathMatchType || null) : null,
rewritePath: isHttp ? (data.rewritePath || null) : null,
rewritePathType: isHttp ? (data.rewritePathType || null) : null,
path: isHttp ? data.path || null : null,
pathMatchType: isHttp ? data.pathMatchType || null : null,
rewritePath: isHttp ? data.rewritePath || null : null,
rewritePathType: isHttp ? data.rewritePathType || null : null,
siteType: site?.type || null,
enabled: true,
targetId: new Date().getTime(),
new: true,
resourceId: 0, // Will be set when resource is created
priority: isHttp ? (data.priority || 100) : 100, // Default priority
priority: isHttp ? data.priority || 100 : 100, // Default priority
hcEnabled: false,
hcPath: null,
hcMethod: null,
@@ -507,7 +511,7 @@ export default function Page() {
try {
const payload = {
name: baseData.name,
http: baseData.http,
http: baseData.http
};
let sanitizedSubdomain: string | undefined;
@@ -577,7 +581,8 @@ export default function Page() {
hcFollowRedirects:
target.hcFollowRedirects || null,
hcStatus: target.hcStatus || null,
hcUnhealthyInterval: target.hcUnhealthyInterval || null,
hcUnhealthyInterval:
target.hcUnhealthyInterval || null,
hcMode: target.hcMode || null,
hcTlsServerName: target.hcTlsServerName
};
@@ -737,7 +742,7 @@ export default function Page() {
const priorityColumn: ColumnDef<LocalTarget> = {
id: "priority",
header: () => (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 p-3">
{t("priority")}
<TooltipProvider>
<Tooltip>
@@ -780,7 +785,7 @@ export default function Page() {
const healthCheckColumn: ColumnDef<LocalTarget> = {
accessorKey: "healthCheck",
header: () => (<span className="p-3">{t("healthCheck")}</span>),
header: () => <span className="p-3">{t("healthCheck")}</span>,
cell: ({ row }) => {
const status = row.original.hcHealth || "unknown";
const isEnabled = row.original.hcEnabled;
@@ -826,18 +831,16 @@ export default function Page() {
{row.original.siteType === "newt" ? (
<Button
variant="outline"
className="flex items-center justify-between gap-2 p-2 w-full text-left cursor-pointer"
className="flex items-center gap-2 w-full text-left cursor-pointer"
onClick={() =>
openHealthCheckDialog(row.original)
}
>
<Badge variant={getStatusColor(status)}>
<div className="flex items-center gap-1">
{getStatusIcon(status)}
{getStatusText(status)}
</div>
</Badge>
<Settings className="h-4 w-4" />
<div className="flex items-center gap-1">
{getStatusIcon(status)}
{getStatusText(status)}
</div>
</Button>
) : (
<span>-</span>
@@ -852,7 +855,7 @@ export default function Page() {
const matchPathColumn: ColumnDef<LocalTarget> = {
accessorKey: "path",
header: () => (<span className="p-3">{t("matchPath")}</span>),
header: () => <span className="p-3">{t("matchPath")}</span>,
cell: ({ row }) => {
const hasPathMatch = !!(
row.original.path || row.original.pathMatchType
@@ -914,7 +917,7 @@ export default function Page() {
const addressColumn: ColumnDef<LocalTarget> = {
accessorKey: "address",
header: () => (<span className="p-3">{t("address")}</span>),
header: () => <span className="p-3">{t("address")}</span>,
cell: ({ row }) => {
const selectedSite = sites.find(
(site) => site.siteId === row.original.siteId
@@ -1035,8 +1038,12 @@ export default function Page() {
{row.original.method || "http"}
</SelectTrigger>
<SelectContent>
<SelectItem value="http">http</SelectItem>
<SelectItem value="https">https</SelectItem>
<SelectItem value="http">
http
</SelectItem>
<SelectItem value="https">
https
</SelectItem>
<SelectItem value="h2c">h2c</SelectItem>
</SelectContent>
</Select>
@@ -1050,7 +1057,7 @@ export default function Page() {
<Input
defaultValue={row.original.ip}
placeholder="IP / Hostname"
placeholder="Host"
className="flex-1 min-w-[120px] pl-0 border-none placeholder-gray-400"
onBlur={(e) => {
const input = e.target.value.trim();
@@ -1128,7 +1135,7 @@ export default function Page() {
const rewritePathColumn: ColumnDef<LocalTarget> = {
accessorKey: "rewritePath",
header: () => (<span className="p-3">{t("rewritePath")}</span>),
header: () => <span className="p-3">{t("rewritePath")}</span>,
cell: ({ row }) => {
const hasRewritePath = !!(
row.original.rewritePath || row.original.rewritePathType
@@ -1198,7 +1205,7 @@ export default function Page() {
const enabledColumn: ColumnDef<LocalTarget> = {
accessorKey: "enabled",
header: () => (<span className="p-3">{t("enabled")}</span>),
header: () => <span className="p-3">{t("enabled")}</span>,
cell: ({ row }) => (
<div className="flex items-center justify-center w-full">
<Switch
@@ -1219,7 +1226,7 @@ export default function Page() {
const actionsColumn: ColumnDef<LocalTarget> = {
id: "actions",
header: () => (<span className="p-3">{t("actions")}</span>),
header: () => <span className="p-3">{t("actions")}</span>,
cell: ({ row }) => (
<div className="flex items-center justify-end w-full">
<Button
@@ -1341,42 +1348,38 @@ export default function Page() {
</form>
</Form>
</SettingsSectionForm>
{resourceTypes.length > 1 && (
<>
<div className="mb-2">
<span className="text-sm font-medium">
{t("type")}
</span>
</div>
<StrategySelect
options={resourceTypes}
defaultValue="http"
onChange={(value) => {
baseForm.setValue(
"http",
value === "http"
);
// Update method default when switching resource type
addTargetForm.setValue(
"method",
value === "http"
? "http"
: null
);
}}
cols={2}
/>
</>
)}
</SettingsSectionBody>
</SettingsSection>
{resourceTypes.length > 1 && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("resourceType")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourceTypeDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<StrategySelect
options={resourceTypes}
defaultValue="http"
onChange={(value) => {
baseForm.setValue(
"http",
value === "http"
);
// Update method default when switching resource type
addTargetForm.setValue(
"method",
value === "http"
? "http"
: null
);
}}
cols={2}
/>
</SettingsSectionBody>
</SettingsSection>
)}
{baseForm.watch("http") ? (
<SettingsSection>
<SettingsSectionHeader>
@@ -1393,6 +1396,8 @@ export default function Page() {
<DomainPicker
orgId={orgId as string}
onDomainChange={(res) => {
if (!res) return;
httpForm.setValue(
"subdomain",
res.subdomain
@@ -1422,146 +1427,98 @@ export default function Page() {
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...tcpUdpForm}>
<form
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault(); // block default enter refresh
}
}}
className="space-y-4"
id="tcp-udp-settings-form"
>
<Controller
control={
tcpUdpForm.control
}
name="protocol"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"protocol"
)}
</FormLabel>
<Select
onValueChange={
field.onChange
}
{...field}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t(
"protocolSelect"
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="tcp">
TCP
</SelectItem>
<SelectItem value="udp">
UDP
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={
tcpUdpForm.control
}
name="proxyPort"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"resourcePortNumber"
)}
</FormLabel>
<Form {...tcpUdpForm}>
<form
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault(); // block default enter refresh
}
}}
className="space-y-4 grid gap-4 grid-cols-1 md:grid-cols-2 items-start"
id="tcp-udp-settings-form"
>
<Controller
control={tcpUdpForm.control}
name="protocol"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("protocol")}
</FormLabel>
<Select
onValueChange={
field.onChange
}
{...field}
>
<FormControl>
<Input
type="number"
value={
field.value ??
""
}
onChange={(
e
) =>
field.onChange(
e
.target
.value
? parseInt(
e
.target
.value
)
: undefined
)
}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"resourcePortNumberDescription"
)}
</FormDescription>
</FormItem>
)}
/>
{/* {build == "oss" && (
<FormField
control={
tcpUdpForm.control
}
name="enableProxy"
render={({
field
}) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
variant={
"outlinePrimarySquare"
}
checked={
field.value
}
onCheckedChange={
field.onChange
}
<SelectTrigger>
<SelectValue
placeholder={t(
"protocolSelect"
)}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>
{t(
"resourceEnableProxy"
)}
</FormLabel>
<FormDescription>
{t(
"resourceEnableProxyDescription"
)}
</FormDescription>
</div>
</FormItem>
)}
/>
)} */}
</form>
</Form>
</SettingsSectionForm>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="tcp">
TCP
</SelectItem>
<SelectItem value="udp">
UDP
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={tcpUdpForm.control}
name="proxyPort"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"resourcePortNumber"
)}
</FormLabel>
<FormControl>
<Input
type="number"
value={
field.value ??
""
}
onChange={(
e
) =>
field.onChange(
e
.target
.value
? parseInt(
e
.target
.value
)
: undefined
)
}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"resourcePortNumberDescription"
)}
</FormDescription>
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionBody>
</SettingsSection>
)}
@@ -1596,13 +1553,21 @@ export default function Page() {
(
header
) => {
const isActionsColumn = header.column.id === "actions";
const isActionsColumn =
header
.column
.id ===
"actions";
return (
<TableHead
key={
header.id
}
className={isActionsColumn ? "sticky right-0 z-10 w-auto min-w-fit bg-card" : ""}
className={
isActionsColumn
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: ""
}
>
{header.isPlaceholder
? null
@@ -1639,13 +1604,21 @@ export default function Page() {
(
cell
) => {
const isActionsColumn = cell.column.id === "actions";
const isActionsColumn =
cell
.column
.id ===
"actions";
return (
<TableCell
key={
cell.id
}
className={isActionsColumn ? "sticky right-0 z-10 w-auto min-w-fit bg-card" : ""}
className={
isActionsColumn
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: ""
}
>
{flexRender(
cell
@@ -1711,7 +1684,7 @@ export default function Page() {
</div>
</>
) : (
<div className="text-center py-8 border-2 border-dashed border-muted rounded-lg p-4">
<div className="text-center p-4">
<p className="text-muted-foreground mb-4">
{t("targetNoOne")}
</p>
@@ -1877,7 +1850,7 @@ export default function Page() {
<Link
className="text-sm text-primary flex items-center gap-1"
href="https://docs.pangolin.net/manage/resources/tcp-udp-resources"
href="https://docs.pangolin.net/manage/resources/public/raw-resources"
target="_blank"
rel="noopener noreferrer"
>

View File

@@ -7,7 +7,9 @@ import { cache } from "react";
import { GetOrgResponse } from "@server/routers/org";
import OrgProvider from "@app/providers/OrgProvider";
import { ListAccessTokensResponse } from "@server/routers/accessToken";
import ShareLinksTable, { ShareLinkRow } from "../../../../components/ShareLinksTable";
import ShareLinksTable, {
ShareLinkRow
} from "../../../../components/ShareLinksTable";
import { getTranslations } from "next-intl/server";
type ShareLinksPageProps = {
@@ -58,8 +60,8 @@ export default async function ShareLinksPage(props: ShareLinksPageProps) {
{/* <ShareableLinksSplash /> */}
<SettingsSectionTitle
title={t('shareTitle')}
description={t('shareDescription')}
title={t("shareTitle")}
description={t("shareDescription")}
/>
<OrgProvider org={org}>

View File

@@ -1,11 +1,12 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
@@ -18,11 +19,26 @@ import { useTranslations } from "next-intl";
import { PickSiteDefaultsResponse } from "@server/routers/site";
import { useSiteContext } from "@app/hooks/useSiteContext";
import { generateKeypair } from "../wireguardConfig";
import RegenerateCredentialsModal from "@app/components/RegenerateCredentialsModal";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
import { build } from "@server/build";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@app/components/ui/tooltip";
import { SecurityFeaturesAlert } from "@app/components/SecurityFeaturesAlert";
import {
InfoSection,
InfoSectionContent,
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import CopyToClipboard from "@app/components/CopyToClipboard";
import CopyTextBox from "@app/components/CopyTextBox";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon } from "lucide-react";
import {
generateWireGuardConfig,
generateObfuscatedWireGuardConfig
} from "@app/lib/wireguard";
import { QRCodeCanvas } from "qrcode.react";
export default function CredentialsPage() {
const { env } = useEnvContext();
@@ -33,9 +49,20 @@ export default function CredentialsPage() {
const { site } = useSiteContext();
const [modalOpen, setModalOpen] = useState(false);
const [siteDefaults, setSiteDefaults] = useState<PickSiteDefaultsResponse | null>(null);
const [siteDefaults, setSiteDefaults] =
useState<PickSiteDefaultsResponse | null>(null);
const [wgConfig, setWgConfig] = useState("");
const [publicKey, setPublicKey] = useState("");
const [currentNewtId, setCurrentNewtId] = useState<string | null>(
site.newtId
);
const [regeneratedSecret, setRegeneratedSecret] = useState<string | null>(
null
);
const [showCredentialsAlert, setShowCredentialsAlert] = useState(false);
const [showWireGuardAlert, setShowWireGuardAlert] = useState(false);
const [loadingDefaults, setLoadingDefaults] = useState(false);
const [shouldDisconnect, setShouldDisconnect] = useState(true);
const { licenseStatus, isUnlocked } = useLicenseStatusContext();
const subscription = useSubscriptionStatusContext();
@@ -47,147 +74,394 @@ export default function CredentialsPage() {
return isEnterpriseNotLicensed || isSaasNotSubscribed;
};
const hydrateWireGuardConfig = (
privateKey: string,
publicKey: string,
subnet: string,
address: string,
endpoint: string,
listenPort: string
) => {
const config = `[Interface]
Address = ${subnet}
ListenPort = 51820
PrivateKey = ${privateKey}
[Peer]
PublicKey = ${publicKey}
AllowedIPs = ${address.split("/")[0]}/32
Endpoint = ${endpoint}:${listenPort}
PersistentKeepalive = 5`;
setWgConfig(config);
return config;
};
// Fetch site defaults for wireguard sites to show in obfuscated config
useEffect(() => {
const fetchSiteDefaults = async () => {
if (site?.type === "wireguard" && !siteDefaults && orgId) {
setLoadingDefaults(true);
try {
const res = await api.get(
`/org/${orgId}/pick-site-defaults`
);
if (res && res.status === 200) {
setSiteDefaults(res.data.data);
}
} catch (error) {
// Silently fail - we'll use site data or obfuscated values
} finally {
setLoadingDefaults(false);
}
} else {
setLoadingDefaults(false);
}
};
fetchSiteDefaults();
}, []);
const handleConfirmRegenerate = async () => {
let generatedPublicKey = "";
let generatedWgConfig = "";
try {
let generatedPublicKey = "";
let generatedWgConfig = "";
if (site?.type === "wireguard") {
const generatedKeypair = generateKeypair();
generatedPublicKey = generatedKeypair.publicKey;
setPublicKey(generatedPublicKey);
if (site?.type === "wireguard") {
const generatedKeypair = generateKeypair();
generatedPublicKey = generatedKeypair.publicKey;
setPublicKey(generatedPublicKey);
const res = await api.get(`/org/${orgId}/pick-site-defaults`);
if (res && res.status === 200) {
const data = res.data.data;
setSiteDefaults(data);
const res = await api.get(`/org/${orgId}/pick-site-defaults`);
if (res && res.status === 200) {
const data = res.data.data;
setSiteDefaults(data);
// generate config with the fetched data
generatedWgConfig = hydrateWireGuardConfig(
generatedKeypair.privateKey,
data.publicKey,
data.subnet,
data.address,
data.endpoint,
data.listenPort
// generate config with the fetched data
generatedWgConfig = generateWireGuardConfig(
generatedKeypair.privateKey,
data.publicKey,
data.subnet,
data.address,
data.endpoint,
data.listenPort
);
setWgConfig(generatedWgConfig);
setShowWireGuardAlert(true);
}
await api.post(
`/re-key/${site?.siteId}/regenerate-site-secret`,
{
type: "wireguard",
pubKey: generatedPublicKey
}
);
}
await api.post(`/re-key/${site?.siteId}/regenerate-site-secret`, {
type: "wireguard",
subnet: res.data.data.subnet,
exitNodeId: res.data.data.exitNodeId,
pubKey: generatedPublicKey
if (site?.type === "newt") {
const res = await api.get(`/org/${orgId}/pick-site-defaults`);
if (res && res.status === 200) {
const data = res.data.data;
const rekeyRes = await api.post(
`/re-key/${site?.siteId}/regenerate-site-secret`,
{
type: "newt",
secret: data.newtSecret,
disconnect: shouldDisconnect
}
);
if (rekeyRes && rekeyRes.status === 200) {
const rekeyData = rekeyRes.data.data;
if (rekeyData && rekeyData.newtId) {
setCurrentNewtId(rekeyData.newtId);
setRegeneratedSecret(data.newtSecret);
setSiteDefaults({
...data,
newtId: rekeyData.newtId
});
setShowCredentialsAlert(true);
}
}
}
}
toast({
title: t("credentialsSaved"),
description: t("credentialsSavedDescription")
});
// ConfirmDeleteDialog handles closing the modal and triggering refresh via setOpen callback
} catch (error) {
toast({
variant: "destructive",
title: t("error") || "Error",
description:
formatAxiosError(error) ||
t("credentialsRegenerateError") ||
"Failed to regenerate credentials"
});
}
if (site?.type === "newt") {
const res = await api.get(`/org/${orgId}/pick-site-defaults`);
if (res && res.status === 200) {
const data = res.data.data;
setSiteDefaults(data);
await api.post(`/re-key/${site?.siteId}/regenerate-site-secret`, {
type: "newt",
newtId: data.newtId,
newtSecret: data.newtSecret
});
}
}
toast({
title: t("credentialsSaved"),
description: t("credentialsSavedDescription")
});
router.refresh();
};
const getCredentialType = () => {
if (site?.type === "wireguard") return "site-wireguard";
if (site?.type === "newt") return "site-newt";
return "site-newt";
const getConfirmationString = () => {
return site?.name || site?.niceId || "My site";
};
const getCredentials = () => {
if (site?.type === "wireguard" && wgConfig) {
return { wgConfig };
}
if (site?.type === "newt" && siteDefaults) {
return {
Id: siteDefaults.newtId,
Secret: siteDefaults.newtSecret
};
}
return undefined;
};
const displayNewtId = currentNewtId || siteDefaults?.newtId || null;
const displaySecret = regeneratedSecret || null;
return (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("generatedcredentials")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("regenerateCredentials")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<>
<SettingsContainer>
{site?.type === "newt" && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("siteNewtCredentials")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("siteNewtCredentialsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="inline-block">
<Button
onClick={() => setModalOpen(true)}
disabled={isSecurityFeatureDisabled()}
>
{t("regeneratecredentials")}
</Button>
</div>
</TooltipTrigger>
<SecurityFeaturesAlert />
{isSecurityFeatureDisabled() && (
<TooltipContent side="top">
{t("featureDisabledTooltip")}
</TooltipContent>
<SettingsSectionBody>
<InfoSections cols={3}>
<InfoSection>
<InfoSectionTitle>
{t("newtEndpoint")}
</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard
text={env.app.dashboardUrl}
/>
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t("newtId")}
</InfoSectionTitle>
<InfoSectionContent>
{displayNewtId ? (
<CopyToClipboard
text={displayNewtId}
/>
) : (
<span>{"••••••••••••••••"}</span>
)}
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t("newtSecretKey")}
</InfoSectionTitle>
<InfoSectionContent>
{displaySecret ? (
<CopyToClipboard
text={displaySecret}
/>
) : (
<span>
{
"••••••••••••••••••••••••••••••••"
}
</span>
)}
</InfoSectionContent>
</InfoSection>
</InfoSections>
{showCredentialsAlert && displaySecret && (
<Alert variant="neutral" className="mt-4">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("siteCredentialsSave")}
</AlertTitle>
<AlertDescription>
{t("siteCredentialsSaveDescription")}
</AlertDescription>
</Alert>
)}
</Tooltip>
</TooltipProvider>
</SettingsSectionBody>
</SettingsSection>
</SettingsSectionBody>
{build !== "oss" && (
<SettingsSectionFooter>
<Button
variant="outline"
onClick={() => {
setShouldDisconnect(false);
setModalOpen(true);
}}
disabled={isSecurityFeatureDisabled()}
>
{t("regenerateCredentialsButton")}
</Button>
<Button
onClick={() => {
setShouldDisconnect(true);
setModalOpen(true);
}}
disabled={isSecurityFeatureDisabled()}
>
{t("siteRegenerateAndDisconnect")}
</Button>
</SettingsSectionFooter>
)}
</SettingsSection>
)}
<RegenerateCredentialsModal
open={modalOpen}
onOpenChange={setModalOpen}
type={getCredentialType()}
onConfirmRegenerate={handleConfirmRegenerate}
dashboardUrl={env.app.dashboardUrl}
credentials={getCredentials()}
/>
</SettingsContainer>
{site?.type === "wireguard" && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("generatedcredentials")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("regenerateCredentials")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SecurityFeaturesAlert />
<SettingsSectionBody>
{!loadingDefaults && (
<>
{wgConfig ? (
<div className="flex items-center gap-4">
<CopyTextBox
text={wgConfig}
outline={true}
/>
<div className="relative w-fit border rounded-md">
<div className="bg-white p-6 rounded-md">
<QRCodeCanvas
value={wgConfig}
size={168}
className="mx-auto"
/>
</div>
</div>
</div>
) : (
<CopyTextBox
text={generateObfuscatedWireGuardConfig(
{
subnet:
siteDefaults?.subnet ||
site?.subnet ||
null,
address:
siteDefaults?.address ||
site?.address ||
null,
endpoint:
siteDefaults?.endpoint ||
site?.endpoint ||
null,
listenPort:
siteDefaults?.listenPort ||
site?.listenPort ||
null,
publicKey:
siteDefaults?.publicKey ||
site?.publicKey ||
site?.pubKey ||
null
}
)}
outline={true}
/>
)}
{showWireGuardAlert && wgConfig && (
<Alert
variant="neutral"
className="mt-4"
>
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("siteCredentialsSave")}
</AlertTitle>
<AlertDescription>
{t(
"siteCredentialsSaveDescription"
)}
</AlertDescription>
</Alert>
)}
</>
)}
</SettingsSectionBody>
{build === "enterprise" && (
<SettingsSectionFooter>
<Button
onClick={() => setModalOpen(true)}
disabled={isSecurityFeatureDisabled()}
>
{t("siteRegenerateAndDisconnect")}
</Button>
</SettingsSectionFooter>
)}
</SettingsSection>
)}
</SettingsContainer>
{site?.type === "newt" && (
<ConfirmDeleteDialog
open={modalOpen}
setOpen={(val) => {
setModalOpen(val);
// Prevent modal from reopening during refresh
if (!val) {
setTimeout(() => {
router.refresh();
}, 150);
}
}}
dialog={
<div className="space-y-2">
{shouldDisconnect ? (
<>
<p>
{t(
"siteRegenerateAndDisconnectConfirmation"
)}
</p>
<p>
{t(
"siteRegenerateAndDisconnectWarning"
)}
</p>
</>
) : (
<>
<p>
{t(
"siteRegenerateCredentialsConfirmation"
)}
</p>
<p>
{t("siteRegenerateCredentialsWarning")}
</p>
</>
)}
</div>
}
buttonText={
shouldDisconnect
? t("siteRegenerateAndDisconnect")
: t("regenerateCredentialsButton")
}
onConfirm={handleConfirmRegenerate}
string={getConfirmationString()}
title={t("regenerateCredentials")}
warningText={t("cannotbeUndone")}
/>
)}
{site?.type === "wireguard" && (
<ConfirmDeleteDialog
open={modalOpen}
setOpen={(val) => {
setModalOpen(val);
// Prevent modal from reopening during refresh
if (!val) {
setTimeout(() => {
router.refresh();
}, 150);
}
}}
dialog={
<div className="space-y-2">
<p>{t("regenerateCredentialsConfirmation")}</p>
<p>{t("regenerateCredentialsWarning")}</p>
</div>
}
buttonText={t("regenerateCredentialsButton")}
onConfirm={handleConfirmRegenerate}
string={getConfirmationString()}
title={t("regenerateCredentials")}
warningText={t("cannotbeUndone")}
/>
)}
</>
);
}
}

View File

@@ -37,7 +37,7 @@ import Link from "next/link";
const GeneralFormSchema = z.object({
name: z.string().nonempty("Name is required"),
niceId: z.string().min(1).max(255).optional(),
dockerSocketEnabled: z.boolean().optional(),
dockerSocketEnabled: z.boolean().optional()
});
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
@@ -52,14 +52,16 @@ export default function GeneralPage() {
const { toast } = useToast();
const [loading, setLoading] = useState(false);
const [activeCidrTagIndex, setActiveCidrTagIndex] = useState<number | null>(null);
const [activeCidrTagIndex, setActiveCidrTagIndex] = useState<number | null>(
null
);
const form = useForm({
resolver: zodResolver(GeneralFormSchema),
defaultValues: {
name: site?.name,
niceId: site?.niceId || "",
dockerSocketEnabled: site?.dockerSocketEnabled ?? false,
dockerSocketEnabled: site?.dockerSocketEnabled ?? false
},
mode: "onChange"
});
@@ -71,17 +73,19 @@ export default function GeneralPage() {
await api.post(`/site/${site?.siteId}`, {
name: data.name,
niceId: data.niceId,
dockerSocketEnabled: data.dockerSocketEnabled,
dockerSocketEnabled: data.dockerSocketEnabled
});
updateSite({
name: data.name,
niceId: data.niceId,
dockerSocketEnabled: data.dockerSocketEnabled,
dockerSocketEnabled: data.dockerSocketEnabled
});
if (data.niceId && data.niceId !== site?.niceId) {
router.replace(`/${site?.orgId}/settings/sites/${data.niceId}/general`);
router.replace(
`/${site?.orgId}/settings/sites/${data.niceId}/general`
);
}
toast({
@@ -92,7 +96,10 @@ export default function GeneralPage() {
toast({
variant: "destructive",
title: t("siteErrorUpdate"),
description: formatAxiosError(e, t("siteErrorUpdateDescription"))
description: formatAxiosError(
e,
t("siteErrorUpdateDescription")
)
});
}
@@ -140,11 +147,15 @@ export default function GeneralPage() {
name="niceId"
render={({ field }) => (
<FormItem>
<FormLabel>{t("identifier")}</FormLabel>
<FormLabel>
{t("identifier")}
</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t("enterIdentifier")}
placeholder={t(
"enterIdentifier"
)}
className="flex-1"
/>
</FormControl>

View File

@@ -35,25 +35,24 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
const navItems = [
{
title: t('general'),
href: `/${params.orgId}/settings/sites/${params.niceId}/general`,
title: t("general"),
href: `/${params.orgId}/settings/sites/${params.niceId}/general`
},
...(site.type !== 'local' && build === 'enterprise'
...(site.type !== "local"
? [
{
title: t('credentials'),
href: `/${params.orgId}/settings/sites/${params.niceId}/credentials`,
},
]
: []),
{
title: t("credentials"),
href: `/${params.orgId}/settings/sites/${params.niceId}/credentials`
}
]
: [])
];
return (
<>
<SettingsSectionTitle
title={t('siteSetting', { siteName: site?.name })}
description={t('siteSettingDescription')}
title={t("siteSetting", { siteName: site?.name })}
description={t("siteSettingDescription")}
/>
<SiteProvider site={site}>

View File

@@ -6,16 +6,16 @@
function gf(init: number[] | undefined = undefined) {
var r = new Float64Array(16);
if (init) {
for (var i = 0; i < init.length; ++i)
r[i] = init[i];
for (var i = 0; i < init.length; ++i) r[i] = init[i];
}
return r;
}
function pack(o: Uint8Array, n: Float64Array) {
var b, m = gf(), t = gf();
for (var i = 0; i < 16; ++i)
t[i] = n[i];
var b,
m = gf(),
t = gf();
for (var i = 0; i < 16; ++i) t[i] = n[i];
carry(t);
carry(t);
carry(t);
@@ -45,7 +45,8 @@ function carry(o: Float64Array) {
}
function cswap(p: Float64Array, q: Float64Array, b: number) {
var t, c = ~(b - 1);
var t,
c = ~(b - 1);
for (var i = 0; i < 16; ++i) {
t = c & (p[i] ^ q[i]);
p[i] ^= t;
@@ -54,40 +55,32 @@ function cswap(p: Float64Array, q: Float64Array, b: number) {
}
function add(o: Float64Array, a: Float64Array, b: Float64Array) {
for (var i = 0; i < 16; ++i)
o[i] = (a[i] + b[i]) | 0;
for (var i = 0; i < 16; ++i) o[i] = (a[i] + b[i]) | 0;
}
function subtract(o: Float64Array, a: Float64Array, b: Float64Array) {
for (var i = 0; i < 16; ++i)
o[i] = (a[i] - b[i]) | 0;
for (var i = 0; i < 16; ++i) o[i] = (a[i] - b[i]) | 0;
}
function multmod(o: Float64Array, a: Float64Array, b: Float64Array) {
var t = new Float64Array(31);
for (var i = 0; i < 16; ++i) {
for (var j = 0; j < 16; ++j)
t[i + j] += a[i] * b[j];
for (var j = 0; j < 16; ++j) t[i + j] += a[i] * b[j];
}
for (var i = 0; i < 15; ++i)
t[i] += 38 * t[i + 16];
for (var i = 0; i < 16; ++i)
o[i] = t[i];
for (var i = 0; i < 15; ++i) t[i] += 38 * t[i + 16];
for (var i = 0; i < 16; ++i) o[i] = t[i];
carry(o);
carry(o);
}
function invert(o: Float64Array, i: Float64Array) {
var c = gf();
for (var a = 0; a < 16; ++a)
c[a] = i[a];
for (var a = 0; a < 16; ++a) c[a] = i[a];
for (var a = 253; a >= 0; --a) {
multmod(c, c, c);
if (a !== 2 && a !== 4)
multmod(c, c, i);
if (a !== 2 && a !== 4) multmod(c, c, i);
}
for (var a = 0; a < 16; ++a)
o[a] = c[a];
for (var a = 0; a < 16; ++a) o[a] = c[a];
}
function clamp(z: Uint8Array) {
@@ -96,7 +89,8 @@ function clamp(z: Uint8Array) {
}
function generatePublicKey(privateKey: Uint8Array) {
var r, z = new Uint8Array(32);
var r,
z = new Uint8Array(32);
var a = gf([1]),
b = gf([9]),
c = gf(),
@@ -105,8 +99,7 @@ function generatePublicKey(privateKey: Uint8Array) {
f = gf(),
_121665 = gf([0xdb41, 1]),
_9 = gf([9]);
for (var i = 0; i < 32; ++i)
z[i] = privateKey[i];
for (var i = 0; i < 32; ++i) z[i] = privateKey[i];
clamp(z);
for (var i = 254; i >= 0; --i) {
r = (z[i >>> 3] >>> (i & 7)) & 1;
@@ -152,9 +145,16 @@ function generatePrivateKey() {
}
function encodeBase64(dest: Uint8Array, src: Uint8Array) {
var input = Uint8Array.from([(src[0] >> 2) & 63, ((src[0] << 4) | (src[1] >> 4)) & 63, ((src[1] << 2) | (src[2] >> 6)) & 63, src[2] & 63]);
var input = Uint8Array.from([
(src[0] >> 2) & 63,
((src[0] << 4) | (src[1] >> 4)) & 63,
((src[1] << 2) | (src[2] >> 6)) & 63,
src[2] & 63
]);
for (var i = 0; i < 4; ++i)
dest[i] = input[i] + 65 +
dest[i] =
input[i] +
65 +
(((25 - input[i]) >> 8) & 6) -
(((51 - input[i]) >> 8) & 75) -
(((61 - input[i]) >> 8) & 15) +
@@ -162,10 +162,14 @@ function encodeBase64(dest: Uint8Array, src: Uint8Array) {
}
function keyToBase64(key: Uint8Array) {
var i, base64 = new Uint8Array(44);
var i,
base64 = new Uint8Array(44);
for (i = 0; i < 32 / 3; ++i)
encodeBase64(base64.subarray(i * 4), key.subarray(i * 3));
encodeBase64(base64.subarray(i * 4), Uint8Array.from([key[i * 3 + 0], key[i * 3 + 1], 0]));
encodeBase64(
base64.subarray(i * 4),
Uint8Array.from([key[i * 3 + 0], key[i * 3 + 1], 0])
);
base64[43] = 61;
return String.fromCharCode.apply(null, base64 as any);
}
@@ -177,4 +181,4 @@ export function generateKeypair() {
publicKey: keyToBase64(publicKey),
privateKey: keyToBase64(privateKey)
};
}
}

View File

@@ -47,6 +47,7 @@ import { Checkbox, CheckboxWithLabel } from "@app/components/ui/checkbox";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { generateKeypair } from "../[niceId]/wireguardConfig";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { generateWireGuardConfig } from "@app/lib/wireguard";
import { useEnvContext } from "@app/hooks/useEnvContext";
import {
CreateSiteBody,
@@ -214,27 +215,6 @@ export default function Page() {
string | undefined
>();
const hydrateWireGuardConfig = (
privateKey: string,
publicKey: string,
subnet: string,
address: string,
endpoint: string,
listenPort: string
) => {
const wgConfig = `[Interface]
Address = ${subnet}
ListenPort = 51820
PrivateKey = ${privateKey}
[Peer]
PublicKey = ${publicKey}
AllowedIPs = ${address.split("/")[0]}/32
Endpoint = ${endpoint}:${listenPort}
PersistentKeepalive = 5`;
setWgConfig(wgConfig);
};
const hydrateCommands = (
id: string,
secret: string,
@@ -252,7 +232,7 @@ PersistentKeepalive = 5`;
All: [
{
title: t("install"),
command: `curl -fsSL https://pangolin.net/get-newt.sh | bash`
command: `curl -fsSL https://static.pangolin.net/get-newt.sh | bash`
},
{
title: t("run"),
@@ -595,7 +575,7 @@ WantedBy=default.target`
acceptClients
);
hydrateWireGuardConfig(
const wgConfig = generateWireGuardConfig(
privateKey,
data.publicKey,
data.subnet,
@@ -603,6 +583,7 @@ WantedBy=default.target`
data.endpoint,
data.listenPort
);
setWgConfig(wgConfig);
setTunnelTypes((prev: any) => {
return prev.map((item: any) => {
@@ -771,7 +752,9 @@ WantedBy=default.target`
{tunnelTypes.length > 1 && (
<>
<div className="mb-2">
<span className="text-sm font-medium">{t("type")}</span>
<span className="text-sm font-medium">
{t("type")}
</span>
</div>
<StrategySelect
options={tunnelTypes}

View File

@@ -31,11 +31,11 @@ export default async function SitesPage(props: SitesPageProps) {
return "-"; // because we are not able to track the data use in a local site right now
}
if (mb >= 1024 * 1024) {
return t('terabytes', {count: (mb / (1024 * 1024)).toFixed(2)});
return t("terabytes", { count: (mb / (1024 * 1024)).toFixed(2) });
} else if (mb >= 1024) {
return t('gigabytes', {count: (mb / 1024).toFixed(2)});
return t("gigabytes", { count: (mb / 1024).toFixed(2) });
} else {
return t('megabytes', {count: mb.toFixed(2)});
return t("megabytes", { count: mb.toFixed(2) });
}
}
@@ -53,7 +53,7 @@ export default async function SitesPage(props: SitesPageProps) {
newtVersion: site.newtVersion || undefined,
newtUpdateAvailable: site.newtUpdateAvailable || false,
exitNodeName: site.exitNodeName || undefined,
exitNodeEndpoint: site.exitNodeEndpoint || undefined,
exitNodeEndpoint: site.exitNodeEndpoint || undefined
};
});
@@ -62,8 +62,8 @@ export default async function SitesPage(props: SitesPageProps) {
{/* <SitesSplashCard /> */}
<SettingsSectionTitle
title={t('siteManageSites')}
description={t('siteDescription')}
title={t("siteManageSites")}
description={t("siteDescription")}
/>
<SitesTable sites={siteRows} orgId={params.orgId} />

View File

@@ -34,14 +34,16 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
const navItems = [
{
title: t('apiKeysPermissionsTitle'),
title: t("apiKeysPermissionsTitle"),
href: "/admin/api-keys/{apiKeyId}/permissions"
}
];
return (
<>
<SettingsSectionTitle title={t('apiKeysSettings', {apiKeyName: apiKey?.name})} />
<SettingsSectionTitle
title={t("apiKeysSettings", { apiKeyName: apiKey?.name })}
/>
<ApiKeyProvider apiKey={apiKey}>
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>

View File

@@ -45,10 +45,10 @@ export default function Page() {
.catch((e) => {
toast({
variant: "destructive",
title: t('apiKeysPermissionsErrorLoadingActions'),
title: t("apiKeysPermissionsErrorLoadingActions"),
description: formatAxiosError(
e,
t('apiKeysPermissionsErrorLoadingActions')
t("apiKeysPermissionsErrorLoadingActions")
)
});
});
@@ -79,18 +79,18 @@ export default function Page() {
)
})
.catch((e) => {
console.error(t('apiKeysErrorSetPermission'), e);
console.error(t("apiKeysErrorSetPermission"), e);
toast({
variant: "destructive",
title: t('apiKeysErrorSetPermission'),
title: t("apiKeysErrorSetPermission"),
description: formatAxiosError(e)
});
});
if (actionsRes && actionsRes.status === 200) {
toast({
title: t('apiKeysPermissionsUpdated'),
description: t('apiKeysPermissionsUpdatedDescription')
title: t("apiKeysPermissionsUpdated"),
description: t("apiKeysPermissionsUpdatedDescription")
});
}
@@ -104,10 +104,12 @@ export default function Page() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('apiKeysPermissionsTitle')}
{t("apiKeysPermissionsTitle")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t('apiKeysPermissionsGeneralSettingsDescription')}
{t(
"apiKeysPermissionsGeneralSettingsDescription"
)}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -125,7 +127,7 @@ export default function Page() {
loading={loadingSavePermissions}
disabled={loadingSavePermissions}
>
{t('apiKeysPermissionsSave')}
{t("apiKeysPermissionsSave")}
</Button>
</SettingsSectionFooter>
</SettingsSectionBody>

View File

@@ -30,7 +30,7 @@ import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { AxiosResponse } from "axios";
import { useRouter } from "next/navigation";
import { useRouter } from "next/navigation";
import {
CreateOrgApiKeyBody,
CreateOrgApiKeyResponse
@@ -64,10 +64,10 @@ export default function Page() {
name: z
.string()
.min(2, {
message: t('nameMin', {len: 2})
message: t("nameMin", { len: 2 })
})
.max(255, {
message: t('nameMax', {len: 255})
message: t("nameMax", { len: 255 })
})
});
@@ -82,7 +82,7 @@ export default function Page() {
return data.copied;
},
{
message: t('apiKeysConfirmCopy2'),
message: t("apiKeysConfirmCopy2"),
path: ["copied"]
}
);
@@ -115,7 +115,7 @@ export default function Page() {
.catch((e) => {
toast({
variant: "destructive",
title: t('apiKeysErrorCreate'),
title: t("apiKeysErrorCreate"),
description: formatAxiosError(e)
});
});
@@ -136,10 +136,10 @@ export default function Page() {
)
})
.catch((e) => {
console.error(t('apiKeysErrorSetPermission'), e);
console.error(t("apiKeysErrorSetPermission"), e);
toast({
variant: "destructive",
title: t('apiKeysErrorSetPermission'),
title: t("apiKeysErrorSetPermission"),
description: formatAxiosError(e)
});
});
@@ -172,8 +172,8 @@ export default function Page() {
<>
<div className="flex justify-between">
<HeaderTitle
title={t('apiKeysCreate')}
description={t('apiKeysCreateDescription')}
title={t("apiKeysCreate")}
description={t("apiKeysCreateDescription")}
/>
<Button
variant="outline"
@@ -181,7 +181,7 @@ export default function Page() {
router.push(`/admin/api-keys`);
}}
>
{t('apiKeysSeeAll')}
{t("apiKeysSeeAll")}
</Button>
</div>
@@ -193,7 +193,7 @@ export default function Page() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('apiKeysTitle')}
{t("apiKeysTitle")}
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -214,7 +214,7 @@ export default function Page() {
render={({ field }) => (
<FormItem>
<FormLabel>
{t('name')}
{t("name")}
</FormLabel>
<FormControl>
<Input
@@ -235,10 +235,12 @@ export default function Page() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('apiKeysGeneralSettings')}
{t("apiKeysGeneralSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t('apiKeysGeneralSettingsDescription')}
{t(
"apiKeysGeneralSettingsDescription"
)}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -258,14 +260,14 @@ export default function Page() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('apiKeysList')}
{t("apiKeysList")}
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody>
<InfoSections cols={2}>
<InfoSection>
<InfoSectionTitle>
{t('name')}
{t("name")}
</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard
@@ -275,7 +277,7 @@ export default function Page() {
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t('created')}
{t("created")}
</InfoSectionTitle>
<InfoSectionContent>
{moment(
@@ -288,10 +290,10 @@ export default function Page() {
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t('apiKeysSave')}
{t("apiKeysSave")}
</AlertTitle>
<AlertDescription>
{t('apiKeysSaveDescription')}
{t("apiKeysSaveDescription")}
</AlertDescription>
</Alert>
@@ -358,7 +360,7 @@ export default function Page() {
router.push(`/admin/api-keys`);
}}
>
{t('cancel')}
{t("cancel")}
</Button>
)}
{!apiKey && (
@@ -370,7 +372,7 @@ export default function Page() {
form.handleSubmit(onSubmit)();
}}
>
{t('generate')}
{t("generate")}
</Button>
)}
@@ -381,7 +383,7 @@ export default function Page() {
copiedForm.handleSubmit(onCopiedSubmit)();
}}
>
{t('done')}
{t("done")}
</Button>
)}
</div>

View File

@@ -34,8 +34,8 @@ export default async function ApiKeysPage(props: ApiKeyPageProps) {
return (
<>
<SettingsSectionTitle
title={t('apiKeysManage')}
description={t('apiKeysDescription')}
title={t("apiKeysManage")}
description={t("apiKeysDescription")}
/>
<ApiKeysTable apiKeys={rows} />

View File

@@ -58,17 +58,17 @@ export default function GeneralPage() {
const t = useTranslations();
const GeneralFormSchema = z.object({
name: z.string().min(2, { message: t('nameMin', {len: 2}) }),
clientId: z.string().min(1, { message: t('idpClientIdRequired') }),
clientSecret: z.string().min(1, { message: t('idpClientSecretRequired') }),
authUrl: z.url({ message: t('idpErrorAuthUrlInvalid') }),
tokenUrl: z.url({ message: t('idpErrorTokenUrlInvalid') }),
identifierPath: z
name: z.string().min(2, { message: t("nameMin", { len: 2 }) }),
clientId: z.string().min(1, { message: t("idpClientIdRequired") }),
clientSecret: z
.string()
.min(1, { message: t('idpPathRequired') }),
.min(1, { message: t("idpClientSecretRequired") }),
authUrl: z.url({ message: t("idpErrorAuthUrlInvalid") }),
tokenUrl: z.url({ message: t("idpErrorTokenUrlInvalid") }),
identifierPath: z.string().min(1, { message: t("idpPathRequired") }),
emailPath: z.string().optional(),
namePath: z.string().optional(),
scopes: z.string().min(1, { message: t('idpScopeRequired') }),
scopes: z.string().min(1, { message: t("idpScopeRequired") }),
autoProvision: z.boolean().default(false)
});
@@ -111,7 +111,7 @@ export default function GeneralPage() {
}
} catch (e) {
toast({
title: t('error'),
title: t("error"),
description: formatAxiosError(e),
variant: "destructive"
});
@@ -145,14 +145,14 @@ export default function GeneralPage() {
if (res.status === 200) {
toast({
title: t('success'),
description: t('idpUpdatedDescription')
title: t("success"),
description: t("idpUpdatedDescription")
});
router.refresh();
}
} catch (e) {
toast({
title: t('error'),
title: t("error"),
description: formatAxiosError(e),
variant: "destructive"
});
@@ -171,17 +171,17 @@ export default function GeneralPage() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('idpTitle')}
{t("idpTitle")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t('idpSettingsDescription')}
{t("idpSettingsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<InfoSections cols={3}>
<InfoSection>
<InfoSectionTitle>
{t('redirectUrl')}
{t("redirectUrl")}
</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard text={redirectUrl} />
@@ -192,10 +192,10 @@ export default function GeneralPage() {
<Alert variant="neutral" className="">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t('redirectUrlAbout')}
{t("redirectUrlAbout")}
</AlertTitle>
<AlertDescription>
{t('redirectUrlAboutDescription')}
{t("redirectUrlAboutDescription")}
</AlertDescription>
</Alert>
<SettingsSectionForm>
@@ -210,12 +210,14 @@ export default function GeneralPage() {
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t('name')}</FormLabel>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t('idpDisplayName')}
{t("idpDisplayName")}
</FormDescription>
<FormMessage />
</FormItem>
@@ -225,7 +227,7 @@ export default function GeneralPage() {
<div className="flex items-start mb-0">
<SwitchInput
id="auto-provision-toggle"
label={t('idpAutoProvisionUsers')}
label={t("idpAutoProvisionUsers")}
defaultChecked={form.getValues(
"autoProvision"
)}
@@ -238,7 +240,7 @@ export default function GeneralPage() {
/>
</div>
<span className="text-sm text-muted-foreground">
{t('idpAutoProvisionUsersDescription')}
{t("idpAutoProvisionUsersDescription")}
</span>
</form>
</Form>
@@ -250,10 +252,10 @@ export default function GeneralPage() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('idpOidcConfigure')}
{t("idpOidcConfigure")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t('idpOidcConfigureDescription')}
{t("idpOidcConfigureDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -270,13 +272,15 @@ export default function GeneralPage() {
render={({ field }) => (
<FormItem>
<FormLabel>
{t('idpClientId')}
{t("idpClientId")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t('idpClientIdDescription')}
{t(
"idpClientIdDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
@@ -289,7 +293,7 @@ export default function GeneralPage() {
render={({ field }) => (
<FormItem>
<FormLabel>
{t('idpClientSecret')}
{t("idpClientSecret")}
</FormLabel>
<FormControl>
<Input
@@ -298,7 +302,9 @@ export default function GeneralPage() {
/>
</FormControl>
<FormDescription>
{t('idpClientSecretDescription')}
{t(
"idpClientSecretDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
@@ -311,13 +317,15 @@ export default function GeneralPage() {
render={({ field }) => (
<FormItem>
<FormLabel>
{t('idpAuthUrl')}
{t("idpAuthUrl")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t('idpAuthUrlDescription')}
{t(
"idpAuthUrlDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
@@ -330,13 +338,15 @@ export default function GeneralPage() {
render={({ field }) => (
<FormItem>
<FormLabel>
{t('idpTokenUrl')}
{t("idpTokenUrl")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t('idpTokenUrlDescription')}
{t(
"idpTokenUrlDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
@@ -351,10 +361,10 @@ export default function GeneralPage() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('idpToken')}
{t("idpToken")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t('idpTokenDescription')}
{t("idpTokenDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -368,17 +378,21 @@ export default function GeneralPage() {
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t('idpJmespathAbout')}
{t("idpJmespathAbout")}
</AlertTitle>
<AlertDescription>
{t('idpJmespathAboutDescription')}
{t(
"idpJmespathAboutDescription"
)}
<a
href="https://jmespath.org"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center"
>
{t('idpJmespathAboutDescriptionLink')}{" "}
{t(
"idpJmespathAboutDescriptionLink"
)}{" "}
<ExternalLink className="ml-1 h-4 w-4" />
</a>
</AlertDescription>
@@ -390,13 +404,15 @@ export default function GeneralPage() {
render={({ field }) => (
<FormItem>
<FormLabel>
{t('idpJmespathLabel')}
{t("idpJmespathLabel")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t('idpJmespathLabelDescription')}
{t(
"idpJmespathLabelDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
@@ -409,13 +425,17 @@ export default function GeneralPage() {
render={({ field }) => (
<FormItem>
<FormLabel>
{t('idpJmespathEmailPathOptional')}
{t(
"idpJmespathEmailPathOptional"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t('idpJmespathEmailPathOptionalDescription')}
{t(
"idpJmespathEmailPathOptionalDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
@@ -428,13 +448,17 @@ export default function GeneralPage() {
render={({ field }) => (
<FormItem>
<FormLabel>
{t('idpJmespathNamePathOptional')}
{t(
"idpJmespathNamePathOptional"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t('idpJmespathNamePathOptionalDescription')}
{t(
"idpJmespathNamePathOptionalDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
@@ -447,13 +471,17 @@ export default function GeneralPage() {
render={({ field }) => (
<FormItem>
<FormLabel>
{t('idpOidcConfigureScopes')}
{t(
"idpOidcConfigureScopes"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t('idpOidcConfigureScopesDescription')}
{t(
"idpOidcConfigureScopesDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
@@ -474,7 +502,7 @@ export default function GeneralPage() {
loading={loading}
disabled={loading}
>
{t('saveGeneralSettings')}
{t("saveGeneralSettings")}
</Button>
</div>
</>

View File

@@ -89,7 +89,7 @@ export default function PoliciesPage() {
const [editingPolicy, setEditingPolicy] = useState<PolicyRow | null>(null);
const policyFormSchema = z.object({
orgId: z.string().min(1, { message: t('orgRequired') }),
orgId: z.string().min(1, { message: t("orgRequired") }),
roleMapping: z.string().optional(),
orgMapping: z.string().optional()
});
@@ -133,7 +133,7 @@ export default function PoliciesPage() {
}
} catch (e) {
toast({
title: t('error'),
title: t("error"),
description: formatAxiosError(e),
variant: "destructive"
});
@@ -148,7 +148,7 @@ export default function PoliciesPage() {
}
} catch (e) {
toast({
title: t('error'),
title: t("error"),
description: formatAxiosError(e),
variant: "destructive"
});
@@ -167,7 +167,7 @@ export default function PoliciesPage() {
}
} catch (e) {
toast({
title: t('error'),
title: t("error"),
description: formatAxiosError(e),
variant: "destructive"
});
@@ -202,15 +202,15 @@ export default function PoliciesPage() {
};
setPolicies([...policies, newPolicy]);
toast({
title: t('success'),
description: t('orgPolicyAddedDescription')
title: t("success"),
description: t("orgPolicyAddedDescription")
});
setShowAddDialog(false);
form.reset();
}
} catch (e) {
toast({
title: t('error'),
title: t("error"),
description: formatAxiosError(e),
variant: "destructive"
});
@@ -244,8 +244,8 @@ export default function PoliciesPage() {
)
);
toast({
title: t('success'),
description: t('orgPolicyUpdatedDescription')
title: t("success"),
description: t("orgPolicyUpdatedDescription")
});
setShowAddDialog(false);
setEditingPolicy(null);
@@ -253,7 +253,7 @@ export default function PoliciesPage() {
}
} catch (e) {
toast({
title: t('error'),
title: t("error"),
description: formatAxiosError(e),
variant: "destructive"
});
@@ -271,13 +271,13 @@ export default function PoliciesPage() {
policies.filter((policy) => policy.orgId !== orgId)
);
toast({
title: t('success'),
description: t('orgPolicyDeletedDescription')
title: t("success"),
description: t("orgPolicyDeletedDescription")
});
}
} catch (e) {
toast({
title: t('error'),
title: t("error"),
description: formatAxiosError(e),
variant: "destructive"
});
@@ -295,13 +295,13 @@ export default function PoliciesPage() {
});
if (res.status === 200) {
toast({
title: t('success'),
description: t('defaultMappingsUpdatedDescription')
title: t("success"),
description: t("defaultMappingsUpdatedDescription")
});
}
} catch (e) {
toast({
title: t('error'),
title: t("error"),
description: formatAxiosError(e),
variant: "destructive"
});
@@ -320,18 +320,18 @@ export default function PoliciesPage() {
<Alert variant="neutral" className="mb-6">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t('orgPoliciesAbout')}
{t("orgPoliciesAbout")}
</AlertTitle>
<AlertDescription>
{/*TODO(vlalx): Validate replacing */}
{t('orgPoliciesAboutDescription')}{" "}
{t("orgPoliciesAboutDescription")}{" "}
<Link
href="https://docs.pangolin.net/manage/identity-providers/auto-provisioning"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{t('orgPoliciesAboutDescriptionLink')}
{t("orgPoliciesAboutDescriptionLink")}
<ExternalLink className="ml-1 h-4 w-4 inline" />
</Link>
</AlertDescription>
@@ -340,10 +340,10 @@ export default function PoliciesPage() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('defaultMappingsOptional')}
{t("defaultMappingsOptional")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t('defaultMappingsOptionalDescription')}
{t("defaultMappingsOptionalDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -362,13 +362,15 @@ export default function PoliciesPage() {
render={({ field }) => (
<FormItem>
<FormLabel>
{t('defaultMappingsRole')}
{t("defaultMappingsRole")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t('defaultMappingsRoleDescription')}
{t(
"defaultMappingsRoleDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
@@ -381,13 +383,15 @@ export default function PoliciesPage() {
render={({ field }) => (
<FormItem>
<FormLabel>
{t('defaultMappingsOrg')}
{t("defaultMappingsOrg")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t('defaultMappingsOrgDescription')}
{t(
"defaultMappingsOrgDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
@@ -402,7 +406,7 @@ export default function PoliciesPage() {
form="policy-default-mappings-form"
loading={updateDefaultMappingsLoading}
>
{t('defaultMappingsSubmit')}
{t("defaultMappingsSubmit")}
</Button>
</SettingsSectionFooter>
</SettingsSectionBody>
@@ -445,11 +449,11 @@ export default function PoliciesPage() {
<CredenzaHeader>
<CredenzaTitle>
{editingPolicy
? t('orgPoliciesEdit')
: t('orgPoliciesAdd')}
? t("orgPoliciesEdit")
: t("orgPoliciesAdd")}
</CredenzaTitle>
<CredenzaDescription>
{t('orgPolicyConfig')}
{t("orgPolicyConfig")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
@@ -466,7 +470,7 @@ export default function PoliciesPage() {
name="orgId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>{t('org')}</FormLabel>
<FormLabel>{t("org")}</FormLabel>
{editingPolicy ? (
<Input {...field} disabled />
) : (
@@ -490,17 +494,25 @@ export default function PoliciesPage() {
org.orgId ===
field.value
)?.name
: t('orgSelect')}
: t(
"orgSelect"
)}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0">
<Command>
<CommandInput placeholder={t('orgSearch')} />
<CommandInput
placeholder={t(
"orgSearch"
)}
/>
<CommandList>
<CommandEmpty>
{t('orgNotFound')}
{t(
"orgNotFound"
)}
</CommandEmpty>
<CommandGroup>
{organizations.map(
@@ -551,13 +563,15 @@ export default function PoliciesPage() {
render={({ field }) => (
<FormItem>
<FormLabel>
{t('roleMappingPathOptional')}
{t("roleMappingPathOptional")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t('defaultMappingsRoleDescription')}
{t(
"defaultMappingsRoleDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
@@ -570,13 +584,15 @@ export default function PoliciesPage() {
render={({ field }) => (
<FormItem>
<FormLabel>
{t('orgMappingPathOptional')}
{t("orgMappingPathOptional")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t('defaultMappingsOrgDescription')}
{t(
"defaultMappingsOrgDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
@@ -603,7 +619,9 @@ export default function PoliciesPage() {
: addPolicyLoading
}
>
{editingPolicy ? t('orgPolicyUpdate') : t('orgPolicyAdd')}
{editingPolicy
? t("orgPolicyUpdate")
: t("orgPolicyAdd")}
</Button>
</CredenzaFooter>
</CredenzaContent>

View File

@@ -48,18 +48,18 @@ export default function Page() {
const t = useTranslations();
const createIdpFormSchema = z.object({
name: z.string().min(2, { message: t('nameMin', {len: 2}) }),
name: z.string().min(2, { message: t("nameMin", { len: 2 }) }),
type: z.enum(["oidc"]),
clientId: z.string().min(1, { message: t('idpClientIdRequired') }),
clientSecret: z.string().min(1, { message: t('idpClientSecretRequired') }),
authUrl: z.url({ message: t('idpErrorAuthUrlInvalid') }),
tokenUrl: z.url({ message: t('idpErrorTokenUrlInvalid') }),
identifierPath: z
clientId: z.string().min(1, { message: t("idpClientIdRequired") }),
clientSecret: z
.string()
.min(1, { message: t('idpPathRequired') }),
.min(1, { message: t("idpClientSecretRequired") }),
authUrl: z.url({ message: t("idpErrorAuthUrlInvalid") }),
tokenUrl: z.url({ message: t("idpErrorTokenUrlInvalid") }),
identifierPath: z.string().min(1, { message: t("idpPathRequired") }),
emailPath: z.string().optional(),
namePath: z.string().optional(),
scopes: z.string().min(1, { message: t('idpScopeRequired') }),
scopes: z.string().min(1, { message: t("idpScopeRequired") }),
autoProvision: z.boolean().default(false)
});
@@ -75,7 +75,7 @@ export default function Page() {
{
id: "oidc",
title: "OAuth2/OIDC",
description: t('idpOidcDescription')
description: t("idpOidcDescription")
}
];
@@ -117,14 +117,14 @@ export default function Page() {
if (res.status === 201) {
toast({
title: t('success'),
description: t('idpCreatedDescription')
title: t("success"),
description: t("idpCreatedDescription")
});
router.push(`/admin/idp/${res.data.data.idpId}`);
}
} catch (e) {
toast({
title: t('error'),
title: t("error"),
description: formatAxiosError(e),
variant: "destructive"
});
@@ -137,8 +137,8 @@ export default function Page() {
<>
<div className="flex justify-between">
<HeaderTitle
title={t('idpCreate')}
description={t('idpCreateDescription')}
title={t("idpCreate")}
description={t("idpCreateDescription")}
/>
<Button
variant="outline"
@@ -146,7 +146,7 @@ export default function Page() {
router.push("/admin/idp");
}}
>
{t('idpSeeAll')}
{t("idpSeeAll")}
</Button>
</div>
@@ -154,10 +154,10 @@ export default function Page() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('idpTitle')}
{t("idpTitle")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t('idpCreateSettingsDescription')}
{t("idpCreateSettingsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -173,12 +173,14 @@ export default function Page() {
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t('name')}</FormLabel>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t('idpDisplayName')}
{t("idpDisplayName")}
</FormDescription>
<FormMessage />
</FormItem>
@@ -188,7 +190,7 @@ export default function Page() {
<div className="flex items-start mb-0">
<SwitchInput
id="auto-provision-toggle"
label={t('idpAutoProvisionUsers')}
label={t("idpAutoProvisionUsers")}
defaultChecked={form.getValues(
"autoProvision"
)}
@@ -201,7 +203,7 @@ export default function Page() {
/>
</div>
<span className="text-sm text-muted-foreground">
{t('idpAutoProvisionUsersDescription')}
{t("idpAutoProvisionUsersDescription")}
</span>
</form>
</Form>
@@ -212,10 +214,10 @@ export default function Page() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('idpType')}
{t("idpType")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t('idpTypeDescription')}
{t("idpTypeDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -235,10 +237,10 @@ export default function Page() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('idpOidcConfigure')}
{t("idpOidcConfigure")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t('idpOidcConfigureDescription')}
{t("idpOidcConfigureDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -254,13 +256,15 @@ export default function Page() {
render={({ field }) => (
<FormItem>
<FormLabel>
{t('idpClientId')}
{t("idpClientId")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t('idpClientIdDescription')}
{t(
"idpClientIdDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
@@ -273,7 +277,7 @@ export default function Page() {
render={({ field }) => (
<FormItem>
<FormLabel>
{t('idpClientSecret')}
{t("idpClientSecret")}
</FormLabel>
<FormControl>
<Input
@@ -282,7 +286,9 @@ export default function Page() {
/>
</FormControl>
<FormDescription>
{t('idpClientSecretDescription')}
{t(
"idpClientSecretDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
@@ -295,7 +301,7 @@ export default function Page() {
render={({ field }) => (
<FormItem>
<FormLabel>
{t('idpAuthUrl')}
{t("idpAuthUrl")}
</FormLabel>
<FormControl>
<Input
@@ -304,7 +310,9 @@ export default function Page() {
/>
</FormControl>
<FormDescription>
{t('idpAuthUrlDescription')}
{t(
"idpAuthUrlDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
@@ -317,7 +325,7 @@ export default function Page() {
render={({ field }) => (
<FormItem>
<FormLabel>
{t('idpTokenUrl')}
{t("idpTokenUrl")}
</FormLabel>
<FormControl>
<Input
@@ -326,7 +334,9 @@ export default function Page() {
/>
</FormControl>
<FormDescription>
{t('idpTokenUrlDescription')}
{t(
"idpTokenUrlDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
@@ -338,10 +348,10 @@ export default function Page() {
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t('idpOidcConfigureAlert')}
{t("idpOidcConfigureAlert")}
</AlertTitle>
<AlertDescription>
{t('idpOidcConfigureAlertDescription')}
{t("idpOidcConfigureAlertDescription")}
</AlertDescription>
</Alert>
</SettingsSectionBody>
@@ -350,10 +360,10 @@ export default function Page() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('idpToken')}
{t("idpToken")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t('idpTokenDescription')}
{t("idpTokenDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -366,17 +376,21 @@ export default function Page() {
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t('idpJmespathAbout')}
{t("idpJmespathAbout")}
</AlertTitle>
<AlertDescription>
{t('idpJmespathAboutDescription')}{" "}
{t(
"idpJmespathAboutDescription"
)}{" "}
<a
href="https://jmespath.org"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center"
>
{t('idpJmespathAboutDescriptionLink')}{" "}
{t(
"idpJmespathAboutDescriptionLink"
)}{" "}
<ExternalLink className="ml-1 h-4 w-4" />
</a>
</AlertDescription>
@@ -388,13 +402,15 @@ export default function Page() {
render={({ field }) => (
<FormItem>
<FormLabel>
{t('idpJmespathLabel')}
{t("idpJmespathLabel")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t('idpJmespathLabelDescription')}
{t(
"idpJmespathLabelDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
@@ -407,13 +423,17 @@ export default function Page() {
render={({ field }) => (
<FormItem>
<FormLabel>
{t('idpJmespathEmailPathOptional')}
{t(
"idpJmespathEmailPathOptional"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t('idpJmespathEmailPathOptionalDescription')}
{t(
"idpJmespathEmailPathOptionalDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
@@ -426,13 +446,17 @@ export default function Page() {
render={({ field }) => (
<FormItem>
<FormLabel>
{t('idpJmespathNamePathOptional')}
{t(
"idpJmespathNamePathOptional"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t('idpJmespathNamePathOptionalDescription')}
{t(
"idpJmespathNamePathOptionalDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
@@ -445,13 +469,17 @@ export default function Page() {
render={({ field }) => (
<FormItem>
<FormLabel>
{t('idpOidcConfigureScopes')}
{t(
"idpOidcConfigureScopes"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t('idpOidcConfigureScopesDescription')}
{t(
"idpOidcConfigureScopesDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
@@ -473,7 +501,7 @@ export default function Page() {
router.push("/admin/idp");
}}
>
{t('cancel')}
{t("cancel")}
</Button>
<Button
type="submit"
@@ -481,7 +509,7 @@ export default function Page() {
loading={createLoading}
onClick={form.handleSubmit(onSubmit)}
>
{t('idpSubmit')}
{t("idpSubmit")}
</Button>
</div>
</>

View File

@@ -22,8 +22,8 @@ export default async function IdpPage() {
return (
<>
<SettingsSectionTitle
title={t('idpManage')}
description={t('idpManageDescription')}
title={t("idpManage")}
description={t("idpManageDescription")}
/>
<IdpTable idps={idps} />
</>

View File

@@ -14,4 +14,3 @@ export default async function AdminLicenseLayout(props: LayoutProps) {
return props.children;
}

View File

@@ -315,10 +315,8 @@ export default function LicensePage() {
setSelectedLicenseKey(null);
}}
dialog={
<div>
<p>
{t("licenseQuestionRemove")}
</p>
<div className="space-y-2">
<p>{t("licenseQuestionRemove")}</p>
<p>
<b>{t("licenseMessageRemove")}</b>
</p>
@@ -362,7 +360,8 @@ export default function LicensePage() {
<div className="space-y-2 text-green-500">
<div className="text-2xl flex items-center gap-2">
<Check />
{t("licensed")}
{t("licensed") +
`${licenseStatus?.tier === "personal" ? ` (${t("personalUseOnly")})` : ""}`}
</div>
</div>
) : (

View File

@@ -188,7 +188,7 @@ export default function UsersTable({ users }: Props) {
},
{
id: "actions",
header: () => (<span className="p-3">{t("actions")}</span>),
header: () => <span className="p-3">{t("actions")}</span>,
cell: ({ row }) => {
const r = row.original;
return (
@@ -243,7 +243,7 @@ export default function UsersTable({ users }: Props) {
setSelected(null);
}}
dialog={
<div>
<div className="space-y-2">
<p>{t("userQuestionRemove")}</p>
<p>{t("userMessageRemove")}</p>

View File

@@ -6,7 +6,7 @@ import { AdminGetUserResponse } from "@server/routers/user/adminGetUser";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import { cache } from "react";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from 'next-intl/server';
import { getTranslations } from "next-intl/server";
interface UserLayoutProps {
children: React.ReactNode;
@@ -36,7 +36,7 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
const navItems = [
{
title: t('general'),
title: t("general"),
href: "/admin/users/{userId}/general"
}
];
@@ -45,11 +45,9 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
<>
<SettingsSectionTitle
title={`${user?.email || user?.name || user?.username}`}
description={t('userDescription2')}
description={t("userDescription2")}
/>
<HorizontalTabs items={navItems}>
{children}
</HorizontalTabs>
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
</>
);
}
}

View File

@@ -5,4 +5,4 @@ export default async function UserPage(props: {
}) {
const { userId } = await props.params;
redirect(`/admin/users/${userId}/general`);
}
}

View File

@@ -36,7 +36,7 @@ export default async function UsersPage(props: PageProps) {
username: row.username,
type: row.type,
idpId: row.idpId,
idpName: row.idpName || t('idpNameInternal'),
idpName: row.idpName || t("idpNameInternal"),
dateCreated: row.dateCreated,
serverAdmin: row.serverAdmin,
twoFactorEnabled: row.twoFactorEnabled,
@@ -47,14 +47,16 @@ export default async function UsersPage(props: PageProps) {
return (
<>
<SettingsSectionTitle
title={t('userTitle')}
description={t('userDescription')}
title={t("userTitle")}
description={t("userDescription")}
/>
<Alert variant="neutral" className="mb-6">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">{t('userAbount')}</AlertTitle>
<AlertTitle className="font-semibold">
{t("userAbount")}
</AlertTitle>
<AlertDescription>
{t('userAbountDescription')}
{t("userAbountDescription")}
</AlertDescription>
</Alert>
<UsersTable users={userRows} />

View File

@@ -26,9 +26,6 @@ import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { GetSessionTransferTokenRenponse } from "@server/routers/auth/types";
import ValidateSessionTransferToken from "@app/components/private/ValidateSessionTransferToken";
import { GetOrgTierResponse } from "@server/routers/billing/types";
import { TierId } from "@server/lib/billing/tiers";
import { getCachedSubscription } from "@app/lib/api/getCachedSubscription";
import { replacePlaceholder } from "@app/lib/replacePlaceholder";
import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";

View File

@@ -45,7 +45,9 @@ export default function Setup2FAPage() {
<CardHeader>
<CardTitle>{t("otpSetup")}</CardTitle>
<CardDescription>
{t("adminEnabled2FaOnYourAccount", { email: email || "your account" })}
{t("adminEnabled2FaOnYourAccount", {
email: email || "your account"
})}
</CardDescription>
</CardHeader>
<CardContent>

View File

@@ -14,8 +14,11 @@ export const dynamic = "force-dynamic";
export default async function Page(props: {
params: Promise<{ orgId: string; idpId: string }>;
searchParams: Promise<{
code: string;
state: string;
code?: string;
state?: string;
error?: string;
error_description?: string;
error_uri?: string;
}>;
}) {
const params = await props.params;
@@ -25,15 +28,17 @@ export default async function Page(props: {
const allCookies = await cookies();
const stateCookie = allCookies.get("p_oidc_state")?.value;
const idpRes = await cache(
async () => await priv.get<AxiosResponse<GetIdpResponse>>(`/idp/${params.idpId}`)
async () =>
await priv.get<AxiosResponse<GetIdpResponse>>(
`/idp/${params.idpId}`
)
)();
const foundIdp = idpRes.data?.data?.idp;
if (!foundIdp) {
return <div>{t('idpErrorNotFound')}</div>;
return <div>{t("idpErrorNotFound")}</div>;
}
const allHeaders = await headers();
@@ -59,6 +64,14 @@ export default async function Page(props: {
}
}
const providerError = searchParams.error
? {
error: searchParams.error,
description: searchParams.error_description,
uri: searchParams.error_uri
}
: undefined;
return (
<>
<ValidateOidcToken
@@ -69,6 +82,7 @@ export default async function Page(props: {
expectedState={searchParams.state}
stateCookie={stateCookie}
idp={{ name: foundIdp.name }}
providerError={providerError}
/>
</>
);

View File

@@ -23,6 +23,7 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
const t = await getTranslations();
let hideFooter = false;
let licenseStatus: GetLicenseStatusResponse | null = null;
if (build == "enterprise") {
const licenseStatusRes = await cache(
async () =>
@@ -30,10 +31,12 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
"/license/status"
)
)();
licenseStatus = licenseStatusRes.data.data;
if (
env.branding.hideAuthLayoutFooter &&
licenseStatusRes.data.data.isHostLicensed &&
licenseStatusRes.data.data.isLicenseValid
licenseStatusRes.data.data.isLicenseValid &&
licenseStatusRes.data.data.tier !== "personal"
) {
hideFooter = true;
}
@@ -83,6 +86,23 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
? t("enterpriseEdition")
: t("pangolinCloud")}
</span>
{build === "enterprise" &&
licenseStatus?.isHostLicensed &&
licenseStatus?.isLicenseValid &&
licenseStatus?.tier === "personal" ? (
<>
<Separator orientation="vertical" />
<span>{t("personalUseOnly")}</span>
</>
) : null}
{build === "enterprise" &&
(!licenseStatus?.isHostLicensed ||
!licenseStatus?.isLicenseValid) ? (
<>
<Separator orientation="vertical" />
<span>{t("unlicensed")}</span>
</>
) : null}
{build === "saas" && (
<>
<Separator orientation="vertical" />

View File

@@ -19,7 +19,9 @@ export default async function DeviceLoginPage({ searchParams }: Props) {
const redirectDestination = code
? `/auth/login/device?code=${encodeURIComponent(code)}`
: "/auth/login/device";
redirect(`/auth/login?forceLogin=true&redirect=${encodeURIComponent(redirectDestination)}`);
redirect(
`/auth/login?forceLogin=true&redirect=${encodeURIComponent(redirectDestination)}`
);
}
const userName = user?.name || user?.username || "";

View File

@@ -26,7 +26,9 @@ export default function DeviceAuthSuccessPage() {
<BrandingLogo height={logoHeight} width={logoWidth} />
</div>
<div className="text-center space-y-1 pt-3">
<p className="text-muted-foreground">{t("deviceActivation")}</p>
<p className="text-muted-foreground">
{t("deviceActivation")}
</p>
</div>
</CardHeader>
<CardContent className="p-6">

View File

@@ -98,7 +98,11 @@ export default async function Page(props: {
</div>
)}
<DashboardLoginForm redirect={redirectUrl} idps={loginIdps} forceLogin={forceLogin} />
<DashboardLoginForm
redirect={redirectUrl}
idps={loginIdps}
forceLogin={forceLogin}
/>
{(!signUpDisabled || isInvite) && (
<p className="text-center text-muted-foreground mt-4">

View File

@@ -88,18 +88,18 @@ export default function ResetPasswordForm({
const formSchema = z
.object({
email: z.email({ message: t('emailInvalid') }),
token: z.string().min(8, { message: t('tokenInvalid') }),
email: z.email({ message: t("emailInvalid") }),
token: z.string().min(8, { message: t("tokenInvalid") }),
password: passwordSchema,
confirmPassword: passwordSchema
})
.refine((data) => data.password === data.confirmPassword, {
path: ["confirmPassword"],
message: t('passwordNotMatch')
message: t("passwordNotMatch")
});
const mfaSchema = z.object({
code: z.string().length(6, { message: t('pincodeInvalid') })
code: z.string().length(6, { message: t("pincodeInvalid") })
});
const form = useForm({
@@ -139,8 +139,8 @@ export default function ResetPasswordForm({
} as RequestPasswordResetBody
)
.catch((e) => {
setError(formatAxiosError(e, t('errorOccurred')));
console.error(t('passwordErrorRequestReset'), e);
setError(formatAxiosError(e, t("errorOccurred")));
console.error(t("passwordErrorRequestReset"), e);
setIsSubmitting(false);
});
@@ -169,8 +169,8 @@ export default function ResetPasswordForm({
} as ResetPasswordBody
)
.catch((e) => {
setError(formatAxiosError(e, t('errorOccurred')));
console.error(t('passwordErrorReset'), e);
setError(formatAxiosError(e, t("errorOccurred")));
console.error(t("passwordErrorReset"), e);
setIsSubmitting(false);
});
@@ -186,7 +186,11 @@ export default function ResetPasswordForm({
return;
}
setSuccessMessage(quickstart ? t('accountSetupSuccess') : t('passwordResetSuccess'));
setSuccessMessage(
quickstart
? t("accountSetupSuccess")
: t("passwordResetSuccess")
);
// Auto-login after successful password reset
try {
@@ -208,7 +212,10 @@ export default function ResetPasswordForm({
try {
await api.post("/auth/verify-email/request");
} catch (verificationError) {
console.error("Failed to send verification code:", verificationError);
console.error(
"Failed to send verification code:",
verificationError
);
}
if (redirect) {
@@ -229,7 +236,6 @@ export default function ResetPasswordForm({
}
setIsSubmitting(false);
}, 1500);
} catch (loginError) {
// Auto-login failed, but password reset was successful
console.error("Auto-login failed:", loginError);
@@ -251,13 +257,14 @@ export default function ResetPasswordForm({
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>
{quickstart ? t('completeAccountSetup') : t('passwordReset')}
{quickstart
? t("completeAccountSetup")
: t("passwordReset")}
</CardTitle>
<CardDescription>
{quickstart
? t('completeAccountSetupDescription')
: t('passwordResetDescription')
}
? t("completeAccountSetupDescription")
: t("passwordResetDescription")}
</CardDescription>
</CardHeader>
<CardContent>
@@ -276,16 +283,19 @@ export default function ResetPasswordForm({
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>{t('email')}</FormLabel>
<FormLabel>
{t("email")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
<FormDescription>
{quickstart
? t('accountSetupSent')
: t('passwordResetSent')
}
? t("accountSetupSent")
: t(
"passwordResetSent"
)}
</FormDescription>
</FormItem>
)}
@@ -306,7 +316,9 @@ export default function ResetPasswordForm({
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>{t('email')}</FormLabel>
<FormLabel>
{t("email")}
</FormLabel>
<FormControl>
<Input
{...field}
@@ -326,9 +338,12 @@ export default function ResetPasswordForm({
<FormItem>
<FormLabel>
{quickstart
? t('accountSetupCode')
: t('passwordResetCode')
}
? t(
"accountSetupCode"
)
: t(
"passwordResetCode"
)}
</FormLabel>
<FormControl>
<Input
@@ -339,9 +354,12 @@ export default function ResetPasswordForm({
<FormMessage />
<FormDescription>
{quickstart
? t('accountSetupCodeDescription')
: t('passwordResetCodeDescription')
}
? t(
"accountSetupCodeDescription"
)
: t(
"passwordResetCodeDescription"
)}
</FormDescription>
</FormItem>
)}
@@ -355,9 +373,8 @@ export default function ResetPasswordForm({
<FormItem>
<FormLabel>
{quickstart
? t('passwordCreate')
: t('passwordNew')
}
? t("passwordCreate")
: t("passwordNew")}
</FormLabel>
<FormControl>
<Input
@@ -376,9 +393,12 @@ export default function ResetPasswordForm({
<FormItem>
<FormLabel>
{quickstart
? t('passwordCreateConfirm')
: t('passwordNewConfirm')
}
? t(
"passwordCreateConfirm"
)
: t(
"passwordNewConfirm"
)}
</FormLabel>
<FormControl>
<Input
@@ -407,7 +427,7 @@ export default function ResetPasswordForm({
render={({ field }) => (
<FormItem>
<FormLabel>
{t('pincodeAuth')}
{t("pincodeAuth")}
</FormLabel>
<FormControl>
<div className="flex justify-center">
@@ -475,8 +495,10 @@ export default function ResetPasswordForm({
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{state === "reset"
? (quickstart ? t('completeSetup') : t('passwordReset'))
: t('pincodeSubmit2')}
? quickstart
? t("completeSetup")
: t("passwordReset")
: t("pincodeSubmit2")}
</Button>
)}
@@ -491,9 +513,8 @@ export default function ResetPasswordForm({
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{quickstart
? t('accountSetupSubmit')
: t('passwordResetSubmit')
}
? t("accountSetupSubmit")
: t("passwordResetSubmit")}
</Button>
)}
@@ -507,7 +528,7 @@ export default function ResetPasswordForm({
mfaForm.reset();
}}
>
{t('passwordBack')}
{t("passwordBack")}
</Button>
)}
@@ -521,7 +542,7 @@ export default function ResetPasswordForm({
form.reset();
}}
>
{t('backToEmail')}
{t("backToEmail")}
</Button>
)}
</div>

View File

@@ -35,10 +35,7 @@ export default async function Page(props: {
return (
<>
<VerifyEmailForm
email={user.email!}
redirect={redirectUrl}
/>
<VerifyEmailForm email={user.email!} redirect={redirectUrl} />
</>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -54,7 +54,7 @@
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive: oklch(0.5382 0.1949 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.646 0.222 41.116);

View File

@@ -1,6 +1,6 @@
import type { Metadata } from "next";
import "./globals.css";
import { Inter } from "next/font/google";
import { Geist, Inter, Manrope } from "next/font/google";
import { ThemeProvider } from "@app/providers/ThemeProvider";
import EnvProvider from "@app/providers/EnvProvider";
import { pullEnv } from "@app/lib/pullEnv";
@@ -25,19 +25,7 @@ import { TailwindIndicator } from "@app/components/TailwindIndicator";
export const metadata: Metadata = {
title: `Dashboard - ${process.env.BRANDING_APP_NAME || "Pangolin"}`,
description: "",
...(process.env.BRANDING_FAVICON_PATH
? {
icons: {
icon: [
{
url: process.env.BRANDING_FAVICON_PATH as string
}
]
}
}
: {})
description: ""
};
export const dynamic = "force-dynamic";

View File

@@ -60,7 +60,8 @@ export const orgNavSections = (): SidebarNavSection[] => [
{
title: "sidebarClientResources",
href: "/{orgId}/settings/resources/client",
icon: <GlobeLock className="size-4 flex-none" />
icon: <GlobeLock className="size-4 flex-none" />,
isBeta: true
}
]
},
@@ -104,7 +105,7 @@ export const orgNavSections = (): SidebarNavSection[] => [
]
},
{
heading: "accessControls",
heading: "access",
items: [
{
title: "sidebarUsers",

View File

@@ -1,17 +1,16 @@
import { getTranslations } from "next-intl/server";
export default async function NotFound() {
const t = await getTranslations();
return (
<div className="w-full max-w-md mx-auto p-3 md:mt-32 text-center">
<h1 className="text-6xl font-bold mb-4">404</h1>
<h2 className="text-2xl font-semibold text-neutral-500 mb-4">
{t('pageNotFound')}
{t("pageNotFound")}
</h2>
<p className="text-neutral-500 dark:text-neutral-700 mb-8">
{t('pageNotFoundDescription')}
{t("pageNotFoundDescription")}
</p>
</div>
);

View File

@@ -312,7 +312,9 @@ export default function StepperForm() {
</FormControl>
<FormMessage />
<FormDescription>
{t("setupSubnetDescription")}
{t(
"setupSubnetDescription"
)}
</FormDescription>
</FormItem>
)}

View File

@@ -14,21 +14,21 @@ export default function AccessPageHeaderAndNav({
hasInvitations
}: AccessPageHeaderAndNavProps) {
const t = useTranslations();
const navItems = [
{
title: t('users'),
title: t("users"),
href: `/{orgId}/settings/access/users`
},
{
title: t('roles'),
title: t("roles"),
href: `/{orgId}/settings/access/roles`
}
];
if (hasInvitations) {
navItems.push({
title: t('invite'),
title: t("invite"),
href: `/{orgId}/settings/access/invitations`
});
}
@@ -36,13 +36,11 @@ export default function AccessPageHeaderAndNav({
return (
<>
<SettingsSectionTitle
title={t('accessUsersRoles')}
description={t('accessUsersRolesDescription')}
title={t("accessUsersRoles")}
description={t("accessUsersRolesDescription")}
/>
<HorizontalTabs items={navItems}>
{children}
</HorizontalTabs>
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
</>
);
}

View File

@@ -20,10 +20,7 @@ type AccessTokenProps = {
resourceId?: number;
};
export default function AccessToken({
token,
resourceId
}: AccessTokenProps) {
export default function AccessToken({ token, resourceId }: AccessTokenProps) {
const [loading, setLoading] = useState(true);
const [isValid, setIsValid] = useState(false);
@@ -79,7 +76,7 @@ export default function AccessToken({
);
}
} catch (e) {
console.error(t('accessTokenError'), e);
console.error(t("accessTokenError"), e);
} finally {
setLoading(false);
}
@@ -102,7 +99,7 @@ export default function AccessToken({
);
}
} catch (e) {
console.error(t('accessTokenError'), e);
console.error(t("accessTokenError"), e);
} finally {
setLoading(false);
}
@@ -118,26 +115,22 @@ export default function AccessToken({
function renderTitle() {
if (isValid) {
return t('accessGranted');
return t("accessGranted");
} else {
return t('accessUrlInvalid');
return t("accessUrlInvalid");
}
}
function renderContent() {
if (isValid) {
return (
<div>
{t('accessGrantedDescription')}
</div>
);
return <div>{t("accessGrantedDescription")}</div>;
} else {
return (
<div>
{t('accessUrlInvalidDescription')}
{t("accessUrlInvalidDescription")}
<div className="text-center mt-4">
<Button>
<Link href="/">{t('goHome')}</Link>
<Link href="/">{t("goHome")}</Link>
</Button>
</div>
</div>

View File

@@ -44,31 +44,35 @@ export default function AccessTokenSection({
<>
<div className="flex items-start space-x-2">
<p className="text-sm text-muted-foreground">
{t('shareTokenDescription')}
{t("shareTokenDescription")}
</p>
</div>
<Tabs defaultValue="token" className="w-full mt-4">
<TabsList className="grid grid-cols-2">
<TabsTrigger value="token">{t('accessToken')}</TabsTrigger>
<TabsTrigger value="usage">{t('usageExamples')}</TabsTrigger>
<TabsTrigger value="token">{t("accessToken")}</TabsTrigger>
<TabsTrigger value="usage">
{t("usageExamples")}
</TabsTrigger>
</TabsList>
<TabsContent value="token" className="space-y-4">
<div className="space-y-1">
<div className="font-bold">{t('tokenId')}</div>
<div className="font-bold">{t("tokenId")}</div>
<CopyToClipboard text={tokenId} isLink={false} />
</div>
<div className="space-y-1">
<div className="font-bold">{t('token')}</div>
<div className="font-bold">{t("token")}</div>
<CopyToClipboard text={token} isLink={false} />
</div>
</TabsContent>
<TabsContent value="usage" className="space-y-4">
<div className="space-y-2">
<h3 className="text-sm font-medium">{t('requestHeades')}</h3>
<h3 className="text-sm font-medium">
{t("requestHeades")}
</h3>
<CopyTextBox
text={`${env.server.resourceAccessTokenHeadersId}: ${tokenId}
${env.server.resourceAccessTokenHeadersToken}: ${token}`}
@@ -76,7 +80,9 @@ ${env.server.resourceAccessTokenHeadersToken}: ${token}`}
</div>
<div className="space-y-2">
<h3 className="text-sm font-medium">{t('queryParameter')}</h3>
<h3 className="text-sm font-medium">
{t("queryParameter")}
</h3>
<CopyTextBox
text={`${resourceUrl}?${env.server.resourceAccessTokenParam}=${tokenId}.${token}`}
/>
@@ -85,17 +91,17 @@ ${env.server.resourceAccessTokenHeadersToken}: ${token}`}
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t('importantNote')}
{t("importantNote")}
</AlertTitle>
<AlertDescription>
{t('shareImportantDescription')}
{t("shareImportantDescription")}
</AlertDescription>
</Alert>
</TabsContent>
</Tabs>
<div className="text-sm text-muted-foreground mt-4">
{t('shareTokenSecurety')}
{t("shareTokenSecurety")}
</div>
</>
);

View File

@@ -26,10 +26,10 @@ export function IdpDataTable<TData, TValue>({
columns={columns}
data={data}
persistPageSize="idp-table"
title={t('idp')}
searchPlaceholder={t('idpSearch')}
title={t("idp")}
searchPlaceholder={t("idpSearch")}
searchColumn="name"
addButtonText={t('idpAdd')}
addButtonText={t("idpAdd")}
onAdd={() => {
router.push("/admin/idp/create");
}}

View File

@@ -175,9 +175,7 @@ export default function IdpTable({ idps }: Props) {
</DropdownMenuContent>
</DropdownMenu>
<Link href={`/admin/idp/${siteRow.idpId}/general`}>
<Button
variant={"outline"}
>
<Button variant={"outline"}>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
@@ -198,7 +196,7 @@ export default function IdpTable({ idps }: Props) {
setSelectedIdp(null);
}}
dialog={
<div>
<div className="space-y-2">
<p>
{t("idpQuestionRemove", {
name: selectedIdp.name

View File

@@ -1,8 +1,6 @@
"use client";
import {
ColumnDef,
} from "@tanstack/react-table";
import { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
import { useTranslations } from "next-intl";
@@ -19,7 +17,6 @@ export function UsersDataTable<TData, TValue>({
onRefresh,
isRefreshing
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
@@ -27,8 +24,8 @@ export function UsersDataTable<TData, TValue>({
columns={columns}
data={data}
persistPageSize="userServer-table"
title={t('userServer')}
searchPlaceholder={t('userSearch')}
title={t("userServer")}
searchPlaceholder={t("userSearch")}
searchColumn="email"
onRefresh={onRefresh}
isRefreshing={isRefreshing}

View File

@@ -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);
@@ -313,7 +313,7 @@ export default function UsersTable({ users }: Props) {
setSelected(null);
}}
dialog={
<div>
<div className="space-y-2">
<p>
{t("userQuestionRemove", {
selectedUser:

View File

@@ -44,7 +44,6 @@ export function ApiKeysDataTable<TData, TValue>({
onRefresh,
isRefreshing
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
@@ -52,11 +51,11 @@ export function ApiKeysDataTable<TData, TValue>({
columns={columns}
data={data}
persistPageSize="apiKeys-table"
title={t('apiKeys')}
searchPlaceholder={t('searchApiKeys')}
title={t("apiKeys")}
searchPlaceholder={t("searchApiKeys")}
searchColumn="name"
onAdd={addApiKey}
addButtonText={t('apiKeysAdd')}
addButtonText={t("apiKeysAdd")}
onRefresh={onRefresh}
isRefreshing={isRefreshing}
enableColumnVisibility={true}

View File

@@ -108,7 +108,7 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
{
accessorKey: "key",
friendlyName: t("key"),
header: () => (<span className="p-3">{t("key")}</span>),
header: () => <span className="p-3">{t("key")}</span>,
cell: ({ row }) => {
const r = row.original;
return <span className="font-mono">{r.key}</span>;
@@ -117,7 +117,7 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
{
accessorKey: "createdAt",
friendlyName: t("createdAt"),
header: () => (<span className="p-3">{t("createdAt")}</span>),
header: () => <span className="p-3">{t("createdAt")}</span>,
cell: ({ row }) => {
const r = row.original;
return <span>{moment(r.createdAt).format("lll")} </span>;
@@ -161,9 +161,7 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
</DropdownMenuContent>
</DropdownMenu>
<Link href={`/admin/api-keys/${r.id}`}>
<Button
variant={"outline"}
>
<Button variant={"outline"}>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
@@ -184,7 +182,7 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
setSelected(null);
}}
dialog={
<div>
<div className="space-y-2">
<p>{t("apiKeysQuestionRemove")}</p>
<p>{t("apiKeysMessageRemove")}</p>

View File

@@ -67,7 +67,8 @@ export default function AutoLoginHandler({
console.error("Failed to generate OIDC URL:", e);
setError(
t("autoLoginErrorGeneratingUrl", {
defaultValue: "An unexpected error occurred. Please try again."
defaultValue:
"An unexpected error occurred. Please try again."
})
);
} finally {

View File

@@ -20,7 +20,10 @@ type ChangePasswordDialogProps = {
setOpen: (val: boolean) => void;
};
export default function ChangePasswordDialog({ open, setOpen }: ChangePasswordDialogProps) {
export default function ChangePasswordDialog({
open,
setOpen
}: ChangePasswordDialogProps) {
const t = useTranslations();
const [currentStep, setCurrentStep] = useState(1);
const [loading, setLoading] = useState(false);
@@ -47,18 +50,16 @@ export default function ChangePasswordDialog({ open, setOpen }: ChangePasswordDi
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
{t('changePassword')}
</CredenzaTitle>
<CredenzaTitle>{t("changePassword")}</CredenzaTitle>
<CredenzaDescription>
{t('changePasswordDescription')}
{t("changePasswordDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<ChangePasswordForm
ref={formRef}
isDialog={true}
submitButtonText={t('submit')}
submitButtonText={t("submit")}
cancelButtonText="Close"
showCancelButton={false}
onComplete={() => setOpen(false)}
@@ -77,7 +78,7 @@ export default function ChangePasswordDialog({ open, setOpen }: ChangePasswordDi
disabled={loading}
onClick={handleSubmit}
>
{t('submit')}
{t("submit")}
</Button>
)}
</CredenzaFooter>

View File

@@ -22,11 +22,7 @@ import {
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { useTranslations } from "next-intl";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot
} from "./ui/input-otp";
import { InputOTP, InputOTPGroup, InputOTPSlot } from "./ui/input-otp";
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
import { ChangePasswordResponse } from "@server/routers/auth";
import { cn } from "@app/lib/cn";
@@ -114,14 +110,22 @@ const ChangePasswordForm = forwardRef<
onLoadingChange?.(loading);
}, [loading, onLoadingChange]);
const passwordSchema = z.object({
oldPassword: z.string().min(1, { message: t("passwordRequired") }),
newPassword: z.string().min(8, { message: t("passwordRequirementsChars") }),
confirmPassword: z.string().min(1, { message: t("passwordRequired") })
}).refine((data) => data.newPassword === data.confirmPassword, {
message: t("passwordsDoNotMatch"),
path: ["confirmPassword"],
});
const passwordSchema = z
.object({
oldPassword: z
.string()
.min(1, { message: t("passwordRequired") }),
newPassword: z
.string()
.min(8, { message: t("passwordRequirementsChars") }),
confirmPassword: z
.string()
.min(1, { message: t("passwordRequired") })
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: t("passwordsDoNotMatch"),
path: ["confirmPassword"]
});
const mfaSchema = z.object({
code: z.string().length(6, { message: t("pincodeInvalid") })
@@ -143,11 +147,13 @@ const ChangePasswordForm = forwardRef<
}
});
const changePassword = async (values: z.infer<typeof passwordSchema>) => {
const changePassword = async (
values: z.infer<typeof passwordSchema>
) => {
setLoading(true);
const endpoint = `/auth/change-password`;
const payload = {
const payload = {
oldPassword: values.oldPassword,
newPassword: values.newPassword
};
@@ -181,7 +187,7 @@ const ChangePasswordForm = forwardRef<
const endpoint = `/auth/change-password`;
const passwordValues = passwordForm.getValues();
const payload = {
const payload = {
oldPassword: passwordValues.oldPassword,
newPassword: passwordValues.newPassword,
code: values.code
@@ -303,7 +309,9 @@ const ChangePasswordForm = forwardRef<
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-foreground">
{t("passwordStrength")}
{t(
"passwordStrength"
)}
</span>
<span
className={cn(
@@ -335,7 +343,9 @@ const ChangePasswordForm = forwardRef<
{/* Requirements Checklist */}
<div className="bg-muted rounded-lg p-3 space-y-2">
<div className="text-sm font-medium text-foreground mb-2">
{t("passwordRequirements")}
{t(
"passwordRequirements"
)}
</div>
<div className="grid grid-cols-1 gap-1.5">
<div className="flex items-center gap-2">
@@ -505,13 +515,14 @@ const ChangePasswordForm = forwardRef<
{confirmPasswordValue.length > 0 &&
!doPasswordsMatch && (
<p className="text-sm text-red-600 mt-1">
{t("passwordsDoNotMatch")}
{t(
"passwordsDoNotMatch"
)}
</p>
)}
{/* Only show FormMessage when field is empty */}
{confirmPasswordValue.length === 0 && (
<FormMessage />
)}
{confirmPasswordValue.length ===
0 && <FormMessage />}
</FormItem>
)}
/>
@@ -523,7 +534,9 @@ const ChangePasswordForm = forwardRef<
{step === 2 && (
<div className="space-y-4">
<div className="text-center">
<h3 className="text-lg font-medium">{t("otpAuth")}</h3>
<h3 className="text-lg font-medium">
{t("otpAuth")}
</h3>
<p className="text-sm text-muted-foreground">
{t("otpAuthDescription")}
</p>
@@ -551,9 +564,12 @@ const ChangePasswordForm = forwardRef<
onChange={(
value: string
) => {
field.onChange(value);
field.onChange(
value
);
if (
value.length === 6
value.length ===
6
) {
mfaForm.handleSubmit(
confirmMfa
@@ -630,10 +646,7 @@ const ChangePasswordForm = forwardRef<
</Button>
)}
{step === 3 && (
<Button
onClick={handleComplete}
className="w-full"
>
<Button onClick={handleComplete} className="w-full">
{t("continueToApplication")}
</Button>
)}
@@ -644,4 +657,4 @@ const ChangePasswordForm = forwardRef<
}
);
export default ChangePasswordForm;
export default ChangePasswordForm;

View File

@@ -19,25 +19,27 @@ export default function SiteInfoCard({}: ClientInfoCardProps) {
return (
<Alert>
<AlertDescription>
<InfoSections cols={2}>
<>
<InfoSection>
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
<InfoSectionContent>
{client.online ? (
<div className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>{t("online")}</span>
</div>
) : (
<div className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>{t("offline")}</span>
</div>
)}
</InfoSectionContent>
</InfoSection>
</>
<InfoSections cols={3}>
<InfoSection>
<InfoSectionTitle>{t("identifier")}</InfoSectionTitle>
<InfoSectionContent>{client.niceId}</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
<InfoSectionContent>
{client.online ? (
<div className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>{t("online")}</span>
</div>
) : (
<div className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>{t("offline")}</span>
</div>
)}
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>{t("address")}</InfoSectionTitle>
<InfoSectionContent>

View File

@@ -25,32 +25,6 @@ import EditInternalResourceDialog from "@app/components/EditInternalResourceDial
import { orgQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
export type TargetHealth = {
targetId: number;
ip: string;
port: number;
enabled: boolean;
healthStatus?: "healthy" | "unhealthy" | "unknown";
};
export type ResourceRow = {
id: number;
nice: string | null;
name: string;
orgId: string;
domain: string;
authState: string;
http: boolean;
protocol: string;
proxyPort: number | null;
enabled: boolean;
domainId?: string;
ssl: boolean;
targetHost?: string;
targetPort?: number;
targets?: TargetHealth[];
};
export type InternalResourceRow = {
id: number;
name: string;
@@ -66,6 +40,10 @@ export type InternalResourceRow = {
destination: string;
// destinationPort: number | null;
alias: string | null;
niceId: string;
tcpPortRangeString: string | null;
udpPortRangeString: string | null;
disableIcmp: boolean;
};
type ClientResourcesTableProps = {
@@ -158,6 +136,28 @@ export default function ClientResourcesTable({
);
}
},
{
id: "niceId",
accessorKey: "niceId",
friendlyName: t("identifier"),
enableHiding: true,
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("identifier")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return <span>{row.original.niceId || "-"}</span>;
}
},
{
accessorKey: "siteName",
friendlyName: t("site"),
@@ -287,7 +287,7 @@ export default function ClientResourcesTable({
setSelectedInternalResource(null);
}}
dialog={
<div>
<div className="space-y-2">
<p>{t("resourceQuestionRemove")}</p>
<p>{t("resourceMessageRemove")}</p>
</div>

View File

@@ -1,8 +1,6 @@
"use client";
import {
ColumnDef,
} from "@tanstack/react-table";
import { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
type TabFilter = {

View File

@@ -1,7 +1,18 @@
import { useState } from "react";
import { Button } from "@app/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@app/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@app/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@app/components/ui/command";
import { CheckIcon, ChevronDownIcon, Filter } from "lucide-react";
import { cn } from "@app/lib/cn";
@@ -31,7 +42,9 @@ export function ColumnFilter({
}: ColumnFilterProps) {
const [open, setOpen] = useState(false);
const selectedOption = options.find(option => option.value === selectedValue);
const selectedOption = options.find(
(option) => option.value === selectedValue
);
return (
<Popover open={open} onOpenChange={setOpen}>
@@ -49,7 +62,9 @@ export function ColumnFilter({
<div className="flex items-center gap-2">
<Filter className="h-4 w-4" />
<span className="truncate">
{selectedOption ? selectedOption.label : placeholder}
{selectedOption
? selectedOption.label
: placeholder}
</span>
</div>
<ChevronDownIcon className="h-4 w-4 shrink-0 opacity-50" />
@@ -79,7 +94,9 @@ export function ColumnFilter({
value={option.label}
onSelect={() => {
onValueChange(
selectedValue === option.value ? undefined : option.value
selectedValue === option.value
? undefined
: option.value
);
setOpen(false);
}}
@@ -101,4 +118,4 @@ export function ColumnFilter({
</PopoverContent>
</Popover>
);
}
}

View File

@@ -186,7 +186,7 @@ const DockerContainersTable: FC<{
{
accessorKey: "name",
friendlyName: t("containerName"),
header: () => (<span className="p-3">{t("containerName")}</span>),
header: () => <span className="p-3">{t("containerName")}</span>,
cell: ({ row }) => (
<div className="font-medium">{row.original.name}</div>
)
@@ -194,7 +194,7 @@ const DockerContainersTable: FC<{
{
accessorKey: "image",
friendlyName: t("containerImage"),
header: () => (<span className="p-3">{t("containerImage")}</span>),
header: () => <span className="p-3">{t("containerImage")}</span>,
cell: ({ row }) => (
<div className="text-sm text-muted-foreground">
{row.original.image}
@@ -204,7 +204,7 @@ const DockerContainersTable: FC<{
{
accessorKey: "state",
friendlyName: t("containerState"),
header: () => (<span className="p-3">{t("containerState")}</span>),
header: () => <span className="p-3">{t("containerState")}</span>,
cell: ({ row }) => (
<Badge
variant={
@@ -220,7 +220,7 @@ const DockerContainersTable: FC<{
{
accessorKey: "networks",
friendlyName: t("containerNetworks"),
header: () => (<span className="p-3">{t("containerNetworks")}</span>),
header: () => <span className="p-3">{t("containerNetworks")}</span>,
cell: ({ row }) => {
const networks = Object.keys(row.original.networks);
return (
@@ -239,7 +239,9 @@ const DockerContainersTable: FC<{
{
accessorKey: "hostname",
friendlyName: t("containerHostnameIp"),
header: () => (<span className="p-3">{t("containerHostnameIp")}</span>),
header: () => (
<span className="p-3">{t("containerHostnameIp")}</span>
),
enableHiding: false,
cell: ({ row }) => (
<div className="text-sm font-mono">
@@ -250,7 +252,7 @@ const DockerContainersTable: FC<{
{
accessorKey: "labels",
friendlyName: t("containerLabels"),
header: () => (<span className="p-3">{t("containerLabels")}</span>),
header: () => <span className="p-3">{t("containerLabels")}</span>,
cell: ({ row }) => {
const labels = row.original.labels || {};
const labelEntries = Object.entries(labels);
@@ -302,7 +304,7 @@ const DockerContainersTable: FC<{
},
{
accessorKey: "ports",
header: () => (<span className="p-3">{t("containerPorts")}</span>),
header: () => <span className="p-3">{t("containerPorts")}</span>,
enableHiding: false,
cell: ({ row }) => {
const ports = getExposedPorts(row.original);
@@ -360,7 +362,7 @@ const DockerContainersTable: FC<{
},
{
id: "actions",
header: () => (<span className="p-3">{t("containerActions")}</span>),
header: () => <span className="p-3">{t("containerActions")}</span>,
cell: ({ row }) => {
const ports = getExposedPorts(row.original);
return (

View File

@@ -29,7 +29,7 @@ export default function CopyTextBox({
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
} catch (err) {
console.error(t('copyTextFailed'), err);
console.error(t("copyTextFailed"), err);
}
}
};
@@ -54,7 +54,7 @@ export default function CopyTextBox({
type="button"
className="absolute top-0.5 right-0 z-10 bg-card"
onClick={copyToClipboard}
aria-label={t('copyTextClipboard')}
aria-label={t("copyTextClipboard")}
>
{isCopied ? (
<Check className="h-4 w-4 text-green-500" />

View File

@@ -9,7 +9,11 @@ type CopyToClipboardProps = {
isLink?: boolean;
};
const CopyToClipboard = ({ text, displayText, isLink }: CopyToClipboardProps) => {
const CopyToClipboard = ({
text,
displayText,
isLink
}: CopyToClipboardProps) => {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
@@ -60,7 +64,7 @@ const CopyToClipboard = ({ text, displayText, isLink }: CopyToClipboardProps) =>
) : (
<Check className="text-green-500 h-4 w-4" />
)}
<span className="sr-only">{t('copyText')}</span>
<span className="sr-only">{t("copyText")}</span>
</button>
</div>
);

View File

@@ -45,11 +45,16 @@ import {
} from "@app/components/InfoSection";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { build } from "@server/build";
import { toASCII, toUnicode } from 'punycode';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { toASCII, toUnicode } from "punycode";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "./ui/select";
import { useRouter } from "next/navigation";
// Helper functions for Unicode domain handling
function toPunycode(domain: string): string {
try {
@@ -76,9 +81,9 @@ function isValidDomainFormat(domain: string): boolean {
return false;
}
const parts = domain.split('.');
const parts = domain.split(".");
for (const part of parts) {
if (part.length === 0 || part.startsWith('-') || part.endsWith('-')) {
if (part.length === 0 || part.startsWith("-") || part.endsWith("-")) {
return false;
}
if (part.length > 63) {
@@ -137,7 +142,8 @@ export default function CreateDomainForm({
resolver: zodResolver(formSchema),
defaultValues: {
baseDomain: "",
type: build == "oss" || !env.flags.usePangolinDns ? "wildcard" : "ns",
type:
build == "oss" || !env.flags.usePangolinDns ? "wildcard" : "ns",
certResolver: null,
preferWildcardCert: false
}
@@ -172,7 +178,9 @@ export default function CreateDomainForm({
description: t("domainCreatedDescription")
});
onCreated?.(domainData);
router.push(`/${org.org.orgId}/settings/domains/${domainData.domainId}`);
router.push(
`/${org.org.orgId}/settings/domains/${domainData.domainId}`
);
} catch (e) {
toast({
title: t("error"),
@@ -182,7 +190,7 @@ export default function CreateDomainForm({
} finally {
setLoading(false);
}
};
}
// Domain type options
let domainOptions: any = [];
@@ -225,145 +233,213 @@ export default function CreateDomainForm({
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="create-domain-form"
>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<StrategySelect
options={domainOptions}
defaultValue={field.value}
onChange={field.onChange}
cols={1}
/>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="baseDomain"
render={({ field }) => (
<FormItem>
<FormLabel>{t("domain")}</FormLabel>
<FormControl>
<Input
placeholder="example.com"
{...field}
/>
</FormControl>
{punycodePreview && (
<FormDescription className="flex items-center gap-2 text-xs">
<Alert>
<Globe className="h-4 w-4" />
<AlertTitle>{t("internationaldomaindetected")}</AlertTitle>
<AlertDescription>
<div className="mt-2 space-y-1">
<p>{t("willbestoredas")} <code className="font-mono px-1 py-0.5 rounded">{punycodePreview}</code></p>
</div>
</AlertDescription>
</Alert>
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
{domainType === "wildcard" && (
<>
<FormField
control={form.control}
name="certResolver"
render={({ field }) => (
<FormItem>
<FormLabel>{t("certResolver")}</FormLabel>
<FormControl>
<Select
value={
field.value === null ? "default" :
(field.value === "" || (field.value && field.value !== "default")) ? "custom" :
"default"
}
onValueChange={(val) => {
if (val === "default") {
field.onChange(null);
} else if (val === "custom") {
field.onChange("");
} else {
field.onChange(val);
}
}}
>
<SelectTrigger>
<SelectValue placeholder={t("selectCertResolver")} />
</SelectTrigger>
<SelectContent>
{certResolverOptions.map((opt) => (
<SelectItem key={opt.id} value={opt.id}>
{opt.title}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="create-domain-form"
>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<StrategySelect
options={domainOptions}
defaultValue={field.value}
onChange={field.onChange}
cols={1}
/>
{form.watch("certResolver") !== null &&
form.watch("certResolver") !== "default" && (
<FormField
control={form.control}
name="certResolver"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder={t("enterCustomResolver")}
value={field.value || ""}
onChange={(e) => field.onChange(e.target.value)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="baseDomain"
render={({ field }) => (
<FormItem>
<FormLabel>{t("domain")}</FormLabel>
<FormControl>
<Input
placeholder="example.com"
{...field}
/>
</FormControl>
{punycodePreview && (
<FormDescription className="flex items-center gap-2 text-xs">
<Alert>
<Globe className="h-4 w-4" />
<AlertTitle>
{t(
"internationaldomaindetected"
)}
</AlertTitle>
<AlertDescription>
<div className="mt-2 space-y-1">
<p>
{t(
"willbestoredas"
)}{" "}
<code className="font-mono px-1 py-0.5 rounded">
{
punycodePreview
}
</code>
</p>
</div>
</AlertDescription>
</Alert>
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
{domainType === "wildcard" && (
<>
<FormField
control={form.control}
name="certResolver"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("certResolver")}
</FormLabel>
<FormControl>
<Select
value={
field.value === null
? "default"
: field.value ===
"" ||
(field.value &&
field.value !==
"default")
? "custom"
: "default"
}
onValueChange={(
val
) => {
if (
val ===
"default"
) {
field.onChange(
null
);
} else if (
val === "custom"
) {
field.onChange(
""
);
} else {
field.onChange(
val
);
}
}}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectCertResolver"
)}
/>
</SelectTrigger>
<SelectContent>
{certResolverOptions.map(
(opt) => (
<SelectItem
key={
opt.id
}
value={
opt.id
}
>
{
opt.title
}
</SelectItem>
)
)}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{form.watch("certResolver") !== null &&
form.watch("certResolver") !==
"default" && (
<FormField
control={form.control}
name="certResolver"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder={t(
"enterCustomResolver"
)}
value={
field.value ||
""
}
onChange={(e) =>
field.onChange(
e.target
.value
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{form.watch("certResolver") !== null &&
form.watch("certResolver") !== "default" && (
<FormField
control={form.control}
name="preferWildcardCert"
render={({ field: checkboxField }) => (
<FormItem className="flex flex-row items-center space-x-3 space-y-0">
<FormControl>
<CheckboxWithLabel
label={t("preferWildcardCert")}
checked={checkboxField.value}
onCheckedChange={checkboxField.onChange}
/>
</FormControl>
{/* <div className="space-y-1 leading-none">
{form.watch("certResolver") !== null &&
form.watch("certResolver") !==
"default" && (
<FormField
control={form.control}
name="preferWildcardCert"
render={({
field: checkboxField
}) => (
<FormItem className="flex flex-row items-center space-x-3 space-y-0">
<FormControl>
<CheckboxWithLabel
label={t(
"preferWildcardCert"
)}
checked={
checkboxField.value
}
onCheckedChange={
checkboxField.onChange
}
/>
</FormControl>
{/* <div className="space-y-1 leading-none">
<FormLabel>
{t("preferWildcardCert")}
</FormLabel>
</div> */}
</FormItem>
)}
/>
)}
</>
)}
</form>
</Form>
</FormItem>
)}
/>
)}
</>
)}
</form>
</Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>

View File

@@ -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,32 @@ 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);
};
// Helper function to clean resource name for FQDN format
const cleanForFQDN = (name: string): string => {
return name
.toLowerCase()
.replace(/[^a-z0-9.-]/g, "-") // Replace invalid chars with hyphens
.replace(/[-]+/g, "-") // Replace multiple hyphens with single hyphen
.replace(/^-|-$/g, "") // Remove leading/trailing hyphens
.replace(/^\.|\.$/g, ""); // Remove leading/trailing dots
};
useEffect(() => {
if (open && availableSites.length > 0) {
form.reset({
@@ -243,16 +356,44 @@ 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]);
const handleSubmit = async (data: FormData) => {
setIsSubmitting(true);
try {
// Validate: if mode is "host" and destination is a hostname (contains letters),
// an alias is required
if (data.mode === "host" && isHostname(data.destination)) {
const currentAlias = data.alias?.trim() || "";
if (!currentAlias) {
// Prefill alias based on destination
let aliasValue = data.destination;
if (data.destination.toLowerCase() === "localhost") {
// Use resource name cleaned for FQDN with .internal suffix
const cleanedName = cleanForFQDN(data.name);
aliasValue = `${cleanedName}.internal`;
}
// Update the form with the prefilled alias
form.setValue("alias", aliasValue);
data.alias = aliasValue;
}
}
const response = await api.put<AxiosResponse<any>>(
`/org/${orgId}/site/${data.siteId}/resource`,
{
@@ -269,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))
: [],
@@ -692,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">

View File

@@ -48,7 +48,7 @@ export default function CreateRoleForm({
const t = useTranslations();
const formSchema = z.object({
name: z.string({ message: t('nameRequired') }).max(32),
name: z.string({ message: t("nameRequired") }).max(32),
description: z.string().max(255).optional()
});
@@ -78,10 +78,10 @@ export default function CreateRoleForm({
.catch((e) => {
toast({
variant: "destructive",
title: t('accessRoleErrorCreate'),
title: t("accessRoleErrorCreate"),
description: formatAxiosError(
e,
t('accessRoleErrorCreateDescription')
t("accessRoleErrorCreateDescription")
)
});
});
@@ -89,8 +89,8 @@ export default function CreateRoleForm({
if (res && res.status === 201) {
toast({
variant: "default",
title: t('accessRoleCreated'),
description: t('accessRoleCreatedDescription')
title: t("accessRoleCreated"),
description: t("accessRoleCreatedDescription")
});
if (open) {
@@ -117,9 +117,9 @@ export default function CreateRoleForm({
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>{t('accessRoleCreate')}</CredenzaTitle>
<CredenzaTitle>{t("accessRoleCreate")}</CredenzaTitle>
<CredenzaDescription>
{t('accessRoleCreateDescription')}
{t("accessRoleCreateDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
@@ -134,7 +134,9 @@ export default function CreateRoleForm({
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t('accessRoleName')}</FormLabel>
<FormLabel>
{t("accessRoleName")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
@@ -147,7 +149,9 @@ export default function CreateRoleForm({
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>{t('description')}</FormLabel>
<FormLabel>
{t("description")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
@@ -160,7 +164,7 @@ export default function CreateRoleForm({
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t('close')}</Button>
<Button variant="outline">{t("close")}</Button>
</CredenzaClose>
<Button
type="submit"
@@ -168,7 +172,7 @@ export default function CreateRoleForm({
loading={loading}
disabled={loading}
>
{t('accessRoleCreateSubmit')}
{t("accessRoleCreateSubmit")}
</Button>
</CredenzaFooter>
</CredenzaContent>

View File

@@ -67,7 +67,7 @@ import {
} from "@app/components/ui/collapsible";
import AccessTokenSection from "@app/components/AccessTokenUsage";
import { useTranslations } from "next-intl";
import { toUnicode } from 'punycode';
import { toUnicode } from "punycode";
type FormProps = {
open: boolean;
@@ -104,7 +104,7 @@ export default function CreateShareLinkForm({
>([]);
const formSchema = z.object({
resourceId: z.number({ message: t('shareErrorSelectResource') }),
resourceId: z.number({ message: t("shareErrorSelectResource") }),
resourceName: z.string(),
resourceUrl: z.string(),
timeUnit: z.string(),
@@ -113,12 +113,12 @@ export default function CreateShareLinkForm({
});
const timeUnits = [
{ unit: "minutes", name: t('minutes') },
{ unit: "hours", name: t('hours') },
{ unit: "days", name: t('days') },
{ unit: "weeks", name: t('weeks') },
{ unit: "months", name: t('months') },
{ unit: "years", name: t('years') }
{ unit: "minutes", name: t("minutes") },
{ unit: "hours", name: t("hours") },
{ unit: "days", name: t("days") },
{ unit: "weeks", name: t("weeks") },
{ unit: "months", name: t("months") },
{ unit: "years", name: t("years") }
];
const form = useForm<z.infer<typeof formSchema>>({
@@ -144,10 +144,10 @@ export default function CreateShareLinkForm({
console.error(e);
toast({
variant: "destructive",
title: t('shareErrorFetchResource'),
title: t("shareErrorFetchResource"),
description: formatAxiosError(
e,
t('shareErrorFetchResourceDescription')
t("shareErrorFetchResourceDescription")
)
});
});
@@ -204,17 +204,21 @@ export default function CreateShareLinkForm({
validForSeconds: neverExpire ? undefined : timeInSeconds,
title:
values.title ||
t('shareLink', {resource: (values.resourceName || "Resource" + values.resourceId)})
t("shareLink", {
resource:
values.resourceName ||
"Resource" + values.resourceId
})
}
)
.catch((e) => {
console.error(e);
toast({
variant: "destructive",
title: t('shareErrorCreate'),
title: t("shareErrorCreate"),
description: formatAxiosError(
e,
t('shareErrorCreateDescription')
t("shareErrorCreateDescription")
)
});
});
@@ -263,9 +267,9 @@ export default function CreateShareLinkForm({
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>{t('shareCreate')}</CredenzaTitle>
<CredenzaTitle>{t("shareCreate")}</CredenzaTitle>
<CredenzaDescription>
{t('shareCreateDescription')}
{t("shareCreateDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
@@ -283,7 +287,7 @@ export default function CreateShareLinkForm({
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>
{t('resource')}
{t("resource")}
</FormLabel>
<Popover>
<PopoverTrigger asChild>
@@ -301,17 +305,25 @@ export default function CreateShareLinkForm({
? getSelectedResourceName(
field.value
)
: t('resourceSelect')}
: t(
"resourceSelect"
)}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0">
<Command>
<CommandInput placeholder={t('resourceSearch')} />
<CommandInput
placeholder={t(
"resourceSearch"
)}
/>
<CommandList>
<CommandEmpty>
{t('resourcesNotFound')}
{t(
"resourcesNotFound"
)}
</CommandEmpty>
<CommandGroup>
{resources.map(
@@ -367,7 +379,9 @@ export default function CreateShareLinkForm({
render={({ field }) => (
<FormItem>
<FormLabel>
{t('shareTitleOptional')}
{t(
"shareTitleOptional"
)}
</FormLabel>
<FormControl>
<Input {...field} />
@@ -379,7 +393,9 @@ export default function CreateShareLinkForm({
<div className="space-y-4">
<div className="space-y-2">
<FormLabel>{t('expireIn')}</FormLabel>
<FormLabel>
{t("expireIn")}
</FormLabel>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
@@ -391,11 +407,17 @@ export default function CreateShareLinkForm({
field.onChange
}
defaultValue={field.value.toString()}
disabled={neverExpire}
disabled={
neverExpire
}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue placeholder={t('selectDuration')} />
<SelectValue
placeholder={t(
"selectDuration"
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -458,12 +480,12 @@ export default function CreateShareLinkForm({
htmlFor="terms"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t('neverExpire')}
{t("neverExpire")}
</label>
</div>
<p className="text-sm text-muted-foreground">
{t('shareExpireDescription')}
{t("shareExpireDescription")}
</p>
</div>
</form>
@@ -471,16 +493,15 @@ export default function CreateShareLinkForm({
)}
{link && (
<div className="max-w-md space-y-4">
<p>
{t('shareSeeOnce')}
</p>
<p>
{t('shareAccessHint')}
</p>
<p>{t("shareSeeOnce")}</p>
<p>{t("shareAccessHint")}</p>
<div className="h-[250px] w-full mx-auto flex items-center justify-center">
<div className="bg-white p-6 border rounded-md">
<QRCodeCanvas value={link} size={200} />
<QRCodeCanvas
value={link}
size={200}
/>
</div>
</div>
@@ -503,12 +524,12 @@ export default function CreateShareLinkForm({
className="p-0 flex items-center justify-between w-full"
>
<h4 className="text-sm font-semibold">
{t('shareTokenUsage')}
{t("shareTokenUsage")}
</h4>
<div>
<ChevronsUpDown className="h-4 w-4" />
<span className="sr-only">
{t('toggle')}
{t("toggle")}
</span>
</div>
</Button>
@@ -538,7 +559,7 @@ export default function CreateShareLinkForm({
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t('close')}</Button>
<Button variant="outline">{t("close")}</Button>
</CredenzaClose>
<Button
type="button"
@@ -546,7 +567,7 @@ export default function CreateShareLinkForm({
loading={loading}
disabled={link !== null || loading}
>
{t('createLink')}
{t("createLink")}
</Button>
</CredenzaFooter>
</CredenzaContent>

View File

@@ -78,7 +78,10 @@ const CredenzaClose = ({ className, children, ...props }: CredenzaProps) => {
const CredenzaClose = isDesktop ? DialogClose : DrawerClose;
return (
<CredenzaClose className={cn("mb-3 mt-3 md:mt-0 md:mb-0", className)} {...props}>
<CredenzaClose
className={cn("mb-3 mt-3 md:mt-0 md:mb-0", className)}
{...props}
>
{children}
</CredenzaClose>
);
@@ -128,7 +131,7 @@ const CredenzaHeader = ({ className, children, ...props }: CredenzaProps) => {
const CredenzaHeader = isDesktop ? DialogHeader : SheetHeader;
return (
<CredenzaHeader className={className} {...props}>
<CredenzaHeader className={cn("-mx-6 px-6", className)} {...props}>
{children}
</CredenzaHeader>
);
@@ -155,7 +158,13 @@ const CredenzaBody = ({ className, children, ...props }: CredenzaProps) => {
// );
return (
<div className={cn("px-0 mb-4 space-y-4 overflow-x-hidden min-w-0", className)} {...props}>
<div
className={cn(
"px-0 mb-4 space-y-4 overflow-x-hidden min-w-0",
className
)}
{...props}
>
{children}
</div>
);
@@ -168,7 +177,13 @@ const CredenzaFooter = ({ className, children, ...props }: CredenzaProps) => {
const CredenzaFooter = isDesktop ? DialogFooter : SheetFooter;
return (
<CredenzaFooter className={cn("mt-8 md:mt-0", className)} {...props}>
<CredenzaFooter
className={cn(
"mt-8 md:mt-0 -mx-6 px-6 pt-4 border-t border-border",
className
)}
{...props}
>
{children}
</CredenzaFooter>
);

View File

@@ -21,10 +21,7 @@ type Props = {
type: string | null;
};
export default function DNSRecordsTable({
records,
type
}: Props) {
export default function DNSRecordsTable({ records, type }: Props) {
const t = useTranslations();
const env = useEnvContext();
@@ -114,11 +111,5 @@ export default function DNSRecordsTable({
...(env.env.flags.usePangolinDns ? [statusColumn] : [])
];
return (
<DNSRecordsDataTable
columns={columns}
data={records}
type={type}
/>
);
return <DNSRecordsDataTable columns={columns} data={records} type={type} />;
}

View File

@@ -110,7 +110,11 @@ export function DNSRecordsDataTable<TData, TValue>({
<h1 className="font-bold">{t("dnsRecord")}</h1>
<Badge variant="secondary">{t("required")}</Badge>
</div>
<Link href="https://docs.pangolin.net/manage/domains" target="_blank" rel="noopener noreferrer">
<Link
href="https://docs.pangolin.net/manage/domains"
target="_blank"
rel="noopener noreferrer"
>
<Button variant="outline">
<ExternalLink className="h-4 w-4 mr-1" />
{t("howToAddRecords")}
@@ -122,9 +126,7 @@ export function DNSRecordsDataTable<TData, TValue>({
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow
key={headerGroup.id}
>
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder

View File

@@ -35,6 +35,9 @@ export default function DashboardLoginForm({
const { isUnlocked } = useLicenseStatusContext();
function getSubtitle() {
if (isUnlocked() && env.branding?.loginPage?.subtitleText) {
return env.branding.loginPage.subtitleText;
}
return t("loginStart");
}

View File

@@ -24,6 +24,8 @@ interface DataTablePaginationProps<TData> {
isServerPagination?: boolean;
isLoading?: boolean;
disabled?: boolean;
pageSize?: number;
pageIndex?: number;
}
export function DataTablePagination<TData>({
@@ -33,60 +35,84 @@ export function DataTablePagination<TData>({
totalCount,
isServerPagination = false,
isLoading = false,
disabled = false
disabled = false,
pageSize: controlledPageSize,
pageIndex: controlledPageIndex
}: DataTablePaginationProps<TData>) {
const t = useTranslations();
// Use controlled values if provided, otherwise fall back to table state
const pageSize = controlledPageSize ?? table.getState().pagination.pageSize;
const pageIndex =
controlledPageIndex ?? table.getState().pagination.pageIndex;
// Calculate page boundaries based on controlled state
// For server-side pagination, use totalCount if available for accurate page count
const pageCount =
isServerPagination && totalCount !== undefined
? Math.ceil(totalCount / pageSize)
: table.getPageCount();
const canNextPage = pageIndex < pageCount - 1;
const canPreviousPage = pageIndex > 0;
const handlePageSizeChange = (value: string) => {
const newPageSize = Number(value);
table.setPageSize(newPageSize);
// Call the callback if provided (for persistence)
if (onPageSizeChange) {
onPageSizeChange(newPageSize);
}
};
const handlePageNavigation = (action: 'first' | 'previous' | 'next' | 'last') => {
const handlePageNavigation = (
action: "first" | "previous" | "next" | "last"
) => {
if (isServerPagination && onPageChange) {
const currentPage = table.getState().pagination.pageIndex;
const currentPage = pageIndex;
const pageCount = table.getPageCount();
let newPage: number;
switch (action) {
case 'first':
case "first":
newPage = 0;
break;
case 'previous':
case "previous":
newPage = Math.max(0, currentPage - 1);
break;
case 'next':
case "next":
newPage = Math.min(pageCount - 1, currentPage + 1);
break;
case 'last':
case "last":
newPage = pageCount - 1;
break;
default:
return;
}
if (newPage !== currentPage) {
onPageChange(newPage);
}
} else {
// Use table's built-in navigation for client-side pagination
// But add bounds checking to prevent going beyond page boundaries
const pageCount = table.getPageCount();
switch (action) {
case 'first':
case "first":
table.setPageIndex(0);
break;
case 'previous':
table.previousPage();
case "previous":
if (pageIndex > 0) {
table.previousPage();
}
break;
case 'next':
table.nextPage();
case "next":
if (pageIndex < pageCount - 1) {
table.nextPage();
}
break;
case 'last':
table.setPageIndex(table.getPageCount() - 1);
case "last":
table.setPageIndex(Math.max(0, pageCount - 1));
break;
}
}
@@ -96,14 +122,12 @@ export function DataTablePagination<TData>({
<div className="flex items-center justify-between text-muted-foreground">
<div className="flex items-center space-x-2">
<Select
value={`${table.getState().pagination.pageSize}`}
value={`${pageSize}`}
onValueChange={handlePageSizeChange}
disabled={disabled}
>
<SelectTrigger className="h-8 w-[73px]" disabled={disabled}>
<SelectValue
placeholder={table.getState().pagination.pageSize}
/>
<SelectValue placeholder={pageSize} />
</SelectTrigger>
<SelectContent side="bottom">
{[10, 20, 30, 40, 50, 100].map((pageSize) => (
@@ -117,50 +141,53 @@ export function DataTablePagination<TData>({
<div className="flex items-center space-x-3 lg:space-x-8">
<div className="flex items-center justify-center text-sm font-medium">
{isServerPagination && totalCount !== undefined ? (
t('paginator', {
current: table.getState().pagination.pageIndex + 1,
last: Math.ceil(totalCount / table.getState().pagination.pageSize)
})
) : (
t('paginator', {current: table.getState().pagination.pageIndex + 1, last: table.getPageCount()})
)}
{isServerPagination && totalCount !== undefined
? t("paginator", {
current: pageIndex + 1,
last: Math.ceil(totalCount / pageSize)
})
: t("paginator", {
current: pageIndex + 1,
last: table.getPageCount()
})}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => handlePageNavigation('first')}
disabled={!table.getCanPreviousPage() || isLoading || disabled}
onClick={() => handlePageNavigation("first")}
disabled={!canPreviousPage || isLoading || disabled}
>
<span className="sr-only">{t('paginatorToFirst')}</span>
<span className="sr-only">{t("paginatorToFirst")}</span>
<DoubleArrowLeftIcon className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => handlePageNavigation('previous')}
disabled={!table.getCanPreviousPage() || isLoading || disabled}
onClick={() => handlePageNavigation("previous")}
disabled={!canPreviousPage || isLoading || disabled}
>
<span className="sr-only">{t('paginatorToPrevious')}</span>
<span className="sr-only">
{t("paginatorToPrevious")}
</span>
<ChevronLeftIcon className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => handlePageNavigation('next')}
disabled={!table.getCanNextPage() || isLoading || disabled}
onClick={() => handlePageNavigation("next")}
disabled={!canNextPage || isLoading || disabled}
>
<span className="sr-only">{t('paginatorToNext')}</span>
<span className="sr-only">{t("paginatorToNext")}</span>
<ChevronRightIcon className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => handlePageNavigation('last')}
disabled={!table.getCanNextPage() || isLoading || disabled}
onClick={() => handlePageNavigation("last")}
disabled={!canNextPage || isLoading || disabled}
>
<span className="sr-only">{t('paginatorToLast')}</span>
<span className="sr-only">{t("paginatorToLast")}</span>
<DoubleArrowRightIcon className="h-4 w-4" />
</Button>
</div>

View File

@@ -62,7 +62,7 @@ export default function DeleteRoleForm({
const api = createApiClient(useEnvContext());
const formSchema = z.object({
newRoleId: z.string({ message: t('accessRoleErrorNewRequired') })
newRoleId: z.string({ message: t("accessRoleErrorNewRequired") })
});
useEffect(() => {
@@ -75,10 +75,10 @@ export default function DeleteRoleForm({
console.error(e);
toast({
variant: "destructive",
title: t('accessRoleErrorFetch'),
title: t("accessRoleErrorFetch"),
description: formatAxiosError(
e,
t('accessRoleErrorFetchDescription')
t("accessRoleErrorFetchDescription")
)
});
});
@@ -114,10 +114,10 @@ export default function DeleteRoleForm({
.catch((e) => {
toast({
variant: "destructive",
title: t('accessRoleErrorRemove'),
title: t("accessRoleErrorRemove"),
description: formatAxiosError(
e,
t('accessRoleErrorRemoveDescription')
t("accessRoleErrorRemoveDescription")
)
});
});
@@ -125,8 +125,8 @@ export default function DeleteRoleForm({
if (res && res.status === 200) {
toast({
variant: "default",
title: t('accessRoleRemoved'),
description: t('accessRoleRemovedDescription')
title: t("accessRoleRemoved"),
description: t("accessRoleRemovedDescription")
});
if (open) {
@@ -153,66 +153,66 @@ export default function DeleteRoleForm({
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>{t('accessRoleRemove')}</CredenzaTitle>
<CredenzaTitle>{t("accessRoleRemove")}</CredenzaTitle>
<CredenzaDescription>
{t('accessRoleRemoveDescription')}
{t("accessRoleRemoveDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<div className="space-y-4">
<p>
{t('accessRoleQuestionRemove', {name: roleToDelete.name})}
</p>
<p>
{t('accessRoleRequiredRemove')}
</p>
</div>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="remove-role-form"
>
<FormField
control={form.control}
name="newRoleId"
render={({ field }) => (
<FormItem>
<FormLabel>{t('role')}</FormLabel>
<Select
onValueChange={
field.onChange
}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('accessRoleSelect')} />
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map((role) => (
<SelectItem
key={
role.roleId
}
value={role.roleId.toString()}
>
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
<div className="space-y-4">
<p>
{t("accessRoleQuestionRemove", {
name: roleToDelete.name
})}
</p>
<p>{t("accessRoleRequiredRemove")}</p>
</div>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="remove-role-form"
>
<FormField
control={form.control}
name="newRoleId"
render={({ field }) => (
<FormItem>
<FormLabel>{t("role")}</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t(
"accessRoleSelect"
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map((role) => (
<SelectItem
key={role.roleId}
value={role.roleId.toString()}
>
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t('close')}</Button>
<Button variant="outline">{t("close")}</Button>
</CredenzaClose>
<Button
variant="destructive"
@@ -221,7 +221,7 @@ export default function DeleteRoleForm({
loading={loading}
disabled={loading}
>
{t('accessRoleRemoveSubmit')}
{t("accessRoleRemoveSubmit")}
</Button>
</CredenzaFooter>
</CredenzaContent>

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