Compare commits

..

29 Commits

Author SHA1 Message Date
Fred KISSIE 403b8a12e4 🚧 wip 2026-08-07 20:57:54 +02:00
Fred KISSIE 3f305e4d5c 🚧 process test alert 2026-08-07 20:52:25 +02:00
Fred KISSIE 6689a8d93e 🚧 wip: test alert rule 2026-08-07 19:05:28 +02:00
Owen e91c344e64 Update link to be correct 2026-08-07 10:21:15 -04:00
miloschwartz 4048fa274a fix non admins cant see private resources details in launcher 2026-08-06 12:32:19 -04:00
miloschwartz 82b86263dc allow chars in 2fa input form closes #3532 2026-08-06 11:39:06 -04:00
Owen 835a30cffe Show the cert status of the namespace domains properly 2026-08-04 17:44:34 -04:00
Owen 18b90da6ab Merge branch 'main' into dev 2026-08-04 17:22:32 -04:00
Owen f079714caf Dont redirect when the browser agent is not real 2026-08-04 10:07:52 -04:00
Owen efd2792197 bump default rate limit 2026-08-03 17:57:36 -04:00
Owen Schwartz efe22c889c Merge pull request #3522 from fosrl/dev
Move rate linmit to file
2026-08-03 14:35:43 -04:00
Owen 7f2b3eb481 Move rate linmit to file 2026-08-03 14:34:11 -04:00
Owen Schwartz 81be4a35d9 Merge pull request #3521 from fosrl/dev
1.21.1-s.1
2026-08-03 14:11:05 -04:00
Owen e84da6a8df Add rate limits to sensitve pages 2026-08-03 14:08:09 -04:00
miloschwartz 3ef3ede7df dont pass orgId in generate oidc url 2026-08-03 11:41:22 -04:00
Owen 1e521b0b54 Dont double log error 2026-08-03 10:35:26 -04:00
Owen 59ea701304 Merge branch 'dev' of github.com:fosrl/pangolin into dev 2026-07-31 14:23:43 -04:00
miloschwartz 7d7c54107d add first seen and last seen columns to user devices table 2026-07-31 14:23:06 -04:00
Owen Schwartz f0f6673d69 Merge pull request #3511 from fosrl/feat/show-idp-icon-in-smart-login-form
feat: show the correct idp type icon in the last used idp
2026-07-31 09:32:01 -04:00
Owen Schwartz 71561d0e65 Merge pull request #3512 from fosrl/fix/batch-status-hover-data
Fix: resolve batch certificates for NS & Wildcard domains
2026-07-31 09:31:21 -04:00
Fred KISSIE af87edf3a6 ♻️ use const 2026-07-30 22:06:38 +01:00
Fred KISSIE 13caad18c7 🎨 format file 2026-07-30 21:53:46 +01:00
Fred KISSIE 47522b7e3a ♻️ set cert error if initial value is null 2026-07-30 21:53:31 +01:00
Fred KISSIE c8c8d74452 🐛 resolve certificates for non exact domains too 2026-07-30 21:52:51 +01:00
Fred KISSIE e7098963d6 ♻️ Code Refactoring 2026-07-30 17:56:15 +01:00
Fred KISSIE f015fb592b ♻️ Prioritize variant in place of idp.type if available 2026-07-30 17:55:58 +01:00
Fred KISSIE c099167905 Merge branch 'dev' into feat/show-idp-icon-in-smart-login-form 2026-07-30 17:37:47 +01:00
Owen b0e274f5a9 Fix 100% showing on resources with no hc anymore 2026-07-30 11:14:34 -04:00
Fred KISSIE e0a8721207 🚧 wip: idp type icon in last used idp 2026-07-29 22:29:56 +01:00
37 changed files with 845 additions and 210 deletions
+4 -4
View File
@@ -77,7 +77,7 @@ jobs:
fi
- name: Log in to Docker Hub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
@@ -149,7 +149,7 @@ jobs:
fi
- name: Log in to Docker Hub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
@@ -204,7 +204,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Log in to Docker Hub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
@@ -407,7 +407,7 @@ jobs:
shell: bash
- name: Login to GitHub Container Registry (for cosign)
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: ghcr.io
username: ${{ github.actor }}
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
with:
days-before-stale: 14
days-before-close: 14
+4
View File
@@ -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.",
"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.",
"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",
"standaloneHcSearchPlaceholder": "Search health checks…",
"standaloneHcAddButton": "Create Health Check",
+1
View File
@@ -151,6 +151,7 @@ export enum ActionsEnum {
createAlertRule = "createAlertRule",
updateAlertRule = "updateAlertRule",
deleteAlertRule = "deleteAlertRule",
testAlertRule = "testAlertRule",
listAlertRules = "listAlertRules",
listOrgLabels = "listOrgLabels",
createOrgLabel = "createOrgLabel",
+18 -1
View File
@@ -258,7 +258,24 @@ export const configSchema = z
pp_transport_prefix: z
.string()
.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()
.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);
}
+22 -1
View File
@@ -58,6 +58,8 @@ import { build } from "@server/build";
const redirectHttpsMiddlewareName = "redirect-to-https";
const redirectToRootMiddlewareName = "redirect-to-root";
const badgerMiddlewareName = "badger";
const landingRateLimitMiddlewareName = "landing-ratelimit";
const bgRateLimitMiddlewareName = "bg-ratelimit";
// Define extended target type with site information
type TargetWithSite = Target & {
@@ -418,6 +420,8 @@ export async function getTraefikConfig(
// logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`);
}
const traefikRateLimit = config.getRawConfig().traefik.rate_limit;
const config_output: any = {
http: {
middlewares: {
@@ -432,6 +436,18 @@ export async function getTraefikConfig(
replacement: "${1}://${2}/auth/org",
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 || [];
const routerMiddlewares = [
badgerMiddlewareName,
bgRateLimitMiddlewareName,
...additionalMiddlewares
];
@@ -1539,6 +1556,7 @@ export async function getTraefikConfig(
entryPoints: [
config.getRawConfig().traefik.https_entrypoint
],
middlewares: [landingRateLimitMiddlewareName],
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\`))`,
priority: 203,
@@ -1557,7 +1575,10 @@ export async function getTraefikConfig(
entryPoints: [
config.getRawConfig().traefik.https_entrypoint
],
middlewares: [redirectToRootMiddlewareName],
middlewares: [
landingRateLimitMiddlewareName,
redirectToRootMiddlewareName
],
service: "landing-service",
rule: `Host(\`${fullDomain}\`)`,
priority: 202,
@@ -16,3 +16,4 @@ export * from "./updateAlertRule";
export * from "./deleteAlertRule";
export * from "./listAlertRules";
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.
*/
import { certificates, db, domains, orgDomains } from "@server/db";
import { certificates, db, domainNamespaces, domains, orgDomains } from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import { type GetBatchedCertificateResponse } from "@server/routers/certificates/types";
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 createHttpError from "http-errors";
import { z } from "zod";
@@ -46,7 +46,7 @@ const getCertificateQuerySchema = z.object({
async function query(orgId: string, domainList: string[]) {
// Try to get CNAME certificates first
let existingCertificates = await db
const existingCertificates = await db
.select({
certId: certificates.certId,
domain: certificates.domain,
@@ -63,26 +63,43 @@ async function query(orgId: string, domainList: string[]) {
})
.from(certificates)
.innerJoin(domains, eq(certificates.domainId, domains.domainId))
.innerJoin(
.leftJoin(
orgDomains,
and(
eq(domains.domainId, orgDomains.domainId),
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`,
// which means exact domain certificates do not
const nonAvailableCertificates = existingCertificates
.filter((cert) => !domainList.includes(cert.domain))
.map((cert) => cert.domain);
// which means exact domain certificates do not exist
const foundDomains = new Set(
existingCertificates.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 wildcardDomainSet = new Set<string>();
for (const domain of nonAvailableCertificates) {
for (const domain of domainsWithMissingCertificates) {
const domainLevelDown = domain.split(".").slice(1).join(".");
const wildcardPrefixed = `*.${domainLevelDown}`;
domainLevelDownSet.add(domainLevelDown);
@@ -107,19 +124,27 @@ async function query(orgId: string, domainList: string[]) {
})
.from(certificates)
.innerJoin(domains, eq(certificates.domainId, domains.domainId))
.innerJoin(
.leftJoin(
orgDomains,
and(
eq(domains.domainId, orgDomains.domainId),
eq(orgDomains.orgId, orgId)
)
)
.leftJoin(
domainNamespaces,
eq(domains.domainId, domainNamespaces.domainId)
)
.where(
and(
eq(certificates.wildcard, true),
or(
inArray(certificates.domain, [...domainLevelDownSet]),
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) {
const domainLevelDown = domain.split(".").slice(1).join(".");
const wildcardPrefixed = `*.${domainLevelDown}`;
certificateMap[domain] =
existingCertificates.find(
(cert) =>
+8
View File
@@ -808,6 +808,14 @@ authenticated.get(
alertRule.listAlertRules
);
authenticated.post(
"/org/:orgId/alert-rule/test",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.testAlertRule),
alertRule.testAlertRule
);
authenticated.get(
"/org/:orgId/alert-rule/:alertRuleId",
verifyValidLicense,
+23
View File
@@ -124,3 +124,26 @@ export interface AlertContext {
/** Human-readable context data included in emails and webhook payloads */
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>;
}
+50 -2
View File
@@ -127,6 +127,9 @@ export async function verifyResourceSession(
// Extract HTTP Basic Auth credentials if present
const clientHeaderAuth = extractBasicAuth(headers);
const clientUserAgent = headers?.["user-agent"] || headers?.["User-Agent"];
const clientIsBrowser = isBrowserUserAgent(clientUserAgent);
const clientIp = requestIp
? stripPortFromHost(requestIp, badgerVersion)
: undefined;
@@ -313,9 +316,14 @@ export async function verifyResourceSession(
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
)}?redirect=${encodeURIComponent(originalRequestURL)}`;
)}?redirect=${encodeURIComponent(originalRequestURL)}`
: undefined;
// check for access token in headers
if (
@@ -1476,6 +1484,46 @@ async function getCountryCodeFromIp(ip: string): Promise<string | undefined> {
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(
headers: Record<string, string> | undefined
): string | undefined {
+17 -6
View File
@@ -67,12 +67,12 @@ const listUserDevicesSchema = z.strictObject({
}),
query: z.string().optional(),
sort_by: z
.enum(["megabytesIn", "megabytesOut"])
.enum(["megabytesIn", "megabytesOut", "firstSeen", "lastSeen"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["megabytesIn", "megabytesOut"],
enum: ["megabytesIn", "megabytesOut", "firstSeen", "lastSeen"],
description: "Field to sort by"
}),
order: z
@@ -183,7 +183,9 @@ function queryUserDevicesBase() {
fingerprintArch: currentFingerprint.arch,
fingerprintSerialNumber: currentFingerprint.serialNumber,
fingerprintUsername: currentFingerprint.username,
fingerprintHostname: currentFingerprint.hostname
fingerprintHostname: currentFingerprint.hostname,
firstSeen: currentFingerprint.firstSeen,
lastSeen: currentFingerprint.lastSeen
})
.from(clients)
.leftJoin(orgs, eq(clients.orgId, orgs.orgId))
@@ -389,14 +391,23 @@ export async function listUserDevices(
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
.limit(pageSize)
.offset(pageSize * (page - 1))
.orderBy(
sort_by
sortColumn
? order === "asc"
? asc(clients[sort_by])
: desc(clients[sort_by])
? asc(sortColumn)
: desc(sortColumn)
: asc(clients.clientId)
);
+2
View File
@@ -112,7 +112,9 @@ export async function updateHolePunch(
destinations: destinations
});
} catch (error) {
if (!(error instanceof Error && error.message === "Exit node not allowed")) {
logger.error(error);
}
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
+3 -1
View File
@@ -34,7 +34,9 @@ const createRoleSchema = z.strictObject({
export const defaultRoleAllowedActions: ActionsEnum[] = [
ActionsEnum.getOrg,
ActionsEnum.getResource,
ActionsEnum.listResources
ActionsEnum.listResources,
ActionsEnum.getSiteResource,
ActionsEnum.listSiteResources
];
export type CreateRoleBody = z.infer<typeof createRoleSchema>;
@@ -3,11 +3,13 @@ import {
DB_TYPE,
Label,
SiteResource,
roleSiteResources,
siteNetworks,
siteResourceLabels,
siteResources,
sites,
labels
labels,
userSiteResources
} from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
@@ -323,7 +325,48 @@ export async function listAllSiteResourcesByOrg(
labels: labelFilter
} = 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) {
// Keep inner joins here: filtering by a specific site implies the
@@ -1,11 +1,17 @@
import { Request, Response, NextFunction } from "express";
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 response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
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 logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
@@ -159,10 +165,47 @@ export async function listSiteResources(
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
const conditions = [
eq(siteNetworks.siteId, siteId),
eq(siteResources.orgId, orgId)
eq(siteResources.orgId, orgId),
inArray(siteResources.siteResourceId, accessibleSiteResourceIds)
];
if (typeof status !== "undefined") {
+3 -1
View File
@@ -28,6 +28,7 @@ import m19 from "./scriptsPg/1.18.4";
import m20 from "./scriptsPg/1.19.0";
import m21 from "./scriptsPg/1.20.0";
import m22 from "./scriptsPg/1.21.0";
import m23 from "./scriptsPg/1.21.1";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA
@@ -55,7 +56,8 @@ const migrations = [
{ version: "1.18.4", run: m19 },
{ version: "1.19.0", run: m20 },
{ 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
] as {
version: string;
+3 -1
View File
@@ -47,6 +47,7 @@ import m41 from "./scriptsSqlite/1.19.0";
import m42 from "./scriptsSqlite/1.19.1";
import m43 from "./scriptsSqlite/1.20.0";
import m44 from "./scriptsSqlite/1.21.0";
import m45 from "./scriptsSqlite/1.21.1";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA
@@ -91,7 +92,8 @@ const migrations = [
{ version: "1.19.0", run: m41 },
{ version: "1.19.1", run: m42 },
{ 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
] as const;
+37
View File
@@ -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;
}
}
+43
View File
@@ -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();
}
}
@@ -181,7 +181,7 @@ export default function NetworkingPage() {
<SettingsSectionDescription>
{t("remoteExitNodeNetworkingDescription")}
<a
href="https://docs.pangolin.net/placeholder"
href="https://docs.pangolin.net/manage/remote-node/backhaul"
target="_blank"
rel="noopener noreferrer"
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),
blocked: Boolean(client.blocked),
approvalState: client.approvalState,
fingerprint
fingerprint,
firstSeen: client.firstSeen ?? null,
lastSeen: client.lastSeen ?? null
};
};
+5 -4
View File
@@ -91,6 +91,7 @@ export default async function Page(props: {
let loginIdps: LoginFormIDP[] = [];
let lastUsedIdpForSmartLogin: (LoginFormIDP & { orgId?: string }) | null =
null;
if (!useSmartLogin) {
// Load IdPs for DashboardLoginForm (OSS or org-only IdP mode)
if (build === "oss" || env.app.identityProviderMode !== "org") {
@@ -117,12 +118,12 @@ export default async function Page(props: {
`/idp/${persistedData.idpId}`
);
const idp = idpRes.data.data.idp;
const res = idpRes.data.data;
lastUsedIdpForSmartLogin = {
idpId: idp.idpId,
name: idp.name,
variant: idp.type,
idpId: res.idp.idpId,
name: res.idp.name,
variant: res.idpOidcConfig?.variant ?? res.idp.type,
orgId: persistedData.orgId,
lastUsed: true
};
+2
View File
@@ -24,6 +24,7 @@ export function ContactSalesBanner() {
<ExternalLink className="size-3.5 shrink-0" />
</Link>
{" " + t("contactSalesOr") + " "}
<span className="whitespace-nowrap">
<Link
href="https://pangolin.net/contact"
target="_blank"
@@ -35,6 +36,7 @@ export function ContactSalesBanner() {
</Link>
.
</span>
</span>
</div>
</div>
</div>
+9 -13
View File
@@ -26,12 +26,14 @@ type IdpLoginButtonsProps = {
idps: LoginFormIDP[];
redirect?: string;
orgId?: string;
passOrgIdToOidcUrl?: boolean;
};
export default function IdpLoginButtons({
idps,
redirect,
orgId
orgId,
passOrgIdToOidcUrl = true
}: IdpLoginButtonsProps) {
const [error, setError] = useState<string | null>(null);
const t = useTranslations();
@@ -68,12 +70,13 @@ export default function IdpLoginButtons({
let redirectToUrl: string | undefined;
try {
console.log("generating", idpId, redirect || "/", orgId);
const oidcOrgId = passOrgIdToOidcUrl ? orgId : undefined;
console.log("generating", idpId, redirect || "/", oidcOrgId);
const safeRedirect = cleanRedirect(redirect || "/");
const response = await generateOidcUrlProxy(
idpId,
safeRedirect,
orgId
oidcOrgId
);
if (response.error) {
@@ -114,7 +117,6 @@ export default function IdpLoginButtons({
<div className="space-y-4">
{params.get("gotoapp") ? (
<>
<Button
type="button"
className="w-full"
@@ -124,18 +126,13 @@ export default function IdpLoginButtons({
>
{t("continueToApplication")}
</Button>
</>
) : (
<>
{idps.map((idp) => {
idps.map((idp) => {
const effectiveType =
idp.variant || idp.name.toLowerCase();
return (
<div
className="w-full relative"
key={idp.idpId}
>
<div className="w-full relative" key={idp.idpId}>
<Button
key={idp.idpId}
type="button"
@@ -165,8 +162,7 @@ export default function IdpLoginButtons({
)}
</div>
);
})}
</>
})
)}
</div>
</div>
+2
View File
@@ -23,6 +23,8 @@ export default function IdpTypeIcon({
}: Props) {
const effectiveType = (variant || type || "").toLowerCase();
console.log(`[IdpTypeIcon]`, { effectiveType, variant, type });
let src: string | null = null;
let defaultAlt = "";
+5 -3
View File
@@ -13,7 +13,7 @@ import {
import { InputOTP, InputOTPGroup, InputOTPSlot } from "./ui/input-otp";
import { Alert, AlertDescription } from "@app/components/ui/alert";
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";
@@ -82,9 +82,11 @@ export default function MfaInputForm({
maxLength={6}
{...field}
autoComplete="one-time-code"
inputMode="numeric"
inputMode="text"
autoFocus
pattern={REGEXP_ONLY_DIGITS}
pattern={
REGEXP_ONLY_DIGITS_AND_CHARS
}
onChange={(value: string) => {
field.onChange(value);
if (value.length === 6) {
+32 -36
View File
@@ -1,17 +1,6 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
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 ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import {
Credenza,
CredenzaBody,
@@ -22,37 +11,42 @@ import {
CredenzaHeader,
CredenzaTitle
} 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 {
ArrowRight,
ArrowUpDown,
MoreHorizontal
} from "lucide-react";
import { useMemo, useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useUserContext } from "@app/hooks/useUserContext";
import { useRouter } from "next/navigation";
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@app/components/ui/command";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import Link from "next/link";
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 { useEnvContext } from "@app/hooks/useEnvContext";
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 { 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 = {
idpId: number;
@@ -483,7 +477,8 @@ export default function IdpTable({ idps, orgId }: Props) {
{group.name}
</div>
<div className="mt-1 flex flex-wrap gap-1">
{group.sources.map((src) => (
{group.sources.map(
(src) => (
<Badge
key={src.orgId}
variant="secondary"
@@ -491,7 +486,8 @@ export default function IdpTable({ idps, orgId }: Props) {
>
{src.orgName}
</Badge>
))}
)
)}
</div>
</div>
</CommandItem>
+1
View File
@@ -303,6 +303,7 @@ export default function SmartLoginForm({
<IdpLoginButtons
idps={[lastUsedIdp]}
orgId={lastUsedIdp.orgId}
passOrgIdToOidcUrl={false}
redirect={redirect}
/>
)}
+3 -1
View File
@@ -134,7 +134,9 @@ export default function UptimeBar({
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 (
<div className={cn("space-y-3", className)}>
+3 -1
View File
@@ -124,7 +124,9 @@ export function UptimeMiniBar({
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 (
<div className="flex items-center gap-2">
+67 -1
View File
@@ -77,6 +77,8 @@ export type ClientRow = {
username: string | null;
hostname: string | null;
} | null;
firstSeen: number | null;
lastSeen: number | null;
};
type ClientTableProps = {
@@ -112,7 +114,9 @@ export default function UserDevicesTable({
const defaultUserColumnVisibility = {
subnet: false,
niceId: false
niceId: false,
firstSeen: false,
lastSeen: false
};
const refreshData = () => {
@@ -621,6 +625,68 @@ export default function UserDevicesTable({
accessorKey: "subnet",
friendlyName: t("address"),
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,
AlertRuleTriggerFields
} from "@app/components/alert-rule-editor/AlertRuleFields";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { SettingsContainer } from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput";
import { Button } from "@app/components/ui/button";
import { Card, CardContent } from "@app/components/ui/card";
import {
@@ -19,6 +21,7 @@ import {
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import {
buildFormSchema,
@@ -27,19 +30,15 @@ import {
type AlertRuleFormValues
} from "@app/lib/alertRuleForm";
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 { AxiosResponse } from "axios";
import { zodResolver } from "@hookform/resolvers/zod";
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 { Cog, Flag, Zap, ZapIcon } from "lucide-react";
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 { useRouter } from "next/navigation";
import { useActionState, useMemo, useTransition, type ReactNode } from "react";
import { useFieldArray, useForm, type Resolver } from "react-hook-form";
import { Badge } from "../ui/badge";
const FORM_ID = "alert-rule-form";
@@ -115,7 +114,6 @@ export default function AlertRuleGraphEditor({
const t = useTranslations();
const router = useRouter();
const api = createApiClient(useEnvContext());
const [isSaving, setIsSaving] = useState(false);
const schema = useMemo(() => buildFormSchema(t), [t]);
const form = useForm<AlertRuleFormValues>({
resolver: zodResolver(schema) as Resolver<AlertRuleFormValues>,
@@ -127,8 +125,22 @@ export default function AlertRuleGraphEditor({
name: "actions"
});
const onSubmit = form.handleSubmit(async (values) => {
setIsSaving(true);
const saveAlert = 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("alertingNoActionsSaveDescription")
});
}
return;
}
const values = form.getValues();
try {
const payload = formValuesToApiPayload(values);
if (isNew) {
@@ -158,14 +170,37 @@ export default function AlertRuleGraphEditor({
description: formatAxiosError(e),
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 (
<Form {...form}>
<form id={FORM_ID} onSubmit={onSubmit}>
<form id={FORM_ID} action={formAction}>
<SettingsContainer>
<PaidFeaturesAlert tiers={tierMatrix.alertingRules} />
<div className="flex flex-col lg:flex-row gap-6 lg:gap-8 items-start">
@@ -263,6 +298,7 @@ export default function AlertRuleGraphEditor({
</FormItem>
)}
/>
<div className="flex flex-col items-center w-full gap-3">
<Button
type="submit"
className="w-full"
@@ -271,6 +307,20 @@ export default function AlertRuleGraphEditor({
>
{t("save")}
</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>
</CardContent>
</Card>
+2 -1
View File
@@ -111,7 +111,8 @@ export function useCertificate({
let certError: string | null = null;
if (restartCert.isError) {
certError = "Failed to restart";
} else if (isError) {
} else if (isError || (!isLoading && data === null)) {
// Null value means failed to get the certificate
certError = "Failed";
}
+27 -29
View File
@@ -1,4 +1,10 @@
import type { LauncherQueryFilters } from "@app/lib/launcherSearchParams";
import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
import { build } from "@server/build";
import {
StatusHistoryResponse,
type BatchedStatusHistoryResponse
} from "@server/lib/statusHistory";
import type { ListAlertRulesResponse } from "@server/routers/alertRule/types";
import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs";
import type {
@@ -7,6 +13,7 @@ import type {
QueryConnectionAuditLogResponse,
QueryRequestAuditLogResponse
} from "@server/routers/auditLogs/types";
import type { GetCertificateResponse } from "@server/routers/certificates/types";
import type {
ListClientsResponse,
ListUserDevicesResponse
@@ -16,15 +23,30 @@ import type {
ListDomainsResponse
} from "@server/routers/domain";
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 {
GetResourceWhitelistResponse,
GetResourcePoliciesResponse,
GetResourceWhitelistResponse,
ListResourceNamesResponse,
ListResourcesResponse,
ListResourceRolesResponse,
ListResourceRulesResponse,
ListResourcesResponse,
ListResourceUsersResponse
} 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 { ListSitesResponse } from "@server/routers/site";
import type {
@@ -33,6 +55,7 @@ import type {
ListSiteResourceRolesResponse,
ListSiteResourceUsersResponse
} from "@server/routers/siteResource";
import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource";
import type { ListTargetsResponse } from "@server/routers/target";
import type { ListUsersResponse } from "@server/routers/user";
import type ResponseT from "@server/types/Response";
@@ -42,37 +65,12 @@ import {
queryOptions
} from "@tanstack/react-query";
import { isAxiosError, type AxiosResponse } from "axios";
import z, { meta } from "zod";
import z from "zod";
import { remote } from "./api";
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 type { LauncherQueryFilters } from "@app/lib/launcherSearchParams";
export type ProductUpdate = {
link: string | null;