Merge branch 'dev' into refactor/show-product-updates-conditionnally

This commit is contained in:
Fred KISSIE
2025-12-06 00:55:18 +01:00
29 changed files with 3851 additions and 5263 deletions

View File

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

View File

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

View File

@@ -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}`
}
]
}

View File

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

View File

@@ -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

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

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

View File

@@ -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 ? (

View File

@@ -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`,
{

View File

@@ -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>
);

View File

@@ -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}`,

View File

@@ -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>

View File

@@ -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>