Compare commits

..

12 Commits

Author SHA1 Message Date
dependabot[bot] 225ca36308 Bump the docker-dependencies group across 1 directory with 2 updates
Bumps the docker-dependencies group with 2 updates in the / directory: docker/library/node and node.


Updates `docker/library/node` from 24-slim to 26-slim

Updates `node` from 24-alpine to 26-alpine

---
updated-dependencies:
- dependency-name: docker/library/node
  dependency-version: 26-slim
  dependency-type: direct:production
  dependency-group: docker-dependencies
- dependency-name: node
  dependency-version: 26-alpine
  dependency-type: direct:production
  dependency-group: docker-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-13 01:33:07 +00:00
Owen Schwartz 048e4fc73c Merge pull request #3574 from fosrl/dev
1.21.1-s.5
2026-08-12 16:34:31 -04:00
miloschwartz 71d9d8f010 fix accept invite as idp user 2026-08-12 16:25:20 -04:00
miloschwartz dd78c2cc08 dont show servers idps in create user when idp mode is org on enterprise 2026-08-12 15:20:41 -04:00
Owen 295e38d2af Remove arbitrary max 50 limit from email whitelist
Fix #3568
2026-08-12 09:11:10 -04:00
Owen 02e4fe8b48 Show the provided domains when changing properly 2026-08-11 09:40:55 -04:00
Owen ed46afd81a add subscription override column 2026-08-10 11:15:48 -04:00
miloschwartz 3dc9c100e9 better form feedback when saving roles 2026-08-10 11:02:44 -04:00
miloschwartz 02e97d6ae4 add copy button to org id 2026-08-10 10:44:25 -04:00
miloschwartz 996160fadc add missing resource policy actions to api key closes #3542 2026-08-10 10:38:40 -04:00
Owen Schwartz d04740fede Merge pull request #3537 from fosrl/dev
1.21.1-s.4
2026-08-06 14:07:42 -04:00
Owen Schwartz b7c0669c38 Merge pull request #3528 from fosrl/dev
1.21.1-s.3
2026-08-04 17:46:49 -04:00
39 changed files with 287 additions and 696 deletions
+2 -2
View File
@@ -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
View File
@@ -1,4 +1,4 @@
FROM node:24-alpine FROM node:26-alpine
WORKDIR /app WORKDIR /app
+5 -8
View File
@@ -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",
-1
View File
@@ -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",
+2 -1
View File
@@ -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", {
+2 -1
View File
@@ -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", {
+3 -30
View File
@@ -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}>
-1
View File
@@ -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([]),
+2 -1
View File
@@ -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 -31
View File
@@ -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";
} }
} }
} }
+10 -31
View File
@@ -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
}; });
} }
} }
+1 -2
View File
@@ -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
-8
View File
@@ -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([]),
-23
View File
@@ -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>;
}
+4 -4
View File
@@ -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()))
}); });
+24 -10
View File
@@ -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}`
); );
}); });
+11
View File
@@ -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)
+1 -1
View File
@@ -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();
+5 -2
View File
@@ -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 &&
+13 -2
View File
@@ -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
+19
View File
@@ -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
+4 -1
View File
@@ -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>
+4 -51
View File
@@ -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>
+3
View File
@@ -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",
+33 -4
View File
@@ -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>
); );
-5
View File
@@ -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
View File
@@ -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;
} }