Compare commits

..

12 Commits

Author SHA1 Message Date
Milo Schwartz d4138e2141 Merge pull request #3421 from fosrl/dev
1.20.0
2026-07-09 10:40:35 -04:00
Milo Schwartz 09d5d9082e Merge pull request #3413 from fosrl/dev
1.20.0
2026-07-08 15:30:03 -04:00
Owen Schwartz 5cb316f4e9 Merge pull request #3404 from fosrl/dev
Add ? to support undefined
2026-07-07 10:59:56 -04:00
Owen Schwartz 6b6c9cf4d8 Merge pull request #3403 from fosrl/dev
Move jit mode to a config param
2026-07-07 10:41:59 -04:00
Owen Schwartz 05617c63c0 Merge pull request #3402 from fosrl/dev
1.19.4-s.5
2026-07-07 10:21:57 -04:00
Owen Schwartz d4c52bbf2f Merge pull request #3396 from fosrl/dev
1.19.4-s.5
2026-07-03 16:15:18 -04:00
Owen Schwartz 87f50bf0cc Merge pull request #3389 from fosrl/dev
1.19.4-s.3
2026-07-03 10:29:15 -04:00
Owen Schwartz 2c66da1b19 Merge pull request #3386 from v1rusnl/main
Upgrade Traefik image to version 3.7
2026-07-03 10:18:44 -04:00
v1rusnl ab19955502 Upgrade Traefik image to version 3.7 2026-07-03 08:22:13 +02:00
v1rusnl 1db9dcec81 Update Traefik image version to v3.7 2026-07-03 08:21:12 +02:00
Owen Schwartz 49c2d3163e Merge pull request #3381 from fosrl/dev
dev
2026-07-02 10:56:39 -04:00
Owen Schwartz 45b9e13a13 Merge pull request #3378 from fosrl/dev
1.19.4-s.1
2026-07-01 21:48:01 -04:00
19 changed files with 79 additions and 296 deletions
+3 -39
View File
@@ -41,7 +41,7 @@
</strong> </strong>
</p> </p>
Pangolin is an open-source, identity-based remote access platform built on WireGuard® that enables secure connectivity to infrastructure anywhere. It combines reverse-proxy and VPN capabilities into one platform, providing browser-based access to web applications and client-based access to private resources with NAT traversal, all with granular access control. Pangolin is an open-source, identity-based remote access platform built on WireGuard® that enables secure, seamless connectivity to private and public resources. Pangolin combines reverse proxy and VPN capabilities into one platform, providing browser-based access to web applications and client-based access to any private resources with NAT traversal, all with granular access controls.
## Installation ## Installation
@@ -63,26 +63,11 @@ Pangolin is an open-source, identity-based remote access platform built on WireG
Pangolin's site connectors provide gateways into networks so you can access any networked resources. Sites use outbound tunnels and intelligent NAT traversal to make networks behind restrictive firewalls available for authorized access without public IPs or open ports. Easily deploy a site as a binary or container on any platform. Pangolin's site connectors provide gateways into networks so you can access any networked resources. Sites use outbound tunnels and intelligent NAT traversal to make networks behind restrictive firewalls available for authorized access without public IPs or open ports. Easily deploy a site as a binary or container on any platform.
* Lightweight user-space connector runs anywhere
* Punches through any firewall
* Doesn't require open ports or a public IP
* Strict network segmentation
* WireGuard-based
* Get alerts when a device or network resource goes down
<img src="public/screenshots/sites.png" alt="Sites" width="100%" /> <img src="public/screenshots/sites.png" alt="Sites" width="100%" />
### Browser-based reverse proxy access ### Browser-based reverse proxy access
Expose HTTPS web applications and connect to VNC, RDP, and SSH entirely in the browser through identity and context-aware tunneled reverse proxies. Users access resources with authentication and granular access control without installing a client. Pangolin handles routing, load balancing, health checking, and automatic SSL certificates without exposing your network directly to the internet. Expose web applications through identity and context-aware tunneled reverse proxies. Users access applications through any web browser with authentication and granular access control without installing a client. Pangolin handles routing, load balancing, health checking, and automatic SSL certificates without exposing your network directly to the internet.
* Expose a web panel anywhere
* Access via any web browser
* Single sign-on across all resources
* HTTPS resources
* Remote desktop in the browser with VNC and RDP
* In-browser SSH terminal with privileged access management (PAM)
* PIN codes, passcodes, email OTP, geoblocking, allow-lists, and more
<img src="public/clip.gif" alt="Reverse proxy access" width="100%" /> <img src="public/clip.gif" alt="Reverse proxy access" width="100%" />
@@ -90,35 +75,14 @@ Expose HTTPS web applications and connect to VNC, RDP, and SSH entirely in the b
Access private resources like SSH servers, databases, RDP, and entire network ranges through Pangolin clients. Intelligent NAT traversal enables connections even through restrictive firewalls, while DNS aliases provide friendly names and fast connections to resources across all your sites. Add redundancy by routing traffic through multiple connectors in your network. Access private resources like SSH servers, databases, RDP, and entire network ranges through Pangolin clients. Intelligent NAT traversal enables connections even through restrictive firewalls, while DNS aliases provide friendly names and fast connections to resources across all your sites. Add redundancy by routing traffic through multiple connectors in your network.
* Peer-to-peer with intelligent NAT traversal
* Hosts/IPs and port ranges
* Network ranges/CIDRs
* Friendly DNS aliases for network addresses
* Privileged access management (PAM) with SSH resources
* Private HTTPS resources only accessible on the private network
<img src="public/screenshots/private-resources.png" alt="Private resources" width="100%" /> <img src="public/screenshots/private-resources.png" alt="Private resources" width="100%" />
### Give users and roles access to resources ### Give users and roles access to resources
Use Pangolin's built-in users or bring your own identity provider and set up role-based access control (RBAC). Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications, services, and routes you explicitly define. Use Pangolin's built in users or bring your own identity provider and set up role based access control (RBAC). Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications, services, and routes you explicitly define.
* Bring your existing identity provider (IdP) or use Pangolin identities
* Sync users and roles from your IdP
* User- and role-based access control
* Full network audit and access logs
<img src="public/screenshots/users.png" alt="Users from identity provider with roles" width="100%" /> <img src="public/screenshots/users.png" alt="Users from identity provider with roles" width="100%" />
### Find and launch resources from a personalized home page
Give users a landing page to quickly find and open the resources they can access. Resources are grouped by site or label, searchable, and filterable, with grid or list views. Saved views capture filters, grouping, and layout as personal or organization-wide defaults.
* Single place for admins and non-admins to see accessible resources
* Create reusable views for common access patterns
<img src="public/screenshots/resource-launcher.png" alt="Resource Launcher" width="100%" />
## Download Clients ## Download Clients
Download the Pangolin client for your platform: Download the Pangolin client for your platform:
+1 -1
View File
@@ -41,7 +41,7 @@ services:
- 80:80 # Port for traefik because of the network_mode - 80:80 # Port for traefik because of the network_mode
traefik: traefik:
image: traefik:v3.6 image: traefik:v3.7
container_name: traefik container_name: traefik
restart: unless-stopped restart: unless-stopped
network_mode: service:gerbil # Ports appear on the gerbil service network_mode: service:gerbil # Ports appear on the gerbil service
+1 -1
View File
@@ -50,7 +50,7 @@ services:
- 80:80{{end}} - 80:80{{end}}
traefik: traefik:
image: docker.io/traefik:v3.6 image: docker.io/traefik:v3.7
container_name: traefik container_name: traefik
restart: unless-stopped restart: unless-stopped
{{if .InstallGerbil}}network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}} {{if .InstallGerbil}}network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}}
-2
View File
@@ -1397,7 +1397,6 @@
"createOrgUser": "Create Org User", "createOrgUser": "Create Org User",
"actionUpdateOrg": "Update Organization", "actionUpdateOrg": "Update Organization",
"actionRemoveInvitation": "Remove Invitation", "actionRemoveInvitation": "Remove Invitation",
"actionRemoveUserRole": "Remove User Role",
"actionUpdateUser": "Update User", "actionUpdateUser": "Update User",
"actionGetUser": "Get User", "actionGetUser": "Get User",
"actionGetOrgUser": "Get Organization User", "actionGetOrgUser": "Get Organization User",
@@ -1517,7 +1516,6 @@
"otpAuthDescription": "Enter the code from your authenticator app or one of your single-use backup codes.", "otpAuthDescription": "Enter the code from your authenticator app or one of your single-use backup codes.",
"otpAuthSubmit": "Submit Code", "otpAuthSubmit": "Submit Code",
"idpContinue": "Or continue with", "idpContinue": "Or continue with",
"idpLastUsed": "Last Used",
"otpAuthBack": "Back to Password", "otpAuthBack": "Back to Password",
"navbar": "Navigation Menu", "navbar": "Navigation Menu",
"navbarDescription": "Main navigation menu for the application", "navbarDescription": "Main navigation menu for the application",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 556 KiB

