mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-08 21:48:02 +02:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 403b8a12e4 | |||
| 3f305e4d5c | |||
| 6689a8d93e | |||
| e91c344e64 | |||
| 4048fa274a | |||
| 82b86263dc | |||
| 835a30cffe | |||
| 18b90da6ab | |||
| f079714caf | |||
| efd2792197 | |||
| efe22c889c | |||
| 7f2b3eb481 | |||
| 81be4a35d9 | |||
| e84da6a8df | |||
| 3ef3ede7df | |||
| 1e521b0b54 | |||
| 59ea701304 | |||
| 7d7c54107d | |||
| f0f6673d69 | |||
| 71561d0e65 | |||
| af87edf3a6 | |||
| 13caad18c7 | |||
| 47522b7e3a | |||
| c8c8d74452 | |||
| e7098963d6 | |||
| f015fb592b | |||
| c099167905 | |||
| b0e274f5a9 | |||
| e0a8721207 |
@@ -1804,6 +1804,10 @@
|
|||||||
"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",
|
||||||
|
"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,6 +151,7 @@ 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",
|
||||||
|
|||||||
@@ -258,7 +258,24 @@ export const configSchema = z
|
|||||||
pp_transport_prefix: z
|
pp_transport_prefix: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.default("pp-transport-v")
|
.default("pp-transport-v"),
|
||||||
|
rate_limit: z
|
||||||
|
.object({
|
||||||
|
average: z
|
||||||
|
.number()
|
||||||
|
.positive()
|
||||||
|
.gt(0)
|
||||||
|
.optional()
|
||||||
|
.default(30),
|
||||||
|
burst: z
|
||||||
|
.number()
|
||||||
|
.positive()
|
||||||
|
.gt(0)
|
||||||
|
.optional()
|
||||||
|
.default(50)
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.prefault({})
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
.prefault({}),
|
.prefault({}),
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import logger from "@server/logger";
|
||||||
|
import type {
|
||||||
|
EmailAlertAction,
|
||||||
|
TestAlertContext
|
||||||
|
} from "@server/routers/alertRule/types";
|
||||||
|
import { sendAlertEmail } from "./sendAlertEmail";
|
||||||
|
import type { db, alertEmailRecipients, users, userOrgRoles } from "@server/db";
|
||||||
|
import type { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
export async function processTestAlerts(context: TestAlertContext) {
|
||||||
|
const emailActions = context.actions.filter(
|
||||||
|
(action) => action.type === "email"
|
||||||
|
);
|
||||||
|
// Process email actions
|
||||||
|
for (const action of emailActions) {
|
||||||
|
try {
|
||||||
|
const recipients = await resolveEmailRecipients(action);
|
||||||
|
if (recipients.length > 0) {
|
||||||
|
await sendAlertEmail(recipients, context);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`processAlerts: failed to send alert email`, 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 emailSet = new Set<string>();
|
||||||
|
|
||||||
|
// for (const row of rows) {
|
||||||
|
// if (row.email) {
|
||||||
|
// emailSet.add(row.email);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if (row.userId) {
|
||||||
|
// const [user] = await db
|
||||||
|
// .select({ email: users.email })
|
||||||
|
// .from(users)
|
||||||
|
// .where(eq(users.userId, row.userId))
|
||||||
|
// .limit(1);
|
||||||
|
// if (user?.email) {
|
||||||
|
// emailSet.add(user.email);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if (row.roleId) {
|
||||||
|
// // Find all users with this role via userOrgRoles
|
||||||
|
// const roleUsers = await db
|
||||||
|
// .select({ email: users.email })
|
||||||
|
// .from(userOrgRoles)
|
||||||
|
// .innerJoin(users, eq(userOrgRoles.userId, users.userId))
|
||||||
|
// .where(eq(userOrgRoles.roleId, Number(row.roleId)));
|
||||||
|
|
||||||
|
// for (const u of roleUsers) {
|
||||||
|
// if (u.email) {
|
||||||
|
// emailSet.add(u.email);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
return Array.from(emailSet);
|
||||||
|
}
|
||||||
@@ -58,6 +58,8 @@ import { build } from "@server/build";
|
|||||||
const redirectHttpsMiddlewareName = "redirect-to-https";
|
const redirectHttpsMiddlewareName = "redirect-to-https";
|
||||||
const redirectToRootMiddlewareName = "redirect-to-root";
|
const redirectToRootMiddlewareName = "redirect-to-root";
|
||||||
const badgerMiddlewareName = "badger";
|
const badgerMiddlewareName = "badger";
|
||||||
|
const landingRateLimitMiddlewareName = "landing-ratelimit";
|
||||||
|
const bgRateLimitMiddlewareName = "bg-ratelimit";
|
||||||
|
|
||||||
// Define extended target type with site information
|
// Define extended target type with site information
|
||||||
type TargetWithSite = Target & {
|
type TargetWithSite = Target & {
|
||||||
@@ -418,6 +420,8 @@ export async function getTraefikConfig(
|
|||||||
// logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`);
|
// logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const traefikRateLimit = config.getRawConfig().traefik.rate_limit;
|
||||||
|
|
||||||
const config_output: any = {
|
const config_output: any = {
|
||||||
http: {
|
http: {
|
||||||
middlewares: {
|
middlewares: {
|
||||||
@@ -432,6 +436,18 @@ export async function getTraefikConfig(
|
|||||||
replacement: "${1}://${2}/auth/org",
|
replacement: "${1}://${2}/auth/org",
|
||||||
permanent: false
|
permanent: false
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
[landingRateLimitMiddlewareName]: {
|
||||||
|
rateLimit: {
|
||||||
|
average: traefikRateLimit.average,
|
||||||
|
burst: traefikRateLimit.burst
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[bgRateLimitMiddlewareName]: {
|
||||||
|
rateLimit: {
|
||||||
|
average: traefikRateLimit.average,
|
||||||
|
burst: traefikRateLimit.burst
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1055,6 +1071,7 @@ export async function getTraefikConfig(
|
|||||||
config.getRawConfig().traefik.additional_middlewares || [];
|
config.getRawConfig().traefik.additional_middlewares || [];
|
||||||
const routerMiddlewares = [
|
const routerMiddlewares = [
|
||||||
badgerMiddlewareName,
|
badgerMiddlewareName,
|
||||||
|
bgRateLimitMiddlewareName,
|
||||||
...additionalMiddlewares
|
...additionalMiddlewares
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -1539,6 +1556,7 @@ export async function getTraefikConfig(
|
|||||||
entryPoints: [
|
entryPoints: [
|
||||||
config.getRawConfig().traefik.https_entrypoint
|
config.getRawConfig().traefik.https_entrypoint
|
||||||
],
|
],
|
||||||
|
middlewares: [landingRateLimitMiddlewareName],
|
||||||
service: "landing-service",
|
service: "landing-service",
|
||||||
rule: `Host(\`${fullDomain}\`) && (PathRegexp(\`^/auth/resource/[^/]+$\`) || PathRegexp(\`^/auth/idp/[0-9]+/oidc/callback\`) || PathPrefix(\`/_next\`) || Path(\`/auth/org\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
|
rule: `Host(\`${fullDomain}\`) && (PathRegexp(\`^/auth/resource/[^/]+$\`) || PathRegexp(\`^/auth/idp/[0-9]+/oidc/callback\`) || PathPrefix(\`/_next\`) || Path(\`/auth/org\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
|
||||||
priority: 203,
|
priority: 203,
|
||||||
@@ -1557,7 +1575,10 @@ export async function getTraefikConfig(
|
|||||||
entryPoints: [
|
entryPoints: [
|
||||||
config.getRawConfig().traefik.https_entrypoint
|
config.getRawConfig().traefik.https_entrypoint
|
||||||
],
|
],
|
||||||
middlewares: [redirectToRootMiddlewareName],
|
middlewares: [
|
||||||
|
landingRateLimitMiddlewareName,
|
||||||
|
redirectToRootMiddlewareName
|
||||||
|
],
|
||||||
service: "landing-service",
|
service: "landing-service",
|
||||||
rule: `Host(\`${fullDomain}\`)`,
|
rule: `Host(\`${fullDomain}\`)`,
|
||||||
priority: 202,
|
priority: 202,
|
||||||
|
|||||||
@@ -16,3 +16,4 @@ export * from "./updateAlertRule";
|
|||||||
export * from "./deleteAlertRule";
|
export * from "./deleteAlertRule";
|
||||||
export * from "./listAlertRules";
|
export * from "./listAlertRules";
|
||||||
export * from "./getAlertRule";
|
export * from "./getAlertRule";
|
||||||
|
export * from "./testAlertRule";
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
/*
|
||||||
|
* 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 { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import {
|
||||||
|
alertRules,
|
||||||
|
alertSites,
|
||||||
|
alertHealthChecks,
|
||||||
|
alertResources
|
||||||
|
} from "@server/db";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { and, asc, desc, eq, inArray, like, or, sql } from "drizzle-orm";
|
||||||
|
import { ListAlertRulesResponse } from "@server/routers/alertRule/types";
|
||||||
|
|
||||||
|
const paramsSchema = z.strictObject({
|
||||||
|
orgId: z.string().nonempty()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SITE_EVENT_TYPES = [
|
||||||
|
"site_online",
|
||||||
|
"site_offline",
|
||||||
|
"site_toggle"
|
||||||
|
] as const;
|
||||||
|
export const HC_EVENT_TYPES = [
|
||||||
|
"health_check_healthy",
|
||||||
|
"health_check_unhealthy",
|
||||||
|
"health_check_toggle"
|
||||||
|
] as const;
|
||||||
|
export const RESOURCE_EVENT_TYPES = [
|
||||||
|
"resource_healthy",
|
||||||
|
"resource_unhealthy",
|
||||||
|
"resource_degraded",
|
||||||
|
"resource_toggle"
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const webhookActionSchema = z.strictObject({
|
||||||
|
webhookUrl: z.string().url(),
|
||||||
|
config: z.string().optional(),
|
||||||
|
enabled: z.boolean().optional().default(true)
|
||||||
|
});
|
||||||
|
|
||||||
|
const bodySchema = z.strictObject({
|
||||||
|
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()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: process alert rule
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,12 +10,12 @@
|
|||||||
*
|
*
|
||||||
* This file is not licensed under the AGPLv3.
|
* This file is not licensed under the AGPLv3.
|
||||||
*/
|
*/
|
||||||
import { certificates, db, domains, orgDomains } from "@server/db";
|
import { certificates, db, domainNamespaces, domains, orgDomains } from "@server/db";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { type GetBatchedCertificateResponse } from "@server/routers/certificates/types";
|
import { type GetBatchedCertificateResponse } from "@server/routers/certificates/types";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { and, eq, inArray, or } from "drizzle-orm";
|
import { and, eq, inArray, isNotNull, or } from "drizzle-orm";
|
||||||
import { NextFunction, Request, Response } from "express";
|
import { NextFunction, Request, Response } from "express";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -46,7 +46,7 @@ const getCertificateQuerySchema = z.object({
|
|||||||
|
|
||||||
async function query(orgId: string, domainList: string[]) {
|
async function query(orgId: string, domainList: string[]) {
|
||||||
// Try to get CNAME certificates first
|
// Try to get CNAME certificates first
|
||||||
let existingCertificates = await db
|
const existingCertificates = await db
|
||||||
.select({
|
.select({
|
||||||
certId: certificates.certId,
|
certId: certificates.certId,
|
||||||
domain: certificates.domain,
|
domain: certificates.domain,
|
||||||
@@ -63,26 +63,43 @@ async function query(orgId: string, domainList: string[]) {
|
|||||||
})
|
})
|
||||||
.from(certificates)
|
.from(certificates)
|
||||||
.innerJoin(domains, eq(certificates.domainId, domains.domainId))
|
.innerJoin(domains, eq(certificates.domainId, domains.domainId))
|
||||||
.innerJoin(
|
.leftJoin(
|
||||||
orgDomains,
|
orgDomains,
|
||||||
and(
|
and(
|
||||||
eq(domains.domainId, orgDomains.domainId),
|
eq(domains.domainId, orgDomains.domainId),
|
||||||
eq(orgDomains.orgId, orgId)
|
eq(orgDomains.orgId, orgId)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.where(and(inArray(certificates.domain, domainList)));
|
.leftJoin(
|
||||||
|
domainNamespaces,
|
||||||
|
eq(domains.domainId, domainNamespaces.domainId)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(certificates.domain, domainList),
|
||||||
|
// Namespace domains are shared across all orgs, so they skip
|
||||||
|
// the org-ownership check (mirrors verifyCertificateAccess).
|
||||||
|
or(
|
||||||
|
isNotNull(orgDomains.orgId),
|
||||||
|
isNotNull(domainNamespaces.domainNamespaceId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
// All non resolved domain certificates might be `ns` or `wildcard`,
|
// All non resolved domain certificates might be `ns` or `wildcard`,
|
||||||
// which means exact domain certificates do not
|
// which means exact domain certificates do not exist
|
||||||
const nonAvailableCertificates = existingCertificates
|
const foundDomains = new Set(
|
||||||
.filter((cert) => !domainList.includes(cert.domain))
|
existingCertificates.map((cert) => cert.domain)
|
||||||
.map((cert) => cert.domain);
|
);
|
||||||
|
const domainsWithMissingCertificates = domainList.filter(
|
||||||
|
(domain) => !foundDomains.has(domain)
|
||||||
|
);
|
||||||
|
|
||||||
if (nonAvailableCertificates.length > 0) {
|
if (domainsWithMissingCertificates.length > 0) {
|
||||||
const domainLevelDownSet = new Set<string>();
|
const domainLevelDownSet = new Set<string>();
|
||||||
const wildcardDomainSet = new Set<string>();
|
const wildcardDomainSet = new Set<string>();
|
||||||
|
|
||||||
for (const domain of nonAvailableCertificates) {
|
for (const domain of domainsWithMissingCertificates) {
|
||||||
const domainLevelDown = domain.split(".").slice(1).join(".");
|
const domainLevelDown = domain.split(".").slice(1).join(".");
|
||||||
const wildcardPrefixed = `*.${domainLevelDown}`;
|
const wildcardPrefixed = `*.${domainLevelDown}`;
|
||||||
domainLevelDownSet.add(domainLevelDown);
|
domainLevelDownSet.add(domainLevelDown);
|
||||||
@@ -107,19 +124,27 @@ async function query(orgId: string, domainList: string[]) {
|
|||||||
})
|
})
|
||||||
.from(certificates)
|
.from(certificates)
|
||||||
.innerJoin(domains, eq(certificates.domainId, domains.domainId))
|
.innerJoin(domains, eq(certificates.domainId, domains.domainId))
|
||||||
.innerJoin(
|
.leftJoin(
|
||||||
orgDomains,
|
orgDomains,
|
||||||
and(
|
and(
|
||||||
eq(domains.domainId, orgDomains.domainId),
|
eq(domains.domainId, orgDomains.domainId),
|
||||||
eq(orgDomains.orgId, orgId)
|
eq(orgDomains.orgId, orgId)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.leftJoin(
|
||||||
|
domainNamespaces,
|
||||||
|
eq(domains.domainId, domainNamespaces.domainId)
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(certificates.wildcard, true),
|
eq(certificates.wildcard, true),
|
||||||
or(
|
or(
|
||||||
inArray(certificates.domain, [...domainLevelDownSet]),
|
inArray(certificates.domain, [...domainLevelDownSet]),
|
||||||
inArray(certificates.domain, [...wildcardDomainSet])
|
inArray(certificates.domain, [...wildcardDomainSet])
|
||||||
|
),
|
||||||
|
or(
|
||||||
|
isNotNull(orgDomains.orgId),
|
||||||
|
isNotNull(domainNamespaces.domainNamespaceId)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -131,6 +156,7 @@ async function query(orgId: string, domainList: string[]) {
|
|||||||
for (const domain of domainList) {
|
for (const domain of domainList) {
|
||||||
const domainLevelDown = domain.split(".").slice(1).join(".");
|
const domainLevelDown = domain.split(".").slice(1).join(".");
|
||||||
const wildcardPrefixed = `*.${domainLevelDown}`;
|
const wildcardPrefixed = `*.${domainLevelDown}`;
|
||||||
|
|
||||||
certificateMap[domain] =
|
certificateMap[domain] =
|
||||||
existingCertificates.find(
|
existingCertificates.find(
|
||||||
(cert) =>
|
(cert) =>
|
||||||
|
|||||||
@@ -808,6 +808,14 @@ authenticated.get(
|
|||||||
alertRule.listAlertRules
|
alertRule.listAlertRules
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/org/:orgId/alert-rule/test",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.testAlertRule),
|
||||||
|
alertRule.testAlertRule
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/alert-rule/:alertRuleId",
|
"/org/:orgId/alert-rule/:alertRuleId",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
|
|||||||
@@ -124,3 +124,26 @@ 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?: string[];
|
||||||
|
emails?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WebhookAlertAction = {
|
||||||
|
type: "webhook";
|
||||||
|
webhookUrl: string;
|
||||||
|
enabled: boolean;
|
||||||
|
config?: string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
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>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -127,6 +127,9 @@ export async function verifyResourceSession(
|
|||||||
// Extract HTTP Basic Auth credentials if present
|
// Extract HTTP Basic Auth credentials if present
|
||||||
const clientHeaderAuth = extractBasicAuth(headers);
|
const clientHeaderAuth = extractBasicAuth(headers);
|
||||||
|
|
||||||
|
const clientUserAgent = headers?.["user-agent"] || headers?.["User-Agent"];
|
||||||
|
const clientIsBrowser = isBrowserUserAgent(clientUserAgent);
|
||||||
|
|
||||||
const clientIp = requestIp
|
const clientIp = requestIp
|
||||||
? stripPortFromHost(requestIp, badgerVersion)
|
? stripPortFromHost(requestIp, badgerVersion)
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -313,9 +316,14 @@ export async function verifyResourceSession(
|
|||||||
return allowed(res, undefined, dontStripSession);
|
return allowed(res, undefined, dontStripSession);
|
||||||
}
|
}
|
||||||
|
|
||||||
const redirectPath = `/auth/resource/${encodeURIComponent(
|
// Only offer a browser redirect to clients that can actually follow one and log in
|
||||||
|
// (an interactive browser). Non-browser clients (curl, scripts, bots, etc.) just get
|
||||||
|
// an unauthorized response from Badger instead of a login redirect URL.
|
||||||
|
const redirectPath = clientIsBrowser
|
||||||
|
? `/auth/resource/${encodeURIComponent(
|
||||||
resource.resourceGuid
|
resource.resourceGuid
|
||||||
)}?redirect=${encodeURIComponent(originalRequestURL)}`;
|
)}?redirect=${encodeURIComponent(originalRequestURL)}`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
// check for access token in headers
|
// check for access token in headers
|
||||||
if (
|
if (
|
||||||
@@ -1476,6 +1484,46 @@ async function getCountryCodeFromIp(ip: string): Promise<string | undefined> {
|
|||||||
return cachedCountryCode;
|
return cachedCountryCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Permissive by default: only reject known non-browser clients or a missing
|
||||||
|
// User-Agent (real browsers always send one). This avoids blocking real
|
||||||
|
// browsers whose UA string doesn't match a hardcoded allow-list.
|
||||||
|
const NON_BROWSER_USER_AGENT_PATTERNS = [
|
||||||
|
/curl/,
|
||||||
|
/wget/,
|
||||||
|
/python-requests/,
|
||||||
|
/python-urllib/,
|
||||||
|
/go-http-client/,
|
||||||
|
/okhttp/,
|
||||||
|
/axios/,
|
||||||
|
/node-fetch/,
|
||||||
|
/postmanruntime/,
|
||||||
|
/insomnia/,
|
||||||
|
/libwww-perl/,
|
||||||
|
/java\//,
|
||||||
|
/ruby/,
|
||||||
|
/php/,
|
||||||
|
/bot/,
|
||||||
|
/spider/,
|
||||||
|
/crawler/,
|
||||||
|
/headlesschrome/,
|
||||||
|
/phantomjs/,
|
||||||
|
/httpclient/,
|
||||||
|
/prometheus/,
|
||||||
|
/go-resty/,
|
||||||
|
/apache-httpclient/,
|
||||||
|
/scrapy/
|
||||||
|
];
|
||||||
|
|
||||||
|
function isBrowserUserAgent(userAgent: string | undefined): boolean {
|
||||||
|
if (!userAgent) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ua = userAgent.toLowerCase();
|
||||||
|
|
||||||
|
return !NON_BROWSER_USER_AGENT_PATTERNS.some((pattern) => pattern.test(ua));
|
||||||
|
}
|
||||||
|
|
||||||
function extractBasicAuth(
|
function extractBasicAuth(
|
||||||
headers: Record<string, string> | undefined
|
headers: Record<string, string> | undefined
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
|
|||||||
@@ -67,12 +67,12 @@ const listUserDevicesSchema = z.strictObject({
|
|||||||
}),
|
}),
|
||||||
query: z.string().optional(),
|
query: z.string().optional(),
|
||||||
sort_by: z
|
sort_by: z
|
||||||
.enum(["megabytesIn", "megabytesOut"])
|
.enum(["megabytesIn", "megabytesOut", "firstSeen", "lastSeen"])
|
||||||
.optional()
|
.optional()
|
||||||
.catch(undefined)
|
.catch(undefined)
|
||||||
.openapi({
|
.openapi({
|
||||||
type: "string",
|
type: "string",
|
||||||
enum: ["megabytesIn", "megabytesOut"],
|
enum: ["megabytesIn", "megabytesOut", "firstSeen", "lastSeen"],
|
||||||
description: "Field to sort by"
|
description: "Field to sort by"
|
||||||
}),
|
}),
|
||||||
order: z
|
order: z
|
||||||
@@ -183,7 +183,9 @@ function queryUserDevicesBase() {
|
|||||||
fingerprintArch: currentFingerprint.arch,
|
fingerprintArch: currentFingerprint.arch,
|
||||||
fingerprintSerialNumber: currentFingerprint.serialNumber,
|
fingerprintSerialNumber: currentFingerprint.serialNumber,
|
||||||
fingerprintUsername: currentFingerprint.username,
|
fingerprintUsername: currentFingerprint.username,
|
||||||
fingerprintHostname: currentFingerprint.hostname
|
fingerprintHostname: currentFingerprint.hostname,
|
||||||
|
firstSeen: currentFingerprint.firstSeen,
|
||||||
|
lastSeen: currentFingerprint.lastSeen
|
||||||
})
|
})
|
||||||
.from(clients)
|
.from(clients)
|
||||||
.leftJoin(orgs, eq(clients.orgId, orgs.orgId))
|
.leftJoin(orgs, eq(clients.orgId, orgs.orgId))
|
||||||
@@ -389,14 +391,23 @@ export async function listUserDevices(
|
|||||||
|
|
||||||
const countQuery = db.$count(baseQuery.as("filtered_clients"));
|
const countQuery = db.$count(baseQuery.as("filtered_clients"));
|
||||||
|
|
||||||
|
const sortColumn =
|
||||||
|
sort_by === "firstSeen"
|
||||||
|
? currentFingerprint.firstSeen
|
||||||
|
: sort_by === "lastSeen"
|
||||||
|
? currentFingerprint.lastSeen
|
||||||
|
: sort_by
|
||||||
|
? clients[sort_by]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const listDevicesQuery = baseQuery
|
const listDevicesQuery = baseQuery
|
||||||
.limit(pageSize)
|
.limit(pageSize)
|
||||||
.offset(pageSize * (page - 1))
|
.offset(pageSize * (page - 1))
|
||||||
.orderBy(
|
.orderBy(
|
||||||
sort_by
|
sortColumn
|
||||||
? order === "asc"
|
? order === "asc"
|
||||||
? asc(clients[sort_by])
|
? asc(sortColumn)
|
||||||
: desc(clients[sort_by])
|
: desc(sortColumn)
|
||||||
: asc(clients.clientId)
|
: asc(clients.clientId)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -112,7 +112,9 @@ export async function updateHolePunch(
|
|||||||
destinations: destinations
|
destinations: destinations
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!(error instanceof Error && error.message === "Exit node not allowed")) {
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
|
}
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ const createRoleSchema = z.strictObject({
|
|||||||
export const defaultRoleAllowedActions: ActionsEnum[] = [
|
export const defaultRoleAllowedActions: ActionsEnum[] = [
|
||||||
ActionsEnum.getOrg,
|
ActionsEnum.getOrg,
|
||||||
ActionsEnum.getResource,
|
ActionsEnum.getResource,
|
||||||
ActionsEnum.listResources
|
ActionsEnum.listResources,
|
||||||
|
ActionsEnum.getSiteResource,
|
||||||
|
ActionsEnum.listSiteResources
|
||||||
];
|
];
|
||||||
|
|
||||||
export type CreateRoleBody = z.infer<typeof createRoleSchema>;
|
export type CreateRoleBody = z.infer<typeof createRoleSchema>;
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import {
|
|||||||
DB_TYPE,
|
DB_TYPE,
|
||||||
Label,
|
Label,
|
||||||
SiteResource,
|
SiteResource,
|
||||||
|
roleSiteResources,
|
||||||
siteNetworks,
|
siteNetworks,
|
||||||
siteResourceLabels,
|
siteResourceLabels,
|
||||||
siteResources,
|
siteResources,
|
||||||
sites,
|
sites,
|
||||||
labels
|
labels,
|
||||||
|
userSiteResources
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
@@ -323,7 +325,48 @@ export async function listAllSiteResourcesByOrg(
|
|||||||
labels: labelFilter
|
labels: labelFilter
|
||||||
} = parsedQuery.data;
|
} = parsedQuery.data;
|
||||||
|
|
||||||
const conditions = [and(eq(siteResources.orgId, orgId))];
|
let accessibleSiteResourceIds: number[];
|
||||||
|
if (req.user) {
|
||||||
|
const accessibleSiteResources = await db
|
||||||
|
.select({
|
||||||
|
siteResourceId: sql<number>`COALESCE(${userSiteResources.siteResourceId}, ${roleSiteResources.siteResourceId})`
|
||||||
|
})
|
||||||
|
.from(userSiteResources)
|
||||||
|
.fullJoin(
|
||||||
|
roleSiteResources,
|
||||||
|
eq(
|
||||||
|
userSiteResources.siteResourceId,
|
||||||
|
roleSiteResources.siteResourceId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
or(
|
||||||
|
eq(userSiteResources.userId, req.user.userId),
|
||||||
|
inArray(
|
||||||
|
roleSiteResources.roleId,
|
||||||
|
req.userOrgRoleIds ?? []
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
accessibleSiteResourceIds = accessibleSiteResources.map(
|
||||||
|
(row) => row.siteResourceId
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const allOrgSiteResources = await db
|
||||||
|
.select({ siteResourceId: siteResources.siteResourceId })
|
||||||
|
.from(siteResources)
|
||||||
|
.where(eq(siteResources.orgId, orgId));
|
||||||
|
accessibleSiteResourceIds = allOrgSiteResources.map(
|
||||||
|
(row) => row.siteResourceId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const conditions = [
|
||||||
|
and(
|
||||||
|
eq(siteResources.orgId, orgId),
|
||||||
|
inArray(siteResources.siteResourceId, accessibleSiteResourceIds)
|
||||||
|
)
|
||||||
|
];
|
||||||
|
|
||||||
if (siteId != null) {
|
if (siteId != null) {
|
||||||
// Keep inner joins here: filtering by a specific site implies the
|
// Keep inner joins here: filtering by a specific site implies the
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, networks, siteNetworks } from "@server/db";
|
import {
|
||||||
|
db,
|
||||||
|
networks,
|
||||||
|
roleSiteResources,
|
||||||
|
siteNetworks,
|
||||||
|
userSiteResources
|
||||||
|
} from "@server/db";
|
||||||
import { siteResources, sites, SiteResource } from "@server/db";
|
import { siteResources, sites, SiteResource } from "@server/db";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import { and, asc, desc, eq } from "drizzle-orm";
|
import { and, asc, desc, eq, inArray, or, sql } from "drizzle-orm";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
@@ -159,10 +165,47 @@ export async function listSiteResources(
|
|||||||
return next(createHttpError(HttpCode.NOT_FOUND, "Site not found"));
|
return next(createHttpError(HttpCode.NOT_FOUND, "Site not found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let accessibleSiteResourceIds: number[];
|
||||||
|
if (req.user) {
|
||||||
|
const accessibleSiteResources = await db
|
||||||
|
.select({
|
||||||
|
siteResourceId: sql<number>`COALESCE(${userSiteResources.siteResourceId}, ${roleSiteResources.siteResourceId})`
|
||||||
|
})
|
||||||
|
.from(userSiteResources)
|
||||||
|
.fullJoin(
|
||||||
|
roleSiteResources,
|
||||||
|
eq(
|
||||||
|
userSiteResources.siteResourceId,
|
||||||
|
roleSiteResources.siteResourceId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
or(
|
||||||
|
eq(userSiteResources.userId, req.user.userId),
|
||||||
|
inArray(
|
||||||
|
roleSiteResources.roleId,
|
||||||
|
req.userOrgRoleIds ?? []
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
accessibleSiteResourceIds = accessibleSiteResources.map(
|
||||||
|
(row) => row.siteResourceId
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const allOrgSiteResources = await db
|
||||||
|
.select({ siteResourceId: siteResources.siteResourceId })
|
||||||
|
.from(siteResources)
|
||||||
|
.where(eq(siteResources.orgId, orgId));
|
||||||
|
accessibleSiteResourceIds = allOrgSiteResources.map(
|
||||||
|
(row) => row.siteResourceId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Get site resources by joining networks to siteResources via siteNetworks
|
// Get site resources by joining networks to siteResources via siteNetworks
|
||||||
const conditions = [
|
const conditions = [
|
||||||
eq(siteNetworks.siteId, siteId),
|
eq(siteNetworks.siteId, siteId),
|
||||||
eq(siteResources.orgId, orgId)
|
eq(siteResources.orgId, orgId),
|
||||||
|
inArray(siteResources.siteResourceId, accessibleSiteResourceIds)
|
||||||
];
|
];
|
||||||
|
|
||||||
if (typeof status !== "undefined") {
|
if (typeof status !== "undefined") {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import m19 from "./scriptsPg/1.18.4";
|
|||||||
import m20 from "./scriptsPg/1.19.0";
|
import m20 from "./scriptsPg/1.19.0";
|
||||||
import m21 from "./scriptsPg/1.20.0";
|
import m21 from "./scriptsPg/1.20.0";
|
||||||
import m22 from "./scriptsPg/1.21.0";
|
import m22 from "./scriptsPg/1.21.0";
|
||||||
|
import m23 from "./scriptsPg/1.21.1";
|
||||||
|
|
||||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||||
@@ -55,7 +56,8 @@ const migrations = [
|
|||||||
{ version: "1.18.4", run: m19 },
|
{ version: "1.18.4", run: m19 },
|
||||||
{ version: "1.19.0", run: m20 },
|
{ version: "1.19.0", run: m20 },
|
||||||
{ version: "1.20.0", run: m21 },
|
{ version: "1.20.0", run: m21 },
|
||||||
{ version: "1.21.0", run: m22 }
|
{ version: "1.21.0", run: m22 },
|
||||||
|
{ version: "1.21.1", run: m23 }
|
||||||
// Add new migrations here as they are created
|
// Add new migrations here as they are created
|
||||||
] as {
|
] as {
|
||||||
version: string;
|
version: string;
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import m41 from "./scriptsSqlite/1.19.0";
|
|||||||
import m42 from "./scriptsSqlite/1.19.1";
|
import m42 from "./scriptsSqlite/1.19.1";
|
||||||
import m43 from "./scriptsSqlite/1.20.0";
|
import m43 from "./scriptsSqlite/1.20.0";
|
||||||
import m44 from "./scriptsSqlite/1.21.0";
|
import m44 from "./scriptsSqlite/1.21.0";
|
||||||
|
import m45 from "./scriptsSqlite/1.21.1";
|
||||||
|
|
||||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||||
@@ -91,7 +92,8 @@ const migrations = [
|
|||||||
{ version: "1.19.0", run: m41 },
|
{ version: "1.19.0", run: m41 },
|
||||||
{ version: "1.19.1", run: m42 },
|
{ version: "1.19.1", run: m42 },
|
||||||
{ version: "1.20.0", run: m43 },
|
{ version: "1.20.0", run: m43 },
|
||||||
{ version: "1.21.0", run: m44 }
|
{ version: "1.21.0", run: m44 },
|
||||||
|
{ version: "1.21.1", run: m45 }
|
||||||
// Add new migrations here as they are created
|
// Add new migrations here as they are created
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { db } from "@server/db/pg/driver";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
|
||||||
|
const version = "1.21.1";
|
||||||
|
|
||||||
|
const actionsToGrant = ["getSiteResource", "listSiteResources"] as const;
|
||||||
|
|
||||||
|
export default async function migration() {
|
||||||
|
console.log(`Running setup script ${version}...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.execute(sql`BEGIN`);
|
||||||
|
|
||||||
|
for (const actionId of actionsToGrant) {
|
||||||
|
await db.execute(sql`
|
||||||
|
INSERT INTO "roleActions" ("roleId", "actionId", "orgId")
|
||||||
|
SELECT r."roleId", ${actionId}, r."orgId"
|
||||||
|
FROM "roles" r
|
||||||
|
WHERE COALESCE(r."isAdmin", false) = false
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM "roleActions" ra
|
||||||
|
WHERE ra."roleId" = r."roleId"
|
||||||
|
AND ra."actionId" = ${actionId}
|
||||||
|
AND ra."orgId" = r."orgId"
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.execute(sql`COMMIT`);
|
||||||
|
console.log(`Finished setup script ${version}`);
|
||||||
|
} catch (e) {
|
||||||
|
await db.execute(sql`ROLLBACK`);
|
||||||
|
console.log("Unable to migrate database");
|
||||||
|
console.log(e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { APP_PATH } from "@server/lib/consts";
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const version = "1.21.1";
|
||||||
|
|
||||||
|
const actionsToGrant = ["getSiteResource", "listSiteResources"] as const;
|
||||||
|
|
||||||
|
export default async function migration() {
|
||||||
|
console.log(`Running setup script ${version}...`);
|
||||||
|
|
||||||
|
const location = path.join(APP_PATH, "db", "db.sqlite");
|
||||||
|
const db = new Database(location);
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.transaction(() => {
|
||||||
|
const insertRoleAction = db.prepare(`
|
||||||
|
INSERT INTO 'roleActions' ("roleId", "actionId", "orgId")
|
||||||
|
SELECT r."roleId", ?, r."orgId"
|
||||||
|
FROM 'roles' r
|
||||||
|
WHERE COALESCE(r."isAdmin", 0) = 0
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM 'roleActions' ra
|
||||||
|
WHERE ra."roleId" = r."roleId"
|
||||||
|
AND ra."actionId" = ?
|
||||||
|
AND ra."orgId" = r."orgId"
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (const actionId of actionsToGrant) {
|
||||||
|
insertRoleAction.run(actionId, actionId);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
console.log(`Finished setup script ${version}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.log("Unable to migrate database");
|
||||||
|
console.log(e);
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -181,7 +181,7 @@ export default function NetworkingPage() {
|
|||||||
<SettingsSectionDescription>
|
<SettingsSectionDescription>
|
||||||
{t("remoteExitNodeNetworkingDescription")}
|
{t("remoteExitNodeNetworkingDescription")}
|
||||||
<a
|
<a
|
||||||
href="https://docs.pangolin.net/placeholder"
|
href="https://docs.pangolin.net/manage/remote-node/backhaul"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||||
|
|||||||
@@ -104,7 +104,9 @@ export default async function ClientsPage(props: ClientsPageProps) {
|
|||||||
archived: Boolean(client.archived),
|
archived: Boolean(client.archived),
|
||||||
blocked: Boolean(client.blocked),
|
blocked: Boolean(client.blocked),
|
||||||
approvalState: client.approvalState,
|
approvalState: client.approvalState,
|
||||||
fingerprint
|
fingerprint,
|
||||||
|
firstSeen: client.firstSeen ?? null,
|
||||||
|
lastSeen: client.lastSeen ?? null
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export default async function Page(props: {
|
|||||||
let loginIdps: LoginFormIDP[] = [];
|
let loginIdps: LoginFormIDP[] = [];
|
||||||
let lastUsedIdpForSmartLogin: (LoginFormIDP & { orgId?: string }) | null =
|
let lastUsedIdpForSmartLogin: (LoginFormIDP & { orgId?: string }) | null =
|
||||||
null;
|
null;
|
||||||
|
|
||||||
if (!useSmartLogin) {
|
if (!useSmartLogin) {
|
||||||
// Load IdPs for DashboardLoginForm (OSS or org-only IdP mode)
|
// Load IdPs for DashboardLoginForm (OSS or org-only IdP mode)
|
||||||
if (build === "oss" || env.app.identityProviderMode !== "org") {
|
if (build === "oss" || env.app.identityProviderMode !== "org") {
|
||||||
@@ -117,12 +118,12 @@ export default async function Page(props: {
|
|||||||
`/idp/${persistedData.idpId}`
|
`/idp/${persistedData.idpId}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const idp = idpRes.data.data.idp;
|
const res = idpRes.data.data;
|
||||||
|
|
||||||
lastUsedIdpForSmartLogin = {
|
lastUsedIdpForSmartLogin = {
|
||||||
idpId: idp.idpId,
|
idpId: res.idp.idpId,
|
||||||
name: idp.name,
|
name: res.idp.name,
|
||||||
variant: idp.type,
|
variant: res.idpOidcConfig?.variant ?? res.idp.type,
|
||||||
orgId: persistedData.orgId,
|
orgId: persistedData.orgId,
|
||||||
lastUsed: true
|
lastUsed: true
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export function ContactSalesBanner() {
|
|||||||
<ExternalLink className="size-3.5 shrink-0" />
|
<ExternalLink className="size-3.5 shrink-0" />
|
||||||
</Link>
|
</Link>
|
||||||
{" " + t("contactSalesOr") + " "}
|
{" " + t("contactSalesOr") + " "}
|
||||||
|
<span className="whitespace-nowrap">
|
||||||
<Link
|
<Link
|
||||||
href="https://pangolin.net/contact"
|
href="https://pangolin.net/contact"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -35,6 +36,7 @@ export function ContactSalesBanner() {
|
|||||||
</Link>
|
</Link>
|
||||||
.
|
.
|
||||||
</span>
|
</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,12 +26,14 @@ type IdpLoginButtonsProps = {
|
|||||||
idps: LoginFormIDP[];
|
idps: LoginFormIDP[];
|
||||||
redirect?: string;
|
redirect?: string;
|
||||||
orgId?: string;
|
orgId?: string;
|
||||||
|
passOrgIdToOidcUrl?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function IdpLoginButtons({
|
export default function IdpLoginButtons({
|
||||||
idps,
|
idps,
|
||||||
redirect,
|
redirect,
|
||||||
orgId
|
orgId,
|
||||||
|
passOrgIdToOidcUrl = true
|
||||||
}: IdpLoginButtonsProps) {
|
}: IdpLoginButtonsProps) {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
@@ -68,12 +70,13 @@ export default function IdpLoginButtons({
|
|||||||
|
|
||||||
let redirectToUrl: string | undefined;
|
let redirectToUrl: string | undefined;
|
||||||
try {
|
try {
|
||||||
console.log("generating", idpId, redirect || "/", orgId);
|
const oidcOrgId = passOrgIdToOidcUrl ? orgId : undefined;
|
||||||
|
console.log("generating", idpId, redirect || "/", oidcOrgId);
|
||||||
const safeRedirect = cleanRedirect(redirect || "/");
|
const safeRedirect = cleanRedirect(redirect || "/");
|
||||||
const response = await generateOidcUrlProxy(
|
const response = await generateOidcUrlProxy(
|
||||||
idpId,
|
idpId,
|
||||||
safeRedirect,
|
safeRedirect,
|
||||||
orgId
|
oidcOrgId
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
@@ -114,7 +117,6 @@ export default function IdpLoginButtons({
|
|||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{params.get("gotoapp") ? (
|
{params.get("gotoapp") ? (
|
||||||
<>
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@@ -124,18 +126,13 @@ export default function IdpLoginButtons({
|
|||||||
>
|
>
|
||||||
{t("continueToApplication")}
|
{t("continueToApplication")}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
idps.map((idp) => {
|
||||||
{idps.map((idp) => {
|
|
||||||
const effectiveType =
|
const effectiveType =
|
||||||
idp.variant || idp.name.toLowerCase();
|
idp.variant || idp.name.toLowerCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="w-full relative" key={idp.idpId}>
|
||||||
className="w-full relative"
|
|
||||||
key={idp.idpId}
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
key={idp.idpId}
|
key={idp.idpId}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -165,8 +162,7 @@ export default function IdpLoginButtons({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export default function IdpTypeIcon({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const effectiveType = (variant || type || "").toLowerCase();
|
const effectiveType = (variant || type || "").toLowerCase();
|
||||||
|
|
||||||
|
console.log(`[IdpTypeIcon]`, { effectiveType, variant, type });
|
||||||
|
|
||||||
let src: string | null = null;
|
let src: string | null = null;
|
||||||
let defaultAlt = "";
|
let defaultAlt = "";
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from "./ui/input-otp";
|
import { InputOTP, InputOTPGroup, InputOTPSlot } from "./ui/input-otp";
|
||||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { REGEXP_ONLY_DIGITS } from "input-otp";
|
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
|
||||||
|
|
||||||
const MFA_OTP_INPUT_ID = "mfa-otp-code";
|
const MFA_OTP_INPUT_ID = "mfa-otp-code";
|
||||||
|
|
||||||
@@ -82,9 +82,11 @@ export default function MfaInputForm({
|
|||||||
maxLength={6}
|
maxLength={6}
|
||||||
{...field}
|
{...field}
|
||||||
autoComplete="one-time-code"
|
autoComplete="one-time-code"
|
||||||
inputMode="numeric"
|
inputMode="text"
|
||||||
autoFocus
|
autoFocus
|
||||||
pattern={REGEXP_ONLY_DIGITS}
|
pattern={
|
||||||
|
REGEXP_ONLY_DIGITS_AND_CHARS
|
||||||
|
}
|
||||||
onChange={(value: string) => {
|
onChange={(value: string) => {
|
||||||
field.onChange(value);
|
field.onChange(value);
|
||||||
if (value.length === 6) {
|
if (value.length === 6) {
|
||||||
|
|||||||
@@ -1,17 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ColumnDef } from "@tanstack/react-table";
|
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||||
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
|
||||||
import { IdpDataTable } from "@app/components/OrgIdpDataTable";
|
|
||||||
import { Button } from "@app/components/ui/button";
|
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
CommandList
|
|
||||||
} from "@app/components/ui/command";
|
|
||||||
import {
|
import {
|
||||||
Credenza,
|
Credenza,
|
||||||
CredenzaBody,
|
CredenzaBody,
|
||||||
@@ -22,37 +11,42 @@ import {
|
|||||||
CredenzaHeader,
|
CredenzaHeader,
|
||||||
CredenzaTitle
|
CredenzaTitle
|
||||||
} from "@app/components/Credenza";
|
} from "@app/components/Credenza";
|
||||||
|
import { isIdpGlobalModeBannerVisible } from "@app/components/IdpGlobalModeBanner";
|
||||||
|
import IdpTypeBadge from "@app/components/IdpTypeBadge";
|
||||||
|
import IdpTypeIcon from "@app/components/IdpTypeIcon";
|
||||||
|
import { IdpDataTable } from "@app/components/OrgIdpDataTable";
|
||||||
|
import { Badge } from "@app/components/ui/badge";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
Command,
|
||||||
ArrowUpDown,
|
CommandEmpty,
|
||||||
MoreHorizontal
|
CommandGroup,
|
||||||
} from "lucide-react";
|
CommandInput,
|
||||||
import { useMemo, useState } from "react";
|
CommandItem,
|
||||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
CommandList
|
||||||
import { toast } from "@app/hooks/useToast";
|
} from "@app/components/ui/command";
|
||||||
import { formatAxiosError } from "@app/lib/api";
|
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||||
import { createApiClient } from "@app/lib/api";
|
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
|
||||||
import { useUserContext } from "@app/hooks/useUserContext";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from "@app/components/ui/dropdown-menu";
|
} from "@app/components/ui/dropdown-menu";
|
||||||
import Link from "next/link";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import IdpTypeBadge from "@app/components/IdpTypeBadge";
|
|
||||||
import IdpTypeIcon from "@app/components/IdpTypeIcon";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useDebounce } from "use-debounce";
|
|
||||||
import type { ListUserAdminOrgIdpsResponse } from "@server/routers/orgIdp/types";
|
|
||||||
import { cn } from "@app/lib/cn";
|
|
||||||
import { Badge } from "@app/components/ui/badge";
|
|
||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
|
import { cn } from "@app/lib/cn";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import { isIdpGlobalModeBannerVisible } from "@app/components/IdpGlobalModeBanner";
|
import type { ListUserAdminOrgIdpsResponse } from "@server/routers/orgIdp/types";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useDebounce } from "use-debounce";
|
||||||
|
|
||||||
export type IdpRow = {
|
export type IdpRow = {
|
||||||
idpId: number;
|
idpId: number;
|
||||||
@@ -483,7 +477,8 @@ export default function IdpTable({ idps, orgId }: Props) {
|
|||||||
{group.name}
|
{group.name}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex flex-wrap gap-1">
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
{group.sources.map((src) => (
|
{group.sources.map(
|
||||||
|
(src) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={src.orgId}
|
key={src.orgId}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -491,7 +486,8 @@ export default function IdpTable({ idps, orgId }: Props) {
|
|||||||
>
|
>
|
||||||
{src.orgName}
|
{src.orgName}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
|
|||||||
@@ -303,6 +303,7 @@ export default function SmartLoginForm({
|
|||||||
<IdpLoginButtons
|
<IdpLoginButtons
|
||||||
idps={[lastUsedIdp]}
|
idps={[lastUsedIdp]}
|
||||||
orgId={lastUsedIdp.orgId}
|
orgId={lastUsedIdp.orgId}
|
||||||
|
passOrgIdToOidcUrl={false}
|
||||||
redirect={redirect}
|
redirect={redirect}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -134,7 +134,9 @@ export default function UptimeBar({
|
|||||||
|
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
|
|
||||||
const allNoData = data.days.every((d) => d.status === "no_data");
|
const allNoData = data.days.every(
|
||||||
|
(d) => d.status === "no_data" || d.status === "unknown"
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("space-y-3", className)}>
|
<div className={cn("space-y-3", className)}>
|
||||||
|
|||||||
@@ -124,7 +124,9 @@ export function UptimeMiniBar({
|
|||||||
|
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
|
|
||||||
const allNoData = data.days.every((d) => d.status === "no_data");
|
const allNoData = data.days.every(
|
||||||
|
(d) => d.status === "no_data" || d.status === "unknown"
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ export type ClientRow = {
|
|||||||
username: string | null;
|
username: string | null;
|
||||||
hostname: string | null;
|
hostname: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
|
firstSeen: number | null;
|
||||||
|
lastSeen: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ClientTableProps = {
|
type ClientTableProps = {
|
||||||
@@ -112,7 +114,9 @@ export default function UserDevicesTable({
|
|||||||
|
|
||||||
const defaultUserColumnVisibility = {
|
const defaultUserColumnVisibility = {
|
||||||
subnet: false,
|
subnet: false,
|
||||||
niceId: false
|
niceId: false,
|
||||||
|
firstSeen: false,
|
||||||
|
lastSeen: false
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshData = () => {
|
const refreshData = () => {
|
||||||
@@ -621,6 +625,68 @@ export default function UserDevicesTable({
|
|||||||
accessorKey: "subnet",
|
accessorKey: "subnet",
|
||||||
friendlyName: t("address"),
|
friendlyName: t("address"),
|
||||||
header: () => <span className="px-3">{t("address")}</span>
|
header: () => <span className="px-3">{t("address")}</span>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "firstSeen",
|
||||||
|
friendlyName: t("firstSeen"),
|
||||||
|
header: () => {
|
||||||
|
const firstSeenOrder = getSortDirection(
|
||||||
|
"firstSeen",
|
||||||
|
searchParams
|
||||||
|
);
|
||||||
|
|
||||||
|
const Icon =
|
||||||
|
firstSeenOrder === "asc"
|
||||||
|
? ArrowDown01Icon
|
||||||
|
: firstSeenOrder === "desc"
|
||||||
|
? ArrowUp10Icon
|
||||||
|
: ChevronsUpDownIcon;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => toggleSort("firstSeen")}
|
||||||
|
>
|
||||||
|
{t("firstSeen")}
|
||||||
|
<Icon className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const firstSeen = row.original.firstSeen;
|
||||||
|
if (!firstSeen) return "-";
|
||||||
|
return new Date(firstSeen * 1000).toLocaleString();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "lastSeen",
|
||||||
|
friendlyName: t("lastSeen"),
|
||||||
|
header: () => {
|
||||||
|
const lastSeenOrder = getSortDirection(
|
||||||
|
"lastSeen",
|
||||||
|
searchParams
|
||||||
|
);
|
||||||
|
|
||||||
|
const Icon =
|
||||||
|
lastSeenOrder === "asc"
|
||||||
|
? ArrowDown01Icon
|
||||||
|
: lastSeenOrder === "desc"
|
||||||
|
? ArrowUp10Icon
|
||||||
|
: ChevronsUpDownIcon;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => toggleSort("lastSeen")}
|
||||||
|
>
|
||||||
|
{t("lastSeen")}
|
||||||
|
<Icon className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const lastSeen = row.original.lastSeen;
|
||||||
|
if (!lastSeen) return "-";
|
||||||
|
return new Date(lastSeen * 1000).toLocaleString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ 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 {
|
||||||
@@ -19,6 +21,7 @@ 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,
|
||||||
@@ -27,19 +30,15 @@ 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 { useEnvContext } from "@app/hooks/useEnvContext";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
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 { zodResolver } from "@hookform/resolvers/zod";
|
import { Cog, Flag, Zap, ZapIcon } from "lucide-react";
|
||||||
import { ChevronLeft, Cog, Flag, Zap } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useMemo, useState, type ReactNode } from "react";
|
|
||||||
import { useFieldArray, useForm, type Resolver } from "react-hook-form";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
import { useRouter } from "next/navigation";
|
||||||
import { SwitchInput } from "@app/components/SwitchInput";
|
import { useActionState, useMemo, useTransition, type ReactNode } from "react";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { useFieldArray, useForm, type Resolver } from "react-hook-form";
|
||||||
import { Badge } from "../ui/badge";
|
import { Badge } from "../ui/badge";
|
||||||
|
|
||||||
const FORM_ID = "alert-rule-form";
|
const FORM_ID = "alert-rule-form";
|
||||||
@@ -115,7 +114,6 @@ 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>,
|
||||||
@@ -127,8 +125,22 @@ export default function AlertRuleGraphEditor({
|
|||||||
name: "actions"
|
name: "actions"
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = form.handleSubmit(async (values) => {
|
const saveAlert = async () => {
|
||||||
setIsSaving(true);
|
const isValid = await form.trigger();
|
||||||
|
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) {
|
||||||
@@ -158,14 +170,37 @@ export default function AlertRuleGraphEditor({
|
|||||||
description: formatAxiosError(e),
|
description: formatAxiosError(e),
|
||||||
variant: "destructive"
|
variant: "destructive"
|
||||||
});
|
});
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
}
|
||||||
|
// const submit = form.handleSubmit(async (values) => {
|
||||||
|
|
||||||
|
// });
|
||||||
|
|
||||||
|
// await submit();
|
||||||
|
};
|
||||||
|
|
||||||
|
const testAlert = async () => {
|
||||||
|
const isValid = await form.trigger();
|
||||||
|
if (!isValid) {
|
||||||
|
const values = form.getValues();
|
||||||
|
if (values.actions.length === 0) {
|
||||||
|
toast({
|
||||||
|
variant: "warning",
|
||||||
|
title: t("alertingNoActionsTitle"),
|
||||||
|
description: t("alertingNoActionsTestDescription")
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = form.getValues();
|
||||||
|
};
|
||||||
|
|
||||||
|
const [, formAction, isSaving] = useActionState(saveAlert, null);
|
||||||
|
const [isTestingAlert, startTransition] = useTransition();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form id={FORM_ID} onSubmit={onSubmit}>
|
<form id={FORM_ID} action={formAction}>
|
||||||
<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">
|
||||||
@@ -263,6 +298,7 @@ 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"
|
||||||
@@ -271,6 +307,20 @@ export default function AlertRuleGraphEditor({
|
|||||||
>
|
>
|
||||||
{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>
|
||||||
|
|||||||
@@ -111,7 +111,8 @@ export function useCertificate({
|
|||||||
let certError: string | null = null;
|
let certError: string | null = null;
|
||||||
if (restartCert.isError) {
|
if (restartCert.isError) {
|
||||||
certError = "Failed to restart";
|
certError = "Failed to restart";
|
||||||
} else if (isError) {
|
} else if (isError || (!isLoading && data === null)) {
|
||||||
|
// Null value means failed to get the certificate
|
||||||
certError = "Failed";
|
certError = "Failed";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+27
-29
@@ -1,4 +1,10 @@
|
|||||||
|
import type { LauncherQueryFilters } from "@app/lib/launcherSearchParams";
|
||||||
|
import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
|
import {
|
||||||
|
StatusHistoryResponse,
|
||||||
|
type BatchedStatusHistoryResponse
|
||||||
|
} from "@server/lib/statusHistory";
|
||||||
import type { ListAlertRulesResponse } from "@server/routers/alertRule/types";
|
import type { ListAlertRulesResponse } from "@server/routers/alertRule/types";
|
||||||
import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs";
|
import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs";
|
||||||
import type {
|
import type {
|
||||||
@@ -7,6 +13,7 @@ import type {
|
|||||||
QueryConnectionAuditLogResponse,
|
QueryConnectionAuditLogResponse,
|
||||||
QueryRequestAuditLogResponse
|
QueryRequestAuditLogResponse
|
||||||
} from "@server/routers/auditLogs/types";
|
} from "@server/routers/auditLogs/types";
|
||||||
|
import type { GetCertificateResponse } from "@server/routers/certificates/types";
|
||||||
import type {
|
import type {
|
||||||
ListClientsResponse,
|
ListClientsResponse,
|
||||||
ListUserDevicesResponse
|
ListUserDevicesResponse
|
||||||
@@ -16,15 +23,30 @@ import type {
|
|||||||
ListDomainsResponse
|
ListDomainsResponse
|
||||||
} from "@server/routers/domain";
|
} from "@server/routers/domain";
|
||||||
import type { GetDomainResponse } from "@server/routers/domain/getDomain";
|
import type { GetDomainResponse } from "@server/routers/domain/getDomain";
|
||||||
|
import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
|
||||||
|
import type { ListOrgLabelsResponse } from "@server/routers/labels/types";
|
||||||
|
import type {
|
||||||
|
LauncherResource,
|
||||||
|
ListLauncherGroupsResponse,
|
||||||
|
ListLauncherLabelsResponse,
|
||||||
|
ListLauncherResourcesResponse,
|
||||||
|
ListLauncherScaleResponse,
|
||||||
|
ListLauncherSitesResponse,
|
||||||
|
ListLauncherViewsResponse
|
||||||
|
} from "@server/routers/launcher/types";
|
||||||
|
import type { GetResourcePolicyResponse } from "@server/routers/policy";
|
||||||
import type {
|
import type {
|
||||||
GetResourceWhitelistResponse,
|
|
||||||
GetResourcePoliciesResponse,
|
GetResourcePoliciesResponse,
|
||||||
|
GetResourceWhitelistResponse,
|
||||||
ListResourceNamesResponse,
|
ListResourceNamesResponse,
|
||||||
ListResourcesResponse,
|
|
||||||
ListResourceRolesResponse,
|
ListResourceRolesResponse,
|
||||||
ListResourceRulesResponse,
|
ListResourceRulesResponse,
|
||||||
|
ListResourcesResponse,
|
||||||
ListResourceUsersResponse
|
ListResourceUsersResponse
|
||||||
} from "@server/routers/resource";
|
} from "@server/routers/resource";
|
||||||
|
import type { GetResourceResponse } from "@server/routers/resource/getResource";
|
||||||
|
import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo";
|
||||||
|
import type { ListResourcePoliciesResponse } from "@server/routers/resource/types";
|
||||||
import type { ListRolesResponse } from "@server/routers/role";
|
import type { ListRolesResponse } from "@server/routers/role";
|
||||||
import type { ListSitesResponse } from "@server/routers/site";
|
import type { ListSitesResponse } from "@server/routers/site";
|
||||||
import type {
|
import type {
|
||||||
@@ -33,6 +55,7 @@ import type {
|
|||||||
ListSiteResourceRolesResponse,
|
ListSiteResourceRolesResponse,
|
||||||
ListSiteResourceUsersResponse
|
ListSiteResourceUsersResponse
|
||||||
} from "@server/routers/siteResource";
|
} from "@server/routers/siteResource";
|
||||||
|
import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource";
|
||||||
import type { ListTargetsResponse } from "@server/routers/target";
|
import type { ListTargetsResponse } from "@server/routers/target";
|
||||||
import type { ListUsersResponse } from "@server/routers/user";
|
import type { ListUsersResponse } from "@server/routers/user";
|
||||||
import type ResponseT from "@server/types/Response";
|
import type ResponseT from "@server/types/Response";
|
||||||
@@ -42,37 +65,12 @@ import {
|
|||||||
queryOptions
|
queryOptions
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import { isAxiosError, type AxiosResponse } from "axios";
|
import { isAxiosError, type AxiosResponse } from "axios";
|
||||||
import z, { meta } from "zod";
|
import z from "zod";
|
||||||
import { remote } from "./api";
|
import { remote } from "./api";
|
||||||
import { durationToMs } from "./durationToMs";
|
import { durationToMs } from "./durationToMs";
|
||||||
import type { ListOrgLabelsResponse } from "@server/routers/labels/types";
|
|
||||||
import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
|
|
||||||
import {
|
|
||||||
StatusHistoryResponse,
|
|
||||||
type BatchedStatusHistoryResponse
|
|
||||||
} from "@server/lib/statusHistory";
|
|
||||||
import type { ListResourcePoliciesResponse } from "@server/routers/resource/types";
|
|
||||||
import type { GetResourcePolicyResponse } from "@server/routers/policy";
|
|
||||||
import type {
|
|
||||||
ListLauncherGroupsResponse,
|
|
||||||
ListLauncherLabelsResponse,
|
|
||||||
ListLauncherResourcesResponse,
|
|
||||||
ListLauncherScaleResponse,
|
|
||||||
ListLauncherSitesResponse,
|
|
||||||
ListLauncherViewsResponse,
|
|
||||||
LauncherListQuery,
|
|
||||||
LauncherResource,
|
|
||||||
LauncherViewConfig
|
|
||||||
} from "@server/routers/launcher/types";
|
|
||||||
import type { GetResourceResponse } from "@server/routers/resource/getResource";
|
|
||||||
import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo";
|
|
||||||
import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource";
|
|
||||||
import type { LauncherQueryFilters } from "@app/lib/launcherSearchParams";
|
|
||||||
import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
|
|
||||||
import type { GetCertificateResponse } from "@server/routers/certificates/types";
|
|
||||||
|
|
||||||
export type { LauncherQueryFilters } from "@app/lib/launcherSearchParams";
|
|
||||||
export { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
|
export { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
|
||||||
|
export type { LauncherQueryFilters } from "@app/lib/launcherSearchParams";
|
||||||
|
|
||||||
export type ProductUpdate = {
|
export type ProductUpdate = {
|
||||||
link: string | null;
|
link: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user