From 9fb677e952b8593a4ca93d597445828059a7ced6 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 8 May 2026 17:48:26 -0700 Subject: [PATCH 1/7] allow editing self and owner user roles --- messages/en-US.json | 11 +++ server/private/routers/user/addUserRole.ts | 9 -- server/private/routers/user/removeUserRole.ts | 4 +- .../private/routers/user/setUserOrgRoles.ts | 23 ++--- server/routers/user/addUserRoleLegacy.ts | 4 +- server/routers/user/getOrgUser.ts | 8 +- .../users/[userId]/access-controls/page.tsx | 82 ++++++++++++++--- src/components/UsersTable.tsx | 88 +++++++++++-------- .../multi-select/multi-select-content.tsx | 2 +- src/components/roles-selector.tsx | 2 +- 10 files changed, 153 insertions(+), 80 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 9a23043d5..9995d3af5 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -523,6 +523,12 @@ "userMessageOrgRemove": "Once removed, this user will no longer have access to the organization. You can always re-invite them later, but they will need to accept the invitation again.", "userRemoveOrgConfirm": "Confirm Remove User", "userRemoveOrg": "Remove User from Organization", + "userQuestionOrgRemoveSelf": "Are you sure you want to remove yourself from this organization?", + "userMessageOrgRemoveSelf": "You will lose access immediately. An administrator can invite you again later, but you will need to accept a new invitation.", + "userRemoveOrgConfirmSelf": "Confirm Remove Myself", + "userRemoveOrgSelf": "Remove yourself from the organization", + "userRemoveOrgSelfWarning": "You will lose access to this organization immediately.", + "userRemoveOrgConfirmPhraseSelf": "REMOVE MYSELF FROM ORG", "users": "Users", "accessRoleMember": "Member", "accessRoleOwner": "Owner", @@ -531,6 +537,11 @@ "emailInvalid": "Invalid email address", "inviteValidityDuration": "Please select a duration", "accessRoleSelectPlease": "Please select a role", + "removeOwnAdminRoleConfirmTitle": "Remove your administrator access?", + "removeOwnAdminRoleConfirmDescription": "You will no longer have administrator permissions in this organization after saving. Another administrator can restore access if needed.", + "removeOwnAdminRoleConfirmButton": "Remove My Administrator Access", + "removeOwnAdminRoleConfirmPhrase": "REMOVE MY ADMIN ACCESS", + "ownerMustRetainAdminRole": "The organization owner must keep at least one administrator role.", "usernameRequired": "Username is required", "idpSelectPlease": "Please select an identity provider", "idpGenericOidc": "Generic OAuth2/OIDC provider.", diff --git a/server/private/routers/user/addUserRole.ts b/server/private/routers/user/addUserRole.ts index 90fa79ee3..1789ca9c4 100644 --- a/server/private/routers/user/addUserRole.ts +++ b/server/private/routers/user/addUserRole.ts @@ -98,15 +98,6 @@ export async function addUserRole( ); } - if (existingUser[0].isOwner) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - "Cannot change the role of the owner of the organization" - ) - ); - } - const roleExists = await db .select() .from(roles) diff --git a/server/private/routers/user/removeUserRole.ts b/server/private/routers/user/removeUserRole.ts index 1a7b763d4..7cd805240 100644 --- a/server/private/routers/user/removeUserRole.ts +++ b/server/private/routers/user/removeUserRole.ts @@ -98,11 +98,11 @@ export async function removeUserRole( ); } - if (existingUser.isOwner) { + if (existingUser.isOwner && role.isAdmin === true) { return next( createHttpError( HttpCode.FORBIDDEN, - "Cannot change the roles of the owner of the organization" + "Cannot remove the administrator role from the organization owner" ) ); } diff --git a/server/private/routers/user/setUserOrgRoles.ts b/server/private/routers/user/setUserOrgRoles.ts index 7567ffc54..7790eacfb 100644 --- a/server/private/routers/user/setUserOrgRoles.ts +++ b/server/private/routers/user/setUserOrgRoles.ts @@ -87,17 +87,8 @@ export async function setUserOrgRoles( ); } - if (existingUser.isOwner) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - "Cannot change the roles of the owner of the organization" - ) - ); - } - const orgRoles = await db - .select({ roleId: roles.roleId }) + .select({ roleId: roles.roleId, isAdmin: roles.isAdmin }) .from(roles) .where( and( @@ -115,6 +106,18 @@ export async function setUserOrgRoles( ); } + if (existingUser.isOwner) { + const hasAdminRole = orgRoles.some((r) => r.isAdmin === true); + if (!hasAdminRole) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "The organization owner must retain an administrator role" + ) + ); + } + } + let orgClientsToRebuild: Client[] = []; await db.transaction(async (trx) => { await trx diff --git a/server/routers/user/addUserRoleLegacy.ts b/server/routers/user/addUserRoleLegacy.ts index 9696e4aac..6e5b805ab 100644 --- a/server/routers/user/addUserRoleLegacy.ts +++ b/server/routers/user/addUserRoleLegacy.ts @@ -88,11 +88,11 @@ export async function addUserRoleLegacy( ); } - if (existingUser.isOwner) { + if (existingUser.isOwner && role.isAdmin !== true) { return next( createHttpError( HttpCode.FORBIDDEN, - "Cannot change the role of the owner of the organization" + "The organization owner must retain an administrator role" ) ); } diff --git a/server/routers/user/getOrgUser.ts b/server/routers/user/getOrgUser.ts index c415e186c..af900150b 100644 --- a/server/routers/user/getOrgUser.ts +++ b/server/routers/user/getOrgUser.ts @@ -47,10 +47,7 @@ export async function queryUser(orgId: string, userId: string) { .from(userOrgRoles) .leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) .where( - and( - eq(userOrgRoles.userId, userId), - eq(userOrgRoles.orgId, orgId) - ) + and(eq(userOrgRoles.userId, userId), eq(userOrgRoles.orgId, orgId)) ); const isAdmin = roleRows.some((r) => r.isAdmin); @@ -61,7 +58,8 @@ export async function queryUser(orgId: string, userId: string) { roleIds: roleRows.map((r) => r.roleId), roles: roleRows.map((r) => ({ roleId: r.roleId, - name: r.roleName ?? "" + name: r.roleName ?? "", + isAdmin: r.isAdmin === true })) }; } diff --git a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx index 717d7f211..2bb9723ed 100644 --- a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx +++ b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx @@ -1,5 +1,6 @@ "use client"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import IdpTypeBadge from "@app/components/IdpTypeBadge"; import OrgRolesTagField from "@app/components/OrgRolesTagField"; import { @@ -25,6 +26,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { userOrgUserContext } from "@app/hooks/useOrgUserContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { toast } from "@app/hooks/useToast"; +import { useUserContext } from "@app/hooks/useUserContext"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { zodResolver } from "@hookform/resolvers/zod"; import { build } from "@server/build"; @@ -32,7 +34,7 @@ import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { UserType } from "@server/types/UserTypes"; import { useTranslations } from "next-intl"; import { useParams } from "next/navigation"; -import { useActionState, useEffect } from "react"; +import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -42,13 +44,15 @@ const accessControlsFormSchema = z.object({ roles: z.array( z.object({ id: z.string(), - text: z.string() + text: z.string(), + isAdmin: z.boolean().optional() }) ) }); export default function AccessControlsPage() { const { orgUser: user, updateOrgUser } = userOrgUserContext(); + const { user: sessionUser } = useUserContext(); const { env } = useEnvContext(); const api = createApiClient({ env }); @@ -72,7 +76,8 @@ export default function AccessControlsPage() { autoProvisioned: user.autoProvisioned || false, roles: (user.roles ?? []).map((r) => ({ id: r.roleId.toString(), - text: r.name + text: r.name, + isAdmin: r.isAdmin === true })) } }); @@ -84,7 +89,8 @@ export default function AccessControlsPage() { "roles", (user.roles ?? []).map((r) => ({ id: r.roleId.toString(), - text: r.name + text: r.name, + isAdmin: r.isAdmin === true })) ); form.setValue("autoProvisioned", user.autoProvisioned || false); @@ -95,11 +101,11 @@ export default function AccessControlsPage() { ? t("singleRolePerUserPlanNotice") : t("singleRolePerUserEditionNotice"); - const [, action, isSubmitting] = useActionState(onSubmit, null); - async function onSubmit() { - const isValid = await form.trigger(); - if (!isValid) return; + const [isSaving, setIsSaving] = useState(false); + const [confirmRemoveOwnAdminOpen, setConfirmRemoveOwnAdminOpen] = + useState(false); + async function executeSave() { const values = form.getValues(); if (values.roles.length === 0) { @@ -111,6 +117,7 @@ export default function AccessControlsPage() { return; } + setIsSaving(true); try { const roleIds = values.roles.map((r) => parseInt(r.id, 10)); const updateRoleRequest = supportsMultipleRolesPerUser @@ -130,7 +137,8 @@ export default function AccessControlsPage() { roleIds, roles: values.roles.map((r) => ({ roleId: parseInt(r.id, 10), - name: r.text + name: r.text, + isAdmin: r.isAdmin === true })), autoProvisioned: values.autoProvisioned }); @@ -149,11 +157,61 @@ export default function AccessControlsPage() { t("accessRoleErrorAddDescription") ) }); + } finally { + setIsSaving(false); } } + async function handleAccessControlsSubmit(e: React.FormEvent) { + e.preventDefault(); + + const isValid = await form.trigger(); + if (!isValid) return; + + const values = form.getValues(); + + if (values.roles.length === 0) { + toast({ + variant: "destructive", + title: t("accessRoleErrorAdd"), + description: t("accessRoleSelectPlease") + }); + return; + } + + const willHaveAdminRole = values.roles.some( + (r) => r.isAdmin === true + ); + + const isRemovingOwnAdmin = + sessionUser.userId === user.userId && + user.isAdmin && + !willHaveAdminRole; + + if (isRemovingOwnAdmin) { + setConfirmRemoveOwnAdminOpen(true); + return; + } + + await executeSave(); + } + return ( + +

{t("removeOwnAdminRoleConfirmDescription")}

+ + } + buttonText={t("removeOwnAdminRoleConfirmButton")} + string={t("removeOwnAdminRoleConfirmPhrase")} + onConfirm={executeSave} + /> + @@ -168,7 +226,7 @@ export default function AccessControlsPage() {
void handleAccessControlsSubmit(e)} className="space-y-4" id="access-controls-form" > @@ -237,8 +295,8 @@ export default function AccessControlsPage() { - ) : ( - - - - )} + ); } @@ -359,22 +348,45 @@ export default function UsersTable({ }} dialog={
-

{t("userQuestionOrgRemove")}

-

{t("userMessageOrgRemove")}

+

+ {t( + isRemovingSelf + ? "userQuestionOrgRemoveSelf" + : "userQuestionOrgRemove" + )} +

+

+ {t( + isRemovingSelf + ? "userMessageOrgRemoveSelf" + : "userMessageOrgRemove" + )} +

} - buttonText={t("userRemoveOrgConfirm")} + buttonText={t( + isRemovingSelf + ? "userRemoveOrgConfirmSelf" + : "userRemoveOrgConfirm" + )} + warningText={ + isRemovingSelf ? t("userRemoveOrgSelfWarning") : undefined + } onConfirm={async () => startTransition(removeUser)} string={ - selectedUser - ? getUserDisplayName({ - email: selectedUser.email, - name: selectedUser.name, - username: selectedUser.username - }) - : "" + isRemovingSelf + ? t("userRemoveOrgConfirmPhraseSelf") + : selectedUser + ? getUserDisplayName({ + email: selectedUser.email, + name: selectedUser.name, + username: selectedUser.username + }) + : "" } - title={t("userRemoveOrg")} + title={t( + isRemovingSelf ? "userRemoveOrgSelf" : "userRemoveOrg" + )} /> = { emptyPlaceholder?: string; diff --git a/src/components/roles-selector.tsx b/src/components/roles-selector.tsx index 7f1b62e60..811971f49 100644 --- a/src/components/roles-selector.tsx +++ b/src/components/roles-selector.tsx @@ -6,7 +6,7 @@ import { useDebounce } from "use-debounce"; import { useTranslations } from "next-intl"; import { MultiSelectTagInput } from "./multi-select/multi-select-tag-input"; -export type SelectedRole = { id: string; text: string }; +export type SelectedRole = { id: string; text: string; isAdmin?: boolean }; export type RolesSelectorProps = { orgId: string; From a066a68e1ac8be44967f00aa89567303060320a1 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 11 May 2026 11:28:32 -0700 Subject: [PATCH 2/7] Pick the most specific domain Fixes #3047 --- server/lib/blueprints/proxyResources.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index 34b352a42..178991962 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -1227,7 +1227,11 @@ async function getDomainId( return null; } - const domainSelection = validDomains[0].domains; + // Pick the most specific (longest baseDomain) valid domain so that, e.g., + // *.test.dev.example.com is assigned to *.dev.example.com rather than *.example.com. + const domainSelection = validDomains.sort( + (a, b) => b.domains.baseDomain.length - a.domains.baseDomain.length + )[0].domains; const baseDomain = domainSelection.baseDomain; // Wildcard full-domains are not allowed on namespace (provided/free) domains From 4aa72eb1a37aafb274c4594cea0decbc5b55ccf8 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 11 May 2026 11:49:51 -0700 Subject: [PATCH 3/7] Confirm delete of share links --- messages/en-US.json | 4 ++++ src/components/ShareLinksTable.tsx | 34 +++++++++++++++++++++++++++--- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 9995d3af5..e095ee662 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -156,6 +156,10 @@ "shareErrorDeleteMessage": "An error occurred deleting link", "shareDeleted": "Link deleted", "shareDeletedDescription": "The link has been deleted", + "shareDelete": "Delete Share Link", + "shareDeleteConfirm": "Confirm Delete Share Link", + "shareQuestionRemove": "Are you sure you want to delete this share link?", + "shareMessageRemove": "Once deleted, the link will no longer work and anyone using it will lose access to the resource.", "shareTokenDescription": "The access token can be passed in two ways: as a query parameter or in the request headers. These must be passed from the client on every request for authenticated access.", "accessToken": "Access Token", "usageExamples": "Usage Examples", diff --git a/src/components/ShareLinksTable.tsx b/src/components/ShareLinksTable.tsx index 333cee03f..239a12cc8 100644 --- a/src/components/ShareLinksTable.tsx +++ b/src/components/ShareLinksTable.tsx @@ -61,6 +61,8 @@ export default function ShareLinksTable({ const api = createApiClient(useEnvContext()); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selectedLink, setSelectedLink] = useState(null); const [rows, setRows] = useState(shareLinks); const [isRefreshing, setIsRefreshing] = useState(false); @@ -92,6 +94,7 @@ export default function ShareLinksTable({ title: t("shareErrorDelete"), description: formatAxiosError(e, t("shareErrorDeleteMessage")) }); + throw e; }); const newRows = rows.filter((r) => r.accessTokenId !== id); @@ -293,9 +296,10 @@ export default function ShareLinksTable({ {/* */} @@ -307,6 +311,30 @@ export default function ShareLinksTable({ return ( <> + {selectedLink && ( + { + setIsDeleteModalOpen(val); + if (!val) setSelectedLink(null); + }} + dialog={ +
+

{t("shareQuestionRemove")}

+

{t("shareMessageRemove")}

+
+ } + buttonText={t("shareDeleteConfirm")} + onConfirm={async () => + deleteSharelink(selectedLink.accessTokenId) + } + string={ + selectedLink.title || selectedLink.resourceName + } + title={t("shareDelete")} + /> + )} + Date: Mon, 11 May 2026 12:06:36 -0700 Subject: [PATCH 4/7] Use the right param for user --- src/components/InviteStatusCard.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/InviteStatusCard.tsx b/src/components/InviteStatusCard.tsx index f35f47629..40b7123e7 100644 --- a/src/components/InviteStatusCard.tsx +++ b/src/components/InviteStatusCard.tsx @@ -99,7 +99,7 @@ export default function InviteStatusCard({ router.push(redirectUrl); } else if (!user && type === "not_logged_in") { const redirectUrl = email - ? `/auth/login?redirect=/invite?token=${tokenParam}&email=${email}` + ? `/auth/login?redirect=/invite?token=${tokenParam}&user=${email}` : `/auth/login?redirect=/invite?token=${tokenParam}`; router.push(redirectUrl); } else { @@ -113,7 +113,7 @@ export default function InviteStatusCard({ async function goToLogin() { await api.post("/auth/logout", {}); const redirectUrl = email - ? `/auth/login?redirect=/invite?token=${tokenParam}&email=${email}` + ? `/auth/login?redirect=/invite?token=${tokenParam}&user=${email}` : `/auth/login?redirect=/invite?token=${tokenParam}`; router.push(redirectUrl); } From 77d17af15b48a5eb8ab8f813e8d6aabe2bc26fe8 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 11 May 2026 16:18:57 -0700 Subject: [PATCH 5/7] Add global hide_powered_by and make it backward --- server/private/lib/config.ts | 7 +++++++ server/private/lib/readConfigFile.ts | 1 + src/components/OrgLoginPage.tsx | 29 +++++++++++++++------------ src/components/ResourceAuthPortal.tsx | 3 ++- src/lib/pullEnv.ts | 2 ++ src/lib/types/env.ts | 1 + 6 files changed, 29 insertions(+), 14 deletions(-) diff --git a/server/private/lib/config.ts b/server/private/lib/config.ts index 9884fe252..75600fba6 100644 --- a/server/private/lib/config.ts +++ b/server/private/lib/config.ts @@ -97,6 +97,13 @@ export class PrivateConfig { ); } + process.env.BRANDING_HIDE_POWERED_BY = + this.rawPrivateConfig.branding?.hide_powered_by === true || + this.rawPrivateConfig.branding?.resource_auth_page + ?.hide_powered_by === true + ? "true" + : "false"; + process.env.LOGIN_PAGE_SUBTITLE_TEXT = this.rawPrivateConfig.branding?.login_page?.subtitle_text || ""; diff --git a/server/private/lib/readConfigFile.ts b/server/private/lib/readConfigFile.ts index 63ca0b068..974e8e590 100644 --- a/server/private/lib/readConfigFile.ts +++ b/server/private/lib/readConfigFile.ts @@ -141,6 +141,7 @@ export const privateConfigSchema = z ) .optional(), hide_auth_layout_footer: z.boolean().optional().default(false), + hide_powered_by: z.boolean().optional(), login_page: z .object({ subtitle_text: z.string().optional() diff --git a/src/components/OrgLoginPage.tsx b/src/components/OrgLoginPage.tsx index 26cc23814..3270b7cb4 100644 --- a/src/components/OrgLoginPage.tsx +++ b/src/components/OrgLoginPage.tsx @@ -16,6 +16,7 @@ import Link from "next/link"; import { replacePlaceholder } from "@app/lib/replacePlaceholder"; import { getTranslations } from "next-intl/server"; import { pullEnv } from "@app/lib/pullEnv"; +import { build } from "@server/build"; type OrgLoginPageProps = { loginPage: LoadLoginPageResponse | undefined; @@ -52,19 +53,21 @@ export default async function OrgLoginPage({ const t = await getTranslations(); return (
-
- - {t("poweredBy")}{" "} - - {env.branding.appName || "Pangolin"} - - -
+ {build !== "enterprise" || !env.branding.hidePoweredBy ? ( +
+ + {t("poweredBy")}{" "} + + {env.branding.appName || "Pangolin"} + + +
+ ) : null} {branding?.logoUrl && ( diff --git a/src/components/ResourceAuthPortal.tsx b/src/components/ResourceAuthPortal.tsx index 0020330c6..64e1d2725 100644 --- a/src/components/ResourceAuthPortal.tsx +++ b/src/components/ResourceAuthPortal.tsx @@ -375,7 +375,8 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { {!accessDenied ? (
{isUnlocked() && build === "enterprise" ? ( - !env.branding.resourceAuthPage?.hidePoweredBy && ( + !env.branding.resourceAuthPage?.hidePoweredBy && + !env.branding.hidePoweredBy && (
{t("poweredBy")}{" "} diff --git a/src/lib/pullEnv.ts b/src/lib/pullEnv.ts index ddbd42c26..21390effc 100644 --- a/src/lib/pullEnv.ts +++ b/src/lib/pullEnv.ts @@ -81,6 +81,8 @@ export function pullEnv(): Env { process.env.BRANDING_HIDE_AUTH_LAYOUT_FOOTER === "true" ? true : false, + hidePoweredBy: + process.env.BRANDING_HIDE_POWERED_BY === "true" ? true : false, logo: { lightPath: process.env.BRANDING_LOGO_LIGHT_PATH as string, darkPath: process.env.BRANDING_LOGO_DARK_PATH as string, diff --git a/src/lib/types/env.ts b/src/lib/types/env.ts index 46513ae52..2a9fd2549 100644 --- a/src/lib/types/env.ts +++ b/src/lib/types/env.ts @@ -41,6 +41,7 @@ export type Env = { appName?: string; background_image_path?: string; hideAuthLayoutFooter?: boolean; + hidePoweredBy?: boolean; logo?: { lightPath?: string; darkPath?: string; From b6caeda0a58575b345ce2b3af06f690660c7daba Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 11 May 2026 22:06:43 -0700 Subject: [PATCH 6/7] improve targets round robin warning --- messages/en-US.json | 1 + .../resources/proxy/[niceId]/proxy/page.tsx | 20 +++++++++----- .../settings/resources/proxy/create/page.tsx | 26 ++++++++++++------- 3 files changed, 30 insertions(+), 17 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index e095ee662..7cbf6b166 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -673,6 +673,7 @@ "targetNoOneDescription": "Adding more than one target above will enable load balancing.", "targetsSubmit": "Save Targets", "addTarget": "Add Target", + "proxyMultiSiteRoundRobinNodeHelp": "Round robin routing will not work between sites that are not connected to the same node, but failover will work.", "targetErrorInvalidIp": "Invalid IP address", "targetErrorInvalidIpDescription": "Please enter a valid IP address or hostname", "targetErrorInvalidPort": "Invalid port", diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx index ba237b9b6..823c0f957 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx @@ -84,6 +84,7 @@ import { AlertTriangle, CircleCheck, CircleX, + ExternalLink, Info, Plus, Settings @@ -961,13 +962,18 @@ function ProxyResourceTargetsForm({ {build === "saas" && targets.length > 1 && new Set(targets.map((t) => t.siteId)).size > 1 && ( -

- - - Round robin routing will not work between - sites that are not connected to the same - node, but failover will work. - +

+ {t("proxyMultiSiteRoundRobinNodeHelp")}{" "} + + {t("learnMore")} + + + .

)} diff --git a/src/app/[orgId]/settings/resources/proxy/create/page.tsx b/src/app/[orgId]/settings/resources/proxy/create/page.tsx index 65d671681..d69bbdcf0 100644 --- a/src/app/[orgId]/settings/resources/proxy/create/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/create/page.tsx @@ -82,8 +82,8 @@ import { AxiosResponse } from "axios"; import { CircleCheck, CircleX, + ExternalLink, Info, - InfoIcon, Plus, Settings, SquareArrowOutUpRight @@ -1425,16 +1425,22 @@ export default function Page() {
)} - {build === "enterprise" && + {build === "saas" && targets.length > 1 && - new Set(targets.map((t) => t.siteId)).size > 1 && ( -

- - - Round robin routing will not work between - sites that are not connected to the same - node, but failover will work. - + new Set(targets.map((t) => t.siteId)).size > + 1 && ( +

+ {t("proxyMultiSiteRoundRobinNodeHelp")}{" "} + + {t("learnMore")} + + + .

)} From f91d914ec612bcd7156686692b0d886069618280 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 12 May 2026 20:13:45 -0700 Subject: [PATCH 7/7] Show when a domain is config managed --- cli/commands/disableUser2fa.ts | 60 ++++++++++++++++++++ cli/index.ts | 2 + messages/en-US.json | 2 + src/components/DomainPageClient.tsx | 21 ++++++- src/components/DomainsTable.tsx | 85 +++++++++++++++++++++-------- 5 files changed, 146 insertions(+), 24 deletions(-) create mode 100644 cli/commands/disableUser2fa.ts diff --git a/cli/commands/disableUser2fa.ts b/cli/commands/disableUser2fa.ts new file mode 100644 index 000000000..8b602c334 --- /dev/null +++ b/cli/commands/disableUser2fa.ts @@ -0,0 +1,60 @@ +import { CommandModule } from "yargs"; +import { db, users } from "@server/db"; +import { eq } from "drizzle-orm"; + +/** + * Disable 2FA for a user by email address. + */ +type DisableUser2faArgs = { + email: string; +}; + +export const disableUser2fa: CommandModule<{}, DisableUser2faArgs> = { + command: "disable-user-2fa", + describe: "Disable 2FA for a user (sets twoFactorEnabled=false, clears secret)", + builder: (yargs) => { + return yargs.option("email", { + type: "string", + demandOption: true, + describe: "User email address" + }); + }, + handler: async (argv: { email: string }) => { + try { + const { email } = argv; + console.log(`Looking for user with email: ${email}`); + + // Find the user by email + const [user] = await db + .select() + .from(users) + .where(eq(users.email, email)) + .limit(1); + + if (!user) { + console.error(`User with email '${email}' not found`); + process.exit(1); + } + + if (!user.twoFactorEnabled) { + console.log(`2FA is already disabled for user '${email}'.`); + process.exit(0); + } + + // Update user: disable 2FA and clear secret + await db.update(users) + .set({ + twoFactorEnabled: false, + twoFactorSecret: null, + twoFactorSetupRequested: false + }) + .where(eq(users.userId, user.userId)); + + console.log(`2FA disabled for user '${email}'.`); + process.exit(0); + } catch (error) { + console.error("Error disabling 2FA:", error); + process.exit(1); + } + } +}; diff --git a/cli/index.ts b/cli/index.ts index 3664bb8f8..19585bc6f 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -10,6 +10,7 @@ import { clearLicenseKeys } from "./commands/clearLicenseKeys"; import { deleteClient } from "./commands/deleteClient"; import { generateOrgCaKeys } from "./commands/generateOrgCaKeys"; import { clearCertificates } from "./commands/clearCertificates"; +import { disableUser2fa } from "./commands/disableUser2fa"; yargs(hideBin(process.argv)) .scriptName("pangctl") @@ -21,5 +22,6 @@ yargs(hideBin(process.argv)) .command(deleteClient) .command(generateOrgCaKeys) .command(clearCertificates) + .command(disableUser2fa) .demandCommand() .help().argv; diff --git a/messages/en-US.json b/messages/en-US.json index 7cbf6b166..52981764b 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2668,6 +2668,8 @@ "validPassword": "Valid Password", "validEmail": "Valid email", "validSSO": "Valid SSO", + "view": "View", + "configManaged": "Config Managed", "connectedClient": "Connected Client", "resourceBlocked": "Resource Blocked", "droppedByRule": "Dropped by Rule", diff --git a/src/components/DomainPageClient.tsx b/src/components/DomainPageClient.tsx index 31527c5b8..6d64e42ce 100644 --- a/src/components/DomainPageClient.tsx +++ b/src/components/DomainPageClient.tsx @@ -13,6 +13,8 @@ import DomainCertForm from "@app/components/DomainCertForm"; import { build } from "@server/build"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useTranslations } from "next-intl"; +import { Lock } from "lucide-react"; +import { Badge } from "@app/components/ui/badge"; interface DomainPageClientProps { initialDomain: GetDomainResponse; @@ -49,7 +51,22 @@ export default function DomainPageClient({ <>
+ {domain.baseDomain} + {domain.configManaged && ( + + + {t("configManaged", { + fallback: "Config Managed" + })} + + )} + + } description={t("domainSettingDescription")} /> {env.flags.usePangolinDns && domain.failed ? ( @@ -90,4 +107,4 @@ export default function DomainPageClient({
); -} \ No newline at end of file +} diff --git a/src/components/DomainsTable.tsx b/src/components/DomainsTable.tsx index 2c3abeb1a..fda219f34 100644 --- a/src/components/DomainsTable.tsx +++ b/src/components/DomainsTable.tsx @@ -16,6 +16,7 @@ import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { Badge } from "@app/components/ui/badge"; +import { Lock } from "lucide-react"; import { useTranslations } from "next-intl"; import CreateDomainForm from "@app/components/CreateDomainForm"; import { useToast } from "@app/hooks/useToast"; @@ -72,7 +73,11 @@ export default function DomainsTable({ domains, orgId }: Props) { const { org } = useOrgContext(); const queryClient = useQueryClient(); - const { data: rawDomains, isRefetching, refetch } = useQuery({ + const { + data: rawDomains, + isRefetching, + refetch + } = useQuery({ ...orgQueries.domains({ orgId }), initialData: domains as any, refetchInterval: durationToMs(10, "seconds") @@ -80,12 +85,15 @@ export default function DomainsTable({ domains, orgId }: Props) { const tableData = useMemo( () => - (rawDomains ?? []).map((d) => ({ - ...d, - baseDomain: toUnicode(d.baseDomain), - type: d.type ?? "", - errorMessage: d.errorMessage ?? null - } as DomainRow)), + (rawDomains ?? []).map( + (d) => + ({ + ...d, + baseDomain: toUnicode(d.baseDomain), + type: d.type ?? "", + errorMessage: d.errorMessage ?? null + }) as DomainRow + ), [rawDomains] ); @@ -198,12 +206,17 @@ export default function DomainsTable({ domains, orgId }: Props) { - + {t("failed", { fallback: "Failed" })} -

{errorMessage}

+

+ {errorMessage} +

@@ -220,12 +233,17 @@ export default function DomainsTable({ domains, orgId }: Props) { - + {t("pending")} -

{errorMessage}

+

+ {errorMessage} +

@@ -253,6 +271,25 @@ export default function DomainsTable({ domains, orgId }: Props) { ); + }, + cell: ({ row }) => { + const domain = row.original; + return ( + + {domain.baseDomain} + {domain.configManaged && ( + + + {t("configManaged", { + fallback: "Config Managed" + })} + + )} + + ); } }, ...(env.env.flags.usePangolinDns ? [typeColumn] : []), @@ -283,16 +320,18 @@ export default function DomainsTable({ domains, orgId }: Props) { {t("viewSettings")} - { - setSelectedDomain(domain); - setIsDeleteModalOpen(true); - }} - > - - {t("delete")} - - + {!domain.configManaged && ( + { + setSelectedDomain(domain); + setIsDeleteModalOpen(true); + }} + > + + {t("delete")} + + + )} {domain.failed && ( @@ -315,7 +354,9 @@ export default function DomainsTable({ domains, orgId }: Props) { href={`/${orgId}/settings/domains/${domain.domainId}`} >