From e91c344e64e0a4653e8adfff48befaed6935dace Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 7 Aug 2026 10:21:15 -0400 Subject: [PATCH 1/9] Update link to be correct --- .../remote-exit-nodes/[remoteExitNodeId]/networking/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/networking/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/networking/page.tsx index e0d03f771..42ccc4242 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/networking/page.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/networking/page.tsx @@ -181,7 +181,7 @@ export default function NetworkingPage() { {t("remoteExitNodeNetworkingDescription")} Date: Mon, 10 Aug 2026 10:38:40 -0400 Subject: [PATCH 2/9] add missing resource policy actions to api key closes #3542 --- messages/en-US.json | 3 +++ server/routers/integration.ts | 8 ++++---- src/components/PermissionsSelectBox.tsx | 3 +++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index aff3be28b..eed3ef5b4 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1449,8 +1449,11 @@ "actionSetResourcePincode": "Set Resource Pincode", "actionSetResourceEmailWhitelist": "Set Resource Email Whitelist", "actionGetResourceEmailWhitelist": "Get Resource Email Whitelist", + "actionListResourcePolicies": "List Resource Policies", + "actionCreateResourcePolicy": "Create Resource Policy", "actionGetResourcePolicy": "Get Resource Policy", "actionUpdateResourcePolicy": "Update Resource Policy", + "actionDeleteResourcePolicy": "Delete Resource Policy", "actionSetResourcePolicyUsers": "Set Resource Policy Users", "actionSetResourcePolicyRoles": "Set Resource Policy Roles", "actionSetResourcePolicyPassword": "Set Resource Policy Password", diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 13498ad25..b28c47a7a 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -726,8 +726,8 @@ authenticated.post( verifyApiKeyResourcePolicyAccess, verifyApiKeyRoleAccess, verifyLimits, - verifyUserHasAction(ActionsEnum.setResourcePolicyUsers), - verifyUserHasAction(ActionsEnum.setResourcePolicyRoles), + verifyApiKeyHasAction(ActionsEnum.setResourcePolicyUsers), + verifyApiKeyHasAction(ActionsEnum.setResourcePolicyRoles), logActionAudit(ActionsEnum.setResourcePolicyUsers), logActionAudit(ActionsEnum.setResourcePolicyRoles), policy.setResourcePolicyAccessControl @@ -742,8 +742,8 @@ authenticated.put( verifyApiKeyResourcePolicyAccess, verifyApiKeyRoleAccess, verifyLimits, - verifyUserHasAction(ActionsEnum.setResourcePolicyUsers), - verifyUserHasAction(ActionsEnum.setResourcePolicyRoles), + verifyApiKeyHasAction(ActionsEnum.setResourcePolicyUsers), + verifyApiKeyHasAction(ActionsEnum.setResourcePolicyRoles), logActionAudit(ActionsEnum.setResourcePolicyUsers), logActionAudit(ActionsEnum.setResourcePolicyRoles), policy.setResourcePolicyAccessControl diff --git a/src/components/PermissionsSelectBox.tsx b/src/components/PermissionsSelectBox.tsx index 48868722e..486841d14 100644 --- a/src/components/PermissionsSelectBox.tsx +++ b/src/components/PermissionsSelectBox.tsx @@ -115,8 +115,11 @@ function getActionsCategories(root: boolean) { }, "Resource Policy": { + [t("actionListResourcePolicies")]: "listResourcePolicies", + [t("actionCreateResourcePolicy")]: "createResourcePolicy", [t("actionGetResourcePolicy")]: "getResourcePolicy", [t("actionUpdateResourcePolicy")]: "updateResourcePolicy", + [t("actionDeleteResourcePolicy")]: "deleteResourcePolicy", [t("actionSetResourcePolicyUsers")]: "setResourcePolicyUsers", [t("actionSetResourcePolicyRoles")]: "setResourcePolicyRoles", [t("actionSetResourcePolicyPassword")]: "setResourcePolicyPassword", From 02e97d6ae4f59f4b2098e70724f6db8ee729cfd6 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 10 Aug 2026 10:44:25 -0400 Subject: [PATCH 3/9] add copy button to org id --- src/components/OrgInfoCard.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/OrgInfoCard.tsx b/src/components/OrgInfoCard.tsx index 796855609..392a5f17c 100644 --- a/src/components/OrgInfoCard.tsx +++ b/src/components/OrgInfoCard.tsx @@ -8,6 +8,7 @@ import { InfoSections, InfoSectionTitle } from "@app/components/InfoSection"; +import CopyToClipboard from "@app/components/CopyToClipboard"; import { useTranslations } from "next-intl"; type OrgInfoCardProps = {}; @@ -26,7 +27,9 @@ export default function OrgInfoCard({}: OrgInfoCardProps) { {t("orgId")} - {org.org.orgId} + + + {t("subnet")} From 3dc9c100e92f6b5cfc525c582ede6657df010bb9 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 10 Aug 2026 11:02:44 -0400 Subject: [PATCH 4/9] better form feedback when saving roles --- .../users/[userId]/access-controls/page.tsx | 44 +++++---------- src/components/OrgRolesTagField.tsx | 55 ++----------------- 2 files changed, 18 insertions(+), 81 deletions(-) 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 a3ed1e2df..2f94e78da 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 @@ -38,18 +38,6 @@ import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; -const accessControlsFormSchema = z.object({ - username: z.string(), - autoProvisioned: z.boolean(), - roles: z.array( - z.object({ - id: z.string(), - text: z.string(), - isAdmin: z.boolean().optional() - }) - ) -}); - export default function AccessControlsPage() { const { orgUser: user, updateOrgUser } = userOrgUserContext(); const { user: sessionUser } = useUserContext(); @@ -69,6 +57,20 @@ export default function AccessControlsPage() { (build === "enterprise" && !isPaid) || (build === "oss" && !isPaid)); + const accessControlsFormSchema = z.object({ + username: z.string(), + autoProvisioned: z.boolean(), + roles: z + .array( + z.object({ + id: z.string(), + text: z.string(), + isAdmin: z.boolean().optional() + }) + ) + .min(1, { message: t("accessRoleSelectPlease") }) + }); + const form = useForm({ resolver: zodResolver(accessControlsFormSchema), defaultValues: { @@ -108,15 +110,6 @@ export default function AccessControlsPage() { async function executeSave() { const values = form.getValues(); - if (values.roles.length === 0) { - toast({ - variant: "destructive", - title: t("accessRoleRequired"), - description: t("accessRoleSelectPlease") - }); - return; - } - setIsSaving(true); try { const roleIds = values.roles.map((r) => parseInt(r.id, 10)); @@ -170,15 +163,6 @@ export default function AccessControlsPage() { const values = form.getValues(); - if (values.roles.length === 0) { - toast({ - variant: "destructive", - title: t("accessRoleRequired"), - description: t("accessRoleSelectPlease") - }); - return; - } - const willHaveAdminRole = values.roles.some((r) => r.isAdmin === true); const isRemovingOwnAdmin = diff --git a/src/components/OrgRolesTagField.tsx b/src/components/OrgRolesTagField.tsx index eef1e0570..32d96794a 100644 --- a/src/components/OrgRolesTagField.tsx +++ b/src/components/OrgRolesTagField.tsx @@ -9,17 +9,15 @@ import { FormMessage } from "@app/components/ui/form"; -import { toast } from "@app/hooks/useToast"; import { useTranslations } from "next-intl"; -import { useRef } from "react"; import type { FieldValues, Path, UseFormReturn } from "react-hook-form"; import { RolesSelector, type SelectedRole } from "./roles-selector"; type OrgRolesTagFieldProps = { form: Pick< UseFormReturn, - "control" | "getValues" | "setValue" + "control" | "getValues" | "setValue" | "clearErrors" >; orgId: string; /** Field in the form that holds Tag[] (role tags). Default: `"roles"`. */ @@ -42,46 +40,6 @@ export default function OrgRolesTagField({ disabled }: OrgRolesTagFieldProps) { const t = useTranslations(); - const isPopoverOpenRef = useRef(false); - const lastValidRolesRef = useRef( - (form.getValues(name) as SelectedRole[]) ?? [] - ); - - function validateRolesSelection() { - const current = form.getValues(name) as SelectedRole[]; - - if (current.length === 0 && lastValidRolesRef.current.length > 0) { - form.setValue(name, lastValidRolesRef.current as never, { - shouldDirty: true - }); - toast({ - variant: "destructive", - title: t("accessRoleRequired"), - description: t("accessRoleSelectPlease") - }); - return false; - } - - if (current.length > 0) { - lastValidRolesRef.current = current; - } - - return true; - } - - function handlePopoverOpenChange(open: boolean) { - isPopoverOpenRef.current = open; - - if (open) { - const current = form.getValues(name) as SelectedRole[]; - if (current.length > 0) { - lastValidRolesRef.current = current; - } - return; - } - - validateRolesSelection(); - } function setRoleTags(nextValue: SelectedRole[]) { const prev = form.getValues(name) as SelectedRole[]; @@ -99,15 +57,14 @@ export default function OrgRolesTagField({ form.setValue(name, [prev[prev.length - 1]] as never, { shouldDirty: true }); + form.clearErrors(name); return; } form.setValue(name, next as never, { shouldDirty: true }); - if (next.length > 0 && !isPopoverOpenRef.current) { - lastValidRolesRef.current = next; - } else if (!isPopoverOpenRef.current) { - validateRolesSelection(); + if (next.length > 0) { + form.clearErrors(name); } } @@ -117,9 +74,6 @@ export default function OrgRolesTagField({ name={name} render={({ field }) => { const selectedRoles = (field.value ?? []) as SelectedRole[]; - if (!isPopoverOpenRef.current && selectedRoles.length > 0) { - lastValidRolesRef.current = selectedRoles; - } return ( @@ -129,7 +83,6 @@ export default function OrgRolesTagField({ orgId={orgId} selectedRoles={selectedRoles} onSelectRoles={setRoleTags} - onPopoverOpenChange={handlePopoverOpenChange} disabled={disabled} /> From ed46afd81a2df778fd79a792f53055957e638c6c Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 10 Aug 2026 11:15:27 -0400 Subject: [PATCH 5/9] add subscription override column --- server/db/pg/schema/privateSchema.ts | 3 +- server/db/sqlite/schema/privateSchema.ts | 3 +- .../hooks/handleSubscriptionDeleted.ts | 9 +++++ .../hooks/handleSubscriptionUpdated.ts | 34 +++++++++++++------ 4 files changed, 37 insertions(+), 12 deletions(-) diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index cbe8a4039..e41498264 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -95,7 +95,8 @@ export const subscriptions = pgTable("subscriptions", { billingCycleAnchor: bigint("billingCycleAnchor", { mode: "number" }), expiresAt: bigint("expiresAt", { mode: "number" }), trial: boolean("trial").default(false), - type: varchar("type", { length: 50 }) // tier1, tier2, tier3, or license + type: varchar("type", { length: 50 }), // tier1, tier2, tier3, or license + override: boolean("override").default(false) }); export const subscriptionItems = pgTable("subscriptionItems", { diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index b75836e29..f8d2f5f09 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -89,7 +89,8 @@ export const subscriptions = sqliteTable("subscriptions", { expiresAt: integer("expiresAt"), trial: integer("trial", { mode: "boolean" }).default(false), billingCycleAnchor: integer("billingCycleAnchor"), - type: text("type") // tier1, tier2, tier3, or license + type: text("type"), // tier1, tier2, tier3, or license + override: integer("override", { mode: "boolean" }).default(false) }); export const subscriptionItems = sqliteTable("subscriptionItems", { diff --git a/server/private/routers/billing/hooks/handleSubscriptionDeleted.ts b/server/private/routers/billing/hooks/handleSubscriptionDeleted.ts index 962cdd424..dd44f4101 100644 --- a/server/private/routers/billing/hooks/handleSubscriptionDeleted.ts +++ b/server/private/routers/billing/hooks/handleSubscriptionDeleted.ts @@ -53,6 +53,15 @@ export async function handleSubscriptionDeleted( return; } + // If the subscription has been manually overridden, we lock it down + // so Stripe can no longer change (or delete) its status locally. + if (existingSubscription.override === true) { + logger.info( + `Subscription ${subscription.id} is locked (override=true). Ignoring deletion event from Stripe.` + ); + return; + } + await db .delete(subscriptions) .where(eq(subscriptions.subscriptionId, subscription.id)); diff --git a/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts b/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts index e1ec7a7b9..13df7910f 100644 --- a/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts +++ b/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts @@ -68,13 +68,27 @@ export async function handleSubscriptionUpdated( const type = getSubType(fullSubscription); const previousType = existingSubscription.type as SubscriptionType | null; + // If the subscription has been manually overridden, we lock the + // status down so Stripe webhooks can no longer change it. + const isLocked = existingSubscription.override === true; + if (isLocked) { + logger.info( + `Subscription ${subscription.id} is locked (override=true). Ignoring status change from Stripe (would have been ${subscription.status}).` + ); + } + const effectiveStatus = isLocked + ? existingSubscription.status + : subscription.status; + await db .update(subscriptions) .set({ - status: subscription.status, - canceledAt: subscription.canceled_at - ? subscription.canceled_at - : null, + status: effectiveStatus, + canceledAt: isLocked + ? existingSubscription.canceledAt + : subscription.canceled_at + ? subscription.canceled_at + : null, updatedAt: Math.floor(Date.now() / 1000), billingCycleAnchor: subscription.billing_cycle_anchor, type: type @@ -275,23 +289,23 @@ export async function handleSubscriptionUpdated( // we only need to handle the limit lifecycle for saas subscriptions not for the licenses await handleSubscriptionLifesycle( customer.orgId, - subscription.status, + effectiveStatus, type ); // Handle feature lifecycle when subscription is canceled or becomes unpaid if ( - subscription.status === "canceled" || - subscription.status === "unpaid" || - subscription.status === "incomplete_expired" + effectiveStatus === "canceled" || + effectiveStatus === "unpaid" || + effectiveStatus === "incomplete_expired" ) { logger.info( - `Subscription ${subscription.id} for org ${customer.orgId} is ${subscription.status}, disabling paid features` + `Subscription ${subscription.id} for org ${customer.orgId} is ${effectiveStatus}, disabling paid features` ); await handleTierChange(customer.orgId, null, previousType ?? undefined); } } else if (type === "license") { - if (subscription.status === "canceled" || subscription.status == "unpaid" || subscription.status == "incomplete_expired") { + if (effectiveStatus === "canceled" || effectiveStatus == "unpaid" || effectiveStatus == "incomplete_expired") { try { // WARNING: // this invalidates ALL OF THE ENTERPRISE LICENSES for this orgId From 02e4fe8b48fdbe2872266a717f87ce0b9929436e Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 11 Aug 2026 09:40:55 -0400 Subject: [PATCH 6/9] Show the provided domains when changing properly --- src/components/DomainPicker.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/components/DomainPicker.tsx b/src/components/DomainPicker.tsx index e06c3e57d..bf25fcfce 100644 --- a/src/components/DomainPicker.tsx +++ b/src/components/DomainPicker.tsx @@ -53,7 +53,7 @@ import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { usePaidStatus } from "@/hooks/usePaidStatus"; import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; import { toUnicode } from "punycode"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useUserContext } from "@app/hooks/useUserContext"; type AvailableOption = { @@ -166,8 +166,19 @@ export default function DomainPicker({ const [selectedProvidedDomain, setSelectedProvidedDomain] = useState(null); + // Only run the initial base-domain selection once the domains have + // loaded. This must not re-run on later `defaultDomainId`/`defaultSubdomain` + // changes, because selecting a provided (namespace) domain calls + // onDomainChange(null), which the parent form echoes back as + // defaultDomainId/defaultSubdomain becoming undefined — re-running this + // effect on that change would immediately snap the selector back to the + // organization domain, making provided domains unselectable whenever one + // was already set. + const didSelectInitialDomainRef = useRef(false); + useEffect(() => { - if (!loadingDomains) { + if (!loadingDomains && !didSelectInitialDomainRef.current) { + didSelectInitialDomainRef.current = true; let domainOptionToSelect: DomainOption | null = null; if (organizationDomains.length > 0) { // Select the first organization domain or the one provided from props From 295e38d2af6314b25daf5e264dc9e594c1a97165 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 12 Aug 2026 09:11:10 -0400 Subject: [PATCH 7/9] Remove arbitrary max 50 limit from email whitelist Fix #3568 --- server/lib/blueprints/types.ts | 1 - server/private/routers/policy/createResourcePolicy.ts | 1 - server/routers/policy/setResourcePolicyWhitelist.ts | 1 - server/routers/resource/setResourceWhitelist.ts | 1 - server/routers/user/listUsers.ts | 2 +- 5 files changed, 1 insertion(+), 5 deletions(-) diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index 828b3bd15..299819656 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -632,7 +632,6 @@ export const ResourcePolicySchema = z.object({ }) ) ) - .max(50) .transform((v) => v.map((e) => e.toLowerCase())) .optional() .default([]), diff --git a/server/private/routers/policy/createResourcePolicy.ts b/server/private/routers/policy/createResourcePolicy.ts index 5cca3f446..8131603dd 100644 --- a/server/private/routers/policy/createResourcePolicy.ts +++ b/server/private/routers/policy/createResourcePolicy.ts @@ -107,7 +107,6 @@ const createResourcePolicyBodySchema = z.strictObject({ }) ) ) - .max(50) .transform((v) => v.map((e) => e.toLowerCase())) .optional() .default([]), diff --git a/server/routers/policy/setResourcePolicyWhitelist.ts b/server/routers/policy/setResourcePolicyWhitelist.ts index 2cdd94e98..d7e8fa28d 100644 --- a/server/routers/policy/setResourcePolicyWhitelist.ts +++ b/server/routers/policy/setResourcePolicyWhitelist.ts @@ -19,7 +19,6 @@ const setResourcePolicyWhitelistBodySchema = z.strictObject({ }) ) ) - .max(50) .transform((v) => v.map((e) => e.toLowerCase())) }); diff --git a/server/routers/resource/setResourceWhitelist.ts b/server/routers/resource/setResourceWhitelist.ts index e6df96de8..ca23cbd4c 100644 --- a/server/routers/resource/setResourceWhitelist.ts +++ b/server/routers/resource/setResourceWhitelist.ts @@ -24,7 +24,6 @@ const setResourceWhitelistBodySchema = z.strictObject({ }) ) ) - .max(50) .transform((v) => v.map((e) => e.toLowerCase())) }); diff --git a/server/routers/user/listUsers.ts b/server/routers/user/listUsers.ts index c74f48468..66438eaf3 100644 --- a/server/routers/user/listUsers.ts +++ b/server/routers/user/listUsers.ts @@ -107,7 +107,7 @@ const listUsersSchema = z.strictObject({ .filter((n) => Number.isInteger(n) && n > 0); const unique = [...new Set(nums)]; return unique.length ? unique : undefined; - }, z.array(z.number().int().positive()).max(50).optional()) + }, z.array(z.number().int().positive()).optional()) .openapi({ description: "Filter users who have any of these role ids in the organization (repeat query param)" From dd78c2cc082adf541a6747ccf59a6905f7bbd163 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 12 Aug 2026 15:20:41 -0400 Subject: [PATCH 8/9] dont show servers idps in create user when idp mode is org on enterprise --- server/lib/idp/idpExistsForOrg.ts | 3 ++- server/routers/user/createOrgUser.ts | 11 +++++++++++ src/app/[orgId]/settings/access/users/create/page.tsx | 8 +++++--- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/server/lib/idp/idpExistsForOrg.ts b/server/lib/idp/idpExistsForOrg.ts index 52530e7ba..772e69bcf 100644 --- a/server/lib/idp/idpExistsForOrg.ts +++ b/server/lib/idp/idpExistsForOrg.ts @@ -1,8 +1,9 @@ import { db, idp, idpOrg, Transaction } from "@server/db"; import { and, eq } from "drizzle-orm"; +import { build } from "@server/build"; export function isOrgIdentityProviderMode(): boolean { - return process.env.IDENTITY_PROVIDER_MODE === "org"; + return build === "saas" || process.env.IDENTITY_PROVIDER_MODE === "org"; } /** diff --git a/server/routers/user/createOrgUser.ts b/server/routers/user/createOrgUser.ts index d80d85999..ea02884fd 100644 --- a/server/routers/user/createOrgUser.ts +++ b/server/routers/user/createOrgUser.ts @@ -20,6 +20,7 @@ import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; import { assignUserToOrg } from "@server/lib/userOrg"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { isOrgRebuildRateLimited } from "@server/lib/rebuildClientAssociations"; +import { idpExistsForOrg } from "@server/lib/idp/idpExistsForOrg"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty() @@ -239,6 +240,16 @@ export async function createOrgUser( ); } + const providerExists = await idpExistsForOrg(idpId, orgId); + if (!providerExists) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Identity provider not found in this organization" + ) + ); + } + const [idpRes] = await db .select() .from(idp) diff --git a/src/app/[orgId]/settings/access/users/create/page.tsx b/src/app/[orgId]/settings/access/users/create/page.tsx index cafbca55f..4f6a2b974 100644 --- a/src/app/[orgId]/settings/access/users/create/page.tsx +++ b/src/app/[orgId]/settings/access/users/create/page.tsx @@ -237,10 +237,13 @@ export default function Page() { return; } + const useOrgIdps = + build === "saas" || env.app.identityProviderMode === "org"; + const res = await api .get< AxiosResponse - >(build === "saas" ? `/org/${orgId}/idp` : "/idp") + >(useOrgIdps ? `/org/${orgId}/idp` : "/idp") .catch((e) => { console.error(e); toast({ @@ -301,8 +304,7 @@ export default function Page() { ); const [isSubmittingExternal, setIsSubmittingExternal] = useState(false); - const loading = - isSubmittingInternal || isSubmittingExternal; + const loading = isSubmittingInternal || isSubmittingExternal; async function onSubmitInternal() { const isValid = await internalForm.trigger(); From 71d9d8f0105e9829193f5174d5ebf8e16a6f7d31 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 12 Aug 2026 16:25:20 -0400 Subject: [PATCH 9/9] fix accept invite as idp user --- messages/en-US.json | 2 + server/routers/user/acceptInvite.ts | 34 ++++++++++++----- src/app/auth/login/page.tsx | 7 +++- src/components/InviteStatusCard.tsx | 19 ++++++++++ src/components/SmartLoginForm.tsx | 37 +++++++++++++++++-- .../resource-launcher/LauncherOrgSelector.tsx | 28 +++++++++++++- 6 files changed, 110 insertions(+), 17 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index eed3ef5b4..ebf25900c 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -43,6 +43,8 @@ "inviteLoginUser": "Please make sure you're logged in as the correct user.", "inviteErrorNoUser": "We're sorry, but it looks like the invite you're trying to access is not for a user that exists.", "inviteCreateUser": "Please create an account first.", + "inviteErrorOidcNotAllowed": "Invites can only be accepted by internal accounts. Sign out and log in with your password for this email.", + "inviteLoginInternalOnly": "Invites require an internal account with a password. Create an account or sign in with your password.", "goHome": "Go Home", "inviteLogInOtherUser": "Log In as a Different User", "createAnAccount": "Create an Account", diff --git a/server/routers/user/acceptInvite.ts b/server/routers/user/acceptInvite.ts index c912fea0e..acd9cd011 100644 --- a/server/routers/user/acceptInvite.ts +++ b/server/routers/user/acceptInvite.ts @@ -22,6 +22,7 @@ import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsFor import { build } from "@server/build"; import { assignUserToOrg } from "@server/lib/userOrg"; import { isOrgRebuildRateLimited } from "@server/lib/rebuildClientAssociations"; +import { UserType } from "@server/types/UserTypes"; const acceptInviteBodySchema = z.strictObject({ token: z.string(), @@ -66,12 +67,17 @@ export async function acceptInvite( ); } - const existingUser = await db + const [existingInternalUser] = await db .select() .from(users) - .where(eq(users.email, existingInvite.email)) + .where( + and( + eq(users.email, existingInvite.email), + eq(users.type, UserType.Internal) + ) + ) .limit(1); - if (!existingUser.length) { + if (!existingInternalUser) { return next( createHttpError( HttpCode.BAD_REQUEST, @@ -80,9 +86,8 @@ export async function acceptInvite( ); } - const { user, session } = await verifySession(req); + const { user } = await verifySession(req); - // at this point we know the user exists if (!user) { return next( createHttpError( @@ -92,7 +97,7 @@ export async function acceptInvite( ); } - if (user && user.email !== existingInvite.email) { + if (user.email !== existingInvite.email) { return next( createHttpError( HttpCode.BAD_REQUEST, @@ -101,6 +106,15 @@ export async function acceptInvite( ); } + if (user.type !== UserType.Internal) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invites can only be accepted by internal users." + ) + ); + } + if (build == "saas") { const usage = await usageService.getUsage( existingInvite.orgId, @@ -195,7 +209,7 @@ export async function acceptInvite( await assignUserToOrg( org, { - userId: existingUser[0].userId, + userId: user.userId, orgId: existingInvite.orgId }, inviteRoleIds, @@ -208,13 +222,13 @@ export async function acceptInvite( .where(eq(userInvites.inviteId, inviteId)); logger.debug( - `User ${existingUser[0].userId} accepted invite to org ${existingInvite.orgId}` + `User ${user.userId} accepted invite to org ${existingInvite.orgId}` ); }); - calculateUserClientsForOrgs(existingUser[0].userId).catch((e) => { + calculateUserClientsForOrgs(user.userId).catch((e) => { logger.error( - `Failed to calculate user clients after accepting invite for user ${existingUser[0].userId}: ${e}` + `Failed to calculate user clients after accepting invite for user ${user.userId}: ${e}` ); }); diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index 31a626281..db523f650 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -193,7 +193,10 @@ export default async function Page(props: { redirect={redirectUrl} forceLogin={forceLogin} defaultUser={defaultUser} - lastUsedIdp={lastUsedIdpForSmartLogin} + inviteMode={isInvite} + lastUsedIdp={ + isInvite ? null : lastUsedIdpForSmartLogin + } orgSignIn={ !isInvite && (build === "saas" || @@ -213,7 +216,7 @@ export default async function Page(props: { ) : ( ("rejected"); useEffect(() => { @@ -69,6 +70,12 @@ export default function InviteStatusCard({ function cardType() { if (error.includes("Invite is not for this user")) { return "wrong_user"; + } else if ( + error.includes( + "Invites can only be accepted by internal users." + ) + ) { + return "oidc_not_allowed"; } else if ( error.includes( "User does not exist. Please create an account first." @@ -166,6 +173,14 @@ export default function InviteStatusCard({

{t("inviteCreateUser")}

); + } else if (type === "oidc_not_allowed") { + return ( +
+

+ {t("inviteErrorOidcNotAllowed")} +

+
+ ); } else if (type === "user_limit_exceeded") { return (
@@ -199,6 +214,10 @@ export default function InviteStatusCard({ ); } else if (type === "user_does_not_exist") { return ; + } else if (type === "oidc_not_allowed") { + return ( + + ); } else if (type === "user_limit_exceeded") { return ( +
+ )} );