mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-02 08:09:10 +00:00
Merge branch 'dev' into refactor/show-product-updates-conditionnally
This commit is contained in:
@@ -25,7 +25,13 @@ import RegenerateCredentialsModal from "@app/components/RegenerateCredentialsMod
|
||||
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 {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger
|
||||
} from "@app/components/ui/tooltip";
|
||||
import { SecurityFeaturesAlert } from "@app/components/SecurityFeaturesAlert";
|
||||
|
||||
export default function CredentialsPage() {
|
||||
const { env } = useEnvContext();
|
||||
@@ -36,7 +42,8 @@ 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 { licenseStatus, isUnlocked } = useLicenseStatusContext();
|
||||
const subscription = useSubscriptionStatusContext();
|
||||
@@ -48,21 +55,19 @@ export default function CredentialsPage() {
|
||||
return isEnterpriseNotLicensed || isSaasNotSubscribed;
|
||||
};
|
||||
|
||||
|
||||
const handleConfirmRegenerate = async () => {
|
||||
|
||||
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);
|
||||
|
||||
await api.put<AxiosResponse<QuickStartRemoteExitNodeResponse>>(
|
||||
`/re-key/${orgId}/reGenerate-remote-exit-node-secret`,
|
||||
`/re-key/${orgId}/regenerate-remote-exit-node-secret`,
|
||||
{
|
||||
remoteExitNodeId: remoteExitNode.remoteExitNodeId,
|
||||
secret: data.secret,
|
||||
secret: data.secret
|
||||
}
|
||||
);
|
||||
|
||||
@@ -85,40 +90,29 @@ export default function CredentialsPage() {
|
||||
};
|
||||
|
||||
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>
|
||||
<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>
|
||||
<SettingsSectionBody>
|
||||
<SecurityFeaturesAlert />
|
||||
<Button
|
||||
onClick={() => setModalOpen(true)}
|
||||
disabled={isSecurityFeatureDisabled()}
|
||||
>
|
||||
{t("regeneratecredentials")}
|
||||
</Button>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
|
||||
<RegenerateCredentialsModal
|
||||
open={modalOpen}
|
||||
@@ -128,6 +122,6 @@ export default function CredentialsPage() {
|
||||
dashboardUrl={env.app.dashboardUrl}
|
||||
credentials={getCredentials()}
|
||||
/>
|
||||
</SettingsContainer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import RegenerateCredentialsModal from "@app/components/RegenerateCredentialsModal";
|
||||
import { SecurityFeaturesAlert } from "@app/components/SecurityFeaturesAlert";
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
@@ -59,7 +60,6 @@ export default function CredentialsPage() {
|
||||
await api.post(
|
||||
`/re-key/${client?.clientId}/regenerate-client-secret`,
|
||||
{
|
||||
olmId: data.olmId,
|
||||
secret: data.olmSecret
|
||||
}
|
||||
);
|
||||
@@ -84,40 +84,29 @@ export default function CredentialsPage() {
|
||||
};
|
||||
|
||||
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>
|
||||
<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>
|
||||
<SettingsSectionBody>
|
||||
<SecurityFeaturesAlert />
|
||||
<Button
|
||||
onClick={() => setModalOpen(true)}
|
||||
disabled={isSecurityFeatureDisabled()}
|
||||
>
|
||||
{t("regeneratecredentials")}
|
||||
</Button>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
|
||||
<RegenerateCredentialsModal
|
||||
open={modalOpen}
|
||||
@@ -127,6 +116,6 @@ export default function CredentialsPage() {
|
||||
dashboardUrl={env.app.dashboardUrl}
|
||||
credentials={getCredentials()}
|
||||
/>
|
||||
</SettingsContainer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ export default function Page() {
|
||||
},
|
||||
{
|
||||
title: t("run"),
|
||||
command: `sudo olm --id ${id} --secret ${secret} --endpoint ${endpoint} --org ${orgId}`
|
||||
command: `sudo olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -152,7 +152,7 @@ export default function Page() {
|
||||
},
|
||||
{
|
||||
title: t("run"),
|
||||
command: `olm.exe --id ${id} --secret ${secret} --endpoint ${endpoint} --org ${orgId}`
|
||||
command: `olm.exe --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -22,7 +22,13 @@ import RegenerateCredentialsModal from "@app/components/RegenerateCredentialsMod
|
||||
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 {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger
|
||||
} from "@app/components/ui/tooltip";
|
||||
import { SecurityFeaturesAlert } from "@app/components/SecurityFeaturesAlert";
|
||||
|
||||
export default function CredentialsPage() {
|
||||
const { env } = useEnvContext();
|
||||
@@ -33,7 +39,8 @@ 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("");
|
||||
|
||||
@@ -47,7 +54,6 @@ export default function CredentialsPage() {
|
||||
return isEnterpriseNotLicensed || isSaasNotSubscribed;
|
||||
};
|
||||
|
||||
|
||||
const hydrateWireGuardConfig = (
|
||||
privateKey: string,
|
||||
publicKey: string,
|
||||
@@ -97,8 +103,6 @@ PersistentKeepalive = 5`;
|
||||
|
||||
await api.post(`/re-key/${site?.siteId}/regenerate-site-secret`, {
|
||||
type: "wireguard",
|
||||
subnet: res.data.data.subnet,
|
||||
exitNodeId: res.data.data.exitNodeId,
|
||||
pubKey: generatedPublicKey
|
||||
});
|
||||
}
|
||||
@@ -109,11 +113,13 @@ PersistentKeepalive = 5`;
|
||||
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
|
||||
});
|
||||
await api.post(
|
||||
`/re-key/${site?.siteId}/regenerate-site-secret`,
|
||||
{
|
||||
type: "newt",
|
||||
secret: data.newtSecret
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,40 +151,30 @@ PersistentKeepalive = 5`;
|
||||
};
|
||||
|
||||
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>
|
||||
<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>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
<SettingsSectionBody>
|
||||
<Button
|
||||
onClick={() => setModalOpen(true)}
|
||||
disabled={isSecurityFeatureDisabled()}
|
||||
>
|
||||
{t("regeneratecredentials")}
|
||||
</Button>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
|
||||
<RegenerateCredentialsModal
|
||||
open={modalOpen}
|
||||
@@ -188,6 +184,6 @@ PersistentKeepalive = 5`;
|
||||
dashboardUrl={env.app.dashboardUrl}
|
||||
credentials={getCredentials()}
|
||||
/>
|
||||
</SettingsContainer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,16 +7,6 @@ import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "@/components/ui/card";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -32,7 +22,6 @@ export default async function Page(props: {
|
||||
const getUser = cache(verifySession);
|
||||
const user = await getUser();
|
||||
const t = await getTranslations();
|
||||
const env = pullEnv();
|
||||
|
||||
if (user) {
|
||||
let loggedOut = false;
|
||||
@@ -55,48 +44,6 @@ export default async function Page(props: {
|
||||
redirectUrl = cleanRedirect(searchParams.redirect);
|
||||
}
|
||||
|
||||
// If email is not enabled, show a message instead of the form
|
||||
if (!env.email.emailEnabled) {
|
||||
return (
|
||||
<>
|
||||
<div className="w-full max-w-md">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("passwordReset")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t("passwordResetDescription")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert variant="neutral">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
{t("passwordResetSmtpRequired")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("passwordResetSmtpRequiredDescription")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-muted-foreground mt-4">
|
||||
<Link
|
||||
href={
|
||||
!searchParams.redirect
|
||||
? `/auth/login`
|
||||
: `/auth/login?redirect=${redirectUrl}`
|
||||
}
|
||||
className="underline"
|
||||
>
|
||||
{t("loginBack")}
|
||||
</Link>
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResetPasswordForm
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -19,6 +19,18 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle,
|
||||
CredenzaBody,
|
||||
CredenzaFooter,
|
||||
CredenzaClose
|
||||
} from "@app/components/Credenza";
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import { AxiosResponse } from "axios";
|
||||
|
||||
export type GlobalUserRow = {
|
||||
id: string;
|
||||
@@ -37,6 +49,12 @@ type Props = {
|
||||
users: GlobalUserRow[];
|
||||
};
|
||||
|
||||
type AdminGeneratePasswordResetCodeResponse = {
|
||||
token: string;
|
||||
email: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export default function UsersTable({ users }: Props) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
@@ -48,6 +66,11 @@ export default function UsersTable({ users }: Props) {
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isPasswordResetCodeDialogOpen, setIsPasswordResetCodeDialogOpen] =
|
||||
useState(false);
|
||||
const [passwordResetCodeData, setPasswordResetCodeData] =
|
||||
useState<AdminGeneratePasswordResetCodeResponse | null>(null);
|
||||
const [isGeneratingCode, setIsGeneratingCode] = useState(false);
|
||||
|
||||
const refreshData = async () => {
|
||||
console.log("Data refreshed");
|
||||
@@ -86,6 +109,29 @@ export default function UsersTable({ users }: Props) {
|
||||
});
|
||||
};
|
||||
|
||||
const generatePasswordResetCode = async (userId: string) => {
|
||||
setIsGeneratingCode(true);
|
||||
try {
|
||||
const res = await api.post<
|
||||
AxiosResponse<AdminGeneratePasswordResetCodeResponse>
|
||||
>(`/user/${userId}/generate-password-reset-code`);
|
||||
|
||||
if (res.data?.data) {
|
||||
setPasswordResetCodeData(res.data.data);
|
||||
setIsPasswordResetCodeDialogOpen(true);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to generate password reset code", e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("error"),
|
||||
description: formatAxiosError(e, t("errorOccurred"))
|
||||
});
|
||||
} finally {
|
||||
setIsGeneratingCode(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ExtendedColumnDef<GlobalUserRow>[] = [
|
||||
{
|
||||
accessorKey: "id",
|
||||
@@ -195,7 +241,7 @@ export default function UsersTable({ users }: Props) {
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<span>
|
||||
{userRow.twoFactorEnabled ||
|
||||
userRow.twoFactorSetupRequested ? (
|
||||
userRow.twoFactorSetupRequested ? (
|
||||
<span className="text-green-500">
|
||||
{t("enabled")}
|
||||
</span>
|
||||
@@ -217,17 +263,21 @@ export default function UsersTable({ users }: Props) {
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<span className="sr-only">
|
||||
Open menu
|
||||
</span>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{r.type !== "internal" && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
generatePasswordResetCode(r.id);
|
||||
}}
|
||||
>
|
||||
{t("generatePasswordResetCode")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelected(r);
|
||||
@@ -295,6 +345,58 @@ export default function UsersTable({ users }: Props) {
|
||||
onRefresh={refreshData}
|
||||
isRefreshing={isRefreshing}
|
||||
/>
|
||||
|
||||
<Credenza
|
||||
open={isPasswordResetCodeDialogOpen}
|
||||
onOpenChange={setIsPasswordResetCodeDialogOpen}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{t("passwordResetCodeGenerated")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("passwordResetCodeGeneratedDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
{passwordResetCodeData && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">
|
||||
{t("email")}
|
||||
</label>
|
||||
<CopyToClipboard
|
||||
text={passwordResetCodeData.email}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">
|
||||
{t("passwordResetCode")}
|
||||
</label>
|
||||
<CopyToClipboard
|
||||
text={passwordResetCodeData.token}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">
|
||||
{t("passwordResetUrl")}
|
||||
</label>
|
||||
<CopyToClipboard
|
||||
text={passwordResetCodeData.url}
|
||||
isLink={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,24 +26,21 @@ const CopyToClipboard = ({ text, displayText, isLink }: CopyToClipboardProps) =>
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2 max-w-full">
|
||||
<div className="flex items-center space-x-2 min-w-0 max-w-full">
|
||||
{isLink ? (
|
||||
<Link
|
||||
href={text}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="truncate hover:underline text-sm"
|
||||
style={{ maxWidth: "100%" }} // Ensures truncation works within parent
|
||||
className="truncate hover:underline text-sm min-w-0 max-w-full"
|
||||
title={text} // Shows full text on hover
|
||||
>
|
||||
{displayValue}
|
||||
</Link>
|
||||
) : (
|
||||
<span
|
||||
className="truncate text-sm"
|
||||
className="truncate text-sm min-w-0 max-w-full"
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
display: "block",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis"
|
||||
@@ -55,7 +52,7 @@ const CopyToClipboard = ({ text, displayText, isLink }: CopyToClipboardProps) =>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="h-6 w-6 p-0 flex items-center justify-center cursor-pointer"
|
||||
className="h-6 w-6 p-0 flex items-center justify-center cursor-pointer flex-shrink-0"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{!copied ? (
|
||||
|
||||
@@ -232,6 +232,21 @@ export default function CreateInternalResourceDialog({
|
||||
|
||||
const mode = form.watch("mode");
|
||||
|
||||
// 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({
|
||||
@@ -253,6 +268,26 @@ export default function CreateInternalResourceDialog({
|
||||
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`,
|
||||
{
|
||||
|
||||
@@ -155,7 +155,7 @@ const CredenzaBody = ({ className, children, ...props }: CredenzaProps) => {
|
||||
// );
|
||||
|
||||
return (
|
||||
<div className={cn("px-0 mb-4 space-y-4", className)} {...props}>
|
||||
<div className={cn("px-0 mb-4 space-y-4 overflow-x-hidden min-w-0", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -273,9 +273,44 @@ export default function EditInternalResourceDialog({
|
||||
|
||||
const mode = form.watch("mode");
|
||||
|
||||
// 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
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Update the site resource
|
||||
await api.post(
|
||||
`/org/${orgId}/site/${resource.siteId}/resource/${resource.id}`,
|
||||
|
||||
@@ -34,8 +34,8 @@ import {
|
||||
ResetPasswordBody,
|
||||
ResetPasswordResponse
|
||||
} from "@server/routers/auth";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Alert, AlertDescription } from "./ui/alert";
|
||||
import { Loader2, InfoIcon } from "lucide-react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
@@ -84,22 +84,23 @@ export default function ResetPasswordForm({
|
||||
|
||||
const [state, setState] = useState<"request" | "reset" | "mfa">(getState());
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
|
||||
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 +140,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 +170,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 +187,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 +213,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 +237,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,47 +258,70 @@ 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>
|
||||
<div className="space-y-4">
|
||||
{state === "request" && (
|
||||
<Form {...requestForm}>
|
||||
<form
|
||||
onSubmit={requestForm.handleSubmit(
|
||||
onRequest
|
||||
)}
|
||||
className="space-y-4"
|
||||
id="form"
|
||||
>
|
||||
<FormField
|
||||
control={requestForm.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('email')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{quickstart
|
||||
? t('accountSetupSent')
|
||||
: t('passwordResetSent')
|
||||
}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
<>
|
||||
{!env.email.emailEnabled && (
|
||||
<Alert variant="neutral">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
{t("passwordResetSmtpRequired")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t(
|
||||
"passwordResetSmtpRequiredDescription"
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{env.email.emailEnabled && (
|
||||
<Form {...requestForm}>
|
||||
<form
|
||||
onSubmit={requestForm.handleSubmit(
|
||||
onRequest
|
||||
)}
|
||||
className="space-y-4"
|
||||
id="form"
|
||||
>
|
||||
<FormField
|
||||
control={requestForm.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("email")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{quickstart
|
||||
? t(
|
||||
"accountSetupSent"
|
||||
)
|
||||
: t(
|
||||
"passwordResetSent"
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === "reset" && (
|
||||
@@ -306,11 +336,13 @@ export default function ResetPasswordForm({
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('email')}</FormLabel>
|
||||
<FormLabel>
|
||||
{t("email")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
disabled
|
||||
disabled={env.email.emailEnabled}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -326,9 +358,12 @@ export default function ResetPasswordForm({
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{quickstart
|
||||
? t('accountSetupCode')
|
||||
: t('passwordResetCode')
|
||||
}
|
||||
? t(
|
||||
"accountSetupCode"
|
||||
)
|
||||
: t(
|
||||
"passwordResetCode"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
@@ -337,12 +372,17 @@ export default function ResetPasswordForm({
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{quickstart
|
||||
? t('accountSetupCodeDescription')
|
||||
: t('passwordResetCodeDescription')
|
||||
}
|
||||
</FormDescription>
|
||||
{env.email.emailEnabled && (
|
||||
<FormDescription>
|
||||
{quickstart
|
||||
? t(
|
||||
"accountSetupCodeDescription"
|
||||
)
|
||||
: t(
|
||||
"passwordResetCodeDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -355,9 +395,8 @@ export default function ResetPasswordForm({
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{quickstart
|
||||
? t('passwordCreate')
|
||||
: t('passwordNew')
|
||||
}
|
||||
? t("passwordCreate")
|
||||
: t("passwordNew")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
@@ -376,9 +415,12 @@ export default function ResetPasswordForm({
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{quickstart
|
||||
? t('passwordCreateConfirm')
|
||||
: t('passwordNewConfirm')
|
||||
}
|
||||
? t(
|
||||
"passwordCreateConfirm"
|
||||
)
|
||||
: t(
|
||||
"passwordNewConfirm"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
@@ -407,7 +449,7 @@ export default function ResetPasswordForm({
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('pincodeAuth')}
|
||||
{t("pincodeAuth")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex justify-center">
|
||||
@@ -475,26 +517,45 @@ 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>
|
||||
)}
|
||||
|
||||
{state === "request" && (
|
||||
<Button
|
||||
type="submit"
|
||||
form="form"
|
||||
className="w-full"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<div className="flex flex-col gap-2">
|
||||
{env.email.emailEnabled && (
|
||||
<Button
|
||||
type="submit"
|
||||
form="form"
|
||||
className="w-full"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{quickstart
|
||||
? t("accountSetupSubmit")
|
||||
: t("passwordResetSubmit")}
|
||||
</Button>
|
||||
)}
|
||||
{quickstart
|
||||
? t('accountSetupSubmit')
|
||||
: t('passwordResetSubmit')
|
||||
}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
const email =
|
||||
requestForm.getValues("email");
|
||||
if (email) {
|
||||
form.setValue("email", email);
|
||||
}
|
||||
setState("reset");
|
||||
}}
|
||||
>
|
||||
{t("passwordResetAlreadyHaveCode")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === "mfa" && (
|
||||
@@ -507,7 +568,7 @@ export default function ResetPasswordForm({
|
||||
mfaForm.reset();
|
||||
}}
|
||||
>
|
||||
{t('passwordBack')}
|
||||
{t("passwordBack")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -521,7 +582,7 @@ export default function ResetPasswordForm({
|
||||
form.reset();
|
||||
}}
|
||||
>
|
||||
{t('backToEmail')}
|
||||
{t("backToEmail")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -71,20 +71,42 @@ function CollapsibleNavItem({
|
||||
build,
|
||||
isUnlocked
|
||||
}: CollapsibleNavItemProps) {
|
||||
const [isOpen, setIsOpen] = React.useState(isChildActive);
|
||||
const storageKey = `pangolin-sidebar-expanded-${item.title}`;
|
||||
|
||||
// Get initial state from localStorage or use isChildActive
|
||||
const getInitialState = (): boolean => {
|
||||
if (typeof window === "undefined") {
|
||||
return isChildActive;
|
||||
}
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
if (saved !== null) {
|
||||
return saved === "true";
|
||||
}
|
||||
return isChildActive;
|
||||
};
|
||||
|
||||
// Update open state when child active state changes
|
||||
const [isOpen, setIsOpen] = React.useState(getInitialState);
|
||||
|
||||
// Update open state when child active state changes (but don't override user preference)
|
||||
React.useEffect(() => {
|
||||
if (isChildActive) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
}, [isChildActive]);
|
||||
|
||||
// Save state to localStorage when it changes
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setIsOpen(open);
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(storageKey, String(open));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
key={item.title}
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
className="group/collapsible"
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
|
||||
Reference in New Issue
Block a user