+
{children}
diff --git a/src/app/[orgId]/settings/sites/create/page.tsx b/src/app/[orgId]/settings/sites/create/page.tsx
index 194a2822c..cb6365b79 100644
--- a/src/app/[orgId]/settings/sites/create/page.tsx
+++ b/src/app/[orgId]/settings/sites/create/page.tsx
@@ -63,7 +63,6 @@ import { QRCodeCanvas } from "qrcode.react";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
import { NewtSiteInstallCommands } from "@app/components/newt-install-commands";
-import { id } from "date-fns/locale";
type SiteType = "newt" | "wireguard" | "local";
diff --git a/src/app/auth/delete-account/DeleteAccountClient.tsx b/src/app/auth/delete-account/DeleteAccountClient.tsx
new file mode 100644
index 000000000..8cd150afe
--- /dev/null
+++ b/src/app/auth/delete-account/DeleteAccountClient.tsx
@@ -0,0 +1,74 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+import { Button } from "@app/components/ui/button";
+import DeleteAccountConfirmDialog from "@app/components/DeleteAccountConfirmDialog";
+import UserProfileCard from "@app/components/UserProfileCard";
+import { ArrowLeft } from "lucide-react";
+import { createApiClient } from "@app/lib/api";
+import { useEnvContext } from "@app/hooks/useEnvContext";
+import { toast } from "@app/hooks/useToast";
+import { formatAxiosError } from "@app/lib/api";
+
+type DeleteAccountClientProps = {
+ displayName: string;
+};
+
+export default function DeleteAccountClient({
+ displayName
+}: DeleteAccountClientProps) {
+ const router = useRouter();
+ const t = useTranslations();
+ const { env } = useEnvContext();
+ const api = createApiClient({ env });
+ const [isDialogOpen, setIsDialogOpen] = useState(false);
+
+ function handleUseDifferentAccount() {
+ api.post("/auth/logout")
+ .catch((e) => {
+ console.error(t("logoutError"), e);
+ toast({
+ title: t("logoutError"),
+ description: formatAxiosError(e, t("logoutError"))
+ });
+ })
+ .then(() => {
+ router.push(
+ "/auth/login?internal_redirect=/auth/delete-account"
+ );
+ router.refresh();
+ });
+ }
+
+ return (
+
+
+
+ {t("deleteAccountDescription")}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/auth/delete-account/page.tsx b/src/app/auth/delete-account/page.tsx
new file mode 100644
index 000000000..5cbc8d738
--- /dev/null
+++ b/src/app/auth/delete-account/page.tsx
@@ -0,0 +1,28 @@
+import { verifySession } from "@app/lib/auth/verifySession";
+import { redirect } from "next/navigation";
+import { build } from "@server/build";
+import { cache } from "react";
+import DeleteAccountClient from "./DeleteAccountClient";
+import { getTranslations } from "next-intl/server";
+import { getUserDisplayName } from "@app/lib/getUserDisplayName";
+
+export const dynamic = "force-dynamic";
+
+export default async function DeleteAccountPage() {
+ const getUser = cache(verifySession);
+ const user = await getUser({ skipCheckVerifyEmail: true });
+
+ if (!user) {
+ redirect("/auth/login");
+ }
+
+ const t = await getTranslations();
+ const displayName = getUserDisplayName({ user });
+
+ return (
+
+
{t("deleteAccount")}
+
+
+ );
+}
diff --git a/src/app/auth/login/device/page.tsx b/src/app/auth/login/device/page.tsx
index 7d2ed4e30..01c23c999 100644
--- a/src/app/auth/login/device/page.tsx
+++ b/src/app/auth/login/device/page.tsx
@@ -3,11 +3,12 @@ import { redirect } from "next/navigation";
import DeviceLoginForm from "@/components/DeviceLoginForm";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { cache } from "react";
+import { cleanRedirect } from "@app/lib/cleanRedirect";
export const dynamic = "force-dynamic";
type Props = {
- searchParams: Promise<{ code?: string; user?: string }>;
+ searchParams: Promise<{ code?: string; user?: string; authPath?: string }>;
};
function deviceRedirectSearchParams(params: {
@@ -30,11 +31,11 @@ export default async function DeviceLoginPage({ searchParams }: Props) {
if (!user) {
const redirectDestination = `/auth/login/device${deviceRedirectSearchParams({ code, user: params.user })}`;
- const loginUrl = new URL("/auth/login", "http://x");
+ const authPath = cleanRedirect(params.authPath || "/auth/login");
+ const loginUrl = new URL(authPath, "http://x");
loginUrl.searchParams.set("forceLogin", "true");
loginUrl.searchParams.set("redirect", redirectDestination);
if (defaultUser) loginUrl.searchParams.set("user", defaultUser);
- console.log("loginUrl", loginUrl.pathname + loginUrl.search);
redirect(loginUrl.pathname + loginUrl.search);
}
diff --git a/src/app/auth/signup/page.tsx b/src/app/auth/signup/page.tsx
index 9ab7b7e61..f51ac9049 100644
--- a/src/app/auth/signup/page.tsx
+++ b/src/app/auth/signup/page.tsx
@@ -15,6 +15,7 @@ export default async function Page(props: {
redirect: string | undefined;
email: string | undefined;
fromSmartLogin: string | undefined;
+ skipVerificationEmail: string | undefined;
}>;
}) {
const searchParams = await props.searchParams;
@@ -75,6 +76,10 @@ export default async function Page(props: {
inviteId={inviteId}
emailParam={searchParams.email}
fromSmartLogin={searchParams.fromSmartLogin === "true"}
+ skipVerificationEmail={
+ searchParams.skipVerificationEmail === "true" ||
+ searchParams.skipVerificationEmail === "1"
+ }
/>
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 0844eb624..0db1b49bf 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,6 +1,5 @@
import type { Metadata } from "next";
import "./globals.css";
-import { Geist, Inter, Manrope, Open_Sans } from "next/font/google";
import { ThemeProvider } from "@app/providers/ThemeProvider";
import EnvProvider from "@app/providers/EnvProvider";
import { pullEnv } from "@app/lib/pullEnv";
@@ -24,6 +23,7 @@ import { TanstackQueryProvider } from "@app/components/TanstackQueryProvider";
import { TailwindIndicator } from "@app/components/TailwindIndicator";
import { ViewportHeightFix } from "@app/components/ViewportHeightFix";
import StoreInternalRedirect from "@app/components/StoreInternalRedirect";
+import { Inter } from "next/font/google";
export const metadata: Metadata = {
title: `Dashboard - ${process.env.BRANDING_APP_NAME || "Pangolin"}`,
@@ -32,10 +32,12 @@ export const metadata: Metadata = {
export const dynamic = "force-dynamic";
-const font = Inter({
+const inter = Inter({
subsets: ["latin"]
});
+const fontClassName = inter.className;
+
export default async function RootLayout({
children
}: Readonly<{
@@ -79,16 +81,16 @@ export default async function RootLayout({
return (
-
+
- {build === "saas" && (
+ {/* build === "saas" && (
- )}
+ )*/}
- {process.env.NODE_ENV === "development" && (
+ {/*process.env.NODE_ENV === "development" && (
- )}
+ )*/}
);
diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx
index 324f051c3..afff6ed2c 100644
--- a/src/app/navigation.tsx
+++ b/src/app/navigation.tsx
@@ -2,6 +2,7 @@ import { SidebarNavItem } from "@app/components/SidebarNav";
import { Env } from "@app/lib/types/env";
import { build } from "@server/build";
import {
+ Building2,
ChartLine,
Combine,
CreditCard,
@@ -12,10 +13,11 @@ import {
KeyRound,
Laptop,
Link as LinkIcon,
- Logs, // Added from 'dev' branch
+ Logs,
MonitorUp,
+ Plug,
ReceiptText,
- ScanEye, // Added from 'dev' branch
+ ScanEye,
Server,
Settings,
ShieldIcon,
@@ -33,6 +35,10 @@ export type SidebarNavSection = {
items: SidebarNavItem[];
};
+export type OrgNavSectionsOptions = {
+ isPrimaryOrg?: boolean;
+};
+
// Merged from 'user-management-and-resources' branch
export const orgLangingNavItems: SidebarNavItem[] = [
{
@@ -42,14 +48,17 @@ export const orgLangingNavItems: SidebarNavItem[] = [
}
];
-export const orgNavSections = (env?: Env): SidebarNavSection[] => [
+export const orgNavSections = (
+ env?: Env,
+ options?: OrgNavSectionsOptions
+): SidebarNavSection[] => [
{
- heading: "sidebarGeneral",
+ heading: "network",
items: [
{
title: "sidebarSites",
href: "/{orgId}/settings/sites",
- icon:
+ icon:
},
{
title: "sidebarResources",
@@ -100,17 +109,22 @@ export const orgNavSections = (env?: Env): SidebarNavSection[] => [
]
},
{
- heading: "access",
+ heading: "accessControl",
items: [
{
- title: "sidebarUsers",
- icon:
,
+ title: "sidebarTeam",
+ icon:
,
items: [
{
title: "sidebarUsers",
href: "/{orgId}/settings/access/users",
icon:
},
+ {
+ title: "sidebarRoles",
+ href: "/{orgId}/settings/access/roles",
+ icon:
+ },
{
title: "sidebarInvitations",
href: "/{orgId}/settings/access/invitations",
@@ -118,11 +132,6 @@ export const orgNavSections = (env?: Env): SidebarNavSection[] => [
}
]
},
- {
- title: "sidebarRoles",
- href: "/{orgId}/settings/access/roles",
- icon:
- },
...(build !== "oss"
? [
{
@@ -170,90 +179,86 @@ export const orgNavSections = (env?: Env): SidebarNavSection[] => [
}
]
},
- {
- heading: "sidebarLogsAndAnalytics",
- items: (() => {
- const logItems: SidebarNavItem[] = [
- {
- title: "sidebarLogsRequest",
- href: "/{orgId}/settings/logs/request",
- icon:
- },
- ...(!env?.flags.disableEnterpriseFeatures
- ? [
- {
- title: "sidebarLogsAccess",
- href: "/{orgId}/settings/logs/access",
- icon:
- },
- {
- title: "sidebarLogsAction",
- href: "/{orgId}/settings/logs/action",
- icon:
- }
- ]
- : [])
- ];
-
- const analytics = {
- title: "sidebarLogsAnalytics",
- href: "/{orgId}/settings/logs/analytics",
- icon:
- };
-
- // If only one log item, return it directly without grouping
- if (logItems.length === 1) {
- return [analytics, ...logItems];
- }
-
- // If multiple log items, create a group
- return [
- analytics,
- {
- title: "sidebarLogs",
- icon:
,
- items: logItems
- }
- ];
- })()
- },
{
heading: "sidebarOrganization",
items: [
{
- title: "sidebarApiKeys",
- href: "/{orgId}/settings/api-keys",
- icon:
+ title: "sidebarLogsAndAnalytics",
+ icon:
,
+ items: [
+ {
+ title: "sidebarLogsAnalytics",
+ href: "/{orgId}/settings/logs/analytics",
+ icon:
+ },
+ {
+ title: "sidebarLogsRequest",
+ href: "/{orgId}/settings/logs/request",
+ icon: (
+
+ )
+ },
+ ...(!env?.flags.disableEnterpriseFeatures
+ ? [
+ {
+ title: "sidebarLogsAccess",
+ href: "/{orgId}/settings/logs/access",
+ icon:
+ },
+ {
+ title: "sidebarLogsAction",
+ href: "/{orgId}/settings/logs/action",
+ icon:
+ }
+ ]
+ : [])
+ ]
},
{
- title: "sidebarBluePrints",
- href: "/{orgId}/settings/blueprints",
- icon:
+ title: "sidebarManagement",
+ icon:
,
+ items: [
+ {
+ title: "sidebarApiKeys",
+ href: "/{orgId}/settings/api-keys",
+ icon:
+ },
+ {
+ title: "sidebarBluePrints",
+ href: "/{orgId}/settings/blueprints",
+ icon:
+ }
+ ]
},
+ ...(build == "saas" && options?.isPrimaryOrg
+ ? [
+ {
+ title: "sidebarBillingAndLicenses",
+ icon:
,
+ items: [
+ {
+ title: "sidebarBilling",
+ href: "/{orgId}/settings/billing",
+ icon: (
+
+ )
+ },
+ {
+ title: "sidebarEnterpriseLicenses",
+ href: "/{orgId}/settings/license",
+ icon: (
+
+ )
+ }
+ ]
+ }
+ ]
+ : []),
{
title: "sidebarSettings",
href: "/{orgId}/settings/general",
icon:
- },
-
- ...(build == "saas"
- ? [
- {
- title: "sidebarBilling",
- href: "/{orgId}/settings/billing",
- icon:
- }
- ]
- : []),
- ...(build == "saas"
- ? [
- {
- title: "sidebarEnterpriseLicenses",
- href: "/{orgId}/settings/license",
- icon:
- }
- ]
- : [])
+ }
]
}
];
diff --git a/src/app/page.tsx b/src/app/page.tsx
index df1a81df1..f6f30276a 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -73,7 +73,7 @@ export default async function Page(props: {
if (!orgs.length) {
if (!env.flags.disableUserCreateOrg || user.serverAdmin) {
- redirect("/setup");
+ redirect("/setup?firstOrg");
}
}
@@ -86,6 +86,14 @@ export default async function Page(props: {
targetOrgId = lastOrgCookie;
} else {
let ownedOrg = orgs.find((org) => org.isOwner);
+ let primaryOrg = orgs.find((org) => org.isPrimaryOrg);
+ if (!ownedOrg) {
+ if (primaryOrg) {
+ ownedOrg = primaryOrg;
+ } else {
+ ownedOrg = orgs[0];
+ }
+ }
if (!ownedOrg) {
ownedOrg = orgs[0];
}
diff --git a/src/app/setup/page.tsx b/src/app/setup/page.tsx
index c8b2af191..8eaf449ad 100644
--- a/src/app/setup/page.tsx
+++ b/src/app/setup/page.tsx
@@ -4,19 +4,14 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { toast } from "@app/hooks/useToast";
import { useCallback, useEffect, useState } from "react";
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle
-} from "@app/components/ui/card";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
+import { useUserContext } from "@app/hooks/useUserContext";
+import { build } from "@server/build";
import { Separator } from "@/components/ui/separator";
import { z } from "zod";
-import { useRouter } from "next/navigation";
+import { useRouter, useSearchParams } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
@@ -35,7 +30,7 @@ import {
CollapsibleContent,
CollapsibleTrigger
} from "@app/components/ui/collapsible";
-import { ChevronsUpDown } from "lucide-react";
+import { ArrowRight, ChevronsUpDown } from "lucide-react";
import { cn } from "@app/lib/cn";
type Step = "org" | "site" | "resources";
@@ -45,6 +40,7 @@ export default function StepperForm() {
const [orgIdTaken, setOrgIdTaken] = useState(false);
const t = useTranslations();
const { env } = useEnvContext();
+ const { user } = useUserContext();
const [loading, setLoading] = useState(false);
const [isChecked, setIsChecked] = useState(false);
@@ -54,7 +50,10 @@ export default function StepperForm() {
const orgSchema = z.object({
orgName: z.string().min(1, { message: t("orgNameRequired") }),
- orgId: z.string().min(1, { message: t("orgIdRequired") }),
+ orgId: z
+ .string()
+ .min(1, { message: t("orgIdRequired") })
+ .max(32, { message: t("orgIdMaxLength") }),
subnet: z.string().min(1, { message: t("subnetRequired") }),
utilitySubnet: z.string().min(1, { message: t("subnetRequired") })
});
@@ -71,12 +70,27 @@ export default function StepperForm() {
const api = createApiClient(useEnvContext());
const router = useRouter();
+ const searchParams = useSearchParams();
+ const isFirstOrg = searchParams.get("firstOrg") != null;
// Fetch default subnet on component mount
useEffect(() => {
fetchDefaultSubnet();
}, []);
+ // Prefill org name and id when build is saas and firstOrg query param is set
+ useEffect(() => {
+ if (build !== "saas" || !user || !isFirstOrg) return;
+
+ const orgName = user.email
+ ? `${user.email}'s Organization`
+ : "My Organization";
+ const orgId = `org_${user.userId}`;
+ orgForm.setValue("orgName", orgName);
+ orgForm.setValue("orgId", orgId);
+ debouncedCheckOrgIdAvailability(orgId);
+ }, []);
+
const fetchDefaultSubnet = async () => {
try {
const res = await api.get(`/pick-org-defaults`);
@@ -129,6 +143,15 @@ export default function StepperForm() {
.replace(/^-+|-+$/g, "");
};
+ const sanitizeOrgId = (value: string) => {
+ return value
+ .toLowerCase()
+ .replace(/\s+/g, "-")
+ .replace(/[^a-z0-9_-]/g, "")
+ .replace(/-+/g, "-")
+ .slice(0, 32);
+ };
+
async function orgSubmit(values: z.infer
) {
if (orgIdTaken) {
return;
@@ -161,263 +184,254 @@ export default function StepperForm() {
}
return (
- <>
-
-
- {t("setupNewOrg")}
- {t("setupCreate")}
-
-
-
-
-
-
- 1
-
-
- {t("setupCreateOrg")}
-
-
-
-
- 2
-
-
- {t("siteCreate")}
-
-
-
-
- 3
-
-
- {t("setupCreateResources")}
-
-
-
+
+
+
+ {t("setupNewOrg")}
+
+
+ {t("setupCreate")}
+
+
+
+
+
+ 1
+
+
+ {t("setupCreateOrg")}
+
+
+
+
+ 2
+
+
+ {t("siteCreate")}
+
+
+
+
+ 3
+
+
+ {t("setupCreateResources")}
+
+
+
-
+
- {currentStep === "org" && (
-