Compare commits

..

6 Commits

Author SHA1 Message Date
Fred KISSIE 9853122a51 delete org 2026-09-02 00:25:49 +02:00
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 2192 additions and 3264 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
-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

+9 -31
View File
@@ -262,9 +262,7 @@ 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(), labelId: serial("labelId").primaryKey(),
name: varchar("name").notNull(), name: varchar("name").notNull(),
color: varchar("color").notNull(), color: varchar("color").notNull(),
@@ -273,9 +271,7 @@ export const labels = pgTable(
onDelete: "cascade" 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,9 +693,7 @@ 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(), sessionId: varchar("id").primaryKey(),
userId: varchar("userId") userId: varchar("userId")
.notNull() .notNull()
@@ -707,9 +701,7 @@ export const sessions = pgTable(
expiresAt: bigint("expiresAt", { mode: "number" }).notNull(), expiresAt: bigint("expiresAt", { mode: "number" }).notNull(),
issuedAt: bigint("issuedAt", { mode: "number" }), issuedAt: bigint("issuedAt", { mode: "number" }),
deviceAuthUsed: boolean("deviceAuthUsed").notNull().default(false) 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,9 +711,7 @@ 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") userId: varchar("userId")
.notNull() .notNull()
.references(() => users.userId, { onDelete: "cascade" }), .references(() => users.userId, { onDelete: "cascade" }),
@@ -733,12 +723,7 @@ export const userOrgs = pgTable(
isOwner: boolean("isOwner").notNull().default(false), isOwner: boolean("isOwner").notNull().default(false),
autoProvisioned: boolean("autoProvisioned").default(false), autoProvisioned: boolean("autoProvisioned").default(false),
pamUsername: varchar("pamUsername") // cleaned username for ssh and such 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,9 +751,7 @@ 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(), roleId: serial("roleId").primaryKey(),
orgId: varchar("orgId") orgId: varchar("orgId")
.references(() => orgs.orgId, { .references(() => orgs.orgId, {
@@ -783,9 +766,7 @@ export const roles = pgTable(
sshSudoCommands: text("sshSudoCommands").default("[]"), sshSudoCommands: text("sshSudoCommands").default("[]"),
sshCreateHomeDir: boolean("sshCreateHomeDir").default(true), sshCreateHomeDir: boolean("sshCreateHomeDir").default(true),
sshUnixGroups: text("sshUnixGroups").default("[]") 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", {
+28 -97
View File
@@ -99,9 +99,7 @@ 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 }), siteId: integer("siteId").primaryKey({ autoIncrement: true }),
orgId: text("orgId") orgId: text("orgId")
.references(() => orgs.orgId, { .references(() => orgs.orgId, {
@@ -143,18 +141,10 @@ export const sites = sqliteTable(
}) })
.notNull() .notNull()
.default(false), .default(false),
status: text("status") status: text("status").$type<"pending" | "approved">().default("approved")
.$type<"pending" | "approved">() });
.default("approved")
},
(table) => [
index("idx_sites_orgId").on(table.orgId)
]
);
export const resources = sqliteTable( export const resources = sqliteTable("resources", {
"resources",
{
resourceId: integer("resourceId").primaryKey({ autoIncrement: true }), resourceId: integer("resourceId").primaryKey({ autoIncrement: true }),
resourcePolicyId: integer("resourcePolicyId").references( resourcePolicyId: integer("resourcePolicyId").references(
() => resourcePolicies.resourcePolicyId, () => resourcePolicies.resourcePolicyId,
@@ -232,14 +222,8 @@ export const resources = sqliteTable(
.$type<"site" | "remote" | "native">() .$type<"site" | "remote" | "native">()
.default("site"), .default("site"),
authDaemonPort: integer("authDaemonPort").default(22123), authDaemonPort: integer("authDaemonPort").default(22123),
status: text("status") status: text("status").$type<"pending" | "approved">().default("approved")
.$type<"pending" | "approved">() });
.default("approved")
},
(table) => [
index("idx_resources_orgId").on(table.orgId)
]
);
export const resourceAiProviders = sqliteTable( export const resourceAiProviders = sqliteTable(
"resourceAiProviders", "resourceAiProviders",
@@ -276,9 +260,7 @@ 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 }), labelId: integer("labelId").primaryKey({ autoIncrement: true }),
name: text("name").notNull(), name: text("name").notNull(),
color: text("color").notNull(), color: text("color").notNull(),
@@ -287,11 +269,7 @@ export const labels = sqliteTable(
onDelete: "cascade" 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,18 +366,14 @@ 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 }), targetId: integer("targetId").primaryKey({ autoIncrement: true }),
resourceId: integer("resourceId").references( resourceId: integer("resourceId").references(() => resources.resourceId, {
() => resources.resourceId, onDelete: "cascade"
{ onDelete: "cascade" } }),
), providerId: integer("providerId").references(() => aiProviders.providerId, {
providerId: integer("providerId").references( onDelete: "cascade"
() => aiProviders.providerId, }),
{ onDelete: "cascade" }
),
siteId: integer("siteId") siteId: integer("siteId")
.references(() => sites.siteId, { .references(() => sites.siteId, {
onDelete: "cascade" onDelete: "cascade"
@@ -420,12 +394,7 @@ export const targets = sqliteTable(
.notNull() .notNull()
.default("http"), .default("http"),
authToken: text("authToken") 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,9 +663,7 @@ 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(), newtId: text("id").primaryKey(),
secretHash: text("secretHash").notNull(), secretHash: text("secretHash").notNull(),
dateCreated: text("dateCreated").notNull(), dateCreated: text("dateCreated").notNull(),
@@ -704,15 +671,9 @@ export const newts = sqliteTable(
siteId: integer("siteId").references(() => sites.siteId, { siteId: integer("siteId").references(() => sites.siteId, {
onDelete: "cascade" onDelete: "cascade"
}) })
}, });
(table) => [
index("idx_newts_siteId").on(table.siteId)
]
);
export const clients = sqliteTable( export const clients = sqliteTable("clients", {
"clients",
{
clientId: integer("clientId").primaryKey({ autoIncrement: true }), clientId: integer("clientId").primaryKey({ autoIncrement: true }),
orgId: text("orgId") orgId: text("orgId")
.references(() => orgs.orgId, { .references(() => orgs.orgId, {
@@ -745,12 +706,7 @@ export const clients = sqliteTable(
approvalState: text("approvalState").$type< approvalState: text("approvalState").$type<
"pending" | "approved" | "denied" "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,9 +734,7 @@ export const clientSiteResourcesAssociationsCache = sqliteTable(
} }
); );
export const olms = sqliteTable( export const olms = sqliteTable("olms", {
"olms",
{
olmId: text("id").primaryKey(), olmId: text("id").primaryKey(),
secretHash: text("secretHash").notNull(), secretHash: text("secretHash").notNull(),
dateCreated: text("dateCreated").notNull(), dateCreated: text("dateCreated").notNull(),
@@ -796,11 +750,7 @@ export const olms = sqliteTable(
onDelete: "cascade" 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,9 +912,7 @@ 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(), sessionId: text("id").primaryKey(),
userId: text("userId") userId: text("userId")
.notNull() .notNull()
@@ -974,11 +922,7 @@ export const sessions = sqliteTable(
deviceAuthUsed: integer("deviceAuthUsed", { mode: "boolean" }) deviceAuthUsed: integer("deviceAuthUsed", { mode: "boolean" })
.notNull() .notNull()
.default(false) .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,9 +940,7 @@ 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") userId: text("userId")
.notNull() .notNull()
.references(() => users.userId, { onDelete: "cascade" }), .references(() => users.userId, { onDelete: "cascade" }),
@@ -1012,12 +954,7 @@ export const userOrgs = sqliteTable(
mode: "boolean" mode: "boolean"
}).default(false), }).default(false),
pamUsername: text("pamUsername") // cleaned username for ssh and such 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,9 +982,7 @@ 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 }), roleId: integer("roleId").primaryKey({ autoIncrement: true }),
orgId: text("orgId") orgId: text("orgId")
.references(() => orgs.orgId, { .references(() => orgs.orgId, {
@@ -1066,11 +1001,7 @@ export const roles = sqliteTable(
true 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;
} }
+11 -6
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,12 +83,20 @@ 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) {
const [namespaceDomain] = await db
.select()
.from(domainNamespaces)
.where(eq(domainNamespaces.domainId, domainId))
.limit(1);
if (namespaceDomain) {
return { return {
success: false, success: false,
error: "Wildcard subdomains are not supported for provided or free domains. Use a specific subdomain instead." error: "Wildcard subdomains are not supported for provided or free domains. Use a specific subdomain instead."
}; };
} }
}
if ( if (
isWildcard && isWildcard &&
+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(
+13 -9
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;
} }
+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);
+6
View File
@@ -87,6 +87,12 @@ 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.delete(
"/admin/org/:orgId",
verifyUserIsServerAdmin,
org.adminDeleteOrg
);
authenticated.get("/user/:userId/orgs", verifyIsLoggedInUser, org.listUserOrgs); authenticated.get("/user/:userId/orgs", verifyIsLoggedInUser, org.listUserOrgs);
authenticated.get( authenticated.get(
+98
View File
@@ -0,0 +1,98 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { deleteOrgById, sendTerminationMessages } from "@server/lib/deleteOrg";
import { db, orgs } from "@server/db";
import { eq } from "drizzle-orm";
const adminDeleteOrgSchema = z.strictObject({
orgId: z.string()
});
export type AdminDeleteOrgResponse = {};
registry.registerPath({
method: "delete",
path: "/admin/org/{orgId}",
description: "Delete any organization in the system (server admin).",
tags: [OpenAPITags.Org],
request: {
params: adminDeleteOrgSchema
},
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 adminDeleteOrg(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = adminDeleteOrgSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId } = parsedParams.data;
const [org] = await db
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId))
.limit(1);
if (!org) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Organization with ID ${orgId} not found`
)
);
}
const result = await deleteOrgById(orgId);
sendTerminationMessages(result);
return response(res, {
data: null,
success: true,
error: false,
message: "Organization deleted successfully",
status: HttpCode.OK
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"An error occurred..."
)
);
}
}
+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..."
)
);
}
}
+2
View File
@@ -9,3 +9,5 @@ export * from "./listOrgs";
export * from "./pickOrgDefaults"; export * from "./pickOrgDefaults";
export * from "./checkOrgUserAccess"; export * from "./checkOrgUserAccess";
export * from "./resetOrgBandwidth"; export * from "./resetOrgBandwidth";
export * from "./adminListOrgs";
export * from "./adminDeleteOrg";
+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",
+300
View File
@@ -0,0 +1,300 @@
"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 } from "@server/routers/org";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient, formatAxiosError } from "@app/lib/api";
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";
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 {
const res = await api.delete(`/admin/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;
} }