Compare commits

..

5 Commits

Author SHA1 Message Date
Fred KISSIE c411a1a5b9 🚧 wip 2026-08-28 22:49:51 +02:00
Fred KISSIE a4d9365563 ♻️ Only show the username instead of the name+username 2026-08-28 22:09:01 +02:00
Fred KISSIE e9f7678b90 List of orgs, with all columns 2026-08-28 22:00:06 +02:00
Fred KISSIE a904c915d8 List orgs in server 2026-08-28 21:42:20 +02:00
Fred KISSIE cb84c2954b 🚧 WIP 2026-08-27 00:03:56 +02:00
32 changed files with 2094 additions and 3265 deletions
+4 -4
View File
@@ -77,7 +77,7 @@ jobs:
fi fi
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with: with:
registry: docker.io registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }} username: ${{ secrets.DOCKER_HUB_USERNAME }}
@@ -149,7 +149,7 @@ jobs:
fi fi
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with: with:
registry: docker.io registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }} username: ${{ secrets.DOCKER_HUB_USERNAME }}
@@ -204,7 +204,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with: with:
registry: docker.io registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }} username: ${{ secrets.DOCKER_HUB_USERNAME }}
@@ -407,7 +407,7 @@ jobs:
shell: bash shell: bash
- name: Login to GitHub Container Registry (for cosign) - name: Login to GitHub Container Registry (for cosign)
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
stale: stale:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0 - uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
with: with:
days-before-stale: 14 days-before-stale: 14
days-before-close: 14 days-before-close: 14
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:26.8.1-alpine FROM node:24.18.1-alpine
WORKDIR /app WORKDIR /app
-13
View File
@@ -99,19 +99,6 @@ Access private resources like SSH servers, databases, RDP, and entire network ra
<img src="public/screenshots/private-resources.png" alt="Private resources" width="100%" /> <img src="public/screenshots/private-resources.png" alt="Private resources" width="100%" />
### Identity-aware AI gateway
Put an identity-aware proxy in front of public cloud (OpenAI, Anthropic, Gemini, etc.) and self-hosted model servers (Ollama, vLLM, Mistral, etc.) so coding agents and AI clients call a single Pangolin URL. Publish it as a public resource with personal API keys, or keep it private on a client tunnel where the connected client is the credential for keyless access. Budgets, session history, and usage analytics sit in front of every call.
* Access self-hosted models (vLLM, Ollama, etc) alongside cloud models (OpenAI, Anthropic, etc) in one place
* Keyless access by authenticating users with the Pangolin desktop client
* Or, provide users with personal API keys
* Control costs and token usage by setting budgets
* Audit with detailed session history and analytics
* Integrate AI clients and coding agents (Claude Code, Codex, OpenCode, etc)
<img src="public/screenshots/expanded-session-logs.png" alt="AI Session Logs" width="100%" />
### Give users and roles access to resources ### Give users and roles access to resources
Use Pangolin's built-in users or bring your own identity provider and set up role-based access control (RBAC). Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications, services, and routes you explicitly define. Use Pangolin's built-in users or bring your own identity provider and set up role-based access control (RBAC). Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications, services, and routes you explicitly define.
+3
View File
@@ -465,6 +465,8 @@
"apiKeysDelete": "Delete API Key", "apiKeysDelete": "Delete API Key",
"apiKeysManage": "Manage API Keys", "apiKeysManage": "Manage API Keys",
"apiKeysDescription": "API keys are used to authenticate with the integration API", "apiKeysDescription": "API keys are used to authenticate with the integration API",
"orgsManage": "Manage Organizations",
"orgsDescription": "View and manage all organizations on this instance",
"provisioningKeysTitle": "Provisioning Key", "provisioningKeysTitle": "Provisioning Key",
"provisioningKeysManage": "Manage Provisioning Keys", "provisioningKeysManage": "Manage Provisioning Keys",
"provisioningKeysDescription": "Provisioning keys are used to authenticate automated site provisioning for your organization.", "provisioningKeysDescription": "Provisioning keys are used to authenticate automated site provisioning for your organization.",
@@ -2089,6 +2091,7 @@
"resourceBudgetSettings": "Budget", "resourceBudgetSettings": "Budget",
"resourceBudgetSettingsDescription": "Configure how this AI gateway restricts usage based on spending or token limits", "resourceBudgetSettingsDescription": "Configure how this AI gateway restricts usage based on spending or token limits",
"sidebarApiKeys": "API Keys", "sidebarApiKeys": "API Keys",
"sidebarOrgs": "Organizations",
"sidebarProvisioning": "Provisioning", "sidebarProvisioning": "Provisioning",
"sidebarSettings": "Settings", "sidebarSettings": "Settings",
"sidebarAllUsers": "All Users", "sidebarAllUsers": "All Users",
+788 -2213
View File
File diff suppressed because it is too large Load Diff
+305 -392
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -94,12 +94,12 @@
"input-otp": "1.4.2", "input-otp": "1.4.2",
"ioredis": "5.11.0", "ioredis": "5.11.0",
"jmespath": "0.16.0", "jmespath": "0.16.0",
"js-yaml": "4.3.1", "js-yaml": "4.3.0",
"jsonwebtoken": "9.0.3", "jsonwebtoken": "9.0.3",
"lucide-react": "1.17.0", "lucide-react": "1.17.0",
"maxmind": "5.0.6", "maxmind": "5.0.6",
"moment": "2.30.1", "moment": "2.30.1",
"next": "16.3.1", "next": "16.2.11",
"next-intl": "4.13.0", "next-intl": "4.13.0",
"next-themes": "0.4.6", "next-themes": "0.4.6",
"nextjs-toploader": "3.9.17", "nextjs-toploader": "3.9.17",
@@ -139,7 +139,7 @@
"devDependencies": { "devDependencies": {
"@dotenvx/dotenvx": "1.69.1", "@dotenvx/dotenvx": "1.69.1",
"@esbuild-plugins/tsconfig-paths": "0.1.2", "@esbuild-plugins/tsconfig-paths": "0.1.2",
"@react-email/ui": "^6.9.2", "@react-email/ui": "^6.5.0",
"@tailwindcss/postcss": "4.3.0", "@tailwindcss/postcss": "4.3.0",
"@tanstack/react-query-devtools": "5.100.14", "@tanstack/react-query-devtools": "5.100.14",
"@types/better-sqlite3": "7.6.13", "@types/better-sqlite3": "7.6.13",
@@ -170,7 +170,7 @@
"esbuild-node-externals": "1.22.0", "esbuild-node-externals": "1.22.0",
"eslint": "10.4.0", "eslint": "10.4.0",
"eslint-config-next": "16.2.6", "eslint-config-next": "16.2.6",
"postcss": "8.5.23", "postcss": "8.5.15",
"prettier": "3.8.3", "prettier": "3.8.3",
"react-email": "6.5.0", "react-email": "6.5.0",
"tailwindcss": "4.3.0", "tailwindcss": "4.3.0",
@@ -182,6 +182,6 @@
"overrides": { "overrides": {
"esbuild": "0.28.0", "esbuild": "0.28.0",
"dompurify": "3.4.0", "dompurify": "3.4.0",
"postcss": "8.5.23" "postcss": "8.5.15"
} }
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 790 KiB

+49 -71
View File
@@ -262,20 +262,16 @@ export const resourceAiModels = pgTable(
(t) => [primaryKey({ columns: [t.resourceId, t.modelId] })] (t) => [primaryKey({ columns: [t.resourceId, t.modelId] })]
); );
export const labels = pgTable( export const labels = pgTable("labels", {
"labels", labelId: serial("labelId").primaryKey(),
{ name: varchar("name").notNull(),
labelId: serial("labelId").primaryKey(), color: varchar("color").notNull(),
name: varchar("name").notNull(), orgId: varchar("orgId")
color: varchar("color").notNull(), .references(() => orgs.orgId, {
orgId: varchar("orgId") onDelete: "cascade"
.references(() => orgs.orgId, { })
onDelete: "cascade" .notNull()
}) });
.notNull()
},
(t) => [index("idx_labels_orgid").on(t.orgId)]
);
export const launcherViews = pgTable("launcherViews", { export const launcherViews = pgTable("launcherViews", {
viewId: serial("viewId").primaryKey(), viewId: serial("viewId").primaryKey(),
@@ -697,19 +693,15 @@ export const twoFactorBackupCodes = pgTable("twoFactorBackupCodes", {
codeHash: varchar("codeHash").notNull() codeHash: varchar("codeHash").notNull()
}); });
export const sessions = pgTable( export const sessions = pgTable("session", {
"session", sessionId: varchar("id").primaryKey(),
{ userId: varchar("userId")
sessionId: varchar("id").primaryKey(), .notNull()
userId: varchar("userId") .references(() => users.userId, { onDelete: "cascade" }),
.notNull() expiresAt: bigint("expiresAt", { mode: "number" }).notNull(),
.references(() => users.userId, { onDelete: "cascade" }), issuedAt: bigint("issuedAt", { mode: "number" }),
expiresAt: bigint("expiresAt", { mode: "number" }).notNull(), deviceAuthUsed: boolean("deviceAuthUsed").notNull().default(false)
issuedAt: bigint("issuedAt", { mode: "number" }), });
deviceAuthUsed: boolean("deviceAuthUsed").notNull().default(false)
},
(t) => [index("idx_sessions_userid").on(t.userId)]
);
export const newtSessions = pgTable("newtSession", { export const newtSessions = pgTable("newtSession", {
sessionId: varchar("id").primaryKey(), sessionId: varchar("id").primaryKey(),
@@ -719,26 +711,19 @@ export const newtSessions = pgTable("newtSession", {
expiresAt: bigint("expiresAt", { mode: "number" }).notNull() expiresAt: bigint("expiresAt", { mode: "number" }).notNull()
}); });
export const userOrgs = pgTable( export const userOrgs = pgTable("userOrgs", {
"userOrgs", userId: varchar("userId")
{ .notNull()
userId: varchar("userId") .references(() => users.userId, { onDelete: "cascade" }),
.notNull() orgId: varchar("orgId")
.references(() => users.userId, { onDelete: "cascade" }), .references(() => orgs.orgId, {
orgId: varchar("orgId") onDelete: "cascade"
.references(() => orgs.orgId, { })
onDelete: "cascade" .notNull(),
}) isOwner: boolean("isOwner").notNull().default(false),
.notNull(), autoProvisioned: boolean("autoProvisioned").default(false),
isOwner: boolean("isOwner").notNull().default(false), pamUsername: varchar("pamUsername") // cleaned username for ssh and such
autoProvisioned: boolean("autoProvisioned").default(false), });
pamUsername: varchar("pamUsername") // cleaned username for ssh and such
},
(t) => [
index("idx_userOrgs_userid").on(t.userId),
index("idx_userOrgs_orgid").on(t.orgId)
]
);
export const emailVerificationCodes = pgTable("emailVerificationCodes", { export const emailVerificationCodes = pgTable("emailVerificationCodes", {
codeId: serial("id").primaryKey(), codeId: serial("id").primaryKey(),
@@ -766,26 +751,22 @@ export const actions = pgTable("actions", {
description: varchar("description") description: varchar("description")
}); });
export const roles = pgTable( export const roles = pgTable("roles", {
"roles", roleId: serial("roleId").primaryKey(),
{ orgId: varchar("orgId")
roleId: serial("roleId").primaryKey(), .references(() => orgs.orgId, {
orgId: varchar("orgId") onDelete: "cascade"
.references(() => orgs.orgId, { })
onDelete: "cascade" .notNull(),
}) isAdmin: boolean("isAdmin"),
.notNull(), name: varchar("name").notNull(),
isAdmin: boolean("isAdmin"), description: varchar("description"),
name: varchar("name").notNull(), requireDeviceApproval: boolean("requireDeviceApproval").default(false),
description: varchar("description"), sshSudoMode: varchar("sshSudoMode", { length: 32 }).default("full"), // "none" | "full" | "commands"
requireDeviceApproval: boolean("requireDeviceApproval").default(false), sshSudoCommands: text("sshSudoCommands").default("[]"),
sshSudoMode: varchar("sshSudoMode", { length: 32 }).default("full"), // "none" | "full" | "commands" sshCreateHomeDir: boolean("sshCreateHomeDir").default(true),
sshSudoCommands: text("sshSudoCommands").default("[]"), sshUnixGroups: text("sshUnixGroups").default("[]")
sshCreateHomeDir: boolean("sshCreateHomeDir").default(true), });
sshUnixGroups: text("sshUnixGroups").default("[]")
},
(t) => [index("idx_roles_orgid").on(t.orgId)]
);
export const userOrgRoles = pgTable( export const userOrgRoles = pgTable(
"userOrgRoles", "userOrgRoles",
@@ -1428,10 +1409,7 @@ export const olms = pgTable(
}), }),
archived: boolean("archived").notNull().default(false) archived: boolean("archived").notNull().default(false)
}, },
(t) => [ (t) => [index("idx_olms_clientid").on(t.clientId)]
index("idx_olms_clientid").on(t.clientId),
index("idx_olms_userid").on(t.userId)
]
); );
export const currentFingerprint = pgTable("currentFingerprint", { export const currentFingerprint = pgTable("currentFingerprint", {
+266 -335
View File
@@ -99,147 +99,131 @@ export const orgDomains = sqliteTable("orgDomains", {
.references(() => domains.domainId, { onDelete: "cascade" }) .references(() => domains.domainId, { onDelete: "cascade" })
}); });
export const sites = sqliteTable( export const sites = sqliteTable("sites", {
"sites", siteId: integer("siteId").primaryKey({ autoIncrement: true }),
{ orgId: text("orgId")
siteId: integer("siteId").primaryKey({ autoIncrement: true }), .references(() => orgs.orgId, {
orgId: text("orgId") onDelete: "cascade"
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull(),
niceId: text("niceId").notNull(),
exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, {
onDelete: "set null"
}),
networkId: integer("networkId").references(() => networks.networkId, {
onDelete: "set null"
}),
name: text("name").notNull(),
pubKey: text("pubKey"),
exitNodeSubnet: text("exitNodeSubnet"),
megabytesIn: integer("bytesIn").default(0),
megabytesOut: integer("bytesOut").default(0),
lastBandwidthUpdate: text("lastBandwidthUpdate"),
type: text("type").notNull(), // "newt" or "wireguard"
online: integer("online", { mode: "boolean" }).notNull().default(false),
lastPing: integer("lastPing"),
// exit node stuff that is how to connect to the site when it has a wg server
address: text("address"), // this is the address of the wireguard interface in newt
endpoint: text("endpoint"), // this is how to reach gerbil externally - gets put into the wireguard config
localEndpoints: text("localEndpoints"), // JSON encoded list of string ips on the local machine to try to connect to
publicKey: text("publicKey"), // TODO: Fix typo in publicKey
lastHolePunch: integer("lastHolePunch"),
listenPort: integer("listenPort"),
dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" })
.notNull()
.default(true),
autoUpdateEnabled: integer("autoUpdateEnabled", { mode: "boolean" })
.notNull()
.default(false),
autoUpdateOverrideOrg: integer("autoUpdateOverrideOrg", {
mode: "boolean"
}) })
.notNull() .notNull(),
.default(false), niceId: text("niceId").notNull(),
status: text("status") exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, {
.$type<"pending" | "approved">() onDelete: "set null"
.default("approved") }),
}, networkId: integer("networkId").references(() => networks.networkId, {
(table) => [ onDelete: "set null"
index("idx_sites_orgId").on(table.orgId) }),
] name: text("name").notNull(),
); pubKey: text("pubKey"),
exitNodeSubnet: text("exitNodeSubnet"),
megabytesIn: integer("bytesIn").default(0),
megabytesOut: integer("bytesOut").default(0),
lastBandwidthUpdate: text("lastBandwidthUpdate"),
type: text("type").notNull(), // "newt" or "wireguard"
online: integer("online", { mode: "boolean" }).notNull().default(false),
lastPing: integer("lastPing"),
export const resources = sqliteTable( // exit node stuff that is how to connect to the site when it has a wg server
"resources", address: text("address"), // this is the address of the wireguard interface in newt
{ endpoint: text("endpoint"), // this is how to reach gerbil externally - gets put into the wireguard config
resourceId: integer("resourceId").primaryKey({ autoIncrement: true }), localEndpoints: text("localEndpoints"), // JSON encoded list of string ips on the local machine to try to connect to
resourcePolicyId: integer("resourcePolicyId").references( publicKey: text("publicKey"), // TODO: Fix typo in publicKey
() => resourcePolicies.resourcePolicyId, lastHolePunch: integer("lastHolePunch"),
{ onDelete: "set null" } listenPort: integer("listenPort"),
), dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" })
defaultResourcePolicyId: integer("defaultResourcePolicyId").references( .notNull()
() => resourcePolicies.resourcePolicyId, .default(true),
{ autoUpdateEnabled: integer("autoUpdateEnabled", { mode: "boolean" })
onDelete: "restrict" .notNull()
} .default(false),
), autoUpdateOverrideOrg: integer("autoUpdateOverrideOrg", {
resourceGuid: text("resourceGuid", { length: 36 }) mode: "boolean"
.unique() })
.notNull() .notNull()
.$defaultFn(() => randomUUID()), .default(false),
orgId: text("orgId") status: text("status").$type<"pending" | "approved">().default("approved")
.references(() => orgs.orgId, { });
onDelete: "cascade"
}) export const resources = sqliteTable("resources", {
.notNull(), resourceId: integer("resourceId").primaryKey({ autoIncrement: true }),
niceId: text("niceId").notNull(), resourcePolicyId: integer("resourcePolicyId").references(
name: text("name").notNull(), () => resourcePolicies.resourcePolicyId,
subdomain: text("subdomain"), { onDelete: "set null" }
fullDomain: text("fullDomain"), ),
domainId: text("domainId").references(() => domains.domainId, { defaultResourcePolicyId: integer("defaultResourcePolicyId").references(
onDelete: "set null" () => resourcePolicies.resourcePolicyId,
}), {
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false), onDelete: "restrict"
blockAccess: integer("blockAccess", { mode: "boolean" }) }
.notNull() ),
.default(false), resourceGuid: text("resourceGuid", { length: 36 })
proxyPort: integer("proxyPort"), .unique()
sso: integer("sso", { mode: "boolean" }), .notNull()
emailWhitelistEnabled: integer("emailWhitelistEnabled", { .$defaultFn(() => randomUUID()),
mode: "boolean" orgId: text("orgId")
}), .references(() => orgs.orgId, {
applyRules: integer("applyRules", { mode: "boolean" }), onDelete: "cascade"
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
stickySession: integer("stickySession", { mode: "boolean" })
.notNull()
.default(false),
tlsServerName: text("tlsServerName"),
setHostHeader: text("setHostHeader"),
enableProxy: integer("enableProxy", { mode: "boolean" }).default(true),
skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, {
onDelete: "set null"
}),
headers: text("headers"), // comma-separated list of headers to add to the request
proxyProtocol: integer("proxyProtocol", { mode: "boolean" })
.notNull()
.default(false),
proxyProtocolVersion: integer("proxyProtocolVersion").default(1),
maintenanceModeEnabled: integer("maintenanceModeEnabled", {
mode: "boolean"
}) })
.notNull() .notNull(),
.default(false), niceId: text("niceId").notNull(),
maintenanceModeType: text("maintenanceModeType", { name: text("name").notNull(),
enum: ["forced", "automatic"] subdomain: text("subdomain"),
}).default("forced"), // "forced" = always show, "automatic" = only when down fullDomain: text("fullDomain"),
maintenanceTitle: text("maintenanceTitle"), domainId: text("domainId").references(() => domains.domainId, {
maintenanceMessage: text("maintenanceMessage"), onDelete: "set null"
maintenanceEstimatedTime: text("maintenanceEstimatedTime"), }),
postAuthPath: text("postAuthPath"), ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown" blockAccess: integer("blockAccess", { mode: "boolean" })
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false), .notNull()
mode: text("mode") .default(false),
.default("http") proxyPort: integer("proxyPort"),
.$type<"rdp" | "ssh" | "http" | "vnc" | "inference" | "tcp" | "udp">() sso: integer("sso", { mode: "boolean" }),
.notNull(), // rdp, ssh, http, vnc, inference emailWhitelistEnabled: integer("emailWhitelistEnabled", {
pamMode: text("pamMode") mode: "boolean"
.$type<"passthrough" | "push">() }),
.default("passthrough"), applyRules: integer("applyRules", { mode: "boolean" }),
authDaemonMode: text("authDaemonMode") enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
.$type<"site" | "remote" | "native">() stickySession: integer("stickySession", { mode: "boolean" })
.default("site"), .notNull()
authDaemonPort: integer("authDaemonPort").default(22123), .default(false),
status: text("status") tlsServerName: text("tlsServerName"),
.$type<"pending" | "approved">() setHostHeader: text("setHostHeader"),
.default("approved") enableProxy: integer("enableProxy", { mode: "boolean" }).default(true),
}, skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, {
(table) => [ onDelete: "set null"
index("idx_resources_orgId").on(table.orgId) }),
] headers: text("headers"), // comma-separated list of headers to add to the request
); proxyProtocol: integer("proxyProtocol", { mode: "boolean" })
.notNull()
.default(false),
proxyProtocolVersion: integer("proxyProtocolVersion").default(1),
maintenanceModeEnabled: integer("maintenanceModeEnabled", {
mode: "boolean"
})
.notNull()
.default(false),
maintenanceModeType: text("maintenanceModeType", {
enum: ["forced", "automatic"]
}).default("forced"), // "forced" = always show, "automatic" = only when down
maintenanceTitle: text("maintenanceTitle"),
maintenanceMessage: text("maintenanceMessage"),
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
postAuthPath: text("postAuthPath"),
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown"
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false),
mode: text("mode")
.default("http")
.$type<"rdp" | "ssh" | "http" | "vnc" | "inference" | "tcp" | "udp">()
.notNull(), // rdp, ssh, http, vnc, inference
pamMode: text("pamMode")
.$type<"passthrough" | "push">()
.default("passthrough"),
authDaemonMode: text("authDaemonMode")
.$type<"site" | "remote" | "native">()
.default("site"),
authDaemonPort: integer("authDaemonPort").default(22123),
status: text("status").$type<"pending" | "approved">().default("approved")
});
export const resourceAiProviders = sqliteTable( export const resourceAiProviders = sqliteTable(
"resourceAiProviders", "resourceAiProviders",
@@ -276,22 +260,16 @@ export const resourceAiModels = sqliteTable(
(t) => [primaryKey({ columns: [t.resourceId, t.modelId] })] (t) => [primaryKey({ columns: [t.resourceId, t.modelId] })]
); );
export const labels = sqliteTable( export const labels = sqliteTable("labels", {
"labels", labelId: integer("labelId").primaryKey({ autoIncrement: true }),
{ name: text("name").notNull(),
labelId: integer("labelId").primaryKey({ autoIncrement: true }), color: text("color").notNull(),
name: text("name").notNull(), orgId: text("orgId")
color: text("color").notNull(), .references(() => orgs.orgId, {
orgId: text("orgId") onDelete: "cascade"
.references(() => orgs.orgId, { })
onDelete: "cascade" .notNull()
}) });
.notNull()
},
(table) => [
index("idx_labels_orgId").on(table.orgId)
]
);
export const launcherViews = sqliteTable("launcherViews", { export const launcherViews = sqliteTable("launcherViews", {
viewId: integer("viewId").primaryKey({ autoIncrement: true }), viewId: integer("viewId").primaryKey({ autoIncrement: true }),
@@ -388,44 +366,35 @@ export const clientLabels = sqliteTable(
(t) => [unique("client_label_uniq").on(t.clientId, t.labelId)] (t) => [unique("client_label_uniq").on(t.clientId, t.labelId)]
); );
export const targets = sqliteTable( export const targets = sqliteTable("targets", {
"targets", targetId: integer("targetId").primaryKey({ autoIncrement: true }),
{ resourceId: integer("resourceId").references(() => resources.resourceId, {
targetId: integer("targetId").primaryKey({ autoIncrement: true }), onDelete: "cascade"
resourceId: integer("resourceId").references( }),
() => resources.resourceId, providerId: integer("providerId").references(() => aiProviders.providerId, {
{ onDelete: "cascade" } onDelete: "cascade"
), }),
providerId: integer("providerId").references( siteId: integer("siteId")
() => aiProviders.providerId, .references(() => sites.siteId, {
{ onDelete: "cascade" } onDelete: "cascade"
), })
siteId: integer("siteId") .notNull(),
.references(() => sites.siteId, { ip: text("ip").notNull(),
onDelete: "cascade" method: text("method"),
}) port: integer("port").notNull(),
.notNull(), internalPort: integer("internalPort"),
ip: text("ip").notNull(), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
method: text("method"), path: text("path"),
port: integer("port").notNull(), pathMatchType: text("pathMatchType"), // exact, prefix, regex
internalPort: integer("internalPort"), rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix
path: text("path"), priority: integer("priority").notNull().default(100),
pathMatchType: text("pathMatchType"), // exact, prefix, regex mode: text("mode")
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target .$type<"http" | "tcp" | "udp" | "ssh" | "rdp" | "vnc">()
rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix .notNull()
priority: integer("priority").notNull().default(100), .default("http"),
mode: text("mode") authToken: text("authToken")
.$type<"http" | "tcp" | "udp" | "ssh" | "rdp" | "vnc">() });
.notNull()
.default("http"),
authToken: text("authToken")
},
(table) => [
index("idx_targets_resourceId").on(table.resourceId),
index("idx_targets_siteId").on(table.siteId)
]
);
export const targetHealthCheck = sqliteTable("targetHealthCheck", { export const targetHealthCheck = sqliteTable("targetHealthCheck", {
targetHealthCheckId: integer("targetHealthCheckId").primaryKey({ targetHealthCheckId: integer("targetHealthCheckId").primaryKey({
@@ -694,63 +663,50 @@ export const setupTokens = sqliteTable("setupTokens", {
dateUsed: text("dateUsed") dateUsed: text("dateUsed")
}); });
export const newts = sqliteTable( export const newts = sqliteTable("newt", {
"newt", newtId: text("id").primaryKey(),
{ secretHash: text("secretHash").notNull(),
newtId: text("id").primaryKey(), dateCreated: text("dateCreated").notNull(),
secretHash: text("secretHash").notNull(), version: text("version"),
dateCreated: text("dateCreated").notNull(), siteId: integer("siteId").references(() => sites.siteId, {
version: text("version"), onDelete: "cascade"
siteId: integer("siteId").references(() => sites.siteId, { })
});
export const clients = sqliteTable("clients", {
clientId: integer("clientId").primaryKey({ autoIncrement: true }),
orgId: text("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade" onDelete: "cascade"
}) })
}, .notNull(),
(table) => [ exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, {
index("idx_newts_siteId").on(table.siteId) onDelete: "set null"
] }),
); userId: text("userId").references(() => users.userId, {
// optionally tied to a user and in this case delete when the user deletes
export const clients = sqliteTable( onDelete: "cascade"
"clients", }),
{ niceId: text("niceId").notNull(),
clientId: integer("clientId").primaryKey({ autoIncrement: true }), name: text("name").notNull(),
orgId: text("orgId") pubKey: text("pubKey"),
.references(() => orgs.orgId, { olmId: text("olmId"), // to lock it to a specific olm optionally
onDelete: "cascade" subnet: text("subnet").notNull(),
}) exitNodeSubnet: text("exitNodeSubnet"), // this is the subnet when connecting to an exit node
.notNull(), megabytesIn: integer("bytesIn"),
exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, { megabytesOut: integer("bytesOut"),
onDelete: "set null" lastBandwidthUpdate: text("lastBandwidthUpdate"),
}), lastPing: integer("lastPing"),
userId: text("userId").references(() => users.userId, { type: text("type").notNull(), // "olm"
// optionally tied to a user and in this case delete when the user deletes online: integer("online", { mode: "boolean" }).notNull().default(false),
onDelete: "cascade" // endpoint: text("endpoint"),
}), lastHolePunch: integer("lastHolePunch"),
niceId: text("niceId").notNull(), archived: integer("archived", { mode: "boolean" }).notNull().default(false),
name: text("name").notNull(), blocked: integer("blocked", { mode: "boolean" }).notNull().default(false),
pubKey: text("pubKey"), approvalState: text("approvalState").$type<
olmId: text("olmId"), // to lock it to a specific olm optionally "pending" | "approved" | "denied"
subnet: text("subnet").notNull(), >()
exitNodeSubnet: text("exitNodeSubnet"), // this is the subnet when connecting to an exit node });
megabytesIn: integer("bytesIn"),
megabytesOut: integer("bytesOut"),
lastBandwidthUpdate: text("lastBandwidthUpdate"),
lastPing: integer("lastPing"),
type: text("type").notNull(), // "olm"
online: integer("online", { mode: "boolean" }).notNull().default(false),
// endpoint: text("endpoint"),
lastHolePunch: integer("lastHolePunch"),
archived: integer("archived", { mode: "boolean" }).notNull().default(false),
blocked: integer("blocked", { mode: "boolean" }).notNull().default(false),
approvalState: text("approvalState").$type<
"pending" | "approved" | "denied"
>()
},
(table) => [
index("idx_clients_orgId").on(table.orgId),
index("idx_clients_userId").on(table.userId)
]
);
export const clientSitesAssociationsCache = sqliteTable( export const clientSitesAssociationsCache = sqliteTable(
"clientSitesAssociationsCache", "clientSitesAssociationsCache",
@@ -778,29 +734,23 @@ export const clientSiteResourcesAssociationsCache = sqliteTable(
} }
); );
export const olms = sqliteTable( export const olms = sqliteTable("olms", {
"olms", olmId: text("id").primaryKey(),
{ secretHash: text("secretHash").notNull(),
olmId: text("id").primaryKey(), dateCreated: text("dateCreated").notNull(),
secretHash: text("secretHash").notNull(), version: text("version"),
dateCreated: text("dateCreated").notNull(), agent: text("agent"),
version: text("version"), name: text("name"),
agent: text("agent"), clientId: integer("clientId").references(() => clients.clientId, {
name: text("name"), // we will switch this depending on the current org it wants to connect to
clientId: integer("clientId").references(() => clients.clientId, { onDelete: "set null"
// we will switch this depending on the current org it wants to connect to }),
onDelete: "set null" userId: text("userId").references(() => users.userId, {
}), // optionally tied to a user and in this case delete when the user deletes
userId: text("userId").references(() => users.userId, { onDelete: "cascade"
// optionally tied to a user and in this case delete when the user deletes }),
onDelete: "cascade" archived: integer("archived", { mode: "boolean" }).notNull().default(false)
}), });
archived: integer("archived", { mode: "boolean" }).notNull().default(false)
},
(table) => [
index("idx_olms_userId").on(table.userId)
]
);
export const currentFingerprint = sqliteTable("currentFingerprint", { export const currentFingerprint = sqliteTable("currentFingerprint", {
fingerprintId: integer("id").primaryKey({ autoIncrement: true }), fingerprintId: integer("id").primaryKey({ autoIncrement: true }),
@@ -962,23 +912,17 @@ export const twoFactorBackupCodes = sqliteTable("twoFactorBackupCodes", {
codeHash: text("codeHash").notNull() codeHash: text("codeHash").notNull()
}); });
export const sessions = sqliteTable( export const sessions = sqliteTable("session", {
"session", sessionId: text("id").primaryKey(),
{ userId: text("userId")
sessionId: text("id").primaryKey(), .notNull()
userId: text("userId") .references(() => users.userId, { onDelete: "cascade" }),
.notNull() expiresAt: integer("expiresAt").notNull(),
.references(() => users.userId, { onDelete: "cascade" }), issuedAt: integer("issuedAt"),
expiresAt: integer("expiresAt").notNull(), deviceAuthUsed: integer("deviceAuthUsed", { mode: "boolean" })
issuedAt: integer("issuedAt"), .notNull()
deviceAuthUsed: integer("deviceAuthUsed", { mode: "boolean" }) .default(false)
.notNull() });
.default(false)
},
(table) => [
index("idx_sessions_userId").on(table.userId)
]
);
export const newtSessions = sqliteTable("newtSession", { export const newtSessions = sqliteTable("newtSession", {
sessionId: text("id").primaryKey(), sessionId: text("id").primaryKey(),
@@ -996,28 +940,21 @@ export const olmSessions = sqliteTable("clientSession", {
expiresAt: integer("expiresAt").notNull() expiresAt: integer("expiresAt").notNull()
}); });
export const userOrgs = sqliteTable( export const userOrgs = sqliteTable("userOrgs", {
"userOrgs", userId: text("userId")
{ .notNull()
userId: text("userId") .references(() => users.userId, { onDelete: "cascade" }),
.notNull() orgId: text("orgId")
.references(() => users.userId, { onDelete: "cascade" }), .references(() => orgs.orgId, {
orgId: text("orgId") onDelete: "cascade"
.references(() => orgs.orgId, { })
onDelete: "cascade" .notNull(),
}) isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false),
.notNull(), autoProvisioned: integer("autoProvisioned", {
isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false), mode: "boolean"
autoProvisioned: integer("autoProvisioned", { }).default(false),
mode: "boolean" pamUsername: text("pamUsername") // cleaned username for ssh and such
}).default(false), });
pamUsername: text("pamUsername") // cleaned username for ssh and such
},
(table) => [
index("idx_userOrgs_userId").on(table.userId),
index("idx_userOrgs_orgId").on(table.orgId)
]
);
export const emailVerificationCodes = sqliteTable("emailVerificationCodes", { export const emailVerificationCodes = sqliteTable("emailVerificationCodes", {
codeId: integer("id").primaryKey({ autoIncrement: true }), codeId: integer("id").primaryKey({ autoIncrement: true }),
@@ -1045,32 +982,26 @@ export const actions = sqliteTable("actions", {
description: text("description") description: text("description")
}); });
export const roles = sqliteTable( export const roles = sqliteTable("roles", {
"roles", roleId: integer("roleId").primaryKey({ autoIncrement: true }),
{ orgId: text("orgId")
roleId: integer("roleId").primaryKey({ autoIncrement: true }), .references(() => orgs.orgId, {
orgId: text("orgId") onDelete: "cascade"
.references(() => orgs.orgId, { })
onDelete: "cascade" .notNull(),
}) isAdmin: integer("isAdmin", { mode: "boolean" }),
.notNull(), name: text("name").notNull(),
isAdmin: integer("isAdmin", { mode: "boolean" }), description: text("description"),
name: text("name").notNull(), requireDeviceApproval: integer("requireDeviceApproval", {
description: text("description"), mode: "boolean"
requireDeviceApproval: integer("requireDeviceApproval", { }).default(false),
mode: "boolean" sshSudoMode: text("sshSudoMode").default("full"), // "none" | "full" | "commands"
}).default(false), sshSudoCommands: text("sshSudoCommands").default("[]"),
sshSudoMode: text("sshSudoMode").default("full"), // "none" | "full" | "commands" sshCreateHomeDir: integer("sshCreateHomeDir", { mode: "boolean" }).default(
sshSudoCommands: text("sshSudoCommands").default("[]"), true
sshCreateHomeDir: integer("sshCreateHomeDir", { mode: "boolean" }).default( ),
true sshUnixGroups: text("sshUnixGroups").default("[]")
), });
sshUnixGroups: text("sshUnixGroups").default("[]")
},
(table) => [
index("idx_roles_orgId").on(table.orgId)
]
);
export const userOrgRoles = sqliteTable( export const userOrgRoles = sqliteTable(
"userOrgRoles", "userOrgRoles",
+2 -2
View File
@@ -101,7 +101,7 @@ export const AuthSchema = z.object({
export const RuleSchema = z export const RuleSchema = z
.object({ .object({
action: z.enum(["allow", "deny", "pass"]), action: z.enum(["allow", "deny", "pass"]),
match: z.enum(["cidr", "path", "ip", "country", "country_is_not", "asn", "region"]), match: z.enum(["cidr", "path", "ip", "country", "asn", "region"]),
value: z.coerce.string(), value: z.coerce.string(),
priority: z.int().optional(), priority: z.int().optional(),
enabled: z.boolean().optional().default(true) enabled: z.boolean().optional().default(true)
@@ -136,7 +136,7 @@ export const RuleSchema = z
) )
.refine( .refine(
(rule) => { (rule) => {
if (rule.match === "country" || rule.match === "country_is_not") { if (rule.match === "country") {
if (!hasMaxmindCountryDb) { if (!hasMaxmindCountryDb) {
return false; return false;
} }
+15 -10
View File
@@ -31,6 +31,7 @@ export async function validateAndConstructDomain(
subdomain?: string | null subdomain?: string | null
): Promise<DomainValidationResult> { ): Promise<DomainValidationResult> {
try { try {
// Query domain with organization access check
const [domainRes] = await db const [domainRes] = await db
.select() .select()
.from(domains) .from(domains)
@@ -41,10 +42,6 @@ export async function validateAndConstructDomain(
eq(orgDomains.orgId, orgId), eq(orgDomains.orgId, orgId),
eq(orgDomains.domainId, domainId) eq(orgDomains.domainId, domainId)
) )
)
.leftJoin(
domainNamespaces,
eq(domainNamespaces.domainId, domainId)
); );
// Check if domain exists // Check if domain exists
@@ -55,7 +52,7 @@ export async function validateAndConstructDomain(
}; };
} }
if (!domainRes.orgDomains && !domainRes.domainNamespaces) { if (!domainRes.orgDomains) {
return { return {
success: false, success: false,
error: `Organization does not have access to domain with ID ${domainId}` error: `Organization does not have access to domain with ID ${domainId}`
@@ -86,11 +83,19 @@ export async function validateAndConstructDomain(
} }
// Wildcard subdomains are not allowed on namespace (provided/free) domains // Wildcard subdomains are not allowed on namespace (provided/free) domains
if (isWildcard && domainRes.domainNamespaces) { if (isWildcard) {
return { const [namespaceDomain] = await db
success: false, .select()
error: "Wildcard subdomains are not supported for provided or free domains. Use a specific subdomain instead." .from(domainNamespaces)
}; .where(eq(domainNamespaces.domainId, domainId))
.limit(1);
if (namespaceDomain) {
return {
success: false,
error: "Wildcard subdomains are not supported for provided or free domains. Use a specific subdomain instead."
};
}
} }
if ( if (
+1 -50
View File
@@ -3,8 +3,6 @@ import config from "./config";
import { getHostMeta } from "./hostMeta"; import { getHostMeta } from "./hostMeta";
import logger from "@server/logger"; import logger from "@server/logger";
import { import {
aiProviders,
aiUsageRecords,
alertRules, alertRules,
apiKeys, apiKeys,
blueprints, blueprints,
@@ -13,16 +11,7 @@ import {
siteResources siteResources
} from "@server/db"; } from "@server/db";
import { sites, users, orgs, resources, clients, idp } from "@server/db"; import { sites, users, orgs, resources, clients, idp } from "@server/db";
import { import { eq, count, notInArray, and, isNotNull, isNull } from "drizzle-orm";
eq,
count,
countDistinct,
notInArray,
and,
isNotNull,
isNull,
gte
} from "drizzle-orm";
import { APP_VERSION } from "./consts"; import { APP_VERSION } from "./consts";
import crypto from "crypto"; import crypto from "crypto";
import { UserType } from "@server/types/UserTypes"; import { UserType } from "@server/types/UserTypes";
@@ -183,25 +172,6 @@ class TelemetryClient {
.select({ count: count() }) .select({ count: count() })
.from(blueprints); .from(blueprints);
const [aiProvidersCount] = await db
.select({ count: count() })
.from(aiProviders);
const [orgsWithAiProviders] = await db
.select({ count: countDistinct(aiProviders.orgId) })
.from(aiProviders);
const usageWindowStart =
Math.floor(Date.now() / 1000) -
this.collectionIntervalDays * 24 * 60 * 60;
const [aiUsageRecordsRecent] = await db
.select({ count: count() })
.from(aiUsageRecords)
.where(gte(aiUsageRecords.createdAt, usageWindowStart));
const [orgsWithRecentAiUsage] = await db
.select({ count: countDistinct(aiUsageRecords.orgId) })
.from(aiUsageRecords)
.where(gte(aiUsageRecords.createdAt, usageWindowStart));
const supporterKey = config.getSupporterData(); const supporterKey = config.getSupporterData();
const allPrivateResources = await db.select().from(siteResources); const allPrivateResources = await db.select().from(siteResources);
@@ -212,7 +182,6 @@ class TelemetryClient {
let numPrivResourceCidr = 0; let numPrivResourceCidr = 0;
let numPrivResourceHttp = 0; let numPrivResourceHttp = 0;
let numPrivResourceSsh = 0; let numPrivResourceSsh = 0;
let numPrivResourceInference = 0;
for (const res of allPrivateResources) { for (const res of allPrivateResources) {
if (res.mode === "host") { if (res.mode === "host") {
numPrivResourceHosts += 1; numPrivResourceHosts += 1;
@@ -222,8 +191,6 @@ class TelemetryClient {
numPrivResourceHttp += 1; numPrivResourceHttp += 1;
} else if (res.mode === "ssh") { } else if (res.mode === "ssh") {
numPrivResourceSsh += 1; numPrivResourceSsh += 1;
} else if (res.mode === "inference") {
numPrivResourceInference += 1;
} }
if (res.alias) { if (res.alias) {
@@ -244,11 +211,6 @@ class TelemetryClient {
numPrivateResourceCidr: numPrivResourceCidr, numPrivateResourceCidr: numPrivResourceCidr,
numPrivateResourceHttp: numPrivResourceHttp, numPrivateResourceHttp: numPrivResourceHttp,
numPrivateResourceSsh: numPrivResourceSsh, numPrivateResourceSsh: numPrivResourceSsh,
numPrivateResourceInference: numPrivResourceInference,
numAiProviders: aiProvidersCount.count,
numOrgsWithAiProviders: orgsWithAiProviders.count,
numAiUsageRecordsRecent: aiUsageRecordsRecent.count,
numOrgsWithRecentAiUsage: orgsWithRecentAiUsage.count,
numAlertRules: numAlertRules.count, numAlertRules: numAlertRules.count,
numUserDevices: userDevicesCount.count, numUserDevices: userDevicesCount.count,
numMachineClients: machineClients.count, numMachineClients: machineClients.count,
@@ -361,17 +323,6 @@ class TelemetryClient {
num_resources_non_http: stats.resources.filter( num_resources_non_http: stats.resources.filter(
(r) => r.mode !== "http" (r) => r.mode !== "http"
).length, ).length,
num_resources_ai_gateway: stats.resources.filter(
(r) => r.mode === "inference"
).length,
num_private_resources_ai_gateway:
stats.numPrivateResourceInference,
num_ai_providers: stats.numAiProviders,
num_orgs_with_ai_providers: stats.numOrgsWithAiProviders,
num_ai_usage_records_recent:
stats.numAiUsageRecordsRecent,
num_orgs_with_recent_ai_usage:
stats.numOrgsWithRecentAiUsage,
num_newt_sites: stats.sites.filter((s) => s.type === "newt") num_newt_sites: stats.sites.filter((s) => s.type === "newt")
.length, .length,
num_local_sites: stats.sites.filter( num_local_sites: stats.sites.filter(
@@ -68,11 +68,6 @@ export async function verifyApiKeyAccessTokenAccess(
); );
} }
if (apiKey.isRoot) {
// Root keys can access any access token in any org
return next();
}
if (!resource.orgId) { if (!resource.orgId) {
return next( return next(
createHttpError( createHttpError(
+14 -10
View File
@@ -149,8 +149,12 @@ LQIDAQAB
} }
// Count used sites and users for license comparison // Count used sites and users for license comparison
const [siteCountRes] = await db.select({ value: count() }).from(sites); const [siteCountRes] = await db
const [userCountRes] = await db.select({ value: count() }).from(users); .select({ value: count() })
.from(sites);
const [userCountRes] = await db
.select({ value: count() })
.from(users);
const status: LicenseStatus = { const status: LicenseStatus = {
hostId: this.hostMeta.hostMetaId, hostId: this.hostMeta.hostMetaId,
@@ -272,13 +276,10 @@ LQIDAQAB
logger.error( logger.error(
`Allowing failure. Will retry one more time at next run interval.` `Allowing failure. Will retry one more time at next run interval.`
); );
// Fall back to last known good status if we have // return last known good status
// one cached; otherwise return the freshly built return this.statusCache.get(
// status (with defaults) rather than undefined.
const lastKnownStatus = this.statusCache.get(
this.statusKey this.statusKey
) as LicenseStatus | undefined; ) as LicenseStatus;
return lastKnownStatus ?? status;
} else { } else {
// Subsequent failures: fail abruptly // Subsequent failures: fail abruptly
throw e; throw e;
@@ -367,7 +368,10 @@ LQIDAQAB
} }
// Only consider quantity if defined and >= 0 (quantity = users, quantity_2 = sites) // Only consider quantity if defined and >= 0 (quantity = users, quantity_2 = sites)
if (cached.quantity_2 !== undefined && cached.quantity_2 >= 0) { if (
cached.quantity_2 !== undefined &&
cached.quantity_2 >= 0
) {
status.maxSites = status.maxSites =
(status.maxSites ?? 0) + cached.quantity_2; (status.maxSites ?? 0) + cached.quantity_2;
} }
@@ -557,7 +561,7 @@ LQIDAQAB
// Calculate exponential backoff delay // Calculate exponential backoff delay
const retryDelay = Math.floor( const retryDelay = Math.floor(
initialRetryDelay * initialRetryDelay *
Math.pow(exponentialFactor, attempt - 1) Math.pow(exponentialFactor, attempt - 1)
); );
logger.debug( logger.debug(
+2 -7
View File
@@ -6,7 +6,7 @@ import HttpCode from "@server/types/HttpCode";
import { response } from "@server/lib/response"; import { response } from "@server/lib/response";
import { db } from "@server/db"; import { db } from "@server/db";
import { passwordResetTokens, users } from "@server/db"; import { passwordResetTokens, users } from "@server/db";
import { eq, and } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { alphabet, generateRandomString, sha256 } from "oslo/crypto"; import { alphabet, generateRandomString, sha256 } from "oslo/crypto";
import { createDate } from "oslo"; import { createDate } from "oslo";
import logger from "@server/logger"; import logger from "@server/logger";
@@ -49,12 +49,7 @@ export async function requestPasswordReset(
const existingUser = await db const existingUser = await db
.select() .select()
.from(users) .from(users)
.where( .where(eq(users.email, email));
and(
eq(users.email, email),
eq(users.type, UserType.Internal)
)
);
if (!existingUser || !existingUser.length) { if (!existingUser || !existingUser.length) {
await randomDelay(2000); await randomDelay(2000);
+1
View File
@@ -87,6 +87,7 @@ authenticated.get("/org/checkId", org.checkId);
authenticated.put("/org", getUserOrgs, org.createOrg); authenticated.put("/org", getUserOrgs, org.createOrg);
authenticated.get("/orgs", verifyUserIsServerAdmin, org.listOrgs); authenticated.get("/orgs", verifyUserIsServerAdmin, org.listOrgs);
authenticated.get("/admin/orgs", verifyUserIsServerAdmin, org.adminListOrgs);
authenticated.get("/user/:userId/orgs", verifyIsLoggedInUser, org.listUserOrgs); authenticated.get("/user/:userId/orgs", verifyIsLoggedInUser, org.listUserOrgs);
authenticated.get( authenticated.get(
+241
View File
@@ -0,0 +1,241 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, users } from "@server/db";
import { orgs, resources, sites, userOrgs } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { and, asc, desc, eq, like, or, sql, type SQL } from "drizzle-orm";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema";
import type { PaginatedResponse } from "@server/types/Pagination";
const adminListOrgsSchema = z.strictObject({
pageSize: z.coerce
.number<string>()
.int()
.positive()
.optional()
.catch(20)
.default(20)
.openapi({
type: "integer",
default: 20,
description: "Number of items per page"
}),
page: z.coerce
.number<string>()
.int()
.positive()
.optional()
.catch(1)
.default(1)
.openapi({
type: "integer",
default: 1,
description: "Page number to retrieve"
}),
query: z.string().optional(),
sort_by: z
.enum(["name", "createdAt"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["name", "createdAt"],
description: "Field to sort by"
}),
order: z
.enum(["asc", "desc"])
.optional()
.default("asc")
.catch("asc")
.openapi({
type: "string",
enum: ["asc", "desc"],
default: "asc",
description: "Sort order"
})
});
export type AdminOrgRow = {
orgId: string;
name: string;
subnet: string | null;
utilitySubnet: string | null;
createdAt: string | null;
userCount: number;
siteCount: number;
resourceCount: number;
owner: {
userId: string;
username: string;
} | null;
};
export type AdminListOrgsResponse = PaginatedResponse<{
orgs: AdminOrgRow[];
}>;
const AdminListOrgsResponseDataSchema = z.object({
orgs: z.array(
z.object({
orgId: z.string(),
name: z.string(),
subnet: z.string().nullable(),
createdAt: z.string().nullable(),
userCount: z.number(),
siteCount: z.number(),
resourceCount: z.number()
})
),
pagination: z.object({
total: z.number(),
page: z.number(),
pageSize: z.number()
})
});
registry.registerPath({
method: "get",
path: "/admin/orgs",
description:
"List all organizations in the system with usage counts (server admin).",
tags: [OpenAPITags.Org],
request: {
query: adminListOrgsSchema
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: createApiResponseSchema(
AdminListOrgsResponseDataSchema
)
}
}
}
}
});
export async function adminListOrgs(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = adminListOrgsSchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error)
)
);
}
const { pageSize, page, query, sort_by, order } = parsedQuery.data;
let conditions: (SQL<unknown> | undefined)[] = [];
if (query) {
const q = "%" + query.toLowerCase() + "%";
conditions.push(
or(
like(sql`LOWER(${orgs.name})`, q),
like(sql`LOWER(${orgs.orgId})`, q),
like(sql`LOWER(${orgs.subnet})`, q)
)
);
}
const sortColumns = {
name: orgs.name,
createdAt: orgs.createdAt
} as const;
const orderBy = sort_by
? order === "asc"
? asc(sortColumns[sort_by])
: desc(sortColumns[sort_by])
: asc(orgs.name);
// Drizzle renders bare column references in the select list without their
// table prefix, which would make a correlated subquery compare a column to
// itself, so the outer `orgs` side is qualified explicitly.
const orgIdRef = sql`${sql.identifier("orgs")}.${sql.identifier("orgId")}`;
const [countRows, rows] = await Promise.all([
db
.select({ count: sql<number>`count(*)` })
.from(orgs)
.where(and(...conditions)),
db
.selectDistinct({
orgId: orgs.orgId,
name: orgs.name,
subnet: orgs.subnet,
utilitySubnet: orgs.utilitySubnet,
createdAt: orgs.createdAt,
userCount: sql<number>`(
SELECT COUNT(*)
FROM ${userOrgs}
WHERE ${userOrgs.orgId} = ${orgIdRef}
)`.as("userCount"),
siteCount: sql<number>`(
SELECT COUNT(*)
FROM ${sites}
WHERE ${sites.orgId} = ${orgIdRef}
)`.as("siteCount"),
resourceCount: sql<number>`(
SELECT COUNT(*)
FROM ${resources}
WHERE ${resources.orgId} = ${orgIdRef}
)`.as("resourceCount"),
owner: {
userId: users.userId,
username: users.username
}
})
.from(orgs)
.where(and(...conditions, eq(userOrgs.isOwner, true)))
.leftJoin(userOrgs, eq(userOrgs.orgId, orgs.orgId))
.leftJoin(users, eq(userOrgs.userId, users.userId))
.limit(pageSize)
.offset(pageSize * (page - 1))
.orderBy(orderBy)
]);
const totalCount = Number(countRows[0]?.count ?? 0);
return response<AdminListOrgsResponse>(res, {
data: {
orgs: rows.map((row) => ({
...row,
userCount: Number(row.userCount ?? 0),
siteCount: Number(row.siteCount ?? 0),
resourceCount: Number(row.resourceCount ?? 0)
})),
pagination: {
total: totalCount,
page,
pageSize
}
},
success: true,
error: false,
message: "Organizations retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"An error occurred..."
)
);
}
}
+1
View File
@@ -9,3 +9,4 @@ export * from "./listOrgs";
export * from "./pickOrgDefaults"; export * from "./pickOrgDefaults";
export * from "./checkOrgUserAccess"; export * from "./checkOrgUserAccess";
export * from "./resetOrgBandwidth"; export * from "./resetOrgBandwidth";
export * from "./adminListOrgs";
+2 -2
View File
@@ -454,14 +454,14 @@ export default async function migration() {
throw new Error(fromZodError(parsedConfig.error).toString()); throw new Error(fromZodError(parsedConfig.error).toString());
} }
traefikConfig.experimental.plugins.badger.version = "v1.7.0"; traefikConfig.experimental.plugins.badger.version = "v1.6.1";
const updatedTraefikYaml = yaml.dump(traefikConfig); const updatedTraefikYaml = yaml.dump(traefikConfig);
fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8"); fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8");
console.log( console.log(
"Updated the version of Badger in your Traefik configuration to v1.7.0" "Updated the version of Badger in your Traefik configuration to v1.6.1"
); );
} catch (e) { } catch (e) {
console.log( console.log(
+2 -2
View File
@@ -459,14 +459,14 @@ export default async function migration() {
throw new Error(fromZodError(parsedConfig.error).toString()); throw new Error(fromZodError(parsedConfig.error).toString());
} }
traefikConfig.experimental.plugins.badger.version = "v1.7.0"; traefikConfig.experimental.plugins.badger.version = "v1.6.1";
const updatedTraefikYaml = yaml.dump(traefikConfig); const updatedTraefikYaml = yaml.dump(traefikConfig);
fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8"); fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8");
console.log( console.log(
"Updated the version of Badger in your Traefik configuration to v1.7.0" "Updated the version of Badger in your Traefik configuration to v1.6.1"
); );
} catch (e) { } catch (e) {
console.log( console.log(
+3 -5
View File
@@ -276,9 +276,7 @@ export default function AiSessionLogsPage() {
cell: ({ row }) => { cell: ({ row }) => {
return ( return (
<div className="whitespace-nowrap"> <div className="whitespace-nowrap">
{new Date( {new Date(row.original.createdAt).toLocaleString()}
row.original.createdAt * 1000
).toLocaleString()}
</div> </div>
); );
} }
@@ -717,8 +715,8 @@ function generateSampleAiSessionLogs(): QueryAiSessionLogResponse["log"] {
null null
]; ];
const now = Math.floor(Date.now() / 1000); const now = Date.now();
const sevenDaysAgoMs = now - 7 * 24 * 60 * 60; const sevenDaysAgoMs = now - 7 * 24 * 60 * 60 * 1000;
return Array.from({ length: 10 }, (_, i) => { return Array.from({ length: 10 }, (_, i) => {
const provider = const provider =
@@ -50,6 +50,8 @@ import {
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { import {
createBrowserGatewayTargetFormSchema, createBrowserGatewayTargetFormSchema,
@@ -57,6 +59,7 @@ import {
selectedSiteSchema, selectedSiteSchema,
type SshSettingsFormValues type SshSettingsFormValues
} from "@app/lib/browserGatewayTargetFormSchema"; } from "@app/lib/browserGatewayTargetFormSchema";
import { DockerManager, DockerState } from "@app/lib/docker";
import { orgQueries } from "@app/lib/queries"; import { orgQueries } from "@app/lib/queries";
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils"; import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
@@ -325,20 +328,19 @@ export default function Page() {
const rawResourcesAllowed = const rawResourcesAllowed =
env.flags.allowRawResources && env.flags.allowRawResources &&
(build !== "saas" || remoteExitNodes.length > 0); (build !== "saas" || remoteExitNodes.length > 0);
const enterpriseModesAllowed =
!env.flags.disableEnterpriseFeatures;
const availableTypes = useMemo((): NewResourceType[] => { const availableTypes = useMemo((): NewResourceType[] => {
const base: NewResourceType[] = [ const base: NewResourceType[] = ["http", "inference"];
"http", if (enterpriseModesAllowed) {
"inference", base.push("ssh", "rdp", "vnc");
"ssh", }
"rdp",
"vnc"
];
if (rawResourcesAllowed) { if (rawResourcesAllowed) {
base.push("tcp", "udp"); base.push("tcp", "udp");
} }
return base; return base;
}, [rawResourcesAllowed]); }, [enterpriseModesAllowed, rawResourcesAllowed]);
useEffect(() => { useEffect(() => {
if (!availableTypes.includes(resourceType)) { if (!availableTypes.includes(resourceType)) {
+59
View File
@@ -0,0 +1,59 @@
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import OrgsTable from "@app/components/OrgsTable";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import type { AdminListOrgsResponse } from "@server/routers/org";
import type { AxiosResponse } from "axios";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
export const metadata: Metadata = {
title: "Organizations"
};
export const dynamic = "force-dynamic";
type OrganizationsPageProps = {
searchParams: Promise<Record<string, string>>;
};
export default async function OrganizationsPage(props: OrganizationsPageProps) {
const searchParams = new URLSearchParams(await props.searchParams);
let orgs: AdminListOrgsResponse["orgs"] = [];
let pagination: AdminListOrgsResponse["pagination"] = {
total: 0,
page: 1,
pageSize: 20
};
try {
const res = await internal.get<AxiosResponse<AdminListOrgsResponse>>(
`/admin/orgs?${searchParams.toString()}`,
await authCookieHeader()
);
const responseData = res.data.data;
orgs = responseData.orgs;
pagination = responseData.pagination;
} catch (e) {}
const t = await getTranslations();
return (
<>
<SettingsSectionTitle
title={t("orgsManage")}
description={t("orgsDescription")}
/>
<OrgsTable
orgs={orgs}
rowCount={pagination.total}
pagination={{
pageIndex: pagination.page - 1,
pageSize: pagination.pageSize
}}
/>
</>
);
}
+7 -1
View File
@@ -6,6 +6,7 @@ import {
Bot, Bot,
Boxes, Boxes,
Building2, Building2,
Building2Icon,
Cable, Cable,
ChartLine, ChartLine,
Coins, Coins,
@@ -377,6 +378,11 @@ export const adminNavSections = (env?: Env): SidebarNavSection[] => [
href: "/admin/api-keys", href: "/admin/api-keys",
icon: <KeyRound className="size-4 flex-none" /> icon: <KeyRound className="size-4 flex-none" />
}, },
{
title: "sidebarOrgs",
href: "/admin/organizations",
icon: <Building2Icon className="size-4 flex-none" />
},
...(build === "oss" || ...(build === "oss" ||
env?.app.identityProviderMode === "global" || env?.app.identityProviderMode === "global" ||
env?.app.identityProviderMode === undefined env?.app.identityProviderMode === undefined
@@ -388,7 +394,7 @@ export const adminNavSections = (env?: Env): SidebarNavSection[] => [
} }
] ]
: []), : []),
...(build == "enterprise" ...(build === "enterprise"
? [ ? [
{ {
title: "sidebarLicense", title: "sidebarLicense",
+305
View File
@@ -0,0 +1,305 @@
"use client";
import { Button } from "@app/components/ui/button";
import {
ControlledDataTable,
type ExtendedColumnDef
} from "@app/components/ui/controlled-data-table";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { toast } from "@app/hooks/useToast";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import type { AdminOrgRow, DeleteOrgResponse } from "@server/routers/org";
import { type PaginationState } from "@tanstack/react-table";
import {
ArrowDown01Icon,
ArrowUp10Icon,
ArrowUpRight,
ChevronsUpDownIcon
} from "lucide-react";
import moment from "moment";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMemo, useState, useTransition } from "react";
import { useDebouncedCallback } from "use-debounce";
import ConfirmDeleteDialog from "./ConfirmDeleteDialog";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import type { AxiosResponse } from "axios";
import api from "gpt-tokenizer";
import { useEnvContext } from "@app/hooks/useEnvContext";
type OrgTableProps = {
orgs: AdminOrgRow[];
pagination: PaginationState;
rowCount: number;
};
export default function OrgsTable({
orgs,
pagination,
rowCount
}: OrgTableProps) {
const router = useRouter();
const t = useTranslations();
const {
navigate: filter,
isNavigating: isFiltering,
searchParams
} = useNavigationContext();
const [isRefreshing, startTransition] = useTransition();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedOrg, setSelectedOrg] = useState<AdminOrgRow | null>();
const api = createApiClient(useEnvContext());
function refreshData() {
startTransition(async () => {
try {
router.refresh();
} catch (error) {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
}
});
}
function toggleSort(column: string) {
const newSearch = getNextSortOrder(column, searchParams);
filter({
searchParams: newSearch
});
}
function sortableHeader(column: string, label: string) {
const sortOrder = getSortDirection(column, searchParams);
const Icon =
sortOrder === "asc"
? ArrowDown01Icon
: sortOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return (
<Button
variant="ghost"
className="p-3"
onClick={() => toggleSort(column)}
>
{label}
<Icon className="ml-2 h-4 w-4" />
</Button>
);
}
const columns = useMemo<ExtendedColumnDef<AdminOrgRow>[]>(() => {
return [
{
accessorKey: "name",
friendlyName: t("name"),
enableHiding: false,
header: () => sortableHeader("name", t("name"))
},
{
accessorKey: "createdAt",
friendlyName: t("createdAt"),
header: () => sortableHeader("createdAt", t("createdAt")),
cell: ({ row }) => {
const createdAt = row.original.createdAt;
return (
<span>
{createdAt ? moment(createdAt).format("lll") : "-"}
</span>
);
}
},
{
accessorKey: "owner",
friendlyName: t("accessRoleOwner"),
header: () => (
<span className="p-3">{t("accessRoleOwner")}</span>
),
cell: ({ row }) => {
const owner = row.original.owner;
return owner ? (
<Button
className="tabular-nums"
asChild
variant="outline"
size="sm"
>
<Link href={`/admin/users/${owner.userId}`}>
{owner.username}
<ArrowUpRight className="ml-2 h-3 w-3" />
</Link>
</Button>
) : (
<code>-</code>
);
}
},
{
accessorKey: "orgId",
friendlyName: t("orgId"),
header: () => <span className="p-3">{t("orgId")}</span>
},
{
accessorKey: "subnet",
friendlyName: t("subnet"),
header: () => <span className="p-3">{t("subnet")}</span>,
cell: ({ row }) => <span>{row.original.subnet || "-"}</span>
},
{
accessorKey: "utilitySubnet",
friendlyName: t("utilitySubnet"),
header: () => <span className="p-3">{t("utilitySubnet")}</span>,
cell: ({ row }) => (
<span>{row.original.utilitySubnet || "-"}</span>
)
},
{
accessorKey: "userCount",
friendlyName: t("users"),
header: () => <span className="p-3">{t("users")}</span>,
cell: ({ row }) => (
<span className="tabular-nums">
{row.original.userCount}
</span>
)
},
{
accessorKey: "siteCount",
friendlyName: t("sites"),
header: () => <span className="p-3">{t("sites")}</span>,
cell: ({ row }) => (
<span className="tabular-nums">
{row.original.siteCount}
</span>
)
},
{
accessorKey: "resourceCount",
friendlyName: t("resources"),
header: () => <span className="p-3">{t("resources")}</span>,
cell: ({ row }) => (
<span className="tabular-nums">
{row.original.resourceCount}
</span>
)
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const orgRow = row.original;
return (
<div className="flex items-center gap-2 justify-end">
<Button
onClick={() => {
setSelectedOrg(orgRow);
setIsDeleteModalOpen(true);
}}
variant="outline"
className="text-red-400 focus:text-destructive "
>
{t("delete")}
</Button>
</div>
);
}
}
];
}, [t, searchParams]);
const handlePaginationChange = (newPage: PaginationState) => {
searchParams.set("page", (newPage.pageIndex + 1).toString());
searchParams.set("pageSize", newPage.pageSize.toString());
filter({
searchParams
});
};
const handleSearchChange = useDebouncedCallback((query: string) => {
searchParams.set("query", query);
searchParams.delete("page");
filter({
searchParams
});
}, 300);
async function deleteOrg(orgId: string) {
try {
// TODO
// const res = await api.delete<AxiosResponse<DeleteOrgResponse>>(
// `/org/${orgId}`
// );
toast({
title: t("orgDeleted"),
description: t("orgDeletedMessage")
});
} catch (err) {
console.error(err);
toast({
variant: "destructive",
title: t("orgErrorDelete"),
description: formatAxiosError(err, t("orgErrorDeleteMessage"))
});
} finally {
router.refresh();
}
}
return (
<>
{selectedOrg && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedOrg(null);
}}
dialog={
<div className="space-y-2">
<p>{t("orgQuestionRemove")}</p>
<p>{t("orgMessageRemove")}</p>
</div>
}
buttonText={t("orgDeleteConfirm")}
onConfirm={async () => {
startTransition(() => deleteOrg(selectedOrg.orgId));
}}
string={selectedOrg.name}
title={t("orgDelete")}
/>
)}
<ControlledDataTable
columns={columns}
rows={orgs}
tableId="admin-orgs-table"
searchPlaceholder={t("orgSearch")}
pagination={pagination}
onPaginationChange={handlePaginationChange}
searchQuery={searchParams.get("query")?.toString()}
onSearch={handleSearchChange}
onRefresh={refreshData}
isRefreshing={isRefreshing || isFiltering}
rowCount={rowCount}
columnVisibility={{
subnet: false,
utilitySubnet: false,
orgId: false
}}
enableColumnVisibility
stickyLeftColumn="name"
stickyRightColumn="actions"
/>
</>
);
}
+1 -3
View File
@@ -21,14 +21,12 @@ import { Switch } from "@app/components/ui/switch";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels"; import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { orgQueries } from "@app/lib/queries"; import { orgQueries } from "@app/lib/queries";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { build } from "@server/build";
import { UpdateResourceResponse } from "@server/routers/resource";
import type { GetBatchedCertificateResponse } from "@server/routers/certificates/types"; import type { GetBatchedCertificateResponse } from "@server/routers/certificates/types";
import { UpdateResourceResponse } from "@server/routers/resource";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import type { PaginationState } from "@tanstack/react-table"; import type { PaginationState } from "@tanstack/react-table";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
-1
View File
@@ -52,7 +52,6 @@ import {
} from "./ui/controlled-data-table"; } from "./ui/controlled-data-table";
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels"; import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
import { durationToMs } from "@app/lib/durationToMs";
import { orgQueries, productUpdatesQueries } from "@app/lib/queries"; import { orgQueries, productUpdatesQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import semver from "semver"; import semver from "semver";
-74
View File
@@ -1,74 +0,0 @@
import { assertEquals } from "@test/assert";
import { detectLocale } from "./detectLocale";
function runTests() {
assertEquals(
detectLocale("zh-TW,zh;q=0.9,en-US;q=0.8,en;q=0.7,ja;q=0.6"),
"zh-TW",
"An exact regional match should take precedence over a language fallback"
);
assertEquals(
detectLocale("ZH-tw"),
"zh-TW",
"Locale matching should be case-insensitive"
);
assertEquals(
detectLocale(" zh-TW ; q=1 , zh-CN;q=0.8 "),
"zh-TW",
"Whitespace and quality parameters should not prevent an exact match"
);
assertEquals(
detectLocale("zh-CN,zh-TW;q=0.9"),
"zh-CN",
"Simplified Chinese should still match exactly"
);
assertEquals(
detectLocale("zh"),
"zh-CN",
"A generic Chinese preference should retain the existing fallback"
);
assertEquals(
detectLocale("en-GB,en;q=0.9"),
"en-US",
"An unsupported region should fall back to a supported locale for the language"
);
assertEquals(
detectLocale("ja-JP,zh-TW;q=0.9"),
"zh-TW",
"The next preference should be used when a language is unsupported"
);
assertEquals(
detectLocale("zh-CN;q=0.5,zh-TW;q=0.9"),
"zh-TW",
"Preferences should be evaluated by quality"
);
assertEquals(
detectLocale("zh-TW;q=0,fr-FR;q=0.8"),
"fr-FR",
"Locales with zero quality should be excluded"
);
assertEquals(
detectLocale("*,de-DE;q=0.8"),
"de-DE",
"A wildcard should not obscure a supported preference"
);
assertEquals(
detectLocale("ja-JP"),
undefined,
"An unsupported language should not match"
);
assertEquals(
detectLocale(""),
undefined,
"An empty Accept-Language header should not match"
);
console.log("All locale detection tests passed!");
}
try {
runTests();
} catch (error) {
console.error("Locale detection test failed:", error);
process.exit(1);
}
-48
View File
@@ -1,48 +0,0 @@
import { Locale, locales } from "./config";
export function detectLocale(acceptLanguage: string): Locale | undefined {
const browserLocales = acceptLanguage
.split(",")
.map((entry, index) => {
const [locale, ...parameters] = entry.trim().split(";");
const qualityParameter = parameters.find((parameter) =>
parameter.trim().toLowerCase().startsWith("q=")
);
const quality = qualityParameter
? Number(qualityParameter.trim().slice(2))
: 1;
return {
locale: locale.trim().toLowerCase(),
quality,
index
};
})
.filter(
({ locale, quality }) =>
locale && locale !== "*" && quality > 0 && quality <= 1
)
.sort(
(left, right) =>
right.quality - left.quality || left.index - right.index
);
for (const { locale: browserLocale } of browserLocales) {
const exactMatch = locales.find(
(locale) => locale.toLowerCase() === browserLocale
);
if (exactMatch) {
return exactMatch;
}
const browserLanguage = browserLocale.split("-")[0];
const languageMatch = locales.find(
(locale) => locale.split("-")[0].toLowerCase() === browserLanguage
);
if (languageMatch) {
return languageMatch;
}
}
return undefined;
}
+6 -2
View File
@@ -2,7 +2,6 @@
import { cookies, headers } from "next/headers"; import { cookies, headers } from "next/headers";
import { Locale, defaultLocale, locales } from "@/i18n/config"; import { Locale, defaultLocale, locales } from "@/i18n/config";
import { detectLocale } from "@/i18n/detectLocale";
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";
@@ -48,7 +47,12 @@ export async function getUserLocale(): Promise<Locale> {
const acceptLang = headerList.get("accept-language"); const acceptLang = headerList.get("accept-language");
if (acceptLang) { if (acceptLang) {
const matched = detectLocale(acceptLang); const browserLang = acceptLang.split(",")[0];
const matched = locales.find((locale) =>
browserLang
.toLowerCase()
.startsWith(locale.split("-")[0].toLowerCase())
);
if (matched) { if (matched) {
return matched; return matched;
} }