Compare commits

..

5 Commits

Author SHA1 Message Date
Fred KISSIE
3d13e9105c 🏷️ fix types 2026-07-03 22:33:58 +02:00
Fred KISSIE
289be30e6b show last used login idp in smart login form 2026-07-03 22:33:51 +02:00
Fred KISSIE
a74c0c227c 🌐 text 2026-07-03 21:05:53 +02:00
Fred KISSIE
05bf77da29 ♻️ remove cache on verifySession calls as it's already wrapped in cache 2026-07-03 21:05:47 +02:00
Fred KISSIE
bb4deb1ae9 remember last used idp in dashboard login form 2026-07-03 21:05:16 +02:00
69 changed files with 831 additions and 3327 deletions

View File

@@ -864,8 +864,8 @@
"policyAuthHeaderAuthTitle": "Basic Header Auth", "policyAuthHeaderAuthTitle": "Basic Header Auth",
"policyAuthHeaderAuthDescription": "Validate a custom HTTP header name and value on each request", "policyAuthHeaderAuthDescription": "Validate a custom HTTP header name and value on each request",
"policyAuthHeaderAuthSummary": "Header configured", "policyAuthHeaderAuthSummary": "Header configured",
"policyAuthHeaderName": "Username", "policyAuthHeaderName": "Header name",
"policyAuthHeaderValue": "Password", "policyAuthHeaderValue": "Expected value",
"policyAuthSetPasscode": "Set Passcode", "policyAuthSetPasscode": "Set Passcode",
"policyAuthSetPincode": "Set PIN Code", "policyAuthSetPincode": "Set PIN Code",
"policyAuthSetEmailWhitelist": "Set Email Whitelist", "policyAuthSetEmailWhitelist": "Set Email Whitelist",
@@ -1503,6 +1503,7 @@
"otpAuthDescription": "Enter the code from your authenticator app or one of your single-use backup codes.", "otpAuthDescription": "Enter the code from your authenticator app or one of your single-use backup codes.",
"otpAuthSubmit": "Submit Code", "otpAuthSubmit": "Submit Code",
"idpContinue": "Or continue with", "idpContinue": "Or continue with",
"idpLastUsed": "Last used",
"otpAuthBack": "Back to Password", "otpAuthBack": "Back to Password",
"navbar": "Navigation Menu", "navbar": "Navigation Menu",
"navbarDescription": "Main navigation menu for the application", "navbarDescription": "Main navigation menu for the application",
@@ -3577,23 +3578,18 @@
"memberPortalResourceDisabled": "Resource Disabled", "memberPortalResourceDisabled": "Resource Disabled",
"memberPortalShowingResources": "Showing {start}-{end} of {total} resources", "memberPortalShowingResources": "Showing {start}-{end} of {total} resources",
"resourceLauncherTitle": "Resource Launcher", "resourceLauncherTitle": "Resource Launcher",
"resourceLauncherDescription": "View all available resources and launch them from one central hub", "resourceLauncherDescription": "View resource details and launch them from one place",
"resourceLauncherSearchPlaceholder": "Search your resources...", "resourceLauncherSearchPlaceholder": "Search all sites...",
"resourceLauncherDefaultView": "Default", "resourceLauncherDefaultView": "Default",
"resourceLauncherSaveView": "Save View", "resourceLauncherSaveView": "Save View",
"resourceLauncherSaveToCurrentView": "Save to Current View", "resourceLauncherSaveToCurrentView": "Save to Current View",
"resourceLauncherSaveDefaultPersonal": "Save for Me",
"resourceLauncherResetView": "Reset View", "resourceLauncherResetView": "Reset View",
"resourceLauncherResetSystemDefault": "Reset to System Default",
"resourceLauncherSystemDefaultRestored": "System default restored",
"resourceLauncherSystemDefaultRestoredDescription": "The default view has been reset to the original settings.",
"resourceLauncherSaveAsNewView": "Save as New View", "resourceLauncherSaveAsNewView": "Save as New View",
"resourceLauncherSaveAsNewViewDescription": "Give this view a name to save your current filters and layout.", "resourceLauncherSaveAsNewViewDescription": "Give this view a name to save your current filters and layout.",
"resourceLauncherSaveForEveryone": "Save for Everyone", "resourceLauncherSaveForEveryone": "Save for Everyone",
"resourceLauncherSaveForEveryoneDescription": "Share this view with all organization members. When unchecked, the view is only visible to you.", "resourceLauncherSaveForEveryoneDescription": "Share this view with all organization members. When unchecked, the view is only visible to you.",
"resourceLauncherMakePersonal": "Make Personal", "resourceLauncherMakePersonal": "Make Personal",
"resourceLauncherFilter": "Filter", "resourceLauncherFilter": "Filter",
"resourceLauncherFilterWithCount": "Filter, {count} applied",
"resourceLauncherSort": "Sort", "resourceLauncherSort": "Sort",
"resourceLauncherSortAscending": "Sort ascending", "resourceLauncherSortAscending": "Sort ascending",
"resourceLauncherSortDescending": "Sort descending", "resourceLauncherSortDescending": "Sort descending",
@@ -3601,7 +3597,6 @@
"resourceLauncherGroupBy": "Group By", "resourceLauncherGroupBy": "Group By",
"resourceLauncherGroupBySite": "Site", "resourceLauncherGroupBySite": "Site",
"resourceLauncherGroupByLabel": "Label", "resourceLauncherGroupByLabel": "Label",
"resourceLauncherGroupByNone": "None",
"resourceLauncherLayout": "Layout", "resourceLauncherLayout": "Layout",
"resourceLauncherLayoutGrid": "Grid", "resourceLauncherLayoutGrid": "Grid",
"resourceLauncherLayoutList": "List", "resourceLauncherLayoutList": "List",
@@ -3609,20 +3604,8 @@
"resourceLauncherShowSiteTags": "Show Site Tags", "resourceLauncherShowSiteTags": "Show Site Tags",
"resourceLauncherShowRecents": "Show Recents", "resourceLauncherShowRecents": "Show Recents",
"resourceLauncherDeleteView": "Delete View", "resourceLauncherDeleteView": "Delete View",
"resourceLauncherDeleteViewTitle": "Delete View",
"resourceLauncherDeleteViewQuestion": "Are you sure you want to delete this launcher view?",
"resourceLauncherDeleteViewConfirm": "Delete View",
"resourceLauncherViewAsAdmin": "View as Admin", "resourceLauncherViewAsAdmin": "View as Admin",
"resourceLauncherResourceDetailsDescription": "Connection information and status for this resource.", "resourceLauncherResourceDetailsDescription": "View details for this resource.",
"resourceLauncherResourceDetails": "Resource Details",
"resourceLauncherAuthMethodsDescription": "Authentication methods enabled for this resource.",
"resourceLauncherPrivateClientRequired": "Connect with a client on your device to access this resource privately.",
"resourceLauncherPrivateClientRequiredTitle": "Client Connection Required",
"resourceLauncherDownloadClient": "Download client",
"resourceLauncherFailedToLoadDetails": "Could not load resource details. You may no longer have access to this resource.",
"resourceLauncherNoPortRestrictions": "No port restrictions",
"resourceLauncherTcp": "TCP",
"resourceLauncherUdp": "UDP",
"resourceLauncherUnlabeled": "Unlabeled", "resourceLauncherUnlabeled": "Unlabeled",
"resourceLauncherNoSite": "No Site", "resourceLauncherNoSite": "No Site",
"resourceLauncherNoResourcesInGroup": "No resources in this group", "resourceLauncherNoResourcesInGroup": "No resources in this group",
@@ -3631,12 +3614,6 @@
"resourceLauncherEmptyStateNoResultsTitle": "No Resources Found", "resourceLauncherEmptyStateNoResultsTitle": "No Resources Found",
"resourceLauncherEmptyStateNoResultsDescription": "No resources match your current search or filters. Try adjusting them to find what you are looking for.", "resourceLauncherEmptyStateNoResultsDescription": "No resources match your current search or filters. Try adjusting them to find what you are looking for.",
"resourceLauncherEmptyStateNoResultsWithQuery": "No resources match \"{query}\". Try adjusting your search or clearing filters to see all resources.", "resourceLauncherEmptyStateNoResultsWithQuery": "No resources match \"{query}\". Try adjusting your search or clearing filters to see all resources.",
"resourceLauncherSearchFirstTitle": "Search or Filter to Browse",
"resourceLauncherSearchFirstDescription": "You have access to many resources. Use search or filter by site or label to find what you need.",
"resourceLauncherSiteGroupingDisabled": "Site grouping is unavailable at this scale. Filter by site to group a smaller set.",
"resourceLauncherLabelGroupingDisabled": "Label grouping is unavailable at this scale.",
"resourceLauncherCompactModeHint": "Showing a simplified list for faster browsing. Use search or filters to narrow results.",
"resourceLauncherCompactGroupingHint": "Apply site or label filters to enable grouping.",
"resourceLauncherCopiedToClipboard": "Copied to clipboard", "resourceLauncherCopiedToClipboard": "Copied to clipboard",
"resourceLauncherCopiedAccessDescription": "Resource access has been copied to your clipboard.", "resourceLauncherCopiedAccessDescription": "Resource access has been copied to your clipboard.",
"resourceLauncherViewNamePlaceholder": "View name", "resourceLauncherViewNamePlaceholder": "View name",

View File

@@ -18,17 +18,7 @@ export function createPoolConfig(
keepAliveInitialDelayMillis: 10000, // send first keepalive after 10s of idle keepAliveInitialDelayMillis: 10000, // send first keepalive after 10s of idle
// Allow connections to be released and recreated more aggressively // Allow connections to be released and recreated more aggressively
// to avoid stale connections building up // to avoid stale connections building up
allowExitOnIdle: false, allowExitOnIdle: false
// Disable JIT compilation for this connection. Our hot-path queries
// (e.g. resource-by-domain lookups) join many tables but only ever
// return a handful of rows. When planner row estimates drift (e.g.
// due to autovacuum lag under write-heavy load), Postgres decides
// these plans are expensive enough to JIT-compile, which can add
// multiple seconds of pure compilation overhead per query and
// saturate the connection pool. JIT never pays off for these
// short-lived OLTP queries, so it's disabled outright rather than
// relying on statistics staying fresh.
options: "-c jit=off"
}; };
} }

View File

@@ -26,7 +26,7 @@ export const domains = pgTable("domains", {
customCertResolver: varchar("customCertResolver"), customCertResolver: varchar("customCertResolver"),
preferWildcardCert: boolean("preferWildcardCert"), preferWildcardCert: boolean("preferWildcardCert"),
errorMessage: text("errorMessage"), errorMessage: text("errorMessage"),
checkedAt: integer("checkedAt") lastCheckedAt: integer("lastCheckedAt")
}); });
export const dnsRecords = pgTable("dnsRecords", { export const dnsRecords = pgTable("dnsRecords", {
@@ -232,7 +232,6 @@ export const launcherViews = pgTable("launcherViews", {
}), }),
name: varchar("name").notNull(), name: varchar("name").notNull(),
config: text("config").notNull(), config: text("config").notNull(),
isDefault: boolean("isDefault").notNull().default(false),
createdAt: varchar("createdAt").notNull(), createdAt: varchar("createdAt").notNull(),
updatedAt: varchar("updatedAt").notNull() updatedAt: varchar("updatedAt").notNull()
}); });

View File

@@ -23,7 +23,7 @@ export const domains = sqliteTable("domains", {
customCertResolver: text("customCertResolver"), customCertResolver: text("customCertResolver"),
preferWildcardCert: integer("preferWildcardCert", { mode: "boolean" }), preferWildcardCert: integer("preferWildcardCert", { mode: "boolean" }),
errorMessage: text("errorMessage"), errorMessage: text("errorMessage"),
checkedAt: integer("checkedAt") lastCheckedAt: integer("lastCheckedAt")
}); });
export const dnsRecords = sqliteTable("dnsRecords", { export const dnsRecords = sqliteTable("dnsRecords", {
@@ -233,9 +233,6 @@ export const launcherViews = sqliteTable("launcherViews", {
}), }),
name: text("name").notNull(), name: text("name").notNull(),
config: text("config").notNull(), config: text("config").notNull(),
isDefault: integer("isDefault", { mode: "boolean" })
.notNull()
.default(false),
createdAt: text("createdAt").notNull(), createdAt: text("createdAt").notNull(),
updatedAt: text("updatedAt").notNull() updatedAt: text("updatedAt").notNull()
}); });

View File

