mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-28 15:56:39 +00:00
Remove site kick
This commit is contained in:
@@ -8,77 +8,60 @@ export type LimitSet = Partial<{
|
|||||||
}>;
|
}>;
|
||||||
|
|
||||||
export const sandboxLimitSet: LimitSet = {
|
export const sandboxLimitSet: LimitSet = {
|
||||||
[FeatureId.SITES]: { value: 1, description: "Sandbox limit" }, // 1 site up for 2 days
|
|
||||||
[FeatureId.USERS]: { value: 1, description: "Sandbox limit" },
|
[FeatureId.USERS]: { value: 1, description: "Sandbox limit" },
|
||||||
[FeatureId.EGRESS_DATA_MB]: { value: 1000, description: "Sandbox limit" }, // 1 GB
|
[FeatureId.SITES]: { value: 1, description: "Sandbox limit" },
|
||||||
[FeatureId.DOMAINS]: { value: 0, description: "Sandbox limit" },
|
[FeatureId.DOMAINS]: { value: 0, description: "Sandbox limit" },
|
||||||
[FeatureId.REMOTE_EXIT_NODES]: { value: 0, description: "Sandbox limit" }
|
[FeatureId.REMOTE_EXIT_NODES]: { value: 0, description: "Sandbox limit" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const freeLimitSet: LimitSet = {
|
export const freeLimitSet: LimitSet = {
|
||||||
[FeatureId.SITES]: { value: 3, description: "Free tier limit" }, // 1 site up for 32 days
|
[FeatureId.USERS]: { value: 5, description: "Starter limit" },
|
||||||
[FeatureId.USERS]: { value: 3, description: "Free tier limit" },
|
[FeatureId.SITES]: { value: 5, description: "Starter limit" },
|
||||||
[FeatureId.EGRESS_DATA_MB]: {
|
[FeatureId.DOMAINS]: { value: 5, description: "Starter limit" },
|
||||||
value: 25000,
|
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Starter limit" },
|
||||||
description: "Free tier limit"
|
|
||||||
}, // 25 GB
|
|
||||||
[FeatureId.DOMAINS]: { value: 3, description: "Free tier limit" },
|
|
||||||
[FeatureId.REMOTE_EXIT_NODES]: { value: 0, description: "Free tier limit" }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const homeLabLimitSet: LimitSet = {
|
export const tier1LimitSet: LimitSet = {
|
||||||
[FeatureId.SITES]: { value: 3, description: "Home lab limit" }, // 1 site up for 32 days
|
[FeatureId.USERS]: { value: 7, description: "Home limit" },
|
||||||
[FeatureId.USERS]: { value: 3, description: "Home lab limit" },
|
[FeatureId.SITES]: { value: 10, description: "Home limit" },
|
||||||
[FeatureId.EGRESS_DATA_MB]: {
|
[FeatureId.DOMAINS]: { value: 10, description: "Home limit" },
|
||||||
value: 25000,
|
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Home limit" },
|
||||||
description: "Home lab limit"
|
|
||||||
}, // 25 GB
|
|
||||||
[FeatureId.DOMAINS]: { value: 3, description: "Home lab limit" },
|
|
||||||
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Home lab limit" }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const tier2LimitSet: LimitSet = {
|
export const tier2LimitSet: LimitSet = {
|
||||||
[FeatureId.SITES]: {
|
|
||||||
value: 10,
|
|
||||||
description: "Starter limit"
|
|
||||||
}, // 50 sites up for 31 days
|
|
||||||
[FeatureId.USERS]: {
|
[FeatureId.USERS]: {
|
||||||
value: 150,
|
value: 100,
|
||||||
description: "Starter limit"
|
description: "Team limit"
|
||||||
|
},
|
||||||
|
[FeatureId.SITES]: {
|
||||||
|
value: 50,
|
||||||
|
description: "Team limit"
|
||||||
},
|
},
|
||||||
[FeatureId.EGRESS_DATA_MB]: {
|
|
||||||
value: 12000000,
|
|
||||||
description: "Starter limit"
|
|
||||||
}, // 12000 GB
|
|
||||||
[FeatureId.DOMAINS]: {
|
[FeatureId.DOMAINS]: {
|
||||||
value: 250,
|
value: 50,
|
||||||
description: "Starter limit"
|
description: "Team limit"
|
||||||
},
|
},
|
||||||
[FeatureId.REMOTE_EXIT_NODES]: {
|
[FeatureId.REMOTE_EXIT_NODES]: {
|
||||||
value: 5,
|
value: 3,
|
||||||
description: "Starter limit"
|
description: "Team limit"
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const tier3LimitSet: LimitSet = {
|
export const tier3LimitSet: LimitSet = {
|
||||||
[FeatureId.SITES]: {
|
|
||||||
value: 10,
|
|
||||||
description: "Scale limit"
|
|
||||||
}, // 50 sites up for 31 days
|
|
||||||
[FeatureId.USERS]: {
|
[FeatureId.USERS]: {
|
||||||
value: 150,
|
value: 500,
|
||||||
description: "Scale limit"
|
description: "Business limit"
|
||||||
},
|
},
|
||||||
[FeatureId.EGRESS_DATA_MB]: {
|
[FeatureId.SITES]: {
|
||||||
value: 12000000,
|
|
||||||
description: "Scale limit"
|
|
||||||
}, // 12000 GB
|
|
||||||
[FeatureId.DOMAINS]: {
|
|
||||||
value: 250,
|
value: 250,
|
||||||
description: "Scale limit"
|
description: "Business limit"
|
||||||
|
},
|
||||||
|
[FeatureId.DOMAINS]: {
|
||||||
|
value: 100,
|
||||||
|
description: "Business limit"
|
||||||
},
|
},
|
||||||
[FeatureId.REMOTE_EXIT_NODES]: {
|
[FeatureId.REMOTE_EXIT_NODES]: {
|
||||||
value: 5,
|
value: 20,
|
||||||
description: "Scale limit"
|
description: "Business limit"
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -517,7 +517,6 @@ export class UsageService {
|
|||||||
|
|
||||||
public async checkLimitSet(
|
public async checkLimitSet(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
kickSites = false,
|
|
||||||
featureId?: FeatureId,
|
featureId?: FeatureId,
|
||||||
usage?: Usage,
|
usage?: Usage,
|
||||||
trx: Transaction | typeof db = db
|
trx: Transaction | typeof db = db
|
||||||
@@ -591,58 +590,6 @@ export class UsageService {
|
|||||||
break; // Exit early if any limit is exceeded
|
break; // Exit early if any limit is exceeded
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If any limits are exceeded, disconnect all sites for this organization
|
|
||||||
if (hasExceededLimits && kickSites) {
|
|
||||||
logger.warn(
|
|
||||||
`Disconnecting all sites for org ${orgId} due to exceeded limits`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get all sites for this organization
|
|
||||||
const orgSites = await trx
|
|
||||||
.select()
|
|
||||||
.from(sites)
|
|
||||||
.where(eq(sites.orgId, orgId));
|
|
||||||
|
|
||||||
// Mark all sites as offline and send termination messages
|
|
||||||
const siteUpdates = orgSites.map((site) => site.siteId);
|
|
||||||
|
|
||||||
if (siteUpdates.length > 0) {
|
|
||||||
// Send termination messages to newt sites
|
|
||||||
for (const site of orgSites) {
|
|
||||||
if (site.type === "newt") {
|
|
||||||
const [newt] = await trx
|
|
||||||
.select()
|
|
||||||
.from(newts)
|
|
||||||
.where(eq(newts.siteId, site.siteId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (newt) {
|
|
||||||
const payload = {
|
|
||||||
type: `newt/wg/terminate`,
|
|
||||||
data: {
|
|
||||||
reason: "Usage limits exceeded"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Don't await to prevent blocking
|
|
||||||
await sendToClient(newt.newtId, payload).catch(
|
|
||||||
(error: any) => {
|
|
||||||
logger.error(
|
|
||||||
`Failed to send termination message to newt ${newt.newtId}:`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`Disconnected ${orgSites.length} sites for org ${orgId} due to exceeded limits`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error checking limits for org ${orgId}:`, error);
|
logger.error(`Error checking limits for org ${orgId}:`, error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
freeLimitSet,
|
freeLimitSet,
|
||||||
homeLabLimitSet,
|
tier1LimitSet,
|
||||||
tier2LimitSet,
|
tier2LimitSet,
|
||||||
tier3LimitSet,
|
tier3LimitSet,
|
||||||
limitsService,
|
limitsService,
|
||||||
@@ -22,10 +22,12 @@ import {
|
|||||||
import { usageService } from "@server/lib/billing/usageService";
|
import { usageService } from "@server/lib/billing/usageService";
|
||||||
import { SubscriptionType } from "./hooks/getSubType";
|
import { SubscriptionType } from "./hooks/getSubType";
|
||||||
|
|
||||||
function getLimitSetForSubscriptionType(subType: SubscriptionType | null): LimitSet {
|
function getLimitSetForSubscriptionType(
|
||||||
|
subType: SubscriptionType | null
|
||||||
|
): LimitSet {
|
||||||
switch (subType) {
|
switch (subType) {
|
||||||
case "tier1":
|
case "tier1":
|
||||||
return homeLabLimitSet;
|
return tier1LimitSet;
|
||||||
case "tier2":
|
case "tier2":
|
||||||
return tier2LimitSet;
|
return tier2LimitSet;
|
||||||
case "tier3":
|
case "tier3":
|
||||||
@@ -48,12 +50,12 @@ export async function handleSubscriptionLifesycle(
|
|||||||
case "active":
|
case "active":
|
||||||
const activeLimitSet = getLimitSetForSubscriptionType(subType);
|
const activeLimitSet = getLimitSetForSubscriptionType(subType);
|
||||||
await limitsService.applyLimitSetToOrg(orgId, activeLimitSet);
|
await limitsService.applyLimitSetToOrg(orgId, activeLimitSet);
|
||||||
await usageService.checkLimitSet(orgId, true);
|
await usageService.checkLimitSet(orgId);
|
||||||
break;
|
break;
|
||||||
case "canceled":
|
case "canceled":
|
||||||
// Subscription canceled - revert to free tier
|
// Subscription canceled - revert to free tier
|
||||||
await limitsService.applyLimitSetToOrg(orgId, freeLimitSet);
|
await limitsService.applyLimitSetToOrg(orgId, freeLimitSet);
|
||||||
await usageService.checkLimitSet(orgId, true);
|
await usageService.checkLimitSet(orgId);
|
||||||
break;
|
break;
|
||||||
case "past_due":
|
case "past_due":
|
||||||
// Payment past due - keep current limits but notify customer
|
// Payment past due - keep current limits but notify customer
|
||||||
@@ -62,7 +64,7 @@ export async function handleSubscriptionLifesycle(
|
|||||||
case "unpaid":
|
case "unpaid":
|
||||||
// Subscription unpaid - revert to free tier
|
// Subscription unpaid - revert to free tier
|
||||||
await limitsService.applyLimitSetToOrg(orgId, freeLimitSet);
|
await limitsService.applyLimitSetToOrg(orgId, freeLimitSet);
|
||||||
await usageService.checkLimitSet(orgId, true);
|
await usageService.checkLimitSet(orgId);
|
||||||
break;
|
break;
|
||||||
case "incomplete":
|
case "incomplete":
|
||||||
// Payment incomplete - give them time to complete payment
|
// Payment incomplete - give them time to complete payment
|
||||||
@@ -70,7 +72,7 @@ export async function handleSubscriptionLifesycle(
|
|||||||
case "incomplete_expired":
|
case "incomplete_expired":
|
||||||
// Payment never completed - revert to free tier
|
// Payment never completed - revert to free tier
|
||||||
await limitsService.applyLimitSetToOrg(orgId, freeLimitSet);
|
await limitsService.applyLimitSetToOrg(orgId, freeLimitSet);
|
||||||
await usageService.checkLimitSet(orgId, true);
|
await usageService.checkLimitSet(orgId);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export async function createRemoteExitNode(
|
|||||||
if (usage) {
|
if (usage) {
|
||||||
const rejectRemoteExitNodes = await usageService.checkLimitSet(
|
const rejectRemoteExitNodes = await usageService.checkLimitSet(
|
||||||
orgId,
|
orgId,
|
||||||
false,
|
|
||||||
FeatureId.REMOTE_EXIT_NODES,
|
FeatureId.REMOTE_EXIT_NODES,
|
||||||
{
|
{
|
||||||
...usage,
|
...usage,
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ export async function createOrgDomain(
|
|||||||
}
|
}
|
||||||
const rejectDomains = await usageService.checkLimitSet(
|
const rejectDomains = await usageService.checkLimitSet(
|
||||||
orgId,
|
orgId,
|
||||||
false,
|
|
||||||
FeatureId.DOMAINS,
|
FeatureId.DOMAINS,
|
||||||
{
|
{
|
||||||
...usage,
|
...usage,
|
||||||
|
|||||||
@@ -178,11 +178,9 @@ export async function updateSiteBandwidth(
|
|||||||
|
|
||||||
// Process usage updates outside of site update transactions
|
// Process usage updates outside of site update transactions
|
||||||
// This separates the concerns and reduces lock contention
|
// This separates the concerns and reduces lock contention
|
||||||
if (calcUsageAndLimits && (orgUsageMap.size > 0)) {
|
if (calcUsageAndLimits && orgUsageMap.size > 0) {
|
||||||
// Sort org IDs to ensure consistent lock ordering
|
// Sort org IDs to ensure consistent lock ordering
|
||||||
const allOrgIds = [
|
const allOrgIds = [...new Set([...orgUsageMap.keys()])].sort();
|
||||||
...new Set([...orgUsageMap.keys()])
|
|
||||||
].sort();
|
|
||||||
|
|
||||||
for (const orgId of allOrgIds) {
|
for (const orgId of allOrgIds) {
|
||||||
try {
|
try {
|
||||||
@@ -199,7 +197,7 @@ export async function updateSiteBandwidth(
|
|||||||
usageService
|
usageService
|
||||||
.checkLimitSet(
|
.checkLimitSet(
|
||||||
orgId,
|
orgId,
|
||||||
true,
|
|
||||||
FeatureId.EGRESS_DATA_MB,
|
FeatureId.EGRESS_DATA_MB,
|
||||||
bandwidthUsage
|
bandwidthUsage
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
import { db, ExitNode, exitNodeOrgs, newts, Transaction } from "@server/db";
|
import { db, ExitNode, newts, Transaction } from "@server/db";
|
||||||
import { MessageHandler } from "@server/routers/ws";
|
import { MessageHandler } from "@server/routers/ws";
|
||||||
import { exitNodes, Newt, resources, sites, Target, targets } from "@server/db";
|
import { exitNodes, Newt, sites } from "@server/db";
|
||||||
import { targetHealthCheck } from "@server/db";
|
import { eq } from "drizzle-orm";
|
||||||
import { eq, and, sql, inArray, ne } from "drizzle-orm";
|
|
||||||
import { addPeer, deletePeer } from "../gerbil/peers";
|
import { addPeer, deletePeer } from "../gerbil/peers";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import {
|
import {
|
||||||
findNextAvailableCidr,
|
findNextAvailableCidr,
|
||||||
getNextAvailableClientSubnet
|
|
||||||
} from "@server/lib/ip";
|
} from "@server/lib/ip";
|
||||||
import { usageService } from "@server/lib/billing/usageService";
|
|
||||||
import { FeatureId } from "@server/lib/billing";
|
|
||||||
import {
|
import {
|
||||||
selectBestExitNode,
|
selectBestExitNode,
|
||||||
verifyExitNodeOrgAccess
|
verifyExitNodeOrgAccess
|
||||||
@@ -30,8 +26,6 @@ export type ExitNodePingResult = {
|
|||||||
wasPreviouslyConnected: boolean;
|
wasPreviouslyConnected: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const numTimesLimitExceededForId: Record<string, number> = {};
|
|
||||||
|
|
||||||
export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
||||||
const { message, client, sendToClient } = context;
|
const { message, client, sendToClient } = context;
|
||||||
const newt = client as Newt;
|
const newt = client as Newt;
|
||||||
@@ -96,42 +90,6 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
|||||||
fetchContainers(newt.newtId);
|
fetchContainers(newt.newtId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rejectSites = await usageService.checkLimitSet(
|
|
||||||
oldSite.orgId,
|
|
||||||
false,
|
|
||||||
FeatureId.SITES
|
|
||||||
);
|
|
||||||
const rejectEgressDataMb = await usageService.checkLimitSet(
|
|
||||||
oldSite.orgId,
|
|
||||||
false,
|
|
||||||
FeatureId.EGRESS_DATA_MB
|
|
||||||
);
|
|
||||||
|
|
||||||
// Do we need to check the users and domains count limits here?
|
|
||||||
// const rejectUsers = await usageService.checkLimitSet(oldSite.orgId, false, FeatureId.USERS);
|
|
||||||
// const rejectDomains = await usageService.checkLimitSet(oldSite.orgId, false, FeatureId.DOMAINS);
|
|
||||||
|
|
||||||
// if (rejectEgressDataMb || rejectSites || rejectUsers || rejectDomains) {
|
|
||||||
if (rejectEgressDataMb || rejectSites) {
|
|
||||||
logger.info(
|
|
||||||
`Usage limits exceeded for org ${oldSite.orgId}. Rejecting newt registration.`
|
|
||||||
);
|
|
||||||
|
|
||||||
// PREVENT FURTHER REGISTRATION ATTEMPTS SO WE DON'T SPAM
|
|
||||||
|
|
||||||
// Increment the limit exceeded count for this site
|
|
||||||
numTimesLimitExceededForId[newt.newtId] =
|
|
||||||
(numTimesLimitExceededForId[newt.newtId] || 0) + 1;
|
|
||||||
|
|
||||||
if (numTimesLimitExceededForId[newt.newtId] > 15) {
|
|
||||||
logger.debug(
|
|
||||||
`Newt ${newt.newtId} has exceeded usage limits 15 times. Terminating...`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let siteSubnet = oldSite.subnet;
|
let siteSubnet = oldSite.subnet;
|
||||||
let exitNodeIdToQuery = oldSite.exitNodeId;
|
let exitNodeIdToQuery = oldSite.exitNodeId;
|
||||||
if (exitNodeId && (oldSite.exitNodeId !== exitNodeId || !oldSite.subnet)) {
|
if (exitNodeId && (oldSite.exitNodeId !== exitNodeId || !oldSite.subnet)) {
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ export async function createSite(
|
|||||||
}
|
}
|
||||||
const rejectSites = await usageService.checkLimitSet(
|
const rejectSites = await usageService.checkLimitSet(
|
||||||
orgId,
|
orgId,
|
||||||
false,
|
|
||||||
FeatureId.SITES,
|
FeatureId.SITES,
|
||||||
{
|
{
|
||||||
...usage,
|
...usage,
|
||||||
|
|||||||
@@ -94,7 +94,10 @@ export async function acceptInvite(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (build == "saas") {
|
if (build == "saas") {
|
||||||
const usage = await usageService.getUsage(existingInvite.orgId, FeatureId.USERS);
|
const usage = await usageService.getUsage(
|
||||||
|
existingInvite.orgId,
|
||||||
|
FeatureId.USERS
|
||||||
|
);
|
||||||
if (!usage) {
|
if (!usage) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
@@ -105,7 +108,7 @@ export async function acceptInvite(
|
|||||||
}
|
}
|
||||||
const rejectUsers = await usageService.checkLimitSet(
|
const rejectUsers = await usageService.checkLimitSet(
|
||||||
existingInvite.orgId,
|
existingInvite.orgId,
|
||||||
false,
|
|
||||||
FeatureId.USERS,
|
FeatureId.USERS,
|
||||||
{
|
{
|
||||||
...usage,
|
...usage,
|
||||||
@@ -163,7 +166,9 @@ export async function acceptInvite(
|
|||||||
.from(userOrgs)
|
.from(userOrgs)
|
||||||
.where(eq(userOrgs.orgId, existingInvite.orgId));
|
.where(eq(userOrgs.orgId, existingInvite.orgId));
|
||||||
|
|
||||||
logger.debug(`User ${existingUser[0].userId} accepted invite to org ${existingInvite.orgId}. Total users in org: ${totalUsers.length}`);
|
logger.debug(
|
||||||
|
`User ${existingUser[0].userId} accepted invite to org ${existingInvite.orgId}. Total users in org: ${totalUsers.length}`
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (totalUsers) {
|
if (totalUsers) {
|
||||||
|
|||||||
@@ -21,11 +21,7 @@ const paramsSchema = z.strictObject({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const bodySchema = z.strictObject({
|
const bodySchema = z.strictObject({
|
||||||
email: z
|
email: z.string().email().toLowerCase().optional(),
|
||||||
.string()
|
|
||||||
.email()
|
|
||||||
.toLowerCase()
|
|
||||||
.optional(),
|
|
||||||
username: z.string().nonempty().toLowerCase(),
|
username: z.string().nonempty().toLowerCase(),
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
type: z.enum(["internal", "oidc"]).optional(),
|
type: z.enum(["internal", "oidc"]).optional(),
|
||||||
@@ -94,7 +90,7 @@ export async function createOrgUser(
|
|||||||
}
|
}
|
||||||
const rejectUsers = await usageService.checkLimitSet(
|
const rejectUsers = await usageService.checkLimitSet(
|
||||||
orgId,
|
orgId,
|
||||||
false,
|
|
||||||
FeatureId.USERS,
|
FeatureId.USERS,
|
||||||
{
|
{
|
||||||
...usage,
|
...usage,
|
||||||
|
|||||||
@@ -133,7 +133,6 @@ export async function inviteUser(
|
|||||||
}
|
}
|
||||||
const rejectUsers = await usageService.checkLimitSet(
|
const rejectUsers = await usageService.checkLimitSet(
|
||||||
orgId,
|
orgId,
|
||||||
false,
|
|
||||||
FeatureId.USERS,
|
FeatureId.USERS,
|
||||||
{
|
{
|
||||||
...usage,
|
...usage,
|
||||||
|
|||||||
Reference in New Issue
Block a user