+7 -40
View File
@@ -16,11 +16,8 @@ import LoginCardHeader from "@app/components/LoginCardHeader";
import { priv } from "@app/lib/api"; import { priv } from "@app/lib/api";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { LoginFormIDP } from "@app/components/LoginForm"; import { LoginFormIDP } from "@app/components/LoginForm";
import { ListIdpsResponse, type GetIdpResponse } from "@server/routers/idp"; import { ListIdpsResponse } from "@server/routers/idp";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { cookies } from "next/headers";
import { LAST_USED_IDP_COOKIE_NAME } from "@app/lib/consts";
import z from "zod";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Log In" title: "Log In"
@@ -32,9 +29,8 @@ export default async function Page(props: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) { }) {
const searchParams = await props.searchParams; const searchParams = await props.searchParams;
const user = await verifySession({ skipCheckVerifyEmail: true }); const getUser = cache(verifySession);
const user = await getUser({ skipCheckVerifyEmail: true });
const lastUsedIdpCookie = (await cookies()).get(LAST_USED_IDP_COOKIE_NAME);
const isInvite = searchParams?.redirect?.includes("/invite"); const isInvite = searchParams?.redirect?.includes("/invite");
const forceLoginParam = searchParams?.forceLogin; const forceLoginParam = searchParams?.forceLogin;
@@ -89,47 +85,19 @@ export default async function Page(props: {
(build === "enterprise" && env.app.identityProviderMode === "org"); (build === "enterprise" && env.app.identityProviderMode === "org");
let loginIdps: LoginFormIDP[] = []; let loginIdps: LoginFormIDP[] = [];
let lastUsedIdpForSmartLogin: (LoginFormIDP & { orgId?: string }) | null =
null;
if (!useSmartLogin) { if (!useSmartLogin) {
// Load IdPs for DashboardLoginForm (OSS or org-only IdP mode) // Load IdPs for DashboardLoginForm (OSS or org-only IdP mode)
if (build === "oss" || env.app.identityProviderMode !== "org") { if (build === "oss" || env.app.identityProviderMode !== "org") {
const idpsRes = const idpsRes = await cache(
await priv.get<AxiosResponse<ListIdpsResponse>>("/idp"); async () =>
await priv.get<AxiosResponse<ListIdpsResponse>>("/idp")
)();
loginIdps = idpsRes.data.data.idps.map((idp) => ({ loginIdps = idpsRes.data.data.idps.map((idp) => ({
idpId: idp.idpId, idpId: idp.idpId,
name: idp.name, name: idp.name,
variant: idp.type variant: idp.type
})) as LoginFormIDP[]; })) as LoginFormIDP[];
} }
} else {
if (lastUsedIdpCookie) {
const lastUsedIdpSchema = z.object({
orgId: z.string().optional(),
idpId: z.number()
});
try {
const persistedData = lastUsedIdpSchema.parse(
JSON.parse(lastUsedIdpCookie.value)
);
const idpRes = await priv.get<AxiosResponse<GetIdpResponse>>(
`/idp/${persistedData.idpId}`
);
const idp = idpRes.data.data.idp;
lastUsedIdpForSmartLogin = {
idpId: idp.idpId,
name: idp.name,
variant: idp.type,
orgId: persistedData.orgId,
lastUsed: true
};
} catch (error) {
// the idp might not exist or the data is malformatted, skip this
}
}
} }
const t = await getTranslations(); const t = await getTranslations();
@@ -192,7 +160,6 @@ export default async function Page(props: {
redirect={redirectUrl} redirect={redirectUrl}
forceLogin={forceLogin} forceLogin={forceLogin}
defaultUser={defaultUser} defaultUser={defaultUser}
lastUsedIdp={lastUsedIdpForSmartLogin}
orgSignIn={ orgSignIn={
!isInvite && !isInvite &&
(build === "saas" || (build === "saas" ||
+8 -17
View File
@@ -13,8 +13,6 @@ import { redirect } from "next/navigation";
import OrgLoginPage from "@app/components/OrgLoginPage"; import OrgLoginPage from "@app/components/OrgLoginPage";
import { pullEnv } from "@app/lib/pullEnv"; import { pullEnv } from "@app/lib/pullEnv";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Organization Login" title: "Organization Login"
@@ -70,22 +68,15 @@ export default async function OrgAuthPage(props: {
variant: idp.variant variant: idp.variant
})) as LoginFormIDP[]; })) as LoginFormIDP[];
const hasLoginPageBranding = await isOrgSubscribed(
orgId,
tierMatrix.loginPageBranding
);
let branding: LoadLoginPageBrandingResponse | null = null; let branding: LoadLoginPageBrandingResponse | null = null;
if (hasLoginPageBranding) { try {
try { const res = await priv.get<
const res = await priv.get< AxiosResponse<LoadLoginPageBrandingResponse>
AxiosResponse<LoadLoginPageBrandingResponse> >(`/login-page-branding?orgId=${orgId}`);
>(`/login-page-branding?orgId=${orgId}`); if (res.status === 200) {
if (res.status === 200) { branding = res.data.data;
branding = res.data.data; }
} } catch (error) {}
} catch (error) {}
}
return ( return (
<OrgLoginPage <OrgLoginPage
+1 -5
View File
@@ -19,7 +19,6 @@ import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
import { OrgSelectionForm } from "@app/components/OrgSelectionForm"; import { OrgSelectionForm } from "@app/components/OrgSelectionForm";
import OrgLoginPage from "@app/components/OrgLoginPage"; import OrgLoginPage from "@app/components/OrgLoginPage";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Choose Organization" title: "Choose Organization"
@@ -84,10 +83,7 @@ export default async function OrgAuthPage(props: {
redirect(env.app.dashboardUrl); redirect(env.app.dashboardUrl);
} }
const subscribed = await isOrgSubscribed( const subscribed = await isOrgSubscribed(loginPage.orgId);
loginPage.orgId,
tierMatrix.loginPageDomain
);
if (build === "saas" && !subscribed) { if (build === "saas" && !subscribed) {
console.log( console.log(
+5 -20
View File
@@ -27,7 +27,6 @@ import { CheckOrgUserAccessResponse } from "@server/routers/org";
import OrgPolicyRequired from "@app/components/OrgPolicyRequired"; import OrgPolicyRequired from "@app/components/OrgPolicyRequired";
import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed"; import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
import { normalizePostAuthPath } from "@server/lib/normalizePostAuthPath"; import { normalizePostAuthPath } from "@server/lib/normalizePostAuthPath";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import type { Metadata } from "next"; import type { Metadata } from "next";
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -71,25 +70,14 @@ export default async function ResourceAuthPage(props: {
); );
} }
const hasLoginPageDomain = await isOrgSubscribed( const subscribed = await isOrgSubscribed(authInfo.orgId);
authInfo.orgId,
tierMatrix.loginPageDomain
);
const hasOrgOidc = await isOrgSubscribed(
authInfo.orgId,
tierMatrix.orgOidc
);
const hasLoginPageBranding = await isOrgSubscribed(
authInfo.orgId,
tierMatrix.loginPageBranding
);
const allHeaders = await headers(); const allHeaders = await headers();
const host = allHeaders.get("host"); const host = allHeaders.get("host");
const expectedHost = env.app.dashboardUrl.split("//")[1]; const expectedHost = env.app.dashboardUrl.split("//")[1];
if (host !== expectedHost) { if (host !== expectedHost) {
if (build === "saas" && !hasLoginPageDomain) { if (build === "saas" && !subscribed) {
redirect(env.app.dashboardUrl); redirect(env.app.dashboardUrl);
} }
@@ -118,10 +106,7 @@ export default async function ResourceAuthPage(props: {
const redirectPort = new URL(searchParams.redirect).port; const redirectPort = new URL(searchParams.redirect).port;
const serverResourceHostWithPort = `${serverResourceHost}:${redirectPort}`; const serverResourceHostWithPort = `${serverResourceHost}:${redirectPort}`;
const wildcardMatchesRedirect = ( const wildcardMatchesRedirect = (wildcardDomain: string, host: string): boolean => {
wildcardDomain: string,
host: string
): boolean => {
if (!wildcardDomain.startsWith("*.")) return false; if (!wildcardDomain.startsWith("*.")) return false;
const suffix = wildcardDomain.slice(1); // e.g. ".wildcard.owen.fosrl.io" const suffix = wildcardDomain.slice(1); // e.g. ".wildcard.owen.fosrl.io"
return host.endsWith(suffix) && host.length > suffix.length; return host.endsWith(suffix) && host.length > suffix.length;
@@ -243,7 +228,7 @@ export default async function ResourceAuthPage(props: {
let loginIdps: LoginFormIDP[] = []; let loginIdps: LoginFormIDP[] = [];
if (build === "saas" || env.app.identityProviderMode === "org") { if (build === "saas" || env.app.identityProviderMode === "org") {
if (hasOrgOidc) { if (subscribed) {
const idpsRes = await cache( const idpsRes = await cache(
async () => async () =>
await priv.get<AxiosResponse<ListOrgIdpsResponse>>( await priv.get<AxiosResponse<ListOrgIdpsResponse>>(
@@ -286,7 +271,7 @@ export default async function ResourceAuthPage(props: {
let branding: LoadLoginPageBrandingResponse | null = null; let branding: LoadLoginPageBrandingResponse | null = null;
try { try {
if (hasLoginPageBranding) { if (subscribed) {
const res = await priv.get< const res = await priv.get<
AxiosResponse<LoadLoginPageBrandingResponse> AxiosResponse<LoadLoginPageBrandingResponse>
>(`/login-page-branding?orgId=${authInfo.orgId}`); >(`/login-page-branding?orgId=${authInfo.orgId}`);
+4 -1
View File
@@ -5,6 +5,7 @@ import UserProvider from "@app/providers/UserProvider";
import { ListUserOrgsResponse } from "@server/routers/org"; import { ListUserOrgsResponse } from "@server/routers/org";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { cache } from "react";
import OrganizationLanding from "@app/components/OrganizationLanding"; import OrganizationLanding from "@app/components/OrganizationLanding";
import { pullEnv } from "@app/lib/pullEnv"; import { pullEnv } from "@app/lib/pullEnv";
import { cleanRedirect } from "@app/lib/cleanRedirect"; import { cleanRedirect } from "@app/lib/cleanRedirect";
@@ -12,6 +13,7 @@ import { Layout } from "@app/components/Layout";
import RedirectToOrg from "@app/components/RedirectToOrg"; import RedirectToOrg from "@app/components/RedirectToOrg";
import { InitialSetupCompleteResponse } from "@server/routers/auth"; import { InitialSetupCompleteResponse } from "@server/routers/auth";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { build } from "@server/build";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -27,7 +29,8 @@ export default async function Page(props: {
const env = pullEnv(); const env = pullEnv();
const user = await verifySession({ skipCheckVerifyEmail: true }); const getUser = cache(verifySession);
const user = await getUser({ skipCheckVerifyEmail: true });
let complete = false; let complete = false;
try { try {
+26 -52
View File
@@ -1,25 +1,26 @@
"use client"; "use client";
import { generateOidcUrlProxy } from "@app/actions/server"; import { useEffect, useState } from "react";
import IdpTypeIcon from "@app/components/IdpTypeIcon";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { cleanRedirect } from "@app/lib/cleanRedirect"; import { Alert, AlertDescription } from "@app/components/ui/alert";
import { LAST_USED_IDP_COOKIE_NAME } from "@app/lib/consts";
import { setClientCookie } from "@app/lib/setClientCookie";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import IdpTypeIcon from "@app/components/IdpTypeIcon";
import {
generateOidcUrlProxy,
type GenerateOidcUrlResponse
} from "@app/actions/server";
import { import {
redirect as redirectTo, redirect as redirectTo,
useRouter, useParams,
useSearchParams useSearchParams
} from "next/navigation"; } from "next/navigation";
import { useEffect, useState, useTransition } from "react"; import { useRouter } from "next/navigation";
import { cleanRedirect } from "@app/lib/cleanRedirect";
export type LoginFormIDP = { export type LoginFormIDP = {
idpId: number; idpId: number;
name: string; name: string;
variant?: string; variant?: string;
lastUsed?: boolean;
}; };
type IdpLoginButtonsProps = { type IdpLoginButtonsProps = {
@@ -34,6 +35,7 @@ export default function IdpLoginButtons({
orgId orgId
}: IdpLoginButtonsProps) { }: IdpLoginButtonsProps) {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const t = useTranslations(); const t = useTranslations();
const params = useSearchParams(); const params = useSearchParams();
@@ -50,22 +52,10 @@ export default function IdpLoginButtons({
} }
}, []); }, []);
const [loading, startTransition] = useTransition();
async function loginWithIdp(idpId: number) { async function loginWithIdp(idpId: number) {
setLoading(true);
setError(null); setError(null);
setClientCookie(
LAST_USED_IDP_COOKIE_NAME,
JSON.stringify({
orgId,
idpId
}),
{
sameSite: "Lax"
}
);
let redirectToUrl: string | undefined; let redirectToUrl: string | undefined;
try { try {
console.log("generating", idpId, redirect || "/", orgId); console.log("generating", idpId, redirect || "/", orgId);
@@ -78,6 +68,7 @@ export default function IdpLoginButtons({
if (response.error) { if (response.error) {
setError(response.message); setError(response.message);
setLoading(false);
return; return;
} }
@@ -93,6 +84,7 @@ export default function IdpLoginButtons({
"An unexpected error occurred. Please try again." "An unexpected error occurred. Please try again."
}) })
); );
setLoading(false);
} }
if (redirectToUrl) { if (redirectToUrl) {
@@ -132,38 +124,20 @@ export default function IdpLoginButtons({
idp.variant || idp.name.toLowerCase(); idp.variant || idp.name.toLowerCase();
return ( return (
<div <Button
className="w-full relative"
key={idp.idpId} key={idp.idpId}
type="button"
variant="outline"
className="w-full inline-flex items-center space-x-2"
onClick={() => {
loginWithIdp(idp.idpId);
}}
disabled={loading}
loading={loading}
> >
<Button <IdpTypeIcon type={effectiveType} size={16} />
key={idp.idpId} <span>{idp.name}</span>
type="button" </Button>
variant="outline"
className="w-full inline-flex items-center space-x-2 after:absolute after:inset-0 after:z-10"
onClick={() => {
startTransition(() =>
loginWithIdp(idp.idpId)
);
}}
disabled={loading}
loading={loading}
>
<IdpTypeIcon
type={effectiveType}
size={16}
/>
<span>{idp.name}</span>
</Button>
{idp.lastUsed && (
<div className="absolute inset-0">
<span className="absolute top-0 right-0 text-xs bg-primary text-primary-foreground rounded-bl-sm rounded-tr-sm px-2 py-0.5">
{t("idpLastUsed")}
</span>
</div>
)}
</div>
); );
})} })}
</> </>
+19 -38
View File
@@ -30,7 +30,10 @@ import Link from "next/link";
import { GenerateOidcUrlResponse } from "@server/routers/idp"; import { GenerateOidcUrlResponse } from "@server/routers/idp";
import { Separator } from "./ui/separator"; import { Separator } from "./ui/separator";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { generateOidcUrlProxy, loginProxy } from "@app/actions/server"; import {
generateOidcUrlProxy,
loginProxy
} from "@app/actions/server";
import { redirect as redirectTo } from "next/navigation"; import { redirect as redirectTo } from "next/navigation";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import IdpTypeIcon from "@app/components/IdpTypeIcon"; import IdpTypeIcon from "@app/components/IdpTypeIcon";
@@ -38,13 +41,11 @@ import IdpTypeIcon from "@app/components/IdpTypeIcon";
import { loadReoScript } from "reodotdev"; import { loadReoScript } from "reodotdev";
import { build } from "@server/build"; import { build } from "@server/build";
import MfaInputForm from "@app/components/MfaInputForm"; import MfaInputForm from "@app/components/MfaInputForm";
import { useLocalStorage } from "@app/hooks/useLocalStorage";
export type LoginFormIDP = { export type LoginFormIDP = {
idpId: number; idpId: number;
name: string; name: string;
variant?: string; variant?: string;
lastUsed?: boolean;
}; };
type LoginFormProps = { type LoginFormProps = {
@@ -104,6 +105,7 @@ export default function LoginForm({
} }
}, []); }, []);
const formSchema = z.object({ const formSchema = z.object({
email: z.string().email({ message: t("emailInvalid") }), email: z.string().email({ message: t("emailInvalid") }),
password: z.string().min(8, { message: t("passwordRequirementsChars") }) password: z.string().min(8, { message: t("passwordRequirementsChars") })
@@ -128,16 +130,11 @@ export default function LoginForm({
} }
}); });
const [lastUsedIdpId, setLastUsedIdpId] = useLocalStorage<string | null>(
"login:last-used-idp",
null
);
async function onSubmit(values: any) { async function onSubmit(values: any) {
const { email, password } = form.getValues(); const { email, password } = form.getValues();
const { code } = mfaForm.getValues(); const { code } = mfaForm.getValues();
setLastUsedIdpId(null);
setLoading(true); setLoading(true);
setError(null); setError(null);
@@ -182,7 +179,8 @@ export default function LoginForm({
if (data.useSecurityKey) { if (data.useSecurityKey) {
setError( setError(
t("securityKeyRequired", { t("securityKeyRequired", {
defaultValue: "Please use your security key to sign in." defaultValue:
"Please use your security key to sign in."
}) })
); );
return; return;
@@ -244,8 +242,6 @@ export default function LoginForm({
async function loginWithIdp(idpId: number) { async function loginWithIdp(idpId: number) {
let redirectUrl: string | undefined; let redirectUrl: string | undefined;
setLastUsedIdpId(idpId.toString());
try { try {
const data = await generateOidcUrlProxy( const data = await generateOidcUrlProxy(
idpId, idpId,
@@ -360,6 +356,7 @@ export default function LoginForm({
)} )}
<div className="space-y-4"> <div className="space-y-4">
{!mfaRequested && ( {!mfaRequested && (
<> <>
<SecurityKeyAuthButton <SecurityKeyAuthButton
@@ -388,41 +385,25 @@ export default function LoginForm({
idp.variant || idp.name.toLowerCase(); idp.variant || idp.name.toLowerCase();
return ( return (
<div <Button
className="w-full relative"
key={idp.idpId} key={idp.idpId}
type="button"
variant="outline"
className="w-full inline-flex items-center space-x-2"
onClick={() => {
loginWithIdp(idp.idpId);
}}
> >
<Button <IdpTypeIcon type={effectiveType} size={16} />
key={idp.idpId} <span>{idp.name}</span>
type="button" </Button>
variant="outline"
className="w-full inline-flex items-center space-x-2 after:absolute after:inset-0 after:z-10"
onClick={() => {
loginWithIdp(idp.idpId);
}}
>
<IdpTypeIcon
type={effectiveType}
size={16}
/>
<span>{idp.name}</span>
</Button>
{lastUsedIdpId ===
idp.idpId.toString() && (
<div className="absolute inset-0">
<span className="absolute top-0 right-0 text-xs bg-primary text-primary-foreground rounded-bl-sm rounded-tr-sm px-2 py-0.5">
{t("idpLastUsed")}
</span>
</div>
)}
</div>
); );
})} })}
</> </>
)} )}
</> </>
)} )}
</div> </div>
</div> </div>
); );
-8
View File
@@ -22,8 +22,6 @@ import Link from "next/link";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { cleanRedirect } from "@app/lib/cleanRedirect"; import { cleanRedirect } from "@app/lib/cleanRedirect";
import MfaInputForm from "@app/components/MfaInputForm"; import MfaInputForm from "@app/components/MfaInputForm";
import { LAST_USED_IDP_COOKIE_NAME } from "@app/lib/consts";
import { setClientCookie } from "@app/lib/setClientCookie";
type LoginPasswordFormProps = { type LoginPasswordFormProps = {
identifier: string; identifier: string;
@@ -84,12 +82,6 @@ export default function LoginPasswordForm({
const { password } = values; const { password } = values;
const { code } = mfaForm.getValues(); const { code } = mfaForm.getValues();
// delete last used auth cookie by setting it in the past
setClientCookie(LAST_USED_IDP_COOKIE_NAME, JSON.stringify(null), {
sameSite: "Lax",
days: -1
});
setLoading(true); setLoading(true);
setError(null); setError(null);
+1 -14
View File
@@ -27,8 +27,6 @@ import UserProfileCard from "@app/components/UserProfileCard";
import SecurityKeyAuthButton from "@app/components/SecurityKeyAuthButton"; import SecurityKeyAuthButton from "@app/components/SecurityKeyAuthButton";
import { Separator } from "@app/components/ui/separator"; import { Separator } from "@app/components/ui/separator";
import OrgSignInLink from "@app/components/OrgSignInLink"; import OrgSignInLink from "@app/components/OrgSignInLink";
import type { LoginFormIDP } from "./LoginForm";
import IdpLoginButtons from "./IdpLoginButtons";
const identifierSchema = z.object({ const identifierSchema = z.object({
identifier: z.string().min(1, "Username or email is required") identifier: z.string().min(1, "Username or email is required")
@@ -55,7 +53,6 @@ type SmartLoginFormProps = {
forceLogin?: boolean; forceLogin?: boolean;
defaultUser?: string; defaultUser?: string;
orgSignIn?: OrgSignInConfig; orgSignIn?: OrgSignInConfig;
lastUsedIdp?: (LoginFormIDP & { orgId?: string }) | null;
}; };
type ViewState = type ViewState =
@@ -92,8 +89,7 @@ export default function SmartLoginForm({
redirect, redirect,
forceLogin, forceLogin,
defaultUser, defaultUser,
orgSignIn, orgSignIn
lastUsedIdp
}: SmartLoginFormProps) { }: SmartLoginFormProps) {
const router = useRouter(); const router = useRouter();
const { env } = useEnvContext(); const { env } = useEnvContext();
@@ -298,15 +294,6 @@ export default function SmartLoginForm({
</span> </span>
</div> </div>
</div> </div>
{lastUsedIdp && (
<IdpLoginButtons
idps={[lastUsedIdp]}
orgId={lastUsedIdp.orgId}
redirect={redirect}
/>
)}
<OrgSignInLink <OrgSignInLink
href={orgSignIn.href} href={orgSignIn.href}
linkText={orgSignIn.linkText} linkText={orgSignIn.linkText}
-13
View File
@@ -17,8 +17,6 @@ import {
} from "next/navigation"; } from "next/navigation";
import { cleanRedirect } from "@app/lib/cleanRedirect"; import { cleanRedirect } from "@app/lib/cleanRedirect";
import { Separator } from "@app/components/ui/separator"; import { Separator } from "@app/components/ui/separator";
import { setClientCookie } from "@app/lib/setClientCookie";
import { LAST_USED_IDP_COOKIE_NAME } from "@app/lib/consts";
type SmartLoginOrgSelectorProps = { type SmartLoginOrgSelectorProps = {
identifier: string; identifier: string;
@@ -143,17 +141,6 @@ export default function SmartLoginOrgSelector({
setPendingIdpId(idpId); setPendingIdpId(idpId);
setError(null); setError(null);
setClientCookie(
LAST_USED_IDP_COOKIE_NAME,
JSON.stringify({
orgId,
idpId
}),
{
sameSite: "Lax"
}
);
let redirectToUrl: string | undefined; let redirectToUrl: string | undefined;
try { try {
const safeRedirect = cleanRedirect(redirect || "/"); const safeRedirect = cleanRedirect(redirect || "/");
+2 -7
View File
@@ -4,13 +4,9 @@ import { getCachedSubscription } from "./getCachedSubscription";
import { priv } from "."; import { priv } from ".";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { GetLicenseStatusResponse } from "@server/routers/license/types"; import { GetLicenseStatusResponse } from "@server/routers/license/types";
import { Tier } from "@server/types/Tiers";
const DEFAULT_PAID_TIERS: Tier[] = ["tier1", "tier2", "tier3", "enterprise"]; export const isOrgSubscribed = cache(async (orgId: string) => {
export const isOrgSubscribed = cache(async (orgId: string, tiers?: Tier[]) => {
let subscribed = false; let subscribed = false;
const allowedTiers = tiers ?? DEFAULT_PAID_TIERS;
if (build === "enterprise") { if (build === "enterprise") {
try { try {
@@ -24,8 +20,7 @@ export const isOrgSubscribed = cache(async (orgId: string, tiers?: Tier[]) => {
try { try {
const subRes = await getCachedSubscription(orgId); const subRes = await getCachedSubscription(orgId);
subscribed = subscribed =
!!subRes.data.data.tier && (subRes.data.data.tier == "tier1" || subRes.data.data.tier == "tier2" || subRes.data.data.tier == "tier3" || subRes.data.data.tier == "enterprise") &&
allowedTiers.includes(subRes.data.data.tier as Tier) &&
subRes.data.data.active; subRes.data.data.active;
} catch {} } catch {}
} }
-1
View File
@@ -1 +0,0 @@
export const LAST_USED_IDP_COOKIE_NAME = "p__last_used_idp";
+1 -5
View File
@@ -1,7 +1,6 @@
import { priv } from "@app/lib/api"; import { priv } from "@app/lib/api";
import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed"; import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
import { build } from "@server/build"; import { build } from "@server/build";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { LoadLoginPageBrandingResponse } from "@server/routers/loginPage/types"; import { LoadLoginPageBrandingResponse } from "@server/routers/loginPage/types";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
@@ -12,10 +11,7 @@ export async function loadOrgLoginPageBranding(orgId: string): Promise<{
return { primaryColor: null }; return { primaryColor: null };
} }
const subscribed = await isOrgSubscribed( const subscribed = await isOrgSubscribed(orgId);
orgId,
tierMatrix.loginPageBranding
);
if (!subscribed) { if (!subscribed) {
return { primaryColor: null }; return { primaryColor: null };
} }
-32
View File
@@ -1,32 +0,0 @@
/**
* Set a cookie on the client side in javascript code, not on the server
* @param name
* @param value
* @param days
* @param options
*/
export function setClientCookie(
name: string,
value: string,
options: {
days?: number;
path?: string;
secure?: boolean;
sameSite?: "Strict" | "Lax" | "None";
} = {}
): void {
let cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
if (options.days) {
const date = new Date();
date.setTime(date.getTime() + options.days * 864e5);
cookie += `; expires=${date.toUTCString()}`;
}
cookie += `; path=${options.path ?? "/"}`;
if (options.secure) cookie += "; Secure";
if (options.sameSite) cookie += `; SameSite=${options.sameSite}`;
document.cookie = cookie;
}