@@ -116,7 +116,7 @@ export async function applyNewtDockerBlueprint(
source: "NEWT" source: "NEWT"
}); });
} catch (error) { } catch (error) {
logger.debug(`Failed to update database from config: ${error}`); logger.error(`Failed to update database from config: ${error}`);
await sendToClient(newtId, { await sendToClient(newtId, {
type: "newt/blueprint/results", type: "newt/blueprint/results",
data: { data: {

View File

@@ -1,83 +0,0 @@
import logger from "@server/logger";
const MAX_RETRIES = 5;
const BASE_DELAY_MS = 50;
/**
* Detect transient errors that are safe to retry (connection drops, deadlocks,
* serialization failures). PostgreSQL deadlocks (40P01) are always safe to
* retry: the database guarantees exactly one winner per deadlock pair, so the
* loser just needs to try again.
*/
export function isTransientError(error: any): boolean {
if (!error) return false;
const message = (error.message || "").toLowerCase();
const causeMessage = (error.cause?.message || "").toLowerCase();
const code = error.code || error.cause?.code || "";
// Connection timeout / terminated
if (
message.includes("connection timeout") ||
message.includes("connection terminated") ||
message.includes("timeout exceeded when trying to connect") ||
causeMessage.includes("connection terminated unexpectedly") ||
causeMessage.includes("connection timeout")
) {
return true;
}
// PostgreSQL deadlock detected - always safe to retry (one winner guaranteed)
if (code === "40P01" || message.includes("deadlock")) {
return true;
}
// PostgreSQL serialization failure
if (code === "40001") {
return true;
}
// ECONNRESET, ECONNREFUSED, EPIPE, ETIMEDOUT
if (
code === "ECONNRESET" ||
code === "ECONNREFUSED" ||
code === "EPIPE" ||
code === "ETIMEDOUT"
) {
return true;
}
return false;
}
/**
* Simple retry wrapper with exponential backoff for transient errors
* (deadlocks, connection timeouts, unexpected disconnects).
*/
export async function withRetry<T>(
operation: () => Promise<T>,
context: string,
maxRetries: number = MAX_RETRIES,
baseDelayMs: number = BASE_DELAY_MS
): Promise<T> {
let attempt = 0;
while (true) {
try {
return await operation();
} catch (error: any) {
if (isTransientError(error) && attempt < maxRetries) {
attempt++;
const baseDelay = Math.pow(2, attempt - 1) * baseDelayMs;
const jitter = Math.random() * baseDelay;
const delay = baseDelay + jitter;
logger.warn(
`Transient DB error in ${context}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms`,
{ code: error?.code ?? error?.cause?.code }
);
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
throw error;
}
}
}

View File

@@ -1,55 +1,30 @@
import { db, exitNodes, Transaction } from "@server/db"; import { db, exitNodes } from "@server/db";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { findNextAvailableCidr } from "@server/lib/ip"; import { findNextAvailableCidr } from "@server/lib/ip";
import { lockManager } from "#dynamic/lib/lock";
/** export async function getNextAvailableSubnet(): Promise<string> {
* Reserves the next available exit node subnet. // Get all existing subnets from routes table
* const existingAddresses = await db
* Exit node subnets must never overlap with one another - regardless of .select({
* which org(s) they belong to - since HA exit nodes can end up routing for address: exitNodes.address
* the same org. This acquires a lock that the caller MUST release (via the })
* returned `release`) only after the chosen address has been durably .from(exitNodes);
* persisted (e.g. after the enclosing transaction commits), otherwise
* concurrent callers can race and pick the same subnet. const addresses = existingAddresses.map((a) => a.address);
*/ let subnet = findNextAvailableCidr(
export async function getNextAvailableSubnet( addresses,
trx: Transaction | typeof db = db config.getRawConfig().gerbil.block_size,
): Promise<{ value: string; release: () => Promise<void> }> { config.getRawConfig().gerbil.subnet_group
const lockKey = "exit-node-subnet-allocation"; );
const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000); if (!subnet) {
if (!acquired) { throw new Error("No available subnets remaining in space");
throw new Error(`Failed to acquire lock: ${lockKey}`);
} }
const release = () => lockManager.releaseLock(lockKey, acquired);
try { // replace the last octet with 1
// Get all existing subnets from routes table subnet =
const existingAddresses = await trx subnet.split(".").slice(0, 3).join(".") +
.select({ ".1" +
address: exitNodes.address "/" +
}) subnet.split("/")[1];
.from(exitNodes); return subnet;
const addresses = existingAddresses.map((a) => a.address);
let subnet = findNextAvailableCidr(
addresses,
config.getRawConfig().gerbil.block_size,
config.getRawConfig().gerbil.subnet_group
);
if (!subnet) {
throw new Error("No available subnets remaining in space");
}
// replace the last octet with 1
subnet =
subnet.split(".").slice(0, 3).join(".") +
".1" +
"/" +
subnet.split("/")[1];
return { value: subnet, release };
} catch (e) {
await release();
throw e;
}
} }

View File

@@ -45,7 +45,6 @@ import {
} from "@server/routers/client/targets"; } from "@server/routers/client/targets";
import { lockManager } from "#dynamic/lib/lock"; import { lockManager } from "#dynamic/lib/lock";
import { rebuildQueue } from "#dynamic/lib/rebuildQueue"; import { rebuildQueue } from "#dynamic/lib/rebuildQueue";
import { withRetry, isTransientError } from "@server/lib/dbRetry";
import { import {
checkOrgRebuildRateLimit, checkOrgRebuildRateLimit,
decrementOrgRebuildCount, decrementOrgRebuildCount,
@@ -286,20 +285,10 @@ export async function rebuildClientAssociationsFromSiteResource(
) { ) {
await incrementOrgRebuildCount(siteResource.orgId); await incrementOrgRebuildCount(siteResource.orgId);
try { try {
// The whole locked rebuild is idempotent (it diffs full expected vs. return await lockManager.withLock(
// actual state each time), so on a transient DB error it's safe to `rebuild-client-associations:site-resource:${siteResource.siteResourceId}`,
// retry the entire thing rather than just the failed query. () => rebuildClientAssociationsFromSiteResourceImpl(siteResource),
return await withRetry( REBUILD_ASSOCIATIONS_LOCK_TTL_MS
() =>
lockManager.withLock(
`rebuild-client-associations:site-resource:${siteResource.siteResourceId}`,
() =>
rebuildClientAssociationsFromSiteResourceImpl(
siteResource
),
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
),
`rebuildClientAssociationsFromSiteResource:${siteResource.siteResourceId}`
); );
} catch (err: any) { } catch (err: any) {
if ( if (
@@ -315,17 +304,6 @@ export async function rebuildClientAssociationsFromSiteResource(
}); });
return { mergedAllClients: [] }; return { mergedAllClients: [] };
} }
if (isTransientError(err)) {
logger.warn(
`rebuildClientAssociations: transient DB error rebuilding site resource ${siteResource.siteResourceId} persisted after retries, queuing for deferred processing:`,
err
);
await rebuildQueue.enqueue({
type: "site-resource",
id: siteResource.siteResourceId
});
return { mergedAllClients: [] };
}
throw err; throw err;
} finally { } finally {
await decrementOrgRebuildCount(siteResource.orgId); await decrementOrgRebuildCount(siteResource.orgId);
@@ -485,7 +463,6 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
await trx await trx
.insert(clientSiteResourcesAssociationsCache) .insert(clientSiteResourcesAssociationsCache)
.values(clientSiteResourcesToInsert) .values(clientSiteResourcesToInsert)
.onConflictDoNothing()
.returning(); .returning();
logger.debug( logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} inserted clientSiteResource associations` `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} inserted clientSiteResource associations`
@@ -533,148 +510,121 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
for (const site of sitesToProcess) { for (const site of sitesToProcess) {
const siteId = site.siteId; const siteId = site.siteId;
try { logger.debug(
logger.debug( `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] processing siteId=${siteId} for siteResourceId=${siteResource.siteResourceId}`
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] processing siteId=${siteId} for siteResourceId=${siteResource.siteResourceId}` );
);
const existingClientSites = await trx const existingClientSites = await trx
.select({ .select({
clientId: clientSitesAssociationsCache.clientId clientId: clientSitesAssociationsCache.clientId
}) })
.from(clientSitesAssociationsCache) .from(clientSitesAssociationsCache)
.where(eq(clientSitesAssociationsCache.siteId, siteId)); .where(eq(clientSitesAssociationsCache.siteId, siteId));
const existingClientSiteIds = existingClientSites.map( const existingClientSiteIds = existingClientSites.map(
(row) => row.clientId (row) => row.clientId
); );
logger.debug( logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} existingClientSiteIds=[${existingClientSiteIds.join(", ")}]` `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} existingClientSiteIds=[${existingClientSiteIds.join(", ")}]`
); );
// Get full client details for existing clients (needed for sending delete messages) // Get full client details for existing clients (needed for sending delete messages)
const existingClients = const existingClients =
existingClientSiteIds.length > 0 existingClientSiteIds.length > 0
? await trx ? await trx
.select({ .select({
clientId: clients.clientId, clientId: clients.clientId,
pubKey: clients.pubKey, pubKey: clients.pubKey,
subnet: clients.subnet subnet: clients.subnet
}) })
.from(clients) .from(clients)
.where( .where(inArray(clients.clientId, existingClientSiteIds))
inArray(clients.clientId, existingClientSiteIds)
)
: [];
const otherResourceClientIds =
clientsFromOtherResourcesBySite.get(siteId) ??
new Set<number>();
logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} otherResourceClientIds=[${[...otherResourceClientIds].join(", ")}] mergedAllClientIds=[${mergedAllClientIds.join(", ")}]`
);
// Expected clients from this resource are site-scoped: if this site is
// no longer attached to the resource, the expected set is empty.
const expectedClientIdsForSite = currentSiteIdSet.has(siteId)
? mergedAllClientIds
: []; : [];
// Note: we deliberately do NOT exclude clients covered by another const otherResourceClientIds =
// site resource here (unlike clientSitesToRemove below). Doing so clientsFromOtherResourcesBySite.get(siteId) ?? new Set<number>();
// previously caused a permanent gap: if resource A saw resource B's
// cache row and skipped adding (assuming B would maintain it), and
// B's own rebuild made the same assumption about A, the site-level
// row could end up never inserted by anyone even though both
// resources' client associations were otherwise correct.
// onConflictDoNothing makes a redundant insert harmless, so there's
// no correctness reason to skip here.
const clientSitesToAdd = expectedClientIdsForSite.filter(
(clientId) => !existingClientSiteIds.includes(clientId)
);
const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({ logger.debug(
clientId, `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} otherResourceClientIds=[${[...otherResourceClientIds].join(", ")}] mergedAllClientIds=[${mergedAllClientIds.join(", ")}]`
siteId );
}));
// Expected clients from this resource are site-scoped: if this site is
// no longer attached to the resource, the expected set is empty.
const expectedClientIdsForSite = currentSiteIdSet.has(siteId)
? mergedAllClientIds
: [];
const clientSitesToAdd = expectedClientIdsForSite.filter(
(clientId) =>
!existingClientSiteIds.includes(clientId) &&
!otherResourceClientIds.has(clientId) // dont add if already connected via another site resource
);
const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({
clientId,
siteId
}));
logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} clientSites toAdd=[${clientSitesToAdd.join(", ")}]`
);
if (clientSitesToInsert.length > 0) {
logger.debug( logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} clientSites toAdd=[${clientSitesToAdd.join(", ")}]` `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserting ${clientSitesToInsert.length} clientSite association(s)`
); );
await trx
if (clientSitesToInsert.length > 0) { .insert(clientSitesAssociationsCache)
logger.debug( .values(clientSitesToInsert)
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserting ${clientSitesToInsert.length} clientSite association(s)` .returning();
);
await trx
.insert(clientSitesAssociationsCache)
.values(clientSitesToInsert)
.onConflictDoNothing()
.returning();
logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserted clientSite associations`
);
} else {
logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} no clientSite associations to insert`
);
}
// Now remove any client-site associations that should no longer exist
const clientSitesToRemove = existingClientSiteIds.filter(
(clientId) =>
!expectedClientIdsForSite.includes(clientId) &&
!otherResourceClientIds.has(clientId) // dont remove if there is still another connection for another site resource
);
logger.debug( logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} clientSites toRemove=[${clientSitesToRemove.join(", ")}]` `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserted clientSite associations`
); );
} else {
if (clientSitesToRemove.length > 0) { logger.debug(
logger.debug( `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} no clientSite associations to insert`
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} deleting ${clientSitesToRemove.length} clientSite association(s)`
);
await trx
.delete(clientSitesAssociationsCache)
.where(
and(
eq(clientSitesAssociationsCache.siteId, siteId),
inArray(
clientSitesAssociationsCache.clientId,
clientSitesToRemove
)
)
);
}
// Now handle the messages to add/remove peers on both the newt and olm sides
await handleMessagesForSiteClients(
site,
siteId,
mergedAllClients,
existingClients,
clientSitesToAdd,
clientSitesToRemove,
trx
); );
} catch (err) {
// Don't let a failure on one site abort processing of every
// other site queued after it in this run. Since we're not
// re-throwing, the outer wrapper's retry/requeue logic never
// sees this failure, so explicitly queue this resource for a
// follow-up pass to reconcile whatever this site didn't get to.
logger.error(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} failed while processing site for siteResourceId=${siteResource.siteResourceId}, continuing with remaining sites and queuing a follow-up pass:`,
err
);
await rebuildQueue.enqueue({
type: "site-resource",
id: siteResource.siteResourceId
});
} }
// Now remove any client-site associations that should no longer exist
const clientSitesToRemove = existingClientSiteIds.filter(
(clientId) =>
!expectedClientIdsForSite.includes(clientId) &&
!otherResourceClientIds.has(clientId) // dont remove if there is still another connection for another site resource
);
logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} clientSites toRemove=[${clientSitesToRemove.join(", ")}]`
);
if (clientSitesToRemove.length > 0) {
logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} deleting ${clientSitesToRemove.length} clientSite association(s)`
);
await trx
.delete(clientSitesAssociationsCache)
.where(
and(
eq(clientSitesAssociationsCache.siteId, siteId),
inArray(
clientSitesAssociationsCache.clientId,
clientSitesToRemove
)
)
);
}
// Now handle the messages to add/remove peers on both the newt and olm sides
await handleMessagesForSiteClients(
site,
siteId,
mergedAllClients,
existingClients,
clientSitesToAdd,
clientSitesToRemove,
trx
);
} }
// Handle subnet proxy target updates for the resource associations // Handle subnet proxy target updates for the resource associations
@@ -707,7 +657,7 @@ async function handleMessagesForSiteClients(
trx: Transaction | typeof db = db trx: Transaction | typeof db = db
): Promise<void> { ): Promise<void> {
if (!site.exitNodeId) { if (!site.exitNodeId) {
logger.debug( logger.warn(
`Exit node ID not on site ${site.siteId} so there is no reason to update clients because it must be offline` `Exit node ID not on site ${site.siteId} so there is no reason to update clients because it must be offline`
); );
return; return;
@@ -721,14 +671,14 @@ async function handleMessagesForSiteClients(
.limit(1); .limit(1);
if (!exitNode) { if (!exitNode) {
logger.debug( logger.warn(
`Exit node not found for site ${site.siteId} so there is no reason to update clients because it must be offline` `Exit node not found for site ${site.siteId} so there is no reason to update clients because it must be offline`
); );
return; return;
} }
if (!site.publicKey) { if (!site.publicKey) {
logger.debug( logger.warn(
`Site publicKey not set for site ${site.siteId} so cannot add peers to clients` `Site publicKey not set for site ${site.siteId} so cannot add peers to clients`
); );
return; return;
@@ -742,7 +692,7 @@ async function handleMessagesForSiteClients(
.where(eq(newts.siteId, siteId)) .where(eq(newts.siteId, siteId))
.limit(1); .limit(1);
if (!newt) { if (!newt) {
logger.debug( logger.warn(
`Newt not found for site ${siteId} so cannot add peers to clients` `Newt not found for site ${siteId} so cannot add peers to clients`
); );
return; return;
@@ -967,7 +917,7 @@ export async function updateClientSiteDestinations(
for (const site of sitesData) { for (const site of sitesData) {
if (!site.sites.subnet) { if (!site.sites.subnet) {
logger.debug(`Site ${site.sites.siteId} has no subnet, skipping`); logger.warn(`Site ${site.sites.siteId} has no subnet, skipping`);
continue; continue;
} }
@@ -1706,17 +1656,10 @@ export async function rebuildClientAssociationsFromClient(
await incrementOrgRebuildCount(client.orgId); await incrementOrgRebuildCount(client.orgId);
try { try {
const trx = primaryDb; const trx = primaryDb;
// The whole locked rebuild is idempotent (it diffs full expected vs. return await lockManager.withLock(
// actual state each time), so on a transient DB error it's safe to `rebuild-client-associations:client:${client.clientId}`,
// retry the entire thing rather than just the failed query. () => rebuildClientAssociationsFromClientImpl(client, trx),
return await withRetry( REBUILD_ASSOCIATIONS_LOCK_TTL_MS
() =>
lockManager.withLock(
`rebuild-client-associations:client:${client.clientId}`,
() => rebuildClientAssociationsFromClientImpl(client, trx),
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
),
`rebuildClientAssociationsFromClient:${client.clientId}`
); );
} catch (err: any) { } catch (err: any) {
if ( if (
@@ -1732,17 +1675,6 @@ export async function rebuildClientAssociationsFromClient(
}); });
return; return;
} }
if (isTransientError(err)) {
logger.warn(
`rebuildClientAssociations: transient DB error rebuilding client ${client.clientId} persisted after retries, queuing for deferred processing:`,
err
);
await rebuildQueue.enqueue({
type: "client",
id: client.clientId
});
return;
}
throw err; throw err;
} finally { } finally {
await decrementOrgRebuildCount(client.orgId); await decrementOrgRebuildCount(client.orgId);
@@ -1894,15 +1826,12 @@ async function rebuildClientAssociationsFromClientImpl(
// Insert new associations // Insert new associations
if (resourcesToAdd.length > 0) { if (resourcesToAdd.length > 0) {
await trx await trx.insert(clientSiteResourcesAssociationsCache).values(
.insert(clientSiteResourcesAssociationsCache) resourcesToAdd.map((siteResourceId) => ({
.values( clientId: client.clientId,
resourcesToAdd.map((siteResourceId) => ({ siteResourceId
clientId: client.clientId, }))
siteResourceId );
}))
)
.onConflictDoNothing();
} }
// Remove old associations // Remove old associations
@@ -1940,15 +1869,12 @@ async function rebuildClientAssociationsFromClientImpl(
// Insert new site associations // Insert new site associations
if (sitesToAdd.length > 0) { if (sitesToAdd.length > 0) {
await trx await trx.insert(clientSitesAssociationsCache).values(
.insert(clientSitesAssociationsCache) sitesToAdd.map((siteId) => ({
.values( clientId: client.clientId,
sitesToAdd.map((siteId) => ({ siteId
clientId: client.clientId, }))
siteId );
}))
)
.onConflictDoNothing();
} }
// Remove old site associations // Remove old site associations

View File

@@ -1,14 +1,10 @@
import logger from "@server/logger"; import logger from "@server/logger";
import { isTransientError } from "@server/lib/dbRetry";
export type RebuildJobType = "site-resource" | "client"; export type RebuildJobType = "site-resource" | "client";
export interface RebuildJob { export interface RebuildJob {
type: RebuildJobType; type: RebuildJobType;
id: number; id: number;
// Number of times this job has already been re-queued after a transient
// failure. Absent/0 means it has not failed yet.
attempt?: number;
} }
export interface RebuildJobHandlers { export interface RebuildJobHandlers {
@@ -28,10 +24,6 @@ export interface RebuildQueueManager {
// retried shortly after against fresh DB state. // retried shortly after against fresh DB state.
const POLL_INTERVAL_MS = 500; const POLL_INTERVAL_MS = 500;
const BATCH_SIZE = 5; const BATCH_SIZE = 5;
// A job that fails with a transient DB error gets re-queued with backoff
// instead of being dropped, up to this many times.
const MAX_JOB_ATTEMPTS = 5;
const JOB_RETRY_BASE_DELAY_MS = 1000;
function dedupeKey(job: RebuildJob): string { function dedupeKey(job: RebuildJob): string {
return `${job.type}:${job.id}`; return `${job.type}:${job.id}`;
@@ -114,29 +106,10 @@ class InMemoryRebuildQueue implements RebuildQueueManager {
`Rebuild queue: completed ${job.type}:${job.id}` `Rebuild queue: completed ${job.type}:${job.id}`
); );
} catch (err) { } catch (err) {
const attempt = (job.attempt ?? 0) + 1; logger.error(
if (isTransientError(err) && attempt <= MAX_JOB_ATTEMPTS) { `Rebuild queue: job ${job.type}:${job.id} threw an error:`,
const delay = err
JOB_RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1); );
logger.warn(
`Rebuild queue: job ${job.type}:${job.id} hit a transient error (attempt ${attempt}/${MAX_JOB_ATTEMPTS}), re-queuing in ${delay}ms:`,
err
);
setTimeout(() => {
this.enqueue({ ...job, attempt }).catch(
(enqueueErr) =>
logger.error(
`Rebuild queue: failed to re-queue ${job.type}:${job.id} after transient error:`,
enqueueErr
)
);
}, delay);
} else {
logger.error(
`Rebuild queue: job ${job.type}:${job.id} threw an error:`,
err
);
}
} }
} }
} finally { } finally {

View File

@@ -14,7 +14,7 @@
import { redis } from "#private/lib/redis"; import { redis } from "#private/lib/redis";
import logger from "@server/logger"; import logger from "@server/logger";
export const ORG_REBUILD_CONCURRENCY_LIMIT = 10; export const ORG_REBUILD_CONCURRENCY_LIMIT = 5;
// Safety-net TTL: slightly longer than the rebuild lock TTL (120 s). If a // Safety-net TTL: slightly longer than the rebuild lock TTL (120 s). If a
// server process dies while holding a rebuild, this ensures the counter key // server process dies while holding a rebuild, this ensures the counter key

View File

@@ -14,16 +14,12 @@
import { redis } from "#private/lib/redis"; import { redis } from "#private/lib/redis";
import { lockManager } from "#private/lib/lock"; import { lockManager } from "#private/lib/lock";
import logger from "@server/logger"; import logger from "@server/logger";
import { isTransientError } from "@server/lib/dbRetry";
export type RebuildJobType = "site-resource" | "client"; export type RebuildJobType = "site-resource" | "client";
export interface RebuildJob { export interface RebuildJob {
type: RebuildJobType; type: RebuildJobType;
id: number; id: number;
// Number of times this job has already been re-queued after a transient
// failure. Absent/0 means it has not failed yet.
attempt?: number;
} }
export interface RebuildJobHandlers { export interface RebuildJobHandlers {
@@ -47,11 +43,6 @@ const PROCESSOR_LOCK_TTL_MS = 120000 * BATCH_SIZE + 30000; // ~630 s
const POLL_INTERVAL_MS = 500; const POLL_INTERVAL_MS = 500;
// A job that fails with a transient DB error gets re-queued with backoff
// instead of being dropped, up to this many times.
const MAX_JOB_ATTEMPTS = 5;
const JOB_RETRY_BASE_DELAY_MS = 1000;
class RedisRebuildQueue { class RedisRebuildQueue {
private processingStarted = false; private processingStarted = false;
@@ -189,33 +180,10 @@ class RedisRebuildQueue {
`Rebuild queue: completed ${job.type}:${job.id}` `Rebuild queue: completed ${job.type}:${job.id}`
); );
} catch (err) { } catch (err) {
const attempt = (job.attempt ?? 0) + 1; logger.error(
if ( `Rebuild queue: job ${job.type}:${job.id} threw an error:`,
isTransientError(err) && err
attempt <= MAX_JOB_ATTEMPTS );
) {
const delay =
JOB_RETRY_BASE_DELAY_MS *
Math.pow(2, attempt - 1);
logger.warn(
`Rebuild queue: job ${job.type}:${job.id} hit a transient error (attempt ${attempt}/${MAX_JOB_ATTEMPTS}), re-queuing in ${delay}ms:`,
err
);
setTimeout(() => {
this.enqueue({ ...job, attempt }).catch(
(enqueueErr) =>
logger.error(
`Rebuild queue: failed to re-queue ${job.type}:${job.id} after transient error:`,
enqueueErr
)
);
}, delay);
} else {
logger.error(
`Rebuild queue: job ${job.type}:${job.id} threw an error:`,
err
);
}
} }
} }
}, },

View File

@@ -894,19 +894,6 @@ class RegionalRedisManager {
return opts; return opts;
} }
// The regional Redis StatefulSet's "redis" service pins to pod redis-0
// (primary). The replica (redis-1) is only reachable through the
// per-pod headless service: <svc>.<namespace>.svc.cluster.local ->
// redis-1.redis-headless.<namespace>.svc.cluster.local. Returns null
// if the configured host doesn't match that pattern (e.g. local dev),
// in which case callers should fall back to the primary for reads.
private getReplicaHost(primaryHost: string): string | null {
const match = primaryHost.match(/^redis\.([^.]+)\.svc\.cluster\.local$/);
if (!match) return null;
const namespace = match[1];
return `redis-1.redis-headless.${namespace}.svc.cluster.local`;
}
private initializeClients(): void { private initializeClients(): void {
const cfg = this.getConfig(); const cfg = this.getConfig();
const baseOpts = { const baseOpts = {
@@ -920,42 +907,35 @@ class RegionalRedisManager {
try { try {
this.writeClient = new Redis(baseOpts); this.writeClient = new Redis(baseOpts);
// redis-1 (replica) handles reads; fall back to primary if not resolvable
this.readClient = new Redis({
...baseOpts,
host: cfg.host!.replace(/^(.*?)(\.\S+)$/, (_, h, rest) => {
// Derive replica hostname from the headless service pattern:
// redis.redis.svc.cluster.local -> redis-1.redis-headless.redis.svc.cluster.local
// If it doesn't look like a k8s service, just use the same host
return h + rest;
})
});
const replicaHost = this.getReplicaHost(cfg.host!); // For simplicity use same host for both; callers can always read from primary
this.readClient = replicaHost // The real replica routing is handled by the StatefulSet headless service
? new Redis({ ...baseOpts, host: replicaHost }) this.readClient = this.writeClient;
: this.writeClient;
this.writeClient.on("ready", () => { this.writeClient.on("ready", () => {
logger.info("Regional Redis write client ready"); logger.info("Regional Redis client ready");
this.isHealthy = true; this.isHealthy = true;
}); });
this.writeClient.on("error", (err) => { this.writeClient.on("error", (err) => {
logger.error("Regional Redis write client error:", err); logger.error("Regional Redis client error:", err);
this.isHealthy = false; this.isHealthy = false;
}); });
this.writeClient.on("reconnecting", () => { this.writeClient.on("reconnecting", () => {
logger.info("Regional Redis write client reconnecting..."); logger.info("Regional Redis client reconnecting...");
this.isHealthy = false; this.isHealthy = false;
}); });
if (this.readClient !== this.writeClient) { logger.info("Regional Redis client initialized");
this.readClient.on("ready", () => {
logger.info("Regional Redis read client ready");
});
this.readClient.on("error", (err) => {
logger.error("Regional Redis read client error:", err);
});
this.readClient.on("reconnecting", () => {
logger.info("Regional Redis read client reconnecting...");
});
}
logger.info(
replicaHost
? `Regional Redis client initialized (reads routed to replica ${replicaHost})`
: "Regional Redis client initialized (no replica resolvable, reads routed to primary)"
);
} catch (error) { } catch (error) {
logger.error("Failed to initialize regional Redis client:", error); logger.error("Failed to initialize regional Redis client:", error);
this.isEnabled = false; this.isEnabled = false;
@@ -1061,14 +1041,11 @@ class RegionalRedisManager {
public async disconnect(): Promise<void> { public async disconnect(): Promise<void> {
try { try {
if (this.readClient && this.readClient !== this.writeClient) {
await this.readClient.quit();
}
this.readClient = null;
if (this.writeClient) { if (this.writeClient) {
await this.writeClient.quit(); await this.writeClient.quit();
this.writeClient = null; this.writeClient = null;
} }
this.readClient = null;
logger.info("Regional Redis client disconnected"); logger.info("Regional Redis client disconnected");
} catch (error) { } catch (error) {
logger.error("Error disconnecting regional Redis client:", error); logger.error("Error disconnecting regional Redis client:", error);

View File

@@ -65,7 +65,6 @@ export function verifyValidSubscription(tiers: Tier[]) {
return next(); return next();
} catch (e) { } catch (e) {
console.error(e);
return next( return next(
createHttpError( createHttpError(
HttpCode.INTERNAL_SERVER_ERROR, HttpCode.INTERNAL_SERVER_ERROR,

View File

@@ -29,41 +29,37 @@ export async function createExitNode(
.where(eq(exitNodes.publicKey, publicKey)); .where(eq(exitNodes.publicKey, publicKey));
let exitNode: ExitNode; let exitNode: ExitNode;
if (!exitNodeQuery) { if (!exitNodeQuery) {
const { value: address, release } = await getNextAvailableSubnet(); const address = await getNextAvailableSubnet();
try { // TODO: eventually we will want to get the next available port so that we can multiple exit nodes
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes // const listenPort = await getNextAvailablePort();
// const listenPort = await getNextAvailablePort(); const listenPort = config.getRawConfig().gerbil.start_port;
const listenPort = config.getRawConfig().gerbil.start_port; let subEndpoint = "";
let subEndpoint = ""; if (config.getRawConfig().gerbil.use_subdomain) {
if (config.getRawConfig().gerbil.use_subdomain) { subEndpoint = await getUniqueExitNodeEndpointName();
subEndpoint = await getUniqueExitNodeEndpointName();
}
const exitNodeName =
config.getRawConfig().gerbil.exit_node_name ||
`Exit Node ${publicKey.slice(0, 8)}`;
// create a new exit node
[exitNode] = await db
.insert(exitNodes)
.values({
publicKey,
endpoint: `${subEndpoint}${subEndpoint != "" ? "." : ""}${config.getRawConfig().gerbil.base_endpoint}`,
address,
listenPort,
online: true,
reachableAt,
name: exitNodeName
})
.returning()
.execute();
logger.info(
`Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}`
);
} finally {
await release();
} }
const exitNodeName =
config.getRawConfig().gerbil.exit_node_name ||
`Exit Node ${publicKey.slice(0, 8)}`;
// create a new exit node
[exitNode] = await db
.insert(exitNodes)
.values({
publicKey,
endpoint: `${subEndpoint}${subEndpoint != "" ? "." : ""}${config.getRawConfig().gerbil.base_endpoint}`,
address,
listenPort,
online: true,
reachableAt,
name: exitNodeName
})
.returning()
.execute();
logger.info(
`Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}`
);
} else { } else {
// update the reachable at // update the reachable at
[exitNode] = await db [exitNode] = await db

View File

@@ -114,6 +114,8 @@ export async function createRemoteExitNode(
} }
const secretHash = await hashPassword(secret); const secretHash = await hashPassword(secret);
// const address = await getNextAvailableSubnet();
const address = "100.89.140.1/24"; // FOR NOW LETS HARDCODE THESE ADDRESSES
const [existingRemoteExitNode] = await db const [existingRemoteExitNode] = await db
.select() .select()
@@ -189,106 +191,89 @@ export async function createRemoteExitNode(
); );
} }
// If this remote exit node isn't already backing an exit node in await db.transaction(async (trx) => {
// another org, we're about to create a brand new one. Reserve a if (!existingExitNode) {
// subnet for it up front so the allocation lock is held across the const [res] = await trx
// whole insert - this guarantees exit node subnets never overlap, .insert(exitNodes)
// even under concurrent creation, which matters for HA setups. .values({
let releaseSubnetLock: (() => Promise<void>) | null = null; name: remoteExitNodeId,
let newExitNodeAddress: string | null = null; address,
if (!existingExitNode) { endpoint: "",
const { value, release } = await getNextAvailableSubnet(); publicKey: "",
newExitNodeAddress = value; listenPort: 0,
releaseSubnetLock = release; online: false,
} type: "remoteExitNode"
})
.returning();
existingExitNode = res;
}
try { if (!existingRemoteExitNode) {
await db.transaction(async (trx) => { await trx.insert(remoteExitNodes).values({
if (!existingExitNode) { remoteExitNodeId: remoteExitNodeId,
const [res] = await trx secretHash,
.insert(exitNodes) dateCreated: moment().toISOString(),
.values({ exitNodeId: existingExitNode.exitNodeId
name: remoteExitNodeId, });
address: newExitNodeAddress!, } else {
endpoint: "", // update the existing remote exit node
publicKey: "", await trx
listenPort: 0, .update(remoteExitNodes)
online: false, .set({
type: "remoteExitNode"
})
.returning();
existingExitNode = res;
}
if (!existingRemoteExitNode) {
await trx.insert(remoteExitNodes).values({
remoteExitNodeId: remoteExitNodeId,
secretHash,
dateCreated: moment().toISOString(),
exitNodeId: existingExitNode.exitNodeId exitNodeId: existingExitNode.exitNodeId
}); })
} else { .where(
// update the existing remote exit node eq(
await trx remoteExitNodes.remoteExitNodeId,
.update(remoteExitNodes) existingRemoteExitNode.remoteExitNodeId
.set({ )
exitNodeId: existingExitNode.exitNodeId );
}) }
.where(
if (!existingExitNodeOrg) {
await trx.insert(exitNodeOrgs).values({
exitNodeId: existingExitNode.exitNodeId,
orgId: orgId
});
}
// calculate if the node is in any other of the orgs before we count it as an add to the billing org
if (org.billingOrgId) {
const otherBillingOrgs = await trx
.select()
.from(orgs)
.where(
and(
eq(orgs.billingOrgId, org.billingOrgId),
ne(orgs.orgId, orgId)
)
);
const billingOrgIds = otherBillingOrgs.map((o) => o.orgId);
const orgsInBillingDomainThatTheNodeIsStillIn = await trx
.select()
.from(exitNodeOrgs)
.where(
and(
eq( eq(
remoteExitNodes.remoteExitNodeId, exitNodeOrgs.exitNodeId,
existingRemoteExitNode.remoteExitNodeId existingExitNode.exitNodeId
) ),
); inArray(exitNodeOrgs.orgId, billingOrgIds)
)
);
if (orgsInBillingDomainThatTheNodeIsStillIn.length === 0) {
await usageService.add(
orgId,
LimitId.REMOTE_EXIT_NODES,
1,
trx
);
} }
}
if (!existingExitNodeOrg) { });
await trx.insert(exitNodeOrgs).values({
exitNodeId: existingExitNode.exitNodeId,
orgId: orgId
});
}
// calculate if the node is in any other of the orgs before we count it as an add to the billing org
if (org.billingOrgId) {
const otherBillingOrgs = await trx
.select()
.from(orgs)
.where(
and(
eq(orgs.billingOrgId, org.billingOrgId),
ne(orgs.orgId, orgId)
)
);
const billingOrgIds = otherBillingOrgs.map((o) => o.orgId);
const orgsInBillingDomainThatTheNodeIsStillIn = await trx
.select()
.from(exitNodeOrgs)
.where(
and(
eq(
exitNodeOrgs.exitNodeId,
existingExitNode.exitNodeId
),
inArray(exitNodeOrgs.orgId, billingOrgIds)
)
);
if (orgsInBillingDomainThatTheNodeIsStillIn.length === 0) {
await usageService.add(
orgId,
LimitId.REMOTE_EXIT_NODES,
1,
trx
);
}
}
});
} finally {
await releaseSubnetLock?.();
}
const token = generateSessionToken(); const token = generateSessionToken();
await createRemoteExitNodeSession(token, remoteExitNodeId); await createRemoteExitNodeSession(token, remoteExitNodeId);

View File

@@ -99,7 +99,7 @@ export async function applyJSONBlueprint(
source: "API" source: "API"
}); });
} catch (error) { } catch (error) {
logger.debug(`Failed to update database from config: ${error}`); logger.error(`Failed to update database from config: ${error}`);
return next( return next(
createHttpError( createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,

View File

@@ -327,14 +327,6 @@ authenticated.get(
siteResource.listAllSiteResourcesByOrg siteResource.listAllSiteResourcesByOrg
); );
authenticated.get(
"/org/:orgId/site-resource/:siteResourceId",
verifyOrgAccess,
verifySiteResourceAccess,
verifyUserHasAction(ActionsEnum.getSiteResource),
siteResource.getSiteResource
);
authenticated.get( authenticated.get(
"/site-resource/:siteResourceId", "/site-resource/:siteResourceId",
verifySiteResourceAccess, verifySiteResourceAccess,
@@ -478,12 +470,6 @@ authenticated.get(
launcher.listLauncherGroups launcher.listLauncherGroups
); );
authenticated.get(
"/org/:orgId/launcher/scale",
verifyOrgAccess,
launcher.listLauncherScale
);
authenticated.get( authenticated.get(
"/org/:orgId/launcher/resources", "/org/:orgId/launcher/resources",
verifyOrgAccess, verifyOrgAccess,
@@ -508,12 +494,6 @@ authenticated.get(
launcher.listLauncherViews launcher.listLauncherViews
); );
authenticated.post(
"/org/:orgId/launcher/invalidate-cache",
verifyOrgAccess,
launcher.invalidateLauncherCache
);
authenticated.post( authenticated.post(
"/org/:orgId/launcher/views", "/org/:orgId/launcher/views",
verifyOrgAccess, verifyOrgAccess,
@@ -526,18 +506,6 @@ authenticated.put(
launcher.updateLauncherView launcher.updateLauncherView
); );
authenticated.put(
"/org/:orgId/launcher/default-view",
verifyOrgAccess,
launcher.upsertLauncherDefaultView
);
authenticated.delete(
"/org/:orgId/launcher/default-view",
verifyOrgAccess,
launcher.deleteLauncherDefaultView
);
authenticated.delete( authenticated.delete(
"/org/:orgId/launcher/views/:viewId", "/org/:orgId/launcher/views/:viewId",
verifyOrgAccess, verifyOrgAccess,

View File

@@ -13,41 +13,37 @@ export async function createExitNode(
const [exitNodeQuery] = await db.select().from(exitNodes).limit(1); const [exitNodeQuery] = await db.select().from(exitNodes).limit(1);
let exitNode: ExitNode; let exitNode: ExitNode;
if (!exitNodeQuery) { if (!exitNodeQuery) {
const { value: address, release } = await getNextAvailableSubnet(); const address = await getNextAvailableSubnet();
try { // TODO: eventually we will want to get the next available port so that we can multiple exit nodes
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes // const listenPort = await getNextAvailablePort();
// const listenPort = await getNextAvailablePort(); const listenPort = config.getRawConfig().gerbil.start_port;
const listenPort = config.getRawConfig().gerbil.start_port; let subEndpoint = "";
let subEndpoint = ""; if (config.getRawConfig().gerbil.use_subdomain) {
if (config.getRawConfig().gerbil.use_subdomain) { subEndpoint = await getUniqueExitNodeEndpointName();
subEndpoint = await getUniqueExitNodeEndpointName();
}
const exitNodeName =
config.getRawConfig().gerbil.exit_node_name ||
`Exit Node ${publicKey.slice(0, 8)}`;
// create a new exit node
[exitNode] = await db
.insert(exitNodes)
.values({
publicKey,
endpoint: `${subEndpoint}${subEndpoint != "" ? "." : ""}${config.getRawConfig().gerbil.base_endpoint}`,
address,
online: true,
listenPort,
reachableAt,
name: exitNodeName
})
.returning()
.execute();
logger.info(
`Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}`
);
} finally {
await release();
} }
const exitNodeName =
config.getRawConfig().gerbil.exit_node_name ||
`Exit Node ${publicKey.slice(0, 8)}`;
// create a new exit node
[exitNode] = await db
.insert(exitNodes)
.values({
publicKey,
endpoint: `${subEndpoint}${subEndpoint != "" ? "." : ""}${config.getRawConfig().gerbil.base_endpoint}`,
address,
online: true,
listenPort,
reachableAt,
name: exitNodeName
})
.returning()
.execute();
logger.info(
`Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}`
);
} else { } else {
// update the existing exit node // update the existing exit node
[exitNode] = await db [exitNode] = await db

View File

@@ -79,8 +79,7 @@ export async function createLauncherView(
), ),
createdAt: created.createdAt, createdAt: created.createdAt,
updatedAt: created.updatedAt, updatedAt: created.updatedAt,
isOrgWide: created.userId == null, isOrgWide: created.userId == null
isDefault: created.isDefault
}, },
success: true, success: true,
error: false, error: false,

View File

@@ -1,104 +0,0 @@
import { response } from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { fromZodError } from "zod-validation-error";
import { z } from "zod";
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
import {
deleteAllDefaultViewOverrides,
deleteDefaultViewOverride
} from "./launcherDefaultView";
const deleteLauncherDefaultViewBodySchema = z.strictObject({
orgWide: z.boolean().optional().default(false),
all: z.boolean().optional().default(false)
});
export async function deleteLauncherDefaultView(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const orgId = req.userOrgId;
const userId = req.user!.userId;
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
const parsed = deleteLauncherDefaultViewBodySchema.safeParse(req.body);
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsed.error)
)
);
}
if (parsed.data.all) {
const canManageOrgWide = await checkUserActionPermission(
ActionsEnum.createOrgWideLauncherView,
req
);
if (!canManageOrgWide) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have permission perform this action"
)
);
}
await deleteAllDefaultViewOverrides(orgId, userId);
} else if (parsed.data.orgWide) {
const canManageOrgWide = await checkUserActionPermission(
ActionsEnum.createOrgWideLauncherView,
req
);
if (!canManageOrgWide) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have permission perform this action"
)
);
}
await deleteDefaultViewOverride({
orgId,
userId,
orgWide: true
});
} else {
await deleteDefaultViewOverride({
orgId,
userId,
orgWide: false
});
}
return response(res, {
data: null,
success: true,
error: false,
message: "Launcher default view reset successfully",
status: HttpCode.OK
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
console.error("Error resetting launcher default view:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}

View File

@@ -46,15 +46,6 @@ export async function deleteLauncherView(
); );
} }
if (existing.isDefault) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"The default view cannot be deleted from here"
)
);
}
const isPersonalView = existing.userId === userId; const isPersonalView = existing.userId === userId;
const isOrgWideView = existing.userId == null; const isOrgWideView = existing.userId == null;
const canManageOrgWide = await checkUserActionPermission( const canManageOrgWide = await checkUserActionPermission(

View File

@@ -1,6 +1,5 @@
export * from "./types"; export * from "./types";
export { listLauncherGroups } from "./listLauncherGroups"; export { listLauncherGroups } from "./listLauncherGroups";
export { listLauncherScale } from "./listLauncherScale";
export { listLauncherResources } from "./listLauncherResources"; export { listLauncherResources } from "./listLauncherResources";
export { listLauncherSites } from "./listLauncherSites"; export { listLauncherSites } from "./listLauncherSites";
export { listLauncherLabels } from "./listLauncherLabels"; export { listLauncherLabels } from "./listLauncherLabels";
@@ -8,6 +7,3 @@ export { listLauncherViews } from "./listLauncherViews";
export { createLauncherView } from "./createLauncherView"; export { createLauncherView } from "./createLauncherView";
export { updateLauncherView } from "./updateLauncherView"; export { updateLauncherView } from "./updateLauncherView";
export { deleteLauncherView } from "./deleteLauncherView"; export { deleteLauncherView } from "./deleteLauncherView";
export { upsertLauncherDefaultView } from "./upsertLauncherDefaultView";
export { deleteLauncherDefaultView } from "./deleteLauncherDefaultView";
export { invalidateLauncherCache } from "./invalidateLauncherCache";

View File

@@ -1,65 +0,0 @@
import { regionalCache as cache } from "#dynamic/lib/cache";
import { response } from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
async function invalidateLauncherCacheForUser(
orgId: string,
userId: string
): Promise<void> {
const prefixes = [
`launcherAccessibleIds:${orgId}:${userId}:`,
`launcher:groups:${orgId}:${userId}:`,
`launcher:results:${orgId}:${userId}:`,
`launcher:scale:counts:${orgId}:${userId}:`
];
const keys = (
await Promise.all(
prefixes.map((prefix) => cache.keysWithPrefix(prefix))
)
).flat();
if (keys.length > 0) {
await cache.del(keys);
}
}
export async function invalidateLauncherCache(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const orgId = req.userOrgId;
const userId = req.user!.userId;
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
await invalidateLauncherCacheForUser(orgId, userId);
return response(res, {
data: null,
success: true,
error: false,
message: "Launcher cache invalidated successfully",
status: HttpCode.OK
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
console.error("Error invalidating launcher cache:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}

View File

@@ -1,137 +0,0 @@
import { db, launcherViews } from "@server/db";
import { and, eq, isNull } from "drizzle-orm";
import moment from "moment";
import {
launcherViewConfigSchema,
type LauncherDefaultViewOverrides,
type LauncherViewConfig,
type LauncherViewRecord
} from "./types";
export function mapViewRow(
row: typeof launcherViews.$inferSelect
): LauncherViewRecord {
return {
viewId: row.viewId,
orgId: row.orgId,
userId: row.userId,
name: row.name,
config: launcherViewConfigSchema.parse(JSON.parse(row.config)),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
isOrgWide: row.userId == null,
isDefault: row.isDefault
};
}
export function extractDefaultViewOverrides(
rows: Array<typeof launcherViews.$inferSelect>
): LauncherDefaultViewOverrides {
const overrideRows = rows.filter((row) => row.isDefault);
const personalRow = overrideRows.find((row) => row.userId !== null);
const orgWideRow = overrideRows.find((row) => row.userId === null);
return {
personal: personalRow ? mapViewRow(personalRow) : null,
orgWide: orgWideRow ? mapViewRow(orgWideRow) : null
};
}
export function listVisibleLauncherViews(
rows: Array<typeof launcherViews.$inferSelect>
): LauncherViewRecord[] {
return rows.filter((row) => !row.isDefault).map(mapViewRow);
}
export async function findDefaultViewOverride(
orgId: string,
orgWide: boolean,
userId: string
) {
const [existing] = await db
.select()
.from(launcherViews)
.where(
and(
eq(launcherViews.orgId, orgId),
eq(launcherViews.isDefault, true),
orgWide
? isNull(launcherViews.userId)
: eq(launcherViews.userId, userId)
)
)
.limit(1);
return existing ?? null;
}
export async function upsertDefaultViewOverride({
orgId,
userId,
orgWide,
config
}: {
orgId: string;
userId: string;
orgWide: boolean;
config: LauncherViewConfig;
}) {
const now = moment().toISOString();
const existing = await findDefaultViewOverride(orgId, orgWide, userId);
if (existing) {
const [updated] = await db
.update(launcherViews)
.set({
config: JSON.stringify(config),
updatedAt: now
})
.where(eq(launcherViews.viewId, existing.viewId))
.returning();
return mapViewRow(updated);
}
const [created] = await db
.insert(launcherViews)
.values({
orgId,
userId: orgWide ? null : userId,
name: "",
isDefault: true,
config: JSON.stringify(config),
createdAt: now,
updatedAt: now
})
.returning();
return mapViewRow(created);
}
export async function deleteDefaultViewOverride({
orgId,
userId,
orgWide
}: {
orgId: string;
userId: string;
orgWide: boolean;
}) {
const existing = await findDefaultViewOverride(orgId, orgWide, userId);
if (!existing) {
return;
}
await db
.delete(launcherViews)
.where(eq(launcherViews.viewId, existing.viewId));
}
export async function deleteAllDefaultViewOverrides(
orgId: string,
userId: string
) {
await deleteDefaultViewOverride({ orgId, userId, orgWide: false });
await deleteDefaultViewOverride({ orgId, userId, orgWide: true });
}

View File

@@ -1,4 +1,3 @@
import { createHash } from "node:crypto";
import { db } from "@server/db"; import { db } from "@server/db";
import { import {
exitNodes, exitNodes,
@@ -32,15 +31,13 @@ import {
isNull, isNull,
like, like,
or, or,
sql, sql
type SQL
} from "drizzle-orm"; } from "drizzle-orm";
import { import {
formatPublicResourceAccess, formatPublicResourceAccess,
formatSiteResourceAccess formatSiteResourceAccess
} from "./formatLauncherAccess"; } from "./formatLauncherAccess";
import { import {
LAUNCHER_FLAT_GROUP_KEY,
LAUNCHER_NO_SITE_GROUP_KEY, LAUNCHER_NO_SITE_GROUP_KEY,
LAUNCHER_UNLABELED_GROUP_KEY, LAUNCHER_UNLABELED_GROUP_KEY,
type LauncherFilterListQuery, type LauncherFilterListQuery,
@@ -62,68 +59,6 @@ export type AccessibleIds = {
}; };
const LAUNCHER_ACCESSIBLE_IDS_TTL_SEC = 60; const LAUNCHER_ACCESSIBLE_IDS_TTL_SEC = 60;
const LAUNCHER_RESOURCES_RESULT_TTL_SEC = 60;
const LAUNCHER_GROUPS_RESULT_TTL_SEC = 60;
type LauncherResourcesCacheEntry = {
items: LauncherResource[];
total: number;
};
type LauncherGroupsCacheEntry = {
groups: LauncherGroup[];
total: number;
};
function launcherListQueryHash(
userRoleIds: number[],
query: LauncherListQuery,
extra?: Record<string, string>
) {
const payload = JSON.stringify({
roles: [...userRoleIds].sort((a, b) => a - b),
query: query.query,
groupBy: query.groupBy,
siteIds: query.siteIds ?? "",
labelIds: query.labelIds ?? "",
sort_by: query.sort_by,
order: query.order,
...extra
});
return createHash("sha256").update(payload).digest("hex").slice(0, 16);
}
function launcherResourcesQueryHash(
userRoleIds: number[],
query: LauncherListQuery & { groupKey: string }
) {
return launcherListQueryHash(userRoleIds, query, {
groupKey: query.groupKey
});
}
function launcherGroupsQueryHash(
userRoleIds: number[],
query: LauncherListQuery
) {
return launcherListQueryHash(userRoleIds, query);
}
function launcherResourcesCacheKey(
orgId: string,
userId: string,
queryHash: string
) {
return `launcher:results:${orgId}:${userId}:${queryHash}`;
}
function launcherGroupsCacheKey(
orgId: string,
userId: string,
queryHash: string
) {
return `launcher:groups:${orgId}:${userId}:${queryHash}`;
}
function launcherAccessibleIdsCacheKey( function launcherAccessibleIdsCacheKey(
orgId: string, orgId: string,
@@ -259,21 +194,6 @@ function searchPattern(query: string) {
return `%${query.trim()}%`; return `%${query.trim()}%`;
} }
function combineOrConditions(
...conditions: (SQL | undefined)[]
): SQL | undefined {
const parts = conditions.filter(
(condition): condition is SQL => !!condition
);
if (parts.length === 0) {
return undefined;
}
if (parts.length === 1) {
return parts[0];
}
return or(...parts);
}
function buildSearchConditionForPublic( function buildSearchConditionForPublic(
query: string, query: string,
labelsFeatureEnabled: boolean labelsFeatureEnabled: boolean
@@ -285,17 +205,7 @@ function buildSearchConditionForPublic(
const queryList = [ const queryList = [
like(sql`LOWER(${resources.name})`, pattern), like(sql`LOWER(${resources.name})`, pattern),
like(sql`LOWER(${resources.fullDomain})`, pattern), like(sql`LOWER(${resources.fullDomain})`, pattern),
like(sql`LOWER(cast(${resources.proxyPort} as text))`, pattern), like(sql`LOWER(cast(${resources.proxyPort} as text))`, pattern)
inArray(
resources.resourceId,
db
.select({ id: resources.resourceId })
.from(resources)
.leftJoin(targets, eq(targets.resourceId, resources.resourceId))
.leftJoin(sites, eq(targets.siteId, sites.siteId))
.leftJoin(exitNodes, eq(sites.exitNodeId, exitNodes.exitNodeId))
.where(like(sql`LOWER(${exitNodes.endpoint})`, pattern))
)
]; ];
if (labelsFeatureEnabled) { if (labelsFeatureEnabled) {
@@ -328,11 +238,6 @@ function buildSearchConditionForSiteResource(
const queryList = [ const queryList = [
like(sql`LOWER(${siteResources.name})`, pattern), like(sql`LOWER(${siteResources.name})`, pattern),
like(sql`LOWER(${siteResources.destination})`, pattern), like(sql`LOWER(${siteResources.destination})`, pattern),
like(
sql`LOWER(cast(${siteResources.destinationPort} as text))`,
pattern
),
like(sql`LOWER(${siteResources.scheme})`, pattern),
like(sql`LOWER(${siteResources.alias})`, pattern), like(sql`LOWER(${siteResources.alias})`, pattern),
like(sql`LOWER(${siteResources.fullDomain})`, pattern), like(sql`LOWER(${siteResources.fullDomain})`, pattern),
like(sql`LOWER(${siteResources.aliasAddress})`, pattern) like(sql`LOWER(${siteResources.aliasAddress})`, pattern)
@@ -357,79 +262,6 @@ function buildSearchConditionForSiteResource(
return or(...queryList); return or(...queryList);
} }
async function filterPublicResourceIdsByTextSearch(
orgId: string,
resourceIds: number[],
query: string,
labelsFeatureEnabled: boolean
): Promise<number[]> {
if (!query.trim() || resourceIds.length === 0) {
return resourceIds;
}
const textMatch = combineOrConditions(
buildSearchConditionForPublic(query, labelsFeatureEnabled),
buildSiteNameSearchCondition(query)
);
if (!textMatch) {
return resourceIds;
}
const rows = await db
.selectDistinct({ resourceId: resources.resourceId })
.from(resources)
.leftJoin(targets, eq(targets.resourceId, resources.resourceId))
.leftJoin(sites, eq(targets.siteId, sites.siteId))
.where(
and(
inArray(resources.resourceId, resourceIds),
eq(resources.orgId, orgId),
eq(resources.enabled, true),
textMatch
)
);
return rows.map((row) => row.resourceId);
}
async function filterSiteResourceIdsByTextSearch(
orgId: string,
siteResourceIds: number[],
query: string,
labelsFeatureEnabled: boolean
): Promise<number[]> {
if (!query.trim() || siteResourceIds.length === 0) {
return siteResourceIds;
}
const textMatch = combineOrConditions(
buildSearchConditionForSiteResource(query, labelsFeatureEnabled),
buildSiteNameSearchCondition(query)
);
if (!textMatch) {
return siteResourceIds;
}
const rows = await db
.selectDistinct({ siteResourceId: siteResources.siteResourceId })
.from(siteResources)
.leftJoin(
siteNetworks,
eq(siteResources.networkId, siteNetworks.networkId)
)
.leftJoin(sites, eq(siteNetworks.siteId, sites.siteId))
.where(
and(
inArray(siteResources.siteResourceId, siteResourceIds),
eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true),
textMatch
)
);
return rows.map((row) => row.siteResourceId);
}
async function labelsEnabled(orgId: string): Promise<boolean> { async function labelsEnabled(orgId: string): Promise<boolean> {
return isLicensedOrSubscribed(orgId, tierMatrix.labels); return isLicensedOrSubscribed(orgId, tierMatrix.labels);
} }
@@ -756,8 +588,9 @@ async function listSiteGroups(
}); });
const total = groups.length; const total = groups.length;
const offset = (query.page - 1) * query.pageSize;
return { return {
groups, groups: groups.slice(offset, offset + query.pageSize),
total total
}; };
} }
@@ -955,53 +788,26 @@ async function listLabelGroups(
}); });
const total = groups.length; const total = groups.length;
const offset = (query.page - 1) * query.pageSize;
return { return {
groups, groups: groups.slice(offset, offset + query.pageSize),
total total
}; };
} }
async function listLauncherGroupsForUserUncached(
orgId: string,
userId: string,
userRoleIds: number[],
query: LauncherListQuery
): Promise<LauncherGroupsCacheEntry> {
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
if (query.groupBy === "label") {
return listLabelGroups(orgId, accessible, query);
}
return listSiteGroups(orgId, accessible, query);
}
export async function listLauncherGroupsForUser( export async function listLauncherGroupsForUser(
orgId: string, orgId: string,
userId: string, userId: string,
userRoleIds: number[], userRoleIds: number[],
query: LauncherListQuery query: LauncherListQuery
): Promise<{ groups: LauncherGroup[]; total: number }> { ): Promise<{ groups: LauncherGroup[]; total: number }> {
const queryHash = launcherGroupsQueryHash(userRoleIds, query); const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
const cacheKey = launcherGroupsCacheKey(orgId, userId, queryHash);
const cached = await cache.get<LauncherGroupsCacheEntry>(cacheKey);
let result = cached; if (query.groupBy === "label") {
if (!result) { return listLabelGroups(orgId, accessible, query);
result = await listLauncherGroupsForUserUncached(
orgId,
userId,
userRoleIds,
query
);
await cache.set(cacheKey, result, LAUNCHER_GROUPS_RESULT_TTL_SEC);
} }
const offset = (query.page - 1) * query.pageSize; return listSiteGroups(orgId, accessible, query);
return {
groups: result.groups.slice(offset, offset + query.pageSize),
total: result.total
};
} }
async function mapPublicResources( async function mapPublicResources(
@@ -1212,6 +1018,26 @@ function filterResourcesByLabel(
); );
} }
function filterResourcesBySearch(
items: LauncherResource[],
query: string
): LauncherResource[] {
if (!query.trim()) {
return items;
}
const pattern = query.trim().toLowerCase();
return items.filter(
(item) =>
item.name.toLowerCase().includes(pattern) ||
item.accessDisplay.toLowerCase().includes(pattern) ||
item.accessCopyValue.toLowerCase().includes(pattern) ||
item.labels.some((label) =>
label.name.toLowerCase().includes(pattern)
) ||
item.site?.name.toLowerCase().includes(pattern)
);
}
function sortLauncherResources( function sortLauncherResources(
items: LauncherResource[], items: LauncherResource[],
order: "asc" | "desc" order: "asc" | "desc"
@@ -1224,12 +1050,12 @@ function sortLauncherResources(
}); });
} }
async function listLauncherResourcesForUserUncached( export async function listLauncherResourcesForUser(
orgId: string, orgId: string,
userId: string, userId: string,
userRoleIds: number[], userRoleIds: number[],
query: LauncherListQuery & { groupKey: string } query: LauncherListQuery & { groupKey: string }
): Promise<LauncherResourcesCacheEntry> { ): Promise<{ resources: LauncherResource[]; total: number }> {
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds); const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
const siteFilterIds = parseIdListParam(query.siteIds); const siteFilterIds = parseIdListParam(query.siteIds);
@@ -1302,27 +1128,6 @@ async function listLauncherResourcesForUserUncached(
} }
} }
const labelsFeatureEnabled = await labelsEnabled(orgId);
if (query.query.trim()) {
if (filteredResourceIds.length > 0) {
filteredResourceIds = await filterPublicResourceIdsByTextSearch(
orgId,
filteredResourceIds,
query.query,
labelsFeatureEnabled
);
}
if (filteredSiteResourceIds.length > 0) {
filteredSiteResourceIds = await filterSiteResourceIdsByTextSearch(
orgId,
filteredSiteResourceIds,
query.query,
labelsFeatureEnabled
);
}
}
const labelMaps = await fetchLabelsForResources( const labelMaps = await fetchLabelsForResources(
orgId, orgId,
filteredResourceIds, filteredResourceIds,
@@ -1354,48 +1159,21 @@ async function listLauncherResourcesForUserUncached(
]); ]);
let items = [...publicItems, ...siteItems]; let items = [...publicItems, ...siteItems];
items = filterResourcesBySearch(items, query.query);
if (query.groupKey !== LAUNCHER_FLAT_GROUP_KEY) { if (query.groupBy === "label") {
if (query.groupBy === "label") { items = filterResourcesByLabel(items, query.groupKey);
items = filterResourcesByLabel(items, query.groupKey); } else if (query.groupBy === "site") {
} else if (query.groupBy === "site") { items = filterResourcesBySite(items, query.groupKey);
items = filterResourcesBySite(items, query.groupKey);
}
} }
items = sortLauncherResources(items, query.order); items = sortLauncherResources(items, query.order);
return { const total = items.length;
items,
total: items.length
};
}
export async function listLauncherResourcesForUser(
orgId: string,
userId: string,
userRoleIds: number[],
query: LauncherListQuery & { groupKey: string }
): Promise<{ resources: LauncherResource[]; total: number }> {
const queryHash = launcherResourcesQueryHash(userRoleIds, query);
const cacheKey = launcherResourcesCacheKey(orgId, userId, queryHash);
const cached = await cache.get<LauncherResourcesCacheEntry>(cacheKey);
let result = cached;
if (!result) {
result = await listLauncherResourcesForUserUncached(
orgId,
userId,
userRoleIds,
query
);
await cache.set(cacheKey, result, LAUNCHER_RESOURCES_RESULT_TTL_SEC);
}
const offset = (query.page - 1) * query.pageSize; const offset = (query.page - 1) * query.pageSize;
return { return {
resources: result.items.slice(offset, offset + query.pageSize), resources: items.slice(offset, offset + query.pageSize),
total: result.total total
}; };
} }

View File

@@ -1,133 +0,0 @@
import { regionalCache as cache } from "#dynamic/lib/cache";
import {
listLauncherGroupsForUser,
resolveAccessibleIds
} from "./launcherResourceAccess";
import {
parseIdListParam,
type LauncherScaleInfo,
type LauncherScaleQuery
} from "./types";
export const LAUNCHER_FULL_MAX_RESOURCES = 2000;
export const LAUNCHER_FULL_MAX_SITE_GROUPS = 200;
export const LAUNCHER_FULL_MAX_LABEL_GROUPS = 100;
export const LAUNCHER_FILTERED_SITE_GROUPING_MAX = 20;
const LAUNCHER_SCALE_COUNTS_TTL_SEC = 60;
type LauncherScaleCountsCacheEntry = {
resourceCount: number;
siteGroupCount: number;
labelGroupCount: number;
mode: LauncherScaleInfo["mode"];
};
function launcherScaleCountsCacheKey(
orgId: string,
userId: string,
roleIds: number[]
) {
const rolesKey = [...roleIds].sort((a, b) => a - b).join(",");
return `launcher:scale:counts:${orgId}:${userId}:${rolesKey}`;
}
function buildScaleCapabilities(
counts: LauncherScaleCountsCacheEntry,
query: LauncherScaleQuery
): LauncherScaleInfo["capabilities"] {
const siteFilterIds = parseIdListParam(query.siteIds);
const labelFilterIds = parseIdListParam(query.labelIds);
return {
allowSiteGrouping:
counts.siteGroupCount <= LAUNCHER_FULL_MAX_SITE_GROUPS ||
(siteFilterIds.length > 0 &&
siteFilterIds.length <= LAUNCHER_FILTERED_SITE_GROUPING_MAX),
allowLabelGrouping:
counts.labelGroupCount <= LAUNCHER_FULL_MAX_LABEL_GROUPS ||
(labelFilterIds.length > 0 &&
labelFilterIds.length <= LAUNCHER_FILTERED_SITE_GROUPING_MAX),
requireSearchOrFilter:
counts.mode === "compact" &&
counts.resourceCount > LAUNCHER_FULL_MAX_RESOURCES
};
}
async function getLauncherScaleCountsForUser(
orgId: string,
userId: string,
userRoleIds: number[]
): Promise<LauncherScaleCountsCacheEntry> {
const cacheKey = launcherScaleCountsCacheKey(orgId, userId, userRoleIds);
const cached = await cache.get<LauncherScaleCountsCacheEntry>(cacheKey);
if (cached) {
return cached;
}
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
const resourceCount =
accessible.resourceIds.length + accessible.siteResourceIds.length;
const baselineQuery = {
query: "",
groupBy: "site" as const,
siteIds: undefined,
labelIds: undefined,
sort_by: "name" as const,
order: "asc" as const,
page: 1,
pageSize: 1
};
const [{ total: siteGroupCount }, { total: labelGroupCount }] =
await Promise.all([
listLauncherGroupsForUser(
orgId,
userId,
userRoleIds,
baselineQuery
),
listLauncherGroupsForUser(orgId, userId, userRoleIds, {
...baselineQuery,
groupBy: "label"
})
]);
const mode =
resourceCount <= LAUNCHER_FULL_MAX_RESOURCES &&
siteGroupCount <= LAUNCHER_FULL_MAX_SITE_GROUPS
? "full"
: "compact";
const result: LauncherScaleCountsCacheEntry = {
resourceCount,
siteGroupCount,
labelGroupCount,
mode
};
await cache.set(cacheKey, result, LAUNCHER_SCALE_COUNTS_TTL_SEC);
return result;
}
export async function getLauncherScaleForUser(
orgId: string,
userId: string,
userRoleIds: number[],
query: LauncherScaleQuery
): Promise<LauncherScaleInfo> {
const counts = await getLauncherScaleCountsForUser(
orgId,
userId,
userRoleIds
);
return {
mode: counts.mode,
resourceCount: counts.resourceCount,
siteGroupCount: counts.siteGroupCount,
labelGroupCount: counts.labelGroupCount,
capabilities: buildScaleCapabilities(counts, query)
};
}

View File

@@ -1,60 +0,0 @@
import { response } from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { fromZodError } from "zod-validation-error";
import { getLauncherScaleForUser } from "./launcherScale";
import { launcherScaleQuerySchema } from "./types";
export async function listLauncherScale(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const orgId = req.userOrgId;
const userId = req.user!.userId;
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
const parsed = launcherScaleQuerySchema.safeParse(req.query);
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsed.error)
)
);
}
const scale = await getLauncherScaleForUser(
orgId,
userId,
req.userOrgRoleIds ?? [],
parsed.data
);
return response(res, {
data: { scale },
success: true,
error: false,
message: "Launcher scale retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
console.error("Error listing launcher scale:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}

View File

@@ -4,10 +4,22 @@ import HttpCode from "@server/types/HttpCode";
import { and, eq, isNull, or } from "drizzle-orm"; import { and, eq, isNull, 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 { import { launcherViewConfigSchema, type LauncherViewRecord } from "./types";
extractDefaultViewOverrides,
listVisibleLauncherViews function mapViewRow(
} from "./launcherDefaultView"; row: typeof launcherViews.$inferSelect
): LauncherViewRecord {
return {
viewId: row.viewId,
orgId: row.orgId,
userId: row.userId,
name: row.name,
config: launcherViewConfigSchema.parse(JSON.parse(row.config)),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
isOrgWide: row.userId == null
};
}
export async function listLauncherViews( export async function listLauncherViews(
req: Request, req: Request,
@@ -39,8 +51,7 @@ export async function listLauncherViews(
return response(res, { return response(res, {
data: { data: {
views: listVisibleLauncherViews(rows), views: rows.map(mapViewRow)
defaultViewOverrides: extractDefaultViewOverrides(rows)
}, },
success: true, success: true,
error: false, error: false,

View File

@@ -2,10 +2,9 @@ import { z } from "zod";
export const LAUNCHER_UNLABELED_GROUP_KEY = "unlabeled"; export const LAUNCHER_UNLABELED_GROUP_KEY = "unlabeled";
export const LAUNCHER_NO_SITE_GROUP_KEY = "no-site"; export const LAUNCHER_NO_SITE_GROUP_KEY = "no-site";
export const LAUNCHER_FLAT_GROUP_KEY = "__all__";
export const launcherViewConfigSchema = z.object({ export const launcherViewConfigSchema = z.object({
groupBy: z.enum(["site", "label", "none"]).default("site"), groupBy: z.enum(["site", "label"]).default("site"),
layout: z.enum(["grid", "list"]).default("grid"), layout: z.enum(["grid", "list"]).default("grid"),
sortBy: z.literal("name").default("name"), sortBy: z.literal("name").default("name"),
order: z.enum(["asc", "desc"]).default("asc"), order: z.enum(["asc", "desc"]).default("asc"),
@@ -89,31 +88,10 @@ export type LauncherViewRecord = {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
isOrgWide: boolean; isOrgWide: boolean;
isDefault: boolean;
}; };
export type LauncherDefaultViewOverrides = {
personal: LauncherViewRecord | null;
orgWide: LauncherViewRecord | null;
};
export function getEffectiveDefaultLauncherConfig(
overrides: LauncherDefaultViewOverrides
): LauncherViewConfig {
if (overrides.personal) {
return overrides.personal.config;
}
if (overrides.orgWide) {
return overrides.orgWide.config;
}
return defaultLauncherViewConfig;
}
export type ListLauncherViewsResponse = { export type ListLauncherViewsResponse = {
views: LauncherViewRecord[]; views: LauncherViewRecord[];
defaultViewOverrides: LauncherDefaultViewOverrides;
}; };
export const launcherFilterListQuerySchema = z.strictObject({ export const launcherFilterListQuerySchema = z.strictObject({
@@ -122,8 +100,8 @@ export const launcherFilterListQuerySchema = z.strictObject({
.int() .int()
.positive() .positive()
.optional() .optional()
.catch(20) .catch(500)
.default(20), .default(500),
page: z.coerce.number().int().min(1).optional().catch(1).default(1), page: z.coerce.number().int().min(1).optional().catch(1).default(1),
query: z.string().optional().default("") query: z.string().optional().default("")
}); });
@@ -160,7 +138,7 @@ export const launcherListQuerySchema = z.strictObject({
.default(20), .default(20),
page: z.coerce.number().int().min(1).optional().catch(1).default(1), page: z.coerce.number().int().min(1).optional().catch(1).default(1),
query: z.string().optional().default(""), query: z.string().optional().default(""),
groupBy: z.enum(["site", "label", "none"]).optional().default("site"), groupBy: z.enum(["site", "label"]).optional().default("site"),
groupKey: z.string().optional(), groupKey: z.string().optional(),
siteIds: z.string().optional(), siteIds: z.string().optional(),
labelIds: z.string().optional(), labelIds: z.string().optional(),
@@ -185,32 +163,3 @@ export const DEFAULT_LAUNCHER_VIEW_ID = "default" as const;
export type LauncherViewSelection = export type LauncherViewSelection =
| { type: "default" } | { type: "default" }
| { type: "saved"; viewId: number }; | { type: "saved"; viewId: number };
export type LauncherScaleCapabilities = {
allowSiteGrouping: boolean;
allowLabelGrouping: boolean;
requireSearchOrFilter: boolean;
};
export type LauncherScaleInfo = {
mode: "full" | "compact";
resourceCount: number;
siteGroupCount: number;
labelGroupCount: number;
capabilities: LauncherScaleCapabilities;
};
export type ListLauncherScaleResponse = {
scale: LauncherScaleInfo;
};
export const launcherScaleQuerySchema = z.strictObject({
query: z.string().optional().default(""),
groupBy: z.enum(["site", "label", "none"]).optional().default("site"),
siteIds: z.string().optional(),
labelIds: z.string().optional(),
sort_by: z.literal("name").optional().default("name"),
order: z.enum(["asc", "desc"]).optional().default("asc")
});
export type LauncherScaleQuery = z.infer<typeof launcherScaleQuerySchema>;

View File

@@ -66,15 +66,6 @@ export async function updateLauncherView(
); );
} }
if (existing.isDefault) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Use the default view save endpoint for this view"
)
);
}
const isPersonalView = existing.userId === userId; const isPersonalView = existing.userId === userId;
const isOrgWideView = existing.userId == null; const isOrgWideView = existing.userId == null;
const canManageOrgWide = await checkUserActionPermission( const canManageOrgWide = await checkUserActionPermission(
@@ -144,8 +135,7 @@ export async function updateLauncherView(
), ),
createdAt: updated.createdAt, createdAt: updated.createdAt,
updatedAt: updated.updatedAt, updatedAt: updated.updatedAt,
isOrgWide: updated.userId == null, isOrgWide: updated.userId == null
isDefault: updated.isDefault
}, },
success: true, success: true,
error: false, error: false,

View File

@@ -1,82 +0,0 @@
import { response } from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { fromZodError } from "zod-validation-error";
import { z } from "zod";
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
import { upsertDefaultViewOverride } from "./launcherDefaultView";
import { launcherViewConfigSchema } from "./types";
const upsertLauncherDefaultViewBodySchema = z.strictObject({
config: launcherViewConfigSchema,
orgWide: z.boolean().optional().default(false)
});
export async function upsertLauncherDefaultView(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const orgId = req.userOrgId;
const userId = req.user!.userId;
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
const parsed = upsertLauncherDefaultViewBodySchema.safeParse(req.body);
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsed.error)
)
);
}
if (parsed.data.orgWide) {
const canManageOrgWide = await checkUserActionPermission(
ActionsEnum.createOrgWideLauncherView,
req
);
if (!canManageOrgWide) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have permission perform this action"
)
);
}
}
const view = await upsertDefaultViewOverride({
orgId,
userId,
orgWide: parsed.data.orgWide,
config: parsed.data.config
});
return response(res, {
data: view,
success: true,
error: false,
message: "Launcher default view saved successfully",
status: HttpCode.OK
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
console.error("Error saving launcher default view:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}

View File

@@ -52,13 +52,13 @@ export async function buildClientConfigurationForNewtClient(
clientsRes clientsRes
.filter((client) => { .filter((client) => {
if (!client.clients.pubKey) { if (!client.clients.pubKey) {
logger.debug( logger.warn(
`Client ${client.clients.clientId} has no public key, skipping` `Client ${client.clients.clientId} has no public key, skipping`
); );
return false; return false;
} }
if (!client.clients.subnet) { if (!client.clients.subnet) {
logger.debug( logger.warn(
`Client ${client.clients.clientId} has no subnet, skipping` `Client ${client.clients.clientId} has no subnet, skipping`
); );
return false; return false;

View File

@@ -50,7 +50,7 @@ export const handleApplyBlueprintMessage: MessageHandler = async (context) => {
source: "NEWT" source: "NEWT"
}); });
} catch (error) { } catch (error) {
logger.debug(`Failed to update database from config: ${error}`); logger.error(`Failed to update database from config: ${error}`);
return { return {
message: { message: {
type: "newt/blueprint/results", type: "newt/blueprint/results",

View File

@@ -19,7 +19,7 @@ export const handleNewtDisconnectingMessage: MessageHandler = async (
} }
if (!newt.siteId) { if (!newt.siteId) {
logger.warn("Newt has no site ID!"); logger.warn("Newt has no client ID!");
return; return;
} }
@@ -34,12 +34,6 @@ export const handleNewtDisconnectingMessage: MessageHandler = async (
.where(eq(sites.siteId, newt.siteId!)) .where(eq(sites.siteId, newt.siteId!))
.returning(); .returning();
if (!site) {
throw new Error(
`Could not find site ${newt.siteId} to update disconnection from disconnect message`
);
}
await fireSiteOfflineAlert( await fireSiteOfflineAlert(
site.orgId, site.orgId,
site.siteId, site.siteId,
@@ -49,6 +43,6 @@ export const handleNewtDisconnectingMessage: MessageHandler = async (
); );
}); });
} catch (error) { } catch (error) {
logger.error("Error handling site disconnecting message", error); logger.error("Error handling disconnecting message", { error });
} }
}; };

View File

@@ -3,7 +3,6 @@ import { sites, clients, olms } from "@server/db";
import { and, eq, inArray } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import { fireSiteOnlineAlert } from "@server/lib/alerts"; import { fireSiteOnlineAlert } from "@server/lib/alerts";
import { withRetry } from "@server/lib/dbRetry";
/** /**
* Ping Accumulator * Ping Accumulator
@@ -23,6 +22,8 @@ import { withRetry } from "@server/lib/dbRetry";
*/ */
const FLUSH_INTERVAL_MS = 10_000; // Flush every 10 seconds const FLUSH_INTERVAL_MS = 10_000; // Flush every 10 seconds
const MAX_RETRIES = 5;
const BASE_DELAY_MS = 50;
// ── Site (newt) pings ────────────────────────────────────────────────── // ── Site (newt) pings ──────────────────────────────────────────────────
// Map of siteId -> latest ping timestamp (unix seconds) // Map of siteId -> latest ping timestamp (unix seconds)
@@ -265,7 +266,85 @@ export async function flushPingsToDb(): Promise<void> {
} }
// ── Retry / Error Helpers ────────────────────────────────────────────── // ── Retry / Error Helpers ──────────────────────────────────────────────
// See @server/lib/dbRetry for the shared withRetry/isTransientError helpers.
/**
* Simple retry wrapper with exponential backoff for transient errors
* (deadlocks, connection timeouts, unexpected disconnects).
*
* PostgreSQL deadlocks (40P01) are always safe to retry: the database
* guarantees exactly one winner per deadlock pair, so the loser just needs
* to try again. MAX_RETRIES is intentionally higher than typical connection
* retry budgets to give deadlock victims enough chances to succeed.
*/
async function withRetry<T>(
operation: () => Promise<T>,
context: string
): Promise<T> {
let attempt = 0;
while (true) {
try {
return await operation();
} catch (error: any) {
if (isTransientError(error) && attempt < MAX_RETRIES) {
attempt++;
const baseDelay = Math.pow(2, attempt - 1) * BASE_DELAY_MS;
const jitter = Math.random() * baseDelay;
const delay = baseDelay + jitter;
logger.warn(
`Transient DB error in ${context}, retrying attempt ${attempt}/${MAX_RETRIES} after ${delay.toFixed(0)}ms`,
{ code: error?.code ?? error?.cause?.code }
);
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
throw error;
}
}
}
/**
* Detect transient errors that are safe to retry.
*/
function isTransientError(error: any): boolean {
if (!error) return false;
const message = (error.message || "").toLowerCase();
const causeMessage = (error.cause?.message || "").toLowerCase();
const code = error.code || error.cause?.code || "";
// Connection timeout / terminated
if (
message.includes("connection timeout") ||
message.includes("connection terminated") ||
message.includes("timeout exceeded when trying to connect") ||
causeMessage.includes("connection terminated unexpectedly") ||
causeMessage.includes("connection timeout")
) {
return true;
}
// PostgreSQL deadlock detected - always safe to retry (one winner guaranteed)
if (code === "40P01" || message.includes("deadlock")) {
return true;
}
// PostgreSQL serialization failure
if (code === "40001") {
return true;
}
// ECONNRESET, ECONNREFUSED, EPIPE, ETIMEDOUT
if (
code === "ECONNRESET" ||
code === "ECONNREFUSED" ||
code === "EPIPE" ||
code === "ETIMEDOUT"
) {
return true;
}
return false;
}
// ── Lifecycle ────────────────────────────────────────────────────────── // ── Lifecycle ──────────────────────────────────────────────────────────

View File

@@ -161,7 +161,7 @@ export async function buildSiteConfigurationForOlmClient(
} }
if (!site.subnet) { if (!site.subnet) {
logger.debug(`Site ${site.siteId} has no subnet, skipping`); logger.warn(`Site ${site.siteId} has no subnet, skipping`);
continue; continue;
} }

View File

@@ -6,9 +6,7 @@ import logger from "@server/logger";
/** /**
* Handles disconnecting messages from clients to show disconnected in the ui * Handles disconnecting messages from clients to show disconnected in the ui
*/ */
export const handleOlmDisconnectingMessage: MessageHandler = async ( export const handleOlmDisconnectingMessage: MessageHandler = async (context) => {
context
) => {
const { message, client: c, sendToClient } = context; const { message, client: c, sendToClient } = context;
const olm = c as Olm; const olm = c as Olm;
@@ -31,6 +29,6 @@ export const handleOlmDisconnectingMessage: MessageHandler = async (
}) })
.where(eq(clients.clientId, olm.clientId)); .where(eq(clients.clientId, olm.clientId));
} catch (error) { } catch (error) {
logger.error("Error handling client disconnecting message", error); logger.error("Error handling disconnecting message", { error });
} }
}; };

View File

@@ -616,8 +616,8 @@ export async function listResources(
and( and(
inArray(resources.mode, browserGatewayModes), inArray(resources.mode, browserGatewayModes),
or( or(
effectiveSso, eq(effectiveSso, true),
effectiveWhitelist, eq(effectiveWhitelist, true),
not(isNull(effectiveHeaderAuthId)), not(isNull(effectiveHeaderAuthId)),
not(isNull(effectivePincodeId)), not(isNull(effectivePincodeId)),
not(isNull(effectivePasswordId)) not(isNull(effectivePasswordId))
@@ -629,8 +629,8 @@ export async function listResources(
conditions.push( conditions.push(
and( and(
inArray(resources.mode, browserGatewayModes), inArray(resources.mode, browserGatewayModes),
not(effectiveSso), not(eq(effectiveSso, true)),
not(effectiveWhitelist), not(eq(effectiveWhitelist, true)),
isNull(effectiveHeaderAuthId), isNull(effectiveHeaderAuthId),
isNull(effectivePincodeId), isNull(effectivePincodeId),
isNull(effectivePasswordId) isNull(effectivePasswordId)

View File

@@ -268,11 +268,7 @@ export async function createSite(
let newSite: Site | undefined; let newSite: Site | undefined;
try { try {
if (type === "wireguard" && subnet && exitNodeId) { if (subnet && exitNodeId) {
// Only wireguard sites actually persist the provided subnet/exitNodeId.
// Newt sites have their subnet/exit node chosen (under a lock) when the
// newt connects, so validating them here is both unnecessary and racy,
// since pickSiteDefaults does not lock the subnet it suggests.
//make sure the subnet is in the range of the exit node if provided //make sure the subnet is in the range of the exit node if provided
const [exitNode] = await db const [exitNode] = await db
.select() .select()

View File

@@ -82,11 +82,9 @@ export default async function OrgPage(props: OrgPageProps) {
orgId={orgId} orgId={orgId}
isAdmin={isAdminOrOwner} isAdmin={isAdminOrOwner}
views={launcherData.views} views={launcherData.views}
defaultViewOverrides={launcherData.defaultViewOverrides}
activeViewId={launcherData.activeViewId} activeViewId={launcherData.activeViewId}
config={launcherData.config} config={launcherData.config}
savedConfig={launcherData.savedConfig} savedConfig={launcherData.savedConfig}
scale={launcherData.scale}
groups={launcherData.groups} groups={launcherData.groups}
groupsPagination={launcherData.groupsPagination} groupsPagination={launcherData.groupsPagination}
/> />

View File

@@ -290,13 +290,7 @@ export default function BillingPage() {
setHasSubscription( setHasSubscription(
tierSub.subscription.status === "active" tierSub.subscription.status === "active"
); );
// expiresAt is only meaningful while the trial hasn't setIsTrial(tierSub.subscription.expiresAt != null);
// actually run out yet; a stale row with a past
// expiresAt should no longer be treated as a live trial
const expiresAt = tierSub.subscription.expiresAt;
setIsTrial(
expiresAt != null && expiresAt * 1000 > Date.now()
);
} }
// Find license subscription // Find license subscription

View File

@@ -41,7 +41,7 @@ import {
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useActionState, useState } from "react"; import { useActionState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
@@ -137,21 +137,11 @@ function ProxyResourceHttpForm({
}); });
const [, formAction, saveLoading] = useActionState(onSubmit, null); const [, formAction, saveLoading] = useActionState(onSubmit, null);
const [headersValid, setHeadersValid] = useState(true);
async function onSubmit() { async function onSubmit() {
const isValid = await form.trigger(); const isValid = await form.trigger();
if (!isValid) return; if (!isValid) return;
if (!headersValid) {
toast({
variant: "destructive",
title: t("settingsErrorUpdate"),
description: t("headersValidationError")
});
return;
}
const data = form.getValues(); const data = form.getValues();
const res = await api const res = await api
@@ -328,9 +318,6 @@ function ProxyResourceHttpForm({
onChange={ onChange={
field.onChange field.onChange
} }
onValidityChange={
setHeadersValid
}
rows={4} rows={4}
/> />
</FormControl> </FormControl>
@@ -354,7 +341,7 @@ function ProxyResourceHttpForm({
<Button <Button
type="submit" type="submit"
loading={saveLoading} loading={saveLoading}
disabled={saveLoading || !headersValid} disabled={saveLoading}
form="http-settings-form" form="http-settings-form"
> >
{t("saveSettings")} {t("saveSettings")}

View File

@@ -16,8 +16,11 @@ import LoginCardHeader from "@app/components/LoginCardHeader";
import { priv } from "@app/lib/api"; import { priv } from "@app/lib/api";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { LoginFormIDP } from "@app/components/LoginForm"; import { LoginFormIDP } from "@app/components/LoginForm";
import { ListIdpsResponse } from "@server/routers/idp"; import { ListIdpsResponse, type GetIdpResponse } from "@server/routers/idp";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { cookies } from "next/headers";
import { LAST_USED_IDP_COOKIE_NAME } from "@app/lib/consts";
import z from "zod";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Log In" title: "Log In"
@@ -29,8 +32,9 @@ export default async function Page(props: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) { }) {
const searchParams = await props.searchParams; const searchParams = await props.searchParams;
const getUser = cache(verifySession); const user = await verifySession({ skipCheckVerifyEmail: true });
const user = await getUser({ skipCheckVerifyEmail: true });
const lastUsedIdpCookie = (await cookies()).get(LAST_USED_IDP_COOKIE_NAME);
const isInvite = searchParams?.redirect?.includes("/invite"); const isInvite = searchParams?.redirect?.includes("/invite");
const forceLoginParam = searchParams?.forceLogin; const forceLoginParam = searchParams?.forceLogin;
@@ -85,19 +89,47 @@ export default async function Page(props: {
(build === "enterprise" && env.app.identityProviderMode === "org"); (build === "enterprise" && env.app.identityProviderMode === "org");
let loginIdps: LoginFormIDP[] = []; let loginIdps: LoginFormIDP[] = [];
let lastUsedIdpForSmartLogin: (LoginFormIDP & { orgId?: string }) | 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") {
const idpsRes = await cache( const idpsRes =
async () => await priv.get<AxiosResponse<ListIdpsResponse>>("/idp");
await priv.get<AxiosResponse<ListIdpsResponse>>("/idp")
)();
loginIdps = idpsRes.data.data.idps.map((idp) => ({ loginIdps = idpsRes.data.data.idps.map((idp) => ({
idpId: idp.idpId, idpId: idp.idpId,
name: idp.name, name: idp.name,
variant: idp.type variant: idp.type
})) as LoginFormIDP[]; })) as LoginFormIDP[];
} }
} else {
if (lastUsedIdpCookie) {
const lastUsedIdpSchema = z.object({
orgId: z.string().optional(),
idpId: z.number()
});
try {
const persistedData = lastUsedIdpSchema.parse(
JSON.parse(lastUsedIdpCookie.value)
);
const idpRes = await priv.get<AxiosResponse<GetIdpResponse>>(
`/idp/${persistedData.idpId}`
);
const idp = idpRes.data.data.idp;
lastUsedIdpForSmartLogin = {
idpId: idp.idpId,
name: idp.name,
variant: idp.type,
orgId: persistedData.orgId,
lastUsed: true
};
} catch (error) {
// the idp might not exist or the data is malformatted, skip this
}
}
} }
const t = await getTranslations(); const t = await getTranslations();
@@ -160,6 +192,7 @@ export default async function Page(props: {
redirect={redirectUrl} redirect={redirectUrl}
forceLogin={forceLogin} forceLogin={forceLogin}
defaultUser={defaultUser} defaultUser={defaultUser}
lastUsedIdp={lastUsedIdpForSmartLogin}
orgSignIn={ orgSignIn={
!isInvite && !isInvite &&
(build === "saas" || (build === "saas" ||

View File

@@ -5,7 +5,6 @@ import UserProvider from "@app/providers/UserProvider";
import { ListUserOrgsResponse } from "@server/routers/org"; import { ListUserOrgsResponse } from "@server/routers/org";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { cache } from "react";
import OrganizationLanding from "@app/components/OrganizationLanding"; import OrganizationLanding from "@app/components/OrganizationLanding";
import { pullEnv } from "@app/lib/pullEnv"; import { pullEnv } from "@app/lib/pullEnv";
import { cleanRedirect } from "@app/lib/cleanRedirect"; import { cleanRedirect } from "@app/lib/cleanRedirect";
@@ -13,7 +12,6 @@ import { Layout } from "@app/components/Layout";
import RedirectToOrg from "@app/components/RedirectToOrg"; import RedirectToOrg from "@app/components/RedirectToOrg";
import { InitialSetupCompleteResponse } from "@server/routers/auth"; import { InitialSetupCompleteResponse } from "@server/routers/auth";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { build } from "@server/build";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -27,8 +25,7 @@ export default async function Page(props: {
const env = pullEnv(); const env = pullEnv();
const getUser = cache(verifySession); const user = await verifySession({ skipCheckVerifyEmail: true });
const user = await getUser({ skipCheckVerifyEmail: true });
let complete = false; let complete = false;
try { try {

View File

@@ -1,4 +1,3 @@
import { cn } from "@app/lib/cn";
import { Check, Copy } from "lucide-react"; import { Check, Copy } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
@@ -8,14 +7,12 @@ type CopyToClipboardProps = {
text: string; text: string;
displayText?: string; displayText?: string;
isLink?: boolean; isLink?: boolean;
className?: string;
}; };
const CopyToClipboard = ({ const CopyToClipboard = ({
text, text,
displayText, displayText,
isLink, isLink
className
}: CopyToClipboardProps) => { }: CopyToClipboardProps) => {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@@ -51,20 +48,14 @@ const CopyToClipboard = ({
href={text} href={text}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className={cn( className="truncate hover:underline text-sm min-w-0 max-w-full"
"truncate hover:underline text-sm min-w-0 max-w-full",
className
)}
title={text} // Shows full text on hover title={text} // Shows full text on hover
> >
{displayValue} {displayValue}
</Link> </Link>
) : ( ) : (
<span <span
className={cn( className="truncate text-sm min-w-0 max-w-full"
"truncate text-sm min-w-0 max-w-full",
className
)}
style={{ style={{
whiteSpace: "nowrap", whiteSpace: "nowrap",
overflow: "hidden", overflow: "hidden",

View File

@@ -2,36 +2,24 @@
import { useEffect, useState, useRef } from "react"; import { useEffect, useState, useRef } from "react";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { useTranslations } from "next-intl";
interface HeadersInputProps { interface HeadersInputProps {
value?: { name: string; value: string }[] | null; value?: { name: string; value: string }[] | null;
onChange: (value: { name: string; value: string }[] | null) => void; onChange: (value: { name: string; value: string }[] | null) => void;
onValidityChange?: (isValid: boolean) => void;
placeholder?: string; placeholder?: string;
rows?: number; rows?: number;
className?: string; className?: string;
} }
// Mirrors the server side validation in updateResource.ts so that invalid
// input is caught (and shown to the user) before it is ever submitted,
// instead of being silently dropped in favor of the last known good value.
const validHeaderNamePattern = /^[a-zA-Z0-9!#$%&'*+\-.^_`|~]+$/;
const validHeaderValuePattern = /^[\t\x20-\x7E]*$/;
const templatePattern = /\{\{[^}]+\}\}/;
export function HeadersInput({ export function HeadersInput({
value = [], value = [],
onChange, onChange,
onValidityChange,
placeholder = `X-Example-Header: example-value placeholder = `X-Example-Header: example-value
X-Another-Header: another-value`, X-Another-Header: another-value`,
rows = 4, rows = 4,
className className
}: HeadersInputProps) { }: HeadersInputProps) {
const t = useTranslations();
const [internalValue, setInternalValue] = useState(""); const [internalValue, setInternalValue] = useState("");
const [error, setError] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const isUserEditingRef = useRef(false); const isUserEditingRef = useRef(false);
@@ -46,56 +34,37 @@ X-Another-Header: another-value`,
.join("\n"); .join("\n");
}; };
// Parse newline-separated text into header objects, validating each line // Convert newline-separated string to header objects array
// against the same rules enforced by the server. Returns either the const convertToHeadersArray = (
// parsed headers or an error message describing the first invalid line.
const parseHeaders = (
newlineSeparated: string newlineSeparated: string
): ): { name: string; value: string }[] | null => {
| { headers: { name: string; value: string }[]; error: null } if (!newlineSeparated || newlineSeparated.trim() === "") return [];
| { headers: null; error: string } => {
if (!newlineSeparated || newlineSeparated.trim() === "") {
return { headers: [], error: null };
}
const lines = newlineSeparated return newlineSeparated
.split("\n") .split("\n")
.map((line) => line.trim()) .map((line) => line.trim())
.filter((line) => line.length > 0); .filter((line) => line.length > 0 && line.includes(":"))
.map((line) => {
const colonIndex = line.indexOf(":");
const name = line.substring(0, colonIndex).trim();
const value = line.substring(colonIndex + 1).trim();
const headers: { name: string; value: string }[] = []; // Ensure header name conforms to HTTP header requirements
// Header names should be case-insensitive, contain only ASCII letters, digits, and hyphens
const normalizedName = name
.replace(/[^a-zA-Z0-9\-]/g, "")
.toLowerCase();
for (const line of lines) { return { name: normalizedName, value };
const colonIndex = line.indexOf(":"); })
if (colonIndex === -1) { .filter((header) => header.name.length > 0); // Filter out headers with invalid names
return { headers: null, error: t("headersValidationError") };
}
const name = line.substring(0, colonIndex).trim();
const value = line.substring(colonIndex + 1).trim();
if (
!validHeaderNamePattern.test(name) ||
!validHeaderValuePattern.test(value) ||
templatePattern.test(name) ||
templatePattern.test(value)
) {
return { headers: null, error: t("headersValidationError") };
}
headers.push({ name, value });
}
return { headers, error: null };
}; };
// Update internal value when external value changes // Update internal value when external value changes
// But only if the user is not currently editing (textarea not focused) // But only if the user is not currently editing (textarea not focused)
useEffect(() => { useEffect(() => {
if (!isUserEditingRef.current) { if (!isUserEditingRef.current) {
setInternalValue(convertToNewlineSeparated(value ?? [])); setInternalValue(convertToNewlineSeparated(value));
setError(null);
onValidityChange?.(true);
} }
}, [value]); }, [value]);
@@ -106,20 +75,31 @@ X-Another-Header: another-value`,
// Mark that user is actively editing // Mark that user is actively editing
isUserEditingRef.current = true; isUserEditingRef.current = true;
const result = parseHeaders(newValue); // Only update parent if the input is in a valid state
// Valid states: empty/whitespace only, or contains properly formatted headers
if (result.error) { if (newValue.trim() === "") {
// Surface the error and do not touch the last known good value. // Empty input is valid - represents no headers
// Silently dropping the update here (without telling the user) onChange([]);
// is what previously let stale data get saved without warning. } else {
setError(result.error); // Check if all non-empty lines are properly formatted (contain ':')
onValidityChange?.(false); const lines = newValue.split("\n");
return; const nonEmptyLines = lines
.map((line) => line.trim())
.filter((line) => line.length > 0);
// If there are no non-empty lines, or all non-empty lines contain ':', it's valid
const isValid =
nonEmptyLines.length === 0 ||
nonEmptyLines.every((line) => line.includes(":"));
if (isValid) {
// Safe to convert and update parent
const headersArray = convertToHeadersArray(newValue);
onChange(headersArray);
}
// If not valid, don't call onChange - let user continue typing
} }
setError(null);
onValidityChange?.(true);
onChange(result.headers);
}; };
const handleFocus = () => { const handleFocus = () => {
@@ -134,20 +114,15 @@ X-Another-Header: another-value`,
}; };
return ( return (
<div> <Textarea
<Textarea ref={textareaRef}
ref={textareaRef} value={internalValue}
value={internalValue} onChange={handleChange}
onChange={handleChange} onFocus={handleFocus}
onFocus={handleFocus} onBlur={handleBlur}
onBlur={handleBlur} placeholder={placeholder}
placeholder={placeholder} rows={rows}
rows={rows} className={className}
className={className} />
/>
{error && (
<p className="text-sm text-destructive mt-1.5">{error}</p>
)}
</div>
); );
} }

View File

@@ -1,26 +1,25 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { generateOidcUrlProxy } from "@app/actions/server";
import { Button } from "@app/components/ui/button";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { useTranslations } from "next-intl";
import IdpTypeIcon from "@app/components/IdpTypeIcon"; import IdpTypeIcon from "@app/components/IdpTypeIcon";
import { import { Alert, AlertDescription } from "@app/components/ui/alert";
generateOidcUrlProxy, import { Button } from "@app/components/ui/button";
type GenerateOidcUrlResponse import { cleanRedirect } from "@app/lib/cleanRedirect";
} from "@app/actions/server"; import { LAST_USED_IDP_COOKIE_NAME } from "@app/lib/consts";
import { setClientCookie } from "@app/lib/setClientCookie";
import { useTranslations } from "next-intl";
import { import {
redirect as redirectTo, redirect as redirectTo,
useParams, useRouter,
useSearchParams useSearchParams
} from "next/navigation"; } from "next/navigation";
import { useRouter } from "next/navigation"; import { useEffect, useState, useTransition } from "react";
import { cleanRedirect } from "@app/lib/cleanRedirect";
export type LoginFormIDP = { export type LoginFormIDP = {
idpId: number; idpId: number;
name: string; name: string;
variant?: string; variant?: string;
lastUsed?: boolean;
}; };
type IdpLoginButtonsProps = { type IdpLoginButtonsProps = {
@@ -35,7 +34,6 @@ export default function IdpLoginButtons({
orgId orgId
}: IdpLoginButtonsProps) { }: IdpLoginButtonsProps) {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const t = useTranslations(); const t = useTranslations();
const params = useSearchParams(); const params = useSearchParams();
@@ -52,10 +50,22 @@ export default function IdpLoginButtons({
} }
}, []); }, []);
const [loading, startTransition] = useTransition();
async function loginWithIdp(idpId: number) { async function loginWithIdp(idpId: number) {
setLoading(true);
setError(null); setError(null);
setClientCookie(
LAST_USED_IDP_COOKIE_NAME,
JSON.stringify({
orgId,
idpId
}),
{
sameSite: "Lax"
}
);
let redirectToUrl: string | undefined; let redirectToUrl: string | undefined;
try { try {
console.log("generating", idpId, redirect || "/", orgId); console.log("generating", idpId, redirect || "/", orgId);
@@ -68,7 +78,6 @@ export default function IdpLoginButtons({
if (response.error) { if (response.error) {
setError(response.message); setError(response.message);
setLoading(false);
return; return;
} }
@@ -84,7 +93,6 @@ export default function IdpLoginButtons({
"An unexpected error occurred. Please try again." "An unexpected error occurred. Please try again."
}) })
); );
setLoading(false);
} }
if (redirectToUrl) { if (redirectToUrl) {
@@ -124,20 +132,38 @@ export default function IdpLoginButtons({
idp.variant || idp.name.toLowerCase(); idp.variant || idp.name.toLowerCase();
return ( return (
<Button <div
className="w-full relative"
key={idp.idpId} key={idp.idpId}
type="button"
variant="outline"
className="w-full inline-flex items-center space-x-2"
onClick={() => {
loginWithIdp(idp.idpId);
}}
disabled={loading}
loading={loading}
> >
<IdpTypeIcon type={effectiveType} size={16} /> <Button
<span>{idp.name}</span> key={idp.idpId}
</Button> type="button"
variant="outline"
className="w-full inline-flex items-center space-x-2 after:absolute after:inset-0 after:z-10"
onClick={() => {
startTransition(() =>
loginWithIdp(idp.idpId)
);
}}
disabled={loading}
loading={loading}
>
<IdpTypeIcon
type={effectiveType}
size={16}
/>
<span>{idp.name}</span>
</Button>
{idp.lastUsed && (
<div className="absolute inset-0">
<span className="absolute top-0 right-0 text-xs bg-primary text-primary-foreground rounded-bl-sm rounded-tr-sm px-2 py-0.5">
{t("idpLastUsed")}
</span>
</div>
)}
</div>
); );
})} })}
</> </>

View File

@@ -5,15 +5,12 @@ import { cn } from "@app/lib/cn";
export function InfoSections({ export function InfoSections({
children, children,
cols, cols,
columnSizing = "content", columnSizing = "content"
layout = "default"
}: { }: {
children: React.ReactNode; children: React.ReactNode;
cols?: number; cols?: number;
/** content (default): fixed gap, columns hug content, left-aligned; fill: equal-width columns across the row */ /** content (default): fixed gap, columns hug content, left-aligned; fill: equal-width columns across the row */
columnSizing?: "fill" | "content"; columnSizing?: "fill" | "content";
/** panel: 2 columns until xl for narrow containers such as side panels */
layout?: "default" | "panel";
}) { }) {
const n = cols || 1; const n = cols || 1;
const track = const track =
@@ -22,14 +19,9 @@ export function InfoSections({
return ( return (
<div <div
className={cn( className={cn(
"grid w-full min-w-0 gap-4", "grid w-full min-w-0 grid-cols-2 md:grid-cols-(--columns) md:space-x-16 gap-4 md:items-start",
layout === "panel"
? "grid-cols-2 xl:grid-cols-(--columns) xl:items-start xl:space-x-16"
: "grid-cols-2 md:grid-cols-(--columns) md:items-start md:space-x-16",
columnSizing === "content" && columnSizing === "content" &&
(layout === "panel" "md:justify-items-start md:justify-start"
? "xl:justify-items-start xl:justify-start"
: "md:justify-items-start md:justify-start")
)} )}
style={{ style={{
// @ts-expect-error dynamic props don't work with tailwind, but we can set the // @ts-expect-error dynamic props don't work with tailwind, but we can set the

View File

@@ -11,7 +11,7 @@ import {
import { launcherQueries, orgQueries } from "@app/lib/queries"; import { launcherQueries, orgQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useMemo, useState } from "react"; import { useState } from "react";
import { useDebounce } from "use-debounce"; import { useDebounce } from "use-debounce";
import { Checkbox } from "./ui/checkbox"; import { Checkbox } from "./ui/checkbox";
@@ -25,7 +25,6 @@ type LabelsFilterSelectorProps = {
orgId: string; orgId: string;
isSelected: (label: LabelFilterOption) => boolean; isSelected: (label: LabelFilterOption) => boolean;
onToggle: (label: LabelFilterOption) => void; onToggle: (label: LabelFilterOption) => void;
selectedLabels?: LabelFilterOption[];
onClear?: () => void; onClear?: () => void;
showClear?: boolean; showClear?: boolean;
scope?: "org" | "launcher"; scope?: "org" | "launcher";
@@ -35,7 +34,6 @@ export function LabelsFilterSelector({
orgId, orgId,
isSelected, isSelected,
onToggle, onToggle,
selectedLabels = [],
onClear, onClear,
showClear = false, showClear = false,
scope = "org" scope = "org"
@@ -56,7 +54,7 @@ export function LabelsFilterSelector({
...launcherQueries.labels({ ...launcherQueries.labels({
orgId, orgId,
query: debouncedQuery, query: debouncedQuery,
perPage: 20 perPage: 500
}), }),
enabled: scope === "launcher" enabled: scope === "launcher"
}); });
@@ -65,18 +63,6 @@ export function LabelsFilterSelector({
? (launcherLabelsQuery.data ?? []) ? (launcherLabelsQuery.data ?? [])
: (orgLabelsQuery.data ?? []); : (orgLabelsQuery.data ?? []);
const labelsShown = useMemo(() => {
const base = [...labels];
if (debouncedQuery.trim().length === 0 && selectedLabels.length > 0) {
const selectedNotInBase = selectedLabels.filter(
(selected) =>
!base.some((label) => label.labelId === selected.labelId)
);
return [...selectedNotInBase, ...base];
}
return base;
}, [debouncedQuery, labels, selectedLabels]);
return ( return (
<Command shouldFilter={false}> <Command shouldFilter={false}>
<CommandInput <CommandInput
@@ -95,7 +81,7 @@ export function LabelsFilterSelector({
{t("accessFilterClear")} {t("accessFilterClear")}
</CommandItem> </CommandItem>
)} )}
{labelsShown.map((label) => ( {labels.map((label) => (
<CommandItem <CommandItem
key={label.labelId} key={label.labelId}
value={label.name} value={label.name}

View File

@@ -30,10 +30,7 @@ import Link from "next/link";
import { GenerateOidcUrlResponse } from "@server/routers/idp"; import { GenerateOidcUrlResponse } from "@server/routers/idp";
import { Separator } from "./ui/separator"; import { Separator } from "./ui/separator";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { import { generateOidcUrlProxy, loginProxy } from "@app/actions/server";
generateOidcUrlProxy,
loginProxy
} from "@app/actions/server";
import { redirect as redirectTo } from "next/navigation"; import { redirect as redirectTo } from "next/navigation";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import IdpTypeIcon from "@app/components/IdpTypeIcon"; import IdpTypeIcon from "@app/components/IdpTypeIcon";
@@ -41,11 +38,13 @@ import IdpTypeIcon from "@app/components/IdpTypeIcon";
import { loadReoScript } from "reodotdev"; import { loadReoScript } from "reodotdev";
import { build } from "@server/build"; import { build } from "@server/build";
import MfaInputForm from "@app/components/MfaInputForm"; import MfaInputForm from "@app/components/MfaInputForm";
import { useLocalStorage } from "@app/hooks/useLocalStorage";
export type LoginFormIDP = { export type LoginFormIDP = {
idpId: number; idpId: number;
name: string; name: string;
variant?: string; variant?: string;
lastUsed?: boolean;
}; };
type LoginFormProps = { type LoginFormProps = {
@@ -105,7 +104,6 @@ export default function LoginForm({
} }
}, []); }, []);
const formSchema = z.object({ const formSchema = z.object({
email: z.string().email({ message: t("emailInvalid") }), email: z.string().email({ message: t("emailInvalid") }),
password: z.string().min(8, { message: t("passwordRequirementsChars") }) password: z.string().min(8, { message: t("passwordRequirementsChars") })
@@ -130,6 +128,10 @@ export default function LoginForm({
} }
}); });
const [lastUsedIdpId, setLastUsedIdpId] = useLocalStorage<string | null>(
"login:last-used-idp",
null
);
async function onSubmit(values: any) { async function onSubmit(values: any) {
const { email, password } = form.getValues(); const { email, password } = form.getValues();
@@ -179,8 +181,7 @@ export default function LoginForm({
if (data.useSecurityKey) { if (data.useSecurityKey) {
setError( setError(
t("securityKeyRequired", { t("securityKeyRequired", {
defaultValue: defaultValue: "Please use your security key to sign in."
"Please use your security key to sign in."
}) })
); );
return; return;
@@ -242,6 +243,8 @@ export default function LoginForm({
async function loginWithIdp(idpId: number) { async function loginWithIdp(idpId: number) {
let redirectUrl: string | undefined; let redirectUrl: string | undefined;
setLastUsedIdpId(idpId.toString());
try { try {
const data = await generateOidcUrlProxy( const data = await generateOidcUrlProxy(
idpId, idpId,
@@ -356,7 +359,6 @@ export default function LoginForm({
)} )}
<div className="space-y-4"> <div className="space-y-4">
{!mfaRequested && ( {!mfaRequested && (
<> <>
<SecurityKeyAuthButton <SecurityKeyAuthButton
@@ -385,25 +387,41 @@ export default function LoginForm({
idp.variant || idp.name.toLowerCase(); idp.variant || idp.name.toLowerCase();
return ( return (
<Button <div
className="w-full relative"
key={idp.idpId} key={idp.idpId}
type="button"
variant="outline"
className="w-full inline-flex items-center space-x-2"
onClick={() => {
loginWithIdp(idp.idpId);
}}
> >
<IdpTypeIcon type={effectiveType} size={16} /> <Button
<span>{idp.name}</span> key={idp.idpId}
</Button> type="button"
variant="outline"
className="w-full inline-flex items-center space-x-2 after:absolute after:inset-0 after:z-10"
onClick={() => {
loginWithIdp(idp.idpId);
}}
>
<IdpTypeIcon
type={effectiveType}
size={16}
/>
<span>{idp.name}</span>
</Button>
{lastUsedIdpId ===
idp.idpId.toString() && (
<div className="absolute inset-0">
<span className="absolute top-0 right-0 text-xs bg-primary text-primary-foreground rounded-bl-sm rounded-tr-sm px-2 py-0.5">
{t("idpLastUsed")}
</span>
</div>
)}
</div>
); );
})} })}
</> </>
)} )}
</> </>
)} )}
</div> </div>
</div> </div>
); );

View File

@@ -27,6 +27,8 @@ import UserProfileCard from "@app/components/UserProfileCard";
import SecurityKeyAuthButton from "@app/components/SecurityKeyAuthButton"; import SecurityKeyAuthButton from "@app/components/SecurityKeyAuthButton";
import { Separator } from "@app/components/ui/separator"; import { Separator } from "@app/components/ui/separator";
import OrgSignInLink from "@app/components/OrgSignInLink"; import OrgSignInLink from "@app/components/OrgSignInLink";
import type { LoginFormIDP } from "./LoginForm";
import IdpLoginButtons from "./IdpLoginButtons";
const identifierSchema = z.object({ const identifierSchema = z.object({
identifier: z.string().min(1, "Username or email is required") identifier: z.string().min(1, "Username or email is required")
@@ -53,6 +55,7 @@ type SmartLoginFormProps = {
forceLogin?: boolean; forceLogin?: boolean;
defaultUser?: string; defaultUser?: string;
orgSignIn?: OrgSignInConfig; orgSignIn?: OrgSignInConfig;
lastUsedIdp?: (LoginFormIDP & { orgId?: string }) | null;
}; };
type ViewState = type ViewState =
@@ -89,7 +92,8 @@ export default function SmartLoginForm({
redirect, redirect,
forceLogin, forceLogin,
defaultUser, defaultUser,
orgSignIn orgSignIn,
lastUsedIdp
}: SmartLoginFormProps) { }: SmartLoginFormProps) {
const router = useRouter(); const router = useRouter();
const { env } = useEnvContext(); const { env } = useEnvContext();
@@ -294,6 +298,15 @@ export default function SmartLoginForm({
</span> </span>
</div> </div>
</div> </div>
{lastUsedIdp && (
<IdpLoginButtons
idps={[lastUsedIdp]}
orgId={lastUsedIdp.orgId}
redirect={redirect}
/>
)}
<OrgSignInLink <OrgSignInLink
href={orgSignIn.href} href={orgSignIn.href}
linkText={orgSignIn.linkText} linkText={orgSignIn.linkText}

View File

@@ -20,8 +20,6 @@ export type MultiSitesSelectorProps = {
onSelectionChange: (sites: Selectedsite[]) => void; onSelectionChange: (sites: Selectedsite[]) => void;
filterTypes?: string[]; filterTypes?: string[];
scope?: "org" | "launcher"; scope?: "org" | "launcher";
onClear?: () => void;
showClear?: boolean;
}; };
export function formatMultiSitesSelectorLabel( export function formatMultiSitesSelectorLabel(
@@ -44,9 +42,7 @@ export function MultiSitesSelector({
selectedSites, selectedSites,
onSelectionChange, onSelectionChange,
filterTypes, filterTypes,
scope = "org", scope = "org"
onClear,
showClear = false
}: MultiSitesSelectorProps) { }: MultiSitesSelectorProps) {
const t = useTranslations(); const t = useTranslations();
const [siteSearchQuery, setSiteSearchQuery] = useState(""); const [siteSearchQuery, setSiteSearchQuery] = useState("");
@@ -64,7 +60,7 @@ export function MultiSitesSelector({
...launcherQueries.sites({ ...launcherQueries.sites({
orgId, orgId,
query: debouncedQuery, query: debouncedQuery,
perPage: 20 perPage: 500
}), }),
enabled: scope === "launcher" enabled: scope === "launcher"
}); });
@@ -111,14 +107,6 @@ export function MultiSitesSelector({
<CommandList> <CommandList>
<CommandEmpty>{t("siteNotFound")}</CommandEmpty> <CommandEmpty>{t("siteNotFound")}</CommandEmpty>
<CommandGroup> <CommandGroup>
{showClear && onClear && (
<CommandItem
onSelect={onClear}
className="text-muted-foreground"
>
{t("accessFilterClear")}
</CommandItem>
)}
{sitesShown.map((site) => ( {sitesShown.map((site) => (
<CommandItem <CommandItem
key={site.siteId} key={site.siteId}

View File

@@ -10,7 +10,6 @@ import {
type SelectedLabel type SelectedLabel
} from "@app/components/labels-selector"; } from "@app/components/labels-selector";
import { LabelsFilterSelector } from "@app/components/LabelsFilterSelector"; import { LabelsFilterSelector } from "@app/components/LabelsFilterSelector";
import { Badge } from "@app/components/ui/badge";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { import {
Popover, Popover,
@@ -47,14 +46,14 @@ export function LauncherFilterPopover({
const { data: labels = [] } = useQuery( const { data: labels = [] } = useQuery(
launcherQueries.labels({ launcherQueries.labels({
orgId, orgId,
perPage: 20 perPage: 500
}) })
); );
const { data: sites = [] } = useQuery( const { data: sites = [] } = useQuery(
launcherQueries.sites({ launcherQueries.sites({
orgId, orgId,
perPage: 20 perPage: 500
}) })
); );
@@ -97,32 +96,14 @@ export function LauncherFilterPopover({
[labels, selectedLabels] [labels, selectedLabels]
); );
const activeFilterCount = selectedSites.length + selectedLabels.length;
return ( return (
<Popover modal={false}> <Popover modal={false}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button variant="outline" size="icon" className="shrink-0">
variant="outline"
size="icon"
className="relative shrink-0"
>
<Funnel className="size-4" /> <Funnel className="size-4" />
<span className="sr-only"> <span className="sr-only">
{activeFilterCount > 0 {t("resourceLauncherFilter")}
? t("resourceLauncherFilterWithCount", {
count: activeFilterCount
})
: t("resourceLauncherFilter")}
</span> </span>
{activeFilterCount > 0 && (
<Badge
variant="secondary"
className="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center px-1.5 text-xs"
>
{activeFilterCount > 99 ? "99+" : activeFilterCount}
</Badge>
)}
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent align="end" className="w-72"> <PopoverContent align="end" className="w-72">
@@ -158,10 +139,6 @@ export function LauncherFilterPopover({
selectedSites={resolvedSelectedSites} selectedSites={resolvedSelectedSites}
onSelectionChange={onSitesChange} onSelectionChange={onSitesChange}
scope="launcher" scope="launcher"
showClear={selectedSites.length > 0}
onClear={() => {
onSitesChange([]);
}}
/> />
</PopoverContent> </PopoverContent>
</Popover> </Popover>
@@ -195,7 +172,6 @@ export function LauncherFilterPopover({
<LabelsFilterSelector <LabelsFilterSelector
orgId={orgId} orgId={orgId}
scope="launcher" scope="launcher"
selectedLabels={resolvedSelectedLabels}
isSelected={(label) => isSelected={(label) =>
selectedLabelIds.has(label.labelId) selectedLabelIds.has(label.labelId)
} }

View File

@@ -1,125 +0,0 @@
"use client";
import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage";
import { hasActiveLauncherFilters } from "@app/lib/launcherScale";
import { launcherQueries } from "@app/lib/queries";
import type {
LauncherResource,
LauncherViewConfig
} from "@server/routers/launcher/types";
import { LAUNCHER_FLAT_GROUP_KEY } from "@server/routers/launcher/types";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { useEffect, useMemo, useRef } from "react";
import { LauncherEmptyState } from "./LauncherEmptyState";
import { LauncherResourceGrid } from "./LauncherResourceGrid";
import { LauncherResourceList } from "./LauncherResourceList";
type LauncherFlatResourceListProps = {
orgId: string;
activeViewId: LauncherActiveViewId;
config: LauncherViewConfig;
onClearFilters?: () => void;
onResourceSelect?: (resource: LauncherResource) => void;
};
export function LauncherFlatResourceList({
orgId,
config,
onClearFilters,
onResourceSelect
}: LauncherFlatResourceListProps) {
const loadMoreRef = useRef<HTMLDivElement | null>(null);
const resourceFilters = useMemo(
() => ({
query: config.query,
groupBy: config.groupBy,
groupKey: LAUNCHER_FLAT_GROUP_KEY,
siteIds: config.siteIds,
labelIds: config.labelIds,
sort_by: config.sortBy,
order: config.order,
pageSize: 20
}),
[
config.groupBy,
config.labelIds,
config.order,
config.query,
config.siteIds,
config.sortBy
]
);
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isFetching } =
useInfiniteQuery({
...launcherQueries.resources(orgId, resourceFilters)
});
const resources = data?.pages.flatMap((page) => page.resources) ?? [];
useEffect(() => {
const node = loadMoreRef.current;
if (!node || !hasNextPage) {
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && !isFetchingNextPage) {
void fetchNextPage();
}
},
{ rootMargin: "200px" }
);
observer.observe(node);
return () => observer.disconnect();
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
if (resources.length === 0) {
if (isFetching) {
return (
<div className="flex items-center justify-center py-16 text-muted-foreground">
<Loader2 className="size-6 animate-spin" />
</div>
);
}
return (
<LauncherEmptyState
variant={
hasActiveLauncherFilters(config) ? "noResults" : "empty"
}
layout={config.layout}
query={config.query}
onClearFilters={onClearFilters}
/>
);
}
return (
<div className="flex flex-col gap-2.5">
{config.layout === "grid" ? (
<LauncherResourceGrid
resources={resources}
showLabels={config.showLabels}
onResourceSelect={onResourceSelect}
/>
) : (
<LauncherResourceList
resources={resources}
showLabels={config.showLabels}
onResourceSelect={onResourceSelect}
/>
)}
<div ref={loadMoreRef} className="h-4" />
{isFetchingNextPage ? (
<div className="flex justify-center py-2">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
) : null}
</div>
);
}

View File

@@ -69,20 +69,16 @@ export function LauncherGroupList({
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isFetching } = const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isFetching } =
useInfiniteQuery({ useInfiniteQuery({
...launcherQueries.groups(orgId, groupFilters), ...launcherQueries.groups(orgId, groupFilters),
...(initialGroups.length > 0 initialData: {
? { pages: [
initialData: { {
pages: [ groups: initialGroups,
{ pagination: groupsPagination
groups: initialGroups, }
pagination: groupsPagination ],
} pageParams: [1]
], },
pageParams: [1] refetchOnMount: false
},
refetchOnMount: false as const
}
: {})
}); });
const groups = data?.pages.flatMap((page) => page.groups) ?? []; const groups = data?.pages.flatMap((page) => page.groups) ?? [];

View File

@@ -23,8 +23,9 @@ export function LauncherRefreshButton({
className="shrink-0" className="shrink-0"
> >
<RefreshCw <RefreshCw
className={`size-4 ${isRefreshing ? "animate-spin" : ""}`} className={`mr-0 sm:mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
/> />
<span className="hidden sm:inline">{t("refresh")}</span>
</Button> </Button>
); );
} }

View File

@@ -1,55 +1,17 @@
"use client"; "use client";
import CopyToClipboard from "@app/components/CopyToClipboard";
import {
InfoSection,
InfoSectionContent,
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import {
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { import {
SidePanel, SidePanel,
SidePanelBody, SidePanelBody,
SidePanelContent, SidePanelContent,
SidePanelDescription,
SidePanelFooter, SidePanelFooter,
SidePanelHeader, SidePanelHeader,
SidePanelTitle SidePanelTitle
} from "@app/components/SidePanel"; } from "@app/components/SidePanel";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import {
derivePublicAuthState,
formatPortRestrictionDisplay,
formatPublicResourceType
} from "@app/lib/launcherResourceDetails";
import { getLauncherResourceAdminHref } from "@app/lib/launcherResourceAdminHref"; import { getLauncherResourceAdminHref } from "@app/lib/launcherResourceAdminHref";
import {
formatSiteResourceDestinationDisplay,
isSafeUrlForLink
} from "@app/lib/launcherResourceAccess";
import { launcherQueries } from "@app/lib/queries";
import type { LauncherResource } from "@server/routers/launcher/types"; import type { LauncherResource } from "@server/routers/launcher/types";
import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo";
import type { GetResourceResponse } from "@server/routers/resource/getResource";
import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource";
import { useQuery } from "@tanstack/react-query";
import {
AlertCircle,
CheckCircle2,
Clock,
ExternalLink,
Loader2,
ShieldCheck,
ShieldOff,
XCircle
} from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Link from "next/link"; import Link from "next/link";
@@ -61,460 +23,6 @@ type LauncherResourcePanelProps = {
isAdmin: boolean; isAdmin: boolean;
}; };
type LauncherResourceDetailResult =
| {
resourceType: "public";
data: GetResourceResponse;
authInfo: GetResourceAuthInfoResponse;
}
| { resourceType: "site"; data: GetSiteResourceResponse };
function AccessMethodContent({
accessDisplay,
accessCopyValue,
accessUrl
}: {
accessDisplay: string;
accessCopyValue: string;
accessUrl?: string | null;
}) {
if (!accessDisplay) {
return <span>-</span>;
}
const href = accessUrl ?? undefined;
const canLink = Boolean(href && isSafeUrlForLink(href));
if (canLink && href) {
return (
<CopyToClipboard
text={href}
displayText={accessDisplay}
isLink={true}
className="text-base"
/>
);
}
return (
<CopyToClipboard
text={accessCopyValue || accessDisplay}
displayText={accessDisplay}
isLink={false}
className="text-base"
/>
);
}
function HealthStatusDisplay({
health
}: {
health: string | null | undefined;
}) {
const t = useTranslations();
const status = health ?? "unknown";
if (status === "healthy") {
return (
<div className="flex items-center space-x-2">
<CheckCircle2 className="size-4 shrink-0 text-green-500" />
<span>{t("resourcesTableHealthy")}</span>
</div>
);
}
if (status === "degraded") {
return (
<div className="flex items-center space-x-2">
<CheckCircle2 className="size-4 shrink-0 text-yellow-500" />
<span>{t("resourcesTableDegraded")}</span>
</div>
);
}
if (status === "unhealthy") {
return (
<div className="flex items-center space-x-2">
<XCircle className="size-4 shrink-0 text-destructive" />
<span>{t("resourcesTableUnhealthy")}</span>
</div>
);
}
return (
<div className="flex items-center space-x-2">
<Clock className="size-4 shrink-0 text-muted-foreground" />
<span>{t("resourcesTableUnknown")}</span>
</div>
);
}
const PUBLIC_AUTH_BROWSER_MODES = ["http", "ssh", "rdp", "vnc"];
function AuthMethodStatusDisplay({ enabled }: { enabled: boolean }) {
const t = useTranslations();
return (
<div className="flex items-center gap-2">
{enabled ? (
<CheckCircle2 className="size-4 text-green-600" />
) : (
<XCircle className="size-4 text-red-600" />
)}
<span>{enabled ? t("enabled") : t("disabled")}</span>
</div>
);
}
function PublicResourceAuthMethods({
authInfo
}: {
authInfo: GetResourceAuthInfoResponse;
}) {
const t = useTranslations();
const authMethods = [
{
key: "sso",
title: t("policyAuthSsoTitle"),
enabled: authInfo.sso
},
{
key: "password",
title: t("policyAuthPasscodeTitle"),
enabled: authInfo.password
},
{
key: "pincode",
title: t("policyAuthPincodeTitle"),
enabled: authInfo.pincode
},
{
key: "whitelist",
title: t("policyAuthEmailTitle"),
enabled: authInfo.whitelist
},
{
key: "headerAuth",
title: t("policyAuthHeaderAuthTitle"),
enabled: authInfo.headerAuth
}
];
return (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("authentication")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourceLauncherAuthMethodsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<InfoSections cols={authMethods.length} layout="panel">
{authMethods.map((method) => (
<InfoSection key={method.key}>
<InfoSectionTitle>{method.title}</InfoSectionTitle>
<InfoSectionContent>
<AuthMethodStatusDisplay
enabled={method.enabled}
/>
</InfoSectionContent>
</InfoSection>
))}
</InfoSections>
</SettingsSectionBody>
</SettingsSection>
);
}
function PublicResourceDetails({
launcherResource,
resource,
authInfo
}: {
launcherResource: LauncherResource;
resource: GetResourceResponse;
authInfo: GetResourceAuthInfoResponse;
}) {
const t = useTranslations();
const supportsAuth = PUBLIC_AUTH_BROWSER_MODES.includes(
resource.mode || ""
);
const authState = derivePublicAuthState(resource.mode, authInfo);
const infoSectionCount = supportsAuth ? 4 : 3;
return (
<div className="space-y-4">
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("resourceLauncherResourceDetails")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourceLauncherResourceDetailsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<InfoSections cols={infoSectionCount} layout="panel">
<InfoSection>
<InfoSectionTitle>{t("type")}</InfoSectionTitle>
<InfoSectionContent>
{formatPublicResourceType(resource)}
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>{t("access")}</InfoSectionTitle>
<InfoSectionContent>
<AccessMethodContent
accessDisplay={
launcherResource.accessDisplay
}
accessCopyValue={
launcherResource.accessCopyValue
}
accessUrl={launcherResource.accessUrl}
/>
</InfoSectionContent>
</InfoSection>
{supportsAuth ? (
<InfoSection>
<InfoSectionTitle>
{t("authentication")}
</InfoSectionTitle>
<InfoSectionContent>
{authState === "protected" ? (
<div className="flex items-center space-x-2">
<ShieldCheck className="size-4 shrink-0 text-green-500" />
<span>{t("protected")}</span>
</div>
) : (
<div className="flex items-center space-x-2">
<ShieldOff className="size-4 shrink-0 text-yellow-500" />
<span>{t("notProtected")}</span>
</div>
)}
</InfoSectionContent>
</InfoSection>
) : null}
<InfoSection>
<InfoSectionTitle>{t("health")}</InfoSectionTitle>
<InfoSectionContent>
<HealthStatusDisplay health={resource.health} />
</InfoSectionContent>
</InfoSection>
</InfoSections>
</SettingsSectionBody>
</SettingsSection>
{supportsAuth ? (
<PublicResourceAuthMethods authInfo={authInfo} />
) : null}
</div>
);
}
function PrivateResourceDetails({
launcherResource,
resource
}: {
launcherResource: LauncherResource;
resource: GetSiteResourceResponse;
}) {
const t = useTranslations();
const modeLabel: Record<GetSiteResourceResponse["mode"], string> = {
host: t("editInternalResourceDialogModeHost"),
cidr: t("editInternalResourceDialogModeCidr"),
http: t("editInternalResourceDialogModeHttp"),
ssh: t("editInternalResourceDialogModeSsh")
};
const destination = formatSiteResourceDestinationDisplay({
mode: resource.mode,
destination: resource.destination,
destinationPort: resource.destinationPort,
scheme: resource.scheme
});
const portRestrictions = formatPortRestrictionDisplay(resource);
const showAlias = resource.mode !== "cidr" && resource.mode !== "http";
const showDestination = !(
resource.mode === "ssh" && resource.authDaemonMode === "native"
);
const infoSectionCount =
2 + (showDestination ? 1 : 0) + (showAlias ? 1 : 0) + 1;
return (
<div className="space-y-4">
<Alert variant="default">
<AlertCircle className="size-4" />
<AlertTitle>
{t("resourceLauncherPrivateClientRequiredTitle")}
</AlertTitle>
<AlertDescription>
<span>
{t("resourceLauncherPrivateClientRequired")}{" "}
<a
href="https://pangolin.net/downloads"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-primary hover:underline"
>
{t("resourceLauncherDownloadClient")}
<ExternalLink className="size-3.5 shrink-0" />
</a>
</span>
</AlertDescription>
</Alert>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("resourceLauncherResourceDetails")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourceLauncherResourceDetailsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<InfoSections cols={infoSectionCount} layout="panel">
<InfoSection>
<InfoSectionTitle>{t("type")}</InfoSectionTitle>
<InfoSectionContent>
{modeLabel[resource.mode]}
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>{t("access")}</InfoSectionTitle>
<InfoSectionContent>
<AccessMethodContent
accessDisplay={
launcherResource.accessDisplay
}
accessCopyValue={
launcherResource.accessCopyValue
}
accessUrl={launcherResource.accessUrl}
/>
</InfoSectionContent>
</InfoSection>
{showDestination ? (
<InfoSection>
<InfoSectionTitle>
{t("editInternalResourceDialogDestination")}
</InfoSectionTitle>
<InfoSectionContent>
{destination || "-"}
</InfoSectionContent>
</InfoSection>
) : null}
{showAlias ? (
<InfoSection>
<InfoSectionTitle>
{t("editInternalResourceDialogAlias")}
</InfoSectionTitle>
<InfoSectionContent>
{resource.alias?.trim()
? resource.alias
: "-"}
</InfoSectionContent>
</InfoSection>
) : null}
<InfoSection>
<InfoSectionTitle>
{t("portRestrictions")}
</InfoSectionTitle>
<InfoSectionContent>
{!portRestrictions.hasNonDefaultPorts ? (
<span>
{t(
"resourceLauncherNoPortRestrictions"
)}
</span>
) : (
<div className="space-y-1">
{portRestrictions.tcp.state !==
"all" ? (
<div>
{t("resourceLauncherTcp")}:{" "}
{portRestrictions.tcp.state ===
"blocked"
? t("blocked")
: portRestrictions.tcp
.ports}
</div>
) : null}
{portRestrictions.udp.state !==
"all" ? (
<div>
{t("resourceLauncherUdp")}:{" "}
{portRestrictions.udp.state ===
"blocked"
? t("blocked")
: portRestrictions.udp
.ports}
</div>
) : null}
</div>
)}
</InfoSectionContent>
</InfoSection>
</InfoSections>
</SettingsSectionBody>
</SettingsSection>
</div>
);
}
function LauncherResourcePanelBody({
orgId,
resource,
open
}: {
orgId: string;
resource: LauncherResource;
open: boolean;
}) {
const t = useTranslations();
const { data, isPending, isError } = useQuery({
...launcherQueries.resourceDetail(orgId, resource),
enabled: open
});
if (isPending) {
return (
<div className="flex items-center justify-center py-12 text-muted-foreground">
<Loader2 className="size-6 animate-spin" />
</div>
);
}
if (isError || !data) {
return (
<p className="text-sm text-muted-foreground">
{t("resourceLauncherFailedToLoadDetails")}
</p>
);
}
const detail = data as LauncherResourceDetailResult;
if (detail.resourceType === "public") {
return (
<PublicResourceDetails
launcherResource={resource}
resource={detail.data}
authInfo={detail.authInfo}
/>
);
}
return (
<PrivateResourceDetails
launcherResource={resource}
resource={detail.data}
/>
);
}
export function LauncherResourcePanel({ export function LauncherResourcePanel({
open, open,
onOpenChange, onOpenChange,
@@ -529,16 +37,11 @@ export function LauncherResourcePanel({
<SidePanelContent> <SidePanelContent>
<SidePanelHeader> <SidePanelHeader>
<SidePanelTitle>{resource?.name ?? ""}</SidePanelTitle> <SidePanelTitle>{resource?.name ?? ""}</SidePanelTitle>
<SidePanelDescription>
{t("resourceLauncherResourceDetailsDescription")}
</SidePanelDescription>
</SidePanelHeader> </SidePanelHeader>
<SidePanelBody> <SidePanelBody />
{resource ? (
<LauncherResourcePanelBody
orgId={orgId}
resource={resource}
open={open}
/>
) : null}
</SidePanelBody>
<SidePanelFooter> <SidePanelFooter>
<Button <Button
variant="outline" variant="outline"

View File

@@ -1,86 +0,0 @@
"use client";
import { cn } from "@app/lib/cn";
import { Search } from "lucide-react";
import { useTranslations } from "next-intl";
type LauncherSearchFirstGateProps = {
layout: "grid" | "list";
};
function GhostResourceGrid() {
return (
<div
className="grid w-full grid-cols-1 gap-2.5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 [&>*]:min-w-0"
aria-hidden
>
{Array.from({ length: 4 }).map((_, index) => (
<div
key={index}
className="flex min-w-0 flex-col gap-2.5 rounded-xl border border-border/60 bg-muted/20 p-4"
>
<div className="flex items-center gap-5">
<div className="size-10 shrink-0 rounded-lg bg-muted/60" />
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="h-3.5 w-3/5 rounded bg-muted/60" />
<div className="h-3 w-2/5 rounded bg-muted/40" />
</div>
</div>
</div>
))}
</div>
);
}
function GhostResourceList() {
return (
<div className="flex w-full flex-col" aria-hidden>
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className={cn(
"flex items-center gap-4 px-4 py-3",
index < 2 && "border-b border-border/60"
)}
>
<div className="size-8 shrink-0 rounded-lg bg-muted/60" />
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="h-3.5 w-2/5 rounded bg-muted/60" />
<div className="h-3 w-1/4 rounded bg-muted/40" />
</div>
</div>
))}
</div>
);
}
export function LauncherSearchFirstGate({
layout
}: LauncherSearchFirstGateProps) {
const t = useTranslations();
return (
<div className="relative w-full overflow-hidden rounded-xl border border-dashed border-border">
<div className="pointer-events-none absolute inset-0 opacity-50">
{layout === "grid" ? (
<GhostResourceGrid />
) : (
<GhostResourceList />
)}
</div>
<div className="relative flex min-h-56 flex-col items-center justify-center gap-4 px-6 py-12 text-center">
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
<Search className="size-5 text-muted-foreground" />
</div>
<div className="max-w-md space-y-1.5">
<h3 className="text-base font-semibold text-foreground">
{t("resourceLauncherSearchFirstTitle")}
</h3>
<p className="text-sm text-muted-foreground">
{t("resourceLauncherSearchFirstDescription")}
</p>
</div>
</div>
</div>
);
}

View File

@@ -15,27 +15,22 @@ import {
SelectValue SelectValue
} from "@app/components/ui/select"; } from "@app/components/ui/select";
import { Switch } from "@app/components/ui/switch"; import { Switch } from "@app/components/ui/switch";
import type { import type { LauncherViewConfig } from "@server/routers/launcher/types";
LauncherScaleCapabilities,
LauncherViewConfig
} from "@server/routers/launcher/types";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Settings } from "lucide-react"; import { Settings } from "lucide-react";
type LauncherSettingsMenuProps = { type LauncherSettingsMenuProps = {
config: LauncherViewConfig; config: LauncherViewConfig;
capabilities: LauncherScaleCapabilities; isDefaultView: boolean;
isCompactMode: boolean;
selectedGroupBy: LauncherViewConfig["groupBy"];
onConfigChange: (patch: Partial<LauncherViewConfig>) => void; onConfigChange: (patch: Partial<LauncherViewConfig>) => void;
onDeleteView: () => void;
}; };
export function LauncherSettingsMenu({ export function LauncherSettingsMenu({
config, config,
capabilities, isDefaultView,
isCompactMode, onConfigChange,
selectedGroupBy, onDeleteView
onConfigChange
}: LauncherSettingsMenuProps) { }: LauncherSettingsMenuProps) {
const t = useTranslations(); const t = useTranslations();
@@ -56,7 +51,7 @@ export function LauncherSettingsMenu({
{t("resourceLauncherGroupBy")} {t("resourceLauncherGroupBy")}
</p> </p>
<Select <Select
value={selectedGroupBy} value={config.groupBy}
onValueChange={(value) => onValueChange={(value) =>
onConfigChange({ onConfigChange({
groupBy: groupBy:
@@ -68,46 +63,14 @@ export function LauncherSettingsMenu({
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="none"> <SelectItem value="site">
{t("resourceLauncherGroupByNone")}
</SelectItem>
<SelectItem
value="site"
disabled={
!capabilities.allowSiteGrouping ||
(isCompactMode &&
config.siteIds.length === 0)
}
>
{t("resourceLauncherGroupBySite")} {t("resourceLauncherGroupBySite")}
</SelectItem> </SelectItem>
<SelectItem <SelectItem value="label">
value="label"
disabled={
!capabilities.allowLabelGrouping ||
(isCompactMode &&
config.labelIds.length === 0)
}
>
{t("resourceLauncherGroupByLabel")} {t("resourceLauncherGroupByLabel")}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
{isCompactMode ? (
<p className="text-xs text-muted-foreground">
{t("resourceLauncherCompactGroupingHint")}
</p>
) : null}
{!isCompactMode && !capabilities.allowSiteGrouping ? (
<p className="text-xs text-muted-foreground">
{t("resourceLauncherSiteGroupingDisabled")}
</p>
) : null}
{!isCompactMode && !capabilities.allowLabelGrouping ? (
<p className="text-xs text-muted-foreground">
{t("resourceLauncherLabelGroupingDisabled")}
</p>
) : null}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -153,6 +116,16 @@ export function LauncherSettingsMenu({
/> />
</div> </div>
</div> </div>
{!isDefaultView ? (
<Button
variant="destructive"
className="w-full rounded-xl"
onClick={onDeleteView}
>
{t("resourceLauncherDeleteView")}
</Button>
) : null}
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>

View File

@@ -68,14 +68,11 @@ type LauncherSaveViewMenuProps = {
isAdmin: boolean; isAdmin: boolean;
isOrgWideView: boolean; isOrgWideView: boolean;
hasUnsavedChanges: boolean; hasUnsavedChanges: boolean;
canResetSystemDefault: boolean;
onSaveToCurrent: () => void; onSaveToCurrent: () => void;
onSaveAsNew: () => void; onSaveAsNew: () => void;
onSaveForEveryone: () => void; onSaveForEveryone: () => void;
onMakePersonal: () => void; onMakePersonal: () => void;
onResetView: () => void; onResetView: () => void;
onResetSystemDefault: () => void;
onDeleteView: () => void;
}; };
export function LauncherSaveViewMenu({ export function LauncherSaveViewMenu({
@@ -83,17 +80,13 @@ export function LauncherSaveViewMenu({
isAdmin, isAdmin,
isOrgWideView, isOrgWideView,
hasUnsavedChanges, hasUnsavedChanges,
canResetSystemDefault,
onSaveToCurrent, onSaveToCurrent,
onSaveAsNew, onSaveAsNew,
onSaveForEveryone, onSaveForEveryone,
onMakePersonal, onMakePersonal,
onResetView, onResetView
onResetSystemDefault,
onDeleteView
}: LauncherSaveViewMenuProps) { }: LauncherSaveViewMenuProps) {
const t = useTranslations(); const t = useTranslations();
const canDeleteView = !isDefaultView && (isAdmin || !isOrgWideView);
return ( return (
<DropdownMenu> <DropdownMenu>
@@ -115,26 +108,7 @@ export function LauncherSaveViewMenu({
<DropdownMenuSeparator /> <DropdownMenuSeparator />
</> </>
) : null} ) : null}
{isDefaultView && canResetSystemDefault ? ( {!isDefaultView && (isAdmin || !isOrgWideView) ? (
<>
<DropdownMenuItem onSelect={onResetSystemDefault}>
{t("resourceLauncherResetSystemDefault")}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
) : null}
{isDefaultView && hasUnsavedChanges ? (
<>
<DropdownMenuItem onSelect={onSaveToCurrent}>
{t("resourceLauncherSaveDefaultPersonal")}
</DropdownMenuItem>
{isAdmin ? (
<DropdownMenuItem onSelect={onSaveForEveryone}>
{t("resourceLauncherSaveForEveryone")}
</DropdownMenuItem>
) : null}
</>
) : !isDefaultView && (isAdmin || !isOrgWideView) ? (
<DropdownMenuItem onSelect={onSaveToCurrent}> <DropdownMenuItem onSelect={onSaveToCurrent}>
{t("resourceLauncherSaveToCurrentView")} {t("resourceLauncherSaveToCurrentView")}
</DropdownMenuItem> </DropdownMenuItem>
@@ -152,17 +126,6 @@ export function LauncherSaveViewMenu({
{t("resourceLauncherMakePersonal")} {t("resourceLauncherMakePersonal")}
</DropdownMenuItem> </DropdownMenuItem>
) : null} ) : null}
{canDeleteView ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={onDeleteView}
className="text-destructive focus:text-destructive"
>
{t("resourceLauncherDeleteView")}
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
); );

View File

@@ -29,23 +29,12 @@ import {
} from "@app/lib/launcherUrlState"; } from "@app/lib/launcherUrlState";
import { useToast } from "@app/hooks/useToast"; import { useToast } from "@app/hooks/useToast";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { import type {
getEffectiveLauncherConfig, LauncherGroup,
shouldShowFlatResourceList, LauncherViewConfig,
shouldShowLauncherGroupList, LauncherViewRecord
shouldShowSearchFirstGate
} from "@app/lib/launcherScale";
import { launcherQueries } from "@app/lib/queries";
import {
getEffectiveDefaultLauncherConfig,
type LauncherDefaultViewOverrides,
type LauncherGroup,
type LauncherResource,
type LauncherScaleInfo,
type LauncherViewConfig,
type LauncherViewRecord
} from "@server/routers/launcher/types"; } from "@server/routers/launcher/types";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { Search } from "lucide-react"; import { Search } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@@ -63,26 +52,20 @@ import type { SelectedLabel } from "@app/components/labels-selector";
import { useMediaQuery } from "@app/hooks/useMediaQuery"; import { useMediaQuery } from "@app/hooks/useMediaQuery";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import { LauncherFilterPopover } from "./LauncherFilterPopover"; import { LauncherFilterPopover } from "./LauncherFilterPopover";
import { LauncherFlatResourceList } from "./LauncherFlatResourceList";
import { LauncherGroupList } from "./LauncherGroupList"; import { LauncherGroupList } from "./LauncherGroupList";
import { LauncherSearchFirstGate } from "./LauncherSearchFirstGate";
import { LauncherRefreshButton } from "./LauncherRefreshButton"; import { LauncherRefreshButton } from "./LauncherRefreshButton";
import { LauncherResourcePanel } from "./LauncherResourcePanel";
import { LauncherSettingsMenu } from "./LauncherSettingsMenu"; import { LauncherSettingsMenu } from "./LauncherSettingsMenu";
import { LauncherSortButton } from "./LauncherSortButton"; import { LauncherSortButton } from "./LauncherSortButton";
import { LauncherSaveViewMenu, LauncherViewTabs } from "./LauncherViewTabs"; import { LauncherSaveViewMenu, LauncherViewTabs } from "./LauncherViewTabs";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
type ResourceLauncherProps = { type ResourceLauncherProps = {
orgId: string; orgId: string;
isAdmin: boolean; isAdmin: boolean;
views: LauncherViewRecord[]; views: LauncherViewRecord[];
defaultViewOverrides: LauncherDefaultViewOverrides;
activeViewId: LauncherActiveViewId; activeViewId: LauncherActiveViewId;
config: LauncherViewConfig; config: LauncherViewConfig;
savedConfig: LauncherViewConfig; savedConfig: LauncherViewConfig;
scale: LauncherScaleInfo;
groups: LauncherGroup[]; groups: LauncherGroup[];
groupsPagination: { groupsPagination: {
total: number; total: number;
@@ -95,11 +78,9 @@ export default function ResourceLauncher({
orgId, orgId,
isAdmin, isAdmin,
views, views,
defaultViewOverrides,
activeViewId, activeViewId,
config, config,
savedConfig, savedConfig,
scale: initialScale,
groups, groups,
groupsPagination groupsPagination
}: ResourceLauncherProps) { }: ResourceLauncherProps) {
@@ -107,7 +88,6 @@ export default function ResourceLauncher({
const { toast } = useToast(); const { toast } = useToast();
const { env } = useEnvContext(); const { env } = useEnvContext();
const api = createApiClient({ env }); const api = createApiClient({ env });
const queryClient = useQueryClient();
const router = useRouter(); const router = useRouter();
const { navigate, isNavigating, searchParams } = useNavigationContext(); const { navigate, isNavigating, searchParams } = useNavigationContext();
const [isRefreshing, startRefreshTransition] = useTransition(); const [isRefreshing, startRefreshTransition] = useTransition();
@@ -115,47 +95,11 @@ export default function ResourceLauncher({
const [searchInputResetKey, setSearchInputResetKey] = useState(0); const [searchInputResetKey, setSearchInputResetKey] = useState(0);
const [saveDialogOpen, setSaveDialogOpen] = useState(false); const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [newViewName, setNewViewName] = useState(""); const [newViewName, setNewViewName] = useState("");
const [saveOrgWide, setSaveOrgWide] = useState(false); const [saveOrgWide, setSaveOrgWide] = useState(false);
const [selectedResource, setSelectedResource] =
useState<LauncherResource | null>(null);
const [panelOpen, setPanelOpen] = useState(false);
const isDesktop = useMediaQuery("(min-width: 768px)"); const isDesktop = useMediaQuery("(min-width: 768px)");
const scaleFilters = useMemo(
() => ({
query: config.query,
groupBy: config.groupBy,
siteIds: config.siteIds,
labelIds: config.labelIds,
sort_by: config.sortBy,
order: config.order
}),
[
config.groupBy,
config.labelIds,
config.order,
config.query,
config.siteIds,
config.sortBy
]
);
const { data: scale = initialScale } = useQuery({
...launcherQueries.scale(orgId, scaleFilters),
initialData: initialScale
});
const showGroupList = shouldShowLauncherGroupList(scale, config);
const showSearchFirstGate = shouldShowSearchFirstGate(scale, config);
const showFlatResourceList = shouldShowFlatResourceList(scale, config);
const effectiveConfig = useMemo(
() => getEffectiveLauncherConfig(scale, config),
[config, scale]
);
const configRef = useRef(config); const configRef = useRef(config);
configRef.current = config; configRef.current = config;
const searchInputRef = useRef(config.query); const searchInputRef = useRef(config.query);
@@ -185,24 +129,13 @@ export default function ResourceLauncher({
return; return;
} }
const baseConfig = getLauncherUrlBaseConfig( const baseConfig = getLauncherUrlBaseConfig(lastView, views);
lastView,
views,
defaultViewOverrides
);
const params = serializeLauncherUrlState({ const params = serializeLauncherUrlState({
viewId: lastView, viewId: lastView,
config: baseConfig config: baseConfig
}); });
navigate({ searchParams: params, replace: true }); navigate({ searchParams: params, replace: true });
}, [ }, [activeViewId, navigate, orgId, searchParams, views]);
activeViewId,
defaultViewOverrides,
navigate,
orgId,
searchParams,
views
]);
const navigateToConfig = useCallback( const navigateToConfig = useCallback(
(viewId: LauncherActiveViewId, nextConfig: LauncherViewConfig) => { (viewId: LauncherActiveViewId, nextConfig: LauncherViewConfig) => {
@@ -225,14 +158,10 @@ export default function ResourceLauncher({
const selectView = useCallback( const selectView = useCallback(
(viewId: LauncherActiveViewId) => { (viewId: LauncherActiveViewId) => {
writeLauncherLastView(orgId, viewId); writeLauncherLastView(orgId, viewId);
const baseConfig = getLauncherUrlBaseConfig( const baseConfig = getLauncherUrlBaseConfig(viewId, views);
viewId,
views,
defaultViewOverrides
);
navigateToConfig(viewId, baseConfig); navigateToConfig(viewId, baseConfig);
}, },
[defaultViewOverrides, navigateToConfig, orgId, views] [navigateToConfig, orgId, views]
); );
const activeSavedView = useMemo( const activeSavedView = useMemo(
@@ -246,10 +175,6 @@ export default function ResourceLauncher({
const isDefaultView = activeViewId === "default"; const isDefaultView = activeViewId === "default";
const isOrgWideView = Boolean(activeSavedView?.isOrgWide); const isOrgWideView = Boolean(activeSavedView?.isOrgWide);
const hasUnsavedChanges = !isLauncherConfigEqual(config, savedConfig); const hasUnsavedChanges = !isLauncherConfigEqual(config, savedConfig);
const canResetSystemDefault =
isDefaultView &&
(Boolean(defaultViewOverrides.personal) ||
(isAdmin && Boolean(defaultViewOverrides.orgWide)));
const selectedSites: Selectedsite[] = useMemo( const selectedSites: Selectedsite[] = useMemo(
() => () =>
@@ -345,87 +270,6 @@ export default function ResourceLauncher({
} }
}); });
const saveDefaultViewMutation = useMutation({
mutationFn: async (payload: {
config: LauncherViewConfig;
orgWide: boolean;
}) => {
const res = await api.put(
`/org/${orgId}/launcher/default-view`,
payload
);
return res.data.data as LauncherViewRecord;
},
onSuccess: () => {
writeLauncherLastView(orgId, "default");
const params = serializeLauncherUrlState({
viewId: "default",
config
});
navigate({ searchParams: params, replace: true });
router.refresh();
toast({
title: t("resourceLauncherViewSaved"),
description: t("resourceLauncherViewSavedDescription")
});
},
onError: (error) => {
toast({
variant: "destructive",
title: t("resourceLauncherViewSaveFailed"),
description: formatAxiosError(
error,
t("resourceLauncherViewSaveFailedDescription")
)
});
}
});
const resetSystemDefaultMutation = useMutation({
mutationFn: async () => {
const resetAll = isAdmin && Boolean(defaultViewOverrides.orgWide);
await api.delete(`/org/${orgId}/launcher/default-view`, {
data: resetAll ? { all: true } : { orgWide: false }
});
},
onSuccess: () => {
writeLauncherLastView(orgId, "default");
const nextOverrides: LauncherDefaultViewOverrides = {
personal: null,
orgWide:
isAdmin && defaultViewOverrides.orgWide
? null
: defaultViewOverrides.orgWide
};
const targetConfig =
getEffectiveDefaultLauncherConfig(nextOverrides);
searchInputRef.current = targetConfig.query;
setSearchInputResetKey((key) => key + 1);
const params = serializeLauncherUrlState({
viewId: "default",
config: targetConfig
});
navigate({ searchParams: params, replace: true });
router.refresh();
toast({
title: t("resourceLauncherSystemDefaultRestored"),
description: t(
"resourceLauncherSystemDefaultRestoredDescription"
)
});
},
onError: (error) => {
toast({
variant: "destructive",
title: t("resourceLauncherViewSaveFailed"),
description: formatAxiosError(
error,
t("resourceLauncherViewSaveFailedDescription")
)
});
}
});
const deleteViewMutation = useMutation({ const deleteViewMutation = useMutation({
mutationFn: async (viewId: number) => { mutationFn: async (viewId: number) => {
await api.delete(`/org/${orgId}/launcher/views/${viewId}`); await api.delete(`/org/${orgId}/launcher/views/${viewId}`);
@@ -434,11 +278,7 @@ export default function ResourceLauncher({
writeLauncherLastView(orgId, "default"); writeLauncherLastView(orgId, "default");
const params = serializeLauncherUrlState({ const params = serializeLauncherUrlState({
viewId: "default", viewId: "default",
config: getLauncherUrlBaseConfig( config: getLauncherUrlBaseConfig("default", views)
"default",
views,
defaultViewOverrides
)
}); });
navigate({ searchParams: params, replace: true }); navigate({ searchParams: params, replace: true });
router.refresh(); router.refresh();
@@ -488,17 +328,9 @@ export default function ResourceLauncher({
navigateToConfig(activeViewIdRef.current, savedConfig); navigateToConfig(activeViewIdRef.current, savedConfig);
}, [navigateToConfig, savedConfig]); }, [navigateToConfig, savedConfig]);
const handleResetSystemDefault = useCallback(() => {
resetSystemDefaultMutation.mutate();
}, [resetSystemDefaultMutation]);
const refreshData = () => { const refreshData = () => {
startRefreshTransition(async () => { startRefreshTransition(async () => {
try { try {
await api.post(`/org/${orgId}/launcher/invalidate-cache`);
await queryClient.invalidateQueries({
queryKey: ["ORG", orgId, "LAUNCHER"]
});
router.refresh(); router.refresh();
} catch { } catch {
toast({ toast({
@@ -511,14 +343,7 @@ export default function ResourceLauncher({
}; };
const handleSaveToCurrent = () => { const handleSaveToCurrent = () => {
if (isDefaultView) { if (isDefaultView || (isOrgWideView && !isAdmin)) {
saveDefaultViewMutation.mutate({
config,
orgWide: false
});
return;
}
if (isOrgWideView && !isAdmin) {
return; return;
} }
updateViewMutation.mutate({ updateViewMutation.mutate({
@@ -535,10 +360,6 @@ export default function ResourceLauncher({
const handleSaveForEveryone = () => { const handleSaveForEveryone = () => {
if (isDefaultView) { if (isDefaultView) {
saveDefaultViewMutation.mutate({
config,
orgWide: true
});
return; return;
} }
updateViewMutation.mutate({ updateViewMutation.mutate({
@@ -568,18 +389,6 @@ export default function ResourceLauncher({
}); });
}; };
const handleResourceSelect = useCallback((resource: LauncherResource) => {
setSelectedResource(resource);
setPanelOpen(true);
}, []);
const handlePanelOpenChange = useCallback((open: boolean) => {
setPanelOpen(open);
if (!open) {
setSelectedResource(null);
}
}, []);
const savedViewTabs = views.map((view) => ({ const savedViewTabs = views.map((view) => ({
viewId: view.viewId, viewId: view.viewId,
name: view.name name: view.name
@@ -603,8 +412,19 @@ export default function ResourceLauncher({
</div> </div>
); );
const renderToolbarFilterSort = () => ( const renderToolbarActions = () => (
<> <>
<LauncherSaveViewMenu
isDefaultView={isDefaultView}
isAdmin={isAdmin}
isOrgWideView={isOrgWideView}
hasUnsavedChanges={hasUnsavedChanges}
onSaveToCurrent={handleSaveToCurrent}
onSaveAsNew={handleSaveAsNew}
onSaveForEveryone={handleSaveForEveryone}
onMakePersonal={handleMakePersonal}
onResetView={handleResetView}
/>
<LauncherFilterPopover <LauncherFilterPopover
orgId={orgId} orgId={orgId}
selectedSites={selectedSites} selectedSites={selectedSites}
@@ -628,31 +448,15 @@ export default function ResourceLauncher({
}) })
} }
/> />
</>
);
const renderToolbarActions = () => (
<>
<LauncherSaveViewMenu
isDefaultView={isDefaultView}
isAdmin={isAdmin}
isOrgWideView={isOrgWideView}
hasUnsavedChanges={hasUnsavedChanges}
canResetSystemDefault={canResetSystemDefault}
onSaveToCurrent={handleSaveToCurrent}
onSaveAsNew={handleSaveAsNew}
onSaveForEveryone={handleSaveForEveryone}
onMakePersonal={handleMakePersonal}
onResetView={handleResetView}
onResetSystemDefault={handleResetSystemDefault}
onDeleteView={() => setDeleteDialogOpen(true)}
/>
<LauncherSettingsMenu <LauncherSettingsMenu
config={config} config={config}
capabilities={scale.capabilities} isDefaultView={isDefaultView}
isCompactMode={scale.mode === "compact"}
selectedGroupBy={effectiveConfig.groupBy}
onConfigChange={applyConfigPatch} onConfigChange={applyConfigPatch}
onDeleteView={() => {
if (!isDefaultView) {
deleteViewMutation.mutate(activeViewId);
}
}}
/> />
<LauncherRefreshButton <LauncherRefreshButton
onRefresh={refreshData} onRefresh={refreshData}
@@ -679,9 +483,6 @@ export default function ResourceLauncher({
{isDesktop ? ( {isDesktop ? (
<div className="mb-6 flex w-full min-w-0 items-center gap-3"> <div className="mb-6 flex w-full min-w-0 items-center gap-3">
{renderToolbarSearch("w-64")} {renderToolbarSearch("w-64")}
<div className="flex shrink-0 items-center gap-2">
{renderToolbarFilterSort()}
</div>
<div className="min-w-0 flex-1 overflow-x-auto"> <div className="min-w-0 flex-1 overflow-x-auto">
{renderToolbarViews()} {renderToolbarViews()}
</div> </div>
@@ -694,64 +495,22 @@ export default function ResourceLauncher({
<div className="flex items-center gap-2 overflow-x-auto"> <div className="flex items-center gap-2 overflow-x-auto">
{renderToolbarActions()} {renderToolbarActions()}
</div> </div>
<div className="flex items-center gap-2"> {renderToolbarSearch("w-full")}
<div className="min-w-0 flex-1">
{renderToolbarSearch("w-full")}
</div>
{renderToolbarFilterSort()}
</div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
{renderToolbarViews()} {renderToolbarViews()}
</div> </div>
</div> </div>
)} )}
{showSearchFirstGate ? ( <LauncherGroupList
<LauncherSearchFirstGate layout={config.layout} />
) : showGroupList ? (
<LauncherGroupList
orgId={orgId}
activeViewId={activeViewId}
config={effectiveConfig}
initialGroups={groups}
groupsPagination={groupsPagination}
onClearFilters={handleClearFilters}
onResourceSelect={handleResourceSelect}
/>
) : showFlatResourceList ? (
<LauncherFlatResourceList
orgId={orgId}
activeViewId={activeViewId}
config={effectiveConfig}
onClearFilters={handleClearFilters}
onResourceSelect={handleResourceSelect}
/>
) : null}
<LauncherResourcePanel
open={panelOpen}
onOpenChange={handlePanelOpenChange}
resource={selectedResource}
orgId={orgId} orgId={orgId}
isAdmin={isAdmin} activeViewId={activeViewId}
config={config}
initialGroups={groups}
groupsPagination={groupsPagination}
onClearFilters={handleClearFilters}
/> />
{activeSavedView ? (
<ConfirmDeleteDialog
open={deleteDialogOpen}
setOpen={setDeleteDialogOpen}
string={activeSavedView.name}
title={t("resourceLauncherDeleteViewTitle")}
buttonText={t("resourceLauncherDeleteViewConfirm")}
dialog={<p>{t("resourceLauncherDeleteViewQuestion")}</p>}
onConfirm={async () => {
await deleteViewMutation.mutateAsync(
activeSavedView.viewId
);
}}
/>
) : null}
<Credenza open={saveDialogOpen} onOpenChange={setSaveDialogOpen}> <Credenza open={saveDialogOpen} onOpenChange={setSaveDialogOpen}>
<CredenzaContent> <CredenzaContent>
<CredenzaHeader> <CredenzaHeader>

1
src/lib/consts.ts Normal file
View File

@@ -0,0 +1 @@
export const LAST_USED_IDP_COOKIE_NAME = "p__last_used_idp";

View File

@@ -10,7 +10,7 @@ function lastViewKey(orgId: string) {
function groupOpenKey( function groupOpenKey(
orgId: string, orgId: string,
viewId: LauncherActiveViewId, viewId: LauncherActiveViewId,
groupBy: "site" | "label" | "none" groupBy: "site" | "label"
) { ) {
return `${GROUP_OPEN_PREFIX}${orgId}:${viewId}:${groupBy}`; return `${GROUP_OPEN_PREFIX}${orgId}:${viewId}:${groupBy}`;
} }
@@ -64,7 +64,7 @@ export function writeLauncherLastView(
export function readLauncherGroupOpenState( export function readLauncherGroupOpenState(
orgId: string, orgId: string,
viewId: LauncherActiveViewId, viewId: LauncherActiveViewId,
groupBy: "site" | "label" | "none" groupBy: "site" | "label"
): Record<string, boolean> { ): Record<string, boolean> {
return readJson<Record<string, boolean>>( return readJson<Record<string, boolean>>(
groupOpenKey(orgId, viewId, groupBy), groupOpenKey(orgId, viewId, groupBy),
@@ -75,7 +75,7 @@ export function readLauncherGroupOpenState(
export function readLauncherGroupOpen( export function readLauncherGroupOpen(
orgId: string, orgId: string,
viewId: LauncherActiveViewId, viewId: LauncherActiveViewId,
groupBy: "site" | "label" | "none", groupBy: "site" | "label",
groupKey: string, groupKey: string,
defaultOpen: boolean defaultOpen: boolean
): boolean { ): boolean {
@@ -86,7 +86,7 @@ export function readLauncherGroupOpen(
export function writeLauncherGroupOpen( export function writeLauncherGroupOpen(
orgId: string, orgId: string,
viewId: LauncherActiveViewId, viewId: LauncherActiveViewId,
groupBy: "site" | "label" | "none", groupBy: "site" | "label",
groupKey: string, groupKey: string,
isOpen: boolean isOpen: boolean
) { ) {

View File

@@ -1,93 +0,0 @@
import type { GetResourceResponse } from "@server/routers/resource/getResource";
import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo";
import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource";
export type PublicAuthState = "protected" | "not_protected" | "none";
const BROWSER_MODES = ["http", "ssh", "rdp", "vnc"];
export function derivePublicAuthState(
mode: string | null,
authInfo: Pick<
GetResourceAuthInfoResponse,
"password" | "pincode" | "sso" | "whitelist" | "headerAuth"
>
): PublicAuthState {
if (!BROWSER_MODES.includes(mode || "")) {
return "none";
}
if (
authInfo.password ||
authInfo.pincode ||
authInfo.sso ||
authInfo.whitelist ||
authInfo.headerAuth
) {
return "protected";
}
return "not_protected";
}
export function formatPublicResourceType(
resource: Pick<GetResourceResponse, "mode" | "ssl">
): string {
if (resource.mode === "http") {
return resource.ssl ? "HTTPS" : "HTTP";
}
const mode = (resource.mode || "").toLowerCase();
if (mode === "tcp") {
return "TCP";
}
if (mode === "udp") {
return "UDP";
}
return (resource.mode || "—").toUpperCase();
}
export type PortProtocolState = "all" | "blocked" | "custom";
function getPortProtocolState(
value: string | null | undefined
): PortProtocolState {
if (value === "*") {
return "all";
}
if (!value || value.trim() === "") {
return "blocked";
}
return "custom";
}
export type PortRestrictionDisplay = {
hasNonDefaultPorts: boolean;
tcp: { state: PortProtocolState; ports: string | null };
udp: { state: PortProtocolState; ports: string | null };
};
export function formatPortRestrictionDisplay(
resource: Pick<
GetSiteResourceResponse,
"tcpPortRangeString" | "udpPortRangeString"
>
): PortRestrictionDisplay {
const tcpState = getPortProtocolState(resource.tcpPortRangeString);
const udpState = getPortProtocolState(resource.udpPortRangeString);
return {
hasNonDefaultPorts: tcpState !== "all" || udpState !== "all",
tcp: {
state: tcpState,
ports: tcpState === "custom" ? resource.tcpPortRangeString : null
},
udp: {
state: udpState,
ports: udpState === "custom" ? resource.udpPortRangeString : null
}
};
}

View File

@@ -1,114 +0,0 @@
import type {
LauncherScaleInfo,
LauncherViewConfig
} from "@server/routers/launcher/types";
export function hasActiveLauncherFilters(config: LauncherViewConfig): boolean {
return (
config.query.trim().length > 0 ||
config.siteIds.length > 0 ||
config.labelIds.length > 0
);
}
export function getEffectiveGroupBy(
scale: LauncherScaleInfo,
config: LauncherViewConfig
): LauncherViewConfig["groupBy"] {
if (scale.mode !== "compact") {
return config.groupBy;
}
if (
config.groupBy === "site" &&
config.siteIds.length > 0 &&
scale.capabilities.allowSiteGrouping
) {
return "site";
}
if (
config.groupBy === "label" &&
config.labelIds.length > 0 &&
scale.capabilities.allowLabelGrouping
) {
return "label";
}
return "none";
}
export function getEffectiveLauncherConfig(
scale: LauncherScaleInfo,
config: LauncherViewConfig
): LauncherViewConfig {
const groupBy = getEffectiveGroupBy(scale, config);
if (groupBy === config.groupBy) {
return config;
}
return { ...config, groupBy };
}
export function shouldFetchLauncherGroups(
scale: LauncherScaleInfo,
config: LauncherViewConfig
): boolean {
const groupBy = getEffectiveGroupBy(scale, config);
if (groupBy === "none") {
return false;
}
if (scale.mode === "full") {
return true;
}
return (
(scale.capabilities.allowSiteGrouping &&
groupBy === "site" &&
config.siteIds.length > 0) ||
(scale.capabilities.allowLabelGrouping &&
groupBy === "label" &&
config.labelIds.length > 0)
);
}
export function shouldShowLauncherGroupList(
scale: LauncherScaleInfo,
config: LauncherViewConfig
): boolean {
return shouldFetchLauncherGroups(scale, config);
}
export function shouldShowSearchFirstGate(
scale: LauncherScaleInfo,
config: LauncherViewConfig
): boolean {
return (
scale.mode === "compact" &&
scale.capabilities.requireSearchOrFilter &&
!hasActiveLauncherFilters(config)
);
}
export function shouldShowFlatResourceList(
scale: LauncherScaleInfo,
config: LauncherViewConfig
): boolean {
if (shouldShowSearchFirstGate(scale, config)) {
return false;
}
const groupBy = getEffectiveGroupBy(scale, config);
if (groupBy === "none") {
return true;
}
if (scale.mode === "full") {
return false;
}
return !shouldShowLauncherGroupList(scale, config);
}

View File

@@ -1,27 +1,21 @@
import { internal } from "@app/lib/api"; import { internal } from "@app/lib/api";
import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage"; import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage";
import { shouldFetchLauncherGroups } from "@app/lib/launcherScale";
import { resolveLauncherStateFromUrl } from "@app/lib/launcherUrlState"; import { resolveLauncherStateFromUrl } from "@app/lib/launcherUrlState";
import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams"; import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
import type { import type {
LauncherGroup, LauncherGroup,
LauncherScaleInfo,
LauncherDefaultViewOverrides,
LauncherViewConfig, LauncherViewConfig,
LauncherViewRecord, LauncherViewRecord,
ListLauncherGroupsResponse, ListLauncherGroupsResponse,
ListLauncherScaleResponse,
ListLauncherViewsResponse ListLauncherViewsResponse
} from "@server/routers/launcher/types"; } from "@server/routers/launcher/types";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
export type LauncherPageData = { export type LauncherPageData = {
views: LauncherViewRecord[]; views: LauncherViewRecord[];
defaultViewOverrides: LauncherDefaultViewOverrides;
activeViewId: LauncherActiveViewId; activeViewId: LauncherActiveViewId;
config: LauncherViewConfig; config: LauncherViewConfig;
savedConfig: LauncherViewConfig; savedConfig: LauncherViewConfig;
scale: LauncherScaleInfo;
groups: LauncherGroup[]; groups: LauncherGroup[];
groupsPagination: { groupsPagination: {
total: number; total: number;
@@ -38,56 +32,19 @@ export async function fetchLauncherPageData(
> >
): Promise<LauncherPageData> { ): Promise<LauncherPageData> {
let views: LauncherViewRecord[] = []; let views: LauncherViewRecord[] = [];
let defaultViewOverrides: LauncherDefaultViewOverrides = {
personal: null,
orgWide: null
};
try { try {
const viewsRes = await internal.get< const viewsRes = await internal.get<
AxiosResponse<ListLauncherViewsResponse> AxiosResponse<ListLauncherViewsResponse>
>(`/org/${orgId}/launcher/views`, cookieHeader); >(`/org/${orgId}/launcher/views`, cookieHeader);
views = viewsRes.data.data.views; views = viewsRes.data.data.views;
defaultViewOverrides = viewsRes.data.data.defaultViewOverrides;
} catch (e) {} } catch (e) {}
const { activeViewId, config, savedConfig } = resolveLauncherStateFromUrl( const { activeViewId, config, savedConfig } = resolveLauncherStateFromUrl(
searchParams, searchParams,
views, views,
null, null
defaultViewOverrides
); );
const scaleFilters = {
query: config.query,
groupBy: config.groupBy,
siteIds: config.siteIds,
labelIds: config.labelIds,
sort_by: config.sortBy,
order: config.order
};
let scale: LauncherScaleInfo = {
mode: "full",
resourceCount: 0,
siteGroupCount: 0,
labelGroupCount: 0,
capabilities: {
allowSiteGrouping: true,
allowLabelGrouping: true,
requireSearchOrFilter: false
}
};
try {
const sp = buildLauncherSearchParams(scaleFilters, 1);
sp.delete("page");
sp.delete("pageSize");
const scaleRes = await internal.get<
AxiosResponse<ListLauncherScaleResponse>
>(`/org/${orgId}/launcher/scale?${sp.toString()}`, cookieHeader);
scale = scaleRes.data.data.scale;
} catch (e) {}
const groupFilters = { const groupFilters = {
query: config.query, query: config.query,
groupBy: config.groupBy, groupBy: config.groupBy,
@@ -105,24 +62,20 @@ export async function fetchLauncherPageData(
pageSize: 20 pageSize: 20
}; };
if (shouldFetchLauncherGroups(scale, config)) { try {
try { const sp = buildLauncherSearchParams(groupFilters, 1);
const sp = buildLauncherSearchParams(groupFilters, 1); const groupsRes = await internal.get<
const groupsRes = await internal.get< AxiosResponse<ListLauncherGroupsResponse>
AxiosResponse<ListLauncherGroupsResponse> >(`/org/${orgId}/launcher/groups?${sp.toString()}`, cookieHeader);
>(`/org/${orgId}/launcher/groups?${sp.toString()}`, cookieHeader); groups = groupsRes.data.data.groups;
groups = groupsRes.data.data.groups; groupsPagination = groupsRes.data.data.pagination;
groupsPagination = groupsRes.data.data.pagination; } catch (e) {}
} catch (e) {}
}
return { return {
views, views,
defaultViewOverrides,
activeViewId, activeViewId,
config, config,
savedConfig, savedConfig,
scale,
groups, groups,
groupsPagination groupsPagination
}; };

View File

@@ -1,9 +1,7 @@
import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage"; import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage";
import { import {
defaultLauncherViewConfig, defaultLauncherViewConfig,
getEffectiveDefaultLauncherConfig,
parseIdListParam, parseIdListParam,
type LauncherDefaultViewOverrides,
type LauncherViewConfig, type LauncherViewConfig,
type LauncherViewRecord type LauncherViewRecord
} from "@server/routers/launcher/types"; } from "@server/routers/launcher/types";
@@ -66,13 +64,10 @@ export function isLauncherConfigEqual(
export function getLauncherUrlBaseConfig( export function getLauncherUrlBaseConfig(
viewId: LauncherActiveViewId, viewId: LauncherActiveViewId,
views: LauncherViewRecord[], views: LauncherViewRecord[]
defaultViewOverrides?: LauncherDefaultViewOverrides
): LauncherViewConfig { ): LauncherViewConfig {
if (viewId === "default") { if (viewId === "default") {
return defaultViewOverrides return defaultLauncherViewConfig;
? getEffectiveDefaultLauncherConfig(defaultViewOverrides)
: defaultLauncherViewConfig;
} }
const savedView = views.find((view) => view.viewId === viewId); const savedView = views.find((view) => view.viewId === viewId);
@@ -118,7 +113,7 @@ function parseConfigOverrides(
} }
const groupBy = searchParams.get("groupBy"); const groupBy = searchParams.get("groupBy");
if (groupBy === "site" || groupBy === "label" || groupBy === "none") { if (groupBy === "site" || groupBy === "label") {
overrides.groupBy = groupBy; overrides.groupBy = groupBy;
} }
@@ -177,8 +172,7 @@ function isValidActiveViewId(
export function resolveLauncherStateFromUrl( export function resolveLauncherStateFromUrl(
searchParams: URLSearchParams, searchParams: URLSearchParams,
views: LauncherViewRecord[], views: LauncherViewRecord[],
fallbackViewId: LauncherActiveViewId | null, fallbackViewId: LauncherActiveViewId | null
defaultViewOverrides?: LauncherDefaultViewOverrides
): ResolvedLauncherState { ): ResolvedLauncherState {
const parsed = parseLauncherUrlState(searchParams); const parsed = parseLauncherUrlState(searchParams);
@@ -194,26 +188,18 @@ export function resolveLauncherStateFromUrl(
: "default"; : "default";
} }
const savedConfig = getLauncherUrlBaseConfig( const savedConfig = getLauncherUrlBaseConfig(activeViewId, views);
activeViewId,
views,
defaultViewOverrides
);
let config: LauncherViewConfig; let config: LauncherViewConfig;
if (hasLauncherConfigParams(searchParams)) { if (hasLauncherConfigParams(searchParams)) {
config = resolveLauncherConfig( config = resolveLauncherConfig(
getEffectiveDefaultLauncherConfig( defaultLauncherViewConfig,
defaultViewOverrides ?? { personal: null, orgWide: null }
),
parsed.configOverrides parsed.configOverrides
); );
} else if (activeViewId !== "default") { } else if (activeViewId !== "default") {
config = savedConfig; config = savedConfig;
} else { } else {
config = getEffectiveDefaultLauncherConfig( config = defaultLauncherViewConfig;
defaultViewOverrides ?? { personal: null, orgWide: null }
);
} }
return { return {

View File

@@ -50,16 +50,11 @@ import type {
ListLauncherGroupsResponse, ListLauncherGroupsResponse,
ListLauncherLabelsResponse, ListLauncherLabelsResponse,
ListLauncherResourcesResponse, ListLauncherResourcesResponse,
ListLauncherScaleResponse,
ListLauncherSitesResponse, ListLauncherSitesResponse,
ListLauncherViewsResponse, ListLauncherViewsResponse,
LauncherListQuery, LauncherListQuery,
LauncherResource,
LauncherViewConfig LauncherViewConfig
} from "@server/routers/launcher/types"; } 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 type { LauncherQueryFilters } from "@app/lib/launcherSearchParams";
import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams"; import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
@@ -1194,13 +1189,13 @@ export const launcherQueries = {
const res = await meta!.api.get< const res = await meta!.api.get<
AxiosResponse<ListLauncherViewsResponse> AxiosResponse<ListLauncherViewsResponse>
>(`/org/${orgId}/launcher/views`, { signal }); >(`/org/${orgId}/launcher/views`, { signal });
return res.data.data; return res.data.data.views;
} }
}), }),
sites: ({ sites: ({
orgId, orgId,
query, query,
perPage = 20 perPage = 500
}: { }: {
orgId: string; orgId: string;
query?: string; query?: string;
@@ -1232,7 +1227,7 @@ export const launcherQueries = {
labels: ({ labels: ({
orgId, orgId,
query, query,
perPage = 20 perPage = 500
}: { }: {
orgId: string; orgId: string;
query?: string; query?: string;
@@ -1303,62 +1298,5 @@ export const launcherQueries = {
const nextPage = page + 1; const nextPage = page + 1;
return page * pageSize < total ? nextPage : undefined; return page * pageSize < total ? nextPage : undefined;
} }
}),
scale: (orgId: string, filters: LauncherQueryFilters) =>
queryOptions({
queryKey: ["ORG", orgId, "LAUNCHER", "SCALE", filters] as const,
queryFn: async ({ signal, meta }) => {
const sp = buildLauncherSearchParams(filters, 1);
sp.delete("page");
sp.delete("pageSize");
sp.delete("groupKey");
const res = await meta!.api.get<
AxiosResponse<ListLauncherScaleResponse>
>(`/org/${orgId}/launcher/scale?${sp.toString()}`, { signal });
return res.data.data.scale;
}
}),
resourceDetail: (orgId: string, resource: LauncherResource | null) =>
queryOptions({
queryKey: [
"ORG",
orgId,
"LAUNCHER",
"RESOURCE_DETAIL",
resource?.launcherResourceKey ?? null
] as const,
enabled: resource != null,
queryFn: async ({ signal, meta }) => {
if (!resource) {
throw new Error("Resource is required");
}
if (resource.resourceType === "public") {
const res = await meta!.api.get<
AxiosResponse<GetResourceResponse>
>(`/org/${orgId}/resource/${resource.niceId}`, { signal });
const resourceData = res.data.data;
const authRes = await meta!.api.get<
AxiosResponse<GetResourceAuthInfoResponse>
>(`/resource/${resourceData.resourceGuid}/auth`, {
signal
});
return {
resourceType: "public" as const,
data: resourceData,
authInfo: authRes.data.data
};
}
const siteResourceId =
resource.siteResourceId ?? resource.resourceId;
const res = await meta!.api.get<
AxiosResponse<GetSiteResourceResponse>
>(`/org/${orgId}/site-resource/${siteResourceId}`, { signal });
return {
resourceType: "site" as const,
data: res.data.data
};
}
}) })
}; };

View File

@@ -0,0 +1,32 @@
/**
* Set a cookie on the client side in javascript code, not on the server
* @param name
* @param value
* @param days
* @param options
*/
export function setClientCookie(
name: string,
value: string,
options: {
days?: number;
path?: string;
secure?: boolean;
sameSite?: "Strict" | "Lax" | "None";
} = {}
): void {
let cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
if (options.days) {
const date = new Date();
date.setTime(date.getTime() + options.days * 864e5);
cookie += `; expires=${date.toUTCString()}`;
}
cookie += `; path=${options.path ?? "/"}`;
if (options.secure) cookie += "; Secure";
if (options.sameSite) cookie += `; SameSite=${options.sameSite}`;
document.cookie = cookie;
}