mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-14 00:09:55 +02:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 225ca36308 | |||
| 048e4fc73c | |||
| 71d9d8f010 | |||
| dd78c2cc08 | |||
| 295e38d2af | |||
| 02e4fe8b48 | |||
| ed46afd81a | |||
| 3dc9c100e9 | |||
| 02e97d6ae4 | |||
| 996160fadc | |||
| d04740fede | |||
| b7c0669c38 |
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
# FROM node:24-slim AS base
|
# FROM node:24-slim AS base
|
||||||
FROM public.ecr.aws/docker/library/node:24-slim AS base
|
FROM public.ecr.aws/docker/library/node:26-slim AS base
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ FROM base AS builder
|
|||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
# FROM node:24-slim AS runner
|
# FROM node:24-slim AS runner
|
||||||
FROM public.ecr.aws/docker/library/node:24-slim AS runner
|
FROM public.ecr.aws/docker/library/node:26-slim AS runner
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
FROM node:24-alpine
|
FROM node:26-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
+5
-8
@@ -43,6 +43,8 @@
|
|||||||
"inviteLoginUser": "Please make sure you're logged in as the correct user.",
|
"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.",
|
"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.",
|
"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",
|
"goHome": "Go Home",
|
||||||
"inviteLogInOtherUser": "Log In as a Different User",
|
"inviteLogInOtherUser": "Log In as a Different User",
|
||||||
"createAnAccount": "Create an Account",
|
"createAnAccount": "Create an Account",
|
||||||
@@ -1449,8 +1451,11 @@
|
|||||||
"actionSetResourcePincode": "Set Resource Pincode",
|
"actionSetResourcePincode": "Set Resource Pincode",
|
||||||
"actionSetResourceEmailWhitelist": "Set Resource Email Whitelist",
|
"actionSetResourceEmailWhitelist": "Set Resource Email Whitelist",
|
||||||
"actionGetResourceEmailWhitelist": "Get Resource Email Whitelist",
|
"actionGetResourceEmailWhitelist": "Get Resource Email Whitelist",
|
||||||
|
"actionListResourcePolicies": "List Resource Policies",
|
||||||
|
"actionCreateResourcePolicy": "Create Resource Policy",
|
||||||
"actionGetResourcePolicy": "Get Resource Policy",
|
"actionGetResourcePolicy": "Get Resource Policy",
|
||||||
"actionUpdateResourcePolicy": "Update Resource Policy",
|
"actionUpdateResourcePolicy": "Update Resource Policy",
|
||||||
|
"actionDeleteResourcePolicy": "Delete Resource Policy",
|
||||||
"actionSetResourcePolicyUsers": "Set Resource Policy Users",
|
"actionSetResourcePolicyUsers": "Set Resource Policy Users",
|
||||||
"actionSetResourcePolicyRoles": "Set Resource Policy Roles",
|
"actionSetResourcePolicyRoles": "Set Resource Policy Roles",
|
||||||
"actionSetResourcePolicyPassword": "Set Resource Policy Password",
|
"actionSetResourcePolicyPassword": "Set Resource Policy Password",
|
||||||
@@ -1695,8 +1700,6 @@
|
|||||||
"alertingRuleSaved": "Alert rule saved",
|
"alertingRuleSaved": "Alert rule saved",
|
||||||
"alertingRuleSavedCreatedDescription": "Your new alert rule was created. You can keep editing it on this page.",
|
"alertingRuleSavedCreatedDescription": "Your new alert rule was created. You can keep editing it on this page.",
|
||||||
"alertingRuleSavedUpdatedDescription": "Your changes to this alert rule were saved.",
|
"alertingRuleSavedUpdatedDescription": "Your changes to this alert rule were saved.",
|
||||||
"alertingTestAlertSent": "Test alert sent",
|
|
||||||
"alertingTestAlertSentDescription": "A test alert was sent to the actions configured on this rule.",
|
|
||||||
"alertingEditRule": "Edit Alert Rule",
|
"alertingEditRule": "Edit Alert Rule",
|
||||||
"alertingCreateRule": "Create Alert Rule",
|
"alertingCreateRule": "Create Alert Rule",
|
||||||
"alertingRuleCredenzaDescription": "Choose what to watch, when to fire, and how to notify",
|
"alertingRuleCredenzaDescription": "Choose what to watch, when to fire, and how to notify",
|
||||||
@@ -1806,12 +1809,6 @@
|
|||||||
"alertingRulesBannerDescription": "Each rule ties together what to watch (a site, health check, or resource), when to fire (for example offline or unhealthy), and how to notify your team via email, webhooks, or integrations. Use this list to create, enable, and manage those rules.",
|
"alertingRulesBannerDescription": "Each rule ties together what to watch (a site, health check, or resource), when to fire (for example offline or unhealthy), and how to notify your team via email, webhooks, or integrations. Use this list to create, enable, and manage those rules.",
|
||||||
"alertingHealthChecksBannerTitle": "Monitor Health & Resources",
|
"alertingHealthChecksBannerTitle": "Monitor Health & Resources",
|
||||||
"alertingHealthChecksBannerDescription": "Health checks are HTTP or TCP monitors you define once. You can then use them as sources in alert rules so you get notified when a target becomes healthy or unhealthy. Health checks on resources also appear here.",
|
"alertingHealthChecksBannerDescription": "Health checks are HTTP or TCP monitors you define once. You can then use them as sources in alert rules so you get notified when a target becomes healthy or unhealthy. Health checks on resources also appear here.",
|
||||||
"alertingTestRule": "Test Alert Rule",
|
|
||||||
"alertingAddActionHeading": "Add New Action",
|
|
||||||
"alertingSelectActionType": "Choose an action type",
|
|
||||||
"alertingNoActionsTitle": "No actions configured",
|
|
||||||
"alertingNoActionsSaveDescription": "Add at least one action so this rule can notify someone when it fires.",
|
|
||||||
"alertingNoActionsTestDescription": "Add at least one action before you can test this rule.",
|
|
||||||
"standaloneHcTableTitle": "Health Checks",
|
"standaloneHcTableTitle": "Health Checks",
|
||||||
"standaloneHcSearchPlaceholder": "Search health checks…",
|
"standaloneHcSearchPlaceholder": "Search health checks…",
|
||||||
"standaloneHcAddButton": "Create Health Check",
|
"standaloneHcAddButton": "Create Health Check",
|
||||||
|
|||||||
@@ -151,7 +151,6 @@ export enum ActionsEnum {
|
|||||||
createAlertRule = "createAlertRule",
|
createAlertRule = "createAlertRule",
|
||||||
updateAlertRule = "updateAlertRule",
|
updateAlertRule = "updateAlertRule",
|
||||||
deleteAlertRule = "deleteAlertRule",
|
deleteAlertRule = "deleteAlertRule",
|
||||||
testAlertRule = "testAlertRule",
|
|
||||||
listAlertRules = "listAlertRules",
|
listAlertRules = "listAlertRules",
|
||||||
listOrgLabels = "listOrgLabels",
|
listOrgLabels = "listOrgLabels",
|
||||||
createOrgLabel = "createOrgLabel",
|
createOrgLabel = "createOrgLabel",
|
||||||
|
|||||||
@@ -95,7 +95,8 @@ export const subscriptions = pgTable("subscriptions", {
|
|||||||
billingCycleAnchor: bigint("billingCycleAnchor", { mode: "number" }),
|
billingCycleAnchor: bigint("billingCycleAnchor", { mode: "number" }),
|
||||||
expiresAt: bigint("expiresAt", { mode: "number" }),
|
expiresAt: bigint("expiresAt", { mode: "number" }),
|
||||||
trial: boolean("trial").default(false),
|
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", {
|
export const subscriptionItems = pgTable("subscriptionItems", {
|
||||||
|
|||||||
@@ -89,7 +89,8 @@ export const subscriptions = sqliteTable("subscriptions", {
|
|||||||
expiresAt: integer("expiresAt"),
|
expiresAt: integer("expiresAt"),
|
||||||
trial: integer("trial", { mode: "boolean" }).default(false),
|
trial: integer("trial", { mode: "boolean" }).default(false),
|
||||||
billingCycleAnchor: integer("billingCycleAnchor"),
|
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", {
|
export const subscriptionItems = sqliteTable("subscriptionItems", {
|
||||||
|
|||||||
@@ -31,24 +31,9 @@ export type AlertNotificationProps = {
|
|||||||
orgId: string;
|
orgId: string;
|
||||||
data: Record<string, unknown>;
|
data: Record<string, unknown>;
|
||||||
dashboardLink: string;
|
dashboardLink: string;
|
||||||
isTestAlert?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function getEventMeta(
|
function getEventMeta(eventType: AlertEventType): {
|
||||||
eventType: AlertEventType,
|
|
||||||
isTestAlert: boolean = false
|
|
||||||
): {
|
|
||||||
heading: string;
|
|
||||||
previewText: string;
|
|
||||||
summary: string;
|
|
||||||
statusLabel: string | null;
|
|
||||||
statusColor: string | null;
|
|
||||||
} {
|
|
||||||
const meta = getBaseEventMeta(eventType);
|
|
||||||
return isTestAlert ? { ...meta, heading: `[TEST] ${meta.heading}` } : meta;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBaseEventMeta(eventType: AlertEventType): {
|
|
||||||
heading: string;
|
heading: string;
|
||||||
previewText: string;
|
previewText: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
@@ -195,14 +180,8 @@ function formatDataItems(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const AlertNotification = (props: AlertNotificationProps) => {
|
export const AlertNotification = (props: AlertNotificationProps) => {
|
||||||
const {
|
const { eventType, orgId, data, dashboardLink } = props;
|
||||||
eventType,
|
const meta = getEventMeta(eventType);
|
||||||
orgId,
|
|
||||||
data,
|
|
||||||
dashboardLink,
|
|
||||||
isTestAlert = false
|
|
||||||
} = props;
|
|
||||||
const meta = getEventMeta(eventType, isTestAlert);
|
|
||||||
const dataItems = formatDataItems(data);
|
const dataItems = formatDataItems(data);
|
||||||
|
|
||||||
const isToggle =
|
const isToggle =
|
||||||
@@ -263,12 +242,6 @@ export const AlertNotification = (props: AlertNotificationProps) => {
|
|||||||
Open your dashboard to view more details and manage
|
Open your dashboard to view more details and manage
|
||||||
your alert rules.
|
your alert rules.
|
||||||
</EmailText>
|
</EmailText>
|
||||||
{isTestAlert && (
|
|
||||||
<EmailText>
|
|
||||||
This is a test alert. No action is required,
|
|
||||||
and no real event has occurred.
|
|
||||||
</EmailText>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<EmailSection>
|
<EmailSection>
|
||||||
<ButtonLink href={dashboardLink}>
|
<ButtonLink href={dashboardLink}>
|
||||||
|
|||||||
@@ -632,7 +632,6 @@ export const ResourcePolicySchema = z.object({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.max(50)
|
|
||||||
.transform((v) => v.map((e) => e.toLowerCase()))
|
.transform((v) => v.map((e) => e.toLowerCase()))
|
||||||
.optional()
|
.optional()
|
||||||
.default([]),
|
.default([]),
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { db, idp, idpOrg, Transaction } from "@server/db";
|
import { db, idp, idpOrg, Transaction } from "@server/db";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
|
||||||
export function isOrgIdentityProviderMode(): boolean {
|
export function isOrgIdentityProviderMode(): boolean {
|
||||||
return process.env.IDENTITY_PROVIDER_MODE === "org";
|
return build === "saas" || process.env.IDENTITY_PROVIDER_MODE === "org";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
import { db, userOrgRoles, users } from "@server/db";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import type {
|
|
||||||
EmailAlertAction,
|
|
||||||
TestAlertContext,
|
|
||||||
WebhookAlertConfig
|
|
||||||
} from "@server/routers/alertRule/types";
|
|
||||||
import { eq, inArray } from "drizzle-orm";
|
|
||||||
import { sendAlertEmail } from "./sendAlertEmail";
|
|
||||||
import { sendAlertWebhook } from "./sendAlertWebhook";
|
|
||||||
|
|
||||||
export async function processTestAlerts(context: TestAlertContext) {
|
|
||||||
// Process email actions
|
|
||||||
const emailActions = context.actions.filter(
|
|
||||||
(action) => action.type === "email"
|
|
||||||
);
|
|
||||||
for (const action of emailActions) {
|
|
||||||
try {
|
|
||||||
const recipients = await resolveEmailRecipients(action);
|
|
||||||
if (recipients.length > 0) {
|
|
||||||
await sendAlertEmail(recipients, {
|
|
||||||
...context,
|
|
||||||
isTest: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`processTestAlerts: failed to send alert email`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process webhook actions
|
|
||||||
const webhookActions = context.actions.filter(
|
|
||||||
(action) => action.type === "webhook"
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const action of webhookActions) {
|
|
||||||
try {
|
|
||||||
let webhookConfig: WebhookAlertConfig = { authType: "none" };
|
|
||||||
|
|
||||||
if (action.config) {
|
|
||||||
try {
|
|
||||||
webhookConfig = JSON.parse(
|
|
||||||
action.config
|
|
||||||
) as WebhookAlertConfig;
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(
|
|
||||||
`processTestAlerts: failed to decrypt webhook`,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await sendAlertWebhook(action.webhookUrl, webhookConfig, {
|
|
||||||
...context,
|
|
||||||
isTest: true
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(
|
|
||||||
`processTestAlerts: failed to send alert webhook `,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves all email addresses for a given `emailActionId`.
|
|
||||||
*
|
|
||||||
* Recipients may be:
|
|
||||||
* - Direct users (by `userId`)
|
|
||||||
* - All users in a role (by `roleId`, resolved via `userOrgRoles`)
|
|
||||||
* - Direct external email addresses
|
|
||||||
*/
|
|
||||||
async function resolveEmailRecipients(
|
|
||||||
action: EmailAlertAction
|
|
||||||
): Promise<string[]> {
|
|
||||||
const emailList: string[] = [];
|
|
||||||
|
|
||||||
emailList.push(...(action.emails ?? []));
|
|
||||||
|
|
||||||
if (action.userIds && action.userIds?.length > 0) {
|
|
||||||
const userList = await db
|
|
||||||
.select({ email: users.email })
|
|
||||||
.from(users)
|
|
||||||
.where(inArray(users.userId, action.userIds));
|
|
||||||
|
|
||||||
emailList.push(
|
|
||||||
...userList.filter((u) => u.email !== null).map((u) => u.email!)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (action.roleIds && action.roleIds?.length > 0) {
|
|
||||||
const userList = await db
|
|
||||||
.select({ email: users.email })
|
|
||||||
.from(userOrgRoles)
|
|
||||||
.innerJoin(users, eq(userOrgRoles.userId, users.userId))
|
|
||||||
.where(inArray(userOrgRoles.roleId, action.roleIds.map(Number)));
|
|
||||||
|
|
||||||
emailList.push(
|
|
||||||
...userList.filter((u) => u.email !== null).map((u) => u.email!)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...new Set(emailList)];
|
|
||||||
}
|
|
||||||
@@ -15,21 +15,7 @@ import { sendEmail } from "@server/emails";
|
|||||||
import AlertNotification from "@server/emails/templates/AlertNotification";
|
import AlertNotification from "@server/emails/templates/AlertNotification";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { type AlertEventType } from "@server/routers/alertRule/types";
|
import { AlertContext } from "@server/routers/alertRule/types";
|
||||||
|
|
||||||
type EmailAlertContext = {
|
|
||||||
eventType: AlertEventType;
|
|
||||||
orgId: string;
|
|
||||||
/** Set for site_online / site_offline events */
|
|
||||||
siteId?: number;
|
|
||||||
/** Set for health_check_* events */
|
|
||||||
healthCheckId?: number;
|
|
||||||
/** Set for resource_* events */
|
|
||||||
resourceId?: number;
|
|
||||||
/** Human-readable context data included in emails and webhook payloads */
|
|
||||||
data: Record<string, unknown>;
|
|
||||||
isTest?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sends an alert notification email to every address in `recipients`.
|
* Sends an alert notification email to every address in `recipients`.
|
||||||
@@ -41,7 +27,7 @@ type EmailAlertContext = {
|
|||||||
*/
|
*/
|
||||||
export async function sendAlertEmail(
|
export async function sendAlertEmail(
|
||||||
recipients: string[],
|
recipients: string[],
|
||||||
context: EmailAlertContext
|
context: AlertContext
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (recipients.length === 0) {
|
if (recipients.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -60,8 +46,7 @@ export async function sendAlertEmail(
|
|||||||
eventType: context.eventType,
|
eventType: context.eventType,
|
||||||
orgId: context.orgId,
|
orgId: context.orgId,
|
||||||
data: context.data,
|
data: context.data,
|
||||||
dashboardLink,
|
dashboardLink
|
||||||
isTestAlert: context.isTest
|
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
from,
|
from,
|
||||||
@@ -85,35 +70,34 @@ export async function sendAlertEmail(
|
|||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function buildSubject(context: EmailAlertContext): string {
|
function buildSubject(context: AlertContext): string {
|
||||||
const prefix = context.isTest ? "[Test Alert]" : "[Alert]";
|
|
||||||
switch (context.eventType) {
|
switch (context.eventType) {
|
||||||
case "site_online":
|
case "site_online":
|
||||||
return `${prefix} Site Back Online`;
|
return "[Alert] Site Back Online";
|
||||||
case "site_offline":
|
case "site_offline":
|
||||||
return `${prefix} Site Offline`;
|
return "[Alert] Site Offline";
|
||||||
case "site_toggle":
|
case "site_toggle":
|
||||||
return `${prefix} Site Status Changed`;
|
return "[Alert] Site Status Changed";
|
||||||
case "health_check_healthy":
|
case "health_check_healthy":
|
||||||
return `${prefix} Health Check Recovered`;
|
return "[Alert] Health Check Recovered";
|
||||||
case "health_check_unhealthy":
|
case "health_check_unhealthy":
|
||||||
return `${prefix} Health Check Failing`;
|
return "[Alert] Health Check Failing";
|
||||||
case "health_check_toggle":
|
case "health_check_toggle":
|
||||||
return `${prefix} Health Check Status Changed`;
|
return "[Alert] Health Check Status Changed";
|
||||||
case "resource_healthy":
|
case "resource_healthy":
|
||||||
return `${prefix} Resource Healthy`;
|
return "[Alert] Resource Healthy";
|
||||||
case "resource_unhealthy":
|
case "resource_unhealthy":
|
||||||
return `${prefix} Resource Unhealthy`;
|
return "[Alert] Resource Unhealthy";
|
||||||
case "resource_degraded":
|
case "resource_degraded":
|
||||||
return `${prefix} Resource Degraded`;
|
return "[Alert] Resource Degraded";
|
||||||
case "resource_toggle":
|
case "resource_toggle":
|
||||||
return `${prefix} Resource Status Changed`;
|
return "[Alert] Resource Status Changed";
|
||||||
default: {
|
default: {
|
||||||
// Exhaustiveness fallback – should never be reached with a
|
// Exhaustiveness fallback – should never be reached with a
|
||||||
// well-typed caller, but keeps runtime behaviour predictable.
|
// well-typed caller, but keeps runtime behaviour predictable.
|
||||||
const _exhaustive: never = context.eventType;
|
const _exhaustive: never = context.eventType;
|
||||||
void _exhaustive;
|
void _exhaustive;
|
||||||
return `${prefix} Event Notification`;
|
return "[Alert] Event Notification";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,28 +14,13 @@
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import {
|
import {
|
||||||
AlertContext,
|
AlertContext,
|
||||||
WebhookAlertConfig,
|
WebhookAlertConfig
|
||||||
type AlertEventType
|
|
||||||
} from "@server/routers/alertRule/types";
|
} from "@server/routers/alertRule/types";
|
||||||
|
|
||||||
const REQUEST_TIMEOUT_MS = 15_000;
|
const REQUEST_TIMEOUT_MS = 15_000;
|
||||||
const MAX_RETRIES = 3;
|
const MAX_RETRIES = 3;
|
||||||
const RETRY_BASE_DELAY_MS = 500;
|
const RETRY_BASE_DELAY_MS = 500;
|
||||||
|
|
||||||
type WebhookAlertContext = {
|
|
||||||
eventType: AlertEventType;
|
|
||||||
orgId: string;
|
|
||||||
/** Set for site_online / site_offline events */
|
|
||||||
siteId?: number;
|
|
||||||
/** Set for health_check_* events */
|
|
||||||
healthCheckId?: number;
|
|
||||||
/** Set for resource_* events */
|
|
||||||
resourceId?: number;
|
|
||||||
/** Human-readable context data included in emails and webhook payloads */
|
|
||||||
data: Record<string, unknown>;
|
|
||||||
isTest?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sends a single webhook POST for an alert event.
|
* Sends a single webhook POST for an alert event.
|
||||||
*
|
*
|
||||||
@@ -55,14 +40,14 @@ type WebhookAlertContext = {
|
|||||||
export async function sendAlertWebhook(
|
export async function sendAlertWebhook(
|
||||||
url: string,
|
url: string,
|
||||||
webhookConfig: WebhookAlertConfig,
|
webhookConfig: WebhookAlertConfig,
|
||||||
context: WebhookAlertContext
|
context: AlertContext
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const eventType = context.eventType;
|
const eventType = context.eventType;
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
const status = deriveStatus(eventType, context.data);
|
const status = deriveStatus(eventType, context.data);
|
||||||
const data = { orgId: context.orgId, ...context.data };
|
const data = { orgId: context.orgId, ...context.data };
|
||||||
|
|
||||||
let body: Record<string, any>;
|
let body: string;
|
||||||
if (webhookConfig.useBodyTemplate && webhookConfig.bodyTemplate?.trim()) {
|
if (webhookConfig.useBodyTemplate && webhookConfig.bodyTemplate?.trim()) {
|
||||||
body = renderTemplate(webhookConfig.bodyTemplate, {
|
body = renderTemplate(webhookConfig.bodyTemplate, {
|
||||||
event: eventType,
|
event: eventType,
|
||||||
@@ -71,11 +56,7 @@ export async function sendAlertWebhook(
|
|||||||
data
|
data
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
body = { event: eventType, timestamp, status, data };
|
body = JSON.stringify({ event: eventType, timestamp, status, data });
|
||||||
}
|
|
||||||
|
|
||||||
if (body.data && context.isTest) {
|
|
||||||
body.data.test = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = buildHeaders(webhookConfig);
|
const headers = buildHeaders(webhookConfig);
|
||||||
@@ -94,7 +75,7 @@ export async function sendAlertWebhook(
|
|||||||
response = await fetch(url, {
|
response = await fetch(url, {
|
||||||
method: webhookConfig.method ?? "POST",
|
method: webhookConfig.method ?? "POST",
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify(body),
|
body,
|
||||||
signal: controller.signal
|
signal: controller.signal
|
||||||
});
|
});
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -266,10 +247,7 @@ interface TemplateContext {
|
|||||||
* left untouched.
|
* left untouched.
|
||||||
* 3. The fixed top-level keys: event, timestamp, status.
|
* 3. The fixed top-level keys: event, timestamp, status.
|
||||||
*/
|
*/
|
||||||
function renderTemplate(
|
function renderTemplate(template: string, ctx: TemplateContext): string {
|
||||||
template: string,
|
|
||||||
ctx: TemplateContext
|
|
||||||
): Record<string, any> {
|
|
||||||
// Step 1 – expand {{data}} first so its contents are already serialised
|
// Step 1 – expand {{data}} first so its contents are already serialised
|
||||||
// and won't be touched by later passes.
|
// and won't be touched by later passes.
|
||||||
let rendered = template.replace(/\{\{data\}\}/g, JSON.stringify(ctx.data));
|
let rendered = template.replace(/\{\{data\}\}/g, JSON.stringify(ctx.data));
|
||||||
@@ -302,19 +280,20 @@ function renderTemplate(
|
|||||||
// Validate the rendered result is valid JSON; if not, log a warning and
|
// Validate the rendered result is valid JSON; if not, log a warning and
|
||||||
// fall back to the default payload so the webhook still fires.
|
// fall back to the default payload so the webhook still fires.
|
||||||
try {
|
try {
|
||||||
return JSON.parse(rendered);
|
JSON.parse(rendered);
|
||||||
|
return rendered;
|
||||||
} catch {
|
} catch {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`sendAlertWebhook: body template produced invalid JSON for event ` +
|
`sendAlertWebhook: body template produced invalid JSON for event ` +
|
||||||
`"${ctx.event}" destined for a webhook. Falling back to default ` +
|
`"${ctx.event}" destined for a webhook. Falling back to default ` +
|
||||||
`payload. Check that {{data}} is NOT wrapped in quotes in your template.`
|
`payload. Check that {{data}} is NOT wrapped in quotes in your template.`
|
||||||
);
|
);
|
||||||
return {
|
return JSON.stringify({
|
||||||
event: ctx.event,
|
event: ctx.event,
|
||||||
timestamp: ctx.timestamp,
|
timestamp: ctx.timestamp,
|
||||||
status: ctx.status,
|
status: ctx.status,
|
||||||
data: ctx.data
|
data: ctx.data
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,5 +15,4 @@ export * from "./createAlertRule";
|
|||||||
export * from "./updateAlertRule";
|
export * from "./updateAlertRule";
|
||||||
export * from "./deleteAlertRule";
|
export * from "./deleteAlertRule";
|
||||||
export * from "./listAlertRules";
|
export * from "./listAlertRules";
|
||||||
export * from "./getAlertRule";
|
export * from "./getAlertRule";
|
||||||
export * from "./testAlertRule";
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This file is licensed under the Fossorial Commercial License.
|
|
||||||
* You may not use this file except in compliance with the License.
|
|
||||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
*
|
|
||||||
* This file is not licensed under the AGPLv3.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { getRandomItemInArray } from "@app/lib/getRandomItemInArray";
|
|
||||||
import response from "@server/lib/response";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import { processTestAlerts } from "@server/private/lib/alerts/processTestAlerts";
|
|
||||||
import { type AlertAction } from "@server/routers/alertRule/types";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import { NextFunction, Request, Response } from "express";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { fromError } from "zod-validation-error";
|
|
||||||
import type { TriggerSiteAlertResponse } from "../alertEvents";
|
|
||||||
import {
|
|
||||||
HC_EVENT_TYPES,
|
|
||||||
SITE_EVENT_TYPES,
|
|
||||||
RESOURCE_EVENT_TYPES
|
|
||||||
} from "./createAlertRule";
|
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
|
||||||
orgId: z.string().nonempty()
|
|
||||||
});
|
|
||||||
|
|
||||||
const webhookActionSchema = z.strictObject({
|
|
||||||
webhookUrl: z.url(),
|
|
||||||
config: z.string().optional(),
|
|
||||||
enabled: z.boolean().optional().default(true)
|
|
||||||
});
|
|
||||||
|
|
||||||
const bodySchema = z.object({
|
|
||||||
eventType: z.enum([
|
|
||||||
...HC_EVENT_TYPES,
|
|
||||||
...SITE_EVENT_TYPES,
|
|
||||||
...RESOURCE_EVENT_TYPES
|
|
||||||
]),
|
|
||||||
// Email recipients (flat)
|
|
||||||
userIds: z.array(z.string().nonempty()).optional().default([]),
|
|
||||||
roleIds: z.array(z.number()).optional().default([]),
|
|
||||||
emails: z.array(z.email()).optional().default([]),
|
|
||||||
// Webhook actions
|
|
||||||
webhookActions: z.array(webhookActionSchema).optional().default([])
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function testAlertRule(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
): Promise<any> {
|
|
||||||
try {
|
|
||||||
const parsedParams = paramsSchema.safeParse(req.params);
|
|
||||||
if (!parsedParams.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedParams.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const { orgId } = parsedParams.data;
|
|
||||||
|
|
||||||
const parsedBody = bodySchema.safeParse(req.body);
|
|
||||||
if (!parsedBody.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedBody.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = parsedBody.data;
|
|
||||||
const collectedActions: AlertAction[] = [];
|
|
||||||
if (
|
|
||||||
body.emails.length > 0 ||
|
|
||||||
body.roleIds.length > 0 ||
|
|
||||||
body.userIds.length > 0
|
|
||||||
) {
|
|
||||||
collectedActions.push({
|
|
||||||
type: "email",
|
|
||||||
emails: body.emails,
|
|
||||||
roleIds: body.roleIds,
|
|
||||||
userIds: body.userIds
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const action of body.webhookActions) {
|
|
||||||
collectedActions.push({
|
|
||||||
type: "webhook",
|
|
||||||
...action
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let data: Record<string, any> = {};
|
|
||||||
switch (body.eventType) {
|
|
||||||
case "site_toggle":
|
|
||||||
data = {
|
|
||||||
status: getRandomItemInArray(["online", "offline"]),
|
|
||||||
siteName: "Test Site Alert"
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
case "site_offline":
|
|
||||||
data = {
|
|
||||||
status: "offline",
|
|
||||||
siteName: "Test Site Alert"
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
case "site_online":
|
|
||||||
data = {
|
|
||||||
status: "online",
|
|
||||||
siteName: "Test Site Alert"
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
case "resource_toggle":
|
|
||||||
data = {
|
|
||||||
status: getRandomItemInArray([
|
|
||||||
"healthy",
|
|
||||||
"unhealthy",
|
|
||||||
"degraded"
|
|
||||||
]),
|
|
||||||
siteName: "Test Resource Alert"
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
case "resource_healthy":
|
|
||||||
data = {
|
|
||||||
status: "healthy",
|
|
||||||
siteName: "Test Resource Alert"
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
case "resource_unhealthy":
|
|
||||||
data = {
|
|
||||||
status: "unhealthy",
|
|
||||||
siteName: "Test Resource Alert"
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
case "resource_degraded":
|
|
||||||
data = {
|
|
||||||
status: "degraded",
|
|
||||||
siteName: "Test Resource Alert"
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
case "health_check_toggle":
|
|
||||||
data = {
|
|
||||||
status: getRandomItemInArray(["healthy", "unhealthy"]),
|
|
||||||
healthCheckName: "Test Health Check Alert"
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
case "health_check_healthy":
|
|
||||||
data = {
|
|
||||||
status: "healthy",
|
|
||||||
healthCheckName: "Test Health Check Alert"
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
case "health_check_unhealthy":
|
|
||||||
data = {
|
|
||||||
status: "unhealthy",
|
|
||||||
healthCheckName: "Test Health Check Alert"
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: process alert rule
|
|
||||||
await processTestAlerts({
|
|
||||||
eventType: body.eventType,
|
|
||||||
orgId,
|
|
||||||
actions: collectedActions,
|
|
||||||
data
|
|
||||||
});
|
|
||||||
|
|
||||||
return response<TriggerSiteAlertResponse>(res, {
|
|
||||||
data: { success: true },
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Alert triggered successfully",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error);
|
|
||||||
return next(
|
|
||||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -53,6 +53,15 @@ export async function handleSubscriptionDeleted(
|
|||||||
return;
|
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
|
await db
|
||||||
.delete(subscriptions)
|
.delete(subscriptions)
|
||||||
.where(eq(subscriptions.subscriptionId, subscription.id));
|
.where(eq(subscriptions.subscriptionId, subscription.id));
|
||||||
|
|||||||
@@ -68,13 +68,27 @@ export async function handleSubscriptionUpdated(
|
|||||||
const type = getSubType(fullSubscription);
|
const type = getSubType(fullSubscription);
|
||||||
const previousType = existingSubscription.type as SubscriptionType | null;
|
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
|
await db
|
||||||
.update(subscriptions)
|
.update(subscriptions)
|
||||||
.set({
|
.set({
|
||||||
status: subscription.status,
|
status: effectiveStatus,
|
||||||
canceledAt: subscription.canceled_at
|
canceledAt: isLocked
|
||||||
? subscription.canceled_at
|
? existingSubscription.canceledAt
|
||||||
: null,
|
: subscription.canceled_at
|
||||||
|
? subscription.canceled_at
|
||||||
|
: null,
|
||||||
updatedAt: Math.floor(Date.now() / 1000),
|
updatedAt: Math.floor(Date.now() / 1000),
|
||||||
billingCycleAnchor: subscription.billing_cycle_anchor,
|
billingCycleAnchor: subscription.billing_cycle_anchor,
|
||||||
type: type
|
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
|
// we only need to handle the limit lifecycle for saas subscriptions not for the licenses
|
||||||
await handleSubscriptionLifesycle(
|
await handleSubscriptionLifesycle(
|
||||||
customer.orgId,
|
customer.orgId,
|
||||||
subscription.status,
|
effectiveStatus,
|
||||||
type
|
type
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle feature lifecycle when subscription is canceled or becomes unpaid
|
// Handle feature lifecycle when subscription is canceled or becomes unpaid
|
||||||
if (
|
if (
|
||||||
subscription.status === "canceled" ||
|
effectiveStatus === "canceled" ||
|
||||||
subscription.status === "unpaid" ||
|
effectiveStatus === "unpaid" ||
|
||||||
subscription.status === "incomplete_expired"
|
effectiveStatus === "incomplete_expired"
|
||||||
) {
|
) {
|
||||||
logger.info(
|
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);
|
await handleTierChange(customer.orgId, null, previousType ?? undefined);
|
||||||
}
|
}
|
||||||
} else if (type === "license") {
|
} else if (type === "license") {
|
||||||
if (subscription.status === "canceled" || subscription.status == "unpaid" || subscription.status == "incomplete_expired") {
|
if (effectiveStatus === "canceled" || effectiveStatus == "unpaid" || effectiveStatus == "incomplete_expired") {
|
||||||
try {
|
try {
|
||||||
// WARNING:
|
// WARNING:
|
||||||
// this invalidates ALL OF THE ENTERPRISE LICENSES for this orgId
|
// this invalidates ALL OF THE ENTERPRISE LICENSES for this orgId
|
||||||
|
|||||||
@@ -808,14 +808,6 @@ authenticated.get(
|
|||||||
alertRule.listAlertRules
|
alertRule.listAlertRules
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.post(
|
|
||||||
"/org/:orgId/test-alert-rule",
|
|
||||||
verifyValidLicense,
|
|
||||||
verifyOrgAccess,
|
|
||||||
verifyUserHasAction(ActionsEnum.testAlertRule),
|
|
||||||
alertRule.testAlertRule
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/alert-rule/:alertRuleId",
|
"/org/:orgId/alert-rule/:alertRuleId",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
|
|||||||
@@ -107,7 +107,6 @@ const createResourcePolicyBodySchema = z.strictObject({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.max(50)
|
|
||||||
.transform((v) => v.map((e) => e.toLowerCase()))
|
.transform((v) => v.map((e) => e.toLowerCase()))
|
||||||
.optional()
|
.optional()
|
||||||
.default([]),
|
.default([]),
|
||||||
|
|||||||
@@ -124,26 +124,3 @@ export interface AlertContext {
|
|||||||
/** Human-readable context data included in emails and webhook payloads */
|
/** Human-readable context data included in emails and webhook payloads */
|
||||||
data: Record<string, unknown>;
|
data: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EmailAlertAction = {
|
|
||||||
type: "email";
|
|
||||||
userIds?: string[];
|
|
||||||
roleIds?: number[];
|
|
||||||
emails?: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WebhookAlertAction = {
|
|
||||||
type: "webhook";
|
|
||||||
webhookUrl: string;
|
|
||||||
enabled: boolean;
|
|
||||||
config?: string | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AlertAction = EmailAlertAction | WebhookAlertAction;
|
|
||||||
export interface TestAlertContext {
|
|
||||||
eventType: AlertEventType;
|
|
||||||
actions: AlertAction[];
|
|
||||||
orgId: string;
|
|
||||||
/** Human-readable context data included in emails and webhook payloads */
|
|
||||||
data: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -726,8 +726,8 @@ authenticated.post(
|
|||||||
verifyApiKeyResourcePolicyAccess,
|
verifyApiKeyResourcePolicyAccess,
|
||||||
verifyApiKeyRoleAccess,
|
verifyApiKeyRoleAccess,
|
||||||
verifyLimits,
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.setResourcePolicyUsers),
|
verifyApiKeyHasAction(ActionsEnum.setResourcePolicyUsers),
|
||||||
verifyUserHasAction(ActionsEnum.setResourcePolicyRoles),
|
verifyApiKeyHasAction(ActionsEnum.setResourcePolicyRoles),
|
||||||
logActionAudit(ActionsEnum.setResourcePolicyUsers),
|
logActionAudit(ActionsEnum.setResourcePolicyUsers),
|
||||||
logActionAudit(ActionsEnum.setResourcePolicyRoles),
|
logActionAudit(ActionsEnum.setResourcePolicyRoles),
|
||||||
policy.setResourcePolicyAccessControl
|
policy.setResourcePolicyAccessControl
|
||||||
@@ -742,8 +742,8 @@ authenticated.put(
|
|||||||
verifyApiKeyResourcePolicyAccess,
|
verifyApiKeyResourcePolicyAccess,
|
||||||
verifyApiKeyRoleAccess,
|
verifyApiKeyRoleAccess,
|
||||||
verifyLimits,
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.setResourcePolicyUsers),
|
verifyApiKeyHasAction(ActionsEnum.setResourcePolicyUsers),
|
||||||
verifyUserHasAction(ActionsEnum.setResourcePolicyRoles),
|
verifyApiKeyHasAction(ActionsEnum.setResourcePolicyRoles),
|
||||||
logActionAudit(ActionsEnum.setResourcePolicyUsers),
|
logActionAudit(ActionsEnum.setResourcePolicyUsers),
|
||||||
logActionAudit(ActionsEnum.setResourcePolicyRoles),
|
logActionAudit(ActionsEnum.setResourcePolicyRoles),
|
||||||
policy.setResourcePolicyAccessControl
|
policy.setResourcePolicyAccessControl
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ const setResourcePolicyWhitelistBodySchema = z.strictObject({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.max(50)
|
|
||||||
.transform((v) => v.map((e) => e.toLowerCase()))
|
.transform((v) => v.map((e) => e.toLowerCase()))
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ const setResourceWhitelistBodySchema = z.strictObject({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.max(50)
|
|
||||||
.transform((v) => v.map((e) => e.toLowerCase()))
|
.transform((v) => v.map((e) => e.toLowerCase()))
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsFor
|
|||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { assignUserToOrg } from "@server/lib/userOrg";
|
import { assignUserToOrg } from "@server/lib/userOrg";
|
||||||
import { isOrgRebuildRateLimited } from "@server/lib/rebuildClientAssociations";
|
import { isOrgRebuildRateLimited } from "@server/lib/rebuildClientAssociations";
|
||||||
|
import { UserType } from "@server/types/UserTypes";
|
||||||
|
|
||||||
const acceptInviteBodySchema = z.strictObject({
|
const acceptInviteBodySchema = z.strictObject({
|
||||||
token: z.string(),
|
token: z.string(),
|
||||||
@@ -66,12 +67,17 @@ export async function acceptInvite(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingUser = await db
|
const [existingInternalUser] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.email, existingInvite.email))
|
.where(
|
||||||
|
and(
|
||||||
|
eq(users.email, existingInvite.email),
|
||||||
|
eq(users.type, UserType.Internal)
|
||||||
|
)
|
||||||
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (!existingUser.length) {
|
if (!existingInternalUser) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
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) {
|
if (!user) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
@@ -92,7 +97,7 @@ export async function acceptInvite(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user && user.email !== existingInvite.email) {
|
if (user.email !== existingInvite.email) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
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") {
|
if (build == "saas") {
|
||||||
const usage = await usageService.getUsage(
|
const usage = await usageService.getUsage(
|
||||||
existingInvite.orgId,
|
existingInvite.orgId,
|
||||||
@@ -195,7 +209,7 @@ export async function acceptInvite(
|
|||||||
await assignUserToOrg(
|
await assignUserToOrg(
|
||||||
org,
|
org,
|
||||||
{
|
{
|
||||||
userId: existingUser[0].userId,
|
userId: user.userId,
|
||||||
orgId: existingInvite.orgId
|
orgId: existingInvite.orgId
|
||||||
},
|
},
|
||||||
inviteRoleIds,
|
inviteRoleIds,
|
||||||
@@ -208,13 +222,13 @@ export async function acceptInvite(
|
|||||||
.where(eq(userInvites.inviteId, inviteId));
|
.where(eq(userInvites.inviteId, inviteId));
|
||||||
|
|
||||||
logger.debug(
|
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(
|
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 { assignUserToOrg } from "@server/lib/userOrg";
|
||||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||||
import { isOrgRebuildRateLimited } from "@server/lib/rebuildClientAssociations";
|
import { isOrgRebuildRateLimited } from "@server/lib/rebuildClientAssociations";
|
||||||
|
import { idpExistsForOrg } from "@server/lib/idp/idpExistsForOrg";
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
const paramsSchema = z.strictObject({
|
||||||
orgId: z.string().nonempty()
|
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
|
const [idpRes] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(idp)
|
.from(idp)
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ const listUsersSchema = z.strictObject({
|
|||||||
.filter((n) => Number.isInteger(n) && n > 0);
|
.filter((n) => Number.isInteger(n) && n > 0);
|
||||||
const unique = [...new Set(nums)];
|
const unique = [...new Set(nums)];
|
||||||
return unique.length ? unique : undefined;
|
return unique.length ? unique : undefined;
|
||||||
}, z.array(z.number().int().positive()).max(50).optional())
|
}, z.array(z.number().int().positive()).optional())
|
||||||
.openapi({
|
.openapi({
|
||||||
description:
|
description:
|
||||||
"Filter users who have any of these role ids in the organization (repeat query param)"
|
"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 { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
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() {
|
export default function AccessControlsPage() {
|
||||||
const { orgUser: user, updateOrgUser } = userOrgUserContext();
|
const { orgUser: user, updateOrgUser } = userOrgUserContext();
|
||||||
const { user: sessionUser } = useUserContext();
|
const { user: sessionUser } = useUserContext();
|
||||||
@@ -69,6 +57,20 @@ export default function AccessControlsPage() {
|
|||||||
(build === "enterprise" && !isPaid) ||
|
(build === "enterprise" && !isPaid) ||
|
||||||
(build === "oss" && !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({
|
const form = useForm({
|
||||||
resolver: zodResolver(accessControlsFormSchema),
|
resolver: zodResolver(accessControlsFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -108,15 +110,6 @@ export default function AccessControlsPage() {
|
|||||||
async function executeSave() {
|
async function executeSave() {
|
||||||
const values = form.getValues();
|
const values = form.getValues();
|
||||||
|
|
||||||
if (values.roles.length === 0) {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t("accessRoleRequired"),
|
|
||||||
description: t("accessRoleSelectPlease")
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
|
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
|
||||||
@@ -170,15 +163,6 @@ export default function AccessControlsPage() {
|
|||||||
|
|
||||||
const values = form.getValues();
|
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 willHaveAdminRole = values.roles.some((r) => r.isAdmin === true);
|
||||||
|
|
||||||
const isRemovingOwnAdmin =
|
const isRemovingOwnAdmin =
|
||||||
|
|||||||
@@ -237,10 +237,13 @@ export default function Page() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const useOrgIdps =
|
||||||
|
build === "saas" || env.app.identityProviderMode === "org";
|
||||||
|
|
||||||
const res = await api
|
const res = await api
|
||||||
.get<
|
.get<
|
||||||
AxiosResponse<ListIdpsResponse>
|
AxiosResponse<ListIdpsResponse>
|
||||||
>(build === "saas" ? `/org/${orgId}/idp` : "/idp")
|
>(useOrgIdps ? `/org/${orgId}/idp` : "/idp")
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
toast({
|
toast({
|
||||||
@@ -301,8 +304,7 @@ export default function Page() {
|
|||||||
);
|
);
|
||||||
const [isSubmittingExternal, setIsSubmittingExternal] = useState(false);
|
const [isSubmittingExternal, setIsSubmittingExternal] = useState(false);
|
||||||
|
|
||||||
const loading =
|
const loading = isSubmittingInternal || isSubmittingExternal;
|
||||||
isSubmittingInternal || isSubmittingExternal;
|
|
||||||
|
|
||||||
async function onSubmitInternal() {
|
async function onSubmitInternal() {
|
||||||
const isValid = await internalForm.trigger();
|
const isValid = await internalForm.trigger();
|
||||||
|
|||||||
@@ -193,7 +193,10 @@ export default async function Page(props: {
|
|||||||
redirect={redirectUrl}
|
redirect={redirectUrl}
|
||||||
forceLogin={forceLogin}
|
forceLogin={forceLogin}
|
||||||
defaultUser={defaultUser}
|
defaultUser={defaultUser}
|
||||||
lastUsedIdp={lastUsedIdpForSmartLogin}
|
inviteMode={isInvite}
|
||||||
|
lastUsedIdp={
|
||||||
|
isInvite ? null : lastUsedIdpForSmartLogin
|
||||||
|
}
|
||||||
orgSignIn={
|
orgSignIn={
|
||||||
!isInvite &&
|
!isInvite &&
|
||||||
(build === "saas" ||
|
(build === "saas" ||
|
||||||
@@ -213,7 +216,7 @@ export default async function Page(props: {
|
|||||||
) : (
|
) : (
|
||||||
<DashboardLoginForm
|
<DashboardLoginForm
|
||||||
redirect={redirectUrl}
|
redirect={redirectUrl}
|
||||||
idps={loginIdps}
|
idps={isInvite ? [] : loginIdps}
|
||||||
forceLogin={forceLogin}
|
forceLogin={forceLogin}
|
||||||
showOrgLogin={
|
showOrgLogin={
|
||||||
!isInvite &&
|
!isInvite &&
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
|||||||
import { usePaidStatus } from "@/hooks/usePaidStatus";
|
import { usePaidStatus } from "@/hooks/usePaidStatus";
|
||||||
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import { toUnicode } from "punycode";
|
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";
|
import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
|
|
||||||
type AvailableOption = {
|
type AvailableOption = {
|
||||||
@@ -166,8 +166,19 @@ export default function DomainPicker({
|
|||||||
const [selectedProvidedDomain, setSelectedProvidedDomain] =
|
const [selectedProvidedDomain, setSelectedProvidedDomain] =
|
||||||
useState<AvailableOption | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!loadingDomains) {
|
if (!loadingDomains && !didSelectInitialDomainRef.current) {
|
||||||
|
didSelectInitialDomainRef.current = true;
|
||||||
let domainOptionToSelect: DomainOption | null = null;
|
let domainOptionToSelect: DomainOption | null = null;
|
||||||
if (organizationDomains.length > 0) {
|
if (organizationDomains.length > 0) {
|
||||||
// Select the first organization domain or the one provided from props
|
// Select the first organization domain or the one provided from props
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export default function InviteStatusCard({
|
|||||||
| "user_does_not_exist"
|
| "user_does_not_exist"
|
||||||
| "not_logged_in"
|
| "not_logged_in"
|
||||||
| "user_limit_exceeded"
|
| "user_limit_exceeded"
|
||||||
|
| "oidc_not_allowed"
|
||||||
>("rejected");
|
>("rejected");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -69,6 +70,12 @@ export default function InviteStatusCard({
|
|||||||
function cardType() {
|
function cardType() {
|
||||||
if (error.includes("Invite is not for this user")) {
|
if (error.includes("Invite is not for this user")) {
|
||||||
return "wrong_user";
|
return "wrong_user";
|
||||||
|
} else if (
|
||||||
|
error.includes(
|
||||||
|
"Invites can only be accepted by internal users."
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return "oidc_not_allowed";
|
||||||
} else if (
|
} else if (
|
||||||
error.includes(
|
error.includes(
|
||||||
"User does not exist. Please create an account first."
|
"User does not exist. Please create an account first."
|
||||||
@@ -166,6 +173,14 @@ export default function InviteStatusCard({
|
|||||||
<p className="text-center">{t("inviteCreateUser")}</p>
|
<p className="text-center">{t("inviteCreateUser")}</p>
|
||||||
</div>
|
</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") {
|
} else if (type === "user_limit_exceeded") {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -199,6 +214,10 @@ export default function InviteStatusCard({
|
|||||||
);
|
);
|
||||||
} else if (type === "user_does_not_exist") {
|
} else if (type === "user_does_not_exist") {
|
||||||
return <Button onClick={goToSignup}>{t("createAnAccount")}</Button>;
|
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") {
|
} else if (type === "user_limit_exceeded") {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
InfoSections,
|
InfoSections,
|
||||||
InfoSectionTitle
|
InfoSectionTitle
|
||||||
} from "@app/components/InfoSection";
|
} from "@app/components/InfoSection";
|
||||||
|
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
type OrgInfoCardProps = {};
|
type OrgInfoCardProps = {};
|
||||||
@@ -26,7 +27,9 @@ export default function OrgInfoCard({}: OrgInfoCardProps) {
|
|||||||
</InfoSection>
|
</InfoSection>
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>{t("orgId")}</InfoSectionTitle>
|
<InfoSectionTitle>{t("orgId")}</InfoSectionTitle>
|
||||||
<InfoSectionContent>{org.org.orgId}</InfoSectionContent>
|
<InfoSectionContent>
|
||||||
|
<CopyToClipboard text={org.org.orgId} />
|
||||||
|
</InfoSectionContent>
|
||||||
</InfoSection>
|
</InfoSection>
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>{t("subnet")}</InfoSectionTitle>
|
<InfoSectionTitle>{t("subnet")}</InfoSectionTitle>
|
||||||
|
|||||||
@@ -9,17 +9,15 @@ import {
|
|||||||
FormMessage
|
FormMessage
|
||||||
} from "@app/components/ui/form";
|
} from "@app/components/ui/form";
|
||||||
|
|
||||||
import { toast } from "@app/hooks/useToast";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
import { useRef } from "react";
|
|
||||||
import type { FieldValues, Path, UseFormReturn } from "react-hook-form";
|
import type { FieldValues, Path, UseFormReturn } from "react-hook-form";
|
||||||
import { RolesSelector, type SelectedRole } from "./roles-selector";
|
import { RolesSelector, type SelectedRole } from "./roles-selector";
|
||||||
|
|
||||||
type OrgRolesTagFieldProps<TFieldValues extends FieldValues> = {
|
type OrgRolesTagFieldProps<TFieldValues extends FieldValues> = {
|
||||||
form: Pick<
|
form: Pick<
|
||||||
UseFormReturn<TFieldValues>,
|
UseFormReturn<TFieldValues>,
|
||||||
"control" | "getValues" | "setValue"
|
"control" | "getValues" | "setValue" | "clearErrors"
|
||||||
>;
|
>;
|
||||||
orgId: string;
|
orgId: string;
|
||||||
/** Field in the form that holds Tag[] (role tags). Default: `"roles"`. */
|
/** Field in the form that holds Tag[] (role tags). Default: `"roles"`. */
|
||||||
@@ -42,46 +40,6 @@ export default function OrgRolesTagField<TFieldValues extends FieldValues>({
|
|||||||
disabled
|
disabled
|
||||||
}: OrgRolesTagFieldProps<TFieldValues>) {
|
}: OrgRolesTagFieldProps<TFieldValues>) {
|
||||||
const t = useTranslations();
|
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[]) {
|
function setRoleTags(nextValue: SelectedRole[]) {
|
||||||
const prev = form.getValues(name) as 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, {
|
form.setValue(name, [prev[prev.length - 1]] as never, {
|
||||||
shouldDirty: true
|
shouldDirty: true
|
||||||
});
|
});
|
||||||
|
form.clearErrors(name);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
form.setValue(name, next as never, { shouldDirty: true });
|
form.setValue(name, next as never, { shouldDirty: true });
|
||||||
|
|
||||||
if (next.length > 0 && !isPopoverOpenRef.current) {
|
if (next.length > 0) {
|
||||||
lastValidRolesRef.current = next;
|
form.clearErrors(name);
|
||||||
} else if (!isPopoverOpenRef.current) {
|
|
||||||
validateRolesSelection();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,9 +74,6 @@ export default function OrgRolesTagField<TFieldValues extends FieldValues>({
|
|||||||
name={name}
|
name={name}
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
const selectedRoles = (field.value ?? []) as SelectedRole[];
|
const selectedRoles = (field.value ?? []) as SelectedRole[];
|
||||||
if (!isPopoverOpenRef.current && selectedRoles.length > 0) {
|
|
||||||
lastValidRolesRef.current = selectedRoles;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormItem className="flex flex-col items-start">
|
<FormItem className="flex flex-col items-start">
|
||||||
@@ -129,7 +83,6 @@ export default function OrgRolesTagField<TFieldValues extends FieldValues>({
|
|||||||
orgId={orgId}
|
orgId={orgId}
|
||||||
selectedRoles={selectedRoles}
|
selectedRoles={selectedRoles}
|
||||||
onSelectRoles={setRoleTags}
|
onSelectRoles={setRoleTags}
|
||||||
onPopoverOpenChange={handlePopoverOpenChange}
|
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@@ -115,8 +115,11 @@ function getActionsCategories(root: boolean) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
"Resource Policy": {
|
"Resource Policy": {
|
||||||
|
[t("actionListResourcePolicies")]: "listResourcePolicies",
|
||||||
|
[t("actionCreateResourcePolicy")]: "createResourcePolicy",
|
||||||
[t("actionGetResourcePolicy")]: "getResourcePolicy",
|
[t("actionGetResourcePolicy")]: "getResourcePolicy",
|
||||||
[t("actionUpdateResourcePolicy")]: "updateResourcePolicy",
|
[t("actionUpdateResourcePolicy")]: "updateResourcePolicy",
|
||||||
|
[t("actionDeleteResourcePolicy")]: "deleteResourcePolicy",
|
||||||
[t("actionSetResourcePolicyUsers")]: "setResourcePolicyUsers",
|
[t("actionSetResourcePolicyUsers")]: "setResourcePolicyUsers",
|
||||||
[t("actionSetResourcePolicyRoles")]: "setResourcePolicyRoles",
|
[t("actionSetResourcePolicyRoles")]: "setResourcePolicyRoles",
|
||||||
[t("actionSetResourcePolicyPassword")]: "setResourcePolicyPassword",
|
[t("actionSetResourcePolicyPassword")]: "setResourcePolicyPassword",
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ type SmartLoginFormProps = {
|
|||||||
defaultUser?: string;
|
defaultUser?: string;
|
||||||
orgSignIn?: OrgSignInConfig;
|
orgSignIn?: OrgSignInConfig;
|
||||||
lastUsedIdp?: (LoginFormIDP & { orgId?: string }) | null;
|
lastUsedIdp?: (LoginFormIDP & { orgId?: string }) | null;
|
||||||
|
inviteMode?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ViewState =
|
type ViewState =
|
||||||
@@ -93,7 +94,8 @@ export default function SmartLoginForm({
|
|||||||
forceLogin,
|
forceLogin,
|
||||||
defaultUser,
|
defaultUser,
|
||||||
orgSignIn,
|
orgSignIn,
|
||||||
lastUsedIdp
|
lastUsedIdp,
|
||||||
|
inviteMode = false
|
||||||
}: SmartLoginFormProps) {
|
}: SmartLoginFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
@@ -136,6 +138,10 @@ export default function SmartLoginForm({
|
|||||||
return;
|
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) {
|
if (!result.found || result.accounts.length === 0) {
|
||||||
// No accounts found
|
// No accounts found
|
||||||
if (!isEmail || forceLogin) {
|
if (!isEmail || forceLogin) {
|
||||||
@@ -147,13 +153,36 @@ export default function SmartLoginForm({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Valid email but no accounts and not forceLogin - redirect to signup
|
// 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);
|
router.push(signupUrl);
|
||||||
return;
|
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
|
// Determine which view to show
|
||||||
const account = result.accounts[0]; // Use first account for now
|
const account = result.accounts[0]; // Use first account for now
|
||||||
|
|
||||||
|
|||||||
@@ -45,14 +45,7 @@ import {
|
|||||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||||
import { orgQueries } from "@app/lib/queries";
|
import { orgQueries } from "@app/lib/queries";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import { Bell, ChevronsUpDown, Globe, Plus, Trash2 } from "lucide-react";
|
||||||
Bell,
|
|
||||||
ChevronRightIcon,
|
|
||||||
ChevronsUpDown,
|
|
||||||
Globe,
|
|
||||||
Plus,
|
|
||||||
Trash2
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { Control, UseFormReturn } from "react-hook-form";
|
import type { Control, UseFormReturn } from "react-hook-form";
|
||||||
@@ -102,7 +95,6 @@ export function AddActionPanel({
|
|||||||
const EXTERNAL_IDS = EXTERNAL_INTEGRATIONS.map((i) => i.id);
|
const EXTERNAL_IDS = EXTERNAL_INTEGRATIONS.map((i) => i.id);
|
||||||
|
|
||||||
const [selected, setSelected] = useState<string | null>("notify");
|
const [selected, setSelected] = useState<string | null>("notify");
|
||||||
const [isPopoverOpen, setPopoverOpen] = useState(false);
|
|
||||||
|
|
||||||
const isPremiumSelected =
|
const isPremiumSelected =
|
||||||
selected !== null && EXTERNAL_IDS.includes(selected as any);
|
selected !== null && EXTERNAL_IDS.includes(selected as any);
|
||||||
@@ -139,41 +131,27 @@ export function AddActionPanel({
|
|||||||
if (!isBuiltInSelected) return;
|
if (!isBuiltInSelected) return;
|
||||||
onAdd(selected as AlertRuleFormAction["type"]);
|
onAdd(selected as AlertRuleFormAction["type"]);
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
setPopoverOpen(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3 items-start">
|
<div className="space-y-3">
|
||||||
<h3 className="font-medium">{t("alertingAddActionHeading")}</h3>
|
<StrategySelect
|
||||||
<Popover open={isPopoverOpen} onOpenChange={setPopoverOpen}>
|
options={actionTypeOptions}
|
||||||
<PopoverTrigger asChild>
|
value={selected}
|
||||||
<Button type="button" variant="outline">
|
cols={2}
|
||||||
{t("alertingSelectActionType")}
|
onChange={(v) => setSelected(v)}
|
||||||
<ChevronRightIcon className="size-4" />
|
/>
|
||||||
</Button>
|
{isPremiumSelected && <ContactSalesBanner />}
|
||||||
</PopoverTrigger>
|
{!isPremiumSelected && (
|
||||||
<PopoverContent className="shadow-md flex flex-col gap-3 w-150">
|
<Button
|
||||||
<StrategySelect
|
type="button"
|
||||||
options={actionTypeOptions}
|
disabled={!isBuiltInSelected}
|
||||||
value={selected}
|
onClick={handleAdd}
|
||||||
cols={2}
|
>
|
||||||
onChange={(v) => setSelected(v)}
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
/>
|
{t("alertingAddAction")}
|
||||||
|
</Button>
|
||||||
{isPremiumSelected ? (
|
)}
|
||||||
<ContactSalesBanner />
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
disabled={!isBuiltInSelected}
|
|
||||||
onClick={handleAdd}
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4 mr-1" />
|
|
||||||
{t("alertingAddAction")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,7 @@ import {
|
|||||||
AlertRuleSourceFields,
|
AlertRuleSourceFields,
|
||||||
AlertRuleTriggerFields
|
AlertRuleTriggerFields
|
||||||
} from "@app/components/alert-rule-editor/AlertRuleFields";
|
} from "@app/components/alert-rule-editor/AlertRuleFields";
|
||||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
|
||||||
import { SettingsContainer } from "@app/components/Settings";
|
import { SettingsContainer } from "@app/components/Settings";
|
||||||
import { SwitchInput } from "@app/components/SwitchInput";
|
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { Card, CardContent } from "@app/components/ui/card";
|
import { Card, CardContent } from "@app/components/ui/card";
|
||||||
import {
|
import {
|
||||||
@@ -21,7 +19,6 @@ import {
|
|||||||
FormMessage
|
FormMessage
|
||||||
} from "@app/components/ui/form";
|
} from "@app/components/ui/form";
|
||||||
import { Input } from "@app/components/ui/input";
|
import { Input } from "@app/components/ui/input";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import {
|
import {
|
||||||
buildFormSchema,
|
buildFormSchema,
|
||||||
@@ -30,15 +27,19 @@ import {
|
|||||||
type AlertRuleFormValues
|
type AlertRuleFormValues
|
||||||
} from "@app/lib/alertRuleForm";
|
} from "@app/lib/alertRuleForm";
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
|
||||||
import type { CreateAlertRuleResponse } from "@server/routers/alertRule/types";
|
import type { CreateAlertRuleResponse } from "@server/routers/alertRule/types";
|
||||||
import type { AxiosResponse } from "axios";
|
import type { AxiosResponse } from "axios";
|
||||||
import { Cog, Flag, Zap, ZapIcon } from "lucide-react";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useTranslations } from "next-intl";
|
import { ChevronLeft, Cog, Flag, Zap } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useActionState, useMemo, useTransition, type ReactNode } from "react";
|
import { useMemo, useState, type ReactNode } from "react";
|
||||||
import { useFieldArray, useForm, type Resolver } from "react-hook-form";
|
import { useFieldArray, useForm, type Resolver } from "react-hook-form";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||||
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import { Badge } from "../ui/badge";
|
import { Badge } from "../ui/badge";
|
||||||
|
|
||||||
const FORM_ID = "alert-rule-form";
|
const FORM_ID = "alert-rule-form";
|
||||||
@@ -114,6 +115,7 @@ export default function AlertRuleGraphEditor({
|
|||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const schema = useMemo(() => buildFormSchema(t), [t]);
|
const schema = useMemo(() => buildFormSchema(t), [t]);
|
||||||
const form = useForm<AlertRuleFormValues>({
|
const form = useForm<AlertRuleFormValues>({
|
||||||
resolver: zodResolver(schema) as Resolver<AlertRuleFormValues>,
|
resolver: zodResolver(schema) as Resolver<AlertRuleFormValues>,
|
||||||
@@ -125,22 +127,8 @@ export default function AlertRuleGraphEditor({
|
|||||||
name: "actions"
|
name: "actions"
|
||||||
});
|
});
|
||||||
|
|
||||||
const saveAlert = async () => {
|
const onSubmit = form.handleSubmit(async (values) => {
|
||||||
const isValid = await form.trigger();
|
setIsSaving(true);
|
||||||
if (!isValid) {
|
|
||||||
const values = form.getValues();
|
|
||||||
if (values.actions.length === 0) {
|
|
||||||
toast({
|
|
||||||
variant: "warning",
|
|
||||||
title: t("alertingNoActionsTitle"),
|
|
||||||
description: t("alertingNoActionsSaveDescription")
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const values = form.getValues();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = formValuesToApiPayload(values);
|
const payload = formValuesToApiPayload(values);
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
@@ -170,48 +158,14 @@ export default function AlertRuleGraphEditor({
|
|||||||
description: formatAxiosError(e),
|
description: formatAxiosError(e),
|
||||||
variant: "destructive"
|
variant: "destructive"
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
const testAlert = async () => {
|
|
||||||
const isValid = await form.trigger("actions");
|
|
||||||
const values = form.getValues();
|
|
||||||
|
|
||||||
if (!isValid) {
|
|
||||||
if (values.actions.length === 0) {
|
|
||||||
toast({
|
|
||||||
variant: "warning",
|
|
||||||
title: t("alertingNoActionsTitle"),
|
|
||||||
description: t("alertingNoActionsTestDescription")
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload = formValuesToApiPayload(values);
|
|
||||||
await api.post(`/org/${orgId}/test-alert-rule`, payload);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: t("alertingTestAlertSent"),
|
|
||||||
description: t("alertingTestAlertSentDescription")
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
toast({
|
|
||||||
title: t("error"),
|
|
||||||
description: formatAxiosError(e),
|
|
||||||
variant: "destructive"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const [, formAction, isSaving] = useActionState(saveAlert, null);
|
|
||||||
const [isTestingAlert, startTransition] = useTransition();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form id={FORM_ID} action={formAction}>
|
<form id={FORM_ID} onSubmit={onSubmit}>
|
||||||
<SettingsContainer>
|
<SettingsContainer>
|
||||||
<PaidFeaturesAlert tiers={tierMatrix.alertingRules} />
|
<PaidFeaturesAlert tiers={tierMatrix.alertingRules} />
|
||||||
<div className="flex flex-col lg:flex-row gap-6 lg:gap-8 items-start">
|
<div className="flex flex-col lg:flex-row gap-6 lg:gap-8 items-start">
|
||||||
@@ -309,29 +263,14 @@ export default function AlertRuleGraphEditor({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col items-center w-full gap-3">
|
<Button
|
||||||
<Button
|
type="submit"
|
||||||
type="submit"
|
className="w-full"
|
||||||
className="w-full"
|
disabled={isSaving}
|
||||||
disabled={isSaving}
|
loading={isSaving}
|
||||||
loading={isSaving}
|
>
|
||||||
>
|
{t("save")}
|
||||||
{t("save")}
|
</Button>
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="w-full gap-1.5"
|
|
||||||
onClick={() =>
|
|
||||||
startTransition(testAlert)
|
|
||||||
}
|
|
||||||
loading={isTestingAlert}
|
|
||||||
>
|
|
||||||
{t("alertingTestRule")}
|
|
||||||
<ZapIcon className="size-3.5 flex-none" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -15,11 +15,14 @@ import {
|
|||||||
} from "@app/components/ui/popover";
|
} from "@app/components/ui/popover";
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
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 { usePathname, useRouter } from "next/navigation";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Button } from "@app/components/ui/button";
|
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 = {
|
type LauncherOrgSelectorProps = {
|
||||||
orgId?: string;
|
orgId?: string;
|
||||||
@@ -31,9 +34,16 @@ export function LauncherOrgSelector({ orgId, orgs }: LauncherOrgSelectorProps) {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
const { env } = useEnvContext();
|
||||||
|
const { user } = useUserContext();
|
||||||
|
|
||||||
const selectedOrg = orgs?.find((org) => org.orgId === orgId);
|
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(() => {
|
const sortedOrgs = useMemo(() => {
|
||||||
if (!orgs?.length) {
|
if (!orgs?.length) {
|
||||||
return orgs ?? [];
|
return orgs ?? [];
|
||||||
@@ -108,6 +118,22 @@ export function LauncherOrgSelector({ orgId, orgs }: LauncherOrgSelectorProps) {
|
|||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
</CommandList>
|
</CommandList>
|
||||||
</Command>
|
</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>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
export function getRandomItemInArray<T>(array: T[]) {
|
|
||||||
// Source - https://stackoverflow.com/a/4550514
|
|
||||||
const randomElement = array[Math.floor(Math.random() * array.length)];
|
|
||||||
return randomElement;
|
|
||||||
}
|
|
||||||
+1
-1
@@ -1371,7 +1371,7 @@ export const approvalQueries = {
|
|||||||
},
|
},
|
||||||
refetchInterval: (query) => {
|
refetchInterval: (query) => {
|
||||||
if (query.state.data) {
|
if (query.state.data) {
|
||||||
return durationToMs(1.5, "minutes");
|
return durationToMs(30, "seconds");
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user