mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-08 15:14:52 +02:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c18936be7 | |||
| cf07cceb5d | |||
| c9cc9581b1 | |||
| eac7c67dcc | |||
| 633d9031af | |||
| 05dc558c4a |
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
alwaysApply: true
|
|
||||||
---
|
|
||||||
|
|
||||||
Don't write or edit migrations in `server/setup` unless specificall instructed to do so.
|
|
||||||
@@ -1401,7 +1401,6 @@
|
|||||||
"actionApplyBlueprint": "Apply Blueprint",
|
"actionApplyBlueprint": "Apply Blueprint",
|
||||||
"actionListBlueprints": "List Blueprints",
|
"actionListBlueprints": "List Blueprints",
|
||||||
"actionGetBlueprint": "Get Blueprint",
|
"actionGetBlueprint": "Get Blueprint",
|
||||||
"actionCreateOrgWideLauncherView": "Create Org-Wide Launcher View",
|
|
||||||
"setupToken": "Setup Token",
|
"setupToken": "Setup Token",
|
||||||
"setupTokenDescription": "Enter the setup token from the server console.",
|
"setupTokenDescription": "Enter the setup token from the server console.",
|
||||||
"setupTokenRequired": "Setup token is required",
|
"setupTokenRequired": "Setup token is required",
|
||||||
@@ -2078,7 +2077,6 @@
|
|||||||
"subnetPlaceholder": "Subnet",
|
"subnetPlaceholder": "Subnet",
|
||||||
"addressDescription": "The internal address of the client. Must fall within the organization's subnet.",
|
"addressDescription": "The internal address of the client. Must fall within the organization's subnet.",
|
||||||
"selectSites": "Select sites",
|
"selectSites": "Select sites",
|
||||||
"selectLabels": "Select labels",
|
|
||||||
"sitesDescription": "The client will have connectivity to the selected sites",
|
"sitesDescription": "The client will have connectivity to the selected sites",
|
||||||
"clientInstallOlm": "Install Machine Client",
|
"clientInstallOlm": "Install Machine Client",
|
||||||
"clientInstallOlmDescription": "Install the machine client for your system",
|
"clientInstallOlmDescription": "Install the machine client for your system",
|
||||||
@@ -2306,7 +2304,6 @@
|
|||||||
"createInternalResourceDialogSite": "Site",
|
"createInternalResourceDialogSite": "Site",
|
||||||
"selectSite": "Select site...",
|
"selectSite": "Select site...",
|
||||||
"multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# sites}}",
|
"multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# sites}}",
|
||||||
"labelsSelectorLabelsCount": "{count, plural, one {# label} other {# labels}}",
|
|
||||||
"noSitesFound": "No sites found.",
|
"noSitesFound": "No sites found.",
|
||||||
"createInternalResourceDialogProtocol": "Protocol",
|
"createInternalResourceDialogProtocol": "Protocol",
|
||||||
"createInternalResourceDialogTcp": "TCP",
|
"createInternalResourceDialogTcp": "TCP",
|
||||||
@@ -3545,55 +3542,6 @@
|
|||||||
"memberPortalEmailWhitelist": "Email Whitelist",
|
"memberPortalEmailWhitelist": "Email Whitelist",
|
||||||
"memberPortalResourceDisabled": "Resource Disabled",
|
"memberPortalResourceDisabled": "Resource Disabled",
|
||||||
"memberPortalShowingResources": "Showing {start}-{end} of {total} resources",
|
"memberPortalShowingResources": "Showing {start}-{end} of {total} resources",
|
||||||
"resourceLauncherTitle": "Resource Launcher",
|
|
||||||
"resourceLauncherDescription": "View resource details and launch them from one place",
|
|
||||||
"resourceLauncherSearchPlaceholder": "Search all sites...",
|
|
||||||
"resourceLauncherDefaultView": "Default",
|
|
||||||
"resourceLauncherSaveView": "Save View",
|
|
||||||
"resourceLauncherSaveToCurrentView": "Save to Current View",
|
|
||||||
"resourceLauncherResetView": "Reset View",
|
|
||||||
"resourceLauncherSaveAsNewView": "Save as New View",
|
|
||||||
"resourceLauncherSaveAsNewViewDescription": "Give this view a name to save your current filters and layout.",
|
|
||||||
"resourceLauncherSaveForEveryone": "Save for Everyone",
|
|
||||||
"resourceLauncherSaveForEveryoneDescription": "Share this view with all organization members. When unchecked, the view is only visible to you.",
|
|
||||||
"resourceLauncherMakePersonal": "Make Personal",
|
|
||||||
"resourceLauncherFilter": "Filter",
|
|
||||||
"resourceLauncherSort": "Sort",
|
|
||||||
"resourceLauncherSortAscending": "Sort ascending",
|
|
||||||
"resourceLauncherSortDescending": "Sort descending",
|
|
||||||
"resourceLauncherSettings": "Settings",
|
|
||||||
"resourceLauncherGroupBy": "Group By",
|
|
||||||
"resourceLauncherGroupBySite": "Site",
|
|
||||||
"resourceLauncherGroupByLabel": "Label",
|
|
||||||
"resourceLauncherLayout": "Layout",
|
|
||||||
"resourceLauncherLayoutGrid": "Grid",
|
|
||||||
"resourceLauncherLayoutList": "List",
|
|
||||||
"resourceLauncherShowLabels": "Show Labels",
|
|
||||||
"resourceLauncherShowSiteTags": "Show Site Tags",
|
|
||||||
"resourceLauncherShowRecents": "Show Recents",
|
|
||||||
"resourceLauncherDeleteView": "Delete View",
|
|
||||||
"resourceLauncherViewAsAdmin": "View as Admin",
|
|
||||||
"resourceLauncherResourceDetailsDescription": "View details for this resource.",
|
|
||||||
"resourceLauncherUnlabeled": "Unlabeled",
|
|
||||||
"resourceLauncherNoSite": "No Site",
|
|
||||||
"resourceLauncherNoResourcesInGroup": "No resources in this group",
|
|
||||||
"resourceLauncherEmptyStateTitle": "No Resources Available",
|
|
||||||
"resourceLauncherEmptyStateDescription": "You don't have access to any resources yet. Contact your administrator to request access.",
|
|
||||||
"resourceLauncherEmptyStateNoResultsTitle": "No Resources Found",
|
|
||||||
"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.",
|
|
||||||
"resourceLauncherCopiedToClipboard": "Copied to clipboard",
|
|
||||||
"resourceLauncherCopiedAccessDescription": "Resource access has been copied to your clipboard.",
|
|
||||||
"resourceLauncherViewNamePlaceholder": "View name",
|
|
||||||
"resourceLauncherViewNameLabel": "View Name",
|
|
||||||
"resourceLauncherViewSaved": "View saved",
|
|
||||||
"resourceLauncherViewSavedDescription": "Your launcher view has been saved.",
|
|
||||||
"resourceLauncherViewSaveFailed": "Failed to save view",
|
|
||||||
"resourceLauncherViewSaveFailedDescription": "Could not save the launcher view. Please try again.",
|
|
||||||
"resourceLauncherViewDeleted": "View deleted",
|
|
||||||
"resourceLauncherViewDeletedDescription": "The launcher view has been deleted.",
|
|
||||||
"resourceLauncherViewDeleteFailed": "Failed to delete view",
|
|
||||||
"resourceLauncherViewDeleteFailedDescription": "Could not delete the launcher view. Please try again.",
|
|
||||||
"memberPortalPrevious": "Previous",
|
"memberPortalPrevious": "Previous",
|
||||||
"memberPortalNext": "Next",
|
"memberPortalNext": "Next",
|
||||||
"httpSettings": "HTTP Settings",
|
"httpSettings": "HTTP Settings",
|
||||||
|
|||||||
@@ -178,8 +178,7 @@ export enum ActionsEnum {
|
|||||||
setResourcePolicyPincode = "setResourcePolicyPincode",
|
setResourcePolicyPincode = "setResourcePolicyPincode",
|
||||||
setResourcePolicyHeaderAuth = "setResourcePolicyHeaderAuth",
|
setResourcePolicyHeaderAuth = "setResourcePolicyHeaderAuth",
|
||||||
setResourcePolicyWhitelist = "setResourcePolicyWhitelist",
|
setResourcePolicyWhitelist = "setResourcePolicyWhitelist",
|
||||||
setResourcePolicyRules = "setResourcePolicyRules",
|
setResourcePolicyRules = "setResourcePolicyRules"
|
||||||
createOrgWideLauncherView = "createOrgWideLauncherView"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkUserActionPermission(
|
export async function checkUserActionPermission(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
pgTable,
|
pgTable,
|
||||||
serial,
|
serial,
|
||||||
varchar,
|
varchar,
|
||||||
|
unique,
|
||||||
boolean,
|
boolean,
|
||||||
integer,
|
integer,
|
||||||
bigint,
|
bigint,
|
||||||
@@ -19,12 +20,13 @@ import {
|
|||||||
roles,
|
roles,
|
||||||
users,
|
users,
|
||||||
exitNodes,
|
exitNodes,
|
||||||
sessions,
|
|
||||||
clients,
|
|
||||||
resources,
|
resources,
|
||||||
siteResources,
|
siteResources,
|
||||||
targetHealthCheck,
|
targetHealthCheck,
|
||||||
sites
|
sites,
|
||||||
|
clients,
|
||||||
|
sessions,
|
||||||
|
labels
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
|
|
||||||
export const certificates = pgTable("certificates", {
|
export const certificates = pgTable("certificates", {
|
||||||
@@ -197,6 +199,42 @@ export const remoteExitNodes = pgTable("remoteExitNode", {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const remoteExitNodeResources = pgTable("remoteExitNodeResources", {
|
||||||
|
remoteExitNodeResourceId: serial("remoteExitNodeResourceId").primaryKey(),
|
||||||
|
remoteExitNodeId: varchar("remoteExitNodeId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => remoteExitNodes.remoteExitNodeId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
}),
|
||||||
|
destination: varchar("destination").notNull() // a cidr range
|
||||||
|
});
|
||||||
|
|
||||||
|
export const remoteExitNodePreferenceLabels = pgTable(
|
||||||
|
// this controls what sites are enforced to connect to this node
|
||||||
|
"remoteExitNodePreferenceLabels",
|
||||||
|
{
|
||||||
|
remoteExitNodePreferenceLabelId: serial(
|
||||||
|
"remoteExitNodePreferenceLabelId"
|
||||||
|
).primaryKey(),
|
||||||
|
remoteExitNodeId: varchar("remoteExitNodeId")
|
||||||
|
.references(() => remoteExitNodes.remoteExitNodeId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull(),
|
||||||
|
labelId: integer("labelId")
|
||||||
|
.references(() => labels.labelId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull()
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
unique("remote_exit_node_preference_label_uniq").on(
|
||||||
|
t.remoteExitNodeId,
|
||||||
|
t.labelId
|
||||||
|
)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
export const remoteExitNodeSessions = pgTable("remoteExitNodeSession", {
|
export const remoteExitNodeSessions = pgTable("remoteExitNodeSession", {
|
||||||
sessionId: varchar("id").primaryKey(),
|
sessionId: varchar("id").primaryKey(),
|
||||||
remoteExitNodeId: varchar("remoteExitNodeId")
|
remoteExitNodeId: varchar("remoteExitNodeId")
|
||||||
|
|||||||
@@ -218,20 +218,6 @@ export const labels = pgTable("labels", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const launcherViews = pgTable("launcherViews", {
|
|
||||||
viewId: serial("viewId").primaryKey(),
|
|
||||||
orgId: varchar("orgId")
|
|
||||||
.notNull()
|
|
||||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
|
||||||
userId: varchar("userId").references(() => users.userId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
}),
|
|
||||||
name: varchar("name").notNull(),
|
|
||||||
config: text("config").notNull(),
|
|
||||||
createdAt: varchar("createdAt").notNull(),
|
|
||||||
updatedAt: varchar("updatedAt").notNull()
|
|
||||||
});
|
|
||||||
|
|
||||||
export const siteLabels = pgTable(
|
export const siteLabels = pgTable(
|
||||||
"siteLabels",
|
"siteLabels",
|
||||||
{
|
{
|
||||||
@@ -1564,7 +1550,6 @@ export type RoundTripMessageTracker = InferSelectModel<
|
|||||||
export type Network = InferSelectModel<typeof networks>;
|
export type Network = InferSelectModel<typeof networks>;
|
||||||
export type StatusHistory = InferSelectModel<typeof statusHistory>;
|
export type StatusHistory = InferSelectModel<typeof statusHistory>;
|
||||||
export type Label = InferSelectModel<typeof labels>;
|
export type Label = InferSelectModel<typeof labels>;
|
||||||
export type LauncherView = InferSelectModel<typeof launcherViews>;
|
|
||||||
export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
|
export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
|
||||||
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
|
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
|
||||||
export type UserPolicy = InferSelectModel<typeof userPolicies>;
|
export type UserPolicy = InferSelectModel<typeof userPolicies>;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
clients,
|
clients,
|
||||||
domains,
|
domains,
|
||||||
exitNodes,
|
exitNodes,
|
||||||
|
labels,
|
||||||
orgs,
|
orgs,
|
||||||
resources,
|
resources,
|
||||||
roles,
|
roles,
|
||||||
@@ -21,9 +22,6 @@ import {
|
|||||||
targetHealthCheck,
|
targetHealthCheck,
|
||||||
users
|
users
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
import { serial, varchar } from "drizzle-orm/mysql-core";
|
|
||||||
import { pgTable } from "drizzle-orm/pg-core";
|
|
||||||
import { bigint } from "zod";
|
|
||||||
|
|
||||||
export const certificates = sqliteTable("certificates", {
|
export const certificates = sqliteTable("certificates", {
|
||||||
certId: integer("certId").primaryKey({ autoIncrement: true }),
|
certId: integer("certId").primaryKey({ autoIncrement: true }),
|
||||||
@@ -195,6 +193,44 @@ export const remoteExitNodes = sqliteTable("remoteExitNode", {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const remoteExitNodeResources = sqliteTable("remoteExitNodeResources", {
|
||||||
|
remoteExitNodeResourceId: integer("remoteExitNodeResourceId").primaryKey({
|
||||||
|
autoIncrement: true
|
||||||
|
}),
|
||||||
|
remoteExitNodeId: text("remoteExitNodeId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => remoteExitNodes.remoteExitNodeId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
}),
|
||||||
|
destination: text("destination").notNull() // a cidr range
|
||||||
|
});
|
||||||
|
|
||||||
|
export const remoteExitNodePreferenceLabels = sqliteTable(
|
||||||
|
// this controls what sites are enforced to connect to this node
|
||||||
|
"remoteExitNodePreferenceLabels",
|
||||||
|
{
|
||||||
|
remoteExitNodePreferenceLabelId: integer(
|
||||||
|
"remoteExitNodePreferenceLabelId"
|
||||||
|
).primaryKey({ autoIncrement: true }),
|
||||||
|
remoteExitNodeId: text("remoteExitNodeId")
|
||||||
|
.references(() => remoteExitNodes.remoteExitNodeId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull(),
|
||||||
|
labelId: integer("labelId")
|
||||||
|
.references(() => labels.labelId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull()
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex("remote_exit_node_preference_label_uniq").on(
|
||||||
|
t.remoteExitNodeId,
|
||||||
|
t.labelId
|
||||||
|
)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
export const remoteExitNodeSessions = sqliteTable("remoteExitNodeSession", {
|
export const remoteExitNodeSessions = sqliteTable("remoteExitNodeSession", {
|
||||||
sessionId: text("id").primaryKey(),
|
sessionId: text("id").primaryKey(),
|
||||||
remoteExitNodeId: text("remoteExitNodeId")
|
remoteExitNodeId: text("remoteExitNodeId")
|
||||||
|
|||||||
@@ -221,20 +221,6 @@ export const labels = sqliteTable("labels", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const launcherViews = sqliteTable("launcherViews", {
|
|
||||||
viewId: integer("viewId").primaryKey({ autoIncrement: true }),
|
|
||||||
orgId: text("orgId")
|
|
||||||
.notNull()
|
|
||||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
|
||||||
userId: text("userId").references(() => users.userId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
}),
|
|
||||||
name: text("name").notNull(),
|
|
||||||
config: text("config").notNull(),
|
|
||||||
createdAt: text("createdAt").notNull(),
|
|
||||||
updatedAt: text("updatedAt").notNull()
|
|
||||||
});
|
|
||||||
|
|
||||||
export const siteLabels = sqliteTable(
|
export const siteLabels = sqliteTable(
|
||||||
"siteLabels",
|
"siteLabels",
|
||||||
{
|
{
|
||||||
@@ -1563,7 +1549,6 @@ export type RoundTripMessageTracker = InferSelectModel<
|
|||||||
>;
|
>;
|
||||||
export type StatusHistory = InferSelectModel<typeof statusHistory>;
|
export type StatusHistory = InferSelectModel<typeof statusHistory>;
|
||||||
export type Label = InferSelectModel<typeof labels>;
|
export type Label = InferSelectModel<typeof labels>;
|
||||||
export type LauncherView = InferSelectModel<typeof launcherViews>;
|
|
||||||
export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
|
export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
|
||||||
export type ResourcePolicyPincode = InferSelectModel<
|
export type ResourcePolicyPincode = InferSelectModel<
|
||||||
typeof resourcePolicyPincode
|
typeof resourcePolicyPincode
|
||||||
|
|||||||
@@ -276,11 +276,12 @@ export class UsageService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const orgIdToUse = await this.getBillingOrg(orgId, trx);
|
let orgIdToUse = orgId;
|
||||||
|
|
||||||
const usageId = `${orgIdToUse}-${featureId}`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
orgIdToUse = await this.getBillingOrg(orgId, trx);
|
||||||
|
|
||||||
|
const usageId = `${orgIdToUse}-${featureId}`;
|
||||||
|
|
||||||
const [result] = await trx
|
const [result] = await trx
|
||||||
.select()
|
.select()
|
||||||
.from(usage)
|
.from(usage)
|
||||||
@@ -340,8 +341,12 @@ export class UsageService {
|
|||||||
`Failed to get usage for ${orgIdToUse}/${featureId}:`,
|
`Failed to get usage for ${orgIdToUse}/${featureId}:`,
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
throw error;
|
if (process.env.NODE_ENV !== "development") {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getBillingOrg(
|
public async getBillingOrg(
|
||||||
@@ -384,13 +389,13 @@ export class UsageService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const orgIdToUse = await this.getBillingOrg(orgId, trx);
|
|
||||||
|
|
||||||
// This method should check the current usage against the limits set for the organization
|
// This method should check the current usage against the limits set for the organization
|
||||||
// and kick out all of the sites on the org
|
// and kick out all of the sites on the org
|
||||||
let hasExceededLimits = false;
|
let hasExceededLimits = false;
|
||||||
|
let orgIdToUse = orgId;
|
||||||
try {
|
try {
|
||||||
|
orgIdToUse = await this.getBillingOrg(orgId, trx);
|
||||||
|
|
||||||
let orgLimits: Limit[] = [];
|
let orgLimits: Limit[] = [];
|
||||||
if (featureId) {
|
if (featureId) {
|
||||||
// Get all limits set for this organization
|
// Get all limits set for this organization
|
||||||
|
|||||||
@@ -18,12 +18,15 @@ import {
|
|||||||
resources,
|
resources,
|
||||||
targets,
|
targets,
|
||||||
sites,
|
sites,
|
||||||
|
siteLabels,
|
||||||
|
remoteExitNodes,
|
||||||
|
remoteExitNodePreferenceLabels,
|
||||||
targetHealthCheck,
|
targetHealthCheck,
|
||||||
Transaction
|
Transaction
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { ExitNodePingResult } from "@server/routers/newt";
|
import { ExitNodePingResult } from "@server/routers/newt";
|
||||||
import { eq, and, or, ne, isNull } from "drizzle-orm";
|
import { eq, and, or, ne, isNull, inArray } from "drizzle-orm";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import config from "../config";
|
import config from "../config";
|
||||||
|
|
||||||
@@ -150,7 +153,8 @@ export async function verifyExitNodeOrgAccess(
|
|||||||
export async function listExitNodes(
|
export async function listExitNodes(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
filterOnline = false,
|
filterOnline = false,
|
||||||
noCloud = false
|
noCloud = false,
|
||||||
|
siteId?: number
|
||||||
) {
|
) {
|
||||||
const allExitNodes = await db
|
const allExitNodes = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -237,7 +241,7 @@ export async function listExitNodes(
|
|||||||
// })
|
// })
|
||||||
// );
|
// );
|
||||||
|
|
||||||
const remoteExitNodes = allExitNodes.filter(
|
let remoteExitNodesList = allExitNodes.filter(
|
||||||
(node) =>
|
(node) =>
|
||||||
node.type === "remoteExitNode" && (!filterOnline || node.online)
|
node.type === "remoteExitNode" && (!filterOnline || node.online)
|
||||||
);
|
);
|
||||||
@@ -246,9 +250,82 @@ export async function listExitNodes(
|
|||||||
node.type === "gerbil" && (!filterOnline || node.online) && !noCloud
|
node.type === "gerbil" && (!filterOnline || node.online) && !noCloud
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Apply label-based filtering to remote exit nodes if siteId is provided
|
||||||
|
if (siteId !== undefined && remoteExitNodesList.length > 0) {
|
||||||
|
// Get the site's labels
|
||||||
|
const siteLabelRows = await db
|
||||||
|
.select({ labelId: siteLabels.labelId })
|
||||||
|
.from(siteLabels)
|
||||||
|
.where(eq(siteLabels.siteId, siteId));
|
||||||
|
const siteLabelIds = new Set(siteLabelRows.map((r) => r.labelId));
|
||||||
|
|
||||||
|
// Get the remoteExitNode records for these exit nodes so we have the remoteExitNodeId
|
||||||
|
const exitNodeIds = remoteExitNodesList.map((n) => n.exitNodeId);
|
||||||
|
const remoteNodeRows = await db
|
||||||
|
.select({
|
||||||
|
exitNodeId: remoteExitNodes.exitNodeId,
|
||||||
|
remoteExitNodeId: remoteExitNodes.remoteExitNodeId
|
||||||
|
})
|
||||||
|
.from(remoteExitNodes)
|
||||||
|
.where(inArray(remoteExitNodes.exitNodeId, exitNodeIds));
|
||||||
|
|
||||||
|
const exitNodeIdToRemoteId = new Map(
|
||||||
|
remoteNodeRows
|
||||||
|
.filter((r) => r.exitNodeId !== null)
|
||||||
|
.map((r) => [r.exitNodeId!, r.remoteExitNodeId])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get preference labels for all remote exit nodes
|
||||||
|
const remoteExitNodeIds = remoteNodeRows.map((r) => r.remoteExitNodeId);
|
||||||
|
const prefLabelRows =
|
||||||
|
remoteExitNodeIds.length > 0
|
||||||
|
? await db
|
||||||
|
.select({
|
||||||
|
remoteExitNodeId:
|
||||||
|
remoteExitNodePreferenceLabels.remoteExitNodeId,
|
||||||
|
labelId: remoteExitNodePreferenceLabels.labelId
|
||||||
|
})
|
||||||
|
.from(remoteExitNodePreferenceLabels)
|
||||||
|
.where(
|
||||||
|
inArray(
|
||||||
|
remoteExitNodePreferenceLabels.remoteExitNodeId,
|
||||||
|
remoteExitNodeIds
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Build a map of remoteExitNodeId -> Set of labelIds
|
||||||
|
const prefLabelsMap = new Map<string, Set<number>>();
|
||||||
|
for (const row of prefLabelRows) {
|
||||||
|
if (!prefLabelsMap.has(row.remoteExitNodeId)) {
|
||||||
|
prefLabelsMap.set(row.remoteExitNodeId, new Set());
|
||||||
|
}
|
||||||
|
prefLabelsMap.get(row.remoteExitNodeId)!.add(row.labelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter: include node if it has no preference labels, or if site shares at least one label
|
||||||
|
const filtered = remoteExitNodesList.filter((node) => {
|
||||||
|
const remoteId = exitNodeIdToRemoteId.get(node.exitNodeId);
|
||||||
|
if (!remoteId) return true; // no remoteExitNode record, don't filter
|
||||||
|
const prefLabels = prefLabelsMap.get(remoteId);
|
||||||
|
if (!prefLabels || prefLabels.size === 0) return true; // no preference labels, include
|
||||||
|
// include only if site has at least one matching label
|
||||||
|
for (const labelId of siteLabelIds) {
|
||||||
|
if (prefLabels.has(labelId)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only apply the filtered list if at least one remote node remains;
|
||||||
|
// otherwise fall through to the gerbil fallback below
|
||||||
|
if (filtered.length > 0 || remoteExitNodesList.length === 0) {
|
||||||
|
remoteExitNodesList = filtered;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// THIS PROVIDES THE FALL
|
// THIS PROVIDES THE FALL
|
||||||
const exitNodesList =
|
const exitNodesList =
|
||||||
remoteExitNodes.length > 0 ? remoteExitNodes : gerbilExitNodes;
|
remoteExitNodesList.length > 0 ? remoteExitNodesList : gerbilExitNodes;
|
||||||
|
|
||||||
return exitNodesList;
|
return exitNodesList;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -329,6 +329,44 @@ authenticated.delete(
|
|||||||
remoteExitNode.deleteRemoteExitNode
|
remoteExitNode.deleteRemoteExitNode
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/remote-exit-node/:remoteExitNodeId/resources",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyRemoteExitNodeAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.getRemoteExitNode),
|
||||||
|
remoteExitNode.listRemoteExitNodeResources
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/org/:orgId/remote-exit-node/:remoteExitNodeId/resources",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyRemoteExitNodeAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.updateRemoteExitNode),
|
||||||
|
logActionAudit(ActionsEnum.updateRemoteExitNode),
|
||||||
|
remoteExitNode.setRemoteExitNodeResources
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/remote-exit-node/:remoteExitNodeId/preference-labels",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyRemoteExitNodeAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.getRemoteExitNode),
|
||||||
|
remoteExitNode.listRemoteExitNodePreferenceLabels
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/org/:orgId/remote-exit-node/:remoteExitNodeId/preference-labels",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyRemoteExitNodeAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.updateRemoteExitNode),
|
||||||
|
logActionAudit(ActionsEnum.updateRemoteExitNode),
|
||||||
|
remoteExitNode.setRemoteExitNodePreferenceLabels
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/org/:orgId/login-page",
|
"/org/:orgId/login-page",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
|
|||||||
@@ -23,3 +23,7 @@ export * from "./pickRemoteExitNodeDefaults";
|
|||||||
export * from "./quickStartRemoteExitNode";
|
export * from "./quickStartRemoteExitNode";
|
||||||
export * from "./offlineChecker";
|
export * from "./offlineChecker";
|
||||||
export * from "./exitNodeReconnectScheduler";
|
export * from "./exitNodeReconnectScheduler";
|
||||||
|
export * from "./listRemoteExitNodeResources";
|
||||||
|
export * from "./setRemoteExitNodeResources";
|
||||||
|
export * from "./listRemoteExitNodePreferenceLabels";
|
||||||
|
export * from "./setRemoteExitNodePreferenceLabels";
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
db,
|
||||||
|
labels,
|
||||||
|
remoteExitNodePreferenceLabels,
|
||||||
|
remoteExitNodes
|
||||||
|
} from "@server/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
|
||||||
|
const paramsSchema = z.strictObject({
|
||||||
|
orgId: z.string().min(1),
|
||||||
|
remoteExitNodeId: z.string().min(1)
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ListRemoteExitNodePreferenceLabelsResponse = {
|
||||||
|
labels: {
|
||||||
|
remoteExitNodePreferenceLabelId: number;
|
||||||
|
labelId: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listRemoteExitNodePreferenceLabels(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { remoteExitNodeId } = parsedParams.data;
|
||||||
|
|
||||||
|
const [remoteExitNode] = await db
|
||||||
|
.select()
|
||||||
|
.from(remoteExitNodes)
|
||||||
|
.where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!remoteExitNode) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Remote exit node with ID ${remoteExitNodeId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
remoteExitNodePreferenceLabelId:
|
||||||
|
remoteExitNodePreferenceLabels.remoteExitNodePreferenceLabelId,
|
||||||
|
labelId: remoteExitNodePreferenceLabels.labelId,
|
||||||
|
name: labels.name,
|
||||||
|
color: labels.color
|
||||||
|
})
|
||||||
|
.from(remoteExitNodePreferenceLabels)
|
||||||
|
.innerJoin(
|
||||||
|
labels,
|
||||||
|
eq(labels.labelId, remoteExitNodePreferenceLabels.labelId)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
remoteExitNodePreferenceLabels.remoteExitNodeId,
|
||||||
|
remoteExitNodeId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return response<ListRemoteExitNodePreferenceLabelsResponse>(res, {
|
||||||
|
data: { labels: rows },
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message:
|
||||||
|
"Remote exit node preference labels retrieved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db, remoteExitNodeResources, remoteExitNodes } from "@server/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
|
||||||
|
const paramsSchema = z.strictObject({
|
||||||
|
orgId: z.string().min(1),
|
||||||
|
remoteExitNodeId: z.string().min(1)
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ListRemoteExitNodeResourcesResponse = {
|
||||||
|
resources: {
|
||||||
|
remoteExitNodeResourceId: number;
|
||||||
|
remoteExitNodeId: string;
|
||||||
|
destination: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listRemoteExitNodeResources(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { remoteExitNodeId } = parsedParams.data;
|
||||||
|
|
||||||
|
const [remoteExitNode] = await db
|
||||||
|
.select()
|
||||||
|
.from(remoteExitNodes)
|
||||||
|
.where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!remoteExitNode) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Remote exit node with ID ${remoteExitNodeId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resources = await db
|
||||||
|
.select()
|
||||||
|
.from(remoteExitNodeResources)
|
||||||
|
.where(
|
||||||
|
eq(remoteExitNodeResources.remoteExitNodeId, remoteExitNodeId)
|
||||||
|
);
|
||||||
|
|
||||||
|
return response<ListRemoteExitNodeResourcesResponse>(res, {
|
||||||
|
data: { resources },
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Remote exit node resources retrieved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
db,
|
||||||
|
labels,
|
||||||
|
remoteExitNodePreferenceLabels,
|
||||||
|
remoteExitNodes
|
||||||
|
} from "@server/db";
|
||||||
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
|
||||||
|
const paramsSchema = z.strictObject({
|
||||||
|
orgId: z.string().min(1),
|
||||||
|
remoteExitNodeId: z.string().min(1)
|
||||||
|
});
|
||||||
|
|
||||||
|
const bodySchema = z.strictObject({
|
||||||
|
labelIds: z.array(z.number().int().positive())
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SetRemoteExitNodePreferenceLabelsBody = z.infer<typeof bodySchema>;
|
||||||
|
|
||||||
|
export type SetRemoteExitNodePreferenceLabelsResponse = {
|
||||||
|
labels: {
|
||||||
|
remoteExitNodePreferenceLabelId: number;
|
||||||
|
labelId: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function setRemoteExitNodePreferenceLabels(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { orgId, remoteExitNodeId } = parsedParams.data;
|
||||||
|
|
||||||
|
const parsedBody = bodySchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { labelIds } = parsedBody.data;
|
||||||
|
|
||||||
|
const [remoteExitNode] = await db
|
||||||
|
.select()
|
||||||
|
.from(remoteExitNodes)
|
||||||
|
.where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!remoteExitNode) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Remote exit node with ID ${remoteExitNodeId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate all provided labelIds belong to this org
|
||||||
|
if (labelIds.length > 0) {
|
||||||
|
const existingLabels = await db
|
||||||
|
.select({ labelId: labels.labelId })
|
||||||
|
.from(labels)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(labels.orgId, orgId),
|
||||||
|
inArray(labels.labelId, labelIds)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingLabels.length !== labelIds.length) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"One or more label IDs are invalid or do not belong to this organization"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace all preference labels atomically
|
||||||
|
await db
|
||||||
|
.delete(remoteExitNodePreferenceLabels)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
remoteExitNodePreferenceLabels.remoteExitNodeId,
|
||||||
|
remoteExitNodeId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (labelIds.length > 0) {
|
||||||
|
await db.insert(remoteExitNodePreferenceLabels).values(
|
||||||
|
labelIds.map((labelId) => ({
|
||||||
|
remoteExitNodeId,
|
||||||
|
labelId
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
remoteExitNodePreferenceLabelId:
|
||||||
|
remoteExitNodePreferenceLabels.remoteExitNodePreferenceLabelId,
|
||||||
|
labelId: remoteExitNodePreferenceLabels.labelId,
|
||||||
|
name: labels.name,
|
||||||
|
color: labels.color
|
||||||
|
})
|
||||||
|
.from(remoteExitNodePreferenceLabels)
|
||||||
|
.innerJoin(
|
||||||
|
labels,
|
||||||
|
eq(labels.labelId, remoteExitNodePreferenceLabels.labelId)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
remoteExitNodePreferenceLabels.remoteExitNodeId,
|
||||||
|
remoteExitNodeId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return response<SetRemoteExitNodePreferenceLabelsResponse>(res, {
|
||||||
|
data: { labels: rows },
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Remote exit node preference labels updated successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
db,
|
||||||
|
newts,
|
||||||
|
remoteExitNodeResources,
|
||||||
|
remoteExitNodes,
|
||||||
|
sites
|
||||||
|
} from "@server/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { sendToClientsBatch } from "#private/routers/ws";
|
||||||
|
|
||||||
|
const paramsSchema = z.strictObject({
|
||||||
|
orgId: z.string().min(1),
|
||||||
|
remoteExitNodeId: z.string().min(1)
|
||||||
|
});
|
||||||
|
|
||||||
|
const cidrRegex =
|
||||||
|
/^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(\/([0-9]|[1-2][0-9]|3[0-2]))$|^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]+|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9]))(\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$/;
|
||||||
|
|
||||||
|
const bodySchema = z.strictObject({
|
||||||
|
destinations: z.array(
|
||||||
|
z.string().regex(cidrRegex, "Must be a valid CIDR range")
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SetRemoteExitNodeResourcesBody = z.infer<typeof bodySchema>;
|
||||||
|
|
||||||
|
export type SetRemoteExitNodeResourcesResponse = {
|
||||||
|
resources: {
|
||||||
|
remoteExitNodeResourceId: number;
|
||||||
|
remoteExitNodeId: string;
|
||||||
|
destination: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function setRemoteExitNodeResources(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { remoteExitNodeId } = parsedParams.data;
|
||||||
|
|
||||||
|
const parsedBody = bodySchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { destinations } = parsedBody.data;
|
||||||
|
|
||||||
|
const [remoteExitNode] = await db
|
||||||
|
.select()
|
||||||
|
.from(remoteExitNodes)
|
||||||
|
.where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!remoteExitNode) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Remote exit node with ID ${remoteExitNodeId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace all resources atomically
|
||||||
|
await db
|
||||||
|
.delete(remoteExitNodeResources)
|
||||||
|
.where(
|
||||||
|
eq(remoteExitNodeResources.remoteExitNodeId, remoteExitNodeId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (destinations.length > 0) {
|
||||||
|
await db.insert(remoteExitNodeResources).values(
|
||||||
|
destinations.map((destination) => ({
|
||||||
|
remoteExitNodeId,
|
||||||
|
destination
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resources = await db
|
||||||
|
.select()
|
||||||
|
.from(remoteExitNodeResources)
|
||||||
|
.where(
|
||||||
|
eq(remoteExitNodeResources.remoteExitNodeId, remoteExitNodeId)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Notify all newts connected to this remote exit node's exit node
|
||||||
|
if (remoteExitNode.exitNodeId) {
|
||||||
|
const connectedNewts = await db
|
||||||
|
.select({ newtId: newts.newtId })
|
||||||
|
.from(newts)
|
||||||
|
.innerJoin(sites, eq(newts.siteId, sites.siteId))
|
||||||
|
.where(eq(sites.exitNodeId, remoteExitNode.exitNodeId));
|
||||||
|
|
||||||
|
await sendToClientsBatch(
|
||||||
|
connectedNewts.map(({ newtId }) => ({
|
||||||
|
clientId: newtId,
|
||||||
|
message: {
|
||||||
|
type: "newt/wg/subnets/update",
|
||||||
|
data: { subnets: destinations }
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response<SetRemoteExitNodeResourcesResponse>(res, {
|
||||||
|
data: { resources },
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Remote exit node resources updated successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,6 @@ import * as idp from "./idp";
|
|||||||
import * as blueprints from "./blueprints";
|
import * as blueprints from "./blueprints";
|
||||||
import * as apiKeys from "./apiKeys";
|
import * as apiKeys from "./apiKeys";
|
||||||
import * as logs from "./auditLogs";
|
import * as logs from "./auditLogs";
|
||||||
import * as launcher from "./launcher";
|
|
||||||
import * as newt from "./newt";
|
import * as newt from "./newt";
|
||||||
import * as olm from "./olm";
|
import * as olm from "./olm";
|
||||||
import * as serverInfo from "./serverInfo";
|
import * as serverInfo from "./serverInfo";
|
||||||
@@ -456,54 +455,6 @@ authenticated.get(
|
|||||||
resource.getUserResources
|
resource.getUserResources
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.get(
|
|
||||||
"/org/:orgId/launcher/groups",
|
|
||||||
verifyOrgAccess,
|
|
||||||
launcher.listLauncherGroups
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.get(
|
|
||||||
"/org/:orgId/launcher/resources",
|
|
||||||
verifyOrgAccess,
|
|
||||||
launcher.listLauncherResources
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.get(
|
|
||||||
"/org/:orgId/launcher/sites",
|
|
||||||
verifyOrgAccess,
|
|
||||||
launcher.listLauncherSites
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.get(
|
|
||||||
"/org/:orgId/launcher/labels",
|
|
||||||
verifyOrgAccess,
|
|
||||||
launcher.listLauncherLabels
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.get(
|
|
||||||
"/org/:orgId/launcher/views",
|
|
||||||
verifyOrgAccess,
|
|
||||||
launcher.listLauncherViews
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.post(
|
|
||||||
"/org/:orgId/launcher/views",
|
|
||||||
verifyOrgAccess,
|
|
||||||
launcher.createLauncherView
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.put(
|
|
||||||
"/org/:orgId/launcher/views/:viewId",
|
|
||||||
verifyOrgAccess,
|
|
||||||
launcher.updateLauncherView
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.delete(
|
|
||||||
"/org/:orgId/launcher/views/:viewId",
|
|
||||||
verifyOrgAccess,
|
|
||||||
launcher.deleteLauncherView
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/user-resource-aliases",
|
"/org/:orgId/user-resource-aliases",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
|
|||||||
@@ -159,7 +159,6 @@ authenticated.get(
|
|||||||
verifyApiKeyOrgAccess,
|
verifyApiKeyOrgAccess,
|
||||||
resource.getUserResources
|
resource.getUserResources
|
||||||
);
|
);
|
||||||
|
|
||||||
// Site Resource endpoints
|
// Site Resource endpoints
|
||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/org/:orgId/site-resource",
|
"/org/:orgId/site-resource",
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
import { db, launcherViews } from "@server/db";
|
|
||||||
import { response } from "@server/lib/response";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import { NextFunction, Request, Response } from "express";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import moment from "moment";
|
|
||||||
import { fromZodError } from "zod-validation-error";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
|
|
||||||
import { launcherViewConfigSchema } from "./types";
|
|
||||||
|
|
||||||
const createLauncherViewBodySchema = z.strictObject({
|
|
||||||
name: z.string().min(1).max(128),
|
|
||||||
config: launcherViewConfigSchema,
|
|
||||||
orgWide: z.boolean().optional().default(false)
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function createLauncherView(
|
|
||||||
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 = createLauncherViewBodySchema.safeParse(req.body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromZodError(parsed.error)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsed.data.orgWide) {
|
|
||||||
const canCreateOrgWide = await checkUserActionPermission(
|
|
||||||
ActionsEnum.createOrgWideLauncherView,
|
|
||||||
req
|
|
||||||
);
|
|
||||||
if (!canCreateOrgWide) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.FORBIDDEN,
|
|
||||||
"User does not have permission perform this action"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = moment().toISOString();
|
|
||||||
const [created] = await db
|
|
||||||
.insert(launcherViews)
|
|
||||||
.values({
|
|
||||||
orgId,
|
|
||||||
userId: parsed.data.orgWide ? null : userId,
|
|
||||||
name: parsed.data.name,
|
|
||||||
config: JSON.stringify(parsed.data.config),
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
return response(res, {
|
|
||||||
data: {
|
|
||||||
viewId: created.viewId,
|
|
||||||
orgId: created.orgId,
|
|
||||||
userId: created.userId,
|
|
||||||
name: created.name,
|
|
||||||
config: launcherViewConfigSchema.parse(
|
|
||||||
JSON.parse(created.config)
|
|
||||||
),
|
|
||||||
createdAt: created.createdAt,
|
|
||||||
updatedAt: created.updatedAt,
|
|
||||||
isOrgWide: created.userId == null
|
|
||||||
},
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Launcher view created successfully",
|
|
||||||
status: HttpCode.CREATED
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (createHttpError.isHttpError(error)) {
|
|
||||||
return next(error);
|
|
||||||
}
|
|
||||||
console.error("Error creating launcher view:", error);
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
|
||||||
"Internal server error"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import { db, launcherViews } from "@server/db";
|
|
||||||
import { response } from "@server/lib/response";
|
|
||||||
import { getFirstString } from "@server/lib/requestParams";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import { and, eq } from "drizzle-orm";
|
|
||||||
import { NextFunction, Request, Response } from "express";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
|
|
||||||
|
|
||||||
export async function deleteLauncherView(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
): Promise<any> {
|
|
||||||
try {
|
|
||||||
const orgId = req.userOrgId;
|
|
||||||
const userId = req.user!.userId;
|
|
||||||
const viewId = Number.parseInt(
|
|
||||||
getFirstString(req.params.viewId) ?? "",
|
|
||||||
10
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!orgId || !Number.isFinite(viewId)) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"Invalid request parameters"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [existing] = await db
|
|
||||||
.select()
|
|
||||||
.from(launcherViews)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(launcherViews.viewId, viewId),
|
|
||||||
eq(launcherViews.orgId, orgId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!existing) {
|
|
||||||
return next(
|
|
||||||
createHttpError(HttpCode.NOT_FOUND, "Launcher view not found")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isPersonalView = existing.userId === userId;
|
|
||||||
const isOrgWideView = existing.userId == null;
|
|
||||||
const canManageOrgWide = await checkUserActionPermission(
|
|
||||||
ActionsEnum.createOrgWideLauncherView,
|
|
||||||
req
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isPersonalView && !(isOrgWideView && canManageOrgWide)) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.FORBIDDEN,
|
|
||||||
"You do not have permission to delete this view"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.delete(launcherViews).where(eq(launcherViews.viewId, viewId));
|
|
||||||
|
|
||||||
return response(res, {
|
|
||||||
data: null,
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Launcher view deleted successfully",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (createHttpError.isHttpError(error)) {
|
|
||||||
return next(error);
|
|
||||||
}
|
|
||||||
console.error("Error deleting launcher view:", error);
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
|
||||||
"Internal server error"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
import { formatEndpoint, parseEndpoint } from "@server/lib/ip";
|
|
||||||
|
|
||||||
export type SiteResourceDestinationInput = {
|
|
||||||
mode: "host" | "cidr" | "http" | "ssh";
|
|
||||||
destination: string | null;
|
|
||||||
destinationPort: number | null;
|
|
||||||
scheme: "http" | "https" | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function resolveHttpHttpsDisplayPort(
|
|
||||||
mode: "http",
|
|
||||||
destinationPort: number | null
|
|
||||||
): number {
|
|
||||||
if (destinationPort != null) {
|
|
||||||
return destinationPort;
|
|
||||||
}
|
|
||||||
return 80;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatSiteResourceDestinationDisplay(
|
|
||||||
row: SiteResourceDestinationInput
|
|
||||||
): string {
|
|
||||||
if (!row.destination) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
const { mode, destination, destinationPort, scheme } = row;
|
|
||||||
if (mode !== "http") {
|
|
||||||
return destination;
|
|
||||||
}
|
|
||||||
const port = resolveHttpHttpsDisplayPort(mode, destinationPort);
|
|
||||||
const downstreamScheme = scheme ?? "http";
|
|
||||||
const hostPart =
|
|
||||||
destination.includes(":") && !destination.startsWith("[")
|
|
||||||
? `[${destination}]`
|
|
||||||
: destination;
|
|
||||||
return `${downstreamScheme}://${hostPart}:${port}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PublicResourceAccessInput = {
|
|
||||||
mode: string;
|
|
||||||
fullDomain: string | null;
|
|
||||||
ssl: boolean;
|
|
||||||
proxyPort: number | null;
|
|
||||||
wildcard: boolean;
|
|
||||||
exitNodeEndpoint?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SiteResourceAccessInput = {
|
|
||||||
mode: string;
|
|
||||||
destination: string | null;
|
|
||||||
destinationPort: number | null;
|
|
||||||
scheme: "http" | "https" | null;
|
|
||||||
ssl: boolean;
|
|
||||||
fullDomain: string | null;
|
|
||||||
alias: string | null;
|
|
||||||
aliasAddress: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type LauncherAccessFields = {
|
|
||||||
accessDisplay: string;
|
|
||||||
accessCopyValue: string;
|
|
||||||
accessUrl: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatTcpUdpResourceAccess(
|
|
||||||
exitNodeEndpoint: string | null | undefined,
|
|
||||||
proxyPort: number | null
|
|
||||||
): LauncherAccessFields {
|
|
||||||
if (proxyPort == null) {
|
|
||||||
return {
|
|
||||||
accessDisplay: "",
|
|
||||||
accessCopyValue: "",
|
|
||||||
accessUrl: null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!exitNodeEndpoint?.trim()) {
|
|
||||||
const port = proxyPort.toString();
|
|
||||||
return {
|
|
||||||
accessDisplay: port,
|
|
||||||
accessCopyValue: port,
|
|
||||||
accessUrl: null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = parseEndpoint(exitNodeEndpoint);
|
|
||||||
const host = parsed?.ip ?? exitNodeEndpoint.trim();
|
|
||||||
const access = formatEndpoint(host, proxyPort);
|
|
||||||
|
|
||||||
return {
|
|
||||||
accessDisplay: access,
|
|
||||||
accessCopyValue: access,
|
|
||||||
accessUrl: null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatPublicResourceAccess(
|
|
||||||
resource: PublicResourceAccessInput
|
|
||||||
): LauncherAccessFields {
|
|
||||||
const browserModes = ["http", "ssh", "rdp", "vnc"];
|
|
||||||
if (!browserModes.includes(resource.mode)) {
|
|
||||||
return formatTcpUdpResourceAccess(
|
|
||||||
resource.exitNodeEndpoint,
|
|
||||||
resource.proxyPort
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!resource.fullDomain) {
|
|
||||||
return {
|
|
||||||
accessDisplay: "",
|
|
||||||
accessCopyValue: "",
|
|
||||||
accessUrl: null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
|
|
||||||
return {
|
|
||||||
accessDisplay: url,
|
|
||||||
accessCopyValue: url,
|
|
||||||
accessUrl: resource.wildcard ? null : url
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatSiteResourceAccess(
|
|
||||||
resource: SiteResourceAccessInput
|
|
||||||
): LauncherAccessFields {
|
|
||||||
if (resource.alias) {
|
|
||||||
return {
|
|
||||||
accessDisplay: resource.alias,
|
|
||||||
accessCopyValue: resource.alias,
|
|
||||||
accessUrl: null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resource.mode === "http" && resource.fullDomain) {
|
|
||||||
const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
|
|
||||||
return {
|
|
||||||
accessDisplay: url,
|
|
||||||
accessCopyValue: url,
|
|
||||||
accessUrl: url
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const destination = formatSiteResourceDestinationDisplay({
|
|
||||||
mode: resource.mode as SiteResourceDestinationInput["mode"],
|
|
||||||
destination: resource.destination,
|
|
||||||
destinationPort: resource.destinationPort,
|
|
||||||
scheme: resource.scheme
|
|
||||||
});
|
|
||||||
|
|
||||||
if (destination) {
|
|
||||||
return {
|
|
||||||
accessDisplay: destination,
|
|
||||||
accessCopyValue: destination,
|
|
||||||
accessUrl: resource.mode === "http" ? destination : null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resource.aliasAddress) {
|
|
||||||
return {
|
|
||||||
accessDisplay: resource.aliasAddress,
|
|
||||||
accessCopyValue: resource.aliasAddress,
|
|
||||||
accessUrl: null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
accessDisplay: "",
|
|
||||||
accessCopyValue: "",
|
|
||||||
accessUrl: null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
export * from "./types";
|
|
||||||
export { listLauncherGroups } from "./listLauncherGroups";
|
|
||||||
export { listLauncherResources } from "./listLauncherResources";
|
|
||||||
export { listLauncherSites } from "./listLauncherSites";
|
|
||||||
export { listLauncherLabels } from "./listLauncherLabels";
|
|
||||||
export { listLauncherViews } from "./listLauncherViews";
|
|
||||||
export { createLauncherView } from "./createLauncherView";
|
|
||||||
export { updateLauncherView } from "./updateLauncherView";
|
|
||||||
export { deleteLauncherView } from "./deleteLauncherView";
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,67 +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 { listLauncherGroupsForUser } from "./launcherResourceAccess";
|
|
||||||
import { launcherListQuerySchema } from "./types";
|
|
||||||
|
|
||||||
export async function listLauncherGroups(
|
|
||||||
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 = launcherListQuerySchema.safeParse(req.query);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromZodError(parsed.error)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { groups, total } = await listLauncherGroupsForUser(
|
|
||||||
orgId,
|
|
||||||
userId,
|
|
||||||
req.userOrgRoleIds ?? [],
|
|
||||||
parsed.data
|
|
||||||
);
|
|
||||||
|
|
||||||
return response(res, {
|
|
||||||
data: {
|
|
||||||
groups,
|
|
||||||
pagination: {
|
|
||||||
total,
|
|
||||||
page: parsed.data.page,
|
|
||||||
pageSize: parsed.data.pageSize
|
|
||||||
}
|
|
||||||
},
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Launcher groups retrieved successfully",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (createHttpError.isHttpError(error)) {
|
|
||||||
return next(error);
|
|
||||||
}
|
|
||||||
console.error("Error listing launcher groups:", error);
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
|
||||||
"Internal server error"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,67 +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 { listAccessibleLauncherLabelsForUser } from "./launcherResourceAccess";
|
|
||||||
import { launcherFilterListQuerySchema } from "./types";
|
|
||||||
|
|
||||||
export async function listLauncherLabels(
|
|
||||||
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 = launcherFilterListQuerySchema.safeParse(req.query);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromZodError(parsed.error)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { labels, total } = await listAccessibleLauncherLabelsForUser(
|
|
||||||
orgId,
|
|
||||||
userId,
|
|
||||||
req.userOrgRoleIds ?? [],
|
|
||||||
parsed.data
|
|
||||||
);
|
|
||||||
|
|
||||||
return response(res, {
|
|
||||||
data: {
|
|
||||||
labels,
|
|
||||||
pagination: {
|
|
||||||
total,
|
|
||||||
page: parsed.data.page,
|
|
||||||
pageSize: parsed.data.pageSize
|
|
||||||
}
|
|
||||||
},
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Launcher labels retrieved successfully",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (createHttpError.isHttpError(error)) {
|
|
||||||
return next(error);
|
|
||||||
}
|
|
||||||
console.error("Error listing launcher labels:", error);
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
|
||||||
"Internal server error"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +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 { listLauncherResourcesForUser } from "./launcherResourceAccess";
|
|
||||||
import { launcherListQuerySchema } from "./types";
|
|
||||||
|
|
||||||
const listLauncherResourcesQuerySchema = launcherListQuerySchema.extend({
|
|
||||||
groupKey: z.string().min(1)
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function listLauncherResources(
|
|
||||||
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 = listLauncherResourcesQuerySchema.safeParse(req.query);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromZodError(parsed.error)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { resources, total } = await listLauncherResourcesForUser(
|
|
||||||
orgId,
|
|
||||||
userId,
|
|
||||||
req.userOrgRoleIds ?? [],
|
|
||||||
parsed.data
|
|
||||||
);
|
|
||||||
|
|
||||||
return response(res, {
|
|
||||||
data: {
|
|
||||||
resources,
|
|
||||||
pagination: {
|
|
||||||
total,
|
|
||||||
page: parsed.data.page,
|
|
||||||
pageSize: parsed.data.pageSize
|
|
||||||
}
|
|
||||||
},
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Launcher resources retrieved successfully",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (createHttpError.isHttpError(error)) {
|
|
||||||
return next(error);
|
|
||||||
}
|
|
||||||
console.error("Error listing launcher resources:", error);
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
|
||||||
"Internal server error"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,67 +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 { listAccessibleLauncherSitesForUser } from "./launcherResourceAccess";
|
|
||||||
import { launcherFilterListQuerySchema } from "./types";
|
|
||||||
|
|
||||||
export async function listLauncherSites(
|
|
||||||
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 = launcherFilterListQuerySchema.safeParse(req.query);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromZodError(parsed.error)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { sites, total } = await listAccessibleLauncherSitesForUser(
|
|
||||||
orgId,
|
|
||||||
userId,
|
|
||||||
req.userOrgRoleIds ?? [],
|
|
||||||
parsed.data
|
|
||||||
);
|
|
||||||
|
|
||||||
return response(res, {
|
|
||||||
data: {
|
|
||||||
sites,
|
|
||||||
pagination: {
|
|
||||||
total,
|
|
||||||
page: parsed.data.page,
|
|
||||||
pageSize: parsed.data.pageSize
|
|
||||||
}
|
|
||||||
},
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Launcher sites retrieved successfully",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (createHttpError.isHttpError(error)) {
|
|
||||||
return next(error);
|
|
||||||
}
|
|
||||||
console.error("Error listing launcher sites:", error);
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
|
||||||
"Internal server error"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import { db, launcherViews } from "@server/db";
|
|
||||||
import { response } from "@server/lib/response";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import { and, eq, isNull, or } from "drizzle-orm";
|
|
||||||
import { NextFunction, Request, Response } from "express";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import { launcherViewConfigSchema, type LauncherViewRecord } from "./types";
|
|
||||||
|
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listLauncherViews(
|
|
||||||
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 rows = await db
|
|
||||||
.select()
|
|
||||||
.from(launcherViews)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(launcherViews.orgId, orgId),
|
|
||||||
or(
|
|
||||||
eq(launcherViews.userId, userId),
|
|
||||||
isNull(launcherViews.userId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
return response(res, {
|
|
||||||
data: {
|
|
||||||
views: rows.map(mapViewRow)
|
|
||||||
},
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Launcher views retrieved successfully",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (createHttpError.isHttpError(error)) {
|
|
||||||
return next(error);
|
|
||||||
}
|
|
||||||
console.error("Error listing launcher views:", error);
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
|
||||||
"Internal server error"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
export const LAUNCHER_UNLABELED_GROUP_KEY = "unlabeled";
|
|
||||||
export const LAUNCHER_NO_SITE_GROUP_KEY = "no-site";
|
|
||||||
|
|
||||||
export const launcherViewConfigSchema = z.object({
|
|
||||||
groupBy: z.enum(["site", "label"]).default("site"),
|
|
||||||
layout: z.enum(["grid", "list"]).default("grid"),
|
|
||||||
sortBy: z.literal("name").default("name"),
|
|
||||||
order: z.enum(["asc", "desc"]).default("asc"),
|
|
||||||
showLabels: z.boolean().default(true),
|
|
||||||
showSiteTags: z.boolean().default(true),
|
|
||||||
showRecents: z.boolean().default(false).optional(),
|
|
||||||
siteIds: z.array(z.number()).default([]),
|
|
||||||
labelIds: z.array(z.number()).default([]),
|
|
||||||
query: z.string().default("")
|
|
||||||
});
|
|
||||||
|
|
||||||
export type LauncherViewConfig = z.infer<typeof launcherViewConfigSchema>;
|
|
||||||
|
|
||||||
export const defaultLauncherViewConfig: LauncherViewConfig =
|
|
||||||
launcherViewConfigSchema.parse({});
|
|
||||||
|
|
||||||
export type LauncherLabel = {
|
|
||||||
labelId: number;
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type LauncherSiteInfo = {
|
|
||||||
siteId: number;
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
online?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type LauncherResource = {
|
|
||||||
launcherResourceKey: string;
|
|
||||||
resourceType: "public" | "site";
|
|
||||||
resourceId: number;
|
|
||||||
siteResourceId?: number;
|
|
||||||
niceId: string;
|
|
||||||
name: string;
|
|
||||||
accessDisplay: string;
|
|
||||||
accessCopyValue: string;
|
|
||||||
accessUrl: string | null;
|
|
||||||
iconUrl: string | null;
|
|
||||||
enabled: boolean;
|
|
||||||
mode: string;
|
|
||||||
labels: LauncherLabel[];
|
|
||||||
site?: LauncherSiteInfo;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type LauncherGroup = {
|
|
||||||
groupKey: string;
|
|
||||||
name: string;
|
|
||||||
groupType: "site" | "label";
|
|
||||||
itemCount: number;
|
|
||||||
siteType?: string;
|
|
||||||
siteOnline?: boolean;
|
|
||||||
labelColor?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ListLauncherGroupsResponse = {
|
|
||||||
groups: LauncherGroup[];
|
|
||||||
pagination: {
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
pageSize: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ListLauncherResourcesResponse = {
|
|
||||||
resources: LauncherResource[];
|
|
||||||
pagination: {
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
pageSize: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type LauncherViewRecord = {
|
|
||||||
viewId: number;
|
|
||||||
orgId: string;
|
|
||||||
userId: string | null;
|
|
||||||
name: string;
|
|
||||||
config: LauncherViewConfig;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
isOrgWide: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ListLauncherViewsResponse = {
|
|
||||||
views: LauncherViewRecord[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const launcherFilterListQuerySchema = z.strictObject({
|
|
||||||
pageSize: z.coerce
|
|
||||||
.number()
|
|
||||||
.int()
|
|
||||||
.positive()
|
|
||||||
.optional()
|
|
||||||
.catch(500)
|
|
||||||
.default(500),
|
|
||||||
page: z.coerce.number().int().min(1).optional().catch(1).default(1),
|
|
||||||
query: z.string().optional().default("")
|
|
||||||
});
|
|
||||||
|
|
||||||
export type LauncherFilterListQuery = z.infer<
|
|
||||||
typeof launcherFilterListQuerySchema
|
|
||||||
>;
|
|
||||||
|
|
||||||
export type ListLauncherSitesResponse = {
|
|
||||||
sites: LauncherSiteInfo[];
|
|
||||||
pagination: {
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
pageSize: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ListLauncherLabelsResponse = {
|
|
||||||
labels: LauncherLabel[];
|
|
||||||
pagination: {
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
pageSize: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const launcherListQuerySchema = z.strictObject({
|
|
||||||
pageSize: z.coerce
|
|
||||||
.number()
|
|
||||||
.int()
|
|
||||||
.positive()
|
|
||||||
.optional()
|
|
||||||
.catch(20)
|
|
||||||
.default(20),
|
|
||||||
page: z.coerce.number().int().min(1).optional().catch(1).default(1),
|
|
||||||
query: z.string().optional().default(""),
|
|
||||||
groupBy: z.enum(["site", "label"]).optional().default("site"),
|
|
||||||
groupKey: z.string().optional(),
|
|
||||||
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 LauncherListQuery = z.infer<typeof launcherListQuerySchema>;
|
|
||||||
|
|
||||||
export function parseIdListParam(value: string | undefined): number[] {
|
|
||||||
if (!value?.trim()) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
.split(",")
|
|
||||||
.map((part) => Number.parseInt(part.trim(), 10))
|
|
||||||
.filter((id) => Number.isFinite(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DEFAULT_LAUNCHER_VIEW_ID = "default" as const;
|
|
||||||
|
|
||||||
export type LauncherViewSelection =
|
|
||||||
| { type: "default" }
|
|
||||||
| { type: "saved"; viewId: number };
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
import { db, launcherViews } from "@server/db";
|
|
||||||
import { response } from "@server/lib/response";
|
|
||||||
import { getFirstString } from "@server/lib/requestParams";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import { and, eq } from "drizzle-orm";
|
|
||||||
import { NextFunction, Request, Response } from "express";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import moment from "moment";
|
|
||||||
import { fromZodError } from "zod-validation-error";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
|
|
||||||
import { launcherViewConfigSchema } from "./types";
|
|
||||||
|
|
||||||
const updateLauncherViewBodySchema = z.strictObject({
|
|
||||||
name: z.string().min(1).max(128).optional(),
|
|
||||||
config: launcherViewConfigSchema.optional(),
|
|
||||||
orgWide: z.boolean().optional()
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function updateLauncherView(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
): Promise<any> {
|
|
||||||
try {
|
|
||||||
const orgId = req.userOrgId;
|
|
||||||
const userId = req.user!.userId;
|
|
||||||
const viewId = Number.parseInt(
|
|
||||||
getFirstString(req.params.viewId) ?? "",
|
|
||||||
10
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!orgId || !Number.isFinite(viewId)) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"Invalid request parameters"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = updateLauncherViewBodySchema.safeParse(req.body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromZodError(parsed.error)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [existing] = await db
|
|
||||||
.select()
|
|
||||||
.from(launcherViews)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(launcherViews.viewId, viewId),
|
|
||||||
eq(launcherViews.orgId, orgId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!existing) {
|
|
||||||
return next(
|
|
||||||
createHttpError(HttpCode.NOT_FOUND, "Launcher view not found")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isPersonalView = existing.userId === userId;
|
|
||||||
const isOrgWideView = existing.userId == null;
|
|
||||||
const canManageOrgWide = await checkUserActionPermission(
|
|
||||||
ActionsEnum.createOrgWideLauncherView,
|
|
||||||
req
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isPersonalView && !(isOrgWideView && canManageOrgWide)) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.FORBIDDEN,
|
|
||||||
"You do not have permission to update this view"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsed.data.orgWide === true && !canManageOrgWide) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.FORBIDDEN,
|
|
||||||
"User does not have permission perform this action"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
parsed.data.orgWide === false &&
|
|
||||||
isOrgWideView &&
|
|
||||||
!canManageOrgWide
|
|
||||||
) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.FORBIDDEN,
|
|
||||||
"User does not have permission perform this action"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextUserId =
|
|
||||||
parsed.data.orgWide === true
|
|
||||||
? null
|
|
||||||
: parsed.data.orgWide === false
|
|
||||||
? userId
|
|
||||||
: existing.userId;
|
|
||||||
|
|
||||||
const [updated] = await db
|
|
||||||
.update(launcherViews)
|
|
||||||
.set({
|
|
||||||
name: parsed.data.name ?? existing.name,
|
|
||||||
config: parsed.data.config
|
|
||||||
? JSON.stringify(parsed.data.config)
|
|
||||||
: existing.config,
|
|
||||||
userId: nextUserId,
|
|
||||||
updatedAt: moment().toISOString()
|
|
||||||
})
|
|
||||||
.where(eq(launcherViews.viewId, viewId))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
return response(res, {
|
|
||||||
data: {
|
|
||||||
viewId: updated.viewId,
|
|
||||||
orgId: updated.orgId,
|
|
||||||
userId: updated.userId,
|
|
||||||
name: updated.name,
|
|
||||||
config: launcherViewConfigSchema.parse(
|
|
||||||
JSON.parse(updated.config)
|
|
||||||
),
|
|
||||||
createdAt: updated.createdAt,
|
|
||||||
updatedAt: updated.updatedAt,
|
|
||||||
isOrgWide: updated.userId == null
|
|
||||||
},
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Launcher view updated successfully",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (createHttpError.isHttpError(error)) {
|
|
||||||
return next(error);
|
|
||||||
}
|
|
||||||
console.error("Error updating launcher view:", error);
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
|
||||||
"Internal server error"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
db,
|
db,
|
||||||
ExitNode,
|
ExitNode,
|
||||||
networks,
|
networks,
|
||||||
|
remoteExitNodeResources,
|
||||||
resources,
|
resources,
|
||||||
Site,
|
Site,
|
||||||
siteNetworks,
|
siteNetworks,
|
||||||
@@ -223,7 +224,8 @@ export async function buildClientConfigurationForNewtClient(
|
|||||||
|
|
||||||
export async function buildTargetConfigurationForNewtClient(
|
export async function buildTargetConfigurationForNewtClient(
|
||||||
siteId: number,
|
siteId: number,
|
||||||
version?: string | null
|
version?: string | null,
|
||||||
|
remoteExitNodeId?: string
|
||||||
) {
|
) {
|
||||||
// Get all enabled targets with their resource mode information
|
// Get all enabled targets with their resource mode information
|
||||||
const allTargets = await db
|
const allTargets = await db
|
||||||
@@ -379,10 +381,24 @@ export async function buildTargetConfigurationForNewtClient(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let remoteExitNodeSubnets: string[] = [];
|
||||||
|
if (remoteExitNodeId) {
|
||||||
|
const remoteNodeResources = await db
|
||||||
|
.select()
|
||||||
|
.from(remoteExitNodeResources)
|
||||||
|
.where(
|
||||||
|
eq(remoteExitNodeResources.remoteExitNodeId, remoteExitNodeId)
|
||||||
|
);
|
||||||
|
|
||||||
|
// filter through these and provide the subnets
|
||||||
|
remoteExitNodeSubnets = remoteNodeResources.map((r) => r.destination);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
validHealthCheckTargets,
|
validHealthCheckTargets,
|
||||||
tcpTargets,
|
tcpTargets,
|
||||||
udpTargets,
|
udpTargets,
|
||||||
browserGatewayTargets
|
browserGatewayTargets,
|
||||||
|
remoteExitNodeSubnets
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ export const handleNewtPingRequestMessage: MessageHandler = async (context) => {
|
|||||||
const exitNodesList = await listExitNodes(
|
const exitNodesList = await listExitNodes(
|
||||||
site.orgId,
|
site.orgId,
|
||||||
true,
|
true,
|
||||||
noCloud || false
|
noCloud || false,
|
||||||
|
newt.siteId
|
||||||
); // filter for only the online ones
|
); // filter for only the online ones
|
||||||
|
|
||||||
let lastExitNodeId = null;
|
let lastExitNodeId = null;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { db, ExitNode, newts, Transaction } from "@server/db";
|
import { db, ExitNode, newts, remoteExitNodes, Transaction } from "@server/db";
|
||||||
import { MessageHandler } from "@server/routers/ws";
|
import { MessageHandler } from "@server/routers/ws";
|
||||||
import { exitNodes, Newt, sites } from "@server/db";
|
import { exitNodes, Newt, sites } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
@@ -196,12 +196,29 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
|||||||
.where(eq(newts.newtId, newt.newtId));
|
.where(eq(newts.newtId, newt.newtId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let remoteExitNodeId: string | undefined;
|
||||||
|
if (exitNode.type == "remoteExitNode") {
|
||||||
|
// get the remote exit node ID associated with this exit node
|
||||||
|
const [remoteExitNode] = await db
|
||||||
|
.select()
|
||||||
|
.from(remoteExitNodes)
|
||||||
|
.where(eq(remoteExitNodes.exitNodeId, exitNode.exitNodeId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
remoteExitNodeId = remoteExitNode?.remoteExitNodeId;
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
tcpTargets,
|
tcpTargets,
|
||||||
udpTargets,
|
udpTargets,
|
||||||
validHealthCheckTargets,
|
validHealthCheckTargets,
|
||||||
browserGatewayTargets
|
browserGatewayTargets,
|
||||||
} = await buildTargetConfigurationForNewtClient(siteId, newtVersion);
|
remoteExitNodeSubnets
|
||||||
|
} = await buildTargetConfigurationForNewtClient(
|
||||||
|
siteId,
|
||||||
|
newtVersion,
|
||||||
|
remoteExitNodeId // this is for the remote node resources
|
||||||
|
);
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Sending health check targets to newt ${newt.newtId}: ${JSON.stringify(validHealthCheckTargets)}`
|
`Sending health check targets to newt ${newt.newtId}: ${JSON.stringify(validHealthCheckTargets)}`
|
||||||
@@ -222,6 +239,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
|||||||
},
|
},
|
||||||
healthCheckTargets: validHealthCheckTargets,
|
healthCheckTargets: validHealthCheckTargets,
|
||||||
browserGatewayTargets: browserGatewayTargets,
|
browserGatewayTargets: browserGatewayTargets,
|
||||||
|
remoteExitNodeSubnets: remoteExitNodeSubnets,
|
||||||
chainId: chainId
|
chainId: chainId
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -197,6 +197,15 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
policyCheck
|
policyCheck
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (policyCheck?.error) {
|
||||||
|
logger.error(
|
||||||
|
`[handleOlmRegisterMessage] Error checking access policies for olm user ${olm.userId} in org ${orgId}: ${policyCheck?.error}`,
|
||||||
|
{ orgId: client.orgId, clientId: client.clientId }
|
||||||
|
);
|
||||||
|
sendOlmError(OlmErrorCodes.ORG_ACCESS_POLICY_DENIED, olm.olmId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (policyCheck.policies?.passwordAge?.compliant === false) {
|
if (policyCheck.policies?.passwordAge?.compliant === false) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`[handleOlmRegisterMessage] Olm user ${olm.userId} has non-compliant password age for org ${orgId}`,
|
`[handleOlmRegisterMessage] Olm user ${olm.userId} has non-compliant password age for org ${orgId}`,
|
||||||
@@ -229,7 +238,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
olm.olmId
|
olm.olmId
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
} else if (!policyCheck.allowed || policyCheck.error) {
|
} else if (!policyCheck.allowed) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`[handleOlmRegisterMessage] Olm user ${olm.userId} does not pass access policies for org ${orgId}: ${policyCheck.error}`,
|
`[handleOlmRegisterMessage] Olm user ${olm.userId} does not pass access policies for org ${orgId}: ${policyCheck.error}`,
|
||||||
{ orgId: client.orgId, clientId: client.clientId }
|
{ orgId: client.orgId, clientId: client.clientId }
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, resourcePolicyRules, resourcePolicies } from "@server/db";
|
import { db, resourcePolicyRules, resourcePolicies } from "@server/db";
|
||||||
import { and, eq, notInArray } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
|
||||||
const ruleSchema = z.strictObject({
|
const ruleSchema = z.strictObject({
|
||||||
ruleId: z.int().positive().optional(),
|
|
||||||
action: z.enum(["ACCEPT", "DROP", "PASS"]).openapi({
|
action: z.enum(["ACCEPT", "DROP", "PASS"]).openapi({
|
||||||
type: "string",
|
type: "string",
|
||||||
enum: ["ACCEPT", "DROP", "PASS"],
|
enum: ["ACCEPT", "DROP", "PASS"],
|
||||||
@@ -122,74 +121,17 @@ export async function setResourcePolicyRules(
|
|||||||
.set({ applyRules })
|
.set({ applyRules })
|
||||||
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId));
|
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId));
|
||||||
|
|
||||||
const incomingRuleIds = rules
|
await trx
|
||||||
.map((r) => r.ruleId)
|
.delete(resourcePolicyRules)
|
||||||
.filter((id): id is number => id !== undefined);
|
.where(
|
||||||
|
eq(resourcePolicyRules.resourcePolicyId, resourcePolicyId)
|
||||||
|
);
|
||||||
|
|
||||||
// Delete rules that are no longer in the incoming list
|
if (rules.length > 0) {
|
||||||
if (incomingRuleIds.length > 0) {
|
|
||||||
await trx
|
|
||||||
.delete(resourcePolicyRules)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(
|
|
||||||
resourcePolicyRules.resourcePolicyId,
|
|
||||||
resourcePolicyId
|
|
||||||
),
|
|
||||||
notInArray(
|
|
||||||
resourcePolicyRules.ruleId,
|
|
||||||
incomingRuleIds
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await trx
|
|
||||||
.delete(resourcePolicyRules)
|
|
||||||
.where(
|
|
||||||
eq(
|
|
||||||
resourcePolicyRules.resourcePolicyId,
|
|
||||||
resourcePolicyId
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update existing rules (those with a ruleId)
|
|
||||||
const existingRules = rules.filter(
|
|
||||||
(r): r is typeof r & { ruleId: number } =>
|
|
||||||
r.ruleId !== undefined
|
|
||||||
);
|
|
||||||
for (const rule of existingRules) {
|
|
||||||
await trx
|
|
||||||
.update(resourcePolicyRules)
|
|
||||||
.set({
|
|
||||||
action: rule.action,
|
|
||||||
match: rule.match,
|
|
||||||
value: rule.value,
|
|
||||||
priority: rule.priority,
|
|
||||||
enabled: rule.enabled
|
|
||||||
})
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(resourcePolicyRules.ruleId, rule.ruleId),
|
|
||||||
eq(
|
|
||||||
resourcePolicyRules.resourcePolicyId,
|
|
||||||
resourcePolicyId
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert new rules (those without a ruleId)
|
|
||||||
const newRules = rules.filter((r) => r.ruleId === undefined);
|
|
||||||
if (newRules.length > 0) {
|
|
||||||
await trx.insert(resourcePolicyRules).values(
|
await trx.insert(resourcePolicyRules).values(
|
||||||
newRules.map((rule) => ({
|
rules.map((rule) => ({
|
||||||
resourcePolicyId,
|
resourcePolicyId,
|
||||||
action: rule.action,
|
...rule
|
||||||
match: rule.match,
|
|
||||||
value: rule.value,
|
|
||||||
priority: rule.priority,
|
|
||||||
enabled: rule.enabled
|
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,8 @@ import {
|
|||||||
userSiteResources,
|
userSiteResources,
|
||||||
roleSiteResources,
|
roleSiteResources,
|
||||||
userOrgRoles,
|
userOrgRoles,
|
||||||
userOrgs,
|
userOrgs
|
||||||
labels,
|
|
||||||
siteResourceLabels
|
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
|
||||||
import { and, eq, inArray, asc, isNotNull, ne, or } from "drizzle-orm";
|
import { and, eq, inArray, asc, isNotNull, ne, or } from "drizzle-orm";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
@@ -23,33 +19,13 @@ import { regionalCache as cache } from "#dynamic/lib/cache";
|
|||||||
|
|
||||||
const USER_RESOURCE_ALIASES_CACHE_TTL_SEC = 60;
|
const USER_RESOURCE_ALIASES_CACHE_TTL_SEC = 60;
|
||||||
|
|
||||||
const labelFilterQuerySchema = z
|
|
||||||
.preprocess((val) => {
|
|
||||||
if (val === undefined || val === null || val === "") {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if (Array.isArray(val)) {
|
|
||||||
return val;
|
|
||||||
}
|
|
||||||
if (typeof val === "string") {
|
|
||||||
return val.split(",");
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}, z.array(z.string()))
|
|
||||||
.optional()
|
|
||||||
.catch([]);
|
|
||||||
|
|
||||||
function userResourceAliasesCacheKey(
|
function userResourceAliasesCacheKey(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
page: number,
|
page: number,
|
||||||
pageSize: number,
|
pageSize: number
|
||||||
includeLabels: boolean,
|
|
||||||
labelFilter: string[]
|
|
||||||
) {
|
) {
|
||||||
const labelsKey =
|
return `userResourceAliases:${orgId}:${userId}:${page}:${pageSize}`;
|
||||||
labelFilter.length > 0 ? labelFilter.slice().sort().join(",") : "all";
|
|
||||||
return `userResourceAliases:${orgId}:${userId}:${page}:${pageSize}:${includeLabels ? "labels" : "plain"}:${labelsKey}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const listUserResourceAliasesParamsSchema = z.strictObject({
|
const listUserResourceAliasesParamsSchema = z.strictObject({
|
||||||
@@ -80,35 +56,43 @@ const listUserResourceAliasesQuerySchema = z.strictObject({
|
|||||||
type: "integer",
|
type: "integer",
|
||||||
default: 1,
|
default: 1,
|
||||||
description: "Page number to retrieve"
|
description: "Page number to retrieve"
|
||||||
}),
|
})
|
||||||
includeLabels: z
|
|
||||||
.enum(["true", "false"])
|
|
||||||
.optional()
|
|
||||||
.default("false")
|
|
||||||
.transform((val) => val === "true")
|
|
||||||
.openapi({
|
|
||||||
type: "boolean",
|
|
||||||
default: false,
|
|
||||||
description:
|
|
||||||
"When true, include label names for each alias in the items field"
|
|
||||||
}),
|
|
||||||
labels: labelFilterQuerySchema.openapi({
|
|
||||||
type: "array",
|
|
||||||
description:
|
|
||||||
"Filter by resource labels. A resource matches when it has any of the given labels (OR)."
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type UserResourceAliasItem = {
|
|
||||||
alias: string;
|
|
||||||
labels: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ListUserResourceAliasesResponse = PaginatedResponse<{
|
export type ListUserResourceAliasesResponse = PaginatedResponse<{
|
||||||
aliases: string[];
|
aliases: string[];
|
||||||
items?: UserResourceAliasItem[];
|
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
// registry.registerPath({
|
||||||
|
// method: "get",
|
||||||
|
// path: "/org/{orgId}/user-resource-aliases",
|
||||||
|
// description:
|
||||||
|
// "List private (host-mode) site resource aliases the authenticated user can access in the organization, paginated.",
|
||||||
|
// tags: [OpenAPITags.PrivateResource],
|
||||||
|
// request: {
|
||||||
|
// params: z.object({
|
||||||
|
// orgId: z.string()
|
||||||
|
// }),
|
||||||
|
// query: listUserResourceAliasesQuerySchema
|
||||||
|
// },
|
||||||
|
// responses: {
|
||||||
|
// 200: {
|
||||||
|
// description: "Successful response",
|
||||||
|
// content: {
|
||||||
|
// "application/json": {
|
||||||
|
// schema: z.object({
|
||||||
|
// data: z.record(z.string(), z.any()).nullable(),
|
||||||
|
// success: z.boolean(),
|
||||||
|
// error: z.boolean(),
|
||||||
|
// message: z.string(),
|
||||||
|
// status: z.number()
|
||||||
|
// })
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
export async function listUserResourceAliases(
|
export async function listUserResourceAliases(
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
@@ -126,12 +110,7 @@ export async function listUserResourceAliases(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const {
|
const { page, pageSize } = parsedQuery.data;
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
includeLabels,
|
|
||||||
labels: labelFilter
|
|
||||||
} = parsedQuery.data;
|
|
||||||
|
|
||||||
const parsedParams = listUserResourceAliasesParamsSchema.safeParse(
|
const parsedParams = listUserResourceAliasesParamsSchema.safeParse(
|
||||||
req.params
|
req.params
|
||||||
@@ -170,9 +149,7 @@ export async function listUserResourceAliases(
|
|||||||
orgId,
|
orgId,
|
||||||
userId,
|
userId,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize
|
||||||
includeLabels,
|
|
||||||
labelFilter ?? []
|
|
||||||
);
|
);
|
||||||
const cachedData: ListUserResourceAliasesResponse | undefined =
|
const cachedData: ListUserResourceAliasesResponse | undefined =
|
||||||
await cache.get(cacheKey);
|
await cache.get(cacheKey);
|
||||||
@@ -227,7 +204,6 @@ export async function listUserResourceAliases(
|
|||||||
if (accessibleSiteResourceIds.length === 0) {
|
if (accessibleSiteResourceIds.length === 0) {
|
||||||
const data: ListUserResourceAliasesResponse = {
|
const data: ListUserResourceAliasesResponse = {
|
||||||
aliases: [],
|
aliases: [],
|
||||||
...(includeLabels ? { items: [] } : {}),
|
|
||||||
pagination: {
|
pagination: {
|
||||||
total: 0,
|
total: 0,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -248,44 +224,18 @@ export async function listUserResourceAliases(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
|
const whereClause = and(
|
||||||
orgId,
|
|
||||||
tierMatrix.labels
|
|
||||||
);
|
|
||||||
|
|
||||||
const whereConditions = [
|
|
||||||
eq(siteResources.orgId, orgId),
|
eq(siteResources.orgId, orgId),
|
||||||
eq(siteResources.enabled, true),
|
eq(siteResources.enabled, true),
|
||||||
or(eq(siteResources.mode, "host"), eq(siteResources.mode, "ssh")),
|
or(eq(siteResources.mode, "host"), eq(siteResources.mode, "ssh")),
|
||||||
isNotNull(siteResources.alias),
|
isNotNull(siteResources.alias),
|
||||||
ne(siteResources.alias, ""),
|
ne(siteResources.alias, ""),
|
||||||
inArray(siteResources.siteResourceId, accessibleSiteResourceIds)
|
inArray(siteResources.siteResourceId, accessibleSiteResourceIds)
|
||||||
];
|
);
|
||||||
|
|
||||||
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) {
|
|
||||||
whereConditions.push(
|
|
||||||
inArray(
|
|
||||||
siteResources.siteResourceId,
|
|
||||||
db
|
|
||||||
.select({ id: siteResourceLabels.siteResourceId })
|
|
||||||
.from(siteResourceLabels)
|
|
||||||
.innerJoin(
|
|
||||||
labels,
|
|
||||||
eq(labels.labelId, siteResourceLabels.labelId)
|
|
||||||
)
|
|
||||||
.where(inArray(labels.name, labelFilter))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const whereClause = and(...whereConditions);
|
|
||||||
|
|
||||||
const baseSelect = () =>
|
const baseSelect = () =>
|
||||||
db
|
db
|
||||||
.select({
|
.select({ alias: siteResources.alias })
|
||||||
alias: siteResources.alias,
|
|
||||||
siteResourceId: siteResources.siteResourceId
|
|
||||||
})
|
|
||||||
.from(siteResources)
|
.from(siteResources)
|
||||||
.where(whereClause);
|
.where(whereClause);
|
||||||
|
|
||||||
@@ -301,46 +251,8 @@ export async function listUserResourceAliases(
|
|||||||
|
|
||||||
const aliases = rows.map((r) => r.alias as string);
|
const aliases = rows.map((r) => r.alias as string);
|
||||||
|
|
||||||
let items: UserResourceAliasItem[] | undefined;
|
|
||||||
if (includeLabels) {
|
|
||||||
const siteResourceIdList = rows.map((r) => r.siteResourceId);
|
|
||||||
|
|
||||||
let labelsForSiteResources: Array<{
|
|
||||||
name: string;
|
|
||||||
siteResourceId: number;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
if (isLabelFeatureEnabled && siteResourceIdList.length > 0) {
|
|
||||||
labelsForSiteResources = await db
|
|
||||||
.select({
|
|
||||||
name: labels.name,
|
|
||||||
siteResourceId: siteResourceLabels.siteResourceId
|
|
||||||
})
|
|
||||||
.from(labels)
|
|
||||||
.innerJoin(
|
|
||||||
siteResourceLabels,
|
|
||||||
eq(siteResourceLabels.labelId, labels.labelId)
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
inArray(
|
|
||||||
siteResourceLabels.siteResourceId,
|
|
||||||
siteResourceIdList
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.orderBy(asc(siteResourceLabels.siteResourceLabelId));
|
|
||||||
}
|
|
||||||
|
|
||||||
items = rows.map((row) => ({
|
|
||||||
alias: row.alias as string,
|
|
||||||
labels: labelsForSiteResources
|
|
||||||
.filter((l) => l.siteResourceId === row.siteResourceId)
|
|
||||||
.map((l) => l.name)
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: ListUserResourceAliasesResponse = {
|
const data: ListUserResourceAliasesResponse = {
|
||||||
aliases,
|
aliases,
|
||||||
...(items !== undefined ? { items } : {}),
|
|
||||||
pagination: {
|
pagination: {
|
||||||
total: totalCount,
|
total: totalCount,
|
||||||
pageSize,
|
pageSize,
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import { Loader2 } from "lucide-react";
|
|
||||||
|
|
||||||
export default function OrgPageLoading() {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center py-16">
|
|
||||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+11
-36
@@ -1,9 +1,9 @@
|
|||||||
import { Layout } from "@app/components/Layout";
|
import { Layout } from "@app/components/Layout";
|
||||||
import ResourceLauncher from "@app/components/resource-launcher/ResourceLauncher";
|
import MemberResourcesPortal from "@app/components/MemberResourcesPortal";
|
||||||
import { internal } from "@app/lib/api";
|
import { internal } from "@app/lib/api";
|
||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
import { fetchLauncherPageData } from "@app/lib/launcherServerData";
|
|
||||||
import { verifySession } from "@app/lib/auth/verifySession";
|
import { verifySession } from "@app/lib/auth/verifySession";
|
||||||
|
import { pullEnv } from "@app/lib/pullEnv";
|
||||||
import UserProvider from "@app/providers/UserProvider";
|
import UserProvider from "@app/providers/UserProvider";
|
||||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||||
import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview";
|
import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview";
|
||||||
@@ -13,14 +13,12 @@ import { cache } from "react";
|
|||||||
|
|
||||||
type OrgPageProps = {
|
type OrgPageProps = {
|
||||||
params: Promise<{ orgId: string }>;
|
params: Promise<{ orgId: string }>;
|
||||||
searchParams: Promise<Record<string, string>>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function OrgPage(props: OrgPageProps) {
|
export default async function OrgPage(props: OrgPageProps) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const orgId = params.orgId;
|
const orgId = params.orgId;
|
||||||
|
const env = pullEnv();
|
||||||
|
|
||||||
if (!orgId) {
|
if (!orgId) {
|
||||||
redirect(`/`);
|
redirect(`/`);
|
||||||
@@ -42,6 +40,12 @@ export default async function OrgPage(props: OrgPageProps) {
|
|||||||
overview = res.data.data;
|
overview = res.data.data;
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
|
// If user is admin or owner, redirect to settings
|
||||||
|
if (overview?.isAdmin || overview?.isOwner) {
|
||||||
|
redirect(`/${orgId}/settings`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For non-admin users, show the member resources portal
|
||||||
let orgs: ListUserOrgsResponse["orgs"] = [];
|
let orgs: ListUserOrgsResponse["orgs"] = [];
|
||||||
try {
|
try {
|
||||||
const getOrgs = cache(async () =>
|
const getOrgs = cache(async () =>
|
||||||
@@ -56,39 +60,10 @@ export default async function OrgPage(props: OrgPageProps) {
|
|||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
const isAdminOrOwner = Boolean(overview?.isAdmin || overview?.isOwner);
|
|
||||||
|
|
||||||
const searchParams = new URLSearchParams(await props.searchParams);
|
|
||||||
const launcherData = overview
|
|
||||||
? await fetchLauncherPageData(
|
|
||||||
orgId,
|
|
||||||
searchParams,
|
|
||||||
await authCookieHeader()
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserProvider user={user}>
|
<UserProvider user={user}>
|
||||||
<Layout
|
<Layout orgId={orgId} navItems={[]} orgs={orgs}>
|
||||||
orgId={orgId}
|
{overview && <MemberResourcesPortal orgId={orgId} />}
|
||||||
orgs={orgs}
|
|
||||||
navItems={[]}
|
|
||||||
showSidebar={false}
|
|
||||||
launcherMode
|
|
||||||
showViewAsAdmin={isAdminOrOwner}
|
|
||||||
>
|
|
||||||
{overview && launcherData ? (
|
|
||||||
<ResourceLauncher
|
|
||||||
orgId={orgId}
|
|
||||||
isAdmin={isAdminOrOwner}
|
|
||||||
views={launcherData.views}
|
|
||||||
activeViewId={launcherData.activeViewId}
|
|
||||||
config={launcherData.config}
|
|
||||||
savedConfig={launcherData.savedConfig}
|
|
||||||
groups={launcherData.groups}
|
|
||||||
groupsPagination={launcherData.groupsPagination}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</Layout>
|
</Layout>
|
||||||
</UserProvider>
|
</UserProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
|||||||
const t = await getTranslations();
|
const t = await getTranslations();
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
|
{
|
||||||
|
title: "Networking",
|
||||||
|
href: "/{orgId}/settings/remote-exit-nodes/{remoteExitNodeId}/networking"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: t("credentials"),
|
title: t("credentials"),
|
||||||
href: "/{orgId}/settings/remote-exit-nodes/{remoteExitNodeId}/credentials"
|
href: "/{orgId}/settings/remote-exit-nodes/{remoteExitNodeId}/credentials"
|
||||||
|
|||||||
+240
@@ -0,0 +1,240 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
SettingsContainer,
|
||||||
|
SettingsSection,
|
||||||
|
SettingsSectionBody,
|
||||||
|
SettingsSectionDescription,
|
||||||
|
SettingsSectionFooter,
|
||||||
|
SettingsSectionHeader,
|
||||||
|
SettingsSectionTitle
|
||||||
|
} from "@app/components/Settings";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import { useRemoteExitNodeContext } from "@app/hooks/useRemoteExitNodeContext";
|
||||||
|
import { TagInput, type Tag } from "@app/components/tags/tag-input";
|
||||||
|
import { MultiSelectTagInput } from "@app/components/multi-select/multi-select-tag-input";
|
||||||
|
import type { TagValue } from "@app/components/multi-select/multi-select-content";
|
||||||
|
import { orgQueries } from "@app/lib/queries";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useDebounce } from "use-debounce";
|
||||||
|
import type { ListRemoteExitNodeResourcesResponse } from "@server/private/routers/remoteExitNode/listRemoteExitNodeResources";
|
||||||
|
import type { SetRemoteExitNodeResourcesResponse } from "@server/private/routers/remoteExitNode/setRemoteExitNodeResources";
|
||||||
|
import type { ListRemoteExitNodePreferenceLabelsResponse } from "@server/private/routers/remoteExitNode/listRemoteExitNodePreferenceLabels";
|
||||||
|
import type { SetRemoteExitNodePreferenceLabelsResponse } from "@server/private/routers/remoteExitNode/setRemoteExitNodePreferenceLabels";
|
||||||
|
|
||||||
|
const cidrRegex =
|
||||||
|
/^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(\/([0-9]|[1-2][0-9]|3[0-2]))$|^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]+|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9]))(\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$/;
|
||||||
|
|
||||||
|
export default function NetworkingPage() {
|
||||||
|
const { env } = useEnvContext();
|
||||||
|
const api = createApiClient({ env });
|
||||||
|
const { orgId } = useParams<{
|
||||||
|
orgId: string;
|
||||||
|
remoteExitNodeId: string;
|
||||||
|
}>();
|
||||||
|
const { remoteExitNode } = useRemoteExitNodeContext();
|
||||||
|
|
||||||
|
// Subnets state
|
||||||
|
const [subnets, setSubnets] = useState<Tag[]>([]);
|
||||||
|
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
|
||||||
|
const [loadingSubnets, setLoadingSubnets] = useState(true);
|
||||||
|
const [savingSubnets, setSavingSubnets] = useState(false);
|
||||||
|
|
||||||
|
// Labels state
|
||||||
|
const [selectedLabels, setSelectedLabels] = useState<TagValue[]>([]);
|
||||||
|
const [labelSearchQuery, setLabelSearchQuery] = useState("");
|
||||||
|
const [loadingLabels, setLoadingLabels] = useState(true);
|
||||||
|
const [savingLabels, setSavingLabels] = useState(false);
|
||||||
|
|
||||||
|
const [debouncedLabelQuery] = useDebounce(labelSearchQuery, 150);
|
||||||
|
|
||||||
|
const { data: availableLabels = [] } = useQuery(
|
||||||
|
orgQueries.labels({ orgId, query: debouncedLabelQuery, perPage: 10 })
|
||||||
|
);
|
||||||
|
|
||||||
|
const labelsShown = useMemo<TagValue[]>(() => {
|
||||||
|
const base: TagValue[] = availableLabels.map((l) => ({
|
||||||
|
id: l.labelId.toString(),
|
||||||
|
text: l.name,
|
||||||
|
color: l.color
|
||||||
|
}));
|
||||||
|
if (debouncedLabelQuery.trim().length === 0) {
|
||||||
|
for (const sel of selectedLabels) {
|
||||||
|
if (!base.find((b) => b.id === sel.id)) {
|
||||||
|
base.unshift(sel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}, [availableLabels, selectedLabels, debouncedLabelQuery]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadSubnets() {
|
||||||
|
try {
|
||||||
|
const res = await api.get<
|
||||||
|
AxiosResponse<ListRemoteExitNodeResourcesResponse>
|
||||||
|
>(
|
||||||
|
`/org/${orgId}/remote-exit-node/${remoteExitNode.remoteExitNodeId}/resources`
|
||||||
|
);
|
||||||
|
setSubnets(
|
||||||
|
res.data.data.resources.map((r) => ({
|
||||||
|
id: r.destination,
|
||||||
|
text: r.destination
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Error",
|
||||||
|
description:
|
||||||
|
formatAxiosError(error) || "Failed to load subnets"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoadingSubnets(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLabels() {
|
||||||
|
try {
|
||||||
|
const res = await api.get<
|
||||||
|
AxiosResponse<ListRemoteExitNodePreferenceLabelsResponse>
|
||||||
|
>(
|
||||||
|
`/org/${orgId}/remote-exit-node/${remoteExitNode.remoteExitNodeId}/preference-labels`
|
||||||
|
);
|
||||||
|
setSelectedLabels(
|
||||||
|
res.data.data.labels.map((l) => ({
|
||||||
|
id: l.labelId.toString(),
|
||||||
|
text: l.name,
|
||||||
|
color: l.color
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Error",
|
||||||
|
description:
|
||||||
|
formatAxiosError(error) || "Failed to load labels"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoadingLabels(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSubnets();
|
||||||
|
loadLabels();
|
||||||
|
}, [remoteExitNode.remoteExitNodeId]);
|
||||||
|
|
||||||
|
const handleSaveSubnets = async () => {
|
||||||
|
setSavingSubnets(true);
|
||||||
|
try {
|
||||||
|
await api.post<AxiosResponse<SetRemoteExitNodeResourcesResponse>>(
|
||||||
|
`/org/${orgId}/remote-exit-node/${remoteExitNode.remoteExitNodeId}/resources`,
|
||||||
|
{ destinations: subnets.map((s) => s.text) }
|
||||||
|
);
|
||||||
|
toast({
|
||||||
|
title: "Subnets saved",
|
||||||
|
description: "Remote subnets have been updated successfully."
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Error",
|
||||||
|
description: formatAxiosError(error) || "Failed to save subnets"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSavingSubnets(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveLabels = async () => {
|
||||||
|
setSavingLabels(true);
|
||||||
|
try {
|
||||||
|
await api.post<
|
||||||
|
AxiosResponse<SetRemoteExitNodePreferenceLabelsResponse>
|
||||||
|
>(
|
||||||
|
`/org/${orgId}/remote-exit-node/${remoteExitNode.remoteExitNodeId}/preference-labels`,
|
||||||
|
{ labelIds: selectedLabels.map((l) => parseInt(l.id)) }
|
||||||
|
);
|
||||||
|
toast({
|
||||||
|
title: "Labels saved",
|
||||||
|
description: "Preference labels have been updated successfully."
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Error",
|
||||||
|
description: formatAxiosError(error) || "Failed to save labels"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSavingLabels(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsContainer>
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>Remote Subnets</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
Define the CIDR ranges that this remote exit node will
|
||||||
|
route traffic to. Type a valid CIDR (e.g.{" "}
|
||||||
|
<code>10.0.0.0/8</code>) and press Enter to add.
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<TagInput
|
||||||
|
tags={subnets}
|
||||||
|
setTags={setSubnets}
|
||||||
|
placeholder="Add a CIDR range (e.g. 10.0.0.0/8)"
|
||||||
|
validateTag={(tag) => cidrRegex.test(tag.trim())}
|
||||||
|
activeTagIndex={activeTagIndex}
|
||||||
|
setActiveTagIndex={setActiveTagIndex}
|
||||||
|
disabled={loadingSubnets}
|
||||||
|
allowDuplicates={false}
|
||||||
|
inlineTags={true}
|
||||||
|
/>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
<SettingsSectionFooter>
|
||||||
|
<Button onClick={handleSaveSubnets} loading={savingSubnets}>
|
||||||
|
Save Subnets
|
||||||
|
</Button>
|
||||||
|
</SettingsSectionFooter>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
Preference Labels
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
Sites with these labels will be enforced to connect
|
||||||
|
through this remote exit node.
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<MultiSelectTagInput
|
||||||
|
value={selectedLabels}
|
||||||
|
options={labelsShown}
|
||||||
|
onChange={setSelectedLabels}
|
||||||
|
onSearch={setLabelSearchQuery}
|
||||||
|
searchQuery={labelSearchQuery}
|
||||||
|
disabled={loadingLabels}
|
||||||
|
buttonText="Select labels..."
|
||||||
|
searchPlaceholder="Search labels..."
|
||||||
|
/>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
<SettingsSectionFooter>
|
||||||
|
<Button onClick={handleSaveLabels} loading={savingLabels}>
|
||||||
|
Save Labels
|
||||||
|
</Button>
|
||||||
|
</SettingsSectionFooter>
|
||||||
|
</SettingsSection>
|
||||||
|
</SettingsContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,6 @@ export default async function RemoteExitNodePage(props: {
|
|||||||
}) {
|
}) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
redirect(
|
redirect(
|
||||||
`/${params.orgId}/settings/remote-exit-nodes/${params.remoteExitNodeId}/credentials`
|
`/${params.orgId}/settings/remote-exit-nodes/${params.remoteExitNodeId}/networking`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,8 +35,6 @@ import { toast } from "@app/hooks/useToast";
|
|||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
|
||||||
import { InfoIcon } from "lucide-react";
|
|
||||||
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { StrategySelect } from "@app/components/StrategySelect";
|
import { StrategySelect } from "@app/components/StrategySelect";
|
||||||
|
|
||||||
|
|||||||
+1
-9
@@ -107,15 +107,7 @@ export default async function Page(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (targetOrgId) {
|
if (targetOrgId) {
|
||||||
const targetOrg = orgs.find((org) => org.orgId === targetOrgId);
|
return <RedirectToOrg targetOrgId={targetOrgId} />;
|
||||||
return (
|
|
||||||
<RedirectToOrg
|
|
||||||
targetOrgId={targetOrgId}
|
|
||||||
isAdminOrOwner={Boolean(
|
|
||||||
targetOrg?.isAdmin || targetOrg?.isOwner
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList
|
||||||
|
} from "@app/components/ui/command";
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
@@ -8,15 +16,16 @@ import {
|
|||||||
} from "@app/components/ui/popover";
|
} from "@app/components/ui/popover";
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
|
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
|
||||||
|
import { CheckIcon, Funnel } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
import { orgQueries } from "@app/lib/queries";
|
import { orgQueries } from "@app/lib/queries";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Funnel } from "lucide-react";
|
import { useDebounce } from "use-debounce";
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { LabelBadge } from "./label-badge";
|
import { LabelBadge } from "./label-badge";
|
||||||
import { LabelOverflowBadge } from "./label-overflow-badge";
|
import { LabelOverflowBadge } from "./label-overflow-badge";
|
||||||
import { LabelsFilterSelector } from "./LabelsFilterSelector";
|
|
||||||
import { LABEL_COLORS } from "./labels-selector";
|
import { LABEL_COLORS } from "./labels-selector";
|
||||||
|
import { Checkbox } from "./ui/checkbox";
|
||||||
|
|
||||||
function areSelectionsEqual(a: string[], b: string[]) {
|
function areSelectionsEqual(a: string[], b: string[]) {
|
||||||
if (a.length !== b.length) {
|
if (a.length !== b.length) {
|
||||||
@@ -45,9 +54,13 @@ export function LabelColumnFilterButton({
|
|||||||
const [draftValues, setDraftValues] = useState<string[]>(selectedValues);
|
const [draftValues, setDraftValues] = useState<string[]>(selectedValues);
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
|
const [labelSearchQuery, setlabelsSearchQuery] = useState("");
|
||||||
|
const [debouncedQuery] = useDebounce(labelSearchQuery, 150);
|
||||||
|
|
||||||
const { data: labels = [] } = useQuery(
|
const { data: labels = [] } = useQuery(
|
||||||
orgQueries.labels({
|
orgQueries.labels({
|
||||||
orgId,
|
orgId,
|
||||||
|
query: debouncedQuery,
|
||||||
perPage: 500
|
perPage: 500
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -139,17 +152,53 @@ export function LabelColumnFilterButton({
|
|||||||
className={dataTableFilterPopoverContentClassName}
|
className={dataTableFilterPopoverContentClassName}
|
||||||
align="start"
|
align="start"
|
||||||
>
|
>
|
||||||
<LabelsFilterSelector
|
<Command shouldFilter={false}>
|
||||||
orgId={orgId}
|
<CommandInput
|
||||||
isSelected={(label) => draftSet.has(label.name)}
|
placeholder={t("labelSearch")}
|
||||||
onToggle={(label) => {
|
value={labelSearchQuery}
|
||||||
toggle(label.name);
|
onValueChange={setlabelsSearchQuery}
|
||||||
}}
|
/>
|
||||||
showClear={draftValues.length > 0}
|
<CommandList>
|
||||||
onClear={() => {
|
<CommandEmpty>{t("labelsNotFound")}</CommandEmpty>
|
||||||
setDraftValues([]);
|
<CommandGroup>
|
||||||
}}
|
{draftValues.length > 0 && (
|
||||||
/>
|
<CommandItem
|
||||||
|
onSelect={() => {
|
||||||
|
setDraftValues([]);
|
||||||
|
}}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
{t("accessFilterClear")}
|
||||||
|
</CommandItem>
|
||||||
|
)}
|
||||||
|
{labels.map((label) => (
|
||||||
|
<CommandItem
|
||||||
|
key={label.name}
|
||||||
|
value={label.name}
|
||||||
|
onSelect={() => {
|
||||||
|
toggle(label.name);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
className="pointer-events-none shrink-0"
|
||||||
|
checked={draftSet.has(label.name)}
|
||||||
|
aria-hidden
|
||||||
|
tabIndex={-1}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="size-2 rounded-full bg-(--color) flex-none"
|
||||||
|
style={{
|
||||||
|
// @ts-expect-error css color
|
||||||
|
"--color": label.color
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{label.name}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
CommandList
|
|
||||||
} from "@app/components/ui/command";
|
|
||||||
import { launcherQueries, orgQueries } from "@app/lib/queries";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useDebounce } from "use-debounce";
|
|
||||||
import { Checkbox } from "./ui/checkbox";
|
|
||||||
|
|
||||||
export type LabelFilterOption = {
|
|
||||||
labelId: number;
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type LabelsFilterSelectorProps = {
|
|
||||||
orgId: string;
|
|
||||||
isSelected: (label: LabelFilterOption) => boolean;
|
|
||||||
onToggle: (label: LabelFilterOption) => void;
|
|
||||||
onClear?: () => void;
|
|
||||||
showClear?: boolean;
|
|
||||||
scope?: "org" | "launcher";
|
|
||||||
};
|
|
||||||
|
|
||||||
export function LabelsFilterSelector({
|
|
||||||
orgId,
|
|
||||||
isSelected,
|
|
||||||
onToggle,
|
|
||||||
onClear,
|
|
||||||
showClear = false,
|
|
||||||
scope = "org"
|
|
||||||
}: LabelsFilterSelectorProps) {
|
|
||||||
const t = useTranslations();
|
|
||||||
const [labelSearchQuery, setlabelsSearchQuery] = useState("");
|
|
||||||
const [debouncedQuery] = useDebounce(labelSearchQuery, 150);
|
|
||||||
|
|
||||||
const orgLabelsQuery = useQuery({
|
|
||||||
...orgQueries.labels({
|
|
||||||
orgId,
|
|
||||||
query: debouncedQuery,
|
|
||||||
perPage: 500
|
|
||||||
}),
|
|
||||||
enabled: scope === "org"
|
|
||||||
});
|
|
||||||
const launcherLabelsQuery = useQuery({
|
|
||||||
...launcherQueries.labels({
|
|
||||||
orgId,
|
|
||||||
query: debouncedQuery,
|
|
||||||
perPage: 500
|
|
||||||
}),
|
|
||||||
enabled: scope === "launcher"
|
|
||||||
});
|
|
||||||
const labels =
|
|
||||||
scope === "launcher"
|
|
||||||
? (launcherLabelsQuery.data ?? [])
|
|
||||||
: (orgLabelsQuery.data ?? []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Command shouldFilter={false}>
|
|
||||||
<CommandInput
|
|
||||||
placeholder={t("labelSearch")}
|
|
||||||
value={labelSearchQuery}
|
|
||||||
onValueChange={setlabelsSearchQuery}
|
|
||||||
/>
|
|
||||||
<CommandList>
|
|
||||||
<CommandEmpty>{t("labelsNotFound")}</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
{showClear && onClear && (
|
|
||||||
<CommandItem
|
|
||||||
onSelect={onClear}
|
|
||||||
className="text-muted-foreground"
|
|
||||||
>
|
|
||||||
{t("accessFilterClear")}
|
|
||||||
</CommandItem>
|
|
||||||
)}
|
|
||||||
{labels.map((label) => (
|
|
||||||
<CommandItem
|
|
||||||
key={label.labelId}
|
|
||||||
value={label.name}
|
|
||||||
onSelect={() => {
|
|
||||||
onToggle(label);
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
className="pointer-events-none shrink-0"
|
|
||||||
checked={isSelected(label)}
|
|
||||||
aria-hidden
|
|
||||||
tabIndex={-1}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="size-2 rounded-full bg-(--color) flex-none"
|
|
||||||
style={{
|
|
||||||
// @ts-expect-error css color
|
|
||||||
"--color": label.color
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{label.name}
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -16,8 +16,6 @@ interface LayoutProps {
|
|||||||
showHeader?: boolean;
|
showHeader?: boolean;
|
||||||
showTopBar?: boolean;
|
showTopBar?: boolean;
|
||||||
defaultSidebarCollapsed?: boolean;
|
defaultSidebarCollapsed?: boolean;
|
||||||
launcherMode?: boolean;
|
|
||||||
showViewAsAdmin?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function Layout({
|
export async function Layout({
|
||||||
@@ -28,9 +26,7 @@ export async function Layout({
|
|||||||
showSidebar = true,
|
showSidebar = true,
|
||||||
showHeader = true,
|
showHeader = true,
|
||||||
showTopBar = true,
|
showTopBar = true,
|
||||||
defaultSidebarCollapsed = false,
|
defaultSidebarCollapsed = false
|
||||||
launcherMode = false,
|
|
||||||
showViewAsAdmin = false
|
|
||||||
}: LayoutProps) {
|
}: LayoutProps) {
|
||||||
const allCookies = await cookies();
|
const allCookies = await cookies();
|
||||||
const sidebarStateCookie = allCookies.get("pangolin-sidebar-state")?.value;
|
const sidebarStateCookie = allCookies.get("pangolin-sidebar-state")?.value;
|
||||||
@@ -68,21 +64,11 @@ export async function Layout({
|
|||||||
navItems={navItems}
|
navItems={navItems}
|
||||||
showSidebar={showSidebar}
|
showSidebar={showSidebar}
|
||||||
showTopBar={showTopBar}
|
showTopBar={showTopBar}
|
||||||
launcherMode={launcherMode}
|
|
||||||
showViewAsAdmin={showViewAsAdmin}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Desktop header */}
|
{/* Desktop header */}
|
||||||
{showHeader && (
|
{showHeader && <LayoutHeader showTopBar={showTopBar} />}
|
||||||
<LayoutHeader
|
|
||||||
showTopBar={showTopBar}
|
|
||||||
launcherMode={launcherMode}
|
|
||||||
orgId={orgId}
|
|
||||||
orgs={orgs}
|
|
||||||
showViewAsAdmin={showViewAsAdmin}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<main className="flex-1 overflow-y-auto p-3 md:p-6 w-full">
|
<main className="flex-1 overflow-y-auto p-3 md:p-6 w-full">
|
||||||
|
|||||||
@@ -8,31 +8,16 @@ import { useTheme } from "next-themes";
|
|||||||
import BrandingLogo from "./BrandingLogo";
|
import BrandingLogo from "./BrandingLogo";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
|
||||||
import { LauncherOrgSelector } from "@app/components/resource-launcher/LauncherOrgSelector";
|
|
||||||
import { Button } from "@app/components/ui/button";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
|
|
||||||
type LayoutHeaderProps = {
|
interface LayoutHeaderProps {
|
||||||
showTopBar: boolean;
|
showTopBar: boolean;
|
||||||
launcherMode?: boolean;
|
}
|
||||||
orgId?: string;
|
|
||||||
orgs?: ListUserOrgsResponse["orgs"];
|
|
||||||
showViewAsAdmin?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function LayoutHeader({
|
export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
|
||||||
showTopBar,
|
|
||||||
launcherMode = false,
|
|
||||||
orgId,
|
|
||||||
orgs,
|
|
||||||
showViewAsAdmin = false
|
|
||||||
}: LayoutHeaderProps) {
|
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const [path, setPath] = useState<string>("");
|
const [path, setPath] = useState<string>("");
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
const { isUnlocked } = useLicenseStatusContext();
|
const { isUnlocked } = useLicenseStatusContext();
|
||||||
const t = useTranslations();
|
|
||||||
|
|
||||||
const logoWidth = isUnlocked()
|
const logoWidth = isUnlocked()
|
||||||
? env.branding.logo?.navbar?.width || 98
|
? env.branding.logo?.navbar?.width || 98
|
||||||
@@ -68,38 +53,16 @@ export function LayoutHeader({
|
|||||||
<div className="relative z-10 px-6 py-2">
|
<div className="relative z-10 px-6 py-2">
|
||||||
<div className="container mx-auto max-w-12xl">
|
<div className="container mx-auto max-w-12xl">
|
||||||
<div className="h-16 flex items-center justify-between">
|
<div className="h-16 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-5 min-w-0">
|
<div className="flex items-center gap-2">
|
||||||
<Link
|
<Link href="/" className="flex items-center">
|
||||||
href="/"
|
|
||||||
className="flex items-center shrink-0"
|
|
||||||
>
|
|
||||||
<BrandingLogo
|
<BrandingLogo
|
||||||
width={logoWidth}
|
width={logoWidth}
|
||||||
height={logoHeight}
|
height={logoHeight}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
{launcherMode ? (
|
{/* {build === "saas" && (
|
||||||
<>
|
<Badge variant="secondary">Cloud Beta</Badge>
|
||||||
<LauncherOrgSelector
|
)} */}
|
||||||
orgId={orgId}
|
|
||||||
orgs={orgs}
|
|
||||||
/>
|
|
||||||
{showViewAsAdmin && orgId ? (
|
|
||||||
<Button
|
|
||||||
variant="text"
|
|
||||||
size="sm"
|
|
||||||
className="p-0"
|
|
||||||
asChild
|
|
||||||
>
|
|
||||||
<Link href={`/${orgId}/settings`}>
|
|
||||||
{t(
|
|
||||||
"resourceLauncherViewAsAdmin"
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showTopBar && (
|
{showTopBar && (
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { OrgSelector } from "@app/components/OrgSelector";
|
|||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { Menu, Server, Settings, SquareMousePointer } from "lucide-react";
|
import { ArrowRight, Menu, Server } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useUserContext } from "@app/hooks/useUserContext";
|
import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
@@ -29,8 +29,6 @@ interface LayoutMobileMenuProps {
|
|||||||
navItems: SidebarNavSection[];
|
navItems: SidebarNavSection[];
|
||||||
showSidebar: boolean;
|
showSidebar: boolean;
|
||||||
showTopBar: boolean;
|
showTopBar: boolean;
|
||||||
launcherMode?: boolean;
|
|
||||||
showViewAsAdmin?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LayoutMobileMenu({
|
export function LayoutMobileMenu({
|
||||||
@@ -38,33 +36,19 @@ export function LayoutMobileMenu({
|
|||||||
orgs,
|
orgs,
|
||||||
navItems,
|
navItems,
|
||||||
showSidebar,
|
showSidebar,
|
||||||
showTopBar,
|
showTopBar
|
||||||
launcherMode = false,
|
|
||||||
showViewAsAdmin = false
|
|
||||||
}: LayoutMobileMenuProps) {
|
}: LayoutMobileMenuProps) {
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const isAdminPage = pathname?.startsWith("/admin");
|
const isAdminPage = pathname?.startsWith("/admin");
|
||||||
const { user } = useUserContext();
|
const { user } = useUserContext();
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const showMobileNav = showSidebar || launcherMode;
|
|
||||||
const currentOrg = orgs?.find((org) => org.orgId === orgId);
|
|
||||||
const isSettingsPage = Boolean(
|
|
||||||
orgId && pathname?.includes(`/${orgId}/settings`)
|
|
||||||
);
|
|
||||||
const canViewResourceLauncher = Boolean(
|
|
||||||
currentOrg?.isAdmin || currentOrg?.isOwner
|
|
||||||
);
|
|
||||||
|
|
||||||
const mobileNavLinkClassName = cn(
|
|
||||||
"flex items-center rounded transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-secondary/50 dark:hover:bg-secondary/20 rounded-md px-3 py-1.5"
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shrink-0 md:hidden sticky top-0 z-50">
|
<div className="shrink-0 md:hidden sticky top-0 z-50">
|
||||||
<div className="h-16 flex items-center px-2">
|
<div className="h-16 flex items-center px-2">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{showMobileNav && (
|
{showSidebar && (
|
||||||
<div>
|
<div>
|
||||||
<Sheet
|
<Sheet
|
||||||
open={isMobileMenuOpen}
|
open={isMobileMenuOpen}
|
||||||
@@ -85,24 +69,24 @@ export function LayoutMobileMenu({
|
|||||||
<SheetDescription className="sr-only">
|
<SheetDescription className="sr-only">
|
||||||
{t("navbarDescription")}
|
{t("navbarDescription")}
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
{launcherMode ? (
|
<div className="w-full border-b border-border">
|
||||||
<>
|
<div className="px-1 shrink-0">
|
||||||
<div className="w-full border-b border-border">
|
<OrgSelector
|
||||||
<div className="px-1 shrink-0">
|
orgId={orgId}
|
||||||
<OrgSelector
|
orgs={orgs}
|
||||||
orgId={orgId}
|
/>
|
||||||
orgs={orgs}
|
</div>
|
||||||
/>
|
</div>
|
||||||
</div>
|
<div className="flex-1 overflow-y-auto relative">
|
||||||
</div>
|
<div className="px-3">
|
||||||
{showViewAsAdmin && orgId ? (
|
{!isAdminPage &&
|
||||||
<div className="px-3">
|
user.serverAdmin && (
|
||||||
<div className="mb-1">
|
<div className="mb-1">
|
||||||
<Link
|
<Link
|
||||||
href={`/${orgId}/settings`}
|
href="/admin"
|
||||||
className={
|
className={cn(
|
||||||
mobileNavLinkClassName
|
"flex items-center rounded transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-secondary/50 dark:hover:bg-secondary/20 rounded-md px-3 py-1.5"
|
||||||
}
|
)}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setIsMobileMenuOpen(
|
setIsMobileMenuOpen(
|
||||||
false
|
false
|
||||||
@@ -110,95 +94,25 @@ export function LayoutMobileMenu({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground mr-3">
|
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground mr-3">
|
||||||
<Settings className="h-4 w-4" />
|
<Server className="h-4 w-4" />
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-1">
|
<span className="flex-1">
|
||||||
{t(
|
{t(
|
||||||
"resourceLauncherViewAsAdmin"
|
"serverAdmin"
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
) : null}
|
<SidebarNav
|
||||||
</>
|
sections={navItems}
|
||||||
) : (
|
onItemClick={() =>
|
||||||
<>
|
setIsMobileMenuOpen(false)
|
||||||
<div className="w-full border-b border-border">
|
}
|
||||||
<div className="px-1 shrink-0">
|
/>
|
||||||
<OrgSelector
|
</div>
|
||||||
orgId={orgId}
|
<div className="sticky bottom-0 left-0 right-0 h-8 pointer-events-none bg-gradient-to-t from-card to-transparent" />
|
||||||
orgs={orgs}
|
</div>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 overflow-y-auto relative">
|
|
||||||
<div className="px-3">
|
|
||||||
{!isAdminPage &&
|
|
||||||
isSettingsPage &&
|
|
||||||
canViewResourceLauncher &&
|
|
||||||
orgId && (
|
|
||||||
<div className="mb-1">
|
|
||||||
<Link
|
|
||||||
href={`/${orgId}`}
|
|
||||||
className={
|
|
||||||
mobileNavLinkClassName
|
|
||||||
}
|
|
||||||
onClick={() =>
|
|
||||||
setIsMobileMenuOpen(
|
|
||||||
false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground mr-3">
|
|
||||||
<SquareMousePointer className="h-4 w-4" />
|
|
||||||
</span>
|
|
||||||
<span className="flex-1">
|
|
||||||
{t(
|
|
||||||
"resourceLauncherTitle"
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!isAdminPage &&
|
|
||||||
user.serverAdmin && (
|
|
||||||
<div className="mb-1">
|
|
||||||
<Link
|
|
||||||
href="/admin"
|
|
||||||
className={
|
|
||||||
mobileNavLinkClassName
|
|
||||||
}
|
|
||||||
onClick={() =>
|
|
||||||
setIsMobileMenuOpen(
|
|
||||||
false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground mr-3">
|
|
||||||
<Server className="h-4 w-4" />
|
|
||||||
</span>
|
|
||||||
<span className="flex-1">
|
|
||||||
{t(
|
|
||||||
"serverAdmin"
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<SidebarNav
|
|
||||||
sections={navItems}
|
|
||||||
onItemClick={() =>
|
|
||||||
setIsMobileMenuOpen(
|
|
||||||
false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="sticky bottom-0 left-0 right-0 h-8 pointer-events-none bg-gradient-to-t from-card to-transparent" />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,13 +18,7 @@ import { approvalQueries } from "@app/lib/queries";
|
|||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||||
import {
|
import { ArrowRight, ExternalLink, PanelRightOpen, Server } from "lucide-react";
|
||||||
ArrowRight,
|
|
||||||
ExternalLink,
|
|
||||||
PanelRightOpen,
|
|
||||||
Server,
|
|
||||||
SquareMousePointer
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -136,13 +130,6 @@ export function LayoutSidebar({
|
|||||||
const showTrial =
|
const showTrial =
|
||||||
build === "saas" && Boolean(orgId) && subscriptionContext?.isTrial;
|
build === "saas" && Boolean(orgId) && subscriptionContext?.isTrial;
|
||||||
|
|
||||||
const isSettingsPage = Boolean(
|
|
||||||
orgId && pathname?.includes(`/${orgId}/settings`)
|
|
||||||
);
|
|
||||||
const canViewResourceLauncher = Boolean(
|
|
||||||
currentOrg?.isAdmin || currentOrg?.isOwner
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -165,46 +152,6 @@ export function LayoutSidebar({
|
|||||||
/>
|
/>
|
||||||
<div className="flex-1 overflow-y-auto relative">
|
<div className="flex-1 overflow-y-auto relative">
|
||||||
<div className="px-2 pt-3">
|
<div className="px-2 pt-3">
|
||||||
{!isAdminPage &&
|
|
||||||
isSettingsPage &&
|
|
||||||
canViewResourceLauncher &&
|
|
||||||
orgId && (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"shrink-0",
|
|
||||||
isSidebarCollapsed ? "mb-4" : "mb-1"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
href={`/${orgId}`}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 rounded-md",
|
|
||||||
isSidebarCollapsed
|
|
||||||
? "px-2 py-2 justify-center"
|
|
||||||
: "px-3 py-1.5"
|
|
||||||
)}
|
|
||||||
title={
|
|
||||||
isSidebarCollapsed
|
|
||||||
? t("resourceLauncherTitle")
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground",
|
|
||||||
!isSidebarCollapsed && "mr-3"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<SquareMousePointer className="h-4 w-4" />
|
|
||||||
</span>
|
|
||||||
{!isSidebarCollapsed && (
|
|
||||||
<span className="flex-1">
|
|
||||||
{t("resourceLauncherTitle")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!isAdminPage && user.serverAdmin && (
|
{!isAdminPage && user.serverAdmin && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,29 +6,20 @@ import { getInternalRedirectTarget } from "@app/lib/internalRedirect";
|
|||||||
|
|
||||||
type RedirectToOrgProps = {
|
type RedirectToOrgProps = {
|
||||||
targetOrgId: string;
|
targetOrgId: string;
|
||||||
isAdminOrOwner?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RedirectToOrg({
|
export default function RedirectToOrg({ targetOrgId }: RedirectToOrgProps) {
|
||||||
targetOrgId,
|
|
||||||
isAdminOrOwner = false
|
|
||||||
}: RedirectToOrgProps) {
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
const target =
|
const target =
|
||||||
getInternalRedirectTarget(targetOrgId) ??
|
getInternalRedirectTarget(targetOrgId) ?? `/${targetOrgId}`;
|
||||||
(isAdminOrOwner
|
|
||||||
? `/${targetOrgId}/settings`
|
|
||||||
: `/${targetOrgId}`);
|
|
||||||
router.replace(target);
|
router.replace(target);
|
||||||
} catch {
|
} catch {
|
||||||
router.replace(
|
router.replace(`/${targetOrgId}`);
|
||||||
isAdminOrOwner ? `/${targetOrgId}/settings` : `/${targetOrgId}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}, [targetOrgId, isAdminOrOwner, router]);
|
}, [targetOrgId, router]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,11 +90,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
|||||||
</InfoSectionTitle>
|
</InfoSectionTitle>
|
||||||
<InfoSectionContent>
|
<InfoSectionContent>
|
||||||
<span className="inline-flex items-center">
|
<span className="inline-flex items-center">
|
||||||
{resource.mode == "http"
|
{resource.ssl ? "HTTPS" : "HTTP"}
|
||||||
? resource.ssl
|
|
||||||
? "HTTPS"
|
|
||||||
: "HTTP"
|
|
||||||
: resource.mode?.toUpperCase()}
|
|
||||||
</span>
|
</span>
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
</InfoSection>
|
</InfoSection>
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import * as React from "react";
|
|
||||||
|
|
||||||
import { useMediaQuery } from "@app/hooks/useMediaQuery";
|
|
||||||
import { cn } from "@app/lib/cn";
|
|
||||||
import {
|
|
||||||
Sheet,
|
|
||||||
SheetClose,
|
|
||||||
SheetDescription,
|
|
||||||
SheetFooter,
|
|
||||||
SheetHeader,
|
|
||||||
SheetOverlay,
|
|
||||||
SheetPortal,
|
|
||||||
SheetTitle,
|
|
||||||
SheetTrigger
|
|
||||||
} from "./ui/sheet";
|
|
||||||
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
|
||||||
|
|
||||||
type BaseProps = {
|
|
||||||
children: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
type RootSidePanelProps = BaseProps & {
|
|
||||||
open?: boolean;
|
|
||||||
onOpenChange?: (open: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SidePanelProps = {
|
|
||||||
className?: string;
|
|
||||||
asChild?: true;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
const desktop = "(min-width: 768px)";
|
|
||||||
|
|
||||||
const SidePanel = ({ children, ...props }: RootSidePanelProps) => {
|
|
||||||
return <Sheet {...props}>{children}</Sheet>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const SidePanelTrigger = ({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: SidePanelProps) => {
|
|
||||||
return (
|
|
||||||
<SheetTrigger className={className} {...props}>
|
|
||||||
{children}
|
|
||||||
</SheetTrigger>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const SidePanelClose = ({ className, children, ...props }: SidePanelProps) => {
|
|
||||||
return (
|
|
||||||
<SheetClose className={className} {...props}>
|
|
||||||
{children}
|
|
||||||
</SheetClose>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const SidePanelContent = ({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: SidePanelProps) => {
|
|
||||||
const isDesktop = useMediaQuery(desktop);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SheetPortal>
|
|
||||||
<SheetOverlay />
|
|
||||||
<SheetPrimitive.Content
|
|
||||||
className={cn(
|
|
||||||
"fixed z-50 flex min-h-0 flex-col gap-4 overflow-hidden border bg-card px-6 pt-6 pb-1 shadow-lg transition ease-in-out",
|
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
|
||||||
"data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0",
|
|
||||||
"data-[state=closed]:duration-200 data-[state=open]:duration-300",
|
|
||||||
isDesktop
|
|
||||||
? "inset-y-0 right-0 h-full w-2/5 border-l"
|
|
||||||
: "inset-x-0 bottom-0 max-h-[85dvh] w-full border-t",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</SheetPrimitive.Content>
|
|
||||||
</SheetPortal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const SidePanelDescription = ({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: SidePanelProps) => {
|
|
||||||
return (
|
|
||||||
<SheetDescription className={className} {...props}>
|
|
||||||
{children}
|
|
||||||
</SheetDescription>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const SidePanelHeader = ({ className, children, ...props }: SidePanelProps) => {
|
|
||||||
return (
|
|
||||||
<SheetHeader
|
|
||||||
className={cn("shrink-0 -mx-6 px-6", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</SheetHeader>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const SidePanelTitle = ({ className, children, ...props }: SidePanelProps) => {
|
|
||||||
return (
|
|
||||||
<SheetTitle className={className} {...props}>
|
|
||||||
{children}
|
|
||||||
</SheetTitle>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const SidePanelBody = ({ className, children, ...props }: SidePanelProps) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"relative min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden px-0",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<div className="space-y-4">{children}</div>
|
|
||||||
<div
|
|
||||||
className="sticky bottom-0 left-0 right-0 h-8 pointer-events-none bg-gradient-to-t from-card to-transparent"
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const SidePanelFooter = ({ className, children, ...props }: SidePanelProps) => {
|
|
||||||
return (
|
|
||||||
<SheetFooter
|
|
||||||
className={cn(
|
|
||||||
"-mt-4 shrink-0 border-t border-border py-4 -mx-6 gap-2 px-6 bg-card",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</SheetFooter>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export {
|
|
||||||
SidePanel,
|
|
||||||
SidePanelBody,
|
|
||||||
SidePanelClose,
|
|
||||||
SidePanelContent,
|
|
||||||
SidePanelDescription,
|
|
||||||
SidePanelFooter,
|
|
||||||
SidePanelHeader,
|
|
||||||
SidePanelTitle,
|
|
||||||
SidePanelTrigger
|
|
||||||
};
|
|
||||||
@@ -54,7 +54,7 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
|
|||||||
<InfoSectionTitle>{t("publicIpEndpoint")}</InfoSectionTitle>
|
<InfoSectionTitle>{t("publicIpEndpoint")}</InfoSectionTitle>
|
||||||
<InfoSectionContent>
|
<InfoSectionContent>
|
||||||
{formatPublicEndpoint(site.endpoint)}
|
{formatPublicEndpoint(site.endpoint)}
|
||||||
<span>
|
<span className="text-lg">
|
||||||
{site.countryCode &&
|
{site.countryCode &&
|
||||||
countryCodeToFlagEmoji(site.countryCode)}
|
countryCodeToFlagEmoji(site.countryCode)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -38,21 +38,6 @@ export type LabelsSelectorProps = {
|
|||||||
toggleLabel: (newlabel: SelectedLabel, action: "detach" | "attach") => void;
|
toggleLabel: (newlabel: SelectedLabel, action: "detach" | "attach") => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function formatLabelsSelectorLabel(
|
|
||||||
selectedLabels: SelectedLabel[],
|
|
||||||
t: (key: string, values?: { count: number }) => string
|
|
||||||
): string {
|
|
||||||
if (selectedLabels.length === 0) {
|
|
||||||
return t("selectLabels");
|
|
||||||
}
|
|
||||||
if (selectedLabels.length === 1) {
|
|
||||||
return selectedLabels[0]!.name;
|
|
||||||
}
|
|
||||||
return t("labelsSelectorLabelsCount", {
|
|
||||||
count: selectedLabels.length
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LABEL_COLORS = {
|
export const LABEL_COLORS = {
|
||||||
red: "#ff6467",
|
red: "#ff6467",
|
||||||
green: "#05df72",
|
green: "#05df72",
|
||||||
|
|||||||
@@ -12,7 +12,12 @@ import { CheckIcon } from "lucide-react";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Checkbox } from "../ui/checkbox";
|
import { Checkbox } from "../ui/checkbox";
|
||||||
|
|
||||||
export type TagValue = { text: string; id: string; isAdmin?: boolean };
|
export type TagValue = {
|
||||||
|
text: string;
|
||||||
|
id: string;
|
||||||
|
isAdmin?: boolean;
|
||||||
|
color?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type MultiSelectTagsProps<T extends TagValue> = {
|
export type MultiSelectTagsProps<T extends TagValue> = {
|
||||||
emptyPlaceholder?: string;
|
emptyPlaceholder?: string;
|
||||||
@@ -77,6 +82,14 @@ export function MultiSelectContent<T extends TagValue>({
|
|||||||
aria-hidden
|
aria-hidden
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
/>
|
/>
|
||||||
|
{option.color && (
|
||||||
|
<span
|
||||||
|
className="size-2 rounded-full flex-none"
|
||||||
|
style={{
|
||||||
|
backgroundColor: option.color
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{`${option.text}`}
|
{`${option.text}`}
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -66,7 +66,17 @@ export function MultiSelectTagInput<T extends TagValue>({
|
|||||||
)}
|
)}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<span>{option.text}</span>
|
{option.color && (
|
||||||
|
<span
|
||||||
|
className="size-2 rounded-full flex-none ml-1"
|
||||||
|
style={{
|
||||||
|
backgroundColor: option.color
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="max-w-40 text-ellipsis overflow-hidden">
|
||||||
|
{option.text}
|
||||||
|
</span>
|
||||||
{isLocked ? (
|
{isLocked ? (
|
||||||
<span className="p-0.5 flex-none">
|
<span className="p-0.5 flex-none">
|
||||||
<LockIcon className="size-3" />
|
<LockIcon className="size-3" />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { launcherQueries, orgQueries } from "@app/lib/queries";
|
import { orgQueries } from "@app/lib/queries";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -19,7 +19,6 @@ export type MultiSitesSelectorProps = {
|
|||||||
selectedSites: Selectedsite[];
|
selectedSites: Selectedsite[];
|
||||||
onSelectionChange: (sites: Selectedsite[]) => void;
|
onSelectionChange: (sites: Selectedsite[]) => void;
|
||||||
filterTypes?: string[];
|
filterTypes?: string[];
|
||||||
scope?: "org" | "launcher";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function formatMultiSitesSelectorLabel(
|
export function formatMultiSitesSelectorLabel(
|
||||||
@@ -41,33 +40,19 @@ export function MultiSitesSelector({
|
|||||||
orgId,
|
orgId,
|
||||||
selectedSites,
|
selectedSites,
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
filterTypes,
|
filterTypes
|
||||||
scope = "org"
|
|
||||||
}: MultiSitesSelectorProps) {
|
}: MultiSitesSelectorProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const [siteSearchQuery, setSiteSearchQuery] = useState("");
|
const [siteSearchQuery, setSiteSearchQuery] = useState("");
|
||||||
const [debouncedQuery] = useDebounce(siteSearchQuery, 150);
|
const [debouncedQuery] = useDebounce(siteSearchQuery, 150);
|
||||||
|
|
||||||
const orgSitesQuery = useQuery({
|
const { data: sites = [] } = useQuery(
|
||||||
...orgQueries.sites({
|
orgQueries.sites({
|
||||||
orgId,
|
orgId,
|
||||||
query: debouncedQuery,
|
query: debouncedQuery,
|
||||||
perPage: 10
|
perPage: 10
|
||||||
}),
|
})
|
||||||
enabled: scope === "org"
|
);
|
||||||
});
|
|
||||||
const launcherSitesQuery = useQuery({
|
|
||||||
...launcherQueries.sites({
|
|
||||||
orgId,
|
|
||||||
query: debouncedQuery,
|
|
||||||
perPage: 500
|
|
||||||
}),
|
|
||||||
enabled: scope === "launcher"
|
|
||||||
});
|
|
||||||
const sites =
|
|
||||||
scope === "launcher"
|
|
||||||
? (launcherSitesQuery.data ?? [])
|
|
||||||
: (orgSitesQuery.data ?? []);
|
|
||||||
|
|
||||||
const sitesShown = useMemo(() => {
|
const sitesShown = useMemo(() => {
|
||||||
const base = filterTypes
|
const base = filterTypes
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { cn } from "@app/lib/cn";
|
|
||||||
import { Check, Copy } from "lucide-react";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
type LauncherCopyIconProps = {
|
|
||||||
text: string;
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function LauncherCopyIcon({ text, className }: LauncherCopyIconProps) {
|
|
||||||
const t = useTranslations();
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
|
|
||||||
if (!text) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
"inline-flex size-4 shrink-0 items-center justify-center text-muted-foreground transition-colors hover:text-foreground",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
void navigator.clipboard.writeText(text);
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{copied ? (
|
|
||||||
<Check className="size-4 text-green-500" />
|
|
||||||
) : (
|
|
||||||
<Copy className="size-4" />
|
|
||||||
)}
|
|
||||||
<span className="sr-only">{t("copyText")}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Button } from "@app/components/ui/button";
|
|
||||||
import { cn } from "@app/lib/cn";
|
|
||||||
import { LayoutGrid, SearchX } from "lucide-react";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
|
|
||||||
type LauncherEmptyStateVariant = "empty" | "noResults";
|
|
||||||
|
|
||||||
type LauncherEmptyStateProps = {
|
|
||||||
variant: LauncherEmptyStateVariant;
|
|
||||||
layout: "grid" | "list";
|
|
||||||
query?: string;
|
|
||||||
onClearFilters?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
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 LauncherEmptyState({
|
|
||||||
variant,
|
|
||||||
layout,
|
|
||||||
query,
|
|
||||||
onClearFilters
|
|
||||||
}: LauncherEmptyStateProps) {
|
|
||||||
const t = useTranslations();
|
|
||||||
const isNoResults = variant === "noResults";
|
|
||||||
const Icon = isNoResults ? SearchX : LayoutGrid;
|
|
||||||
const trimmedQuery = query?.trim();
|
|
||||||
|
|
||||||
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">
|
|
||||||
<Icon className="size-5 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
<div className="max-w-md space-y-1.5">
|
|
||||||
<h3 className="text-base font-semibold text-foreground">
|
|
||||||
{isNoResults
|
|
||||||
? t("resourceLauncherEmptyStateNoResultsTitle")
|
|
||||||
: t("resourceLauncherEmptyStateTitle")}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{isNoResults
|
|
||||||
? trimmedQuery
|
|
||||||
? t(
|
|
||||||
"resourceLauncherEmptyStateNoResultsWithQuery",
|
|
||||||
{ query: trimmedQuery }
|
|
||||||
)
|
|
||||||
: t(
|
|
||||||
"resourceLauncherEmptyStateNoResultsDescription"
|
|
||||||
)
|
|
||||||
: t("resourceLauncherEmptyStateDescription")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{isNoResults && onClearFilters ? (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={onClearFilters}
|
|
||||||
>
|
|
||||||
{t("clearAllFilters")}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
formatMultiSitesSelectorLabel,
|
|
||||||
MultiSitesSelector
|
|
||||||
} from "@app/components/multi-site-selector";
|
|
||||||
import {
|
|
||||||
formatLabelsSelectorLabel,
|
|
||||||
LABEL_COLORS,
|
|
||||||
type SelectedLabel
|
|
||||||
} from "@app/components/labels-selector";
|
|
||||||
import { LabelsFilterSelector } from "@app/components/LabelsFilterSelector";
|
|
||||||
import { Button } from "@app/components/ui/button";
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger
|
|
||||||
} from "@app/components/ui/popover";
|
|
||||||
import { cn } from "@app/lib/cn";
|
|
||||||
import { launcherQueries } from "@app/lib/queries";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { ChevronsUpDown, Funnel } from "lucide-react";
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import type { Selectedsite } from "@app/components/site-selector";
|
|
||||||
|
|
||||||
type LauncherFilterPopoverProps = {
|
|
||||||
orgId: string;
|
|
||||||
selectedSites: Selectedsite[];
|
|
||||||
selectedLabels: SelectedLabel[];
|
|
||||||
onSitesChange: (sites: Selectedsite[]) => void;
|
|
||||||
onLabelsChange: (labels: SelectedLabel[]) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function LauncherFilterPopover({
|
|
||||||
orgId,
|
|
||||||
selectedSites,
|
|
||||||
selectedLabels,
|
|
||||||
onSitesChange,
|
|
||||||
onLabelsChange
|
|
||||||
}: LauncherFilterPopoverProps) {
|
|
||||||
const t = useTranslations();
|
|
||||||
const [sitesOpen, setSitesOpen] = useState(false);
|
|
||||||
const [labelsOpen, setLabelsOpen] = useState(false);
|
|
||||||
|
|
||||||
const { data: labels = [] } = useQuery(
|
|
||||||
launcherQueries.labels({
|
|
||||||
orgId,
|
|
||||||
perPage: 500
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: sites = [] } = useQuery(
|
|
||||||
launcherQueries.sites({
|
|
||||||
orgId,
|
|
||||||
perPage: 500
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const resolvedSelectedSites: Selectedsite[] = useMemo(
|
|
||||||
() =>
|
|
||||||
selectedSites.map((selected) => {
|
|
||||||
const found = sites.find(
|
|
||||||
(site) => site.siteId === selected.siteId
|
|
||||||
);
|
|
||||||
return found
|
|
||||||
? {
|
|
||||||
siteId: found.siteId,
|
|
||||||
name: found.name,
|
|
||||||
type: found.type,
|
|
||||||
online: found.online
|
|
||||||
}
|
|
||||||
: selected;
|
|
||||||
}),
|
|
||||||
[sites, selectedSites]
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectedLabelIds = useMemo(
|
|
||||||
() => new Set(selectedLabels.map((label) => label.labelId)),
|
|
||||||
[selectedLabels]
|
|
||||||
);
|
|
||||||
|
|
||||||
const resolvedSelectedLabels: SelectedLabel[] = useMemo(
|
|
||||||
() =>
|
|
||||||
selectedLabels.map((selected) => {
|
|
||||||
const found = labels.find(
|
|
||||||
(label) => label.labelId === selected.labelId
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
found ?? {
|
|
||||||
...selected,
|
|
||||||
color: selected.color || LABEL_COLORS.gray
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
[labels, selectedLabels]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover modal={false}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button variant="outline" size="icon" className="shrink-0">
|
|
||||||
<Funnel className="size-4" />
|
|
||||||
<span className="sr-only">
|
|
||||||
{t("resourceLauncherFilter")}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent align="end" className="w-72">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-sm font-semibold">{t("sites")}</p>
|
|
||||||
<Popover open={sitesOpen} onOpenChange={setSitesOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
role="combobox"
|
|
||||||
className={cn(
|
|
||||||
"w-full justify-between font-normal",
|
|
||||||
selectedSites.length === 0 &&
|
|
||||||
"text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="truncate text-left">
|
|
||||||
{formatMultiSitesSelectorLabel(
|
|
||||||
resolvedSelectedSites,
|
|
||||||
t
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent
|
|
||||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
|
||||||
align="start"
|
|
||||||
>
|
|
||||||
<MultiSitesSelector
|
|
||||||
orgId={orgId}
|
|
||||||
selectedSites={resolvedSelectedSites}
|
|
||||||
onSelectionChange={onSitesChange}
|
|
||||||
scope="launcher"
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-sm font-semibold">{t("labels")}</p>
|
|
||||||
<Popover open={labelsOpen} onOpenChange={setLabelsOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
role="combobox"
|
|
||||||
className={cn(
|
|
||||||
"w-full justify-between font-normal",
|
|
||||||
selectedLabels.length === 0 &&
|
|
||||||
"text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="truncate text-left">
|
|
||||||
{formatLabelsSelectorLabel(
|
|
||||||
resolvedSelectedLabels,
|
|
||||||
t
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent
|
|
||||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
|
||||||
align="start"
|
|
||||||
>
|
|
||||||
<LabelsFilterSelector
|
|
||||||
orgId={orgId}
|
|
||||||
scope="launcher"
|
|
||||||
isSelected={(label) =>
|
|
||||||
selectedLabelIds.has(label.labelId)
|
|
||||||
}
|
|
||||||
onToggle={(label) => {
|
|
||||||
if (
|
|
||||||
selectedLabelIds.has(label.labelId)
|
|
||||||
) {
|
|
||||||
onLabelsChange(
|
|
||||||
selectedLabels.filter(
|
|
||||||
(item) =>
|
|
||||||
item.labelId !==
|
|
||||||
label.labelId
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
onLabelsChange([
|
|
||||||
...selectedLabels,
|
|
||||||
label
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
showClear={selectedLabels.length > 0}
|
|
||||||
onClear={() => {
|
|
||||||
onLabelsChange([]);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage";
|
|
||||||
import { launcherQueries } from "@app/lib/queries";
|
|
||||||
import type {
|
|
||||||
LauncherGroup,
|
|
||||||
LauncherResource,
|
|
||||||
LauncherViewConfig
|
|
||||||
} 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 { LauncherGroupSection } from "./LauncherGroupSection";
|
|
||||||
|
|
||||||
type LauncherGroupListProps = {
|
|
||||||
orgId: string;
|
|
||||||
activeViewId: LauncherActiveViewId;
|
|
||||||
config: LauncherViewConfig;
|
|
||||||
initialGroups: LauncherGroup[];
|
|
||||||
groupsPagination: {
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
pageSize: number;
|
|
||||||
};
|
|
||||||
onClearFilters?: () => void;
|
|
||||||
onResourceSelect?: (resource: LauncherResource) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
function hasActiveLauncherFilters(config: LauncherViewConfig): boolean {
|
|
||||||
return (
|
|
||||||
config.query.trim().length > 0 ||
|
|
||||||
config.siteIds.length > 0 ||
|
|
||||||
config.labelIds.length > 0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LauncherGroupList({
|
|
||||||
orgId,
|
|
||||||
activeViewId,
|
|
||||||
config,
|
|
||||||
initialGroups,
|
|
||||||
groupsPagination,
|
|
||||||
onClearFilters,
|
|
||||||
onResourceSelect
|
|
||||||
}: LauncherGroupListProps) {
|
|
||||||
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
const groupFilters = useMemo(
|
|
||||||
() => ({
|
|
||||||
query: config.query,
|
|
||||||
groupBy: config.groupBy,
|
|
||||||
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.groups(orgId, groupFilters),
|
|
||||||
initialData: {
|
|
||||||
pages: [
|
|
||||||
{
|
|
||||||
groups: initialGroups,
|
|
||||||
pagination: groupsPagination
|
|
||||||
}
|
|
||||||
],
|
|
||||||
pageParams: [1]
|
|
||||||
},
|
|
||||||
refetchOnMount: false
|
|
||||||
});
|
|
||||||
|
|
||||||
const groups = data?.pages.flatMap((page) => page.groups) ?? [];
|
|
||||||
|
|
||||||
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 (groups.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">
|
|
||||||
{groups.map((group) => (
|
|
||||||
<LauncherGroupSection
|
|
||||||
key={group.groupKey}
|
|
||||||
orgId={orgId}
|
|
||||||
activeViewId={activeViewId}
|
|
||||||
group={group}
|
|
||||||
config={config}
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Collapsible,
|
|
||||||
CollapsibleContent
|
|
||||||
} from "@app/components/ui/collapsible";
|
|
||||||
import { cn } from "@app/lib/cn";
|
|
||||||
import {
|
|
||||||
readLauncherGroupOpen,
|
|
||||||
writeLauncherGroupOpen,
|
|
||||||
type LauncherActiveViewId
|
|
||||||
} from "@app/lib/launcherLocalStorage";
|
|
||||||
import { launcherQueries } from "@app/lib/queries";
|
|
||||||
import type {
|
|
||||||
LauncherGroup,
|
|
||||||
LauncherResource,
|
|
||||||
LauncherViewConfig
|
|
||||||
} from "@server/routers/launcher/types";
|
|
||||||
import {
|
|
||||||
LAUNCHER_NO_SITE_GROUP_KEY,
|
|
||||||
LAUNCHER_UNLABELED_GROUP_KEY
|
|
||||||
} from "@server/routers/launcher/types";
|
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { LauncherGroupTrigger } from "./LauncherGroupTrigger";
|
|
||||||
import { LauncherResourceGrid } from "./LauncherResourceGrid";
|
|
||||||
import { LauncherResourceList } from "./LauncherResourceList";
|
|
||||||
|
|
||||||
type LauncherGroupSectionProps = {
|
|
||||||
orgId: string;
|
|
||||||
activeViewId: LauncherActiveViewId;
|
|
||||||
group: LauncherGroup;
|
|
||||||
config: LauncherViewConfig;
|
|
||||||
initialResources?: LauncherResource[];
|
|
||||||
initialResourcesPagination?: {
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
pageSize: number;
|
|
||||||
};
|
|
||||||
defaultOpen?: boolean;
|
|
||||||
onResourceSelect?: (resource: LauncherResource) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function LauncherGroupSection({
|
|
||||||
orgId,
|
|
||||||
activeViewId,
|
|
||||||
group,
|
|
||||||
config,
|
|
||||||
initialResources,
|
|
||||||
initialResourcesPagination,
|
|
||||||
defaultOpen = true,
|
|
||||||
onResourceSelect
|
|
||||||
}: LauncherGroupSectionProps) {
|
|
||||||
const t = useTranslations();
|
|
||||||
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const [isOpen, setIsOpen] = useState(() =>
|
|
||||||
readLauncherGroupOpen(
|
|
||||||
orgId,
|
|
||||||
activeViewId,
|
|
||||||
config.groupBy,
|
|
||||||
group.groupKey,
|
|
||||||
defaultOpen
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setIsOpen(
|
|
||||||
readLauncherGroupOpen(
|
|
||||||
orgId,
|
|
||||||
activeViewId,
|
|
||||||
config.groupBy,
|
|
||||||
group.groupKey,
|
|
||||||
defaultOpen
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}, [activeViewId, config.groupBy, defaultOpen, group.groupKey, orgId]);
|
|
||||||
|
|
||||||
const handleOpenChange = (open: boolean) => {
|
|
||||||
setIsOpen(open);
|
|
||||||
writeLauncherGroupOpen(
|
|
||||||
orgId,
|
|
||||||
activeViewId,
|
|
||||||
config.groupBy,
|
|
||||||
group.groupKey,
|
|
||||||
open
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasInitialResources = initialResources !== undefined;
|
|
||||||
|
|
||||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } =
|
|
||||||
useInfiniteQuery({
|
|
||||||
...launcherQueries.resources(orgId, {
|
|
||||||
query: config.query,
|
|
||||||
groupBy: config.groupBy,
|
|
||||||
groupKey: group.groupKey,
|
|
||||||
siteIds: config.siteIds,
|
|
||||||
labelIds: config.labelIds,
|
|
||||||
sort_by: config.sortBy,
|
|
||||||
order: config.order,
|
|
||||||
pageSize: 20
|
|
||||||
}),
|
|
||||||
enabled: isOpen,
|
|
||||||
refetchOnMount: false,
|
|
||||||
...(hasInitialResources
|
|
||||||
? {
|
|
||||||
initialData: {
|
|
||||||
pages: [
|
|
||||||
{
|
|
||||||
resources: initialResources,
|
|
||||||
pagination: initialResourcesPagination ?? {
|
|
||||||
total: initialResources.length,
|
|
||||||
page: 1,
|
|
||||||
pageSize: 20
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
pageParams: [1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
: {})
|
|
||||||
});
|
|
||||||
|
|
||||||
const resources = data?.pages.flatMap((page) => page.resources) ?? [];
|
|
||||||
const showInitialLoader = isLoading && resources.length === 0;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const node = loadMoreRef.current;
|
|
||||||
if (!node || !hasNextPage || !isOpen) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
if (entries[0]?.isIntersecting && !isFetchingNextPage) {
|
|
||||||
void fetchNextPage();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ rootMargin: "200px" }
|
|
||||||
);
|
|
||||||
|
|
||||||
observer.observe(node);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [fetchNextPage, hasNextPage, isFetchingNextPage, isOpen]);
|
|
||||||
|
|
||||||
const groupTitle =
|
|
||||||
group.groupKey === LAUNCHER_UNLABELED_GROUP_KEY
|
|
||||||
? t("resourceLauncherUnlabeled")
|
|
||||||
: group.groupKey === LAUNCHER_NO_SITE_GROUP_KEY
|
|
||||||
? t("resourceLauncherNoSite")
|
|
||||||
: group.name;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Collapsible
|
|
||||||
open={isOpen}
|
|
||||||
onOpenChange={handleOpenChange}
|
|
||||||
className="flex w-full flex-col gap-2.5"
|
|
||||||
>
|
|
||||||
<LauncherGroupTrigger
|
|
||||||
group={group}
|
|
||||||
title={groupTitle}
|
|
||||||
isOpen={isOpen}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<CollapsibleContent className="w-full">
|
|
||||||
{showInitialLoader ? (
|
|
||||||
<div className="flex items-center justify-center py-10 text-muted-foreground">
|
|
||||||
<Loader2 className="size-5 animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : resources.length === 0 ? (
|
|
||||||
<p className="py-4 text-sm text-muted-foreground">
|
|
||||||
{t("resourceLauncherNoResourcesInGroup")}
|
|
||||||
</p>
|
|
||||||
) : config.layout === "grid" ? (
|
|
||||||
<LauncherResourceGrid
|
|
||||||
resources={resources}
|
|
||||||
showLabels={config.showLabels}
|
|
||||||
onResourceSelect={onResourceSelect}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<LauncherResourceList
|
|
||||||
resources={resources}
|
|
||||||
showLabels={config.showLabels}
|
|
||||||
onResourceSelect={onResourceSelect}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
ref={loadMoreRef}
|
|
||||||
className={cn("h-4", !hasNextPage && "hidden")}
|
|
||||||
/>
|
|
||||||
{isFetchingNextPage ? (
|
|
||||||
<div className="flex justify-center py-2">
|
|
||||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { CollapsibleTrigger } from "@app/components/ui/collapsible";
|
|
||||||
import type { LauncherGroup } from "@server/routers/launcher/types";
|
|
||||||
import { ChevronDown, ChevronLeft } from "lucide-react";
|
|
||||||
|
|
||||||
type LauncherGroupTriggerProps = {
|
|
||||||
group: LauncherGroup;
|
|
||||||
title: string;
|
|
||||||
isOpen: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
function LauncherGroupStatusDot({ group }: { group: LauncherGroup }) {
|
|
||||||
if (group.groupType === "label") {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className="size-2 shrink-0 rounded-full"
|
|
||||||
style={{ backgroundColor: group.labelColor }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (group.groupType === "site") {
|
|
||||||
if (
|
|
||||||
(group.siteType === "newt" || group.siteType === "wireguard") &&
|
|
||||||
typeof group.siteOnline === "boolean"
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
group.siteOnline
|
|
||||||
? "size-2 shrink-0 rounded-full bg-green-500"
|
|
||||||
: "size-2 shrink-0 rounded-full bg-neutral-500"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <span className="size-2 shrink-0 rounded-full bg-neutral-500" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LauncherGroupTrigger({
|
|
||||||
group,
|
|
||||||
title,
|
|
||||||
isOpen
|
|
||||||
}: LauncherGroupTriggerProps) {
|
|
||||||
return (
|
|
||||||
<CollapsibleTrigger className="flex w-full items-center gap-2.5 rounded-md bg-accent px-4 py-2.5 text-left transition-colors cursor-pointer">
|
|
||||||
{group.groupType === "site" || group.groupType === "label" ? (
|
|
||||||
<LauncherGroupStatusDot group={group} />
|
|
||||||
) : null}
|
|
||||||
<span className="flex min-w-0 items-center gap-2.5 text-sm font-semibold text-foreground">
|
|
||||||
<span className="truncate">
|
|
||||||
{title} ({group.itemCount})
|
|
||||||
</span>
|
|
||||||
{isOpen ? (
|
|
||||||
<ChevronDown className="size-4 shrink-0 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<ChevronLeft className="size-4 shrink-0 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { LauncherLabel } from "@server/routers/launcher/types";
|
|
||||||
import { LabelBadge } from "@app/components/label-badge";
|
|
||||||
import { LabelOverflowBadge } from "@app/components/label-overflow-badge";
|
|
||||||
import { cn } from "@app/lib/cn";
|
|
||||||
import { useLayoutEffect, useRef, useState } from "react";
|
|
||||||
|
|
||||||
const MAX_LABEL_ROWS = 2;
|
|
||||||
const SINGLE_ROW_MAX_LABELS = 5;
|
|
||||||
|
|
||||||
type LauncherLabelsRowProps = {
|
|
||||||
labels: LauncherLabel[];
|
|
||||||
className?: string;
|
|
||||||
variant?: "wrap" | "single-row";
|
|
||||||
};
|
|
||||||
|
|
||||||
function countFlexRows(container: HTMLElement): number {
|
|
||||||
const rowTops = new Set<number>();
|
|
||||||
|
|
||||||
for (const child of container.children) {
|
|
||||||
const element = child as HTMLElement;
|
|
||||||
if (element.style.display === "none") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
rowTops.add(element.offsetTop);
|
|
||||||
}
|
|
||||||
|
|
||||||
return rowTops.size;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LauncherLabelsRow({
|
|
||||||
labels,
|
|
||||||
className,
|
|
||||||
variant = "wrap"
|
|
||||||
}: LauncherLabelsRowProps) {
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const measureRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [visibleCount, setVisibleCount] = useState(labels.length);
|
|
||||||
|
|
||||||
const labelKey = labels.map((label) => label.labelId).join(",");
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (variant === "single-row") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const container = containerRef.current;
|
|
||||||
const measure = measureRef.current;
|
|
||||||
if (!container || !measure || labels.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const recompute = () => {
|
|
||||||
const width = container.clientWidth;
|
|
||||||
if (width <= 0) {
|
|
||||||
setVisibleCount(labels.length);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
measure.style.width = `${width}px`;
|
|
||||||
|
|
||||||
const labelNodes = measure.querySelectorAll<HTMLElement>(
|
|
||||||
"[data-measure-label]"
|
|
||||||
);
|
|
||||||
const overflowNode = measure.querySelector<HTMLElement>(
|
|
||||||
"[data-measure-overflow]"
|
|
||||||
);
|
|
||||||
|
|
||||||
const fits = (visible: number) => {
|
|
||||||
labelNodes.forEach((node, index) => {
|
|
||||||
node.style.display = index < visible ? "" : "none";
|
|
||||||
});
|
|
||||||
|
|
||||||
if (overflowNode) {
|
|
||||||
const overflowCount = labels.length - visible;
|
|
||||||
if (overflowCount > 0) {
|
|
||||||
overflowNode.style.display = "";
|
|
||||||
} else {
|
|
||||||
overflowNode.style.display = "none";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return countFlexRows(measure) <= MAX_LABEL_ROWS;
|
|
||||||
};
|
|
||||||
|
|
||||||
let best = 0;
|
|
||||||
for (let visible = labels.length; visible >= 0; visible--) {
|
|
||||||
if (fits(visible)) {
|
|
||||||
best = visible;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setVisibleCount(best);
|
|
||||||
};
|
|
||||||
|
|
||||||
recompute();
|
|
||||||
|
|
||||||
const observer = new ResizeObserver(recompute);
|
|
||||||
observer.observe(container);
|
|
||||||
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [labelKey, labels, variant]);
|
|
||||||
|
|
||||||
if (labels.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedVisibleCount =
|
|
||||||
variant === "single-row"
|
|
||||||
? Math.min(labels.length, SINGLE_ROW_MAX_LABELS)
|
|
||||||
: visibleCount;
|
|
||||||
const visibleLabels = labels.slice(0, resolvedVisibleCount);
|
|
||||||
const overflowLabels = labels.slice(resolvedVisibleCount);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
className={cn("relative min-w-0 w-full", className)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-1",
|
|
||||||
variant === "single-row" ? "flex-nowrap" : "flex-wrap"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{visibleLabels.map((label) => (
|
|
||||||
<LabelBadge
|
|
||||||
key={label.labelId}
|
|
||||||
name={label.name}
|
|
||||||
color={label.color}
|
|
||||||
displayOnly
|
|
||||||
className="shrink-0"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{overflowLabels.length > 0 ? (
|
|
||||||
<LabelOverflowBadge
|
|
||||||
labels={overflowLabels.map((label) => ({
|
|
||||||
color: label.color,
|
|
||||||
name: label.name
|
|
||||||
}))}
|
|
||||||
displayOnly
|
|
||||||
className="shrink-0"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{variant === "wrap" ? (
|
|
||||||
<div
|
|
||||||
ref={measureRef}
|
|
||||||
className="pointer-events-none invisible absolute left-0 top-0 flex flex-wrap items-center gap-1"
|
|
||||||
aria-hidden
|
|
||||||
>
|
|
||||||
{labels.map((label) => (
|
|
||||||
<span key={label.labelId} data-measure-label>
|
|
||||||
<LabelBadge
|
|
||||||
name={label.name}
|
|
||||||
color={label.color}
|
|
||||||
displayOnly
|
|
||||||
className="shrink-0"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
<span
|
|
||||||
data-measure-overflow
|
|
||||||
className="inline-flex shrink-0"
|
|
||||||
>
|
|
||||||
<LabelOverflowBadge labels={labels} displayOnly />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
CommandList
|
|
||||||
} from "@app/components/ui/command";
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger
|
|
||||||
} from "@app/components/ui/popover";
|
|
||||||
import { cn } from "@app/lib/cn";
|
|
||||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
|
||||||
import { Check, ChevronDown, ChevronsUpDown } from "lucide-react";
|
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { Button } from "@app/components/ui/button";
|
|
||||||
|
|
||||||
type LauncherOrgSelectorProps = {
|
|
||||||
orgId?: string;
|
|
||||||
orgs?: ListUserOrgsResponse["orgs"];
|
|
||||||
};
|
|
||||||
|
|
||||||
export function LauncherOrgSelector({ orgId, orgs }: LauncherOrgSelectorProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const router = useRouter();
|
|
||||||
const pathname = usePathname();
|
|
||||||
const t = useTranslations();
|
|
||||||
|
|
||||||
const selectedOrg = orgs?.find((org) => org.orgId === orgId);
|
|
||||||
|
|
||||||
const sortedOrgs = useMemo(() => {
|
|
||||||
if (!orgs?.length) {
|
|
||||||
return orgs ?? [];
|
|
||||||
}
|
|
||||||
return [...orgs].sort((a, b) => {
|
|
||||||
const aPrimary = Boolean(a.isPrimaryOrg);
|
|
||||||
const bPrimary = Boolean(b.isPrimaryOrg);
|
|
||||||
if (aPrimary && !bPrimary) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
if (!aPrimary && bPrimary) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
}, [orgs]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
className="inline-flex items-center gap-1 p-0"
|
|
||||||
variant="text"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<span className="truncate max-w-[200px]">
|
|
||||||
{selectedOrg?.name ?? t("noneSelected")}
|
|
||||||
</span>
|
|
||||||
<ChevronDown className="size-4 shrink-0" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-[320px] p-0" align="start">
|
|
||||||
<Command className="rounded-lg border-0">
|
|
||||||
<CommandInput placeholder={t("searchPlaceholder")} />
|
|
||||||
<CommandList className="max-h-[280px]">
|
|
||||||
<CommandEmpty>{t("orgNotFound2")}</CommandEmpty>
|
|
||||||
<CommandGroup heading={t("orgs")}>
|
|
||||||
{sortedOrgs.map((org) => (
|
|
||||||
<CommandItem
|
|
||||||
key={org.orgId}
|
|
||||||
onSelect={() => {
|
|
||||||
setOpen(false);
|
|
||||||
const newPath = pathname.includes(
|
|
||||||
"/settings/"
|
|
||||||
)
|
|
||||||
? pathname.replace(
|
|
||||||
/^\/[^/]+/,
|
|
||||||
`/${org.orgId}`
|
|
||||||
)
|
|
||||||
: `/${org.orgId}`;
|
|
||||||
router.push(newPath);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col flex-1 min-w-0">
|
|
||||||
<span className="font-medium truncate text-sm">
|
|
||||||
{org.name}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground font-mono truncate">
|
|
||||||
{org.orgId}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"h-4 w-4 text-primary shrink-0",
|
|
||||||
orgId === org.orgId
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Button } from "@app/components/ui/button";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { RefreshCw } from "lucide-react";
|
|
||||||
|
|
||||||
type LauncherRefreshButtonProps = {
|
|
||||||
onRefresh: () => void;
|
|
||||||
isRefreshing: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function LauncherRefreshButton({
|
|
||||||
onRefresh,
|
|
||||||
isRefreshing
|
|
||||||
}: LauncherRefreshButtonProps) {
|
|
||||||
const t = useTranslations();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={onRefresh}
|
|
||||||
disabled={isRefreshing}
|
|
||||||
className="shrink-0"
|
|
||||||
>
|
|
||||||
<RefreshCw
|
|
||||||
className={`mr-0 sm:mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
|
|
||||||
/>
|
|
||||||
<span className="hidden sm:inline">{t("refresh")}</span>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { isSafeUrlForLink } from "@app/lib/launcherResourceAccess";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { LauncherCopyIcon } from "./LauncherCopyIcon";
|
|
||||||
|
|
||||||
type LauncherResourceAccessProps = {
|
|
||||||
accessDisplay: string;
|
|
||||||
accessCopyValue: string;
|
|
||||||
accessUrl?: string | null;
|
|
||||||
variant: "grid" | "list";
|
|
||||||
};
|
|
||||||
|
|
||||||
export function LauncherResourceAccess({
|
|
||||||
accessDisplay,
|
|
||||||
accessCopyValue,
|
|
||||||
accessUrl,
|
|
||||||
variant
|
|
||||||
}: LauncherResourceAccessProps) {
|
|
||||||
if (!accessDisplay) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const href = accessUrl ?? undefined;
|
|
||||||
const canLink = href && isSafeUrlForLink(href);
|
|
||||||
const copyValue = canLink ? href : accessCopyValue;
|
|
||||||
|
|
||||||
if (variant === "list") {
|
|
||||||
return (
|
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2.5 max-md:min-w-[12rem] max-md:shrink-0 max-md:flex-none">
|
|
||||||
{canLink ? (
|
|
||||||
<Link
|
|
||||||
href={href}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="min-w-0 truncate text-sm text-muted-foreground hover:underline max-md:overflow-visible max-md:whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{accessDisplay}
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<span className="min-w-0 truncate text-sm text-muted-foreground max-md:overflow-visible max-md:whitespace-nowrap">
|
|
||||||
{accessDisplay}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<LauncherCopyIcon text={copyValue} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex w-full min-w-0 items-center gap-2.5">
|
|
||||||
{canLink ? (
|
|
||||||
<Link
|
|
||||||
href={href}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="min-w-0 flex-1 truncate text-sm text-muted-foreground hover:underline"
|
|
||||||
>
|
|
||||||
{accessDisplay}
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<span className="min-w-0 flex-1 truncate text-sm text-muted-foreground">
|
|
||||||
{accessDisplay}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<LauncherCopyIcon text={copyValue} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { cn } from "@app/lib/cn";
|
|
||||||
import type { LauncherResource } from "@server/routers/launcher/types";
|
|
||||||
import { LauncherLabelsRow } from "./LauncherLabelsRow";
|
|
||||||
import { LauncherResourceAccess } from "./LauncherResourceAccess";
|
|
||||||
import { LauncherResourceIcon } from "./LauncherResourceIcon";
|
|
||||||
import { getLauncherResourceSelectProps } from "./useLauncherResourceAction";
|
|
||||||
|
|
||||||
type LauncherResourceCardProps = {
|
|
||||||
resource: LauncherResource;
|
|
||||||
showLabels: boolean;
|
|
||||||
onSelect?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function LauncherResourceCard({
|
|
||||||
resource,
|
|
||||||
showLabels,
|
|
||||||
onSelect
|
|
||||||
}: LauncherResourceCardProps) {
|
|
||||||
const hasIcon = Boolean(resource.iconUrl);
|
|
||||||
const clickProps = onSelect
|
|
||||||
? getLauncherResourceSelectProps(onSelect)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex min-w-0 flex-col gap-2.5 overflow-hidden rounded-xl border border-border bg-background p-4",
|
|
||||||
clickProps?.className
|
|
||||||
)}
|
|
||||||
onClick={clickProps?.onClick}
|
|
||||||
onKeyDown={clickProps?.onKeyDown}
|
|
||||||
role={clickProps?.role}
|
|
||||||
tabIndex={clickProps?.tabIndex}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex w-full items-center",
|
|
||||||
hasIcon ? "gap-5" : "gap-0"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{hasIcon ? (
|
|
||||||
<LauncherResourceIcon
|
|
||||||
iconUrl={resource.iconUrl}
|
|
||||||
name={resource.name}
|
|
||||||
variant="grid"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
||||||
<div className="truncate text-sm font-semibold text-foreground">
|
|
||||||
{resource.name}
|
|
||||||
</div>
|
|
||||||
<LauncherResourceAccess
|
|
||||||
accessDisplay={resource.accessDisplay}
|
|
||||||
accessCopyValue={resource.accessCopyValue}
|
|
||||||
accessUrl={resource.accessUrl}
|
|
||||||
variant="grid"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showLabels && resource.labels.length > 0 ? (
|
|
||||||
<LauncherLabelsRow labels={resource.labels} />
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { LauncherResource } from "@server/routers/launcher/types";
|
|
||||||
import { LauncherResourceCard } from "./LauncherResourceCard";
|
|
||||||
|
|
||||||
type LauncherResourceGridProps = {
|
|
||||||
resources: LauncherResource[];
|
|
||||||
showLabels: boolean;
|
|
||||||
onResourceSelect?: (resource: LauncherResource) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function LauncherResourceGrid({
|
|
||||||
resources,
|
|
||||||
showLabels,
|
|
||||||
onResourceSelect
|
|
||||||
}: LauncherResourceGridProps) {
|
|
||||||
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">
|
|
||||||
{resources.map((resource) => (
|
|
||||||
<LauncherResourceCard
|
|
||||||
key={resource.launcherResourceKey}
|
|
||||||
resource={resource}
|
|
||||||
showLabels={showLabels}
|
|
||||||
onSelect={
|
|
||||||
onResourceSelect
|
|
||||||
? () => onResourceSelect(resource)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { cn } from "@app/lib/cn";
|
|
||||||
|
|
||||||
type LauncherResourceIconProps = {
|
|
||||||
iconUrl?: string | null;
|
|
||||||
name: string;
|
|
||||||
className?: string;
|
|
||||||
variant?: "grid" | "list";
|
|
||||||
};
|
|
||||||
|
|
||||||
export function LauncherResourceIcon({
|
|
||||||
iconUrl,
|
|
||||||
name,
|
|
||||||
className,
|
|
||||||
variant = "grid"
|
|
||||||
}: LauncherResourceIconProps) {
|
|
||||||
const dimension = variant === "list" ? "size-5" : "size-10";
|
|
||||||
|
|
||||||
if (iconUrl) {
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
src={iconUrl}
|
|
||||||
alt={name}
|
|
||||||
className={cn(dimension, "shrink-0 object-cover", className)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (variant === "list") {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
dimension,
|
|
||||||
"flex shrink-0 items-center justify-center text-muted-foreground",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="text-sm font-semibold">-</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { LauncherResource } from "@server/routers/launcher/types";
|
|
||||||
import { LauncherResourceRow } from "./LauncherResourceRow";
|
|
||||||
|
|
||||||
type LauncherResourceListProps = {
|
|
||||||
resources: LauncherResource[];
|
|
||||||
showLabels: boolean;
|
|
||||||
onResourceSelect?: (resource: LauncherResource) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function LauncherResourceList({
|
|
||||||
resources,
|
|
||||||
showLabels,
|
|
||||||
onResourceSelect
|
|
||||||
}: LauncherResourceListProps) {
|
|
||||||
return (
|
|
||||||
<div className="w-full max-md:overflow-x-auto max-md:overflow-y-hidden">
|
|
||||||
<div className="flex w-full flex-col max-md:w-max">
|
|
||||||
{resources.map((resource, index) => (
|
|
||||||
<LauncherResourceRow
|
|
||||||
key={resource.launcherResourceKey}
|
|
||||||
resource={resource}
|
|
||||||
showLabels={showLabels}
|
|
||||||
isLast={index === resources.length - 1}
|
|
||||||
onSelect={
|
|
||||||
onResourceSelect
|
|
||||||
? () => onResourceSelect(resource)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
SidePanel,
|
|
||||||
SidePanelBody,
|
|
||||||
SidePanelContent,
|
|
||||||
SidePanelDescription,
|
|
||||||
SidePanelFooter,
|
|
||||||
SidePanelHeader,
|
|
||||||
SidePanelTitle
|
|
||||||
} from "@app/components/SidePanel";
|
|
||||||
import { Button } from "@app/components/ui/button";
|
|
||||||
import { getLauncherResourceAdminHref } from "@app/lib/launcherResourceAdminHref";
|
|
||||||
import type { LauncherResource } from "@server/routers/launcher/types";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
type LauncherResourcePanelProps = {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
resource: LauncherResource | null;
|
|
||||||
orgId: string;
|
|
||||||
isAdmin: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function LauncherResourcePanel({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
resource,
|
|
||||||
orgId,
|
|
||||||
isAdmin
|
|
||||||
}: LauncherResourcePanelProps) {
|
|
||||||
const t = useTranslations();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidePanel open={open} onOpenChange={onOpenChange}>
|
|
||||||
<SidePanelContent>
|
|
||||||
<SidePanelHeader>
|
|
||||||
<SidePanelTitle>{resource?.name ?? ""}</SidePanelTitle>
|
|
||||||
<SidePanelDescription>
|
|
||||||
{t("resourceLauncherResourceDetailsDescription")}
|
|
||||||
</SidePanelDescription>
|
|
||||||
</SidePanelHeader>
|
|
||||||
<SidePanelBody />
|
|
||||||
<SidePanelFooter>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
>
|
|
||||||
{t("close")}
|
|
||||||
</Button>
|
|
||||||
{isAdmin && resource ? (
|
|
||||||
<Button variant="outline" asChild>
|
|
||||||
<Link
|
|
||||||
href={getLauncherResourceAdminHref(
|
|
||||||
orgId,
|
|
||||||
resource
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{t("resourceLauncherViewAsAdmin")}
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</SidePanelFooter>
|
|
||||||
</SidePanelContent>
|
|
||||||
</SidePanel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { cn } from "@app/lib/cn";
|
|
||||||
import type { LauncherResource } from "@server/routers/launcher/types";
|
|
||||||
import { LauncherLabelsRow } from "./LauncherLabelsRow";
|
|
||||||
import { LauncherResourceAccess } from "./LauncherResourceAccess";
|
|
||||||
import { LauncherResourceIcon } from "./LauncherResourceIcon";
|
|
||||||
import { getLauncherResourceSelectProps } from "./useLauncherResourceAction";
|
|
||||||
|
|
||||||
type LauncherResourceRowProps = {
|
|
||||||
resource: LauncherResource;
|
|
||||||
showLabels: boolean;
|
|
||||||
isLast?: boolean;
|
|
||||||
onSelect?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function LauncherResourceRow({
|
|
||||||
resource,
|
|
||||||
showLabels,
|
|
||||||
isLast = false,
|
|
||||||
onSelect
|
|
||||||
}: LauncherResourceRowProps) {
|
|
||||||
const hasTags = showLabels && resource.labels.length > 0;
|
|
||||||
const clickProps = onSelect
|
|
||||||
? getLauncherResourceSelectProps(onSelect)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2.5 p-4 max-md:min-w-max max-md:whitespace-nowrap",
|
|
||||||
isLast ? undefined : "border-b border-border",
|
|
||||||
clickProps?.className
|
|
||||||
)}
|
|
||||||
onClick={clickProps?.onClick}
|
|
||||||
onKeyDown={clickProps?.onKeyDown}
|
|
||||||
role={clickProps?.role}
|
|
||||||
tabIndex={clickProps?.tabIndex}
|
|
||||||
>
|
|
||||||
<LauncherResourceIcon
|
|
||||||
iconUrl={resource.iconUrl}
|
|
||||||
name={resource.name}
|
|
||||||
variant="list"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<span className="shrink-0 text-sm font-semibold text-foreground">
|
|
||||||
{resource.name}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<LauncherResourceAccess
|
|
||||||
accessDisplay={resource.accessDisplay}
|
|
||||||
accessCopyValue={resource.accessCopyValue}
|
|
||||||
accessUrl={resource.accessUrl}
|
|
||||||
variant="list"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{hasTags ? (
|
|
||||||
<div className="flex min-w-0 max-w-md shrink items-center justify-end gap-1 max-md:shrink-0 max-md:max-w-none md:ml-auto">
|
|
||||||
<LauncherLabelsRow
|
|
||||||
labels={resource.labels}
|
|
||||||
variant="single-row"
|
|
||||||
className="w-auto shrink-0 justify-end"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Button } from "@app/components/ui/button";
|
|
||||||
import { Label } from "@app/components/ui/label";
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger
|
|
||||||
} from "@app/components/ui/popover";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue
|
|
||||||
} from "@app/components/ui/select";
|
|
||||||
import { Switch } from "@app/components/ui/switch";
|
|
||||||
import type { LauncherViewConfig } from "@server/routers/launcher/types";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { Settings } from "lucide-react";
|
|
||||||
|
|
||||||
type LauncherSettingsMenuProps = {
|
|
||||||
config: LauncherViewConfig;
|
|
||||||
isDefaultView: boolean;
|
|
||||||
onConfigChange: (patch: Partial<LauncherViewConfig>) => void;
|
|
||||||
onDeleteView: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function LauncherSettingsMenu({
|
|
||||||
config,
|
|
||||||
isDefaultView,
|
|
||||||
onConfigChange,
|
|
||||||
onDeleteView
|
|
||||||
}: LauncherSettingsMenuProps) {
|
|
||||||
const t = useTranslations();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button variant="outline" size="icon" className="shrink-0">
|
|
||||||
<Settings className="size-4" />
|
|
||||||
<span className="sr-only">
|
|
||||||
{t("resourceLauncherSettings")}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent align="end" className="w-72">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-sm font-semibold">
|
|
||||||
{t("resourceLauncherGroupBy")}
|
|
||||||
</p>
|
|
||||||
<Select
|
|
||||||
value={config.groupBy}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
onConfigChange({
|
|
||||||
groupBy:
|
|
||||||
value as LauncherViewConfig["groupBy"]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="site">
|
|
||||||
{t("resourceLauncherGroupBySite")}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="label">
|
|
||||||
{t("resourceLauncherGroupByLabel")}
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-sm font-semibold">
|
|
||||||
{t("resourceLauncherLayout")}
|
|
||||||
</p>
|
|
||||||
<Select
|
|
||||||
value={config.layout}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
onConfigChange({
|
|
||||||
layout: value as LauncherViewConfig["layout"]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="grid">
|
|
||||||
{t("resourceLauncherLayoutGrid")}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="list">
|
|
||||||
{t("resourceLauncherLayoutList")}
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<Label
|
|
||||||
htmlFor="show-labels"
|
|
||||||
className="text-sm font-semibold"
|
|
||||||
>
|
|
||||||
{t("resourceLauncherShowLabels")}
|
|
||||||
</Label>
|
|
||||||
<Switch
|
|
||||||
id="show-labels"
|
|
||||||
checked={config.showLabels}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
onConfigChange({ showLabels: checked })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!isDefaultView ? (
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
className="w-full rounded-xl"
|
|
||||||
onClick={onDeleteView}
|
|
||||||
>
|
|
||||||
{t("resourceLauncherDeleteView")}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Button } from "@app/components/ui/button";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { ArrowDown01, ArrowUp10 } from "lucide-react";
|
|
||||||
|
|
||||||
type LauncherSortButtonProps = {
|
|
||||||
order: "asc" | "desc";
|
|
||||||
onToggle: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function LauncherSortButton({
|
|
||||||
order,
|
|
||||||
onToggle
|
|
||||||
}: LauncherSortButtonProps) {
|
|
||||||
const t = useTranslations();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="shrink-0"
|
|
||||||
onClick={onToggle}
|
|
||||||
title={
|
|
||||||
order === "asc"
|
|
||||||
? t("resourceLauncherSortAscending")
|
|
||||||
: t("resourceLauncherSortDescending")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{order === "asc" ? (
|
|
||||||
<ArrowDown01 className="size-4" />
|
|
||||||
) : (
|
|
||||||
<ArrowUp10 className="size-4" />
|
|
||||||
)}
|
|
||||||
<span className="sr-only">{t("resourceLauncherSort")}</span>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Button } from "@app/components/ui/button";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger
|
|
||||||
} from "@app/components/ui/dropdown-menu";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { ChevronDown } from "lucide-react";
|
|
||||||
import { cn } from "@app/lib/cn";
|
|
||||||
|
|
||||||
type LauncherViewTabsProps = {
|
|
||||||
activeViewId: number | "default";
|
|
||||||
savedViews: Array<{ viewId: number; name: string }>;
|
|
||||||
onSelectView: (viewId: number | "default") => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function LauncherViewTabs({
|
|
||||||
activeViewId,
|
|
||||||
savedViews,
|
|
||||||
onSelectView
|
|
||||||
}: LauncherViewTabsProps) {
|
|
||||||
const t = useTranslations();
|
|
||||||
|
|
||||||
const viewOptions: Array<{
|
|
||||||
value: number | "default";
|
|
||||||
label: string;
|
|
||||||
}> = [
|
|
||||||
{ value: "default", label: t("resourceLauncherDefaultView") },
|
|
||||||
...savedViews.map((view) => ({
|
|
||||||
value: view.viewId,
|
|
||||||
label: view.name
|
|
||||||
}))
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex w-max items-center gap-2">
|
|
||||||
{viewOptions.map((option) => {
|
|
||||||
const isSelected = activeViewId === option.value;
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
key={option.value}
|
|
||||||
type="button"
|
|
||||||
variant={
|
|
||||||
isSelected
|
|
||||||
? "squareOutlinePrimary"
|
|
||||||
: "squareOutline"
|
|
||||||
}
|
|
||||||
className={cn(
|
|
||||||
"shrink-0 min-w-30 shadow-none",
|
|
||||||
isSelected && "bg-primary/10"
|
|
||||||
)}
|
|
||||||
onClick={() => onSelectView(option.value)}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type LauncherSaveViewMenuProps = {
|
|
||||||
isDefaultView: boolean;
|
|
||||||
isAdmin: boolean;
|
|
||||||
isOrgWideView: boolean;
|
|
||||||
hasUnsavedChanges: boolean;
|
|
||||||
onSaveToCurrent: () => void;
|
|
||||||
onSaveAsNew: () => void;
|
|
||||||
onSaveForEveryone: () => void;
|
|
||||||
onMakePersonal: () => void;
|
|
||||||
onResetView: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function LauncherSaveViewMenu({
|
|
||||||
isDefaultView,
|
|
||||||
isAdmin,
|
|
||||||
isOrgWideView,
|
|
||||||
hasUnsavedChanges,
|
|
||||||
onSaveToCurrent,
|
|
||||||
onSaveAsNew,
|
|
||||||
onSaveForEveryone,
|
|
||||||
onMakePersonal,
|
|
||||||
onResetView
|
|
||||||
}: LauncherSaveViewMenuProps) {
|
|
||||||
const t = useTranslations();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="outline" className="shrink-0">
|
|
||||||
{hasUnsavedChanges ? (
|
|
||||||
<span className="size-2 rounded-full bg-primary mr-2" />
|
|
||||||
) : null}
|
|
||||||
{t("resourceLauncherSaveView")}
|
|
||||||
<ChevronDown className="ml-2 size-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
{hasUnsavedChanges ? (
|
|
||||||
<>
|
|
||||||
<DropdownMenuItem onSelect={onResetView}>
|
|
||||||
{t("resourceLauncherResetView")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{!isDefaultView && (isAdmin || !isOrgWideView) ? (
|
|
||||||
<DropdownMenuItem onSelect={onSaveToCurrent}>
|
|
||||||
{t("resourceLauncherSaveToCurrentView")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
<DropdownMenuItem onSelect={onSaveAsNew}>
|
|
||||||
{t("resourceLauncherSaveAsNewView")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
{isAdmin && !isDefaultView && !isOrgWideView ? (
|
|
||||||
<DropdownMenuItem onSelect={onSaveForEveryone}>
|
|
||||||
{t("resourceLauncherSaveForEveryone")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
{isAdmin && !isDefaultView && isOrgWideView ? (
|
|
||||||
<DropdownMenuItem onSelect={onMakePersonal}>
|
|
||||||
{t("resourceLauncherMakePersonal")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,577 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Credenza,
|
|
||||||
CredenzaBody,
|
|
||||||
CredenzaContent,
|
|
||||||
CredenzaDescription,
|
|
||||||
CredenzaFooter,
|
|
||||||
CredenzaHeader,
|
|
||||||
CredenzaTitle
|
|
||||||
} from "@app/components/Credenza";
|
|
||||||
import { Button } from "@app/components/ui/button";
|
|
||||||
import { CheckboxWithLabel } from "@app/components/ui/checkbox";
|
|
||||||
import { Input } from "@app/components/ui/input";
|
|
||||||
import { Label } from "@app/components/ui/label";
|
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
|
||||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
|
||||||
import {
|
|
||||||
readLauncherLastView,
|
|
||||||
writeLauncherLastView,
|
|
||||||
type LauncherActiveViewId
|
|
||||||
} from "@app/lib/launcherLocalStorage";
|
|
||||||
import {
|
|
||||||
buildLauncherPath,
|
|
||||||
getLauncherUrlBaseConfig,
|
|
||||||
isLauncherConfigEqual,
|
|
||||||
parseLauncherUrlState,
|
|
||||||
serializeLauncherUrlState
|
|
||||||
} from "@app/lib/launcherUrlState";
|
|
||||||
import { useToast } from "@app/hooks/useToast";
|
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
|
||||||
import type {
|
|
||||||
LauncherGroup,
|
|
||||||
LauncherViewConfig,
|
|
||||||
LauncherViewRecord
|
|
||||||
} from "@server/routers/launcher/types";
|
|
||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { Search } from "lucide-react";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import {
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
useTransition
|
|
||||||
} from "react";
|
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
|
||||||
import type { Selectedsite } from "@app/components/site-selector";
|
|
||||||
import type { SelectedLabel } from "@app/components/labels-selector";
|
|
||||||
import { useMediaQuery } from "@app/hooks/useMediaQuery";
|
|
||||||
import { cn } from "@app/lib/cn";
|
|
||||||
import { LauncherFilterPopover } from "./LauncherFilterPopover";
|
|
||||||
import { LauncherGroupList } from "./LauncherGroupList";
|
|
||||||
import { LauncherRefreshButton } from "./LauncherRefreshButton";
|
|
||||||
import { LauncherSettingsMenu } from "./LauncherSettingsMenu";
|
|
||||||
import { LauncherSortButton } from "./LauncherSortButton";
|
|
||||||
import { LauncherSaveViewMenu, LauncherViewTabs } from "./LauncherViewTabs";
|
|
||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
|
||||||
|
|
||||||
type ResourceLauncherProps = {
|
|
||||||
orgId: string;
|
|
||||||
isAdmin: boolean;
|
|
||||||
views: LauncherViewRecord[];
|
|
||||||
activeViewId: LauncherActiveViewId;
|
|
||||||
config: LauncherViewConfig;
|
|
||||||
savedConfig: LauncherViewConfig;
|
|
||||||
groups: LauncherGroup[];
|
|
||||||
groupsPagination: {
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
pageSize: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ResourceLauncher({
|
|
||||||
orgId,
|
|
||||||
isAdmin,
|
|
||||||
views,
|
|
||||||
activeViewId,
|
|
||||||
config,
|
|
||||||
savedConfig,
|
|
||||||
groups,
|
|
||||||
groupsPagination
|
|
||||||
}: ResourceLauncherProps) {
|
|
||||||
const t = useTranslations();
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { env } = useEnvContext();
|
|
||||||
const api = createApiClient({ env });
|
|
||||||
const router = useRouter();
|
|
||||||
const { navigate, isNavigating, searchParams } = useNavigationContext();
|
|
||||||
const [isRefreshing, startRefreshTransition] = useTransition();
|
|
||||||
const hasRestoredLastView = useRef(false);
|
|
||||||
|
|
||||||
const [searchInputResetKey, setSearchInputResetKey] = useState(0);
|
|
||||||
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
|
||||||
const [newViewName, setNewViewName] = useState("");
|
|
||||||
const [saveOrgWide, setSaveOrgWide] = useState(false);
|
|
||||||
|
|
||||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
|
||||||
|
|
||||||
const configRef = useRef(config);
|
|
||||||
configRef.current = config;
|
|
||||||
const searchInputRef = useRef(config.query);
|
|
||||||
const activeViewIdRef = useRef(activeViewId);
|
|
||||||
activeViewIdRef.current = activeViewId;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasRestoredLastView.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
hasRestoredLastView.current = true;
|
|
||||||
|
|
||||||
const parsed = parseLauncherUrlState(searchParams);
|
|
||||||
if (parsed.hasAnyLauncherParams) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastView = readLauncherLastView(orgId);
|
|
||||||
if (lastView === null || lastView === activeViewId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValid =
|
|
||||||
lastView === "default" ||
|
|
||||||
views.some((view) => view.viewId === lastView);
|
|
||||||
if (!isValid) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseConfig = getLauncherUrlBaseConfig(lastView, views);
|
|
||||||
const params = serializeLauncherUrlState({
|
|
||||||
viewId: lastView,
|
|
||||||
config: baseConfig
|
|
||||||
});
|
|
||||||
navigate({ searchParams: params, replace: true });
|
|
||||||
}, [activeViewId, navigate, orgId, searchParams, views]);
|
|
||||||
|
|
||||||
const navigateToConfig = useCallback(
|
|
||||||
(viewId: LauncherActiveViewId, nextConfig: LauncherViewConfig) => {
|
|
||||||
const params = serializeLauncherUrlState({
|
|
||||||
viewId,
|
|
||||||
config: nextConfig
|
|
||||||
});
|
|
||||||
navigate({ searchParams: params });
|
|
||||||
},
|
|
||||||
[navigate]
|
|
||||||
);
|
|
||||||
|
|
||||||
const debouncedNavigateSearch = useDebouncedCallback(
|
|
||||||
(viewId: LauncherActiveViewId, query: string) => {
|
|
||||||
navigateToConfig(viewId, { ...configRef.current, query });
|
|
||||||
},
|
|
||||||
300
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectView = useCallback(
|
|
||||||
(viewId: LauncherActiveViewId) => {
|
|
||||||
writeLauncherLastView(orgId, viewId);
|
|
||||||
const baseConfig = getLauncherUrlBaseConfig(viewId, views);
|
|
||||||
navigateToConfig(viewId, baseConfig);
|
|
||||||
},
|
|
||||||
[navigateToConfig, orgId, views]
|
|
||||||
);
|
|
||||||
|
|
||||||
const activeSavedView = useMemo(
|
|
||||||
() =>
|
|
||||||
activeViewId === "default"
|
|
||||||
? null
|
|
||||||
: views.find((view) => view.viewId === activeViewId),
|
|
||||||
[activeViewId, views]
|
|
||||||
);
|
|
||||||
|
|
||||||
const isDefaultView = activeViewId === "default";
|
|
||||||
const isOrgWideView = Boolean(activeSavedView?.isOrgWide);
|
|
||||||
const hasUnsavedChanges = !isLauncherConfigEqual(config, savedConfig);
|
|
||||||
|
|
||||||
const selectedSites: Selectedsite[] = useMemo(
|
|
||||||
() =>
|
|
||||||
config.siteIds.map((siteId) => ({
|
|
||||||
siteId,
|
|
||||||
name: String(siteId),
|
|
||||||
type: "newt"
|
|
||||||
})),
|
|
||||||
[config.siteIds]
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectedLabels: SelectedLabel[] = useMemo(
|
|
||||||
() =>
|
|
||||||
config.labelIds.map((labelId) => ({
|
|
||||||
labelId,
|
|
||||||
name: String(labelId),
|
|
||||||
color: "#a1a1aa"
|
|
||||||
})),
|
|
||||||
[config.labelIds]
|
|
||||||
);
|
|
||||||
|
|
||||||
const createViewMutation = useMutation({
|
|
||||||
mutationFn: async (payload: {
|
|
||||||
name: string;
|
|
||||||
config: LauncherViewConfig;
|
|
||||||
orgWide: boolean;
|
|
||||||
}) => {
|
|
||||||
const res = await api.post(`/org/${orgId}/launcher/views`, payload);
|
|
||||||
return res.data.data as LauncherViewRecord;
|
|
||||||
},
|
|
||||||
onSuccess: (view) => {
|
|
||||||
writeLauncherLastView(orgId, view.viewId);
|
|
||||||
const params = serializeLauncherUrlState({
|
|
||||||
viewId: view.viewId,
|
|
||||||
config: view.config
|
|
||||||
});
|
|
||||||
navigate({ searchParams: params, replace: true });
|
|
||||||
router.refresh();
|
|
||||||
setSaveDialogOpen(false);
|
|
||||||
setNewViewName("");
|
|
||||||
toast({
|
|
||||||
title: t("resourceLauncherViewSaved"),
|
|
||||||
description: t("resourceLauncherViewSavedDescription")
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t("resourceLauncherViewSaveFailed"),
|
|
||||||
description: formatAxiosError(
|
|
||||||
error,
|
|
||||||
t("resourceLauncherViewSaveFailedDescription")
|
|
||||||
)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateViewMutation = useMutation({
|
|
||||||
mutationFn: async (payload: {
|
|
||||||
viewId: number;
|
|
||||||
name?: string;
|
|
||||||
config?: LauncherViewConfig;
|
|
||||||
orgWide?: boolean;
|
|
||||||
}) => {
|
|
||||||
const { viewId, ...body } = payload;
|
|
||||||
const res = await api.put(
|
|
||||||
`/org/${orgId}/launcher/views/${viewId}`,
|
|
||||||
body
|
|
||||||
);
|
|
||||||
return res.data.data as LauncherViewRecord;
|
|
||||||
},
|
|
||||||
onSuccess: (view) => {
|
|
||||||
const params = serializeLauncherUrlState({
|
|
||||||
viewId: view.viewId,
|
|
||||||
config: view.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 deleteViewMutation = useMutation({
|
|
||||||
mutationFn: async (viewId: number) => {
|
|
||||||
await api.delete(`/org/${orgId}/launcher/views/${viewId}`);
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
writeLauncherLastView(orgId, "default");
|
|
||||||
const params = serializeLauncherUrlState({
|
|
||||||
viewId: "default",
|
|
||||||
config: getLauncherUrlBaseConfig("default", views)
|
|
||||||
});
|
|
||||||
navigate({ searchParams: params, replace: true });
|
|
||||||
router.refresh();
|
|
||||||
toast({
|
|
||||||
title: t("resourceLauncherViewDeleted"),
|
|
||||||
description: t("resourceLauncherViewDeletedDescription")
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t("resourceLauncherViewDeleteFailed"),
|
|
||||||
description: formatAxiosError(
|
|
||||||
error,
|
|
||||||
t("resourceLauncherViewDeleteFailedDescription")
|
|
||||||
)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const applyConfigPatch = useCallback(
|
|
||||||
(patch: Partial<LauncherViewConfig>) => {
|
|
||||||
const nextConfig = {
|
|
||||||
...configRef.current,
|
|
||||||
...patch,
|
|
||||||
query: searchInputRef.current
|
|
||||||
};
|
|
||||||
navigateToConfig(activeViewIdRef.current, nextConfig);
|
|
||||||
},
|
|
||||||
[navigateToConfig]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleClearFilters = useCallback(() => {
|
|
||||||
searchInputRef.current = "";
|
|
||||||
setSearchInputResetKey((key) => key + 1);
|
|
||||||
navigateToConfig(activeViewIdRef.current, {
|
|
||||||
...configRef.current,
|
|
||||||
query: "",
|
|
||||||
siteIds: [],
|
|
||||||
labelIds: []
|
|
||||||
});
|
|
||||||
}, [navigateToConfig]);
|
|
||||||
|
|
||||||
const handleResetView = useCallback(() => {
|
|
||||||
searchInputRef.current = savedConfig.query;
|
|
||||||
setSearchInputResetKey((key) => key + 1);
|
|
||||||
navigateToConfig(activeViewIdRef.current, savedConfig);
|
|
||||||
}, [navigateToConfig, savedConfig]);
|
|
||||||
|
|
||||||
const refreshData = () => {
|
|
||||||
startRefreshTransition(async () => {
|
|
||||||
try {
|
|
||||||
router.refresh();
|
|
||||||
} catch {
|
|
||||||
toast({
|
|
||||||
title: t("error"),
|
|
||||||
description: t("refreshError"),
|
|
||||||
variant: "destructive"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveToCurrent = () => {
|
|
||||||
if (isDefaultView || (isOrgWideView && !isAdmin)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
updateViewMutation.mutate({
|
|
||||||
viewId: activeViewId,
|
|
||||||
config
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveAsNew = () => {
|
|
||||||
setSaveOrgWide(false);
|
|
||||||
setNewViewName("");
|
|
||||||
setSaveDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveForEveryone = () => {
|
|
||||||
if (isDefaultView) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
updateViewMutation.mutate({
|
|
||||||
viewId: activeViewId,
|
|
||||||
orgWide: true
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMakePersonal = () => {
|
|
||||||
if (isDefaultView) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
updateViewMutation.mutate({
|
|
||||||
viewId: activeViewId,
|
|
||||||
orgWide: false
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateView = () => {
|
|
||||||
if (!newViewName.trim()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
createViewMutation.mutate({
|
|
||||||
name: newViewName.trim(),
|
|
||||||
config,
|
|
||||||
orgWide: saveOrgWide && isAdmin
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const savedViewTabs = views.map((view) => ({
|
|
||||||
viewId: view.viewId,
|
|
||||||
name: view.name
|
|
||||||
}));
|
|
||||||
|
|
||||||
const renderToolbarSearch = (searchClassName: string) => (
|
|
||||||
<div className={cn("relative shrink-0", searchClassName)}>
|
|
||||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
key={`${activeViewId}-${searchInputResetKey}`}
|
|
||||||
defaultValue={config.query}
|
|
||||||
onChange={(event) => {
|
|
||||||
const value = event.currentTarget.value;
|
|
||||||
searchInputRef.current = value;
|
|
||||||
debouncedNavigateSearch(activeViewIdRef.current, value);
|
|
||||||
}}
|
|
||||||
placeholder={t("resourceLauncherSearchPlaceholder")}
|
|
||||||
className="pl-8"
|
|
||||||
type="search"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderToolbarActions = () => (
|
|
||||||
<>
|
|
||||||
<LauncherSaveViewMenu
|
|
||||||
isDefaultView={isDefaultView}
|
|
||||||
isAdmin={isAdmin}
|
|
||||||
isOrgWideView={isOrgWideView}
|
|
||||||
hasUnsavedChanges={hasUnsavedChanges}
|
|
||||||
onSaveToCurrent={handleSaveToCurrent}
|
|
||||||
onSaveAsNew={handleSaveAsNew}
|
|
||||||
onSaveForEveryone={handleSaveForEveryone}
|
|
||||||
onMakePersonal={handleMakePersonal}
|
|
||||||
onResetView={handleResetView}
|
|
||||||
/>
|
|
||||||
<LauncherFilterPopover
|
|
||||||
orgId={orgId}
|
|
||||||
selectedSites={selectedSites}
|
|
||||||
selectedLabels={selectedLabels}
|
|
||||||
onSitesChange={(sites) =>
|
|
||||||
applyConfigPatch({
|
|
||||||
siteIds: sites.map((site) => site.siteId)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
onLabelsChange={(labels) =>
|
|
||||||
applyConfigPatch({
|
|
||||||
labelIds: labels.map((label) => label.labelId)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<LauncherSortButton
|
|
||||||
order={config.order}
|
|
||||||
onToggle={() =>
|
|
||||||
applyConfigPatch({
|
|
||||||
order: config.order === "asc" ? "desc" : "asc"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<LauncherSettingsMenu
|
|
||||||
config={config}
|
|
||||||
isDefaultView={isDefaultView}
|
|
||||||
onConfigChange={applyConfigPatch}
|
|
||||||
onDeleteView={() => {
|
|
||||||
if (!isDefaultView) {
|
|
||||||
deleteViewMutation.mutate(activeViewId);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<LauncherRefreshButton
|
|
||||||
onRefresh={refreshData}
|
|
||||||
isRefreshing={isRefreshing || isNavigating}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderToolbarViews = () => (
|
|
||||||
<LauncherViewTabs
|
|
||||||
activeViewId={activeViewId}
|
|
||||||
savedViews={savedViewTabs}
|
|
||||||
onSelectView={selectView}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col" aria-busy={isNavigating}>
|
|
||||||
<SettingsSectionTitle
|
|
||||||
title={t("resourceLauncherTitle")}
|
|
||||||
description={t("resourceLauncherDescription")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isDesktop ? (
|
|
||||||
<div className="mb-6 flex w-full min-w-0 items-center gap-3">
|
|
||||||
{renderToolbarSearch("w-64")}
|
|
||||||
<div className="min-w-0 flex-1 overflow-x-auto">
|
|
||||||
{renderToolbarViews()}
|
|
||||||
</div>
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
|
||||||
{renderToolbarActions()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="mb-6 flex flex-col gap-3">
|
|
||||||
<div className="flex items-center gap-2 overflow-x-auto">
|
|
||||||
{renderToolbarActions()}
|
|
||||||
</div>
|
|
||||||
{renderToolbarSearch("w-full")}
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
{renderToolbarViews()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<LauncherGroupList
|
|
||||||
orgId={orgId}
|
|
||||||
activeViewId={activeViewId}
|
|
||||||
config={config}
|
|
||||||
initialGroups={groups}
|
|
||||||
groupsPagination={groupsPagination}
|
|
||||||
onClearFilters={handleClearFilters}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Credenza open={saveDialogOpen} onOpenChange={setSaveDialogOpen}>
|
|
||||||
<CredenzaContent>
|
|
||||||
<CredenzaHeader>
|
|
||||||
<CredenzaTitle>
|
|
||||||
{t("resourceLauncherSaveAsNewView")}
|
|
||||||
</CredenzaTitle>
|
|
||||||
<CredenzaDescription>
|
|
||||||
{t("resourceLauncherSaveAsNewViewDescription")}
|
|
||||||
</CredenzaDescription>
|
|
||||||
</CredenzaHeader>
|
|
||||||
<CredenzaBody>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="new-view-name">
|
|
||||||
{t("resourceLauncherViewNameLabel")}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="new-view-name"
|
|
||||||
value={newViewName}
|
|
||||||
onChange={(event) =>
|
|
||||||
setNewViewName(event.target.value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{isAdmin ? (
|
|
||||||
<div className="mt-4">
|
|
||||||
<CheckboxWithLabel
|
|
||||||
id="save-org-wide"
|
|
||||||
aria-describedby="save-org-wide-desc"
|
|
||||||
label={t("resourceLauncherSaveForEveryone")}
|
|
||||||
checked={saveOrgWide}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
setSaveOrgWide(checked === true)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<p
|
|
||||||
id="save-org-wide-desc"
|
|
||||||
className="text-sm text-muted-foreground mt-2"
|
|
||||||
>
|
|
||||||
{t(
|
|
||||||
"resourceLauncherSaveForEveryoneDescription"
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</CredenzaBody>
|
|
||||||
<CredenzaFooter>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setSaveDialogOpen(false)}
|
|
||||||
>
|
|
||||||
{t("cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleCreateView}
|
|
||||||
loading={createViewMutation.isPending}
|
|
||||||
>
|
|
||||||
{t("save")}
|
|
||||||
</Button>
|
|
||||||
</CredenzaFooter>
|
|
||||||
</CredenzaContent>
|
|
||||||
</Credenza>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useToast } from "@app/hooks/useToast";
|
|
||||||
import { isSafeUrlForLink } from "@app/lib/launcherResourceAccess";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { useCallback, type KeyboardEvent, type MouseEvent } from "react";
|
|
||||||
|
|
||||||
type LauncherResourceActionInput = {
|
|
||||||
accessUrl?: string | null;
|
|
||||||
accessCopyValue: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useLauncherResourceAction({
|
|
||||||
accessUrl,
|
|
||||||
accessCopyValue
|
|
||||||
}: LauncherResourceActionInput) {
|
|
||||||
const { toast } = useToast();
|
|
||||||
const t = useTranslations();
|
|
||||||
|
|
||||||
const href = accessUrl ?? undefined;
|
|
||||||
const canLink = Boolean(href && isSafeUrlForLink(href));
|
|
||||||
const isClickable = canLink || Boolean(accessCopyValue);
|
|
||||||
|
|
||||||
const handleAction = useCallback(() => {
|
|
||||||
if (canLink && href) {
|
|
||||||
window.open(href, "_blank", "noopener,noreferrer");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!accessCopyValue) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
void navigator.clipboard.writeText(accessCopyValue).then(() => {
|
|
||||||
toast({
|
|
||||||
title: t("resourceLauncherCopiedToClipboard"),
|
|
||||||
description: t("resourceLauncherCopiedAccessDescription"),
|
|
||||||
duration: 2000
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, [accessCopyValue, canLink, href, t, toast]);
|
|
||||||
|
|
||||||
return { handleAction, isClickable };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isLauncherResourceInteractiveTarget(
|
|
||||||
target: EventTarget | null,
|
|
||||||
container?: EventTarget | null
|
|
||||||
): boolean {
|
|
||||||
if (!(target instanceof Element)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const interactive = target.closest(
|
|
||||||
"a, button, [role='button'], input, textarea, select"
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!interactive) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (container instanceof Element && interactive === container) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleLauncherResourceClick(
|
|
||||||
event: MouseEvent,
|
|
||||||
handleAction: () => void
|
|
||||||
) {
|
|
||||||
if (
|
|
||||||
isLauncherResourceInteractiveTarget(event.target, event.currentTarget)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleAction();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getLauncherResourceSelectProps(onSelect: () => void) {
|
|
||||||
return {
|
|
||||||
onClick: (event: MouseEvent) => {
|
|
||||||
if (
|
|
||||||
isLauncherResourceInteractiveTarget(
|
|
||||||
event.target,
|
|
||||||
event.currentTarget
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onSelect();
|
|
||||||
},
|
|
||||||
className: "cursor-pointer",
|
|
||||||
role: "button" as const,
|
|
||||||
tabIndex: 0,
|
|
||||||
onKeyDown: (event: KeyboardEvent) => {
|
|
||||||
if (event.key === "Enter" || event.key === " ") {
|
|
||||||
event.preventDefault();
|
|
||||||
onSelect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getLauncherResourceClickProps(
|
|
||||||
handleAction: () => void,
|
|
||||||
isClickable: boolean
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
onClick: (event: MouseEvent) =>
|
|
||||||
handleLauncherResourceClick(event, handleAction),
|
|
||||||
className: isClickable ? "cursor-pointer" : undefined,
|
|
||||||
role: isClickable ? ("button" as const) : undefined,
|
|
||||||
tabIndex: isClickable ? 0 : undefined,
|
|
||||||
onKeyDown: isClickable
|
|
||||||
? (event: KeyboardEvent) => {
|
|
||||||
if (event.key === "Enter" || event.key === " ") {
|
|
||||||
event.preventDefault();
|
|
||||||
handleAction();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -340,8 +340,7 @@ function PolicyAccessRulesSectionEdit({
|
|||||||
? rules.filter((rule) => !rule.fromPolicy)
|
? rules.filter((rule) => !rule.fromPolicy)
|
||||||
: rules;
|
: rules;
|
||||||
const rulesPayload = rulesToValidate.map(
|
const rulesPayload = rulesToValidate.map(
|
||||||
({ ruleId, action, match, value, priority, enabled, new: isNew }) => ({
|
({ action, match, value, priority, enabled }) => ({
|
||||||
...(isNew ? {} : { ruleId }),
|
|
||||||
action,
|
action,
|
||||||
match,
|
match,
|
||||||
value,
|
value,
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
export type LauncherActiveViewId = number | "default";
|
|
||||||
|
|
||||||
const LAST_VIEW_PREFIX = "pangolin:launcher:last-view:";
|
|
||||||
const GROUP_OPEN_PREFIX = "pangolin:launcher:group-open:";
|
|
||||||
|
|
||||||
function lastViewKey(orgId: string) {
|
|
||||||
return `${LAST_VIEW_PREFIX}${orgId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function groupOpenKey(
|
|
||||||
orgId: string,
|
|
||||||
viewId: LauncherActiveViewId,
|
|
||||||
groupBy: "site" | "label"
|
|
||||||
) {
|
|
||||||
return `${GROUP_OPEN_PREFIX}${orgId}:${viewId}:${groupBy}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readJson<T>(key: string, fallback: T): T {
|
|
||||||
if (typeof window === "undefined") {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const raw = window.localStorage.getItem(key);
|
|
||||||
return raw ? (JSON.parse(raw) as T) : fallback;
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`Error reading localStorage key "${key}":`, error);
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeJson(key: string, value: unknown) {
|
|
||||||
if (typeof window === "undefined") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem(key, JSON.stringify(value));
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`Error writing localStorage key "${key}":`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function readLauncherLastView(
|
|
||||||
orgId: string
|
|
||||||
): LauncherActiveViewId | null {
|
|
||||||
const value = readJson<LauncherActiveViewId | null>(
|
|
||||||
lastViewKey(orgId),
|
|
||||||
null
|
|
||||||
);
|
|
||||||
if (value === "default" || typeof value === "number") {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function writeLauncherLastView(
|
|
||||||
orgId: string,
|
|
||||||
viewId: LauncherActiveViewId
|
|
||||||
) {
|
|
||||||
writeJson(lastViewKey(orgId), viewId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function readLauncherGroupOpenState(
|
|
||||||
orgId: string,
|
|
||||||
viewId: LauncherActiveViewId,
|
|
||||||
groupBy: "site" | "label"
|
|
||||||
): Record<string, boolean> {
|
|
||||||
return readJson<Record<string, boolean>>(
|
|
||||||
groupOpenKey(orgId, viewId, groupBy),
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function readLauncherGroupOpen(
|
|
||||||
orgId: string,
|
|
||||||
viewId: LauncherActiveViewId,
|
|
||||||
groupBy: "site" | "label",
|
|
||||||
groupKey: string,
|
|
||||||
defaultOpen: boolean
|
|
||||||
): boolean {
|
|
||||||
const state = readLauncherGroupOpenState(orgId, viewId, groupBy);
|
|
||||||
return groupKey in state ? state[groupKey] : defaultOpen;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function writeLauncherGroupOpen(
|
|
||||||
orgId: string,
|
|
||||||
viewId: LauncherActiveViewId,
|
|
||||||
groupBy: "site" | "label",
|
|
||||||
groupKey: string,
|
|
||||||
isOpen: boolean
|
|
||||||
) {
|
|
||||||
const state = readLauncherGroupOpenState(orgId, viewId, groupBy);
|
|
||||||
writeJson(groupOpenKey(orgId, viewId, groupBy), {
|
|
||||||
...state,
|
|
||||||
[groupKey]: isOpen
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
import {
|
|
||||||
formatSiteResourceDestinationDisplay,
|
|
||||||
type SiteResourceDestinationInput
|
|
||||||
} from "./formatSiteResourceAccess";
|
|
||||||
|
|
||||||
export {
|
|
||||||
formatSiteResourceDestinationDisplay,
|
|
||||||
resolveHttpHttpsDisplayPort,
|
|
||||||
type SiteResourceDestinationInput
|
|
||||||
} from "./formatSiteResourceAccess";
|
|
||||||
|
|
||||||
export type PublicResourceAccessInput = {
|
|
||||||
mode: string;
|
|
||||||
fullDomain: string | null;
|
|
||||||
ssl: boolean;
|
|
||||||
proxyPort: number | null;
|
|
||||||
wildcard: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SiteResourceAccessInput = {
|
|
||||||
mode: string;
|
|
||||||
destination: string | null;
|
|
||||||
destinationPort: number | null;
|
|
||||||
scheme: "http" | "https" | null;
|
|
||||||
ssl: boolean;
|
|
||||||
fullDomain: string | null;
|
|
||||||
alias: string | null;
|
|
||||||
aliasAddress: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type LauncherAccessFields = {
|
|
||||||
accessDisplay: string;
|
|
||||||
accessCopyValue: string;
|
|
||||||
accessUrl: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function formatPublicResourceAccess(
|
|
||||||
resource: PublicResourceAccessInput
|
|
||||||
): LauncherAccessFields {
|
|
||||||
const browserModes = ["http", "ssh", "rdp", "vnc"];
|
|
||||||
if (!browserModes.includes(resource.mode)) {
|
|
||||||
const port = resource.proxyPort?.toString() ?? "";
|
|
||||||
return {
|
|
||||||
accessDisplay: port,
|
|
||||||
accessCopyValue: port,
|
|
||||||
accessUrl: null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!resource.fullDomain) {
|
|
||||||
return {
|
|
||||||
accessDisplay: "",
|
|
||||||
accessCopyValue: "",
|
|
||||||
accessUrl: null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
|
|
||||||
return {
|
|
||||||
accessDisplay: url,
|
|
||||||
accessCopyValue: url,
|
|
||||||
accessUrl: resource.wildcard ? null : url
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatSiteResourceAccess(
|
|
||||||
resource: SiteResourceAccessInput
|
|
||||||
): LauncherAccessFields {
|
|
||||||
if (resource.alias) {
|
|
||||||
return {
|
|
||||||
accessDisplay: resource.alias,
|
|
||||||
accessCopyValue: resource.alias,
|
|
||||||
accessUrl: null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resource.mode === "http" && resource.fullDomain) {
|
|
||||||
const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
|
|
||||||
return {
|
|
||||||
accessDisplay: url,
|
|
||||||
accessCopyValue: url,
|
|
||||||
accessUrl: url
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const destination = formatSiteResourceDestinationDisplay({
|
|
||||||
mode: resource.mode as SiteResourceDestinationInput["mode"],
|
|
||||||
destination: resource.destination,
|
|
||||||
destinationPort: resource.destinationPort,
|
|
||||||
scheme: resource.scheme
|
|
||||||
});
|
|
||||||
|
|
||||||
if (destination) {
|
|
||||||
return {
|
|
||||||
accessDisplay: destination,
|
|
||||||
accessCopyValue: destination,
|
|
||||||
accessUrl: resource.mode === "http" ? destination : null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resource.aliasAddress) {
|
|
||||||
return {
|
|
||||||
accessDisplay: resource.aliasAddress,
|
|
||||||
accessCopyValue: resource.aliasAddress,
|
|
||||||
accessUrl: null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
accessDisplay: "",
|
|
||||||
accessCopyValue: "",
|
|
||||||
accessUrl: null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isSafeUrlForLink(url: string): boolean {
|
|
||||||
try {
|
|
||||||
const parsed = new URL(url);
|
|
||||||
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import type { LauncherResource } from "@server/routers/launcher/types";
|
|
||||||
|
|
||||||
export function getLauncherResourceAdminHref(
|
|
||||||
orgId: string,
|
|
||||||
resource: LauncherResource
|
|
||||||
): string {
|
|
||||||
if (resource.resourceType === "public") {
|
|
||||||
return `/${orgId}/settings/resources/public/${resource.niceId}/general`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const qs = new URLSearchParams({ query: resource.niceId });
|
|
||||||
if (resource.site?.siteId != null) {
|
|
||||||
qs.set("siteId", String(resource.site.siteId));
|
|
||||||
}
|
|
||||||
|
|
||||||
return `/${orgId}/settings/resources/private?${qs.toString()}`;
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import type { LauncherListQuery } from "@server/routers/launcher/types";
|
|
||||||
|
|
||||||
export type LauncherQueryFilters = {
|
|
||||||
query?: string;
|
|
||||||
groupBy?: LauncherListQuery["groupBy"];
|
|
||||||
groupKey?: string;
|
|
||||||
siteIds?: number[];
|
|
||||||
labelIds?: number[];
|
|
||||||
sort_by?: LauncherListQuery["sort_by"];
|
|
||||||
order?: LauncherListQuery["order"];
|
|
||||||
pageSize?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function buildLauncherSearchParams(
|
|
||||||
filters: LauncherQueryFilters,
|
|
||||||
page: number
|
|
||||||
) {
|
|
||||||
const sp = new URLSearchParams();
|
|
||||||
sp.set("page", String(page));
|
|
||||||
sp.set("pageSize", String(filters.pageSize ?? 20));
|
|
||||||
if (filters.query) {
|
|
||||||
sp.set("query", filters.query);
|
|
||||||
}
|
|
||||||
if (filters.groupBy) {
|
|
||||||
sp.set("groupBy", filters.groupBy);
|
|
||||||
}
|
|
||||||
if (filters.groupKey) {
|
|
||||||
sp.set("groupKey", filters.groupKey);
|
|
||||||
}
|
|
||||||
if (filters.siteIds?.length) {
|
|
||||||
sp.set("siteIds", filters.siteIds.join(","));
|
|
||||||
}
|
|
||||||
if (filters.labelIds?.length) {
|
|
||||||
sp.set("labelIds", filters.labelIds.join(","));
|
|
||||||
}
|
|
||||||
if (filters.sort_by) {
|
|
||||||
sp.set("sort_by", filters.sort_by);
|
|
||||||
}
|
|
||||||
if (filters.order) {
|
|
||||||
sp.set("order", filters.order);
|
|
||||||
}
|
|
||||||
return sp;
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import { internal } from "@app/lib/api";
|
|
||||||
import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage";
|
|
||||||
import { resolveLauncherStateFromUrl } from "@app/lib/launcherUrlState";
|
|
||||||
import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
|
|
||||||
import type {
|
|
||||||
LauncherGroup,
|
|
||||||
LauncherViewConfig,
|
|
||||||
LauncherViewRecord,
|
|
||||||
ListLauncherGroupsResponse,
|
|
||||||
ListLauncherViewsResponse
|
|
||||||
} from "@server/routers/launcher/types";
|
|
||||||
import { AxiosResponse } from "axios";
|
|
||||||
|
|
||||||
export type LauncherPageData = {
|
|
||||||
views: LauncherViewRecord[];
|
|
||||||
activeViewId: LauncherActiveViewId;
|
|
||||||
config: LauncherViewConfig;
|
|
||||||
savedConfig: LauncherViewConfig;
|
|
||||||
groups: LauncherGroup[];
|
|
||||||
groupsPagination: {
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
pageSize: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function fetchLauncherPageData(
|
|
||||||
orgId: string,
|
|
||||||
searchParams: URLSearchParams,
|
|
||||||
cookieHeader: Awaited<
|
|
||||||
ReturnType<typeof import("@app/lib/api/cookies").authCookieHeader>
|
|
||||||
>
|
|
||||||
): Promise<LauncherPageData> {
|
|
||||||
let views: LauncherViewRecord[] = [];
|
|
||||||
try {
|
|
||||||
const viewsRes = await internal.get<
|
|
||||||
AxiosResponse<ListLauncherViewsResponse>
|
|
||||||
>(`/org/${orgId}/launcher/views`, cookieHeader);
|
|
||||||
views = viewsRes.data.data.views;
|
|
||||||
} catch (e) {}
|
|
||||||
|
|
||||||
const { activeViewId, config, savedConfig } = resolveLauncherStateFromUrl(
|
|
||||||
searchParams,
|
|
||||||
views,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
const groupFilters = {
|
|
||||||
query: config.query,
|
|
||||||
groupBy: config.groupBy,
|
|
||||||
siteIds: config.siteIds,
|
|
||||||
labelIds: config.labelIds,
|
|
||||||
sort_by: config.sortBy,
|
|
||||||
order: config.order,
|
|
||||||
pageSize: 20
|
|
||||||
};
|
|
||||||
|
|
||||||
let groups: LauncherGroup[] = [];
|
|
||||||
let groupsPagination: LauncherPageData["groupsPagination"] = {
|
|
||||||
total: 0,
|
|
||||||
page: 1,
|
|
||||||
pageSize: 20
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const sp = buildLauncherSearchParams(groupFilters, 1);
|
|
||||||
const groupsRes = await internal.get<
|
|
||||||
AxiosResponse<ListLauncherGroupsResponse>
|
|
||||||
>(`/org/${orgId}/launcher/groups?${sp.toString()}`, cookieHeader);
|
|
||||||
groups = groupsRes.data.data.groups;
|
|
||||||
groupsPagination = groupsRes.data.data.pagination;
|
|
||||||
} catch (e) {}
|
|
||||||
|
|
||||||
return {
|
|
||||||
views,
|
|
||||||
activeViewId,
|
|
||||||
config,
|
|
||||||
savedConfig,
|
|
||||||
groups,
|
|
||||||
groupsPagination
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,278 +0,0 @@
|
|||||||
import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage";
|
|
||||||
import {
|
|
||||||
defaultLauncherViewConfig,
|
|
||||||
parseIdListParam,
|
|
||||||
type LauncherViewConfig,
|
|
||||||
type LauncherViewRecord
|
|
||||||
} from "@server/routers/launcher/types";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
const launcherUrlBooleanSchema = z
|
|
||||||
.enum(["0", "1"])
|
|
||||||
.transform((value) => value === "1");
|
|
||||||
|
|
||||||
export type LauncherUrlConfigOverrides = Partial<
|
|
||||||
Pick<
|
|
||||||
LauncherViewConfig,
|
|
||||||
| "groupBy"
|
|
||||||
| "layout"
|
|
||||||
| "order"
|
|
||||||
| "showLabels"
|
|
||||||
| "siteIds"
|
|
||||||
| "labelIds"
|
|
||||||
| "query"
|
|
||||||
>
|
|
||||||
>;
|
|
||||||
|
|
||||||
export type ParsedLauncherUrlState = {
|
|
||||||
viewId: LauncherActiveViewId | null;
|
|
||||||
configOverrides: LauncherUrlConfigOverrides;
|
|
||||||
hasAnyLauncherParams: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ResolvedLauncherState = {
|
|
||||||
activeViewId: LauncherActiveViewId;
|
|
||||||
config: LauncherViewConfig;
|
|
||||||
savedConfig: LauncherViewConfig;
|
|
||||||
};
|
|
||||||
|
|
||||||
const LAUNCHER_CONFIG_PARAM_KEYS = [
|
|
||||||
"query",
|
|
||||||
"groupBy",
|
|
||||||
"layout",
|
|
||||||
"order",
|
|
||||||
"showLabels",
|
|
||||||
"siteIds",
|
|
||||||
"labelIds"
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const LAUNCHER_URL_PARAM_KEYS = [
|
|
||||||
"view",
|
|
||||||
...LAUNCHER_CONFIG_PARAM_KEYS
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export function hasLauncherConfigParams(searchParams: URLSearchParams) {
|
|
||||||
return LAUNCHER_CONFIG_PARAM_KEYS.some((key) => searchParams.has(key));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isLauncherConfigEqual(
|
|
||||||
a: LauncherViewConfig,
|
|
||||||
b: LauncherViewConfig
|
|
||||||
) {
|
|
||||||
return JSON.stringify(a) === JSON.stringify(b);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getLauncherUrlBaseConfig(
|
|
||||||
viewId: LauncherActiveViewId,
|
|
||||||
views: LauncherViewRecord[]
|
|
||||||
): LauncherViewConfig {
|
|
||||||
if (viewId === "default") {
|
|
||||||
return defaultLauncherViewConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
const savedView = views.find((view) => view.viewId === viewId);
|
|
||||||
return savedView?.config ?? defaultLauncherViewConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveLauncherConfig(
|
|
||||||
baseConfig: LauncherViewConfig,
|
|
||||||
overrides: LauncherUrlConfigOverrides
|
|
||||||
): LauncherViewConfig {
|
|
||||||
return {
|
|
||||||
...baseConfig,
|
|
||||||
...overrides,
|
|
||||||
sortBy: "name"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseViewParam(value: string | null): LauncherActiveViewId | null {
|
|
||||||
if (value === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value === "default") {
|
|
||||||
return "default";
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = Number.parseInt(value, 10);
|
|
||||||
if (!Number.isFinite(parsed)) {
|
|
||||||
return "default";
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseConfigOverrides(
|
|
||||||
searchParams: URLSearchParams
|
|
||||||
): LauncherUrlConfigOverrides {
|
|
||||||
const overrides: LauncherUrlConfigOverrides = {};
|
|
||||||
|
|
||||||
const query = searchParams.get("query");
|
|
||||||
if (query !== null) {
|
|
||||||
overrides.query = query;
|
|
||||||
}
|
|
||||||
|
|
||||||
const groupBy = searchParams.get("groupBy");
|
|
||||||
if (groupBy === "site" || groupBy === "label") {
|
|
||||||
overrides.groupBy = groupBy;
|
|
||||||
}
|
|
||||||
|
|
||||||
const layout = searchParams.get("layout");
|
|
||||||
if (layout === "grid" || layout === "list") {
|
|
||||||
overrides.layout = layout;
|
|
||||||
}
|
|
||||||
|
|
||||||
const order = searchParams.get("order");
|
|
||||||
if (order === "asc" || order === "desc") {
|
|
||||||
overrides.order = order;
|
|
||||||
}
|
|
||||||
|
|
||||||
const showLabels = searchParams.get("showLabels");
|
|
||||||
if (showLabels !== null) {
|
|
||||||
const parsed = launcherUrlBooleanSchema.safeParse(showLabels);
|
|
||||||
if (parsed.success) {
|
|
||||||
overrides.showLabels = parsed.data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const siteIds = searchParams.get("siteIds");
|
|
||||||
if (siteIds !== null) {
|
|
||||||
overrides.siteIds = parseIdListParam(siteIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
const labelIds = searchParams.get("labelIds");
|
|
||||||
if (labelIds !== null) {
|
|
||||||
overrides.labelIds = parseIdListParam(labelIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
return overrides;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseLauncherUrlState(
|
|
||||||
searchParams: URLSearchParams
|
|
||||||
): ParsedLauncherUrlState {
|
|
||||||
const hasAnyLauncherParams = LAUNCHER_URL_PARAM_KEYS.some((key) =>
|
|
||||||
searchParams.has(key)
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
viewId: parseViewParam(searchParams.get("view")),
|
|
||||||
configOverrides: parseConfigOverrides(searchParams),
|
|
||||||
hasAnyLauncherParams
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function isValidActiveViewId(
|
|
||||||
viewId: LauncherActiveViewId,
|
|
||||||
views: LauncherViewRecord[]
|
|
||||||
) {
|
|
||||||
return viewId === "default" || views.some((view) => view.viewId === viewId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveLauncherStateFromUrl(
|
|
||||||
searchParams: URLSearchParams,
|
|
||||||
views: LauncherViewRecord[],
|
|
||||||
fallbackViewId: LauncherActiveViewId | null
|
|
||||||
): ResolvedLauncherState {
|
|
||||||
const parsed = parseLauncherUrlState(searchParams);
|
|
||||||
|
|
||||||
let activeViewId: LauncherActiveViewId = "default";
|
|
||||||
|
|
||||||
if (parsed.viewId !== null) {
|
|
||||||
activeViewId = isValidActiveViewId(parsed.viewId, views)
|
|
||||||
? parsed.viewId
|
|
||||||
: "default";
|
|
||||||
} else if (!parsed.hasAnyLauncherParams && fallbackViewId !== null) {
|
|
||||||
activeViewId = isValidActiveViewId(fallbackViewId, views)
|
|
||||||
? fallbackViewId
|
|
||||||
: "default";
|
|
||||||
}
|
|
||||||
|
|
||||||
const savedConfig = getLauncherUrlBaseConfig(activeViewId, views);
|
|
||||||
|
|
||||||
let config: LauncherViewConfig;
|
|
||||||
if (hasLauncherConfigParams(searchParams)) {
|
|
||||||
config = resolveLauncherConfig(
|
|
||||||
defaultLauncherViewConfig,
|
|
||||||
parsed.configOverrides
|
|
||||||
);
|
|
||||||
} else if (activeViewId !== "default") {
|
|
||||||
config = savedConfig;
|
|
||||||
} else {
|
|
||||||
config = defaultLauncherViewConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
activeViewId,
|
|
||||||
config,
|
|
||||||
savedConfig
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function idListsEqual(a: number[], b: number[]) {
|
|
||||||
if (a.length !== b.length) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return a.every((value, index) => value === b[index]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function serializeLauncherUrlState({
|
|
||||||
viewId,
|
|
||||||
config
|
|
||||||
}: {
|
|
||||||
viewId: LauncherActiveViewId;
|
|
||||||
config: LauncherViewConfig;
|
|
||||||
}): URLSearchParams {
|
|
||||||
const baseConfig = defaultLauncherViewConfig;
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
|
|
||||||
if (viewId !== "default") {
|
|
||||||
params.set("view", String(viewId));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.query !== baseConfig.query && config.query) {
|
|
||||||
params.set("query", config.query);
|
|
||||||
} else if (config.query !== baseConfig.query && !config.query) {
|
|
||||||
params.set("query", "");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.groupBy !== baseConfig.groupBy) {
|
|
||||||
params.set("groupBy", config.groupBy);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.layout !== baseConfig.layout) {
|
|
||||||
params.set("layout", config.layout);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.order !== baseConfig.order) {
|
|
||||||
params.set("order", config.order);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.showLabels !== baseConfig.showLabels) {
|
|
||||||
params.set("showLabels", config.showLabels ? "1" : "0");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!idListsEqual(config.siteIds, baseConfig.siteIds)) {
|
|
||||||
if (config.siteIds.length > 0) {
|
|
||||||
params.set("siteIds", config.siteIds.join(","));
|
|
||||||
} else {
|
|
||||||
params.set("siteIds", "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!idListsEqual(config.labelIds, baseConfig.labelIds)) {
|
|
||||||
if (config.labelIds.length > 0) {
|
|
||||||
params.set("labelIds", config.labelIds.join(","));
|
|
||||||
} else {
|
|
||||||
params.set("labelIds", "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return params;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildLauncherPath(orgId: string, params: URLSearchParams) {
|
|
||||||
const query = params.toString();
|
|
||||||
return query ? `/${orgId}?${query}` : `/${orgId}`;
|
|
||||||
}
|
|
||||||
@@ -46,20 +46,6 @@ import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
|
|||||||
import { StatusHistoryResponse } from "@server/lib/statusHistory";
|
import { StatusHistoryResponse } from "@server/lib/statusHistory";
|
||||||
import type { ListResourcePoliciesResponse } from "@server/routers/resource/types";
|
import type { ListResourcePoliciesResponse } from "@server/routers/resource/types";
|
||||||
import type { GetResourcePolicyResponse } from "@server/routers/policy";
|
import type { GetResourcePolicyResponse } from "@server/routers/policy";
|
||||||
import type {
|
|
||||||
ListLauncherGroupsResponse,
|
|
||||||
ListLauncherLabelsResponse,
|
|
||||||
ListLauncherResourcesResponse,
|
|
||||||
ListLauncherSitesResponse,
|
|
||||||
ListLauncherViewsResponse,
|
|
||||||
LauncherListQuery,
|
|
||||||
LauncherViewConfig
|
|
||||||
} from "@server/routers/launcher/types";
|
|
||||||
import type { LauncherQueryFilters } from "@app/lib/launcherSearchParams";
|
|
||||||
import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
|
|
||||||
|
|
||||||
export type { LauncherQueryFilters } from "@app/lib/launcherSearchParams";
|
|
||||||
export { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
|
|
||||||
|
|
||||||
export type ProductUpdate = {
|
export type ProductUpdate = {
|
||||||
link: string | null;
|
link: string | null;
|
||||||
@@ -1180,123 +1166,3 @@ export const domainQueries = {
|
|||||||
refetchInterval: durationToMs(10, "seconds")
|
refetchInterval: durationToMs(10, "seconds")
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
export const launcherQueries = {
|
|
||||||
views: (orgId: string) =>
|
|
||||||
queryOptions({
|
|
||||||
queryKey: ["ORG", orgId, "LAUNCHER", "VIEWS"] as const,
|
|
||||||
queryFn: async ({ signal, meta }) => {
|
|
||||||
const res = await meta!.api.get<
|
|
||||||
AxiosResponse<ListLauncherViewsResponse>
|
|
||||||
>(`/org/${orgId}/launcher/views`, { signal });
|
|
||||||
return res.data.data.views;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
sites: ({
|
|
||||||
orgId,
|
|
||||||
query,
|
|
||||||
perPage = 500
|
|
||||||
}: {
|
|
||||||
orgId: string;
|
|
||||||
query?: string;
|
|
||||||
perPage?: number;
|
|
||||||
}) =>
|
|
||||||
queryOptions({
|
|
||||||
queryKey: [
|
|
||||||
"ORG",
|
|
||||||
orgId,
|
|
||||||
"LAUNCHER",
|
|
||||||
"SITES",
|
|
||||||
{ query, perPage }
|
|
||||||
] as const,
|
|
||||||
queryFn: async ({ signal, meta }) => {
|
|
||||||
const sp = new URLSearchParams({
|
|
||||||
pageSize: perPage.toString()
|
|
||||||
});
|
|
||||||
|
|
||||||
if (query?.trim()) {
|
|
||||||
sp.set("query", query);
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await meta!.api.get<
|
|
||||||
AxiosResponse<ListLauncherSitesResponse>
|
|
||||||
>(`/org/${orgId}/launcher/sites?${sp.toString()}`, { signal });
|
|
||||||
return res.data.data.sites;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
labels: ({
|
|
||||||
orgId,
|
|
||||||
query,
|
|
||||||
perPage = 500
|
|
||||||
}: {
|
|
||||||
orgId: string;
|
|
||||||
query?: string;
|
|
||||||
perPage?: number;
|
|
||||||
}) =>
|
|
||||||
queryOptions({
|
|
||||||
queryKey: [
|
|
||||||
"ORG",
|
|
||||||
orgId,
|
|
||||||
"LAUNCHER",
|
|
||||||
"LABELS",
|
|
||||||
{ query, perPage }
|
|
||||||
] as const,
|
|
||||||
queryFn: async ({ signal, meta }) => {
|
|
||||||
const sp = new URLSearchParams({
|
|
||||||
pageSize: perPage.toString()
|
|
||||||
});
|
|
||||||
|
|
||||||
if (query?.trim()) {
|
|
||||||
sp.set("query", query);
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await meta!.api.get<
|
|
||||||
AxiosResponse<ListLauncherLabelsResponse>
|
|
||||||
>(`/org/${orgId}/launcher/labels?${sp.toString()}`, {
|
|
||||||
signal
|
|
||||||
});
|
|
||||||
return res.data.data.labels;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
groups: (orgId: string, filters: LauncherQueryFilters) =>
|
|
||||||
infiniteQueryOptions({
|
|
||||||
queryKey: ["ORG", orgId, "LAUNCHER", "GROUPS", filters] as const,
|
|
||||||
queryFn: async ({ pageParam = 1, signal, meta }) => {
|
|
||||||
const sp = buildLauncherSearchParams(filters, pageParam);
|
|
||||||
const res = await meta!.api.get<
|
|
||||||
AxiosResponse<ListLauncherGroupsResponse>
|
|
||||||
>(`/org/${orgId}/launcher/groups?${sp.toString()}`, { signal });
|
|
||||||
return res.data.data;
|
|
||||||
},
|
|
||||||
initialPageParam: 1,
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
getNextPageParam: (lastPage) => {
|
|
||||||
const { page, pageSize, total } = lastPage.pagination;
|
|
||||||
const nextPage = page + 1;
|
|
||||||
return page * pageSize < total ? nextPage : undefined;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
resources: (
|
|
||||||
orgId: string,
|
|
||||||
filters: LauncherQueryFilters & { groupKey: string }
|
|
||||||
) =>
|
|
||||||
infiniteQueryOptions({
|
|
||||||
queryKey: ["ORG", orgId, "LAUNCHER", "RESOURCES", filters] as const,
|
|
||||||
queryFn: async ({ pageParam = 1, signal, meta }) => {
|
|
||||||
const sp = buildLauncherSearchParams(filters, pageParam);
|
|
||||||
const res = await meta!.api.get<
|
|
||||||
AxiosResponse<ListLauncherResourcesResponse>
|
|
||||||
>(`/org/${orgId}/launcher/resources?${sp.toString()}`, {
|
|
||||||
signal
|
|
||||||
});
|
|
||||||
return res.data.data;
|
|
||||||
},
|
|
||||||
initialPageParam: 1,
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
getNextPageParam: (lastPage) => {
|
|
||||||
const { page, pageSize, total } = lastPage.pagination;
|
|
||||||
const nextPage = page + 1;
|
|
||||||
return page * pageSize < total ? nextPage : undefined;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|||||||
Reference in New Issue
Block a user