diff --git a/.gitignore b/.gitignore index a4ca8de1..dacf66a1 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ dist .dist installer *.tar +bin diff --git a/Makefile b/Makefile index de182bfe..da0ce742 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -build-all: +build-release: @if [ -z "$(tag)" ]; then \ echo "Error: tag is required. Usage: make build-all tag="; \ exit 1; \ @@ -12,6 +12,9 @@ build-arm: build-x86: docker buildx build --platform linux/amd64 -t fosrl/pangolin:latest . +build-x86-ecr: + docker buildx build --platform linux/amd64 -t 216989133116.dkr.ecr.us-east-1.amazonaws.com/pangolin:latest --push . + build: docker build -t fosrl/pangolin:latest . diff --git a/README.md b/README.md index 759f083b..a1829db4 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,11 @@ # Pangolin +[![Documentation](https://img.shields.io/badge/docs-latest-blue.svg?style=flat-square)](https://docs.fossorial.io/) +[![Docker](https://img.shields.io/docker/pulls/fosrl/pangolin?style=flat-square)](https://hub.docker.com/r/fosrl/pangolin) +![Stars](https://img.shields.io/github/stars/fosrl/pangolin?style=flat-square) +[![Discord](https://img.shields.io/discord/1325658630518865980?logo=discord&style=flat-square)](https://discord.gg/HCJR8Xhme4) +[![Youtube](https://img.shields.io/badge/YouTube-red?logo=youtube&logoColor=white&style=flat-square)](https://www.youtube.com/@fossorial-app) + Pangolin is a self-hosted tunneled reverse proxy management server with identity and access management, designed to securely expose private resources through use with the Traefik reverse proxy and WireGuard tunnel clients like Newt. With Pangolin, you retain full control over your infrastructure while providing a user-friendly and feature-rich solution for managing proxies, authentication, and access, and simplifying complex network setups, all with a clean and simple UI. ### Installation and Documentation diff --git a/config/config.example.yml b/config/config.example.yml index 9311514e..827a2c49 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -21,8 +21,9 @@ traefik: gerbil: start_port: 51820 base_endpoint: localhost - block_size: 16 - subnet_group: 10.0.0.0/8 + block_size: 24 + site_block_size: 30 + subnet_group: 100.89.137.0/20 use_subdomain: true rate_limits: diff --git a/install/Makefile b/install/Makefile index 647bff8b..acc663ae 100644 --- a/install/Makefile +++ b/install/Makefile @@ -1,8 +1,14 @@ all: build -build: - CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o installer +build: + CGO_ENABLED=0 go build -o bin/installer + +release: + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/installer_linux_amd64 + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o bin/installer_linux_arm64 clean: - rm installer \ No newline at end of file + rm bin/installer + rm bin/installer_linux_amd64 + rm bin/installer_linux_arm64 diff --git a/install/fs/config.yml b/install/fs/config.yml index 2ad323f0..21a8c0ff 100644 --- a/install/fs/config.yml +++ b/install/fs/config.yml @@ -1,6 +1,6 @@ app: - dashboard_url: https://{{.Domain}} - base_domain: {{.Domain}} + dashboard_url: https://{{.DashboardDomain}} + base_domain: {{.BaseDomain}} log_level: info save_logs: false @@ -21,10 +21,11 @@ traefik: gerbil: start_port: 51820 - base_endpoint: {{.Domain}} + base_endpoint: {{.DashboardDomain}} use_subdomain: false - block_size: 16 - subnet_group: 10.0.0.0/8 + block_size: 24 + site_block_size: 30 + subnet_group: 100.89.137.0/20 rate_limits: global: diff --git a/install/fs/traefik/dynamic_config.yml b/install/fs/traefik/dynamic_config.yml index cb21242d..770c30ba 100644 --- a/install/fs/traefik/dynamic_config.yml +++ b/install/fs/traefik/dynamic_config.yml @@ -8,7 +8,7 @@ http: routers: # HTTP to HTTPS redirect router main-app-router-redirect: - rule: "Host(`{{.Domain}}`)" + rule: "Host(`{{.DashboardDomain}}`)" service: next-service entryPoints: - web @@ -17,7 +17,7 @@ http: # Next.js router (handles everything except API and WebSocket paths) next-router: - rule: "Host(`{{.Domain}}`) && !PathPrefix(`/api/v1`)" + rule: "Host(`{{.DashboardDomain}}`) && !PathPrefix(`/api/v1`)" service: next-service entryPoints: - websecure @@ -26,7 +26,7 @@ http: # API router (handles /api/v1 paths) api-router: - rule: "Host(`{{.Domain}}`) && PathPrefix(`/api/v1`)" + rule: "Host(`{{.DashboardDomain}}`) && PathPrefix(`/api/v1`)" service: api-service entryPoints: - websecure @@ -35,7 +35,7 @@ http: # WebSocket router ws-router: - rule: "Host(`{{.Domain}}`)" + rule: "Host(`{{.DashboardDomain}}`)" service: api-service entryPoints: - websecure diff --git a/install/main.go b/install/main.go index 897b4741..480ff934 100644 --- a/install/main.go +++ b/install/main.go @@ -18,7 +18,8 @@ import ( var configFiles embed.FS type Config struct { - Domain string `yaml:"domain"` + BaseDomain string `yaml:"baseDomain"` + DashboardDomain string `yaml:"dashboardUrl"` LetsEncryptEmail string `yaml:"letsEncryptEmail"` AdminUserEmail string `yaml:"adminUserEmail"` AdminUserPassword string `yaml:"adminUserPassword"` @@ -44,7 +45,10 @@ func main() { // check if there is already a config file if _, err := os.Stat("config/config.yml"); err != nil { config := collectUserInput(reader) - createConfigFiles(config) + if err := createConfigFiles(config); err != nil { + fmt.Printf("Error creating config files: %v\n", err) + os.Exit(1) + } if !isDockerInstalled() && runtime.GOOS == "linux" { if shouldInstallDocker() { @@ -102,12 +106,13 @@ func collectUserInput(reader *bufio.Reader) Config { // Basic configuration fmt.Println("\n=== Basic Configuration ===") - config.Domain = readString(reader, "Enter your domain name", "") + config.BaseDomain = readString(reader, "Enter your base domain (no subdomain e.g. example.com)", "") + config.DashboardDomain = readString(reader, "Enter the domain for the Pangolin dashboard", "pangolin."+config.BaseDomain) config.LetsEncryptEmail = readString(reader, "Enter email for Let's Encrypt certificates", "") // Admin user configuration fmt.Println("\n=== Admin User Configuration ===") - config.AdminUserEmail = readString(reader, "Enter admin user email", "admin@"+config.Domain) + config.AdminUserEmail = readString(reader, "Enter admin user email", "admin@"+config.BaseDomain) for { config.AdminUserPassword = readString(reader, "Enter admin user password", "") if valid, message := validatePassword(config.AdminUserPassword); valid { @@ -140,10 +145,14 @@ func collectUserInput(reader *bufio.Reader) Config { } // Validate required fields - if config.Domain == "" { + if config.BaseDomain == "" { fmt.Println("Error: Domain name is required") os.Exit(1) } + if config.DashboardDomain == "" { + fmt.Println("Error: Dashboard Domain name is required") + os.Exit(1) + } if config.LetsEncryptEmail == "" { fmt.Println("Error: Let's Encrypt email is required") os.Exit(1) @@ -269,8 +278,26 @@ func createConfigFiles(config Config) error { return fmt.Errorf("error walking config files: %v", err) } - // move the docker-compose.yml file to the root directory - os.Rename("config/docker-compose.yml", "docker-compose.yml") + // get the current directory + dir, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get current directory: %v", err) + } + + sourcePath := filepath.Join(dir, "config/docker-compose.yml") + destPath := filepath.Join(dir, "docker-compose.yml") + + // Check if source file exists + if _, err := os.Stat(sourcePath); err != nil { + return fmt.Errorf("source docker-compose.yml not found: %v", err) + } + + // Try to move the file + err = os.Rename(sourcePath, destPath) + if err != nil { + return fmt.Errorf("failed to move docker-compose.yml from %s to %s: %v", + sourcePath, destPath, err) + } return nil } diff --git a/package.json b/package.json index e05785bf..744b65c9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fosrl/pangolin", - "version": "1.0.0-beta.2", + "version": "1.0.0-beta.3", "private": true, "type": "module", "description": "Tunneled Reverse Proxy Management Server with Identity and Access Control and Dashboard UI", diff --git a/server/lib/config.ts b/server/lib/config.ts index 35540ba7..3da4ea36 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -45,7 +45,8 @@ const environmentSchema = z.object({ base_endpoint: z.string().transform((url) => url.toLowerCase()), use_subdomain: z.boolean(), subnet_group: z.string(), - block_size: z.number().positive().gt(0) + block_size: z.number().positive().gt(0), + site_block_size: z.number().positive().gt(0) }), rate_limits: z.object({ global: z.object({ diff --git a/server/routers/site/pickSiteDefaults.ts b/server/routers/site/pickSiteDefaults.ts index 5a111faa..56d072e0 100644 --- a/server/routers/site/pickSiteDefaults.ts +++ b/server/routers/site/pickSiteDefaults.ts @@ -8,6 +8,7 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { findNextAvailableCidr } from "@server/lib/ip"; import { generateId } from "@server/auth/sessions/app"; +import config from "@server/lib/config"; export type PickSiteDefaultsResponse = { exitNodeId: number; @@ -51,9 +52,9 @@ export async function pickSiteDefaults( // TODO: we need to lock this subnet for some time so someone else does not take it let subnets = sitesQuery.map((site) => site.subnet); - // exclude the exit node address by replacing after the / with a /28 - subnets.push(exitNode.address.replace(/\/\d+$/, "/29")); - const newSubnet = findNextAvailableCidr(subnets, 29, exitNode.address); + // exclude the exit node address by replacing after the / with a site block size + subnets.push(exitNode.address.replace(/\/\d+$/, `/${config.getRawConfig().gerbil.site_block_size}`)); + const newSubnet = findNextAvailableCidr(subnets, config.getRawConfig().gerbil.site_block_size, exitNode.address); if (!newSubnet) { return next( createHttpError( diff --git a/server/routers/user/acceptInvite.ts b/server/routers/user/acceptInvite.ts index b7f225a4..ade89e58 100644 --- a/server/routers/user/acceptInvite.ts +++ b/server/routers/user/acceptInvite.ts @@ -72,6 +72,16 @@ export async function acceptInvite( const { user, session } = await verifySession(req); + // at this point we know the user exists + if (!user) { + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + "You must be logged in to accept an invite" + ) + ); + } + if (user && user.email !== existingInvite.email) { return next( createHttpError( diff --git a/server/setup/migrations.ts b/server/setup/migrations.ts index 7b1ad8ce..c0fe6216 100644 --- a/server/setup/migrations.ts +++ b/server/setup/migrations.ts @@ -8,6 +8,7 @@ import { __DIRNAME } from "@server/lib/consts"; import { loadAppVersion } from "@server/lib/loadAppVersion"; import m1 from "./scripts/1.0.0-beta1"; import m2 from "./scripts/1.0.0-beta2"; +import m3 from "./scripts/1.0.0-beta3"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -15,7 +16,8 @@ import m2 from "./scripts/1.0.0-beta2"; // Define the migration list with versions and their corresponding functions const migrations = [ { version: "1.0.0-beta.1", run: m1 }, - { version: "1.0.0-beta.2", run: m2 } + { version: "1.0.0-beta.2", run: m2 }, + { version: "1.0.0-beta.3", run: m3 } // Add new migrations here as they are created ] as const; diff --git a/server/setup/scripts/1.0.0-beta3.ts b/server/setup/scripts/1.0.0-beta3.ts new file mode 100644 index 00000000..3bbaae81 --- /dev/null +++ b/server/setup/scripts/1.0.0-beta3.ts @@ -0,0 +1,42 @@ +import { configFilePath1, configFilePath2 } from "@server/lib/consts"; +import fs from "fs"; +import yaml from "js-yaml"; + +export default async function migration() { + console.log("Running setup script 1.0.0-beta.3..."); + + // Determine which config file exists + const filePaths = [configFilePath1, configFilePath2]; + let filePath = ""; + for (const path of filePaths) { + if (fs.existsSync(path)) { + filePath = path; + break; + } + } + + if (!filePath) { + throw new Error( + `No config file found (expected config.yml or config.yaml).` + ); + } + + // Read and parse the YAML file + let rawConfig: any; + const fileContents = fs.readFileSync(filePath, "utf8"); + rawConfig = yaml.load(fileContents); + + // Validate the structure + if (!rawConfig.gerbil) { + throw new Error(`Invalid config file: gerbil is missing.`); + } + + // Update the config + rawConfig.gerbil.site_block_size = 29; + + // Write the updated YAML back to the file + const updatedYaml = yaml.dump(rawConfig); + fs.writeFileSync(filePath, updatedYaml, "utf8"); + + console.log("Done."); +} \ No newline at end of file diff --git a/src/app/[orgId]/layout.tsx b/src/app/[orgId]/layout.tsx index b8b5c6a9..fa41beb2 100644 --- a/src/app/[orgId]/layout.tsx +++ b/src/app/[orgId]/layout.tsx @@ -25,7 +25,7 @@ export default async function OrgLayout(props: { const user = await getUser(); if (!user) { - redirect(`/?redirect=/${orgId}`); + redirect(`/`); } try { diff --git a/src/app/[orgId]/settings/general/layout.tsx b/src/app/[orgId]/settings/general/layout.tsx index 5923118e..4b41b8c3 100644 --- a/src/app/[orgId]/settings/general/layout.tsx +++ b/src/app/[orgId]/settings/general/layout.tsx @@ -26,7 +26,7 @@ export default async function GeneralSettingsPage({ const user = await getUser(); if (!user) { - redirect(`/?redirect=/${orgId}/settings/general`); + redirect(`/`); } let orgUser = null; diff --git a/src/app/[orgId]/settings/layout.tsx b/src/app/[orgId]/settings/layout.tsx index 95a6cc00..b0b561a2 100644 --- a/src/app/[orgId]/settings/layout.tsx +++ b/src/app/[orgId]/settings/layout.tsx @@ -61,7 +61,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { const user = await getUser(); if (!user) { - redirect(`/?redirect=/${params.orgId}/`); + redirect(`/`); } const cookie = await authCookieHeader(); diff --git a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx index a6d8821b..30a645f0 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx @@ -62,6 +62,7 @@ import { SettingsSectionFooter } from "@app/components/Settings"; import { SwitchInput } from "@app/components/SwitchInput"; +import { useSiteContext } from "@app/hooks/useSiteContext"; // Regular expressions for validation const DOMAIN_REGEX = @@ -351,7 +352,7 @@ export default function ReverseProxyTargets(props: { }, { accessorKey: "ip", - header: "IP Address", + header: "IP / Hostname", cell: ({ row }) => ( ( - IP Address + IP / Hostname + {site?.type === "newt" ? ( + + This is the IP or hostname + of the target service on + your network. + + ) : site?.type === "wireguard" ? ( + + This is the IP of the + WireGuard peer. + + ) : null} )} /> @@ -551,6 +564,19 @@ export default function ReverseProxyTargets(props: { /> + {site?.type === "newt" ? ( + + This is the port of the + target service on your + network. + + ) : site?.type === "wireguard" ? ( + + This is the port exposed on + an address on the WireGuard + network. + + ) : null} )} /> diff --git a/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx b/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx index fea9ea78..5f5b90fa 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx @@ -2,13 +2,12 @@ import ResourceProvider from "@app/providers/ResourceProvider"; import { internal } from "@app/lib/api"; import { GetResourceAuthInfoResponse, - GetResourceResponse, + GetResourceResponse } from "@server/routers/resource"; import { AxiosResponse } from "axios"; import { redirect } from "next/navigation"; import { authCookieHeader } from "@app/lib/api/cookies"; import { SidebarSettings } from "@app/components/SidebarSettings"; -import { Cloud, Settings, Shield } from "lucide-react"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { GetOrgResponse } from "@server/routers/org"; import OrgProvider from "@app/providers/OrgProvider"; @@ -20,7 +19,7 @@ import { BreadcrumbLink, BreadcrumbList, BreadcrumbPage, - BreadcrumbSeparator, + BreadcrumbSeparator } from "@app/components/ui/breadcrumb"; import Link from "next/link"; @@ -39,7 +38,7 @@ export default async function ResourceLayout(props: ResourceLayoutProps) { try { const res = await internal.get>( `/resource/${params.resourceId}`, - await authCookieHeader(), + await authCookieHeader() ); resource = res.data.data; } catch { @@ -68,8 +67,8 @@ export default async function ResourceLayout(props: ResourceLayoutProps) { const getOrg = cache(async () => internal.get>( `/org/${params.orgId}`, - await authCookieHeader(), - ), + await authCookieHeader() + ) ); const res = await getOrg(); org = res.data.data; @@ -84,19 +83,19 @@ export default async function ResourceLayout(props: ResourceLayoutProps) { const sidebarNavItems = [ { title: "General", - href: `/{orgId}/settings/resources/{resourceId}/general`, + href: `/{orgId}/settings/resources/{resourceId}/general` // icon: , }, { title: "Connectivity", - href: `/{orgId}/settings/resources/{resourceId}/connectivity`, + href: `/{orgId}/settings/resources/{resourceId}/connectivity` // icon: , }, { title: "Authentication", - href: `/{orgId}/settings/resources/{resourceId}/authentication`, + href: `/{orgId}/settings/resources/{resourceId}/authentication` // icon: , - }, + } ]; return ( diff --git a/src/app/auth/layout.tsx b/src/app/auth/layout.tsx index 58af7310..2f0728ea 100644 --- a/src/app/auth/layout.tsx +++ b/src/app/auth/layout.tsx @@ -21,7 +21,7 @@ export default async function AuthLayout({ children }: AuthLayoutProps) { <> {user && ( -
+
diff --git a/src/app/auth/login/DashboardLoginForm.tsx b/src/app/auth/login/DashboardLoginForm.tsx index 088fc631..715a0fb9 100644 --- a/src/app/auth/login/DashboardLoginForm.tsx +++ b/src/app/auth/login/DashboardLoginForm.tsx @@ -13,6 +13,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { useRouter } from "next/navigation"; import { useEffect } from "react"; import Image from "next/image"; +import { cleanRedirect } from "@app/lib/cleanRedirect"; type DashboardLoginFormProps = { redirect?: string; @@ -57,10 +58,9 @@ export default function DashboardLoginForm({ { - if (redirect && redirect.includes("http")) { - window.location.href = redirect; - } else if (redirect) { - router.push(redirect); + if (redirect) { + const safe = cleanRedirect(redirect); + router.push(safe); } else { router.push("/"); } diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index 87c27071..118cfcd0 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -5,6 +5,7 @@ import { cache } from "react"; import DashboardLoginForm from "./DashboardLoginForm"; import { Mail } from "lucide-react"; import { pullEnv } from "@app/lib/pullEnv"; +import { cleanRedirect } from "@app/lib/cleanRedirect"; export const dynamic = "force-dynamic"; @@ -25,6 +26,11 @@ export default async function Page(props: { redirect("/"); } + let redirectUrl: string | undefined = undefined; + if (searchParams.redirect) { + redirectUrl = cleanRedirect(searchParams.redirect as string); + } + return ( <> {isInvite && ( @@ -42,16 +48,16 @@ export default async function Page(props: {
)} - + {(!signUpDisabled || isInvite) && (

Don't have an account?{" "} diff --git a/src/app/auth/reset-password/ResetPasswordForm.tsx b/src/app/auth/reset-password/ResetPasswordForm.tsx index a9232d4d..ae997818 100644 --- a/src/app/auth/reset-password/ResetPasswordForm.tsx +++ b/src/app/auth/reset-password/ResetPasswordForm.tsx @@ -43,6 +43,7 @@ import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp"; import { passwordSchema } from "@server/auth/passwordSchema"; +import { cleanRedirect } from "@app/lib/cleanRedirect"; const requestSchema = z.object({ email: z.string().email() @@ -186,11 +187,9 @@ export default function ResetPasswordForm({ setSuccessMessage("Password reset successfully! Back to login..."); setTimeout(() => { - if (redirect && redirect.includes("http")) { - window.location.href = redirect; - } if (redirect) { - router.push(redirect); + const safe = cleanRedirect(redirect); + router.push(safe); } else { router.push("/login"); } diff --git a/src/app/auth/reset-password/page.tsx b/src/app/auth/reset-password/page.tsx index b5636c42..73654beb 100644 --- a/src/app/auth/reset-password/page.tsx +++ b/src/app/auth/reset-password/page.tsx @@ -3,6 +3,7 @@ import { redirect } from "next/navigation"; import { cache } from "react"; import ResetPasswordForm from "./ResetPasswordForm"; import Link from "next/link"; +import { cleanRedirect } from "@app/lib/cleanRedirect"; export const dynamic = "force-dynamic"; @@ -21,6 +22,11 @@ export default async function Page(props: { redirect("/"); } + let redirectUrl: string | undefined = undefined; + if (searchParams.redirect) { + redirectUrl = cleanRedirect(searchParams.redirect); + } + return ( <> diff --git a/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx b/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx index a25edf74..c23403a8 100644 --- a/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx +++ b/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx @@ -481,11 +481,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { className={`${numMethods <= 1 ? "mt-0" : ""}`} > await handleSSOAuth() } diff --git a/src/app/auth/resource/[resourceId]/page.tsx b/src/app/auth/resource/[resourceId]/page.tsx index 886801c7..2cf37848 100644 --- a/src/app/auth/resource/[resourceId]/page.tsx +++ b/src/app/auth/resource/[resourceId]/page.tsx @@ -55,7 +55,17 @@ export default async function ResourceAuthPage(props: { ); } - const redirectUrl = searchParams.redirect || authInfo.url; + let redirectUrl = authInfo.url; + if (searchParams.redirect) { + try { + const serverResourceHost = new URL(authInfo.url).host; + const redirectHost = new URL(searchParams.redirect).host; + + if (serverResourceHost === redirectHost) { + redirectUrl = searchParams.redirect; + } + } catch (e) {} + } const hasAuth = authInfo.password || diff --git a/src/app/auth/signup/SignupForm.tsx b/src/app/auth/signup/SignupForm.tsx index 9630d907..f839284e 100644 --- a/src/app/auth/signup/SignupForm.tsx +++ b/src/app/auth/signup/SignupForm.tsx @@ -30,6 +30,7 @@ import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import Image from "next/image"; +import { cleanRedirect } from "@app/lib/cleanRedirect"; type SignupFormProps = { redirect?: string; @@ -92,17 +93,17 @@ export default function SignupForm({ if (res.data?.data?.emailVerificationRequired) { if (redirect) { - router.push(`/auth/verify-email?redirect=${redirect}`); + const safe = cleanRedirect(redirect); + router.push(`/auth/verify-email?redirect=${safe}`); } else { router.push("/auth/verify-email"); } return; } - if (redirect && redirect.includes("http")) { - window.location.href = redirect; - } else if (redirect) { - router.push(redirect); + if (redirect) { + const safe = cleanRedirect(redirect); + router.push(safe); } else { router.push("/"); } diff --git a/src/app/auth/signup/page.tsx b/src/app/auth/signup/page.tsx index f53ff2c8..361cc0db 100644 --- a/src/app/auth/signup/page.tsx +++ b/src/app/auth/signup/page.tsx @@ -1,5 +1,6 @@ import SignupForm from "@app/app/auth/signup/SignupForm"; import { verifySession } from "@app/lib/auth/verifySession"; +import { cleanRedirect } from "@app/lib/cleanRedirect"; import { pullEnv } from "@app/lib/pullEnv"; import { Mail } from "lucide-react"; import Link from "next/link"; @@ -41,6 +42,11 @@ export default async function Page(props: { } } + let redirectUrl: string | undefined; + if (searchParams.redirect) { + redirectUrl = cleanRedirect(searchParams.redirect); + } + return ( <> {isInvite && ( @@ -59,7 +65,7 @@ export default async function Page(props: { )} @@ -68,9 +74,9 @@ export default async function Page(props: { Already have an account?{" "} diff --git a/src/app/auth/verify-email/VerifyEmailForm.tsx b/src/app/auth/verify-email/VerifyEmailForm.tsx index 7a6bc082..8a0ca89a 100644 --- a/src/app/auth/verify-email/VerifyEmailForm.tsx +++ b/src/app/auth/verify-email/VerifyEmailForm.tsx @@ -36,6 +36,7 @@ import { useRouter } from "next/navigation"; import { formatAxiosError } from "@app/lib/api";; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; +import { cleanRedirect } from "@app/lib/cleanRedirect"; const FormSchema = z.object({ email: z.string().email({ message: "Invalid email address" }), @@ -91,11 +92,9 @@ export default function VerifyEmailForm({ "Email successfully verified! Redirecting you..." ); setTimeout(() => { - if (redirect && redirect.includes("http")) { - window.location.href = redirect; - } if (redirect) { - router.push(redirect); + const safe = cleanRedirect(redirect); + router.push(safe); } else { router.push("/"); } diff --git a/src/app/auth/verify-email/page.tsx b/src/app/auth/verify-email/page.tsx index 3452df69..033fa75d 100644 --- a/src/app/auth/verify-email/page.tsx +++ b/src/app/auth/verify-email/page.tsx @@ -1,5 +1,6 @@ import VerifyEmailForm from "@app/app/auth/verify-email/VerifyEmailForm"; import { verifySession } from "@app/lib/auth/verifySession"; +import { cleanRedirect } from "@app/lib/cleanRedirect"; import { pullEnv } from "@app/lib/pullEnv"; import { redirect } from "next/navigation"; import { cache } from "react"; @@ -27,11 +28,16 @@ export default async function Page(props: { redirect("/"); } + let redirectUrl: string | undefined; + if (searchParams.redirect) { + redirectUrl = cleanRedirect(searchParams.redirect as string); + } + return ( <> ); diff --git a/src/app/invite/InviteStatusCard.tsx b/src/app/invite/InviteStatusCard.tsx index 8957fd3e..313bee66 100644 --- a/src/app/invite/InviteStatusCard.tsx +++ b/src/app/invite/InviteStatusCard.tsx @@ -14,7 +14,7 @@ import { XCircle } from "lucide-react"; import { useRouter } from "next/navigation"; type InviteStatusCardProps = { - type: "rejected" | "wrong_user" | "user_does_not_exist"; + type: "rejected" | "wrong_user" | "user_does_not_exist" | "not_logged_in"; token: string; }; diff --git a/src/app/invite/page.tsx b/src/app/invite/page.tsx index 009fe792..b105c0b1 100644 --- a/src/app/invite/page.tsx +++ b/src/app/invite/page.tsx @@ -60,6 +60,8 @@ export default async function InvitePage(props: { ) ) { return "user_does_not_exist"; + } else if (error.includes("You must be logged in to accept an invite")) { + return "not_logged_in"; } else { return "rejected"; } @@ -71,6 +73,10 @@ export default async function InvitePage(props: { redirect(`/auth/signup?redirect=/invite?token=${params.token}`); } + if (!user && type === "not_logged_in") { + redirect(`/auth/login?redirect=/invite?token=${params.token}`); + } + return ( <> diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 16ce9963..02a9daba 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -6,6 +6,8 @@ import { ThemeProvider } from "@app/providers/ThemeProvider"; import EnvProvider from "@app/providers/EnvProvider"; import { Separator } from "@app/components/ui/separator"; import { pullEnv } from "@app/lib/pullEnv"; +import { BookOpenText } from "lucide-react"; +import Image from "next/image"; export const metadata: Metadata = { title: `Dashboard - Pangolin`, @@ -38,10 +40,10 @@ export default async function RootLayout({

{children}
{/* Footer */} -