mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-14 00:09:55 +02:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7b9c6d16d | |||
| 048e4fc73c | |||
| 71d9d8f010 | |||
| dd78c2cc08 | |||
| 295e38d2af | |||
| 02e4fe8b48 | |||
| ed46afd81a | |||
| 3dc9c100e9 | |||
| 02e97d6ae4 | |||
| d04740fede | |||
| b7c0669c38 |
@@ -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",
|
||||
|
||||
Generated
+2473
-3916
File diff suppressed because it is too large
Load Diff
+72
-72
@@ -32,49 +32,49 @@
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@asteasolutions/zod-to-openapi": "8.5.0",
|
||||
"@asteasolutions/zod-to-openapi": "9.1.0",
|
||||
"@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
|
||||
"@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.1.tgz",
|
||||
"@aws-sdk/client-s3": "3.1056.0",
|
||||
"@aws-sdk/client-s3": "3.1106.0",
|
||||
"@headlessui/react": "2.2.10",
|
||||
"@hookform/resolvers": "5.4.0",
|
||||
"@hookform/resolvers": "5.7.1",
|
||||
"@monaco-editor/react": "4.7.0",
|
||||
"@node-rs/argon2": "2.0.2",
|
||||
"@novnc/novnc": "^1.7.0",
|
||||
"@oslojs/crypto": "1.0.1",
|
||||
"@oslojs/encoding": "1.1.0",
|
||||
"@radix-ui/react-avatar": "1.1.11",
|
||||
"@radix-ui/react-checkbox": "1.3.3",
|
||||
"@radix-ui/react-collapsible": "1.1.12",
|
||||
"@radix-ui/react-dialog": "1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.16",
|
||||
"@radix-ui/react-avatar": "1.2.6",
|
||||
"@radix-ui/react-checkbox": "1.3.11",
|
||||
"@radix-ui/react-collapsible": "1.1.20",
|
||||
"@radix-ui/react-dialog": "1.1.23",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.24",
|
||||
"@radix-ui/react-icons": "1.3.2",
|
||||
"@radix-ui/react-label": "2.1.8",
|
||||
"@radix-ui/react-popover": "1.1.15",
|
||||
"@radix-ui/react-progress": "1.1.8",
|
||||
"@radix-ui/react-radio-group": "1.3.8",
|
||||
"@radix-ui/react-scroll-area": "1.2.10",
|
||||
"@radix-ui/react-select": "2.2.6",
|
||||
"@radix-ui/react-separator": "1.1.8",
|
||||
"@radix-ui/react-slot": "1.2.4",
|
||||
"@radix-ui/react-switch": "1.2.6",
|
||||
"@radix-ui/react-tabs": "1.1.13",
|
||||
"@radix-ui/react-toast": "1.2.15",
|
||||
"@radix-ui/react-tooltip": "1.2.8",
|
||||
"@radix-ui/react-label": "2.1.15",
|
||||
"@radix-ui/react-popover": "1.1.23",
|
||||
"@radix-ui/react-progress": "1.1.16",
|
||||
"@radix-ui/react-radio-group": "1.4.7",
|
||||
"@radix-ui/react-scroll-area": "1.2.18",
|
||||
"@radix-ui/react-select": "2.3.7",
|
||||
"@radix-ui/react-separator": "1.1.15",
|
||||
"@radix-ui/react-slot": "1.3.3",
|
||||
"@radix-ui/react-switch": "1.3.7",
|
||||
"@radix-ui/react-tabs": "1.1.21",
|
||||
"@radix-ui/react-toast": "1.2.23",
|
||||
"@radix-ui/react-tooltip": "1.2.16",
|
||||
"@react-email/body": "0.3.0",
|
||||
"@react-email/components": "1.0.12",
|
||||
"@react-email/render": "2.0.8",
|
||||
"@react-email/render": "2.1.0",
|
||||
"@react-email/tailwind": "2.0.7",
|
||||
"@simplewebauthn/browser": "13.3.0",
|
||||
"@simplewebauthn/server": "13.3.1",
|
||||
"@simplewebauthn/server": "13.3.2",
|
||||
"@tailwindcss/forms": "0.5.11",
|
||||
"@tanstack/react-query": "5.100.14",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@tanstack/react-query": "5.101.4",
|
||||
"@tanstack/react-table": "9.1.2",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"arctic": "3.7.0",
|
||||
"axios": "1.18.0",
|
||||
"axios": "1.19.0",
|
||||
"better-sqlite3": "11.9.1",
|
||||
"canvas-confetti": "1.9.4",
|
||||
"class-variance-authority": "0.7.1",
|
||||
@@ -86,61 +86,61 @@
|
||||
"d3": "7.9.0",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"express": "5.2.1",
|
||||
"express-rate-limit": "8.5.2",
|
||||
"express-rate-limit": "8.6.2",
|
||||
"glob": "13.0.6",
|
||||
"helmet": "8.2.0",
|
||||
"helmet": "8.3.0",
|
||||
"http-errors": "2.0.1",
|
||||
"input-otp": "1.4.2",
|
||||
"ioredis": "5.11.0",
|
||||
"ioredis": "6.0.0",
|
||||
"jmespath": "0.16.0",
|
||||
"js-yaml": "4.3.0",
|
||||
"js-yaml": "5.2.3",
|
||||
"jsonwebtoken": "9.0.3",
|
||||
"lucide-react": "1.17.0",
|
||||
"maxmind": "5.0.6",
|
||||
"lucide-react": "1.31.0",
|
||||
"maxmind": "5.0.7",
|
||||
"moment": "2.30.1",
|
||||
"next": "16.2.11",
|
||||
"next-intl": "4.13.0",
|
||||
"next": "16.3.0",
|
||||
"next-intl": "4.13.5",
|
||||
"next-themes": "0.4.6",
|
||||
"nextjs-toploader": "3.9.17",
|
||||
"node-cache": "5.1.2",
|
||||
"nodemailer": "9.0.1",
|
||||
"nodemailer": "9.0.5",
|
||||
"oslo": "1.2.1",
|
||||
"pg": "8.21.0",
|
||||
"posthog-node": "5.35.6",
|
||||
"pg": "8.23.0",
|
||||
"posthog-node": "5.48.1",
|
||||
"qrcode.react": "4.2.0",
|
||||
"react": "19.2.6",
|
||||
"react-day-picker": "9.14.0",
|
||||
"react-dom": "19.2.6",
|
||||
"react": "19.2.8",
|
||||
"react-day-picker": "10.0.1",
|
||||
"react-dom": "19.2.8",
|
||||
"react-easy-sort": "1.8.0",
|
||||
"react-hook-form": "7.76.1",
|
||||
"react-icons": "5.6.0",
|
||||
"recharts": "3.8.1",
|
||||
"react-hook-form": "7.85.0",
|
||||
"react-icons": "5.7.0",
|
||||
"recharts": "3.10.1",
|
||||
"reodotdev": "1.1.0",
|
||||
"semver": "7.8.1",
|
||||
"semver": "7.8.5",
|
||||
"sshpk": "1.18.0",
|
||||
"stripe": "22.2.0",
|
||||
"stripe": "22.4.0",
|
||||
"swagger-ui-express": "5.0.1",
|
||||
"tailwind-merge": "3.6.0",
|
||||
"topojson-client": "3.1.0",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"use-debounce": "10.1.1",
|
||||
"uuid": "14.0.0",
|
||||
"uuid": "14.0.1",
|
||||
"vaul": "1.1.2",
|
||||
"visionscarto-world-atlas": "1.0.0",
|
||||
"winston": "3.19.0",
|
||||
"winston-daily-rotate-file": "5.0.0",
|
||||
"ws": "8.21.0",
|
||||
"ws": "8.21.3",
|
||||
"yaml": "2.9.0",
|
||||
"yargs": "18.0.0",
|
||||
"yargs": "18.1.0",
|
||||
"zod": "4.4.3",
|
||||
"zod-validation-error": "5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dotenvx/dotenvx": "1.69.1",
|
||||
"@dotenvx/dotenvx": "2.20.1",
|
||||
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
||||
"@react-email/ui": "^6.5.0",
|
||||
"@tailwindcss/postcss": "4.3.0",
|
||||
"@tanstack/react-query-devtools": "5.100.14",
|
||||
"@react-email/ui": "^6.9.2",
|
||||
"@tailwindcss/postcss": "4.3.3",
|
||||
"@tanstack/react-query-devtools": "5.101.4",
|
||||
"@types/better-sqlite3": "7.6.13",
|
||||
"@types/cookie-parser": "1.4.10",
|
||||
"@types/cors": "2.8.19",
|
||||
@@ -151,36 +151,36 @@
|
||||
"@types/jmespath": "0.15.2",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/jsonwebtoken": "9.0.10",
|
||||
"@types/node": "25.9.1",
|
||||
"@types/nodemailer": "8.0.0",
|
||||
"@types/node": "26.2.0",
|
||||
"@types/nodemailer": "8.0.1",
|
||||
"@types/nprogress": "0.2.3",
|
||||
"@types/pg": "8.20.0",
|
||||
"@types/react": "19.2.15",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/semver": "7.7.1",
|
||||
"@types/sshpk": "1.17.4",
|
||||
"@types/pg": "8.21.0",
|
||||
"@types/react": "19.2.18",
|
||||
"@types/react-dom": "19.2.4",
|
||||
"@types/semver": "7.8.0",
|
||||
"@types/sshpk": "1.17.5",
|
||||
"@types/swagger-ui-express": "4.1.8",
|
||||
"@types/topojson-client": "3.1.5",
|
||||
"@types/ws": "8.18.1",
|
||||
"@types/yargs": "17.0.35",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"drizzle-kit": "0.31.10",
|
||||
"esbuild": "0.28.1",
|
||||
"esbuild-node-externals": "1.22.0",
|
||||
"eslint": "10.4.0",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"postcss": "8.5.15",
|
||||
"prettier": "3.8.3",
|
||||
"react-email": "6.5.0",
|
||||
"tailwindcss": "4.3.0",
|
||||
"tsc-alias": "1.8.17",
|
||||
"tsx": "4.22.3",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.60.0"
|
||||
"esbuild": "0.28.2",
|
||||
"esbuild-node-externals": "2.0.0",
|
||||
"eslint": "10.8.1",
|
||||
"eslint-config-next": "16.3.0",
|
||||
"postcss": "8.5.26",
|
||||
"prettier": "3.9.6",
|
||||
"react-email": "6.9.2",
|
||||
"tailwindcss": "4.3.3",
|
||||
"tsc-alias": "1.9.1",
|
||||
"tsx": "4.23.11",
|
||||
"typescript": "7.0.2",
|
||||
"typescript-eslint": "8.66.0"
|
||||
},
|
||||
"overrides": {
|
||||
"esbuild": "0.28.1",
|
||||
"esbuild": "0.28.2",
|
||||
"dompurify": "3.4.0",
|
||||
"postcss": "8.5.15"
|
||||
"postcss": "8.5.26"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -632,7 +632,6 @@ export const ResourcePolicySchema = z.object({
|
||||
})
|
||||
)
|
||||
)
|
||||
.max(50)
|
||||
.transform((v) => v.map((e) => e.toLowerCase()))
|
||||
.optional()
|
||||
.default([]),
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -107,7 +107,6 @@ const createResourcePolicyBodySchema = z.strictObject({
|
||||
})
|
||||
)
|
||||
)
|
||||
.max(50)
|
||||
.transform((v) => v.map((e) => e.toLowerCase()))
|
||||
.optional()
|
||||
.default([]),
|
||||
|
||||
@@ -19,7 +19,6 @@ const setResourcePolicyWhitelistBodySchema = z.strictObject({
|
||||
})
|
||||
)
|
||||
)
|
||||
.max(50)
|
||||
.transform((v) => v.map((e) => e.toLowerCase()))
|
||||
});
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ const setResourceWhitelistBodySchema = z.strictObject({
|
||||
})
|
||||
)
|
||||
)
|
||||
.max(50)
|
||||
.transform((v) => v.map((e) => e.toLowerCase()))
|
||||
});
|
||||
|
||||
|
||||
@@ -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}`
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -237,10 +237,13 @@ export default function Page() {
|
||||
return;
|
||||
}
|
||||
|
||||
const useOrgIdps =
|
||||
build === "saas" || env.app.identityProviderMode === "org";
|
||||
|
||||
const res = await api
|
||||
.get<
|
||||
AxiosResponse<ListIdpsResponse>
|
||||
>(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();
|
||||
|
||||
@@ -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: {
|
||||
) : (
|
||||
<DashboardLoginForm
|
||||
redirect={redirectUrl}
|
||||
idps={loginIdps}
|
||||
idps={isInvite ? [] : loginIdps}
|
||||
forceLogin={forceLogin}
|
||||
showOrgLogin={
|
||||
!isInvite &&
|
||||
|
||||
@@ -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<AvailableOption | null>(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
|
||||
|
||||
@@ -44,6 +44,7 @@ export default function InviteStatusCard({
|
||||
| "user_does_not_exist"
|
||||
| "not_logged_in"
|
||||
| "user_limit_exceeded"
|
||||
| "oidc_not_allowed"
|
||||
>("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({
|
||||
<p className="text-center">{t("inviteCreateUser")}</p>
|
||||
</div>
|
||||
);
|
||||
} else if (type === "oidc_not_allowed") {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-center mb-4">
|
||||
{t("inviteErrorOidcNotAllowed")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
} else if (type === "user_limit_exceeded") {
|
||||
return (
|
||||
<div>
|
||||
@@ -199,6 +214,10 @@ export default function InviteStatusCard({
|
||||
);
|
||||
} else if (type === "user_does_not_exist") {
|
||||
return <Button onClick={goToSignup}>{t("createAnAccount")}</Button>;
|
||||
} else if (type === "oidc_not_allowed") {
|
||||
return (
|
||||
<Button onClick={goToLogin}>{t("inviteLogInOtherUser")}</Button>
|
||||
);
|
||||
} else if (type === "user_limit_exceeded") {
|
||||
return (
|
||||
<Button
|
||||
|
||||
@@ -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) {
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("orgId")}</InfoSectionTitle>
|
||||
<InfoSectionContent>{org.org.orgId}</InfoSectionContent>
|
||||
<InfoSectionContent>
|
||||
<CopyToClipboard text={org.org.orgId} />
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("subnet")}</InfoSectionTitle>
|
||||
|
||||
@@ -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<TFieldValues extends FieldValues> = {
|
||||
form: Pick<
|
||||
UseFormReturn<TFieldValues>,
|
||||
"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<TFieldValues extends FieldValues>({
|
||||
disabled
|
||||
}: OrgRolesTagFieldProps<TFieldValues>) {
|
||||
const t = useTranslations();
|
||||
const isPopoverOpenRef = useRef(false);
|
||||
const lastValidRolesRef = useRef<SelectedRole[]>(
|
||||
(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<TFieldValues extends FieldValues>({
|
||||
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<TFieldValues extends FieldValues>({
|
||||
name={name}
|
||||
render={({ field }) => {
|
||||
const selectedRoles = (field.value ?? []) as SelectedRole[];
|
||||
if (!isPopoverOpenRef.current && selectedRoles.length > 0) {
|
||||
lastValidRolesRef.current = selectedRoles;
|
||||
}
|
||||
|
||||
return (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
@@ -129,7 +83,6 @@ export default function OrgRolesTagField<TFieldValues extends FieldValues>({
|
||||
orgId={orgId}
|
||||
selectedRoles={selectedRoles}
|
||||
onSelectRoles={setRoleTags}
|
||||
onPopoverOpenChange={handlePopoverOpenChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -56,6 +56,7 @@ type SmartLoginFormProps = {
|
||||
defaultUser?: string;
|
||||
orgSignIn?: OrgSignInConfig;
|
||||
lastUsedIdp?: (LoginFormIDP & { orgId?: string }) | null;
|
||||
inviteMode?: boolean;
|
||||
};
|
||||
|
||||
type ViewState =
|
||||
@@ -93,7 +94,8 @@ export default function SmartLoginForm({
|
||||
forceLogin,
|
||||
defaultUser,
|
||||
orgSignIn,
|
||||
lastUsedIdp
|
||||
lastUsedIdp,
|
||||
inviteMode = false
|
||||
}: SmartLoginFormProps) {
|
||||
const router = useRouter();
|
||||
const { env } = useEnvContext();
|
||||
@@ -136,6 +138,10 @@ export default function SmartLoginForm({
|
||||
return;
|
||||
}
|
||||
|
||||
const signupUrl = redirect
|
||||
? `/auth/signup?email=${encodeURIComponent(identifier)}&redirect=${encodeURIComponent(redirect)}&fromSmartLogin=true`
|
||||
: `/auth/signup?email=${encodeURIComponent(identifier)}&fromSmartLogin=true`;
|
||||
|
||||
if (!result.found || result.accounts.length === 0) {
|
||||
// No accounts found
|
||||
if (!isEmail || forceLogin) {
|
||||
@@ -147,13 +153,36 @@ export default function SmartLoginForm({
|
||||
return;
|
||||
}
|
||||
// Valid email but no accounts and not forceLogin - redirect to signup
|
||||
const signupUrl = redirect
|
||||
? `/auth/signup?email=${encodeURIComponent(identifier)}&redirect=${encodeURIComponent(redirect)}&fromSmartLogin=true`
|
||||
: `/auth/signup?email=${encodeURIComponent(identifier)}&fromSmartLogin=true`;
|
||||
router.push(signupUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// Invite accept only supports internal (password) accounts
|
||||
if (inviteMode) {
|
||||
const internalAccount = result.accounts.find(
|
||||
(acc) => acc.hasInternalAuth
|
||||
);
|
||||
if (internalAccount) {
|
||||
setViewState({
|
||||
type: "password",
|
||||
identifier,
|
||||
account: internalAccount
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEmail && !forceLogin) {
|
||||
router.push(signupUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
form.setError("identifier", {
|
||||
type: "manual",
|
||||
message: t("inviteLoginInternalOnly")
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine which view to show
|
||||
const account = result.accounts[0]; // Use first account for now
|
||||
|
||||
|
||||
@@ -15,11 +15,14 @@ import {
|
||||
} from "@app/components/ui/popover";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { Check, ChevronDown, ChevronsUpDown } from "lucide-react";
|
||||
import { Check, ChevronDown, Plus } from "lucide-react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import { build } from "@server/build";
|
||||
|
||||
type LauncherOrgSelectorProps = {
|
||||
orgId?: string;
|
||||
@@ -31,9 +34,16 @@ export function LauncherOrgSelector({ orgId, orgs }: LauncherOrgSelectorProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
const { user } = useUserContext();
|
||||
|
||||
const selectedOrg = orgs?.find((org) => org.orgId === orgId);
|
||||
|
||||
let canCreateOrg = !env.flags.disableUserCreateOrg || user.serverAdmin;
|
||||
if (build === "saas" && user.type !== "internal") {
|
||||
canCreateOrg = false;
|
||||
}
|
||||
|
||||
const sortedOrgs = useMemo(() => {
|
||||
if (!orgs?.length) {
|
||||
return orgs ?? [];
|
||||
@@ -108,6 +118,22 @@ export function LauncherOrgSelector({ orgId, orgs }: LauncherOrgSelectorProps) {
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
{canCreateOrg && (
|
||||
<div className="p-2 border-t border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start h-8 font-normal text-muted-foreground"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
router.push("/setup");
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 mr-2" />
|
||||
{t("setupNewOrg")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user